From 0bd313717c712d38783fd3ca4b01f6a7ae1098e7 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Wed, 1 Jul 2026 14:13:16 -0400 Subject: [PATCH 001/464] fix(downloads): preserve path whitespace Preserve download-client reported path whitespace for torrent source/content paths while keeping destination validation separate. Add user-provided library destination validation for add, move, and root-folder workflows, allowing filesystem roots for root folders while rejecting parent traversal for concrete destinations. --- .../Features/Library/LibraryAddWorkflow.cs | 16 +- .../Features/Library/LibraryMoveWorkflow.cs | 16 +- .../Audiobooks/Catalog/LibraryAddService.cs | 50 ++- .../Contracts/ILibraryAddService.cs | 4 + .../RootFolders/RootFolderService.cs | 22 +- .../Downloads/Common/DownloadClientGateway.cs | 6 +- .../Common/FileUtils.PathCombining.cs | 7 +- .../Common/FileUtils.UserProvidedPaths.cs | 361 ++++++++++++++++++ .../Paths/RemotePathMappingService.cs | 2 +- .../Common/TorrentClientPathMapper.cs | 60 +-- .../QbittorrentImportPathResolver.cs | 2 +- .../TransmissionImportPathResolver.cs | 4 +- .../LibraryController_AddToLibraryTests.cs | 39 +- .../Library/LibraryController_MoveTests.cs | 25 ++ .../RootFolders/RootFolderServiceTests.cs | 93 +++++ .../Common/DownloadClientGatewayTests.cs | 58 +++ tests/Features/Domain/Utils/FileUtilsTests.cs | 178 +++++++++ .../Common/DownloadClientAdapterTests.cs | 27 ++ .../Common/TorrentClientPathMapperTests.cs | 119 ++++++ tests/Mocks/Api/TransmissionApiMock.cs | 36 ++ 20 files changed, 1053 insertions(+), 72 deletions(-) create mode 100644 listenarr.domain/Common/FileUtils.UserProvidedPaths.cs create mode 100644 tests/Features/Infrastructure/DownloadClients/Common/TorrentClientPathMapperTests.cs diff --git a/listenarr.api/Features/Library/LibraryAddWorkflow.cs b/listenarr.api/Features/Library/LibraryAddWorkflow.cs index dfcd9a7bc..d6622e3b2 100644 --- a/listenarr.api/Features/Library/LibraryAddWorkflow.cs +++ b/listenarr.api/Features/Library/LibraryAddWorkflow.cs @@ -67,6 +67,11 @@ public async Task AddAsync(LibraryController.AddToLibraryRequest HistoryMessage = $"Audiobook '{request.Metadata.Title}' added to library from Add New page" }); + if (result.ValidationFailed) + { + return new BadRequestObjectResult(new { message = result.ValidationMessage ?? result.Message }); + } + if (result.AlreadyExists) { return new ConflictObjectResult(new { message = result.Message, audiobook = result.Audiobook }); @@ -133,7 +138,16 @@ public async Task AddAsync(LibraryController.AddToLibraryRequest if (!string.IsNullOrWhiteSpace(request.DestinationPath)) { - audiobook.BasePath = FileUtils.NormalizeStoredPath(request.DestinationPath); + if (!FileUtils.TryNormalizeUserProvidedDirectoryPathForCurrentOs( + request.DestinationPath, + out var normalizedDestinationPath, + out var validationReason, + rejectParentTraversal: true)) + { + return new BadRequestObjectResult(new { message = $"DestinationPath is not valid for this operating system: {validationReason}" }); + } + + audiobook.BasePath = normalizedDestinationPath; _logger.LogInformation("Using custom destination path for audiobook '{Title}': {BasePath}", audiobook.Title, audiobook.BasePath); } diff --git a/listenarr.api/Features/Library/LibraryMoveWorkflow.cs b/listenarr.api/Features/Library/LibraryMoveWorkflow.cs index ed3fc469e..d974b6e58 100644 --- a/listenarr.api/Features/Library/LibraryMoveWorkflow.cs +++ b/listenarr.api/Features/Library/LibraryMoveWorkflow.cs @@ -55,11 +55,6 @@ public async Task EnqueueAsync(int id, LibraryController.MoveRequ return new BadRequestObjectResult(new { message = "DestinationPath is required" }); } - if (FileUtils.IsPathInvalidForCurrentOs(request.DestinationPath)) - { - return new BadRequestObjectResult(new { message = "DestinationPath is not valid for this operating system" }); - } - try { using var scope = _scopeFactory.CreateScope(); @@ -67,8 +62,15 @@ public async Task EnqueueAsync(int id, LibraryController.MoveRequ var settings = await configService.GetApplicationSettingsAsync(); var destinationIsRooted = Path.IsPathRooted(request.DestinationPath!); - var final = FileUtils.CombineWithOptionalBase(settings.OutputPath, request.DestinationPath!); - final = FileUtils.NormalizeStoredPath(final); + var destinationCandidate = FileUtils.CombineWithOptionalBase(settings.OutputPath, request.DestinationPath!); + if (!FileUtils.TryNormalizeUserProvidedDirectoryPathForCurrentOs( + destinationCandidate, + out var final, + out var validationReason, + rejectParentTraversal: true)) + { + return new BadRequestObjectResult(new { message = $"DestinationPath is not valid for this operating system: {validationReason}" }); + } if (!destinationIsRooted && !string.IsNullOrWhiteSpace(settings.OutputPath) && !_fileSystem.TryValidateMutationTarget(final, [settings.OutputPath], out final, out var finalReason)) diff --git a/listenarr.application/Audiobooks/Catalog/LibraryAddService.cs b/listenarr.application/Audiobooks/Catalog/LibraryAddService.cs index af3cce394..e8ec83982 100644 --- a/listenarr.application/Audiobooks/Catalog/LibraryAddService.cs +++ b/listenarr.application/Audiobooks/Catalog/LibraryAddService.cs @@ -17,6 +17,7 @@ */ using System.Security.Cryptography; using System.Text; +using Listenarr.Domain.Common; using Microsoft.Extensions.Logging; namespace Listenarr.Application.Audiobooks.Catalog @@ -149,30 +150,38 @@ public async Task AddToLibraryAsync( var settings = await _configurationService.GetApplicationSettingsAsync(); - // Check validity of given path - var baseDirectory = request.DestinationPath; - if (!string.IsNullOrWhiteSpace(baseDirectory)) + var requestedBaseDirectory = request.DestinationPath; + if (!string.IsNullOrWhiteSpace(requestedBaseDirectory)) { - try - { - Path.GetFullPath(baseDirectory); - } - catch (Exception) + if (!FileUtils.TryNormalizeUserProvidedDirectoryPathForCurrentOs( + requestedBaseDirectory, + out var normalizedRequestedBaseDirectory, + out var validationReason, + rejectParentTraversal: true)) { - baseDirectory = string.Empty; + return ValidationFailure($"DestinationPath is not valid for this operating system: {validationReason}"); } - } - - if (string.IsNullOrWhiteSpace(baseDirectory)) - { - var rootFolder = await _rootFolderService.GetDefaultAsync(); - baseDirectory = rootFolder != null ? rootFolder.Path : settings.OutputPath; - audiobook.BasePath = Path.Join(baseDirectory, _fileNamingService.ApplyNamingPattern(settings.FolderNamingPattern, metadata)); + audiobook.BasePath = normalizedRequestedBaseDirectory; } else { - audiobook.BasePath = baseDirectory; + var rootFolder = await _rootFolderService.GetDefaultAsync(); + var baseDirectory = rootFolder != null ? rootFolder.Path : settings.OutputPath; + + // This validates the Listenarr-owned library destination. Do not use it for + // download-client source paths, which must preserve the client's exact path identity. + var generatedBasePath = Path.Join(baseDirectory, _fileNamingService.ApplyNamingPattern(settings.FolderNamingPattern, metadata)); + if (!FileUtils.TryNormalizeUserProvidedDirectoryPathForCurrentOs( + generatedBasePath, + out var normalizedGeneratedBasePath, + out var validationReason, + rejectParentTraversal: true)) + { + return ValidationFailure($"Generated library destination is not valid for this operating system: {validationReason}"); + } + + audiobook.BasePath = normalizedGeneratedBasePath; } cancellationToken.ThrowIfCancellationRequested(); @@ -363,6 +372,13 @@ private async Task AddHistoryEntryAsync( await _historyRepository.AddAsync(historyEntry, cancellationToken); } + private static LibraryAddOperationResult ValidationFailure(string message) => new() + { + ValidationFailed = true, + Message = message, + ValidationMessage = message + }; + private static string? ToStringOrFirst(object? value) { if (value is List list) diff --git a/listenarr.application/Audiobooks/Contracts/ILibraryAddService.cs b/listenarr.application/Audiobooks/Contracts/ILibraryAddService.cs index 890108c21..24ab82c75 100644 --- a/listenarr.application/Audiobooks/Contracts/ILibraryAddService.cs +++ b/listenarr.application/Audiobooks/Contracts/ILibraryAddService.cs @@ -50,8 +50,12 @@ public sealed class LibraryAddOperationResult public bool AlreadyExists { get; set; } + public bool ValidationFailed { get; set; } + public string Message { get; set; } = string.Empty; + public string? ValidationMessage { get; set; } + public Audiobook? Audiobook { get; set; } } } diff --git a/listenarr.application/Audiobooks/RootFolders/RootFolderService.cs b/listenarr.application/Audiobooks/RootFolders/RootFolderService.cs index 30c947621..94a7ce3b7 100644 --- a/listenarr.application/Audiobooks/RootFolders/RootFolderService.cs +++ b/listenarr.application/Audiobooks/RootFolders/RootFolderService.cs @@ -15,6 +15,7 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ +using Listenarr.Domain.Common; using Microsoft.Extensions.Logging; namespace Listenarr.Application.Audiobooks.RootFolders @@ -39,10 +40,9 @@ public RootFolderService(IRootFolderRepository repo, ILogger? public async Task CreateAsync(RootFolder root) { - root.Path = root.Path?.Trim() ?? string.Empty; root.Name = root.Name?.Trim() ?? string.Empty; + root.Path = NormalizeRootFolderPathForStorage(root.Path?.Trim()); - if (string.IsNullOrWhiteSpace(root.Path)) throw new ArgumentException("Path is required"); if (string.IsNullOrWhiteSpace(root.Name)) throw new ArgumentException("Name is required"); var existingByPath = await _repo.GetByPathAsync(root.Path); @@ -85,8 +85,10 @@ public async Task DeleteAsync(int id, int? reassignRootId = null) public async Task UpdateAsync(RootFolder root, bool moveFiles = false, bool deleteEmptySource = true) { if (root == null) throw new ArgumentNullException(nameof(root)); - root.Path = root.Path?.Trim() ?? string.Empty; root.Name = root.Name?.Trim() ?? string.Empty; + root.Path = NormalizeRootFolderPathForStorage(root.Path?.Trim()); + + if (string.IsNullOrWhiteSpace(root.Name)) throw new ArgumentException("Name is required"); var existing = await _repo.GetByIdAsync(root.Id); if (existing == null) throw new KeyNotFoundException("Root folder not found"); @@ -148,5 +150,19 @@ public async Task UpdateAsync(RootFolder root, bool moveFiles = fals return existing; } + + private static string NormalizeRootFolderPathForStorage(string? path) + { + if (!FileUtils.TryNormalizeUserProvidedDirectoryPathForCurrentOs( + path, + out var normalizedPath, + out var validationReason, + allowFileSystemRoot: true)) + { + throw new ArgumentException($"Path is not valid for this operating system: {validationReason}"); + } + + return normalizedPath; + } } } diff --git a/listenarr.application/Downloads/Common/DownloadClientGateway.cs b/listenarr.application/Downloads/Common/DownloadClientGateway.cs index 643c9479f..5ac28f489 100644 --- a/listenarr.application/Downloads/Common/DownloadClientGateway.cs +++ b/listenarr.application/Downloads/Common/DownloadClientGateway.cs @@ -232,12 +232,12 @@ private List GetExternalIds(List downloads) /// private async Task TranslateQueueItemPathsAsync(DownloadClientConfiguration client, QueueItem item) { - if (!string.IsNullOrWhiteSpace(item.RemotePath)) + if (!string.IsNullOrEmpty(item.RemotePath)) { item.LocalPath = await remotePathMappingService.TranslatePathAsync(client, item.RemotePath); } - if (!string.IsNullOrWhiteSpace(item.ContentPath)) + if (!string.IsNullOrEmpty(item.ContentPath)) { item.ContentPath = await remotePathMappingService.TranslatePathAsync(client, item.ContentPath); } @@ -257,7 +257,7 @@ private async Task TranslateQueueItemPathsAsync(DownloadClientConfigu } item.SourceFiles = sourceFiles; } - else if (!string.IsNullOrWhiteSpace(item.ContentPath)) + else if (!string.IsNullOrEmpty(item.ContentPath)) { // Scan ContentPath only after the adapter has supplied a non-empty path. // Active queue snapshots may not be import-ready, so adapters should leave diff --git a/listenarr.domain/Common/FileUtils.PathCombining.cs b/listenarr.domain/Common/FileUtils.PathCombining.cs index 0e710ed02..964f1661e 100644 --- a/listenarr.domain/Common/FileUtils.PathCombining.cs +++ b/listenarr.domain/Common/FileUtils.PathCombining.cs @@ -239,6 +239,11 @@ public static string SafeFileName(string name) return normalized.Length == 0 ? "unknown" : normalized; } + /// + /// Combines a relative candidate path with an optional base path without trimming + /// path-segment whitespace. Callers that must constrain rooted-looking child paths + /// should make those paths relative before calling this helper. + /// public static string CombineWithOptionalBase(string? basePath, string candidatePath) { if (string.IsNullOrEmpty(candidatePath)) @@ -246,7 +251,7 @@ public static string CombineWithOptionalBase(string? basePath, string candidateP return candidatePath; } - if (Path.IsPathRooted(candidatePath) || string.IsNullOrWhiteSpace(basePath)) + if (Path.IsPathRooted(candidatePath) || string.IsNullOrEmpty(basePath)) { return candidatePath; } diff --git a/listenarr.domain/Common/FileUtils.UserProvidedPaths.cs b/listenarr.domain/Common/FileUtils.UserProvidedPaths.cs new file mode 100644 index 000000000..4131c121e --- /dev/null +++ b/listenarr.domain/Common/FileUtils.UserProvidedPaths.cs @@ -0,0 +1,361 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +using System.Text.RegularExpressions; + +namespace Listenarr.Domain.Common +{ + public static partial class FileUtils + { + private static readonly Regex WindowsDriveRootPattern = new("^[A-Za-z]:[\\\\/]", RegexOptions.Compiled); + private static readonly Regex WindowsReservedDeviceNamePattern = new( + "^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + + /// + /// Validates and normalizes a user-provided directory path that Listenarr will store or create. + /// This must not be used for externally reported download-client source paths, where whitespace + /// and other path identity details must be preserved exactly as reported by the client. + /// + public static bool TryNormalizeUserProvidedDirectoryPathForCurrentOs( + string? path, + out string normalizedPath, + out string reason, + bool allowFileSystemRoot = false, + bool rejectParentTraversal = false) => + TryNormalizeUserProvidedDirectoryPathForOs( + path, + OperatingSystem.IsWindows(), + out normalizedPath, + out reason, + allowFileSystemRoot, + rejectParentTraversal); + + public static bool TryNormalizeUserProvidedDirectoryPathForOs( + string? path, + bool isWindows, + out string normalizedPath, + out string reason, + bool allowFileSystemRoot = false, + bool rejectParentTraversal = false) + { + normalizedPath = string.Empty; + reason = string.Empty; + + if (string.IsNullOrWhiteSpace(path)) + { + reason = "Path is required."; + return false; + } + + var candidate = path; + if (candidate.IndexOf('\0') >= 0) + { + reason = "Path contains invalid characters."; + return false; + } + + if (isWindows) + { + return TryNormalizeWindowsUserProvidedDirectoryPath( + candidate, + out normalizedPath, + out reason, + allowFileSystemRoot, + rejectParentTraversal); + } + + return TryNormalizeUnixUserProvidedDirectoryPath( + candidate, + out normalizedPath, + out reason, + allowFileSystemRoot, + rejectParentTraversal); + } + + private static bool TryNormalizeWindowsUserProvidedDirectoryPath( + string path, + out string normalizedPath, + out string reason, + bool allowFileSystemRoot, + bool rejectParentTraversal) + { + normalizedPath = string.Empty; + reason = string.Empty; + + var rootLength = GetWindowsRootLength(path); + if (rootLength <= 0) + { + reason = "Path must be an absolute directory path."; + return false; + } + + var pathWithoutRoot = path[rootLength..]; + if (string.IsNullOrWhiteSpace(pathWithoutRoot.Trim('/', '\\')) && !allowFileSystemRoot) + { + reason = "Path cannot be the filesystem root."; + return false; + } + + if (!ValidateWindowsDirectorySegments(pathWithoutRoot, rejectParentTraversal, out reason)) + { + return false; + } + + try + { + normalizedPath = OperatingSystem.IsWindows() + ? Path.GetFullPath(path) + : NormalizeWindowsDirectoryPathSyntax(path); + + if (IsWindowsRootOnly(normalizedPath) && !allowFileSystemRoot) + { + normalizedPath = string.Empty; + reason = "Path cannot be the filesystem root."; + return false; + } + + return true; + } + catch (Exception exception) when (exception is not (OperationCanceledException or OutOfMemoryException or StackOverflowException)) + { + normalizedPath = string.Empty; + reason = "Path is not valid for this operating system."; + return false; + } + } + + private static bool TryNormalizeUnixUserProvidedDirectoryPath( + string path, + out string normalizedPath, + out string reason, + bool allowFileSystemRoot, + bool rejectParentTraversal) + { + normalizedPath = string.Empty; + reason = string.Empty; + + if (!path.StartsWith("/", StringComparison.Ordinal)) + { + reason = "Path must be an absolute directory path."; + return false; + } + + if (string.IsNullOrWhiteSpace(path.Trim('/')) && !allowFileSystemRoot) + { + reason = "Path cannot be the filesystem root."; + return false; + } + + if (rejectParentTraversal && ContainsParentDirectorySegment(path, '/')) + { + reason = "Path cannot traverse to a parent directory."; + return false; + } + + try + { + normalizedPath = OperatingSystem.IsWindows() + ? NormalizeUnixDirectoryPathSyntax(path) + : Path.GetFullPath(path); + + if (IsUnixRootOnly(normalizedPath) && !allowFileSystemRoot) + { + normalizedPath = string.Empty; + reason = "Path cannot be the filesystem root."; + return false; + } + + return true; + } + catch (Exception exception) when (exception is not (OperationCanceledException or OutOfMemoryException or StackOverflowException)) + { + normalizedPath = string.Empty; + reason = "Path is not valid for this operating system."; + return false; + } + } + + private static int GetWindowsRootLength(string path) + { + if (WindowsDriveRootPattern.IsMatch(path)) + { + return 3; + } + + if (!path.StartsWith(@"\\", StringComparison.Ordinal) && !path.StartsWith("//", StringComparison.Ordinal)) + { + return 0; + } + + var parts = path.Split(new[] { '\\', '/' }, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length < 2) + { + return 0; + } + + var index = 2; + var separatorsSeen = 0; + while (index < path.Length && separatorsSeen < 2) + { + if (path[index] is '\\' or '/') + { + separatorsSeen++; + } + + index++; + } + + return index; + } + + private static bool ValidateWindowsDirectorySegments(string pathWithoutRoot, bool rejectParentTraversal, out string reason) + { + reason = string.Empty; + var segments = pathWithoutRoot.Split(new[] { '\\', '/' }, StringSplitOptions.RemoveEmptyEntries); + foreach (var segment in segments) + { + if (segment == ".." && rejectParentTraversal) + { + reason = "Path cannot traverse to a parent directory."; + return false; + } + + if (segment is "." or "..") + { + continue; + } + + if (segment.Any(IsInvalidWindowsDirectorySegmentCharacter)) + { + reason = "Path contains invalid characters."; + return false; + } + + if (segment.EndsWith(' ') || segment.EndsWith('.')) + { + reason = "Path segments cannot end with a space or period on Windows."; + return false; + } + + var stem = segment.Split('.', 2)[0]; + if (WindowsReservedDeviceNamePattern.IsMatch(stem)) + { + reason = "Path contains a reserved Windows device name."; + return false; + } + } + + return true; + } + + private static bool IsInvalidWindowsDirectorySegmentCharacter(char character) + { + return character < 32 || character is '<' or '>' or ':' or '"' or '|' or '?' or '*'; + } + + private static bool ContainsParentDirectorySegment(string path, params char[] separators) + { + return path.Split(separators, StringSplitOptions.RemoveEmptyEntries) + .Any(segment => segment == ".."); + } + + private static string NormalizeWindowsDirectoryPathSyntax(string path) + { + var normalizedPath = path.Replace('/', '\\'); + var rootLength = GetWindowsRootLength(normalizedPath); + var root = normalizedPath[..rootLength]; + var pathWithoutRoot = normalizedPath[rootLength..]; + var segments = new List(); + + foreach (var segment in pathWithoutRoot.Split('\\', StringSplitOptions.RemoveEmptyEntries)) + { + if (segment == ".") + { + continue; + } + + if (segment == "..") + { + if (segments.Count > 0) + { + segments.RemoveAt(segments.Count - 1); + } + + continue; + } + + segments.Add(segment); + } + + return segments.Count == 0 + ? root.TrimEnd('\\') + : root.TrimEnd('\\') + "\\" + string.Join("\\", segments); + } + + private static bool IsWindowsRootOnly(string path) + { + var pathWithWindowsSeparators = path.Replace('/', '\\'); + var normalizedPath = pathWithWindowsSeparators.TrimEnd('\\'); + if (Regex.IsMatch(normalizedPath, "^[A-Za-z]:$")) + { + return true; + } + + var rootLength = GetWindowsRootLength(pathWithWindowsSeparators); + if (rootLength <= 0) + { + return false; + } + + var root = pathWithWindowsSeparators[..rootLength].TrimEnd('\\'); + return string.Equals(normalizedPath, root, StringComparison.OrdinalIgnoreCase); + } + + private static string NormalizeUnixDirectoryPathSyntax(string path) + { + var segments = new List(); + foreach (var segment in path.Split('/', StringSplitOptions.RemoveEmptyEntries)) + { + if (segment == ".") + { + continue; + } + + if (segment == "..") + { + if (segments.Count > 0) + { + segments.RemoveAt(segments.Count - 1); + } + + continue; + } + + segments.Add(segment); + } + + return segments.Count == 0 ? "/" : "/" + string.Join("/", segments); + } + + private static bool IsUnixRootOnly(string path) + { + return string.Equals(path.TrimEnd('/'), string.Empty, StringComparison.Ordinal) + || string.Equals(path.TrimEnd('/'), "/", StringComparison.Ordinal); + } + } +} diff --git a/listenarr.infrastructure/Configuration/Paths/RemotePathMappingService.cs b/listenarr.infrastructure/Configuration/Paths/RemotePathMappingService.cs index 7e09c2346..416fd0e5e 100644 --- a/listenarr.infrastructure/Configuration/Paths/RemotePathMappingService.cs +++ b/listenarr.infrastructure/Configuration/Paths/RemotePathMappingService.cs @@ -116,7 +116,7 @@ public async Task DeleteAsync(int id) public async Task TranslatePathAsync(DownloadClientConfiguration client, string remotePath) { - if (string.IsNullOrWhiteSpace(remotePath)) + if (string.IsNullOrEmpty(remotePath)) { return remotePath; } diff --git a/listenarr.infrastructure/DownloadClients/Common/TorrentClientPathMapper.cs b/listenarr.infrastructure/DownloadClients/Common/TorrentClientPathMapper.cs index 614406038..3fa0cd9a8 100644 --- a/listenarr.infrastructure/DownloadClients/Common/TorrentClientPathMapper.cs +++ b/listenarr.infrastructure/DownloadClients/Common/TorrentClientPathMapper.cs @@ -27,22 +27,25 @@ public static List BuildQbittorrentSourceFiles( string savePath, List> files) { - if (string.IsNullOrWhiteSpace(savePath) || files == null || files.Count == 0) + if (string.IsNullOrEmpty(savePath) || files == null || files.Count == 0) { return new List(); } + // External client paths are filesystem identifiers, not user text. Do not trim + // whitespace from path segments; only strip separators when intentionally + // converting a rooted-looking child path into a relative child path. return files .Select(file => file.TryGetValue("name", out var nameEl) ? nameEl.GetString() ?? string.Empty : string.Empty) - .Where(name => !string.IsNullOrWhiteSpace(name)) - .Select(name => CombineWithOptionalBase(savePath, name.Replace('/', Path.DirectorySeparatorChar))) + .Where(name => !string.IsNullOrEmpty(name)) + .Select(name => CombineClientReportedPath(savePath, name.Replace('/', Path.DirectorySeparatorChar))) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); } public static List BuildTransmissionSourceFiles(string? downloadDir, JsonElement filesElement) { - if (string.IsNullOrWhiteSpace(downloadDir) || filesElement.ValueKind != JsonValueKind.Array) + if (string.IsNullOrEmpty(downloadDir) || filesElement.ValueKind != JsonValueKind.Array) { return new List(); } @@ -56,12 +59,12 @@ public static List BuildTransmissionSourceFiles(string? downloadDir, Jso } var relativePath = nameProp.GetString(); - if (string.IsNullOrWhiteSpace(relativePath)) + if (string.IsNullOrEmpty(relativePath)) { continue; } - sourceFiles.Add(FileUtils.CombineWithOptionalBase(downloadDir, relativePath)); + sourceFiles.Add(CombineClientReportedPath(downloadDir, relativePath)); } return sourceFiles; @@ -71,14 +74,14 @@ public static string ResolveQbittorrentContentPath( string savePath, List> files) { - if (string.IsNullOrWhiteSpace(savePath) || files == null || files.Count == 0) + if (string.IsNullOrEmpty(savePath) || files == null || files.Count == 0) { return string.Empty; } var fileNames = files .Select(f => f.TryGetValue("name", out var nameEl) ? nameEl.GetString() ?? string.Empty : string.Empty) - .Where(name => !string.IsNullOrWhiteSpace(name)) + .Where(name => !string.IsNullOrEmpty(name)) .ToList(); if (fileNames.Count == 0) @@ -93,8 +96,8 @@ public static string ResolveQbittorrentContentPath( if (fileNames.Count == 1) { return hasNestedPath - ? CombineWithOptionalBase(savePath, firstParts[0]) - : CombineWithOptionalBase(savePath, firstFile); + ? CombineClientReportedPath(savePath, firstParts[0]) + : CombineClientReportedPath(savePath, firstFile); } if (!hasNestedPath) @@ -110,34 +113,39 @@ public static string ResolveQbittorrentContentPath( }); return allShareTopLevel - ? CombineWithOptionalBase(savePath, topLevel) + ? CombineClientReportedPath(savePath, topLevel) : savePath; } - private static string CombineWithOptionalBase(string? basePath, string candidatePath) + private static string CombineClientReportedPath(string? basePath, string candidatePath) { - var normalizedPath = candidatePath.Trim(); - - if (string.IsNullOrEmpty(normalizedPath)) + if (string.IsNullOrEmpty(candidatePath) || string.IsNullOrEmpty(basePath)) { - return normalizedPath; + return candidatePath; } - if (Path.IsPathRooted(normalizedPath) || string.IsNullOrWhiteSpace(basePath)) + var relativePath = candidatePath.TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + if (Path.IsPathRooted(relativePath)) { - return normalizedPath; + var root = Path.GetPathRoot(relativePath) ?? string.Empty; + relativePath = relativePath[root.Length..] + .TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); } - - var relativePath = normalizedPath.TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - if (Path.IsPathRooted(relativePath)) + else if (HasDriveRootedPrefix(relativePath)) { - return relativePath; + relativePath = relativePath[2..] + .TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); } - var normalizedBasePath = basePath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - return string.IsNullOrEmpty(normalizedBasePath) - ? relativePath - : normalizedBasePath + Path.DirectorySeparatorChar + relativePath; + return FileUtils.CombineWithOptionalBase(basePath, relativePath); + } + + private static bool HasDriveRootedPrefix(string path) + { + return path.Length >= 2 + && char.IsLetter(path[0]) + && path[1] == ':' + && (path.Length == 2 || path[2] is '/' or '\\'); } } } diff --git a/listenarr.infrastructure/DownloadClients/Qbittorrent/QbittorrentImportPathResolver.cs b/listenarr.infrastructure/DownloadClients/Qbittorrent/QbittorrentImportPathResolver.cs index e43269734..9b89d635c 100644 --- a/listenarr.infrastructure/DownloadClients/Qbittorrent/QbittorrentImportPathResolver.cs +++ b/listenarr.infrastructure/DownloadClients/Qbittorrent/QbittorrentImportPathResolver.cs @@ -31,7 +31,7 @@ public static List BuildSourceFiles( public static List TranslateSourceFiles(IEnumerable sourceFiles) { return sourceFiles - .Where(path => !string.IsNullOrWhiteSpace(path)) + .Where(path => !string.IsNullOrEmpty(path)) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); } diff --git a/listenarr.infrastructure/DownloadClients/Transmission/TransmissionImportPathResolver.cs b/listenarr.infrastructure/DownloadClients/Transmission/TransmissionImportPathResolver.cs index 25e124cc1..f05a74fe5 100644 --- a/listenarr.infrastructure/DownloadClients/Transmission/TransmissionImportPathResolver.cs +++ b/listenarr.infrastructure/DownloadClients/Transmission/TransmissionImportPathResolver.cs @@ -29,14 +29,14 @@ public static bool IsExistingLocalPath(string? path) public static string? BuildContentPath(string? downloadDir, string? name, string? fallbackPath = null) { - return !string.IsNullOrWhiteSpace(downloadDir) && !string.IsNullOrWhiteSpace(name) + return !string.IsNullOrEmpty(downloadDir) && !string.IsNullOrEmpty(name) ? FileUtils.CombineWithOptionalBase(downloadDir, name) : fallbackPath; } public static List BuildSourceFiles(string? downloadDir, JsonElement filesElement) { - return [.. TorrentClientPathMapper.BuildTransmissionSourceFiles(downloadDir, filesElement).Where(path => !string.IsNullOrWhiteSpace(path))]; + return [.. TorrentClientPathMapper.BuildTransmissionSourceFiles(downloadDir, filesElement).Where(path => !string.IsNullOrEmpty(path))]; } } } diff --git a/tests/Features/Api/Features/Library/LibraryController_AddToLibraryTests.cs b/tests/Features/Api/Features/Library/LibraryController_AddToLibraryTests.cs index 98bdc6dd3..9ee36589c 100644 --- a/tests/Features/Api/Features/Library/LibraryController_AddToLibraryTests.cs +++ b/tests/Features/Api/Features/Library/LibraryController_AddToLibraryTests.cs @@ -230,7 +230,7 @@ public async Task AddToLibrary_WithCustomPath_StoresCustomPathAsBasePath() { var controller = _provider.GetRequiredService(); - var customPath = "/custom/audiobooks/Author/Series/Title"; + var customPath = Path.Join(tempRoot, "custom", "audiobooks", "Author", "Series", "Title"); var request = new LibraryController.AddToLibraryRequest { Metadata = new AudibleBookMetadata @@ -250,19 +250,41 @@ public async Task AddToLibrary_WithCustomPath_StoresCustomPathAsBasePath() var stored = (await _audiobookRepository.GetAllAsync()).First(); Assert.NotNull(stored); - // NormalizeStoredPath calls Path.GetFullPath which is platform-dependent: - // on Windows "/custom/..." becomes "C:\custom\...", on Linux it stays "/custom/..." var expectedPath = Path.GetFullPath(customPath); Assert.Equal(expectedPath, stored.BasePath); } + [Fact] + public async Task AddToLibrary_RejectsCustomPathParentTraversal() + { + var controller = _provider.GetRequiredService(); + + var parentSegment = new string('.', 2); + var customPath = Path.Join(tempRoot, "Books", parentSegment, "Other"); + var request = new LibraryController.AddToLibraryRequest + { + Metadata = new AudibleBookMetadata + { + Title = "Traversal Path Test", + Author = "Custom Author" + }, + Monitored = true, + DestinationPath = customPath + }; + + var actionResult = await controller.AddToLibrary(request); + + var badRequest = Assert.IsType(actionResult); + Assert.Contains("DestinationPath", badRequest.Value.ToString(), StringComparison.OrdinalIgnoreCase); + Assert.Empty(await _audiobookRepository.GetAllAsync()); + } + [Fact] public async Task AddToLibrary_HandlesWrongCustomPath() { var controller = _provider.GetRequiredService(); var customPath = "/custom/* ?|<>\0/Author/Series/Title"; - Assert.Throws(() => Path.GetFullPath(customPath)); var request = new LibraryController.AddToLibraryRequest { @@ -279,12 +301,9 @@ public async Task AddToLibrary_HandlesWrongCustomPath() var actionResult = await controller.AddToLibrary(request); // Assert - Assert.IsType(actionResult); - - var stored = (await _audiobookRepository.GetAllAsync()).First(); - Assert.NotNull(stored); - // Uses fallback logic with folder naming pattern - Assert.Equal(Path.Join(tempRoot, "Custom Author"), stored.BasePath); + var badRequest = Assert.IsType(actionResult); + Assert.Contains("DestinationPath", badRequest.Value.ToString(), StringComparison.OrdinalIgnoreCase); + Assert.Empty(await _audiobookRepository.GetAllAsync()); } } } diff --git a/tests/Features/Api/Features/Library/LibraryController_MoveTests.cs b/tests/Features/Api/Features/Library/LibraryController_MoveTests.cs index 9b73ae781..3d65512c4 100644 --- a/tests/Features/Api/Features/Library/LibraryController_MoveTests.cs +++ b/tests/Features/Api/Features/Library/LibraryController_MoveTests.cs @@ -150,6 +150,31 @@ await _applicationSettingsRepository.SaveAsync(new ApplicationSettingsBuilder() Assert.StartsWith(" listenarr-move-dst-", Path.GetFileName(updated.BasePath), StringComparison.Ordinal); } + [Fact] + [Trait("Method", "EnqueueMove")] + [Trait("Scenario", "RejectsInvalidDestinationPath")] + public async Task MoveAudiobook_RejectsInvalidDestinationPath() + { + var sourcePath = FileService.GetTempDirectory("listenarr-move-src"); + var audiobook = await _audiobookRepository.AddAsync(new AudiobookBuilder() + .WithTitle("Test") + .WithBasePath(sourcePath) + .Build()); + + var controller = _provider.GetRequiredService(); + var request = new LibraryController.MoveRequest + { + DestinationPath = Path.Join(FileService.GetTempPath(), "bad\0target"), + MoveFiles = false + }; + + var result = await controller.EnqueueMove(audiobook.Id, request); + + var badObj = Assert.IsAssignableFrom(result); + Assert.Equal(400, badObj.StatusCode); + Assert.Contains("DestinationPath", badObj.Value?.ToString() ?? string.Empty); + } + [Fact] [Trait("Method", "EnqueueMove")] [Trait("Scenario", "RejectsRelativeDestinationOutsideOutputPath")] diff --git a/tests/Features/Application/Audiobooks/RootFolders/RootFolderServiceTests.cs b/tests/Features/Application/Audiobooks/RootFolders/RootFolderServiceTests.cs index 4e031198f..5351b1517 100644 --- a/tests/Features/Application/Audiobooks/RootFolders/RootFolderServiceTests.cs +++ b/tests/Features/Application/Audiobooks/RootFolders/RootFolderServiceTests.cs @@ -50,6 +50,99 @@ public async Task Create_Throws_WhenPathDuplicate() await Assert.ThrowsAsync(() => svc.CreateAsync(new RootFolder { Name = "B", Path = booksPath })); } + [Fact] + public async Task Create_AllowsFilesystemRootPath() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + + var dbFactory = new TestDbFactory(options); + var repo = new EfRootFolderRepository(dbFactory, Mock.Of>()); + var svc = new RootFolderService(repo, null!); + var filesystemRoot = Path.GetPathRoot(FileUtils.GetAbsolutePath("root")); + Assert.False(string.IsNullOrWhiteSpace(filesystemRoot)); + + var created = await svc.CreateAsync(new RootFolder { Name = "Drive Root", Path = filesystemRoot! }); + + Assert.Equal(Path.GetFullPath(filesystemRoot!), created.Path); + } + + [Fact] + public async Task Create_Throws_WhenPathInvalidForCurrentOs() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + + var dbFactory = new TestDbFactory(options); + var repo = new EfRootFolderRepository(dbFactory, Mock.Of>()); + var svc = new RootFolderService(repo, null!); + + var exception = await Assert.ThrowsAsync(() => + svc.CreateAsync(new RootFolder { Name = "Invalid", Path = "relative-root" })); + Assert.Contains("not valid", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Create_NormalizesPathBeforeStorage() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + + var dbFactory = new TestDbFactory(options); + var repo = new EfRootFolderRepository(dbFactory, Mock.Of>()); + var svc = new RootFolderService(repo, null!); + var rawPath = Path.Join(rootPath, "."); + + var created = await svc.CreateAsync(new RootFolder { Name = "Normalized", Path = rawPath }); + + Assert.Equal(Path.GetFullPath(rawPath), created.Path); + } + + [Fact] + public async Task Create_Throws_WhenNormalizedPathDuplicate() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + + var normalizedPath = Path.GetFullPath(rootPath); + var db = new ListenArrDbContext(options); + db.RootFolders.Add(new RootFolder { Name = "A", Path = normalizedPath }); + await db.SaveChangesAsync(); + + var dbFactory = new TestDbFactory(options); + var repo = new EfRootFolderRepository(dbFactory, Mock.Of>()); + var svc = new RootFolderService(repo, null!); + + await Assert.ThrowsAsync(() => + svc.CreateAsync(new RootFolder { Name = "B", Path = Path.Join(rootPath, ".") })); + } + + [Fact] + public async Task Update_Throws_WhenPathInvalidForCurrentOs() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + + var db = new ListenArrDbContext(options); + var root = new RootFolder { Name = "R", Path = rootPath }; + db.RootFolders.Add(root); + await db.SaveChangesAsync(); + + var dbFactory = new TestDbFactory(options); + var repo = new EfRootFolderRepository(dbFactory, Mock.Of>()); + var logger = new TestLogger(_output); + var svc = new RootFolderService(repo, logger); + + var exception = await Assert.ThrowsAsync(() => + svc.UpdateAsync(new RootFolder { Id = root.Id, Name = "R2", Path = "relative-root" })); + Assert.Contains("not valid", exception.Message, StringComparison.OrdinalIgnoreCase); + } + [Fact] public async Task Delete_Throws_WhenReferencedWithoutReassign() { diff --git a/tests/Features/Application/Downloads/Common/DownloadClientGatewayTests.cs b/tests/Features/Application/Downloads/Common/DownloadClientGatewayTests.cs index 47bbcf4a5..3ce4e2f02 100644 --- a/tests/Features/Application/Downloads/Common/DownloadClientGatewayTests.cs +++ b/tests/Features/Application/Downloads/Common/DownloadClientGatewayTests.cs @@ -293,5 +293,63 @@ public async Task GetQueueItemAsync_UseContentPath_Directory_Empty() Assert.NotNull(item.SourceFiles); Assert.Empty(item.SourceFiles); } + + [Fact] + [Trait("Method", "GetQueueItemAsync")] + [Trait("Scenario", "Remote path mapping preserves whitespace-bearing path segments")] + public async Task GetQueueItemAsync_PreservesWhitespaceAfterRemotePathMapping() + { + var remoteFile = FileUtils.GetAbsolutePath("downloads", " Book Folder ", "chapter1.m4b"); + var expectedLocalFile = Path.Join(localMapping, " Book Folder ", "chapter1.m4b"); + var downloadCLientAdapterMock = (DownloadCLientAdapterMock)((DownloadClientGateway)downloadClientGateway).ResolveAdapter(client); + downloadCLientAdapterMock.QueueItemMock = new QueueItemBuilder() + .WithRemotePath(remoteFile) + .WithContentPath(remoteFile) + .WithSourceFile(remoteFile) + .WithStatus("completed") + .Build(); + + var item = await downloadClientGateway.GetQueueItemAsync(client, new DownloadBuilder().Build(), new QueueItem()); + + Assert.Equal(expectedLocalFile, item.LocalPath); + Assert.Equal(expectedLocalFile, item.ContentPath); + Assert.Equal([expectedLocalFile], item.SourceFiles); + } + + [Fact] + [Trait("Method", "GetQueueItemAsync")] + [Trait("Scenario", "Directory expansion preserves whitespace-bearing filesystem paths")] + public async Task GetQueueItemAsync_ExpandsWhitespaceBearingDirectoryIntoExactSourceFiles() + { + if (OperatingSystem.IsWindows()) + { + return; + } + + var root = Path.Join(Path.GetTempPath(), "listenarr-gateway-whitespace-" + Guid.NewGuid().ToString("N")); + var sourceDirectory = Path.Join(root, " Book Folder "); + Directory.CreateDirectory(sourceDirectory); + var sourceFile = Path.Join(sourceDirectory, "chapter1.m4b"); + await File.WriteAllTextAsync(sourceFile, "audio"); + + try + { + var downloadCLientAdapterMock = (DownloadCLientAdapterMock)((DownloadClientGateway)downloadClientGateway).ResolveAdapter(client); + downloadCLientAdapterMock.QueueItemMock = new QueueItemBuilder() + .WithContentPath(sourceDirectory) + .WithStatus("completed") + .Build(); + + var item = await downloadClientGateway.GetQueueItemAsync(client, new DownloadBuilder().Build(), new QueueItem()); + var actual = Assert.Single(item.SourceFiles); + + Assert.Equal(FileUtils.NormalizeStoredPath(sourceFile), actual); + Assert.True(File.Exists(actual)); + } + finally + { + try { Directory.Delete(root, true); } catch (IOException ex) { System.Diagnostics.Debug.WriteLine(ex.Message); } catch (UnauthorizedAccessException ex) { System.Diagnostics.Debug.WriteLine(ex.Message); } + } + } } } diff --git a/tests/Features/Domain/Utils/FileUtilsTests.cs b/tests/Features/Domain/Utils/FileUtilsTests.cs index c5a0b9eec..7292f25a0 100644 --- a/tests/Features/Domain/Utils/FileUtilsTests.cs +++ b/tests/Features/Domain/Utils/FileUtilsTests.cs @@ -335,6 +335,56 @@ public void CombineWithOptionalBase_PreservesPathWhitespace() result); } + [Fact] + public void CombineWithOptionalBase_PreservesNestedPathSegmentWhitespace() + { + var result = FileUtils.CombineWithOptionalBase( + FileUtils.GetAbsolutePath("downloads"), + " Book Folder / chapter 01.m4b "); + + Assert.Equal( + FileUtils.GetAbsolutePath("downloads") + Path.DirectorySeparatorChar + " Book Folder / chapter 01.m4b ", + result); + } + + [Fact] + public void CombineWithOptionalBase_PreservesRootedCandidatePath() + { + var candidate = Path.DirectorySeparatorChar + " Book Folder /chapter.m4b"; + + var result = FileUtils.CombineWithOptionalBase( + FileUtils.GetAbsolutePath("downloads"), + candidate); + + Assert.Equal(candidate, result); + } + + [Fact] + public void NormalizeStoredPath_DoesNotTrimPathWhitespace_OnNonWindows() + { + if (OperatingSystem.IsWindows()) + { + return; + } + + var root = Path.Join(Path.GetTempPath(), "listenarr-path-whitespace-" + Guid.NewGuid().ToString("N")); + var whitespaceSegment = " Book Folder "; + var directory = Path.Join(root, whitespaceSegment); + Directory.CreateDirectory(directory); + + try + { + var normalized = FileUtils.NormalizeStoredPath(directory); + + Assert.EndsWith(Path.DirectorySeparatorChar + whitespaceSegment, normalized, StringComparison.Ordinal); + Assert.True(Directory.Exists(normalized)); + } + finally + { + try { Directory.Delete(root, true); } catch (IOException ex) { System.Diagnostics.Debug.WriteLine(ex.Message); } catch (UnauthorizedAccessException ex) { System.Diagnostics.Debug.WriteLine(ex.Message); } + } + } + [Fact] public void CombineRelativePath_JoinsRelativeSegmentsAndTrimsLeadingSeparators() { @@ -478,6 +528,134 @@ public void TryValidateMutationTarget_BlocksDirectorySymlinkEscape() } } + [Theory] + [InlineData(@"C:\Books\Author", true)] + [InlineData(@"C:\", false)] + [InlineData(@"Books\Author", false)] + [InlineData(@"C:\Books\Author ", false)] + [InlineData(@"C:\Books\Author.", false)] + [InlineData(@"C:\Books\NUL", false)] + [InlineData(@"C:\Books\COM1.txt", false)] + [InlineData(@"C:\Books\Bad|Name", false)] + public void TryNormalizeUserProvidedDirectoryPathForOs_UsesWindowsRules(string path, bool expected) + { + var valid = FileUtils.TryNormalizeUserProvidedDirectoryPathForOs( + path, + isWindows: true, + out var normalizedPath, + out var reason); + + Assert.Equal(expected, valid); + if (expected) + { + Assert.False(string.IsNullOrWhiteSpace(normalizedPath)); + Assert.Equal(string.Empty, reason); + } + else + { + Assert.False(string.IsNullOrWhiteSpace(reason)); + } + } + + [Fact] + public void TryNormalizeUserProvidedDirectoryPathForOs_AllowsWindowsRootWhenExplicitlyRequested() + { + var separator = new string((char)92, 1); + var driveRoot = "C:" + separator; + var uncRoot = separator + separator + "server" + separator + "share"; + + Assert.True(FileUtils.TryNormalizeUserProvidedDirectoryPathForOs( + driveRoot, + isWindows: true, + out var normalizedDriveRoot, + out var driveRootReason, + allowFileSystemRoot: true)); + Assert.Equal(string.Empty, driveRootReason); + Assert.Equal("C:", normalizedDriveRoot.TrimEnd((char)92)); + + Assert.True(FileUtils.TryNormalizeUserProvidedDirectoryPathForOs( + uncRoot, + isWindows: true, + out var normalizedUncRoot, + out var uncRootReason, + allowFileSystemRoot: true)); + Assert.Equal(string.Empty, uncRootReason); + Assert.Contains("server", normalizedUncRoot, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void TryNormalizeUserProvidedDirectoryPathForOs_RejectsParentTraversalForDestinations() + { + var separator = new string((char)92, 1); + var windowsTraversal = "C:" + separator + "Books" + separator + ".." + separator + "Other"; + + Assert.False(FileUtils.TryNormalizeUserProvidedDirectoryPathForOs( + windowsTraversal, + isWindows: true, + out _, + out var windowsReason, + rejectParentTraversal: true)); + Assert.Contains("parent", windowsReason, StringComparison.OrdinalIgnoreCase); + + Assert.False(FileUtils.TryNormalizeUserProvidedDirectoryPathForOs( + "/media/../other", + isWindows: false, + out _, + out var unixReason, + rejectParentTraversal: true)); + Assert.Contains("parent", unixReason, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData("/media/Author", true)] + [InlineData("/media/./Author", true)] + [InlineData("/media/Author ", true)] + [InlineData("/media/NUL", true)] + [InlineData("/media/..", false)] + [InlineData("media/Author", false)] + [InlineData("/", false)] + [InlineData("", false)] + public void TryNormalizeUserProvidedDirectoryPathForOs_UsesUnixRules(string path, bool expected) + { + var valid = FileUtils.TryNormalizeUserProvidedDirectoryPathForOs( + path, + isWindows: false, + out var normalizedPath, + out var reason); + + Assert.Equal(expected, valid); + if (expected) + { + Assert.False(string.IsNullOrWhiteSpace(normalizedPath)); + Assert.Equal(string.Empty, reason); + } + else + { + Assert.False(string.IsNullOrWhiteSpace(reason)); + } + } + + [Fact] + public void TryNormalizeUserProvidedDirectoryPathForOs_RejectsNullCharacter() + { + Assert.False(FileUtils.TryNormalizeUserProvidedDirectoryPathForOs( + "/media/book\0folder", + isWindows: false, + out _, + out var reason)); + Assert.Contains("invalid", reason, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void TryNormalizeUserProvidedDirectoryPathForCurrentOs_NormalizesCurrentHostPath() + { + var path = Path.Join(Path.GetTempPath(), "listenarr-normalize-" + Guid.NewGuid().ToString("N")); + + Assert.True(FileUtils.TryNormalizeUserProvidedDirectoryPathForCurrentOs(path, out var normalizedPath, out var reason)); + Assert.Equal(Path.GetFullPath(path), normalizedPath); + Assert.Equal(string.Empty, reason); + } + [Fact] public async Task FilesHaveSameContentAsync_UsesSizeAndHash() { diff --git a/tests/Features/Infrastructure/DownloadClients/Common/DownloadClientAdapterTests.cs b/tests/Features/Infrastructure/DownloadClients/Common/DownloadClientAdapterTests.cs index f1f0bd3ad..717204718 100644 --- a/tests/Features/Infrastructure/DownloadClients/Common/DownloadClientAdapterTests.cs +++ b/tests/Features/Infrastructure/DownloadClients/Common/DownloadClientAdapterTests.cs @@ -121,6 +121,33 @@ public async Task Transmission_LegacyGetImportItemAsync_PopulatesClientReportedS }, resolved.SourceFiles); } + [Fact] + [Trait("Third-Party", "Transmission")] + [Trait("Method", "GetImportItemAsync")] + public async Task Transmission_LegacyGetImportItemAsync_PreservesWhitespaceBearingFolderPaths() + { + var item = new QueueItem + { + Id = TransmissionApiMock.WHITESPACE_FOLDER_TORRENT.ToString(), + ContentPath = string.Empty + }; + + var download = new DownloadBuilder().Build(); + await _downloadRepository.AddAsync(download); + + var adapter = MockUtils.CreateTransmissionAdapter(_provider); + var resolved = await adapter.GetImportItemAsync(_transmissionClient, download, item); + + Assert.Equal(FileUtils.GetAbsolutePath("downloads", " Book Folder "), resolved.ContentPath); + Assert.Equal( + new[] + { + FileUtils.GetAbsolutePath("downloads", " Book Folder ", "chapter1.m4b"), + FileUtils.GetAbsolutePath("downloads", " Book Folder ", "book.txt") + }, + resolved.SourceFiles); + } + [Theory] [Trait("Third-Party", "Sabnzbd")] [Trait("Method", "GetImportItemAsync")] diff --git a/tests/Features/Infrastructure/DownloadClients/Common/TorrentClientPathMapperTests.cs b/tests/Features/Infrastructure/DownloadClients/Common/TorrentClientPathMapperTests.cs new file mode 100644 index 000000000..cb8782860 --- /dev/null +++ b/tests/Features/Infrastructure/DownloadClients/Common/TorrentClientPathMapperTests.cs @@ -0,0 +1,119 @@ +using System.Text.Json; +using Listenarr.Infrastructure.DownloadClients.Common; +using Listenarr.Tests.Common; + +namespace Listenarr.Tests.Features.Infrastructure.DownloadClients.Common +{ + [Trait("Name", "TorrentClientPathMapperTests")] + [Trait("Category", "DownloadClientPathMapping")] + public class TorrentClientPathMapperTests : BaseTests + { + [Fact] + public void BuildTransmissionSourceFiles_PreservesTopLevelFolderWhitespace() + { + var downloadDir = FileUtils.GetAbsolutePath("downloads"); + using var document = JsonDocument.Parse( + """ + [ + { "name": " Book Folder /chapter1.m4b" } + ] + """); + + var sourceFiles = TorrentClientPathMapper.BuildTransmissionSourceFiles(downloadDir, document.RootElement); + + var expected = FileUtils.CombineWithOptionalBase(downloadDir, " Book Folder /chapter1.m4b"); + Assert.Equal([expected], sourceFiles); + Assert.StartsWith(downloadDir + Path.DirectorySeparatorChar, expected, StringComparison.Ordinal); + Assert.Contains(" Book Folder ", expected, StringComparison.Ordinal); + } + + [Fact] + public void BuildTransmissionSourceFiles_PreservesTrailingFolderWhitespace() + { + var downloadDir = FileUtils.GetAbsolutePath("downloads"); + using var document = JsonDocument.Parse( + """ + [ + { "name": "Book Folder /chapter1.m4b" } + ] + """); + + var sourceFiles = TorrentClientPathMapper.BuildTransmissionSourceFiles(downloadDir, document.RootElement); + + var expected = FileUtils.CombineWithOptionalBase(downloadDir, "Book Folder /chapter1.m4b"); + Assert.Equal([expected], sourceFiles); + Assert.StartsWith(downloadDir + Path.DirectorySeparatorChar, expected, StringComparison.Ordinal); + Assert.Contains("Book Folder ", expected, StringComparison.Ordinal); + } + + [Fact] + public void BuildQbittorrentSourceFiles_PreservesTorrentFolderWhitespace() + { + var savePath = FileUtils.GetAbsolutePath("downloads"); + var files = ParseFiles( + """ + [ + { "name": " Book Folder /chapter1.m4b" } + ] + """); + + var sourceFiles = TorrentClientPathMapper.BuildQbittorrentSourceFiles(savePath, files); + + var expected = Path.Join(savePath, " Book Folder ", "chapter1.m4b"); + Assert.Equal([expected], sourceFiles); + } + + [Fact] + public void ResolveQbittorrentContentPath_PreservesSharedTopLevelFolderWhitespace() + { + var savePath = FileUtils.GetAbsolutePath("downloads"); + var files = ParseFiles( + """ + [ + { "name": " Book Folder /chapter1.m4b" }, + { "name": " Book Folder /chapter2.m4b" } + ] + """); + + var contentPath = TorrentClientPathMapper.ResolveQbittorrentContentPath(savePath, files); + + Assert.Equal(Path.Join(savePath, " Book Folder "), contentPath); + } + + [Fact] + public void BuildQbittorrentSourceFiles_RootedChildPathsStayUnderSavePath() + { + var savePath = FileUtils.GetAbsolutePath("downloads"); + var files = ParseFiles( + """ + [ + { "name": "/ Book Folder /chapter1.m4b" } + ] + """); + + var sourceFile = Assert.Single(TorrentClientPathMapper.BuildQbittorrentSourceFiles(savePath, files)); + + Assert.Equal(Path.Join(savePath, " Book Folder ", "chapter1.m4b"), sourceFile); + Assert.True(FileUtils.IsPathSameOrInside(sourceFile, savePath)); + } + + private static List> ParseFiles(string json) + { + using var document = JsonDocument.Parse(json); + var files = new List>(); + + foreach (var element in document.RootElement.EnumerateArray()) + { + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var property in element.EnumerateObject()) + { + map[property.Name] = property.Value.Clone(); + } + + files.Add(map); + } + + return files; + } + } +} diff --git a/tests/Mocks/Api/TransmissionApiMock.cs b/tests/Mocks/Api/TransmissionApiMock.cs index 3b41474f4..8a255c945 100644 --- a/tests/Mocks/Api/TransmissionApiMock.cs +++ b/tests/Mocks/Api/TransmissionApiMock.cs @@ -8,6 +8,7 @@ public class TransmissionApiMock : BaseApiMock public static readonly int SINGLE_FILE_TORRENT = 1; public static readonly int ANOTHER_SINGLE_FILE_TORRENT = 306; public static readonly int MULTI_FILE_TORRENT = 2; + public static readonly int WHITESPACE_FOLDER_TORRENT = 528; public TransmissionApiMock() { @@ -42,6 +43,10 @@ public static async Task GetTorrent(HttpRequestMessage requ { return AnotherSingleFileTorrentGet(); } + else if (id == WHITESPACE_FOLDER_TORRENT) + { + return WhitespaceFolderTorrentGet(); + } } var response = """ @@ -195,5 +200,36 @@ private static HttpResponseMessage MultiFileTorrentGet() response = MockUtils.PutPathInResponse(response, "{{FILE2}}", Path.Join("Book Folder", "book.txt")); return MockUtils.GetCannedResponse(response); } + + private static HttpResponseMessage WhitespaceFolderTorrentGet() + { + var response = """ + { + "arguments": { + "torrents": [ + { + "id": 528, + "name": " Book Folder ", + "downloadDir": "{{DIR}}", + "files": [ + { + "name": "{{FILE1}}" + }, + { + "name": "{{FILE2}}" + } + ] + } + ] + }, + "result": "success", + "tag": 3 + } + """; + response = MockUtils.PutPathInResponse(response, "{{DIR}}", FileUtils.GetAbsolutePath("downloads")); + response = MockUtils.PutPathInResponse(response, "{{FILE1}}", Path.Join(" Book Folder ", "chapter1.m4b")); + response = MockUtils.PutPathInResponse(response, "{{FILE2}}", Path.Join(" Book Folder ", "book.txt")); + return MockUtils.GetCannedResponse(response); + } } } From 6d0f2658eb8ebdefca4741fbfd0ed06fc9e409b9 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Wed, 1 Jul 2026 15:06:52 -0400 Subject: [PATCH 002/464] fix(paths): allow root-folder filesystem roots Allow root-folder configuration to accept filesystem roots, including Windows current-drive roots, while keeping concrete destination paths strict against root-only and parent-traversal targets. Add cross-platform root-folder validation coverage and standardize the new torrent path mapper test header. --- .../Common/FileUtils.UserProvidedPaths.cs | 30 ++++++++++ .../RootFolders/RootFolderServiceTests.cs | 22 ++++++++ tests/Features/Domain/Utils/FileUtilsTests.cs | 55 ++++++++++++++++++- .../Common/TorrentClientPathMapperTests.cs | 17 ++++++ 4 files changed, 123 insertions(+), 1 deletion(-) diff --git a/listenarr.domain/Common/FileUtils.UserProvidedPaths.cs b/listenarr.domain/Common/FileUtils.UserProvidedPaths.cs index 4131c121e..ae6944c58 100644 --- a/listenarr.domain/Common/FileUtils.UserProvidedPaths.cs +++ b/listenarr.domain/Common/FileUtils.UserProvidedPaths.cs @@ -97,6 +97,31 @@ private static bool TryNormalizeWindowsUserProvidedDirectoryPath( normalizedPath = string.Empty; reason = string.Empty; + // Windows accepts \ or / as the current drive root. Root-folder configuration may + // intentionally use that boundary, but concrete destinations must still reject it. + if (IsWindowsCurrentDriveRoot(path)) + { + if (!allowFileSystemRoot) + { + reason = "Path cannot be the filesystem root."; + return false; + } + + try + { + normalizedPath = OperatingSystem.IsWindows() + ? Path.GetFullPath(path) + : path.Replace('/', '\\'); + return true; + } + catch (Exception exception) when (exception is not (OperationCanceledException or OutOfMemoryException or StackOverflowException)) + { + normalizedPath = string.Empty; + reason = "Path is not valid for this operating system."; + return false; + } + } + var rootLength = GetWindowsRootLength(path); if (rootLength <= 0) { @@ -190,6 +215,11 @@ private static bool TryNormalizeUnixUserProvidedDirectoryPath( } } + private static bool IsWindowsCurrentDriveRoot(string path) + { + return path.Length == 1 && (path[0] is '\\' or '/'); + } + private static int GetWindowsRootLength(string path) { if (WindowsDriveRootPattern.IsMatch(path)) diff --git a/tests/Features/Application/Audiobooks/RootFolders/RootFolderServiceTests.cs b/tests/Features/Application/Audiobooks/RootFolders/RootFolderServiceTests.cs index 5351b1517..dfcdc10aa 100644 --- a/tests/Features/Application/Audiobooks/RootFolders/RootFolderServiceTests.cs +++ b/tests/Features/Application/Audiobooks/RootFolders/RootFolderServiceTests.cs @@ -68,6 +68,28 @@ public async Task Create_AllowsFilesystemRootPath() Assert.Equal(Path.GetFullPath(filesystemRoot!), created.Path); } + [Fact] + public async Task Create_AllowsWindowsCurrentDriveRootPath() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + + var dbFactory = new TestDbFactory(options); + var repo = new EfRootFolderRepository(dbFactory, Mock.Of>()); + var svc = new RootFolderService(repo, null!); + var currentDriveRoot = new string((char)92, 1); + + var created = await svc.CreateAsync(new RootFolder { Name = "Current Drive Root", Path = currentDriveRoot }); + + Assert.Equal(Path.GetFullPath(currentDriveRoot), created.Path); + } + [Fact] public async Task Create_Throws_WhenPathInvalidForCurrentOs() { diff --git a/tests/Features/Domain/Utils/FileUtilsTests.cs b/tests/Features/Domain/Utils/FileUtilsTests.cs index 7292f25a0..b8a891e29 100644 --- a/tests/Features/Domain/Utils/FileUtilsTests.cs +++ b/tests/Features/Domain/Utils/FileUtilsTests.cs @@ -563,6 +563,7 @@ public void TryNormalizeUserProvidedDirectoryPathForOs_AllowsWindowsRootWhenExpl var separator = new string((char)92, 1); var driveRoot = "C:" + separator; var uncRoot = separator + separator + "server" + separator + "share"; + var currentDriveRoot = separator; Assert.True(FileUtils.TryNormalizeUserProvidedDirectoryPathForOs( driveRoot, @@ -571,7 +572,7 @@ public void TryNormalizeUserProvidedDirectoryPathForOs_AllowsWindowsRootWhenExpl out var driveRootReason, allowFileSystemRoot: true)); Assert.Equal(string.Empty, driveRootReason); - Assert.Equal("C:", normalizedDriveRoot.TrimEnd((char)92)); + Assert.False(string.IsNullOrWhiteSpace(normalizedDriveRoot)); Assert.True(FileUtils.TryNormalizeUserProvidedDirectoryPathForOs( uncRoot, @@ -581,6 +582,44 @@ public void TryNormalizeUserProvidedDirectoryPathForOs_AllowsWindowsRootWhenExpl allowFileSystemRoot: true)); Assert.Equal(string.Empty, uncRootReason); Assert.Contains("server", normalizedUncRoot, StringComparison.OrdinalIgnoreCase); + + Assert.True(FileUtils.TryNormalizeUserProvidedDirectoryPathForOs( + currentDriveRoot, + isWindows: true, + out var normalizedCurrentDriveRoot, + out var currentDriveRootReason, + allowFileSystemRoot: true)); + Assert.Equal(string.Empty, currentDriveRootReason); + Assert.False(string.IsNullOrWhiteSpace(normalizedCurrentDriveRoot)); + + Assert.True(FileUtils.TryNormalizeUserProvidedDirectoryPathForOs( + "/", + isWindows: true, + out var normalizedForwardSlashRoot, + out var forwardSlashRootReason, + allowFileSystemRoot: true)); + Assert.Equal(string.Empty, forwardSlashRootReason); + Assert.False(string.IsNullOrWhiteSpace(normalizedForwardSlashRoot)); + } + + [Fact] + public void TryNormalizeUserProvidedDirectoryPathForOs_RejectsWindowsRootByDefault() + { + var separator = new string((char)92, 1); + + Assert.False(FileUtils.TryNormalizeUserProvidedDirectoryPathForOs( + separator, + isWindows: true, + out _, + out var currentDriveRootReason)); + Assert.Contains("root", currentDriveRootReason, StringComparison.OrdinalIgnoreCase); + + Assert.False(FileUtils.TryNormalizeUserProvidedDirectoryPathForOs( + "/", + isWindows: true, + out _, + out var forwardSlashRootReason)); + Assert.Contains("root", forwardSlashRootReason, StringComparison.OrdinalIgnoreCase); } [Fact] @@ -606,6 +645,20 @@ public void TryNormalizeUserProvidedDirectoryPathForOs_RejectsParentTraversalFor Assert.Contains("parent", unixReason, StringComparison.OrdinalIgnoreCase); } + [Fact] + public void TryNormalizeUserProvidedDirectoryPathForOs_AllowsUnixRootWhenExplicitlyRequested() + { + Assert.True(FileUtils.TryNormalizeUserProvidedDirectoryPathForOs( + "/", + isWindows: false, + out var normalizedRoot, + out var reason, + allowFileSystemRoot: true)); + + Assert.Equal(string.Empty, reason); + Assert.Equal("/", normalizedRoot); + } + [Theory] [InlineData("/media/Author", true)] [InlineData("/media/./Author", true)] diff --git a/tests/Features/Infrastructure/DownloadClients/Common/TorrentClientPathMapperTests.cs b/tests/Features/Infrastructure/DownloadClients/Common/TorrentClientPathMapperTests.cs index cb8782860..91a1927cd 100644 --- a/tests/Features/Infrastructure/DownloadClients/Common/TorrentClientPathMapperTests.cs +++ b/tests/Features/Infrastructure/DownloadClients/Common/TorrentClientPathMapperTests.cs @@ -1,3 +1,20 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ using System.Text.Json; using Listenarr.Infrastructure.DownloadClients.Common; using Listenarr.Tests.Common; From 6290b23dc95719b02307e888cb2b14e4867f8e3f Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Wed, 1 Jul 2026 18:29:59 -0400 Subject: [PATCH 003/464] fix(paths): reject parent traversal broadly --- .../RootFolders/RootFolderService.cs | 5 +- .../Common/FileUtils.UserProvidedPaths.cs | 2 + .../Common/TorrentClientPathMapper.cs | 19 ++++- .../LibraryController_AddToLibraryTests.cs | 27 +++++++ .../RootFolders/RootFolderServiceTests.cs | 42 +++++++++++ tests/Features/Domain/Utils/FileUtilsTests.cs | 75 +++++++++++++++++++ .../Common/TorrentClientPathMapperTests.cs | 60 +++++++++++++++ 7 files changed, 228 insertions(+), 2 deletions(-) diff --git a/listenarr.application/Audiobooks/RootFolders/RootFolderService.cs b/listenarr.application/Audiobooks/RootFolders/RootFolderService.cs index 94a7ce3b7..ca4d2b782 100644 --- a/listenarr.application/Audiobooks/RootFolders/RootFolderService.cs +++ b/listenarr.application/Audiobooks/RootFolders/RootFolderService.cs @@ -153,11 +153,14 @@ public async Task UpdateAsync(RootFolder root, bool moveFiles = fals private static string NormalizeRootFolderPathForStorage(string? path) { + // Root folders may be filesystem boundaries, but parent traversal is still + // rejected so the stored boundary is explicit rather than reached indirectly. if (!FileUtils.TryNormalizeUserProvidedDirectoryPathForCurrentOs( path, out var normalizedPath, out var validationReason, - allowFileSystemRoot: true)) + allowFileSystemRoot: true, + rejectParentTraversal: true)) { throw new ArgumentException($"Path is not valid for this operating system: {validationReason}"); } diff --git a/listenarr.domain/Common/FileUtils.UserProvidedPaths.cs b/listenarr.domain/Common/FileUtils.UserProvidedPaths.cs index ae6944c58..73b4ad963 100644 --- a/listenarr.domain/Common/FileUtils.UserProvidedPaths.cs +++ b/listenarr.domain/Common/FileUtils.UserProvidedPaths.cs @@ -45,6 +45,8 @@ public static bool TryNormalizeUserProvidedDirectoryPathForCurrentOs( allowFileSystemRoot, rejectParentTraversal); + // The explicit OS parameter lets tests verify Windows and Unix validation rules + // from any host. Production callers should use TryNormalizeUserProvidedDirectoryPathForCurrentOs. public static bool TryNormalizeUserProvidedDirectoryPathForOs( string? path, bool isWindows, diff --git a/listenarr.infrastructure/DownloadClients/Common/TorrentClientPathMapper.cs b/listenarr.infrastructure/DownloadClients/Common/TorrentClientPathMapper.cs index 3fa0cd9a8..fe50f2524 100644 --- a/listenarr.infrastructure/DownloadClients/Common/TorrentClientPathMapper.cs +++ b/listenarr.infrastructure/DownloadClients/Common/TorrentClientPathMapper.cs @@ -39,6 +39,7 @@ public static List BuildQbittorrentSourceFiles( .Select(file => file.TryGetValue("name", out var nameEl) ? nameEl.GetString() ?? string.Empty : string.Empty) .Where(name => !string.IsNullOrEmpty(name)) .Select(name => CombineClientReportedPath(savePath, name.Replace('/', Path.DirectorySeparatorChar))) + .Where(path => !string.IsNullOrEmpty(path)) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); } @@ -64,7 +65,11 @@ public static List BuildTransmissionSourceFiles(string? downloadDir, Jso continue; } - sourceFiles.Add(CombineClientReportedPath(downloadDir, relativePath)); + var sourceFile = CombineClientReportedPath(downloadDir, relativePath); + if (!string.IsNullOrEmpty(sourceFile)) + { + sourceFiles.Add(sourceFile); + } } return sourceFiles; @@ -82,6 +87,7 @@ public static string ResolveQbittorrentContentPath( var fileNames = files .Select(f => f.TryGetValue("name", out var nameEl) ? nameEl.GetString() ?? string.Empty : string.Empty) .Where(name => !string.IsNullOrEmpty(name)) + .Where(name => !ContainsParentDirectorySegment(name)) .ToList(); if (fileNames.Count == 0) @@ -137,9 +143,20 @@ private static string CombineClientReportedPath(string? basePath, string candida .TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); } + if (ContainsParentDirectorySegment(relativePath)) + { + return string.Empty; + } + return FileUtils.CombineWithOptionalBase(basePath, relativePath); } + private static bool ContainsParentDirectorySegment(string path) + { + return path.Split(new[] { '/', '\\' }, StringSplitOptions.RemoveEmptyEntries) + .Any(segment => segment.Length == 2 && segment[0] == '.' && segment[1] == '.'); + } + private static bool HasDriveRootedPrefix(string path) { return path.Length >= 2 diff --git a/tests/Features/Api/Features/Library/LibraryController_AddToLibraryTests.cs b/tests/Features/Api/Features/Library/LibraryController_AddToLibraryTests.cs index 9ee36589c..dc7308d71 100644 --- a/tests/Features/Api/Features/Library/LibraryController_AddToLibraryTests.cs +++ b/tests/Features/Api/Features/Library/LibraryController_AddToLibraryTests.cs @@ -80,6 +80,33 @@ public async Task AddToLibrary_UsesLegacyAuthorField_PopulatesAuthorsAndBasePath Assert.Equal(Path.Join(tempRoot, "Legacy Author"), stored.BasePath); } + [Fact] + public async Task AddToLibrary_WithGeneratedPathFromSanitizedMetadata_Succeeds() + { + await _applicationSettingsRepository.SaveAsync(new ApplicationSettingsBuilder() + .WithFolderNamingPattern("{Author}/{Title}") + .WithFileNamingPattern("{Title}") + .Build()); + var controller = _provider.GetRequiredService(); + + var request = new LibraryController.AddToLibraryRequest + { + Metadata = new AudibleBookMetadata + { + Title = "Book: The Ending.", + Author = "CON" + }, + Monitored = true + }; + + var actionResult = await controller.AddToLibrary(request); + + Assert.IsType(actionResult); + var stored = (await _audiobookRepository.GetAllAsync()).First(); + Assert.NotNull(stored); + Assert.Equal(Path.Join(tempRoot, "CON_", "Book - The Ending"), stored.BasePath); + } + [Fact] public async Task AddToLibrary_PersistsEditableMetadataFields() { diff --git a/tests/Features/Application/Audiobooks/RootFolders/RootFolderServiceTests.cs b/tests/Features/Application/Audiobooks/RootFolders/RootFolderServiceTests.cs index dfcdc10aa..803dec04d 100644 --- a/tests/Features/Application/Audiobooks/RootFolders/RootFolderServiceTests.cs +++ b/tests/Features/Application/Audiobooks/RootFolders/RootFolderServiceTests.cs @@ -106,6 +106,24 @@ public async Task Create_Throws_WhenPathInvalidForCurrentOs() Assert.Contains("not valid", exception.Message, StringComparison.OrdinalIgnoreCase); } + [Fact] + public async Task Create_Throws_WhenRootFolderPathContainsParentTraversal() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + + var dbFactory = new TestDbFactory(options); + var repo = new EfRootFolderRepository(dbFactory, Mock.Of>()); + var svc = new RootFolderService(repo, null!); + var parentSegment = new string('.', 2); + var traversingPath = Path.Join(rootPath, "Audiobooks", parentSegment, "Shared"); + + var exception = await Assert.ThrowsAsync(() => + svc.CreateAsync(new RootFolder { Name = "Traversal Root", Path = traversingPath })); + Assert.Contains("parent", exception.Message, StringComparison.OrdinalIgnoreCase); + } + [Fact] public async Task Create_NormalizesPathBeforeStorage() { @@ -165,6 +183,30 @@ public async Task Update_Throws_WhenPathInvalidForCurrentOs() Assert.Contains("not valid", exception.Message, StringComparison.OrdinalIgnoreCase); } + [Fact] + public async Task Update_Throws_WhenRootFolderPathContainsParentTraversal() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + + var db = new ListenArrDbContext(options); + var root = new RootFolder { Name = "R", Path = rootPath }; + db.RootFolders.Add(root); + await db.SaveChangesAsync(); + + var dbFactory = new TestDbFactory(options); + var repo = new EfRootFolderRepository(dbFactory, Mock.Of>()); + var logger = new TestLogger(_output); + var svc = new RootFolderService(repo, logger); + var parentSegment = new string('.', 2); + var traversingPath = Path.Join(rootPath, "Audiobooks", parentSegment, "Shared"); + + var exception = await Assert.ThrowsAsync(() => + svc.UpdateAsync(new RootFolder { Id = root.Id, Name = "R2", Path = traversingPath })); + Assert.Contains("parent", exception.Message, StringComparison.OrdinalIgnoreCase); + } + [Fact] public async Task Delete_Throws_WhenReferencedWithoutReassign() { diff --git a/tests/Features/Domain/Utils/FileUtilsTests.cs b/tests/Features/Domain/Utils/FileUtilsTests.cs index b8a891e29..6186fe8c2 100644 --- a/tests/Features/Domain/Utils/FileUtilsTests.cs +++ b/tests/Features/Domain/Utils/FileUtilsTests.cs @@ -645,6 +645,81 @@ public void TryNormalizeUserProvidedDirectoryPathForOs_RejectsParentTraversalFor Assert.Contains("parent", unixReason, StringComparison.OrdinalIgnoreCase); } + [Fact] + public void TryNormalizeUserProvidedDirectoryPathForOs_RejectsRootFolderParentTraversal() + { + var separator = new string((char)92, 1); + var parentSegment = new string('.', 2); + var windowsTraversal = "C:" + separator + "Books" + separator + parentSegment + separator + "Other"; + + Assert.False(FileUtils.TryNormalizeUserProvidedDirectoryPathForOs( + windowsTraversal, + isWindows: true, + out _, + out var windowsReason, + allowFileSystemRoot: true, + rejectParentTraversal: true)); + Assert.Contains("parent", windowsReason, StringComparison.OrdinalIgnoreCase); + + var unixTraversal = string.Join('/', string.Empty, "media", parentSegment, "other"); + Assert.False(FileUtils.TryNormalizeUserProvidedDirectoryPathForOs( + unixTraversal, + isWindows: false, + out _, + out var unixReason, + allowFileSystemRoot: true, + rejectParentTraversal: true)); + Assert.Contains("parent", unixReason, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void TryNormalizeUserProvidedDirectoryPathForOs_AllowsRootsWhenExplicitlyRequestedAndTraversalRejected() + { + var separator = new string((char)92, 1); + var driveRoot = "C:" + separator; + var uncRoot = separator + separator + "server" + separator + "share"; + + Assert.True(FileUtils.TryNormalizeUserProvidedDirectoryPathForOs( + driveRoot, + isWindows: true, + out var normalizedDriveRoot, + out var driveRootReason, + allowFileSystemRoot: true, + rejectParentTraversal: true)); + Assert.Equal(string.Empty, driveRootReason); + Assert.False(string.IsNullOrWhiteSpace(normalizedDriveRoot)); + + Assert.True(FileUtils.TryNormalizeUserProvidedDirectoryPathForOs( + separator, + isWindows: true, + out var normalizedCurrentDriveRoot, + out var currentDriveRootReason, + allowFileSystemRoot: true, + rejectParentTraversal: true)); + Assert.Equal(string.Empty, currentDriveRootReason); + Assert.False(string.IsNullOrWhiteSpace(normalizedCurrentDriveRoot)); + + Assert.True(FileUtils.TryNormalizeUserProvidedDirectoryPathForOs( + uncRoot, + isWindows: true, + out var normalizedUncRoot, + out var uncRootReason, + allowFileSystemRoot: true, + rejectParentTraversal: true)); + Assert.Equal(string.Empty, uncRootReason); + Assert.False(string.IsNullOrWhiteSpace(normalizedUncRoot)); + + Assert.True(FileUtils.TryNormalizeUserProvidedDirectoryPathForOs( + "/", + isWindows: false, + out var normalizedUnixRoot, + out var unixRootReason, + allowFileSystemRoot: true, + rejectParentTraversal: true)); + Assert.Equal(string.Empty, unixRootReason); + Assert.Equal("/", normalizedUnixRoot); + } + [Fact] public void TryNormalizeUserProvidedDirectoryPathForOs_AllowsUnixRootWhenExplicitlyRequested() { diff --git a/tests/Features/Infrastructure/DownloadClients/Common/TorrentClientPathMapperTests.cs b/tests/Features/Infrastructure/DownloadClients/Common/TorrentClientPathMapperTests.cs index 91a1927cd..dbcb504d4 100644 --- a/tests/Features/Infrastructure/DownloadClients/Common/TorrentClientPathMapperTests.cs +++ b/tests/Features/Infrastructure/DownloadClients/Common/TorrentClientPathMapperTests.cs @@ -114,6 +114,66 @@ public void BuildQbittorrentSourceFiles_RootedChildPathsStayUnderSavePath() Assert.True(FileUtils.IsPathSameOrInside(sourceFile, savePath)); } + [Fact] + public void BuildQbittorrentSourceFiles_DropsParentTraversalChildPaths() + { + var savePath = FileUtils.GetAbsolutePath("downloads"); + var parentSegment = new string('.', 2); + var files = ParseFiles( + $$""" + [ + { "name": "{{parentSegment}}/escape.m4b" }, + { "name": " Book Folder /chapter1.m4b" } + ] + """); + + var sourceFiles = TorrentClientPathMapper.BuildQbittorrentSourceFiles(savePath, files); + + var sourceFile = Assert.Single(sourceFiles); + Assert.Equal(Path.Join(savePath, " Book Folder ", "chapter1.m4b"), sourceFile); + Assert.True(FileUtils.IsPathSameOrInside(sourceFile, savePath)); + } + + [Fact] + public void BuildTransmissionSourceFiles_DropsParentTraversalChildPaths() + { + var downloadDir = FileUtils.GetAbsolutePath("downloads"); + var parentSegment = new string('.', 2); + using var document = JsonDocument.Parse( + $$""" + [ + { "name": "Book/{{parentSegment}}/{{parentSegment}}/escape.m4b" }, + { "name": " Book Folder /chapter1.m4b" } + ] + """); + + var sourceFiles = TorrentClientPathMapper.BuildTransmissionSourceFiles(downloadDir, document.RootElement); + + var sourceFile = Assert.Single(sourceFiles); + Assert.Equal(FileUtils.CombineWithOptionalBase(downloadDir, " Book Folder /chapter1.m4b"), sourceFile); + Assert.True(FileUtils.IsPathSameOrInside(sourceFile, downloadDir)); + } + + [Fact] + public void ResolveQbittorrentContentPath_IgnoresParentTraversalTopLevelPath() + { + var savePath = FileUtils.GetAbsolutePath("downloads"); + var parentSegment = new string('.', 2); + var files = ParseFiles( + $$""" + [ + { "name": "{{parentSegment}}/escape.m4b" }, + { "name": " Book Folder /chapter1.m4b" }, + { "name": " Book Folder /chapter2.m4b" } + ] + """); + + var contentPath = TorrentClientPathMapper.ResolveQbittorrentContentPath(savePath, files); + + Assert.Equal(Path.Join(savePath, " Book Folder "), contentPath); + Assert.True(FileUtils.IsPathSameOrInside(contentPath, savePath)); + } + private static List> ParseFiles(string json) { using var document = JsonDocument.Parse(json); From 8f5878587b829179269225c41ff5391cce111d36 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Wed, 1 Jul 2026 20:46:25 -0400 Subject: [PATCH 004/464] fix(paths): constrain library move destinations --- .../Features/Library/LibraryMoveWorkflow.cs | 85 +++++++++- .../Common/FileUtils.UserProvidedPaths.cs | 7 +- .../Common/TorrentClientPathMapper.cs | 10 +- .../Library/LibraryController_MoveTests.cs | 151 +++++++++++++++++- .../RootFolders/RootFolderServiceTests.cs | 2 +- tests/Features/Domain/Utils/FileUtilsTests.cs | 27 +++- 6 files changed, 259 insertions(+), 23 deletions(-) diff --git a/listenarr.api/Features/Library/LibraryMoveWorkflow.cs b/listenarr.api/Features/Library/LibraryMoveWorkflow.cs index d974b6e58..50f86da00 100644 --- a/listenarr.api/Features/Library/LibraryMoveWorkflow.cs +++ b/listenarr.api/Features/Library/LibraryMoveWorkflow.cs @@ -59,10 +59,45 @@ public async Task EnqueueAsync(int id, LibraryController.MoveRequ { using var scope = _scopeFactory.CreateScope(); var configService = scope.ServiceProvider.GetRequiredService(); + var rootFolderService = scope.ServiceProvider.GetRequiredService(); var settings = await configService.GetApplicationSettingsAsync(); + var rootFolders = await rootFolderService.GetAllAsync(); + + var allowedMoveRoots = new List(); + var normalizedOutputPath = TryNormalizeMoveRoot(settings.OutputPath, "configured output path"); + AddAllowedMoveRoot(allowedMoveRoots, normalizedOutputPath); + + string? defaultRootPath = null; + foreach (var rootFolder in rootFolders) + { + var normalizedRootPath = TryNormalizeMoveRoot(rootFolder.Path, $"root folder {rootFolder.Id}"); + if (normalizedRootPath == null) + { + continue; + } + + AddAllowedMoveRoot(allowedMoveRoots, normalizedRootPath); + if (rootFolder.IsDefault && defaultRootPath == null) + { + defaultRootPath = normalizedRootPath; + } + } + + if (allowedMoveRoots.Count == 0) + { + return new BadRequestObjectResult(new { message = "DestinationPath must be inside a configured root folder or output path" }); + } var destinationIsRooted = Path.IsPathRooted(request.DestinationPath!); - var destinationCandidate = FileUtils.CombineWithOptionalBase(settings.OutputPath, request.DestinationPath!); + var relativeMoveBase = normalizedOutputPath ?? defaultRootPath ?? allowedMoveRoots.FirstOrDefault(); + if (!destinationIsRooted && string.IsNullOrEmpty(relativeMoveBase)) + { + return new BadRequestObjectResult(new { message = "DestinationPath requires a configured root folder or output path" }); + } + + var destinationCandidate = destinationIsRooted + ? request.DestinationPath! + : FileUtils.CombineWithOptionalBase(relativeMoveBase, request.DestinationPath!); if (!FileUtils.TryNormalizeUserProvidedDirectoryPathForCurrentOs( destinationCandidate, out var final, @@ -71,16 +106,14 @@ public async Task EnqueueAsync(int id, LibraryController.MoveRequ { return new BadRequestObjectResult(new { message = $"DestinationPath is not valid for this operating system: {validationReason}" }); } - if (!destinationIsRooted - && !string.IsNullOrWhiteSpace(settings.OutputPath) - && !_fileSystem.TryValidateMutationTarget(final, [settings.OutputPath], out final, out var finalReason)) + if (!_fileSystem.TryValidateMutationTarget(final, allowedMoveRoots, out final, out var finalReason)) { _logger.LogWarning( "Blocked move destination for audiobook {AudiobookId}: {Destination}. Reason: {Reason}", id, final, finalReason); - return new BadRequestObjectResult(new { message = "DestinationPath must be inside the configured output path" }); + return new BadRequestObjectResult(new { message = "DestinationPath must be inside a configured root folder or output path" }); } if (request.MoveFiles == false) @@ -201,6 +234,48 @@ public async Task RequeueAsync(string jobId) return new AcceptedResult(string.Empty, new { message = "Requeued move job", jobId = newJobId }); } + private string? TryNormalizeMoveRoot(string? path, string description) + { + if (string.IsNullOrWhiteSpace(path)) + { + return null; + } + + if (FileUtils.TryNormalizeUserProvidedDirectoryPathForCurrentOs( + path, + out var normalizedPath, + out var validationReason, + allowFileSystemRoot: true, + rejectParentTraversal: true)) + { + return normalizedPath; + } + + _logger.LogWarning( + "Skipping invalid move boundary from {Description}: {Reason}", + description, + validationReason); + return null; + } + + private static void AddAllowedMoveRoot(List allowedRoots, string? normalizedRoot) + { + if (string.IsNullOrEmpty(normalizedRoot)) + { + return; + } + + var comparison = OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + if (allowedRoots.Any(root => string.Equals(root, normalizedRoot, comparison))) + { + return; + } + + allowedRoots.Add(normalizedRoot); + } + private async Task BroadcastQueuedAsync(Guid jobId, int? audiobookId) { try diff --git a/listenarr.domain/Common/FileUtils.UserProvidedPaths.cs b/listenarr.domain/Common/FileUtils.UserProvidedPaths.cs index 73b4ad963..072f2090c 100644 --- a/listenarr.domain/Common/FileUtils.UserProvidedPaths.cs +++ b/listenarr.domain/Common/FileUtils.UserProvidedPaths.cs @@ -300,8 +300,13 @@ private static bool IsInvalidWindowsDirectorySegmentCharacter(char character) return character < 32 || character is '<' or '>' or ':' or '"' or '|' or '?' or '*'; } - private static bool ContainsParentDirectorySegment(string path, params char[] separators) + public static bool ContainsParentDirectorySegment(string path, params char[] separators) { + if (string.IsNullOrEmpty(path) || separators.Length == 0) + { + return false; + } + return path.Split(separators, StringSplitOptions.RemoveEmptyEntries) .Any(segment => segment == ".."); } diff --git a/listenarr.infrastructure/DownloadClients/Common/TorrentClientPathMapper.cs b/listenarr.infrastructure/DownloadClients/Common/TorrentClientPathMapper.cs index fe50f2524..ed6b20acb 100644 --- a/listenarr.infrastructure/DownloadClients/Common/TorrentClientPathMapper.cs +++ b/listenarr.infrastructure/DownloadClients/Common/TorrentClientPathMapper.cs @@ -87,7 +87,7 @@ public static string ResolveQbittorrentContentPath( var fileNames = files .Select(f => f.TryGetValue("name", out var nameEl) ? nameEl.GetString() ?? string.Empty : string.Empty) .Where(name => !string.IsNullOrEmpty(name)) - .Where(name => !ContainsParentDirectorySegment(name)) + .Where(name => !FileUtils.ContainsParentDirectorySegment(name, '/', '\\')) .ToList(); if (fileNames.Count == 0) @@ -143,7 +143,7 @@ private static string CombineClientReportedPath(string? basePath, string candida .TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); } - if (ContainsParentDirectorySegment(relativePath)) + if (FileUtils.ContainsParentDirectorySegment(relativePath, '/', '\\')) { return string.Empty; } @@ -151,12 +151,6 @@ private static string CombineClientReportedPath(string? basePath, string candida return FileUtils.CombineWithOptionalBase(basePath, relativePath); } - private static bool ContainsParentDirectorySegment(string path) - { - return path.Split(new[] { '/', '\\' }, StringSplitOptions.RemoveEmptyEntries) - .Any(segment => segment.Length == 2 && segment[0] == '.' && segment[1] == '.'); - } - private static bool HasDriveRootedPrefix(string path) { return path.Length >= 2 diff --git a/tests/Features/Api/Features/Library/LibraryController_MoveTests.cs b/tests/Features/Api/Features/Library/LibraryController_MoveTests.cs index 3d65512c4..15fd44133 100644 --- a/tests/Features/Api/Features/Library/LibraryController_MoveTests.cs +++ b/tests/Features/Api/Features/Library/LibraryController_MoveTests.cs @@ -34,12 +34,17 @@ public async Task MoveAudiobook_ReturnsBadRequest_WhenSourceDoesNotExist() // Given var controller = _provider.GetRequiredService(); + var outputPath = FileService.GetTempDirectory("listenarr-move-output"); + await _applicationSettingsRepository.SaveAsync(new ApplicationSettingsBuilder() + .WithOutputPath(outputPath) + .Build()); + var ab = await _audiobookRepository.AddAsync(new AudiobookBuilder() .WithTitle("Test") .WithBasePath(Path.Join(FileService.GetTempPath(), "nonexistent")) .Build()); - var request = new LibraryController.MoveRequest { DestinationPath = Path.Join(FileService.GetTempPath(), "target") }; + var request = new LibraryController.MoveRequest { DestinationPath = Path.Join(outputPath, "target") }; // When var result = await controller.EnqueueMove(ab.Id, request); @@ -64,12 +69,17 @@ public async Task MoveAudiobook_EnqueuesJob_WhenSourceExists() Init(services => services.WithSingleton(mockMoveQueue.Object)); var controller = _provider.GetRequiredService(); + var outputPath = FileService.GetTempDirectory("listenarr-move-output"); + await _applicationSettingsRepository.SaveAsync(new ApplicationSettingsBuilder() + .WithOutputPath(outputPath) + .Build()); + var ab = await _audiobookRepository.AddAsync(new AudiobookBuilder() .WithTitle("Test") .WithBasePath(FileService.GetTempDirectory("listenarr-move-src")) .Build()); - var target = Path.Join(FileService.GetTempPath(), "listenarr-move-dst"); + var target = Path.Join(outputPath, "listenarr-move-dst"); var request = new LibraryController.MoveRequest { DestinationPath = target }; // When @@ -92,12 +102,17 @@ public async Task MoveAudiobook_UpdatesBasePath_WhenMoveFilesFalse() Init(services => services.WithSingleton(mockMoveQueue.Object)); var controller = _provider.GetRequiredService(); + var outputPath = FileService.GetTempDirectory("listenarr-move-output"); + await _applicationSettingsRepository.SaveAsync(new ApplicationSettingsBuilder() + .WithOutputPath(outputPath) + .Build()); + var ab = await _audiobookRepository.AddAsync(new AudiobookBuilder() .WithTitle("Test") .WithBasePath(Path.Join(FileService.GetTempPath(), "listenarr-move-src")) .Build()); - var target = Path.Join(FileService.GetTempPath(), "listenarr-move-dst"); + var target = Path.Join(outputPath, "listenarr-move-dst"); var request = new LibraryController.MoveRequest { DestinationPath = target, MoveFiles = false }; // When @@ -155,6 +170,11 @@ await _applicationSettingsRepository.SaveAsync(new ApplicationSettingsBuilder() [Trait("Scenario", "RejectsInvalidDestinationPath")] public async Task MoveAudiobook_RejectsInvalidDestinationPath() { + var outputPath = FileService.GetTempDirectory("listenarr-move-output"); + await _applicationSettingsRepository.SaveAsync(new ApplicationSettingsBuilder() + .WithOutputPath(outputPath) + .Build()); + var sourcePath = FileService.GetTempDirectory("listenarr-move-src"); var audiobook = await _audiobookRepository.AddAsync(new AudiobookBuilder() .WithTitle("Test") @@ -175,6 +195,131 @@ public async Task MoveAudiobook_RejectsInvalidDestinationPath() Assert.Contains("DestinationPath", badObj.Value?.ToString() ?? string.Empty); } + [Fact] + [Trait("Method", "EnqueueMove")] + [Trait("Scenario", "AllowsAbsoluteDestinationInsideConfiguredRootFolder")] + public async Task MoveAudiobook_AllowsAbsoluteDestinationInsideConfiguredRootFolder() + { + var rootPath = FileService.GetTempDirectory("listenarr-move-root"); + await _rootFolderRepository.AddAsync(new RootFolderBuilder() + .WithName("Move Root") + .WithPath(rootPath) + .WithIsDefault() + .Build()); + + var audiobook = await _audiobookRepository.AddAsync(new AudiobookBuilder() + .WithTitle("Test") + .WithBasePath(FileService.GetTempDirectory("listenarr-move-src")) + .Build()); + + var controller = _provider.GetRequiredService(); + var target = Path.Join(rootPath, "Author", "Title"); + var request = new LibraryController.MoveRequest { DestinationPath = target, MoveFiles = false }; + + var result = await controller.EnqueueMove(audiobook.Id, request); + + var okObj = Assert.IsAssignableFrom(result); + Assert.Equal(200, okObj.StatusCode); + + var updated = await _audiobookRepository.GetByIdAsync(audiobook.Id); + Assert.NotNull(updated); + Assert.Equal(FileUtils.NormalizeStoredPath(target), updated.BasePath); + } + + [Fact] + [Trait("Method", "EnqueueMove")] + [Trait("Scenario", "AllowsAbsoluteDestinationInsideConfiguredOutputPath")] + public async Task MoveAudiobook_AllowsAbsoluteDestinationInsideConfiguredOutputPath() + { + var outputPath = FileService.GetTempDirectory("listenarr-move-output"); + await _applicationSettingsRepository.SaveAsync(new ApplicationSettingsBuilder() + .WithOutputPath(outputPath) + .Build()); + + var audiobook = await _audiobookRepository.AddAsync(new AudiobookBuilder() + .WithTitle("Test") + .WithBasePath(FileService.GetTempDirectory("listenarr-move-src")) + .Build()); + + var controller = _provider.GetRequiredService(); + var target = Path.Join(outputPath, "Author", "Title"); + var request = new LibraryController.MoveRequest { DestinationPath = target, MoveFiles = false }; + + var result = await controller.EnqueueMove(audiobook.Id, request); + + var okObj = Assert.IsAssignableFrom(result); + Assert.Equal(200, okObj.StatusCode); + + var updated = await _audiobookRepository.GetByIdAsync(audiobook.Id); + Assert.NotNull(updated); + Assert.Equal(FileUtils.NormalizeStoredPath(target), updated.BasePath); + } + + [Fact] + [Trait("Method", "EnqueueMove")] + [Trait("Scenario", "RejectsAbsoluteDestinationOutsideConfiguredRoots")] + public async Task MoveAudiobook_RejectsAbsoluteDestinationOutsideConfiguredRoots() + { + var outputPath = FileService.GetTempDirectory("listenarr-move-output"); + await _applicationSettingsRepository.SaveAsync(new ApplicationSettingsBuilder() + .WithOutputPath(outputPath) + .Build()); + + var originalBasePath = FileService.GetTempDirectory("listenarr-move-src"); + var audiobook = await _audiobookRepository.AddAsync(new AudiobookBuilder() + .WithTitle("Test") + .WithBasePath(originalBasePath) + .Build()); + + var controller = _provider.GetRequiredService(); + var outsidePath = Path.Join(FileService.GetTempDirectory("listenarr-move-outside"), "Author", "Title"); + var request = new LibraryController.MoveRequest { DestinationPath = outsidePath, MoveFiles = false }; + + var result = await controller.EnqueueMove(audiobook.Id, request); + + var badObj = Assert.IsAssignableFrom(result); + Assert.Equal(400, badObj.StatusCode); + Assert.Contains("configured root folder or output path", badObj.Value?.ToString() ?? string.Empty); + + var unchanged = await _audiobookRepository.GetByIdAsync(audiobook.Id); + Assert.NotNull(unchanged); + Assert.Equal(originalBasePath, unchanged.BasePath); + } + + [Fact] + [Trait("Method", "EnqueueMove")] + [Trait("Scenario", "UsesDefaultRootFolderForRelativeDestination_WhenOutputPathEmpty")] + public async Task MoveAudiobook_UsesDefaultRootFolderForRelativeDestination_WhenOutputPathEmpty() + { + await _applicationSettingsRepository.SaveAsync(new ApplicationSettingsBuilder() + .WithOutputPath(string.Empty) + .Build()); + var rootPath = FileService.GetTempDirectory("listenarr-move-root"); + await _rootFolderRepository.AddAsync(new RootFolderBuilder() + .WithName("Default Move Root") + .WithPath(rootPath) + .WithIsDefault() + .Build()); + + var audiobook = await _audiobookRepository.AddAsync(new AudiobookBuilder() + .WithTitle("Test") + .WithBasePath(FileService.GetTempDirectory("listenarr-move-src")) + .Build()); + + var controller = _provider.GetRequiredService(); + var relativeTarget = Path.Join("Author", "Title"); + var request = new LibraryController.MoveRequest { DestinationPath = relativeTarget, MoveFiles = false }; + + var result = await controller.EnqueueMove(audiobook.Id, request); + + var okObj = Assert.IsAssignableFrom(result); + Assert.Equal(200, okObj.StatusCode); + + var updated = await _audiobookRepository.GetByIdAsync(audiobook.Id); + Assert.NotNull(updated); + Assert.Equal(FileUtils.NormalizeStoredPath(Path.Join(rootPath, relativeTarget)), updated.BasePath); + } + [Fact] [Trait("Method", "EnqueueMove")] [Trait("Scenario", "RejectsRelativeDestinationOutsideOutputPath")] diff --git a/tests/Features/Application/Audiobooks/RootFolders/RootFolderServiceTests.cs b/tests/Features/Application/Audiobooks/RootFolders/RootFolderServiceTests.cs index 803dec04d..57b0dc441 100644 --- a/tests/Features/Application/Audiobooks/RootFolders/RootFolderServiceTests.cs +++ b/tests/Features/Application/Audiobooks/RootFolders/RootFolderServiceTests.cs @@ -83,7 +83,7 @@ public async Task Create_AllowsWindowsCurrentDriveRootPath() var dbFactory = new TestDbFactory(options); var repo = new EfRootFolderRepository(dbFactory, Mock.Of>()); var svc = new RootFolderService(repo, null!); - var currentDriveRoot = new string((char)92, 1); + var currentDriveRoot = "\\"; var created = await svc.CreateAsync(new RootFolder { Name = "Current Drive Root", Path = currentDriveRoot }); diff --git a/tests/Features/Domain/Utils/FileUtilsTests.cs b/tests/Features/Domain/Utils/FileUtilsTests.cs index 6186fe8c2..3ee75b29b 100644 --- a/tests/Features/Domain/Utils/FileUtilsTests.cs +++ b/tests/Features/Domain/Utils/FileUtilsTests.cs @@ -560,7 +560,7 @@ public void TryNormalizeUserProvidedDirectoryPathForOs_UsesWindowsRules(string p [Fact] public void TryNormalizeUserProvidedDirectoryPathForOs_AllowsWindowsRootWhenExplicitlyRequested() { - var separator = new string((char)92, 1); + var separator = "\\"; var driveRoot = "C:" + separator; var uncRoot = separator + separator + "server" + separator + "share"; var currentDriveRoot = separator; @@ -605,7 +605,7 @@ public void TryNormalizeUserProvidedDirectoryPathForOs_AllowsWindowsRootWhenExpl [Fact] public void TryNormalizeUserProvidedDirectoryPathForOs_RejectsWindowsRootByDefault() { - var separator = new string((char)92, 1); + var separator = "\\"; Assert.False(FileUtils.TryNormalizeUserProvidedDirectoryPathForOs( separator, @@ -625,7 +625,7 @@ public void TryNormalizeUserProvidedDirectoryPathForOs_RejectsWindowsRootByDefau [Fact] public void TryNormalizeUserProvidedDirectoryPathForOs_RejectsParentTraversalForDestinations() { - var separator = new string((char)92, 1); + var separator = "\\"; var windowsTraversal = "C:" + separator + "Books" + separator + ".." + separator + "Other"; Assert.False(FileUtils.TryNormalizeUserProvidedDirectoryPathForOs( @@ -648,7 +648,7 @@ public void TryNormalizeUserProvidedDirectoryPathForOs_RejectsParentTraversalFor [Fact] public void TryNormalizeUserProvidedDirectoryPathForOs_RejectsRootFolderParentTraversal() { - var separator = new string((char)92, 1); + var separator = "\\"; var parentSegment = new string('.', 2); var windowsTraversal = "C:" + separator + "Books" + separator + parentSegment + separator + "Other"; @@ -675,7 +675,7 @@ public void TryNormalizeUserProvidedDirectoryPathForOs_RejectsRootFolderParentTr [Fact] public void TryNormalizeUserProvidedDirectoryPathForOs_AllowsRootsWhenExplicitlyRequestedAndTraversalRejected() { - var separator = new string((char)92, 1); + var separator = "\\"; var driveRoot = "C:" + separator; var uncRoot = separator + separator + "server" + separator + "share"; @@ -720,6 +720,23 @@ public void TryNormalizeUserProvidedDirectoryPathForOs_AllowsRootsWhenExplicitly Assert.Equal("/", normalizedUnixRoot); } + [Theory] + [InlineData("../escape", true)] + [InlineData("Book/../../escape", true)] + [InlineData(".../Book", false)] + [InlineData("..hidden/Book", false)] + [InlineData("Book../Title", false)] + public void ContainsParentDirectorySegment_DetectsOnlyLiteralParentSegments(string path, bool expected) + { + Assert.Equal(expected, FileUtils.ContainsParentDirectorySegment(path, '/', '\\')); + } + + [Fact] + public void ContainsParentDirectorySegment_WithUnixSeparator_DoesNotTreatBackslashAsSeparator() + { + Assert.False(FileUtils.ContainsParentDirectorySegment("Book\\..\\Title", '/')); + } + [Fact] public void TryNormalizeUserProvidedDirectoryPathForOs_AllowsUnixRootWhenExplicitlyRequested() { From e218c0c50a12082c6673590c37cf8aad9c50abc4 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Wed, 1 Jul 2026 21:36:51 -0400 Subject: [PATCH 005/464] test(paths): use verbatim backslash literals --- .../Audiobooks/RootFolders/RootFolderServiceTests.cs | 2 +- tests/Features/Domain/Utils/FileUtilsTests.cs | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/Features/Application/Audiobooks/RootFolders/RootFolderServiceTests.cs b/tests/Features/Application/Audiobooks/RootFolders/RootFolderServiceTests.cs index 57b0dc441..74dca04da 100644 --- a/tests/Features/Application/Audiobooks/RootFolders/RootFolderServiceTests.cs +++ b/tests/Features/Application/Audiobooks/RootFolders/RootFolderServiceTests.cs @@ -83,7 +83,7 @@ public async Task Create_AllowsWindowsCurrentDriveRootPath() var dbFactory = new TestDbFactory(options); var repo = new EfRootFolderRepository(dbFactory, Mock.Of>()); var svc = new RootFolderService(repo, null!); - var currentDriveRoot = "\\"; + var currentDriveRoot = @"\"; var created = await svc.CreateAsync(new RootFolder { Name = "Current Drive Root", Path = currentDriveRoot }); diff --git a/tests/Features/Domain/Utils/FileUtilsTests.cs b/tests/Features/Domain/Utils/FileUtilsTests.cs index 3ee75b29b..a75c59cd8 100644 --- a/tests/Features/Domain/Utils/FileUtilsTests.cs +++ b/tests/Features/Domain/Utils/FileUtilsTests.cs @@ -560,7 +560,7 @@ public void TryNormalizeUserProvidedDirectoryPathForOs_UsesWindowsRules(string p [Fact] public void TryNormalizeUserProvidedDirectoryPathForOs_AllowsWindowsRootWhenExplicitlyRequested() { - var separator = "\\"; + var separator = @"\"; var driveRoot = "C:" + separator; var uncRoot = separator + separator + "server" + separator + "share"; var currentDriveRoot = separator; @@ -605,7 +605,7 @@ public void TryNormalizeUserProvidedDirectoryPathForOs_AllowsWindowsRootWhenExpl [Fact] public void TryNormalizeUserProvidedDirectoryPathForOs_RejectsWindowsRootByDefault() { - var separator = "\\"; + var separator = @"\"; Assert.False(FileUtils.TryNormalizeUserProvidedDirectoryPathForOs( separator, @@ -625,7 +625,7 @@ public void TryNormalizeUserProvidedDirectoryPathForOs_RejectsWindowsRootByDefau [Fact] public void TryNormalizeUserProvidedDirectoryPathForOs_RejectsParentTraversalForDestinations() { - var separator = "\\"; + var separator = @"\"; var windowsTraversal = "C:" + separator + "Books" + separator + ".." + separator + "Other"; Assert.False(FileUtils.TryNormalizeUserProvidedDirectoryPathForOs( @@ -648,7 +648,7 @@ public void TryNormalizeUserProvidedDirectoryPathForOs_RejectsParentTraversalFor [Fact] public void TryNormalizeUserProvidedDirectoryPathForOs_RejectsRootFolderParentTraversal() { - var separator = "\\"; + var separator = @"\"; var parentSegment = new string('.', 2); var windowsTraversal = "C:" + separator + "Books" + separator + parentSegment + separator + "Other"; @@ -675,7 +675,7 @@ public void TryNormalizeUserProvidedDirectoryPathForOs_RejectsRootFolderParentTr [Fact] public void TryNormalizeUserProvidedDirectoryPathForOs_AllowsRootsWhenExplicitlyRequestedAndTraversalRejected() { - var separator = "\\"; + var separator = @"\"; var driveRoot = "C:" + separator; var uncRoot = separator + separator + "server" + separator + "share"; From 4fb112cf9d77bbb7b9f5b315e6dba506c354b755 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Thu, 2 Jul 2026 09:56:02 -0400 Subject: [PATCH 006/464] fix(paths): refine destination validation --- .../Features/Library/LibraryAddWorkflow.cs | 9 ++- .../Features/Library/LibraryMoveWorkflow.cs | 15 +++- .../Audiobooks/Catalog/LibraryAddService.cs | 11 ++- .../Common/FileUtils.PathCombining.cs | 27 ------- .../Common/FileUtils.UserProvidedPaths.cs | 17 +++++ listenarr.domain/Common/FileUtils.cs | 9 ++- .../Common/TorrentClientPathMapper.cs | 2 +- .../QbittorrentImportPathResolver.cs | 2 +- .../LibraryController_AddToLibraryTests.cs | 46 ++++++++++++ .../Library/LibraryController_MoveTests.cs | 70 +++++++++++++++++++ tests/Features/Domain/Utils/FileUtilsTests.cs | 20 +++++- .../Common/TorrentClientPathMapperTests.cs | 34 +++++++++ 12 files changed, 225 insertions(+), 37 deletions(-) diff --git a/listenarr.api/Features/Library/LibraryAddWorkflow.cs b/listenarr.api/Features/Library/LibraryAddWorkflow.cs index d6622e3b2..8e101dc1f 100644 --- a/listenarr.api/Features/Library/LibraryAddWorkflow.cs +++ b/listenarr.api/Features/Library/LibraryAddWorkflow.cs @@ -138,13 +138,20 @@ public async Task AddAsync(LibraryController.AddToLibraryRequest if (!string.IsNullOrWhiteSpace(request.DestinationPath)) { + // Preserve valid Unix path-segment whitespace, but reject values that only become + // absolute after trimming accidental leading whitespace. + if (FileUtils.HasLeadingWhitespaceBeforeRootedPath(request.DestinationPath)) + { + return new BadRequestObjectResult(new { message = "DestinationPath is invalid: leading whitespace before an absolute path is not allowed." }); + } + if (!FileUtils.TryNormalizeUserProvidedDirectoryPathForCurrentOs( request.DestinationPath, out var normalizedDestinationPath, out var validationReason, rejectParentTraversal: true)) { - return new BadRequestObjectResult(new { message = $"DestinationPath is not valid for this operating system: {validationReason}" }); + return new BadRequestObjectResult(new { message = $"DestinationPath is invalid: {validationReason}" }); } audiobook.BasePath = normalizedDestinationPath; diff --git a/listenarr.api/Features/Library/LibraryMoveWorkflow.cs b/listenarr.api/Features/Library/LibraryMoveWorkflow.cs index 50f86da00..e5cd5c176 100644 --- a/listenarr.api/Features/Library/LibraryMoveWorkflow.cs +++ b/listenarr.api/Features/Library/LibraryMoveWorkflow.cs @@ -55,6 +55,14 @@ public async Task EnqueueAsync(int id, LibraryController.MoveRequ return new BadRequestObjectResult(new { message = "DestinationPath is required" }); } + // Preserve valid Unix path-segment whitespace, but reject values that only become + // absolute after trimming accidental leading whitespace. Otherwise move would treat + // " /books/Title" as a relative child folder under the configured destination root. + if (FileUtils.HasLeadingWhitespaceBeforeRootedPath(request.DestinationPath)) + { + return new BadRequestObjectResult(new { message = "DestinationPath is invalid: leading whitespace before an absolute path is not allowed." }); + } + try { using var scope = _scopeFactory.CreateScope(); @@ -104,7 +112,7 @@ public async Task EnqueueAsync(int id, LibraryController.MoveRequ out var validationReason, rejectParentTraversal: true)) { - return new BadRequestObjectResult(new { message = $"DestinationPath is not valid for this operating system: {validationReason}" }); + return new BadRequestObjectResult(new { message = $"DestinationPath is invalid: {validationReason}" }); } if (!_fileSystem.TryValidateMutationTarget(final, allowedMoveRoots, out final, out var finalReason)) { @@ -174,7 +182,10 @@ public async Task EnqueueAsync(int id, LibraryController.MoveRequ { var srcFull = Path.GetFullPath(sourcePath).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); var tgtFull = Path.GetFullPath(final).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - if (string.Equals(srcFull, tgtFull, StringComparison.OrdinalIgnoreCase)) + var pathComparison = OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + if (string.Equals(srcFull, tgtFull, pathComparison)) { return new BadRequestObjectResult(new { message = "Source and target paths are identical; nothing to move." }); } diff --git a/listenarr.application/Audiobooks/Catalog/LibraryAddService.cs b/listenarr.application/Audiobooks/Catalog/LibraryAddService.cs index e8ec83982..5090dfa91 100644 --- a/listenarr.application/Audiobooks/Catalog/LibraryAddService.cs +++ b/listenarr.application/Audiobooks/Catalog/LibraryAddService.cs @@ -153,13 +153,20 @@ public async Task AddToLibraryAsync( var requestedBaseDirectory = request.DestinationPath; if (!string.IsNullOrWhiteSpace(requestedBaseDirectory)) { + // Preserve valid Unix path-segment whitespace, but reject values that only become + // absolute after trimming accidental leading whitespace. + if (FileUtils.HasLeadingWhitespaceBeforeRootedPath(requestedBaseDirectory)) + { + return ValidationFailure("DestinationPath is invalid: leading whitespace before an absolute path is not allowed."); + } + if (!FileUtils.TryNormalizeUserProvidedDirectoryPathForCurrentOs( requestedBaseDirectory, out var normalizedRequestedBaseDirectory, out var validationReason, rejectParentTraversal: true)) { - return ValidationFailure($"DestinationPath is not valid for this operating system: {validationReason}"); + return ValidationFailure($"DestinationPath is invalid: {validationReason}"); } audiobook.BasePath = normalizedRequestedBaseDirectory; @@ -178,7 +185,7 @@ public async Task AddToLibraryAsync( out var validationReason, rejectParentTraversal: true)) { - return ValidationFailure($"Generated library destination is not valid for this operating system: {validationReason}"); + return ValidationFailure($"Generated library destination is invalid: {validationReason}"); } audiobook.BasePath = normalizedGeneratedBasePath; diff --git a/listenarr.domain/Common/FileUtils.PathCombining.cs b/listenarr.domain/Common/FileUtils.PathCombining.cs index 964f1661e..c49af1241 100644 --- a/listenarr.domain/Common/FileUtils.PathCombining.cs +++ b/listenarr.domain/Common/FileUtils.PathCombining.cs @@ -196,33 +196,6 @@ private static string NormalizeFullPathForBoundary(string path) return fullPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); } - private static bool HasInvalidWindowsPathWhitespace(string path) - { - if (string.IsNullOrWhiteSpace(path)) - { - return true; - } - - var root = Path.GetPathRoot(path); - var pathWithoutRoot = !string.IsNullOrEmpty(root) && path.StartsWith(root, StringComparison.OrdinalIgnoreCase) - ? path[root.Length..] - : path; - - return pathWithoutRoot - .Split(new[] { '\\', '/' }, StringSplitOptions.None) - .Any(IsInvalidWindowsPathSegmentWhitespace); - } - - private static bool IsInvalidWindowsPathSegmentWhitespace(string segment) - { - if (string.IsNullOrEmpty(segment) || segment == "." || segment == "..") - { - return false; - } - - return segment.EndsWith(' ') || segment.EndsWith('.'); - } - /// /// Create a filesystem-safe name from arbitrary text by removing invalid path characters /// and normalizing whitespace. Keeps it conservative to avoid unexpected folder creation. diff --git a/listenarr.domain/Common/FileUtils.UserProvidedPaths.cs b/listenarr.domain/Common/FileUtils.UserProvidedPaths.cs index 072f2090c..4b5507f75 100644 --- a/listenarr.domain/Common/FileUtils.UserProvidedPaths.cs +++ b/listenarr.domain/Common/FileUtils.UserProvidedPaths.cs @@ -217,6 +217,23 @@ private static bool TryNormalizeUnixUserProvidedDirectoryPath( } } + /// + /// Detects values that visually look like absolute paths after accidental leading whitespace. + /// Do not trim user-provided paths before validation because Unix path-segment whitespace is valid. + /// + public static bool HasLeadingWhitespaceBeforeRootedPath(string? path) + { + if (string.IsNullOrEmpty(path) || !char.IsWhiteSpace(path[0])) + { + return false; + } + + var trimmedStart = path.TrimStart(); + return Path.IsPathRooted(trimmedStart) + || IsWindowsCurrentDriveRoot(trimmedStart) + || GetWindowsRootLength(trimmedStart) > 0; + } + private static bool IsWindowsCurrentDriveRoot(string path) { return path.Length == 1 && (path[0] is '\\' or '/'); diff --git a/listenarr.domain/Common/FileUtils.cs b/listenarr.domain/Common/FileUtils.cs index cccc499e9..fcd069a3b 100644 --- a/listenarr.domain/Common/FileUtils.cs +++ b/listenarr.domain/Common/FileUtils.cs @@ -63,12 +63,17 @@ public static bool IsPathInvalidForCurrentOs(string? path) public static bool IsPathInvalidForOs(string? path, bool isWindows) { - if (string.IsNullOrEmpty(path)) + if (string.IsNullOrEmpty(path) || !isWindows) { return false; } - return isWindows && HasInvalidWindowsPathWhitespace(path); + var rootLength = GetWindowsRootLength(path); + var pathWithoutRoot = rootLength > 0 ? path[rootLength..] : path; + return !ValidateWindowsDirectorySegments( + pathWithoutRoot, + rejectParentTraversal: false, + out _); } public static HashSet NormalizeExtensions(IEnumerable? extensions) diff --git a/listenarr.infrastructure/DownloadClients/Common/TorrentClientPathMapper.cs b/listenarr.infrastructure/DownloadClients/Common/TorrentClientPathMapper.cs index ed6b20acb..7fc7142ed 100644 --- a/listenarr.infrastructure/DownloadClients/Common/TorrentClientPathMapper.cs +++ b/listenarr.infrastructure/DownloadClients/Common/TorrentClientPathMapper.cs @@ -40,7 +40,7 @@ public static List BuildQbittorrentSourceFiles( .Where(name => !string.IsNullOrEmpty(name)) .Select(name => CombineClientReportedPath(savePath, name.Replace('/', Path.DirectorySeparatorChar))) .Where(path => !string.IsNullOrEmpty(path)) - .Distinct(StringComparer.OrdinalIgnoreCase) + .Distinct(StringComparer.Ordinal) .ToList(); } diff --git a/listenarr.infrastructure/DownloadClients/Qbittorrent/QbittorrentImportPathResolver.cs b/listenarr.infrastructure/DownloadClients/Qbittorrent/QbittorrentImportPathResolver.cs index 9b89d635c..3dbee9003 100644 --- a/listenarr.infrastructure/DownloadClients/Qbittorrent/QbittorrentImportPathResolver.cs +++ b/listenarr.infrastructure/DownloadClients/Qbittorrent/QbittorrentImportPathResolver.cs @@ -32,7 +32,7 @@ public static List TranslateSourceFiles(IEnumerable sourceFiles) { return sourceFiles .Where(path => !string.IsNullOrEmpty(path)) - .Distinct(StringComparer.OrdinalIgnoreCase) + .Distinct(StringComparer.Ordinal) .ToList(); } diff --git a/tests/Features/Api/Features/Library/LibraryController_AddToLibraryTests.cs b/tests/Features/Api/Features/Library/LibraryController_AddToLibraryTests.cs index dc7308d71..d995d8a23 100644 --- a/tests/Features/Api/Features/Library/LibraryController_AddToLibraryTests.cs +++ b/tests/Features/Api/Features/Library/LibraryController_AddToLibraryTests.cs @@ -281,6 +281,52 @@ public async Task AddToLibrary_WithCustomPath_StoresCustomPathAsBasePath() Assert.Equal(expectedPath, stored.BasePath); } + [Fact] + public async Task AddToLibrary_RejectsCustomPathWithLeadingWhitespaceBeforeAbsolutePath() + { + var controller = _provider.GetRequiredService(); + var customPath = " " + Path.Join(tempRoot, "custom", "audiobooks", "Author", "Title"); + var request = new LibraryController.AddToLibraryRequest + { + Metadata = new AudibleBookMetadata + { + Title = "Leading Space Path Test", + Author = "Custom Author" + }, + Monitored = true, + DestinationPath = customPath + }; + + var actionResult = await controller.AddToLibrary(request); + + var badRequest = Assert.IsType(actionResult); + Assert.Contains("leading whitespace", badRequest.Value.ToString(), StringComparison.OrdinalIgnoreCase); + Assert.Empty(await _audiobookRepository.GetAllAsync()); + } + + [Fact] + public async Task LibraryAddService_RejectsDestinationPathWithLeadingWhitespaceBeforeAbsolutePath() + { + var service = _provider.GetRequiredService(); + var customPath = " " + Path.Join(tempRoot, "custom", "audiobooks", "Author", "Title"); + var request = new LibraryAddOperationRequest + { + Metadata = new AudibleBookMetadata + { + Title = "Leading Space Service Path Test", + Author = "Custom Author" + }, + Monitored = true, + DestinationPath = customPath + }; + + var result = await service.AddToLibraryAsync(request); + + Assert.True(result.ValidationFailed); + Assert.Contains("leading whitespace", result.ValidationMessage, StringComparison.OrdinalIgnoreCase); + Assert.Empty(await _audiobookRepository.GetAllAsync()); + } + [Fact] public async Task AddToLibrary_RejectsCustomPathParentTraversal() { diff --git a/tests/Features/Api/Features/Library/LibraryController_MoveTests.cs b/tests/Features/Api/Features/Library/LibraryController_MoveTests.cs index 15fd44133..a217af9b0 100644 --- a/tests/Features/Api/Features/Library/LibraryController_MoveTests.cs +++ b/tests/Features/Api/Features/Library/LibraryController_MoveTests.cs @@ -320,6 +320,76 @@ await _rootFolderRepository.AddAsync(new RootFolderBuilder() Assert.Equal(FileUtils.NormalizeStoredPath(Path.Join(rootPath, relativeTarget)), updated.BasePath); } + [Fact] + [Trait("Method", "EnqueueMove")] + [Trait("Scenario", "RejectsDestinationPathWithLeadingWhitespaceBeforeAbsolutePath")] + public async Task MoveAudiobook_RejectsDestinationPathWithLeadingWhitespaceBeforeAbsolutePath() + { + var outputPath = FileService.GetTempDirectory("listenarr-move-output"); + await _applicationSettingsRepository.SaveAsync(new ApplicationSettingsBuilder() + .WithOutputPath(outputPath) + .Build()); + + var sourcePath = FileService.GetTempDirectory("listenarr-move-src"); + var audiobook = await _audiobookRepository.AddAsync(new AudiobookBuilder() + .WithTitle("Test") + .WithBasePath(sourcePath) + .Build()); + + var controller = _provider.GetRequiredService(); + var request = new LibraryController.MoveRequest + { + DestinationPath = " " + Path.Join(outputPath, "target"), + MoveFiles = false + }; + + var result = await controller.EnqueueMove(audiobook.Id, request); + + var badObj = Assert.IsAssignableFrom(result); + Assert.Equal(400, badObj.StatusCode); + Assert.Contains("leading whitespace", badObj.Value?.ToString() ?? string.Empty, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + [Trait("Method", "EnqueueMove")] + [Trait("Scenario", "AllowsCaseOnlyDestinationDifference_OnCaseSensitiveHosts")] + public async Task MoveAudiobook_AllowsCaseOnlyDestinationDifference_OnCaseSensitiveHosts() + { + if (OperatingSystem.IsWindows()) + { + return; + } + + var mockMoveQueue = new Mock(); + var expectedId = Guid.NewGuid(); + mockMoveQueue.Setup(m => m.EnqueueMoveAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(expectedId); + + Init(services => services.WithSingleton(mockMoveQueue.Object)); + var controller = _provider.GetRequiredService(); + + var outputPath = FileService.GetTempDirectory("listenarr-move-output"); + await _applicationSettingsRepository.SaveAsync(new ApplicationSettingsBuilder() + .WithOutputPath(outputPath) + .Build()); + + var sourcePath = Path.Join(outputPath, "CaseOnlyBook"); + Directory.CreateDirectory(sourcePath); + var audiobook = await _audiobookRepository.AddAsync(new AudiobookBuilder() + .WithTitle("Test") + .WithBasePath(sourcePath) + .Build()); + + var targetPath = Path.Join(outputPath, "caseonlybook"); + var request = new LibraryController.MoveRequest { DestinationPath = targetPath }; + + var result = await controller.EnqueueMove(audiobook.Id, request); + + var acceptedObj = Assert.IsAssignableFrom(result); + Assert.Equal(202, acceptedObj.StatusCode); + mockMoveQueue.Verify(m => m.EnqueueMoveAsync(audiobook.Id, FileUtils.NormalizeStoredPath(targetPath), sourcePath), Times.Once); + } + [Fact] [Trait("Method", "EnqueueMove")] [Trait("Scenario", "RejectsRelativeDestinationOutsideOutputPath")] diff --git a/tests/Features/Domain/Utils/FileUtilsTests.cs b/tests/Features/Domain/Utils/FileUtilsTests.cs index a75c59cd8..f507a3382 100644 --- a/tests/Features/Domain/Utils/FileUtilsTests.cs +++ b/tests/Features/Domain/Utils/FileUtilsTests.cs @@ -299,7 +299,11 @@ public void NormalizeStoredPath_DoesNotDropPrefix_WhenMalformedDriveSegmentAppea [InlineData(" folder", false)] [InlineData(@"C:\Program Files\Listenarr", false)] [InlineData(@"C:\media\folder \book.m4b", true)] - public void IsPathInvalidForOs_UsesWindowsWhitespaceRules(string path, bool expected) + [InlineData(@"C:\Books\NUL", true)] + [InlineData(@"C:\Books\COM1.txt", true)] + [InlineData(@"C:\Books\Bad|Name", true)] + [InlineData(@"C:\Books\..\Other", false)] + public void IsPathInvalidForOs_UsesSharedWindowsSegmentRules(string path, bool expected) { Assert.Equal(expected, FileUtils.IsPathInvalidForOs(path, isWindows: true)); } @@ -311,6 +315,7 @@ public void IsPathInvalidForOs_UsesWindowsWhitespaceRules(string path, bool expe [InlineData("folder name", false)] [InlineData(" folder", false)] [InlineData("/media/folder /book.m4b", false)] + [InlineData("/media/NUL", false)] public void IsPathInvalidForOs_AllowsLinuxWhitespacePaths(string path, bool expected) { Assert.Equal(expected, FileUtils.IsPathInvalidForOs(path, isWindows: false)); @@ -737,6 +742,19 @@ public void ContainsParentDirectorySegment_WithUnixSeparator_DoesNotTreatBacksla Assert.False(FileUtils.ContainsParentDirectorySegment("Book\\..\\Title", '/')); } + [Theory] + [InlineData(" /media/Author", true)] + [InlineData(" /media/Author", true)] + [InlineData(@" C:\Books\Author", true)] + [InlineData(@" \\server\share\Books", true)] + [InlineData(" Relative Folder", false)] + [InlineData("/media/Author ", false)] + [InlineData("/media/ Author", false)] + public void HasLeadingWhitespaceBeforeRootedPath_DetectsOnlyAmbiguousRootedInputs(string path, bool expected) + { + Assert.Equal(expected, FileUtils.HasLeadingWhitespaceBeforeRootedPath(path)); + } + [Fact] public void TryNormalizeUserProvidedDirectoryPathForOs_AllowsUnixRootWhenExplicitlyRequested() { diff --git a/tests/Features/Infrastructure/DownloadClients/Common/TorrentClientPathMapperTests.cs b/tests/Features/Infrastructure/DownloadClients/Common/TorrentClientPathMapperTests.cs index dbcb504d4..e8bdff5dd 100644 --- a/tests/Features/Infrastructure/DownloadClients/Common/TorrentClientPathMapperTests.cs +++ b/tests/Features/Infrastructure/DownloadClients/Common/TorrentClientPathMapperTests.cs @@ -80,6 +80,40 @@ public void BuildQbittorrentSourceFiles_PreservesTorrentFolderWhitespace() Assert.Equal([expected], sourceFiles); } + [Fact] + public void BuildQbittorrentSourceFiles_PreservesCaseDistinctSourceFiles() + { + var savePath = FileUtils.GetAbsolutePath("downloads"); + var files = ParseFiles( + """ + [ + { "name": "Book/chapter.m4b" }, + { "name": "Book/Chapter.m4b" } + ] + """); + + var sourceFiles = TorrentClientPathMapper.BuildQbittorrentSourceFiles(savePath, files); + + Assert.Equal( + [ + Path.Join(savePath, "Book", "chapter.m4b"), + Path.Join(savePath, "Book", "Chapter.m4b") + ], + sourceFiles); + } + + [Fact] + public void TranslateSourceFiles_PreservesCaseDistinctSourceFiles() + { + var savePath = FileUtils.GetAbsolutePath("downloads"); + var lower = Path.Join(savePath, "Book", "chapter.m4b"); + var upper = Path.Join(savePath, "Book", "Chapter.m4b"); + + var sourceFiles = QbittorrentImportPathResolver.TranslateSourceFiles([lower, upper]); + + Assert.Equal([lower, upper], sourceFiles); + } + [Fact] public void ResolveQbittorrentContentPath_PreservesSharedTopLevelFolderWhitespace() { From 823b029a0ab5236255e10fb364b9402c10ddc016 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Thu, 2 Jul 2026 15:36:05 -0400 Subject: [PATCH 007/464] Fix audiobook move workflow --- .../EditAudiobookModal.moveOptions.spec.ts | 167 ++++++++- fe/src/__tests__/utils/path.spec.ts | 120 +++++++ .../domain/audiobook/EditAudiobookModal.vue | 119 ++++-- fe/src/utils/path.ts | 256 ++++++++++++- .../Features/Library/LibraryMoveWorkflow.cs | 19 +- ...rastructureStartupCompositionExtensions.cs | 14 + .../Workers/WorkerRegistrationExtensions.cs | 1 + .../Moving/AudiobookContentMoveService.cs | 340 ++++++++++++++++++ .../Library/Moving/MoveJobProcessor.cs | 184 +--------- .../Persistence/MoveJobSchemaRepair.cs | 147 ++++++++ .../Repositories/EfMoveQueuePersistence.cs | 58 ++- tests/Builders/ServiceCollectionBuilder.cs | 1 + .../Architecture/BackendArchitectureTests.cs | 1 + .../AudiobookContentMoveServiceTests.cs | 160 +++++++++ .../Library/Moving/MoveJobProcessorTests.cs | 75 ++++ .../Persistence/MoveJobSchemaRepairTests.cs | 143 ++++++++ 16 files changed, 1570 insertions(+), 235 deletions(-) create mode 100644 listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.cs create mode 100644 listenarr.infrastructure/Persistence/MoveJobSchemaRepair.cs create mode 100644 tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceTests.cs create mode 100644 tests/Features/Infrastructure/Persistence/MoveJobSchemaRepairTests.cs diff --git a/fe/src/__tests__/EditAudiobookModal.moveOptions.spec.ts b/fe/src/__tests__/EditAudiobookModal.moveOptions.spec.ts index 30f882c7b..07b3ce975 100644 --- a/fe/src/__tests__/EditAudiobookModal.moveOptions.spec.ts +++ b/fe/src/__tests__/EditAudiobookModal.moveOptions.spec.ts @@ -86,6 +86,10 @@ describe('EditAudiobookModal move options', () => { const { apiService } = await import('@/services/api') expect(apiService.updateAudiobook).toHaveBeenCalledTimes(1) + expect(apiService.updateAudiobook).toHaveBeenCalledWith( + 1, + expect.objectContaining({ basePath: 'C:/root/New Author/New Book' }), + ) expect(apiService.moveAudiobook).toHaveBeenCalledTimes(0) }) @@ -117,14 +121,171 @@ describe('EditAudiobookModal move options', () => { const { apiService } = await import('@/services/api') expect(apiService.updateAudiobook).toHaveBeenCalledTimes(1) + expect(apiService.updateAudiobook).toHaveBeenCalledWith( + 1, + expect.not.objectContaining({ basePath: expect.anything() }), + ) expect(apiService.moveAudiobook).toHaveBeenCalledTimes(1) + expect(apiService.moveAudiobook).toHaveBeenCalledWith(1, 'C:/root/New Author/New Book', { + sourcePath: 'C:\\root\\Some Author\\Some Title', + moveFiles: true, + deleteEmptySource: true, + }) + }) + + it('Destination with parent traversal should be invalid and not call save APIs', async () => { + const wrapper = mount(EditAudiobookModal, { + props: { isOpen: true, audiobook }, + attachTo: document.body, + global: { plugins: [(await import('pinia')).createPinia()] }, + }) + + await new Promise((r) => setTimeout(r, 200)) + ;(wrapper.vm as unknown).selectedRootId = 0 + ;(wrapper.vm as unknown).customRootPath = 'C:\\root\\Some Author\\Some Title\\..' + await wrapper.vm.$nextTick() + + expect(wrapper.text()).toContain('Path traversal is not allowed in the destination folder') + expect( + wrapper.find('button[aria-label="Save destination"]').attributes('disabled'), + ).toBeDefined() + + await (wrapper.vm as unknown).handleSave() + await new Promise((r) => setTimeout(r, 50)) + + const { apiService } = await import('@/services/api') + expect(apiService.updateAudiobook).toHaveBeenCalledTimes(0) + expect(apiService.moveAudiobook).toHaveBeenCalledTimes(0) + }) + + it('Destination segment with trailing whitespace should be invalid and not call save APIs', async () => { + const wrapper = mount(EditAudiobookModal, { + props: { isOpen: true, audiobook }, + attachTo: document.body, + global: { plugins: [(await import('pinia')).createPinia()] }, + }) + + await new Promise((r) => setTimeout(r, 200)) + ;(wrapper.vm as unknown).selectedRootId = 0 + ;(wrapper.vm as unknown).customRootPath = 'C:\\root\\Some Author\\Some Title\\test ' + await wrapper.vm.$nextTick() + + expect(wrapper.text()).toContain( + 'Windows destination folder segments cannot end with a space or period', + ) + expect( + wrapper.find('button[aria-label="Save destination"]').attributes('disabled'), + ).toBeDefined() + + await (wrapper.vm as unknown).handleSave() + await new Promise((r) => setTimeout(r, 50)) + + const { apiService } = await import('@/services/api') + expect(apiService.updateAudiobook).toHaveBeenCalledTimes(0) + expect(apiService.moveAudiobook).toHaveBeenCalledTimes(0) + }) + + it('Destination inside current source should be allowed as a content move', async () => { + const wrapper = mount(EditAudiobookModal, { + props: { isOpen: true, audiobook }, + attachTo: document.body, + global: { plugins: [(await import('pinia')).createPinia()] }, + }) + + await new Promise((r) => setTimeout(r, 200)) + ;(wrapper.vm as unknown).selectedRootId = 0 + ;(wrapper.vm as unknown).customRootPath = 'C:\\root\\Some Author\\Some Title\\ test' + await wrapper.vm.$nextTick() + + expect(wrapper.text()).not.toContain('Source and destination folders cannot overlap') + expect( + wrapper.find('button[aria-label="Save destination"]').attributes('disabled'), + ).toBeUndefined() + + const savePromise = (wrapper.vm as unknown).handleSave() + await new Promise((r) => setTimeout(r, 10)) + const resolver = (wrapper.vm as unknown).moveConfirmResolver + if (resolver) resolver({ proceed: true, moveFiles: true, deleteEmptySource: true }) + await savePromise + await new Promise((r) => setTimeout(r, 50)) + + const { apiService } = await import('@/services/api') + expect(apiService.updateAudiobook).toHaveBeenCalledTimes(0) + expect(apiService.moveAudiobook).toHaveBeenCalledWith( + 1, + 'C:/root/Some Author/Some Title/ test', + { + sourcePath: 'C:\\root\\Some Author\\Some Title', + moveFiles: true, + deleteEmptySource: true, + }, + ) + }) + + it('Windows destination segment with leading whitespace outside source should be allowed', async () => { + const wrapper = mount(EditAudiobookModal, { + props: { isOpen: true, audiobook }, + attachTo: document.body, + global: { plugins: [(await import('pinia')).createPinia()] }, + }) + + await new Promise((r) => setTimeout(r, 200)) + ;(wrapper.vm as unknown).selectedRootId = 0 + ;(wrapper.vm as unknown).customRootPath = 'C:\\root\\Some Author\\Other Title\\ test' + await wrapper.vm.$nextTick() + + expect(wrapper.text()).not.toContain('Windows destination folder segments cannot end') + expect( + wrapper.find('button[aria-label="Save destination"]').attributes('disabled'), + ).toBeUndefined() + + const savePromise = (wrapper.vm as unknown).handleSave() + await new Promise((r) => setTimeout(r, 10)) + const resolver = (wrapper.vm as unknown).moveConfirmResolver + if (resolver) resolver({ proceed: true, moveFiles: true, deleteEmptySource: true }) + await savePromise + await new Promise((r) => setTimeout(r, 50)) + + const { apiService } = await import('@/services/api') expect(apiService.moveAudiobook).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - expect.objectContaining({ moveFiles: true, deleteEmptySource: true }), + 1, + 'C:/root/Some Author/Other Title/ test', + { + sourcePath: 'C:\\root\\Some Author\\Some Title', + moveFiles: true, + deleteEmptySource: true, + }, ) }) + it('Move-only destination changes should enqueue move without pre-saving BasePath', async () => { + const wrapper = mount(EditAudiobookModal, { + props: { isOpen: true, audiobook }, + attachTo: document.body, + global: { plugins: [(await import('pinia')).createPinia()] }, + }) + + await new Promise((r) => setTimeout(r, 200)) + ;(wrapper.vm as unknown).selectedRootId = 0 + ;(wrapper.vm as unknown).customRootPath = 'C:\\root\\New Author\\New Book' + await wrapper.vm.$nextTick() + + const savePromise = (wrapper.vm as unknown).handleSave() + await new Promise((r) => setTimeout(r, 10)) + const resolver = (wrapper.vm as unknown).moveConfirmResolver + if (resolver) resolver({ proceed: true, moveFiles: true, deleteEmptySource: true }) + await savePromise + await new Promise((r) => setTimeout(r, 50)) + + const { apiService } = await import('@/services/api') + expect(apiService.updateAudiobook).toHaveBeenCalledTimes(0) + expect(apiService.moveAudiobook).toHaveBeenCalledWith(1, 'C:/root/New Author/New Book', { + sourcePath: 'C:\\root\\Some Author\\Some Title', + moveFiles: true, + deleteEmptySource: true, + }) + }) + it('Edition-only changes should persist through updateAudiobook', async () => { const wrapper = mount(EditAudiobookModal, { props: { isOpen: true, audiobook }, diff --git a/fe/src/__tests__/utils/path.spec.ts b/fe/src/__tests__/utils/path.spec.ts index 88ece48b9..9ea589cfd 100644 --- a/fe/src/__tests__/utils/path.spec.ts +++ b/fe/src/__tests__/utils/path.spec.ts @@ -21,6 +21,17 @@ import { trimTrailingSlash, normalizeForCompare, isAbsolutePath, + hasRelativePathSegment, + hasParentTraversalSegment, + hasEmptyMiddlePathSegment, + hasControlCharacter, + hasOuterWhitespace, + hasPathSegmentOuterWhitespace, + hasWindowsTrailingSpaceOrPeriodSegment, + hasWindowsInvalidCharacter, + pathsOverlap, + hasWindowsReservedDeviceSegment, + validateLibraryDestinationPath, stripRootPrefix, } from '@/utils/path' @@ -46,6 +57,115 @@ describe('path utils', () => { expect(isAbsolutePath('relative/path')).toBe(false) }) + it('detects exact relative path segments without blocking periods in names', () => { + expect(hasRelativePathSegment('D:\\Books\\Title\\.')).toBe(true) + expect(hasRelativePathSegment('D:\\Books\\Title\\..')).toBe(true) + expect(hasRelativePathSegment('/books/./title')).toBe(true) + expect(hasRelativePathSegment('/books/../title')).toBe(true) + expect(hasRelativePathSegment('/books/Dr. Seuss')).toBe(false) + expect(hasRelativePathSegment('/books/.metadata')).toBe(false) + expect(hasRelativePathSegment('/books/title...')).toBe(false) + }) + + it('hasParentTraversalSegment detects parent directory traversal', () => { + expect(hasParentTraversalSegment('D:\\Books\\Title\\..')).toBe(true) + expect(hasParentTraversalSegment('/books/title/../other')).toBe(true) + expect(hasParentTraversalSegment('/books/title..')).toBe(false) + expect(hasParentTraversalSegment('/books/.../title')).toBe(false) + expect(hasParentTraversalSegment(null)).toBe(false) + }) + + it('detects empty middle path segments without rejecting roots', () => { + expect(hasEmptyMiddlePathSegment('D:\\Books\\\\Title')).toBe(true) + expect(hasEmptyMiddlePathSegment('/books//title')).toBe(true) + expect(hasEmptyMiddlePathSegment('D:\\Books\\Title')).toBe(false) + expect(hasEmptyMiddlePathSegment('/books/title')).toBe(false) + expect(hasEmptyMiddlePathSegment('D:\\')).toBe(false) + expect(hasEmptyMiddlePathSegment('\\\\server\\share\\Audiobooks')).toBe(false) + expect(hasEmptyMiddlePathSegment('\\\\server\\share\\\\Audiobooks')).toBe(true) + }) + + it('detects control characters and segment whitespace', () => { + expect(hasControlCharacter('D:\\Books\\Title\n')).toBe(true) + expect(hasControlCharacter('D:\\Books\\Title')).toBe(false) + expect(hasOuterWhitespace(' D:\\Books\\Title')).toBe(true) + expect(hasOuterWhitespace('D:\\Books\\Title ')).toBe(true) + expect(hasOuterWhitespace('D:\\Listenarr Test\\Title')).toBe(false) + expect(hasPathSegmentOuterWhitespace('D:\\Books\\test ')).toBe(true) + expect(hasPathSegmentOuterWhitespace('D:\\Books\\ test')).toBe(true) + expect(hasPathSegmentOuterWhitespace('D:\\Listenarr Test\\Title')).toBe(false) + }) + + it('detects Windows-only trailing space or period segments', () => { + expect(hasWindowsTrailingSpaceOrPeriodSegment('D:\\Books\\test ')).toBe(true) + expect(hasWindowsTrailingSpaceOrPeriodSegment('D:\\Books\\test.')).toBe(true) + expect(hasWindowsTrailingSpaceOrPeriodSegment('D:\\Books\\ test')).toBe(false) + expect(hasWindowsTrailingSpaceOrPeriodSegment('/books/test ')).toBe(false) + expect(hasWindowsTrailingSpaceOrPeriodSegment('/books/ test ')).toBe(false) + }) + + it('detects Windows invalid characters and reserved device names', () => { + expect(hasWindowsInvalidCharacter('D:\\Books\\Bad|Folder')).toBe(true) + expect(hasWindowsInvalidCharacter('D:\\Books\\Bad:Folder')).toBe(true) + expect(hasWindowsInvalidCharacter('D:\\Books\\Good Folder')).toBe(false) + expect(hasWindowsReservedDeviceSegment('D:\\Books\\CON')).toBe(true) + expect(hasWindowsReservedDeviceSegment('D:\\Books\\NUL.txt')).toBe(true) + expect(hasWindowsReservedDeviceSegment('D:\\Books\\COM1.folder')).toBe(true) + expect(hasWindowsReservedDeviceSegment('D:\\Books\\Concert')).toBe(false) + }) + + it('detects overlapping source and destination paths', () => { + expect(pathsOverlap('D:\\Books\\Title\\Child', 'D:\\Books\\Title', 'windows')).toBe(true) + expect(pathsOverlap('D:\\Books\\Title', 'D:\\Books\\Title\\Child', 'windows')).toBe(true) + expect(pathsOverlap('D:\\Books\\Title2', 'D:\\Books\\Title', 'windows')).toBe(false) + expect(pathsOverlap('/books/title/child', '/books/title', 'unix')).toBe(true) + expect(pathsOverlap('/books/title2', '/books/title', 'unix')).toBe(false) + }) + + it('validates library destination paths while allowing platform-valid whitespace', () => { + expect(validateLibraryDestinationPath('D:\\Books\\Title\\.')).toContain( + 'Path traversal is not allowed', + ) + expect(validateLibraryDestinationPath('D:\\Books\\Title\\..')).toContain( + 'Path traversal is not allowed', + ) + expect(validateLibraryDestinationPath('D:\\Books\\\\Title')).toContain('empty path segments') + expect(validateLibraryDestinationPath('D:\\Books\\Bad*Folder')).toContain('invalid on Windows') + expect(validateLibraryDestinationPath('D:\\Books\\CON.txt')).toContain('reserved Windows') + expect(validateLibraryDestinationPath('D:\\Books\\test ')).toContain( + 'cannot end with a space or period', + ) + expect(validateLibraryDestinationPath('D:\\Books\\test.')).toContain( + 'cannot end with a space or period', + ) + expect(validateLibraryDestinationPath('D:\\Books\\ test')).toBe(null) + expect(validateLibraryDestinationPath('/books/ test /')).toBe(null) + expect(validateLibraryDestinationPath('D:\\Books\\Dr. Seuss')).toBe(null) + expect(validateLibraryDestinationPath('D:\\Books\\.metadata')).toBe(null) + expect(validateLibraryDestinationPath('D:\\Books\\Title...')).toContain( + 'cannot end with a space or period', + ) + expect(validateLibraryDestinationPath('/books/Title...')).toBe(null) + expect( + validateLibraryDestinationPath('D:\\Books\\Title\\Child', { + pathKind: 'windows', + sourcePath: 'D:\\Books\\Title', + }), + ).toBe(null) + expect( + validateLibraryDestinationPath('/books/title/child', { + pathKind: 'unix', + sourcePath: '/books/title', + }), + ).toBe(null) + expect( + validateLibraryDestinationPath('D:\\Books', { + pathKind: 'windows', + sourcePath: 'D:\\Books\\Title', + }), + ).toBe(null) + }) + it('stripRootPrefix removes root prefix when present', () => { const root = 'C:\\temp\\Isaac Asimov\\Foundation' const full = 'C:\\temp\\Isaac Asimov\\Foundation\\Prelude to Foundation' diff --git a/fe/src/components/domain/audiobook/EditAudiobookModal.vue b/fe/src/components/domain/audiobook/EditAudiobookModal.vue index 7632ab9dd..ce964c0e1 100644 --- a/fe/src/components/domain/audiobook/EditAudiobookModal.vue +++ b/fe/src/components/domain/audiobook/EditAudiobookModal.vue @@ -504,8 +504,9 @@ type="button" class="btn icon-btn btn-primary btn-sm" @click="finishEditingDestination" + :disabled="Boolean(destinationPathValidationError)" aria-label="Save destination" - title="Done" + :title="destinationPathValidationError || 'Done'" > @@ -524,6 +525,10 @@ organizing within the selected root.

+
+ + {{ destinationPathValidationError }} +
@@ -735,8 +740,8 @@ type="button" class="btn btn-primary" @click="handleSave" - :disabled="saving || !hasChanges" - :title="saving ? 'Saving...' : 'Save'" + :disabled="saving || !hasChanges || Boolean(destinationPathValidationError)" + :title="saving ? 'Saving...' : destinationPathValidationError || 'Save'" :aria-label="saving ? 'Saving' : 'Save'" > Saving... @@ -1480,6 +1485,9 @@ import { trimTrailingSlash, normalizeForCompare, isAbsolutePath, + validateLibraryDestinationPath, + detectPathKind, + type PathKind, stripRootPrefix, } from '@/utils/path' @@ -1501,9 +1509,15 @@ function resolveSelectedRootPath(): string | null { return rootPath.value || null } +function selectedDestinationPathKind(): PathKind { + const root = + resolveSelectedRootPath() || rootPath.value || baselineAudiobook.value?.basePath || '' + return detectPathKind(root) +} + function combinedBasePath(): string | null { const r = resolveSelectedRootPath() || '' - const rel = (formData.value.relativePath || '').trim() + const rel = formData.value.relativePath || '' if (!r && !rel) return null if (!r) return rel @@ -1511,9 +1525,9 @@ function combinedBasePath(): string | null { // input as the exact destination where files should be stored. Do NOT // append the relative or naming pattern — return the custom root exactly. if (selectedRootId.value === 0) { - let out = toForward(r) - out = trimTrailingSlash(out) - return out + const pathKind = detectPathKind(r) + const normalized = pathKind === 'windows' ? toForward(r) : r + return trimTrailingSlash(normalized) } if (!rel) return r @@ -1522,9 +1536,20 @@ function combinedBasePath(): string | null { return r + (needsSep ? sep : '') + rel } -// Path-length warning for the destination path +// Path-length warning and validation for the destination path const editDestinationPath = computed(() => combinedBasePath() || '') const { pathLengthWarning: destinationPathWarning } = usePathLengthCheck(editDestinationPath) +const destinationPathValidationError = computed(() => { + const destination = editDestinationPath.value + const source = baselineAudiobook.value?.basePath || '' + const pathKind = selectedDestinationPathKind() + const basePathChanged = destination !== source + + return validateLibraryDestinationPath(destination, { + pathKind, + sourcePath: basePathChanged ? source : null, + }) +}) // Helper: derive relative path from full base and configured root (moved to module scope so it can be reused) function deriveRelativeFromBase( @@ -1534,14 +1559,17 @@ function deriveRelativeFromBase( if (!base) return '' if (!root) return base - const normBase = toForward(base) - const normRoot = toForward(root) + const pathKind = detectPathKind(root) + const normBase = pathKind === 'windows' ? toForward(base) : base + const normRoot = pathKind === 'windows' ? toForward(root) : root const rootWithSlash = normRoot.endsWith('/') ? normRoot : normRoot + '/' - if (normalizeForCompare(normBase) === normalizeForCompare(normRoot)) return '' - if (normalizeForCompare(normBase).startsWith(normalizeForCompare(rootWithSlash))) { + if (normalizeForCompare(normBase, pathKind) === normalizeForCompare(normRoot, pathKind)) return '' + if ( + normalizeForCompare(normBase, pathKind).startsWith(normalizeForCompare(rootWithSlash, pathKind)) + ) { const rel = normBase.slice(rootWithSlash.length).replace(/^\/+/, '') - const useBackslash = root.includes('\\') + const useBackslash = pathKind === 'windows' && root.includes('\\') return useBackslash ? rel.replace(/\//g, '\\') : rel } @@ -1582,9 +1610,14 @@ function startEditingDestination() { * an absolute/full path. This makes the UI stable when toggling edit mode. */ function finishEditingDestination() { + if (destinationPathValidationError.value) { + toast.error('Invalid destination', destinationPathValidationError.value) + return + } + try { const chosenRoot = resolveSelectedRootPath() || rootPath.value - const val = (formData.value.relativePath || '').trim() + const val = formData.value.relativePath || '' if (!chosenRoot) { // No root available — nothing to do @@ -1614,7 +1647,10 @@ function finishEditingDestination() { const relOrVal = formData.value.relativePath || val || '' if ( isAbsolute || - (relOrVal && normalizeForCompare(relOrVal).startsWith(normalizeForCompare(chosenRoot || ''))) + (relOrVal && + normalizeForCompare(relOrVal, detectPathKind(chosenRoot)).startsWith( + normalizeForCompare(chosenRoot || '', detectPathKind(chosenRoot)), + )) ) { formData.value.relativePath = deriveRelativeFromBase( relOrVal || formData.value.basePath || '', @@ -1637,9 +1673,32 @@ async function handleSave() { // If the base path (destination) changed, prompt the user with rich options const combined = combinedBasePath() const originalBase = audiobook.basePath || '' + const pathKind = selectedDestinationPathKind() + const basePathChanged = (combined || '') !== originalBase + const destinationValidationMessage = validateLibraryDestinationPath(combined, { + pathKind, + sourcePath: basePathChanged ? originalBase : null, + }) + if (destinationValidationMessage) { + toast.error('Invalid destination', destinationValidationMessage) + return + } + if ( + basePathChanged && + combined && + originalBase && + normalizeForCompare(combined, pathKind) === normalizeForCompare(originalBase, pathKind) + ) { + toast.error( + 'Invalid destination', + 'Destination folder must be different from the current source folder.', + ) + return + } + let userWantsMove = true let userWantsDeleteEmpty = true - if ((combined || '') !== originalBase) { + if (basePathChanged) { const choice = await askMoveConfirmation(originalBase || '', combined || '') if (!choice || !choice.proceed) return userWantsMove = Boolean(choice.moveFiles) @@ -1692,8 +1751,11 @@ async function handleSave() { updates.runtime = parsedRuntime } - // If user changed destination/base path, include the combined root+relative value in updates - if ((combined || '') !== (audiobook.basePath || '')) { + const shouldPersistBasePathImmediately = basePathChanged && !userWantsMove + + // Physical moves are committed by the move worker after the filesystem operation succeeds. + // Pre-saving BasePath here can leave the library pointing at the destination when enqueue fails. + if (shouldPersistBasePathImmediately) { ;(updates as Partial).basePath = combined ?? undefined } @@ -1740,7 +1802,7 @@ async function handleSave() { JSON.stringify([...(audiobook.tags || [])].sort()) || formData.value.abridged !== Boolean(audiobook.abridged) || formData.value.explicit !== Boolean(audiobook.explicit) || - (combined || '') !== (audiobook.basePath || '') + shouldPersistBasePathImmediately if (hasNonIdentifierChanges) { await apiService.updateAudiobook(audiobook.id, updates) @@ -1755,7 +1817,7 @@ async function handleSave() { } // If base path changed, either update DB without moving or enqueue server-side move and show progress via SignalR - if ((combined || '') !== (audiobook.basePath || '')) { + if (basePathChanged) { if (!userWantsMove) { // User requested a DB-only change toast.info('Destination updated', 'Destination changed without moving files.') @@ -1805,6 +1867,7 @@ async function handleSave() { } catch (moveErr) { console.error('Failed to enqueue move job:', moveErr) toast.error('Move failed', 'Failed to enqueue move job. Please try again.') + return } } } @@ -2023,17 +2086,27 @@ function close() { diff --git a/fe/src/components/domain/collection/BulkEditModal.vue b/fe/src/components/domain/collection/BulkEditModal.vue index da8490fbf..349b2c7fb 100644 --- a/fe/src/components/domain/collection/BulkEditModal.vue +++ b/fe/src/components/domain/collection/BulkEditModal.vue @@ -88,16 +88,11 @@
- +

- Select a named root or provide an absolute custom root within a configured root - folder or output path. If you choose "Use default" and no named default exists, the - application default output path will be used. + Select a configured root. If you choose "Use default" and no named default exists, + the application default output path will be used.

Destination root: {{ resolvedRootPath }} @@ -195,8 +190,7 @@ interface FormData { qualityProfileId: number | null // root change controls rootChangeEnabled: boolean - rootId: number | null | 0 - rootCustomPath: string | null + rootId: number | null } const props = defineProps() @@ -206,7 +200,6 @@ const emit = defineEmits<{ }>() const qualityProfiles = ref([]) -const rootFolders = ref([]) const rootStore = useRootFoldersStore() const moveJobsStore = useMoveJobsStore() const saving = ref(false) @@ -231,15 +224,10 @@ const formData = ref({ qualityProfileId: null, rootChangeEnabled: false, rootId: null, - rootCustomPath: null, }) const resolvedRootPath = computed(() => { if (!formData.value.rootChangeEnabled) return null - if (formData.value.rootId === 0) { - const customPath = formData.value.rootCustomPath - return customPath && customPath.trim().length > 0 ? customPath : null - } if (formData.value.rootId && formData.value.rootId > 0) { return rootStore.folders.find((folder) => folder.id === formData.value.rootId)?.path ?? null } @@ -250,9 +238,7 @@ const hasChanges = computed(() => { return ( formData.value.monitored !== null || formData.value.qualityProfileId !== null || - (formData.value.rootChangeEnabled === true && - (formData.value.rootId !== null || - (formData.value.rootCustomPath && formData.value.rootCustomPath.length > 0))) + formData.value.rootChangeEnabled === true ) }) @@ -281,16 +267,12 @@ async function loadData() { // Load root folders from configuration await rootStore.load() if (rootStore.folders.length > 0) { - rootFolders.value = rootStore.folders.map((f) => f.path) // Capture default output path for fallback when user picks "Use default" const def = rootStore.folders.find((f) => f.isDefault) defaultOutputPath.value = def?.path ?? null } else { const appSettings = await apiService.getApplicationSettings() - if (appSettings.outputPath) { - rootFolders.value = [appSettings.outputPath] - defaultOutputPath.value = appSettings.outputPath - } + defaultOutputPath.value = appSettings.outputPath || null } } catch (error) { console.error('Failed to load bulk edit data:', error) @@ -303,7 +285,6 @@ function resetForm() { qualityProfileId: null, rootChangeEnabled: false, rootId: null, - rootCustomPath: null, } } diff --git a/fe/src/components/form/RootFolderSelect.vue b/fe/src/components/form/RootFolderSelect.vue index e91bd0816..d52f1341f 100644 --- a/fe/src/components/form/RootFolderSelect.vue +++ b/fe/src/components/form/RootFolderSelect.vue @@ -24,96 +24,59 @@ Loading root folders...

-
-
- -
+
+
@@ -124,6 +87,7 @@ function onChange(e: Event) { flex-direction: column; gap: 0.5rem; } + .root-select-content.inline { display: flex; gap: 0.5rem; @@ -132,15 +96,11 @@ function onChange(e: Event) { width: 100%; } -.recent-paths { - width: 100%; -} - .form-select { padding: 0.75rem 1rem; height: 40px; box-sizing: border-box; - background-color: #1a1a1a; /* match input background */ + background-color: #1a1a1a; border: 1px solid #333; border-radius: 6px; color: white; @@ -155,14 +115,6 @@ function onChange(e: Event) { box-shadow: 0 0 0 3px rgba(var(--brand-rgb), 0.2); } -.form-input { - padding: 0.6rem 0.75rem; - background-color: #1a1a1a; - border: 1px solid #333; - color: white; - border-radius: 6px; -} - .loading-row { display: flex; align-items: center; diff --git a/fe/src/services/api.ts b/fe/src/services/api.ts index 420e2569c..6b6e046ef 100644 --- a/fe/src/services/api.ts +++ b/fe/src/services/api.ts @@ -1295,6 +1295,8 @@ class ApiService { status: string target?: string error?: string + recoveryDisposition?: string + canRetry?: boolean }> { const job = await this.request<{ id: string @@ -1307,6 +1309,7 @@ class ApiService { enqueuedAt?: string updatedAt?: string nextAttemptAt?: string + recoveryDisposition?: string canRetry?: boolean }>('/library/move/' + encodeURIComponent(jobId)) @@ -1316,9 +1319,31 @@ class ApiService { status: job.status, target: job.requestedPath, error: job.error, + recoveryDisposition: job.recoveryDisposition, + canRetry: job.canRetry, } } + async getMoveRecoveryState(audiobookId: number): Promise<{ + hasUnresolvedMove: boolean + disposition: string + jobId?: string | null + status?: string | null + phase?: string | null + requestedPath?: string | null + error?: string | null + canRetry: boolean + blockingJobIds: string[] + }> { + return this.request(`/library/${audiobookId}/move/recovery`) + } + + async requeueMoveJob(jobId: string): Promise<{ message: string; jobId: string }> { + return this.request(`/library/move/requeue/${encodeURIComponent(jobId)}`, { + method: 'POST', + }) + } + async removeFromLibrary( id: number, options?: { deleteFiles?: boolean; deleteFolder?: boolean }, @@ -1452,12 +1477,16 @@ class ApiService { return this.request(`/library/manual-import/preview${params}`) } - async startManualImport( - request: ManualImportRequest, - ): Promise<{ importedCount: number; totalCount?: number; results?: ManualImportResult[] }> { + async startManualImport(request: ManualImportRequest): Promise<{ + importedCount: number + totalCount?: number + stoppedByCancellation?: boolean + results?: ManualImportResult[] + }> { return this.request<{ importedCount: number totalCount?: number + stoppedByCancellation?: boolean results?: ManualImportResult[] }>(`/library/manual-import`, { method: 'POST', diff --git a/fe/src/services/apiErrors.ts b/fe/src/services/apiErrors.ts index 4372f1bb9..2ffde7b01 100644 --- a/fe/src/services/apiErrors.ts +++ b/fe/src/services/apiErrors.ts @@ -3,6 +3,11 @@ export interface ApiValidationErrorPayload { field?: string message: string resolvedDestination?: string | null + jobId?: string + status?: string + requestedPath?: string + recoveryDisposition?: string + canRetry?: boolean } type ApiErrorWithBody = Error & { @@ -34,6 +39,12 @@ export function getApiValidationError( typeof payload.resolvedDestination === 'string' || payload.resolvedDestination === null ? payload.resolvedDestination : undefined, + jobId: typeof payload.jobId === 'string' ? payload.jobId : undefined, + status: typeof payload.status === 'string' ? payload.status : undefined, + requestedPath: typeof payload.requestedPath === 'string' ? payload.requestedPath : undefined, + recoveryDisposition: + typeof payload.recoveryDisposition === 'string' ? payload.recoveryDisposition : undefined, + canRetry: typeof payload.canRetry === 'boolean' ? payload.canRetry : undefined, } } catch { return null diff --git a/fe/src/stores/configuration.ts b/fe/src/stores/configuration.ts index fd449328a..dd2eadd6e 100644 --- a/fe/src/stores/configuration.ts +++ b/fe/src/stores/configuration.ts @@ -124,11 +124,13 @@ export const useConfigurationStore = defineStore('configuration', () => { try { const settings = await apiService.getApplicationSettings() applicationSettings.value = settings + return settings } catch (error) { errorTracking.captureException(error as Error, { component: 'ConfigurationStore', operation: 'loadApplicationSettings', }) + return null } finally { isLoading.value = false } @@ -138,6 +140,7 @@ export const useConfigurationStore = defineStore('configuration', () => { try { const savedSettings = await apiService.saveApplicationSettings(settings) applicationSettings.value = savedSettings + return savedSettings } catch (error) { errorTracking.captureException(error as Error, { component: 'ConfigurationStore', diff --git a/fe/src/stores/moveJobs.ts b/fe/src/stores/moveJobs.ts index 1d23597aa..388b60f6b 100644 --- a/fe/src/stores/moveJobs.ts +++ b/fe/src/stores/moveJobs.ts @@ -37,6 +37,20 @@ export interface TrackedMoveJob { status: MoveJobStatus target?: string error?: string + recoveryDisposition?: string + canRetry?: boolean +} + +export interface MoveRecoveryState { + hasUnresolvedMove: boolean + disposition: string + jobId?: string + status?: MoveJobStatus + phase?: string + requestedPath?: string + error?: string + canRetry: boolean + blockingJobIds: string[] } type MoveJobUpdate = { @@ -45,6 +59,8 @@ type MoveJobUpdate = { status?: string target?: string error?: string + recoveryDisposition?: string + canRetry?: boolean } const terminalStatuses = new Set([ @@ -86,6 +102,48 @@ export const useMoveJobsStore = defineStore('moveJobs', () => { const trackedJobs = computed(() => Object.values(trackedById.value)) + function getActiveJobForAudiobook(audiobookId: number): TrackedMoveJob | undefined { + return trackedJobs.value.find( + (job) => job.audiobookId === audiobookId && !terminalStatuses.has(job.status), + ) + } + + async function getRecoveryStateForAudiobook(audiobookId: number): Promise { + const response = await apiService.getMoveRecoveryState(audiobookId) + const status = response.status ? normalizeStatus(response.status) : null + return { + hasUnresolvedMove: Boolean(response.hasUnresolvedMove), + disposition: response.disposition, + jobId: response.jobId || undefined, + status: status ?? undefined, + phase: response.phase || undefined, + requestedPath: response.requestedPath || undefined, + error: response.error || undefined, + canRetry: Boolean(response.canRetry), + blockingJobIds: response.blockingJobIds || [], + } + } + + async function requeueMoveJob( + jobId: string, + audiobookId?: number, + target?: string, + ): Promise { + const response = await apiService.requeueMoveJob(jobId) + const requeuedJobId = response.jobId?.trim() + if (!requeuedJobId) { + throw new Error('The server did not return a durable move job ID.') + } + + trackQueuedJob({ + jobId: requeuedJobId, + audiobookId, + target, + status: 'Queued', + }) + return requeuedJobId + } + function start() { if (unsubscribe) { return @@ -171,6 +229,8 @@ export const useMoveJobsStore = defineStore('moveJobs', () => { status, target: update.target ?? existing.target, error: update.error, + recoveryDisposition: update.recoveryDisposition ?? existing.recoveryDisposition, + canRetry: update.canRetry ?? existing.canRetry, } trackedById.value[key] = next @@ -200,6 +260,9 @@ export const useMoveJobsStore = defineStore('moveJobs', () => { return { trackedJobs, trackedById, + getActiveJobForAudiobook, + getRecoveryStateForAudiobook, + requeueMoveJob, start, stop, trackQueuedJob, diff --git a/fe/src/stores/rootFolders.ts b/fe/src/stores/rootFolders.ts index 308a385d9..785f6b9db 100644 --- a/fe/src/stores/rootFolders.ts +++ b/fe/src/stores/rootFolders.ts @@ -88,16 +88,17 @@ export const useRootFoldersStore = defineStore('rootFolders', () => { const requestedMode = payload.caseSensitivityMode ?? current.caseSensitivityMode ?? 'Auto' const hasPathChange = rootFolderPathChanged(current, payload.path) + const hasSemanticsChange = requestedMode !== (current.caseSensitivityMode ?? 'Auto') let pathChangeError: string | null = null - if (hasPathChange) { - if (opts?.pathChangeConfirmed !== true) { + if (hasPathChange || hasSemanticsChange) { + if (hasPathChange && opts?.pathChangeConfirmed !== true) { throw new Error('Root folder path change requires confirmation') } const result = await apiService.changeRootFolderPath(id, { - targetPath: payload.path, - mode: opts?.moveFiles === false ? 'metadataOnly' : 'relocate', - deleteEmptySource: opts?.deleteEmptySource !== false, + targetPath: hasPathChange ? payload.path : current.path, + mode: hasPathChange && opts?.moveFiles !== false ? 'relocate' : 'metadataOnly', + deleteEmptySource: hasPathChange && opts?.deleteEmptySource !== false, desiredName: payload.name, desiredIsDefault: payload.isDefault === true, targetCaseSensitivityMode: requestedMode, diff --git a/fe/src/types/index.ts b/fe/src/types/index.ts index 16021102e..36057c0ee 100644 --- a/fe/src/types/index.ts +++ b/fe/src/types/index.ts @@ -324,6 +324,7 @@ export interface TranslatePathResponse { } export interface ApplicationSettings { + version: number outputPath: string folderNamingPattern: string fileNamingPattern: string @@ -992,13 +993,12 @@ export interface ManualImportRequest { export interface ManualImportResult { success: boolean - skipped?: boolean - skipReason?: string - filePath?: string + sourcePath?: string destinationPath?: string - audiobookId?: number - audiobookTitle?: string + audiobook?: Audiobook error?: string + skipped?: boolean + skipReason?: string } // Audible API Types diff --git a/fe/src/utils/path.ts b/fe/src/utils/path.ts index e9d08b2f9..2e61479d6 100644 --- a/fe/src/utils/path.ts +++ b/fe/src/utils/path.ts @@ -161,6 +161,18 @@ export function isAbsolutePath(s: string, pathKind: PathKind = 'unknown'): boole return /^([a-zA-Z]:[\\/]|[\\/])/.test(s) } +/** + * Returns true when an input begins from a filesystem root rather than being + * relative to a selected authority. Windows root-relative values such as + * `\\Author\\Title` count as rooted even though they do not include a drive. + */ +export function isRootedPath(s: string, pathKind: PathKind = 'unknown'): boolean { + const kind = pathKind === 'unknown' ? detectPathKind(s) : pathKind + if (kind === 'unix') return s.startsWith('/') + if (kind === 'windows') return /^[a-zA-Z]:[\\/]/.test(s) || /^[\\/]/.test(s) + return /^([a-zA-Z]:[\\/]|[\\/])/.test(s) +} + export function isFileSystemRoot( s: string | null | undefined, pathKind: PathKind = 'unknown', diff --git a/fe/src/views/SettingsView.vue b/fe/src/views/SettingsView.vue index 11e4e2ca5..6d5cd400d 100644 --- a/fe/src/views/SettingsView.vue +++ b/fe/src/views/SettingsView.vue @@ -256,6 +256,12 @@ v-if="activeTab === 'notifications' && settings" ref="notificationsRef" :settings="settings" + @update:settings=" + (v) => { + settings = v + configStore.applicationSettings = v + } + " />
@@ -440,15 +446,6 @@ const router = useRouter() const configStore = useConfigurationStore() const auth = useAuthStore() const toast = useToast() -// Debug environment markers (Vitest exposes import.meta.vitest / import.meta.env.VITEST) -logger.debug( - '[test-debug] import.meta.vitest:', - (import.meta as unknown as { vitest?: unknown }).vitest, - 'env.VITEST:', - (import.meta as unknown as { env?: Record }).env?.VITEST, - '__vitest_global__:', - (globalThis as unknown as { __vitest?: unknown }).__vitest, -) const activeTab = ref< 'rootfolders' | 'indexers' | 'clients' | 'quality-profiles' | 'notifications' | 'bot' | 'general' >('rootfolders') @@ -804,25 +801,11 @@ const saveSettings = async () => { // No PascalCase keys are produced anymore; we only send camelCase properties. - // Resolve the configuration store at call-time to ensure tests that set up Pinia - // before mounting (or that replace the store) receive the correct instance. - const runtimeConfigStore = useConfigurationStore() - // Debug: log when saveSettings is invoked in tests to help diagnose test failures - // (will be removed once tests are stable) - logger.debug('[test-debug] saveSettings invoked', settingsToSave) - // Call the runtime store save method. Some test setups replace the store - // instance or spy on the store returned from `useConfigurationStore()` at - // different times; call both if they differ to ensure the spy is observed. - await runtimeConfigStore.saveApplicationSettings(settingsToSave) - if ( - configStore !== runtimeConfigStore && - typeof configStore.saveApplicationSettings === 'function' - ) { - // If the module-level `configStore` differs (older test setups), call it too - // so tests that replaced/observed that instance receive the call. - // Avoid failing if the method isn't a function. - configStore.saveApplicationSettings(settingsToSave) - } + // The backend uses an optimistic-concurrency version for the singleton settings row. + // Submit exactly once through this component's store instance so the same versioned + // payload cannot race itself and produce a false stale-write conflict. + const savedSettings = await configStore.saveApplicationSettings(settingsToSave) + settings.value = savedSettings toast.success('Settings', 'Settings saved successfully') // If user toggled the authEnabled, attempt to save to startup config try { @@ -951,6 +934,16 @@ const saveSettings = async () => { component: 'SettingsView', operation: 'saveSettings', }) + + // The backend intentionally preserves non-admin settings if admin provisioning + // fails after the singleton settings row commits. A stale-version conflict can + // likewise mean another writer already advanced the row. Reload before the next + // edit so this view never retries with a concurrency token that may be obsolete. + const reloadedSettings = await configStore.loadApplicationSettings() + if (reloadedSettings) { + settings.value = reloadedSettings + } + const errorMessage = formatApiError(error) toast.error('Save failed', errorMessage) } diff --git a/fe/src/views/settings/NotificationsTab.vue b/fe/src/views/settings/NotificationsTab.vue index f6b3d165f..7101f04c0 100644 --- a/fe/src/views/settings/NotificationsTab.vue +++ b/fe/src/views/settings/NotificationsTab.vue @@ -411,6 +411,9 @@ import { apiService } from '@/services/api' const props = defineProps<{ settings: ApplicationSettings | null }>() +const emit = defineEmits<{ + 'update:settings': [value: ApplicationSettings] +}>() const toast = useToast() const configStore = useConfigurationStore() @@ -987,16 +990,20 @@ const testWebhookConfig = async () => { // Persist webhooks to backend settings (do not mutate incoming props) const persistWebhooks = async () => { // Create a shallow copy of settings and assign updated webhooks - const current = props.settings - ? { ...(props.settings as unknown as Record) } - : {} + const current = configStore.applicationSettings ?? props.settings + if (!current) { + throw new Error('Application settings are unavailable') + } try { const payload: ApplicationSettings = { - ...(current as unknown as ApplicationSettings), + ...current, webhooks: webhooks.value, } - // Save to backend using the configuration store - await configStore.saveApplicationSettings(payload) + // Save from the latest committed snapshot so repeated webhook edits carry + // the current optimistic-concurrency version. Propagate the returned snapshot + // to the parent because the backend increments that version on every save. + const savedSettings = await configStore.saveApplicationSettings(payload) + emit('update:settings', savedSettings) } catch (error) { errorTracking.captureException(error as Error, { component: 'NotificationsTab', diff --git a/listenarr.api/Features/Configuration/SettingsController.cs b/listenarr.api/Features/Configuration/SettingsController.cs index 130be42ba..b1431e735 100644 --- a/listenarr.api/Features/Configuration/SettingsController.cs +++ b/listenarr.api/Features/Configuration/SettingsController.cs @@ -17,6 +17,7 @@ */ using Listenarr.Api.Attributes; +using Listenarr.Application.Common.Exceptions; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Caching.Memory; using System.Text.Json; @@ -83,7 +84,7 @@ public async Task> SaveApplicationSettings([Fr await _configurationService.SaveApplicationSettingsAsync(settings); _cache?.Remove("default-search-region"); - var savedSettings = PrepareApplicationSettingsResponse(await _configurationService.GetApplicationSettingsAsync()); + var savedSettings = PrepareApplicationSettingsResponse(settings); savedSettings.AdminUsername = null; savedSettings.AdminPassword = null; @@ -100,10 +101,17 @@ await _hubBroadcaster.BroadcastAsync( return Ok(savedSettings); } + catch (ApplicationConflictException ex) + { + _logger.LogInformation( + ex, + "Application settings save rejected because the client version is stale or missing"); + return Conflict(new { code = ex.Code, message = ex.SafeDetail }); + } catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) { _logger.LogError(ex, "Error saving application settings"); - return StatusCode(500, new { error = "Failed to save application settings", message = ex.Message }); + return StatusCode(500, new { error = "Failed to save application settings" }); } } diff --git a/listenarr.api/Features/Downloads/ManualImportController.Coordination.cs b/listenarr.api/Features/Downloads/ManualImportController.Coordination.cs index f639a39c0..6599bfc27 100644 --- a/listenarr.api/Features/Downloads/ManualImportController.Coordination.cs +++ b/listenarr.api/Features/Downloads/ManualImportController.Coordination.cs @@ -11,10 +11,24 @@ private Task ExecuteWithAudiobookLocksAsync( CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(operation); + var lockedAudiobookIds = audiobookIds + .Distinct() + .OrderBy(id => id) + .ToArray(); return _filesystemMutationCoordinator.ExecuteExclusiveAsync( globalToken => _audiobookOperationCoordinator.ExecuteExclusiveAsync( - audiobookIds, - operation, + lockedAudiobookIds, + async operationToken => + { + foreach (var audiobookId in lockedAudiobookIds) + { + await _moveQueueService.EnsureFilesystemMutationAllowedAsync( + audiobookId, + operationToken); + } + + await operation(operationToken); + }, globalToken), cancellationToken); } @@ -99,6 +113,14 @@ await _scanPathAuthorizationService.AuthorizeAsync( }, cancellationToken); } + catch (OperationCanceledException exception) when ( + !cancellationToken.CanBeCanceled) + { + _logger.LogWarning( + exception, + "Manual import completed for audiobook {AudiobookId}, but its focused scan was canceled after commit", + group.Key); + } catch (Exception exception) when ( WorkerExceptionClassifier.IsNonFatal(exception)) { diff --git a/listenarr.api/Features/Downloads/ManualImportController.ProcessItem.cs b/listenarr.api/Features/Downloads/ManualImportController.ProcessItem.cs index 8a976a77a..97949836d 100644 --- a/listenarr.api/Features/Downloads/ManualImportController.ProcessItem.cs +++ b/listenarr.api/Features/Downloads/ManualImportController.ProcessItem.cs @@ -237,6 +237,25 @@ await _audiobookFileService }; } + if (!string.IsNullOrWhiteSpace(audiobook.Asin)) + { + try + { + await _metadataService.WriteAsinTagAsync( + registrationLease, + audiobook.Asin); + } + catch (Exception exception) when (exception is not ( + OutOfMemoryException or StackOverflowException)) + { + _logger.LogWarning( + exception, + "Manual import completed, but generation-bound ASIN tag enrichment failed for audiobook {AudiobookId} at {Path}", + audiobook.Id, + LogRedaction.SanitizeFilePath(destinationPath)); + } + } + var completion = registrationLease.CompletePublication(); if (completion == RegistrationPublicationCompletion.CommittedCleanupPending) @@ -249,24 +268,6 @@ await _audiobookFileService } destinationTracker.Commit(destinationReservation); - if (!string.IsNullOrWhiteSpace(audiobook.Asin)) - { - try - { - await _metadataService.WriteAsinTagAsync( - destinationPath, - audiobook.Asin); - } - catch (Exception exception) when (exception is not ( - OutOfMemoryException or StackOverflowException)) - { - _logger.LogWarning( - exception, - "Manual import completed, but ASIN tag enrichment failed for audiobook {AudiobookId} at {Path}", - audiobook.Id, - LogRedaction.SanitizeFilePath(destinationPath)); - } - } return new ManualImportResultDto { diff --git a/listenarr.api/Features/Downloads/ManualImportController.cs b/listenarr.api/Features/Downloads/ManualImportController.cs index 1ffe806f0..639440fd0 100644 --- a/listenarr.api/Features/Downloads/ManualImportController.cs +++ b/listenarr.api/Features/Downloads/ManualImportController.cs @@ -16,6 +16,7 @@ * along with this program. If not, see . */ using Microsoft.AspNetCore.Mvc; +using Listenarr.Application.Common.Exceptions; using Listenarr.Domain.Common; using Listenarr.Api.Dtos.ManualImport; @@ -40,6 +41,7 @@ public partial class ManualImportController : ControllerBase private readonly IFileSystemSemanticsResolver _semanticsResolver; private readonly IFilesystemMutationCoordinator _filesystemMutationCoordinator; private readonly IAudiobookOperationCoordinator _audiobookOperationCoordinator; + private readonly IMoveQueueService _moveQueueService; private readonly ManualImportPathPlanner _pathPlanner; private readonly ManualImportCompanionImporter _companionImporter; private readonly ILibraryDirectoryOwnershipStore _directoryOwnershipStore; @@ -59,6 +61,7 @@ public ManualImportController( IFileSystemSemanticsResolver semanticsResolver, IFilesystemMutationCoordinator filesystemMutationCoordinator, IAudiobookOperationCoordinator audiobookOperationCoordinator, + IMoveQueueService moveQueueService, ILibraryDirectoryOwnershipStore directoryOwnershipStore, ManualImportPathPlanner? pathPlanner = null, ManualImportCompanionImporter? companionImporter = null) @@ -78,6 +81,7 @@ public ManualImportController( _semanticsResolver = semanticsResolver; _filesystemMutationCoordinator = filesystemMutationCoordinator ?? throw new ArgumentNullException(nameof(filesystemMutationCoordinator)); _audiobookOperationCoordinator = audiobookOperationCoordinator ?? throw new ArgumentNullException(nameof(audiobookOperationCoordinator)); + _moveQueueService = moveQueueService ?? throw new ArgumentNullException(nameof(moveQueueService)); _directoryOwnershipStore = directoryOwnershipStore ?? throw new ArgumentNullException(nameof(directoryOwnershipStore)); _pathPlanner = pathPlanner ?? new ManualImportPathPlanner(fileNamingService); _companionImporter = companionImporter ?? new ManualImportCompanionImporter( @@ -201,12 +205,12 @@ public async Task> Start( : Array.Empty(); _logger.LogDebug("Manual import batch: {ItemCount} items", orderedItems.Count); + var stoppedByCancellation = false; await ExecuteWithAudiobookLocksAsync( orderedItems.Select(item => item.MatchedAudiobookId), async operationToken => { - OperationCanceledException? postMutationCancellation = null; var planningBasePaths = new Dictionary(); try { @@ -265,10 +269,10 @@ await ExecuteWithAudiobookLocksAsync( _fileSystem.DeleteEmptyDirectories(sourceDirectory); } } - catch (OperationCanceledException exception) when ( + catch (OperationCanceledException) when ( results.Any(result => result.Success)) { - postMutationCancellation = exception; + stoppedByCancellation = true; } var hasSuccessfulMutation = results.Any(result => result.Success); @@ -278,14 +282,15 @@ await EnqueueFocusedScansAsync( ? CancellationToken.None : operationToken); - if (postMutationCancellation != null) + if (hasSuccessfulMutation + && operationToken.IsCancellationRequested) { - System.Runtime.ExceptionServices.ExceptionDispatchInfo - .Capture(postMutationCancellation) - .Throw(); + stoppedByCancellation = true; + } + else + { + operationToken.ThrowIfCancellationRequested(); } - - operationToken.ThrowIfCancellationRequested(); }, cancellationToken); @@ -294,10 +299,19 @@ await EnqueueFocusedScansAsync( return Ok(new { importedCount = successCount, - totalCount = results.Count, + totalCount = orderedItems.Count, + stoppedByCancellation, results = results }); } + catch (ApplicationConflictException exception) + { + return Conflict(new + { + error = exception.SafeDetail, + code = exception.Code + }); + } catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) { _logger.LogError(ex, "Error starting manual import"); diff --git a/listenarr.api/Features/Library/LibraryAddWorkflow.cs b/listenarr.api/Features/Library/LibraryAddWorkflow.cs index 79c953664..4e36f7dd5 100644 --- a/listenarr.api/Features/Library/LibraryAddWorkflow.cs +++ b/listenarr.api/Features/Library/LibraryAddWorkflow.cs @@ -197,7 +197,7 @@ private async Task AddCoreAsync(LibraryController.AddToLibraryReq } audiobook.BasePath = normalizedDestinationPath; - _logger.LogInformation("Using custom destination path for audiobook '{Title}': {BasePath}", + _logger.LogInformation("Using requested destination path for audiobook '{Title}': {BasePath}", audiobook.Title, audiobook.BasePath); } diff --git a/listenarr.api/Features/Library/LibraryBulkEditWorkflow.Delete.cs b/listenarr.api/Features/Library/LibraryBulkEditWorkflow.Delete.cs index c29ba8408..8d2920827 100644 --- a/listenarr.api/Features/Library/LibraryBulkEditWorkflow.Delete.cs +++ b/listenarr.api/Features/Library/LibraryBulkEditWorkflow.Delete.cs @@ -17,6 +17,7 @@ */ using System.Text.RegularExpressions; +using Listenarr.Application.Common.Exceptions; using Listenarr.Domain.Common; using Microsoft.AspNetCore.Mvc; @@ -24,7 +25,9 @@ namespace Listenarr.Api.Features.Library { public sealed partial class LibraryBulkEditWorkflow { - public async Task BulkDeleteAsync(LibraryController.BulkDeleteRequest request) + public async Task BulkDeleteAsync( + LibraryController.BulkDeleteRequest request, + CancellationToken cancellationToken = default) { if (request.Ids == null || !request.Ids.Any()) { @@ -38,9 +41,23 @@ public async Task BulkDeleteAsync(LibraryController.BulkDeleteReq foreach (var id in request.Ids.Distinct()) { - var outcome = await _audiobookOperationCoordinator.ExecuteExclusiveAsync( - id, - _ => DeleteOneAsync(id)); + BulkDeleteOutcome outcome; + try + { + outcome = await _filesystemMutationCoordinator.ExecuteExclusiveAsync( + globalToken => _audiobookOperationCoordinator.ExecuteExclusiveAsync( + id, + audiobookToken => DeleteOneAsync(id, audiobookToken), + globalToken), + cancellationToken); + } + catch (OperationCanceledException) when (deletedCount > 0) + { + errors.Add( + "Bulk deletion stopped after request cancellation; no additional audiobooks were deleted."); + break; + } + deletedImagesCount += outcome.DeletedImages; if (outcome.Deleted) { @@ -78,19 +95,29 @@ public async Task BulkDeleteAsync(LibraryController.BulkDeleteReq return new OkObjectResult(result); } - private async Task DeleteOneAsync(int id) + private async Task DeleteOneAsync( + int id, + CancellationToken cancellationToken) { try { + await _moveQueueService.EnsureFilesystemMutationAllowedAsync( + id, + cancellationToken); + using var scope = _scopeFactory.CreateScope(); - var repository = scope.ServiceProvider.GetRequiredService(); - var audiobook = await repository.GetByIdAsync(id); - if (audiobook == null) + var deletionCommitService = scope.ServiceProvider + .GetRequiredService(); + var commit = await deletionCommitService.DeleteAsync( + id, + cancellationToken); + if (commit.Outcome == AudiobookDeletionCommitOutcome.NotFound) { return new BulkDeleteOutcome(false, 0, $"Audiobook with ID {id} not found"); } - if (!await repository.DeleteByIdAsync(id)) + if (commit.Outcome != AudiobookDeletionCommitOutcome.Deleted + || commit.Audiobook == null) { return new BulkDeleteOutcome( false, @@ -98,6 +125,7 @@ private async Task DeleteOneAsync(int id) $"Failed to delete audiobook with ID {id}"); } + var audiobook = commit.Audiobook; var deletedImages = await DeleteCachedImageAsync(audiobook); try { @@ -112,7 +140,7 @@ await _historyRepository.AddAsync(new History }); } catch (Exception historyException) when (historyException is not ( - OperationCanceledException or OutOfMemoryException or StackOverflowException)) + OutOfMemoryException or StackOverflowException)) { _logger.LogWarning( historyException, @@ -126,9 +154,12 @@ await _historyRepository.AddAsync(new History id); return new BulkDeleteOutcome(true, deletedImages, null); } - catch (Exception ex) when (ex is not OperationCanceledException - && ex is not OutOfMemoryException - && ex is not StackOverflowException) + catch (ApplicationConflictException exception) + { + return new BulkDeleteOutcome(false, 0, exception.SafeDetail); + } + catch (Exception ex) when (ex is not ( + OperationCanceledException or OutOfMemoryException or StackOverflowException)) { _logger.LogError(ex, "Error during bulk delete for ID {Id}", id); return new BulkDeleteOutcome( @@ -170,7 +201,8 @@ private async Task DeleteCachedImageAsync(Audiobook audiobook) return await DeleteCachedImageFromUrlAsync(audiobook); } } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + catch (Exception ex) when (ex is not ( + OutOfMemoryException or StackOverflowException)) { _logger.LogWarning(ex, "Failed to delete cached image for audiobook id {Id}", audiobook.Id); } @@ -221,7 +253,8 @@ private async Task DeleteCachedImageFromUrlAsync(Audiobook audiobook) } } } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + catch (Exception ex) when (ex is not ( + OutOfMemoryException or StackOverflowException)) { _logger.LogWarning(ex, "Failed to delete cached image based on stored ImageUrl for audiobook id {Id}", audiobook.Id); } diff --git a/listenarr.api/Features/Library/LibraryBulkEditWorkflow.cs b/listenarr.api/Features/Library/LibraryBulkEditWorkflow.cs index f8f57a229..86acf9382 100644 --- a/listenarr.api/Features/Library/LibraryBulkEditWorkflow.cs +++ b/listenarr.api/Features/Library/LibraryBulkEditWorkflow.cs @@ -16,6 +16,7 @@ * along with this program. If not, see . */ +using Listenarr.Application.Common.Exceptions; using Microsoft.AspNetCore.Mvc; namespace Listenarr.Api.Features.Library @@ -29,7 +30,9 @@ public sealed partial class LibraryBulkEditWorkflow private readonly string _contentRootPath; private readonly IFileSystem _fileSystem; private readonly IAudiobookDestinationRewriteService _destinationRewriteService; + private readonly IFilesystemMutationCoordinator _filesystemMutationCoordinator; private readonly IAudiobookOperationCoordinator _audiobookOperationCoordinator; + private readonly IMoveQueueService _moveQueueService; private readonly LibraryMoveWorkflow _moveWorkflow; private readonly ILogger _logger; @@ -41,7 +44,9 @@ public LibraryBulkEditWorkflow( IApplicationPathService applicationPathService, IFileSystem fileSystem, IAudiobookDestinationRewriteService destinationRewriteService, + IFilesystemMutationCoordinator filesystemMutationCoordinator, IAudiobookOperationCoordinator audiobookOperationCoordinator, + IMoveQueueService moveQueueService, LibraryMoveWorkflow moveWorkflow, ILogger logger) { @@ -52,7 +57,9 @@ public LibraryBulkEditWorkflow( _contentRootPath = applicationPathService.ContentRootPath; _fileSystem = fileSystem; _destinationRewriteService = destinationRewriteService ?? throw new ArgumentNullException(nameof(destinationRewriteService)); + _filesystemMutationCoordinator = filesystemMutationCoordinator ?? throw new ArgumentNullException(nameof(filesystemMutationCoordinator)); _audiobookOperationCoordinator = audiobookOperationCoordinator ?? throw new ArgumentNullException(nameof(audiobookOperationCoordinator)); + _moveQueueService = moveQueueService ?? throw new ArgumentNullException(nameof(moveQueueService)); _moveWorkflow = moveWorkflow ?? throw new ArgumentNullException(nameof(moveWorkflow)); _logger = logger; } @@ -109,13 +116,32 @@ await RewriteRootFolderIfRequestedAsync( metadataUpdates, settings) }; - var outcome = await _audiobookOperationCoordinator.ExecuteExclusiveAsync( - id, - _ => UpdateOneAsync( + BulkUpdateOutcome outcome; + try + { + outcome = await _audiobookOperationCoordinator.ExecuteExclusiveAsync( id, - metadataUpdates, - rootRewrite.Rewritten, - pathChangeMode == LibraryController.BulkPathChangeMode.Physical)); + async token => + { + if (pathChangeMode == LibraryController.BulkPathChangeMode.Physical) + { + await _moveQueueService.EnsureFilesystemMutationAllowedAsync(id, token); + } + + return await UpdateOneAsync( + id, + metadataUpdates, + rootRewrite.Rewritten, + pathChangeMode == LibraryController.BulkPathChangeMode.Physical); + }); + } + catch (ApplicationConflictException exception) + { + outcome = new BulkUpdateOutcome( + Success: false, + MetadataUpdated: false, + Errors: [exception.SafeDetail]); + } var errors = outcome.Errors .Concat(rootRewrite.Error == null ? [] : [rootRewrite.Error]) .Concat(physicalPlan.Error == null ? [] : [physicalPlan.Error]) diff --git a/listenarr.api/Features/Library/LibraryController.cs b/listenarr.api/Features/Library/LibraryController.cs index c93c006eb..40cbe7ab2 100644 --- a/listenarr.api/Features/Library/LibraryController.cs +++ b/listenarr.api/Features/Library/LibraryController.cs @@ -217,11 +217,16 @@ public async Task DeleteAudiobook( /// Delete multiple audiobooks in a single transaction. ///
/// List of audiobook IDs to delete. + /// Request cancellation token. /// Summary with deleted count, image cleanup count, and any per-item errors. [HttpPost("delete-bulk")] - public async Task BulkDeleteAudiobooks([FromBody] BulkDeleteRequest request) + public async Task BulkDeleteAudiobooks( + [FromBody] BulkDeleteRequest request, + CancellationToken cancellationToken = default) { - return await _bulkEditWorkflow.BulkDeleteAsync(request); + return await _bulkEditWorkflow.BulkDeleteAsync( + request, + cancellationToken); } /// @@ -269,6 +274,19 @@ public async Task EnqueueMove( return await _moveWorkflow.EnqueueAsync(id, request, cancellationToken); } + /// + /// Get the durable unresolved move state for an audiobook. + /// + /// Audiobook ID. + /// Request cancellation token. + [HttpGet("{id}/move/recovery")] + public async Task GetMoveRecoveryState( + int id, + CancellationToken cancellationToken) + { + return await _moveWorkflow.GetRecoveryStateAsync(id, cancellationToken); + } + /// /// Get the current status of a file-move background job. /// diff --git a/listenarr.api/Features/Library/LibraryDeleteWorkflow.cs b/listenarr.api/Features/Library/LibraryDeleteWorkflow.cs index 7836c66fe..160cc798d 100644 --- a/listenarr.api/Features/Library/LibraryDeleteWorkflow.cs +++ b/listenarr.api/Features/Library/LibraryDeleteWorkflow.cs @@ -17,6 +17,7 @@ */ using System.Text.RegularExpressions; +using Listenarr.Application.Common.Exceptions; using Listenarr.Domain.Common; using Microsoft.AspNetCore.Mvc; @@ -24,32 +25,35 @@ namespace Listenarr.Api.Features.Library { public sealed class LibraryDeleteWorkflow { - private readonly IAudiobookRepository _repo; + private readonly IAudiobookDeletionCommitService _deletionCommitService; private readonly IImageCacheService _imageCacheService; private readonly IAudiobookFilesystemDeleteService _audiobookFilesystemDeleteService; private readonly string _contentRootPath; private readonly IFileSystem _fileSystem; private readonly IFilesystemMutationCoordinator _filesystemMutationCoordinator; private readonly IAudiobookOperationCoordinator _audiobookOperationCoordinator; + private readonly IMoveQueueService _moveQueueService; private readonly ILogger _logger; public LibraryDeleteWorkflow( - IAudiobookRepository repo, + IAudiobookDeletionCommitService deletionCommitService, IImageCacheService imageCacheService, IAudiobookFilesystemDeleteService audiobookFilesystemDeleteService, IApplicationPathService applicationPathService, IFileSystem fileSystem, IFilesystemMutationCoordinator filesystemMutationCoordinator, IAudiobookOperationCoordinator audiobookOperationCoordinator, + IMoveQueueService moveQueueService, ILogger logger) { - _repo = repo; + _deletionCommitService = deletionCommitService ?? throw new ArgumentNullException(nameof(deletionCommitService)); _imageCacheService = imageCacheService; _audiobookFilesystemDeleteService = audiobookFilesystemDeleteService; _contentRootPath = applicationPathService.ContentRootPath; _fileSystem = fileSystem; _filesystemMutationCoordinator = filesystemMutationCoordinator ?? throw new ArgumentNullException(nameof(filesystemMutationCoordinator)); _audiobookOperationCoordinator = audiobookOperationCoordinator ?? throw new ArgumentNullException(nameof(audiobookOperationCoordinator)); + _moveQueueService = moveQueueService ?? throw new ArgumentNullException(nameof(moveQueueService)); _logger = logger; } @@ -75,16 +79,31 @@ private async Task DeleteCoreAsync( bool deleteFolder, CancellationToken cancellationToken) { - var audiobook = await _repo.GetByIdAsync(id); - if (audiobook == null) + try { - return new NotFoundObjectResult(new { message = "Audiobook not found" }); + await _moveQueueService.EnsureFilesystemMutationAllowedAsync( + id, + cancellationToken); + } + catch (ApplicationConflictException exception) + { + return new ConflictObjectResult(new + { + message = exception.SafeDetail, + code = exception.Code + }); } - deleteFiles = deleteFiles || deleteFolder; + var commit = await _deletionCommitService.DeleteAsync( + id, + cancellationToken); + if (commit.Outcome == AudiobookDeletionCommitOutcome.NotFound) + { + return new NotFoundObjectResult(new { message = "Audiobook not found" }); + } - var deleted = await _repo.DeleteByIdAsync(id); - if (!deleted) + if (commit.Outcome != AudiobookDeletionCommitOutcome.Deleted + || commit.Audiobook == null) { return new ObjectResult(new { message = "Failed to delete audiobook" }) { @@ -92,6 +111,9 @@ private async Task DeleteCoreAsync( }; } + var audiobook = commit.Audiobook; + deleteFiles = deleteFiles || deleteFolder; + AudiobookFilesystemDeleteResult? filesystemResult = null; if (deleteFiles) { diff --git a/listenarr.api/Features/Library/LibraryManualScanWorkflow.cs b/listenarr.api/Features/Library/LibraryManualScanWorkflow.cs index 0f71b0274..948886f12 100644 --- a/listenarr.api/Features/Library/LibraryManualScanWorkflow.cs +++ b/listenarr.api/Features/Library/LibraryManualScanWorkflow.cs @@ -16,6 +16,7 @@ * along with this program. If not, see . */ +using Listenarr.Application.Common.Exceptions; using Listenarr.Domain.Common; using Microsoft.AspNetCore.Mvc; @@ -31,6 +32,7 @@ public sealed class LibraryManualScanWorkflow private readonly IFileSystem _fileSystem; private readonly IFilesystemMutationCoordinator _filesystemMutationCoordinator; private readonly IAudiobookOperationCoordinator _audiobookOperationCoordinator; + private readonly IMoveQueueService _moveQueueService; private readonly ILogger _logger; public LibraryManualScanWorkflow( @@ -41,6 +43,7 @@ public LibraryManualScanWorkflow( IFileSystem fileSystem, IFilesystemMutationCoordinator filesystemMutationCoordinator, IAudiobookOperationCoordinator audiobookOperationCoordinator, + IMoveQueueService moveQueueService, ILogger logger, INotificationService? notificationService = null) { @@ -53,6 +56,7 @@ public LibraryManualScanWorkflow( ?? throw new ArgumentNullException(nameof(filesystemMutationCoordinator)); _audiobookOperationCoordinator = audiobookOperationCoordinator ?? throw new ArgumentNullException(nameof(audiobookOperationCoordinator)); + _moveQueueService = moveQueueService ?? throw new ArgumentNullException(nameof(moveQueueService)); _logger = logger; _notificationService = notificationService; } @@ -71,6 +75,21 @@ private async Task ScanCoreAsync( LibraryController.ScanRequest? request, CancellationToken cancellationToken) { + try + { + await _moveQueueService.EnsureFilesystemMutationAllowedAsync( + id, + cancellationToken); + } + catch (ApplicationConflictException exception) + { + return new ConflictObjectResult(new + { + message = exception.SafeDetail, + code = exception.Code + }); + } + var audiobook = await _repo.GetByIdAsync(id); if (audiobook == null) { diff --git a/listenarr.api/Features/Library/LibraryMetadataRescanWorkflow.cs b/listenarr.api/Features/Library/LibraryMetadataRescanWorkflow.cs index 091130075..69d629aeb 100644 --- a/listenarr.api/Features/Library/LibraryMetadataRescanWorkflow.cs +++ b/listenarr.api/Features/Library/LibraryMetadataRescanWorkflow.cs @@ -16,6 +16,7 @@ * along with this program. If not, see . */ +using Listenarr.Application.Common.Exceptions; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Caching.Memory; @@ -34,6 +35,7 @@ public sealed partial class LibraryMetadataRescanWorkflow private readonly IImageCacheService _imageCacheService; private readonly IServiceScopeFactory _scopeFactory; private readonly IAudiobookOperationCoordinator _audiobookOperationCoordinator; + private readonly IMoveQueueService _moveQueueService; private readonly ILogger _logger; private readonly IMemoryCache? _memoryCache; private readonly IAsinLookupService? _asinLookupService; @@ -44,6 +46,7 @@ public LibraryMetadataRescanWorkflow( IImageCacheService imageCacheService, IServiceScopeFactory scopeFactory, IAudiobookOperationCoordinator audiobookOperationCoordinator, + IMoveQueueService moveQueueService, ILogger logger, IMemoryCache? memoryCache = null, IAsinLookupService? asinLookupService = null) @@ -53,6 +56,7 @@ public LibraryMetadataRescanWorkflow( _imageCacheService = imageCacheService; _scopeFactory = scopeFactory; _audiobookOperationCoordinator = audiobookOperationCoordinator ?? throw new ArgumentNullException(nameof(audiobookOperationCoordinator)); + _moveQueueService = moveQueueService ?? throw new ArgumentNullException(nameof(moveQueueService)); _logger = logger; _memoryCache = memoryCache; _asinLookupService = asinLookupService; @@ -286,12 +290,28 @@ async Task TryMetadataLookupByAsinAsync(string asin, string? preferredRegi resolvedAsin, string.IsNullOrWhiteSpace(providerSource) ? "Audible" : providerSource!); - var applyResult = await _audiobookOperationCoordinator.ExecuteExclusiveAsync( - id, - _ => ApplyMetadataRescanResultAsync( + MetadataRescanApplyResult applyResult; + try + { + applyResult = await _audiobookOperationCoordinator.ExecuteExclusiveAsync( id, - convertedMetadata, - expectedMetadataState)); + async token => + { + await _moveQueueService.EnsureFilesystemMutationAllowedAsync(id, token); + return await ApplyMetadataRescanResultAsync( + id, + convertedMetadata, + expectedMetadataState); + }); + } + catch (ApplicationConflictException exception) + { + return new ConflictObjectResult(new + { + message = exception.SafeDetail, + code = exception.Code + }); + } if (applyResult.Status == MetadataRescanApplyStatus.NotFound) { return new NotFoundObjectResult(new { message = "Audiobook not found" }); diff --git a/listenarr.api/Features/Library/LibraryMoveWorkflow.Paths.cs b/listenarr.api/Features/Library/LibraryMoveWorkflow.Paths.cs index 7165e001f..d09c8428c 100644 --- a/listenarr.api/Features/Library/LibraryMoveWorkflow.Paths.cs +++ b/listenarr.api/Features/Library/LibraryMoveWorkflow.Paths.cs @@ -32,7 +32,12 @@ private async Task AddAllowedMoveRootAsync( List allowedRoots, string? normalizedRoot, FileSystemCaseSensitivityMode caseSensitivityMode, - CancellationToken cancellationToken) + IDirectoryObjectIdentityResolver directoryIdentityResolver, + CancellationToken cancellationToken, + int? expectedDirectoryIdentityVersion = null, + string? expectedDirectoryIdentity = null, + string? directoryIdentityUnavailableReason = null, + FileSystemPathSemantics? persistedSemantics = null) { if (string.IsNullOrEmpty(normalizedRoot)) { @@ -43,7 +48,10 @@ private async Task AddAllowedMoveRootAsync( normalizedRoot, caseSensitivityMode, cancellationToken); - if (resolution.State != PathIdentityState.Valid) + var semantics = resolution.State == PathIdentityState.Valid + ? resolution.Semantics + : persistedSemantics; + if (!semantics.HasValue) { _logger.LogWarning( "Skipping move boundary {Root}: {Reason}", @@ -52,19 +60,53 @@ private async Task AddAllowedMoveRootAsync( return; } + var hasPersistedDirectoryIdentity = + expectedDirectoryIdentityVersion.HasValue + || !string.IsNullOrWhiteSpace(expectedDirectoryIdentity) + || !string.IsNullOrWhiteSpace(directoryIdentityUnavailableReason); + DirectoryObjectIdentityResolution directoryIdentity; + if (hasPersistedDirectoryIdentity) + { + var current = await directoryIdentityResolver.ResolveExistingAsync( + normalizedRoot, + cancellationToken); + directoryIdentity = current.IsAvailable + && current.Version == expectedDirectoryIdentityVersion + && string.Equals( + current.Value, + expectedDirectoryIdentity, + StringComparison.Ordinal) + && string.IsNullOrWhiteSpace(directoryIdentityUnavailableReason) + ? current + : DirectoryObjectIdentityResolution.Unavailable( + current.UnavailableReason + ?? directoryIdentityUnavailableReason + ?? "The configured root no longer identifies its enrolled physical generation."); + } + else + { + directoryIdentity = await directoryIdentityResolver.ResolveAsync( + normalizedRoot, + cancellationToken); + } + var existingIndex = allowedRoots.FindIndex(root => FileSystemPathIdentity.AreEquivalent( root.Path, normalizedRoot, - resolution.Semantics)); + semantics.Value)); if (existingIndex >= 0) { - if (caseSensitivityMode != FileSystemCaseSensitivityMode.Auto - && allowedRoots[existingIndex].CaseSensitivityMode == FileSystemCaseSensitivityMode.Auto) + if (hasPersistedDirectoryIdentity + || (caseSensitivityMode != FileSystemCaseSensitivityMode.Auto + && allowedRoots[existingIndex].CaseSensitivityMode + == FileSystemCaseSensitivityMode.Auto)) { allowedRoots[existingIndex] = new MoveRootBoundary( normalizedRoot, - resolution.Semantics, - caseSensitivityMode); + semantics.Value, + caseSensitivityMode, + directoryIdentity, + hasPersistedDirectoryIdentity); } return; @@ -72,8 +114,10 @@ private async Task AddAllowedMoveRootAsync( allowedRoots.Add(new MoveRootBoundary( normalizedRoot, - resolution.Semantics, - caseSensitivityMode)); + semantics.Value, + caseSensitivityMode, + directoryIdentity, + hasPersistedDirectoryIdentity)); } private string? TryFindNearestExistingDirectory(string path) @@ -93,7 +137,7 @@ private async Task AddAllowedMoveRootAsync( } catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) { - _logger.LogDebug(ex, "Unable to resolve nearest existing custom move destination directory."); + _logger.LogDebug(ex, "Unable to resolve nearest existing move destination directory."); } return null; @@ -110,6 +154,9 @@ private async Task AddAllowedMoveRootAsync( .OrderByDescending(root => FileSystemPathIdentity.Canonicalize( root.Path, root.Semantics.Syntax).Length) + // If OutputPath aliases a configured RootFolder, the persisted managed-root + // generation is the stronger authority and must win an equal-depth tie. + .ThenByDescending(root => root.IsManagedRoot) .FirstOrDefault(); private static bool SourceStateMatches( @@ -145,7 +192,9 @@ private static bool AreSameMoveEndpoint( private sealed record MoveRootBoundary( string Path, FileSystemPathSemantics Semantics, - FileSystemCaseSensitivityMode CaseSensitivityMode); + FileSystemCaseSensitivityMode CaseSensitivityMode, + DirectoryObjectIdentityResolution DirectoryIdentity, + bool IsManagedRoot); private static BadRequestObjectResult ValidationResult( string code, diff --git a/listenarr.api/Features/Library/LibraryMoveWorkflow.Physical.cs b/listenarr.api/Features/Library/LibraryMoveWorkflow.Physical.cs index a8274a738..52742f604 100644 --- a/listenarr.api/Features/Library/LibraryMoveWorkflow.Physical.cs +++ b/listenarr.api/Features/Library/LibraryMoveWorkflow.Physical.cs @@ -13,6 +13,14 @@ private async Task EnqueuePhysicalAsync( CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); + var recovery = await _moveQueueService!.GetRecoveryStateForAudiobookAsync( + id, + cancellationToken); + if (recovery.BlocksFilesystemMutation) + { + return MoveRecoveryConflict(recovery); + } + var audiobook = await _repo.GetByIdAsync(id); if (audiobook == null) { @@ -26,6 +34,8 @@ private async Task EnqueuePhysicalAsync( var rootFolderService = scope.ServiceProvider.GetRequiredService(); var settings = await configService.GetApplicationSettingsAsync(); var rootFolders = await rootFolderService.GetAllAsync(); + var directoryIdentityResolver = scope.ServiceProvider + .GetRequiredService(); cancellationToken.ThrowIfCancellationRequested(); var allowedMoveRoots = new List(); @@ -34,6 +44,7 @@ await AddAllowedMoveRootAsync( allowedMoveRoots, normalizedOutputPath, FileSystemCaseSensitivityMode.Auto, + directoryIdentityResolver, cancellationToken); string? defaultRootPath = null; @@ -51,7 +62,12 @@ await AddAllowedMoveRootAsync( allowedMoveRoots, normalizedRootPath, rootFolder.CaseSensitivityMode, - cancellationToken); + directoryIdentityResolver, + cancellationToken, + rootFolder.DirectoryObjectIdentityVersion, + rootFolder.DirectoryObjectIdentity, + rootFolder.DirectoryObjectIdentityUnavailableReason, + RootFolderPathSemantics.ResolvePersisted(rootFolder)?.Semantics); if (rootFolder.IsDefault && defaultRootPath == null) { defaultRootPath = normalizedRootPath; @@ -117,6 +133,13 @@ await AddAllowedMoveRootAsync( "Destination filesystem identity is unavailable.", final); } + if (!targetBoundary.DirectoryIdentity.IsAvailable) + { + return DestinationValidationResult( + "destination_physical_identity_unavailable", + "Destination root physical identity is unavailable or changed.", + final); + } var targetParent = Path.GetDirectoryName(final); if (string.IsNullOrEmpty(targetParent)) @@ -157,6 +180,14 @@ await AddAllowedMoveRootAsync( id, async lockedToken => { + var recovery = await _moveQueueService!.GetRecoveryStateForAudiobookAsync( + id, + lockedToken); + if (recovery.BlocksFilesystemMutation) + { + throw new MoveRecoveryConflictException(recovery); + } + using var authoritativeScope = _scopeFactory.CreateScope(); var authoritativeRepository = authoritativeScope.ServiceProvider .GetRequiredService(); @@ -242,6 +273,8 @@ await AddAllowedMoveRootAsync( manifest.Entries, final, targetIdentity, + targetBoundary.DirectoryIdentity.Version!.Value, + targetBoundary.DirectoryIdentity.Value!, deleteEmptySource, sourceCleanupBoundary), lockedToken); @@ -252,20 +285,17 @@ await AddAllowedMoveRootAsync( string.Empty, new MoveEnqueuedResponse("Move enqueued", jobId, final)); } + catch (MoveRecoveryConflictException exception) + { + return MoveRecoveryConflict(exception.Recovery); + } catch (PersistenceException ex) { _logger.LogError( ex, "Move queue persistence failed while enqueueing move job for audiobook {AudiobookId}", id); - return new ObjectResult(new - { - message = "Move queue persistence is unavailable. Check database migrations.", - code = "move_queue_persistence_unavailable" - }) - { - StatusCode = StatusCodes.Status500InternalServerError - }; + return MoveQueuePersistenceUnavailableResult(); } catch (MoveRelocationConflictException ex) { diff --git a/listenarr.api/Features/Library/LibraryMoveWorkflow.cs b/listenarr.api/Features/Library/LibraryMoveWorkflow.cs index 21f61c34e..11a3aa1a5 100644 --- a/listenarr.api/Features/Library/LibraryMoveWorkflow.cs +++ b/listenarr.api/Features/Library/LibraryMoveWorkflow.cs @@ -16,6 +16,7 @@ * along with this program. If not, see . */ +using Listenarr.Application.Common; using Listenarr.Application.Common.Exceptions; using Listenarr.Domain.Common; using Microsoft.AspNetCore.Mvc; @@ -38,8 +39,20 @@ internal sealed record MoveJobStatusResponse( DateTime EnqueuedAt, DateTime? UpdatedAt, DateTime? NextAttemptAt, + string RecoveryDisposition, bool CanRetry); + internal sealed record MoveRecoveryStateResponse( + bool HasUnresolvedMove, + string Disposition, + Guid? JobId, + MoveJobStatus? Status, + MoveJobPhase? Phase, + string? RequestedPath, + string? Error, + bool CanRetry, + IReadOnlyList BlockingJobIds); + public sealed partial class LibraryMoveWorkflow { private readonly IAudiobookRepository _repo; @@ -127,6 +140,25 @@ await _destinationRewriteService.RewriteDestinationAsync( } } + try + { + var recovery = await _moveQueueService.GetRecoveryStateForAudiobookAsync( + id, + cancellationToken); + if (recovery.BlocksFilesystemMutation) + { + return MoveRecoveryConflict(recovery); + } + } + catch (PersistenceException ex) + { + _logger.LogError( + ex, + "Move queue persistence failed while checking unresolved move state for audiobook {AudiobookId}", + id); + return MoveQueuePersistenceUnavailableResult(); + } + return await _mutationCoordinator.ExecuteExclusiveAsync( token => EnqueuePhysicalAsync(id, request, token), cancellationToken); @@ -148,8 +180,60 @@ public async Task GetStatusAsync( return new NotFoundObjectResult(new { message = "Job not found" }); } - private static MoveJobStatusResponse ToStatusResponse(MoveJob job) => - new( + private static ConflictObjectResult MoveRecoveryConflict(MoveRecoveryState recovery) + { + var (code, message) = recovery.Disposition switch + { + MoveRecoveryDisposition.InProgress => ( + "move_already_active", + "A move is already in progress for this audiobook. Wait for it to finish before changing the destination again."), + MoveRecoveryDisposition.RetryAvailable => ( + "move_recovery_required", + "An interrupted move still owns this audiobook's filesystem state. Resume that move before changing the destination again."), + MoveRecoveryDisposition.OperatorRepairRequired => ( + "move_repair_required", + "A previous move left unresolved filesystem state that requires repair before another move can start."), + MoveRecoveryDisposition.Ambiguous => ( + "move_recovery_ambiguous", + "Multiple move jobs contain unresolved filesystem state. Operator reconciliation is required before another move can start."), + _ => ( + "move_recovery_required", + "An unresolved move must be completed before another move can start.") + }; + + return new ConflictObjectResult(new + { + message, + code, + jobId = recovery.JobId, + status = recovery.Status, + requestedPath = recovery.RequestedPath, + recoveryDisposition = recovery.Disposition.ToString(), + canRetry = recovery.CanRetry, + blockingJobIds = recovery.BlockingJobIds + }); + } + + private sealed class MoveRecoveryConflictException(MoveRecoveryState recovery) + : Exception("An unresolved move blocks a new physical move.") + { + public MoveRecoveryState Recovery { get; } = recovery; + } + + private static ObjectResult MoveQueuePersistenceUnavailableResult() => + new(new + { + message = "Move queue persistence is unavailable. Check database migrations.", + code = "move_queue_persistence_unavailable" + }) + { + StatusCode = StatusCodes.Status500InternalServerError + }; + + private static MoveJobStatusResponse ToStatusResponse(MoveJob job) + { + var disposition = MoveRecoveryPolicy.GetDisposition(job); + return new MoveJobStatusResponse( job.Id, job.AudiobookId, job.Status, @@ -160,7 +244,35 @@ private static MoveJobStatusResponse ToStatusResponse(MoveJob job) => job.EnqueuedAt, job.UpdatedAt, job.NextAttemptAt, - job.Status is MoveJobStatus.Failed or MoveJobStatus.NeedsAttention); + disposition.ToString(), + disposition == MoveRecoveryDisposition.RetryAvailable); + } + + public async Task GetRecoveryStateAsync( + int audiobookId, + CancellationToken cancellationToken = default) + { + if (_moveQueueService == null) + { + return new NotFoundObjectResult(new { message = "Move queue not available" }); + } + + var recovery = await _moveQueueService.GetRecoveryStateForAudiobookAsync( + audiobookId, + cancellationToken); + return new OkObjectResult(new MoveRecoveryStateResponse( + recovery.BlocksFilesystemMutation, + recovery.Disposition.ToString(), + recovery.JobId, + recovery.Status, + recovery.Phase, + recovery.RequestedPath, + recovery.Error == null + ? null + : MoveJobPublicProjection.ToError(recovery.Error, MoveFailureKind.Unknown), + recovery.CanRetry, + recovery.BlockingJobIds)); + } public async Task RequeueAsync( string jobId, @@ -172,6 +284,27 @@ public async Task RequeueAsync( Guid? newJobId; try { + var existing = await _moveQueueService.GetJobAsync(gid, cancellationToken); + if (existing == null) + { + return new NotFoundObjectResult(new { message = "Move job not found" }); + } + + var disposition = MoveRecoveryPolicy.GetDisposition(existing); + if (existing.Status != MoveJobStatus.Queued + && disposition != MoveRecoveryDisposition.RetryAvailable) + { + return new ConflictObjectResult(new + { + message = "This move cannot be retried automatically because its persisted recovery evidence requires operator repair.", + code = "move_repair_required", + jobId = existing.Id, + status = existing.Status, + recoveryDisposition = disposition.ToString(), + canRetry = false + }); + } + newJobId = await _moveQueueService.RequeueMoveAsync( gid, cancellationToken); diff --git a/listenarr.api/Features/Library/LibraryRenameWorkflow.cs b/listenarr.api/Features/Library/LibraryRenameWorkflow.cs index a19275d6f..a0fdba410 100644 --- a/listenarr.api/Features/Library/LibraryRenameWorkflow.cs +++ b/listenarr.api/Features/Library/LibraryRenameWorkflow.cs @@ -7,6 +7,7 @@ * by the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. */ +using Listenarr.Application.Common.Exceptions; using Microsoft.AspNetCore.Mvc; namespace Listenarr.Api.Features.Library; @@ -55,8 +56,15 @@ public async Task ExecuteAsync( return new BadRequestObjectResult(new { message = "Cannot execute more than 500 rename operations at once" }); } - return new OkObjectResult( - await renameService.ExecuteRenameAsync(request.Operations, cancellationToken)); + try + { + return new OkObjectResult( + await renameService.ExecuteRenameAsync(request.Operations, cancellationToken)); + } + catch (ApplicationConflictException exception) + { + return MoveConflict(exception); + } } public async Task PreviewSingleAsync( @@ -91,13 +99,27 @@ public async Task ExecuteSingleAsync( } operation.AudiobookId = id; - var result = (await renameService.ExecuteRenameAsync([operation], cancellationToken)) - .FirstOrDefault(); - return result == null - ? new NotFoundObjectResult(new { message = "Audiobook not found" }) - : new OkObjectResult(result); + try + { + var result = (await renameService.ExecuteRenameAsync([operation], cancellationToken)) + .FirstOrDefault(); + return result == null + ? new NotFoundObjectResult(new { message = "Audiobook not found" }) + : new OkObjectResult(result); + } + catch (ApplicationConflictException exception) + { + return MoveConflict(exception); + } } + private static ConflictObjectResult MoveConflict(ApplicationConflictException exception) => + new(new + { + message = exception.SafeDetail, + code = exception.Code + }); + private static ObjectResult Unavailable() => new(new { message = "Rename service not available" }) { diff --git a/listenarr.api/Features/Library/LibraryScanQueueWorkflow.cs b/listenarr.api/Features/Library/LibraryScanQueueWorkflow.cs index 26629f103..0ecc0fc7a 100644 --- a/listenarr.api/Features/Library/LibraryScanQueueWorkflow.cs +++ b/listenarr.api/Features/Library/LibraryScanQueueWorkflow.cs @@ -151,7 +151,14 @@ private async Task BroadcastQueuedAsync(Guid jobId, int? audiobookId) var job = new { jobId = jobId.ToString(), audiobookId, status = "Queued", enqueuedAt = DateTime.UtcNow }; await hub.BroadcastAsync(RealtimeHubTarget.Downloads, "ScanJobUpdate", job); } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + catch (OperationCanceledException ex) + { + _logger.LogDebug( + ex, + "Scan job {JobId} was committed before its realtime broadcast was canceled", + jobId); + } + catch (Exception ex) when (ex is not (OutOfMemoryException or StackOverflowException)) { _logger.LogWarning(ex, "Failed to broadcast ScanJobUpdate for job {JobId}", jobId); } diff --git a/listenarr.api/Features/Library/RootFoldersController.Metadata.cs b/listenarr.api/Features/Library/RootFoldersController.Metadata.cs new file mode 100644 index 000000000..ce7054f2a --- /dev/null +++ b/listenarr.api/Features/Library/RootFoldersController.Metadata.cs @@ -0,0 +1,59 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +using Microsoft.AspNetCore.Mvc; + +namespace Listenarr.Api.Features.Library; + +public partial class RootFoldersController +{ + [HttpPatch("{id}")] + public async Task Patch( + int id, + [FromBody] RootFolderMetadataUpdateRequest request) + { + if (!Enum.IsDefined(request.CaseSensitivityMode)) + { + return BadRequest(new { message = "The root folder metadata is invalid." }); + } + + var existing = await _service.GetByIdAsync(id); + if (existing == null) + { + return NotFound(new { message = "Root folder not found" }); + } + + if (existing.CaseSensitivityMode != request.CaseSensitivityMode) + { + return Conflict(new + { + message = "Root filesystem semantics must be changed through the path-changes endpoint." + }); + } + + try + { + existing.Name = request.Name; + existing.IsDefault = request.IsDefault; + var updated = await _service.UpdateAsync(existing); + return Ok(await MapAsync(updated)); + } + catch (ArgumentException) + { + return BadRequest(new { message = "The root folder metadata is invalid." }); + } + catch (InvalidOperationException) + { + return Conflict(new + { + message = "The root folder metadata conflicts with an existing root folder." + }); + } + } +} diff --git a/listenarr.api/Features/Library/RootFoldersController.cs b/listenarr.api/Features/Library/RootFoldersController.cs index be052281d..007609f7f 100644 --- a/listenarr.api/Features/Library/RootFoldersController.cs +++ b/listenarr.api/Features/Library/RootFoldersController.cs @@ -186,18 +186,23 @@ public async Task Update( existing.Path, normalizedRequestedPath, persistedSourceSemantics.Value); - if (!pathChanged) + var semanticsChanged = + existing.CaseSensitivityMode != request.CaseSensitivityMode; + if (!pathChanged && !semanticsChanged) { request.Path = existing.Path; var updatedMetadata = await _service.UpdateAsync(request); return Ok(await MapAsync(updatedMetadata)); } + var relocationTargetPath = pathChanged + ? normalizedRequestedPath + : existing.Path; var relocation = await _relocationService.StartAsync( id, new RootFolderPathChangeCommand( - normalizedRequestedPath, - moveFiles + relocationTargetPath, + pathChanged && moveFiles ? RootFolderRelocationMode.Relocate : RootFolderRelocationMode.MetadataOnly, deleteEmptySource, @@ -206,14 +211,18 @@ public async Task Update( request.CaseSensitivityMode, existing.Path), cancellationToken); - if (relocation.Status is RootFolderRelocationStatus.Completed - or RootFolderRelocationStatus.NeedsAttention) + if (relocation.Status == RootFolderRelocationStatus.Completed) { var updated = await _service.GetByIdAsync(id) ?? throw new KeyNotFoundException("Root folder not found"); return Ok(await MapAsync(updated)); } + if (relocation.Status == RootFolderRelocationStatus.NeedsAttention) + { + return Conflict(RootFolderRelocationPublicProjection.Sanitize(relocation)); + } + return AcceptedAtRoute( "GetRootFolderRelocation", new { id = relocation.RelocationId }, @@ -236,39 +245,6 @@ public async Task Update( } } - [HttpPatch("{id}")] - public async Task Patch( - int id, - [FromBody] RootFolderMetadataUpdateRequest request) - { - if (!Enum.IsDefined(request.CaseSensitivityMode)) - { - return BadRequest(new { message = "The root folder metadata is invalid." }); - } - - var existing = await _service.GetByIdAsync(id); - if (existing == null) return NotFound(new { message = "Root folder not found" }); - existing.Name = request.Name; - existing.IsDefault = request.IsDefault; - existing.CaseSensitivityMode = request.CaseSensitivityMode; - try - { - var updated = await _service.UpdateAsync(existing); - return Ok(await MapAsync(updated)); - } - catch (ArgumentException) - { - return BadRequest(new { message = "The root folder metadata is invalid." }); - } - catch (InvalidOperationException) - { - return Conflict(new - { - message = "The root folder metadata conflicts with an existing root folder." - }); - } - } - [HttpPost("{id}/path-changes")] public async Task ChangePath( int id, @@ -296,12 +272,17 @@ public async Task ChangePath( request.ExpectedCurrentPath), cancellationToken); var publicResult = RootFolderRelocationPublicProjection.Sanitize(result); - return mode == RootFolderRelocationMode.Relocate - ? AcceptedAtRoute( + return result.Status switch + { + RootFolderRelocationStatus.Completed => Ok(publicResult), + RootFolderRelocationStatus.NeedsAttention or RootFolderRelocationStatus.Failed => + Conflict(publicResult), + _ when mode == RootFolderRelocationMode.Relocate => AcceptedAtRoute( "GetRootFolderRelocation", new { id = result.RelocationId }, - publicResult) - : Ok(publicResult); + publicResult), + _ => Ok(publicResult) + }; } catch (KeyNotFoundException) { diff --git a/listenarr.application/Audiobooks/Contracts/IAudioTagWriter.cs b/listenarr.application/Audiobooks/Contracts/IAudioTagWriter.cs index 660dad28b..a75276529 100644 --- a/listenarr.application/Audiobooks/Contracts/IAudioTagWriter.cs +++ b/listenarr.application/Audiobooks/Contracts/IAudioTagWriter.cs @@ -21,5 +21,9 @@ namespace Listenarr.Application.Audiobooks.Contracts public interface IAudioTagWriter { Task WriteAsinTagAsync(string filePath, string asin); + + Task WriteAsinTagAsync( + IAudiobookFileRegistrationLease registrationLease, + string asin); } } diff --git a/listenarr.application/Audiobooks/Contracts/IAudiobookDeletionCommitService.cs b/listenarr.application/Audiobooks/Contracts/IAudiobookDeletionCommitService.cs new file mode 100644 index 000000000..4f9ce5b9f --- /dev/null +++ b/listenarr.application/Audiobooks/Contracts/IAudiobookDeletionCommitService.cs @@ -0,0 +1,24 @@ +namespace Listenarr.Application.Audiobooks.Contracts; + +public enum AudiobookDeletionCommitOutcome +{ + Deleted, + NotFound, + Failed +} + +public sealed record AudiobookDeletionCommitResult( + AudiobookDeletionCommitOutcome Outcome, + Audiobook? Audiobook); + +/// +/// Owns the irreversible database deletion boundary for an audiobook. +/// Request cancellation is authoritative until immediately before the delete +/// commit starts; once that boundary is crossed, the commit must finish. +/// +public interface IAudiobookDeletionCommitService +{ + Task DeleteAsync( + int id, + CancellationToken requestCancellationToken = default); +} diff --git a/listenarr.application/Audiobooks/Contracts/IAudiobookFileService.cs b/listenarr.application/Audiobooks/Contracts/IAudiobookFileService.cs index 0143a49d9..7811da5e5 100644 --- a/listenarr.application/Audiobooks/Contracts/IAudiobookFileService.cs +++ b/listenarr.application/Audiobooks/Contracts/IAudiobookFileService.cs @@ -13,6 +13,12 @@ public interface IAudiobookFileRegistrationLease : IDisposable string MetadataPath { get; } string PhysicalObjectIdentity { get; } string? SourcePhysicalObjectIdentity { get; } + Stream OpenMetadataReadStream() => + throw new NotSupportedException( + "This registration lease does not expose generation-bound metadata reads."); + Stream OpenMetadataWriteStream() => + throw new NotSupportedException( + "This registration lease does not expose generation-bound metadata writes."); bool MatchesCurrentPublication(); bool PrepareCleanupRecovery(int audiobookId); RegistrationPublicationCompletion CompletePublication(); diff --git a/listenarr.application/Audiobooks/Contracts/IMoveQueuePersistence.cs b/listenarr.application/Audiobooks/Contracts/IMoveQueuePersistence.cs index 7a7e508c3..8983790f5 100644 --- a/listenarr.application/Audiobooks/Contracts/IMoveQueuePersistence.cs +++ b/listenarr.application/Audiobooks/Contracts/IMoveQueuePersistence.cs @@ -50,6 +50,13 @@ public interface IMoveQueuePersistence Task> GetActiveAsync(CancellationToken cancellationToken = default); + Task> GetRecoveryCandidatesByAudiobookAsync( + int audiobookId, + CancellationToken cancellationToken = default); + + Task> GetRecoveryCandidatesAsync( + CancellationToken cancellationToken = default); + Task ReconcileIdentityKeysAsync(CancellationToken cancellationToken = default); Task GetHealthAsync( diff --git a/listenarr.application/Audiobooks/Contracts/IMoveQueueService.cs b/listenarr.application/Audiobooks/Contracts/IMoveQueueService.cs index c1b548993..bdae1b320 100644 --- a/listenarr.application/Audiobooks/Contracts/IMoveQueueService.cs +++ b/listenarr.application/Audiobooks/Contracts/IMoveQueueService.cs @@ -26,6 +26,8 @@ public sealed record MoveEnqueueCommand( IReadOnlyList SourceEntries, string TargetPath, PathIdentitySnapshot TargetIdentity, + int TargetBoundaryDirectoryObjectIdentityVersion, + string TargetBoundaryDirectoryObjectIdentity, bool DeleteEmptySource = true, string? SourceCleanupBoundary = null, Guid? RelocationId = null); @@ -57,6 +59,14 @@ Task EnqueueMoveAsync( Task HeartbeatJobAsync(Guid jobId, string leaseOwner, int leaseGeneration, CancellationToken cancellationToken = default); Task RecoverActiveJobsAsync(CancellationToken cancellationToken = default); Task> GetActiveJobsAsync(CancellationToken cancellationToken = default); + Task GetRecoveryStateForAudiobookAsync( + int audiobookId, + CancellationToken cancellationToken = default); + Task> GetFilesystemBlockingJobsAsync( + CancellationToken cancellationToken = default); + Task EnsureFilesystemMutationAllowedAsync( + int audiobookId, + CancellationToken cancellationToken = default); Task GetQueueHealthAsync(CancellationToken cancellationToken = default); Task GetJobAsync(Guid id, CancellationToken cancellationToken = default); Task IncrementAttemptAsync(Guid id, string leaseOwner, int leaseGeneration, CancellationToken cancellationToken = default); diff --git a/listenarr.application/Audiobooks/Contracts/MoveManifestIdentity.cs b/listenarr.application/Audiobooks/Contracts/MoveManifestIdentity.cs index 11d709e21..eb747efa6 100644 --- a/listenarr.application/Audiobooks/Contracts/MoveManifestIdentity.cs +++ b/listenarr.application/Audiobooks/Contracts/MoveManifestIdentity.cs @@ -7,7 +7,9 @@ namespace Listenarr.Application.Audiobooks.Contracts; public static class MoveManifestIdentity { - public const int Version = 5; + public const int Version = 6; + private const string TargetBoundaryAuthorizationDomain = + "LISTENARR-MOVE-TARGET-BOUNDARY"; public static string CreateDeduplicationKey( int audiobookId, @@ -15,14 +17,27 @@ public static string CreateDeduplicationKey( PathIdentitySnapshot sourceIdentity, string target, PathIdentitySnapshot targetIdentity, - IEnumerable entries) => - CreateDeduplicationKeyCore( + IEnumerable entries) + { + ArgumentNullException.ThrowIfNull(entries); + var persistedEntries = entries.ToList(); + if (!TryGetTargetBoundaryAuthorization( + persistedEntries, + out _, + out _)) + { + throw new InvalidOperationException( + "A durable move identity requires target-boundary physical-generation authorization."); + } + + return CreateDeduplicationKeyCore( audiobookId, source, sourceIdentity, target, targetIdentity, - entries.Select(ToIdentityEntry)); + persistedEntries.Select(ToIdentityEntry)); + } public static string CreateDeduplicationKey( int audiobookId, @@ -39,6 +54,24 @@ public static string CreateDeduplicationKey( targetIdentity, entries.Select(ToIdentityEntry)); + public static string CreateReconciliationKey( + int audiobookId, + string source, + PathIdentitySnapshot sourceIdentity, + string target, + PathIdentitySnapshot targetIdentity, + IEnumerable entries) + { + ArgumentNullException.ThrowIfNull(entries); + return CreateDeduplicationKeyCore( + audiobookId, + source, + sourceIdentity, + target, + targetIdentity, + entries.Select(ToIdentityEntry)); + } + public static bool SourceManifestsMatch( IEnumerable currentEntries, IEnumerable persistedEntries, @@ -51,11 +84,84 @@ public static bool SourceManifestsMatch( currentEntries.Select(ToIdentityEntry), semantics), ComputeManifestDigest( - persistedEntries.Select(ToIdentityEntry), + persistedEntries + .Where(entry => !IsTargetBoundaryAuthorization(entry)) + .Select(ToIdentityEntry), semantics), StringComparison.Ordinal); } + public static MoveJobEntry CreateTargetBoundaryAuthorization( + int directoryIdentityVersion, + string directoryIdentity) + { + if (directoryIdentityVersion <= 0) + { + throw new ArgumentOutOfRangeException( + nameof(directoryIdentityVersion)); + } + ArgumentException.ThrowIfNullOrWhiteSpace(directoryIdentity); + return new MoveJobEntry + { + RelativePath = string.Empty, + EntryType = MoveJobEntryType.Directory, + Length = directoryIdentityVersion, + LastWriteTimeUtc = DateTime.UnixEpoch, + Sha256 = ComputeTargetBoundaryAuthorizationDigest( + directoryIdentityVersion, + directoryIdentity), + CopyState = MoveJobEntryCopyState.Pending, + CleanupState = MoveJobEntryCleanupState.Pending + }; + } + + public static bool IsTargetBoundaryAuthorization(MoveJobEntry entry) => + entry.EntryType == MoveJobEntryType.Directory + && string.IsNullOrEmpty(entry.RelativePath) + && entry.Length > 0 + && entry.Sha256 is { Length: 64 } digest + && digest.All(Uri.IsHexDigit); + + public static bool TryGetTargetBoundaryAuthorization( + IEnumerable entries, + out int directoryIdentityVersion, + out string digest) + { + ArgumentNullException.ThrowIfNull(entries); + var matches = entries + .Where(IsTargetBoundaryAuthorization) + .Take(2) + .ToList(); + if (matches.Count != 1 + || matches[0].Length > int.MaxValue) + { + directoryIdentityVersion = 0; + digest = string.Empty; + return false; + } + + directoryIdentityVersion = (int)matches[0].Length; + digest = matches[0].Sha256!.ToUpperInvariant(); + return true; + } + + public static string ComputeTargetBoundaryAuthorizationDigest( + int directoryIdentityVersion, + string directoryIdentity) + { + if (directoryIdentityVersion <= 0) + { + throw new ArgumentOutOfRangeException( + nameof(directoryIdentityVersion)); + } + ArgumentException.ThrowIfNullOrWhiteSpace(directoryIdentity); + using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + AppendUtf8(hash, TargetBoundaryAuthorizationDomain); + AppendInt32(hash, directoryIdentityVersion); + AppendUtf8(hash, directoryIdentity); + return Convert.ToHexString(hash.GetHashAndReset()); + } + private static string CreateDeduplicationKeyCore( int audiobookId, string source, @@ -163,6 +269,19 @@ private static ManifestIdentityEntry NormalizeEntry( semantics); if (entry.EntryType == MoveJobEntryType.Directory) { + if (string.IsNullOrEmpty(entry.RelativePath) + && entry.Length > 0 + && entry.Sha256 is { Length: 64 } authorizationDigest + && authorizationDigest.All(Uri.IsHexDigit)) + { + return new ManifestIdentityEntry( + relativePath, + entry.EntryType, + entry.Length, + DateTime.UnixEpoch, + authorizationDigest.ToUpperInvariant()); + } + // Directory timestamps are not ownership evidence and may change when // unrelated content appears in a shared source tree. return new ManifestIdentityEntry( diff --git a/listenarr.application/Audiobooks/Deletion/AudiobookDeletionCommitService.cs b/listenarr.application/Audiobooks/Deletion/AudiobookDeletionCommitService.cs new file mode 100644 index 000000000..2dcd5a483 --- /dev/null +++ b/listenarr.application/Audiobooks/Deletion/AudiobookDeletionCommitService.cs @@ -0,0 +1,40 @@ +using Listenarr.Application.Common; + +namespace Listenarr.Application.Audiobooks.Deletion; + +/// +/// Centralizes the cancellation-to-commit transition for audiobook deletion. +/// +public sealed class AudiobookDeletionCommitService( + IAudiobookRepository repository) : IAudiobookDeletionCommitService +{ + public async Task DeleteAsync( + int id, + CancellationToken requestCancellationToken = default) + { + requestCancellationToken.ThrowIfCancellationRequested(); + var audiobook = await repository.GetByIdSnapshotAsync( + id, + requestCancellationToken); + if (audiobook == null) + { + return new AudiobookDeletionCommitResult( + AudiobookDeletionCommitOutcome.NotFound, + null); + } + + // This is the single request-cancellation fence for the irreversible + // database deletion. IAudiobookRepository.DeleteByIdAsync is intentionally + // non-request-cancelable, so a disconnect after this point cannot leave + // the workflow pretending that a committed delete was rolled back. + RequestCancellationBoundary.EnterNonCancelablePhase( + requestCancellationToken); + + var deleted = await repository.DeleteByIdAsync(id); + return new AudiobookDeletionCommitResult( + deleted + ? AudiobookDeletionCommitOutcome.Deleted + : AudiobookDeletionCommitOutcome.Failed, + audiobook); + } +} diff --git a/listenarr.application/Audiobooks/Files/AudiobookFileService.Claims.cs b/listenarr.application/Audiobooks/Files/AudiobookFileService.Claims.cs index f5cc1f1fb..e4f9a980c 100644 --- a/listenarr.application/Audiobooks/Files/AudiobookFileService.Claims.cs +++ b/listenarr.application/Audiobooks/Files/AudiobookFileService.Claims.cs @@ -77,6 +77,9 @@ public Task ClaimAudiobookFileAsync( audiobook.Id, async token => { + await moveQueueService.EnsureFilesystemMutationAllowedAsync( + audiobook.Id, + token); var currentAudiobook = await audiobookRepository.GetByIdSnapshotAsync( audiobook.Id, token); diff --git a/listenarr.application/Audiobooks/Files/AudiobookFileService.cs b/listenarr.application/Audiobooks/Files/AudiobookFileService.cs index 9368a4d1c..2fe9ba1ed 100644 --- a/listenarr.application/Audiobooks/Files/AudiobookFileService.cs +++ b/listenarr.application/Audiobooks/Files/AudiobookFileService.cs @@ -38,7 +38,8 @@ public partial class AudiobookFileService( IRootFolderService rootFolderService, ILogger logger, IFilesystemMutationCoordinator filesystemMutationCoordinator, - IAudiobookOperationCoordinator audiobookOperationCoordinator) : IAudiobookFileService + IAudiobookOperationCoordinator audiobookOperationCoordinator, + IMoveQueueService moveQueueService) : IAudiobookFileService { public Task EnsureAudiobookFileAsync( Audiobook audiobook, @@ -114,6 +115,9 @@ private Task EnsureAudiobookFileAsync( audiobook.Id, async token => { + await moveQueueService.EnsureFilesystemMutationAllowedAsync( + audiobook.Id, + token); var currentAudiobook = await audiobookRepository.GetByIdSnapshotAsync(audiobook.Id, token); if (currentAudiobook == null) { diff --git a/listenarr.application/Audiobooks/Jobs/MoveQueueService.PostCommit.cs b/listenarr.application/Audiobooks/Jobs/MoveQueueService.PostCommit.cs index 283e5a339..c115b2d8f 100644 --- a/listenarr.application/Audiobooks/Jobs/MoveQueueService.PostCommit.cs +++ b/listenarr.application/Audiobooks/Jobs/MoveQueueService.PostCommit.cs @@ -18,10 +18,10 @@ await NotifyPersistedJobStateAsync( error, cancellationToken); } - catch (OperationCanceledException) when ( - cancellationToken.IsCancellationRequested) + catch (OperationCanceledException exception) { _logger.LogDebug( + exception, "Move job {JobId} state was committed before notification cancellation", id); } diff --git a/listenarr.application/Audiobooks/Jobs/MoveQueueService.Recovery.cs b/listenarr.application/Audiobooks/Jobs/MoveQueueService.Recovery.cs new file mode 100644 index 000000000..0c10db5df --- /dev/null +++ b/listenarr.application/Audiobooks/Jobs/MoveQueueService.Recovery.cs @@ -0,0 +1,82 @@ +using Listenarr.Application.Common; +using Listenarr.Application.Common.Exceptions; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Application.Audiobooks.Jobs; + +public partial class MoveQueueService +{ + public async Task GetRecoveryStateForAudiobookAsync( + int audiobookId, + CancellationToken cancellationToken = default) + { + IReadOnlyList jobs; + try + { + jobs = await _persistence.GetRecoveryCandidatesByAudiobookAsync( + audiobookId, + cancellationToken); + } + catch (Exception exception) when (WorkerExceptionClassifier.IsNonFatal(exception)) + { + _logger.LogWarning( + exception, + "Failed to query unresolved move state for audiobook {AudiobookId}", + audiobookId); + throw; + } + + return MoveRecoveryPolicy.ClassifyAudiobookJobs(jobs); + } + + public async Task> GetFilesystemBlockingJobsAsync( + CancellationToken cancellationToken = default) + { + IReadOnlyList jobs; + try + { + jobs = await _persistence.GetRecoveryCandidatesAsync(cancellationToken); + } + catch (Exception exception) when (WorkerExceptionClassifier.IsNonFatal(exception)) + { + _logger.LogWarning(exception, "Failed to query filesystem-blocking move jobs"); + throw; + } + + return jobs + .Where(MoveRecoveryPolicy.BlocksFilesystemMutation) + .ToArray(); + } + + public async Task EnsureFilesystemMutationAllowedAsync( + int audiobookId, + CancellationToken cancellationToken = default) + { + var recovery = await GetRecoveryStateForAudiobookAsync( + audiobookId, + cancellationToken); + if (!recovery.BlocksFilesystemMutation) + { + return; + } + + throw recovery.Disposition switch + { + MoveRecoveryDisposition.InProgress => new ApplicationConflictException( + "move_already_active", + "A move is already in progress for this audiobook. Wait for it to finish before changing its files."), + MoveRecoveryDisposition.RetryAvailable => new ApplicationConflictException( + "move_recovery_required", + "An interrupted move still owns this audiobook's filesystem state. Resume that move before changing its files."), + MoveRecoveryDisposition.OperatorRepairRequired => new ApplicationConflictException( + "move_repair_required", + "A previous move left unresolved filesystem state that requires repair before this audiobook can be changed."), + MoveRecoveryDisposition.Ambiguous => new ApplicationConflictException( + "move_recovery_ambiguous", + "Multiple move jobs contain unresolved filesystem state for this audiobook. Operator reconciliation is required before changing its files."), + _ => new ApplicationConflictException( + "move_recovery_required", + "An unresolved move must be completed before changing this audiobook's files.") + }; + } +} diff --git a/listenarr.application/Audiobooks/Jobs/MoveQueueService.Requeue.cs b/listenarr.application/Audiobooks/Jobs/MoveQueueService.Requeue.cs index 0609de35c..ce0b66d0a 100644 --- a/listenarr.application/Audiobooks/Jobs/MoveQueueService.Requeue.cs +++ b/listenarr.application/Audiobooks/Jobs/MoveQueueService.Requeue.cs @@ -154,6 +154,27 @@ await MarkUnsafeStoredPathNeedsAttentionAsync( jobToNotify = job; return null; } + if (!MoveManifestIdentity.TryGetTargetBoundaryAuthorization( + job.Entries, + out _, + out _)) + { + await MarkUnsafeStoredPathNeedsAttentionAsync( + job, + "The move job has no durable target-boundary physical-generation authorization and cannot be requeued safely.", + cancellationToken); + jobToNotify = job; + return null; + } + + if (job.Status != MoveJobStatus.Queued + && MoveRecoveryPolicy.GetDisposition(job) != MoveRecoveryDisposition.RetryAvailable) + { + _logger.LogInformation( + "Move job {JobId} requires operator repair and cannot be manually requeued", + jobId); + return null; + } var deduplicationKey = MoveManifestIdentity.CreateDeduplicationKey( job.AudiobookId, @@ -204,17 +225,19 @@ await MarkUnsafeStoredPathNeedsAttentionAsync( if (jobToSchedule != null) { await ScheduleAsync(jobToSchedule); - await NotifyPersistedJobStateAsync( + await NotifyCommittedJobStateAsync( jobToSchedule.Id, jobToSchedule.Status, - jobToSchedule.Error); + jobToSchedule.Error, + CancellationToken.None); } else if (jobToNotify != null) { - await NotifyPersistedJobStateAsync( + await NotifyCommittedJobStateAsync( jobToNotify.Id, jobToNotify.Status, - jobToNotify.Error); + jobToNotify.Error, + CancellationToken.None); } return result; diff --git a/listenarr.application/Audiobooks/Jobs/MoveQueueService.cs b/listenarr.application/Audiobooks/Jobs/MoveQueueService.cs index d07d1860f..56cdb11d9 100644 --- a/listenarr.application/Audiobooks/Jobs/MoveQueueService.cs +++ b/listenarr.application/Audiobooks/Jobs/MoveQueueService.cs @@ -90,13 +90,26 @@ public async Task EnqueueMoveAsync( target, command.TargetIdentity, command.SourceEntries); + if (command.TargetBoundaryDirectoryObjectIdentityVersion <= 0 + || string.IsNullOrWhiteSpace( + command.TargetBoundaryDirectoryObjectIdentity)) + { + throw new InvalidOperationException( + "A physical move requires durable target-boundary generation authorization."); + } + + var persistedEntries = manifest.Entries.ToList(); + persistedEntries.Add( + MoveManifestIdentity.CreateTargetBoundaryAuthorization( + command.TargetBoundaryDirectoryObjectIdentityVersion, + command.TargetBoundaryDirectoryObjectIdentity)); var deduplicationKey = MoveManifestIdentity.CreateDeduplicationKey( command.AudiobookId, source, command.SourceIdentity, target, command.TargetIdentity, - manifest.Entries); + persistedEntries); MoveJob? jobToSchedule = null; var jobId = await _mutationCoordinator.ExecuteExclusiveAsync(async token => @@ -133,7 +146,7 @@ await ThrowIfRelocationBoundaryProtectedAsync( SourceCleanupBoundary = command.SourceCleanupBoundary, DeleteEmptySource = command.DeleteEmptySource, RelocationId = command.RelocationId, - Entries = manifest.Entries.ToList() + Entries = persistedEntries }; job.SetSourceIdentity(command.SourceIdentity); job.SetTargetIdentity(command.TargetIdentity); @@ -169,10 +182,11 @@ await ThrowIfRelocationBoundaryProtectedAsync( if (jobToSchedule != null) { await ScheduleAsync(jobToSchedule); - await NotifyPersistedJobStateAsync( + await NotifyCommittedJobStateAsync( jobToSchedule.Id, jobToSchedule.Status, - jobToSchedule.Error); + jobToSchedule.Error, + CancellationToken.None); } return jobId; diff --git a/listenarr.application/Audiobooks/Jobs/MoveRecoveryPolicy.cs b/listenarr.application/Audiobooks/Jobs/MoveRecoveryPolicy.cs new file mode 100644 index 000000000..2d2433d71 --- /dev/null +++ b/listenarr.application/Audiobooks/Jobs/MoveRecoveryPolicy.cs @@ -0,0 +1,148 @@ +namespace Listenarr.Application.Audiobooks.Jobs; + +public enum MoveRecoveryDisposition +{ + None, + InProgress, + RetryAvailable, + OperatorRepairRequired, + Ambiguous +} + +public sealed record MoveRecoveryState( + MoveRecoveryDisposition Disposition, + Guid? JobId, + MoveJobStatus? Status, + MoveJobPhase? Phase, + string? RequestedPath, + string? Error, + IReadOnlyList BlockingJobIds) +{ + public bool BlocksFilesystemMutation => Disposition is + MoveRecoveryDisposition.InProgress or + MoveRecoveryDisposition.RetryAvailable or + MoveRecoveryDisposition.OperatorRepairRequired or + MoveRecoveryDisposition.Ambiguous; + + public bool CanRetry => Disposition == MoveRecoveryDisposition.RetryAvailable; + + public static MoveRecoveryState None { get; } = new( + MoveRecoveryDisposition.None, + JobId: null, + Status: null, + Phase: null, + RequestedPath: null, + Error: null, + BlockingJobIds: []); +} + +public static class MoveRecoveryPolicy +{ + public static bool HasFilesystemExecutionEvidence(MoveJob job) + { + ArgumentNullException.ThrowIfNull(job); + + if (job.Phase >= MoveJobPhase.Copying) + { + return true; + } + + if (job.Entries.Any(entry => + entry.EntryType == MoveJobEntryType.File + && (entry.CopyState != MoveJobEntryCopyState.Pending + || entry.CleanupState != MoveJobEntryCleanupState.Pending))) + { + return true; + } + + return job.CreatedDirectories.Any(directory => directory.State is + MoveCreatedDirectoryState.Created or + MoveCreatedDirectoryState.Retained); + } + + public static bool BlocksFilesystemMutation(MoveJob job) + { + ArgumentNullException.ThrowIfNull(job); + if (job.Status.IsActive()) + { + return true; + } + + if (job.Status is MoveJobStatus.Completed or MoveJobStatus.Superseded) + { + return false; + } + + return job.Status is MoveJobStatus.Failed or MoveJobStatus.NeedsAttention + && HasFilesystemExecutionEvidence(job); + } + + public static MoveRecoveryDisposition GetDisposition(MoveJob job) + { + ArgumentNullException.ThrowIfNull(job); + if (job.Status.IsActive()) + { + return MoveRecoveryDisposition.InProgress; + } + + if (job.Status is MoveJobStatus.Completed or MoveJobStatus.Superseded) + { + return MoveRecoveryDisposition.None; + } + + if (job.Status == MoveJobStatus.Failed) + { + // Failed is the legacy/manual-retry terminal state. Requeue never trusts the + // failure classification by itself: it first requires persisted endpoint, + // manifest, and target-boundary authorization evidence, and the worker then + // re-verifies the exact recovery artifacts before any mutation. NeedsAttention + // remains the state used to fence conditions that are known to require repair. + return MoveRecoveryDisposition.RetryAvailable; + } + + if (job.Status == MoveJobStatus.NeedsAttention) + { + return job.FailureKind is MoveFailureKind.Transient or MoveFailureKind.Persistence + ? MoveRecoveryDisposition.RetryAvailable + : MoveRecoveryDisposition.OperatorRepairRequired; + } + + return MoveRecoveryDisposition.None; + } + + public static MoveRecoveryState ClassifyAudiobookJobs(IEnumerable jobs) + { + ArgumentNullException.ThrowIfNull(jobs); + var blocking = jobs + .Where(BlocksFilesystemMutation) + .OrderBy(job => job.EnqueuedAt) + .ThenBy(job => job.Id) + .ToList(); + if (blocking.Count == 0) + { + return MoveRecoveryState.None; + } + + if (blocking.Count > 1) + { + return new MoveRecoveryState( + MoveRecoveryDisposition.Ambiguous, + JobId: null, + Status: null, + Phase: null, + RequestedPath: null, + Error: "Multiple move jobs contain unresolved filesystem execution evidence.", + BlockingJobIds: blocking.Select(job => job.Id).ToArray()); + } + + var job = blocking[0]; + return new MoveRecoveryState( + GetDisposition(job), + job.Id, + job.Status, + job.Phase, + job.RequestedPath, + job.Error, + [job.Id]); + } +} diff --git a/listenarr.application/Audiobooks/Moving/AudiobookDestinationRewriteService.cs b/listenarr.application/Audiobooks/Moving/AudiobookDestinationRewriteService.cs index 1586f094c..ad4596cde 100644 --- a/listenarr.application/Audiobooks/Moving/AudiobookDestinationRewriteService.cs +++ b/listenarr.application/Audiobooks/Moving/AudiobookDestinationRewriteService.cs @@ -78,22 +78,17 @@ private async Task RewriteDestinationCoreAsyn } var sourceBasePath = currentAudiobook.BasePath; + MoveRootBoundary? sourceBoundary = null; var sourceSemantics = destination.TargetBoundary.Semantics; if (!string.IsNullOrWhiteSpace(sourceBasePath)) { - var sourceBoundary = FindAllowedMoveRoot(sourceBasePath, destination.AllowedMoveRoots); - if (sourceBoundary != null) - { - sourceSemantics = sourceBoundary.Semantics; - } - else - { - // Metadata-only updates must not require source filesystem access. - // If the source is not inside a configured boundary, reuse the validated - // target boundary semantics only for stale-source comparison and best-effort - // reference rewriting. Invalid source references are preserved by the rewriter. - sourceSemantics = destination.TargetBoundary.Semantics; - } + sourceBoundary = FindAllowedMoveRoot( + sourceBasePath, + destination.AllowedMoveRoots); + sourceSemantics = sourceBoundary?.Semantics + ?? ResolveConservativeStoredSourceSemantics( + sourceBasePath, + destination.TargetBoundary.Semantics); } if (!string.IsNullOrWhiteSpace(expectedSourcePath)) @@ -102,7 +97,7 @@ private async Task RewriteDestinationCoreAsyn || !StoredSourcePathMatchesExpected( expectedSourcePath, sourceBasePath, - sourceSemantics)) + sourceBoundary?.Semantics)) { throw new ApplicationConflictException( "source_path_changed", @@ -345,21 +340,29 @@ private async Task TryIsBoundaryProtectedAsync( private static bool StoredSourcePathMatchesExpected( string expectedSourcePath, string sourceBasePath, - FileSystemPathSemantics sourceSemantics) + FileSystemPathSemantics? authoritativeSourceSemantics) { + // ExpectedSourcePath is an optimistic-concurrency token. If the persisted + // source is no longer beneath a configured authority, no unrelated target + // filesystem may weaken that token's comparison rules. + if (!authoritativeSourceSemantics.HasValue) + { + return string.Equals( + expectedSourcePath, + sourceBasePath, + StringComparison.Ordinal); + } + try { return FileSystemPathIdentity.AreEquivalent( expectedSourcePath, sourceBasePath, - sourceSemantics); + authoritativeSourceSemantics.Value); } catch (Exception exception) when (exception is ArgumentException or NotSupportedException or PathTooLongException or System.Security.SecurityException) { - // If a legacy stored path cannot be canonicalized, preserve stale-source - // protection by accepting only the exact persisted value the caller observed. - // Never reinterpret stored syntax through the current host. return string.Equals( expectedSourcePath, sourceBasePath, @@ -367,6 +370,27 @@ private static bool StoredSourcePathMatchesExpected( } } + private static FileSystemPathSemantics ResolveConservativeStoredSourceSemantics( + string sourceBasePath, + FileSystemPathSemantics targetSemantics) + { + if (FileSystemPathIdentity.TryDetectAbsoluteSyntax( + sourceBasePath, + out var sourceSyntax)) + { + // Without an authoritative source boundary, case-sensitive comparison is + // the conservative choice: it can preserve an uncertain reference, but it + // cannot broaden a rewrite to a case-distinct path. + return new FileSystemPathSemantics( + sourceSyntax, + FileSystemCaseSensitivity.Sensitive); + } + + return new FileSystemPathSemantics( + targetSemantics.Syntax, + FileSystemCaseSensitivity.Sensitive); + } + private static MoveRootBoundary? FindAllowedMoveRoot( string path, IReadOnlyCollection allowedRoots) => diff --git a/listenarr.application/Audiobooks/Renaming/RenameService.DirectoryPlans.cs b/listenarr.application/Audiobooks/Renaming/RenameService.DirectoryPlans.cs index 1d81eeac0..b08bddfeb 100644 --- a/listenarr.application/Audiobooks/Renaming/RenameService.DirectoryPlans.cs +++ b/listenarr.application/Audiobooks/Renaming/RenameService.DirectoryPlans.cs @@ -1,194 +1,20 @@ -using Listenarr.Domain.Common; - namespace Listenarr.Application.Audiobooks.Renaming; public partial class RenameService { - private async Task BuildDirectoryMovePlanAsync( - Audiobook audiobook, - string sourceBasePath, - string targetBasePath, - FileSystemPathSemantics sourceSemantics, - FileSystemPathSemantics targetSemantics, - CancellationToken cancellationToken) - { - var targetOwner = new Audiobook - { - Id = audiobook.Id, - BasePath = targetBasePath - }; - var ownershipKeys = new HashSet(StringComparer.Ordinal); - var updates = new List(); - - foreach (var file in audiobook.Files ?? []) - { - cancellationToken.ThrowIfCancellationRequested(); - if (string.IsNullOrWhiteSpace(file.Path)) - { - return DirectoryMovePlanResult.Failed( - "A tracked audiobook file path is missing.", - conflict: true); - } - - if (!TryRewriteStoredFilePath( - file.Path, - sourceBasePath, - targetBasePath, - sourceSemantics, - targetSemantics, - out var storedPath, - out var physicalPath)) - { - return DirectoryMovePlanResult.Failed( - "A tracked audiobook file path is outside the expected source folder.", - conflict: true); - } - - var identity = await _filePathIdentityResolver.ResolveAsync( - targetOwner, - physicalPath, - cancellationToken); - if (identity.State != PathIdentityState.Valid - || string.IsNullOrWhiteSpace(identity.OwnershipKey)) - { - return DirectoryMovePlanResult.Failed( - "A destination audiobook file identity is unavailable."); - } - - if (!ownershipKeys.Add(identity.OwnershipKey)) - { - return DirectoryMovePlanResult.Failed( - "The folder move would create duplicate audiobook file destinations."); - } - - var ownership = await _audiobookFileRepository.CheckOwnershipAsync( - audiobook.Id, - file.Id, - identity, - cancellationToken); - if (ownership.Outcome != AudiobookFileOwnershipCheckOutcome.Available) - { - var publicError = ownership.Outcome switch - { - AudiobookFileOwnershipCheckOutcome.OwnedByOtherAudiobook => - "A destination audiobook file is owned by another audiobook.", - AudiobookFileOwnershipCheckOutcome.AlreadyOwnedByAudiobook => - "A destination audiobook file is already owned by another file record for this audiobook.", - AudiobookFileOwnershipCheckOutcome.IdentityConflict => - "A destination audiobook file identity conflicts with existing ownership data.", - _ => "A destination audiobook file identity is unavailable." - }; - return DirectoryMovePlanResult.Failed( - publicError, - ownership.Outcome is - AudiobookFileOwnershipCheckOutcome.OwnedByOtherAudiobook or - AudiobookFileOwnershipCheckOutcome.IdentityConflict); - } - - updates.Add(new DirectoryFileUpdate(file, storedPath, identity)); - } - - string? rewrittenLegacyPath = null; - if (!string.IsNullOrWhiteSpace(audiobook.FilePath)) - { - if (!TryRewriteStoredFilePath( - audiobook.FilePath, - sourceBasePath, - targetBasePath, - sourceSemantics, - targetSemantics, - out rewrittenLegacyPath, - out _)) - { - return DirectoryMovePlanResult.Failed( - "The legacy audiobook file path is outside the expected source folder.", - conflict: true); - } - } - - return new DirectoryMovePlanResult(updates, rewrittenLegacyPath, null, false); - } - - private static bool TryRewriteStoredFilePath( - string storedPath, - string sourceBasePath, - string targetBasePath, - FileSystemPathSemantics sourceSemantics, - FileSystemPathSemantics targetSemantics, - out string rewrittenStoredPath, - out string physicalTargetPath) + private static HashSet GetTrackedFileIdsForFolderChange( + Audiobook audiobook) { - rewrittenStoredPath = storedPath; - physicalTargetPath = string.Empty; - if (FileSystemPathIdentity.TryDetectAbsoluteSyntax( - storedPath, - sourceSemantics.Syntax, - out _)) - { - if (!FileSystemPathIdentity.TryGetRelativePathWithinBase( - sourceBasePath, - storedPath, - sourceSemantics, - out var relativePath)) - { - return false; - } - - var convertedRelative = FileSystemPathIdentity.ConvertRelativePathSyntax( - relativePath, - sourceSemantics.Syntax, - targetSemantics.Syntax); - if (!FileSystemPathIdentity.TryResolveRelativePathWithinBase( - targetBasePath, - convertedRelative, - targetSemantics, - out physicalTargetPath)) - { - return false; - } - - rewrittenStoredPath = physicalTargetPath; - return true; - } - - if (FileSystemPathIdentity.TryDetectAbsoluteSyntax(storedPath, out _)) + if (audiobook.Files is { Count: > 0 }) { - return false; + return audiobook.Files + .Where(file => !string.IsNullOrWhiteSpace(file.Path)) + .Select(file => file.Id) + .ToHashSet(); } - var relativeStoredPath = FileSystemPathIdentity.ConvertRelativePathSyntax( - storedPath, - sourceSemantics.Syntax, - targetSemantics.Syntax); - if (!FileSystemPathIdentity.TryResolveRelativePathWithinBase( - targetBasePath, - relativeStoredPath, - targetSemantics, - out physicalTargetPath)) - { - return false; - } - - rewrittenStoredPath = relativeStoredPath; - return true; - } - - private sealed record DirectoryFileUpdate( - AudiobookFile File, - string StoredPath, - AudiobookFilePathIdentity Identity); - - private sealed record DirectoryMovePlanResult( - IReadOnlyList FileUpdates, - string? LegacyFilePath, - string? Error, - bool Conflict) - { - public bool Success => Error == null; - - public static DirectoryMovePlanResult Failed( - string error, - bool conflict = false) => - new([], null, error, conflict); + return !string.IsNullOrWhiteSpace(audiobook.FilePath) + ? [0] + : []; } } diff --git a/listenarr.application/Audiobooks/Renaming/RenameService.Execution.cs b/listenarr.application/Audiobooks/Renaming/RenameService.Execution.cs index 5a12f1447..3c0a6baeb 100644 --- a/listenarr.application/Audiobooks/Renaming/RenameService.Execution.cs +++ b/listenarr.application/Audiobooks/Renaming/RenameService.Execution.cs @@ -111,16 +111,39 @@ await EnsureOwnedRenameHierarchyAsync( if (!PathsEqual(source, destination, semantics)) { - var moved = await _fileMover.PerformActionOn( - FileAction.Move, + var operationId = FileMoveOperationIdentity.Create( + "audiobook-file-rename", + audiobook.Id, + fileOperation.FileId, source, - destination, - FileMoveOperationIdentity.Create( - "audiobook-file-rename", - audiobook.Id, - fileOperation.FileId, + destination); + bool moved; + if (databaseFile != null) + { + if (string.IsNullOrWhiteSpace( + databaseFile.PhysicalObjectIdentity)) + { + item.Error = + "Tracked source physical identity is unavailable."; + return item; + } + + moved = await _fileMover + .MoveFilePreservingPhysicalIdentityAsync( + source, + destination, + databaseFile.PhysicalObjectIdentity, + operationId); + } + else + { + moved = await _fileMover.PerformActionOn( + FileAction.Move, source, - destination)); + destination, + operationId); + } + if (!moved) { item.Error = "File move operation failed."; @@ -154,115 +177,4 @@ await EnsureOwnedRenameHierarchyAsync( return item; } - - private async Task<(bool Success, bool Conflict, string? Error)> ExecuteDirectoryMoveAsync( - Audiobook audiobook, - string newFolderPath, - IReadOnlyCollection allowedRoots, - List rootFolders, - FileSystemPathSemantics sourceSemantics, - CancellationToken cancellationToken) - { - var currentBase = ComputeCurrentBasePath(audiobook, sourceSemantics); - if (string.IsNullOrWhiteSpace(currentBase)) - { - return (false, true, "The audiobook current folder is unavailable."); - } - - var normalizedCurrent = NormalizePath(currentBase); - var normalizedNew = NormalizePath(newFolderPath); - if (!IsPathWithinAllowedRoots( - normalizedCurrent, - allowedRoots, - sourceSemantics) - || !IsPathWithinAllowedRoots( - normalizedNew, - allowedRoots, - sourceSemantics)) - { - return (false, false, "Destination path is outside the allowed library roots."); - } - - if (!_fileSystem.TryValidateMutationTarget( - normalizedNew, - allowedRoots, - out var validatedNew, - out _)) - { - return ( - false, - false, - "Destination path could not be resolved safely within the allowed library roots."); - } - - if (!_fileSystem.TryValidateMutationTarget( - normalizedCurrent, - allowedRoots, - out var validatedCurrent, - out _)) - { - return ( - false, - true, - "Source path could not be resolved safely within the allowed library roots."); - } - - normalizedCurrent = validatedCurrent; - normalizedNew = validatedNew; - if (!_fileSystem.DirectoryExists(normalizedCurrent)) - { - return (false, true, "Source folder not found."); - } - - if (_fileSystem.DirectoryExists(normalizedNew) - && _fileSystem.EnumerateFileSystemEntries(normalizedNew).Any()) - { - return (false, false, "Target folder already exists and is not empty."); - } - - var targetSemantics = await ResolveRenameSemanticsAsync( - normalizedNew, - rootFolders, - cancellationToken); - var plan = await BuildDirectoryMovePlanAsync( - audiobook, - normalizedCurrent, - normalizedNew, - sourceSemantics, - targetSemantics, - cancellationToken); - if (!plan.Success) - { - return (false, plan.Conflict, plan.Error); - } - - var parent = Path.GetDirectoryName(normalizedNew); - if (!string.IsNullOrWhiteSpace(parent)) - { - await EnsureOwnedRenameHierarchyAsync( - parent, - allowedRoots, - targetSemantics, - audiobook.Id, - Guid.NewGuid(), - cancellationToken); - } - - var moved = await _fileMover.MoveDirectoryAsync( - normalizedCurrent, - normalizedNew); - if (!moved) - { - return (false, false, "Folder move operation failed."); - } - - audiobook.BasePath = normalizedNew; - foreach (var update in plan.FileUpdates) - { - update.File.ApplyPathIdentity(update.StoredPath, update.Identity); - } - - audiobook.FilePath = plan.LegacyFilePath; - return (true, false, null); - } } diff --git a/listenarr.application/Audiobooks/Renaming/RenameService.Rollback.cs b/listenarr.application/Audiobooks/Renaming/RenameService.Rollback.cs index 9854780af..548a74d2c 100644 --- a/listenarr.application/Audiobooks/Renaming/RenameService.Rollback.cs +++ b/listenarr.application/Audiobooks/Renaming/RenameService.Rollback.cs @@ -14,15 +14,6 @@ private static AudiobookPathRollbackState CaptureAudiobookPathRollbackState( (audiobook.Files ?? []) .ToDictionary(file => file.Id, file => file.CapturePathState())); - private static DirectoryRollbackState CaptureDirectoryRollbackState( - Audiobook audiobook, - string sourcePath, - string targetPath) => - new( - sourcePath, - targetPath, - CaptureAudiobookPathRollbackState(audiobook)); - private static void RestoreAudiobookPathState( Audiobook audiobook, AudiobookPathRollbackState rollbackState) @@ -39,75 +30,6 @@ private static void RestoreAudiobookPathState( } } - private async Task RollBackDirectoryMoveAsync( - Audiobook audiobook, - DirectoryRollbackState rollbackState, - IReadOnlyCollection allowedRoots, - FileSystemPathSemantics semantics, - CancellationToken cancellationToken) - { - try - { - if (!_fileSystem.TryValidateMutationTarget( - rollbackState.TargetPath, - allowedRoots, - out var rollbackSource, - out _) - || !_fileSystem.TryValidateMutationTarget( - rollbackState.SourcePath, - allowedRoots, - out var rollbackDestination, - out _)) - { - return false; - } - - if (_fileSystem.DirectoryExists(rollbackSource)) - { - if (_fileSystem.DirectoryExists(rollbackDestination)) - { - return false; - } - - var parent = Path.GetDirectoryName(rollbackDestination); - if (!string.IsNullOrWhiteSpace(parent)) - { - await EnsureOwnedRenameHierarchyAsync( - parent, - allowedRoots, - semantics, - audiobook.Id, - Guid.NewGuid(), - cancellationToken); - } - - if (!await _fileMover.MoveDirectoryAsync( - rollbackSource, - rollbackDestination)) - { - return false; - } - } - else if (!_fileSystem.DirectoryExists(rollbackDestination)) - { - return false; - } - - RestoreAudiobookPathState(audiobook, rollbackState.AudiobookState); - return true; - } - catch (Exception exception) when (exception is not OperationCanceledException - && exception is not OutOfMemoryException - && exception is not StackOverflowException) - { - _logger.LogError( - exception, - "Failed to roll back folder organize operation for audiobook {AudiobookId}", - audiobook.Id); - return false; - } - } - private async Task RollBackFileRenamesAsync( Audiobook audiobook, IReadOnlyList completedItems, @@ -166,16 +88,41 @@ await EnsureOwnedRenameHierarchyAsync( cancellationToken); } - var moved = await _fileMover.PerformActionOn( - FileAction.Move, + var rollbackOperationId = FileMoveOperationIdentity.Create( + "audiobook-file-rename-rollback", + audiobook.Id, + item.FileId, rollbackSource, - rollbackDestination, - FileMoveOperationIdentity.Create( - "audiobook-file-rename-rollback", - audiobook.Id, - item.FileId, + rollbackDestination); + bool moved; + if (item.FileId == 0) + { + moved = await _fileMover.PerformActionOn( + FileAction.Move, rollbackSource, - rollbackDestination)); + rollbackDestination, + rollbackOperationId); + } + 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); + } if (!moved) { rollbackSucceeded = false; @@ -250,9 +197,4 @@ private sealed record AudiobookPathRollbackState( string? LegacyFilePath, long? FileSize, IReadOnlyDictionary FileStates); - - private sealed record DirectoryRollbackState( - string SourcePath, - string TargetPath, - AudiobookPathRollbackState AudiobookState); } diff --git a/listenarr.application/Audiobooks/Renaming/RenameService.Validation.cs b/listenarr.application/Audiobooks/Renaming/RenameService.Validation.cs index 6e57f4b49..307ca1340 100644 --- a/listenarr.application/Audiobooks/Renaming/RenameService.Validation.cs +++ b/listenarr.application/Audiobooks/Renaming/RenameService.Validation.cs @@ -39,21 +39,14 @@ public partial class RenameService } } - if (!string.IsNullOrWhiteSpace(operation.NewFolderPath) - && fileOperations.Count > 0) + if (!string.IsNullOrWhiteSpace(operation.NewFolderPath)) { - var expectedFileIds = audiobook.Files is { Count: > 0 } - ? audiobook.Files - .Where(file => !string.IsNullOrWhiteSpace(file.Path)) - .Select(file => file.Id) - .ToHashSet() - : !string.IsNullOrWhiteSpace(audiobook.FilePath) - ? new HashSet { 0 } - : []; + var expectedFileIds = GetTrackedFileIdsForFolderChange(audiobook); var requestedFileIds = fileOperations .Select(file => file.FileId) .ToHashSet(); - if (!expectedFileIds.SetEquals(requestedFileIds)) + if (expectedFileIds.Count == 0 + || !expectedFileIds.SetEquals(requestedFileIds)) { result.Error = "A folder-changing organize request must include every tracked audiobook file."; result.Conflict = true; diff --git a/listenarr.application/Audiobooks/Renaming/RenameService.cs b/listenarr.application/Audiobooks/Renaming/RenameService.cs index 118646f32..342bf927d 100644 --- a/listenarr.application/Audiobooks/Renaming/RenameService.cs +++ b/listenarr.application/Audiobooks/Renaming/RenameService.cs @@ -37,6 +37,7 @@ public partial class RenameService : IRenameService private readonly IFileSystemSemanticsResolver _semanticsResolver; private readonly IHistoryRepository? _historyRepository; private readonly IAudiobookOperationCoordinator _audiobookOperationCoordinator; + private readonly IMoveQueueService _moveQueueService; private readonly ILibraryDirectoryOwnershipStore _directoryOwnershipStore; public RenameService( @@ -51,6 +52,7 @@ public RenameService( ILogger logger, IFileSystemSemanticsResolver semanticsResolver, IAudiobookOperationCoordinator audiobookOperationCoordinator, + IMoveQueueService moveQueueService, ILibraryDirectoryOwnershipStore directoryOwnershipStore, IRootFolderService? rootFolderService = null, IHistoryRepository? historyRepository = null) @@ -68,6 +70,7 @@ public RenameService( _rootFolderService = rootFolderService; _historyRepository = historyRepository; _audiobookOperationCoordinator = audiobookOperationCoordinator ?? throw new ArgumentNullException(nameof(audiobookOperationCoordinator)); + _moveQueueService = moveQueueService ?? throw new ArgumentNullException(nameof(moveQueueService)); _directoryOwnershipStore = directoryOwnershipStore ?? throw new ArgumentNullException(nameof(directoryOwnershipStore)); } @@ -100,6 +103,15 @@ public async Task> ExecuteRenameAsync(List o operations.Select(operation => operation.AudiobookId), async keyedToken => { + foreach (var audiobookId in operations + .Select(operation => operation.AudiobookId) + .Distinct()) + { + await _moveQueueService.EnsureFilesystemMutationAllowedAsync( + audiobookId, + keyedToken); + } + var settings = await _configService.GetApplicationSettingsAsync(); var rootFolders = await LoadRootFoldersAsync(); var results = new List(operations.Count); @@ -255,7 +267,6 @@ private async Task ExecuteSingleAsync( var mutationToken = RequestCancellationBoundary.EnterNonCancelablePhase(ct); var result = new RenameResult { AudiobookId = operation.AudiobookId }; var audiobookRollbackState = CaptureAudiobookPathRollbackState(audiobook); - DirectoryRollbackState? directoryRollbackState = null; foreach (var fileOperation in operation.FileRenames ?? []) { var fileResult = await ExecuteFileRenameAsync( @@ -280,33 +291,7 @@ await RollBackFileRenamesAsync( } } - if (!hasFileOperations - && folderRequested - && !PathsEqual(currentBasePath, operation.NewFolderPath, semantics)) - { - directoryRollbackState = CaptureDirectoryRollbackState( - audiobook, - currentBasePath, - NormalizePath(operation.NewFolderPath)); - var directoryMove = await ExecuteDirectoryMoveAsync( - audiobook, - operation.NewFolderPath!, - allowedRoots, - rootFolders, - semantics, - mutationToken); - result.Success = directoryMove.Success; - result.Error = directoryMove.Error; - result.Conflict = directoryMove.Conflict; - if (!directoryMove.Success) - { - return result; - } - } - else - { - result.Success = true; - } + result.Success = true; if (hasFileOperations || folderRequested) { @@ -321,21 +306,13 @@ await RollBackFileRenamesAsync( catch (Exception persistenceException) when (persistenceException is not OutOfMemoryException && persistenceException is not StackOverflowException) { - var rollbackSucceeded = hasFileOperations - ? await RollBackFileRenamesAsync( - audiobook, - result.RenamedFiles, - audiobookRollbackState, - allowedRoots, - semantics, - CancellationToken.None) - : directoryRollbackState != null - && await RollBackDirectoryMoveAsync( - audiobook, - directoryRollbackState, - allowedRoots, - semantics, - CancellationToken.None); + var rollbackSucceeded = await RollBackFileRenamesAsync( + audiobook, + result.RenamedFiles, + audiobookRollbackState, + allowedRoots, + semantics, + CancellationToken.None); if (!rollbackSucceeded) { diff --git a/listenarr.application/Audiobooks/RootFolders/RootFolderService.cs b/listenarr.application/Audiobooks/RootFolders/RootFolderService.cs index 523e9fd07..2eb29ddad 100644 --- a/listenarr.application/Audiobooks/RootFolders/RootFolderService.cs +++ b/listenarr.application/Audiobooks/RootFolders/RootFolderService.cs @@ -69,7 +69,6 @@ private async Task CreateCoreAsync(RootFolder root) var resolution = await ResolveSemanticsAsync(root.Path, root.CaseSensitivityMode); ApplyIdentity(root, resolution); - await CaptureInitialDirectoryObjectIdentityAsync(root); if (await _relocationService.IsBoundaryProtectedAsync(root.Path, resolution.Semantics)) { throw new InvalidOperationException( @@ -82,6 +81,8 @@ private async Task CreateCoreAsync(RootFolder root) throw new InvalidOperationException(BuildRootFolderConflictMessage(conflict)); } + await CaptureInitialDirectoryObjectIdentityAsync(root); + if (root.IsDefault) { var currentDefaultId = (await _repo.GetDefaultAsync())?.Id; @@ -163,34 +164,39 @@ private async Task UpdateCoreAsync( if (existing == null) throw new KeyNotFoundException("Root folder not found"); await EnsureNoActiveRelocationAsync(existing.Id); - var existingResolution = await ResolvePersistedSemanticsAsync( - existing.Path, - existing.CaseSensitivityMode); + if (existing.CaseSensitivityMode != root.CaseSensitivityMode) + { + throw new InvalidOperationException( + "Root filesystem semantics cannot be changed by metadata updates; use the path-changes endpoint so persisted identities are migrated."); + } + + var persistedSemantics = RootFolderPathSemantics.ResolvePersisted(existing) + ?? throw new InvalidOperationException( + "Root filesystem semantics are unavailable; use the path-changes endpoint to repair persisted identities."); if (!FileSystemPathIdentity.AreEquivalent( existing.Path, root.Path, - existingResolution.Semantics)) + persistedSemantics.Semantics)) { throw new InvalidOperationException( "Root paths cannot be changed by metadata updates; use the path-changes endpoint."); } - await EnsureNoActiveMoveJobsTouchRootAsync(existing.Path, existingResolution.Semantics); + await EnsureNoActiveMoveJobsTouchRootAsync( + existing.Path, + persistedSemantics.Semantics); await ValidateExistingDirectoryObjectIdentityAsync(existing); existing.Name = root.Name; existing.IsDefault = root.IsDefault; - existing.CaseSensitivityMode = root.CaseSensitivityMode; - var resolution = await ResolvePersistedSemanticsAsync(existing.Path, root.CaseSensitivityMode); var conflict = await FindConflictingRootFolderAsync( existing.Path, - resolution.Semantics, + persistedSemantics.Semantics, existing.Id); if (conflict != null) { throw new InvalidOperationException(BuildRootFolderConflictMessage(conflict)); } - ApplyIdentity(existing, resolution); existing.UpdatedAt = DateTime.UtcNow; if (root.IsDefault) { @@ -259,13 +265,13 @@ private async Task EnsureNoActiveMoveJobsTouchRootAsync( string rootPath, FileSystemPathSemantics semantics) { - var activeJobsTask = _moveQueue.GetActiveJobsAsync(); - IReadOnlyList? activeJobs = activeJobsTask == null + var blockingJobsTask = _moveQueue.GetFilesystemBlockingJobsAsync(); + IReadOnlyList? blockingJobs = blockingJobsTask == null ? Array.Empty() - : await activeJobsTask; - activeJobs ??= Array.Empty(); + : await blockingJobsTask; + blockingJobs ??= Array.Empty(); - var conflictingJob = activeJobs.FirstOrDefault(job => + var conflictingJob = blockingJobs.FirstOrDefault(job => MoveJobBoundaryConflict.TouchesBoundary(job, rootPath, semantics)); if (conflictingJob == null) @@ -274,7 +280,7 @@ private async Task EnsureNoActiveMoveJobsTouchRootAsync( } throw new InvalidOperationException( - $"Root folder has active move job {conflictingJob.Id}; wait for queued or processing moves touching this root to finish before deleting or reassigning it."); + $"Root folder has unresolved move job {conflictingJob.Id}; resolve moves touching this root before deleting or reassigning it."); } private async Task ResolveSemanticsAsync( diff --git a/listenarr.application/Configuration/Contracts/Repositories/IApplicationSettingsRepository.cs b/listenarr.application/Configuration/Contracts/Repositories/IApplicationSettingsRepository.cs index 0a2b889bf..b123804b3 100644 --- a/listenarr.application/Configuration/Contracts/Repositories/IApplicationSettingsRepository.cs +++ b/listenarr.application/Configuration/Contracts/Repositories/IApplicationSettingsRepository.cs @@ -21,6 +21,9 @@ namespace Listenarr.Application.Configuration.Contracts.Repositories public interface IApplicationSettingsRepository { Task GetAsync(CancellationToken ct = default); + Task InitializeIfMissingAsync( + ApplicationSettings defaults, + CancellationToken ct = default); Task SaveAsync(ApplicationSettings settings, CancellationToken ct = default); } } diff --git a/listenarr.application/Configuration/Core/ConfigurationService.cs b/listenarr.application/Configuration/Core/ConfigurationService.cs index 3cb19e877..7adefede2 100644 --- a/listenarr.application/Configuration/Core/ConfigurationService.cs +++ b/listenarr.application/Configuration/Core/ConfigurationService.cs @@ -47,8 +47,8 @@ public async Task GetApplicationSettingsAsync() if (settings == null) { - settings = new ApplicationSettings(); - await settingsRepository.SaveAsync(settings); + settings = await settingsRepository.InitializeIfMissingAsync( + new ApplicationSettings()); } ApplyRuntimeDefaults(settings); @@ -62,7 +62,7 @@ public async Task GetApplicationSettingsAsync() catch (Exception exception) when (exception is not (OperationCanceledException or OutOfMemoryException or StackOverflowException)) { logger.LogError(exception, "Error loading application settings from database (no runtime ALTERs will be attempted)"); - return new ApplicationSettings(); + throw; } } @@ -140,6 +140,13 @@ public async Task SaveApplicationSettingsAsync(ApplicationSettings settings) // Preserve fields from existing settings when the incoming payload omits them. // Must run before normalization so null-checks catch truly absent fields. var existing = await settingsRepository.GetAsync(); + if (existing != null && settings.Version <= 0) + { + throw new ApplicationConflictException( + "settings_concurrency_conflict", + "Application settings must include the current version. Reload and try again."); + } + if (existing != null) { if (settings.ProwlarrUrl == null) diff --git a/listenarr.application/Downloads/Common/DownloadClientGateway.cs b/listenarr.application/Downloads/Common/DownloadClientGateway.cs index 8ea22af7e..bdc201748 100644 --- a/listenarr.application/Downloads/Common/DownloadClientGateway.cs +++ b/listenarr.application/Downloads/Common/DownloadClientGateway.cs @@ -342,16 +342,15 @@ private async Task> ResolveSourceFileComparerAsync(Que private static void EnsureNativePath(string? path, string clientName) { - if (string.IsNullOrWhiteSpace(path)) + if (string.IsNullOrEmpty(path)) { return; } - var valid = OperatingSystem.IsWindows() - ? path.Length >= 3 && char.IsLetter(path[0]) && path[1] == ':' && path[2] is '/' or '\\' - || path.StartsWith("\\\\", StringComparison.Ordinal) - : path[0] == '/'; - if (!valid) + if (!FileSystemPathIdentity.TryCanonicalizeUnambiguousStoredAbsolutePathForHost( + path, + out _, + out _)) { throw new InvalidOperationException( $"Download client '{clientName}' reported a save path that is not valid on this host; check its remote path mappings."); diff --git a/listenarr.application/Downloads/Contracts/IFileMover.cs b/listenarr.application/Downloads/Contracts/IFileMover.cs index 31e808472..be1341722 100644 --- a/listenarr.application/Downloads/Contracts/IFileMover.cs +++ b/listenarr.application/Downloads/Contracts/IFileMover.cs @@ -19,12 +19,21 @@ namespace Listenarr.Application.Downloads.Contracts { /// - /// This class responsability is to handle all file manipulation operations + /// Handles file manipulation within a destination hierarchy that has already + /// been established by the caller. Implementations must not create missing + /// managed destination parents; library hierarchy creation and enrollment + /// belong to . /// public interface IFileMover { Task MoveDirectoryAsync(string source, string destination); + Task MoveFilePreservingPhysicalIdentityAsync( + string source, + string destination, + string expectedSourcePhysicalObjectIdentity, + Guid? operationId = null); + Task CopyDirectoryAsync(string source, string destination); /// diff --git a/listenarr.application/Downloads/Import/DownloadImportService.Coordination.cs b/listenarr.application/Downloads/Import/DownloadImportService.Coordination.cs index da4110132..ee3f59029 100644 --- a/listenarr.application/Downloads/Import/DownloadImportService.Coordination.cs +++ b/listenarr.application/Downloads/Import/DownloadImportService.Coordination.cs @@ -14,6 +14,9 @@ public Task> ImportDownloadFilesAsync( audiobook.Id, async token => { + await moveQueueService.EnsureFilesystemMutationAllowedAsync( + audiobook.Id, + token); var currentAudiobook = await audiobookRepository.GetByIdSnapshotAsync( audiobook.Id, token) diff --git a/listenarr.application/Downloads/Import/DownloadImportService.cs b/listenarr.application/Downloads/Import/DownloadImportService.cs index ce52d8c76..98b8021c4 100644 --- a/listenarr.application/Downloads/Import/DownloadImportService.cs +++ b/listenarr.application/Downloads/Import/DownloadImportService.cs @@ -34,6 +34,7 @@ public partial class DownloadImportService( IAudiobookRepository audiobookRepository, IFilesystemMutationCoordinator filesystemMutationCoordinator, IAudiobookOperationCoordinator audiobookOperationCoordinator, + IMoveQueueService moveQueueService, ILibraryDirectoryOwnershipStore directoryOwnershipStore, ILogger logger) : IDownloadImportService { diff --git a/listenarr.application/Metadata/Contracts/IMetadataService.cs b/listenarr.application/Metadata/Contracts/IMetadataService.cs index 27dcf2c18..b6f664de5 100644 --- a/listenarr.application/Metadata/Contracts/IMetadataService.cs +++ b/listenarr.application/Metadata/Contracts/IMetadataService.cs @@ -77,6 +77,18 @@ Task FetchMetadataAsync( /// Task WriteAsinTagAsync(string filePath, string asin); + /// + /// Writes the ASIN through a generation-bound registration lease so a + /// replacement pathname cannot receive metadata intended for the + /// published audiobook file. + /// + Task WriteAsinTagAsync( + IAudiobookFileRegistrationLease registrationLease, + string asin) => + Task.FromException( + new NotSupportedException( + "Generation-bound ASIN tagging is unavailable.")); + /// /// Downloads cover art image from URL /// diff --git a/listenarr.application/Metadata/Extraction/MetadataService.cs b/listenarr.application/Metadata/Extraction/MetadataService.cs index dd241217c..f14eb3cfd 100644 --- a/listenarr.application/Metadata/Extraction/MetadataService.cs +++ b/listenarr.application/Metadata/Extraction/MetadataService.cs @@ -247,6 +247,15 @@ public Task WriteAsinTagAsync(string filePath, string asin) return _audioTagWriter.WriteAsinTagAsync(filePath, asin); } + public Task WriteAsinTagAsync( + IAudiobookFileRegistrationLease registrationLease, + string asin) + { + return _audioTagWriter.WriteAsinTagAsync( + registrationLease, + asin); + } + public async Task DownloadCoverArtAsync(string coverArtUrl) { try diff --git a/listenarr.domain/Audiobooks/MoveJobBoundaryConflict.cs b/listenarr.domain/Audiobooks/MoveJobBoundaryConflict.cs index c6d3f1792..5220805ef 100644 --- a/listenarr.domain/Audiobooks/MoveJobBoundaryConflict.cs +++ b/listenarr.domain/Audiobooks/MoveJobBoundaryConflict.cs @@ -11,11 +11,11 @@ public static bool TouchesBoundary( { ArgumentNullException.ThrowIfNull(job); ArgumentException.ThrowIfNullOrWhiteSpace(boundaryPath); - if (!job.Status.IsActive()) - { - return false; - } + // This primitive answers path geometry only. Callers own the lifecycle policy + // that decides which move jobs are relevant (active, unresolved, historical, + // etc.); hiding an active-status filter here caused unresolved terminal moves + // to bypass root-folder mutation fences. return EndpointTouchesBoundary( job.SourcePath, job.TryGetSourceIdentity(out var sourceIdentity) @@ -62,8 +62,8 @@ public static bool EndpointTouchesBoundary( ArgumentException or InvalidOperationException or NotSupportedException or PathTooLongException) { - // Active jobs with malformed or incomplete endpoint identity must block a - // destructive root mutation until the job is reconciled or repaired. + // A caller that selected this job as relevant must fail closed when its + // endpoint identity is malformed or incomplete. return true; } } diff --git a/listenarr.infrastructure/DependencyInjection/Library/LibraryRegistrationExtensions.cs b/listenarr.infrastructure/DependencyInjection/Library/LibraryRegistrationExtensions.cs index 99a846507..1cc6ae47d 100644 --- a/listenarr.infrastructure/DependencyInjection/Library/LibraryRegistrationExtensions.cs +++ b/listenarr.infrastructure/DependencyInjection/Library/LibraryRegistrationExtensions.cs @@ -7,6 +7,7 @@ * by the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. */ +using Listenarr.Application.Audiobooks.Deletion; using Listenarr.Application.Audiobooks.RootFolders; using Listenarr.Infrastructure.Library.Realtime; using Listenarr.Infrastructure.Persistence; @@ -30,6 +31,7 @@ public static IServiceCollection AddLibraryServices(this IServiceCollection serv services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/listenarr.infrastructure/FileSystem/FileMover.Copying.Helpers.cs b/listenarr.infrastructure/FileSystem/FileMover.Copying.Helpers.cs index d8484aa8c..41480a0c9 100644 --- a/listenarr.infrastructure/FileSystem/FileMover.Copying.Helpers.cs +++ b/listenarr.infrastructure/FileSystem/FileMover.Copying.Helpers.cs @@ -1,5 +1,3 @@ -using System.Diagnostics; - namespace Listenarr.Infrastructure.FileSystem; public partial class FileMover @@ -20,31 +18,4 @@ private static void CapturePublishedRegistrationLease( } } - private static string Truncate(string? value, int max) - { - if (string.IsNullOrEmpty(value)) return string.Empty; - if (value.Length <= max) return value; - return value[..max] + "..."; - } - - private static ProcessStartInfo CreateRobocopyStartInfo( - params string[] arguments) - { - var startInfo = new ProcessStartInfo - { - FileName = "robocopy", - CreateNoWindow = true, - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true - }; - - foreach (var argument in arguments.Where( - argument => !string.IsNullOrWhiteSpace(argument))) - { - startInfo.ArgumentList.Add(argument); - } - - return startInfo; - } } diff --git a/listenarr.infrastructure/FileSystem/FileMover.Copying.Outcomes.cs b/listenarr.infrastructure/FileSystem/FileMover.Copying.Outcomes.cs new file mode 100644 index 000000000..be38a459b --- /dev/null +++ b/listenarr.infrastructure/FileSystem/FileMover.Copying.Outcomes.cs @@ -0,0 +1,15 @@ +namespace Listenarr.Infrastructure.FileSystem; + +internal enum IdempotentFileMoveOutcome +{ + NotApplicable, + Completed, + SourcePathRecreated +} + +internal enum SameContentShortcutOutcome +{ + NotApplicable, + Completed, + Blocked +} diff --git a/listenarr.infrastructure/FileSystem/FileMover.Copying.cs b/listenarr.infrastructure/FileSystem/FileMover.Copying.cs index 858f3fb15..8860eee17 100644 --- a/listenarr.infrastructure/FileSystem/FileMover.Copying.cs +++ b/listenarr.infrastructure/FileSystem/FileMover.Copying.cs @@ -7,20 +7,6 @@ namespace Listenarr.Infrastructure.FileSystem { - internal enum IdempotentFileMoveOutcome - { - NotApplicable, - Completed, - SourcePathRecreated - } - - internal enum SameContentShortcutOutcome - { - NotApplicable, - Completed, - Blocked - } - public partial class FileMover : IFileMover { public async Task CopyDirectoryAsync(string sourceDir, string destDir) @@ -144,17 +130,6 @@ private async Task CopyOrHardlinkPinnedFileAsync( { try { - if (await IsFilesystemAliasAsync(sourceFile, destFile)) - { - LogMutation( - FileMutationOutcome.Blocked, - action, - sourceFile, - destFile, - "Source and destination are linked aliases of the same file"); - return false; - } - if (await IsSameFilesystemPathAsync(sourceFile, destFile)) { if (capturePublication != null) @@ -172,6 +147,44 @@ private async Task CopyOrHardlinkPinnedFileAsync( return true; } + using var lease = await TryAcquireFileMoveGateAsync( + sourceFile, + destFile, + allowExistingAliasForRecovery: true); + if (lease == null) + { + return false; + } + + var publicationStateName = + await GetPreparedFilePublicationStateNameAsync(destFile); + var recovery = await RecoverPreparedFilePublicationAsync( + lease.DestinationParent, + lease.DestinationName, + publicationStateName); + if (recovery.Outcome + == PreparedPublicationRecoveryOutcome.Completed) + { + return TryCompleteRecoveredPreparedPublication( + recovery, + lease, + action, + sourceFile, + destFile, + capturePublication); + } + + if (await IsFilesystemAliasAsync(sourceFile, destFile)) + { + LogMutation( + FileMutationOutcome.Blocked, + action, + sourceFile, + destFile, + "Source and destination are linked aliases of the same file"); + return false; + } + if (!TryValidateUnlinkedFileOperationEndpoints( sourceFile, destFile, @@ -194,22 +207,6 @@ await BeforeFileSameContentShortcutForTestAsync( destFile); } - using var lease = await TryAcquireFileMoveGateAsync( - sourceFile, - destFile, - createDestinationParent: true); - if (lease == null) - { - return false; - } - - var publicationStateName = - await GetPreparedFilePublicationStateNameAsync(destFile); - RecoverPreparedFilePublication( - lease.DestinationParent, - lease.DestinationName, - publicationStateName); - if (AfterFileEndpointsPinnedForTestAsync != null) { await AfterFileEndpointsPinnedForTestAsync( @@ -362,6 +359,7 @@ await AfterPinnedSourceContentCapturedForTestAsync( $".listenarr-file-copy-{Guid.NewGuid():N}.tmp"; PinnedDirectoryCreation.PinnedFileEntry? prepared = null; var published = false; + var durablePublicationOwnsPrepared = false; try { if (preferHardlink @@ -441,10 +439,12 @@ IOException or System.ComponentModel.Win32Exception { await PublishPreparedFileReplacingCapturedDestinationAsync( prepared, + sourceEntry.GetObjectIdentity(), lease.DestinationParent, lease.DestinationName, destinationEntry, - publicationStateName); + publicationStateName, + () => durablePublicationOwnsPrepared = true); } published = true; if (capturePublication != null) @@ -470,6 +470,7 @@ await PublishPreparedFileReplacingCapturedDestinationAsync( finally { if (!published + && !durablePublicationOwnsPrepared && prepared != null && prepared.VisiblePathMatches()) { diff --git a/listenarr.infrastructure/FileSystem/FileMover.DirectoryCleanupJournal.Validation.cs b/listenarr.infrastructure/FileSystem/FileMover.DirectoryCleanupJournal.Validation.cs index f5c5dac0b..6ffd0d769 100644 --- a/listenarr.infrastructure/FileSystem/FileMover.DirectoryCleanupJournal.Validation.cs +++ b/listenarr.infrastructure/FileSystem/FileMover.DirectoryCleanupJournal.Validation.cs @@ -249,9 +249,8 @@ private async Task DeletePinnedCleanupTreeAsync( { return false; } - childPublication.DeletePinnedEmptyDirectory( - name, - immediateWindows: true); + childPublication.RetirePinnedEmptyDirectoryFromNamespace( + name); continue; } var isRecoveryFile = false; diff --git a/listenarr.infrastructure/FileSystem/FileMover.DirectoryCleanupJournal.cs b/listenarr.infrastructure/FileSystem/FileMover.DirectoryCleanupJournal.cs index 76ae15a6d..bd649ede3 100644 --- a/listenarr.infrastructure/FileSystem/FileMover.DirectoryCleanupJournal.cs +++ b/listenarr.infrastructure/FileSystem/FileMover.DirectoryCleanupJournal.cs @@ -191,6 +191,21 @@ is not { } sourceParent } } + using var journal = parent.OpenExistingFile( + journalName, + requireDeleteAccess: true); + var currentJournal = await ReadCleanupJournalAsync(journal); + if (currentJournal == null + || currentJournal.OperationId != payload.OperationId + || !string.Equals( + currentJournal.ManifestHash, + payload.ManifestHash, + StringComparison.Ordinal) + || !journal.VisiblePathMatches()) + { + return false; + } + if (!await ValidateCleanupManifestAsync( payload, quarantine.FullPath, @@ -202,15 +217,12 @@ is not { } sourceParent { return false; } - quarantinePublication.DeletePinnedEmptyDirectory( - payload.QuarantineName, - immediateWindows: true); + quarantinePublication.RetirePinnedEmptyDirectoryFromNamespace( + payload.QuarantineName); FlushFileMoveDirectory( parent, "recovered directory cleanup quarantine retirement"); - using var journal = parent.OpenExistingFile( - journalName, - requireDeleteAccess: true); + AfterCleanupQuarantineRetiredForTest?.Invoke(journal.FullPath); journal.Delete(immediateWindows: true); FlushFileMoveDirectory( parent, @@ -426,9 +438,8 @@ private async Task ExecuteJournaledDirectoryCleanupA false, "The quarantined source changed during retirement; recovery evidence was preserved."); } - quarantinePublication.DeletePinnedEmptyDirectory( - quarantineName, - immediateWindows: true); + quarantinePublication.RetirePinnedEmptyDirectoryFromNamespace( + quarantineName); FlushFileMoveDirectory( sourceParent, "directory cleanup quarantine retirement"); diff --git a/listenarr.infrastructure/FileSystem/FileMover.DirectoryCopy.Validation.cs b/listenarr.infrastructure/FileSystem/FileMover.DirectoryCopy.Validation.cs index 6b12622e3..3f6212b7b 100644 --- a/listenarr.infrastructure/FileSystem/FileMover.DirectoryCopy.Validation.cs +++ b/listenarr.infrastructure/FileSystem/FileMover.DirectoryCopy.Validation.cs @@ -307,9 +307,8 @@ private async Task TryCleanupDirectoryCopyStagingAsync( } child.Dispose(); - directory.DeletePinnedEmptyDirectory( - childName, - immediateWindows: true); + directory.RetirePinnedEmptyDirectoryFromNamespace( + childName); } if (Directory.EnumerateFileSystemEntries(stagingAnchor.FullPath).Any() @@ -320,9 +319,8 @@ private async Task TryCleanupDirectoryCopyStagingAsync( } stagingAnchor.Dispose(); - stagingPublication.DeletePinnedEmptyDirectory( - stagingName, - immediateWindows: true); + stagingPublication.RetirePinnedEmptyDirectoryFromNamespace( + stagingName); } catch (Exception exception) when (exception is not ( OperationCanceledException or OutOfMemoryException or StackOverflowException)) diff --git a/listenarr.infrastructure/FileSystem/FileMover.DirectoryNativeMove.cs b/listenarr.infrastructure/FileSystem/FileMover.DirectoryNativeMove.cs index e7a33440f..3b64906b4 100644 --- a/listenarr.infrastructure/FileSystem/FileMover.DirectoryNativeMove.cs +++ b/listenarr.infrastructure/FileSystem/FileMover.DirectoryNativeMove.cs @@ -36,7 +36,7 @@ private PinnedDirectoryMoveOutcome TryPinnedSameVolumeDirectoryMove( using var destinationParent = PinnedDirectoryCreation.OpenPinnedHierarchyNoFollow( destinationParentPath, - createMissing: true); + createMissing: false); using var sourcePublication = sourceParent.OpenExistingChildForPublication( Path.GetFileName(source)); diff --git a/listenarr.infrastructure/FileSystem/FileMover.DirectoryRenameJournal.cs b/listenarr.infrastructure/FileSystem/FileMover.DirectoryRenameJournal.cs index b9561591f..9bd88f44b 100644 --- a/listenarr.infrastructure/FileSystem/FileMover.DirectoryRenameJournal.cs +++ b/listenarr.infrastructure/FileSystem/FileMover.DirectoryRenameJournal.cs @@ -142,7 +142,10 @@ private static void TryRetireDirectoryRenameJournal( PinnedDirectoryCreation.OpenPinnedHierarchyNoFollow( sourceParentPath, createMissing: false); - var matches = new List<(string Name, DirectoryRenameJournalPayload Payload)>(); + var matches = new List<( + string Name, + string ObjectIdentity, + DirectoryRenameJournalPayload Payload)>(); var journalPattern = $"{GetDirectoryRenameJournalStem(source, destination)}-*.journal"; foreach (var path in Directory.EnumerateFiles( @@ -176,11 +179,14 @@ private static void TryRetireDirectoryRenameJournal( return PinnedDirectoryMoveOutcome.Indeterminate; } - matches.Add((name, payload with - { - SourcePath = payloadSource, - DestinationPath = payloadDestination - })); + matches.Add(( + name, + journal.GetObjectIdentity(), + payload with + { + SourcePath = payloadSource, + DestinationPath = payloadDestination + })); } if (matches.Count == 0) @@ -240,12 +246,18 @@ private static void TryRetireDirectoryRenameJournal( return outcome; } + BeforeDirectoryRenameJournalRetirementForTest?.Invoke( + Path.Join(sourceParent.FullPath, match.Name)); using var journalForDelete = sourceParent.OpenExistingFile( match.Name, requireDeleteAccess: true); var revalidated = ReadDirectoryRenameJournal(journalForDelete); if (revalidated == null || revalidated.OperationId != match.Payload.OperationId + || !string.Equals( + journalForDelete.GetObjectIdentity(), + match.ObjectIdentity, + StringComparison.Ordinal) || !journalForDelete.VisiblePathMatches()) { return PinnedDirectoryMoveOutcome.Indeterminate; diff --git a/listenarr.infrastructure/FileSystem/FileMover.FileFallback.cs b/listenarr.infrastructure/FileSystem/FileMover.FileFallback.cs index d45941c0a..7460bbfab 100644 --- a/listenarr.infrastructure/FileSystem/FileMover.FileFallback.cs +++ b/listenarr.infrastructure/FileSystem/FileMover.FileFallback.cs @@ -13,7 +13,9 @@ private enum FileMoveFallbackOutcome private async Task TryManagedFileMoveFallbackAsync( FileMoveGateLease lease, - Guid? operationId) + Guid? operationId, + string? expectedSourcePhysicalObjectIdentity = null, + bool requirePhysicalIdentityPreservation = false) { var sourceFile = lease.SourcePath; var destinationFile = lease.DestinationPath; @@ -27,7 +29,9 @@ private async Task TryManagedFileMoveFallbackAsync( { return await TryRemoveVerifiedFileMoveSourceAsync( lease, - operationId); + operationId, + expectedSourcePhysicalObjectIdentity, + requirePhysicalIdentityPreservation); } catch (Exception exception) when (exception is not ( OperationCanceledException or OutOfMemoryException or StackOverflowException)) @@ -43,13 +47,17 @@ private async Task TryManagedFileMoveFallbackAsync( private async Task TryRemoveVerifiedFileMoveSourceAsync( FileMoveGateLease lease, - Guid? operationId) + Guid? operationId, + string? expectedSourcePhysicalObjectIdentity = null, + bool requirePhysicalIdentityPreservation = false) { var sourceFile = lease.SourcePath; var destinationFile = lease.DestinationPath; var removalOutcome = await TryRemoveVerifiedFileMoveSourceWithClaimsAsync( lease, - operationId); + operationId, + expectedSourcePhysicalObjectIdentity, + requirePhysicalIdentityPreservation); if (removalOutcome == VerifiedFileMoveRemovalOutcome.Removed) { return FileMoveFallbackOutcome.Success; diff --git a/listenarr.infrastructure/FileSystem/FileMover.FileMoveClaims.cs b/listenarr.infrastructure/FileSystem/FileMover.FileMoveClaims.cs index 355ab81c9..39ee19aea 100644 --- a/listenarr.infrastructure/FileSystem/FileMover.FileMoveClaims.cs +++ b/listenarr.infrastructure/FileSystem/FileMover.FileMoveClaims.cs @@ -47,7 +47,9 @@ private sealed record FileMoveStatePaths( private async Task TryRemoveVerifiedFileMoveSourceWithClaimsAsync( FileMoveGateLease lease, - Guid? operationId) + Guid? operationId, + string? expectedSourcePhysicalObjectIdentity = null, + bool requirePhysicalIdentityPreservation = false) { var sourceFile = lease.SourcePath; var destinationFile = lease.DestinationPath; @@ -87,6 +89,21 @@ private async Task TryRemoveVerifiedFileMoveSour { return VerifiedFileMoveRemovalOutcome.NotRemoved; } + if (!string.IsNullOrWhiteSpace(expectedSourcePhysicalObjectIdentity) + && !string.Equals( + initialSource.GetObjectIdentity(), + expectedSourcePhysicalObjectIdentity, + StringComparison.Ordinal)) + { + return VerifiedFileMoveRemovalOutcome.NotRemoved; + } + if (requirePhysicalIdentityPreservation + && (DisableNativeFileRenameForTest + || !initialSource.IsOnSameVolume( + lease.DestinationParent))) + { + return VerifiedFileMoveRemovalOutcome.NotRemoved; + } FlushFileMoveDirectory( lease.SourceParent, "source durability capability"); @@ -104,6 +121,9 @@ private async Task TryRemoveVerifiedFileMoveSour } var sourceSnapshot = await CaptureFileMoveContentAsync(initialSource); + var sourceObjectIdentity = initialSource.GetObjectIdentity(); + var destinationPreviousObjectIdentity = + initialDestination?.GetObjectIdentity(); var useNativeRename = !DisableNativeFileRenameForTest && initialSource.IsOnSameVolume( lease.DestinationParent); @@ -122,6 +142,10 @@ await WriteFileMoveContentAsync( operationId, sourceIdentity, destinationIdentity, + sourceObjectIdentity, + destinationStageObjectIdentity: null, + destinationPreviousObjectIdentity, + publishedDestinationObjectIdentity: null, useNativeRename); FlushFileMoveDirectory(sourceState, "source operation state"); FlushFileMoveDirectory(lease.SourceParent, "source state publication"); @@ -166,7 +190,6 @@ await AfterSourceQuarantinedForTestAsync( initialDestination.MoveTo( destinationState, "destination.previous"); - initialDestination.Dispose(); FlushFileMoveDirectory( lease.DestinationParent, "previous destination quarantine removal"); @@ -175,6 +198,7 @@ await AfterSourceQuarantinedForTestAsync( "previous destination quarantine publication"); } PinnedDirectoryCreation.PinnedFileEntry? destinationStage = null; + string? destinationStageObjectIdentity = null; try { if (!useNativeRename) @@ -198,6 +222,22 @@ await AfterSourceQuarantinedForTestAsync( FlushFileMoveDirectory( destinationState, "destination stage bytes and metadata"); + destinationStageObjectIdentity = + destinationStage.GetObjectIdentity(); + await WriteFileMoveContentAsync( + operationState, + sourceSnapshot, + operationId, + sourceIdentity, + destinationIdentity, + sourceObjectIdentity, + destinationStageObjectIdentity, + destinationPreviousObjectIdentity, + publishedDestinationObjectIdentity: null, + nativeRename: false); + FlushFileMoveDirectory( + sourceState, + "destination stage generation evidence"); } if (AfterDestinationQuarantinedForTestAsync != null) @@ -221,6 +261,17 @@ await AfterDestinationQuarantinedForTestAsync( "The verified source or destination stage changed before source retirement."); } + using var publicationClaim = useNativeRename + ? sourceClaim.CreateHardLinkTo( + destinationState, + "destination.published.claim") + : destinationStage!.CreateHardLinkTo( + destinationState, + "destination.published.claim"); + FlushFileMoveDirectory( + destinationState, + "destination publication generation claim"); + using var generationFence = sourceState.CreateNewFile( "replacement-generation.fence"); generationFence.FlushToDisk(); @@ -267,6 +318,31 @@ await AfterDestinationQuarantinedForTestAsync( FlushFileMoveDirectory( lease.DestinationParent, "destination publication"); + using var publishedDestination = + lease.DestinationParent.TryOpenExistingFile( + lease.DestinationName, + requireDeleteAccess: false); + if (publishedDestination == null + || !publicationClaim.VisiblePathMatches() + || !publishedDestination.VisiblePathMatches() + || !publicationClaim.IdentifiesSameEntry(publishedDestination)) + { + return VerifiedFileMoveRemovalOutcome.NotRemoved; + } + await WriteFileMoveContentAsync( + operationState, + sourceSnapshot, + operationId, + sourceIdentity, + destinationIdentity, + sourceObjectIdentity, + destinationStageObjectIdentity, + destinationPreviousObjectIdentity, + publishedDestination.GetObjectIdentity(), + useNativeRename); + FlushFileMoveDirectory( + sourceState, + "published destination generation evidence"); if (!useNativeRename) { FlushFileMoveDirectory( @@ -278,14 +354,28 @@ await AfterDestinationQuarantinedForTestAsync( await AfterDestinationPublishedForTestAsync(destinationFile); } - using var previous = destinationState.TryOpenExistingFile( - "destination.previous", - requireDeleteAccess: true); - previous?.Delete(immediateWindows: true); - previous?.Dispose(); + if (initialDestination != null) + { + if (!initialDestination.VisiblePathMatches()) + { + return VerifiedFileMoveRemovalOutcome.NotRemoved; + } + + initialDestination.Delete(immediateWindows: true); + initialDestination.Dispose(); + } FlushFileMoveDirectory( destinationState, "previous destination retirement"); + if (!publicationClaim.VisiblePathMatches()) + { + return VerifiedFileMoveRemovalOutcome.NotRemoved; + } + publicationClaim.Delete(immediateWindows: true); + publicationClaim.Dispose(); + FlushFileMoveDirectory( + destinationState, + "destination publication claim retirement"); using var recreatedSource = lease.SourceParent.TryOpenExistingFile( lease.SourceName, requireDeleteAccess: false); @@ -302,13 +392,11 @@ await AfterDestinationQuarantinedForTestAsync( destinationState.Dispose(); if (!sourcePathWasRecreated) { - sourceStatePublication.DeletePinnedEmptyDirectory( - Path.GetFileName(state.SourceStateDirectory), - immediateWindows: true); + sourceStatePublication.RetirePinnedEmptyDirectoryFromNamespace( + Path.GetFileName(state.SourceStateDirectory)); } - destinationStatePublication.DeletePinnedEmptyDirectory( - Path.GetFileName(state.DestinationStateDirectory), - immediateWindows: true); + destinationStatePublication.RetirePinnedEmptyDirectoryFromNamespace( + Path.GetFileName(state.DestinationStateDirectory)); FlushFileMoveDirectory( lease.DestinationParent, "destination state retirement"); diff --git a/listenarr.infrastructure/FileSystem/FileMover.FileMoveContent.cs b/listenarr.infrastructure/FileSystem/FileMover.FileMoveContent.cs index d427b0684..ccfb0e13c 100644 --- a/listenarr.infrastructure/FileSystem/FileMover.FileMoveContent.cs +++ b/listenarr.infrastructure/FileSystem/FileMover.FileMoveContent.cs @@ -11,6 +11,10 @@ private readonly record struct FileMoveFence( Guid? OperationId, string SourceIdentity, string DestinationIdentity, + string? SourceObjectIdentity, + string? DestinationStageObjectIdentity, + string? DestinationPreviousObjectIdentity, + string? PublishedDestinationObjectIdentity, bool NativeRename, FileMoveContent Content); @@ -68,14 +72,23 @@ private static async Task WriteFileMoveContentAsync( Guid? operationId, string sourceIdentity, string destinationIdentity, + string sourceObjectIdentity, + string? destinationStageObjectIdentity, + string? destinationPreviousObjectIdentity, + string? publishedDestinationObjectIdentity, bool nativeRename) { - const int version = 1; + const int version = 2; + ArgumentException.ThrowIfNullOrWhiteSpace(sourceObjectIdentity); var body = $"version={version}\n" + $"operationId={operationId?.ToString("D") ?? string.Empty}\n" + $"sourceIdentity={sourceIdentity}\n" + $"destinationIdentity={destinationIdentity}\n" + + $"sourceObjectIdentity={sourceObjectIdentity}\n" + + $"destinationStageObjectIdentity={destinationStageObjectIdentity ?? string.Empty}\n" + + $"destinationPreviousObjectIdentity={destinationPreviousObjectIdentity ?? string.Empty}\n" + + $"publishedDestinationObjectIdentity={publishedDestinationObjectIdentity ?? string.Empty}\n" + $"mode={(nativeRename ? "native" : "copy")}\n" + $"length={content.Length.ToString(System.Globalization.CultureInfo.InvariantCulture)}\n" + $"sha256={content.Sha256}\n"; @@ -86,6 +99,8 @@ private static async Task WriteFileMoveContentAsync( await using var stream = entry.OpenWriteStream( bufferSize: 4096, asynchronous: false); + stream.SetLength(0); + stream.Position = 0; await stream.WriteAsync(payload); await stream.FlushAsync(); stream.Flush(flushToDisk: true); @@ -116,35 +131,50 @@ private static async Task WriteFileMoveContentAsync( var parts = Encoding.UTF8.GetString(payload) .Split('\n', StringSplitOptions.RemoveEmptyEntries); - if (parts.Length != 8 - || parts[0] != "version=1" + var version = parts.Length > 0 && parts[0] == "version=2" + ? 2 + : parts.Length > 0 && parts[0] == "version=1" + ? 1 + : 0; + var modeIndex = version == 2 ? 8 : 4; + var lengthIndex = version == 2 ? 9 : 5; + var shaIndex = version == 2 ? 10 : 6; + var checksumIndex = version == 2 ? 11 : 7; + var expectedPartCount = version == 2 ? 12 : 8; + if (version == 0 + || parts.Length != expectedPartCount || !parts[1].StartsWith("operationId=", StringComparison.Ordinal) || !parts[2].StartsWith("sourceIdentity=", StringComparison.Ordinal) || !parts[3].StartsWith("destinationIdentity=", StringComparison.Ordinal) - || parts[4] is not ("mode=native" or "mode=copy") - || !parts[5].StartsWith("length=", StringComparison.Ordinal) - || !parts[6].StartsWith("sha256=", StringComparison.Ordinal) - || !parts[7].StartsWith("checksum=", StringComparison.Ordinal) + || (version == 2 + && (!parts[4].StartsWith("sourceObjectIdentity=", StringComparison.Ordinal) + || !parts[5].StartsWith("destinationStageObjectIdentity=", StringComparison.Ordinal) + || !parts[6].StartsWith("destinationPreviousObjectIdentity=", StringComparison.Ordinal) + || !parts[7].StartsWith("publishedDestinationObjectIdentity=", StringComparison.Ordinal))) + || parts[modeIndex] is not ("mode=native" or "mode=copy") + || !parts[lengthIndex].StartsWith("length=", StringComparison.Ordinal) + || !parts[shaIndex].StartsWith("sha256=", StringComparison.Ordinal) + || !parts[checksumIndex].StartsWith("checksum=", StringComparison.Ordinal) || !long.TryParse( - parts[5]["length=".Length..], + parts[lengthIndex]["length=".Length..], System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out var length) || length < 0 - || parts[6].Length != "sha256=".Length + 64 - || parts[6]["sha256=".Length..].Any(character => !Uri.IsHexDigit(character)) - || parts[7].Length != "checksum=".Length + 64 - || parts[7]["checksum=".Length..].Any(character => !Uri.IsHexDigit(character))) + || parts[shaIndex].Length != "sha256=".Length + 64 + || parts[shaIndex]["sha256=".Length..].Any(character => !Uri.IsHexDigit(character)) + || parts[checksumIndex].Length != "checksum=".Length + 64 + || parts[checksumIndex]["checksum=".Length..].Any(character => !Uri.IsHexDigit(character))) { return null; } - var body = string.Join('\n', parts[..7]) + "\n"; + var body = string.Join('\n', parts[..checksumIndex]) + "\n"; var expectedChecksum = Convert.ToHexString( SHA256.HashData(Encoding.UTF8.GetBytes(body))); if (!string.Equals( expectedChecksum, - parts[7]["checksum=".Length..], + parts[checksumIndex]["checksum=".Length..], StringComparison.OrdinalIgnoreCase)) { return null; @@ -162,23 +192,51 @@ private static async Task WriteFileMoveContentAsync( } var sourceIdentity = parts[2]["sourceIdentity=".Length..]; var destinationIdentity = parts[3]["destinationIdentity=".Length..]; + var sourceObjectIdentity = version == 2 + ? parts[4]["sourceObjectIdentity=".Length..] + : null; + var destinationStageObjectIdentity = version == 2 + ? parts[5]["destinationStageObjectIdentity=".Length..] + : null; + var destinationPreviousObjectIdentity = version == 2 + ? parts[6]["destinationPreviousObjectIdentity=".Length..] + : null; + var publishedDestinationObjectIdentity = version == 2 + ? parts[7]["publishedDestinationObjectIdentity=".Length..] + : null; if (sourceIdentity.Length == 0 || destinationIdentity.Length == 0 || sourceIdentity.Contains('\r') - || destinationIdentity.Contains('\r')) + || destinationIdentity.Contains('\r') + || (version == 2 + && (string.IsNullOrWhiteSpace(sourceObjectIdentity) + || sourceObjectIdentity.Contains('\r') + || destinationStageObjectIdentity!.Contains('\r') + || destinationPreviousObjectIdentity!.Contains('\r') + || publishedDestinationObjectIdentity!.Contains('\r')))) { return null; } return new FileMoveFence( - Version: 1, + version, operationId, sourceIdentity, destinationIdentity, - NativeRename: parts[4] == "mode=native", + sourceObjectIdentity, + string.IsNullOrEmpty(destinationStageObjectIdentity) + ? null + : destinationStageObjectIdentity, + string.IsNullOrEmpty(destinationPreviousObjectIdentity) + ? null + : destinationPreviousObjectIdentity, + string.IsNullOrEmpty(publishedDestinationObjectIdentity) + ? null + : publishedDestinationObjectIdentity, + NativeRename: parts[modeIndex] == "mode=native", new FileMoveContent( length, - parts[6]["sha256=".Length..].ToUpperInvariant())); + parts[shaIndex]["sha256=".Length..].ToUpperInvariant())); } private static async Task ReadLegacyFileMoveContentAsync( diff --git a/listenarr.infrastructure/FileSystem/FileMover.FileMoveLocks.cs b/listenarr.infrastructure/FileSystem/FileMover.FileMoveLocks.cs index 8ed4912bc..ac528fd26 100644 --- a/listenarr.infrastructure/FileSystem/FileMover.FileMoveLocks.cs +++ b/listenarr.infrastructure/FileSystem/FileMover.FileMoveLocks.cs @@ -103,7 +103,6 @@ private sealed record FileMoveEndpoint(string LockIdentity, string ResolvedPath) private async Task TryAcquireFileMoveGateAsync( string sourceFile, string destinationFile, - bool createDestinationParent = false, bool allowExistingAliasForRecovery = false) { if (!allowExistingAliasForRecovery @@ -199,7 +198,7 @@ await lockDirectory.OpenOrCreateExclusiveLockFileAsync( createMissing: false); destinationParent = PinnedDirectoryCreation.OpenPinnedHierarchyNoFollow( destinationParentPath, - createDestinationParent); + createMissing: false); var pinnedSource = await ResolveFileMoveEndpointAsync(sourceFile); var pinnedDestination = await ResolveFileMoveEndpointAsync( destinationFile); diff --git a/listenarr.infrastructure/FileSystem/FileMover.FileMoveRecovery.GenerationProof.cs b/listenarr.infrastructure/FileSystem/FileMover.FileMoveRecovery.GenerationProof.cs new file mode 100644 index 000000000..be6ffe000 --- /dev/null +++ b/listenarr.infrastructure/FileSystem/FileMover.FileMoveRecovery.GenerationProof.cs @@ -0,0 +1,69 @@ +namespace Listenarr.Infrastructure.FileSystem; + +public partial class FileMover +{ + private static bool RecoveryArtifactsMatchPersistedGeneration( + FileMoveFence? persistedFence, + PinnedDirectoryCreation.PinnedFileEntry? sourceClaim, + PinnedDirectoryCreation.PinnedFileEntry? destinationStage, + PinnedDirectoryCreation.PinnedFileEntry? destinationPrevious, + PinnedDirectoryCreation.PinnedFileEntry? destinationPublicationClaim, + bool sourceRetirementCommitted) + { + if (sourceClaim != null + && (persistedFence is not { Version: >= 2 } sourceFence + || string.IsNullOrWhiteSpace(sourceFence.SourceObjectIdentity) + || !string.Equals( + sourceClaim.GetObjectIdentity(), + sourceFence.SourceObjectIdentity, + StringComparison.Ordinal))) + { + return false; + } + + if (destinationStage != null + && (persistedFence is not { Version: >= 2 } stageFence + || string.IsNullOrWhiteSpace(stageFence.DestinationStageObjectIdentity) + || !string.Equals( + destinationStage.GetObjectIdentity(), + stageFence.DestinationStageObjectIdentity, + StringComparison.Ordinal))) + { + return false; + } + + if (destinationPrevious != null + && (persistedFence is not { Version: >= 2 } previousFence + || string.IsNullOrWhiteSpace( + previousFence.DestinationPreviousObjectIdentity) + || !string.Equals( + destinationPrevious.GetObjectIdentity(), + previousFence.DestinationPreviousObjectIdentity, + StringComparison.Ordinal))) + { + return false; + } + + if (destinationPublicationClaim == null) + { + return true; + } + + if (persistedFence is not { Version: >= 2 } publicationFence + || !destinationPublicationClaim.VisiblePathMatches()) + { + return false; + } + + var publicationSource = publicationFence.NativeRename + ? sourceClaim + : destinationStage; + if (publicationSource != null + && !destinationPublicationClaim.IdentifiesSameEntry(publicationSource)) + { + return false; + } + + return sourceRetirementCommitted || publicationSource != null; + } +} diff --git a/listenarr.infrastructure/FileSystem/FileMover.FileMoveRecovery.cs b/listenarr.infrastructure/FileSystem/FileMover.FileMoveRecovery.cs index 65e1755f4..cd8e25db9 100644 --- a/listenarr.infrastructure/FileSystem/FileMover.FileMoveRecovery.cs +++ b/listenarr.infrastructure/FileSystem/FileMover.FileMoveRecovery.cs @@ -69,7 +69,8 @@ private async Task TryRecoverInterruptedFileMoveCl && !AnchoredStateContainsOnly( destinationState, "destination.stage", - "destination.previous"))) + "destination.previous", + "destination.published.claim"))) { return FileMoveClaimRecoveryOutcome.Blocked; } @@ -86,6 +87,10 @@ private async Task TryRecoverInterruptedFileMoveCl using var destinationPrevious = destinationState?.TryOpenExistingFile( "destination.previous", requireDeleteAccess: true); + using var destinationPublicationClaim = + destinationState?.TryOpenExistingFile( + "destination.published.claim", + requireDeleteAccess: true); using var generationFence = sourceState?.TryOpenExistingFile( "replacement-generation.fence", requireDeleteAccess: true); @@ -109,6 +114,17 @@ private async Task TryRecoverInterruptedFileMoveCl } } + if (!RecoveryArtifactsMatchPersistedGeneration( + persistedFence, + sourceClaim, + destinationStage, + destinationPrevious, + destinationPublicationClaim, + sourceRetirementCommitted: generationFence != null)) + { + return FileMoveClaimRecoveryOutcome.Blocked; + } + if (generationFence == null) { using var publicSource = lease.SourceParent.TryOpenExistingFile( @@ -120,11 +136,37 @@ private async Task TryRecoverInterruptedFileMoveCl requireDeleteAccess: false); if (sourceClaim != null) { - if (publicSource != null + if (persistedFence is not { Version: >= 2 } rollbackFence + || publicSource != null || (destinationPrevious != null && publicDestination != null)) { return FileMoveClaimRecoveryOutcome.Blocked; } + if (publicDestination != null) + { + if (string.IsNullOrWhiteSpace( + rollbackFence.DestinationPreviousObjectIdentity) + || !string.Equals( + publicDestination.GetObjectIdentity(), + rollbackFence.DestinationPreviousObjectIdentity, + StringComparison.Ordinal)) + { + return FileMoveClaimRecoveryOutcome.Blocked; + } + } + else if (destinationPrevious == null + && !string.IsNullOrWhiteSpace( + rollbackFence.DestinationPreviousObjectIdentity)) + { + return FileMoveClaimRecoveryOutcome.Blocked; + } + if (!rollbackFence.NativeRename + && !string.IsNullOrWhiteSpace( + rollbackFence.DestinationStageObjectIdentity) + && destinationStage == null) + { + return FileMoveClaimRecoveryOutcome.Blocked; + } sourceClaim.MoveTo(lease.SourceParent, lease.SourceName); sourceClaim.Dispose(); @@ -134,6 +176,14 @@ private async Task TryRecoverInterruptedFileMoveCl FlushFileMoveDirectory( sourceState!, "interrupted source claim retirement"); + if (destinationPublicationClaim != null) + { + destinationPublicationClaim.Delete(immediateWindows: true); + destinationPublicationClaim.Dispose(); + FlushFileMoveDirectory( + destinationState!, + "interrupted publication claim retirement"); + } destinationStage?.Delete(immediateWindows: true); destinationStage?.Dispose(); if (destinationPrevious != null) @@ -153,7 +203,9 @@ private async Task TryRecoverInterruptedFileMoveCl "interrupted destination state rollback"); } } - else if (destinationStage != null || destinationPrevious != null) + else if (destinationStage != null + || destinationPrevious != null + || destinationPublicationClaim != null) { return FileMoveClaimRecoveryOutcome.Blocked; } @@ -187,22 +239,17 @@ private async Task TryRecoverInterruptedFileMoveCl return FileMoveClaimRecoveryOutcome.Ready; } - var nativeRename = false; - FileMoveContent committedContent; - if (persistedFence.HasValue) + if (persistedFence is not { Version: >= 2 } committedFenceState) { - nativeRename = persistedFence.Value.NativeRename; - committedContent = persistedFence.Value.Content; + return FileMoveClaimRecoveryOutcome.Blocked; } - else + var nativeRename = committedFenceState.NativeRename; + var committedContent = committedFenceState.Content; + if (destinationPublicationClaim == null + && string.IsNullOrWhiteSpace( + committedFenceState.PublishedDestinationObjectIdentity)) { - var legacyContent = await ReadLegacyFileMoveContentAsync( - generationFence); - if (!legacyContent.HasValue) - { - return FileMoveClaimRecoveryOutcome.Blocked; - } - committedContent = legacyContent.Value; + return FileMoveClaimRecoveryOutcome.Blocked; } if (nativeRename && destinationStage != null) @@ -289,13 +336,69 @@ private async Task TryRecoverInterruptedFileMoveCl lease.DestinationName, requireDeleteAccess: false); if (publishedDestination == null - || !await FileMatchesMoveContentAsync( + || !publishedDestination.VisiblePathMatches()) + { + return FileMoveClaimRecoveryOutcome.Blocked; + } + if (!await FileMatchesMoveContentAsync( publishedDestination, committedContent)) { return FileMoveClaimRecoveryOutcome.Blocked; } + var publishedObjectIdentity = publishedDestination.GetObjectIdentity(); + if (destinationPublicationClaim != null) + { + if (!destinationPublicationClaim.VisiblePathMatches() + || !destinationPublicationClaim.IdentifiesSameEntry( + publishedDestination)) + { + return FileMoveClaimRecoveryOutcome.Blocked; + } + if (!string.IsNullOrWhiteSpace( + committedFenceState.PublishedDestinationObjectIdentity) + && !string.Equals( + committedFenceState.PublishedDestinationObjectIdentity, + publishedObjectIdentity, + StringComparison.Ordinal)) + { + return FileMoveClaimRecoveryOutcome.Blocked; + } + if (operationState == null) + { + return FileMoveClaimRecoveryOutcome.Blocked; + } + + await WriteFileMoveContentAsync( + operationState, + committedContent, + committedFenceState.OperationId, + committedFenceState.SourceIdentity, + committedFenceState.DestinationIdentity, + committedFenceState.SourceObjectIdentity!, + committedFenceState.DestinationStageObjectIdentity, + committedFenceState.DestinationPreviousObjectIdentity, + publishedObjectIdentity, + nativeRename); + FlushFileMoveDirectory( + sourceState!, + "recovered published destination generation evidence"); + committedFenceState = committedFenceState with + { + PublishedDestinationObjectIdentity = publishedObjectIdentity + }; + } + else if (string.IsNullOrWhiteSpace( + committedFenceState.PublishedDestinationObjectIdentity) + || !string.Equals( + committedFenceState.PublishedDestinationObjectIdentity, + publishedObjectIdentity, + StringComparison.Ordinal)) + { + return FileMoveClaimRecoveryOutcome.Blocked; + } + destinationPrevious?.Delete(immediateWindows: true); destinationPrevious?.Dispose(); if (destinationState != null) @@ -305,6 +408,34 @@ private async Task TryRecoverInterruptedFileMoveCl "previous destination recovery retirement"); } + if (destinationPublicationClaim != null) + { + if (!destinationPublicationClaim.VisiblePathMatches() + || !publishedDestination.VisiblePathMatches() + || !destinationPublicationClaim.IdentifiesSameEntry( + publishedDestination)) + { + return FileMoveClaimRecoveryOutcome.Blocked; + } + destinationPublicationClaim.Delete(immediateWindows: true); + destinationPublicationClaim.Dispose(); + FlushFileMoveDirectory( + destinationState!, + "destination publication claim recovery retirement"); + } + + if (!publishedDestination.VisiblePathMatches() + || !string.Equals( + publishedDestination.GetObjectIdentity(), + committedFenceState.PublishedDestinationObjectIdentity, + StringComparison.Ordinal) + || !await FileMatchesMoveContentAsync( + publishedDestination, + committedContent)) + { + return FileMoveClaimRecoveryOutcome.Blocked; + } + using var publishedSource = lease.SourceParent.TryOpenExistingFile( lease.SourceName, requireDeleteAccess: false); diff --git a/listenarr.infrastructure/FileSystem/FileMover.FileMoveState.cs b/listenarr.infrastructure/FileSystem/FileMover.FileMoveState.cs index 698cd92d7..fc717c451 100644 --- a/listenarr.infrastructure/FileSystem/FileMover.FileMoveState.cs +++ b/listenarr.infrastructure/FileSystem/FileMover.FileMoveState.cs @@ -73,19 +73,7 @@ private static PinnedDirectoryCreation CreateAnchoredFileMoveStateDirectory( throw new IOException( "The deterministic file-move state directory is already occupied."); } - if (!OperatingSystem.IsWindows()) - { - File.SetUnixFileMode( - creation.FullPath, - UnixFileMode.UserRead - | UnixFileMode.UserWrite - | UnixFileMode.UserExecute); - if (!creation.VisiblePathMatches()) - { - throw new IOException( - "The file-move state directory changed while permissions were restricted."); - } - } + creation.RestrictToCurrentUser(); return creation; } @@ -125,9 +113,8 @@ private static void TryDeleteAnchoredStateDirectory( } try { - publication.DeletePinnedEmptyDirectory( - stateName, - immediateWindows: true); + publication.RetirePinnedEmptyDirectoryFromNamespace( + stateName); } catch (Exception exception) when (exception is IOException or UnauthorizedAccessException diff --git a/listenarr.infrastructure/FileSystem/FileMover.Move.cs b/listenarr.infrastructure/FileSystem/FileMover.Move.cs index f88c131a1..b51b7e8fd 100644 --- a/listenarr.infrastructure/FileSystem/FileMover.Move.cs +++ b/listenarr.infrastructure/FileSystem/FileMover.Move.cs @@ -17,6 +17,90 @@ public partial class FileMover public Task MoveFileAsync(string sourceFile, string destFile) => MoveFileAsync(sourceFile, destFile, operationId: null); + public async Task MoveFilePreservingPhysicalIdentityAsync( + string source, + string destination, + string expectedSourcePhysicalObjectIdentity, + Guid? operationId = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace( + expectedSourcePhysicalObjectIdentity); + if (string.Equals( + Path.GetFullPath(source), + Path.GetFullPath(destination), + StringComparison.Ordinal)) + { + try + { + using var lease = PinnedAudiobookFileRegistrationLease.Open( + source, + expectedSourcePhysicalObjectIdentity); + return lease.MatchesCurrentPublication(); + } + catch (Exception exception) when (exception is not ( + OperationCanceledException or OutOfMemoryException or StackOverflowException)) + { + _logger.LogWarning( + exception, + "Blocked generation-preserving file move because the source identity is unavailable: {Source}", + LogRedaction.SanitizeFilePath(source)); + return false; + } + } + + using var pathLock = await TryAcquireFileMoveGateAsync( + source, + destination); + if (pathLock == null) + { + return false; + } + + var recoveryOutcome = await TryRecoverInterruptedFileMoveClaimsAsync( + pathLock, + operationId); + if (recoveryOutcome == FileMoveClaimRecoveryOutcome.Completed) + { + using var recoveredDestination = + pathLock.DestinationParent.TryOpenExistingFile( + pathLock.DestinationName, + requireDeleteAccess: false); + return recoveredDestination != null + && recoveredDestination.VisiblePathMatches() + && string.Equals( + recoveredDestination.GetObjectIdentity(), + expectedSourcePhysicalObjectIdentity, + StringComparison.Ordinal); + } + + if (recoveryOutcome is FileMoveClaimRecoveryOutcome.Blocked + or FileMoveClaimRecoveryOutcome.SourceRecreated) + { + return false; + } + + var moved = await MoveFileWithLocksAsync( + pathLock, + operationId, + expectedSourcePhysicalObjectIdentity, + requirePhysicalIdentityPreservation: true); + if (!moved) + { + return false; + } + + using var destinationEntry = + pathLock.DestinationParent.TryOpenExistingFile( + pathLock.DestinationName, + requireDeleteAccess: false); + return destinationEntry != null + && destinationEntry.VisiblePathMatches() + && string.Equals( + destinationEntry.GetObjectIdentity(), + expectedSourcePhysicalObjectIdentity, + StringComparison.Ordinal); + } + internal async Task MoveFileAsync( string sourceFile, string destFile, @@ -68,10 +152,38 @@ internal async Task MoveFileAsync( private async Task MoveFileWithLocksAsync( FileMoveGateLease lease, - Guid? operationId) + Guid? operationId, + string? expectedSourcePhysicalObjectIdentity = null, + bool requirePhysicalIdentityPreservation = false) { var sourceFile = lease.SourcePath; var destFile = lease.DestinationPath; + if (!string.IsNullOrWhiteSpace(expectedSourcePhysicalObjectIdentity)) + { + using var sourceEntry = lease.SourceParent.TryOpenExistingFile( + lease.SourceName, + requireDeleteAccess: false); + using var existingDestination = requirePhysicalIdentityPreservation + ? lease.DestinationParent.TryOpenExistingFile( + lease.DestinationName, + requireDeleteAccess: false) + : null; + if (sourceEntry == null + || !sourceEntry.VisiblePathMatches() + || !string.Equals( + sourceEntry.GetObjectIdentity(), + expectedSourcePhysicalObjectIdentity, + StringComparison.Ordinal) + || (requirePhysicalIdentityPreservation + && (existingDestination != null + || DisableNativeFileRenameForTest + || !sourceEntry.IsOnSameVolume( + lease.DestinationParent)))) + { + return false; + } + } + var pathEquivalence = await TryDetermineFilesystemPathEquivalenceAsync( sourceFile, destFile); @@ -86,9 +198,11 @@ private async Task MoveFileWithLocksAsync( return true; } - var idempotentOutcome = await TryCompleteIdempotentFileMoveAsync( - lease, - operationId); + var idempotentOutcome = requirePhysicalIdentityPreservation + ? IdempotentFileMoveOutcome.NotApplicable + : await TryCompleteIdempotentFileMoveAsync( + lease, + operationId); if (idempotentOutcome == IdempotentFileMoveOutcome.Completed) { return true; @@ -118,7 +232,9 @@ private async Task MoveFileWithLocksAsync( var managedFallback = await TryManagedFileMoveFallbackAsync( lease, - operationId); + operationId, + expectedSourcePhysicalObjectIdentity, + requirePhysicalIdentityPreservation); if (managedFallback == FileMoveFallbackOutcome.Success) { LogMutation( diff --git a/listenarr.infrastructure/FileSystem/FileMover.PinnedClaims.cs b/listenarr.infrastructure/FileSystem/FileMover.PinnedClaims.cs index 422e3ec6c..6efd173c0 100644 --- a/listenarr.infrastructure/FileSystem/FileMover.PinnedClaims.cs +++ b/listenarr.infrastructure/FileSystem/FileMover.PinnedClaims.cs @@ -1,30 +1,9 @@ -using Listenarr.Domain.Common; - namespace Listenarr.Infrastructure.FileSystem; public partial class FileMover { - private async Task GetPreparedFilePublicationStateNameAsync( - string destinationPath) - { - var normalizedDestination = Path.GetFullPath(destinationPath); - var semantics = await _semanticsResolver.ResolveAsync(normalizedDestination); - if (semantics.State != PathIdentityState.Valid - || semantics.Semantics.CaseSensitivity - == FileSystemCaseSensitivity.Unknown) - { - throw new IOException( - "Filesystem identity is unavailable for recoverable file publication."); - } - - var publicationIdentity = FileSystemPathIdentity.CreateKey( - "file-publication", - normalizedDestination, - semantics.Semantics); - return $".listenarr-file-publication-{HashPathIdentity(publicationIdentity)}.state"; - } - - private void RecoverPreparedFilePublication( + private async Task + RecoverPreparedFilePublicationAsync( PinnedDirectoryCreation.PinnedDirectoryAnchor destinationParent, string destinationName, string stateName) @@ -33,7 +12,8 @@ private void RecoverPreparedFilePublication( destinationParent.TryOpenExistingChildForPublication(stateName); if (statePublication == null) { - return; + return new PreparedPublicationRecoveryResult( + PreparedPublicationRecoveryOutcome.None); } using var state = statePublication.OpenCreatedDirectoryAnchor(); @@ -41,149 +21,253 @@ private void RecoverPreparedFilePublication( || !state.VisiblePathMatches() || !AnchoredStateContainsOnly( state, + "operation.state", "prepared.claim", "destination.previous", + "destination.published.claim", "publication.fence")) { throw new IOException( "Recoverable file-publication state contains unsafe entries."); } + var operationState = state.TryOpenExistingFile( + "operation.state", + requireDeleteAccess: true); var prepared = state.TryOpenExistingFile( "prepared.claim", requireDeleteAccess: true); var previous = state.TryOpenExistingFile( "destination.previous", requireDeleteAccess: true); - var fence = state.TryOpenExistingFile( + var publicationClaim = state.TryOpenExistingFile( + "destination.published.claim", + requireDeleteAccess: true); + var legacyFence = state.TryOpenExistingFile( "publication.fence", requireDeleteAccess: true); + PinnedDirectoryCreation.PinnedFileEntry? destination = null; + var recoveryResult = new PreparedPublicationRecoveryResult( + PreparedPublicationRecoveryOutcome.RolledBack); try { - using var destination = destinationParent.TryOpenExistingFile( + if (operationState == null || legacyFence != null) + { + throw new IOException( + "Prepared file-publication state lacks current durable generation evidence."); + } + + var evidence = ReadPreparedPublicationState(operationState); + if (evidence == null + || !string.Equals( + evidence.DestinationName, + destinationName, + StringComparison.Ordinal)) + { + throw new IOException( + "Prepared file-publication generation evidence is invalid."); + } + + var expectedContent = new FileMoveContent( + evidence.PreparedLength, + evidence.PreparedSha256); + if (prepared != null + && (!prepared.VisiblePathMatches() + || !string.Equals( + prepared.GetObjectIdentity(), + evidence.PreparedObjectIdentity, + StringComparison.Ordinal) + || !await FileMatchesMoveContentAsync( + prepared, + expectedContent))) + { + throw new IOException( + "The prepared publication claim changed before recovery."); + } + if (previous != null + && (!previous.VisiblePathMatches() + || !string.Equals( + previous.GetObjectIdentity(), + evidence.PreviousObjectIdentity, + StringComparison.Ordinal))) + { + throw new IOException( + "The previous destination generation changed before recovery."); + } + + destination = destinationParent.TryOpenExistingFile( destinationName, requireDeleteAccess: false); - if (fence == null) + if (publicationClaim != null) { - if (destination != null && previous != null) + if (!publicationClaim.VisiblePathMatches()) { throw new IOException( - "Interrupted file publication has ambiguous pre-commit state."); - } - - if (prepared != null) - { - if (destination != null || previous == null) - { - throw new IOException( - "Interrupted file publication has incomplete pre-commit evidence."); - } - - prepared.Delete(immediateWindows: true); - prepared.Dispose(); - prepared = null; - FlushFileMoveDirectory( - state, - "uncommitted prepared-generation retirement"); + "The destination publication claim changed before recovery."); } - if (previous != null) - { - previous.MoveTo(destinationParent, destinationName); - previous.Dispose(); - previous = null; - FlushFileMoveDirectory( - destinationParent, - "interrupted destination restoration"); - FlushFileMoveDirectory( - state, - "interrupted previous-generation retirement"); - } - } - else if (destination != null) - { - if (prepared != null) + var claimedGeneration = prepared ?? destination; + if (claimedGeneration == null + || !publicationClaim.IdentifiesSameEntry(claimedGeneration)) { throw new IOException( - "The destination was recreated before interrupted publication could recover."); + "The destination publication claim no longer proves the recoverable generation."); } - - previous?.Delete(immediateWindows: true); - previous?.Dispose(); - previous = null; - fence.Delete(immediateWindows: true); - fence.Dispose(); - fence = null; - FlushFileMoveDirectory( - state, - "completed publication-journal retirement"); } - else if (prepared != null) + + if (!evidence.Committed) { - prepared.MoveTo(destinationParent, destinationName); - prepared.Dispose(); - prepared = null; - FlushFileMoveDirectory( + RecoverUncommittedPreparedPublication( destinationParent, - "interrupted prepared-generation publication"); - FlushFileMoveDirectory( - state, - "interrupted prepared-claim retirement"); - previous?.Delete(immediateWindows: true); - previous?.Dispose(); - previous = null; - fence.Delete(immediateWindows: true); - fence.Dispose(); - fence = null; - FlushFileMoveDirectory( + destinationName, state, - "recovered publication-journal retirement"); + evidence, + ref prepared, + ref previous, + ref publicationClaim, + destination); } else { - if (previous != null) + destination = await RecoverCommittedPreparedPublicationAsync( + destinationParent, + destinationName, + state, + operationState, + evidence, + expectedContent, + prepared, + previous, + publicationClaim, + destination); + var completedEvidence = ReadPreparedPublicationState( + operationState); + if (completedEvidence == null + || string.IsNullOrWhiteSpace( + completedEvidence.PublishedDestinationObjectIdentity)) { - previous.MoveTo(destinationParent, destinationName); - previous.Dispose(); - previous = null; - FlushFileMoveDirectory( - destinationParent, - "interrupted previous-generation restoration"); - FlushFileMoveDirectory( - state, - "interrupted previous-generation retirement"); + throw new IOException( + "Completed prepared publication lacks durable public generation evidence."); } - - fence.Delete(immediateWindows: true); - fence.Dispose(); - fence = null; - FlushFileMoveDirectory( - state, - "abandoned publication-journal retirement"); + recoveryResult = new PreparedPublicationRecoveryResult( + PreparedPublicationRecoveryOutcome.Completed, + completedEvidence.PublishedDestinationObjectIdentity, + completedEvidence.SourceObjectIdentity); } + + operationState.Delete(immediateWindows: true); + operationState.Dispose(); + operationState = null; + FlushFileMoveDirectory( + state, + "prepared publication operation-state retirement"); } finally { - fence?.Dispose(); + destination?.Dispose(); + legacyFence?.Dispose(); + publicationClaim?.Dispose(); previous?.Dispose(); prepared?.Dispose(); + operationState?.Dispose(); } state.Dispose(); - statePublication.DeletePinnedEmptyDirectory( - stateName, - immediateWindows: true); + statePublication.RetirePinnedEmptyDirectoryFromNamespace( + stateName); FlushFileMoveDirectory( destinationParent, "file-publication state retirement"); + return recoveryResult; + } + + private void RecoverUncommittedPreparedPublication( + PinnedDirectoryCreation.PinnedDirectoryAnchor destinationParent, + string destinationName, + PinnedDirectoryCreation.PinnedDirectoryAnchor state, + PreparedPublicationState evidence, + ref PinnedDirectoryCreation.PinnedFileEntry? prepared, + ref PinnedDirectoryCreation.PinnedFileEntry? previous, + ref PinnedDirectoryCreation.PinnedFileEntry? publicationClaim, + PinnedDirectoryCreation.PinnedFileEntry? destination) + { + if (previous == null) + { + if (prepared != null + || publicationClaim != null + || destination == null + || !destination.VisiblePathMatches() + || !string.Equals( + destination.GetObjectIdentity(), + evidence.PreviousObjectIdentity, + StringComparison.Ordinal)) + { + throw new IOException( + "Interrupted file publication has incomplete pre-commit generation evidence."); + } + return; + } + + if (destination != null) + { + throw new IOException( + "Interrupted file publication has ambiguous pre-commit destination state."); + } + + if (publicationClaim != null) + { + if (prepared == null + || !publicationClaim.IdentifiesSameEntry(prepared)) + { + throw new IOException( + "Interrupted publication claim does not match the prepared generation."); + } + publicationClaim.Delete(immediateWindows: true); + publicationClaim.Dispose(); + publicationClaim = null; + FlushFileMoveDirectory( + state, + "uncommitted publication-claim retirement"); + } + + if (prepared != null) + { + prepared.Delete(immediateWindows: true); + prepared.Dispose(); + prepared = null; + FlushFileMoveDirectory( + state, + "uncommitted prepared-generation retirement"); + } + + previous.MoveTo(destinationParent, destinationName); + if (!previous.VisiblePathMatches() + || !string.Equals( + previous.GetObjectIdentity(), + evidence.PreviousObjectIdentity, + StringComparison.Ordinal)) + { + throw new IOException( + "The previous destination changed while it was being restored."); + } + previous.Dispose(); + previous = null; + FlushFileMoveDirectory( + destinationParent, + "interrupted destination restoration"); + FlushFileMoveDirectory( + state, + "interrupted previous-generation retirement"); } private async Task PublishPreparedFileReplacingCapturedDestinationAsync( PinnedDirectoryCreation.PinnedFileEntry prepared, + string sourceObjectIdentity, PinnedDirectoryCreation.PinnedDirectoryAnchor destinationParent, string destinationName, PinnedDirectoryCreation.PinnedFileEntry capturedDestination, - string stateName) + string stateName, + Action transferPreparedOwnership) { using var statePublication = CreateAnchoredFileMoveStateDirectory( destinationParent, @@ -193,7 +277,33 @@ private async Task PublishPreparedFileReplacingCapturedDestinationAsync( destinationParent, "file-publication state creation"); + var preparedContent = await CaptureFileMoveContentAsync(prepared); + var evidence = new PreparedPublicationState( + PreparedPublicationStateVersion, + destinationName, + sourceObjectIdentity, + prepared.GetObjectIdentity(), + capturedDestination.GetObjectIdentity(), + preparedContent.Length, + preparedContent.Sha256, + Committed: false, + PublishedDestinationObjectIdentity: null); + using var operationState = state.CreateNewFile("operation.state"); + WritePreparedPublicationState(operationState, evidence); + FlushFileMoveDirectory( + state, + "prepared publication generation evidence"); + capturedDestination.MoveTo(state, "destination.previous"); + if (!capturedDestination.VisiblePathMatches() + || !string.Equals( + capturedDestination.GetObjectIdentity(), + evidence.PreviousObjectIdentity, + StringComparison.Ordinal)) + { + throw new IOException( + "The captured destination changed while it was quarantined."); + } FlushFileMoveDirectory( destinationParent, "captured destination retirement"); @@ -206,16 +316,62 @@ private async Task PublishPreparedFileReplacingCapturedDestinationAsync( } prepared.MoveTo(state, "prepared.claim"); + transferPreparedOwnership(); + if (!prepared.VisiblePathMatches() + || !await FileMatchesMoveContentAsync( + prepared, + preparedContent)) + { + throw new IOException( + "The prepared generation changed while it was claimed."); + } + if (AfterPreparedClaimMovedBeforeEvidenceForTestAsync != null) + { + await AfterPreparedClaimMovedBeforeEvidenceForTestAsync(); + } + evidence = evidence with + { + PreparedObjectIdentity = prepared.GetObjectIdentity() + }; + WritePreparedPublicationState(operationState, evidence); FlushFileMoveDirectory( destinationParent, "prepared generation retirement"); FlushFileMoveDirectory( state, "prepared generation claim"); + if (AfterPreparedClaimPublishedForTestAsync != null) + { + await AfterPreparedClaimPublishedForTestAsync(); + } + + using var publicationClaim = prepared.CreateHardLinkTo( + state, + "destination.published.claim"); + if (!publicationClaim.VisiblePathMatches() + || !publicationClaim.IdentifiesSameEntry(prepared)) + { + throw new IOException( + "The prepared generation could not establish a durable publication claim."); + } + evidence = evidence with + { + PreparedObjectIdentity = prepared.GetObjectIdentity() + }; + WritePreparedPublicationState(operationState, evidence); + FlushFileMoveDirectory( + state, + "destination publication generation claim"); - using var fence = state.CreateNewFile("publication.fence"); - fence.FlushToDisk(); - FlushFileMoveDirectory(state, "file-publication commit fence"); + evidence = evidence with { Committed = true }; + WritePreparedPublicationState(operationState, evidence); + FlushFileMoveDirectory( + state, + "file-publication commit state"); + if (AfterPreparedPublicationCommittedForTestAsync != null) + { + await AfterPreparedPublicationCommittedForTestAsync(); + } using var appearedDestination = destinationParent.TryOpenExistingFile( destinationName, @@ -233,17 +389,87 @@ private async Task PublishPreparedFileReplacingCapturedDestinationAsync( FlushFileMoveDirectory( state, "prepared generation claim retirement"); + if (AfterPreparedDestinationPublishedForTestAsync != null) + { + await AfterPreparedDestinationPublishedForTestAsync(); + } + using var publishedDestination = destinationParent.TryOpenExistingFile( + destinationName, + requireDeleteAccess: false); + if (publishedDestination == null + || !prepared.VisiblePathMatches() + || !publishedDestination.VisiblePathMatches() + || !publicationClaim.VisiblePathMatches() + || !prepared.IdentifiesSameEntry(publishedDestination) + || !publicationClaim.IdentifiesSameEntry(publishedDestination) + || !await FileMatchesMoveContentAsync( + publishedDestination, + preparedContent)) + { + throw new IOException( + "The prepared generation changed while it was published."); + } + evidence = evidence with + { + PublishedDestinationObjectIdentity = + publishedDestination.GetObjectIdentity() + }; + WritePreparedPublicationState(operationState, evidence); + FlushFileMoveDirectory( + state, + "published destination generation evidence"); + + if (!capturedDestination.VisiblePathMatches() + || !string.Equals( + capturedDestination.GetObjectIdentity(), + evidence.PreviousObjectIdentity, + StringComparison.Ordinal)) + { + throw new IOException( + "The previous destination changed before retirement."); + } capturedDestination.Delete(immediateWindows: true); capturedDestination.Dispose(); - fence.Delete(immediateWindows: true); - fence.Dispose(); - FlushFileMoveDirectory(state, "file-publication journal retirement"); + FlushFileMoveDirectory( + state, + "captured destination retirement"); + + if (!publicationClaim.VisiblePathMatches() + || !publishedDestination.VisiblePathMatches() + || !publicationClaim.IdentifiesSameEntry(publishedDestination)) + { + throw new IOException( + "The public destination changed before publication-claim retirement."); + } + publicationClaim.Delete(immediateWindows: true); + publicationClaim.Dispose(); + FlushFileMoveDirectory( + state, + "destination publication-claim retirement"); + + if (!publishedDestination.VisiblePathMatches() + || !string.Equals( + publishedDestination.GetObjectIdentity(), + evidence.PublishedDestinationObjectIdentity, + StringComparison.Ordinal) + || !await FileMatchesMoveContentAsync( + publishedDestination, + preparedContent)) + { + throw new IOException( + "The public destination changed before publication state retirement."); + } + + operationState.Delete(immediateWindows: true); + operationState.Dispose(); + FlushFileMoveDirectory( + state, + "file-publication operation-state retirement"); state.Dispose(); - statePublication.DeletePinnedEmptyDirectory( - stateName, - immediateWindows: true); + statePublication.RetirePinnedEmptyDirectoryFromNamespace( + stateName); FlushFileMoveDirectory( destinationParent, "file-publication state retirement"); diff --git a/listenarr.infrastructure/FileSystem/FileMover.PreparedMove.cs b/listenarr.infrastructure/FileSystem/FileMover.PreparedMove.cs index b300dc9ac..2ab59d7db 100644 --- a/listenarr.infrastructure/FileSystem/FileMover.PreparedMove.cs +++ b/listenarr.infrastructure/FileSystem/FileMover.PreparedMove.cs @@ -218,7 +218,13 @@ private async Task RecoverPreparedMoveClaimAsync( return false; } - if (!await RegistrationLeaseMatchesFileAsync( + if (string.IsNullOrWhiteSpace( + registrationLease.SourcePhysicalObjectIdentity) + || !string.Equals( + claim.GetObjectIdentity(), + registrationLease.SourcePhysicalObjectIdentity, + StringComparison.Ordinal) + || !await RegistrationLeaseMatchesFileAsync( registrationLease, claim) || !claim.VisiblePathMatches() @@ -229,42 +235,54 @@ private async Task RecoverPreparedMoveClaimAsync( } claim.Delete(immediateWindows: true); - FlushFileMoveDirectory( - gate.SourceParent, - "recovered prepared move source retirement"); - if (AfterPreparedMoveSourceDeletedForTestAsync != null) + try { - await AfterPreparedMoveSourceDeletedForTestAsync( - gate.DestinationPath); - } + FlushFileMoveDirectory( + gate.SourceParent, + "recovered prepared move source retirement"); + if (AfterPreparedMoveSourceDeletedForTestAsync != null) + { + await AfterPreparedMoveSourceDeletedForTestAsync( + gate.DestinationPath); + } - var recreatedOutcome = - gate.SourceParent.TryOpenExistingFileWithOutcome( - gate.SourceName, - requireDeleteAccess: false, - out var recreatedSource); - recreatedSource?.Dispose(); - if (recreatedOutcome != PinnedFileOpenOutcome.NotFound) - { - _ = await TryPreserveDeletedPreparedMoveSourceAsync( - gate, - claim, - claim.FileName, - preferVisibleSource: false); - return false; - } + var recreatedOutcome = + gate.SourceParent.TryOpenExistingFileWithOutcome( + gate.SourceName, + requireDeleteAccess: false, + out var recreatedSource); + recreatedSource?.Dispose(); + if (recreatedOutcome != PinnedFileOpenOutcome.NotFound) + { + _ = await TryPreserveDeletedPreparedMoveSourceAsync( + gate, + claim, + claim.FileName, + preferVisibleSource: false); + return false; + } + + if (!registrationLease.MatchesCurrentPublication()) + { + _ = await TryPreserveDeletedPreparedMoveSourceAsync( + gate, + claim, + claim.FileName, + preferVisibleSource: true); + return false; + } - if (!registrationLease.MatchesCurrentPublication()) + return true; + } + catch { _ = await TryPreserveDeletedPreparedMoveSourceAsync( gate, claim, claim.FileName, preferVisibleSource: true); - return false; + throw; } - - return true; } private static async Task RegistrationLeaseMatchesFileAsync( @@ -285,11 +303,6 @@ private async Task TryPreserveDeletedPreparedMoveSourceAsync( string claimName, bool preferVisibleSource) { - if (OperatingSystem.IsWindows()) - { - return false; - } - if (preferVisibleSource && await sourceEntry.TryRestoreUnlinkedCopyToAsync( gate.SourceParent, diff --git a/listenarr.infrastructure/FileSystem/FileMover.PreparedPublicationCompletion.cs b/listenarr.infrastructure/FileSystem/FileMover.PreparedPublicationCompletion.cs new file mode 100644 index 000000000..72de0f998 --- /dev/null +++ b/listenarr.infrastructure/FileSystem/FileMover.PreparedPublicationCompletion.cs @@ -0,0 +1,56 @@ +using Listenarr.Domain.Audiobooks.Enumerations; + +namespace Listenarr.Infrastructure.FileSystem; + +public partial class FileMover +{ + private bool TryCompleteRecoveredPreparedPublication( + PreparedPublicationRecoveryResult recovery, + FileMoveGateLease lease, + FileAction action, + string sourceFile, + string destinationFile, + Action? capturePublication) + { + using var recoveredDestination = + lease.DestinationParent.TryOpenExistingFile( + lease.DestinationName, + requireDeleteAccess: false); + if (!lease.DestinationParent.VisiblePathMatches() + || recoveredDestination == null + || !recoveredDestination.VisiblePathMatches() + || string.IsNullOrWhiteSpace( + recovery.PublishedDestinationObjectIdentity) + || !string.Equals( + recoveredDestination.GetObjectIdentity(), + recovery.PublishedDestinationObjectIdentity, + StringComparison.Ordinal)) + { + return false; + } + + if (capturePublication != null) + { + if (string.IsNullOrWhiteSpace(recovery.SourceObjectIdentity)) + { + throw new InvalidOperationException( + "Recovered file publication has no source generation evidence."); + } + CapturePublishedRegistrationLease( + PinnedAudiobookFileRegistrationLease.Create( + recoveredDestination.OpenStableRegistrationCopy(), + destinationFile, + sourcePhysicalObjectIdentity: + recovery.SourceObjectIdentity), + capturePublication); + } + + LogMutation( + FileMutationOutcome.Success, + action, + sourceFile, + destinationFile, + "Recovered a durable prepared-file publication"); + return true; + } +} diff --git a/listenarr.infrastructure/FileSystem/FileMover.PreparedPublicationRecovery.cs b/listenarr.infrastructure/FileSystem/FileMover.PreparedPublicationRecovery.cs new file mode 100644 index 000000000..601117a4e --- /dev/null +++ b/listenarr.infrastructure/FileSystem/FileMover.PreparedPublicationRecovery.cs @@ -0,0 +1,133 @@ +namespace Listenarr.Infrastructure.FileSystem; + +public partial class FileMover +{ + private async Task + RecoverCommittedPreparedPublicationAsync( + PinnedDirectoryCreation.PinnedDirectoryAnchor destinationParent, + string destinationName, + PinnedDirectoryCreation.PinnedDirectoryAnchor state, + PinnedDirectoryCreation.PinnedFileEntry operationState, + PreparedPublicationState evidence, + FileMoveContent expectedContent, + PinnedDirectoryCreation.PinnedFileEntry? prepared, + PinnedDirectoryCreation.PinnedFileEntry? previous, + PinnedDirectoryCreation.PinnedFileEntry? publicationClaim, + PinnedDirectoryCreation.PinnedFileEntry? destination) + { + if (prepared != null) + { + if (destination != null + || publicationClaim == null + || !publicationClaim.IdentifiesSameEntry(prepared)) + { + throw new IOException( + "Committed file publication cannot prove its unpublished prepared generation."); + } + + prepared.MoveTo(destinationParent, destinationName); + FlushFileMoveDirectory( + destinationParent, + "interrupted prepared-generation publication"); + FlushFileMoveDirectory( + state, + "interrupted prepared-claim retirement"); + destination = destinationParent.TryOpenExistingFile( + destinationName, + requireDeleteAccess: false); + if (destination == null + || !prepared.VisiblePathMatches() + || !destination.VisiblePathMatches() + || !prepared.IdentifiesSameEntry(destination) + || !publicationClaim.IdentifiesSameEntry(destination)) + { + throw new IOException( + "The prepared generation changed while publication was recovered."); + } + } + + if (destination == null + || !destination.VisiblePathMatches() + || !await FileMatchesMoveContentAsync( + destination, + expectedContent)) + { + throw new IOException( + "Committed file publication has no matching public destination generation."); + } + + var publishedIdentity = destination.GetObjectIdentity(); + if (publicationClaim != null) + { + if (!publicationClaim.VisiblePathMatches() + || !publicationClaim.IdentifiesSameEntry(destination) + || (!string.IsNullOrWhiteSpace( + evidence.PublishedDestinationObjectIdentity) + && !string.Equals( + evidence.PublishedDestinationObjectIdentity, + publishedIdentity, + StringComparison.Ordinal))) + { + throw new IOException( + "The destination publication claim does not prove the public generation."); + } + + evidence = evidence with + { + PublishedDestinationObjectIdentity = publishedIdentity + }; + WritePreparedPublicationState(operationState, evidence); + FlushFileMoveDirectory( + state, + "recovered public destination generation evidence"); + } + else if (string.IsNullOrWhiteSpace( + evidence.PublishedDestinationObjectIdentity) + || !string.Equals( + evidence.PublishedDestinationObjectIdentity, + publishedIdentity, + StringComparison.Ordinal)) + { + throw new IOException( + "The committed public destination generation is unproven."); + } + + if (previous != null) + { + previous.Delete(immediateWindows: true); + FlushFileMoveDirectory( + state, + "previous destination recovery retirement"); + } + + if (publicationClaim != null) + { + if (!publicationClaim.VisiblePathMatches() + || !destination.VisiblePathMatches() + || !publicationClaim.IdentifiesSameEntry(destination)) + { + throw new IOException( + "The public destination changed before publication-claim retirement."); + } + publicationClaim.Delete(immediateWindows: true); + FlushFileMoveDirectory( + state, + "destination publication-claim retirement"); + } + + if (!destination.VisiblePathMatches() + || !string.Equals( + destination.GetObjectIdentity(), + evidence.PublishedDestinationObjectIdentity, + StringComparison.Ordinal) + || !await FileMatchesMoveContentAsync( + destination, + expectedContent)) + { + throw new IOException( + "The public destination changed before publication recovery committed."); + } + + return destination; + } +} diff --git a/listenarr.infrastructure/FileSystem/FileMover.PreparedPublicationState.cs b/listenarr.infrastructure/FileSystem/FileMover.PreparedPublicationState.cs new file mode 100644 index 000000000..222050364 --- /dev/null +++ b/listenarr.infrastructure/FileSystem/FileMover.PreparedPublicationState.cs @@ -0,0 +1,196 @@ +using System.Security.Cryptography; +using System.Text.Json; +using Listenarr.Domain.Common; + +namespace Listenarr.Infrastructure.FileSystem; + +public partial class FileMover +{ + private const int PreparedPublicationStateVersion = 1; + + private enum PreparedPublicationRecoveryOutcome + { + None, + RolledBack, + Completed + } + + private readonly record struct PreparedPublicationRecoveryResult( + PreparedPublicationRecoveryOutcome Outcome, + string? PublishedDestinationObjectIdentity = null, + string? SourceObjectIdentity = null); + + private sealed record PreparedPublicationState( + int Version, + string DestinationName, + string SourceObjectIdentity, + string PreparedObjectIdentity, + string PreviousObjectIdentity, + long PreparedLength, + string PreparedSha256, + bool Committed, + string? PublishedDestinationObjectIdentity); + + private sealed record PreparedPublicationStateEnvelope( + string PayloadBase64, + string Sha256); + + private async Task GetPreparedFilePublicationStateNameAsync( + string destinationPath) + { + var normalizedDestination = Path.GetFullPath(destinationPath); + var semantics = await _semanticsResolver.ResolveAsync(normalizedDestination); + if (semantics.State != PathIdentityState.Valid + || semantics.Semantics.CaseSensitivity + == FileSystemCaseSensitivity.Unknown) + { + throw new IOException( + "Filesystem identity is unavailable for recoverable file publication."); + } + + var publicationIdentity = FileSystemPathIdentity.CreateKey( + "file-publication", + normalizedDestination, + semantics.Semantics); + return $".listenarr-file-publication-{HashPathIdentity(publicationIdentity)}.state"; + } + + private static void WritePreparedPublicationState( + PinnedDirectoryCreation.PinnedFileEntry stateFile, + PreparedPublicationState state) + { + ValidatePreparedPublicationState(state); + var payload = JsonSerializer.SerializeToUtf8Bytes(state); + var envelope = new PreparedPublicationStateEnvelope( + Convert.ToBase64String(payload), + Convert.ToHexString(SHA256.HashData(payload))); + var bytes = JsonSerializer.SerializeToUtf8Bytes(envelope); + using var stream = stateFile.OpenWriteStream( + bufferSize: 4096, + asynchronous: false); + stream.SetLength(0); + stream.Position = 0; + stream.Write(bytes); + stream.Flush(flushToDisk: true); + } + + private static PreparedPublicationState? ReadPreparedPublicationState( + PinnedDirectoryCreation.PinnedFileEntry stateFile) + { + using var stream = stateFile.OpenReadStream( + bufferSize: 4096, + asynchronous: false); + if (stream.Length is <= 0 or > 16 * 1024) + { + return null; + } + + var bytes = new byte[stream.Length]; + var offset = 0; + while (offset < bytes.Length) + { + var read = stream.Read(bytes, offset, bytes.Length - offset); + if (read == 0) + { + return null; + } + offset += read; + } + + PreparedPublicationStateEnvelope? envelope; + try + { + envelope = JsonSerializer.Deserialize(bytes); + } + catch (JsonException) + { + return null; + } + if (envelope == null + || string.IsNullOrWhiteSpace(envelope.PayloadBase64) + || envelope.Sha256.Length != 64 + || envelope.Sha256.Any(character => !Uri.IsHexDigit(character))) + { + return null; + } + + byte[] payload; + try + { + payload = Convert.FromBase64String(envelope.PayloadBase64); + } + catch (FormatException) + { + return null; + } + if (!string.Equals( + Convert.ToHexString(SHA256.HashData(payload)), + envelope.Sha256, + StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + PreparedPublicationState? state; + try + { + state = JsonSerializer.Deserialize(payload); + } + catch (JsonException) + { + return null; + } + if (state == null) + { + return null; + } + + try + { + ValidatePreparedPublicationState(state); + return state; + } + catch (ArgumentException) + { + return null; + } + catch (InvalidOperationException) + { + return null; + } + } + + private static void ValidatePreparedPublicationState( + PreparedPublicationState state) + { + if (state.Version != PreparedPublicationStateVersion) + { + throw new InvalidOperationException( + "The prepared publication state version is unsupported."); + } + if (string.IsNullOrWhiteSpace(state.DestinationName) + || !string.Equals( + Path.GetFileName(state.DestinationName), + state.DestinationName, + StringComparison.Ordinal) + || string.IsNullOrWhiteSpace(state.SourceObjectIdentity) + || string.IsNullOrWhiteSpace(state.PreparedObjectIdentity) + || string.IsNullOrWhiteSpace(state.PreviousObjectIdentity) + || state.PreparedLength < 0 + || state.PreparedSha256.Length != 64 + || state.PreparedSha256.Any(character => !Uri.IsHexDigit(character)) + || state.SourceObjectIdentity.Contains('\r') + || state.SourceObjectIdentity.Contains('\n') + || state.PreparedObjectIdentity.Contains('\r') + || state.PreparedObjectIdentity.Contains('\n') + || state.PreviousObjectIdentity.Contains('\r') + || state.PreviousObjectIdentity.Contains('\n') + || state.PublishedDestinationObjectIdentity?.Contains('\r') == true + || state.PublishedDestinationObjectIdentity?.Contains('\n') == true) + { + throw new ArgumentException( + "The prepared publication state is invalid.", + nameof(state)); + } + } +} diff --git a/listenarr.infrastructure/FileSystem/FileMover.RegistrationPublication.CleanupIntent.cs b/listenarr.infrastructure/FileSystem/FileMover.RegistrationPublication.CleanupIntent.cs index a9028d85b..9fd77dcb1 100644 --- a/listenarr.infrastructure/FileSystem/FileMover.RegistrationPublication.CleanupIntent.cs +++ b/listenarr.infrastructure/FileSystem/FileMover.RegistrationPublication.CleanupIntent.cs @@ -276,33 +276,12 @@ internal RegistrationPublicationCleanupCandidate? return null; } + // A committed move intentionally retires the source before the + // registration-publication state is cleaned up. Candidate discovery + // therefore validates only the persisted source-path syntax here. + // Any destructive rollback reopens and verifies the exact source + // generation again in TryRollbackUncommittedRegistrationPublication. sourcePath = canonicalSourcePath; - var sourceParentPath = Path.GetDirectoryName(canonicalSourcePath); - if (string.IsNullOrWhiteSpace(sourceParentPath)) - { - return null; - } - - using var sourceParent = - PinnedDirectoryCreation.OpenPinnedHierarchyNoFollow( - sourceParentPath, - createMissing: false); - using var source = sourceParent.TryOpenExistingFile( - Path.GetFileName(canonicalSourcePath), - requireDeleteAccess: false); - if (source == null - || !source.VisiblePathMatches() - || !string.Equals( - source.GetObjectIdentity(), - intent.SourcePhysicalObjectIdentity, - StringComparison.Ordinal) - || (published != null - && !source.IdentifiesSameEntry(published)) - || (claim != null - && !source.IdentifiesSameEntry(claim))) - { - return null; - } } else if (published == null) { @@ -383,16 +362,34 @@ IOException or UnauthorizedAccessException or JsonException } } - private bool DeleteRegistrationCleanupIntentIfPresent( - PinnedDirectoryCreation.PinnedDirectoryAnchor state) + private static bool RegistrationCleanupIntentMatchesPublication( + PinnedDirectoryCreation.PinnedFileEntry intentEntry, + string destinationName, + string expectedPhysicalObjectIdentity) { - using var intent = state.TryOpenExistingFile( - RegistrationCleanupIntentName, - requireDeleteAccess: true); - if (intent == null) + if (!intentEntry.VisiblePathMatches()) { - return true; + return false; } + + var intent = ReadRegistrationCleanupIntent(intentEntry); + return intent != null + && intent.Version is 1 or RegistrationCleanupIntentVersion + && string.Equals( + intent.DestinationName, + destinationName, + StringComparison.Ordinal) + && string.Equals( + intent.PhysicalObjectIdentity, + expectedPhysicalObjectIdentity, + StringComparison.Ordinal) + && intentEntry.VisiblePathMatches(); + } + + private bool DeletePinnedRegistrationCleanupIntent( + PinnedDirectoryCreation.PinnedDirectoryAnchor state, + PinnedDirectoryCreation.PinnedFileEntry intent) + { if (!intent.VisiblePathMatches()) { return false; diff --git a/listenarr.infrastructure/FileSystem/FileMover.RegistrationPublication.Completion.cs b/listenarr.infrastructure/FileSystem/FileMover.RegistrationPublication.Completion.cs index c6a0cd2e1..23e32e0b7 100644 --- a/listenarr.infrastructure/FileSystem/FileMover.RegistrationPublication.Completion.cs +++ b/listenarr.infrastructure/FileSystem/FileMover.RegistrationPublication.Completion.cs @@ -14,7 +14,6 @@ private async Task using var gate = await TryAcquireFileMoveGateAsync( source, destination, - createDestinationParent: false, allowExistingAliasForRecovery: true); if (gate == null) { @@ -74,6 +73,17 @@ private async Task using var claim = state.TryOpenExistingFile( "publication.claim", requireDeleteAccess: true); + using var intent = state.TryOpenExistingFile( + RegistrationCleanupIntentName, + requireDeleteAccess: true); + if (intent != null + && !RegistrationCleanupIntentMatchesPublication( + intent, + gate.DestinationName, + expectedPhysicalObjectIdentity)) + { + return null; + } if (claim != null) { if (!claim.VisiblePathMatches() @@ -89,15 +99,15 @@ private async Task "registered publication claim retirement"); } - if (!DeleteRegistrationCleanupIntentIfPresent(state)) + if (intent != null + && !DeletePinnedRegistrationCleanupIntent(state, intent)) { return null; } state.Dispose(); - statePublication.DeletePinnedEmptyDirectory( - stateName, - immediateWindows: true); + statePublication.RetirePinnedEmptyDirectoryFromNamespace( + stateName); FlushFileMoveDirectory( gate.DestinationParent, "registered publication state retirement"); @@ -158,7 +168,7 @@ private bool CompleteHardlinkRegistrationPublication( requireDeleteAccess: true); using var intent = state.TryOpenExistingFile( RegistrationCleanupIntentName, - requireDeleteAccess: false); + requireDeleteAccess: true); using var published = parent.TryOpenExistingFile( Path.GetFileName(destination), requireDeleteAccess: false); @@ -171,7 +181,12 @@ private bool CompleteHardlinkRegistrationPublication( || (claim == null && intent == null) || (claim != null && (!claim.VisiblePathMatches() - || !claim.IdentifiesSameEntry(published)))) + || !claim.IdentifiesSameEntry(published))) + || (intent != null + && !RegistrationCleanupIntentMatchesPublication( + intent, + Path.GetFileName(destination), + expectedPhysicalObjectIdentity))) { return false; } @@ -185,16 +200,15 @@ private bool CompleteHardlinkRegistrationPublication( "completed registration-publication claim retirement"); } AfterRegistrationPublicationClaimRetiredForTest?.Invoke(); - intent?.Dispose(); - if (!DeleteRegistrationCleanupIntentIfPresent(state)) + if (intent != null + && !DeletePinnedRegistrationCleanupIntent(state, intent)) { return false; } state.Dispose(); - statePublication.DeletePinnedEmptyDirectory( - stateName, - immediateWindows: true); + statePublication.RetirePinnedEmptyDirectoryFromNamespace( + stateName); FlushFileMoveDirectory( parent, "completed registration-publication state retirement"); diff --git a/listenarr.infrastructure/FileSystem/FileMover.RegistrationPublication.Rollback.cs b/listenarr.infrastructure/FileSystem/FileMover.RegistrationPublication.Rollback.cs index b5a1bb7fa..e6574da7b 100644 --- a/listenarr.infrastructure/FileSystem/FileMover.RegistrationPublication.Rollback.cs +++ b/listenarr.infrastructure/FileSystem/FileMover.RegistrationPublication.Rollback.cs @@ -152,9 +152,8 @@ internal bool TryRollbackUncommittedRegistrationPublication( } state.Dispose(); - statePublication.DeletePinnedEmptyDirectory( - current.StateName, - immediateWindows: true); + statePublication.RetirePinnedEmptyDirectoryFromNamespace( + current.StateName); FlushFileMoveDirectory( destinationParent, "uncommitted registration state rollback"); diff --git a/listenarr.infrastructure/FileSystem/FileMover.RegistrationPublication.cs b/listenarr.infrastructure/FileSystem/FileMover.RegistrationPublication.cs index 4d27ff6d3..5329d8cdc 100644 --- a/listenarr.infrastructure/FileSystem/FileMover.RegistrationPublication.cs +++ b/listenarr.infrastructure/FileSystem/FileMover.RegistrationPublication.cs @@ -35,7 +35,6 @@ private async Task using var gate = await TryAcquireFileMoveGateAsync( source, destination, - createDestinationParent: true, allowExistingAliasForRecovery: true); if (gate == null) { @@ -357,9 +356,8 @@ private bool TryRetireEmptyRegistrationPublicationState( { try { - statePublication.DeletePinnedEmptyDirectory( - stateName, - immediateWindows: true); + statePublication.RetirePinnedEmptyDirectoryFromNamespace( + stateName); FlushFileMoveDirectory( destinationParent, "abandoned registration-publication state retirement"); diff --git a/listenarr.infrastructure/FileSystem/FileMover.TestHooks.cs b/listenarr.infrastructure/FileSystem/FileMover.TestHooks.cs index be7725179..79edf47ac 100644 --- a/listenarr.infrastructure/FileSystem/FileMover.TestHooks.cs +++ b/listenarr.infrastructure/FileSystem/FileMover.TestHooks.cs @@ -28,6 +28,7 @@ internal Action? AfterUncommittedRegistrationDestinationRetiredForTest internal bool DisableNativeFileRenameForTest { get; init; } internal Action? BeforeFileMoveDurabilityBarrierForTest { get; init; } internal Action? AfterDirectoryRenameJournalPublishedForTest { get; init; } + internal Action? BeforeDirectoryRenameJournalRetirementForTest { get; init; } internal Func? AfterDirectoryCopyStagingDirectoriesCreatedForTestAsync { get; init; } internal Func? BeforeDirectoryCopyPublicationForTestAsync { get; init; } internal Func? BeforeDirectoryCopyStagingCleanupForTestAsync { get; init; } @@ -35,6 +36,7 @@ internal Action? AfterUncommittedRegistrationDestinationRetiredForTest internal Func? AfterCleanupSourceFileRetiredForTestAsync { get; init; } internal Func? BeforeCleanupSourceRecoveryDeleteForTestAsync { get; init; } internal Func? AfterCleanupSourceRecoveryDeleteForTestAsync { get; init; } + internal Action? AfterCleanupQuarantineRetiredForTest { get; init; } internal int DirectoryCleanupJournalVersionForTest { get; init; } = 2; internal string? FileMoveLockDirectoryForTest { get; init; } } diff --git a/listenarr.infrastructure/FileSystem/FileMover.cs b/listenarr.infrastructure/FileSystem/FileMover.cs index e3c1c4412..3c25d51a9 100644 --- a/listenarr.infrastructure/FileSystem/FileMover.cs +++ b/listenarr.infrastructure/FileSystem/FileMover.cs @@ -53,8 +53,6 @@ public partial class FileMover : IFileMover private static partial int LinkNative(string oldpath, string newpath); private readonly ILogger _logger; - private readonly IProcessRunner? _processRunner; - private readonly FileMoverOptions _options; private readonly IFileSystemSemanticsResolver _semanticsResolver; internal Func? AfterSourceStateCreatedForTestAsync { get; init; } @@ -66,6 +64,10 @@ public partial class FileMover : IFileMover internal Func? AfterSourceClaimDeletedForTestAsync { get; init; } internal Func? AfterFileMoveStateCleanedForTestAsync { get; init; } internal Func? AfterPreparedDestinationCapturedForTestAsync { get; init; } + internal Func? AfterPreparedClaimMovedBeforeEvidenceForTestAsync { get; init; } + internal Func? AfterPreparedClaimPublishedForTestAsync { get; init; } + internal Func? AfterPreparedPublicationCommittedForTestAsync { get; init; } + internal Func? AfterPreparedDestinationPublishedForTestAsync { get; init; } internal Func? AfterDirectoryCopyPreflightForTestAsync { get; init; } internal Action? BeforeDirectoryTreePreflightForTest { get; init; } @@ -76,8 +78,8 @@ public FileMover( IFileSystemSemanticsResolver? semanticsResolver = null) { _logger = logger; - _processRunner = processRunner; - _options = options?.Value ?? new FileMoverOptions(); + _ = processRunner; + _ = options; _semanticsResolver = semanticsResolver ?? new FileSystemSemanticsResolver(); } @@ -253,84 +255,11 @@ public async Task MoveDirectoryAsync(string sourceDir, string destDir) } catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) { - _logger.LogError(ex, "Copy+delete fallback failed for directory {Source} -> {Dest}", sourceDir, destDir); - - // On Windows attempt robocopy as a final-resort atomic-ish fallback - try - { - var robocopyFallbackSafe = false; - if (!Directory.Exists(destinationRoot) - && await SourceSnapshotStillMatchesAsync(copySnapshot)) - { - try - { - await EnsureDirectoryCopyTargetSafeAsync( - copySnapshot.SourceRoot, - destinationRoot, - destinationRoot); - robocopyFallbackSafe = true; - } - catch (Exception safetyException) when (safetyException is not ( - OperationCanceledException or OutOfMemoryException or StackOverflowException)) - { - _logger.LogWarning( - safetyException, - "Robocopy fallback was blocked because directory safety could not be revalidated"); - } - } - - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) - && _options.EnableRobocopy - && _processRunner != null - && robocopyFallbackSafe) - { - _logger.LogWarning("Attempting robocopy fallback for directory move: {Source} -> {Dest}", sourceDir, destDir); - var startInfo = CreateRobocopyStartInfo( - sourceDir, - destinationRoot, - "/E", - "/NFL", - "/NDL", - "/NJH", - "/NJS", - "/NP"); - - var pr = await _processRunner.RunAsync(startInfo, _options.RobocopyTimeoutMs); - if (!pr.TimedOut && pr.ExitCode <= 7 && pr.ExitCode >= 0) - { - var cleanup = await CleanupCopiedSourceTreeAsync( - copySnapshot, - destinationRoot); - if (!cleanup.DestinationVerified) - { - _logger.LogWarning( - "Robocopy completed, but source cleanup was blocked because the destination could not be verified: {Reason}", - cleanup.Reason); - return false; - } - - if (!cleanup.SourceRemoved) - { - _logger.LogWarning( - "Robocopy completed and preserved changed source content at {Source}: {Reason}", - LogRedaction.SanitizeFilePath(sourceDir), - cleanup.Reason); - return false; - } - - _logger.LogInformation("Robocopy fallback succeeded with exit code {Code}", pr.ExitCode); - _logger.LogDebug("Robocopy stdout: {Out}", LogRedaction.RedactText(Truncate(pr.Stdout, 2000), LogRedaction.GetSensitiveValuesFromEnvironment())); - return true; - } - - _logger.LogWarning("Robocopy fallback failed or returned non-success code: {Code}. Stderr: {Err}", pr.ExitCode, LogRedaction.RedactText(Truncate(pr.Stderr, 2000), LogRedaction.GetSensitiveValuesFromEnvironment())); - } - } - catch (Exception rex) when (rex is not OperationCanceledException && rex is not OutOfMemoryException && rex is not StackOverflowException) - { - _logger.LogWarning(rex, "Robocopy fallback threw an exception"); - } - + _logger.LogError( + ex, + "Verified copy+cleanup failed for directory {Source} -> {Dest}; preserving the source because an external path-based fallback cannot retain pinned filesystem-generation authority", + sourceDir, + destDir); return false; } } diff --git a/listenarr.infrastructure/FileSystem/FileSystemSafety.Deletion.cs b/listenarr.infrastructure/FileSystem/FileSystemSafety.Deletion.cs index 77295d992..e83cd51a8 100644 --- a/listenarr.infrastructure/FileSystem/FileSystemSafety.Deletion.cs +++ b/listenarr.infrastructure/FileSystem/FileSystemSafety.Deletion.cs @@ -92,6 +92,17 @@ public static bool TryDeleteEmptyDirectory( public static bool TryDeleteFile( string filePath, IEnumerable allowedRoots, + out string reason) => + TryDeleteFile( + filePath, + allowedRoots, + expectedPhysicalObjectIdentity: null, + out reason); + + public static bool TryDeleteFile( + string filePath, + IEnumerable allowedRoots, + string? expectedPhysicalObjectIdentity, out string reason) { reason = string.Empty; @@ -126,6 +137,17 @@ public static bool TryDeleteFile( using var entry = parent.OpenExistingFile( fileName, requireDeleteAccess: true); + if (!string.IsNullOrWhiteSpace(expectedPhysicalObjectIdentity) + && !string.Equals( + entry.GetObjectIdentity(), + expectedPhysicalObjectIdentity, + StringComparison.Ordinal)) + { + reason = + "File deletion was blocked because the target physical generation no longer matches the tracked audiobook file."; + return false; + } + if (!TryValidateMutationTarget( normalizedFile, roots, diff --git a/listenarr.infrastructure/FileSystem/PinnedAudiobookFileRegistrationLease.cs b/listenarr.infrastructure/FileSystem/PinnedAudiobookFileRegistrationLease.cs index acb96ac8a..99147dc98 100644 --- a/listenarr.infrastructure/FileSystem/PinnedAudiobookFileRegistrationLease.cs +++ b/listenarr.infrastructure/FileSystem/PinnedAudiobookFileRegistrationLease.cs @@ -38,6 +38,22 @@ private PinnedAudiobookFileRegistrationLease( public string PhysicalObjectIdentity { get; } public string? SourcePhysicalObjectIdentity { get; } + public Stream OpenMetadataReadStream() + { + ObjectDisposedException.ThrowIf(_disposed, this); + return _file.OpenIndependentReadStream( + bufferSize: 128 * 1024, + asynchronous: false); + } + + public Stream OpenMetadataWriteStream() + { + ObjectDisposedException.ThrowIf(_disposed, this); + return _file.OpenIndependentWriteStream( + bufferSize: 128 * 1024, + asynchronous: false); + } + internal static PinnedAudiobookFileRegistrationLease Open( string publicPath, string? expectedPhysicalObjectIdentity = null, diff --git a/listenarr.infrastructure/FileSystem/PinnedDestinationRetentionGuard.cs b/listenarr.infrastructure/FileSystem/PinnedDestinationRetentionGuard.cs index f63aa3ebb..994360b2c 100644 --- a/listenarr.infrastructure/FileSystem/PinnedDestinationRetentionGuard.cs +++ b/listenarr.infrastructure/FileSystem/PinnedDestinationRetentionGuard.cs @@ -16,6 +16,8 @@ internal sealed class PinnedDestinationRetentionGuard : IDisposable private bool _linearized; private bool _completed; + internal Action? AfterWindowsPublicTargetReleasedForTest { get; set; } + private PinnedDestinationRetentionGuard( PinnedDirectoryCreation.PinnedDirectoryAnchor parent, PinnedDirectoryCreation.PinnedFileEntry retention, @@ -377,14 +379,6 @@ internal async Task CompleteAsync( return false; } - if (OperatingSystem.IsWindows()) - { - // The stable public handle defines the Windows commit point by denying - // delete and rename sharing. Release it only after publication has been - // proven so the sibling retention link can be retired. - _publicTarget?.Dispose(); - } - if (!OperatingSystem.IsWindows()) { var retirementName = @@ -403,6 +397,14 @@ internal async Task CompleteAsync( } _completed = true; + if (OperatingSystem.IsWindows()) + { + // The stable public handle is the Windows linearization guard. Keep it + // alive until the recovery copy has been retired and its directory entry + // flushed so no replacement pathname can appear inside the commit window. + _publicTarget?.Dispose(); + AfterWindowsPublicTargetReleasedForTest?.Invoke(); + } return true; } diff --git a/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.DirectoryPublication.cs b/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.DirectoryPublication.cs index c9de2d028..8730da32d 100644 --- a/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.DirectoryPublication.cs +++ b/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.DirectoryPublication.cs @@ -105,9 +105,15 @@ internal PinnedDirectoryAnchor RepublishPinnedDirectory( "The republished directory does not identify the pinned directory."); } - internal void DeletePinnedEmptyDirectory( + internal void DeletePinnedEmptyDirectory(string currentName) => + DeletePinnedEmptyDirectoryCore(currentName, requireImmediateNamespaceRetirement: false); + + internal void RetirePinnedEmptyDirectoryFromNamespace(string currentName) => + DeletePinnedEmptyDirectoryCore(currentName, requireImmediateNamespaceRetirement: true); + + private void DeletePinnedEmptyDirectoryCore( string currentName, - bool immediateWindows = false) + bool requireImmediateNamespaceRetirement) { ThrowIfDisposed(); ValidateLeafName(currentName); @@ -118,25 +124,62 @@ internal void DeletePinnedEmptyDirectory( } var currentPath = Path.Join(_parentPath, currentName); - using var currentAnchor = new PinnedDirectoryAnchor( + using (var currentAnchor = new PinnedDirectoryAnchor( DuplicateSafeHandle(_directoryHandle), currentPath, - followVisibleFinalLink: false); - if (!currentAnchor.VisiblePathMatches()) + followVisibleFinalLink: false)) { - throw new InvalidOperationException( - "The directory changed before pinned deletion."); + if (!currentAnchor.VisiblePathMatches()) + { + throw new InvalidOperationException( + "The directory changed before pinned deletion."); + } } + if (OperatingSystem.IsWindows()) { - if (immediateWindows) + if (!requireImmediateNamespaceRetirement) { - DeleteOpenedFileImmediatelyWindows(_directoryHandle); + DeleteOpenedFileWindows(_directoryHandle); + return; } - else + + // POSIX delete semantics are applied through a distinct file object. Closing + // that handle before returning is the namespace-retirement boundary; using a + // duplicate of _directoryHandle would keep cleanup tied to the original file + // object's lifetime and could leave a child delete-pending while its parent is + // retired immediately afterwards. + using (var retirementHandle = OpenRelativeDirectoryWindows( + _parentHandle, + currentName, + currentPath, + requireDeleteAccess: true)) { - DeleteOpenedFileWindows(_directoryHandle); + if (!HandlesIdentifySameDirectory(_directoryHandle, retirementHandle)) + { + throw new InvalidOperationException( + "The directory changed before immediate pinned retirement."); + } + + DeleteOpenedFileImmediatelyWindows( + retirementHandle, + allowLegacyFallback: false); } + + if (Directory.Exists(currentPath)) + { + using var visible = OpenRelativeDirectoryWindows( + _parentHandle, + currentName, + currentPath); + if (HandlesIdentifySameDirectory(_directoryHandle, visible)) + { + throw new System.ComponentModel.Win32Exception( + 145, + "The verified empty directory remained visible after immediate retirement."); + } + } + return; } diff --git a/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.FileMove.cs b/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.FileMove.cs index f8fa54898..dfb773a85 100644 --- a/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.FileMove.cs +++ b/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.FileMove.cs @@ -281,7 +281,8 @@ internal bool HasUnsupportedCrossVolumeMetadata() internal PinnedFileEntry CreateHardLinkTo( PinnedDirectoryAnchor destinationParent, - string destinationName) + string destinationName, + Action? afterLinkCreatedForTest = null) { ThrowIfDisposed(); ArgumentNullException.ThrowIfNull(destinationParent); @@ -313,6 +314,8 @@ internal PinnedFileEntry CreateHardLinkTo( "Could not create a hardlink between pinned filesystem endpoints."); } + afterLinkCreatedForTest?.Invoke(); + PinnedFileEntry? linked = null; try { @@ -329,7 +332,9 @@ internal PinnedFileEntry CreateHardLinkTo( } catch { - if (linked != null && linked.VisiblePathMatches()) + if (linked != null + && linked.VisiblePathMatches() + && IdentifiesSameEntry(linked)) { linked.Delete(immediateWindows: true); } diff --git a/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.FileRecovery.cs b/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.FileRecovery.cs index 5f2f4fcb1..9a2bc9403 100644 --- a/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.FileRecovery.cs +++ b/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.FileRecovery.cs @@ -11,11 +11,6 @@ internal async Task TryRestoreUnlinkedCopyToAsync( ThrowIfDisposed(); ArgumentNullException.ThrowIfNull(destinationParent); ValidateLeafName(destinationName); - if (OperatingSystem.IsWindows()) - { - throw new PlatformNotSupportedException( - "Unlinked pinned-file recovery is only required on Unix-like platforms."); - } if (!destinationParent.VisiblePathMatches()) { return false; diff --git a/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.FileRetirement.cs b/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.FileRetirement.cs index 1e54de8d5..ad065f4bd 100644 --- a/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.FileRetirement.cs +++ b/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.FileRetirement.cs @@ -43,16 +43,7 @@ internal void Delete(bool immediateWindows = false) "Could not create an exclusive private retirement directory."); } - File.SetUnixFileMode( - retirementDirectory.FullPath, - System.IO.UnixFileMode.UserRead - | System.IO.UnixFileMode.UserWrite - | System.IO.UnixFileMode.UserExecute); - if (!retirementDirectory.VisiblePathMatches()) - { - throw new InvalidOperationException( - "The private retirement directory changed while its permissions were restricted."); - } + retirementDirectory.RestrictToCurrentUser(); { using var retirementAnchor = retirementDirectory.OpenCreatedDirectoryAnchor(); @@ -165,7 +156,9 @@ private static void DeleteOpenedFileWindows(SafeFileHandle fileHandle) } } - private static void DeleteOpenedFileImmediatelyWindows(SafeFileHandle fileHandle) + private static void DeleteOpenedFileImmediatelyWindows( + SafeFileHandle fileHandle, + bool allowLegacyFallback = true) { const int fileDispositionDelete = 0x1; const int fileDispositionPosixSemantics = 0x2; @@ -188,7 +181,7 @@ private static void DeleteOpenedFileImmediatelyWindows(SafeFileHandle fileHandle } var error = Marshal.GetLastWin32Error(); - if (error is 1 or 50 or 87) + if (allowLegacyFallback && error is 1 or 50 or 87) { DeleteOpenedFileWindows(fileHandle); return; diff --git a/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.LockFiles.cs b/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.LockFiles.cs index d510836a2..e5ff6fa9e 100644 --- a/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.LockFiles.cs +++ b/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.LockFiles.cs @@ -13,6 +13,34 @@ internal sealed partial class PinnedDirectoryCreation private const int LinuxWouldBlock = 11; private const int MacWouldBlock = 35; + internal void RestrictToCurrentUser() + { + ThrowIfDisposed(); + if (!Created || _directoryHandle == null || _directoryHandle.IsInvalid) + { + throw new InvalidOperationException( + "A created pinned directory is required to restrict permissions."); + } + + if (OperatingSystem.IsWindows()) + { + if (!VisiblePathMatches()) + { + throw new IOException( + "The pinned directory identity changed while permissions were restricted."); + } + return; + } + + using var handle = DuplicateSafeHandle(_directoryHandle); + RestrictDirectoryHandleToCurrentUser(handle); + if (!VisiblePathMatches()) + { + throw new IOException( + "The pinned directory identity changed while permissions were restricted."); + } + } + internal sealed partial class PinnedDirectoryAnchor { internal void RestrictToCurrentUser() @@ -25,13 +53,8 @@ internal void RestrictToCurrentUser() } using var handle = DuplicateHandleForOperation(); - var privateMode = - System.IO.UnixFileMode.UserRead - | System.IO.UnixFileMode.UserWrite - | System.IO.UnixFileMode.UserExecute; - File.SetUnixFileMode(handle, privateMode); - if (File.GetUnixFileMode(handle) != privateMode - || !VisiblePathMatches()) + RestrictDirectoryHandleToCurrentUser(handle); + if (!VisiblePathMatches()) { throw new IOException( "The pinned lock directory permissions or identity changed."); @@ -172,6 +195,22 @@ internal async Task OpenOrCreateExclusiveLockFileAsync( } } + [UnsupportedOSPlatform("windows")] + private static void RestrictDirectoryHandleToCurrentUser( + SafeFileHandle handle) + { + var privateMode = + System.IO.UnixFileMode.UserRead + | System.IO.UnixFileMode.UserWrite + | System.IO.UnixFileMode.UserExecute; + File.SetUnixFileMode(handle, privateMode); + if (File.GetUnixFileMode(handle) != privateMode) + { + throw new IOException( + "The pinned directory permissions could not be restricted to the current user."); + } + } + [UnsupportedOSPlatform("windows")] private static SafeFileHandle? TryOpenOrCreateExclusiveLockFileUnix( SafeFileHandle directoryHandle, diff --git a/listenarr.infrastructure/FileSystem/RegistrationPublicationCleanupProcessor.cs b/listenarr.infrastructure/FileSystem/RegistrationPublicationCleanupProcessor.cs index 01c730119..b0238bb99 100644 --- a/listenarr.infrastructure/FileSystem/RegistrationPublicationCleanupProcessor.cs +++ b/listenarr.infrastructure/FileSystem/RegistrationPublicationCleanupProcessor.cs @@ -124,6 +124,14 @@ private async Task ProcessCandidateAsync( audiobook, files, cancellationToken); + if (registrationState == RegistrationGenerationState.Unavailable) + { + logger.LogWarning( + "Preserved registration cleanup state {StatePath} because the committed registration state could not be resolved for audiobook {AudiobookId}", + LogRedaction.SanitizeFilePath(candidate.StateDirectoryPath), + candidate.AudiobookId); + return; + } if (registrationState == RegistrationGenerationState.Conflicting) { logger.LogWarning( @@ -285,7 +293,7 @@ private async Task GetRegistrationStateAsync( cancellationToken: cancellationToken); if (resolution.State != PathIdentityState.Valid) { - return RegistrationGenerationState.Absent; + return RegistrationGenerationState.Unavailable; } var conflictingPath = false; @@ -371,6 +379,7 @@ private enum RegistrationGenerationState { Absent, Exact, - Conflicting + Conflicting, + Unavailable } } diff --git a/listenarr.infrastructure/Library/Files/TagLibAudioTagWriter.cs b/listenarr.infrastructure/Library/Files/TagLibAudioTagWriter.cs index 5f59977ca..3c2d27275 100644 --- a/listenarr.infrastructure/Library/Files/TagLibAudioTagWriter.cs +++ b/listenarr.infrastructure/Library/Files/TagLibAudioTagWriter.cs @@ -37,19 +37,7 @@ public Task WriteAsinTagAsync(string filePath, string asin) try { using var file = TagLib.File.Create(filePath); - - if (file.Tag is TagLib.Mpeg4.AppleTag appleTag) - appleTag.SetDashBox("com.apple.iTunes", "ASIN", asin); - else if (file.GetTag(TagLib.TagTypes.Id3v2) is TagLib.Id3v2.Tag id3Tag) - { - var frame = TagLib.Id3v2.UserTextInformationFrame.Get(id3Tag, "ASIN", true); - frame.Text = new[] { asin }; - } - else if (file.GetTag(TagLib.TagTypes.Xiph) is TagLib.Ogg.XiphComment xiph) - xiph.SetField("ASIN", asin); - else - return Task.CompletedTask; - + ApplyAsinTag(file, asin); file.Save(); _logger.LogDebug("Wrote ASIN tag '{Asin}' to {File}", asin, LogRedaction.SanitizeFilePath(filePath)); } @@ -60,5 +48,63 @@ public Task WriteAsinTagAsync(string filePath, string asin) return Task.CompletedTask; } + + public Task WriteAsinTagAsync( + IAudiobookFileRegistrationLease registrationLease, + string asin) + { + ArgumentNullException.ThrowIfNull(registrationLease); + if (string.IsNullOrWhiteSpace(asin)) + { + return Task.CompletedTask; + } + + try + { + using var file = TagLib.File.Create( + new RegistrationLeaseFileAbstraction(registrationLease)); + ApplyAsinTag(file, asin); + file.Save(); + _logger.LogDebug( + "Wrote ASIN tag '{Asin}' to generation-bound file {File}", + asin, + LogRedaction.SanitizeFilePath(registrationLease.PublicPath)); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning( + ex, + "Failed to write ASIN tag to generation-bound file {File} - import will continue", + LogRedaction.SanitizeFilePath(registrationLease.PublicPath)); + } + + return Task.CompletedTask; + } + + private static void ApplyAsinTag(TagLib.File file, string asin) + { + if (file.Tag is TagLib.Mpeg4.AppleTag appleTag) + appleTag.SetDashBox("com.apple.iTunes", "ASIN", asin); + else if (file.GetTag(TagLib.TagTypes.Id3v2) is TagLib.Id3v2.Tag id3Tag) + { + var frame = TagLib.Id3v2.UserTextInformationFrame.Get(id3Tag, "ASIN", true); + frame.Text = new[] { asin }; + } + else if (file.GetTag(TagLib.TagTypes.Xiph) is TagLib.Ogg.XiphComment xiph) + xiph.SetField("ASIN", asin); + } + + private sealed class RegistrationLeaseFileAbstraction( + IAudiobookFileRegistrationLease registrationLease) + : TagLib.File.IFileAbstraction + { + public string Name => registrationLease.PublicPath; + + public Stream ReadStream => registrationLease.OpenMetadataReadStream(); + + public Stream WriteStream => registrationLease.OpenMetadataWriteStream(); + + public void CloseStream(Stream stream) => stream.Dispose(); + } } } diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.FaultInjection.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.FaultInjection.cs index ef380ac5b..ee01b7c96 100644 --- a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.FaultInjection.cs +++ b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.FaultInjection.cs @@ -37,7 +37,9 @@ internal enum SourceCleanupFaultPoint BeforePinnedQuarantineDelete, AfterPinnedQuarantineDelete, BeforeEmptySourceDirectoryQuarantine, - AfterEmptySourceDirectoryQuarantine + AfterEmptySourceDirectoryQuarantine, + BeforeEmptySourceClaimDelete, + BeforeEmptySourceStateDelete } internal enum CopyMutationFaultPoint diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.LeaseFencing.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.LeaseFencing.cs index f437f6acb..57661f000 100644 --- a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.LeaseFencing.cs +++ b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.LeaseFencing.cs @@ -4,6 +4,15 @@ namespace Listenarr.Infrastructure.Library.Moving; internal sealed partial class AudiobookContentMoveService { + internal Task EnsureMutationAuthorizedAsync( + AudiobookContentMoveRequest request, + CancellationToken cancellationToken) => + EnsureMutationAuthorizedAsync( + request, + request.Source, + request.Target, + cancellationToken); + private Task EnsureMutationAuthorizedAsync( AudiobookContentMoveRequest request, string source, diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.PersistedSourceManifest.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.PersistedSourceManifest.cs index 3725ef97e..5d7522794 100644 --- a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.PersistedSourceManifest.cs +++ b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.PersistedSourceManifest.cs @@ -36,6 +36,10 @@ private static async Task ValidatePersistedSourceManifestAsync( foreach (var entry in manifest) { cancellationToken.ThrowIfCancellationRequested(); + if (MoveManifestIdentity.IsTargetBoundaryAuthorization(entry)) + { + continue; + } if (IsRootManifestEntry(entry) || string.IsNullOrWhiteSpace(entry.RelativePath) || string.Equals(entry.RelativePath, ".", StringComparison.Ordinal) @@ -184,9 +188,10 @@ private static async Task SourceTreeExactlyMatchesManifestAsync( ownedScaffoldPaths, structuralSpinePaths, ownedDirectoryMarkerPaths); - var expectedEntryCount = manifest.Count(entry => - !IsRootManifestEntry(entry)); - if (validatedEntries.Count != expectedEntryCount) + var expectedSourceManifest = manifest + .Where(entry => !IsRootManifestEntry(entry)) + .ToList(); + if (validatedEntries.Count != expectedSourceManifest.Count) { return false; } @@ -196,7 +201,7 @@ private static async Task SourceTreeExactlyMatchesManifestAsync( validatedEntries, cancellationToken); return ManifestMatches( - manifest.ToList(), + expectedSourceManifest, currentManifest, sourceSemantics); } diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.RecoveryMarkers.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.RecoveryMarkers.cs index f805db147..e05a4a434 100644 --- a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.RecoveryMarkers.cs +++ b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.RecoveryMarkers.cs @@ -141,34 +141,59 @@ private static void ValidateRecoveryMarkerWritePath( private ParsedRecoveryMarker? ReadRecoveryMarker(string markerPath) { - if (!File.Exists(markerPath)) + var markerDirectory = Path.GetDirectoryName(Path.GetFullPath(markerPath)); + if (string.IsNullOrWhiteSpace(markerDirectory) + || !Directory.Exists(markerDirectory)) { return null; } - if ((File.GetAttributes(markerPath) & FileAttributes.ReparsePoint) != 0) - { - throw new MoveNeedsAttentionException( - "The move recovery marker is a symbolic link or reparse point."); - } - try { - var fileInfo = new FileInfo(markerPath); - if (fileInfo.Length > MaximumMarkerLength) + using var directoryAnchor = + PinnedDirectoryCreation.OpenPinnedDirectoryNoFollow(markerDirectory); + if (!directoryAnchor.VisiblePathMatches()) { throw new MoveNeedsAttentionException( - "The move recovery marker exceeds the supported size and was preserved."); + "The move recovery marker directory changed while it was being inspected."); + } + + PinnedDirectoryCreation.PinnedFileEntry markerEntry; + try + { + markerEntry = directoryAnchor.OpenExistingFileForStableRead( + Path.GetFileName(markerPath)); } + catch (System.ComponentModel.Win32Exception exception) when ( + exception.NativeErrorCode is 2 or 3) + { + return null; + } + catch (FileNotFoundException) + { + return null; + } + catch (DirectoryNotFoundException) + { + return null; + } + using (markerEntry) + { + if (!markerEntry.VisiblePathMatches()) + { + throw new MoveNeedsAttentionException( + "The move recovery marker changed while it was being inspected."); + } - var content = File.ReadAllText(markerPath).Trim(); - return ParseRecoveryMarkerContent(content); + return ReadRecoveryMarker(markerEntry, markerPath); + } } catch (MoveNeedsAttentionException) { throw; } - catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + catch (Exception exception) when (exception is + IOException or UnauthorizedAccessException or System.ComponentModel.Win32Exception) { logger.LogWarning( exception, diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.SourcePhysicalIdentity.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.SourcePhysicalIdentity.cs index 9090c8556..63bc656e8 100644 --- a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.SourcePhysicalIdentity.cs +++ b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.SourcePhysicalIdentity.cs @@ -15,14 +15,17 @@ private static void ValidatePinnedSourcePhysicalIdentity( if (!identities.TryGetValue( manifestEntry.RelativePath, - out var expectedIdentity) - || string.IsNullOrWhiteSpace(expectedIdentity)) + out var expectedIdentity)) { - throw new MoveNeedsAttentionException( - $"The move request has no physical source identity for tracked file: {manifestEntry.RelativePath}"); + // Non-audio companion files are authorized by the exclusive managed + // audiobook directory plus their immutable persisted content manifest. + // Tracked audiobook files are present in this identity map and retain + // the stronger physical-generation fence. + return; } - if (!string.Equals( + if (string.IsNullOrWhiteSpace(expectedIdentity) + || !string.Equals( sourceEntry.GetObjectIdentity(), expectedIdentity, StringComparison.Ordinal)) diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.SourceRootQuarantine.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.SourceRootQuarantine.cs index 5524094dc..8260f1f5a 100644 --- a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.SourceRootQuarantine.cs +++ b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.SourceRootQuarantine.cs @@ -34,14 +34,14 @@ private async Task RecoverEmptySourceDirectoryQuarantineAsync( } var sourceExists = Directory.Exists(request.Source); - if (!Directory.Exists(statePath)) + if (File.Exists(request.Source)) { - return sourceExists; + throw new MoveNeedsAttentionException( + "The source path was recreated as a file while empty-source cleanup state exists; both were preserved."); } - if (sourceExists) + if (!Directory.Exists(statePath)) { - throw new MoveNeedsAttentionException( - "Both the source directory and its interrupted cleanup claim exist; both were preserved."); + return sourceExists; } using var state = PinnedDirectoryCreation.OpenExistingForPublication( @@ -63,15 +63,25 @@ await EnsureMutationAuthorizedAsync( request.Source, request.Target, cancellationToken); - if (Directory.Exists(request.Source) + if (File.Exists(request.Source) + || Directory.Exists(request.Source) != sourceExists || !stateAnchor.VisiblePathMatches()) { throw new MoveNeedsAttentionException( "The source or empty-source cleanup state changed before recovery."); } - state.DeletePinnedEmptyDirectory(EmptySourceQuarantineDirectoryName); - return false; + DeleteEmptySourcePrivateDirectory( + request, + state, + EmptySourceQuarantineDirectoryName, + SourceCleanupFaultPoint.BeforeEmptySourceStateDelete); + return sourceExists; + } + if (sourceExists) + { + throw new MoveNeedsAttentionException( + "Both the source directory and its interrupted cleanup claim exist; both were preserved."); } if (entries.Count != 1 || !string.Equals( @@ -121,10 +131,18 @@ await EnsureMutationAuthorizedAsync( "The source path or empty-source claim changed before deletion."); } - claim.DeletePinnedEmptyDirectory(EmptySourceClaimDirectoryName); + DeleteEmptySourcePrivateDirectory( + request, + claim, + EmptySourceClaimDirectoryName, + SourceCleanupFaultPoint.BeforeEmptySourceClaimDelete); claimAnchor.Dispose(); claim.Dispose(); - state.DeletePinnedEmptyDirectory(EmptySourceQuarantineDirectoryName); + DeleteEmptySourcePrivateDirectory( + request, + state, + EmptySourceQuarantineDirectoryName, + SourceCleanupFaultPoint.BeforeEmptySourceStateDelete); return false; } @@ -175,19 +193,7 @@ private async Task QuarantineAndDeleteEmptySourceDirectoryAsync( throw new MoveNeedsAttentionException( "The empty-source private cleanup state could not be created exclusively."); } - if (!OperatingSystem.IsWindows()) - { - File.SetUnixFileMode( - statePath, - UnixFileMode.UserRead - | UnixFileMode.UserWrite - | UnixFileMode.UserExecute); - } - if (!state.VisiblePathMatches()) - { - throw new MoveNeedsAttentionException( - "The empty-source cleanup state changed while permissions were restricted."); - } + state.RestrictToCurrentUser(); using var stateAnchor = state.OpenCreatedDirectoryAnchor(); await EnsureMutationAuthorizedAsync( @@ -239,12 +245,20 @@ await EnsureMutationAuthorizedAsync( "The source path or empty-source claim changed before deletion."); } - claim.DeletePinnedEmptyDirectory(EmptySourceClaimDirectoryName); + DeleteEmptySourcePrivateDirectory( + request, + claim, + EmptySourceClaimDirectoryName, + SourceCleanupFaultPoint.BeforeEmptySourceClaimDelete); claimAnchor.Dispose(); claim.Dispose(); sourceAnchor.Dispose(); source.Dispose(); - state.DeletePinnedEmptyDirectory(EmptySourceQuarantineDirectoryName); + DeleteEmptySourcePrivateDirectory( + request, + state, + EmptySourceQuarantineDirectoryName, + SourceCleanupFaultPoint.BeforeEmptySourceStateDelete); } private async Task RestorePinnedEmptySourceClaimAsync( @@ -276,6 +290,30 @@ await EnsureMutationAuthorizedAsync( "The empty-source claim could not be restored to the source path."); } - state.DeletePinnedEmptyDirectory(EmptySourceQuarantineDirectoryName); + DeleteEmptySourcePrivateDirectory( + request, + state, + EmptySourceQuarantineDirectoryName, + SourceCleanupFaultPoint.BeforeEmptySourceStateDelete); + } + + private void DeleteEmptySourcePrivateDirectory( + AudiobookContentMoveRequest request, + PinnedDirectoryCreation directory, + string directoryName, + SourceCleanupFaultPoint faultPoint) + { + try + { + faultInjector?.OnSourceCleanupMutation(request.JobId, faultPoint); + directory.RetirePinnedEmptyDirectoryFromNamespace( + directoryName); + } + catch (System.ComponentModel.Win32Exception exception) + { + throw new IOException( + "Verified empty-source cleanup state could not be retired; durable recovery state was preserved for retry.", + exception); + } } } diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.SourceValidation.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.SourceValidation.cs index 8c0ad9d5f..f53517c0b 100644 --- a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.SourceValidation.cs +++ b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.SourceValidation.cs @@ -76,7 +76,7 @@ private static IReadOnlyList ValidateSourceTreeForMove( } var entryName = Path.GetFileName(entry); - if (IsReservedMoveArtifactName(entryName)) + if (MoveFilesystemArtifactNames.IsReserved(entryName)) { if (!string.IsNullOrWhiteSpace(ownedRecoveryMarkerPath) && FileSystemPathIdentity.AreEquivalent( @@ -132,18 +132,6 @@ private static bool IsRootManifestEntry(MoveJobEntry entry) => RootManifestRelativePath, StringComparison.Ordinal); - private static bool IsReservedMoveArtifactName(string name) => - name.StartsWith(".listenarr-move-", StringComparison.Ordinal) - || name.StartsWith(".listenarr-quarantine-", StringComparison.Ordinal) - || name.StartsWith(".listenarr-temporary-directory-", StringComparison.Ordinal) - || string.Equals(name, ".listenarr-temp-owner.json", StringComparison.Ordinal) - || string.Equals(name, ".listenarr-quarantine-owner.json", StringComparison.Ordinal) - || string.Equals(name, LibraryDirectoryOwnershipMarker.FileName, StringComparison.Ordinal) - || name.StartsWith(".listenarr-directory-owner-", StringComparison.Ordinal) - && name.EndsWith(".json", StringComparison.Ordinal) - || name.Contains(".listenarr-", StringComparison.Ordinal) - && name.EndsWith(".partial", StringComparison.Ordinal); - private static async Task> BuildManifestAsync( Guid jobId, IReadOnlyList validatedEntries, diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.TargetScaffoldingMarkers.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.TargetScaffoldingMarkers.cs index dc550edb1..1870450c6 100644 --- a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.TargetScaffoldingMarkers.cs +++ b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.TargetScaffoldingMarkers.cs @@ -50,40 +50,61 @@ private static void WriteScaffoldMarker( private static ScaffoldOwnershipMarker? ReadScaffoldMarker(string directory) { - var markerPath = Path.Join(directory, ScaffoldOwnerFileName); - if (!File.Exists(markerPath)) + if (!Directory.Exists(directory)) { return null; } - if ((File.GetAttributes(markerPath) & FileAttributes.ReparsePoint) != 0) + try { - throw new MoveNeedsAttentionException( - "The target scaffold ownership marker is linked."); - } + using var directoryAnchor = + PinnedDirectoryCreation.OpenPinnedDirectoryNoFollow(directory); + if (!directoryAnchor.VisiblePathMatches()) + { + throw new MoveNeedsAttentionException( + "The target scaffold directory changed while its ownership marker was being inspected."); + } - var length = new FileInfo(markerPath).Length; - if (length <= 0 || length > MaximumScaffoldMarkerBytes) - { - throw new MoveNeedsAttentionException( - "The target scaffold ownership marker has an invalid size."); - } + PinnedDirectoryCreation.PinnedFileEntry markerEntry; + try + { + markerEntry = directoryAnchor.OpenExistingFileForStableRead( + ScaffoldOwnerFileName); + } + catch (System.ComponentModel.Win32Exception exception) when ( + exception.NativeErrorCode is 2 or 3) + { + return null; + } + catch (FileNotFoundException) + { + return null; + } + catch (DirectoryNotFoundException) + { + return null; + } - try + using (markerEntry) + { + if (!markerEntry.VisiblePathMatches()) + { + throw new MoveNeedsAttentionException( + "The target scaffold ownership marker changed while it was being inspected."); + } + + return ReadScaffoldMarker(markerEntry); + } + } + catch (MoveNeedsAttentionException) { - using var stream = new FileStream( - markerPath, - FileMode.Open, - FileAccess.Read, - FileShare.Read, - 4096, - FileOptions.SequentialScan); - return JsonSerializer.Deserialize(stream); + throw; } - catch (JsonException exception) + catch (Exception exception) when (exception is + IOException or UnauthorizedAccessException or System.ComponentModel.Win32Exception) { throw new MoveNeedsAttentionException( - $"The target scaffold ownership marker is invalid: {exception.Message}"); + $"The target scaffold ownership marker is unreadable: {exception.Message}"); } } diff --git a/listenarr.infrastructure/Library/Moving/AudiobookFilesystemDeleteService.PinnedTree.cs b/listenarr.infrastructure/Library/Moving/AudiobookFilesystemDeleteService.PinnedTree.cs index b1d47928a..c79e58bf4 100644 --- a/listenarr.infrastructure/Library/Moving/AudiobookFilesystemDeleteService.PinnedTree.cs +++ b/listenarr.infrastructure/Library/Moving/AudiobookFilesystemDeleteService.PinnedTree.cs @@ -26,6 +26,7 @@ public sealed partial class AudiobookFilesystemDeleteService private static bool TryValidatePinnedDirectoryTree( PinnedDirectoryCreation.PinnedDirectoryAnchor rootAuthorization, PinnedDirectoryCreation.PinnedDirectoryAnchor currentDirectory, + IReadOnlyDictionary trackedPhysicalObjectIdentities, IDictionary preflightIdentities, out string reason) { @@ -80,6 +81,7 @@ private static bool TryValidatePinnedDirectoryTree( if (!TryValidatePinnedDirectoryTree( rootAuthorization, child, + trackedPhysicalObjectIdentities, preflightIdentities, out reason)) { @@ -92,9 +94,23 @@ private static bool TryValidatePinnedDirectoryTree( using var file = currentDirectory.OpenExistingFile( entryName, requireDeleteAccess: false); + var physicalObjectIdentity = file.GetObjectIdentity(); + if (trackedPhysicalObjectIdentities.TryGetValue( + entryPath, + out var expectedTrackedPhysicalObjectIdentity) + && !string.Equals( + physicalObjectIdentity, + expectedTrackedPhysicalObjectIdentity, + StringComparison.Ordinal)) + { + reason = + "A tracked audiobook file physical generation changed before recursive-delete preflight."; + return false; + } + preflightIdentities[Path.GetRelativePath( rootAuthorization.FullPath, - entryPath)] = file.GetObjectIdentity(); + entryPath)] = physicalObjectIdentity; if (!rootAuthorization.VisiblePathMatches() || !currentDirectory.VisiblePathMatches() || !file.VisiblePathMatches()) @@ -219,9 +235,8 @@ private bool TryDeletePinnedDirectoryContents( return false; } - childPublication.DeletePinnedEmptyDirectory( - entryName, - immediateWindows: true); + childPublication.RetirePinnedEmptyDirectoryFromNamespace( + entryName); } continue; diff --git a/listenarr.infrastructure/Library/Moving/AudiobookFilesystemDeleteService.cs b/listenarr.infrastructure/Library/Moving/AudiobookFilesystemDeleteService.cs index cae0c82cc..b1d7391d9 100644 --- a/listenarr.infrastructure/Library/Moving/AudiobookFilesystemDeleteService.cs +++ b/listenarr.infrastructure/Library/Moving/AudiobookFilesystemDeleteService.cs @@ -81,6 +81,18 @@ public async Task DeleteAsync( deleteSemantics, result, out var hasUnresolvedTrackedPaths); + var trackedPhysicalObjectIdentities = ResolveTrackedPhysicalObjectIdentities( + audiobook, + deleteSemantics, + result, + out var hasConflictingTrackedPhysicalIdentities, + out var hasUnprovenTrackedPhysicalIdentities); + if (hasConflictingTrackedPhysicalIdentities + || hasUnprovenTrackedPhysicalIdentities) + { + return result; + } + var deleteTarget = hasUnresolvedTrackedPaths ? null : await ResolveDeleteFolderTargetAsync( @@ -115,6 +127,7 @@ public async Task DeleteAsync( contentsDeleted = TryDeleteFolderContents( deleteTarget, targetAuthorization, + trackedPhysicalObjectIdentities, result); } @@ -139,7 +152,14 @@ await TryDeleteAudiobookFolderAsync( cancellationToken); foreach (var trackedFilePath in trackedFilePaths) { - TryDeleteFile(trackedFilePath, result, allowedRoots); + trackedPhysicalObjectIdentities.TryGetValue( + trackedFilePath, + out var expectedPhysicalObjectIdentity); + TryDeleteFile( + trackedFilePath, + expectedPhysicalObjectIdentity, + result, + allowedRoots); } if (deleteFolder) @@ -303,6 +323,54 @@ private static IReadOnlyList ResolveTrackedFilePaths( return paths.ToList(); } + private static IReadOnlyDictionary ResolveTrackedPhysicalObjectIdentities( + Audiobook audiobook, + FileSystemPathSemantics semantics, + AudiobookFilesystemDeleteResult result, + out bool hasConflict, + out bool hasUnprovenTrackedPhysicalIdentities) + { + var identities = new Dictionary(semantics.Comparer); + hasConflict = false; + hasUnprovenTrackedPhysicalIdentities = false; + foreach (var file in audiobook.Files ?? []) + { + if (string.IsNullOrWhiteSpace(file.Path) + || !TryResolveStoredFilePath( + audiobook, + file.Path, + semantics, + out var resolvedPath)) + { + continue; + } + + if (string.IsNullOrWhiteSpace(file.PhysicalObjectIdentity)) + { + hasUnprovenTrackedPhysicalIdentities = true; + result.Warnings.Add( + "A tracked audiobook file has no persisted physical generation, so filesystem deletion was blocked."); + continue; + } + + if (identities.TryGetValue(resolvedPath, out var existingIdentity) + && !string.Equals( + existingIdentity, + file.PhysicalObjectIdentity, + StringComparison.Ordinal)) + { + hasConflict = true; + result.Warnings.Add( + "Conflicting tracked physical generations reference the same audiobook file path, so filesystem deletion was blocked."); + return identities; + } + + identities[resolvedPath] = file.PhysicalObjectIdentity; + } + + return identities; + } + private static bool TryResolveStoredFilePath( Audiobook audiobook, string storedPath, @@ -337,6 +405,7 @@ private static bool TryResolveStoredFilePath( private void TryDeleteFile( string path, + string? expectedPhysicalObjectIdentity, AudiobookFilesystemDeleteResult result, IEnumerable allowedRoots) { @@ -348,9 +417,13 @@ private void TryDeleteFile( if (!FileSystemSafety.TryDeleteFile( path, allowedRoots, + expectedPhysicalObjectIdentity, out var reason)) { - var warning = $"Could not delete file '{Path.GetFileName(path)}'."; + var warning = !string.IsNullOrWhiteSpace(expectedPhysicalObjectIdentity) + && reason.Contains("physical generation", StringComparison.OrdinalIgnoreCase) + ? $"Could not delete file '{Path.GetFileName(path)}' because its tracked physical generation changed." + : $"Could not delete file '{Path.GetFileName(path)}'."; result.Warnings.Add(warning); _logger.LogWarning( "Blocked audiobook file delete for {Path}: {Reason}", @@ -368,6 +441,7 @@ private void TryDeleteFile( private bool TryDeleteFolderContents( DeleteFolderTarget deleteTarget, PinnedDirectoryCreation.PinnedDirectoryAnchor targetAuthorization, + IReadOnlyDictionary trackedPhysicalObjectIdentities, AudiobookFilesystemDeleteResult result) { var folderPath = deleteTarget.FolderPath; @@ -384,6 +458,7 @@ private bool TryDeleteFolderContents( if (!TryValidatePinnedDirectoryTree( targetAuthorization, targetAuthorization, + trackedPhysicalObjectIdentities, preflightIdentities, out var reason)) { diff --git a/listenarr.infrastructure/Library/Moving/EfLibraryDirectoryOwnershipStore.Helpers.cs b/listenarr.infrastructure/Library/Moving/EfLibraryDirectoryOwnershipStore.Helpers.cs new file mode 100644 index 000000000..dad55fd6f --- /dev/null +++ b/listenarr.infrastructure/Library/Moving/EfLibraryDirectoryOwnershipStore.Helpers.cs @@ -0,0 +1,160 @@ +using Listenarr.Domain.Common; + +namespace Listenarr.Infrastructure.Library.Moving; + +internal sealed partial class EfLibraryDirectoryOwnershipStore +{ + private static void ValidatePinnedOwnership( + LibraryDirectoryOwnership ownership, + PinnedDirectoryCreation creation) + { + using var directory = creation.OpenCreatedDirectoryAnchor(); + using var parent = creation.OpenParentDirectoryAnchor(); + LibraryDirectoryOwnershipMarker.Validate( + ownership, + directory, + parent); + } + + private static void CleanupRetiredSiblingMarkers( + IEnumerable retiredCandidates, + string canonicalPath, + FileSystemPathSemantics semantics) + { + foreach (var retired in retiredCandidates) + { + try + { + if (Compare(retired, canonicalPath, semantics) == OwnershipComparison.Compatible) + { + LibraryDirectoryOwnershipMarker.TryDeleteRetiredSiblingMarker( + retired, + out _); + } + } + catch (Exception exception) when (exception is + ArgumentException or InvalidOperationException or NotSupportedException or PathTooLongException) + { + // Removed rows are nonauthoritative. Corrupt retired metadata must not + // prevent a new, independently proven ownership claim for the live path. + } + } + } + + private static LibraryDirectoryOwnership CreateOwnership( + LibraryDirectoryOwnershipClaim claim, + string canonicalPath, + string lookupKey, + string? ownershipKey, + LibraryDirectoryOwnershipState state, + string? reason, + int? managedRootFolderId, + string nativeDirectoryIdentity, + DateTime now) + { + var ownershipToken = Guid.NewGuid().ToString("N"); + return new LibraryDirectoryOwnership + { + Path = claim.Path, + CanonicalPath = canonicalPath, + PathSyntax = claim.Semantics.Syntax, + PathCaseSensitivity = claim.Semantics.CaseSensitivity, + PathCaseSensitivityMode = claim.Semantics.CaseSensitivity == FileSystemCaseSensitivity.Sensitive + ? FileSystemCaseSensitivityMode.Sensitive + : FileSystemCaseSensitivityMode.Insensitive, + PathIdentityBoundary = canonicalPath, + PathIdentityLookupKey = lookupKey, + PathOwnershipKey = ownershipKey, + OwnershipToken = ownershipToken, + State = state, + CreationWorkflow = claim.CreationWorkflow, + CreationOperationId = claim.CreationOperationId, + AudiobookId = claim.AudiobookId, + ManagedRootFolderId = managedRootFolderId, + DirectoryObjectIdentityVersion = ManagedDirectoryIdentity.CurrentVersion, + DirectoryObjectIdentity = ManagedDirectoryIdentity.Create( + ownershipToken, + nativeDirectoryIdentity), + DirectoryObjectIdentityUnavailableReason = managedRootFolderId.HasValue + ? null + : "The claim was not created through an authorized managed root.", + StateReason = reason, + CreatedAt = now, + UpdatedAt = now + }; + } + + private static void EnsureAuthorizedPhysicalIdentity( + LibraryDirectoryOwnership ownership, + int? managedRootFolderId, + string directoryObjectIdentity) + { + if (!managedRootFolderId.HasValue + || ownership.ManagedRootFolderId != managedRootFolderId + || !ManagedDirectoryIdentity.Matches( + ownership.DirectoryObjectIdentityVersion, + ownership.DirectoryObjectIdentity, + ownership.OwnershipToken, + directoryObjectIdentity) + || (ownership.State != + LibraryDirectoryOwnershipState.Unavailable + && !string.IsNullOrWhiteSpace( + ownership.DirectoryObjectIdentityUnavailableReason))) + { + throw new InvalidOperationException( + "The existing ownership claim lacks matching managed-root and physical-directory authorization."); + } + } + + private static OwnershipComparison Compare( + LibraryDirectoryOwnership ownership, + string canonicalPath, + FileSystemPathSemantics currentSemantics) + { + var identity = ownership.GetIdentity(); + identity.ValidateForPath(ownership.CanonicalPath); + if (identity.Syntax != currentSemantics.Syntax) + { + return OwnershipComparison.Distinct; + } + + var matchesCurrent = FileSystemPathIdentity.AreEquivalent( + ownership.CanonicalPath, + canonicalPath, + currentSemantics); + var matchesStored = FileSystemPathIdentity.AreEquivalent( + ownership.CanonicalPath, + canonicalPath, + identity.Semantics); + if (!matchesCurrent && !matchesStored) + { + return OwnershipComparison.Distinct; + } + + return matchesCurrent + && matchesStored + && identity.CaseSensitivity == currentSemantics.CaseSensitivity + && FileSystemPathIdentity.AreEquivalent( + identity.BoundaryPath, + canonicalPath, + currentSemantics) + ? OwnershipComparison.Compatible + : OwnershipComparison.Conflict; + } + + private static void EnsureResolved(FileSystemPathSemantics semantics) + { + if (semantics.CaseSensitivity == FileSystemCaseSensitivity.Unknown) + { + throw new InvalidOperationException( + "Filesystem case sensitivity must be resolved before claiming directory ownership."); + } + } + + private enum OwnershipComparison + { + Distinct, + Compatible, + Conflict + } +} diff --git a/listenarr.infrastructure/Library/Moving/EfLibraryDirectoryOwnershipStore.Resolution.cs b/listenarr.infrastructure/Library/Moving/EfLibraryDirectoryOwnershipStore.Resolution.cs index 355140f26..6b644307a 100644 --- a/listenarr.infrastructure/Library/Moving/EfLibraryDirectoryOwnershipStore.Resolution.cs +++ b/listenarr.infrastructure/Library/Moving/EfLibraryDirectoryOwnershipStore.Resolution.cs @@ -120,26 +120,33 @@ or LibraryDirectoryOwnershipState.Retained { try { - using var live = PinnedDirectoryCreation.OpenPinnedVisibleDirectory( - resolved.CanonicalPath); + var parentPath = Path.GetDirectoryName(resolved.CanonicalPath) + ?? throw new InvalidOperationException( + "The owned directory has no parent for durable proof validation."); + using var parent = PinnedDirectoryCreation.OpenPinnedBoundary(parentPath); + using var live = parent.OpenExistingChild( + Path.GetFileName(resolved.CanonicalPath)); if (!ManagedDirectoryIdentity.Matches( resolved.DirectoryObjectIdentityVersion, resolved.DirectoryObjectIdentity, resolved.OwnershipToken, live.GetDirectoryObjectIdentity()) - || !live.VisiblePathMatches()) + || !live.VisiblePathMatches() + || !parent.VisiblePathMatches()) { throw new InvalidOperationException( "The owned directory no longer matches its enrolled physical identity."); } + AfterOwnedDirectoryPhysicalIdentityPinnedForTest?.Invoke(); LibraryDirectoryOwnershipMarker.Validate( resolved, - resolved.CanonicalPath); + live, + parent); } catch (Exception exception) when (exception is ArgumentException or IOException or UnauthorizedAccessException or InvalidOperationException or NotSupportedException - or PathTooLongException) + or PathTooLongException or System.ComponentModel.Win32Exception) { return new LibraryDirectoryOwnershipResolution( LibraryDirectoryOwnershipResolutionState.Unavailable, diff --git a/listenarr.infrastructure/Library/Moving/EfLibraryDirectoryOwnershipStore.cs b/listenarr.infrastructure/Library/Moving/EfLibraryDirectoryOwnershipStore.cs index 8f091d780..43fef5cf8 100644 --- a/listenarr.infrastructure/Library/Moving/EfLibraryDirectoryOwnershipStore.cs +++ b/listenarr.infrastructure/Library/Moving/EfLibraryDirectoryOwnershipStore.cs @@ -27,6 +27,12 @@ internal Action? AfterOwnershipMarkerPublicationForTest set; } + internal Action? AfterOwnedDirectoryPhysicalIdentityPinnedForTest + { + get; + set; + } + public async Task RecordCreatedAsync( LibraryDirectoryOwnershipClaim claim, CancellationToken cancellationToken = default) @@ -344,157 +350,4 @@ await PinnedLibraryDirectoryOwnershipMarker.EnsureAsync( return ownership; } - private static void ValidatePinnedOwnership( - LibraryDirectoryOwnership ownership, - PinnedDirectoryCreation creation) - { - using var directory = creation.OpenCreatedDirectoryAnchor(); - using var parent = creation.OpenParentDirectoryAnchor(); - LibraryDirectoryOwnershipMarker.Validate( - ownership, - directory, - parent); - } - - private static void CleanupRetiredSiblingMarkers( - IEnumerable retiredCandidates, - string canonicalPath, - FileSystemPathSemantics semantics) - { - foreach (var retired in retiredCandidates) - { - try - { - if (Compare(retired, canonicalPath, semantics) == OwnershipComparison.Compatible) - { - LibraryDirectoryOwnershipMarker.TryDeleteRetiredSiblingMarker( - retired, - out _); - } - } - catch (Exception exception) when (exception is - ArgumentException or InvalidOperationException or NotSupportedException or PathTooLongException) - { - // Removed rows are nonauthoritative. Corrupt retired metadata must not - // prevent a new, independently proven ownership claim for the live path. - } - } - } - - private static LibraryDirectoryOwnership CreateOwnership( - LibraryDirectoryOwnershipClaim claim, - string canonicalPath, - string lookupKey, - string? ownershipKey, - LibraryDirectoryOwnershipState state, - string? reason, - int? managedRootFolderId, - string nativeDirectoryIdentity, - DateTime now) - { - var ownershipToken = Guid.NewGuid().ToString("N"); - return new LibraryDirectoryOwnership - { - Path = claim.Path, - CanonicalPath = canonicalPath, - PathSyntax = claim.Semantics.Syntax, - PathCaseSensitivity = claim.Semantics.CaseSensitivity, - PathCaseSensitivityMode = claim.Semantics.CaseSensitivity == FileSystemCaseSensitivity.Sensitive - ? FileSystemCaseSensitivityMode.Sensitive - : FileSystemCaseSensitivityMode.Insensitive, - PathIdentityBoundary = canonicalPath, - PathIdentityLookupKey = lookupKey, - PathOwnershipKey = ownershipKey, - OwnershipToken = ownershipToken, - State = state, - CreationWorkflow = claim.CreationWorkflow, - CreationOperationId = claim.CreationOperationId, - AudiobookId = claim.AudiobookId, - ManagedRootFolderId = managedRootFolderId, - DirectoryObjectIdentityVersion = ManagedDirectoryIdentity.CurrentVersion, - DirectoryObjectIdentity = ManagedDirectoryIdentity.Create( - ownershipToken, - nativeDirectoryIdentity), - DirectoryObjectIdentityUnavailableReason = managedRootFolderId.HasValue - ? null - : "The claim was not created through an authorized managed root.", - StateReason = reason, - CreatedAt = now, - UpdatedAt = now - }; - } - - private static void EnsureAuthorizedPhysicalIdentity( - LibraryDirectoryOwnership ownership, - int? managedRootFolderId, - string directoryObjectIdentity) - { - if (!managedRootFolderId.HasValue - || ownership.ManagedRootFolderId != managedRootFolderId - || !ManagedDirectoryIdentity.Matches( - ownership.DirectoryObjectIdentityVersion, - ownership.DirectoryObjectIdentity, - ownership.OwnershipToken, - directoryObjectIdentity) - || (ownership.State != - LibraryDirectoryOwnershipState.Unavailable - && !string.IsNullOrWhiteSpace( - ownership.DirectoryObjectIdentityUnavailableReason))) - { - throw new InvalidOperationException( - "The existing ownership claim lacks matching managed-root and physical-directory authorization."); - } - } - - private static OwnershipComparison Compare( - LibraryDirectoryOwnership ownership, - string canonicalPath, - FileSystemPathSemantics currentSemantics) - { - var identity = ownership.GetIdentity(); - identity.ValidateForPath(ownership.CanonicalPath); - if (identity.Syntax != currentSemantics.Syntax) - { - return OwnershipComparison.Distinct; - } - - var matchesCurrent = FileSystemPathIdentity.AreEquivalent( - ownership.CanonicalPath, - canonicalPath, - currentSemantics); - var matchesStored = FileSystemPathIdentity.AreEquivalent( - ownership.CanonicalPath, - canonicalPath, - identity.Semantics); - if (!matchesCurrent && !matchesStored) - { - return OwnershipComparison.Distinct; - } - - return matchesCurrent - && matchesStored - && identity.CaseSensitivity == currentSemantics.CaseSensitivity - && FileSystemPathIdentity.AreEquivalent( - identity.BoundaryPath, - canonicalPath, - currentSemantics) - ? OwnershipComparison.Compatible - : OwnershipComparison.Conflict; - } - - private static void EnsureResolved(FileSystemPathSemantics semantics) - { - if (semantics.CaseSensitivity == FileSystemCaseSensitivity.Unknown) - { - throw new InvalidOperationException( - "Filesystem case sensitivity must be resolved before claiming directory ownership."); - } - } - - private enum OwnershipComparison - { - Distinct, - Compatible, - Conflict - } } diff --git a/listenarr.infrastructure/Library/Moving/EfMoveExecutionStore.Helpers.cs b/listenarr.infrastructure/Library/Moving/EfMoveExecutionStore.Helpers.cs index 6821ef3f8..3ce9097b3 100644 --- a/listenarr.infrastructure/Library/Moving/EfMoveExecutionStore.Helpers.cs +++ b/listenarr.infrastructure/Library/Moving/EfMoveExecutionStore.Helpers.cs @@ -33,6 +33,153 @@ ArgumentException or InvalidOperationException } } + private static async Task EnsureRelocationTargetGenerationAuthorizedAsync( + ListenArrDbContext db, + Guid relocationId, + string target, + FileSystemPathSemantics targetSemantics, + CancellationToken cancellationToken) + { + var relocation = await db.RootFolderRelocations + .AsNoTracking() + .Where(candidate => candidate.Id == relocationId) + .Select(candidate => new + { + candidate.ActiveRootFolderId, + candidate.TargetPath, + candidate.TargetIdentityEnrollmentState, + candidate.TargetDirectoryObjectIdentityVersion, + candidate.TargetDirectoryObjectIdentity, + candidate.TargetDirectoryObjectIdentityUnavailableReason + }) + .SingleOrDefaultAsync(cancellationToken) + ?? throw new MoveNeedsAttentionException( + "The relocation owning this move no longer exists."); + if (!relocation.ActiveRootFolderId.HasValue + || relocation.TargetIdentityEnrollmentState + != TargetIdentityEnrollmentState.Authorized) + { + throw new MoveNeedsAttentionException( + "The relocation target no longer has active physical-directory authorization."); + } + + string targetRoot; + try + { + if (!FileSystemPathIdentity.TryCanonicalizeUnambiguousStoredAbsolutePathForHost( + relocation.TargetPath, + out targetRoot, + out var pathReason) + || !FileSystemPathIdentity.IsSameOrInside( + target, + targetRoot, + targetSemantics)) + { + throw new MoveNeedsAttentionException( + pathReason + ?? "The move target escaped its authorized relocation target root."); + } + } + catch (MoveNeedsAttentionException) + { + throw; + } + catch (Exception exception) when (exception is + ArgumentException or InvalidOperationException + or NotSupportedException or PathTooLongException) + { + throw new MoveNeedsAttentionException( + $"The relocation target identity is invalid: {exception.Message}"); + } + + try + { + using var root = PinnedDirectoryCreation.OpenPinnedBoundary(targetRoot); + await ManagedDirectoryEnrollment.RequireMatchingEnrollmentAsync( + root, + relocation.TargetDirectoryObjectIdentityVersion, + relocation.TargetDirectoryObjectIdentity, + relocation.TargetDirectoryObjectIdentityUnavailableReason, + cancellationToken); + } + catch (Exception exception) when (exception is + IOException or UnauthorizedAccessException + or InvalidOperationException or NotSupportedException + or System.ComponentModel.Win32Exception) + { + throw new MoveNeedsAttentionException( + $"The relocation target physical generation is no longer authorized: {exception.Message}"); + } + } + + private static async Task EnsureTargetBoundaryGenerationAuthorizedAsync( + ListenArrDbContext db, + Guid jobId, + string targetBoundary, + CancellationToken cancellationToken) + { + var authorizationEntries = await db.MoveJobEntries + .AsNoTracking() + .Where(entry => entry.MoveJobId == jobId + && entry.EntryType == MoveJobEntryType.Directory + && entry.RelativePath == string.Empty + && entry.Length > 0 + && entry.Sha256 != null) + .Select(entry => new + { + entry.Length, + entry.Sha256 + }) + .Take(2) + .ToListAsync(cancellationToken); + if (authorizationEntries.Count != 1 + || authorizationEntries[0].Length > int.MaxValue + || authorizationEntries[0].Sha256 is not { Length: 64 } expectedDigest + || !expectedDigest.All(Uri.IsHexDigit)) + { + throw new MoveNeedsAttentionException( + "The move job lacks one authoritative target-boundary physical-generation proof."); + } + + try + { + using var boundary = PinnedDirectoryCreation.OpenPinnedBoundary( + targetBoundary); + var nativeIdentity = boundary.GetDirectoryObjectIdentity(); + var current = await ManagedDirectoryEnrollment.ResolveAsync( + boundary, + nativeIdentity, + enrollIfMissing: false, + cancellationToken); + var currentVersion = (int)authorizationEntries[0].Length; + if (!current.IsAvailable + || current.Version != currentVersion + || !string.Equals( + MoveManifestIdentity.ComputeTargetBoundaryAuthorizationDigest( + currentVersion, + current.Value!), + expectedDigest, + StringComparison.OrdinalIgnoreCase) + || !boundary.VisiblePathMatches()) + { + throw new MoveNeedsAttentionException( + "The move target boundary no longer identifies its authorized physical generation."); + } + } + catch (MoveNeedsAttentionException) + { + throw; + } + catch (Exception exception) when (exception is + IOException or UnauthorizedAccessException + or InvalidOperationException or NotSupportedException + or System.ComponentModel.Win32Exception) + { + throw new MoveNeedsAttentionException( + $"The move target boundary physical generation is unavailable: {exception.Message}"); + } + } + private static async Task IsLeaseActiveAsync( ListenArrDbContext db, Guid jobId, diff --git a/listenarr.infrastructure/Library/Moving/EfMoveExecutionStore.cs b/listenarr.infrastructure/Library/Moving/EfMoveExecutionStore.cs index af8a0d318..32b90bda7 100644 --- a/listenarr.infrastructure/Library/Moving/EfMoveExecutionStore.cs +++ b/listenarr.infrastructure/Library/Moving/EfMoveExecutionStore.cs @@ -149,7 +149,13 @@ public Task EnsureMutationAuthorizedAsync( && job.LeaseGeneration == leaseToken.Generation && job.LeaseExpiresAt != null && job.LeaseExpiresAt > nowUtc) - .Select(job => new { job.SourcePath, job.RequestedPath }) + .Select(job => new + { + job.SourcePath, + job.RequestedPath, + job.TargetIdentityBoundary, + job.RelocationId + }) .SingleOrDefaultAsync(cancellationToken); if (state == null) { @@ -175,6 +181,25 @@ public Task EnsureMutationAuthorizedAsync( targetSemantics, "Persisted move identity changed before a filesystem mutation.", "Persisted move identity became invalid before a filesystem mutation."); + if (string.IsNullOrWhiteSpace(state.TargetIdentityBoundary)) + { + throw new MoveNeedsAttentionException( + "The move target has no durable authorization boundary."); + } + await EnsureTargetBoundaryGenerationAuthorizedAsync( + db, + jobId, + state.TargetIdentityBoundary, + cancellationToken); + if (state.RelocationId.HasValue) + { + await EnsureRelocationTargetGenerationAuthorizedAsync( + db, + state.RelocationId.Value, + target, + targetSemantics, + cancellationToken); + } }, cancellationToken); @@ -186,11 +211,15 @@ public Task> LoadManifestAsync( async () => { await using var db = await dbContextFactory.CreateDbContextAsync(cancellationToken); - return await db.MoveJobEntries + var entries = await db.MoveJobEntries .AsNoTracking() .Where(entry => entry.MoveJobId == jobId) .OrderBy(entry => entry.Id) .ToListAsync(cancellationToken); + return entries + .Where(entry => + !MoveManifestIdentity.IsTargetBoundaryAuthorization(entry)) + .ToList(); }, cancellationToken); @@ -325,7 +354,8 @@ public Task UpdateCopyStateAsync( if (!db.Database.IsRelational()) { var entries = await db.MoveJobEntries - .Where(entry => entry.MoveJobId == jobId) + .Where(entry => entry.MoveJobId == jobId + && entry.RelativePath != string.Empty) .ToListAsync(cancellationToken); foreach (var entry in entries) { @@ -337,6 +367,7 @@ public Task UpdateCopyStateAsync( var affected = await db.MoveJobEntries .Where(entry => entry.MoveJobId == jobId + && entry.RelativePath != string.Empty && entry.MoveJob.Status == MoveJobStatus.Running && entry.MoveJob.LeaseOwner == leaseToken.Owner && entry.MoveJob.LeaseGeneration == leaseToken.Generation @@ -348,7 +379,8 @@ public Task UpdateCopyStateAsync( MoveJobEntryCopyState.Verified), cancellationToken); var expected = await db.MoveJobEntries.CountAsync( - entry => entry.MoveJobId == jobId, + entry => entry.MoveJobId == jobId + && entry.RelativePath != string.Empty, cancellationToken); if (affected != expected) { diff --git a/listenarr.infrastructure/Library/Moving/LibraryDirectoryOwnershipBoundaryAuthorizer.cs b/listenarr.infrastructure/Library/Moving/LibraryDirectoryOwnershipBoundaryAuthorizer.cs index 4a257c0a2..8f887f966 100644 --- a/listenarr.infrastructure/Library/Moving/LibraryDirectoryOwnershipBoundaryAuthorizer.cs +++ b/listenarr.infrastructure/Library/Moving/LibraryDirectoryOwnershipBoundaryAuthorizer.cs @@ -81,6 +81,109 @@ internal async Task AuthorizeContainingRoot cancellationToken); } + internal async Task + TryAuthorizeContainingRootAsync( + string path, + FileSystemPathSemantics semantics, + CancellationToken cancellationToken) + { + var canonicalPath = CanonicalizeHostAuthorizedPath(path, semantics); + var parentPath = Path.GetDirectoryName(canonicalPath) + ?? throw new InvalidOperationException( + "The retained directory has no parent."); + await using var db = await dbContextFactory.CreateDbContextAsync(cancellationToken); + var roots = await db.RootFolders.AsNoTracking().ToListAsync(cancellationToken); + var root = roots + .Where(candidate => HasCompatibleSyntax(candidate.Path, semantics.Syntax)) + .Where(candidate => FileSystemPathIdentity.IsSameOrInside( + canonicalPath, + candidate.Path, + semantics)) + .Where(candidate => FileSystemPathIdentity.IsSameOrInside( + parentPath, + candidate.Path, + semantics)) + .OrderByDescending(candidate => candidate.Path.Length) + .FirstOrDefault(); + if (root != null) + { + return await TryAuthorizePathWithinBoundaryAsync( + canonicalPath, + semantics, + root.Id, + root.Path, + root.DirectoryObjectIdentityVersion, + root.DirectoryObjectIdentity, + root.DirectoryObjectIdentityUnavailableReason, + cancellationToken); + } + + var activeRelocations = await db.RootFolderRelocations + .AsNoTracking() + .Where(relocation => relocation.ActiveRootFolderId != null) + .ToListAsync(cancellationToken); + var relocation = activeRelocations + .Where(candidate => HasCompatibleSyntax( + candidate.TargetPath, + semantics.Syntax)) + .Where(candidate => FileSystemPathIdentity.IsSameOrInside( + canonicalPath, + candidate.TargetPath, + semantics)) + .Where(candidate => FileSystemPathIdentity.IsSameOrInside( + parentPath, + candidate.TargetPath, + semantics)) + .OrderByDescending(candidate => candidate.TargetPath.Length) + .FirstOrDefault(); + if (relocation == null) + { + return null; + } + + return await TryAuthorizePathWithinBoundaryAsync( + canonicalPath, + semantics, + relocation.ActiveRootFolderId!.Value, + relocation.TargetPath, + relocation.TargetDirectoryObjectIdentityVersion, + relocation.TargetDirectoryObjectIdentity, + relocation.TargetDirectoryObjectIdentityUnavailableReason, + cancellationToken); + } + + private async Task + TryAuthorizePathWithinBoundaryAsync( + string canonicalPath, + FileSystemPathSemantics semantics, + int rootFolderId, + string boundaryPath, + int? expectedDirectoryIdentityVersion, + string? expectedDirectoryIdentity, + string? identityUnavailableReason, + CancellationToken cancellationToken) + { + try + { + return await AuthorizePathWithinBoundaryAsync( + canonicalPath, + semantics, + rootFolderId, + boundaryPath, + expectedDirectoryIdentityVersion, + expectedDirectoryIdentity, + identityUnavailableReason, + cancellationToken); + } + catch (Exception exception) when (exception is + InvalidOperationException or IOException or UnauthorizedAccessException + or ArgumentException or NotSupportedException or PathTooLongException + or System.ComponentModel.Win32Exception) + { + return null; + } + } + internal async Task AuthorizeAsync( string boundaryPath, FileSystemPathSemantics semantics, diff --git a/listenarr.infrastructure/Library/Moving/LibraryDirectoryOwnershipMarker.cs b/listenarr.infrastructure/Library/Moving/LibraryDirectoryOwnershipMarker.cs index 7eb4b52f9..e9d8928c0 100644 --- a/listenarr.infrastructure/Library/Moving/LibraryDirectoryOwnershipMarker.cs +++ b/listenarr.infrastructure/Library/Moving/LibraryDirectoryOwnershipMarker.cs @@ -1,5 +1,4 @@ using System.Text.Json; -using Listenarr.Domain.Common; namespace Listenarr.Infrastructure.Library.Moving; @@ -15,20 +14,29 @@ public static void Validate( string directory) { ArgumentNullException.ThrowIfNull(ownership); - ValidateDirectory(directory); - ValidateMarkerFile(ownership, GetInsidePath(directory)); - ValidateMarkerFile(ownership, GetSiblingPath(ownership)); + var fullDirectory = Path.GetFullPath(directory); + var parentPath = Path.GetDirectoryName(fullDirectory) + ?? throw new InvalidOperationException( + "The durable directory ownership path has no parent."); + using var parent = PinnedDirectoryCreation.OpenPinnedBoundary(parentPath); + using var pinnedDirectory = parent.OpenExistingChild( + Path.GetFileName(fullDirectory)); + Validate(ownership, pinnedDirectory, parent); } public static bool ContainsOnlyInsideMarker( LibraryDirectoryOwnership ownership, string directory) { - Validate(ownership, directory); - var markerPath = GetInsidePath(directory); - var entries = Directory.EnumerateFileSystemEntries(directory).Take(2).ToList(); - return entries.Count == 1 - && string.Equals(entries[0], markerPath, StringComparison.Ordinal); + ArgumentNullException.ThrowIfNull(ownership); + var fullDirectory = Path.GetFullPath(directory); + var parentPath = Path.GetDirectoryName(fullDirectory) + ?? throw new InvalidOperationException( + "The durable directory ownership path has no parent."); + using var parent = PinnedDirectoryCreation.OpenPinnedBoundary(parentPath); + using var pinnedDirectory = parent.OpenExistingChild( + Path.GetFileName(fullDirectory)); + return ContainsOnlyInsideMarker(ownership, pinnedDirectory, parent); } public static bool ContainsOnlyInsideMarker( @@ -55,8 +63,15 @@ public static void DeleteInsideMarker( LibraryDirectoryOwnership ownership, string directory) { - Validate(ownership, directory); - DeleteValidatedMarker(ownership, GetInsidePath(directory)); + ArgumentNullException.ThrowIfNull(ownership); + var fullDirectory = Path.GetFullPath(directory); + var parentPath = Path.GetDirectoryName(fullDirectory) + ?? throw new InvalidOperationException( + "The durable directory ownership path has no parent."); + using var parent = PinnedDirectoryCreation.OpenPinnedBoundary(parentPath); + using var pinnedDirectory = parent.OpenExistingChild( + Path.GetFileName(fullDirectory)); + DeleteInsideMarker(ownership, pinnedDirectory, parent); } public static void DeleteInsideMarker( @@ -155,9 +170,7 @@ private static void ValidatePinnedCore( public static void DeleteSiblingMarker(LibraryDirectoryOwnership ownership) { - var markerPath = GetSiblingPath(ownership); - ValidateMarkerFile(ownership, markerPath); - DeleteValidatedMarker(ownership, markerPath); + DeleteValidatedMarker(ownership, GetSiblingPath(ownership)); } public static bool TryDeleteRetiredSiblingMarker( @@ -165,22 +178,16 @@ public static bool TryDeleteRetiredSiblingMarker( out string? reason) { var markerPath = GetSiblingPath(ownership); - if (!File.Exists(markerPath)) - { - reason = null; - return true; - } - try { - ValidateMarkerFile(ownership, markerPath); DeleteValidatedMarker(ownership, markerPath); reason = null; return true; } catch (Exception exception) when (exception is ArgumentException or IOException or UnauthorizedAccessException - or InvalidOperationException or NotSupportedException) + or InvalidOperationException or NotSupportedException + or System.ComponentModel.Win32Exception) { if (!File.Exists(markerPath)) { @@ -201,29 +208,23 @@ public static bool HasValidSiblingMarker(LibraryDirectoryOwnership ownership) { try { - ValidateMarkerFile(ownership, GetSiblingPath(ownership)); + var siblingPath = GetSiblingPath(ownership); + var parentPath = Path.GetDirectoryName(siblingPath) + ?? throw new InvalidOperationException( + "The durable directory ownership sibling marker has no parent."); + using var parent = PinnedDirectoryCreation.OpenPinnedBoundary(parentPath); + ValidateSiblingMarker(ownership, parent); return true; } - catch (InvalidOperationException) + catch (Exception exception) when (exception is + ArgumentException or IOException or UnauthorizedAccessException + or InvalidOperationException or NotSupportedException + or System.ComponentModel.Win32Exception) { return false; } } - private static void ValidateMarkerFile( - LibraryDirectoryOwnership ownership, - string markerPath) => - ValidateMarkerFile( - new MarkerPayload( - Version, - ownership.OwnershipToken, - ownership.CanonicalPath, - ownership.ManagedRootFolderId, - ownership.DirectoryObjectIdentityVersion, - ownership.DirectoryObjectIdentity), - markerPath, - ownership.GetIdentity().Semantics); - private static void DeleteValidatedMarker( LibraryDirectoryOwnership ownership, string markerPath) @@ -306,71 +307,6 @@ internal static void ValidateMarkerFile( } } - private static void ValidateMarkerFile( - MarkerPayload expected, - string markerPath, - FileSystemPathSemantics? semantics = null) - { - if (!File.Exists(markerPath)) - { - throw new InvalidOperationException( - "The durable directory ownership marker is missing."); - } - - var markerInfo = new FileInfo(markerPath); - if ((markerInfo.Attributes & FileAttributes.ReparsePoint) != 0) - { - throw new InvalidOperationException( - "The durable directory ownership marker is a symbolic link or reparse point."); - } - if (markerInfo.Length <= 0 || markerInfo.Length > MaximumBytes) - { - throw new InvalidOperationException( - "The durable directory ownership marker has an invalid size."); - } - - MarkerPayload? marker; - try - { - marker = JsonSerializer.Deserialize( - File.ReadAllText(markerPath), - JsonOptions); - } - catch (JsonException exception) - { - throw new InvalidOperationException( - "The durable directory ownership marker is invalid.", - exception); - } - - var pathsMatch = semantics.HasValue - ? marker != null - && MarkerPathMatches( - marker.CanonicalPath, - expected.CanonicalPath, - semantics.Value) - : marker != null - && string.Equals( - marker.CanonicalPath, - expected.CanonicalPath, - StringComparison.Ordinal); - if (marker == null - || marker.Version != Version - || !string.Equals(marker.OwnershipToken, expected.OwnershipToken, StringComparison.Ordinal) - || marker.ManagedRootFolderId != expected.ManagedRootFolderId - || marker.DirectoryObjectIdentityVersion - != expected.DirectoryObjectIdentityVersion - || !string.Equals( - marker.DirectoryObjectIdentity, - expected.DirectoryObjectIdentity, - StringComparison.Ordinal) - || !pathsMatch) - { - throw new InvalidOperationException( - "The durable directory ownership marker does not match the persisted ownership claim."); - } - } - private static string GetInsidePath(string directory) => Path.Join(directory, FileName); internal static void ValidateOwnershipToken(string ownershipToken) @@ -393,22 +329,6 @@ private static string GetSiblingPath(LibraryDirectoryOwnership ownership) $".listenarr-directory-owner-{ownership.OwnershipToken}.json"); } - private static void ValidateDirectory(string directory) - { - if (!Directory.Exists(directory)) - { - throw new InvalidOperationException( - "The durable directory ownership path does not exist."); - } - - var attributes = File.GetAttributes(directory); - if ((attributes & FileAttributes.ReparsePoint) != 0) - { - throw new InvalidOperationException( - "The durable directory ownership path is a symbolic link or reparse point."); - } - } - internal static string SerializePayload(LibraryDirectoryOwnership ownership) => SerializePayload( new MarkerPayload( diff --git a/listenarr.infrastructure/Library/Moving/MoveFilesystemArtifactNames.cs b/listenarr.infrastructure/Library/Moving/MoveFilesystemArtifactNames.cs new file mode 100644 index 000000000..f6c3f6af9 --- /dev/null +++ b/listenarr.infrastructure/Library/Moving/MoveFilesystemArtifactNames.cs @@ -0,0 +1,16 @@ +namespace Listenarr.Infrastructure.Library.Moving; + +internal static class MoveFilesystemArtifactNames +{ + public static bool IsReserved(string name) => + name.StartsWith(".listenarr-move-", StringComparison.Ordinal) + || name.StartsWith(".listenarr-quarantine-", StringComparison.Ordinal) + || name.StartsWith(".listenarr-temporary-directory-", StringComparison.Ordinal) + || string.Equals(name, ".listenarr-temp-owner.json", StringComparison.Ordinal) + || string.Equals(name, ".listenarr-quarantine-owner.json", StringComparison.Ordinal) + || string.Equals(name, LibraryDirectoryOwnershipMarker.FileName, StringComparison.Ordinal) + || name.StartsWith(".listenarr-directory-owner-", StringComparison.Ordinal) + && name.EndsWith(".json", StringComparison.Ordinal) + || name.Contains(".listenarr-", StringComparison.Ordinal) + && name.EndsWith(".partial", StringComparison.Ordinal); +} diff --git a/listenarr.infrastructure/Library/Moving/MoveJobProcessor.Completion.cs b/listenarr.infrastructure/Library/Moving/MoveJobProcessor.Completion.cs index 5176b655d..45194fe04 100644 --- a/listenarr.infrastructure/Library/Moving/MoveJobProcessor.Completion.cs +++ b/listenarr.infrastructure/Library/Moving/MoveJobProcessor.Completion.cs @@ -59,6 +59,14 @@ await moveQueueService.NotifyPersistedJobStateAsync( MoveJobStatus.Completed, cancellationToken: cancellationToken); } + catch (OperationCanceledException exception) when ( + !cancellationToken.IsCancellationRequested) + { + logger.LogWarning( + exception, + "Move job {JobId} completed durably but its current state publication was canceled internally", + context.JobId); + } catch (Exception exception) when (WorkerExceptionClassifier.IsNonFatal(exception)) { logger.LogWarning( @@ -87,6 +95,14 @@ await historyRepository.MarkNotificationSentAsync( context.MoveHistoryId, cancellationToken); } + catch (OperationCanceledException exception) when ( + !cancellationToken.IsCancellationRequested) + { + logger.LogWarning( + exception, + "Move completion was committed but its notification flag update was canceled internally for job {JobId}", + context.JobId); + } catch (Exception exception) when (WorkerExceptionClassifier.IsNonFatal(exception)) { logger.LogWarning( @@ -157,6 +173,14 @@ await notificationService.SendNotificationAsync( return true; } + catch (OperationCanceledException exception) + { + logger.LogWarning( + exception, + "Move notification was canceled internally for {JobId}", + context.JobId); + return false; + } catch (Exception exception) when (WorkerExceptionClassifier.IsNonFatal(exception)) { logger.LogWarning( @@ -181,6 +205,13 @@ await toastService.PublishToastAsync( timeoutMs: 5000); logger.LogDebug("Sent toast notification for move job {JobId}", context.JobId); } + catch (OperationCanceledException exception) + { + logger.LogDebug( + exception, + "Toast notification was canceled internally for move job {JobId}", + context.JobId); + } catch (Exception exception) when (WorkerExceptionClassifier.IsNonFatal(exception)) { logger.LogDebug( @@ -209,6 +240,14 @@ await audiobookUpdatePublisher.PublishCurrentAsync( context.AudiobookId, context.JobId); } + catch (OperationCanceledException exception) when ( + !cancellationToken.IsCancellationRequested) + { + logger.LogWarning( + exception, + "AudiobookUpdate broadcast was canceled internally after move job {JobId}", + context.JobId); + } catch (Exception exception) when (WorkerExceptionClassifier.IsNonFatal(exception)) { logger.LogWarning( diff --git a/listenarr.infrastructure/Library/Moving/MoveJobProcessor.Core.cs b/listenarr.infrastructure/Library/Moving/MoveJobProcessor.Core.cs index b8401415d..6d047bf9c 100644 --- a/listenarr.infrastructure/Library/Moving/MoveJobProcessor.Core.cs +++ b/listenarr.infrastructure/Library/Moving/MoveJobProcessor.Core.cs @@ -364,6 +364,13 @@ private async Task ExecuteFilesystemMoveAsync( moveResult = await contentMoveService.ResumeSourceCleanupAsync(moveRequest, moveResult, stoppingToken); source = moveResult.Source; target = moveResult.Target; + if (AfterSourceCleanupBeforeMetadataRewriteForTest != null) + { + await AfterSourceCleanupBeforeMetadataRewriteForTest(job); + } + await contentMoveService.EnsureMutationAuthorizedAsync( + moveRequest, + stoppingToken); using (var rewriteScope = scopeFactory.CreateScope()) { diff --git a/listenarr.infrastructure/Library/Moving/MoveJobProcessor.SourcePhysicalIdentity.cs b/listenarr.infrastructure/Library/Moving/MoveJobProcessor.SourcePhysicalIdentity.cs index d70403ec9..74c2e8fd3 100644 --- a/listenarr.infrastructure/Library/Moving/MoveJobProcessor.SourcePhysicalIdentity.cs +++ b/listenarr.infrastructure/Library/Moving/MoveJobProcessor.SourcePhysicalIdentity.cs @@ -11,12 +11,12 @@ private static IReadOnlyDictionary string source, FileSystemPathSemantics sourceSemantics) { + var trackedRelativePaths = new HashSet(sourceSemantics.Comparer); var identities = new Dictionary(sourceSemantics.Comparer); foreach (var file in audiobook.Files ?? []) { if (file.PathIdentityState != PathIdentityState.Valid || string.IsNullOrWhiteSpace(file.CanonicalPath) - || string.IsNullOrWhiteSpace(file.PhysicalObjectIdentity) || !FileSystemPathIdentity.TryGetRelativePathWithinBase( source, file.CanonicalPath, @@ -28,12 +28,17 @@ private static IReadOnlyDictionary continue; } - identities[relativePath] = file.PhysicalObjectIdentity; + trackedRelativePaths.Add(relativePath); + if (!string.IsNullOrWhiteSpace(file.PhysicalObjectIdentity)) + { + identities[relativePath] = file.PhysicalObjectIdentity; + } } foreach (var entry in job.Entries.Where(candidate => candidate.EntryType == MoveJobEntryType.File - && candidate.CleanupState != MoveJobEntryCleanupState.Deleted)) + && candidate.CleanupState != MoveJobEntryCleanupState.Deleted + && trackedRelativePaths.Contains(candidate.RelativePath))) { if (!identities.ContainsKey(entry.RelativePath)) { diff --git a/listenarr.infrastructure/Library/Moving/MoveJobProcessor.cs b/listenarr.infrastructure/Library/Moving/MoveJobProcessor.cs index 3c67d1a85..1a95f724b 100644 --- a/listenarr.infrastructure/Library/Moving/MoveJobProcessor.cs +++ b/listenarr.infrastructure/Library/Moving/MoveJobProcessor.cs @@ -36,6 +36,8 @@ internal partial class MoveJobProcessor( IAudiobookOperationCoordinator audiobookOperationCoordinator, IAudiobookUpdatePublisher? audiobookUpdatePublisher = null) : IMoveJobProcessor, IMoveJobProcessorPhases { + internal Func? AfterSourceCleanupBeforeMetadataRewriteForTest { get; set; } + public async Task ProcessJobAsync(MoveJob job, CancellationToken stoppingToken) { var postCommit = await ProcessDurableJobAsync(job, stoppingToken); @@ -75,11 +77,21 @@ await filesystemMutationCoordinator.ExecuteExclusiveAsync( if (postCommit == null) { - await moveQueueService.NotifyPersistedJobStateAsync( - job.Id, - job.Status, - job.Error, - stoppingToken); + try + { + await moveQueueService.NotifyPersistedJobStateAsync( + job.Id, + job.Status, + job.Error, + stoppingToken); + } + catch (OperationCanceledException exception) + { + logger.LogDebug( + exception, + "Move job {JobId} state was already persisted before terminal notification cancellation", + job.Id); + } } return postCommit; diff --git a/listenarr.infrastructure/Library/Moving/MoveSourceCompanionManifestBuilder.cs b/listenarr.infrastructure/Library/Moving/MoveSourceCompanionManifestBuilder.cs new file mode 100644 index 000000000..25cb62176 --- /dev/null +++ b/listenarr.infrastructure/Library/Moving/MoveSourceCompanionManifestBuilder.cs @@ -0,0 +1,405 @@ +using System.Security.Cryptography; +using Listenarr.Application.Common.Exceptions; +using Listenarr.Domain.Common; + +namespace Listenarr.Infrastructure.Library.Moving; + +internal static class MoveSourceCompanionManifestBuilder +{ + public static async Task> BuildAsync( + Audiobook audiobook, + string sourceRoot, + PathIdentitySnapshot sourceIdentity, + IReadOnlyCollection trackedFilePaths, + LibraryDirectoryOwnershipBoundaryAuthorizer? ownershipAuthorizer, + IAudiobookRepository? audiobookRepository, + IAudiobookFileRepository fileRepository, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(audiobook); + if (ownershipAuthorizer == null + || audiobookRepository == null + || string.IsNullOrWhiteSpace(audiobook.BasePath) + || !FileSystemPathIdentity.TryCanonicalizeStoredPathWithIdentityForHost( + audiobook.BasePath, + sourceIdentity, + out var canonicalBasePath, + out _) + || !FileSystemPathIdentity.AreEquivalent( + canonicalBasePath, + sourceRoot, + sourceIdentity.Semantics)) + { + return []; + } + + if (!await HasExclusiveAudiobookReferenceAsync( + audiobook, + sourceRoot, + sourceIdentity.Semantics, + audiobookRepository, + fileRepository, + cancellationToken)) + { + return []; + } + + var tracked = trackedFilePaths + .Select(path => FileSystemPathIdentity.Canonicalize( + path, + sourceIdentity.Syntax)) + .ToHashSet(sourceIdentity.Semantics.Comparer); + + try + { + using var authorization = await ownershipAuthorizer.TryAuthorizeContainingRootAsync( + sourceRoot, + sourceIdentity.Semantics, + cancellationToken); + if (authorization == null) + { + return []; + } + + var sourceParent = Path.GetDirectoryName(sourceRoot) + ?? throw Conflict("The audiobook companion source root has no parent directory."); + if (!FileSystemPathIdentity.AreEquivalent( + authorization.ParentAnchor.FullPath, + sourceParent, + sourceIdentity.Semantics) + || !authorization.ParentAnchor.VisiblePathMatches()) + { + throw Conflict( + "The audiobook companion source root could not be pinned beneath its managed library root."); + } + + using var source = authorization.ParentAnchor.OpenExistingChild( + Path.GetFileName(sourceRoot)); + if (!source.VisiblePathMatches(sourceRoot)) + { + throw Conflict( + "The audiobook companion source directory changed while its move manifest was being created."); + } + + var entries = new List(); + await CaptureDirectoryAsync( + source, + source, + tracked, + sourceIdentity.Semantics, + entries, + cancellationToken); + if (!source.VisiblePathMatches(sourceRoot)) + { + throw Conflict( + "The audiobook companion source directory changed after its move manifest was created."); + } + + return entries; + } + catch (ApplicationConflictException) + { + throw; + } + catch (Exception exception) when (exception is + IOException or UnauthorizedAccessException + or ArgumentException or InvalidOperationException + or NotSupportedException or PathTooLongException + or System.ComponentModel.Win32Exception) + { + throw Conflict( + $"Audiobook companion files could not be safely included in the move manifest: {exception.Message}"); + } + } + + private static async Task HasExclusiveAudiobookReferenceAsync( + Audiobook audiobook, + string sourceRoot, + FileSystemPathSemantics semantics, + IAudiobookRepository audiobookRepository, + IAudiobookFileRepository fileRepository, + CancellationToken cancellationToken) + { + var otherAudiobooks = (await audiobookRepository.GetAllAsync()) + .Where(candidate => candidate.Id != audiobook.Id) + .ToDictionary(candidate => candidate.Id); + foreach (var other in otherAudiobooks.Values) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!string.IsNullOrWhiteSpace(other.BasePath)) + { + if (!FileSystemPathIdentity.TryCanonicalizeUnambiguousStoredAbsolutePathForHost( + other.BasePath, + out var otherBasePath, + out _)) + { + return false; + } + + if (FileSystemPathIdentity.IsSameOrInside( + otherBasePath, + sourceRoot, + semantics) + || FileSystemPathIdentity.IsSameOrInside( + sourceRoot, + otherBasePath, + semantics)) + { + return false; + } + } + + if (!string.IsNullOrWhiteSpace(other.FilePath)) + { + if (!TryResolveOtherStoredFilePath( + other, + other.FilePath, + semantics, + out var legacyPath)) + { + return false; + } + + if (FileSystemPathIdentity.IsSameOrInside( + legacyPath, + sourceRoot, + semantics)) + { + return false; + } + } + } + + foreach (var file in (await fileRepository.GetAllAsync()).Where(file => + file.AudiobookId != audiobook.Id + && !string.IsNullOrWhiteSpace(file.Path))) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!otherAudiobooks.TryGetValue(file.AudiobookId, out var owner) + || !TryResolveOtherStoredFilePath( + owner, + file.Path!, + semantics, + out var otherFilePath)) + { + return false; + } + + if (FileSystemPathIdentity.IsSameOrInside( + otherFilePath, + sourceRoot, + semantics)) + { + return false; + } + } + + return true; + } + + private static bool TryResolveOtherStoredFilePath( + Audiobook audiobook, + string storedPath, + FileSystemPathSemantics semantics, + out string resolvedPath) + { + resolvedPath = string.Empty; + if (FileSystemPathIdentity.TryDetectAbsoluteSyntax(storedPath, out _)) + { + if (!FileSystemPathIdentity.TryCanonicalizeUnambiguousStoredAbsolutePathForHost( + storedPath, + out resolvedPath, + out _)) + { + return false; + } + + return true; + } + + return !string.IsNullOrWhiteSpace(audiobook.BasePath) + && FileSystemPathIdentity.TryCanonicalizeUnambiguousStoredAbsolutePathForHost( + audiobook.BasePath, + out var basePath, + out _) + && FileSystemPathIdentity.TryResolveRelativePathWithinBase( + basePath, + storedPath, + semantics, + out resolvedPath); + } + + private static async Task CaptureDirectoryAsync( + PinnedDirectoryCreation.PinnedDirectoryAnchor root, + PinnedDirectoryCreation.PinnedDirectoryAnchor current, + IReadOnlySet trackedFilePaths, + FileSystemPathSemantics semantics, + ICollection entries, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + EnsureVisible(root, current); + var beforeNames = EnumerateEntryNames(current, semantics); + var containsCompanion = false; + + foreach (var entryName in beforeNames) + { + cancellationToken.ThrowIfCancellationRequested(); + if (MoveFilesystemArtifactNames.IsReserved(entryName)) + { + continue; + } + + var entryPath = Path.Join(current.FullPath, entryName); + var attributes = File.GetAttributes(entryPath); + if ((attributes & FileAttributes.ReparsePoint) != 0) + { + throw Conflict( + $"A linked or reparse-point entry exists in the audiobook companion tree: {entryName}"); + } + + if ((attributes & FileAttributes.Directory) != 0) + { + using var child = current.OpenExistingChild(entryName); + var childContainsCompanion = await CaptureDirectoryAsync( + root, + child, + trackedFilePaths, + semantics, + entries, + cancellationToken); + if (childContainsCompanion) + { + entries.Add(new MoveSourceManifestEntry( + GetRelativePath(root, child.FullPath, semantics), + MoveJobEntryType.Directory, + 0, + Directory.GetLastWriteTimeUtc(child.FullPath), + null)); + containsCompanion = true; + } + + continue; + } + + var canonicalFilePath = FileSystemPathIdentity.Canonicalize( + entryPath, + semantics.Syntax); + if (trackedFilePaths.Contains(canonicalFilePath) + || FileUtils.IsAudioFile(canonicalFilePath)) + { + continue; + } + + entries.Add(await CaptureFileAsync( + root, + current, + entryName, + semantics, + cancellationToken)); + containsCompanion = true; + } + + EnsureVisible(root, current); + var afterNames = EnumerateEntryNames(current, semantics); + if (!beforeNames.SequenceEqual(afterNames, semantics.Comparer)) + { + throw Conflict( + "The audiobook companion directory changed while its move manifest was being created."); + } + + return containsCompanion; + } + + private static async Task CaptureFileAsync( + PinnedDirectoryCreation.PinnedDirectoryAnchor root, + PinnedDirectoryCreation.PinnedDirectoryAnchor parent, + string fileName, + FileSystemPathSemantics semantics, + CancellationToken cancellationToken) + { + using var file = parent.OpenExistingFileForStableRead(fileName); + var physicalObjectIdentity = file.GetObjectIdentity(); + await using var stream = file.OpenReadStream( + bufferSize: 128 * 1024, + asynchronous: false); + var length = stream.Length; + var lastWriteTimeUtc = File.GetLastWriteTimeUtc(file.FullPath); + var hashBytes = await SHA256.HashDataAsync(stream, cancellationToken); + var hash = Convert.ToHexString(hashBytes); + if (!root.VisiblePathMatches() + || !parent.VisiblePathMatches() + || !file.VisiblePathMatches() + || !string.Equals( + file.GetObjectIdentity(), + physicalObjectIdentity, + StringComparison.Ordinal) + || stream.Length != length + || File.GetLastWriteTimeUtc(file.FullPath) != lastWriteTimeUtc) + { + throw Conflict( + $"Audiobook companion file changed while its move manifest was being created: {fileName}"); + } + + return new MoveSourceManifestEntry( + GetRelativePath(root, file.FullPath, semantics), + MoveJobEntryType.File, + length, + lastWriteTimeUtc, + hash); + } + + private static string[] EnumerateEntryNames( + PinnedDirectoryCreation.PinnedDirectoryAnchor directory, + FileSystemPathSemantics semantics) + { + var names = Directory.EnumerateFileSystemEntries(directory.FullPath) + .Select(Path.GetFileName) + .Where(name => !string.IsNullOrWhiteSpace(name)) + .Cast() + .OrderBy(name => name, semantics.Comparer) + .ToArray(); + if (!directory.VisiblePathMatches()) + { + throw Conflict( + "The audiobook companion directory changed during enumeration."); + } + + return names; + } + + private static string GetRelativePath( + PinnedDirectoryCreation.PinnedDirectoryAnchor root, + string path, + FileSystemPathSemantics semantics) + { + if (!FileSystemPathIdentity.TryGetRelativePathWithinBase( + root.FullPath, + path, + semantics, + out var relativePath) + || string.IsNullOrWhiteSpace(relativePath) + || Path.IsPathRooted(relativePath)) + { + throw Conflict( + "An audiobook companion manifest entry escaped the tracked source root."); + } + + return relativePath; + } + + private static void EnsureVisible( + PinnedDirectoryCreation.PinnedDirectoryAnchor root, + PinnedDirectoryCreation.PinnedDirectoryAnchor current) + { + if (!root.VisiblePathMatches() + || !current.VisiblePathMatches()) + { + throw Conflict( + "The audiobook companion directory generation changed during manifest capture."); + } + } + + private static ApplicationConflictException Conflict(string message) => + new("move_source_unverified", message); +} diff --git a/listenarr.infrastructure/Library/Moving/MoveSourceManifestService.cs b/listenarr.infrastructure/Library/Moving/MoveSourceManifestService.cs index 1adfbd202..907ffcef8 100644 --- a/listenarr.infrastructure/Library/Moving/MoveSourceManifestService.cs +++ b/listenarr.infrastructure/Library/Moving/MoveSourceManifestService.cs @@ -5,7 +5,9 @@ namespace Listenarr.Infrastructure.Library.Moving; internal sealed class MoveSourceManifestService( - IAudiobookFileRepository fileRepository) : IMoveSourceManifestService + IAudiobookFileRepository fileRepository, + LibraryDirectoryOwnershipBoundaryAuthorizer? ownershipAuthorizer = null, + IAudiobookRepository? audiobookRepository = null) : IMoveSourceManifestService { public async Task BuildAsync( Audiobook audiobook, @@ -69,6 +71,19 @@ public async Task BuildAsync( sourceRoot, validated, identitySnapshot.Semantics); + var companionEntries = await MoveSourceCompanionManifestBuilder.BuildAsync( + audiobook, + sourceRoot, + identitySnapshot, + validated.Select(file => file.Path).ToList(), + ownershipAuthorizer, + audiobookRepository, + fileRepository, + cancellationToken); + entries = MergeEntries( + entries, + companionEntries, + identitySnapshot.Semantics); var sourceIdentity = new PathIdentitySnapshot( identitySnapshot.Syntax, identitySnapshot.CaseSensitivity, @@ -387,6 +402,35 @@ private static IReadOnlyList BuildEntries( .ToList(); } + private static IReadOnlyList MergeEntries( + IReadOnlyList trackedEntries, + IReadOnlyList companionEntries, + FileSystemPathSemantics semantics) + { + var merged = new Dictionary( + semantics.Comparer); + foreach (var entry in trackedEntries.Concat(companionEntries)) + { + if (merged.TryGetValue(entry.RelativePath, out var existing)) + { + if (existing.EntryType != entry.EntryType) + { + throw Conflict( + $"Move manifest path changed type while companion files were being captured: {entry.RelativePath}"); + } + + continue; + } + + merged.Add(entry.RelativePath, entry); + } + + return merged.Values + .OrderBy(entry => entry.EntryType == MoveJobEntryType.Directory ? 0 : 1) + .ThenBy(entry => entry.RelativePath, semantics.Comparer) + .ToList(); + } + private static bool HasSameFilesystemAuthority( PathIdentitySnapshot left, PathIdentitySnapshot right) => diff --git a/listenarr.infrastructure/Library/Moving/PinnedLibraryDirectoryOwnershipMarker.Migration.cs b/listenarr.infrastructure/Library/Moving/PinnedLibraryDirectoryOwnershipMarker.Migration.cs index 5edbda546..944a560c7 100644 --- a/listenarr.infrastructure/Library/Moving/PinnedLibraryDirectoryOwnershipMarker.Migration.cs +++ b/listenarr.infrastructure/Library/Moving/PinnedLibraryDirectoryOwnershipMarker.Migration.cs @@ -25,6 +25,16 @@ public static async Task PublishMigrationTargetAsync( } var targetNativeIdentity = directory.GetDirectoryObjectIdentity(); + if (!ManagedDirectoryIdentity.Matches( + source.DirectoryObjectIdentityVersion, + source.DirectoryObjectIdentity, + source.OwnershipToken, + targetNativeIdentity)) + { + throw new InvalidOperationException( + "Metadata-only relocation cannot transfer destructive directory ownership to a different physical directory generation."); + } + target.DirectoryObjectIdentityVersion = ManagedDirectoryIdentity.CurrentVersion; target.DirectoryObjectIdentity = ManagedDirectoryIdentity.Create( diff --git a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.BoundaryConflicts.cs b/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.BoundaryConflicts.cs index f35334bbc..c4af598cc 100644 --- a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.BoundaryConflicts.cs +++ b/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.BoundaryConflicts.cs @@ -1,9 +1,46 @@ using Listenarr.Domain.Common; +using Listenarr.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; namespace Listenarr.Infrastructure.Library.Moving; public sealed partial class RootFolderRelocationService { + private static async Task EnsureNoUnresolvedMoveConflictsAsync( + ListenArrDbContext db, + IReadOnlySet affectedAudiobookIds, + string sourceRootPath, + FileSystemPathSemantics? sourceSemantics, + string targetPath, + FileSystemPathSemantics targetSemantics, + CancellationToken cancellationToken) + { + var moveJobCandidates = await db.MoveJobs + .AsNoTracking() + .AsSplitQuery() + .Include(job => job.Entries) + .Include(job => job.CreatedDirectories) + .Where(job => job.Status == MoveJobStatus.Queued + || job.Status == MoveJobStatus.Running + || job.Status == MoveJobStatus.RetryScheduled + || job.Status == MoveJobStatus.Failed + || job.Status == MoveJobStatus.NeedsAttention) + .ToListAsync(cancellationToken); + var conflictingMoveJob = moveJobCandidates.FirstOrDefault(job => + MoveRecoveryPolicy.BlocksFilesystemMutation(job) + && (affectedAudiobookIds.Contains(job.AudiobookId) + || (sourceSemantics.HasValue + && (PathTouchesBoundary(job.SourcePath, sourceRootPath, sourceSemantics.Value) + || PathTouchesBoundary(job.RequestedPath, sourceRootPath, sourceSemantics.Value))) + || PathTouchesBoundary(job.SourcePath, targetPath, targetSemantics) + || PathTouchesBoundary(job.RequestedPath, targetPath, targetSemantics))); + if (conflictingMoveJob != null) + { + throw new InvalidOperationException( + $"Unresolved move job {conflictingMoveJob.Id} overlaps this root folder relocation; resolve it before starting the relocation."); + } + } + private static bool RootBoundaryConflictsWithTarget( RootFolder candidate, string targetPath, diff --git a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.MetadataStart.cs b/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.MetadataStart.cs index c76678105..c122863a6 100644 --- a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.MetadataStart.cs +++ b/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.MetadataStart.cs @@ -141,6 +141,13 @@ private async Task StartMetadataOnlyAsync( AfterMetadataOnlyJournalCommitForTest?.Invoke(); try { + if (targetObjectIdentity.IsAvailable) + { + await RequireTargetDirectoryGenerationAsync( + targetPath, + targetObjectIdentity, + completionToken); + } await PublishOwnershipMigrationTargetsAsync( ownershipPlans, targetPath, @@ -153,6 +160,13 @@ await PublishOwnershipMigrationTargetsAsync( plan.Journal.UpdatedAt = DateTime.UtcNow; } await db.SaveChangesAsync(completionToken); + if (targetObjectIdentity.IsAvailable) + { + await RequireTargetDirectoryGenerationAsync( + targetPath, + targetObjectIdentity, + completionToken); + } } catch (Exception exception) when (exception is not ( OutOfMemoryException or StackOverflowException)) @@ -226,10 +240,14 @@ await PublishOwnershipMigrationTargetsAsync( ownershipPlans, targetPath, CancellationToken.None); - RetireOwnershipMigrationSources( + await RetireOwnershipMigrationSourcesAsync( ownershipPlans, sourcePath, - targetPath); + targetPath, + targetObjectIdentity.Version, + targetObjectIdentity.Value, + targetObjectIdentity.UnavailableReason, + CancellationToken.None); db.LibraryDirectoryOwnershipPathMigrations.RemoveRange( ownershipPlans.Select(plan => plan.Journal)); var completedWithoutAttention = skipped.Count == 0; diff --git a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.OwnershipMigrationRecovery.cs b/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.OwnershipMigrationRecovery.cs index 8d067b73d..c4c52995e 100644 --- a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.OwnershipMigrationRecovery.cs +++ b/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.OwnershipMigrationRecovery.cs @@ -50,6 +50,12 @@ private async Task> var plans = RehydrateOwnershipMigrationPlans(relocation); try { + await RequireTargetDirectoryGenerationAsync( + relocation.TargetPath, + relocation.TargetDirectoryObjectIdentityVersion, + relocation.TargetDirectoryObjectIdentity, + relocation.TargetDirectoryObjectIdentityUnavailableReason, + cancellationToken); var preparedPlans = plans .Where(plan => plan.Journal.State == LibraryDirectoryOwnershipPathMigrationState.Prepared) @@ -102,10 +108,14 @@ await PublishOwnershipMigrationTargetsAsync( relocation.TargetPath, CancellationToken.None, allowPublication: false); - RetireOwnershipMigrationSources( + await RetireOwnershipMigrationSourcesAsync( plans, relocation.SourcePath, - relocation.TargetPath); + relocation.TargetPath, + relocation.TargetDirectoryObjectIdentityVersion, + relocation.TargetDirectoryObjectIdentity, + relocation.TargetDirectoryObjectIdentityUnavailableReason, + CancellationToken.None); db.LibraryDirectoryOwnershipPathMigrations .RemoveRange(plans.Select(plan => plan.Journal)); FinalizeRecoveredMetadataOnlyRelocation( diff --git a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.OwnershipMigrationRetirement.cs b/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.OwnershipMigrationRetirement.cs index a27a09f79..7fc15d06e 100644 --- a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.OwnershipMigrationRetirement.cs +++ b/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.OwnershipMigrationRetirement.cs @@ -10,12 +10,28 @@ internal Action? BeforeOwnershipMigrationSourceRetirementForTest set; } - private void RetireOwnershipMigrationSources( + private async Task RetireOwnershipMigrationSourcesAsync( IReadOnlyList plans, string sourceBoundary, - string targetBoundary) + string targetBoundary, + int? targetIdentityVersion, + string? targetIdentityValue, + string? targetIdentityUnavailableReason, + CancellationToken cancellationToken) { BeforeOwnershipMigrationSourceRetirementForTest?.Invoke(); + if (plans.Count > 0) + { + using var targetBoundaryAnchor = await OpenVerifiedMarkerParentWithinBoundaryAsync( + targetBoundary, + targetBoundary, + plans[0].Target.GetIdentity().Semantics, + targetIdentityVersion, + targetIdentityValue, + targetIdentityUnavailableReason, + cancellationToken); + } + foreach (var plan in plans) { var sourceSiblingMarker = @@ -43,10 +59,14 @@ private void RetireOwnershipMigrationSources( sourceBoundary, sourceParentPath, plan.Source.GetIdentity().Semantics); - using var targetParent = OpenMarkerParentWithinBoundary( + using var targetParent = await OpenVerifiedMarkerParentWithinBoundaryAsync( targetBoundary, targetParentPath, - plan.Target.GetIdentity().Semantics); + plan.Target.GetIdentity().Semantics, + targetIdentityVersion, + targetIdentityValue, + targetIdentityUnavailableReason, + cancellationToken); using var targetMarker = targetParent.OpenExistingFileForStableRead( Path.GetFileName(targetSiblingMarker)); ValidateRetirementTarget(plan, targetParent, targetMarker); @@ -217,4 +237,80 @@ private static PinnedDirectoryCreation.PinnedDirectoryAnchor throw; } } + + private static async Task + OpenVerifiedMarkerParentWithinBoundaryAsync( + string boundaryPath, + string parentPath, + FileSystemPathSemantics semantics, + int? expectedBoundaryIdentityVersion = null, + string? expectedBoundaryIdentityValue = null, + string? boundaryIdentityUnavailableReason = null, + CancellationToken cancellationToken = default) + { + var canonicalBoundary = FileSystemPathIdentity.Canonicalize( + boundaryPath, + semantics.Syntax); + var canonicalParent = FileSystemPathIdentity.Canonicalize( + parentPath, + semantics.Syntax); + if (!FileSystemPathIdentity.IsSameOrInside( + canonicalParent, + canonicalBoundary, + semantics)) + { + throw new InvalidOperationException( + "An ownership migration marker escaped its authorized root boundary."); + } + + var current = PinnedDirectoryCreation.OpenPinnedBoundary( + canonicalBoundary); + try + { + if (expectedBoundaryIdentityVersion.HasValue + || !string.IsNullOrWhiteSpace(expectedBoundaryIdentityValue) + || !string.IsNullOrWhiteSpace(boundaryIdentityUnavailableReason)) + { + await ManagedDirectoryEnrollment.RequireMatchingEnrollmentAsync( + current, + expectedBoundaryIdentityVersion, + expectedBoundaryIdentityValue, + boundaryIdentityUnavailableReason, + cancellationToken); + } + + if (FileSystemPathIdentity.AreEquivalent( + canonicalParent, + canonicalBoundary, + semantics)) + { + return current; + } + + var relative = Path.GetRelativePath( + canonicalBoundary, + canonicalParent); + foreach (var segment in relative.Split( + [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], + StringSplitOptions.RemoveEmptyEntries)) + { + if (segment is "." or "..") + { + throw new InvalidOperationException( + "An ownership marker parent contains navigation segments."); + } + + var next = current.OpenExistingChild(segment); + current.Dispose(); + current = next; + } + + return current; + } + catch + { + current.Dispose(); + throw; + } + } } diff --git a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.Reauthorization.cs b/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.Reauthorization.cs index 93f163a52..a9489283c 100644 --- a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.Reauthorization.cs +++ b/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.Reauthorization.cs @@ -133,6 +133,14 @@ private async Task ReauthorizeLegacyTargetCoreAsync( ?? "The relocation target changed while its enrollment identity was captured."); } + foreach (var job in relocation.MoveJobs) + { + job.Entries.Add( + MoveManifestIdentity.CreateTargetBoundaryAuthorization( + targetObjectIdentity.Version!.Value, + targetObjectIdentity.Value!)); + } + relocation.TargetDirectoryObjectIdentityVersion = targetObjectIdentity.Version; relocation.TargetDirectoryObjectIdentity = targetObjectIdentity.Value; @@ -154,6 +162,15 @@ private static void ValidateLegacyReauthorizationEvidence( { foreach (var job in relocation.MoveJobs) { + if (MoveManifestIdentity.TryGetTargetBoundaryAuthorization( + job.Entries, + out _, + out _)) + { + throw new InvalidOperationException( + "A legacy move job already contains target-boundary authorization evidence and cannot be reauthorized automatically."); + } + if (string.IsNullOrWhiteSpace(job.SourcePath) || string.IsNullOrWhiteSpace(job.RequestedPath) || !job.TryGetSourceIdentity(out var sourceIdentity) diff --git a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.Retry.cs b/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.Retry.cs index c57b27b68..22d8651ac 100644 --- a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.Retry.cs +++ b/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.Retry.cs @@ -202,6 +202,18 @@ private async Task RetryCoreAsync( unsafeRetryJobs++; continue; } + if (!MoveManifestIdentity.TryGetTargetBoundaryAuthorization( + job.Entries, + out _, + out _)) + { + job.Status = MoveJobStatus.NeedsAttention; + job.Error = "The move job has no durable target-boundary physical-generation authorization and cannot be retried safely."; + job.FailureKind = MoveFailureKind.Verification; + job.ActiveDeduplicationKey = null; + unsafeRetryJobs++; + continue; + } var deduplicationKey = MoveManifestIdentity.CreateDeduplicationKey( job.AudiobookId, diff --git a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.TargetIdentity.cs b/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.TargetIdentity.cs index 9d2d36166..93ae95127 100644 --- a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.TargetIdentity.cs +++ b/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.TargetIdentity.cs @@ -1,3 +1,5 @@ +using Listenarr.Domain.Common; + namespace Listenarr.Infrastructure.Library.Moving; public sealed partial class RootFolderRelocationService @@ -27,6 +29,54 @@ private static void RejectTargetNavigationSegments(string targetPath) } } + private static async Task RequireTargetDirectoryGenerationAsync( + string targetPath, + int? expectedVersion, + string? expectedValue, + string? unavailableReason, + CancellationToken cancellationToken) + { + if (!FileSystemPathIdentity.TryCanonicalizeUnambiguousStoredAbsolutePathForHost( + targetPath, + out var canonicalTargetPath, + out var pathReason)) + { + throw new InvalidOperationException(pathReason); + } + + try + { + using var target = PinnedDirectoryCreation.OpenPinnedBoundary( + canonicalTargetPath); + await ManagedDirectoryEnrollment.RequireMatchingEnrollmentAsync( + target, + expectedVersion, + expectedValue, + unavailableReason, + cancellationToken); + } + catch (Exception exception) when (exception is + IOException or UnauthorizedAccessException + or InvalidOperationException or NotSupportedException + or System.ComponentModel.Win32Exception) + { + throw new InvalidOperationException( + "The relocation target no longer identifies its authorized physical directory generation.", + exception); + } + } + + private static Task RequireTargetDirectoryGenerationAsync( + string targetPath, + DirectoryObjectIdentityResolution expectedIdentity, + CancellationToken cancellationToken) => + RequireTargetDirectoryGenerationAsync( + targetPath, + expectedIdentity.Version, + expectedIdentity.Value, + expectedIdentity.UnavailableReason, + cancellationToken); + private static void ApplyRootDirectoryObjectIdentity( RootFolder root, DirectoryObjectIdentityResolution identity) @@ -89,16 +139,39 @@ or InvalidOperationException or NotSupportedException } } - private async Task + private Task + ResolveOrEnrollDirectoryObjectIdentityAsync( + string path, + CancellationToken cancellationToken) => + ResolveDirectoryObjectIdentityAsync( + path, + enrollIfMissing: true, + cancellationToken); + + private Task ResolveExistingDirectoryObjectIdentityAsync( string path, + CancellationToken cancellationToken) => + ResolveDirectoryObjectIdentityAsync( + path, + enrollIfMissing: false, + cancellationToken); + + private async Task + ResolveDirectoryObjectIdentityAsync( + string path, + bool enrollIfMissing, CancellationToken cancellationToken) { if (_directoryObjectIdentityResolver != null) { - return await _directoryObjectIdentityResolver.ResolveAsync( - path, - cancellationToken); + return enrollIfMissing + ? await _directoryObjectIdentityResolver.ResolveAsync( + path, + cancellationToken) + : await _directoryObjectIdentityResolver.ResolveExistingAsync( + path, + cancellationToken); } try @@ -108,7 +181,7 @@ private async Task return await ManagedDirectoryEnrollment.ResolveAsync( anchor, nativeIdentity, - enrollIfMissing: true, + enrollIfMissing, cancellationToken); } catch (Exception exception) when (exception is diff --git a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.TargetReservationPlanning.cs b/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.TargetReservationPlanning.cs index efd7d1ef3..aca5df74c 100644 --- a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.TargetReservationPlanning.cs +++ b/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.TargetReservationPlanning.cs @@ -36,7 +36,7 @@ await dbContextFactory.CreateDbContextAsync(cancellationToken)) var plan = DiscoverTargetReservationPlan(targetPath); if (plan.Segments.Count == 0) { - return await ResolveExistingDirectoryObjectIdentityAsync( + return await ResolveOrEnrollDirectoryObjectIdentityAsync( targetPath, cancellationToken); } diff --git a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.cs b/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.cs index f147b5e07..d003acae4 100644 --- a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.cs +++ b/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.cs @@ -51,11 +51,6 @@ private async Task StartCoreAsync( throw new InvalidOperationException( targetResolution.Reason ?? "Target filesystem semantics are unavailable; select an explicit override."); } - var targetObjectIdentity = - await ResolveExistingDirectoryObjectIdentityAsync( - targetPath, - cancellationToken); - await using var db = await dbContextFactory.CreateDbContextAsync(cancellationToken); await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken); var root = await db.RootFolders.SingleOrDefaultAsync( @@ -205,24 +200,14 @@ await db.AudiobookFiles } var affectedAudiobookIds = affected.Select(candidate => candidate.Audiobook.Id).ToHashSet(); - var activeMoveJobs = await db.MoveJobs - .Where(job => job.Status == MoveJobStatus.Queued - || job.Status == MoveJobStatus.Running - || job.Status == MoveJobStatus.RetryScheduled) - .AsNoTracking() - .ToListAsync(cancellationToken); - var conflictingMoveJob = activeMoveJobs.FirstOrDefault(job => - affectedAudiobookIds.Contains(job.AudiobookId) - || (sourceOperationSemantics.HasValue - && (PathTouchesBoundary(job.SourcePath, root.Path, sourceOperationSemantics.Value) - || PathTouchesBoundary(job.RequestedPath, root.Path, sourceOperationSemantics.Value))) - || PathTouchesBoundary(job.SourcePath, targetPath, targetResolution.Semantics) - || PathTouchesBoundary(job.RequestedPath, targetPath, targetResolution.Semantics)); - if (conflictingMoveJob != null) - { - throw new InvalidOperationException( - $"Active move job {conflictingMoveJob.Id} overlaps this root folder relocation; wait for it to finish before starting the relocation."); - } + await EnsureNoUnresolvedMoveConflictsAsync( + db, + affectedAudiobookIds, + root.Path, + sourceOperationSemantics, + targetPath, + targetResolution.Semantics, + cancellationToken); var movePlans = new List(); if (sourceResolution != null @@ -273,6 +258,14 @@ await db.AudiobookFiles RejectDuplicateRelocationTargets(movePlans, targetResolution.Semantics); } + // Directory enrollment is a filesystem mutation. Keep it behind every + // read-only request/root/source/conflict/manifest validation so a rejected + // path-change request cannot leave Listenarr metadata in an unadopted target. + var targetObjectIdentity = + await ResolveOrEnrollDirectoryObjectIdentityAsync( + targetPath, + cancellationToken); + RootFolderRelocation? relocation = null; var relocationWasPrecommitted = false; var precommittedContinuationCommitted = false; @@ -386,6 +379,12 @@ await db.AudiobookFiles foreach (var plan in movePlans) { var audiobook = plan.Candidate.Audiobook; + if (!targetObjectIdentity.IsAvailable) + { + throw new InvalidOperationException( + "Relocation move jobs require durable target-boundary generation authorization."); + } + var entries = plan.Manifest.Entries .Select(entry => new MoveJobEntry { @@ -398,6 +397,10 @@ await db.AudiobookFiles CleanupState = MoveJobEntryCleanupState.Pending }) .ToList(); + entries.Add( + MoveManifestIdentity.CreateTargetBoundaryAuthorization( + targetObjectIdentity.Version!.Value, + targetObjectIdentity.Value!)); var moveJob = new MoveJob { AudiobookId = audiobook.Id, @@ -427,6 +430,10 @@ await db.AudiobookFiles await db.SaveChangesAsync(cancellationToken); if (affected.Count == 0) { + await RequireTargetDirectoryGenerationAsync( + targetPath, + targetObjectIdentity, + cancellationToken); ApplyRootMetadata(root, command, targetPath, targetResolution, targetIdentityKey); ApplyRootDirectoryObjectIdentity(root, targetObjectIdentity); if (command.DesiredIsDefault) diff --git a/listenarr.infrastructure/Library/Scanning/MoveScanHandoffDispatchWorkflow.cs b/listenarr.infrastructure/Library/Scanning/MoveScanHandoffDispatchWorkflow.cs index 3411ae375..41713e3a8 100644 --- a/listenarr.infrastructure/Library/Scanning/MoveScanHandoffDispatchWorkflow.cs +++ b/listenarr.infrastructure/Library/Scanning/MoveScanHandoffDispatchWorkflow.cs @@ -149,6 +149,32 @@ await handoffStore.ReleaseClaimAsync( { throw; } + catch (OperationCanceledException exception) + { + logger.LogWarning( + exception, + "Move scan handoff {HandoffId} dispatch was canceled internally; releasing the claim for recovery", + claim.HandoffId); + try + { + await handoffStore.ReleaseClaimAsync( + claim.HandoffId, + claim.LeaseOwner, + claim.LeaseGeneration, + exception.Message, + timeProvider.GetUtcNow(), + CancellationToken.None); + } + catch (Exception releaseException) when (WorkerExceptionClassifier.IsNonFatal(releaseException)) + { + logger.LogDebug( + releaseException, + "Unable to release internally canceled move scan handoff {HandoffId}; waiting for lease expiry", + claim.HandoffId); + } + + return new MoveScanDispatchResult(MoveScanDispatchOutcome.Failed); + } catch (Exception exception) when (WorkerExceptionClassifier.IsNonFatal(exception)) { logger.LogWarning( diff --git a/listenarr.infrastructure/Library/Scanning/ScanFileDiscovery.Enumeration.cs b/listenarr.infrastructure/Library/Scanning/ScanFileDiscovery.Enumeration.cs index c2b5e9994..ea4ef36fa 100644 --- a/listenarr.infrastructure/Library/Scanning/ScanFileDiscovery.Enumeration.cs +++ b/listenarr.infrastructure/Library/Scanning/ScanFileDiscovery.Enumeration.cs @@ -5,7 +5,7 @@ namespace Listenarr.Infrastructure.Library.Scanning; internal static partial class ScanFileDiscovery { - private static EnumerationResult CollectCandidates( + internal static EnumerationResult CollectCandidates( IFileSystem fileSystem, string scanRoot, Guid jobId, @@ -262,7 +262,7 @@ private sealed record DirectoryEnumerationAnchor( PinnedDirectoryCreation.PinnedDirectoryAnchor Anchor, string ObjectIdentity); - private sealed record EnumerationResult( + internal sealed record EnumerationResult( IReadOnlyList Candidates, IReadOnlyList EnumeratedDirectories, IReadOnlyDictionary DirectoryObjectIdentities, diff --git a/listenarr.infrastructure/Library/Scanning/ScanJobProcessor.Coordination.cs b/listenarr.infrastructure/Library/Scanning/ScanJobProcessor.Coordination.cs index d43a7aff3..3edb03ffa 100644 --- a/listenarr.infrastructure/Library/Scanning/ScanJobProcessor.Coordination.cs +++ b/listenarr.infrastructure/Library/Scanning/ScanJobProcessor.Coordination.cs @@ -18,6 +18,7 @@ public partial class ScanJobProcessor private readonly TimeProvider _timeProvider; private readonly IFilesystemMutationCoordinator _filesystemMutationCoordinator; private readonly IAudiobookOperationCoordinator _audiobookOperationCoordinator; + private readonly IMoveQueueService _moveQueueService; private readonly IAudiobookUpdatePublisher? _audiobookUpdatePublisher; public ScanJobProcessor( @@ -29,6 +30,7 @@ public ScanJobProcessor( IFileSystemSemanticsResolver semanticsResolver, IFilesystemMutationCoordinator filesystemMutationCoordinator, IAudiobookOperationCoordinator audiobookOperationCoordinator, + IMoveQueueService moveQueueService, IMoveScanHandoffStore? moveScanHandoffStore = null, TimeProvider? timeProvider = null, IAudiobookUpdatePublisher? audiobookUpdatePublisher = null) @@ -43,6 +45,7 @@ public ScanJobProcessor( _timeProvider = timeProvider ?? TimeProvider.System; _filesystemMutationCoordinator = filesystemMutationCoordinator ?? throw new ArgumentNullException(nameof(filesystemMutationCoordinator)); _audiobookOperationCoordinator = audiobookOperationCoordinator ?? throw new ArgumentNullException(nameof(audiobookOperationCoordinator)); + _moveQueueService = moveQueueService ?? throw new ArgumentNullException(nameof(moveQueueService)); _audiobookUpdatePublisher = audiobookUpdatePublisher; } diff --git a/listenarr.infrastructure/Library/Scanning/ScanJobProcessor.cs b/listenarr.infrastructure/Library/Scanning/ScanJobProcessor.cs index ff6a96654..4a0b79d94 100644 --- a/listenarr.infrastructure/Library/Scanning/ScanJobProcessor.cs +++ b/listenarr.infrastructure/Library/Scanning/ScanJobProcessor.cs @@ -34,6 +34,9 @@ private async Task ProcessJobCoreAsync( "Processing scan job {JobId} for audiobook {AudiobookId}", job.Id, job.AudiobookId); + await _moveQueueService.EnsureFilesystemMutationAllowedAsync( + job.AudiobookId, + stoppingToken); await BroadcastProcessingAsync(job); try { diff --git a/listenarr.infrastructure/Library/Scanning/ScanPathAuthorizationService.cs b/listenarr.infrastructure/Library/Scanning/ScanPathAuthorizationService.cs index 39d989ecf..2e39409e4 100644 --- a/listenarr.infrastructure/Library/Scanning/ScanPathAuthorizationService.cs +++ b/listenarr.infrastructure/Library/Scanning/ScanPathAuthorizationService.cs @@ -62,23 +62,22 @@ public async Task AuthorizeAsync( boundary.RequestedMode, boundary.Path, fullPath); - if (!TryCapturePhysicalIdentity( - boundary.Path, - fullPath, - boundary.Semantics, - out var physicalIdentity, - out var physicalError)) + var physicalCapture = await TryCapturePhysicalIdentityAsync( + boundary, + fullPath, + cancellationToken); + if (!physicalCapture.Success) { return ScanPathAuthorizationResult.Rejected( ScanPathAuthorizationFailure.IdentityUnavailable, - physicalError + physicalCapture.Error ?? "The scan path physical identity could not be established safely."); } return ScanPathAuthorizationResult.Authorized( fullPath, identity, - physicalIdentity); + physicalCapture.Identity); } public async Task ResolveDefaultAsync( @@ -137,13 +136,21 @@ private async Task> LoadAuthorizedRootsAsync( var candidates = configuredRoots .Select(root => new RootCandidate( root.Path, - root.CaseSensitivityMode)) + root.CaseSensitivityMode, + RequiresEnrollment: true, + root.DirectoryObjectIdentityVersion, + root.DirectoryObjectIdentity, + root.DirectoryObjectIdentityUnavailableReason)) .ToList(); if (!string.IsNullOrWhiteSpace(settings?.OutputPath)) { candidates.Add(new RootCandidate( settings.OutputPath, - FileSystemCaseSensitivityMode.Auto)); + FileSystemCaseSensitivityMode.Auto, + RequiresEnrollment: false, + DirectoryObjectIdentityVersion: null, + DirectoryObjectIdentity: null, + DirectoryObjectIdentityUnavailableReason: null)); } var roots = new List(); @@ -207,32 +214,39 @@ private async Task> LoadAuthorizedRootsAsync( roots.Add(new AuthorizedRoot( canonical, resolution.Semantics, - candidate.RequestedMode)); + candidate.RequestedMode, + candidate.RequiresEnrollment, + candidate.DirectoryObjectIdentityVersion, + candidate.DirectoryObjectIdentity, + candidate.DirectoryObjectIdentityUnavailableReason)); } return roots; } - private static bool TryCapturePhysicalIdentity( - string boundaryPath, + private static async Task TryCapturePhysicalIdentityAsync( + AuthorizedRoot authorizedRoot, string scanPath, - FileSystemPathSemantics semantics, - out ScanPathPhysicalIdentity identity, - out string? error) + CancellationToken cancellationToken) { - identity = default; - error = null; try { var canonicalBoundary = FileSystemPathIdentity.Canonicalize( - boundaryPath, - semantics.Syntax); + authorizedRoot.Path, + authorizedRoot.Semantics.Syntax); var canonicalScanPath = FileSystemPathIdentity.Canonicalize( scanPath, - semantics.Syntax); + authorizedRoot.Semantics.Syntax); using var boundary = PinnedDirectoryCreation.OpenPinnedBoundary( canonicalBoundary); - var boundaryIdentity = boundary.GetDirectoryObjectIdentity(); + var boundaryIdentity = authorizedRoot.RequiresEnrollment + ? await ManagedDirectoryEnrollment.RequireMatchingEnrollmentAsync( + boundary, + authorizedRoot.DirectoryObjectIdentityVersion, + authorizedRoot.DirectoryObjectIdentity, + authorizedRoot.DirectoryObjectIdentityUnavailableReason, + cancellationToken) + : boundary.GetDirectoryObjectIdentity(); using var scanRoot = OpenRelativeScanRoot( boundary, canonicalBoundary, @@ -240,26 +254,27 @@ private static bool TryCapturePhysicalIdentity( if (!boundary.VisiblePathMatches() || !scanRoot.VisiblePathMatches()) { - error = "The configured scan boundary changed while its physical identity was being captured."; - return false; + return PhysicalIdentityCapture.Failed( + "The configured scan boundary changed while its physical identity was being captured."); } - identity = new ScanPathPhysicalIdentity( - boundaryIdentity, - scanRoot.GetDirectoryObjectIdentity()); - return true; + return PhysicalIdentityCapture.Captured( + new ScanPathPhysicalIdentity( + boundaryIdentity, + scanRoot.GetDirectoryObjectIdentity())); } catch (Exception exception) when (exception is not ( OperationCanceledException or OutOfMemoryException or StackOverflowException)) { - error = exception switch + return PhysicalIdentityCapture.Failed(exception switch { DirectoryNotFoundException => "The scan path no longer exists beneath its configured root.", + _ when authorizedRoot.RequiresEnrollment => + "The configured scan root no longer identifies its enrolled physical generation.", _ => "The scan path contains a linked, replaced, or unavailable directory component." - }; - return false; + }); } } @@ -337,10 +352,31 @@ private static bool TryGetStoredFullPath( private sealed record RootCandidate( string Path, - FileSystemCaseSensitivityMode RequestedMode); + FileSystemCaseSensitivityMode RequestedMode, + bool RequiresEnrollment, + int? DirectoryObjectIdentityVersion, + string? DirectoryObjectIdentity, + string? DirectoryObjectIdentityUnavailableReason); private sealed record AuthorizedRoot( string Path, FileSystemPathSemantics Semantics, - FileSystemCaseSensitivityMode RequestedMode); + FileSystemCaseSensitivityMode RequestedMode, + bool RequiresEnrollment, + int? DirectoryObjectIdentityVersion, + string? DirectoryObjectIdentity, + string? DirectoryObjectIdentityUnavailableReason); + + private sealed record PhysicalIdentityCapture( + bool Success, + ScanPathPhysicalIdentity Identity, + string? Error) + { + public static PhysicalIdentityCapture Captured( + ScanPathPhysicalIdentity identity) => + new(true, identity, null); + + public static PhysicalIdentityCapture Failed(string error) => + new(false, default, error); + } } diff --git a/listenarr.infrastructure/Library/Scanning/UnmatchedScanBackgroundService.cs b/listenarr.infrastructure/Library/Scanning/UnmatchedScanBackgroundService.cs index dbbf5049d..864cfae11 100644 --- a/listenarr.infrastructure/Library/Scanning/UnmatchedScanBackgroundService.cs +++ b/listenarr.infrastructure/Library/Scanning/UnmatchedScanBackgroundService.cs @@ -165,25 +165,26 @@ private async Task> ScanAsync(string rootFolderPath, C var fileRepository = scope.ServiceProvider.GetRequiredService(); var audiobookRepository = scope.ServiceProvider.GetRequiredService(); var configService = scope.ServiceProvider.GetRequiredService(); + var scanAuthorizationService = scope.ServiceProvider + .GetRequiredService(); + var fileSystem = scope.ServiceProvider.GetRequiredService(); var appSettings = await configService.GetApplicationSettingsAsync(); var concurrency = Math.Clamp(appSettings?.UnmatchedScanConcurrency ?? 2, 1, 8); - if (!FileSystemPathIdentity.TryCanonicalizeUnambiguousStoredAbsolutePathForHost( - rootFolderPath, - out var canonicalRootFolderPath, - out var pathReason)) - { - throw new ArgumentException(pathReason, nameof(rootFolderPath)); - } - - var semanticsResolution = await _semanticsResolver.ResolveAsync( - canonicalRootFolderPath, - cancellationToken: ct); - if (semanticsResolution.State != PathIdentityState.Valid) + var authorization = await scanAuthorizationService.AuthorizeAsync( + rootFolderPath, + ct); + if (!authorization.IsAuthorized + || authorization.Path == null + || !authorization.Identity.HasValue + || !authorization.PhysicalIdentity.HasValue) { throw new InvalidOperationException( - semanticsResolution.Reason ?? "Root filesystem identity is unavailable."); + authorization.Error + ?? "The unmatched scan root could not be authorized safely."); } - var semantics = semanticsResolution.Semantics; + + var canonicalRootFolderPath = authorization.Path; + var semantics = authorization.Identity.Value.Semantics; // Load all tracked file paths (normalized) from DB. // Check BOTH AudiobookFiles (multi-file imports) AND Audiobook.FilePath (single-file imports) @@ -201,8 +202,34 @@ private async Task> ScanAsync(string rootFolderPath, C .Select(path => NormalizePath(path, semantics.Syntax)), semantics.Comparer); - // Walk the root folder tree - var candidates = CollectAudioFiles(canonicalRootFolderPath, semantics); + // Walk the root folder tree through the same pinned/generation-aware + // enumeration primitive used by authoritative audiobook scans. + using var pinnedRoot = PinnedDirectoryCreation.OpenPinnedBoundary( + canonicalRootFolderPath); + if (!pinnedRoot.VisiblePathMatches() + || !string.Equals( + pinnedRoot.GetDirectoryObjectIdentity(), + authorization.PhysicalIdentity.Value.ScanRootObjectIdentity, + StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "The unmatched scan root changed after authorization."); + } + var enumeration = ScanFileDiscovery.CollectCandidates( + fileSystem, + canonicalRootFolderPath, + jobId: Guid.Empty, + _logger, + semantics, + pinnedRoot); + if (enumeration.Issues.Any(issue => issue.Kind is + ScanDiscoveryIssueKind.DirectoryGenerationChanged + or ScanDiscoveryIssueKind.EnumerationFailure)) + { + throw new InvalidOperationException( + "The unmatched scan root changed or became unavailable during enumeration."); + } + var candidates = enumeration.Candidates.ToList(); // Filter to untracked files var unmatched = candidates diff --git a/listenarr.infrastructure/Library/Scanning/UnmatchedScanProcessor.Grouping.cs b/listenarr.infrastructure/Library/Scanning/UnmatchedScanProcessor.Grouping.cs index c4cbfe18e..be4181448 100644 --- a/listenarr.infrastructure/Library/Scanning/UnmatchedScanProcessor.Grouping.cs +++ b/listenarr.infrastructure/Library/Scanning/UnmatchedScanProcessor.Grouping.cs @@ -4,7 +4,6 @@ */ using System.Text.RegularExpressions; using Listenarr.Domain.Common; -using Microsoft.Extensions.Logging; namespace Listenarr.Infrastructure.Library.Scanning { @@ -203,64 +202,6 @@ private static void ApplyEmbeddedTags(PathParsedMetadata target, PathParsedMetad if (!string.IsNullOrEmpty(tags.Asin)) target.Asin = tags.Asin; } - private List CollectAudioFiles( - string rootFolderPath, - FileSystemPathSemantics semantics) - { - var candidates = new List(); - var normalizedRoot = Path.GetFullPath(rootFolderPath); - var dirs = new Stack(); - dirs.Push(normalizedRoot); - - while (dirs.Count > 0) - { - var dir = dirs.Pop(); - try - { - var normalizedDir = Path.GetFullPath(dir); - foreach (var file in Directory.EnumerateFiles(normalizedDir)) - { - try - { - if (AudioExtensions.Contains(Path.GetExtension(file), StringComparer.OrdinalIgnoreCase)) - candidates.Add(file); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogDebug(ex, "Skipped file {File} during unmatched scan", file); - } - } - foreach (var sub in Directory.EnumerateDirectories(normalizedDir)) - { - // Skip reparse points (symlinks, junctions) because they can point outside the root. - if (new DirectoryInfo(sub).Attributes.HasFlag(FileAttributes.ReparsePoint)) - { - _logger.LogDebug("Skipping reparse point {Dir}", sub); - continue; - } - var resolvedSub = Path.GetFullPath(sub); - if (!FileSystemPathIdentity.IsSameOrInside( - resolvedSub, - normalizedRoot, - semantics)) - { - _logger.LogWarning("Skipping {Dir}: resolves outside configured root {Root}", sub, normalizedRoot); - continue; - } - dirs.Push(resolvedSub); - } - } - catch (IOException ioEx) { _logger.LogWarning(ioEx, "IO error scanning {Dir}", dir); } - catch (UnauthorizedAccessException uaEx) { _logger.LogWarning(uaEx, "Access denied scanning {Dir}", dir); } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Unexpected error scanning {Dir}", dir); - } - } - - return candidates; - } - /// /// Extracts a normalized title stem from a filename for grouping purposes. /// Strips leading track numbers, trailing Part/CD/Disc numbers, year and diff --git a/listenarr.infrastructure/Metadata/Jobs/MetadataRescanService.cs b/listenarr.infrastructure/Metadata/Jobs/MetadataRescanService.cs index 5e627d7e7..2da432faa 100644 --- a/listenarr.infrastructure/Metadata/Jobs/MetadataRescanService.cs +++ b/listenarr.infrastructure/Metadata/Jobs/MetadataRescanService.cs @@ -46,6 +46,7 @@ await cycleRunner.RunPeriodicAsync( public class MetadataRescanProcessor( IServiceScopeFactory scopeFactory, IAudiobookOperationCoordinator audiobookOperationCoordinator, + IMoveQueueService moveQueueService, ILogger logger) : IMetadataRescanProcessor { private readonly AsyncNonKeyedLocker _sem = new(2); // bound concurrent extractions @@ -72,6 +73,19 @@ public async Task RunCycleAsync(CancellationToken cancellationToken) using var taskScope = scopeFactory.CreateScope(); var taskFileRepository = taskScope.ServiceProvider.GetRequiredService(); + var recovery = await moveQueueService.GetRecoveryStateForAudiobookAsync( + candidate.AudiobookId, + cancellationToken); + if (recovery.BlocksFilesystemMutation) + { + logger.LogDebug( + "Skipping metadata rescan for file id={Id}; audiobook {AudiobookId} has unresolved move state {Disposition}", + candidate.Id, + candidate.AudiobookId, + recovery.Disposition); + return; + } + var file = await taskFileRepository.GetByIdAsync(candidate.Id, cancellationToken); if (file == null) { @@ -145,6 +159,9 @@ await audiobookOperationCoordinator.ExecuteExclusiveAsync( audiobookId, async token => { + await moveQueueService.EnsureFilesystemMutationAllowedAsync( + audiobookId, + token); using var applyScope = scopeFactory.CreateScope(); var fileRepository = applyScope.ServiceProvider.GetRequiredService(); var audiobookRepository = applyScope.ServiceProvider.GetRequiredService(); diff --git a/listenarr.infrastructure/Persistence/Repositories/EfApplicationSettingsRepository.cs b/listenarr.infrastructure/Persistence/Repositories/EfApplicationSettingsRepository.cs index f09ec9542..89b61be46 100644 --- a/listenarr.infrastructure/Persistence/Repositories/EfApplicationSettingsRepository.cs +++ b/listenarr.infrastructure/Persistence/Repositories/EfApplicationSettingsRepository.cs @@ -35,6 +35,45 @@ public EfApplicationSettingsRepository(ListenArrDbContext db) return await _db.ApplicationSettings.AsNoTracking().FirstOrDefaultAsync(s => s.Id == 1, ct); } + public async Task InitializeIfMissingAsync( + ApplicationSettings defaults, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(defaults); + defaults.Id = 1; + + await SingletonSettingsWriteLock.WaitAsync(ct); + try + { + var existing = await _db.ApplicationSettings + .AsNoTracking() + .FirstOrDefaultAsync(settings => settings.Id == 1, ct); + if (existing != null) + { + return existing; + } + + defaults.Version = 1; + _db.ApplicationSettings.Add(defaults); + try + { + await _db.SaveChangesAsync(ct); + return defaults; + } + catch (UniqueConstraintViolationException) + { + _db.Entry(defaults).State = EntityState.Detached; + return await _db.ApplicationSettings + .AsNoTracking() + .SingleAsync(settings => settings.Id == 1, ct); + } + } + finally + { + SingletonSettingsWriteLock.Release(); + } + } + public async Task SaveAsync(ApplicationSettings settings, CancellationToken ct = default) { settings.Id = 1; @@ -57,24 +96,25 @@ public async Task SaveAsync(ApplicationSettings settings, C await _db.SaveChangesAsync(ct); return settings; } - catch (UniqueConstraintViolationException) + catch (UniqueConstraintViolationException exception) { _db.Entry(settings).State = EntityState.Detached; - var racedExisting = await _db.ApplicationSettings.FindAsync([1], ct); - if (racedExisting != null) - { - return racedExisting; - } - - throw; + throw new ApplicationConflictException( + "settings_concurrency_conflict", + "Application settings were initialized by another request. Reload and try again.", + exception); } } var persistedVersion = existing.Version; - var expectedVersion = settings.Version == 0 - ? persistedVersion - : settings.Version; + if (settings.Version <= 0) + { + throw new ApplicationConflictException( + "settings_concurrency_conflict", + "Application settings must include the current version. Reload and try again."); + } + var expectedVersion = settings.Version; if (expectedVersion != persistedVersion) { throw new ApplicationConflictException( diff --git a/listenarr.infrastructure/Persistence/Repositories/EfAudiobookFileRepository.BasePathRegistration.cs b/listenarr.infrastructure/Persistence/Repositories/EfAudiobookFileRepository.BasePathRegistration.cs index 42a0c17bb..0bf2f2590 100644 --- a/listenarr.infrastructure/Persistence/Repositories/EfAudiobookFileRepository.BasePathRegistration.cs +++ b/listenarr.infrastructure/Persistence/Repositories/EfAudiobookFileRepository.BasePathRegistration.cs @@ -54,10 +54,18 @@ public async Task ClaimWithBasePathAsync( _db.AudiobookFiles.Add(file); try { - await _db.SaveChangesAsync(ct); - if (transaction != null) + if (transaction == null) + { + var completionToken = + RequestCancellationBoundary.EnterNonCancelablePhase(ct); + await _db.SaveChangesAsync(completionToken); + } + else { - await transaction.CommitAsync(ct); + await _db.SaveChangesAsync(ct); + var completionToken = + RequestCancellationBoundary.EnterNonCancelablePhase(ct); + await transaction.CommitAsync(completionToken); } return new AudiobookFileClaimResult( @@ -86,6 +94,8 @@ public async Task ApplyBasePathAsync( ValidateBasePathMutation(basePathMutation.AudiobookId, basePathMutation); if (_db.Database.IsRelational()) { + var completionToken = + RequestCancellationBoundary.EnterNonCancelablePhase(ct); var updated = await _db.Audiobooks .Where(candidate => candidate.Id == basePathMutation.AudiobookId @@ -94,7 +104,7 @@ public async Task ApplyBasePathAsync( setters => setters.SetProperty( candidate => candidate.BasePath, basePathMutation.ResultingBasePath), - ct); + completionToken); if (updated == 1) { SynchronizeTrackedBasePath(basePathMutation); @@ -134,7 +144,9 @@ public async Task ApplyBasePathAsync( } audiobook.BasePath = basePathMutation.ResultingBasePath; - await _db.SaveChangesAsync(ct); + var nonCancelableToken = + RequestCancellationBoundary.EnterNonCancelablePhase(ct); + await _db.SaveChangesAsync(nonCancelableToken); return true; } @@ -173,7 +185,9 @@ public async Task ReplacePhysicalGenerationWithBasePathAsync( audiobook.BasePath = basePathMutation.ResultingBasePath; ApplyPhysicalGeneration(existing, replacement); - await _db.SaveChangesAsync(ct); + var nonRelationalCompletionToken = + RequestCancellationBoundary.EnterNonCancelablePhase(ct); + await _db.SaveChangesAsync(nonRelationalCompletionToken); return true; } @@ -230,7 +244,9 @@ public async Task ReplacePhysicalGenerationWithBasePathAsync( return false; } - await transaction.CommitAsync(ct); + var completionToken = + RequestCancellationBoundary.EnterNonCancelablePhase(ct); + await transaction.CommitAsync(completionToken); SynchronizeTrackedBasePath(basePathMutation); SynchronizeTrackedPhysicalGeneration(fileId, replacement); return true; @@ -268,7 +284,9 @@ public async Task DeletePhysicalGenerationWithBasePathAsync( audiobook.BasePath = basePathMutation.ResultingBasePath; _db.AudiobookFiles.Remove(file); - await _db.SaveChangesAsync(ct); + var nonRelationalCompletionToken = + RequestCancellationBoundary.EnterNonCancelablePhase(ct); + await _db.SaveChangesAsync(nonRelationalCompletionToken); return true; } @@ -303,7 +321,9 @@ public async Task DeletePhysicalGenerationWithBasePathAsync( return false; } - await transaction.CommitAsync(ct); + var completionToken = + RequestCancellationBoundary.EnterNonCancelablePhase(ct); + await transaction.CommitAsync(completionToken); var trackedFile = _db.ChangeTracker.Entries() .FirstOrDefault(entry => entry.Entity.Id == fileId); if (trackedFile != null) diff --git a/listenarr.infrastructure/Persistence/Repositories/EfAudiobookFileRepository.PhysicalGeneration.cs b/listenarr.infrastructure/Persistence/Repositories/EfAudiobookFileRepository.PhysicalGeneration.cs index 0985131db..a37dae9f7 100644 --- a/listenarr.infrastructure/Persistence/Repositories/EfAudiobookFileRepository.PhysicalGeneration.cs +++ b/listenarr.infrastructure/Persistence/Repositories/EfAudiobookFileRepository.PhysicalGeneration.cs @@ -21,6 +21,8 @@ public async Task ReplacePhysicalGenerationAsync( && candidate.PhysicalObjectIdentity == expectedPhysicalObjectIdentity); if (_db.Database.IsRelational()) { + var completionToken = + RequestCancellationBoundary.EnterNonCancelablePhase(ct); var updated = await query.ExecuteUpdateAsync( setters => setters .SetProperty(candidate => candidate.Size, replacement.Size) @@ -43,7 +45,7 @@ public async Task ReplacePhysicalGenerationAsync( .SetProperty( candidate => candidate.PhysicalIdentityObservedAtUtc, replacement.PhysicalIdentityObservedAtUtc), - ct); + completionToken); if (updated != 1) { return false; @@ -60,7 +62,9 @@ public async Task ReplacePhysicalGenerationAsync( } ApplyPhysicalGeneration(existing, replacement); - await _db.SaveChangesAsync(ct); + var nonRelationalCompletionToken = + RequestCancellationBoundary.EnterNonCancelablePhase(ct); + await _db.SaveChangesAsync(nonRelationalCompletionToken); return true; } @@ -79,7 +83,9 @@ public async Task DeletePhysicalGenerationAsync( == expectedPhysicalObjectIdentity); if (_db.Database.IsRelational()) { - var deleted = await query.ExecuteDeleteAsync(ct); + var completionToken = + RequestCancellationBoundary.EnterNonCancelablePhase(ct); + var deleted = await query.ExecuteDeleteAsync(completionToken); if (deleted != 1) { return false; @@ -102,7 +108,9 @@ public async Task DeletePhysicalGenerationAsync( } _db.AudiobookFiles.Remove(existing); - await _db.SaveChangesAsync(ct); + var nonRelationalCompletionToken = + RequestCancellationBoundary.EnterNonCancelablePhase(ct); + await _db.SaveChangesAsync(nonRelationalCompletionToken); return true; } diff --git a/listenarr.infrastructure/Persistence/Repositories/EfMoveQueuePersistence.Reconciliation.cs b/listenarr.infrastructure/Persistence/Repositories/EfMoveQueuePersistence.Reconciliation.cs index c432c661f..9b9e57aef 100644 --- a/listenarr.infrastructure/Persistence/Repositories/EfMoveQueuePersistence.Reconciliation.cs +++ b/listenarr.infrastructure/Persistence/Repositories/EfMoveQueuePersistence.Reconciliation.cs @@ -46,7 +46,6 @@ public async Task ReconcileIdentityKeysAsync(CancellationToken cancellationToken "The move job has no persisted tracked-file source manifest and cannot be reconciled safely."); continue; } - if (string.IsNullOrWhiteSpace(job.SourcePath) || string.IsNullOrWhiteSpace(job.RequestedPath)) { @@ -119,7 +118,7 @@ public async Task ReconcileIdentityKeysAsync(CancellationToken cancellationToken job.IdentityKeyVersion = MoveManifestIdentity.Version; resolvedJobs.Add(( job, - MoveManifestIdentity.CreateDeduplicationKey( + MoveManifestIdentity.CreateReconciliationKey( job.AudiobookId, sourcePath, sourceIdentity, @@ -140,6 +139,7 @@ public async Task ReconcileIdentityKeysAsync(CancellationToken cancellationToken var activeJobIds = activeJobs.Select(job => job.Id).ToList(); var jobIdsWithManifestExecutionState = await db.MoveJobEntries .Where(entry => activeJobIds.Contains(entry.MoveJobId) + && entry.RelativePath != string.Empty && (entry.CopyState != MoveJobEntryCopyState.Pending || entry.CleanupState != MoveJobEntryCleanupState.Pending)) .Select(entry => entry.MoveJobId) @@ -215,15 +215,36 @@ public async Task ReconcileIdentityKeysAsync(CancellationToken cancellationToken continue; } + if (evidenceBearing.Count == 0 && candidates.Count > 1) + { + foreach (var candidate in candidates) + { + MarkIdentityConflict( + candidate.Job, + "Multiple active move jobs share one identity but no authoritative recovery owner can be proven."); + } + continue; + } + var canonical = evidenceBearing.Count == 1 ? evidenceBearing[0] - : candidates - .OrderByDescending(item => item.Job.Phase) - .ThenByDescending(item => item.Job.Status == MoveJobStatus.Running) - .ThenByDescending(item => item.Job.UpdatedAt ?? item.Job.EnqueuedAt) - .First(); - canonical.Job.ActiveDeduplicationKey = group.Key; - canonical.Job.IdentityKeyVersion = MoveManifestIdentity.Version; + : candidates[0]; + var canonicalHasTargetAuthorization = + MoveManifestIdentity.TryGetTargetBoundaryAuthorization( + canonical.Job.Entries, + out _, + out _); + if (canonicalHasTargetAuthorization) + { + canonical.Job.ActiveDeduplicationKey = group.Key; + canonical.Job.IdentityKeyVersion = MoveManifestIdentity.Version; + } + else + { + MarkIdentityConflict( + canonical.Job, + "The move job has no durable target-boundary physical-generation authorization and cannot be reconciled safely."); + } foreach (var duplicate in candidates.Where(item => item.Job.Id != canonical.Job.Id)) { diff --git a/listenarr.infrastructure/Persistence/Repositories/EfMoveQueuePersistence.ReconciliationEvidence.cs b/listenarr.infrastructure/Persistence/Repositories/EfMoveQueuePersistence.ReconciliationEvidence.cs index 37d4aac8d..c666de3f7 100644 --- a/listenarr.infrastructure/Persistence/Repositories/EfMoveQueuePersistence.ReconciliationEvidence.cs +++ b/listenarr.infrastructure/Persistence/Repositories/EfMoveQueuePersistence.ReconciliationEvidence.cs @@ -17,32 +17,34 @@ private static OwnershipEvidenceResult ReadTargetOwnershipEvidence( try { - if ((File.GetAttributes(target) & FileAttributes.ReparsePoint) != 0) + using var targetAnchor = PinnedDirectoryCreation.OpenPinnedDirectoryNoFollow(target); + if (!targetAnchor.VisiblePathMatches()) { - return OwnershipEvidenceResult.Ambiguous("The move target is a symbolic link or reparse point."); + return OwnershipEvidenceResult.Ambiguous( + "The move target changed while ownership evidence was being inspected."); } - var markerPath = Path.Join(target, ".listenarr-temp-owner.json"); - if (File.Exists(markerPath)) + const string markerName = ".listenarr-temp-owner.json"; + using var markerEntry = targetAnchor.TryOpenExistingFile( + markerName, + requireDeleteAccess: false); + if (markerEntry != null) { - if ((File.GetAttributes(markerPath) & FileAttributes.ReparsePoint) != 0) + if (!markerEntry.VisiblePathMatches()) { - return OwnershipEvidenceResult.Ambiguous("The target ownership marker is linked."); + return OwnershipEvidenceResult.Ambiguous( + "The target ownership marker changed while it was being inspected."); } - var markerInfo = new FileInfo(markerPath); - if (markerInfo.Length <= 0 || markerInfo.Length > MaximumOwnershipMarkerBytes) + using var stream = markerEntry.OpenReadStream( + bufferSize: 4096, + asynchronous: false); + if (stream.Length <= 0 || stream.Length > MaximumOwnershipMarkerBytes) { return OwnershipEvidenceResult.Ambiguous("The target ownership marker has an invalid size."); } - using var stream = new FileStream( - markerPath, - FileMode.Open, - FileAccess.Read, - FileShare.Read, - 4096, - FileOptions.SequentialScan); + stream.Position = 0; var marker = JsonSerializer.Deserialize(stream); if (marker == null || marker.Version != 1 @@ -107,40 +109,17 @@ private static OwnershipEvidenceResult ReadTargetOwnershipEvidence( return OwnershipEvidenceResult.Valid(marker.JobId); } - var writeOwners = new HashSet(); - foreach (var writePath in Directory.EnumerateFiles( - target, - ".listenarr-temp-owner.json.writing-*", - SearchOption.TopDirectoryOnly)) + if (Directory.EnumerateFiles( + target, + ".listenarr-temp-owner.json.writing-*", + SearchOption.TopDirectoryOnly) + .Any()) { - if ((File.GetAttributes(writePath) & FileAttributes.ReparsePoint) != 0) - { - return OwnershipEvidenceResult.Ambiguous("A target ownership-marker write file is linked."); - } - - var fileName = Path.GetFileName(writePath); - var owners = candidates - .Where(candidate => fileName.Contains( - $"writing-{candidate.Job.Id:N}-", - StringComparison.Ordinal)) - .Select(candidate => candidate.Job.Id) - .ToList(); - if (owners.Count != 1) - { - return OwnershipEvidenceResult.Ambiguous( - "A target ownership-marker write file cannot be attributed to one active move job."); - } - - writeOwners.Add(owners[0]); + return OwnershipEvidenceResult.Ambiguous( + "An incomplete target ownership-marker publication exists and cannot establish an owner."); } - return writeOwners.Count switch - { - 0 => OwnershipEvidenceResult.None, - 1 => OwnershipEvidenceResult.Valid(writeOwners.Single()), - _ => OwnershipEvidenceResult.Ambiguous( - "Multiple move jobs have incomplete target ownership-marker publications.") - }; + return OwnershipEvidenceResult.None; } catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException or PathTooLongException or JsonException) @@ -189,7 +168,7 @@ private static JobEvidenceState CollectJobSpecificRecoveryEvidence( Path.GetFileName(target) + ".tmp-" + job.Id.ToString("N")))) || HasOwnedPartialFile(target, job.Id)) { - return JobEvidenceState.Owned; + return JobEvidenceState.Ambiguous; } } @@ -206,7 +185,7 @@ private static JobEvidenceState CollectJobSpecificRecoveryEvidence( sourceParent, $".listenarr-quarantine-{job.Id:N}")))) { - return JobEvidenceState.Owned; + return JobEvidenceState.Ambiguous; } } } diff --git a/listenarr.infrastructure/Persistence/Repositories/EfMoveQueuePersistence.cs b/listenarr.infrastructure/Persistence/Repositories/EfMoveQueuePersistence.cs index c3463f566..5be48bdcf 100644 --- a/listenarr.infrastructure/Persistence/Repositories/EfMoveQueuePersistence.cs +++ b/listenarr.infrastructure/Persistence/Repositories/EfMoveQueuePersistence.cs @@ -75,6 +75,60 @@ public async Task> GetActiveAsync(CancellationToken cance } } + public async Task> GetRecoveryCandidatesByAudiobookAsync( + int audiobookId, + CancellationToken cancellationToken = default) + { + try + { + await using var db = await dbFactory.CreateDbContextAsync(cancellationToken); + return await db.MoveJobs + .AsNoTracking() + .AsSplitQuery() + .Include(job => job.Entries) + .Include(job => job.CreatedDirectories) + .Where(job => job.AudiobookId == audiobookId + && (job.Status == MoveJobStatus.Queued + || job.Status == MoveJobStatus.Running + || job.Status == MoveJobStatus.RetryScheduled + || job.Status == MoveJobStatus.Failed + || job.Status == MoveJobStatus.NeedsAttention)) + .OrderBy(job => job.EnqueuedAt) + .ThenBy(job => job.Id) + .ToListAsync(cancellationToken); + } + catch (DbException ex) + { + throw new PersistenceException("Failed to query move recovery candidates.", ex); + } + } + + public async Task> GetRecoveryCandidatesAsync( + CancellationToken cancellationToken = default) + { + try + { + await using var db = await dbFactory.CreateDbContextAsync(cancellationToken); + return await db.MoveJobs + .AsNoTracking() + .AsSplitQuery() + .Include(job => job.Entries) + .Include(job => job.CreatedDirectories) + .Where(job => job.Status == MoveJobStatus.Queued + || job.Status == MoveJobStatus.Running + || job.Status == MoveJobStatus.RetryScheduled + || job.Status == MoveJobStatus.Failed + || job.Status == MoveJobStatus.NeedsAttention) + .OrderBy(job => job.EnqueuedAt) + .ThenBy(job => job.Id) + .ToListAsync(cancellationToken); + } + catch (DbException ex) + { + throw new PersistenceException("Failed to query move recovery candidates.", ex); + } + } + public async Task GetHealthAsync( DateTimeOffset now, CancellationToken cancellationToken = default) diff --git a/listenarr.infrastructure/Persistence/Repositories/EfMoveScanHandoffStore.cs b/listenarr.infrastructure/Persistence/Repositories/EfMoveScanHandoffStore.cs index c3aecd2dc..a0e1605a5 100644 --- a/listenarr.infrastructure/Persistence/Repositories/EfMoveScanHandoffStore.cs +++ b/listenarr.infrastructure/Persistence/Repositories/EfMoveScanHandoffStore.cs @@ -319,18 +319,23 @@ await FailUndispatchableClaimAsync( cancellationToken); return null; } - var targetManifest = await db.MoveJobEntries + var persistedEntries = await db.MoveJobEntries .AsNoTracking() .Where(entry => entry.MoveJobId == claimed.MoveJobId) .OrderBy(entry => entry.Id) .ToListAsync(cancellationToken); - if (targetManifest.Count == 0) + var targetManifest = persistedEntries + .Where(entry => + !MoveManifestIdentity.IsTargetBoundaryAuthorization(entry)) + .ToList(); + if (!targetManifest.Any(entry => + entry.EntryType == MoveJobEntryType.File)) { await FailUndispatchableClaimAsync( claimed.Id, claimed.AttemptGeneration, claimed.TargetPath, - "The completed move has no durable target manifest.", + "The completed move has no durable target manifest with tracked-file evidence.", now, cancellationToken); return null; diff --git a/listenarr.infrastructure/Persistence/Repositories/EfRootFolderRepository.cs b/listenarr.infrastructure/Persistence/Repositories/EfRootFolderRepository.cs index 48b2cad0e..6ee058018 100644 --- a/listenarr.infrastructure/Persistence/Repositories/EfRootFolderRepository.cs +++ b/listenarr.infrastructure/Persistence/Repositories/EfRootFolderRepository.cs @@ -179,10 +179,18 @@ private async Task MutateDefaultAsync( } await mutation(ctx, ct); - await ctx.SaveChangesAsync(ct); - if (transaction != null) + if (transaction == null) + { + var completionToken = + RequestCancellationBoundary.EnterNonCancelablePhase(ct); + await ctx.SaveChangesAsync(completionToken); + } + else { - await transaction.CommitAsync(ct); + await ctx.SaveChangesAsync(ct); + var completionToken = + RequestCancellationBoundary.EnterNonCancelablePhase(ct); + await transaction.CommitAsync(completionToken); } } diff --git a/tests/Common/MoveJobTestFactory.cs b/tests/Common/MoveJobTestFactory.cs index 361a67b82..ce4c2fe6b 100644 --- a/tests/Common/MoveJobTestFactory.cs +++ b/tests/Common/MoveJobTestFactory.cs @@ -1,7 +1,50 @@ +using Microsoft.EntityFrameworkCore; + namespace Listenarr.Tests.Common; internal static class MoveJobTestFactory { + public static async Task SeedUnresolvedExecutionAsync( + IServiceProvider services, + int audiobookId, + string sourcePath, + string targetPath, + MoveJobStatus status = MoveJobStatus.Failed, + MoveJobPhase phase = MoveJobPhase.Published, + MoveFailureKind failureKind = MoveFailureKind.Unknown) + { + var job = new MoveJob + { + Id = Guid.NewGuid(), + AudiobookId = audiobookId, + SourcePath = Path.GetFullPath(sourcePath), + RequestedPath = Path.GetFullPath(targetPath), + Status = status, + Phase = phase, + FailureKind = failureKind, + Error = "Injected unresolved filesystem execution for regression coverage.", + EnqueuedAt = DateTime.UtcNow, + Entries = + [ + new MoveJobEntry + { + RelativePath = "book.m4b", + EntryType = MoveJobEntryType.File, + Length = 1, + LastWriteTimeUtc = DateTime.UnixEpoch, + Sha256 = new string('A', 64), + CopyState = MoveJobEntryCopyState.Verified, + CleanupState = MoveJobEntryCleanupState.Deleted + } + ] + }; + var factory = services.GetRequiredService>(); + await using var db = await factory.CreateDbContextAsync(); + db.MoveJobs.Add(job); + await db.SaveChangesAsync(); + return job; + } + public static async Task CreateCommandAsync( IServiceProvider services, int audiobookId, @@ -27,11 +70,25 @@ public static async Task CreateCommandAsync( FileSystemCaseSensitivityMode.Auto, sourceResolution.BoundaryPath, sourcePath); + var targetBoundary = FindTargetBoundary( + sourcePath, + targetPath, + sourceResolution.Semantics); var targetIdentity = PathIdentitySnapshot.FromResolution( targetResolution.Semantics, FileSystemCaseSensitivityMode.Auto, - targetResolution.BoundaryPath, + targetBoundary, targetPath); + var directoryIdentityResolver = + services.GetRequiredService(); + var targetDirectoryIdentity = await directoryIdentityResolver.ResolveAsync( + targetBoundary); + if (!targetDirectoryIdentity.IsAvailable) + { + throw new InvalidOperationException( + targetDirectoryIdentity.UnavailableReason + ?? "Move test target boundary identity is unavailable."); + } var manifest = await BuildManifestAsync(sourcePath); await EnsureTrackedRowsAsync( services, @@ -46,10 +103,36 @@ await EnsureTrackedRowsAsync( manifest, targetPath, targetIdentity, + targetDirectoryIdentity.Version!.Value, + targetDirectoryIdentity.Value!, deleteEmptySource, sourceCleanupBoundary); } + private static string FindTargetBoundary( + string sourcePath, + string targetPath, + FileSystemPathSemantics sourceSemantics) + { + var source = Path.GetFullPath(sourcePath); + var current = Path.GetDirectoryName(Path.GetFullPath(targetPath)); + while (!string.IsNullOrWhiteSpace(current)) + { + if (Directory.Exists(current) + && !FileSystemPathIdentity.IsSameOrInside( + current, + source, + sourceSemantics)) + { + return current; + } + current = Path.GetDirectoryName(current); + } + + throw new InvalidOperationException( + "Move test target has no enclosing authorization boundary outside the source tree."); + } + private static async Task EnsureTrackedRowsAsync( IServiceProvider services, int audiobookId, diff --git a/tests/Common/TempFileService.cs b/tests/Common/TempFileService.cs index 44646c03c..929dfee62 100644 --- a/tests/Common/TempFileService.cs +++ b/tests/Common/TempFileService.cs @@ -3,6 +3,7 @@ namespace Listenarr.Tests.Common public class TempFileService : IAsyncLifetime { private string? _tempFolder = null; + private readonly List _additionalTempFolders = []; public TempFileService() { @@ -16,16 +17,14 @@ public async Task InitializeAsync() public async Task DisposeAsync() { - if (_tempFolder != null && Directory.Exists(_tempFolder)) + if (_tempFolder != null) { - try - { - Directory.Delete(_tempFolder, true); - } - catch (IOException) - { - // FIXME: Folder is probably not deleted - } + TryDeleteTempFolder(_tempFolder); + } + + foreach (var additionalTempFolder in _additionalTempFolders) + { + TryDeleteTempFolder(additionalTempFolder); } } @@ -53,7 +52,8 @@ public string GetTempDirectory(string directory) public async Task GetFileAsync(string directory, string filename, string content = "test") { - if (!directory.StartsWith(GetTempPath())) + if (!Path.IsPathFullyQualified(directory) + && !directory.StartsWith(GetTempPath(), StringComparison.Ordinal)) { directory = Path.Join(GetTempPath(), directory); } @@ -68,5 +68,40 @@ public async Task GetTempFileAsync(string filename) { return await GetFileAsync(GetTempPath(), filename); } + + public string GetWindowsRootRelativeTempPath(string name) + { + var target = WindowsPathTestFixture + .GetRootRelativeAliasCompatiblePath(name); + _additionalTempFolders.Add(target); + return target; + } + + public string GetWindowsRootRelativeTempDirectory(string directory) + { + var target = GetWindowsRootRelativeTempPath(directory); + Directory.CreateDirectory(target); + return target; + } + + public static string GetWindowsRootRelativeForeignAlias(string nativePath) => + WindowsPathTestFixture.GetRootRelativeForeignAlias(nativePath); + + private static void TryDeleteTempFolder(string directory) + { + if (!Directory.Exists(directory)) + { + return; + } + + try + { + Directory.Delete(directory, true); + } + catch (IOException) + { + // FIXME: Folder is probably not deleted + } + } } } diff --git a/tests/Common/WindowsPathTestFixture.cs b/tests/Common/WindowsPathTestFixture.cs new file mode 100644 index 000000000..46022f816 --- /dev/null +++ b/tests/Common/WindowsPathTestFixture.cs @@ -0,0 +1,77 @@ +namespace Listenarr.Tests.Common; + +public static class WindowsPathTestFixture +{ + public static string CreateRootRelativeAliasCompatibleDirectory(string name) + { + var directory = GetRootRelativeAliasCompatiblePath(name); + Directory.CreateDirectory(directory); + return directory; + } + + public static string GetRootRelativeAliasCompatiblePath(string name) + { + if (!OperatingSystem.IsWindows()) + { + throw new PlatformNotSupportedException( + "The root-relative Windows path fixture is available only on Windows."); + } + + var rootRelativeDrive = Path.GetFullPath( + Path.DirectorySeparatorChar.ToString()); + var fixtureBase = Path.GetFullPath(AppContext.BaseDirectory); + if (!HasSameDrive(fixtureBase, rootRelativeDrive)) + { + fixtureBase = Path.GetFullPath(Environment.CurrentDirectory); + } + + if (!HasSameDrive(fixtureBase, rootRelativeDrive)) + { + throw new InvalidOperationException( + "No test location was found on the drive used by Windows root-relative path resolution."); + } + + var safeName = Path.GetFileName(name); + if (string.IsNullOrWhiteSpace(safeName)) + { + safeName = "fixture"; + } + + return Path.Join( + fixtureBase, + $".listenarr-root-relative-{safeName}-{Guid.NewGuid():N}"); + } + + public static string GetRootRelativeForeignAlias(string nativePath) + { + if (!OperatingSystem.IsWindows()) + { + throw new PlatformNotSupportedException( + "The root-relative Windows path fixture is available only on Windows."); + } + + var nativeFullPath = Path.GetFullPath(nativePath); + var driveRoot = Path.GetPathRoot(nativeFullPath) + ?? throw new InvalidOperationException( + "The native Windows fixture path has no drive root."); + var foreignPath = "/" + nativeFullPath[driveRoot.Length..] + .Replace('\\', '/'); + var resolvedForeignPath = Path.GetFullPath(foreignPath); + if (!string.Equals( + nativeFullPath, + resolvedForeignPath, + StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"The Windows foreign-path fixture is invalid: native '{nativeFullPath}' resolves separately from '{resolvedForeignPath}'."); + } + + return foreignPath; + } + + private static bool HasSameDrive(string left, string right) => + string.Equals( + Path.GetPathRoot(left), + Path.GetPathRoot(right), + StringComparison.OrdinalIgnoreCase); +} diff --git a/tests/Features/Api/Features/Configuration/ConfigurationControllerSettingsTests.cs b/tests/Features/Api/Features/Configuration/ConfigurationControllerSettingsTests.cs index e1220efc6..54f3b6e2a 100644 --- a/tests/Features/Api/Features/Configuration/ConfigurationControllerSettingsTests.cs +++ b/tests/Features/Api/Features/Configuration/ConfigurationControllerSettingsTests.cs @@ -15,13 +15,86 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ +using Listenarr.Application.Common.Exceptions; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging.Abstractions; +using System.Text.Json; namespace Listenarr.Tests.Features.Api.Features.Configuration { public class ConfigurationControllerSettingsTests { + [Fact] + public async Task SaveApplicationSettings_MissingVersion_ReturnsStableConflictWithoutBroadcast() + { + var configurationService = new Mock(MockBehavior.Strict); + configurationService + .Setup(service => service.SaveApplicationSettingsAsync( + It.Is(settings => settings.Version == 0))) + .ThrowsAsync(new ApplicationConflictException( + "settings_concurrency_conflict", + "Application settings must include the current version. Reload and try again.")); + var broadcaster = new Mock(MockBehavior.Strict); + var controller = new SettingsController( + configurationService.Object, + NullLogger.Instance, + broadcaster.Object); + + var result = await controller.SaveApplicationSettings( + new ApplicationSettings { Version = 0 }); + + var conflict = Assert.IsType(result.Result); + var payload = JsonSerializer.SerializeToElement(conflict.Value); + Assert.Equal( + "settings_concurrency_conflict", + payload.GetProperty("code").GetString()); + Assert.Equal( + "Application settings must include the current version. Reload and try again.", + payload.GetProperty("message").GetString()); + configurationService.Verify(service => service.SaveApplicationSettingsAsync( + It.IsAny()), Times.Once); + configurationService.VerifyNoOtherCalls(); + broadcaster.VerifyNoOtherCalls(); + } + + [Fact] + public async Task SaveApplicationSettings_UsesCommittedPayloadWithoutPostCommitRead() + { + var configurationService = new Mock(MockBehavior.Strict); + configurationService + .Setup(service => service.SaveApplicationSettingsAsync(It.IsAny())) + .Callback(settings => settings.Version = 8) + .Returns(Task.CompletedTask); + var broadcaster = new Mock(MockBehavior.Strict); + broadcaster.Setup(candidate => candidate.BroadcastAsync( + RealtimeHubTarget.Settings, + "SettingsUpdated", + It.IsAny(), + It.IsAny())) + .Returns(Task.CompletedTask); + var controller = new SettingsController( + configurationService.Object, + NullLogger.Instance, + broadcaster.Object); + + var result = await controller.SaveApplicationSettings( + new ApplicationSettings { Version = 7, OutputPath = "library" }); + + var ok = Assert.IsType(result.Result); + var saved = Assert.IsType(ok.Value); + Assert.Equal(8, saved.Version); + Assert.Equal("library", saved.OutputPath); + configurationService.Verify(candidate => candidate.SaveApplicationSettingsAsync( + It.IsAny()), Times.Once); + configurationService.VerifyNoOtherCalls(); + broadcaster.Verify(candidate => candidate.BroadcastAsync( + RealtimeHubTarget.Settings, + "SettingsUpdated", + It.IsAny(), + It.IsAny()), Times.Once); + broadcaster.VerifyNoOtherCalls(); + } + [Fact] public async Task GetApplicationSettings_DoesNotReturnEncryptedProwlarrApiKey() { diff --git a/tests/Features/Api/Features/Downloads/ManualImportControllerTests.cs b/tests/Features/Api/Features/Downloads/ManualImportControllerTests.cs index a250c8ef9..1fef3c85f 100644 --- a/tests/Features/Api/Features/Downloads/ManualImportControllerTests.cs +++ b/tests/Features/Api/Features/Downloads/ManualImportControllerTests.cs @@ -17,6 +17,7 @@ */ using Microsoft.Extensions.Logging.Abstractions; using Listenarr.Api.Dtos.ManualImport; +using Listenarr.Application.Common.Exceptions; using Listenarr.Tests.Common; namespace Listenarr.Tests.Features.Api.Features.Downloads @@ -66,14 +67,13 @@ private String CreateTempDirectory(string name) [WindowsFact] public async Task GeneratePathAsync_ForeignConfiguredOutputAlias_DoesNotReclassifyCustomBasePath() { - var broadRoot = CreateTempDirectory("manual-import-foreign-output-root"); + var broadRoot = WindowsPathTestFixture + .CreateRootRelativeAliasCompatibleDirectory( + "manual-import-foreign-output-root"); + _tempDirectories.Add(broadRoot); var customBasePath = Path.Join(broadRoot, "Custom Book Folder"); - var driveRoot = Path.GetPathRoot(customBasePath)!; - var foreignOutputPath = "/" + customBasePath[driveRoot.Length..].Replace('\\', '/'); - Assert.Equal( - Path.GetFullPath(customBasePath), - Path.GetFullPath(foreignOutputPath), - StringComparer.OrdinalIgnoreCase); + var foreignOutputPath = WindowsPathTestFixture + .GetRootRelativeForeignAlias(customBasePath); var settings = new ApplicationSettings { @@ -142,7 +142,8 @@ public ManualImportController GetController( IFileSystemSemanticsResolver semanticsResolver = null, IFilesystemMutationCoordinator filesystemMutationCoordinator = null, ILibraryDirectoryOwnershipStore directoryOwnershipStore = null, - Mock metadataMock = null) + Mock metadataMock = null, + IMoveQueueService moveQueueServiceOverride = null) { repoMock ??= GetRepoMock(book); scanMock ??= GetScanMock(); @@ -228,7 +229,9 @@ public ManualImportController GetController( .ReturnsAsync(new AudioMetadata { Title = "Different Book", Album = "Different Book", Artist = "Author A", Format = "mp3" }); metadataMock.Setup(m => m.ExtractFileMetadataAsync(It.Is(path => path.EndsWith("Track 01.mp3", StringComparison.OrdinalIgnoreCase)))) .ReturnsAsync(new AudioMetadata { Title = "Companion Book", Format = "mp3", BitRate = 128000 }); - metadataMock.Setup(m => m.WriteAsinTagAsync(It.IsAny(), It.IsAny())) + metadataMock.Setup(m => m.WriteAsinTagAsync( + It.IsAny(), + It.IsAny())) .Returns(Task.CompletedTask); var configMock = new Mock(); @@ -282,10 +285,19 @@ public ManualImportController GetController( It.IsAny(), It.IsAny(), It.IsAny())) + .Callback( + (destinationDirectory, _, _, _, _, _, _) => + Directory.CreateDirectory(destinationDirectory)) .ReturnsAsync([]); directoryOwnershipStore = directoryOwnershipStoreMock.Object; } + var moveQueueService = new Mock(); + moveQueueService.Setup(service => service.EnsureFilesystemMutationAllowedAsync( + It.IsAny(), + It.IsAny())) + .Returns(Task.CompletedTask); + return new ManualImportController( Mock.Of>(), repoMock.Object, @@ -301,10 +313,61 @@ public ManualImportController GetController( semanticsResolver, filesystemMutationCoordinator ?? new FilesystemMutationCoordinator(), _operationCoordinator, + moveQueueServiceOverride ?? moveQueueService.Object, directoryOwnershipStore ); } + [Fact] + public async Task Start_UnresolvedMoveExecution_BlocksBeforeFilesystemImport() + { + var basePath = CreateTempDirectory("listenarr-manual-unresolved-destination"); + var sourceDirectory = CreateTempDirectory("listenarr-manual-unresolved-source"); + var sourceFile = Path.Join(sourceDirectory, "chapter.mp3"); + await File.WriteAllTextAsync(sourceFile, "audio"); + var book = new Audiobook + { + Id = 40, + Title = "Unresolved Manual Import", + BasePath = basePath + }; + var moveQueue = new Mock(MockBehavior.Strict); + moveQueue.Setup(service => service.EnsureFilesystemMutationAllowedAsync( + book.Id, + It.IsAny())) + .ThrowsAsync(new ApplicationConflictException( + "move_recovery_required", + "An interrupted move still owns this audiobook's filesystem state.")); + var fileMover = new Mock(MockBehavior.Strict); + var controller = GetController( + book, + new ApplicationSettings { OutputPath = basePath }, + fileMover: fileMover.Object, + moveQueueServiceOverride: moveQueue.Object); + var request = new ManualImportRequestDto + { + Path = sourceDirectory, + Mode = "interactive", + Action = FileAction.Copy, + Items = + [ + new ManualImportItemDto + { + FullPath = sourceFile, + MatchedAudiobookId = book.Id + } + ] + }; + + var result = await controller.Start(request); + + var conflict = Assert.IsType(result.Result); + var payload = System.Text.Json.JsonSerializer.Serialize(conflict.Value); + Assert.Contains("move_recovery_required", payload, StringComparison.Ordinal); + Assert.True(File.Exists(sourceFile)); + fileMover.VerifyNoOtherCalls(); + } + [Fact] public async Task Start_CanceledWhileWaitingForFilesystemMutation_DoesNotImport() { @@ -1036,7 +1099,7 @@ public async Task InteractiveManualImport_AsinTagFailureAfterMove_RemainsSuccess BitRate = 128000 }); metadata.Setup(service => service.WriteAsinTagAsync( - It.IsAny(), + It.IsAny(), book.Asin)) .ThrowsAsync(new IOException("simulated tag failure")); var controller = GetController( @@ -1083,8 +1146,11 @@ public async Task InteractiveManualImport_AsinTagFailureAfterMove_RemainsSuccess await File.ReadAllTextAsync( Path.Join(destinationRoot, "Tagged Book.mp3"))); metadata.Verify(service => service.WriteAsinTagAsync( - Path.Join(destinationRoot, "Tagged Book.mp3"), + It.IsAny(), book.Asin), Times.Once); + metadata.Verify(service => service.WriteAsinTagAsync( + It.IsAny(), + It.IsAny()), Times.Never); } [Fact] @@ -1131,7 +1197,7 @@ public async Task InteractiveManualImport_CompanionPass_SkipsDifferentAudiobookA } [Fact] - public async Task InteractiveManualImport_RequestCancelledAfterFileMutationStillQueuesFocusedScan() + public async Task InteractiveManualImport_RequestCancelledAfterCommittedMutationReturnsCommittedResultAndQueuesFocusedScan() { var basePath = CreateTempDirectory("listenarr-manual-post-mutation-cancel-dst"); var srcDir = CreateTempDirectory("listenarr-manual-post-mutation-cancel-src"); @@ -1189,9 +1255,26 @@ public async Task InteractiveManualImport_RequestCancelledAfterFileMutationStill ] }; - await Assert.ThrowsAnyAsync(() => - controller.Start(request, cancellation.Token)); + var action = await controller.Start(request, cancellation.Token); + var ok = Assert.IsType( + action.Result); + Assert.NotNull(ok.Value); + var payload = ok.Value!; + Assert.Equal( + 1, + Assert.IsType(payload.GetType() + .GetProperty("importedCount")! + .GetValue(payload))); + Assert.Equal( + 1, + Assert.IsType(payload.GetType() + .GetProperty("totalCount")! + .GetValue(payload))); + Assert.True( + Assert.IsType(payload.GetType() + .GetProperty("stoppedByCancellation")! + .GetValue(payload))); Assert.True(File.Exists(Path.Join( basePath, "Post Mutation Cancellation.mp3"))); @@ -1204,6 +1287,219 @@ await Assert.ThrowsAnyAsync(() => && !command.IsAuthoritativeScope)), Times.Once); } + [Fact] + public async Task InteractiveManualImport_FocusedScanCancellationAfterCommittedMutation_ReturnsCommittedResult() + { + var basePath = CreateTempDirectory("listenarr-manual-scan-cancel-dst"); + var srcDir = CreateTempDirectory("listenarr-manual-scan-cancel-src"); + var source = Path.Join(srcDir, "book.mp3"); + await File.WriteAllTextAsync(source, "audio"); + var book = new Audiobook + { + Id = 504, + Title = "Post Commit Scan Cancellation", + BasePath = basePath + }; + var scanMock = GetScanMock(); + scanMock.Setup(service => service.EnqueueScanAsync( + It.IsAny())) + .ThrowsAsync(new TaskCanceledException( + "Injected post-commit focused scan cancellation.")); + var controller = GetController( + book, + new ApplicationSettings + { + OutputPath = basePath, + FolderNamingPattern = "", + FileNamingPattern = "{Title}" + }, + scanMock: scanMock); + var request = new ManualImportRequestDto + { + Path = srcDir, + Mode = "interactive", + Action = FileAction.Copy, + Items = + [ + new ManualImportItemDto + { + FullPath = source, + MatchedAudiobookId = book.Id + } + ] + }; + + var action = await controller.Start(request); + + var ok = Assert.IsType( + action.Result); + Assert.NotNull(ok.Value); + var payload = ok.Value!; + Assert.Equal( + 1, + Assert.IsType(payload.GetType() + .GetProperty("importedCount")! + .GetValue(payload))); + Assert.True(File.Exists(Path.Join( + basePath, + "Post Commit Scan Cancellation.mp3"))); + scanMock.Verify(service => service.EnqueueScanAsync( + It.Is(command => + command.Audiobook.Id == book.Id + && !command.IsAuthoritativeScope)), Times.Once); + } + + [Fact] + public async Task InteractiveManualImport_RequestCancelledAfterFirstCommittedItem_ReturnsPartialCommitAndDoesNotStartSecondItem() + { + var basePath = CreateTempDirectory("listenarr-manual-partial-cancel-dst"); + var srcDir = CreateTempDirectory("listenarr-manual-partial-cancel-src"); + var firstSource = Path.Join(srcDir, "first.mp3"); + var secondSource = Path.Join(srcDir, "second.mp3"); + await File.WriteAllTextAsync(firstSource, "first-audio"); + await File.WriteAllTextAsync(secondSource, "second-audio"); + var book = new Audiobook + { + Id = 502, + Title = "Partial Cancellation", + BasePath = basePath + }; + using var cancellation = new CancellationTokenSource(); + var actualMover = new FileMover( + NullLogger.Instance, + semanticsResolver: new FileSystemSemanticsResolver()); + var prepareCount = 0; + var fileMover = new Mock(MockBehavior.Strict); + fileMover.Setup(mover => mover.PrepareActionForRegistrationAsync( + FileAction.Copy, + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(async (action, sourcePath, destination, operationId) => + { + prepareCount++; + return await actualMover.PrepareActionForRegistrationAsync( + action, + sourcePath, + destination, + operationId); + }); + var fileService = new Mock(MockBehavior.Strict); + fileService.Setup(service => service.CheckAudiobookFileOwnershipAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new AudiobookFileOwnershipCheckResult( + AudiobookFileOwnershipCheckOutcome.Available)); + var registrationCount = 0; + fileService.Setup(service => service.RegisterPublishedGenerationWithBasePathAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(( + Audiobook audiobook, + AudiobookFileOwnershipCheckResult _, + IAudiobookFileRegistrationLease registrationLease, + string authoritativeBasePath, + string? _, + CancellationToken _) => + { + registrationCount++; + if (!registrationLease.PrepareCleanupRecovery(audiobook.Id)) + { + return false; + } + + audiobook.BasePath = authoritativeBasePath; + if (registrationCount == 1) + { + cancellation.Cancel(); + } + return true; + }); + var metadata = new Mock(); + metadata.Setup(service => service.ExtractFileMetadataAsync(firstSource)) + .ReturnsAsync(new AudioMetadata + { + Title = book.Title, + Format = "mp3", + TrackNumber = 1 + }); + metadata.Setup(service => service.ExtractFileMetadataAsync(secondSource)) + .ReturnsAsync(new AudioMetadata + { + Title = book.Title, + Format = "mp3", + TrackNumber = 2 + }); + var scanMock = GetScanMock(); + var controller = GetController( + book, + new ApplicationSettings + { + OutputPath = basePath, + FolderNamingPattern = "", + FileNamingPattern = "{Title}", + MultiFileNamingPattern = "{Title} - {ChapterNumber}" + }, + scanMock: scanMock, + fileMover: fileMover.Object, + audiobookFileService: fileService.Object, + metadataMock: metadata); + var request = new ManualImportRequestDto + { + Path = srcDir, + Mode = "interactive", + Action = FileAction.Copy, + Items = + [ + new ManualImportItemDto + { + FullPath = firstSource, + MatchedAudiobookId = book.Id + }, + new ManualImportItemDto + { + FullPath = secondSource, + MatchedAudiobookId = book.Id + } + ] + }; + + var action = await controller.Start(request, cancellation.Token); + + var ok = Assert.IsType( + action.Result); + Assert.NotNull(ok.Value); + var payload = ok.Value!; + Assert.Equal( + 1, + Assert.IsType(payload.GetType() + .GetProperty("importedCount")! + .GetValue(payload))); + Assert.Equal( + 2, + Assert.IsType(payload.GetType() + .GetProperty("totalCount")! + .GetValue(payload))); + Assert.True( + Assert.IsType(payload.GetType() + .GetProperty("stoppedByCancellation")! + .GetValue(payload))); + Assert.Equal(1, prepareCount); + Assert.Equal(1, registrationCount); + Assert.Equal("second-audio", await File.ReadAllTextAsync(secondSource)); + scanMock.Verify(service => service.EnqueueScanAsync( + It.Is(command => + command.Audiobook.Id == book.Id + && command.Path == basePath + && !command.IsAuthoritativeScope)), Times.Once); + } + [Fact] public async Task InteractiveManualImport_HardlinkPublicationInterrupted_RetryUsesSameDestination() { diff --git a/tests/Features/Api/Features/Library/LibraryBulkDeleteCancellationTests.cs b/tests/Features/Api/Features/Library/LibraryBulkDeleteCancellationTests.cs new file mode 100644 index 000000000..b24a85098 --- /dev/null +++ b/tests/Features/Api/Features/Library/LibraryBulkDeleteCancellationTests.cs @@ -0,0 +1,229 @@ +using Listenarr.Tests.Common; +using Microsoft.AspNetCore.Mvc; + +namespace Listenarr.Tests.Features.Api.Features.Library; + +[Trait("Area", "LibraryApi")] +[Trait("Name", "LibraryBulkDeleteCancellationTests")] +[Trait("Category", "LibraryController")] +public sealed class LibraryBulkDeleteCancellationTests : BaseTests +{ + [Fact] + public async Task BulkDelete_RequestCanceledWhilePreflightCompletes_DoesNotCommit() + { + // Given + const int audiobookId = 4201; + var audiobook = new Audiobook + { + Id = audiobookId, + Title = "Cancelable bulk delete" + }; + var preflightStarted = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var releasePreflight = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var repository = new Mock(MockBehavior.Strict); + repository.Setup(service => service.GetByIdSnapshotAsync( + audiobookId, + It.IsAny())) + .Returns(async () => + { + preflightStarted.SetResult(); + return await releasePreflight.Task; + }); + var imageCache = new Mock(MockBehavior.Strict); + var history = new Mock(MockBehavior.Strict); + var fileSystem = new Mock(MockBehavior.Strict); + Init(services => services + .WithSingleton(repository.Object) + .WithSingleton(imageCache.Object) + .WithSingleton(history.Object) + .WithSingleton(fileSystem.Object)); + using var cancellation = new CancellationTokenSource(); + var controller = _provider.GetRequiredService(); + + // When + var deletion = controller.BulkDeleteAudiobooks( + new LibraryController.BulkDeleteRequest { Ids = [audiobookId] }, + cancellation.Token); + await preflightStarted.Task; + cancellation.Cancel(); + releasePreflight.SetResult(audiobook); + + // Then + await Assert.ThrowsAnyAsync(() => deletion); + repository.Verify(service => service.DeleteByIdAsync(audiobookId), Times.Never); + imageCache.VerifyNoOtherCalls(); + history.VerifyNoOtherCalls(); + fileSystem.VerifyNoOtherCalls(); + } + + [Fact] + public async Task BulkDelete_RequestCanceledAfterCommitBoundary_RemainsSuccessful() + { + // Given + const int audiobookId = 4203; + var audiobook = new Audiobook + { + Id = audiobookId, + Title = "Committed bulk delete cancellation" + }; + using var cancellation = new CancellationTokenSource(); + var repository = new Mock(MockBehavior.Strict); + repository.Setup(service => service.GetByIdSnapshotAsync( + audiobookId, + cancellation.Token)) + .ReturnsAsync(audiobook); + repository.Setup(service => service.DeleteByIdAsync(audiobookId)) + .Returns(() => + { + cancellation.Cancel(); + return Task.FromResult(true); + }); + var imageCache = new Mock(MockBehavior.Strict); + var history = new Mock(MockBehavior.Strict); + history.Setup(service => service.AddAsync( + It.Is(entry => + entry.AudiobookId == audiobookId + && entry.EventType == "Deleted"), + It.IsAny())) + .ReturnsAsync((History entry, CancellationToken _) => entry); + var fileSystem = new Mock(MockBehavior.Strict); + Init(services => services + .WithSingleton(repository.Object) + .WithSingleton(imageCache.Object) + .WithSingleton(history.Object) + .WithSingleton(fileSystem.Object)); + + // When + var result = await _provider.GetRequiredService() + .BulkDeleteAudiobooks( + new LibraryController.BulkDeleteRequest { Ids = [audiobookId] }, + cancellation.Token); + + // Then + Assert.IsType(result); + Assert.True(cancellation.IsCancellationRequested); + repository.Verify(service => service.DeleteByIdAsync(audiobookId), Times.Once); + history.Verify(service => service.AddAsync( + It.Is(entry => entry.AudiobookId == audiobookId), + It.IsAny()), Times.Once); + imageCache.VerifyNoOtherCalls(); + fileSystem.VerifyNoOtherCalls(); + } + + [Fact] + public async Task BulkDelete_RequestCanceledAfterFirstCommit_ReturnsCommittedPartialResultAndDoesNotDeleteNext() + { + // Given + const int firstId = 4204; + const int secondId = 4205; + var first = new Audiobook + { + Id = firstId, + Title = "Committed first bulk delete" + }; + using var cancellation = new CancellationTokenSource(); + var repository = new Mock(MockBehavior.Strict); + repository.Setup(service => service.GetByIdSnapshotAsync( + firstId, + cancellation.Token)) + .ReturnsAsync(first); + repository.Setup(service => service.DeleteByIdAsync(firstId)) + .Returns(() => + { + cancellation.Cancel(); + return Task.FromResult(true); + }); + var imageCache = new Mock(MockBehavior.Strict); + var history = new Mock(MockBehavior.Strict); + history.Setup(service => service.AddAsync( + It.Is(entry => + entry.AudiobookId == firstId + && entry.EventType == "Deleted"), + It.IsAny())) + .ReturnsAsync((History entry, CancellationToken _) => entry); + var fileSystem = new Mock(MockBehavior.Strict); + Init(services => services + .WithSingleton(repository.Object) + .WithSingleton(imageCache.Object) + .WithSingleton(history.Object) + .WithSingleton(fileSystem.Object)); + + // When + var result = await _provider.GetRequiredService() + .BulkDeleteAudiobooks( + new LibraryController.BulkDeleteRequest + { + Ids = [firstId, secondId] + }, + cancellation.Token); + + // Then + var ok = Assert.IsType(result); + var payload = ok.Value!; + Assert.Equal( + 1, + Assert.IsType(payload.GetType().GetProperty("deletedCount")!.GetValue(payload))); + var deletedIds = Assert.IsAssignableFrom>( + payload.GetType().GetProperty("ids")!.GetValue(payload)); + Assert.Equal([firstId], deletedIds); + repository.Verify(service => service.DeleteByIdAsync(firstId), Times.Once); + repository.Verify(service => service.GetByIdSnapshotAsync( + secondId, + It.IsAny()), Times.Never); + repository.Verify(service => service.DeleteByIdAsync(secondId), Times.Never); + } + + [Fact] + public async Task BulkDelete_CanceledImageCleanupAfterCommit_RemainsSuccessful() + { + // Given + const int audiobookId = 4202; + var audiobook = new Audiobook + { + Id = audiobookId, + Title = "Committed bulk delete", + Asin = "BULKCANCEL" + }; + var repository = new Mock(MockBehavior.Strict); + repository.Setup(service => service.GetByIdSnapshotAsync( + audiobookId, + It.IsAny())) + .ReturnsAsync(audiobook); + repository.Setup(service => service.DeleteByIdAsync(audiobookId)) + .ReturnsAsync(true); + var imageCache = new Mock(MockBehavior.Strict); + imageCache.Setup(service => service.GetCachedImagePathAsync(audiobook.Asin)) + .ThrowsAsync(new TaskCanceledException("Injected post-commit cleanup cancellation.")); + var history = new Mock(MockBehavior.Strict); + history.Setup(service => service.AddAsync( + It.Is(entry => + entry.AudiobookId == audiobookId + && entry.EventType == "Deleted"), + It.IsAny())) + .ReturnsAsync((History entry, CancellationToken _) => entry); + var fileSystem = new Mock(MockBehavior.Strict); + Init(services => services + .WithSingleton(repository.Object) + .WithSingleton(imageCache.Object) + .WithSingleton(history.Object) + .WithSingleton(fileSystem.Object)); + + // When + var result = await _provider.GetRequiredService() + .BulkDeleteAudiobooks(new LibraryController.BulkDeleteRequest + { + Ids = [audiobookId] + }); + + // Then + Assert.IsType(result); + repository.Verify(service => service.DeleteByIdAsync(audiobookId), Times.Once); + imageCache.Verify(service => service.GetCachedImagePathAsync(audiobook.Asin), Times.Once); + history.Verify(service => service.AddAsync( + It.Is(entry => entry.AudiobookId == audiobookId), + It.IsAny()), Times.Once); + fileSystem.VerifyNoOtherCalls(); + } +} diff --git a/tests/Features/Api/Features/Library/LibraryController_AddToLibraryTests.cs b/tests/Features/Api/Features/Library/LibraryController_AddToLibraryTests.cs index ff5fdbf52..a9c4e8a80 100644 --- a/tests/Features/Api/Features/Library/LibraryController_AddToLibraryTests.cs +++ b/tests/Features/Api/Features/Library/LibraryController_AddToLibraryTests.cs @@ -444,10 +444,12 @@ await _audiobookRepository.GetAllAsync(), [Fact] public async Task AddToLibrary_WithGeneratedPathFromSanitizedMetadata_Succeeds() { - await _applicationSettingsRepository.SaveAsync(new ApplicationSettingsBuilder() - .WithFolderNamingPattern("{Author}/{Title}") - .WithFileNamingPattern("{Title}") - .Build()); + var settings = await _applicationSettingsRepository.GetAsync() + ?? await _applicationSettingsRepository.InitializeIfMissingAsync( + new ApplicationSettingsBuilder().Build()); + settings.FolderNamingPattern = "{Author}/{Title}"; + settings.FileNamingPattern = "{Title}"; + await _applicationSettingsRepository.SaveAsync(settings); var controller = _provider.GetRequiredService(); var request = new LibraryController.AddToLibraryRequest diff --git a/tests/Features/Api/Features/Library/LibraryController_BulkUpdateTests.cs b/tests/Features/Api/Features/Library/LibraryController_BulkUpdateTests.cs index 9a41ea3a3..674402cbc 100644 --- a/tests/Features/Api/Features/Library/LibraryController_BulkUpdateTests.cs +++ b/tests/Features/Api/Features/Library/LibraryController_BulkUpdateTests.cs @@ -19,6 +19,7 @@ using Listenarr.Tests.Builders; using Listenarr.Tests.Common; using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; namespace Listenarr.Tests.Features.Api.Features.Library { @@ -26,6 +27,47 @@ namespace Listenarr.Tests.Features.Api.Features.Library [Trait("Category", "LibraryController")] public sealed class LibraryController_BulkUpdateTests : BaseTests { + private static Mock CreateMoveQueueMock() + { + var moveQueue = new Mock(); + moveQueue.Setup(service => service.GetRecoveryStateForAudiobookAsync( + It.IsAny(), + It.IsAny())) + .ReturnsAsync(MoveRecoveryState.None); + moveQueue.Setup(service => service.EnsureFilesystemMutationAllowedAsync( + It.IsAny(), + It.IsAny())) + .Returns(Task.CompletedTask); + return moveQueue; + } + + [Fact] + public async Task BulkDelete_UnresolvedMoveExecution_BlocksBeforeCatalogDeletion() + { + Init(); + var source = FileService.GetTempDirectory("bulk-delete-unresolved-source"); + var audiobook = await _audiobookRepository.AddAsync(new AudiobookBuilder() + .WithTitle("Bulk Delete Move Fence") + .WithBasePath(source) + .Build()); + await MoveJobTestFactory.SeedUnresolvedExecutionAsync( + _provider, + audiobook.Id, + source, + Path.Join(FileService.GetTempPath(), $"bulk-delete-target-{Guid.NewGuid():N}")); + + var result = await _provider.GetRequiredService() + .BulkDeleteAudiobooks(new LibraryController.BulkDeleteRequest + { + Ids = [audiobook.Id] + }); + + var badRequest = Assert.IsType(result); + var json = JsonSerializer.Serialize(badRequest.Value); + Assert.Contains("interrupted move", json, StringComparison.OrdinalIgnoreCase); + Assert.NotNull(await _audiobookRepository.GetByIdAsync(audiobook.Id)); + } + [Fact] public async Task BulkDelete_DatabaseFailure_PreservesCachedImageAndDoesNotWriteDeletionHistory() { @@ -36,7 +78,9 @@ public async Task BulkDelete_DatabaseFailure_PreservesCachedImageAndDoesNotWrite Asin = "B000DELETE" }; var repository = new Mock(MockBehavior.Strict); - repository.Setup(service => service.GetByIdAsync(audiobook.Id)) + repository.Setup(service => service.GetByIdSnapshotAsync( + audiobook.Id, + It.IsAny())) .ReturnsAsync(audiobook); repository.Setup(service => service.DeleteByIdAsync(audiobook.Id)) .ReturnsAsync(false); @@ -61,7 +105,9 @@ public async Task BulkDelete_DatabaseFailure_PreservesCachedImageAndDoesNotWrite $"Failed to delete audiobook with ID {audiobook.Id}", json, StringComparison.Ordinal); - repository.Verify(service => service.GetByIdAsync(audiobook.Id), Times.Once); + repository.Verify(service => service.GetByIdSnapshotAsync( + audiobook.Id, + It.IsAny()), Times.Once); repository.Verify(service => service.DeleteByIdAsync(audiobook.Id), Times.Once); repository.VerifyNoOtherCalls(); imageCache.VerifyNoOtherCalls(); @@ -273,12 +319,75 @@ public async Task BulkUpdate_HistoryFailure_DoesNotReverseCommittedMetadataUpdat It.IsAny()), Times.Once); } + [Fact] + public async Task BulkUpdate_PhysicalPathChange_UnresolvedMoveExecution_BlocksBeforeMetadataOrMoveMutation() + { + Init(); + var destinationRoot = FileService.GetTempDirectory("bulk-unresolved-destination"); + await _applicationSettingsRepository.SaveAsync(new ApplicationSettingsBuilder() + .WithOutputPath(destinationRoot) + .WithFileNamingPattern("{Author}/{Title}") + .Build()); + var sourceBasePath = FileService.GetTempDirectory("bulk-unresolved-source"); + var sourceFilePath = await FileService.GetFileAsync(sourceBasePath, "book.m4b", "audio"); + var audiobook = await _audiobookRepository.AddAsync(new Audiobook + { + Title = "Bulk Move Fence", + Authors = ["Physical Author"], + Monitored = false, + BasePath = sourceBasePath, + FilePath = sourceFilePath + }); + await AddTrackedFileAsync(audiobook, sourceFilePath); + var move = await MoveJobTestFactory.SeedUnresolvedExecutionAsync( + _provider, + audiobook.Id, + sourceBasePath, + Path.Join(destinationRoot, "Existing", "Interrupted")); + + var actionResult = await _provider.GetRequiredService() + .BulkUpdateAudiobooks(new LibraryController.BulkUpdateRequest + { + Ids = [audiobook.Id], + Updates = new Dictionary + { + ["monitored"] = true + }, + PathChange = new LibraryController.BulkPathChangeRequest + { + Mode = LibraryController.BulkPathChangeMode.Physical, + DestinationRootOrPath = destinationRoot, + DeleteEmptySource = false + } + }); + + var ok = Assert.IsType(actionResult); + using var document = JsonDocument.Parse(JsonSerializer.Serialize(ok.Value)); + var item = Assert.Single(document.RootElement.GetProperty("results").EnumerateArray()); + Assert.False(item.GetProperty("success").GetBoolean()); + Assert.Contains( + "interrupted move", + item.GetProperty("errors")[0].GetString() ?? string.Empty, + StringComparison.OrdinalIgnoreCase); + var stored = Assert.IsType(await GetFreshAudiobookAsync(audiobook.Id)); + Assert.False(stored.Monitored); + Assert.Equal(sourceBasePath, stored.BasePath); + var factory = _provider.GetRequiredService>(); + await using var verification = await factory.CreateDbContextAsync(); + Assert.Equal( + [move.Id], + await verification.MoveJobs + .Where(job => job.AudiobookId == audiobook.Id) + .Select(job => job.Id) + .ToListAsync()); + } + [Fact] public async Task BulkUpdate_PhysicalPathChange_EnqueuesFromAuthoritativeSourceWithoutRewritingPaths() { MoveEnqueueCommand? captured = null; var jobId = Guid.NewGuid(); - var moveQueue = new Mock(); + var moveQueue = CreateMoveQueueMock(); moveQueue.Setup(service => service.EnqueueMoveAsync( It.IsAny(), It.IsAny())) @@ -352,7 +461,7 @@ public async Task BulkUpdate_PhysicalPathChange_PreservesTrailingSpaceInUnixDest { MoveEnqueueCommand? captured = null; var jobId = Guid.NewGuid(); - var moveQueue = new Mock(); + var moveQueue = CreateMoveQueueMock(); moveQueue.Setup(service => service.EnqueueMoveAsync( It.IsAny(), It.IsAny())) @@ -407,7 +516,7 @@ await _applicationSettingsRepository.SaveAsync(new ApplicationSettingsBuilder() public async Task BulkUpdate_PhysicalPathChange_WithoutMetadata_EnqueuesAndReportsNoMetadataUpdate() { var jobId = Guid.NewGuid(); - var moveQueue = new Mock(); + var moveQueue = CreateMoveQueueMock(); moveQueue.Setup(service => service.EnqueueMoveAsync( It.IsAny(), It.IsAny())) @@ -458,7 +567,7 @@ await _applicationSettingsRepository.SaveAsync(new ApplicationSettingsBuilder() [Fact] public async Task BulkUpdate_PhysicalPathChange_EmptyJobIdFailsClosed() { - var moveQueue = new Mock(); + var moveQueue = CreateMoveQueueMock(); moveQueue.Setup(service => service.EnqueueMoveAsync( It.IsAny(), It.IsAny())) @@ -508,7 +617,7 @@ await _applicationSettingsRepository.SaveAsync(new ApplicationSettingsBuilder() [Fact] public async Task BulkUpdate_PhysicalPathChange_EnqueueFailureIsReturnedPerItem() { - var moveQueue = new Mock(); + var moveQueue = CreateMoveQueueMock(); moveQueue.Setup(service => service.EnqueueMoveAsync( It.IsAny(), It.IsAny())) @@ -615,7 +724,7 @@ await _applicationSettingsRepository.SaveAsync(new ApplicationSettingsBuilder() [Fact] public async Task BulkUpdate_PhysicalPathChange_DoesNotEnqueueWhenRequestedMetadataIsInvalid() { - var moveQueue = new Mock(); + var moveQueue = CreateMoveQueueMock(); moveQueue.Setup(service => service.EnqueueMoveAsync( It.IsAny(), It.IsAny())) diff --git a/tests/Features/Api/Features/Library/LibraryController_DeleteFilesystemTests.cs b/tests/Features/Api/Features/Library/LibraryController_DeleteFilesystemTests.cs index 69e7e21dd..22b7aa97f 100644 --- a/tests/Features/Api/Features/Library/LibraryController_DeleteFilesystemTests.cs +++ b/tests/Features/Api/Features/Library/LibraryController_DeleteFilesystemTests.cs @@ -43,6 +43,61 @@ private async Task AddAuthorizedRootAsync(RootFolder root) await _rootFolderRepository.AddAsync(root); } + private async Task AddTrackedGenerationAsync( + Audiobook audiobook, + string storedPath) + { + var identity = await _provider + .GetRequiredService() + .ResolveAsync(audiobook, storedPath); + Assert.Equal(PathIdentityState.Valid, identity.State); + var file = AudiobookFile.CreateUnresolved(storedPath); + file.AudiobookId = audiobook.Id; + file.ApplyPathIdentity(storedPath, identity); + using (var lease = PinnedAudiobookFileRegistrationLease.Open( + identity.CanonicalPath)) + { + file.ApplyPhysicalObjectIdentity( + lease.PhysicalObjectIdentity, + DateTime.UtcNow); + } + + return await _audiobookFileRepository.AddAsync(file); + } + + [Fact] + public async Task DeleteAudiobook_UnresolvedMoveExecution_BlocksBeforeCatalogDeletion() + { + var source = FileService.GetTempDirectory("delete-unresolved-move-source"); + var target = Path.Join( + FileService.GetTempPath(), + $"delete-unresolved-move-target-{Guid.NewGuid():N}"); + var audiobook = await _audiobookRepository.AddAsync(new AudiobookBuilder() + .WithTitle("Unresolved Move Delete Fence") + .WithBasePath(source) + .Build()); + var move = await MoveJobTestFactory.SeedUnresolvedExecutionAsync( + _provider, + audiobook.Id, + source, + target); + + var result = await _provider.GetRequiredService() + .DeleteAudiobook( + audiobook.Id, + deleteFiles: false, + deleteFolder: false); + + var conflict = Assert.IsType(result); + var payload = System.Text.Json.JsonSerializer.Serialize(conflict.Value); + Assert.Contains("move_recovery_required", payload, StringComparison.Ordinal); + Assert.NotNull(await _audiobookRepository.GetByIdAsync(audiobook.Id)); + Assert.Equal( + move.Id, + (await _provider.GetRequiredService() + .GetRecoveryStateForAudiobookAsync(audiobook.Id)).JobId); + } + [Fact] public async Task DeleteAudiobook_DatabaseFailure_PreservesCachedImage() { @@ -53,7 +108,9 @@ public async Task DeleteAudiobook_DatabaseFailure_PreservesCachedImage() Asin = "B000DELETE" }; var repository = new Mock(MockBehavior.Strict); - repository.Setup(service => service.GetByIdAsync(audiobook.Id)) + repository.Setup(service => service.GetByIdSnapshotAsync( + audiobook.Id, + It.IsAny())) .ReturnsAsync(audiobook); repository.Setup(service => service.DeleteByIdAsync(audiobook.Id)) .ReturnsAsync(false); @@ -75,7 +132,9 @@ public async Task DeleteAudiobook_DatabaseFailure_PreservesCachedImage() var failure = Assert.IsType(result); Assert.Equal(500, failure.StatusCode); - repository.Verify(service => service.GetByIdAsync(audiobook.Id), Times.Once); + repository.Verify(service => service.GetByIdSnapshotAsync( + audiobook.Id, + It.IsAny()), Times.Once); repository.Verify(service => service.DeleteByIdAsync(audiobook.Id), Times.Once); imageCache.VerifyNoOtherCalls(); filesystemDelete.VerifyNoOtherCalls(); @@ -91,7 +150,9 @@ public async Task DeleteAudiobook_DatabaseFailure_DoesNotDeleteFiles() Title = "Delete Files Commit Failure" }; var repository = new Mock(MockBehavior.Strict); - repository.Setup(service => service.GetByIdAsync(audiobook.Id)) + repository.Setup(service => service.GetByIdSnapshotAsync( + audiobook.Id, + It.IsAny())) .ReturnsAsync(audiobook); repository.Setup(service => service.DeleteByIdAsync(audiobook.Id)) .ReturnsAsync(false); @@ -118,6 +179,102 @@ public async Task DeleteAudiobook_DatabaseFailure_DoesNotDeleteFiles() fileSystem.VerifyNoOtherCalls(); } + [Fact] + public async Task DeleteAudiobook_RequestCanceledWhilePreflightCompletes_DoesNotCommit() + { + var audiobook = new Audiobook + { + Id = 9905, + Title = "Cancelable Delete Preflight" + }; + var preflightStarted = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var releasePreflight = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var repository = new Mock(MockBehavior.Strict); + repository.Setup(service => service.GetByIdSnapshotAsync( + audiobook.Id, + It.IsAny())) + .Returns(async () => + { + preflightStarted.SetResult(); + return await releasePreflight.Task; + }); + repository.Setup(service => service.DeleteByIdAsync(audiobook.Id)) + .ReturnsAsync(true); + var imageCache = new Mock(MockBehavior.Strict); + var filesystemDelete = new Mock( + MockBehavior.Strict); + var fileSystem = new Mock(MockBehavior.Strict); + Init(services => services + .WithSingleton(repository.Object) + .WithSingleton(imageCache.Object) + .WithSingleton(filesystemDelete.Object) + .WithSingleton(fileSystem.Object)); + using var cancellation = new CancellationTokenSource(); + + var deletion = _provider.GetRequiredService() + .DeleteAudiobook( + audiobook.Id, + deleteFiles: false, + deleteFolder: false, + cancellation.Token); + await preflightStarted.Task; + cancellation.Cancel(); + releasePreflight.SetResult(audiobook); + + await Assert.ThrowsAnyAsync(() => deletion); + repository.Verify(service => service.DeleteByIdAsync(audiobook.Id), Times.Never); + imageCache.VerifyNoOtherCalls(); + filesystemDelete.VerifyNoOtherCalls(); + fileSystem.VerifyNoOtherCalls(); + } + + [Fact] + public async Task DeleteAudiobook_RequestCanceledAfterCommitBoundary_RemainsSuccessful() + { + var audiobook = new Audiobook + { + Id = 9906, + Title = "Committed Delete Request Cancellation" + }; + using var cancellation = new CancellationTokenSource(); + var repository = new Mock(MockBehavior.Strict); + repository.Setup(service => service.GetByIdSnapshotAsync( + audiobook.Id, + cancellation.Token)) + .ReturnsAsync(audiobook); + repository.Setup(service => service.DeleteByIdAsync(audiobook.Id)) + .Returns(() => + { + cancellation.Cancel(); + return Task.FromResult(true); + }); + var imageCache = new Mock(MockBehavior.Strict); + var filesystemDelete = new Mock( + MockBehavior.Strict); + var fileSystem = new Mock(MockBehavior.Strict); + Init(services => services + .WithSingleton(repository.Object) + .WithSingleton(imageCache.Object) + .WithSingleton(filesystemDelete.Object) + .WithSingleton(fileSystem.Object)); + + var result = await _provider.GetRequiredService() + .DeleteAudiobook( + audiobook.Id, + deleteFiles: false, + deleteFolder: false, + cancellation.Token); + + Assert.IsType(result); + Assert.True(cancellation.IsCancellationRequested); + repository.Verify(service => service.DeleteByIdAsync(audiobook.Id), Times.Once); + imageCache.VerifyNoOtherCalls(); + filesystemDelete.VerifyNoOtherCalls(); + fileSystem.VerifyNoOtherCalls(); + } + [Fact] public async Task DeleteAudiobook_CanceledImageCleanupAfterCommit_RemainsSuccessful() { @@ -128,7 +285,9 @@ public async Task DeleteAudiobook_CanceledImageCleanupAfterCommit_RemainsSuccess Asin = "B000CANCEL" }; var repository = new Mock(MockBehavior.Strict); - repository.Setup(service => service.GetByIdAsync(audiobook.Id)) + repository.Setup(service => service.GetByIdSnapshotAsync( + audiobook.Id, + It.IsAny())) .ReturnsAsync(audiobook); repository.Setup(service => service.DeleteByIdAsync(audiobook.Id)) .ReturnsAsync(true); @@ -167,7 +326,9 @@ public async Task DeleteAudiobook_FilesystemFailureAfterCommit_ReturnsSuccessWit }; var deleteCommitted = false; var repository = new Mock(MockBehavior.Strict); - repository.Setup(service => service.GetByIdAsync(audiobook.Id)) + repository.Setup(service => service.GetByIdSnapshotAsync( + audiobook.Id, + It.IsAny())) .ReturnsAsync(audiobook); repository.Setup(service => service.DeleteByIdAsync(audiobook.Id)) .ReturnsAsync(() => @@ -242,10 +403,7 @@ await AddAuthorizedRootAsync(new RootFolderBuilder() .WithFilePath(audioPath) .Build()); - await _audiobookFileRepository.AddAsync(new AudiobookFileBuilder() - .WithAudiobook(audiobook) - .WithPath(audioPath) - .Build()); + await AddTrackedGenerationAsync(audiobook, audioPath); var controller = _provider.GetRequiredService(); @@ -265,17 +423,13 @@ await _audiobookFileRepository.AddAsync(new AudiobookFileBuilder() [WindowsFact] public async Task DeleteAudiobook_ForeignTrackedPathUnderProtectedRoot_PreservesWindowsAlias() { - var tempRoot = FileService.GetTempDirectory("listenarr-delete-foreign-tracked"); + var tempRoot = FileService.GetWindowsRootRelativeTempDirectory("listenarr-delete-foreign-tracked"); var bookFolder = Path.Join(tempRoot, "Foreign Book"); var audioPath = Path.Join(bookFolder, "track.m4b"); Directory.CreateDirectory(bookFolder); await File.WriteAllTextAsync(audioPath, "audio"); - var driveRoot = Path.GetPathRoot(audioPath)!; - var foreignAudioPath = "/" + audioPath[driveRoot.Length..].Replace('\\', '/'); - Assert.Equal( - Path.GetFullPath(audioPath), - Path.GetFullPath(foreignAudioPath), - StringComparer.OrdinalIgnoreCase); + var foreignAudioPath = TempFileService + .GetWindowsRootRelativeForeignAlias(audioPath); await AddAuthorizedRootAsync(new RootFolderBuilder() .WithId(500) .WithPath(tempRoot) @@ -304,17 +458,13 @@ await _audiobookFileRepository.AddAsync(new AudiobookFileBuilder() [WindowsFact] public async Task DeleteAudiobook_ForeignTrackedPathUnderBookFolder_DoesNotDeleteAliasedContent() { - var tempRoot = FileService.GetTempDirectory("listenarr-delete-foreign-book-folder"); + var tempRoot = FileService.GetWindowsRootRelativeTempDirectory("listenarr-delete-foreign-book-folder"); var bookFolder = Path.Join(tempRoot, "Foreign Book Folder"); var audioPath = Path.Join(bookFolder, "track.m4b"); Directory.CreateDirectory(bookFolder); await File.WriteAllTextAsync(audioPath, "audio"); - var driveRoot = Path.GetPathRoot(audioPath)!; - var foreignAudioPath = "/" + audioPath[driveRoot.Length..].Replace('\\', '/'); - Assert.Equal( - Path.GetFullPath(audioPath), - Path.GetFullPath(foreignAudioPath), - StringComparer.OrdinalIgnoreCase); + var foreignAudioPath = TempFileService + .GetWindowsRootRelativeForeignAlias(audioPath); await AddAuthorizedRootAsync(new RootFolderBuilder() .WithId(504) .WithPath(tempRoot) @@ -348,19 +498,15 @@ await _audiobookFileRepository.AddAsync(new AudiobookFileBuilder() [WindowsFact] public async Task DeleteAudiobook_ForeignBasePath_DoesNotAuthorizeWindowsAliasFolderContents() { - var tempRoot = FileService.GetTempDirectory("listenarr-delete-foreign-base"); + var tempRoot = FileService.GetWindowsRootRelativeTempDirectory("listenarr-delete-foreign-base"); var bookFolder = Path.Join(tempRoot, "Foreign Base Book"); var audioPath = Path.Join(bookFolder, "track.m4b"); var sidecarPath = Path.Join(bookFolder, "notes.txt"); Directory.CreateDirectory(bookFolder); await File.WriteAllTextAsync(audioPath, "audio"); await File.WriteAllTextAsync(sidecarPath, "notes"); - var driveRoot = Path.GetPathRoot(bookFolder)!; - var foreignBasePath = "/" + bookFolder[driveRoot.Length..].Replace('\\', '/'); - Assert.Equal( - Path.GetFullPath(bookFolder), - Path.GetFullPath(foreignBasePath), - StringComparer.OrdinalIgnoreCase); + var foreignBasePath = TempFileService + .GetWindowsRootRelativeForeignAlias(bookFolder); await AddAuthorizedRootAsync(new RootFolderBuilder() .WithId(505) .WithPath(tempRoot) @@ -445,17 +591,13 @@ await _audiobookFileRepository.AddAsync(new AudiobookFileBuilder() [WindowsFact] public async Task DeleteAudiobook_ForeignConfiguredOutputPath_DoesNotProtectWindowsAliasFolder() { - var tempRoot = FileService.GetTempDirectory("listenarr-delete-foreign-output-root"); + var tempRoot = FileService.GetWindowsRootRelativeTempDirectory("listenarr-delete-foreign-output-root"); var bookFolder = Path.Join(tempRoot, "Native Book"); var audioPath = Path.Join(bookFolder, "track.m4b"); Directory.CreateDirectory(bookFolder); await File.WriteAllTextAsync(audioPath, "audio"); - var driveRoot = Path.GetPathRoot(bookFolder)!; - var foreignOutputPath = "/" + bookFolder[driveRoot.Length..].Replace('\\', '/'); - Assert.Equal( - Path.GetFullPath(bookFolder), - Path.GetFullPath(foreignOutputPath), - StringComparer.OrdinalIgnoreCase); + var foreignOutputPath = TempFileService + .GetWindowsRootRelativeForeignAlias(bookFolder); await _applicationSettingsRepository.SaveAsync( new ApplicationSettingsBuilder() .WithOutputPath(foreignOutputPath) @@ -470,10 +612,7 @@ await AddAuthorizedRootAsync(new RootFolderBuilder() .WithBasePath(bookFolder) .WithFilePath(audioPath) .Build()); - await _audiobookFileRepository.AddAsync(new AudiobookFileBuilder() - .WithAudiobook(audiobook) - .WithPath(audioPath) - .Build()); + await AddTrackedGenerationAsync(audiobook, audioPath); var result = await _provider.GetRequiredService() .DeleteAudiobook( @@ -489,17 +628,13 @@ await _audiobookFileRepository.AddAsync(new AudiobookFileBuilder() [WindowsFact] public async Task DeleteAudiobook_ForeignPersistedRoot_DoesNotProtectWindowsAliasFolder() { - var tempRoot = FileService.GetTempDirectory("listenarr-delete-foreign-protected-root"); + var tempRoot = FileService.GetWindowsRootRelativeTempDirectory("listenarr-delete-foreign-protected-root"); var bookFolder = Path.Join(tempRoot, "Native Root Book"); var audioPath = Path.Join(bookFolder, "track.m4b"); Directory.CreateDirectory(bookFolder); await File.WriteAllTextAsync(audioPath, "audio"); - var driveRoot = Path.GetPathRoot(bookFolder)!; - var foreignRootPath = "/" + bookFolder[driveRoot.Length..].Replace('\\', '/'); - Assert.Equal( - Path.GetFullPath(bookFolder), - Path.GetFullPath(foreignRootPath), - StringComparer.OrdinalIgnoreCase); + var foreignRootPath = TempFileService + .GetWindowsRootRelativeForeignAlias(bookFolder); await _rootFolderRepository.AddAsync(new RootFolderBuilder() .WithId(507) .WithPath(foreignRootPath) @@ -514,10 +649,7 @@ await AddAuthorizedRootAsync(new RootFolderBuilder() .WithBasePath(bookFolder) .WithFilePath(audioPath) .Build()); - await _audiobookFileRepository.AddAsync(new AudiobookFileBuilder() - .WithAudiobook(audiobook) - .WithPath(audioPath) - .Build()); + await AddTrackedGenerationAsync(audiobook, audioPath); var result = await _provider.GetRequiredService() .DeleteAudiobook( @@ -547,10 +679,9 @@ await AddAuthorizedRootAsync(new RootFolderBuilder() .WithTitle("Relative Book") .WithBasePath(tempRoot) .Build()); - await _audiobookFileRepository.AddAsync(new AudiobookFileBuilder() - .WithAudiobook(audiobook) - .WithPath(Path.Join("Relative Book", "track.m4b")) - .Build()); + await AddTrackedGenerationAsync( + audiobook, + Path.Join("Relative Book", "track.m4b")); var result = await _provider.GetRequiredService() .DeleteAudiobook( @@ -589,10 +720,7 @@ await AddAuthorizedRootAsync(new RootFolderBuilder() .WithFilePath(audioPath) .Build()); - await _audiobookFileRepository.AddAsync(new AudiobookFileBuilder() - .WithAudiobook(audiobook) - .WithPath(audioPath) - .Build()); + await AddTrackedGenerationAsync(audiobook, audioPath); var controller = _provider.GetRequiredService(); @@ -634,14 +762,8 @@ public async Task DeleteAudiobook_DeleteFolder_PreservesSharedDirectoryWhenAnoth .WithFilePath(otherAudioPath) .Build()); - await _audiobookFileRepository.AddAsync(new AudiobookFileBuilder() - .WithAudiobook(current) - .WithPath(currentAudioPath) - .Build()); - await _audiobookFileRepository.AddAsync(new AudiobookFileBuilder() - .WithAudiobook(other) - .WithPath(otherAudioPath) - .Build()); + await AddTrackedGenerationAsync(current, currentAudioPath); + await AddTrackedGenerationAsync(other, otherAudioPath); var controller = _provider.GetRequiredService(); @@ -665,7 +787,7 @@ await _audiobookFileRepository.AddAsync(new AudiobookFileBuilder() [WindowsFact] public async Task DeleteAudiobook_DeleteFolder_ForeignOtherAudiobookPathBlocksRecursiveDelete() { - var tempRoot = FileService.GetTempDirectory("listenarr-delete-foreign-other"); + var tempRoot = FileService.GetWindowsRootRelativeTempDirectory("listenarr-delete-foreign-other"); var sharedFolder = Path.Join(tempRoot, "Shared"); var currentAudioPath = Path.Join(sharedFolder, "current.mp3"); var otherAudioPath = Path.Join(sharedFolder, "other.mp3"); @@ -683,23 +805,17 @@ await AddAuthorizedRootAsync(new RootFolderBuilder() .WithBasePath(sharedFolder) .WithFilePath(currentAudioPath) .Build()); - var driveRoot = Path.GetPathRoot(sharedFolder)!; - var foreignSharedFolder = "/" + sharedFolder[driveRoot.Length..].Replace('\\', '/'); - var foreignOtherPath = foreignSharedFolder + "/other.mp3"; - Assert.Equal( - Path.GetFullPath(otherAudioPath), - Path.GetFullPath(foreignOtherPath), - StringComparer.OrdinalIgnoreCase); + var foreignSharedFolder = TempFileService + .GetWindowsRootRelativeForeignAlias(sharedFolder); + var foreignOtherPath = TempFileService + .GetWindowsRootRelativeForeignAlias(otherAudioPath); var other = await _audiobookRepository.AddAsync(new AudiobookBuilder() .WithId(503) .WithTitle("Other") .WithBasePath(foreignSharedFolder) .WithFilePath(foreignOtherPath) .Build()); - await _audiobookFileRepository.AddAsync(new AudiobookFileBuilder() - .WithAudiobook(current) - .WithPath(currentAudioPath) - .Build()); + await AddTrackedGenerationAsync(current, currentAudioPath); await _audiobookFileRepository.AddAsync(new AudiobookFileBuilder() .WithAudiobook(other) .WithPath(foreignOtherPath) @@ -750,10 +866,7 @@ await AddAuthorizedRootAsync(new RootFolder .WithFilePath(audioPath) .Build()); - await _audiobookFileRepository.AddAsync(new AudiobookFileBuilder() - .WithAudiobook(audiobook) - .WithPath(audioPath) - .Build()); + await AddTrackedGenerationAsync(audiobook, audioPath); var controller = _provider.GetRequiredService(); @@ -795,10 +908,7 @@ await AddAuthorizedRootAsync(new RootFolder .WithBasePath(bookFolder) .WithFilePath(audioPath) .Build()); - await _audiobookFileRepository.AddAsync(new AudiobookFileBuilder() - .WithAudiobook(audiobook) - .WithPath(audioPath) - .Build()); + await AddTrackedGenerationAsync(audiobook, audioPath); var controller = _provider.GetRequiredService(); var result = await controller.DeleteAudiobook( @@ -846,10 +956,7 @@ await ownershipStore.RecordCreatedAsync( .WithBasePath(bookFolder) .WithFilePath(audioPath) .Build()); - await _audiobookFileRepository.AddAsync(new AudiobookFileBuilder() - .WithAudiobook(audiobook) - .WithPath(audioPath) - .Build()); + await AddTrackedGenerationAsync(audiobook, audioPath); var controller = _provider.GetRequiredService(); var result = await controller.DeleteAudiobook( @@ -898,10 +1005,7 @@ await ownershipStore.RecordCreatedAsync( .WithBasePath(bookFolder) .WithFilePath(audioPath) .Build()); - await _audiobookFileRepository.AddAsync(new AudiobookFileBuilder() - .WithAudiobook(audiobook) - .WithPath(audioPath) - .Build()); + await AddTrackedGenerationAsync(audiobook, audioPath); var ambiguousPeerBasePath = "//?/" + Path.GetFullPath(unrelatedFolder).Replace('\\', '/'); Assert.False(FileSystemPathIdentity.TryDetectAbsoluteSyntax( ambiguousPeerBasePath, @@ -972,10 +1076,7 @@ await AddAuthorizedRootAsync(new RootFolder .WithBasePath(bookFolder) .WithFilePath(audioPath) .Build()); - await _audiobookFileRepository.AddAsync(new AudiobookFileBuilder() - .WithAudiobook(audiobook) - .WithPath(audioPath) - .Build()); + await AddTrackedGenerationAsync(audiobook, audioPath); var controller = _provider.GetRequiredService(); var result = await controller.DeleteAudiobook( @@ -1005,6 +1106,318 @@ await _audiobookFileRepository.AddAsync(new AudiobookFileBuilder() markerPath => Assert.False(File.Exists(markerPath)))); } + [Fact] + public async Task FilesystemDelete_LegacyTrackedFileWithoutPhysicalIdentity_ReplacedBeforeDelete_PreservesReplacement() + { + var tempRoot = FileService.GetTempDirectory( + "listenarr-delete-legacy-unproven-generation"); + var bookFolder = Path.Join(tempRoot, "Book"); + var audioPath = Path.Join(bookFolder, "book.mp3"); + var displacedPath = Path.Join(bookFolder, "book-original.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() + .WithId(904) + .WithTitle("Legacy Unproven Tracked Generation Book") + .WithBasePath(bookFolder) + .WithFilePath(audioPath) + .Build()); + var trackedFile = AudiobookFile.CreateUnresolved(audioPath); + trackedFile.AudiobookId = audiobook.Id; + var factory = _provider.GetRequiredService>(); + await using (var db = await factory.CreateDbContextAsync()) + { + db.AudiobookFiles.Add(trackedFile); + await db.SaveChangesAsync(); + } + + File.Move(audioPath, displacedPath); + await File.WriteAllTextAsync(audioPath, "replacement audio"); + var snapshot = await _audiobookRepository.GetByIdSnapshotAsync(audiobook.Id); + Assert.NotNull(snapshot); + Assert.Equal( + PathIdentityState.Unavailable, + snapshot!.Files!.Single().PathIdentityState); + Assert.Null(snapshot.Files!.Single().PhysicalObjectIdentity); + + var service = _provider.GetRequiredService(); + var result = await service.DeleteAsync(snapshot, deleteFolder: false); + + Assert.True(File.Exists(audioPath)); + Assert.Equal("replacement audio", await File.ReadAllTextAsync(audioPath)); + Assert.True(File.Exists(displacedPath)); + Assert.Equal("owned audio", await File.ReadAllTextAsync(displacedPath)); + Assert.Equal(0, result.DeletedFiles); + Assert.Contains(result.Warnings, warning => + warning.Contains("physical", StringComparison.OrdinalIgnoreCase) + || warning.Contains("generation", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public async Task FilesystemDelete_TrackedFileWithoutPhysicalIdentity_ReplacedBeforeDelete_PreservesReplacement() + { + var tempRoot = FileService.GetTempDirectory( + "listenarr-delete-unproven-tracked-generation"); + var bookFolder = Path.Join(tempRoot, "Book"); + var audioPath = Path.Join(bookFolder, "book.mp3"); + var displacedPath = Path.Join(bookFolder, "book-original.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() + .WithId(902) + .WithTitle("Unproven Tracked Generation Book") + .WithBasePath(bookFolder) + .WithFilePath(audioPath) + .Build()); + var trackedFile = AudiobookFile.CreateUnresolved(audioPath); + trackedFile.AudiobookId = audiobook.Id; + trackedFile.ApplyPathIdentity( + audioPath, + AudiobookFilePathIdentity.CreateValid( + audioPath, + FileSystemPathSemantics.CurrentHostDefault, + FileSystemCaseSensitivityMode.Auto, + tempRoot)); + var factory = _provider.GetRequiredService>(); + await using (var db = await factory.CreateDbContextAsync()) + { + db.AudiobookFiles.Add(trackedFile); + await db.SaveChangesAsync(); + } + + File.Move(audioPath, displacedPath); + await File.WriteAllTextAsync(audioPath, "replacement audio"); + var snapshot = await _audiobookRepository.GetByIdSnapshotAsync(audiobook.Id); + Assert.NotNull(snapshot); + Assert.Null(snapshot!.Files!.Single().PhysicalObjectIdentity); + + var service = _provider.GetRequiredService(); + var result = await service.DeleteAsync(snapshot, deleteFolder: false); + + Assert.True(File.Exists(audioPath)); + Assert.Equal("replacement audio", await File.ReadAllTextAsync(audioPath)); + Assert.True(File.Exists(displacedPath)); + Assert.Equal("owned audio", await File.ReadAllTextAsync(displacedPath)); + Assert.Equal(0, result.DeletedFiles); + Assert.Contains(result.Warnings, warning => + warning.Contains("physical", StringComparison.OrdinalIgnoreCase) + || warning.Contains("generation", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public async Task FilesystemDelete_TrackedFileReplacedBeforeDelete_PreservesReplacementGeneration() + { + var tempRoot = FileService.GetTempDirectory( + "listenarr-delete-tracked-generation"); + var bookFolder = Path.Join(tempRoot, "Book"); + var audioPath = Path.Join(bookFolder, "book.mp3"); + var displacedPath = Path.Join(bookFolder, "book-original.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() + .WithId(900) + .WithTitle("Tracked Generation Book") + .WithBasePath(bookFolder) + .WithFilePath(audioPath) + .Build()); + var trackedFile = AudiobookFile.CreateUnresolved(audioPath); + trackedFile.AudiobookId = audiobook.Id; + trackedFile.ApplyPathIdentity( + audioPath, + AudiobookFilePathIdentity.CreateValid( + audioPath, + FileSystemPathSemantics.CurrentHostDefault, + FileSystemCaseSensitivityMode.Auto, + tempRoot)); + using (var lease = PinnedAudiobookFileRegistrationLease.Open(audioPath)) + { + trackedFile.ApplyPhysicalObjectIdentity( + lease.PhysicalObjectIdentity, + DateTime.UtcNow); + } + + var factory = _provider.GetRequiredService>(); + await using (var db = await factory.CreateDbContextAsync()) + { + db.AudiobookFiles.Add(trackedFile); + await db.SaveChangesAsync(); + } + + File.Move(audioPath, displacedPath); + await File.WriteAllTextAsync(audioPath, "replacement audio"); + var snapshot = await _audiobookRepository.GetByIdSnapshotAsync(audiobook.Id); + Assert.NotNull(snapshot); + Assert.Single(snapshot!.Files!); + Assert.False(string.IsNullOrWhiteSpace( + snapshot.Files!.Single().PhysicalObjectIdentity)); + + var service = _provider.GetRequiredService(); + var result = await service.DeleteAsync(snapshot, deleteFolder: false); + + Assert.True(File.Exists(audioPath)); + Assert.Equal("replacement audio", await File.ReadAllTextAsync(audioPath)); + Assert.True(File.Exists(displacedPath)); + Assert.Equal("owned audio", await File.ReadAllTextAsync(displacedPath)); + Assert.Equal(0, result.DeletedFiles); + Assert.Contains(result.Warnings, warning => + warning.Contains("generation", StringComparison.OrdinalIgnoreCase) + || warning.Contains("physical", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public async Task FilesystemDelete_FallbackTrackedFileWithoutPhysicalIdentity_ReplacedBeforeDelete_PreservesReplacement() + { + var tempRoot = FileService.GetTempDirectory( + "listenarr-delete-fallback-unproven-generation"); + var bookFolder = Path.Join(tempRoot, "Book"); + var audioPath = Path.Join(bookFolder, "book.mp3"); + var displacedPath = Path.Join(bookFolder, "book-original.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() + .WithId(903) + .WithTitle("Fallback Unproven Tracked Generation Book") + .WithBasePath(bookFolder) + .Build()); + var trackedFile = AudiobookFile.CreateUnresolved(audioPath); + trackedFile.AudiobookId = audiobook.Id; + trackedFile.ApplyPathIdentity( + audioPath, + AudiobookFilePathIdentity.CreateValid( + audioPath, + FileSystemPathSemantics.CurrentHostDefault, + FileSystemCaseSensitivityMode.Auto, + tempRoot)); + var unresolvedFile = AudiobookFile.CreateUnresolved( + OperatingSystem.IsWindows() + ? "/foreign/unresolved.mp3" + : @"C:\foreign\unresolved.mp3"); + unresolvedFile.AudiobookId = audiobook.Id; + var factory = _provider.GetRequiredService>(); + await using (var db = await factory.CreateDbContextAsync()) + { + db.AudiobookFiles.AddRange(trackedFile, unresolvedFile); + await db.SaveChangesAsync(); + } + + File.Move(audioPath, displacedPath); + await File.WriteAllTextAsync(audioPath, "replacement audio"); + var snapshot = await _audiobookRepository.GetByIdSnapshotAsync(audiobook.Id); + Assert.NotNull(snapshot); + Assert.Null(snapshot!.Files!.Single(file => file.Path == audioPath).PhysicalObjectIdentity); + + var service = _provider.GetRequiredService(); + var result = await service.DeleteAsync(snapshot, deleteFolder: false); + + Assert.True(File.Exists(audioPath)); + Assert.Equal("replacement audio", await File.ReadAllTextAsync(audioPath)); + Assert.True(File.Exists(displacedPath)); + Assert.Equal("owned audio", await File.ReadAllTextAsync(displacedPath)); + Assert.Equal(0, result.DeletedFiles); + Assert.Contains(result.Warnings, warning => + warning.Contains("physical", StringComparison.OrdinalIgnoreCase) + || warning.Contains("generation", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public async Task FilesystemDelete_FallbackTrackedFileReplacedBeforeDelete_PreservesReplacementGeneration() + { + var tempRoot = FileService.GetTempDirectory( + "listenarr-delete-fallback-generation"); + var bookFolder = Path.Join(tempRoot, "Book"); + var audioPath = Path.Join(bookFolder, "book.mp3"); + var displacedPath = Path.Join(bookFolder, "book-original.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() + .WithId(899) + .WithTitle("Fallback Tracked Generation Book") + .WithBasePath(bookFolder) + .Build()); + var trackedFile = AudiobookFile.CreateUnresolved(audioPath); + trackedFile.AudiobookId = audiobook.Id; + trackedFile.ApplyPathIdentity( + audioPath, + AudiobookFilePathIdentity.CreateValid( + audioPath, + FileSystemPathSemantics.CurrentHostDefault, + FileSystemCaseSensitivityMode.Auto, + tempRoot)); + using (var lease = PinnedAudiobookFileRegistrationLease.Open(audioPath)) + { + trackedFile.ApplyPhysicalObjectIdentity( + lease.PhysicalObjectIdentity, + DateTime.UtcNow); + } + + var unresolvedFile = AudiobookFile.CreateUnresolved( + OperatingSystem.IsWindows() + ? "/foreign/unresolved.mp3" + : @"C:\foreign\unresolved.mp3"); + unresolvedFile.AudiobookId = audiobook.Id; + var factory = _provider.GetRequiredService>(); + await using (var db = await factory.CreateDbContextAsync()) + { + db.AudiobookFiles.AddRange(trackedFile, unresolvedFile); + await db.SaveChangesAsync(); + } + + File.Move(audioPath, displacedPath); + await File.WriteAllTextAsync(audioPath, "replacement audio"); + var snapshot = await _audiobookRepository.GetByIdSnapshotAsync(audiobook.Id); + Assert.NotNull(snapshot); + Assert.Equal(2, snapshot!.Files!.Count); + + var service = _provider.GetRequiredService(); + var result = await service.DeleteAsync(snapshot, deleteFolder: false); + + Assert.True(File.Exists(audioPath)); + Assert.Equal("replacement audio", await File.ReadAllTextAsync(audioPath)); + Assert.True(File.Exists(displacedPath)); + Assert.Equal("owned audio", await File.ReadAllTextAsync(displacedPath)); + Assert.Equal(0, result.DeletedFiles); + Assert.Contains(result.Warnings, warning => + warning.Contains("unavailable", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(result.Warnings, warning => + warning.Contains("generation", StringComparison.OrdinalIgnoreCase) + || warning.Contains("physical", StringComparison.OrdinalIgnoreCase)); + } + [LinuxFact] public async Task FilesystemDelete_NativeCaseSensitiveCaseDistinctPath_DoesNotBlockDelete() { @@ -1162,10 +1575,7 @@ await AddAuthorizedRootAsync(new RootFolder .WithBasePath(bookFolder) .WithFilePath(audioPath) .Build()); - await _audiobookFileRepository.AddAsync(new AudiobookFileBuilder() - .WithAudiobook(audiobook) - .WithPath(audioPath) - .Build()); + await AddTrackedGenerationAsync(audiobook, audioPath); var failingService = new AudiobookFilesystemDeleteService( _provider.GetRequiredService(), _provider.GetRequiredService(), @@ -1236,10 +1646,7 @@ await AddAuthorizedRootAsync(new RootFolder .WithBasePath(bookFolder) .WithFilePath(audioPath) .Build()); - await _audiobookFileRepository.AddAsync(new AudiobookFileBuilder() - .WithAudiobook(audiobook) - .WithPath(audioPath) - .Build()); + await AddTrackedGenerationAsync(audiobook, audioPath); using var cancellation = new CancellationTokenSource(); var cancelled = false; using var hook = ExclusiveDirectoryCreator.PushBeforeOpenParentHook(path => @@ -1293,10 +1700,7 @@ await AddAuthorizedRootAsync(new RootFolder .WithBasePath(bookFolder) .WithFilePath(audioPath) .Build()); - await _audiobookFileRepository.AddAsync(new AudiobookFileBuilder() - .WithAudiobook(audiobook) - .WithPath(audioPath) - .Build()); + await AddTrackedGenerationAsync(audiobook, audioPath); using var cancellation = new CancellationTokenSource(); var service = new AudiobookFilesystemDeleteService( _provider.GetRequiredService(), @@ -1389,10 +1793,7 @@ await AddAuthorizedRootAsync(new RootFolderBuilder() .WithBasePath(folder) .WithFilePath(audioPath) .Build()); - await _audiobookFileRepository.AddAsync(new AudiobookFileBuilder() - .WithAudiobook(audiobook) - .WithPath(audioPath) - .Build()); + await AddTrackedGenerationAsync(audiobook, audioPath); var filesystemCoordinator = _provider.GetRequiredService(); var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); diff --git a/tests/Features/Api/Features/Library/LibraryController_DeleteLinkSafetyTests.cs b/tests/Features/Api/Features/Library/LibraryController_DeleteLinkSafetyTests.cs index 42df8af67..458a9d42f 100644 --- a/tests/Features/Api/Features/Library/LibraryController_DeleteLinkSafetyTests.cs +++ b/tests/Features/Api/Features/Library/LibraryController_DeleteLinkSafetyTests.cs @@ -42,10 +42,7 @@ public async Task FilesystemDelete_LinkedDirectoryDoesNotDeleteExternalFiles() .WithBasePath(bookFolder) .WithFilePath(localFile) .Build()); - await _audiobookFileRepository.AddAsync(new AudiobookFileBuilder() - .WithAudiobook(audiobook) - .WithPath(localFile) - .Build()); + await AddTrackedGenerationAsync(audiobook, localFile); var service = _provider.GetRequiredService(); var result = await service.DeleteAsync(audiobook, deleteFolder: true); @@ -89,10 +86,7 @@ public async Task FilesystemDelete_LinkedFileDoesNotDeleteExternalFile() .WithBasePath(bookFolder) .WithFilePath(localFile) .Build()); - await _audiobookFileRepository.AddAsync(new AudiobookFileBuilder() - .WithAudiobook(audiobook) - .WithPath(localFile) - .Build()); + await AddTrackedGenerationAsync(audiobook, localFile); var service = _provider.GetRequiredService(); var result = await service.DeleteAsync(audiobook, deleteFolder: true); @@ -126,10 +120,7 @@ public async Task FilesystemDelete_ParentReplacedAfterValidation_PreservesBothGe .WithBasePath(bookFolder) .WithFilePath(localFile) .Build()); - await _audiobookFileRepository.AddAsync(new AudiobookFileBuilder() - .WithAudiobook(audiobook) - .WithPath(localFile) - .Build()); + await AddTrackedGenerationAsync(audiobook, localFile); var replaced = false; using var hook = ExclusiveDirectoryCreator.PushBeforeOpenParentHook(path => @@ -185,11 +176,7 @@ public async Task FilesystemDelete_AuthorizedRootReplacedAfterEnumeration_Preser .WithBasePath(bookFolder) .WithFilePath(localFile) .Build()); - await _audiobookFileRepository.AddAsync( - new AudiobookFileBuilder() - .WithAudiobook(audiobook) - .WithPath(localFile) - .Build()); + await AddTrackedGenerationAsync(audiobook, localFile); var replaced = false; using var hook = ExclusiveDirectoryCreator.PushBeforeOpenParentHook(path => @@ -239,10 +226,7 @@ public async Task FilesystemDelete_FolderReplacedByFile_DoesNotReportSuccess() .WithBasePath(bookFolder) .WithFilePath(localFile) .Build()); - await _audiobookFileRepository.AddAsync(new AudiobookFileBuilder() - .WithAudiobook(audiobook) - .WithPath(localFile) - .Build()); + await AddTrackedGenerationAsync(audiobook, localFile); var replaced = false; using var hook = ExclusiveDirectoryCreator.PushBeforeOpenParentHook(path => @@ -272,6 +256,24 @@ await _audiobookFileRepository.AddAsync(new AudiobookFileBuilder() Directory.Move(displacedFolder, bookFolder); } + private async Task AddTrackedGenerationAsync( + Audiobook audiobook, + string path) + { + var tracked = new AudiobookFileBuilder() + .WithAudiobook(audiobook) + .WithPath(path) + .Build(); + using (var lease = PinnedAudiobookFileRegistrationLease.Open(path)) + { + tracked.ApplyPhysicalObjectIdentity( + lease.PhysicalObjectIdentity, + DateTime.UtcNow); + } + + await _audiobookFileRepository.AddAsync(tracked); + } + private static bool TryCreateDirectoryLink(string linkPath, string targetPath) { try diff --git a/tests/Features/Api/Features/Library/LibraryController_MoveTests.cs b/tests/Features/Api/Features/Library/LibraryController_MoveTests.cs index 7f5dbc4a8..87096015d 100644 --- a/tests/Features/Api/Features/Library/LibraryController_MoveTests.cs +++ b/tests/Features/Api/Features/Library/LibraryController_MoveTests.cs @@ -28,6 +28,22 @@ namespace Listenarr.Tests.Features.Api.Features.Library [Trait("Category", "LibraryController")] public class LibraryController_MoveTests : BaseTests { + private static Mock CreateMoveQueueMock( + MockBehavior behavior = MockBehavior.Loose) + { + var moveQueue = new Mock(behavior); + moveQueue.Setup(service => service.GetActiveJobsAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + moveQueue.Setup(service => service.GetRecoveryStateForAudiobookAsync( + It.IsAny(), + It.IsAny())) + .ReturnsAsync(MoveRecoveryState.None); + return moveQueue; + } + + private static Mock CreateStrictMoveQueueMock() => + CreateMoveQueueMock(MockBehavior.Strict); + [Fact] public async Task GetMoveJobStatus_ReturnsPublicContractWithoutWorkerInternals() { @@ -71,7 +87,7 @@ public async Task GetMoveJobStatus_ReturnsPublicContractWithoutWorkerInternals() } ] }; - var moveQueue = new Mock(MockBehavior.Strict); + var moveQueue = CreateStrictMoveQueueMock(); moveQueue.Setup(service => service.GetJobAsync( jobId, It.IsAny())) @@ -115,6 +131,50 @@ public async Task GetMoveJobStatus_ReturnsPublicContractWithoutWorkerInternals() } } + [Fact] + public async Task GetMoveJobStatus_NeedsAttentionVerification_ReportsOperatorRepairNotRetryable() + { + var jobId = Guid.NewGuid(); + var job = new MoveJob + { + Id = jobId, + AudiobookId = 42, + RequestedPath = "/library/Author/Title", + SourcePath = "/incoming/Author/Title", + Status = MoveJobStatus.NeedsAttention, + Phase = MoveJobPhase.CleaningSource, + Error = "Persisted generation changed.", + FailureKind = MoveFailureKind.Verification, + Entries = + [ + new MoveJobEntry + { + RelativePath = "book.m4b", + EntryType = MoveJobEntryType.File, + CopyState = MoveJobEntryCopyState.Verified, + CleanupState = MoveJobEntryCleanupState.Quarantined + } + ] + }; + var moveQueue = CreateStrictMoveQueueMock(); + moveQueue.Setup(service => service.GetJobAsync( + jobId, + It.IsAny())) + .ReturnsAsync(job); + Init(services => services.WithSingleton(moveQueue.Object)); + + var result = await _provider.GetRequiredService() + .GetMoveJobStatus(jobId.ToString("D"), CancellationToken.None); + + var ok = Assert.IsType(result); + var json = JsonSerializer.Serialize(ok.Value); + Assert.Contains( + "\"RecoveryDisposition\":\"OperatorRepairRequired\"", + json, + StringComparison.OrdinalIgnoreCase); + Assert.Contains("\"CanRetry\":false", json, StringComparison.OrdinalIgnoreCase); + } + [Fact] [Trait("Method", "EnqueueMove")] [Trait("Scenario", "ReturnsConflict_WhenTrackedSourceDoesNotExist")] @@ -149,10 +209,85 @@ await AddTrackedFileAsync( Assert.Contains("missing from disk", conflict.Value?.ToString() ?? string.Empty); } + [Fact] + public async Task MoveAudiobook_UnresolvedPublishedMove_BlocksBeforeFreshManifestValidation() + { + var manifestService = new Mock(MockBehavior.Strict); + Init(services => services.WithSingleton(manifestService.Object)); + var missingSource = Path.Join( + FileService.GetTempPath(), + $"listenarr-interrupted-move-source-{Guid.NewGuid():N}"); + var audiobook = await _audiobookRepository.AddAsync(new AudiobookBuilder() + .WithTitle("Interrupted Physical Move") + .WithBasePath(missingSource) + .Build()); + await AddTrackedFileAsync( + audiobook, + missingSource, + createFile: false); + var jobId = Guid.NewGuid(); + var target = Path.Join( + FileService.GetTempPath(), + $"listenarr-interrupted-move-target-{Guid.NewGuid():N}"); + var factory = _provider.GetRequiredService>(); + await using (var db = await factory.CreateDbContextAsync()) + { + db.MoveJobs.Add(new MoveJob + { + Id = jobId, + AudiobookId = audiobook.Id, + SourcePath = missingSource, + RequestedPath = target, + Status = MoveJobStatus.Failed, + Phase = MoveJobPhase.Published, + FailureKind = MoveFailureKind.Unknown, + Error = "Interrupted after published source cleanup", + Entries = + [ + new MoveJobEntry + { + RelativePath = "book.m4b", + EntryType = MoveJobEntryType.File, + Length = 5, + LastWriteTimeUtc = DateTime.UtcNow, + Sha256 = new string('A', 64), + CopyState = MoveJobEntryCopyState.Verified, + CleanupState = MoveJobEntryCleanupState.Deleted + } + ] + }); + await db.SaveChangesAsync(); + } + + var result = await _provider.GetRequiredService() + .EnqueueMove( + audiobook.Id, + new LibraryController.MoveRequest + { + DestinationPath = target, + SourcePath = missingSource, + MoveFiles = true, + DeleteEmptySource = true + }); + + var conflict = Assert.IsType(result); + var payload = JsonSerializer.Serialize(conflict.Value); + Assert.Contains("move_recovery_required", payload, StringComparison.Ordinal); + Assert.Contains(jobId.ToString("D"), payload, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("move_source_unverified", payload, StringComparison.Ordinal); + manifestService.Verify(service => service.BuildAsync( + It.IsAny(), + It.IsAny()), Times.Never); + await using var verification = await factory.CreateDbContextAsync(); + Assert.Single(await verification.MoveJobs + .Where(job => job.AudiobookId == audiobook.Id) + .ToListAsync()); + } + [WindowsFact] public async Task MoveAudiobook_PersistedUnixOutputRoot_DoesNotAuthorizeCurrentWindowsDrive() { - var moveQueue = new Mock(MockBehavior.Strict); + var moveQueue = CreateStrictMoveQueueMock(); Init(services => services.WithSingleton(moveQueue.Object)); var controller = _provider.GetRequiredService(); @@ -195,7 +330,7 @@ await _applicationSettingsRepository.SaveAsync(new ApplicationSettingsBuilder() public async Task MoveAudiobook_EnqueuesJob_WhenSourceExists() { // Given - var mockMoveQueue = new Mock(); + var mockMoveQueue = CreateMoveQueueMock(); var expectedId = Guid.NewGuid(); mockMoveQueue.Setup(m => m.EnqueueMoveAsync( It.IsAny(), @@ -245,7 +380,7 @@ await _applicationSettingsRepository.SaveAsync(new ApplicationSettingsBuilder() public async Task MoveAudiobook_BroadBasePath_QueuesOnlyTrackedBookManifest() { MoveEnqueueCommand? captured = null; - var moveQueue = new Mock(); + var moveQueue = CreateMoveQueueMock(); moveQueue.Setup(service => service.EnqueueMoveAsync( It.IsAny(), It.IsAny())) @@ -302,7 +437,7 @@ await AddTrackedFileAsync( public async Task MoveAudiobook_SharedFlatFolder_QueuesOnlyTrackedFile() { MoveEnqueueCommand? captured = null; - var moveQueue = new Mock(); + var moveQueue = CreateMoveQueueMock(); moveQueue.Setup(service => service.EnqueueMoveAsync( It.IsAny(), It.IsAny())) @@ -349,7 +484,7 @@ await AddTrackedFileAsync( [Fact] public async Task MoveAudiobook_NoTrackedFiles_RequiresRepair() { - var moveQueue = new Mock(MockBehavior.Strict); + var moveQueue = CreateStrictMoveQueueMock(); Init(services => services.WithSingleton(moveQueue.Object)); var outputPath = FileService.GetTempDirectory("listenarr-move-output"); await _applicationSettingsRepository.SaveAsync( @@ -383,7 +518,7 @@ await _applicationSettingsRepository.SaveAsync( [Fact] public async Task MoveAudiobook_InvalidSourcePath_IsBoundToSourceField() { - var moveQueue = new Mock(MockBehavior.Strict); + var moveQueue = CreateStrictMoveQueueMock(); Init(services => services.WithSingleton(moveQueue.Object)); var outputPath = FileService.GetTempDirectory("listenarr-move-output"); await _applicationSettingsRepository.SaveAsync(new ApplicationSettingsBuilder() @@ -424,7 +559,7 @@ await _applicationSettingsRepository.SaveAsync(new ApplicationSettingsBuilder() [Trait("Scenario", "RejectsStalePhysicalSourcePath")] public async Task MoveAudiobook_PhysicalMoveRejectsStaleExistingSourcePath() { - var moveQueue = new Mock(MockBehavior.Strict); + var moveQueue = CreateStrictMoveQueueMock(); Init(services => services.WithSingleton(moveQueue.Object)); var outputPath = FileService.GetTempDirectory("listenarr-move-output"); await _applicationSettingsRepository.SaveAsync(new ApplicationSettingsBuilder() @@ -461,7 +596,7 @@ await _applicationSettingsRepository.SaveAsync(new ApplicationSettingsBuilder() [Fact] public async Task MoveAudiobook_SourceChangesAfterPreflight_RejectsBeforeQueuePersistence() { - var moveQueue = new Mock(MockBehavior.Strict); + var moveQueue = CreateStrictMoveQueueMock(); var updatedSource = FileService.GetTempDirectory("listenarr-updated-source"); using var coordinator = new BeforeExecuteAudiobookCoordinator(async () => { @@ -509,7 +644,7 @@ await _applicationSettingsRepository.SaveAsync(new ApplicationSettingsBuilder() [Fact] public async Task MoveAudiobook_UnavailableTargetAncestor_ReturnsStructuredDestinationError() { - var moveQueue = new Mock(MockBehavior.Strict); + var moveQueue = CreateStrictMoveQueueMock(); var fileSystem = new Mock(MockBehavior.Strict); var validationCalls = 0; fileSystem.Setup(system => system.TryValidateMutationTarget( @@ -569,7 +704,7 @@ await _applicationSettingsRepository.SaveAsync(new ApplicationSettingsBuilder() [Trait("Scenario", "RejectsCustomPhysicalDestinationOutsideConfiguredRoots")] public async Task MoveAudiobook_MoveFilesTrue_RejectsCustomDestinationOutsideConfiguredRoots() { - var mockMoveQueue = new Mock(); + var mockMoveQueue = CreateMoveQueueMock(); Init(services => services.WithSingleton(mockMoveQueue.Object)); var configuredOutputPath = FileService.GetTempDirectory("listenarr-move-output"); await _applicationSettingsRepository.SaveAsync(new ApplicationSettingsBuilder() @@ -614,7 +749,7 @@ await _applicationSettingsRepository.SaveAsync(new ApplicationSettingsBuilder() [Fact] public async Task MoveAudiobook_CustomSiblingMove_PersistsCommonSeriesCleanupBoundary() { - var moveQueue = new Mock(); + var moveQueue = CreateMoveQueueMock(); moveQueue.Setup(service => service.EnqueueMoveAsync( It.IsAny(), It.IsAny())) @@ -662,7 +797,7 @@ await _applicationSettingsRepository.SaveAsync(new ApplicationSettingsBuilder() [Fact] public async Task MoveAudiobook_RelocationConflictReturnsConflict() { - var moveQueue = new Mock(); + var moveQueue = CreateMoveQueueMock(); moveQueue.Setup(service => service.EnqueueMoveAsync( It.IsAny(), It.IsAny())) @@ -886,7 +1021,7 @@ await _applicationSettingsRepository.SaveAsync(new ApplicationSettingsBuilder() public async Task MoveAudiobook_PhysicalPreflightWaitsForFilesystemMutationCoordinator() { var coordinator = new FilesystemMutationCoordinator(); - var moveQueue = new Mock(); + var moveQueue = CreateMoveQueueMock(); moveQueue.Setup(service => service.EnqueueMoveAsync( It.IsAny(), It.IsAny())) @@ -934,11 +1069,72 @@ await _applicationSettingsRepository.SaveAsync(new ApplicationSettingsBuilder() It.IsAny()), Times.Once); } + [Fact] + public async Task MoveAudiobook_ActiveMoveRejectsBeforeWaitingForFilesystemMutationCoordinator() + { + var coordinator = new FilesystemMutationCoordinator(); + var activeJob = new MoveJob + { + Id = Guid.NewGuid(), + AudiobookId = 42, + SourcePath = "C:\\source", + RequestedPath = "C:\\target", + Status = MoveJobStatus.Running, + Phase = MoveJobPhase.Copying, + EnqueuedAt = DateTime.UtcNow + }; + var moveQueue = CreateMoveQueueMock(MockBehavior.Strict); + moveQueue.Setup(service => service.GetRecoveryStateForAudiobookAsync( + activeJob.AudiobookId, + It.IsAny())) + .ReturnsAsync(new MoveRecoveryState( + MoveRecoveryDisposition.InProgress, + activeJob.Id, + activeJob.Status, + activeJob.Phase, + activeJob.RequestedPath, + activeJob.Error, + [activeJob.Id])); + Init(services => services + .WithSingleton(moveQueue.Object) + .WithSingleton(coordinator)); + var lockEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var releaseLock = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var lockTask = coordinator.ExecuteExclusiveAsync(async _ => + { + lockEntered.SetResult(); + await releaseLock.Task; + }); + await lockEntered.Task; + + var moveTask = _provider.GetRequiredService().EnqueueMove( + activeJob.AudiobookId, + new LibraryController.MoveRequest + { + DestinationPath = "C:\\target", + SourcePath = "C:\\source", + MoveFiles = true + }); + + var completed = await Task.WhenAny(moveTask, Task.Delay(TimeSpan.FromSeconds(1))); + Assert.Same(moveTask, completed); + var conflict = Assert.IsType(await moveTask); + var payload = JsonSerializer.Serialize(conflict.Value); + Assert.Contains("move_already_active", payload, StringComparison.Ordinal); + Assert.Contains(activeJob.Id.ToString(), payload, StringComparison.OrdinalIgnoreCase); + + releaseLock.SetResult(); + await lockTask; + moveQueue.Verify(service => service.GetRecoveryStateForAudiobookAsync( + activeJob.AudiobookId, + It.IsAny()), Times.Once); + } + [Fact] public async Task MoveAudiobook_CancelledWhileWaitingForFilesystemMutationDoesNotEnqueue() { var coordinator = new FilesystemMutationCoordinator(); - var moveQueue = new Mock(); + var moveQueue = CreateMoveQueueMock(); Init(services => services .WithSingleton(moveQueue.Object) .WithSingleton(coordinator)); @@ -1333,7 +1529,7 @@ await _applicationSettingsRepository.SaveAsync(new ApplicationSettingsBuilder() public async Task MoveAudiobook_AllowsCaseOnlyDestinationDifference_OnCaseSensitiveHosts() { - var mockMoveQueue = new Mock(); + var mockMoveQueue = CreateMoveQueueMock(); var expectedId = Guid.NewGuid(); mockMoveQueue.Setup(m => m.EnqueueMoveAsync( It.IsAny(), @@ -1378,7 +1574,7 @@ await _applicationSettingsRepository.SaveAsync(new ApplicationSettingsBuilder() [Trait("Scenario", "TreatsCaseOnlyDestinationAsIdentical_OnCaseInsensitiveRoot")] public async Task MoveAudiobook_TreatsCaseOnlyDestinationAsIdentical_OnCaseInsensitiveRoot() { - var mockMoveQueue = new Mock(); + var mockMoveQueue = CreateMoveQueueMock(); Init(services => services.WithSingleton(mockMoveQueue.Object)); var rootPath = FileService.GetTempDirectory("listenarr-move-insensitive-root"); await _applicationSettingsRepository.SaveAsync(new ApplicationSettingsBuilder() @@ -1422,7 +1618,7 @@ await AddTrackedFileAsync( [Trait("Scenario", "AllowsCaseOnlyDestination_OnExplicitlyCaseSensitiveRoot")] public async Task MoveAudiobook_AllowsCaseOnlyDestination_OnExplicitlyCaseSensitiveRoot() { - var mockMoveQueue = new Mock(); + var mockMoveQueue = CreateMoveQueueMock(); var expectedId = Guid.NewGuid(); mockMoveQueue.Setup(m => m.EnqueueMoveAsync( It.IsAny(), @@ -1475,7 +1671,7 @@ await AddTrackedFileAsync( [Fact] public async Task MoveAudiobook_NestedExplicitRoot_OverridesBroaderOutputPathSemantics() { - var mockMoveQueue = new Mock(); + var mockMoveQueue = CreateMoveQueueMock(); mockMoveQueue.Setup(service => service.EnqueueMoveAsync( It.IsAny(), It.IsAny())) diff --git a/tests/Features/Api/Features/Library/LibraryController_ScanPathValidationTests.cs b/tests/Features/Api/Features/Library/LibraryController_ScanPathValidationTests.cs index a7d8a77dc..891e12913 100644 --- a/tests/Features/Api/Features/Library/LibraryController_ScanPathValidationTests.cs +++ b/tests/Features/Api/Features/Library/LibraryController_ScanPathValidationTests.cs @@ -84,6 +84,33 @@ public void GetScanJobStatus_ReturnsPublicContractWithoutPathAuthorityOrInternal } } + [Fact] + public async Task ScanAudiobook_UnresolvedMoveExecution_BlocksBeforeScanPlanning() + { + var source = FileService.GetTempDirectory("scan-unresolved-move-source"); + var target = Path.Join( + FileService.GetTempPath(), + $"scan-unresolved-move-target-{Guid.NewGuid():N}"); + var audiobook = await _audiobookRepository.AddAsync(new AudiobookBuilder() + .WithTitle("Unresolved Move Scan Fence") + .WithBasePath(source) + .Build()); + await MoveJobTestFactory.SeedUnresolvedExecutionAsync( + _provider, + audiobook.Id, + source, + target); + + var result = await _provider.GetRequiredService() + .ScanAudiobookFiles( + audiobook.Id, + new LibraryController.ScanRequest { Path = source }); + + var conflict = Assert.IsType(result); + var payload = JsonSerializer.Serialize(conflict.Value); + Assert.Contains("move_recovery_required", payload, StringComparison.Ordinal); + } + [Fact] public async Task ScanAudiobook_PathAuthorizationFailure_DoesNotExposeInternalReason() { @@ -133,7 +160,7 @@ public async Task ScanAudiobook_AllowsRequestPathWithinConfiguredRoot_ReturnsOk( var tempRoot = FileService.GetTempDirectory("listenarr-test-root"); Init(services => services.Without()); var controller = _provider.GetRequiredService(); - await _rootFolderRepository.AddAsync(new RootFolder { Name = "root", Path = tempRoot }); + await AddAuthorizedRootAsync(tempRoot); await _applicationSettingsRepository.SaveAsync(new ApplicationSettingsBuilder().WithOutputPath(FileService.GetTempPath()).Build()); var ab = await _audiobookRepository.AddAsync(new AudiobookBuilder().WithTitle("Test").Build()); var result = await controller.ScanAudiobookFiles(ab.Id, new LibraryController.ScanRequest { Path = tempRoot }); @@ -151,11 +178,7 @@ public async Task ScanAudiobook_QueuedScanPersistsConfiguredRootIdentity() var requestedPath = Path.Join(configuredRoot, "Author", "Book"); Directory.CreateDirectory(requestedPath); var controller = _provider.GetRequiredService(); - await _rootFolderRepository.AddAsync(new RootFolder - { - Name = "root", - Path = configuredRoot - }); + await AddAuthorizedRootAsync(configuredRoot); await _applicationSettingsRepository.SaveAsync( new ApplicationSettingsBuilder() .WithOutputPath(configuredRoot) @@ -204,11 +227,7 @@ public async Task ScanAudiobook_AuthoritativeScope_RequiresScanRootToCoverExisti $"Unknown relationship fixture: {relationship}") }; Directory.CreateDirectory(requestedPath); - await _rootFolderRepository.AddAsync(new RootFolder - { - Name = "root", - Path = configuredRoot - }); + await AddAuthorizedRootAsync(configuredRoot); await _applicationSettingsRepository.SaveAsync( new ApplicationSettingsBuilder() .WithOutputPath(configuredRoot) @@ -239,7 +258,7 @@ public async Task ScanAudiobook_PersistsBasePathBeforeClaimingRelativeFileOwners var fileService = new Mock(MockBehavior.Strict); Init(services => services.Without().WithSingleton(fileService.Object)); var controller = _provider.GetRequiredService(); - await _rootFolderRepository.AddAsync(new RootFolder { Name = "root", Path = tempRoot }); + await AddAuthorizedRootAsync(tempRoot); await _applicationSettingsRepository.SaveAsync(new ApplicationSettingsBuilder().WithOutputPath(tempRoot).Build()); var audiobook = await _audiobookRepository.AddAsync(new AudiobookBuilder().WithTitle("Test").Build()); fileService.Setup(service => service.EnsureAudiobookFileAsync( @@ -273,7 +292,7 @@ public async Task ScanAudiobook_ExistingAbsoluteOwnershipRow_IsNotRemovedAsMissi var audioPath = await FileService.GetFileAsync(tempRoot, "Test.m4b"); Init(services => services.Without()); var controller = _provider.GetRequiredService(); - await _rootFolderRepository.AddAsync(new RootFolder { Name = "root", Path = tempRoot }); + await AddAuthorizedRootAsync(tempRoot); await _applicationSettingsRepository.SaveAsync(new ApplicationSettingsBuilder().WithOutputPath(tempRoot).Build()); var audiobook = await _audiobookRepository.AddAsync(new AudiobookBuilder().WithTitle("Test").WithBasePath(tempRoot).Build()); var resolution = await _provider.GetRequiredService().ResolveAsync(tempRoot); diff --git a/tests/Features/Api/Features/Library/LibraryScanQueueWorkflowTests.cs b/tests/Features/Api/Features/Library/LibraryScanQueueWorkflowTests.cs new file mode 100644 index 000000000..34b05ae9d --- /dev/null +++ b/tests/Features/Api/Features/Library/LibraryScanQueueWorkflowTests.cs @@ -0,0 +1,85 @@ +using Listenarr.Tests.Common; +using Microsoft.AspNetCore.Mvc; + +namespace Listenarr.Tests.Features.Api.Features.Library; + +[Trait("Area", "LibraryApi")] +[Trait("Name", "LibraryScanQueueWorkflowTests")] +[Trait("Category", "LibraryController")] +public sealed class LibraryScanQueueWorkflowTests : BaseTests +{ + [Fact] + public async Task TryEnqueueAsync_BroadcastCancellationAfterDurableEnqueue_ReturnsAccepted() + { + var jobId = Guid.NewGuid(); + var queue = new Mock(MockBehavior.Strict); + queue.Setup(service => service.EnqueueScanAsync(It.IsAny())) + .ReturnsAsync(jobId); + var broadcaster = CreateCanceledBroadcaster(); + using var provider = BuildProvider(broadcaster.Object); + var workflow = new LibraryScanQueueWorkflow( + provider.GetRequiredService(), + Mock.Of>(), + queue.Object); + + var result = await workflow.TryEnqueueAsync( + new Audiobook { Id = 4401, Title = "Queued scan" }, + requestedPath: null, + pathIdentity: null, + physicalIdentity: null, + isAuthoritativeScope: true); + + var accepted = Assert.IsType(result); + Assert.Equal(202, accepted.StatusCode); + queue.Verify(service => service.EnqueueScanAsync(It.IsAny()), Times.Once); + broadcaster.Verify(service => service.BroadcastAsync( + RealtimeHubTarget.Downloads, + "ScanJobUpdate", + It.IsAny(), + It.IsAny()), Times.Once); + } + + [Fact] + public async Task RequeueAsync_BroadcastCancellationAfterDurableRequeue_ReturnsAccepted() + { + var originalJobId = Guid.NewGuid(); + var newJobId = Guid.NewGuid(); + var queue = new Mock(MockBehavior.Strict); + queue.Setup(service => service.RequeueScanAsync(originalJobId)) + .ReturnsAsync(newJobId); + var broadcaster = CreateCanceledBroadcaster(); + using var provider = BuildProvider(broadcaster.Object); + var workflow = new LibraryScanQueueWorkflow( + provider.GetRequiredService(), + Mock.Of>(), + queue.Object); + + var result = await workflow.RequeueAsync(originalJobId.ToString()); + + var accepted = Assert.IsType(result); + Assert.Equal(202, accepted.StatusCode); + queue.Verify(service => service.RequeueScanAsync(originalJobId), Times.Once); + broadcaster.Verify(service => service.BroadcastAsync( + RealtimeHubTarget.Downloads, + "ScanJobUpdate", + It.IsAny(), + It.IsAny()), Times.Once); + } + + private static Mock CreateCanceledBroadcaster() + { + var broadcaster = new Mock(MockBehavior.Strict); + broadcaster.Setup(service => service.BroadcastAsync( + RealtimeHubTarget.Downloads, + "ScanJobUpdate", + It.IsAny(), + It.IsAny())) + .ThrowsAsync(new TaskCanceledException("Injected post-commit broadcast cancellation.")); + return broadcaster; + } + + private static ServiceProvider BuildProvider(IHubBroadcaster broadcaster) => + new ServiceCollection() + .AddSingleton(broadcaster) + .BuildServiceProvider(); +} diff --git a/tests/Features/Api/Features/Library/LibraryUpdateWorkflowTests.cs b/tests/Features/Api/Features/Library/LibraryUpdateWorkflowTests.cs index c6a1f9603..d66b500b1 100644 --- a/tests/Features/Api/Features/Library/LibraryUpdateWorkflowTests.cs +++ b/tests/Features/Api/Features/Library/LibraryUpdateWorkflowTests.cs @@ -28,12 +28,8 @@ public async Task UpdateAsync_ForeignPersistedBasePathAlias_RoutesThroughAuthori Path.GetPathRoot(Environment.CurrentDirectory)!, "listenarr-update-foreign-alias", Guid.NewGuid().ToString("N")); - var driveRoot = Path.GetPathRoot(nativeTarget)!; - var foreignSource = "/" + nativeTarget[driveRoot.Length..].Replace('\\', '/'); - Assert.Equal( - Path.GetFullPath(nativeTarget), - Path.GetFullPath(foreignSource), - StringComparer.OrdinalIgnoreCase); + var foreignSource = TempFileService + .GetWindowsRootRelativeForeignAlias(nativeTarget); var before = new Audiobook { Id = id, diff --git a/tests/Features/Api/Features/Library/RootFoldersControllerTests.cs b/tests/Features/Api/Features/Library/RootFoldersControllerTests.cs index 218807414..85c3cbbe6 100644 --- a/tests/Features/Api/Features/Library/RootFoldersControllerTests.cs +++ b/tests/Features/Api/Features/Library/RootFoldersControllerTests.cs @@ -680,7 +680,7 @@ public async Task Update_DistinctPathWithMoveFiles_ReturnsAcceptedAndUsesRelocat } [Fact] - public async Task Update_CaseOnlyEditOnInsensitivePersistedRoot_PreservesCanonicalSpelling() + public async Task Update_CaseSensitivityChangeOnEquivalentPath_MigratesIdentitiesAndPreservesCanonicalSpelling() { var sourcePath = FileUtils.GetAbsolutePath("LegacyCaseRoot"); var targetPath = sourcePath.ToLowerInvariant(); @@ -693,6 +693,26 @@ public async Task Update_CaseOnlyEditOnInsensitivePersistedRoot_PreservesCanonic CaseSensitivityMode = FileSystemCaseSensitivityMode.Insensitive }); var relocationService = new Mock(); + relocationService.Setup(service => service.StartAsync( + 1, + It.IsAny(), + It.IsAny())) + .Callback((_, command, _) => + { + var stored = svc.Store.Single(); + stored.Name = command.DesiredName; + stored.IsDefault = command.DesiredIsDefault; + stored.CaseSensitivityMode = command.TargetCaseSensitivityMode; + }) + .ReturnsAsync(new RootFolderPathChangeResult( + null, + 1, + sourcePath, + sourcePath, + RootFolderRelocationStatus.Completed, + 0, + 0, + null)); var db = CreateDb(); var controller = new RootFoldersController( svc, @@ -716,6 +736,112 @@ public async Task Update_CaseOnlyEditOnInsensitivePersistedRoot_PreservesCanonic var payload = Assert.IsType(ok.Value); Assert.Equal(sourcePath, payload.Path); Assert.Equal("Renamed", payload.Name); + Assert.Equal("Sensitive", payload.CaseSensitivityMode); + relocationService.Verify(service => service.StartAsync( + 1, + It.Is(command => + command.TargetPath == sourcePath + && command.Mode == RootFolderRelocationMode.MetadataOnly + && command.TargetCaseSensitivityMode == FileSystemCaseSensitivityMode.Sensitive), + It.IsAny()), Times.Once); + } + + [Fact] + public async Task Update_SynchronousNeedsAttention_ReturnsRecoveryResultInsteadOfRootSuccess() + { + var sourcePath = FileUtils.GetAbsolutePath("AttentionSourceRoot"); + var targetPath = FileUtils.GetAbsolutePath("AttentionTargetRoot"); + var svc = new FakeService(); + svc.Store.Add(new RootFolder + { + Id = 1, + Name = "Root", + Path = sourcePath, + CaseSensitivityMode = FileSystemCaseSensitivityMode.Auto + }); + var relocationId = Guid.NewGuid(); + var relocationService = new Mock(); + relocationService.Setup(service => service.StartAsync( + 1, + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new RootFolderPathChangeResult( + relocationId, + 1, + sourcePath, + targetPath, + RootFolderRelocationStatus.NeedsAttention, + 0, + 0, + $"Internal failure at {targetPath}", + TargetIdentityEnrollmentState.Authorized)); + var db = CreateDb(); + var controller = new RootFoldersController( + svc, + _fakeQueue, + new EfAudiobookFileRepository(db), + new AudiobookRepository(db), + new LocalFileSystem(), + relocationService: relocationService.Object); + + var result = await controller.Update( + 1, + new RootFolder + { + Id = 1, + Name = "Renamed", + Path = targetPath, + CaseSensitivityMode = FileSystemCaseSensitivityMode.Auto + }); + + var conflict = Assert.IsType(result); + var payload = Assert.IsType(conflict.Value); + Assert.Equal(relocationId, payload.RelocationId); + Assert.Equal(RootFolderRelocationStatus.NeedsAttention, payload.Status); + Assert.DoesNotContain(targetPath, payload.Error, StringComparison.OrdinalIgnoreCase); + Assert.Contains("requires attention", payload.Error!, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Patch_CaseSensitivityChange_RequiresPathChangeEndpoint() + { + var sourcePath = FileUtils.GetAbsolutePath("PatchSemanticsRoot"); + var svc = new FakeService(); + svc.Store.Add(new RootFolder + { + Id = 1, + Name = "Root", + Path = sourcePath, + CaseSensitivityMode = FileSystemCaseSensitivityMode.Sensitive, + ResolvedCaseSensitivity = FileSystemCaseSensitivity.Sensitive + }); + var relocationService = new Mock(); + var db = CreateDb(); + var controller = new RootFoldersController( + svc, + _fakeQueue, + new EfAudiobookFileRepository(db), + new AudiobookRepository(db), + new LocalFileSystem(), + relocationService: relocationService.Object); + + var result = await controller.Patch( + 1, + new RootFolderMetadataUpdateRequest( + "Renamed", + true, + FileSystemCaseSensitivityMode.Insensitive)); + + var conflict = Assert.IsType(result); + Assert.Contains( + "path-changes", + conflict.Value!.ToString(), + StringComparison.OrdinalIgnoreCase); + Assert.Equal("Root", svc.Store.Single().Name); + Assert.False(svc.Store.Single().IsDefault); + Assert.Equal( + FileSystemCaseSensitivityMode.Sensitive, + svc.Store.Single().CaseSensitivityMode); relocationService.Verify(service => service.StartAsync( It.IsAny(), It.IsAny(), @@ -1067,6 +1193,68 @@ public async Task ChangePath_MissingExpectedCurrentPath_RejectsBeforeRelocation( relocationService.VerifyNoOtherCalls(); } + [Theory] + [InlineData(RootFolderRelocationStatus.Completed, typeof(Microsoft.AspNetCore.Mvc.OkObjectResult))] + [InlineData(RootFolderRelocationStatus.NeedsAttention, typeof(Microsoft.AspNetCore.Mvc.ConflictObjectResult))] + public async Task ChangePath_RelocateTerminalResult_UsesTerminalHttpStatus( + RootFolderRelocationStatus status, + Type expectedResultType) + { + var relocationId = Guid.NewGuid(); + var sourcePath = FileUtils.GetAbsolutePath("terminal-relocate-source"); + var targetPath = FileUtils.GetAbsolutePath("terminal-relocate-target"); + var relocationService = new Mock(); + relocationService.Setup(service => service.StartAsync( + 1, + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new RootFolderPathChangeResult( + relocationId, + 1, + sourcePath, + targetPath, + status, + 0, + 0, + status == RootFolderRelocationStatus.NeedsAttention + ? $"Internal failure at {targetPath}" + : null, + TargetIdentityEnrollmentState.Authorized)); + var db = CreateDb(); + var controller = new RootFoldersController( + new FakeService(), + _fakeQueue, + new EfAudiobookFileRepository(db), + new AudiobookRepository(db), + new LocalFileSystem(), + relocationService: relocationService.Object); + var request = new RootFolderPathChangeRequest( + targetPath, + "relocate", + false, + "Root", + false, + FileSystemCaseSensitivityMode.Auto, + sourcePath); + + var result = await controller.ChangePath(1, request, CancellationToken.None); + + Assert.IsType(expectedResultType, result); + var value = result switch + { + Microsoft.AspNetCore.Mvc.OkObjectResult ok => ok.Value, + Microsoft.AspNetCore.Mvc.ConflictObjectResult conflict => conflict.Value, + _ => null + }; + var payload = Assert.IsType(value); + Assert.Equal(status, payload.Status); + if (status == RootFolderRelocationStatus.NeedsAttention) + { + Assert.DoesNotContain(targetPath, payload.Error, StringComparison.OrdinalIgnoreCase); + Assert.Contains("requires attention", payload.Error!, StringComparison.OrdinalIgnoreCase); + } + } + [Theory] [InlineData("relocate", RootFolderRelocationMode.Relocate)] [InlineData("RELOCATE", RootFolderRelocationMode.Relocate)] diff --git a/tests/Features/Api/LibraryController_MetadataRescanTests.cs b/tests/Features/Api/LibraryController_MetadataRescanTests.cs index 0afb1c500..dede55210 100644 --- a/tests/Features/Api/LibraryController_MetadataRescanTests.cs +++ b/tests/Features/Api/LibraryController_MetadataRescanTests.cs @@ -32,6 +32,95 @@ public LibraryController_MetadataRescanTests(ListenarrWebApplicationFactory fact _factory = factory; } + [Fact] + public async Task RescanMetadata_UnresolvedMoveExecution_BlocksMetadataCommit() + { + var metadataMock = new Mock(); + metadataMock + .Setup(service => service.GetMetadataAsync("B0MOVEFENC", "us", false)) + .ReturnsAsync(new + { + metadata = new AudibleBookResponse + { + Asin = "B0MOVEFENC", + Title = "Provider Title" + }, + source = "Audible", + sourceUrl = "https://audible.com" + }); + var factory = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureServices(services => + { + services.RemoveAll(); + services.AddSingleton(metadataMock.Object); + }); + }); + int audiobookId; + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var source = Path.Join(Path.GetTempPath(), $"metadata-move-source-{Guid.NewGuid():N}"); + var target = Path.Join(Path.GetTempPath(), $"metadata-move-target-{Guid.NewGuid():N}"); + var audiobook = new Audiobook + { + Title = "Catalog Title", + BasePath = source, + Asin = "B0MOVEFENC", + ExternalIdentifiers = + [ + new AudiobookExternalIdentifier + { + Type = AudiobookExternalIdentifierType.Asin, + ValueRaw = "B0MOVEFENC", + ValueNormalized = "B0MOVEFENC", + Region = "us", + IsPrimary = true, + Source = AudiobookExternalIdentifierSource.Manual + } + ] + }; + db.Audiobooks.Add(audiobook); + await db.SaveChangesAsync(); + audiobookId = audiobook.Id; + db.MoveJobs.Add(new MoveJob + { + AudiobookId = audiobookId, + SourcePath = source, + RequestedPath = target, + Status = MoveJobStatus.Failed, + Phase = MoveJobPhase.Published, + FailureKind = MoveFailureKind.Unknown, + Entries = + [ + new MoveJobEntry + { + RelativePath = "book.m4b", + EntryType = MoveJobEntryType.File, + Length = 1, + LastWriteTimeUtc = DateTime.UnixEpoch, + Sha256 = new string('A', 64), + CopyState = MoveJobEntryCopyState.Verified, + CleanupState = MoveJobEntryCleanupState.Deleted + } + ] + }); + await db.SaveChangesAsync(); + } + + using var client = factory.CreateClient(); + var response = await PostRescanAsync(client, audiobookId); + + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + var payload = await response.Content.ReadAsStringAsync(); + Assert.Contains("move_recovery_required", payload, StringComparison.Ordinal); + using var verificationScope = factory.Services.CreateScope(); + var verification = verificationScope.ServiceProvider.GetRequiredService(); + Assert.Equal( + "Catalog Title", + (await verification.Audiobooks.SingleAsync(book => book.Id == audiobookId)).Title); + } + [Fact] public async Task RescanMetadata_UsesIdentifiersAndUpdatesAudiobook() { diff --git a/tests/Features/Api/Services/FileMoverDestinationHierarchyOwnershipTests.cs b/tests/Features/Api/Services/FileMoverDestinationHierarchyOwnershipTests.cs new file mode 100644 index 000000000..a4fbb6293 --- /dev/null +++ b/tests/Features/Api/Services/FileMoverDestinationHierarchyOwnershipTests.cs @@ -0,0 +1,92 @@ +using Listenarr.Tests.Common; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Listenarr.Tests.Features.Api.Services; + +[Trait("Area", "FileSystem")] +[Trait("Name", "FileMoverDestinationHierarchyOwnershipTests")] +[Trait("Category", "FileSystem")] +public sealed class FileMoverDestinationHierarchyOwnershipTests : BaseTests +{ + [Fact] + public async Task CopyFileAsync_DestinationParentRemovedAfterResolution_DoesNotRecreateHierarchy() + { + // Given + var root = FileService.GetTempDirectory("file-mover-owned-file-parent-race"); + var sourceParent = Path.Join(root, "source"); + var destinationParent = Path.Join(root, "owned", "destination"); + Directory.CreateDirectory(sourceParent); + Directory.CreateDirectory(destinationParent); + var source = Path.Join(sourceParent, "book.m4b"); + var destination = Path.Join(destinationParent, "book.m4b"); + await File.WriteAllTextAsync(source, "audio"); + var removed = false; + var mover = new FileMover( + new NullLogger(), + semanticsResolver: new FileSystemSemanticsResolver()) + { + AfterFileMoveEndpointsResolvedForTestAsync = (_, observedDestination) => + { + if (!removed + && string.Equals( + Path.GetFullPath(observedDestination), + Path.GetFullPath(destination), + StringComparison.OrdinalIgnoreCase)) + { + Directory.Delete(destinationParent); + removed = true; + } + + return Task.CompletedTask; + } + }; + + // When + var copied = await mover.CopyFileAsync(source, destination); + + // Then + Assert.True(removed); + Assert.False(copied); + Assert.True(File.Exists(source)); + Assert.False(Directory.Exists(destinationParent)); + Assert.False(File.Exists(destination)); + } + + [Fact] + public async Task MoveDirectoryAsync_DestinationParentRemovedAtMutationBoundary_DoesNotRecreateHierarchy() + { + // Given + var root = FileService.GetTempDirectory("file-mover-owned-directory-parent-race"); + var source = Path.Join(root, "source"); + var destinationParent = Path.Join(root, "owned", "destination"); + var destination = Path.Join(destinationParent, "book"); + Directory.CreateDirectory(source); + Directory.CreateDirectory(destinationParent); + await File.WriteAllTextAsync(Path.Join(source, "book.m4b"), "audio"); + var removed = false; + var mover = new FileMover( + new NullLogger(), + semanticsResolver: new FileSystemSemanticsResolver()) + { + BeforeDirectoryMoveAttemptForTest = () => + { + if (!removed) + { + Directory.Delete(destinationParent); + removed = true; + } + } + }; + + // When + var moved = await mover.MoveDirectoryAsync(source, destination); + + // Then + Assert.True(removed); + Assert.False(moved); + Assert.True(Directory.Exists(source)); + Assert.Equal("audio", await File.ReadAllTextAsync(Path.Join(source, "book.m4b"))); + Assert.False(Directory.Exists(destinationParent)); + Assert.False(Directory.Exists(destination)); + } +} diff --git a/tests/Features/Api/Services/FileMoverFallbackTests.cs b/tests/Features/Api/Services/FileMoverFallbackTests.cs index c18481ab0..62ae09562 100644 --- a/tests/Features/Api/Services/FileMoverFallbackTests.cs +++ b/tests/Features/Api/Services/FileMoverFallbackTests.cs @@ -115,6 +115,116 @@ public async Task MoveFileAsync_SamePath_IsNoOpAndPreservesFile() Assert.Equal("content", await File.ReadAllTextAsync(file)); } + [Fact] + public async Task MoveFilePreservingPhysicalIdentity_AbsentDestination_PreservesExactGeneration() + { + var source = Path.Join(_root, "preserve-source.mp3"); + var destination = Path.Join(_root, "preserve-destination.mp3"); + await File.WriteAllTextAsync(source, "audio"); + string expectedIdentity; + using (var sourceLease = PinnedAudiobookFileRegistrationLease.Open(source)) + { + expectedIdentity = sourceLease.PhysicalObjectIdentity; + } + var mover = new FileMover(new NullLogger()); + + var moved = await mover.MoveFilePreservingPhysicalIdentityAsync( + source, + destination, + expectedIdentity); + + Assert.True(moved); + Assert.False(File.Exists(source)); + using var published = PinnedAudiobookFileRegistrationLease.Open(destination); + Assert.Equal(expectedIdentity, published.PhysicalObjectIdentity); + Assert.Equal("audio", await File.ReadAllTextAsync(destination)); + } + + [Fact] + public async Task MoveFilePreservingPhysicalIdentity_SourceGenerationReplaced_FailsClosed() + { + var source = Path.Join(_root, "generation-source.mp3"); + var destination = Path.Join(_root, "generation-destination.mp3"); + await File.WriteAllTextAsync(source, "original"); + string expectedIdentity; + using (var lease = PinnedAudiobookFileRegistrationLease.Open(source)) + { + expectedIdentity = lease.PhysicalObjectIdentity; + } + + File.Delete(source); + await File.WriteAllTextAsync(source, "replacement"); + var mover = new FileMover(new NullLogger()); + + var moved = await mover.MoveFilePreservingPhysicalIdentityAsync( + source, + destination, + expectedIdentity); + + Assert.False(moved); + Assert.Equal("replacement", await File.ReadAllTextAsync(source)); + Assert.False(File.Exists(destination)); + } + + [Fact] + public async Task MoveFilePreservingPhysicalIdentity_SameContentDifferentDestinationGeneration_FailsWithoutRetiringSource() + { + var source = Path.Join(_root, "same-content-source.mp3"); + var destination = Path.Join(_root, "same-content-destination.mp3"); + await File.WriteAllTextAsync(source, "audio"); + await File.WriteAllTextAsync(destination, "audio"); + string expectedIdentity; + string originalDestinationIdentity; + using (var sourceLease = PinnedAudiobookFileRegistrationLease.Open(source)) + using (var destinationLease = PinnedAudiobookFileRegistrationLease.Open(destination)) + { + expectedIdentity = sourceLease.PhysicalObjectIdentity; + originalDestinationIdentity = destinationLease.PhysicalObjectIdentity; + } + Assert.NotEqual(expectedIdentity, originalDestinationIdentity); + var mover = new FileMover(new NullLogger()); + + var moved = await mover.MoveFilePreservingPhysicalIdentityAsync( + source, + destination, + expectedIdentity); + + Assert.False(moved); + Assert.True(File.Exists(source)); + using var retainedSource = PinnedAudiobookFileRegistrationLease.Open(source); + Assert.Equal(expectedIdentity, retainedSource.PhysicalObjectIdentity); + using var retainedDestination = PinnedAudiobookFileRegistrationLease.Open(destination); + Assert.Equal(originalDestinationIdentity, retainedDestination.PhysicalObjectIdentity); + Assert.Equal("audio", await File.ReadAllTextAsync(source)); + Assert.Equal("audio", await File.ReadAllTextAsync(destination)); + } + + [Fact] + public async Task MoveFilePreservingPhysicalIdentity_NativeRenameUnavailable_DoesNotCopyDelete() + { + var source = Path.Join(_root, "native-required-source.mp3"); + var destination = Path.Join(_root, "native-required-destination.mp3"); + await File.WriteAllTextAsync(source, "audio"); + string expectedIdentity; + using (var lease = PinnedAudiobookFileRegistrationLease.Open(source)) + { + expectedIdentity = lease.PhysicalObjectIdentity; + } + var mover = new FileMover(new NullLogger()) + { + DisableNativeFileRenameForTest = true + }; + + var moved = await mover.MoveFilePreservingPhysicalIdentityAsync( + source, + destination, + expectedIdentity); + + Assert.False(moved); + Assert.Equal("audio", await File.ReadAllTextAsync(source)); + Assert.False(File.Exists(destination)); + } + [Fact] public async Task PerformActionOn_MoveToSamePath_IsNoOpAndPreservesFile() { @@ -449,6 +559,75 @@ await recoveryMover.PrepareActionForRegistrationAsync( Assert.False(Directory.Exists(legacyStatePath)); } + [Fact] + public async Task PrepareActionForRegistration_CopyRecovery_RetainsOriginalSourceGenerationEvidence() + { + var source = Path.Join( + _root, + "copy-registration-recovery-source.m4b"); + var displacedSource = Path.Join( + _root, + "copy-registration-recovery-source-original.m4b"); + var destination = Path.Join( + _root, + "copy-registration-recovery-destination.m4b"); + await File.WriteAllTextAsync(source, "audio"); + await File.WriteAllTextAsync(destination, "previous"); + string originalSourceIdentity; + using (var sourceLease = + PinnedAudiobookFileRegistrationLease.Open(source)) + { + originalSourceIdentity = sourceLease.PhysicalObjectIdentity; + } + + var interruptedMover = new FileMover( + new NullLogger(), + semanticsResolver: new FileSystemSemanticsResolver()) + { + AfterPreparedPublicationCommittedForTestAsync = () => + throw new IOException( + "simulated crash after prepared copy commit") + }; + using var interruptedLease = + await interruptedMover.PrepareActionForRegistrationAsync( + FileAction.Copy, + source, + destination, + Guid.NewGuid()); + + Assert.Null(interruptedLease); + Assert.False(File.Exists(destination)); + Assert.Single(Directory.EnumerateFiles( + _root, + "prepared.claim", + SearchOption.AllDirectories)); + File.Move(source, displacedSource); + await File.WriteAllTextAsync(source, "audio"); + + var recoveryMover = new FileMover( + new NullLogger(), + semanticsResolver: new FileSystemSemanticsResolver()); + using var recoveredLease = + await recoveryMover.PrepareActionForRegistrationAsync( + FileAction.Copy, + source, + destination, + Guid.NewGuid()); + + Assert.NotNull(recoveredLease); + Assert.True(recoveredLease.MatchesCurrentPublication()); + Assert.Equal( + originalSourceIdentity, + recoveredLease.SourcePhysicalObjectIdentity); + Assert.Equal("audio", await File.ReadAllTextAsync(destination)); + Assert.Equal("audio", await File.ReadAllTextAsync(source)); + Assert.Equal("audio", await File.ReadAllTextAsync(displacedSource)); + Assert.Empty(Directory.EnumerateDirectories( + _root, + ".listenarr-file-publication-*.state", + SearchOption.TopDirectoryOnly)); + } + [Fact] public async Task PrepareActionForRegistration_StateCreationCollision_DoesNotFallBackToByteCopy() { @@ -835,6 +1014,54 @@ await recoveryMover.PrepareActionForRegistrationAsync( ".listenarr-registration-publication-*.state")); } + [Fact] + public async Task PrepareActionForRegistration_CleanupIntentReplacedAfterClaimRetirement_PreservesReplacementAndBlocksCleanup() + { + var source = Path.Join(_root, "registered-intent-race-source.m4b"); + var destination = Path.Join(_root, "registered-intent-race-destination.m4b"); + await File.WriteAllTextAsync(source, "audio"); + string? replacementIntent = null; + string? displacedIntent = null; + var mover = new FileMover( + new NullLogger(), + semanticsResolver: new FileSystemSemanticsResolver()) + { + AfterRegistrationPublicationClaimRetiredForTest = () => + { + var stateDirectory = Assert.Single( + Directory.EnumerateDirectories( + _root, + ".listenarr-registration-publication-*.state")); + replacementIntent = Path.Join( + stateDirectory, + "registration.cleanup.json"); + displacedIntent = replacementIntent + ".displaced"; + File.Move(replacementIntent, displacedIntent); + File.WriteAllText(replacementIntent, "{\"replacement\":true}"); + } + }; + using var lease = await mover.PrepareActionForRegistrationAsync( + FileAction.HardlinkCopy, + source, + destination, + Guid.NewGuid()); + Assert.NotNull(lease); + + var completion = CompletePreparedPublication(lease); + + Assert.Equal( + RegistrationPublicationCompletion.CommittedCleanupPending, + completion); + Assert.NotNull(replacementIntent); + Assert.NotNull(displacedIntent); + Assert.True(File.Exists(replacementIntent)); + Assert.Equal( + "{\"replacement\":true}", + await File.ReadAllTextAsync(replacementIntent!)); + Assert.True(File.Exists(displacedIntent)); + Assert.True(File.Exists(destination)); + } + [Fact] public async Task PrepareActionForRegistration_UnregisteredHardlinkAlias_RemainsRejected() { @@ -1007,22 +1234,19 @@ public async Task CompletePreparedMoveAsync_SameContentSourceReplacement_IsNotDe await File.ReadAllTextAsync(destination)); } - [LinuxFact] - public async Task CompletePreparedMoveAsync_UnixDestinationReplacedAfterSourceRetirement_RestoresSource() + [WindowsFact] + public async Task CompletePreparedMoveAsync_WindowsFailureAfterSourceRetirement_RestoresSource() { - var source = Path.Join(_root, "prepared-race-source.mp3"); - var destination = Path.Join(_root, "prepared-race-destination.mp3"); - var displaced = Path.Join(_root, "prepared-race-displaced.mp3"); + var source = Path.Join(_root, "prepared-windows-race-source.mp3"); + var destination = Path.Join(_root, "prepared-windows-race-destination.mp3"); await File.WriteAllTextAsync(source, "original"); var mover = new FileMover( new NullLogger(), semanticsResolver: new FileSystemSemanticsResolver()) { - AfterPreparedMoveSourceDeletedForTestAsync = async path => - { - File.Move(path, displaced); - await File.WriteAllTextAsync(path, "replacement"); - } + AfterPreparedMoveSourceDeletedForTestAsync = _ => + Task.FromException( + new IOException("Injected post-retirement failure.")) }; using var lease = await mover.PrepareActionForRegistrationAsync( FileAction.Move, @@ -1037,20 +1261,75 @@ public async Task CompletePreparedMoveAsync_UnixDestinationReplacedAfterSourceRe Assert.False(completed); Assert.Equal("original", await File.ReadAllTextAsync(source)); - Assert.Equal("replacement", await File.ReadAllTextAsync(destination)); - Assert.Equal("original", await File.ReadAllTextAsync(displaced)); + Assert.Equal("original", await File.ReadAllTextAsync(destination)); } - [LinuxFact] - public async Task CompletePreparedMoveAsync_UnixRecoveredClaimDestinationReplacedAfterRetirement_RestoresSource() + [Fact] + public async Task CompletePreparedMoveAsync_RecoveredClaimSameContentReplacement_IsPreserved() { - var source = Path.Join(_root, "prepared-recovery-race-source.mp3"); - var destination = Path.Join( + var source = Path.Join(_root, "prepared-replaced-claim-source.mp3"); + var destination = Path.Join(_root, "prepared-replaced-claim-destination.mp3"); + var displacedClaim = Path.Join(_root, "prepared-replaced-claim-original.mp3"); + await File.WriteAllTextAsync(source, "same-bytes"); + var operationId = Guid.NewGuid(); + var semanticsResolver = new FileSystemSemanticsResolver(); + using var lease = await new FileMover( + new NullLogger(), + semanticsResolver: semanticsResolver) + .PrepareActionForRegistrationAsync( + FileAction.Move, + source, + destination, + operationId); + Assert.NotNull(lease); + + var resolution = await semanticsResolver.ResolveAsync(source); + Assert.Equal(PathIdentityState.Valid, resolution.State); + var sourceIdentity = Path.GetFullPath(source); + var destinationIdentity = Path.GetFullPath(destination); + if (resolution.Semantics.CaseSensitivity + == FileSystemCaseSensitivity.Insensitive) + { + sourceIdentity = sourceIdentity.ToUpperInvariant(); + destinationIdentity = destinationIdentity.ToUpperInvariant(); + } + + var claimIdentity = FormattableString.Invariant( + $"{operationId:N}\0{sourceIdentity}\0{destinationIdentity}"); + var claimDigest = Convert.ToHexString( + System.Security.Cryptography.SHA256.HashData( + System.Text.Encoding.UTF8.GetBytes(claimIdentity))); + var claimPath = Path.Join( _root, - "prepared-recovery-race-destination.mp3"); - var displaced = Path.Join( + $".listenarr-registration-move-{claimDigest[..32]}.claim"); + File.Move(source, displacedClaim); + await File.WriteAllTextAsync(claimPath, "same-bytes"); + + var completed = await new FileMover( + new NullLogger(), + semanticsResolver: semanticsResolver) + .CompletePreparedMoveAsync( + source, + destination, + lease, + operationId); + + Assert.False(completed); + Assert.False(File.Exists(claimPath)); + Assert.True(File.Exists(source)); + Assert.Equal("same-bytes", await File.ReadAllTextAsync(source)); + Assert.True(File.Exists(displacedClaim)); + Assert.Equal("same-bytes", await File.ReadAllTextAsync(displacedClaim)); + Assert.Equal("same-bytes", await File.ReadAllTextAsync(destination)); + } + + [Fact] + public async Task CompletePreparedMoveAsync_RecoveredClaimFailureAfterRetirement_RestoresSource() + { + var source = Path.Join(_root, "prepared-recovery-failure-source.mp3"); + var destination = Path.Join( _root, - "prepared-recovery-race-displaced.mp3"); + "prepared-recovery-failure-destination.mp3"); await File.WriteAllTextAsync(source, "original"); var operationId = Guid.NewGuid(); var semanticsResolver = new FileSystemSemanticsResolver(); @@ -1089,11 +1368,9 @@ public async Task CompletePreparedMoveAsync_UnixRecoveredClaimDestinationReplace new NullLogger(), semanticsResolver: semanticsResolver) { - AfterPreparedMoveSourceDeletedForTestAsync = async path => - { - File.Move(path, displaced); - await File.WriteAllTextAsync(path, "replacement"); - } + AfterPreparedMoveSourceDeletedForTestAsync = _ => + Task.FromException( + new IOException("Injected recovered-claim failure.")) }; var completed = await recoveryMover.CompletePreparedMoveAsync( @@ -1104,50 +1381,151 @@ public async Task CompletePreparedMoveAsync_UnixRecoveredClaimDestinationReplace Assert.False(completed); Assert.Equal("original", await File.ReadAllTextAsync(source)); - Assert.Equal("replacement", await File.ReadAllTextAsync(destination)); - Assert.Equal("original", await File.ReadAllTextAsync(displaced)); + Assert.Equal("original", await File.ReadAllTextAsync(destination)); Assert.False(File.Exists(claimPath)); } - [Fact] - public async Task MoveDirectoryAsync_SamePath_IsNoOpAndPreservesContents() - { - var directory = Path.Join(_root, "same-directory"); - Directory.CreateDirectory(directory); - var file = Path.Join(directory, "track.mp3"); - await File.WriteAllTextAsync(file, "content"); - var mover = new FileMover(new NullLogger()); - - var ok = await mover.MoveDirectoryAsync(directory, directory); - - Assert.True(ok); - Assert.True(File.Exists(file)); - } - - [Theory] - [InlineData("same-volume directory source retirement")] - [InlineData("same-volume directory destination publication")] - public async Task MoveDirectoryAsync_PostRenameBarrierFailure_ReconcilesSuccessfulMove( - string failingPhase) + [LinuxFact] + public async Task CompletePreparedMoveAsync_UnixDestinationReplacedAfterSourceRetirement_RestoresSource() { - var source = Path.Join(_root, $"barrier-source-{Guid.NewGuid():N}"); - var destination = Path.Join(_root, $"barrier-destination-{Guid.NewGuid():N}"); - Directory.CreateDirectory(source); - await File.WriteAllTextAsync(Path.Join(source, "book.m4b"), "audio"); + var source = Path.Join(_root, "prepared-race-source.mp3"); + var destination = Path.Join(_root, "prepared-race-destination.mp3"); + var displaced = Path.Join(_root, "prepared-race-displaced.mp3"); + await File.WriteAllTextAsync(source, "original"); var mover = new FileMover( new NullLogger(), semanticsResolver: new FileSystemSemanticsResolver()) { - BeforeFileMoveDurabilityBarrierForTest = phase => + AfterPreparedMoveSourceDeletedForTestAsync = async path => { - if (string.Equals(phase, failingPhase, StringComparison.Ordinal)) - { - throw new IOException("simulated post-rename durability failure"); - } + File.Move(path, displaced); + await File.WriteAllTextAsync(path, "replacement"); } }; - - var moved = await mover.MoveDirectoryAsync(source, destination); + using var lease = await mover.PrepareActionForRegistrationAsync( + FileAction.Move, + source, + destination); + Assert.NotNull(lease); + + var completed = await mover.CompletePreparedMoveAsync( + source, + destination, + lease); + + Assert.False(completed); + Assert.Equal("original", await File.ReadAllTextAsync(source)); + Assert.Equal("replacement", await File.ReadAllTextAsync(destination)); + Assert.Equal("original", await File.ReadAllTextAsync(displaced)); + } + + [LinuxFact] + public async Task CompletePreparedMoveAsync_UnixRecoveredClaimDestinationReplacedAfterRetirement_RestoresSource() + { + var source = Path.Join(_root, "prepared-recovery-race-source.mp3"); + var destination = Path.Join( + _root, + "prepared-recovery-race-destination.mp3"); + var displaced = Path.Join( + _root, + "prepared-recovery-race-displaced.mp3"); + await File.WriteAllTextAsync(source, "original"); + var operationId = Guid.NewGuid(); + var semanticsResolver = new FileSystemSemanticsResolver(); + using var lease = await new FileMover( + new NullLogger(), + semanticsResolver: semanticsResolver) + .PrepareActionForRegistrationAsync( + FileAction.Move, + source, + destination, + operationId); + Assert.NotNull(lease); + + var resolution = await semanticsResolver.ResolveAsync(source); + Assert.Equal(PathIdentityState.Valid, resolution.State); + var sourceIdentity = Path.GetFullPath(source); + var destinationIdentity = Path.GetFullPath(destination); + if (resolution.Semantics.CaseSensitivity + == FileSystemCaseSensitivity.Insensitive) + { + sourceIdentity = sourceIdentity.ToUpperInvariant(); + destinationIdentity = destinationIdentity.ToUpperInvariant(); + } + + var claimIdentity = FormattableString.Invariant( + $"{operationId:N}\0{sourceIdentity}\0{destinationIdentity}"); + var claimDigest = Convert.ToHexString( + System.Security.Cryptography.SHA256.HashData( + System.Text.Encoding.UTF8.GetBytes(claimIdentity))); + var claimPath = Path.Join( + _root, + $".listenarr-registration-move-{claimDigest[..32]}.claim"); + File.Move(source, claimPath); + + var recoveryMover = new FileMover( + new NullLogger(), + semanticsResolver: semanticsResolver) + { + AfterPreparedMoveSourceDeletedForTestAsync = async path => + { + File.Move(path, displaced); + await File.WriteAllTextAsync(path, "replacement"); + } + }; + + var completed = await recoveryMover.CompletePreparedMoveAsync( + source, + destination, + lease, + operationId); + + Assert.False(completed); + Assert.Equal("original", await File.ReadAllTextAsync(source)); + Assert.Equal("replacement", await File.ReadAllTextAsync(destination)); + Assert.Equal("original", await File.ReadAllTextAsync(displaced)); + Assert.False(File.Exists(claimPath)); + } + + [Fact] + public async Task MoveDirectoryAsync_SamePath_IsNoOpAndPreservesContents() + { + var directory = Path.Join(_root, "same-directory"); + Directory.CreateDirectory(directory); + var file = Path.Join(directory, "track.mp3"); + await File.WriteAllTextAsync(file, "content"); + var mover = new FileMover(new NullLogger()); + + var ok = await mover.MoveDirectoryAsync(directory, directory); + + Assert.True(ok); + Assert.True(File.Exists(file)); + } + + [Theory] + [InlineData("same-volume directory source retirement")] + [InlineData("same-volume directory destination publication")] + public async Task MoveDirectoryAsync_PostRenameBarrierFailure_ReconcilesSuccessfulMove( + string failingPhase) + { + var source = Path.Join(_root, $"barrier-source-{Guid.NewGuid():N}"); + var destination = Path.Join(_root, $"barrier-destination-{Guid.NewGuid():N}"); + Directory.CreateDirectory(source); + await File.WriteAllTextAsync(Path.Join(source, "book.m4b"), "audio"); + var mover = new FileMover( + new NullLogger(), + semanticsResolver: new FileSystemSemanticsResolver()) + { + BeforeFileMoveDurabilityBarrierForTest = phase => + { + if (string.Equals(phase, failingPhase, StringComparison.Ordinal)) + { + throw new IOException("simulated post-rename durability failure"); + } + } + }; + + var moved = await mover.MoveDirectoryAsync(source, destination); Assert.True(moved); Assert.False(Directory.Exists(source)); @@ -1202,6 +1580,57 @@ await Assert.ThrowsAsync(() => SearchOption.TopDirectoryOnly)); } + [Fact] + public async Task MoveDirectoryAsync_RenameJournalReplacedBeforeRetirement_PreservesReplacement() + { + var source = Path.Join(_root, $"journal-replaced-source-{Guid.NewGuid():N}"); + var destination = Path.Join( + _root, + $"journal-replaced-destination-{Guid.NewGuid():N}"); + Directory.CreateDirectory(source); + await File.WriteAllTextAsync(Path.Join(source, "book.m4b"), "audio"); + var crashingMover = new FileMover( + new NullLogger(), + semanticsResolver: new FileSystemSemanticsResolver()) + { + AfterDirectoryRenameJournalPublishedForTest = _ => + { + Directory.Move(source, destination); + throw new OperationCanceledException( + "simulated process termination after rename"); + } + }; + await Assert.ThrowsAsync(() => + crashingMover.MoveDirectoryAsync(source, destination)); + var journalPath = Assert.Single(Directory.EnumerateFiles( + _root, + ".listenarr-directory-rename-*.journal", + SearchOption.TopDirectoryOnly)); + var originalBytes = await File.ReadAllBytesAsync(journalPath); + var displacedJournalPath = journalPath + ".displaced"; + var recoveringMover = new FileMover( + new NullLogger(), + semanticsResolver: new FileSystemSemanticsResolver()) + { + BeforeDirectoryRenameJournalRetirementForTest = pinnedJournalPath => + { + Assert.Equal(journalPath, pinnedJournalPath); + File.Move(journalPath, displacedJournalPath); + File.WriteAllBytes(journalPath, originalBytes); + } + }; + + await Assert.ThrowsAsync(() => + recoveringMover.MoveDirectoryAsync(source, destination)); + + Assert.False(Directory.Exists(source)); + Assert.True(Directory.Exists(destination)); + Assert.True(File.Exists(journalPath)); + Assert.Equal(originalBytes, await File.ReadAllBytesAsync(journalPath)); + Assert.True(File.Exists(displacedJournalPath)); + Assert.Equal(originalBytes, await File.ReadAllBytesAsync(displacedJournalPath)); + } + [WindowsFact] public async Task MoveDirectoryAsync_ForeignPersistedRenameJournalAlias_FailsClosed() { @@ -1963,6 +2392,120 @@ public async Task MoveFileAsync_OpenHandleMutationAfterStaging_IsRestoredAndFail SearchOption.AllDirectories)); } + [Fact] + public async Task MoveFileAsync_UncommittedRecoverySourceClaimReplaced_PreservesReplacementAndBlocks() + { + var sourceFile = Path.Join(_root, "recovery-source-claim-source.mp3"); + var destinationFile = Path.Join(_root, "recovery-source-claim-target.mp3"); + await File.WriteAllTextAsync(sourceFile, "source-generation"); + await File.WriteAllTextAsync(destinationFile, "destination-generation"); + var interruptedMover = new FileMover( + new NullLogger(), + semanticsResolver: new FileSystemSemanticsResolver()) + { + DisableNativeFileRenameForTest = true, + AfterDestinationQuarantinedForTestAsync = (_, _) => + throw new OperationCanceledException("simulated uncommitted interruption") + }; + + await Assert.ThrowsAsync( + () => interruptedMover.MoveFileAsync(sourceFile, destinationFile)); + var claim = Assert.Single(Directory.EnumerateFiles( + _root, + "source.claim", + SearchOption.AllDirectories)); + var displaced = Path.Join(_root, "original-source-claim.mp3"); + File.Move(claim, displaced); + await File.WriteAllTextAsync(claim, "replacement-source-claim"); + + var retried = await new FileMover( + new NullLogger(), + semanticsResolver: new FileSystemSemanticsResolver()) + .MoveFileAsync(sourceFile, destinationFile); + + Assert.False(retried); + Assert.False(File.Exists(sourceFile)); + Assert.False(File.Exists(destinationFile)); + Assert.Equal("replacement-source-claim", await File.ReadAllTextAsync(claim)); + Assert.Equal("source-generation", await File.ReadAllTextAsync(displaced)); + } + + [Fact] + public async Task MoveFileAsync_UncommittedRecoveryStageReplaced_PreservesReplacementAndBlocks() + { + var sourceFile = Path.Join(_root, "recovery-stage-source.mp3"); + var destinationFile = Path.Join(_root, "recovery-stage-target.mp3"); + await File.WriteAllTextAsync(sourceFile, "source-generation"); + await File.WriteAllTextAsync(destinationFile, "destination-generation"); + var interruptedMover = new FileMover( + new NullLogger(), + semanticsResolver: new FileSystemSemanticsResolver()) + { + DisableNativeFileRenameForTest = true, + AfterDestinationQuarantinedForTestAsync = (_, _) => + throw new OperationCanceledException("simulated uncommitted interruption") + }; + + await Assert.ThrowsAsync( + () => interruptedMover.MoveFileAsync(sourceFile, destinationFile)); + var stage = Assert.Single(Directory.EnumerateFiles( + _root, + "destination.stage", + SearchOption.AllDirectories)); + var displaced = Path.Join(_root, "original-destination-stage.mp3"); + File.Move(stage, displaced); + await File.WriteAllTextAsync(stage, "replacement-stage"); + + var retried = await new FileMover( + new NullLogger(), + semanticsResolver: new FileSystemSemanticsResolver()) + .MoveFileAsync(sourceFile, destinationFile); + + Assert.False(retried); + Assert.False(File.Exists(sourceFile)); + Assert.False(File.Exists(destinationFile)); + Assert.Equal("replacement-stage", await File.ReadAllTextAsync(stage)); + Assert.Equal("source-generation", await File.ReadAllTextAsync(displaced)); + } + + [Fact] + public async Task MoveFileAsync_UncommittedRecoveryPreviousReplaced_PreservesReplacementAndBlocks() + { + var sourceFile = Path.Join(_root, "recovery-previous-source.mp3"); + var destinationFile = Path.Join(_root, "recovery-previous-target.mp3"); + await File.WriteAllTextAsync(sourceFile, "source-generation"); + await File.WriteAllTextAsync(destinationFile, "destination-generation"); + var interruptedMover = new FileMover( + new NullLogger(), + semanticsResolver: new FileSystemSemanticsResolver()) + { + DisableNativeFileRenameForTest = true, + AfterDestinationQuarantinedForTestAsync = (_, _) => + throw new OperationCanceledException("simulated uncommitted interruption") + }; + + await Assert.ThrowsAsync( + () => interruptedMover.MoveFileAsync(sourceFile, destinationFile)); + var previous = Assert.Single(Directory.EnumerateFiles( + _root, + "destination.previous", + SearchOption.AllDirectories)); + var displaced = Path.Join(_root, "original-destination-previous.mp3"); + File.Move(previous, displaced); + await File.WriteAllTextAsync(previous, "replacement-previous"); + + var retried = await new FileMover( + new NullLogger(), + semanticsResolver: new FileSystemSemanticsResolver()) + .MoveFileAsync(sourceFile, destinationFile); + + Assert.False(retried); + Assert.False(File.Exists(sourceFile)); + Assert.False(File.Exists(destinationFile)); + Assert.Equal("replacement-previous", await File.ReadAllTextAsync(previous)); + Assert.Equal("destination-generation", await File.ReadAllTextAsync(displaced)); + } + [Fact] public async Task MoveFileAsync_InterruptedAfterDestinationStage_RecoversOnRetry() { @@ -2071,6 +2614,33 @@ await Assert.ThrowsAsync( "destination.stage", SearchOption.AllDirectories)); Assert.Equal("original", await File.ReadAllTextAsync(interruptedStage)); + var interruptedPrevious = Assert.Single(Directory.EnumerateFiles( + _root, + "destination.previous", + SearchOption.AllDirectories)); + var operationState = Assert.Single(Directory.EnumerateFiles( + _root, + "operation.state", + SearchOption.AllDirectories)); + var operationPayload = await File.ReadAllTextAsync(operationState); + using (var stageParent = PinnedDirectoryCreation.OpenPinnedDirectoryNoFollow( + Path.GetDirectoryName(interruptedStage)!)) + using (var stage = stageParent.OpenExistingFile( + Path.GetFileName(interruptedStage), + requireDeleteAccess: false)) + using (var previous = stageParent.OpenExistingFile( + Path.GetFileName(interruptedPrevious), + requireDeleteAccess: false)) + { + Assert.Contains( + $"destinationStageObjectIdentity={stage.GetObjectIdentity()}", + operationPayload, + StringComparison.Ordinal); + Assert.Contains( + $"destinationPreviousObjectIdentity={previous.GetObjectIdentity()}", + operationPayload, + StringComparison.Ordinal); + } var retryMover = new FileMover( new NullLogger(), semanticsResolver: new FileSystemSemanticsResolver()); @@ -2247,22 +2817,65 @@ await Assert.ThrowsAsync( } [Fact] - public async Task MoveFileAsync_PublishedDestinationSubstitution_BlocksRecovery() + public async Task MoveFileAsync_QuarantinedPredecessorReplacedBeforeRetirement_PreservesReplacement() { - var sourceFile = Path.Join(_root, "substituted-source.mp3"); - var destinationFile = Path.Join(_root, "substituted-target.mp3"); + var sourceFile = Path.Join(_root, "predecessor-race-source.mp3"); + var destinationFile = Path.Join(_root, "predecessor-race-target.mp3"); await File.WriteAllTextAsync(sourceFile, "new-generation"); await File.WriteAllTextAsync(destinationFile, "previous-generation"); + string? displacedPredecessor = null; var mover = new FileMover( new NullLogger(), semanticsResolver: new FileSystemSemanticsResolver()) { AfterDestinationPublishedForTestAsync = _ => - throw new OperationCanceledException( - "simulated interruption after publication") - }; - - await Assert.ThrowsAsync( + { + var predecessor = Assert.Single(Directory.EnumerateFiles( + _root, + "destination.previous", + SearchOption.AllDirectories)); + displacedPredecessor = predecessor + ".displaced"; + File.Move(predecessor, displacedPredecessor); + File.WriteAllText(predecessor, "replacement-predecessor"); + return Task.CompletedTask; + } + }; + + var moved = await mover.MoveFileAsync(sourceFile, destinationFile); + + Assert.False(moved); + Assert.Equal("new-generation", await File.ReadAllTextAsync(destinationFile)); + var replacement = Assert.Single(Directory.EnumerateFiles( + _root, + "destination.previous", + SearchOption.AllDirectories)); + Assert.Equal( + "replacement-predecessor", + await File.ReadAllTextAsync(replacement)); + Assert.NotNull(displacedPredecessor); + Assert.True(File.Exists(displacedPredecessor)); + Assert.Equal( + "previous-generation", + await File.ReadAllTextAsync(displacedPredecessor!)); + } + + [Fact] + public async Task MoveFileAsync_PublishedDestinationSubstitution_BlocksRecovery() + { + var sourceFile = Path.Join(_root, "substituted-source.mp3"); + var destinationFile = Path.Join(_root, "substituted-target.mp3"); + await File.WriteAllTextAsync(sourceFile, "new-generation"); + await File.WriteAllTextAsync(destinationFile, "previous-generation"); + var mover = new FileMover( + new NullLogger(), + semanticsResolver: new FileSystemSemanticsResolver()) + { + AfterDestinationPublishedForTestAsync = _ => + throw new OperationCanceledException( + "simulated interruption after publication") + }; + + await Assert.ThrowsAsync( () => mover.MoveFileAsync(sourceFile, destinationFile)); File.Delete(destinationFile); await File.WriteAllTextAsync(destinationFile, "attacker-replacement"); @@ -2692,6 +3305,340 @@ await Assert.ThrowsAsync( $"{directory}: {string.Join(", ", Directory.EnumerateFileSystemEntries(directory).Select(Path.GetFileName))}"))); } + [Fact] + public async Task HardlinkFileAsync_CrashAfterPreparedClaimMoveBeforeEvidence_RecoversOnRetry() + { + var sourceFile = Path.Join( + _root, + "publication-claim-evidence-gap-source.mp3"); + var destinationFile = Path.Join( + _root, + "publication-claim-evidence-gap-target.mp3"); + await File.WriteAllTextAsync(sourceFile, "source generation"); + await File.WriteAllTextAsync(destinationFile, "destination generation"); + var interruptedMover = new FileMover( + new NullLogger(), + semanticsResolver: new FileSystemSemanticsResolver()) + { + AfterPreparedClaimMovedBeforeEvidenceForTestAsync = () => + throw new OperationCanceledException( + "simulated crash after prepared claim move") + }; + + await Assert.ThrowsAsync( + () => interruptedMover.HardlinkFileAsync( + sourceFile, + destinationFile)); + Assert.False(File.Exists(destinationFile)); + Assert.Single(Directory.EnumerateFiles( + _root, + "prepared.claim", + SearchOption.AllDirectories)); + + var retried = await new FileMover( + new NullLogger(), + semanticsResolver: new FileSystemSemanticsResolver()) + .HardlinkFileAsync(sourceFile, destinationFile); + + Assert.True(retried); + Assert.Equal( + "source generation", + await File.ReadAllTextAsync(destinationFile)); + Assert.Equal( + "source generation", + await File.ReadAllTextAsync(sourceFile)); + Assert.Empty(Directory.EnumerateDirectories( + _root, + ".listenarr-file-publication-*.state", + SearchOption.TopDirectoryOnly)); + } + + [Fact] + public async Task HardlinkFileAsync_CrashAfterCommitBeforePublication_RecoversOnRetry() + { + var sourceFile = Path.Join( + _root, + "publication-postcommit-source.mp3"); + var destinationFile = Path.Join( + _root, + "publication-postcommit-target.mp3"); + await File.WriteAllTextAsync(sourceFile, "source generation"); + await File.WriteAllTextAsync(destinationFile, "destination generation"); + var interruptedMover = new FileMover( + new NullLogger(), + semanticsResolver: new FileSystemSemanticsResolver()) + { + AfterPreparedPublicationCommittedForTestAsync = () => + throw new OperationCanceledException( + "simulated crash after publication commit") + }; + + await Assert.ThrowsAsync( + () => interruptedMover.HardlinkFileAsync( + sourceFile, + destinationFile)); + Assert.False(File.Exists(destinationFile)); + Assert.Single(Directory.EnumerateFiles( + _root, + "prepared.claim", + SearchOption.AllDirectories)); + Assert.Single(Directory.EnumerateFiles( + _root, + "destination.published.claim", + SearchOption.AllDirectories)); + + var retried = await new FileMover( + new NullLogger(), + semanticsResolver: new FileSystemSemanticsResolver()) + .HardlinkFileAsync(sourceFile, destinationFile); + + Assert.True(retried); + Assert.Equal( + "source generation", + await File.ReadAllTextAsync(destinationFile)); + Assert.Equal( + "source generation", + await File.ReadAllTextAsync(sourceFile)); + Assert.Empty(Directory.EnumerateDirectories( + _root, + ".listenarr-file-publication-*.state", + SearchOption.TopDirectoryOnly)); + } + + [Fact] + public async Task HardlinkFileAsync_CrashAfterPublicationBeforeGenerationEvidence_RecoversOnRetry() + { + var sourceFile = Path.Join( + _root, + "publication-postpublish-source.mp3"); + var destinationFile = Path.Join( + _root, + "publication-postpublish-target.mp3"); + await File.WriteAllTextAsync(sourceFile, "source generation"); + await File.WriteAllTextAsync(destinationFile, "destination generation"); + var interruptedMover = new FileMover( + new NullLogger(), + semanticsResolver: new FileSystemSemanticsResolver()) + { + AfterPreparedDestinationPublishedForTestAsync = () => + throw new OperationCanceledException( + "simulated crash after prepared publication") + }; + + await Assert.ThrowsAsync( + () => interruptedMover.HardlinkFileAsync( + sourceFile, + destinationFile)); + Assert.Equal( + "source generation", + await File.ReadAllTextAsync(destinationFile)); + Assert.Single(Directory.EnumerateFiles( + _root, + "destination.published.claim", + SearchOption.AllDirectories)); + + var retried = await new FileMover( + new NullLogger(), + semanticsResolver: new FileSystemSemanticsResolver()) + .HardlinkFileAsync(sourceFile, destinationFile); + + Assert.True(retried); + Assert.Equal( + "source generation", + await File.ReadAllTextAsync(destinationFile)); + Assert.Equal( + "source generation", + await File.ReadAllTextAsync(sourceFile)); + Assert.Empty(Directory.EnumerateDirectories( + _root, + ".listenarr-file-publication-*.state", + SearchOption.TopDirectoryOnly)); + } + + [Fact] + public async Task HardlinkFileAsync_PrecommitPreviousReplaced_PreservesReplacementAndBlocks() + { + var sourceFile = Path.Join(_root, "publication-precommit-previous-source.mp3"); + var destinationFile = Path.Join(_root, "publication-precommit-previous-target.mp3"); + await File.WriteAllTextAsync(sourceFile, "source generation"); + await File.WriteAllTextAsync(destinationFile, "destination generation"); + string? stateDirectory = null; + string? displacedPrevious = null; + var interruptedMover = new FileMover( + new NullLogger(), + semanticsResolver: new FileSystemSemanticsResolver()) + { + AfterPreparedDestinationCapturedForTestAsync = () => + { + stateDirectory = Assert.Single(Directory.EnumerateDirectories( + _root, + ".listenarr-file-publication-*.state", + SearchOption.TopDirectoryOnly)); + var previous = Path.Join(stateDirectory, "destination.previous"); + displacedPrevious = Path.Join(_root, "original-precommit-previous.mp3"); + File.Move(previous, displacedPrevious); + File.WriteAllText(previous, "replacement previous"); + throw new OperationCanceledException("simulated precommit previous replacement"); + } + }; + + await Assert.ThrowsAsync( + () => interruptedMover.HardlinkFileAsync(sourceFile, destinationFile)); + + var retried = await new FileMover( + new NullLogger(), + semanticsResolver: new FileSystemSemanticsResolver()) + .HardlinkFileAsync(sourceFile, destinationFile); + + Assert.False(retried); + Assert.False(File.Exists(destinationFile)); + Assert.NotNull(stateDirectory); + Assert.Equal( + "replacement previous", + await File.ReadAllTextAsync(Path.Join(stateDirectory!, "destination.previous"))); + Assert.NotNull(displacedPrevious); + Assert.Equal("destination generation", await File.ReadAllTextAsync(displacedPrevious!)); + Assert.Equal("source generation", await File.ReadAllTextAsync(sourceFile)); + } + + [Fact] + public async Task HardlinkFileAsync_PrecommitPreparedClaimReplaced_PreservesReplacementAndBlocks() + { + var sourceFile = Path.Join(_root, "publication-precommit-prepared-source.mp3"); + var destinationFile = Path.Join(_root, "publication-precommit-prepared-target.mp3"); + await File.WriteAllTextAsync(sourceFile, "source generation"); + await File.WriteAllTextAsync(destinationFile, "destination generation"); + string? stateDirectory = null; + string? displacedPrepared = null; + var interruptedMover = new FileMover( + new NullLogger(), + semanticsResolver: new FileSystemSemanticsResolver()) + { + AfterPreparedClaimPublishedForTestAsync = () => + { + stateDirectory = Assert.Single(Directory.EnumerateDirectories( + _root, + ".listenarr-file-publication-*.state", + SearchOption.TopDirectoryOnly)); + var prepared = Path.Join(stateDirectory, "prepared.claim"); + displacedPrepared = Path.Join(_root, "original-precommit-prepared.mp3"); + File.Move(prepared, displacedPrepared); + File.WriteAllText(prepared, "replacement prepared"); + throw new OperationCanceledException("simulated precommit prepared replacement"); + } + }; + + await Assert.ThrowsAsync( + () => interruptedMover.HardlinkFileAsync(sourceFile, destinationFile)); + + var retried = await new FileMover( + new NullLogger(), + semanticsResolver: new FileSystemSemanticsResolver()) + .HardlinkFileAsync(sourceFile, destinationFile); + + Assert.False(retried); + Assert.False(File.Exists(destinationFile)); + Assert.NotNull(stateDirectory); + Assert.Equal( + "replacement prepared", + await File.ReadAllTextAsync(Path.Join(stateDirectory!, "prepared.claim"))); + Assert.NotNull(displacedPrepared); + Assert.Equal("source generation", await File.ReadAllTextAsync(displacedPrepared!)); + Assert.Equal("source generation", await File.ReadAllTextAsync(sourceFile)); + } + + [Fact] + public async Task HardlinkFileAsync_CommittedPreparedClaimReplaced_PreservesReplacementAndBlocks() + { + var sourceFile = Path.Join(_root, "publication-committed-prepared-source.mp3"); + var destinationFile = Path.Join(_root, "publication-committed-prepared-target.mp3"); + await File.WriteAllTextAsync(sourceFile, "source generation"); + await File.WriteAllTextAsync(destinationFile, "destination generation"); + string? stateDirectory = null; + string? displacedPrepared = null; + var interruptedMover = new FileMover( + new NullLogger(), + semanticsResolver: new FileSystemSemanticsResolver()) + { + AfterPreparedPublicationCommittedForTestAsync = () => + { + stateDirectory = Assert.Single(Directory.EnumerateDirectories( + _root, + ".listenarr-file-publication-*.state", + SearchOption.TopDirectoryOnly)); + var prepared = Path.Join(stateDirectory, "prepared.claim"); + displacedPrepared = Path.Join(_root, "original-committed-prepared.mp3"); + File.Move(prepared, displacedPrepared); + File.WriteAllText(prepared, "replacement committed prepared"); + throw new OperationCanceledException("simulated committed prepared replacement"); + } + }; + + await Assert.ThrowsAsync( + () => interruptedMover.HardlinkFileAsync(sourceFile, destinationFile)); + + var retried = await new FileMover( + new NullLogger(), + semanticsResolver: new FileSystemSemanticsResolver()) + .HardlinkFileAsync(sourceFile, destinationFile); + + Assert.False(retried); + Assert.False(File.Exists(destinationFile)); + Assert.NotNull(stateDirectory); + Assert.Equal( + "replacement committed prepared", + await File.ReadAllTextAsync(Path.Join(stateDirectory!, "prepared.claim"))); + Assert.NotNull(displacedPrepared); + Assert.Equal("source generation", await File.ReadAllTextAsync(displacedPrepared!)); + Assert.Equal("source generation", await File.ReadAllTextAsync(sourceFile)); + } + + [Fact] + public async Task HardlinkFileAsync_CommittedPreviousReplaced_PreservesReplacementAndBlocks() + { + var sourceFile = Path.Join(_root, "publication-committed-previous-source.mp3"); + var destinationFile = Path.Join(_root, "publication-committed-previous-target.mp3"); + await File.WriteAllTextAsync(sourceFile, "source generation"); + await File.WriteAllTextAsync(destinationFile, "destination generation"); + string? stateDirectory = null; + string? displacedPrevious = null; + var interruptedMover = new FileMover( + new NullLogger(), + semanticsResolver: new FileSystemSemanticsResolver()) + { + AfterPreparedPublicationCommittedForTestAsync = () => + { + stateDirectory = Assert.Single(Directory.EnumerateDirectories( + _root, + ".listenarr-file-publication-*.state", + SearchOption.TopDirectoryOnly)); + var previous = Path.Join(stateDirectory, "destination.previous"); + displacedPrevious = Path.Join(_root, "original-committed-previous.mp3"); + File.Move(previous, displacedPrevious); + File.WriteAllText(previous, "replacement committed previous"); + throw new OperationCanceledException("simulated committed previous replacement"); + } + }; + + await Assert.ThrowsAsync( + () => interruptedMover.HardlinkFileAsync(sourceFile, destinationFile)); + + var retried = await new FileMover( + new NullLogger(), + semanticsResolver: new FileSystemSemanticsResolver()) + .HardlinkFileAsync(sourceFile, destinationFile); + + Assert.False(retried); + Assert.False(File.Exists(destinationFile)); + Assert.NotNull(stateDirectory); + Assert.Equal( + "replacement committed previous", + await File.ReadAllTextAsync(Path.Join(stateDirectory!, "destination.previous"))); + Assert.NotNull(displacedPrevious); + Assert.Equal("destination generation", await File.ReadAllTextAsync(displacedPrevious!)); + Assert.Equal("source generation", await File.ReadAllTextAsync(sourceFile)); + } + [Fact] public async Task HardlinkFileAsync_InterruptedBeforeCommitFence_RollsBackAndRetries() { @@ -2727,10 +3674,6 @@ await Assert.ThrowsAsync( stateDirectory, "destination.previous", SearchOption.TopDirectoryOnly)); - await File.WriteAllTextAsync( - Path.Join(stateDirectory, "prepared.claim"), - "source generation"); - var retried = await new FileMover( new NullLogger(), semanticsResolver: new FileSystemSemanticsResolver()) @@ -3010,6 +3953,54 @@ await File.ReadAllTextAsync( SearchOption.TopDirectoryOnly)); } + [Fact] + public async Task CleanupCopiedSourceTreeAsync_JournalReplacedAfterQuarantineRetirement_PreservesReplacement() + { + var source = Path.Join(_root, "cleanup-journal-replacement-source"); + var destination = Path.Join(_root, "cleanup-journal-replacement-target"); + Directory.CreateDirectory(source); + Directory.CreateDirectory(destination); + await File.WriteAllTextAsync(Path.Join(source, "book.m4b"), "audio"); + await File.WriteAllTextAsync(Path.Join(destination, "book.m4b"), "audio"); + var interrupted = new FileMover(new NullLogger()) + { + AfterCleanupDestinationPinnedForTestAsync = _ => + throw new IOException("simulated cleanup interruption") + }; + + var cleanup = await interrupted.CleanupCopiedSourceTreeAsync( + source, + destination); + Assert.True(cleanup.DestinationVerified); + Assert.False(cleanup.SourceRemoved); + var journalPath = Assert.Single(Directory.EnumerateFiles( + _root, + ".listenarr-copy-cleanup-*.journal", + SearchOption.TopDirectoryOnly)); + var displacedJournalPath = journalPath + ".displaced"; + var recovery = new FileMover(new NullLogger()) + { + AfterCleanupQuarantineRetiredForTest = pinnedJournalPath => + { + Assert.Equal(journalPath, pinnedJournalPath); + File.Move(journalPath, displacedJournalPath); + File.WriteAllText(journalPath, "replacement journal"); + } + }; + + var recovered = recovery.TryRecoverInterruptedCopiedSourceCleanup( + source, + out var recoveryReason); + + Assert.False(recovered); + Assert.Contains("recovery failed", recoveryReason, StringComparison.OrdinalIgnoreCase); + Assert.True(File.Exists(journalPath)); + Assert.Equal("replacement journal", await File.ReadAllTextAsync(journalPath)); + Assert.True(File.Exists(displacedJournalPath)); + Assert.False(Directory.Exists(source)); + Assert.Equal("audio", await File.ReadAllTextAsync(Path.Join(destination, "book.m4b"))); + } + [WindowsFact] public async Task CleanupCopiedSourceTreeAsync_ForeignPersistedJournalAliases_FailClosed() { @@ -3403,7 +4394,7 @@ public async Task CleanupCopiedSourceTreeAsync_EmptyDirectoryReplacementIsPreser } [WindowsFact] - public async Task MoveDirectoryAsync_RobocopyFallback_UsesArgumentList() + public async Task MoveDirectoryAsync_VerifiedFallbackFailure_DoesNotInvokeExternalProcess() { var source = Path.Join(_root, "robocopy-source"); var dest = Path.Join(_root, "robocopy-destination"); @@ -3439,20 +4430,59 @@ public async Task MoveDirectoryAsync_RobocopyFallback_UsesArgumentList() var ok = await mover.MoveDirectoryAsync(source, dest); - Assert.True(ok); + Assert.False(ok); Assert.True(publicationHookRan); - Assert.NotNull(runner.LastStartInfo); - Assert.Equal("robocopy", runner.LastStartInfo!.FileName); - Assert.True(string.IsNullOrEmpty(runner.LastStartInfo.Arguments)); - Assert.Equal(source, runner.LastStartInfo.ArgumentList[0]); - Assert.Equal(dest, runner.LastStartInfo.ArgumentList[1]); - Assert.Contains("/E", runner.LastStartInfo.ArgumentList); - Assert.DoesNotContain("/MOVE", runner.LastStartInfo.ArgumentList); - Assert.All(runner.LastStartInfo.ArgumentList, argument => - { - Assert.False(argument.StartsWith("\"", StringComparison.Ordinal)); - Assert.False(argument.EndsWith("\"", StringComparison.Ordinal)); + Assert.Null(runner.LastStartInfo); + Assert.True(File.Exists(Path.Join(source, "nested", "book.m4b"))); + Assert.False(Directory.Exists(dest)); + } + + [WindowsFact] + public async Task MoveDirectoryAsync_RobocopyFallback_DestinationParentReplacedAfterSafetyPreflight_DoesNotWriteReplacement() + { + var source = Path.Join(_root, "robocopy-race-source"); + var destinationParent = Path.Join(_root, "robocopy-race-parent"); + var displacedParent = destinationParent + ".original"; + var destination = Path.Join(destinationParent, "destination"); + var sourceFile = Path.Join(source, "nested", "book.m4b"); + Directory.CreateDirectory(Path.GetDirectoryName(sourceFile)!); + Directory.CreateDirectory(destinationParent); + await File.WriteAllTextAsync(sourceFile, "audio"); + var runner = new RecordingProcessRunner(startInfo => + { + Directory.Move(destinationParent, displacedParent); + Directory.CreateDirectory(destinationParent); + var processDestination = startInfo.ArgumentList[1]; + Directory.CreateDirectory(Path.Join(processDestination, "nested")); + File.Copy( + sourceFile, + Path.Join(processDestination, "nested", "book.m4b")); }); + var mover = new FileMover( + new NullLogger(), + runner, + Options.Create(new FileMoverOptions + { + EnableRobocopy = true, + MaxRetries = 1, + RobocopyTimeoutMs = 1000, + }), + new FileSystemSemanticsResolver()) + { + BeforeDirectoryMoveAttemptForTest = () => + throw new IOException("Force the verified directory fallback."), + BeforeDirectoryCopyPublicationForTestAsync = _ => + throw new IOException("Force the external fallback boundary.") + }; + + var ok = await mover.MoveDirectoryAsync(source, destination); + + Assert.False(ok); + Assert.Null(runner.LastStartInfo); + Assert.True(File.Exists(sourceFile)); + Assert.False(Directory.Exists(displacedParent)); + Assert.True(Directory.Exists(destinationParent)); + Assert.False(Directory.Exists(destination)); } [WindowsFact] diff --git a/tests/Features/Api/Services/FileMoverHardlinkTests.cs b/tests/Features/Api/Services/FileMoverHardlinkTests.cs index 4d099e47a..5141e3b80 100644 --- a/tests/Features/Api/Services/FileMoverHardlinkTests.cs +++ b/tests/Features/Api/Services/FileMoverHardlinkTests.cs @@ -62,7 +62,7 @@ public async Task HardlinkFileAsync_CreatesHardlink_WhenBothFilesOnSameVolume() } [Fact] - public async Task HardlinkFileAsync_CreatesDestinationDirectory_WhenMissing() + public async Task HardlinkFileAsync_MissingDestinationDirectory_FailsClosedWithoutCreatingHierarchy() { // Arrange var sourceFile = Path.Join(_root, "source.mp3"); @@ -76,9 +76,10 @@ public async Task HardlinkFileAsync_CreatesDestinationDirectory_WhenMissing() var result = await _mover.HardlinkFileAsync(sourceFile, destFile); // Assert - Assert.True(result, "HardlinkFileAsync should succeed"); - Assert.True(Directory.Exists(destDir), "Destination directory should be created"); - Assert.True(File.Exists(destFile), "Destination file should exist"); + Assert.False(result, "HardlinkFileAsync must not create an unenrolled destination hierarchy"); + Assert.False(Directory.Exists(destDir), "Destination directory must not be created"); + Assert.False(File.Exists(destFile), "Destination file must not be published"); + Assert.True(File.Exists(sourceFile), "Source file must be preserved"); } [Fact] diff --git a/tests/Features/Api/Services/FileNamingService_PatternSelectionTests.cs b/tests/Features/Api/Services/FileNamingService_PatternSelectionTests.cs index d41482fff..67712c868 100644 --- a/tests/Features/Api/Services/FileNamingService_PatternSelectionTests.cs +++ b/tests/Features/Api/Services/FileNamingService_PatternSelectionTests.cs @@ -82,15 +82,11 @@ public async Task GenerateFilePathAsync_OutputRootComparisonUsesResolvedSemantic [WindowsFact] public async Task GenerateFilePathAsync_ForeignConfiguredRootAlias_DoesNotOwnNativeCustomBase() { - var requestedRoot = Path.GetFullPath(Path.Join( - Path.GetTempPath(), - $"listenarr-naming-foreign-root-{Guid.NewGuid():N}")); - var driveRoot = Path.GetPathRoot(requestedRoot)!; - var foreignConfiguredRoot = "/" + requestedRoot[driveRoot.Length..].Replace('\\', '/'); - Assert.Equal( - requestedRoot, - Path.GetFullPath(foreignConfiguredRoot), - StringComparer.OrdinalIgnoreCase); + var requestedRoot = WindowsPathTestFixture + .GetRootRelativeAliasCompatiblePath( + "listenarr-naming-foreign-root"); + var foreignConfiguredRoot = WindowsPathTestFixture + .GetRootRelativeForeignAlias(requestedRoot); var settings = new ApplicationSettings { OutputPath = foreignConfiguredRoot, diff --git a/tests/Features/Api/Services/ImportServiceTests.cs b/tests/Features/Api/Services/ImportServiceTests.cs index f4de736d2..1c9a60cb3 100644 --- a/tests/Features/Api/Services/ImportServiceTests.cs +++ b/tests/Features/Api/Services/ImportServiceTests.cs @@ -70,6 +70,18 @@ private async Task InitDataAsync() await _downloadRepository.AddAsync(_download); } + private async Task SaveCurrentSettingsAsync( + ApplicationSettings settings) + { + var current = await _applicationSettingsRepository.GetAsync(); + if (current != null) + { + settings.Version = current.Version; + } + + return await _applicationSettingsRepository.SaveAsync(settings); + } + [Fact] public async Task ImportFilesFromDirectory_CreatesDestinationDirectory_WhenMissing() { @@ -147,7 +159,7 @@ public async Task ImportFilesFromDirectory_ForewordAndChapterOne_GetStableUnique await AddAuthorizedRootAsync(FileService.GetTempPath()); await InitDataAsync(); - var settings = await _applicationSettingsRepository.SaveAsync(new ApplicationSettingsBuilder() + var settings = await SaveCurrentSettingsAsync(new ApplicationSettingsBuilder() .WithOutputPath(outputRoot) .WithCopyFileOnCompleted() .WithMetadataProcessing() @@ -365,7 +377,7 @@ public async Task ImportSingleFile_WithWindowsShortBasePath_NormalizesFinalPath( "The short-path spelling must differ from the long path."); Assert.Contains('~', shortBasePath!); - var settings = await _applicationSettingsRepository.SaveAsync(new ApplicationSettings + var settings = await SaveCurrentSettingsAsync(new ApplicationSettings { OutputPath = outputRoot, CompletedFileAction = FileAction.Move, @@ -416,7 +428,7 @@ public async Task ImportSingleFile_WithAudiobookNarrators_AllowsNarratorTokenInN await AddAuthorizedRootAsync(FileService.GetTempPath()); await InitDataAsync(); - var settings = await _applicationSettingsRepository.SaveAsync(new ApplicationSettings + var settings = await SaveCurrentSettingsAsync(new ApplicationSettings { OutputPath = outputRoot, CompletedFileAction = FileAction.Copy, @@ -471,7 +483,7 @@ public async Task ImportSingleFile_WithoutAuthors_DoesNotUseNarratorAsAuthorFall await AddAuthorizedRootAsync(FileService.GetTempPath()); await InitDataAsync(); - var settings = await _applicationSettingsRepository.SaveAsync(new ApplicationSettings + var settings = await SaveCurrentSettingsAsync(new ApplicationSettings { OutputPath = outputRoot, CompletedFileAction = FileAction.Copy, @@ -519,7 +531,7 @@ public async Task ImportSingleFile_WithAudiobookMetadata_SupportsSubtitlePublish await AddAuthorizedRootAsync(FileService.GetTempPath()); await InitDataAsync(); - var settings = await _applicationSettingsRepository.SaveAsync(new ApplicationSettings + var settings = await SaveCurrentSettingsAsync(new ApplicationSettings { OutputPath = outputRoot, CompletedFileAction = FileAction.Copy, @@ -564,7 +576,7 @@ public async Task ImportSingleFile_WhenDestinationHasSameContent_ReusesDestinati var firstSourceFile = await FileService.GetFileAsync(sourceDir, "first.mp3", "same audio"); var secondSourceFile = await FileService.GetFileAsync(sourceDir, "second.mp3", "same audio"); - await _applicationSettingsRepository.SaveAsync(new ApplicationSettings + await SaveCurrentSettingsAsync(new ApplicationSettings { OutputPath = outputRoot, CompletedFileAction = FileAction.Copy, @@ -607,7 +619,7 @@ public async Task ImportSingleFile_WhenDestinationContentDiffers_UsesUniqueDesti var firstSourceFile = await FileService.GetFileAsync(sourceDir, "first.mp3", "old audio"); var secondSourceFile = await FileService.GetFileAsync(sourceDir, "second.mp3", "new audio"); - await _applicationSettingsRepository.SaveAsync(new ApplicationSettings + await SaveCurrentSettingsAsync(new ApplicationSettings { OutputPath = outputRoot, CompletedFileAction = FileAction.Copy, @@ -655,7 +667,7 @@ public async Task ImportFilesFromDirectory_WithAudiobookMetadata_SupportsEdition await AddAuthorizedRootAsync(FileService.GetTempPath()); await InitDataAsync(); - var settings = await _applicationSettingsRepository.SaveAsync(new ApplicationSettings + var settings = await SaveCurrentSettingsAsync(new ApplicationSettings { OutputPath = outputRoot, CompletedFileAction = FileAction.Copy, diff --git a/tests/Features/Api/Services/WorkerProcessorBoundaryTests.cs b/tests/Features/Api/Services/WorkerProcessorBoundaryTests.cs index 2a4dd005b..75fb8561d 100644 --- a/tests/Features/Api/Services/WorkerProcessorBoundaryTests.cs +++ b/tests/Features/Api/Services/WorkerProcessorBoundaryTests.cs @@ -105,9 +105,19 @@ public async Task MetadataRescanProcessor_NonAudioFile_RemovesFileRecord() using var provider = services.BuildServiceProvider(); using var operationCoordinator = new AudiobookOperationCoordinator(); + var moveQueueService = new Mock(); + moveQueueService.Setup(service => service.GetRecoveryStateForAudiobookAsync( + It.IsAny(), + It.IsAny())) + .ReturnsAsync(MoveRecoveryState.None); + moveQueueService.Setup(service => service.EnsureFilesystemMutationAllowedAsync( + It.IsAny(), + It.IsAny())) + .Returns(Task.CompletedTask); var processor = new MetadataRescanProcessor( provider.GetRequiredService(), operationCoordinator, + moveQueueService.Object, Mock.Of>()); await processor.RunCycleAsync(CancellationToken.None); @@ -243,6 +253,7 @@ public async Task UnmatchedScanProcessor_ProcessJob_CompletesWithUntrackedAudio( { var root = FileService.GetTempDirectory("unmatched-processor-root"); var file = await FileService.GetFileAsync(root, "Untracked Book.m4b", "audio"); + await AddAuthorizedRootAsync(root); await CreateApplicationSettings(); var queue = new UnmatchedScanQueueService( _provider.GetRequiredService>(), @@ -271,15 +282,13 @@ public async Task UnmatchedScanProcessor_ProcessJob_CompletesWithUntrackedAudio( [WindowsFact] public async Task UnmatchedScanProcessor_ForeignRootSyntax_DoesNotScanWindowsAlias() { - var root = FileService.GetTempDirectory("unmatched-processor-foreign-root"); + var root = FileService.GetWindowsRootRelativeTempDirectory( + "unmatched-processor-foreign-root"); var file = await FileService.GetFileAsync(root, "Foreign Alias Book.m4b", "audio"); + await AddAuthorizedRootAsync(root); await CreateApplicationSettings(); - var driveRoot = Path.GetPathRoot(root)!; - var foreignRoot = "/" + root[driveRoot.Length..].Replace('\\', '/'); - Assert.Equal( - Path.GetFullPath(root), - Path.GetFullPath(foreignRoot), - StringComparer.OrdinalIgnoreCase); + var foreignRoot = TempFileService + .GetWindowsRootRelativeForeignAlias(root); var queue = new UnmatchedScanQueueService( _provider.GetRequiredService>(), _provider.GetRequiredService()); @@ -294,7 +303,7 @@ public async Task UnmatchedScanProcessor_ForeignRootSyntax_DoesNotScanWindowsAli await queue.EnqueueAsync(foreignRoot); Assert.True(queue.Reader.TryRead(out var job)); - await Assert.ThrowsAsync(() => + await Assert.ThrowsAsync(() => processor.ProcessJobAsync(job, CancellationToken.None)); Assert.True(File.Exists(file)); @@ -303,8 +312,10 @@ await Assert.ThrowsAsync(() => } [Fact] - public async Task UnmatchedScanProcessor_ProcessJob_MissingRootCompletesEmpty() + public async Task UnmatchedScanProcessor_AuthorizedRootMissingBeforeProcessing_FailsClosed() { + var missingRoot = FileService.GetTempDirectory("missing-root"); + await AddAuthorizedRootAsync(missingRoot); await CreateApplicationSettings(); var queue = new UnmatchedScanQueueService( _provider.GetRequiredService>(), @@ -318,18 +329,57 @@ public async Task UnmatchedScanProcessor_ProcessJob_MissingRootCompletesEmpty() hubContext.Object, _provider.GetRequiredService(), _provider.GetRequiredService()); - var missingRoot = Path.Join(FileService.GetTempPath(), "missing-root"); await queue.EnqueueAsync(missingRoot); Assert.True(queue.Reader.TryRead(out var job)); + Directory.Delete(missingRoot, recursive: true); - await processor.ProcessJobAsync(job, CancellationToken.None); + await Assert.ThrowsAsync(() => + processor.ProcessJobAsync(job, CancellationToken.None)); Assert.True(queue.TryGetJob(job.Id, out var updatedJob)); - Assert.Equal("Completed", updatedJob!.Status); - Assert.Empty(updatedJob.Results!); + Assert.Equal("Processing", updatedJob!.Status); clientProxy.Verify( p => p.SendCoreAsync("UnmatchedScanComplete", It.IsAny(), It.IsAny()), - Times.Once); + Times.Never); + } + + [Fact] + public async Task UnmatchedScanProcessor_AuthorizedRootReplacedBeforeProcessing_FailsClosed() + { + var parent = FileService.GetTempDirectory("unmatched-root-replacement-parent"); + var root = Path.Join(parent, "library"); + var displaced = Path.Join(parent, "library-original"); + Directory.CreateDirectory(root); + 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)); + + Directory.Move(root, displaced); + Directory.CreateDirectory(root); + var replacementFile = await FileService.GetFileAsync( + root, + "Replacement Book.m4b", + "replacement audio"); + + await Assert.ThrowsAsync(() => + processor.ProcessJobAsync(job, CancellationToken.None)); + + Assert.True(File.Exists(replacementFile)); + Assert.True(queue.TryGetJob(job.Id, out var updatedJob)); + Assert.Equal("Processing", updatedJob!.Status); + Assert.Null(updatedJob.Results); } private static Mock CreateHubProxy(out Mock> hubContext) diff --git a/tests/Features/Api/Startup/ProductionCompositionValidationTests.cs b/tests/Features/Api/Startup/ProductionCompositionValidationTests.cs index 45746289a..1c8f51067 100644 --- a/tests/Features/Api/Startup/ProductionCompositionValidationTests.cs +++ b/tests/Features/Api/Startup/ProductionCompositionValidationTests.cs @@ -39,6 +39,8 @@ public void DevelopmentComposition_ValidatesCompleteProductionServiceGraph() [ typeof(TimeProvider), typeof(IFilesystemMutationCoordinator), + typeof(IDirectoryObjectIdentityResolver), + typeof(LibraryDirectoryOwnershipBoundaryAuthorizer), typeof(IAudiobookOperationCoordinator), typeof(IAudiobookUpdatePublisher), typeof(IRootFolderRelocationService), @@ -73,6 +75,18 @@ public void DevelopmentComposition_ValidatesCompleteProductionServiceGraph() Assert.Equal(ServiceLifetime.Singleton, descriptor.Lifetime); } + Type[] affectedScopedServiceTypes = + [ + typeof(IAudiobookDeletionCommitService) + ]; + foreach (var serviceType in affectedScopedServiceTypes) + { + var descriptor = Assert.Single( + builder.Services, + candidate => candidate.ServiceType == serviceType); + Assert.Equal(ServiceLifetime.Scoped, descriptor.Lifetime); + } + Type[] affectedHostedServiceTypes = [ typeof(ScanBackgroundService), @@ -96,6 +110,13 @@ public void DevelopmentComposition_ValidatesCompleteProductionServiceGraph() { Assert.NotNull(provider.GetRequiredService(serviceType)); } + using (var scope = provider.CreateScope()) + { + foreach (var serviceType in affectedScopedServiceTypes) + { + Assert.NotNull(scope.ServiceProvider.GetRequiredService(serviceType)); + } + } var hostedServices = provider.GetServices().ToList(); foreach (var implementationType in affectedHostedServiceTypes) diff --git a/tests/Features/Application/Audiobooks/Deletion/AudiobookDeletionCommitServiceTests.cs b/tests/Features/Application/Audiobooks/Deletion/AudiobookDeletionCommitServiceTests.cs new file mode 100644 index 000000000..28866223e --- /dev/null +++ b/tests/Features/Application/Audiobooks/Deletion/AudiobookDeletionCommitServiceTests.cs @@ -0,0 +1,100 @@ +using Listenarr.Application.Audiobooks.Deletion; +using Listenarr.Tests.Common; + +namespace Listenarr.Tests.Features.Application.Audiobooks.Deletion; + +[Trait("Area", "Library")] +[Trait("Name", "AudiobookDeletionCommitServiceTests")] +[Trait("Category", "Application")] +public sealed class AudiobookDeletionCommitServiceTests : BaseTests +{ + [Fact] + public async Task DeleteAsync_RequestCanceledWhilePreflightCompletes_DoesNotCommit() + { + // Given + const int audiobookId = 4101; + var audiobook = new Audiobook + { + Id = audiobookId, + Title = "Cancelable delete" + }; + var preflightStarted = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var releasePreflight = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var repository = new Mock(MockBehavior.Strict); + repository.Setup(service => service.GetByIdSnapshotAsync( + audiobookId, + It.IsAny())) + .Returns(async () => + { + preflightStarted.SetResult(); + return await releasePreflight.Task; + }); + using var cancellation = new CancellationTokenSource(); + var service = new AudiobookDeletionCommitService(repository.Object); + + // When + var deletion = service.DeleteAsync(audiobookId, cancellation.Token); + await preflightStarted.Task; + cancellation.Cancel(); + releasePreflight.SetResult(audiobook); + + // Then + await Assert.ThrowsAnyAsync(() => deletion); + repository.Verify(service => service.DeleteByIdAsync(audiobookId), Times.Never); + } + + [Fact] + public async Task DeleteAsync_RequestCanceledAfterCommitBoundary_CompletesCommit() + { + // Given + const int audiobookId = 4102; + var audiobook = new Audiobook + { + Id = audiobookId, + Title = "Committed delete" + }; + using var cancellation = new CancellationTokenSource(); + var repository = new Mock(MockBehavior.Strict); + repository.Setup(service => service.GetByIdSnapshotAsync( + audiobookId, + cancellation.Token)) + .ReturnsAsync(audiobook); + repository.Setup(service => service.DeleteByIdAsync(audiobookId)) + .Returns(() => + { + cancellation.Cancel(); + return Task.FromResult(true); + }); + var service = new AudiobookDeletionCommitService(repository.Object); + + // When + var result = await service.DeleteAsync(audiobookId, cancellation.Token); + + // Then + Assert.Equal(AudiobookDeletionCommitOutcome.Deleted, result.Outcome); + Assert.Same(audiobook, result.Audiobook); + Assert.True(cancellation.IsCancellationRequested); + repository.Verify(service => service.DeleteByIdAsync(audiobookId), Times.Once); + } + + [Fact] + public async Task DeleteAsync_CanceledBeforePreflight_DoesNotCommit() + { + // Given + const int audiobookId = 4103; + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + var repository = new Mock(MockBehavior.Strict); + var service = new AudiobookDeletionCommitService(repository.Object); + + // When / Then + await Assert.ThrowsAnyAsync( + () => service.DeleteAsync(audiobookId, cancellation.Token)); + repository.Verify(service => service.GetByIdSnapshotAsync( + audiobookId, + It.IsAny()), Times.Never); + repository.Verify(service => service.DeleteByIdAsync(audiobookId), Times.Never); + } +} diff --git a/tests/Features/Application/Audiobooks/Files/AudioFileServiceTests.cs b/tests/Features/Application/Audiobooks/Files/AudioFileServiceTests.cs index 79b1e0974..5bc3fc5f0 100644 --- a/tests/Features/Application/Audiobooks/Files/AudioFileServiceTests.cs +++ b/tests/Features/Application/Audiobooks/Files/AudioFileServiceTests.cs @@ -472,16 +472,12 @@ public async Task EnsureAudiobookFileAsync_SymlinkedDirectoryEscapesBasePath_Fai [WindowsFact] public async Task EnsureAudiobookFileAsync_ForeignPersistedBasePath_DoesNotAuthorizeWindowsAlias() { - var nativeBase = FileService.GetTempDirectory( + var nativeBase = FileService.GetWindowsRootRelativeTempDirectory( "audio-file-foreign-persisted-base"); var candidate = Path.Join(nativeBase, "track.m4b"); await File.WriteAllTextAsync(candidate, "audio"); - var driveRoot = Path.GetPathRoot(nativeBase)!; - var foreignBase = "/" + nativeBase[driveRoot.Length..].Replace('\\', '/'); - Assert.Equal( - Path.GetFullPath(nativeBase), - Path.GetFullPath(foreignBase), - StringComparer.OrdinalIgnoreCase); + var foreignBase = TempFileService + .GetWindowsRootRelativeForeignAlias(nativeBase); var audiobook = await _audiobookRepository.AddAsync(new Audiobook { Title = "Foreign Persisted Base", diff --git a/tests/Features/Application/Audiobooks/Files/AudiobookFileServiceCoordinationTests.cs b/tests/Features/Application/Audiobooks/Files/AudiobookFileServiceCoordinationTests.cs index c49843794..bc00bc48a 100644 --- a/tests/Features/Application/Audiobooks/Files/AudiobookFileServiceCoordinationTests.cs +++ b/tests/Features/Application/Audiobooks/Files/AudiobookFileServiceCoordinationTests.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging.Abstractions; +using Listenarr.Application.Common.Exceptions; using Listenarr.Tests.Common; @@ -9,6 +10,56 @@ namespace Listenarr.Tests.Features.Application.Audiobooks.Files; [Trait("Category", "Application")] public sealed class AudiobookFileServiceCoordinationTests : BaseTests { + [Fact] + public async Task ClaimAudiobookFileAsync_UnresolvedMoveExecution_BlocksBeforeCatalogOrFilesystemClaim() + { + var audiobook = new Audiobook + { + Id = 41, + Title = "Claim Move Fence", + BasePath = Path.GetTempPath() + }; + var moveQueueService = new Mock(MockBehavior.Strict); + moveQueueService.Setup(service => service.EnsureFilesystemMutationAllowedAsync( + audiobook.Id, + It.IsAny())) + .ThrowsAsync(new ApplicationConflictException( + "move_recovery_required", + "An interrupted move still owns this audiobook's filesystem state.")); + var audiobookRepository = new Mock(MockBehavior.Strict); + var fileRepository = new Mock(MockBehavior.Strict); + using var memoryCache = new MemoryCache(new MemoryCacheOptions()); + var service = new AudiobookFileService( + memoryCache, + new MetadataExtractionLimiter(), + audiobookRepository.Object, + fileRepository.Object, + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + NullLogger.Instance, + new FilesystemMutationCoordinator(), + new AudiobookOperationCoordinator(), + moveQueueService.Object); + var file = AudiobookFile.CreateUnresolved( + Path.Join(Path.GetTempPath(), "claim-move-fence.m4b")); + + var exception = await Assert.ThrowsAsync(() => + service.ClaimAudiobookFileAsync( + audiobook, + file, + file.Path!)); + + Assert.Equal("move_recovery_required", exception.Code); + audiobookRepository.VerifyNoOtherCalls(); + fileRepository.VerifyNoOtherCalls(); + } + [Fact] public async Task ClaimAudiobookFileAsync_AcquiresGlobalBoundaryBeforeAudiobookLock() { @@ -71,6 +122,11 @@ public async Task ClaimAudiobookFileAsync_AcquiresGlobalBoundaryBeforeAudiobookL basePath)); var rootFolderService = new Mock(MockBehavior.Strict); rootFolderService.Setup(service => service.GetAllAsync()).ReturnsAsync([]); + var moveQueueService = new Mock(MockBehavior.Strict); + moveQueueService.Setup(service => service.EnsureFilesystemMutationAllowedAsync( + audiobook.Id, + It.IsAny())) + .Returns(Task.CompletedTask); using var memoryCache = new MemoryCache(new MemoryCacheOptions()); var service = new AudiobookFileService( memoryCache, @@ -87,7 +143,8 @@ public async Task ClaimAudiobookFileAsync_AcquiresGlobalBoundaryBeforeAudiobookL rootFolderService.Object, NullLogger.Instance, globalCoordinator, - audiobookCoordinator); + audiobookCoordinator, + moveQueueService.Object); var result = await service.ClaimAudiobookFileAsync( audiobook, diff --git a/tests/Features/Application/Audiobooks/Jobs/MoveManifestIdentityTests.cs b/tests/Features/Application/Audiobooks/Jobs/MoveManifestIdentityTests.cs index 8e03e13d7..3c8d8114d 100644 --- a/tests/Features/Application/Audiobooks/Jobs/MoveManifestIdentityTests.cs +++ b/tests/Features/Application/Audiobooks/Jobs/MoveManifestIdentityTests.cs @@ -7,9 +7,77 @@ namespace Listenarr.Tests.Features.Application.Audiobooks.Jobs; public sealed class MoveManifestIdentityTests : BaseTests { [Fact] - public void Version_IsFive() + public void Version_IsSix() { - Assert.Equal(5, MoveManifestIdentity.Version); + Assert.Equal(6, MoveManifestIdentity.Version); + } + + [Fact] + public void SourceManifestsMatch_TargetBoundaryAuthorization_IsNotSourceContent() + { + var semantics = new FileSystemPathSemantics( + FileSystemPathSyntax.Unix, + FileSystemCaseSensitivity.Sensitive); + var current = new[] { SourceFile("book.m4b", 1, 2, 'A') }; + var persisted = new List + { + PersistedFile("book.m4b", 1, 2, 'A'), + MoveManifestIdentity.CreateTargetBoundaryAuthorization( + 2, + "target-generation") + }; + + Assert.True(MoveManifestIdentity.SourceManifestsMatch( + current, + persisted, + semantics)); + } + + [Fact] + public void CreateDeduplicationKey_TargetBoundaryGenerationChangesIdentity() + { + var semantics = new FileSystemPathSemantics( + FileSystemPathSyntax.Unix, + FileSystemCaseSensitivity.Sensitive); + var source = "/downloads/book"; + var target = "/library/book"; + var sourceIdentity = new PathIdentitySnapshot( + semantics.Syntax, + semantics.CaseSensitivity, + FileSystemCaseSensitivityMode.Sensitive, + "/downloads"); + var targetIdentity = new PathIdentitySnapshot( + semantics.Syntax, + semantics.CaseSensitivity, + FileSystemCaseSensitivityMode.Sensitive, + "/library"); + var firstEntries = new List + { + PersistedFile("book.m4b", 1, 2, 'A'), + MoveManifestIdentity.CreateTargetBoundaryAuthorization(2, "generation-a") + }; + var secondEntries = new List + { + PersistedFile("book.m4b", 1, 2, 'A'), + MoveManifestIdentity.CreateTargetBoundaryAuthorization(2, "generation-b") + }; + + var first = MoveManifestIdentity.CreateDeduplicationKey( + 1, + source, + sourceIdentity, + target, + targetIdentity, + firstEntries); + var second = MoveManifestIdentity.CreateDeduplicationKey( + 1, + source, + sourceIdentity, + target, + targetIdentity, + secondEntries); + + Assert.NotEqual(first, second); } [Fact] diff --git a/tests/Features/Application/Audiobooks/Jobs/MoveQueueServiceTests.cs b/tests/Features/Application/Audiobooks/Jobs/MoveQueueServiceTests.cs index 722390fff..eefa154ae 100644 --- a/tests/Features/Application/Audiobooks/Jobs/MoveQueueServiceTests.cs +++ b/tests/Features/Application/Audiobooks/Jobs/MoveQueueServiceTests.cs @@ -256,6 +256,68 @@ await service.UpdateJobStatusAsync( broadcaster.VerifyNoOtherCalls(); } + [Fact] + public async Task UpdateJobStatus_InternalNotificationCancellationAfterCommit_RemainsSuccessful() + { + var job = new MoveJob + { + Id = Guid.NewGuid(), + AudiobookId = 42, + Status = MoveJobStatus.Running, + LeaseOwner = LeaseOwner, + LeaseGeneration = 3, + LeaseExpiresAt = DateTime.UtcNow.AddMinutes(1) + }; + var persistence = new Mock(); + persistence.Setup(store => store.GetByIdAsync( + job.Id, + It.IsAny())) + .ReturnsAsync(job); + persistence.Setup(store => store.UpdateStatusAsync( + job.Id, + LeaseOwner, + job.LeaseGeneration, + MoveJobStatus.Completed, + It.IsAny(), + null, + MoveFailureKind.None, + It.IsAny(), + It.IsAny())) + .ReturnsAsync(true); + var relocation = new Mock(); + relocation.Setup(service => service.OnMoveJobStateChangedAsync( + job.Id, + It.IsAny())) + .ThrowsAsync(new TaskCanceledException( + "Injected internal post-commit notification cancellation.")); + var broadcaster = new Mock(MockBehavior.Strict); + var service = new MoveQueueService( + NullLogger.Instance, + persistence.Object, + broadcaster.Object, + TimeProvider.System, + BuildSemanticsResolver(), + relocation.Object); + + await service.UpdateJobStatusAsync( + job.Id, + LeaseOwner, + job.LeaseGeneration, + MoveJobStatus.Completed); + + persistence.Verify(store => store.UpdateStatusAsync( + job.Id, + LeaseOwner, + job.LeaseGeneration, + MoveJobStatus.Completed, + It.IsAny(), + null, + MoveFailureKind.None, + It.IsAny(), + It.IsAny()), Times.Once); + broadcaster.VerifyNoOtherCalls(); + } + [Fact] public async Task NotifyPersistedJobState_InternalError_BroadcastsPublicProjection() { @@ -774,6 +836,8 @@ [new MoveSourceManifestEntry( FileSystemCaseSensitivityMode.Auto, boundary, target), + TargetBoundaryDirectoryObjectIdentityVersion: 2, + TargetBoundaryDirectoryObjectIdentity: "test-target-boundary-identity", DeleteEmptySource: true, SourceCleanupBoundary: boundary), cancellation.Token); @@ -787,6 +851,50 @@ [new MoveSourceManifestEntry( It.Is(token => !token.CanBeCanceled)), Times.Once); } + [Fact] + public async Task EnqueueMoveAsync_InternalNotificationCancellationAfterCommit_ReturnsJobId() + { + var jobs = new List(); + var persistence = CreateInMemoryPersistence(jobs); + var relocation = new Mock(MockBehavior.Strict); + relocation.Setup(service => service.IsBoundaryProtectedAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(false); + relocation.Setup(service => service.OnMoveJobStateChangedAsync( + It.IsAny(), + It.IsAny())) + .Returns(Task.CompletedTask); + var broadcaster = new Mock(MockBehavior.Strict); + broadcaster.Setup(service => service.BroadcastAsync( + "MoveJobUpdate", + It.IsAny(), + It.IsAny())) + .ThrowsAsync(new TaskCanceledException( + "Injected post-commit move notification cancellation.")); + var service = new MoveQueueService( + NullLogger.Instance, + persistence.Object, + broadcaster.Object, + TimeProvider.System, + BuildSemanticsResolver(), + relocation.Object); + + var jobId = await service.EnqueueMoveAsync( + 9, + "/library/Title", + "/downloads/Title"); + + Assert.Equal(jobId, Assert.Single(jobs).Id); + Assert.True(service.Reader.TryRead(out var scheduled)); + Assert.Equal(jobId, scheduled.Id); + broadcaster.Verify(service => service.BroadcastAsync( + "MoveJobUpdate", + It.IsAny(), + It.IsAny()), Times.Once); + } + [Fact] public async Task RequeueMoveAsync_FailedJob_ReusesRecoveryIdentity() { @@ -866,6 +974,62 @@ await service.UpdateJobStatusAsync( It.Is(token => !token.CanBeCanceled)), Times.Once); } + [Fact] + public async Task RequeueMoveAsync_InternalNotificationCancellationAfterCommit_ReturnsJobId() + { + var jobs = new List(); + var persistence = CreateInMemoryPersistence(jobs); + var relocation = new Mock(MockBehavior.Strict); + relocation.Setup(service => service.IsBoundaryProtectedAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(false); + relocation.Setup(service => service.OnMoveJobStateChangedAsync( + It.IsAny(), + It.IsAny())) + .Returns(Task.CompletedTask); + var cancelBroadcast = false; + var broadcaster = new Mock(MockBehavior.Strict); + broadcaster.Setup(service => service.BroadcastAsync( + "MoveJobUpdate", + It.IsAny(), + It.IsAny())) + .Returns(() => cancelBroadcast + ? Task.FromException(new TaskCanceledException( + "Injected post-commit requeue notification cancellation.")) + : Task.CompletedTask); + var service = new MoveQueueService( + NullLogger.Instance, + persistence.Object, + broadcaster.Object, + TimeProvider.System, + BuildSemanticsResolver(), + relocation.Object); + var jobId = await service.EnqueueMoveAsync( + 9, + "/library/Title", + "/downloads/Title"); + Assert.True(service.Reader.TryRead(out _)); + await service.UpdateJobStatusAsync( + jobId, + LeaseOwner, + 0, + MoveJobStatus.Failed, + "copy interrupted"); + cancelBroadcast = true; + + var requeued = await service.RequeueMoveAsync(jobId); + + Assert.Equal(jobId, requeued); + Assert.True(service.Reader.TryRead(out var scheduled)); + Assert.Equal(jobId, scheduled.Id); + broadcaster.Verify(service => service.BroadcastAsync( + "MoveJobUpdate", + It.IsAny(), + It.IsAny()), Times.Exactly(3)); + } + [Fact] public async Task RequeueMoveAsync_CancelledWhileWaitingForMutationCoordinatorDoesNotRequeue() { @@ -946,7 +1110,10 @@ public async Task RequeueMoveAsync_FailedJob_ResetsRetryStateAndPreservesRecover Length = 1, LastWriteTimeUtc = DateTime.UnixEpoch, Sha256 = new string('A', 64) - } + }, + MoveManifestIdentity.CreateTargetBoundaryAuthorization( + 2, + "test-target-generation") ] }; var persistence = CreateInMemoryPersistence([job]); @@ -979,6 +1146,58 @@ public async Task RequeueMoveAsync_FailedJob_ResetsRetryStateAndPreservesRecover It.IsAny()), Times.Once); } + [Fact] + public async Task RequeueMoveAsync_NeedsAttentionVerificationWithValidEvidence_RemainsOperatorRepairOnly() + { + var sourcePath = Path.GetFullPath(Path.Join(Path.GetTempPath(), "listenarr-repair-source", "Title")); + var targetPath = Path.GetFullPath(Path.Join(Path.GetTempPath(), "listenarr-repair-target", "Title")); + var job = new MoveJob + { + Id = Guid.NewGuid(), + AudiobookId = 9, + SourcePath = sourcePath, + RequestedPath = targetPath, + Status = MoveJobStatus.NeedsAttention, + Phase = MoveJobPhase.CleaningSource, + FailureKind = MoveFailureKind.Verification, + Error = "The persisted filesystem generation changed.", + Entries = + [ + new MoveJobEntry + { + RelativePath = "book.m4b", + EntryType = MoveJobEntryType.File, + Length = 1, + LastWriteTimeUtc = DateTime.UnixEpoch, + Sha256 = new string('A', 64), + CopyState = MoveJobEntryCopyState.Verified, + CleanupState = MoveJobEntryCleanupState.Quarantined + }, + MoveManifestIdentity.CreateTargetBoundaryAuthorization( + 2, + "test-target-generation") + ] + }; + var persistence = CreateInMemoryPersistence([job]); + var service = new MoveQueueService( + NullLogger.Instance, + persistence.Object, + new NoopHubBroadcaster(), + TimeProvider.System, + BuildSemanticsResolver()); + + var requeuedJobId = await service.RequeueMoveAsync(job.Id); + + Assert.Null(requeuedJobId); + Assert.Equal(MoveJobStatus.NeedsAttention, job.Status); + Assert.Equal(MoveFailureKind.Verification, job.FailureKind); + Assert.Equal("The persisted filesystem generation changed.", job.Error); + Assert.False(service.Reader.TryRead(out _)); + persistence.Verify(store => store.RequeueAsync( + It.IsAny(), + It.IsAny()), Times.Never); + } + [Theory] [InlineData(nameof(MoveJobStatus.Failed))] [InlineData(nameof(MoveJobStatus.NeedsAttention))] @@ -1618,8 +1837,10 @@ [new MoveSourceManifestEntry( new string('A', 64))], target, targetIdentity, - deleteEmptySource, - sourceCleanupBoundary)); + TargetBoundaryDirectoryObjectIdentityVersion: 2, + TargetBoundaryDirectoryObjectIdentity: "test-target-boundary-identity", + DeleteEmptySource: deleteEmptySource, + SourceCleanupBoundary: sourceCleanupBoundary)); } } } diff --git a/tests/Features/Application/Audiobooks/Jobs/MoveRecoveryPolicyTests.cs b/tests/Features/Application/Audiobooks/Jobs/MoveRecoveryPolicyTests.cs new file mode 100644 index 000000000..7c61dbc9d --- /dev/null +++ b/tests/Features/Application/Audiobooks/Jobs/MoveRecoveryPolicyTests.cs @@ -0,0 +1,116 @@ +using Listenarr.Tests.Common; + +namespace Listenarr.Tests.Features.Application.Audiobooks.Jobs; + +[Trait("Area", "Audiobooks")] +[Trait("Name", "MoveRecoveryPolicyTests")] +[Trait("Category", "Application")] +public sealed class MoveRecoveryPolicyTests : BaseTests +{ + [Fact] + public void ClassifyAudiobookJobs_FailedPublishedUnknown_IsRetryableAndBlocking() + { + var job = CreateJob( + MoveJobStatus.Failed, + MoveJobPhase.Published, + MoveFailureKind.Unknown, + MoveJobEntryCopyState.Verified, + MoveJobEntryCleanupState.Deleted); + + var state = MoveRecoveryPolicy.ClassifyAudiobookJobs([job]); + + Assert.Equal(MoveRecoveryDisposition.RetryAvailable, state.Disposition); + Assert.True(state.BlocksFilesystemMutation); + Assert.True(state.CanRetry); + Assert.Equal(job.Id, state.JobId); + } + + [Fact] + public void ClassifyAudiobookJobs_PreMutationHistoricalFailure_DoesNotBlock() + { + var job = CreateJob( + MoveJobStatus.Failed, + MoveJobPhase.Planned, + MoveFailureKind.Unknown, + MoveJobEntryCopyState.Pending, + MoveJobEntryCleanupState.Pending); + + var state = MoveRecoveryPolicy.ClassifyAudiobookJobs([job]); + + Assert.Equal(MoveRecoveryDisposition.None, state.Disposition); + Assert.False(state.BlocksFilesystemMutation); + Assert.False(state.CanRetry); + } + + [Fact] + public void ClassifyAudiobookJobs_NeedsAttentionVerification_IsOperatorRepairOnly() + { + var job = CreateJob( + MoveJobStatus.NeedsAttention, + MoveJobPhase.CleaningSource, + MoveFailureKind.Verification, + MoveJobEntryCopyState.Verified, + MoveJobEntryCleanupState.Quarantined); + + var state = MoveRecoveryPolicy.ClassifyAudiobookJobs([job]); + + Assert.Equal(MoveRecoveryDisposition.OperatorRepairRequired, state.Disposition); + Assert.True(state.BlocksFilesystemMutation); + Assert.False(state.CanRetry); + } + + [Fact] + public void ClassifyAudiobookJobs_MultipleUnresolvedExecutions_FailsClosedAsAmbiguous() + { + var first = CreateJob( + MoveJobStatus.Failed, + MoveJobPhase.Published, + MoveFailureKind.Unknown, + MoveJobEntryCopyState.Verified, + MoveJobEntryCleanupState.Deleted); + var second = CreateJob( + MoveJobStatus.NeedsAttention, + MoveJobPhase.CleaningSource, + MoveFailureKind.Transient, + MoveJobEntryCopyState.Verified, + MoveJobEntryCleanupState.Quarantined); + + var state = MoveRecoveryPolicy.ClassifyAudiobookJobs([first, second]); + + Assert.Equal(MoveRecoveryDisposition.Ambiguous, state.Disposition); + Assert.True(state.BlocksFilesystemMutation); + Assert.False(state.CanRetry); + Assert.Null(state.JobId); + Assert.Equal(2, state.BlockingJobIds.Count); + } + + private static MoveJob CreateJob( + MoveJobStatus status, + MoveJobPhase phase, + MoveFailureKind failureKind, + MoveJobEntryCopyState copyState, + MoveJobEntryCleanupState cleanupState) => + new() + { + Id = Guid.NewGuid(), + AudiobookId = 42, + SourcePath = Path.GetFullPath(Path.Join("source", Guid.NewGuid().ToString("N"))), + RequestedPath = Path.GetFullPath(Path.Join("target", Guid.NewGuid().ToString("N"))), + Status = status, + Phase = phase, + FailureKind = failureKind, + Entries = + [ + new MoveJobEntry + { + RelativePath = "book.m4b", + EntryType = MoveJobEntryType.File, + Length = 1, + LastWriteTimeUtc = DateTime.UtcNow, + Sha256 = new string('A', 64), + CopyState = copyState, + CleanupState = cleanupState + } + ] + }; +} diff --git a/tests/Features/Application/Audiobooks/Moving/AudiobookDestinationRewriteServiceTests.cs b/tests/Features/Application/Audiobooks/Moving/AudiobookDestinationRewriteServiceTests.cs index 981d5b395..965724a25 100644 --- a/tests/Features/Application/Audiobooks/Moving/AudiobookDestinationRewriteServiceTests.cs +++ b/tests/Features/Application/Audiobooks/Moving/AudiobookDestinationRewriteServiceTests.cs @@ -67,15 +67,12 @@ public async Task RewriteDestinationAsync_PersistedUnixRoot_DoesNotAuthorizeCurr [WindowsFact] public async Task RewriteDestinationAsync_ForeignExpectedSourceAlias_DoesNotMatchNativeCurrentSource() { - var rootPath = FileService.GetTempDirectory("destination-rewrite-foreign-expected"); + var rootPath = FileService.GetWindowsRootRelativeTempDirectory( + "destination-rewrite-foreign-expected"); var sourcePath = Path.Join(rootPath, "Author", "Old Title"); var destinationPath = Path.Join(rootPath, "Author", "New Title"); - var driveRoot = Path.GetPathRoot(sourcePath)!; - var foreignExpectedSource = "/" + sourcePath[driveRoot.Length..].Replace('\\', '/'); - Assert.Equal( - Path.GetFullPath(sourcePath), - Path.GetFullPath(foreignExpectedSource), - StringComparer.OrdinalIgnoreCase); + var foreignExpectedSource = TempFileService + .GetWindowsRootRelativeForeignAlias(sourcePath); var repository = new Mock(MockBehavior.Strict); var settings = new Mock(MockBehavior.Strict); @@ -153,6 +150,101 @@ public async Task RewriteDestinationAsync_ForeignExpectedSourceAlias_DoesNotMatc It.IsAny()), Times.Never); } + [Fact] + public async Task RewriteDestinationAsync_SourceOutsideConfiguredRoots_DoesNotUseTargetSemanticsForStaleToken() + { + var rootPath = Path.Join( + Path.GetTempPath(), + $"listenarr-rewrite-insensitive-target-{Guid.NewGuid():N}"); + var destinationPath = Path.Join(rootPath, "Author", "New Title"); + var sourcePath = OperatingSystem.IsWindows() + ? @"Z:\Legacy\Author\Book" + : "/legacy/Author/Book"; + var staleExpectedSource = OperatingSystem.IsWindows() + ? @"Z:\Legacy\Author\book" + : "/legacy/Author/book"; + var targetSemantics = new FileSystemPathSemantics( + FileSystemPathSemantics.CurrentHostDefault.Syntax, + FileSystemCaseSensitivity.Insensitive); + var repository = new Mock(MockBehavior.Strict); + var settings = new Mock(MockBehavior.Strict); + var rootFolders = new Mock(MockBehavior.Strict); + var fileSystem = new Mock(MockBehavior.Strict); + var semanticsResolver = new Mock(MockBehavior.Strict); + var relocationService = new Mock(MockBehavior.Strict); + + settings.Setup(service => service.GetApplicationSettingsAsync()) + .ReturnsAsync(new ApplicationSettings { OutputPath = rootPath }); + rootFolders.Setup(service => service.GetAllAsync()) + .ReturnsAsync([]); + semanticsResolver.Setup(service => service.ResolveAsync( + rootPath, + FileSystemCaseSensitivityMode.Auto, + It.IsAny())) + .Returns(new ValueTask( + new FileSystemSemanticsResolution( + targetSemantics, + PathIdentityState.Valid, + rootPath))); + string normalizedTarget = destinationPath; + string validationReason = string.Empty; + fileSystem.Setup(service => service.TryValidateMutationTarget( + destinationPath, + It.IsAny>(), + out normalizedTarget, + out validationReason)) + .Returns(true); + repository.Setup(repo => repo.GetByIdAsync(88)) + .ReturnsAsync(new Audiobook + { + Id = 88, + Title = "Book", + BasePath = sourcePath + }); + relocationService.Setup(service => service.IsBoundaryProtectedAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(false); + repository.Setup(repo => repo.RewritePathReferencesAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(true); + + using var operationCoordinator = new AudiobookOperationCoordinator(); + var service = new AudiobookDestinationRewriteService( + repository.Object, + settings.Object, + rootFolders.Object, + fileSystem.Object, + semanticsResolver.Object, + Mock.Of>(), + relocationService.Object, + new FilesystemMutationCoordinator(), + operationCoordinator); + + var exception = await Assert.ThrowsAsync(() => + service.RewriteDestinationAsync( + 88, + destinationPath, + expectedSourcePath: staleExpectedSource)); + + Assert.Equal("source_path_changed", exception.Code); + repository.Verify(repo => repo.RewritePathReferencesAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), Times.Never); + } + [Fact] public async Task RewriteDestinationAsync_RepairsLegacyInvalidBasePathWhenExpectedSourceMatchesExactly() { @@ -160,6 +252,9 @@ public async Task RewriteDestinationAsync_RepairsLegacyInvalidBasePathWhenExpect var destinationPath = Path.Join(rootPath, "Author", "Valid Title"); const string legacyInvalidSourcePath = "\0invalid"; var normalizedLegacySourcePath = FileUtils.NormalizeStoredPath(legacyInvalidSourcePath); + var conservativeSourceSemantics = new FileSystemPathSemantics( + FileSystemPathSemantics.CurrentHostDefault.Syntax, + FileSystemCaseSensitivity.Sensitive); var repository = new Mock(MockBehavior.Strict); var settings = new Mock(MockBehavior.Strict); var rootFolders = new Mock(MockBehavior.Strict); @@ -211,7 +306,7 @@ public async Task RewriteDestinationAsync_RepairsLegacyInvalidBasePathWhenExpect 85, normalizedLegacySourcePath, destinationPath, - rootSemantics, + conservativeSourceSemantics, rootSemantics, It.IsAny())) .ReturnsAsync(true); diff --git a/tests/Features/Application/Audiobooks/Renaming/RenameServiceTests.cs b/tests/Features/Application/Audiobooks/Renaming/RenameServiceTests.cs index 2571fe65e..333b0b3c7 100644 --- a/tests/Features/Application/Audiobooks/Renaming/RenameServiceTests.cs +++ b/tests/Features/Application/Audiobooks/Renaming/RenameServiceTests.cs @@ -15,6 +15,7 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ +using Listenarr.Application.Common.Exceptions; using Listenarr.Infrastructure.Persistence.Repositories; using Listenarr.Tests.Common; using Microsoft.EntityFrameworkCore; @@ -24,7 +25,13 @@ namespace Listenarr.Tests.Features.Application.Audiobooks.Renaming { public class RenameServiceTests : IDisposable { - private readonly string _tempRoot = Path.Join(Path.GetTempPath(), "ListenarrRenameTests", Guid.NewGuid().ToString("N")); + private readonly string _tempRoot = OperatingSystem.IsWindows() + ? WindowsPathTestFixture.GetRootRelativeAliasCompatiblePath( + "ListenarrRenameTests") + : Path.Join( + Path.GetTempPath(), + "ListenarrRenameTests", + Guid.NewGuid().ToString("N")); private readonly List _contexts = new(); private readonly AudiobookOperationCoordinator _operationCoordinator = new(); @@ -189,13 +196,7 @@ public async Task ExecuteRename_CustomBasePathOutsideConfiguredRoots_AllowsInPla BasePath = customBase, Files = [ - new AudiobookFile - { - Id = 201, - AudiobookId = 20, - Path = sourcePath, - Format = "m4b" - } + CreateTrackedFile(201, 20, sourcePath) ] }; db.Audiobooks.Add(audiobook); @@ -234,12 +235,8 @@ public async Task ExecuteRename_ForeignUnixBasePath_DoesNotAuthorizeWindowsAlias Directory.CreateDirectory(nativeBase); await File.WriteAllTextAsync(nativeSource, "audio"); - var root = Path.GetPathRoot(nativeBase)!; - var foreignBase = "/" + nativeBase[root.Length..].Replace('\\', '/'); - Assert.Equal( - Path.GetFullPath(nativeBase), - Path.GetFullPath(foreignBase), - StringComparer.OrdinalIgnoreCase); + var foreignBase = WindowsPathTestFixture + .GetRootRelativeForeignAlias(nativeBase); var configuredOutput = Path.Join(_tempRoot, "configured-library"); var (service, db, _) = BuildService(new ApplicationSettings @@ -542,8 +539,11 @@ public async Task ExecuteRename_TargetUnderDifferentConfiguredRoot_IsRejected() var rootB = Path.Join(_tempRoot, "organize-root-b"); var sourceFolder = Path.Join(rootA, "Author", "Book"); var targetFolder = Path.Join(rootB, "Author", "Book"); + var sourceFile = Path.Join(sourceFolder, "Book.m4b"); + var targetFile = Path.Join(targetFolder, "Book.m4b"); Directory.CreateDirectory(sourceFolder); Directory.CreateDirectory(rootB); + await File.WriteAllTextAsync(sourceFile, "audio"); var rootFolderService = new Mock(MockBehavior.Strict); rootFolderService.Setup(service => service.GetAllAsync()) @@ -576,7 +576,8 @@ public async Task ExecuteRename_TargetUnderDifferentConfiguredRoot_IsRejected() Id = 13, Title = "Book", Authors = ["Author"], - BasePath = sourceFolder + BasePath = sourceFolder, + Files = [CreateTrackedFile(131, 13, sourceFile)] }); await db.SaveChangesAsync(); @@ -588,7 +589,16 @@ public async Task ExecuteRename_TargetUnderDifferentConfiguredRoot_IsRejected() AudiobookId = 13, CurrentFolderPath = preview.CurrentFolderPath, CurrentFolderSemantics = preview.CurrentFolderSemantics, - NewFolderPath = targetFolder + NewFolderPath = targetFolder, + FileRenames = + [ + new FileRenameOperation + { + FileId = 131, + CurrentPath = sourceFile, + NewPath = targetFile + } + ] } ])); @@ -610,7 +620,9 @@ public async Task ExecuteRename_SymbolicLinkDestinationOutsideRoot_IsRejected() Directory.CreateDirectory(sourceFolder); Directory.CreateDirectory(outsideRoot); Directory.CreateSymbolicLink(linkedRoot, outsideRoot); - await File.WriteAllTextAsync(Path.Join(sourceFolder, "book.m4b"), "audio"); + var sourceFile = Path.Join(sourceFolder, "book.m4b"); + var targetFile = Path.Join(targetFolder, "book.m4b"); + await File.WriteAllTextAsync(sourceFile, "audio"); var settings = new ApplicationSettings { OutputPath = libraryRoot, @@ -623,7 +635,8 @@ public async Task ExecuteRename_SymbolicLinkDestinationOutsideRoot_IsRejected() Id = 70, Title = "Book", Authors = new List { "Author" }, - BasePath = sourceFolder + BasePath = sourceFolder, + Files = [CreateTrackedFile(701, 70, sourceFile)] }); await db.SaveChangesAsync(); @@ -634,7 +647,16 @@ public async Task ExecuteRename_SymbolicLinkDestinationOutsideRoot_IsRejected() AudiobookId = 70, CurrentFolderPath = sourceFolder, CurrentFolderSemantics = ExpectedSemantics(sourceFolder), - NewFolderPath = targetFolder + NewFolderPath = targetFolder, + FileRenames = + [ + new FileRenameOperation + { + FileId = 701, + CurrentPath = sourceFile, + NewPath = targetFile + } + ] } })); @@ -668,10 +690,10 @@ public async Task ExecuteRename_RollsBackCompletedFileMovesAfterFailure() var (service, db, dbName) = BuildService(settings, fileMover => { - fileMover.Setup(mover => mover.PerformActionOn( - FileAction.Move, + fileMover.Setup(mover => mover.MoveFilePreservingPhysicalIdentityAsync( It.IsAny(), It.Is(dest => dest.EndsWith("Part 2.m4b", StringComparison.OrdinalIgnoreCase)), + It.IsAny(), It.IsAny())) .ReturnsAsync(false); }); @@ -685,8 +707,8 @@ public async Task ExecuteRename_RollsBackCompletedFileMovesAfterFailure() FilePath = firstSourcePath, Files = new List { - new() { Id = 71, AudiobookId = 7, Path = firstSourcePath, Format = "m4b" }, - new() { Id = 72, AudiobookId = 7, Path = secondSourcePath, Format = "m4b" } + CreateTrackedFile(71, 7, firstSourcePath), + CreateTrackedFile(72, 7, secondSourcePath) } }); await db.SaveChangesAsync(); @@ -754,10 +776,10 @@ public async Task ExecuteRename_MoverException_DoesNotExposeInternalError() }; var (service, db, _) = BuildService(settings, fileMover => { - fileMover.Setup(mover => mover.PerformActionOn( - FileAction.Move, + fileMover.Setup(mover => mover.MoveFilePreservingPhysicalIdentityAsync( sourcePath, targetPath, + It.IsAny(), It.IsAny())) .ThrowsAsync(new IOException(secret)); }); @@ -768,13 +790,7 @@ public async Task ExecuteRename_MoverException_DoesNotExposeInternalError() BasePath = sourceFolder, Files = [ - new AudiobookFile - { - Id = 731, - AudiobookId = 73, - Path = sourcePath, - Format = "m4b" - } + CreateTrackedFile(731, 73, sourcePath) ] }); await db.SaveChangesAsync(); @@ -808,6 +824,68 @@ public async Task ExecuteRename_MoverException_DoesNotExposeInternalError() Assert.False(File.Exists(targetPath)); } + [Fact] + public async Task ExecuteRename_UnresolvedMoveExecution_BlocksBeforeFileMutation() + { + var libraryRoot = Path.Join(_tempRoot, "unresolved-move-rename"); + var sourceFolder = Path.Join(libraryRoot, "Old"); + var targetFolder = Path.Join(libraryRoot, "Author", "Book"); + Directory.CreateDirectory(sourceFolder); + var sourcePath = Path.Join(sourceFolder, "old-name.m4b"); + var targetPath = Path.Join(targetFolder, "Book.m4b"); + await File.WriteAllTextAsync(sourcePath, "test"); + var moveQueue = new Mock(MockBehavior.Strict); + moveQueue.Setup(service => service.EnsureFilesystemMutationAllowedAsync( + 44, + It.IsAny())) + .ThrowsAsync(new ApplicationConflictException( + "move_recovery_required", + "An interrupted move still owns this audiobook's filesystem state.")); + var (service, db, _) = BuildService( + new ApplicationSettings + { + OutputPath = libraryRoot, + FolderNamingPattern = "{Author}/{Title}", + FileNamingPattern = "{Title}" + }, + moveQueueServiceOverride: moveQueue.Object); + db.Audiobooks.Add(new Audiobook + { + Id = 44, + Title = "Book", + Authors = ["Author"], + BasePath = sourceFolder, + FilePath = sourcePath, + Files = [CreateTrackedFile(441, 44, sourcePath)] + }); + await db.SaveChangesAsync(); + + var exception = await Assert.ThrowsAsync(() => + service.ExecuteRenameAsync( + [ + new RenameOperation + { + AudiobookId = 44, + CurrentFolderPath = sourceFolder, + CurrentFolderSemantics = ExpectedSemantics(sourceFolder), + NewFolderPath = targetFolder, + FileRenames = + [ + new FileRenameOperation + { + FileId = 441, + CurrentPath = sourcePath, + NewPath = targetPath + } + ] + } + ])); + + Assert.Equal("move_recovery_required", exception.Code); + Assert.True(File.Exists(sourcePath)); + Assert.False(File.Exists(targetPath)); + } + [Fact] public async Task ExecuteRename_MovesFileAndUpdatesDatabasePaths() { @@ -836,7 +914,7 @@ public async Task ExecuteRename_MovesFileAndUpdatesDatabasePaths() FilePath = sourcePath, Files = new List { - new() { Id = 41, AudiobookId = 4, Path = sourcePath, Format = "m4b" } + CreateTrackedFile(41, 4, sourcePath) } }); await db.SaveChangesAsync(); @@ -1002,8 +1080,8 @@ public async Task ExecuteRename_PartialInFolderRename_WithRelativeUnchangedRow_P BasePath = bookFolder, Files = [ - new AudiobookFile { Id = 181, AudiobookId = 18, Path = firstRelative }, - new AudiobookFile { Id = 182, AudiobookId = 18, Path = secondRelative } + CreateTrackedFile(181, 18, firstRelative, firstSource), + CreateTrackedFile(182, 18, secondRelative, secondSource) ] }; db.Audiobooks.Add(audiobook); @@ -1061,13 +1139,7 @@ public async Task ExecuteRename_RelativeStoredFilePath_ResolvesAgainstAudiobookB FilePath = sourcePath, Files = [ - new AudiobookFile - { - Id = 111, - AudiobookId = 11, - Path = relativePath, - Format = "m4b" - } + CreateTrackedFile(111, 11, relativePath, sourcePath) ] }; db.Audiobooks.Add(audiobook); @@ -1136,12 +1208,12 @@ public async Task ExecuteRename_CancellationAfterFirstFileMove_CompletesStableCo FolderNamingPattern = "{Author}/{Title}", FileNamingPattern = "{Title}" }, - fileMover => fileMover.Setup(mover => mover.PerformActionOn( - FileAction.Move, + fileMover => fileMover.Setup(mover => mover.MoveFilePreservingPhysicalIdentityAsync( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .Returns((_, source, destination, _) => + .Returns((source, destination, _, _) => { Directory.CreateDirectory(Path.GetDirectoryName(destination)!); File.Move(source, destination, overwrite: true); @@ -1160,18 +1232,8 @@ public async Task ExecuteRename_CancellationAfterFirstFileMove_CompletesStableCo BasePath = bookFolder, Files = [ - new AudiobookFile - { - Id = 151, - AudiobookId = 15, - Path = firstSource - }, - new AudiobookFile - { - Id = 152, - AudiobookId = 15, - Path = secondSource - } + CreateTrackedFile(151, 15, firstSource), + CreateTrackedFile(152, 15, secondSource) ] }); await db.SaveChangesAsync(); @@ -1399,14 +1461,7 @@ public async Task ExecuteRename_FilePersistenceFailure_RestoresSameScopeStateFor FileSize = 5, Files = [ - new AudiobookFile - { - Id = 101, - AudiobookId = 10, - Path = sourcePath, - Size = 5, - Format = "m4b" - } + CreateTrackedFile(101, 10, sourcePath, size: 5) ] }; db.Audiobooks.Add(audiobook); @@ -1453,65 +1508,53 @@ public async Task ExecuteRename_FilePersistenceFailure_RestoresSameScopeStateFor } [Fact] - public async Task ExecuteRename_FolderPersistenceFailure_RollsBackDirectoryAndPathState() + public async Task ExecuteRename_FolderOnlyRequest_DoesNotMoveUnownedDirectoryTree() { - var libraryRoot = Path.Join(_tempRoot, "library-persistence-rollback"); + var libraryRoot = Path.Join(_tempRoot, "folder-only-rejected"); var sourceFolder = Path.Join(libraryRoot, "Old"); var targetFolder = Path.Join(libraryRoot, "Author", "Book"); var sourcePath = Path.Join(sourceFolder, "Book.m4b"); + var unrelatedPath = Path.Join(sourceFolder, "foreign.txt"); Directory.CreateDirectory(sourceFolder); await File.WriteAllTextAsync(sourcePath, "audio"); + await File.WriteAllTextAsync(unrelatedPath, "foreign"); - var settings = new ApplicationSettings + var (service, db, _) = BuildService(new ApplicationSettings { OutputPath = libraryRoot, FolderNamingPattern = "{Author}/{Title}", FileNamingPattern = "{Title}" - }; - var (service, db, dbName) = BuildService( - settings, - contextFactory: options => new FailureInjectingListenArrDbContext(options)); - var failureContext = Assert.IsType(db); - db.Audiobooks.Add(new Audiobook + }); + var audiobook = new Audiobook { Id = 8, Title = "Book", - Authors = new List { "Author" }, + Authors = ["Author"], BasePath = sourceFolder, FilePath = sourcePath, - Files = new List - { - new() { Id = 81, AudiobookId = 8, Path = sourcePath, Format = "m4b" } - } - }); + Files = [CreateTrackedFile(81, 8, sourcePath)] + }; + db.Audiobooks.Add(audiobook); await db.SaveChangesAsync(); - failureContext.FailNextSave = true; - var result = Assert.Single(await service.ExecuteRenameAsync(new List - { - new() + var result = Assert.Single(await service.ExecuteRenameAsync( + [ + new RenameOperation { AudiobookId = 8, CurrentFolderPath = sourceFolder, CurrentFolderSemantics = ExpectedSemantics(sourceFolder), NewFolderPath = targetFolder } - })); + ])); Assert.False(result.Success); - Assert.Contains("rolled back", result.Error, StringComparison.OrdinalIgnoreCase); - Assert.True(Directory.Exists(sourceFolder)); + Assert.True(result.Conflict); + Assert.Contains("every tracked", result.Error, StringComparison.OrdinalIgnoreCase); + Assert.Equal(NormalizePath(sourceFolder), NormalizePath(audiobook.BasePath)); Assert.True(File.Exists(sourcePath)); + Assert.Equal("foreign", await File.ReadAllTextAsync(unrelatedPath)); Assert.False(Directory.Exists(targetFolder)); - - await using var verification = CreateContext(dbName); - var saved = await verification.Audiobooks - .Include(audiobook => audiobook.Files) - .SingleAsync(audiobook => audiobook.Id == 8); - Assert.Equal(NormalizePath(sourceFolder), NormalizePath(saved.BasePath)); - Assert.Equal(NormalizePath(sourcePath), NormalizePath(saved.FilePath)); - Assert.Equal(NormalizePath(sourcePath), NormalizePath(saved.Files!.Single().Path)); - Assert.Equal(PathIdentityState.Unavailable, saved.Files.Single().PathIdentityState); } [Fact] @@ -1670,7 +1713,8 @@ public override Task SaveChangesAsync( IAudiobookOperationCoordinator? operationCoordinator = null, IFileSystemSemanticsResolver? semanticsResolverOverride = null, IRootFolderService? rootFolderServiceOverride = null, - IAudiobookFilePathIdentityResolver? identityResolverOverride = null) + IAudiobookFilePathIdentityResolver? identityResolverOverride = null, + IMoveQueueService? moveQueueServiceOverride = null) { var dbName = Guid.NewGuid().ToString(); var options = new DbContextOptionsBuilder() @@ -1701,6 +1745,33 @@ public override Task SaveChangesAsync( File.Move(source, dest, true); return Task.FromResult(true); }); + fileMover.Setup(mover => mover.MoveFilePreservingPhysicalIdentityAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns((source, dest, expectedIdentity, _) => + { + if (!string.Equals( + GetPhysicalObjectIdentity(source), + expectedIdentity, + StringComparison.Ordinal)) + { + return Task.FromResult(false); + } + + var dir = Path.GetDirectoryName(dest); + if (!string.IsNullOrWhiteSpace(dir)) + { + Directory.CreateDirectory(dir); + } + + File.Move(source, dest, true); + return Task.FromResult(string.Equals( + GetPhysicalObjectIdentity(dest), + expectedIdentity, + StringComparison.Ordinal)); + }); fileMover.Setup(mover => mover.MoveDirectoryAsync(It.IsAny(), It.IsAny())) .Returns((source, dest) => { @@ -1750,6 +1821,11 @@ public override Task SaveChangesAsync( It.IsAny(), It.IsAny())) .ReturnsAsync([]); + var moveQueueService = new Mock(); + moveQueueService.Setup(service => service.EnsureFilesystemMutationAllowedAsync( + It.IsAny(), + It.IsAny())) + .Returns(Task.CompletedTask); var service = new RenameService( config.Object, fileNaming, @@ -1762,12 +1838,41 @@ public override Task SaveChangesAsync( NullLogger.Instance, semanticsResolver, operationCoordinator ?? _operationCoordinator, + moveQueueServiceOverride ?? moveQueueService.Object, directoryOwnershipStore.Object, rootFolderServiceOverride); return (service, db, dbName); } + private static AudiobookFile CreateTrackedFile( + int id, + int audiobookId, + string storedPath, + string? physicalPath = null, + string? format = "m4b", + long? size = null) + { + var file = new AudiobookFile + { + Id = id, + AudiobookId = audiobookId, + Path = storedPath, + Format = format, + Size = size + }; + file.ApplyPhysicalObjectIdentity( + GetPhysicalObjectIdentity(physicalPath ?? storedPath), + DateTime.UtcNow); + return file; + } + + private static string GetPhysicalObjectIdentity(string path) + { + using var lease = PinnedAudiobookFileRegistrationLease.Open(path); + return lease.PhysicalObjectIdentity; + } + private static IFileSystemSemanticsResolver BuildSemanticsResolver(FileSystemCaseSensitivity? caseSensitivity) { var resolver = new Mock(); diff --git a/tests/Features/Application/Audiobooks/RootFolders/RootFolderActiveMoveBoundaryTests.cs b/tests/Features/Application/Audiobooks/RootFolders/RootFolderActiveMoveBoundaryTests.cs index f3e357ccf..64410c324 100644 --- a/tests/Features/Application/Audiobooks/RootFolders/RootFolderActiveMoveBoundaryTests.cs +++ b/tests/Features/Application/Audiobooks/RootFolders/RootFolderActiveMoveBoundaryTests.cs @@ -28,7 +28,7 @@ public async Task DeleteAsync_ActiveMoveEndpointContainsRoot_IsBlocked() }; await repository.AddAsync(root); var moveQueue = new Mock(); - moveQueue.Setup(service => service.GetActiveJobsAsync(It.IsAny())) + moveQueue.Setup(service => service.GetFilesystemBlockingJobsAsync(It.IsAny())) .ReturnsAsync([ new MoveJob { @@ -67,7 +67,7 @@ public async Task DeleteAsync_ActiveMoveEndpointContainsRoot_IsBlocked() var exception = await Assert.ThrowsAsync(() => service.DeleteAsync(root.Id)); - Assert.Contains("active move", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("unresolved move", exception.Message, StringComparison.OrdinalIgnoreCase); Assert.NotNull(await repository.GetByIdAsync(root.Id)); } diff --git a/tests/Features/Application/Audiobooks/RootFolders/RootFolderServiceTests.cs b/tests/Features/Application/Audiobooks/RootFolders/RootFolderServiceTests.cs index 3e035a454..b5a5269ef 100644 --- a/tests/Features/Application/Audiobooks/RootFolders/RootFolderServiceTests.cs +++ b/tests/Features/Application/Audiobooks/RootFolders/RootFolderServiceTests.cs @@ -133,7 +133,83 @@ private static async Task DeleteAndReturnAsync( } [Fact] - public async Task Update_InsensitiveOverrideRejectsCaseVariantIdentityConflict() + public async Task Update_CaseSensitivityChange_RequiresIdentityMigrationWorkflow() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + var repository = new EfRootFolderRepository( + new TestDbFactory(options), + Mock.Of>()); + var root = new RootFolder + { + Name = "Library", + Path = rootPath, + CaseSensitivityMode = FileSystemCaseSensitivityMode.Sensitive, + ResolvedCaseSensitivity = FileSystemCaseSensitivity.Sensitive, + PathIdentityState = PathIdentityState.Valid + }; + await repository.AddAsync(root); + var service = new RootFolderService(repository, null); + + var exception = await Assert.ThrowsAsync(() => + service.UpdateAsync(new RootFolder + { + Id = root.Id, + Name = root.Name, + Path = root.Path, + IsDefault = root.IsDefault, + CaseSensitivityMode = FileSystemCaseSensitivityMode.Insensitive + })); + + Assert.Contains("path-change", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Update_MetadataOnlyPreservesPersistedAutoSemantics() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + var repository = new EfRootFolderRepository( + new TestDbFactory(options), + Mock.Of>()); + var semantics = new FileSystemPathSemantics( + FileSystemPathSemantics.CurrentHostDefault.Syntax, + FileSystemCaseSensitivity.Sensitive); + var root = new RootFolder + { + Name = "Library", + Path = rootPath, + CaseSensitivityMode = FileSystemCaseSensitivityMode.Auto, + ResolvedCaseSensitivity = FileSystemCaseSensitivity.Sensitive, + PathIdentityState = PathIdentityState.Valid, + PathIdentityKey = FileSystemPathIdentity.CreateKey("root", rootPath, semantics) + }; + await repository.AddAsync(root); + var resolver = new Mock(MockBehavior.Strict); + var service = new RootFolderService( + repository, + null, + semanticsResolver: resolver.Object); + + var updated = await service.UpdateAsync(new RootFolder + { + Id = root.Id, + Name = "Renamed", + Path = root.Path, + IsDefault = root.IsDefault, + CaseSensitivityMode = FileSystemCaseSensitivityMode.Auto + }); + + Assert.Equal("Renamed", updated.Name); + Assert.Equal(FileSystemCaseSensitivity.Sensitive, updated.ResolvedCaseSensitivity); + Assert.Equal(root.PathIdentityKey, updated.PathIdentityKey); + resolver.VerifyNoOtherCalls(); + } + + [Fact] + public async Task Update_InsensitiveOverrideRequiresIdentityMigrationWorkflow() { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()) @@ -178,7 +254,7 @@ public async Task Update_InsensitiveOverrideRejectsCaseVariantIdentityConflict() CaseSensitivityMode = FileSystemCaseSensitivityMode.Insensitive })); - Assert.Contains("already", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("path-changes", exception.Message, StringComparison.OrdinalIgnoreCase); } [Fact] @@ -357,6 +433,79 @@ public async Task Create_Throws_WhenNestedInsideExistingRoot() Assert.Contains("nested", exception.Message, StringComparison.OrdinalIgnoreCase); } + [Fact] + public async Task Create_NestedRejectedRoot_DoesNotEnrollCandidateDirectory() + { + var tempRoot = Path.Join( + Path.GetTempPath(), + "listenarr-root-create-rejected-" + Guid.NewGuid().ToString("N")); + var nestedPath = Path.Join(tempRoot, "Nested"); + Directory.CreateDirectory(nestedPath); + try + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + await using (var db = new ListenArrDbContext(options)) + { + db.RootFolders.Add(new RootFolder + { + Name = "Existing", + Path = tempRoot, + ResolvedCaseSensitivity = + FileSystemPathSemantics.CurrentHostDefault.CaseSensitivity, + PathIdentityState = PathIdentityState.Valid + }); + await db.SaveChangesAsync(); + } + + var repository = new EfRootFolderRepository( + new TestDbFactory(options), + Mock.Of>()); + var relocation = new Mock(); + relocation.Setup(service => service.IsBoundaryProtectedAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(false); + var service = new AppRootFolderService( + repository, + null, + new FileSystemSemanticsResolver(), + Mock.Of(), + relocation.Object, + new FilesystemMutationCoordinator(), + new AudiobookOperationCoordinator(), + new DirectoryObjectIdentityResolver()); + var enrollmentPath = Path.Join( + nestedPath, + ManagedDirectoryEnrollment.FileName); + Assert.False(File.Exists(enrollmentPath)); + + await Assert.ThrowsAsync(() => + service.CreateAsync(new RootFolder + { + Name = "Nested", + Path = nestedPath + })); + + Assert.False(File.Exists(enrollmentPath)); + } + finally + { + try + { + Directory.Delete(tempRoot, recursive: true); + } + catch (IOException) + { + } + catch (UnauthorizedAccessException) + { + } + } + } + [LinuxFact] public async Task Create_InsensitiveRequestedRootRejectsCaseVariantNestedExistingRoot() { @@ -752,7 +901,7 @@ public async Task Delete_Throws_WhenActiveMoveJobTouchesSourcePathInsideRoot() await db.SaveChangesAsync(); var repo = new EfRootFolderRepository(new TestDbFactory(options), Mock.Of>()); var moveQueue = new Mock(); - moveQueue.Setup(queue => queue.GetActiveJobsAsync(It.IsAny())) + moveQueue.Setup(queue => queue.GetFilesystemBlockingJobsAsync(It.IsAny())) .ReturnsAsync([ new MoveJob { @@ -765,7 +914,7 @@ public async Task Delete_Throws_WhenActiveMoveJobTouchesSourcePathInsideRoot() var exception = await Assert.ThrowsAsync(() => service.DeleteAsync(root.Id)); - Assert.Contains("active move job", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("unresolved move job", exception.Message, StringComparison.OrdinalIgnoreCase); } [Fact] @@ -780,7 +929,7 @@ public async Task Delete_Throws_WhenActiveMoveJobTouchesDestinationPathInsideRoo await db.SaveChangesAsync(); var repo = new EfRootFolderRepository(new TestDbFactory(options), Mock.Of>()); var moveQueue = new Mock(); - moveQueue.Setup(queue => queue.GetActiveJobsAsync(It.IsAny())) + moveQueue.Setup(queue => queue.GetFilesystemBlockingJobsAsync(It.IsAny())) .ReturnsAsync([ new MoveJob { @@ -793,7 +942,43 @@ public async Task Delete_Throws_WhenActiveMoveJobTouchesDestinationPathInsideRoo var exception = await Assert.ThrowsAsync(() => service.DeleteAsync(root.Id)); - Assert.Contains("active move job", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("unresolved move job", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Delete_Throws_WhenFailedPublishedMoveStillOwnsRootFilesystemState() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + var db = new ListenArrDbContext(options); + var root = new RootFolder { Name = "R", Path = rootPath }; + db.RootFolders.Add(root); + await db.SaveChangesAsync(); + var repo = new EfRootFolderRepository( + new TestDbFactory(options), + Mock.Of>()); + var moveQueue = new Mock(); + var failedJob = new MoveJob + { + Id = Guid.NewGuid(), + SourcePath = Path.Join(rootPath, "Author", "Title"), + RequestedPath = Path.Join(newRootPath, "Author", "Title"), + Status = MoveJobStatus.Failed, + Phase = MoveJobPhase.Published, + FailureKind = MoveFailureKind.Unknown + }; + moveQueue.Setup(queue => queue.GetFilesystemBlockingJobsAsync( + It.IsAny())) + .ReturnsAsync([failedJob]); + var service = new RootFolderService(repo, null!, moveQueue.Object); + + var exception = await Assert.ThrowsAsync(() => + service.DeleteAsync(root.Id)); + + Assert.Contains("unresolved move job", exception.Message, StringComparison.OrdinalIgnoreCase); + await using var verification = new ListenArrDbContext(options); + Assert.Single(verification.RootFolders); } [Fact] @@ -808,15 +993,8 @@ public async Task Delete_AllowsCompletedMoveJobTouchingRoot() await db.SaveChangesAsync(); var repo = new EfRootFolderRepository(new TestDbFactory(options), Mock.Of>()); var moveQueue = new Mock(); - moveQueue.Setup(queue => queue.GetActiveJobsAsync(It.IsAny())) - .ReturnsAsync([ - new MoveJob - { - SourcePath = Path.Join(rootPath, "Author", "Title"), - RequestedPath = Path.Join(newRootPath, "Author", "Title"), - Status = MoveJobStatus.Completed - } - ]); + moveQueue.Setup(queue => queue.GetFilesystemBlockingJobsAsync(It.IsAny())) + .ReturnsAsync([]); var service = new RootFolderService(repo, null!, moveQueue.Object); await service.DeleteAsync(root.Id); @@ -837,7 +1015,7 @@ public async Task Update_Throws_WhenActiveMoveJobTouchesOldRoot() await db.SaveChangesAsync(); var repo = new EfRootFolderRepository(new TestDbFactory(options), Mock.Of>()); var moveQueue = new Mock(); - moveQueue.Setup(queue => queue.GetActiveJobsAsync(It.IsAny())) + moveQueue.Setup(queue => queue.GetFilesystemBlockingJobsAsync(It.IsAny())) .ReturnsAsync([ new MoveJob { @@ -866,7 +1044,7 @@ public async Task Update_Throws_WhenActiveMoveJobTouchesNewRoot() await db.SaveChangesAsync(); var repo = new EfRootFolderRepository(new TestDbFactory(options), Mock.Of>()); var moveQueue = new Mock(); - moveQueue.Setup(queue => queue.GetActiveJobsAsync(It.IsAny())) + moveQueue.Setup(queue => queue.GetFilesystemBlockingJobsAsync(It.IsAny())) .ReturnsAsync([ new MoveJob { @@ -976,7 +1154,7 @@ public async Task Update_RenameWithMove_EnqueuesMovesAndUpdatesDB() } [LinuxFact] - public async Task Update_CaseOnlyRenameOnCaseSensitiveHost_MigratesAudiobookPaths() + public async Task Update_CaseOnlyRenameOnCaseSensitiveHost_RequiresPathChangeWorkflow() { var options = new DbContextOptionsBuilder() diff --git a/tests/Features/Application/Configuration/Core/ConfigurationServiceTests.cs b/tests/Features/Application/Configuration/Core/ConfigurationServiceTests.cs index 6185cf0dc..ef292849a 100644 --- a/tests/Features/Application/Configuration/Core/ConfigurationServiceTests.cs +++ b/tests/Features/Application/Configuration/Core/ConfigurationServiceTests.cs @@ -25,6 +25,25 @@ namespace Listenarr.Tests.Features.Application.Configuration.Core [Trait("Category", "ConfigurationService")] public class ConfigurationServiceTests : BaseTests { + [Fact] + public async Task GetApplicationSettings_RepositoryFailure_PropagatesInsteadOfFabricatingEditableDefaults() + { + var repository = new Mock(MockBehavior.Strict); + repository.Setup(candidate => candidate.GetAsync(It.IsAny())) + .ThrowsAsync(new IOException("simulated settings read failure")); + Init(builder => builder.WithScoped(_ => repository.Object)); + var service = _provider.GetRequiredService(); + + var exception = await Assert.ThrowsAsync(() => + service.GetApplicationSettingsAsync()); + + Assert.Equal("simulated settings read failure", exception.Message); + repository.Verify( + candidate => candidate.GetAsync(It.IsAny()), + Times.Once); + repository.VerifyNoOtherCalls(); + } + [WindowsFact] public async Task SaveApplicationSettings_ChangedOutputPath_NormalizesCurrentHostUserInputBeforePersistence() { @@ -103,7 +122,12 @@ public async Task SaveApplicationSettings_PersistsChanges() Assert.Single(saved.Webhooks!); Assert.Equal("UnitWebhook", saved.Webhooks![0].Name); - var partial = new ApplicationSettings { Id = 1, OutputPath = partialUpdatePath }; + var partial = new ApplicationSettings + { + Id = 1, + Version = saved.Version, + OutputPath = partialUpdatePath + }; await svc.SaveApplicationSettingsAsync(partial); var afterPartial = await svc.GetApplicationSettingsAsync(); @@ -179,9 +203,11 @@ await svc.SaveProwlarrImportSettingsAsync(new ProwlarrImportConnectionSettings TagFilter = "audiobooks" }); + var current = await svc.GetApplicationSettingsAsync(); await svc.SaveApplicationSettingsAsync(new ApplicationSettings { Id = 1, + Version = current.Version, OutputPath = FileUtils.GetAbsolutePath("updated-output") }); diff --git a/tests/Features/Application/Downloads/Common/DownloadClientGatewayTests.cs b/tests/Features/Application/Downloads/Common/DownloadClientGatewayTests.cs index c8a193121..732f98112 100644 --- a/tests/Features/Application/Downloads/Common/DownloadClientGatewayTests.cs +++ b/tests/Features/Application/Downloads/Common/DownloadClientGatewayTests.cs @@ -179,6 +179,27 @@ public async Task FetchDownloadsAsync_WrapsUnexpectedAdapterException() Assert.Equal(["1"], downloadClientAdapterMock.LastRequestedQueueIds); } + [LinuxFact] + public async Task GetQueueItemAsync_AmbiguousDoubleSlashPath_IsRejectedAsForeignSyntax() + { + var ambiguousPath = "//server/share/audiobooks/Book"; + var adapter = (DownloadCLientAdapterMock)((DownloadClientGateway)downloadClientGateway) + .ResolveAdapter(client); + adapter.QueueItemMock = new QueueItemBuilder() + .WithRemotePath(ambiguousPath) + .WithContentPath(ambiguousPath) + .WithStatus("completed") + .Build(); + + var exception = await Assert.ThrowsAsync(() => + downloadClientGateway.GetQueueItemAsync( + client, + new DownloadBuilder().Build(), + new QueueItem())); + + Assert.Contains("remote path mappings", exception.Message, StringComparison.OrdinalIgnoreCase); + } + [Fact] [Trait("Method", "GetQueueItemAsync")] [Trait("Scenario", "Check SourceFiles is empty when adapter gives null for both source files and content path")] diff --git a/tests/Features/Application/Downloads/Import/DownloadImportServiceTests.cs b/tests/Features/Application/Downloads/Import/DownloadImportServiceTests.cs index b18dd50f4..9640a73b5 100644 --- a/tests/Features/Application/Downloads/Import/DownloadImportServiceTests.cs +++ b/tests/Features/Application/Downloads/Import/DownloadImportServiceTests.cs @@ -15,6 +15,7 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ +using Listenarr.Application.Common.Exceptions; using Listenarr.Tests.Common; using Listenarr.Tests.Builders; using System.Runtime.InteropServices; @@ -74,6 +75,29 @@ public void ImportDestinationPlanner_PreservesUnixSegmentWhitespace() Assert.Equal("/library/ Disc 1/Chapter 01.mp3 ", destination); } + [Fact] + public async Task ImportDownloadFilesAsync_UnresolvedMoveExecution_BlocksBeforeDestinationPlanning() + { + var basePath = FileService.GetTempDirectory("download-import-unresolved-move"); + var sourceFile = await FileService.GetTempFileAsync("unresolved-import.mp3"); + var audiobook = await _audiobookRepository.AddAsync(new AudiobookBuilder() + .WithTitle("Import Move Fence") + .WithBasePath(basePath) + .Build()); + await MoveJobTestFactory.SeedUnresolvedExecutionAsync( + _provider, + audiobook.Id, + basePath, + Path.Join(FileService.GetTempPath(), $"download-import-target-{Guid.NewGuid():N}")); + + var exception = await Assert.ThrowsAsync(() => + _provider.GetRequiredService() + .ImportDownloadFilesAsync(audiobook, [sourceFile])); + + Assert.Equal("move_recovery_required", exception.Code); + Assert.True(File.Exists(sourceFile)); + } + [Fact] public async Task ImportDownloadFilesAsync_NestedRootUsesMostSpecificDestinationSemantics() { diff --git a/tests/Features/Architecture/BackendArchitectureTests.cs b/tests/Features/Architecture/BackendArchitectureTests.cs index a982eaa22..8bd026ecd 100644 --- a/tests/Features/Architecture/BackendArchitectureTests.cs +++ b/tests/Features/Architecture/BackendArchitectureTests.cs @@ -694,6 +694,77 @@ public void FeatureRegistrations_AreOwnedByFeatureModules() Assert.Empty(violations); } + [Fact] + public void FileMover_DoesNotOwnManagedHierarchyCreation() + { + var fileMoverRoot = Path.Join( + RepositoryRoot, + "listenarr.infrastructure", + "FileSystem"); + var createMissingPattern = new Regex( + @"createMissing\s*:\s*true", + RegexOptions.Compiled); + var matches = Directory + .EnumerateFiles(fileMoverRoot, "FileMover*.cs", SearchOption.TopDirectoryOnly) + .SelectMany(file => createMissingPattern + .Matches(File.ReadAllText(file)) + .Select(_ => Normalize(Path.GetRelativePath(RepositoryRoot, file)))) + .ToList(); + + Assert.Equal( + "listenarr.infrastructure/FileSystem/FileMover.FileMoveLocks.cs", + Assert.Single(matches)); + var lockSource = File.ReadAllText(Path.Join( + fileMoverRoot, + "FileMover.FileMoveLocks.cs")); + Assert.Contains( + "OpenFileMoveLockDirectory()", + lockSource, + StringComparison.Ordinal); + Assert.Contains( + "directory,\n createMissing: true", + lockSource.Replace("\r\n", "\n", StringComparison.Ordinal), + StringComparison.Ordinal); + Assert.DoesNotContain( + "createDestinationParent", + string.Join(Environment.NewLine, Directory + .EnumerateFiles(fileMoverRoot, "FileMover*.cs", SearchOption.TopDirectoryOnly) + .Select(File.ReadAllText)), + StringComparison.Ordinal); + } + + [Fact] + public void AudiobookDatabaseDeletion_UsesSharedCommitBoundary() + { + const string commitOwner = + "listenarr.application/Audiobooks/Deletion/AudiobookDeletionCommitService.cs"; + var directDeletePattern = new Regex( + @"\.\s*DeleteByIdAsync\s*\(", + RegexOptions.Compiled); + var productionRoots = new[] + { + "listenarr.application", + "listenarr.infrastructure", + "listenarr.api" + }; + + var violations = productionRoots + .SelectMany(root => Directory.EnumerateFiles( + Path.Join(RepositoryRoot, root), + "*.cs", + SearchOption.AllDirectories)) + .Where(file => !IsBuildArtifact(file)) + .Where(file => directDeletePattern.IsMatch(File.ReadAllText(file))) + .Select(file => Normalize(Path.GetRelativePath(RepositoryRoot, file))) + .Where(file => !string.Equals( + file, + commitOwner, + StringComparison.OrdinalIgnoreCase)) + .ToList(); + + Assert.Empty(violations); + } + [Fact] public void Controllers_DoNotResolveServicesOrImplementPersistence() { diff --git a/tests/Features/Common/WindowsPathTestFixtureTests.cs b/tests/Features/Common/WindowsPathTestFixtureTests.cs new file mode 100644 index 000000000..fee429516 --- /dev/null +++ b/tests/Features/Common/WindowsPathTestFixtureTests.cs @@ -0,0 +1,54 @@ +using Listenarr.Tests.Common; + +namespace Listenarr.Tests.Features.Common; + +[Trait("Name", "WindowsPathTestFixtureTests")] +[Trait("Category", "TestInfrastructure")] +public sealed class WindowsPathTestFixtureTests : BaseTests +{ + [WindowsFact] + public void CreateRootRelativeAliasCompatibleDirectory_UsesRootRelativeResolutionDrive() + { + var directory = WindowsPathTestFixture + .CreateRootRelativeAliasCompatibleDirectory("drive-contract"); + try + { + var rootRelativeDrive = Path.GetPathRoot(Path.GetFullPath( + Path.DirectorySeparatorChar.ToString())); + + Assert.Equal( + rootRelativeDrive, + Path.GetPathRoot(directory), + StringComparer.OrdinalIgnoreCase); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + [WindowsFact] + public void GetRootRelativeForeignAlias_ProvesSameNativeEndpoint() + { + var directory = WindowsPathTestFixture + .CreateRootRelativeAliasCompatibleDirectory("alias-contract"); + try + { + var nativePath = Path.Join(directory, "book.m4b"); + File.WriteAllText(nativePath, "audio"); + + var foreignPath = WindowsPathTestFixture + .GetRootRelativeForeignAlias(nativePath); + + Assert.Equal( + Path.GetFullPath(nativePath), + Path.GetFullPath(foreignPath), + StringComparer.OrdinalIgnoreCase); + Assert.True(File.Exists(foreignPath)); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } +} diff --git a/tests/Features/Domain/Utils/FileUtilsTests.cs b/tests/Features/Domain/Utils/FileUtilsTests.cs index ed0ae33d5..4beaa102d 100644 --- a/tests/Features/Domain/Utils/FileUtilsTests.cs +++ b/tests/Features/Domain/Utils/FileUtilsTests.cs @@ -554,18 +554,13 @@ public void TryValidateMutationTarget_AllowsOnlyConfiguredRoots() [WindowsFact] public void TryValidateMutationTarget_ForeignUnixAlias_IsRejectedBeforeWindowsNormalization() { - var root = Path.Join( - Path.GetTempPath(), - "fu-mutation-foreign-" + Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(root); + var root = WindowsPathTestFixture + .CreateRootRelativeAliasCompatibleDirectory( + "fu-mutation-foreign"); var nativeTarget = Path.Join(root, "book.m4b"); File.WriteAllText(nativeTarget, "audio"); - var driveRoot = Path.GetPathRoot(nativeTarget)!; - var foreignTarget = "/" + nativeTarget[driveRoot.Length..].Replace('\\', '/'); - Assert.Equal( - Path.GetFullPath(nativeTarget), - Path.GetFullPath(foreignTarget), - StringComparer.OrdinalIgnoreCase); + var foreignTarget = WindowsPathTestFixture + .GetRootRelativeForeignAlias(nativeTarget); try { diff --git a/tests/Features/Infrastructure/Configuration/Paths/RemotePathMappingServiceTests.cs b/tests/Features/Infrastructure/Configuration/Paths/RemotePathMappingServiceTests.cs index 4fc6d02e1..37fc9fc7b 100644 --- a/tests/Features/Infrastructure/Configuration/Paths/RemotePathMappingServiceTests.cs +++ b/tests/Features/Infrastructure/Configuration/Paths/RemotePathMappingServiceTests.cs @@ -174,12 +174,8 @@ await _remotePathMappingRepository.SaveAsync(new RemotePathMappingBuilder() public async Task TranslatePathAsync_ForeignPersistedLocalRoot_DoesNotMapWindowsAlias() { var nativeLocalRoot = FileUtils.GetAbsolutePath("foreign-local-root"); - var driveRoot = Path.GetPathRoot(nativeLocalRoot)!; - var foreignLocalRoot = "/" + nativeLocalRoot[driveRoot.Length..].Replace('\\', '/'); - Assert.Equal( - Path.GetFullPath(nativeLocalRoot), - Path.GetFullPath(foreignLocalRoot), - StringComparer.OrdinalIgnoreCase); + var foreignLocalRoot = TempFileService + .GetWindowsRootRelativeForeignAlias(nativeLocalRoot); await _remotePathMappingRepository.SaveAsync(new RemotePathMappingBuilder() .WithDownloadClientConfiguration(client) diff --git a/tests/Features/Infrastructure/FileSystem/PinnedAudiobookFileRegistrationLeaseTests.cs b/tests/Features/Infrastructure/FileSystem/PinnedAudiobookFileRegistrationLeaseTests.cs new file mode 100644 index 000000000..8bef7689f --- /dev/null +++ b/tests/Features/Infrastructure/FileSystem/PinnedAudiobookFileRegistrationLeaseTests.cs @@ -0,0 +1,59 @@ +using Listenarr.Tests.Common; + +namespace Listenarr.Tests.Features.Infrastructure.FileSystem; + +[Trait("Name", "PinnedAudiobookFileRegistrationLeaseTests")] +[Trait("Category", "FileSystem")] +public sealed class PinnedAudiobookFileRegistrationLeaseTests : BaseTests +{ + [LinuxFact] + public async Task OpenMetadataWriteStream_PublicPathReplaced_DoesNotOpenReplacementGeneration() + { + var parent = FileService.GetTempDirectory( + "registration-lease-metadata-replacement"); + var publicPath = await FileService.GetFileAsync( + parent, + "book.m4b", + "original generation"); + var displacedPath = Path.Join(parent, "book-original.m4b"); + using var lease = PinnedAudiobookFileRegistrationLease.Open(publicPath); + + File.Move(publicPath, displacedPath); + await File.WriteAllTextAsync(publicPath, "replacement generation"); + + Assert.Throws(() => + lease.OpenMetadataWriteStream()); + Assert.Equal( + "replacement generation", + await File.ReadAllTextAsync(publicPath)); + Assert.Equal( + "original generation", + await File.ReadAllTextAsync(displacedPath)); + } + + [WindowsFact] + public async Task StableRegistrationLease_BlocksPublicPathReplacementUntilDisposed() + { + var parent = FileService.GetTempDirectory( + "registration-lease-metadata-replacement-windows"); + var publicPath = await FileService.GetFileAsync( + parent, + "book.m4b", + "original generation"); + var displacedPath = Path.Join(parent, "book-original.m4b"); + + using (var lease = PinnedAudiobookFileRegistrationLease.Open(publicPath)) + { + Assert.ThrowsAny(() => + File.Move(publicPath, displacedPath)); + Assert.Equal( + "original generation", + await File.ReadAllTextAsync(publicPath)); + } + + File.Move(publicPath, displacedPath); + Assert.Equal( + "original generation", + await File.ReadAllTextAsync(displacedPath)); + } +} diff --git a/tests/Features/Infrastructure/FileSystem/RegistrationPublicationCleanupProcessorTests.cs b/tests/Features/Infrastructure/FileSystem/RegistrationPublicationCleanupProcessorTests.cs index 3ed110a98..79cbbf39d 100644 --- a/tests/Features/Infrastructure/FileSystem/RegistrationPublicationCleanupProcessorTests.cs +++ b/tests/Features/Infrastructure/FileSystem/RegistrationPublicationCleanupProcessorTests.cs @@ -10,14 +10,11 @@ public sealed class RegistrationPublicationCleanupProcessorTests : BaseTests [WindowsFact] public async Task RunCycleAsync_ForeignPersistedRoot_DoesNotProcessWindowsAliasCleanup() { - var root = FileService.GetTempDirectory("registration-cleanup-foreign-root"); + var root = FileService.GetWindowsRootRelativeTempDirectory( + "registration-cleanup-foreign-root"); var pending = await CreatePendingCleanupAsync(root, audiobookId: 40); - var driveRoot = Path.GetPathRoot(root)!; - var foreignRoot = "/" + root[driveRoot.Length..].Replace('\\', '/'); - Assert.Equal( - Path.GetFullPath(root), - Path.GetFullPath(foreignRoot), - StringComparer.OrdinalIgnoreCase); + var foreignRoot = TempFileService + .GetWindowsRootRelativeForeignAlias(root); using var provider = BuildProvider( foreignRoot, pending, @@ -63,14 +60,11 @@ public async Task CleanupIntent_ForeignPersistedSourceAlias_IsRejectedBeforeReco [WindowsFact] public async Task RunCycleAsync_ForeignRegisteredPathAlias_DoesNotProveCommittedRegistration() { - var root = FileService.GetTempDirectory("registration-cleanup-foreign-registration"); + var root = FileService.GetWindowsRootRelativeTempDirectory( + "registration-cleanup-foreign-registration"); var pending = await CreatePendingCleanupAsync(root, audiobookId: 48); - var driveRoot = Path.GetPathRoot(pending.DestinationPath)!; - var foreignDestination = "/" + pending.DestinationPath[driveRoot.Length..].Replace('\\', '/'); - Assert.Equal( - Path.GetFullPath(pending.DestinationPath), - Path.GetFullPath(foreignDestination), - StringComparer.OrdinalIgnoreCase); + var foreignDestination = TempFileService + .GetWindowsRootRelativeForeignAlias(pending.DestinationPath); using var provider = BuildProvider( root, pending, @@ -103,6 +97,50 @@ public async Task RunCycleAsync_ExactCommittedGeneration_RetiresPendingCleanup() Assert.Equal("audio", await File.ReadAllTextAsync(pending.SourcePath)); } + [Fact] + public async Task RunCycleAsync_CommittedGenerationWithUnavailableDestinationSemantics_PreservesPublication() + { + var root = FileService.GetTempDirectory("registration-cleanup-unavailable-semantics"); + var destinationParent = Path.Join(root, "published"); + var pending = await CreatePendingCleanupAsync( + root, + audiobookId: 50, + destinationParent); + using var provider = BuildProvider( + root, + pending, + registeredPhysicalIdentity: pending.PhysicalObjectIdentity); + var resolver = new Mock(MockBehavior.Strict); + resolver.Setup(candidate => candidate.ResolveAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns((string path, FileSystemCaseSensitivityMode _, CancellationToken _) => + ValueTask.FromResult(string.Equals( + Path.GetFullPath(path), + Path.GetFullPath(destinationParent), + OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal) + ? new FileSystemSemanticsResolution( + FileSystemPathSemantics.CurrentHostDefault, + PathIdentityState.Unavailable, + path, + "simulated transient semantics failure") + : new FileSystemSemanticsResolution( + FileSystemPathSemantics.CurrentHostDefault, + PathIdentityState.Valid, + path, + CanonicalPath: path))); + var processor = CreateProcessor(provider, resolver.Object); + + await processor.RunCycleAsync(CancellationToken.None); + + Assert.True(Directory.Exists(pending.StateDirectoryPath)); + Assert.Equal("audio", await File.ReadAllTextAsync(pending.DestinationPath)); + Assert.Equal("audio", await File.ReadAllTextAsync(pending.SourcePath)); + } + [Fact] public async Task RunCycleAsync_MissingCommittedGeneration_RollsBackPublishedAlias() { @@ -158,6 +196,25 @@ public async Task RunCycleAsync_ConflictingRegisteredGeneration_PreservesPending Assert.Equal("audio", await File.ReadAllTextAsync(pending.SourcePath)); } + [Fact] + public async Task RunCycleAsync_CommittedGenerationAfterSourceRetirement_RetiresPendingCleanup() + { + var root = FileService.GetTempDirectory("registration-cleanup-source-retired"); + var pending = await CreatePendingCleanupAsync(root, audiobookId: 51); + File.Delete(pending.SourcePath); + using var provider = BuildProvider( + root, + pending, + registeredPhysicalIdentity: pending.PhysicalObjectIdentity); + var processor = CreateProcessor(provider); + + await processor.RunCycleAsync(CancellationToken.None); + + Assert.False(Directory.Exists(pending.StateDirectoryPath)); + Assert.Equal("audio", await File.ReadAllTextAsync(pending.DestinationPath)); + Assert.False(File.Exists(pending.SourcePath)); + } + [Fact] public async Task RunCycleAsync_SourceGenerationChanged_PreservesPublishedAliasAndCleanupState() { @@ -242,10 +299,11 @@ public async Task RunCycleAsync_DestinationGenerationReplaced_PreservesReplaceme } private static RegistrationPublicationCleanupProcessor CreateProcessor( - ServiceProvider provider) => + ServiceProvider provider, + IFileSystemSemanticsResolver? semanticsResolver = null) => new( provider.GetRequiredService(), - new FileSystemSemanticsResolver(), + semanticsResolver ?? new FileSystemSemanticsResolver(), new Listenarr.Application.Common.FilesystemMutationCoordinator(), new Listenarr.Application.Audiobooks.Jobs.AudiobookOperationCoordinator(), NullLogger.Instance); @@ -315,10 +373,13 @@ private static ServiceProvider BuildProvider( private static async Task CreatePendingCleanupAsync( string root, - int audiobookId) + int audiobookId, + string? destinationParent = null) { + destinationParent ??= root; + Directory.CreateDirectory(destinationParent); var source = Path.Join(root, "source.m4b"); - var destination = Path.Join(root, "destination.m4b"); + var destination = Path.Join(destinationParent, "destination.m4b"); await File.WriteAllTextAsync(source, "audio"); var crashingMover = new FileMover( NullLogger.Instance, @@ -340,7 +401,7 @@ private static async Task CreatePendingCleanupAsync( lease.CompletePublication()); var stateDirectory = Assert.Single( Directory.EnumerateDirectories( - root, + destinationParent, ".listenarr-registration-publication-*.state")); Assert.True(File.Exists(Path.Join( stateDirectory, diff --git a/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceEmptyManifestTests.cs b/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceEmptyManifestTests.cs index 1afeb0e36..2ebab3dc4 100644 --- a/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceEmptyManifestTests.cs +++ b/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceEmptyManifestTests.cs @@ -27,7 +27,13 @@ public async Task MoveContentsAsync_EmptySourceWithoutTrackedManifest_RequiresAt Assert.True(Directory.Exists(source)); Assert.Empty(Directory.EnumerateFileSystemEntries(source)); Assert.False(Directory.Exists(target)); - Assert.Empty(await LoadPersistedManifestAsync(request.JobId)); + var persistedEntries = await LoadPersistedManifestAsync(request.JobId); + Assert.Single( + persistedEntries, + MoveManifestIdentity.IsTargetBoundaryAuthorization); + Assert.DoesNotContain( + persistedEntries, + entry => !MoveManifestIdentity.IsTargetBoundaryAuthorization(entry)); } private async Task> LoadPersistedManifestAsync(Guid jobId) diff --git a/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceMarkerPublicationTests.cs b/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceMarkerPublicationTests.cs index 7e66a5f19..f9564ede0 100644 --- a/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceMarkerPublicationTests.cs +++ b/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceMarkerPublicationTests.cs @@ -84,6 +84,53 @@ public async Task MoveContentsAsync_RecoveryMarkerTempCleanupFailure_LeavesNonAu Assert.Equal("verified audio", await File.ReadAllTextAsync(Path.Join(target, "book.m4b"))); } + [Fact] + public async Task MoveContentsAsync_RecoveryMarkerReplacedBeforePinnedRead_IsPreservedAndRequiresAttention() + { + var source = FileService.GetTempDirectory("content-move-marker-read-swap-src"); + var sourceFile = await FileService.GetFileAsync(source, "book.m4b", "verified audio"); + var target = FileService.GetTempDirectory("content-move-marker-read-swap-dst"); + var jobId = Guid.NewGuid(); + var request = await CreateLeasedMoveRequestAsync(source, target, jobId); + await PersistFileManifestAsync(jobId, "book.m4b", sourceFile); + await WriteRecoveryMarkerAsync(target, jobId, source, target, "copy-started"); + var markerPath = Path.Join(target, $".listenarr-move-{jobId:N}.pending"); + var replacement = System.Text.Json.JsonSerializer.Serialize(new + { + Version = 1, + JobId = Guid.NewGuid(), + Source = Path.GetFullPath(source), + Target = Path.GetFullPath(target), + Stage = "copy-started" + }); + var replaced = false; + using var hook = ExclusiveDirectoryCreator.PushBeforeOpenParentHook(path => + { + if (replaced + || !string.Equals( + Path.GetFullPath(path), + Path.GetFullPath(target), + StringComparison.OrdinalIgnoreCase)) + { + return; + } + + replaced = true; + File.Delete(markerPath); + File.WriteAllText(markerPath, replacement); + }); + var service = _provider.GetRequiredService(); + + var exception = await Assert.ThrowsAsync(() => + service.MoveContentsAsync(request, CancellationToken.None)); + + Assert.True(replaced); + Assert.Contains("different job", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal(replacement, await File.ReadAllTextAsync(markerPath)); + Assert.True(File.Exists(sourceFile)); + Assert.False(File.Exists(Path.Join(target, "book.m4b"))); + } + [Fact] public async Task MoveContentsAsync_RecoveryMarkerReplacedBeforeStageUpdate_IsPreservedAndRequiresAttention() { diff --git a/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceQuarantineSafetyTests.cs b/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceQuarantineSafetyTests.cs index a51628afd..a3c2d0ea9 100644 --- a/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceQuarantineSafetyTests.cs +++ b/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceQuarantineSafetyTests.cs @@ -337,6 +337,7 @@ private async Task PersistQuarantinedEntryAsync( CleanupState = MoveJobEntryCleanupState.Quarantined }); await db.SaveChangesAsync(); + await AuthorizeExistingMoveJobTargetAsync(jobId, target); } private static AudiobookContentMoveRequest CreateCleanupRequest( diff --git a/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceTargetScaffoldingTests.cs b/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceTargetScaffoldingTests.cs index a818fdaf5..267b928a9 100644 --- a/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceTargetScaffoldingTests.cs +++ b/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceTargetScaffoldingTests.cs @@ -405,6 +405,47 @@ await Assert.ThrowsAsync(() => await AssertScaffoldingNotRemovedAsync(state.Request.JobId); } + [Fact] + public async Task CleanupTerminalTargetScaffoldingAsync_MarkerReplacedBeforePinnedRead_PreservesArtifact() + { + var state = await CreateQuarantinedTargetScaffoldAsync(); + var markerPath = Path.Join(state.Quarantine, ".listenarr-scaffold-owner.json"); + var replacement = System.Text.Json.JsonSerializer.Serialize(new + { + Version = 1, + JobId = Guid.NewGuid(), + TargetPath = state.Request.Target, + PublishedRoot = state.PublishedRoot + }); + var replaced = false; + using var hook = ExclusiveDirectoryCreator.PushBeforeOpenParentHook(path => + { + if (replaced + || !string.Equals( + Path.GetFullPath(path), + Path.GetFullPath(state.Quarantine), + StringComparison.OrdinalIgnoreCase)) + { + return; + } + + replaced = true; + File.Delete(markerPath); + File.WriteAllText(markerPath, replacement); + }); + + await Assert.ThrowsAsync(() => + _provider.GetRequiredService() + .CleanupTerminalTargetScaffoldingAsync( + state.Request, + CancellationToken.None)); + + Assert.True(replaced); + Assert.Equal(replacement, await File.ReadAllTextAsync(markerPath)); + Assert.True(Directory.Exists(state.Quarantine)); + await AssertScaffoldingNotRemovedAsync(state.Request.JobId); + } + [LinuxFact] public async Task CleanupTerminalTargetScaffoldingAsync_AmbiguousPersistedMarkerPath_PreservesArtifact() { diff --git a/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceTests.cs b/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceTests.cs index 08984e8b3..0d205ba98 100644 --- a/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceTests.cs +++ b/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceTests.cs @@ -119,6 +119,137 @@ await CreateLeasedMoveRequestAsync(source, target), Assert.True(File.Exists(Path.Join(target, "extras", "cover.jpg"))); } + [Fact] + public async Task MoveContentsAsync_NormalMoveTargetBoundaryReplaced_DoesNotPublishIntoReplacement() + { + var source = FileService.GetTempDirectory( + "content-move-normal-target-replacement-src"); + var sourceFile = await FileService.GetFileAsync( + source, + "book.m4b", + "original audio"); + var targetRoot = Path.Join( + Path.GetTempPath(), + $"listenarr-normal-move-authority-{Guid.NewGuid():N}"); + var displacedRoot = targetRoot + ".original"; + var target = Path.Join(targetRoot, "Author", "Book"); + Directory.CreateDirectory(targetRoot); + try + { + var request = await CreateLeasedMoveRequestAsync(source, target); + Directory.Move(targetRoot, displacedRoot); + Directory.CreateDirectory(targetRoot); + var foreignFile = Path.Join(targetRoot, "foreign.txt"); + await File.WriteAllTextAsync( + foreignFile, + "foreign generation"); + + var service = _provider.GetRequiredService(); + var exception = await Assert.ThrowsAsync(() => + service.MoveContentsAsync(request, CancellationToken.None)); + + Assert.Contains( + "target boundary", + exception.Message, + StringComparison.OrdinalIgnoreCase); + Assert.True(File.Exists(sourceFile)); + Assert.Equal("original audio", await File.ReadAllTextAsync(sourceFile)); + Assert.False(Directory.Exists(target)); + Assert.Equal( + "foreign generation", + await File.ReadAllTextAsync(foreignFile)); + } + finally + { + if (Directory.Exists(targetRoot)) + { + Directory.Delete(targetRoot, recursive: true); + } + if (Directory.Exists(displacedRoot)) + { + Directory.Delete(displacedRoot, recursive: true); + } + } + } + + [Fact] + public async Task MoveContentsAsync_ActiveRelocationTargetRootReplaced_DoesNotPublishIntoReplacement() + { + var source = FileService.GetTempDirectory( + "content-move-relocation-target-replacement-src"); + var sourceFile = await FileService.GetFileAsync( + source, + "book.m4b", + "original audio"); + var targetRoot = Path.Join( + Path.GetTempPath(), + $"listenarr-relocation-authority-{Guid.NewGuid():N}"); + var target = Path.Join(targetRoot, "Author", "Book"); + Directory.CreateDirectory(targetRoot); + try + { + var targetIdentity = await _provider + .GetRequiredService() + .ResolveAsync(targetRoot); + Assert.True(targetIdentity.IsAvailable, targetIdentity.UnavailableReason); + var factory = _provider + .GetRequiredService>(); + Guid relocationId; + await using (var db = await factory.CreateDbContextAsync()) + { + var root = await db.RootFolders.SingleAsync(); + var relocation = new RootFolderRelocation + { + RootFolderId = root.Id, + ActiveRootFolderId = root.Id, + SourcePath = root.Path, + TargetPath = targetRoot, + Mode = RootFolderRelocationMode.Relocate, + Status = RootFolderRelocationStatus.Running, + DesiredName = root.Name, + TargetIdentityEnrollmentState = + TargetIdentityEnrollmentState.Authorized, + TargetDirectoryObjectIdentityVersion = targetIdentity.Version, + TargetDirectoryObjectIdentity = targetIdentity.Value, + TargetDirectoryObjectIdentityUnavailableReason = + targetIdentity.UnavailableReason + }; + db.RootFolderRelocations.Add(relocation); + await db.SaveChangesAsync(); + relocationId = relocation.Id; + } + + var request = await CreateLeasedMoveRequestAsync(source, target); + await using (var db = await factory.CreateDbContextAsync()) + { + var job = await db.MoveJobs.SingleAsync(candidate => + candidate.Id == request.JobId); + job.RelocationId = relocationId; + await db.SaveChangesAsync(); + } + Directory.Delete(targetRoot, recursive: true); + Directory.CreateDirectory(targetRoot); + var foreignFile = Path.Join(targetRoot, "foreign.txt"); + await File.WriteAllTextAsync(foreignFile, "foreign generation"); + + var service = _provider.GetRequiredService(); + await Assert.ThrowsAsync(() => + service.MoveContentsAsync(request, CancellationToken.None)); + + Assert.True(File.Exists(sourceFile)); + Assert.Equal("original audio", await File.ReadAllTextAsync(sourceFile)); + Assert.False(Directory.Exists(target)); + Assert.Equal("foreign generation", await File.ReadAllTextAsync(foreignFile)); + } + finally + { + if (Directory.Exists(targetRoot)) + { + Directory.Delete(targetRoot, recursive: true); + } + } + } + [LinuxFact] public async Task MoveContentsAsync_EndpointEqualityUsesBothFilesystemSemantics() { @@ -973,7 +1104,7 @@ public async Task FinalizeMove_OwnershipMarkerMissing_PreservesDirectoryAndRequi var exception = await Assert.ThrowsAsync(() => service.FinalizeMoveAsync(request, result, CancellationToken.None)); - Assert.Contains("marker is missing", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("ownership proof is unavailable", exception.Message, StringComparison.OrdinalIgnoreCase); Assert.True(Directory.Exists(oldTitle)); Assert.True(File.Exists(result.RecoveryMarkerPath)); } @@ -1076,6 +1207,97 @@ await recoveryService.ResumeSourceCleanupAsync( Assert.True(File.Exists(Path.Join(target, "book.m4b"))); } + [Fact] + public async Task MoveContentsAsync_RetryWithRestoredSourceAndEmptyCleanupState_CompletesSafely() + { + var source = FileService.GetTempDirectory("content-move-source-restored-state-retry"); + await FileService.GetFileAsync(source, "book.m4b", "audio"); + var target = Path.Join( + FileService.GetTempPath(), + $"content-move-source-restored-state-retry-dst-{Guid.NewGuid():N}"); + var request = await CreateLeasedMoveRequestAsync(source, target); + var failingService = new AudiobookContentMoveService( + _provider.GetRequiredService>(), + _provider.GetRequiredService>(), + TimeProvider.System, + new InterruptAfterEmptySourceQuarantine(source, recreateSource: false)); + + await Assert.ThrowsAsync(() => failingService.MoveContentsAsync( + request, + CancellationToken.None)); + + var statePath = Path.Join( + Path.GetDirectoryName(source)!, + $".listenarr-quarantine-{request.JobId:N}", + ".listenarr-empty-source.state"); + var claimPath = Path.Join(statePath, "source.claim"); + Assert.False(Directory.Exists(source)); + Assert.True(Directory.Exists(claimPath)); + Assert.Empty(Directory.EnumerateFileSystemEntries(claimPath)); + Directory.Move(claimPath, source); + Assert.True(Directory.Exists(source)); + Assert.Empty(Directory.EnumerateFileSystemEntries(source)); + Assert.Empty(Directory.EnumerateFileSystemEntries(statePath)); + + var recoveryService = _provider.GetRequiredService(); + var recovered = Assert.IsType( + await recoveryService.GetRecoverableMoveAsync( + request, + CancellationToken.None)); + await recoveryService.ResumeSourceCleanupAsync( + request, + recovered, + CancellationToken.None); + + Assert.False(Directory.Exists(source)); + Assert.False(Directory.Exists(statePath)); + Assert.True(File.Exists(Path.Join(target, "book.m4b"))); + } + + [Fact] + public async Task MoveContentsAsync_EmptySourceStateNativeDeleteFailure_RemainsRecoverable() + { + var source = FileService.GetTempDirectory("content-move-source-state-delete-retry"); + await FileService.GetFileAsync(source, "book.m4b", "audio"); + var target = Path.Join( + FileService.GetTempPath(), + $"content-move-source-state-delete-retry-dst-{Guid.NewGuid():N}"); + var request = await CreateLeasedMoveRequestAsync(source, target); + var failingService = new AudiobookContentMoveService( + _provider.GetRequiredService>(), + _provider.GetRequiredService>(), + TimeProvider.System, + new FailEmptySourceStateDeleteOnce()); + + var exception = await Assert.ThrowsAsync(() => failingService.MoveContentsAsync( + request, + CancellationToken.None)); + + Assert.IsType(exception.InnerException); + var statePath = Path.Join( + Path.GetDirectoryName(source)!, + $".listenarr-quarantine-{request.JobId:N}", + ".listenarr-empty-source.state"); + Assert.False(Directory.Exists(source)); + Assert.True(Directory.Exists(statePath)); + Assert.Empty(Directory.EnumerateFileSystemEntries(statePath)); + Assert.True(File.Exists(Path.Join(target, "book.m4b"))); + + var recoveryService = _provider.GetRequiredService(); + var recovered = Assert.IsType( + await recoveryService.GetRecoverableMoveAsync( + request, + CancellationToken.None)); + await recoveryService.ResumeSourceCleanupAsync( + request, + recovered, + CancellationToken.None); + + Assert.False(Directory.Exists(source)); + Assert.False(Directory.Exists(statePath)); + Assert.True(File.Exists(Path.Join(target, "book.m4b"))); + } + [Fact] public async Task MoveContentsAsync_RecreatedSourceDuringQuarantineIsPreserved() { @@ -1602,6 +1824,7 @@ await WriteQuarantineOwnershipMarkerAsync( await db.SaveChangesAsync(); } + await AuthorizeExistingMoveJobTargetAsync(jobId, target); var service = _provider.GetRequiredService(); var resumed = await service.ResumeSourceCleanupAsync( new AudiobookContentMoveRequest( @@ -1627,9 +1850,11 @@ await WriteQuarantineOwnershipMarkerAsync( Assert.False(Directory.Exists(quarantineRoot)); Assert.False(Directory.Exists(source)); await using var verification = await factory.CreateDbContextAsync(); + var persistedEntries = await verification.MoveJobEntries.ToListAsync(); Assert.Equal( MoveJobEntryCleanupState.Deleted, - (await verification.MoveJobEntries.SingleAsync()).CleanupState); + persistedEntries.Single(entry => + !MoveManifestIdentity.IsTargetBoundaryAuthorization(entry)).CleanupState); } [Fact] @@ -1670,6 +1895,7 @@ public async Task ResumeSourceCleanup_DeletedQuarantine_ConvergesAfterCrash() await db.SaveChangesAsync(); } + await AuthorizeExistingMoveJobTargetAsync(jobId, target); var service = _provider.GetRequiredService(); var resumed = await service.ResumeSourceCleanupAsync( new AudiobookContentMoveRequest( @@ -1693,9 +1919,11 @@ public async Task ResumeSourceCleanup_DeletedQuarantine_ConvergesAfterCrash() Assert.True(resumed.SourceCleanupCompleted); Assert.False(Directory.Exists(source)); await using var verification = await factory.CreateDbContextAsync(); + var persistedEntries = await verification.MoveJobEntries.ToListAsync(); Assert.Equal( MoveJobEntryCleanupState.Deleted, - (await verification.MoveJobEntries.SingleAsync()).CleanupState); + persistedEntries.Single(entry => + !MoveManifestIdentity.IsTargetBoundaryAuthorization(entry)).CleanupState); } [Fact] @@ -1749,6 +1977,7 @@ await WriteQuarantineOwnershipMarkerAsync( await db.SaveChangesAsync(); } + await AuthorizeExistingMoveJobTargetAsync(jobId, target); var service = _provider.GetRequiredService(); await Assert.ThrowsAsync(() => service.ResumeSourceCleanupAsync( new AudiobookContentMoveRequest( @@ -1916,7 +2145,8 @@ private async Task PersistFileManifestAsync( var existing = await db.MoveJobEntries .Where(entry => entry.MoveJobId == jobId) .ToListAsync(); - db.MoveJobEntries.RemoveRange(existing); + db.MoveJobEntries.RemoveRange(existing.Where(entry => + !MoveManifestIdentity.IsTargetBoundaryAuthorization(entry))); db.MoveJobEntries.Add(new MoveJobEntry { MoveJobId = jobId, @@ -1960,9 +2190,20 @@ private async Task CreateLeasedMoveRequestAsync( string? sourceCleanupBoundary = null) { var id = jobId ?? Guid.NewGuid(); + var effectiveTargetSemantics = + targetSemantics + ?? sourceSemantics + ?? FileSystemPathSemantics.CurrentHostDefault; + var targetBoundary = FindMoveTargetBoundary(target, effectiveTargetSemantics); + var targetDirectoryIdentity = await _provider + .GetRequiredService() + .ResolveAsync(targetBoundary); + Assert.True( + targetDirectoryIdentity.IsAvailable, + targetDirectoryIdentity.UnavailableReason); var factory = _provider.GetRequiredService>(); await using var db = await factory.CreateDbContextAsync(); - db.MoveJobs.Add(new MoveJob + var job = new MoveJob { Id = id, AudiobookId = 1, @@ -1972,8 +2213,21 @@ private async Task CreateLeasedMoveRequestAsync( LeaseOwner = TestLeaseOwner, LeaseGeneration = 1, LeaseExpiresAt = DateTime.UtcNow.AddMinutes(5), - ActiveDeduplicationKey = $"test:{id:N}" - }); + ActiveDeduplicationKey = $"test:{id:N}", + IdentityKeyVersion = MoveManifestIdentity.Version, + Entries = + [ + MoveManifestIdentity.CreateTargetBoundaryAuthorization( + targetDirectoryIdentity.Version!.Value, + targetDirectoryIdentity.Value!) + ] + }; + job.SetTargetIdentity(new PathIdentitySnapshot( + effectiveTargetSemantics.Syntax, + effectiveTargetSemantics.CaseSensitivity, + FileSystemCaseSensitivityMode.Auto, + targetBoundary)); + db.MoveJobs.Add(job); await db.SaveChangesAsync(); if (!IsTestFilesystemRoot( source, @@ -1988,11 +2242,45 @@ private async Task CreateLeasedMoveRequestAsync( id, deleteEmptySource, sourceSemantics ?? FileSystemPathSemantics.CurrentHostDefault, - targetSemantics ?? sourceSemantics ?? FileSystemPathSemantics.CurrentHostDefault, + effectiveTargetSemantics, LeaseToken(1), sourceCleanupBoundary); } + private async Task AuthorizeExistingMoveJobTargetAsync( + Guid jobId, + string target, + FileSystemPathSemantics? targetSemantics = null) + { + var semantics = targetSemantics ?? FileSystemPathSemantics.CurrentHostDefault; + var targetBoundary = FindMoveTargetBoundary(target, semantics); + var targetDirectoryIdentity = await _provider + .GetRequiredService() + .ResolveAsync(targetBoundary); + Assert.True( + targetDirectoryIdentity.IsAvailable, + targetDirectoryIdentity.UnavailableReason); + + var factory = _provider.GetRequiredService>(); + await using var db = await factory.CreateDbContextAsync(); + var job = await db.MoveJobs + .Include(candidate => candidate.Entries) + .SingleAsync(candidate => candidate.Id == jobId); + job.IdentityKeyVersion = MoveManifestIdentity.Version; + job.SetTargetIdentity(new PathIdentitySnapshot( + semantics.Syntax, + semantics.CaseSensitivity, + FileSystemCaseSensitivityMode.Auto, + targetBoundary)); + if (!job.Entries.Any(MoveManifestIdentity.IsTargetBoundaryAuthorization)) + { + job.Entries.Add(MoveManifestIdentity.CreateTargetBoundaryAuthorization( + targetDirectoryIdentity.Version!.Value, + targetDirectoryIdentity.Value!)); + } + await db.SaveChangesAsync(); + } + private async Task ClearPersistedManifestAsync(Guid jobId) { var factory = _provider.GetRequiredService>(); @@ -2000,10 +2288,48 @@ private async Task ClearPersistedManifestAsync(Guid jobId) var entries = await db.MoveJobEntries .Where(entry => entry.MoveJobId == jobId) .ToListAsync(); - db.MoveJobEntries.RemoveRange(entries); + db.MoveJobEntries.RemoveRange(entries.Where(entry => + !MoveManifestIdentity.IsTargetBoundaryAuthorization(entry))); await db.SaveChangesAsync(); } + private string FindMoveTargetBoundary( + string targetPath, + FileSystemPathSemantics targetSemantics) + { + var target = Path.GetFullPath(targetPath); + var managedRoot = Path.GetFullPath(FileService.GetTempPath()); + if (IsTestFilesystemRoot(target, targetSemantics)) + { + // Endpoint-root tests must reach the production endpoint guard without + // trying to enroll the host filesystem root as a side effect of setup. + return managedRoot; + } + + if (FileSystemPathIdentity.IsSameOrInside( + target, + managedRoot, + targetSemantics)) + { + // Production jobs authorize the configured library/output boundary, + // not an already-existing content destination inside that boundary. + return managedRoot; + } + + var current = Path.GetDirectoryName(target); + while (!string.IsNullOrWhiteSpace(current)) + { + if (Directory.Exists(current)) + { + return current; + } + current = Path.GetDirectoryName(current); + } + + throw new InvalidOperationException( + "Move test target has no existing authorization boundary."); + } + private static bool IsTestFilesystemRoot( string path, FileSystemPathSemantics semantics) @@ -2178,6 +2504,27 @@ public void OnSourceCleanupMutation( } } + private sealed class FailEmptySourceStateDeleteOnce : IMoveFaultInjector + { + private bool _failed; + + public void OnSourceCleanupMutation( + Guid jobId, + SourceCleanupFaultPoint faultPoint) + { + if (_failed + || faultPoint != SourceCleanupFaultPoint.BeforeEmptySourceStateDelete) + { + return; + } + + _failed = true; + throw new System.ComponentModel.Win32Exception( + 145, + "Injected empty-source state retirement failure."); + } + } + private sealed class AddSourceFileAfterPublish(string source) : IMoveFaultInjector { public Task AfterPublishedAsync(Guid jobId, CancellationToken cancellationToken) => diff --git a/tests/Features/Infrastructure/Library/Moving/AudiobookPathReferenceRewriterTests.cs b/tests/Features/Infrastructure/Library/Moving/AudiobookPathReferenceRewriterTests.cs index d97b647ca..557b52c1e 100644 --- a/tests/Features/Infrastructure/Library/Moving/AudiobookPathReferenceRewriterTests.cs +++ b/tests/Features/Infrastructure/Library/Moving/AudiobookPathReferenceRewriterTests.cs @@ -17,16 +17,12 @@ public void Rewrite_ForeignSourceAlias_DoesNotMatchNativeCurrentBasePath() Path.GetPathRoot(Environment.CurrentDirectory)!, "listenarr-rewriter-native", Guid.NewGuid().ToString("N")); - var driveRoot = Path.GetPathRoot(current)!; - var foreignSource = "/" + current[driveRoot.Length..].Replace('\\', '/'); + var foreignSource = TempFileService + .GetWindowsRootRelativeForeignAlias(current); var target = Path.Join( Path.GetPathRoot(Environment.CurrentDirectory)!, "listenarr-rewriter-target", Guid.NewGuid().ToString("N")); - Assert.Equal( - Path.GetFullPath(current), - Path.GetFullPath(foreignSource), - StringComparer.OrdinalIgnoreCase); var audiobook = new Audiobook { BasePath = current }; var semantics = FileSystemPathSemantics.CurrentHostDefault; diff --git a/tests/Features/Infrastructure/Library/Moving/EfLibraryDirectoryOwnershipStoreTests.cs b/tests/Features/Infrastructure/Library/Moving/EfLibraryDirectoryOwnershipStoreTests.cs index 86d88ad51..74142359c 100644 --- a/tests/Features/Infrastructure/Library/Moving/EfLibraryDirectoryOwnershipStoreTests.cs +++ b/tests/Features/Infrastructure/Library/Moving/EfLibraryDirectoryOwnershipStoreTests.cs @@ -14,10 +14,7 @@ public sealed class EfLibraryDirectoryOwnershipStoreTests : BaseTests Path.GetTempPath(), "listenarr-tests", $"directory-ownership-{Guid.NewGuid():N}.db"); - private readonly string _root = Path.Join( - Path.GetTempPath(), - "listenarr-tests", - $"directory-ownership-root-{Guid.NewGuid():N}"); + private string _root = string.Empty; private IDbContextFactory _factory = null!; private EfLibraryDirectoryOwnershipStore _store = null!; @@ -25,6 +22,13 @@ public override async Task InitializeAsync() { await base.InitializeAsync(); Directory.CreateDirectory(Path.GetDirectoryName(_databasePath)!); + _root = OperatingSystem.IsWindows() + ? WindowsPathTestFixture.CreateRootRelativeAliasCompatibleDirectory( + "directory-ownership-root") + : Path.Join( + Path.GetTempPath(), + "listenarr-tests", + $"directory-ownership-root-{Guid.NewGuid():N}"); Directory.CreateDirectory(_root); var options = new DbContextOptionsBuilder() .UseSqlite($"Data Source={_databasePath};Pooling=False") @@ -343,7 +347,7 @@ public async Task PhysicalPathReplacementWithoutInsideMarkerFailsValidation() var exception = Assert.Throws(() => LibraryDirectoryOwnershipMarker.Validate(ownership, directory)); - Assert.Contains("marker is missing", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("marker", exception.Message, StringComparison.OrdinalIgnoreCase); } [Fact] @@ -379,6 +383,52 @@ public async Task PhysicalPathReplacementWithCopiedMarkersFailsClosed() StringComparison.OrdinalIgnoreCase); } + [LinuxFact] + public async Task ResolveOwnedAsync_DirectoryReplacedAfterPhysicalIdentityPin_DoesNotMixMarkerGeneration() + { + var directory = Path.Join(_root, "PinnedGenerationReplacement"); + var displacedDirectory = directory + ".original"; + Directory.CreateDirectory(directory); + var ownership = await _store.RecordCreatedAsync( + new LibraryDirectoryOwnershipClaim( + directory, + FileSystemPathSemantics.CurrentHostDefault, + "test")); + var insideMarker = Path.Join( + directory, + LibraryDirectoryOwnershipMarker.FileName); + var insidePayload = await File.ReadAllTextAsync(insideMarker); + var replaced = false; + _store.AfterOwnedDirectoryPhysicalIdentityPinnedForTest = () => + { + if (replaced) + { + return; + } + + replaced = true; + Directory.Move(directory, displacedDirectory); + Directory.CreateDirectory(directory); + File.WriteAllText(insideMarker, insidePayload); + }; + + var resolution = await _store.ResolveOwnedAsync( + directory, + FileSystemPathSemantics.CurrentHostDefault); + + Assert.True(replaced); + Assert.Equal( + LibraryDirectoryOwnershipResolutionState.Unavailable, + resolution.State); + Assert.Contains( + "proof", + resolution.Reason, + StringComparison.OrdinalIgnoreCase); + Assert.True(Directory.Exists(displacedDirectory)); + Assert.Equal(insidePayload, await File.ReadAllTextAsync(insideMarker)); + Assert.Equal(ownership.Id, resolution.Ownership?.Id); + } + [Fact] public async Task EnsureCreatedHierarchyAsync_ClaimsOnlyDirectoriesCreatedExclusively() { @@ -406,6 +456,61 @@ public async Task EnsureCreatedHierarchyAsync_ClaimsOnlyDirectoriesCreatedExclus } } + [Fact] + public async Task EnrolledDestinationRemovedBeforePublication_IsNotRecreatedAndOwnershipFailsClosed() + { + var sourceDirectory = Path.Join(_root, "Source"); + Directory.CreateDirectory(sourceDirectory); + var source = Path.Join(sourceDirectory, "book.m4b"); + await File.WriteAllTextAsync(source, "audio"); + var destinationDirectory = Path.Join(_root, "Author", "Book"); + var destination = Path.Join(destinationDirectory, "book.m4b"); + var ownerships = await _store.EnsureCreatedHierarchyAsync( + destinationDirectory, + _root, + FileSystemPathSemantics.CurrentHostDefault, + "publication-race", + Guid.NewGuid(), + audiobookId: 7); + var destinationOwnership = ownerships.Single(ownership => + FileSystemPathIdentity.AreEquivalent( + ownership.CanonicalPath, + destinationDirectory, + FileSystemPathSemantics.CurrentHostDefault)); + var siblingMarker = LibraryDirectoryOwnershipMarker + .GetMarkerPaths(destinationOwnership) + .Single(marker => !FileSystemPathIdentity.IsSameOrInside( + marker, + destinationDirectory, + FileSystemPathSemantics.CurrentHostDefault)); + Assert.True(File.Exists(siblingMarker)); + Directory.Delete(destinationDirectory, recursive: true); + var mover = new FileMover( + new NullLogger(), + semanticsResolver: new FileSystemSemanticsResolver()); + + var copied = await mover.CopyFileAsync(source, destination); + + Assert.False(copied); + Assert.True(File.Exists(source)); + Assert.False(Directory.Exists(destinationDirectory)); + Assert.False(File.Exists(destination)); + Assert.True(File.Exists(siblingMarker)); + var resolution = await _store.ResolveOwnedAsync( + destinationDirectory, + FileSystemPathSemantics.CurrentHostDefault); + Assert.Equal( + LibraryDirectoryOwnershipResolutionState.Unavailable, + resolution.State); + await using var db = await _factory.CreateDbContextAsync(); + var durableOwnership = await db.LibraryDirectoryOwnerships + .AsNoTracking() + .SingleAsync(ownership => ownership.Id == destinationOwnership.Id); + Assert.Equal( + LibraryDirectoryOwnershipState.Owned, + durableOwnership.State); + } + [Fact] public async Task EnsureCreatedHierarchyAsync_PersistenceFailureRemovesOnlyUnchangedEmptyCreation() { @@ -1205,13 +1310,8 @@ public async Task Reconciler_ForeignRetiredMarkerPath_DoesNotDeleteWindowsAlias( var siblingMarkerPath = LibraryDirectoryOwnershipMarker.GetMarkerPaths(ownership)[1]; Assert.True(File.Exists(siblingMarkerPath)); - var root = Path.GetPathRoot(siblingMarkerPath)!; - var foreignMarkerPath = - "/" + siblingMarkerPath[root.Length..].Replace('\\', '/'); - Assert.Equal( - Path.GetFullPath(siblingMarkerPath), - Path.GetFullPath(foreignMarkerPath), - StringComparer.OrdinalIgnoreCase); + var foreignMarkerPath = WindowsPathTestFixture + .GetRootRelativeForeignAlias(siblingMarkerPath); await using (var db = await _factory.CreateDbContextAsync()) { diff --git a/tests/Features/Infrastructure/Library/Moving/EfMoveExecutionStoreTests.cs b/tests/Features/Infrastructure/Library/Moving/EfMoveExecutionStoreTests.cs index 7659cddfb..77646af78 100644 --- a/tests/Features/Infrastructure/Library/Moving/EfMoveExecutionStoreTests.cs +++ b/tests/Features/Infrastructure/Library/Moving/EfMoveExecutionStoreTests.cs @@ -9,6 +9,63 @@ namespace Listenarr.Tests.Features.Infrastructure.Library.Moving; [Trait("Category", "Infrastructure")] public sealed class EfMoveExecutionStoreTests : BaseTests { + [Fact] + public async Task SourceManifestOperations_ExcludeTargetBoundaryAuthorization() + { + var jobId = Guid.NewGuid(); + var lease = new MoveLeaseToken("worker", 1); + var factory = _provider.GetRequiredService>(); + await using (var db = await factory.CreateDbContextAsync()) + { + db.MoveJobs.Add(new MoveJob + { + Id = jobId, + AudiobookId = 1, + RequestedPath = Path.Join(FileService.GetTempPath(), "target"), + SourcePath = Path.Join(FileService.GetTempPath(), "source"), + Status = MoveJobStatus.Running, + LeaseOwner = lease.Owner, + LeaseGeneration = lease.Generation, + LeaseExpiresAt = DateTime.UtcNow.AddMinutes(5), + ActiveDeduplicationKey = $"test:{jobId:N}", + Entries = + [ + new MoveJobEntry + { + RelativePath = "book.m4b", + EntryType = MoveJobEntryType.File, + Length = 5, + Sha256 = new string('A', 64) + }, + MoveManifestIdentity.CreateTargetBoundaryAuthorization( + 2, + "test-target-generation") + ] + }); + await db.SaveChangesAsync(); + } + + var store = new EfMoveExecutionStore(factory, TimeProvider.System); + var manifest = await store.LoadManifestAsync(jobId, CancellationToken.None); + + var sourceEntry = Assert.Single(manifest); + Assert.Equal("book.m4b", sourceEntry.RelativePath); + + await store.UpdateCopyStateAsync(jobId, lease, CancellationToken.None); + + await using var verification = await factory.CreateDbContextAsync(); + var entries = await verification.MoveJobEntries + .AsNoTracking() + .Where(entry => entry.MoveJobId == jobId) + .ToListAsync(); + Assert.Equal( + MoveJobEntryCopyState.Verified, + entries.Single(entry => entry.RelativePath == "book.m4b").CopyState); + Assert.Equal( + MoveJobEntryCopyState.Pending, + entries.Single(MoveManifestIdentity.IsTargetBoundaryAuthorization).CopyState); + } + [Fact] public async Task ProviderFailures_AreTranslatedAcrossMoveExecutionBoundary() { diff --git a/tests/Features/Infrastructure/Library/Moving/MoveJobProcessorArtifactCleanupTests.cs b/tests/Features/Infrastructure/Library/Moving/MoveJobProcessorArtifactCleanupTests.cs index 42123ae67..fb2f1b51e 100644 --- a/tests/Features/Infrastructure/Library/Moving/MoveJobProcessorArtifactCleanupTests.cs +++ b/tests/Features/Infrastructure/Library/Moving/MoveJobProcessorArtifactCleanupTests.cs @@ -55,6 +55,58 @@ await _historyRepository.GetByCorrelationIdAsync($"move:{job.Id:N}"), entry => entry.EventType == "Moved"); } + [Fact] + public async Task ProcessJobAsync_EmptySourceStateNativeDeleteFailure_SchedulesAndCompletesRetry() + { + var source = FileService.GetTempDirectory("move-processor-source-state-delete-retry-src"); + await FileService.GetFileAsync(source, "book.m4b", "audio"); + var target = Path.Join( + FileService.GetTempPath(), + $"move-processor-source-state-delete-retry-dst-{Guid.NewGuid():N}"); + var audiobook = await _audiobookRepository.AddAsync(new Audiobook + { + Title = "Source State Delete Retry", + BasePath = source + }); + var (queue, job) = await CreateQueuedMoveJobAsync(audiobook, target, source); + var faultingContentMoveService = new AudiobookContentMoveService( + _provider.GetRequiredService>(), + _provider.GetRequiredService>(), + TimeProvider.System, + new FailEmptySourceStateDeleteOnce()); + var faultingProcessor = ActivatorUtilities.CreateInstance( + _provider, + faultingContentMoveService); + + await faultingProcessor.ProcessJobAsync(job, CancellationToken.None); + + var retryJob = Assert.IsType(await queue.GetJobAsync(job.Id)); + Assert.Equal(MoveJobStatus.RetryScheduled, retryJob.Status); + Assert.NotNull(retryJob.NextAttemptAt); + Assert.False(Directory.Exists(source)); + var statePath = Path.Join( + Path.GetDirectoryName(source)!, + $".listenarr-quarantine-{job.Id:N}", + ".listenarr-empty-source.state"); + Assert.True(Directory.Exists(statePath)); + Assert.Empty(Directory.EnumerateFileSystemEntries(statePath)); + Assert.True(File.Exists(Path.Join(target, "book.m4b"))); + await MakeRetryDueAsync(job.Id); + + var retryGeneration = Assert.IsType( + await queue.TryClaimJobAsync(job.Id, LeaseOwner)); + retryJob.LeaseOwner = LeaseOwner; + retryJob.LeaseGeneration = retryGeneration; + await _provider.GetRequiredService() + .ProcessJobAsync(retryJob, CancellationToken.None); + + var completed = Assert.IsType(await queue.GetJobAsync(job.Id)); + Assert.Equal(MoveJobStatus.Completed, completed.Status); + Assert.False(Directory.Exists(source)); + Assert.False(Directory.Exists(statePath)); + Assert.True(File.Exists(Path.Join(target, "book.m4b"))); + } + [Fact] public async Task ProcessJobAsync_ForeignSourceFileBeforeMarkerDelete_PreservesFileAndCompletes() { @@ -333,6 +385,27 @@ private async Task MakeRetryDueAsync(Guid jobId) await db.SaveChangesAsync(); } + private sealed class FailEmptySourceStateDeleteOnce : IMoveFaultInjector + { + private bool _failed; + + public void OnSourceCleanupMutation( + Guid jobId, + SourceCleanupFaultPoint faultPoint) + { + if (_failed + || faultPoint != SourceCleanupFaultPoint.BeforeEmptySourceStateDelete) + { + return; + } + + _failed = true; + throw new System.ComponentModel.Win32Exception( + 145, + "Injected empty-source state retirement failure."); + } + } + private sealed class RecreateSourceBeforeMarkerDelete( string source) : IMoveFaultInjector { diff --git a/tests/Features/Infrastructure/Library/Moving/MoveJobProcessorCompletionHandoffTests.cs b/tests/Features/Infrastructure/Library/Moving/MoveJobProcessorCompletionHandoffTests.cs index fa2e60732..6df43ff78 100644 --- a/tests/Features/Infrastructure/Library/Moving/MoveJobProcessorCompletionHandoffTests.cs +++ b/tests/Features/Infrastructure/Library/Moving/MoveJobProcessorCompletionHandoffTests.cs @@ -125,6 +125,59 @@ public async Task ProcessJobAsync_PostCommitScanDispatchFailure_PreservesDurable Assert.Equal(MoveScanHandoffStatus.Pending, handoff.Status); } + [Fact] + public async Task RunPostCompletionEffectsAsync_MoveNotificationCancellation_StillAttemptsDurableScanHandoff() + { + var context = new MovePostCommitContext( + Guid.NewGuid(), + int.MaxValue, + "Post Commit Notification Cancellation", + Path.Join(FileService.GetTempPath(), "post-commit-source"), + Path.Join(FileService.GetTempPath(), "post-commit-target"), + Guid.NewGuid(), + MoveHistoryId: 0, + MoveHistoryCreated: false); + var moveQueue = new Mock(MockBehavior.Strict); + moveQueue.Setup(service => service.NotifyPersistedJobStateAsync( + context.JobId, + MoveJobStatus.Completed, + null, + It.IsAny())) + .ThrowsAsync(new TaskCanceledException( + "Injected completed-move notification cancellation.")); + var handoffStore = new Mock(MockBehavior.Strict); + handoffStore.Setup(store => store.TryClaimAsync( + context.HandoffId, + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync((MoveScanHandoffClaim?)null); + var scanQueue = new Mock(MockBehavior.Strict); + var processor = ActivatorUtilities.CreateInstance( + _provider, + moveQueue.Object, + handoffStore.Object, + scanQueue.Object); + + await processor.RunPostCompletionEffectsAsync( + context, + CancellationToken.None); + + moveQueue.Verify(service => service.NotifyPersistedJobStateAsync( + context.JobId, + MoveJobStatus.Completed, + null, + It.IsAny()), Times.Once); + handoffStore.Verify(store => store.TryClaimAsync( + context.HandoffId, + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), Times.Once); + scanQueue.VerifyNoOtherCalls(); + } + [Fact] public async Task ProcessJobAsync_DurableScanFailure_DoesNotReplayHandoff() { diff --git a/tests/Features/Infrastructure/Library/Moving/MoveJobProcessorFinalizedRecoveryTests.cs b/tests/Features/Infrastructure/Library/Moving/MoveJobProcessorFinalizedRecoveryTests.cs index c0de68858..2ebafd4f7 100644 --- a/tests/Features/Infrastructure/Library/Moving/MoveJobProcessorFinalizedRecoveryTests.cs +++ b/tests/Features/Infrastructure/Library/Moving/MoveJobProcessorFinalizedRecoveryTests.cs @@ -10,9 +10,8 @@ public async Task ProcessJobAsync_ForeignBasePathAlias_DoesNotInventFinalizedMov { var source = FileService.GetTempDirectory("move-processor-foreign-base-finalized-src"); var sourceFile = await FileService.GetFileAsync(source, "book.m4b", "verified audio"); - var target = Path.Join( - FileService.GetTempPath(), - $"move-processor-foreign-base-finalized-dst-{Guid.NewGuid():N}"); + var target = FileService.GetWindowsRootRelativeTempPath( + "move-processor-foreign-base-finalized-dst"); var audiobook = await _audiobookRepository.AddAsync(new Audiobook { Title = "Foreign Base Finalized Recovery", @@ -26,12 +25,8 @@ public async Task ProcessJobAsync_ForeignBasePathAlias_DoesNotInventFinalizedMov Directory.CreateDirectory(target); File.Copy(sourceFile, Path.Join(target, "book.m4b")); Directory.Delete(source, recursive: true); - var driveRoot = Path.GetPathRoot(target)!; - var foreignTarget = "/" + target[driveRoot.Length..].Replace('\\', '/'); - Assert.Equal( - Path.GetFullPath(target), - Path.GetFullPath(foreignTarget), - StringComparer.OrdinalIgnoreCase); + var foreignTarget = TempFileService + .GetWindowsRootRelativeForeignAlias(target); audiobook.BasePath = foreignTarget; await _audiobookRepository.UpdateAsync(audiobook); Assert.True(job.Phase < MoveJobPhase.Published); diff --git a/tests/Features/Infrastructure/Library/Moving/MoveJobProcessorTests.cs b/tests/Features/Infrastructure/Library/Moving/MoveJobProcessorTests.cs index b46ab1014..1cca832a9 100644 --- a/tests/Features/Infrastructure/Library/Moving/MoveJobProcessorTests.cs +++ b/tests/Features/Infrastructure/Library/Moving/MoveJobProcessorTests.cs @@ -37,6 +37,91 @@ public async Task ProcessJobAsync_HappyPath_MovesFilesAndCompletesJob() metricsMock.Verify(m => m.Increment("worker.move.job.completed", It.IsAny()), Times.Once); } + [Fact] + public async Task ProcessJobAsync_UntrackedNonAudioCompanionInManagedAudiobookFolder_MovesWithTrackedAudio() + { + var sourceRoot = FileService.GetTempDirectory("move-processor-companion-source-root"); + await AddAuthorizedRootAsync(sourceRoot, "Companion Source Root"); + var source = Path.Join(sourceRoot, "Author", "Book"); + Directory.CreateDirectory(source); + var audioPath = await FileService.GetFileAsync(source, "book.m4b", "audio"); + var coverPath = await FileService.GetFileAsync(source, "cover.jpg", "cover"); + var audiobook = await _audiobookRepository.AddAsync(new Audiobook + { + Title = "Companion Move", + BasePath = source + }); + var sourceSemantics = FileSystemPathSemantics.CurrentHostDefault; + var audioIdentity = AudiobookFilePathIdentity.CreateValid( + audioPath, + sourceSemantics, + FileSystemCaseSensitivityMode.Auto, + source); + var trackedAudio = AudiobookFile.CreateUnresolved(audioPath); + trackedAudio.AudiobookId = audiobook.Id; + trackedAudio.ApplyPathIdentity(audioPath, audioIdentity); + ApplyTestPhysicalObjectIdentity(trackedAudio, audioPath); + var audioClaim = await _audiobookFileRepository.ClaimAsync(trackedAudio); + Assert.Equal(AudiobookFileClaimOutcome.Created, audioClaim.Outcome); + + var manifest = await _provider + .GetRequiredService() + .BuildAsync(audiobook); + Assert.Contains(manifest.Entries, entry => + entry.EntryType == MoveJobEntryType.File + && string.Equals(entry.RelativePath, "cover.jpg", StringComparison.Ordinal)); + Assert.DoesNotContain( + await _audiobookFileRepository.GetByAudiobookIdAsync(audiobook.Id), + file => string.Equals(file.Path, coverPath, StringComparison.Ordinal)); + + var targetRoot = FileService.GetTempDirectory("move-processor-companion-target-root"); + var targetRootFolder = await AddAuthorizedRootAsync( + targetRoot, + "Companion Target Root"); + var target = Path.Join(targetRoot, "Author", "Book"); + var targetResolution = await _provider + .GetRequiredService() + .ResolveAsync(targetRoot); + Assert.Equal(PathIdentityState.Valid, targetResolution.State); + var targetIdentity = PathIdentitySnapshot.FromResolution( + targetResolution.Semantics, + targetRootFolder.CaseSensitivityMode, + targetRoot, + target); + var queue = _provider.GetRequiredService(); + var jobId = await queue.EnqueueMoveAsync( + new MoveEnqueueCommand( + audiobook.Id, + manifest.SourceRoot, + manifest.SourceIdentity, + manifest.Entries, + target, + targetIdentity, + targetRootFolder.DirectoryObjectIdentityVersion!.Value, + targetRootFolder.DirectoryObjectIdentity!, + true, + sourceRoot)); + var job = Assert.IsType(await queue.GetJobAsync(jobId)); + await PrepareJobForProcessingAsync(queue, job); + + await _provider.GetRequiredService() + .ProcessJobAsync(job, CancellationToken.None); + + var completed = Assert.IsType(await queue.GetJobAsync(jobId)); + Assert.True( + completed.Status == MoveJobStatus.Completed, + completed.Error ?? $"Unexpected move status: {completed.Status}"); + Assert.False(File.Exists(coverPath)); + Assert.True(File.Exists(Path.Join(target, "book.m4b"))); + Assert.Equal("cover", await File.ReadAllTextAsync(Path.Join(target, "cover.jpg"))); + var persistedFiles = await _audiobookFileRepository + .GetByAudiobookIdAsync(audiobook.Id); + Assert.Single(persistedFiles); + Assert.Equal( + Path.Join(target, "book.m4b"), + persistedFiles[0].CanonicalPath ?? persistedFiles[0].Path); + } + [Fact] public async Task ProcessJobAsync_RemovesEmptySourceAncestorsWithinConfiguredRoot() { @@ -191,6 +276,68 @@ await _historyRepository.GetByCorrelationIdAsync($"move:{job.Id:N}"), Times.Once); } + [Fact] + public async Task ProcessDurableJobAsync_TerminalStateNotificationCancellation_DoesNotEscapeCommit() + { + var job = new MoveJob + { + Id = Guid.NewGuid(), + AudiobookId = int.MaxValue, + RequestedPath = Path.Join(FileService.GetTempPath(), "missing-audiobook-target"), + Status = MoveJobStatus.Running, + LeaseOwner = LeaseOwner, + LeaseGeneration = 1, + LeaseExpiresAt = DateTime.UtcNow.AddMinutes(1) + }; + var queue = new Mock(MockBehavior.Strict); + queue.Setup(service => service.UpdateJobStatusAsync( + job.Id, + LeaseOwner, + job.LeaseGeneration, + MoveJobStatus.Running, + null, + It.IsAny())) + .Returns(Task.CompletedTask); + queue.Setup(service => service.UpdateJobStatusWithoutNotificationAsync( + job.Id, + LeaseOwner, + job.LeaseGeneration, + MoveJobStatus.Failed, + "Audiobook not found", + It.IsAny())) + .Returns(Task.CompletedTask); + queue.Setup(service => service.NotifyPersistedJobStateAsync( + job.Id, + MoveJobStatus.Failed, + "Audiobook not found", + It.IsAny())) + .ThrowsAsync(new TaskCanceledException( + "Injected terminal-state notification cancellation.")); + var processor = ActivatorUtilities.CreateInstance( + _provider, + queue.Object); + + var postCommit = await processor.ProcessDurableJobAsync( + job, + CancellationToken.None); + + Assert.Null(postCommit); + Assert.Equal(MoveJobStatus.Failed, job.Status); + Assert.Equal("Audiobook not found", job.Error); + queue.Verify(service => service.UpdateJobStatusWithoutNotificationAsync( + job.Id, + LeaseOwner, + job.LeaseGeneration, + MoveJobStatus.Failed, + "Audiobook not found", + It.IsAny()), Times.Once); + queue.Verify(service => service.NotifyPersistedJobStateAsync( + job.Id, + MoveJobStatus.Failed, + "Audiobook not found", + It.IsAny()), Times.Once); + } + [Fact] public async Task ProcessJobAsync_TargetInsideSource_MovesSourceContentsIntoTarget() { @@ -425,6 +572,48 @@ await Assert.ThrowsAsync(() => processor.ProcessJobAsync It.IsAny()), Times.Never); } + [Fact] + public async Task ProcessJobAsync_LeaseExpiresAfterFilesystemCleanup_DoesNotRewriteAudiobookMetadata() + { + var source = FileService.GetTempDirectory( + "move-processor-expired-before-rewrite-src"); + await FileService.GetFileAsync(source, "book.m4b", "audio"); + var target = Path.Join( + FileService.GetTempPath(), + $"move-processor-expired-before-rewrite-dst-{Guid.NewGuid():N}"); + var audiobook = await _audiobookRepository.AddAsync(new Audiobook + { + Title = "Move Processor Expired Before Rewrite", + BasePath = source + }); + var (queue, job) = await CreateQueuedMoveJobAsync(audiobook, target, source); + var factory = _provider + .GetRequiredService>(); + var processor = _provider.GetRequiredService(); + processor.AfterSourceCleanupBeforeMetadataRewriteForTest = async observedJob => + { + await using var db = await factory.CreateDbContextAsync(); + var persisted = await db.MoveJobs.SingleAsync(candidate => + candidate.Id == observedJob.Id); + persisted.LeaseExpiresAt = DateTime.UtcNow.AddMinutes(-1); + await db.SaveChangesAsync(); + }; + + await Assert.ThrowsAsync(() => + processor.ProcessJobAsync(job, CancellationToken.None)); + + await using var verification = await factory.CreateDbContextAsync(); + var audiobookAfter = await verification.Audiobooks + .AsNoTracking() + .SingleAsync(candidate => candidate.Id == audiobook.Id); + Assert.Equal(source, audiobookAfter.BasePath); + Assert.True(File.Exists(Path.Join(target, "book.m4b"))); + var persistedJob = await verification.MoveJobs + .AsNoTracking() + .SingleAsync(candidate => candidate.Id == job.Id); + Assert.Equal(MoveJobStatus.Running, persistedJob.Status); + } + [Fact] public async Task ProcessJobAsync_CanceledToken_ThrowsBeforeStateChange() { @@ -1182,11 +1371,20 @@ await ownershipStore.RecordCreatedAsync( FileSystemCaseSensitivityMode.Auto, sourceResolution.BoundaryPath, sourcePath); + var targetBoundary = FindTargetBoundary( + requestedPath, + targetResolution.Semantics); var targetIdentity = PathIdentitySnapshot.FromResolution( targetResolution.Semantics, FileSystemCaseSensitivityMode.Auto, - targetResolution.BoundaryPath, + targetBoundary, requestedPath); + var targetDirectoryIdentity = await _provider + .GetRequiredService() + .ResolveAsync(targetBoundary); + Assert.True( + targetDirectoryIdentity.IsAvailable, + targetDirectoryIdentity.UnavailableReason); var manifest = await BuildMoveManifestAsync(sourcePath); await EnsureTrackedManifestRowsAsync( audiobook, @@ -1201,6 +1399,8 @@ await EnsureTrackedManifestRowsAsync( manifest, requestedPath, targetIdentity, + targetDirectoryIdentity.Version!.Value, + targetDirectoryIdentity.Value!, deleteEmptySource)); var job = Assert.IsType( await queue.GetJobAsync(jobId)); @@ -1208,6 +1408,34 @@ await EnsureTrackedManifestRowsAsync( return (queue, job); } + private string FindTargetBoundary( + string targetPath, + FileSystemPathSemantics targetSemantics) + { + var target = Path.GetFullPath(targetPath); + var managedRoot = Path.GetFullPath(FileService.GetTempPath()); + if (FileSystemPathIdentity.IsSameOrInside( + target, + managedRoot, + targetSemantics)) + { + return managedRoot; + } + + var current = Path.GetDirectoryName(target); + while (!string.IsNullOrWhiteSpace(current)) + { + if (Directory.Exists(current)) + { + return current; + } + current = Path.GetDirectoryName(current); + } + + throw new InvalidOperationException( + "Move test target has no existing authorization boundary."); + } + private async Task EnsureTrackedManifestRowsAsync( Audiobook audiobook, string sourcePath, diff --git a/tests/Features/Infrastructure/Library/Moving/MoveJobProcessor_FileReferenceRewriteTests.cs b/tests/Features/Infrastructure/Library/Moving/MoveJobProcessor_FileReferenceRewriteTests.cs index 6e685d790..9d89db88b 100644 --- a/tests/Features/Infrastructure/Library/Moving/MoveJobProcessor_FileReferenceRewriteTests.cs +++ b/tests/Features/Infrastructure/Library/Moving/MoveJobProcessor_FileReferenceRewriteTests.cs @@ -180,11 +180,18 @@ await AddTrackedFileAsync( .GetRequiredService() .ResolveAsync(target); Assert.Equal(PathIdentityState.Valid, targetResolution.State); + var targetBoundary = FileService.GetTempPath(); var targetIdentity = PathIdentitySnapshot.FromResolution( targetResolution.Semantics, FileSystemCaseSensitivityMode.Auto, - targetResolution.BoundaryPath, + targetBoundary, target); + var targetDirectoryIdentity = await _provider + .GetRequiredService() + .ResolveAsync(targetBoundary); + Assert.True( + targetDirectoryIdentity.IsAvailable, + targetDirectoryIdentity.UnavailableReason); var queue = _provider.GetRequiredService(); var jobId = await queue.EnqueueMoveAsync(new MoveEnqueueCommand( audiobook.Id, @@ -193,6 +200,8 @@ await AddTrackedFileAsync( manifest.Entries, target, targetIdentity, + targetDirectoryIdentity.Version!.Value, + targetDirectoryIdentity.Value!, DeleteEmptySource: true)); var job = Assert.IsType( await queue.GetJobAsync(jobId)); diff --git a/tests/Features/Infrastructure/Library/Moving/MoveSourceManifestServiceTests.cs b/tests/Features/Infrastructure/Library/Moving/MoveSourceManifestServiceTests.cs index f8672f73a..8211ef8cd 100644 --- a/tests/Features/Infrastructure/Library/Moving/MoveSourceManifestServiceTests.cs +++ b/tests/Features/Infrastructure/Library/Moving/MoveSourceManifestServiceTests.cs @@ -74,6 +74,125 @@ public async Task BuildAsync_SharedFlatFolder_IncludesOnlyTrackedFile() Assert.Equal(MoveJobEntryType.File, file.EntryType); } + [Fact] + public async Task BuildAsync_ManagedAudiobookFolder_IncludesNonAudioCompanionButNotUntrackedAudio() + { + var root = FileService.GetTempDirectory("move-manifest-companion-root"); + await AddAuthorizedRootAsync(root); + var book = Path.Join(root, "Author", "Book"); + Directory.CreateDirectory(book); + var trackedAudio = await FileService.GetFileAsync( + book, + "Book.m4b", + "tracked audio"); + _ = await FileService.GetFileAsync( + book, + "cover.jpg", + "cover image"); + _ = await FileService.GetFileAsync( + book, + "untracked-bonus.m4b", + "foreign audio"); + var audiobook = await _audiobookRepository.AddAsync( + new AudiobookBuilder() + .WithTitle("Book") + .WithBasePath(book) + .Build()); + // Scans may persist the audiobook directory itself as the file identity + // boundary rather than the configured library root. Companion ownership + // must come from the managed-root authorizer, not this path identity field. + await AddTrackedFileAsync(audiobook, trackedAudio, book); + + var manifest = await _provider + .GetRequiredService() + .BuildAsync(audiobook); + + Assert.Equal(book, manifest.SourceRoot); + var files = manifest.Entries + .Where(entry => entry.EntryType == MoveJobEntryType.File) + .OrderBy(entry => entry.RelativePath, StringComparer.Ordinal) + .ToList(); + Assert.Equal(["Book.m4b", "cover.jpg"], files.Select(entry => entry.RelativePath)); + Assert.DoesNotContain(files, entry => + string.Equals(entry.RelativePath, "untracked-bonus.m4b", StringComparison.Ordinal)); + } + + [Fact] + public async Task BuildAsync_AudiobookAtConfiguredRoot_DoesNotClaimRootCompanion() + { + var root = FileService.GetTempDirectory("move-manifest-root-level-companion"); + await AddAuthorizedRootAsync(root); + var trackedAudio = await FileService.GetFileAsync( + root, + "Book.m4b", + "tracked audio"); + _ = await FileService.GetFileAsync( + root, + "cover.jpg", + "root-level cover"); + var audiobook = await _audiobookRepository.AddAsync( + new AudiobookBuilder() + .WithTitle("Root Book") + .WithBasePath(root) + .Build()); + await AddTrackedFileAsync(audiobook, trackedAudio, root); + + var manifest = await _provider + .GetRequiredService() + .BuildAsync(audiobook); + + var file = Assert.Single( + manifest.Entries, + entry => entry.EntryType == MoveJobEntryType.File); + Assert.Equal("Book.m4b", file.RelativePath); + Assert.DoesNotContain(manifest.Entries, entry => + string.Equals(entry.RelativePath, "cover.jpg", StringComparison.Ordinal)); + } + + [Fact] + public async Task BuildAsync_SharedAudiobookFolder_DoesNotClaimNonAudioCompanion() + { + var root = FileService.GetTempDirectory("move-manifest-shared-companion-root"); + await AddAuthorizedRootAsync(root); + var shared = Path.Join(root, "Shared"); + Directory.CreateDirectory(shared); + var requestedAudio = await FileService.GetFileAsync( + shared, + "Book One.m4b", + "requested"); + var otherAudio = await FileService.GetFileAsync( + shared, + "Book Two.m4b", + "other"); + _ = await FileService.GetFileAsync( + shared, + "cover.jpg", + "ambiguous cover"); + var requested = await _audiobookRepository.AddAsync( + new AudiobookBuilder() + .WithTitle("Book One") + .WithBasePath(shared) + .Build()); + var other = await _audiobookRepository.AddAsync( + new AudiobookBuilder() + .WithTitle("Book Two") + .WithBasePath(shared) + .Build()); + await AddTrackedFileAsync(requested, requestedAudio, root); + await AddTrackedFileAsync(other, otherAudio, root); + + var manifest = await _provider + .GetRequiredService() + .BuildAsync(requested); + + var file = Assert.Single( + manifest.Entries, + entry => entry.EntryType == MoveJobEntryType.File); + Assert.Equal("Book One.m4b", file.RelativePath); + Assert.DoesNotContain(manifest.Entries, entry => + string.Equals(entry.RelativePath, "cover.jpg", StringComparison.Ordinal)); + } + [Fact] public async Task BuildAsync_NestedDiscs_UsesCommonBookDirectory() { @@ -157,15 +276,13 @@ public async Task BuildAsync_NoTrackedFiles_FailsClosed() [WindowsFact] public async Task BuildAsync_ForeignPersistedUnixPath_IsRejectedBeforeNativeAliasProbe() { - var root = FileService.GetTempDirectory("move-manifest-foreign-persisted-path"); + var root = FileService.GetWindowsRootRelativeTempDirectory( + "move-manifest-foreign-persisted-path"); var nativePath = await FileService.GetFileAsync(root, "Book.m4b", "audio"); - var driveRoot = Path.GetPathRoot(root)!; - var foreignRoot = "/" + root[driveRoot.Length..].Replace('\\', '/'); - var foreignPath = "/" + nativePath[driveRoot.Length..].Replace('\\', '/'); - Assert.Equal( - Path.GetFullPath(nativePath), - Path.GetFullPath(foreignPath), - StringComparer.OrdinalIgnoreCase); + var foreignRoot = TempFileService + .GetWindowsRootRelativeForeignAlias(root); + var foreignPath = TempFileService + .GetWindowsRootRelativeForeignAlias(nativePath); Assert.True(File.Exists(foreignPath)); var audiobook = await _audiobookRepository.AddAsync( diff --git a/tests/Features/Infrastructure/Library/Moving/PinnedDirectoryPublicationTests.cs b/tests/Features/Infrastructure/Library/Moving/PinnedDirectoryPublicationTests.cs index d038971e7..5d9a7bee0 100644 --- a/tests/Features/Infrastructure/Library/Moving/PinnedDirectoryPublicationTests.cs +++ b/tests/Features/Infrastructure/Library/Moving/PinnedDirectoryPublicationTests.cs @@ -6,8 +6,34 @@ namespace Listenarr.Tests.Features.Infrastructure.Library.Moving; [Trait("Area", "Library")] [Trait("Name", "PinnedDirectoryCreationTests")] [Trait("Category", "Infrastructure")] -public sealed class PinnedDirectoryCreationTests : BaseTests +public sealed partial class PinnedDirectoryCreationTests : BaseTests { + [WindowsFact] + public void RetirePinnedEmptyDirectoryFromNamespace_NestedLiveAnchors_RetiresChildBeforeParent() + { + var parent = FileService.GetTempDirectory( + "pinned-directory-immediate-nested-retirement"); + var statePath = Path.Join(parent, "state"); + var claimPath = Path.Join(statePath, "claim"); + using var parentAnchor = PinnedDirectoryCreation.OpenPinnedDirectoryNoFollow(parent); + using var state = parentAnchor.TryCreateChildForPublication("state"); + Assert.True(state.Created); + using var stateAnchor = state.OpenCreatedDirectoryAnchor(); + using var claim = stateAnchor.TryCreateChildForPublication("claim"); + Assert.True(claim.Created); + using var claimAnchor = claim.OpenCreatedDirectoryAnchor(); + Assert.True(claimAnchor.VisiblePathMatches()); + Assert.True(stateAnchor.VisiblePathMatches()); + + claim.RetirePinnedEmptyDirectoryFromNamespace("claim"); + + Assert.False(Directory.Exists(claimPath)); + Assert.True(stateAnchor.VisiblePathMatches()); + state.RetirePinnedEmptyDirectoryFromNamespace("state"); + + Assert.False(Directory.Exists(statePath)); + } + [Fact] public void PublishCreatedDirectoryAs_EmptyDirectory_PublishesWithinPinnedParent() { @@ -22,6 +48,118 @@ public void PublishCreatedDirectoryAs_EmptyDirectory_PublishesWithinPinnedParent Assert.True(published.VisiblePathMatches()); } + [DirectoryLinkFact] + public void RestrictToCurrentUser_PublicPathReplacedWithLink_DoesNotMutateReplacementTarget() + { + var parent = FileService.GetTempDirectory( + "pinned-directory-permissions-parent"); + var replacementTarget = FileService.GetTempDirectory( + "pinned-directory-permissions-external"); + using var creation = PinnedDirectoryCreation.TryCreateForPublication( + parent, + "private-state"); + Assert.True(creation.Created); + var displaced = Path.Join(parent, "private-state-original"); + UnixFileMode? replacementMode = null; + if (!OperatingSystem.IsWindows()) + { + replacementMode = UnixFileMode.UserRead + | UnixFileMode.UserWrite + | UnixFileMode.UserExecute + | UnixFileMode.GroupRead + | UnixFileMode.GroupExecute + | UnixFileMode.OtherRead + | UnixFileMode.OtherExecute; + File.SetUnixFileMode(replacementTarget, replacementMode.Value); + } + + Directory.Move(creation.FullPath, displaced); + Directory.CreateSymbolicLink(creation.FullPath, replacementTarget); + try + { + Assert.ThrowsAny(() => creation.RestrictToCurrentUser()); + + Assert.True(Directory.Exists(displaced)); + if (!OperatingSystem.IsWindows()) + { + Assert.Equal( + replacementMode!.Value, + File.GetUnixFileMode(replacementTarget)); + } + } + finally + { + if (Directory.Exists(creation.FullPath)) + { + Directory.Delete(creation.FullPath); + } + } + } + + [Fact] + public async Task CreateHardLinkTo_SamePinnedParent_CreatesGenerationWitness() + { + var parent = FileService.GetTempDirectory( + "pinned-hardlink-same-parent"); + using var parentAnchor = + PinnedDirectoryCreation.OpenPinnedDirectoryNoFollow(parent); + using var source = parentAnchor.CreateNewFile("source.stage"); + await using (var stream = source.OpenWriteStream(4096, asynchronous: false)) + { + await stream.WriteAsync("owned audio"u8.ToArray()); + await stream.FlushAsync(); + stream.Flush(flushToDisk: true); + } + + using var claim = source.CreateHardLinkTo( + parentAnchor, + "destination.published.claim"); + + Assert.True(source.IdentifiesSameEntry(claim)); + Assert.True(claim.VisiblePathMatches()); + } + + [Fact] + public async Task CreateHardLinkTo_DestinationReplacedBeforeVerification_PreservesReplacementGeneration() + { + var parent = FileService.GetTempDirectory( + "pinned-hardlink-verification-replacement"); + var sourcePath = await FileService.GetFileAsync( + parent, + "source.m4b", + "owned audio"); + var destinationPath = Path.Join(parent, "destination.m4b"); + var displacedLinkPath = Path.Join(parent, "destination-created.m4b"); + using (var parentAnchor = + PinnedDirectoryCreation.OpenPinnedDirectoryNoFollow(parent)) + using (var sourceEntry = parentAnchor.OpenExistingFile( + Path.GetFileName(sourcePath), + requireDeleteAccess: true)) + { + Assert.ThrowsAny(() => + sourceEntry.CreateHardLinkTo( + parentAnchor, + Path.GetFileName(destinationPath), + () => + { + File.Move(destinationPath, displacedLinkPath); + File.WriteAllText(destinationPath, "replacement audio"); + }).Dispose()); + } + + Assert.True(File.Exists(destinationPath)); + Assert.Equal( + "replacement audio", + await File.ReadAllTextAsync(destinationPath)); + Assert.True(File.Exists(displacedLinkPath)); + Assert.Equal( + "owned audio", + await File.ReadAllTextAsync(displacedLinkPath)); + Assert.Equal( + "owned audio", + await File.ReadAllTextAsync(sourcePath)); + } + [Fact] public async Task PublishCreatedDirectoryAs_NonEmptyHierarchyWithReleasedDescendants_PublishesWithinPinnedParent() { @@ -159,6 +297,50 @@ public async Task OpenOrRepairOwnedAsync_InterruptedRetentionCopy_RebuildsFromPi Assert.False(File.Exists(retentionPath)); } + [WindowsFact] + public async Task DestinationRetentionGuard_WindowsReleasesPublicHandleOnlyAfterRetentionRetired() + { + var parent = FileService.GetTempDirectory( + "pinned-destination-retention-release-order"); + var publicPath = await FileService.GetFileAsync( + parent, + "book.m4b", + "verified audio"); + var displacedPath = Path.Join(parent, "book-displaced.m4b"); + var operationId = Guid.NewGuid(); + var retentionName = PinnedDestinationRetentionGuard.CreateRetentionName( + operationId, + "book.m4b"); + var retentionPath = Path.Join(parent, retentionName); + var expectedBytes = await File.ReadAllBytesAsync(publicPath); + var expectedHash = Convert.ToHexString(SHA256.HashData(expectedBytes)); + using var anchor = PinnedDirectoryCreation.OpenPinnedDirectoryNoFollow( + parent); + using var guard = await PinnedDestinationRetentionGuard.OpenOrCreateAsync( + anchor, + "book.m4b", + retentionName, + expectedBytes.LongLength, + expectedHash, + CancellationToken.None); + Assert.NotNull(guard); + Assert.True(File.Exists(retentionPath)); + Assert.True(await guard.TryLinearizePublicationAsync( + CancellationToken.None)); + guard.AfterWindowsPublicTargetReleasedForTest = () => + { + Assert.False(File.Exists(retentionPath)); + File.Move(publicPath, displacedPath); + File.WriteAllText(publicPath, "replacement audio"); + }; + + Assert.True(await guard.CompleteAsync(CancellationToken.None)); + + Assert.False(File.Exists(retentionPath)); + Assert.Equal("verified audio", await File.ReadAllTextAsync(displacedPath)); + Assert.Equal("replacement audio", await File.ReadAllTextAsync(publicPath)); + } + [Fact] public async Task DeleteOpenedFile_RemovesVerifiedPinnedEntry() { diff --git a/tests/Features/Infrastructure/Library/Moving/PinnedLibraryDirectoryOwnershipMarkerMigrationTests.cs b/tests/Features/Infrastructure/Library/Moving/PinnedLibraryDirectoryOwnershipMarkerMigrationTests.cs new file mode 100644 index 000000000..579a15ef2 --- /dev/null +++ b/tests/Features/Infrastructure/Library/Moving/PinnedLibraryDirectoryOwnershipMarkerMigrationTests.cs @@ -0,0 +1,116 @@ +namespace Listenarr.Tests.Features.Infrastructure.Library.Moving; + +public sealed partial class PinnedDirectoryCreationTests +{ + [Fact] + public async Task PublishMigrationTargetAsync_DifferentPhysicalGeneration_DoesNotGrantOwnership() + { + // Given + var root = FileService.GetTempDirectory("ownership-migration-generation"); + var sourceDirectory = Path.Join(root, "source", "Book"); + var targetDirectory = Path.Join(root, "target", "Book"); + Directory.CreateDirectory(sourceDirectory); + Directory.CreateDirectory(targetDirectory); + var ownershipToken = Guid.NewGuid().ToString("N"); + using var sourceAnchor = PinnedDirectoryCreation.OpenPinnedBoundary(sourceDirectory); + using var targetAnchor = PinnedDirectoryCreation.OpenPinnedBoundary(targetDirectory); + Assert.NotEqual( + sourceAnchor.GetDirectoryObjectIdentity(), + targetAnchor.GetDirectoryObjectIdentity()); + var source = CreateOwnership( + sourceDirectory, + ownershipToken, + sourceAnchor.GetDirectoryObjectIdentity()); + var target = CreateOwnership( + targetDirectory, + ownershipToken, + sourceAnchor.GetDirectoryObjectIdentity()); + using var targetParent = PinnedDirectoryCreation.OpenPinnedBoundary( + Path.GetDirectoryName(targetDirectory)!); + + // When + var exception = await Assert.ThrowsAsync(() => + PinnedLibraryDirectoryOwnershipMarker.PublishMigrationTargetAsync( + source, + target, + targetParent, + CancellationToken.None)); + + // Then + Assert.Contains( + "different physical directory generation", + exception.Message, + StringComparison.OrdinalIgnoreCase); + Assert.False(File.Exists(Path.Join( + targetDirectory, + LibraryDirectoryOwnershipMarker.FileName))); + Assert.False(File.Exists(Path.Join( + Path.GetDirectoryName(targetDirectory)!, + $".listenarr-directory-owner-{ownershipToken}.json"))); + } + + [Fact] + public async Task PublishMigrationTargetAsync_SamePhysicalGeneration_CanPublishOwnership() + { + // Given + var directory = Path.Join( + FileService.GetTempDirectory("ownership-migration-same-generation"), + "Book"); + Directory.CreateDirectory(directory); + var ownershipToken = Guid.NewGuid().ToString("N"); + using var directoryAnchor = PinnedDirectoryCreation.OpenPinnedBoundary(directory); + var nativeIdentity = directoryAnchor.GetDirectoryObjectIdentity(); + var source = CreateOwnership(directory, ownershipToken, nativeIdentity); + var target = CreateOwnership(directory, ownershipToken, nativeIdentity); + using var parent = PinnedDirectoryCreation.OpenPinnedBoundary( + Path.GetDirectoryName(directory)!); + + // When + await PinnedLibraryDirectoryOwnershipMarker.PublishMigrationTargetAsync( + source, + target, + parent, + CancellationToken.None); + + // Then + LibraryDirectoryOwnershipMarker.Validate(target, directory); + Assert.True(ManagedDirectoryIdentity.Matches( + target.DirectoryObjectIdentityVersion, + target.DirectoryObjectIdentity, + ownershipToken, + nativeIdentity)); + } + + private static LibraryDirectoryOwnership CreateOwnership( + string path, + string ownershipToken, + string nativeIdentity) + { + var semantics = FileSystemPathSemantics.CurrentHostDefault; + return new LibraryDirectoryOwnership + { + Path = path, + CanonicalPath = path, + PathSyntax = semantics.Syntax, + PathCaseSensitivity = semantics.CaseSensitivity, + PathCaseSensitivityMode = FileSystemCaseSensitivityMode.Auto, + PathIdentityBoundary = path, + PathIdentityLookupKey = FileSystemPathIdentity.CreateLookupKey( + "library-directory", + path, + semantics.Syntax), + PathOwnershipKey = FileSystemPathIdentity.CreateKey( + "library-directory", + path, + semantics), + OwnershipToken = ownershipToken, + State = LibraryDirectoryOwnershipState.Owned, + CreationWorkflow = "Test", + ManagedRootFolderId = 1, + DirectoryObjectIdentityVersion = ManagedDirectoryIdentity.CurrentVersion, + DirectoryObjectIdentity = ManagedDirectoryIdentity.Create( + ownershipToken, + nativeIdentity) + }; + } +} diff --git a/tests/Features/Infrastructure/Library/Moving/RootFolderRelocationServiceTests.cs b/tests/Features/Infrastructure/Library/Moving/RootFolderRelocationServiceTests.cs index 6d0b8a7fd..3b2b0aecd 100644 --- a/tests/Features/Infrastructure/Library/Moving/RootFolderRelocationServiceTests.cs +++ b/tests/Features/Infrastructure/Library/Moving/RootFolderRelocationServiceTests.cs @@ -44,6 +44,7 @@ public async Task StartRelocation_ExpectedSourceChanged_RejectsBeforeCreatingSag var staleSource = Path.Join(TempRoot, $"stale-source-{Guid.NewGuid():N}"); var target = Path.Join(TempRoot, $"expected-target-{Guid.NewGuid():N}"); Directory.CreateDirectory(source); + Directory.CreateDirectory(target); int rootId; await using (var db = await _factory.CreateDbContextAsync()) { @@ -72,6 +73,57 @@ public async Task StartRelocation_ExpectedSourceChanged_RejectsBeforeCreatingSag Assert.Empty(verification.RootFolderRelocations); Assert.Empty(verification.MoveJobs); Assert.Equal(source, (await verification.RootFolders.SingleAsync()).Path); + Assert.False(File.Exists(Path.Join( + target, + ManagedDirectoryEnrollment.FileName))); + } + + [Fact] + public async Task StartRelocation_NoMoveJobs_TargetGenerationReplacedBeforeMetadataCommit_FailsClosed() + { + var source = Path.Join( + TempRoot, + $"no-jobs-replaced-source-{Guid.NewGuid():N}"); + var target = Path.Join( + TempRoot, + $"no-jobs-replaced-target-{Guid.NewGuid():N}"); + var displacedTarget = target + "-displaced"; + Directory.CreateDirectory(source); + Directory.CreateDirectory(target); + int rootId; + await using (var db = await _factory.CreateDbContextAsync()) + { + var root = new RootFolder { Name = "Library", Path = source }; + db.RootFolders.Add(root); + await db.SaveChangesAsync(); + rootId = root.Id; + } + + var resolver = new DirectoryObjectIdentityResolver(); + var replacingResolver = new Mock(); + replacingResolver + .Setup(candidate => candidate.ResolveAsync( + It.IsAny(), + It.IsAny())) + .Returns(async (path, cancellationToken) => + { + var identity = await resolver.ResolveAsync(path, cancellationToken); + Directory.Move(target, displacedTarget); + Directory.CreateDirectory(target); + return identity; + }); + var service = CreateService( + directoryObjectIdentityResolver: replacingResolver.Object); + + await Assert.ThrowsAsync(() => + service.StartAsync(rootId, BuildRelocationCommand(target))); + + await using var verification = await _factory.CreateDbContextAsync(); + Assert.Equal(source, (await verification.RootFolders.SingleAsync()).Path); + Assert.Empty(verification.RootFolderRelocations); + Assert.Empty(verification.MoveJobs); + Assert.True(Directory.Exists(displacedTarget)); + Assert.True(Directory.Exists(target)); } [Fact] @@ -129,8 +181,11 @@ await AddTrackedFileAsync( Assert.Equal(relocation.Id, job.RelocationId); Assert.Equal(source, job.SourceCleanupBoundary); Assert.Equal(MoveManifestIdentity.Version, job.IdentityKeyVersion); - Assert.Single(job.Entries); - Assert.Equal("book.m4b", job.Entries.Single().RelativePath); + Assert.Single(job.Entries, MoveManifestIdentity.IsTargetBoundaryAuthorization); + var sourceEntry = Assert.Single( + job.Entries, + entry => !MoveManifestIdentity.IsTargetBoundaryAuthorization(entry)); + Assert.Equal("book.m4b", sourceEntry.RelativePath); Assert.Equal(RootFolderRelocationStatus.Pending, result.Status); Assert.True(await service.IsBoundaryProtectedAsync( target, @@ -1005,7 +1060,10 @@ await CreateService(manifestScopes).StartAsync( Assert.Equal(bookPath, job.SourcePath); Assert.Equal(Path.Join(target, "Shared Author", "Book One"), job.RequestedPath); Assert.Equal(source, job.SourceCleanupBoundary); - var entry = Assert.Single(job.Entries); + Assert.Single(job.Entries, MoveManifestIdentity.IsTargetBoundaryAuthorization); + var entry = Assert.Single( + job.Entries, + candidate => !MoveManifestIdentity.IsTargetBoundaryAuthorization(candidate)); Assert.Equal("Book One.m4b", entry.RelativePath); Assert.Equal(authorPath, (await verification.Audiobooks.SingleAsync()).BasePath); } @@ -1055,11 +1113,16 @@ await CreateService(manifestScopes).StartAsync( Assert.Equal(sharedPath, job.SourcePath); Assert.Equal(Path.Join(target, "Shared"), job.RequestedPath); Assert.Equal(MoveManifestIdentity.Version, job.IdentityKeyVersion); - Assert.Single(job.Entries); + Assert.Single(job.Entries, MoveManifestIdentity.IsTargetBoundaryAuthorization); + Assert.Single( + job.Entries, + entry => !MoveManifestIdentity.IsTargetBoundaryAuthorization(entry)); }); Assert.Equal( new[] { "First.m4b", "Second.m4b" }, - jobs.Select(job => job.Entries.Single().RelativePath).OrderBy(path => path)); + jobs.Select(job => job.Entries.Single(entry => + !MoveManifestIdentity.IsTargetBoundaryAuthorization(entry)).RelativePath) + .OrderBy(path => path)); Assert.NotEqual(jobs[0].ActiveDeduplicationKey, jobs[1].ActiveDeduplicationKey); Assert.Equal(1, manifestScopes.CreatedScopeCount); Assert.Equal(1, manifestScopes.DisposedScopeCount); @@ -1617,6 +1680,12 @@ private static async Task CreateMoveCommandAsync( var targetResolution = await resolver.ResolveAsync(targetPath); Assert.Equal(PathIdentityState.Valid, sourceResolution.State); Assert.Equal(PathIdentityState.Valid, targetResolution.State); + var targetBoundary = FindExistingMoveTargetBoundary(targetPath); + var targetDirectoryIdentity = await new DirectoryObjectIdentityResolver() + .ResolveAsync(targetBoundary); + Assert.True( + targetDirectoryIdentity.IsAvailable, + targetDirectoryIdentity.UnavailableReason); return new MoveEnqueueCommand( audiobookId, sourcePath, @@ -1637,11 +1706,31 @@ private static async Task CreateMoveCommandAsync( PathIdentitySnapshot.FromResolution( targetResolution.Semantics, FileSystemCaseSensitivityMode.Auto, - targetResolution.BoundaryPath, + targetBoundary, targetPath), + targetDirectoryIdentity.Version!.Value, + targetDirectoryIdentity.Value!, DeleteEmptySource: true); } + private static string FindExistingMoveTargetBoundary(string targetPath) + { + var current = Directory.Exists(targetPath) + ? Path.GetFullPath(targetPath) + : Path.GetDirectoryName(Path.GetFullPath(targetPath)); + while (!string.IsNullOrWhiteSpace(current)) + { + if (Directory.Exists(current)) + { + return current; + } + current = Path.GetDirectoryName(current); + } + + throw new InvalidOperationException( + "Move test target has no existing authorization boundary."); + } + [Fact] public async Task ConcurrentMoveFirst_BlocksWaitingRelocationAfterMoveIsPersisted() { @@ -1681,7 +1770,7 @@ await CreateMoveCommandAsync( coordinator.ReleaseFirst(); await moveTask; var exception = await Assert.ThrowsAsync(() => relocationTask); - Assert.Contains("active move job", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("unresolved move job", exception.Message, StringComparison.OrdinalIgnoreCase); } [Fact] @@ -1819,6 +1908,147 @@ public async Task MetadataOnly_UpdatesRootAndAudiobooksInOneTransaction() Assert.Equal(RootFolderRelocationStatus.Completed, result.Status); } + [Fact] + public async Task MetadataOnly_SamePathCaseSensitivityChange_MigratesPersistedIdentityKeys() + { + var rootPath = Path.Join( + TempRoot, + $"metadata-semantics-{Guid.NewGuid():N}"); + var audiobookPath = Path.Join(rootPath, "Title"); + var audioPath = Path.Join(audiobookPath, "book.m4b"); + Directory.CreateDirectory(audiobookPath); + var sourceSemantics = new FileSystemPathSemantics( + FileSystemPathSemantics.CurrentHostDefault.Syntax, + FileSystemCaseSensitivity.Sensitive); + var targetSemantics = new FileSystemPathSemantics( + FileSystemPathSemantics.CurrentHostDefault.Syntax, + FileSystemCaseSensitivity.Insensitive); + int rootId; + string originalFileOwnershipKey; + await using (var db = await _factory.CreateDbContextAsync()) + { + var root = new RootFolder + { + Name = "Library", + Path = rootPath, + CaseSensitivityMode = FileSystemCaseSensitivityMode.Sensitive, + ResolvedCaseSensitivity = FileSystemCaseSensitivity.Sensitive, + PathIdentityState = PathIdentityState.Valid, + PathIdentityKey = FileSystemPathIdentity.CreateKey( + "root", + rootPath, + sourceSemantics) + }; + var audiobook = new Audiobook + { + Title = "Title", + BasePath = audiobookPath + }; + db.RootFolders.Add(root); + db.Audiobooks.Add(audiobook); + await db.SaveChangesAsync(); + await AddTrackedFileAsync( + db, + audiobook, + audioPath, + rootPath, + sourceSemantics, + FileSystemCaseSensitivityMode.Sensitive); + rootId = root.Id; + originalFileOwnershipKey = (await db.AudiobookFiles.SingleAsync()).PathOwnershipKey!; + } + + var result = await CreateService().StartAsync( + rootId, + new RootFolderPathChangeCommand( + rootPath, + RootFolderRelocationMode.MetadataOnly, + false, + "Library", + false, + FileSystemCaseSensitivityMode.Insensitive, + rootPath)); + + await using var verification = await _factory.CreateDbContextAsync(); + var rootAfter = await verification.RootFolders.SingleAsync(); + var fileAfter = await verification.AudiobookFiles.SingleAsync(); + Assert.Equal(RootFolderRelocationStatus.Completed, result.Status); + Assert.Equal(rootPath, rootAfter.Path); + Assert.Equal(FileSystemCaseSensitivityMode.Insensitive, rootAfter.CaseSensitivityMode); + Assert.Equal(FileSystemCaseSensitivity.Insensitive, rootAfter.ResolvedCaseSensitivity); + Assert.Equal( + FileSystemPathIdentity.CreateKey("root", rootPath, targetSemantics), + rootAfter.PathIdentityKey); + Assert.Equal(audioPath, fileAfter.Path); + Assert.NotEqual(originalFileOwnershipKey, fileAfter.PathOwnershipKey); + Assert.Equal( + AudiobookFilePathIdentity.CreateValid( + audioPath, + targetSemantics, + FileSystemCaseSensitivityMode.Insensitive, + rootPath).OwnershipKey, + fileAfter.PathOwnershipKey); + Assert.Empty(await verification.RootFolderRelocations.ToListAsync()); + } + + [Fact] + public async Task MetadataOnly_TargetRootReplacedAfterJournalCommit_DoesNotCommitStaleGeneration() + { + var source = Path.Join( + TempRoot, + $"metadata-target-generation-source-{Guid.NewGuid():N}"); + var target = Path.Join( + TempRoot, + $"metadata-target-generation-target-{Guid.NewGuid():N}"); + var displacedTarget = target + ".original"; + Directory.CreateDirectory(source); + Directory.CreateDirectory(target); + int rootId; + await using (var db = await _factory.CreateDbContextAsync()) + { + var root = new RootFolder + { + Name = "Library", + Path = source + }; + db.RootFolders.Add(root); + await db.SaveChangesAsync(); + rootId = root.Id; + } + + var service = CreateService(); + service.AfterMetadataOnlyJournalCommitForTest = () => + { + Directory.Move(target, displacedTarget); + Directory.CreateDirectory(target); + File.WriteAllText( + Path.Join(target, "foreign.txt"), + "replacement generation"); + }; + + await Assert.ThrowsAsync(() => + service.StartAsync( + rootId, + new RootFolderPathChangeCommand( + target, + RootFolderRelocationMode.MetadataOnly, + false, + "Metadata Library", + false, + FileSystemCaseSensitivityMode.Auto))); + + await using var verification = await _factory.CreateDbContextAsync(); + var rootAfter = await verification.RootFolders.SingleAsync(); + var relocation = await verification.RootFolderRelocations.SingleAsync(); + Assert.Equal(source, rootAfter.Path); + Assert.Equal(RootFolderRelocationStatus.NeedsAttention, relocation.Status); + Assert.Equal(rootId, relocation.ActiveRootFolderId); + Assert.True(Directory.Exists(displacedTarget)); + Assert.Equal( + "replacement generation", + await File.ReadAllTextAsync(Path.Join(target, "foreign.txt"))); + } + [Fact] public async Task MetadataOnly_RequestCancelledAfterJournalCommit_CompletesAuthoritatively() { @@ -1872,6 +2102,190 @@ public async Task MetadataOnly_RequestCancelledAfterJournalCommit_CompletesAutho Assert.Empty(await verification.RootFolderRelocations.ToListAsync()); } + [Fact] + public async Task ReauthorizeLegacyTarget_ExistingMoveJob_BindsConfirmedTargetGenerationBeforeRetry() + { + var (rootId, _, _, target) = await SeedRelocationScenarioAsync(); + var service = CreateService(); + var started = await service.StartAsync( + rootId, + BuildRelocationCommand(target)); + Assert.NotNull(started.RelocationId); + + await using (var db = await _factory.CreateDbContextAsync()) + { + var relocation = await db.RootFolderRelocations + .Include(candidate => candidate.MoveJobs) + .ThenInclude(job => job.Entries) + .SingleAsync(); + var job = Assert.Single(relocation.MoveJobs); + var targetAuthorization = Assert.Single( + job.Entries, + MoveManifestIdentity.IsTargetBoundaryAuthorization); + db.MoveJobEntries.Remove(targetAuthorization); + job.Status = MoveJobStatus.NeedsAttention; + job.Error = "Legacy target identity must be reauthorized."; + job.ActiveDeduplicationKey = null; + relocation.Status = RootFolderRelocationStatus.NeedsAttention; + relocation.TargetIdentityEnrollmentState = + TargetIdentityEnrollmentState.LegacyUnenrolled; + relocation.TargetDirectoryObjectIdentityVersion = null; + relocation.TargetDirectoryObjectIdentity = null; + relocation.TargetDirectoryObjectIdentityUnavailableReason = + "Legacy relocation has no enrolled target generation."; + relocation.Error = job.Error; + await db.SaveChangesAsync(); + } + + var result = await service.ReauthorizeLegacyTargetAsync( + started.RelocationId!.Value, + target); + + Assert.Equal(RootFolderRelocationStatus.Running, result.Status); + Assert.Equal( + TargetIdentityEnrollmentState.Authorized, + result.TargetIdentityEnrollmentState); + await using var verification = await _factory.CreateDbContextAsync(); + var retried = await verification.MoveJobs + .Include(job => job.Entries) + .SingleAsync(); + Assert.Equal(MoveJobStatus.Queued, retried.Status); + Assert.True(MoveManifestIdentity.TryGetTargetBoundaryAuthorization( + retried.Entries, + out var authorizationVersion, + out var authorizationDigest)); + var relocationAfter = await verification.RootFolderRelocations.SingleAsync(); + Assert.Equal( + relocationAfter.TargetDirectoryObjectIdentityVersion, + authorizationVersion); + Assert.Equal( + MoveManifestIdentity.ComputeTargetBoundaryAuthorizationDigest( + relocationAfter.TargetDirectoryObjectIdentityVersion!.Value, + relocationAfter.TargetDirectoryObjectIdentity!), + authorizationDigest); + } + + [Fact] + public async Task ReauthorizeLegacyTarget_ContradictoryChildAuthorization_RejectsBeforeTargetEnrollment() + { + var source = Path.Join( + TempRoot, + $"legacy-contradictory-source-{Guid.NewGuid():N}"); + var target = Path.Join( + TempRoot, + $"legacy-contradictory-target-{Guid.NewGuid():N}"); + var sourceBook = Path.Join(source, "Book"); + var targetBook = Path.Join(target, "Book"); + Directory.CreateDirectory(sourceBook); + Directory.CreateDirectory(target); + + var semanticsResolver = new FileSystemSemanticsResolver(); + var sourceResolution = await semanticsResolver.ResolveAsync(source); + var targetResolution = await semanticsResolver.ResolveAsync(target); + Assert.Equal(PathIdentityState.Valid, sourceResolution.State); + Assert.Equal(PathIdentityState.Valid, targetResolution.State); + + Guid relocationId; + await using (var db = await _factory.CreateDbContextAsync()) + { + var root = new RootFolder + { + Name = "Library", + Path = source, + CaseSensitivityMode = FileSystemCaseSensitivityMode.Auto, + ResolvedCaseSensitivity = sourceResolution.Semantics.CaseSensitivity, + PathIdentityState = PathIdentityState.Valid, + PathIdentityKey = FileSystemPathIdentity.CreateKey( + "root", + source, + sourceResolution.Semantics) + }; + var audiobook = new Audiobook + { + Title = "Book", + BasePath = sourceBook + }; + db.RootFolders.Add(root); + db.Audiobooks.Add(audiobook); + await db.SaveChangesAsync(); + + var relocation = new RootFolderRelocation + { + RootFolderId = root.Id, + ActiveRootFolderId = root.Id, + SourcePath = source, + SourceCaseSensitivityMode = FileSystemCaseSensitivityMode.Auto, + TargetPath = target, + TargetCaseSensitivityMode = FileSystemCaseSensitivityMode.Auto, + TargetIdentityEnrollmentState = TargetIdentityEnrollmentState.LegacyUnenrolled, + Mode = RootFolderRelocationMode.Relocate, + Status = RootFolderRelocationStatus.NeedsAttention, + DesiredName = "Library", + DesiredIsDefault = false, + TotalJobs = 1, + Error = "Legacy target identity must be reauthorized." + }; + db.RootFolderRelocations.Add(relocation); + await db.SaveChangesAsync(); + relocationId = relocation.Id; + + var sourceIdentity = PathIdentitySnapshot.FromResolution( + sourceResolution.Semantics, + FileSystemCaseSensitivityMode.Auto, + source, + sourceBook); + var targetIdentity = PathIdentitySnapshot.FromResolution( + targetResolution.Semantics, + FileSystemCaseSensitivityMode.Auto, + target, + targetBook); + var job = new MoveJob + { + AudiobookId = audiobook.Id, + RelocationId = relocation.Id, + SourcePath = sourceBook, + RequestedPath = targetBook, + Status = MoveJobStatus.NeedsAttention, + IdentityKeyVersion = MoveManifestIdentity.Version, + Entries = + [ + new MoveJobEntry + { + RelativePath = "book.m4b", + EntryType = MoveJobEntryType.File, + Length = 1, + Sha256 = new string('A', 64) + }, + MoveManifestIdentity.CreateTargetBoundaryAuthorization( + ManagedDirectoryIdentity.CurrentVersion, + "contradictory-target-generation") + ] + }; + job.SetSourceIdentity(sourceIdentity); + job.SetTargetIdentity(targetIdentity); + db.MoveJobs.Add(job); + await db.SaveChangesAsync(); + } + + var service = CreateService(); + var exception = await Assert.ThrowsAsync(() => + service.ReauthorizeLegacyTargetAsync(relocationId, target)); + + Assert.Contains( + "already contains target-boundary authorization", + exception.Message, + StringComparison.OrdinalIgnoreCase); + Assert.False(File.Exists(Path.Join( + target, + ManagedDirectoryEnrollment.FileName))); + await using var verification = await _factory.CreateDbContextAsync(); + var relocationAfter = await verification.RootFolderRelocations + .SingleAsync(candidate => candidate.Id == relocationId); + Assert.Equal( + TargetIdentityEnrollmentState.LegacyUnenrolled, + relocationAfter.TargetIdentityEnrollmentState); + } + [Fact] public async Task ReauthorizeLegacyTarget_RequestCancelledAfterAuthorization_CompletesRetry() { @@ -1973,6 +2387,12 @@ public async Task MetadataOnly_PostCommitOwnershipCleanupFailure_ReturnsProtecte .ResolveAsync(ownedPath); Assert.True(rootObjectIdentity.IsAvailable); Assert.True(ownedObjectIdentity.IsAvailable); + var ownershipToken = Guid.NewGuid().ToString("N"); + using var ownedAnchor = + PinnedDirectoryCreation.OpenPinnedBoundary(ownedPath); + var ownershipIdentity = ManagedDirectoryIdentity.Create( + ownershipToken, + ownedAnchor.GetDirectoryObjectIdentity()); int rootId; string targetOwnershipKey; @@ -2031,14 +2451,14 @@ public async Task MetadataOnly_PostCommitOwnershipCleanupFailure_ReturnsProtecte "library-directory", ownedPath, semantics.Semantics), - OwnershipToken = Guid.NewGuid().ToString("N"), + OwnershipToken = ownershipToken, State = LibraryDirectoryOwnershipState.Owned, CreationWorkflow = "Test", AudiobookId = audiobook.Id, ManagedRootFolderId = root.Id, DirectoryObjectIdentityVersion = - ownedObjectIdentity.Version, - DirectoryObjectIdentity = ownedObjectIdentity.Value + ManagedDirectoryIdentity.CurrentVersion, + DirectoryObjectIdentity = ownershipIdentity }); await db.SaveChangesAsync(); } @@ -2172,6 +2592,41 @@ public async Task ReconcileOwnershipMigration_SourceRetirementBlocked_PreservesJ candidate.RelocationId == scenario.RelocationId)); } + [Fact] + public async Task ReconcileOwnershipMigration_TargetGenerationReplacedAtSourceRetirement_PreservesSourceEvidence() + { + var scenario = await SeedPublishedOwnershipMigrationAsync(); + var displacedRoot = scenario.RootPath + ".original"; + var service = CreateService(); + service.BeforeOwnershipMigrationSourceRetirementForTest = () => + { + Directory.Move(scenario.RootPath, displacedRoot); + Directory.CreateDirectory(scenario.RootPath); + }; + + await service.ReconcileActiveAsync(); + + await using var verification = await _factory.CreateDbContextAsync(); + var relocation = await verification.RootFolderRelocations + .SingleAsync(candidate => candidate.Id == scenario.RelocationId); + var journal = await verification.LibraryDirectoryOwnershipPathMigrations + .SingleAsync(candidate => candidate.RelocationId == scenario.RelocationId); + Assert.Equal(RootFolderRelocationStatus.NeedsAttention, relocation.Status); + Assert.Contains( + "enrolled physical generation", + relocation.Error ?? string.Empty, + StringComparison.OrdinalIgnoreCase); + Assert.Equal( + LibraryDirectoryOwnershipPathMigrationState.MetadataCommitted, + journal.State); + Assert.True(File.Exists(Path.Join( + displacedRoot, + $".listenarr-directory-owner-{scenario.OwnershipToken}.json"))); + Assert.False(File.Exists(Path.Join( + scenario.RootPath, + $".listenarr-directory-owner-{scenario.OwnershipToken}.json"))); + } + [Fact] public async Task ReconcileOwnershipMigration_TargetGenerationReplaced_BlocksBeforeMetadataCommit() { @@ -3028,7 +3483,9 @@ public async Task MetadataOnly_SourceRootFilePathCompletesWithoutRetry() false, FileSystemCaseSensitivityMode.Auto)); - Assert.Equal(RootFolderRelocationStatus.Completed, started.Status); + Assert.True( + started.Status == RootFolderRelocationStatus.Completed, + started.Error ?? $"Unexpected status: {started.Status}"); Assert.Equal(1, started.CompletedJobs); Assert.Null(started.RelocationId); @@ -3253,12 +3710,8 @@ public async Task RetryAsync_ForeignPersistedSourceIdentity_RemainsNeedsAttentio var relocation = await db.RootFolderRelocations.SingleAsync(); var job = await db.MoveJobs.SingleAsync(); var nativeSource = Assert.IsType(job.SourcePath); - var driveRoot = Path.GetPathRoot(nativeSource)!; - var foreignSource = "/" + nativeSource[driveRoot.Length..].Replace('\\', '/'); - Assert.Equal( - Path.GetFullPath(nativeSource), - Path.GetFullPath(foreignSource), - StringComparer.OrdinalIgnoreCase); + var foreignSource = TempFileService + .GetWindowsRootRelativeForeignAlias(nativeSource); job.SourcePath = foreignSource; job.SourcePathSyntax = FileSystemPathSyntax.Unix; job.SourceCaseSensitivity = FileSystemCaseSensitivity.Sensitive; @@ -3393,6 +3846,44 @@ await AddTrackedFileAsync( Assert.Equal(relocationAfter.TotalJobs, relocationAfter.CompletedJobs); } + [Fact] + public async Task FinalizeCompletedRelocation_ReplacedTargetWithoutEnrollment_DoesNotEnrollReplacement() + { + var (rootId, _, source, target) = await SeedRelocationScenarioAsync(); + var service = CreateService(); + var started = await service.StartAsync( + rootId, + BuildRelocationCommand(target)); + Guid jobId; + await using (var db = await _factory.CreateDbContextAsync()) + { + var job = await db.MoveJobs.SingleAsync(); + jobId = job.Id; + job.Status = MoveJobStatus.Completed; + job.ActiveDeduplicationKey = null; + await db.SaveChangesAsync(); + } + + var displacedTarget = target + "-displaced"; + Directory.Move(target, displacedTarget); + Directory.CreateDirectory(target); + var replacementEnrollment = Path.Join( + target, + ".listenarr-root-enrollment.json"); + Assert.False(File.Exists(replacementEnrollment)); + + await service.OnMoveJobStateChangedAsync(jobId); + + await using var verification = await _factory.CreateDbContextAsync(); + var rootAfter = await verification.RootFolders.SingleAsync(root => root.Id == rootId); + var relocationAfter = await verification.RootFolderRelocations + .SingleAsync(relocation => relocation.Id == started.RelocationId); + Assert.Equal(source, rootAfter.Path); + Assert.Equal(RootFolderRelocationStatus.NeedsAttention, relocationAfter.Status); + Assert.False(File.Exists(replacementEnrollment)); + Assert.True(Directory.Exists(displacedTarget)); + } + [Fact] public async Task RetryAsync_AllJobsCompletedButTargetStillUnavailable_StaysNeedsAttentionWithoutMutatingRoot() { @@ -4294,12 +4785,75 @@ public async Task StartRelocation_ActiveMoveBoundaryUsesPersistedInsensitiveSema false, FileSystemCaseSensitivityMode.Auto))); - Assert.Contains("active move job", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("unresolved move job", exception.Message, StringComparison.OrdinalIgnoreCase); await using var verification = await _factory.CreateDbContextAsync(); Assert.Empty(await verification.RootFolderRelocations.ToListAsync()); Assert.Single(await verification.MoveJobs.ToListAsync()); } + [Fact] + public async Task StartRelocation_RejectsOverlappingFailedPublishedMove() + { + var source = Path.Join(Path.GetTempPath(), $"failed-move-source-{Guid.NewGuid():N}"); + var target = Path.Join(Path.GetTempPath(), $"failed-move-target-{Guid.NewGuid():N}"); + var audiobookPath = Path.Join(source, "Author", "Title"); + Directory.CreateDirectory(audiobookPath); + int rootId; + await using (var db = await _factory.CreateDbContextAsync()) + { + var root = new RootFolder + { + Name = "Library", + Path = source, + CaseSensitivityMode = FileSystemCaseSensitivityMode.Auto + }; + var audiobook = new Audiobook { Title = "Title", BasePath = audiobookPath }; + db.RootFolders.Add(root); + db.Audiobooks.Add(audiobook); + await db.SaveChangesAsync(); + rootId = root.Id; + db.MoveJobs.Add(new MoveJob + { + AudiobookId = audiobook.Id, + SourcePath = audiobookPath, + RequestedPath = Path.Join(target, "Author", "Title"), + Status = MoveJobStatus.Failed, + Phase = MoveJobPhase.Published, + FailureKind = MoveFailureKind.Unknown, + EnqueuedAt = DateTime.UtcNow, + Entries = + [ + new MoveJobEntry + { + RelativePath = "book.m4b", + EntryType = MoveJobEntryType.File, + Length = 1, + LastWriteTimeUtc = DateTime.UnixEpoch, + Sha256 = new string('A', 64), + CopyState = MoveJobEntryCopyState.Verified, + CleanupState = MoveJobEntryCleanupState.Deleted + } + ] + }); + await db.SaveChangesAsync(); + } + + var exception = await Assert.ThrowsAsync(() => + CreateService().StartAsync( + rootId, + new RootFolderPathChangeCommand( + target, + RootFolderRelocationMode.Relocate, + true, + "Renamed Library", + false, + FileSystemCaseSensitivityMode.Auto))); + + Assert.Contains("unresolved move job", exception.Message, StringComparison.OrdinalIgnoreCase); + await using var verification = await _factory.CreateDbContextAsync(); + Assert.Empty(await verification.RootFolderRelocations.ToListAsync()); + } + [Theory] [InlineData("audiobook")] [InlineData("source")] @@ -4360,7 +4914,7 @@ public async Task StartRelocation_RejectsOverlappingActiveStandaloneMove(string false, FileSystemCaseSensitivityMode.Insensitive))); - Assert.Contains("active move job", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("unresolved move job", exception.Message, StringComparison.OrdinalIgnoreCase); await using var verification = await _factory.CreateDbContextAsync(); Assert.Empty(await verification.RootFolderRelocations.ToListAsync()); Assert.Empty(await verification.RootFolderRelocationSkippedItems.ToListAsync()); @@ -4730,8 +5284,12 @@ await File.WriteAllTextAsync( private async Task<(int RootId, int AudiobookId, string Source, string Target)> SeedRelocationScenarioAsync() { - var source = Path.Join(Path.GetTempPath(), $"relocation-source-{Guid.NewGuid():N}"); - var target = Path.Join(Path.GetTempPath(), $"relocation-target-{Guid.NewGuid():N}"); + var source = OperatingSystem.IsWindows() + ? FileService.GetWindowsRootRelativeTempPath("relocation-source") + : Path.Join(Path.GetTempPath(), $"relocation-source-{Guid.NewGuid():N}"); + var target = OperatingSystem.IsWindows() + ? FileService.GetWindowsRootRelativeTempPath("relocation-target") + : Path.Join(Path.GetTempPath(), $"relocation-target-{Guid.NewGuid():N}"); Directory.CreateDirectory(Path.Join(source, "Author", "Title")); Directory.CreateDirectory(target); await using var db = await _factory.CreateDbContextAsync(); @@ -4951,14 +5509,16 @@ public Task BroadcastAsync( private RootFolderRelocationService CreateService( IServiceScopeFactory? manifestScopeFactory = null, - IFileSystemSemanticsResolver? semanticsResolver = null) => new( + IFileSystemSemanticsResolver? semanticsResolver = null, + IDirectoryObjectIdentityResolver? directoryObjectIdentityResolver = null) => new( _factory, semanticsResolver ?? new FileSystemSemanticsResolver(), new NoopHubBroadcaster(), TimeProvider.System, new FilesystemMutationCoordinator(), _operationCoordinator, - manifestScopeFactory ?? CreateMoveSourceManifestService()); + manifestScopeFactory ?? CreateMoveSourceManifestService(), + directoryObjectIdentityResolver); private ManifestServiceScopeFactory CreateMoveSourceManifestService() { diff --git a/tests/Features/Infrastructure/Library/Scanning/AudiobookScanServiceTests.cs b/tests/Features/Infrastructure/Library/Scanning/AudiobookScanServiceTests.cs index e71f5021f..09090b7d7 100644 --- a/tests/Features/Infrastructure/Library/Scanning/AudiobookScanServiceTests.cs +++ b/tests/Features/Infrastructure/Library/Scanning/AudiobookScanServiceTests.cs @@ -801,10 +801,11 @@ private async Task ScanAsync( string scanRoot, bool isAuthoritativeScope = true) { - await _applicationSettingsRepository.SaveAsync( - new ApplicationSettingsBuilder() - .WithOutputPath(scanRoot) - .Build()); + var settings = await _applicationSettingsRepository.GetAsync() + ?? await _applicationSettingsRepository.InitializeIfMissingAsync( + new ApplicationSettingsBuilder().Build()); + settings.OutputPath = scanRoot; + await _applicationSettingsRepository.SaveAsync(settings); var authorization = await _provider .GetRequiredService() .AuthorizeAsync(scanRoot); diff --git a/tests/Features/Infrastructure/Library/Scanning/MoveScanHandoffDispatchWorkflowTests.cs b/tests/Features/Infrastructure/Library/Scanning/MoveScanHandoffDispatchWorkflowTests.cs new file mode 100644 index 000000000..ab3b5eb42 --- /dev/null +++ b/tests/Features/Infrastructure/Library/Scanning/MoveScanHandoffDispatchWorkflowTests.cs @@ -0,0 +1,87 @@ +using Listenarr.Tests.Common; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Listenarr.Tests.Features.Infrastructure.Library.Scanning; + +[Trait("Area", "LibraryScanning")] +[Trait("Name", "MoveScanHandoffDispatchWorkflowTests")] +[Trait("Category", "Infrastructure")] +public sealed class MoveScanHandoffDispatchWorkflowTests : BaseTests +{ + [Fact] + public async Task TryDispatchPendingAsync_InternalAuthorizationCancellation_ReleasesClaimForRecovery() + { + var handoffId = Guid.NewGuid(); + var target = Path.Join( + Path.GetTempPath(), + "listenarr-tests", + $"move-scan-dispatch-{Guid.NewGuid():N}"); + var boundary = Path.GetPathRoot(Path.GetFullPath(target)) + ?? throw new InvalidOperationException("Test target root is unavailable."); + var semantics = FileSystemPathSemantics.CurrentHostDefault; + var identity = PathIdentitySnapshot.FromResolution( + semantics, + FileSystemCaseSensitivityMode.Auto, + boundary, + target); + var claim = new MoveScanHandoffClaim( + handoffId, + Guid.NewGuid(), + 4401, + target, + identity, + [], + AttemptGeneration: 1, + LeaseOwner: "dispatch-test-owner", + LeaseGeneration: 2); + var handoffStore = new Mock(MockBehavior.Strict); + handoffStore.Setup(store => store.TryClaimAsync( + handoffId, + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(claim); + handoffStore.Setup(store => store.ReleaseClaimAsync( + handoffId, + claim.LeaseOwner, + claim.LeaseGeneration, + It.Is(error => error != null && error.Contains("cancellation", StringComparison.OrdinalIgnoreCase)), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(true); + var authorization = new Mock(MockBehavior.Strict); + authorization.Setup(service => service.AuthorizeAsync( + target, + It.IsAny())) + .ThrowsAsync(new TaskCanceledException( + "Injected internal authorization cancellation.")); + using var provider = new ServiceCollection() + .AddSingleton(authorization.Object) + .BuildServiceProvider(); + var scanQueue = new Mock(MockBehavior.Strict); + + var result = await MoveScanHandoffDispatchWorkflow.TryDispatchPendingAsync( + handoffId, + ownerPrefix: "dispatch-test", + knownAudiobook: new Audiobook { Id = claim.AudiobookId, Title = "Book" }, + beforeEnqueue: null, + scanQueue.Object, + handoffStore.Object, + provider.GetRequiredService(), + TimeProvider.System, + NullLogger.Instance, + CancellationToken.None); + + Assert.Equal(MoveScanDispatchOutcome.Failed, result.Outcome); + Assert.Null(result.ScanJobId); + handoffStore.Verify(store => store.ReleaseClaimAsync( + handoffId, + claim.LeaseOwner, + claim.LeaseGeneration, + It.IsAny(), + It.IsAny(), + It.IsAny()), Times.Once); + scanQueue.VerifyNoOtherCalls(); + } +} diff --git a/tests/Features/Infrastructure/Library/Scanning/MoveScanHandoffRecoveryServiceTests.cs b/tests/Features/Infrastructure/Library/Scanning/MoveScanHandoffRecoveryServiceTests.cs index 12877c7f0..3c70c36d4 100644 --- a/tests/Features/Infrastructure/Library/Scanning/MoveScanHandoffRecoveryServiceTests.cs +++ b/tests/Features/Infrastructure/Library/Scanning/MoveScanHandoffRecoveryServiceTests.cs @@ -176,7 +176,7 @@ public async Task RecoverAsync_TargetManifestReplacedBeforeDispatch_PreservesHan .SingleAsync(candidate => candidate.Id == handoff.Id); Assert.Equal(MoveScanHandoffStatus.Pending, persisted.Status); Assert.Contains( - "verification", + "physical generation", persisted.LastError ?? string.Empty, StringComparison.OrdinalIgnoreCase); Assert.Equal( @@ -220,6 +220,13 @@ private async Task InsertHandoffAsync( string targetPath, MoveScanHandoffStatus status) { + Directory.CreateDirectory(targetPath); + var manifestFile = Path.Join(targetPath, "handoff.m4b"); + if (!File.Exists(manifestFile)) + { + await File.WriteAllTextAsync(manifestFile, "completed move content"); + } + await using var db = await GetFactory().CreateDbContextAsync(); var resolution = await _provider .GetRequiredService() @@ -252,8 +259,11 @@ private async Task InsertHandoffAsync( db.MoveJobEntries.Add(new MoveJobEntry { MoveJobId = moveJob.Id, - RelativePath = string.Empty, - EntryType = MoveJobEntryType.Directory + RelativePath = Path.GetFileName(manifestFile), + EntryType = MoveJobEntryType.File, + Length = new FileInfo(manifestFile).Length, + Sha256 = Convert.ToHexString( + SHA256.HashData(await File.ReadAllBytesAsync(manifestFile))) }); db.MoveScanHandoffs.Add(handoff); await db.SaveChangesAsync(); diff --git a/tests/Features/Infrastructure/Library/Scanning/ScanJobProcessorTests.cs b/tests/Features/Infrastructure/Library/Scanning/ScanJobProcessorTests.cs index c0739c4fb..8af7cfa4a 100644 --- a/tests/Features/Infrastructure/Library/Scanning/ScanJobProcessorTests.cs +++ b/tests/Features/Infrastructure/Library/Scanning/ScanJobProcessorTests.cs @@ -29,6 +29,30 @@ await _applicationSettingsRepository.SaveAsync( .Build()); } + [Fact] + public async Task ProcessJobAsync_UnresolvedMoveExecution_BlocksBeforeScanReconciliation() + { + var basePath = FileService.GetTempDirectory("scan-processor-unresolved-move"); + _ = await FileService.GetFileAsync(basePath, "Scan Book.m4b", "audio"); + var audiobook = await _audiobookRepository.AddAsync(new AudiobookBuilder() + .WithTitle("Scan Move Fence") + .WithBasePath(basePath) + .Build()); + await MoveJobTestFactory.SeedUnresolvedExecutionAsync( + _provider, + audiobook.Id, + basePath, + Path.Join(FileService.GetTempPath(), $"scan-move-target-{Guid.NewGuid():N}")); + var (queue, job) = await CreateQueuedScanJobAsync(audiobook); + + await _provider.GetRequiredService() + .ProcessJobAsync(job, CancellationToken.None); + + var updatedJob = GetRequiredJob(queue, job.Id); + Assert.Equal("Failed", updatedJob.Status); + Assert.Empty(await _audiobookFileRepository.GetByAudiobookIdAsync(audiobook.Id)); + } + [Fact] public async Task ProcessJobAsync_HappyPath_ReconcilesFilesAndCompletesJob() { @@ -130,10 +154,11 @@ public async Task ProcessJobAsync_ConfiguredRootChangedAfterEnqueue_RejectsQueue var bookPath = Path.Join(originalRoot, "Author", "Book"); Directory.CreateDirectory(bookPath); _ = await FileService.GetFileAsync(bookPath, "Book.m4b", "audio"); - await _applicationSettingsRepository.SaveAsync( - new ApplicationSettingsBuilder() - .WithOutputPath(originalRoot) - .Build()); + var settings = await _applicationSettingsRepository.GetAsync() + ?? await _applicationSettingsRepository.InitializeIfMissingAsync( + new ApplicationSettingsBuilder().Build()); + settings.OutputPath = originalRoot; + settings = await _applicationSettingsRepository.SaveAsync(settings); var audiobook = await _audiobookRepository.AddAsync( new AudiobookBuilder() .WithTitle("Book") @@ -152,10 +177,8 @@ await _applicationSettingsRepository.SaveAsync( AuthorizationMode: ScanAuthorizationMode.PreauthorizedPath)); Assert.True(queue.Reader.TryRead(out var job)); Assert.Equal(jobId, job.Id); - await _applicationSettingsRepository.SaveAsync( - new ApplicationSettingsBuilder() - .WithOutputPath(replacementRoot) - .Build()); + settings.OutputPath = replacementRoot; + await _applicationSettingsRepository.SaveAsync(settings); await _provider.GetRequiredService() .ProcessJobAsync(job, CancellationToken.None); @@ -527,7 +550,8 @@ public async Task ProcessJobAsync_ForeignPersistedBasePath_IsAuthorizedBeforeAny _provider.GetRequiredService(), _provider.GetRequiredService(), _provider.GetRequiredService(), - _provider.GetRequiredService()); + _provider.GetRequiredService(), + _provider.GetRequiredService()); await processor.ProcessJobAsync(job, CancellationToken.None); @@ -607,7 +631,8 @@ public async Task ProcessJobAsync_AudiobookDeletedBeforeCompletion_MarksMoveScan _provider.GetRequiredService(), _provider.GetRequiredService(), _provider.GetRequiredService(), - _provider.GetRequiredService()); + _provider.GetRequiredService(), + _provider.GetRequiredService()); await processor.ProcessJobAsync(job, CancellationToken.None); diff --git a/tests/Features/Infrastructure/Library/Scanning/ScanPathAuthorizationServiceTests.cs b/tests/Features/Infrastructure/Library/Scanning/ScanPathAuthorizationServiceTests.cs index ceb3f5bfa..4ee05bd99 100644 --- a/tests/Features/Infrastructure/Library/Scanning/ScanPathAuthorizationServiceTests.cs +++ b/tests/Features/Infrastructure/Library/Scanning/ScanPathAuthorizationServiceTests.cs @@ -67,7 +67,7 @@ public async Task AuthorizeAsync_ForeignPersistedRootSyntax_CannotAliasWindowsRo } [Fact] - public async Task AuthorizeAsync_ReplacedRoot_ProducesDifferentPhysicalIdentity() + public async Task AuthorizeAsync_ReplacedEnrolledRoot_IsRejected() { var parent = FileService.GetTempDirectory("scan-authorization-root-replacement"); var configuredRoot = Path.Join(parent, "library"); @@ -83,10 +83,11 @@ public async Task AuthorizeAsync_ReplacedRoot_ProducesDifferentPhysicalIdentity( Directory.CreateDirectory(scanRoot); var replacement = await service.AuthorizeAsync(scanRoot); - Assert.True(replacement.IsAuthorized, replacement.Error); - Assert.NotEqual( - original.PhysicalIdentity, - replacement.PhysicalIdentity); + Assert.False(replacement.IsAuthorized); + Assert.Equal( + ScanPathAuthorizationFailure.IdentityUnavailable, + replacement.Failure); + Assert.Null(replacement.PhysicalIdentity); Assert.True(Directory.Exists(Path.Join(displacedRoot, "Book"))); Assert.True(Directory.Exists(scanRoot)); } diff --git a/tests/Features/Infrastructure/Metadata/Jobs/MetadataRescanProcessorTests.cs b/tests/Features/Infrastructure/Metadata/Jobs/MetadataRescanProcessorTests.cs index b85396526..8ef2e10c0 100644 --- a/tests/Features/Infrastructure/Metadata/Jobs/MetadataRescanProcessorTests.cs +++ b/tests/Features/Infrastructure/Metadata/Jobs/MetadataRescanProcessorTests.cs @@ -19,6 +19,44 @@ namespace Listenarr.Tests.Features.Infrastructure.Metadata.Jobs [Trait("Category", "Infrastructure")] public sealed class MetadataRescanProcessorTests : BaseTests { + [Fact] + public async Task RunCycleAsync_UnresolvedMoveExecution_SkipsCandidateBeforeExtraction() + { + var metadataService = new Mock(MockBehavior.Strict); + Init(builder => builder.WithSingleton(metadataService.Object)); + var audioPath = await FileService.GetFileAsync( + FileService.GetTempDirectory("metadata-rescan-unresolved-move"), + "book.m4b", + "audio"); + var audiobook = await _audiobookRepository.AddAsync(new AudiobookBuilder() + .WithTitle("Metadata Move Fence") + .WithBasePath(Path.GetDirectoryName(audioPath)!) + .Build()); + var file = await _audiobookFileRepository.AddAsync(new AudiobookFileBuilder() + .WithAudiobook(audiobook) + .WithPath(audioPath) + .Build()); + await MoveJobTestFactory.SeedUnresolvedExecutionAsync( + _provider, + audiobook.Id, + audiobook.BasePath!, + Path.Join(FileService.GetTempPath(), $"metadata-move-target-{Guid.NewGuid():N}")); + + var processor = new MetadataRescanProcessor( + _provider.GetRequiredService(), + _provider.GetRequiredService(), + _provider.GetRequiredService(), + NullLogger.Instance); + await processor.RunCycleAsync(CancellationToken.None); + + var factory = _provider.GetRequiredService>(); + await using var verification = await factory.CreateDbContextAsync(); + var persisted = await verification.AudiobookFiles.SingleAsync(candidate => candidate.Id == file.Id); + Assert.Null(persisted.DurationSeconds); + Assert.Null(persisted.Format); + metadataService.VerifyNoOtherCalls(); + } + [Fact] public async Task RunCycleAsync_PathChangesDuringExtraction_DiscardsStaleMetadataResult() { @@ -58,6 +96,7 @@ public async Task RunCycleAsync_PathChangesDuringExtraction_DiscardsStaleMetadat var processor = new MetadataRescanProcessor( _provider.GetRequiredService(), _provider.GetRequiredService(), + _provider.GetRequiredService(), NullLogger.Instance); var cycle = processor.RunCycleAsync(CancellationToken.None); await extractionStarted.Task; @@ -140,6 +179,7 @@ public async Task RunCycleAsync_FileGenerationReplacedDuringExtraction_DoesNotAp var processor = new MetadataRescanProcessor( _provider.GetRequiredService(), _provider.GetRequiredService(), + _provider.GetRequiredService(), NullLogger.Instance); await processor.RunCycleAsync(CancellationToken.None); @@ -196,6 +236,7 @@ public async Task RunCycleAsync_PartialMetadataRefresh_PreservesExistingValidFie var processor = new MetadataRescanProcessor( _provider.GetRequiredService(), _provider.GetRequiredService(), + _provider.GetRequiredService(), NullLogger.Instance); await processor.RunCycleAsync(CancellationToken.None); @@ -242,6 +283,7 @@ await _rootFolderRepository.AddAsync(new RootFolderBuilder() var processor = new MetadataRescanProcessor( _provider.GetRequiredService(), _provider.GetRequiredService(), + _provider.GetRequiredService(), NullLogger.Instance); await processor.RunCycleAsync(CancellationToken.None); @@ -292,6 +334,7 @@ await _rootFolderRepository.AddAsync(new RootFolderBuilder() var processor = new MetadataRescanProcessor( _provider.GetRequiredService(), _provider.GetRequiredService(), + _provider.GetRequiredService(), NullLogger.Instance); await processor.RunCycleAsync(CancellationToken.None); diff --git a/tests/Features/Infrastructure/Persistence/ApplicationSettingsConcurrencyTests.cs b/tests/Features/Infrastructure/Persistence/ApplicationSettingsConcurrencyTests.cs index a17532363..bb3fa11c8 100644 --- a/tests/Features/Infrastructure/Persistence/ApplicationSettingsConcurrencyTests.cs +++ b/tests/Features/Infrastructure/Persistence/ApplicationSettingsConcurrencyTests.cs @@ -11,6 +11,7 @@ using Listenarr.Infrastructure.Persistence.Repositories; using Listenarr.Application.Common.Exceptions; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; namespace Listenarr.Tests.Features.Infrastructure.Persistence; @@ -49,14 +50,99 @@ public async Task ConcurrentInitialSave_ReturnsSingletonSettingsForBothCallers() var repository2 = new EfApplicationSettingsRepository(db2); var results = await Task.WhenAll( - repository1.SaveAsync(new ApplicationSettings()), - repository2.SaveAsync(new ApplicationSettings())); + repository1.InitializeIfMissingAsync(new ApplicationSettings()), + repository2.InitializeIfMissingAsync(new ApplicationSettings())); Assert.All(results, settings => Assert.Equal(1, settings.Id)); await using var verificationDb = new ListenArrDbContext(_options); Assert.Equal(1, await verificationDb.ApplicationSettings.CountAsync()); } + [Fact] + public async Task InitialSave_RacedByExternalInitialization_ThrowsConflictInsteadOfReportingSuccess() + { + var interceptor = new InsertCompetingSettingsInterceptor(_options); + var racingOptions = new DbContextOptionsBuilder() + .UseSqlite($"Data Source={_databasePath};Pooling=False") + .AddInterceptors(interceptor) + .Options; + await using var db = new ListenArrDbContext(racingOptions); + var repository = new EfApplicationSettingsRepository(db); + + var exception = await Assert.ThrowsAsync(() => + repository.SaveAsync(new ApplicationSettings + { + OutputPath = "submitted" + })); + + Assert.Equal("settings_concurrency_conflict", exception.Code); + await using var verificationDb = new ListenArrDbContext(_options); + var persisted = await verificationDb.ApplicationSettings + .AsNoTracking() + .SingleAsync(); + Assert.Equal("winner", persisted.OutputPath); + Assert.Equal(1, persisted.Version); + } + + [Fact] + public async Task ExistingSettingsUpdate_WithoutVersion_ThrowsStableConflict() + { + await using (var seedDb = new ListenArrDbContext(_options)) + { + await new EfApplicationSettingsRepository(seedDb).SaveAsync(new ApplicationSettings + { + OutputPath = "original" + }); + } + + await using var updateDb = new ListenArrDbContext(_options); + var repository = new EfApplicationSettingsRepository(updateDb); + + var exception = await Assert.ThrowsAsync(() => + repository.SaveAsync(new ApplicationSettings + { + OutputPath = "versionless-overwrite" + })); + + Assert.Equal("settings_concurrency_conflict", exception.Code); + await using var verificationDb = new ListenArrDbContext(_options); + var persisted = await verificationDb.ApplicationSettings + .AsNoTracking() + .SingleAsync(); + Assert.Equal("original", persisted.OutputPath); + Assert.Equal(1, persisted.Version); + } + + private sealed class InsertCompetingSettingsInterceptor( + DbContextOptions competingOptions) : SaveChangesInterceptor + { + private int _invoked; + + public override async ValueTask> SavingChangesAsync( + DbContextEventData eventData, + InterceptionResult result, + CancellationToken cancellationToken = default) + { + if (Interlocked.Exchange(ref _invoked, 1) != 0 + || eventData.Context?.ChangeTracker + .Entries() + .All(entry => entry.State != EntityState.Added) != false) + { + return result; + } + + await using var competingDb = new ListenArrDbContext(competingOptions); + competingDb.ApplicationSettings.Add(new ApplicationSettings + { + Id = 1, + Version = 1, + OutputPath = "winner" + }); + await competingDb.SaveChangesAsync(cancellationToken); + return result; + } + } + [Fact] public async Task StaleSettingsUpdate_ThrowsStableConflict() { diff --git a/tests/Features/Infrastructure/Persistence/EfAudiobookFileRepositoryBasePathRegistrationTests.cs b/tests/Features/Infrastructure/Persistence/EfAudiobookFileRepositoryBasePathRegistrationTests.cs index 20b24081f..0af46cdcd 100644 --- a/tests/Features/Infrastructure/Persistence/EfAudiobookFileRepositoryBasePathRegistrationTests.cs +++ b/tests/Features/Infrastructure/Persistence/EfAudiobookFileRepositoryBasePathRegistrationTests.cs @@ -2,6 +2,7 @@ using Listenarr.Tests.Common; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; namespace Listenarr.Tests.Features.Infrastructure.Persistence; @@ -9,6 +10,105 @@ namespace Listenarr.Tests.Features.Infrastructure.Persistence; [Trait("Category", "Infrastructure")] public sealed class EfAudiobookFileRepositoryBasePathRegistrationTests : BaseTests { + [Fact] + public async Task ReplacePhysicalGenerationAsync_CancelledAtMutationCommand_CommitsUnambiguously() + { + await using var connection = await OpenDatabaseAsync(); + var interceptor = new CancelDuringMutationCommandInterceptor(); + var options = CreateOptions(connection, interceptor); + var boundary = Path.GetFullPath(Path.Join("library", "PhysicalGeneration")); + 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-one"); + seed.AudiobookFiles.Add(persisted); + await seed.SaveChangesAsync(); + } + using var cancellation = new CancellationTokenSource(); + interceptor.Arm(cancellation); + await using var context = new ListenArrDbContext(options); + var repository = new EfAudiobookFileRepository(context); + + var updated = await repository.ReplacePhysicalGenerationAsync( + persisted.Id, + 1, + filePath, + "generation-one", + CreateFile(1, filePath, boundary, "generation-two"), + cancellation.Token); + + Assert.True(cancellation.IsCancellationRequested); + Assert.True(updated); + await using var verification = new ListenArrDbContext(options); + Assert.Equal( + "generation-two", + (await verification.AudiobookFiles.AsNoTracking().SingleAsync()).PhysicalObjectIdentity); + } + + [Fact] + public async Task DeletePhysicalGenerationAsync_CancelledAtMutationCommand_CommitsUnambiguously() + { + await using var connection = await OpenDatabaseAsync(); + var interceptor = new CancelDuringMutationCommandInterceptor(); + var options = CreateOptions(connection, interceptor); + var boundary = Path.GetFullPath(Path.Join("library", "PhysicalGenerationDelete")); + 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-one"); + seed.AudiobookFiles.Add(persisted); + await seed.SaveChangesAsync(); + } + using var cancellation = new CancellationTokenSource(); + interceptor.Arm(cancellation); + await using var context = new ListenArrDbContext(options); + var repository = new EfAudiobookFileRepository(context); + + var deleted = await repository.DeletePhysicalGenerationAsync( + persisted.Id, + 1, + filePath, + "generation-one", + cancellation.Token); + + Assert.True(cancellation.IsCancellationRequested); + Assert.True(deleted); + await using var verification = new ListenArrDbContext(options); + Assert.Empty(await verification.AudiobookFiles.AsNoTracking().ToListAsync()); + } + + [Fact] + public async Task ApplyBasePathAsync_CancelledAtMutationCommand_CommitsUnambiguously() + { + await using var connection = await OpenDatabaseAsync(); + var interceptor = new CancelDuringMutationCommandInterceptor(); + var options = CreateOptions(connection, interceptor); + var originalBasePath = Path.GetFullPath(Path.Join("library", "ApplyBaseOriginal")); + var destination = Path.GetFullPath(Path.Join("library", "ApplyBaseDestination")); + await SeedAudiobooksAsync( + options, + new Audiobook { Id = 1, Title = "Book", BasePath = originalBasePath }); + using var cancellation = new CancellationTokenSource(); + interceptor.Arm(cancellation); + await using var context = new ListenArrDbContext(options); + var repository = new EfAudiobookFileRepository(context); + + var applied = await repository.ApplyBasePathAsync( + new AudiobookBasePathMutation(1, originalBasePath, destination), + cancellation.Token); + + Assert.True(cancellation.IsCancellationRequested); + Assert.True(applied); + await using var verification = new ListenArrDbContext(options); + Assert.Equal( + destination, + (await verification.Audiobooks.AsNoTracking().SingleAsync()).BasePath); + } + [Fact] public async Task ClaimWithBasePathAsync_CommitsFileAndBasePathTogether() { @@ -34,6 +134,34 @@ public async Task ClaimWithBasePathAsync_CommitsFileAndBasePathTogether() Assert.Equal("generation-one", persistedFile.PhysicalObjectIdentity); } + [Fact] + public async Task ClaimWithBasePathAsync_CancelledDuringCommit_CommitsUnambiguously() + { + await using var connection = await OpenDatabaseAsync(); + var interceptor = new CancelDuringCommitInterceptor(); + var options = CreateOptions(connection, interceptor); + var destination = Path.GetFullPath(Path.Join("library", "CommitCancel", "Book")); + var filePath = Path.Join(destination, "Book.m4b"); + await SeedAudiobooksAsync(options, new Audiobook { Id = 1, Title = "Book" }); + using var cancellation = new CancellationTokenSource(); + interceptor.Arm(cancellation); + await using var context = new ListenArrDbContext(options); + var repository = new EfAudiobookFileRepository(context); + + var result = await repository.ClaimWithBasePathAsync( + CreateFile(1, filePath, destination, "generation-one"), + new AudiobookBasePathMutation(1, null, destination), + cancellation.Token); + + Assert.True(cancellation.IsCancellationRequested); + Assert.True(result.Created, result.Reason); + await using var verification = new ListenArrDbContext(options); + Assert.Equal(destination, (await verification.Audiobooks.AsNoTracking().SingleAsync()).BasePath); + Assert.Equal( + "generation-one", + (await verification.AudiobookFiles.AsNoTracking().SingleAsync()).PhysicalObjectIdentity); + } + [Fact] public async Task ClaimWithBasePathAsync_OwnershipConflict_PreservesBasePath() { @@ -103,6 +231,87 @@ await SeedAudiobooksAsync( Assert.Equal("generation-one", file.PhysicalObjectIdentity); } + [Fact] + public async Task ReplacePhysicalGenerationWithBasePathAsync_CancelledDuringCommit_CommitsUnambiguously() + { + await using var connection = await OpenDatabaseAsync(); + var interceptor = new CancelDuringCommitInterceptor(); + var options = CreateOptions(connection, interceptor); + var originalBasePath = Path.GetFullPath(Path.Join("library", "CommitCancelOriginal")); + var destination = Path.GetFullPath(Path.Join("library", "CommitCancelDestination")); + var filePath = Path.Join(destination, "Book.m4b"); + await SeedAudiobooksAsync( + options, + new Audiobook { Id = 1, Title = "Book", BasePath = originalBasePath }); + AudiobookFile persisted; + await using (var seed = new ListenArrDbContext(options)) + { + persisted = CreateFile(1, filePath, destination, "generation-one"); + seed.AudiobookFiles.Add(persisted); + await seed.SaveChangesAsync(); + } + using var cancellation = new CancellationTokenSource(); + interceptor.Arm(cancellation); + await using var context = new ListenArrDbContext(options); + var repository = new EfAudiobookFileRepository(context); + + var updated = await repository.ReplacePhysicalGenerationWithBasePathAsync( + persisted.Id, + 1, + filePath, + "generation-one", + CreateFile(1, filePath, destination, "generation-two"), + new AudiobookBasePathMutation(1, originalBasePath, destination), + cancellation.Token); + + Assert.True(cancellation.IsCancellationRequested); + Assert.True(updated); + await using var verification = new ListenArrDbContext(options); + Assert.Equal(destination, (await verification.Audiobooks.AsNoTracking().SingleAsync()).BasePath); + Assert.Equal( + "generation-two", + (await verification.AudiobookFiles.AsNoTracking().SingleAsync()).PhysicalObjectIdentity); + } + + [Fact] + public async Task DeletePhysicalGenerationWithBasePathAsync_CancelledDuringCommit_CommitsUnambiguously() + { + await using var connection = await OpenDatabaseAsync(); + var interceptor = new CancelDuringCommitInterceptor(); + var options = CreateOptions(connection, interceptor); + var destination = Path.GetFullPath(Path.Join("library", "CommitCancelDeleteDestination")); + var previousBasePath = Path.GetFullPath(Path.Join("library", "CommitCancelDeletePrevious")); + 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, "generation-one"); + seed.AudiobookFiles.Add(persisted); + await seed.SaveChangesAsync(); + } + using var cancellation = new CancellationTokenSource(); + interceptor.Arm(cancellation); + await using var context = new ListenArrDbContext(options); + var repository = new EfAudiobookFileRepository(context); + + var deleted = await repository.DeletePhysicalGenerationWithBasePathAsync( + persisted.Id, + 1, + filePath, + "generation-one", + new AudiobookBasePathMutation(1, destination, previousBasePath), + cancellation.Token); + + Assert.True(cancellation.IsCancellationRequested); + Assert.True(deleted); + await using var verification = new ListenArrDbContext(options); + Assert.Equal(previousBasePath, (await verification.Audiobooks.AsNoTracking().SingleAsync()).BasePath); + Assert.Empty(await verification.AudiobookFiles.AsNoTracking().ToListAsync()); + } + [Fact] public async Task DeletePhysicalGenerationWithBasePathAsync_RestoresBothClaims() { @@ -166,10 +375,55 @@ private static async Task OpenDatabaseAsync() return connection; } - private static DbContextOptions CreateOptions(SqliteConnection connection) => - new DbContextOptionsBuilder() - .UseSqlite(connection) - .Options; + private static DbContextOptions CreateOptions( + SqliteConnection connection, + IInterceptor? interceptor = null) + { + var builder = new DbContextOptionsBuilder() + .UseSqlite(connection); + if (interceptor != null) + { + builder.AddInterceptors(interceptor); + } + + return builder.Options; + } + + private sealed class CancelDuringMutationCommandInterceptor : DbCommandInterceptor + { + private CancellationTokenSource? _cancellation; + + public void Arm(CancellationTokenSource cancellation) => + _cancellation = cancellation; + + public override ValueTask> NonQueryExecutingAsync( + System.Data.Common.DbCommand command, + CommandEventData eventData, + InterceptionResult result, + CancellationToken cancellationToken = default) + { + Interlocked.Exchange(ref _cancellation, null)?.Cancel(); + return ValueTask.FromResult(result); + } + } + + private sealed class CancelDuringCommitInterceptor : IDbTransactionInterceptor + { + private CancellationTokenSource? _cancellation; + + public void Arm(CancellationTokenSource cancellation) => + _cancellation = cancellation; + + public ValueTask TransactionCommittingAsync( + System.Data.Common.DbTransaction transaction, + TransactionEventData eventData, + InterceptionResult result, + CancellationToken cancellationToken = default) + { + Interlocked.Exchange(ref _cancellation, null)?.Cancel(); + return ValueTask.FromResult(result); + } + } private static async Task SeedAudiobooksAsync( DbContextOptions options, diff --git a/tests/Features/Infrastructure/Persistence/EfMoveQueuePersistenceTests.cs b/tests/Features/Infrastructure/Persistence/EfMoveQueuePersistenceTests.cs index 26828a122..40d8c0219 100644 --- a/tests/Features/Infrastructure/Persistence/EfMoveQueuePersistenceTests.cs +++ b/tests/Features/Infrastructure/Persistence/EfMoveQueuePersistenceTests.cs @@ -104,7 +104,7 @@ public async Task ReconcileIdentityKeys_SelectsMostAdvancedLegacyDuplicate() Phase = MoveJobPhase.Planned, IdentityKeyVersion = 1, ActiveDeduplicationKey = "legacy:first", - Entries = [CreateManifestEntry()] + Entries = CreateAuthorizedManifestEntries() }, new MoveJob { @@ -115,7 +115,7 @@ public async Task ReconcileIdentityKeys_SelectsMostAdvancedLegacyDuplicate() Phase = MoveJobPhase.Published, IdentityKeyVersion = 1, ActiveDeduplicationKey = "legacy:second", - Entries = [CreateManifestEntry()] + Entries = CreateAuthorizedManifestEntries() }); await db.SaveChangesAsync(); } @@ -132,11 +132,11 @@ public async Task ReconcileIdentityKeys_SelectsMostAdvancedLegacyDuplicate() Assert.True( jobs[1].Status == MoveJobStatus.Running, jobs[1].Error ?? $"Unexpected status: {jobs[1].Status}"); - Assert.StartsWith("v5:move-source:42:", jobs[1].ActiveDeduplicationKey); + Assert.StartsWith("v6:move-source:42:", jobs[1].ActiveDeduplicationKey); } [Fact] - public async Task ReconcileIdentityKeys_Version4ActiveJob_RebuildsVersion5KeyForDeduplication() + public async Task ReconcileIdentityKeys_LegacyActiveJobWithoutTargetGeneration_RequiresAttention() { var sourcePath = Path.GetFullPath(Path.Join( Path.GetTempPath(), @@ -168,24 +168,17 @@ public async Task ReconcileIdentityKeys_Version4ActiveJob_RebuildsVersion5KeyFor var reconciled = await persistence.GetByIdAsync(legacy.Id); Assert.NotNull(reconciled); - Assert.Equal(5, reconciled.IdentityKeyVersion); - Assert.True(reconciled.TryGetSourceIdentity(out var sourceIdentity)); - Assert.True(reconciled.TryGetTargetIdentity(out var targetIdentity)); - var expectedKey = MoveManifestIdentity.CreateDeduplicationKey( - reconciled.AudiobookId, - reconciled.SourcePath!, - sourceIdentity, - reconciled.RequestedPath!, - targetIdentity, - reconciled.Entries); - Assert.Equal(expectedKey, reconciled.ActiveDeduplicationKey); - Assert.Equal( - legacy.Id, - (await persistence.GetActiveByKeyAsync(expectedKey))?.Id); + Assert.Equal(MoveManifestIdentity.Version, reconciled.IdentityKeyVersion); + Assert.Equal(MoveJobStatus.NeedsAttention, reconciled.Status); + Assert.Null(reconciled.ActiveDeduplicationKey); + Assert.Contains( + "target-boundary physical-generation authorization", + reconciled.Error ?? string.Empty, + StringComparison.OrdinalIgnoreCase); } [Fact] - public async Task MoveQueueStartup_Version4ActiveJob_DeduplicatesIdenticalVersion5Enqueue() + public async Task MoveQueueStartup_LegacyJobWithoutTargetGeneration_DoesNotBlockNewAuthorizedMove() { var sourcePath = Path.GetFullPath(Path.Join( Path.GetTempPath(), @@ -234,11 +227,25 @@ public async Task MoveQueueStartup_Version4ActiveJob_DeduplicatesIdenticalVersio await queue.RecoverActiveJobsAsync(); var reconciled = await persistence.GetByIdAsync(legacy.Id); Assert.NotNull(reconciled); - Assert.True(reconciled.TryGetSourceIdentity(out var sourceIdentity)); - Assert.True(reconciled.TryGetTargetIdentity(out var targetIdentity)); + Assert.Equal(MoveJobStatus.NeedsAttention, reconciled.Status); + Assert.Null(reconciled.ActiveDeduplicationKey); + var sourceResolution = await BuildSemanticsResolver().ResolveAsync(sourcePath); + var targetResolution = await BuildSemanticsResolver().ResolveAsync(targetPath); + Assert.Equal(PathIdentityState.Valid, sourceResolution.State); + Assert.Equal(PathIdentityState.Valid, targetResolution.State); + var sourceIdentity = PathIdentitySnapshot.FromResolution( + sourceResolution.Semantics, + FileSystemCaseSensitivityMode.Auto, + sourceResolution.BoundaryPath, + sourcePath); + var targetIdentity = PathIdentitySnapshot.FromResolution( + targetResolution.Semantics, + FileSystemCaseSensitivityMode.Auto, + targetResolution.BoundaryPath, + targetPath); var returnedId = await queue.EnqueueMoveAsync(new MoveEnqueueCommand( reconciled.AudiobookId, - reconciled.SourcePath!, + sourcePath, sourceIdentity, [ new MoveSourceManifestEntry( @@ -248,19 +255,30 @@ public async Task MoveQueueStartup_Version4ActiveJob_DeduplicatesIdenticalVersio DateTime.UnixEpoch, new string('A', 64)) ], - reconciled.RequestedPath!, - targetIdentity)); + targetPath, + targetIdentity, + TargetBoundaryDirectoryObjectIdentityVersion: 2, + TargetBoundaryDirectoryObjectIdentity: "new-authorized-target-generation")); - Assert.Equal(legacy.Id, returnedId); + Assert.NotEqual(legacy.Id, returnedId); await using var verification = await _factory.CreateDbContextAsync(); - Assert.Single(await verification.MoveJobs.ToListAsync()); - Assert.Equal( - 5, - (await verification.MoveJobs.SingleAsync()).IdentityKeyVersion); + var jobs = await verification.MoveJobs + .Include(job => job.Entries) + .OrderBy(job => job.EnqueuedAt) + .ToListAsync(); + Assert.Equal(2, jobs.Count); + Assert.Equal(MoveJobStatus.NeedsAttention, jobs.Single(job => job.Id == legacy.Id).Status); + var authorized = jobs.Single(job => job.Id == returnedId); + Assert.Equal(MoveManifestIdentity.Version, authorized.IdentityKeyVersion); + Assert.NotNull(authorized.ActiveDeduplicationKey); + Assert.True(MoveManifestIdentity.TryGetTargetBoundaryAuthorization( + authorized.Entries, + out _, + out _)); } [Fact] - public async Task ReconcileIdentityKeys_Version5WriteFailure_RollsBackClearedActiveKey() + public async Task ReconcileIdentityKeys_Version6WriteFailure_RollsBackClearedActiveKey() { var sourcePath = Path.GetFullPath(Path.Join( Path.GetTempPath(), @@ -280,7 +298,7 @@ public async Task ReconcileIdentityKeys_Version5WriteFailure_RollsBackClearedAct Phase = MoveJobPhase.Planned, IdentityKeyVersion = 4, ActiveDeduplicationKey = originalKey, - Entries = [CreateManifestEntry()] + Entries = CreateAuthorizedManifestEntries() }; await using (var db = await _factory.CreateDbContextAsync()) { @@ -288,11 +306,11 @@ public async Task ReconcileIdentityKeys_Version5WriteFailure_RollsBackClearedAct await db.SaveChangesAsync(); await db.Database.ExecuteSqlRawAsync( """ - CREATE TRIGGER fail_version_5_identity_key + CREATE TRIGGER fail_version_6_identity_key BEFORE UPDATE OF ActiveDeduplicationKey ON MoveJobs - WHEN NEW.ActiveDeduplicationKey LIKE 'v5:%' + WHEN NEW.ActiveDeduplicationKey LIKE 'v6:%' BEGIN - SELECT RAISE(ABORT, 'simulated version 5 write failure'); + SELECT RAISE(ABORT, 'simulated version 6 write failure'); END; """); } @@ -356,7 +374,9 @@ public async Task ReconcileIdentityKeysAsync_DifferentSourcesRemainDistinctDespi EntryType = MoveJobEntryType.File, Length = 1, Sha256 = new string('b', 64) - }); + }, + CreateTargetAuthorizationEntry(first.Id), + CreateTargetAuthorizationEntry(second.Id)); await db.SaveChangesAsync(); } @@ -374,6 +394,119 @@ public async Task ReconcileIdentityKeysAsync_DifferentSourcesRemainDistinctDespi }); } + [Fact] + public async Task ReconcileIdentityKeysAsync_SameManifestWithoutAuthoritativeEvidence_RequiresAttention() + { + var source = Path.Join( + Path.GetTempPath(), + "listenarr-tests", + $"move-reconcile-no-evidence-source-{Guid.NewGuid():N}"); + var target = Path.Join( + Path.GetTempPath(), + "listenarr-tests", + $"move-reconcile-no-evidence-target-{Guid.NewGuid():N}"); + await using (var db = await _factory.CreateDbContextAsync()) + { + db.MoveJobs.AddRange( + new MoveJob + { + AudiobookId = 42, + SourcePath = source, + RequestedPath = target, + Status = MoveJobStatus.Queued, + Phase = MoveJobPhase.Planned, + IdentityKeyVersion = 1, + ActiveDeduplicationKey = "legacy:no-evidence-first", + Entries = CreateAuthorizedManifestEntries() + }, + new MoveJob + { + AudiobookId = 42, + SourcePath = source, + RequestedPath = target, + Status = MoveJobStatus.RetryScheduled, + Phase = MoveJobPhase.Planned, + IdentityKeyVersion = 1, + ActiveDeduplicationKey = "legacy:no-evidence-second", + Entries = CreateAuthorizedManifestEntries() + }); + await db.SaveChangesAsync(); + } + + await CreatePersistence().ReconcileIdentityKeysAsync(); + + await using var verification = await _factory.CreateDbContextAsync(); + var jobs = await verification.MoveJobs.AsNoTracking().ToListAsync(); + Assert.Equal(2, jobs.Count); + Assert.All(jobs, job => + { + Assert.Equal(MoveJobStatus.NeedsAttention, job.Status); + Assert.Contains( + "no authoritative recovery owner", + job.Error, + StringComparison.OrdinalIgnoreCase); + Assert.Null(job.ActiveDeduplicationKey); + }); + } + + [Fact] + public async Task ReconcileIdentityKeysAsync_TargetAuthorizationState_IsNotExecutionEvidence() + { + var source = Path.Join( + Path.GetTempPath(), + "listenarr-tests", + $"move-reconcile-auth-state-source-{Guid.NewGuid():N}"); + var target = Path.Join( + Path.GetTempPath(), + "listenarr-tests", + $"move-reconcile-auth-state-target-{Guid.NewGuid():N}"); + var firstEntries = CreateAuthorizedManifestEntries(); + firstEntries.Single(MoveManifestIdentity.IsTargetBoundaryAuthorization).CopyState = + MoveJobEntryCopyState.Verified; + await using (var db = await _factory.CreateDbContextAsync()) + { + db.MoveJobs.AddRange( + new MoveJob + { + AudiobookId = 42, + SourcePath = source, + RequestedPath = target, + Status = MoveJobStatus.Queued, + Phase = MoveJobPhase.Planned, + IdentityKeyVersion = 1, + ActiveDeduplicationKey = "legacy:auth-state-first", + Entries = firstEntries + }, + new MoveJob + { + AudiobookId = 42, + SourcePath = source, + RequestedPath = target, + Status = MoveJobStatus.RetryScheduled, + Phase = MoveJobPhase.Planned, + IdentityKeyVersion = 1, + ActiveDeduplicationKey = "legacy:auth-state-second", + Entries = CreateAuthorizedManifestEntries() + }); + await db.SaveChangesAsync(); + } + + await CreatePersistence().ReconcileIdentityKeysAsync(); + + await using var verification = await _factory.CreateDbContextAsync(); + var jobs = await verification.MoveJobs.AsNoTracking().ToListAsync(); + Assert.Equal(2, jobs.Count); + Assert.All(jobs, job => + { + Assert.Equal(MoveJobStatus.NeedsAttention, job.Status); + Assert.Contains( + "no authoritative recovery owner", + job.Error, + StringComparison.OrdinalIgnoreCase); + Assert.Null(job.ActiveDeduplicationKey); + }); + } + [Fact] public async Task ReconcileIdentityKeysAsync_SameManifestWithExecutionStateOnBothJobs_RequiresAttention() { @@ -396,11 +529,8 @@ public async Task ReconcileIdentityKeysAsync_SameManifestWithExecutionStateOnBot Phase = MoveJobPhase.Copying, IdentityKeyVersion = 1, ActiveDeduplicationKey = "legacy:executed-first", - Entries = - [ - CreateManifestEntry( - copyState: MoveJobEntryCopyState.Staged) - ] + Entries = CreateAuthorizedManifestEntries( + copyState: MoveJobEntryCopyState.Staged) }; var second = new MoveJob { @@ -411,11 +541,8 @@ public async Task ReconcileIdentityKeysAsync_SameManifestWithExecutionStateOnBot Phase = MoveJobPhase.Copying, IdentityKeyVersion = 1, ActiveDeduplicationKey = "legacy:executed-second", - Entries = - [ - CreateManifestEntry( - copyState: MoveJobEntryCopyState.Staged) - ] + Entries = CreateAuthorizedManifestEntries( + copyState: MoveJobEntryCopyState.Staged) }; db.MoveJobs.AddRange(first, second); await db.SaveChangesAsync(); @@ -438,7 +565,7 @@ public async Task ReconcileIdentityKeysAsync_SameManifestWithExecutionStateOnBot } [Fact] - public async Task ReconcileIdentityKeysAsync_AuthoritativeMarkerPreservesOwnerAndSupersedesCleanDuplicate() + public async Task ReconcileIdentityKeysAsync_UnstructuredRecoveryMarkerRequiresAttention() { var target = Path.Join( Path.GetTempPath(), @@ -458,7 +585,7 @@ public async Task ReconcileIdentityKeysAsync_AuthoritativeMarkerPreservesOwnerAn Phase = MoveJobPhase.Planned, IdentityKeyVersion = 1, ActiveDeduplicationKey = "legacy:owner", - Entries = [CreateManifestEntry()] + Entries = CreateAuthorizedManifestEntries() }; duplicate = new MoveJob { @@ -469,7 +596,7 @@ public async Task ReconcileIdentityKeysAsync_AuthoritativeMarkerPreservesOwnerAn Phase = MoveJobPhase.Planned, IdentityKeyVersion = 1, ActiveDeduplicationKey = "legacy:duplicate", - Entries = [CreateManifestEntry()] + Entries = CreateAuthorizedManifestEntries() }; db.MoveJobs.AddRange(owner, duplicate); await db.SaveChangesAsync(); @@ -485,13 +612,9 @@ await File.WriteAllTextAsync( .SingleAsync(job => job.Id == owner.Id); var persistedDuplicate = await verification.MoveJobs.AsNoTracking() .SingleAsync(job => job.Id == duplicate.Id); - Assert.True( - persistedOwner.Status == MoveJobStatus.Queued, - persistedOwner.Error ?? $"Unexpected status: {persistedOwner.Status}"); - Assert.NotNull(persistedOwner.ActiveDeduplicationKey); - Assert.True( - persistedDuplicate.Status == MoveJobStatus.Superseded, - persistedDuplicate.Error ?? $"Unexpected status: {persistedDuplicate.Status}"); + Assert.Equal(MoveJobStatus.NeedsAttention, persistedOwner.Status); + Assert.Null(persistedOwner.ActiveDeduplicationKey); + Assert.Equal(MoveJobStatus.NeedsAttention, persistedDuplicate.Status); Assert.Null(persistedDuplicate.ActiveDeduplicationKey); } @@ -516,7 +639,7 @@ public async Task ReconcileIdentityKeysAsync_MismatchedTargetOwnershipMarkerRequ Phase = MoveJobPhase.Planned, IdentityKeyVersion = 1, ActiveDeduplicationKey = "legacy:owner", - Entries = [CreateManifestEntry()] + Entries = CreateAuthorizedManifestEntries() }; duplicate = new MoveJob { @@ -527,7 +650,7 @@ public async Task ReconcileIdentityKeysAsync_MismatchedTargetOwnershipMarkerRequ Phase = MoveJobPhase.Planned, IdentityKeyVersion = 1, ActiveDeduplicationKey = "legacy:duplicate", - Entries = [CreateManifestEntry()] + Entries = CreateAuthorizedManifestEntries() }; db.MoveJobs.AddRange(owner, duplicate); await db.SaveChangesAsync(); @@ -561,12 +684,160 @@ await File.WriteAllTextAsync( }); } + [Fact] + public async Task ReconcileIdentityKeysAsync_StructuredTargetOwnershipMarkerPreservesOwner() + { + var target = Path.Join( + Path.GetTempPath(), + "listenarr-tests", + $"move-reconcile-structured-marker-{Guid.NewGuid():N}"); + Directory.CreateDirectory(target); + MoveJob owner; + MoveJob duplicate; + await using (var db = await _factory.CreateDbContextAsync()) + { + owner = new MoveJob + { + AudiobookId = 42, + RequestedPath = target, + SourcePath = target + "-source", + Status = MoveJobStatus.Queued, + Phase = MoveJobPhase.Planned, + IdentityKeyVersion = 1, + ActiveDeduplicationKey = "legacy:owner", + Entries = CreateAuthorizedManifestEntries() + }; + duplicate = new MoveJob + { + AudiobookId = 42, + RequestedPath = target, + SourcePath = owner.SourcePath, + Status = MoveJobStatus.Queued, + Phase = MoveJobPhase.Planned, + IdentityKeyVersion = 1, + ActiveDeduplicationKey = "legacy:duplicate", + Entries = CreateAuthorizedManifestEntries() + }; + db.MoveJobs.AddRange(owner, duplicate); + await db.SaveChangesAsync(); + } + + var targetParent = Path.GetDirectoryName(target)!; + await File.WriteAllTextAsync( + Path.Join(target, ".listenarr-temp-owner.json"), + JsonSerializer.Serialize(new + { + Version = 1, + ArtifactType = "temporary-directory", + JobId = owner.Id, + Source = owner.SourcePath, + Target = target, + DirectoryPath = Path.Join( + targetParent, + Path.GetFileName(target) + ".tmp-" + owner.Id.ToString("N")) + })); + + await CreatePersistence().ReconcileIdentityKeysAsync(); + + await using var verification = await _factory.CreateDbContextAsync(); + var persistedOwner = await verification.MoveJobs.AsNoTracking() + .SingleAsync(job => job.Id == owner.Id); + var persistedDuplicate = await verification.MoveJobs.AsNoTracking() + .SingleAsync(job => job.Id == duplicate.Id); + Assert.Equal(MoveJobStatus.Queued, persistedOwner.Status); + Assert.NotNull(persistedOwner.ActiveDeduplicationKey); + Assert.Equal(MoveJobStatus.Superseded, persistedDuplicate.Status); + Assert.Null(persistedDuplicate.ActiveDeduplicationKey); + } + + [Fact] + public async Task ReconcileIdentityKeysAsync_TargetOwnershipMarkerReplacedDuringPinnedOpen_RequiresAttention() + { + var target = Path.Join( + Path.GetTempPath(), + "listenarr-tests", + $"move-reconcile-marker-race-{Guid.NewGuid():N}"); + Directory.CreateDirectory(target); + MoveJob owner; + MoveJob duplicate; + await using (var db = await _factory.CreateDbContextAsync()) + { + owner = new MoveJob + { + AudiobookId = 42, + RequestedPath = target, + SourcePath = target + "-source", + Status = MoveJobStatus.Queued, + Phase = MoveJobPhase.Planned, + IdentityKeyVersion = 1, + ActiveDeduplicationKey = "legacy:owner", + Entries = CreateAuthorizedManifestEntries() + }; + duplicate = new MoveJob + { + AudiobookId = 42, + RequestedPath = target, + SourcePath = owner.SourcePath, + Status = MoveJobStatus.Queued, + Phase = MoveJobPhase.Planned, + IdentityKeyVersion = 1, + ActiveDeduplicationKey = "legacy:duplicate", + Entries = CreateAuthorizedManifestEntries() + }; + db.MoveJobs.AddRange(owner, duplicate); + await db.SaveChangesAsync(); + } + + var markerPath = Path.Join(target, ".listenarr-temp-owner.json"); + var targetParent = Path.GetDirectoryName(target)!; + await File.WriteAllTextAsync( + markerPath, + JsonSerializer.Serialize(new + { + Version = 1, + ArtifactType = "temporary-directory", + JobId = owner.Id, + Source = owner.SourcePath, + Target = target, + DirectoryPath = Path.Join( + targetParent, + Path.GetFileName(target) + ".tmp-" + owner.Id.ToString("N")) + })); + var replaced = false; + using var hook = ExclusiveDirectoryCreator.PushBeforeOpenParentHook(path => + { + if (replaced + || !string.Equals( + Path.GetFullPath(path), + Path.GetFullPath(markerPath), + StringComparison.OrdinalIgnoreCase)) + { + return; + } + + replaced = true; + File.Delete(markerPath); + File.WriteAllText(markerPath, "{\"Version\":1,\"ArtifactType\":\"temporary-directory\"}"); + }); + + await CreatePersistence().ReconcileIdentityKeysAsync(); + + Assert.True(replaced); + await using var verification = await _factory.CreateDbContextAsync(); + var jobs = await verification.MoveJobs.AsNoTracking().ToListAsync(); + Assert.All(jobs, job => + { + Assert.Equal(MoveJobStatus.NeedsAttention, job.Status); + Assert.Null(job.ActiveDeduplicationKey); + }); + } + [Theory] [InlineData("source-marker-write")] [InlineData("target-partial")] [InlineData("target-ownership-write")] [InlineData("target-cleanup")] - public async Task ReconcileIdentityKeysAsync_ResidualFilesystemEvidencePreservesOwner( + public async Task ReconcileIdentityKeysAsync_ResidualFilesystemEvidenceRequiresAttention( string evidenceKind) { var root = Path.Join( @@ -588,7 +859,7 @@ public async Task ReconcileIdentityKeysAsync_ResidualFilesystemEvidencePreserves Phase = MoveJobPhase.Planned, IdentityKeyVersion = 1, ActiveDeduplicationKey = "legacy:owner", - Entries = [CreateManifestEntry()] + Entries = CreateAuthorizedManifestEntries() }; duplicate = new MoveJob { @@ -599,7 +870,7 @@ public async Task ReconcileIdentityKeysAsync_ResidualFilesystemEvidencePreserves Phase = MoveJobPhase.Planned, IdentityKeyVersion = 1, ActiveDeduplicationKey = "legacy:duplicate", - Entries = [CreateManifestEntry()] + Entries = CreateAuthorizedManifestEntries() }; db.MoveJobs.AddRange(owner, duplicate); await db.SaveChangesAsync(); @@ -646,9 +917,9 @@ await File.WriteAllTextAsync( .SingleAsync(job => job.Id == owner.Id); var persistedDuplicate = await verification.MoveJobs.AsNoTracking() .SingleAsync(job => job.Id == duplicate.Id); - Assert.Equal(MoveJobStatus.Queued, persistedOwner.Status); - Assert.NotNull(persistedOwner.ActiveDeduplicationKey); - Assert.Equal(MoveJobStatus.Superseded, persistedDuplicate.Status); + Assert.Equal(MoveJobStatus.NeedsAttention, persistedOwner.Status); + Assert.Null(persistedOwner.ActiveDeduplicationKey); + Assert.Equal(MoveJobStatus.NeedsAttention, persistedDuplicate.Status); Assert.Null(persistedDuplicate.ActiveDeduplicationKey); } @@ -668,7 +939,7 @@ public async Task ReconcileIdentityKeysAsync_MalformedLegacyJobMarksNeedsAttenti Phase = MoveJobPhase.None, IdentityKeyVersion = 1, ActiveDeduplicationKey = "legacy:bad", - Entries = [CreateManifestEntry()] + Entries = CreateAuthorizedManifestEntries() }, new MoveJob { @@ -679,7 +950,7 @@ public async Task ReconcileIdentityKeysAsync_MalformedLegacyJobMarksNeedsAttenti Phase = MoveJobPhase.None, IdentityKeyVersion = 1, ActiveDeduplicationKey = "legacy:good", - Entries = [CreateManifestEntry()] + Entries = CreateAuthorizedManifestEntries() }); await db.SaveChangesAsync(); } @@ -700,7 +971,7 @@ public async Task ReconcileIdentityKeysAsync_MalformedLegacyJobMarksNeedsAttenti Assert.Contains("Move path identity could not be reconciled", bad.Error, StringComparison.Ordinal); Assert.Null(bad.ActiveDeduplicationKey); Assert.Equal(MoveJobStatus.Queued, good.Status); - Assert.StartsWith("v5:move-source:43:", good.ActiveDeduplicationKey); + Assert.StartsWith("v6:move-source:43:", good.ActiveDeduplicationKey); } [Fact] @@ -728,7 +999,7 @@ public async Task ReconcileIdentityKeysAsync_ForeignLegacyPaths_ArePreservedAndR LeaseOwner = "legacy-worker", LeaseGeneration = 2, LeaseExpiresAt = DateTime.UtcNow.AddMinutes(5), - Entries = [CreateManifestEntry()] + Entries = CreateAuthorizedManifestEntries() }); await db.SaveChangesAsync(); } @@ -776,7 +1047,7 @@ public async Task ReconcileIdentityKeysAsync_ForeignPersistedIdentity_RequiresAt LeaseOwner = "legacy-worker", LeaseGeneration = 2, LeaseExpiresAt = DateTime.UtcNow.AddMinutes(5), - Entries = [CreateManifestEntry()] + Entries = CreateAuthorizedManifestEntries() }; job.SetSourceIdentity(new PathIdentitySnapshot( syntax, @@ -832,7 +1103,7 @@ public async Task ReconcileIdentityKeysAsync_InvalidTarget_DoesNotPartiallyRewri Phase = MoveJobPhase.None, IdentityKeyVersion = 1, ActiveDeduplicationKey = "legacy:partial", - Entries = [CreateManifestEntry()] + Entries = CreateAuthorizedManifestEntries() }); await db.SaveChangesAsync(); } @@ -878,7 +1149,7 @@ public async Task ReconcileIdentityKeysAsync_NavigationSegment_IsPreservedAndReq LeaseOwner = "legacy-worker", LeaseGeneration = 7, LeaseExpiresAt = DateTime.UtcNow.AddMinutes(5), - Entries = [CreateManifestEntry()] + Entries = CreateAuthorizedManifestEntries() }); await db.SaveChangesAsync(); } @@ -917,7 +1188,7 @@ public async Task ReconcileIdentityKeysAsync_RelativeLegacyPath_IsPreservedAndRe Phase = MoveJobPhase.None, IdentityKeyVersion = 1, ActiveDeduplicationKey = "legacy:relative", - Entries = [CreateManifestEntry()] + Entries = CreateAuthorizedManifestEntries() }); await db.SaveChangesAsync(); } @@ -1634,13 +1905,41 @@ private static MoveJobEntry CreateManifestEntry( CleanupState = cleanupState }; + private static List CreateAuthorizedManifestEntries( + string hashCharacter = "A", + MoveJobEntryCopyState copyState = MoveJobEntryCopyState.Pending, + MoveJobEntryCleanupState cleanupState = MoveJobEntryCleanupState.Pending) => + [ + CreateManifestEntry(hashCharacter, copyState, cleanupState), + MoveManifestIdentity.CreateTargetBoundaryAuthorization( + 2, + "test-target-generation") + ]; + + private static MoveJobEntry CreateTargetAuthorizationEntry( + Guid jobId, + string targetGeneration = "test-target-generation") + { + var entry = MoveManifestIdentity.CreateTargetBoundaryAuthorization( + 2, + targetGeneration); + entry.MoveJobId = jobId; + return entry; + } + private static MoveJob CreateJob(string key) => new() { AudiobookId = 42, RequestedPath = "/library/book", Status = MoveJobStatus.Queued, ActiveDeduplicationKey = key, - Entries = [CreateManifestEntry()] + Entries = + [ + CreateManifestEntry(), + MoveManifestIdentity.CreateTargetBoundaryAuthorization( + 2, + "test-target-generation") + ] }; private static RequeueMoveCommand CreateRequeueCommand( diff --git a/tests/Features/Infrastructure/Persistence/EfMoveScanHandoffStoreTests.cs b/tests/Features/Infrastructure/Persistence/EfMoveScanHandoffStoreTests.cs index 7511e907c..c61d997e0 100644 --- a/tests/Features/Infrastructure/Persistence/EfMoveScanHandoffStoreTests.cs +++ b/tests/Features/Infrastructure/Persistence/EfMoveScanHandoffStoreTests.cs @@ -583,6 +583,104 @@ public async Task ConcurrentClaims_OnlyOneWorkerAcquiresHandoff() Assert.Single(claims, claim => claim != null); } + [Fact] + public async Task TryClaimAsync_RootProofWithoutTrackedFileManifest_FailsClosed() + { + var handoff = await InsertPendingHandoffAsync(); + await using (var db = await GetFactory().CreateDbContextAsync()) + { + var moveJobId = await db.MoveScanHandoffs + .Where(candidate => candidate.Id == handoff.Id) + .Select(candidate => candidate.MoveJobId) + .SingleAsync(); + var entries = await db.MoveJobEntries + .Where(entry => entry.MoveJobId == moveJobId) + .ToListAsync(); + db.MoveJobEntries.RemoveRange(entries); + db.MoveJobEntries.Add(new MoveJobEntry + { + MoveJobId = moveJobId, + RelativePath = string.Empty, + EntryType = MoveJobEntryType.Directory + }); + await db.SaveChangesAsync(); + } + + var store = _provider.GetRequiredService(); + var claim = await store.TryClaimAsync( + handoff.Id, + "worker", + DateTimeOffset.UtcNow, + DateTimeOffset.UtcNow.AddMinutes(5)); + + Assert.Null(claim); + await using var verification = await GetFactory().CreateDbContextAsync(); + var persisted = await verification.MoveScanHandoffs + .AsNoTracking() + .SingleAsync(candidate => candidate.Id == handoff.Id); + Assert.Equal(MoveScanHandoffStatus.Failed, persisted.Status); + Assert.Contains( + "tracked-file", + persisted.LastError, + StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task TryClaimAsync_TargetAuthorizationWithoutSourceManifest_FailsClosed() + { + var target = FileService.GetTempDirectory("move-scan-handoff-auth-only"); + await using (var db = await GetFactory().CreateDbContextAsync()) + { + var move = new MoveJob + { + AudiobookId = 42, + SourcePath = target, + RequestedPath = target, + Status = MoveJobStatus.Completed, + Phase = MoveJobPhase.RecordingCompletion + }; + SetPathIdentities(move); + move.IdentityKeyVersion = MoveManifestIdentity.Version; + db.MoveJobs.Add(move); + var authorization = MoveManifestIdentity.CreateTargetBoundaryAuthorization( + 2, + "test-target-generation"); + authorization.MoveJobId = move.Id; + db.MoveJobEntries.Add(authorization); + db.MoveScanHandoffs.Add(new MoveScanHandoff + { + MoveJobId = move.Id, + AudiobookId = move.AudiobookId, + TargetPath = target, + Status = MoveScanHandoffStatus.Pending + }); + await db.SaveChangesAsync(); + } + + var store = _provider.GetRequiredService(); + await using var lookup = await GetFactory().CreateDbContextAsync(); + var handoffId = await lookup.MoveScanHandoffs + .Select(handoff => handoff.Id) + .SingleAsync(); + + var claim = await store.TryClaimAsync( + handoffId, + "worker", + DateTimeOffset.UtcNow, + DateTimeOffset.UtcNow.AddMinutes(5)); + + Assert.Null(claim); + await using var verification = await GetFactory().CreateDbContextAsync(); + var handoff = await verification.MoveScanHandoffs + .AsNoTracking() + .SingleAsync(candidate => candidate.Id == handoffId); + Assert.Equal(MoveScanHandoffStatus.Failed, handoff.Status); + Assert.Contains( + "no durable target manifest", + handoff.LastError, + StringComparison.OrdinalIgnoreCase); + } + private IDbContextFactory GetFactory() => _provider.GetRequiredService>(); @@ -606,8 +704,10 @@ private async Task InsertRunningMoveAsync(int audiobookId, string targe db.MoveJobEntries.Add(new MoveJobEntry { MoveJobId = job.Id, - RelativePath = string.Empty, - EntryType = MoveJobEntryType.Directory + RelativePath = "book.m4b", + EntryType = MoveJobEntryType.File, + Length = 1, + Sha256 = new string('A', 64) }); await db.SaveChangesAsync(); return job; @@ -637,8 +737,10 @@ private async Task InsertPendingHandoffAsync() db.MoveJobEntries.Add(new MoveJobEntry { MoveJobId = move.Id, - RelativePath = string.Empty, - EntryType = MoveJobEntryType.Directory + RelativePath = "book.m4b", + EntryType = MoveJobEntryType.File, + Length = 1, + Sha256 = new string('A', 64) }); db.MoveScanHandoffs.Add(handoff); await db.SaveChangesAsync(); diff --git a/tests/Features/Infrastructure/Persistence/EfRootFolderRepositoryDefaultTransactionTests.cs b/tests/Features/Infrastructure/Persistence/EfRootFolderRepositoryDefaultTransactionTests.cs index 5a07750be..75d7dd334 100644 --- a/tests/Features/Infrastructure/Persistence/EfRootFolderRepositoryDefaultTransactionTests.cs +++ b/tests/Features/Infrastructure/Persistence/EfRootFolderRepositoryDefaultTransactionTests.cs @@ -3,6 +3,7 @@ using Listenarr.Tests.Common; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; namespace Listenarr.Tests.Features.Infrastructure.Persistence; @@ -65,6 +66,44 @@ await Assert.ThrowsAnyAsync(() => repository.UpdateAndSetDefaultAsync Assert.Equal(Path.GetFullPath("candidate"), roots.Single(root => root.Id == 2).Path); } + [Fact] + public async Task AddAndSetDefaultAsync_CancelledDuringCommit_CommitsUnambiguously() + { + await using var connection = await OpenDatabaseAsync(); + var interceptor = new CancelDuringCommitInterceptor(); + var options = CreateOptions(connection, interceptor); + await SeedAsync( + options, + new RootFolder + { + Id = 1, + Name = "Current", + Path = Path.GetFullPath("commit-cancel-current"), + IsDefault = true + }); + using var cancellation = new CancellationTokenSource(); + interceptor.Arm(cancellation); + var repository = CreateRepository(options); + var replacementPath = Path.GetFullPath("commit-cancel-replacement"); + + await repository.AddAndSetDefaultAsync( + new RootFolder + { + Name = "Replacement", + Path = replacementPath, + IsDefault = true + }, + expectedCurrentDefaultId: 1, + cancellation.Token); + + Assert.True(cancellation.IsCancellationRequested); + await using var verification = new ListenArrDbContext(options); + var roots = await verification.RootFolders.AsNoTracking().ToListAsync(); + Assert.Equal(2, roots.Count); + Assert.False(roots.Single(root => root.Id == 1).IsDefault); + Assert.True(roots.Single(root => root.Path == replacementPath).IsDefault); + } + [Fact] public async Task AddAndSetDefaultAsync_StaleExpectedDefault_IsRejectedWithoutMutation() { @@ -199,10 +238,19 @@ private static async Task OpenDatabaseAsync() return connection; } - private static DbContextOptions CreateOptions(SqliteConnection connection) => - new DbContextOptionsBuilder() - .UseSqlite(connection) - .Options; + private static DbContextOptions CreateOptions( + SqliteConnection connection, + IInterceptor? interceptor = null) + { + var builder = new DbContextOptionsBuilder() + .UseSqlite(connection); + if (interceptor != null) + { + builder.AddInterceptors(interceptor); + } + + return builder.Options; + } private static async Task SeedAsync( DbContextOptions options, @@ -219,6 +267,24 @@ private static EfRootFolderRepository CreateRepository( new TestDbContextFactory(options), Mock.Of>()); + private sealed class CancelDuringCommitInterceptor : IDbTransactionInterceptor + { + private CancellationTokenSource? _cancellation; + + public void Arm(CancellationTokenSource cancellation) => + _cancellation = cancellation; + + public ValueTask TransactionCommittingAsync( + System.Data.Common.DbTransaction transaction, + TransactionEventData eventData, + InterceptionResult result, + CancellationToken cancellationToken = default) + { + Interlocked.Exchange(ref _cancellation, null)?.Cancel(); + return ValueTask.FromResult(result); + } + } + private sealed class TestDbContextFactory(DbContextOptions options) : IDbContextFactory { From 4cc0da22e386179d7445b53a866bd0b90d755726 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Wed, 5 Aug 2026 00:14:44 -0400 Subject: [PATCH 403/464] Fix native recovery and migration provenance --- ...udiobookContentMoveService.Finalization.cs | 4 +- ...obookContentMoveService.RecoveryMarkers.cs | 50 +++ ...aryDirectoryOwnershipMigrationPreflight.cs | 2 +- ..._AddOwnershipRecoveryProtocols.Designer.cs | 8 - ...entityAndMoveCleanupProtection.Designer.cs | 8 - ...ectoryOwnershipRootForeignKey.Designer.cs} | 320 +++++++++++++++++- ...ibraryDirectoryOwnershipRootForeignKey.cs} | 0 ...braryController_ScanPathValidationTests.cs | 6 +- .../MigrationProvenanceArchitectureTests.cs | 11 + ...okContentMoveServiceRecoverySafetyTests.cs | 91 +++++ .../EfLibraryDirectoryOwnershipStoreTests.cs | 6 +- .../RootFolderRelocationServiceTests.cs | 18 +- .../Migrations/MigrationMetadataTests.cs | 22 +- .../Persistence/SqliteMigrationSchemaTests.cs | 76 ++++- 14 files changed, 564 insertions(+), 58 deletions(-) rename listenarr.infrastructure/Persistence/Migrations/{20260726500000_AddLibraryDirectoryOwnershipRootForeignKey.Designer.cs => 20260805034058_AddLibraryDirectoryOwnershipRootForeignKey.Designer.cs} (86%) rename listenarr.infrastructure/Persistence/Migrations/{20260726500000_AddLibraryDirectoryOwnershipRootForeignKey.cs => 20260805034058_AddLibraryDirectoryOwnershipRootForeignKey.cs} (100%) diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.Finalization.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.Finalization.cs index 9182bfec5..7b979dd07 100644 --- a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.Finalization.cs +++ b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.Finalization.cs @@ -133,7 +133,7 @@ await VerifyPublishedManifestAsync( result.Target, manifest); - if (!File.Exists(result.RecoveryMarkerPath)) + if (!RecoveryMarkerEntryExists(result.RecoveryMarkerPath)) { await UpdateJobPhaseAsync( request.JobId, @@ -171,7 +171,7 @@ await UpdateJobPhaseAsync( result.RecoveryMarkerPath, result.Target, request.TargetSemantics); - if ((File.GetAttributes(result.RecoveryMarkerPath) & FileAttributes.ReparsePoint) != 0) + if (RecoveryMarkerPathIsLinked(result.RecoveryMarkerPath)) { throw new MoveNeedsAttentionException( "The completed recovery marker became a symbolic link or reparse point."); diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.RecoveryMarkers.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.RecoveryMarkers.cs index e05a4a434..a59bee61c 100644 --- a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.RecoveryMarkers.cs +++ b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.RecoveryMarkers.cs @@ -192,6 +192,20 @@ private static void ValidateRecoveryMarkerWritePath( { throw; } + catch (Exception exception) when ( + (exception is InvalidOperationException + or IOException + or UnauthorizedAccessException + or System.ComponentModel.Win32Exception) + && RecoveryMarkerPathIsLinked(markerPath)) + { + logger.LogWarning( + exception, + "Move recovery marker {Marker} is linked and cannot be trusted", + LogRedaction.SanitizeFilePath(markerPath)); + throw new MoveNeedsAttentionException( + "The move recovery marker is a symbolic link or reparse point and was preserved for review."); + } catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or System.ComponentModel.Win32Exception) { @@ -205,6 +219,42 @@ private static void ValidateRecoveryMarkerWritePath( } } + private static bool RecoveryMarkerEntryExists(string markerPath) + { + try + { + var marker = new FileInfo(markerPath); + return marker.Exists || !string.IsNullOrWhiteSpace(marker.LinkTarget); + } + catch (Exception exception) when (exception is + FileNotFoundException or DirectoryNotFoundException) + { + return false; + } + catch (Exception exception) when (exception is + IOException or UnauthorizedAccessException or NotSupportedException) + { + // Fail closed. The subsequent pinned read will classify the concrete + // unreadable state without treating an uncertain artifact as absent. + return true; + } + } + + private static bool RecoveryMarkerPathIsLinked(string markerPath) + { + try + { + var marker = new FileInfo(markerPath); + return !string.IsNullOrWhiteSpace(marker.LinkTarget) + || (marker.Attributes & FileAttributes.ReparsePoint) != 0; + } + catch (Exception exception) when (exception is + IOException or UnauthorizedAccessException or NotSupportedException) + { + return false; + } + } + private ParsedRecoveryMarker ReadRecoveryMarker( PinnedDirectoryCreation.PinnedFileEntry markerEntry, string markerPath) diff --git a/listenarr.infrastructure/Persistence/LibraryDirectoryOwnershipMigrationPreflight.cs b/listenarr.infrastructure/Persistence/LibraryDirectoryOwnershipMigrationPreflight.cs index 4a744fbe8..8116d9511 100644 --- a/listenarr.infrastructure/Persistence/LibraryDirectoryOwnershipMigrationPreflight.cs +++ b/listenarr.infrastructure/Persistence/LibraryDirectoryOwnershipMigrationPreflight.cs @@ -10,7 +10,7 @@ internal static class LibraryDirectoryOwnershipMigrationPreflight internal const string PredecessorMigrationId = "20260726042801_AddDirectoryObjectIdentityAuthorization"; internal const string ForeignKeyMigrationId = - "20260726500000_AddLibraryDirectoryOwnershipRootForeignKey"; + "20260805034058_AddLibraryDirectoryOwnershipRootForeignKey"; public static int RepairLegacyForeignKeyReferences(ListenArrDbContext context) { diff --git a/listenarr.infrastructure/Persistence/Migrations/20260727000644_AddOwnershipRecoveryProtocols.Designer.cs b/listenarr.infrastructure/Persistence/Migrations/20260727000644_AddOwnershipRecoveryProtocols.Designer.cs index 6068801f2..52919234c 100644 --- a/listenarr.infrastructure/Persistence/Migrations/20260727000644_AddOwnershipRecoveryProtocols.Designer.cs +++ b/listenarr.infrastructure/Persistence/Migrations/20260727000644_AddOwnershipRecoveryProtocols.Designer.cs @@ -2318,14 +2318,6 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) 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") diff --git a/listenarr.infrastructure/Persistence/Migrations/20260730033245_AddPhysicalFileIdentityAndMoveCleanupProtection.Designer.cs b/listenarr.infrastructure/Persistence/Migrations/20260730033245_AddPhysicalFileIdentityAndMoveCleanupProtection.Designer.cs index 1bc28b159..5747385ad 100644 --- a/listenarr.infrastructure/Persistence/Migrations/20260730033245_AddPhysicalFileIdentityAndMoveCleanupProtection.Designer.cs +++ b/listenarr.infrastructure/Persistence/Migrations/20260730033245_AddPhysicalFileIdentityAndMoveCleanupProtection.Designer.cs @@ -2335,14 +2335,6 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) 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") diff --git a/listenarr.infrastructure/Persistence/Migrations/20260726500000_AddLibraryDirectoryOwnershipRootForeignKey.Designer.cs b/listenarr.infrastructure/Persistence/Migrations/20260805034058_AddLibraryDirectoryOwnershipRootForeignKey.Designer.cs similarity index 86% rename from listenarr.infrastructure/Persistence/Migrations/20260726500000_AddLibraryDirectoryOwnershipRootForeignKey.Designer.cs rename to listenarr.infrastructure/Persistence/Migrations/20260805034058_AddLibraryDirectoryOwnershipRootForeignKey.Designer.cs index f0bf80d2b..6f9520bd9 100644 --- a/listenarr.infrastructure/Persistence/Migrations/20260726500000_AddLibraryDirectoryOwnershipRootForeignKey.Designer.cs +++ b/listenarr.infrastructure/Persistence/Migrations/20260805034058_AddLibraryDirectoryOwnershipRootForeignKey.Designer.cs @@ -1,4 +1,4 @@ -// +// using System; using Listenarr.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; @@ -11,7 +11,7 @@ namespace Listenarr.Infrastructure.Persistence.Migrations { [DbContext(typeof(ListenArrDbContext))] - [Migration("20260726500000_AddLibraryDirectoryOwnershipRootForeignKey")] + [Migration("20260805034058_AddLibraryDirectoryOwnershipRootForeignKey")] partial class AddLibraryDirectoryOwnershipRootForeignKey { /// @@ -459,6 +459,18 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .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"); @@ -684,6 +696,198 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) 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("State") + .IsRequired() + .HasMaxLength(24) + .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.LibraryDirectoryOwnershipRetiredMarker", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CanonicalMarkerPath") + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("CanonicalOwnershipPath") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("CanonicalPayload") + .HasMaxLength(16384) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DirectoryObjectIdentity") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("DirectoryObjectIdentityVersion") + .HasColumnType("INTEGER"); + + b.Property("OriginalManagedRootFolderId") + .HasColumnType("INTEGER"); + + b.Property("OwnershipId") + .HasColumnType("INTEGER"); + + b.Property("OwnershipToken") + .IsRequired() + .HasMaxLength(64) + .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("PathSyntax") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PayloadSha256") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("PayloadVersion") + .HasColumnType("INTEGER"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CanonicalMarkerPath") + .IsUnique(); + + b.HasIndex("OwnershipId") + .IsUnique(); + + b.ToTable("LibraryDirectoryOwnershipRetiredMarkers", (string)null); + }); + modelBuilder.Entity("Listenarr.Domain.Audiobooks.MonitoredAuthor", b => { b.Property("Id") @@ -950,6 +1154,11 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .ValueGeneratedOnAdd() .HasColumnType("INTEGER"); + b.Property("CleanupProtectionVersion") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + b.Property("CleanupState") .IsRequired() .HasMaxLength(16) @@ -1286,6 +1495,13 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("TargetDirectoryObjectIdentityVersion") .HasColumnType("INTEGER"); + b.Property("TargetIdentityEnrollmentState") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(24) + .HasColumnType("TEXT") + .HasDefaultValue("Authorized"); + b.Property("TargetPath") .IsRequired() .HasMaxLength(1000) @@ -1308,6 +1524,54 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) 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") @@ -2079,6 +2343,36 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .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.LibraryDirectoryOwnershipRetiredMarker", b => + { + b.HasOne("Listenarr.Domain.Audiobooks.LibraryDirectoryOwnership", "Ownership") + .WithOne("RetiredMarker") + .HasForeignKey("Listenarr.Domain.Audiobooks.LibraryDirectoryOwnershipRetiredMarker", "OwnershipId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Ownership"); + }); + modelBuilder.Entity("Listenarr.Domain.Audiobooks.MoveJob", b => { b.HasOne("Listenarr.Domain.Audiobooks.RootFolderRelocation", "Relocation") @@ -2132,6 +2426,17 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) 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") @@ -2152,6 +2457,13 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Navigation("SeriesMemberships"); }); + modelBuilder.Entity("Listenarr.Domain.Audiobooks.LibraryDirectoryOwnership", b => + { + b.Navigation("PathMigrations"); + + b.Navigation("RetiredMarker"); + }); + modelBuilder.Entity("Listenarr.Domain.Audiobooks.MoveJob", b => { b.Navigation("CreatedDirectories"); @@ -2168,8 +2480,12 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) 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/20260726500000_AddLibraryDirectoryOwnershipRootForeignKey.cs b/listenarr.infrastructure/Persistence/Migrations/20260805034058_AddLibraryDirectoryOwnershipRootForeignKey.cs similarity index 100% rename from listenarr.infrastructure/Persistence/Migrations/20260726500000_AddLibraryDirectoryOwnershipRootForeignKey.cs rename to listenarr.infrastructure/Persistence/Migrations/20260805034058_AddLibraryDirectoryOwnershipRootForeignKey.cs diff --git a/tests/Features/Api/Features/Library/LibraryController_ScanPathValidationTests.cs b/tests/Features/Api/Features/Library/LibraryController_ScanPathValidationTests.cs index 891e12913..11047e822 100644 --- a/tests/Features/Api/Features/Library/LibraryController_ScanPathValidationTests.cs +++ b/tests/Features/Api/Features/Library/LibraryController_ScanPathValidationTests.cs @@ -322,11 +322,7 @@ public async Task ScanAudiobook_SymlinkedDirectoryOutsideRoot_IsNotTraversed() Init(services => services.Without()); var controller = _provider.GetRequiredService(); - await _rootFolderRepository.AddAsync(new RootFolder - { - Name = "root", - Path = tempRoot - }); + await AddAuthorizedRootAsync(tempRoot); await _applicationSettingsRepository.SaveAsync(new ApplicationSettingsBuilder() .WithOutputPath(tempRoot) .Build()); diff --git a/tests/Features/Architecture/MigrationProvenanceArchitectureTests.cs b/tests/Features/Architecture/MigrationProvenanceArchitectureTests.cs index 25fb80815..6dc0b97b1 100644 --- a/tests/Features/Architecture/MigrationProvenanceArchitectureTests.cs +++ b/tests/Features/Architecture/MigrationProvenanceArchitectureTests.cs @@ -7,6 +7,7 @@ * by the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. */ +using System.Globalization; using Listenarr.Tests.Common; namespace Listenarr.Tests.Features.Architecture; @@ -45,6 +46,16 @@ public void PullRequestMigrations_KeepEfScaffoldingAsSourceOfTruth() { var source = File.ReadAllText(main); var relative = Normalize(Path.GetRelativePath(RepositoryRoot, main)); + var migrationTimestamp = Path.GetFileName(main)[..14]; + if (!DateTime.TryParseExact( + migrationTimestamp, + "yyyyMMddHHmmss", + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out _)) + { + violations.Add($"{relative}: migration ID prefix is not an EF-style UTC timestamp"); + } if (source.Contains("migrationBuilder.Sql(", StringComparison.Ordinal) || source.Contains("suppressTransaction", StringComparison.Ordinal) || source.Contains("[Migration(", StringComparison.Ordinal) diff --git a/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceRecoverySafetyTests.cs b/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceRecoverySafetyTests.cs index af6ac0f53..ffa530a10 100644 --- a/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceRecoverySafetyTests.cs +++ b/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceRecoverySafetyTests.cs @@ -292,6 +292,97 @@ await File.WriteAllTextAsync( } } + [FileLinkFact] + public async Task GetRecoverableMoveAsync_DanglingRecoveryMarkerLink_PreservesLinkAndRequiresAttention() + { + var source = FileService.GetTempDirectory("content-move-dangling-marker-src"); + await FileService.GetFileAsync(source, "book.m4b", "audio"); + var target = FileService.GetTempDirectory("content-move-dangling-marker-dst"); + var external = FileService.GetTempDirectory("content-move-dangling-marker-external"); + var jobId = Guid.NewGuid(); + var request = await CreateLeasedMoveRequestAsync(source, target, jobId); + var missingTarget = Path.Join(external, "missing-marker.json"); + var markerPath = Path.Join(target, $".listenarr-move-{jobId:N}.pending"); + try + { + File.CreateSymbolicLink(markerPath, missingTarget); + } + catch (Exception exception) when (exception is + IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + throw new Xunit.Sdk.XunitException( + $"This native filesystem regression requires symbolic-link support: {exception.Message}"); + } + + try + { + var service = _provider.GetRequiredService(); + var exception = await Assert.ThrowsAsync(() => + service.GetRecoverableMoveAsync(request)); + + Assert.Contains("symbolic link or reparse point", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.NotNull(new FileInfo(markerPath).LinkTarget); + Assert.False(File.Exists(missingTarget)); + Assert.True(File.Exists(Path.Join(source, "book.m4b"))); + } + finally + { + if (!string.IsNullOrWhiteSpace(new FileInfo(markerPath).LinkTarget)) + { + File.Delete(markerPath); + } + } + } + + [FileLinkFact] + public async Task CleanupCompletedMoveArtifactsAsync_DanglingRecoveryMarkerLink_PreservesLinkAndRequiresAttention() + { + var source = FileService.GetTempDirectory("content-move-dangling-cleanup-src"); + await FileService.GetFileAsync(source, "book.m4b", "audio"); + var target = Path.Join( + FileService.GetTempPath(), + $"content-move-dangling-cleanup-dst-{Guid.NewGuid():N}"); + var external = FileService.GetTempDirectory("content-move-dangling-cleanup-external"); + var missingTarget = Path.Join(external, "missing-marker.json"); + + var service = _provider.GetRequiredService(); + var request = await CreateLeasedMoveRequestAsync(source, target); + var result = await service.MoveContentsAsync(request, CancellationToken.None); + await service.FinalizeMoveAsync(request, result, CancellationToken.None); + File.Delete(result.RecoveryMarkerPath); + try + { + File.CreateSymbolicLink(result.RecoveryMarkerPath, missingTarget); + } + catch (Exception exception) when (exception is + IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + throw new Xunit.Sdk.XunitException( + $"This native filesystem regression requires symbolic-link support: {exception.Message}"); + } + + try + { + var exception = await Assert.ThrowsAsync(() => + service.CleanupCompletedMoveArtifactsAsync( + request, + result, + CancellationToken.None)); + + Assert.Contains("symbolic link or reparse point", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.NotNull(new FileInfo(result.RecoveryMarkerPath).LinkTarget); + Assert.False(File.Exists(missingTarget)); + Assert.True(File.Exists(Path.Join(target, "book.m4b"))); + } + finally + { + if (!string.IsNullOrWhiteSpace(new FileInfo(result.RecoveryMarkerPath).LinkTarget)) + { + File.Delete(result.RecoveryMarkerPath); + } + } + } + [DirectoryLinkFact] public async Task GetRecoverableMoveAsync_AtomicMarkerWithLinkedTarget_RequiresAttention() { diff --git a/tests/Features/Infrastructure/Library/Moving/EfLibraryDirectoryOwnershipStoreTests.cs b/tests/Features/Infrastructure/Library/Moving/EfLibraryDirectoryOwnershipStoreTests.cs index 74142359c..d43f03d66 100644 --- a/tests/Features/Infrastructure/Library/Moving/EfLibraryDirectoryOwnershipStoreTests.cs +++ b/tests/Features/Infrastructure/Library/Moving/EfLibraryDirectoryOwnershipStoreTests.cs @@ -426,7 +426,11 @@ public async Task ResolveOwnedAsync_DirectoryReplacedAfterPhysicalIdentityPin_Do StringComparison.OrdinalIgnoreCase); Assert.True(Directory.Exists(displacedDirectory)); Assert.Equal(insidePayload, await File.ReadAllTextAsync(insideMarker)); - Assert.Equal(ownership.Id, resolution.Ownership?.Id); + Assert.Null(resolution.Ownership); + await using var verification = await _factory.CreateDbContextAsync(); + var persisted = await verification.LibraryDirectoryOwnerships + .SingleAsync(candidate => candidate.Id == ownership.Id); + Assert.Equal(ownership.Id, persisted.Id); } [Fact] diff --git a/tests/Features/Infrastructure/Library/Moving/RootFolderRelocationServiceTests.cs b/tests/Features/Infrastructure/Library/Moving/RootFolderRelocationServiceTests.cs index 3b2b0aecd..6bbea8383 100644 --- a/tests/Features/Infrastructure/Library/Moving/RootFolderRelocationServiceTests.cs +++ b/tests/Features/Infrastructure/Library/Moving/RootFolderRelocationServiceTests.cs @@ -2881,6 +2881,7 @@ public async Task MetadataOnly_LinkedSourceAndPhysicalTarget_PreservesActiveOwne .ResolveAsync(linkedOwnedPath); Assert.True(rootIdentity.IsAvailable); Assert.True(ownedIdentity.IsAvailable); + var ownershipToken = Guid.NewGuid().ToString("N"); int rootId; LibraryDirectoryOwnership ownership; @@ -2930,13 +2931,15 @@ public async Task MetadataOnly_LinkedSourceAndPhysicalTarget_PreservesActiveOwne "library-directory", linkedOwnedPath, sourceResolution.Semantics), - OwnershipToken = Guid.NewGuid().ToString("N"), + OwnershipToken = ownershipToken, State = LibraryDirectoryOwnershipState.Owned, CreationWorkflow = "Test", AudiobookId = audiobook.Id, ManagedRootFolderId = rootFolder.Id, - DirectoryObjectIdentityVersion = ownedIdentity.Version, - DirectoryObjectIdentity = ownedIdentity.Value + DirectoryObjectIdentityVersion = ManagedDirectoryIdentity.CurrentVersion, + DirectoryObjectIdentity = ManagedDirectoryIdentity.Create( + ownershipToken, + ownedIdentity.Value!) }; db.LibraryDirectoryOwnerships.Add(ownership); await db.SaveChangesAsync(); @@ -3018,6 +3021,7 @@ public async Task MetadataOnly_PhysicalSourceAndLinkedTarget_PreservesActiveOwne .ResolveAsync(physicalOwnedPath); Assert.True(rootIdentity.IsAvailable); Assert.True(ownedIdentity.IsAvailable); + var ownershipToken = Guid.NewGuid().ToString("N"); int rootId; LibraryDirectoryOwnership ownership; @@ -3067,13 +3071,15 @@ public async Task MetadataOnly_PhysicalSourceAndLinkedTarget_PreservesActiveOwne "library-directory", physicalOwnedPath, sourceResolution.Semantics), - OwnershipToken = Guid.NewGuid().ToString("N"), + OwnershipToken = ownershipToken, State = LibraryDirectoryOwnershipState.Owned, CreationWorkflow = "Test", AudiobookId = audiobook.Id, ManagedRootFolderId = rootFolder.Id, - DirectoryObjectIdentityVersion = ownedIdentity.Version, - DirectoryObjectIdentity = ownedIdentity.Value + DirectoryObjectIdentityVersion = ManagedDirectoryIdentity.CurrentVersion, + DirectoryObjectIdentity = ManagedDirectoryIdentity.Create( + ownershipToken, + ownedIdentity.Value!) }; db.LibraryDirectoryOwnerships.Add(ownership); await db.SaveChangesAsync(); diff --git a/tests/Features/Infrastructure/Migrations/MigrationMetadataTests.cs b/tests/Features/Infrastructure/Migrations/MigrationMetadataTests.cs index b33150c4f..1a17d5e86 100644 --- a/tests/Features/Infrastructure/Migrations/MigrationMetadataTests.cs +++ b/tests/Features/Infrastructure/Migrations/MigrationMetadataTests.cs @@ -45,26 +45,20 @@ public void AddRootFolderRelocationSkippedItemsMigration_IsDiscoverableByEf() } [Fact] - public void AddLibraryDirectoryOwnershipRootForeignKeyMigration_IsDiscoverableByEf() + public void AddLibraryDirectoryOwnershipRootForeignKey_IsDiscoverableAndIsolated() { var attribute = typeof(AddLibraryDirectoryOwnershipRootForeignKey) .GetCustomAttribute(); - Assert.NotNull(attribute); Assert.Equal( - "20260726500000_AddLibraryDirectoryOwnershipRootForeignKey", + "20260805034058_AddLibraryDirectoryOwnershipRootForeignKey", attribute!.Id); - } - [Fact] - public void AddLibraryDirectoryOwnershipRootForeignKey_ContainsOnlyForeignKeyOperation() - { var migration = new AddLibraryDirectoryOwnershipRootForeignKey(); var upBuilder = new MigrationBuilder( "Microsoft.EntityFrameworkCore.Sqlite"); var downBuilder = new MigrationBuilder( "Microsoft.EntityFrameworkCore.Sqlite"); - typeof(AddLibraryDirectoryOwnershipRootForeignKey) .GetMethod( "Up", @@ -76,10 +70,14 @@ public void AddLibraryDirectoryOwnershipRootForeignKey_ContainsOnlyForeignKeyOpe BindingFlags.Instance | BindingFlags.NonPublic)! .Invoke(migration, [downBuilder]); - Assert.Single(upBuilder.Operations); - Assert.IsType(upBuilder.Operations[0]); - Assert.Single(downBuilder.Operations); - Assert.IsType(downBuilder.Operations[0]); + var addForeignKey = Assert.Single(upBuilder.Operations); + Assert.Equal( + "FK_LibraryDirectoryOwnerships_RootFolders_ManagedRootFolderId", + Assert.IsType(addForeignKey).Name); + var dropForeignKey = Assert.Single(downBuilder.Operations); + Assert.Equal( + "FK_LibraryDirectoryOwnerships_RootFolders_ManagedRootFolderId", + Assert.IsType(dropForeignKey).Name); } [Fact] diff --git a/tests/Features/Infrastructure/Persistence/SqliteMigrationSchemaTests.cs b/tests/Features/Infrastructure/Persistence/SqliteMigrationSchemaTests.cs index d81a5c461..423085af5 100644 --- a/tests/Features/Infrastructure/Persistence/SqliteMigrationSchemaTests.cs +++ b/tests/Features/Infrastructure/Persistence/SqliteMigrationSchemaTests.cs @@ -64,7 +64,6 @@ public class SqliteMigrationSchemaTests : BaseTests "20260713181804_HardenMoveExecutionAndScanHandoffs", "20260717143713_AddLibraryDirectoryOwnership", "20260726042801_AddDirectoryObjectIdentityAuthorization", - "20260726500000_AddLibraryDirectoryOwnershipRootForeignKey", "20260727000644_AddOwnershipRecoveryProtocols", PhysicalIdentityMigrationId }; @@ -533,7 +532,7 @@ await context.Database.ExecuteSqlRawAsync( """); await migrator.MigrateAsync( - "20260726500000_AddLibraryDirectoryOwnershipRootForeignKey"); + "20260805034058_AddLibraryDirectoryOwnershipRootForeignKey"); await using (var foreignKeyCommand = connection.CreateCommand()) { @@ -642,7 +641,7 @@ SELECT group_concat( [Fact] [Trait("Scenario", "IntermediatePrDatabaseCompatibility")] - public async Task IntermediatePrDatabase_MissingIsolatedForeignKeyHistory_ReappliesCleanly() + public async Task IntermediatePrDatabase_RetiredForeignKeyHistory_IsTolerated() { await using var connection = new SqliteConnection("DataSource=:memory:"); await connection.OpenAsync(); @@ -659,7 +658,13 @@ await baseline.Database.ExecuteSqlRawAsync( """ DELETE FROM "__EFMigrationsHistory" WHERE "MigrationId" = - '20260726500000_AddLibraryDirectoryOwnershipRootForeignKey'; + '20260805034058_AddLibraryDirectoryOwnershipRootForeignKey'; + + INSERT OR IGNORE INTO "__EFMigrationsHistory" ( + "MigrationId", "ProductVersion") + VALUES ( + '20260726500000_AddLibraryDirectoryOwnershipRootForeignKey', + '10.0.8'); """); } @@ -680,10 +685,12 @@ DELETE FROM "__EFMigrationsHistory" """ SELECT COUNT(*) FROM "__EFMigrationsHistory" - WHERE "MigrationId" = - '20260726500000_AddLibraryDirectoryOwnershipRootForeignKey' + WHERE "MigrationId" IN ( + '20260726500000_AddLibraryDirectoryOwnershipRootForeignKey', + '20260727000644_AddOwnershipRecoveryProtocols', + '20260805034058_AddLibraryDirectoryOwnershipRootForeignKey') """; - Assert.Equal(1L, (long)(await historyCommand.ExecuteScalarAsync())!); + Assert.Equal(3L, (long)(await historyCommand.ExecuteScalarAsync())!); await using var integrityCommand = connection.CreateCommand(); integrityCommand.CommandText = "PRAGMA integrity_check;"; @@ -712,7 +719,7 @@ public async Task OwnershipRecoveryMigration_InterruptedSchemaTransaction_Retrie await using var context = new ListenArrDbContext(options); var migrator = context.GetService(); await migrator.MigrateAsync( - "20260726500000_AddLibraryDirectoryOwnershipRootForeignKey"); + "20260726042801_AddDirectoryObjectIdentityAuthorization"); await Assert.ThrowsAsync(() => migrator.MigrateAsync( @@ -740,7 +747,7 @@ await migrator.MigrateAsync( [Fact] [Trait("Scenario", "OwnershipRecoveryProtocolDowngrade")] - public async Task OwnershipRecoveryMigration_DowngradeKeepsIsolatedOwnershipForeignKey() + public async Task OwnershipRecoveryMigration_DowngradeRevertsRecoverySchema() { await using var connection = new SqliteConnection("DataSource=:memory:"); @@ -756,7 +763,7 @@ await migrator.MigrateAsync( "20260727000644_AddOwnershipRecoveryProtocols"); await migrator.MigrateAsync( - "20260726500000_AddLibraryDirectoryOwnershipRootForeignKey"); + "20260726042801_AddDirectoryObjectIdentityAuthorization"); Assert.False(await ColumnExistsAsync( connection, @@ -773,9 +780,7 @@ FROM pragma_foreign_key_list('LibraryDirectoryOwnerships') WHERE "table" = 'RootFolders' AND "from" = 'ManagedRootFolderId' """; - Assert.Equal( - "SET NULL", - (await foreignKeyCommand.ExecuteScalarAsync())?.ToString()); + Assert.Null(await foreignKeyCommand.ExecuteScalarAsync()); await migrator.MigrateAsync( "20260727000644_AddOwnershipRecoveryProtocols"); @@ -784,6 +789,37 @@ await migrator.MigrateAsync( "LibraryDirectoryOwnershipRetiredMarkers")); } + [Fact] + [Trait("Scenario", "OwnershipRootForeignKeyRetry")] + public async Task OwnershipRootForeignKeyMigration_DowngradeAndReapply_IsolatedCleanly() + { + await using var connection = + new SqliteConnection("DataSource=:memory:"); + await connection.OpenAsync(); + var options = new DbContextOptionsBuilder() + .UseSqlite(connection, sqlite => + sqlite.MigrationsAssembly( + typeof(ListenArrDbContext).Assembly.GetName().Name)) + .Options; + await using var context = new ListenArrDbContext(options); + var migrator = context.GetService(); + + await migrator.MigrateAsync( + "20260805034058_AddLibraryDirectoryOwnershipRootForeignKey"); + Assert.Equal( + "SET NULL", + await GetOwnershipRootForeignKeyDeleteBehaviorAsync(connection)); + + await migrator.MigrateAsync(PhysicalIdentityMigrationId); + Assert.Null(await GetOwnershipRootForeignKeyDeleteBehaviorAsync(connection)); + + await migrator.MigrateAsync( + "20260805034058_AddLibraryDirectoryOwnershipRootForeignKey"); + Assert.Equal( + "SET NULL", + await GetOwnershipRootForeignKeyDeleteBehaviorAsync(connection)); + } + [Fact] [Trait("Scenario", "ConcurrentDefaultRootPromotions")] public async Task ConcurrentDefaultRootPromotions_CannotCommitTwoDefaults() @@ -1238,6 +1274,20 @@ public void MoveJobs_SourcePathColumn_ExistsAfterMigrate() Assert.Contains("SourcePath", columns); } + private static async Task GetOwnershipRootForeignKeyDeleteBehaviorAsync( + SqliteConnection connection) + { + await using var command = connection.CreateCommand(); + command.CommandText = + """ + SELECT "on_delete" + FROM pragma_foreign_key_list('LibraryDirectoryOwnerships') + WHERE "table" = 'RootFolders' + AND "from" = 'ManagedRootFolderId' + """; + return (await command.ExecuteScalarAsync())?.ToString(); + } + private static async Task ExecuteScalarAsync( SqliteConnection connection, string commandText) From 48bfca41e085b8cbb62566f5ca51a84ecd9e5fce Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Wed, 5 Aug 2026 10:16:00 -0400 Subject: [PATCH 404/464] Repair root storage identity authorization --- fe/src/__tests__/RootFoldersSettings.spec.ts | 24 +- ...ootFolderRelocationReauthorization.spec.ts | 33 +- .../rootFolders.reauthorization.store.spec.ts | 18 + fe/src/__tests__/test-setup.ts | 1 + .../settings/RootFoldersSettings.vue | 69 ++++ fe/src/services/api.ts | 10 + fe/src/stores/rootFolders.ts | 7 + .../Library/LibraryMoveWorkflow.Physical.cs | 8 +- .../Features/Library/RootFoldersController.cs | 46 +++ .../IDirectoryObjectIdentityResolver.cs | 9 +- .../Contracts/IRootFolderService.cs | 4 + .../AudiobookDestinationRewriteService.cs | 6 +- .../RootFolderService.DirectoryIdentity.cs | 210 +++++++++++ .../RootFolders/RootFolderService.cs | 64 +--- .../DirectoryObjectIdentityResolver.cs | 52 +++ .../FileSystem/ManagedDirectoryEnrollment.cs | 8 +- ...bookContentMoveService.SourceValidation.cs | 29 +- .../Moving/AudiobookContentMoveService.cs | 3 +- .../Moving/MoveFilesystemArtifactNames.cs | 1 + .../Library/LibraryController_MoveTests.cs | 77 ++++ .../Library/RootFoldersControllerTests.cs | 97 +++++ .../RootFolders/RootFolderServiceTests.cs | 343 +++++++++++++++++- .../DirectoryObjectIdentityResolverTests.cs | 47 ++- .../AudiobookContentMoveServiceTests.cs | 36 ++ 24 files changed, 1135 insertions(+), 67 deletions(-) create mode 100644 listenarr.application/Audiobooks/RootFolders/RootFolderService.DirectoryIdentity.cs diff --git a/fe/src/__tests__/RootFoldersSettings.spec.ts b/fe/src/__tests__/RootFoldersSettings.spec.ts index f626e3804..c01495ffc 100644 --- a/fe/src/__tests__/RootFoldersSettings.spec.ts +++ b/fe/src/__tests__/RootFoldersSettings.spec.ts @@ -41,7 +41,7 @@ function relocation( } } -function rootFolder(activeRelocation: RootFolderPathChangeResult): RootFolder { +function rootFolder(activeRelocation: RootFolderPathChangeResult | null): RootFolder { return { id: 3, name: 'Audiobooks', @@ -89,6 +89,28 @@ describe('RootFoldersSettings', () => { await wrapper.vm.$nextTick() }) + it('reauthorizes the exact configured root path only after confirmation', async () => { + const folder = rootFolder(null) + vi.mocked(apiService.getRootFolders).mockResolvedValue([folder]) + vi.mocked(apiService.reauthorizeRootFolderIdentity).mockResolvedValue(folder) + const pinia = createPinia() + setActivePinia(pinia) + const wrapper = mount(RootFoldersSettings, { global: { plugins: [pinia] } }) + await flushPromises() + + const action = wrapper.get('[data-cy="reauthorize-root-identity"]') + await action.trigger('click') + + const displayedPath = wrapper.get('[data-testid="root-reauthorization-path"]') + expect(displayedPath.element.textContent).toBe(folder.path) + const confirm = wrapper.get('.modal-delete-button') + expect(confirm.text()).toContain('Reauthorize root') + await confirm.trigger('click') + await flushPromises() + + expect(apiService.reauthorizeRootFolderIdentity).toHaveBeenCalledWith(folder.id, folder.path) + }) + it('shows legacy reauthorization separately and confirms the exact target path', async () => { const legacy = relocation('LegacyUnenrolled') vi.mocked(apiService.getRootFolders).mockResolvedValue([rootFolder(legacy)]) diff --git a/fe/src/__tests__/api.rootFolderRelocationReauthorization.spec.ts b/fe/src/__tests__/api.rootFolderRelocationReauthorization.spec.ts index 573e0a507..fa6230a94 100644 --- a/fe/src/__tests__/api.rootFolderRelocationReauthorization.spec.ts +++ b/fe/src/__tests__/api.rootFolderRelocationReauthorization.spec.ts @@ -9,12 +9,43 @@ */ import { afterEach, describe, expect, it, vi } from 'vitest' -describe('ApiService legacy relocation target reauthorization', () => { +describe('ApiService root-folder reauthorization', () => { afterEach(() => { vi.restoreAllMocks() vi.unstubAllGlobals() }) + it('posts the exact confirmed root path to the physical identity endpoint', async () => { + vi.resetModules() + const rootPath = '/srv/Library ' + const fetchMock = vi.fn(() => + Promise.resolve( + new Response( + JSON.stringify({ + id: 3, + name: 'Library', + path: rootPath, + isDefault: true, + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }, + ), + ), + ) + vi.stubGlobal('fetch', fetchMock) + + const actual = await vi.importActual('@/services/api') + await actual.apiService.reauthorizeRootFolderIdentity(3, rootPath) + + expect(fetchMock).toHaveBeenCalledTimes(1) + const [requestInfo, options] = fetchMock.mock.calls[0] as [RequestInfo, RequestInit] + expect(String(requestInfo)).toContain('/rootfolders/3/reauthorize-identity') + expect(options.method).toBe('POST') + expect(JSON.parse(String(options.body))).toEqual({ expectedCurrentPath: rootPath }) + }) + it('posts the exact confirmed target path to the dedicated endpoint', async () => { vi.resetModules() const targetPath = '/srv/Audiobooks ' diff --git a/fe/src/__tests__/rootFolders.reauthorization.store.spec.ts b/fe/src/__tests__/rootFolders.reauthorization.store.spec.ts index c66b0fff5..11bfa54c4 100644 --- a/fe/src/__tests__/rootFolders.reauthorization.store.spec.ts +++ b/fe/src/__tests__/rootFolders.reauthorization.store.spec.ts @@ -171,6 +171,24 @@ describe('root folder relocation store actions', () => { expect(apiService.getRootFolders).toHaveBeenCalledTimes(1) }) + it('passes the exact confirmed root path and reloads after identity reauthorization', async () => { + const current = { + id: 3, + name: 'Library', + path: '/srv/Library ', + isDefault: false, + caseSensitivityMode: 'Auto' as const, + } + vi.mocked(apiService.reauthorizeRootFolderIdentity).mockResolvedValueOnce(current) + vi.mocked(apiService.getRootFolders).mockResolvedValueOnce([current]) + const store = useRootFoldersStore() + + await expect(store.reauthorizeIdentity(current.id, current.path)).resolves.toEqual(current) + + expect(apiService.reauthorizeRootFolderIdentity).toHaveBeenCalledWith(current.id, current.path) + expect(apiService.getRootFolders).toHaveBeenCalledTimes(1) + }) + it('passes the exact confirmed target path and reloads root folders', async () => { const targetPath = '/srv/Audiobooks ' const result: RootFolderPathChangeResult = { diff --git a/fe/src/__tests__/test-setup.ts b/fe/src/__tests__/test-setup.ts index b5a5738f1..fae2f06e6 100644 --- a/fe/src/__tests__/test-setup.ts +++ b/fe/src/__tests__/test-setup.ts @@ -168,6 +168,7 @@ vi.mock('@/services/api', () => { getRootFolders: vi.fn(async () => []), updateRootFolder: vi.fn(async (_id: number, payload: unknown) => payload), changeRootFolderPath: vi.fn(async () => ({})), + reauthorizeRootFolderIdentity: vi.fn(async () => ({})), retryRootFolderRelocation: vi.fn(async () => ({})), reauthorizeLegacyRootFolderRelocationTarget: vi.fn(async () => ({})), diff --git a/fe/src/components/settings/RootFoldersSettings.vue b/fe/src/components/settings/RootFoldersSettings.vue index e9f108de7..31b9723a5 100644 --- a/fe/src/components/settings/RootFoldersSettings.vue +++ b/fe/src/components/settings/RootFoldersSettings.vue @@ -82,6 +82,15 @@ > +
    -
  • +
  • {{ item.title }}
    {{ item.message }}
    -
    {{ formatTime(item.timestamp) }}
    + +
    + {{ formatTime(item.timestamp) }} +
  • -
  • +
  • No recent activity
@@ -558,11 +563,12 @@ import { useConfirmService } from '@/composables/confirmService' import { useNotification } from '@/composables/useNotification' import { useDownloadsStore } from '@/stores/downloads' import { useLibraryStore } from '@/stores/library' +import { useMoveJobsStore } from '@/stores/moveJobs' import { useAuthStore } from '@/stores/auth' import { apiService } from '@/services/api' import { getStartupConfigCached } from '@/services/startupConfigCache' import { handleImageError } from '@/utils/imageFallback' -import { Pill } from '@/components/base' +import { Pill, ProgressBar } from '@/components/base' import { getPlaceholderUrl } from '@/utils/placeholder' import { useProtectedImages } from '@/composables/useProtectedImages' import { logSessionState, clearAllAuthData } from '@/utils/sessionDebug' @@ -586,6 +592,7 @@ const { notification, close: closeNotification } = useNotification() const { getProtectedImageSrc } = useProtectedImages() const downloadsStore = useDownloadsStore() const libraryStore = useLibraryStore() +const moveJobsStore = useMoveJobsStore() const auth = useAuthStore() const authEnabled = ref(false) const startupConfigLoaded = ref(false) @@ -793,7 +800,6 @@ const closeMobileMenu = () => { } // Reactive state for badges and counters -const notificationCount = computed(() => recentNotifications.filter((n) => !n.dismissed).length) const queueItems = ref([]) const wantedCount = computed( () => libraryStore.audiobooks.filter((book) => book.wanted === true).length, @@ -872,13 +878,41 @@ type HistoryNotification = { title: string message: string icon?: string - timestamp: string + timestamp?: string dismissed?: boolean + progress?: number + phase?: string + active?: boolean } const recentNotifications = reactive([]) const recentDownloadTitles = ref>(new Set()) // Track recent download titles to avoid spam +const activeMoveNotifications = computed(() => + moveJobsStore.trackedJobs.map((job) => { + const audiobookTitle = job.audiobookId + ? libraryStore.audiobooks.find((book) => book.id === job.audiobookId)?.title + : undefined + const target = job.target ? ` to ${job.target}` : '' + return { + id: `move-${job.jobId}`, + title: audiobookTitle ? `Moving ${audiobookTitle}` : 'Moving audiobook', + message: `${job.phase || 'Preparing move'}${target}`, + icon: 'ph ph-folder-open', + progress: job.progress, + phase: job.phase, + active: true, + } + }), +) + +const visibleNotifications = computed(() => [ + ...activeMoveNotifications.value, + ...recentNotifications.filter((notification) => !notification.dismissed), +]) + +const notificationCount = computed(() => visibleNotifications.value.length) + function pushNotification(n: HistoryNotification) { // Ensure new notifications are not dismissed const notification = { ...n, dismissed: false } @@ -1156,12 +1190,17 @@ onMounted(async () => { // If authenticated, load protected resources and enable real-time updates if (auth.user.authenticated) { + // Keep durable move jobs globally visible so the notification dropdown can + // show progress even when the Activity page is not mounted. + moveJobsStore.start() + // Hydrate the app once, then keep it current from SignalR updates. await Promise.all([downloadsStore.loadDownloads(), syncLibrarySnapshot()]) unsubscribeSignalRConnected = signalRService.onConnected(() => { if (auth.user.authenticated) { void syncLibrarySnapshot() + void moveJobsStore.loadActiveJobs() } }) @@ -1343,6 +1382,7 @@ onUnmounted(() => { if (unsubscribeSignalRConnected) { unsubscribeSignalRConnected() } + moveJobsStore.stop() // Event listeners are automatically cleaned up by VueUse }) diff --git a/fe/src/__tests__/ActivityView.mobile.spec.ts b/fe/src/__tests__/ActivityView.mobile.spec.ts index eccced07c..1978d54be 100644 --- a/fe/src/__tests__/ActivityView.mobile.spec.ts +++ b/fe/src/__tests__/ActivityView.mobile.spec.ts @@ -89,6 +89,13 @@ describe('ActivityView mobile virtualization', () => { }), })) + vi.doMock('@/stores/moveJobs', () => ({ + useMoveJobsStore: () => ({ + trackedJobs: [], + start: vi.fn(), + }), + })) + vi.doMock('@/services/errorTracking', () => ({ errorTracking: { captureException: vi.fn(), diff --git a/fe/src/__tests__/ActivityView.spec.ts b/fe/src/__tests__/ActivityView.spec.ts index 8e76fe443..c0d951361 100644 --- a/fe/src/__tests__/ActivityView.spec.ts +++ b/fe/src/__tests__/ActivityView.spec.ts @@ -80,6 +80,20 @@ const mockLibraryStore = (audiobooks: Array<{ id: number; title: string }> = []) })) } +const mockMoveJobsStore = (overrides: Record = {}) => { + const store = { + trackedJobs: [], + start: vi.fn(), + ...overrides, + } + + vi.doMock('@/stores/moveJobs', () => ({ + useMoveJobsStore: () => store, + })) + + return store +} + const mockDownloadsStore = (overrides: Record = {}) => { const store = { activeDownloads: [], @@ -116,6 +130,7 @@ describe('ActivityView', () => { beforeEach(() => { vi.resetModules() vi.clearAllMocks() + mockMoveJobsStore() vi.spyOn(globalThis, 'setInterval').mockReturnValue( 1 as unknown as ReturnType, ) @@ -126,6 +141,34 @@ describe('ActivityView', () => { vi.restoreAllMocks() }) + it('shows active library move progress in the unified activity list', async () => { + mockSignalR() + mockApi() + mockConfigurationStore(false) + mockLibraryStore([{ id: 42, title: 'Book' }]) + mockDownloadsStore() + mockMoveJobsStore({ + trackedJobs: [ + { + jobId: 'job-1', + audiobookId: 42, + status: 'Running', + progress: 37.5, + phase: 'Copying', + target: '/library/book', + }, + ], + }) + + const wrapper = await mountActivityView() + const vm = wrapper.vm as unknown as ActivityViewVm + const move = vm.allActivityItems.find((item) => item.id === 'move:job-1') + + expect(move).toMatchObject({ status: 'moving', progress: 37.5 }) + expect(wrapper.text()).toContain('38%') + expect(wrapper.text()).toContain('Moving') + }) + it('includes completed external downloads from the downloads store in the unified list', async () => { mockSignalR() mockApi() diff --git a/fe/src/__tests__/AppActivityBadge.spec.ts b/fe/src/__tests__/AppActivityBadge.spec.ts index 167f0591f..25a5630c1 100644 --- a/fe/src/__tests__/AppActivityBadge.spec.ts +++ b/fe/src/__tests__/AppActivityBadge.spec.ts @@ -20,6 +20,24 @@ import { mount, type VueWrapper } from '@vue/test-utils' import { computed, ref } from 'vue' import { createPinia, setActivePinia } from 'pinia' +const moveJobsMock = vi.hoisted(() => ({ + trackedJobs: [] as Array<{ + jobId: string + audiobookId?: number + status: string + progress: number + phase?: string + target?: string + }>, + start: vi.fn(), + stop: vi.fn(), + loadActiveJobs: vi.fn(async () => undefined), +})) + +vi.mock('@/stores/moveJobs', () => ({ + useMoveJobsStore: () => moveJobsMock, +})) + // Mock the downloads store so App.vue picks up the activeDownloads correctly vi.mock('@/stores/downloads', () => ({ useDownloadsStore: () => ({ @@ -75,6 +93,7 @@ describe('App.vue activity badge', () => { beforeEach(() => { // reset mocks between tests vi.resetModules() + moveJobsMock.trackedJobs.length = 0 setActivePinia(createPinia()) }) @@ -103,6 +122,39 @@ describe('App.vue activity badge', () => { }) } + it('shows active move progress in the notification dropdown', async () => { + moveJobsMock.trackedJobs.push({ + jobId: 'move-1', + audiobookId: 98, + status: 'Running', + progress: 42.4, + phase: 'Verifying source', + target: 'D:\\Listenarr Test\\Book', + }) + + const { default: AppComponent } = await import('@/App.vue') + const router = createRouter({ + history: createMemoryHistory(), + routes: [{ path: '/', name: 'home', component: { template: '
' } }], + }) + await router.push('/') + await router.isReady().catch(() => {}) + + wrapper = mount(AppComponent, { + global: { stubs: ['RouterLink', 'RouterView'], plugins: [createPinia(), router] }, + }) + await new Promise((resolve) => setTimeout(resolve, 20)) + + await wrapper.find('.notification-wrapper .nav-btn').trigger('click') + + const dropdown = wrapper.find('.notification-dropdown') + expect(dropdown.exists()).toBe(true) + expect(dropdown.text()).toContain('Moving audiobook') + expect(dropdown.text()).toContain('Verifying source') + expect(dropdown.text()).toContain('42%') + expect(dropdown.find('.progress-fill').attributes('style')).toContain('width: 42.4%') + }) + it('counts active downloads correctly even when statuses are lowercase', async () => { // replace the downloads mock with one that returns a lowercased status const active = ref([ diff --git a/fe/src/__tests__/EditAudiobookModal.moveOptions.spec.ts b/fe/src/__tests__/EditAudiobookModal.moveOptions.spec.ts index 2f883f7f6..250ed0d80 100644 --- a/fe/src/__tests__/EditAudiobookModal.moveOptions.spec.ts +++ b/fe/src/__tests__/EditAudiobookModal.moveOptions.spec.ts @@ -690,7 +690,12 @@ describe('EditAudiobookModal move options', () => { jobId: 'job-1', audiobookId: 1, status: 'Queued', + progress: 0, + phase: undefined, target: 'C:\\root\\New Author\\New Book', + error: undefined, + recoveryDisposition: undefined, + canRetry: undefined, }) expect(signalRMocks.onMoveJobUpdate).toHaveBeenCalledTimes(1) expect(wrapper.emitted('saved')).toHaveLength(1) diff --git a/fe/src/__tests__/moveJobs.store.spec.ts b/fe/src/__tests__/moveJobs.store.spec.ts index 0e38ab9fd..3190e6592 100644 --- a/fe/src/__tests__/moveJobs.store.spec.ts +++ b/fe/src/__tests__/moveJobs.store.spec.ts @@ -22,6 +22,8 @@ type MoveJobUpdate = { jobId?: string audiobookId?: number status?: string + progress?: number + phase?: string target?: string error?: string } @@ -33,6 +35,7 @@ const toastMocks = vi.hoisted(() => ({ })) const apiMocks = vi.hoisted(() => ({ + getActiveMoveJobs: vi.fn(), getMoveJobStatus: vi.fn(), })) @@ -51,6 +54,7 @@ const signalRMocks = vi.hoisted(() => { vi.mock('@/services/api', () => ({ apiService: { + getActiveMoveJobs: apiMocks.getActiveMoveJobs, getMoveJobStatus: apiMocks.getMoveJobStatus, }, })) @@ -72,6 +76,7 @@ describe('move jobs store', () => { vi.clearAllMocks() setActivePinia(createPinia()) signalRMocks.callback = null + apiMocks.getActiveMoveJobs.mockResolvedValue([]) apiMocks.getMoveJobStatus.mockImplementation(() => new Promise(() => {})) signalRMocks.onMoveJobUpdate.mockImplementation((callback: (job: MoveJobUpdate) => void) => { signalRMocks.callback = callback @@ -91,6 +96,30 @@ describe('move jobs store', () => { expect(signalRMocks.unsubscribe).toHaveBeenCalledTimes(1) }) + it('recovers active move jobs when the store starts', async () => { + apiMocks.getActiveMoveJobs.mockResolvedValue([ + { + jobId: 'job-active', + audiobookId: 42, + status: 'Running', + progress: 61.5, + phase: 'Copying', + target: '/library/book', + }, + ]) + const store = useMoveJobsStore() + + store.start() + + await vi.waitFor(() => + expect(store.trackedById['job-active']).toMatchObject({ + status: 'Running', + progress: 61.5, + phase: 'Copying', + }), + ) + }) + it('tracks queued move jobs and subscribes on first track', () => { const store = useMoveJobsStore() @@ -105,6 +134,7 @@ describe('move jobs store', () => { jobId: 'JOB-1', audiobookId: 42, status: 'Queued', + progress: 0, target: '/library/book', }) }) @@ -124,6 +154,33 @@ describe('move jobs store', () => { expect(store.trackedById['job-1']?.status).toBe('Running') }) + it('tracks realtime move progress and phase without repeating the running toast', () => { + const store = useMoveJobsStore() + store.trackQueuedJob({ jobId: 'job-1', target: '/library/book' }) + + signalRMocks.callback?.({ + jobId: 'job-1', + status: 'Running', + progress: 18.5, + phase: 'Copying', + target: '/library/book', + }) + signalRMocks.callback?.({ + jobId: 'job-1', + status: 'Running', + progress: 63.25, + phase: 'Copying', + target: '/library/book', + }) + + expect(store.trackedById['job-1']).toMatchObject({ + status: 'Running', + progress: 63.25, + phase: 'Copying', + }) + expect(toastMocks.info).toHaveBeenCalledTimes(1) + }) + it('shows success toast and clears tracked job on completion', () => { const store = useMoveJobsStore() store.trackQueuedJob({ jobId: 'job-1', target: '/library/book' }) diff --git a/fe/src/services/api.ts b/fe/src/services/api.ts index 8a95968b6..ac326a1db 100644 --- a/fe/src/services/api.ts +++ b/fe/src/services/api.ts @@ -1299,10 +1299,52 @@ class ApiService { ) } + async getActiveMoveJobs(): Promise< + Array<{ + jobId: string + audiobookId?: number + status: string + progress?: number + phase?: string + target?: string + error?: string + recoveryDisposition?: string + canRetry?: boolean + }> + > { + const jobs = await this.request< + Array<{ + id: string + audiobookId?: number + status: string + phase?: string + progress?: number + requestedPath?: string + error?: string + recoveryDisposition?: string + canRetry?: boolean + }> + >('/library/move') + + return jobs.map((job) => ({ + jobId: job.id, + audiobookId: job.audiobookId, + status: job.status, + progress: job.progress, + phase: job.phase, + target: job.requestedPath, + error: job.error, + recoveryDisposition: job.recoveryDisposition, + canRetry: job.canRetry, + })) + } + async getMoveJobStatus(jobId: string): Promise<{ jobId: string audiobookId?: number status: string + progress?: number + phase?: string target?: string error?: string recoveryDisposition?: string @@ -1313,6 +1355,7 @@ class ApiService { audiobookId?: number status: string phase?: string + progress?: number requestedPath?: string error?: string attemptCount?: number @@ -1327,6 +1370,8 @@ class ApiService { jobId: job.id, audiobookId: job.audiobookId, status: job.status, + progress: job.progress, + phase: job.phase, target: job.requestedPath, error: job.error, recoveryDisposition: job.recoveryDisposition, diff --git a/fe/src/services/signalr.ts b/fe/src/services/signalr.ts index 66d3903eb..13ce0b352 100644 --- a/fe/src/services/signalr.ts +++ b/fe/src/services/signalr.ts @@ -440,6 +440,8 @@ class SignalRService { jobId: string audiobookId?: number status: string + progress?: number + phase?: string target?: string error?: string } @@ -749,6 +751,8 @@ class SignalRService { jobId: string audiobookId?: number status: string + progress?: number + phase?: string target?: string error?: string }) => void, diff --git a/fe/src/stores/moveJobs.ts b/fe/src/stores/moveJobs.ts index 388b60f6b..57eae4f25 100644 --- a/fe/src/stores/moveJobs.ts +++ b/fe/src/stores/moveJobs.ts @@ -35,6 +35,8 @@ export interface TrackedMoveJob { jobId: string audiobookId?: number status: MoveJobStatus + progress: number + phase?: string target?: string error?: string recoveryDisposition?: string @@ -57,6 +59,8 @@ type MoveJobUpdate = { jobId?: string audiobookId?: number status?: string + progress?: number + phase?: string target?: string error?: string recoveryDisposition?: string @@ -95,6 +99,14 @@ function normalizeJobId(jobId: string): string { return jobId.trim().toLowerCase() } +function normalizeProgress(progress: number | undefined, fallback: number): number { + if (progress == null || !Number.isFinite(progress)) { + return fallback + } + + return Math.min(100, Math.max(0, progress)) +} + export const useMoveJobsStore = defineStore('moveJobs', () => { const trackedById = ref>({}) const toast = useToast() @@ -150,6 +162,39 @@ export const useMoveJobsStore = defineStore('moveJobs', () => { } unsubscribe = signalRService.onMoveJobUpdate(handleMoveJobUpdate) + void loadActiveJobs() + } + + async function loadActiveJobs() { + try { + const jobs = await apiService.getActiveMoveJobs() + for (const job of jobs) { + if (!job.jobId?.trim()) { + continue + } + + const status = normalizeStatus(job.status) + if (status == null || terminalStatuses.has(status)) { + continue + } + + const key = normalizeJobId(job.jobId) + const existing = trackedById.value[key] + trackedById.value[key] = { + jobId: job.jobId, + audiobookId: job.audiobookId ?? existing?.audiobookId, + status, + progress: normalizeProgress(job.progress, existing?.progress ?? 0), + phase: job.phase ?? existing?.phase, + target: job.target ?? existing?.target, + error: job.error, + recoveryDisposition: job.recoveryDisposition ?? existing?.recoveryDisposition, + canRetry: job.canRetry ?? existing?.canRetry, + } + } + } catch (error) { + logger.debug('Failed to load active move jobs', error) + } } function stop() { @@ -180,6 +225,7 @@ export const useMoveJobsStore = defineStore('moveJobs', () => { jobId: job.jobId, audiobookId: job.audiobookId, status: job.status ?? 'Queued', + progress: job.status === 'Completed' ? 100 : 0, target: job.target, } void reconcileTrackedJob(key, job.jobId) @@ -227,6 +273,11 @@ export const useMoveJobsStore = defineStore('moveJobs', () => { ...existing, audiobookId: update.audiobookId ?? existing.audiobookId, status, + progress: normalizeProgress( + update.progress, + status === 'Completed' ? 100 : existing.progress, + ), + phase: update.phase ?? existing.phase, target: update.target ?? existing.target, error: update.error, recoveryDisposition: update.recoveryDisposition ?? existing.recoveryDisposition, @@ -265,6 +316,7 @@ export const useMoveJobsStore = defineStore('moveJobs', () => { requeueMoveJob, start, stop, + loadActiveJobs, trackQueuedJob, handleMoveJobUpdate, } diff --git a/fe/src/views/activity/ActivityView.vue b/fe/src/views/activity/ActivityView.vue index c8b7ba399..b3b0ee857 100644 --- a/fe/src/views/activity/ActivityView.vue +++ b/fe/src/views/activity/ActivityView.vue @@ -29,7 +29,7 @@ v-model="filterText" type="text" class="filter-input" - placeholder="Filter downloads..." + placeholder="Filter activity..." />
@@ -248,6 +248,7 @@ import { apiService } from '@/services/api' import { signalRService } from '@/services/signalr' import { useDownloadsStore } from '@/stores/downloads' import { useLibraryStore } from '@/stores/library' +import { useMoveJobsStore, type TrackedMoveJob } from '@/stores/moveJobs' import { EmptyState, LoadingState, ProgressBar } from '@/components/base' import { useConfigurationStore } from '@/stores/configuration' import type { QueueClientStatus, QueueItem, QueueUpdatePayload, Download } from '@/types' @@ -255,6 +256,7 @@ import { normalizeQueueSnapshot } from '@/utils/queueSnapshot' const downloadsStore = useDownloadsStore() const libraryStore = useLibraryStore() +const moveJobsStore = useMoveJobsStore() const configStore = useConfigurationStore() const filterText = ref('') @@ -480,6 +482,26 @@ const convertDownloadToQueueItem = (download: Download): QueueItem => { } } +const convertMoveJobToQueueItem = (job: TrackedMoveJob): QueueItem => ({ + id: `move:${job.jobId}`, + title: 'Library move', + audiobookId: job.audiobookId, + status: job.status === 'Queued' || job.status === 'RetryScheduled' ? 'queued' : 'moving', + progress: job.progress, + size: 0, + downloaded: 0, + downloadSpeed: 0, + eta: undefined, + quality: '', + downloadClient: job.phase ? `Library move · ${job.phase}` : 'Library move', + downloadClientId: 'LISTENARR_MOVE', + downloadClientType: 'move', + addedAt: '', + errorMessage: job.error, + canPause: false, + canRemove: false, +}) + // Read user preference from configuration store const showCompletedExternalDownloads = computed( () => configStore.applicationSettings?.showCompletedExternalDownloads ?? false, @@ -549,6 +571,17 @@ const allActivityItems = computed(() => { const failedFromDownloads = (downloadsStore.failedDownloads || []).map(convertDownloadToQueueItem) const finalMap = new Map() + for (const job of moveJobsStore.trackedJobs) { + if ( + job.status !== 'Completed' && + job.status !== 'Failed' && + job.status !== 'NeedsAttention' && + job.status !== 'Superseded' + ) { + const item = convertMoveJobToQueueItem(job) + finalMap.set(item.id, item) + } + } for (const it of combined) finalMap.set(it.id, it) for (const it of completedExternal) if (!finalMap.has(it.id)) finalMap.set(it.id, it) for (const it of failedFromDownloads) if (!finalMap.has(it.id)) finalMap.set(it.id, it) @@ -669,6 +702,7 @@ const formatStatus = (status: string): string => { completed: 'Completed', failed: 'Failed', processing: 'Processing', + moving: 'Moving', importpending: 'Importing', importblocked: 'Import Blocked', imported: 'Imported', @@ -685,6 +719,7 @@ const formatEta = (seconds: number): string => { // Subscribe to SignalR for real-time updates onMounted(async () => { + moveJobsStore.start() updateActivityLayoutMode() if (typeof window !== 'undefined') { window.addEventListener('resize', handleViewportResize, { passive: true }) @@ -1077,7 +1112,8 @@ onUnmounted(() => { color: #868e96; } -.status-badge.processing { +.status-badge.processing, +.status-badge.moving { background-color: rgba(190, 75, 219, 0.15); color: #be4bdb; } diff --git a/listenarr.api/Features/Library/LibraryController.cs b/listenarr.api/Features/Library/LibraryController.cs index 40cbe7ab2..390290115 100644 --- a/listenarr.api/Features/Library/LibraryController.cs +++ b/listenarr.api/Features/Library/LibraryController.cs @@ -287,6 +287,16 @@ public async Task GetMoveRecoveryState( return await _moveWorkflow.GetRecoveryStateAsync(id, cancellationToken); } + /// + /// Get active file-move background jobs for activity recovery. + /// + /// Request cancellation token. + [HttpGet("move")] + public async Task GetActiveMoveJobs(CancellationToken cancellationToken) + { + return await _moveWorkflow.GetActiveAsync(cancellationToken); + } + /// /// Get the current status of a file-move background job. /// diff --git a/listenarr.api/Features/Library/LibraryMoveWorkflow.Paths.cs b/listenarr.api/Features/Library/LibraryMoveWorkflow.Paths.cs index d09c8428c..1804a4556 100644 --- a/listenarr.api/Features/Library/LibraryMoveWorkflow.Paths.cs +++ b/listenarr.api/Features/Library/LibraryMoveWorkflow.Paths.cs @@ -67,9 +67,16 @@ private async Task AddAllowedMoveRootAsync( DirectoryObjectIdentityResolution directoryIdentity; if (hasPersistedDirectoryIdentity) { - var current = await directoryIdentityResolver.ResolveExistingAsync( - normalizedRoot, - cancellationToken); + var current = expectedDirectoryIdentityVersion.HasValue + && !string.IsNullOrWhiteSpace(expectedDirectoryIdentity) + ? await directoryIdentityResolver.ResolveExistingAsync( + normalizedRoot, + expectedDirectoryIdentityVersion.Value, + expectedDirectoryIdentity, + cancellationToken) + : DirectoryObjectIdentityResolution.Unavailable( + directoryIdentityUnavailableReason + ?? "The configured root has incomplete persisted physical identity."); directoryIdentity = current.IsAvailable && current.Version == expectedDirectoryIdentityVersion && string.Equals( diff --git a/listenarr.api/Features/Library/LibraryMoveWorkflow.Physical.cs b/listenarr.api/Features/Library/LibraryMoveWorkflow.Physical.cs index fd9f1ec09..b4ac61128 100644 --- a/listenarr.api/Features/Library/LibraryMoveWorkflow.Physical.cs +++ b/listenarr.api/Features/Library/LibraryMoveWorkflow.Physical.cs @@ -21,12 +21,6 @@ private async Task EnqueuePhysicalAsync( return MoveRecoveryConflict(recovery); } - var audiobook = await _repo.GetByIdAsync(id); - if (audiobook == null) - { - return new NotFoundObjectResult(new { message = "Audiobook not found" }); - } - try { using var scope = _scopeFactory.CreateScope(); @@ -198,14 +192,13 @@ await AddAllowedMoveRootAsync( var authoritativeRepository = authoritativeScope.ServiceProvider .GetRequiredService(); var manifestService = authoritativeScope.ServiceProvider - .GetRequiredService(); - var currentAudiobook = await authoritativeRepository.GetByIdSnapshotAsync( - id, - lockedToken) + .GetRequiredService(); + var currentAudiobook = await authoritativeRepository + .GetPathReferenceSnapshotAsync(id, lockedToken) ?? throw new ApplicationNotFoundException( "audiobook_not_found", "Audiobook not found"); - var manifest = await manifestService.BuildAsync( + var manifest = await manifestService.BuildPlanAsync( currentAudiobook, lockedToken); diff --git a/listenarr.api/Features/Library/LibraryMoveWorkflow.cs b/listenarr.api/Features/Library/LibraryMoveWorkflow.cs index 11a3aa1a5..516565474 100644 --- a/listenarr.api/Features/Library/LibraryMoveWorkflow.cs +++ b/listenarr.api/Features/Library/LibraryMoveWorkflow.cs @@ -33,6 +33,7 @@ internal sealed record MoveJobStatusResponse( int AudiobookId, MoveJobStatus Status, MoveJobPhase Phase, + double Progress, string? RequestedPath, string? Error, int AttemptCount, @@ -164,6 +165,18 @@ await _destinationRewriteService.RewriteDestinationAsync( cancellationToken); } + public async Task GetActiveAsync( + CancellationToken cancellationToken = default) + { + if (_moveQueueService == null) + { + return new NotFoundObjectResult(new { message = "Move queue not available" }); + } + + var jobs = await _moveQueueService.GetActiveJobsAsync(cancellationToken); + return new OkObjectResult(jobs.Select(ToStatusResponse).ToList()); + } + public async Task GetStatusAsync( string jobId, CancellationToken cancellationToken = default) @@ -238,6 +251,7 @@ private static MoveJobStatusResponse ToStatusResponse(MoveJob job) job.AudiobookId, job.Status, job.Phase, + MoveJobPublicProjection.CalculateProgress(job, job.Status), job.RequestedPath, MoveJobPublicProjection.ToError(job), job.AttemptCount, diff --git a/listenarr.application/Audiobooks/Contracts/IDirectoryObjectIdentityResolver.cs b/listenarr.application/Audiobooks/Contracts/IDirectoryObjectIdentityResolver.cs index 235a0cf79..d894aa20c 100644 --- a/listenarr.application/Audiobooks/Contracts/IDirectoryObjectIdentityResolver.cs +++ b/listenarr.application/Audiobooks/Contracts/IDirectoryObjectIdentityResolver.cs @@ -3,8 +3,7 @@ namespace Listenarr.Application.Audiobooks.Contracts; public sealed record DirectoryObjectIdentityResolution( int? Version, string? Value, - string? UnavailableReason, - bool EnrollmentCreated = false) + string? UnavailableReason) { public bool IsAvailable => Version.HasValue @@ -23,6 +22,8 @@ Task ResolveAsync( Task ResolveExistingAsync( string path, + int expectedVersion, + string expectedValue, CancellationToken cancellationToken = default); Task UpgradeLegacyAsync( @@ -30,10 +31,4 @@ Task UpgradeLegacyAsync( int legacyVersion, string legacyValue, CancellationToken cancellationToken = default); - - Task RetireEnrollmentAsync( - string path, - int expectedVersion, - string expectedValue, - CancellationToken cancellationToken = default); } diff --git a/listenarr.application/Audiobooks/Contracts/IMoveQueueService.cs b/listenarr.application/Audiobooks/Contracts/IMoveQueueService.cs index bdae1b320..794deb380 100644 --- a/listenarr.application/Audiobooks/Contracts/IMoveQueueService.cs +++ b/listenarr.application/Audiobooks/Contracts/IMoveQueueService.cs @@ -85,6 +85,11 @@ Task ScheduleRetryWithoutNotificationAsync( Task UpdateJobStatusAsync(Guid id, string leaseOwner, int leaseGeneration, MoveJobStatus status, string? error = null, CancellationToken cancellationToken = default); Task UpdateJobStatusWithoutNotificationAsync(Guid id, string leaseOwner, int leaseGeneration, MoveJobStatus status, string? error = null, CancellationToken cancellationToken = default); Task NotifyPersistedJobStateAsync(Guid id, MoveJobStatus status, string? error = null, CancellationToken cancellationToken = default); + Task PublishProgressAsync( + Guid id, + double progress, + string phase, + CancellationToken cancellationToken = default); System.Threading.Channels.ChannelReader Reader { get; } } } diff --git a/listenarr.application/Audiobooks/Contracts/IMoveSourceManifestService.cs b/listenarr.application/Audiobooks/Contracts/IMoveSourceManifestService.cs index f42622067..ccc42a0dd 100644 --- a/listenarr.application/Audiobooks/Contracts/IMoveSourceManifestService.cs +++ b/listenarr.application/Audiobooks/Contracts/IMoveSourceManifestService.cs @@ -21,3 +21,10 @@ Task BuildAsync( Audiobook audiobook, CancellationToken cancellationToken = default); } + +public interface IMoveSourcePlanService +{ + Task BuildPlanAsync( + AudiobookPathReferenceSnapshot audiobook, + CancellationToken cancellationToken = default); +} diff --git a/listenarr.application/Audiobooks/Contracts/MoveJobPublicProjection.cs b/listenarr.application/Audiobooks/Contracts/MoveJobPublicProjection.cs index 593c1e355..edfdf87c7 100644 --- a/listenarr.application/Audiobooks/Contracts/MoveJobPublicProjection.cs +++ b/listenarr.application/Audiobooks/Contracts/MoveJobPublicProjection.cs @@ -6,6 +6,8 @@ public sealed record MoveJobPublicUpdate( string Status, string? Error, string? Target, + double Progress, + string Phase, DateTime UpdatedAt); public static class MoveJobPublicProjection @@ -48,7 +50,9 @@ public static MoveJobPublicUpdate CreateUpdate( MoveJobStatus fallbackStatus, string? fallbackError, DateTime fallbackUpdatedAt, - MoveJob? persistedJob) + MoveJob? persistedJob, + double? progressOverride = null, + string? phaseOverride = null) { var status = persistedJob?.Status ?? fallbackStatus; var error = persistedJob == null @@ -60,6 +64,53 @@ public static MoveJobPublicUpdate CreateUpdate( status.ToString(), error, persistedJob?.RequestedPath, + Math.Clamp( + progressOverride ?? CalculateProgress(persistedJob, status), + 0, + 100), + phaseOverride ?? persistedJob?.Phase.ToString() ?? MoveJobPhase.None.ToString(), persistedJob?.UpdatedAt ?? fallbackUpdatedAt); } + + public static double CalculateProgress(MoveJob? job, MoveJobStatus fallbackStatus) + { + var status = job?.Status ?? fallbackStatus; + if (status == MoveJobStatus.Completed) + { + return 100; + } + if (status == MoveJobStatus.Queued) + { + return 0; + } + + var phase = job?.Phase ?? MoveJobPhase.None; + var files = job?.Entries + .Where(entry => entry.EntryType == MoveJobEntryType.File + && !MoveManifestIdentity.IsTargetBoundaryAuthorization(entry)) + .ToList() ?? []; + var totalBytes = files.Sum(entry => Math.Max(entry.Length, 1)); + var copiedBytes = files + .Where(entry => entry.CopyState == MoveJobEntryCopyState.Verified) + .Sum(entry => Math.Max(entry.Length, 1)); + var cleanedBytes = files + .Where(entry => entry.CleanupState is + MoveJobEntryCleanupState.Deleted or MoveJobEntryCleanupState.Retained) + .Sum(entry => Math.Max(entry.Length, 1)); + var copyRatio = totalBytes == 0 ? 0 : (double)copiedBytes / totalBytes; + var cleanupRatio = totalBytes == 0 ? 0 : (double)cleanedBytes / totalBytes; + + return phase switch + { + MoveJobPhase.None => 1, + MoveJobPhase.Planned => 5, + MoveJobPhase.Copying => 5 + (copyRatio * 65), + MoveJobPhase.Published => 72, + MoveJobPhase.CleaningSource => 75 + (cleanupRatio * 15), + MoveJobPhase.Finalizing => 92, + MoveJobPhase.CleaningArtifacts => 98, + MoveJobPhase.RecordingCompletion => 99, + _ => 1 + }; + } } diff --git a/listenarr.application/Audiobooks/Contracts/MoveManifestIdentity.cs b/listenarr.application/Audiobooks/Contracts/MoveManifestIdentity.cs index eb747efa6..a2823cdb1 100644 --- a/listenarr.application/Audiobooks/Contracts/MoveManifestIdentity.cs +++ b/listenarr.application/Audiobooks/Contracts/MoveManifestIdentity.cs @@ -91,6 +91,28 @@ public static bool SourceManifestsMatch( StringComparison.Ordinal); } + public static bool SourceManifestShapesMatch( + IEnumerable currentEntries, + IEnumerable persistedEntries, + FileSystemPathSemantics semantics) + { + ArgumentNullException.ThrowIfNull(currentEntries); + ArgumentNullException.ThrowIfNull(persistedEntries); + return string.Equals( + ComputeManifestDigest( + currentEntries + .Select(ToIdentityEntry) + .Select(entry => entry with { Sha256 = null }), + semantics), + ComputeManifestDigest( + persistedEntries + .Where(entry => !IsTargetBoundaryAuthorization(entry)) + .Select(ToIdentityEntry) + .Select(entry => entry with { Sha256 = null }), + semantics), + StringComparison.Ordinal); + } + public static MoveJobEntry CreateTargetBoundaryAuthorization( int directoryIdentityVersion, string directoryIdentity) diff --git a/listenarr.application/Audiobooks/Contracts/Repositories/IAudiobookFileRepository.cs b/listenarr.application/Audiobooks/Contracts/Repositories/IAudiobookFileRepository.cs index 0f394f19c..5f7983aec 100644 --- a/listenarr.application/Audiobooks/Contracts/Repositories/IAudiobookFileRepository.cs +++ b/listenarr.application/Audiobooks/Contracts/Repositories/IAudiobookFileRepository.cs @@ -24,6 +24,10 @@ public sealed record AudiobookBasePathMutation( string? ExpectedCurrentBasePath, string? ResultingBasePath); + public sealed record AudiobookFilePathReferenceSnapshot( + int AudiobookId, + string? Path); + public interface IAudiobookFileRepository { Task GetByIdAsync(int id, CancellationToken ct = default); @@ -77,6 +81,9 @@ Task> GetAllFilePathsAsync( FileSystemPathSemantics comparisonSemantics, CancellationToken ct = default); Task> GetAllAsync(CancellationToken ct = default); + Task> GetOtherPathReferenceSnapshotsAsync( + int audiobookId, + CancellationToken ct = default); Task> GetFormatSummariesAsync(CancellationToken ct = default); Task> GetCountsByAudiobookIdAsync(CancellationToken ct = default); } diff --git a/listenarr.application/Audiobooks/Contracts/Repositories/IAudiobookRepository.cs b/listenarr.application/Audiobooks/Contracts/Repositories/IAudiobookRepository.cs index 3e3ad6f75..29df853df 100644 --- a/listenarr.application/Audiobooks/Contracts/Repositories/IAudiobookRepository.cs +++ b/listenarr.application/Audiobooks/Contracts/Repositories/IAudiobookRepository.cs @@ -20,9 +20,20 @@ namespace Listenarr.Application.Audiobooks.Contracts.Repositories { + public sealed record AudiobookPathReferenceSnapshot( + int AudiobookId, + string? BasePath, + string? FilePath); + public interface IAudiobookRepository { Task> GetAllAsync(); + Task GetPathReferenceSnapshotAsync( + int audiobookId, + CancellationToken ct = default); + Task> GetOtherPathReferenceSnapshotsAsync( + int audiobookId, + CancellationToken ct = default); Task> GetLibraryAsync(); Task>> GetAllSeriesMembershipsGroupedByAudiobookIdAsync(CancellationToken ct = default); Task> GetByIdsWithFilesAsync(IEnumerable ids, CancellationToken ct = default); @@ -32,6 +43,8 @@ public interface IAudiobookRepository Task GetByIsbnAsync(string isbn); Task GetByIdAsync(int id); Task GetByIdSnapshotAsync(int id, CancellationToken ct = default); + Task GetForScanAsync(int id, CancellationToken ct = default); + Task GetForScanSnapshotAsync(int id, CancellationToken ct = default); Task TryUpdateBasePathAsync( int audiobookId, string expectedBasePath, diff --git a/listenarr.application/Audiobooks/Files/AudiobookFileService.Registration.cs b/listenarr.application/Audiobooks/Files/AudiobookFileService.Registration.cs index 071d47310..d9f0ca7d4 100644 --- a/listenarr.application/Audiobooks/Files/AudiobookFileService.Registration.cs +++ b/listenarr.application/Audiobooks/Files/AudiobookFileService.Registration.cs @@ -87,7 +87,8 @@ await EnsureAudiobookFileAsync( ApplyCommittedBasePath(audiobook, registration.Mutation); if (registrationLease.MatchesCurrentPublication()) { - return true; + return CompleteRegisteredPublication( + registrationLease); } await RollbackPublishedGenerationIfStaleAsync( @@ -133,7 +134,8 @@ await RollbackPublishedGenerationIfStaleAsync( ApplyCommittedBasePath(audiobook, basePathCommit.Mutation); if (registrationLease.MatchesCurrentPublication()) { - return true; + return CompleteRegisteredPublication( + registrationLease); } await RollbackPublishedGenerationIfStaleAsync( @@ -169,7 +171,8 @@ await RefreshPhysicalGenerationAsync( ApplyCommittedBasePath(audiobook, refresh.Mutation); if (registrationLease.MatchesCurrentPublication()) { - return true; + return CompleteRegisteredPublication( + registrationLease); } await RollbackPublishedGenerationIfStaleAsync( @@ -187,6 +190,12 @@ await RollbackPublishedGenerationIfStaleAsync( return false; } + private static bool CompleteRegisteredPublication( + IAudiobookFileRegistrationLease registrationLease) => + registrationLease.CompletePublication() is + RegistrationPublicationCompletion.Completed or + RegistrationPublicationCompletion.CommittedCleanupPending; + public Task RollbackPublishedGenerationIfStaleAsync( Audiobook audiobook, IAudiobookFileRegistrationLease registrationLease) => diff --git a/listenarr.application/Audiobooks/Jobs/MoveQueueService.Manifest.cs b/listenarr.application/Audiobooks/Jobs/MoveQueueService.Manifest.cs index d05ccc3d5..4bd902c62 100644 --- a/listenarr.application/Audiobooks/Jobs/MoveQueueService.Manifest.cs +++ b/listenarr.application/Audiobooks/Jobs/MoveQueueService.Manifest.cs @@ -63,8 +63,9 @@ private static ValidatedMoveManifest ValidateSourceManifest( if (entry.EntryType == MoveJobEntryType.File) { if (entry.Length < 0 - || entry.Sha256?.Length != 64 - || !entry.Sha256.All(Uri.IsHexDigit)) + || (entry.Sha256 != null + && (entry.Sha256.Length != 64 + || !entry.Sha256.All(Uri.IsHexDigit)))) { throw new InvalidOperationException( "A move source file manifest entry has invalid length or hash evidence."); diff --git a/listenarr.application/Audiobooks/Jobs/MoveQueueService.Progress.cs b/listenarr.application/Audiobooks/Jobs/MoveQueueService.Progress.cs new file mode 100644 index 000000000..255c2c8c5 --- /dev/null +++ b/listenarr.application/Audiobooks/Jobs/MoveQueueService.Progress.cs @@ -0,0 +1,74 @@ +using Microsoft.Extensions.Logging; + +namespace Listenarr.Application.Audiobooks.Jobs; + +public partial class MoveQueueService +{ + public async Task PublishProgressAsync( + Guid id, + double progress, + string phase, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(phase); + var publicationGate = AcquirePublicationGate(id); + var enteredPublicationGate = false; + try + { + await publicationGate.Gate.WaitAsync(cancellationToken); + enteredPublicationGate = true; + + MoveJob? dbJob; + try + { + dbJob = await _persistence.GetByIdAsync(id, cancellationToken); + } + catch (Exception ex) when (ex is not ( + OperationCanceledException or OutOfMemoryException or StackOverflowException)) + { + _logger.LogDebug( + ex, + "Skipped transient move progress publication for job {JobId} because persisted state could not be reloaded", + id); + return; + } + + if (dbJob == null || dbJob.Status != MoveJobStatus.Running) + { + return; + } + + try + { + await _hubBroadcaster.BroadcastAsync( + "MoveJobUpdate", + MoveJobPublicProjection.CreateUpdate( + id, + dbJob.Status, + dbJob.Error, + _timeProvider.GetUtcNow().UtcDateTime, + dbJob, + progress, + phase), + cancellationToken); + } + catch (Exception ex) when (ex is not ( + OperationCanceledException or OutOfMemoryException or StackOverflowException)) + { + _logger.LogDebug( + ex, + "Non-fatal: failed to broadcast progress for move job {JobId}", + id); + } + } + finally + { + if (enteredPublicationGate) + { + publicationGate.Gate.Release(); + } + + ReleasePublicationGate(id, publicationGate); + } + } +} diff --git a/listenarr.application/Audiobooks/Jobs/MoveQueueService.cs b/listenarr.application/Audiobooks/Jobs/MoveQueueService.cs index 56cdb11d9..24b8a134e 100644 --- a/listenarr.application/Audiobooks/Jobs/MoveQueueService.cs +++ b/listenarr.application/Audiobooks/Jobs/MoveQueueService.cs @@ -140,6 +140,7 @@ await ThrowIfRelocationBoundaryProtectedAsync( RequestedPath = target, ActiveDeduplicationKey = deduplicationKey, IdentityKeyVersion = MoveManifestIdentity.Version, + ExecutionProtocolVersion = MoveExecutionProtocol.Current, EnqueuedAt = _timeProvider.GetUtcNow().UtcDateTime, Status = MoveJobStatus.Queued, SourcePath = source, diff --git a/listenarr.application/Audiobooks/RootFolders/RootFolderService.DirectoryIdentity.cs b/listenarr.application/Audiobooks/RootFolders/RootFolderService.DirectoryIdentity.cs index e44442369..846fa09f5 100644 --- a/listenarr.application/Audiobooks/RootFolders/RootFolderService.DirectoryIdentity.cs +++ b/listenarr.application/Audiobooks/RootFolders/RootFolderService.DirectoryIdentity.cs @@ -15,7 +15,6 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -using System.Runtime.ExceptionServices; using Listenarr.Domain.Common; namespace Listenarr.Application.Audiobooks.RootFolders @@ -85,16 +84,13 @@ await EnsureNoActiveMoveJobsTouchRootAsync( root.DirectoryObjectIdentity = identity.Value; root.DirectoryObjectIdentityUnavailableReason = null; root.UpdatedAt = DateTime.UtcNow; - return await PersistRootWithEnrollmentCompensationAsync( - root, - identity, - () => _repo.UpdateAsync(root)); + await _repo.UpdateAsync(root); + return root; }, cancellationToken); } - private async Task - CaptureInitialDirectoryObjectIdentityAsync(RootFolder root) + private async Task CaptureInitialDirectoryObjectIdentityAsync(RootFolder root) { var resolution = _directoryObjectIdentityResolver == null ? DirectoryObjectIdentityResolution.Unavailable( @@ -103,73 +99,6 @@ private async Task root.DirectoryObjectIdentityVersion = resolution.Version; root.DirectoryObjectIdentity = resolution.Value; root.DirectoryObjectIdentityUnavailableReason = resolution.UnavailableReason; - return resolution; - } - - private async Task PersistRootWithEnrollmentCompensationAsync( - RootFolder root, - DirectoryObjectIdentityResolution identity, - Func persistAsync) - { - try - { - await persistAsync(); - return root; - } - catch (Exception persistenceException) - { - RootFolder? durableRoot; - try - { - durableRoot = root.Id > 0 - ? await _repo.GetByIdAsync(root.Id) - : await _repo.GetByPathAsync(root.Path); - } - catch (Exception verificationException) - { - throw new InvalidOperationException( - "Root folder persistence failed and its durable outcome could not be verified; the physical enrollment marker was preserved.", - new AggregateException( - persistenceException, - verificationException)); - } - - if (durableRoot != null - && durableRoot.DirectoryObjectIdentityVersion == identity.Version - && string.Equals( - durableRoot.DirectoryObjectIdentity, - identity.Value, - StringComparison.Ordinal)) - { - return durableRoot; - } - - if (identity.EnrollmentCreated - && identity.Version.HasValue - && !string.IsNullOrWhiteSpace(identity.Value) - && _directoryObjectIdentityResolver != null) - { - try - { - await _directoryObjectIdentityResolver.RetireEnrollmentAsync( - root.Path, - identity.Version.Value, - identity.Value, - CancellationToken.None); - } - catch (Exception compensationException) - { - throw new InvalidOperationException( - "Root folder persistence failed and its newly created physical enrollment could not be retired safely.", - new AggregateException( - persistenceException, - compensationException)); - } - } - - ExceptionDispatchInfo.Capture(persistenceException).Throw(); - throw new InvalidOperationException("Unreachable persistence compensation state."); - } } private async Task ValidateExistingDirectoryObjectIdentityAsync(RootFolder root) @@ -194,7 +123,9 @@ private async Task ValidateExistingDirectoryObjectIdentityAsync(RootFolder root) } var current = await _directoryObjectIdentityResolver.ResolveExistingAsync( - canonicalRootPath); + canonicalRootPath, + root.DirectoryObjectIdentityVersion.Value, + root.DirectoryObjectIdentity); if (!current.IsAvailable || current.Version != root.DirectoryObjectIdentityVersion || !string.Equals( diff --git a/listenarr.application/Audiobooks/RootFolders/RootFolderService.cs b/listenarr.application/Audiobooks/RootFolders/RootFolderService.cs index 15c54eb26..fe3d04c4c 100644 --- a/listenarr.application/Audiobooks/RootFolders/RootFolderService.cs +++ b/listenarr.application/Audiobooks/RootFolders/RootFolderService.cs @@ -81,21 +81,19 @@ private async Task CreateCoreAsync(RootFolder root) throw new InvalidOperationException(BuildRootFolderConflictMessage(conflict)); } - var identity = await CaptureInitialDirectoryObjectIdentityAsync(root); + await CaptureInitialDirectoryObjectIdentityAsync(root); if (root.IsDefault) { var currentDefaultId = (await _repo.GetDefaultAsync())?.Id; - return await PersistRootWithEnrollmentCompensationAsync( - root, - identity, - () => _repo.AddAndSetDefaultAsync(root, currentDefaultId)); + await _repo.AddAndSetDefaultAsync(root, currentDefaultId); + } + else + { + await _repo.AddAsync(root); } - return await PersistRootWithEnrollmentCompensationAsync( - root, - identity, - () => _repo.AddAsync(root)); + return root; } public Task DeleteAsync(int id, int? reassignRootId = null) => diff --git a/listenarr.domain/Audiobooks/MoveJob.cs b/listenarr.domain/Audiobooks/MoveJob.cs index f5e95c040..a7eb4cc24 100644 --- a/listenarr.domain/Audiobooks/MoveJob.cs +++ b/listenarr.domain/Audiobooks/MoveJob.cs @@ -72,10 +72,18 @@ public enum MoveJobEntryCleanupState { Pending, Quarantined, + DeletionAuthorized, Deleted, Retained } + public static class MoveExecutionProtocol + { + public const int LegacyFilesystemArtifacts = 1; + public const int MarkerlessDatabaseState = 2; + public const int Current = MarkerlessDatabaseState; + } + public static class MoveJobStatusExtensions { public static bool IsActive(this MoveJobStatus status) => status is @@ -93,6 +101,14 @@ public class MoveJob public DateTime EnqueuedAt { get; set; } = DateTime.UtcNow; public MoveJobStatus Status { get; set; } = MoveJobStatus.Queued; public MoveJobPhase Phase { get; set; } = MoveJobPhase.None; + public int ExecutionProtocolVersion { get; set; } = + MoveExecutionProtocol.LegacyFilesystemArtifacts; + [MaxLength(512)] + public string? SourceDirectoryObjectIdentity { get; set; } + [MaxLength(512)] + public string? TargetDirectoryObjectIdentity { get; set; } + public MoveJobEntryCleanupState SourceDirectoryCleanupState { get; set; } = + MoveJobEntryCleanupState.Pending; public string? Error { get; set; } public MoveFailureKind FailureKind { get; set; } = MoveFailureKind.None; public int AttemptCount { get; set; } = 0; @@ -212,5 +228,9 @@ public class MoveJobEntry public MoveJobEntryCopyState CopyState { get; set; } public MoveJobEntryCleanupState CleanupState { get; set; } public int CleanupProtectionVersion { get; set; } + [MaxLength(512)] + public string? SourcePhysicalObjectIdentity { get; set; } + [MaxLength(512)] + public string? TargetPhysicalObjectIdentity { get; set; } } } diff --git a/listenarr.domain/Audiobooks/MoveScanHandoff.cs b/listenarr.domain/Audiobooks/MoveScanHandoff.cs index 2a04d3d7d..ca0019f63 100644 --- a/listenarr.domain/Audiobooks/MoveScanHandoff.cs +++ b/listenarr.domain/Audiobooks/MoveScanHandoff.cs @@ -50,4 +50,6 @@ public sealed class MoveJobCreatedDirectory [Required, MaxLength(2000)] public string Path { get; set; } = string.Empty; public MoveCreatedDirectoryState State { get; set; } = MoveCreatedDirectoryState.Planned; + [MaxLength(512)] + public string? DirectoryObjectIdentity { get; set; } } diff --git a/listenarr.domain/Audiobooks/RootFolderRelocation.cs b/listenarr.domain/Audiobooks/RootFolderRelocation.cs index a4ba32f8f..edf5f9509 100644 --- a/listenarr.domain/Audiobooks/RootFolderRelocation.cs +++ b/listenarr.domain/Audiobooks/RootFolderRelocation.cs @@ -30,7 +30,8 @@ public enum LibraryDirectoryOwnershipPathMigrationState { Prepared, MarkersPublished, - MetadataCommitted + MetadataCommitted, + SourceMarkersRetired } public enum RootFolderRelocationCreatedDirectoryState diff --git a/listenarr.domain/Downloads/FileMutationJournal.cs b/listenarr.domain/Downloads/FileMutationJournal.cs new file mode 100644 index 000000000..9f39bcd0b --- /dev/null +++ b/listenarr.domain/Downloads/FileMutationJournal.cs @@ -0,0 +1,51 @@ +using System.ComponentModel.DataAnnotations; + +namespace Listenarr.Domain.Downloads; + +public static class FileMutationProtocol +{ + public const int MarkerlessDatabaseState = 2; +} + +public enum FileMutationJournalState +{ + Planned, + TargetIdentityPersisted, + TargetVerified, + RegistrationCommitted, + SourceDeletionAuthorized, + SourceDeleted, + Completed, + NeedsAttention +} + +/// +/// Durable coordination for a single final-name file mutation. Filesystem paths +/// contain only user content; recovery authority is persisted in SQLite. +/// +public sealed class FileMutationJournal +{ + [Key] + public Guid OperationId { get; set; } + public int ProtocolVersion { get; set; } = + FileMutationProtocol.MarkerlessDatabaseState; + public FileAction Action { get; set; } + [Required, MaxLength(4096)] + public string SourcePath { get; set; } = string.Empty; + [Required, MaxLength(4096)] + public string DestinationPath { get; set; } = string.Empty; + [Required, MaxLength(512)] + public string SourcePhysicalObjectIdentity { get; set; } = string.Empty; + [MaxLength(512)] + public string? TargetPhysicalObjectIdentity { get; set; } + public long SourceLength { get; set; } + [MaxLength(64)] + public string? SourceSha256 { get; set; } + public FileMutationJournalState State { get; set; } = + FileMutationJournalState.Planned; + public int? AudiobookId { get; set; } + [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 1cc6ae47d..aaf633f3a 100644 --- a/listenarr.infrastructure/DependencyInjection/Library/LibraryRegistrationExtensions.cs +++ b/listenarr.infrastructure/DependencyInjection/Library/LibraryRegistrationExtensions.cs @@ -39,7 +39,11 @@ public static IServiceCollection AddLibraryServices(this IServiceCollection serv services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); + services.AddScoped(); + services.AddScoped(serviceProvider => + serviceProvider.GetRequiredService()); + services.AddScoped(serviceProvider => + serviceProvider.GetRequiredService()); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/listenarr.infrastructure/FileSystem/DirectoryObjectIdentityResolver.cs b/listenarr.infrastructure/FileSystem/DirectoryObjectIdentityResolver.cs index bf3c3d69e..4b847fcb2 100644 --- a/listenarr.infrastructure/FileSystem/DirectoryObjectIdentityResolver.cs +++ b/listenarr.infrastructure/FileSystem/DirectoryObjectIdentityResolver.cs @@ -14,20 +14,35 @@ internal sealed class DirectoryObjectIdentityResolver( public Task ResolveAsync( string path, CancellationToken cancellationToken = default) => - ResolveCoreAsync( + ResolvePinnedAsync( path, - enrollIfMissing: true, - expectedLegacyIdentity: null, - cancellationToken); + cancellationToken, + nativeIdentity => new DirectoryObjectIdentityResolution( + ManagedDirectoryIdentity.CurrentVersion, + ManagedDirectoryIdentity.CreateMarkerless(nativeIdentity), + null)); public Task ResolveExistingAsync( string path, - CancellationToken cancellationToken = default) => - ResolveCoreAsync( + int expectedVersion, + string expectedValue, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(expectedValue); + return ResolvePinnedAsync( path, - enrollIfMissing: false, - expectedLegacyIdentity: null, - cancellationToken); + cancellationToken, + nativeIdentity => ManagedDirectoryIdentity.MatchesNativeIdentity( + expectedVersion, + expectedValue, + nativeIdentity) + ? new DirectoryObjectIdentityResolution( + expectedVersion, + expectedValue, + null) + : DirectoryObjectIdentityResolution.Unavailable( + "The live directory no longer matches its persisted physical identity.")); + } public Task UpgradeLegacyAsync( string path, @@ -43,70 +58,25 @@ public Task UpgradeLegacyAsync( $"Directory identity version {legacyVersion} cannot be upgraded automatically.")); } - return ResolveCoreAsync( + return ResolvePinnedAsync( path, - enrollIfMissing: true, - expectedLegacyIdentity: legacyValue, - cancellationToken); - } - - public async Task RetireEnrollmentAsync( - string path, - int expectedVersion, - string expectedValue, - CancellationToken cancellationToken = default) - { - ArgumentException.ThrowIfNullOrWhiteSpace(expectedValue); - if (expectedVersion != ManagedDirectoryIdentity.CurrentVersion) - { - throw new InvalidOperationException( - $"Directory identity version {expectedVersion} cannot be retired as a managed enrollment."); - } - - cancellationToken.ThrowIfCancellationRequested(); - if (!FileSystemPathIdentity.TryCanonicalizeStoredAbsolutePathForHost( - path, - out var canonicalPath, - out var pathReason)) - { - throw new InvalidOperationException(pathReason); - } - - try - { - using var anchor = PinnedDirectoryCreation.OpenPinnedBoundary(canonicalPath); - var nativeIdentity = _nativeIdentityResolver(anchor); - var current = await ManagedDirectoryEnrollment.ResolveAsync( - anchor, - nativeIdentity, - enrollIfMissing: false, - cancellationToken); - if (!current.IsAvailable - || current.Version != expectedVersion - || !string.Equals(current.Value, expectedValue, StringComparison.Ordinal) - || !anchor.VisiblePathMatches()) - { - throw new InvalidOperationException( - "The managed directory enrollment changed before compensation and was preserved."); - } - - ManagedDirectoryEnrollment.RetireValidMarker(anchor); - } - catch (Exception exception) when (exception is - IOException or UnauthorizedAccessException or Win32Exception - or PlatformNotSupportedException) - { - throw new InvalidOperationException( - "The managed directory enrollment could not be retired safely.", - exception); - } + cancellationToken, + nativeIdentity => string.Equals( + nativeIdentity, + legacyValue, + StringComparison.Ordinal) + ? new DirectoryObjectIdentityResolution( + ManagedDirectoryIdentity.CurrentVersion, + ManagedDirectoryIdentity.CreateMarkerless(nativeIdentity), + null) + : DirectoryObjectIdentityResolution.Unavailable( + "The live directory no longer matches its legacy physical identity and cannot be upgraded automatically.")); } - private async Task ResolveCoreAsync( + private Task ResolvePinnedAsync( string path, - bool enrollIfMissing, - string? expectedLegacyIdentity, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + Func resolve) { cancellationToken.ThrowIfCancellationRequested(); ArgumentException.ThrowIfNullOrWhiteSpace(path); @@ -115,7 +85,8 @@ private async Task ResolveCoreAsync( out var canonicalPath, out var pathReason)) { - return DirectoryObjectIdentityResolution.Unavailable(pathReason); + return Task.FromResult( + DirectoryObjectIdentityResolution.Unavailable(pathReason)); } try @@ -124,31 +95,19 @@ private async Task ResolveCoreAsync( var nativeIdentity = _nativeIdentityResolver(anchor); if (!anchor.VisiblePathMatches()) { - return DirectoryObjectIdentityResolution.Unavailable( - "The directory changed while its physical identity was captured."); + return Task.FromResult( + DirectoryObjectIdentityResolution.Unavailable( + "The directory changed while its physical identity was captured.")); } - if (expectedLegacyIdentity != null - && !string.Equals( - nativeIdentity, - expectedLegacyIdentity, - StringComparison.Ordinal)) - { - return DirectoryObjectIdentityResolution.Unavailable( - "The live directory no longer matches its legacy physical identity and cannot be enrolled automatically."); - } - - return await ManagedDirectoryEnrollment.ResolveAsync( - anchor, - nativeIdentity, - enrollIfMissing, - cancellationToken); + return Task.FromResult(resolve(nativeIdentity)); } catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or Win32Exception or PlatformNotSupportedException or InvalidOperationException) { - return DirectoryObjectIdentityResolution.Unavailable(exception.Message); + return Task.FromResult( + DirectoryObjectIdentityResolution.Unavailable(exception.Message)); } } } diff --git a/listenarr.infrastructure/FileSystem/FileMover.Actions.cs b/listenarr.infrastructure/FileSystem/FileMover.Actions.cs index ce20b4046..c0aa8fc95 100644 --- a/listenarr.infrastructure/FileSystem/FileMover.Actions.cs +++ b/listenarr.infrastructure/FileSystem/FileMover.Actions.cs @@ -58,6 +58,17 @@ FileAction.Copy or return null; } + var markerless = await TryPrepareActionForRegistrationMarkerlessAsync( + action, + source, + destination, + operationId, + expectedRegisteredPhysicalObjectIdentity); + if (markerless.Handled) + { + return markerless.Lease; + } + if (action == FileAction.HardlinkCopy) { if (!operationId.HasValue) diff --git a/listenarr.infrastructure/FileSystem/FileMover.MarkerlessMove.cs b/listenarr.infrastructure/FileSystem/FileMover.MarkerlessMove.cs new file mode 100644 index 000000000..55cc85a24 --- /dev/null +++ b/listenarr.infrastructure/FileSystem/FileMover.MarkerlessMove.cs @@ -0,0 +1,488 @@ +using System.Security.Cryptography; +using Listenarr.Domain.Audiobooks.Enumerations; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Infrastructure.FileSystem; + +public partial class FileMover +{ + private async Task TryMoveFileMarkerlessAsync( + string source, + string destination, + Guid? operationId) + { + if (!operationId.HasValue || _fileMutationJournalStore == null) + { + return null; + } + if (operationId.Value == Guid.Empty) + { + throw new ArgumentException( + "A markerless file move requires a non-empty operation ID.", + nameof(operationId)); + } + + using var pathLock = await TryAcquireFileMoveGateAsync( + source, + destination, + allowExistingAliasForRecovery: true); + if (pathLock == null) + { + return false; + } + + var cancellationToken = CancellationToken.None; + var journal = await _fileMutationJournalStore.GetAsync( + operationId.Value, + cancellationToken); + if (journal == null) + { + using var initialSource = pathLock.SourceParent.TryOpenExistingFile( + pathLock.SourceName, + requireDeleteAccess: true); + using var initialDestination = + pathLock.DestinationParent.TryOpenExistingFile( + pathLock.DestinationName, + requireDeleteAccess: false); + if (initialSource == null + || initialDestination != null + || !initialSource.VisiblePathMatches()) + { + return false; + } + + var proof = await CaptureMarkerlessSourceProofAsync( + initialSource, + cancellationToken); + journal = await _fileMutationJournalStore.GetOrCreateAsync( + new FileMutationJournalClaim( + operationId.Value, + FileAction.Move, + pathLock.SourcePath, + pathLock.DestinationPath, + proof.PhysicalObjectIdentity, + proof.Length, + proof.Sha256), + cancellationToken); + if (AfterMarkerlessMoveJournalPlannedForTestAsync != null) + { + await AfterMarkerlessMoveJournalPlannedForTestAsync(); + } + } + else + { + ValidateMarkerlessMoveJournal(journal, pathLock); + } + + if (journal.State == FileMutationJournalState.NeedsAttention) + { + return false; + } + + using (var observedSource = pathLock.SourceParent.TryOpenExistingFile( + pathLock.SourceName, + requireDeleteAccess: false)) + using (var observedTarget = + pathLock.DestinationParent.TryOpenExistingFile( + pathLock.DestinationName, + requireDeleteAccess: false)) + { + if (journal.State == FileMutationJournalState.Planned) + { + if (observedSource != null && observedTarget != null) + { + await MarkMarkerlessMoveNeedsAttentionAsync( + journal, + "Both source and destination exist before markerless publication proof was persisted.", + cancellationToken); + return false; + } + if (observedSource == null && observedTarget == null) + { + await MarkMarkerlessMoveNeedsAttentionAsync( + journal, + "Both source and destination are missing for a markerless move.", + cancellationToken); + return false; + } + if (observedSource == null) + { + if (observedTarget == null + || !observedTarget.VisiblePathMatches() + || !string.Equals( + observedTarget.GetObjectIdentity(), + journal.SourcePhysicalObjectIdentity, + StringComparison.Ordinal) + || !await observedTarget.MatchesAsync( + journal.SourceLength, + journal.SourceSha256, + cancellationToken)) + { + await MarkMarkerlessMoveNeedsAttentionAsync( + journal, + "An unproven markerless destination cannot be attributed to the original source generation.", + cancellationToken); + return false; + } + + journal = await _fileMutationJournalStore.AdvanceAsync( + journal.OperationId, + FileMutationJournalState.TargetIdentityPersisted, + observedTarget.GetObjectIdentity(), + audiobookId: null, + error: null, + cancellationToken); + } + } + } + + if (journal.State == FileMutationJournalState.Planned) + { + using var sourceEntry = pathLock.SourceParent.TryOpenExistingFile( + pathLock.SourceName, + requireDeleteAccess: true); + using var existingTarget = + pathLock.DestinationParent.TryOpenExistingFile( + pathLock.DestinationName, + requireDeleteAccess: false); + if (sourceEntry == null + || existingTarget != null + || !await MatchesMarkerlessSourceProofAsync( + sourceEntry, + journal, + cancellationToken)) + { + await MarkMarkerlessMoveNeedsAttentionAsync( + journal, + "The markerless move source changed before publication.", + cancellationToken); + return false; + } + + string targetIdentity; + if (!DisableNativeFileRenameForTest + && sourceEntry.IsOnSameVolume(pathLock.DestinationParent)) + { + sourceEntry.MoveTo( + pathLock.DestinationParent, + pathLock.DestinationName); + pathLock.SourceParent.FlushDirectoryEntry(); + if (!string.Equals( + pathLock.SourceParent.FullPath, + pathLock.DestinationParent.FullPath, + StringComparison.Ordinal)) + { + pathLock.DestinationParent.FlushDirectoryEntry(); + } + targetIdentity = sourceEntry.GetObjectIdentity(); + if (!sourceEntry.VisiblePathMatches() + || !string.Equals( + targetIdentity, + journal.SourcePhysicalObjectIdentity, + StringComparison.Ordinal)) + { + throw new IOException( + "The markerless native move target could not be verified."); + } + if (AfterMarkerlessMovePublishedBeforeTargetStateForTestAsync != null) + { + await AfterMarkerlessMovePublishedBeforeTargetStateForTestAsync(); + } + } + else + { + using var created = pathLock.DestinationParent.CreateNewFile( + pathLock.DestinationName); + targetIdentity = created.GetObjectIdentity(); + if (AfterMarkerlessMoveTargetCreatedBeforeStateForTestAsync != null) + { + await AfterMarkerlessMoveTargetCreatedBeforeStateForTestAsync(); + } + } + + journal = await _fileMutationJournalStore.AdvanceAsync( + journal.OperationId, + FileMutationJournalState.TargetIdentityPersisted, + targetIdentity, + audiobookId: null, + error: null, + cancellationToken); + if (AfterMarkerlessMoveTargetStateForTestAsync != null) + { + await AfterMarkerlessMoveTargetStateForTestAsync(); + } + } + + if (journal.State == FileMutationJournalState.TargetIdentityPersisted) + { + using var targetEntry = + pathLock.DestinationParent.TryOpenExistingFile( + pathLock.DestinationName, + requireDeleteAccess: false); + if (targetEntry == null + || !TargetMatchesMarkerlessJournal(targetEntry, journal)) + { + await MarkMarkerlessMoveNeedsAttentionAsync( + journal, + "The markerless destination changed before content verification.", + cancellationToken); + return false; + } + + if (!await targetEntry.MatchesAsync( + journal.SourceLength, + journal.SourceSha256, + cancellationToken)) + { + using var sourceEntry = + pathLock.SourceParent.TryOpenExistingFile( + pathLock.SourceName, + requireDeleteAccess: false); + if (sourceEntry == null + || !await MatchesMarkerlessSourceProofAsync( + sourceEntry, + journal, + cancellationToken)) + { + await MarkMarkerlessMoveNeedsAttentionAsync( + journal, + "The markerless source is unavailable before the destination content was verified.", + cancellationToken); + return false; + } + + await CopyMarkerlessFileAsync( + sourceEntry, + targetEntry, + cancellationToken); + sourceEntry.PreserveMarkerlessMetadataTo(targetEntry); + if (!TargetMatchesMarkerlessJournal(targetEntry, journal) + || !await targetEntry.MatchesAsync( + journal.SourceLength, + journal.SourceSha256, + cancellationToken)) + { + throw new IOException( + "The markerless destination failed content verification."); + } + if (AfterMarkerlessMoveTargetWrittenBeforeVerifiedStateForTestAsync != null) + { + await AfterMarkerlessMoveTargetWrittenBeforeVerifiedStateForTestAsync(); + } + } + + journal = await _fileMutationJournalStore.AdvanceAsync( + journal.OperationId, + FileMutationJournalState.TargetVerified, + journal.TargetPhysicalObjectIdentity, + audiobookId: null, + error: null, + cancellationToken); + } + else if (journal.State >= FileMutationJournalState.TargetVerified) + { + using var targetEntry = + pathLock.DestinationParent.TryOpenExistingFile( + pathLock.DestinationName, + requireDeleteAccess: false); + if (targetEntry == null + || !TargetMatchesMarkerlessJournal(targetEntry, journal) + || !await targetEntry.MatchesAsync( + journal.SourceLength, + journal.SourceSha256, + cancellationToken)) + { + await MarkMarkerlessMoveNeedsAttentionAsync( + journal, + "The verified markerless destination changed.", + cancellationToken); + return false; + } + } + + if (journal.State >= FileMutationJournalState.SourceDeleted) + { + using var recreatedSource = + pathLock.SourceParent.TryOpenExistingFile( + pathLock.SourceName, + requireDeleteAccess: false); + if (recreatedSource != null) + { + await MarkMarkerlessMoveNeedsAttentionAsync( + journal, + "A source path was recreated after markerless deletion completed.", + cancellationToken); + return false; + } + } + + if (journal.State < FileMutationJournalState.SourceDeletionAuthorized) + { + journal = await _fileMutationJournalStore.AdvanceAsync( + journal.OperationId, + FileMutationJournalState.SourceDeletionAuthorized, + journal.TargetPhysicalObjectIdentity, + audiobookId: null, + error: null, + cancellationToken); + } + + if (journal.State == FileMutationJournalState.SourceDeletionAuthorized) + { + using var sourceEntry = pathLock.SourceParent.TryOpenExistingFile( + pathLock.SourceName, + requireDeleteAccess: true); + if (sourceEntry != null) + { + if (!await MatchesMarkerlessSourceProofAsync( + sourceEntry, + journal, + cancellationToken)) + { + await MarkMarkerlessMoveNeedsAttentionAsync( + journal, + "The markerless source was replaced before authorized deletion.", + cancellationToken); + return false; + } + + sourceEntry.Delete(immediateWindows: true); + pathLock.SourceParent.FlushDirectoryEntry(); + if (AfterMarkerlessMoveSourceDeletedBeforeStateForTestAsync != null) + { + await AfterMarkerlessMoveSourceDeletedBeforeStateForTestAsync(); + } + } + + journal = await _fileMutationJournalStore.AdvanceAsync( + journal.OperationId, + FileMutationJournalState.SourceDeleted, + journal.TargetPhysicalObjectIdentity, + audiobookId: null, + error: null, + cancellationToken); + } + + _ = await _fileMutationJournalStore.AdvanceAsync( + journal.OperationId, + FileMutationJournalState.Completed, + journal.TargetPhysicalObjectIdentity, + audiobookId: null, + error: null, + cancellationToken); + LogMutation( + FileMutationOutcome.Success, + FileAction.Move, + source, + destination, + "Markerless database-backed file move"); + return true; + } + + private static async Task + CaptureMarkerlessSourceProofAsync( + PinnedDirectoryCreation.PinnedFileEntry source, + CancellationToken cancellationToken) + { + var physicalObjectIdentity = source.GetObjectIdentity(); + await using var stream = source.OpenReadStream( + bufferSize: 128 * 1024, + asynchronous: false); + var length = stream.Length; + stream.Position = 0; + var hash = await SHA256.HashDataAsync(stream, cancellationToken); + return new MarkerlessSourceProof( + physicalObjectIdentity, + length, + Convert.ToHexString(hash)); + } + + private static async Task MatchesMarkerlessSourceProofAsync( + PinnedDirectoryCreation.PinnedFileEntry source, + FileMutationJournal journal, + CancellationToken cancellationToken) => + source.VisiblePathMatches() + && string.Equals( + source.GetObjectIdentity(), + journal.SourcePhysicalObjectIdentity, + StringComparison.Ordinal) + && await source.MatchesAsync( + journal.SourceLength, + journal.SourceSha256, + cancellationToken); + + private static bool TargetMatchesMarkerlessJournal( + PinnedDirectoryCreation.PinnedFileEntry target, + FileMutationJournal journal) => + target.VisiblePathMatches() + && !string.IsNullOrWhiteSpace( + journal.TargetPhysicalObjectIdentity) + && string.Equals( + target.GetObjectIdentity(), + journal.TargetPhysicalObjectIdentity, + StringComparison.Ordinal); + + private static async Task CopyMarkerlessFileAsync( + PinnedDirectoryCreation.PinnedFileEntry source, + PinnedDirectoryCreation.PinnedFileEntry target, + CancellationToken cancellationToken) + { + await using var sourceStream = source.OpenReadStream( + bufferSize: 128 * 1024, + asynchronous: false); + await using var targetStream = target.OpenWriteStream( + bufferSize: 128 * 1024, + asynchronous: false); + targetStream.SetLength(0); + await sourceStream.CopyToAsync( + targetStream, + 128 * 1024, + cancellationToken); + await targetStream.FlushAsync(cancellationToken); + targetStream.Flush(flushToDisk: true); + } + + private static void ValidateMarkerlessMoveJournal( + FileMutationJournal journal, + FileMoveGateLease pathLock) + { + if (journal.ProtocolVersion + != FileMutationProtocol.MarkerlessDatabaseState + || journal.Action != FileAction.Move + || !string.Equals( + journal.SourcePath, + Path.GetFullPath(pathLock.SourcePath), + StringComparison.Ordinal) + || !string.Equals( + journal.DestinationPath, + Path.GetFullPath(pathLock.DestinationPath), + StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "The durable markerless move identity does not match the requested operation."); + } + } + + private async Task MarkMarkerlessMoveNeedsAttentionAsync( + FileMutationJournal journal, + string reason, + CancellationToken cancellationToken) + { + _ = await _fileMutationJournalStore!.AdvanceAsync( + journal.OperationId, + FileMutationJournalState.NeedsAttention, + journal.TargetPhysicalObjectIdentity, + journal.AudiobookId, + reason, + cancellationToken); + _logger.LogWarning( + "Markerless file move {OperationId} requires attention: {Reason}", + journal.OperationId, + reason); + } + + private sealed record MarkerlessSourceProof( + string PhysicalObjectIdentity, + long Length, + string Sha256); +} diff --git a/listenarr.infrastructure/FileSystem/FileMover.MarkerlessRegistration.cs b/listenarr.infrastructure/FileSystem/FileMover.MarkerlessRegistration.cs new file mode 100644 index 000000000..b72361fbb --- /dev/null +++ b/listenarr.infrastructure/FileSystem/FileMover.MarkerlessRegistration.cs @@ -0,0 +1,497 @@ +using System.ComponentModel; +using Listenarr.Domain.Audiobooks.Enumerations; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Infrastructure.FileSystem; + +public partial class FileMover +{ + private readonly record struct MarkerlessRegistrationPreparation( + bool Handled, + IAudiobookFileRegistrationLease? Lease); + + private async Task + TryPrepareActionForRegistrationMarkerlessAsync( + FileAction action, + string source, + string destination, + Guid? operationId, + string? expectedRegisteredPhysicalObjectIdentity) + { + if (!operationId.HasValue || _fileMutationJournalStore == null) + { + return new MarkerlessRegistrationPreparation(false, null); + } + if (operationId.Value == Guid.Empty) + { + throw new ArgumentException( + "A markerless registration publication requires a non-empty operation ID.", + nameof(operationId)); + } + + using var gate = await TryAcquireFileMoveGateAsync( + source, + destination, + allowExistingAliasForRecovery: true); + if (gate == null) + { + return new MarkerlessRegistrationPreparation(true, null); + } + + var cancellationToken = CancellationToken.None; + var journal = await _fileMutationJournalStore.GetAsync( + operationId.Value, + cancellationToken); + if (journal == null) + { + using var initialSource = gate.SourceParent.TryOpenExistingFile( + gate.SourceName, + requireDeleteAccess: false); + using var initialDestination = gate.DestinationParent.TryOpenExistingFile( + gate.DestinationName, + requireDeleteAccess: false); + if (initialSource == null || !initialSource.VisiblePathMatches()) + { + return new MarkerlessRegistrationPreparation(true, null); + } + + var proof = await CaptureMarkerlessSourceProofAsync( + initialSource, + cancellationToken); + if (initialDestination != null + && (!initialDestination.VisiblePathMatches() + || !await initialDestination.MatchesAsync( + proof.Length, + proof.Sha256, + cancellationToken))) + { + return new MarkerlessRegistrationPreparation(true, null); + } + + journal = await _fileMutationJournalStore.GetOrCreateAsync( + new FileMutationJournalClaim( + operationId.Value, + action, + gate.SourcePath, + gate.DestinationPath, + proof.PhysicalObjectIdentity, + proof.Length, + proof.Sha256), + cancellationToken); + if (initialDestination != null) + { + journal = await _fileMutationJournalStore.AdvanceAsync( + journal.OperationId, + FileMutationJournalState.TargetIdentityPersisted, + initialDestination.GetObjectIdentity(), + audiobookId: null, + error: null, + cancellationToken); + } + } + else + { + ValidateMarkerlessRegistrationJournal(journal, action, gate); + } + + if (journal.State == FileMutationJournalState.NeedsAttention) + { + return new MarkerlessRegistrationPreparation(true, null); + } + + if (journal.State == FileMutationJournalState.Planned) + { + journal = await PublishMarkerlessRegistrationTargetAsync( + action, + gate, + journal, + cancellationToken); + if (journal.State == FileMutationJournalState.NeedsAttention) + { + return new MarkerlessRegistrationPreparation(true, null); + } + } + + if (journal.State == FileMutationJournalState.TargetIdentityPersisted) + { + journal = await VerifyMarkerlessRegistrationTargetAsync( + gate, + journal, + cancellationToken); + if (journal.State == FileMutationJournalState.NeedsAttention) + { + return new MarkerlessRegistrationPreparation(true, null); + } + } + else if (journal.State >= FileMutationJournalState.TargetVerified) + { + if (!await MarkerlessRegistrationTargetMatchesAsync( + gate, + journal, + cancellationToken)) + { + await MarkMarkerlessRegistrationNeedsAttentionAsync( + journal, + "The verified registration destination changed physical generation or content.", + cancellationToken); + return new MarkerlessRegistrationPreparation(true, null); + } + } + + if (!string.IsNullOrWhiteSpace(expectedRegisteredPhysicalObjectIdentity) + && !string.Equals( + journal.TargetPhysicalObjectIdentity, + expectedRegisteredPhysicalObjectIdentity, + StringComparison.Ordinal)) + { + await MarkMarkerlessRegistrationNeedsAttentionAsync( + journal, + "Durable audiobook ownership identifies a different destination generation.", + cancellationToken); + return new MarkerlessRegistrationPreparation(true, null); + } + + var targetEntry = gate.DestinationParent.OpenExistingFileForStableRead( + gate.DestinationName); + try + { + if (!TargetMatchesMarkerlessJournal(targetEntry, journal) + || !await targetEntry.MatchesAsync( + journal.SourceLength, + journal.SourceSha256, + cancellationToken)) + { + targetEntry.Dispose(); + await MarkMarkerlessRegistrationNeedsAttentionAsync( + journal, + "The registration destination changed while its lease was opened.", + cancellationToken); + return new MarkerlessRegistrationPreparation(true, null); + } + + var lease = PinnedAudiobookFileRegistrationLease.Create( + targetEntry, + gate.DestinationPath, + journal.TargetPhysicalObjectIdentity, + journal.SourcePhysicalObjectIdentity, + commitRegistration: audiobookId => CommitMarkerlessRegistration( + journal.OperationId, + action, + journal.TargetPhysicalObjectIdentity!, + audiobookId)); + targetEntry = null!; + return new MarkerlessRegistrationPreparation(true, lease); + } + finally + { + targetEntry?.Dispose(); + } + } + + private async Task PublishMarkerlessRegistrationTargetAsync( + FileAction action, + FileMoveGateLease gate, + FileMutationJournal journal, + CancellationToken cancellationToken) + { + using var sourceEntry = gate.SourceParent.TryOpenExistingFile( + gate.SourceName, + requireDeleteAccess: false); + using var existingTarget = gate.DestinationParent.TryOpenExistingFile( + gate.DestinationName, + requireDeleteAccess: false); + + if (existingTarget != null) + { + if (action == FileAction.HardlinkCopy + && existingTarget.VisiblePathMatches() + && string.Equals( + existingTarget.GetObjectIdentity(), + journal.SourcePhysicalObjectIdentity, + StringComparison.Ordinal) + && await existingTarget.MatchesAsync( + journal.SourceLength, + journal.SourceSha256, + cancellationToken)) + { + return await _fileMutationJournalStore!.AdvanceAsync( + journal.OperationId, + FileMutationJournalState.TargetIdentityPersisted, + existingTarget.GetObjectIdentity(), + audiobookId: null, + error: null, + cancellationToken); + } + + await MarkMarkerlessRegistrationNeedsAttentionAsync( + journal, + "A registration destination appeared before its physical identity was persisted.", + cancellationToken); + return await _fileMutationJournalStore!.GetAsync( + journal.OperationId, + cancellationToken) + ?? throw new InvalidOperationException( + "The markerless registration journal disappeared."); + } + + if (sourceEntry == null + || !await MatchesMarkerlessSourceProofAsync( + sourceEntry, + journal, + cancellationToken)) + { + await MarkMarkerlessRegistrationNeedsAttentionAsync( + journal, + "The registration source changed before destination publication.", + cancellationToken); + return await _fileMutationJournalStore!.GetAsync( + journal.OperationId, + cancellationToken) + ?? throw new InvalidOperationException( + "The markerless registration journal disappeared."); + } + + string targetIdentity; + PinnedDirectoryCreation.PinnedFileEntry? publishedHardlink = null; + if (action == FileAction.HardlinkCopy + && sourceEntry.IsOnSameVolume(gate.DestinationParent)) + { + try + { + publishedHardlink = sourceEntry.CreateHardLinkTo( + gate.DestinationParent, + gate.DestinationName); + targetIdentity = publishedHardlink.GetObjectIdentity(); + return await _fileMutationJournalStore!.AdvanceAsync( + journal.OperationId, + FileMutationJournalState.TargetIdentityPersisted, + targetIdentity, + audiobookId: null, + error: null, + cancellationToken); + } + catch (Exception exception) when (exception is + IOException or Win32Exception or PlatformNotSupportedException) + { + _logger.LogInformation( + exception, + "Markerless hardlink publication was unavailable; falling back to a direct final-name copy: {Source} -> {Destination}", + LogRedaction.SanitizeFilePath(gate.SourcePath), + LogRedaction.SanitizeFilePath(gate.DestinationPath)); + } + finally + { + publishedHardlink?.Dispose(); + } + } + + using var created = gate.DestinationParent.CreateNewFile( + gate.DestinationName); + targetIdentity = created.GetObjectIdentity(); + return await _fileMutationJournalStore!.AdvanceAsync( + journal.OperationId, + FileMutationJournalState.TargetIdentityPersisted, + targetIdentity, + audiobookId: null, + error: null, + cancellationToken); + } + + private async Task VerifyMarkerlessRegistrationTargetAsync( + FileMoveGateLease gate, + FileMutationJournal journal, + CancellationToken cancellationToken) + { + using var targetEntry = gate.DestinationParent.TryOpenExistingFile( + gate.DestinationName, + requireDeleteAccess: false); + if (targetEntry == null + || !TargetMatchesMarkerlessJournal(targetEntry, journal)) + { + await MarkMarkerlessRegistrationNeedsAttentionAsync( + journal, + "The registration destination changed before content verification.", + cancellationToken); + return await _fileMutationJournalStore!.GetAsync( + journal.OperationId, + cancellationToken) + ?? throw new InvalidOperationException( + "The markerless registration journal disappeared."); + } + + if (!await targetEntry.MatchesAsync( + journal.SourceLength, + journal.SourceSha256, + cancellationToken)) + { + using var sourceEntry = gate.SourceParent.TryOpenExistingFile( + gate.SourceName, + requireDeleteAccess: false); + if (sourceEntry == null + || !await MatchesMarkerlessSourceProofAsync( + sourceEntry, + journal, + cancellationToken)) + { + await MarkMarkerlessRegistrationNeedsAttentionAsync( + journal, + "The registration source is unavailable before destination content was verified.", + cancellationToken); + return await _fileMutationJournalStore!.GetAsync( + journal.OperationId, + cancellationToken) + ?? throw new InvalidOperationException( + "The markerless registration journal disappeared."); + } + + await CopyMarkerlessFileAsync( + sourceEntry, + targetEntry, + cancellationToken); + sourceEntry.PreserveMarkerlessMetadataTo(targetEntry); + if (!TargetMatchesMarkerlessJournal(targetEntry, journal) + || !await targetEntry.MatchesAsync( + journal.SourceLength, + journal.SourceSha256, + cancellationToken)) + { + throw new IOException( + "The markerless registration destination failed content verification."); + } + } + + return await _fileMutationJournalStore!.AdvanceAsync( + journal.OperationId, + FileMutationJournalState.TargetVerified, + journal.TargetPhysicalObjectIdentity, + audiobookId: null, + error: null, + cancellationToken); + } + + private static async Task MarkerlessRegistrationTargetMatchesAsync( + FileMoveGateLease gate, + FileMutationJournal journal, + CancellationToken cancellationToken) + { + using var targetEntry = gate.DestinationParent.TryOpenExistingFile( + gate.DestinationName, + requireDeleteAccess: false); + return targetEntry != null + && TargetMatchesMarkerlessJournal(targetEntry, journal) + && await targetEntry.MatchesAsync( + journal.SourceLength, + journal.SourceSha256, + cancellationToken); + } + + private bool CommitMarkerlessRegistration( + Guid operationId, + FileAction action, + string targetPhysicalObjectIdentity, + int audiobookId) + { + var journal = _fileMutationJournalStore!.Get(operationId) + ?? throw new InvalidOperationException( + "The markerless registration journal no longer exists."); + if (journal.ProtocolVersion != FileMutationProtocol.MarkerlessDatabaseState + || journal.Action != action + || !string.Equals( + journal.TargetPhysicalObjectIdentity, + targetPhysicalObjectIdentity, + StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "The markerless registration identity changed before commit."); + } + if (journal.State == FileMutationJournalState.NeedsAttention) + { + throw new InvalidOperationException( + "A markerless registration requiring attention cannot be committed."); + } + if (journal.State < FileMutationJournalState.TargetVerified) + { + throw new InvalidOperationException( + "The markerless registration destination is not verified."); + } + + if (journal.State < FileMutationJournalState.RegistrationCommitted) + { + journal = _fileMutationJournalStore.Advance( + operationId, + FileMutationJournalState.RegistrationCommitted, + targetPhysicalObjectIdentity, + audiobookId, + error: null); + } + else if (!journal.AudiobookId.HasValue) + { + journal = _fileMutationJournalStore.Advance( + operationId, + journal.State, + targetPhysicalObjectIdentity, + audiobookId, + error: null); + } + else if (journal.AudiobookId.Value != audiobookId) + { + throw new InvalidOperationException( + "The markerless registration journal is committed to another audiobook."); + } + + if (action != FileAction.Move + && journal.State < FileMutationJournalState.Completed) + { + journal = _fileMutationJournalStore.Advance( + operationId, + FileMutationJournalState.Completed, + targetPhysicalObjectIdentity, + audiobookId, + error: null); + } + + return journal.State != FileMutationJournalState.NeedsAttention + && (action == FileAction.Move + ? journal.State >= FileMutationJournalState.RegistrationCommitted + : journal.State >= FileMutationJournalState.Completed); + } + + private static void ValidateMarkerlessRegistrationJournal( + FileMutationJournal journal, + FileAction action, + FileMoveGateLease gate) + { + if (journal.ProtocolVersion != FileMutationProtocol.MarkerlessDatabaseState + || journal.Action != action + || !string.Equals( + journal.SourcePath, + Path.GetFullPath(gate.SourcePath), + StringComparison.Ordinal) + || !string.Equals( + journal.DestinationPath, + Path.GetFullPath(gate.DestinationPath), + StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "The durable registration identity does not match the requested operation."); + } + } + + private async Task MarkMarkerlessRegistrationNeedsAttentionAsync( + FileMutationJournal journal, + string reason, + CancellationToken cancellationToken) + { + _ = await _fileMutationJournalStore!.AdvanceAsync( + journal.OperationId, + FileMutationJournalState.NeedsAttention, + journal.TargetPhysicalObjectIdentity, + journal.AudiobookId, + reason, + cancellationToken); + _logger.LogWarning( + "Markerless registration publication {OperationId} requires attention: {Reason}", + journal.OperationId, + reason); + } +} diff --git a/listenarr.infrastructure/FileSystem/FileMover.MarkerlessRegistrationMove.cs b/listenarr.infrastructure/FileSystem/FileMover.MarkerlessRegistrationMove.cs new file mode 100644 index 000000000..309edef89 --- /dev/null +++ b/listenarr.infrastructure/FileSystem/FileMover.MarkerlessRegistrationMove.cs @@ -0,0 +1,192 @@ +using Listenarr.Domain.Audiobooks.Enumerations; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Infrastructure.FileSystem; + +public partial class FileMover +{ + private async Task TryCompletePreparedMoveMarkerlessAsync( + string source, + string destination, + IAudiobookFileRegistrationLease registrationLease, + Guid? operationId) + { + if (!operationId.HasValue || _fileMutationJournalStore == null) + { + return null; + } + if (operationId.Value == Guid.Empty) + { + throw new ArgumentException( + "A markerless registration move requires a non-empty operation ID.", + nameof(operationId)); + } + + var cancellationToken = CancellationToken.None; + var journal = await _fileMutationJournalStore.GetAsync( + operationId.Value, + cancellationToken); + if (journal == null) + { + // Legacy interrupted registration publications predate the database + // journal and must remain recoverable by the old read/retire path. + return null; + } + + if (journal.ProtocolVersion != FileMutationProtocol.MarkerlessDatabaseState + || journal.Action != FileAction.Move + || !string.Equals( + journal.SourcePath, + Path.GetFullPath(source), + StringComparison.Ordinal) + || !string.Equals( + journal.DestinationPath, + Path.GetFullPath(destination), + StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "The markerless registration move identity does not match the requested completion."); + } + if (journal.State == FileMutationJournalState.NeedsAttention) + { + return false; + } + if (journal.State < FileMutationJournalState.RegistrationCommitted + || !journal.AudiobookId.HasValue) + { + _logger.LogWarning( + "Blocked markerless source retirement for {OperationId} because registration is not durably committed.", + journal.OperationId); + return false; + } + if (!string.Equals( + journal.TargetPhysicalObjectIdentity, + registrationLease.PhysicalObjectIdentity, + StringComparison.Ordinal) + || !string.Equals( + journal.SourcePhysicalObjectIdentity, + registrationLease.SourcePhysicalObjectIdentity, + StringComparison.Ordinal) + || !registrationLease.MatchesCurrentPublication()) + { + await MarkMarkerlessRegistrationNeedsAttentionAsync( + journal, + "The registration lease no longer identifies the journaled source and destination generations.", + cancellationToken); + return false; + } + + using var gate = await TryAcquireFileMoveGateAsync( + source, + destination, + allowExistingAliasForRecovery: true); + if (gate == null) + { + return false; + } + + if (!await MarkerlessRegistrationTargetMatchesAsync( + gate, + journal, + cancellationToken) + || !registrationLease.MatchesCurrentPublication()) + { + await MarkMarkerlessRegistrationNeedsAttentionAsync( + journal, + "The registered destination changed before source retirement.", + cancellationToken); + return false; + } + + if (journal.State >= FileMutationJournalState.SourceDeleted) + { + using var recreatedSource = gate.SourceParent.TryOpenExistingFile( + gate.SourceName, + requireDeleteAccess: false); + if (recreatedSource != null) + { + await MarkMarkerlessRegistrationNeedsAttentionAsync( + journal, + "A source path was recreated after the registered source generation was deleted.", + cancellationToken); + return false; + } + } + + if (journal.State < FileMutationJournalState.SourceDeletionAuthorized) + { + journal = await _fileMutationJournalStore.AdvanceAsync( + journal.OperationId, + FileMutationJournalState.SourceDeletionAuthorized, + journal.TargetPhysicalObjectIdentity, + journal.AudiobookId, + error: null, + cancellationToken); + } + + if (journal.State == FileMutationJournalState.SourceDeletionAuthorized) + { + using var sourceEntry = gate.SourceParent.TryOpenExistingFile( + gate.SourceName, + requireDeleteAccess: true); + if (sourceEntry != null) + { + if (!await MatchesMarkerlessSourceProofAsync( + sourceEntry, + journal, + cancellationToken)) + { + await MarkMarkerlessRegistrationNeedsAttentionAsync( + journal, + "The registered move source was replaced before authorized deletion.", + cancellationToken); + return false; + } + + sourceEntry.Delete(immediateWindows: true); + gate.SourceParent.FlushDirectoryEntry(); + if (AfterMarkerlessMoveSourceDeletedBeforeStateForTestAsync != null) + { + await AfterMarkerlessMoveSourceDeletedBeforeStateForTestAsync(); + } + } + + journal = await _fileMutationJournalStore.AdvanceAsync( + journal.OperationId, + FileMutationJournalState.SourceDeleted, + journal.TargetPhysicalObjectIdentity, + journal.AudiobookId, + error: null, + cancellationToken); + } + + using (var recreatedSource = gate.SourceParent.TryOpenExistingFile( + gate.SourceName, + requireDeleteAccess: false)) + { + if (recreatedSource != null) + { + await MarkMarkerlessRegistrationNeedsAttentionAsync( + journal, + "A source path was recreated before the markerless registration move completed.", + cancellationToken); + return false; + } + } + + _ = await _fileMutationJournalStore.AdvanceAsync( + journal.OperationId, + FileMutationJournalState.Completed, + journal.TargetPhysicalObjectIdentity, + journal.AudiobookId, + error: null, + cancellationToken); + LogMutation( + FileMutationOutcome.Success, + FileAction.Move, + source, + destination, + "Retired the database-authorized markerless registration source"); + return true; + } +} diff --git a/listenarr.infrastructure/FileSystem/FileMover.MarkerlessRename.cs b/listenarr.infrastructure/FileSystem/FileMover.MarkerlessRename.cs new file mode 100644 index 000000000..50c6151ab --- /dev/null +++ b/listenarr.infrastructure/FileSystem/FileMover.MarkerlessRename.cs @@ -0,0 +1,310 @@ +using Listenarr.Domain.Audiobooks.Enumerations; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Infrastructure.FileSystem; + +public partial class FileMover +{ + private async Task TryMoveFilePreservingPhysicalIdentityMarkerlessAsync( + string source, + string destination, + string expectedSourcePhysicalObjectIdentity, + Guid? operationId) + { + if (!operationId.HasValue || _fileMutationJournalStore == null) + { + return null; + } + if (operationId.Value == Guid.Empty) + { + throw new ArgumentException( + "A markerless file rename requires a non-empty operation ID.", + nameof(operationId)); + } + + using var pathLock = await TryAcquireFileMoveGateAsync( + source, + destination, + allowExistingAliasForRecovery: true); + if (pathLock == null) + { + return false; + } + + var cancellationToken = CancellationToken.None; + var journal = await _fileMutationJournalStore.GetAsync( + operationId.Value, + cancellationToken); + if (journal == null) + { + using var initialSource = pathLock.SourceParent.TryOpenExistingFile( + pathLock.SourceName, + requireDeleteAccess: true); + using var initialDestination = + pathLock.DestinationParent.TryOpenExistingFile( + pathLock.DestinationName, + requireDeleteAccess: false); + if (initialSource == null + || initialDestination != null + || !initialSource.VisiblePathMatches() + || !string.Equals( + initialSource.GetObjectIdentity(), + expectedSourcePhysicalObjectIdentity, + StringComparison.Ordinal) + || !initialSource.IsOnSameVolume( + pathLock.DestinationParent)) + { + return false; + } + + long sourceLength; + using (var stream = initialSource.OpenReadStream( + bufferSize: 128 * 1024, + asynchronous: false)) + { + sourceLength = stream.Length; + } + journal = await _fileMutationJournalStore.GetOrCreateAsync( + new FileMutationJournalClaim( + operationId.Value, + FileAction.Move, + pathLock.SourcePath, + pathLock.DestinationPath, + expectedSourcePhysicalObjectIdentity, + sourceLength, + SourceSha256: null), + cancellationToken); + if (AfterMarkerlessRenameJournalPlannedForTestAsync != null) + { + await AfterMarkerlessRenameJournalPlannedForTestAsync(); + } + } + else + { + ValidateMarkerlessRenameJournal( + journal, + pathLock, + expectedSourcePhysicalObjectIdentity); + } + + if (journal.State == FileMutationJournalState.NeedsAttention) + { + return false; + } + + using var sourceEntry = pathLock.SourceParent.TryOpenExistingFile( + pathLock.SourceName, + requireDeleteAccess: true); + using var destinationEntry = + pathLock.DestinationParent.TryOpenExistingFile( + pathLock.DestinationName, + requireDeleteAccess: false); + if (sourceEntry != null && destinationEntry != null) + { + await MarkMarkerlessRenameNeedsAttentionAsync( + journal, + "Both source and destination exist for a markerless rename.", + cancellationToken); + return false; + } + if (sourceEntry == null && destinationEntry == null) + { + await MarkMarkerlessRenameNeedsAttentionAsync( + journal, + "Both source and destination are missing for a markerless rename.", + cancellationToken); + return false; + } + + string targetPhysicalObjectIdentity; + if (sourceEntry != null) + { + if (journal.State != FileMutationJournalState.Planned + || !sourceEntry.VisiblePathMatches() + || !string.Equals( + sourceEntry.GetObjectIdentity(), + expectedSourcePhysicalObjectIdentity, + StringComparison.Ordinal) + || !sourceEntry.IsOnSameVolume( + pathLock.DestinationParent)) + { + await MarkMarkerlessRenameNeedsAttentionAsync( + journal, + "The markerless rename source changed or was recreated.", + cancellationToken); + return false; + } + + sourceEntry.MoveTo( + pathLock.DestinationParent, + pathLock.DestinationName); + pathLock.SourceParent.FlushDirectoryEntry(); + if (!string.Equals( + pathLock.SourceParent.FullPath, + pathLock.DestinationParent.FullPath, + StringComparison.Ordinal)) + { + pathLock.DestinationParent.FlushDirectoryEntry(); + } + targetPhysicalObjectIdentity = sourceEntry.GetObjectIdentity(); + if (!sourceEntry.VisiblePathMatches() + || !string.Equals( + targetPhysicalObjectIdentity, + expectedSourcePhysicalObjectIdentity, + StringComparison.Ordinal)) + { + throw new IOException( + "The markerless rename target could not be verified after publication."); + } + if (AfterMarkerlessRenamePublishedBeforeTargetStateForTestAsync != null) + { + await AfterMarkerlessRenamePublishedBeforeTargetStateForTestAsync(); + } + } + else + { + if (destinationEntry == null + || !destinationEntry.VisiblePathMatches()) + { + await MarkMarkerlessRenameNeedsAttentionAsync( + journal, + "The markerless rename destination is unavailable.", + cancellationToken); + return false; + } + targetPhysicalObjectIdentity = destinationEntry.GetObjectIdentity(); + if (!string.Equals( + targetPhysicalObjectIdentity, + expectedSourcePhysicalObjectIdentity, + StringComparison.Ordinal)) + { + await MarkMarkerlessRenameNeedsAttentionAsync( + journal, + "The markerless rename destination identifies another physical file generation.", + cancellationToken); + return false; + } + } + + if (!string.IsNullOrWhiteSpace( + journal.TargetPhysicalObjectIdentity) + && !string.Equals( + journal.TargetPhysicalObjectIdentity, + targetPhysicalObjectIdentity, + StringComparison.Ordinal)) + { + await MarkMarkerlessRenameNeedsAttentionAsync( + journal, + "The markerless rename target changed after publication.", + cancellationToken); + return false; + } + + if (journal.State < FileMutationJournalState.TargetIdentityPersisted) + { + journal = await _fileMutationJournalStore.AdvanceAsync( + operationId.Value, + FileMutationJournalState.TargetIdentityPersisted, + targetPhysicalObjectIdentity, + audiobookId: null, + error: null, + cancellationToken); + if (AfterMarkerlessRenameTargetStateForTestAsync != null) + { + await AfterMarkerlessRenameTargetStateForTestAsync(); + } + } + if (journal.State < FileMutationJournalState.TargetVerified) + { + journal = await _fileMutationJournalStore.AdvanceAsync( + operationId.Value, + FileMutationJournalState.TargetVerified, + targetPhysicalObjectIdentity, + audiobookId: null, + error: null, + cancellationToken); + } + if (journal.State < FileMutationJournalState.SourceDeletionAuthorized) + { + journal = await _fileMutationJournalStore.AdvanceAsync( + operationId.Value, + FileMutationJournalState.SourceDeletionAuthorized, + targetPhysicalObjectIdentity, + audiobookId: null, + error: null, + cancellationToken); + } + if (journal.State < FileMutationJournalState.SourceDeleted) + { + journal = await _fileMutationJournalStore.AdvanceAsync( + operationId.Value, + FileMutationJournalState.SourceDeleted, + targetPhysicalObjectIdentity, + audiobookId: null, + error: null, + cancellationToken); + } + if (journal.State < FileMutationJournalState.Completed) + { + _ = await _fileMutationJournalStore.AdvanceAsync( + operationId.Value, + FileMutationJournalState.Completed, + targetPhysicalObjectIdentity, + audiobookId: null, + error: null, + cancellationToken); + } + + LogMutation( + FileMutationOutcome.Success, + FileAction.Move, + source, + destination, + "Markerless generation-preserving rename"); + return true; + } + + private static void ValidateMarkerlessRenameJournal( + FileMutationJournal journal, + FileMoveGateLease pathLock, + string expectedSourcePhysicalObjectIdentity) + { + if (journal.ProtocolVersion + != FileMutationProtocol.MarkerlessDatabaseState + || journal.Action != FileAction.Move + || !string.Equals( + journal.SourcePath, + Path.GetFullPath(pathLock.SourcePath), + StringComparison.Ordinal) + || !string.Equals( + journal.DestinationPath, + Path.GetFullPath(pathLock.DestinationPath), + StringComparison.Ordinal) + || !string.Equals( + journal.SourcePhysicalObjectIdentity, + expectedSourcePhysicalObjectIdentity, + StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "The durable markerless rename identity does not match the requested operation."); + } + } + + private async Task MarkMarkerlessRenameNeedsAttentionAsync( + FileMutationJournal journal, + string reason, + CancellationToken cancellationToken) + { + _ = await _fileMutationJournalStore!.AdvanceAsync( + journal.OperationId, + FileMutationJournalState.NeedsAttention, + journal.TargetPhysicalObjectIdentity, + journal.AudiobookId, + reason, + cancellationToken); + _logger.LogWarning( + "Markerless file rename {OperationId} requires attention: {Reason}", + journal.OperationId, + reason); + } +} diff --git a/listenarr.infrastructure/FileSystem/FileMover.Move.cs b/listenarr.infrastructure/FileSystem/FileMover.Move.cs index b51b7e8fd..e75f1bd3c 100644 --- a/listenarr.infrastructure/FileSystem/FileMover.Move.cs +++ b/listenarr.infrastructure/FileSystem/FileMover.Move.cs @@ -48,6 +48,17 @@ public async Task MoveFilePreservingPhysicalIdentityAsync( } } + var markerlessResult = + await TryMoveFilePreservingPhysicalIdentityMarkerlessAsync( + source, + destination, + expectedSourcePhysicalObjectIdentity, + operationId); + if (markerlessResult.HasValue) + { + return markerlessResult.Value; + } + using var pathLock = await TryAcquireFileMoveGateAsync( source, destination); @@ -114,6 +125,15 @@ internal async Task MoveFileAsync( return true; } + var markerlessResult = await TryMoveFileMarkerlessAsync( + sourceFile, + destFile, + operationId); + if (markerlessResult.HasValue) + { + return markerlessResult.Value; + } + using var pathLock = await TryAcquireFileMoveGateAsync( sourceFile, destFile); diff --git a/listenarr.infrastructure/FileSystem/FileMover.PreparedMove.cs b/listenarr.infrastructure/FileSystem/FileMover.PreparedMove.cs index 2ab59d7db..0e09ab64a 100644 --- a/listenarr.infrastructure/FileSystem/FileMover.PreparedMove.cs +++ b/listenarr.infrastructure/FileSystem/FileMover.PreparedMove.cs @@ -19,6 +19,16 @@ public async Task CompletePreparedMoveAsync( try { + var markerlessResult = await TryCompletePreparedMoveMarkerlessAsync( + source, + destination, + registrationLease, + operationId); + if (markerlessResult.HasValue) + { + return markerlessResult.Value; + } + if (!registrationLease.MatchesCurrentPublication()) { return false; diff --git a/listenarr.infrastructure/FileSystem/FileMover.TestHooks.cs b/listenarr.infrastructure/FileSystem/FileMover.TestHooks.cs index 79edf47ac..f8a009678 100644 --- a/listenarr.infrastructure/FileSystem/FileMover.TestHooks.cs +++ b/listenarr.infrastructure/FileSystem/FileMover.TestHooks.cs @@ -25,6 +25,54 @@ internal Action? AfterUncommittedRegistrationDestinationRetiredForTest init; } internal Func? AfterPreparedMoveSourceDeletedForTestAsync { get; init; } + internal Func? AfterMarkerlessRenameJournalPlannedForTestAsync + { + get; + init; + } + internal Func? + AfterMarkerlessRenamePublishedBeforeTargetStateForTestAsync + { + get; + init; + } + internal Func? AfterMarkerlessRenameTargetStateForTestAsync + { + get; + init; + } + internal Func? AfterMarkerlessMoveJournalPlannedForTestAsync + { + get; + init; + } + internal Func? + AfterMarkerlessMovePublishedBeforeTargetStateForTestAsync + { + get; + init; + } + internal Func? AfterMarkerlessMoveTargetCreatedBeforeStateForTestAsync + { + get; + init; + } + internal Func? AfterMarkerlessMoveTargetStateForTestAsync + { + get; + init; + } + internal Func? + AfterMarkerlessMoveTargetWrittenBeforeVerifiedStateForTestAsync + { + get; + init; + } + internal Func? AfterMarkerlessMoveSourceDeletedBeforeStateForTestAsync + { + get; + init; + } internal bool DisableNativeFileRenameForTest { get; init; } internal Action? BeforeFileMoveDurabilityBarrierForTest { get; init; } internal Action? AfterDirectoryRenameJournalPublishedForTest { get; init; } diff --git a/listenarr.infrastructure/FileSystem/FileMover.cs b/listenarr.infrastructure/FileSystem/FileMover.cs index 3c25d51a9..d6ce14074 100644 --- a/listenarr.infrastructure/FileSystem/FileMover.cs +++ b/listenarr.infrastructure/FileSystem/FileMover.cs @@ -18,6 +18,8 @@ using System.Runtime.InteropServices; using System.Diagnostics.CodeAnalysis; using Listenarr.Domain.Audiobooks.Enumerations; +using Listenarr.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using Microsoft.Extensions.Logging; @@ -54,6 +56,7 @@ public partial class FileMover : IFileMover private readonly ILogger _logger; private readonly IFileSystemSemanticsResolver _semanticsResolver; + private readonly IFileMutationJournalStore? _fileMutationJournalStore; internal Func? AfterSourceStateCreatedForTestAsync { get; init; } internal Func? AfterSourceQuarantinedForTestAsync { get; init; } @@ -75,12 +78,19 @@ public FileMover( ILogger logger, IProcessRunner? processRunner = null, IOptions? options = null, - IFileSystemSemanticsResolver? semanticsResolver = null) + IFileSystemSemanticsResolver? semanticsResolver = null, + IDbContextFactory? dbContextFactory = null, + TimeProvider? timeProvider = null) { _logger = logger; _ = processRunner; _ = options; _semanticsResolver = semanticsResolver ?? new FileSystemSemanticsResolver(); + _fileMutationJournalStore = dbContextFactory == null + ? null + : new EfFileMutationJournalStore( + dbContextFactory, + timeProvider ?? TimeProvider.System); } public async Task MoveDirectoryAsync(string sourceDir, string destDir) diff --git a/listenarr.infrastructure/FileSystem/FileMutationJournalStore.cs b/listenarr.infrastructure/FileSystem/FileMutationJournalStore.cs new file mode 100644 index 000000000..21937c9e6 --- /dev/null +++ b/listenarr.infrastructure/FileSystem/FileMutationJournalStore.cs @@ -0,0 +1,452 @@ +using Listenarr.Domain.Audiobooks.Enumerations; +using Listenarr.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace Listenarr.Infrastructure.FileSystem; + +internal sealed record FileMutationJournalClaim( + Guid OperationId, + FileAction Action, + string SourcePath, + string DestinationPath, + string SourcePhysicalObjectIdentity, + long SourceLength, + string? SourceSha256); + +internal interface IFileMutationJournalStore +{ + Task GetOrCreateAsync( + FileMutationJournalClaim claim, + CancellationToken cancellationToken); + + Task GetAsync( + Guid operationId, + CancellationToken cancellationToken); + + FileMutationJournal? Get(Guid operationId); + + Task AdvanceAsync( + Guid operationId, + FileMutationJournalState state, + string? targetPhysicalObjectIdentity, + int? audiobookId, + string? error, + CancellationToken cancellationToken); + + FileMutationJournal Advance( + Guid operationId, + FileMutationJournalState state, + string? targetPhysicalObjectIdentity, + int? audiobookId, + string? error); +} + +internal sealed class EfFileMutationJournalStore( + IDbContextFactory dbContextFactory, + TimeProvider timeProvider) : IFileMutationJournalStore +{ + internal Func? AfterAdvanceLoadedForTestAsync { get; set; } + + public async Task GetOrCreateAsync( + FileMutationJournalClaim claim, + CancellationToken cancellationToken) + { + ValidateClaim(claim); + var canonicalSource = Path.GetFullPath(claim.SourcePath); + var canonicalDestination = Path.GetFullPath(claim.DestinationPath); + await using var db = + await dbContextFactory.CreateDbContextAsync(cancellationToken); + var existing = await db.FileMutationJournals + .SingleOrDefaultAsync( + journal => journal.OperationId == claim.OperationId, + cancellationToken); + if (existing != null) + { + ValidateIdentity(existing, claim, canonicalSource, canonicalDestination); + return existing; + } + + var now = timeProvider.GetUtcNow().UtcDateTime; + var journal = new FileMutationJournal + { + OperationId = claim.OperationId, + ProtocolVersion = FileMutationProtocol.MarkerlessDatabaseState, + Action = claim.Action, + SourcePath = canonicalSource, + DestinationPath = canonicalDestination, + SourcePhysicalObjectIdentity = claim.SourcePhysicalObjectIdentity, + SourceLength = claim.SourceLength, + SourceSha256 = claim.SourceSha256, + State = FileMutationJournalState.Planned, + CreatedAt = now, + UpdatedAt = now + }; + db.FileMutationJournals.Add(journal); + try + { + await db.SaveChangesAsync(cancellationToken); + return journal; + } + catch (UniqueConstraintViolationException) + { + db.Entry(journal).State = EntityState.Detached; + existing = await db.FileMutationJournals + .SingleAsync( + candidate => candidate.OperationId == claim.OperationId, + cancellationToken); + ValidateIdentity(existing, claim, canonicalSource, canonicalDestination); + return existing; + } + } + + public async Task GetAsync( + Guid operationId, + CancellationToken cancellationToken) + { + if (operationId == Guid.Empty) + { + throw new ArgumentException( + "A file-mutation operation ID must not be empty.", + nameof(operationId)); + } + + await using var db = + await dbContextFactory.CreateDbContextAsync(cancellationToken); + return await db.FileMutationJournals + .AsNoTracking() + .SingleOrDefaultAsync( + journal => journal.OperationId == operationId, + cancellationToken); + } + + public FileMutationJournal? Get(Guid operationId) + { + if (operationId == Guid.Empty) + { + throw new ArgumentException( + "A file-mutation operation ID must not be empty.", + nameof(operationId)); + } + + using var db = dbContextFactory.CreateDbContext(); + return db.FileMutationJournals + .AsNoTracking() + .SingleOrDefault(journal => journal.OperationId == operationId); + } + + public async Task AdvanceAsync( + Guid operationId, + FileMutationJournalState state, + string? targetPhysicalObjectIdentity, + int? audiobookId, + string? error, + CancellationToken cancellationToken) + { + ValidateAdvanceRequest( + operationId, + state, + targetPhysicalObjectIdentity, + audiobookId); + for (var attempt = 0; attempt < 3; attempt++) + { + await using var db = + await dbContextFactory.CreateDbContextAsync(cancellationToken); + var journal = await db.FileMutationJournals + .AsNoTracking() + .SingleOrDefaultAsync( + candidate => candidate.OperationId == operationId, + cancellationToken) + ?? throw new InvalidOperationException( + "The durable file-mutation journal does not exist."); + var expected = CaptureMutableState(journal); + ApplyAdvance( + journal, + state, + targetPhysicalObjectIdentity, + audiobookId, + error); + if (AfterAdvanceLoadedForTestAsync != null) + { + await AfterAdvanceLoadedForTestAsync(); + } + + if (await TryPersistAdvanceAsync( + db, + journal, + expected, + cancellationToken)) + { + return journal; + } + } + + throw new InvalidOperationException( + "The file-mutation journal changed concurrently too many times."); + } + + public FileMutationJournal Advance( + Guid operationId, + FileMutationJournalState state, + string? targetPhysicalObjectIdentity, + int? audiobookId, + string? error) + { + ValidateAdvanceRequest( + operationId, + state, + targetPhysicalObjectIdentity, + audiobookId); + for (var attempt = 0; attempt < 3; attempt++) + { + using var db = dbContextFactory.CreateDbContext(); + var journal = db.FileMutationJournals + .AsNoTracking() + .SingleOrDefault(candidate => candidate.OperationId == operationId) + ?? throw new InvalidOperationException( + "The durable file-mutation journal does not exist."); + var expected = CaptureMutableState(journal); + ApplyAdvance( + journal, + state, + targetPhysicalObjectIdentity, + audiobookId, + error); + if (TryPersistAdvance(db, journal, expected)) + { + return journal; + } + } + + throw new InvalidOperationException( + "The file-mutation journal changed concurrently too many times."); + } + + private static FileMutationJournalMutableState CaptureMutableState( + FileMutationJournal journal) => + new( + journal.State, + journal.TargetPhysicalObjectIdentity, + journal.AudiobookId, + journal.Error); + + private static async Task TryPersistAdvanceAsync( + ListenArrDbContext db, + FileMutationJournal journal, + FileMutationJournalMutableState expected, + CancellationToken cancellationToken) + { + if (!db.Database.IsRelational()) + { + db.FileMutationJournals.Update(journal); + return await db.SaveChangesAsync(cancellationToken) == 1; + } + + var affected = await db.FileMutationJournals + .Where(candidate => candidate.OperationId == journal.OperationId + && candidate.State == expected.State + && candidate.TargetPhysicalObjectIdentity + == expected.TargetPhysicalObjectIdentity + && candidate.AudiobookId == expected.AudiobookId + && candidate.Error == expected.Error) + .ExecuteUpdateAsync( + setters => setters + .SetProperty( + candidate => candidate.State, + journal.State) + .SetProperty( + candidate => candidate.TargetPhysicalObjectIdentity, + journal.TargetPhysicalObjectIdentity) + .SetProperty( + candidate => candidate.AudiobookId, + journal.AudiobookId) + .SetProperty( + candidate => candidate.Error, + journal.Error) + .SetProperty( + candidate => candidate.UpdatedAt, + journal.UpdatedAt), + cancellationToken); + return affected == 1; + } + + private static bool TryPersistAdvance( + ListenArrDbContext db, + FileMutationJournal journal, + FileMutationJournalMutableState expected) + { + if (!db.Database.IsRelational()) + { + db.FileMutationJournals.Update(journal); + return db.SaveChanges() == 1; + } + + var affected = db.FileMutationJournals + .Where(candidate => candidate.OperationId == journal.OperationId + && candidate.State == expected.State + && candidate.TargetPhysicalObjectIdentity + == expected.TargetPhysicalObjectIdentity + && candidate.AudiobookId == expected.AudiobookId + && candidate.Error == expected.Error) + .ExecuteUpdate(setters => setters + .SetProperty( + candidate => candidate.State, + journal.State) + .SetProperty( + candidate => candidate.TargetPhysicalObjectIdentity, + journal.TargetPhysicalObjectIdentity) + .SetProperty( + candidate => candidate.AudiobookId, + journal.AudiobookId) + .SetProperty( + candidate => candidate.Error, + journal.Error) + .SetProperty( + candidate => candidate.UpdatedAt, + journal.UpdatedAt)); + return affected == 1; + } + + private sealed record FileMutationJournalMutableState( + FileMutationJournalState State, + string? TargetPhysicalObjectIdentity, + int? AudiobookId, + string? Error); + + private static void ValidateAdvanceRequest( + Guid operationId, + FileMutationJournalState state, + string? targetPhysicalObjectIdentity, + int? audiobookId) + { + if (operationId == Guid.Empty) + { + throw new ArgumentException( + "A file-mutation operation ID must not be empty.", + nameof(operationId)); + } + if (state >= FileMutationJournalState.TargetIdentityPersisted + && state != FileMutationJournalState.NeedsAttention + && string.IsNullOrWhiteSpace(targetPhysicalObjectIdentity)) + { + throw new ArgumentException( + "A persisted target generation is required for this file-mutation state.", + nameof(targetPhysicalObjectIdentity)); + } + if (audiobookId <= 0) + { + throw new ArgumentOutOfRangeException(nameof(audiobookId)); + } + } + + private void ApplyAdvance( + FileMutationJournal journal, + FileMutationJournalState state, + string? targetPhysicalObjectIdentity, + int? audiobookId, + string? error) + { + if (journal.ProtocolVersion + != FileMutationProtocol.MarkerlessDatabaseState) + { + throw new InvalidOperationException( + "The durable file-mutation journal uses an unsupported protocol."); + } + if (journal.State == FileMutationJournalState.NeedsAttention + && state != FileMutationJournalState.NeedsAttention) + { + throw new InvalidOperationException( + "A file mutation requiring attention cannot resume automatically."); + } + if (state != FileMutationJournalState.NeedsAttention + && state < journal.State) + { + throw new InvalidOperationException( + "A file-mutation state transition would regress durable state."); + } + if (!string.IsNullOrWhiteSpace(journal.TargetPhysicalObjectIdentity) + && !string.IsNullOrWhiteSpace(targetPhysicalObjectIdentity) + && !string.Equals( + journal.TargetPhysicalObjectIdentity, + targetPhysicalObjectIdentity, + StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "The file-mutation target changed physical generation."); + } + if (journal.AudiobookId.HasValue + && audiobookId.HasValue + && journal.AudiobookId != audiobookId) + { + throw new InvalidOperationException( + "The file-mutation registration owner changed."); + } + + journal.TargetPhysicalObjectIdentity ??= + targetPhysicalObjectIdentity; + journal.AudiobookId ??= audiobookId; + if (state > journal.State + || state == FileMutationJournalState.NeedsAttention) + { + journal.State = state; + } + journal.Error = error; + journal.UpdatedAt = timeProvider.GetUtcNow().UtcDateTime; + } + + private static void ValidateClaim(FileMutationJournalClaim claim) + { + if (claim.OperationId == Guid.Empty) + { + throw new ArgumentException( + "A file-mutation operation ID must not be empty.", + nameof(claim)); + } + ArgumentException.ThrowIfNullOrWhiteSpace(claim.SourcePath); + ArgumentException.ThrowIfNullOrWhiteSpace(claim.DestinationPath); + ArgumentException.ThrowIfNullOrWhiteSpace( + claim.SourcePhysicalObjectIdentity); + if (claim.SourceLength < 0) + { + throw new ArgumentOutOfRangeException(nameof(claim)); + } + if (claim.SourceSha256 is { Length: > 0 } + && claim.SourceSha256.Length != 64) + { + throw new ArgumentException( + "A file-mutation SHA-256 proof must contain 64 hexadecimal characters.", + nameof(claim)); + } + } + + private static void ValidateIdentity( + FileMutationJournal journal, + FileMutationJournalClaim claim, + string canonicalSource, + string canonicalDestination) + { + if (journal.ProtocolVersion + != FileMutationProtocol.MarkerlessDatabaseState + || journal.Action != claim.Action + || !string.Equals( + journal.SourcePath, + canonicalSource, + StringComparison.Ordinal) + || !string.Equals( + journal.DestinationPath, + canonicalDestination, + StringComparison.Ordinal) + || !string.Equals( + journal.SourcePhysicalObjectIdentity, + claim.SourcePhysicalObjectIdentity, + StringComparison.Ordinal) + || journal.SourceLength != claim.SourceLength + || !string.Equals( + journal.SourceSha256, + claim.SourceSha256, + StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "The operation ID is already bound to another file-mutation identity."); + } + } +} diff --git a/listenarr.infrastructure/FileSystem/FileSystemSemanticsResolver.cs b/listenarr.infrastructure/FileSystem/FileSystemSemanticsResolver.cs index 39610019a..dbeef77db 100644 --- a/listenarr.infrastructure/FileSystem/FileSystemSemanticsResolver.cs +++ b/listenarr.infrastructure/FileSystem/FileSystemSemanticsResolver.cs @@ -1,13 +1,26 @@ +using System.ComponentModel; +using System.Runtime.InteropServices; +using Microsoft.Win32.SafeHandles; using Listenarr.Domain.Common; namespace Listenarr.Infrastructure.FileSystem; public sealed class FileSystemSemanticsResolver : IFileSystemSemanticsResolver { - private const string ProbePrefix = ".listenarr-case-probe-"; - - internal Action? BeforeProbeForTest { get; init; } - internal Action? AfterPrimaryProbeCreatedForTest { get; init; } + private const uint FileReadAttributes = 0x0080; + private const uint FileShareRead = 0x00000001; + private const uint FileShareWrite = 0x00000002; + private const uint FileShareDelete = 0x00000004; + private const uint OpenExisting = 3; + private const uint FileFlagBackupSemantics = 0x02000000; + private const int FileCaseSensitiveInfo = 23; + private const uint FileCsFlagCaseSensitiveDir = 0x00000001; + + private const int OpenReadOnly = 0; + private const int OpenDirectory = 0x10000; + private const int OpenCloseOnExec = 0x80000; + private const ulong FsIocGetFlags = 0x80086601; + private const int FsCasefoldFlag = 0x40000000; public ValueTask ResolveAsync( string path, @@ -18,7 +31,9 @@ public ValueTask ResolveAsync( ArgumentException.ThrowIfNullOrWhiteSpace(path); if (!Path.IsPathFullyQualified(path)) { - throw new ArgumentException("Filesystem semantics require an absolute path.", nameof(path)); + throw new ArgumentException( + "Filesystem semantics require an absolute path.", + nameof(path)); } var syntax = OperatingSystem.IsWindows() @@ -41,108 +56,122 @@ public ValueTask ResolveAsync( var boundary = FindExistingBoundary(fullPath); if (boundary == null) { - return ValueTask.FromResult(Unavailable(syntax, fullPath, "No existing filesystem boundary could be found.")); + return ValueTask.FromResult(Unavailable( + syntax, + fullPath, + "No existing filesystem boundary could be found.")); } - BeforeProbeForTest?.Invoke(boundary); - var resolved = Probe(boundary, syntax); - return ValueTask.FromResult(resolved with { CanonicalPath = fullPath }); + var resolution = ResolveReadOnly(boundary, syntax); + return ValueTask.FromResult(resolution with { CanonicalPath = fullPath }); } - private FileSystemSemanticsResolution Probe( + private static FileSystemSemanticsResolution ResolveReadOnly( string boundary, FileSystemPathSyntax syntax) { - var probeName = ProbePrefix + Guid.NewGuid().ToString("N") + "-a"; - var alternateName = probeName.ToUpperInvariant(); - PinnedDirectoryCreation.PinnedFileEntry? primary = null; - PinnedDirectoryCreation.PinnedFileEntry? alternate = null; - var alternateCreated = false; - try + if (OperatingSystem.IsWindows()) { - using var pinnedBoundary = - PinnedDirectoryCreation.OpenPinnedBoundary(boundary); - primary = pinnedBoundary.CreateNewFile(probeName); - AfterPrimaryProbeCreatedForTest?.Invoke( - primary.FullPath, - Path.Join(boundary, alternateName)); - - try - { - alternate = pinnedBoundary.CreateNewFile(alternateName); - alternateCreated = true; - if (!pinnedBoundary.VisiblePathMatches() - || !primary.VisiblePathMatches() - || !alternate.VisiblePathMatches()) - { - return Unavailable( - syntax, - boundary, - "Filesystem case-sensitivity probe entries changed during classification."); - } - - return new FileSystemSemanticsResolution( - new FileSystemPathSemantics( - syntax, - FileSystemCaseSensitivity.Sensitive), - PathIdentityState.Valid, - boundary); - } - catch (Exception exception) when ( - exception is InvalidOperationException - || exception is System.ComponentModel.Win32Exception - { - NativeErrorCode: 17 - }) - { - alternate = pinnedBoundary.TryOpenExistingFile( - alternateName, - requireDeleteAccess: false); - if (alternate == null - || !pinnedBoundary.VisiblePathMatches() - || !primary.VisiblePathMatches() - || !alternate.VisiblePathMatches() - || !primary.IdentifiesSameEntry(alternate) - || primary.GetLinkCount() != 1 - || !pinnedBoundary.VisiblePathMatches() - || !primary.VisiblePathMatches() - || !alternate.VisiblePathMatches()) - { - return Unavailable( - syntax, - boundary, - "Filesystem case-sensitivity probe collision could not be attributed to the created entry."); - } - - return new FileSystemSemanticsResolution( - new FileSystemPathSemantics( - syntax, - FileSystemCaseSensitivity.Insensitive), - PathIdentityState.Valid, - boundary); - } + return ResolveWindows(boundary, syntax); + } + + if (OperatingSystem.IsLinux()) + { + return ResolveLinux(boundary, syntax); } - catch (Exception exception) when (exception is - IOException or UnauthorizedAccessException or InvalidOperationException - or System.ComponentModel.Win32Exception) + + return Unavailable( + syntax, + boundary, + "Automatic case-sensitivity detection is unavailable on this host without writing a probe. Select Sensitive or Insensitive explicitly."); + } + + private static FileSystemSemanticsResolution ResolveWindows( + string boundary, + FileSystemPathSyntax syntax) + { + using var handle = CreateFileWindows( + boundary, + FileReadAttributes, + FileShareRead | FileShareWrite | FileShareDelete, + IntPtr.Zero, + OpenExisting, + FileFlagBackupSemantics, + IntPtr.Zero); + if (handle.IsInvalid) { return Unavailable( syntax, boundary, - $"Filesystem case sensitivity could not be probed: {exception.GetType().Name}."); + $"Filesystem case sensitivity could not be read: {new Win32Exception(Marshal.GetLastWin32Error()).Message}"); } - finally + + if (!GetFileInformationByHandleEx( + handle, + FileCaseSensitiveInfo, + out var info, + (uint)Marshal.SizeOf())) { - if (alternateCreated && alternate != null) + var error = Marshal.GetLastWin32Error(); + // Older Windows/filesystems do not expose per-directory case-sensitive + // mode. Their Win32 namespace is case-insensitive. + if (error is 1 or 50 or 87) { - TryDeleteProbe(alternate); + return Valid( + syntax, + boundary, + FileSystemCaseSensitivity.Insensitive); } - alternate?.Dispose(); - if (primary != null) + + return Unavailable( + syntax, + boundary, + $"Filesystem case sensitivity could not be read: {new Win32Exception(error).Message}"); + } + + return Valid( + syntax, + boundary, + (info.Flags & FileCsFlagCaseSensitiveDir) != 0 + ? FileSystemCaseSensitivity.Sensitive + : FileSystemCaseSensitivity.Insensitive); + } + + private static FileSystemSemanticsResolution ResolveLinux( + string boundary, + FileSystemPathSyntax syntax) + { + var descriptor = OpenUnix( + boundary, + OpenReadOnly | OpenDirectory | OpenCloseOnExec); + if (descriptor < 0) + { + return Unavailable( + syntax, + boundary, + $"Filesystem case sensitivity could not be read: {new Win32Exception(Marshal.GetLastWin32Error()).Message}"); + } + + try + { + if (IoctlUnix(descriptor, FsIocGetFlags, out var flags) == 0) { - TryDeleteProbe(primary); + return Valid( + syntax, + boundary, + (flags & FsCasefoldFlag) != 0 + ? FileSystemCaseSensitivity.Insensitive + : FileSystemCaseSensitivity.Sensitive); } - primary?.Dispose(); + + return Unavailable( + syntax, + boundary, + "The filesystem does not expose read-only case-sensitivity metadata. Select Sensitive or Insensitive explicitly."); + } + finally + { + _ = CloseUnix(descriptor); } } @@ -162,37 +191,63 @@ IOException or UnauthorizedAccessException or InvalidOperationException return null; } + private static FileSystemSemanticsResolution Valid( + FileSystemPathSyntax syntax, + string boundary, + FileSystemCaseSensitivity sensitivity) => + new( + new FileSystemPathSemantics(syntax, sensitivity), + PathIdentityState.Valid, + boundary); + private static FileSystemSemanticsResolution Unavailable( FileSystemPathSyntax syntax, string boundary, - string reason) - { - return new FileSystemSemanticsResolution( - new FileSystemPathSemantics(syntax, FileSystemCaseSensitivity.Unknown), + string reason) => + new( + new FileSystemPathSemantics( + syntax, + FileSystemCaseSensitivity.Unknown), PathIdentityState.Unavailable, boundary, reason, boundary); - } - private static void TryDeleteProbe( - PinnedDirectoryCreation.PinnedFileEntry probe) + [StructLayout(LayoutKind.Sequential)] + private struct FileCaseSensitiveInformation { - try - { - if (probe.VisiblePathMatches()) - { - probe.Delete(immediateWindows: true); - } - } - catch (Exception exception) when (exception is - IOException or UnauthorizedAccessException or InvalidOperationException - or System.ComponentModel.Win32Exception) - { - System.Diagnostics.Trace.TraceWarning( - "Failed to remove filesystem case-sensitivity probe {0}: {1}", - probe.FullPath, - exception.Message); - } + public uint Flags; } + + [DllImport("kernel32.dll", EntryPoint = "CreateFileW", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern SafeFileHandle CreateFileWindows( + string fileName, + uint desiredAccess, + uint shareMode, + IntPtr securityAttributes, + uint creationDisposition, + uint flagsAndAttributes, + IntPtr templateFile); + + [DllImport("kernel32.dll", EntryPoint = "GetFileInformationByHandleEx", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GetFileInformationByHandleEx( + SafeFileHandle fileHandle, + int fileInformationClass, + out FileCaseSensitiveInformation fileInformation, + uint bufferSize); + + [DllImport("libc", EntryPoint = "open", SetLastError = true)] + private static extern int OpenUnix( + [MarshalAs(UnmanagedType.LPUTF8Str)] string path, + int flags); + + [DllImport("libc", EntryPoint = "ioctl", SetLastError = true)] + private static extern int IoctlUnix( + int descriptor, + ulong request, + out int flags); + + [DllImport("libc", EntryPoint = "close", SetLastError = true)] + private static extern int CloseUnix(int descriptor); } diff --git a/listenarr.infrastructure/FileSystem/ManagedDirectoryEnrollment.cs b/listenarr.infrastructure/FileSystem/ManagedDirectoryEnrollment.cs index f9114e665..660906538 100644 --- a/listenarr.infrastructure/FileSystem/ManagedDirectoryEnrollment.cs +++ b/listenarr.infrastructure/FileSystem/ManagedDirectoryEnrollment.cs @@ -1,8 +1,10 @@ -using System.ComponentModel; using System.Text.Json; namespace Listenarr.Infrastructure.FileSystem; +// Compatibility reader/retirer for the short-lived marker-backed root identity +// format. New code must never publish this file; root physical identity is persisted +// in SQLite and verified against the pinned OS-native directory generation. internal static class ManagedDirectoryEnrollment { internal const string FileName = ".listenarr-root-enrollment.json"; @@ -11,138 +13,85 @@ internal static class ManagedDirectoryEnrollment private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); - internal static async Task ResolveAsync( + internal static DirectoryObjectIdentityResolution ResolveExisting( PinnedDirectoryCreation.PinnedDirectoryAnchor anchor, - string nativeIdentity, - bool enrollIfMissing, - CancellationToken cancellationToken) + string nativeIdentity) { ArgumentNullException.ThrowIfNull(anchor); ArgumentException.ThrowIfNullOrWhiteSpace(nativeIdentity); - cancellationToken.ThrowIfCancellationRequested(); var existing = TryRead(anchor, nativeIdentity, out var markerMissing); - if (existing != null || !markerMissing || !enrollIfMissing) - { - return existing - ?? DirectoryObjectIdentityResolution.Unavailable( - markerMissing - ? "The managed directory enrollment marker is missing." - : "The managed directory enrollment marker is invalid or identifies a different physical directory."); - } + return existing + ?? DirectoryObjectIdentityResolution.Unavailable( + markerMissing + ? "The legacy managed-directory enrollment marker is missing." + : "The legacy managed-directory enrollment marker is invalid or identifies a different physical directory."); + } - var token = Guid.NewGuid().ToString("N"); - var payload = new EnrollmentPayload( - MarkerVersion, - token, - nativeIdentity, - DateTimeOffset.UtcNow); - var temporaryName = - $"{FileName}.{Guid.NewGuid():N}.tmp"; - try + internal static void RetireValidMarker( + PinnedDirectoryCreation.PinnedDirectoryAnchor anchor) + { + ArgumentNullException.ThrowIfNull(anchor); + var nativeIdentity = anchor.GetDirectoryObjectIdentity(); + var current = TryRead(anchor, nativeIdentity, out var markerMissing); + if (markerMissing) { - await anchor.PublishNewFileAsync( - temporaryName, - FileName, - beforeCreateAsync: () => Task.CompletedTask, - writeAndFlushAsync: async stream => - { - await JsonSerializer.SerializeAsync( - stream, - payload, - JsonOptions, - cancellationToken); - await stream.FlushAsync(cancellationToken); - stream.Flush(flushToDisk: true); - }, - beforePublicationAsync: () => Task.CompletedTask, - preserveTemporaryFileOnFailure: _ => false); - anchor.FlushDirectoryEntry(); + return; } - catch (Exception exception) when (exception is - IOException or UnauthorizedAccessException or Win32Exception - or InvalidOperationException) + if (current == null) { - var raced = TryRead(anchor, nativeIdentity, out _); - if (raced != null) - { - return raced; - } - - return DirectoryObjectIdentityResolution.Unavailable( - $"The managed directory could not be enrolled safely: {exception.Message}"); + throw new InvalidOperationException( + "The legacy managed-directory enrollment marker is invalid and was preserved."); } - var enrolled = TryRead(anchor, nativeIdentity, out _); - return enrolled == null - ? DirectoryObjectIdentityResolution.Unavailable( - "The managed directory enrollment could not be verified after publication.") - : enrolled with { EnrollmentCreated = true }; + RetireVerifiedMarker(anchor, nativeIdentity, current.Value!); } - internal static async Task RequireMatchingEnrollmentAsync( + internal static bool TryRetireMatchingLegacyMarker( PinnedDirectoryCreation.PinnedDirectoryAnchor anchor, int? expectedVersion, - string? expectedValue, - string? unavailableReason, - CancellationToken cancellationToken) + string? expectedValue) { ArgumentNullException.ThrowIfNull(anchor); if (expectedVersion != ManagedDirectoryIdentity.CurrentVersion - || string.IsNullOrWhiteSpace(expectedValue) - || !string.IsNullOrWhiteSpace(unavailableReason)) - { - throw new InvalidOperationException( - "The managed directory has no usable Listenarr enrollment identity."); - } - - cancellationToken.ThrowIfCancellationRequested(); - var nativeIdentity = anchor.GetDirectoryObjectIdentity(); - var current = await ResolveAsync( - anchor, - nativeIdentity, - enrollIfMissing: false, - cancellationToken); - if (!current.IsAvailable - || current.Version != expectedVersion - || !string.Equals( - current.Value, - expectedValue, - StringComparison.Ordinal) - || !anchor.VisiblePathMatches()) + || string.IsNullOrWhiteSpace(expectedValue)) { - throw new InvalidOperationException( - "The managed directory no longer identifies its enrolled physical generation."); + return false; } - return nativeIdentity; - } - - internal static void RetireValidMarker( - PinnedDirectoryCreation.PinnedDirectoryAnchor anchor) - { - ArgumentNullException.ThrowIfNull(anchor); var nativeIdentity = anchor.GetDirectoryObjectIdentity(); var current = TryRead(anchor, nativeIdentity, out var markerMissing); if (markerMissing) { - return; + return false; } - if (current == null) + if (current == null + || current.Version != expectedVersion + || !string.Equals(current.Value, expectedValue, StringComparison.Ordinal)) { - throw new InvalidOperationException( - "The managed directory enrollment marker is invalid and was preserved."); + return false; } + RetireVerifiedMarker(anchor, nativeIdentity, expectedValue); + return true; + } + + private static void RetireVerifiedMarker( + PinnedDirectoryCreation.PinnedDirectoryAnchor anchor, + string nativeIdentity, + string expectedValue) + { using var marker = anchor.OpenExistingFile( FileName, requireDeleteAccess: true); - if (TryRead(anchor, nativeIdentity, out _) == null + var current = TryRead(anchor, nativeIdentity, out _); + if (current == null + || !string.Equals(current.Value, expectedValue, StringComparison.Ordinal) || !marker.VisiblePathMatches() || !anchor.VisiblePathMatches()) { throw new InvalidOperationException( - "The managed directory enrollment marker changed before retirement."); + "The legacy managed-directory enrollment marker changed before retirement."); } marker.Delete(); diff --git a/listenarr.infrastructure/FileSystem/ManagedDirectoryIdentity.cs b/listenarr.infrastructure/FileSystem/ManagedDirectoryIdentity.cs index 189dd896c..9518215ed 100644 --- a/listenarr.infrastructure/FileSystem/ManagedDirectoryIdentity.cs +++ b/listenarr.infrastructure/FileSystem/ManagedDirectoryIdentity.cs @@ -24,9 +24,40 @@ internal static string Create(string token, string nativeIdentity) { ArgumentException.ThrowIfNullOrWhiteSpace(token); ArgumentException.ThrowIfNullOrWhiteSpace(nativeIdentity); - var nativeHash = Convert.ToHexString( - SHA256.HashData(Encoding.UTF8.GetBytes(nativeIdentity))) - .ToLowerInvariant(); - return FormattableString.Invariant($"{Prefix}:{token}:{nativeHash}"); + return FormattableString.Invariant( + $"{Prefix}:{token}:{HashNativeIdentity(nativeIdentity)}"); } + + internal static string CreateMarkerless(string nativeIdentity) + { + ArgumentException.ThrowIfNullOrWhiteSpace(nativeIdentity); + var nativeHash = HashNativeIdentity(nativeIdentity); + return FormattableString.Invariant( + $"{Prefix}:{nativeHash[..32]}:{nativeHash}"); + } + + internal static bool MatchesNativeIdentity( + int? version, + string? value, + string nativeIdentity) + { + if (version != CurrentVersion || string.IsNullOrWhiteSpace(value)) + { + return false; + } + + var parts = value.Split(':'); + return parts.Length == 3 + && string.Equals(parts[0], Prefix, StringComparison.Ordinal) + && Guid.TryParseExact(parts[1], "N", out _) + && string.Equals( + parts[2], + HashNativeIdentity(nativeIdentity), + StringComparison.Ordinal); + } + + private static string HashNativeIdentity(string nativeIdentity) => + Convert.ToHexString( + SHA256.HashData(Encoding.UTF8.GetBytes(nativeIdentity))) + .ToLowerInvariant(); } diff --git a/listenarr.infrastructure/FileSystem/PinnedAudiobookFileRegistrationLease.cs b/listenarr.infrastructure/FileSystem/PinnedAudiobookFileRegistrationLease.cs index 99147dc98..e0dcc543e 100644 --- a/listenarr.infrastructure/FileSystem/PinnedAudiobookFileRegistrationLease.cs +++ b/listenarr.infrastructure/FileSystem/PinnedAudiobookFileRegistrationLease.cs @@ -8,8 +8,11 @@ internal sealed class PinnedAudiobookFileRegistrationLease : private readonly PinnedDirectoryCreation.PinnedFileEntry _file; private readonly Microsoft.Win32.SafeHandles.SafeFileHandle? _stableHandle; private readonly Func? _prepareCleanupRecovery; + private readonly Func? _commitRegistration; private readonly Func? _completePublication; + private int? _cleanupRecoveryAudiobookId; private bool _cleanupRecoveryPrepared; + private bool _registrationCommitted; private bool _publicationCompleted; private bool _disposed; @@ -21,11 +24,13 @@ private PinnedAudiobookFileRegistrationLease( string physicalObjectIdentity, string? sourcePhysicalObjectIdentity, Func? prepareCleanupRecovery, - Func? completePublication) + Func? completePublication, + Func? commitRegistration) { _file = file; _stableHandle = stableHandle; _prepareCleanupRecovery = prepareCleanupRecovery; + _commitRegistration = commitRegistration; _completePublication = completePublication; PublicPath = publicPath; MetadataPath = metadataPath; @@ -59,7 +64,8 @@ internal static PinnedAudiobookFileRegistrationLease Open( string? expectedPhysicalObjectIdentity = null, string? sourcePhysicalObjectIdentity = null, Func? prepareCleanupRecovery = null, - Func? completePublication = null) + Func? completePublication = null, + Func? commitRegistration = null) { ArgumentException.ThrowIfNullOrWhiteSpace(publicPath); var canonicalPath = Path.GetFullPath(publicPath); @@ -77,7 +83,8 @@ internal static PinnedAudiobookFileRegistrationLease Open( expectedPhysicalObjectIdentity, sourcePhysicalObjectIdentity, prepareCleanupRecovery, - completePublication); + completePublication, + commitRegistration); } internal static PinnedAudiobookFileRegistrationLease Create( @@ -86,7 +93,8 @@ internal static PinnedAudiobookFileRegistrationLease Create( string? expectedPhysicalObjectIdentity = null, string? sourcePhysicalObjectIdentity = null, Func? prepareCleanupRecovery = null, - Func? completePublication = null) + Func? completePublication = null, + Func? commitRegistration = null) { ArgumentNullException.ThrowIfNull(file); ArgumentException.ThrowIfNullOrWhiteSpace(publicPath); @@ -116,7 +124,8 @@ internal static PinnedAudiobookFileRegistrationLease Create( physicalObjectIdentity, sourcePhysicalObjectIdentity, prepareCleanupRecovery, - completePublication); + completePublication, + commitRegistration); } if (OperatingSystem.IsLinux()) @@ -138,7 +147,8 @@ internal static PinnedAudiobookFileRegistrationLease Create( physicalObjectIdentity, sourcePhysicalObjectIdentity, prepareCleanupRecovery, - completePublication); + completePublication, + commitRegistration); stableHandle = null; return result; } @@ -238,6 +248,14 @@ public bool PrepareCleanupRecovery(int audiobookId) { throw new ArgumentOutOfRangeException(nameof(audiobookId)); } + if (_cleanupRecoveryAudiobookId.HasValue + && _cleanupRecoveryAudiobookId.Value != audiobookId) + { + throw new InvalidOperationException( + "The registration lease is already bound to another audiobook."); + } + + _cleanupRecoveryAudiobookId = audiobookId; if (_cleanupRecoveryPrepared || _prepareCleanupRecovery == null) { _cleanupRecoveryPrepared = true; @@ -255,12 +273,26 @@ public RegistrationPublicationCompletion CompletePublication() { return RegistrationPublicationCompletion.Completed; } - if (_prepareCleanupRecovery != null && !_cleanupRecoveryPrepared) + if ((_prepareCleanupRecovery != null || _commitRegistration != null) + && !_cleanupRecoveryPrepared) { throw new InvalidOperationException( "Durable cleanup recovery must be prepared before publication is completed."); } + if (!_registrationCommitted && _commitRegistration != null) + { + var audiobookId = _cleanupRecoveryAudiobookId + ?? throw new InvalidOperationException( + "The registration lease has no durable audiobook owner."); + if (!_commitRegistration(audiobookId)) + { + return RegistrationPublicationCompletion.CommittedCleanupPending; + } + + _registrationCommitted = true; + } + if (_completePublication != null && !_completePublication()) { return RegistrationPublicationCompletion.CommittedCleanupPending; diff --git a/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.FileMove.cs b/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.FileMove.cs index dfb773a85..bcebf06b1 100644 --- a/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.FileMove.cs +++ b/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.FileMove.cs @@ -343,28 +343,6 @@ internal PinnedFileEntry CreateHardLinkTo( } } - internal void PreserveMetadataTo(PinnedFileEntry destination) - { - ThrowIfDisposed(); - ArgumentNullException.ThrowIfNull(destination); - destination.ThrowIfDisposed(); - if (!OperatingSystem.IsWindows()) - { - File.SetUnixFileMode( - destination._fileHandle, - File.GetUnixFileMode(_fileHandle)); - } - File.SetAttributes( - destination._fileHandle, - File.GetAttributes(_fileHandle)); - File.SetLastWriteTimeUtc( - destination._fileHandle, - File.GetLastWriteTimeUtc(_fileHandle)); - File.SetCreationTimeUtc( - destination._fileHandle, - File.GetCreationTimeUtc(_fileHandle)); - } - internal async Task MatchesAsync( long expectedLength, string? expectedSha256, diff --git a/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.FileOpenWindows.cs b/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.FileOpenWindows.cs index bf075e4e0..e0b4a7644 100644 --- a/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.FileOpenWindows.cs +++ b/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.FileOpenWindows.cs @@ -132,6 +132,68 @@ private static SafeFileHandle OpenRelativeFileStableReadWindows( } } + private static SafeFileHandle OpenRelativeFileVerificationLeaseWindows( + SafeFileHandle parentHandle, + string fileName, + string fullPath) + { + var nameBuffer = Marshal.StringToHGlobalUni(fileName); + var unicodeStringPointer = IntPtr.Zero; + try + { + var unicodeString = new UnicodeString + { + Length = checked((ushort)(fileName.Length * sizeof(char))), + MaximumLength = checked((ushort)((fileName.Length + 1) * sizeof(char))), + Buffer = nameBuffer + }; + unicodeStringPointer = Marshal.AllocHGlobal(Marshal.SizeOf()); + Marshal.StructureToPtr(unicodeString, unicodeStringPointer, fDeleteOld: false); + var attributes = new ObjectAttributes + { + Length = (uint)Marshal.SizeOf(), + RootDirectory = parentHandle.DangerousGetHandle(), + ObjectName = unicodeStringPointer + }; + var status = NtCreateFile( + out var rawHandle, + GenericRead | Synchronize, + ref attributes, + out _, + IntPtr.Zero, + fileAttributes: 0, + FileShareRead | FileShareDelete, + FileOpen, + FileNonDirectoryFile | FileSynchronousIoNonAlert | FileOpenReparsePoint, + IntPtr.Zero, + 0); + if (status < 0) + { + throw CreateNtOpenException(status, fullPath); + } + + var handle = new SafeFileHandle(rawHandle, ownsHandle: true); + try + { + EnsureFileHandleIsNotReparsePoint(handle, fullPath); + return handle; + } + catch + { + handle.Dispose(); + throw; + } + } + finally + { + if (unicodeStringPointer != IntPtr.Zero) + { + Marshal.FreeHGlobal(unicodeStringPointer); + } + Marshal.FreeHGlobal(nameBuffer); + } + } + private static SafeFileHandle OpenRelativeFileStableDeleteWindows( SafeFileHandle parentHandle, string fileName, diff --git a/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.FileVerificationLease.cs b/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.FileVerificationLease.cs new file mode 100644 index 000000000..8dce1d140 --- /dev/null +++ b/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.FileVerificationLease.cs @@ -0,0 +1,37 @@ +namespace Listenarr.Infrastructure.FileSystem; + +internal sealed partial class PinnedDirectoryCreation +{ + internal sealed partial class PinnedDirectoryAnchor + { + internal PinnedFileEntry OpenExistingFileForVerificationLease( + string fileName) + { + ThrowIfDisposed(); + ValidateLeafName(fileName); + var fullPath = Path.Join(FullPath, fileName); + ExclusiveDirectoryCreator.InvokeBeforeOpenParentHook(fullPath); + EnsureVisiblePathMatches(); + var handle = OperatingSystem.IsWindows() + ? OpenRelativeFileVerificationLeaseWindows( + _handle, + fileName, + fullPath) + : OpenRelativeFileUnix(_handle, fileName, fullPath); + var entry = new PinnedFileEntry( + DuplicateSafeHandle(_handle), + handle, + FullPath, + fileName, + _followVisibleFinalLink); + if (entry.VisiblePathMatches()) + { + return entry; + } + + entry.Dispose(); + throw new InvalidOperationException( + "The file changed while its verification lease was being opened."); + } + } +} diff --git a/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.MarkerlessMetadata.cs b/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.MarkerlessMetadata.cs new file mode 100644 index 000000000..90a6bb543 --- /dev/null +++ b/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.MarkerlessMetadata.cs @@ -0,0 +1,198 @@ +using System.ComponentModel; +using System.Runtime.InteropServices; +using Microsoft.Win32.SafeHandles; + +namespace Listenarr.Infrastructure.FileSystem; + +internal sealed partial class PinnedDirectoryCreation +{ + private static SafeFileHandle OpenRelativeFileForMetadataWindows( + SafeFileHandle parentHandle, + string fileName, + string fullPath) + { + var nameBuffer = Marshal.StringToHGlobalUni(fileName); + var unicodeStringPointer = IntPtr.Zero; + try + { + var unicodeString = new UnicodeString + { + Length = checked((ushort)(fileName.Length * sizeof(char))), + MaximumLength = checked((ushort)((fileName.Length + 1) * sizeof(char))), + Buffer = nameBuffer + }; + unicodeStringPointer = Marshal.AllocHGlobal( + Marshal.SizeOf()); + Marshal.StructureToPtr( + unicodeString, + unicodeStringPointer, + fDeleteOld: false); + var attributes = new ObjectAttributes + { + Length = (uint)Marshal.SizeOf(), + RootDirectory = parentHandle.DangerousGetHandle(), + ObjectName = unicodeStringPointer + }; + var status = NtCreateFile( + out var rawHandle, + FileReadAttributes | FileWriteAttributes | Synchronize, + ref attributes, + out _, + IntPtr.Zero, + fileAttributes: 0, + FileShareAll, + FileOpen, + FileNonDirectoryFile | FileSynchronousIoNonAlert + | FileOpenReparsePoint, + IntPtr.Zero, + 0); + if (status < 0) + { + throw CreateNtOpenException(status, fullPath); + } + + var handle = new SafeFileHandle(rawHandle, ownsHandle: true); + try + { + EnsureFileHandleIsNotReparsePoint(handle, fullPath); + return handle; + } + catch + { + handle.Dispose(); + throw; + } + } + finally + { + if (unicodeStringPointer != IntPtr.Zero) + { + Marshal.FreeHGlobal(unicodeStringPointer); + } + Marshal.FreeHGlobal(nameBuffer); + } + } + + internal sealed partial class PinnedFileEntry + { + internal bool MatchesMetadata( + long expectedLength, + DateTime expectedLastWriteTimeUtc) + { + ThrowIfDisposed(); + using var stream = OpenReadStream( + bufferSize: 1, + asynchronous: false); + return stream.Length == expectedLength + && File.GetLastWriteTimeUtc(_fileHandle) == expectedLastWriteTimeUtc; + } + + internal void PreserveMetadataTo(PinnedFileEntry destination) + { + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(destination); + destination.ThrowIfDisposed(); + if (!OperatingSystem.IsWindows()) + { + File.SetUnixFileMode( + destination._fileHandle, + File.GetUnixFileMode(_fileHandle)); + } + File.SetAttributes( + destination._fileHandle, + File.GetAttributes(_fileHandle)); + File.SetLastWriteTimeUtc( + destination._fileHandle, + File.GetLastWriteTimeUtc(_fileHandle)); + File.SetCreationTimeUtc( + destination._fileHandle, + File.GetCreationTimeUtc(_fileHandle)); + } + + internal void PreserveMarkerlessMetadataTo(PinnedFileEntry destination) + { + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(destination); + destination.ThrowIfDisposed(); + if (OperatingSystem.IsWindows()) + { + PreserveMarkerlessMetadataWindows(destination); + return; + } + + File.SetUnixFileMode( + destination._fileHandle, + File.GetUnixFileMode(_fileHandle)); + File.SetAttributes( + destination._fileHandle, + File.GetAttributes(_fileHandle)); + File.SetLastWriteTimeUtc( + destination._fileHandle, + File.GetLastWriteTimeUtc(_fileHandle)); + } + + private void PreserveMarkerlessMetadataWindows( + PinnedFileEntry destination) + { + using var metadataHandle = OpenRelativeFileForMetadataWindows( + destination._parentHandle, + destination._fileName, + destination.FullPath); + if (!HandlesIdentifySameDirectory( + destination._fileHandle, + metadataHandle)) + { + throw new InvalidOperationException( + "The markerless destination changed before metadata preservation."); + } + + if (!GetFileBasicInformationByHandleEx( + _fileHandle, + FileInformationClass.FileBasicInfo, + out var sourceInformation, + (uint)Marshal.SizeOf())) + { + throw new Win32Exception( + Marshal.GetLastWin32Error(), + "Could not read markerless source metadata from the pinned file handle."); + } + + if (!GetFileBasicInformationByHandleEx( + metadataHandle, + FileInformationClass.FileBasicInfo, + out var destinationInformation, + (uint)Marshal.SizeOf())) + { + throw new Win32Exception( + Marshal.GetLastWin32Error(), + "Could not read markerless destination metadata from the pinned file handle."); + } + + destinationInformation.LastWriteTime = sourceInformation.LastWriteTime; + destinationInformation.FileAttributes = sourceInformation.FileAttributes; + var buffer = Marshal.AllocHGlobal( + Marshal.SizeOf()); + try + { + Marshal.StructureToPtr( + destinationInformation, + buffer, + fDeleteOld: false); + if (!SetFileInformationByHandle( + metadataHandle, + FileInformationClass.FileBasicInfo, + buffer, + (uint)Marshal.SizeOf())) + { + throw new Win32Exception( + Marshal.GetLastWin32Error(), + "Could not preserve markerless destination metadata on the pinned file handle."); + } + } + finally + { + Marshal.FreeHGlobal(buffer); + } + } + } +} diff --git a/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.NativeOperations.Platform.cs b/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.NativeOperations.Platform.cs index 669f9006c..65b275982 100644 --- a/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.NativeOperations.Platform.cs +++ b/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.NativeOperations.Platform.cs @@ -77,7 +77,8 @@ private static int CreateRelativeWindows( var desiredAccess = directory ? FileListDirectory | FileReadAttributes | Synchronize | (requireDirectoryDeleteAccess ? DeleteAccess : 0u) - : GenericRead | GenericWrite | DeleteAccess | Synchronize; + : GenericRead | GenericWrite | FileReadAttributes + | FileWriteAttributes | DeleteAccess | Synchronize; var createOptions = (directory ? FileDirectoryFile : FileNonDirectoryFile) | FileSynchronousIoNonAlert | FileOpenReparsePoint; diff --git a/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.cs b/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.cs index c243a747f..8591ca315 100644 --- a/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.cs +++ b/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.cs @@ -20,12 +20,14 @@ internal sealed partial class PinnedDirectoryCreation : IDisposable private const uint FileListDirectory = 0x0001; private const uint FileReadAttributes = 0x0080; + private const uint FileWriteAttributes = 0x0100; private const uint Synchronize = 0x00100000; private const uint GenericRead = 0x80000000; private const uint GenericWrite = 0x40000000; private const uint DeleteAccess = 0x00010000; private const uint FileShareRead = 0x00000001; private const uint FileShareReadWrite = 0x00000003; + private const uint FileShareDelete = 0x00000004; private const uint FileShareAll = 0x00000007; private const uint OpenExisting = 3; private const uint FileFlagBackupSemantics = 0x02000000; @@ -216,34 +218,16 @@ private static PinnedDirectoryCreation TryCreateUnix( string childName) { var parentHandle = OpenDirectoryUnix(parentPath, noFollow: true); - var temporaryName = $".listenarr-create-{Guid.NewGuid():N}"; SafeFileHandle? directoryHandle = null; - var temporaryExists = false; try { ExclusiveDirectoryCreator.InvokeBeforeCreateHook(Path.Join(parentPath, childName)); var parentFd = parentHandle.DangerousGetHandle().ToInt32(); - if (MkdirAt(parentFd, temporaryName, UnixDirectoryMode) != 0) - { - throw new Win32Exception( - Marshal.GetLastWin32Error(), - $"Could not create a pinned temporary directory beneath '{parentPath}'."); - } - temporaryExists = true; - - directoryHandle = OpenDirectoryAtUnix(parentHandle, temporaryName); - var renameResult = OperatingSystem.IsMacOS() - ? RenameAtExclusiveMac(parentFd, temporaryName, parentFd, childName, RenameExclusiveMac) - : RenameAtNoReplaceLinux(parentFd, temporaryName, parentFd, childName, RenameNoReplace); - if (renameResult != 0) + if (MkdirAt(parentFd, childName, UnixDirectoryMode) != 0) { var error = Marshal.GetLastWin32Error(); if (error == UnixAlreadyExists) { - directoryHandle.Dispose(); - directoryHandle = null; - RemoveDirectoryAtUnix(parentHandle, temporaryName); - temporaryExists = false; return new PinnedDirectoryCreation( parentHandle, directoryHandle: null, @@ -255,25 +239,30 @@ private static PinnedDirectoryCreation TryCreateUnix( throw new Win32Exception( error, - $"Could not publish a pinned directory beneath '{parentPath}'."); + $"Could not create the requested directory beneath '{parentPath}'."); } - temporaryExists = false; - return new PinnedDirectoryCreation( + directoryHandle = OpenDirectoryAtUnix(parentHandle, childName); + var created = new PinnedDirectoryCreation( parentHandle, directoryHandle, parentPath, childName, created: true, parentFollowsVisibleFinalLink: false); + directoryHandle = null; + if (!created.VisiblePathMatches()) + { + created.Dispose(); + throw new InvalidOperationException( + "The newly created directory changed before it could be pinned."); + } + + return created; } catch { directoryHandle?.Dispose(); - if (temporaryExists) - { - TryRemoveDirectoryAtUnix(parentHandle, temporaryName); - } parentHandle.Dispose(); throw; } diff --git a/listenarr.infrastructure/FileSystem/RootDirectoryObjectIdentity.cs b/listenarr.infrastructure/FileSystem/RootDirectoryObjectIdentity.cs new file mode 100644 index 000000000..60c1ddc53 --- /dev/null +++ b/listenarr.infrastructure/FileSystem/RootDirectoryObjectIdentity.cs @@ -0,0 +1 @@ +namespace Listenarr.Infrastructure.FileSystem; diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.Copy.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.Copy.cs index 5afd13997..f1a1ba9a5 100644 --- a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.Copy.cs +++ b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.Copy.cs @@ -103,7 +103,8 @@ private void ValidateExistingDestinationContents( ValidatedTempOwnership? tempOwnership = null, ValidatedQuarantineOwnership? quarantineOwnership = null, bool allowPartialFiles = true, - LibraryDirectoryOwnership? targetDirectoryOwnership = null) + LibraryDirectoryOwnership? targetDirectoryOwnership = null, + bool allowRecoveryMarker = true) { if (!Directory.Exists(destinationRoot)) { @@ -189,7 +190,11 @@ private void ValidateExistingDestinationContents( file, targetDirectoryOwnership, targetSemantics) - || FileSystemPathIdentity.AreEquivalent(file, markerPath, targetSemantics) + || (allowRecoveryMarker + && FileSystemPathIdentity.AreEquivalent( + file, + markerPath, + targetSemantics)) || (tempOwnership != null && FileSystemPathIdentity.AreEquivalent( file, diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.DirectoryOwnership.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.DirectoryOwnership.cs index 67cca76db..fa7d17fdd 100644 --- a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.DirectoryOwnership.cs +++ b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.DirectoryOwnership.cs @@ -53,16 +53,7 @@ private async Task WithValidatedTargetDirectoryOwne "Durable target-directory ownership does not match the exact move target."); } - try - { - LibraryDirectoryOwnershipMarker.Validate(ownership, target); - } - catch (InvalidOperationException exception) - { - throw new MoveNeedsAttentionException( - $"The target-directory ownership marker is invalid: {exception.Message}"); - } - + RevalidateTargetDirectoryOwnership(ownership); return ownership; } @@ -76,14 +67,31 @@ private static void RevalidateTargetDirectoryOwnership( try { - LibraryDirectoryOwnershipMarker.Validate( - ownership, - ownership.CanonicalPath); + var parentPath = Path.GetDirectoryName(ownership.CanonicalPath) + ?? throw new InvalidOperationException( + "The target ownership path has no parent directory."); + using var parent = PinnedDirectoryCreation.OpenPinnedBoundary(parentPath); + using var directory = parent.OpenExistingChild( + Path.GetFileName(ownership.CanonicalPath)); + if (!ManagedDirectoryIdentity.Matches( + ownership.DirectoryObjectIdentityVersion, + ownership.DirectoryObjectIdentity, + ownership.OwnershipToken, + directory.GetDirectoryObjectIdentity()) + || !directory.VisiblePathMatches() + || !parent.VisiblePathMatches()) + { + throw new InvalidOperationException( + "The target directory no longer matches its persisted physical ownership generation."); + } } - catch (InvalidOperationException exception) + catch (Exception exception) when (exception is + ArgumentException or IOException or UnauthorizedAccessException + or InvalidOperationException or NotSupportedException + or PathTooLongException or System.ComponentModel.Win32Exception) { throw new MoveNeedsAttentionException( - $"The target-directory ownership marker changed: {exception.Message}"); + $"The target-directory ownership changed: {exception.Message}"); } } @@ -126,18 +134,6 @@ private async Task> LoadValidatedOwnedS throw new MoveNeedsAttentionException( "A durably owned source directory is missing."); } - - try - { - LibraryDirectoryOwnershipMarker.Validate( - ownership, - ownership.CanonicalPath); - } - catch (InvalidOperationException exception) - { - throw new MoveNeedsAttentionException( - $"A source-directory ownership marker is invalid: {exception.Message}"); - } } return ownerships; @@ -177,10 +173,6 @@ private async Task> LoadOwnedSourceDire throw new InvalidOperationException( "A durably owned source directory is missing without a removal intent."); } - - LibraryDirectoryOwnershipMarker.Validate( - ownership, - ownership.CanonicalPath); } catch (InvalidOperationException exception) { @@ -323,23 +315,7 @@ await ResumeOwnedDirectoryRemovalAsync( continue; } - try - { - LibraryDirectoryOwnershipMarker.Validate( - current, - current.CanonicalPath); - } - catch (InvalidOperationException exception) - { - throw new MoveNeedsAttentionException( - $"A source-directory ownership marker changed before cleanup: {exception.Message}"); - } - - var insideMarker = Path.Join( - current.CanonicalPath, - LibraryDirectoryOwnershipMarker.FileName); var remainingEntries = Directory.EnumerateFileSystemEntries(current.CanonicalPath) - .Where(entry => !string.Equals(entry, insideMarker, StringComparison.Ordinal)) .Take(1) .ToList(); if (remainingEntries.Count != 0) diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.FaultInjection.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.FaultInjection.cs index ee01b7c96..4fc252b79 100644 --- a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.FaultInjection.cs +++ b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.FaultInjection.cs @@ -39,7 +39,9 @@ internal enum SourceCleanupFaultPoint BeforeEmptySourceDirectoryQuarantine, AfterEmptySourceDirectoryQuarantine, BeforeEmptySourceClaimDelete, - BeforeEmptySourceStateDelete + BeforeEmptySourceStateDelete, + AfterMarkerlessSourceFileDeleteBeforeStateUpdate, + AfterMarkerlessSourceFileStateUpdate } internal enum CopyMutationFaultPoint @@ -47,7 +49,11 @@ internal enum CopyMutationFaultPoint BeforeCopyRootValidation, BeforePartialFileCreation, AfterChunkWritten, - BeforePartialPublication + BeforePartialPublication, + AfterMarkerlessFileCreationBeforeStateUpdate, + AfterMarkerlessFileStateUpdate, + AfterMarkerlessFileWriteBeforePublishedState, + AfterMarkerlessNativeRenameBeforeStateUpdate } internal enum AtomicRenameFaultPoint @@ -80,7 +86,9 @@ internal enum CompletedArtifactCleanupFaultPoint internal enum TargetScaffoldPreparationFaultPoint { BeforePublication, - AfterPublication + AfterPublication, + AfterMarkerlessDirectoryCreationBeforeStateUpdate, + AfterMarkerlessDirectoryStateUpdate } internal enum TargetScaffoldCleanupFaultPoint @@ -114,6 +122,7 @@ internal enum FinalizedVerificationFaultPoint internal interface IMoveFaultInjector { bool AllowAtomicRename => false; + bool AllowMarkerlessFileRename => false; Task AfterPublishedAsync(Guid jobId, CancellationToken cancellationToken) => Task.CompletedTask; diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.Finalization.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.Finalization.cs index 7b979dd07..63cf85703 100644 --- a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.Finalization.cs +++ b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.Finalization.cs @@ -7,7 +7,6 @@ * by the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. */ -using Listenarr.Domain.Common; namespace Listenarr.Infrastructure.Library.Moving; @@ -57,6 +56,22 @@ await RemoveEmptySourceAncestorsAsync( cancellationToken); } + if (await GetExecutionProtocolVersionAsync( + request.JobId, + cancellationToken) + >= MoveExecutionProtocol.MarkerlessDatabaseState) + { + var manifest = await LoadManifestAsync( + request.JobId, + cancellationToken); + VerifySourceCleanupState( + request, + result.Source, + result.Target, + manifest); + return; + } + var tempOwnership = await TryValidatePublishedTempOwnershipAsync( result.Target, request, @@ -106,6 +121,54 @@ await ValidatePersistedMoveIdentityAsync( result.Target, manifest, request.TargetSemantics); + if (await GetExecutionProtocolVersionAsync( + request.JobId, + cancellationToken) + >= MoveExecutionProtocol.MarkerlessDatabaseState) + { + try + { + await VerifyMarkerlessTargetAsync( + request, + result.Target, + manifest, + cancellationToken, + progressStart: 92, + progressSpan: 5, + progressPhase: "Final verification", + targetVerificationLease: result.TargetVerificationLease); + VerifySourceCleanupState( + request, + result.Source, + result.Target, + manifest); + await UpdateJobPhaseAsync( + request.JobId, + request.LeaseToken, + MoveJobPhase.CleaningArtifacts, + cancellationToken); + foreach (var directory in await GetCreatedDirectoriesAsync( + request.JobId, + cancellationToken)) + { + if (directory.State == MoveCreatedDirectoryState.Created) + { + await UpdateCreatedDirectoryStateAsync( + request.JobId, + request.LeaseToken, + directory.Path, + MoveCreatedDirectoryState.Retained, + cancellationToken); + } + } + } + finally + { + result.TargetVerificationLease?.Dispose(); + } + return; + } + var publishedTempOwnership = await TryValidatePublishedTempOwnershipAsync( result.Target, request, @@ -267,220 +330,4 @@ await UpdateJobPhaseAsync( MoveJobPhase.RecordingCompletion, cancellationToken); } - - private async Task RemoveEmptyDirectoryTreeAsync( - AudiobookContentMoveRequest request, - string source, - string target, - string directory, - string boundary, - FileSystemPathSemantics semantics, - CancellationToken cancellationToken) - { - var current = directory; - while (Directory.Exists(current) - && !FileSystemPathIdentity.AreEquivalent( - current, - boundary, - semantics)) - { - if (!FileSystemSafety.TryValidateMutationTarget( - current, - [boundary], - out current, - out var reason)) - { - throw new MoveNeedsAttentionException(reason); - } - - var ownership = await ResolveOwnedDirectoryForCleanupAsync( - current, - semantics, - cancellationToken); - if (ownership == null) - { - return; - } - if (ownership.State == LibraryDirectoryOwnershipState.Removing) - { - var interruptedRemovalCompleted = await ResumeOwnedDirectoryRemovalAsync( - request, - source, - target, - ownership, - cancellationToken); - if (!interruptedRemovalCompleted) - { - return; - } - - current = Path.GetDirectoryName(current) ?? boundary; - continue; - } - - ValidateExistingMoveDirectory(current, "source ancestor cleanup directory"); - if (!LibraryDirectoryOwnershipMarker.ContainsOnlyInsideMarker( - ownership, - current)) - { - return; - } - - faultInjector?.OnMoveFinalization( - request.JobId, - MoveFinalizationFaultPoint.BeforeSourceAncestorDelete); - await EnsureMutationAuthorizedAsync(request, source, target, cancellationToken); - var finalOwnership = await ResolveOwnedDirectoryForCleanupAsync( - current, - semantics, - cancellationToken); - if (finalOwnership == null - || finalOwnership.Id != ownership.Id - || !string.Equals( - finalOwnership.PathOwnershipKey, - ownership.PathOwnershipKey, - StringComparison.Ordinal)) - { - throw new MoveNeedsAttentionException( - "The durable directory ownership claim changed before source-parent cleanup."); - } - - ValidateExistingMoveDirectory(current, "source ancestor cleanup directory"); - if (!LibraryDirectoryOwnershipMarker.ContainsOnlyInsideMarker( - finalOwnership, - current)) - { - return; - } - - var ownershipKey = finalOwnership.PathOwnershipKey - ?? throw new MoveNeedsAttentionException( - "The durable directory ownership key is unavailable."); - await directoryOwnershipStore.BeginRemovalAsync( - finalOwnership.Id, - ownershipKey, - cancellationToken); - var removalCompleted = await ResumeOwnedDirectoryRemovalAsync( - request, - source, - target, - finalOwnership, - cancellationToken); - if (!removalCompleted) - { - return; - } - - current = Path.GetDirectoryName(current) ?? boundary; - } - } - - private static bool IsSourceCleanupBoundary( - string path, - string? boundary, - FileSystemPathSemantics semantics) - { - if (string.IsNullOrWhiteSpace(boundary)) - { - return false; - } - - try - { - return FileSystemPathIdentity.AreEquivalent(path, boundary, semantics); - } - catch (Exception exception) when (exception is - ArgumentException or InvalidOperationException or NotSupportedException or PathTooLongException) - { - throw new MoveNeedsAttentionException( - $"The source cleanup boundary is invalid: {exception.Message}"); - } - } - - private async Task RemoveEmptySourceAncestorsAsync( - AudiobookContentMoveRequest request, - string source, - string target, - string? boundary, - FileSystemPathSemantics semantics, - CancellationToken cancellationToken) - { - if (string.IsNullOrWhiteSpace(boundary)) - { - return; - } - - var fullBoundary = Path.GetFullPath(boundary); - var current = Path.GetDirectoryName(Path.GetFullPath(source)); - while (current != null - && FileSystemPathIdentity.IsSameOrInside(current, fullBoundary, semantics)) - { - if (FileSystemPathIdentity.AreEquivalent(current, fullBoundary, semantics)) - { - return; - } - - if (Directory.Exists(current)) - { - await RemoveEmptyDirectoryTreeAsync( - request, - source, - target, - current, - fullBoundary, - semantics, - cancellationToken); - return; - } - - var ownership = await ResolveOwnedDirectoryForCleanupAsync( - current, - semantics, - cancellationToken); - if (ownership != null) - { - if (ownership.State != LibraryDirectoryOwnershipState.Removing) - { - throw new MoveNeedsAttentionException( - "An owned source-parent directory disappeared without a durable cleanup intent."); - } - - await ResumeOwnedDirectoryRemovalAsync( - request, - source, - target, - ownership, - cancellationToken); - } - - current = Path.GetDirectoryName(current); - } - } - - private async Task ResolveOwnedDirectoryForCleanupAsync( - string directory, - FileSystemPathSemantics semantics, - CancellationToken cancellationToken) - { - var resolution = await directoryOwnershipStore.ResolveOwnedAsync( - directory, - semantics, - cancellationToken); - return resolution.State switch - { - LibraryDirectoryOwnershipResolutionState.Owned - when resolution.Ownership != null => resolution.Ownership, - LibraryDirectoryOwnershipResolutionState.Unowned => null, - LibraryDirectoryOwnershipResolutionState.Conflict => - throw new MoveNeedsAttentionException( - resolution.Reason - ?? "Conflicting durable directory ownership claims prevent cleanup."), - LibraryDirectoryOwnershipResolutionState.Unavailable => - throw new MoveNeedsAttentionException( - resolution.Reason - ?? "Durable directory ownership is unavailable for cleanup."), - _ => throw new MoveNeedsAttentionException( - "Durable directory ownership could not be resolved for cleanup.") - }; - } } diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.Manifest.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.Manifest.cs index 525f5bac1..a30d62725 100644 --- a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.Manifest.cs +++ b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.Manifest.cs @@ -128,12 +128,40 @@ internal static async Task VerifyPublishedManifestAsync( continue; } - if (!File.Exists(destinationPath) - || new FileInfo(destinationPath).Length != entry.Length - || !string.Equals( - await ComputeSha256Async(destinationPath, cancellationToken), - entry.Sha256, - StringComparison.Ordinal)) + if (!File.Exists(destinationPath)) + { + throw new MoveNeedsAttentionException( + $"Published file verification failed: {entry.RelativePath}"); + } + + var parentPath = Path.GetDirectoryName(destinationPath) + ?? throw new MoveNeedsAttentionException( + "A published manifest file has no parent directory."); + using var parent = PinnedDirectoryCreation.OpenPinnedBoundary(parentPath); + using var file = parent.OpenExistingFile( + Path.GetFileName(destinationPath), + requireDeleteAccess: false); + if (!file.VisiblePathMatches() + || (!string.IsNullOrWhiteSpace(entry.TargetPhysicalObjectIdentity) + && !string.Equals( + entry.TargetPhysicalObjectIdentity, + file.GetObjectIdentity(), + StringComparison.Ordinal))) + { + throw new MoveNeedsAttentionException( + $"Published file generation changed: {entry.RelativePath}"); + } + + var verified = !string.IsNullOrWhiteSpace(entry.Sha256) + ? await PinnedFileMatchesManifestAsync( + file, + entry, + cancellationToken) + : IsVerifiedMarkerlessNativeRenameEntry(entry) + && file.MatchesMetadata( + entry.Length, + entry.LastWriteTimeUtc); + if (!verified) { throw new MoveNeedsAttentionException( $"Published file verification failed: {entry.RelativePath}"); diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.Markerless.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.Markerless.cs new file mode 100644 index 000000000..f017e78c0 --- /dev/null +++ b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.Markerless.cs @@ -0,0 +1,372 @@ +using Listenarr.Domain.Common; + +namespace Listenarr.Infrastructure.Library.Moving; + +internal sealed partial class AudiobookContentMoveService +{ + private async Task MoveContentsMarkerlessAsync( + AudiobookContentMoveRequest request, + string source, + string target, + bool targetInsideSource, + bool sourceInsideTarget, + CancellationToken cancellationToken) + { + if (HasLegacyFilesystemRecoveryArtifacts(source, target, request.JobId)) + { + throw new MoveNeedsAttentionException( + "A markerless move encountered legacy filesystem recovery artifacts. They were preserved for explicit recovery."); + } + + await ReportProgressAsync(request, 2, "Preparing", cancellationToken); + var manifest = await LoadManifestAsync(request.JobId, cancellationToken); + if (manifest.Count == 0) + { + throw new MoveNeedsAttentionException( + "The markerless move has no persisted tracked-file source manifest."); + } + + var targetOwnership = Directory.Exists(target) + ? await LoadValidatedTargetDirectoryOwnershipAsync( + target, + request.TargetSemantics, + cancellationToken) + : null; + request = request with { TargetDirectoryOwnership = targetOwnership }; + var resumedCleanup = await TryResumeMarkerlessSourceCleanupAsync( + request, + source, + target, + targetInsideSource, + sourceInsideTarget, + manifest, + cancellationToken); + if (resumedCleanup != null) + { + return resumedCleanup; + } + + EnsureTargetCanReceiveContents( + source, + target, + sourceInsideTarget, + resumingOwnedDirectCopy: true, + request.TargetSemantics, + targetOwnership); + await RecoverInterruptedMarkerlessNativeRenamesAsync( + request, + source, + target, + manifest, + cancellationToken); + + _ = await LoadValidatedOwnedSourceDirectoriesAsync( + source, + request.SourceSemantics, + cancellationToken); + var targetStructuralSpine = GetTargetStructuralSpine( + source, + target, + request.SourceSemantics); + ValidateExistingTargetSpine( + targetStructuralSpine, + target, + request.SourceSemantics); + await ValidatePersistedSourceManifestAsync( + source, + target, + targetInsideSource, + manifest, + request.SourceSemantics, + cancellationToken, + verifyFileContents: false, + allowVerifiedNativeRenameMissingSources: true); + _ = ValidateSourceTreeForMove( + source, + target, + targetInsideSource, + request.SourceSemantics, + cancellationToken, + ownedRecoveryMarkerPath: null, + ownedScaffoldPaths: [], + structuralSpinePaths: targetStructuralSpine, + ownedDirectoryMarkerPaths: [], + request.SourceCleanupBoundary); + + await ReportProgressAsync(request, 3, "Capturing source", cancellationToken); + await CaptureMarkerlessSourceIdentitiesAsync( + request, + source, + manifest, + cancellationToken); + await ReportProgressAsync(request, 5, "Planning", cancellationToken); + await UpdateJobPhaseAsync( + request.JobId, + request.LeaseToken, + MoveJobPhase.Planned, + cancellationToken); + await CreateMarkerlessTargetDirectoriesAsync( + request, + target, + manifest, + cancellationToken); + await CaptureOrValidateMarkerlessTargetRootAsync( + request, + target, + cancellationToken); + + ValidateExistingDestinationContents( + source, + target, + manifest, + request.JobId, + request.TargetSemantics, + tempOwnership: null, + quarantineOwnership: null, + allowPartialFiles: false, + targetDirectoryOwnership: request.TargetDirectoryOwnership); + await UpdateJobPhaseAsync( + request.JobId, + request.LeaseToken, + MoveJobPhase.Copying, + cancellationToken); + await ReportProgressAsync(request, 5, "Verifying source", cancellationToken); + var targetVerificationLease = new MarkerlessTargetVerificationLease( + request.TargetSemantics); + try + { + await CopyMarkerlessTargetFilesAsync( + request, + source, + target, + manifest, + targetVerificationLease, + cancellationToken); + await UpdateJobPhaseAsync( + request.JobId, + request.LeaseToken, + MoveJobPhase.Published, + cancellationToken); + await ReportProgressAsync(request, 72, "Copy verified", cancellationToken); + + if (faultInjector != null) + { + await faultInjector.AfterPublishedAsync(request.JobId, cancellationToken); + } + + await UpdateJobPhaseAsync( + request.JobId, + request.LeaseToken, + MoveJobPhase.CleaningSource, + cancellationToken); + await ReportProgressAsync(request, 75, "Cleaning source", cancellationToken); + await DeleteMarkerlessSourceAsync( + request, + source, + target, + targetInsideSource, + manifest, + cancellationToken); + VerifySourceCleanupState(request, source, target, manifest); + await UpdateJobPhaseAsync( + request.JobId, + request.LeaseToken, + MoveJobPhase.Finalizing, + cancellationToken); + await ReportProgressAsync(request, 92, "Finalizing", cancellationToken); + + return CreateMarkerlessMoveResult( + request, + source, + target, + targetInsideSource, + sourceInsideTarget, + manifest, + targetVerificationLease.IsEmpty ? null : targetVerificationLease); + } + catch + { + targetVerificationLease.Dispose(); + throw; + } + } + + private async Task TryResumeMarkerlessSourceCleanupAsync( + AudiobookContentMoveRequest request, + string source, + string target, + bool targetInsideSource, + bool sourceInsideTarget, + IReadOnlyCollection manifest, + CancellationToken cancellationToken) + { + var endpoints = await GetEndpointObjectIdentitiesAsync( + request.JobId, + cancellationToken); + var physicalEntries = manifest + .Where(IsPhysicalManifestEntry) + .ToList(); + var cleanupStarted = physicalEntries.Any(entry => + entry.CleanupState != MoveJobEntryCleanupState.Pending) + || endpoints.SourceDirectoryCleanupState + != MoveJobEntryCleanupState.Pending; + if (!cleanupStarted) + { + return null; + } + + if (physicalEntries + .Where(entry => entry.EntryType == MoveJobEntryType.File) + .Any(entry => entry.CopyState != MoveJobEntryCopyState.Verified + || string.IsNullOrWhiteSpace( + entry.TargetPhysicalObjectIdentity))) + { + throw new MoveNeedsAttentionException( + "Markerless source cleanup started before every target file was durably verified."); + } + if (physicalEntries.Any(entry => string.IsNullOrWhiteSpace( + entry.SourcePhysicalObjectIdentity))) + { + throw new MoveNeedsAttentionException( + "Markerless source cleanup lacks persisted source-generation evidence."); + } + + await UpdateJobPhaseAsync( + request.JobId, + request.LeaseToken, + MoveJobPhase.CleaningSource, + cancellationToken); + await DeleteMarkerlessSourceAsync( + request, + source, + target, + targetInsideSource, + manifest, + cancellationToken); + VerifySourceCleanupState(request, source, target, manifest); + await UpdateJobPhaseAsync( + request.JobId, + request.LeaseToken, + MoveJobPhase.Finalizing, + cancellationToken); + return CreateMarkerlessMoveResult( + request, + source, + target, + targetInsideSource, + sourceInsideTarget, + manifest); + } + + private static AudiobookContentMoveResult CreateMarkerlessMoveResult( + AudiobookContentMoveRequest request, + string source, + string target, + bool targetInsideSource, + bool sourceInsideTarget, + IEnumerable manifest, + MarkerlessTargetVerificationLease? targetVerificationLease = null) + { + var targetIdentities = CreatePersistedTargetPhysicalIdentityMap( + target, + manifest, + request.TargetSemantics); + return new AudiobookContentMoveResult( + source, + target, + targetInsideSource, + sourceInsideTarget, + RecoveryMarkerPath: string.Empty, + SourceCleanupCompleted: true, + targetIdentities, + targetVerificationLease); + } + + private async Task GetMarkerlessRecoverableMoveAsync( + AudiobookContentMoveRequest request, + string source, + string target, + CancellationToken cancellationToken) + { + var manifest = await LoadManifestAsync(request.JobId, cancellationToken); + if (manifest.Count == 0) + { + throw new MoveNeedsAttentionException( + "A markerless move has no persisted manifest for recovery."); + } + + var files = manifest + .Where(entry => entry.EntryType == MoveJobEntryType.File) + .Where(IsPhysicalManifestEntry) + .ToList(); + if (files.Any(entry => entry.CopyState != MoveJobEntryCopyState.Verified)) + { + return null; + } + if (!Directory.Exists(target)) + { + throw new MoveNeedsAttentionException( + "The verified markerless move target is missing."); + } + + faultInjector?.OnFinalizedVerification( + request.JobId, + FinalizedVerificationFaultPoint.BeforeManifestVerification); + await VerifyMarkerlessTargetAsync( + request, + target, + manifest, + cancellationToken); + var endpoints = await GetEndpointObjectIdentitiesAsync( + request.JobId, + cancellationToken); + var sourceEntriesComplete = manifest + .Where(IsPhysicalManifestEntry) + .All(entry => entry.CleanupState is + MoveJobEntryCleanupState.Deleted + or MoveJobEntryCleanupState.Retained); + var sourceRootComplete = endpoints.SourceDirectoryCleanupState is + MoveJobEntryCleanupState.Deleted + or MoveJobEntryCleanupState.Retained; + if (!sourceEntriesComplete || !sourceRootComplete) + { + return null; + } + + VerifySourceCleanupState(request, source, target, manifest); + var identities = CreatePersistedTargetPhysicalIdentityMap( + target, + files, + request.TargetSemantics); + return new AudiobookContentMoveResult( + source, + target, + IsSameOrInside(target, source, request.SourceSemantics), + IsSameOrInside(source, target, request.TargetSemantics), + RecoveryMarkerPath: string.Empty, + SourceCleanupCompleted: true, + identities); + } + + private static bool IsPhysicalManifestEntry(MoveJobEntry entry) => + !IsRootManifestEntry(entry) + && !MoveManifestIdentity.IsTargetBoundaryAuthorization(entry); + + private static string ResolveManifestPath( + string root, + MoveJobEntry entry, + FileSystemPathSemantics semantics, + string endpoint) + { + if (!FileSystemPathIdentity.TryResolveRelativePathWithinBase( + root, + entry.RelativePath, + semantics, + out var path)) + { + throw new MoveNeedsAttentionException( + $"A manifest entry escaped the markerless {endpoint} root: {entry.RelativePath}"); + } + return path; + } +} diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.MarkerlessCleanup.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.MarkerlessCleanup.cs new file mode 100644 index 000000000..241631117 --- /dev/null +++ b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.MarkerlessCleanup.cs @@ -0,0 +1,448 @@ +namespace Listenarr.Infrastructure.Library.Moving; + +internal sealed partial class AudiobookContentMoveService +{ + private async Task DeleteMarkerlessSourceAsync( + AudiobookContentMoveRequest request, + string source, + string target, + bool targetInsideSource, + IReadOnlyCollection manifest, + CancellationToken cancellationToken) + { + var files = manifest + .Where(candidate => candidate.EntryType == MoveJobEntryType.File) + .Where(IsPhysicalManifestEntry) + .ToList(); + var resumingCleanup = manifest + .Where(IsPhysicalManifestEntry) + .Any(entry => entry.CleanupState != MoveJobEntryCleanupState.Pending); + if (resumingCleanup) + { + await VerifyMarkerlessTargetAsync( + request, + target, + manifest, + cancellationToken); + } + + var totalUnits = files.Sum(GetProgressUnits); + var completedUnits = files + .Where(entry => entry.CleanupState is + MoveJobEntryCleanupState.Deleted or MoveJobEntryCleanupState.Retained) + .Sum(GetProgressUnits); + foreach (var entry in files) + { + var wasComplete = entry.CleanupState is + MoveJobEntryCleanupState.Deleted or MoveJobEntryCleanupState.Retained; + await DeleteMarkerlessSourceFileAsync( + request, + source, + target, + entry, + cancellationToken); + if (!wasComplete && entry.CleanupState is + MoveJobEntryCleanupState.Deleted or MoveJobEntryCleanupState.Retained) + { + completedUnits += GetProgressUnits(entry); + } + await ReportProgressAsync( + request, + CalculateWeightedProgress(75, 15, completedUnits, totalUnits), + "Cleaning source", + cancellationToken); + } + + foreach (var entry in manifest + .Where(candidate => candidate.EntryType == MoveJobEntryType.Directory) + .Where(IsPhysicalManifestEntry) + .OrderByDescending(candidate => candidate.RelativePath.Length)) + { + await DeleteMarkerlessSourceDirectoryAsync( + request, + source, + target, + targetInsideSource, + entry, + cancellationToken); + } + + await DeleteMarkerlessSourceRootAsync( + request, + source, + target, + targetInsideSource, + cancellationToken); + await ReportProgressAsync(request, 90, "Cleaning source", cancellationToken); + } + + private async Task DeleteMarkerlessSourceFileAsync( + AudiobookContentMoveRequest request, + string source, + string target, + MoveJobEntry entry, + CancellationToken cancellationToken) + { + var sourcePath = ResolveManifestPath( + source, + entry, + request.SourceSemantics, + "source"); + var targetPath = ResolveManifestPath( + target, + entry, + request.TargetSemantics, + "target"); + if (!File.Exists(sourcePath)) + { + if (entry.CleanupState == MoveJobEntryCleanupState.DeletionAuthorized) + { + await UpdateCleanupStateAsync( + request.JobId, + request.LeaseToken, + entry.RelativePath, + MoveJobEntryCleanupState.Deleted, + cancellationToken); + entry.CleanupState = MoveJobEntryCleanupState.Deleted; + return; + } + if (entry.CleanupState == MoveJobEntryCleanupState.Deleted) + { + return; + } + if (await TryCompleteMarkerlessNativeRenameCleanupAsync( + request, + entry, + targetPath, + cancellationToken)) + { + return; + } + throw new MoveNeedsAttentionException( + $"A source file disappeared before markerless deletion was authorized: {entry.RelativePath}"); + } + if (entry.CleanupState == MoveJobEntryCleanupState.Deleted) + { + throw new MoveNeedsAttentionException( + $"A deleted source file path was recreated: {entry.RelativePath}"); + } + if (entry.CleanupState == MoveJobEntryCleanupState.Retained) + { + throw new MoveNeedsAttentionException( + $"A retained source file cannot be considered cleaned: {entry.RelativePath}"); + } + + var sourceParentPath = Path.GetDirectoryName(sourcePath) + ?? throw new MoveNeedsAttentionException( + "A markerless source file has no parent."); + var targetParentPath = Path.GetDirectoryName(targetPath) + ?? throw new MoveNeedsAttentionException( + "A markerless target file has no parent."); + using var sourceParent = PinnedDirectoryCreation.OpenPinnedBoundary( + sourceParentPath); + using var sourceEntry = sourceParent.OpenExistingFile( + Path.GetFileName(sourcePath), + requireDeleteAccess: true); + ValidateMarkerlessSourceEntry(request, entry, sourceEntry); + if (!await PinnedFileMatchesManifestAsync( + sourceEntry, + entry, + cancellationToken)) + { + throw new MoveNeedsAttentionException( + $"A source file changed before markerless deletion: {entry.RelativePath}"); + } + + using var targetParent = PinnedDirectoryCreation.OpenPinnedBoundary( + targetParentPath); + using var targetEntry = targetParent.OpenExistingFile( + Path.GetFileName(targetPath), + requireDeleteAccess: false); + ValidateMarkerlessTargetEntry(entry, targetEntry); + if (!await PinnedFileMatchesManifestAsync( + targetEntry, + entry, + cancellationToken)) + { + throw new MoveNeedsAttentionException( + $"The target file changed before source deletion: {entry.RelativePath}"); + } + + if (entry.CleanupState == MoveJobEntryCleanupState.Pending) + { + await UpdateCleanupStateAsync( + request.JobId, + request.LeaseToken, + entry.RelativePath, + MoveJobEntryCleanupState.DeletionAuthorized, + cancellationToken); + entry.CleanupState = MoveJobEntryCleanupState.DeletionAuthorized; + } + await EnsureMutationAuthorizedAsync( + request, + source, + target, + cancellationToken); + ValidateMarkerlessSourceEntry(request, entry, sourceEntry); + ValidateMarkerlessTargetEntry(entry, targetEntry); + sourceEntry.Delete(); + faultInjector?.OnSourceCleanupMutation( + request.JobId, + SourceCleanupFaultPoint + .AfterMarkerlessSourceFileDeleteBeforeStateUpdate); + await UpdateCleanupStateAsync( + request.JobId, + request.LeaseToken, + entry.RelativePath, + MoveJobEntryCleanupState.Deleted, + cancellationToken); + entry.CleanupState = MoveJobEntryCleanupState.Deleted; + faultInjector?.OnSourceCleanupMutation( + request.JobId, + SourceCleanupFaultPoint.AfterMarkerlessSourceFileStateUpdate); + } + + private async Task DeleteMarkerlessSourceDirectoryAsync( + AudiobookContentMoveRequest request, + string source, + string target, + bool targetInsideSource, + MoveJobEntry entry, + CancellationToken cancellationToken) + { + var sourcePath = ResolveManifestPath( + source, + entry, + request.SourceSemantics, + "source"); + if (File.Exists(sourcePath)) + { + throw new MoveNeedsAttentionException( + $"A source directory changed into a file: {entry.RelativePath}"); + } + if (!Directory.Exists(sourcePath)) + { + if (entry.CleanupState == MoveJobEntryCleanupState.DeletionAuthorized) + { + await UpdateCleanupStateAsync( + request.JobId, + request.LeaseToken, + entry.RelativePath, + MoveJobEntryCleanupState.Deleted, + cancellationToken); + entry.CleanupState = MoveJobEntryCleanupState.Deleted; + return; + } + if (entry.CleanupState == MoveJobEntryCleanupState.Deleted) + { + return; + } + throw new MoveNeedsAttentionException( + $"A source directory disappeared before markerless deletion was authorized: {entry.RelativePath}"); + } + if (entry.CleanupState == MoveJobEntryCleanupState.Deleted) + { + throw new MoveNeedsAttentionException( + $"A deleted source directory path was recreated: {entry.RelativePath}"); + } + + if (targetInsideSource + && (IsSameOrInside(target, sourcePath, request.SourceSemantics) + || IsSameOrInside(sourcePath, target, request.SourceSemantics))) + { + await RetainMarkerlessSourceEntryAsync(request, entry, cancellationToken); + return; + } + if (Directory.EnumerateFileSystemEntries(sourcePath).Any()) + { + await RetainMarkerlessSourceEntryAsync(request, entry, cancellationToken); + return; + } + + var parentPath = Path.GetDirectoryName(sourcePath) + ?? throw new MoveNeedsAttentionException( + "A markerless source directory has no parent."); + using var parent = PinnedDirectoryCreation.OpenPinnedBoundary(parentPath); + using var publication = parent.OpenExistingChildForPublication( + Path.GetFileName(sourcePath)); + using var directory = publication.OpenCreatedDirectoryAnchor(); + ValidateMarkerlessSourceDirectory(entry, directory); + if (entry.CleanupState == MoveJobEntryCleanupState.Pending) + { + await UpdateCleanupStateAsync( + request.JobId, + request.LeaseToken, + entry.RelativePath, + MoveJobEntryCleanupState.DeletionAuthorized, + cancellationToken); + entry.CleanupState = MoveJobEntryCleanupState.DeletionAuthorized; + } + await EnsureMutationAuthorizedAsync( + request, + source, + target, + cancellationToken); + ValidateMarkerlessSourceDirectory(entry, directory); + if (Directory.EnumerateFileSystemEntries(sourcePath).Any()) + { + await RetainMarkerlessSourceEntryAsync(request, entry, cancellationToken); + return; + } + publication.RetirePinnedEmptyDirectoryFromNamespace( + Path.GetFileName(sourcePath)); + await UpdateCleanupStateAsync( + request.JobId, + request.LeaseToken, + entry.RelativePath, + MoveJobEntryCleanupState.Deleted, + cancellationToken); + entry.CleanupState = MoveJobEntryCleanupState.Deleted; + } + + private async Task DeleteMarkerlessSourceRootAsync( + AudiobookContentMoveRequest request, + string source, + string target, + bool targetInsideSource, + CancellationToken cancellationToken) + { + var endpoints = await GetEndpointObjectIdentitiesAsync( + request.JobId, + cancellationToken); + if (!request.DeleteEmptySource + || targetInsideSource + || IsSourceCleanupBoundary( + source, + request.SourceCleanupBoundary, + request.SourceSemantics)) + { + if (endpoints.SourceDirectoryCleanupState + == MoveJobEntryCleanupState.Pending) + { + await UpdateSourceDirectoryCleanupStateAsync( + request.JobId, + request.LeaseToken, + MoveJobEntryCleanupState.Retained, + cancellationToken); + } + return; + } + + if (!Directory.Exists(source)) + { + if (endpoints.SourceDirectoryCleanupState + == MoveJobEntryCleanupState.DeletionAuthorized) + { + await UpdateSourceDirectoryCleanupStateAsync( + request.JobId, + request.LeaseToken, + MoveJobEntryCleanupState.Deleted, + cancellationToken); + return; + } + if (endpoints.SourceDirectoryCleanupState + == MoveJobEntryCleanupState.Deleted) + { + return; + } + throw new MoveNeedsAttentionException( + "The source directory disappeared before markerless deletion was authorized."); + } + if (endpoints.SourceDirectoryCleanupState + == MoveJobEntryCleanupState.Deleted) + { + throw new MoveNeedsAttentionException( + "The deleted source directory path was recreated."); + } + if (Directory.EnumerateFileSystemEntries(source).Any()) + { + await UpdateSourceDirectoryCleanupStateAsync( + request.JobId, + request.LeaseToken, + MoveJobEntryCleanupState.Retained, + cancellationToken); + return; + } + + var parentPath = Path.GetDirectoryName(source) + ?? throw new MoveNeedsAttentionException( + "The markerless source directory has no parent."); + using var parent = PinnedDirectoryCreation.OpenPinnedBoundary(parentPath); + using var publication = parent.OpenExistingChildForPublication( + Path.GetFileName(source)); + using var directory = publication.OpenCreatedDirectoryAnchor(); + if (string.IsNullOrWhiteSpace(endpoints.SourceDirectoryObjectIdentity) + || !string.Equals( + endpoints.SourceDirectoryObjectIdentity, + directory.GetDirectoryObjectIdentity(), + StringComparison.Ordinal) + || !directory.VisiblePathMatches()) + { + throw new MoveNeedsAttentionException( + "The markerless source root changed physical generation before deletion."); + } + if (endpoints.SourceDirectoryCleanupState + == MoveJobEntryCleanupState.Pending) + { + await UpdateSourceDirectoryCleanupStateAsync( + request.JobId, + request.LeaseToken, + MoveJobEntryCleanupState.DeletionAuthorized, + cancellationToken); + } + await EnsureMutationAuthorizedAsync( + request, + source, + target, + cancellationToken); + if (Directory.EnumerateFileSystemEntries(source).Any() + || !directory.VisiblePathMatches()) + { + await UpdateSourceDirectoryCleanupStateAsync( + request.JobId, + request.LeaseToken, + MoveJobEntryCleanupState.Retained, + cancellationToken); + return; + } + publication.RetirePinnedEmptyDirectoryFromNamespace(Path.GetFileName(source)); + await UpdateSourceDirectoryCleanupStateAsync( + request.JobId, + request.LeaseToken, + MoveJobEntryCleanupState.Deleted, + cancellationToken); + } + + private async Task RetainMarkerlessSourceEntryAsync( + AudiobookContentMoveRequest request, + MoveJobEntry entry, + CancellationToken cancellationToken) + { + if (entry.CleanupState != MoveJobEntryCleanupState.Retained) + { + await UpdateCleanupStateAsync( + request.JobId, + request.LeaseToken, + entry.RelativePath, + MoveJobEntryCleanupState.Retained, + cancellationToken); + entry.CleanupState = MoveJobEntryCleanupState.Retained; + } + } + + private static void ValidateMarkerlessSourceDirectory( + MoveJobEntry entry, + PinnedDirectoryCreation.PinnedDirectoryAnchor directory) + { + if (string.IsNullOrWhiteSpace(entry.SourcePhysicalObjectIdentity) + || !string.Equals( + entry.SourcePhysicalObjectIdentity, + directory.GetDirectoryObjectIdentity(), + StringComparison.Ordinal) + || !directory.VisiblePathMatches()) + { + throw new MoveNeedsAttentionException( + $"A markerless source directory changed physical generation: {entry.RelativePath}"); + } + } +} diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.MarkerlessCopy.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.MarkerlessCopy.cs new file mode 100644 index 000000000..8a24d14e4 --- /dev/null +++ b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.MarkerlessCopy.cs @@ -0,0 +1,497 @@ +using System.Buffers; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Infrastructure.Library.Moving; + +internal sealed partial class AudiobookContentMoveService +{ + private async Task CopyMarkerlessTargetFilesAsync( + AudiobookContentMoveRequest request, + string source, + string target, + IReadOnlyCollection manifest, + MarkerlessTargetVerificationLease targetVerificationLease, + CancellationToken cancellationToken) + { + var files = manifest + .Where(candidate => candidate.EntryType == MoveJobEntryType.File) + .Where(IsPhysicalManifestEntry) + .ToList(); + var totalWorkUnits = files.Sum(entry => checked(GetProgressUnits(entry) * 2)); + var completedWorkUnits = files + .Where(entry => entry.CopyState == MoveJobEntryCopyState.Verified) + .Sum(entry => checked(GetProgressUnits(entry) * 2)); + + foreach (var entry in files) + { + cancellationToken.ThrowIfCancellationRequested(); + var sourcePath = ResolveManifestPath( + source, + entry, + request.SourceSemantics, + "source"); + var targetPath = ResolveManifestPath( + target, + entry, + request.TargetSemantics, + "target"); + var sourceParentPath = Path.GetDirectoryName(sourcePath) + ?? throw new MoveNeedsAttentionException( + "A markerless source file has no parent."); + var targetParentPath = Path.GetDirectoryName(targetPath) + ?? throw new MoveNeedsAttentionException( + "A markerless target file has no parent."); + + using var sourceParent = PinnedDirectoryCreation.OpenPinnedBoundary( + sourceParentPath); + using var targetParent = PinnedDirectoryCreation.OpenPinnedBoundary( + targetParentPath); + using var existingTarget = targetParent.TryOpenExistingFile( + Path.GetFileName(targetPath), + requireDeleteAccess: false); + using var sourceEntry = sourceParent.TryOpenExistingFile( + Path.GetFileName(sourcePath), + requireDeleteAccess: false); + + if (sourceEntry == null) + { + var wasVerified = entry.CopyState == MoveJobEntryCopyState.Verified; + if (existingTarget == null + || !await TryRecoverMarkerlessNativeRenameAsync( + request, + entry, + existingTarget, + cancellationToken)) + { + throw new MoveNeedsAttentionException( + $"Source file disappeared before markerless publication completed: {entry.RelativePath}"); + } + + if (!wasVerified) + { + completedWorkUnits += checked(GetProgressUnits(entry) * 2); + } + await ReportProgressAsync( + request, + CalculateWeightedProgress( + 5, + 65, + completedWorkUnits, + totalWorkUnits), + "Moving", + cancellationToken); + continue; + } + + ValidateMarkerlessSourceEntry(request, entry, sourceEntry); + if (entry.CopyState == MoveJobEntryCopyState.Verified + && existingTarget != null) + { + await HandleExistingMarkerlessTargetAsync( + request, + entry, + sourcePath, + sourceEntry, + targetPath, + existingTarget, + completedWorkUnits, + totalWorkUnits, + cancellationToken); + continue; + } + + if (existingTarget == null + && entry.CopyState == MoveJobEntryCopyState.Pending + && string.IsNullOrWhiteSpace(entry.TargetPhysicalObjectIdentity)) + { + var stableRenameEntry = TryOpenMarkerlessStableNativeRenameSource( + entry, + sourceParent, + sourceEntry, + targetParent); + try + { + var nativeRename = await TryPublishMarkerlessNativeRenameAsync( + request, + source, + target, + entry, + sourceParent, + sourceEntry, + targetParent, + stableRenameEntry, + cancellationToken); + if (nativeRename.Published) + { + if (nativeRename.VerificationLease != null) + { + targetVerificationLease.Add( + entry.RelativePath, + nativeRename.VerificationLease); + } + completedWorkUnits += checked(GetProgressUnits(entry) * 2); + await ReportProgressAsync( + request, + CalculateWeightedProgress( + 5, + 65, + completedWorkUnits, + totalWorkUnits), + "Moving", + cancellationToken); + continue; + } + } + finally + { + stableRenameEntry?.Dispose(); + } + } + + var observedHash = await ComputeMarkerlessSourceProofHashAsync( + request, + entry, + sourcePath, + sourceEntry, + completedWorkUnits, + totalWorkUnits, + cancellationToken); + if (!string.IsNullOrWhiteSpace(entry.Sha256) + && !string.Equals( + entry.Sha256, + observedHash, + StringComparison.OrdinalIgnoreCase)) + { + throw new MoveNeedsAttentionException( + $"Source file changed before markerless publication: {entry.RelativePath}"); + } + if (string.IsNullOrWhiteSpace(entry.Sha256)) + { + await UpdateSourceEntryProofAsync( + request.JobId, + request.LeaseToken, + entry.RelativePath, + sourceEntry.GetObjectIdentity(), + observedHash, + cancellationToken); + entry.Sha256 = observedHash; + } + completedWorkUnits += GetProgressUnits(entry); + await ReportProgressAsync( + request, + CalculateWeightedProgress( + 5, + 65, + completedWorkUnits, + totalWorkUnits), + "Verifying source", + cancellationToken); + + if (existingTarget != null) + { + var wasVerified = entry.CopyState == MoveJobEntryCopyState.Verified; + await HandleExistingMarkerlessTargetAsync( + request, + entry, + sourcePath, + sourceEntry, + targetPath, + existingTarget, + completedWorkUnits, + totalWorkUnits, + cancellationToken); + if (!wasVerified && entry.CopyState == MoveJobEntryCopyState.Verified) + { + completedWorkUnits += GetProgressUnits(entry); + } + await ReportProgressAsync( + request, + CalculateWeightedProgress( + 5, + 65, + completedWorkUnits, + totalWorkUnits), + "Copying", + cancellationToken); + continue; + } + + if (entry.CopyState != MoveJobEntryCopyState.Pending + || !string.IsNullOrWhiteSpace(entry.TargetPhysicalObjectIdentity)) + { + throw new MoveNeedsAttentionException( + $"A markerless target file disappeared after publication began: {entry.RelativePath}"); + } + + await EnsureMutationAuthorizedAsync( + request, + source, + target, + cancellationToken); + using var created = targetParent.CreateNewFile( + Path.GetFileName(targetPath)); + var targetIdentity = created.GetObjectIdentity(); + faultInjector?.OnCopyMutation( + request.JobId, + CopyMutationFaultPoint + .AfterMarkerlessFileCreationBeforeStateUpdate); + try + { + await UpdateTargetEntryStateAsync( + request.JobId, + request.LeaseToken, + entry.RelativePath, + MoveJobEntryCopyState.Staged, + targetIdentity, + cancellationToken); + } + catch + { + TryRetireUncommittedMarkerlessFile(created); + throw; + } + + entry.CopyState = MoveJobEntryCopyState.Staged; + entry.TargetPhysicalObjectIdentity = targetIdentity; + faultInjector?.OnCopyMutation( + request.JobId, + CopyMutationFaultPoint.AfterMarkerlessFileStateUpdate); + await WriteMarkerlessTargetAsync( + request, + entry, + sourcePath, + sourceEntry, + targetPath, + created, + completedWorkUnits, + totalWorkUnits, + cancellationToken); + completedWorkUnits += GetProgressUnits(entry); + await ReportProgressAsync( + request, + CalculateWeightedProgress( + 5, + 65, + completedWorkUnits, + totalWorkUnits), + "Copying", + cancellationToken); + } + } + + private async Task HandleExistingMarkerlessTargetAsync( + AudiobookContentMoveRequest request, + MoveJobEntry entry, + string sourcePath, + PinnedDirectoryCreation.PinnedFileEntry sourceEntry, + string targetPath, + PinnedDirectoryCreation.PinnedFileEntry targetEntry, + long completedUnitsBeforeFile, + long totalUnits, + CancellationToken cancellationToken) + { + var currentIdentity = targetEntry.GetObjectIdentity(); + if (string.IsNullOrWhiteSpace(entry.TargetPhysicalObjectIdentity)) + { + if (entry.CopyState != MoveJobEntryCopyState.Pending + || !await PinnedFileMatchesManifestAsync( + targetEntry, + entry, + cancellationToken)) + { + throw new MoveNeedsAttentionException( + $"An existing final target file has no persisted markerless ownership proof: {entry.RelativePath}"); + } + + await UpdateTargetEntryStateAsync( + request.JobId, + request.LeaseToken, + entry.RelativePath, + MoveJobEntryCopyState.Verified, + currentIdentity, + cancellationToken); + entry.CopyState = MoveJobEntryCopyState.Verified; + entry.TargetPhysicalObjectIdentity = currentIdentity; + return; + } + + ValidateMarkerlessTargetEntry(entry, targetEntry); + if (entry.CopyState == MoveJobEntryCopyState.Verified) + { + if (!await PinnedFileMatchesManifestAsync( + targetEntry, + entry, + cancellationToken)) + { + throw new MoveNeedsAttentionException( + $"A verified markerless target file changed: {entry.RelativePath}"); + } + return; + } + + if (entry.CopyState is not ( + MoveJobEntryCopyState.Staged or MoveJobEntryCopyState.Published)) + { + throw new MoveNeedsAttentionException( + $"The persisted markerless target-file state is inconsistent: {entry.RelativePath}"); + } + + await WriteMarkerlessTargetAsync( + request, + entry, + sourcePath, + sourceEntry, + targetPath, + targetEntry, + completedUnitsBeforeFile, + totalUnits, + cancellationToken); + } + + private async Task WriteMarkerlessTargetAsync( + AudiobookContentMoveRequest request, + MoveJobEntry entry, + string sourcePath, + PinnedDirectoryCreation.PinnedFileEntry sourceEntry, + string targetPath, + PinnedDirectoryCreation.PinnedFileEntry targetEntry, + long completedWorkUnitsBeforeFile, + long totalWorkUnits, + CancellationToken cancellationToken) + { + ValidateMarkerlessSourceEntry(request, entry, sourceEntry); + ValidateMarkerlessTargetEntry(entry, targetEntry); + await using (var sourceStream = sourceEntry.OpenReadStream( + bufferSize: 1024 * 1024, + asynchronous: false)) + await using (var targetStream = targetEntry.OpenWriteStream( + bufferSize: 1024 * 1024, + asynchronous: false)) + { + targetStream.SetLength(0); + var buffer = ArrayPool.Shared.Rent(1024 * 1024); + try + { + long copied = 0; + long lastReported = 0; + var reportInterval = Math.Max( + 16L * 1024 * 1024, + Math.Max(totalWorkUnits / 100, 1)); + while (true) + { + var read = await sourceStream.ReadAsync( + buffer.AsMemory(0, buffer.Length), + cancellationToken); + if (read == 0) + { + break; + } + + await targetStream.WriteAsync( + buffer.AsMemory(0, read), + cancellationToken); + copied += read; + if (copied - lastReported >= reportInterval) + { + lastReported = copied; + await ReportProgressAsync( + request, + CalculateWeightedProgress( + 5, + 65, + completedWorkUnitsBeforeFile + copied, + totalWorkUnits), + "Copying", + cancellationToken); + } + } + + if (copied != entry.Length) + { + throw new IOException( + $"Markerless source length changed while copying: {entry.RelativePath}"); + } + + await targetStream.FlushAsync(cancellationToken); + targetStream.Flush(flushToDisk: true); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + // The independently opened write stream is identity-verified against the + // pinned entry and Flush(true) is the durability barrier. The observation + // handle may be read-only during recovery and must not be flushed again. + PreserveMarkerlessFileMetadata(sourcePath, targetPath); + faultInjector?.OnCopyMutation( + request.JobId, + CopyMutationFaultPoint + .AfterMarkerlessFileWriteBeforePublishedState); + await UpdateTargetEntryStateAsync( + request.JobId, + request.LeaseToken, + entry.RelativePath, + MoveJobEntryCopyState.Published, + entry.TargetPhysicalObjectIdentity, + cancellationToken); + entry.CopyState = MoveJobEntryCopyState.Published; + + ValidateMarkerlessTargetEntry(entry, targetEntry); + if (!await PinnedFileMatchesManifestAsync( + targetEntry, + entry, + cancellationToken)) + { + throw new IOException( + $"Markerless target verification failed: {entry.RelativePath}"); + } + + await UpdateTargetEntryStateAsync( + request.JobId, + request.LeaseToken, + entry.RelativePath, + MoveJobEntryCopyState.Verified, + entry.TargetPhysicalObjectIdentity, + cancellationToken); + entry.CopyState = MoveJobEntryCopyState.Verified; + } + + private void PreserveMarkerlessFileMetadata( + string sourceFile, + string destinationFile) + { + try + { + File.SetAttributes(destinationFile, File.GetAttributes(sourceFile)); + File.SetLastWriteTimeUtc( + destinationFile, + File.GetLastWriteTimeUtc(sourceFile)); + } + catch (Exception exception) when ( + WorkerExceptionClassifier.IsNonFatal(exception)) + { + logger.LogDebug( + exception, + "Non-fatal: failed to preserve markerless file metadata for {File}", + LogRedaction.SanitizeFilePath(sourceFile)); + } + } + + private static void TryRetireUncommittedMarkerlessFile( + PinnedDirectoryCreation.PinnedFileEntry file) + { + try + { + if (file.VisiblePathMatches()) + { + file.Delete(); + } + } + catch + { + // If persistence failed after final-name creation, preserve anything that + // cannot still be proven to be this exact newly-created file. + } + } +} diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.MarkerlessDirectories.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.MarkerlessDirectories.cs new file mode 100644 index 000000000..bb1444319 --- /dev/null +++ b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.MarkerlessDirectories.cs @@ -0,0 +1,355 @@ +using Listenarr.Domain.Common; + +namespace Listenarr.Infrastructure.Library.Moving; + +internal sealed partial class AudiobookContentMoveService +{ + private async Task CreateMarkerlessTargetDirectoriesAsync( + AudiobookContentMoveRequest request, + string target, + IReadOnlyCollection manifest, + CancellationToken cancellationToken) + { + var desired = CollectMarkerlessTargetDirectories( + target, + manifest, + request.TargetSemantics); + var missing = desired + .Where(path => !Directory.Exists(path) && !File.Exists(path)) + .ToArray(); + await PersistCreatedDirectoriesAsync( + request.JobId, + request.LeaseToken, + missing, + cancellationToken); + + var ledger = (await GetCreatedDirectoriesAsync( + request.JobId, + cancellationToken)) + .ToDictionary(directory => directory.Path, request.TargetSemantics.Comparer); + var pathsToProcess = new HashSet( + desired, + request.TargetSemantics.Comparer); + foreach (var persisted in ledger.Values) + { + ValidateMarkerlessTargetDirectoryLedgerPath( + persisted.Path, + target, + request.TargetSemantics); + pathsToProcess.Add(persisted.Path); + } + + foreach (var path in pathsToProcess.OrderBy(GetPathDepth)) + { + cancellationToken.ThrowIfCancellationRequested(); + if (File.Exists(path)) + { + throw new MoveNeedsAttentionException( + $"A markerless target directory path is occupied by a file: {path}"); + } + + if (!ledger.TryGetValue(path, out var planned)) + { + if (!Directory.Exists(path)) + { + throw new MoveNeedsAttentionException( + "A required target directory was not durably planned before creation."); + } + ValidateExistingMoveDirectory(path, "markerless target directory"); + continue; + } + + if (Directory.Exists(path)) + { + if (planned.State == MoveCreatedDirectoryState.Planned + && string.IsNullOrWhiteSpace(planned.DirectoryObjectIdentity)) + { + await RetainUnexplainedMarkerlessDirectoryAsync( + request, + planned, + cancellationToken); + continue; + } + if (planned.State is not ( + MoveCreatedDirectoryState.Created + or MoveCreatedDirectoryState.Retained) + || string.IsNullOrWhiteSpace(planned.DirectoryObjectIdentity)) + { + throw new MoveNeedsAttentionException( + $"A planned markerless target directory has inconsistent persisted state: {path}"); + } + ValidateMarkerlessCreatedDirectory(planned); + continue; + } + + if (planned.State != MoveCreatedDirectoryState.Planned + || !string.IsNullOrWhiteSpace(planned.DirectoryObjectIdentity)) + { + throw new MoveNeedsAttentionException( + $"A previously created markerless target directory disappeared: {path}"); + } + + var parentPath = Path.GetDirectoryName(path) + ?? throw new MoveNeedsAttentionException( + "A markerless target directory has no parent."); + await EnsureMutationAuthorizedAsync( + request, + request.Source, + request.Target, + cancellationToken); + using var creation = PinnedDirectoryCreation.TryCreate( + parentPath, + Path.GetFileName(path)); + if (!creation.Created) + { + throw new MoveNeedsAttentionException( + $"A markerless target directory was concurrently created: {path}"); + } + + using var directory = creation.OpenCreatedDirectoryAnchor(); + var identity = directory.GetDirectoryObjectIdentity(); + if (!directory.VisiblePathMatches()) + { + throw new MoveNeedsAttentionException( + $"A newly created target directory changed before persistence: {path}"); + } + + faultInjector?.OnTargetScaffoldPreparation( + request.JobId, + TargetScaffoldPreparationFaultPoint + .AfterMarkerlessDirectoryCreationBeforeStateUpdate); + try + { + await UpdateCreatedDirectoryPublicationAsync( + request.JobId, + request.LeaseToken, + path, + MoveCreatedDirectoryState.Created, + identity, + cancellationToken); + } + catch + { + TryRetireUncommittedMarkerlessDirectory(creation, directory, path); + throw; + } + + planned.State = MoveCreatedDirectoryState.Created; + planned.DirectoryObjectIdentity = identity; + faultInjector?.OnTargetScaffoldPreparation( + request.JobId, + TargetScaffoldPreparationFaultPoint + .AfterMarkerlessDirectoryStateUpdate); + } + } + + private async Task RetainUnexplainedMarkerlessDirectoryAsync( + AudiobookContentMoveRequest request, + MoveJobCreatedDirectory planned, + CancellationToken cancellationToken) + { + ValidateExistingMoveDirectory( + planned.Path, + "unexplained markerless target directory"); + if (Directory.EnumerateFileSystemEntries(planned.Path).Any()) + { + throw new MoveNeedsAttentionException( + $"An unproven markerless target directory contains content: {planned.Path}"); + } + + var parentPath = Path.GetDirectoryName(planned.Path) + ?? throw new MoveNeedsAttentionException( + "An unproven markerless target directory has no parent."); + using var parent = PinnedDirectoryCreation.OpenPinnedBoundary(parentPath); + using var directory = parent.OpenExistingChild(Path.GetFileName(planned.Path)); + if (!directory.VisiblePathMatches() + || !parent.VisiblePathMatches() + || Directory.EnumerateFileSystemEntries(planned.Path).Any()) + { + throw new MoveNeedsAttentionException( + $"An unproven markerless target directory changed during recovery: {planned.Path}"); + } + + var identity = directory.GetDirectoryObjectIdentity(); + await UpdateCreatedDirectoryPublicationAsync( + request.JobId, + request.LeaseToken, + planned.Path, + MoveCreatedDirectoryState.Retained, + identity, + cancellationToken); + planned.State = MoveCreatedDirectoryState.Retained; + planned.DirectoryObjectIdentity = identity; + } + + private static IReadOnlyCollection CollectMarkerlessTargetDirectories( + string target, + IReadOnlyCollection manifest, + FileSystemPathSemantics semantics) + { + var paths = new HashSet(semantics.Comparer); + AddTargetDirectoryChain(paths, target, target, semantics); + foreach (var entry in manifest.Where(IsPhysicalManifestEntry)) + { + var resolved = ResolveManifestPath(target, entry, semantics, "target"); + var directory = entry.EntryType == MoveJobEntryType.Directory + ? resolved + : Path.GetDirectoryName(resolved); + if (!string.IsNullOrWhiteSpace(directory)) + { + AddTargetDirectoryChain(paths, target, directory, semantics); + } + } + + var targetParent = Path.GetDirectoryName(target) + ?? throw new MoveNeedsAttentionException( + "The markerless target has no parent directory."); + foreach (var ancestor in FindMissingTargetAncestors(targetParent)) + { + paths.Add(ancestor); + } + return paths; + } + + private static void ValidateMarkerlessTargetDirectoryLedgerPath( + string directory, + string target, + FileSystemPathSemantics semantics) + { + try + { + if (!FileSystemPathIdentity.AreEquivalent( + directory, + target, + semantics) + && !FileSystemPathIdentity.IsSameOrInside( + directory, + target, + semantics) + && !FileSystemPathIdentity.IsSameOrInside( + target, + directory, + semantics)) + { + throw new MoveNeedsAttentionException( + "A persisted markerless target directory is unrelated to the requested target."); + } + } + catch (MoveNeedsAttentionException) + { + throw; + } + catch (Exception exception) when (exception is + ArgumentException or InvalidOperationException + or NotSupportedException or PathTooLongException) + { + throw new MoveNeedsAttentionException( + "A persisted markerless target directory has an invalid path identity."); + } + } + + private static void AddTargetDirectoryChain( + ISet paths, + string target, + string directory, + FileSystemPathSemantics semantics) + { + var current = directory; + while (true) + { + if (!FileSystemPathIdentity.IsSameOrInside( + current, + target, + semantics)) + { + throw new MoveNeedsAttentionException( + "A markerless target directory escaped the requested target root."); + } + + paths.Add(current); + if (FileSystemPathIdentity.AreEquivalent(current, target, semantics)) + { + return; + } + + current = Path.GetDirectoryName(current) + ?? throw new MoveNeedsAttentionException( + "A markerless target directory chain has no parent."); + } + } + + private static void ValidateMarkerlessCreatedDirectory( + MoveJobCreatedDirectory planned) + { + var parentPath = Path.GetDirectoryName(planned.Path) + ?? throw new MoveNeedsAttentionException( + "A persisted target directory has no parent."); + using var parent = PinnedDirectoryCreation.OpenPinnedBoundary(parentPath); + using var directory = parent.OpenExistingChild(Path.GetFileName(planned.Path)); + if (!string.Equals( + directory.GetDirectoryObjectIdentity(), + planned.DirectoryObjectIdentity, + StringComparison.Ordinal) + || !directory.VisiblePathMatches() + || !parent.VisiblePathMatches()) + { + throw new MoveNeedsAttentionException( + $"A move-created target directory changed physical generation: {planned.Path}"); + } + } + + private static void TryRetireUncommittedMarkerlessDirectory( + PinnedDirectoryCreation creation, + PinnedDirectoryCreation.PinnedDirectoryAnchor directory, + string path) + { + try + { + if (!Directory.EnumerateFileSystemEntries(path).Any() + && directory.VisiblePathMatches()) + { + creation.RetirePinnedEmptyDirectoryFromNamespace(Path.GetFileName(path)); + } + } + catch + { + // A crash or concurrent mutation can leave the final requested directory. + // It has no DB identity and will be preserved for explicit attention. + } + } + + private async Task CaptureOrValidateMarkerlessTargetRootAsync( + AudiobookContentMoveRequest request, + string target, + CancellationToken cancellationToken) + { + using var root = PinnedDirectoryCreation.OpenPinnedBoundary(target); + var identity = root.GetDirectoryObjectIdentity(); + var endpoints = await GetEndpointObjectIdentitiesAsync( + request.JobId, + cancellationToken); + if (!string.IsNullOrWhiteSpace(endpoints.TargetDirectoryObjectIdentity) + && !string.Equals( + endpoints.TargetDirectoryObjectIdentity, + identity, + StringComparison.Ordinal)) + { + throw new MoveNeedsAttentionException( + "The markerless move target root changed physical generation."); + } + if (!root.VisiblePathMatches()) + { + throw new MoveNeedsAttentionException( + "The markerless target root changed while pinned."); + } + if (string.IsNullOrWhiteSpace(endpoints.TargetDirectoryObjectIdentity)) + { + await UpdateEndpointObjectIdentitiesAsync( + request.JobId, + request.LeaseToken, + sourceDirectoryObjectIdentity: null, + identity, + cancellationToken); + } + } +} diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.MarkerlessRename.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.MarkerlessRename.cs new file mode 100644 index 000000000..830d3aea5 --- /dev/null +++ b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.MarkerlessRename.cs @@ -0,0 +1,303 @@ +namespace Listenarr.Infrastructure.Library.Moving; + +internal sealed partial class AudiobookContentMoveService +{ + private async Task RecoverInterruptedMarkerlessNativeRenamesAsync( + AudiobookContentMoveRequest request, + string source, + string target, + IReadOnlyCollection manifest, + CancellationToken cancellationToken) + { + foreach (var entry in manifest + .Where(candidate => candidate.EntryType == MoveJobEntryType.File) + .Where(IsPhysicalManifestEntry)) + { + var sourcePath = ResolveManifestPath( + source, + entry, + request.SourceSemantics, + "source"); + if (File.Exists(sourcePath)) + { + continue; + } + + var targetPath = ResolveManifestPath( + target, + entry, + request.TargetSemantics, + "target"); + var targetParentPath = Path.GetDirectoryName(targetPath); + if (string.IsNullOrWhiteSpace(targetParentPath) + || !Directory.Exists(targetParentPath)) + { + continue; + } + + using var targetParent = PinnedDirectoryCreation.OpenPinnedBoundary( + targetParentPath); + using var targetEntry = targetParent.TryOpenExistingFile( + Path.GetFileName(targetPath), + requireDeleteAccess: false); + if (targetEntry != null) + { + _ = await TryRecoverMarkerlessNativeRenameAsync( + request, + entry, + targetEntry, + cancellationToken); + } + } + } + + private PinnedDirectoryCreation.PinnedFileEntry? TryOpenMarkerlessStableNativeRenameSource( + MoveJobEntry entry, + PinnedDirectoryCreation.PinnedDirectoryAnchor sourceParent, + PinnedDirectoryCreation.PinnedFileEntry sourceEntry, + PinnedDirectoryCreation.PinnedDirectoryAnchor targetParent) + { + if (!OperatingSystem.IsWindows() + || (faultInjector != null && !faultInjector.AllowMarkerlessFileRename) + || !sourceEntry.IsOnSameVolume(targetParent) + || sourceParent.TryOpenExistingFileForStableDeleteWithOutcome( + Path.GetFileName(sourceEntry.FullPath), + out var stableEntry) != PinnedFileOpenOutcome.Opened + || stableEntry == null) + { + return null; + } + + if (!stableEntry.IdentifiesSameEntry(sourceEntry) + || !stableEntry.VisiblePathMatches() + || !stableEntry.MatchesMetadata(entry.Length, entry.LastWriteTimeUtc) + || !string.Equals( + stableEntry.GetObjectIdentity(), + entry.SourcePhysicalObjectIdentity, + StringComparison.Ordinal)) + { + stableEntry.Dispose(); + return null; + } + + return stableEntry; + } + + private async Task<(bool Published, PinnedDirectoryCreation.PinnedFileEntry? VerificationLease)> + TryPublishMarkerlessNativeRenameAsync( + AudiobookContentMoveRequest request, + string source, + string target, + MoveJobEntry entry, + PinnedDirectoryCreation.PinnedDirectoryAnchor sourceParent, + PinnedDirectoryCreation.PinnedFileEntry sourceEntry, + PinnedDirectoryCreation.PinnedDirectoryAnchor targetParent, + PinnedDirectoryCreation.PinnedFileEntry? stableRenameEntry, + CancellationToken cancellationToken) + { + if ((faultInjector != null && !faultInjector.AllowMarkerlessFileRename) + || !sourceEntry.IsOnSameVolume(targetParent)) + { + return (false, null); + } + + PinnedDirectoryCreation.PinnedFileEntry? ownedRenameEntry = null; + PinnedDirectoryCreation.PinnedFileEntry? verificationLease = null; + var renameEntry = stableRenameEntry; + if (renameEntry == null) + { + ownedRenameEntry = sourceParent.TryOpenExistingFile( + Path.GetFileName(sourceEntry.FullPath), + requireDeleteAccess: true); + renameEntry = ownedRenameEntry; + } + + try + { + if (renameEntry == null + || !renameEntry.IdentifiesSameEntry(sourceEntry) + || !renameEntry.VisiblePathMatches() + || !string.Equals( + renameEntry.GetObjectIdentity(), + entry.SourcePhysicalObjectIdentity, + StringComparison.Ordinal)) + { + return (false, null); + } + + await EnsureMutationAuthorizedAsync( + request, + source, + target, + cancellationToken); + renameEntry.MoveTo( + targetParent, + Path.GetFileName(ResolveManifestPath( + target, + entry, + request.TargetSemantics, + "target"))); + sourceParent.FlushDirectoryEntry(); + if (!string.Equals( + sourceParent.FullPath, + targetParent.FullPath, + StringComparison.Ordinal)) + { + targetParent.FlushDirectoryEntry(); + } + + faultInjector?.OnCopyMutation( + request.JobId, + CopyMutationFaultPoint.AfterMarkerlessNativeRenameBeforeStateUpdate); + var targetIdentity = renameEntry.GetObjectIdentity(); + if (!renameEntry.VisiblePathMatches() + || !string.Equals( + targetIdentity, + entry.SourcePhysicalObjectIdentity, + StringComparison.Ordinal)) + { + throw new MoveNeedsAttentionException( + $"The markerless native rename target changed physical generation: {entry.RelativePath}"); + } + + if (stableRenameEntry != null) + { + verificationLease = targetParent.OpenExistingFileForVerificationLease( + Path.GetFileName(renameEntry.FullPath)); + if (!verificationLease.IdentifiesSameEntry(renameEntry) + || !verificationLease.VisiblePathMatches()) + { + throw new MoveNeedsAttentionException( + $"The markerless native rename verification lease did not capture the published generation: {entry.RelativePath}"); + } + } + + await UpdateTargetEntryStateAsync( + request.JobId, + request.LeaseToken, + entry.RelativePath, + MoveJobEntryCopyState.Verified, + targetIdentity, + cancellationToken); + entry.CopyState = MoveJobEntryCopyState.Verified; + entry.TargetPhysicalObjectIdentity = targetIdentity; + var result = (Published: true, VerificationLease: verificationLease); + verificationLease = null; + return result; + } + finally + { + verificationLease?.Dispose(); + ownedRenameEntry?.Dispose(); + } + } + + private async Task TryRecoverMarkerlessNativeRenameAsync( + AudiobookContentMoveRequest request, + MoveJobEntry entry, + PinnedDirectoryCreation.PinnedFileEntry targetEntry, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(entry.SourcePhysicalObjectIdentity) + || !targetEntry.VisiblePathMatches() + || !string.Equals( + targetEntry.GetObjectIdentity(), + entry.SourcePhysicalObjectIdentity, + StringComparison.Ordinal) + || entry.CopyState is not ( + MoveJobEntryCopyState.Pending or MoveJobEntryCopyState.Verified) + || (entry.CopyState == MoveJobEntryCopyState.Pending + && !string.IsNullOrWhiteSpace( + entry.TargetPhysicalObjectIdentity)) + || (entry.CopyState == MoveJobEntryCopyState.Verified + && !string.Equals( + entry.TargetPhysicalObjectIdentity, + entry.SourcePhysicalObjectIdentity, + StringComparison.Ordinal)) + || !targetEntry.MatchesMetadata( + entry.Length, + entry.LastWriteTimeUtc)) + { + return false; + } + + if (entry.CopyState == MoveJobEntryCopyState.Pending) + { + await UpdateTargetEntryStateAsync( + request.JobId, + request.LeaseToken, + entry.RelativePath, + MoveJobEntryCopyState.Verified, + entry.SourcePhysicalObjectIdentity, + cancellationToken); + entry.CopyState = MoveJobEntryCopyState.Verified; + entry.TargetPhysicalObjectIdentity = + entry.SourcePhysicalObjectIdentity; + } + + return true; + } + + private async Task TryCompleteMarkerlessNativeRenameCleanupAsync( + AudiobookContentMoveRequest request, + MoveJobEntry entry, + string targetPath, + CancellationToken cancellationToken) + { + if (entry.CopyState != MoveJobEntryCopyState.Verified + || string.IsNullOrWhiteSpace(entry.SourcePhysicalObjectIdentity) + || !string.Equals( + entry.SourcePhysicalObjectIdentity, + entry.TargetPhysicalObjectIdentity, + StringComparison.Ordinal)) + { + return false; + } + + var targetParentPath = Path.GetDirectoryName(targetPath) + ?? throw new MoveNeedsAttentionException( + "A markerless native-rename target has no parent."); + using var targetParent = PinnedDirectoryCreation.OpenPinnedBoundary( + targetParentPath); + using var targetEntry = targetParent.TryOpenExistingFile( + Path.GetFileName(targetPath), + requireDeleteAccess: false); + if (targetEntry == null + || !TargetMatchesMarkerlessRenameEntry(entry, targetEntry) + || !targetEntry.MatchesMetadata( + entry.Length, + entry.LastWriteTimeUtc)) + { + return false; + } + + await UpdateCleanupStateAsync( + request.JobId, + request.LeaseToken, + entry.RelativePath, + MoveJobEntryCleanupState.DeletionAuthorized, + cancellationToken); + entry.CleanupState = MoveJobEntryCleanupState.DeletionAuthorized; + await UpdateCleanupStateAsync( + request.JobId, + request.LeaseToken, + entry.RelativePath, + MoveJobEntryCleanupState.Deleted, + cancellationToken); + entry.CleanupState = MoveJobEntryCleanupState.Deleted; + return true; + } + + private static bool TargetMatchesMarkerlessRenameEntry( + MoveJobEntry entry, + PinnedDirectoryCreation.PinnedFileEntry targetEntry) => + targetEntry.VisiblePathMatches() + && string.Equals( + targetEntry.GetObjectIdentity(), + entry.SourcePhysicalObjectIdentity, + StringComparison.Ordinal) + && string.Equals( + targetEntry.GetObjectIdentity(), + entry.TargetPhysicalObjectIdentity, + StringComparison.Ordinal); +} diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.MarkerlessSourceProof.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.MarkerlessSourceProof.cs new file mode 100644 index 000000000..39641de1b --- /dev/null +++ b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.MarkerlessSourceProof.cs @@ -0,0 +1,186 @@ +using System.Buffers; +using System.Security.Cryptography; + +namespace Listenarr.Infrastructure.Library.Moving; + +internal sealed partial class AudiobookContentMoveService +{ + private async Task CaptureMarkerlessSourceIdentitiesAsync( + AudiobookContentMoveRequest request, + string source, + IReadOnlyCollection manifest, + CancellationToken cancellationToken) + { + using (var root = PinnedDirectoryCreation.OpenPinnedBoundary(source)) + { + var rootIdentity = root.GetDirectoryObjectIdentity(); + var endpoints = await GetEndpointObjectIdentitiesAsync( + request.JobId, + cancellationToken); + if (!string.IsNullOrWhiteSpace( + endpoints.SourceDirectoryObjectIdentity) + && !string.Equals( + endpoints.SourceDirectoryObjectIdentity, + rootIdentity, + StringComparison.Ordinal)) + { + throw new MoveNeedsAttentionException( + "The markerless move source root changed physical generation."); + } + if (!root.VisiblePathMatches()) + { + throw new MoveNeedsAttentionException( + "The markerless move source root changed while it was pinned."); + } + if (string.IsNullOrWhiteSpace( + endpoints.SourceDirectoryObjectIdentity)) + { + await UpdateEndpointObjectIdentitiesAsync( + request.JobId, + request.LeaseToken, + rootIdentity, + targetDirectoryObjectIdentity: null, + cancellationToken); + } + } + + foreach (var entry in manifest.Where(IsPhysicalManifestEntry)) + { + cancellationToken.ThrowIfCancellationRequested(); + var fullPath = ResolveManifestPath( + source, + entry, + request.SourceSemantics, + "source"); + if (entry.EntryType == MoveJobEntryType.File + && !File.Exists(fullPath) + && IsVerifiedMarkerlessNativeRenameEntry(entry)) + { + continue; + } + + var parentPath = Path.GetDirectoryName(fullPath) + ?? throw new MoveNeedsAttentionException( + "A source manifest entry has no parent directory."); + using var parent = PinnedDirectoryCreation.OpenPinnedBoundary(parentPath); + string identity; + if (entry.EntryType == MoveJobEntryType.Directory) + { + using var directory = parent.OpenExistingChild( + Path.GetFileName(fullPath)); + identity = directory.GetDirectoryObjectIdentity(); + if (!directory.VisiblePathMatches()) + { + throw new MoveNeedsAttentionException( + $"Source directory changed while pinned: {entry.RelativePath}"); + } + } + else + { + using var file = parent.OpenExistingFile( + Path.GetFileName(fullPath), + requireDeleteAccess: false); + ValidatePinnedSourcePhysicalIdentity(request, entry, file); + if (!PinnedFileLengthMatchesManifest(file, entry)) + { + throw new MoveNeedsAttentionException( + $"Source file changed while its generation was captured: {entry.RelativePath}"); + } + identity = file.GetObjectIdentity(); + } + + if (!string.IsNullOrWhiteSpace(entry.SourcePhysicalObjectIdentity) + && !string.Equals( + entry.SourcePhysicalObjectIdentity, + identity, + StringComparison.Ordinal)) + { + throw new MoveNeedsAttentionException( + $"Source entry changed physical generation: {entry.RelativePath}"); + } + if (string.IsNullOrWhiteSpace(entry.SourcePhysicalObjectIdentity)) + { + await UpdateSourceEntryProofAsync( + request.JobId, + request.LeaseToken, + entry.RelativePath, + identity, + entry.Sha256, + cancellationToken); + entry.SourcePhysicalObjectIdentity = identity; + } + } + } + + private static async Task ComputeMarkerlessSourceProofHashAsync( + AudiobookContentMoveRequest request, + MoveJobEntry entry, + string fullPath, + PinnedDirectoryCreation.PinnedFileEntry file, + long completedWorkUnits, + long totalWorkUnits, + CancellationToken cancellationToken) + { + var initialLastWriteTimeUtc = File.GetLastWriteTimeUtc(fullPath); + await using var stream = file.OpenReadStream( + bufferSize: 1024 * 1024, + asynchronous: false); + if (stream.Length != entry.Length + || initialLastWriteTimeUtc != entry.LastWriteTimeUtc) + { + throw new MoveNeedsAttentionException( + $"Source file metadata changed before content proof was captured: {entry.RelativePath}"); + } + + using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + var buffer = ArrayPool.Shared.Rent(1024 * 1024); + try + { + long hashed = 0; + long lastReported = 0; + var reportInterval = Math.Max( + 16L * 1024 * 1024, + Math.Max(totalWorkUnits / 100, 1)); + while (true) + { + var read = await stream.ReadAsync( + buffer.AsMemory(0, buffer.Length), + cancellationToken); + if (read == 0) + { + break; + } + + hash.AppendData(buffer, 0, read); + hashed += read; + if (hashed - lastReported >= reportInterval) + { + lastReported = hashed; + await ReportProgressAsync( + request, + CalculateWeightedProgress( + 5, + 65, + completedWorkUnits + hashed, + totalWorkUnits), + "Verifying source", + cancellationToken); + } + } + + if (hashed != entry.Length + || !file.VisiblePathMatches() + || File.GetLastWriteTimeUtc(fullPath) != initialLastWriteTimeUtc) + { + throw new MoveNeedsAttentionException( + $"Source file changed while its content proof was being captured: {entry.RelativePath}"); + } + + return Convert.ToHexString(hash.GetHashAndReset()); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } +} diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.MarkerlessVerification.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.MarkerlessVerification.cs new file mode 100644 index 000000000..a5805fbba --- /dev/null +++ b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.MarkerlessVerification.cs @@ -0,0 +1,250 @@ +using System.Buffers; +using System.Security.Cryptography; + +namespace Listenarr.Infrastructure.Library.Moving; + +internal sealed partial class AudiobookContentMoveService +{ + private async Task VerifyMarkerlessTargetAsync( + AudiobookContentMoveRequest request, + string target, + IReadOnlyCollection manifest, + CancellationToken cancellationToken, + double? progressStart = null, + double progressSpan = 0, + string? progressPhase = null, + MarkerlessTargetVerificationLease? targetVerificationLease = null) + { + await CaptureOrValidateMarkerlessTargetRootAsync( + request, + target, + cancellationToken); + ValidateExistingDestinationContents( + request.Source, + target, + manifest, + request.JobId, + request.TargetSemantics, + tempOwnership: null, + quarantineOwnership: null, + allowPartialFiles: false, + targetDirectoryOwnership: request.TargetDirectoryOwnership, + allowRecoveryMarker: false); + var files = manifest + .Where(IsPhysicalManifestEntry) + .Where(entry => entry.EntryType == MoveJobEntryType.File) + .ToList(); + var totalUnits = files.Sum(GetProgressUnits); + long completedUnits = 0; + foreach (var entry in manifest.Where(IsPhysicalManifestEntry)) + { + var targetPath = ResolveManifestPath( + target, + entry, + request.TargetSemantics, + "target"); + if (entry.EntryType == MoveJobEntryType.Directory) + { + ValidateExistingMoveDirectory( + targetPath, + "markerless target manifest directory"); + continue; + } + + if (entry.CopyState != MoveJobEntryCopyState.Verified + || string.IsNullOrWhiteSpace(entry.TargetPhysicalObjectIdentity)) + { + throw new MoveNeedsAttentionException( + $"A markerless target file is not durably verified: {entry.RelativePath}"); + } + var parentPath = Path.GetDirectoryName(targetPath) + ?? throw new MoveNeedsAttentionException( + "A markerless target file has no parent."); + using var parent = PinnedDirectoryCreation.OpenPinnedBoundary(parentPath); + using var file = parent.OpenExistingFile( + Path.GetFileName(targetPath), + requireDeleteAccess: false); + ValidateMarkerlessTargetEntry(entry, file); + PinnedDirectoryCreation.PinnedFileEntry? leasedTargetEntry = null; + var hasProtectedContentProof = targetVerificationLease != null + && targetVerificationLease.TryGet( + entry.RelativePath, + out leasedTargetEntry); + if (hasProtectedContentProof) + { + if (leasedTargetEntry == null + || !leasedTargetEntry.VisiblePathMatches() + || !leasedTargetEntry.IdentifiesSameEntry(file) + || !string.Equals( + leasedTargetEntry.GetObjectIdentity(), + entry.TargetPhysicalObjectIdentity, + StringComparison.Ordinal) + || !leasedTargetEntry.MatchesMetadata( + entry.Length, + entry.LastWriteTimeUtc)) + { + throw new MoveNeedsAttentionException( + $"A protected markerless target generation changed after native publication: {entry.RelativePath}"); + } + } + else if (IsVerifiedMarkerlessNativeRenameEntry(entry)) + { + if (!file.MatchesMetadata(entry.Length, entry.LastWriteTimeUtc)) + { + throw new MoveNeedsAttentionException( + $"A markerless native-rename target changed metadata after publication: {entry.RelativePath}"); + } + } + else + { + Func? reportFileProgress = null; + if (progressStart.HasValue && progressSpan > 0) + { + reportFileProgress = bytesRead => ReportProgressAsync( + request, + CalculateWeightedProgress( + progressStart.Value, + progressSpan, + completedUnits + Math.Min(bytesRead, GetProgressUnits(entry)), + totalUnits), + progressPhase ?? "Verifying target", + cancellationToken); + } + if (!await PinnedFileMatchesManifestAsync( + file, + entry, + cancellationToken, + reportFileProgress)) + { + throw new MoveNeedsAttentionException( + $"A markerless target file failed final verification: {entry.RelativePath}"); + } + } + completedUnits += GetProgressUnits(entry); + if (progressStart.HasValue && progressSpan > 0) + { + await ReportProgressAsync( + request, + CalculateWeightedProgress( + progressStart.Value, + progressSpan, + completedUnits, + totalUnits), + progressPhase ?? "Verifying target", + cancellationToken); + } + } + } + + private static void ValidateMarkerlessSourceEntry( + AudiobookContentMoveRequest request, + MoveJobEntry entry, + PinnedDirectoryCreation.PinnedFileEntry sourceEntry) + { + ValidatePinnedSourcePhysicalIdentity(request, entry, sourceEntry); + if (string.IsNullOrWhiteSpace(entry.SourcePhysicalObjectIdentity) + || !string.Equals( + entry.SourcePhysicalObjectIdentity, + sourceEntry.GetObjectIdentity(), + StringComparison.Ordinal) + || !sourceEntry.VisiblePathMatches()) + { + throw new MoveNeedsAttentionException( + $"A markerless source file changed physical generation: {entry.RelativePath}"); + } + } + + private static void ValidateMarkerlessTargetEntry( + MoveJobEntry entry, + PinnedDirectoryCreation.PinnedFileEntry targetEntry) + { + if (string.IsNullOrWhiteSpace(entry.TargetPhysicalObjectIdentity) + || !string.Equals( + entry.TargetPhysicalObjectIdentity, + targetEntry.GetObjectIdentity(), + StringComparison.Ordinal) + || !targetEntry.VisiblePathMatches()) + { + throw new MoveNeedsAttentionException( + $"A markerless target file changed physical generation: {entry.RelativePath}"); + } + } + + private static bool PinnedFileLengthMatchesManifest( + PinnedDirectoryCreation.PinnedFileEntry file, + MoveJobEntry manifestEntry) + { + if (manifestEntry.EntryType != MoveJobEntryType.File) + { + return false; + } + + using var stream = file.OpenReadStream( + bufferSize: 128 * 1024, + asynchronous: false); + return stream.Length == manifestEntry.Length; + } + + private static async Task PinnedFileMatchesManifestAsync( + PinnedDirectoryCreation.PinnedFileEntry file, + MoveJobEntry manifestEntry, + CancellationToken cancellationToken, + Func? progressReporter = null) + { + if (manifestEntry.EntryType != MoveJobEntryType.File + || string.IsNullOrWhiteSpace(manifestEntry.Sha256)) + { + return false; + } + await using var stream = file.OpenReadStream( + bufferSize: 1024 * 1024, + asynchronous: false); + if (stream.Length != manifestEntry.Length) + { + return false; + } + + using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + var buffer = ArrayPool.Shared.Rent(1024 * 1024); + try + { + long hashed = 0; + long lastReported = 0; + var reportInterval = Math.Max( + 16L * 1024 * 1024, + Math.Max(manifestEntry.Length / 100, 1)); + while (true) + { + var read = await stream.ReadAsync( + buffer.AsMemory(0, buffer.Length), + cancellationToken); + if (read == 0) + { + break; + } + + hash.AppendData(buffer, 0, read); + hashed += read; + if (progressReporter != null + && hashed - lastReported >= reportInterval) + { + lastReported = hashed; + await progressReporter(hashed); + } + } + + if (hashed != manifestEntry.Length) + { + return false; + } + return string.Equals( + Convert.ToHexString(hash.GetHashAndReset()), + manifestEntry.Sha256, + StringComparison.Ordinal); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } +} diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.PersistedSourceManifest.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.PersistedSourceManifest.cs index 5d7522794..235f37492 100644 --- a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.PersistedSourceManifest.cs +++ b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.PersistedSourceManifest.cs @@ -11,7 +11,9 @@ private static async Task ValidatePersistedSourceManifestAsync( IReadOnlyCollection manifest, FileSystemPathSemantics sourceSemantics, CancellationToken cancellationToken, - bool requireTrackedFile = true) + bool requireTrackedFile = true, + bool verifyFileContents = true, + bool allowVerifiedNativeRenameMissingSources = false) { ValidateMoveSourceRoot(source); if (manifest.Count == 0) @@ -88,13 +90,22 @@ private static async Task ValidatePersistedSourceManifestAsync( continue; } + if (!File.Exists(fullPath) + && allowVerifiedNativeRenameMissingSources + && IsVerifiedMarkerlessNativeRenameEntry(entry)) + { + continue; + } + if (entry.EntryType != MoveJobEntryType.File || !File.Exists(fullPath) || (File.GetAttributes(fullPath) & FileAttributes.ReparsePoint) != 0 - || !await FileMatchesManifestAsync( - fullPath, - entry, - cancellationToken)) + || !FileMetadataMatchesManifest(fullPath, entry) + || (verifyFileContents + && !await FileMatchesManifestAsync( + fullPath, + entry, + cancellationToken))) { throw new MoveNeedsAttentionException( $"Manifest file changed, disappeared, or became linked: {entry.RelativePath}"); @@ -104,6 +115,30 @@ private static async Task ValidatePersistedSourceManifestAsync( ValidateMoveSourceRoot(source); } + private static bool IsVerifiedMarkerlessNativeRenameEntry( + MoveJobEntry entry) => + entry.EntryType == MoveJobEntryType.File + && entry.CopyState == MoveJobEntryCopyState.Verified + && !string.IsNullOrWhiteSpace(entry.SourcePhysicalObjectIdentity) + && string.Equals( + entry.SourcePhysicalObjectIdentity, + entry.TargetPhysicalObjectIdentity, + StringComparison.Ordinal); + + private static bool FileMetadataMatchesManifest( + string path, + MoveJobEntry manifestEntry) + { + if (manifestEntry.EntryType != MoveJobEntryType.File) + { + return false; + } + + var fileInfo = new FileInfo(path); + return fileInfo.Length == manifestEntry.Length + && fileInfo.LastWriteTimeUtc == manifestEntry.LastWriteTimeUtc; + } + private static void ValidateManifestAncestorChain( string source, string fullPath, diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.Persistence.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.Persistence.cs index 978644794..c29ada04e 100644 --- a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.Persistence.cs +++ b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.Persistence.cs @@ -10,6 +10,40 @@ private Task EnsureLeaseOwnedAsync( CancellationToken cancellationToken) => executionStore.EnsureLeaseOwnedAsync(jobId, leaseToken, cancellationToken); + private Task GetExecutionProtocolVersionAsync( + Guid jobId, + CancellationToken cancellationToken) => + executionStore.GetExecutionProtocolVersionAsync(jobId, cancellationToken); + + private Task GetEndpointObjectIdentitiesAsync( + Guid jobId, + CancellationToken cancellationToken) => + executionStore.GetEndpointObjectIdentitiesAsync(jobId, cancellationToken); + + private Task UpdateEndpointObjectIdentitiesAsync( + Guid jobId, + MoveLeaseToken leaseToken, + string? sourceDirectoryObjectIdentity, + string? targetDirectoryObjectIdentity, + CancellationToken cancellationToken) => + executionStore.UpdateEndpointObjectIdentitiesAsync( + jobId, + leaseToken, + sourceDirectoryObjectIdentity, + targetDirectoryObjectIdentity, + cancellationToken); + + private Task UpdateSourceDirectoryCleanupStateAsync( + Guid jobId, + MoveLeaseToken leaseToken, + MoveJobEntryCleanupState cleanupState, + CancellationToken cancellationToken) => + executionStore.UpdateSourceDirectoryCleanupStateAsync( + jobId, + leaseToken, + cleanupState, + cancellationToken); + private Task ValidatePersistedMoveIdentityAsync( Guid jobId, string source, @@ -65,6 +99,36 @@ private Task UpdateCopyStateAsync( CancellationToken cancellationToken) => executionStore.UpdateCopyStateAsync(jobId, leaseToken, cancellationToken); + private Task UpdateSourceEntryProofAsync( + Guid jobId, + MoveLeaseToken leaseToken, + string relativePath, + string sourcePhysicalObjectIdentity, + string? sha256, + CancellationToken cancellationToken) => + executionStore.UpdateSourceEntryProofAsync( + jobId, + leaseToken, + relativePath, + sourcePhysicalObjectIdentity, + sha256, + cancellationToken); + + private Task UpdateTargetEntryStateAsync( + Guid jobId, + MoveLeaseToken leaseToken, + string relativePath, + MoveJobEntryCopyState copyState, + string? targetPhysicalObjectIdentity, + CancellationToken cancellationToken) => + executionStore.UpdateTargetEntryStateAsync( + jobId, + leaseToken, + relativePath, + copyState, + targetPhysicalObjectIdentity, + cancellationToken); + private Task UpdateJobPhaseAsync( Guid jobId, MoveLeaseToken leaseToken, @@ -104,4 +168,19 @@ private Task UpdateCreatedDirectoryStateAsync( path, state, cancellationToken); + + private Task UpdateCreatedDirectoryPublicationAsync( + Guid jobId, + MoveLeaseToken leaseToken, + string path, + MoveCreatedDirectoryState state, + string directoryObjectIdentity, + CancellationToken cancellationToken) => + executionStore.UpdateCreatedDirectoryPublicationAsync( + jobId, + leaseToken, + path, + state, + directoryObjectIdentity, + cancellationToken); } diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.Progress.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.Progress.cs new file mode 100644 index 000000000..cb327082b --- /dev/null +++ b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.Progress.cs @@ -0,0 +1,34 @@ +namespace Listenarr.Infrastructure.Library.Moving; + +internal sealed partial class AudiobookContentMoveService +{ + private static Task ReportProgressAsync( + AudiobookContentMoveRequest request, + double progress, + string phase, + CancellationToken cancellationToken) => + request.ProgressReporter?.Invoke( + Math.Clamp(progress, 0, 100), + phase, + cancellationToken) ?? Task.CompletedTask; + + private static double CalculateWeightedProgress( + double start, + double span, + long completedUnits, + long totalUnits) + { + if (totalUnits <= 0) + { + return start + span; + } + + return start + (span * Math.Clamp( + (double)completedUnits / totalUnits, + 0, + 1)); + } + + private static long GetProgressUnits(MoveJobEntry entry) => + Math.Max(entry.Length, 1); +} diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.RecoveryWorkflow.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.RecoveryWorkflow.cs index f6bd8c4c9..c6b106332 100644 --- a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.RecoveryWorkflow.cs +++ b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.RecoveryWorkflow.cs @@ -29,6 +29,17 @@ await ValidatePersistedMoveIdentityAsync( targetSemantics, request.LeaseToken, cancellationToken); + if (await GetExecutionProtocolVersionAsync( + request.JobId, + cancellationToken) + >= MoveExecutionProtocol.MarkerlessDatabaseState) + { + return await GetMarkerlessRecoverableMoveAsync( + request, + source, + target, + cancellationToken); + } await RecoverRecoveryMarkerWriteFilesAsync( source, request, @@ -224,6 +235,34 @@ public async Task ResumeSourceCleanupAsync( } await EnsureLeaseOwnedAsync(request.JobId, request.LeaseToken, cancellationToken); + if (await GetExecutionProtocolVersionAsync( + request.JobId, + cancellationToken) + >= MoveExecutionProtocol.MarkerlessDatabaseState) + { + await DeleteMarkerlessSourceAsync( + request, + result.Source, + result.Target, + result.TargetInsideSource, + manifest, + cancellationToken); + VerifySourceCleanupState( + request, + result.Source, + result.Target, + manifest); + var markerlessIdentities = CreatePersistedTargetPhysicalIdentityMap( + result.Target, + manifest, + request.TargetSemantics); + return result with + { + SourceCleanupCompleted = true, + RecoveryMarkerPath = string.Empty, + TargetPhysicalObjectIdentities = markerlessIdentities + }; + } await DeleteOriginalSourceAsync( result.Source, diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.SourceAncestorCleanup.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.SourceAncestorCleanup.cs new file mode 100644 index 000000000..043c6218a --- /dev/null +++ b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.SourceAncestorCleanup.cs @@ -0,0 +1,249 @@ +using Listenarr.Domain.Common; + +namespace Listenarr.Infrastructure.Library.Moving; + +internal sealed partial class AudiobookContentMoveService +{ + private async Task RemoveEmptyDirectoryTreeAsync( + AudiobookContentMoveRequest request, + string source, + string target, + string directory, + string boundary, + FileSystemPathSemantics semantics, + CancellationToken cancellationToken) + { + var current = directory; + while (Directory.Exists(current) + && !FileSystemPathIdentity.AreEquivalent( + current, + boundary, + semantics)) + { + if (!FileSystemSafety.TryValidateMutationTarget( + current, + [boundary], + out current, + out var reason)) + { + throw new MoveNeedsAttentionException(reason); + } + + var ownership = await ResolveOwnedDirectoryForCleanupAsync( + current, + semantics, + cancellationToken); + if (ownership == null) + { + if (!request.AllowUnownedSourceAncestorCleanup) + { + return; + } + + ValidateExistingMoveDirectory( + current, + "unowned source ancestor cleanup directory"); + if (Directory.EnumerateFileSystemEntries(current).Any()) + { + return; + } + + faultInjector?.OnMoveFinalization( + request.JobId, + MoveFinalizationFaultPoint.BeforeSourceAncestorDelete); + await EnsureMutationAuthorizedAsync( + request, + source, + target, + cancellationToken); + if (!FileSystemSafety.TryDeleteEmptyDirectory( + current, + [boundary], + out _)) + { + return; + } + + current = Path.GetDirectoryName(current) ?? boundary; + continue; + } + if (ownership.State == LibraryDirectoryOwnershipState.Removing) + { + var interruptedRemovalCompleted = await ResumeOwnedDirectoryRemovalAsync( + request, + source, + target, + ownership, + cancellationToken); + if (!interruptedRemovalCompleted) + { + return; + } + + current = Path.GetDirectoryName(current) ?? boundary; + continue; + } + + ValidateExistingMoveDirectory(current, "source ancestor cleanup directory"); + if (Directory.EnumerateFileSystemEntries(current).Any()) + { + return; + } + + faultInjector?.OnMoveFinalization( + request.JobId, + MoveFinalizationFaultPoint.BeforeSourceAncestorDelete); + await EnsureMutationAuthorizedAsync(request, source, target, cancellationToken); + var finalOwnership = await ResolveOwnedDirectoryForCleanupAsync( + current, + semantics, + cancellationToken); + if (finalOwnership == null + || finalOwnership.Id != ownership.Id + || !string.Equals( + finalOwnership.PathOwnershipKey, + ownership.PathOwnershipKey, + StringComparison.Ordinal)) + { + throw new MoveNeedsAttentionException( + "The durable directory ownership claim changed before source-parent cleanup."); + } + + ValidateExistingMoveDirectory(current, "source ancestor cleanup directory"); + if (Directory.EnumerateFileSystemEntries(current).Any()) + { + return; + } + + var ownershipKey = finalOwnership.PathOwnershipKey + ?? throw new MoveNeedsAttentionException( + "The durable directory ownership key is unavailable."); + await directoryOwnershipStore.BeginRemovalAsync( + finalOwnership.Id, + ownershipKey, + cancellationToken); + var removalCompleted = await ResumeOwnedDirectoryRemovalAsync( + request, + source, + target, + finalOwnership, + cancellationToken); + if (!removalCompleted) + { + return; + } + + current = Path.GetDirectoryName(current) ?? boundary; + } + } + + private static bool IsSourceCleanupBoundary( + string path, + string? boundary, + FileSystemPathSemantics semantics) + { + if (string.IsNullOrWhiteSpace(boundary)) + { + return false; + } + + try + { + return FileSystemPathIdentity.AreEquivalent(path, boundary, semantics); + } + catch (Exception exception) when (exception is + ArgumentException or InvalidOperationException + or NotSupportedException or PathTooLongException) + { + throw new MoveNeedsAttentionException( + $"The source cleanup boundary is invalid: {exception.Message}"); + } + } + + private async Task RemoveEmptySourceAncestorsAsync( + AudiobookContentMoveRequest request, + string source, + string target, + string? boundary, + FileSystemPathSemantics semantics, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(boundary)) + { + return; + } + + var fullBoundary = Path.GetFullPath(boundary); + var current = Path.GetDirectoryName(Path.GetFullPath(source)); + while (current != null + && FileSystemPathIdentity.IsSameOrInside(current, fullBoundary, semantics)) + { + if (FileSystemPathIdentity.AreEquivalent(current, fullBoundary, semantics)) + { + return; + } + + if (Directory.Exists(current)) + { + await RemoveEmptyDirectoryTreeAsync( + request, + source, + target, + current, + fullBoundary, + semantics, + cancellationToken); + return; + } + + var ownership = await ResolveOwnedDirectoryForCleanupAsync( + current, + semantics, + cancellationToken); + if (ownership != null) + { + if (ownership.State != LibraryDirectoryOwnershipState.Removing) + { + throw new MoveNeedsAttentionException( + "An owned source-parent directory disappeared without a durable cleanup intent."); + } + + await ResumeOwnedDirectoryRemovalAsync( + request, + source, + target, + ownership, + cancellationToken); + } + + current = Path.GetDirectoryName(current); + } + } + + private async Task ResolveOwnedDirectoryForCleanupAsync( + string directory, + FileSystemPathSemantics semantics, + CancellationToken cancellationToken) + { + var resolution = await directoryOwnershipStore.ResolveOwnedAsync( + directory, + semantics, + cancellationToken); + return resolution.State switch + { + LibraryDirectoryOwnershipResolutionState.Owned + when resolution.Ownership != null => resolution.Ownership, + LibraryDirectoryOwnershipResolutionState.Unowned => null, + LibraryDirectoryOwnershipResolutionState.Conflict => + throw new MoveNeedsAttentionException( + resolution.Reason + ?? "Conflicting durable directory ownership claims prevent cleanup."), + LibraryDirectoryOwnershipResolutionState.Unavailable => + throw new MoveNeedsAttentionException( + resolution.Reason + ?? "Durable directory ownership is unavailable for cleanup."), + _ => throw new MoveNeedsAttentionException( + "Durable directory ownership could not be resolved for cleanup.") + }; + } +} diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.TargetPhysicalIdentity.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.TargetPhysicalIdentity.cs index b755ba718..ee23c650c 100644 --- a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.TargetPhysicalIdentity.cs +++ b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.TargetPhysicalIdentity.cs @@ -25,6 +25,42 @@ internal async Task> cancellationToken); } + private static IReadOnlyDictionary + CreatePersistedTargetPhysicalIdentityMap( + string target, + IEnumerable manifest, + FileSystemPathSemantics targetSemantics) + { + var identities = new Dictionary(targetSemantics.Comparer); + foreach (var entry in manifest + .Where(candidate => candidate.EntryType == MoveJobEntryType.File) + .Where(IsPhysicalManifestEntry)) + { + if (string.IsNullOrWhiteSpace(entry.TargetPhysicalObjectIdentity)) + { + throw new MoveNeedsAttentionException( + $"A markerless target file lacks persisted physical identity: {entry.RelativePath}"); + } + if (!FileSystemPathIdentity.TryResolveRelativePathWithinBase( + target, + entry.RelativePath, + targetSemantics, + out var targetFilePath)) + { + throw new MoveNeedsAttentionException( + $"The persisted markerless target identity escaped its root: {entry.RelativePath}"); + } + + identities.Add( + FileSystemPathIdentity.Canonicalize( + targetFilePath, + targetSemantics.Syntax), + entry.TargetPhysicalObjectIdentity); + } + + return identities; + } + private static async Task> CapturePublishedTargetPhysicalIdentitiesAsync( string target, diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.cs index 33ee9c6f9..0f36f29bd 100644 --- a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.cs +++ b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.cs @@ -26,7 +26,9 @@ internal sealed record AudiobookContentMoveRequest( MoveLeaseToken LeaseToken, string? SourceCleanupBoundary = null, LibraryDirectoryOwnership? TargetDirectoryOwnership = null, - IReadOnlyDictionary? SourcePhysicalObjectIdentities = null) + IReadOnlyDictionary? SourcePhysicalObjectIdentities = null, + Func? ProgressReporter = null, + bool AllowUnownedSourceAncestorCleanup = false) { public string LeaseOwner => LeaseToken.Owner; public int LeaseGeneration => LeaseToken.Generation; @@ -39,7 +41,8 @@ internal sealed record AudiobookContentMoveResult( bool SourceInsideTarget, string RecoveryMarkerPath, bool SourceCleanupCompleted, - IReadOnlyDictionary TargetPhysicalObjectIdentities); + IReadOnlyDictionary TargetPhysicalObjectIdentities, + MarkerlessTargetVerificationLease? TargetVerificationLease = null); internal sealed class MoveNeedsAttentionException(string message) : IOException(message); @@ -103,6 +106,19 @@ await ValidatePersistedMoveIdentityAsync( var targetInsideSource = IsSameOrInside(target, source, sourceSemantics); var sourceInsideTarget = IsSameOrInside(source, target, targetSemantics); + var executionProtocolVersion = await GetExecutionProtocolVersionAsync( + request.JobId, + cancellationToken); + if (executionProtocolVersion >= MoveExecutionProtocol.MarkerlessDatabaseState) + { + return await MoveContentsMarkerlessAsync( + request, + source, + target, + targetInsideSource, + sourceInsideTarget, + cancellationToken); + } var targetParent = Path.GetDirectoryName(target); if (string.IsNullOrEmpty(targetParent)) diff --git a/listenarr.infrastructure/Library/Moving/AudiobookFilesystemDeleteService.Ownership.cs b/listenarr.infrastructure/Library/Moving/AudiobookFilesystemDeleteService.Ownership.cs index bbc26b7a3..7c4ce7406 100644 --- a/listenarr.infrastructure/Library/Moving/AudiobookFilesystemDeleteService.Ownership.cs +++ b/listenarr.infrastructure/Library/Moving/AudiobookFilesystemDeleteService.Ownership.cs @@ -125,9 +125,6 @@ private static void ValidateOwnedDirectoryForDelete( "An owned directory is missing without a removal intent."); } - LibraryDirectoryOwnershipMarker.Validate( - ownership, - ownership.CanonicalPath); } private async Task RetireOwnedDirectoryAsync( diff --git a/listenarr.infrastructure/Library/Moving/EfLibraryDirectoryOwnershipStore.Helpers.cs b/listenarr.infrastructure/Library/Moving/EfLibraryDirectoryOwnershipStore.Helpers.cs index dad55fd6f..c33a76674 100644 --- a/listenarr.infrastructure/Library/Moving/EfLibraryDirectoryOwnershipStore.Helpers.cs +++ b/listenarr.infrastructure/Library/Moving/EfLibraryDirectoryOwnershipStore.Helpers.cs @@ -10,10 +10,17 @@ private static void ValidatePinnedOwnership( { using var directory = creation.OpenCreatedDirectoryAnchor(); using var parent = creation.OpenParentDirectoryAnchor(); - LibraryDirectoryOwnershipMarker.Validate( - ownership, - directory, - parent); + if (!ManagedDirectoryIdentity.Matches( + ownership.DirectoryObjectIdentityVersion, + ownership.DirectoryObjectIdentity, + ownership.OwnershipToken, + directory.GetDirectoryObjectIdentity()) + || !directory.VisiblePathMatches() + || !parent.VisiblePathMatches()) + { + throw new InvalidOperationException( + "The owned directory no longer matches its persisted physical identity."); + } } private static void CleanupRetiredSiblingMarkers( diff --git a/listenarr.infrastructure/Library/Moving/EfLibraryDirectoryOwnershipStore.Resolution.cs b/listenarr.infrastructure/Library/Moving/EfLibraryDirectoryOwnershipStore.Resolution.cs index 6b644307a..0903c3f28 100644 --- a/listenarr.infrastructure/Library/Moving/EfLibraryDirectoryOwnershipStore.Resolution.cs +++ b/listenarr.infrastructure/Library/Moving/EfLibraryDirectoryOwnershipStore.Resolution.cs @@ -120,11 +120,11 @@ or LibraryDirectoryOwnershipState.Retained { try { - var parentPath = Path.GetDirectoryName(resolved.CanonicalPath) - ?? throw new InvalidOperationException( - "The owned directory has no parent for durable proof validation."); - using var parent = PinnedDirectoryCreation.OpenPinnedBoundary(parentPath); - using var live = parent.OpenExistingChild( + using var authorization = + await _boundaryAuthorizer.AuthorizeOwnershipAsync( + resolved, + cancellationToken); + using var live = authorization.ParentAnchor.OpenExistingChild( Path.GetFileName(resolved.CanonicalPath)); if (!ManagedDirectoryIdentity.Matches( resolved.DirectoryObjectIdentityVersion, @@ -132,16 +132,17 @@ or LibraryDirectoryOwnershipState.Retained resolved.OwnershipToken, live.GetDirectoryObjectIdentity()) || !live.VisiblePathMatches() - || !parent.VisiblePathMatches()) + || !authorization.ParentAnchor.VisiblePathMatches()) { throw new InvalidOperationException( - "The owned directory no longer matches its enrolled physical identity."); + "The owned directory no longer matches its persisted physical identity."); } AfterOwnedDirectoryPhysicalIdentityPinnedForTest?.Invoke(); - LibraryDirectoryOwnershipMarker.Validate( + _ = LibraryDirectoryOwnershipMarker.TryRetireMatchingMarkers( resolved, live, - parent); + authorization.ParentAnchor, + out _); } catch (Exception exception) when (exception is ArgumentException or IOException or UnauthorizedAccessException @@ -221,6 +222,33 @@ public async Task> GetOwnedWithinAsync( "A durable ownership claim lacks managed-root physical identity."); } + if (candidate.State is LibraryDirectoryOwnershipState.Owned + or LibraryDirectoryOwnershipState.Retained) + { + using var authorization = + await _boundaryAuthorizer.AuthorizeOwnershipAsync( + candidate, + cancellationToken); + using var live = authorization.ParentAnchor.OpenExistingChild( + Path.GetFileName(candidate.CanonicalPath)); + if (!ManagedDirectoryIdentity.Matches( + candidate.DirectoryObjectIdentityVersion, + candidate.DirectoryObjectIdentity, + candidate.OwnershipToken, + live.GetDirectoryObjectIdentity()) + || !live.VisiblePathMatches() + || !authorization.ParentAnchor.VisiblePathMatches()) + { + throw new InvalidOperationException( + "A durable ownership claim no longer matches its persisted physical directory generation."); + } + _ = LibraryDirectoryOwnershipMarker.TryRetireMatchingMarkers( + candidate, + live, + authorization.ParentAnchor, + out _); + } + owned.Add(candidate); } diff --git a/listenarr.infrastructure/Library/Moving/EfLibraryDirectoryOwnershipStore.cs b/listenarr.infrastructure/Library/Moving/EfLibraryDirectoryOwnershipStore.cs index 43fef5cf8..6edec2db2 100644 --- a/listenarr.infrastructure/Library/Moving/EfLibraryDirectoryOwnershipStore.cs +++ b/listenarr.infrastructure/Library/Moving/EfLibraryDirectoryOwnershipStore.cs @@ -15,13 +15,7 @@ internal sealed partial class EfLibraryDirectoryOwnershipStore( boundaryAuthorizer ?? new LibraryDirectoryOwnershipBoundaryAuthorizer(dbContextFactory); - internal Action? AfterInsideOwnershipMarkerPublicationForTest - { - get; - set; - } - - internal Action? AfterOwnershipMarkerPublicationForTest + internal Action? BeforeNewOwnershipCommitForTest { get; set; @@ -190,10 +184,6 @@ or LibraryDirectoryOwnershipState.Retained managedRootFolderId, directoryObjectIdentity); cancellationToken.ThrowIfCancellationRequested(); - await PinnedLibraryDirectoryOwnershipMarker.EnsureAsync( - existing, - markerCreation, - CancellationToken.None); ValidatePinnedOwnership(existing, markerCreation); existing.State = LibraryDirectoryOwnershipState.Owned; existing.StateReason = null; @@ -246,13 +236,13 @@ await PinnedLibraryDirectoryOwnershipMarker.EnsureAsync( canonicalPath, lookupKey, ownershipKey, - LibraryDirectoryOwnershipState.Unavailable, - "Durable ownership marker publication is pending.", + LibraryDirectoryOwnershipState.Owned, + reason: null, managedRootFolderId, directoryObjectIdentity, now); - ownership.DirectoryObjectIdentityUnavailableReason = - "Durable ownership marker publication is pending."; + ValidatePinnedOwnership(ownership, markerCreation); + BeforeNewOwnershipCommitForTest?.Invoke(); db.LibraryDirectoryOwnerships.Add(ownership); try { @@ -288,10 +278,6 @@ await PinnedLibraryDirectoryOwnershipMarker.EnsureAsync( managedRootFolderId, directoryObjectIdentity); cancellationToken.ThrowIfCancellationRequested(); - await PinnedLibraryDirectoryOwnershipMarker.EnsureAsync( - concurrent, - markerCreation, - CancellationToken.None); ValidatePinnedOwnership(concurrent, markerCreation); concurrent.State = LibraryDirectoryOwnershipState.Owned; concurrent.StateReason = null; @@ -307,42 +293,6 @@ await PinnedLibraryDirectoryOwnershipMarker.EnsureAsync( throw; } - try - { - await PinnedLibraryDirectoryOwnershipMarker.EnsureAsync( - ownership, - markerCreation, - CancellationToken.None, - AfterInsideOwnershipMarkerPublicationForTest); - AfterOwnershipMarkerPublicationForTest?.Invoke(); - ValidatePinnedOwnership(ownership, markerCreation); - } - catch (Exception exception) when (exception is not ( - OutOfMemoryException or StackOverflowException)) - { - ownership.State = LibraryDirectoryOwnershipState.Unavailable; - ownership.StateReason = - "Durable ownership marker publication requires recovery."; - ownership.DirectoryObjectIdentityUnavailableReason = - exception.Message; - ownership.UpdatedAt = timeProvider.GetUtcNow().UtcDateTime; - try - { - await db.SaveChangesAsync(CancellationToken.None); - } - catch - { - // The committed pending row already preserves the ownership token. - // Do not replace the original publication failure. - } - throw; - } - - ownership.State = LibraryDirectoryOwnershipState.Owned; - ownership.StateReason = null; - ownership.DirectoryObjectIdentityUnavailableReason = null; - ownership.UpdatedAt = timeProvider.GetUtcNow().UtcDateTime; - await db.SaveChangesAsync(CancellationToken.None); CleanupRetiredSiblingMarkers( retiredCandidates, canonicalPath, diff --git a/listenarr.infrastructure/Library/Moving/EfMoveExecutionStore.Helpers.cs b/listenarr.infrastructure/Library/Moving/EfMoveExecutionStore.Helpers.cs index 3ce9097b3..76854f5d4 100644 --- a/listenarr.infrastructure/Library/Moving/EfMoveExecutionStore.Helpers.cs +++ b/listenarr.infrastructure/Library/Moving/EfMoveExecutionStore.Helpers.cs @@ -95,12 +95,18 @@ ArgumentException or InvalidOperationException try { using var root = PinnedDirectoryCreation.OpenPinnedBoundary(targetRoot); - await ManagedDirectoryEnrollment.RequireMatchingEnrollmentAsync( - root, - relocation.TargetDirectoryObjectIdentityVersion, - relocation.TargetDirectoryObjectIdentity, - relocation.TargetDirectoryObjectIdentityUnavailableReason, - cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + if (!string.IsNullOrWhiteSpace( + relocation.TargetDirectoryObjectIdentityUnavailableReason) + || !ManagedDirectoryIdentity.MatchesNativeIdentity( + relocation.TargetDirectoryObjectIdentityVersion, + relocation.TargetDirectoryObjectIdentity, + root.GetDirectoryObjectIdentity()) + || !root.VisiblePathMatches()) + { + throw new InvalidOperationException( + "The relocation target no longer identifies its authorized physical generation."); + } } catch (Exception exception) when (exception is IOException or UnauthorizedAccessException @@ -125,6 +131,7 @@ private static async Task EnsureTargetBoundaryGenerationAuthorizedAsync( && entry.RelativePath == string.Empty && entry.Length > 0 && entry.Sha256 != null) + .OrderBy(entry => entry.Id) .Select(entry => new { entry.Length, @@ -145,19 +152,46 @@ private static async Task EnsureTargetBoundaryGenerationAuthorizedAsync( { using var boundary = PinnedDirectoryCreation.OpenPinnedBoundary( targetBoundary); + cancellationToken.ThrowIfCancellationRequested(); var nativeIdentity = boundary.GetDirectoryObjectIdentity(); - var current = await ManagedDirectoryEnrollment.ResolveAsync( - boundary, - nativeIdentity, - enrollIfMissing: false, - cancellationToken); var currentVersion = (int)authorizationEntries[0].Length; - if (!current.IsAvailable - || current.Version != currentVersion - || !string.Equals( - MoveManifestIdentity.ComputeTargetBoundaryAuthorizationDigest( + var currentValue = ManagedDirectoryIdentity.CreateMarkerless(nativeIdentity); + var currentDigest = MoveManifestIdentity.ComputeTargetBoundaryAuthorizationDigest( + currentVersion, + currentValue); + if (!string.Equals( + currentDigest, + expectedDigest, + StringComparison.OrdinalIgnoreCase)) + { + currentDigest = await TryResolveConfiguredRootBoundaryDigestAsync( + db, + targetBoundary, + nativeIdentity, currentVersion, - current.Value!), + cancellationToken) + ?? currentDigest; + } + if (!string.Equals( + currentDigest, + expectedDigest, + StringComparison.OrdinalIgnoreCase)) + { + // Last-resort compatibility for jobs created before a configured-root + // identity was available in the database. Read an existing legacy + // marker only; never create one. + var legacy = ManagedDirectoryEnrollment.ResolveExisting( + boundary, + nativeIdentity); + currentDigest = legacy.IsAvailable && legacy.Version == currentVersion + ? MoveManifestIdentity.ComputeTargetBoundaryAuthorizationDigest( + currentVersion, + legacy.Value!) + : currentDigest; + } + + if (!string.Equals( + currentDigest, expectedDigest, StringComparison.OrdinalIgnoreCase) || !boundary.VisiblePathMatches()) @@ -180,6 +214,57 @@ or InvalidOperationException or NotSupportedException } } + private static async Task TryResolveConfiguredRootBoundaryDigestAsync( + ListenArrDbContext db, + string targetBoundary, + string nativeIdentity, + int expectedVersion, + CancellationToken cancellationToken) + { + var roots = await db.RootFolders + .AsNoTracking() + .ToListAsync(cancellationToken); + foreach (var root in roots) + { + var persisted = RootFolderPathSemantics.ResolvePersisted(root); + if (persisted == null + || root.DirectoryObjectIdentityVersion != expectedVersion + || string.IsNullOrWhiteSpace(root.DirectoryObjectIdentity) + || !string.IsNullOrWhiteSpace( + root.DirectoryObjectIdentityUnavailableReason) + || !ManagedDirectoryIdentity.MatchesNativeIdentity( + root.DirectoryObjectIdentityVersion, + root.DirectoryObjectIdentity, + nativeIdentity)) + { + continue; + } + + try + { + if (!FileSystemPathIdentity.AreEquivalent( + root.Path, + targetBoundary, + persisted.Value.Semantics)) + { + continue; + } + } + catch (Exception exception) when (exception is + ArgumentException or InvalidOperationException + or NotSupportedException or PathTooLongException) + { + continue; + } + + return MoveManifestIdentity.ComputeTargetBoundaryAuthorizationDigest( + expectedVersion, + root.DirectoryObjectIdentity); + } + + return null; + } + private static async Task IsLeaseActiveAsync( ListenArrDbContext db, Guid jobId, diff --git a/listenarr.infrastructure/Library/Moving/EfMoveExecutionStore.Markerless.cs b/listenarr.infrastructure/Library/Moving/EfMoveExecutionStore.Markerless.cs new file mode 100644 index 000000000..1268b0d7e --- /dev/null +++ b/listenarr.infrastructure/Library/Moving/EfMoveExecutionStore.Markerless.cs @@ -0,0 +1,404 @@ +using Microsoft.EntityFrameworkCore; + +namespace Listenarr.Infrastructure.Library.Moving; + +internal sealed partial class EfMoveExecutionStore +{ + public Task GetEndpointObjectIdentitiesAsync( + Guid jobId, + CancellationToken cancellationToken) => + ExecuteAsync( + "load markerless move endpoint identities", + async () => + { + await using var db = await dbContextFactory.CreateDbContextAsync(cancellationToken); + return await db.MoveJobs + .Where(job => job.Id == jobId) + .Select(job => new MarkerlessMoveEndpointState( + job.SourceDirectoryObjectIdentity, + job.TargetDirectoryObjectIdentity, + job.SourceDirectoryCleanupState)) + .SingleAsync(cancellationToken); + }, + cancellationToken); + + public Task UpdateEndpointObjectIdentitiesAsync( + Guid jobId, + MoveLeaseToken leaseToken, + string? sourceDirectoryObjectIdentity, + string? targetDirectoryObjectIdentity, + CancellationToken cancellationToken) => + ExecuteAsync( + "persist markerless move endpoint identities", + async () => + { + EnsureLeaseTokenProvided(jobId, leaseToken); + if (string.IsNullOrWhiteSpace(sourceDirectoryObjectIdentity) + && string.IsNullOrWhiteSpace(targetDirectoryObjectIdentity)) + { + throw new ArgumentException( + "At least one endpoint physical identity is required."); + } + + var nowUtc = timeProvider.GetUtcNow().UtcDateTime; + await using var db = await dbContextFactory.CreateDbContextAsync(cancellationToken); + var job = await db.MoveJobs.SingleOrDefaultAsync( + candidate => candidate.Id == jobId + && candidate.Status == MoveJobStatus.Running + && candidate.LeaseOwner == leaseToken.Owner + && candidate.LeaseGeneration == leaseToken.Generation + && candidate.LeaseExpiresAt != null + && candidate.LeaseExpiresAt > nowUtc, + cancellationToken); + if (job == null) + { + throw new MoveLeaseLostException(jobId, leaseToken.Generation); + } + + EnsureSameOrUnassigned( + job.SourceDirectoryObjectIdentity, + sourceDirectoryObjectIdentity, + "The source root changed physical generation."); + EnsureSameOrUnassigned( + job.TargetDirectoryObjectIdentity, + targetDirectoryObjectIdentity, + "The target root changed physical generation."); + var observedSourceIdentity = job.SourceDirectoryObjectIdentity; + var observedTargetIdentity = job.TargetDirectoryObjectIdentity; + var desiredSourceIdentity = observedSourceIdentity + ?? sourceDirectoryObjectIdentity; + var desiredTargetIdentity = observedTargetIdentity + ?? targetDirectoryObjectIdentity; + if (!db.Database.IsRelational()) + { + job.SourceDirectoryObjectIdentity = desiredSourceIdentity; + job.TargetDirectoryObjectIdentity = desiredTargetIdentity; + job.UpdatedAt = nowUtc; + await db.SaveChangesAsync(cancellationToken); + return; + } + + db.Entry(job).State = EntityState.Detached; + var affected = await db.MoveJobs + .Where(candidate => candidate.Id == jobId + && candidate.Status == MoveJobStatus.Running + && candidate.LeaseOwner == leaseToken.Owner + && candidate.LeaseGeneration == leaseToken.Generation + && candidate.LeaseExpiresAt != null + && candidate.LeaseExpiresAt > nowUtc + && candidate.SourceDirectoryObjectIdentity + == observedSourceIdentity + && candidate.TargetDirectoryObjectIdentity + == observedTargetIdentity) + .ExecuteUpdateAsync( + updates => updates + .SetProperty( + candidate => candidate.SourceDirectoryObjectIdentity, + desiredSourceIdentity) + .SetProperty( + candidate => candidate.TargetDirectoryObjectIdentity, + desiredTargetIdentity) + .SetProperty(candidate => candidate.UpdatedAt, nowUtc), + cancellationToken); + if (affected != 1) + { + throw new MoveLeaseLostException(jobId, leaseToken.Generation); + } + }, + cancellationToken); + + public Task UpdateSourceDirectoryCleanupStateAsync( + Guid jobId, + MoveLeaseToken leaseToken, + MoveJobEntryCleanupState cleanupState, + CancellationToken cancellationToken) => + ExecuteAsync( + "persist markerless source-directory cleanup state", + async () => + { + EnsureLeaseTokenProvided(jobId, leaseToken); + var nowUtc = timeProvider.GetUtcNow().UtcDateTime; + await using var db = await dbContextFactory.CreateDbContextAsync(cancellationToken); + var job = await db.MoveJobs.SingleOrDefaultAsync( + candidate => candidate.Id == jobId + && candidate.Status == MoveJobStatus.Running + && candidate.LeaseOwner == leaseToken.Owner + && candidate.LeaseGeneration == leaseToken.Generation + && candidate.LeaseExpiresAt != null + && candidate.LeaseExpiresAt > nowUtc, + cancellationToken); + if (job == null) + { + throw new MoveLeaseLostException(jobId, leaseToken.Generation); + } + + var observedState = job.SourceDirectoryCleanupState; + var desiredState = observedState < cleanupState + ? cleanupState + : observedState; + if (!db.Database.IsRelational()) + { + job.SourceDirectoryCleanupState = desiredState; + job.UpdatedAt = nowUtc; + await db.SaveChangesAsync(cancellationToken); + return; + } + + db.Entry(job).State = EntityState.Detached; + var affected = await db.MoveJobs + .Where(candidate => candidate.Id == jobId + && candidate.Status == MoveJobStatus.Running + && candidate.LeaseOwner == leaseToken.Owner + && candidate.LeaseGeneration == leaseToken.Generation + && candidate.LeaseExpiresAt != null + && candidate.LeaseExpiresAt > nowUtc + && candidate.SourceDirectoryCleanupState == observedState) + .ExecuteUpdateAsync( + updates => updates + .SetProperty( + candidate => candidate.SourceDirectoryCleanupState, + desiredState) + .SetProperty(candidate => candidate.UpdatedAt, nowUtc), + cancellationToken); + if (affected != 1) + { + throw new MoveLeaseLostException(jobId, leaseToken.Generation); + } + }, + cancellationToken); + + public Task UpdateTargetEntryStateAsync( + Guid jobId, + MoveLeaseToken leaseToken, + string relativePath, + MoveJobEntryCopyState copyState, + string? targetPhysicalObjectIdentity, + CancellationToken cancellationToken) => + ExecuteAsync( + "persist markerless target-file state", + async () => + { + EnsureLeaseTokenProvided(jobId, leaseToken); + ArgumentException.ThrowIfNullOrWhiteSpace(relativePath); + if (copyState < MoveJobEntryCopyState.Staged) + { + throw new ArgumentOutOfRangeException(nameof(copyState)); + } + if (copyState == MoveJobEntryCopyState.Staged + && string.IsNullOrWhiteSpace(targetPhysicalObjectIdentity)) + { + throw new ArgumentException( + "A staged target file requires a physical object identity.", + nameof(targetPhysicalObjectIdentity)); + } + + var nowUtc = timeProvider.GetUtcNow().UtcDateTime; + await using var db = await dbContextFactory.CreateDbContextAsync(cancellationToken); + var entry = await db.MoveJobEntries + .Include(candidate => candidate.MoveJob) + .SingleOrDefaultAsync( + candidate => candidate.MoveJobId == jobId + && candidate.RelativePath == relativePath, + cancellationToken); + if (entry == null + || entry.MoveJob.Status != MoveJobStatus.Running + || !string.Equals( + entry.MoveJob.LeaseOwner, + leaseToken.Owner, + StringComparison.Ordinal) + || entry.MoveJob.LeaseGeneration != leaseToken.Generation + || entry.MoveJob.LeaseExpiresAt == null + || entry.MoveJob.LeaseExpiresAt <= nowUtc) + { + throw new MoveLeaseLostException(jobId, leaseToken.Generation); + } + + if (!string.IsNullOrWhiteSpace(entry.TargetPhysicalObjectIdentity) + && !string.IsNullOrWhiteSpace(targetPhysicalObjectIdentity) + && !string.Equals( + entry.TargetPhysicalObjectIdentity, + targetPhysicalObjectIdentity, + StringComparison.Ordinal)) + { + throw new MoveNeedsAttentionException( + "The target file generation changed after markerless publication began."); + } + + if (AfterMarkerlessStateLoadedForTestAsync != null) + { + await AfterMarkerlessStateLoadedForTestAsync(); + } + + var observedIdentity = entry.TargetPhysicalObjectIdentity; + var observedCopyState = entry.CopyState; + var desiredIdentity = observedIdentity + ?? targetPhysicalObjectIdentity; + var desiredCopyState = observedCopyState < copyState + ? copyState + : observedCopyState; + if (!db.Database.IsRelational()) + { + entry.TargetPhysicalObjectIdentity = desiredIdentity; + entry.CopyState = desiredCopyState; + entry.MoveJob.UpdatedAt = nowUtc; + await db.SaveChangesAsync(cancellationToken); + return; + } + + db.Entry(entry).State = EntityState.Detached; + db.Entry(entry.MoveJob).State = EntityState.Detached; + var affected = await db.MoveJobEntries + .Where(candidate => candidate.MoveJobId == jobId + && candidate.RelativePath == relativePath + && candidate.TargetPhysicalObjectIdentity == observedIdentity + && candidate.CopyState == observedCopyState + && candidate.MoveJob.Status == MoveJobStatus.Running + && candidate.MoveJob.LeaseOwner == leaseToken.Owner + && candidate.MoveJob.LeaseGeneration == leaseToken.Generation + && candidate.MoveJob.LeaseExpiresAt != null + && candidate.MoveJob.LeaseExpiresAt > nowUtc) + .ExecuteUpdateAsync( + updates => updates + .SetProperty( + candidate => candidate.TargetPhysicalObjectIdentity, + desiredIdentity) + .SetProperty( + candidate => candidate.CopyState, + desiredCopyState), + cancellationToken); + if (affected != 1) + { + throw new MoveLeaseLostException(jobId, leaseToken.Generation); + } + + _ = await db.MoveJobs + .Where(candidate => candidate.Id == jobId + && candidate.Status == MoveJobStatus.Running + && candidate.LeaseOwner == leaseToken.Owner + && candidate.LeaseGeneration == leaseToken.Generation + && candidate.LeaseExpiresAt != null + && candidate.LeaseExpiresAt > nowUtc) + .ExecuteUpdateAsync( + updates => updates.SetProperty( + candidate => candidate.UpdatedAt, + nowUtc), + cancellationToken); + }, + cancellationToken); + + private static void EnsureSameOrUnassigned( + string? persisted, + string? current, + string message) + { + if (!string.IsNullOrWhiteSpace(persisted) + && !string.IsNullOrWhiteSpace(current) + && !string.Equals(persisted, current, StringComparison.Ordinal)) + { + throw new MoveNeedsAttentionException(message); + } + } + + public Task UpdateCreatedDirectoryPublicationAsync( + Guid jobId, + MoveLeaseToken leaseToken, + string path, + MoveCreatedDirectoryState state, + string directoryObjectIdentity, + CancellationToken cancellationToken) => + ExecuteAsync( + "persist markerless target-directory state", + async () => + { + EnsureLeaseTokenProvided(jobId, leaseToken); + ArgumentException.ThrowIfNullOrWhiteSpace(path); + ArgumentException.ThrowIfNullOrWhiteSpace(directoryObjectIdentity); + var nowUtc = timeProvider.GetUtcNow().UtcDateTime; + await using var db = await dbContextFactory.CreateDbContextAsync(cancellationToken); + var directory = await db.MoveJobCreatedDirectories + .Include(candidate => candidate.MoveJob) + .SingleOrDefaultAsync( + candidate => candidate.MoveJobId == jobId + && candidate.Path == path, + cancellationToken); + if (directory == null + || directory.MoveJob.Status != MoveJobStatus.Running + || !string.Equals( + directory.MoveJob.LeaseOwner, + leaseToken.Owner, + StringComparison.Ordinal) + || directory.MoveJob.LeaseGeneration != leaseToken.Generation + || directory.MoveJob.LeaseExpiresAt == null + || directory.MoveJob.LeaseExpiresAt <= nowUtc) + { + throw new MoveLeaseLostException(jobId, leaseToken.Generation); + } + + if (!string.IsNullOrWhiteSpace(directory.DirectoryObjectIdentity) + && !string.Equals( + directory.DirectoryObjectIdentity, + directoryObjectIdentity, + StringComparison.Ordinal)) + { + throw new MoveNeedsAttentionException( + "A move-created target directory changed physical generation."); + } + if (state < directory.State + || directory.State == MoveCreatedDirectoryState.Removed) + { + throw new MoveNeedsAttentionException( + "A markerless target-directory state transition would regress durable state."); + } + + var observedIdentity = directory.DirectoryObjectIdentity; + var observedState = directory.State; + var desiredIdentity = observedIdentity ?? directoryObjectIdentity; + var desiredState = observedState < state ? state : observedState; + if (!db.Database.IsRelational()) + { + directory.DirectoryObjectIdentity = desiredIdentity; + directory.State = desiredState; + directory.MoveJob.UpdatedAt = nowUtc; + await db.SaveChangesAsync(cancellationToken); + return; + } + + db.Entry(directory).State = EntityState.Detached; + db.Entry(directory.MoveJob).State = EntityState.Detached; + var affected = await db.MoveJobCreatedDirectories + .Where(candidate => candidate.MoveJobId == jobId + && candidate.Path == path + && candidate.DirectoryObjectIdentity == observedIdentity + && candidate.State == observedState + && candidate.MoveJob.Status == MoveJobStatus.Running + && candidate.MoveJob.LeaseOwner == leaseToken.Owner + && candidate.MoveJob.LeaseGeneration == leaseToken.Generation + && candidate.MoveJob.LeaseExpiresAt != null + && candidate.MoveJob.LeaseExpiresAt > nowUtc) + .ExecuteUpdateAsync( + updates => updates + .SetProperty( + candidate => candidate.DirectoryObjectIdentity, + desiredIdentity) + .SetProperty(candidate => candidate.State, desiredState), + cancellationToken); + if (affected != 1) + { + throw new MoveLeaseLostException(jobId, leaseToken.Generation); + } + + _ = await db.MoveJobs + .Where(candidate => candidate.Id == jobId + && candidate.Status == MoveJobStatus.Running + && candidate.LeaseOwner == leaseToken.Owner + && candidate.LeaseGeneration == leaseToken.Generation + && candidate.LeaseExpiresAt != null + && candidate.LeaseExpiresAt > nowUtc) + .ExecuteUpdateAsync( + updates => updates.SetProperty( + candidate => candidate.UpdatedAt, + nowUtc), + cancellationToken); + }, + cancellationToken); +} diff --git a/listenarr.infrastructure/Library/Moving/EfMoveExecutionStore.MarkerlessSourceProof.cs b/listenarr.infrastructure/Library/Moving/EfMoveExecutionStore.MarkerlessSourceProof.cs new file mode 100644 index 000000000..11b7cdc95 --- /dev/null +++ b/listenarr.infrastructure/Library/Moving/EfMoveExecutionStore.MarkerlessSourceProof.cs @@ -0,0 +1,134 @@ +using Microsoft.EntityFrameworkCore; + +namespace Listenarr.Infrastructure.Library.Moving; + +internal sealed partial class EfMoveExecutionStore +{ + public Task UpdateSourceEntryProofAsync( + Guid jobId, + MoveLeaseToken leaseToken, + string relativePath, + string sourcePhysicalObjectIdentity, + string? sha256, + CancellationToken cancellationToken) => + ExecuteAsync( + "persist markerless source-entry proof", + async () => + { + EnsureLeaseTokenProvided(jobId, leaseToken); + ArgumentException.ThrowIfNullOrWhiteSpace(relativePath); + ArgumentException.ThrowIfNullOrWhiteSpace(sourcePhysicalObjectIdentity); + var nowUtc = timeProvider.GetUtcNow().UtcDateTime; + await using var db = await dbContextFactory.CreateDbContextAsync(cancellationToken); + var entry = await db.MoveJobEntries + .Include(candidate => candidate.MoveJob) + .SingleOrDefaultAsync( + candidate => candidate.MoveJobId == jobId + && candidate.RelativePath == relativePath, + cancellationToken); + if (entry == null + || entry.MoveJob.Status != MoveJobStatus.Running + || !string.Equals( + entry.MoveJob.LeaseOwner, + leaseToken.Owner, + StringComparison.Ordinal) + || entry.MoveJob.LeaseGeneration != leaseToken.Generation + || entry.MoveJob.LeaseExpiresAt == null + || entry.MoveJob.LeaseExpiresAt <= nowUtc) + { + throw new MoveLeaseLostException(jobId, leaseToken.Generation); + } + + if (entry.EntryType == MoveJobEntryType.File + && sha256 != null) + { + if (sha256.Length != 64 + || !sha256.All(Uri.IsHexDigit)) + { + throw new ArgumentException( + "A markerless source-file proof contains an invalid SHA-256 digest.", + nameof(sha256)); + } + + sha256 = sha256.ToUpperInvariant(); + } + else if (entry.EntryType != MoveJobEntryType.File + && sha256 != null) + { + throw new ArgumentException( + "A markerless source-directory proof cannot contain a SHA-256 digest.", + nameof(sha256)); + } + + if (!string.IsNullOrWhiteSpace(entry.SourcePhysicalObjectIdentity) + && !string.Equals( + entry.SourcePhysicalObjectIdentity, + sourcePhysicalObjectIdentity, + StringComparison.Ordinal)) + { + throw new MoveNeedsAttentionException( + "The source entry changed physical generation before markerless execution."); + } + if (!string.IsNullOrWhiteSpace(entry.Sha256) + && sha256 != null + && !string.Equals( + entry.Sha256, + sha256, + StringComparison.OrdinalIgnoreCase)) + { + throw new MoveNeedsAttentionException( + "The source entry content changed before markerless execution."); + } + + var observedIdentity = entry.SourcePhysicalObjectIdentity; + var observedSha256 = entry.Sha256; + var desiredIdentity = observedIdentity ?? sourcePhysicalObjectIdentity; + var desiredSha256 = observedSha256 ?? sha256; + if (!db.Database.IsRelational()) + { + entry.SourcePhysicalObjectIdentity = desiredIdentity; + entry.Sha256 = desiredSha256; + entry.MoveJob.UpdatedAt = nowUtc; + await db.SaveChangesAsync(cancellationToken); + return; + } + + db.Entry(entry).State = EntityState.Detached; + db.Entry(entry.MoveJob).State = EntityState.Detached; + var affected = await db.MoveJobEntries + .Where(candidate => candidate.MoveJobId == jobId + && candidate.RelativePath == relativePath + && candidate.SourcePhysicalObjectIdentity == observedIdentity + && candidate.Sha256 == observedSha256 + && candidate.MoveJob.Status == MoveJobStatus.Running + && candidate.MoveJob.LeaseOwner == leaseToken.Owner + && candidate.MoveJob.LeaseGeneration == leaseToken.Generation + && candidate.MoveJob.LeaseExpiresAt != null + && candidate.MoveJob.LeaseExpiresAt > nowUtc) + .ExecuteUpdateAsync( + updates => updates + .SetProperty( + candidate => candidate.SourcePhysicalObjectIdentity, + desiredIdentity) + .SetProperty(candidate => candidate.Sha256, desiredSha256), + cancellationToken); + if (affected != 1) + { + throw new MoveLeaseLostException(jobId, leaseToken.Generation); + } + + _ = await db.MoveJobs + .Where(candidate => candidate.Id == jobId + && candidate.Status == MoveJobStatus.Running + && candidate.LeaseOwner == leaseToken.Owner + && candidate.LeaseGeneration == leaseToken.Generation + && candidate.LeaseExpiresAt != null + && candidate.LeaseExpiresAt > nowUtc) + .ExecuteUpdateAsync( + updates => updates.SetProperty( + candidate => candidate.UpdatedAt, + nowUtc), + cancellationToken); + }, + cancellationToken); +} diff --git a/listenarr.infrastructure/Library/Moving/EfMoveExecutionStore.cs b/listenarr.infrastructure/Library/Moving/EfMoveExecutionStore.cs index 32b90bda7..415b63521 100644 --- a/listenarr.infrastructure/Library/Moving/EfMoveExecutionStore.cs +++ b/listenarr.infrastructure/Library/Moving/EfMoveExecutionStore.cs @@ -8,6 +8,8 @@ internal sealed partial class EfMoveExecutionStore( IDbContextFactory dbContextFactory, TimeProvider timeProvider) : IMoveExecutionStore { + internal Func? AfterMarkerlessStateLoadedForTestAsync { get; set; } + public Task EnsureLeaseOwnedAsync( Guid jobId, MoveLeaseToken leaseToken, @@ -31,6 +33,21 @@ public Task EnsureLeaseOwnedAsync( }, cancellationToken); + public Task GetExecutionProtocolVersionAsync( + Guid jobId, + CancellationToken cancellationToken) => + ExecuteAsync( + "load the move execution protocol", + async () => + { + await using var db = await dbContextFactory.CreateDbContextAsync(cancellationToken); + return await db.MoveJobs + .Where(job => job.Id == jobId) + .Select(job => job.ExecutionProtocolVersion) + .SingleAsync(cancellationToken); + }, + cancellationToken); + public Task ValidateOrAdoptIdentityAsync( Guid jobId, string source, diff --git a/listenarr.infrastructure/Library/Moving/LibraryDirectoryOwnershipBoundaryAuthorizer.cs b/listenarr.infrastructure/Library/Moving/LibraryDirectoryOwnershipBoundaryAuthorizer.cs index 8f887f966..89c95a11b 100644 --- a/listenarr.infrastructure/Library/Moving/LibraryDirectoryOwnershipBoundaryAuthorizer.cs +++ b/listenarr.infrastructure/Library/Moving/LibraryDirectoryOwnershipBoundaryAuthorizer.cs @@ -208,13 +208,19 @@ internal async Task AuthorizeAsync( var anchor = PinnedDirectoryCreation.OpenPinnedBoundary(canonicalBoundary); try { - var liveIdentity = await ManagedDirectoryEnrollment - .RequireMatchingEnrollmentAsync( - anchor, + cancellationToken.ThrowIfCancellationRequested(); + var liveIdentity = anchor.GetDirectoryObjectIdentity(); + if (!string.IsNullOrWhiteSpace( + root.DirectoryObjectIdentityUnavailableReason) + || !ManagedDirectoryIdentity.MatchesNativeIdentity( root.DirectoryObjectIdentityVersion, root.DirectoryObjectIdentity, - root.DirectoryObjectIdentityUnavailableReason, - cancellationToken); + liveIdentity) + || !anchor.VisiblePathMatches()) + { + throw new InvalidOperationException( + "The managed root no longer identifies its authorized physical generation."); + } return new ManagedLibraryBoundaryAuthorization( root.Id, @@ -322,12 +328,17 @@ private static async Task var boundary = PinnedDirectoryCreation.OpenPinnedBoundary(boundaryPath); try { - await ManagedDirectoryEnrollment.RequireMatchingEnrollmentAsync( - boundary, - expectedIdentityVersion, - expectedIdentity, - identityUnavailableReason, - cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + if (!string.IsNullOrWhiteSpace(identityUnavailableReason) + || !ManagedDirectoryIdentity.MatchesNativeIdentity( + expectedIdentityVersion, + expectedIdentity, + boundary.GetDirectoryObjectIdentity()) + || !boundary.VisiblePathMatches()) + { + throw new InvalidOperationException( + "The managed root boundary no longer identifies its authorized physical generation."); + } var current = boundary.Duplicate(); try diff --git a/listenarr.infrastructure/Library/Moving/LibraryDirectoryOwnershipMarker.cs b/listenarr.infrastructure/Library/Moving/LibraryDirectoryOwnershipMarker.cs index e9d8928c0..92bc1ae02 100644 --- a/listenarr.infrastructure/Library/Moving/LibraryDirectoryOwnershipMarker.cs +++ b/listenarr.infrastructure/Library/Moving/LibraryDirectoryOwnershipMarker.cs @@ -200,6 +200,133 @@ or InvalidOperationException or NotSupportedException } } + public static bool TryRetireMatchingSiblingArtifacts( + LibraryDirectoryOwnership ownership, + PinnedDirectoryCreation.PinnedDirectoryAnchor parent, + out string? reason) + { + ArgumentNullException.ThrowIfNull(ownership); + ArgumentNullException.ThrowIfNull(parent); + try + { + var siblingName = Path.GetFileName(GetSiblingPath(ownership)); + RetireMatchingMarkerIfPresent( + ownership, + parent, + siblingName); + RetireMatchingMarkerIfPresent( + ownership, + parent, + siblingName + ".v2.tmp"); + RetireMatchingMarkerIfPresent( + ownership, + parent, + siblingName + ".migration.tmp"); + RetireMatchingMarkerIfPresent( + ownership, + parent, + PinnedDirectoryCreation.GetConditionalReplacementBackupName( + siblingName)); + parent.FlushDirectoryEntry(); + reason = null; + return true; + } + catch (Exception exception) when (exception is + ArgumentException or IOException or UnauthorizedAccessException + or InvalidOperationException or NotSupportedException + or System.ComponentModel.Win32Exception) + { + reason = exception.Message; + return false; + } + } + + public static bool TryRetireMatchingMarkers( + LibraryDirectoryOwnership ownership, + PinnedDirectoryCreation.PinnedDirectoryAnchor directory, + PinnedDirectoryCreation.PinnedDirectoryAnchor parent, + out string? reason) + { + ArgumentNullException.ThrowIfNull(ownership); + ArgumentNullException.ThrowIfNull(directory); + ArgumentNullException.ThrowIfNull(parent); + try + { + RetireMatchingMarkerIfPresent( + ownership, + directory, + FileName); + RetireMatchingMarkerIfPresent( + ownership, + directory, + FileName + ".v2.tmp"); + RetireMatchingMarkerIfPresent( + ownership, + directory, + FileName + ".migration.tmp"); + RetireMatchingMarkerIfPresent( + ownership, + directory, + PinnedDirectoryCreation.GetConditionalReplacementBackupName( + FileName)); + if (!TryRetireMatchingSiblingArtifacts( + ownership, + parent, + out reason)) + { + return false; + } + directory.FlushDirectoryEntry(); + reason = null; + return true; + } + catch (Exception exception) when (exception is + ArgumentException or IOException or UnauthorizedAccessException + or InvalidOperationException or NotSupportedException + or System.ComponentModel.Win32Exception) + { + reason = exception.Message; + return false; + } + } + + private static void RetireMatchingMarkerIfPresent( + LibraryDirectoryOwnership ownership, + PinnedDirectoryCreation.PinnedDirectoryAnchor parent, + string fileName) + { + using var marker = parent.TryOpenExistingFile( + fileName, + requireDeleteAccess: true); + if (marker == null) + { + return; + } + + var payload = ReadPayload(marker); + if (!MatchesCurrentPayload(ownership, payload) + && !MatchesLegacyPayload(ownership, payload)) + { + throw new InvalidOperationException( + "A legacy directory ownership artifact does not match the persisted ownership claim."); + } + if (!parent.VisiblePathMatches() || !marker.VisiblePathMatches()) + { + throw new InvalidOperationException( + "A legacy directory ownership artifact changed before retirement."); + } + + var verifiedPayload = ReadPayload(marker); + if (!MatchesCurrentPayload(ownership, verifiedPayload) + && !MatchesLegacyPayload(ownership, verifiedPayload)) + { + throw new InvalidOperationException( + "A legacy directory ownership artifact changed before retirement."); + } + + marker.Delete(); + } + public static IReadOnlyList GetMarkerPaths( LibraryDirectoryOwnership ownership) => [GetInsidePath(ownership.CanonicalPath), GetSiblingPath(ownership)]; diff --git a/listenarr.infrastructure/Library/Moving/LibraryDirectoryOwnershipReconciler.cs b/listenarr.infrastructure/Library/Moving/LibraryDirectoryOwnershipReconciler.cs index 1ccb34e52..474572d82 100644 --- a/listenarr.infrastructure/Library/Moving/LibraryDirectoryOwnershipReconciler.cs +++ b/listenarr.infrastructure/Library/Moving/LibraryDirectoryOwnershipReconciler.cs @@ -98,33 +98,39 @@ OperationCanceledException or OutOfMemoryException ownership.CanonicalPath, ownership.GetIdentity().Semantics, cancellationToken); - if (LibraryDirectoryOwnershipRemoval - .TryValidateLegacyMissingBothRecovery( + LibraryDirectoryOwnershipMarker.MarkerPayload? legacyPayload = null; + try + { + LibraryDirectoryOwnershipRemoval.TryValidateLegacyMissingBothRecovery( ownership, missingAuthorization.ParentAnchor, - out var legacyPayload)) + out legacyPayload); + } + catch (Exception exception) when (exception is not ( + OperationCanceledException or OutOfMemoryException + or StackOverflowException)) + { + logger.LogWarning( + exception, + "An obsolete directory ownership artifact for ownership {OwnershipId} was preserved because it could not be validated; the completed removal is still converging from durable database state.", + ownership.Id); + } + + var now = DateTime.UtcNow; + if (legacyPayload != null) { - var now = DateTime.UtcNow; db.LibraryDirectoryOwnershipRetiredMarkers.Add( LibraryDirectoryOwnershipRetiredMarkerEvidence.Create( ownership, - legacyPayload - ?? throw new InvalidOperationException( - "The validated legacy marker payload is unavailable."), + legacyPayload, now)); - ownership.State = - LibraryDirectoryOwnershipState.Removed; - ownership.PathOwnershipKey = null; - ownership.ManagedRootFolderId = null; - ownership.StateReason = null; - ownership.UpdatedAt = now; - await db.SaveChangesAsync(cancellationToken); - continue; } - - LibraryDirectoryOwnershipMarker.ValidateSiblingMarker( - ownership, - missingAuthorization.ParentAnchor); + ownership.State = LibraryDirectoryOwnershipState.Removed; + ownership.PathOwnershipKey = null; + ownership.ManagedRootFolderId = null; + ownership.StateReason = null; + ownership.UpdatedAt = now; + await db.SaveChangesAsync(cancellationToken); continue; } @@ -150,8 +156,6 @@ OperationCanceledException or OutOfMemoryException "The owned directory and its recovery quarantine are missing."); using var directory = publication.OpenCreatedDirectoryAnchor(); var liveIdentity = directory.GetDirectoryObjectIdentity(); - var priorIdentity = CloneForIdentityMigration(ownership); - var requiresIdentityMigration = false; if (ownership.DirectoryObjectIdentityVersion == ManagedDirectoryIdentity.CurrentVersion) { @@ -175,8 +179,6 @@ OperationCanceledException or OutOfMemoryException throw new InvalidOperationException( "The live directory differs from its legacy physical identity."); } - - requiresIdentityMigration = true; } else if (ownership.DirectoryObjectIdentityVersion.HasValue) { @@ -185,7 +187,8 @@ OperationCanceledException or OutOfMemoryException } else { - requiresIdentityMigration = true; + // A pre-physical-identity claim can be upgraded only after the + // exact live directory is pinned through its managed root. } ownership.ManagedRootFolderId = authorization.RootFolderId; @@ -196,30 +199,28 @@ OperationCanceledException or OutOfMemoryException liveIdentity); ownership.DirectoryObjectIdentityUnavailableReason = null; ownership.StateReason = null; - if (requiresIdentityMigration) + if (ownership.State == LibraryDirectoryOwnershipState.Unavailable) { - await PinnedLibraryDirectoryOwnershipMarker - .PublishIdentityMigrationAsync( - priorIdentity, - ownership, - directory, - authorization.ParentAnchor, - cancellationToken); + ownership.State = LibraryDirectoryOwnershipState.Owned; } - else - { - await PinnedLibraryDirectoryOwnershipMarker.ReconcileAsync( + ownership.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(cancellationToken); + + // Older builds left these marker files permanently. The durable row, + // managed-root authorization, and pinned native directory generation + // now provide the at-rest proof. Retire only artifacts that still match + // this exact ownership; unrelated files are preserved. + if (!LibraryDirectoryOwnershipMarker.TryRetireMatchingMarkers( ownership, directory, authorization.ParentAnchor, - cancellationToken); - } - if (ownership.State == LibraryDirectoryOwnershipState.Unavailable) + out var markerRetirementReason)) { - ownership.State = LibraryDirectoryOwnershipState.Owned; + logger.LogWarning( + "Obsolete directory ownership artifacts for ownership {OwnershipId} could not be retired safely and were preserved: {Reason}", + ownership.Id, + markerRetirementReason); } - ownership.UpdatedAt = DateTime.UtcNow; - await db.SaveChangesAsync(cancellationToken); } catch (Exception exception) when (exception is not ( OperationCanceledException or OutOfMemoryException @@ -332,34 +333,6 @@ private async Task BackfillLegacyRemovedOwnershipEvidenceAsync( } } - private static LibraryDirectoryOwnership CloneForIdentityMigration( - LibraryDirectoryOwnership ownership) => new() - { - Id = ownership.Id, - Path = ownership.Path, - CanonicalPath = ownership.CanonicalPath, - PathSyntax = ownership.PathSyntax, - PathCaseSensitivity = ownership.PathCaseSensitivity, - PathCaseSensitivityMode = ownership.PathCaseSensitivityMode, - PathIdentityBoundary = ownership.PathIdentityBoundary, - PathIdentityLookupKey = ownership.PathIdentityLookupKey, - PathOwnershipKey = ownership.PathOwnershipKey, - OwnershipToken = ownership.OwnershipToken, - State = ownership.State, - CreationWorkflow = ownership.CreationWorkflow, - CreationOperationId = ownership.CreationOperationId, - AudiobookId = ownership.AudiobookId, - ManagedRootFolderId = ownership.ManagedRootFolderId, - DirectoryObjectIdentityVersion = - ownership.DirectoryObjectIdentityVersion, - DirectoryObjectIdentity = ownership.DirectoryObjectIdentity, - DirectoryObjectIdentityUnavailableReason = - ownership.DirectoryObjectIdentityUnavailableReason, - StateReason = ownership.StateReason, - CreatedAt = ownership.CreatedAt, - UpdatedAt = ownership.UpdatedAt - }; - private static void ReconcileRetiredMarker( LibraryDirectoryOwnershipRetiredMarker evidence) { diff --git a/listenarr.infrastructure/Library/Moving/LibraryDirectoryOwnershipRemoval.cs b/listenarr.infrastructure/Library/Moving/LibraryDirectoryOwnershipRemoval.cs index 455da83ef..f9ae068e0 100644 --- a/listenarr.infrastructure/Library/Moving/LibraryDirectoryOwnershipRemoval.cs +++ b/listenarr.infrastructure/Library/Moving/LibraryDirectoryOwnershipRemoval.cs @@ -43,59 +43,33 @@ public static void ValidateRecoverableState(LibraryDirectoryOwnership ownership) "Both the owned directory and its removal quarantine exist."); } - if (originalExists) + if (!originalExists && !quarantineExists) { - LibraryDirectoryOwnershipMarker.Validate( - ownership, - ownership.CanonicalPath); + // The committed Removing state is the durable deletion intent. If neither + // pathname exists, the physical retirement already completed and the + // database can safely converge to Removed without a permanent marker. return; } - if (quarantineExists) - { - var insideMarkerPath = Path.Join( - quarantinePath, - LibraryDirectoryOwnershipMarker.FileName); - if (!File.Exists(insideMarkerPath) - && !Directory.EnumerateFileSystemEntries(quarantinePath).Any()) - { - var parentPath = Path.GetDirectoryName(ownership.CanonicalPath) - ?? throw new InvalidOperationException( - "The durable directory ownership path has no parent directory."); - using var parent = - PinnedDirectoryCreation.OpenPinnedBoundary(parentPath); - using var publication = - parent.OpenExistingChildForPublication( - Path.GetFileName(quarantinePath)); - using var quarantine = publication.OpenCreatedDirectoryAnchor(); - EnsurePhysicalIdentity(ownership, quarantine); - LibraryDirectoryOwnershipMarker.ValidateSiblingMarker( - ownership, - parent); - if (Directory.EnumerateFileSystemEntries(quarantinePath).Any() - || !quarantine.VisiblePathMatches() - || !parent.VisiblePathMatches()) - { - throw new InvalidOperationException( - "The owned directory removal quarantine changed during recovery validation."); - } - - return; - } - - LibraryDirectoryOwnershipMarker.Validate( - ownership, - quarantinePath); - return; - } - - if (!LibraryDirectoryOwnershipMarker.HasValidSiblingMarker(ownership)) + var visiblePath = originalExists + ? ownership.CanonicalPath + : quarantinePath; + var parentPath = Path.GetDirectoryName(visiblePath) + ?? throw new InvalidOperationException( + "The owned directory recovery path has no parent directory."); + using var parent = PinnedDirectoryCreation.OpenPinnedBoundary(parentPath); + using var directory = parent.OpenExistingChild(Path.GetFileName(visiblePath)); + EnsurePhysicalIdentity(ownership, directory); + if (!parent.VisiblePathMatches()) { throw new InvalidOperationException( - "The missing owned directory has no valid interrupted-removal proof."); + "The owned directory recovery parent changed during validation."); } } + // Compatibility for an older interrupted-removal format where the directory and + // quarantine are already absent but a durable sibling marker remains. New removal + // operations do not require or create this marker. public static bool TryValidateLegacyMissingBothRecovery( LibraryDirectoryOwnership ownership, PinnedDirectoryCreation.PinnedDirectoryAnchor parent, @@ -136,20 +110,22 @@ public static bool TryValidateLegacyMissingBothRecovery( if (temporary != null || Directory.Exists(Path.Join(parentPath, temporaryName))) { throw new InvalidOperationException( - "Legacy ownership removal proof is mixed with an incomplete v2 marker upgrade."); + "Legacy ownership removal proof is mixed with an incomplete marker upgrade."); } - using var sibling = parent.OpenExistingFile( + using var sibling = parent.TryOpenExistingFile( Path.GetFileName(siblingPath), requireDeleteAccess: false); - var payload = LibraryDirectoryOwnershipMarker.ReadPayload(sibling); - if (LibraryDirectoryOwnershipMarker.MatchesCurrentPayload( - ownership, - payload)) + if (sibling == null) { return false; } - if (!LibraryDirectoryOwnershipMarker.MatchesLegacyPayload( + + var payload = LibraryDirectoryOwnershipMarker.ReadPayload(sibling); + if (!LibraryDirectoryOwnershipMarker.MatchesCurrentPayload( + ownership, + payload) + && !LibraryDirectoryOwnershipMarker.MatchesLegacyPayload( ownership, payload)) { @@ -161,12 +137,7 @@ public static bool TryValidateLegacyMissingBothRecovery( || Directory.Exists(originalPath) || File.Exists(originalPath) || Directory.Exists(quarantinePath) - || File.Exists(quarantinePath) - || File.Exists(Path.Join(parentPath, temporaryName)) - || Directory.Exists(Path.Join(parentPath, temporaryName)) - || !LibraryDirectoryOwnershipMarker.MatchesLegacyPayload( - ownership, - LibraryDirectoryOwnershipMarker.ReadPayload(sibling))) + || File.Exists(quarantinePath)) { throw new InvalidOperationException( "The legacy ownership removal proof changed during validation."); @@ -202,9 +173,6 @@ public static LibraryDirectoryRemovalOutcome RemoveEmptyDirectory( throw new InvalidOperationException( "Both the owned directory and its removal quarantine exist."); } - - PinnedDirectoryCreation? pinnedDirectory = null; - PinnedDirectoryCreation.PinnedDirectoryAnchor? quarantineAnchor = null; if (!FileSystemPathIdentity.AreEquivalent( parentAnchor.FullPath, parentPath, @@ -214,110 +182,87 @@ public static LibraryDirectoryRemovalOutcome RemoveEmptyDirectory( throw new InvalidOperationException( "The authorized ownership parent no longer matches the persisted path."); } - if (originalExists) - { - pinnedDirectory = parentAnchor.OpenExistingChildForPublication( - Path.GetFileName(originalPath)); - using var originalAnchor = pinnedDirectory.OpenCreatedDirectoryAnchor(); - EnsurePhysicalIdentity(ownership, originalAnchor); - if (!LibraryDirectoryOwnershipMarker.ContainsOnlyInsideMarker( - ownership, - originalAnchor, - parentAnchor)) - { - pinnedDirectory.Dispose(); - return LibraryDirectoryRemovalOutcome.Retained; - } - - if (File.Exists(quarantinePath) || Directory.Exists(quarantinePath)) - { - throw new InvalidOperationException( - "The owned directory removal quarantine path is already occupied."); - } - - quarantineAnchor = pinnedDirectory.RepublishPinnedDirectory( - Path.GetFileName(originalPath), - Path.GetFileName(quarantinePath)); - quarantineExists = true; - } - if (!quarantineExists) + if (!originalExists && !quarantineExists) { - if (!LibraryDirectoryOwnershipMarker.HasValidSiblingMarker(ownership)) - { - throw new InvalidOperationException( - "The removed owned directory has no valid sibling ownership proof."); - } - + RetireLegacySiblingArtifacts(ownership, parentAnchor); return LibraryDirectoryRemovalOutcome.AlreadyRemoved; } - pinnedDirectory ??= parentAnchor.OpenExistingChildForPublication( - Path.GetFileName(quarantinePath)); - quarantineAnchor ??= pinnedDirectory.OpenCreatedDirectoryAnchor(); - try + if (originalExists) { - EnsurePhysicalIdentity(ownership, quarantineAnchor); - var insideMarkerPath = Path.Join( - quarantinePath, - LibraryDirectoryOwnershipMarker.FileName); - if (!File.Exists(insideMarkerPath)) - { - LibraryDirectoryOwnershipMarker.ValidateSiblingMarker( - ownership, - parentAnchor); - if (Directory.EnumerateFileSystemEntries(quarantinePath).Any() - || !quarantineAnchor.VisiblePathMatches() - || !parentAnchor.VisiblePathMatches()) - { - throw new InvalidOperationException( - "The owned directory removal quarantine is not empty after its inside marker was retired."); - } - - cancellationToken.ThrowIfCancellationRequested(); - LibraryDirectoryOwnershipMarker.ValidateSiblingMarker( - ownership, - parentAnchor); - pinnedDirectory.DeletePinnedEmptyDirectory( - Path.GetFileName(quarantinePath)); - return LibraryDirectoryRemovalOutcome.Removed; - } - - LibraryDirectoryOwnershipMarker.Validate( - ownership, - quarantineAnchor, - parentAnchor); - if (Directory.EnumerateFileSystemEntries(quarantinePath) - .Any(path => !string.Equals(path, insideMarkerPath, StringComparison.Ordinal)) - || !quarantineAnchor.VisiblePathMatches()) + using var publication = parentAnchor.OpenExistingChildForPublication( + Path.GetFileName(originalPath)); + using var directory = publication.OpenCreatedDirectoryAnchor(); + EnsurePhysicalIdentity(ownership, directory); + RetireLegacyOwnershipArtifacts(ownership, directory, parentAnchor); + if (Directory.EnumerateFileSystemEntries(originalPath).Any() + || !directory.VisiblePathMatches() + || !parentAnchor.VisiblePathMatches()) { - RestorePinnedQuarantine( - pinnedDirectory, - originalPath, - quarantinePath); return LibraryDirectoryRemovalOutcome.Retained; } cancellationToken.ThrowIfCancellationRequested(); - LibraryDirectoryOwnershipMarker.DeleteInsideMarker( - ownership, - quarantineAnchor, - parentAnchor); + EnsurePhysicalIdentity(ownership, directory); + publication.RetirePinnedEmptyDirectoryFromNamespace( + Path.GetFileName(originalPath)); + RetireLegacySiblingArtifacts(ownership, parentAnchor); + return LibraryDirectoryRemovalOutcome.Removed; + } + + // Compatibility only: older versions may have already renamed the directory + // into a job-shaped quarantine. New removals never create that pathname. + using (var publication = parentAnchor.OpenExistingChildForPublication( + Path.GetFileName(quarantinePath))) + using (var directory = publication.OpenCreatedDirectoryAnchor()) + { + EnsurePhysicalIdentity(ownership, directory); + RetireLegacyOwnershipArtifacts(ownership, directory, parentAnchor); if (Directory.EnumerateFileSystemEntries(quarantinePath).Any() - || !quarantineAnchor.VisiblePathMatches()) + || !directory.VisiblePathMatches() + || !parentAnchor.VisiblePathMatches()) { - throw new InvalidOperationException( - "The owned directory removal quarantine changed after its inside marker was removed."); + RestorePinnedQuarantine(publication, originalPath, quarantinePath); + return LibraryDirectoryRemovalOutcome.Retained; } - pinnedDirectory.DeletePinnedEmptyDirectory( + cancellationToken.ThrowIfCancellationRequested(); + EnsurePhysicalIdentity(ownership, directory); + publication.RetirePinnedEmptyDirectoryFromNamespace( Path.GetFileName(quarantinePath)); + RetireLegacySiblingArtifacts(ownership, parentAnchor); return LibraryDirectoryRemovalOutcome.Removed; } - finally + } + + private static void RetireLegacyOwnershipArtifacts( + LibraryDirectoryOwnership ownership, + PinnedDirectoryCreation.PinnedDirectoryAnchor directory, + PinnedDirectoryCreation.PinnedDirectoryAnchor parent) + { + if (!LibraryDirectoryOwnershipMarker.TryRetireMatchingMarkers( + ownership, + directory, + parent, + out var reason)) + { + throw new InvalidOperationException( + $"Legacy directory ownership artifacts could not be retired safely: {reason}"); + } + } + + private static void RetireLegacySiblingArtifacts( + LibraryDirectoryOwnership ownership, + PinnedDirectoryCreation.PinnedDirectoryAnchor parent) + { + if (!LibraryDirectoryOwnershipMarker.TryRetireMatchingSiblingArtifacts( + ownership, + parent, + out var reason)) { - quarantineAnchor?.Dispose(); - pinnedDirectory?.Dispose(); + throw new InvalidOperationException( + $"Legacy directory ownership sibling artifacts could not be retired safely: {reason}"); } } @@ -333,7 +278,7 @@ private static void EnsurePhysicalIdentity( || !directory.VisiblePathMatches()) { throw new InvalidOperationException( - "The owned directory no longer matches its enrolled physical identity."); + "The owned directory no longer matches its persisted physical identity."); } } diff --git a/listenarr.infrastructure/Library/Moving/MarkerlessTargetVerificationLease.cs b/listenarr.infrastructure/Library/Moving/MarkerlessTargetVerificationLease.cs new file mode 100644 index 000000000..8f5d0426e --- /dev/null +++ b/listenarr.infrastructure/Library/Moving/MarkerlessTargetVerificationLease.cs @@ -0,0 +1,54 @@ +using Listenarr.Domain.Common; + +namespace Listenarr.Infrastructure.Library.Moving; + +internal sealed class MarkerlessTargetVerificationLease : IDisposable +{ + private readonly Dictionary _entries; + private bool _disposed; + + public MarkerlessTargetVerificationLease(FileSystemPathSemantics semantics) + { + _entries = new Dictionary( + semantics.Comparer); + } + + public bool IsEmpty => _entries.Count == 0; + + public void Add( + string relativePath, + PinnedDirectoryCreation.PinnedFileEntry entry) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentException.ThrowIfNullOrWhiteSpace(relativePath); + ArgumentNullException.ThrowIfNull(entry); + if (!_entries.TryAdd(relativePath, entry)) + { + throw new InvalidOperationException( + $"A target verification lease already exists for '{relativePath}'."); + } + } + + public bool TryGet( + string relativePath, + out PinnedDirectoryCreation.PinnedFileEntry? entry) + { + ObjectDisposedException.ThrowIf(_disposed, this); + return _entries.TryGetValue(relativePath, out entry); + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + foreach (var entry in _entries.Values) + { + entry.Dispose(); + } + _entries.Clear(); + } +} diff --git a/listenarr.infrastructure/Library/Moving/MoveCleanupBoundaryResolver.Boundaries.cs b/listenarr.infrastructure/Library/Moving/MoveCleanupBoundaryResolver.Boundaries.cs index bf444a9d8..6ca87cd35 100644 --- a/listenarr.infrastructure/Library/Moving/MoveCleanupBoundaryResolver.Boundaries.cs +++ b/listenarr.infrastructure/Library/Moving/MoveCleanupBoundaryResolver.Boundaries.cs @@ -11,6 +11,16 @@ private static MoveCleanupBoundaryResolution SelectNarrowerBoundary( { try { + if (FileSystemPathIdentity.AreEquivalent( + persistedBoundary, + configuredBoundary, + semantics)) + { + return new MoveCleanupBoundaryResolution( + configuredBoundary, + MoveCleanupBoundaryKind.ConfiguredRoot); + } + if (FileSystemPathIdentity.IsSameOrInside( persistedBoundary, configuredBoundary, diff --git a/listenarr.infrastructure/Library/Moving/MoveExecutionStore.cs b/listenarr.infrastructure/Library/Moving/MoveExecutionStore.cs index 0acefcae3..7932ccce6 100644 --- a/listenarr.infrastructure/Library/Moving/MoveExecutionStore.cs +++ b/listenarr.infrastructure/Library/Moving/MoveExecutionStore.cs @@ -2,10 +2,36 @@ namespace Listenarr.Infrastructure.Library.Moving; +internal sealed record MarkerlessMoveEndpointState( + string? SourceDirectoryObjectIdentity, + string? TargetDirectoryObjectIdentity, + MoveJobEntryCleanupState SourceDirectoryCleanupState); + internal interface IMoveExecutionStore { Task EnsureLeaseOwnedAsync(Guid jobId, MoveLeaseToken leaseToken, CancellationToken cancellationToken); + Task GetExecutionProtocolVersionAsync( + Guid jobId, + CancellationToken cancellationToken); + + Task GetEndpointObjectIdentitiesAsync( + Guid jobId, + CancellationToken cancellationToken); + + Task UpdateEndpointObjectIdentitiesAsync( + Guid jobId, + MoveLeaseToken leaseToken, + string? sourceDirectoryObjectIdentity, + string? targetDirectoryObjectIdentity, + CancellationToken cancellationToken); + + Task UpdateSourceDirectoryCleanupStateAsync( + Guid jobId, + MoveLeaseToken leaseToken, + MoveJobEntryCleanupState cleanupState, + CancellationToken cancellationToken); + Task ValidateOrAdoptIdentityAsync( Guid jobId, string source, @@ -46,6 +72,22 @@ Task UpdateCopyStateAsync( MoveLeaseToken leaseToken, CancellationToken cancellationToken); + Task UpdateSourceEntryProofAsync( + Guid jobId, + MoveLeaseToken leaseToken, + string relativePath, + string sourcePhysicalObjectIdentity, + string? sha256, + CancellationToken cancellationToken); + + Task UpdateTargetEntryStateAsync( + Guid jobId, + MoveLeaseToken leaseToken, + string relativePath, + MoveJobEntryCopyState copyState, + string? targetPhysicalObjectIdentity, + CancellationToken cancellationToken); + Task UpdateJobPhaseAsync( Guid jobId, MoveLeaseToken leaseToken, @@ -68,4 +110,12 @@ Task UpdateCreatedDirectoryStateAsync( string path, MoveCreatedDirectoryState state, CancellationToken cancellationToken); + + Task UpdateCreatedDirectoryPublicationAsync( + Guid jobId, + MoveLeaseToken leaseToken, + string path, + MoveCreatedDirectoryState state, + string directoryObjectIdentity, + CancellationToken cancellationToken); } diff --git a/listenarr.infrastructure/Library/Moving/MoveJobProcessor.Core.cs b/listenarr.infrastructure/Library/Moving/MoveJobProcessor.Core.cs index 6d047bf9c..644ba9bc8 100644 --- a/listenarr.infrastructure/Library/Moving/MoveJobProcessor.Core.cs +++ b/listenarr.infrastructure/Library/Moving/MoveJobProcessor.Core.cs @@ -118,7 +118,10 @@ private async Task ProcessJobCoreAsync( resolvedSourceIdentity.Semantics, targetSemantics, CreateLeaseToken(job), - cleanupBoundaryResolution.Boundary); + cleanupBoundaryResolution.Boundary, + AllowUnownedSourceAncestorCleanup: + cleanupBoundaryResolution.Kind + == MoveCleanupBoundaryKind.ConfiguredRoot); try { var resumedMove = await contentMoveService.GetRecoverableMoveAsync(recoveryRequest, stoppingToken); @@ -348,6 +351,7 @@ private async Task ExecuteFilesystemMoveAsync( CancellationToken stoppingToken) { AudiobookContentMoveRequest? moveRequest = null; + AudiobookContentMoveResult? moveResult = recoveredMove; try { moveRequest = new AudiobookContentMoveRequest( @@ -359,8 +363,17 @@ private async Task ExecuteFilesystemMoveAsync( targetSemantics, CreateLeaseToken(job), cleanupBoundaryResolution.Boundary, - SourcePhysicalObjectIdentities: sourcePhysicalObjectIdentities); - var moveResult = recoveredMove ?? await contentMoveService.MoveContentsAsync(moveRequest, stoppingToken); + SourcePhysicalObjectIdentities: sourcePhysicalObjectIdentities, + ProgressReporter: (progress, phase, token) => + moveQueueService.PublishProgressAsync( + job.Id, + progress, + phase, + token), + AllowUnownedSourceAncestorCleanup: + cleanupBoundaryResolution.Kind + == MoveCleanupBoundaryKind.ConfiguredRoot); + moveResult ??= await contentMoveService.MoveContentsAsync(moveRequest, stoppingToken); moveResult = await contentMoveService.ResumeSourceCleanupAsync(moveRequest, moveResult, stoppingToken); source = moveResult.Source; target = moveResult.Target; @@ -461,6 +474,10 @@ await RecordTerminalMoveFailureAsync( ex, stoppingToken); } + finally + { + moveResult?.TargetVerificationLease?.Dispose(); + } } } diff --git a/listenarr.infrastructure/Library/Moving/MoveJobProcessor.SourceState.cs b/listenarr.infrastructure/Library/Moving/MoveJobProcessor.SourceState.cs index 097c29b39..5da064817 100644 --- a/listenarr.infrastructure/Library/Moving/MoveJobProcessor.SourceState.cs +++ b/listenarr.infrastructure/Library/Moving/MoveJobProcessor.SourceState.cs @@ -268,13 +268,18 @@ private static async Task CurrentTrackedManifestMatchesAsync( try { var currentManifest = await services - .GetRequiredService() - .BuildAsync(currentAudiobook, cancellationToken); + .GetRequiredService() + .BuildPlanAsync( + new AudiobookPathReferenceSnapshot( + currentAudiobook.Id, + currentAudiobook.BasePath, + currentAudiobook.FilePath), + cancellationToken); return FileSystemPathIdentity.AreEquivalent( currentManifest.SourceRoot, source, sourceIdentity.Semantics) - && MoveManifestIdentity.SourceManifestsMatch( + && MoveManifestIdentity.SourceManifestShapesMatch( currentManifest.Entries, job.Entries, sourceIdentity.Semantics); diff --git a/listenarr.infrastructure/Library/Moving/MoveSourceCompanionManifestBuilder.cs b/listenarr.infrastructure/Library/Moving/MoveSourceCompanionManifestBuilder.cs index 25cb62176..3239ce5ab 100644 --- a/listenarr.infrastructure/Library/Moving/MoveSourceCompanionManifestBuilder.cs +++ b/listenarr.infrastructure/Library/Moving/MoveSourceCompanionManifestBuilder.cs @@ -7,21 +7,22 @@ namespace Listenarr.Infrastructure.Library.Moving; internal static class MoveSourceCompanionManifestBuilder { public static async Task> BuildAsync( - Audiobook audiobook, + int audiobookId, + string? audiobookBasePath, string sourceRoot, PathIdentitySnapshot sourceIdentity, IReadOnlyCollection trackedFilePaths, LibraryDirectoryOwnershipBoundaryAuthorizer? ownershipAuthorizer, IAudiobookRepository? audiobookRepository, IAudiobookFileRepository fileRepository, + bool includeContentHashes, CancellationToken cancellationToken) { - ArgumentNullException.ThrowIfNull(audiobook); if (ownershipAuthorizer == null || audiobookRepository == null - || string.IsNullOrWhiteSpace(audiobook.BasePath) + || string.IsNullOrWhiteSpace(audiobookBasePath) || !FileSystemPathIdentity.TryCanonicalizeStoredPathWithIdentityForHost( - audiobook.BasePath, + audiobookBasePath, sourceIdentity, out var canonicalBasePath, out _) @@ -34,7 +35,7 @@ public static async Task> BuildAsync( } if (!await HasExclusiveAudiobookReferenceAsync( - audiobook, + audiobookId, sourceRoot, sourceIdentity.Semantics, audiobookRepository, @@ -88,6 +89,7 @@ await CaptureDirectoryAsync( tracked, sourceIdentity.Semantics, entries, + includeContentHashes, cancellationToken); if (!source.VisiblePathMatches(sourceRoot)) { @@ -113,16 +115,16 @@ or NotSupportedException or PathTooLongException } private static async Task HasExclusiveAudiobookReferenceAsync( - Audiobook audiobook, + int audiobookId, string sourceRoot, FileSystemPathSemantics semantics, IAudiobookRepository audiobookRepository, IAudiobookFileRepository fileRepository, CancellationToken cancellationToken) { - var otherAudiobooks = (await audiobookRepository.GetAllAsync()) - .Where(candidate => candidate.Id != audiobook.Id) - .ToDictionary(candidate => candidate.Id); + var otherAudiobooks = (await audiobookRepository + .GetOtherPathReferenceSnapshotsAsync(audiobookId, cancellationToken)) + .ToDictionary(candidate => candidate.AudiobookId); foreach (var other in otherAudiobooks.Values) { cancellationToken.ThrowIfCancellationRequested(); @@ -152,7 +154,7 @@ private static async Task HasExclusiveAudiobookReferenceAsync( if (!string.IsNullOrWhiteSpace(other.FilePath)) { if (!TryResolveOtherStoredFilePath( - other, + other.BasePath, other.FilePath, semantics, out var legacyPath)) @@ -170,14 +172,13 @@ private static async Task HasExclusiveAudiobookReferenceAsync( } } - foreach (var file in (await fileRepository.GetAllAsync()).Where(file => - file.AudiobookId != audiobook.Id - && !string.IsNullOrWhiteSpace(file.Path))) + foreach (var file in await fileRepository + .GetOtherPathReferenceSnapshotsAsync(audiobookId, cancellationToken)) { cancellationToken.ThrowIfCancellationRequested(); if (!otherAudiobooks.TryGetValue(file.AudiobookId, out var owner) || !TryResolveOtherStoredFilePath( - owner, + owner.BasePath, file.Path!, semantics, out var otherFilePath)) @@ -198,7 +199,7 @@ private static async Task HasExclusiveAudiobookReferenceAsync( } private static bool TryResolveOtherStoredFilePath( - Audiobook audiobook, + string? audiobookBasePath, string storedPath, FileSystemPathSemantics semantics, out string resolvedPath) @@ -217,9 +218,9 @@ private static bool TryResolveOtherStoredFilePath( return true; } - return !string.IsNullOrWhiteSpace(audiobook.BasePath) + return !string.IsNullOrWhiteSpace(audiobookBasePath) && FileSystemPathIdentity.TryCanonicalizeUnambiguousStoredAbsolutePathForHost( - audiobook.BasePath, + audiobookBasePath, out var basePath, out _) && FileSystemPathIdentity.TryResolveRelativePathWithinBase( @@ -235,6 +236,7 @@ private static async Task CaptureDirectoryAsync( IReadOnlySet trackedFilePaths, FileSystemPathSemantics semantics, ICollection entries, + bool includeContentHashes, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); @@ -267,6 +269,7 @@ private static async Task CaptureDirectoryAsync( trackedFilePaths, semantics, entries, + includeContentHashes, cancellationToken); if (childContainsCompanion) { @@ -296,6 +299,7 @@ private static async Task CaptureDirectoryAsync( current, entryName, semantics, + includeContentHashes, cancellationToken)); containsCompanion = true; } @@ -316,6 +320,7 @@ private static async Task CaptureFileAsync( PinnedDirectoryCreation.PinnedDirectoryAnchor parent, string fileName, FileSystemPathSemantics semantics, + bool includeContentHash, CancellationToken cancellationToken) { using var file = parent.OpenExistingFileForStableRead(fileName); @@ -325,8 +330,10 @@ private static async Task CaptureFileAsync( asynchronous: false); var length = stream.Length; var lastWriteTimeUtc = File.GetLastWriteTimeUtc(file.FullPath); - var hashBytes = await SHA256.HashDataAsync(stream, cancellationToken); - var hash = Convert.ToHexString(hashBytes); + var hash = includeContentHash + ? Convert.ToHexString( + await SHA256.HashDataAsync(stream, cancellationToken)) + : null; if (!root.VisiblePathMatches() || !parent.VisiblePathMatches() || !file.VisiblePathMatches() diff --git a/listenarr.infrastructure/Library/Moving/MoveSourceManifestService.EntryPoints.cs b/listenarr.infrastructure/Library/Moving/MoveSourceManifestService.EntryPoints.cs new file mode 100644 index 000000000..33aee3444 --- /dev/null +++ b/listenarr.infrastructure/Library/Moving/MoveSourceManifestService.EntryPoints.cs @@ -0,0 +1,28 @@ +namespace Listenarr.Infrastructure.Library.Moving; + +internal sealed partial class MoveSourceManifestService +{ + public Task BuildAsync( + Audiobook audiobook, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(audiobook); + return BuildCoreAsync( + audiobook.Id, + audiobook.BasePath, + includeContentHashes: true, + cancellationToken); + } + + public Task BuildPlanAsync( + AudiobookPathReferenceSnapshot audiobook, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(audiobook); + return BuildCoreAsync( + audiobook.AudiobookId, + audiobook.BasePath, + includeContentHashes: false, + cancellationToken); + } +} diff --git a/listenarr.infrastructure/Library/Moving/MoveSourceManifestService.cs b/listenarr.infrastructure/Library/Moving/MoveSourceManifestService.cs index 907ffcef8..90fa6186d 100644 --- a/listenarr.infrastructure/Library/Moving/MoveSourceManifestService.cs +++ b/listenarr.infrastructure/Library/Moving/MoveSourceManifestService.cs @@ -4,18 +4,20 @@ namespace Listenarr.Infrastructure.Library.Moving; -internal sealed class MoveSourceManifestService( +internal sealed partial class MoveSourceManifestService( IAudiobookFileRepository fileRepository, LibraryDirectoryOwnershipBoundaryAuthorizer? ownershipAuthorizer = null, - IAudiobookRepository? audiobookRepository = null) : IMoveSourceManifestService + IAudiobookRepository? audiobookRepository = null) + : IMoveSourceManifestService, IMoveSourcePlanService { - public async Task BuildAsync( - Audiobook audiobook, - CancellationToken cancellationToken = default) + private async Task BuildCoreAsync( + int audiobookId, + string? audiobookBasePath, + bool includeContentHashes, + CancellationToken cancellationToken) { - ArgumentNullException.ThrowIfNull(audiobook); var trackedFiles = await fileRepository.GetByAudiobookIdAsync( - audiobook.Id, + audiobookId, cancellationToken); if (trackedFiles.Count == 0) { @@ -42,6 +44,7 @@ public async Task BuildAsync( trackedFile.Id, path, trackedFile.PhysicalObjectIdentity, + includeContentHashes, cancellationToken)); } @@ -72,13 +75,15 @@ public async Task BuildAsync( validated, identitySnapshot.Semantics); var companionEntries = await MoveSourceCompanionManifestBuilder.BuildAsync( - audiobook, + audiobookId, + audiobookBasePath, sourceRoot, identitySnapshot, validated.Select(file => file.Path).ToList(), ownershipAuthorizer, audiobookRepository, fileRepository, + includeContentHashes, cancellationToken); entries = MergeEntries( entries, @@ -137,6 +142,7 @@ private static async Task ValidateFileAsync( int audiobookFileId, string path, string? expectedPhysicalObjectIdentity, + bool includeContentHash, CancellationToken cancellationToken) { if (!File.Exists(path)) @@ -175,8 +181,10 @@ private static async Task ValidateFileAsync( asynchronous: false); var length = stream.Length; var lastWriteTimeUtc = File.GetLastWriteTimeUtc(path); - var hashBytes = await SHA256.HashDataAsync(stream, cancellationToken); - var hash = Convert.ToHexString(hashBytes); + var hash = includeContentHash + ? Convert.ToHexString( + await SHA256.HashDataAsync(stream, cancellationToken)) + : null; if (!file.VisiblePathMatches() || !string.Equals( file.GetObjectIdentity(), @@ -474,5 +482,5 @@ private sealed record ValidatedTrackedFile( string Path, long Length, DateTime LastWriteTimeUtc, - string Sha256); + string? Sha256); } diff --git a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.Helpers.cs b/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.Helpers.cs index 21c11963c..a481f64fb 100644 --- a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.Helpers.cs +++ b/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.Helpers.cs @@ -192,12 +192,22 @@ private async Task FinalizeCompletedRelocationAsync( relocation.Error = "Target filesystem identity became unavailable during finalization."; return; } + if (!relocation.TargetDirectoryObjectIdentityVersion.HasValue + || string.IsNullOrWhiteSpace(relocation.TargetDirectoryObjectIdentity)) + { + relocation.Status = RootFolderRelocationStatus.NeedsAttention; + relocation.Error = + "The target directory no longer has persisted physical identity authorization."; + return; + } + var currentObjectIdentity = await ResolveExistingDirectoryObjectIdentityAsync( canonicalTargetPath, + relocation.TargetDirectoryObjectIdentityVersion.Value, + relocation.TargetDirectoryObjectIdentity, cancellationToken); - if (!relocation.TargetDirectoryObjectIdentityVersion.HasValue - || !currentObjectIdentity.IsAvailable + if (!currentObjectIdentity.IsAvailable || currentObjectIdentity.Version != relocation.TargetDirectoryObjectIdentityVersion || !string.Equals( diff --git a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.MetadataStart.cs b/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.MetadataStart.cs index c122863a6..819f598ce 100644 --- a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.MetadataStart.cs +++ b/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.MetadataStart.cs @@ -248,6 +248,20 @@ await RetireOwnershipMigrationSourcesAsync( targetObjectIdentity.Value, targetObjectIdentity.UnavailableReason, CancellationToken.None); + foreach (var plan in ownershipPlans) + { + plan.Journal.State = + LibraryDirectoryOwnershipPathMigrationState.SourceMarkersRetired; + plan.Journal.UpdatedAt = DateTime.UtcNow; + } + await db.SaveChangesAsync(CancellationToken.None); + await RetireOwnershipMigrationTargetsAsync( + ownershipPlans, + targetPath, + targetObjectIdentity.Version, + targetObjectIdentity.Value, + targetObjectIdentity.UnavailableReason, + CancellationToken.None); db.LibraryDirectoryOwnershipPathMigrations.RemoveRange( ownershipPlans.Select(plan => plan.Journal)); var completedWithoutAttention = skipped.Count == 0; diff --git a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.OwnershipMigrationArtifactCleanup.cs b/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.OwnershipMigrationArtifactCleanup.cs new file mode 100644 index 000000000..8ca0eda46 --- /dev/null +++ b/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.OwnershipMigrationArtifactCleanup.cs @@ -0,0 +1,53 @@ +namespace Listenarr.Infrastructure.Library.Moving; + +public sealed partial class RootFolderRelocationService +{ + private static async Task RetireOwnershipMigrationTargetsAsync( + IReadOnlyList plans, + string targetBoundary, + int? targetIdentityVersion, + string? targetIdentityValue, + string? targetIdentityUnavailableReason, + CancellationToken cancellationToken) + { + foreach (var plan in plans) + { + cancellationToken.ThrowIfCancellationRequested(); + var targetParentPath = Path.GetDirectoryName(plan.Target.CanonicalPath) + ?? throw new InvalidOperationException( + "The ownership migration target has no parent directory."); + using var targetParent = await OpenVerifiedMarkerParentWithinBoundaryAsync( + targetBoundary, + targetParentPath, + plan.Target.GetIdentity().Semantics, + targetIdentityVersion, + targetIdentityValue, + targetIdentityUnavailableReason, + cancellationToken); + using var publication = targetParent.OpenExistingChildForPublication( + Path.GetFileName(plan.Target.CanonicalPath)); + using var targetDirectory = publication.OpenCreatedDirectoryAnchor(); + if (!ManagedDirectoryIdentity.Matches( + plan.Target.DirectoryObjectIdentityVersion, + plan.Target.DirectoryObjectIdentity, + plan.Target.OwnershipToken, + targetDirectory.GetDirectoryObjectIdentity()) + || !targetDirectory.VisiblePathMatches() + || !targetParent.VisiblePathMatches()) + { + throw new InvalidOperationException( + "The ownership migration target changed before temporary artifact cleanup."); + } + + if (!LibraryDirectoryOwnershipMarker.TryRetireMatchingMarkers( + plan.Target, + targetDirectory, + targetParent, + out var reason)) + { + throw new InvalidOperationException( + $"Temporary ownership migration artifacts could not be retired safely: {reason}"); + } + } + } +} diff --git a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.OwnershipMigrationRecovery.cs b/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.OwnershipMigrationRecovery.cs index c4c52995e..dc1e0b070 100644 --- a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.OwnershipMigrationRecovery.cs +++ b/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.OwnershipMigrationRecovery.cs @@ -116,6 +116,28 @@ await RetireOwnershipMigrationSourcesAsync( relocation.TargetDirectoryObjectIdentity, relocation.TargetDirectoryObjectIdentityUnavailableReason, CancellationToken.None); + foreach (var plan in plans) + { + plan.Journal.State = + LibraryDirectoryOwnershipPathMigrationState.SourceMarkersRetired; + plan.Journal.UpdatedAt = + timeProvider.GetUtcNow().UtcDateTime; + } + await db.SaveChangesAsync(CancellationToken.None); + } + + if (plans.All(plan => + plan.Journal.State + == LibraryDirectoryOwnershipPathMigrationState + .SourceMarkersRetired)) + { + await RetireOwnershipMigrationTargetsAsync( + plans, + relocation.TargetPath, + relocation.TargetDirectoryObjectIdentityVersion, + relocation.TargetDirectoryObjectIdentity, + relocation.TargetDirectoryObjectIdentityUnavailableReason, + CancellationToken.None); db.LibraryDirectoryOwnershipPathMigrations .RemoveRange(plans.Select(plan => plan.Journal)); FinalizeRecoveredMetadataOnlyRelocation( diff --git a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.OwnershipMigrationRetirement.cs b/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.OwnershipMigrationRetirement.cs index 7fc15d06e..9c8556669 100644 --- a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.OwnershipMigrationRetirement.cs +++ b/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.OwnershipMigrationRetirement.cs @@ -271,12 +271,17 @@ private static async Task || !string.IsNullOrWhiteSpace(expectedBoundaryIdentityValue) || !string.IsNullOrWhiteSpace(boundaryIdentityUnavailableReason)) { - await ManagedDirectoryEnrollment.RequireMatchingEnrollmentAsync( - current, - expectedBoundaryIdentityVersion, - expectedBoundaryIdentityValue, - boundaryIdentityUnavailableReason, - cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + if (!string.IsNullOrWhiteSpace(boundaryIdentityUnavailableReason) + || !ManagedDirectoryIdentity.MatchesNativeIdentity( + expectedBoundaryIdentityVersion, + expectedBoundaryIdentityValue, + current.GetDirectoryObjectIdentity()) + || !current.VisiblePathMatches()) + { + throw new InvalidOperationException( + "The ownership migration boundary no longer identifies its authorized physical generation."); + } } if (FileSystemPathIdentity.AreEquivalent( diff --git a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.Reauthorization.cs b/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.Reauthorization.cs index a9489283c..67b57962a 100644 --- a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.Reauthorization.cs +++ b/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.Reauthorization.cs @@ -119,18 +119,16 @@ private async Task ReauthorizeLegacyTargetCoreAsync( relocation, sourceResolution.Semantics, targetResolution.Semantics); + cancellationToken.ThrowIfCancellationRequested(); var targetNativeIdentity = target.GetDirectoryObjectIdentity(); - var targetObjectIdentity = await ManagedDirectoryEnrollment.ResolveAsync( - target, - targetNativeIdentity, - enrollIfMissing: true, - cancellationToken); - if (!targetObjectIdentity.IsAvailable - || !target.VisiblePathMatches()) + var targetObjectIdentity = new DirectoryObjectIdentityResolution( + ManagedDirectoryIdentity.CurrentVersion, + ManagedDirectoryIdentity.CreateMarkerless(targetNativeIdentity), + null); + if (!target.VisiblePathMatches()) { throw new InvalidOperationException( - targetObjectIdentity.UnavailableReason - ?? "The relocation target changed while its enrollment identity was captured."); + "The relocation target changed while its physical identity was captured."); } foreach (var job in relocation.MoveJobs) diff --git a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.TargetIdentity.cs b/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.TargetIdentity.cs index 93ae95127..05e3ebe5f 100644 --- a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.TargetIdentity.cs +++ b/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.TargetIdentity.cs @@ -29,7 +29,7 @@ private static void RejectTargetNavigationSegments(string targetPath) } } - private static async Task RequireTargetDirectoryGenerationAsync( + private static Task RequireTargetDirectoryGenerationAsync( string targetPath, int? expectedVersion, string? expectedValue, @@ -48,12 +48,19 @@ private static async Task RequireTargetDirectoryGenerationAsync( { using var target = PinnedDirectoryCreation.OpenPinnedBoundary( canonicalTargetPath); - await ManagedDirectoryEnrollment.RequireMatchingEnrollmentAsync( - target, - expectedVersion, - expectedValue, - unavailableReason, - cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + if (!string.IsNullOrWhiteSpace(unavailableReason) + || !ManagedDirectoryIdentity.MatchesNativeIdentity( + expectedVersion, + expectedValue, + target.GetDirectoryObjectIdentity()) + || !target.VisiblePathMatches()) + { + throw new InvalidOperationException( + "The managed directory no longer identifies its authorized physical generation."); + } + + return Task.CompletedTask; } catch (Exception exception) when (exception is IOException or UnauthorizedAccessException @@ -86,7 +93,7 @@ private static void ApplyRootDirectoryObjectIdentity( root.DirectoryObjectIdentityUnavailableReason = identity.UnavailableReason; } - private async Task + private Task ResolveOrCreateRelocationTargetIdentityAsync( string targetPath, CancellationToken cancellationToken) @@ -109,24 +116,14 @@ private async Task "The relocation target changed while its physical identity was reserved."); } - var nativeIdentity = anchor.GetDirectoryObjectIdentity(); - return await ManagedDirectoryEnrollment.ResolveAsync( - anchor, - nativeIdentity, - enrollIfMissing: true, - cancellationToken); + return Task.FromResult(CreateMarkerlessIdentity(anchor)); } try { using var existing = PinnedDirectoryCreation.OpenPinnedBoundary( targetPath); - var nativeIdentity = existing.GetDirectoryObjectIdentity(); - return await ManagedDirectoryEnrollment.ResolveAsync( - existing, - nativeIdentity, - enrollIfMissing: true, - cancellationToken); + return Task.FromResult(CreateMarkerlessIdentity(existing)); } catch (Exception exception) when (exception is IOException or UnauthorizedAccessException @@ -142,55 +139,92 @@ or InvalidOperationException or NotSupportedException private Task ResolveOrEnrollDirectoryObjectIdentityAsync( string path, - CancellationToken cancellationToken) => - ResolveDirectoryObjectIdentityAsync( + CancellationToken cancellationToken) + { + if (_directoryObjectIdentityResolver != null) + { + return _directoryObjectIdentityResolver.ResolveAsync( + path, + cancellationToken); + } + + return ResolveMarkerlessDirectoryObjectIdentityAsync( path, - enrollIfMissing: true, + expectedVersion: null, + expectedValue: null, cancellationToken); + } private Task ResolveExistingDirectoryObjectIdentityAsync( string path, - CancellationToken cancellationToken) => - ResolveDirectoryObjectIdentityAsync( - path, - enrollIfMissing: false, - cancellationToken); - - private async Task - ResolveDirectoryObjectIdentityAsync( - string path, - bool enrollIfMissing, + int expectedVersion, + string expectedValue, CancellationToken cancellationToken) { if (_directoryObjectIdentityResolver != null) { - return enrollIfMissing - ? await _directoryObjectIdentityResolver.ResolveAsync( - path, - cancellationToken) - : await _directoryObjectIdentityResolver.ResolveExistingAsync( - path, - cancellationToken); + return _directoryObjectIdentityResolver.ResolveExistingAsync( + path, + expectedVersion, + expectedValue, + cancellationToken); } + return ResolveMarkerlessDirectoryObjectIdentityAsync( + path, + expectedVersion, + expectedValue, + cancellationToken); + } + + private static Task + ResolveMarkerlessDirectoryObjectIdentityAsync( + string path, + int? expectedVersion, + string? expectedValue, + CancellationToken cancellationToken) + { try { using var anchor = PinnedDirectoryCreation.OpenPinnedBoundary(path); + cancellationToken.ThrowIfCancellationRequested(); var nativeIdentity = anchor.GetDirectoryObjectIdentity(); - return await ManagedDirectoryEnrollment.ResolveAsync( - anchor, - nativeIdentity, - enrollIfMissing, - cancellationToken); + if (expectedVersion.HasValue && expectedValue != null) + { + return Task.FromResult( + ManagedDirectoryIdentity.MatchesNativeIdentity( + expectedVersion, + expectedValue, + nativeIdentity) + ? new DirectoryObjectIdentityResolution( + expectedVersion, + expectedValue, + null) + : DirectoryObjectIdentityResolution.Unavailable( + "The live directory no longer matches its persisted physical identity.")); + } + + return Task.FromResult(CreateMarkerlessIdentity(anchor)); } catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or InvalidOperationException or NotSupportedException or System.ComponentModel.Win32Exception) { - return DirectoryObjectIdentityResolution.Unavailable( - exception.Message); + return Task.FromResult( + DirectoryObjectIdentityResolution.Unavailable( + exception.Message)); } } + + private static DirectoryObjectIdentityResolution CreateMarkerlessIdentity( + PinnedDirectoryCreation.PinnedDirectoryAnchor anchor) + { + var nativeIdentity = anchor.GetDirectoryObjectIdentity(); + return new DirectoryObjectIdentityResolution( + ManagedDirectoryIdentity.CurrentVersion, + ManagedDirectoryIdentity.CreateMarkerless(nativeIdentity), + null); + } } diff --git a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.TargetReservationPersistence.cs b/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.TargetReservationPersistence.cs index 3a7dcc68e..77b813d6f 100644 --- a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.TargetReservationPersistence.cs +++ b/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.TargetReservationPersistence.cs @@ -1,4 +1,3 @@ -using System.Text; using System.Text.Json; using Microsoft.EntityFrameworkCore; @@ -6,6 +5,9 @@ namespace Listenarr.Infrastructure.Library.Moving; public sealed partial class RootFolderRelocationService { + private static readonly JsonSerializerOptions ReservationJsonOptions = + new(JsonSerializerDefaults.Web); + private async Task PersistTargetReservationPlanAsync( Guid relocationId, TargetReservationPlan plan, @@ -72,111 +74,6 @@ private async Task PersistTargetReservationPlanAsync( await transaction.CommitAsync(CancellationToken.None); } - private async Task EnsureReservationParentMarkerAsync( - Guid relocationId, - RootFolderRelocationCreatedDirectory reservation, - PinnedDirectoryCreation.PinnedDirectoryAnchor parent, - bool allowPublication, - CancellationToken cancellationToken) - { - var nativeIdentity = parent.GetDirectoryObjectIdentity(); - var expectedParentIdentity = ManagedDirectoryIdentity.Create( - reservation.OwnershipToken, - nativeIdentity); - if (reservation.State - == RootFolderRelocationCreatedDirectoryState.Planned - && (reservation.DirectoryObjectIdentityVersion - != ManagedDirectoryIdentity.CurrentVersion - || !string.Equals( - reservation.DirectoryObjectIdentity, - expectedParentIdentity, - StringComparison.Ordinal))) - { - throw new InvalidOperationException( - "The parent of a planned relocation directory changed before creation."); - } - - var fileName = GetReservationParentMarkerName(reservation); - using var existing = parent.TryOpenExistingFile( - fileName, - requireDeleteAccess: false); - if (existing != null) - { - ValidateReservationParentMarker( - relocationId, - reservation, - parent, - existing); - return; - } - - var temporaryName = fileName + ".tmp"; - using var interrupted = parent.TryOpenExistingFile( - temporaryName, - requireDeleteAccess: true); - if (interrupted != null) - { - ValidateReservationParentMarker( - relocationId, - reservation, - parent, - interrupted); - interrupted.MoveWithinParent(fileName); - parent.FlushDirectoryEntry(); - ValidateReservationParentMarker( - relocationId, - reservation, - parent); - AfterReservationParentMarkerPublishedForTest?.Invoke( - reservation.CanonicalPath); - return; - } - - if (!allowPublication) - { - throw new InvalidOperationException( - "A published relocation child has no durable parent reservation intent."); - } - - var payload = new TargetReservationParentMarker( - 1, - relocationId, - reservation.OwnershipToken, - reservation.CanonicalPath, - expectedParentIdentity); - await parent.PublishNewFileAsync( - temporaryName, - fileName, - beforeCreateAsync: () => - { - cancellationToken.ThrowIfCancellationRequested(); - return Task.CompletedTask; - }, - writeAndFlushAsync: async stream => - { - await JsonSerializer.SerializeAsync( - stream, - payload, - ReservationJsonOptions, - cancellationToken); - await stream.FlushAsync(cancellationToken); - stream.Flush(flushToDisk: true); - }, - beforePublicationAsync: () => - { - cancellationToken.ThrowIfCancellationRequested(); - return Task.CompletedTask; - }, - preserveTemporaryFileOnFailure: _ => false); - parent.FlushDirectoryEntry(); - ValidateReservationParentMarker( - relocationId, - reservation, - parent); - AfterReservationParentMarkerPublishedForTest?.Invoke( - reservation.CanonicalPath); - } - private static void RetireReservationParentMarker( Guid relocationId, RootFolderRelocationCreatedDirectory reservation, @@ -281,48 +178,80 @@ private static string GetReservationParentMarkerName( return $"{RelocationReservationParentMarkerPrefix}{reservation.OwnershipToken}.json"; } - private static void WriteReservationMarker( + private static bool TryValidateLegacyReservationMarkers( Guid relocationId, RootFolderRelocationCreatedDirectory reservation, + PinnedDirectoryCreation.PinnedDirectoryAnchor parent, PinnedDirectoryCreation.PinnedDirectoryAnchor directory) { - using var marker = directory.CreateNewFile( + using var parentMarker = parent.TryOpenExistingFile( + GetReservationParentMarkerName(reservation), + requireDeleteAccess: false); + using var directoryMarker = directory.TryOpenExistingFile( RelocationReservationMarkerName, - hiddenFile: true); - using (var stream = marker.OpenWriteStream( - bufferSize: 4096, - asynchronous: false)) + requireDeleteAccess: false); + if (parentMarker == null && directoryMarker == null) { - var bytes = Encoding.UTF8.GetBytes( - JsonSerializer.Serialize( - new TargetReservationMarker( - 1, - relocationId, - reservation.OwnershipToken, - reservation.CanonicalPath), - ReservationJsonOptions)); - stream.Write(bytes); - stream.Flush(flushToDisk: true); + return false; } - - if (!marker.VisiblePathMatches() - || !directory.VisiblePathMatches()) + if (parentMarker == null || directoryMarker == null) { throw new InvalidOperationException( - "The relocation reservation marker changed during publication."); + "A legacy relocation reservation has incomplete marker evidence."); } + + ValidateReservationParentMarker( + relocationId, + reservation, + parent, + parentMarker); + ValidateReservationMarker( + relocationId, + reservation, + directoryMarker); + return parent.VisiblePathMatches() + && directory.VisiblePathMatches() + && parentMarker.VisiblePathMatches() + && directoryMarker.VisiblePathMatches(); } - private static void ValidateReservationDirectory( + private void RetireLegacyReservationMarkers( Guid relocationId, RootFolderRelocationCreatedDirectory reservation, + PinnedDirectoryCreation.PinnedDirectoryAnchor parent, PinnedDirectoryCreation.PinnedDirectoryAnchor directory) { - ValidateReservationDirectoryIdentity(reservation, directory); - ValidateReservationMarker( + using (var marker = directory.TryOpenExistingFile( + RelocationReservationMarkerName, + requireDeleteAccess: true)) + { + if (marker != null) + { + ValidateReservationMarker( + relocationId, + reservation, + marker); + if (!marker.VisiblePathMatches() + || !directory.VisiblePathMatches()) + { + throw new InvalidOperationException( + "A legacy relocation reservation marker changed before retirement."); + } + + BeforeReservationMarkerRetirementForTest?.Invoke( + reservation.CanonicalPath); + marker.Delete(); + directory.FlushDirectoryEntry(); + AfterReservationMarkerRetiredForTest?.Invoke( + reservation.CanonicalPath); + } + } + + RetireReservationParentMarker( relocationId, reservation, - directory); + parent); + ManagedDirectoryEnrollment.RetireValidMarker(directory); } private static void ValidateReservationDirectoryIdentity( @@ -402,57 +331,6 @@ private static void ValidateReservationMarker( } } - private void FlushReservationDirectory( - PinnedDirectoryCreation.PinnedDirectoryAnchor directory) - { - directory.FlushDirectoryEntry(); - TargetReservationDirectoryFlushedForTest?.Invoke( - directory.FullPath); - } - - private static bool TryEnrollPublishedPlannedReservation( - Guid relocationId, - RootFolderRelocationCreatedDirectory reservation, - PinnedDirectoryCreation.PinnedDirectoryAnchor parent, - PinnedDirectoryCreation.PinnedDirectoryAnchor directory) - { - try - { - if (reservation.DirectoryObjectIdentityVersion - != ManagedDirectoryIdentity.CurrentVersion - || string.IsNullOrWhiteSpace( - reservation.DirectoryObjectIdentity) - || !string.Equals( - reservation.DirectoryObjectIdentity, - ManagedDirectoryIdentity.Create( - reservation.OwnershipToken, - parent.GetDirectoryObjectIdentity()), - StringComparison.Ordinal) - || !parent.VisiblePathMatches() - || !directory.VisiblePathMatches()) - { - return false; - } - - ValidateReservationParentMarker( - relocationId, - reservation, - parent); - ValidateReservationMarker( - relocationId, - reservation, - directory); - return parent.VisiblePathMatches() - && directory.VisiblePathMatches(); - } - catch (Exception exception) when (exception is not ( - OutOfMemoryException - or StackOverflowException)) - { - return false; - } - } - private static StringComparison PathComparison => OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase diff --git a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.TargetReservationState.cs b/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.TargetReservationState.cs new file mode 100644 index 000000000..34db066b8 --- /dev/null +++ b/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.TargetReservationState.cs @@ -0,0 +1,138 @@ +using Listenarr.Domain.Common; +using Listenarr.Infrastructure.Persistence; + +namespace Listenarr.Infrastructure.Library.Moving; + +public sealed partial class RootFolderRelocationService +{ + private async Task PersistOrValidatePlannedParentIdentityAsync( + ListenArrDbContext db, + RootFolderRelocationCreatedDirectory reservation, + PinnedDirectoryCreation.PinnedDirectoryAnchor parent, + CancellationToken cancellationToken) + { + var expected = ManagedDirectoryIdentity.Create( + reservation.OwnershipToken, + parent.GetDirectoryObjectIdentity()); + if (reservation.DirectoryObjectIdentityVersion == null + && string.IsNullOrWhiteSpace( + reservation.DirectoryObjectIdentity)) + { + reservation.DirectoryObjectIdentityVersion = + ManagedDirectoryIdentity.CurrentVersion; + reservation.DirectoryObjectIdentity = expected; + reservation.UpdatedAt = + timeProvider.GetUtcNow().UtcDateTime; + await db.SaveChangesAsync(cancellationToken); + AfterReservationParentIntentPersistedForTest?.Invoke( + reservation.CanonicalPath); + return; + } + + if (reservation.DirectoryObjectIdentityVersion + != ManagedDirectoryIdentity.CurrentVersion + || !string.Equals( + reservation.DirectoryObjectIdentity, + expected, + StringComparison.Ordinal) + || !parent.VisiblePathMatches()) + { + throw new InvalidOperationException( + "The parent of a planned relocation directory changed before creation."); + } + } + + private static void ValidatePlannedReservationParent( + RootFolderRelocationCreatedDirectory reservation, + PinnedDirectoryCreation.PinnedDirectoryAnchor parent) + { + if (reservation.DirectoryObjectIdentityVersion + != ManagedDirectoryIdentity.CurrentVersion + || !string.Equals( + reservation.DirectoryObjectIdentity, + ManagedDirectoryIdentity.Create( + reservation.OwnershipToken, + parent.GetDirectoryObjectIdentity()), + StringComparison.Ordinal) + || !parent.VisiblePathMatches()) + { + throw new InvalidOperationException( + "A planned relocation directory lost its parent-generation authorization."); + } + } + + private void CaptureCreatedReservation( + RootFolderRelocationCreatedDirectory reservation, + PinnedDirectoryCreation.PinnedDirectoryAnchor directory) + { + if (!directory.VisiblePathMatches()) + { + throw new InvalidOperationException( + "A relocation-created directory changed before enrollment."); + } + + reservation.State = + RootFolderRelocationCreatedDirectoryState.Created; + reservation.DirectoryObjectIdentityVersion = + ManagedDirectoryIdentity.CurrentVersion; + reservation.DirectoryObjectIdentity = + ManagedDirectoryIdentity.Create( + reservation.OwnershipToken, + directory.GetDirectoryObjectIdentity()); + reservation.UpdatedAt = + timeProvider.GetUtcNow().UtcDateTime; + } + + private void RetainObservedReservation( + RootFolderRelocationCreatedDirectory reservation, + PinnedDirectoryCreation.PinnedDirectoryAnchor directory) + { + if (!directory.VisiblePathMatches()) + { + throw new InvalidOperationException( + "An observed relocation directory changed before retention."); + } + + reservation.State = + RootFolderRelocationCreatedDirectoryState.Retained; + reservation.DirectoryObjectIdentityVersion = + ManagedDirectoryIdentity.CurrentVersion; + reservation.DirectoryObjectIdentity = + ManagedDirectoryIdentity.Create( + reservation.OwnershipToken, + directory.GetDirectoryObjectIdentity()); + reservation.UpdatedAt = + timeProvider.GetUtcNow().UtcDateTime; + } + + private static void ValidateReservationPathIsDirectChild( + RootFolderRelocationCreatedDirectory reservation, + string parentPath) + { + var expectedParent = Path.GetDirectoryName( + RequireHostReservationPath(reservation.CanonicalPath)); + if (string.IsNullOrWhiteSpace(expectedParent) + || !string.Equals( + RequireHostReservationPath(expectedParent), + RequireHostReservationPath(parentPath), + PathComparison)) + { + throw new InvalidOperationException( + "A relocation reservation escaped its persisted parent chain."); + } + } + + private static string RequireHostReservationPath(string path) + { + if (!FileSystemPathIdentity + .TryCanonicalizeUnambiguousStoredAbsolutePathForHost( + path, + out var canonicalPath, + out var reason)) + { + throw new InvalidOperationException(reason); + } + + return canonicalPath; + } +} diff --git a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.TargetReservations.cs b/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.TargetReservations.cs index 0ca2745a7..02bec39c6 100644 --- a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.TargetReservations.cs +++ b/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.TargetReservations.cs @@ -1,5 +1,3 @@ -using System.Text.Json; -using Listenarr.Domain.Common; using Listenarr.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; @@ -11,14 +9,13 @@ public sealed partial class RootFolderRelocationService ".listenarr-relocation-directory.json"; private const string RelocationReservationParentMarkerPrefix = ".listenarr-relocation-parent-"; - private static readonly JsonSerializerOptions ReservationJsonOptions = - new(JsonSerializerDefaults.Web); + internal Action? TargetReservationDirectoryFlushedForTest { get; set; } - internal Action? AfterReservationParentMarkerPublishedForTest + internal Action? AfterReservationParentIntentPersistedForTest { get; set; @@ -33,6 +30,11 @@ internal Action? AfterReservationMarkerRetiredForTest get; set; } + internal Action? AfterTargetReservationStatePersistedForTest + { + get; + set; + } private async Task ReconcileRelocationTargetReservationsAsync( Guid relocationId, @@ -63,20 +65,18 @@ private async Task ReconcileRelocationTargetReservationsAsync( PinnedDirectoryCreation.OpenPinnedHierarchyNoFollow( parentPath, createMissing: false); + ValidateReservationPathIsDirectChild( + reservation, + parent.FullPath); using var publication = parent.TryOpenExistingChildForPublication( Path.GetFileName(canonicalPath)); if (publication == null) { - if (reservation.State == - RootFolderRelocationCreatedDirectoryState.Planned) - { - RetireReservationParentMarker( - relocationId, - reservation, - parent); - } - + RetireReservationParentMarker( + relocationId, + reservation, + parent); reservation.State = RootFolderRelocationCreatedDirectoryState.Removed; reservation.UpdatedAt = @@ -85,67 +85,44 @@ private async Task ReconcileRelocationTargetReservationsAsync( continue; } - using var directory = - publication.OpenCreatedDirectoryAnchor(); + using var directory = publication.OpenCreatedDirectoryAnchor(); if (reservation.State == RootFolderRelocationCreatedDirectoryState.Planned) { - if (!TryEnrollPublishedPlannedReservation( + ValidatePlannedReservationParent( + reservation, + parent); + if (!TryValidateLegacyReservationMarkers( relocationId, reservation, parent, directory)) { - reservation.State = - RootFolderRelocationCreatedDirectoryState.Retained; - reservation.UpdatedAt = - timeProvider.GetUtcNow().UtcDateTime; + RetainObservedReservation( + reservation, + directory); await db.SaveChangesAsync(cancellationToken); continue; } - var nativeIdentity = directory.GetDirectoryObjectIdentity(); - reservation.State = - RootFolderRelocationCreatedDirectoryState.Created; - reservation.DirectoryObjectIdentityVersion = - ManagedDirectoryIdentity.CurrentVersion; - reservation.DirectoryObjectIdentity = - ManagedDirectoryIdentity.Create( - reservation.OwnershipToken, - nativeIdentity); - reservation.UpdatedAt = - timeProvider.GetUtcNow().UtcDateTime; - await db.SaveChangesAsync(cancellationToken); - RetireReservationParentMarker( - relocationId, + CaptureCreatedReservation( reservation, - parent); + directory); + await db.SaveChangesAsync(cancellationToken); } else { - RetireReservationParentMarker( - relocationId, + ValidateReservationDirectoryIdentity( reservation, - parent); + directory); } - ValidateReservationDirectory( + RetireLegacyReservationMarkers( relocationId, reservation, + parent, directory); - ManagedDirectoryEnrollment.RetireValidMarker(directory); - var entries = Directory.EnumerateFileSystemEntries( - canonicalPath) - .Take(2) - .ToList(); - var markerPath = Path.Join( - canonicalPath, - RelocationReservationMarkerName); - if (entries.Count != 1 - || !string.Equals( - entries[0], - markerPath, - PathComparison) + if (Directory.EnumerateFileSystemEntries(canonicalPath).Any() || !directory.VisiblePathMatches() || !parent.VisiblePathMatches()) { @@ -157,37 +134,7 @@ private async Task ReconcileRelocationTargetReservationsAsync( continue; } - using (var marker = directory.OpenExistingFile( - RelocationReservationMarkerName, - requireDeleteAccess: true)) - { - ValidateReservationMarker( - relocationId, - reservation, - marker); - if (!marker.VisiblePathMatches() - || !directory.VisiblePathMatches()) - { - throw new InvalidOperationException( - "A relocation reservation marker changed before cleanup."); - } - - ValidateReservationMarker( - relocationId, - reservation, - marker); - marker.Delete(); - } - - if (Directory.EnumerateFileSystemEntries( - canonicalPath).Any() - || !directory.VisiblePathMatches()) - { - throw new InvalidOperationException( - "A relocation-created directory changed after marker retirement."); - } - - publication.DeletePinnedEmptyDirectory( + publication.RetirePinnedEmptyDirectoryFromNamespace( Path.GetFileName(canonicalPath)); reservation.State = RootFolderRelocationCreatedDirectoryState.Removed; @@ -209,14 +156,9 @@ private async Task FinalizeRelocationTargetReservationsAsync( foreach (var reservation in reservations) { cancellationToken.ThrowIfCancellationRequested(); - if (reservation.State == - RootFolderRelocationCreatedDirectoryState.Retained) - { - continue; - } - - if (reservation.State != - RootFolderRelocationCreatedDirectoryState.Created) + if (reservation.State is not ( + RootFolderRelocationCreatedDirectoryState.Created + or RootFolderRelocationCreatedDirectoryState.Retained)) { throw new InvalidOperationException( "A successful relocation has an incomplete target directory reservation."); @@ -231,40 +173,28 @@ private async Task FinalizeRelocationTargetReservationsAsync( PinnedDirectoryCreation.OpenPinnedHierarchyNoFollow( parentPath, createMissing: false); + ValidateReservationPathIsDirectChild( + reservation, + parent.FullPath); using var publication = parent.TryOpenExistingChildForPublication( Path.GetFileName(canonicalPath)) ?? throw new InvalidOperationException( - "A relocation-created target directory disappeared before finalization."); - using var directory = - publication.OpenCreatedDirectoryAnchor(); + "A relocation target directory disappeared before finalization."); + using var directory = publication.OpenCreatedDirectoryAnchor(); ValidateReservationDirectoryIdentity( reservation, directory); - - using var marker = directory.TryOpenExistingFile( - RelocationReservationMarkerName, - requireDeleteAccess: false); - if (marker != null) - { - ValidateReservationMarker( - relocationId, - reservation, - marker); - if (!marker.VisiblePathMatches() - || !directory.VisiblePathMatches() - || !parent.VisiblePathMatches()) - { - throw new InvalidOperationException( - "A relocation reservation marker changed before finalization."); - } - } - + RetireLegacyReservationMarkers( + relocationId, + reservation, + parent, + directory); if (!directory.VisiblePathMatches() || !parent.VisiblePathMatches()) { throw new InvalidOperationException( - "A relocation-created target directory changed during finalization."); + "A relocation target directory changed during finalization."); } reservation.State = @@ -296,60 +226,40 @@ private async Task foreach (var reservation in reservations) { cancellationToken.ThrowIfCancellationRequested(); - if (reservation.State is - RootFolderRelocationCreatedDirectoryState.Retained - or RootFolderRelocationCreatedDirectoryState.Removed) + if (reservation.State == + RootFolderRelocationCreatedDirectoryState.Removed) { throw new InvalidOperationException( - "A terminal relocation target reservation cannot be reused."); + "A removed relocation target reservation cannot be reused."); } var canonicalPath = RequireHostReservationPath( reservation.CanonicalPath); + ValidateReservationPathIsDirectChild( + reservation, + current.FullPath); var childName = Path.GetFileName(canonicalPath); - var parentIdentity = - current.GetDirectoryObjectIdentity(); + bool childAlreadyExists; + using (var existing = + current.TryOpenExistingChildForPublication(childName)) + { + childAlreadyExists = existing != null; + } if (reservation.State == RootFolderRelocationCreatedDirectoryState.Planned) { - var expectedParentIdentity = ManagedDirectoryIdentity.Create( - reservation.OwnershipToken, - parentIdentity); - if (reservation.DirectoryObjectIdentityVersion == null + if (childAlreadyExists + && reservation.DirectoryObjectIdentityVersion == null && string.IsNullOrWhiteSpace( reservation.DirectoryObjectIdentity)) - { - reservation.DirectoryObjectIdentityVersion = - ManagedDirectoryIdentity.CurrentVersion; - reservation.DirectoryObjectIdentity = - expectedParentIdentity; - reservation.UpdatedAt = - timeProvider.GetUtcNow().UtcDateTime; - await db.SaveChangesAsync(cancellationToken); - } - else if (reservation.DirectoryObjectIdentityVersion - != ManagedDirectoryIdentity.CurrentVersion - || !string.Equals( - reservation.DirectoryObjectIdentity, - expectedParentIdentity, - StringComparison.Ordinal)) { throw new InvalidOperationException( - "The parent of a planned relocation directory was replaced before creation."); - } - - bool childAlreadyExists; - using (var existingChild = - current.TryOpenExistingChildForPublication(childName)) - { - childAlreadyExists = existingChild != null; + "A relocation child appeared before its parent-generation intent was persisted."); } - - await EnsureReservationParentMarkerAsync( - relocationId, + await PersistOrValidatePlannedParentIdentityAsync( + db, reservation, current, - allowPublication: !childAlreadyExists, cancellationToken); } @@ -361,51 +271,69 @@ await EnsureReservationParentMarkerAsync( if (creation.Created) { next = creation.OpenCreatedDirectoryAnchor(); - WriteReservationMarker( - relocationId, + next.FlushDirectoryEntry(); + current.FlushDirectoryEntry(); + TargetReservationDirectoryFlushedForTest?.Invoke( + canonicalPath); + CaptureCreatedReservation( reservation, next); - FlushReservationDirectory(next); - FlushReservationDirectory(current); + await db.SaveChangesAsync(cancellationToken); + AfterTargetReservationStatePersistedForTest?.Invoke( + canonicalPath); } else { next = current.OpenExistingChild(childName); - ValidateReservationMarker( - relocationId, - reservation, - next); + if (reservation.State == + RootFolderRelocationCreatedDirectoryState.Planned) + { + if (TryValidateLegacyReservationMarkers( + relocationId, + reservation, + current, + next)) + { + CaptureCreatedReservation( + reservation, + next); + } + else + { + if (Directory.EnumerateFileSystemEntries( + canonicalPath).Any()) + { + throw new InvalidOperationException( + "An unproven relocation target directory contains content."); + } + RetainObservedReservation( + reservation, + next); + } + await db.SaveChangesAsync(cancellationToken); + AfterTargetReservationStatePersistedForTest?.Invoke( + canonicalPath); + } + else + { + ValidateReservationDirectoryIdentity( + reservation, + next); + } } - var liveIdentity = - next.GetDirectoryObjectIdentity(); - if (reservation.State == - RootFolderRelocationCreatedDirectoryState.Created - && !ManagedDirectoryIdentity.Matches( - reservation.DirectoryObjectIdentityVersion, - reservation.DirectoryObjectIdentity, - reservation.OwnershipToken, - liveIdentity)) + RetireLegacyReservationMarkers( + relocationId, + reservation, + current, + next); + if (!next.VisiblePathMatches() + || !current.VisiblePathMatches()) { throw new InvalidOperationException( - "A relocation-created directory was replaced after enrollment."); + "A relocation target reservation changed before use."); } - reservation.State = - RootFolderRelocationCreatedDirectoryState.Created; - reservation.DirectoryObjectIdentityVersion = - ManagedDirectoryIdentity.CurrentVersion; - reservation.DirectoryObjectIdentity = - ManagedDirectoryIdentity.Create( - reservation.OwnershipToken, - liveIdentity); - reservation.UpdatedAt = - timeProvider.GetUtcNow().UtcDateTime; - await db.SaveChangesAsync(cancellationToken); - RetireReservationParentMarker( - relocationId, - reservation, - current); current.Dispose(); current = next; next = null; @@ -422,12 +350,12 @@ await EnsureReservationParentMarkerAsync( "The reserved relocation target changed before use."); } - var finalNativeIdentity = current.GetDirectoryObjectIdentity(); - return await ManagedDirectoryEnrollment.ResolveAsync( - current, - finalNativeIdentity, - enrollIfMissing: true, - cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + return new DirectoryObjectIdentityResolution( + ManagedDirectoryIdentity.CurrentVersion, + ManagedDirectoryIdentity.CreateMarkerless( + current.GetDirectoryObjectIdentity()), + null); } finally { @@ -435,19 +363,6 @@ await EnsureReservationParentMarkerAsync( } } - private static string RequireHostReservationPath(string path) - { - if (!FileSystemPathIdentity.TryCanonicalizeUnambiguousStoredAbsolutePathForHost( - path, - out var canonicalPath, - out var reason)) - { - throw new InvalidOperationException(reason); - } - - return canonicalPath; - } - private async Task MarkPrecommittedRelocationNeedsAttentionAsync( Guid relocationId, Exception exception, @@ -459,18 +374,11 @@ private async Task MarkPrecommittedRelocationNeedsAttentionAsync( .SingleAsync( candidate => candidate.Id == relocationId, cancellationToken); - if (persisted.Status is - RootFolderRelocationStatus.Pending - or RootFolderRelocationStatus.Running) - { - persisted.Status = - RootFolderRelocationStatus.NeedsAttention; - persisted.Error = - $"Target directory reservation requires attention: {exception.Message}"; - persisted.UpdatedAt = - timeProvider.GetUtcNow().UtcDateTime; - await recoveryDb.SaveChangesAsync(cancellationToken); - } + persisted.Status = RootFolderRelocationStatus.NeedsAttention; + persisted.Error = + $"Relocation target reservation requires attention: {exception.Message}"; + persisted.UpdatedAt = + timeProvider.GetUtcNow().UtcDateTime; + await recoveryDb.SaveChangesAsync(cancellationToken); } - } diff --git a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.cs b/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.cs index d003acae4..177e68213 100644 --- a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.cs +++ b/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.cs @@ -413,6 +413,7 @@ await ResolveOrEnrollDirectoryObjectIdentityAsync( EnqueuedAt = nowUtc, RelocationId = relocation.Id, IdentityKeyVersion = MoveManifestIdentity.Version, + ExecutionProtocolVersion = MoveExecutionProtocol.Current, ActiveDeduplicationKey = MoveManifestIdentity.CreateDeduplicationKey( audiobook.Id, plan.Manifest.SourceRoot, diff --git a/listenarr.infrastructure/Library/Scanning/AudiobookScanService.cs b/listenarr.infrastructure/Library/Scanning/AudiobookScanService.cs index 23bad67c7..f908446b1 100644 --- a/listenarr.infrastructure/Library/Scanning/AudiobookScanService.cs +++ b/listenarr.infrastructure/Library/Scanning/AudiobookScanService.cs @@ -30,7 +30,9 @@ public async Task ScanAsync( var semantics = await ValidateCommandAsync(command, cancellationToken); using var pinnedAuthority = OpenPinnedScanAuthority(command); - var audiobook = await audiobookRepository.GetByIdAsync(command.AudiobookId) + var audiobook = await audiobookRepository.GetForScanAsync( + command.AudiobookId, + cancellationToken) ?? throw new InvalidOperationException( $"Audiobook {command.AudiobookId} no longer exists."); var existingFiles = await fileRepository.GetByAudiobookIdAsync( @@ -154,7 +156,7 @@ public async Task ScanAsync( diagnostics, cancellationToken); - var refreshed = await audiobookRepository.GetByIdSnapshotAsync( + var refreshed = await audiobookRepository.GetForScanSnapshotAsync( audiobook.Id, cancellationToken) ?? throw new InvalidOperationException( diff --git a/listenarr.infrastructure/Library/Scanning/MoveScanHandoffDispatchWorkflow.cs b/listenarr.infrastructure/Library/Scanning/MoveScanHandoffDispatchWorkflow.cs index 41713e3a8..267547c94 100644 --- a/listenarr.infrastructure/Library/Scanning/MoveScanHandoffDispatchWorkflow.cs +++ b/listenarr.infrastructure/Library/Scanning/MoveScanHandoffDispatchWorkflow.cs @@ -9,6 +9,7 @@ internal enum MoveScanDispatchOutcome NotClaimed, Dispatched, Deferred, + Superseded, Failed } @@ -47,23 +48,40 @@ public static async Task TryDispatchPendingAsync( try { - var audiobook = knownAudiobook; - if (audiobook == null) + using var audiobookScope = scopeFactory.CreateScope(); + var audiobookRepository = audiobookScope.ServiceProvider + .GetRequiredService(); + var currentAudiobook = await audiobookRepository.GetPathReferenceSnapshotAsync( + claim.AudiobookId, + cancellationToken); + if (currentAudiobook == null) { - using var scope = scopeFactory.CreateScope(); - var audiobookRepository = scope.ServiceProvider - .GetRequiredService(); - audiobook = await audiobookRepository.GetByIdAsync(claim.AudiobookId); + await handoffStore.CompleteAttemptAsync( + claim.HandoffId, + claim.AttemptGeneration, + scanJobId: null, + MoveScanTerminalOutcome.Failed, + $"Audiobook {claim.AudiobookId} no longer exists.", + found: 0, + created: 0, + scanPath: claim.TargetPath, + timeProvider.GetUtcNow(), + cancellationToken); + return new MoveScanDispatchResult(MoveScanDispatchOutcome.Failed); } - if (audiobook == null) + if (!TryCurrentAudiobookTargetsClaim( + currentAudiobook.BasePath, + claim, + out var targetsClaim, + out var currentPathError)) { await handoffStore.CompleteAttemptAsync( claim.HandoffId, claim.AttemptGeneration, scanJobId: null, MoveScanTerminalOutcome.Failed, - $"Audiobook {claim.AudiobookId} no longer exists.", + currentPathError, found: 0, created: 0, scanPath: claim.TargetPath, @@ -72,6 +90,37 @@ await handoffStore.CompleteAttemptAsync( return new MoveScanDispatchResult(MoveScanDispatchOutcome.Failed); } + if (!targetsClaim) + { + const string supersededReason = + "A newer audiobook destination superseded this move scan handoff."; + await handoffStore.CompleteAttemptAsync( + claim.HandoffId, + claim.AttemptGeneration, + scanJobId: null, + MoveScanTerminalOutcome.Superseded, + supersededReason, + found: 0, + created: 0, + scanPath: claim.TargetPath, + timeProvider.GetUtcNow(), + cancellationToken); + logger.LogInformation( + "Superseded stale move scan handoff {HandoffId} for audiobook {AudiobookId} before filesystem authorization", + claim.HandoffId, + claim.AudiobookId); + return new MoveScanDispatchResult(MoveScanDispatchOutcome.Superseded); + } + + var audiobook = new Audiobook + { + Id = claim.AudiobookId, + Title = knownAudiobook?.Id == claim.AudiobookId + ? knownAudiobook.Title + : string.Empty, + BasePath = currentAudiobook.BasePath + }; + using var authorizationScope = scopeFactory.CreateScope(); var authorizationService = authorizationScope.ServiceProvider .GetRequiredService(); @@ -202,4 +251,44 @@ await handoffStore.ReleaseClaimAsync( return new MoveScanDispatchResult(MoveScanDispatchOutcome.Failed); } } + + private static bool TryCurrentAudiobookTargetsClaim( + string? currentPath, + MoveScanHandoffClaim claim, + out bool targetsClaim, + out string? error) + { + targetsClaim = false; + error = null; + if (string.IsNullOrWhiteSpace(currentPath)) + { + return true; + } + + if (!FileSystemPathIdentity.TryCanonicalizeUnambiguousStoredAbsolutePathForHost( + currentPath, + out var canonicalCurrentPath, + out var pathReason)) + { + error = pathReason + ?? "The audiobook's current path is unavailable on this host."; + return false; + } + + try + { + targetsClaim = FileSystemPathIdentity.AreEquivalent( + canonicalCurrentPath, + claim.TargetPath, + claim.TargetIdentity.Semantics); + return true; + } + catch (Exception exception) when (exception is + ArgumentException or InvalidOperationException + or NotSupportedException or PathTooLongException) + { + error = $"The audiobook's current path could not be compared to the move scan target: {exception.Message}"; + return false; + } + } } diff --git a/listenarr.infrastructure/Library/Scanning/ScanJobProcessor.PathSafety.cs b/listenarr.infrastructure/Library/Scanning/ScanJobProcessor.PathSafety.cs index e435dff24..925fbdcfc 100644 --- a/listenarr.infrastructure/Library/Scanning/ScanJobProcessor.PathSafety.cs +++ b/listenarr.infrastructure/Library/Scanning/ScanJobProcessor.PathSafety.cs @@ -63,7 +63,9 @@ private async Task HandleUnexpectedScanFailureAsync( using var historyScope = _scopeFactory.CreateScope(); var historyRepository = historyScope.ServiceProvider.GetRequiredService(); var audiobookRepository = historyScope.ServiceProvider.GetRequiredService(); - var audiobook = await audiobookRepository.GetByIdAsync(job.AudiobookId); + var audiobook = await audiobookRepository.GetForScanAsync( + job.AudiobookId, + cancellationToken); terminalDecision = await CommitTerminalDecisionAsync( job, commitToken => RecordScanFailureHistoryAsync( diff --git a/listenarr.infrastructure/Library/Scanning/ScanJobProcessor.cs b/listenarr.infrastructure/Library/Scanning/ScanJobProcessor.cs index 4a0b79d94..eb2a98ecc 100644 --- a/listenarr.infrastructure/Library/Scanning/ScanJobProcessor.cs +++ b/listenarr.infrastructure/Library/Scanning/ScanJobProcessor.cs @@ -55,7 +55,9 @@ await _moveQueueService.EnsureFilesystemMutationAllowedAsync( .GetRequiredService(); var historyRepository = scope.ServiceProvider .GetRequiredService(); - var audiobook = await audiobookRepository.GetByIdAsync(job.AudiobookId); + var audiobook = await audiobookRepository.GetForScanAsync( + job.AudiobookId, + stoppingToken); if (audiobook == null) { _logger.LogWarning( diff --git a/listenarr.infrastructure/Library/Scanning/ScanPathAuthorizationService.cs b/listenarr.infrastructure/Library/Scanning/ScanPathAuthorizationService.cs index 2e39409e4..b90f472af 100644 --- a/listenarr.infrastructure/Library/Scanning/ScanPathAuthorizationService.cs +++ b/listenarr.infrastructure/Library/Scanning/ScanPathAuthorizationService.cs @@ -159,9 +159,9 @@ private async Task> LoadAuthorizedRootsAsync( cancellationToken.ThrowIfCancellationRequested(); if (!TryGetStoredFullPath(candidate.Path, out var fullPath)) { - logger.LogWarning( - "Ignoring invalid configured scan root {Path}", - LogRedaction.SanitizeFilePath(candidate.Path)); + LogUnavailableCandidate( + candidate, + "Ignoring invalid configured scan root {Path}"); continue; } @@ -171,9 +171,9 @@ private async Task> LoadAuthorizedRootsAsync( cancellationToken); if (resolution.State != PathIdentityState.Valid) { - logger.LogWarning( + LogUnavailableCandidate( + candidate, "Ignoring configured scan root {Path}: {Reason}", - LogRedaction.SanitizeFilePath(candidate.Path), resolution.Reason); continue; } @@ -183,9 +183,9 @@ private async Task> LoadAuthorizedRootsAsync( resolution.Semantics.Syntax); if (IsFilesystemRoot(canonical, resolution.Semantics)) { - logger.LogWarning( - "Ignoring unsafe filesystem-root scan boundary {Path}", - LogRedaction.SanitizeFilePath(candidate.Path)); + LogUnavailableCandidate( + candidate, + "Ignoring unsafe filesystem-root scan boundary {Path}"); continue; } @@ -224,6 +224,35 @@ private async Task> LoadAuthorizedRootsAsync( return roots; } + private void LogUnavailableCandidate( + RootCandidate candidate, + string message, + string? reason = null) + { + var sanitizedPath = LogRedaction.SanitizeFilePath(candidate.Path); + if (candidate.RequiresEnrollment) + { + if (reason == null) + { + logger.LogWarning(message, sanitizedPath); + } + else + { + logger.LogWarning(message, sanitizedPath, reason); + } + return; + } + + if (reason == null) + { + logger.LogDebug(message, sanitizedPath); + } + else + { + logger.LogDebug(message, sanitizedPath, reason); + } + } + private static async Task TryCapturePhysicalIdentityAsync( AuthorizedRoot authorizedRoot, string scanPath, @@ -239,14 +268,19 @@ private static async Task TryCapturePhysicalIdentityAsy authorizedRoot.Semantics.Syntax); using var boundary = PinnedDirectoryCreation.OpenPinnedBoundary( canonicalBoundary); - var boundaryIdentity = authorizedRoot.RequiresEnrollment - ? await ManagedDirectoryEnrollment.RequireMatchingEnrollmentAsync( - boundary, - authorizedRoot.DirectoryObjectIdentityVersion, - authorizedRoot.DirectoryObjectIdentity, - authorizedRoot.DirectoryObjectIdentityUnavailableReason, - cancellationToken) - : boundary.GetDirectoryObjectIdentity(); + cancellationToken.ThrowIfCancellationRequested(); + var boundaryIdentity = boundary.GetDirectoryObjectIdentity(); + if (authorizedRoot.RequiresEnrollment + && (!string.IsNullOrWhiteSpace( + authorizedRoot.DirectoryObjectIdentityUnavailableReason) + || !ManagedDirectoryIdentity.MatchesNativeIdentity( + authorizedRoot.DirectoryObjectIdentityVersion, + authorizedRoot.DirectoryObjectIdentity, + boundaryIdentity))) + { + throw new InvalidOperationException( + "The configured scan root no longer identifies its authorized physical generation."); + } using var scanRoot = OpenRelativeScanRoot( boundary, canonicalBoundary, diff --git a/listenarr.infrastructure/Persistence/Configurations/FileMutationJournalConfiguration.cs b/listenarr.infrastructure/Persistence/Configurations/FileMutationJournalConfiguration.cs new file mode 100644 index 000000000..d6d0dfb4d --- /dev/null +++ b/listenarr.infrastructure/Persistence/Configurations/FileMutationJournalConfiguration.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Listenarr.Infrastructure.Persistence.Configurations; + +public sealed class FileMutationJournalConfiguration : + IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("FileMutationJournals"); + builder.Property(journal => journal.ProtocolVersion) + .HasDefaultValue(FileMutationProtocol.MarkerlessDatabaseState); + builder.Property(journal => journal.Action) + .HasConversion() + .HasMaxLength(24); + builder.Property(journal => journal.State) + .HasConversion() + .HasMaxLength(32); + builder.Property(journal => journal.SourcePath).HasMaxLength(4096); + builder.Property(journal => journal.DestinationPath).HasMaxLength(4096); + builder.Property(journal => journal.SourcePhysicalObjectIdentity) + .HasMaxLength(512); + builder.Property(journal => journal.TargetPhysicalObjectIdentity) + .HasMaxLength(512); + builder.Property(journal => journal.SourceSha256).HasMaxLength(64); + builder.Property(journal => journal.Error).HasMaxLength(2048); + builder.HasIndex(journal => journal.State); + builder.HasIndex(journal => journal.UpdatedAt); + } +} diff --git a/listenarr.infrastructure/Persistence/Configurations/MoveJobConfiguration.cs b/listenarr.infrastructure/Persistence/Configurations/MoveJobConfiguration.cs index d91f8d9c0..d0a83f8a3 100644 --- a/listenarr.infrastructure/Persistence/Configurations/MoveJobConfiguration.cs +++ b/listenarr.infrastructure/Persistence/Configurations/MoveJobConfiguration.cs @@ -20,6 +20,14 @@ public void Configure(EntityTypeBuilder builder) { builder.Property(job => job.Status).HasConversion().HasMaxLength(32); builder.Property(job => job.Phase).HasConversion().HasMaxLength(32); + builder.Property(job => job.ExecutionProtocolVersion) + .HasDefaultValue(MoveExecutionProtocol.LegacyFilesystemArtifacts); + builder.Property(job => job.SourceDirectoryObjectIdentity).HasMaxLength(512); + builder.Property(job => job.TargetDirectoryObjectIdentity).HasMaxLength(512); + builder.Property(job => job.SourceDirectoryCleanupState) + .HasConversion() + .HasMaxLength(24) + .HasDefaultValue(MoveJobEntryCleanupState.Pending); builder.Property(job => job.FailureKind).HasConversion().HasMaxLength(32); builder.Property(job => job.ActiveDeduplicationKey).HasMaxLength(1024); builder.Property(job => job.SourcePathSyntax).HasConversion().HasMaxLength(16); @@ -160,6 +168,8 @@ public void Configure(EntityTypeBuilder builder) builder.Property(entry => entry.CopyState).HasConversion().HasMaxLength(16); builder.Property(entry => entry.CleanupState).HasConversion().HasMaxLength(16); builder.Property(entry => entry.CleanupProtectionVersion).HasDefaultValue(0); + builder.Property(entry => entry.SourcePhysicalObjectIdentity).HasMaxLength(512); + builder.Property(entry => entry.TargetPhysicalObjectIdentity).HasMaxLength(512); builder.HasIndex(entry => new { entry.MoveJobId, entry.RelativePath }).IsUnique(); } } diff --git a/listenarr.infrastructure/Persistence/Configurations/MoveScanHandoffConfiguration.cs b/listenarr.infrastructure/Persistence/Configurations/MoveScanHandoffConfiguration.cs index 0981643de..1a445f89f 100644 --- a/listenarr.infrastructure/Persistence/Configurations/MoveScanHandoffConfiguration.cs +++ b/listenarr.infrastructure/Persistence/Configurations/MoveScanHandoffConfiguration.cs @@ -33,6 +33,7 @@ public void Configure(EntityTypeBuilder builder) builder.ToTable("MoveJobCreatedDirectories"); builder.Property(directory => directory.Path).HasMaxLength(2000); builder.Property(directory => directory.State).HasConversion().HasMaxLength(16); + builder.Property(directory => directory.DirectoryObjectIdentity).HasMaxLength(512); builder.HasIndex(directory => new { directory.MoveJobId, directory.Path }).IsUnique(); builder.HasOne(directory => directory.MoveJob) .WithMany(job => job.CreatedDirectories) diff --git a/listenarr.infrastructure/Persistence/ListenArrDbContext.cs b/listenarr.infrastructure/Persistence/ListenArrDbContext.cs index c00334139..542e6481f 100644 --- a/listenarr.infrastructure/Persistence/ListenArrDbContext.cs +++ b/listenarr.infrastructure/Persistence/ListenArrDbContext.cs @@ -41,6 +41,7 @@ public class ListenArrDbContext : DbContext public DbSet Users { get; set; } = null!; public DbSet Downloads { get; set; } = null!; public DbSet DownloadProcessingJobs { get; set; } = null!; + public DbSet FileMutationJournals { get; set; } = null!; public DbSet DownloadHistories { get; set; } = null!; public DbSet QualityProfiles { get; set; } = null!; public DbSet RemotePathMappings { get; set; } = null!; diff --git a/listenarr.infrastructure/Persistence/Migrations/20260805192525_AddMarkerlessMoveExecutionState.Designer.cs b/listenarr.infrastructure/Persistence/Migrations/20260805192525_AddMarkerlessMoveExecutionState.Designer.cs new file mode 100644 index 000000000..f3bf1ae5d --- /dev/null +++ b/listenarr.infrastructure/Persistence/Migrations/20260805192525_AddMarkerlessMoveExecutionState.Designer.cs @@ -0,0 +1,2526 @@ +// +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("20260805192525_AddMarkerlessMoveExecutionState")] + partial class AddMarkerlessMoveExecutionState + { + /// + 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.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() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PathCaseSensitivityMode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + 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() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PathIdentityVersion") + .HasColumnType("INTEGER"); + + 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("State") + .IsRequired() + .HasMaxLength(24) + .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.LibraryDirectoryOwnershipRetiredMarker", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CanonicalMarkerPath") + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("CanonicalOwnershipPath") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("CanonicalPayload") + .HasMaxLength(16384) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DirectoryObjectIdentity") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("DirectoryObjectIdentityVersion") + .HasColumnType("INTEGER"); + + b.Property("OriginalManagedRootFolderId") + .HasColumnType("INTEGER"); + + b.Property("OwnershipId") + .HasColumnType("INTEGER"); + + b.Property("OwnershipToken") + .IsRequired() + .HasMaxLength(64) + .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("PathSyntax") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PayloadSha256") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("PayloadVersion") + .HasColumnType("INTEGER"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CanonicalMarkerPath") + .IsUnique(); + + b.HasIndex("OwnershipId") + .IsUnique(); + + b.ToTable("LibraryDirectoryOwnershipRetiredMarkers", (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(1); + + b.Property("FailureKind") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + 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() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + 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("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("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("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() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + 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() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("ResolvedCaseSensitivity") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + 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.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.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.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.LibraryDirectoryOwnershipRetiredMarker", b => + { + b.HasOne("Listenarr.Domain.Audiobooks.LibraryDirectoryOwnership", "Ownership") + .WithOne("RetiredMarker") + .HasForeignKey("Listenarr.Domain.Audiobooks.LibraryDirectoryOwnershipRetiredMarker", "OwnershipId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Ownership"); + }); + + 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"); + + b.Navigation("RetiredMarker"); + }); + + 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/20260805192525_AddMarkerlessMoveExecutionState.cs b/listenarr.infrastructure/Persistence/Migrations/20260805192525_AddMarkerlessMoveExecutionState.cs new file mode 100644 index 000000000..b6fa2d075 --- /dev/null +++ b/listenarr.infrastructure/Persistence/Migrations/20260805192525_AddMarkerlessMoveExecutionState.cs @@ -0,0 +1,96 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Listenarr.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddMarkerlessMoveExecutionState : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ExecutionProtocolVersion", + table: "MoveJobs", + type: "INTEGER", + nullable: false, + defaultValue: 1); + + migrationBuilder.AddColumn( + name: "SourceDirectoryCleanupState", + table: "MoveJobs", + type: "TEXT", + maxLength: 24, + nullable: false, + defaultValue: "Pending"); + + migrationBuilder.AddColumn( + name: "SourceDirectoryObjectIdentity", + table: "MoveJobs", + type: "TEXT", + maxLength: 512, + nullable: true); + + migrationBuilder.AddColumn( + name: "TargetDirectoryObjectIdentity", + table: "MoveJobs", + type: "TEXT", + maxLength: 512, + nullable: true); + + migrationBuilder.AddColumn( + name: "SourcePhysicalObjectIdentity", + table: "MoveJobEntries", + type: "TEXT", + maxLength: 512, + nullable: true); + + migrationBuilder.AddColumn( + name: "TargetPhysicalObjectIdentity", + table: "MoveJobEntries", + type: "TEXT", + maxLength: 512, + nullable: true); + + migrationBuilder.AddColumn( + name: "DirectoryObjectIdentity", + table: "MoveJobCreatedDirectories", + type: "TEXT", + maxLength: 512, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ExecutionProtocolVersion", + table: "MoveJobs"); + + migrationBuilder.DropColumn( + name: "SourceDirectoryCleanupState", + table: "MoveJobs"); + + migrationBuilder.DropColumn( + name: "SourceDirectoryObjectIdentity", + table: "MoveJobs"); + + migrationBuilder.DropColumn( + name: "TargetDirectoryObjectIdentity", + table: "MoveJobs"); + + migrationBuilder.DropColumn( + name: "SourcePhysicalObjectIdentity", + table: "MoveJobEntries"); + + migrationBuilder.DropColumn( + name: "TargetPhysicalObjectIdentity", + table: "MoveJobEntries"); + + migrationBuilder.DropColumn( + name: "DirectoryObjectIdentity", + table: "MoveJobCreatedDirectories"); + } + } +} diff --git a/listenarr.infrastructure/Persistence/Migrations/20260805202154_AddMarkerlessFileMutationJournal.Designer.cs b/listenarr.infrastructure/Persistence/Migrations/20260805202154_AddMarkerlessFileMutationJournal.Designer.cs new file mode 100644 index 000000000..392bee746 --- /dev/null +++ b/listenarr.infrastructure/Persistence/Migrations/20260805202154_AddMarkerlessFileMutationJournal.Designer.cs @@ -0,0 +1,2595 @@ +// +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("20260805202154_AddMarkerlessFileMutationJournal")] + partial class AddMarkerlessFileMutationJournal + { + /// + 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.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() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PathCaseSensitivityMode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + 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() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PathIdentityVersion") + .HasColumnType("INTEGER"); + + 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("State") + .IsRequired() + .HasMaxLength(24) + .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.LibraryDirectoryOwnershipRetiredMarker", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CanonicalMarkerPath") + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("CanonicalOwnershipPath") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("CanonicalPayload") + .HasMaxLength(16384) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DirectoryObjectIdentity") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("DirectoryObjectIdentityVersion") + .HasColumnType("INTEGER"); + + b.Property("OriginalManagedRootFolderId") + .HasColumnType("INTEGER"); + + b.Property("OwnershipId") + .HasColumnType("INTEGER"); + + b.Property("OwnershipToken") + .IsRequired() + .HasMaxLength(64) + .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("PathSyntax") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PayloadSha256") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("PayloadVersion") + .HasColumnType("INTEGER"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CanonicalMarkerPath") + .IsUnique(); + + b.HasIndex("OwnershipId") + .IsUnique(); + + b.ToTable("LibraryDirectoryOwnershipRetiredMarkers", (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(1); + + b.Property("FailureKind") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + 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() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + 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("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("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("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() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + 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() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("ResolvedCaseSensitivity") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + 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.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.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("AudiobookId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DestinationPath") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("Error") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("ProtocolVersion") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(2); + + b.Property("SourceLength") + .HasColumnType("INTEGER"); + + 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.LibraryDirectoryOwnershipRetiredMarker", b => + { + b.HasOne("Listenarr.Domain.Audiobooks.LibraryDirectoryOwnership", "Ownership") + .WithOne("RetiredMarker") + .HasForeignKey("Listenarr.Domain.Audiobooks.LibraryDirectoryOwnershipRetiredMarker", "OwnershipId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Ownership"); + }); + + 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"); + + b.Navigation("RetiredMarker"); + }); + + 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/20260805202154_AddMarkerlessFileMutationJournal.cs b/listenarr.infrastructure/Persistence/Migrations/20260805202154_AddMarkerlessFileMutationJournal.cs new file mode 100644 index 000000000..cfad1fe52 --- /dev/null +++ b/listenarr.infrastructure/Persistence/Migrations/20260805202154_AddMarkerlessFileMutationJournal.cs @@ -0,0 +1,56 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Listenarr.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddMarkerlessFileMutationJournal : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "FileMutationJournals", + columns: table => new + { + OperationId = table.Column(type: "TEXT", nullable: false), + ProtocolVersion = table.Column(type: "INTEGER", nullable: false, defaultValue: 2), + Action = table.Column(type: "TEXT", maxLength: 24, nullable: false), + SourcePath = table.Column(type: "TEXT", maxLength: 4096, nullable: false), + DestinationPath = table.Column(type: "TEXT", maxLength: 4096, nullable: false), + SourcePhysicalObjectIdentity = table.Column(type: "TEXT", maxLength: 512, nullable: false), + TargetPhysicalObjectIdentity = table.Column(type: "TEXT", maxLength: 512, nullable: true), + SourceLength = table.Column(type: "INTEGER", nullable: false), + SourceSha256 = table.Column(type: "TEXT", maxLength: 64, nullable: true), + State = table.Column(type: "TEXT", maxLength: 32, nullable: false), + AudiobookId = table.Column(type: "INTEGER", nullable: true), + 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_FileMutationJournals", x => x.OperationId); + }); + + migrationBuilder.CreateIndex( + name: "IX_FileMutationJournals_State", + table: "FileMutationJournals", + column: "State"); + + migrationBuilder.CreateIndex( + name: "IX_FileMutationJournals_UpdatedAt", + table: "FileMutationJournals", + column: "UpdatedAt"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "FileMutationJournals"); + } + } +} diff --git a/listenarr.infrastructure/Persistence/Migrations/ListenArrDbContextModelSnapshot.cs b/listenarr.infrastructure/Persistence/Migrations/ListenArrDbContextModelSnapshot.cs index 1447b118b..9f873e84f 100644 --- a/listenarr.infrastructure/Persistence/Migrations/ListenArrDbContextModelSnapshot.cs +++ b/listenarr.infrastructure/Persistence/Migrations/ListenArrDbContextModelSnapshot.cs @@ -1022,6 +1022,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Error") .HasColumnType("TEXT"); + b.Property("ExecutionProtocolVersion") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(1); + b.Property("FailureKind") .IsRequired() .HasMaxLength(32) @@ -1068,6 +1073,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(2000) .HasColumnType("TEXT"); + 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"); @@ -1092,6 +1108,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(16) .HasColumnType("TEXT"); + b.Property("TargetDirectoryObjectIdentity") + .HasMaxLength(512) + .HasColumnType("TEXT"); + b.Property("TargetIdentityBoundary") .HasMaxLength(2000) .HasColumnType("TEXT"); @@ -1124,6 +1144,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .ValueGeneratedOnAdd() .HasColumnType("INTEGER"); + b.Property("DirectoryObjectIdentity") + .HasMaxLength(512) + .HasColumnType("TEXT"); + b.Property("MoveJobId") .HasColumnType("TEXT"); @@ -1189,6 +1213,14 @@ protected override void BuildModel(ModelBuilder modelBuilder) .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") @@ -2105,6 +2137,75 @@ protected override void BuildModel(ModelBuilder modelBuilder) 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("AudiobookId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DestinationPath") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("Error") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("ProtocolVersion") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(2); + + b.Property("SourceLength") + .HasColumnType("INTEGER"); + + 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") diff --git a/listenarr.infrastructure/Persistence/Repositories/AudiobookRepository.cs b/listenarr.infrastructure/Persistence/Repositories/AudiobookRepository.cs index 483a4b747..35df0fc60 100644 --- a/listenarr.infrastructure/Persistence/Repositories/AudiobookRepository.cs +++ b/listenarr.infrastructure/Persistence/Repositories/AudiobookRepository.cs @@ -36,6 +36,30 @@ public async Task> GetAllAsync() .ToListAsync(); } + public Task GetPathReferenceSnapshotAsync( + int audiobookId, + CancellationToken ct = default) => + _db.Audiobooks + .AsNoTracking() + .Where(audiobook => audiobook.Id == audiobookId) + .Select(audiobook => new AudiobookPathReferenceSnapshot( + audiobook.Id, + audiobook.BasePath, + audiobook.FilePath)) + .SingleOrDefaultAsync(ct); + + public Task> GetOtherPathReferenceSnapshotsAsync( + int audiobookId, + CancellationToken ct = default) => + _db.Audiobooks + .AsNoTracking() + .Where(audiobook => audiobook.Id != audiobookId) + .Select(audiobook => new AudiobookPathReferenceSnapshot( + audiobook.Id, + audiobook.BasePath, + audiobook.FilePath)) + .ToListAsync(ct); + public async Task> GetLibraryAsync() { return await _db.Audiobooks @@ -114,6 +138,18 @@ public async Task>> GetAllSeries .FirstOrDefaultAsync(a => a.Id == id, ct); } + public Task GetForScanAsync( + int id, + CancellationToken ct = default) => + _db.Audiobooks.FirstOrDefaultAsync(a => a.Id == id, ct); + + public Task GetForScanSnapshotAsync( + int id, + CancellationToken ct = default) => + _db.Audiobooks + .AsNoTracking() + .FirstOrDefaultAsync(a => a.Id == id, ct); + public async Task> GetByIdsWithFilesAsync(IEnumerable ids, System.Threading.CancellationToken ct = default) { var idSet = ids.ToHashSet(); diff --git a/listenarr.infrastructure/Persistence/Repositories/EfAudiobookFileRepository.cs b/listenarr.infrastructure/Persistence/Repositories/EfAudiobookFileRepository.cs index 3aa697c9f..56ae1db54 100644 --- a/listenarr.infrastructure/Persistence/Repositories/EfAudiobookFileRepository.cs +++ b/listenarr.infrastructure/Persistence/Repositories/EfAudiobookFileRepository.cs @@ -248,6 +248,18 @@ public async Task> GetAllAsync(CancellationToken ct = defaul .ToListAsync(ct); } + public Task> GetOtherPathReferenceSnapshotsAsync( + int audiobookId, + CancellationToken ct = default) => + _db.AudiobookFiles + .AsNoTracking() + .Where(file => file.AudiobookId != audiobookId) + .Where(file => file.Path != null) + .Select(file => new AudiobookFilePathReferenceSnapshot( + file.AudiobookId, + file.Path)) + .ToListAsync(ct); + public async Task> GetFormatSummariesAsync(CancellationToken ct = default) { var rows = await _db.AudiobookFiles diff --git a/listenarr.infrastructure/Persistence/RootFolderObjectIdentityReconciler.cs b/listenarr.infrastructure/Persistence/RootFolderObjectIdentityReconciler.cs index 5b584a4bf..0e15c386f 100644 --- a/listenarr.infrastructure/Persistence/RootFolderObjectIdentityReconciler.cs +++ b/listenarr.infrastructure/Persistence/RootFolderObjectIdentityReconciler.cs @@ -56,6 +56,8 @@ private async Task ReconcileCoreAsync(CancellationToken cancellationToken) { current = await identityResolver.ResolveExistingAsync( canonicalRootPath, + root.DirectoryObjectIdentityVersion.Value, + root.DirectoryObjectIdentity, cancellationToken); } else @@ -93,6 +95,10 @@ private async Task ReconcileCoreAsync(CancellationToken cancellationToken) root.DirectoryObjectIdentityVersion = current.Version; root.DirectoryObjectIdentity = current.Value; root.DirectoryObjectIdentityUnavailableReason = null; + TryRetireLegacyRootEnrollmentMarker( + canonicalRootPath, + root, + logger); } var relocations = await db.RootFolderRelocations @@ -105,4 +111,34 @@ private async Task ReconcileCoreAsync(CancellationToken cancellationToken) await db.SaveChangesAsync(cancellationToken); } + + private static void TryRetireLegacyRootEnrollmentMarker( + string rootPath, + RootFolder root, + ILogger logger) + { + try + { + using var anchor = PinnedDirectoryCreation.OpenPinnedBoundary(rootPath); + if (ManagedDirectoryEnrollment.TryRetireMatchingLegacyMarker( + anchor, + root.DirectoryObjectIdentityVersion, + root.DirectoryObjectIdentity)) + { + logger.LogInformation( + "Retired obsolete filesystem enrollment marker for root folder {RootFolderId}; physical identity is now database-only.", + root.Id); + } + } + catch (Exception exception) when (exception is + IOException or UnauthorizedAccessException + or InvalidOperationException or NotSupportedException + or System.ComponentModel.Win32Exception) + { + logger.LogWarning( + exception, + "Could not retire obsolete filesystem enrollment marker for root folder {RootFolderId}; the marker is not used for authorization and was preserved.", + root.Id); + } + } } diff --git a/scripts/run-native-backend-tests.ps1 b/scripts/run-native-backend-tests.ps1 index 72525cf47..73328b1e0 100644 --- a/scripts/run-native-backend-tests.ps1 +++ b/scripts/run-native-backend-tests.ps1 @@ -14,18 +14,83 @@ Write-Host "Runner OS: $env:RUNNER_OS" Write-Host "Runner architecture: $env:RUNNER_ARCH" Write-Host "Runner image: $env:ImageOS $env:ImageVersion" -$preflightFilter = 'FullyQualifiedName=Listenarr.Tests.Features.Architecture.NativeTestCapabilityContractTests.RequiredNativeTestCapabilities_AreAvailable' -& dotnet test tests/Listenarr.Tests.csproj ` - -c Release ` - --no-build ` - --filter $preflightFilter ` - --logger 'console;verbosity=normal' -if ($LASTEXITCODE -ne 0) { - exit $LASTEXITCODE +$readOnlySourceRoot = $null +$readOnlyMountRoot = $null +$readOnlyMountActive = $false +$exitCode = 0 + +try { + if ($IsLinux) { + $mountId = [Guid]::NewGuid().ToString('N') + $readOnlySourceRoot = Join-Path ([IO.Path]::GetTempPath()) "listenarr-readonly-source-$mountId" + $readOnlyMountRoot = Join-Path ([IO.Path]::GetTempPath()) "listenarr-readonly-mount-$mountId" + $bookDirectory = Join-Path $readOnlySourceRoot 'Author/Book B012345678' + New-Item -ItemType Directory -Path $bookDirectory -Force | Out-Null + New-Item -ItemType Directory -Path $readOnlyMountRoot -Force | Out-Null + [IO.File]::WriteAllText( + (Join-Path $bookDirectory '01.m4b'), + 'audio') + + & sudo mount --bind $readOnlySourceRoot $readOnlyMountRoot + if ($LASTEXITCODE -ne 0) { + throw "Could not create the native read-only validation bind mount." + } + $readOnlyMountActive = $true + + & sudo mount -o remount,bind,ro $readOnlySourceRoot $readOnlyMountRoot + if ($LASTEXITCODE -ne 0) { + throw "Could not remount the native validation bind mount read-only." + } + + $mountOptions = (& findmnt -no OPTIONS --target $readOnlyMountRoot | Out-String).Trim() + if (($mountOptions -split ',') -notcontains 'ro') { + throw "Expected a read-only bind mount, got: $mountOptions" + } + + $env:LISTENARR_READONLY_LIBRARY_PATH = $readOnlyMountRoot + Write-Host "Read-only scan validation mount: $readOnlyMountRoot" + } + + $preflightFilter = 'FullyQualifiedName=Listenarr.Tests.Features.Architecture.NativeTestCapabilityContractTests.RequiredNativeTestCapabilities_AreAvailable' + & dotnet test tests/Listenarr.Tests.csproj ` + -c Release ` + --no-build ` + --filter $preflightFilter ` + --logger 'console;verbosity=normal' + $exitCode = $LASTEXITCODE + + if ($exitCode -eq 0) { + & dotnet test listenarr.slnx ` + -c Release ` + --no-build ` + --logger 'console;verbosity=normal' + $exitCode = $LASTEXITCODE + } + + if ($exitCode -eq 0 -and $readOnlySourceRoot) { + $artifacts = @( + Get-ChildItem -LiteralPath $readOnlySourceRoot -Force -Recurse | + Where-Object { $_.Name.StartsWith('.listenarr', [StringComparison]::OrdinalIgnoreCase) }) + if ($artifacts.Count -gt 0) { + Write-Error "Read-only scan validation found Listenarr filesystem artifacts: $($artifacts.FullName -join ', ')" + $exitCode = 1 + } + } +} +finally { + Remove-Item Env:LISTENARR_READONLY_LIBRARY_PATH -ErrorAction SilentlyContinue + if ($readOnlyMountActive -and $readOnlyMountRoot) { + & sudo umount $readOnlyMountRoot + if ($LASTEXITCODE -ne 0) { + Write-Warning "Could not unmount read-only validation path $readOnlyMountRoot." + } + } + if ($readOnlyMountRoot) { + Remove-Item -LiteralPath $readOnlyMountRoot -Recurse -Force -ErrorAction SilentlyContinue + } + if ($readOnlySourceRoot) { + Remove-Item -LiteralPath $readOnlySourceRoot -Recurse -Force -ErrorAction SilentlyContinue + } } -& dotnet test listenarr.slnx ` - -c Release ` - --no-build ` - --logger 'console;verbosity=normal' -exit $LASTEXITCODE +exit $exitCode diff --git a/tests/Common/PlatformFactAttributes.cs b/tests/Common/PlatformFactAttributes.cs index 3ccbe34d1..87109b060 100644 --- a/tests/Common/PlatformFactAttributes.cs +++ b/tests/Common/PlatformFactAttributes.cs @@ -44,6 +44,27 @@ public LinuxTheoryAttribute() } } +public sealed class ReadOnlyBindMountFactAttribute : FactAttribute +{ + public const string LibraryPathEnvironmentVariable = + "LISTENARR_READONLY_LIBRARY_PATH"; + + public ReadOnlyBindMountFactAttribute() + { + if (!OperatingSystem.IsLinux()) + { + Skip = "This test requires a native Linux read-only bind mount."; + return; + } + + if (string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable( + LibraryPathEnvironmentVariable))) + { + Skip = "The native test runner did not provide a read-only library bind mount."; + } + } +} + public sealed class DirectoryLinkFactAttribute : FactAttribute { public DirectoryLinkFactAttribute() diff --git a/tests/Features/Api/Features/Downloads/ManualImportMarkerlessRegistrationTests.cs b/tests/Features/Api/Features/Downloads/ManualImportMarkerlessRegistrationTests.cs new file mode 100644 index 000000000..2b3dc6db5 --- /dev/null +++ b/tests/Features/Api/Features/Downloads/ManualImportMarkerlessRegistrationTests.cs @@ -0,0 +1,109 @@ +using Listenarr.Api.Dtos.ManualImport; +using Listenarr.Tests.Common; +using Microsoft.EntityFrameworkCore; + +namespace Listenarr.Tests.Features.Api.Features.Downloads; + +[Trait("Name", "ManualImportMarkerlessRegistrationTests")] +[Trait("Category", "Api")] +public sealed class ManualImportMarkerlessRegistrationTests : BaseTests +{ + public ManualImportMarkerlessRegistrationTests() + { + var metadata = new Mock(); + metadata.Setup(service => service.ExtractFileMetadataAsync( + It.IsAny())) + .ReturnsAsync(new AudioMetadata + { + Title = "Manual Markerless", + Format = "mp3", + BitRate = 128000 + }); + Init(builder => builder.WithSingleton(metadata.Object)); + } + + [Fact] + public async Task Start_Move_UsesMarkerlessRegistrationJournalWithoutLibraryArtifacts() + { + var outputRoot = FileService.GetTempDirectory("manual-markerless-out"); + var sourceRoot = FileService.GetTempDirectory("manual-markerless-src"); + var sourceFile = await FileService.GetFileAsync( + sourceRoot, + "incoming.mp3", + "manual markerless audio"); + await AddAuthorizedRootAsync(outputRoot); + + var settings = await _applicationSettingsRepository.GetAsync() + ?? new ApplicationSettings(); + settings.OutputPath = outputRoot; + settings.FolderNamingPattern = ""; + settings.FileNamingPattern = "{Title}"; + settings.EnableMetadataProcessing = false; + await _applicationSettingsRepository.SaveAsync(settings); + + var audiobook = await _audiobookRepository.AddAsync(new Audiobook + { + Title = "Manual Markerless", + Authors = ["Author"], + BasePath = outputRoot + }); + var controller = ActivatorUtilities.CreateInstance( + _provider); + var request = new ManualImportRequestDto + { + Path = sourceRoot, + Mode = "interactive", + Action = FileAction.Move, + Items = + [ + new ManualImportItemDto + { + FullPath = sourceFile, + MatchedAudiobookId = audiobook.Id + } + ] + }; + + var action = await controller.Start(request); + + var ok = Assert.IsType( + action.Result); + Assert.Equal( + 1, + Assert.IsType(ok.Value!.GetType() + .GetProperty("importedCount")! + .GetValue(ok.Value))); + Assert.False(File.Exists(sourceFile)); + var destination = Path.Join(outputRoot, "Manual Markerless.mp3"); + Assert.Equal( + "manual markerless audio", + await File.ReadAllTextAsync(destination)); + + var factory = _provider.GetRequiredService< + IDbContextFactory>(); + await using var db = await factory.CreateDbContextAsync(); + var journal = await db.FileMutationJournals + .AsNoTracking() + .SingleAsync(); + Assert.Equal(FileAction.Move, journal.Action); + Assert.Equal(FileMutationJournalState.Completed, journal.State); + Assert.Equal(audiobook.Id, journal.AudiobookId); + Assert.Equal(Path.GetFullPath(sourceFile), journal.SourcePath); + Assert.Equal(Path.GetFullPath(destination), journal.DestinationPath); + + AssertNoListenarrArtifacts(sourceRoot); + AssertNoListenarrArtifacts(outputRoot); + } + + private static void AssertNoListenarrArtifacts(string root) + { + Assert.DoesNotContain( + Directory.EnumerateFileSystemEntries( + root, + "*", + SearchOption.AllDirectories), + path => Path.GetFileName(path).StartsWith( + ".listenarr", + StringComparison.OrdinalIgnoreCase)); + } +} diff --git a/tests/Features/Api/Features/Library/LibraryController_DeleteFilesystemTests.cs b/tests/Features/Api/Features/Library/LibraryController_DeleteFilesystemTests.cs index 22b7aa97f..f6f9de612 100644 --- a/tests/Features/Api/Features/Library/LibraryController_DeleteFilesystemTests.cs +++ b/tests/Features/Api/Features/Library/LibraryController_DeleteFilesystemTests.cs @@ -535,14 +535,8 @@ await _audiobookFileRepository.AddAsync(new AudiobookFileBuilder() } [WindowsFact] - public async Task DeleteAudiobook_AmbiguousPersistedBasePath_DoesNotProbeWindowsDeviceAlias() + public async Task DeleteAudiobook_AmbiguousPersistedBasePath_DoesNotWriteProbeArtifacts() { - var probedBoundaries = new List(); - var semanticsResolver = new FileSystemSemanticsResolver - { - BeforeProbeForTest = path => probedBoundaries.Add(Path.GetFullPath(path)) - }; - Init(builder => builder.WithSingleton(semanticsResolver)); var tempRoot = FileService.GetTempDirectory("listenarr-delete-ambiguous-base"); var bookFolder = Path.Join(tempRoot, "Ambiguous Base Book"); var audioPath = Path.Join(bookFolder, "track.m4b"); @@ -559,7 +553,6 @@ await AddAuthorizedRootAsync(new RootFolderBuilder() .WithId(509) .WithPath(tempRoot) .Build()); - probedBoundaries.Clear(); var audiobook = await _audiobookRepository.AddAsync(new AudiobookBuilder() .WithId(509) .WithTitle("Ambiguous Base Book") @@ -581,11 +574,14 @@ await _audiobookFileRepository.AddAsync(new AudiobookFileBuilder() Assert.True(File.Exists(audioPath)); Assert.True(File.Exists(sidecarPath)); Assert.True(Directory.Exists(bookFolder)); - var ambiguousNativeAlias = Path.GetFullPath(ambiguousBasePath); - Assert.DoesNotContain(probedBoundaries, path => string.Equals( - path, - ambiguousNativeAlias, - StringComparison.OrdinalIgnoreCase)); + Assert.DoesNotContain( + Directory.EnumerateFileSystemEntries( + tempRoot, + "*", + SearchOption.AllDirectories), + path => Path.GetFileName(path).StartsWith( + ".listenarr-", + StringComparison.Ordinal)); } [WindowsFact] @@ -1597,7 +1593,7 @@ await Assert.ThrowsAsync(() => Assert.False(Directory.Exists(bookFolder)); Assert.False(Directory.Exists(authorFolder)); - Assert.True(File.Exists(authorSiblingMarker)); + Assert.False(File.Exists(authorSiblingMarker)); var factory = _provider.GetRequiredService>(); await using (var interruptedDb = await factory.CreateDbContextAsync()) { diff --git a/tests/Features/Api/Features/Library/LibraryController_MoveTests.cs b/tests/Features/Api/Features/Library/LibraryController_MoveTests.cs index f93652b27..09974f975 100644 --- a/tests/Features/Api/Features/Library/LibraryController_MoveTests.cs +++ b/tests/Features/Api/Features/Library/LibraryController_MoveTests.cs @@ -131,6 +131,56 @@ public async Task GetMoveJobStatus_ReturnsPublicContractWithoutWorkerInternals() } } + [Fact] + public async Task GetActiveMoveJobs_ReturnsByteWeightedProgressWithoutWorkerInternals() + { + var job = new MoveJob + { + Id = Guid.NewGuid(), + AudiobookId = 42, + RequestedPath = "/library/Author/Title", + SourcePath = "/incoming/Author/Title", + Status = MoveJobStatus.Running, + Phase = MoveJobPhase.Copying, + LeaseOwner = "worker-secret", + Entries = + [ + new MoveJobEntry + { + RelativePath = "part-1.m4b", + EntryType = MoveJobEntryType.File, + Length = 100, + CopyState = MoveJobEntryCopyState.Verified + }, + new MoveJobEntry + { + RelativePath = "part-2.m4b", + EntryType = MoveJobEntryType.File, + Length = 300, + CopyState = MoveJobEntryCopyState.Pending + } + ] + }; + var moveQueue = CreateStrictMoveQueueMock(); + moveQueue.Setup(service => service.GetActiveJobsAsync( + It.IsAny())) + .ReturnsAsync([job]); + Init(services => services.WithSingleton(moveQueue.Object)); + + var result = await _provider.GetRequiredService() + .GetActiveMoveJobs(CancellationToken.None); + + var ok = Assert.IsType(result); + var json = JsonSerializer.Serialize(ok.Value); + using var document = JsonDocument.Parse(json); + var projected = Assert.Single(document.RootElement.EnumerateArray()); + Assert.Equal((int)MoveJobStatus.Running, projected.GetProperty("Status").GetInt32()); + Assert.Equal((int)MoveJobPhase.Copying, projected.GetProperty("Phase").GetInt32()); + Assert.Equal(21.25, projected.GetProperty("Progress").GetDouble()); + Assert.DoesNotContain(nameof(MoveJob.Entries), json, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("worker-secret", json, StringComparison.OrdinalIgnoreCase); + } + [Fact] public async Task GetMoveJobStatus_NeedsAttentionVerification_ReportsOperatorRepairNotRetryable() { diff --git a/tests/Features/Api/Services/ImportServiceTests.cs b/tests/Features/Api/Services/ImportServiceTests.cs index 1c9a60cb3..b6cc37a75 100644 --- a/tests/Features/Api/Services/ImportServiceTests.cs +++ b/tests/Features/Api/Services/ImportServiceTests.cs @@ -17,6 +17,7 @@ */ using System.Runtime.InteropServices; using System.Text; +using Microsoft.EntityFrameworkCore; using Listenarr.Tests.Common; using Listenarr.Tests.Builders; @@ -568,6 +569,63 @@ public async Task ImportSingleFile_WithAudiobookMetadata_SupportsSubtitlePublish Assert.Contains("The Gunslinger - Revised Edition - The Dark Tower Begins.m4b", result.FinalPath!, StringComparison.Ordinal); } + [Fact] + public async Task ImportSingleFile_Move_UsesMarkerlessRegistrationJournalWithoutLibraryArtifacts() + { + var outputRoot = FileService.GetTempDirectory("markerless-import-out"); + var sourceDir = FileService.GetTempDirectory("markerless-import-src"); + var sourceFile = await FileService.GetFileAsync( + sourceDir, + "source.mp3", + "markerless audio"); + + await SaveCurrentSettingsAsync(new ApplicationSettings + { + OutputPath = outputRoot, + CompletedFileAction = FileAction.Move, + EnableMetadataProcessing = false, + FolderNamingPattern = "", + FileNamingPattern = "{Title}" + }); + + var audiobook = await _audiobookRepository.AddAsync(new Audiobook + { + Id = 990, + Title = "Markerless Book", + Authors = ["Markerless Author"], + BasePath = outputRoot + }); + + var downloadImportService = + _provider.GetRequiredService(); + var result = Assert.Single( + await downloadImportService.ImportDownloadFilesAsync( + audiobook, + [sourceFile])); + + Assert.True(result.Success); + Assert.NotNull(result.FinalPath); + Assert.False(File.Exists(sourceFile)); + Assert.Equal( + "markerless audio", + await File.ReadAllTextAsync(result.FinalPath!)); + + var factory = _provider.GetRequiredService< + IDbContextFactory>(); + await using var db = await factory.CreateDbContextAsync(); + var journal = await db.FileMutationJournals + .AsNoTracking() + .SingleAsync(); + Assert.Equal(FileAction.Move, journal.Action); + Assert.Equal(FileMutationJournalState.Completed, journal.State); + Assert.Equal(audiobook.Id, journal.AudiobookId); + Assert.Equal(Path.GetFullPath(sourceFile), journal.SourcePath); + Assert.Equal(Path.GetFullPath(result.FinalPath!), journal.DestinationPath); + + AssertNoListenarrArtifacts(sourceDir); + AssertNoListenarrArtifacts(outputRoot); + } + [Fact] public async Task ImportSingleFile_WhenDestinationHasSameContent_ReusesDestinationAndRegistersMissingFile() { @@ -611,6 +669,18 @@ await SaveCurrentSettingsAsync(new ApplicationSettings Assert.Contains(registeredFiles, file => string.Equals(file.Path, first.FinalPath, StringComparison.OrdinalIgnoreCase)); } + private static void AssertNoListenarrArtifacts(string root) + { + Assert.DoesNotContain( + Directory.EnumerateFileSystemEntries( + root, + "*", + SearchOption.AllDirectories), + path => Path.GetFileName(path).StartsWith( + ".listenarr", + StringComparison.OrdinalIgnoreCase)); + } + [Fact] public async Task ImportSingleFile_WhenDestinationContentDiffers_UsesUniqueDestination() { diff --git a/tests/Features/Application/Audiobooks/RootFolders/RootFolderServiceTests.cs b/tests/Features/Application/Audiobooks/RootFolders/RootFolderServiceTests.cs index 9c1a384e3..54df976ee 100644 --- a/tests/Features/Application/Audiobooks/RootFolders/RootFolderServiceTests.cs +++ b/tests/Features/Application/Audiobooks/RootFolders/RootFolderServiceTests.cs @@ -133,7 +133,7 @@ private static async Task DeleteAndReturnAsync( } [Fact] - public async Task Create_PersistenceFailure_RetiresEnrollmentCreatedByAttempt() + public async Task Create_PersistenceFailure_DoesNotWriteFilesystemIdentityMarker() { var directory = CreateTempDirectory("root-create-enrollment-compensation"); var repository = new Mock(); @@ -167,7 +167,7 @@ await Assert.ThrowsAsync(() => } [Fact] - public async Task ReauthorizeDirectoryIdentity_MissingEnrollmentMarker_EnrollsNewGeneration() + public async Task ReauthorizeDirectoryIdentity_MissingLegacyMarker_UsesDatabaseOnlyGeneration() { var directory = CreateTempDirectory("root-identity-reauthorize-missing"); var options = new DbContextOptionsBuilder() @@ -179,7 +179,9 @@ public async Task ReauthorizeDirectoryIdentity_MissingEnrollmentMarker_EnrollsNe var identityResolver = new DirectoryObjectIdentityResolver(); var originalIdentity = await identityResolver.ResolveAsync(directory); Assert.True(originalIdentity.IsAvailable, originalIdentity.UnavailableReason); - File.Delete(Path.Join(directory, ManagedDirectoryEnrollment.FileName)); + Assert.False(File.Exists(Path.Join( + directory, + ManagedDirectoryEnrollment.FileName))); var semantics = FileSystemPathSemantics.CurrentHostDefault; var root = new RootFolder { @@ -202,16 +204,16 @@ public async Task ReauthorizeDirectoryIdentity_MissingEnrollmentMarker_EnrollsNe root.Id, directory); - Assert.True(File.Exists(Path.Join(directory, ManagedDirectoryEnrollment.FileName))); + Assert.False(File.Exists(Path.Join(directory, ManagedDirectoryEnrollment.FileName))); Assert.Equal(ManagedDirectoryIdentity.CurrentVersion, updated.DirectoryObjectIdentityVersion); - Assert.NotEqual(originalIdentity.Value, updated.DirectoryObjectIdentity); + Assert.Equal(originalIdentity.Value, updated.DirectoryObjectIdentity); Assert.Null(updated.DirectoryObjectIdentityUnavailableReason); var persisted = await repository.GetByIdAsync(root.Id); Assert.Equal(updated.DirectoryObjectIdentity, persisted!.DirectoryObjectIdentity); } [Fact] - public async Task ReauthorizeDirectoryIdentity_PersistenceFailure_RetiresNewEnrollment() + public async Task ReauthorizeDirectoryIdentity_PersistenceFailure_DoesNotWriteFilesystemIdentityMarker() { var directory = CreateTempDirectory("root-identity-reauthorize-compensation"); var semantics = FileSystemPathSemantics.CurrentHostDefault; @@ -265,7 +267,7 @@ await Assert.ThrowsAsync(() => } [Fact] - public async Task ReauthorizeDirectoryIdentity_CommittedThenThrow_PreservesCommittedEnrollment() + public async Task ReauthorizeDirectoryIdentity_CommittedThenThrow_LeavesDatabaseOutcomeToRepositoryContract() { var directory = CreateTempDirectory("root-identity-reauthorize-ambiguous-commit"); var semantics = FileSystemPathSemantics.CurrentHostDefault; @@ -335,15 +337,18 @@ public async Task ReauthorizeDirectoryIdentity_CommittedThenThrow_PreservesCommi relocationService: relocationService.Object, directoryObjectIdentityResolver: identityResolver); - var updated = await service.ReauthorizeDirectoryIdentityAsync(8, directory); + await Assert.ThrowsAsync(() => + service.ReauthorizeDirectoryIdentityAsync(8, directory)); Assert.NotNull(durableRoot); - Assert.Equal(durableRoot!.DirectoryObjectIdentity, updated.DirectoryObjectIdentity); - Assert.True(File.Exists(Path.Join( + Assert.False(File.Exists(Path.Join( directory, ManagedDirectoryEnrollment.FileName))); - var existing = await identityResolver.ResolveExistingAsync(directory); - Assert.Equal(updated.DirectoryObjectIdentity, existing.Value); + var existing = await identityResolver.ResolveExistingAsync( + directory, + durableRoot!.DirectoryObjectIdentityVersion!.Value, + durableRoot.DirectoryObjectIdentity!); + Assert.Equal(durableRoot.DirectoryObjectIdentity, existing.Value); } [Fact] @@ -380,7 +385,7 @@ await Assert.ThrowsAsync(() => } [Fact] - public async Task ReauthorizeDirectoryIdentity_InvalidExistingEnrollment_IsPreserved() + public async Task ReauthorizeDirectoryIdentity_InvalidLegacyMarker_IsIgnoredAndPreserved() { var directory = CreateTempDirectory("root-identity-reauthorize-invalid"); var markerPath = Path.Join(directory, ManagedDirectoryEnrollment.FileName); @@ -410,10 +415,14 @@ public async Task ReauthorizeDirectoryIdentity_InvalidExistingEnrollment_IsPrese null, directoryObjectIdentityResolver: new DirectoryObjectIdentityResolver()); - await Assert.ThrowsAsync(() => - service.ReauthorizeDirectoryIdentityAsync(root.Id, directory)); + var updated = await service.ReauthorizeDirectoryIdentityAsync( + root.Id, + directory); Assert.Equal(originalMarker, await File.ReadAllTextAsync(markerPath)); + Assert.Equal(ManagedDirectoryIdentity.CurrentVersion, updated.DirectoryObjectIdentityVersion); + Assert.NotEqual(root.DirectoryObjectIdentity, updated.DirectoryObjectIdentity); + Assert.Null(updated.DirectoryObjectIdentityUnavailableReason); } [Fact] diff --git a/tests/Features/Architecture/BackendArchitectureTests.cs b/tests/Features/Architecture/BackendArchitectureTests.cs index 8bd026ecd..7e08adc48 100644 --- a/tests/Features/Architecture/BackendArchitectureTests.cs +++ b/tests/Features/Architecture/BackendArchitectureTests.cs @@ -820,6 +820,63 @@ public void ActiveProductionSourceFiles_RemainFocused() Assert.Empty(violations); } + [Fact] + public void LegacyDirectoryMover_IsNotConnectedToProductionWorkflows() + { + var projectRoots = new[] + { + "listenarr.domain", + "listenarr.application", + "listenarr.infrastructure", + "listenarr.api" + }; + var invocationPattern = new Regex( + @"\.(?:MoveDirectoryAsync|CopyDirectoryAsync)\s*\(", + RegexOptions.Compiled); + var violations = projectRoots + .SelectMany(root => Directory.EnumerateFiles( + Path.Join(RepositoryRoot, root), + "*.cs", + SearchOption.AllDirectories)) + .Where(file => !IsBuildArtifact(file)) + .Where(file => !file.Contains( + $"{Path.DirectorySeparatorChar}Persistence{Path.DirectorySeparatorChar}Migrations{Path.DirectorySeparatorChar}", + StringComparison.OrdinalIgnoreCase)) + .Where(file => invocationPattern.IsMatch(File.ReadAllText(file))) + .Select(file => Normalize(Path.GetRelativePath(RepositoryRoot, file))) + .ToList(); + + Assert.Empty(violations); + } + + [Fact] + public void RootDirectoryIdentity_DoesNotPublishPermanentFilesystemEnrollment() + { + var legacyEnrollmentSource = File.ReadAllText(Path.Join( + RepositoryRoot, + "listenarr.infrastructure", + "FileSystem", + "ManagedDirectoryEnrollment.cs")); + var resolverSource = File.ReadAllText(Path.Join( + RepositoryRoot, + "listenarr.infrastructure", + "FileSystem", + "DirectoryObjectIdentityResolver.cs")); + + Assert.DoesNotContain( + "PublishNewFileAsync", + legacyEnrollmentSource, + StringComparison.Ordinal); + Assert.DoesNotContain( + "enrollIfMissing", + legacyEnrollmentSource, + StringComparison.Ordinal); + Assert.DoesNotContain( + "ManagedDirectoryEnrollment", + resolverSource, + StringComparison.Ordinal); + } + [Fact] public void DurableMoveBoundary_RequiresExplicitFilesystemSemantics() { diff --git a/tests/Features/Architecture/NativeTestCapabilityContractTests.cs b/tests/Features/Architecture/NativeTestCapabilityContractTests.cs index dff54416d..66122665e 100644 --- a/tests/Features/Architecture/NativeTestCapabilityContractTests.cs +++ b/tests/Features/Architecture/NativeTestCapabilityContractTests.cs @@ -238,7 +238,9 @@ public void NativeBackendRunner_PerformsPreflightThenUnfilteredFullSuite() "dotnet test listenarr.slnx", script, StringComparison.Ordinal); - Assert.Equal(2, Regex.Matches(script, @"(?m)^& dotnet test ").Count); + Assert.Equal( + 2, + Regex.Matches(script, @"(?m)^\s*& dotnet test ").Count); var fullSuiteIndex = script.IndexOf( "& dotnet test listenarr.slnx", @@ -246,7 +248,11 @@ public void NativeBackendRunner_PerformsPreflightThenUnfilteredFullSuite() Assert.True(fullSuiteIndex >= 0); var fullSuiteCommand = script[fullSuiteIndex..]; Assert.DoesNotContain("--filter", fullSuiteCommand, StringComparison.Ordinal); - Assert.Contains("exit $LASTEXITCODE", fullSuiteCommand, StringComparison.Ordinal); + Assert.Contains( + "$exitCode = $LASTEXITCODE", + fullSuiteCommand, + StringComparison.Ordinal); + Assert.Contains("exit $exitCode", fullSuiteCommand, StringComparison.Ordinal); } [Fact] diff --git a/tests/Features/Infrastructure/FileSystem/DirectoryObjectIdentityResolverTests.cs b/tests/Features/Infrastructure/FileSystem/DirectoryObjectIdentityResolverTests.cs index a2cfe208c..8f83a06c2 100644 --- a/tests/Features/Infrastructure/FileSystem/DirectoryObjectIdentityResolverTests.cs +++ b/tests/Features/Infrastructure/FileSystem/DirectoryObjectIdentityResolverTests.cs @@ -7,73 +7,94 @@ namespace Listenarr.Tests.Features.Infrastructure.FileSystem; public sealed class DirectoryObjectIdentityResolverTests : BaseTests { [Fact] - public async Task ResolveAsync_IsStableForSameEnrolledDirectory() + public async Task ResolveAsync_IsStableWithoutFilesystemMarker() { var directory = FileService.GetTempDirectory("directory-object-identity-stable"); var resolver = new DirectoryObjectIdentityResolver(); var first = await resolver.ResolveAsync(directory); - var second = await resolver.ResolveExistingAsync(directory); + var second = await resolver.ResolveAsync(directory); Assert.True(first.IsAvailable, first.UnavailableReason); Assert.Equal(ManagedDirectoryIdentity.CurrentVersion, first.Version); - Assert.Equal(first.Version, second.Version); - Assert.Equal(first.Value, second.Value); - Assert.True(File.Exists(Path.Join( + Assert.Equal(first, second); + Assert.False(File.Exists(Path.Join( directory, ManagedDirectoryEnrollment.FileName))); } [Fact] - public async Task ResolveExistingAsync_RecreatedPathWithReusedNativeIdentity_IsUnavailable() + public async Task ResolveExistingAsync_LegacyVersionTwoValue_ValidatesFromNativeGenerationWithoutMarker() + { + var directory = FileService.GetTempDirectory("directory-object-identity-existing-v2"); + const string nativeIdentity = "stable-native-generation"; + var resolver = new DirectoryObjectIdentityResolver( + nativeIdentityResolver: static _ => nativeIdentity); + var legacyPersisted = ManagedDirectoryIdentity.Create( + Guid.NewGuid().ToString("N"), + nativeIdentity); + + var existing = await resolver.ResolveExistingAsync( + directory, + ManagedDirectoryIdentity.CurrentVersion, + legacyPersisted); + + Assert.True(existing.IsAvailable, existing.UnavailableReason); + Assert.Equal(legacyPersisted, existing.Value); + Assert.False(File.Exists(Path.Join( + directory, + ManagedDirectoryEnrollment.FileName))); + } + + [Fact] + public async Task ResolveExistingAsync_DifferentNativeGeneration_IsUnavailable() { var directory = FileService.GetTempDirectory("directory-object-identity-recreated"); + var nativeIdentity = "generation-a"; var resolver = new DirectoryObjectIdentityResolver( - nativeIdentityResolver: static _ => "simulated-reused-native-identity"); + nativeIdentityResolver: _ => nativeIdentity); var first = await resolver.ResolveAsync(directory); Assert.True(first.IsAvailable, first.UnavailableReason); - Directory.Delete(directory, recursive: true); - Directory.CreateDirectory(directory); - var existing = await resolver.ResolveExistingAsync(directory); - var reenrolled = await resolver.ResolveAsync(directory); + nativeIdentity = "generation-b"; + var existing = await resolver.ResolveExistingAsync( + directory, + first.Version!.Value, + first.Value!); Assert.False(existing.IsAvailable); Assert.Contains( - "enrollment", + "physical identity", existing.UnavailableReason, StringComparison.OrdinalIgnoreCase); - Assert.True(reenrolled.IsAvailable, reenrolled.UnavailableReason); - Assert.NotEqual(first.Value, reenrolled.Value); } [Fact] - public async Task ResolveExistingAsync_CopiedEnrollmentMarkerWithDifferentNativeIdentity_IsUnavailable() + public async Task ResolveExistingAsync_ForeignMarkerCannotAuthorizeDifferentNativeGeneration() { - var source = FileService.GetTempDirectory("directory-object-identity-source"); - var replacement = FileService.GetTempDirectory("directory-object-identity-replacement"); - var sourceResolver = new DirectoryObjectIdentityResolver( - nativeIdentityResolver: anchor => - anchor.FullPath.EndsWith("source", StringComparison.Ordinal) - ? "native-source" - : "native-replacement"); - var sourceIdentity = await sourceResolver.ResolveAsync(source); - Assert.True(sourceIdentity.IsAvailable, sourceIdentity.UnavailableReason); - File.Copy( - Path.Join(source, ManagedDirectoryEnrollment.FileName), - Path.Join(replacement, ManagedDirectoryEnrollment.FileName)); - - var replacementIdentity = await sourceResolver.ResolveExistingAsync(replacement); - - Assert.False(replacementIdentity.IsAvailable); - Assert.Contains( - "physical directory", - replacementIdentity.UnavailableReason, - StringComparison.OrdinalIgnoreCase); + var directory = FileService.GetTempDirectory("directory-object-identity-foreign-marker"); + var resolver = new DirectoryObjectIdentityResolver( + nativeIdentityResolver: static _ => "native-current"); + var expected = ManagedDirectoryIdentity.Create( + Guid.NewGuid().ToString("N"), + "native-original"); + await File.WriteAllTextAsync( + Path.Join(directory, ManagedDirectoryEnrollment.FileName), + "{\"version\":1,\"token\":\"00000000000000000000000000000000\"}"); + + var existing = await resolver.ResolveExistingAsync( + directory, + ManagedDirectoryIdentity.CurrentVersion, + expected); + + Assert.False(existing.IsAvailable); + Assert.True(File.Exists(Path.Join( + directory, + ManagedDirectoryEnrollment.FileName))); } [Fact] - public async Task UpgradeLegacyAsync_MatchingNativeIdentity_EnrollsVersionTwo() + public async Task UpgradeLegacyAsync_MatchingNativeIdentity_ProducesMarkerlessVersionTwo() { var directory = FileService.GetTempDirectory("directory-object-identity-upgrade"); var resolver = new DirectoryObjectIdentityResolver( @@ -83,19 +104,21 @@ public async Task UpgradeLegacyAsync_MatchingNativeIdentity_EnrollsVersionTwo() directory, legacyVersion: 1, legacyValue: "legacy-native"); - var existing = await resolver.ResolveExistingAsync(directory); + var existing = await resolver.ResolveExistingAsync( + directory, + upgraded.Version!.Value, + upgraded.Value!); Assert.True(upgraded.IsAvailable, upgraded.UnavailableReason); - Assert.True(upgraded.EnrollmentCreated); Assert.Equal(ManagedDirectoryIdentity.CurrentVersion, upgraded.Version); - Assert.True(existing.IsAvailable, existing.UnavailableReason); - Assert.False(existing.EnrollmentCreated); - Assert.Equal(upgraded.Version, existing.Version); - Assert.Equal(upgraded.Value, existing.Value); + Assert.Equal(upgraded, existing); + Assert.False(File.Exists(Path.Join( + directory, + ManagedDirectoryEnrollment.FileName))); } [Fact] - public async Task UpgradeLegacyAsync_MismatchedNativeIdentity_FailsClosedWithoutEnrollment() + public async Task UpgradeLegacyAsync_MismatchedNativeIdentity_FailsClosedWithoutMarker() { var directory = FileService.GetTempDirectory("directory-object-identity-upgrade-mismatch"); var resolver = new DirectoryObjectIdentityResolver( @@ -113,48 +136,7 @@ public async Task UpgradeLegacyAsync_MismatchedNativeIdentity_FailsClosedWithout } [Fact] - public async Task RetireEnrollmentAsync_ExactCreatedGeneration_RemovesMarker() - { - var directory = FileService.GetTempDirectory("directory-object-identity-retire"); - var resolver = new DirectoryObjectIdentityResolver(); - var enrolled = await resolver.ResolveAsync(directory); - Assert.True(enrolled.IsAvailable, enrolled.UnavailableReason); - Assert.True(enrolled.EnrollmentCreated); - - await resolver.RetireEnrollmentAsync( - directory, - enrolled.Version!.Value, - enrolled.Value!); - - Assert.False(File.Exists(Path.Join( - directory, - ManagedDirectoryEnrollment.FileName))); - var existing = await resolver.ResolveExistingAsync(directory); - Assert.False(existing.IsAvailable); - } - - [Fact] - public async Task RetireEnrollmentAsync_MismatchedExpectedGeneration_PreservesMarker() - { - var directory = FileService.GetTempDirectory("directory-object-identity-retire-mismatch"); - var resolver = new DirectoryObjectIdentityResolver(); - var enrolled = await resolver.ResolveAsync(directory); - Assert.True(enrolled.IsAvailable, enrolled.UnavailableReason); - var markerPath = Path.Join(directory, ManagedDirectoryEnrollment.FileName); - var original = await File.ReadAllTextAsync(markerPath); - - await Assert.ThrowsAsync(() => - resolver.RetireEnrollmentAsync( - directory, - enrolled.Version!.Value, - "listenarr-directory-v2:00000000000000000000000000000000:" - + new string('0', 64))); - - Assert.Equal(original, await File.ReadAllTextAsync(markerPath)); - } - - [Fact] - public async Task ResolveAsync_ForeignPersistedSyntax_FailsClosedBeforeNativeProbeOrEnrollment() + public async Task ResolveAsync_ForeignPersistedSyntax_FailsClosedBeforeNativeProbeOrMarkerWrite() { var directory = FileService.GetTempDirectory("directory-object-identity-foreign-syntax"); var nativeProbeCount = 0; @@ -171,9 +153,13 @@ public async Task ResolveAsync_ForeignPersistedSyntax_FailsClosedBeforeNativePro var expectedForeignSyntax = OperatingSystem.IsWindows() ? FileSystemPathSyntax.Unix : FileSystemPathSyntax.Windows; + var expected = ManagedDirectoryIdentity.CreateMarkerless("expected-native"); var resolution = await resolver.ResolveAsync(foreignPath); - var existing = await resolver.ResolveExistingAsync(foreignPath); + var existing = await resolver.ResolveExistingAsync( + foreignPath, + ManagedDirectoryIdentity.CurrentVersion, + expected); var legacy = await resolver.UpgradeLegacyAsync( foreignPath, legacyVersion: 1, @@ -207,7 +193,7 @@ public async Task ResolveAsync_ReturnsUnavailableForMissingDirectory() } [LinuxFact] - public async Task ResolveExistingAsync_ImmediateNativeDeleteRecreate_DoesNotRetainEnrollment() + public async Task ResolveExistingAsync_ImmediateNativeDeleteRecreate_DetectsGenerationChange() { var directory = FileService.GetTempDirectory("directory-object-identity-native-recreate"); var resolver = new DirectoryObjectIdentityResolver(); @@ -216,7 +202,10 @@ public async Task ResolveExistingAsync_ImmediateNativeDeleteRecreate_DoesNotReta Directory.Delete(directory, recursive: true); Directory.CreateDirectory(directory); - var existing = await resolver.ResolveExistingAsync(directory); + var existing = await resolver.ResolveExistingAsync( + directory, + first.Version!.Value, + first.Value!); Assert.False(existing.IsAvailable); } diff --git a/tests/Features/Infrastructure/FileSystem/FileMoverMarkerlessMoveTests.cs b/tests/Features/Infrastructure/FileSystem/FileMoverMarkerlessMoveTests.cs new file mode 100644 index 000000000..a42a443be --- /dev/null +++ b/tests/Features/Infrastructure/FileSystem/FileMoverMarkerlessMoveTests.cs @@ -0,0 +1,382 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; + +using Listenarr.Tests.Common; + +namespace Listenarr.Tests.Features.Infrastructure.FileSystem; + +[Trait("Name", "FileMoverMarkerlessMoveTests")] +[Trait("Category", "Infrastructure")] +public sealed class FileMoverMarkerlessMoveTests : BaseTests +{ + [Fact] + public async Task MoveFileAsync_NativeRenameBeforeTargetStateCommitResumes() + { + var scenario = await CreateScenarioAsync(); + var interrupted = CreateMover( + afterPublishedBeforeTargetState: () => + throw new IOException("Injected crash after markerless native rename.")); + + await Assert.ThrowsAsync(() => interrupted.MoveFileAsync( + scenario.Source, + scenario.Destination, + scenario.OperationId)); + + Assert.False(File.Exists(scenario.Source)); + Assert.Equal("audio", await File.ReadAllTextAsync(scenario.Destination)); + Assert.Equal(scenario.SourceIdentity, GetFileIdentity(scenario.Destination)); + await AssertJournalStateAsync( + scenario.OperationId, + FileMutationJournalState.Planned, + targetIdentity: null); + AssertNoLibraryArtifacts(scenario.Root); + + Assert.True(await CreateMover().MoveFileAsync( + scenario.Source, + scenario.Destination, + scenario.OperationId)); + + Assert.False(File.Exists(scenario.Source)); + Assert.Equal("audio", await File.ReadAllTextAsync(scenario.Destination)); + await AssertJournalStateAsync( + scenario.OperationId, + FileMutationJournalState.Completed, + scenario.SourceIdentity); + AssertNoLibraryArtifacts(scenario.Root); + } + + [Fact] + public async Task MoveFileAsync_FinalFileCreatedBeforeTargetIdentityBecomesNeedsAttention() + { + var scenario = await CreateScenarioAsync(); + var interrupted = CreateMover( + disableNativeRename: true, + afterTargetCreatedBeforeState: () => + throw new IOException("Injected crash after markerless target creation.")); + + await Assert.ThrowsAsync(() => interrupted.MoveFileAsync( + scenario.Source, + scenario.Destination, + scenario.OperationId)); + + Assert.True(File.Exists(scenario.Source)); + Assert.True(File.Exists(scenario.Destination)); + Assert.Equal(0, new FileInfo(scenario.Destination).Length); + await AssertJournalStateAsync( + scenario.OperationId, + FileMutationJournalState.Planned, + targetIdentity: null); + AssertNoLibraryArtifacts(scenario.Root); + + Assert.False(await CreateMover(disableNativeRename: true).MoveFileAsync( + scenario.Source, + scenario.Destination, + scenario.OperationId)); + + Assert.Equal("audio", await File.ReadAllTextAsync(scenario.Source)); + Assert.Equal(0, new FileInfo(scenario.Destination).Length); + await AssertJournalStateAsync( + scenario.OperationId, + FileMutationJournalState.NeedsAttention, + targetIdentity: null); + AssertNoLibraryArtifacts(scenario.Root); + } + + [Fact] + public async Task MoveFileAsync_TargetIdentityPersistedBeforeBytesResumes() + { + var scenario = await CreateScenarioAsync(); + var interrupted = CreateMover( + disableNativeRename: true, + afterTargetState: () => + throw new IOException("Injected crash after markerless target identity persistence.")); + + await Assert.ThrowsAsync(() => interrupted.MoveFileAsync( + scenario.Source, + scenario.Destination, + scenario.OperationId)); + + Assert.True(File.Exists(scenario.Source)); + Assert.True(File.Exists(scenario.Destination)); + Assert.Equal(0, new FileInfo(scenario.Destination).Length); + var targetIdentity = GetFileIdentity(scenario.Destination); + await AssertJournalStateAsync( + scenario.OperationId, + FileMutationJournalState.TargetIdentityPersisted, + targetIdentity); + AssertNoLibraryArtifacts(scenario.Root); + + Assert.True(await CreateMover(disableNativeRename: true).MoveFileAsync( + scenario.Source, + scenario.Destination, + scenario.OperationId)); + + Assert.False(File.Exists(scenario.Source)); + Assert.Equal("audio", await File.ReadAllTextAsync(scenario.Destination)); + await AssertJournalStateAsync( + scenario.OperationId, + FileMutationJournalState.Completed, + targetIdentity); + AssertNoLibraryArtifacts(scenario.Root); + } + + [Fact] + public async Task MoveFileAsync_BytesWrittenBeforeVerificationStateResumes() + { + var scenario = await CreateScenarioAsync(); + var interrupted = CreateMover( + disableNativeRename: true, + afterTargetWrittenBeforeVerifiedState: () => + throw new IOException("Injected crash after markerless target write.")); + + await Assert.ThrowsAsync(() => interrupted.MoveFileAsync( + scenario.Source, + scenario.Destination, + scenario.OperationId)); + + Assert.True(File.Exists(scenario.Source)); + Assert.Equal("audio", await File.ReadAllTextAsync(scenario.Destination)); + var targetIdentity = GetFileIdentity(scenario.Destination); + await AssertJournalStateAsync( + scenario.OperationId, + FileMutationJournalState.TargetIdentityPersisted, + targetIdentity); + AssertNoLibraryArtifacts(scenario.Root); + + Assert.True(await CreateMover(disableNativeRename: true).MoveFileAsync( + scenario.Source, + scenario.Destination, + scenario.OperationId)); + + Assert.False(File.Exists(scenario.Source)); + Assert.Equal("audio", await File.ReadAllTextAsync(scenario.Destination)); + await AssertJournalStateAsync( + scenario.OperationId, + FileMutationJournalState.Completed, + targetIdentity); + AssertNoLibraryArtifacts(scenario.Root); + } + + [Fact] + public async Task MoveFileAsync_SourceDeletedBeforeDeletionStateCommitResumes() + { + var scenario = await CreateScenarioAsync(); + var interrupted = CreateMover( + disableNativeRename: true, + afterSourceDeletedBeforeState: () => + throw new IOException("Injected crash after markerless source deletion.")); + + await Assert.ThrowsAsync(() => interrupted.MoveFileAsync( + scenario.Source, + scenario.Destination, + scenario.OperationId)); + + Assert.False(File.Exists(scenario.Source)); + Assert.Equal("audio", await File.ReadAllTextAsync(scenario.Destination)); + var targetIdentity = GetFileIdentity(scenario.Destination); + await AssertJournalStateAsync( + scenario.OperationId, + FileMutationJournalState.SourceDeletionAuthorized, + targetIdentity); + AssertNoLibraryArtifacts(scenario.Root); + + Assert.True(await CreateMover(disableNativeRename: true).MoveFileAsync( + scenario.Source, + scenario.Destination, + scenario.OperationId)); + + Assert.False(File.Exists(scenario.Source)); + Assert.Equal("audio", await File.ReadAllTextAsync(scenario.Destination)); + await AssertJournalStateAsync( + scenario.OperationId, + FileMutationJournalState.Completed, + targetIdentity); + AssertNoLibraryArtifacts(scenario.Root); + } + + [Fact] + public async Task MoveFileAsync_TargetReplacedAfterIdentityPersistenceIsPreservedAndBlocked() + { + var scenario = await CreateScenarioAsync(); + var interrupted = CreateMover( + disableNativeRename: true, + afterTargetState: () => + throw new IOException("Injected crash after markerless target identity persistence.")); + await Assert.ThrowsAsync(() => interrupted.MoveFileAsync( + scenario.Source, + scenario.Destination, + scenario.OperationId)); + + var originalTargetIdentity = GetFileIdentity(scenario.Destination); + File.Delete(scenario.Destination); + await File.WriteAllTextAsync(scenario.Destination, "foreign-target"); + Assert.NotEqual(originalTargetIdentity, GetFileIdentity(scenario.Destination)); + + Assert.False(await CreateMover(disableNativeRename: true).MoveFileAsync( + scenario.Source, + scenario.Destination, + scenario.OperationId)); + + Assert.Equal("audio", await File.ReadAllTextAsync(scenario.Source)); + Assert.Equal("foreign-target", await File.ReadAllTextAsync(scenario.Destination)); + await AssertJournalStateAsync( + scenario.OperationId, + FileMutationJournalState.NeedsAttention, + originalTargetIdentity); + AssertNoLibraryArtifacts(scenario.Root); + } + + [Fact] + public async Task MoveFileAsync_SourceReplacedAfterTargetWriteIsPreservedAndBlocked() + { + var scenario = await CreateScenarioAsync(); + var interrupted = CreateMover( + disableNativeRename: true, + afterTargetWrittenBeforeVerifiedState: () => + throw new IOException("Injected crash after markerless target write.")); + await Assert.ThrowsAsync(() => interrupted.MoveFileAsync( + scenario.Source, + scenario.Destination, + scenario.OperationId)); + + File.Delete(scenario.Source); + await File.WriteAllTextAsync(scenario.Source, "foreign-source"); + Assert.NotEqual(scenario.SourceIdentity, GetFileIdentity(scenario.Source)); + var targetIdentity = GetFileIdentity(scenario.Destination); + + Assert.False(await CreateMover(disableNativeRename: true).MoveFileAsync( + scenario.Source, + scenario.Destination, + scenario.OperationId)); + + Assert.Equal("foreign-source", await File.ReadAllTextAsync(scenario.Source)); + Assert.Equal("audio", await File.ReadAllTextAsync(scenario.Destination)); + await AssertJournalStateAsync( + scenario.OperationId, + FileMutationJournalState.NeedsAttention, + targetIdentity); + AssertNoLibraryArtifacts(scenario.Root); + } + + [Fact] + public async Task MoveFileAsync_CompletedJournalWithRecreatedSourcePreservesSourceAndBlocks() + { + var scenario = await CreateScenarioAsync(); + var mover = CreateMover(disableNativeRename: true); + + Assert.True(await mover.MoveFileAsync( + scenario.Source, + scenario.Destination, + scenario.OperationId)); + var targetIdentity = GetFileIdentity(scenario.Destination); + await AssertJournalStateAsync( + scenario.OperationId, + FileMutationJournalState.Completed, + targetIdentity); + + await File.WriteAllTextAsync(scenario.Source, "recreated-source"); + + Assert.False(await CreateMover(disableNativeRename: true).MoveFileAsync( + scenario.Source, + scenario.Destination, + scenario.OperationId)); + + Assert.Equal("recreated-source", await File.ReadAllTextAsync(scenario.Source)); + Assert.Equal("audio", await File.ReadAllTextAsync(scenario.Destination)); + await AssertJournalStateAsync( + scenario.OperationId, + FileMutationJournalState.NeedsAttention, + targetIdentity); + AssertNoLibraryArtifacts(scenario.Root); + } + + private FileMover CreateMover( + bool disableNativeRename = false, + Func? afterPublishedBeforeTargetState = null, + Func? afterTargetCreatedBeforeState = null, + Func? afterTargetState = null, + Func? afterTargetWrittenBeforeVerifiedState = null, + Func? afterSourceDeletedBeforeState = null) + { + var factory = _provider.GetRequiredService< + IDbContextFactory>(); + return new FileMover( + new NullLogger(), + dbContextFactory: factory, + timeProvider: TimeProvider.System) + { + FileMoveLockDirectoryForTest = FileService.GetTempDirectory( + "file-mover-markerless-locks"), + DisableNativeFileRenameForTest = disableNativeRename, + AfterMarkerlessMovePublishedBeforeTargetStateForTestAsync = + afterPublishedBeforeTargetState, + AfterMarkerlessMoveTargetCreatedBeforeStateForTestAsync = + afterTargetCreatedBeforeState, + AfterMarkerlessMoveTargetStateForTestAsync = afterTargetState, + AfterMarkerlessMoveTargetWrittenBeforeVerifiedStateForTestAsync = + afterTargetWrittenBeforeVerifiedState, + AfterMarkerlessMoveSourceDeletedBeforeStateForTestAsync = + afterSourceDeletedBeforeState + }; + } + + private async Task CreateScenarioAsync() + { + var root = FileService.GetTempDirectory("file-mover-markerless-move"); + var source = Path.Join(root, "source.m4b"); + var destinationDirectory = Path.Join(root, "destination"); + Directory.CreateDirectory(destinationDirectory); + var destination = Path.Join(destinationDirectory, "moved.m4b"); + await File.WriteAllTextAsync(source, "audio"); + return new Scenario( + root, + source, + destination, + GetFileIdentity(source), + Guid.NewGuid()); + } + + private async Task AssertJournalStateAsync( + Guid operationId, + FileMutationJournalState state, + string? targetIdentity) + { + var factory = _provider.GetRequiredService< + IDbContextFactory>(); + await using var db = await factory.CreateDbContextAsync(); + var journal = await db.FileMutationJournals + .AsNoTracking() + .SingleAsync(candidate => candidate.OperationId == operationId); + Assert.Equal(state, journal.State); + Assert.Equal(targetIdentity, journal.TargetPhysicalObjectIdentity); + } + + private static string GetFileIdentity(string path) + { + using var lease = PinnedAudiobookFileRegistrationLease.Open(path); + return lease.PhysicalObjectIdentity; + } + + private static void AssertNoLibraryArtifacts(string root) + { + Assert.DoesNotContain( + Directory.EnumerateFileSystemEntries( + root, + "*", + SearchOption.AllDirectories), + path => + { + var name = Path.GetFileName(path); + return name.Contains(".listenarr-", StringComparison.Ordinal) + || name.EndsWith(".partial", StringComparison.Ordinal) + || name.Contains("quarantine", StringComparison.OrdinalIgnoreCase); + }); + } + + private sealed record Scenario( + string Root, + string Source, + string Destination, + string SourceIdentity, + Guid OperationId); +} diff --git a/tests/Features/Infrastructure/FileSystem/FileMoverMarkerlessRegistrationTests.cs b/tests/Features/Infrastructure/FileSystem/FileMoverMarkerlessRegistrationTests.cs new file mode 100644 index 000000000..b9d677e7a --- /dev/null +++ b/tests/Features/Infrastructure/FileSystem/FileMoverMarkerlessRegistrationTests.cs @@ -0,0 +1,309 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; + +using Listenarr.Tests.Common; + +namespace Listenarr.Tests.Features.Infrastructure.FileSystem; + +[Trait("Name", "FileMoverMarkerlessRegistrationTests")] +[Trait("Category", "Infrastructure")] +public sealed class FileMoverMarkerlessRegistrationTests : BaseTests +{ + [Fact] + public async Task PrepareMove_RequiresRegistrationCommitBeforeSourceDeletion() + { + var scenario = await CreateScenarioAsync("move-authority"); + var mover = CreateMover(); + + using var lease = await mover.PrepareActionForRegistrationAsync( + FileAction.Move, + scenario.Source, + scenario.Destination, + scenario.OperationId); + + Assert.NotNull(lease); + Assert.True(File.Exists(scenario.Source)); + Assert.Equal("audio", await File.ReadAllTextAsync(scenario.Destination)); + await AssertJournalStateAsync( + scenario.OperationId, + FileMutationJournalState.TargetVerified, + audiobookId: null); + + Assert.False(await mover.CompletePreparedMoveAsync( + scenario.Source, + scenario.Destination, + lease, + scenario.OperationId)); + Assert.True(File.Exists(scenario.Source)); + await AssertJournalStateAsync( + scenario.OperationId, + FileMutationJournalState.TargetVerified, + audiobookId: null); + + Assert.True(lease.PrepareCleanupRecovery(17)); + Assert.Equal( + RegistrationPublicationCompletion.Completed, + lease.CompletePublication()); + await AssertJournalStateAsync( + scenario.OperationId, + FileMutationJournalState.RegistrationCommitted, + audiobookId: 17); + + Assert.True(await mover.CompletePreparedMoveAsync( + scenario.Source, + scenario.Destination, + lease, + scenario.OperationId)); + Assert.False(File.Exists(scenario.Source)); + Assert.Equal("audio", await File.ReadAllTextAsync(scenario.Destination)); + await AssertJournalStateAsync( + scenario.OperationId, + FileMutationJournalState.Completed, + audiobookId: 17); + AssertNoLibraryArtifacts(scenario.Root); + } + + [Theory] + [InlineData(FileAction.Copy)] + [InlineData(FileAction.HardlinkCopy)] + public async Task PrepareCopy_CommittedRegistrationCompletesJournalWithoutSourceMutation( + FileAction action) + { + var scenario = await CreateScenarioAsync($"registration-{action}"); + var mover = CreateMover(); + + using var lease = await mover.PrepareActionForRegistrationAsync( + action, + scenario.Source, + scenario.Destination, + scenario.OperationId); + + Assert.NotNull(lease); + Assert.True(lease.MatchesCurrentPublication()); + Assert.True(File.Exists(scenario.Source)); + Assert.Equal("audio", await File.ReadAllTextAsync(scenario.Destination)); + await AssertJournalStateAsync( + scenario.OperationId, + FileMutationJournalState.TargetVerified, + audiobookId: null); + + Assert.True(lease.PrepareCleanupRecovery(23)); + Assert.Equal( + RegistrationPublicationCompletion.Completed, + lease.CompletePublication()); + + Assert.True(File.Exists(scenario.Source)); + Assert.Equal("audio", await File.ReadAllTextAsync(scenario.Source)); + Assert.Equal("audio", await File.ReadAllTextAsync(scenario.Destination)); + await AssertJournalStateAsync( + scenario.OperationId, + FileMutationJournalState.Completed, + audiobookId: 23); + AssertNoLibraryArtifacts(scenario.Root); + } + + [Fact] + public async Task PrepareMove_RetryAfterOwnershipCommitGapReusesVerifiedGeneration() + { + var scenario = await CreateScenarioAsync("registration-retry"); + var firstMover = CreateMover(); + string targetIdentity; + using (var firstLease = await firstMover.PrepareActionForRegistrationAsync( + FileAction.Move, + scenario.Source, + scenario.Destination, + scenario.OperationId)) + { + Assert.NotNull(firstLease); + targetIdentity = firstLease.PhysicalObjectIdentity; + } + + await AssertJournalStateAsync( + scenario.OperationId, + FileMutationJournalState.TargetVerified, + audiobookId: null); + + var retryMover = CreateMover(); + using var retryLease = await retryMover.PrepareActionForRegistrationAsync( + FileAction.Move, + scenario.Source, + scenario.Destination, + scenario.OperationId, + targetIdentity); + + Assert.NotNull(retryLease); + Assert.Equal(targetIdentity, retryLease.PhysicalObjectIdentity); + Assert.True(retryLease.PrepareCleanupRecovery(31)); + Assert.Equal( + RegistrationPublicationCompletion.Completed, + retryLease.CompletePublication()); + Assert.True(await retryMover.CompletePreparedMoveAsync( + scenario.Source, + scenario.Destination, + retryLease, + scenario.OperationId)); + + Assert.False(File.Exists(scenario.Source)); + Assert.Equal("audio", await File.ReadAllTextAsync(scenario.Destination)); + await AssertJournalStateAsync( + scenario.OperationId, + FileMutationJournalState.Completed, + audiobookId: 31); + AssertNoLibraryArtifacts(scenario.Root); + } + + [Fact] + public async Task CompleteMove_CrashAfterSourceDeletionResumesFromDatabaseAuthorization() + { + var scenario = await CreateScenarioAsync("registration-delete-crash"); + var crashingMover = CreateMover( + afterSourceDeletedBeforeState: () => + throw new IOException("Injected crash after source deletion.")); + using var lease = await crashingMover.PrepareActionForRegistrationAsync( + FileAction.Move, + scenario.Source, + scenario.Destination, + scenario.OperationId); + Assert.NotNull(lease); + Assert.True(lease.PrepareCleanupRecovery(41)); + Assert.Equal( + RegistrationPublicationCompletion.Completed, + lease.CompletePublication()); + + Assert.False(await crashingMover.CompletePreparedMoveAsync( + scenario.Source, + scenario.Destination, + lease, + scenario.OperationId)); + Assert.False(File.Exists(scenario.Source)); + await AssertJournalStateAsync( + scenario.OperationId, + FileMutationJournalState.SourceDeletionAuthorized, + audiobookId: 41); + + var recoveryMover = CreateMover(); + using var recoveryLease = await recoveryMover.PrepareActionForRegistrationAsync( + FileAction.Move, + scenario.Source, + scenario.Destination, + scenario.OperationId, + lease.PhysicalObjectIdentity); + Assert.NotNull(recoveryLease); + Assert.True(recoveryLease.PrepareCleanupRecovery(41)); + Assert.Equal( + RegistrationPublicationCompletion.Completed, + recoveryLease.CompletePublication()); + Assert.True(await recoveryMover.CompletePreparedMoveAsync( + scenario.Source, + scenario.Destination, + recoveryLease, + scenario.OperationId)); + + Assert.False(File.Exists(scenario.Source)); + Assert.Equal("audio", await File.ReadAllTextAsync(scenario.Destination)); + await AssertJournalStateAsync( + scenario.OperationId, + FileMutationJournalState.Completed, + audiobookId: 41); + AssertNoLibraryArtifacts(scenario.Root); + } + + [Fact] + public async Task CompleteMove_ReplacedSourceIsPreservedAndJournalNeedsAttention() + { + var scenario = await CreateScenarioAsync("registration-source-replaced"); + var mover = CreateMover(); + using var lease = await mover.PrepareActionForRegistrationAsync( + FileAction.Move, + scenario.Source, + scenario.Destination, + scenario.OperationId); + Assert.NotNull(lease); + Assert.True(lease.PrepareCleanupRecovery(53)); + Assert.Equal( + RegistrationPublicationCompletion.Completed, + lease.CompletePublication()); + + File.Delete(scenario.Source); + await File.WriteAllTextAsync(scenario.Source, "foreign-source"); + + Assert.False(await mover.CompletePreparedMoveAsync( + scenario.Source, + scenario.Destination, + lease, + scenario.OperationId)); + + Assert.Equal("foreign-source", await File.ReadAllTextAsync(scenario.Source)); + Assert.Equal("audio", await File.ReadAllTextAsync(scenario.Destination)); + await AssertJournalStateAsync( + scenario.OperationId, + FileMutationJournalState.NeedsAttention, + audiobookId: 53); + AssertNoLibraryArtifacts(scenario.Root); + } + + private FileMover CreateMover( + Func? afterSourceDeletedBeforeState = null) + { + var factory = _provider.GetRequiredService< + IDbContextFactory>(); + return new FileMover( + new NullLogger(), + dbContextFactory: factory, + timeProvider: TimeProvider.System) + { + FileMoveLockDirectoryForTest = FileService.GetTempDirectory( + "file-mover-markerless-registration-locks"), + AfterMarkerlessMoveSourceDeletedBeforeStateForTestAsync = + afterSourceDeletedBeforeState + }; + } + + private async Task CreateScenarioAsync(string name) + { + var root = FileService.GetTempDirectory(name); + var source = Path.Join(root, "source.m4b"); + var destinationDirectory = Path.Join(root, "destination"); + Directory.CreateDirectory(destinationDirectory); + var destination = Path.Join(destinationDirectory, "published.m4b"); + await File.WriteAllTextAsync(source, "audio"); + return new Scenario(root, source, destination, Guid.NewGuid()); + } + + private async Task AssertJournalStateAsync( + Guid operationId, + FileMutationJournalState state, + int? audiobookId) + { + var factory = _provider.GetRequiredService< + IDbContextFactory>(); + await using var db = await factory.CreateDbContextAsync(); + var journal = await db.FileMutationJournals + .AsNoTracking() + .SingleAsync(candidate => candidate.OperationId == operationId); + Assert.Equal(state, journal.State); + Assert.Equal(audiobookId, journal.AudiobookId); + } + + private static void AssertNoLibraryArtifacts(string root) + { + Assert.DoesNotContain( + Directory.EnumerateFileSystemEntries( + root, + "*", + SearchOption.AllDirectories), + path => + { + var name = Path.GetFileName(path); + return name.Contains(".listenarr-", StringComparison.Ordinal) + || name.EndsWith(".partial", StringComparison.Ordinal) + || name.Contains("quarantine", StringComparison.OrdinalIgnoreCase); + }); + } + + private sealed record Scenario( + string Root, + string Source, + string Destination, + Guid OperationId); +} diff --git a/tests/Features/Infrastructure/FileSystem/FileMoverMarkerlessRenameTests.cs b/tests/Features/Infrastructure/FileSystem/FileMoverMarkerlessRenameTests.cs new file mode 100644 index 000000000..82bf9b4e7 --- /dev/null +++ b/tests/Features/Infrastructure/FileSystem/FileMoverMarkerlessRenameTests.cs @@ -0,0 +1,316 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; + +using Listenarr.Tests.Common; + +namespace Listenarr.Tests.Features.Infrastructure.FileSystem; + +[Trait("Name", "FileMoverMarkerlessRenameTests")] +[Trait("Category", "Infrastructure")] +public sealed class FileMoverMarkerlessRenameTests : BaseTests +{ + [Fact] + public async Task MoveFilePreservingPhysicalIdentityAsync_MarkerlessRenameCompletesWithoutArtifacts() + { + var scenario = await CreateScenarioAsync(); + var mover = CreateMover(); + + Assert.True(await mover.MoveFilePreservingPhysicalIdentityAsync( + scenario.Source, + scenario.Destination, + scenario.SourceIdentity, + scenario.OperationId)); + + Assert.False(File.Exists(scenario.Source)); + Assert.Equal("audio", await File.ReadAllTextAsync(scenario.Destination)); + Assert.Equal( + scenario.SourceIdentity, + GetFileIdentity(scenario.Destination)); + await AssertJournalStateAsync( + scenario.OperationId, + FileMutationJournalState.Completed, + scenario.SourceIdentity); + AssertNoLibraryArtifacts(scenario.Root); + } + + [Fact] + public async Task MoveFilePreservingPhysicalIdentityAsync_CrashAfterJournalPlanResumes() + { + var scenario = await CreateScenarioAsync(); + var interrupted = CreateMover( + afterJournalPlanned: () => + throw new IOException("Injected crash after markerless journal plan.")); + + await Assert.ThrowsAsync(() => + interrupted.MoveFilePreservingPhysicalIdentityAsync( + scenario.Source, + scenario.Destination, + scenario.SourceIdentity, + scenario.OperationId)); + + Assert.True(File.Exists(scenario.Source)); + Assert.False(File.Exists(scenario.Destination)); + await AssertJournalStateAsync( + scenario.OperationId, + FileMutationJournalState.Planned, + targetIdentity: null); + AssertNoLibraryArtifacts(scenario.Root); + + Assert.True(await CreateMover().MoveFilePreservingPhysicalIdentityAsync( + scenario.Source, + scenario.Destination, + scenario.SourceIdentity, + scenario.OperationId)); + Assert.False(File.Exists(scenario.Source)); + Assert.Equal("audio", await File.ReadAllTextAsync(scenario.Destination)); + await AssertJournalStateAsync( + scenario.OperationId, + FileMutationJournalState.Completed, + scenario.SourceIdentity); + AssertNoLibraryArtifacts(scenario.Root); + } + + [Fact] + public async Task MoveFilePreservingPhysicalIdentityAsync_CrashAfterNativeRenameResumesFromIdentity() + { + var scenario = await CreateScenarioAsync(); + var interrupted = CreateMover( + afterPublishedBeforeTargetState: () => + throw new IOException("Injected crash after markerless native rename.")); + + await Assert.ThrowsAsync(() => + interrupted.MoveFilePreservingPhysicalIdentityAsync( + scenario.Source, + scenario.Destination, + scenario.SourceIdentity, + scenario.OperationId)); + + Assert.False(File.Exists(scenario.Source)); + Assert.True(File.Exists(scenario.Destination)); + Assert.Equal( + scenario.SourceIdentity, + GetFileIdentity(scenario.Destination)); + await AssertJournalStateAsync( + scenario.OperationId, + FileMutationJournalState.Planned, + targetIdentity: null); + AssertNoLibraryArtifacts(scenario.Root); + + Assert.True(await CreateMover().MoveFilePreservingPhysicalIdentityAsync( + scenario.Source, + scenario.Destination, + scenario.SourceIdentity, + scenario.OperationId)); + await AssertJournalStateAsync( + scenario.OperationId, + FileMutationJournalState.Completed, + scenario.SourceIdentity); + AssertNoLibraryArtifacts(scenario.Root); + } + + [Fact] + public async Task MoveFilePreservingPhysicalIdentityAsync_CrashAfterTargetStateResumes() + { + var scenario = await CreateScenarioAsync(); + var interrupted = CreateMover( + afterTargetState: () => + throw new IOException("Injected crash after markerless target state.")); + + await Assert.ThrowsAsync(() => + interrupted.MoveFilePreservingPhysicalIdentityAsync( + scenario.Source, + scenario.Destination, + scenario.SourceIdentity, + scenario.OperationId)); + + Assert.False(File.Exists(scenario.Source)); + Assert.Equal("audio", await File.ReadAllTextAsync(scenario.Destination)); + await AssertJournalStateAsync( + scenario.OperationId, + FileMutationJournalState.TargetIdentityPersisted, + scenario.SourceIdentity); + + Assert.True(await CreateMover().MoveFilePreservingPhysicalIdentityAsync( + scenario.Source, + scenario.Destination, + scenario.SourceIdentity, + scenario.OperationId)); + await AssertJournalStateAsync( + scenario.OperationId, + FileMutationJournalState.Completed, + scenario.SourceIdentity); + AssertNoLibraryArtifacts(scenario.Root); + } + + [Fact] + public async Task MoveFilePreservingPhysicalIdentityAsync_CompletedRetryIsIdempotent() + { + var scenario = await CreateScenarioAsync(); + var mover = CreateMover(); + + Assert.True(await mover.MoveFilePreservingPhysicalIdentityAsync( + scenario.Source, + scenario.Destination, + scenario.SourceIdentity, + scenario.OperationId)); + Assert.True(await CreateMover().MoveFilePreservingPhysicalIdentityAsync( + scenario.Source, + scenario.Destination, + scenario.SourceIdentity, + scenario.OperationId)); + + Assert.False(File.Exists(scenario.Source)); + Assert.Equal("audio", await File.ReadAllTextAsync(scenario.Destination)); + Assert.Equal( + scenario.SourceIdentity, + GetFileIdentity(scenario.Destination)); + await AssertJournalStateAsync( + scenario.OperationId, + FileMutationJournalState.Completed, + scenario.SourceIdentity); + AssertNoLibraryArtifacts(scenario.Root); + } + + [Fact] + public async Task MoveFilePreservingPhysicalIdentityAsync_CompletedJournalWithRecreatedSourcePreservesSourceAndBlocks() + { + var scenario = await CreateScenarioAsync(); + var mover = CreateMover(); + Assert.True(await mover.MoveFilePreservingPhysicalIdentityAsync( + scenario.Source, + scenario.Destination, + scenario.SourceIdentity, + scenario.OperationId)); + + await File.WriteAllTextAsync(scenario.Source, "recreated-source"); + + Assert.False(await CreateMover().MoveFilePreservingPhysicalIdentityAsync( + scenario.Source, + scenario.Destination, + scenario.SourceIdentity, + scenario.OperationId)); + Assert.Equal("recreated-source", await File.ReadAllTextAsync(scenario.Source)); + Assert.Equal("audio", await File.ReadAllTextAsync(scenario.Destination)); + await AssertJournalStateAsync( + scenario.OperationId, + FileMutationJournalState.NeedsAttention, + scenario.SourceIdentity); + AssertNoLibraryArtifacts(scenario.Root); + } + + [Fact] + public async Task MoveFilePreservingPhysicalIdentityAsync_TargetReplacedAfterUncommittedRenameIsPreservedAndBlocked() + { + var scenario = await CreateScenarioAsync(); + var interrupted = CreateMover( + afterPublishedBeforeTargetState: () => + throw new IOException("Injected crash after markerless native rename.")); + await Assert.ThrowsAsync(() => + interrupted.MoveFilePreservingPhysicalIdentityAsync( + scenario.Source, + scenario.Destination, + scenario.SourceIdentity, + scenario.OperationId)); + + File.Delete(scenario.Destination); + await File.WriteAllTextAsync(scenario.Destination, "foreign"); + var replacementIdentity = GetFileIdentity(scenario.Destination); + Assert.NotEqual(scenario.SourceIdentity, replacementIdentity); + + Assert.False(await CreateMover().MoveFilePreservingPhysicalIdentityAsync( + scenario.Source, + scenario.Destination, + scenario.SourceIdentity, + scenario.OperationId)); + + Assert.Equal("foreign", await File.ReadAllTextAsync(scenario.Destination)); + await AssertJournalStateAsync( + scenario.OperationId, + FileMutationJournalState.NeedsAttention, + targetIdentity: null); + AssertNoLibraryArtifacts(scenario.Root); + } + + private FileMover CreateMover( + Func? afterJournalPlanned = null, + Func? afterPublishedBeforeTargetState = null, + Func? afterTargetState = null) + { + var factory = _provider.GetRequiredService< + IDbContextFactory>(); + return new FileMover( + new NullLogger(), + dbContextFactory: factory, + timeProvider: TimeProvider.System) + { + FileMoveLockDirectoryForTest = FileService.GetTempDirectory( + "file-mover-markerless-locks"), + AfterMarkerlessRenameJournalPlannedForTestAsync = + afterJournalPlanned, + AfterMarkerlessRenamePublishedBeforeTargetStateForTestAsync = + afterPublishedBeforeTargetState, + AfterMarkerlessRenameTargetStateForTestAsync = afterTargetState + }; + } + + private async Task CreateScenarioAsync() + { + var root = FileService.GetTempDirectory( + "file-mover-markerless-rename"); + var source = Path.Join(root, "source.m4b"); + var destinationDirectory = Path.Join(root, "destination"); + Directory.CreateDirectory(destinationDirectory); + var destination = Path.Join(destinationDirectory, "renamed.m4b"); + await File.WriteAllTextAsync(source, "audio"); + return new Scenario( + root, + source, + destination, + GetFileIdentity(source), + Guid.NewGuid()); + } + + private async Task AssertJournalStateAsync( + Guid operationId, + FileMutationJournalState state, + string? targetIdentity) + { + var factory = _provider.GetRequiredService< + IDbContextFactory>(); + await using var db = await factory.CreateDbContextAsync(); + var journal = await db.FileMutationJournals + .AsNoTracking() + .SingleAsync(candidate => candidate.OperationId == operationId); + Assert.Equal(state, journal.State); + Assert.Equal(targetIdentity, journal.TargetPhysicalObjectIdentity); + } + + private static string GetFileIdentity(string path) + { + using var lease = PinnedAudiobookFileRegistrationLease.Open(path); + return lease.PhysicalObjectIdentity; + } + + private static void AssertNoLibraryArtifacts(string root) + { + Assert.DoesNotContain( + Directory.EnumerateFileSystemEntries( + root, + "*", + SearchOption.AllDirectories), + path => + { + var name = Path.GetFileName(path); + return name.Contains(".listenarr-", StringComparison.Ordinal) + || name.EndsWith(".partial", StringComparison.Ordinal) + || name.Contains("quarantine", StringComparison.OrdinalIgnoreCase); + }); + } + + private sealed record Scenario( + string Root, + string Source, + string Destination, + string SourceIdentity, + Guid OperationId); +} diff --git a/tests/Features/Infrastructure/FileSystem/FileMutationJournalStoreTests.cs b/tests/Features/Infrastructure/FileSystem/FileMutationJournalStoreTests.cs new file mode 100644 index 000000000..eae3ed64b --- /dev/null +++ b/tests/Features/Infrastructure/FileSystem/FileMutationJournalStoreTests.cs @@ -0,0 +1,244 @@ +using Microsoft.EntityFrameworkCore; + +using Listenarr.Tests.Common; + +namespace Listenarr.Tests.Features.Infrastructure.FileSystem; + +[Trait("Name", "FileMutationJournalStoreTests")] +[Trait("Category", "Infrastructure")] +public sealed class FileMutationJournalStoreTests : BaseTests +{ + [Fact] + public async Task GetOrCreateAsync_ExactRetryReturnsExistingJournal() + { + var operationId = Guid.NewGuid(); + var claim = CreateClaim(operationId); + var store = CreateStore(); + + var created = await store.GetOrCreateAsync( + claim, + CancellationToken.None); + var retried = await store.GetOrCreateAsync( + claim, + CancellationToken.None); + + Assert.Equal(operationId, created.OperationId); + Assert.Equal(FileMutationJournalState.Planned, created.State); + Assert.Equal(created.OperationId, retried.OperationId); + var factory = _provider.GetRequiredService< + IDbContextFactory>(); + await using var db = await factory.CreateDbContextAsync(); + Assert.Equal(1, await db.FileMutationJournals.CountAsync()); + } + + [Fact] + public async Task GetOrCreateAsync_ReusedOperationForDifferentIdentityFailsClosed() + { + var operationId = Guid.NewGuid(); + var claim = CreateClaim(operationId); + var store = CreateStore(); + await store.GetOrCreateAsync(claim, CancellationToken.None); + + var exception = await Assert.ThrowsAsync(() => + store.GetOrCreateAsync( + claim with + { + DestinationPath = Path.Join( + FileService.GetTempPath(), + "different-destination.m4b") + }, + CancellationToken.None)); + + Assert.Contains( + "another file-mutation identity", + exception.Message, + StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task AdvanceAsync_IsMonotonicAndGenerationBound() + { + var operationId = Guid.NewGuid(); + var claim = CreateClaim(operationId); + var store = CreateStore(); + await store.GetOrCreateAsync(claim, CancellationToken.None); + + var persisted = await store.AdvanceAsync( + operationId, + FileMutationJournalState.TargetIdentityPersisted, + "target-generation", + audiobookId: null, + error: null, + CancellationToken.None); + Assert.Equal( + FileMutationJournalState.TargetIdentityPersisted, + persisted.State); + Assert.Equal( + "target-generation", + persisted.TargetPhysicalObjectIdentity); + + persisted = await store.AdvanceAsync( + operationId, + FileMutationJournalState.TargetVerified, + "target-generation", + audiobookId: 42, + error: null, + CancellationToken.None); + Assert.Equal(FileMutationJournalState.TargetVerified, persisted.State); + Assert.Equal(42, persisted.AudiobookId); + + await Assert.ThrowsAsync(() => + store.AdvanceAsync( + operationId, + FileMutationJournalState.Planned, + "target-generation", + audiobookId: 42, + error: null, + CancellationToken.None)); + await Assert.ThrowsAsync(() => + store.AdvanceAsync( + operationId, + FileMutationJournalState.TargetVerified, + "replacement-generation", + audiobookId: 42, + error: null, + CancellationToken.None)); + await Assert.ThrowsAsync(() => + store.AdvanceAsync( + operationId, + FileMutationJournalState.TargetVerified, + "target-generation", + audiobookId: 43, + error: null, + CancellationToken.None)); + } + + [Fact] + public async Task AdvanceAsync_StaleWriterCannotRegressConcurrentHigherState() + { + var databasePath = Path.Join( + FileService.GetTempPath(), + $"file-mutation-cas-{Guid.NewGuid():N}.db"); + var options = new DbContextOptionsBuilder() + .UseSqlite($"Data Source={databasePath}") + .Options; + var factory = new TestDbContextFactory(options); + await using (var db = await factory.CreateDbContextAsync()) + { + await db.Database.EnsureCreatedAsync(); + } + + var operationId = Guid.NewGuid(); + var claim = CreateClaim(operationId); + var setupStore = CreateStore(factory); + await setupStore.GetOrCreateAsync(claim, CancellationToken.None); + await setupStore.AdvanceAsync( + operationId, + FileMutationJournalState.TargetVerified, + "target-generation", + audiobookId: null, + error: null, + CancellationToken.None); + + var staleWriterLoaded = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var releaseStaleWriter = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var staleStore = CreateStore(factory); + staleStore.AfterAdvanceLoadedForTestAsync = async () => + { + staleWriterLoaded.TrySetResult(); + await releaseStaleWriter.Task; + }; + var staleAdvance = staleStore.AdvanceAsync( + operationId, + FileMutationJournalState.RegistrationCommitted, + "target-generation", + audiobookId: 42, + error: null, + CancellationToken.None); + await staleWriterLoaded.Task; + + var currentStore = CreateStore(factory); + var current = await currentStore.AdvanceAsync( + operationId, + FileMutationJournalState.SourceDeletionAuthorized, + "target-generation", + audiobookId: 42, + error: null, + CancellationToken.None); + Assert.Equal( + FileMutationJournalState.SourceDeletionAuthorized, + current.State); + + releaseStaleWriter.TrySetResult(); + await Assert.ThrowsAsync(async () => + await staleAdvance); + + var persisted = await currentStore.GetAsync( + operationId, + CancellationToken.None); + Assert.NotNull(persisted); + Assert.Equal( + FileMutationJournalState.SourceDeletionAuthorized, + persisted.State); + Assert.Equal(42, persisted.AudiobookId); + } + + [Fact] + public async Task AdvanceAsync_NeedsAttentionIsTerminal() + { + var operationId = Guid.NewGuid(); + var store = CreateStore(); + await store.GetOrCreateAsync( + CreateClaim(operationId), + CancellationToken.None); + var blocked = await store.AdvanceAsync( + operationId, + FileMutationJournalState.NeedsAttention, + targetPhysicalObjectIdentity: null, + audiobookId: null, + error: "unproven final file", + CancellationToken.None); + + Assert.Equal(FileMutationJournalState.NeedsAttention, blocked.State); + Assert.Equal("unproven final file", blocked.Error); + await Assert.ThrowsAsync(() => + store.AdvanceAsync( + operationId, + FileMutationJournalState.TargetIdentityPersisted, + "target-generation", + audiobookId: null, + error: null, + CancellationToken.None)); + } + + private EfFileMutationJournalStore CreateStore() => + CreateStore(_provider.GetRequiredService< + IDbContextFactory>()); + + private static EfFileMutationJournalStore CreateStore( + IDbContextFactory factory) => + new(factory, TimeProvider.System); + + private FileMutationJournalClaim CreateClaim(Guid operationId) => + new( + operationId, + FileAction.Move, + Path.Join(FileService.GetTempPath(), "source.m4b"), + Path.Join(FileService.GetTempPath(), "destination.m4b"), + "source-generation", + SourceLength: 123, + SourceSha256: new string('A', 64)); + + private sealed class TestDbContextFactory( + DbContextOptions options) : + IDbContextFactory + { + public ListenArrDbContext CreateDbContext() => new(options); + + public Task CreateDbContextAsync( + CancellationToken cancellationToken = default) => + Task.FromResult(CreateDbContext()); + } +} diff --git a/tests/Features/Infrastructure/FileSystem/FileSystemSemanticsResolverTests.cs b/tests/Features/Infrastructure/FileSystem/FileSystemSemanticsResolverTests.cs index d865c73a7..8aacfc6d9 100644 --- a/tests/Features/Infrastructure/FileSystem/FileSystemSemanticsResolverTests.cs +++ b/tests/Features/Infrastructure/FileSystem/FileSystemSemanticsResolverTests.cs @@ -1,4 +1,4 @@ -using System.Runtime.InteropServices; +using System.Runtime.Versioning; using Listenarr.Tests.Common; namespace Listenarr.Tests.Features.Infrastructure.FileSystem; @@ -11,7 +11,7 @@ public sealed class FileSystemSemanticsResolverTests : BaseTests [InlineData("")] [InlineData("relative/path")] [InlineData("relative\0path")] - public async Task ResolveAsync_RejectsInvalidOrRelativePathBeforeProbing(string path) + public async Task ResolveAsync_RejectsInvalidOrRelativePath(string path) { var resolver = new FileSystemSemanticsResolver(); @@ -20,77 +20,90 @@ await Assert.ThrowsAnyAsync(async () => } [Fact] - public async Task ExplicitOverride_ResolvesWithoutExistingPath() + public async Task ExplicitOverride_ResolvesMissingPathWithoutFilesystemWrites() { - var probes = 0; - var resolver = new FileSystemSemanticsResolver + var parent = Path.Join( + Path.GetTempPath(), + "filesystem-semantics-explicit-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(parent); + try { - BeforeProbeForTest = _ => probes++ - }; - var missingPath = Path.Join(Path.GetTempPath(), Guid.NewGuid().ToString("N"), "books"); + var missingPath = Path.Join(parent, "future", "books"); + var before = Snapshot(parent); - var resolution = await resolver.ResolveAsync( - missingPath, - FileSystemCaseSensitivityMode.Sensitive); + var resolution = await new FileSystemSemanticsResolver().ResolveAsync( + missingPath, + FileSystemCaseSensitivityMode.Sensitive); - Assert.Equal(FileSystemCaseSensitivity.Sensitive, resolution.Semantics.CaseSensitivity); - Assert.Equal(PathIdentityState.Valid, resolution.State); - Assert.Equal(0, probes); + Assert.Equal(PathIdentityState.Valid, resolution.State); + Assert.Equal( + FileSystemCaseSensitivity.Sensitive, + resolution.Semantics.CaseSensitivity); + Assert.Equal(before, Snapshot(parent)); + Assert.False(Directory.Exists(Path.Join(parent, "future"))); + } + finally + { + Directory.Delete(parent, true); + } } [Fact] - public async Task AutoProbe_RepeatedBoundary_IsProbedIndependently() + public async Task Auto_ExistingBoundary_DoesNotCreateOrModifyEntries() { - var root = Path.Join(Path.GetTempPath(), "filesystem-semantics-" + Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(root); - var probes = 0; - var resolver = new FileSystemSemanticsResolver - { - BeforeProbeForTest = boundary => - { - Assert.Equal(Path.GetFullPath(root), Path.GetFullPath(boundary)); - probes++; - } - }; + var boundary = Path.Join( + Path.GetTempPath(), + "filesystem-semantics-auto-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(boundary); + await File.WriteAllTextAsync(Path.Join(boundary, "existing.txt"), "unchanged"); try { - var first = await resolver.ResolveAsync(root, FileSystemCaseSensitivityMode.Auto); - var second = await resolver.ResolveAsync(root, FileSystemCaseSensitivityMode.Auto); + var before = Snapshot(boundary); - Assert.Equal(PathIdentityState.Valid, first.State); - Assert.Equal(PathIdentityState.Valid, second.State); - Assert.Equal(2, probes); - Assert.Empty(Directory.EnumerateFileSystemEntries(root, ".listenarr-case-probe-*")); + var resolution = await new FileSystemSemanticsResolver().ResolveAsync( + boundary, + FileSystemCaseSensitivityMode.Auto); + + AssertReadOnlyResolutionForCurrentHost(resolution); + Assert.Equal(before, Snapshot(boundary)); + Assert.Equal("unchanged", await File.ReadAllTextAsync( + Path.Join(boundary, "existing.txt"))); } finally { - Directory.Delete(root, true); + Directory.Delete(boundary, true); } } [Fact] - public async Task AutoProbe_ExistingBoundary_ProbesWithinBoundaryAndRemovesProbeFile() + public async Task Auto_MissingDescendant_UsesExistingBoundaryWithoutCreatingIt() { - var root = Path.Join(Path.GetTempPath(), "filesystem-semantics-" + Guid.NewGuid().ToString("N")); - var boundary = Path.Join(root, "Books"); + var boundary = Path.Join( + Path.GetTempPath(), + "filesystem-semantics-future-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(boundary); - var resolver = new FileSystemSemanticsResolver(); try { - var resolution = await resolver.ResolveAsync(boundary, FileSystemCaseSensitivityMode.Auto); + var requested = Path.Join(boundary, "future", "books"); + var before = Snapshot(boundary); - Assert.NotEqual(FileSystemCaseSensitivity.Unknown, resolution.Semantics.CaseSensitivity); - Assert.Equal(PathIdentityState.Valid, resolution.State); - Assert.Empty(Directory.EnumerateFileSystemEntries(boundary, ".listenarr-case-probe-*")); + var resolution = await new FileSystemSemanticsResolver().ResolveAsync( + requested, + FileSystemCaseSensitivityMode.Auto); + + AssertReadOnlyResolutionForCurrentHost(resolution); + Assert.Equal(Path.GetFullPath(requested), resolution.CanonicalPath); + Assert.Equal(before, Snapshot(boundary)); + Assert.False(Directory.Exists(Path.Join(boundary, "future"))); } finally { - Directory.Delete(root, true); + Directory.Delete(boundary, true); } } [DirectoryLinkFact] - public async Task AutoProbe_LinkedBoundary_ProbesPinnedPhysicalDirectoryAndRemovesProbeFiles() + public async Task Auto_LinkedBoundary_DoesNotWriteThroughLink() { var root = Path.Join( Path.GetTempPath(), @@ -98,22 +111,19 @@ public async Task AutoProbe_LinkedBoundary_ProbesPinnedPhysicalDirectoryAndRemov var physical = Path.Join(root, "physical"); var linked = Path.Join(root, "linked"); Directory.CreateDirectory(physical); + await File.WriteAllTextAsync(Path.Join(physical, "existing.txt"), "unchanged"); Directory.CreateSymbolicLink(linked, physical); - var resolver = new FileSystemSemanticsResolver(); try { - var resolution = await resolver.ResolveAsync( + var before = Snapshot(physical); + + var resolution = await new FileSystemSemanticsResolver().ResolveAsync( linked, FileSystemCaseSensitivityMode.Auto); - Assert.Equal(PathIdentityState.Valid, resolution.State); - Assert.NotEqual( - FileSystemCaseSensitivity.Unknown, - resolution.Semantics.CaseSensitivity); + AssertReadOnlyResolutionForCurrentHost(resolution); Assert.Equal(Path.GetFullPath(linked), resolution.CanonicalPath); - Assert.Empty(Directory.EnumerateFileSystemEntries( - physical, - ".listenarr-case-probe-*")); + Assert.Equal(before, Snapshot(physical)); } finally { @@ -122,189 +132,97 @@ public async Task AutoProbe_LinkedBoundary_ProbesPinnedPhysicalDirectoryAndRemov } } - [Fact] - public async Task AutoProbe_ResolvesAndRemovesProbeFile() - { - var root = Path.Join(Path.GetTempPath(), "filesystem-semantics-" + Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(root); - var resolver = new FileSystemSemanticsResolver(); - try - { - var resolution = await resolver.ResolveAsync( - Path.Join(root, "future", "books"), - FileSystemCaseSensitivityMode.Auto); - - Assert.NotEqual(FileSystemCaseSensitivity.Unknown, resolution.Semantics.CaseSensitivity); - Assert.Equal(PathIdentityState.Valid, resolution.State); - Assert.Empty(Directory.EnumerateFiles(root, ".listenarr-case-probe-*")); - } - finally - { - Directory.Delete(root, true); - } - } - - [Fact] - public async Task AutoProbe_PrimaryGenerationIsReplaced_PreservesReplacementAndReturnsUnavailable() + [LinuxFact] + [SupportedOSPlatform("linux")] + public async Task Auto_ReadOnlyBoundary_ResolvesWithoutRequiringWritePermission() { - var root = Path.Join( + var boundary = Path.Join( Path.GetTempPath(), - "filesystem-semantics-race-" + Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(root); - string? replacementPath = null; - var resolver = new FileSystemSemanticsResolver - { - AfterPrimaryProbeCreatedForTest = (primaryPath, _) => - { - File.Delete(primaryPath); - File.WriteAllText(primaryPath, "replacement"); - replacementPath = primaryPath; - } - }; + "filesystem-semantics-readonly-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(boundary); + await File.WriteAllTextAsync(Path.Join(boundary, "existing.txt"), "unchanged"); + var originalMode = File.GetUnixFileMode(boundary); try { - var resolution = await resolver.ResolveAsync( - root, + File.SetUnixFileMode( + boundary, + UnixFileMode.UserRead | UnixFileMode.UserExecute + | UnixFileMode.GroupRead | UnixFileMode.GroupExecute + | UnixFileMode.OtherRead | UnixFileMode.OtherExecute); + var before = Snapshot(boundary); + + var resolution = await new FileSystemSemanticsResolver().ResolveAsync( + boundary, FileSystemCaseSensitivityMode.Auto); - Assert.Equal(PathIdentityState.Unavailable, resolution.State); - Assert.Equal( + Assert.Equal(PathIdentityState.Valid, resolution.State); + Assert.NotEqual( FileSystemCaseSensitivity.Unknown, resolution.Semantics.CaseSensitivity); - Assert.NotNull(replacementPath); - Assert.Equal("replacement", await File.ReadAllTextAsync(replacementPath)); + Assert.Equal(before, Snapshot(boundary)); } finally { - Directory.Delete(root, true); + File.SetUnixFileMode(boundary, originalMode); + Directory.Delete(boundary, true); } } - [LinuxFact] - public async Task AutoProbe_AlternateSpellingIsOccupied_PreservesUnownedEntryAndReturnsUnavailable() + [Fact] + public async Task Auto_RepeatedResolution_NeverPublishesProbeArtifacts() { - var root = Path.Join( + var boundary = Path.Join( Path.GetTempPath(), - "filesystem-semantics-alternate-" + Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(root); - var capabilityLower = Path.Join(root, "case-capability-a"); - var capabilityUpper = Path.Join(root, "CASE-CAPABILITY-A"); - await File.WriteAllTextAsync(capabilityLower, "lower"); + "filesystem-semantics-repeat-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(boundary); try { - await using var alternateCapability = new FileStream( - capabilityUpper, - FileMode.CreateNew, - FileAccess.Write, - FileShare.Read); - } - catch (IOException exception) - { - Directory.Delete(root, true); - throw new Xunit.Sdk.XunitException( - $"This regression requires a case-sensitive native filesystem: {exception.Message}"); - } + var before = Snapshot(boundary); + var resolver = new FileSystemSemanticsResolver(); - File.Delete(capabilityLower); - File.Delete(capabilityUpper); - string? occupiedAlternate = null; - var resolver = new FileSystemSemanticsResolver - { - AfterPrimaryProbeCreatedForTest = (_, alternatePath) => - { - File.WriteAllText(alternatePath, "external"); - occupiedAlternate = alternatePath; - } - }; - try - { - var resolution = await resolver.ResolveAsync( - root, - FileSystemCaseSensitivityMode.Auto); + var first = await resolver.ResolveAsync(boundary); + var second = await resolver.ResolveAsync(boundary); - Assert.Equal(PathIdentityState.Unavailable, resolution.State); - Assert.Equal( - FileSystemCaseSensitivity.Unknown, - resolution.Semantics.CaseSensitivity); - Assert.NotNull(occupiedAlternate); - Assert.Equal("external", await File.ReadAllTextAsync(occupiedAlternate)); + Assert.Equal(first.State, second.State); + Assert.Equal(first.Semantics, second.Semantics); + Assert.Equal(before, Snapshot(boundary)); + Assert.DoesNotContain( + Directory.EnumerateFileSystemEntries(boundary), + entry => Path.GetFileName(entry).StartsWith( + ".listenarr-", + StringComparison.Ordinal)); } finally { - Directory.Delete(root, true); + Directory.Delete(boundary, true); } } - [LinuxFact] - public async Task AutoProbe_AlternateSpellingHardlinkSpoof_ReturnsUnavailableAndPreservesUnownedLink() + private static void AssertReadOnlyResolutionForCurrentHost( + FileSystemSemanticsResolution resolution) { - var root = Path.Join( - Path.GetTempPath(), - "filesystem-semantics-hardlink-" + Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(root); - var capabilityLower = Path.Join(root, "case-capability-a"); - var capabilityUpper = Path.Join(root, "CASE-CAPABILITY-A"); - await File.WriteAllTextAsync(capabilityLower, "lower"); - if (!TryCreateHardLink(capabilityUpper, capabilityLower)) + if (OperatingSystem.IsWindows() || OperatingSystem.IsLinux()) { - Directory.Delete(root, true); - Assert.Fail("The required hard link could not be created."); - } - - File.Delete(capabilityLower); - File.Delete(capabilityUpper); - string? spoofedAlternate = null; - var resolver = new FileSystemSemanticsResolver - { - AfterPrimaryProbeCreatedForTest = (primaryPath, alternatePath) => - { - Assert.True(TryCreateHardLink(alternatePath, primaryPath)); - spoofedAlternate = alternatePath; - } - }; - try - { - var resolution = await resolver.ResolveAsync( - root, - FileSystemCaseSensitivityMode.Auto); - - Assert.Equal(PathIdentityState.Unavailable, resolution.State); - Assert.Equal( + Assert.Equal(PathIdentityState.Valid, resolution.State); + Assert.NotEqual( FileSystemCaseSensitivity.Unknown, resolution.Semantics.CaseSensitivity); - Assert.NotNull(spoofedAlternate); - Assert.True(File.Exists(spoofedAlternate)); + return; } - finally - { - Directory.Delete(root, true); - } - } - private static bool TryCreateHardLink(string linkPath, string existingPath) - { - try - { - return OperatingSystem.IsWindows() - ? CreateHardLinkWindows(linkPath, existingPath, IntPtr.Zero) - : LinkUnix(existingPath, linkPath) == 0; - } - catch (Exception exception) when (exception is - IOException or UnauthorizedAccessException or PlatformNotSupportedException) - { - return false; - } + Assert.Equal(PathIdentityState.Unavailable, resolution.State); + Assert.Equal( + FileSystemCaseSensitivity.Unknown, + resolution.Semantics.CaseSensitivity); + Assert.Contains( + "Select Sensitive or Insensitive explicitly", + resolution.Reason ?? string.Empty, + StringComparison.Ordinal); } - [DllImport("kernel32.dll", EntryPoint = "CreateHardLinkW", CharSet = CharSet.Unicode, SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - private static extern bool CreateHardLinkWindows( - string fileName, - string existingFileName, - IntPtr securityAttributes); - - [DllImport("libc", EntryPoint = "link", SetLastError = true)] - private static extern int LinkUnix( - [MarshalAs(UnmanagedType.LPUTF8Str)] string existingPath, - [MarshalAs(UnmanagedType.LPUTF8Str)] string newPath); + private static IReadOnlyList Snapshot(string directory) => + Directory.EnumerateFileSystemEntries(directory, "*", SearchOption.AllDirectories) + .Select(path => Path.GetRelativePath(directory, path)) + .OrderBy(path => path, StringComparer.Ordinal) + .ToArray(); } diff --git a/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceTests.cs b/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceTests.cs index e774f5518..4826d91eb 100644 --- a/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceTests.cs +++ b/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceTests.cs @@ -647,14 +647,14 @@ public async Task FinalizeMove_SourceEqualsCleanupBoundary_PreservesBoundaryDire } [Fact] - public async Task MoveContents_SourceAtManagedRoot_PreservesRootEnrollmentMarker() + public async Task MoveContents_SourceAtManagedRoot_DoesNotCreateRootEnrollmentMarker() { var source = FileService.GetTempDirectory("content-move-managed-root-source"); await FileService.GetFileAsync(source, "book.m4b", "audio"); - var enrollment = await new DirectoryObjectIdentityResolver().ResolveAsync(source); - Assert.True(enrollment.IsAvailable, enrollment.UnavailableReason); + var identity = await new DirectoryObjectIdentityResolver().ResolveAsync(source); + Assert.True(identity.IsAvailable, identity.UnavailableReason); var enrollmentMarker = Path.Join(source, ManagedDirectoryEnrollment.FileName); - var originalEnrollment = await File.ReadAllTextAsync(enrollmentMarker); + Assert.False(File.Exists(enrollmentMarker)); var target = Path.Join( FileService.GetTempPath(), $"content-move-managed-root-target-{Guid.NewGuid():N}"); @@ -666,8 +666,7 @@ public async Task MoveContents_SourceAtManagedRoot_PreservesRootEnrollmentMarker sourceCleanupBoundary: source); var result = await service.MoveContentsAsync(request, CancellationToken.None); - Assert.True(File.Exists(enrollmentMarker)); - Assert.Equal(originalEnrollment, await File.ReadAllTextAsync(enrollmentMarker)); + Assert.False(File.Exists(enrollmentMarker)); Assert.False(File.Exists(Path.Join(target, ManagedDirectoryEnrollment.FileName))); Assert.True(File.Exists(Path.Join(target, "book.m4b"))); @@ -677,8 +676,8 @@ await service.CleanupCompletedMoveArtifactsAsync( result, CancellationToken.None); - Assert.True(File.Exists(enrollmentMarker)); - Assert.Equal(originalEnrollment, await File.ReadAllTextAsync(enrollmentMarker)); + Assert.False(File.Exists(enrollmentMarker)); + Assert.False(File.Exists(Path.Join(target, ManagedDirectoryEnrollment.FileName))); } [Fact] @@ -704,8 +703,7 @@ public async Task FinalizeMove_ExistingEmptyTarget_PrunesSourceParentAfterNested Assert.False(Directory.Exists(source)); Assert.True(Directory.Exists(oldTitle)); - Assert.Single(Directory.EnumerateFileSystemEntries(oldTitle)); - Assert.True(File.Exists(Path.Join(oldTitle, LibraryDirectoryOwnershipMarker.FileName))); + Assert.Empty(Directory.EnumerateFileSystemEntries(oldTitle)); Assert.True(File.Exists(result.RecoveryMarkerPath)); await service.FinalizeMoveAsync(request, result, CancellationToken.None); @@ -833,7 +831,6 @@ public async Task FinalizeMove_RetryAfterImmediateParentRemoved_PrunesHigherEmpt var ownership = Assert.IsType(resolution.Ownership); var ownershipKey = Assert.IsType(ownership.PathOwnershipKey); await ownershipStore.BeginRemovalAsync(ownership.Id, ownershipKey); - LibraryDirectoryOwnershipMarker.DeleteInsideMarker(ownership, oldTitle); Directory.Delete(oldTitle, false); Assert.True(Directory.Exists(author)); Assert.False(Directory.Exists(oldTitle)); @@ -848,7 +845,7 @@ public async Task FinalizeMove_RetryAfterImmediateParentRemoved_PrunesHigherEmpt } [Fact] - public async Task FinalizeMove_LiveRemovingPathWithoutInsideMarkerRequiresAttention() + public async Task FinalizeMove_LiveRemovingEmptyPath_CompletesWithoutMarkerProof() { var sourceRoot = FileService.GetTempDirectory("content-move-finalize-predelete-retry-root"); var oldTitle = Path.Join(sourceRoot, "Author", "Old Title"); @@ -873,22 +870,18 @@ public async Task FinalizeMove_LiveRemovingPathWithoutInsideMarkerRequiresAttent var ownership = Assert.IsType(resolution.Ownership); var ownershipKey = Assert.IsType(ownership.PathOwnershipKey); await ownershipStore.BeginRemovalAsync(ownership.Id, ownershipKey); - LibraryDirectoryOwnershipMarker.DeleteInsideMarker(ownership, oldTitle); Assert.True(Directory.Exists(oldTitle)); Assert.Empty(Directory.EnumerateFileSystemEntries(oldTitle)); - var exception = await Assert.ThrowsAsync(() => - service.FinalizeMoveAsync(request, result, CancellationToken.None)); + await service.FinalizeMoveAsync(request, result, CancellationToken.None); - Assert.Contains("could not be proven safe", exception.Message, StringComparison.OrdinalIgnoreCase); - Assert.True(Directory.Exists(oldTitle)); - Assert.Empty(Directory.EnumerateFileSystemEntries(oldTitle)); + Assert.False(Directory.Exists(oldTitle)); var factory = _provider.GetRequiredService>(); await using var db = await factory.CreateDbContextAsync(); var persisted = await db.LibraryDirectoryOwnerships.SingleAsync( candidate => candidate.Id == ownership.Id); - Assert.Equal(LibraryDirectoryOwnershipState.Removing, persisted.State); - Assert.Equal(ownershipKey, persisted.PathOwnershipKey); + Assert.Equal(LibraryDirectoryOwnershipState.Removed, persisted.State); + Assert.Null(persisted.PathOwnershipKey); } [Fact] @@ -972,7 +965,7 @@ public async Task FinalizeMove_RecreatedOriginalBesideQuarantineRequiresAttentio } [Fact] - public async Task FinalizeMove_MarkRemovedFailure_PreservesSiblingProofForRetry() + public async Task FinalizeMove_MarkRemovedFailure_RetriesFromDatabaseIntent() { var sourceRoot = FileService.GetTempDirectory("content-move-finalize-mark-removed-failure-root"); var oldTitle = Path.Join(sourceRoot, "Author", "Old Title"); @@ -996,11 +989,6 @@ public async Task FinalizeMove_MarkRemovedFailure_PreservesSiblingProofForRetry( FileSystemPathSemantics.CurrentHostDefault); var ownership = Assert.IsType(resolution.Ownership); var ownershipKey = Assert.IsType(ownership.PathOwnershipKey); - var siblingMarker = LibraryDirectoryOwnershipMarker.GetMarkerPaths(ownership) - .Single(path => !FileSystemPathIdentity.IsSameOrInside( - path, - oldTitle, - FileSystemPathSemantics.CurrentHostDefault)); var factory = _provider.GetRequiredService>(); var failingService = new AudiobookContentMoveService( NullLogger.Instance, @@ -1012,7 +1000,6 @@ await Assert.ThrowsAsync(() => failingService.FinalizeMoveAsync(request, result, CancellationToken.None)); Assert.False(Directory.Exists(oldTitle)); - Assert.True(File.Exists(siblingMarker)); await using (var failedDb = await factory.CreateDbContextAsync()) { var interrupted = await failedDb.LibraryDirectoryOwnerships.AsNoTracking() @@ -1023,7 +1010,6 @@ await Assert.ThrowsAsync(() => await normalService.FinalizeMoveAsync(request, result, CancellationToken.None); - Assert.False(File.Exists(siblingMarker)); await using var recoveredDb = await factory.CreateDbContextAsync(); var recovered = await recoveredDb.LibraryDirectoryOwnerships.AsNoTracking() .SingleAsync(candidate => candidate.Id == ownership.Id); @@ -1032,7 +1018,7 @@ await Assert.ThrowsAsync(() => } [Fact] - public async Task FinalizeMove_LiveRemovingPathWithNewContentRequiresAttention() + public async Task FinalizeMove_LiveRemovingPathWithNewContent_RetainsDirectory() { var sourceRoot = FileService.GetTempDirectory("content-move-finalize-predelete-arrival-root"); var oldTitle = Path.Join(sourceRoot, "Author", "Old Title"); @@ -1057,24 +1043,21 @@ public async Task FinalizeMove_LiveRemovingPathWithNewContentRequiresAttention() var ownership = Assert.IsType(resolution.Ownership); var ownershipKey = Assert.IsType(ownership.PathOwnershipKey); await ownershipStore.BeginRemovalAsync(ownership.Id, ownershipKey); - LibraryDirectoryOwnershipMarker.DeleteInsideMarker(ownership, oldTitle); await File.WriteAllTextAsync(Path.Join(oldTitle, "arrived-late.txt"), "keep"); - var exception = await Assert.ThrowsAsync(() => - service.FinalizeMoveAsync(request, result, CancellationToken.None)); + await service.FinalizeMoveAsync(request, result, CancellationToken.None); - Assert.Contains("could not be proven safe", exception.Message, StringComparison.OrdinalIgnoreCase); Assert.True(Directory.Exists(oldTitle)); Assert.True(File.Exists(Path.Join(oldTitle, "arrived-late.txt"))); var interrupted = await ownershipStore.ResolveOwnedAsync( oldTitle, FileSystemPathSemantics.CurrentHostDefault); - Assert.Equal(LibraryDirectoryOwnershipState.Removing, interrupted.Ownership?.State); + Assert.Equal(LibraryDirectoryOwnershipState.Retained, interrupted.Ownership?.State); Assert.Equal(ownershipKey, interrupted.Ownership?.PathOwnershipKey); } [Fact] - public async Task FinalizeMove_MissingRemovedDirectoryWithoutSiblingProofRequiresAttention() + public async Task FinalizeMove_MissingRemovedDirectory_ConvergesFromDatabaseIntent() { var sourceRoot = FileService.GetTempDirectory("content-move-missing-parent-proof-root"); var oldTitle = Path.Join(sourceRoot, "Author", "Old Title"); @@ -1099,26 +1082,20 @@ public async Task FinalizeMove_MissingRemovedDirectoryWithoutSiblingProofRequire var ownership = Assert.IsType(resolution.Ownership); var ownershipKey = Assert.IsType(ownership.PathOwnershipKey); await ownershipStore.BeginRemovalAsync(ownership.Id, ownershipKey); - foreach (var markerPath in LibraryDirectoryOwnershipMarker.GetMarkerPaths(ownership)) - { - File.Delete(markerPath); - } Directory.Delete(oldTitle, false); - var exception = await Assert.ThrowsAsync(() => - service.FinalizeMoveAsync(request, result, CancellationToken.None)); + await service.FinalizeMoveAsync(request, result, CancellationToken.None); - Assert.Contains("sibling ownership proof", exception.Message, StringComparison.OrdinalIgnoreCase); var factory = _provider.GetRequiredService>(); await using var db = await factory.CreateDbContextAsync(); var persisted = await db.LibraryDirectoryOwnerships.AsNoTracking() .SingleAsync(candidate => candidate.Id == ownership.Id); - Assert.Equal(LibraryDirectoryOwnershipState.Removing, persisted.State); - Assert.Equal(ownershipKey, persisted.PathOwnershipKey); + Assert.Equal(LibraryDirectoryOwnershipState.Removed, persisted.State); + Assert.Null(persisted.PathOwnershipKey); } [Fact] - public async Task FinalizeMove_OwnershipMarkerMissing_PreservesDirectoryAndRequiresAttention() + public async Task FinalizeMove_MarkerlessOwnership_PrunesDirectory() { var sourceRoot = FileService.GetTempDirectory("content-move-missing-ownership-marker"); var oldTitle = Path.Join(sourceRoot, "Author", "Old Title"); @@ -1134,13 +1111,10 @@ public async Task FinalizeMove_OwnershipMarkerMissing_PreservesDirectoryAndRequi target, sourceCleanupBoundary: sourceRoot); var result = await service.MoveContentsAsync(request, CancellationToken.None); - File.Delete(Path.Join(oldTitle, LibraryDirectoryOwnershipMarker.FileName)); - var exception = await Assert.ThrowsAsync(() => - service.FinalizeMoveAsync(request, result, CancellationToken.None)); + await service.FinalizeMoveAsync(request, result, CancellationToken.None); - Assert.Contains("ownership proof is unavailable", exception.Message, StringComparison.OrdinalIgnoreCase); - Assert.True(Directory.Exists(oldTitle)); + Assert.False(Directory.Exists(oldTitle)); Assert.True(File.Exists(result.RecoveryMarkerPath)); } @@ -1579,6 +1553,747 @@ public async Task MoveContentsAsync_TargetChangesAfterPublish_BlocksSourceCleanu Assert.True(File.Exists(Path.Join(target, "arrived-late.txt"))); } + [Fact] + public async Task MoveContentsAsync_MarkerlessProtocol_WritesOnlyFinalUserContent() + { + var root = FileService.GetTempDirectory("content-move-markerless-root"); + var source = Path.Join(root, "source"); + var sourceDisc = Path.Join(source, "Disc 01"); + Directory.CreateDirectory(sourceDisc); + await FileService.GetFileAsync(sourceDisc, "book.m4b", "audio"); + await FileService.GetFileAsync(source, "cover.jpg", "image"); + var target = Path.Join(root, "destination", "Author", "Book"); + var request = await CreateLeasedMoveRequestAsync( + source, + target, + sourceCleanupBoundary: root, + executionProtocolVersion: + MoveExecutionProtocol.MarkerlessDatabaseState); + var service = _provider.GetRequiredService(); + + var result = await service.MoveContentsAsync( + request, + CancellationToken.None); + await service.FinalizeMoveAsync( + request, + result, + CancellationToken.None); + await service.CleanupCompletedMoveArtifactsAsync( + request, + result, + CancellationToken.None); + + Assert.Equal(string.Empty, result.RecoveryMarkerPath); + Assert.False(Directory.Exists(source)); + Assert.Equal("audio", await File.ReadAllTextAsync( + Path.Join(target, "Disc 01", "book.m4b"))); + Assert.Equal("image", await File.ReadAllTextAsync( + Path.Join(target, "cover.jpg"))); + AssertNoListenarrArtifacts(root); + + var factory = _provider.GetRequiredService< + IDbContextFactory>(); + await using var db = await factory.CreateDbContextAsync(); + var job = await db.MoveJobs + .Include(candidate => candidate.Entries) + .Include(candidate => candidate.CreatedDirectories) + .SingleAsync(candidate => candidate.Id == request.JobId); + Assert.Equal( + MoveExecutionProtocol.MarkerlessDatabaseState, + job.ExecutionProtocolVersion); + Assert.Equal( + MoveJobEntryCleanupState.Deleted, + job.SourceDirectoryCleanupState); + Assert.All( + job.Entries.Where(entry => + entry.EntryType == MoveJobEntryType.File), + entry => + { + Assert.Equal(MoveJobEntryCopyState.Verified, entry.CopyState); + Assert.Equal(MoveJobEntryCleanupState.Deleted, entry.CleanupState); + Assert.False(string.IsNullOrWhiteSpace( + entry.SourcePhysicalObjectIdentity)); + Assert.False(string.IsNullOrWhiteSpace( + entry.TargetPhysicalObjectIdentity)); + }); + Assert.All( + job.CreatedDirectories, + directory => Assert.Equal( + MoveCreatedDirectoryState.Retained, + directory.State)); + } + + [Fact] + public async Task MoveContentsAsync_MarkerlessPlanWithoutHashes_PersistsSourceProofBeforePublication() + { + var root = FileService.GetTempDirectory( + "content-move-markerless-worker-proof-root"); + var source = Path.Join(root, "source"); + Directory.CreateDirectory(source); + await FileService.GetFileAsync(source, "book.m4b", "audio"); + var target = Path.Join(root, "destination", "Book"); + var request = await CreateLeasedMoveRequestAsync( + source, + target, + sourceCleanupBoundary: root, + executionProtocolVersion: + MoveExecutionProtocol.MarkerlessDatabaseState); + var factory = _provider.GetRequiredService< + IDbContextFactory>(); + await using (var db = await factory.CreateDbContextAsync()) + { + var fileEntry = await db.MoveJobEntries.SingleAsync(entry => + entry.MoveJobId == request.JobId + && entry.EntryType == MoveJobEntryType.File); + fileEntry.Sha256 = null; + await db.SaveChangesAsync(); + } + + var progress = new List<(double Value, string Phase)>(); + request = request with + { + ProgressReporter = (value, phase, _) => + { + progress.Add((value, phase)); + return Task.CompletedTask; + } + }; + var service = new AudiobookContentMoveService( + _provider.GetRequiredService< + ILogger>(), + _provider.GetRequiredService< + IDbContextFactory>(), + TimeProvider.System, + new DisableMarkerlessFileRename()); + + var result = await service.MoveContentsAsync( + request, + CancellationToken.None); + + Assert.True(result.SourceCleanupCompleted); + Assert.Equal("audio", await File.ReadAllTextAsync( + Path.Join(target, "book.m4b"))); + Assert.Contains(progress, update => + string.Equals( + update.Phase, + "Verifying source", + StringComparison.Ordinal)); + Assert.Contains(progress, update => update.Value >= 25); + + await using var verificationDb = await factory.CreateDbContextAsync(); + var persisted = await verificationDb.MoveJobEntries + .AsNoTracking() + .SingleAsync(entry => + entry.MoveJobId == request.JobId + && entry.EntryType == MoveJobEntryType.File); + Assert.NotNull(persisted.Sha256); + Assert.Equal(64, persisted.Sha256!.Length); + Assert.False(string.IsNullOrWhiteSpace( + persisted.SourcePhysicalObjectIdentity)); + Assert.Equal(MoveJobEntryCopyState.Verified, persisted.CopyState); + } + + [Fact] + public async Task MoveContentsAsync_MarkerlessRetryAfterPublication_UsesDatabaseStateOnly() + { + var root = FileService.GetTempDirectory( + "content-move-markerless-retry-root"); + var source = Path.Join(root, "source"); + Directory.CreateDirectory(source); + await FileService.GetFileAsync(source, "book.m4b", "audio"); + var target = Path.Join(root, "destination", "Book"); + var request = await CreateLeasedMoveRequestAsync( + source, + target, + sourceCleanupBoundary: root, + executionProtocolVersion: + MoveExecutionProtocol.MarkerlessDatabaseState); + var interruptedService = new AudiobookContentMoveService( + _provider.GetRequiredService< + ILogger>(), + _provider.GetRequiredService< + IDbContextFactory>(), + TimeProvider.System, + new FailAfterPublishedOnce()); + + await Assert.ThrowsAsync(() => + interruptedService.MoveContentsAsync( + request, + CancellationToken.None)); + + Assert.True(File.Exists(Path.Join(source, "book.m4b"))); + Assert.Equal("audio", await File.ReadAllTextAsync( + Path.Join(target, "book.m4b"))); + AssertNoListenarrArtifacts(root); + + var service = _provider.GetRequiredService(); + var result = await service.MoveContentsAsync( + request, + CancellationToken.None); + await service.FinalizeMoveAsync( + request, + result, + CancellationToken.None); + await service.CleanupCompletedMoveArtifactsAsync( + request, + result, + CancellationToken.None); + + Assert.False(Directory.Exists(source)); + Assert.Equal("audio", await File.ReadAllTextAsync( + Path.Join(target, "book.m4b"))); + AssertNoListenarrArtifacts(root); + } + + [WindowsFact] + public async Task MoveContentsAsync_MarkerlessNativeRename_HoldsStableContentProofThroughFinalVerification() + { + var root = FileService.GetTempDirectory( + "content-move-markerless-stable-native-rename-root"); + var source = Path.Join(root, "source"); + Directory.CreateDirectory(source); + await FileService.GetFileAsync(source, "book.m4b", "audio"); + var target = Path.Join(root, "destination", "Book"); + var targetFile = Path.Join(target, "book.m4b"); + var request = await CreateLeasedMoveRequestAsync( + source, + target, + sourceCleanupBoundary: root, + executionProtocolVersion: + MoveExecutionProtocol.MarkerlessDatabaseState); + var factory = _provider.GetRequiredService< + IDbContextFactory>(); + await using (var db = await factory.CreateDbContextAsync()) + { + var entry = await db.MoveJobEntries.SingleAsync(candidate => + candidate.MoveJobId == request.JobId + && candidate.EntryType == MoveJobEntryType.File); + entry.Sha256 = null; + await db.SaveChangesAsync(); + } + var service = _provider.GetRequiredService(); + + var result = await service.MoveContentsAsync( + request, + CancellationToken.None); + + Assert.NotNull(result.TargetVerificationLease); + await using (var db = await factory.CreateDbContextAsync()) + { + var entry = await db.MoveJobEntries + .AsNoTracking() + .SingleAsync(candidate => + candidate.MoveJobId == request.JobId + && candidate.EntryType == MoveJobEntryType.File); + Assert.Null(entry.Sha256); + Assert.Equal( + entry.SourcePhysicalObjectIdentity, + entry.TargetPhysicalObjectIdentity); + } + Assert.ThrowsAny(() => + { + using var writer = new FileStream( + targetFile, + FileMode.Open, + FileAccess.Write, + FileShare.ReadWrite | FileShare.Delete); + }); + + await service.FinalizeMoveAsync( + request, + result, + CancellationToken.None); + await service.CleanupCompletedMoveArtifactsAsync( + request, + result, + CancellationToken.None); + + using (var writer = new FileStream( + targetFile, + FileMode.Open, + FileAccess.Write, + FileShare.ReadWrite | FileShare.Delete)) + { + Assert.True(writer.CanWrite); + } + Assert.Equal("audio", await File.ReadAllTextAsync(targetFile)); + AssertNoListenarrArtifacts(root); + } + + [Fact] + public async Task MoveContentsAsync_MarkerlessNativeRenameCrashBeforeStateCommit_ResumesByPhysicalGeneration() + { + var root = FileService.GetTempDirectory( + "content-move-markerless-native-rename-retry-root"); + var source = Path.Join(root, "source"); + Directory.CreateDirectory(source); + await FileService.GetFileAsync(source, "book.m4b", "audio"); + var sourceFile = Path.Join(source, "book.m4b"); + var target = Path.Join(root, "destination", "Book"); + var targetFile = Path.Join(target, "book.m4b"); + var request = await CreateLeasedMoveRequestAsync( + source, + target, + sourceCleanupBoundary: root, + executionProtocolVersion: + MoveExecutionProtocol.MarkerlessDatabaseState); + var factory = _provider.GetRequiredService< + IDbContextFactory>(); + await using (var db = await factory.CreateDbContextAsync()) + { + var entry = await db.MoveJobEntries.SingleAsync(candidate => + candidate.MoveJobId == request.JobId + && candidate.EntryType == MoveJobEntryType.File); + entry.Sha256 = null; + await db.SaveChangesAsync(); + } + var interruptedService = new AudiobookContentMoveService( + _provider.GetRequiredService< + ILogger>(), + _provider.GetRequiredService< + IDbContextFactory>(), + TimeProvider.System, + new FailOnceAfterMarkerlessNativeRename()); + + await Assert.ThrowsAsync(() => + interruptedService.MoveContentsAsync( + request, + CancellationToken.None)); + + Assert.False(File.Exists(sourceFile)); + Assert.Equal("audio", await File.ReadAllTextAsync(targetFile)); + AssertNoListenarrArtifacts(root); + + await using (var db = await factory.CreateDbContextAsync()) + { + var entry = await db.MoveJobEntries + .AsNoTracking() + .SingleAsync(candidate => + candidate.MoveJobId == request.JobId + && candidate.EntryType == MoveJobEntryType.File); + Assert.Equal(MoveJobEntryCopyState.Pending, entry.CopyState); + Assert.Null(entry.TargetPhysicalObjectIdentity); + Assert.False(string.IsNullOrWhiteSpace( + entry.SourcePhysicalObjectIdentity)); + Assert.Null(entry.Sha256); + } + + var service = _provider.GetRequiredService(); + var result = await service.MoveContentsAsync( + request, + CancellationToken.None); + await service.FinalizeMoveAsync( + request, + result, + CancellationToken.None); + await service.CleanupCompletedMoveArtifactsAsync( + request, + result, + CancellationToken.None); + + Assert.False(Directory.Exists(source)); + Assert.Equal("audio", await File.ReadAllTextAsync(targetFile)); + AssertNoListenarrArtifacts(root); + + await using (var db = await factory.CreateDbContextAsync()) + { + var entry = await db.MoveJobEntries + .AsNoTracking() + .SingleAsync(candidate => + candidate.MoveJobId == request.JobId + && candidate.EntryType == MoveJobEntryType.File); + Assert.Equal(MoveJobEntryCopyState.Verified, entry.CopyState); + Assert.Equal(MoveJobEntryCleanupState.Deleted, entry.CleanupState); + Assert.Equal( + entry.SourcePhysicalObjectIdentity, + entry.TargetPhysicalObjectIdentity); + } + } + + [Fact] + public async Task MoveContentsAsync_MarkerlessNativeRenameCrashThenTargetChanges_RequiresAttention() + { + var root = FileService.GetTempDirectory( + "content-move-markerless-native-rename-changed-retry-root"); + var source = Path.Join(root, "source"); + Directory.CreateDirectory(source); + await FileService.GetFileAsync(source, "book.m4b", "audio"); + var target = Path.Join(root, "destination", "Book"); + var targetFile = Path.Join(target, "book.m4b"); + var request = await CreateLeasedMoveRequestAsync( + source, + target, + sourceCleanupBoundary: root, + executionProtocolVersion: + MoveExecutionProtocol.MarkerlessDatabaseState); + var factory = _provider.GetRequiredService< + IDbContextFactory>(); + await using (var db = await factory.CreateDbContextAsync()) + { + var entry = await db.MoveJobEntries.SingleAsync(candidate => + candidate.MoveJobId == request.JobId + && candidate.EntryType == MoveJobEntryType.File); + entry.Sha256 = null; + await db.SaveChangesAsync(); + } + var interruptedService = new AudiobookContentMoveService( + _provider.GetRequiredService< + ILogger>(), + _provider.GetRequiredService< + IDbContextFactory>(), + TimeProvider.System, + new FailOnceAfterMarkerlessNativeRename()); + + await Assert.ThrowsAsync(() => + interruptedService.MoveContentsAsync( + request, + CancellationToken.None)); + + await File.WriteAllTextAsync(targetFile, "modified audio"); + var service = _provider.GetRequiredService(); + + var exception = await Assert.ThrowsAsync(() => + service.MoveContentsAsync(request, CancellationToken.None)); + + Assert.Contains( + "changed", + exception.Message, + StringComparison.OrdinalIgnoreCase); + Assert.Equal("modified audio", await File.ReadAllTextAsync(targetFile)); + AssertNoListenarrArtifacts(root); + } + + [Fact] + public async Task MoveContentsAsync_MarkerlessCrashBeforeTargetFileIdentity_PreservesUnprovenFinalFile() + { + var root = FileService.GetTempDirectory( + "content-move-markerless-file-unproven-root"); + var source = Path.Join(root, "source"); + Directory.CreateDirectory(source); + await FileService.GetFileAsync(source, "book.m4b", "audio"); + var target = Path.Join(root, "destination", "Book"); + var request = await CreateLeasedMoveRequestAsync( + source, + target, + sourceCleanupBoundary: root, + executionProtocolVersion: + MoveExecutionProtocol.MarkerlessDatabaseState); + var interruptedService = new AudiobookContentMoveService( + _provider.GetRequiredService< + ILogger>(), + _provider.GetRequiredService< + IDbContextFactory>(), + TimeProvider.System, + new FailOnceAtCopyMutationPoint( + CopyMutationFaultPoint + .AfterMarkerlessFileCreationBeforeStateUpdate)); + + await Assert.ThrowsAsync(() => + interruptedService.MoveContentsAsync( + request, + CancellationToken.None)); + + var targetFile = Path.Join(target, "book.m4b"); + Assert.True(File.Exists(targetFile)); + Assert.Equal(0, new FileInfo(targetFile).Length); + Assert.True(File.Exists(Path.Join(source, "book.m4b"))); + AssertNoListenarrArtifacts(root); + + var factory = _provider.GetRequiredService< + IDbContextFactory>(); + await using (var db = await factory.CreateDbContextAsync()) + { + var entry = await db.MoveJobEntries + .AsNoTracking() + .SingleAsync(candidate => + candidate.MoveJobId == request.JobId + && candidate.EntryType == MoveJobEntryType.File); + Assert.Equal(MoveJobEntryCopyState.Pending, entry.CopyState); + Assert.Null(entry.TargetPhysicalObjectIdentity); + } + + var service = _provider.GetRequiredService(); + var exception = await Assert.ThrowsAsync(() => + service.MoveContentsAsync(request, CancellationToken.None)); + Assert.Contains( + "no persisted markerless ownership proof", + exception.Message, + StringComparison.OrdinalIgnoreCase); + Assert.Equal(0, new FileInfo(targetFile).Length); + Assert.True(File.Exists(Path.Join(source, "book.m4b"))); + AssertNoListenarrArtifacts(root); + } + + [Fact] + public async Task MoveContentsAsync_MarkerlessRetryAfterTargetFileStateUpdate_Completes() + { + await AssertMarkerlessTargetFileRetryAsync( + CopyMutationFaultPoint.AfterMarkerlessFileStateUpdate); + } + + [Fact] + public async Task MoveContentsAsync_MarkerlessRetryAfterTargetFileWrite_Completes() + { + await AssertMarkerlessTargetFileRetryAsync( + CopyMutationFaultPoint + .AfterMarkerlessFileWriteBeforePublishedState); + } + + private async Task AssertMarkerlessTargetFileRetryAsync( + CopyMutationFaultPoint faultPoint) + { + var root = FileService.GetTempDirectory( + "content-move-markerless-file-retry-root"); + var source = Path.Join(root, "source"); + Directory.CreateDirectory(source); + await FileService.GetFileAsync(source, "book.m4b", "audio"); + var target = Path.Join(root, "destination", "Book"); + var request = await CreateLeasedMoveRequestAsync( + source, + target, + sourceCleanupBoundary: root, + executionProtocolVersion: + MoveExecutionProtocol.MarkerlessDatabaseState); + var interruptedService = new AudiobookContentMoveService( + _provider.GetRequiredService< + ILogger>(), + _provider.GetRequiredService< + IDbContextFactory>(), + TimeProvider.System, + new FailOnceAtCopyMutationPoint(faultPoint)); + + await Assert.ThrowsAsync(() => + interruptedService.MoveContentsAsync( + request, + CancellationToken.None)); + + var targetFile = Path.Join(target, "book.m4b"); + Assert.True(File.Exists(targetFile)); + AssertNoListenarrArtifacts(root); + var factory = _provider.GetRequiredService< + IDbContextFactory>(); + await using (var db = await factory.CreateDbContextAsync()) + { + var entry = await db.MoveJobEntries + .AsNoTracking() + .SingleAsync(candidate => + candidate.MoveJobId == request.JobId + && candidate.EntryType == MoveJobEntryType.File); + Assert.Equal(MoveJobEntryCopyState.Staged, entry.CopyState); + Assert.False(string.IsNullOrWhiteSpace( + entry.TargetPhysicalObjectIdentity)); + } + + var service = _provider.GetRequiredService(); + var result = await service.MoveContentsAsync( + request, + CancellationToken.None); + await service.FinalizeMoveAsync( + request, + result, + CancellationToken.None); + await service.CleanupCompletedMoveArtifactsAsync( + request, + result, + CancellationToken.None); + + Assert.False(Directory.Exists(source)); + Assert.Equal("audio", await File.ReadAllTextAsync(targetFile)); + AssertNoListenarrArtifacts(root); + } + + [Fact] + public async Task MoveContentsAsync_MarkerlessRetryAfterDirectoryCreationBeforeStateUpdate_RetainsAndCompletes() + { + await AssertMarkerlessDirectoryCreationRetryAsync( + TargetScaffoldPreparationFaultPoint + .AfterMarkerlessDirectoryCreationBeforeStateUpdate, + MoveCreatedDirectoryState.Planned, + expectIdentity: false); + } + + [Fact] + public async Task MoveContentsAsync_MarkerlessRetryAfterDirectoryStateUpdate_Completes() + { + await AssertMarkerlessDirectoryCreationRetryAsync( + TargetScaffoldPreparationFaultPoint + .AfterMarkerlessDirectoryStateUpdate, + MoveCreatedDirectoryState.Created, + expectIdentity: true); + } + + private async Task AssertMarkerlessDirectoryCreationRetryAsync( + TargetScaffoldPreparationFaultPoint faultPoint, + MoveCreatedDirectoryState expectedInterruptedState, + bool expectIdentity) + { + var root = FileService.GetTempDirectory( + "content-move-markerless-directory-retry-root"); + var source = Path.Join(root, "source"); + Directory.CreateDirectory(source); + await FileService.GetFileAsync(source, "book.m4b", "audio"); + var target = Path.Join(root, "destination", "Author", "Book"); + var request = await CreateLeasedMoveRequestAsync( + source, + target, + sourceCleanupBoundary: root, + executionProtocolVersion: + MoveExecutionProtocol.MarkerlessDatabaseState); + var interruptedService = new AudiobookContentMoveService( + _provider.GetRequiredService< + ILogger>(), + _provider.GetRequiredService< + IDbContextFactory>(), + TimeProvider.System, + new FailOnceAtTargetScaffoldPreparationPoint(faultPoint)); + + await Assert.ThrowsAsync(() => + interruptedService.MoveContentsAsync( + request, + CancellationToken.None)); + + var factory = _provider.GetRequiredService< + IDbContextFactory>(); + string interruptedPath; + await using (var db = await factory.CreateDbContextAsync()) + { + var directories = await db.MoveJobCreatedDirectories + .AsNoTracking() + .Where(directory => directory.MoveJobId == request.JobId) + .OrderBy(directory => directory.Id) + .ToListAsync(); + var interrupted = Assert.Single( + directories, + directory => Directory.Exists(directory.Path)); + Assert.Equal(expectedInterruptedState, interrupted.State); + Assert.Equal( + expectIdentity, + !string.IsNullOrWhiteSpace( + interrupted.DirectoryObjectIdentity)); + interruptedPath = interrupted.Path; + } + Assert.Empty(Directory.EnumerateFileSystemEntries(interruptedPath)); + AssertNoListenarrArtifacts(root); + + var service = _provider.GetRequiredService(); + var result = await service.MoveContentsAsync( + request, + CancellationToken.None); + await service.FinalizeMoveAsync( + request, + result, + CancellationToken.None); + await service.CleanupCompletedMoveArtifactsAsync( + request, + result, + CancellationToken.None); + + Assert.False(Directory.Exists(source)); + Assert.Equal("audio", await File.ReadAllTextAsync( + Path.Join(target, "book.m4b"))); + AssertNoListenarrArtifacts(root); + await using var verification = await factory.CreateDbContextAsync(); + var recovered = await verification.MoveJobCreatedDirectories + .AsNoTracking() + .SingleAsync(directory => + directory.MoveJobId == request.JobId + && directory.Path == interruptedPath); + Assert.Equal(MoveCreatedDirectoryState.Retained, recovered.State); + Assert.False(string.IsNullOrWhiteSpace( + recovered.DirectoryObjectIdentity)); + } + + [Fact] + public async Task MoveContentsAsync_MarkerlessRetryAfterSourceDeleteBeforeStateUpdate_Completes() + { + await AssertMarkerlessSourceCleanupRetryAsync( + SourceCleanupFaultPoint + .AfterMarkerlessSourceFileDeleteBeforeStateUpdate, + MoveJobEntryCleanupState.DeletionAuthorized); + } + + [Fact] + public async Task MoveContentsAsync_MarkerlessRetryAfterSourceDeleteStateUpdate_Completes() + { + await AssertMarkerlessSourceCleanupRetryAsync( + SourceCleanupFaultPoint.AfterMarkerlessSourceFileStateUpdate, + MoveJobEntryCleanupState.Deleted); + } + + private async Task AssertMarkerlessSourceCleanupRetryAsync( + SourceCleanupFaultPoint faultPoint, + MoveJobEntryCleanupState expectedInterruptedState) + { + var root = FileService.GetTempDirectory( + "content-move-markerless-cleanup-retry-root"); + var source = Path.Join(root, "source"); + Directory.CreateDirectory(source); + await FileService.GetFileAsync(source, "first.m4b", "first"); + await FileService.GetFileAsync(source, "second.m4b", "second"); + var target = Path.Join(root, "destination", "Book"); + var request = await CreateLeasedMoveRequestAsync( + source, + target, + sourceCleanupBoundary: root, + executionProtocolVersion: + MoveExecutionProtocol.MarkerlessDatabaseState); + var interruptedService = new AudiobookContentMoveService( + _provider.GetRequiredService< + ILogger>(), + _provider.GetRequiredService< + IDbContextFactory>(), + TimeProvider.System, + new FailOnceAtSourceCleanupPoint(faultPoint)); + + await Assert.ThrowsAsync(() => + interruptedService.MoveContentsAsync( + request, + CancellationToken.None)); + + var factory = _provider.GetRequiredService< + IDbContextFactory>(); + await using (var db = await factory.CreateDbContextAsync()) + { + var sourceEntries = await db.MoveJobEntries + .AsNoTracking() + .Where(entry => entry.MoveJobId == request.JobId) + .Where(entry => entry.EntryType == MoveJobEntryType.File) + .OrderBy(entry => entry.Id) + .ToListAsync(); + var interruptedEntry = Assert.Single( + sourceEntries, + entry => entry.CleanupState == expectedInterruptedState); + Assert.False(File.Exists(Path.Join( + source, + interruptedEntry.RelativePath))); + Assert.Single( + sourceEntries, + entry => entry.CleanupState + == MoveJobEntryCleanupState.Pending); + } + AssertNoListenarrArtifacts(root); + + var service = _provider.GetRequiredService(); + var result = await service.MoveContentsAsync( + request, + CancellationToken.None); + await service.FinalizeMoveAsync( + request, + result, + CancellationToken.None); + await service.CleanupCompletedMoveArtifactsAsync( + request, + result, + CancellationToken.None); + + Assert.False(Directory.Exists(source)); + Assert.Equal("first", await File.ReadAllTextAsync( + Path.Join(target, "first.m4b"))); + Assert.Equal("second", await File.ReadAllTextAsync( + Path.Join(target, "second.m4b"))); + AssertNoListenarrArtifacts(root); + } + [Fact] public async Task MoveContentsAsync_OwnedSourceMarkersAreRetiredAndNeverPublished() { @@ -1640,8 +2355,7 @@ await service.CleanupCompletedMoveArtifactsAsync( Assert.False(Directory.Exists(source)); Assert.True(File.Exists(Path.Join(target, "book.m4b"))); - Assert.True(File.Exists(Path.Join(target, LibraryDirectoryOwnershipMarker.FileName))); - LibraryDirectoryOwnershipMarker.Validate(ownership, target); + Assert.False(File.Exists(Path.Join(target, LibraryDirectoryOwnershipMarker.FileName))); var resolution = await ownershipStore.ResolveOwnedAsync( target, FileSystemPathSemantics.CurrentHostDefault); @@ -1649,7 +2363,7 @@ await service.CleanupCompletedMoveArtifactsAsync( } [Fact] - public async Task OwnedTargetMarkerChangedAfterPublication_BlocksSourceCleanup() + public async Task OwnedTargetGenerationChangedAfterPublication_BlocksSourceCleanup() { var source = FileService.GetTempDirectory("content-move-owned-target-tamper-src"); await FileService.GetFileAsync(source, "book.m4b", "audio"); @@ -1664,16 +2378,17 @@ await ownershipStore.RecordCreatedAsync( _provider.GetRequiredService>(), _provider.GetRequiredService>(), TimeProvider.System, - new TamperTargetOwnershipAfterPublish(target), + new ReplaceTargetDirectoryAfterPublish(target), directoryOwnershipStore: ownershipStore); var request = await CreateLeasedMoveRequestAsync(source, target); var exception = await Assert.ThrowsAsync(() => service.MoveContentsAsync(request, CancellationToken.None)); - Assert.Contains("ownership marker changed", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("ownership changed", exception.Message, StringComparison.OrdinalIgnoreCase); Assert.True(File.Exists(Path.Join(source, "book.m4b"))); - Assert.True(File.Exists(Path.Join(target, "book.m4b"))); + Assert.False(File.Exists(Path.Join(target, "book.m4b"))); + Assert.True(File.Exists(Path.Join(target + ".original", "book.m4b"))); } [Fact] @@ -2222,7 +2937,9 @@ private async Task CreateLeasedMoveRequestAsync( bool deleteEmptySource = true, FileSystemPathSemantics? sourceSemantics = null, FileSystemPathSemantics? targetSemantics = null, - string? sourceCleanupBoundary = null) + string? sourceCleanupBoundary = null, + int executionProtocolVersion = + MoveExecutionProtocol.LegacyFilesystemArtifacts) { var id = jobId ?? Guid.NewGuid(); var effectiveTargetSemantics = @@ -2250,6 +2967,7 @@ private async Task CreateLeasedMoveRequestAsync( LeaseExpiresAt = DateTime.UtcNow.AddMinutes(5), ActiveDeduplicationKey = $"test:{id:N}", IdentityKeyVersion = MoveManifestIdentity.Version, + ExecutionProtocolVersion = executionProtocolVersion, Entries = [ MoveManifestIdentity.CreateTargetBoundaryAuthorization( @@ -2561,6 +3279,119 @@ public void OnSourceCleanupMutation( } } + private static void AssertNoListenarrArtifacts(string root) + { + Assert.DoesNotContain( + Directory.EnumerateFileSystemEntries( + root, + "*", + SearchOption.AllDirectories), + path => + { + var name = Path.GetFileName(path); + return name.StartsWith(".listenarr-", StringComparison.Ordinal) + || name.Contains(".listenarr-", StringComparison.Ordinal) + || name.Contains(".tmp-", StringComparison.Ordinal); + }); + } + + private sealed class FailAfterPublishedOnce : IMoveFaultInjector + { + private int _failed; + + public Task AfterPublishedAsync( + Guid jobId, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + if (Interlocked.Exchange(ref _failed, 1) == 0) + { + throw new IOException( + "Injected markerless interruption after publication."); + } + return Task.CompletedTask; + } + } + + private sealed class DisableMarkerlessFileRename : IMoveFaultInjector + { + public bool AllowMarkerlessFileRename => false; + } + + private sealed class FailOnceAfterMarkerlessNativeRename : IMoveFaultInjector + { + private int _failed; + + public bool AllowMarkerlessFileRename => true; + + public void OnCopyMutation( + Guid jobId, + CopyMutationFaultPoint faultPoint) + { + if (faultPoint == CopyMutationFaultPoint + .AfterMarkerlessNativeRenameBeforeStateUpdate + && Interlocked.Exchange(ref _failed, 1) == 0) + { + throw new IOException( + "Injected markerless native-rename interruption before state commit."); + } + } + } + + private sealed class FailOnceAtCopyMutationPoint( + CopyMutationFaultPoint expectedPoint) : IMoveFaultInjector + { + private int _failed; + + public void OnCopyMutation( + Guid jobId, + CopyMutationFaultPoint faultPoint) + { + if (faultPoint == expectedPoint + && Interlocked.Exchange(ref _failed, 1) == 0) + { + throw new IOException( + $"Injected markerless target-file interruption at {faultPoint}."); + } + } + } + + private sealed class FailOnceAtTargetScaffoldPreparationPoint( + TargetScaffoldPreparationFaultPoint expectedPoint) : IMoveFaultInjector + { + private int _failed; + + public void OnTargetScaffoldPreparation( + Guid jobId, + TargetScaffoldPreparationFaultPoint faultPoint) + { + if (faultPoint == expectedPoint + && Interlocked.Exchange(ref _failed, 1) == 0) + { + throw new IOException( + $"Injected markerless target-directory interruption at {faultPoint}."); + } + } + } + + private sealed class FailOnceAtSourceCleanupPoint( + SourceCleanupFaultPoint expectedPoint) : IMoveFaultInjector + { + private int _failed; + + public void OnSourceCleanupMutation( + Guid jobId, + SourceCleanupFaultPoint faultPoint) + { + if (faultPoint == expectedPoint + && Interlocked.Exchange(ref _failed, 1) == 0) + { + throw new IOException( + $"Injected markerless source-cleanup interruption at {faultPoint}."); + } + } + } + private sealed class AddSourceFileAfterPublish(string source) : IMoveFaultInjector { public Task AfterPublishedAsync(Guid jobId, CancellationToken cancellationToken) => @@ -2584,16 +3415,14 @@ private sealed class AllowAtomicRenameInjector : IMoveFaultInjector public bool AllowAtomicRename => true; } - private sealed class TamperTargetOwnershipAfterPublish(string target) : IMoveFaultInjector + private sealed class ReplaceTargetDirectoryAfterPublish(string target) : IMoveFaultInjector { - public async Task AfterPublishedAsync(Guid jobId, CancellationToken cancellationToken) + public Task AfterPublishedAsync(Guid jobId, CancellationToken cancellationToken) { - var markerPath = Path.Join(target, LibraryDirectoryOwnershipMarker.FileName); - if (OperatingSystem.IsWindows()) - { - File.SetAttributes(markerPath, FileAttributes.Normal); - } - await File.WriteAllTextAsync(markerPath, "tampered", cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + Directory.Move(target, target + ".original"); + Directory.CreateDirectory(target); + return Task.CompletedTask; } } diff --git a/tests/Features/Infrastructure/Library/Moving/EfLibraryDirectoryOwnershipStoreTests.cs b/tests/Features/Infrastructure/Library/Moving/EfLibraryDirectoryOwnershipStoreTests.cs index d43f03d66..4019611fc 100644 --- a/tests/Features/Infrastructure/Library/Moving/EfLibraryDirectoryOwnershipStoreTests.cs +++ b/tests/Features/Infrastructure/Library/Moving/EfLibraryDirectoryOwnershipStoreTests.cs @@ -102,7 +102,7 @@ public async Task BoundaryAuthorizer_ForeignPersistedUnixRoot_CannotAuthorizeWin } [Fact] - public async Task RecordCreatedAsync_PersistsIdentityAndMatchingPhysicalMarkers() + public async Task RecordCreatedAsync_PersistsIdentityWithoutPermanentFilesystemMarkers() { var directory = Path.Join(_root, "Author"); Directory.CreateDirectory(directory); @@ -118,28 +118,7 @@ public async Task RecordCreatedAsync_PersistsIdentityAndMatchingPhysicalMarkers( Assert.NotEqual(0, ownership.Id); Assert.False(string.IsNullOrWhiteSpace(ownership.PathOwnershipKey)); Assert.False(string.IsNullOrWhiteSpace(ownership.OwnershipToken)); - Assert.True(File.Exists(Path.Join(directory, LibraryDirectoryOwnershipMarker.FileName))); - Assert.Single(Directory.EnumerateFiles( - _root, - ".listenarr-directory-owner-*.json", - SearchOption.TopDirectoryOnly)); - using (var marker = JsonDocument.Parse( - await File.ReadAllTextAsync( - Path.Join(directory, LibraryDirectoryOwnershipMarker.FileName)))) - { - var payload = marker.RootElement; - Assert.Equal(2, payload.GetProperty("version").GetInt32()); - Assert.Equal( - ownership.ManagedRootFolderId, - payload.GetProperty("managedRootFolderId").GetInt32()); - Assert.Equal( - ownership.DirectoryObjectIdentityVersion, - payload.GetProperty("directoryObjectIdentityVersion").GetInt32()); - Assert.Equal( - ownership.DirectoryObjectIdentity, - payload.GetProperty("directoryObjectIdentity").GetString()); - } - LibraryDirectoryOwnershipMarker.Validate(ownership, directory); + AssertNoPersistentOwnershipArtifacts(ownership); var resolution = await _store.ResolveOwnedAsync( directory, @@ -149,44 +128,38 @@ await File.ReadAllTextAsync( } [Fact] - public async Task RecordCreatedAsync_RequestCancelledAfterMarkerPublication_CommitsMatchingOwnership() + public async Task RecordCreatedAsync_RequestCancelledBeforeCommit_LeavesNoOwnershipOrArtifacts() { - var directory = Path.Join(_root, "CancelledAfterMarker"); + var directory = Path.Join(_root, "CancelledBeforeCommit"); Directory.CreateDirectory(directory); using var cancellation = new CancellationTokenSource(); - _store.AfterOwnershipMarkerPublicationForTest = cancellation.Cancel; + _store.BeforeNewOwnershipCommitForTest = cancellation.Cancel; - var ownership = await _store.RecordCreatedAsync( - new LibraryDirectoryOwnershipClaim( - directory, - FileSystemPathSemantics.CurrentHostDefault, - "test", - Guid.NewGuid(), - AudiobookId: 11), - cancellation.Token); + await Assert.ThrowsAnyAsync(() => + _store.RecordCreatedAsync( + new LibraryDirectoryOwnershipClaim( + directory, + FileSystemPathSemantics.CurrentHostDefault, + "test", + Guid.NewGuid(), + AudiobookId: 11), + cancellation.Token)); Assert.True(cancellation.IsCancellationRequested); - Assert.NotEqual(0, ownership.Id); - LibraryDirectoryOwnershipMarker.Validate(ownership, directory); + Assert.Empty(Directory.EnumerateFileSystemEntries(directory)); await using var db = await _factory.CreateDbContextAsync(); - var persisted = await db.LibraryDirectoryOwnerships - .SingleAsync(candidate => candidate.Id == ownership.Id); - Assert.Equal(ownership.OwnershipToken, persisted.OwnershipToken); - Assert.Equal( - LibraryDirectoryOwnershipState.Owned, - persisted.State); + Assert.Empty(await db.LibraryDirectoryOwnerships.ToListAsync()); } [Fact] - public async Task RecordCreatedAsync_InterruptedAfterInsideMarker_RecoversSameOwnershipToken() + public async Task RecordCreatedAsync_InterruptedBeforeCommit_LeavesNoClaimOrArtifacts() { - var directory = Path.Join(_root, "InterruptedBetweenMarkers"); + var directory = Path.Join(_root, "InterruptedBeforeCommit"); Directory.CreateDirectory(directory); - _store.AfterInsideOwnershipMarkerPublicationForTest = () => - throw new IOException( - "Injected interruption after inside marker publication."); + _store.BeforeNewOwnershipCommitForTest = () => + throw new IOException("Injected interruption before ownership commit."); - await Assert.ThrowsAsync(() => + var exception = await Assert.ThrowsAsync(() => _store.RecordCreatedAsync( new LibraryDirectoryOwnershipClaim( directory, @@ -195,49 +168,16 @@ await Assert.ThrowsAsync(() => Guid.NewGuid(), AudiobookId: 12))); - await using (var interruptedDb = - await _factory.CreateDbContextAsync()) - { - var interrupted = await interruptedDb - .LibraryDirectoryOwnerships.SingleAsync(); - Assert.Equal( - LibraryDirectoryOwnershipState.Unavailable, - interrupted.State); - Assert.NotNull(interrupted.PathOwnershipKey); - Assert.Contains( - "Injected interruption", - interrupted.DirectoryObjectIdentityUnavailableReason, - StringComparison.Ordinal); - var markerPaths = - LibraryDirectoryOwnershipMarker.GetMarkerPaths(interrupted); - Assert.True(File.Exists(markerPaths[0])); - Assert.False(File.Exists(markerPaths[1])); - using var interruptedDirectory = - PinnedDirectoryCreation.OpenPinnedBoundary(directory); - using var interruptedMarker = interruptedDirectory.OpenExistingFile( - LibraryDirectoryOwnershipMarker.FileName, - requireDeleteAccess: false); - LibraryDirectoryOwnershipMarker.ValidateMarkerFile( - interrupted, - interruptedMarker); - } - - await CreateOwnershipReconciler().ReconcileAsync(); - - await using var recoveredDb = await _factory.CreateDbContextAsync(); - var recovered = await recoveredDb - .LibraryDirectoryOwnerships.SingleAsync(); - Assert.Equal( - LibraryDirectoryOwnershipState.Owned, - recovered.State); - Assert.Null(recovered.DirectoryObjectIdentityUnavailableReason); - LibraryDirectoryOwnershipMarker.Validate(recovered, directory); + Assert.Contains("before ownership commit", exception.Message, StringComparison.Ordinal); + Assert.Empty(Directory.EnumerateFileSystemEntries(directory)); + await using var db = await _factory.CreateDbContextAsync(); + Assert.Empty(await db.LibraryDirectoryOwnerships.ToListAsync()); } [Fact] - public async Task RecordCreatedAsync_RetryAfterPartialPublication_RepairsSameClaim() + public async Task RecordCreatedAsync_RetryAfterPreCommitInterruption_CreatesSingleClaim() { - var directory = Path.Join(_root, "RetryInterruptedPublication"); + var directory = Path.Join(_root, "RetryInterruptedCommit"); Directory.CreateDirectory(directory); var claim = new LibraryDirectoryOwnershipClaim( directory, @@ -245,36 +185,20 @@ public async Task RecordCreatedAsync_RetryAfterPartialPublication_RepairsSameCla "test", Guid.NewGuid(), AudiobookId: 13); - _store.AfterInsideOwnershipMarkerPublicationForTest = () => - throw new IOException("Injected partial publication."); + _store.BeforeNewOwnershipCommitForTest = () => + throw new IOException("Injected ownership persistence interruption."); await Assert.ThrowsAsync(() => _store.RecordCreatedAsync(claim)); - long ownershipId; - string ownershipToken; - await using (var interruptedDb = - await _factory.CreateDbContextAsync()) - { - var interrupted = await interruptedDb - .LibraryDirectoryOwnerships.SingleAsync(); - ownershipId = interrupted.Id; - ownershipToken = interrupted.OwnershipToken; - Assert.Equal( - LibraryDirectoryOwnershipState.Unavailable, - interrupted.State); - } + _store.BeforeNewOwnershipCommitForTest = null; - _store.AfterInsideOwnershipMarkerPublicationForTest = null; var repaired = await _store.RecordCreatedAsync(claim); - Assert.Equal(ownershipId, repaired.Id); - Assert.Equal(ownershipToken, repaired.OwnershipToken); Assert.Equal(LibraryDirectoryOwnershipState.Owned, repaired.State); Assert.Null(repaired.DirectoryObjectIdentityUnavailableReason); - LibraryDirectoryOwnershipMarker.Validate(repaired, directory); + AssertNoPersistentOwnershipArtifacts(repaired); await using var verification = await _factory.CreateDbContextAsync(); - Assert.Single(await verification - .LibraryDirectoryOwnerships.ToListAsync()); + Assert.Single(await verification.LibraryDirectoryOwnerships.ToListAsync()); } [Fact] @@ -331,23 +255,32 @@ await _store.RecordCreatedAsync( } [Fact] - public async Task PhysicalPathReplacementWithoutInsideMarkerFailsValidation() + public async Task PhysicalPathReplacementWithoutMarkerFailsNativeGenerationValidation() { var directory = Path.Join(_root, "Author"); Directory.CreateDirectory(directory); - var ownership = await _store.RecordCreatedAsync( + await _store.RecordCreatedAsync( new LibraryDirectoryOwnershipClaim( directory, FileSystemPathSemantics.CurrentHostDefault, "test")); + Assert.False(File.Exists(Path.Join( + directory, + LibraryDirectoryOwnershipMarker.FileName))); - File.Delete(Path.Join(directory, LibraryDirectoryOwnershipMarker.FileName)); Directory.Delete(directory, recursive: false); Directory.CreateDirectory(directory); - var exception = Assert.Throws(() => - LibraryDirectoryOwnershipMarker.Validate(ownership, directory)); - Assert.Contains("marker", exception.Message, StringComparison.OrdinalIgnoreCase); + var resolution = await _store.ResolveOwnedAsync( + directory, + FileSystemPathSemantics.CurrentHostDefault); + Assert.Equal( + LibraryDirectoryOwnershipResolutionState.Unavailable, + resolution.State); + Assert.Contains( + "physical", + resolution.Reason, + StringComparison.OrdinalIgnoreCase); } [Fact] @@ -360,6 +293,7 @@ public async Task PhysicalPathReplacementWithCopiedMarkersFailsClosed() directory, FileSystemPathSemantics.CurrentHostDefault, "test")); + await PublishLegacyOwnershipMarkersAsync(ownership); var insideMarker = Path.Join( directory, LibraryDirectoryOwnershipMarker.FileName); @@ -454,9 +388,7 @@ public async Task EnsureCreatedHierarchyAsync_ClaimsOnlyDirectoriesCreatedExclus Assert.Equal(LibraryDirectoryOwnershipResolutionState.Unowned, rootResolution.State); foreach (var ownership in ownerships) { - LibraryDirectoryOwnershipMarker.Validate( - ownership, - ownership.CanonicalPath); + AssertNoPersistentOwnershipArtifacts(ownership); } } @@ -487,7 +419,7 @@ public async Task EnrolledDestinationRemovedBeforePublication_IsNotRecreatedAndO marker, destinationDirectory, FileSystemPathSemantics.CurrentHostDefault)); - Assert.True(File.Exists(siblingMarker)); + Assert.False(File.Exists(siblingMarker)); Directory.Delete(destinationDirectory, recursive: true); var mover = new FileMover( new NullLogger(), @@ -499,7 +431,7 @@ public async Task EnrolledDestinationRemovedBeforePublication_IsNotRecreatedAndO Assert.True(File.Exists(source)); Assert.False(Directory.Exists(destinationDirectory)); Assert.False(File.Exists(destination)); - Assert.True(File.Exists(siblingMarker)); + Assert.False(File.Exists(siblingMarker)); var resolution = await _store.ResolveOwnedAsync( destinationDirectory, FileSystemPathSemantics.CurrentHostDefault); @@ -584,7 +516,7 @@ public async Task EnsureCreatedHierarchyAsync_CancellationAfterExclusiveCreation Assert.True(cancellation.IsCancellationRequested); var ownership = Assert.Single(ownerships); - LibraryDirectoryOwnershipMarker.Validate(ownership, destination); + AssertNoPersistentOwnershipArtifacts(ownership); var resolution = await _store.ResolveOwnedAsync( destination, FileSystemPathSemantics.CurrentHostDefault); @@ -592,12 +524,8 @@ public async Task EnsureCreatedHierarchyAsync_CancellationAfterExclusiveCreation Assert.Equal(ownership.Id, resolution.Ownership?.Id); } - [Theory] - [InlineData(0)] - [InlineData(1)] - [InlineData(2)] - public async Task EnsureCreatedHierarchyAsync_RepairsMarkersOnlyForExistingDurableClaim( - int missingMarker) + [Fact] + public async Task EnsureCreatedHierarchyAsync_ExistingDurableClaimDoesNotRequireMarkers() { var destination = Path.Join(_root, "Author", "Book"); var ownerships = await _store.EnsureCreatedHierarchyAsync( @@ -610,20 +538,7 @@ public async Task EnsureCreatedHierarchyAsync_RepairsMarkersOnlyForExistingDurab item.CanonicalPath, destination, FileSystemPathSemantics.CurrentHostDefault)); - var markerPaths = LibraryDirectoryOwnershipMarker - .GetMarkerPaths(ownership) - .ToList(); - IReadOnlyList pathsToDelete = missingMarker == 2 - ? markerPaths - : [markerPaths[missingMarker]]; - foreach (var markerPath in pathsToDelete) - { - if (OperatingSystem.IsWindows()) - { - File.SetAttributes(markerPath, FileAttributes.Normal); - } - File.Delete(markerPath); - } + AssertNoPersistentOwnershipArtifacts(ownership); var repaired = await _store.EnsureCreatedHierarchyAsync( destination, @@ -632,7 +547,13 @@ public async Task EnsureCreatedHierarchyAsync_RepairsMarkersOnlyForExistingDurab "test-retry"); Assert.Empty(repaired); - LibraryDirectoryOwnershipMarker.Validate(ownership, destination); + AssertNoPersistentOwnershipArtifacts(ownership); + var resolution = await _store.ResolveOwnedAsync( + destination, + FileSystemPathSemantics.CurrentHostDefault); + Assert.Equal( + LibraryDirectoryOwnershipResolutionState.Owned, + resolution.State); } [Fact] @@ -684,7 +605,7 @@ public async Task RemovingDirectory_CanCompleteAfterDirectoryDeletionAndRestart( var ownershipKey = Assert.IsType(ownership.PathOwnershipKey); await _store.BeginRemovalAsync(ownership.Id, ownershipKey); - LibraryDirectoryOwnershipMarker.DeleteInsideMarker(ownership, directory); + AssertNoPersistentOwnershipArtifacts(ownership); Directory.Delete(directory, recursive: false); var restartedStore = new EfLibraryDirectoryOwnershipStore(_factory, TimeProvider.System); @@ -735,6 +656,7 @@ public async Task RecordCreatedAsync_RemovesRetiredSiblingMarkerFromPriorOwnersh FileSystemPathSemantics.CurrentHostDefault, "test")); var ownershipKey = Assert.IsType(prior.PathOwnershipKey); + await PublishLegacyOwnershipMarkersAsync(prior); var retiredSiblingMarker = LibraryDirectoryOwnershipMarker.GetMarkerPaths(prior) .Single(path => !FileSystemPathIdentity.IsSameOrInside( path, @@ -759,7 +681,7 @@ public async Task RecordCreatedAsync_RemovesRetiredSiblingMarkerFromPriorOwnersh Assert.NotEqual(prior.Id, recreated.Id); Assert.NotEqual(prior.OwnershipToken, recreated.OwnershipToken); Assert.False(File.Exists(retiredSiblingMarker)); - LibraryDirectoryOwnershipMarker.Validate(recreated, directory); + AssertNoPersistentOwnershipArtifacts(recreated); } [Fact] @@ -775,7 +697,7 @@ public async Task RemovalPath_InvalidOwnershipTokenCannotEscapeParent() var insideMarker = Path.Join( directory, LibraryDirectoryOwnershipMarker.FileName); - LibraryDirectoryOwnershipMarker.DeleteInsideMarker(ownership, directory); + Assert.False(File.Exists(insideMarker)); ownership.OwnershipToken = $"..{Path.DirectorySeparatorChar}outside"; var quarantineException = Assert.Throws(() => @@ -808,7 +730,6 @@ public async Task RemovalPath_FileReplacementAtOriginalPathFailsClosed() var ownershipKey = Assert.IsType(ownership.PathOwnershipKey); await _store.BeginRemovalAsync(ownership.Id, ownershipKey); ownership.State = LibraryDirectoryOwnershipState.Removing; - LibraryDirectoryOwnershipMarker.DeleteInsideMarker(ownership, directory); Directory.Delete(directory, recursive: false); await File.WriteAllTextAsync(directory, "user file"); @@ -833,7 +754,6 @@ public async Task RemovalPath_FileAtQuarantinePathFailsClosed() var ownershipKey = Assert.IsType(ownership.PathOwnershipKey); await _store.BeginRemovalAsync(ownership.Id, ownershipKey); ownership.State = LibraryDirectoryOwnershipState.Removing; - LibraryDirectoryOwnershipMarker.DeleteInsideMarker(ownership, directory); Directory.Delete(directory, recursive: false); var quarantinePath = LibraryDirectoryOwnershipRemoval.GetQuarantinePath(ownership); await File.WriteAllTextAsync(quarantinePath, "foreign file"); @@ -862,9 +782,6 @@ public async Task RemovalPath_EmptyQuarantineAfterInsideMarkerRetirementComplete var quarantinePath = LibraryDirectoryOwnershipRemoval.GetQuarantinePath(ownership); Directory.Move(directory, quarantinePath); - LibraryDirectoryOwnershipMarker.DeleteInsideMarker( - ownership, - quarantinePath); LibraryDirectoryOwnershipRemoval.ValidateRecoverableState(ownership); using var parent = @@ -875,9 +792,7 @@ public async Task RemovalPath_EmptyQuarantineAfterInsideMarkerRetirementComplete Assert.Equal(LibraryDirectoryRemovalOutcome.Removed, outcome); Assert.False(Directory.Exists(quarantinePath)); - Assert.True( - File.Exists( - LibraryDirectoryOwnershipMarker.GetMarkerPaths(ownership)[1])); + AssertNoPersistentOwnershipArtifacts(ownership); } [Fact] @@ -893,7 +808,6 @@ public async Task RecordCreatedAsync_CorruptRemovedIdentityDoesNotBlockNewClaim( var ownershipKey = Assert.IsType(prior.PathOwnershipKey); await _store.BeginRemovalAsync(prior.Id, ownershipKey); - LibraryDirectoryOwnershipMarker.DeleteInsideMarker(prior, directory); Directory.Delete(directory, recursive: false); await _store.MarkRemovedAsync(prior.Id, ownershipKey); await using (var db = await _factory.CreateDbContextAsync()) @@ -912,7 +826,7 @@ public async Task RecordCreatedAsync_CorruptRemovedIdentityDoesNotBlockNewClaim( "test-recreated")); Assert.NotEqual(prior.Id, recreated.Id); - LibraryDirectoryOwnershipMarker.Validate(recreated, directory); + AssertNoPersistentOwnershipArtifacts(recreated); } [Fact] @@ -959,7 +873,7 @@ public async Task Reconciler_TransientRootOutage_PreservesAndRecoversClaim() } [Fact] - public async Task Reconciler_SiblingOnlyRemoval_PreservesRecoverableIntent() + public async Task Reconciler_MissingRemovingDirectoryConvergesWithoutMarkerProof() { var directory = Path.Join(_root, "SiblingOnlyRemoval"); Directory.CreateDirectory(directory); @@ -970,7 +884,6 @@ public async Task Reconciler_SiblingOnlyRemoval_PreservesRecoverableIntent() "test")); var ownershipKey = Assert.IsType(ownership.PathOwnershipKey); await _store.BeginRemovalAsync(ownership.Id, ownershipKey); - LibraryDirectoryOwnershipMarker.DeleteInsideMarker(ownership, directory); Directory.Delete(directory); var reconciler = new LibraryDirectoryOwnershipReconciler( _factory, @@ -983,10 +896,10 @@ public async Task Reconciler_SiblingOnlyRemoval_PreservesRecoverableIntent() await using var verification = await _factory.CreateDbContextAsync(); var persisted = await verification.LibraryDirectoryOwnerships.SingleAsync(); Assert.Equal( - LibraryDirectoryOwnershipState.Removing, + LibraryDirectoryOwnershipState.Removed, persisted.State); - Assert.Equal(ownershipKey, persisted.PathOwnershipKey); - LibraryDirectoryOwnershipRemoval.ValidateRecoverableState(persisted); + Assert.Null(persisted.PathOwnershipKey); + AssertNoPersistentOwnershipArtifacts(persisted); } [Fact] @@ -1006,6 +919,7 @@ public async Task Reconciler_LegacyRemovedRowWithoutEvidence_BackfillsAndRetires const string originalStateReason = "Legacy cleanup completed.\nPreserve this diagnostic."; + await PublishLegacyOwnershipMarkersAsync(ownership); await _store.BeginRemovalAsync(ownership.Id, ownershipKey); LibraryDirectoryOwnershipMarker.DeleteInsideMarker(ownership, directory); Directory.Delete(directory); @@ -1102,7 +1016,7 @@ public async Task Reconciler_LegacyMissingBothProofMarksOnlyDatabaseRowRemoved() } [Fact] - public async Task Reconciler_LegacyMissingBothCorruptMarkerFailsClosed() + public async Task Reconciler_LegacyMissingBothCorruptMarker_PreservesArtifactAndConvergesRemoval() { var fixture = await PrepareLegacyMissingBothAsync( "LegacyMissingBothCorrupt"); @@ -1113,11 +1027,11 @@ public async Task Reconciler_LegacyMissingBothCorruptMarkerFailsClosed() await CreateOwnershipReconciler().ReconcileAsync(); - await AssertLegacyRecoveryRejectedAsync(fixture); + await AssertRemovalConvergedWithPreservedLegacyArtifactAsync(fixture); } [Fact] - public async Task Reconciler_LegacyMissingBothWrongTokenFailsClosed() + public async Task Reconciler_LegacyMissingBothWrongToken_PreservesArtifactAndConvergesRemoval() { var fixture = await PrepareLegacyMissingBothAsync( "LegacyMissingBothWrongToken"); @@ -1134,7 +1048,7 @@ await File.WriteAllTextAsync( await CreateOwnershipReconciler().ReconcileAsync(); - await AssertLegacyRecoveryRejectedAsync(fixture); + await AssertRemovalConvergedWithPreservedLegacyArtifactAsync(fixture); } [Fact] @@ -1175,6 +1089,7 @@ public async Task Reconciler_PredecessorDisplacedBeforeUpgradePublication_Comple directory, FileSystemPathSemantics.CurrentHostDefault, "test")); + await PublishLegacyOwnershipMarkersAsync(ownership); var markerPath = Path.Join( directory, LibraryDirectoryOwnershipMarker.FileName); @@ -1199,7 +1114,7 @@ await File.WriteAllTextAsync( Assert.False(File.Exists(backupPath)); Assert.False(File.Exists(temporaryPath)); - LibraryDirectoryOwnershipMarker.Validate(ownership, directory); + AssertNoPersistentOwnershipArtifacts(ownership); } [Fact] @@ -1212,6 +1127,7 @@ public async Task Reconciler_UpgradePublishedBeforePredecessorCleanup_RetiresBac directory, FileSystemPathSemantics.CurrentHostDefault, "test")); + await PublishLegacyOwnershipMarkersAsync(ownership); var backupPath = Path.Join( directory, PinnedDirectoryCreation.GetConditionalReplacementBackupName( @@ -1227,7 +1143,7 @@ await File.WriteAllTextAsync( await CreateOwnershipReconciler().ReconcileAsync(); Assert.False(File.Exists(backupPath)); - LibraryDirectoryOwnershipMarker.Validate(ownership, directory); + AssertNoPersistentOwnershipArtifacts(ownership); } [Fact] @@ -1240,6 +1156,7 @@ public async Task Reconciler_CurrentMarkerWithDisplacedLegacyTemporary_RetiresCr directory, FileSystemPathSemantics.CurrentHostDefault, "test")); + await PublishLegacyOwnershipMarkersAsync(ownership); var temporaryPath = Path.Join( directory, LibraryDirectoryOwnershipMarker.FileName + ".v2.tmp"); @@ -1254,7 +1171,7 @@ await File.WriteAllTextAsync( await CreateOwnershipReconciler().ReconcileAsync(); Assert.False(File.Exists(temporaryPath)); - LibraryDirectoryOwnershipMarker.Validate(ownership, directory); + AssertNoPersistentOwnershipArtifacts(ownership); await using var verification = await _factory.CreateDbContextAsync(); var persisted = await verification.LibraryDirectoryOwnerships .SingleAsync(candidate => candidate.Id == ownership.Id); @@ -1262,7 +1179,7 @@ await File.WriteAllTextAsync( } [Fact] - public async Task Reconciler_LegacyMissingBothMixedUpgradeMarkersFailClosed() + public async Task Reconciler_LegacyMissingBothMixedUpgradeMarkers_PreservesArtifactsAndConvergesRemoval() { var fixture = await PrepareLegacyMissingBothAsync( "LegacyMissingBothMixed"); @@ -1273,7 +1190,8 @@ await File.WriteAllTextAsync( await CreateOwnershipReconciler().ReconcileAsync(); - await AssertLegacyRecoveryRejectedAsync(fixture); + await AssertRemovalConvergedWithPreservedLegacyArtifactAsync(fixture); + Assert.True(File.Exists(fixture.SiblingMarkerPath + ".v2.tmp")); } [Fact] @@ -1306,6 +1224,7 @@ public async Task Reconciler_ForeignRetiredMarkerPath_DoesNotDeleteWindowsAlias( FileSystemPathSemantics.CurrentHostDefault, "test")); var ownershipKey = Assert.IsType(ownership.PathOwnershipKey); + await PublishLegacyOwnershipMarkersAsync(ownership); await _store.BeginRemovalAsync(ownership.Id, ownershipKey); LibraryDirectoryOwnershipMarker.DeleteInsideMarker(ownership, directory); Directory.Delete(directory); @@ -1347,6 +1266,7 @@ public async Task TryDeleteRetiredSiblingMarker_AmbiguousPersistedPayload_Preser FileSystemPathSemantics.CurrentHostDefault, "test")); var ownershipKey = Assert.IsType(ownership.PathOwnershipKey); + await PublishLegacyOwnershipMarkersAsync(ownership); await _store.BeginRemovalAsync(ownership.Id, ownershipKey); LibraryDirectoryOwnershipMarker.DeleteInsideMarker(ownership, directory); Directory.Delete(directory); @@ -1390,6 +1310,7 @@ public async Task Reconciler_RetiredMarkerReplacementRemainsPending() "test")); var ownershipKey = Assert.IsType( ownership.PathOwnershipKey); + await PublishLegacyOwnershipMarkersAsync(ownership); await _store.BeginRemovalAsync(ownership.Id, ownershipKey); LibraryDirectoryOwnershipMarker.DeleteInsideMarker( ownership, @@ -1423,6 +1344,41 @@ await File.WriteAllTextAsync( evidence.State); } + private static void AssertNoPersistentOwnershipArtifacts( + LibraryDirectoryOwnership ownership) + { + var markerPaths = LibraryDirectoryOwnershipMarker.GetMarkerPaths(ownership); + Assert.False(File.Exists(markerPaths[0])); + Assert.False(File.Exists(markerPaths[0] + ".v2.tmp")); + Assert.False(File.Exists(markerPaths[0] + ".migration.tmp")); + Assert.False(File.Exists(Path.Join( + Path.GetDirectoryName(markerPaths[0])!, + PinnedDirectoryCreation.GetConditionalReplacementBackupName( + Path.GetFileName(markerPaths[0]))))); + Assert.False(File.Exists(markerPaths[1])); + Assert.False(File.Exists(markerPaths[1] + ".v2.tmp")); + Assert.False(File.Exists(markerPaths[1] + ".migration.tmp")); + Assert.False(File.Exists(Path.Join( + Path.GetDirectoryName(markerPaths[1])!, + PinnedDirectoryCreation.GetConditionalReplacementBackupName( + Path.GetFileName(markerPaths[1]))))); + } + + private static async Task PublishLegacyOwnershipMarkersAsync( + LibraryDirectoryOwnership ownership) + { + var parentPath = Path.GetDirectoryName(ownership.CanonicalPath) + ?? throw new InvalidOperationException( + "The test ownership path has no parent directory."); + using var parent = PinnedDirectoryCreation.OpenPinnedBoundary(parentPath); + using var publication = parent.OpenExistingChildForPublication( + Path.GetFileName(ownership.CanonicalPath)); + await PinnedLibraryDirectoryOwnershipMarker.EnsureAsync( + ownership, + publication, + CancellationToken.None); + } + private LibraryDirectoryOwnershipReconciler CreateOwnershipReconciler() => new( _factory, @@ -1441,6 +1397,7 @@ private async Task PrepareLegacyMissingBothAsync( FileSystemPathSemantics.CurrentHostDefault, "test")); var ownershipKey = Assert.IsType(ownership.PathOwnershipKey); + await PublishLegacyOwnershipMarkersAsync(ownership); await _store.BeginRemovalAsync(ownership.Id, ownershipKey); LibraryDirectoryOwnershipMarker.DeleteInsideMarker( ownership, @@ -1464,6 +1421,19 @@ await File.WriteAllTextAsync( siblingMarkerPath); } + private async Task AssertRemovalConvergedWithPreservedLegacyArtifactAsync( + LegacyRemovalFixture fixture) + { + await using var verification = await _factory.CreateDbContextAsync(); + var persisted = await verification.LibraryDirectoryOwnerships + .SingleAsync(candidate => candidate.Id == fixture.Ownership.Id); + Assert.Equal(LibraryDirectoryOwnershipState.Removed, persisted.State); + Assert.Null(persisted.PathOwnershipKey); + Assert.True(File.Exists(fixture.SiblingMarkerPath)); + Assert.False(Directory.Exists(fixture.DirectoryPath)); + Assert.False(Directory.Exists(fixture.QuarantinePath)); + } + private async Task AssertLegacyRecoveryRejectedAsync( LegacyRemovalFixture fixture) { diff --git a/tests/Features/Infrastructure/Library/Moving/EfMoveExecutionStoreTests.cs b/tests/Features/Infrastructure/Library/Moving/EfMoveExecutionStoreTests.cs index 77646af78..301ecf1a5 100644 --- a/tests/Features/Infrastructure/Library/Moving/EfMoveExecutionStoreTests.cs +++ b/tests/Features/Infrastructure/Library/Moving/EfMoveExecutionStoreTests.cs @@ -1,5 +1,6 @@ using System.Data.Common; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; using Listenarr.Tests.Common; @@ -66,6 +67,150 @@ public async Task SourceManifestOperations_ExcludeTargetBoundaryAuthorization() entries.Single(MoveManifestIdentity.IsTargetBoundaryAuthorization).CopyState); } + [Fact] + public async Task UpdateTargetEntryStateAsync_LeaseReplacedAfterLoad_CannotPersistStaleState() + { + var databasePath = Path.Join( + FileService.GetTempPath(), + $"move-execution-lease-race-{Guid.NewGuid():N}.db"); + var options = new DbContextOptionsBuilder() + .UseSqlite($"Data Source={databasePath};Foreign Keys=False") + .Options; + var factory = new TestDbContextFactory(options); + var jobId = Guid.NewGuid(); + var originalLease = new MoveLeaseToken("worker-1", 1); + await using (var db = await factory.CreateDbContextAsync()) + { + await db.Database.EnsureCreatedAsync(); + db.MoveJobs.Add(new MoveJob + { + Id = jobId, + AudiobookId = 1, + RequestedPath = Path.Join(FileService.GetTempPath(), "target"), + SourcePath = Path.Join(FileService.GetTempPath(), "source"), + Status = MoveJobStatus.Running, + LeaseOwner = originalLease.Owner, + LeaseGeneration = originalLease.Generation, + LeaseExpiresAt = DateTime.UtcNow.AddMinutes(5), + ActiveDeduplicationKey = $"test:{jobId:N}", + Entries = + [ + new MoveJobEntry + { + RelativePath = "book.m4b", + EntryType = MoveJobEntryType.File, + Length = 5, + Sha256 = new string('A', 64) + } + ] + }); + await db.SaveChangesAsync(); + } + + var stateLoaded = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var releaseWriter = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var store = new EfMoveExecutionStore(factory, TimeProvider.System) + { + AfterMarkerlessStateLoadedForTestAsync = async () => + { + stateLoaded.TrySetResult(); + await releaseWriter.Task; + } + }; + var staleUpdate = store.UpdateTargetEntryStateAsync( + jobId, + originalLease, + "book.m4b", + MoveJobEntryCopyState.Staged, + "target-generation", + CancellationToken.None); + await stateLoaded.Task; + + await using (var replacement = await factory.CreateDbContextAsync()) + { + var job = await replacement.MoveJobs.SingleAsync( + candidate => candidate.Id == jobId); + job.LeaseOwner = "worker-2"; + job.LeaseGeneration = 2; + job.LeaseExpiresAt = DateTime.UtcNow.AddMinutes(5); + await replacement.SaveChangesAsync(); + } + + releaseWriter.TrySetResult(); + await Assert.ThrowsAsync(async () => + await staleUpdate); + + await using var verification = await factory.CreateDbContextAsync(); + var entry = await verification.MoveJobEntries + .AsNoTracking() + .SingleAsync(candidate => candidate.MoveJobId == jobId); + Assert.Equal(MoveJobEntryCopyState.Pending, entry.CopyState); + Assert.Null(entry.TargetPhysicalObjectIdentity); + } + + [Fact] + public async Task EnsureMutationAuthorizedAsync_RowLimitedBoundaryProofQuery_IsDeterministicallyOrdered() + { + var databasePath = Path.Join( + FileService.GetTempPath(), + $"move-execution-ordered-boundary-proof-{Guid.NewGuid():N}.db"); + var options = new DbContextOptionsBuilder() + .UseSqlite($"Data Source={databasePath};Foreign Keys=False") + .ConfigureWarnings(warnings => warnings.Throw( + CoreEventId.RowLimitingOperationWithoutOrderByWarning)) + .Options; + var factory = new TestDbContextFactory(options); + var jobId = Guid.NewGuid(); + var lease = new MoveLeaseToken("worker", 1); + var source = FileService.GetTempDirectory("move-execution-ordered-source"); + var target = FileService.GetTempDirectory("move-execution-ordered-target"); + var semantics = FileSystemPathSemantics.CurrentHostDefault; + string targetBoundaryIdentity; + using (var boundary = PinnedDirectoryCreation.OpenPinnedBoundary(target)) + { + targetBoundaryIdentity = ManagedDirectoryIdentity.CreateMarkerless( + boundary.GetDirectoryObjectIdentity()); + } + + await using (var db = await factory.CreateDbContextAsync()) + { + await db.Database.EnsureCreatedAsync(); + db.MoveJobs.Add(new MoveJob + { + Id = jobId, + AudiobookId = 1, + RequestedPath = target, + SourcePath = source, + TargetIdentityBoundary = target, + Status = MoveJobStatus.Running, + LeaseOwner = lease.Owner, + LeaseGeneration = lease.Generation, + LeaseExpiresAt = DateTime.UtcNow.AddMinutes(5), + ActiveDeduplicationKey = $"test:{jobId:N}", + Entries = + [ + MoveManifestIdentity.CreateTargetBoundaryAuthorization( + ManagedDirectoryIdentity.CurrentVersion, + targetBoundaryIdentity) + ] + }); + await db.SaveChangesAsync(); + } + + var store = new EfMoveExecutionStore(factory, TimeProvider.System); + + await store.EnsureMutationAuthorizedAsync( + jobId, + lease, + source, + target, + semantics, + semantics, + CancellationToken.None); + } + [Fact] public async Task ProviderFailures_AreTranslatedAcrossMoveExecutionBoundary() { @@ -131,6 +276,17 @@ public async Task ProviderFailures_AreTranslatedAcrossMoveExecutionBoundary() } } + private sealed class TestDbContextFactory( + DbContextOptions options) : + IDbContextFactory + { + public ListenArrDbContext CreateDbContext() => new(options); + + public Task CreateDbContextAsync( + CancellationToken cancellationToken = default) => + Task.FromResult(CreateDbContext()); + } + private sealed class ThrowingDbContextFactory : IDbContextFactory { public ListenArrDbContext CreateDbContext() => diff --git a/tests/Features/Infrastructure/Library/Moving/MoveCleanupBoundaryResolverTests.cs b/tests/Features/Infrastructure/Library/Moving/MoveCleanupBoundaryResolverTests.cs index 014004353..8dadc76a1 100644 --- a/tests/Features/Infrastructure/Library/Moving/MoveCleanupBoundaryResolverTests.cs +++ b/tests/Features/Infrastructure/Library/Moving/MoveCleanupBoundaryResolverTests.cs @@ -112,6 +112,29 @@ public async Task ResolveAsync_BroadPersistedBoundary_IsNarrowedToConfiguredRoot Assert.Equal(configuredRoot, result.Boundary); } + [Fact] + public async Task ResolveAsync_PersistedConfiguredRoot_IsRevalidatedAsConfiguredRoot() + { + var configuredRoot = FileService.GetTempDirectory("move-boundary-persisted-configured-root"); + var source = Path.Join(configuredRoot, "Author", "Title", "test"); + var target = Path.Join( + FileService.GetTempDirectory("move-boundary-persisted-configured-target"), + "Author", + "Title", + "test"); + var resolver = CreateResolver(); + + var result = await resolver.ResolveAsync( + source, + target, + [new RootFolder { Name = "Library", Path = configuredRoot }], + configuredRoot); + + Assert.True(result.IsAvailable, result.Reason); + Assert.Equal(MoveCleanupBoundaryKind.ConfiguredRoot, result.Kind); + Assert.Equal(configuredRoot, result.Boundary); + } + [Fact] public async Task ResolveAsync_NarrowPersistedBoundary_IsPreservedWithinConfiguredRoot() { diff --git a/tests/Features/Infrastructure/Library/Moving/MoveJobProcessorArtifactCleanupTests.cs b/tests/Features/Infrastructure/Library/Moving/MoveJobProcessorArtifactCleanupTests.cs index fb2f1b51e..a20d3a122 100644 --- a/tests/Features/Infrastructure/Library/Moving/MoveJobProcessorArtifactCleanupTests.cs +++ b/tests/Features/Infrastructure/Library/Moving/MoveJobProcessorArtifactCleanupTests.cs @@ -17,7 +17,11 @@ public async Task ProcessJobAsync_ArtifactCleanupFailsOnce_SchedulesAndCompletes Title = "Artifact Cleanup Retry", BasePath = source }); - var (queue, job) = await CreateQueuedMoveJobAsync(audiobook, target, source); + var (queue, job) = await CreateQueuedMoveJobAsync( + audiobook, + target, + source, + executionProtocolVersion: MoveExecutionProtocol.LegacyFilesystemArtifacts); var faultingContentMoveService = new AudiobookContentMoveService( _provider.GetRequiredService>(), _provider.GetRequiredService>(), @@ -68,7 +72,11 @@ public async Task ProcessJobAsync_EmptySourceStateNativeDeleteFailure_SchedulesA Title = "Source State Delete Retry", BasePath = source }); - var (queue, job) = await CreateQueuedMoveJobAsync(audiobook, target, source); + var (queue, job) = await CreateQueuedMoveJobAsync( + audiobook, + target, + source, + executionProtocolVersion: MoveExecutionProtocol.LegacyFilesystemArtifacts); var faultingContentMoveService = new AudiobookContentMoveService( _provider.GetRequiredService>(), _provider.GetRequiredService>(), @@ -120,7 +128,11 @@ public async Task ProcessJobAsync_ForeignSourceFileBeforeMarkerDelete_PreservesF Title = "Recreated Source", BasePath = source }); - var (queue, job) = await CreateQueuedMoveJobAsync(audiobook, target, source); + var (queue, job) = await CreateQueuedMoveJobAsync( + audiobook, + target, + source, + executionProtocolVersion: MoveExecutionProtocol.LegacyFilesystemArtifacts); var faultingContentMoveService = new AudiobookContentMoveService( _provider.GetRequiredService>(), _provider.GetRequiredService>(), @@ -156,7 +168,11 @@ public async Task ProcessJobAsync_TargetChangesBeforeMarkerDelete_RequiresAttent Title = "Mutated Target", BasePath = source }); - var (queue, job) = await CreateQueuedMoveJobAsync(audiobook, target, source); + var (queue, job) = await CreateQueuedMoveJobAsync( + audiobook, + target, + source, + executionProtocolVersion: MoveExecutionProtocol.LegacyFilesystemArtifacts); var faultingContentMoveService = new AudiobookContentMoveService( _provider.GetRequiredService>(), _provider.GetRequiredService>(), @@ -192,7 +208,11 @@ public async Task ProcessJobAsync_UnownedFileAppearsAfterFinalHash_PreservesMark Title = "Final Hash Ownership Race", BasePath = source }); - var (queue, job) = await CreateQueuedMoveJobAsync(audiobook, target, source); + var (queue, job) = await CreateQueuedMoveJobAsync( + audiobook, + target, + source, + executionProtocolVersion: MoveExecutionProtocol.LegacyFilesystemArtifacts); var contentMoveService = new AudiobookContentMoveService( _provider.GetRequiredService>(), _provider.GetRequiredService>(), @@ -250,7 +270,7 @@ public async Task ProcessJobAsync_FinalizationIoFailure_SchedulesAndCompletesRet Assert.Equal(MoveJobStatus.RetryScheduled, retryJob.Status); Assert.Equal(MoveJobPhase.Finalizing, retryJob.Phase); Assert.True(Directory.Exists(sourceParent)); - Assert.True(File.Exists(Path.Join(target, $".listenarr-move-{job.Id:N}.pending"))); + Assert.False(File.Exists(Path.Join(target, $".listenarr-move-{job.Id:N}.pending"))); Assert.NotNull(retryJob.NextAttemptAt); Assert.Null(await queue.TryClaimJobAsync(job.Id, LeaseOwner)); await MakeRetryDueAsync(job.Id); @@ -272,11 +292,11 @@ await _provider.GetRequiredService() public async Task ProcessJobAsync_SourceAncestorReceivesContentDuringFinalization_PreservesItAndCompletes() { var sourceRoot = FileService.GetTempDirectory("move-processor-finalize-arrival-root"); + await AddAuthorizedRootAsync(sourceRoot, "Finalization Arrival Source Root"); var sourceParent = Path.Join(sourceRoot, "Author", "Old Title"); var source = Path.Join(sourceParent, "test"); Directory.CreateDirectory(source); await FileService.GetFileAsync(source, "book.m4b", "audio"); - await RecordOwnedDirectoryHierarchyAsync(sourceRoot, sourceParent); var target = Path.Join( FileService.GetTempPath(), $"move-processor-finalize-arrival-dst-{Guid.NewGuid():N}"); @@ -371,7 +391,7 @@ await RecordOwnedDirectoryHierarchyAsync( retryDelays); Assert.All(retryDelays, delay => Assert.True(delay <= MoveTimingPolicy.MaxRetryDelay)); - Assert.True(File.Exists(Path.Join( + Assert.False(File.Exists(Path.Join( target, $".listenarr-move-{initialJob.Id:N}.pending"))); } diff --git a/tests/Features/Infrastructure/Library/Moving/MoveJobProcessorFinalizedRecoveryTests.cs b/tests/Features/Infrastructure/Library/Moving/MoveJobProcessorFinalizedRecoveryTests.cs index 2ebafd4f7..5758c0841 100644 --- a/tests/Features/Infrastructure/Library/Moving/MoveJobProcessorFinalizedRecoveryTests.cs +++ b/tests/Features/Infrastructure/Library/Moving/MoveJobProcessorFinalizedRecoveryTests.cs @@ -224,7 +224,7 @@ public async Task ProcessJobAsync_MarkerlessPublishedCopy_ResumesFullFinalizatio var service = _provider.GetRequiredService(); var request = CreateMoveRequest(source, target, job, deleteEmptySource: false); var result = await service.MoveContentsAsync(request, CancellationToken.None); - File.Delete(result.RecoveryMarkerPath); + Assert.Empty(result.RecoveryMarkerPath); var persistedJob = Assert.IsType( await queue.GetJobAsync(job.Id)); Assert.Equal(MoveJobPhase.Finalizing, persistedJob.Phase); @@ -270,7 +270,7 @@ public async Task ProcessJobAsync_MarkerlessAtomicMove_WithPersistedManifest_Com audiobook.BasePath = target; await _audiobookRepository.UpdateAsync(audiobook); await service.FinalizeMoveAsync(request, result, CancellationToken.None); - File.Delete(result.RecoveryMarkerPath); + Assert.Empty(result.RecoveryMarkerPath); var processor = _provider.GetRequiredService(); await processor.ProcessJobAsync(job, CancellationToken.None); @@ -305,7 +305,7 @@ public async Task ProcessJobAsync_MarkerlessAtomicTargetChanged_RequiresAttentio audiobook.BasePath = target; await _audiobookRepository.UpdateAsync(audiobook); await service.FinalizeMoveAsync(request, result, CancellationToken.None); - File.Delete(result.RecoveryMarkerPath); + Assert.Empty(result.RecoveryMarkerPath); Directory.Delete(target, recursive: true); if (!string.Equals(mutation, "deleted", StringComparison.Ordinal)) { @@ -350,7 +350,8 @@ private async Task CreateMarkerlessFinalizedCopySt audiobook.BasePath = target; await _audiobookRepository.UpdateAsync(audiobook); await service.FinalizeMoveAsync(request, result, CancellationToken.None); - File.Delete(result.RecoveryMarkerPath); + result.TargetVerificationLease?.Dispose(); + Assert.Empty(result.RecoveryMarkerPath); return new MarkerlessFinalizedCopyState(queue, job, source, target); } diff --git a/tests/Features/Infrastructure/Library/Moving/MoveJobProcessorTests.cs b/tests/Features/Infrastructure/Library/Moving/MoveJobProcessorTests.cs index 1cca832a9..07f6b3260 100644 --- a/tests/Features/Infrastructure/Library/Moving/MoveJobProcessorTests.cs +++ b/tests/Features/Infrastructure/Library/Moving/MoveJobProcessorTests.cs @@ -126,12 +126,10 @@ await _provider.GetRequiredService() public async Task ProcessJobAsync_RemovesEmptySourceAncestorsWithinConfiguredRoot() { var sourceRoot = FileService.GetTempDirectory("move-processor-cleanup-root"); + await AddAuthorizedRootAsync(sourceRoot, "Move Cleanup Source Root"); var source = Path.Join(sourceRoot, "Author", "Series", "Title", "test"); Directory.CreateDirectory(source); await FileService.GetFileAsync(source, "book.m4b", "audio"); - await RecordOwnedDirectoryHierarchyAsync( - sourceRoot, - Path.GetDirectoryName(source)!); var target = Path.Join(FileService.GetTempPath(), $"move-processor-cleanup-dst-{Guid.NewGuid():N}"); var audiobook = await _audiobookRepository.AddAsync(new Audiobook { Title = "Cleanup Test", BasePath = source }); var (queue, job) = await CreateQueuedMoveJobAsync(audiobook, target, source); @@ -1357,7 +1355,8 @@ await ownershipStore.RecordCreatedAsync( Audiobook audiobook, string requestedPath, string sourcePath, - bool deleteEmptySource = true) + bool deleteEmptySource = true, + int executionProtocolVersion = MoveExecutionProtocol.Current) { var queue = _provider.GetRequiredService(); var semanticsResolver = _provider @@ -1404,6 +1403,17 @@ await EnsureTrackedManifestRowsAsync( deleteEmptySource)); var job = Assert.IsType( await queue.GetJobAsync(jobId)); + if (job.ExecutionProtocolVersion != executionProtocolVersion) + { + var factory = _provider.GetRequiredService< + IDbContextFactory>(); + await using var db = await factory.CreateDbContextAsync(); + var persisted = await db.MoveJobs.SingleAsync( + candidate => candidate.Id == job.Id); + persisted.ExecutionProtocolVersion = executionProtocolVersion; + await db.SaveChangesAsync(); + job.ExecutionProtocolVersion = executionProtocolVersion; + } await PrepareJobForProcessingAsync(queue, job); return (queue, job); } diff --git a/tests/Features/Infrastructure/Library/Moving/MoveJobProcessor_FileReferenceRewriteTests.cs b/tests/Features/Infrastructure/Library/Moving/MoveJobProcessor_FileReferenceRewriteTests.cs index 9d89db88b..16bdd1db3 100644 --- a/tests/Features/Infrastructure/Library/Moving/MoveJobProcessor_FileReferenceRewriteTests.cs +++ b/tests/Features/Infrastructure/Library/Moving/MoveJobProcessor_FileReferenceRewriteTests.cs @@ -162,8 +162,8 @@ await AddTrackedFileAsync( var movedChapter = Assert.Single(updatedFiles, file => file.Path == movedChapterPath); Assert.Equal(GetPhysicalObjectIdentity(movedBookPath), movedBook.PhysicalObjectIdentity); Assert.Equal(GetPhysicalObjectIdentity(movedChapterPath), movedChapter.PhysicalObjectIdentity); - Assert.NotEqual(originalPhysicalIdentities[bookPath], movedBook.PhysicalObjectIdentity); - Assert.NotEqual(originalPhysicalIdentities[chapterPath], movedChapter.PhysicalObjectIdentity); + Assert.Equal(originalPhysicalIdentities[bookPath], movedBook.PhysicalObjectIdentity); + Assert.Equal(originalPhysicalIdentities[chapterPath], movedChapter.PhysicalObjectIdentity); Assert.DoesNotContain( updatedFiles, file => file.Path?.StartsWith(source, StringComparison.Ordinal) == true); diff --git a/tests/Features/Infrastructure/Library/Moving/MoveSourceManifestServiceTests.cs b/tests/Features/Infrastructure/Library/Moving/MoveSourceManifestServiceTests.cs index 8211ef8cd..355f81c5d 100644 --- a/tests/Features/Infrastructure/Library/Moving/MoveSourceManifestServiceTests.cs +++ b/tests/Features/Infrastructure/Library/Moving/MoveSourceManifestServiceTests.cs @@ -45,6 +45,44 @@ public async Task BuildAsync_BroadAuthorBasePath_UsesTrackedBookDirectory() entry.RelativePath.Contains("Book Two", StringComparison.Ordinal)); } + [Fact] + public async Task BuildPlanAsync_ProducesStructuralManifestWithoutReadingContentHash() + { + var root = FileService.GetTempDirectory("move-plan-metadata-only"); + var filePath = await FileService.GetFileAsync( + root, + "Book.m4b", + "source bytes"); + var audiobook = await _audiobookRepository.AddAsync( + new AudiobookBuilder() + .WithTitle("Book") + .WithBasePath(root) + .Build()); + await AddTrackedFileAsync(audiobook, filePath, root); + + var plan = await _provider + .GetRequiredService() + .BuildPlanAsync(new AudiobookPathReferenceSnapshot( + audiobook.Id, + audiobook.BasePath, + audiobook.FilePath)); + var fullManifest = await _provider + .GetRequiredService() + .BuildAsync(audiobook); + + var plannedFile = Assert.Single( + plan.Entries, + entry => entry.EntryType == MoveJobEntryType.File); + var hashedFile = Assert.Single( + fullManifest.Entries, + entry => entry.EntryType == MoveJobEntryType.File); + Assert.Null(plannedFile.Sha256); + Assert.NotNull(hashedFile.Sha256); + Assert.Equal(hashedFile.RelativePath, plannedFile.RelativePath); + Assert.Equal(hashedFile.Length, plannedFile.Length); + Assert.Equal(hashedFile.LastWriteTimeUtc, plannedFile.LastWriteTimeUtc); + } + [Fact] public async Task BuildAsync_SharedFlatFolder_IncludesOnlyTrackedFile() { diff --git a/tests/Features/Infrastructure/Library/Moving/RootFolderRelocationServiceTests.cs b/tests/Features/Infrastructure/Library/Moving/RootFolderRelocationServiceTests.cs index 6bbea8383..ef0a1606c 100644 --- a/tests/Features/Infrastructure/Library/Moving/RootFolderRelocationServiceTests.cs +++ b/tests/Features/Infrastructure/Library/Moving/RootFolderRelocationServiceTests.cs @@ -198,7 +198,7 @@ await AddTrackedFileAsync( } [Fact] - public async Task StartRelocation_EmptyNestedTarget_RetainsDirectoriesAndRetiresMarkers() + public async Task StartRelocation_EmptyNestedTarget_RetainsDirectoriesWithoutArtifacts() { var source = Path.Join( TempRoot, @@ -227,7 +227,7 @@ public async Task StartRelocation_EmptyNestedTarget_RetainsDirectoriesAndRetires var service = CreateService(); service.TargetReservationDirectoryFlushedForTest = path => { - var reservationIndex = flushOrder.Count / 2; + var reservationIndex = flushOrder.Count; using var observation = _factory.CreateDbContext(); statesObservedAtFlush.Add(observation .RootFolderRelocationCreatedDirectories @@ -250,11 +250,7 @@ public async Task StartRelocation_EmptyNestedTarget_RetainsDirectoriesAndRetires .ToListAsync(); Assert.True(reservations.Count >= 3); Assert.Equal( - reservations.SelectMany(reservation => new[] - { - reservation.CanonicalPath, - Path.GetDirectoryName(reservation.CanonicalPath)! - }), + reservations.Select(reservation => reservation.CanonicalPath), flushOrder); Assert.All(statesObservedAtFlush, state => Assert.Equal( @@ -266,9 +262,14 @@ public async Task StartRelocation_EmptyNestedTarget_RetainsDirectoriesAndRetires RootFolderRelocationCreatedDirectoryState.Retained, reservation.State); Assert.True(Directory.Exists(reservation.CanonicalPath)); - Assert.False(File.Exists(Path.Join( - reservation.CanonicalPath, - ".listenarr-relocation-directory.json"))); + Assert.DoesNotContain( + Directory.EnumerateFileSystemEntries( + reservation.CanonicalPath, + "*", + SearchOption.AllDirectories), + path => Path.GetFileName(path).Contains( + ".listenarr-", + StringComparison.Ordinal)); }); } @@ -322,14 +323,14 @@ await verification.RootFolderRelocationCreatedDirectories } [Fact] - public async Task ReconcileActive_RetainedReservationMarkerCleanupResumesAfterCrash() + public async Task ReconcileActive_RetainedReservationsRemainArtifactFreeAndIdempotent() { var source = Path.Join( TempRoot, - $"reservation-marker-recovery-source-{Guid.NewGuid():N}"); + $"reservation-markerless-recovery-source-{Guid.NewGuid():N}"); var target = Path.Join( TempRoot, - $"reservation-marker-recovery-target-{Guid.NewGuid():N}", + $"reservation-markerless-recovery-target-{Guid.NewGuid():N}", "nested"); Directory.CreateDirectory(source); int rootId; @@ -345,32 +346,12 @@ public async Task ReconcileActive_RetainedReservationMarkerCleanupResumesAfterCr rootId = root.Id; } - var interrupted = CreateService(); - interrupted.BeforeReservationMarkerRetirementForTest = _ => - throw new IOException( - "Injected crash before post-commit marker retirement."); - var result = await interrupted.StartAsync( + var result = await CreateService().StartAsync( rootId, BuildRelocationCommand(target)); - Assert.Equal(RootFolderRelocationStatus.Completed, result.Status); - await using (var verification = - await _factory.CreateDbContextAsync()) - { - var reservations = await verification - .RootFolderRelocationCreatedDirectories - .ToListAsync(); - Assert.All(reservations, reservation => - { - Assert.Equal( - RootFolderRelocationCreatedDirectoryState.Retained, - reservation.State); - Assert.True(File.Exists(Path.Join( - reservation.CanonicalPath, - ".listenarr-relocation-directory.json"))); - }); - } + await CreateService().ReconcileActiveAsync(); await CreateService().ReconcileActiveAsync(); await using var completed = @@ -378,14 +359,22 @@ await _factory.CreateDbContextAsync()) var completedReservations = await completed .RootFolderRelocationCreatedDirectories .ToListAsync(); + Assert.NotEmpty(completedReservations); Assert.All(completedReservations, reservation => { Assert.Equal( RootFolderRelocationCreatedDirectoryState.Retained, reservation.State); + Assert.True(Directory.Exists(reservation.CanonicalPath)); + }); + Assert.All(completedReservations, reservation => + { Assert.False(File.Exists(Path.Join( reservation.CanonicalPath, ".listenarr-relocation-directory.json"))); + Assert.False(File.Exists(Path.Join( + Path.GetDirectoryName(reservation.CanonicalPath)!, + $".listenarr-relocation-parent-{reservation.OwnershipToken}.json"))); }); } @@ -413,26 +402,25 @@ public async Task ReconcileActive_ForeignPersistedReservationPath_DoesNotTouchWi rootId = root.Id; } - var interrupted = CreateService(); - interrupted.BeforeReservationMarkerRetirementForTest = _ => - throw new IOException( - "Injected crash before post-commit marker retirement."); - var result = await interrupted.StartAsync( + var result = await CreateService().StartAsync( rootId, BuildRelocationCommand(target)); Assert.Equal(RootFolderRelocationStatus.Completed, result.Status); - List nativeMarkerPaths; + List nativeSentinelPaths; await using (var db = await _factory.CreateDbContextAsync()) { var reservations = await db.RootFolderRelocationCreatedDirectories .ToListAsync(); - nativeMarkerPaths = reservations + nativeSentinelPaths = reservations .Select(reservation => Path.Join( reservation.CanonicalPath, - ".listenarr-relocation-directory.json")) + "user-content.txt")) .ToList(); - Assert.All(nativeMarkerPaths, path => Assert.True(File.Exists(path))); + foreach (var sentinel in nativeSentinelPaths) + { + await File.WriteAllTextAsync(sentinel, "preserve"); + } foreach (var reservation in reservations) { @@ -446,13 +434,13 @@ public async Task ReconcileActive_ForeignPersistedReservationPath_DoesNotTouchWi await CreateService().ReconcileActiveAsync(); - Assert.All(nativeMarkerPaths, path => Assert.True( + Assert.All(nativeSentinelPaths, path => Assert.True( File.Exists(path), $"Foreign persisted reservation path touched Windows alias: {path}")); } [LinuxFact] - public async Task ReconcileActive_AmbiguousPersistedReservationPath_PreservesMarker() + public async Task ReconcileActive_AmbiguousPersistedReservationPath_PreservesUserContent() { var source = Path.Join( TempRoot, @@ -475,26 +463,25 @@ public async Task ReconcileActive_AmbiguousPersistedReservationPath_PreservesMar rootId = root.Id; } - var interrupted = CreateService(); - interrupted.BeforeReservationMarkerRetirementForTest = _ => - throw new IOException( - "Injected crash before post-commit marker retirement."); - var result = await interrupted.StartAsync( + var result = await CreateService().StartAsync( rootId, BuildRelocationCommand(target)); Assert.Equal(RootFolderRelocationStatus.Completed, result.Status); - List nativeMarkerPaths; + List nativeSentinelPaths; await using (var db = await _factory.CreateDbContextAsync()) { var reservations = await db.RootFolderRelocationCreatedDirectories .ToListAsync(); - nativeMarkerPaths = reservations + nativeSentinelPaths = reservations .Select(reservation => Path.Join( reservation.CanonicalPath, - ".listenarr-relocation-directory.json")) + "user-content.txt")) .ToList(); - Assert.All(nativeMarkerPaths, path => Assert.True(File.Exists(path))); + foreach (var sentinel in nativeSentinelPaths) + { + await File.WriteAllTextAsync(sentinel, "preserve"); + } foreach (var reservation in reservations) { @@ -509,9 +496,9 @@ public async Task ReconcileActive_AmbiguousPersistedReservationPath_PreservesMar await CreateService().ReconcileActiveAsync(); - Assert.All(nativeMarkerPaths, path => Assert.True( + Assert.All(nativeSentinelPaths, path => Assert.True( File.Exists(path), - $"Ambiguous persisted reservation path retired marker: {path}")); + $"Ambiguous persisted reservation path touched user content: {path}")); } [Fact] @@ -550,20 +537,18 @@ await AddTrackedFileAsync( rootId = root.Id; } - var finalParent = Path.GetDirectoryName(target)!; var service = CreateService(); - service.TargetReservationDirectoryFlushedForTest = path => + service.AfterTargetReservationStatePersistedForTest = path => { - if (Directory.Exists(target) - && string.Equals( + if (string.Equals( path, - finalParent, + target, OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal)) { throw new IOException( - "Injected crash after the final reserved child and parent were flushed."); + "Injected crash after the final reserved directory state was persisted."); } }; await Assert.ThrowsAsync(() => @@ -662,7 +647,7 @@ await Assert.ThrowsAsync(() => TargetIdentityEnrollmentState.Unavailable, relocation.TargetIdentityEnrollmentState); Assert.Contains( - "Target reservations", + "target reservation", relocation.Error, StringComparison.OrdinalIgnoreCase); } @@ -739,12 +724,22 @@ await _factory.CreateDbContextAsync()) Assert.Equal( TargetIdentityEnrollmentState.Authorized, relocation.TargetIdentityEnrollmentState); + var recoveredReservations = await recovered + .RootFolderRelocationCreatedDirectories + .ToListAsync(); + Assert.Single( + recoveredReservations, + reservation => reservation.State == + RootFolderRelocationCreatedDirectoryState.Retained); Assert.All( - await recovered.RootFolderRelocationCreatedDirectories - .ToListAsync(), - reservation => Assert.Equal( - RootFolderRelocationCreatedDirectoryState.Created, - reservation.State)); + recoveredReservations, + reservation => Assert.Contains( + reservation.State, + new[] + { + RootFolderRelocationCreatedDirectoryState.Created, + RootFolderRelocationCreatedDirectoryState.Retained + })); } var result = await restarted.RetryAsync(relocationId); @@ -788,7 +783,7 @@ public async Task ReconcileActive_ParentIntentPublishedBeforeChildCreation_Resum var injected = false; var interrupted = CreateService(); - interrupted.AfterReservationParentMarkerPublishedForTest = _ => + interrupted.AfterReservationParentIntentPersistedForTest = _ => { if (!injected) { @@ -813,23 +808,24 @@ await _factory.CreateDbContextAsync()) Assert.Equal( RootFolderRelocationStatus.NeedsAttention, relocation.Status); - var firstReservation = await verification + var plannedReservation = await verification .RootFolderRelocationCreatedDirectories - .OrderBy(candidate => candidate.CanonicalPath.Length) - .FirstAsync(); + .SingleAsync(candidate => candidate.State == + RootFolderRelocationCreatedDirectoryState.Planned); + Assert.False(Directory.Exists(plannedReservation.CanonicalPath)); Assert.Equal( - RootFolderRelocationCreatedDirectoryState.Planned, - firstReservation.State); - Assert.False(Directory.Exists(firstReservation.CanonicalPath)); - Assert.True(File.Exists(Path.Join( - Path.GetDirectoryName(firstReservation.CanonicalPath)!, - $".listenarr-relocation-parent-{firstReservation.OwnershipToken}.json"))); + ManagedDirectoryIdentity.CurrentVersion, + plannedReservation.DirectoryObjectIdentityVersion); + Assert.False(string.IsNullOrWhiteSpace( + plannedReservation.DirectoryObjectIdentity)); + Assert.False(File.Exists(Path.Join( + Path.GetDirectoryName(plannedReservation.CanonicalPath)!, + $".listenarr-relocation-parent-{plannedReservation.OwnershipToken}.json"))); } var restarted = CreateService(); await restarted.ReconcileActiveAsync(); - List expectedParentMarkers; await using (var recovered = await _factory.CreateDbContextAsync()) { @@ -847,19 +843,15 @@ await _factory.CreateDbContextAsync()) reservation => Assert.Equal( RootFolderRelocationCreatedDirectoryState.Created, reservation.State)); - expectedParentMarkers = recoveredReservations - .Select(reservation => Path.Join( - Path.GetDirectoryName(reservation.CanonicalPath)!, - $".listenarr-relocation-parent-{reservation.OwnershipToken}.json")) - .ToList(); } - var remainingParentMarkers = expectedParentMarkers - .Where(File.Exists) - .Order(StringComparer.Ordinal) - .ToList(); - Assert.True( - remainingParentMarkers.Count == 0, - $"Unretired parent markers: {string.Join(", ", remainingParentMarkers)}"); + Assert.DoesNotContain( + Directory.EnumerateFileSystemEntries( + Path.GetDirectoryName(target)!, + "*", + SearchOption.AllDirectories), + path => Path.GetFileName(path).Contains( + ".listenarr-", + StringComparison.Ordinal)); var result = await restarted.RetryAsync(relocationId); Assert.Equal(RootFolderRelocationStatus.Completed, result.Status); @@ -888,14 +880,12 @@ public async Task ReconcileActive_PublishedChildWithoutParentIntent_RemainsUntru rootId = root.Id; } - var parent = Path.GetDirectoryName(target)!; var interrupted = CreateService(); interrupted.TargetReservationDirectoryFlushedForTest = path => { - if (Directory.Exists(target) - && string.Equals( + if (string.Equals( path, - parent, + target, OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal)) @@ -923,12 +913,10 @@ await _factory.CreateDbContextAsync()) Assert.Equal( RootFolderRelocationCreatedDirectoryState.Planned, reservation.State); + reservation.DirectoryObjectIdentityVersion = null; + reservation.DirectoryObjectIdentity = null; + await verification.SaveChangesAsync(); } - var parentMarker = Path.Join( - parent, - $".listenarr-relocation-parent-{reservation.OwnershipToken}.json"); - Assert.True(File.Exists(parentMarker)); - File.Delete(parentMarker); await CreateService().ReconcileActiveAsync(); @@ -948,12 +936,13 @@ await _factory.CreateDbContextAsync()) RootFolderRelocationCreatedDirectoryState.Planned, blockedReservation.State); Assert.True(Directory.Exists(target)); - Assert.True(File.Exists(Path.Join( + Assert.Empty(Directory.EnumerateFileSystemEntries(target)); + Assert.False(File.Exists(Path.Join( target, ".listenarr-relocation-directory.json"))); Assert.False(File.Exists(Path.Join( - target, - ManagedDirectoryEnrollment.FileName))); + Path.GetDirectoryName(target)!, + $".listenarr-relocation-parent-{reservation.OwnershipToken}.json"))); } [Fact] @@ -2613,7 +2602,7 @@ public async Task ReconcileOwnershipMigration_TargetGenerationReplacedAtSourceRe .SingleAsync(candidate => candidate.RelocationId == scenario.RelocationId); Assert.Equal(RootFolderRelocationStatus.NeedsAttention, relocation.Status); Assert.Contains( - "enrolled physical generation", + "physical generation", relocation.Error ?? string.Empty, StringComparison.OrdinalIgnoreCase); Assert.Equal( @@ -2710,7 +2699,7 @@ public async Task ReconcileOwnershipMigration_FirstFailure_DoesNotPoisonLaterSag } [Fact] - public async Task ReconcileOwnershipMigration_DistinctHardlinkedSourceMarker_RetiresSourceName() + public async Task ReconcileOwnershipMigration_DistinctHardlinkedMarkersAreFullyRetired() { var scenario = await SeedPublishedOwnershipMigrationAsync(); var sourceRoot = Path.Join( @@ -2785,7 +2774,7 @@ await File.WriteAllTextAsync( scenario.RootPath, $".listenarr-directory-owner-{scenario.OwnershipToken}.json"); Assert.False(File.Exists(sourceSibling)); - Assert.True(File.Exists(targetSiblingAfter)); + Assert.False(File.Exists(targetSiblingAfter)); await using var verification = await _factory.CreateDbContextAsync(); Assert.False(await verification .LibraryDirectoryOwnershipPathMigrations.AnyAsync()); @@ -2830,7 +2819,7 @@ public async Task ReconcileOwnershipMigration_SourceAlreadyRetired_TargetReplace } [Fact] - public async Task ReconcileOwnershipMigration_EquivalentSiblingMarker_RemainsAfterCompletion() + public async Task ReconcileOwnershipMigration_EquivalentLegacyMarkersAreRetiredAfterCompletion() { var scenario = await SeedPublishedOwnershipMigrationAsync(); @@ -2842,8 +2831,8 @@ public async Task ReconcileOwnershipMigration_EquivalentSiblingMarker_RemainsAft var insideMarker = Path.Join( scenario.OwnedPath, LibraryDirectoryOwnershipMarker.FileName); - Assert.True(File.Exists(siblingMarker)); - Assert.True(File.Exists(insideMarker)); + Assert.False(File.Exists(siblingMarker)); + Assert.False(File.Exists(insideMarker)); await using var verification = await _factory.CreateDbContextAsync(); var ownershipAfter = await verification .LibraryDirectoryOwnerships.SingleAsync(); diff --git a/tests/Features/Infrastructure/Library/Scanning/MoveScanHandoffDispatchWorkflowTests.cs b/tests/Features/Infrastructure/Library/Scanning/MoveScanHandoffDispatchWorkflowTests.cs index ab3b5eb42..c5349bfc7 100644 --- a/tests/Features/Infrastructure/Library/Scanning/MoveScanHandoffDispatchWorkflowTests.cs +++ b/tests/Features/Infrastructure/Library/Scanning/MoveScanHandoffDispatchWorkflowTests.cs @@ -50,6 +50,14 @@ public async Task TryDispatchPendingAsync_InternalAuthorizationCancellation_Rele It.IsAny(), It.IsAny())) .ReturnsAsync(true); + var audiobookRepository = new Mock(MockBehavior.Strict); + audiobookRepository.Setup(repository => repository.GetPathReferenceSnapshotAsync( + claim.AudiobookId, + It.IsAny())) + .ReturnsAsync(new AudiobookPathReferenceSnapshot( + claim.AudiobookId, + target, + FilePath: null)); var authorization = new Mock(MockBehavior.Strict); authorization.Setup(service => service.AuthorizeAsync( target, @@ -57,6 +65,7 @@ public async Task TryDispatchPendingAsync_InternalAuthorizationCancellation_Rele .ThrowsAsync(new TaskCanceledException( "Injected internal authorization cancellation.")); using var provider = new ServiceCollection() + .AddSingleton(audiobookRepository.Object) .AddSingleton(authorization.Object) .BuildServiceProvider(); var scanQueue = new Mock(MockBehavior.Strict); @@ -84,4 +93,260 @@ public async Task TryDispatchPendingAsync_InternalAuthorizationCancellation_Rele It.IsAny()), Times.Once); scanQueue.VerifyNoOtherCalls(); } + + [Fact] + public async Task TryDispatchPendingAsync_NewerAudiobookDestination_SupersedesBeforeAuthorization() + { + var handoffId = Guid.NewGuid(); + var oldTarget = Path.Join( + Path.GetTempPath(), + "listenarr-tests", + $"move-scan-stale-{Guid.NewGuid():N}"); + var currentTarget = Path.Join( + Path.GetTempPath(), + "listenarr-tests", + $"move-scan-current-{Guid.NewGuid():N}"); + var boundary = Path.GetPathRoot(Path.GetFullPath(oldTarget)) + ?? throw new InvalidOperationException("Test target root is unavailable."); + var identity = PathIdentitySnapshot.FromResolution( + FileSystemPathSemantics.CurrentHostDefault, + FileSystemCaseSensitivityMode.Auto, + boundary, + oldTarget); + var claim = new MoveScanHandoffClaim( + handoffId, + Guid.NewGuid(), + 4402, + oldTarget, + identity, + [], + AttemptGeneration: 101, + LeaseOwner: "dispatch-stale-owner", + LeaseGeneration: 102); + var handoffStore = new Mock(MockBehavior.Strict); + handoffStore.Setup(store => store.TryClaimAsync( + handoffId, + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(claim); + handoffStore.Setup(store => store.CompleteAttemptAsync( + handoffId, + claim.AttemptGeneration, + scanJobId: null, + MoveScanTerminalOutcome.Superseded, + It.Is(error => error != null && error.Contains("newer", StringComparison.OrdinalIgnoreCase)), + 0, + 0, + oldTarget, + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new MoveScanAttemptResult( + MoveScanAttemptOutcome.Superseded, + null)); + var audiobookRepository = new Mock(MockBehavior.Strict); + audiobookRepository.Setup(repository => repository.GetPathReferenceSnapshotAsync( + claim.AudiobookId, + It.IsAny())) + .ReturnsAsync(new AudiobookPathReferenceSnapshot( + claim.AudiobookId, + currentTarget, + FilePath: null)); + var authorization = new Mock(MockBehavior.Strict); + var scanQueue = new Mock(MockBehavior.Strict); + using var provider = new ServiceCollection() + .AddSingleton(audiobookRepository.Object) + .AddSingleton(authorization.Object) + .BuildServiceProvider(); + + var result = await MoveScanHandoffDispatchWorkflow.TryDispatchPendingAsync( + handoffId, + ownerPrefix: "dispatch-stale-test", + knownAudiobook: null, + beforeEnqueue: null, + scanQueue.Object, + handoffStore.Object, + provider.GetRequiredService(), + TimeProvider.System, + NullLogger.Instance, + CancellationToken.None); + + Assert.Equal(MoveScanDispatchOutcome.Superseded, result.Outcome); + Assert.Null(result.ScanJobId); + authorization.VerifyNoOtherCalls(); + scanQueue.VerifyNoOtherCalls(); + handoffStore.Verify(store => store.CompleteAttemptAsync( + handoffId, + claim.AttemptGeneration, + null, + MoveScanTerminalOutcome.Superseded, + It.IsAny(), + 0, + 0, + oldTarget, + It.IsAny(), + It.IsAny()), Times.Once); + } + + [WindowsFact] + public async Task VerifyPublishedManifestAsync_HashlessNativeRenameReplacementGeneration_RequiresAttention() + { + var target = FileService.GetTempDirectory("move-scan-native-replacement"); + var filePath = await FileService.GetFileAsync( + target, + "book.mp3", + "audio"); + var originalLastWriteTimeUtc = File.GetLastWriteTimeUtc(filePath); + string originalIdentity; + using (var targetAnchor = PinnedDirectoryCreation.OpenPinnedBoundary(target)) + using (var file = targetAnchor.OpenExistingFile( + Path.GetFileName(filePath), + requireDeleteAccess: false)) + { + originalIdentity = file.GetObjectIdentity(); + } + + File.Delete(filePath); + await File.WriteAllTextAsync(filePath, "audio"); + File.SetLastWriteTimeUtc(filePath, originalLastWriteTimeUtc); + using (var targetAnchor = PinnedDirectoryCreation.OpenPinnedBoundary(target)) + using (var replacement = targetAnchor.OpenExistingFile( + Path.GetFileName(filePath), + requireDeleteAccess: false)) + { + Assert.NotEqual(originalIdentity, replacement.GetObjectIdentity()); + } + + var entry = new MoveJobEntry + { + RelativePath = "book.mp3", + EntryType = MoveJobEntryType.File, + Length = new FileInfo(filePath).Length, + LastWriteTimeUtc = originalLastWriteTimeUtc, + Sha256 = null, + CopyState = MoveJobEntryCopyState.Verified, + CleanupState = MoveJobEntryCleanupState.Deleted, + SourcePhysicalObjectIdentity = originalIdentity, + TargetPhysicalObjectIdentity = originalIdentity + }; + + var exception = await Assert.ThrowsAsync(() => + AudiobookContentMoveService.VerifyPublishedManifestAsync( + target, + [entry], + FileSystemPathSemantics.CurrentHostDefault, + CancellationToken.None)); + + Assert.Contains("generation", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task TryDispatchPendingAsync_HashlessNativeRenameManifest_DispatchesByPhysicalGeneration() + { + var target = FileService.GetTempDirectory("move-scan-native-target"); + var filePath = await FileService.GetFileAsync( + target, + "book.mp3", + "audio"); + string fileIdentity; + var lastWriteTimeUtc = File.GetLastWriteTimeUtc(filePath); + using (var targetAnchor = PinnedDirectoryCreation.OpenPinnedBoundary(target)) + using (var file = targetAnchor.OpenExistingFile( + Path.GetFileName(filePath), + requireDeleteAccess: false)) + { + fileIdentity = file.GetObjectIdentity(); + } + + var semantics = FileSystemPathSemantics.CurrentHostDefault; + var identity = PathIdentitySnapshot.FromResolution( + semantics, + FileSystemCaseSensitivityMode.Auto, + target, + target); + var entry = new MoveJobEntry + { + RelativePath = "book.mp3", + EntryType = MoveJobEntryType.File, + Length = new FileInfo(filePath).Length, + LastWriteTimeUtc = lastWriteTimeUtc, + Sha256 = null, + CopyState = MoveJobEntryCopyState.Verified, + CleanupState = MoveJobEntryCleanupState.Deleted, + SourcePhysicalObjectIdentity = fileIdentity, + TargetPhysicalObjectIdentity = fileIdentity + }; + var handoffId = Guid.NewGuid(); + var claim = new MoveScanHandoffClaim( + handoffId, + Guid.NewGuid(), + 4403, + target, + identity, + [entry], + AttemptGeneration: 1, + LeaseOwner: "dispatch-native-owner", + LeaseGeneration: 1); + var handoffStore = new Mock(MockBehavior.Strict); + handoffStore.Setup(store => store.TryClaimAsync( + handoffId, + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(claim); + var audiobookRepository = new Mock(MockBehavior.Strict); + audiobookRepository.Setup(repository => repository.GetPathReferenceSnapshotAsync( + claim.AudiobookId, + It.IsAny())) + .ReturnsAsync(new AudiobookPathReferenceSnapshot( + claim.AudiobookId, + target, + FilePath: null)); + var physicalIdentity = new ScanPathPhysicalIdentity( + "boundary-generation", + "scan-root-generation"); + var authorizationResult = ScanPathAuthorizationResult.Authorized( + target, + identity, + physicalIdentity); + var authorization = new Mock(MockBehavior.Strict); + authorization.Setup(service => service.AuthorizeAsync( + target, + It.IsAny())) + .ReturnsAsync(authorizationResult); + var scanJobId = Guid.NewGuid(); + var scanQueue = new Mock(MockBehavior.Strict); + scanQueue.Setup(queue => queue.EnqueueMoveHandoffScanAsync( + It.Is(audiobook => + audiobook.Id == claim.AudiobookId + && audiobook.BasePath == target), + claim, + physicalIdentity)) + .ReturnsAsync(scanJobId); + using var provider = new ServiceCollection() + .AddSingleton(audiobookRepository.Object) + .AddSingleton(authorization.Object) + .BuildServiceProvider(); + + var result = await MoveScanHandoffDispatchWorkflow.TryDispatchPendingAsync( + handoffId, + ownerPrefix: "dispatch-native-test", + knownAudiobook: new Audiobook { Id = claim.AudiobookId, Title = "Book" }, + beforeEnqueue: null, + scanQueue.Object, + handoffStore.Object, + provider.GetRequiredService(), + TimeProvider.System, + NullLogger.Instance, + CancellationToken.None); + + Assert.Equal(MoveScanDispatchOutcome.Dispatched, result.Outcome); + Assert.Equal(scanJobId, result.ScanJobId); + authorization.Verify(service => service.AuthorizeAsync( + target, + It.IsAny()), Times.Exactly(2)); + scanQueue.VerifyAll(); + } } diff --git a/tests/Features/Infrastructure/Library/Scanning/ReadOnlyLibraryBindMountTests.cs b/tests/Features/Infrastructure/Library/Scanning/ReadOnlyLibraryBindMountTests.cs new file mode 100644 index 000000000..5cc0dac1c --- /dev/null +++ b/tests/Features/Infrastructure/Library/Scanning/ReadOnlyLibraryBindMountTests.cs @@ -0,0 +1,68 @@ +using Listenarr.Tests.Builders; +using Listenarr.Tests.Common; + +namespace Listenarr.Tests.Features.Infrastructure.Library.Scanning; + +[Trait("Name", "ReadOnlyLibraryBindMountTests")] +[Trait("Category", "Infrastructure")] +public sealed class ReadOnlyLibraryBindMountTests : BaseTests +{ + [ReadOnlyBindMountFact] + public async Task ScanAsync_RealReadOnlyBindMount_DoesNotMutateLibrary() + { + var scanRoot = Path.GetFullPath( + Environment.GetEnvironmentVariable( + ReadOnlyBindMountFactAttribute.LibraryPathEnvironmentVariable) + ?? throw new InvalidOperationException( + "The read-only library bind mount was not provided.")); + var expectedFile = Path.Join( + scanRoot, + "Author", + "Book B012345678", + "01.m4b"); + Assert.True(File.Exists(expectedFile)); + + var audiobookToAdd = new AudiobookBuilder() + .WithTitle("Book") + .WithAuthor("Author") + .Build(); + audiobookToAdd.Asin = "B012345678"; + var audiobook = await _audiobookRepository.AddAsync(audiobookToAdd); + + var settings = await _applicationSettingsRepository.GetAsync() + ?? await _applicationSettingsRepository.InitializeIfMissingAsync( + new ApplicationSettingsBuilder().Build()); + settings.OutputPath = scanRoot; + await _applicationSettingsRepository.SaveAsync(settings); + + var authorization = await _provider + .GetRequiredService() + .AuthorizeAsync(scanRoot); + Assert.True(authorization.IsAuthorized, authorization.Error); + var pathIdentity = Assert.IsType( + authorization.Identity); + var physicalIdentity = Assert.IsType( + authorization.PhysicalIdentity); + + var result = await _provider.GetRequiredService() + .ScanAsync(new AudiobookScanCommand( + audiobook.Id, + scanRoot, + pathIdentity, + physicalIdentity, + IsAuthoritativeScope: true)); + + Assert.Contains(expectedFile, result.AttributedFiles); + var tracked = Assert.Single( + await _audiobookFileRepository.GetByAudiobookIdAsync(audiobook.Id)); + Assert.Equal(expectedFile, tracked.Path); + Assert.DoesNotContain( + Directory.EnumerateFileSystemEntries( + scanRoot, + "*", + SearchOption.AllDirectories), + path => Path.GetFileName(path).StartsWith( + ".listenarr", + StringComparison.OrdinalIgnoreCase)); + } +} diff --git a/tests/Features/Infrastructure/Library/Scanning/ScanJobProcessorTests.cs b/tests/Features/Infrastructure/Library/Scanning/ScanJobProcessorTests.cs index 8af7cfa4a..7b1c3f42d 100644 --- a/tests/Features/Infrastructure/Library/Scanning/ScanJobProcessorTests.cs +++ b/tests/Features/Infrastructure/Library/Scanning/ScanJobProcessorTests.cs @@ -517,7 +517,9 @@ public async Task ProcessJobAsync_ForeignPersistedBasePath_IsAuthorizedBeforeAny .Build(); Assert.False(Directory.Exists(Path.GetFullPath(audiobook.BasePath!))); var audiobookRepository = new Mock(); - audiobookRepository.Setup(repository => repository.GetByIdAsync(audiobook.Id)) + audiobookRepository.Setup(repository => repository.GetForScanAsync( + audiobook.Id, + It.IsAny())) .ReturnsAsync(audiobook); var historyRepository = new Mock(); historyRepository.Setup(repository => repository.AddAsync( @@ -572,7 +574,9 @@ public async Task ProcessJobAsync_AudiobookDeletedBeforeCompletion_MarksMoveScan .WithBasePath(basePath) .Build(); var audiobookRepository = new Mock(); - audiobookRepository.Setup(repository => repository.GetByIdAsync(audiobook.Id)) + audiobookRepository.Setup(repository => repository.GetForScanAsync( + audiobook.Id, + It.IsAny())) .ReturnsAsync(audiobook); var fileRepository = new Mock(); fileRepository.Setup(repository => repository.GetByAudiobookIdAsync( diff --git a/tests/Features/Infrastructure/Library/Scanning/ScanPathAuthorizationServiceTests.cs b/tests/Features/Infrastructure/Library/Scanning/ScanPathAuthorizationServiceTests.cs index 4ee05bd99..5d94ff1df 100644 --- a/tests/Features/Infrastructure/Library/Scanning/ScanPathAuthorizationServiceTests.cs +++ b/tests/Features/Infrastructure/Library/Scanning/ScanPathAuthorizationServiceTests.cs @@ -66,6 +66,60 @@ public async Task AuthorizeAsync_ForeignPersistedRootSyntax_CannotAliasWindowsRo Assert.True(Directory.Exists(scanRoot)); } + [WindowsFact] + public async Task AuthorizeAsync_ForeignFallbackOutputPath_DoesNotEmitWarning() + { + var configuredRoot = FileService.GetTempDirectory( + "scan-authorization-valid-windows-root"); + var scanRoot = Path.Join(configuredRoot, "Book"); + Directory.CreateDirectory(scanRoot); + var root = await AddAuthorizedRootAsync(configuredRoot); + var rootFolderService = new Mock(); + rootFolderService.Setup(service => service.GetAllAsync()) + .ReturnsAsync([root]); + var configurationService = new Mock(); + configurationService.Setup(service => service.GetApplicationSettingsAsync()) + .ReturnsAsync(new ApplicationSettings + { + OutputPath = "/server/mnt/drive/Audiobooks" + }); + var logger = new CapturingScanAuthorizationLogger(); + var service = new ScanPathAuthorizationService( + configurationService.Object, + rootFolderService.Object, + _provider.GetRequiredService(), + logger); + + var result = await service.AuthorizeAsync(scanRoot); + + Assert.True(result.IsAuthorized, result.Error); + Assert.DoesNotContain(logger.Entries, log => + log.Level == LogLevel.Warning + && log.Message.Contains("Audiobooks", StringComparison.Ordinal)); + Assert.Contains(logger.Entries, log => + log.Level == LogLevel.Debug + && log.Message.Contains("Audiobooks", StringComparison.Ordinal)); + } + + private sealed class CapturingScanAuthorizationLogger + : ILogger + { + public List<(LogLevel Level, string Message)> Entries { get; } = []; + + public IDisposable? BeginScope(TState state) + where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) => + Entries.Add((logLevel, formatter(state, exception))); + } + [Fact] public async Task AuthorizeAsync_ReplacedEnrolledRoot_IsRejected() { diff --git a/tests/Features/Infrastructure/Migrations/MigrationMetadataTests.cs b/tests/Features/Infrastructure/Migrations/MigrationMetadataTests.cs index 1a17d5e86..448b24c2b 100644 --- a/tests/Features/Infrastructure/Migrations/MigrationMetadataTests.cs +++ b/tests/Features/Infrastructure/Migrations/MigrationMetadataTests.cs @@ -80,6 +80,114 @@ public void AddLibraryDirectoryOwnershipRootForeignKey_IsDiscoverableAndIsolated Assert.IsType(dropForeignKey).Name); } + [Fact] + public void AddMarkerlessMoveExecutionState_IsDiscoverableAndContainsOnlyExpectedColumns() + { + var attribute = typeof(AddMarkerlessMoveExecutionState) + .GetCustomAttribute(); + Assert.NotNull(attribute); + Assert.Equal( + "20260805192525_AddMarkerlessMoveExecutionState", + attribute!.Id); + + var migration = new AddMarkerlessMoveExecutionState(); + var upBuilder = new MigrationBuilder( + "Microsoft.EntityFrameworkCore.Sqlite"); + var downBuilder = new MigrationBuilder( + "Microsoft.EntityFrameworkCore.Sqlite"); + typeof(AddMarkerlessMoveExecutionState) + .GetMethod( + "Up", + BindingFlags.Instance | BindingFlags.NonPublic)! + .Invoke(migration, [upBuilder]); + typeof(AddMarkerlessMoveExecutionState) + .GetMethod( + "Down", + BindingFlags.Instance | BindingFlags.NonPublic)! + .Invoke(migration, [downBuilder]); + + var expectedColumns = new[] + { + "DirectoryObjectIdentity", + "ExecutionProtocolVersion", + "SourceDirectoryCleanupState", + "SourceDirectoryObjectIdentity", + "SourcePhysicalObjectIdentity", + "TargetDirectoryObjectIdentity", + "TargetPhysicalObjectIdentity" + }; + Assert.Equal( + expectedColumns, + upBuilder.Operations + .Select(operation => Assert.IsType(operation).Name) + .OrderBy(name => name, StringComparer.Ordinal) + .ToArray()); + Assert.Equal( + expectedColumns, + downBuilder.Operations + .Select(operation => Assert.IsType(operation).Name) + .OrderBy(name => name, StringComparer.Ordinal) + .ToArray()); + } + + [Fact] + public void AddMarkerlessFileMutationJournal_IsDiscoverableAndIsolated() + { + var attribute = typeof(AddMarkerlessFileMutationJournal) + .GetCustomAttribute(); + Assert.NotNull(attribute); + Assert.Equal( + "20260805202154_AddMarkerlessFileMutationJournal", + attribute!.Id); + + var migration = new AddMarkerlessFileMutationJournal(); + var upBuilder = new MigrationBuilder( + "Microsoft.EntityFrameworkCore.Sqlite"); + var downBuilder = new MigrationBuilder( + "Microsoft.EntityFrameworkCore.Sqlite"); + typeof(AddMarkerlessFileMutationJournal) + .GetMethod( + "Up", + BindingFlags.Instance | BindingFlags.NonPublic)! + .Invoke(migration, [upBuilder]); + typeof(AddMarkerlessFileMutationJournal) + .GetMethod( + "Down", + BindingFlags.Instance | BindingFlags.NonPublic)! + .Invoke(migration, [downBuilder]); + + var create = Assert.Single( + upBuilder.Operations.OfType()); + Assert.Equal("FileMutationJournals", create.Name); + Assert.Equal( + [ + "Action", + "AudiobookId", + "CreatedAt", + "DestinationPath", + "Error", + "OperationId", + "ProtocolVersion", + "SourceLength", + "SourcePath", + "SourcePhysicalObjectIdentity", + "SourceSha256", + "State", + "TargetPhysicalObjectIdentity", + "UpdatedAt" + ], + create.Columns + .Select(column => column.Name) + .OrderBy(name => name, StringComparer.Ordinal) + .ToArray()); + Assert.Equal(2, upBuilder.Operations.OfType().Count()); + Assert.Equal(3, upBuilder.Operations.Count); + Assert.Equal( + "FileMutationJournals", + Assert.Single(downBuilder.Operations.OfType()).Name); + Assert.Single(downBuilder.Operations); + } + [Fact] public void OwnershipRecoveryProtocols_ContainsNoRawSqlOperations() { diff --git a/tests/Features/Infrastructure/Persistence/RootFolderObjectIdentityReconcilerTests.cs b/tests/Features/Infrastructure/Persistence/RootFolderObjectIdentityReconcilerTests.cs index 10835d028..9aaf8dcaf 100644 --- a/tests/Features/Infrastructure/Persistence/RootFolderObjectIdentityReconcilerTests.cs +++ b/tests/Features/Infrastructure/Persistence/RootFolderObjectIdentityReconcilerTests.cs @@ -52,6 +52,111 @@ public async Task ReconcileAsync_AmbiguousPersistedRoot_DoesNotEnrollWindowsDevi StringComparison.OrdinalIgnoreCase); } + [Fact] + public async Task ReconcileAsync_LegacyVersionTwoIdentityWithoutMarker_RemainsAuthorized() + { + var rootPath = FileService.GetTempDirectory("root-object-identity-markerless-v2"); + using var anchor = PinnedDirectoryCreation.OpenPinnedBoundary(rootPath); + var nativeIdentity = anchor.GetDirectoryObjectIdentity(); + var persistedIdentity = ManagedDirectoryIdentity.Create( + Guid.NewGuid().ToString("N"), + nativeIdentity); + Assert.False(File.Exists(Path.Join( + rootPath, + ManagedDirectoryEnrollment.FileName))); + + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + await using (var setup = new ListenArrDbContext(options)) + { + setup.RootFolders.Add(new RootFolder + { + Id = 1, + Name = "Library", + Path = rootPath, + DirectoryObjectIdentityVersion = ManagedDirectoryIdentity.CurrentVersion, + DirectoryObjectIdentity = persistedIdentity + }); + await setup.SaveChangesAsync(); + } + + var reconciler = new RootFolderObjectIdentityReconciler( + new TestDbContextFactory(options), + new DirectoryObjectIdentityResolver(), + new FilesystemMutationCoordinator(), + NullLogger.Instance); + + await reconciler.ReconcileAsync(); + + await using var verification = new ListenArrDbContext(options); + var root = await verification.RootFolders.SingleAsync(); + Assert.Equal(ManagedDirectoryIdentity.CurrentVersion, root.DirectoryObjectIdentityVersion); + Assert.Equal(persistedIdentity, root.DirectoryObjectIdentity); + Assert.Null(root.DirectoryObjectIdentityUnavailableReason); + Assert.False(File.Exists(Path.Join( + rootPath, + ManagedDirectoryEnrollment.FileName))); + } + + [Fact] + public async Task ReconcileAsync_MatchingLegacyEnrollmentMarker_RetiresMarkerAndKeepsDatabaseIdentity() + { + var rootPath = FileService.GetTempDirectory("root-object-identity-retire-marker"); + string nativeIdentity; + using (var anchor = PinnedDirectoryCreation.OpenPinnedBoundary(rootPath)) + { + nativeIdentity = anchor.GetDirectoryObjectIdentity(); + } + var token = Guid.NewGuid().ToString("N"); + var legacyIdentity = new DirectoryObjectIdentityResolution( + ManagedDirectoryIdentity.CurrentVersion, + ManagedDirectoryIdentity.Create(token, nativeIdentity), + null); + var markerPath = Path.Join(rootPath, ManagedDirectoryEnrollment.FileName); + await File.WriteAllTextAsync( + markerPath, + System.Text.Json.JsonSerializer.Serialize(new + { + version = 1, + token, + nativeIdentity, + createdAtUtc = DateTimeOffset.UtcNow + })); + Assert.True(File.Exists(markerPath)); + + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + await using (var setup = new ListenArrDbContext(options)) + { + setup.RootFolders.Add(new RootFolder + { + Id = 1, + Name = "Library", + Path = rootPath, + DirectoryObjectIdentityVersion = legacyIdentity.Version, + DirectoryObjectIdentity = legacyIdentity.Value + }); + await setup.SaveChangesAsync(); + } + + var reconciler = new RootFolderObjectIdentityReconciler( + new TestDbContextFactory(options), + new DirectoryObjectIdentityResolver(), + new FilesystemMutationCoordinator(), + NullLogger.Instance); + + await reconciler.ReconcileAsync(); + + await using var verification = new ListenArrDbContext(options); + var root = await verification.RootFolders.SingleAsync(); + Assert.Equal(legacyIdentity.Version, root.DirectoryObjectIdentityVersion); + Assert.Equal(legacyIdentity.Value, root.DirectoryObjectIdentity); + Assert.Null(root.DirectoryObjectIdentityUnavailableReason); + Assert.False(File.Exists(markerPath)); + } + private sealed class TestDbContextFactory( DbContextOptions options) : IDbContextFactory diff --git a/tests/Features/Infrastructure/Persistence/SqliteMigrationSchemaTests.cs b/tests/Features/Infrastructure/Persistence/SqliteMigrationSchemaTests.cs index 423085af5..51f711de0 100644 --- a/tests/Features/Infrastructure/Persistence/SqliteMigrationSchemaTests.cs +++ b/tests/Features/Infrastructure/Persistence/SqliteMigrationSchemaTests.cs @@ -47,6 +47,10 @@ public class SqliteMigrationSchemaTests : BaseTests "20260730033245_AddPhysicalFileIdentityAndMoveCleanupProtection"; private const string PhysicalIdentityMigrationPredecessorId = "20260727000644_AddOwnershipRecoveryProtocols"; + private const string MarkerlessMoveMigrationId = + "20260805192525_AddMarkerlessMoveExecutionState"; + private const string MarkerlessFileMutationMigrationId = + "20260805202154_AddMarkerlessFileMutationJournal"; public static TheoryData ChangedMigrationIds => new() { @@ -65,7 +69,9 @@ public class SqliteMigrationSchemaTests : BaseTests "20260717143713_AddLibraryDirectoryOwnership", "20260726042801_AddDirectoryObjectIdentityAuthorization", "20260727000644_AddOwnershipRecoveryProtocols", - PhysicalIdentityMigrationId + PhysicalIdentityMigrationId, + MarkerlessMoveMigrationId, + MarkerlessFileMutationMigrationId }; private static (SqliteConnection Connection, ListenArrDbContext Context) CreateMigratedSqliteContext() @@ -556,6 +562,189 @@ await context.Database.ExecuteSqlRawAsync( Assert.Equal(DBNull.Value, await ownershipCommand.ExecuteScalarAsync()); } + [Fact] + [Trait("Scenario", "MarkerlessMoveExecutionStateUpgrade")] + public async Task MarkerlessMoveExecutionStateMigration_PreservesLegacyRowsAndDefaults() + { + await using var connection = new SqliteConnection("DataSource=:memory:"); + await connection.OpenAsync(); + var options = new DbContextOptionsBuilder() + .UseSqlite(connection, sqlite => + sqlite.MigrationsAssembly( + typeof(ListenArrDbContext).Assembly.GetName().Name)) + .Options; + await using var context = new ListenArrDbContext(options); + var migrator = context.GetService(); + await migrator.MigrateAsync( + "20260805034058_AddLibraryDirectoryOwnershipRootForeignKey"); + + var moveJobId = Guid.NewGuid(); + await context.Database.ExecuteSqlRawAsync( + """ + INSERT INTO "MoveJobs" ( + "Id", "AudiobookId", "EnqueuedAt", "Status", + "AttemptCount", "DeleteEmptySource", "FailureKind", + "IdentityKeyVersion", "LeaseGeneration", "Phase") + VALUES ({0}, 501, CURRENT_TIMESTAMP, 'Queued', 0, 1, + 'None', 5, 0, 'None') + """, + moveJobId); + await context.Database.ExecuteSqlRawAsync( + """ + INSERT INTO "MoveJobEntries" ( + "MoveJobId", "RelativePath", "EntryType", "Length", + "LastWriteTimeUtc", "CopyState", "CleanupState", + "CleanupProtectionVersion") + VALUES ({0}, 'book.m4b', 'File', 1234, CURRENT_TIMESTAMP, + 'Pending', 'Pending', 0) + """, + moveJobId); + await context.Database.ExecuteSqlRawAsync( + """ + INSERT INTO "MoveJobCreatedDirectories" ( + "MoveJobId", "Path", "State") + VALUES ({0}, '/library/author/book', 'Planned') + """, + moveJobId); + + await migrator.MigrateAsync(MarkerlessMoveMigrationId); + + await using (var command = connection.CreateCommand()) + { + command.CommandText = + """ + SELECT "ExecutionProtocolVersion", + "SourceDirectoryCleanupState", + "SourceDirectoryObjectIdentity", + "TargetDirectoryObjectIdentity" + FROM "MoveJobs" + WHERE "Id" = $jobId + """; + command.Parameters.AddWithValue("$jobId", moveJobId); + await using var reader = await command.ExecuteReaderAsync(); + Assert.True(await reader.ReadAsync()); + Assert.Equal(MoveExecutionProtocol.LegacyFilesystemArtifacts, reader.GetInt32(0)); + Assert.Equal("Pending", reader.GetString(1)); + Assert.True(reader.IsDBNull(2)); + Assert.True(reader.IsDBNull(3)); + } + + await using (var command = connection.CreateCommand()) + { + command.CommandText = + """ + SELECT "SourcePhysicalObjectIdentity", + "TargetPhysicalObjectIdentity" + FROM "MoveJobEntries" + WHERE "MoveJobId" = $jobId + """; + command.Parameters.AddWithValue("$jobId", moveJobId); + await using var reader = await command.ExecuteReaderAsync(); + Assert.True(await reader.ReadAsync()); + Assert.True(reader.IsDBNull(0)); + Assert.True(reader.IsDBNull(1)); + } + + Assert.Equal( + DBNull.Value, + await ExecuteScalarAsync( + connection, + "SELECT \"DirectoryObjectIdentity\" FROM \"MoveJobCreatedDirectories\"")); + + await migrator.MigrateAsync( + "20260805034058_AddLibraryDirectoryOwnershipRootForeignKey"); + Assert.False(await ColumnExistsAsync( + connection, + "MoveJobs", + "ExecutionProtocolVersion")); + Assert.False(await ColumnExistsAsync( + connection, + "MoveJobEntries", + "TargetPhysicalObjectIdentity")); + Assert.False(await ColumnExistsAsync( + connection, + "MoveJobCreatedDirectories", + "DirectoryObjectIdentity")); + } + + [Fact] + [Trait("Scenario", "MarkerlessFileMutationJournalUpgrade")] + public async Task MarkerlessFileMutationJournalMigration_CreatesDurableDefaultsAndIndexes() + { + await using var connection = new SqliteConnection("DataSource=:memory:"); + await connection.OpenAsync(); + var options = new DbContextOptionsBuilder() + .UseSqlite(connection, sqlite => + sqlite.MigrationsAssembly( + typeof(ListenArrDbContext).Assembly.GetName().Name)) + .Options; + await using var context = new ListenArrDbContext(options); + var migrator = context.GetService(); + await migrator.MigrateAsync(MarkerlessMoveMigrationId); + Assert.False(await TableExistsAsync( + connection, + "FileMutationJournals")); + + await migrator.MigrateAsync(MarkerlessFileMutationMigrationId); + Assert.True(await TableExistsAsync( + connection, + "FileMutationJournals")); + var operationId = Guid.NewGuid(); + await context.Database.ExecuteSqlRawAsync( + """ + INSERT INTO "FileMutationJournals" ( + "OperationId", "Action", "SourcePath", "DestinationPath", + "SourcePhysicalObjectIdentity", "SourceLength", "State", + "CreatedAt", "UpdatedAt") + VALUES ({0}, 'Move', '/source/book.m4b', + '/library/book.m4b', 'source-generation', 123, + 'Planned', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + """, + operationId); + + await using (var command = connection.CreateCommand()) + { + command.CommandText = + """ + SELECT "ProtocolVersion", "State", + "TargetPhysicalObjectIdentity", "AudiobookId" + FROM "FileMutationJournals" + WHERE "OperationId" = $operationId + """; + command.Parameters.AddWithValue("$operationId", operationId); + await using var reader = await command.ExecuteReaderAsync(); + Assert.True(await reader.ReadAsync()); + Assert.Equal( + FileMutationProtocol.MarkerlessDatabaseState, + reader.GetInt32(0)); + Assert.Equal("Planned", reader.GetString(1)); + Assert.True(reader.IsDBNull(2)); + Assert.True(reader.IsDBNull(3)); + } + + await using (var command = connection.CreateCommand()) + { + command.CommandText = + """ + SELECT group_concat("name", ',') + FROM ( + SELECT "name" + FROM pragma_index_list('FileMutationJournals') + WHERE "name" LIKE 'IX_FileMutationJournals_%' + ORDER BY "name") + """; + Assert.Equal( + "IX_FileMutationJournals_State," + + "IX_FileMutationJournals_UpdatedAt", + (await command.ExecuteScalarAsync())?.ToString()); + } + + await migrator.MigrateAsync(MarkerlessMoveMigrationId); + Assert.False(await TableExistsAsync( + connection, + "FileMutationJournals")); + } + [Fact] [Trait("Scenario", "IntermediatePrDatabaseOrphanRepair")] public async Task MigrationPreflight_RepairsOrphanOwnershipReferencesBeforeForeignKey() diff --git a/tests/Features/Infrastructure/Repositories/AudiobookRepositoryTests.cs b/tests/Features/Infrastructure/Repositories/AudiobookRepositoryTests.cs index 92d2c6fc8..e461170d2 100644 --- a/tests/Features/Infrastructure/Repositories/AudiobookRepositoryTests.cs +++ b/tests/Features/Infrastructure/Repositories/AudiobookRepositoryTests.cs @@ -83,6 +83,69 @@ public async Task GetByIdsWithFilesAsync_ReturnsDetachedPreviewSnapshots() Assert.All(snapshot.Files!, file => Assert.Equal(EntityState.Detached, db.Entry(file).State)); } + [Fact] + public async Task GetForScanAsync_DoesNotLoadUnneededNavigationGraphs() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + await using var db = new ListenArrDbContext(options); + var qualityProfile = new QualityProfile { Name = "Scan Profile" }; + db.QualityProfiles.Add(qualityProfile); + await db.SaveChangesAsync(); + var audiobook = new Audiobook + { + Title = "Narrow Scan Read", + BasePath = "/library/book", + QualityProfileId = qualityProfile.Id + }; + db.Audiobooks.Add(audiobook); + await db.SaveChangesAsync(); + db.AudiobookFiles.Add(new AudiobookFile + { + AudiobookId = audiobook.Id, + Path = "/library/book/book.m4b" + }); + db.AudiobookExternalIdentifiers.Add(new AudiobookExternalIdentifier + { + AudiobookId = audiobook.Id, + Type = AudiobookExternalIdentifierType.Asin, + ValueRaw = "B000SCAN01", + ValueNormalized = "B000SCAN01", + Source = AudiobookExternalIdentifierSource.Manual + }); + db.AudiobookSeriesMemberships.Add(new AudiobookSeriesMembership + { + AudiobookId = audiobook.Id, + SeriesName = "Scan Series" + }); + await db.SaveChangesAsync(); + db.ChangeTracker.Clear(); + var repository = new AudiobookRepository(db); + + var tracked = Assert.IsType( + await repository.GetForScanAsync(audiobook.Id)); + + var entry = db.Entry(tracked); + Assert.False(entry.Collection(candidate => candidate.Files!).IsLoaded); + Assert.False(entry.Collection(candidate => candidate.ExternalIdentifiers!).IsLoaded); + Assert.False(entry.Collection(candidate => candidate.SeriesMemberships!).IsLoaded); + Assert.False(entry.Reference(candidate => candidate.QualityProfile).IsLoaded); + Assert.Null(tracked.Files); + Assert.Null(tracked.ExternalIdentifiers); + Assert.Null(tracked.SeriesMemberships); + Assert.Null(tracked.QualityProfile); + + db.ChangeTracker.Clear(); + var snapshot = Assert.IsType( + await repository.GetForScanSnapshotAsync(audiobook.Id)); + Assert.Equal(EntityState.Detached, db.Entry(snapshot).State); + Assert.Null(snapshot.Files); + Assert.Null(snapshot.ExternalIdentifiers); + Assert.Null(snapshot.SeriesMemberships); + Assert.Null(snapshot.QualityProfile); + } + [Fact] public async Task UpdateAsync_TrackedMetadataChange_DoesNotOverwriteNewerBasePathFromAnotherContext() { From a658cac0a73f765d36601dd7b98a4d2a7e642273 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Thu, 6 Aug 2026 08:50:46 -0400 Subject: [PATCH 406/464] Fix native markerless ownership regressions --- ...fLibraryDirectoryOwnershipStore.Resolution.cs | 11 +++++++++++ ...obookContentMoveServiceRecoverySafetyTests.cs | 5 ++++- .../DirectoryCreationParentReplacementTests.cs | 2 +- .../Moving/RootFolderRelocationServiceTests.cs | 16 ++++++++++++++-- 4 files changed, 30 insertions(+), 4 deletions(-) diff --git a/listenarr.infrastructure/Library/Moving/EfLibraryDirectoryOwnershipStore.Resolution.cs b/listenarr.infrastructure/Library/Moving/EfLibraryDirectoryOwnershipStore.Resolution.cs index 0903c3f28..336a524b3 100644 --- a/listenarr.infrastructure/Library/Moving/EfLibraryDirectoryOwnershipStore.Resolution.cs +++ b/listenarr.infrastructure/Library/Moving/EfLibraryDirectoryOwnershipStore.Resolution.cs @@ -138,6 +138,17 @@ await _boundaryAuthorizer.AuthorizeOwnershipAsync( "The owned directory no longer matches its persisted physical identity."); } AfterOwnedDirectoryPhysicalIdentityPinnedForTest?.Invoke(); + if (!ManagedDirectoryIdentity.Matches( + resolved.DirectoryObjectIdentityVersion, + resolved.DirectoryObjectIdentity, + resolved.OwnershipToken, + live.GetDirectoryObjectIdentity()) + || !live.VisiblePathMatches() + || !authorization.ParentAnchor.VisiblePathMatches()) + { + throw new InvalidOperationException( + "The owned directory changed after its physical identity was pinned."); + } _ = LibraryDirectoryOwnershipMarker.TryRetireMatchingMarkers( resolved, live, diff --git a/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceRecoverySafetyTests.cs b/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceRecoverySafetyTests.cs index ffa530a10..5db5e62a1 100644 --- a/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceRecoverySafetyTests.cs +++ b/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceRecoverySafetyTests.cs @@ -369,7 +369,10 @@ public async Task CleanupCompletedMoveArtifactsAsync_DanglingRecoveryMarkerLink_ result, CancellationToken.None)); - Assert.Contains("symbolic link or reparse point", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains( + "Linked filesystem entry blocked safe traversal", + exception.Message, + StringComparison.OrdinalIgnoreCase); Assert.NotNull(new FileInfo(result.RecoveryMarkerPath).LinkTarget); Assert.False(File.Exists(missingTarget)); Assert.True(File.Exists(Path.Join(target, "book.m4b"))); diff --git a/tests/Features/Infrastructure/Library/Moving/DirectoryCreationParentReplacementTests.cs b/tests/Features/Infrastructure/Library/Moving/DirectoryCreationParentReplacementTests.cs index 8d34d7f5a..5a1365c25 100644 --- a/tests/Features/Infrastructure/Library/Moving/DirectoryCreationParentReplacementTests.cs +++ b/tests/Features/Infrastructure/Library/Moving/DirectoryCreationParentReplacementTests.cs @@ -39,7 +39,7 @@ public async Task EnsureCreatedHierarchyAsync_LinkedManagedBoundary_CreatesInsid Assert.Equal(2, created.Count); Assert.True(Directory.Exists(Path.Join(physicalBoundary, "Author", "Book"))); - Assert.True(File.Exists(Path.Join( + Assert.False(File.Exists(Path.Join( physicalBoundary, "Author", "Book", diff --git a/tests/Features/Infrastructure/Library/Moving/RootFolderRelocationServiceTests.cs b/tests/Features/Infrastructure/Library/Moving/RootFolderRelocationServiceTests.cs index ef0a1606c..a3a4dc7f1 100644 --- a/tests/Features/Infrastructure/Library/Moving/RootFolderRelocationServiceTests.cs +++ b/tests/Features/Infrastructure/Library/Moving/RootFolderRelocationServiceTests.cs @@ -2870,6 +2870,12 @@ public async Task MetadataOnly_LinkedSourceAndPhysicalTarget_PreservesActiveOwne .ResolveAsync(linkedOwnedPath); Assert.True(rootIdentity.IsAvailable); Assert.True(ownedIdentity.IsAvailable); + string ownedNativeIdentity; + using (var ownedAnchor = + PinnedDirectoryCreation.OpenPinnedBoundary(linkedOwnedPath)) + { + ownedNativeIdentity = ownedAnchor.GetDirectoryObjectIdentity(); + } var ownershipToken = Guid.NewGuid().ToString("N"); int rootId; @@ -2928,7 +2934,7 @@ public async Task MetadataOnly_LinkedSourceAndPhysicalTarget_PreservesActiveOwne DirectoryObjectIdentityVersion = ManagedDirectoryIdentity.CurrentVersion, DirectoryObjectIdentity = ManagedDirectoryIdentity.Create( ownershipToken, - ownedIdentity.Value!) + ownedNativeIdentity) }; db.LibraryDirectoryOwnerships.Add(ownership); await db.SaveChangesAsync(); @@ -3010,6 +3016,12 @@ public async Task MetadataOnly_PhysicalSourceAndLinkedTarget_PreservesActiveOwne .ResolveAsync(physicalOwnedPath); Assert.True(rootIdentity.IsAvailable); Assert.True(ownedIdentity.IsAvailable); + string ownedNativeIdentity; + using (var ownedAnchor = + PinnedDirectoryCreation.OpenPinnedBoundary(physicalOwnedPath)) + { + ownedNativeIdentity = ownedAnchor.GetDirectoryObjectIdentity(); + } var ownershipToken = Guid.NewGuid().ToString("N"); int rootId; @@ -3068,7 +3080,7 @@ public async Task MetadataOnly_PhysicalSourceAndLinkedTarget_PreservesActiveOwne DirectoryObjectIdentityVersion = ManagedDirectoryIdentity.CurrentVersion, DirectoryObjectIdentity = ManagedDirectoryIdentity.Create( ownershipToken, - ownedIdentity.Value!) + ownedNativeIdentity) }; db.LibraryDirectoryOwnerships.Add(ownership); await db.SaveChangesAsync(); From 27e2fbb46f453d04bc986a47db6a87ed9f11f9f3 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Thu, 6 Aug 2026 09:20:40 -0400 Subject: [PATCH 407/464] Strengthen Linux directory generation identity --- fe/src/__tests__/ActivityView.spec.ts | 13 +- ...edDirectoryCreation.LinuxObjectIdentity.cs | 163 ++++++++++++++++++ ...innedDirectoryCreation.NativeOperations.cs | 6 +- .../EfLibraryDirectoryOwnershipStoreTests.cs | 9 + .../RootFolderRelocationServiceTests.cs | 30 ++-- 5 files changed, 204 insertions(+), 17 deletions(-) create mode 100644 listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.LinuxObjectIdentity.cs diff --git a/fe/src/__tests__/ActivityView.spec.ts b/fe/src/__tests__/ActivityView.spec.ts index c0d951361..e13ae13c7 100644 --- a/fe/src/__tests__/ActivityView.spec.ts +++ b/fe/src/__tests__/ActivityView.spec.ts @@ -80,18 +80,16 @@ const mockLibraryStore = (audiobooks: Array<{ id: number; title: string }> = []) })) } +let currentMoveJobsStore: Record + const mockMoveJobsStore = (overrides: Record = {}) => { - const store = { + currentMoveJobsStore = { trackedJobs: [], start: vi.fn(), ...overrides, } - vi.doMock('@/stores/moveJobs', () => ({ - useMoveJobsStore: () => store, - })) - - return store + return currentMoveJobsStore } const mockDownloadsStore = (overrides: Record = {}) => { @@ -131,6 +129,9 @@ describe('ActivityView', () => { vi.resetModules() vi.clearAllMocks() mockMoveJobsStore() + vi.doMock('@/stores/moveJobs', () => ({ + useMoveJobsStore: () => currentMoveJobsStore, + })) vi.spyOn(globalThis, 'setInterval').mockReturnValue( 1 as unknown as ReturnType, ) diff --git a/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.LinuxObjectIdentity.cs b/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.LinuxObjectIdentity.cs new file mode 100644 index 000000000..1ff0af3c4 --- /dev/null +++ b/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.LinuxObjectIdentity.cs @@ -0,0 +1,163 @@ +using System.ComponentModel; +using System.Runtime.InteropServices; +using Microsoft.Win32.SafeHandles; + +namespace Listenarr.Infrastructure.FileSystem; + +internal sealed partial class PinnedDirectoryCreation +{ + private const int LinuxAtHandleFid = 0x0200; + private const int LinuxAtEmptyPath = 0x1000; + private const int LinuxInvalidArgument = 22; + private const int LinuxNotTy = 25; + private const int LinuxFunctionNotImplemented = 38; + private const int LinuxOverflow = 75; + private const int LinuxOperationNotSupported = 95; + private const int LinuxFileHandleHeaderBytes = 8; + private const int LinuxInitialFileHandleBytes = 128; + private const int LinuxMaximumFileHandleBytes = 4096; + private const ulong LinuxFsIocGetVersion64 = 0x80087601; + private const ulong LinuxFsIocGetVersion32 = 0x80047601; + + private static string? TryGetLinuxGenerationIdentity( + SafeFileHandle handle) + { + var fileHandle = TryGetLinuxFileHandleIdentity( + handle, + LinuxAtEmptyPath | LinuxAtHandleFid, + retryWithoutHandleFid: true); + if (!string.IsNullOrWhiteSpace(fileHandle)) + { + return $"fh:{fileHandle}"; + } + + if (TryGetLinuxInodeGeneration(handle, out var generation)) + { + return FormattableString.Invariant($"gen:{generation:x8}"); + } + + return null; + } + + private static string? TryGetLinuxFileHandleIdentity( + SafeFileHandle handle, + int flags, + bool retryWithoutHandleFid) + { + var capacity = LinuxInitialFileHandleBytes; + for (var attempt = 0; attempt < 2; attempt++) + { + var buffer = Marshal.AllocHGlobal( + LinuxFileHandleHeaderBytes + capacity); + try + { + Marshal.WriteInt32(buffer, 0, capacity); + Marshal.WriteInt32(buffer, sizeof(int), 0); + if (NameToHandleAt( + handle.DangerousGetHandle().ToInt32(), + string.Empty, + buffer, + out _, + flags) == 0) + { + var handleBytes = Marshal.ReadInt32(buffer, 0); + if (handleBytes <= 0 || handleBytes > capacity) + { + throw new InvalidOperationException( + "Linux returned an invalid filesystem file-handle length."); + } + + var handleType = Marshal.ReadInt32(buffer, sizeof(int)); + var bytes = new byte[handleBytes]; + Marshal.Copy( + IntPtr.Add(buffer, LinuxFileHandleHeaderBytes), + bytes, + 0, + handleBytes); + return FormattableString.Invariant( + $"{handleType:x8}:{Convert.ToHexString(bytes).ToLowerInvariant()}"); + } + + var error = Marshal.GetLastWin32Error(); + var requiredBytes = Marshal.ReadInt32(buffer, 0); + if (error == LinuxOverflow + && requiredBytes > capacity + && requiredBytes <= LinuxMaximumFileHandleBytes) + { + capacity = requiredBytes; + continue; + } + + if (retryWithoutHandleFid + && error == LinuxInvalidArgument + && (flags & LinuxAtHandleFid) != 0) + { + return TryGetLinuxFileHandleIdentity( + handle, + LinuxAtEmptyPath, + retryWithoutHandleFid: false); + } + + if (error is LinuxInvalidArgument + or LinuxNotTy + or LinuxFunctionNotImplemented + or LinuxOverflow + or LinuxOperationNotSupported) + { + return null; + } + + throw new Win32Exception(error); + } + finally + { + Marshal.FreeHGlobal(buffer); + } + } + + return null; + } + + private static bool TryGetLinuxInodeGeneration( + SafeFileHandle handle, + out uint generation) + { + generation = 0; + var request = IntPtr.Size == sizeof(long) + ? LinuxFsIocGetVersion64 + : LinuxFsIocGetVersion32; + if (IoctlGetVersion( + handle.DangerousGetHandle().ToInt32(), + request, + out var rawGeneration) == 0) + { + generation = unchecked((uint)rawGeneration); + return true; + } + + var error = Marshal.GetLastWin32Error(); + if (error is LinuxInvalidArgument + or LinuxNotTy + or LinuxFunctionNotImplemented + or LinuxOperationNotSupported) + { + return false; + } + + throw new Win32Exception(error); + } + + [DllImport("libc", EntryPoint = "name_to_handle_at", SetLastError = true)] + private static extern int NameToHandleAt( + int directoryFileDescriptor, + [MarshalAs(UnmanagedType.LPUTF8Str)] string path, + IntPtr handle, + out int mountId, + int flags); + + [DllImport("libc", EntryPoint = "ioctl", SetLastError = true)] + private static extern int IoctlGetVersion( + int fileDescriptor, + ulong request, + out int version); +} diff --git a/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.NativeOperations.cs b/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.NativeOperations.cs index ab16e12e7..6e6c50fa9 100644 --- a/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.NativeOperations.cs +++ b/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.NativeOperations.cs @@ -238,8 +238,12 @@ private static string GetDirectoryObjectIdentity(SafeFileHandle handle) "The filesystem does not expose complete directory generation identity."); } - return FormattableString.Invariant( + var baseIdentity = FormattableString.Invariant( $"linux:{information.DeviceMajor:x8}:{information.DeviceMinor:x8}:{information.Inode:x16}:{information.BirthTime.Seconds:x16}:{information.BirthTime.Nanoseconds:x8}"); + var generationIdentity = TryGetLinuxGenerationIdentity(handle); + return string.IsNullOrWhiteSpace(generationIdentity) + ? baseIdentity + : $"{baseIdentity}:{generationIdentity}"; } if (OperatingSystem.IsMacOS()) diff --git a/tests/Features/Infrastructure/Library/Moving/EfLibraryDirectoryOwnershipStoreTests.cs b/tests/Features/Infrastructure/Library/Moving/EfLibraryDirectoryOwnershipStoreTests.cs index 4019611fc..94572abc2 100644 --- a/tests/Features/Infrastructure/Library/Moving/EfLibraryDirectoryOwnershipStoreTests.cs +++ b/tests/Features/Infrastructure/Library/Moving/EfLibraryDirectoryOwnershipStoreTests.cs @@ -328,6 +328,15 @@ public async Task ResolveOwnedAsync_DirectoryReplacedAfterPhysicalIdentityPin_Do directory, FileSystemPathSemantics.CurrentHostDefault, "test")); + using (var parent = PinnedDirectoryCreation.OpenPinnedBoundary(_root)) + using (var publication = parent.OpenExistingChildForPublication( + Path.GetFileName(directory))) + { + await PinnedLibraryDirectoryOwnershipMarker.EnsureAsync( + ownership, + publication, + CancellationToken.None); + } var insideMarker = Path.Join( directory, LibraryDirectoryOwnershipMarker.FileName); diff --git a/tests/Features/Infrastructure/Library/Moving/RootFolderRelocationServiceTests.cs b/tests/Features/Infrastructure/Library/Moving/RootFolderRelocationServiceTests.cs index a3a4dc7f1..39ac602f6 100644 --- a/tests/Features/Infrastructure/Library/Moving/RootFolderRelocationServiceTests.cs +++ b/tests/Features/Infrastructure/Library/Moving/RootFolderRelocationServiceTests.cs @@ -2844,7 +2844,7 @@ public async Task ReconcileOwnershipMigration_EquivalentLegacyMarkersAreRetiredA } [DirectoryLinkFact] - public async Task MetadataOnly_LinkedSourceAndPhysicalTarget_PreservesActiveOwnershipMarkers() + public async Task MetadataOnly_LinkedSourceAndPhysicalTarget_RetiresLegacyOwnershipMarkers() { var root = Path.Join( TempRoot, @@ -2969,10 +2969,15 @@ await PinnedLibraryDirectoryOwnershipMarker.EnsureAsync( .LibraryDirectoryOwnerships.SingleAsync(); Assert.Equal(physicalRoot, rootAfter.Path); Assert.Equal(physicalOwnedPath, ownershipAfter.CanonicalPath); - LibraryDirectoryOwnershipMarker.Validate( - ownershipAfter, - physicalOwnedPath); - Assert.True(File.Exists(Path.Join( + Assert.True(ManagedDirectoryIdentity.Matches( + ownershipAfter.DirectoryObjectIdentityVersion, + ownershipAfter.DirectoryObjectIdentity, + ownershipAfter.OwnershipToken, + ownedNativeIdentity)); + Assert.False(File.Exists(Path.Join( + physicalOwnedPath, + LibraryDirectoryOwnershipMarker.FileName))); + Assert.False(File.Exists(Path.Join( physicalRoot, $".listenarr-directory-owner-{ownership.OwnershipToken}.json"))); Assert.False(await verification @@ -2990,7 +2995,7 @@ await PinnedLibraryDirectoryOwnershipMarker.EnsureAsync( } [DirectoryLinkFact] - public async Task MetadataOnly_PhysicalSourceAndLinkedTarget_PreservesActiveOwnershipMarkers() + public async Task MetadataOnly_PhysicalSourceAndLinkedTarget_RetiresLegacyOwnershipMarkers() { var root = Path.Join( TempRoot, @@ -3115,10 +3120,15 @@ await PinnedLibraryDirectoryOwnershipMarker.EnsureAsync( .LibraryDirectoryOwnerships.SingleAsync(); Assert.Equal(linkedRoot, rootAfter.Path); Assert.Equal(linkedOwnedPath, ownershipAfter.CanonicalPath); - LibraryDirectoryOwnershipMarker.Validate( - ownershipAfter, - linkedOwnedPath); - Assert.True(File.Exists(Path.Join( + Assert.True(ManagedDirectoryIdentity.Matches( + ownershipAfter.DirectoryObjectIdentityVersion, + ownershipAfter.DirectoryObjectIdentity, + ownershipAfter.OwnershipToken, + ownedNativeIdentity)); + Assert.False(File.Exists(Path.Join( + linkedOwnedPath, + LibraryDirectoryOwnershipMarker.FileName))); + Assert.False(File.Exists(Path.Join( physicalRoot, $".listenarr-directory-owner-{ownership.OwnershipToken}.json"))); Assert.False(await verification From b8c933cd91c068d445a8f61560cb5ed382a0b4a5 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Thu, 6 Aug 2026 19:22:13 -0400 Subject: [PATCH 408/464] Harden move recovery and markerless cleanup --- fe/src/__tests__/RootFoldersSettings.spec.ts | 28 + fe/src/__tests__/moveJobs.store.spec.ts | 165 ++++++ .../rootFolders.reauthorization.store.spec.ts | 29 + fe/src/__tests__/test-setup.ts | 8 + .../settings/RootFoldersSettings.vue | 16 +- fe/src/stores/moveJobs.ts | 108 +++- fe/src/stores/rootFolders.ts | 19 +- .../Library/LibraryBulkEditWorkflow.Update.cs | 85 ++- .../Library/LibraryBulkEditWorkflow.cs | 232 ++++---- .../Features/Library/LibraryController.cs | 30 +- .../Library/LibraryManualScanWorkflow.cs | 14 +- ...raryMetadataRescanWorkflow.Coordination.cs | 6 +- .../Library/LibraryMetadataRescanWorkflow.cs | 14 +- .../Library/LibraryUpdateWorkflow.Metadata.cs | 11 +- .../Features/Library/LibraryUpdateWorkflow.cs | 44 +- .../ILibraryDirectoryOwnershipStore.cs | 7 + .../Repositories/IAudiobookRepository.cs | 1 + .../Audiobooks/Jobs/MoveRecoveryPolicy.cs | 77 ++- .../Audiobooks/RootFolderRelocation.cs | 5 +- .../FileMover.MarkerlessMove.Proofs.cs | 170 ++++++ .../FileSystem/FileMover.MarkerlessMove.cs | 109 +--- ...FileMover.MarkerlessRegistration.Target.cs | 210 +++++++ .../FileMover.MarkerlessRegistration.cs | 234 +------- .../FileMutationJournalStore.SourceHash.cs | 128 +++++ .../FileSystem/FileMutationJournalStore.cs | 16 +- ...okContentMoveService.DirectoryOwnership.cs | 122 +++++ ...iobookContentMoveService.FaultInjection.cs | 1 + .../AudiobookContentMoveService.Markerless.cs | 11 + ...ookContentMoveService.MarkerlessCleanup.cs | 211 ------- ...iobookContentMoveService.MarkerlessCopy.cs | 53 +- ...tMoveService.MarkerlessDirectoryCleanup.cs | 332 +++++++++++ ...entMoveService.MarkerlessEntryPreflight.cs | 28 + ...bookContentMoveService.OwnershipMarkers.cs | 9 - ...ice.TargetScaffoldingCleanup.Markerless.cs | 141 +++++ ...entMoveService.TargetScaffoldingCleanup.cs | 11 + .../Moving/AudiobookContentMoveService.cs | 12 +- ...oryOwnershipStore.MarkerlessReplacement.cs | 191 +++++++ ...rectoryOwnershipMarker.MigrationCleanup.cs | 125 +++++ .../Moving/MoveJobProcessor.Helpers.cs | 39 +- ...otFolderRelocationService.MetadataStart.cs | 32 +- ...derRelocationService.OwnershipMigration.cs | 48 +- ...rvice.OwnershipMigrationArtifactCleanup.cs | 6 +- ...ationService.OwnershipMigrationRecovery.cs | 72 ++- ...ionService.OwnershipMigrationRetirement.cs | 50 ++ ...FolderRelocationService.Reauthorization.cs | 1 + .../RootFolderRelocationService.Retry.cs | 1 + .../AudiobookRepository.UpdateSnapshot.cs | 13 + .../Repositories/AudiobookRepository.cs | 2 + .../Repositories/EfMoveQueuePersistence.cs | 2 + .../LibraryController_BulkUpdateTests.cs | 178 ++++++ ...LibraryController_DeleteFilesystemTests.cs | 26 + ...braryController_ScanPathValidationTests.cs | 20 + .../LibraryController_UpdateAudiobookTests.cs | 38 +- .../Library/LibraryUpdateWorkflowTests.cs | 90 ++- .../LibraryController_MetadataRescanTests.cs | 81 +++ .../Audiobooks/Jobs/MoveQueueServiceTests.cs | 74 +++ .../Jobs/MoveRecoveryPolicyTests.cs | 71 +++ .../FileMoverMarkerlessMoveTests.cs | 44 ++ .../FileMoverMarkerlessRegistrationTests.cs | 27 + ...MoveServiceOwnershipMarkerRecoveryTests.cs | 36 ++ .../AudiobookContentMoveServiceTests.cs | 514 ++++++++++++++++++ .../RootFolderRelocationServiceTests.cs | 227 +++++++- .../EfMoveQueuePersistenceTests.cs | 54 ++ .../Repositories/AudiobookRepositoryTests.cs | 69 +++ 64 files changed, 4072 insertions(+), 756 deletions(-) create mode 100644 listenarr.infrastructure/FileSystem/FileMover.MarkerlessMove.Proofs.cs create mode 100644 listenarr.infrastructure/FileSystem/FileMover.MarkerlessRegistration.Target.cs create mode 100644 listenarr.infrastructure/FileSystem/FileMutationJournalStore.SourceHash.cs create mode 100644 listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.MarkerlessDirectoryCleanup.cs create mode 100644 listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.MarkerlessEntryPreflight.cs create mode 100644 listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.TargetScaffoldingCleanup.Markerless.cs create mode 100644 listenarr.infrastructure/Library/Moving/EfLibraryDirectoryOwnershipStore.MarkerlessReplacement.cs create mode 100644 listenarr.infrastructure/Library/Moving/LibraryDirectoryOwnershipMarker.MigrationCleanup.cs create mode 100644 listenarr.infrastructure/Persistence/Repositories/AudiobookRepository.UpdateSnapshot.cs diff --git a/fe/src/__tests__/RootFoldersSettings.spec.ts b/fe/src/__tests__/RootFoldersSettings.spec.ts index c01495ffc..b76e0a9a3 100644 --- a/fe/src/__tests__/RootFoldersSettings.spec.ts +++ b/fe/src/__tests__/RootFoldersSettings.spec.ts @@ -21,6 +21,7 @@ import { createPinia, setActivePinia } from 'pinia' import RootFoldersSettings from '@/components/settings/RootFoldersSettings.vue' import { useRootFoldersStore } from '@/stores/rootFolders' import { apiService } from '@/services/api' +import { signalRService } from '@/services/signalr' import type { RootFolder, RootFolderPathChangeResult } from '@/types' const targetPath = '/srv/Audiobooks ' @@ -57,6 +58,7 @@ describe('RootFoldersSettings', () => { beforeEach(() => { vi.restoreAllMocks() vi.clearAllMocks() + vi.mocked(apiService.getRootFolders).mockReset().mockResolvedValue([]) }) it('shows header spinner and loading state when store.loading is true', async () => { @@ -89,6 +91,32 @@ describe('RootFoldersSettings', () => { await wrapper.vm.$nextTick() }) + it('reloads root relocation state after SignalR reconnect', async () => { + const active = relocation('Authorized') + vi.mocked(apiService.getRootFolders) + .mockResolvedValueOnce([rootFolder(active)]) + .mockResolvedValueOnce([rootFolder(null)]) + let connected: (() => void) | undefined + const unsubscribe = vi.fn() + vi.spyOn(signalRService, 'onConnected').mockImplementation((callback) => { + connected = callback + return unsubscribe + }) + const pinia = createPinia() + setActivePinia(pinia) + const wrapper = mount(RootFoldersSettings, { global: { plugins: [pinia] } }) + await flushPromises() + + expect(wrapper.text()).toContain('NeedsAttention') + connected?.() + await flushPromises() + + expect(apiService.getRootFolders).toHaveBeenCalledTimes(2) + expect(wrapper.text()).not.toContain('NeedsAttention') + wrapper.unmount() + expect(unsubscribe).toHaveBeenCalledTimes(1) + }) + it('reauthorizes the exact configured root path only after confirmation', async () => { const folder = rootFolder(null) vi.mocked(apiService.getRootFolders).mockResolvedValue([folder]) diff --git a/fe/src/__tests__/moveJobs.store.spec.ts b/fe/src/__tests__/moveJobs.store.spec.ts index 3190e6592..acd14d7d7 100644 --- a/fe/src/__tests__/moveJobs.store.spec.ts +++ b/fe/src/__tests__/moveJobs.store.spec.ts @@ -120,6 +120,171 @@ describe('move jobs store', () => { ) }) + it('recovers a missed terminal update when a tracked job disappears from the active snapshot', async () => { + apiMocks.getActiveMoveJobs + .mockResolvedValueOnce([ + { + jobId: 'job-1', + audiobookId: 42, + status: 'Running', + progress: 40, + target: '/library/book', + }, + ]) + .mockResolvedValueOnce([]) + apiMocks.getMoveJobStatus.mockResolvedValue({ + jobId: 'job-1', + audiobookId: 42, + status: 'Completed', + progress: 100, + target: '/library/book', + }) + const store = useMoveJobsStore() + + store.start() + await vi.waitFor(() => expect(store.trackedById['job-1']?.status).toBe('Running')) + + await store.loadActiveJobs() + + expect(apiMocks.getMoveJobStatus).toHaveBeenCalledWith('job-1') + expect(toastMocks.success).toHaveBeenCalledWith( + 'Move completed', + 'Files moved to /library/book', + ) + expect(store.trackedById['job-1']).toBeUndefined() + }) + + it('preserves a job when a newer status read still reports it active', async () => { + apiMocks.getActiveMoveJobs + .mockResolvedValueOnce([ + { + jobId: 'job-1', + audiobookId: 42, + status: 'Running', + progress: 40, + target: '/library/book', + }, + ]) + .mockResolvedValueOnce([]) + apiMocks.getMoveJobStatus.mockResolvedValue({ + jobId: 'job-1', + audiobookId: 42, + status: 'Running', + progress: 55, + target: '/library/book', + }) + const store = useMoveJobsStore() + + store.start() + await vi.waitFor(() => expect(store.trackedById['job-1']?.status).toBe('Running')) + + await store.loadActiveJobs() + + expect(store.trackedById['job-1']?.status).toBe('Running') + expect(toastMocks.success).not.toHaveBeenCalled() + expect(toastMocks.error).not.toHaveBeenCalled() + }) + + it('does not resurrect a terminal job from an older in-flight active snapshot', async () => { + let resolveActive: + | (( + jobs: Array<{ + jobId: string + audiobookId: number + status: string + progress: number + target: string + }>, + ) => void) + | undefined + apiMocks.getActiveMoveJobs.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveActive = resolve + }), + ) + const store = useMoveJobsStore() + store.trackQueuedJob({ jobId: 'job-1', audiobookId: 42, target: '/library/book' }) + + const refresh = store.loadActiveJobs() + signalRMocks.callback?.({ + jobId: 'job-1', + audiobookId: 42, + status: 'Completed', + progress: 100, + target: '/library/book', + }) + resolveActive?.([ + { + jobId: 'job-1', + audiobookId: 42, + status: 'Running', + progress: 75, + target: '/library/book', + }, + ]) + await refresh + + expect(store.trackedById['job-1']).toBeUndefined() + expect(toastMocks.success).toHaveBeenCalledTimes(1) + }) + + it('ignores an older active snapshot when a newer refresh finishes first', async () => { + let resolveOlder: + | (( + jobs: Array<{ + jobId: string + audiobookId: number + status: string + progress: number + target: string + }>, + ) => void) + | undefined + apiMocks.getActiveMoveJobs + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveOlder = resolve + }), + ) + .mockResolvedValueOnce([]) + const store = useMoveJobsStore() + + const olderRefresh = store.loadActiveJobs() + await store.loadActiveJobs() + resolveOlder?.([ + { + jobId: 'job-old', + audiobookId: 42, + status: 'Running', + progress: 50, + target: '/library/book', + }, + ]) + await olderRefresh + + expect(store.trackedById['job-old']).toBeUndefined() + }) + + it('does not prune a newly tracked job from an older in-flight active snapshot', async () => { + let resolveActive: ((jobs: never[]) => void) | undefined + apiMocks.getActiveMoveJobs.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveActive = resolve + }), + ) + const store = useMoveJobsStore() + + const refresh = store.loadActiveJobs() + store.trackQueuedJob({ jobId: 'job-new', target: '/library/new' }) + resolveActive?.([]) + await refresh + + expect(store.trackedById['job-new']?.status).toBe('Queued') + }) + it('tracks queued move jobs and subscribes on first track', () => { const store = useMoveJobsStore() diff --git a/fe/src/__tests__/rootFolders.reauthorization.store.spec.ts b/fe/src/__tests__/rootFolders.reauthorization.store.spec.ts index 11bfa54c4..ba11a1f8a 100644 --- a/fe/src/__tests__/rootFolders.reauthorization.store.spec.ts +++ b/fe/src/__tests__/rootFolders.reauthorization.store.spec.ts @@ -19,6 +19,35 @@ describe('root folder relocation store actions', () => { setActivePinia(createPinia()) }) + it('does not let an older load overwrite a newer root-folder snapshot', async () => { + const older = { + id: 3, + name: 'Library', + path: '/srv/Old', + isDefault: false, + caseSensitivityMode: 'Auto' as const, + } + const newer = { ...older, path: '/srv/New' } + let resolveOlder: ((folders: (typeof older)[]) => void) | undefined + vi.mocked(apiService.getRootFolders) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveOlder = resolve + }), + ) + .mockResolvedValueOnce([newer]) + const store = useRootFoldersStore() + + const olderLoad = store.load() + await store.load() + resolveOlder?.([older]) + await olderLoad + + expect(store.folders).toEqual([newer]) + expect(store.loading).toBe(false) + }) + it('sends the exact current path as the server relocation precondition', async () => { const current = { id: 3, diff --git a/fe/src/__tests__/test-setup.ts b/fe/src/__tests__/test-setup.ts index fae2f06e6..94d5b9887 100644 --- a/fe/src/__tests__/test-setup.ts +++ b/fe/src/__tests__/test-setup.ts @@ -241,6 +241,14 @@ vi.mock('@/services/signalr', () => ({ void cb return () => {} }, + onConnected: (cb?: (...args: unknown[]) => void) => { + void cb + return () => {} + }, + onRootFolderRelocationUpdate: (cb?: (...args: unknown[]) => void) => { + void cb + return () => {} + }, onDownloadUpdate: (cb?: (...args: unknown[]) => void) => { void cb return () => {} diff --git a/fe/src/components/settings/RootFoldersSettings.vue b/fe/src/components/settings/RootFoldersSettings.vue index 31b9723a5..58ea97fb5 100644 --- a/fe/src/components/settings/RootFoldersSettings.vue +++ b/fe/src/components/settings/RootFoldersSettings.vue @@ -272,13 +272,21 @@ onMounted(async () => { await store.load() }) +const refreshRootFolders = () => { + store.load().catch(() => {}) +} const unsubscribeRelocation = typeof signalRService.onRootFolderRelocationUpdate === 'function' - ? signalRService.onRootFolderRelocationUpdate(() => { - store.load().catch(() => {}) - }) + ? signalRService.onRootFolderRelocationUpdate(refreshRootFolders) : () => {} -onUnmounted(unsubscribeRelocation) +const unsubscribeConnected = + typeof signalRService.onConnected === 'function' + ? signalRService.onConnected(refreshRootFolders) + : () => {} +onUnmounted(() => { + unsubscribeRelocation() + unsubscribeConnected() +}) function openAdd() { editing.value = null diff --git a/fe/src/stores/moveJobs.ts b/fe/src/stores/moveJobs.ts index 57eae4f25..745b81fcd 100644 --- a/fe/src/stores/moveJobs.ts +++ b/fe/src/stores/moveJobs.ts @@ -111,9 +111,31 @@ export const useMoveJobsStore = defineStore('moveJobs', () => { const trackedById = ref>({}) const toast = useToast() let unsubscribe: (() => void) | null = null + let evidenceClock = 0 + let activeRefreshGeneration = 0 + const evidenceVersionById = new Map() const trackedJobs = computed(() => Object.values(trackedById.value)) + function getEvidenceVersion(key: string): number { + return evidenceVersionById.get(key) ?? 0 + } + + function markEvidence(key: string): void { + evidenceClock += 1 + evidenceVersionById.set(key, evidenceClock) + } + + function setTrackedJob(key: string, job: TrackedMoveJob): void { + trackedById.value[key] = job + markEvidence(key) + } + + function removeTrackedJob(key: string): void { + delete trackedById.value[key] + markEvidence(key) + } + function getActiveJobForAudiobook(audiobookId: number): TrackedMoveJob | undefined { return trackedJobs.value.find( (job) => job.audiobookId === audiobookId && !terminalStatuses.has(job.status), @@ -167,7 +189,18 @@ export const useMoveJobsStore = defineStore('moveJobs', () => { async function loadActiveJobs() { try { + // Reconcile only jobs that were already tracked when this authoritative + // active-job snapshot began. A newly queued job can be added locally while + // the request is in flight and must not be pruned from an older snapshot. + const refreshGeneration = ++activeRefreshGeneration + const refreshEvidenceVersion = evidenceClock + const trackedBeforeRefresh = new Set(Object.keys(trackedById.value)) const jobs = await apiService.getActiveMoveJobs() + if (refreshGeneration !== activeRefreshGeneration) { + return + } + + const activeKeys = new Set() for (const job of jobs) { if (!job.jobId?.trim()) { continue @@ -179,8 +212,13 @@ export const useMoveJobsStore = defineStore('moveJobs', () => { } const key = normalizeJobId(job.jobId) + activeKeys.add(key) + if (getEvidenceVersion(key) > refreshEvidenceVersion) { + continue + } + const existing = trackedById.value[key] - trackedById.value[key] = { + setTrackedJob(key, { jobId: job.jobId, audiobookId: job.audiobookId ?? existing?.audiobookId, status, @@ -190,6 +228,50 @@ export const useMoveJobsStore = defineStore('moveJobs', () => { error: job.error, recoveryDisposition: job.recoveryDisposition ?? existing?.recoveryDisposition, canRetry: job.canRetry ?? existing?.canRetry, + }) + } + + for (const key of trackedBeforeRefresh) { + if (activeKeys.has(key)) { + continue + } + + const existing = trackedById.value[key] + if (!existing || getEvidenceVersion(key) > refreshEvidenceVersion) { + continue + } + + const lookupEvidenceVersion = getEvidenceVersion(key) + try { + const current = await apiService.getMoveJobStatus(existing.jobId) + if ( + refreshGeneration !== activeRefreshGeneration || + getEvidenceVersion(key) !== lookupEvidenceVersion + ) { + continue + } + + const currentStatus = normalizeStatus(current.status) + if (currentStatus != null && terminalStatuses.has(currentStatus)) { + handleMoveJobUpdate(current) + continue + } + if (currentStatus != null && !terminalStatuses.has(currentStatus)) { + // A status read taken after the active snapshot is newer evidence. + // Preserve the job and let the next refresh reconcile it. + continue + } + } catch { + // The active-list request succeeded and is authoritative for whether the + // job remains active. If its terminal detail lookup fails, drop the stale + // local entry rather than displaying a move forever after reconnect. + } + + if ( + refreshGeneration === activeRefreshGeneration && + getEvidenceVersion(key) === lookupEvidenceVersion + ) { + removeTrackedJob(key) } } } catch (error) { @@ -221,19 +303,24 @@ export const useMoveJobsStore = defineStore('moveJobs', () => { start() const key = normalizeJobId(job.jobId) - trackedById.value[key] = { + setTrackedJob(key, { jobId: job.jobId, audiobookId: job.audiobookId, status: job.status ?? 'Queued', progress: job.status === 'Completed' ? 100 : 0, target: job.target, - } + }) void reconcileTrackedJob(key, job.jobId) } async function reconcileTrackedJob(key: string, jobId: string) { + const lookupEvidenceVersion = getEvidenceVersion(key) try { const current = await apiService.getMoveJobStatus(jobId) + if (getEvidenceVersion(key) !== lookupEvidenceVersion) { + return + } + const existing = trackedById.value[key] if (!existing) { return @@ -259,13 +346,16 @@ export const useMoveJobsStore = defineStore('moveJobs', () => { } const key = normalizeJobId(update.jobId) - const existing = trackedById.value[key] - if (!existing) { + const status = normalizeStatus(update.status) + if (status == null) { return } - const status = normalizeStatus(update.status) - if (status == null) { + const existing = trackedById.value[key] + if (!existing) { + if (terminalStatuses.has(status)) { + markEvidence(key) + } return } @@ -283,7 +373,7 @@ export const useMoveJobsStore = defineStore('moveJobs', () => { recoveryDisposition: update.recoveryDisposition ?? existing.recoveryDisposition, canRetry: update.canRetry ?? existing.canRetry, } - trackedById.value[key] = next + setTrackedJob(key, next) if (status === 'Running' && existing.status !== 'Running') { toast.info('Move in progress', `Moving files to ${next.target || 'selected destination'}`) @@ -305,7 +395,7 @@ export const useMoveJobsStore = defineStore('moveJobs', () => { ) } - delete trackedById.value[key] + removeTrackedJob(key) } return { diff --git a/fe/src/stores/rootFolders.ts b/fe/src/stores/rootFolders.ts index d7c980905..03562d674 100644 --- a/fe/src/stores/rootFolders.ts +++ b/fe/src/stores/rootFolders.ts @@ -25,23 +25,28 @@ import { rootFolderPathChanged } from '@/utils/rootFolderPath' export const useRootFoldersStore = defineStore('rootFolders', () => { const folders = ref([]) const loading = ref(false) + let loadGeneration = 0 const defaultFolder = computed(() => folders.value.find((f) => f.isDefault) || null) async function load() { + const generation = ++loadGeneration loading.value = true try { - if (typeof apiService.getRootFolders === 'function') { - folders.value = await apiService.getRootFolders() - } else { - // In some tests the apiService is mocked partially; default to empty list - folders.value = [] + const nextFolders = + typeof apiService.getRootFolders === 'function' ? await apiService.getRootFolders() : [] + if (generation === loadGeneration) { + folders.value = nextFolders } } catch (err) { logger.debug('Failed to load root folders:', err) - folders.value = [] + if (generation === loadGeneration) { + folders.value = [] + } } finally { - loading.value = false + if (generation === loadGeneration) { + loading.value = false + } } } diff --git a/listenarr.api/Features/Library/LibraryBulkEditWorkflow.Update.cs b/listenarr.api/Features/Library/LibraryBulkEditWorkflow.Update.cs index 150d875a3..b6cf56494 100644 --- a/listenarr.api/Features/Library/LibraryBulkEditWorkflow.Update.cs +++ b/listenarr.api/Features/Library/LibraryBulkEditWorkflow.Update.cs @@ -20,7 +20,8 @@ private async Task RewriteRootFolderIfRequestedAsync( int id, Dictionary? updates, ApplicationSettings? settings, - string? explicitRootPath = null) + string? explicitRootPath = null, + CancellationToken cancellationToken = default) { object? rootObject = explicitRootPath; if (rootObject == null @@ -31,6 +32,7 @@ private async Task RewriteRootFolderIfRequestedAsync( try { + cancellationToken.ThrowIfCancellationRequested(); var rootPath = ExtractRootPath(rootObject); if (string.IsNullOrWhiteSpace(rootPath)) { @@ -40,6 +42,7 @@ private async Task RewriteRootFolderIfRequestedAsync( using var scope = _scopeFactory.CreateScope(); var repository = scope.ServiceProvider.GetRequiredService(); var audiobook = await repository.GetByIdAsync(id); + cancellationToken.ThrowIfCancellationRequested(); if (audiobook == null) { return new RootFolderRewriteOutcome( @@ -71,7 +74,8 @@ private async Task RewriteRootFolderIfRequestedAsync( await _destinationRewriteService.RewriteDestinationAsync( id, newBasePath, - audiobook.BasePath); + audiobook.BasePath, + cancellationToken); await TryAddBulkUpdateHistoryAsync( audiobook, $"Destination path rewritten to {newBasePath} via bulk update"); @@ -99,7 +103,8 @@ await TryAddBulkUpdateHistoryAsync( private async Task PlanPhysicalPathChangeAsync( int id, string? destinationRootOrPath, - ApplicationSettings? settings) + ApplicationSettings? settings, + CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(destinationRootOrPath)) { @@ -110,9 +115,11 @@ private async Task PlanPhysicalPathChangeAsync( try { + cancellationToken.ThrowIfCancellationRequested(); using var scope = _scopeFactory.CreateScope(); var repository = scope.ServiceProvider.GetRequiredService(); var audiobook = await repository.GetByIdAsync(id); + cancellationToken.ThrowIfCancellationRequested(); if (audiobook == null) { return new PhysicalPathChangePlan( @@ -189,14 +196,17 @@ private async Task UpdateOneAsync( int id, Dictionary? updates, bool rootFolderRewritten, - bool physicalPathChangeRequested) + bool physicalPathChangeRequested, + CancellationToken cancellationToken) { var errors = new List(); try { + cancellationToken.ThrowIfCancellationRequested(); using var scope = _scopeFactory.CreateScope(); var repository = scope.ServiceProvider.GetRequiredService(); var audiobook = await repository.GetByIdAsync(id); + cancellationToken.ThrowIfCancellationRequested(); if (audiobook == null) { errors.Add($"Audiobook with ID {id} not found"); @@ -263,6 +273,7 @@ private async Task UpdateOneAsync( if (changed) { + cancellationToken.ThrowIfCancellationRequested(); if (!await repository.UpdateAsync(audiobook)) { errors.Add( @@ -302,18 +313,80 @@ private sealed record PhysicalPathChangePlan(string? Destination, string? Error) public static PhysicalPathChangePlan NotRequested { get; } = new(null, null); } + private sealed record PhysicalBulkUpdateOutcome( + PhysicalPathChangePlan Plan, + BulkUpdateOutcome Update, + IActionResult? EnqueueResult); + private sealed record BulkUpdateOutcome( bool Success, bool MetadataUpdated, List Errors); - private async Task TryLoadApplicationSettingsAsync() + private Task ExecutePhysicalPathChangeAsync( + int id, + Dictionary metadataUpdates, + ApplicationSettings? settings, + LibraryController.BulkPathChangeRequest? pathChange, + CancellationToken cancellationToken) => + _filesystemMutationCoordinator.ExecuteExclusiveAsync( + globalToken => _audiobookOperationCoordinator.ExecuteExclusiveAsync( + id, + async token => + { + await _moveQueueService.EnsureFilesystemMutationAllowedAsync( + id, + token); + var plan = await PlanPhysicalPathChangeAsync( + id, + pathChange?.DestinationRootOrPath, + settings, + token); + var update = await UpdateOneAsync( + id, + metadataUpdates, + rootFolderRewritten: false, + physicalPathChangeRequested: true, + token); + IActionResult? enqueueResult = null; + if (plan.Error == null + && !string.IsNullOrWhiteSpace(plan.Destination) + && update.Success) + { + var enqueueToken = update.MetadataUpdated + ? CancellationToken.None + : token; + enqueueToken.ThrowIfCancellationRequested(); + enqueueResult = await _moveWorkflow.EnqueueAsync( + id, + new LibraryController.MoveRequest + { + DestinationPath = plan.Destination, + MoveFiles = true, + DeleteEmptySource = pathChange?.DeleteEmptySource ?? true + }, + enqueueToken); + } + + return new PhysicalBulkUpdateOutcome( + plan, + update, + enqueueResult); + }, + globalToken), + cancellationToken); + + private async Task TryLoadApplicationSettingsAsync( + CancellationToken cancellationToken) { try { + cancellationToken.ThrowIfCancellationRequested(); using var scope = _scopeFactory.CreateScope(); var configService = scope.ServiceProvider.GetRequiredService(); - return await configService.GetApplicationSettingsAsync(); + var settings = await configService.GetApplicationSettingsAsync(); + cancellationToken.ThrowIfCancellationRequested(); + return settings; } catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) { diff --git a/listenarr.api/Features/Library/LibraryBulkEditWorkflow.cs b/listenarr.api/Features/Library/LibraryBulkEditWorkflow.cs index 86acf9382..1ff9f189f 100644 --- a/listenarr.api/Features/Library/LibraryBulkEditWorkflow.cs +++ b/listenarr.api/Features/Library/LibraryBulkEditWorkflow.cs @@ -64,15 +64,19 @@ public LibraryBulkEditWorkflow( _logger = logger; } - public async Task BulkUpdateAsync(LibraryController.BulkUpdateRequest request) + public async Task BulkUpdateAsync( + LibraryController.BulkUpdateRequest request, + CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); if (request?.Ids == null || !request.Ids.Any()) { return new BadRequestObjectResult(new { message = "No audiobook IDs provided for bulk update" }); } var results = new List(); - var settings = await TryLoadApplicationSettingsAsync(); + var stoppedAfterCancellation = false; + var settings = await TryLoadApplicationSettingsAsync(cancellationToken); var pathChangeMode = request.PathChange?.Mode ?? LibraryController.BulkPathChangeMode.None; if (!Enum.IsDefined(pathChangeMode)) @@ -95,129 +99,151 @@ public async Task BulkUpdateAsync(LibraryController.BulkUpdateReq foreach (var id in request.Ids.Distinct()) { - var physicalPlan = pathChangeMode == LibraryController.BulkPathChangeMode.Physical - ? await PlanPhysicalPathChangeAsync( - id, - request.PathChange?.DestinationRootOrPath, - settings) - : PhysicalPathChangePlan.NotRequested; - var rootRewrite = pathChangeMode switch + try { - LibraryController.BulkPathChangeMode.Physical => - new RootFolderRewriteOutcome(false, null, null), - LibraryController.BulkPathChangeMode.MetadataOnly => - await RewriteRootFolderIfRequestedAsync( + cancellationToken.ThrowIfCancellationRequested(); + var rootRewrite = pathChangeMode switch + { + LibraryController.BulkPathChangeMode.Physical => + new RootFolderRewriteOutcome(false, null, null), + LibraryController.BulkPathChangeMode.MetadataOnly => + await RewriteRootFolderIfRequestedAsync( + id, + metadataUpdates, + settings, + request.PathChange?.DestinationRootOrPath, + cancellationToken), + _ => await RewriteRootFolderIfRequestedAsync( id, metadataUpdates, settings, - request.PathChange?.DestinationRootOrPath), - _ => await RewriteRootFolderIfRequestedAsync( - id, - metadataUpdates, - settings) - }; - BulkUpdateOutcome outcome; - try - { - outcome = await _audiobookOperationCoordinator.ExecuteExclusiveAsync( - id, - async token => + cancellationToken: cancellationToken) + }; + var physicalPlan = PhysicalPathChangePlan.NotRequested; + IActionResult? enqueueResult = null; + BulkUpdateOutcome outcome; + try + { + if (pathChangeMode == LibraryController.BulkPathChangeMode.Physical) { - if (pathChangeMode == LibraryController.BulkPathChangeMode.Physical) - { - await _moveQueueService.EnsureFilesystemMutationAllowedAsync(id, token); - } - - return await UpdateOneAsync( + var physical = await ExecutePhysicalPathChangeAsync( id, metadataUpdates, - rootRewrite.Rewritten, - pathChangeMode == LibraryController.BulkPathChangeMode.Physical); - }); - } - catch (ApplicationConflictException exception) - { - outcome = new BulkUpdateOutcome( - Success: false, - MetadataUpdated: false, - Errors: [exception.SafeDetail]); - } - var errors = outcome.Errors - .Concat(rootRewrite.Error == null ? [] : [rootRewrite.Error]) - .Concat(physicalPlan.Error == null ? [] : [physicalPlan.Error]) - .Distinct(StringComparer.Ordinal) - .ToList(); - var success = outcome.Success - && (pathChangeMode != LibraryController.BulkPathChangeMode.MetadataOnly - || rootRewrite.Error == null); - Guid? moveJobId = null; - var resolvedDestination = pathChangeMode == LibraryController.BulkPathChangeMode.MetadataOnly - ? rootRewrite.Destination - : physicalPlan.Destination; - var pathChangeOutcome = pathChangeMode switch - { - LibraryController.BulkPathChangeMode.Physical => "not-enqueued", - LibraryController.BulkPathChangeMode.MetadataOnly when rootRewrite.Rewritten => "metadata-updated", - LibraryController.BulkPathChangeMode.MetadataOnly => "failed", - _ => "none" - }; - - if (pathChangeMode == LibraryController.BulkPathChangeMode.Physical) - { - if (physicalPlan.Error != null || string.IsNullOrWhiteSpace(physicalPlan.Destination)) + settings, + request.PathChange, + cancellationToken); + physicalPlan = physical.Plan; + outcome = physical.Update; + enqueueResult = physical.EnqueueResult; + } + else + { + var itemContinuationToken = rootRewrite.Rewritten + ? CancellationToken.None + : cancellationToken; + outcome = await _audiobookOperationCoordinator.ExecuteExclusiveAsync( + id, + token => UpdateOneAsync( + id, + metadataUpdates, + rootRewrite.Rewritten, + physicalPathChangeRequested: false, + token), + itemContinuationToken); + } + } + catch (ApplicationConflictException exception) { - success = false; + outcome = new BulkUpdateOutcome( + Success: false, + MetadataUpdated: false, + Errors: [exception.SafeDetail]); } - else if (outcome.Success) + + var errors = outcome.Errors + .Concat(rootRewrite.Error == null ? [] : [rootRewrite.Error]) + .Concat(physicalPlan.Error == null ? [] : [physicalPlan.Error]) + .Distinct(StringComparer.Ordinal) + .ToList(); + var success = outcome.Success + && (pathChangeMode != LibraryController.BulkPathChangeMode.MetadataOnly + || rootRewrite.Error == null); + Guid? moveJobId = null; + var resolvedDestination = pathChangeMode == LibraryController.BulkPathChangeMode.MetadataOnly + ? rootRewrite.Destination + : physicalPlan.Destination; + var pathChangeOutcome = pathChangeMode switch { - var enqueueResult = await _moveWorkflow.EnqueueAsync( - id, - new LibraryController.MoveRequest - { - DestinationPath = physicalPlan.Destination, - MoveFiles = true, - DeleteEmptySource = request.PathChange?.DeleteEmptySource ?? true - }); - if (enqueueResult is AcceptedResult - { - Value: MoveEnqueuedResponse enqueued - }) + LibraryController.BulkPathChangeMode.Physical => "not-enqueued", + LibraryController.BulkPathChangeMode.MetadataOnly when rootRewrite.Rewritten => "metadata-updated", + LibraryController.BulkPathChangeMode.MetadataOnly => "failed", + _ => "none" + }; + + if (pathChangeMode == LibraryController.BulkPathChangeMode.Physical) + { + if (physicalPlan.Error != null + || string.IsNullOrWhiteSpace(physicalPlan.Destination)) { - if (enqueued.JobId == Guid.Empty) + success = false; + } + else if (outcome.Success) + { + if (enqueueResult is AcceptedResult + { + Value: MoveEnqueuedResponse enqueued + }) { - success = false; - pathChangeOutcome = "failed"; - errors.Add("The server did not return a durable move job ID."); + if (enqueued.JobId == Guid.Empty) + { + success = false; + pathChangeOutcome = "failed"; + errors.Add( + "The server did not return a durable move job ID."); + } + else + { + moveJobId = enqueued.JobId; + resolvedDestination = enqueued.Target; + pathChangeOutcome = "enqueued"; + } } else { - moveJobId = enqueued.JobId; - resolvedDestination = enqueued.Target; - pathChangeOutcome = "enqueued"; + success = false; + pathChangeOutcome = "failed"; + errors.Add(enqueueResult == null + ? "Physical move was not enqueued." + : GetActionResultError(enqueueResult)); } } - else - { - success = false; - pathChangeOutcome = "failed"; - errors.Add(GetActionResultError(enqueueResult)); - } } - } - results.Add(new + results.Add(new + { + id, + success, + metadataUpdated = outcome.MetadataUpdated, + pathChangeOutcome, + moveJobId, + resolvedDestination, + errors = errors.Distinct(StringComparer.Ordinal).ToList() + }); + } + catch (OperationCanceledException) when (results.Count > 0) { - id, - success, - metadataUpdated = outcome.MetadataUpdated, - pathChangeOutcome, - moveJobId, - resolvedDestination, - errors = errors.Distinct(StringComparer.Ordinal).ToList() - }); + stoppedAfterCancellation = true; + break; + } } - return new OkObjectResult(new { message = "Bulk update completed", results }); + return new OkObjectResult(new + { + message = stoppedAfterCancellation + ? "Bulk update stopped after request cancellation" + : "Bulk update completed", + results + }); } } diff --git a/listenarr.api/Features/Library/LibraryController.cs b/listenarr.api/Features/Library/LibraryController.cs index 390290115..7722922c3 100644 --- a/listenarr.api/Features/Library/LibraryController.cs +++ b/listenarr.api/Features/Library/LibraryController.cs @@ -186,10 +186,17 @@ public async Task GetAudiobookFilesDebug(int id) /// /// Audiobook ID. /// Fields to update. + /// Request cancellation token. [HttpPut("{id}")] - public async Task UpdateAudiobook(int id, [FromBody] AudiobookUpdateRequest request) + public async Task UpdateAudiobook( + int id, + [FromBody] AudiobookUpdateRequest request, + CancellationToken cancellationToken = default) { - return await _updateWorkflow.UpdateAsync(id, request); + return await _updateWorkflow.UpdateAsync( + id, + request, + cancellationToken); } /// @@ -233,10 +240,15 @@ public async Task BulkDeleteAudiobooks( /// Bulk-update fields (monitored status, quality profile, root folder) for multiple audiobooks at once. /// /// Audiobook IDs and the fields to update. + /// Request cancellation token. [HttpPost("bulk-update")] - public async Task BulkUpdateAudiobooks([FromBody] BulkUpdateRequest request) + public async Task BulkUpdateAudiobooks( + [FromBody] BulkUpdateRequest request, + CancellationToken cancellationToken = default) { - return await _bulkEditWorkflow.BulkUpdateAsync(request); + return await _bulkEditWorkflow.BulkUpdateAsync( + request, + cancellationToken); } /// @@ -244,9 +256,15 @@ public async Task BulkUpdateAudiobooks([FromBody] BulkUpdateReque /// Optional body: { path: "C:\\some\\folder" } to scan a specific folder instead of the configured output path. /// [HttpPost("{id}/scan")] - public async Task ScanAudiobookFiles(int id, [FromBody] ScanRequest? request) + public async Task ScanAudiobookFiles( + int id, + [FromBody] ScanRequest? request, + CancellationToken cancellationToken = default) { - return await _manualScanWorkflow.ScanAsync(id, request); + return await _manualScanWorkflow.ScanAsync( + id, + request, + cancellationToken); } /// diff --git a/listenarr.api/Features/Library/LibraryManualScanWorkflow.cs b/listenarr.api/Features/Library/LibraryManualScanWorkflow.cs index 948886f12..2a53bcf29 100644 --- a/listenarr.api/Features/Library/LibraryManualScanWorkflow.cs +++ b/listenarr.api/Features/Library/LibraryManualScanWorkflow.cs @@ -63,12 +63,14 @@ public LibraryManualScanWorkflow( public Task ScanAsync( int id, - LibraryController.ScanRequest? request) => + LibraryController.ScanRequest? request, + CancellationToken cancellationToken = default) => _filesystemMutationCoordinator.ExecuteExclusiveAsync( globalToken => _audiobookOperationCoordinator.ExecuteExclusiveAsync( id, token => ScanCoreAsync(id, request, token), - globalToken)); + globalToken), + cancellationToken); private async Task ScanCoreAsync( int id, @@ -90,7 +92,9 @@ await _moveQueueService.EnsureFilesystemMutationAllowedAsync( }); } - var audiobook = await _repo.GetByIdAsync(id); + var audiobook = await _repo.GetForScanSnapshotAsync( + id, + cancellationToken); if (audiobook == null) { return new NotFoundObjectResult(new @@ -101,7 +105,8 @@ await _moveQueueService.EnsureFilesystemMutationAllowedAsync( var pathResolution = await _scanPathResolver.ResolveAsync( audiobook, - request?.Path); + request?.Path, + cancellationToken); if (pathResolution.ErrorResult != null) { return pathResolution.ErrorResult; @@ -134,6 +139,7 @@ await _moveQueueService.EnsureFilesystemMutationAllowedAsync( audiobook.BasePath, scanRoot, pathResolution.PathIdentity.Value.Semantics); + cancellationToken.ThrowIfCancellationRequested(); var queuedResult = await _scanQueueWorkflow.TryEnqueueAsync( audiobook, scanRoot, diff --git a/listenarr.api/Features/Library/LibraryMetadataRescanWorkflow.Coordination.cs b/listenarr.api/Features/Library/LibraryMetadataRescanWorkflow.Coordination.cs index 416592991..a6f5fbc61 100644 --- a/listenarr.api/Features/Library/LibraryMetadataRescanWorkflow.Coordination.cs +++ b/listenarr.api/Features/Library/LibraryMetadataRescanWorkflow.Coordination.cs @@ -7,11 +7,14 @@ public sealed partial class LibraryMetadataRescanWorkflow private async Task ApplyMetadataRescanResultAsync( int audiobookId, AudibleBookMetadata metadata, - string expectedMetadataState) + string expectedMetadataState, + CancellationToken cancellationToken) { + cancellationToken.ThrowIfCancellationRequested(); using var scope = _scopeFactory.CreateScope(); var repository = scope.ServiceProvider.GetRequiredService(); var audiobook = await repository.GetByIdAsync(audiobookId); + cancellationToken.ThrowIfCancellationRequested(); if (audiobook == null) { return new MetadataRescanApplyResult(MetadataRescanApplyStatus.NotFound); @@ -38,6 +41,7 @@ private async Task ApplyMetadataRescanResultAsync( AudiobookIdentifierMapper.SyncImportedIdentifiersFromLegacyFields(audiobook); } + cancellationToken.ThrowIfCancellationRequested(); if (!await repository.UpdateAsync(audiobook)) { return new MetadataRescanApplyResult( diff --git a/listenarr.api/Features/Library/LibraryMetadataRescanWorkflow.cs b/listenarr.api/Features/Library/LibraryMetadataRescanWorkflow.cs index 69d629aeb..3b4deb964 100644 --- a/listenarr.api/Features/Library/LibraryMetadataRescanWorkflow.cs +++ b/listenarr.api/Features/Library/LibraryMetadataRescanWorkflow.cs @@ -64,9 +64,13 @@ public LibraryMetadataRescanWorkflow( public async Task RescanAsync(int id, HttpContext httpContext) { + var cancellationToken = httpContext.RequestAborted; + cancellationToken.ThrowIfCancellationRequested(); + using var preflightScope = _scopeFactory.CreateScope(); var preflightRepository = preflightScope.ServiceProvider.GetRequiredService(); var audiobook = await preflightRepository.GetByIdAsync(id); + cancellationToken.ThrowIfCancellationRequested(); if (audiobook == null) { @@ -163,6 +167,7 @@ async Task TryMetadataLookupByAsinAsync(string asin, string? preferredRegi try { rawResult = await _metadataService.GetMetadataAsync(normalizedAsin, regionValue, cache: false); + cancellationToken.ThrowIfCancellationRequested(); } catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) { @@ -240,6 +245,7 @@ async Task TryMetadataLookupByAsinAsync(string asin, string? preferredRegi isbnConversionAttempts++; var (success, asinFromIsbn, _) = await _asinLookupService.GetAsinFromIsbnAsync(isbnValue); + cancellationToken.ThrowIfCancellationRequested(); if (!success || string.IsNullOrWhiteSpace(asinFromIsbn)) { continue; @@ -285,6 +291,7 @@ async Task TryMetadataLookupByAsinAsync(string asin, string? preferredRegi }); } + cancellationToken.ThrowIfCancellationRequested(); var convertedMetadata = _metadataConverters.ConvertAudibleToMetadata( providerMetadata, resolvedAsin, @@ -298,11 +305,14 @@ async Task TryMetadataLookupByAsinAsync(string asin, string? preferredRegi async token => { await _moveQueueService.EnsureFilesystemMutationAllowedAsync(id, token); + token.ThrowIfCancellationRequested(); return await ApplyMetadataRescanResultAsync( id, convertedMetadata, - expectedMetadataState); - }); + expectedMetadataState, + token); + }, + cancellationToken); } catch (ApplicationConflictException exception) { diff --git a/listenarr.api/Features/Library/LibraryUpdateWorkflow.Metadata.cs b/listenarr.api/Features/Library/LibraryUpdateWorkflow.Metadata.cs index 133646591..a8da8f9c6 100644 --- a/listenarr.api/Features/Library/LibraryUpdateWorkflow.Metadata.cs +++ b/listenarr.api/Features/Library/LibraryUpdateWorkflow.Metadata.cs @@ -9,11 +9,14 @@ private async Task ApplyMetadataUpdatesAsync( AudiobookUpdateRequest request, bool basePathRewritten, bool suppressStaleImageUrl, - bool metadataUpdateRequested) + bool metadataUpdateRequested, + CancellationToken cancellationToken) { + cancellationToken.ThrowIfCancellationRequested(); using var scope = _scopeFactory.CreateScope(); var repository = scope.ServiceProvider.GetRequiredService(); var existingAudiobook = await repository.GetByIdAsync(id); + cancellationToken.ThrowIfCancellationRequested(); if (existingAudiobook == null) { return new NotFoundObjectResult(new { message = "Audiobook not found" }); @@ -67,13 +70,17 @@ private async Task ApplyMetadataUpdatesAsync( if (request.FileSize.HasValue) existingAudiobook.FileSize = request.FileSize; if (request.Quality != null) existingAudiobook.Quality = request.Quality; - await ApplyQualityProfileAsync(existingAudiobook, request); + await ApplyQualityProfileAsync( + existingAudiobook, + request, + cancellationToken); if (legacyIdentifierFieldsTouched) { AudiobookIdentifierMapper.SyncImportedIdentifiersFromLegacyFields(existingAudiobook); } + cancellationToken.ThrowIfCancellationRequested(); if (metadataUpdateRequested && !await repository.UpdateAsync(existingAudiobook)) { diff --git a/listenarr.api/Features/Library/LibraryUpdateWorkflow.cs b/listenarr.api/Features/Library/LibraryUpdateWorkflow.cs index c3cf42a17..113a560c6 100644 --- a/listenarr.api/Features/Library/LibraryUpdateWorkflow.cs +++ b/listenarr.api/Features/Library/LibraryUpdateWorkflow.cs @@ -44,9 +44,15 @@ public LibraryUpdateWorkflow( _logger = logger; } - public async Task UpdateAsync(int id, AudiobookUpdateRequest request) + public async Task UpdateAsync( + int id, + AudiobookUpdateRequest request, + CancellationToken cancellationToken = default) { - var existingAudiobook = await GetAudiobookPreflightSnapshotAsync(id); + cancellationToken.ThrowIfCancellationRequested(); + var existingAudiobook = await GetAudiobookPreflightSnapshotAsync( + id, + cancellationToken); if (existingAudiobook == null) { return new NotFoundObjectResult(new { message = "Audiobook not found" }); @@ -65,7 +71,8 @@ public async Task UpdateAsync(int id, AudiobookUpdateRequest requ { suppressStaleImageUrl = await IsPathInsideBasePathAsync( request.ImageUrl, - existingAudiobook.BasePath); + existingAudiobook.BasePath, + cancellationToken); _logger.LogWarning( "Deprecated PUT /library/{AudiobookId} BasePath update received. Route destination changes through the move endpoint with moveFiles=false.", id); @@ -75,7 +82,8 @@ public async Task UpdateAsync(int id, AudiobookUpdateRequest requ await _destinationRewriteService.RewriteDestinationAsync( id, request.BasePath, - existingAudiobook.BasePath); + existingAudiobook.BasePath, + cancellationToken); basePathRewritten = true; } catch (ListenarrApplicationException ex) @@ -98,26 +106,34 @@ await _destinationRewriteService.RewriteDestinationAsync( } } + var completionToken = basePathRewritten + ? CancellationToken.None + : cancellationToken; return await _audiobookOperationCoordinator.ExecuteExclusiveAsync( id, - _ => ApplyMetadataUpdatesAsync( + token => ApplyMetadataUpdatesAsync( id, request, basePathRewritten, suppressStaleImageUrl, - metadataUpdateRequested)); + metadataUpdateRequested, + token), + completionToken); } - private async Task GetAudiobookPreflightSnapshotAsync(int id) + private async Task GetAudiobookPreflightSnapshotAsync( + int id, + CancellationToken cancellationToken) { using var scope = _scopeFactory.CreateScope(); var repository = scope.ServiceProvider.GetRequiredService(); - return await repository.GetByIdAsync(id); + return await repository.GetForUpdateSnapshotAsync(id, cancellationToken); } private async Task IsPathInsideBasePathAsync( string? candidatePath, - string? basePath) + string? basePath, + CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(candidatePath) || string.IsNullOrWhiteSpace(basePath)) @@ -129,7 +145,8 @@ private async Task IsPathInsideBasePathAsync( { var resolution = await _fileSystemSemanticsResolver.ResolveAsync( basePath, - FileSystemCaseSensitivityMode.Auto); + FileSystemCaseSensitivityMode.Auto, + cancellationToken); return resolution.State == PathIdentityState.Valid && FileSystemPathIdentity.IsSameOrInside( candidatePath, @@ -226,7 +243,10 @@ private static void ApplySeriesMembershipUpdates(Audiobook existingAudiobook, Au AudiobookSeriesMembershipHelper.ApplyPrimarySeriesFields(existingAudiobook); } - private async Task ApplyQualityProfileAsync(Audiobook existingAudiobook, AudiobookUpdateRequest request) + private async Task ApplyQualityProfileAsync( + Audiobook existingAudiobook, + AudiobookUpdateRequest request, + CancellationToken cancellationToken) { if (!request.QualityProfileId.HasValue) { @@ -235,9 +255,11 @@ private async Task ApplyQualityProfileAsync(Audiobook existingAudiobook, Audiobo if (request.QualityProfileId.Value == -1) { + cancellationToken.ThrowIfCancellationRequested(); using var scope = _scopeFactory.CreateScope(); var qualityProfileService = scope.ServiceProvider.GetRequiredService(); var defaultProfile = await qualityProfileService.GetDefaultAsync(); + cancellationToken.ThrowIfCancellationRequested(); if (defaultProfile != null) { existingAudiobook.QualityProfileId = defaultProfile.Id; diff --git a/listenarr.application/Audiobooks/Contracts/ILibraryDirectoryOwnershipStore.cs b/listenarr.application/Audiobooks/Contracts/ILibraryDirectoryOwnershipStore.cs index d9b63275a..f19cc2896 100644 --- a/listenarr.application/Audiobooks/Contracts/ILibraryDirectoryOwnershipStore.cs +++ b/listenarr.application/Audiobooks/Contracts/ILibraryDirectoryOwnershipStore.cs @@ -54,6 +54,13 @@ Task> GetOwnedWithinAsync( FileSystemPathSemantics semantics, CancellationToken cancellationToken = default); + Task TryRetireReplacedByMarkerlessMoveAsync( + string path, + FileSystemPathSemantics semantics, + Guid moveJobId, + string replacementDirectoryObjectIdentity, + CancellationToken cancellationToken = default); + Task BeginRemovalAsync( long ownershipId, string expectedOwnershipKey, diff --git a/listenarr.application/Audiobooks/Contracts/Repositories/IAudiobookRepository.cs b/listenarr.application/Audiobooks/Contracts/Repositories/IAudiobookRepository.cs index 29df853df..3df20b2a3 100644 --- a/listenarr.application/Audiobooks/Contracts/Repositories/IAudiobookRepository.cs +++ b/listenarr.application/Audiobooks/Contracts/Repositories/IAudiobookRepository.cs @@ -43,6 +43,7 @@ Task> GetOtherPathReferenceSnapshotsAsync( Task GetByIsbnAsync(string isbn); Task GetByIdAsync(int id); Task GetByIdSnapshotAsync(int id, CancellationToken ct = default); + Task GetForUpdateSnapshotAsync(int id, CancellationToken ct = default); Task GetForScanAsync(int id, CancellationToken ct = default); Task GetForScanSnapshotAsync(int id, CancellationToken ct = default); Task TryUpdateBasePathAsync( diff --git a/listenarr.application/Audiobooks/Jobs/MoveRecoveryPolicy.cs b/listenarr.application/Audiobooks/Jobs/MoveRecoveryPolicy.cs index 2d2433d71..559f2ae4d 100644 --- a/listenarr.application/Audiobooks/Jobs/MoveRecoveryPolicy.cs +++ b/listenarr.application/Audiobooks/Jobs/MoveRecoveryPolicy.cs @@ -102,14 +102,85 @@ public static MoveRecoveryDisposition GetDisposition(MoveJob job) if (job.Status == MoveJobStatus.NeedsAttention) { - return job.FailureKind is MoveFailureKind.Transient or MoveFailureKind.Persistence - ? MoveRecoveryDisposition.RetryAvailable - : MoveRecoveryDisposition.OperatorRepairRequired; + if (job.FailureKind is MoveFailureKind.Transient or MoveFailureKind.Persistence) + { + return MoveRecoveryDisposition.RetryAvailable; + } + + if (job.FailureKind == MoveFailureKind.Unknown + && HasCompletedMarkerlessRecoveryEvidence(job)) + { + return MoveRecoveryDisposition.RetryAvailable; + } + + return MoveRecoveryDisposition.OperatorRepairRequired; } return MoveRecoveryDisposition.None; } + private static bool HasCompletedMarkerlessRecoveryEvidence(MoveJob job) + { + if (job.ExecutionProtocolVersion < MoveExecutionProtocol.MarkerlessDatabaseState + || job.SourceDirectoryCleanupState != MoveJobEntryCleanupState.Deleted + || string.IsNullOrWhiteSpace(job.TargetDirectoryObjectIdentity) + || string.IsNullOrWhiteSpace(job.RequestedPath)) + { + return false; + } + + var fileEntries = job.Entries + .Where(entry => entry.EntryType == MoveJobEntryType.File) + .ToList(); + if (fileEntries.Count == 0 + || fileEntries.Any(entry => + entry.CopyState != MoveJobEntryCopyState.Verified + || entry.CleanupState != MoveJobEntryCleanupState.Deleted)) + { + return false; + } + + if (!job.TryGetTargetIdentity(out var targetIdentity) + || !Listenarr.Domain.Common.FileSystemPathIdentity + .TryCanonicalizeStoredPathWithIdentityForHost( + job.RequestedPath, + targetIdentity, + out var requestedPath, + out _)) + { + return false; + } + + foreach (var directory in job.CreatedDirectories) + { + if (directory.State != MoveCreatedDirectoryState.Created + || string.IsNullOrWhiteSpace(directory.DirectoryObjectIdentity) + || !string.Equals( + directory.DirectoryObjectIdentity, + job.TargetDirectoryObjectIdentity, + StringComparison.Ordinal) + || !Listenarr.Domain.Common.FileSystemPathIdentity + .TryCanonicalizeStoredPathWithIdentityForHost( + directory.Path, + targetIdentity, + out var directoryPath, + out _)) + { + continue; + } + + if (Listenarr.Domain.Common.FileSystemPathIdentity.AreEquivalent( + directoryPath, + requestedPath, + targetIdentity.Semantics)) + { + return true; + } + } + + return false; + } + public static MoveRecoveryState ClassifyAudiobookJobs(IEnumerable jobs) { ArgumentNullException.ThrowIfNull(jobs); diff --git a/listenarr.domain/Audiobooks/RootFolderRelocation.cs b/listenarr.domain/Audiobooks/RootFolderRelocation.cs index edf5f9509..6b2acd73e 100644 --- a/listenarr.domain/Audiobooks/RootFolderRelocation.cs +++ b/listenarr.domain/Audiobooks/RootFolderRelocation.cs @@ -31,7 +31,10 @@ public enum LibraryDirectoryOwnershipPathMigrationState Prepared, MarkersPublished, MetadataCommitted, - SourceMarkersRetired + SourceMarkersRetired, + TargetValidated, + MarkerlessCommitted, + MarkerlessRetired } public enum RootFolderRelocationCreatedDirectoryState diff --git a/listenarr.infrastructure/FileSystem/FileMover.MarkerlessMove.Proofs.cs b/listenarr.infrastructure/FileSystem/FileMover.MarkerlessMove.Proofs.cs new file mode 100644 index 000000000..e19d5965b --- /dev/null +++ b/listenarr.infrastructure/FileSystem/FileMover.MarkerlessMove.Proofs.cs @@ -0,0 +1,170 @@ +using System.Security.Cryptography; + +namespace Listenarr.Infrastructure.FileSystem; + +public partial class FileMover +{ + private static async Task + CaptureMarkerlessSourceProofAsync( + PinnedDirectoryCreation.PinnedFileEntry source, + CancellationToken cancellationToken, + bool includeSha256 = true) + { + var physicalObjectIdentity = source.GetObjectIdentity(); + await using var stream = source.OpenReadStream( + bufferSize: 128 * 1024, + asynchronous: false); + var length = stream.Length; + if (!includeSha256) + { + return new MarkerlessSourceProof( + physicalObjectIdentity, + length, + Sha256: null); + } + + stream.Position = 0; + var hash = await SHA256.HashDataAsync(stream, cancellationToken); + return new MarkerlessSourceProof( + physicalObjectIdentity, + length, + Convert.ToHexString(hash)); + } + + private async Task EnsureMarkerlessSourceHashAsync( + PinnedDirectoryCreation.PinnedFileEntry source, + FileMutationJournal journal, + CancellationToken cancellationToken) + { + if (!string.IsNullOrWhiteSpace(journal.SourceSha256)) + { + return journal; + } + if (!source.VisiblePathMatches() + || !string.Equals( + source.GetObjectIdentity(), + journal.SourcePhysicalObjectIdentity, + StringComparison.Ordinal)) + { + throw new IOException( + "The markerless move source changed before content hashing."); + } + + await using var stream = source.OpenReadStream( + bufferSize: 128 * 1024, + asynchronous: false); + if (stream.Length != journal.SourceLength) + { + throw new IOException( + "The markerless move source length changed before content hashing."); + } + + stream.Position = 0; + var hash = Convert.ToHexString( + await SHA256.HashDataAsync(stream, cancellationToken)); + return await _fileMutationJournalStore!.SetSourceSha256Async( + journal.OperationId, + journal.SourcePhysicalObjectIdentity, + journal.SourceLength, + hash, + cancellationToken); + } + + private static async Task MatchesMarkerlessSourceProofAsync( + PinnedDirectoryCreation.PinnedFileEntry source, + FileMutationJournal journal, + CancellationToken cancellationToken) + { + if (!source.VisiblePathMatches() + || !string.Equals( + source.GetObjectIdentity(), + journal.SourcePhysicalObjectIdentity, + StringComparison.Ordinal)) + { + return false; + } + + return await MatchesMarkerlessContentAsync( + source, + journal.SourceLength, + journal.SourceSha256, + cancellationToken); + } + + private static async Task MatchesMarkerlessTargetContentAsync( + PinnedDirectoryCreation.PinnedFileEntry target, + FileMutationJournal journal, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(journal.SourceSha256) + && !string.Equals( + target.GetObjectIdentity(), + journal.SourcePhysicalObjectIdentity, + StringComparison.Ordinal)) + { + return false; + } + + return await MatchesMarkerlessContentAsync( + target, + journal.SourceLength, + journal.SourceSha256, + cancellationToken); + } + + private static async Task MatchesMarkerlessContentAsync( + PinnedDirectoryCreation.PinnedFileEntry file, + long expectedLength, + string? expectedSha256, + CancellationToken cancellationToken) + { + if (!string.IsNullOrWhiteSpace(expectedSha256)) + { + return await file.MatchesAsync( + expectedLength, + expectedSha256, + cancellationToken); + } + + await using var stream = file.OpenReadStream( + bufferSize: 1, + asynchronous: false); + return stream.Length == expectedLength; + } + + private static bool TargetMatchesMarkerlessJournal( + PinnedDirectoryCreation.PinnedFileEntry target, + FileMutationJournal journal) => + target.VisiblePathMatches() + && !string.IsNullOrWhiteSpace( + journal.TargetPhysicalObjectIdentity) + && string.Equals( + target.GetObjectIdentity(), + journal.TargetPhysicalObjectIdentity, + StringComparison.Ordinal); + + private static async Task CopyMarkerlessFileAsync( + PinnedDirectoryCreation.PinnedFileEntry source, + PinnedDirectoryCreation.PinnedFileEntry target, + CancellationToken cancellationToken) + { + await using var sourceStream = source.OpenReadStream( + bufferSize: 128 * 1024, + asynchronous: false); + await using var targetStream = target.OpenWriteStream( + bufferSize: 128 * 1024, + asynchronous: false); + targetStream.SetLength(0); + await sourceStream.CopyToAsync( + targetStream, + 128 * 1024, + cancellationToken); + await targetStream.FlushAsync(cancellationToken); + targetStream.Flush(flushToDisk: true); + } + + private sealed record MarkerlessSourceProof( + string PhysicalObjectIdentity, + long Length, + string? Sha256); +} diff --git a/listenarr.infrastructure/FileSystem/FileMover.MarkerlessMove.cs b/listenarr.infrastructure/FileSystem/FileMover.MarkerlessMove.cs index 55cc85a24..ee3dc8b4b 100644 --- a/listenarr.infrastructure/FileSystem/FileMover.MarkerlessMove.cs +++ b/listenarr.infrastructure/FileSystem/FileMover.MarkerlessMove.cs @@ -1,4 +1,3 @@ -using System.Security.Cryptography; using Listenarr.Domain.Audiobooks.Enumerations; using Microsoft.Extensions.Logging; @@ -53,7 +52,8 @@ public partial class FileMover var proof = await CaptureMarkerlessSourceProofAsync( initialSource, - cancellationToken); + cancellationToken, + includeSha256: false); journal = await _fileMutationJournalStore.GetOrCreateAsync( new FileMutationJournalClaim( operationId.Value, @@ -113,9 +113,9 @@ await MarkMarkerlessMoveNeedsAttentionAsync( observedTarget.GetObjectIdentity(), journal.SourcePhysicalObjectIdentity, StringComparison.Ordinal) - || !await observedTarget.MatchesAsync( - journal.SourceLength, - journal.SourceSha256, + || !await MatchesMarkerlessTargetContentAsync( + observedTarget, + journal, cancellationToken)) { await MarkMarkerlessMoveNeedsAttentionAsync( @@ -159,9 +159,18 @@ await MarkMarkerlessMoveNeedsAttentionAsync( return false; } + var canUseNativeRename = !DisableNativeFileRenameForTest + && sourceEntry.IsOnSameVolume(pathLock.DestinationParent); + if (!canUseNativeRename) + { + journal = await EnsureMarkerlessSourceHashAsync( + sourceEntry, + journal, + cancellationToken); + } + string targetIdentity; - if (!DisableNativeFileRenameForTest - && sourceEntry.IsOnSameVolume(pathLock.DestinationParent)) + if (canUseNativeRename) { sourceEntry.MoveTo( pathLock.DestinationParent, @@ -229,9 +238,9 @@ await MarkMarkerlessMoveNeedsAttentionAsync( return false; } - if (!await targetEntry.MatchesAsync( - journal.SourceLength, - journal.SourceSha256, + if (!await MatchesMarkerlessTargetContentAsync( + targetEntry, + journal, cancellationToken)) { using var sourceEntry = @@ -257,9 +266,9 @@ await CopyMarkerlessFileAsync( cancellationToken); sourceEntry.PreserveMarkerlessMetadataTo(targetEntry); if (!TargetMatchesMarkerlessJournal(targetEntry, journal) - || !await targetEntry.MatchesAsync( - journal.SourceLength, - journal.SourceSha256, + || !await MatchesMarkerlessTargetContentAsync( + targetEntry, + journal, cancellationToken)) { throw new IOException( @@ -287,9 +296,9 @@ await CopyMarkerlessFileAsync( requireDeleteAccess: false); if (targetEntry == null || !TargetMatchesMarkerlessJournal(targetEntry, journal) - || !await targetEntry.MatchesAsync( - journal.SourceLength, - journal.SourceSha256, + || !await MatchesMarkerlessTargetContentAsync( + targetEntry, + journal, cancellationToken)) { await MarkMarkerlessMoveNeedsAttentionAsync( @@ -379,69 +388,6 @@ await MarkMarkerlessMoveNeedsAttentionAsync( return true; } - private static async Task - CaptureMarkerlessSourceProofAsync( - PinnedDirectoryCreation.PinnedFileEntry source, - CancellationToken cancellationToken) - { - var physicalObjectIdentity = source.GetObjectIdentity(); - await using var stream = source.OpenReadStream( - bufferSize: 128 * 1024, - asynchronous: false); - var length = stream.Length; - stream.Position = 0; - var hash = await SHA256.HashDataAsync(stream, cancellationToken); - return new MarkerlessSourceProof( - physicalObjectIdentity, - length, - Convert.ToHexString(hash)); - } - - private static async Task MatchesMarkerlessSourceProofAsync( - PinnedDirectoryCreation.PinnedFileEntry source, - FileMutationJournal journal, - CancellationToken cancellationToken) => - source.VisiblePathMatches() - && string.Equals( - source.GetObjectIdentity(), - journal.SourcePhysicalObjectIdentity, - StringComparison.Ordinal) - && await source.MatchesAsync( - journal.SourceLength, - journal.SourceSha256, - cancellationToken); - - private static bool TargetMatchesMarkerlessJournal( - PinnedDirectoryCreation.PinnedFileEntry target, - FileMutationJournal journal) => - target.VisiblePathMatches() - && !string.IsNullOrWhiteSpace( - journal.TargetPhysicalObjectIdentity) - && string.Equals( - target.GetObjectIdentity(), - journal.TargetPhysicalObjectIdentity, - StringComparison.Ordinal); - - private static async Task CopyMarkerlessFileAsync( - PinnedDirectoryCreation.PinnedFileEntry source, - PinnedDirectoryCreation.PinnedFileEntry target, - CancellationToken cancellationToken) - { - await using var sourceStream = source.OpenReadStream( - bufferSize: 128 * 1024, - asynchronous: false); - await using var targetStream = target.OpenWriteStream( - bufferSize: 128 * 1024, - asynchronous: false); - targetStream.SetLength(0); - await sourceStream.CopyToAsync( - targetStream, - 128 * 1024, - cancellationToken); - await targetStream.FlushAsync(cancellationToken); - targetStream.Flush(flushToDisk: true); - } - private static void ValidateMarkerlessMoveJournal( FileMutationJournal journal, FileMoveGateLease pathLock) @@ -480,9 +426,4 @@ private async Task MarkMarkerlessMoveNeedsAttentionAsync( journal.OperationId, reason); } - - private sealed record MarkerlessSourceProof( - string PhysicalObjectIdentity, - long Length, - string Sha256); } diff --git a/listenarr.infrastructure/FileSystem/FileMover.MarkerlessRegistration.Target.cs b/listenarr.infrastructure/FileSystem/FileMover.MarkerlessRegistration.Target.cs new file mode 100644 index 000000000..0f67f9cb8 --- /dev/null +++ b/listenarr.infrastructure/FileSystem/FileMover.MarkerlessRegistration.Target.cs @@ -0,0 +1,210 @@ +using System.ComponentModel; +using Listenarr.Domain.Audiobooks.Enumerations; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Infrastructure.FileSystem; + +public partial class FileMover +{ + private async Task PublishMarkerlessRegistrationTargetAsync( + FileAction action, + FileMoveGateLease gate, + FileMutationJournal journal, + CancellationToken cancellationToken) + { + using var sourceEntry = gate.SourceParent.TryOpenExistingFile( + gate.SourceName, + requireDeleteAccess: false); + using var existingTarget = gate.DestinationParent.TryOpenExistingFile( + gate.DestinationName, + requireDeleteAccess: false); + + if (existingTarget != null) + { + if (action == FileAction.HardlinkCopy + && existingTarget.VisiblePathMatches() + && string.Equals( + existingTarget.GetObjectIdentity(), + journal.SourcePhysicalObjectIdentity, + StringComparison.Ordinal) + && await MatchesMarkerlessContentAsync( + existingTarget, + journal.SourceLength, + journal.SourceSha256, + cancellationToken)) + { + return await _fileMutationJournalStore!.AdvanceAsync( + journal.OperationId, + FileMutationJournalState.TargetIdentityPersisted, + existingTarget.GetObjectIdentity(), + audiobookId: null, + error: null, + cancellationToken); + } + + await MarkMarkerlessRegistrationNeedsAttentionAsync( + journal, + "A registration destination appeared before its physical identity was persisted.", + cancellationToken); + return await _fileMutationJournalStore!.GetAsync( + journal.OperationId, + cancellationToken) + ?? throw new InvalidOperationException( + "The markerless registration journal disappeared."); + } + + if (sourceEntry == null + || !await MatchesMarkerlessSourceProofAsync( + sourceEntry, + journal, + cancellationToken)) + { + await MarkMarkerlessRegistrationNeedsAttentionAsync( + journal, + "The registration source changed before destination publication.", + cancellationToken); + return await _fileMutationJournalStore!.GetAsync( + journal.OperationId, + cancellationToken) + ?? throw new InvalidOperationException( + "The markerless registration journal disappeared."); + } + + string targetIdentity; + PinnedDirectoryCreation.PinnedFileEntry? publishedHardlink = null; + if (action == FileAction.HardlinkCopy + && sourceEntry.IsOnSameVolume(gate.DestinationParent)) + { + try + { + publishedHardlink = sourceEntry.CreateHardLinkTo( + gate.DestinationParent, + gate.DestinationName); + targetIdentity = publishedHardlink.GetObjectIdentity(); + return await _fileMutationJournalStore!.AdvanceAsync( + journal.OperationId, + FileMutationJournalState.TargetIdentityPersisted, + targetIdentity, + audiobookId: null, + error: null, + cancellationToken); + } + catch (Exception exception) when (exception is + IOException or Win32Exception or PlatformNotSupportedException) + { + _logger.LogInformation( + exception, + "Markerless hardlink publication was unavailable; falling back to a direct final-name copy: {Source} -> {Destination}", + LogRedaction.SanitizeFilePath(gate.SourcePath), + LogRedaction.SanitizeFilePath(gate.DestinationPath)); + } + finally + { + publishedHardlink?.Dispose(); + } + } + + journal = await EnsureMarkerlessSourceHashAsync( + sourceEntry, + journal, + cancellationToken); + using var created = gate.DestinationParent.CreateNewFile( + gate.DestinationName); + targetIdentity = created.GetObjectIdentity(); + return await _fileMutationJournalStore!.AdvanceAsync( + journal.OperationId, + FileMutationJournalState.TargetIdentityPersisted, + targetIdentity, + audiobookId: null, + error: null, + cancellationToken); + } + + private async Task VerifyMarkerlessRegistrationTargetAsync( + FileMoveGateLease gate, + FileMutationJournal journal, + CancellationToken cancellationToken) + { + using var targetEntry = gate.DestinationParent.TryOpenExistingFile( + gate.DestinationName, + requireDeleteAccess: false); + if (targetEntry == null + || !TargetMatchesMarkerlessJournal(targetEntry, journal)) + { + await MarkMarkerlessRegistrationNeedsAttentionAsync( + journal, + "The registration destination changed before content verification.", + cancellationToken); + return await _fileMutationJournalStore!.GetAsync( + journal.OperationId, + cancellationToken) + ?? throw new InvalidOperationException( + "The markerless registration journal disappeared."); + } + + if (!await MatchesMarkerlessTargetContentAsync( + targetEntry, + journal, + cancellationToken)) + { + using var sourceEntry = gate.SourceParent.TryOpenExistingFile( + gate.SourceName, + requireDeleteAccess: false); + if (sourceEntry == null + || !await MatchesMarkerlessSourceProofAsync( + sourceEntry, + journal, + cancellationToken)) + { + await MarkMarkerlessRegistrationNeedsAttentionAsync( + journal, + "The registration source is unavailable before destination content was verified.", + cancellationToken); + return await _fileMutationJournalStore!.GetAsync( + journal.OperationId, + cancellationToken) + ?? throw new InvalidOperationException( + "The markerless registration journal disappeared."); + } + + await CopyMarkerlessFileAsync( + sourceEntry, + targetEntry, + cancellationToken); + sourceEntry.PreserveMarkerlessMetadataTo(targetEntry); + if (!TargetMatchesMarkerlessJournal(targetEntry, journal) + || !await MatchesMarkerlessTargetContentAsync( + targetEntry, + journal, + cancellationToken)) + { + throw new IOException( + "The markerless registration destination failed content verification."); + } + } + + return await _fileMutationJournalStore!.AdvanceAsync( + journal.OperationId, + FileMutationJournalState.TargetVerified, + journal.TargetPhysicalObjectIdentity, + audiobookId: null, + error: null, + cancellationToken); + } + + private static async Task MarkerlessRegistrationTargetMatchesAsync( + FileMoveGateLease gate, + FileMutationJournal journal, + CancellationToken cancellationToken) + { + using var targetEntry = gate.DestinationParent.TryOpenExistingFile( + gate.DestinationName, + requireDeleteAccess: false); + return targetEntry != null + && TargetMatchesMarkerlessJournal(targetEntry, journal) + && await MatchesMarkerlessTargetContentAsync( + targetEntry, + journal, + cancellationToken); + } +} diff --git a/listenarr.infrastructure/FileSystem/FileMover.MarkerlessRegistration.cs b/listenarr.infrastructure/FileSystem/FileMover.MarkerlessRegistration.cs index b72361fbb..216a94199 100644 --- a/listenarr.infrastructure/FileSystem/FileMover.MarkerlessRegistration.cs +++ b/listenarr.infrastructure/FileSystem/FileMover.MarkerlessRegistration.cs @@ -1,4 +1,3 @@ -using System.ComponentModel; using Listenarr.Domain.Audiobooks.Enumerations; using Microsoft.Extensions.Logging; @@ -57,15 +56,31 @@ private async Task var proof = await CaptureMarkerlessSourceProofAsync( initialSource, - cancellationToken); - if (initialDestination != null - && (!initialDestination.VisiblePathMatches() - || !await initialDestination.MatchesAsync( + cancellationToken, + includeSha256: action != FileAction.HardlinkCopy); + if (initialDestination != null) + { + if (string.IsNullOrWhiteSpace(proof.Sha256) + && !string.Equals( + initialDestination.GetObjectIdentity(), + proof.PhysicalObjectIdentity, + StringComparison.Ordinal)) + { + proof = await CaptureMarkerlessSourceProofAsync( + initialSource, + cancellationToken, + includeSha256: true); + } + + if (!initialDestination.VisiblePathMatches() + || !await MatchesMarkerlessContentAsync( + initialDestination, proof.Length, proof.Sha256, - cancellationToken))) - { - return new MarkerlessRegistrationPreparation(true, null); + cancellationToken)) + { + return new MarkerlessRegistrationPreparation(true, null); + } } journal = await _fileMutationJournalStore.GetOrCreateAsync( @@ -156,9 +171,9 @@ await MarkMarkerlessRegistrationNeedsAttentionAsync( try { if (!TargetMatchesMarkerlessJournal(targetEntry, journal) - || !await targetEntry.MatchesAsync( - journal.SourceLength, - journal.SourceSha256, + || !await MatchesMarkerlessTargetContentAsync( + targetEntry, + journal, cancellationToken)) { targetEntry.Dispose(); @@ -188,203 +203,6 @@ await MarkMarkerlessRegistrationNeedsAttentionAsync( } } - private async Task PublishMarkerlessRegistrationTargetAsync( - FileAction action, - FileMoveGateLease gate, - FileMutationJournal journal, - CancellationToken cancellationToken) - { - using var sourceEntry = gate.SourceParent.TryOpenExistingFile( - gate.SourceName, - requireDeleteAccess: false); - using var existingTarget = gate.DestinationParent.TryOpenExistingFile( - gate.DestinationName, - requireDeleteAccess: false); - - if (existingTarget != null) - { - if (action == FileAction.HardlinkCopy - && existingTarget.VisiblePathMatches() - && string.Equals( - existingTarget.GetObjectIdentity(), - journal.SourcePhysicalObjectIdentity, - StringComparison.Ordinal) - && await existingTarget.MatchesAsync( - journal.SourceLength, - journal.SourceSha256, - cancellationToken)) - { - return await _fileMutationJournalStore!.AdvanceAsync( - journal.OperationId, - FileMutationJournalState.TargetIdentityPersisted, - existingTarget.GetObjectIdentity(), - audiobookId: null, - error: null, - cancellationToken); - } - - await MarkMarkerlessRegistrationNeedsAttentionAsync( - journal, - "A registration destination appeared before its physical identity was persisted.", - cancellationToken); - return await _fileMutationJournalStore!.GetAsync( - journal.OperationId, - cancellationToken) - ?? throw new InvalidOperationException( - "The markerless registration journal disappeared."); - } - - if (sourceEntry == null - || !await MatchesMarkerlessSourceProofAsync( - sourceEntry, - journal, - cancellationToken)) - { - await MarkMarkerlessRegistrationNeedsAttentionAsync( - journal, - "The registration source changed before destination publication.", - cancellationToken); - return await _fileMutationJournalStore!.GetAsync( - journal.OperationId, - cancellationToken) - ?? throw new InvalidOperationException( - "The markerless registration journal disappeared."); - } - - string targetIdentity; - PinnedDirectoryCreation.PinnedFileEntry? publishedHardlink = null; - if (action == FileAction.HardlinkCopy - && sourceEntry.IsOnSameVolume(gate.DestinationParent)) - { - try - { - publishedHardlink = sourceEntry.CreateHardLinkTo( - gate.DestinationParent, - gate.DestinationName); - targetIdentity = publishedHardlink.GetObjectIdentity(); - return await _fileMutationJournalStore!.AdvanceAsync( - journal.OperationId, - FileMutationJournalState.TargetIdentityPersisted, - targetIdentity, - audiobookId: null, - error: null, - cancellationToken); - } - catch (Exception exception) when (exception is - IOException or Win32Exception or PlatformNotSupportedException) - { - _logger.LogInformation( - exception, - "Markerless hardlink publication was unavailable; falling back to a direct final-name copy: {Source} -> {Destination}", - LogRedaction.SanitizeFilePath(gate.SourcePath), - LogRedaction.SanitizeFilePath(gate.DestinationPath)); - } - finally - { - publishedHardlink?.Dispose(); - } - } - - using var created = gate.DestinationParent.CreateNewFile( - gate.DestinationName); - targetIdentity = created.GetObjectIdentity(); - return await _fileMutationJournalStore!.AdvanceAsync( - journal.OperationId, - FileMutationJournalState.TargetIdentityPersisted, - targetIdentity, - audiobookId: null, - error: null, - cancellationToken); - } - - private async Task VerifyMarkerlessRegistrationTargetAsync( - FileMoveGateLease gate, - FileMutationJournal journal, - CancellationToken cancellationToken) - { - using var targetEntry = gate.DestinationParent.TryOpenExistingFile( - gate.DestinationName, - requireDeleteAccess: false); - if (targetEntry == null - || !TargetMatchesMarkerlessJournal(targetEntry, journal)) - { - await MarkMarkerlessRegistrationNeedsAttentionAsync( - journal, - "The registration destination changed before content verification.", - cancellationToken); - return await _fileMutationJournalStore!.GetAsync( - journal.OperationId, - cancellationToken) - ?? throw new InvalidOperationException( - "The markerless registration journal disappeared."); - } - - if (!await targetEntry.MatchesAsync( - journal.SourceLength, - journal.SourceSha256, - cancellationToken)) - { - using var sourceEntry = gate.SourceParent.TryOpenExistingFile( - gate.SourceName, - requireDeleteAccess: false); - if (sourceEntry == null - || !await MatchesMarkerlessSourceProofAsync( - sourceEntry, - journal, - cancellationToken)) - { - await MarkMarkerlessRegistrationNeedsAttentionAsync( - journal, - "The registration source is unavailable before destination content was verified.", - cancellationToken); - return await _fileMutationJournalStore!.GetAsync( - journal.OperationId, - cancellationToken) - ?? throw new InvalidOperationException( - "The markerless registration journal disappeared."); - } - - await CopyMarkerlessFileAsync( - sourceEntry, - targetEntry, - cancellationToken); - sourceEntry.PreserveMarkerlessMetadataTo(targetEntry); - if (!TargetMatchesMarkerlessJournal(targetEntry, journal) - || !await targetEntry.MatchesAsync( - journal.SourceLength, - journal.SourceSha256, - cancellationToken)) - { - throw new IOException( - "The markerless registration destination failed content verification."); - } - } - - return await _fileMutationJournalStore!.AdvanceAsync( - journal.OperationId, - FileMutationJournalState.TargetVerified, - journal.TargetPhysicalObjectIdentity, - audiobookId: null, - error: null, - cancellationToken); - } - - private static async Task MarkerlessRegistrationTargetMatchesAsync( - FileMoveGateLease gate, - FileMutationJournal journal, - CancellationToken cancellationToken) - { - using var targetEntry = gate.DestinationParent.TryOpenExistingFile( - gate.DestinationName, - requireDeleteAccess: false); - return targetEntry != null - && TargetMatchesMarkerlessJournal(targetEntry, journal) - && await targetEntry.MatchesAsync( - journal.SourceLength, - journal.SourceSha256, - cancellationToken); - } - private bool CommitMarkerlessRegistration( Guid operationId, FileAction action, diff --git a/listenarr.infrastructure/FileSystem/FileMutationJournalStore.SourceHash.cs b/listenarr.infrastructure/FileSystem/FileMutationJournalStore.SourceHash.cs new file mode 100644 index 000000000..9bc3c5439 --- /dev/null +++ b/listenarr.infrastructure/FileSystem/FileMutationJournalStore.SourceHash.cs @@ -0,0 +1,128 @@ +using Microsoft.EntityFrameworkCore; + +namespace Listenarr.Infrastructure.FileSystem; + +internal sealed partial class EfFileMutationJournalStore +{ + public async Task SetSourceSha256Async( + Guid operationId, + string expectedSourcePhysicalObjectIdentity, + long expectedSourceLength, + string sourceSha256, + CancellationToken cancellationToken) + { + if (operationId == Guid.Empty) + { + throw new ArgumentException( + "A file-mutation operation ID must not be empty.", + nameof(operationId)); + } + ArgumentException.ThrowIfNullOrWhiteSpace( + expectedSourcePhysicalObjectIdentity); + if (expectedSourceLength < 0) + { + throw new ArgumentOutOfRangeException(nameof(expectedSourceLength)); + } + ValidateSha256(sourceSha256, nameof(sourceSha256)); + + for (var attempt = 0; attempt < 3; attempt++) + { + await using var db = + await dbContextFactory.CreateDbContextAsync(cancellationToken); + var journal = await db.FileMutationJournals + .AsNoTracking() + .SingleOrDefaultAsync( + candidate => candidate.OperationId == operationId, + cancellationToken) + ?? throw new InvalidOperationException( + "The durable file-mutation journal does not exist."); + if (!string.Equals( + journal.SourcePhysicalObjectIdentity, + expectedSourcePhysicalObjectIdentity, + StringComparison.Ordinal) + || journal.SourceLength != expectedSourceLength) + { + throw new InvalidOperationException( + "The file-mutation source generation changed before content hashing."); + } + if (!string.IsNullOrWhiteSpace(journal.SourceSha256)) + { + if (!string.Equals( + journal.SourceSha256, + sourceSha256, + StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "The file-mutation source content changed after hashing."); + } + + return journal; + } + if (journal.State != FileMutationJournalState.Planned) + { + throw new InvalidOperationException( + "A source content hash can only be added before markerless publication."); + } + + var now = timeProvider.GetUtcNow().UtcDateTime; + if (!db.Database.IsRelational()) + { + var tracked = await db.FileMutationJournals.SingleAsync( + candidate => candidate.OperationId == operationId, + cancellationToken); + if (tracked.SourceSha256 == null + && tracked.State == FileMutationJournalState.Planned + && string.Equals( + tracked.SourcePhysicalObjectIdentity, + expectedSourcePhysicalObjectIdentity, + StringComparison.Ordinal) + && tracked.SourceLength == expectedSourceLength) + { + tracked.SourceSha256 = sourceSha256; + tracked.UpdatedAt = now; + await db.SaveChangesAsync(cancellationToken); + return tracked; + } + + continue; + } + + var affected = await db.FileMutationJournals + .Where(candidate => candidate.OperationId == operationId + && candidate.State == FileMutationJournalState.Planned + && candidate.SourceSha256 == null + && candidate.SourcePhysicalObjectIdentity + == expectedSourcePhysicalObjectIdentity + && candidate.SourceLength == expectedSourceLength) + .ExecuteUpdateAsync( + setters => setters + .SetProperty( + candidate => candidate.SourceSha256, + sourceSha256) + .SetProperty( + candidate => candidate.UpdatedAt, + now), + cancellationToken); + if (affected == 1) + { + journal.SourceSha256 = sourceSha256; + journal.UpdatedAt = now; + return journal; + } + } + + throw new InvalidOperationException( + "The file-mutation source hash changed concurrently too many times."); + } + + private static void ValidateSha256(string value, string parameterName) + { + if (value.Length != 64 + || value.Any(character => !Uri.IsHexDigit(character))) + { + throw new ArgumentException( + "A file-mutation SHA-256 proof must contain 64 hexadecimal characters.", + parameterName); + } + } +} diff --git a/listenarr.infrastructure/FileSystem/FileMutationJournalStore.cs b/listenarr.infrastructure/FileSystem/FileMutationJournalStore.cs index 21937c9e6..a02fa00e9 100644 --- a/listenarr.infrastructure/FileSystem/FileMutationJournalStore.cs +++ b/listenarr.infrastructure/FileSystem/FileMutationJournalStore.cs @@ -23,6 +23,13 @@ Task GetOrCreateAsync( Guid operationId, CancellationToken cancellationToken); + Task SetSourceSha256Async( + Guid operationId, + string expectedSourcePhysicalObjectIdentity, + long expectedSourceLength, + string sourceSha256, + CancellationToken cancellationToken); + FileMutationJournal? Get(Guid operationId); Task AdvanceAsync( @@ -41,7 +48,7 @@ FileMutationJournal Advance( string? error); } -internal sealed class EfFileMutationJournalStore( +internal sealed partial class EfFileMutationJournalStore( IDbContextFactory dbContextFactory, TimeProvider timeProvider) : IFileMutationJournalStore { @@ -409,12 +416,9 @@ private static void ValidateClaim(FileMutationJournalClaim claim) { throw new ArgumentOutOfRangeException(nameof(claim)); } - if (claim.SourceSha256 is { Length: > 0 } - && claim.SourceSha256.Length != 64) + if (claim.SourceSha256 is { Length: > 0 }) { - throw new ArgumentException( - "A file-mutation SHA-256 proof must contain 64 hexadecimal characters.", - nameof(claim)); + ValidateSha256(claim.SourceSha256, nameof(claim)); } } diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.DirectoryOwnership.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.DirectoryOwnership.cs index fa7d17fdd..3c8bba10c 100644 --- a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.DirectoryOwnership.cs +++ b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.DirectoryOwnership.cs @@ -14,6 +14,17 @@ private async Task WithValidatedTargetDirectoryOwne return request; } + if (await GetExecutionProtocolVersionAsync( + request.JobId, + cancellationToken) + >= MoveExecutionProtocol.MarkerlessDatabaseState) + { + await TryRetireReplacedMarkerlessTargetOwnershipAsync( + request, + request.Target, + cancellationToken); + } + var ownership = await LoadValidatedTargetDirectoryOwnershipAsync( request.Target, request.TargetSemantics, @@ -21,6 +32,44 @@ private async Task WithValidatedTargetDirectoryOwne return request with { TargetDirectoryOwnership = ownership }; } + private async Task TryRetireReplacedMarkerlessTargetOwnershipAsync( + AudiobookContentMoveRequest request, + string target, + CancellationToken cancellationToken) + { + if (!Directory.Exists(target)) + { + return; + } + + var endpoints = await GetEndpointObjectIdentitiesAsync( + request.JobId, + cancellationToken); + if (string.IsNullOrWhiteSpace(endpoints.TargetDirectoryObjectIdentity)) + { + return; + } + + try + { + _ = await directoryOwnershipStore + .TryRetireReplacedByMarkerlessMoveAsync( + target, + request.TargetSemantics, + request.JobId, + endpoints.TargetDirectoryObjectIdentity, + cancellationToken); + } + catch (Exception exception) when (exception is + ArgumentException or IOException or UnauthorizedAccessException + or InvalidOperationException or NotSupportedException + or PathTooLongException or System.ComponentModel.Win32Exception) + { + throw new MoveNeedsAttentionException( + $"The markerless target ownership replacement could not be reconciled safely: {exception.Message}"); + } + } + private async Task LoadValidatedTargetDirectoryOwnershipAsync( string target, FileSystemPathSemantics targetSemantics, @@ -213,6 +262,79 @@ private void TryDeleteRetiredOwnershipMarker( LogRedaction.SanitizeText(reason)); } + private async Task + ResolveMarkerlessSourceDirectoryOwnershipAsync( + string path, + FileSystemPathSemantics semantics, + CancellationToken cancellationToken) + { + var resolution = await directoryOwnershipStore.ResolveOwnedAsync( + path, + semantics, + cancellationToken); + if (resolution.State == LibraryDirectoryOwnershipResolutionState.Unowned) + { + return null; + } + if (resolution.State != LibraryDirectoryOwnershipResolutionState.Owned + || resolution.Ownership == null) + { + throw new MoveNeedsAttentionException( + resolution.Reason + ?? "Durable source-directory ownership is conflicting or unavailable."); + } + + return resolution.Ownership; + } + + private async Task RemoveMarkerlessOwnedDirectoryAsync( + AudiobookContentMoveRequest request, + string source, + string target, + LibraryDirectoryOwnership ownership, + CancellationToken cancellationToken) + { + var ownershipKey = ownership.PathOwnershipKey + ?? throw new MoveNeedsAttentionException( + "The markerless source-directory ownership key is unavailable."); + if (ownership.State != LibraryDirectoryOwnershipState.Removing) + { + await directoryOwnershipStore.BeginRemovalAsync( + ownership.Id, + ownershipKey, + cancellationToken); + ownership.State = LibraryDirectoryOwnershipState.Removing; + } + + return await ResumeOwnedDirectoryRemovalAsync( + request, + source, + target, + ownership, + cancellationToken); + } + + private async Task RetainMarkerlessOwnedDirectoryIfRemovingAsync( + LibraryDirectoryOwnership? ownership, + string reason, + CancellationToken cancellationToken) + { + if (ownership?.State != LibraryDirectoryOwnershipState.Removing) + { + return; + } + + var ownershipKey = ownership.PathOwnershipKey + ?? throw new MoveNeedsAttentionException( + "The markerless source-directory ownership key is unavailable while retaining the directory."); + await directoryOwnershipStore.RetainAsync( + ownership.Id, + ownershipKey, + reason, + cancellationToken); + ownership.State = LibraryDirectoryOwnershipState.Retained; + } + private async Task ResumeOwnedDirectoryRemovalAsync( AudiobookContentMoveRequest request, string source, diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.FaultInjection.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.FaultInjection.cs index 4fc252b79..29b3fc8e7 100644 --- a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.FaultInjection.cs +++ b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.FaultInjection.cs @@ -53,6 +53,7 @@ internal enum CopyMutationFaultPoint AfterMarkerlessFileCreationBeforeStateUpdate, AfterMarkerlessFileStateUpdate, AfterMarkerlessFileWriteBeforePublishedState, + BeforeMarkerlessMetadataPreservation, AfterMarkerlessNativeRenameBeforeStateUpdate } diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.Markerless.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.Markerless.cs index f017e78c0..0f4bb3627 100644 --- a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.Markerless.cs +++ b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.Markerless.cs @@ -26,6 +26,13 @@ private async Task MoveContentsMarkerlessAsync( "The markerless move has no persisted tracked-file source manifest."); } + if (Directory.Exists(target)) + { + await TryRetireReplacedMarkerlessTargetOwnershipAsync( + request, + target, + cancellationToken); + } var targetOwnership = Directory.Exists(target) ? await LoadValidatedTargetDirectoryOwnershipAsync( target, @@ -114,6 +121,10 @@ await CaptureOrValidateMarkerlessTargetRootAsync( request, target, cancellationToken); + await TryRetireReplacedMarkerlessTargetOwnershipAsync( + request, + target, + cancellationToken); ValidateExistingDestinationContents( source, diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.MarkerlessCleanup.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.MarkerlessCleanup.cs index 241631117..2bd9d6950 100644 --- a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.MarkerlessCleanup.cs +++ b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.MarkerlessCleanup.cs @@ -202,217 +202,6 @@ await UpdateCleanupStateAsync( SourceCleanupFaultPoint.AfterMarkerlessSourceFileStateUpdate); } - private async Task DeleteMarkerlessSourceDirectoryAsync( - AudiobookContentMoveRequest request, - string source, - string target, - bool targetInsideSource, - MoveJobEntry entry, - CancellationToken cancellationToken) - { - var sourcePath = ResolveManifestPath( - source, - entry, - request.SourceSemantics, - "source"); - if (File.Exists(sourcePath)) - { - throw new MoveNeedsAttentionException( - $"A source directory changed into a file: {entry.RelativePath}"); - } - if (!Directory.Exists(sourcePath)) - { - if (entry.CleanupState == MoveJobEntryCleanupState.DeletionAuthorized) - { - await UpdateCleanupStateAsync( - request.JobId, - request.LeaseToken, - entry.RelativePath, - MoveJobEntryCleanupState.Deleted, - cancellationToken); - entry.CleanupState = MoveJobEntryCleanupState.Deleted; - return; - } - if (entry.CleanupState == MoveJobEntryCleanupState.Deleted) - { - return; - } - throw new MoveNeedsAttentionException( - $"A source directory disappeared before markerless deletion was authorized: {entry.RelativePath}"); - } - if (entry.CleanupState == MoveJobEntryCleanupState.Deleted) - { - throw new MoveNeedsAttentionException( - $"A deleted source directory path was recreated: {entry.RelativePath}"); - } - - if (targetInsideSource - && (IsSameOrInside(target, sourcePath, request.SourceSemantics) - || IsSameOrInside(sourcePath, target, request.SourceSemantics))) - { - await RetainMarkerlessSourceEntryAsync(request, entry, cancellationToken); - return; - } - if (Directory.EnumerateFileSystemEntries(sourcePath).Any()) - { - await RetainMarkerlessSourceEntryAsync(request, entry, cancellationToken); - return; - } - - var parentPath = Path.GetDirectoryName(sourcePath) - ?? throw new MoveNeedsAttentionException( - "A markerless source directory has no parent."); - using var parent = PinnedDirectoryCreation.OpenPinnedBoundary(parentPath); - using var publication = parent.OpenExistingChildForPublication( - Path.GetFileName(sourcePath)); - using var directory = publication.OpenCreatedDirectoryAnchor(); - ValidateMarkerlessSourceDirectory(entry, directory); - if (entry.CleanupState == MoveJobEntryCleanupState.Pending) - { - await UpdateCleanupStateAsync( - request.JobId, - request.LeaseToken, - entry.RelativePath, - MoveJobEntryCleanupState.DeletionAuthorized, - cancellationToken); - entry.CleanupState = MoveJobEntryCleanupState.DeletionAuthorized; - } - await EnsureMutationAuthorizedAsync( - request, - source, - target, - cancellationToken); - ValidateMarkerlessSourceDirectory(entry, directory); - if (Directory.EnumerateFileSystemEntries(sourcePath).Any()) - { - await RetainMarkerlessSourceEntryAsync(request, entry, cancellationToken); - return; - } - publication.RetirePinnedEmptyDirectoryFromNamespace( - Path.GetFileName(sourcePath)); - await UpdateCleanupStateAsync( - request.JobId, - request.LeaseToken, - entry.RelativePath, - MoveJobEntryCleanupState.Deleted, - cancellationToken); - entry.CleanupState = MoveJobEntryCleanupState.Deleted; - } - - private async Task DeleteMarkerlessSourceRootAsync( - AudiobookContentMoveRequest request, - string source, - string target, - bool targetInsideSource, - CancellationToken cancellationToken) - { - var endpoints = await GetEndpointObjectIdentitiesAsync( - request.JobId, - cancellationToken); - if (!request.DeleteEmptySource - || targetInsideSource - || IsSourceCleanupBoundary( - source, - request.SourceCleanupBoundary, - request.SourceSemantics)) - { - if (endpoints.SourceDirectoryCleanupState - == MoveJobEntryCleanupState.Pending) - { - await UpdateSourceDirectoryCleanupStateAsync( - request.JobId, - request.LeaseToken, - MoveJobEntryCleanupState.Retained, - cancellationToken); - } - return; - } - - if (!Directory.Exists(source)) - { - if (endpoints.SourceDirectoryCleanupState - == MoveJobEntryCleanupState.DeletionAuthorized) - { - await UpdateSourceDirectoryCleanupStateAsync( - request.JobId, - request.LeaseToken, - MoveJobEntryCleanupState.Deleted, - cancellationToken); - return; - } - if (endpoints.SourceDirectoryCleanupState - == MoveJobEntryCleanupState.Deleted) - { - return; - } - throw new MoveNeedsAttentionException( - "The source directory disappeared before markerless deletion was authorized."); - } - if (endpoints.SourceDirectoryCleanupState - == MoveJobEntryCleanupState.Deleted) - { - throw new MoveNeedsAttentionException( - "The deleted source directory path was recreated."); - } - if (Directory.EnumerateFileSystemEntries(source).Any()) - { - await UpdateSourceDirectoryCleanupStateAsync( - request.JobId, - request.LeaseToken, - MoveJobEntryCleanupState.Retained, - cancellationToken); - return; - } - - var parentPath = Path.GetDirectoryName(source) - ?? throw new MoveNeedsAttentionException( - "The markerless source directory has no parent."); - using var parent = PinnedDirectoryCreation.OpenPinnedBoundary(parentPath); - using var publication = parent.OpenExistingChildForPublication( - Path.GetFileName(source)); - using var directory = publication.OpenCreatedDirectoryAnchor(); - if (string.IsNullOrWhiteSpace(endpoints.SourceDirectoryObjectIdentity) - || !string.Equals( - endpoints.SourceDirectoryObjectIdentity, - directory.GetDirectoryObjectIdentity(), - StringComparison.Ordinal) - || !directory.VisiblePathMatches()) - { - throw new MoveNeedsAttentionException( - "The markerless source root changed physical generation before deletion."); - } - if (endpoints.SourceDirectoryCleanupState - == MoveJobEntryCleanupState.Pending) - { - await UpdateSourceDirectoryCleanupStateAsync( - request.JobId, - request.LeaseToken, - MoveJobEntryCleanupState.DeletionAuthorized, - cancellationToken); - } - await EnsureMutationAuthorizedAsync( - request, - source, - target, - cancellationToken); - if (Directory.EnumerateFileSystemEntries(source).Any() - || !directory.VisiblePathMatches()) - { - await UpdateSourceDirectoryCleanupStateAsync( - request.JobId, - request.LeaseToken, - MoveJobEntryCleanupState.Retained, - cancellationToken); - return; - } - publication.RetirePinnedEmptyDirectoryFromNamespace(Path.GetFileName(source)); - await UpdateSourceDirectoryCleanupStateAsync( - request.JobId, - request.LeaseToken, - MoveJobEntryCleanupState.Deleted, - cancellationToken); - } - private async Task RetainMarkerlessSourceEntryAsync( AudiobookContentMoveRequest request, MoveJobEntry entry, diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.MarkerlessCopy.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.MarkerlessCopy.cs index 8a24d14e4..a950af4c5 100644 --- a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.MarkerlessCopy.cs +++ b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.MarkerlessCopy.cs @@ -90,9 +90,7 @@ await ReportProgressAsync( await HandleExistingMarkerlessTargetAsync( request, entry, - sourcePath, sourceEntry, - targetPath, existingTarget, completedWorkUnits, totalWorkUnits, @@ -193,9 +191,7 @@ await ReportProgressAsync( await HandleExistingMarkerlessTargetAsync( request, entry, - sourcePath, sourceEntry, - targetPath, existingTarget, completedWorkUnits, totalWorkUnits, @@ -259,9 +255,7 @@ await UpdateTargetEntryStateAsync( await WriteMarkerlessTargetAsync( request, entry, - sourcePath, sourceEntry, - targetPath, created, completedWorkUnits, totalWorkUnits, @@ -282,9 +276,7 @@ await ReportProgressAsync( private async Task HandleExistingMarkerlessTargetAsync( AudiobookContentMoveRequest request, MoveJobEntry entry, - string sourcePath, PinnedDirectoryCreation.PinnedFileEntry sourceEntry, - string targetPath, PinnedDirectoryCreation.PinnedFileEntry targetEntry, long completedUnitsBeforeFile, long totalUnits, @@ -339,9 +331,7 @@ await UpdateTargetEntryStateAsync( await WriteMarkerlessTargetAsync( request, entry, - sourcePath, sourceEntry, - targetPath, targetEntry, completedUnitsBeforeFile, totalUnits, @@ -351,9 +341,7 @@ await WriteMarkerlessTargetAsync( private async Task WriteMarkerlessTargetAsync( AudiobookContentMoveRequest request, MoveJobEntry entry, - string sourcePath, PinnedDirectoryCreation.PinnedFileEntry sourceEntry, - string targetPath, PinnedDirectoryCreation.PinnedFileEntry targetEntry, long completedWorkUnitsBeforeFile, long totalWorkUnits, @@ -423,11 +411,29 @@ await ReportProgressAsync( // The independently opened write stream is identity-verified against the // pinned entry and Flush(true) is the durability barrier. The observation // handle may be read-only during recovery and must not be flushed again. - PreserveMarkerlessFileMetadata(sourcePath, targetPath); faultInjector?.OnCopyMutation( request.JobId, CopyMutationFaultPoint .AfterMarkerlessFileWriteBeforePublishedState); + try + { + faultInjector?.OnCopyMutation( + request.JobId, + CopyMutationFaultPoint.BeforeMarkerlessMetadataPreservation); + sourceEntry.PreserveMarkerlessMetadataTo(targetEntry); + } + catch (Exception exception) when ( + WorkerExceptionClassifier.IsNonFatal(exception)) + { + // Metadata preservation is best-effort, but only while the pinned file + // still owns the visible destination pathname. A replacement race must + // remain a hard failure instead of being treated as a metadata warning. + ValidateMarkerlessTargetEntry(entry, targetEntry); + logger.LogDebug( + exception, + "Non-fatal: failed to preserve markerless file metadata for {File}", + LogRedaction.SanitizeFilePath(targetEntry.FullPath)); + } await UpdateTargetEntryStateAsync( request.JobId, request.LeaseToken, @@ -457,27 +463,6 @@ await UpdateTargetEntryStateAsync( entry.CopyState = MoveJobEntryCopyState.Verified; } - private void PreserveMarkerlessFileMetadata( - string sourceFile, - string destinationFile) - { - try - { - File.SetAttributes(destinationFile, File.GetAttributes(sourceFile)); - File.SetLastWriteTimeUtc( - destinationFile, - File.GetLastWriteTimeUtc(sourceFile)); - } - catch (Exception exception) when ( - WorkerExceptionClassifier.IsNonFatal(exception)) - { - logger.LogDebug( - exception, - "Non-fatal: failed to preserve markerless file metadata for {File}", - LogRedaction.SanitizeFilePath(sourceFile)); - } - } - private static void TryRetireUncommittedMarkerlessFile( PinnedDirectoryCreation.PinnedFileEntry file) { diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.MarkerlessDirectoryCleanup.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.MarkerlessDirectoryCleanup.cs new file mode 100644 index 000000000..b36a4259f --- /dev/null +++ b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.MarkerlessDirectoryCleanup.cs @@ -0,0 +1,332 @@ +namespace Listenarr.Infrastructure.Library.Moving; + +internal sealed partial class AudiobookContentMoveService +{ + private async Task DeleteMarkerlessSourceDirectoryAsync( + AudiobookContentMoveRequest request, + string source, + string target, + bool targetInsideSource, + MoveJobEntry entry, + CancellationToken cancellationToken) + { + var sourcePath = ResolveManifestPath( + source, + entry, + request.SourceSemantics, + "source"); + if (File.Exists(sourcePath)) + { + throw new MoveNeedsAttentionException( + $"A source directory changed into a file: {entry.RelativePath}"); + } + + var ownership = await ResolveMarkerlessSourceDirectoryOwnershipAsync( + sourcePath, + request.SourceSemantics, + cancellationToken); + if (!Directory.Exists(sourcePath)) + { + if (entry.CleanupState is + MoveJobEntryCleanupState.DeletionAuthorized + or MoveJobEntryCleanupState.Deleted) + { + if (ownership != null) + { + if (ownership.State != LibraryDirectoryOwnershipState.Removing) + { + throw new MoveNeedsAttentionException( + $"A durably owned source directory disappeared before its ownership removal was authorized: {entry.RelativePath}"); + } + + _ = await RemoveMarkerlessOwnedDirectoryAsync( + request, + source, + target, + ownership, + cancellationToken); + } + + if (entry.CleanupState != MoveJobEntryCleanupState.Deleted) + { + await UpdateCleanupStateAsync( + request.JobId, + request.LeaseToken, + entry.RelativePath, + MoveJobEntryCleanupState.Deleted, + cancellationToken); + entry.CleanupState = MoveJobEntryCleanupState.Deleted; + } + return; + } + + throw new MoveNeedsAttentionException( + $"A source directory disappeared before markerless deletion was authorized: {entry.RelativePath}"); + } + if (entry.CleanupState == MoveJobEntryCleanupState.Deleted) + { + throw new MoveNeedsAttentionException( + $"A deleted source directory path was recreated: {entry.RelativePath}"); + } + + if (targetInsideSource + && (IsSameOrInside(target, sourcePath, request.SourceSemantics) + || IsSameOrInside(sourcePath, target, request.SourceSemantics))) + { + await RetainMarkerlessOwnedDirectoryIfRemovingAsync( + ownership, + "The source directory overlaps the retained markerless target.", + cancellationToken); + await RetainMarkerlessSourceEntryAsync(request, entry, cancellationToken); + return; + } + if (Directory.EnumerateFileSystemEntries(sourcePath).Any()) + { + await RetainMarkerlessOwnedDirectoryIfRemovingAsync( + ownership, + "The source directory gained content before markerless deletion.", + cancellationToken); + await RetainMarkerlessSourceEntryAsync(request, entry, cancellationToken); + return; + } + + var parentPath = Path.GetDirectoryName(sourcePath) + ?? throw new MoveNeedsAttentionException( + "A markerless source directory has no parent."); + using (var parent = PinnedDirectoryCreation.OpenPinnedBoundary(parentPath)) + using (var publication = parent.OpenExistingChildForPublication( + Path.GetFileName(sourcePath))) + using (var directory = publication.OpenCreatedDirectoryAnchor()) + { + ValidateMarkerlessSourceDirectory(entry, directory); + if (entry.CleanupState == MoveJobEntryCleanupState.Pending) + { + await UpdateCleanupStateAsync( + request.JobId, + request.LeaseToken, + entry.RelativePath, + MoveJobEntryCleanupState.DeletionAuthorized, + cancellationToken); + entry.CleanupState = MoveJobEntryCleanupState.DeletionAuthorized; + } + + if (ownership == null) + { + await EnsureMutationAuthorizedAsync( + request, + source, + target, + cancellationToken); + ValidateMarkerlessSourceDirectory(entry, directory); + if (Directory.EnumerateFileSystemEntries(sourcePath).Any()) + { + await RetainMarkerlessSourceEntryAsync( + request, + entry, + cancellationToken); + return; + } + + publication.RetirePinnedEmptyDirectoryFromNamespace( + Path.GetFileName(sourcePath)); + } + } + + if (ownership != null) + { + var removed = await RemoveMarkerlessOwnedDirectoryAsync( + request, + source, + target, + ownership, + cancellationToken); + if (!removed) + { + await RetainMarkerlessSourceEntryAsync( + request, + entry, + cancellationToken); + return; + } + } + + await UpdateCleanupStateAsync( + request.JobId, + request.LeaseToken, + entry.RelativePath, + MoveJobEntryCleanupState.Deleted, + cancellationToken); + entry.CleanupState = MoveJobEntryCleanupState.Deleted; + } + + private async Task DeleteMarkerlessSourceRootAsync( + AudiobookContentMoveRequest request, + string source, + string target, + bool targetInsideSource, + CancellationToken cancellationToken) + { + var endpoints = await GetEndpointObjectIdentitiesAsync( + request.JobId, + cancellationToken); + var ownership = await ResolveMarkerlessSourceDirectoryOwnershipAsync( + source, + request.SourceSemantics, + cancellationToken); + if (!request.DeleteEmptySource + || targetInsideSource + || IsSourceCleanupBoundary( + source, + request.SourceCleanupBoundary, + request.SourceSemantics)) + { + await RetainMarkerlessOwnedDirectoryIfRemovingAsync( + ownership, + "The source root is retained by the markerless move cleanup policy.", + cancellationToken); + if (endpoints.SourceDirectoryCleanupState + == MoveJobEntryCleanupState.Pending) + { + await UpdateSourceDirectoryCleanupStateAsync( + request.JobId, + request.LeaseToken, + MoveJobEntryCleanupState.Retained, + cancellationToken); + } + return; + } + + if (!Directory.Exists(source)) + { + if (endpoints.SourceDirectoryCleanupState is + MoveJobEntryCleanupState.DeletionAuthorized + or MoveJobEntryCleanupState.Deleted) + { + if (ownership != null) + { + if (ownership.State != LibraryDirectoryOwnershipState.Removing) + { + throw new MoveNeedsAttentionException( + "A durably owned source root disappeared before its ownership removal was authorized."); + } + + _ = await RemoveMarkerlessOwnedDirectoryAsync( + request, + source, + target, + ownership, + cancellationToken); + } + + if (endpoints.SourceDirectoryCleanupState + != MoveJobEntryCleanupState.Deleted) + { + await UpdateSourceDirectoryCleanupStateAsync( + request.JobId, + request.LeaseToken, + MoveJobEntryCleanupState.Deleted, + cancellationToken); + } + return; + } + + throw new MoveNeedsAttentionException( + "The source directory disappeared before markerless deletion was authorized."); + } + if (endpoints.SourceDirectoryCleanupState + == MoveJobEntryCleanupState.Deleted) + { + throw new MoveNeedsAttentionException( + "The deleted source directory path was recreated."); + } + if (Directory.EnumerateFileSystemEntries(source).Any()) + { + await RetainMarkerlessOwnedDirectoryIfRemovingAsync( + ownership, + "The source root gained content before markerless deletion.", + cancellationToken); + await UpdateSourceDirectoryCleanupStateAsync( + request.JobId, + request.LeaseToken, + MoveJobEntryCleanupState.Retained, + cancellationToken); + return; + } + + var parentPath = Path.GetDirectoryName(source) + ?? throw new MoveNeedsAttentionException( + "The markerless source directory has no parent."); + using (var parent = PinnedDirectoryCreation.OpenPinnedBoundary(parentPath)) + using (var publication = parent.OpenExistingChildForPublication( + Path.GetFileName(source))) + using (var directory = publication.OpenCreatedDirectoryAnchor()) + { + if (string.IsNullOrWhiteSpace(endpoints.SourceDirectoryObjectIdentity) + || !string.Equals( + endpoints.SourceDirectoryObjectIdentity, + directory.GetDirectoryObjectIdentity(), + StringComparison.Ordinal) + || !directory.VisiblePathMatches()) + { + throw new MoveNeedsAttentionException( + "The markerless source root changed physical generation before deletion."); + } + if (endpoints.SourceDirectoryCleanupState + == MoveJobEntryCleanupState.Pending) + { + await UpdateSourceDirectoryCleanupStateAsync( + request.JobId, + request.LeaseToken, + MoveJobEntryCleanupState.DeletionAuthorized, + cancellationToken); + } + + if (ownership == null) + { + await EnsureMutationAuthorizedAsync( + request, + source, + target, + cancellationToken); + if (Directory.EnumerateFileSystemEntries(source).Any() + || !directory.VisiblePathMatches()) + { + await UpdateSourceDirectoryCleanupStateAsync( + request.JobId, + request.LeaseToken, + MoveJobEntryCleanupState.Retained, + cancellationToken); + return; + } + + publication.RetirePinnedEmptyDirectoryFromNamespace( + Path.GetFileName(source)); + } + } + + if (ownership != null) + { + var removed = await RemoveMarkerlessOwnedDirectoryAsync( + request, + source, + target, + ownership, + cancellationToken); + if (!removed) + { + await UpdateSourceDirectoryCleanupStateAsync( + request.JobId, + request.LeaseToken, + MoveJobEntryCleanupState.Retained, + cancellationToken); + return; + } + } + + await UpdateSourceDirectoryCleanupStateAsync( + request.JobId, + request.LeaseToken, + MoveJobEntryCleanupState.Deleted, + cancellationToken); + } +} diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.MarkerlessEntryPreflight.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.MarkerlessEntryPreflight.cs new file mode 100644 index 000000000..eef44f979 --- /dev/null +++ b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.MarkerlessEntryPreflight.cs @@ -0,0 +1,28 @@ +namespace Listenarr.Infrastructure.Library.Moving; + +internal sealed partial class AudiobookContentMoveService +{ + private async Task ValidateMoveSourceRootForExecutionAsync( + Guid jobId, + string source, + int executionProtocolVersion, + CancellationToken cancellationToken) + { + if (executionProtocolVersion >= MoveExecutionProtocol.MarkerlessDatabaseState + && !Directory.Exists(source) + && !File.Exists(source)) + { + var endpoints = await GetEndpointObjectIdentitiesAsync( + jobId, + cancellationToken); + if (endpoints.SourceDirectoryCleanupState is + MoveJobEntryCleanupState.DeletionAuthorized + or MoveJobEntryCleanupState.Deleted) + { + return; + } + } + + ValidateMoveSourceRoot(source); + } +} diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.OwnershipMarkers.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.OwnershipMarkers.cs index 0be4d9289..b684ccd63 100644 --- a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.OwnershipMarkers.cs +++ b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.OwnershipMarkers.cs @@ -152,15 +152,6 @@ await RetireCorruptOwnershipWriteAsync( } var validWritePath = validWrites[0].Path; - if (OperatingSystem.IsWindows()) - { - await authorizeMutation(); - ValidateOwnershipMarkerWritePath(validWritePath, markerDirectory); - File.SetAttributes( - validWritePath, - File.GetAttributes(validWritePath) | FileAttributes.Hidden); - } - return await PublishRecoveredOwnershipWriteAsync( markerPath, validWritePath, diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.TargetScaffoldingCleanup.Markerless.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.TargetScaffoldingCleanup.Markerless.cs new file mode 100644 index 000000000..6a5254b46 --- /dev/null +++ b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.TargetScaffoldingCleanup.Markerless.cs @@ -0,0 +1,141 @@ +namespace Listenarr.Infrastructure.Library.Moving; + +internal sealed partial class AudiobookContentMoveService +{ + private async Task CleanupTerminalMarkerlessTargetDirectoriesAsync( + AudiobookContentMoveRequest request, + CancellationToken cancellationToken) + { + var directories = (await GetCreatedDirectoriesAsync( + request.JobId, + cancellationToken)) + .OrderByDescending(directory => GetPathDepth(directory.Path)) + .ToList(); + foreach (var planned in directories) + { + cancellationToken.ThrowIfCancellationRequested(); + ValidateMarkerlessTargetDirectoryLedgerPath( + planned.Path, + request.Target, + request.TargetSemantics); + if (planned.State is MoveCreatedDirectoryState.Removed + or MoveCreatedDirectoryState.Retained) + { + continue; + } + if (File.Exists(planned.Path)) + { + throw new MoveNeedsAttentionException( + $"A markerless move-created directory path is occupied by a file: {planned.Path}"); + } + if (!Directory.Exists(planned.Path)) + { + await UpdateCreatedDirectoryStateAsync( + request.JobId, + request.LeaseToken, + planned.Path, + MoveCreatedDirectoryState.Removed, + cancellationToken); + planned.State = MoveCreatedDirectoryState.Removed; + continue; + } + + var parentPath = Path.GetDirectoryName(planned.Path) + ?? throw new MoveNeedsAttentionException( + "A markerless move-created directory has no parent."); + if (planned.State == MoveCreatedDirectoryState.Planned + && string.IsNullOrWhiteSpace(planned.DirectoryObjectIdentity)) + { + using var parent = PinnedDirectoryCreation.OpenPinnedBoundary(parentPath); + using var directory = parent.OpenExistingChild( + Path.GetFileName(planned.Path)); + if (!directory.VisiblePathMatches() + || !parent.VisiblePathMatches()) + { + throw new MoveNeedsAttentionException( + "An unproven markerless target directory changed while it was being retained."); + } + + await UpdateCreatedDirectoryPublicationAsync( + request.JobId, + request.LeaseToken, + planned.Path, + MoveCreatedDirectoryState.Retained, + directory.GetDirectoryObjectIdentity(), + cancellationToken); + planned.State = MoveCreatedDirectoryState.Retained; + continue; + } + if (planned.State != MoveCreatedDirectoryState.Created + || string.IsNullOrWhiteSpace(planned.DirectoryObjectIdentity)) + { + throw new MoveNeedsAttentionException( + $"A markerless move-created directory has inconsistent durable state: {planned.Path}"); + } + + using var publication = PinnedDirectoryCreation.OpenExistingForPublication( + parentPath, + Path.GetFileName(planned.Path)); + using var parentAnchor = publication.OpenParentDirectoryAnchor(); + using var directoryAnchor = publication.OpenCreatedDirectoryAnchor(); + if (!string.Equals( + directoryAnchor.GetDirectoryObjectIdentity(), + planned.DirectoryObjectIdentity, + StringComparison.Ordinal) + || !directoryAnchor.VisiblePathMatches() + || !parentAnchor.VisiblePathMatches()) + { + throw new MoveNeedsAttentionException( + $"A markerless move-created directory changed physical generation before terminal cleanup: {planned.Path}"); + } + if (Directory.EnumerateFileSystemEntries(planned.Path).Any()) + { + await UpdateCreatedDirectoryStateAsync( + request.JobId, + request.LeaseToken, + planned.Path, + MoveCreatedDirectoryState.Retained, + cancellationToken); + planned.State = MoveCreatedDirectoryState.Retained; + continue; + } + + await EnsureMutationAuthorizedAsync( + request, + request.Source, + request.Target, + cancellationToken); + if (!string.Equals( + directoryAnchor.GetDirectoryObjectIdentity(), + planned.DirectoryObjectIdentity, + StringComparison.Ordinal) + || !directoryAnchor.VisiblePathMatches() + || !parentAnchor.VisiblePathMatches()) + { + throw new MoveNeedsAttentionException( + $"A markerless move-created directory changed before terminal retirement: {planned.Path}"); + } + if (Directory.EnumerateFileSystemEntries(planned.Path).Any()) + { + await UpdateCreatedDirectoryStateAsync( + request.JobId, + request.LeaseToken, + planned.Path, + MoveCreatedDirectoryState.Retained, + cancellationToken); + planned.State = MoveCreatedDirectoryState.Retained; + continue; + } + + publication.RetirePinnedEmptyDirectoryFromNamespace( + Path.GetFileName(planned.Path)); + await UpdateCreatedDirectoryStateAsync( + request.JobId, + request.LeaseToken, + planned.Path, + MoveCreatedDirectoryState.Removed, + cancellationToken); + planned.State = MoveCreatedDirectoryState.Removed; + } + } +} diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.TargetScaffoldingCleanup.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.TargetScaffoldingCleanup.cs index 5b2b5365e..8184f6d69 100644 --- a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.TargetScaffoldingCleanup.cs +++ b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.TargetScaffoldingCleanup.cs @@ -99,6 +99,17 @@ public async Task CleanupTerminalTargetScaffoldingAsync( AudiobookContentMoveRequest request, CancellationToken cancellationToken) { + if (await GetExecutionProtocolVersionAsync( + request.JobId, + cancellationToken) + >= MoveExecutionProtocol.MarkerlessDatabaseState) + { + await CleanupTerminalMarkerlessTargetDirectoriesAsync( + request, + cancellationToken); + return; + } + var scaffolding = (await GetCreatedDirectoriesAsync(request.JobId, cancellationToken)) .OrderBy(directory => GetPathDepth(directory.Path)) .ToList(); diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.cs index 0f36f29bd..70a19bfef 100644 --- a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.cs +++ b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.cs @@ -93,7 +93,14 @@ public async Task MoveContentsAsync( "Move source and target must be distinct non-root directories."); } - ValidateMoveSourceRoot(source); + var executionProtocolVersion = await GetExecutionProtocolVersionAsync( + request.JobId, + cancellationToken); + await ValidateMoveSourceRootForExecutionAsync( + request.JobId, + source, + executionProtocolVersion, + cancellationToken); ValidateMoveTargetRoot(target); await ValidatePersistedMoveIdentityAsync( request.JobId, @@ -106,9 +113,6 @@ await ValidatePersistedMoveIdentityAsync( var targetInsideSource = IsSameOrInside(target, source, sourceSemantics); var sourceInsideTarget = IsSameOrInside(source, target, targetSemantics); - var executionProtocolVersion = await GetExecutionProtocolVersionAsync( - request.JobId, - cancellationToken); if (executionProtocolVersion >= MoveExecutionProtocol.MarkerlessDatabaseState) { return await MoveContentsMarkerlessAsync( diff --git a/listenarr.infrastructure/Library/Moving/EfLibraryDirectoryOwnershipStore.MarkerlessReplacement.cs b/listenarr.infrastructure/Library/Moving/EfLibraryDirectoryOwnershipStore.MarkerlessReplacement.cs new file mode 100644 index 000000000..035e0c21a --- /dev/null +++ b/listenarr.infrastructure/Library/Moving/EfLibraryDirectoryOwnershipStore.MarkerlessReplacement.cs @@ -0,0 +1,191 @@ +using Listenarr.Domain.Common; +using Microsoft.EntityFrameworkCore; + +namespace Listenarr.Infrastructure.Library.Moving; + +internal sealed partial class EfLibraryDirectoryOwnershipStore +{ + public async Task TryRetireReplacedByMarkerlessMoveAsync( + string path, + FileSystemPathSemantics semantics, + Guid moveJobId, + string replacementDirectoryObjectIdentity, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + ArgumentException.ThrowIfNullOrWhiteSpace(replacementDirectoryObjectIdentity); + if (moveJobId == Guid.Empty) + { + throw new ArgumentException( + "A markerless replacement proof requires a move job ID.", + nameof(moveJobId)); + } + EnsureResolved(semantics); + + var canonicalPath = FileSystemPathIdentity.Canonicalize( + path, + semantics.Syntax); + var lookupKey = FileSystemPathIdentity.CreateLookupKey( + IdentityScope, + canonicalPath, + semantics.Syntax); + await using var db = await dbContextFactory.CreateDbContextAsync(cancellationToken); + var candidates = await db.LibraryDirectoryOwnerships + .Where(ownership => ownership.PathIdentityLookupKey == lookupKey + && ownership.State != LibraryDirectoryOwnershipState.Removed) + .ToListAsync(cancellationToken); + if (candidates.Count == 0) + { + return false; + } + + var compatible = new List(); + var conflicting = false; + foreach (var candidate in candidates) + { + var comparison = Compare(candidate, canonicalPath, semantics); + if (comparison == OwnershipComparison.Compatible + && candidate.State is LibraryDirectoryOwnershipState.Owned + or LibraryDirectoryOwnershipState.Retained + or LibraryDirectoryOwnershipState.Unavailable) + { + compatible.Add(candidate); + } + else if (comparison is OwnershipComparison.Compatible + or OwnershipComparison.Conflict) + { + conflicting = true; + } + } + + if (compatible.Count == 0 && !conflicting) + { + return false; + } + if (conflicting || compatible.Count != 1) + { + throw new InvalidOperationException( + "The markerless replacement path has conflicting durable ownership claims."); + } + + var stale = compatible[0]; + if (string.IsNullOrWhiteSpace(stale.PathOwnershipKey)) + { + throw new InvalidOperationException( + "The prior directory ownership claim is not eligible for markerless replacement retirement."); + } + + var move = await db.MoveJobs + .AsNoTracking() + .Include(job => job.CreatedDirectories) + .SingleOrDefaultAsync(job => job.Id == moveJobId, cancellationToken); + if (move == null + || move.ExecutionProtocolVersion < MoveExecutionProtocol.MarkerlessDatabaseState + || string.IsNullOrWhiteSpace(move.RequestedPath) + || !FileSystemPathIdentity.AreEquivalent( + canonicalPath, + move.RequestedPath, + semantics) + || !string.Equals( + move.TargetDirectoryObjectIdentity, + replacementDirectoryObjectIdentity, + StringComparison.Ordinal)) + { + return false; + } + + var creationEvidence = move.CreatedDirectories + .Where(directory => FileSystemPathIdentity.AreEquivalent( + directory.Path, + canonicalPath, + semantics)) + .ToList(); + if (creationEvidence.Count != 1 + || creationEvidence[0].State != MoveCreatedDirectoryState.Created + || !string.Equals( + creationEvidence[0].DirectoryObjectIdentity, + replacementDirectoryObjectIdentity, + StringComparison.Ordinal)) + { + return false; + } + + using var authorization = await _boundaryAuthorizer.AuthorizeContainingRootAsync( + canonicalPath, + semantics, + cancellationToken); + using var liveDirectory = authorization.ParentAnchor.OpenExistingChild( + Path.GetFileName(canonicalPath)); + var liveIdentity = liveDirectory.GetDirectoryObjectIdentity(); + if (!string.Equals( + liveIdentity, + replacementDirectoryObjectIdentity, + StringComparison.Ordinal) + || !liveDirectory.VisiblePathMatches() + || !authorization.ParentAnchor.VisiblePathMatches()) + { + throw new InvalidOperationException( + "The markerless replacement directory no longer matches its persisted move generation."); + } + if (ManagedDirectoryIdentity.Matches( + stale.DirectoryObjectIdentityVersion, + stale.DirectoryObjectIdentity, + stale.OwnershipToken, + liveIdentity)) + { + return false; + } + if (!liveDirectory.VisiblePathMatches() + || !authorization.ParentAnchor.VisiblePathMatches()) + { + throw new InvalidOperationException( + "The markerless replacement directory changed before stale ownership retirement."); + } + + await using var transaction = db.Database.IsRelational() + ? await db.Database.BeginTransactionAsync(cancellationToken) + : null; + var now = timeProvider.GetUtcNow().UtcDateTime; + if (!await db.LibraryDirectoryOwnershipRetiredMarkers.AnyAsync( + marker => marker.OwnershipId == stale.Id, + cancellationToken)) + { + if (stale.ManagedRootFolderId.HasValue + && stale.DirectoryObjectIdentityVersion.HasValue + && !string.IsNullOrWhiteSpace(stale.DirectoryObjectIdentity)) + { + db.LibraryDirectoryOwnershipRetiredMarkers.Add( + LibraryDirectoryOwnershipRetiredMarkerEvidence.Create( + stale, + new LibraryDirectoryOwnershipMarker.MarkerPayload( + LibraryDirectoryOwnershipMarker.Version, + stale.OwnershipToken, + stale.CanonicalPath, + stale.ManagedRootFolderId, + stale.DirectoryObjectIdentityVersion, + stale.DirectoryObjectIdentity), + now)); + } + else + { + db.LibraryDirectoryOwnershipRetiredMarkers.Add( + LibraryDirectoryOwnershipRetiredMarkerEvidence + .CreateLegacyPending(stale)); + } + } + + stale.State = LibraryDirectoryOwnershipState.Removed; + stale.PathOwnershipKey = null; + stale.ManagedRootFolderId = null; + stale.StateReason = null; + stale.UpdatedAt = now; + await db.SaveChangesAsync(cancellationToken); + if (transaction != null) + { + cancellationToken.ThrowIfCancellationRequested(); + await transaction.CommitAsync(CancellationToken.None); + } + + return true; + } +} diff --git a/listenarr.infrastructure/Library/Moving/LibraryDirectoryOwnershipMarker.MigrationCleanup.cs b/listenarr.infrastructure/Library/Moving/LibraryDirectoryOwnershipMarker.MigrationCleanup.cs new file mode 100644 index 000000000..3b34bca1a --- /dev/null +++ b/listenarr.infrastructure/Library/Moving/LibraryDirectoryOwnershipMarker.MigrationCleanup.cs @@ -0,0 +1,125 @@ +namespace Listenarr.Infrastructure.Library.Moving; + +internal static partial class LibraryDirectoryOwnershipMarker +{ + internal static bool TryRetireMigrationArtifacts( + LibraryDirectoryOwnership source, + LibraryDirectoryOwnership target, + PinnedDirectoryCreation.PinnedDirectoryAnchor directory, + PinnedDirectoryCreation.PinnedDirectoryAnchor parent, + out string? reason) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(target); + ArgumentNullException.ThrowIfNull(directory); + ArgumentNullException.ThrowIfNull(parent); + try + { + var retiredInsideArtifact = false; + foreach (var fileName in GetInsideMigrationArtifactNames()) + { + retiredInsideArtifact |= RetireMigrationArtifactIfPresent( + source, + target, + directory, + fileName); + } + + var retiredSiblingArtifact = false; + var siblingName = + $".listenarr-directory-owner-{source.OwnershipToken}.json"; + foreach (var fileName in GetSiblingMigrationArtifactNames( + siblingName)) + { + retiredSiblingArtifact |= RetireMigrationArtifactIfPresent( + source, + target, + parent, + fileName); + } + + if (retiredInsideArtifact) + { + directory.FlushDirectoryEntry(); + } + if (retiredSiblingArtifact) + { + parent.FlushDirectoryEntry(); + } + reason = null; + return true; + } + catch (Exception exception) when (exception is + ArgumentException or IOException or UnauthorizedAccessException + or InvalidOperationException or NotSupportedException + or System.ComponentModel.Win32Exception) + { + reason = exception.Message; + return false; + } + } + + private static IEnumerable GetInsideMigrationArtifactNames() + { + yield return FileName; + yield return FileName + ".v2.tmp"; + yield return FileName + ".migration.tmp"; + yield return PinnedDirectoryCreation + .GetConditionalReplacementBackupName(FileName); + } + + private static IEnumerable GetSiblingMigrationArtifactNames( + string siblingName) + { + yield return siblingName; + yield return siblingName + ".v2.tmp"; + yield return siblingName + ".migration.tmp"; + yield return PinnedDirectoryCreation + .GetConditionalReplacementBackupName(siblingName); + } + + private static bool RetireMigrationArtifactIfPresent( + LibraryDirectoryOwnership source, + LibraryDirectoryOwnership target, + PinnedDirectoryCreation.PinnedDirectoryAnchor parent, + string fileName) + { + using var marker = parent.TryOpenExistingFile( + fileName, + requireDeleteAccess: true); + if (marker == null) + { + return false; + } + + var payload = ReadPayload(marker); + if (!MatchesMigrationPayload(source, target, payload) + || !parent.VisiblePathMatches() + || !marker.VisiblePathMatches()) + { + throw new InvalidOperationException( + "An ownership migration artifact does not match either persisted migration generation."); + } + + var verifiedPayload = ReadPayload(marker); + if (!MatchesMigrationPayload(source, target, verifiedPayload) + || !parent.VisiblePathMatches() + || !marker.VisiblePathMatches()) + { + throw new InvalidOperationException( + "An ownership migration artifact changed before retirement."); + } + + marker.Delete(); + return true; + } + + private static bool MatchesMigrationPayload( + LibraryDirectoryOwnership source, + LibraryDirectoryOwnership target, + MarkerPayload payload) => + MatchesCurrentPayload(source, payload) + || MatchesLegacyPayload(source, payload) + || MatchesCurrentPayload(target, payload) + || MatchesLegacyPayload(target, payload); +} diff --git a/listenarr.infrastructure/Library/Moving/MoveJobProcessor.Helpers.cs b/listenarr.infrastructure/Library/Moving/MoveJobProcessor.Helpers.cs index bd77b6c27..060dec1f6 100644 --- a/listenarr.infrastructure/Library/Moving/MoveJobProcessor.Helpers.cs +++ b/listenarr.infrastructure/Library/Moving/MoveJobProcessor.Helpers.cs @@ -348,6 +348,33 @@ private async Task ScheduleTransientRetryAsync( AudiobookContentMoveService? contentMoveService = null, AudiobookContentMoveRequest? moveRequest = null) { + var terminalCleanupCompleted = false; + if (contentMoveService != null && moveRequest != null) + { + var persistedBeforeRetry = await moveQueueService.GetJobAsync( + job.Id, + cancellationToken) + ?? throw new MoveLeaseLostException( + job.Id, + job.LeaseGeneration); + if (persistedBeforeRetry.Status == MoveJobStatus.Running + && string.Equals( + persistedBeforeRetry.LeaseOwner, + job.LeaseOwner, + StringComparison.Ordinal) + && persistedBeforeRetry.LeaseGeneration == job.LeaseGeneration + && persistedBeforeRetry.AttemptCount + 1 + >= MoveTimingPolicy.MaxTransientAttempts) + { + await TryCleanupTerminalTargetScaffoldingAsync( + job, + contentMoveService, + moveRequest, + cancellationToken); + terminalCleanupCompleted = true; + } + } + var result = await moveQueueService.ScheduleRetryWithoutNotificationAsync( job.Id, job.LeaseOwner!, @@ -360,13 +387,13 @@ private async Task ScheduleTransientRetryAsync( : error; if (result.Status == MoveJobStatus.NeedsAttention) { - if (contentMoveService != null && moveRequest != null) + if (contentMoveService != null + && moveRequest != null + && !terminalCleanupCompleted) { - await TryCleanupTerminalTargetScaffoldingAsync( - job, - contentMoveService, - moveRequest, - cancellationToken); + logger.LogWarning( + "Move job {JobId} reached the retry limit without terminal scaffolding cleanup under its active lease", + job.Id); } metrics.Increment("worker.move.job.needs_attention"); diff --git a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.MetadataStart.cs b/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.MetadataStart.cs index 819f598ce..ced9bd8c2 100644 --- a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.MetadataStart.cs +++ b/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.MetadataStart.cs @@ -148,7 +148,7 @@ await RequireTargetDirectoryGenerationAsync( targetObjectIdentity, completionToken); } - await PublishOwnershipMigrationTargetsAsync( + ValidateMarkerlessOwnershipMigrationTargets( ownershipPlans, targetPath, completionToken); @@ -156,7 +156,7 @@ await PublishOwnershipMigrationTargetsAsync( { plan.Journal.State = LibraryDirectoryOwnershipPathMigrationState - .MarkersPublished; + .TargetValidated; plan.Journal.UpdatedAt = DateTime.UtcNow; } await db.SaveChangesAsync(completionToken); @@ -167,6 +167,10 @@ await RequireTargetDirectoryGenerationAsync( targetObjectIdentity, completionToken); } + ValidateMarkerlessOwnershipMigrationTargets( + ownershipPlans, + targetPath, + completionToken); } catch (Exception exception) when (exception is not ( OutOfMemoryException or StackOverflowException)) @@ -198,7 +202,11 @@ await RequireTargetDirectoryGenerationAsync( RejectDuplicateAudiobookFileOwnership(db); ApplyOwnershipMigrationMetadata(ownershipPlans, nowUtc); await db.SaveChangesAsync(completionToken); - AssignOwnershipMigrationKeys(ownershipPlans, nowUtc); + AssignOwnershipMigrationKeys( + ownershipPlans, + nowUtc, + LibraryDirectoryOwnershipPathMigrationState + .MarkerlessCommitted); ApplyRootMetadata( root, command, @@ -236,22 +244,26 @@ await ClearOtherDefaultsAsync( try { AfterMetadataOnlyCommitForTest?.Invoke(); - await PublishOwnershipMigrationTargetsAsync( + if (targetObjectIdentity.IsAvailable) + { + await RequireTargetDirectoryGenerationAsync( + targetPath, + targetObjectIdentity, + CancellationToken.None); + } + ValidateMarkerlessOwnershipMigrationTargets( ownershipPlans, targetPath, CancellationToken.None); - await RetireOwnershipMigrationSourcesAsync( + TryRetireMarkerlessOwnershipMigrationSourceArtifacts( ownershipPlans, sourcePath, - targetPath, - targetObjectIdentity.Version, - targetObjectIdentity.Value, - targetObjectIdentity.UnavailableReason, CancellationToken.None); foreach (var plan in ownershipPlans) { plan.Journal.State = - LibraryDirectoryOwnershipPathMigrationState.SourceMarkersRetired; + LibraryDirectoryOwnershipPathMigrationState + .MarkerlessRetired; plan.Journal.UpdatedAt = DateTime.UtcNow; } await db.SaveChangesAsync(CancellationToken.None); diff --git a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.OwnershipMigration.cs b/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.OwnershipMigration.cs index 0adc85469..aa8985c8b 100644 --- a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.OwnershipMigration.cs +++ b/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.OwnershipMigration.cs @@ -208,6 +208,46 @@ private async Task> return plans; } + private static void ValidateMarkerlessOwnershipMigrationTargets( + IReadOnlyList plans, + string targetBoundary, + CancellationToken cancellationToken) + { + foreach (var plan in plans) + { + cancellationToken.ThrowIfCancellationRequested(); + var targetParentPath = Path.GetDirectoryName( + plan.Target.CanonicalPath) + ?? throw new InvalidOperationException( + "The migrated ownership target has no parent directory."); + using var targetParent = OpenMarkerParentWithinBoundary( + targetBoundary, + targetParentPath, + plan.Target.GetIdentity().Semantics); + using var directory = targetParent.OpenExistingChild( + Path.GetFileName(plan.Target.CanonicalPath)); + var nativeIdentity = directory.GetDirectoryObjectIdentity(); + if (!ManagedDirectoryIdentity.Matches( + plan.Source.DirectoryObjectIdentityVersion, + plan.Source.DirectoryObjectIdentity, + plan.Source.OwnershipToken, + nativeIdentity) + || !directory.VisiblePathMatches() + || !targetParent.VisiblePathMatches()) + { + throw new InvalidOperationException( + "Metadata-only relocation cannot transfer directory ownership to a different physical generation."); + } + + plan.Target.DirectoryObjectIdentityVersion = + ManagedDirectoryIdentity.CurrentVersion; + plan.Target.DirectoryObjectIdentity = ManagedDirectoryIdentity.Create( + plan.Target.OwnershipToken, + nativeIdentity); + plan.Target.DirectoryObjectIdentityUnavailableReason = null; + } + } + private static async Task PublishOwnershipMigrationTargetsAsync( IReadOnlyList plans, string targetBoundary, @@ -273,15 +313,15 @@ private static void ApplyOwnershipMigrationMetadata( private static void AssignOwnershipMigrationKeys( IReadOnlyList plans, - DateTime now) + DateTime now, + LibraryDirectoryOwnershipPathMigrationState committedState = + LibraryDirectoryOwnershipPathMigrationState.MetadataCommitted) { foreach (var plan in plans) { plan.Tracked.PathOwnershipKey = plan.Target.PathOwnershipKey; - plan.Journal.State = - LibraryDirectoryOwnershipPathMigrationState - .MetadataCommitted; + plan.Journal.State = committedState; plan.Journal.UpdatedAt = now; } } diff --git a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.OwnershipMigrationArtifactCleanup.cs b/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.OwnershipMigrationArtifactCleanup.cs index 8ca0eda46..e38d41677 100644 --- a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.OwnershipMigrationArtifactCleanup.cs +++ b/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.OwnershipMigrationArtifactCleanup.cs @@ -24,9 +24,8 @@ private static async Task RetireOwnershipMigrationTargetsAsync( targetIdentityValue, targetIdentityUnavailableReason, cancellationToken); - using var publication = targetParent.OpenExistingChildForPublication( + using var targetDirectory = targetParent.OpenExistingChild( Path.GetFileName(plan.Target.CanonicalPath)); - using var targetDirectory = publication.OpenCreatedDirectoryAnchor(); if (!ManagedDirectoryIdentity.Matches( plan.Target.DirectoryObjectIdentityVersion, plan.Target.DirectoryObjectIdentity, @@ -39,7 +38,8 @@ private static async Task RetireOwnershipMigrationTargetsAsync( "The ownership migration target changed before temporary artifact cleanup."); } - if (!LibraryDirectoryOwnershipMarker.TryRetireMatchingMarkers( + if (!LibraryDirectoryOwnershipMarker.TryRetireMigrationArtifacts( + plan.Source, plan.Target, targetDirectory, targetParent, diff --git a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.OwnershipMigrationRecovery.cs b/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.OwnershipMigrationRecovery.cs index dc1e0b070..2fb466439 100644 --- a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.OwnershipMigrationRecovery.cs +++ b/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.OwnershipMigrationRecovery.cs @@ -41,6 +41,7 @@ private async Task> await using var db = await dbContextFactory.CreateDbContextAsync(cancellationToken); var relocation = await db.RootFolderRelocations + .AsSplitQuery() .Include(candidate => candidate.OwnershipPathMigrations) .ThenInclude(migration => migration.Ownership) .Include(candidate => candidate.SkippedItems) @@ -62,7 +63,7 @@ await RequireTargetDirectoryGenerationAsync( .ToList(); if (preparedPlans.Count > 0) { - await PublishOwnershipMigrationTargetsAsync( + ValidateMarkerlessOwnershipMigrationTargets( preparedPlans, relocation.TargetPath, cancellationToken); @@ -70,7 +71,7 @@ await PublishOwnershipMigrationTargetsAsync( { plan.Journal.State = LibraryDirectoryOwnershipPathMigrationState - .MarkersPublished; + .TargetValidated; plan.Journal.UpdatedAt = timeProvider.GetUtcNow().UtcDateTime; } @@ -84,8 +85,9 @@ await PublishOwnershipMigrationTargetsAsync( .ToList(); if (publishedPlans.Count > 0) { - // Re-prove the target directory generation and both durable - // markers after every restart before committing metadata. + // Existing MarkersPublished rows can only have been created by + // the legacy sidecar protocol. Re-prove those artifacts without + // publishing any new files before completing their old journal. await PublishOwnershipMigrationTargetsAsync( publishedPlans, relocation.TargetPath, @@ -98,6 +100,26 @@ await CompleteOwnershipMigrationMetadataAsync( cancellationToken); } + var markerlessValidatedPlans = plans + .Where(plan => plan.Journal.State + == LibraryDirectoryOwnershipPathMigrationState + .TargetValidated) + .ToList(); + if (markerlessValidatedPlans.Count > 0) + { + ValidateMarkerlessOwnershipMigrationTargets( + markerlessValidatedPlans, + relocation.TargetPath, + cancellationToken); + await CompleteOwnershipMigrationMetadataAsync( + db, + relocation, + plans, + cancellationToken, + LibraryDirectoryOwnershipPathMigrationState + .MarkerlessCommitted); + } + if (plans.All(plan => plan.Journal.State == LibraryDirectoryOwnershipPathMigrationState @@ -129,7 +151,38 @@ await RetireOwnershipMigrationSourcesAsync( if (plans.All(plan => plan.Journal.State == LibraryDirectoryOwnershipPathMigrationState - .SourceMarkersRetired)) + .MarkerlessCommitted)) + { + await RequireTargetDirectoryGenerationAsync( + relocation.TargetPath, + relocation.TargetDirectoryObjectIdentityVersion, + relocation.TargetDirectoryObjectIdentity, + relocation.TargetDirectoryObjectIdentityUnavailableReason, + CancellationToken.None); + ValidateMarkerlessOwnershipMigrationTargets( + plans, + relocation.TargetPath, + CancellationToken.None); + TryRetireMarkerlessOwnershipMigrationSourceArtifacts( + plans, + relocation.SourcePath, + CancellationToken.None); + foreach (var plan in plans) + { + plan.Journal.State = + LibraryDirectoryOwnershipPathMigrationState + .MarkerlessRetired; + plan.Journal.UpdatedAt = + timeProvider.GetUtcNow().UtcDateTime; + } + await db.SaveChangesAsync(CancellationToken.None); + } + + if (plans.All(plan => + plan.Journal.State is + LibraryDirectoryOwnershipPathMigrationState.SourceMarkersRetired + or LibraryDirectoryOwnershipPathMigrationState + .MarkerlessRetired)) { await RetireOwnershipMigrationTargetsAsync( plans, @@ -215,7 +268,9 @@ private async Task CompleteOwnershipMigrationMetadataAsync( ListenArrDbContext db, RootFolderRelocation relocation, IReadOnlyList plans, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + LibraryDirectoryOwnershipPathMigrationState committedState = + LibraryDirectoryOwnershipPathMigrationState.MetadataCommitted) { var rootId = relocation.RootFolderId ?? throw new InvalidOperationException( @@ -324,7 +379,10 @@ await db.AudiobookFiles ApplyOwnershipMigrationMetadata(plans, now); BeforeOwnershipMigrationMetadataSaveForTest?.Invoke(); await db.SaveChangesAsync(cancellationToken); - AssignOwnershipMigrationKeys(plans, now); + AssignOwnershipMigrationKeys( + plans, + now, + committedState); var command = new RootFolderPathChangeCommand( relocation.TargetPath, relocation.Mode, diff --git a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.OwnershipMigrationRetirement.cs b/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.OwnershipMigrationRetirement.cs index 9c8556669..b954a303d 100644 --- a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.OwnershipMigrationRetirement.cs +++ b/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.OwnershipMigrationRetirement.cs @@ -10,6 +10,56 @@ internal Action? BeforeOwnershipMigrationSourceRetirementForTest set; } + private static void TryRetireMarkerlessOwnershipMigrationSourceArtifacts( + IReadOnlyList plans, + string sourceBoundary, + CancellationToken cancellationToken) + { + foreach (var plan in plans) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + var sourceParentPath = Path.GetDirectoryName( + plan.Source.CanonicalPath) + ?? throw new InvalidOperationException( + "The migrated ownership source has no parent directory."); + using var sourceParent = OpenMarkerParentWithinBoundary( + sourceBoundary, + sourceParentPath, + plan.Source.GetIdentity().Semantics); + using var sourceDirectory = sourceParent.OpenExistingChild( + Path.GetFileName(plan.Source.CanonicalPath)); + if (!ManagedDirectoryIdentity.Matches( + plan.Source.DirectoryObjectIdentityVersion, + plan.Source.DirectoryObjectIdentity, + plan.Source.OwnershipToken, + sourceDirectory.GetDirectoryObjectIdentity()) + || !sourceDirectory.VisiblePathMatches() + || !sourceParent.VisiblePathMatches()) + { + continue; + } + + _ = LibraryDirectoryOwnershipMarker.TryRetireMigrationArtifacts( + plan.Source, + plan.Target, + sourceDirectory, + sourceParent, + out _); + } + catch (Exception exception) when (exception is not ( + OperationCanceledException or OutOfMemoryException + or StackOverflowException)) + { + // Fresh markerless migration never publishes source artifacts. + // Any source-side marker here is legacy cleanup only. The old path + // may legitimately be absent after an external rename, so failure + // to reach or prove it cannot invalidate the committed relocation. + } + } + } + private async Task RetireOwnershipMigrationSourcesAsync( IReadOnlyList plans, string sourceBoundary, diff --git a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.Reauthorization.cs b/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.Reauthorization.cs index 67b57962a..6a823ee1f 100644 --- a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.Reauthorization.cs +++ b/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.Reauthorization.cs @@ -46,6 +46,7 @@ private async Task ReauthorizeLegacyTargetCoreAsync( await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken); var relocation = await db.RootFolderRelocations + .AsSplitQuery() .Include(candidate => candidate.MoveJobs) .ThenInclude(job => job.Entries) .SingleOrDefaultAsync( diff --git a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.Retry.cs b/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.Retry.cs index 22d8651ac..bb6cde87f 100644 --- a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.Retry.cs +++ b/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.Retry.cs @@ -74,6 +74,7 @@ private async Task RetryCoreAsync( await using var db = await dbContextFactory.CreateDbContextAsync(cancellationToken); await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken); var relocation = await db.RootFolderRelocations + .AsSplitQuery() .Include(candidate => candidate.MoveJobs) .ThenInclude(job => job.Entries) .Include(candidate => candidate.SkippedItems) diff --git a/listenarr.infrastructure/Persistence/Repositories/AudiobookRepository.UpdateSnapshot.cs b/listenarr.infrastructure/Persistence/Repositories/AudiobookRepository.UpdateSnapshot.cs new file mode 100644 index 000000000..3a0129094 --- /dev/null +++ b/listenarr.infrastructure/Persistence/Repositories/AudiobookRepository.UpdateSnapshot.cs @@ -0,0 +1,13 @@ +using Microsoft.EntityFrameworkCore; + +namespace Listenarr.Infrastructure.Persistence.Repositories; + +public partial class AudiobookRepository +{ + public Task GetForUpdateSnapshotAsync( + int id, + CancellationToken ct = default) => + _db.Audiobooks + .AsNoTracking() + .FirstOrDefaultAsync(audiobook => audiobook.Id == id, ct); +} diff --git a/listenarr.infrastructure/Persistence/Repositories/AudiobookRepository.cs b/listenarr.infrastructure/Persistence/Repositories/AudiobookRepository.cs index 35df0fc60..5981d0c90 100644 --- a/listenarr.infrastructure/Persistence/Repositories/AudiobookRepository.cs +++ b/listenarr.infrastructure/Persistence/Repositories/AudiobookRepository.cs @@ -118,6 +118,7 @@ public async Task>> GetAllSeries { // Include QualityProfile and Files for callers that need full audiobook details return await _db.Audiobooks + .AsSplitQuery() .Include(a => a.QualityProfile) .Include(a => a.Files) .Include(a => a.ExternalIdentifiers) @@ -131,6 +132,7 @@ public async Task>> GetAllSeries { return await _db.Audiobooks .AsNoTracking() + .AsSplitQuery() .Include(a => a.QualityProfile) .Include(a => a.Files) .Include(a => a.ExternalIdentifiers) diff --git a/listenarr.infrastructure/Persistence/Repositories/EfMoveQueuePersistence.cs b/listenarr.infrastructure/Persistence/Repositories/EfMoveQueuePersistence.cs index 5be48bdcf..4c2e02a25 100644 --- a/listenarr.infrastructure/Persistence/Repositories/EfMoveQueuePersistence.cs +++ b/listenarr.infrastructure/Persistence/Repositories/EfMoveQueuePersistence.cs @@ -25,7 +25,9 @@ public sealed partial class EfMoveQueuePersistence( await using var db = await dbFactory.CreateDbContextAsync(cancellationToken); return await db.MoveJobs .AsNoTracking() + .AsSplitQuery() .Include(job => job.Entries) + .Include(job => job.CreatedDirectories) .SingleOrDefaultAsync(job => job.Id == id, cancellationToken); } catch (DbException ex) diff --git a/tests/Features/Api/Features/Library/LibraryController_BulkUpdateTests.cs b/tests/Features/Api/Features/Library/LibraryController_BulkUpdateTests.cs index 674402cbc..cf1f4df41 100644 --- a/tests/Features/Api/Features/Library/LibraryController_BulkUpdateTests.cs +++ b/tests/Features/Api/Features/Library/LibraryController_BulkUpdateTests.cs @@ -115,6 +115,94 @@ public async Task BulkDelete_DatabaseFailure_PreservesCachedImageAndDoesNotWrite fileSystem.VerifyNoOtherCalls(); } + [Fact] + public async Task BulkUpdate_PreCanceledRequest_StopsBeforeAnyMutation() + { + var repository = new Mock(MockBehavior.Strict); + var history = new Mock(MockBehavior.Strict); + Init(services => services + .WithSingleton(repository.Object) + .WithSingleton(history.Object)); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + await Assert.ThrowsAnyAsync(() => + _provider.GetRequiredService() + .BulkUpdateAudiobooks( + new LibraryController.BulkUpdateRequest + { + Ids = [8129], + Updates = new Dictionary + { + ["monitored"] = true + } + }, + cancellation.Token)); + + repository.VerifyNoOtherCalls(); + history.VerifyNoOtherCalls(); + } + + [Fact] + public async Task BulkUpdate_CanceledAfterFirstMetadataCommit_ReturnsPartialResultWithoutStartingNextItem() + { + const int firstId = 8130; + const int secondId = 8131; + var first = new Audiobook + { + Id = firstId, + Title = "Committed bulk update", + Monitored = false + }; + using var cancellation = new CancellationTokenSource(); + var repository = new Mock(MockBehavior.Strict); + repository.Setup(service => service.GetByIdAsync(firstId)) + .ReturnsAsync(first); + repository.Setup(service => service.UpdateAsync(first)) + .Returns(() => + { + cancellation.Cancel(); + return Task.FromResult(true); + }); + var history = new Mock(MockBehavior.Strict); + history.Setup(service => service.AddAsync( + It.Is(entry => entry.AudiobookId == firstId), + It.IsAny())) + .ReturnsAsync((History entry, CancellationToken _) => entry); + Init(services => services + .WithSingleton(repository.Object) + .WithSingleton(history.Object)); + + var result = await _provider.GetRequiredService() + .BulkUpdateAudiobooks( + new LibraryController.BulkUpdateRequest + { + Ids = [firstId, secondId], + Updates = new Dictionary + { + ["monitored"] = true + } + }, + cancellation.Token); + + var ok = Assert.IsType(result); + using var payload = JsonDocument.Parse(JsonSerializer.Serialize(ok.Value)); + Assert.Contains( + "request cancellation", + payload.RootElement.GetProperty("message").GetString(), + StringComparison.OrdinalIgnoreCase); + var item = Assert.Single(payload.RootElement.GetProperty("results").EnumerateArray()); + Assert.Equal(firstId, item.GetProperty("id").GetInt32()); + Assert.True(item.GetProperty("success").GetBoolean()); + Assert.True(item.GetProperty("metadataUpdated").GetBoolean()); + Assert.True(first.Monitored); + repository.Verify(service => service.GetByIdAsync(secondId), Times.Never); + repository.Verify(service => service.UpdateAsync(first), Times.Once); + history.Verify(service => service.AddAsync( + It.Is(entry => entry.AudiobookId == firstId), + It.IsAny()), Times.Once); + } + [Fact] public async Task BulkUpdate_DatabaseFailure_DoesNotWriteHistoryOrExposeInternalError() { @@ -456,6 +544,96 @@ await _applicationSettingsRepository.SaveAsync(new ApplicationSettingsBuilder() It.IsAny()), Times.Once); } + [Fact] + public async Task BulkUpdate_PhysicalPathChange_HoldsAudiobookBoundaryThroughDurableEnqueue() + { + var recoveryEntered = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var releaseRecovery = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var recoveryCalls = 0; + var moveQueue = CreateMoveQueueMock(); + moveQueue.Setup(service => service.GetRecoveryStateForAudiobookAsync( + It.IsAny(), + It.IsAny())) + .Returns(async (_, cancellationToken) => + { + if (Interlocked.Increment(ref recoveryCalls) == 1) + { + recoveryEntered.TrySetResult(); + await releaseRecovery.Task.WaitAsync(cancellationToken); + } + + return MoveRecoveryState.None; + }); + moveQueue.Setup(service => service.EnqueueMoveAsync( + It.IsAny(), + It.IsAny())) + .ReturnsAsync(Guid.NewGuid()); + Init(services => services.WithSingleton(moveQueue.Object)); + + var destinationRoot = FileService.GetTempDirectory( + "bulk-boundary-destination"); + await _applicationSettingsRepository.SaveAsync( + new ApplicationSettingsBuilder() + .WithOutputPath(destinationRoot) + .WithFileNamingPattern("{Author}/{Title}") + .Build()); + var sourceBasePath = FileService.GetTempDirectory( + "bulk-boundary-source"); + var sourceFilePath = Path.Join(sourceBasePath, "book.m4b"); + await File.WriteAllTextAsync(sourceFilePath, "audio"); + var audiobook = await _audiobookRepository.AddAsync(new Audiobook + { + Title = "Boundary Book", + Authors = ["Boundary Author"], + BasePath = sourceBasePath, + FilePath = sourceFilePath + }); + await AddTrackedFileAsync(audiobook, sourceFilePath); + + var controller = _provider.GetRequiredService(); + var bulkUpdate = controller.BulkUpdateAudiobooks( + new LibraryController.BulkUpdateRequest + { + Ids = [audiobook.Id], + Updates = [], + PathChange = new LibraryController.BulkPathChangeRequest + { + Mode = LibraryController.BulkPathChangeMode.Physical, + DestinationRootOrPath = destinationRoot, + DeleteEmptySource = false + } + }); + await recoveryEntered.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + var contenderEntered = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var coordinator = _provider + .GetRequiredService(); + var contender = coordinator.ExecuteExclusiveAsync( + audiobook.Id, + _ => + { + contenderEntered.TrySetResult(); + return Task.CompletedTask; + }); + var earlyEntry = await Task.WhenAny( + contenderEntered.Task, + Task.Delay(TimeSpan.FromMilliseconds(150))); + Assert.NotSame(contenderEntered.Task, earlyEntry); + + releaseRecovery.TrySetResult(); + var actionResult = await bulkUpdate; + await contender.WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.IsType(actionResult); + Assert.True(contenderEntered.Task.IsCompletedSuccessfully); + moveQueue.Verify(service => service.EnqueueMoveAsync( + It.IsAny(), + It.IsAny()), Times.Once); + } + [LinuxFact] public async Task BulkUpdate_PhysicalPathChange_PreservesTrailingSpaceInUnixDestinationRoot() { diff --git a/tests/Features/Api/Features/Library/LibraryController_DeleteFilesystemTests.cs b/tests/Features/Api/Features/Library/LibraryController_DeleteFilesystemTests.cs index f6f9de612..af6def75c 100644 --- a/tests/Features/Api/Features/Library/LibraryController_DeleteFilesystemTests.cs +++ b/tests/Features/Api/Features/Library/LibraryController_DeleteFilesystemTests.cs @@ -1935,6 +1935,19 @@ public Task> GetOwnedWithinAsync( CancellationToken cancellationToken = default) => inner.GetOwnedWithinAsync(basePath, semantics, cancellationToken); + public Task TryRetireReplacedByMarkerlessMoveAsync( + string path, + FileSystemPathSemantics semantics, + Guid moveJobId, + string replacementDirectoryObjectIdentity, + CancellationToken cancellationToken = default) => + inner.TryRetireReplacedByMarkerlessMoveAsync( + path, + semantics, + moveJobId, + replacementDirectoryObjectIdentity, + cancellationToken); + public Task BeginRemovalAsync( long ownershipId, string expectedOwnershipKey, @@ -2009,6 +2022,19 @@ public Task> GetOwnedWithinAsync( CancellationToken cancellationToken = default) => inner.GetOwnedWithinAsync(basePath, semantics, cancellationToken); + public Task TryRetireReplacedByMarkerlessMoveAsync( + string path, + FileSystemPathSemantics semantics, + Guid moveJobId, + string replacementDirectoryObjectIdentity, + CancellationToken cancellationToken = default) => + inner.TryRetireReplacedByMarkerlessMoveAsync( + path, + semantics, + moveJobId, + replacementDirectoryObjectIdentity, + cancellationToken); + public Task BeginRemovalAsync( long ownershipId, string expectedOwnershipKey, diff --git a/tests/Features/Api/Features/Library/LibraryController_ScanPathValidationTests.cs b/tests/Features/Api/Features/Library/LibraryController_ScanPathValidationTests.cs index 11047e822..357764e05 100644 --- a/tests/Features/Api/Features/Library/LibraryController_ScanPathValidationTests.cs +++ b/tests/Features/Api/Features/Library/LibraryController_ScanPathValidationTests.cs @@ -84,6 +84,26 @@ public void GetScanJobStatus_ReturnsPublicContractWithoutPathAuthorityOrInternal } } + [Fact] + public async Task ScanAudiobook_PreCanceledRequest_DoesNotPlanOrEnqueueScan() + { + var audiobook = await _audiobookRepository.AddAsync( + new AudiobookBuilder() + .WithTitle("Canceled Scan") + .WithBasePath(FileService.GetTempDirectory("canceled-scan")) + .Build()); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + var scan = _provider.GetRequiredService() + .ScanAudiobookFiles( + audiobook.Id, + request: null, + cancellation.Token); + + await Assert.ThrowsAnyAsync(() => scan); + } + [Fact] public async Task ScanAudiobook_UnresolvedMoveExecution_BlocksBeforeScanPlanning() { diff --git a/tests/Features/Api/Features/Library/LibraryController_UpdateAudiobookTests.cs b/tests/Features/Api/Features/Library/LibraryController_UpdateAudiobookTests.cs index 90b909dbd..842de521b 100644 --- a/tests/Features/Api/Features/Library/LibraryController_UpdateAudiobookTests.cs +++ b/tests/Features/Api/Features/Library/LibraryController_UpdateAudiobookTests.cs @@ -149,6 +149,35 @@ public async Task UpdateAudiobook_PersistsExpandedMetadataFields() Assert.True(storedAudiobook.Abridged); } + [Fact] + public async Task UpdateAudiobook_PreCanceledRequest_DoesNotMutateAudiobook() + { + var audiobook = await _audiobookRepository.AddAsync(new Audiobook + { + Title = "Original", + Monitored = true + }); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + var update = _provider + .GetRequiredService() + .UpdateAudiobook( + audiobook.Id, + new AudiobookUpdateRequest + { + Title = "Canceled update", + Monitored = false + }, + cancellation.Token); + + await Assert.ThrowsAnyAsync(() => update); + var stored = await GetFreshAudiobookAsync(audiobook.Id); + Assert.NotNull(stored); + Assert.Equal("Original", stored.Title); + Assert.True(stored.Monitored); + } + [Fact] public async Task UpdateAudiobook_RepositoryReportsMissing_ReturnsNotFound() { @@ -159,6 +188,10 @@ public async Task UpdateAudiobook_RepositoryReportsMissing_ReturnsNotFound() }; var repository = new Mock( MockBehavior.Strict); + repository.Setup(service => service.GetForUpdateSnapshotAsync( + audiobook.Id, + It.IsAny())) + .ReturnsAsync(audiobook); repository.Setup(service => service.GetByIdAsync(audiobook.Id)) .ReturnsAsync(audiobook); repository.Setup(service => service.UpdateAsync( @@ -175,8 +208,11 @@ public async Task UpdateAudiobook_RepositoryReportsMissing_ReturnsNotFound() new AudiobookUpdateRequest { Title = "Updated" }); Assert.IsType(result); + repository.Verify(service => service.GetForUpdateSnapshotAsync( + audiobook.Id, + It.IsAny()), Times.Once); repository.Verify(service => service.GetByIdAsync(audiobook.Id), - Times.Exactly(2)); + Times.Once); repository.Verify(service => service.UpdateAsync( It.Is(candidate => candidate.Id == audiobook.Id)), Times.Once); diff --git a/tests/Features/Api/Features/Library/LibraryUpdateWorkflowTests.cs b/tests/Features/Api/Features/Library/LibraryUpdateWorkflowTests.cs index d66b500b1..ff88aac9a 100644 --- a/tests/Features/Api/Features/Library/LibraryUpdateWorkflowTests.cs +++ b/tests/Features/Api/Features/Library/LibraryUpdateWorkflowTests.cs @@ -44,9 +44,11 @@ public async Task UpdateAsync_ForeignPersistedBasePathAlias_RoutesThroughAuthori }; var repository = new Mock(MockBehavior.Strict); - repository - .SetupSequence(candidate => candidate.GetByIdAsync(id)) - .ReturnsAsync(before) + repository.Setup(candidate => candidate.GetForUpdateSnapshotAsync( + id, + It.IsAny())) + .ReturnsAsync(before); + repository.Setup(candidate => candidate.GetByIdAsync(id)) .ReturnsAsync(after); var rewriteService = new Mock(MockBehavior.Strict); rewriteService @@ -109,9 +111,11 @@ public async Task UpdateAsync_DestinationOnlyRewrite_DoesNotIssueMetadataWrite() }; var repository = new Mock(MockBehavior.Strict); - repository - .SetupSequence(candidate => candidate.GetByIdAsync(id)) - .ReturnsAsync(before) + repository.Setup(candidate => candidate.GetForUpdateSnapshotAsync( + id, + It.IsAny())) + .ReturnsAsync(before); + repository.Setup(candidate => candidate.GetByIdAsync(id)) .ReturnsAsync(after); var rewriteService = new Mock(MockBehavior.Strict); rewriteService @@ -142,6 +146,72 @@ public async Task UpdateAsync_DestinationOnlyRewrite_DoesNotIssueMetadataWrite() Assert.False(after.Monitored); } + [Fact] + public async Task UpdateAsync_CancelledAfterDestinationCommit_CompletesRequestedMetadataFinalization() + { + var id = 44; + var source = Path.Join(Path.GetTempPath(), $"listenarr-update-source-{Guid.NewGuid():N}"); + var target = Path.Join(Path.GetTempPath(), $"listenarr-update-target-{Guid.NewGuid():N}"); + var before = new Audiobook + { + Id = id, + Title = "Original", + BasePath = source + }; + var after = new Audiobook + { + Id = id, + Title = "Original", + BasePath = target + }; + using var cancellation = new CancellationTokenSource(); + var repository = new Mock(MockBehavior.Strict); + repository.Setup(candidate => candidate.GetForUpdateSnapshotAsync( + id, + It.IsAny())) + .ReturnsAsync(before); + repository.Setup(candidate => candidate.GetByIdAsync(id)) + .ReturnsAsync(after); + repository.Setup(candidate => candidate.UpdateAsync(after)).ReturnsAsync(true); + var rewriteService = new Mock(MockBehavior.Strict); + rewriteService + .Setup(candidate => candidate.RewriteDestinationAsync( + id, + target, + source, + It.IsAny())) + .Returns(() => + { + cancellation.Cancel(); + return Task.FromResult( + new AudiobookDestinationRewriteResult(id, target, source)); + }); + + var services = new ServiceCollection(); + services.AddSingleton(repository.Object); + using var provider = services.BuildServiceProvider(); + using var operationCoordinator = new AudiobookOperationCoordinator(); + var workflow = new LibraryUpdateWorkflow( + provider.GetRequiredService(), + rewriteService.Object, + operationCoordinator, + new FileSystemSemanticsResolver(), + NullLogger.Instance); + + var result = await workflow.UpdateAsync( + id, + new AudiobookUpdateRequest + { + BasePath = target, + Title = "Edited" + }, + cancellation.Token); + + Assert.IsType(result); + Assert.Equal("Edited", after.Title); + repository.Verify(candidate => candidate.UpdateAsync(after), Times.Once); + } + [Fact] public async Task UpdateAsync_DestinationAndMetadataUpdate_PreservesOmittedBooleans() { @@ -168,9 +238,11 @@ public async Task UpdateAsync_DestinationAndMetadataUpdate_PreservesOmittedBoole }; var repository = new Mock(MockBehavior.Strict); - repository - .SetupSequence(candidate => candidate.GetByIdAsync(id)) - .ReturnsAsync(before) + repository.Setup(candidate => candidate.GetForUpdateSnapshotAsync( + id, + It.IsAny())) + .ReturnsAsync(before); + repository.Setup(candidate => candidate.GetByIdAsync(id)) .ReturnsAsync(after); repository.Setup(candidate => candidate.UpdateAsync(after)).ReturnsAsync(true); var rewriteService = new Mock(MockBehavior.Strict); diff --git a/tests/Features/Api/LibraryController_MetadataRescanTests.cs b/tests/Features/Api/LibraryController_MetadataRescanTests.cs index dede55210..aeb233b48 100644 --- a/tests/Features/Api/LibraryController_MetadataRescanTests.cs +++ b/tests/Features/Api/LibraryController_MetadataRescanTests.cs @@ -18,6 +18,7 @@ using System.Net; using System.Text.Json; using Listenarr.Tests.Mocks; +using Microsoft.AspNetCore.Http; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -509,6 +510,86 @@ await response.Content.ReadAsStringAsync(), (await verification.Audiobooks.SingleAsync(candidate => candidate.Id == audiobookId)).Title); } + [Fact] + public async Task RescanMetadata_RequestCancelledDuringProviderLookup_DoesNotCommit() + { + const string asin = "B0CANCEL01"; + var lookupStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var releaseLookup = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var metadataMock = new Mock(); + metadataMock + .Setup(service => service.GetMetadataAsync(asin, "us", false)) + .Returns(async () => + { + lookupStarted.SetResult(); + await releaseLookup.Task; + return new + { + metadata = new AudibleBookResponse + { + Asin = asin, + Title = "Provider Title" + }, + source = "Audible" + }; + }); + var factory = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureServices(services => + { + services.RemoveAll(); + services.AddSingleton(metadataMock.Object); + }); + }); + + int audiobookId; + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var audiobook = new Audiobook + { + Title = "Original Title", + Asin = asin, + ExternalIdentifiers = + [ + new AudiobookExternalIdentifier + { + Type = AudiobookExternalIdentifierType.Asin, + ValueRaw = asin, + ValueNormalized = asin, + Region = "us", + IsPrimary = true, + Source = AudiobookExternalIdentifierSource.Manual + } + ] + }; + db.Audiobooks.Add(audiobook); + await db.SaveChangesAsync(); + audiobookId = audiobook.Id; + } + + using var workflowScope = factory.Services.CreateScope(); + var workflow = workflowScope.ServiceProvider.GetRequiredService(); + using var cancellation = new CancellationTokenSource(); + var httpContext = new DefaultHttpContext + { + RequestAborted = cancellation.Token + }; + + var rescan = workflow.RescanAsync(audiobookId, httpContext); + await lookupStarted.Task; + cancellation.Cancel(); + releaseLookup.SetResult(); + + await Assert.ThrowsAnyAsync(() => rescan); + + using var verificationScope = factory.Services.CreateScope(); + var verification = verificationScope.ServiceProvider.GetRequiredService(); + Assert.Equal( + "Original Title", + (await verification.Audiobooks.SingleAsync(candidate => candidate.Id == audiobookId)).Title); + } + [Fact] public async Task RescanMetadata_Returns429_WhenRepeatedImmediately_ForSameAudiobookAndActor() { diff --git a/tests/Features/Application/Audiobooks/Jobs/MoveQueueServiceTests.cs b/tests/Features/Application/Audiobooks/Jobs/MoveQueueServiceTests.cs index eefa154ae..1edb1f01f 100644 --- a/tests/Features/Application/Audiobooks/Jobs/MoveQueueServiceTests.cs +++ b/tests/Features/Application/Audiobooks/Jobs/MoveQueueServiceTests.cs @@ -1146,6 +1146,80 @@ public async Task RequeueMoveAsync_FailedJob_ResetsRetryStateAndPreservesRecover It.IsAny()), Times.Once); } + [Fact] + public async Task RequeueMoveAsync_MarkerlessNeedsAttentionUnknownWithCompletedRecoveryEvidence_Requeues() + { + var sourcePath = Path.GetFullPath(Path.Join(Path.GetTempPath(), "listenarr-recoverable-source", "Title")); + var targetPath = Path.GetFullPath(Path.Join(Path.GetTempPath(), "listenarr-recoverable-target", "Title")); + var semantics = FileSystemPathSemantics.CurrentHostDefault; + var identity = new PathIdentitySnapshot( + semantics.Syntax, + semantics.CaseSensitivity, + FileSystemCaseSensitivityMode.Auto, + Path.GetFullPath(Path.GetTempPath())); + var job = new MoveJob + { + Id = Guid.NewGuid(), + AudiobookId = 9, + SourcePath = sourcePath, + RequestedPath = targetPath, + Status = MoveJobStatus.NeedsAttention, + Phase = MoveJobPhase.Published, + ExecutionProtocolVersion = MoveExecutionProtocol.MarkerlessDatabaseState, + SourceDirectoryCleanupState = MoveJobEntryCleanupState.Deleted, + TargetDirectoryObjectIdentity = "target-generation", + FailureKind = MoveFailureKind.Unknown, + Error = "A prior build could not reconcile stale target ownership.", + Entries = + [ + new MoveJobEntry + { + RelativePath = "book.m4b", + EntryType = MoveJobEntryType.File, + Length = 1, + LastWriteTimeUtc = DateTime.UnixEpoch, + Sha256 = new string('A', 64), + CopyState = MoveJobEntryCopyState.Verified, + CleanupState = MoveJobEntryCleanupState.Deleted + }, + MoveManifestIdentity.CreateTargetBoundaryAuthorization( + 2, + "test-target-generation") + ], + CreatedDirectories = + [ + new MoveJobCreatedDirectory + { + Path = targetPath, + State = MoveCreatedDirectoryState.Created, + DirectoryObjectIdentity = "target-generation" + } + ] + }; + job.SetSourceIdentity(identity); + job.SetTargetIdentity(identity); + var persistence = CreateInMemoryPersistence([job]); + var service = new MoveQueueService( + NullLogger.Instance, + persistence.Object, + new NoopHubBroadcaster(), + TimeProvider.System, + BuildSemanticsResolver()); + + var requeuedJobId = await service.RequeueMoveAsync(job.Id); + + Assert.Equal(job.Id, requeuedJobId); + Assert.Equal(MoveJobStatus.Queued, job.Status); + Assert.Equal(MoveJobPhase.Published, job.Phase); + Assert.Equal(MoveFailureKind.None, job.FailureKind); + Assert.Null(job.Error); + persistence.Verify(store => store.RequeueAsync( + It.Is(command => + command.JobId == job.Id + && command.ExpectedStatus == MoveJobStatus.NeedsAttention), + It.IsAny()), Times.Once); + } + [Fact] public async Task RequeueMoveAsync_NeedsAttentionVerificationWithValidEvidence_RemainsOperatorRepairOnly() { diff --git a/tests/Features/Application/Audiobooks/Jobs/MoveRecoveryPolicyTests.cs b/tests/Features/Application/Audiobooks/Jobs/MoveRecoveryPolicyTests.cs index 7c61dbc9d..f69190264 100644 --- a/tests/Features/Application/Audiobooks/Jobs/MoveRecoveryPolicyTests.cs +++ b/tests/Features/Application/Audiobooks/Jobs/MoveRecoveryPolicyTests.cs @@ -42,6 +42,77 @@ public void ClassifyAudiobookJobs_PreMutationHistoricalFailure_DoesNotBlock() Assert.False(state.CanRetry); } + [Fact] + public void ClassifyAudiobookJobs_MarkerlessNeedsAttentionUnknownWithCompletedRecoveryEvidence_IsRetryable() + { + var job = CreateJob( + MoveJobStatus.NeedsAttention, + MoveJobPhase.Published, + MoveFailureKind.Unknown, + MoveJobEntryCopyState.Verified, + MoveJobEntryCleanupState.Deleted); + job.ExecutionProtocolVersion = MoveExecutionProtocol.MarkerlessDatabaseState; + job.SourceDirectoryCleanupState = MoveJobEntryCleanupState.Deleted; + job.TargetDirectoryObjectIdentity = "target-generation"; + var targetSemantics = FileSystemPathSemantics.CurrentHostDefault; + job.SetTargetIdentity(new PathIdentitySnapshot( + targetSemantics.Syntax, + targetSemantics.CaseSensitivity, + FileSystemCaseSensitivityMode.Auto, + Path.GetPathRoot(job.RequestedPath!)!)); + job.CreatedDirectories = + [ + new MoveJobCreatedDirectory + { + Path = job.RequestedPath!, + State = MoveCreatedDirectoryState.Created, + DirectoryObjectIdentity = job.TargetDirectoryObjectIdentity + } + ]; + + var state = MoveRecoveryPolicy.ClassifyAudiobookJobs([job]); + + Assert.Equal(MoveRecoveryDisposition.RetryAvailable, state.Disposition); + Assert.True(state.BlocksFilesystemMutation); + Assert.True(state.CanRetry); + Assert.Equal(job.Id, state.JobId); + } + + [Fact] + public void ClassifyAudiobookJobs_MarkerlessNeedsAttentionUnknownWithoutExactTargetGeneration_IsOperatorRepairOnly() + { + var job = CreateJob( + MoveJobStatus.NeedsAttention, + MoveJobPhase.Published, + MoveFailureKind.Unknown, + MoveJobEntryCopyState.Verified, + MoveJobEntryCleanupState.Deleted); + job.ExecutionProtocolVersion = MoveExecutionProtocol.MarkerlessDatabaseState; + job.SourceDirectoryCleanupState = MoveJobEntryCleanupState.Deleted; + job.TargetDirectoryObjectIdentity = "expected-generation"; + var targetSemantics = FileSystemPathSemantics.CurrentHostDefault; + job.SetTargetIdentity(new PathIdentitySnapshot( + targetSemantics.Syntax, + targetSemantics.CaseSensitivity, + FileSystemCaseSensitivityMode.Auto, + Path.GetPathRoot(job.RequestedPath!)!)); + job.CreatedDirectories = + [ + new MoveJobCreatedDirectory + { + Path = job.RequestedPath!, + State = MoveCreatedDirectoryState.Created, + DirectoryObjectIdentity = "different-generation" + } + ]; + + var state = MoveRecoveryPolicy.ClassifyAudiobookJobs([job]); + + Assert.Equal(MoveRecoveryDisposition.OperatorRepairRequired, state.Disposition); + Assert.True(state.BlocksFilesystemMutation); + Assert.False(state.CanRetry); + } + [Fact] public void ClassifyAudiobookJobs_NeedsAttentionVerification_IsOperatorRepairOnly() { diff --git a/tests/Features/Infrastructure/FileSystem/FileMoverMarkerlessMoveTests.cs b/tests/Features/Infrastructure/FileSystem/FileMoverMarkerlessMoveTests.cs index a42a443be..2c20979c6 100644 --- a/tests/Features/Infrastructure/FileSystem/FileMoverMarkerlessMoveTests.cs +++ b/tests/Features/Infrastructure/FileSystem/FileMoverMarkerlessMoveTests.cs @@ -9,6 +9,50 @@ namespace Listenarr.Tests.Features.Infrastructure.FileSystem; [Trait("Category", "Infrastructure")] public sealed class FileMoverMarkerlessMoveTests : BaseTests { + [Fact] + public async Task MoveFileAsync_NativeRename_PersistsHashlessSourceProof() + { + var scenario = await CreateScenarioAsync(); + + Assert.True(await CreateMover().MoveFileAsync( + scenario.Source, + scenario.Destination, + scenario.OperationId)); + + var factory = _provider.GetRequiredService< + IDbContextFactory>(); + await using var db = await factory.CreateDbContextAsync(); + var journal = await db.FileMutationJournals + .AsNoTracking() + .SingleAsync(candidate => candidate.OperationId == scenario.OperationId); + Assert.Equal(FileMutationJournalState.Completed, journal.State); + Assert.Null(journal.SourceSha256); + Assert.Equal(scenario.SourceIdentity, journal.SourcePhysicalObjectIdentity); + Assert.Equal(scenario.SourceIdentity, journal.TargetPhysicalObjectIdentity); + AssertNoLibraryArtifacts(scenario.Root); + } + + [Fact] + public async Task MoveFileAsync_CopyFallback_PersistsContentHash() + { + var scenario = await CreateScenarioAsync(); + + Assert.True(await CreateMover(disableNativeRename: true).MoveFileAsync( + scenario.Source, + scenario.Destination, + scenario.OperationId)); + + var factory = _provider.GetRequiredService< + IDbContextFactory>(); + await using var db = await factory.CreateDbContextAsync(); + var journal = await db.FileMutationJournals + .AsNoTracking() + .SingleAsync(candidate => candidate.OperationId == scenario.OperationId); + Assert.Equal(FileMutationJournalState.Completed, journal.State); + Assert.Matches("^[0-9A-F]{64}$", journal.SourceSha256 ?? string.Empty); + AssertNoLibraryArtifacts(scenario.Root); + } + [Fact] public async Task MoveFileAsync_NativeRenameBeforeTargetStateCommitResumes() { diff --git a/tests/Features/Infrastructure/FileSystem/FileMoverMarkerlessRegistrationTests.cs b/tests/Features/Infrastructure/FileSystem/FileMoverMarkerlessRegistrationTests.cs index b9d677e7a..227390d14 100644 --- a/tests/Features/Infrastructure/FileSystem/FileMoverMarkerlessRegistrationTests.cs +++ b/tests/Features/Infrastructure/FileSystem/FileMoverMarkerlessRegistrationTests.cs @@ -63,6 +63,33 @@ await AssertJournalStateAsync( AssertNoLibraryArtifacts(scenario.Root); } + [Fact] + public async Task PrepareHardlinkCopy_SameVolumePersistsHashlessSourceProof() + { + var scenario = await CreateScenarioAsync("registration-hardlink-hashless"); + var mover = CreateMover(); + + using var lease = await mover.PrepareActionForRegistrationAsync( + FileAction.HardlinkCopy, + scenario.Source, + scenario.Destination, + scenario.OperationId); + + Assert.NotNull(lease); + var factory = _provider.GetRequiredService< + IDbContextFactory>(); + await using var db = await factory.CreateDbContextAsync(); + var journal = await db.FileMutationJournals + .AsNoTracking() + .SingleAsync(candidate => candidate.OperationId == scenario.OperationId); + Assert.Equal(FileMutationJournalState.TargetVerified, journal.State); + Assert.Null(journal.SourceSha256); + Assert.Equal( + journal.SourcePhysicalObjectIdentity, + journal.TargetPhysicalObjectIdentity); + AssertNoLibraryArtifacts(scenario.Root); + } + [Theory] [InlineData(FileAction.Copy)] [InlineData(FileAction.HardlinkCopy)] diff --git a/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceOwnershipMarkerRecoveryTests.cs b/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceOwnershipMarkerRecoveryTests.cs index 43a1702d6..7d2281463 100644 --- a/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceOwnershipMarkerRecoveryTests.cs +++ b/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceOwnershipMarkerRecoveryTests.cs @@ -1,3 +1,4 @@ +using Listenarr.Tests.Common; using Microsoft.EntityFrameworkCore; namespace Listenarr.Tests.Features.Infrastructure.Library.Moving; @@ -67,6 +68,41 @@ public async Task MoveContentsAsync_InterruptedTempOwnershipPublication_RetriesW Assert.True(result.SourceCleanupCompleted); } + [WindowsFact] + public async Task MoveContentsAsync_RecoveredOwnershipWrite_DoesNotMutateAttributesBeforePinnedPublication() + { + var source = FileService.GetTempDirectory( + "content-move-recovered-marker-attributes-src"); + await FileService.GetFileAsync(source, "book.m4b", "verified audio"); + var target = Path.Join( + FileService.GetTempPath(), + $"content-move-recovered-marker-attributes-dst-{Guid.NewGuid():N}"); + var request = await CreateLeasedMoveRequestAsync(source, target); + var faultingService = CreateOwnershipFaultingService( + OwnershipMarkerKind.TemporaryDirectory); + await Assert.ThrowsAnyAsync(() => + faultingService.MoveContentsAsync(request, CancellationToken.None)); + var tempDirectory = Path.Join( + Path.GetDirectoryName(target)!, + Path.GetFileName(target) + ".tmp-" + request.JobId.ToString("N")); + var writePath = Assert.Single(Directory.EnumerateFiles( + tempDirectory, + ".listenarr-temp-owner.json.writing-*")); + File.SetAttributes(writePath, FileAttributes.Normal); + var recoveryService = CreateMoveService( + new SingleOwnershipPublicationFaultInjector( + OwnershipMarkerKind.TemporaryDirectory, + OwnershipMarkerWriteFaultPoint.BeforeRecoveredPublication)); + + await Assert.ThrowsAsync(() => + recoveryService.MoveContentsAsync(request, CancellationToken.None)); + + Assert.False( + File.GetAttributes(writePath).HasFlag(FileAttributes.Hidden)); + Assert.True(File.Exists(Path.Join(source, "book.m4b"))); + Assert.False(File.Exists(Path.Join(tempDirectory, ".listenarr-temp-owner.json"))); + } + [Fact] public async Task MoveContentsAsync_ReplacedRecoveredOwnershipWrite_IsPreserved() { diff --git a/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceTests.cs b/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceTests.cs index 4826d91eb..fe4e8f780 100644 --- a/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceTests.cs +++ b/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceTests.cs @@ -1623,6 +1623,281 @@ await service.CleanupCompletedMoveArtifactsAsync( directory.State)); } + [Fact] + public async Task MoveContentsAsync_MarkerlessOwnedSource_RetiresDurableOwnership() + { + var root = FileService.GetTempDirectory( + "content-move-markerless-owned-source-root"); + var source = Path.Join(root, "Owned Book"); + Directory.CreateDirectory(source); + await FileService.GetFileAsync(source, "book.m4b", "audio"); + var target = Path.Join(root, "destination", "Book"); + var ownershipStore = _provider.GetRequiredService(); + var ownership = await ownershipStore.RecordCreatedAsync( + new LibraryDirectoryOwnershipClaim( + source, + FileSystemPathSemantics.CurrentHostDefault, + "rename", + AudiobookId: 98)); + var request = await CreateLeasedMoveRequestAsync( + source, + target, + sourceCleanupBoundary: root, + executionProtocolVersion: + MoveExecutionProtocol.MarkerlessDatabaseState); + var service = _provider.GetRequiredService(); + + var result = await service.MoveContentsAsync( + request, + CancellationToken.None); + + Assert.True(result.SourceCleanupCompleted); + Assert.False(Directory.Exists(source)); + Assert.Equal("audio", await File.ReadAllTextAsync( + Path.Join(target, "book.m4b"))); + var resolution = await ownershipStore.ResolveOwnedAsync( + source, + FileSystemPathSemantics.CurrentHostDefault); + Assert.Equal( + LibraryDirectoryOwnershipResolutionState.Unowned, + resolution.State); + var factory = _provider.GetRequiredService< + IDbContextFactory>(); + await using var db = await factory.CreateDbContextAsync(); + var persisted = await db.LibraryDirectoryOwnerships + .AsNoTracking() + .SingleAsync(candidate => candidate.Id == ownership.Id); + Assert.Equal(LibraryDirectoryOwnershipState.Removed, persisted.State); + Assert.Null(persisted.PathOwnershipKey); + Assert.Null(persisted.ManagedRootFolderId); + AssertNoListenarrArtifacts(root); + } + + [Fact] + public async Task MoveContentsAsync_MarkerlessOwnedSource_MarkRemovedFailureResumesFromDurableIntents() + { + var root = FileService.GetTempDirectory( + "content-move-markerless-owned-source-retry-root"); + var source = Path.Join(root, "Owned Book"); + Directory.CreateDirectory(source); + await FileService.GetFileAsync(source, "book.m4b", "audio"); + var target = Path.Join(root, "destination", "Book"); + var ownershipStore = _provider.GetRequiredService(); + var ownership = await ownershipStore.RecordCreatedAsync( + new LibraryDirectoryOwnershipClaim( + source, + FileSystemPathSemantics.CurrentHostDefault, + "rename", + AudiobookId: 98)); + var request = await CreateLeasedMoveRequestAsync( + source, + target, + sourceCleanupBoundary: root, + executionProtocolVersion: + MoveExecutionProtocol.MarkerlessDatabaseState); + var factory = _provider.GetRequiredService< + IDbContextFactory>(); + var interruptedService = new AudiobookContentMoveService( + NullLogger.Instance, + factory, + TimeProvider.System, + directoryOwnershipStore: + new FailingMarkRemovedOwnershipStore(ownershipStore)); + + await Assert.ThrowsAsync(() => + interruptedService.MoveContentsAsync( + request, + CancellationToken.None)); + + Assert.False(Directory.Exists(source)); + Assert.Equal("audio", await File.ReadAllTextAsync( + Path.Join(target, "book.m4b"))); + await using (var interruptedDb = await factory.CreateDbContextAsync()) + { + var interruptedOwnership = await interruptedDb + .LibraryDirectoryOwnerships + .AsNoTracking() + .SingleAsync(candidate => candidate.Id == ownership.Id); + var interruptedJob = await interruptedDb.MoveJobs + .AsNoTracking() + .SingleAsync(candidate => candidate.Id == request.JobId); + Assert.Equal( + LibraryDirectoryOwnershipState.Removing, + interruptedOwnership.State); + Assert.Equal( + MoveJobEntryCleanupState.DeletionAuthorized, + interruptedJob.SourceDirectoryCleanupState); + } + + var service = _provider.GetRequiredService(); + var result = await service.MoveContentsAsync( + request, + CancellationToken.None); + await service.FinalizeMoveAsync( + request, + result, + CancellationToken.None); + await service.CleanupCompletedMoveArtifactsAsync( + request, + result, + CancellationToken.None); + + await using var verification = await factory.CreateDbContextAsync(); + var retired = await verification.LibraryDirectoryOwnerships + .AsNoTracking() + .SingleAsync(candidate => candidate.Id == ownership.Id); + var completedJob = await verification.MoveJobs + .AsNoTracking() + .SingleAsync(candidate => candidate.Id == request.JobId); + Assert.Equal(LibraryDirectoryOwnershipState.Removed, retired.State); + Assert.Null(retired.PathOwnershipKey); + Assert.Null(retired.ManagedRootFolderId); + Assert.Equal( + MoveJobEntryCleanupState.Deleted, + completedJob.SourceDirectoryCleanupState); + AssertNoListenarrArtifacts(root); + } + + [Fact] + public async Task MoveContentsAsync_MarkerlessRecreatedTarget_RetiresStaleOwnershipAndCompletesRecovery() + { + var root = FileService.GetTempDirectory( + "content-move-markerless-stale-target-root"); + var source = Path.Join(root, "source"); + Directory.CreateDirectory(source); + await FileService.GetFileAsync(source, "book.m4b", "audio"); + var target = Path.Join(root, "destination", "Book"); + Directory.CreateDirectory(target); + var ownershipStore = _provider.GetRequiredService(); + var staleOwnership = await ownershipStore.RecordCreatedAsync( + new LibraryDirectoryOwnershipClaim( + target, + FileSystemPathSemantics.CurrentHostDefault, + "rename", + AudiobookId: 98)); + Directory.Delete(target, recursive: false); + var factory = _provider.GetRequiredService< + IDbContextFactory>(); + await using (var db = await factory.CreateDbContextAsync()) + { + var stale = await db.LibraryDirectoryOwnerships.SingleAsync( + candidate => candidate.Id == staleOwnership.Id); + stale.DirectoryObjectIdentityUnavailableReason = + "The owned directory and its recovery quarantine are missing."; + stale.StateReason = + "Physical directory ownership could not be reconciled safely."; + await db.SaveChangesAsync(); + } + var request = await CreateLeasedMoveRequestAsync( + source, + target, + sourceCleanupBoundary: root, + executionProtocolVersion: + MoveExecutionProtocol.MarkerlessDatabaseState); + // Simulate a job produced by the pre-fix markerless worker: the move + // creates and proves the replacement target generation but does not retire + // the stale ownership row before source cleanup completes. + var preFixService = new AudiobookContentMoveService( + NullLogger.Instance, + factory, + TimeProvider.System, + directoryOwnershipStore: + new SuppressMarkerlessReplacementRetirementOwnershipStore( + ownershipStore)); + var result = await preFixService.MoveContentsAsync( + request, + CancellationToken.None); + await using (var stuckDb = await factory.CreateDbContextAsync()) + { + var stale = await stuckDb.LibraryDirectoryOwnerships + .AsNoTracking() + .SingleAsync(candidate => candidate.Id == staleOwnership.Id); + var createdTarget = await stuckDb.MoveJobCreatedDirectories + .AsNoTracking() + .SingleAsync(directory => + directory.MoveJobId == request.JobId + && directory.Path == target); + var stuckJob = await stuckDb.MoveJobs + .AsNoTracking() + .SingleAsync(candidate => candidate.Id == request.JobId); + Assert.Equal(LibraryDirectoryOwnershipState.Owned, stale.State); + Assert.False(string.IsNullOrWhiteSpace( + stale.DirectoryObjectIdentityUnavailableReason)); + Assert.Equal(MoveCreatedDirectoryState.Created, createdTarget.State); + Assert.Equal( + createdTarget.DirectoryObjectIdentity, + stuckJob.TargetDirectoryObjectIdentity); + } + + var service = _provider.GetRequiredService(); + result = await service.ResumeSourceCleanupAsync( + request, + result, + CancellationToken.None); + await service.FinalizeMoveAsync( + request, + result, + CancellationToken.None); + await service.CleanupCompletedMoveArtifactsAsync( + request, + result, + CancellationToken.None); + + Assert.False(Directory.Exists(source)); + Assert.Equal("audio", await File.ReadAllTextAsync( + Path.Join(target, "book.m4b"))); + await using var verification = await factory.CreateDbContextAsync(); + var retired = await verification.LibraryDirectoryOwnerships + .AsNoTracking() + .SingleAsync(candidate => candidate.Id == staleOwnership.Id); + Assert.Equal(LibraryDirectoryOwnershipState.Removed, retired.State); + Assert.Null(retired.PathOwnershipKey); + Assert.Null(retired.ManagedRootFolderId); + AssertNoListenarrArtifacts(root); + } + + [Fact] + public async Task CleanupTerminalTargetScaffoldingAsync_MarkerlessTargetWithContent_DoesNotRequireLegacyMarker() + { + var root = FileService.GetTempDirectory( + "content-move-markerless-terminal-cleanup-root"); + var source = Path.Join(root, "source"); + Directory.CreateDirectory(source); + await FileService.GetFileAsync(source, "book.m4b", "audio"); + var target = Path.Join(root, "destination", "Book"); + var request = await CreateLeasedMoveRequestAsync( + source, + target, + sourceCleanupBoundary: root, + executionProtocolVersion: + MoveExecutionProtocol.MarkerlessDatabaseState); + var service = _provider.GetRequiredService(); + + _ = await service.MoveContentsAsync( + request, + CancellationToken.None); + await service.CleanupTerminalTargetScaffoldingAsync( + request, + CancellationToken.None); + + Assert.Equal("audio", await File.ReadAllTextAsync( + Path.Join(target, "book.m4b"))); + AssertNoListenarrArtifacts(root); + var factory = _provider.GetRequiredService< + IDbContextFactory>(); + await using var db = await factory.CreateDbContextAsync(); + var directories = await db.MoveJobCreatedDirectories + .AsNoTracking() + .Where(directory => directory.MoveJobId == request.JobId) + .ToListAsync(); + Assert.NotEmpty(directories); + Assert.All( + directories, + directory => Assert.Equal( + MoveCreatedDirectoryState.Retained, + directory.State)); + } + [Fact] public async Task MoveContentsAsync_MarkerlessPlanWithoutHashes_PersistsSourceProofBeforePublication() { @@ -2024,6 +2299,109 @@ await Assert.ThrowsAsync(() => AssertNoListenarrArtifacts(root); } + [Fact] + public async Task MoveContentsAsync_MarkerlessTargetReplacedBeforeMetadataPreservation_DoesNotMutateReplacement() + { + var root = FileService.GetTempDirectory( + "content-move-markerless-metadata-replacement-root"); + var source = Path.Join(root, "source"); + Directory.CreateDirectory(source); + var sourceFile = await FileService.GetFileAsync( + source, + "book.m4b", + "audio"); + var sourceTimestamp = new DateTime( + 2020, + 1, + 2, + 3, + 4, + 5, + DateTimeKind.Utc); + File.SetLastWriteTimeUtc(sourceFile, sourceTimestamp); + var target = Path.Join(root, "destination", "Book"); + var targetFile = Path.Join(target, "book.m4b"); + var replacementTimestamp = new DateTime( + 2021, + 6, + 7, + 8, + 9, + 10, + DateTimeKind.Utc); + var request = await CreateLeasedMoveRequestAsync( + source, + target, + sourceCleanupBoundary: root, + executionProtocolVersion: + MoveExecutionProtocol.MarkerlessDatabaseState); + var injector = new ReplaceMarkerlessTargetAfterWrite( + targetFile, + replacementTimestamp); + var service = new AudiobookContentMoveService( + _provider.GetRequiredService< + ILogger>(), + _provider.GetRequiredService< + IDbContextFactory>(), + TimeProvider.System, + injector); + + var exception = await Record.ExceptionAsync(() => + service.MoveContentsAsync(request, CancellationToken.None)); + + Assert.NotNull(exception); + Assert.True(exception is IOException or InvalidOperationException); + Assert.True(injector.Replaced); + Assert.Equal("replacement", await File.ReadAllTextAsync(targetFile)); + Assert.Equal( + replacementTimestamp, + File.GetLastWriteTimeUtc(targetFile)); + Assert.True(File.Exists(injector.DisplacedPath)); + } + + [Fact] + public async Task MoveContentsAsync_MarkerlessMetadataPreservationFailure_RemainsNonFatal() + { + var root = FileService.GetTempDirectory( + "content-move-markerless-metadata-nonfatal-root"); + var source = Path.Join(root, "source"); + Directory.CreateDirectory(source); + await FileService.GetFileAsync(source, "book.m4b", "audio"); + var target = Path.Join(root, "destination", "Book"); + var targetFile = Path.Join(target, "book.m4b"); + var request = await CreateLeasedMoveRequestAsync( + source, + target, + sourceCleanupBoundary: root, + executionProtocolVersion: + MoveExecutionProtocol.MarkerlessDatabaseState); + var injector = new FailMarkerlessMetadataPreservationOnce(); + var service = new AudiobookContentMoveService( + _provider.GetRequiredService< + ILogger>(), + _provider.GetRequiredService< + IDbContextFactory>(), + TimeProvider.System, + injector); + + var result = await service.MoveContentsAsync( + request, + CancellationToken.None); + await service.FinalizeMoveAsync( + request, + result, + CancellationToken.None); + await service.CleanupCompletedMoveArtifactsAsync( + request, + result, + CancellationToken.None); + + Assert.True(injector.Triggered); + Assert.Equal("audio", await File.ReadAllTextAsync(targetFile)); + Assert.False(Directory.Exists(source)); + AssertNoListenarrArtifacts(root); + } + [Fact] public async Task MoveContentsAsync_MarkerlessRetryAfterTargetFileStateUpdate_Completes() { @@ -3168,6 +3546,81 @@ private static bool IsTestReservedMoveArtifact(string name) => || name.Contains(".listenarr-", StringComparison.Ordinal) && name.EndsWith(".partial", StringComparison.Ordinal); + private sealed class SuppressMarkerlessReplacementRetirementOwnershipStore( + ILibraryDirectoryOwnershipStore inner) : ILibraryDirectoryOwnershipStore + { + public Task RecordCreatedAsync( + LibraryDirectoryOwnershipClaim claim, + CancellationToken cancellationToken = default) => + inner.RecordCreatedAsync(claim, cancellationToken); + + public Task> EnsureCreatedHierarchyAsync( + string destinationDirectory, + string managedBoundary, + FileSystemPathSemantics semantics, + string creationWorkflow, + Guid? creationOperationId = null, + int? audiobookId = null, + CancellationToken cancellationToken = default) => + inner.EnsureCreatedHierarchyAsync( + destinationDirectory, + managedBoundary, + semantics, + creationWorkflow, + creationOperationId, + audiobookId, + cancellationToken); + + public Task ResolveOwnedAsync( + string path, + FileSystemPathSemantics semantics, + CancellationToken cancellationToken = default) => + inner.ResolveOwnedAsync(path, semantics, cancellationToken); + + public Task> GetOwnedWithinAsync( + string basePath, + FileSystemPathSemantics semantics, + CancellationToken cancellationToken = default) => + inner.GetOwnedWithinAsync(basePath, semantics, cancellationToken); + + public Task TryRetireReplacedByMarkerlessMoveAsync( + string path, + FileSystemPathSemantics semantics, + Guid moveJobId, + string replacementDirectoryObjectIdentity, + CancellationToken cancellationToken = default) => + Task.FromResult(false); + + public Task BeginRemovalAsync( + long ownershipId, + string expectedOwnershipKey, + CancellationToken cancellationToken = default) => + inner.BeginRemovalAsync( + ownershipId, + expectedOwnershipKey, + cancellationToken); + + public Task RetainAsync( + long ownershipId, + string expectedOwnershipKey, + string? reason = null, + CancellationToken cancellationToken = default) => + inner.RetainAsync( + ownershipId, + expectedOwnershipKey, + reason, + cancellationToken); + + public Task MarkRemovedAsync( + long ownershipId, + string expectedOwnershipKey, + CancellationToken cancellationToken = default) => + inner.MarkRemovedAsync( + ownershipId, + expectedOwnershipKey, + cancellationToken); + } + private sealed class FailingMarkRemovedOwnershipStore( ILibraryDirectoryOwnershipStore inner) : ILibraryDirectoryOwnershipStore { @@ -3205,6 +3658,19 @@ public Task> GetOwnedWithinAsync( CancellationToken cancellationToken = default) => inner.GetOwnedWithinAsync(basePath, semantics, cancellationToken); + public Task TryRetireReplacedByMarkerlessMoveAsync( + string path, + FileSystemPathSemantics semantics, + Guid moveJobId, + string replacementDirectoryObjectIdentity, + CancellationToken cancellationToken = default) => + inner.TryRetireReplacedByMarkerlessMoveAsync( + path, + semantics, + moveJobId, + replacementDirectoryObjectIdentity, + cancellationToken); + public Task BeginRemovalAsync( long ownershipId, string expectedOwnershipKey, @@ -3356,6 +3822,54 @@ public void OnCopyMutation( } } + private sealed class FailMarkerlessMetadataPreservationOnce + : IMoveFaultInjector + { + private int _triggered; + + public bool Triggered => Volatile.Read(ref _triggered) != 0; + + public void OnCopyMutation( + Guid jobId, + CopyMutationFaultPoint faultPoint) + { + if (faultPoint + == CopyMutationFaultPoint.BeforeMarkerlessMetadataPreservation + && Interlocked.Exchange(ref _triggered, 1) == 0) + { + throw new IOException( + "Injected non-fatal markerless metadata preservation failure."); + } + } + } + + private sealed class ReplaceMarkerlessTargetAfterWrite( + string targetFile, + DateTime replacementTimestamp) : IMoveFaultInjector + { + private int _replaced; + + public string DisplacedPath { get; } = targetFile + ".displaced"; + + public bool Replaced => Volatile.Read(ref _replaced) != 0; + + public void OnCopyMutation( + Guid jobId, + CopyMutationFaultPoint faultPoint) + { + if (faultPoint != CopyMutationFaultPoint + .AfterMarkerlessFileWriteBeforePublishedState + || Interlocked.Exchange(ref _replaced, 1) != 0) + { + return; + } + + File.Move(targetFile, DisplacedPath); + File.WriteAllText(targetFile, "replacement"); + File.SetLastWriteTimeUtc(targetFile, replacementTimestamp); + } + } + private sealed class FailOnceAtTargetScaffoldPreparationPoint( TargetScaffoldPreparationFaultPoint expectedPoint) : IMoveFaultInjector { diff --git a/tests/Features/Infrastructure/Library/Moving/RootFolderRelocationServiceTests.cs b/tests/Features/Infrastructure/Library/Moving/RootFolderRelocationServiceTests.cs index 39ac602f6..ade6cf9f8 100644 --- a/tests/Features/Infrastructure/Library/Moving/RootFolderRelocationServiceTests.cs +++ b/tests/Features/Infrastructure/Library/Moving/RootFolderRelocationServiceTests.cs @@ -2359,6 +2359,226 @@ public async Task ReauthorizeLegacyTarget_RequestCancelledAfterAuthorization_Com Assert.Null(relocationAfter.ActiveRootFolderId); } + [Fact] + public async Task MetadataOnly_ExternallyRenamedOwnedTree_DoesNotRequireOldSourcePathForFreshMarkerlessCleanup() + { + var source = Path.Join( + TempRoot, + $"metadata-markerless-renamed-source-{Guid.NewGuid():N}"); + var target = Path.Join( + TempRoot, + $"metadata-markerless-renamed-target-{Guid.NewGuid():N}"); + var sourceOwned = Path.Join(source, "Author", "Book B012345678"); + var targetOwned = Path.Join(target, "Author", "Book B012345678"); + Directory.CreateDirectory(sourceOwned); + await File.WriteAllTextAsync(Path.Join(sourceOwned, "01.m4b"), "audio"); + var semantics = await new FileSystemSemanticsResolver() + .ResolveAsync(source); + Assert.Equal(PathIdentityState.Valid, semantics.State); + var rootObjectIdentity = await new DirectoryObjectIdentityResolver() + .ResolveAsync(source); + Assert.True(rootObjectIdentity.IsAvailable); + var ownershipToken = Guid.NewGuid().ToString("N"); + string ownershipIdentity; + using (var ownedAnchor = PinnedDirectoryCreation.OpenPinnedBoundary(sourceOwned)) + { + ownershipIdentity = ManagedDirectoryIdentity.Create( + ownershipToken, + ownedAnchor.GetDirectoryObjectIdentity()); + } + + int rootId; + await using (var db = await _factory.CreateDbContextAsync()) + { + var root = new RootFolder + { + Name = "Library", + Path = source, + CaseSensitivityMode = FileSystemCaseSensitivityMode.Auto, + ResolvedCaseSensitivity = semantics.Semantics.CaseSensitivity, + PathIdentityState = PathIdentityState.Valid, + PathIdentityKey = FileSystemPathIdentity.CreateKey( + "root", + source, + semantics.Semantics), + DirectoryObjectIdentityVersion = rootObjectIdentity.Version, + DirectoryObjectIdentity = rootObjectIdentity.Value + }; + var audiobook = new Audiobook + { + Title = "Book", + BasePath = sourceOwned + }; + db.RootFolders.Add(root); + db.Audiobooks.Add(audiobook); + await db.SaveChangesAsync(); + rootId = root.Id; + db.LibraryDirectoryOwnerships.Add(new LibraryDirectoryOwnership + { + Path = sourceOwned, + CanonicalPath = sourceOwned, + PathSyntax = semantics.Semantics.Syntax, + PathCaseSensitivity = semantics.Semantics.CaseSensitivity, + PathCaseSensitivityMode = FileSystemCaseSensitivityMode.Auto, + PathIdentityBoundary = sourceOwned, + PathIdentityLookupKey = FileSystemPathIdentity.CreateLookupKey( + "library-directory", + sourceOwned, + semantics.Semantics.Syntax), + PathOwnershipKey = FileSystemPathIdentity.CreateKey( + "library-directory", + sourceOwned, + semantics.Semantics), + OwnershipToken = ownershipToken, + State = LibraryDirectoryOwnershipState.Owned, + CreationWorkflow = "Test", + AudiobookId = audiobook.Id, + ManagedRootFolderId = root.Id, + DirectoryObjectIdentityVersion = ManagedDirectoryIdentity.CurrentVersion, + DirectoryObjectIdentity = ownershipIdentity + }); + await db.SaveChangesAsync(); + } + + Directory.Move(source, target); + Assert.False(Directory.Exists(source)); + Assert.True(Directory.Exists(targetOwned)); + + var result = await CreateService().StartAsync( + rootId, + new RootFolderPathChangeCommand( + target, + RootFolderRelocationMode.MetadataOnly, + false, + "Renamed Library", + false, + FileSystemCaseSensitivityMode.Auto)); + + Assert.Equal(RootFolderRelocationStatus.Completed, result.Status); + await using var verification = await _factory.CreateDbContextAsync(); + Assert.Equal(target, (await verification.RootFolders.SingleAsync()).Path); + Assert.Equal(targetOwned, (await verification.Audiobooks.SingleAsync()).BasePath); + Assert.Equal( + targetOwned, + (await verification.LibraryDirectoryOwnerships.SingleAsync()).CanonicalPath); + Assert.False(await verification + .LibraryDirectoryOwnershipPathMigrations.AnyAsync()); + Assert.Empty(await verification.RootFolderRelocations.ToListAsync()); + Assert.DoesNotContain( + Directory.EnumerateFileSystemEntries( + target, + "*", + SearchOption.AllDirectories), + path => Path.GetFileName(path).StartsWith( + ".listenarr", + StringComparison.OrdinalIgnoreCase)); + } + + [ReadOnlyBindMountFact] + public async Task MetadataOnly_RealReadOnlyBindMount_UsesDatabaseOnlyOwnershipMigration() + { + var rootPath = Path.GetFullPath( + Environment.GetEnvironmentVariable( + ReadOnlyBindMountFactAttribute.LibraryPathEnvironmentVariable) + ?? throw new InvalidOperationException( + "The read-only library bind mount was not provided.")); + var ownedPath = Path.Join(rootPath, "Author", "Book B012345678"); + Assert.True(Directory.Exists(ownedPath)); + var semantics = await new FileSystemSemanticsResolver() + .ResolveAsync(rootPath); + Assert.Equal(PathIdentityState.Valid, semantics.State); + var rootObjectIdentity = await new DirectoryObjectIdentityResolver() + .ResolveAsync(rootPath); + var ownedObjectIdentity = await new DirectoryObjectIdentityResolver() + .ResolveAsync(ownedPath); + Assert.True(rootObjectIdentity.IsAvailable); + Assert.True(ownedObjectIdentity.IsAvailable); + var ownershipToken = Guid.NewGuid().ToString("N"); + using var ownedAnchor = PinnedDirectoryCreation.OpenPinnedBoundary(ownedPath); + var ownershipIdentity = ManagedDirectoryIdentity.Create( + ownershipToken, + ownedAnchor.GetDirectoryObjectIdentity()); + + int rootId; + await using (var db = await _factory.CreateDbContextAsync()) + { + var root = new RootFolder + { + Name = "Read Only Library", + Path = rootPath, + CaseSensitivityMode = FileSystemCaseSensitivityMode.Auto, + ResolvedCaseSensitivity = semantics.Semantics.CaseSensitivity, + PathIdentityState = PathIdentityState.Valid, + PathIdentityKey = FileSystemPathIdentity.CreateKey( + "root", + rootPath, + semantics.Semantics), + DirectoryObjectIdentityVersion = rootObjectIdentity.Version, + DirectoryObjectIdentity = rootObjectIdentity.Value + }; + var audiobook = new Audiobook + { + Title = "Book", + BasePath = ownedPath + }; + db.RootFolders.Add(root); + db.Audiobooks.Add(audiobook); + await db.SaveChangesAsync(); + rootId = root.Id; + db.LibraryDirectoryOwnerships.Add(new LibraryDirectoryOwnership + { + Path = ownedPath, + CanonicalPath = ownedPath, + PathSyntax = semantics.Semantics.Syntax, + PathCaseSensitivity = semantics.Semantics.CaseSensitivity, + PathCaseSensitivityMode = FileSystemCaseSensitivityMode.Auto, + PathIdentityBoundary = ownedPath, + PathIdentityLookupKey = FileSystemPathIdentity.CreateLookupKey( + "library-directory", + ownedPath, + semantics.Semantics.Syntax), + PathOwnershipKey = FileSystemPathIdentity.CreateKey( + "library-directory", + ownedPath, + semantics.Semantics), + OwnershipToken = ownershipToken, + State = LibraryDirectoryOwnershipState.Owned, + CreationWorkflow = "Test", + AudiobookId = audiobook.Id, + ManagedRootFolderId = root.Id, + DirectoryObjectIdentityVersion = ManagedDirectoryIdentity.CurrentVersion, + DirectoryObjectIdentity = ownershipIdentity + }); + await db.SaveChangesAsync(); + } + + var result = await CreateService().StartAsync( + rootId, + new RootFolderPathChangeCommand( + rootPath, + RootFolderRelocationMode.MetadataOnly, + false, + "Renamed Read Only Library", + false, + FileSystemCaseSensitivityMode.Auto)); + + Assert.Equal(RootFolderRelocationStatus.Completed, result.Status); + await using var verification = await _factory.CreateDbContextAsync(); + Assert.Equal( + "Renamed Read Only Library", + (await verification.RootFolders.SingleAsync()).Name); + Assert.False(await verification + .LibraryDirectoryOwnershipPathMigrations.AnyAsync()); + Assert.DoesNotContain( + Directory.EnumerateFileSystemEntries( + rootPath, + "*", + SearchOption.AllDirectories), + path => Path.GetFileName(path).StartsWith( + ".listenarr", + StringComparison.OrdinalIgnoreCase)); + } + [Fact] public async Task MetadataOnly_PostCommitOwnershipCleanupFailure_ReturnsProtectedAttentionAndRecovers() { @@ -2484,10 +2704,15 @@ await _factory.CreateDbContextAsync()) Assert.Equal("Renamed Library", root.Name); Assert.Equal(targetOwnershipKey, ownership.PathOwnershipKey); Assert.Equal( - LibraryDirectoryOwnershipPathMigrationState.MetadataCommitted, + LibraryDirectoryOwnershipPathMigrationState + .MarkerlessCommitted, journal.State); Assert.Equal(rootId, relocation.ActiveRootFolderId); } + Assert.Empty(Directory.EnumerateFiles( + rootPath, + ".listenarr-*", + SearchOption.AllDirectories)); var retried = await CreateService().RetryAsync( result.RelocationId!.Value); diff --git a/tests/Features/Infrastructure/Persistence/EfMoveQueuePersistenceTests.cs b/tests/Features/Infrastructure/Persistence/EfMoveQueuePersistenceTests.cs index 40d8c0219..12d1095d5 100644 --- a/tests/Features/Infrastructure/Persistence/EfMoveQueuePersistenceTests.cs +++ b/tests/Features/Infrastructure/Persistence/EfMoveQueuePersistenceTests.cs @@ -87,6 +87,60 @@ public async Task SourceCleanupBoundary_RoundTripsWithMoveJob() Assert.Equal("/downloads", persisted!.SourceCleanupBoundary); } + [Fact] + public async Task GetByIdAsync_LoadsCompleteRecoveryAggregate() + { + var persistence = CreatePersistence(); + var sourcePath = Path.GetFullPath(Path.Join( + Path.GetTempPath(), + $"listenarr-recovery-source-{Guid.NewGuid():N}")); + var targetPath = Path.GetFullPath(Path.Join( + Path.GetTempPath(), + $"listenarr-recovery-target-{Guid.NewGuid():N}")); + var semantics = FileSystemPathSemantics.CurrentHostDefault; + var identity = new PathIdentitySnapshot( + semantics.Syntax, + semantics.CaseSensitivity, + FileSystemCaseSensitivityMode.Auto, + Path.GetFullPath(Path.GetTempPath())); + var job = new MoveJob + { + AudiobookId = 42, + SourcePath = sourcePath, + RequestedPath = targetPath, + Status = MoveJobStatus.NeedsAttention, + Phase = MoveJobPhase.Published, + ExecutionProtocolVersion = MoveExecutionProtocol.MarkerlessDatabaseState, + SourceDirectoryCleanupState = MoveJobEntryCleanupState.Deleted, + TargetDirectoryObjectIdentity = "target-generation", + FailureKind = MoveFailureKind.Unknown, + Entries = CreateAuthorizedManifestEntries( + copyState: MoveJobEntryCopyState.Verified, + cleanupState: MoveJobEntryCleanupState.Deleted), + CreatedDirectories = + [ + new MoveJobCreatedDirectory + { + Path = targetPath, + State = MoveCreatedDirectoryState.Created, + DirectoryObjectIdentity = "target-generation" + } + ] + }; + job.SetSourceIdentity(identity); + job.SetTargetIdentity(identity); + + await persistence.AddAsync(job); + var persisted = await persistence.GetByIdAsync(job.Id); + + var loaded = Assert.IsType(persisted); + Assert.Single(loaded.CreatedDirectories); + Assert.Equal(2, loaded.Entries.Count); + Assert.Equal( + MoveRecoveryDisposition.RetryAvailable, + MoveRecoveryPolicy.GetDisposition(loaded)); + } + [Fact] public async Task ReconcileIdentityKeys_SelectsMostAdvancedLegacyDuplicate() { diff --git a/tests/Features/Infrastructure/Repositories/AudiobookRepositoryTests.cs b/tests/Features/Infrastructure/Repositories/AudiobookRepositoryTests.cs index e461170d2..f1536b9a5 100644 --- a/tests/Features/Infrastructure/Repositories/AudiobookRepositoryTests.cs +++ b/tests/Features/Infrastructure/Repositories/AudiobookRepositoryTests.cs @@ -15,7 +15,9 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ +using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; using Listenarr.Infrastructure.Persistence.Repositories; using Listenarr.Tests.Builders; @@ -61,6 +63,65 @@ public async Task GetAll_IncludesWantedFlag_ForMonitoredWithoutFiles() Assert.False(hasFileDto.wanted); } + [Fact] + public async Task GetByIdQueries_DoNotTriggerMultipleCollectionIncludeWarning() + { + await using var connection = new SqliteConnection("DataSource=:memory:"); + await connection.OpenAsync(); + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .ConfigureWarnings(warnings => warnings.Throw( + RelationalEventId.MultipleCollectionIncludeWarning)) + .Options; + await using var db = new ListenArrDbContext(options); + await db.Database.EnsureCreatedAsync(); + + var qualityProfile = new QualityProfile { Name = "Split Query Profile" }; + var audiobook = new Audiobook + { + Title = "Split Query Book", + QualityProfile = qualityProfile, + Files = [new AudiobookFile { Path = "/library/book.m4b" }], + ExternalIdentifiers = + [ + new AudiobookExternalIdentifier + { + Type = AudiobookExternalIdentifierType.Asin, + ValueRaw = "B000SPLIT1", + ValueNormalized = "B000SPLIT1", + Source = AudiobookExternalIdentifierSource.Manual + } + ], + SeriesMemberships = + [ + new AudiobookSeriesMembership + { + SeriesName = "Split Query Series" + } + ] + }; + db.Audiobooks.Add(audiobook); + await db.SaveChangesAsync(); + db.ChangeTracker.Clear(); + var repository = new AudiobookRepository(db); + + var tracked = Assert.IsType( + await repository.GetByIdAsync(audiobook.Id)); + Assert.NotNull(tracked.QualityProfile); + Assert.Single(tracked.Files!); + Assert.Single(tracked.ExternalIdentifiers!); + Assert.Single(tracked.SeriesMemberships!); + + db.ChangeTracker.Clear(); + var snapshot = Assert.IsType( + await repository.GetByIdSnapshotAsync(audiobook.Id)); + Assert.NotNull(snapshot.QualityProfile); + Assert.Single(snapshot.Files!); + Assert.Single(snapshot.ExternalIdentifiers!); + Assert.Single(snapshot.SeriesMemberships!); + Assert.Equal(EntityState.Detached, db.Entry(snapshot).State); + } + [Fact] public async Task GetByIdsWithFilesAsync_ReturnsDetachedPreviewSnapshots() { @@ -144,6 +205,14 @@ public async Task GetForScanAsync_DoesNotLoadUnneededNavigationGraphs() Assert.Null(snapshot.ExternalIdentifiers); Assert.Null(snapshot.SeriesMemberships); Assert.Null(snapshot.QualityProfile); + + var updateSnapshot = Assert.IsType( + await repository.GetForUpdateSnapshotAsync(audiobook.Id)); + Assert.Equal(EntityState.Detached, db.Entry(updateSnapshot).State); + Assert.Null(updateSnapshot.Files); + Assert.Null(updateSnapshot.ExternalIdentifiers); + Assert.Null(updateSnapshot.SeriesMemberships); + Assert.Null(updateSnapshot.QualityProfile); } [Fact] From d7c217b82406246e8085ba20930a7e55ea0278a1 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Thu, 6 Aug 2026 20:22:20 -0400 Subject: [PATCH 409/464] Fix application-owned file move lock storage --- .../Contracts/IApplicationPathService.cs | 7 + .../Paths/ApplicationPathService.cs | 3 + .../FileSystem/FileMover.FileMoveLocks.cs | 17 +-- .../FileSystem/FileMover.cs | 6 +- .../Paths/ApplicationPathServiceTests.cs | 24 ++++ .../FileMoverFileMoveLockDirectoryTests.cs | 126 ++++++++++++++++++ 6 files changed, 171 insertions(+), 12 deletions(-) create mode 100644 tests/Features/Infrastructure/FileSystem/FileMoverFileMoveLockDirectoryTests.cs diff --git a/listenarr.application/Configuration/Contracts/IApplicationPathService.cs b/listenarr.application/Configuration/Contracts/IApplicationPathService.cs index bd280de64..8740a9b91 100644 --- a/listenarr.application/Configuration/Contracts/IApplicationPathService.cs +++ b/listenarr.application/Configuration/Contracts/IApplicationPathService.cs @@ -33,6 +33,13 @@ public interface IApplicationPathService /// Gets the absolute path to the logs directory (ConfigRootPath/logs). string LogsRootPath { get; } + /// + /// Gets the absolute path to the file-move coordination directory + /// (ConfigRootPath/runtime/file-move-locks). Processes that share a + /// Listenarr configuration root share this coordination namespace. + /// + string FileMoveLockRootPath { get; } + /// Gets the absolute path to the bundled FFmpeg directory (ConfigRootPath/ffmpeg). string FfmpegRootPath { get; } diff --git a/listenarr.infrastructure/Configuration/Paths/ApplicationPathService.cs b/listenarr.infrastructure/Configuration/Paths/ApplicationPathService.cs index 512dcb077..f093defcb 100644 --- a/listenarr.infrastructure/Configuration/Paths/ApplicationPathService.cs +++ b/listenarr.infrastructure/Configuration/Paths/ApplicationPathService.cs @@ -35,6 +35,8 @@ public sealed class ApplicationPathService : IApplicationPathService /// public string LogsRootPath { get; } /// + public string FileMoveLockRootPath { get; } + /// public string FfmpegRootPath { get; } /// public string ToolsRootPath { get; } @@ -59,6 +61,7 @@ public ApplicationPathService(string? contentRootPath) ContentRootPath = Path.GetFullPath(contentRoot); ConfigRootPath = ResolveFromContentRoot("config"); LogsRootPath = ResolveFromConfig("logs"); + FileMoveLockRootPath = ResolveFromConfig("runtime", "file-move-locks"); FfmpegRootPath = ResolveFromConfig("ffmpeg"); ToolsRootPath = ResolveFromContentRoot("tools"); DiscordBotRootPath = Path.GetFullPath(FileUtils.CombineRelativePath(ToolsRootPath, "discord-bot")); diff --git a/listenarr.infrastructure/FileSystem/FileMover.FileMoveLocks.cs b/listenarr.infrastructure/FileSystem/FileMover.FileMoveLocks.cs index ac528fd26..31fdbc0e0 100644 --- a/listenarr.infrastructure/FileSystem/FileMover.FileMoveLocks.cs +++ b/listenarr.infrastructure/FileSystem/FileMover.FileMoveLocks.cs @@ -388,18 +388,13 @@ private PinnedDirectoryCreation.PinnedDirectoryAnchor var directory = FileMoveLockDirectoryForTest; if (string.IsNullOrWhiteSpace(directory)) { - var localData = Environment.GetFolderPath( - Environment.SpecialFolder.LocalApplicationData); - if (string.IsNullOrWhiteSpace(localData)) - { - throw new IOException( - "A per-user application-data directory is required for file-move locks."); - } + directory = _applicationPathService.FileMoveLockRootPath; + } - directory = Path.Join( - localData, - "Listenarr", - "file-move-locks"); + if (string.IsNullOrWhiteSpace(directory)) + { + throw new IOException( + "An application-owned directory is required for file-move locks."); } var pinned = PinnedDirectoryCreation.OpenPinnedHierarchyNoFollow( diff --git a/listenarr.infrastructure/FileSystem/FileMover.cs b/listenarr.infrastructure/FileSystem/FileMover.cs index d6ce14074..002da7b32 100644 --- a/listenarr.infrastructure/FileSystem/FileMover.cs +++ b/listenarr.infrastructure/FileSystem/FileMover.cs @@ -57,6 +57,7 @@ public partial class FileMover : IFileMover private readonly ILogger _logger; private readonly IFileSystemSemanticsResolver _semanticsResolver; private readonly IFileMutationJournalStore? _fileMutationJournalStore; + private readonly IApplicationPathService _applicationPathService; internal Func? AfterSourceStateCreatedForTestAsync { get; init; } internal Func? AfterSourceQuarantinedForTestAsync { get; init; } @@ -80,12 +81,15 @@ public FileMover( IOptions? options = null, IFileSystemSemanticsResolver? semanticsResolver = null, IDbContextFactory? dbContextFactory = null, - TimeProvider? timeProvider = null) + TimeProvider? timeProvider = null, + IApplicationPathService? applicationPathService = null) { _logger = logger; _ = processRunner; _ = options; _semanticsResolver = semanticsResolver ?? new FileSystemSemanticsResolver(); + _applicationPathService = applicationPathService + ?? new ApplicationPathService(AppContext.BaseDirectory); _fileMutationJournalStore = dbContextFactory == null ? null : new EfFileMutationJournalStore( diff --git a/tests/Features/Infrastructure/Configuration/Paths/ApplicationPathServiceTests.cs b/tests/Features/Infrastructure/Configuration/Paths/ApplicationPathServiceTests.cs index 0af357d16..48f9dfe9c 100644 --- a/tests/Features/Infrastructure/Configuration/Paths/ApplicationPathServiceTests.cs +++ b/tests/Features/Infrastructure/Configuration/Paths/ApplicationPathServiceTests.cs @@ -24,6 +24,30 @@ namespace Listenarr.Tests.Features.Infrastructure.Configuration.Paths [Trait("Category", "ApplicationPathService")] public class ApplicationPathServiceTests { + [Fact] + [Trait("Method", "Constructor")] + [Trait("Scenario", "ExposesFileMoveLockRootUnderConfigRuntime")] + public void Constructor_ExposesFileMoveLockRootUnderConfigRuntime() + { + var contentRootPath = Path.Join( + Path.GetTempPath(), + "listenarr-path-service-tests", + Guid.NewGuid().ToString("N")); + var service = new ApplicationPathService(contentRootPath); + + Assert.Equal( + Path.GetFullPath(Path.Join( + contentRootPath, + "config", + "runtime", + "file-move-locks")), + service.FileMoveLockRootPath); + Assert.StartsWith( + service.ConfigRootPath, + service.FileMoveLockRootPath, + StringComparison.Ordinal); + } + [Fact] [Trait("Method", "Constructor")] [Trait("Scenario", "ExposesDiscordBotRootPathUnderToolsRoot")] diff --git a/tests/Features/Infrastructure/FileSystem/FileMoverFileMoveLockDirectoryTests.cs b/tests/Features/Infrastructure/FileSystem/FileMoverFileMoveLockDirectoryTests.cs new file mode 100644 index 000000000..93f9d323b --- /dev/null +++ b/tests/Features/Infrastructure/FileSystem/FileMoverFileMoveLockDirectoryTests.cs @@ -0,0 +1,126 @@ +using Listenarr.Tests.Common; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; + +namespace Listenarr.Tests.Features.Infrastructure.FileSystem; + +[Trait("Area", "FileSystem")] +[Trait("Name", "FileMoverFileMoveLockDirectoryTests")] +[Trait("Category", "FileSystem")] +public sealed class FileMoverFileMoveLockDirectoryTests : BaseTests +{ + [Fact] + [Trait("Method", "PerformActionOn")] + [Trait("Scenario", "CreatesApplicationOwnedLockHierarchyFromScratch")] + public async Task PerformActionOn_CreatesApplicationOwnedLockHierarchyFromScratch() + { + var contentRoot = FileService.GetTempDirectory("file-move-lock-app-root"); + var paths = new ApplicationPathService(contentRoot); + var sourceRoot = FileService.GetTempDirectory("file-move-lock-source"); + var source = await FileService.GetFileAsync(sourceRoot, "source.m4b", "audio"); + var destination = Path.Join(sourceRoot, "destination.m4b"); + Assert.False(Directory.Exists(paths.ConfigRootPath)); + Assert.False(Directory.Exists(paths.FileMoveLockRootPath)); + + var mover = CreateMover(paths); + + var result = await mover.PerformActionOn( + FileAction.Copy, + source, + destination); + + Assert.True(result); + Assert.Equal("audio", await File.ReadAllTextAsync(source)); + Assert.Equal("audio", await File.ReadAllTextAsync(destination)); + Assert.True(Directory.Exists(paths.FileMoveLockRootPath)); + Assert.NotEmpty(Directory.EnumerateFiles( + paths.FileMoveLockRootPath, + "stripe-*.lock", + SearchOption.TopDirectoryOnly)); + Assert.Empty(Directory.EnumerateFiles( + sourceRoot, + "stripe-*.lock", + SearchOption.AllDirectories)); + } + + [Fact] + [Trait("Method", "DependencyInjection")] + [Trait("Scenario", "ResolvedMoverUsesRegisteredApplicationLockRoot")] + public async Task DependencyInjection_ResolvedMoverUsesRegisteredApplicationLockRoot() + { + var sourceRoot = FileService.GetTempDirectory("file-move-lock-di-source"); + var source = await FileService.GetFileAsync(sourceRoot, "source.m4b", "audio"); + var destination = Path.Join(sourceRoot, "destination.m4b"); + Assert.False(Directory.Exists(_applicationPathService.FileMoveLockRootPath)); + var mover = _provider.GetRequiredService(); + + var result = await mover.PerformActionOn( + FileAction.Copy, + source, + destination); + + Assert.True(result); + Assert.True(Directory.Exists(_applicationPathService.FileMoveLockRootPath)); + Assert.NotEmpty(Directory.EnumerateFiles( + _applicationPathService.FileMoveLockRootPath, + "stripe-*.lock", + SearchOption.TopDirectoryOnly)); + } + + [Fact] + [Trait("Method", "PerformActionOn")] + [Trait("Scenario", "MissingApplicationLockRootFailsClosed")] + public async Task PerformActionOn_MissingApplicationLockRootFailsClosed() + { + var sourceRoot = FileService.GetTempDirectory("file-move-lock-missing-root"); + var source = await FileService.GetFileAsync(sourceRoot, "source.m4b", "audio"); + var destination = Path.Join(sourceRoot, "destination.m4b"); + var paths = Mock.Of(service => + service.FileMoveLockRootPath == string.Empty); + var mover = CreateMover(paths); + + var result = await mover.PerformActionOn( + FileAction.Copy, + source, + destination); + + Assert.False(result); + Assert.Equal("audio", await File.ReadAllTextAsync(source)); + Assert.False(File.Exists(destination)); + } + + [DirectoryLinkFact] + [Trait("Method", "PerformActionOn")] + [Trait("Scenario", "LinkedApplicationLockAncestorFailsClosed")] + public async Task PerformActionOn_LinkedApplicationLockAncestorFailsClosed() + { + var contentRoot = FileService.GetTempDirectory("file-move-lock-linked-app-root"); + var paths = new ApplicationPathService(contentRoot); + var sourceRoot = FileService.GetTempDirectory("file-move-lock-linked-source"); + var source = await FileService.GetFileAsync(sourceRoot, "source.m4b", "audio"); + var destination = Path.Join(sourceRoot, "destination.m4b"); + var external = FileService.GetTempDirectory("file-move-lock-linked-external"); + Directory.CreateDirectory(paths.ConfigRootPath); + Directory.CreateSymbolicLink( + Path.Join(paths.ConfigRootPath, "runtime"), + external); + var mover = CreateMover(paths); + + var result = await mover.PerformActionOn( + FileAction.Copy, + source, + destination); + + Assert.False(result); + Assert.False(Directory.Exists(Path.Join(external, "file-move-locks"))); + Assert.Equal("audio", await File.ReadAllTextAsync(source)); + Assert.False(File.Exists(destination)); + } + + private static FileMover CreateMover(IApplicationPathService applicationPathService) + => new( + new NullLogger(), + options: Options.Create(new FileMoverOptions { MaxRetries = 1 }), + semanticsResolver: new FileSystemSemanticsResolver(), + applicationPathService: applicationPathService); +} From 89d7d2588954690557eab6d5bf7e293923f7da82 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Thu, 6 Aug 2026 22:21:51 -0400 Subject: [PATCH 410/464] Harden case-sensitive filesystem boundaries --- listenarr.domain/Common/FileUtils.cs | 6 +- .../FileMover.DirectoryCopy.Validation.cs | 12 +-- .../FileSystem/FileMover.DirectoryCopy.cs | 12 ++- .../FileSystem/FileSystemSafety.Deletion.cs | 4 +- .../FileSystem/FileSystemSafety.cs | 90 +++++++++++++++++-- tests/Features/Domain/Utils/FileUtilsTests.cs | 60 +++++++++++++ ...FileMoverDirectoryCopyPathIdentityTests.cs | 27 ++++++ 7 files changed, 186 insertions(+), 25 deletions(-) create mode 100644 tests/Features/Infrastructure/FileSystem/FileMoverDirectoryCopyPathIdentityTests.cs diff --git a/listenarr.domain/Common/FileUtils.cs b/listenarr.domain/Common/FileUtils.cs index aefd40795..f5480b75a 100644 --- a/listenarr.domain/Common/FileUtils.cs +++ b/listenarr.domain/Common/FileUtils.cs @@ -256,7 +256,11 @@ public static bool TryResolveRelativePathWithinBase(string basePath, string rela return false; } - if (ContainsRootedPathSegment(relativePath)) + if (ContainsRootedPathSegment(relativePath) + || relativePath.Split( + [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], + StringSplitOptions.RemoveEmptyEntries) + .Any(segment => segment is "." or "..")) { return false; } diff --git a/listenarr.infrastructure/FileSystem/FileMover.DirectoryCopy.Validation.cs b/listenarr.infrastructure/FileSystem/FileMover.DirectoryCopy.Validation.cs index 3f6212b7b..63aadd7ec 100644 --- a/listenarr.infrastructure/FileSystem/FileMover.DirectoryCopy.Validation.cs +++ b/listenarr.infrastructure/FileSystem/FileMover.DirectoryCopy.Validation.cs @@ -25,9 +25,7 @@ private async Task SourceSnapshotStillMatchesAsync( return false; } - var comparer = OperatingSystem.IsWindows() - ? StringComparer.OrdinalIgnoreCase - : StringComparer.Ordinal; + var comparer = DirectoryCopySnapshotPathComparer; if (!snapshot.RelativeDirectories.SequenceEqual( currentSnapshot.RelativeDirectories, comparer) @@ -94,9 +92,7 @@ private async Task DirectoryCopySnapshotExactlyMatchesAsync( return false; } - var comparer = OperatingSystem.IsWindows() - ? StringComparer.OrdinalIgnoreCase - : StringComparer.Ordinal; + var comparer = DirectoryCopySnapshotPathComparer; var relativeDirectories = candidateDirectories .Select(path => GetVerifiedRelativePath(candidateRoot, path)) .OrderBy(PathDepth) @@ -161,9 +157,7 @@ private async Task TryCleanupDirectoryCopyStagingAsync( return; } - var comparer = OperatingSystem.IsWindows() - ? StringComparer.OrdinalIgnoreCase - : StringComparer.Ordinal; + var comparer = DirectoryCopySnapshotPathComparer; var expectedFiles = snapshot.Files .Select(file => file.RelativePath) .ToHashSet(comparer); diff --git a/listenarr.infrastructure/FileSystem/FileMover.DirectoryCopy.cs b/listenarr.infrastructure/FileSystem/FileMover.DirectoryCopy.cs index ff3bdd2d5..09527c553 100644 --- a/listenarr.infrastructure/FileSystem/FileMover.DirectoryCopy.cs +++ b/listenarr.infrastructure/FileSystem/FileMover.DirectoryCopy.cs @@ -24,6 +24,12 @@ private sealed record DirectoryCopySnapshot( IReadOnlyDictionary DirectoryIdentities, IReadOnlyList Files); + // Snapshot paths are captured lexical evidence. Never fold them by host OS: + // Windows can expose case-sensitive directory namespaces, where names that differ + // only by case identify distinct entries. + internal static StringComparer DirectoryCopySnapshotPathComparer { get; } = + StringComparer.Ordinal; + private static bool TryCaptureDirectoryCopySnapshot( string sourceDirectory, out DirectoryCopySnapshot? snapshot, @@ -232,10 +238,8 @@ private async Task PopulateDirectoryCopyStagingAsync( DirectoryCopySnapshot snapshot, PinnedDirectoryCreation.PinnedDirectoryAnchor stagingAnchor) { - var comparer = OperatingSystem.IsWindows() - ? StringComparer.OrdinalIgnoreCase - : StringComparer.Ordinal; - var anchors = new Dictionary(comparer) + var anchors = new Dictionary( + DirectoryCopySnapshotPathComparer) { [string.Empty] = stagingAnchor }; diff --git a/listenarr.infrastructure/FileSystem/FileSystemSafety.Deletion.cs b/listenarr.infrastructure/FileSystem/FileSystemSafety.Deletion.cs index e83cd51a8..d9bbf84ca 100644 --- a/listenarr.infrastructure/FileSystem/FileSystemSafety.Deletion.cs +++ b/listenarr.infrastructure/FileSystem/FileSystemSafety.Deletion.cs @@ -61,7 +61,7 @@ public static bool TryDeleteEmptyDirectory( roots, out var revalidatedDirectory, out reason) - || !PathComparer.Equals(normalizedDirectory, revalidatedDirectory) + || !StringComparer.Ordinal.Equals(normalizedDirectory, revalidatedDirectory) || !pinnedDirectory.VisiblePathMatches()) { reason = string.IsNullOrWhiteSpace(reason) @@ -153,7 +153,7 @@ public static bool TryDeleteFile( roots, out var revalidatedFile, out reason) - || !PathComparer.Equals(normalizedFile, revalidatedFile) + || !StringComparer.Ordinal.Equals(normalizedFile, revalidatedFile) || !parent.VisiblePathMatches() || !entry.VisiblePathMatches()) { diff --git a/listenarr.infrastructure/FileSystem/FileSystemSafety.cs b/listenarr.infrastructure/FileSystem/FileSystemSafety.cs index caca2e04c..60351197b 100644 --- a/listenarr.infrastructure/FileSystem/FileSystemSafety.cs +++ b/listenarr.infrastructure/FileSystem/FileSystemSafety.cs @@ -89,7 +89,10 @@ public static bool TryValidateMutationTarget( } var normalizedTarget = normalizedPath; - var normalizedRoots = new HashSet(PathComparer); + // Mutation authorization must not assume all Windows directories are + // case-insensitive. Without pinned proof that two differently-cased + // spellings identify the same boundary, fail closed on lexical aliases. + var normalizedRoots = new HashSet(StringComparer.Ordinal); foreach (var root in allowedRoots.Where(root => !string.IsNullOrWhiteSpace(root))) { if (FileSystemPathIdentity.TryCanonicalizeStoredAbsolutePathForHost( @@ -108,7 +111,7 @@ public static bool TryValidateMutationTarget( } var candidateRoots = normalizedRoots - .Where(root => FileUtils.IsPathSameOrInside(normalizedTarget, root)) + .Where(root => IsSameOrInsideMutationBoundary(normalizedTarget, root)) .OrderByDescending(root => root.Length) .ToList(); if (candidateRoots.Count == 0) @@ -234,9 +237,9 @@ private static bool TryValidateResolvedComponents( return false; } - if (!FileUtils.IsPathSameOrInside(existingTargetPath, existingRootPath)) + if (!IsSameOrInsideMutationBoundary(existingTargetPath, existingRootPath)) { - if (FileUtils.IsPathSameOrInside(existingRootPath, existingTargetPath)) + if (IsSameOrInsideMutationBoundary(existingRootPath, existingTargetPath)) { existingTargetPath = existingRootPath; } @@ -269,7 +272,7 @@ private static bool TryValidateResolvedComponents( : null; currentResolvedPath = Path.GetFullPath( resolvedTarget?.FullName ?? Path.Join(currentResolvedPath, segment)); - if (!FileUtils.IsPathSameOrInside(currentResolvedPath, resolvedRootPath)) + if (!IsSameOrInsideMutationBoundary(currentResolvedPath, resolvedRootPath)) { reason = "Target path resolves outside an allowed mutation root through a linked path component."; return false; @@ -336,8 +339,77 @@ private static bool TryResolveExistingFinalPath(string path, out string resolved } } - private static StringComparer PathComparer => - OperatingSystem.IsWindows() - ? StringComparer.OrdinalIgnoreCase - : StringComparer.Ordinal; + internal static bool IsSameOrInsideMutationBoundary( + string candidatePath, + string rootPath, + FileSystemPathSyntax? syntax = null) + { + var effectiveSyntax = syntax + ?? (OperatingSystem.IsWindows() + ? FileSystemPathSyntax.Windows + : FileSystemPathSyntax.Unix); + var sensitiveSemantics = new FileSystemPathSemantics( + effectiveSyntax, + FileSystemCaseSensitivity.Sensitive); + if (FileSystemPathIdentity.IsSameOrInside( + candidatePath, + rootPath, + sensitiveSemantics)) + { + return true; + } + + // A Windows namespace can be case-insensitive or case-sensitive per + // directory. Accept a differently-cased spelling only when the candidate + // prefix that corresponds to the allowed root can be pinned and proven to + // identify the same physical directory. This preserves normal Windows case + // aliases without authorizing a case-distinct sibling on a sensitive parent. + if (effectiveSyntax != FileSystemPathSyntax.Windows + || !OperatingSystem.IsWindows()) + { + return false; + } + + var insensitiveSemantics = new FileSystemPathSemantics( + FileSystemPathSyntax.Windows, + FileSystemCaseSensitivity.Insensitive); + if (!FileSystemPathIdentity.IsSameOrInside( + candidatePath, + rootPath, + insensitiveSemantics)) + { + return false; + } + + try + { + var canonicalRoot = FileSystemPathIdentity.Canonicalize( + rootPath, + FileSystemPathSyntax.Windows); + var canonicalCandidate = FileSystemPathIdentity.Canonicalize( + candidatePath, + FileSystemPathSyntax.Windows); + if (canonicalCandidate.Length < canonicalRoot.Length) + { + return false; + } + + var candidateRootAlias = canonicalCandidate[..canonicalRoot.Length]; + using var expectedRoot = PinnedDirectoryCreation.OpenPinnedHierarchyNoFollow( + canonicalRoot, + createMissing: false); + using var candidateRoot = PinnedDirectoryCreation.OpenPinnedHierarchyNoFollow( + candidateRootAlias, + createMissing: false); + return string.Equals( + expectedRoot.GetDirectoryObjectIdentity(), + candidateRoot.GetDirectoryObjectIdentity(), + StringComparison.Ordinal); + } + catch (Exception exception) when (exception is not ( + OperationCanceledException or OutOfMemoryException or StackOverflowException)) + { + return false; + } + } } diff --git a/tests/Features/Domain/Utils/FileUtilsTests.cs b/tests/Features/Domain/Utils/FileUtilsTests.cs index 4beaa102d..03e87b823 100644 --- a/tests/Features/Domain/Utils/FileUtilsTests.cs +++ b/tests/Features/Domain/Utils/FileUtilsTests.cs @@ -501,6 +501,23 @@ public void TryResolveRelativePathWithinBase_AllowsNestedTempFile() } } + [WindowsFact] + public void TryResolveRelativePathWithinBase_BlocksCaseDistinctSiblingTraversalOnWindows() + { + var parent = Path.Join( + Path.GetTempPath(), + "fu-case-boundary-" + Guid.NewGuid().ToString("N")); + var root = Path.Join(parent, "Library"); + + var ok = FileUtils.TryResolveRelativePathWithinBase( + root, + Path.Join("..", "library", "escape.m4b"), + out var resolved); + + Assert.False(ok); + Assert.Equal(string.Empty, resolved); + } + [Theory] [InlineData("../escape.m4b")] [InlineData("author/../../escape.m4b")] @@ -524,6 +541,49 @@ public void TryResolveRelativePathWithinBase_BlocksTraversalAndRootedSegments(st } } + [Fact] + public void MutationBoundary_WindowsCaseDistinctSibling_IsNotContained() + { + Assert.True(FileSystemSafety.IsSameOrInsideMutationBoundary( + @"c:\Library\Author\Book.m4b", + @"C:\Library\", + FileSystemPathSyntax.Windows)); + Assert.False(FileSystemSafety.IsSameOrInsideMutationBoundary( + @"C:\library\Author\Book.m4b", + @"C:\Library", + FileSystemPathSyntax.Windows)); + } + + [WindowsFact] + public void TryValidateMutationTarget_AllowsCaseAliasOnlyWhenItIsSamePhysicalRoot() + { + var parent = Path.Join( + Path.GetTempPath(), + "fu-mutation-case-alias-" + Guid.NewGuid().ToString("N")); + var root = Path.Join(parent, "LibraryRoot"); + Directory.CreateDirectory(root); + var aliasRoot = Path.Join(parent, "libraryroot"); + + try + { + Assert.True( + Directory.Exists(aliasRoot), + "The Windows temp boundary must expose its normal case-insensitive alias for this regression."); + + var target = Path.Join(aliasRoot, "book.m4b"); + Assert.True(new LocalFileSystem().TryValidateMutationTarget( + target, + [root], + out _, + out var reason), + reason); + } + finally + { + try { Directory.Delete(parent, true); } catch (IOException ex) { System.Diagnostics.Debug.WriteLine(ex.Message); } catch (UnauthorizedAccessException ex) { System.Diagnostics.Debug.WriteLine(ex.Message); } + } + } + [Fact] public void TryValidateMutationTarget_AllowsOnlyConfiguredRoots() { diff --git a/tests/Features/Infrastructure/FileSystem/FileMoverDirectoryCopyPathIdentityTests.cs b/tests/Features/Infrastructure/FileSystem/FileMoverDirectoryCopyPathIdentityTests.cs new file mode 100644 index 000000000..108acaaad --- /dev/null +++ b/tests/Features/Infrastructure/FileSystem/FileMoverDirectoryCopyPathIdentityTests.cs @@ -0,0 +1,27 @@ +using Listenarr.Tests.Common; + +namespace Listenarr.Tests.Features.Infrastructure.FileSystem; + +[Trait("Area", "FileSystem")] +[Trait("Name", "FileMoverDirectoryCopyPathIdentityTests")] +[Trait("Category", "FileSystem")] +public sealed class FileMoverDirectoryCopyPathIdentityTests : BaseTests +{ + [Fact] + [Trait("Method", "DirectoryCopySnapshotPathComparer")] + [Trait("Scenario", "CaseDistinctSnapshotEntriesRemainDistinct")] + public void DirectoryCopySnapshotPathComparer_CaseDistinctSnapshotEntriesRemainDistinct() + { + var comparer = FileMover.DirectoryCopySnapshotPathComparer; + var paths = new HashSet(comparer) + { + Path.Join("Disc", "Track.m4b"), + Path.Join("disc", "track.m4b") + }; + + Assert.Equal(2, paths.Count); + Assert.False(comparer.Equals( + Path.Join("Disc", "Track.m4b"), + Path.Join("disc", "track.m4b"))); + } +} From b19aae52930500200f7aa77910ebcf167ad1bf30 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Thu, 6 Aug 2026 22:37:29 -0400 Subject: [PATCH 411/464] Preserve case in directory rename recovery --- .../FileMover.DirectoryRenameJournal.cs | 47 ++++++++++++------- .../Api/Services/FileMoverFallbackTests.cs | 15 ++++++ 2 files changed, 44 insertions(+), 18 deletions(-) diff --git a/listenarr.infrastructure/FileSystem/FileMover.DirectoryRenameJournal.cs b/listenarr.infrastructure/FileSystem/FileMover.DirectoryRenameJournal.cs index 9bd88f44b..59741c24d 100644 --- a/listenarr.infrastructure/FileSystem/FileMover.DirectoryRenameJournal.cs +++ b/listenarr.infrastructure/FileSystem/FileMover.DirectoryRenameJournal.cs @@ -33,8 +33,8 @@ private PinnedDirectoryCreation.PinnedFileEntry PublishDirectoryRenameJournal( var payload = new DirectoryRenameJournalPayload( Version: 1, Guid.NewGuid(), - Path.GetFullPath(source), - Path.GetFullPath(destination), + CanonicalizeDirectoryRenameJournalPath(source), + CanonicalizeDirectoryRenameJournalPath(destination), sourceObjectIdentity, sourceParent.GetDirectoryObjectIdentity(), destinationParent.GetDirectoryObjectIdentity()); @@ -126,8 +126,8 @@ private static void TryRetireDirectoryRenameJournal( string sourceDirectory, string destinationDirectory) { - var source = Path.GetFullPath(sourceDirectory); - var destination = Path.GetFullPath(destinationDirectory); + var source = CanonicalizeDirectoryRenameJournalPath(sourceDirectory); + var destination = CanonicalizeDirectoryRenameJournalPath(destinationDirectory); var sourceParentPath = Path.GetDirectoryName(source); if (string.IsNullOrWhiteSpace(sourceParentPath) || !Directory.Exists(sourceParentPath)) @@ -135,9 +135,6 @@ private static void TryRetireDirectoryRenameJournal( return null; } - var comparison = OperatingSystem.IsWindows() - ? StringComparison.OrdinalIgnoreCase - : StringComparison.Ordinal; using var sourceParent = PinnedDirectoryCreation.OpenPinnedHierarchyNoFollow( sourceParentPath, @@ -170,11 +167,11 @@ private static void TryRetireDirectoryRenameJournal( || !string.Equals( payloadSource, source, - comparison) + StringComparison.Ordinal) || !string.Equals( payloadDestination, destination, - comparison)) + StringComparison.Ordinal)) { return PinnedDirectoryMoveOutcome.Indeterminate; } @@ -268,17 +265,21 @@ payload with return outcome; } - private static string GetDirectoryRenameJournalStem( + internal static string GetDirectoryRenameJournalStem( string source, - string destination) + string destination, + FileSystemPathSyntax? syntax = null) { - var normalizedSource = Path.GetFullPath(source); - var normalizedDestination = Path.GetFullPath(destination); - if (OperatingSystem.IsWindows()) - { - normalizedSource = normalizedSource.ToUpperInvariant(); - normalizedDestination = normalizedDestination.ToUpperInvariant(); - } + var effectiveSyntax = syntax + ?? (OperatingSystem.IsWindows() + ? FileSystemPathSyntax.Windows + : FileSystemPathSyntax.Unix); + var normalizedSource = syntax.HasValue + ? FileSystemPathIdentity.Canonicalize(source, effectiveSyntax) + : CanonicalizeDirectoryRenameJournalPath(source); + var normalizedDestination = syntax.HasValue + ? FileSystemPathIdentity.Canonicalize(destination, effectiveSyntax) + : CanonicalizeDirectoryRenameJournalPath(destination); var keyBytes = Encoding.UTF8.GetBytes( normalizedSource + "\0" + normalizedDestination); @@ -286,6 +287,16 @@ private static string GetDirectoryRenameJournalStem( return DirectoryRenameJournalPrefix + key; } + private static string CanonicalizeDirectoryRenameJournalPath(string path) + { + var syntax = OperatingSystem.IsWindows() + ? FileSystemPathSyntax.Windows + : FileSystemPathSyntax.Unix; + return FileSystemPathIdentity.Canonicalize( + Path.GetFullPath(path), + syntax); + } + private static DirectoryRenameJournalPayload? ReadDirectoryRenameJournal( PinnedDirectoryCreation.PinnedFileEntry journal) { diff --git a/tests/Features/Api/Services/FileMoverFallbackTests.cs b/tests/Features/Api/Services/FileMoverFallbackTests.cs index 62ae09562..6ff269595 100644 --- a/tests/Features/Api/Services/FileMoverFallbackTests.cs +++ b/tests/Features/Api/Services/FileMoverFallbackTests.cs @@ -1534,6 +1534,21 @@ public async Task MoveDirectoryAsync_PostRenameBarrierFailure_ReconcilesSuccessf await File.ReadAllTextAsync(Path.Join(destination, "book.m4b"))); } + [Fact] + public void DirectoryRenameJournalStem_WindowsCaseDistinctPaths_DoNotCollide() + { + var first = FileMover.GetDirectoryRenameJournalStem( + @"C:\Library\Book", + @"C:\Destination\Book", + FileSystemPathSyntax.Windows); + var second = FileMover.GetDirectoryRenameJournalStem( + @"C:\Library\book", + @"C:\Destination\book", + FileSystemPathSyntax.Windows); + + Assert.NotEqual(first, second); + } + [Fact] public async Task MoveDirectoryAsync_CrashAfterRenameJournalPublication_RecoversMovedGeneration() { From 0b5effebd9e2bde233d41e926d08fce2a8764790 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Fri, 7 Aug 2026 05:19:01 -0400 Subject: [PATCH 412/464] Preserve exact filesystem recovery identities --- .../Common/FileUtils.CommonPaths.cs | 184 ++++++++++++++++++ .../Common/FileUtils.MutationRoots.cs | 5 +- listenarr.domain/Common/FileUtils.cs | 146 -------------- .../FileMover.DirectoryCleanupJournal.cs | 18 +- .../FileMover.DirectoryCopy.Validation.cs | 11 +- .../FileMover.DirectoryRenameJournal.cs | 22 +-- .../FileMover.DurablePathIdentity.cs | 44 +++++ .../FileSystem/FileMover.FileMoveState.cs | 5 +- ...r.RegistrationPublication.CleanupIntent.cs | 30 +-- ...eMover.RegistrationPublication.Rollback.cs | 20 +- .../FileMover.RegistrationPublication.cs | 22 +-- ...ContentMoveService.SourceRootQuarantine.cs | 8 +- ...ionService.TargetReservationPersistence.cs | 5 +- tests/Features/Domain/Utils/FileUtilsTests.cs | 35 ++++ ...FileMoverDirectoryCopyPathIdentityTests.cs | 29 +++ 15 files changed, 336 insertions(+), 248 deletions(-) create mode 100644 listenarr.domain/Common/FileUtils.CommonPaths.cs create mode 100644 listenarr.infrastructure/FileSystem/FileMover.DurablePathIdentity.cs diff --git a/listenarr.domain/Common/FileUtils.CommonPaths.cs b/listenarr.domain/Common/FileUtils.CommonPaths.cs new file mode 100644 index 000000000..a678abca2 --- /dev/null +++ b/listenarr.domain/Common/FileUtils.CommonPaths.cs @@ -0,0 +1,184 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +namespace Listenarr.Domain.Common; + +public static partial class FileUtils +{ + public static string? GetCommonDirectory(IEnumerable paths) + { + try + { + var directories = paths + .Where(path => !string.IsNullOrWhiteSpace(path)) + .Select(path => + { + var fullPath = NormalizeStoredPath(path); + return Path.GetDirectoryName(fullPath) ?? fullPath; + }) + .Distinct(StringComparer.Ordinal) + .ToList(); + + if (directories.Count == 0) + { + return null; + } + + var commonPath = GetCommonPathForDirectories(directories); + return string.IsNullOrWhiteSpace(commonPath) ? directories[0] : commonPath; + } + catch (Exception exception) when (exception is not ( + OperationCanceledException or OutOfMemoryException or StackOverflowException)) + { + return null; + } + } + + public static string? GetCommonPathForDirectories(IEnumerable directories) + => GetCommonPathForDirectories( + directories, + new FileSystemPathSemantics( + OperatingSystem.IsWindows() + ? FileSystemPathSyntax.Windows + : FileSystemPathSyntax.Unix, + FileSystemCaseSensitivity.Sensitive)); + + public static string? GetCommonPathForDirectories( + IEnumerable directories, + FileSystemPathSemantics semantics) + { + try + { + var normalizedDirectories = directories + .Where(path => !string.IsNullOrWhiteSpace(path)) + .Select(path => FileSystemPathIdentity.Canonicalize( + path, + semantics.Syntax)) + .Distinct(semantics.Comparer) + .ToList(); + + if (normalizedDirectories.Count == 0) + { + return null; + } + + if (normalizedDirectories.Count == 1) + { + return normalizedDirectories[0]; + } + + var commonPath = normalizedDirectories[0]; + foreach (var directory in normalizedDirectories.Skip(1)) + { + commonPath = GetCommonPath(commonPath, directory, semantics); + if (string.IsNullOrWhiteSpace(commonPath)) + { + break; + } + } + + return string.IsNullOrWhiteSpace(commonPath) ? null : commonPath; + } + catch (Exception exception) when (exception is not ( + OperationCanceledException or OutOfMemoryException or StackOverflowException)) + { + return null; + } + } + + private static string GetCommonPath( + string firstPath, + string secondPath, + FileSystemPathSemantics semantics) + { + var first = DecomposePathForCommonPath(firstPath, semantics.Syntax); + var second = DecomposePathForCommonPath(secondPath, semantics.Syntax); + var comparison = semantics.CaseSensitivity == FileSystemCaseSensitivity.Sensitive + ? StringComparison.Ordinal + : StringComparison.OrdinalIgnoreCase; + + if (!string.Equals(first.Root, second.Root, comparison)) + { + return string.Empty; + } + + var commonSegments = new List(); + var segmentCount = Math.Min(first.Segments.Count, second.Segments.Count); + for (var index = 0; index < segmentCount; index++) + { + if (!string.Equals(first.Segments[index], second.Segments[index], comparison)) + { + break; + } + + commonSegments.Add(first.Segments[index]); + } + + return BuildPathFromRootAndSegments( + first.Root, + commonSegments, + semantics.Syntax); + } + + private static (string Root, IReadOnlyList Segments) DecomposePathForCommonPath( + string path, + FileSystemPathSyntax syntax) + { + var normalizedPath = FileSystemPathIdentity.Canonicalize(path, syntax); + if (syntax == FileSystemPathSyntax.Windows) + { + var rootLength = GetWindowsRootLength(normalizedPath); + var root = rootLength > 0 + ? NormalizeWindowsRootForStorage(normalizedPath[..rootLength]) + : string.Empty; + var remainingPath = rootLength > 0 + ? normalizedPath[rootLength..] + : normalizedPath; + return ( + root, + remainingPath.Split( + ['\\', '/'], + StringSplitOptions.RemoveEmptyEntries)); + } + + var unixRoot = normalizedPath.StartsWith("/", StringComparison.Ordinal) + ? "/" + : string.Empty; + var unixRemainingPath = unixRoot.Length > 0 + ? normalizedPath[unixRoot.Length..] + : normalizedPath; + return ( + unixRoot, + unixRemainingPath.Split( + '/', + StringSplitOptions.RemoveEmptyEntries)); + } + + private static string BuildPathFromRootAndSegments( + string root, + IReadOnlyList segments, + FileSystemPathSyntax syntax) + { + if (segments.Count == 0) + { + return root; + } + + var separator = syntax == FileSystemPathSyntax.Windows ? '\\' : '/'; + if (string.IsNullOrEmpty(root)) + { + return string.Join(separator, segments); + } + + return root.TrimEnd('/', '\\') + + separator + + string.Join(separator, segments); + } +} diff --git a/listenarr.domain/Common/FileUtils.MutationRoots.cs b/listenarr.domain/Common/FileUtils.MutationRoots.cs index 14b549a77..b39fa008e 100644 --- a/listenarr.domain/Common/FileUtils.MutationRoots.cs +++ b/listenarr.domain/Common/FileUtils.MutationRoots.cs @@ -17,10 +17,7 @@ public static IReadOnlyList GetValidMutationRootsForCurrentOs( { ArgumentNullException.ThrowIfNull(paths); - var comparer = OperatingSystem.IsWindows() - ? StringComparer.OrdinalIgnoreCase - : StringComparer.Ordinal; - var normalizedRoots = new HashSet(comparer); + var normalizedRoots = new HashSet(StringComparer.Ordinal); foreach (var path in paths) { if (string.IsNullOrWhiteSpace(path) diff --git a/listenarr.domain/Common/FileUtils.cs b/listenarr.domain/Common/FileUtils.cs index f5480b75a..9d9c966c5 100644 --- a/listenarr.domain/Common/FileUtils.cs +++ b/listenarr.domain/Common/FileUtils.cs @@ -312,152 +312,6 @@ private static bool IsPathSameOrInside(string candidatePath, string basePath, bo return normalizedCandidate.StartsWith(baseWithSeparator, comparison); } - public static string? GetCommonDirectory(IEnumerable paths) - { - try - { - var directories = paths - .Where(path => !string.IsNullOrWhiteSpace(path)) - .Select(path => - { - var fullPath = NormalizeStoredPath(path); - return Path.GetDirectoryName(fullPath) ?? fullPath; - }) - .Distinct(FilesystemPathComparerForCurrentOs) - .ToList(); - - if (directories.Count == 0) - { - return null; - } - - var commonPath = GetCommonPathForDirectories(directories); - return string.IsNullOrWhiteSpace(commonPath) ? directories[0] : commonPath; - } - catch (Exception caughtEx_4) when (caughtEx_4 is not OperationCanceledException && caughtEx_4 is not OutOfMemoryException && caughtEx_4 is not StackOverflowException) - { - return null; - } - } - - public static string? GetCommonPathForDirectories(IEnumerable directories) - => GetCommonPathForDirectories(directories, FileSystemPathSemantics.CurrentHostDefault); - - public static string? GetCommonPathForDirectories( - IEnumerable directories, - FileSystemPathSemantics semantics) - { - try - { - var normalizedDirectories = directories - .Where(path => !string.IsNullOrWhiteSpace(path)) - .Select(NormalizeFullPathForBoundary) - .Distinct(semantics.Comparer) - .ToList(); - - if (normalizedDirectories.Count == 0) - { - return null; - } - - if (normalizedDirectories.Count == 1) - { - return normalizedDirectories[0]; - } - - var commonPath = normalizedDirectories[0]; - foreach (var directory in normalizedDirectories.Skip(1)) - { - commonPath = GetCommonPath(commonPath, directory, semantics); - if (string.IsNullOrWhiteSpace(commonPath)) - { - break; - } - } - - return string.IsNullOrWhiteSpace(commonPath) ? null : commonPath; - } - catch (Exception exception) when (exception is not (OperationCanceledException or OutOfMemoryException or StackOverflowException)) - { - return null; - } - } - - private static string GetCommonPath( - string firstPath, - string secondPath, - FileSystemPathSemantics semantics) - { - var first = DecomposePathForCommonPath(firstPath); - var second = DecomposePathForCommonPath(secondPath); - var comparison = semantics.CaseSensitivity == FileSystemCaseSensitivity.Sensitive - ? StringComparison.Ordinal - : StringComparison.OrdinalIgnoreCase; - - if (!string.Equals(first.Root, second.Root, comparison)) - { - return string.Empty; - } - - var commonSegments = new List(); - var segmentCount = Math.Min(first.Segments.Count, second.Segments.Count); - for (var index = 0; index < segmentCount; index++) - { - if (!string.Equals(first.Segments[index], second.Segments[index], comparison)) - { - break; - } - - commonSegments.Add(first.Segments[index]); - } - - return BuildPathFromRootAndSegments(first.Root, commonSegments); - } - - private static (string Root, IReadOnlyList Segments) DecomposePathForCommonPath(string path) - { - var normalizedPath = NormalizeFullPathForBoundary(path); - string root; - string remainingPath; - if (OperatingSystem.IsWindows()) - { - var rootLength = GetWindowsRootLength(normalizedPath); - root = rootLength > 0 - ? NormalizeWindowsRootForStorage(normalizedPath[..rootLength]) - : string.Empty; - remainingPath = rootLength > 0 ? normalizedPath[rootLength..] : normalizedPath; - } - else - { - root = normalizedPath.StartsWith(Path.DirectorySeparatorChar) - ? Path.DirectorySeparatorChar.ToString() - : string.Empty; - remainingPath = root.Length > 0 ? normalizedPath[root.Length..] : normalizedPath; - } - - var segments = remainingPath - .Split(new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }, StringSplitOptions.RemoveEmptyEntries) - .ToList(); - return (root, segments); - } - - private static string BuildPathFromRootAndSegments(string root, IReadOnlyList segments) - { - if (segments.Count == 0) - { - return root; - } - - if (string.IsNullOrEmpty(root)) - { - return Path.Join(segments.ToArray()); - } - - return root.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) - + Path.DirectorySeparatorChar - + Path.Join(segments.ToArray()); - } - private static bool IsGenericTrackLabel(string? value) { if (string.IsNullOrWhiteSpace(value)) diff --git a/listenarr.infrastructure/FileSystem/FileMover.DirectoryCleanupJournal.cs b/listenarr.infrastructure/FileSystem/FileMover.DirectoryCleanupJournal.cs index bd649ede3..16f965cf4 100644 --- a/listenarr.infrastructure/FileSystem/FileMover.DirectoryCleanupJournal.cs +++ b/listenarr.infrastructure/FileSystem/FileMover.DirectoryCleanupJournal.cs @@ -35,7 +35,7 @@ private bool TryRecoverJournaledDirectoryCleanup( out string reason) { reason = string.Empty; - var normalizedSource = Path.GetFullPath(sourceRoot); + var normalizedSource = CanonicalizeDurablePathEvidence(sourceRoot); var parentPath = Path.GetDirectoryName(normalizedSource); if (string.IsNullOrWhiteSpace(parentPath) || !Directory.Exists(parentPath)) @@ -87,9 +87,7 @@ private bool TryRecoverJournaledDirectoryCleanup( if (string.Equals( payloadSourceRoot, normalizedSource, - OperatingSystem.IsWindows() - ? StringComparison.OrdinalIgnoreCase - : StringComparison.Ordinal)) + StringComparison.Ordinal)) { if (matching != null) { @@ -154,11 +152,9 @@ private async Task RecoverCleanupJournalAsync( || Path.GetDirectoryName(Path.GetFullPath(payload.SourceRoot)) is not { } sourceParent || !string.Equals( - Path.GetFullPath(sourceParent), - Path.GetFullPath(parent.FullPath), - OperatingSystem.IsWindows() - ? StringComparison.OrdinalIgnoreCase - : StringComparison.Ordinal)) + CanonicalizeDurablePathEvidence(sourceParent), + CanonicalizeDurablePathEvidence(parent.FullPath), + StringComparison.Ordinal)) { return false; } @@ -297,7 +293,7 @@ private async Task ExecuteJournaledDirectoryCleanupA DirectoryCopySnapshot snapshot, string destinationRoot) { - var normalizedDestination = Path.GetFullPath(destinationRoot); + var normalizedDestination = CanonicalizeDurablePathEvidence(destinationRoot); if (!TryGetDirectoryIdentity(normalizedDestination, out var destinationIdentity)) { return new DirectoryCopyCleanupResult( @@ -359,7 +355,7 @@ private async Task ExecuteJournaledDirectoryCleanupA var payload = new CleanupJournalPayload( Version: journalVersion, operationId, - Path.GetFullPath(snapshot.SourceRoot), + CanonicalizeDurablePathEvidence(snapshot.SourceRoot), normalizedDestination, snapshot.SourceRootIdentity, destinationIdentity, diff --git a/listenarr.infrastructure/FileSystem/FileMover.DirectoryCopy.Validation.cs b/listenarr.infrastructure/FileSystem/FileMover.DirectoryCopy.Validation.cs index 63aadd7ec..98d49776d 100644 --- a/listenarr.infrastructure/FileSystem/FileMover.DirectoryCopy.Validation.cs +++ b/listenarr.infrastructure/FileSystem/FileMover.DirectoryCopy.Validation.cs @@ -422,7 +422,7 @@ private async Task EnsureDirectoryCopyTargetSafeAsync( [destinationRoot], out var normalizedTarget, out var validationReason) - || !PathsMatchForCurrentHost(normalizedTarget, targetPath)) + || !DurablePathEvidenceEquals(normalizedTarget, targetPath)) { throw new IOException( $"Directory copy destination failed mutation-boundary validation: {validationReason}"); @@ -439,7 +439,7 @@ private static string GetVerifiedRelativePath(string root, string path) { var relativePath = Path.GetRelativePath(root, path); var resolvedPath = ResolveSnapshotPath(root, relativePath, "snapshot entry"); - if (!PathsMatchForCurrentHost(resolvedPath, path)) + if (!DurablePathEvidenceEquals(resolvedPath, path)) { throw new IOException("Directory copy snapshot entry escaped its source root."); } @@ -469,11 +469,4 @@ private static int PathDepth(string path) => character == Path.DirectorySeparatorChar || character == Path.AltDirectorySeparatorChar); - private static bool PathsMatchForCurrentHost(string first, string second) => - string.Equals( - Path.GetFullPath(first), - Path.GetFullPath(second), - OperatingSystem.IsWindows() - ? StringComparison.OrdinalIgnoreCase - : StringComparison.Ordinal); } diff --git a/listenarr.infrastructure/FileSystem/FileMover.DirectoryRenameJournal.cs b/listenarr.infrastructure/FileSystem/FileMover.DirectoryRenameJournal.cs index 59741c24d..bff3cad0a 100644 --- a/listenarr.infrastructure/FileSystem/FileMover.DirectoryRenameJournal.cs +++ b/listenarr.infrastructure/FileSystem/FileMover.DirectoryRenameJournal.cs @@ -33,8 +33,8 @@ private PinnedDirectoryCreation.PinnedFileEntry PublishDirectoryRenameJournal( var payload = new DirectoryRenameJournalPayload( Version: 1, Guid.NewGuid(), - CanonicalizeDirectoryRenameJournalPath(source), - CanonicalizeDirectoryRenameJournalPath(destination), + CanonicalizeDurablePathEvidence(source), + CanonicalizeDurablePathEvidence(destination), sourceObjectIdentity, sourceParent.GetDirectoryObjectIdentity(), destinationParent.GetDirectoryObjectIdentity()); @@ -126,8 +126,8 @@ private static void TryRetireDirectoryRenameJournal( string sourceDirectory, string destinationDirectory) { - var source = CanonicalizeDirectoryRenameJournalPath(sourceDirectory); - var destination = CanonicalizeDirectoryRenameJournalPath(destinationDirectory); + var source = CanonicalizeDurablePathEvidence(sourceDirectory); + var destination = CanonicalizeDurablePathEvidence(destinationDirectory); var sourceParentPath = Path.GetDirectoryName(source); if (string.IsNullOrWhiteSpace(sourceParentPath) || !Directory.Exists(sourceParentPath)) @@ -276,10 +276,10 @@ internal static string GetDirectoryRenameJournalStem( : FileSystemPathSyntax.Unix); var normalizedSource = syntax.HasValue ? FileSystemPathIdentity.Canonicalize(source, effectiveSyntax) - : CanonicalizeDirectoryRenameJournalPath(source); + : CanonicalizeDurablePathEvidence(source); var normalizedDestination = syntax.HasValue ? FileSystemPathIdentity.Canonicalize(destination, effectiveSyntax) - : CanonicalizeDirectoryRenameJournalPath(destination); + : CanonicalizeDurablePathEvidence(destination); var keyBytes = Encoding.UTF8.GetBytes( normalizedSource + "\0" + normalizedDestination); @@ -287,16 +287,6 @@ internal static string GetDirectoryRenameJournalStem( return DirectoryRenameJournalPrefix + key; } - private static string CanonicalizeDirectoryRenameJournalPath(string path) - { - var syntax = OperatingSystem.IsWindows() - ? FileSystemPathSyntax.Windows - : FileSystemPathSyntax.Unix; - return FileSystemPathIdentity.Canonicalize( - Path.GetFullPath(path), - syntax); - } - private static DirectoryRenameJournalPayload? ReadDirectoryRenameJournal( PinnedDirectoryCreation.PinnedFileEntry journal) { diff --git a/listenarr.infrastructure/FileSystem/FileMover.DurablePathIdentity.cs b/listenarr.infrastructure/FileSystem/FileMover.DurablePathIdentity.cs new file mode 100644 index 000000000..955731caa --- /dev/null +++ b/listenarr.infrastructure/FileSystem/FileMover.DurablePathIdentity.cs @@ -0,0 +1,44 @@ +using Listenarr.Domain.Common; + +namespace Listenarr.Infrastructure.FileSystem; + +public partial class FileMover +{ + internal static StringComparer DurableRecoveryArtifactNameComparer { get; } = + StringComparer.Ordinal; + + // Durable recovery evidence records a specific logical pathname. Preserve path + // segment casing even on Windows because directory namespaces can be + // case-sensitive. Any alias acceptance must be proved separately by pinned + // physical identity rather than inferred from the host OS. + private static string CanonicalizeDurablePathEvidence(string path) + { + var syntax = OperatingSystem.IsWindows() + ? FileSystemPathSyntax.Windows + : FileSystemPathSyntax.Unix; + return FileSystemPathIdentity.Canonicalize( + Path.GetFullPath(path), + syntax); + } + + internal static bool DurablePathEvidenceEquals( + string first, + string second, + FileSystemPathSyntax? syntax = null) + { + var effectiveSyntax = syntax + ?? (OperatingSystem.IsWindows() + ? FileSystemPathSyntax.Windows + : FileSystemPathSyntax.Unix); + var normalizedFirst = syntax.HasValue + ? FileSystemPathIdentity.Canonicalize(first, effectiveSyntax) + : CanonicalizeDurablePathEvidence(first); + var normalizedSecond = syntax.HasValue + ? FileSystemPathIdentity.Canonicalize(second, effectiveSyntax) + : CanonicalizeDurablePathEvidence(second); + return string.Equals( + normalizedFirst, + normalizedSecond, + StringComparison.Ordinal); + } +} diff --git a/listenarr.infrastructure/FileSystem/FileMover.FileMoveState.cs b/listenarr.infrastructure/FileSystem/FileMover.FileMoveState.cs index fc717c451..c85acef5b 100644 --- a/listenarr.infrastructure/FileSystem/FileMover.FileMoveState.cs +++ b/listenarr.infrastructure/FileSystem/FileMover.FileMoveState.cs @@ -92,10 +92,7 @@ private static bool AnchoredStateContainsOnly( { return false; } - var comparer = OperatingSystem.IsWindows() - ? StringComparer.OrdinalIgnoreCase - : StringComparer.Ordinal; - var allowed = allowedNames.ToHashSet(comparer); + var allowed = allowedNames.ToHashSet(DurableRecoveryArtifactNameComparer); var actual = Directory.EnumerateFileSystemEntries(state.FullPath) .Select(Path.GetFileName) .ToList(); diff --git a/listenarr.infrastructure/FileSystem/FileMover.RegistrationPublication.CleanupIntent.cs b/listenarr.infrastructure/FileSystem/FileMover.RegistrationPublication.CleanupIntent.cs index 9fd77dcb1..7564eea7e 100644 --- a/listenarr.infrastructure/FileSystem/FileMover.RegistrationPublication.CleanupIntent.cs +++ b/listenarr.infrastructure/FileSystem/FileMover.RegistrationPublication.CleanupIntent.cs @@ -106,11 +106,9 @@ private bool PrepareHardlinkRegistrationCleanupRecovery( expectedPhysicalObjectIdentity, StringComparison.Ordinal) && string.Equals( - Path.GetFullPath(intent.SourcePath ?? string.Empty), - sourcePath, - OperatingSystem.IsWindows() - ? StringComparison.OrdinalIgnoreCase - : StringComparison.Ordinal) + CanonicalizeDurablePathEvidence(intent.SourcePath ?? string.Empty), + CanonicalizeDurablePathEvidence(sourcePath), + StringComparison.Ordinal) && string.Equals( intent.SourcePhysicalObjectIdentity, sourcePhysicalObjectIdentity, @@ -134,7 +132,7 @@ private bool PrepareHardlinkRegistrationCleanupRecovery( audiobookId, Path.GetFileName(destinationPath), expectedPhysicalObjectIdentity, - sourcePath, + CanonicalizeDurablePathEvidence(sourcePath), sourcePhysicalObjectIdentity)); using var intentEntry = state.CreateNewFile(RegistrationCleanupIntentName); using (var stream = intentEntry.OpenWriteStream( @@ -175,14 +173,10 @@ internal RegistrationPublicationCleanupCandidate? if (string.IsNullOrWhiteSpace(stateName) || !stateName.StartsWith( ".listenarr-registration-publication-", - OperatingSystem.IsWindows() - ? StringComparison.OrdinalIgnoreCase - : StringComparison.Ordinal) + StringComparison.Ordinal) || !stateName.EndsWith( ".state", - OperatingSystem.IsWindows() - ? StringComparison.OrdinalIgnoreCase - : StringComparison.Ordinal)) + StringComparison.Ordinal)) { return null; } @@ -321,15 +315,11 @@ internal bool TryCompleteRegistrationPublicationCleanup( || !string.Equals( current.StateName, candidate.StateName, - OperatingSystem.IsWindows() - ? StringComparison.OrdinalIgnoreCase - : StringComparison.Ordinal) + StringComparison.Ordinal) || !string.Equals( - Path.GetFullPath(current.DestinationPath), - Path.GetFullPath(candidate.DestinationPath), - OperatingSystem.IsWindows() - ? StringComparison.OrdinalIgnoreCase - : StringComparison.Ordinal) + CanonicalizeDurablePathEvidence(current.DestinationPath), + CanonicalizeDurablePathEvidence(candidate.DestinationPath), + StringComparison.Ordinal) || !string.Equals( current.PhysicalObjectIdentity, candidate.PhysicalObjectIdentity, diff --git a/listenarr.infrastructure/FileSystem/FileMover.RegistrationPublication.Rollback.cs b/listenarr.infrastructure/FileSystem/FileMover.RegistrationPublication.Rollback.cs index e6574da7b..d6d7bc569 100644 --- a/listenarr.infrastructure/FileSystem/FileMover.RegistrationPublication.Rollback.cs +++ b/listenarr.infrastructure/FileSystem/FileMover.RegistrationPublication.Rollback.cs @@ -18,21 +18,15 @@ internal bool TryRollbackUncommittedRegistrationPublication( || !string.Equals( current.StateName, candidate.StateName, - OperatingSystem.IsWindows() - ? StringComparison.OrdinalIgnoreCase - : StringComparison.Ordinal) + StringComparison.Ordinal) || !string.Equals( - Path.GetFullPath(current.DestinationPath), - Path.GetFullPath(candidate.DestinationPath), - OperatingSystem.IsWindows() - ? StringComparison.OrdinalIgnoreCase - : StringComparison.Ordinal) + CanonicalizeDurablePathEvidence(current.DestinationPath), + CanonicalizeDurablePathEvidence(candidate.DestinationPath), + StringComparison.Ordinal) || !string.Equals( - Path.GetFullPath(current.SourcePath), - Path.GetFullPath(candidate.SourcePath ?? string.Empty), - OperatingSystem.IsWindows() - ? StringComparison.OrdinalIgnoreCase - : StringComparison.Ordinal) + CanonicalizeDurablePathEvidence(current.SourcePath), + CanonicalizeDurablePathEvidence(candidate.SourcePath ?? string.Empty), + StringComparison.Ordinal) || !string.Equals( current.PhysicalObjectIdentity, candidate.PhysicalObjectIdentity, diff --git a/listenarr.infrastructure/FileSystem/FileMover.RegistrationPublication.cs b/listenarr.infrastructure/FileSystem/FileMover.RegistrationPublication.cs index 5329d8cdc..f6fb1c278 100644 --- a/listenarr.infrastructure/FileSystem/FileMover.RegistrationPublication.cs +++ b/listenarr.infrastructure/FileSystem/FileMover.RegistrationPublication.cs @@ -429,9 +429,7 @@ private static IReadOnlyList var identity = GetRegistrationPublicationStateIdentity( logicalIdentity, sourcePhysicalObjectIdentity: string.Empty); - var comparer = OperatingSystem.IsWindows() - ? StringComparer.OrdinalIgnoreCase - : StringComparer.Ordinal; + var comparer = DurableRecoveryArtifactNameComparer; var candidates = Directory.EnumerateFileSystemEntries( destinationParent.FullPath, ".listenarr-registration-publication-*.state", @@ -439,15 +437,12 @@ private static IReadOnlyList .Select(Path.GetFileName) .Where(name => name != null && (comparer.Equals(name, identity.LegacyStateName) - || (name.StartsWith(identity.CandidatePrefix, - OperatingSystem.IsWindows() - ? StringComparison.OrdinalIgnoreCase - : StringComparison.Ordinal) + || (name.StartsWith( + identity.CandidatePrefix, + StringComparison.Ordinal) && name.EndsWith( ".state", - OperatingSystem.IsWindows() - ? StringComparison.OrdinalIgnoreCase - : StringComparison.Ordinal)))) + StringComparison.Ordinal)))) .Select(name => name!) .Distinct(comparer) .ToArray(); @@ -461,10 +456,5 @@ private static IReadOnlyList } private static bool StateNameEquals(string first, string second) => - string.Equals( - first, - second, - OperatingSystem.IsWindows() - ? StringComparison.OrdinalIgnoreCase - : StringComparison.Ordinal); + DurableRecoveryArtifactNameComparer.Equals(first, second); } diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.SourceRootQuarantine.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.SourceRootQuarantine.cs index 8260f1f5a..bf1c4d02d 100644 --- a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.SourceRootQuarantine.cs +++ b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.SourceRootQuarantine.cs @@ -85,11 +85,9 @@ await EnsureMutationAuthorizedAsync( } if (entries.Count != 1 || !string.Equals( - Path.GetFullPath(entries[0]), - Path.GetFullPath(claimPath), - OperatingSystem.IsWindows() - ? StringComparison.OrdinalIgnoreCase - : StringComparison.Ordinal) + Path.GetFileName(entries[0]), + EmptySourceClaimDirectoryName, + StringComparison.Ordinal) || File.Exists(claimPath) || !Directory.Exists(claimPath)) { diff --git a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.TargetReservationPersistence.cs b/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.TargetReservationPersistence.cs index 77b813d6f..6f00665ae 100644 --- a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.TargetReservationPersistence.cs +++ b/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.TargetReservationPersistence.cs @@ -331,10 +331,7 @@ private static void ValidateReservationMarker( } } - private static StringComparison PathComparison => - OperatingSystem.IsWindows() - ? StringComparison.OrdinalIgnoreCase - : StringComparison.Ordinal; + private static StringComparison PathComparison => StringComparison.Ordinal; private sealed record TargetReservationPlan( string ExistingAncestor, diff --git a/tests/Features/Domain/Utils/FileUtilsTests.cs b/tests/Features/Domain/Utils/FileUtilsTests.cs index 03e87b823..1a0ec90bb 100644 --- a/tests/Features/Domain/Utils/FileUtilsTests.cs +++ b/tests/Features/Domain/Utils/FileUtilsTests.cs @@ -1083,6 +1083,19 @@ public void TryNormalizeUserProvidedDirectoryPathForCurrentOs_NormalizesCurrentH Assert.Equal(string.Empty, reason); } + [WindowsFact] + public void GetValidMutationRootsForCurrentOs_PreservesCaseDistinctWindowsRoots() + { + var roots = FileUtils.GetValidMutationRootsForCurrentOs([ + @"C:\Library", + @"C:\library" + ]); + + Assert.Equal(2, roots.Count); + Assert.Contains(@"C:\Library", roots, StringComparer.Ordinal); + Assert.Contains(@"C:\library", roots, StringComparer.Ordinal); + } + [WindowsFact] public void GetValidMutationRootsForCurrentOs_DoesNotLaunderPersistedUnixRoot() { @@ -1178,6 +1191,28 @@ public void GetCommonPathForDirectories_UsesHostFilesystemCaseRules() } } + [Fact] + public void GetCommonPathForDirectories_ExplicitWindowsSemantics_AreHostIndependent() + { + var semantics = new FileSystemPathSemantics( + FileSystemPathSyntax.Windows, + FileSystemCaseSensitivity.Sensitive); + + Assert.Equal(@"C:\Library", FileUtils.GetCommonPathForDirectories([ + @"c:\Library\Author\BookA", + @"C:\Library\author\BookB" + ], semantics)); + } + + [WindowsFact] + public void GetCommonPathForDirectories_PreservesCaseDistinctWindowsSegments() + { + Assert.Equal(@"C:\Library", FileUtils.GetCommonPathForDirectories([ + @"C:\Library\Author\BookA", + @"C:\Library\author\BookB" + ])); + } + [Fact] public void GetCommonPathForDirectories_RespectsPathSegmentBoundaries() { diff --git a/tests/Features/Infrastructure/FileSystem/FileMoverDirectoryCopyPathIdentityTests.cs b/tests/Features/Infrastructure/FileSystem/FileMoverDirectoryCopyPathIdentityTests.cs index 108acaaad..3e1b7f9c3 100644 --- a/tests/Features/Infrastructure/FileSystem/FileMoverDirectoryCopyPathIdentityTests.cs +++ b/tests/Features/Infrastructure/FileSystem/FileMoverDirectoryCopyPathIdentityTests.cs @@ -7,6 +7,35 @@ namespace Listenarr.Tests.Features.Infrastructure.FileSystem; [Trait("Category", "FileSystem")] public sealed class FileMoverDirectoryCopyPathIdentityTests : BaseTests { + [Fact] + [Trait("Method", "DurableRecoveryArtifactNameComparer")] + [Trait("Scenario", "CaseDistinctRecoveryArtifactsRemainDistinct")] + public void DurableRecoveryArtifactNameComparer_CaseDistinctRecoveryArtifactsRemainDistinct() + { + var names = new HashSet(FileMover.DurableRecoveryArtifactNameComparer) + { + "source.claim", + "Source.Claim" + }; + + Assert.Equal(2, names.Count); + } + + [Fact] + [Trait("Method", "DurablePathEvidenceEquals")] + [Trait("Scenario", "WindowsCaseDistinctPathsRemainDistinct")] + public void DurablePathEvidenceEquals_WindowsCaseDistinctPathsRemainDistinct() + { + Assert.True(FileMover.DurablePathEvidenceEquals( + @"c:\Library\Book", + @"C:\Library\Book", + FileSystemPathSyntax.Windows)); + Assert.False(FileMover.DurablePathEvidenceEquals( + @"C:\Library\Book", + @"C:\Library\book", + FileSystemPathSyntax.Windows)); + } + [Fact] [Trait("Method", "DirectoryCopySnapshotPathComparer")] [Trait("Scenario", "CaseDistinctSnapshotEntriesRemainDistinct")] From bd99ef99ee49fae1aae129ce6ba397b5e301ae9b Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Fri, 7 Aug 2026 10:22:21 -0400 Subject: [PATCH 413/464] chore(ci): add temporary PR 717 cleanup analysis --- .github/workflows/pr717-cleanup-analysis.yml | 56 ++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 .github/workflows/pr717-cleanup-analysis.yml diff --git a/.github/workflows/pr717-cleanup-analysis.yml b/.github/workflows/pr717-cleanup-analysis.yml new file mode 100644 index 000000000..a0a6ae727 --- /dev/null +++ b/.github/workflows/pr717-cleanup-analysis.yml @@ -0,0 +1,56 @@ +name: PR 717 cleanup analysis + +on: + push: + branches: + - bugfix/unix-folder-name-space + +permissions: + contents: read + +jobs: + analyze: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Inventory cleanup candidates + shell: bash + run: | + set -euo pipefail + echo '=== HEAD ===' + git rev-parse HEAD + echo '=== post-canary migrations ===' + find listenarr.infrastructure/Persistence/Migrations -maxdepth 1 -type f -name '*.cs' -printf '%f\n' \ + | sort \ + | awk '$0 > "20260621002226"' + echo '=== intermediate compatibility tokens ===' + for token in \ + 'LegacyUnenrolled' \ + 'reauthorize-legacy-target' \ + 'ReauthorizeLegacyTarget' \ + 'ManagedDirectoryEnrollment' \ + '.listenarr-root-enrollment' \ + 'ObsoleteStage' \ + 'pre-release recovery marker' \ + 'LegacyFilesystemArtifacts' \ + 'LibraryDirectoryOwnershipMarker' \ + 'PinnedLibraryDirectoryOwnershipMarker' \ + 'TargetIdentityEnrollment' \ + 'ExclusiveDirectoryCreator.TryCreate' \ + 'isWindowsShapedPath' \ + 'hasOuterWhitespace' \ + 'hasPathSegmentOuterWhitespace' \ + 'pathsOverlap'; do + echo "--- $token" + git grep -n -F "$token" -- ':!listenarr.infrastructure/Persistence/Migrations/*.Designer.cs' ':!listenarr.infrastructure/Persistence/Migrations/ListenArrDbContextModelSnapshot.cs' || true + done + echo '=== marker artifact literals ===' + git grep -n -E '\.listenarr-(directory-owner|root-enrollment|move|recovery)|marker-backed|marker marker|ownership marker|recovery marker' -- \ + 'listenarr.*' 'tests' 'fe/src' 2>/dev/null || true + + - name: Build backend + run: dotnet build listenarr.slnx --configuration Release --no-restore From 1a8c3ecdd0196bbf2fead2b69ee5de87c86c46b6 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Fri, 7 Aug 2026 10:27:48 -0400 Subject: [PATCH 414/464] chore(ci): execute compatibility policy cleanup --- .github/workflows/pr717-cleanup-analysis.yml | 124 ++++++++++++++----- 1 file changed, 90 insertions(+), 34 deletions(-) diff --git a/.github/workflows/pr717-cleanup-analysis.yml b/.github/workflows/pr717-cleanup-analysis.yml index a0a6ae727..ef7e9bb79 100644 --- a/.github/workflows/pr717-cleanup-analysis.yml +++ b/.github/workflows/pr717-cleanup-analysis.yml @@ -6,10 +6,10 @@ on: - bugfix/unix-folder-name-space permissions: - contents: read + contents: write jobs: - analyze: + codify-policy: if: github.actor != 'github-actions[bot]' runs-on: ubuntu-latest steps: @@ -17,40 +17,96 @@ jobs: with: fetch-depth: 0 - - name: Inventory cleanup candidates + - name: Codify compatibility and migration policy shell: bash run: | set -euo pipefail - echo '=== HEAD ===' - git rev-parse HEAD - echo '=== post-canary migrations ===' - find listenarr.infrastructure/Persistence/Migrations -maxdepth 1 -type f -name '*.cs' -printf '%f\n' \ - | sort \ - | awk '$0 > "20260621002226"' - echo '=== intermediate compatibility tokens ===' - for token in \ - 'LegacyUnenrolled' \ - 'reauthorize-legacy-target' \ - 'ReauthorizeLegacyTarget' \ - 'ManagedDirectoryEnrollment' \ - '.listenarr-root-enrollment' \ - 'ObsoleteStage' \ - 'pre-release recovery marker' \ - 'LegacyFilesystemArtifacts' \ - 'LibraryDirectoryOwnershipMarker' \ - 'PinnedLibraryDirectoryOwnershipMarker' \ - 'TargetIdentityEnrollment' \ - 'ExclusiveDirectoryCreator.TryCreate' \ - 'isWindowsShapedPath' \ - 'hasOuterWhitespace' \ - 'hasPathSegmentOuterWhitespace' \ - 'pathsOverlap'; do - echo "--- $token" - git grep -n -F "$token" -- ':!listenarr.infrastructure/Persistence/Migrations/*.Designer.cs' ':!listenarr.infrastructure/Persistence/Migrations/ListenArrDbContextModelSnapshot.cs' || true + python3 - <<'PY' + from pathlib import Path + + agent_policy = '''## Compatibility Boundary for Development Branches + +- The authoritative compatibility boundary is the target branch or released version that a change will merge into. Persisted states produced only by an unmerged feature branch, pull-request build, or intermediate development image are not supported upgrade inputs. +- Do not add production compatibility code, recovery states, filesystem marker readers, API endpoints, schema versions, or tests solely to preserve intermediate iterations of an unmerged change. Remove or regenerate those development artifacts before merge. +- EF migrations are immutable historical artifacts only after they reach the target branch. While a feature branch is unmerged, delete superseded branch-only migrations, restore the target branch model snapshot, and regenerate the minimum final EF migration set from the cleaned final model. Never hand-shape generated migrations to preserve intermediate branch states. +- Compatibility with the actual target-branch schema and persisted data remains mandatory. Before deleting legacy-looking behavior, prove whether the state can exist on the target branch; released or merged states must continue to fail safely or upgrade deterministically. +- Reviews must distinguish current crash-safety evidence from development-history compatibility. A filesystem marker or recovery protocol that is still part of the final safety contract is not removable merely because earlier versions of that protocol existed during development. + +''' + contributor_policy = '''### Compatibility and migration development policy + +The compatibility boundary for a pull request is the branch it targets. Databases, filesystem artifacts, API states, and recovery formats that were produced only by an unmerged feature branch or intermediate PR image are development artifacts, not supported upgrade inputs. + +- Do not keep production compatibility paths solely for intermediate versions of an unmerged PR. +- Preserve and regression-test compatibility with schemas and persisted data that actually exist on the target branch. +- Once an EF migration is merged into a supported branch it is immutable history. Before merge, superseded branch-only migrations should be removed and the final migration set regenerated with EF from the target branch model snapshot. +- Do not hand-edit EF scaffolding to emulate intermediate branch schemas. Put unavoidable data repair for real target-branch upgrades in explicit, tested startup/reconciliation primitives. + +''' + backend_policy = '''## Compatibility and Migration Boundary + +Backend compatibility begins at the branch or release that code has actually reached. An unmerged feature branch is free to replace its own schema, recovery protocol, or persisted development artifacts; those intermediate states must not become permanent production compatibility surfaces merely because a developer ran an earlier PR build. + +EF migrations become immutable historical artifacts when they merge into the target branch. Before merge, a feature branch should remove superseded branch-only migrations, restore the target branch model snapshot, and regenerate the smallest final EF-scaffolded migration set from the cleaned model. Compatibility and startup reconciliation must be designed against data that can exist on the actual target branch, not against transient schemas that existed only during feature development. + +This rule does not authorize deleting current crash-safety evidence. Marker files, journals, leases, identities, or other durable protocols that the final implementation itself writes and requires for restart safety remain part of the current contract. Reviewers must distinguish those from readers, endpoints, states, and migrations whose only purpose is upgrading an obsolete intermediate branch protocol. + +''' + + for filename in ['.github/AGENTS.md', '.github/CLAUDE.md']: + path = Path(filename) + text = path.read_text() + if '## Compatibility Boundary for Development Branches' not in text: + marker = '## Cross-shell null redirection\n' + if marker not in text: + raise SystemExit(f'missing insertion marker in {filename}') + text = text.replace(marker, agent_policy + marker, 1) + path.write_text(text) + + path = Path('CONTRIBUTING.md') + text = path.read_text() + if '### Compatibility and migration development policy' not in text: + marker = '### Branching Model\n' + if marker not in text: + raise SystemExit('missing CONTRIBUTING insertion marker') + text = text.replace(marker, contributor_policy + marker, 1) + path.write_text(text) + + path = Path('BACKEND_ARCHITECTURE.md') + text = path.read_text() + if '## Compatibility and Migration Boundary' not in text: + marker = '## Boundary Cleanup\n' + if marker not in text: + raise SystemExit('missing architecture insertion marker') + text = text.replace(marker, backend_policy + marker, 1) + path.write_text(text) + PY + + - name: Adversarial review policy-only diff + shell: bash + run: | + set -euo pipefail + git diff --check + mapfile -t changed < <(git diff --name-only) + printf 'Changed files:\n%s\n' "${changed[*]}" + expected=(.github/AGENTS.md .github/CLAUDE.md CONTRIBUTING.md BACKEND_ARCHITECTURE.md) + for path in "${changed[@]}"; do + case "$path" in + .github/AGENTS.md|.github/CLAUDE.md|CONTRIBUTING.md|BACKEND_ARCHITECTURE.md) ;; + *) echo "Unexpected file changed: $path" >&2; exit 1 ;; + esac + done + for path in "${expected[@]}"; do + grep -q 'target branch' "$path" || { echo "Missing target-branch policy in $path" >&2; exit 1; } done - echo '=== marker artifact literals ===' - git grep -n -E '\.listenarr-(directory-owner|root-enrollment|move|recovery)|marker-backed|marker marker|ownership marker|recovery marker' -- \ - 'listenarr.*' 'tests' 'fe/src' 2>/dev/null || true + git diff -- .github/AGENTS.md .github/CLAUDE.md CONTRIBUTING.md BACKEND_ARCHITECTURE.md - - name: Build backend - run: dotnet build listenarr.slnx --configuration Release --no-restore + - name: Commit policy + shell: bash + run: | + set -euo pipefail + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add .github/AGENTS.md .github/CLAUDE.md CONTRIBUTING.md BACKEND_ARCHITECTURE.md + git commit -m 'docs: define target-branch compatibility boundary' + git push origin HEAD:bugfix/unix-folder-name-space From d2c78a44f57de33e4661780e202e86c16f9f2e38 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Fri, 7 Aug 2026 10:32:19 -0400 Subject: [PATCH 415/464] chore(ci): fix cleanup executor syntax --- .github/workflows/pr717-cleanup-analysis.yml | 70 ++------------------ 1 file changed, 6 insertions(+), 64 deletions(-) diff --git a/.github/workflows/pr717-cleanup-analysis.yml b/.github/workflows/pr717-cleanup-analysis.yml index ef7e9bb79..3882f6513 100644 --- a/.github/workflows/pr717-cleanup-analysis.yml +++ b/.github/workflows/pr717-cleanup-analysis.yml @@ -21,66 +21,8 @@ jobs: shell: bash run: | set -euo pipefail - python3 - <<'PY' - from pathlib import Path - - agent_policy = '''## Compatibility Boundary for Development Branches - -- The authoritative compatibility boundary is the target branch or released version that a change will merge into. Persisted states produced only by an unmerged feature branch, pull-request build, or intermediate development image are not supported upgrade inputs. -- Do not add production compatibility code, recovery states, filesystem marker readers, API endpoints, schema versions, or tests solely to preserve intermediate iterations of an unmerged change. Remove or regenerate those development artifacts before merge. -- EF migrations are immutable historical artifacts only after they reach the target branch. While a feature branch is unmerged, delete superseded branch-only migrations, restore the target branch model snapshot, and regenerate the minimum final EF migration set from the cleaned final model. Never hand-shape generated migrations to preserve intermediate branch states. -- Compatibility with the actual target-branch schema and persisted data remains mandatory. Before deleting legacy-looking behavior, prove whether the state can exist on the target branch; released or merged states must continue to fail safely or upgrade deterministically. -- Reviews must distinguish current crash-safety evidence from development-history compatibility. A filesystem marker or recovery protocol that is still part of the final safety contract is not removable merely because earlier versions of that protocol existed during development. - -''' - contributor_policy = '''### Compatibility and migration development policy - -The compatibility boundary for a pull request is the branch it targets. Databases, filesystem artifacts, API states, and recovery formats that were produced only by an unmerged feature branch or intermediate PR image are development artifacts, not supported upgrade inputs. - -- Do not keep production compatibility paths solely for intermediate versions of an unmerged PR. -- Preserve and regression-test compatibility with schemas and persisted data that actually exist on the target branch. -- Once an EF migration is merged into a supported branch it is immutable history. Before merge, superseded branch-only migrations should be removed and the final migration set regenerated with EF from the target branch model snapshot. -- Do not hand-edit EF scaffolding to emulate intermediate branch schemas. Put unavoidable data repair for real target-branch upgrades in explicit, tested startup/reconciliation primitives. - -''' - backend_policy = '''## Compatibility and Migration Boundary - -Backend compatibility begins at the branch or release that code has actually reached. An unmerged feature branch is free to replace its own schema, recovery protocol, or persisted development artifacts; those intermediate states must not become permanent production compatibility surfaces merely because a developer ran an earlier PR build. - -EF migrations become immutable historical artifacts when they merge into the target branch. Before merge, a feature branch should remove superseded branch-only migrations, restore the target branch model snapshot, and regenerate the smallest final EF-scaffolded migration set from the cleaned model. Compatibility and startup reconciliation must be designed against data that can exist on the actual target branch, not against transient schemas that existed only during feature development. - -This rule does not authorize deleting current crash-safety evidence. Marker files, journals, leases, identities, or other durable protocols that the final implementation itself writes and requires for restart safety remain part of the current contract. Reviewers must distinguish those from readers, endpoints, states, and migrations whose only purpose is upgrading an obsolete intermediate branch protocol. - -''' - - for filename in ['.github/AGENTS.md', '.github/CLAUDE.md']: - path = Path(filename) - text = path.read_text() - if '## Compatibility Boundary for Development Branches' not in text: - marker = '## Cross-shell null redirection\n' - if marker not in text: - raise SystemExit(f'missing insertion marker in {filename}') - text = text.replace(marker, agent_policy + marker, 1) - path.write_text(text) - - path = Path('CONTRIBUTING.md') - text = path.read_text() - if '### Compatibility and migration development policy' not in text: - marker = '### Branching Model\n' - if marker not in text: - raise SystemExit('missing CONTRIBUTING insertion marker') - text = text.replace(marker, contributor_policy + marker, 1) - path.write_text(text) - - path = Path('BACKEND_ARCHITECTURE.md') - text = path.read_text() - if '## Compatibility and Migration Boundary' not in text: - marker = '## Boundary Cleanup\n' - if marker not in text: - raise SystemExit('missing architecture insertion marker') - text = text.replace(marker, backend_policy + marker, 1) - path.write_text(text) - PY + echo 'ZnJvbSBwYXRobGliIGltcG9ydCBQYXRoCgphZ2VudF9wb2xpY3kgPSAiIiIjIyBDb21wYXRpYmlsaXR5IEJvdW5kYXJ5IGZvciBEZXZlbG9wbWVudCBCcmFuY2hlcwoKLSBUaGUgYXV0aG9yaXRhdGl2ZSBjb21wYXRpYmlsaXR5IGJvdW5kYXJ5IGlzIHRoZSB0YXJnZXQgYnJhbmNoIG9yIHJlbGVhc2VkIHZlcnNpb24gdGhhdCBhIGNoYW5nZSB3aWxsIG1lcmdlIGludG8uIFBlcnNpc3RlZCBzdGF0ZXMgcHJvZHVjZWQgb25seSBieSBhbiB1bm1lcmdlZCBmZWF0dXJlIGJyYW5jaCwgcHVsbC1yZXF1ZXN0IGJ1aWxkLCBvciBpbnRlcm1lZGlhdGUgZGV2ZWxvcG1lbnQgaW1hZ2UgYXJlIG5vdCBzdXBwb3J0ZWQgdXBncmFkZSBpbnB1dHMuCi0gRG8gbm90IGFkZCBwcm9kdWN0aW9uIGNvbXBhdGliaWxpdHkgY29kZSwgcmVjb3Zlcnkgc3RhdGVzLCBmaWxlc3lzdGVtIG1hcmtlciByZWFkZXJzLCBBUEkgZW5kcG9pbnRzLCBzY2hlbWEgdmVyc2lvbnMsIG9yIHRlc3RzIHNvbGVseSB0byBwcmVzZXJ2ZSBpbnRlcm1lZGlhdGUgaXRlcmF0aW9ucyBvZiBhbiB1bm1lcmdlZCBjaGFuZ2UuIFJlbW92ZSBvciByZWdlbmVyYXRlIHRob3NlIGRldmVsb3BtZW50IGFydGlmYWN0cyBiZWZvcmUgbWVyZ2UuCi0gRUYgbWlncmF0aW9ucyBhcmUgaW1tdXRhYmxlIGhpc3RvcmljYWwgYXJ0aWZhY3RzIG9ubHkgYWZ0ZXIgdGhleSByZWFjaCB0aGUgdGFyZ2V0IGJyYW5jaC4gV2hpbGUgYSBmZWF0dXJlIGJyYW5jaCBpcyB1bm1lcmdlZCwgZGVsZXRlIHN1cGVyc2VkZWQgYnJhbmNoLW9ubHkgbWlncmF0aW9ucywgcmVzdG9yZSB0aGUgdGFyZ2V0IGJyYW5jaCBtb2RlbCBzbmFwc2hvdCwgYW5kIHJlZ2VuZXJhdGUgdGhlIG1pbmltdW0gZmluYWwgRUYgbWlncmF0aW9uIHNldCBmcm9tIHRoZSBjbGVhbmVkIGZpbmFsIG1vZGVsLiBOZXZlciBoYW5kLXNoYXBlIGdlbmVyYXRlZCBtaWdyYXRpb25zIHRvIHByZXNlcnZlIGludGVybWVkaWF0ZSBicmFuY2ggc3RhdGVzLgotIENvbXBhdGliaWxpdHkgd2l0aCB0aGUgYWN0dWFsIHRhcmdldC1icmFuY2ggc2NoZW1hIGFuZCBwZXJzaXN0ZWQgZGF0YSByZW1haW5zIG1hbmRhdG9yeS4gQmVmb3JlIGRlbGV0aW5nIGxlZ2FjeS1sb29raW5nIGJlaGF2aW9yLCBwcm92ZSB3aGV0aGVyIHRoZSBzdGF0ZSBjYW4gZXhpc3Qgb24gdGhlIHRhcmdldCBicmFuY2g7IHJlbGVhc2VkIG9yIG1lcmdlZCBzdGF0ZXMgbXVzdCBjb250aW51ZSB0byBmYWlsIHNhZmVseSBvciB1cGdyYWRlIGRldGVybWluaXN0aWNhbGx5LgotIFJldmlld3MgbXVzdCBkaXN0aW5ndWlzaCBjdXJyZW50IGNyYXNoLXNhZmV0eSBldmlkZW5jZSBmcm9tIGRldmVsb3BtZW50LWhpc3RvcnkgY29tcGF0aWJpbGl0eS4gQSBmaWxlc3lzdGVtIG1hcmtlciBvciByZWNvdmVyeSBwcm90b2NvbCB0aGF0IGlzIHN0aWxsIHBhcnQgb2YgdGhlIGZpbmFsIHNhZmV0eSBjb250cmFjdCBpcyBub3QgcmVtb3ZhYmxlIG1lcmVseSBiZWNhdXNlIGVhcmxpZXIgdmVyc2lvbnMgb2YgdGhhdCBwcm90b2NvbCBleGlzdGVkIGR1cmluZyBkZXZlbG9wbWVudC4KCiIiIgpjb250cmlidXRvcl9wb2xpY3kgPSAiIiIjIyMgQ29tcGF0aWJpbGl0eSBhbmQgbWlncmF0aW9uIGRldmVsb3BtZW50IHBvbGljeQoKVGhlIGNvbXBhdGliaWxpdHkgYm91bmRhcnkgZm9yIGEgcHVsbCByZXF1ZXN0IGlzIHRoZSBicmFuY2ggaXQgdGFyZ2V0cy4gRGF0YWJhc2VzLCBmaWxlc3lzdGVtIGFydGlmYWN0cywgQVBJIHN0YXRlcywgYW5kIHJlY292ZXJ5IGZvcm1hdHMgdGhhdCB3ZXJlIHByb2R1Y2VkIG9ubHkgYnkgYW4gdW5tZXJnZWQgZmVhdHVyZSBicmFuY2ggb3IgaW50ZXJtZWRpYXRlIFBSIGltYWdlIGFyZSBkZXZlbG9wbWVudCBhcnRpZmFjdHMsIG5vdCBzdXBwb3J0ZWQgdXBncmFkZSBpbnB1dHMuCgotIERvIG5vdCBrZWVwIHByb2R1Y3Rpb24gY29tcGF0aWJpbGl0eSBwYXRocyBzb2xlbHkgZm9yIGludGVybWVkaWF0ZSB2ZXJzaW9ucyBvZiBhbiB1bm1lcmdlZCBQUi4KLSBQcmVzZXJ2ZSBhbmQgcmVncmVzc2lvbi10ZXN0IGNvbXBhdGliaWxpdHkgd2l0aCBzY2hlbWFzIGFuZCBwZXJzaXN0ZWQgZGF0YSB0aGF0IGFjdHVhbGx5IGV4aXN0IG9uIHRoZSB0YXJnZXQgYnJhbmNoLgotIE9uY2UgYW4gRUYgbWlncmF0aW9uIGlzIG1lcmdlZCBpbnRvIGEgc3VwcG9ydGVkIGJyYW5jaCBpdCBpcyBpbW11dGFibGUgaGlzdG9yeS4gQmVmb3JlIG1lcmdlLCBzdXBlcnNlZGVkIGJyYW5jaC1vbmx5IG1pZ3JhdGlvbnMgc2hvdWxkIGJlIHJlbW92ZWQgYW5kIHRoZSBmaW5hbCBtaWdyYXRpb24gc2V0IHJlZ2VuZXJhdGVkIHdpdGggRUYgZnJvbSB0aGUgdGFyZ2V0IGJyYW5jaCBtb2RlbCBzbmFwc2hvdC4KLSBEbyBub3QgaGFuZC1lZGl0IEVGIHNjYWZmb2xkaW5nIHRvIGVtdWxhdGUgaW50ZXJtZWRpYXRlIGJyYW5jaCBzY2hlbWFzLiBQdXQgdW5hdm9pZGFibGUgZGF0YSByZXBhaXIgZm9yIHJlYWwgdGFyZ2V0LWJyYW5jaCB1cGdyYWRlcyBpbiBleHBsaWNpdCwgdGVzdGVkIHN0YXJ0dXAvcmVjb25jaWxpYXRpb24gcHJpbWl0aXZlcy4KCiIiIgpiYWNrZW5kX3BvbGljeSA9ICIiIiMjIENvbXBhdGliaWxpdHkgYW5kIE1pZ3JhdGlvbiBCb3VuZGFyeQoKQmFja2VuZCBjb21wYXRpYmlsaXR5IGJlZ2lucyBhdCB0aGUgYnJhbmNoIG9yIHJlbGVhc2UgdGhhdCBjb2RlIGhhcyBhY3R1YWxseSByZWFjaGVkLiBBbiB1bm1lcmdlZCBmZWF0dXJlIGJyYW5jaCBpcyBmcmVlIHRvIHJlcGxhY2UgaXRzIG93biBzY2hlbWEsIHJlY292ZXJ5IHByb3RvY29sLCBvciBwZXJzaXN0ZWQgZGV2ZWxvcG1lbnQgYXJ0aWZhY3RzOyB0aG9zZSBpbnRlcm1lZGlhdGUgc3RhdGVzIG11c3Qgbm90IGJlY29tZSBwZXJtYW5lbnQgcHJvZHVjdGlvbiBjb21wYXRpYmlsaXR5IHN1cmZhY2VzIG1lcmVseSBiZWNhdXNlIGEgZGV2ZWxvcGVyIHJhbiBhbiBlYXJsaWVyIFBSIGJ1aWxkLgoKRUYgbWlncmF0aW9ucyBiZWNvbWUgaW1tdXRhYmxlIGhpc3RvcmljYWwgYXJ0aWZhY3RzIHdoZW4gdGhleSBtZXJnZSBpbnRvIHRoZSB0YXJnZXQgYnJhbmNoLiBCZWZvcmUgbWVyZ2UsIGEgZmVhdHVyZSBicmFuY2ggc2hvdWxkIHJlbW92ZSBzdXBlcnNlZGVkIGJyYW5jaC1vbmx5IG1pZ3JhdGlvbnMsIHJlc3RvcmUgdGhlIHRhcmdldCBicmFuY2ggbW9kZWwgc25hcHNob3QsIGFuZCByZWdlbmVyYXRlIHRoZSBzbWFsbGVzdCBmaW5hbCBFRi1zY2FmZm9sZGVkIG1pZ3JhdGlvbiBzZXQgZnJvbSB0aGUgY2xlYW5lZCBtb2RlbC4gQ29tcGF0aWJpbGl0eSBhbmQgc3RhcnR1cCByZWNvbmNpbGlhdGlvbiBtdXN0IGJlIGRlc2lnbmVkIGFnYWluc3QgZGF0YSB0aGF0IGNhbiBleGlzdCBvbiB0aGUgYWN0dWFsIHRhcmdldCBicmFuY2gsIG5vdCBhZ2FpbnN0IHRyYW5zaWVudCBzY2hlbWFzIHRoYXQgZXhpc3RlZCBvbmx5IGR1cmluZyBmZWF0dXJlIGRldmVsb3BtZW50LgoKVGhpcyBydWxlIGRvZXMgbm90IGF1dGhvcml6ZSBkZWxldGluZyBjdXJyZW50IGNyYXNoLXNhZmV0eSBldmlkZW5jZS4gTWFya2VyIGZpbGVzLCBqb3VybmFscywgbGVhc2VzLCBpZGVudGl0aWVzLCBvciBvdGhlciBkdXJhYmxlIHByb3RvY29scyB0aGF0IHRoZSBmaW5hbCBpbXBsZW1lbnRhdGlvbiBpdHNlbGYgd3JpdGVzIGFuZCByZXF1aXJlcyBmb3IgcmVzdGFydCBzYWZldHkgcmVtYWluIHBhcnQgb2YgdGhlIGN1cnJlbnQgY29udHJhY3QuIFJldmlld2VycyBtdXN0IGRpc3Rpbmd1aXNoIHRob3NlIGZyb20gcmVhZGVycywgZW5kcG9pbnRzLCBzdGF0ZXMsIGFuZCBtaWdyYXRpb25zIHdob3NlIG9ubHkgcHVycG9zZSBpcyB1cGdyYWRpbmcgYW4gb2Jzb2xldGUgaW50ZXJtZWRpYXRlIGJyYW5jaCBwcm90b2NvbC4KCiIiIgoKZm9yIGZpbGVuYW1lIGluIFsnLmdpdGh1Yi9BR0VOVFMubWQnLCAnLmdpdGh1Yi9DTEFVREUubWQnXToKICAgIHBhdGggPSBQYXRoKGZpbGVuYW1lKQogICAgdGV4dCA9IHBhdGgucmVhZF90ZXh0KCkKICAgIGlmICcjIyBDb21wYXRpYmlsaXR5IEJvdW5kYXJ5IGZvciBEZXZlbG9wbWVudCBCcmFuY2hlcycgbm90IGluIHRleHQ6CiAgICAgICAgbWFya2VyID0gJyMjIENyb3NzLXNoZWxsIG51bGwgcmVkaXJlY3Rpb25cbicKICAgICAgICBpZiBtYXJrZXIgbm90IGluIHRleHQ6CiAgICAgICAgICAgIHJhaXNlIFN5c3RlbUV4aXQoZidtaXNzaW5nIGluc2VydGlvbiBtYXJrZXIgaW4ge2ZpbGVuYW1lfScpCiAgICAgICAgdGV4dCA9IHRleHQucmVwbGFjZShtYXJrZXIsIGFnZW50X3BvbGljeSArIG1hcmtlciwgMSkKICAgICAgICBwYXRoLndyaXRlX3RleHQodGV4dCkKCnBhdGggPSBQYXRoKCdDT05UUklCVVRJTkcubWQnKQp0ZXh0ID0gcGF0aC5yZWFkX3RleHQoKQppZiAnIyMjIENvbXBhdGliaWxpdHkgYW5kIG1pZ3JhdGlvbiBkZXZlbG9wbWVudCBwb2xpY3knIG5vdCBpbiB0ZXh0OgogICAgbWFya2VyID0gJyMjIyBCcmFuY2hpbmcgTW9kZWxcbicKICAgIGlmIG1hcmtlciBub3QgaW4gdGV4dDoKICAgICAgICByYWlzZSBTeXN0ZW1FeGl0KCdtaXNzaW5nIENPTlRSSUJVVElORyBpbnNlcnRpb24gbWFya2VyJykKICAgIHRleHQgPSB0ZXh0LnJlcGxhY2UobWFya2VyLCBjb250cmlidXRvcl9wb2xpY3kgKyBtYXJrZXIsIDEpCiAgICBwYXRoLndyaXRlX3RleHQodGV4dCkKCnBhdGggPSBQYXRoKCdCQUNLRU5EX0FSQ0hJVEVDVFVSRS5tZCcpCnRleHQgPSBwYXRoLnJlYWRfdGV4dCgpCmlmICcjIyBDb21wYXRpYmlsaXR5IGFuZCBNaWdyYXRpb24gQm91bmRhcnknIG5vdCBpbiB0ZXh0OgogICAgbWFya2VyID0gJyMjIEJvdW5kYXJ5IENsZWFudXBcbicKICAgIGlmIG1hcmtlciBub3QgaW4gdGV4dDoKICAgICAgICByYWlzZSBTeXN0ZW1FeGl0KCdtaXNzaW5nIGFyY2hpdGVjdHVyZSBpbnNlcnRpb24gbWFya2VyJykKICAgIHRleHQgPSB0ZXh0LnJlcGxhY2UobWFya2VyLCBiYWNrZW5kX3BvbGljeSArIG1hcmtlciwgMSkKICAgIHBhdGgud3JpdGVfdGV4dCh0ZXh0KQo=' | base64 -d > /tmp/policy.py + python3 /tmp/policy.py - name: Adversarial review policy-only diff shell: bash @@ -88,17 +30,17 @@ This rule does not authorize deleting current crash-safety evidence. Marker file set -euo pipefail git diff --check mapfile -t changed < <(git diff --name-only) - printf 'Changed files:\n%s\n' "${changed[*]}" - expected=(.github/AGENTS.md .github/CLAUDE.md CONTRIBUTING.md BACKEND_ARCHITECTURE.md) + test "${#changed[@]}" -eq 4 for path in "${changed[@]}"; do case "$path" in .github/AGENTS.md|.github/CLAUDE.md|CONTRIBUTING.md|BACKEND_ARCHITECTURE.md) ;; *) echo "Unexpected file changed: $path" >&2; exit 1 ;; esac done - for path in "${expected[@]}"; do - grep -q 'target branch' "$path" || { echo "Missing target-branch policy in $path" >&2; exit 1; } + for path in .github/AGENTS.md .github/CLAUDE.md CONTRIBUTING.md BACKEND_ARCHITECTURE.md; do + grep -q 'target branch' "$path" done + git diff --stat git diff -- .github/AGENTS.md .github/CLAUDE.md CONTRIBUTING.md BACKEND_ARCHITECTURE.md - name: Commit policy From 8100e2bd16a01154c57282a84a622f66eb29a7e0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:32:36 +0000 Subject: [PATCH 416/464] docs: define target-branch compatibility boundary --- .github/AGENTS.md | 8 ++++++++ .github/CLAUDE.md | 8 ++++++++ BACKEND_ARCHITECTURE.md | 8 ++++++++ CONTRIBUTING.md | 9 +++++++++ 4 files changed, 33 insertions(+) diff --git a/.github/AGENTS.md b/.github/AGENTS.md index ea3c682f0..33d7159ff 100644 --- a/.github/AGENTS.md +++ b/.github/AGENTS.md @@ -36,6 +36,14 @@ Required review behavior: - Do not call a diff clean or merge-ready until two consecutive, complete, unchanged review passes find no confirmed defects or repository-rule violations. - Clearly distinguish confirmed findings, unverified risks, missing platform validation, process blockers, and non-blocking suggestions. +## Compatibility Boundary for Development Branches + +- The authoritative compatibility boundary is the target branch or released version that a change will merge into. Persisted states produced only by an unmerged feature branch, pull-request build, or intermediate development image are not supported upgrade inputs. +- Do not add production compatibility code, recovery states, filesystem marker readers, API endpoints, schema versions, or tests solely to preserve intermediate iterations of an unmerged change. Remove or regenerate those development artifacts before merge. +- EF migrations are immutable historical artifacts only after they reach the target branch. While a feature branch is unmerged, delete superseded branch-only migrations, restore the target branch model snapshot, and regenerate the minimum final EF migration set from the cleaned final model. Never hand-shape generated migrations to preserve intermediate branch states. +- Compatibility with the actual target-branch schema and persisted data remains mandatory. Before deleting legacy-looking behavior, prove whether the state can exist on the target branch; released or merged states must continue to fail safely or upgrade deterministically. +- Reviews must distinguish current crash-safety evidence from development-history compatibility. A filesystem marker or recovery protocol that is still part of the final safety contract is not removable merely because earlier versions of that protocol existed during development. + ## Cross-shell null redirection - Never redirect output to `NUL` from Git Bash, MSYS, WSL, or another POSIX shell; those environments can create a real Windows-reserved file named `NUL` in the checkout. diff --git a/.github/CLAUDE.md b/.github/CLAUDE.md index 8ae5aca58..845eeeee5 100644 --- a/.github/CLAUDE.md +++ b/.github/CLAUDE.md @@ -36,6 +36,14 @@ Required review behavior: - Do not call a diff clean or merge-ready until two consecutive, complete, unchanged review passes find no confirmed defects or repository-rule violations. - Clearly distinguish confirmed findings, unverified risks, missing platform validation, process blockers, and non-blocking suggestions. +## Compatibility Boundary for Development Branches + +- The authoritative compatibility boundary is the target branch or released version that a change will merge into. Persisted states produced only by an unmerged feature branch, pull-request build, or intermediate development image are not supported upgrade inputs. +- Do not add production compatibility code, recovery states, filesystem marker readers, API endpoints, schema versions, or tests solely to preserve intermediate iterations of an unmerged change. Remove or regenerate those development artifacts before merge. +- EF migrations are immutable historical artifacts only after they reach the target branch. While a feature branch is unmerged, delete superseded branch-only migrations, restore the target branch model snapshot, and regenerate the minimum final EF migration set from the cleaned final model. Never hand-shape generated migrations to preserve intermediate branch states. +- Compatibility with the actual target-branch schema and persisted data remains mandatory. Before deleting legacy-looking behavior, prove whether the state can exist on the target branch; released or merged states must continue to fail safely or upgrade deterministically. +- Reviews must distinguish current crash-safety evidence from development-history compatibility. A filesystem marker or recovery protocol that is still part of the final safety contract is not removable merely because earlier versions of that protocol existed during development. + ## Cross-shell null redirection - Never redirect output to `NUL` from Git Bash, MSYS, WSL, or another POSIX shell; those environments can create a real Windows-reserved file named `NUL` in the checkout. diff --git a/BACKEND_ARCHITECTURE.md b/BACKEND_ARCHITECTURE.md index 5a138f198..b5a03b64a 100644 --- a/BACKEND_ARCHITECTURE.md +++ b/BACKEND_ARCHITECTURE.md @@ -38,6 +38,14 @@ New implementation-specific dependencies should go in `listenarr.infrastructure` The application project should not reference SQLite providers, EF Core implementation packages, Swagger/OpenAPI packages, HTML parsers, image libraries, audio tagging libraries, ASP.NET Core hosting types, SignalR hubs, HTTP context, or data-protection implementations directly. SQLite and EF Core belong to infrastructure, Swagger/OpenAPI belongs to API, hosted adapters and SignalR delivery belong to infrastructure/API, and parsing/tagging/image inspection belong behind application ports implemented by infrastructure. +## Compatibility and Migration Boundary + +Backend compatibility begins at the branch or release that code has actually reached. An unmerged feature branch is free to replace its own schema, recovery protocol, or persisted development artifacts; those intermediate states must not become permanent production compatibility surfaces merely because a developer ran an earlier PR build. + +EF migrations become immutable historical artifacts when they merge into the target branch. Before merge, a feature branch should remove superseded branch-only migrations, restore the target branch model snapshot, and regenerate the smallest final EF-scaffolded migration set from the cleaned model. Compatibility and startup reconciliation must be designed against data that can exist on the actual target branch, not against transient schemas that existed only during feature development. + +This rule does not authorize deleting current crash-safety evidence. Marker files, journals, leases, identities, or other durable protocols that the final implementation itself writes and requires for restart safety remain part of the current contract. Reviewers must distinguish those from readers, endpoints, states, and migrations whose only purpose is upgrading an obsolete intermediate branch protocol. + ## Boundary Cleanup The application layer now delegates these infrastructure-shaped concerns through interfaces: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index af5df7acc..3fa13c78b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -151,6 +151,15 @@ This project follows a layered pattern: domain models in `listenarr.domain`, EF - Run frontend type checks: `cd fe && npm run type-check` - Ensure all tests pass before submitting PR +### Compatibility and migration development policy + +The compatibility boundary for a pull request is the branch it targets. Databases, filesystem artifacts, API states, and recovery formats that were produced only by an unmerged feature branch or intermediate PR image are development artifacts, not supported upgrade inputs. + +- Do not keep production compatibility paths solely for intermediate versions of an unmerged PR. +- Preserve and regression-test compatibility with schemas and persisted data that actually exist on the target branch. +- Once an EF migration is merged into a supported branch it is immutable history. Before merge, superseded branch-only migrations should be removed and the final migration set regenerated with EF from the target branch model snapshot. +- Do not hand-edit EF scaffolding to emulate intermediate branch schemas. Put unavoidable data repair for real target-branch upgrades in explicit, tested startup/reconciliation primitives. + ### Branching Model Listenarr follows a **canary → beta → main** release flow: From 7b68e86eb9b4fdfe45a7073dc68c6ad7494f4b3f Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Fri, 7 Aug 2026 10:38:50 -0400 Subject: [PATCH 417/464] chore(ci): execute legacy relocation cleanup --- .github/workflows/pr717-cleanup-analysis.yml | 56 +++++++++++++------- 1 file changed, 36 insertions(+), 20 deletions(-) diff --git a/.github/workflows/pr717-cleanup-analysis.yml b/.github/workflows/pr717-cleanup-analysis.yml index 3882f6513..eeb3f29a9 100644 --- a/.github/workflows/pr717-cleanup-analysis.yml +++ b/.github/workflows/pr717-cleanup-analysis.yml @@ -9,7 +9,7 @@ permissions: contents: write jobs: - codify-policy: + remove-legacy-relocation: if: github.actor != 'github-actions[bot]' runs-on: ubuntu-latest steps: @@ -17,38 +17,54 @@ jobs: with: fetch-depth: 0 - - name: Codify compatibility and migration policy + - name: Remove intermediate root relocation compatibility shell: bash run: | set -euo pipefail - echo 'ZnJvbSBwYXRobGliIGltcG9ydCBQYXRoCgphZ2VudF9wb2xpY3kgPSAiIiIjIyBDb21wYXRpYmlsaXR5IEJvdW5kYXJ5IGZvciBEZXZlbG9wbWVudCBCcmFuY2hlcwoKLSBUaGUgYXV0aG9yaXRhdGl2ZSBjb21wYXRpYmlsaXR5IGJvdW5kYXJ5IGlzIHRoZSB0YXJnZXQgYnJhbmNoIG9yIHJlbGVhc2VkIHZlcnNpb24gdGhhdCBhIGNoYW5nZSB3aWxsIG1lcmdlIGludG8uIFBlcnNpc3RlZCBzdGF0ZXMgcHJvZHVjZWQgb25seSBieSBhbiB1bm1lcmdlZCBmZWF0dXJlIGJyYW5jaCwgcHVsbC1yZXF1ZXN0IGJ1aWxkLCBvciBpbnRlcm1lZGlhdGUgZGV2ZWxvcG1lbnQgaW1hZ2UgYXJlIG5vdCBzdXBwb3J0ZWQgdXBncmFkZSBpbnB1dHMuCi0gRG8gbm90IGFkZCBwcm9kdWN0aW9uIGNvbXBhdGliaWxpdHkgY29kZSwgcmVjb3Zlcnkgc3RhdGVzLCBmaWxlc3lzdGVtIG1hcmtlciByZWFkZXJzLCBBUEkgZW5kcG9pbnRzLCBzY2hlbWEgdmVyc2lvbnMsIG9yIHRlc3RzIHNvbGVseSB0byBwcmVzZXJ2ZSBpbnRlcm1lZGlhdGUgaXRlcmF0aW9ucyBvZiBhbiB1bm1lcmdlZCBjaGFuZ2UuIFJlbW92ZSBvciByZWdlbmVyYXRlIHRob3NlIGRldmVsb3BtZW50IGFydGlmYWN0cyBiZWZvcmUgbWVyZ2UuCi0gRUYgbWlncmF0aW9ucyBhcmUgaW1tdXRhYmxlIGhpc3RvcmljYWwgYXJ0aWZhY3RzIG9ubHkgYWZ0ZXIgdGhleSByZWFjaCB0aGUgdGFyZ2V0IGJyYW5jaC4gV2hpbGUgYSBmZWF0dXJlIGJyYW5jaCBpcyB1bm1lcmdlZCwgZGVsZXRlIHN1cGVyc2VkZWQgYnJhbmNoLW9ubHkgbWlncmF0aW9ucywgcmVzdG9yZSB0aGUgdGFyZ2V0IGJyYW5jaCBtb2RlbCBzbmFwc2hvdCwgYW5kIHJlZ2VuZXJhdGUgdGhlIG1pbmltdW0gZmluYWwgRUYgbWlncmF0aW9uIHNldCBmcm9tIHRoZSBjbGVhbmVkIGZpbmFsIG1vZGVsLiBOZXZlciBoYW5kLXNoYXBlIGdlbmVyYXRlZCBtaWdyYXRpb25zIHRvIHByZXNlcnZlIGludGVybWVkaWF0ZSBicmFuY2ggc3RhdGVzLgotIENvbXBhdGliaWxpdHkgd2l0aCB0aGUgYWN0dWFsIHRhcmdldC1icmFuY2ggc2NoZW1hIGFuZCBwZXJzaXN0ZWQgZGF0YSByZW1haW5zIG1hbmRhdG9yeS4gQmVmb3JlIGRlbGV0aW5nIGxlZ2FjeS1sb29raW5nIGJlaGF2aW9yLCBwcm92ZSB3aGV0aGVyIHRoZSBzdGF0ZSBjYW4gZXhpc3Qgb24gdGhlIHRhcmdldCBicmFuY2g7IHJlbGVhc2VkIG9yIG1lcmdlZCBzdGF0ZXMgbXVzdCBjb250aW51ZSB0byBmYWlsIHNhZmVseSBvciB1cGdyYWRlIGRldGVybWluaXN0aWNhbGx5LgotIFJldmlld3MgbXVzdCBkaXN0aW5ndWlzaCBjdXJyZW50IGNyYXNoLXNhZmV0eSBldmlkZW5jZSBmcm9tIGRldmVsb3BtZW50LWhpc3RvcnkgY29tcGF0aWJpbGl0eS4gQSBmaWxlc3lzdGVtIG1hcmtlciBvciByZWNvdmVyeSBwcm90b2NvbCB0aGF0IGlzIHN0aWxsIHBhcnQgb2YgdGhlIGZpbmFsIHNhZmV0eSBjb250cmFjdCBpcyBub3QgcmVtb3ZhYmxlIG1lcmVseSBiZWNhdXNlIGVhcmxpZXIgdmVyc2lvbnMgb2YgdGhhdCBwcm90b2NvbCBleGlzdGVkIGR1cmluZyBkZXZlbG9wbWVudC4KCiIiIgpjb250cmlidXRvcl9wb2xpY3kgPSAiIiIjIyMgQ29tcGF0aWJpbGl0eSBhbmQgbWlncmF0aW9uIGRldmVsb3BtZW50IHBvbGljeQoKVGhlIGNvbXBhdGliaWxpdHkgYm91bmRhcnkgZm9yIGEgcHVsbCByZXF1ZXN0IGlzIHRoZSBicmFuY2ggaXQgdGFyZ2V0cy4gRGF0YWJhc2VzLCBmaWxlc3lzdGVtIGFydGlmYWN0cywgQVBJIHN0YXRlcywgYW5kIHJlY292ZXJ5IGZvcm1hdHMgdGhhdCB3ZXJlIHByb2R1Y2VkIG9ubHkgYnkgYW4gdW5tZXJnZWQgZmVhdHVyZSBicmFuY2ggb3IgaW50ZXJtZWRpYXRlIFBSIGltYWdlIGFyZSBkZXZlbG9wbWVudCBhcnRpZmFjdHMsIG5vdCBzdXBwb3J0ZWQgdXBncmFkZSBpbnB1dHMuCgotIERvIG5vdCBrZWVwIHByb2R1Y3Rpb24gY29tcGF0aWJpbGl0eSBwYXRocyBzb2xlbHkgZm9yIGludGVybWVkaWF0ZSB2ZXJzaW9ucyBvZiBhbiB1bm1lcmdlZCBQUi4KLSBQcmVzZXJ2ZSBhbmQgcmVncmVzc2lvbi10ZXN0IGNvbXBhdGliaWxpdHkgd2l0aCBzY2hlbWFzIGFuZCBwZXJzaXN0ZWQgZGF0YSB0aGF0IGFjdHVhbGx5IGV4aXN0IG9uIHRoZSB0YXJnZXQgYnJhbmNoLgotIE9uY2UgYW4gRUYgbWlncmF0aW9uIGlzIG1lcmdlZCBpbnRvIGEgc3VwcG9ydGVkIGJyYW5jaCBpdCBpcyBpbW11dGFibGUgaGlzdG9yeS4gQmVmb3JlIG1lcmdlLCBzdXBlcnNlZGVkIGJyYW5jaC1vbmx5IG1pZ3JhdGlvbnMgc2hvdWxkIGJlIHJlbW92ZWQgYW5kIHRoZSBmaW5hbCBtaWdyYXRpb24gc2V0IHJlZ2VuZXJhdGVkIHdpdGggRUYgZnJvbSB0aGUgdGFyZ2V0IGJyYW5jaCBtb2RlbCBzbmFwc2hvdC4KLSBEbyBub3QgaGFuZC1lZGl0IEVGIHNjYWZmb2xkaW5nIHRvIGVtdWxhdGUgaW50ZXJtZWRpYXRlIGJyYW5jaCBzY2hlbWFzLiBQdXQgdW5hdm9pZGFibGUgZGF0YSByZXBhaXIgZm9yIHJlYWwgdGFyZ2V0LWJyYW5jaCB1cGdyYWRlcyBpbiBleHBsaWNpdCwgdGVzdGVkIHN0YXJ0dXAvcmVjb25jaWxpYXRpb24gcHJpbWl0aXZlcy4KCiIiIgpiYWNrZW5kX3BvbGljeSA9ICIiIiMjIENvbXBhdGliaWxpdHkgYW5kIE1pZ3JhdGlvbiBCb3VuZGFyeQoKQmFja2VuZCBjb21wYXRpYmlsaXR5IGJlZ2lucyBhdCB0aGUgYnJhbmNoIG9yIHJlbGVhc2UgdGhhdCBjb2RlIGhhcyBhY3R1YWxseSByZWFjaGVkLiBBbiB1bm1lcmdlZCBmZWF0dXJlIGJyYW5jaCBpcyBmcmVlIHRvIHJlcGxhY2UgaXRzIG93biBzY2hlbWEsIHJlY292ZXJ5IHByb3RvY29sLCBvciBwZXJzaXN0ZWQgZGV2ZWxvcG1lbnQgYXJ0aWZhY3RzOyB0aG9zZSBpbnRlcm1lZGlhdGUgc3RhdGVzIG11c3Qgbm90IGJlY29tZSBwZXJtYW5lbnQgcHJvZHVjdGlvbiBjb21wYXRpYmlsaXR5IHN1cmZhY2VzIG1lcmVseSBiZWNhdXNlIGEgZGV2ZWxvcGVyIHJhbiBhbiBlYXJsaWVyIFBSIGJ1aWxkLgoKRUYgbWlncmF0aW9ucyBiZWNvbWUgaW1tdXRhYmxlIGhpc3RvcmljYWwgYXJ0aWZhY3RzIHdoZW4gdGhleSBtZXJnZSBpbnRvIHRoZSB0YXJnZXQgYnJhbmNoLiBCZWZvcmUgbWVyZ2UsIGEgZmVhdHVyZSBicmFuY2ggc2hvdWxkIHJlbW92ZSBzdXBlcnNlZGVkIGJyYW5jaC1vbmx5IG1pZ3JhdGlvbnMsIHJlc3RvcmUgdGhlIHRhcmdldCBicmFuY2ggbW9kZWwgc25hcHNob3QsIGFuZCByZWdlbmVyYXRlIHRoZSBzbWFsbGVzdCBmaW5hbCBFRi1zY2FmZm9sZGVkIG1pZ3JhdGlvbiBzZXQgZnJvbSB0aGUgY2xlYW5lZCBtb2RlbC4gQ29tcGF0aWJpbGl0eSBhbmQgc3RhcnR1cCByZWNvbmNpbGlhdGlvbiBtdXN0IGJlIGRlc2lnbmVkIGFnYWluc3QgZGF0YSB0aGF0IGNhbiBleGlzdCBvbiB0aGUgYWN0dWFsIHRhcmdldCBicmFuY2gsIG5vdCBhZ2FpbnN0IHRyYW5zaWVudCBzY2hlbWFzIHRoYXQgZXhpc3RlZCBvbmx5IGR1cmluZyBmZWF0dXJlIGRldmVsb3BtZW50LgoKVGhpcyBydWxlIGRvZXMgbm90IGF1dGhvcml6ZSBkZWxldGluZyBjdXJyZW50IGNyYXNoLXNhZmV0eSBldmlkZW5jZS4gTWFya2VyIGZpbGVzLCBqb3VybmFscywgbGVhc2VzLCBpZGVudGl0aWVzLCBvciBvdGhlciBkdXJhYmxlIHByb3RvY29scyB0aGF0IHRoZSBmaW5hbCBpbXBsZW1lbnRhdGlvbiBpdHNlbGYgd3JpdGVzIGFuZCByZXF1aXJlcyBmb3IgcmVzdGFydCBzYWZldHkgcmVtYWluIHBhcnQgb2YgdGhlIGN1cnJlbnQgY29udHJhY3QuIFJldmlld2VycyBtdXN0IGRpc3Rpbmd1aXNoIHRob3NlIGZyb20gcmVhZGVycywgZW5kcG9pbnRzLCBzdGF0ZXMsIGFuZCBtaWdyYXRpb25zIHdob3NlIG9ubHkgcHVycG9zZSBpcyB1cGdyYWRpbmcgYW4gb2Jzb2xldGUgaW50ZXJtZWRpYXRlIGJyYW5jaCBwcm90b2NvbC4KCiIiIgoKZm9yIGZpbGVuYW1lIGluIFsnLmdpdGh1Yi9BR0VOVFMubWQnLCAnLmdpdGh1Yi9DTEFVREUubWQnXToKICAgIHBhdGggPSBQYXRoKGZpbGVuYW1lKQogICAgdGV4dCA9IHBhdGgucmVhZF90ZXh0KCkKICAgIGlmICcjIyBDb21wYXRpYmlsaXR5IEJvdW5kYXJ5IGZvciBEZXZlbG9wbWVudCBCcmFuY2hlcycgbm90IGluIHRleHQ6CiAgICAgICAgbWFya2VyID0gJyMjIENyb3NzLXNoZWxsIG51bGwgcmVkaXJlY3Rpb25cbicKICAgICAgICBpZiBtYXJrZXIgbm90IGluIHRleHQ6CiAgICAgICAgICAgIHJhaXNlIFN5c3RlbUV4aXQoZidtaXNzaW5nIGluc2VydGlvbiBtYXJrZXIgaW4ge2ZpbGVuYW1lfScpCiAgICAgICAgdGV4dCA9IHRleHQucmVwbGFjZShtYXJrZXIsIGFnZW50X3BvbGljeSArIG1hcmtlciwgMSkKICAgICAgICBwYXRoLndyaXRlX3RleHQodGV4dCkKCnBhdGggPSBQYXRoKCdDT05UUklCVVRJTkcubWQnKQp0ZXh0ID0gcGF0aC5yZWFkX3RleHQoKQppZiAnIyMjIENvbXBhdGliaWxpdHkgYW5kIG1pZ3JhdGlvbiBkZXZlbG9wbWVudCBwb2xpY3knIG5vdCBpbiB0ZXh0OgogICAgbWFya2VyID0gJyMjIyBCcmFuY2hpbmcgTW9kZWxcbicKICAgIGlmIG1hcmtlciBub3QgaW4gdGV4dDoKICAgICAgICByYWlzZSBTeXN0ZW1FeGl0KCdtaXNzaW5nIENPTlRSSUJVVElORyBpbnNlcnRpb24gbWFya2VyJykKICAgIHRleHQgPSB0ZXh0LnJlcGxhY2UobWFya2VyLCBjb250cmlidXRvcl9wb2xpY3kgKyBtYXJrZXIsIDEpCiAgICBwYXRoLndyaXRlX3RleHQodGV4dCkKCnBhdGggPSBQYXRoKCdCQUNLRU5EX0FSQ0hJVEVDVFVSRS5tZCcpCnRleHQgPSBwYXRoLnJlYWRfdGV4dCgpCmlmICcjIyBDb21wYXRpYmlsaXR5IGFuZCBNaWdyYXRpb24gQm91bmRhcnknIG5vdCBpbiB0ZXh0OgogICAgbWFya2VyID0gJyMjIEJvdW5kYXJ5IENsZWFudXBcbicKICAgIGlmIG1hcmtlciBub3QgaW4gdGV4dDoKICAgICAgICByYWlzZSBTeXN0ZW1FeGl0KCdtaXNzaW5nIGFyY2hpdGVjdHVyZSBpbnNlcnRpb24gbWFya2VyJykKICAgIHRleHQgPSB0ZXh0LnJlcGxhY2UobWFya2VyLCBiYWNrZW5kX3BvbGljeSArIG1hcmtlciwgMSkKICAgIHBhdGgud3JpdGVfdGV4dCh0ZXh0KQo=' | base64 -d > /tmp/policy.py - python3 /tmp/policy.py + echo 'CmZyb20gcGF0aGxpYiBpbXBvcnQgUGF0aAppbXBvcnQgcmUKCmRlZiByZXBsYWNlX29uY2UocGF0aCwgb2xkLCBuZXcpOgogICAgcCA9IFBhdGgocGF0aCkKICAgIHRleHQgPSBwLnJlYWRfdGV4dCgpCiAgICBpZiBvbGQgbm90IGluIHRleHQ6CiAgICAgICAgcmFpc2UgU3lzdGVtRXhpdChmIm1pc3NpbmcgZXhwZWN0ZWQgdGV4dCBpbiB7cGF0aH06IHtvbGRbOjgwXSFyfSIpCiAgICBwLndyaXRlX3RleHQodGV4dC5yZXBsYWNlKG9sZCwgbmV3LCAxKSkKCmRlZiByZW1vdmVfYnJhY2VkX2RlY2xhcmF0aW9uKHBhdGgsIG5lZWRsZSwgaW5jbHVkZV9hdHRyaWJ1dGVzPUZhbHNlKToKICAgIHAgPSBQYXRoKHBhdGgpCiAgICB0ZXh0ID0gcC5yZWFkX3RleHQoKQogICAgcG9zID0gdGV4dC5maW5kKG5lZWRsZSkKICAgIGlmIHBvcyA8IDA6CiAgICAgICAgcmFpc2UgU3lzdGVtRXhpdChmIm1pc3NpbmcgZGVjbGFyYXRpb24ge25lZWRsZSFyfSBpbiB7cGF0aH0iKQogICAgc3RhcnQgPSB0ZXh0LnJmaW5kKCJcbiIsIDAsIHBvcykgKyAxCiAgICBpZiBpbmNsdWRlX2F0dHJpYnV0ZXM6CiAgICAgICAgc2NhbiA9IHN0YXJ0CiAgICAgICAgd2hpbGUgc2NhbiA+IDA6CiAgICAgICAgICAgIHByZXZfZW5kID0gc2NhbiAtIDEKICAgICAgICAgICAgcHJldl9zdGFydCA9IHRleHQucmZpbmQoIlxuIiwgMCwgcHJldl9lbmQpICsgMQogICAgICAgICAgICBsaW5lID0gdGV4dFtwcmV2X3N0YXJ0OnByZXZfZW5kKzFdLnN0cmlwKCkKICAgICAgICAgICAgaWYgbGluZS5zdGFydHN3aXRoKCJbIikgb3IgbGluZSA9PSAiIjoKICAgICAgICAgICAgICAgIHNjYW4gPSBwcmV2X3N0YXJ0CiAgICAgICAgICAgICAgICBpZiBsaW5lID09ICIiOgogICAgICAgICAgICAgICAgICAgICMgZG9uJ3QgY3Jvc3MgYmxhbmsgc2VwYXJhdG9yCiAgICAgICAgICAgICAgICAgICAgYnJlYWsKICAgICAgICAgICAgICAgIHN0YXJ0ID0gcHJldl9zdGFydAogICAgICAgICAgICAgICAgY29udGludWUKICAgICAgICAgICAgYnJlYWsKICAgIGJyYWNlID0gdGV4dC5maW5kKCJ7IiwgcG9zKQogICAgaWYgYnJhY2UgPCAwOgogICAgICAgIHJhaXNlIFN5c3RlbUV4aXQoZiJtaXNzaW5nIG9wZW5pbmcgYnJhY2UgZm9yIHtuZWVkbGUhcn0gaW4ge3BhdGh9IikKICAgIGRlcHRoID0gMAogICAgaSA9IGJyYWNlCiAgICBpbl9zdHIgPSBOb25lCiAgICBlc2MgPSBGYWxzZQogICAgd2hpbGUgaSA8IGxlbih0ZXh0KToKICAgICAgICBjaCA9IHRleHRbaV0KICAgICAgICBpZiBpbl9zdHI6CiAgICAgICAgICAgIGlmIGVzYzoKICAgICAgICAgICAgICAgIGVzYyA9IEZhbHNlCiAgICAgICAgICAgIGVsaWYgY2ggPT0gIlxcIjoKICAgICAgICAgICAgICAgIGVzYyA9IFRydWUKICAgICAgICAgICAgZWxpZiBjaCA9PSBpbl9zdHI6CiAgICAgICAgICAgICAgICBpbl9zdHIgPSBOb25lCiAgICAgICAgZWxzZToKICAgICAgICAgICAgaWYgY2ggaW4gKCInIiwgJyInKToKICAgICAgICAgICAgICAgIGluX3N0ciA9IGNoCiAgICAgICAgICAgIGVsaWYgY2ggPT0gInsiOgogICAgICAgICAgICAgICAgZGVwdGggKz0gMQogICAgICAgICAgICBlbGlmIGNoID09ICJ9IjoKICAgICAgICAgICAgICAgIGRlcHRoIC09IDEKICAgICAgICAgICAgICAgIGlmIGRlcHRoID09IDA6CiAgICAgICAgICAgICAgICAgICAgZW5kID0gaSArIDEKICAgICAgICAgICAgICAgICAgICB3aGlsZSBlbmQgPCBsZW4odGV4dCkgYW5kIHRleHRbZW5kXSBpbiAiIFx0IjoKICAgICAgICAgICAgICAgICAgICAgICAgZW5kICs9IDEKICAgICAgICAgICAgICAgICAgICBpZiBlbmQgPCBsZW4odGV4dCkgYW5kIHRleHRbZW5kXSA9PSAiXHIiOgogICAgICAgICAgICAgICAgICAgICAgICBlbmQgKz0gMQogICAgICAgICAgICAgICAgICAgIGlmIGVuZCA8IGxlbih0ZXh0KSBhbmQgdGV4dFtlbmRdID09ICJcbiI6CiAgICAgICAgICAgICAgICAgICAgICAgIGVuZCArPSAxCiAgICAgICAgICAgICAgICAgICAgIyByZW1vdmUgb25lIGZvbGxvd2luZyBibGFuayBsaW5lCiAgICAgICAgICAgICAgICAgICAgaWYgZW5kIDwgbGVuKHRleHQpIGFuZCB0ZXh0W2VuZF0gPT0gIlxuIjoKICAgICAgICAgICAgICAgICAgICAgICAgZW5kICs9IDEKICAgICAgICAgICAgICAgICAgICBwLndyaXRlX3RleHQodGV4dFs6c3RhcnRdICsgdGV4dFtlbmQ6XSkKICAgICAgICAgICAgICAgICAgICByZXR1cm4KICAgICAgICBpICs9IDEKICAgIHJhaXNlIFN5c3RlbUV4aXQoZiJ1bmJhbGFuY2VkIGJyYWNlcyBmb3Ige25lZWRsZSFyfSBpbiB7cGF0aH0iKQoKZGVmIHJlbW92ZV90c190ZXN0KHBhdGgsIHRpdGxlKToKICAgIHAgPSBQYXRoKHBhdGgpCiAgICB0ZXh0ID0gcC5yZWFkX3RleHQoKQogICAgbmVlZGxlID0gZiIgIGl0KCd7dGl0bGV9JyIKICAgIHBvcyA9IHRleHQuZmluZChuZWVkbGUpCiAgICBpZiBwb3MgPCAwOgogICAgICAgIHJhaXNlIFN5c3RlbUV4aXQoZiJtaXNzaW5nIHRlc3Qge3RpdGxlIXJ9IGluIHtwYXRofSIpCiAgICBzdGFydCA9IHBvcwogICAgbmV4dF9wb3MgPSB0ZXh0LmZpbmQoIlxuICBpdCgiLCBwb3MgKyBsZW4obmVlZGxlKSkKICAgIGlmIG5leHRfcG9zIDwgMDoKICAgICAgICByYWlzZSBTeXN0ZW1FeGl0KGYiY291bGQgbm90IGZpbmQgZm9sbG93aW5nIHRlc3QgYWZ0ZXIge3RpdGxlIXJ9IikKICAgIHAud3JpdGVfdGV4dCh0ZXh0WzpzdGFydF0gKyB0ZXh0W25leHRfcG9zKzE6XSkKCiMgQVBJL2FwcGxpY2F0aW9uIGNvbnRyYWN0LgpyZXBsYWNlX29uY2UoCiAgICAibGlzdGVuYXJyLmFwcGxpY2F0aW9uL0F1ZGlvYm9va3MvQ29udHJhY3RzL0lSb290Rm9sZGVyUmVsb2NhdGlvblNlcnZpY2UuY3MiLAogICAgIiIiICAgIFRhc2s8Um9vdEZvbGRlclBhdGhDaGFuZ2VSZXN1bHQ+IFJlYXV0aG9yaXplTGVnYWN5VGFyZ2V0QXN5bmMoCiAgICAgICAgR3VpZCByZWxvY2F0aW9uSWQsCiAgICAgICAgc3RyaW5nIGNvbmZpcm1lZFRhcmdldFBhdGgsCiAgICAgICAgQ2FuY2VsbGF0aW9uVG9rZW4gY2FuY2VsbGF0aW9uVG9rZW4gPSBkZWZhdWx0KTsKCiIiIiwKICAgICIiLAopCnJlcGxhY2Vfb25jZSgKICAgICJsaXN0ZW5hcnIuYXBwbGljYXRpb24vQXVkaW9ib29rcy9Db250cmFjdHMvUm9vdEZvbGRlclJlbG9jYXRpb25QdWJsaWNQcm9qZWN0aW9uLmNzIiwKICAgICIiIiAgICAgICAgICAgIFRhcmdldElkZW50aXR5RW5yb2xsbWVudFN0YXRlLkxlZ2FjeVVuZW5yb2xsZWQgPT4KICAgICAgICAgICAgICAgICJUaGUgcmVsb2NhdGlvbiB0YXJnZXQgbXVzdCBiZSByZWF1dGhvcml6ZWQgYmVmb3JlIHRoZSByZWxvY2F0aW9uIGNhbiBjb250aW51ZS4iLAoiIiIsCiAgICAiIiwKKQpyZXBsYWNlX29uY2UoCiAgICAibGlzdGVuYXJyLmRvbWFpbi9BdWRpb2Jvb2tzL1Jvb3RGb2xkZXJSZWxvY2F0aW9uLmNzIiwKICAgICIgICAgTGVnYWN5VW5lbnJvbGxlZCxcbiIsCiAgICAiIiwKKQpyZW1vdmVfYnJhY2VkX2RlY2xhcmF0aW9uKAogICAgImxpc3RlbmFyci5kb21haW4vQXVkaW9ib29rcy9Sb290Rm9sZGVyUmVsb2NhdGlvbi5jcyIsCiAgICAicHVibGljIHN0YXRpYyBjbGFzcyBUYXJnZXRJZGVudGl0eUVucm9sbG1lbnQiLAopCnJlcGxhY2Vfb25jZSgKICAgICJsaXN0ZW5hcnIuaW5mcmFzdHJ1Y3R1cmUvUGVyc2lzdGVuY2UvUm9vdEZvbGRlck9iamVjdElkZW50aXR5UmVjb25jaWxlci5jcyIsCiAgICAiIiIgICAgICAgIHZhciByZWxvY2F0aW9ucyA9IGF3YWl0IGRiLlJvb3RGb2xkZXJSZWxvY2F0aW9ucwogICAgICAgICAgICAuVG9MaXN0QXN5bmMoY2FuY2VsbGF0aW9uVG9rZW4pOwogICAgICAgIGZvcmVhY2ggKHZhciByZWxvY2F0aW9uIGluIHJlbG9jYXRpb25zKQogICAgICAgIHsKICAgICAgICAgICAgcmVsb2NhdGlvbi5UYXJnZXRJZGVudGl0eUVucm9sbG1lbnRTdGF0ZSA9CiAgICAgICAgICAgICAgICBUYXJnZXRJZGVudGl0eUVucm9sbG1lbnQuQ2xhc3NpZnkocmVsb2NhdGlvbik7CiAgICAgICAgfQoKIiIiLAogICAgIiIsCikKcmVwbGFjZV9vbmNlKAogICAgImxpc3RlbmFyci5pbmZyYXN0cnVjdHVyZS9MaWJyYXJ5L01vdmluZy9Sb290Rm9sZGVyUmVsb2NhdGlvblNlcnZpY2UuUmV0cnkuY3MiLAogICAgIiIiICAgICAgICBpZiAocmVsb2NhdGlvbi5UYXJnZXRJZGVudGl0eUVucm9sbG1lbnRTdGF0ZQogICAgICAgICAgICA9PSBUYXJnZXRJZGVudGl0eUVucm9sbG1lbnRTdGF0ZS5MZWdhY3lVbmVucm9sbGVkKQogICAgICAgIHsKICAgICAgICAgICAgdGhyb3cgbmV3IEludmFsaWRPcGVyYXRpb25FeGNlcHRpb24oCiAgICAgICAgICAgICAgICAiVGhlIGxlZ2FjeSByZWxvY2F0aW9uIHRhcmdldCBtdXN0IGJlIGV4cGxpY2l0bHkgcmVhdXRob3JpemVkIGJlZm9yZSByZXRyeS4iKTsKICAgICAgICB9CiIiIiwKICAgICIiLAopCgojIENvbnRyb2xsZXIgZW5kcG9pbnQuCnJlcGxhY2Vfb25jZSgKICAgICJsaXN0ZW5hcnIuYXBpL0ZlYXR1cmVzL0xpYnJhcnkvUm9vdEZvbGRlclJlbG9jYXRpb25zQ29udHJvbGxlci5jcyIsCiAgICAiICAgIHB1YmxpYyBzZWFsZWQgcmVjb3JkIFJlYXV0aG9yaXplTGVnYWN5VGFyZ2V0UmVxdWVzdChzdHJpbmcgQ29uZmlybWVkVGFyZ2V0UGF0aCk7XG5cbiIsCiAgICAiIiwKKQpyZW1vdmVfYnJhY2VkX2RlY2xhcmF0aW9uKAogICAgImxpc3RlbmFyci5hcGkvRmVhdHVyZXMvTGlicmFyeS9Sb290Rm9sZGVyUmVsb2NhdGlvbnNDb250cm9sbGVyLmNzIiwKICAgICJwdWJsaWMgYXN5bmMgVGFzazxJQWN0aW9uUmVzdWx0PiBSZWF1dGhvcml6ZUxlZ2FjeVRhcmdldCgiLAogICAgaW5jbHVkZV9hdHRyaWJ1dGVzPVRydWUsCikKCiMgRGVkaWNhdGVkIGltcGxlbWVudGF0aW9uIGlzIGVudGlyZWx5IGJyYW5jaC1oaXN0b3J5IGNvbXBhdGliaWxpdHkuClBhdGgoImxpc3RlbmFyci5pbmZyYXN0cnVjdHVyZS9MaWJyYXJ5L01vdmluZy9Sb290Rm9sZGVyUmVsb2NhdGlvblNlcnZpY2UuUmVhdXRob3JpemF0aW9uLmNzIikudW5saW5rKCkKCiMgRnJvbnRlbmQgY29udHJhY3QgYW5kIHN0b3JlLgpyZXBsYWNlX29uY2UoCiAgICAiZmUvc3JjL3R5cGVzL2luZGV4LnRzIiwKICAgICIgIHRhcmdldElkZW50aXR5RW5yb2xsbWVudFN0YXRlOiAnTm90UmVxdWlyZWQnIHwgJ0F1dGhvcml6ZWQnIHwgJ0xlZ2FjeVVuZW5yb2xsZWQnIHwgJ1VuYXZhaWxhYmxlJ1xuIiwKICAgICIgIHRhcmdldElkZW50aXR5RW5yb2xsbWVudFN0YXRlOiAnTm90UmVxdWlyZWQnIHwgJ0F1dGhvcml6ZWQnIHwgJ1VuYXZhaWxhYmxlJ1xuIiwKKQpyZW1vdmVfYnJhY2VkX2RlY2xhcmF0aW9uKAogICAgImZlL3NyYy9zZXJ2aWNlcy9hcGkudHMiLAogICAgImFzeW5jIHJlYXV0aG9yaXplTGVnYWN5Um9vdEZvbGRlclJlbG9jYXRpb25UYXJnZXQoIiwKKQpyZW1vdmVfYnJhY2VkX2RlY2xhcmF0aW9uKAogICAgImZlL3NyYy9zdG9yZXMvcm9vdEZvbGRlcnMudHMiLAogICAgImFzeW5jIGZ1bmN0aW9uIHJlYXV0aG9yaXplTGVnYWN5VGFyZ2V0KCIsCikKcmVwbGFjZV9vbmNlKAogICAgImZlL3NyYy9zdG9yZXMvcm9vdEZvbGRlcnMudHMiLAogICAgIiAgICByZWF1dGhvcml6ZUxlZ2FjeVRhcmdldCxcbiIsCiAgICAiIiwKKQoKIyBGcm9udGVuZCB2aWV3OiByZW1vdmUgb25seSByZWxvY2F0aW9uLXRhcmdldCByZWF1dGhvcml6YXRpb247IHJvb3QgaWRlbnRpdHkgcmVhdXRob3JpemF0aW9uIHJlbWFpbnMuCnJlcGxhY2Vfb25jZSgKICAgICJmZS9zcmMvY29tcG9uZW50cy9zZXR0aW5ncy9Sb290Rm9sZGVyc1NldHRpbmdzLnZ1ZSIsCiAgICAiIiIgICAgICAgICAgICAgIDxidXR0b24KICAgICAgICAgICAgICAgIHYtaWY9ImNhblJlYXV0aG9yaXplTGVnYWN5VGFyZ2V0KGZvbGRlcikiCiAgICAgICAgICAgICAgICB0eXBlPSJidXR0b24iCiAgICAgICAgICAgICAgICBjbGFzcz0iYnRuIGJ0bi1zZWNvbmRhcnkiCiAgICAgICAgICAgICAgICBkYXRhLWN5PSJyZWF1dGhvcml6ZS1yZWxvY2F0aW9uLXRhcmdldCIKICAgICAgICAgICAgICAgIEBjbGljaz0iY29uZmlybUxlZ2FjeVRhcmdldFJlYXV0aG9yaXphdGlvbihmb2xkZXIpIgogICAgICAgICAgICAgID4KICAgICAgICAgICAgICAgIFJlYXV0aG9yaXplIHRhcmdldAogICAgICAgICAgICAgIDwvYnV0dG9uPgoiIiIsCiAgICAiIiwKKQpyZXBsYWNlX29uY2UoCiAgICAiZmUvc3JjL2NvbXBvbmVudHMvc2V0dGluZ3MvUm9vdEZvbGRlcnNTZXR0aW5ncy52dWUiLAogICAgIiIiICAgIDxEZWxldGVDb25maXJtYXRpb25Nb2RhbAogICAgICA6dmlzaWJsZT0icmVsb2NhdGlvblRvUmVhdXRob3JpemUgIT09IG51bGwiCiAgICAgIHRpdGxlPSJSZWF1dGhvcml6ZSByZWxvY2F0aW9uIHRhcmdldCIKICAgICAgY29uZmlybS10ZXh0PSJSZWF1dGhvcml6ZSB0YXJnZXQiCiAgICAgIEBjbG9zZT0icmVsb2NhdGlvblRvUmVhdXRob3JpemUgPSBudWxsIgogICAgICBAY29uZmlybT0iZXhlY3V0ZUxlZ2FjeVRhcmdldFJlYXV0aG9yaXphdGlvbiIKICAgID4KICAgICAgPHRlbXBsYXRlICNjb25maXJtLWljb24+PFBoU2hpZWxkQ2hlY2sgLz48L3RlbXBsYXRlPgogICAgICA8dGVtcGxhdGUgI2RlZmF1bHQ+CiAgICAgICAgPHA+CiAgICAgICAgICBDb25maXJtIHRoYXQgdGhpcyBpcyB0aGUgZXhhY3QgdGFyZ2V0IGRpcmVjdG9yeSB5b3UgaW50ZW5kIHRvIGF1dGhvcml6ZSBmb3IgdGhlIHBlbmRpbmcKICAgICAgICAgIHJlbG9jYXRpb246CiAgICAgICAgPC9wPgogICAgICAgIDxwPgogICAgICAgICAgPGNvZGUgY2xhc3M9InJlYXV0aG9yaXphdGlvbi10YXJnZXQtcGF0aCIgZGF0YS10ZXN0aWQ9InJlYXV0aG9yaXphdGlvbi10YXJnZXQtcGF0aCI+e3sKICAgICAgICAgICAgcmVsb2NhdGlvblRvUmVhdXRob3JpemU/LnRhcmdldFBhdGgKICAgICAgICAgIH19PC9jb2RlPgogICAgICAgIDwvcD4KICAgICAgICA8cD5UaGlzIGF1dGhvcml6YXRpb24gYWxzbyByZXRyaWVzIHRoZSBwZW5kaW5nIHJlbG9jYXRpb24uPC9wPgogICAgICA8L3RlbXBsYXRlPgogICAgPC9EZWxldGVDb25maXJtYXRpb25Nb2RhbD4KIiIiLAogICAgIiIsCikKcmVwbGFjZV9vbmNlKAogICAgImZlL3NyYy9jb21wb25lbnRzL3NldHRpbmdzL1Jvb3RGb2xkZXJzU2V0dGluZ3MudnVlIiwKICAgICJpbXBvcnQgdHlwZSB7IFJvb3RGb2xkZXIsIFJvb3RGb2xkZXJQYXRoQ2hhbmdlUmVzdWx0IH0gZnJvbSAnQC90eXBlcydcbiIsCiAgICAiaW1wb3J0IHR5cGUgeyBSb290Rm9sZGVyIH0gZnJvbSAnQC90eXBlcydcbiIsCikKcmVwbGFjZV9vbmNlKAogICAgImZlL3NyYy9jb21wb25lbnRzL3NldHRpbmdzL1Jvb3RGb2xkZXJzU2V0dGluZ3MudnVlIiwKICAgICIiImNvbnN0IHJlbG9jYXRpb25Ub1JlYXV0aG9yaXplID0gcmVmPHsKICByZWxvY2F0aW9uSWQ6IHN0cmluZwogIHRhcmdldFBhdGg6IHN0cmluZwp9IHwgbnVsbD4obnVsbCkKIiIiLAogICAgIiIsCikKZm9yIG5lZWRsZSBpbiBbCiAgICAiZnVuY3Rpb24gY2FuUmVhdXRob3JpemVMZWdhY3lUYXJnZXQoIiwKICAgICJmdW5jdGlvbiBjb25maXJtTGVnYWN5VGFyZ2V0UmVhdXRob3JpemF0aW9uKCIsCiAgICAiYXN5bmMgZnVuY3Rpb24gZXhlY3V0ZUxlZ2FjeVRhcmdldFJlYXV0aG9yaXphdGlvbigiLApdOgogICAgcmVtb3ZlX2JyYWNlZF9kZWNsYXJhdGlvbigiZmUvc3JjL2NvbXBvbmVudHMvc2V0dGluZ3MvUm9vdEZvbGRlcnNTZXR0aW5ncy52dWUiLCBuZWVkbGUpCgojIFRlc3RzIHRoYXQgb25seSBleGVyY2lzZSB0aGUgZGlzY2FyZGVkIGJyYW5jaC1vbmx5IHN0YXRlL2VuZHBvaW50LgpQYXRoKCJmZS9zcmMvX190ZXN0c19fL2FwaS5yb290Rm9sZGVyUmVsb2NhdGlvblJlYXV0aG9yaXphdGlvbi5zcGVjLnRzIikudW5saW5rKCkKcmVtb3ZlX3RzX3Rlc3QoCiAgICAiZmUvc3JjL19fdGVzdHNfXy9Sb290Rm9sZGVyc1NldHRpbmdzLnNwZWMudHMiLAogICAgInNob3dzIGxlZ2FjeSByZWF1dGhvcml6YXRpb24gc2VwYXJhdGVseSBhbmQgY29uZmlybXMgdGhlIGV4YWN0IHRhcmdldCBwYXRoIiwKKQpmb3IgbWV0aG9kIGluIFsKICAgICJSZWF1dGhvcml6ZUxlZ2FjeVRhcmdldF9FeGlzdGluZ01vdmVKb2JfQmluZHNDb25maXJtZWRUYXJnZXRHZW5lcmF0aW9uQmVmb3JlUmV0cnkiLAogICAgIlJlYXV0aG9yaXplTGVnYWN5VGFyZ2V0X0NvbnRyYWRpY3RvcnlDaGlsZEF1dGhvcml6YXRpb25fUmVqZWN0c0JlZm9yZVRhcmdldEVucm9sbG1lbnQiLAogICAgIlJlYXV0aG9yaXplTGVnYWN5VGFyZ2V0X1JlcXVlc3RDYW5jZWxsZWRBZnRlckF1dGhvcml6YXRpb25fQ29tcGxldGVzUmV0cnkiLApdOgogICAgcmVtb3ZlX2JyYWNlZF9kZWNsYXJhdGlvbigKICAgICAgICAidGVzdHMvRmVhdHVyZXMvSW5mcmFzdHJ1Y3R1cmUvTGlicmFyeS9Nb3ZpbmcvUm9vdEZvbGRlclJlbG9jYXRpb25TZXJ2aWNlVGVzdHMuY3MiLAogICAgICAgIG1ldGhvZCwKICAgICAgICBpbmNsdWRlX2F0dHJpYnV0ZXM9VHJ1ZSwKICAgICkKcmVtb3ZlX2JyYWNlZF9kZWNsYXJhdGlvbigKICAgICJ0ZXN0cy9GZWF0dXJlcy9Eb21haW4vQXVkaW9ib29rcy9Sb290Rm9sZGVyUmVsb2NhdGlvblN0YXRlVGVzdHMuY3MiLAogICAgIlRhcmdldElkZW50aXR5RW5yb2xsbWVudF9DbGFzc2lmaWNhdGlvbklzRGV0ZXJtaW5pc3RpYyIsCiAgICBpbmNsdWRlX2F0dHJpYnV0ZXM9VHJ1ZSwKKQo=' | base64 -d > /tmp/cleanup.py + python3 /tmp/cleanup.py - - name: Adversarial review policy-only diff + - name: Restore and build + shell: bash + run: | + set -euo pipefail + dotnet restore listenarr.slnx + dotnet build listenarr.slnx --configuration Release --no-restore + + - name: Focused backend tests + shell: bash + run: | + set -euo pipefail + dotnet test tests/Listenarr.Tests.csproj --configuration Release --no-build --filter 'FullyQualifiedName~RootFolderRelocation|FullyQualifiedName~RootFolderObjectIdentityReconciler' + + - name: Focused frontend validation + shell: bash + run: | + set -euo pipefail + cd fe + npm ci + npx vitest run src/__tests__/RootFoldersSettings.spec.ts + npm run type-check + + - name: Adversarial review removed surface shell: bash run: | set -euo pipefail git diff --check - mapfile -t changed < <(git diff --name-only) - test "${#changed[@]}" -eq 4 - for path in "${changed[@]}"; do - case "$path" in - .github/AGENTS.md|.github/CLAUDE.md|CONTRIBUTING.md|BACKEND_ARCHITECTURE.md) ;; - *) echo "Unexpected file changed: $path" >&2; exit 1 ;; - esac - done - for path in .github/AGENTS.md .github/CLAUDE.md CONTRIBUTING.md BACKEND_ARCHITECTURE.md; do - grep -q 'target branch' "$path" - done + if git grep -n -E 'LegacyUnenrolled|reauthorize-legacy-target|ReauthorizeLegacyTarget|reauthorizeLegacyRootFolderRelocationTarget|reauthorizeLegacyTarget' -- ':!listenarr.infrastructure/Persistence/Migrations/**' ':!.github/workflows/pr717-cleanup-analysis.yml'; then + echo 'Intermediate relocation compatibility remains' >&2 + exit 1 + fi + test ! -e listenarr.infrastructure/Library/Moving/RootFolderRelocationService.Reauthorization.cs + test ! -e fe/src/__tests__/api.rootFolderRelocationReauthorization.spec.ts git diff --stat - git diff -- .github/AGENTS.md .github/CLAUDE.md CONTRIBUTING.md BACKEND_ARCHITECTURE.md - - name: Commit policy + - name: Commit cleanup shell: bash run: | set -euo pipefail git config user.name 'github-actions[bot]' git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add .github/AGENTS.md .github/CLAUDE.md CONTRIBUTING.md BACKEND_ARCHITECTURE.md - git commit -m 'docs: define target-branch compatibility boundary' + git add -A + git commit -m 'refactor(relocation): remove intermediate target reauthorization' git push origin HEAD:bugfix/unix-folder-name-space From 8347deab97166b434141284f0e30d020682fa6dc Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Fri, 7 Aug 2026 10:49:00 -0400 Subject: [PATCH 418/464] chore(ci): retry legacy relocation cleanup --- .github/workflows/pr717-cleanup-analysis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr717-cleanup-analysis.yml b/.github/workflows/pr717-cleanup-analysis.yml index eeb3f29a9..5229ad66c 100644 --- a/.github/workflows/pr717-cleanup-analysis.yml +++ b/.github/workflows/pr717-cleanup-analysis.yml @@ -21,7 +21,7 @@ jobs: shell: bash run: | set -euo pipefail - echo 'CmZyb20gcGF0aGxpYiBpbXBvcnQgUGF0aAppbXBvcnQgcmUKCmRlZiByZXBsYWNlX29uY2UocGF0aCwgb2xkLCBuZXcpOgogICAgcCA9IFBhdGgocGF0aCkKICAgIHRleHQgPSBwLnJlYWRfdGV4dCgpCiAgICBpZiBvbGQgbm90IGluIHRleHQ6CiAgICAgICAgcmFpc2UgU3lzdGVtRXhpdChmIm1pc3NpbmcgZXhwZWN0ZWQgdGV4dCBpbiB7cGF0aH06IHtvbGRbOjgwXSFyfSIpCiAgICBwLndyaXRlX3RleHQodGV4dC5yZXBsYWNlKG9sZCwgbmV3LCAxKSkKCmRlZiByZW1vdmVfYnJhY2VkX2RlY2xhcmF0aW9uKHBhdGgsIG5lZWRsZSwgaW5jbHVkZV9hdHRyaWJ1dGVzPUZhbHNlKToKICAgIHAgPSBQYXRoKHBhdGgpCiAgICB0ZXh0ID0gcC5yZWFkX3RleHQoKQogICAgcG9zID0gdGV4dC5maW5kKG5lZWRsZSkKICAgIGlmIHBvcyA8IDA6CiAgICAgICAgcmFpc2UgU3lzdGVtRXhpdChmIm1pc3NpbmcgZGVjbGFyYXRpb24ge25lZWRsZSFyfSBpbiB7cGF0aH0iKQogICAgc3RhcnQgPSB0ZXh0LnJmaW5kKCJcbiIsIDAsIHBvcykgKyAxCiAgICBpZiBpbmNsdWRlX2F0dHJpYnV0ZXM6CiAgICAgICAgc2NhbiA9IHN0YXJ0CiAgICAgICAgd2hpbGUgc2NhbiA+IDA6CiAgICAgICAgICAgIHByZXZfZW5kID0gc2NhbiAtIDEKICAgICAgICAgICAgcHJldl9zdGFydCA9IHRleHQucmZpbmQoIlxuIiwgMCwgcHJldl9lbmQpICsgMQogICAgICAgICAgICBsaW5lID0gdGV4dFtwcmV2X3N0YXJ0OnByZXZfZW5kKzFdLnN0cmlwKCkKICAgICAgICAgICAgaWYgbGluZS5zdGFydHN3aXRoKCJbIikgb3IgbGluZSA9PSAiIjoKICAgICAgICAgICAgICAgIHNjYW4gPSBwcmV2X3N0YXJ0CiAgICAgICAgICAgICAgICBpZiBsaW5lID09ICIiOgogICAgICAgICAgICAgICAgICAgICMgZG9uJ3QgY3Jvc3MgYmxhbmsgc2VwYXJhdG9yCiAgICAgICAgICAgICAgICAgICAgYnJlYWsKICAgICAgICAgICAgICAgIHN0YXJ0ID0gcHJldl9zdGFydAogICAgICAgICAgICAgICAgY29udGludWUKICAgICAgICAgICAgYnJlYWsKICAgIGJyYWNlID0gdGV4dC5maW5kKCJ7IiwgcG9zKQogICAgaWYgYnJhY2UgPCAwOgogICAgICAgIHJhaXNlIFN5c3RlbUV4aXQoZiJtaXNzaW5nIG9wZW5pbmcgYnJhY2UgZm9yIHtuZWVkbGUhcn0gaW4ge3BhdGh9IikKICAgIGRlcHRoID0gMAogICAgaSA9IGJyYWNlCiAgICBpbl9zdHIgPSBOb25lCiAgICBlc2MgPSBGYWxzZQogICAgd2hpbGUgaSA8IGxlbih0ZXh0KToKICAgICAgICBjaCA9IHRleHRbaV0KICAgICAgICBpZiBpbl9zdHI6CiAgICAgICAgICAgIGlmIGVzYzoKICAgICAgICAgICAgICAgIGVzYyA9IEZhbHNlCiAgICAgICAgICAgIGVsaWYgY2ggPT0gIlxcIjoKICAgICAgICAgICAgICAgIGVzYyA9IFRydWUKICAgICAgICAgICAgZWxpZiBjaCA9PSBpbl9zdHI6CiAgICAgICAgICAgICAgICBpbl9zdHIgPSBOb25lCiAgICAgICAgZWxzZToKICAgICAgICAgICAgaWYgY2ggaW4gKCInIiwgJyInKToKICAgICAgICAgICAgICAgIGluX3N0ciA9IGNoCiAgICAgICAgICAgIGVsaWYgY2ggPT0gInsiOgogICAgICAgICAgICAgICAgZGVwdGggKz0gMQogICAgICAgICAgICBlbGlmIGNoID09ICJ9IjoKICAgICAgICAgICAgICAgIGRlcHRoIC09IDEKICAgICAgICAgICAgICAgIGlmIGRlcHRoID09IDA6CiAgICAgICAgICAgICAgICAgICAgZW5kID0gaSArIDEKICAgICAgICAgICAgICAgICAgICB3aGlsZSBlbmQgPCBsZW4odGV4dCkgYW5kIHRleHRbZW5kXSBpbiAiIFx0IjoKICAgICAgICAgICAgICAgICAgICAgICAgZW5kICs9IDEKICAgICAgICAgICAgICAgICAgICBpZiBlbmQgPCBsZW4odGV4dCkgYW5kIHRleHRbZW5kXSA9PSAiXHIiOgogICAgICAgICAgICAgICAgICAgICAgICBlbmQgKz0gMQogICAgICAgICAgICAgICAgICAgIGlmIGVuZCA8IGxlbih0ZXh0KSBhbmQgdGV4dFtlbmRdID09ICJcbiI6CiAgICAgICAgICAgICAgICAgICAgICAgIGVuZCArPSAxCiAgICAgICAgICAgICAgICAgICAgIyByZW1vdmUgb25lIGZvbGxvd2luZyBibGFuayBsaW5lCiAgICAgICAgICAgICAgICAgICAgaWYgZW5kIDwgbGVuKHRleHQpIGFuZCB0ZXh0W2VuZF0gPT0gIlxuIjoKICAgICAgICAgICAgICAgICAgICAgICAgZW5kICs9IDEKICAgICAgICAgICAgICAgICAgICBwLndyaXRlX3RleHQodGV4dFs6c3RhcnRdICsgdGV4dFtlbmQ6XSkKICAgICAgICAgICAgICAgICAgICByZXR1cm4KICAgICAgICBpICs9IDEKICAgIHJhaXNlIFN5c3RlbUV4aXQoZiJ1bmJhbGFuY2VkIGJyYWNlcyBmb3Ige25lZWRsZSFyfSBpbiB7cGF0aH0iKQoKZGVmIHJlbW92ZV90c190ZXN0KHBhdGgsIHRpdGxlKToKICAgIHAgPSBQYXRoKHBhdGgpCiAgICB0ZXh0ID0gcC5yZWFkX3RleHQoKQogICAgbmVlZGxlID0gZiIgIGl0KCd7dGl0bGV9JyIKICAgIHBvcyA9IHRleHQuZmluZChuZWVkbGUpCiAgICBpZiBwb3MgPCAwOgogICAgICAgIHJhaXNlIFN5c3RlbUV4aXQoZiJtaXNzaW5nIHRlc3Qge3RpdGxlIXJ9IGluIHtwYXRofSIpCiAgICBzdGFydCA9IHBvcwogICAgbmV4dF9wb3MgPSB0ZXh0LmZpbmQoIlxuICBpdCgiLCBwb3MgKyBsZW4obmVlZGxlKSkKICAgIGlmIG5leHRfcG9zIDwgMDoKICAgICAgICByYWlzZSBTeXN0ZW1FeGl0KGYiY291bGQgbm90IGZpbmQgZm9sbG93aW5nIHRlc3QgYWZ0ZXIge3RpdGxlIXJ9IikKICAgIHAud3JpdGVfdGV4dCh0ZXh0WzpzdGFydF0gKyB0ZXh0W25leHRfcG9zKzE6XSkKCiMgQVBJL2FwcGxpY2F0aW9uIGNvbnRyYWN0LgpyZXBsYWNlX29uY2UoCiAgICAibGlzdGVuYXJyLmFwcGxpY2F0aW9uL0F1ZGlvYm9va3MvQ29udHJhY3RzL0lSb290Rm9sZGVyUmVsb2NhdGlvblNlcnZpY2UuY3MiLAogICAgIiIiICAgIFRhc2s8Um9vdEZvbGRlclBhdGhDaGFuZ2VSZXN1bHQ+IFJlYXV0aG9yaXplTGVnYWN5VGFyZ2V0QXN5bmMoCiAgICAgICAgR3VpZCByZWxvY2F0aW9uSWQsCiAgICAgICAgc3RyaW5nIGNvbmZpcm1lZFRhcmdldFBhdGgsCiAgICAgICAgQ2FuY2VsbGF0aW9uVG9rZW4gY2FuY2VsbGF0aW9uVG9rZW4gPSBkZWZhdWx0KTsKCiIiIiwKICAgICIiLAopCnJlcGxhY2Vfb25jZSgKICAgICJsaXN0ZW5hcnIuYXBwbGljYXRpb24vQXVkaW9ib29rcy9Db250cmFjdHMvUm9vdEZvbGRlclJlbG9jYXRpb25QdWJsaWNQcm9qZWN0aW9uLmNzIiwKICAgICIiIiAgICAgICAgICAgIFRhcmdldElkZW50aXR5RW5yb2xsbWVudFN0YXRlLkxlZ2FjeVVuZW5yb2xsZWQgPT4KICAgICAgICAgICAgICAgICJUaGUgcmVsb2NhdGlvbiB0YXJnZXQgbXVzdCBiZSByZWF1dGhvcml6ZWQgYmVmb3JlIHRoZSByZWxvY2F0aW9uIGNhbiBjb250aW51ZS4iLAoiIiIsCiAgICAiIiwKKQpyZXBsYWNlX29uY2UoCiAgICAibGlzdGVuYXJyLmRvbWFpbi9BdWRpb2Jvb2tzL1Jvb3RGb2xkZXJSZWxvY2F0aW9uLmNzIiwKICAgICIgICAgTGVnYWN5VW5lbnJvbGxlZCxcbiIsCiAgICAiIiwKKQpyZW1vdmVfYnJhY2VkX2RlY2xhcmF0aW9uKAogICAgImxpc3RlbmFyci5kb21haW4vQXVkaW9ib29rcy9Sb290Rm9sZGVyUmVsb2NhdGlvbi5jcyIsCiAgICAicHVibGljIHN0YXRpYyBjbGFzcyBUYXJnZXRJZGVudGl0eUVucm9sbG1lbnQiLAopCnJlcGxhY2Vfb25jZSgKICAgICJsaXN0ZW5hcnIuaW5mcmFzdHJ1Y3R1cmUvUGVyc2lzdGVuY2UvUm9vdEZvbGRlck9iamVjdElkZW50aXR5UmVjb25jaWxlci5jcyIsCiAgICAiIiIgICAgICAgIHZhciByZWxvY2F0aW9ucyA9IGF3YWl0IGRiLlJvb3RGb2xkZXJSZWxvY2F0aW9ucwogICAgICAgICAgICAuVG9MaXN0QXN5bmMoY2FuY2VsbGF0aW9uVG9rZW4pOwogICAgICAgIGZvcmVhY2ggKHZhciByZWxvY2F0aW9uIGluIHJlbG9jYXRpb25zKQogICAgICAgIHsKICAgICAgICAgICAgcmVsb2NhdGlvbi5UYXJnZXRJZGVudGl0eUVucm9sbG1lbnRTdGF0ZSA9CiAgICAgICAgICAgICAgICBUYXJnZXRJZGVudGl0eUVucm9sbG1lbnQuQ2xhc3NpZnkocmVsb2NhdGlvbik7CiAgICAgICAgfQoKIiIiLAogICAgIiIsCikKcmVwbGFjZV9vbmNlKAogICAgImxpc3RlbmFyci5pbmZyYXN0cnVjdHVyZS9MaWJyYXJ5L01vdmluZy9Sb290Rm9sZGVyUmVsb2NhdGlvblNlcnZpY2UuUmV0cnkuY3MiLAogICAgIiIiICAgICAgICBpZiAocmVsb2NhdGlvbi5UYXJnZXRJZGVudGl0eUVucm9sbG1lbnRTdGF0ZQogICAgICAgICAgICA9PSBUYXJnZXRJZGVudGl0eUVucm9sbG1lbnRTdGF0ZS5MZWdhY3lVbmVucm9sbGVkKQogICAgICAgIHsKICAgICAgICAgICAgdGhyb3cgbmV3IEludmFsaWRPcGVyYXRpb25FeGNlcHRpb24oCiAgICAgICAgICAgICAgICAiVGhlIGxlZ2FjeSByZWxvY2F0aW9uIHRhcmdldCBtdXN0IGJlIGV4cGxpY2l0bHkgcmVhdXRob3JpemVkIGJlZm9yZSByZXRyeS4iKTsKICAgICAgICB9CiIiIiwKICAgICIiLAopCgojIENvbnRyb2xsZXIgZW5kcG9pbnQuCnJlcGxhY2Vfb25jZSgKICAgICJsaXN0ZW5hcnIuYXBpL0ZlYXR1cmVzL0xpYnJhcnkvUm9vdEZvbGRlclJlbG9jYXRpb25zQ29udHJvbGxlci5jcyIsCiAgICAiICAgIHB1YmxpYyBzZWFsZWQgcmVjb3JkIFJlYXV0aG9yaXplTGVnYWN5VGFyZ2V0UmVxdWVzdChzdHJpbmcgQ29uZmlybWVkVGFyZ2V0UGF0aCk7XG5cbiIsCiAgICAiIiwKKQpyZW1vdmVfYnJhY2VkX2RlY2xhcmF0aW9uKAogICAgImxpc3RlbmFyci5hcGkvRmVhdHVyZXMvTGlicmFyeS9Sb290Rm9sZGVyUmVsb2NhdGlvbnNDb250cm9sbGVyLmNzIiwKICAgICJwdWJsaWMgYXN5bmMgVGFzazxJQWN0aW9uUmVzdWx0PiBSZWF1dGhvcml6ZUxlZ2FjeVRhcmdldCgiLAogICAgaW5jbHVkZV9hdHRyaWJ1dGVzPVRydWUsCikKCiMgRGVkaWNhdGVkIGltcGxlbWVudGF0aW9uIGlzIGVudGlyZWx5IGJyYW5jaC1oaXN0b3J5IGNvbXBhdGliaWxpdHkuClBhdGgoImxpc3RlbmFyci5pbmZyYXN0cnVjdHVyZS9MaWJyYXJ5L01vdmluZy9Sb290Rm9sZGVyUmVsb2NhdGlvblNlcnZpY2UuUmVhdXRob3JpemF0aW9uLmNzIikudW5saW5rKCkKCiMgRnJvbnRlbmQgY29udHJhY3QgYW5kIHN0b3JlLgpyZXBsYWNlX29uY2UoCiAgICAiZmUvc3JjL3R5cGVzL2luZGV4LnRzIiwKICAgICIgIHRhcmdldElkZW50aXR5RW5yb2xsbWVudFN0YXRlOiAnTm90UmVxdWlyZWQnIHwgJ0F1dGhvcml6ZWQnIHwgJ0xlZ2FjeVVuZW5yb2xsZWQnIHwgJ1VuYXZhaWxhYmxlJ1xuIiwKICAgICIgIHRhcmdldElkZW50aXR5RW5yb2xsbWVudFN0YXRlOiAnTm90UmVxdWlyZWQnIHwgJ0F1dGhvcml6ZWQnIHwgJ1VuYXZhaWxhYmxlJ1xuIiwKKQpyZW1vdmVfYnJhY2VkX2RlY2xhcmF0aW9uKAogICAgImZlL3NyYy9zZXJ2aWNlcy9hcGkudHMiLAogICAgImFzeW5jIHJlYXV0aG9yaXplTGVnYWN5Um9vdEZvbGRlclJlbG9jYXRpb25UYXJnZXQoIiwKKQpyZW1vdmVfYnJhY2VkX2RlY2xhcmF0aW9uKAogICAgImZlL3NyYy9zdG9yZXMvcm9vdEZvbGRlcnMudHMiLAogICAgImFzeW5jIGZ1bmN0aW9uIHJlYXV0aG9yaXplTGVnYWN5VGFyZ2V0KCIsCikKcmVwbGFjZV9vbmNlKAogICAgImZlL3NyYy9zdG9yZXMvcm9vdEZvbGRlcnMudHMiLAogICAgIiAgICByZWF1dGhvcml6ZUxlZ2FjeVRhcmdldCxcbiIsCiAgICAiIiwKKQoKIyBGcm9udGVuZCB2aWV3OiByZW1vdmUgb25seSByZWxvY2F0aW9uLXRhcmdldCByZWF1dGhvcml6YXRpb247IHJvb3QgaWRlbnRpdHkgcmVhdXRob3JpemF0aW9uIHJlbWFpbnMuCnJlcGxhY2Vfb25jZSgKICAgICJmZS9zcmMvY29tcG9uZW50cy9zZXR0aW5ncy9Sb290Rm9sZGVyc1NldHRpbmdzLnZ1ZSIsCiAgICAiIiIgICAgICAgICAgICAgIDxidXR0b24KICAgICAgICAgICAgICAgIHYtaWY9ImNhblJlYXV0aG9yaXplTGVnYWN5VGFyZ2V0KGZvbGRlcikiCiAgICAgICAgICAgICAgICB0eXBlPSJidXR0b24iCiAgICAgICAgICAgICAgICBjbGFzcz0iYnRuIGJ0bi1zZWNvbmRhcnkiCiAgICAgICAgICAgICAgICBkYXRhLWN5PSJyZWF1dGhvcml6ZS1yZWxvY2F0aW9uLXRhcmdldCIKICAgICAgICAgICAgICAgIEBjbGljaz0iY29uZmlybUxlZ2FjeVRhcmdldFJlYXV0aG9yaXphdGlvbihmb2xkZXIpIgogICAgICAgICAgICAgID4KICAgICAgICAgICAgICAgIFJlYXV0aG9yaXplIHRhcmdldAogICAgICAgICAgICAgIDwvYnV0dG9uPgoiIiIsCiAgICAiIiwKKQpyZXBsYWNlX29uY2UoCiAgICAiZmUvc3JjL2NvbXBvbmVudHMvc2V0dGluZ3MvUm9vdEZvbGRlcnNTZXR0aW5ncy52dWUiLAogICAgIiIiICAgIDxEZWxldGVDb25maXJtYXRpb25Nb2RhbAogICAgICA6dmlzaWJsZT0icmVsb2NhdGlvblRvUmVhdXRob3JpemUgIT09IG51bGwiCiAgICAgIHRpdGxlPSJSZWF1dGhvcml6ZSByZWxvY2F0aW9uIHRhcmdldCIKICAgICAgY29uZmlybS10ZXh0PSJSZWF1dGhvcml6ZSB0YXJnZXQiCiAgICAgIEBjbG9zZT0icmVsb2NhdGlvblRvUmVhdXRob3JpemUgPSBudWxsIgogICAgICBAY29uZmlybT0iZXhlY3V0ZUxlZ2FjeVRhcmdldFJlYXV0aG9yaXphdGlvbiIKICAgID4KICAgICAgPHRlbXBsYXRlICNjb25maXJtLWljb24+PFBoU2hpZWxkQ2hlY2sgLz48L3RlbXBsYXRlPgogICAgICA8dGVtcGxhdGUgI2RlZmF1bHQ+CiAgICAgICAgPHA+CiAgICAgICAgICBDb25maXJtIHRoYXQgdGhpcyBpcyB0aGUgZXhhY3QgdGFyZ2V0IGRpcmVjdG9yeSB5b3UgaW50ZW5kIHRvIGF1dGhvcml6ZSBmb3IgdGhlIHBlbmRpbmcKICAgICAgICAgIHJlbG9jYXRpb246CiAgICAgICAgPC9wPgogICAgICAgIDxwPgogICAgICAgICAgPGNvZGUgY2xhc3M9InJlYXV0aG9yaXphdGlvbi10YXJnZXQtcGF0aCIgZGF0YS10ZXN0aWQ9InJlYXV0aG9yaXphdGlvbi10YXJnZXQtcGF0aCI+e3sKICAgICAgICAgICAgcmVsb2NhdGlvblRvUmVhdXRob3JpemU/LnRhcmdldFBhdGgKICAgICAgICAgIH19PC9jb2RlPgogICAgICAgIDwvcD4KICAgICAgICA8cD5UaGlzIGF1dGhvcml6YXRpb24gYWxzbyByZXRyaWVzIHRoZSBwZW5kaW5nIHJlbG9jYXRpb24uPC9wPgogICAgICA8L3RlbXBsYXRlPgogICAgPC9EZWxldGVDb25maXJtYXRpb25Nb2RhbD4KIiIiLAogICAgIiIsCikKcmVwbGFjZV9vbmNlKAogICAgImZlL3NyYy9jb21wb25lbnRzL3NldHRpbmdzL1Jvb3RGb2xkZXJzU2V0dGluZ3MudnVlIiwKICAgICJpbXBvcnQgdHlwZSB7IFJvb3RGb2xkZXIsIFJvb3RGb2xkZXJQYXRoQ2hhbmdlUmVzdWx0IH0gZnJvbSAnQC90eXBlcydcbiIsCiAgICAiaW1wb3J0IHR5cGUgeyBSb290Rm9sZGVyIH0gZnJvbSAnQC90eXBlcydcbiIsCikKcmVwbGFjZV9vbmNlKAogICAgImZlL3NyYy9jb21wb25lbnRzL3NldHRpbmdzL1Jvb3RGb2xkZXJzU2V0dGluZ3MudnVlIiwKICAgICIiImNvbnN0IHJlbG9jYXRpb25Ub1JlYXV0aG9yaXplID0gcmVmPHsKICByZWxvY2F0aW9uSWQ6IHN0cmluZwogIHRhcmdldFBhdGg6IHN0cmluZwp9IHwgbnVsbD4obnVsbCkKIiIiLAogICAgIiIsCikKZm9yIG5lZWRsZSBpbiBbCiAgICAiZnVuY3Rpb24gY2FuUmVhdXRob3JpemVMZWdhY3lUYXJnZXQoIiwKICAgICJmdW5jdGlvbiBjb25maXJtTGVnYWN5VGFyZ2V0UmVhdXRob3JpemF0aW9uKCIsCiAgICAiYXN5bmMgZnVuY3Rpb24gZXhlY3V0ZUxlZ2FjeVRhcmdldFJlYXV0aG9yaXphdGlvbigiLApdOgogICAgcmVtb3ZlX2JyYWNlZF9kZWNsYXJhdGlvbigiZmUvc3JjL2NvbXBvbmVudHMvc2V0dGluZ3MvUm9vdEZvbGRlcnNTZXR0aW5ncy52dWUiLCBuZWVkbGUpCgojIFRlc3RzIHRoYXQgb25seSBleGVyY2lzZSB0aGUgZGlzY2FyZGVkIGJyYW5jaC1vbmx5IHN0YXRlL2VuZHBvaW50LgpQYXRoKCJmZS9zcmMvX190ZXN0c19fL2FwaS5yb290Rm9sZGVyUmVsb2NhdGlvblJlYXV0aG9yaXphdGlvbi5zcGVjLnRzIikudW5saW5rKCkKcmVtb3ZlX3RzX3Rlc3QoCiAgICAiZmUvc3JjL19fdGVzdHNfXy9Sb290Rm9sZGVyc1NldHRpbmdzLnNwZWMudHMiLAogICAgInNob3dzIGxlZ2FjeSByZWF1dGhvcml6YXRpb24gc2VwYXJhdGVseSBhbmQgY29uZmlybXMgdGhlIGV4YWN0IHRhcmdldCBwYXRoIiwKKQpmb3IgbWV0aG9kIGluIFsKICAgICJSZWF1dGhvcml6ZUxlZ2FjeVRhcmdldF9FeGlzdGluZ01vdmVKb2JfQmluZHNDb25maXJtZWRUYXJnZXRHZW5lcmF0aW9uQmVmb3JlUmV0cnkiLAogICAgIlJlYXV0aG9yaXplTGVnYWN5VGFyZ2V0X0NvbnRyYWRpY3RvcnlDaGlsZEF1dGhvcml6YXRpb25fUmVqZWN0c0JlZm9yZVRhcmdldEVucm9sbG1lbnQiLAogICAgIlJlYXV0aG9yaXplTGVnYWN5VGFyZ2V0X1JlcXVlc3RDYW5jZWxsZWRBZnRlckF1dGhvcml6YXRpb25fQ29tcGxldGVzUmV0cnkiLApdOgogICAgcmVtb3ZlX2JyYWNlZF9kZWNsYXJhdGlvbigKICAgICAgICAidGVzdHMvRmVhdHVyZXMvSW5mcmFzdHJ1Y3R1cmUvTGlicmFyeS9Nb3ZpbmcvUm9vdEZvbGRlclJlbG9jYXRpb25TZXJ2aWNlVGVzdHMuY3MiLAogICAgICAgIG1ldGhvZCwKICAgICAgICBpbmNsdWRlX2F0dHJpYnV0ZXM9VHJ1ZSwKICAgICkKcmVtb3ZlX2JyYWNlZF9kZWNsYXJhdGlvbigKICAgICJ0ZXN0cy9GZWF0dXJlcy9Eb21haW4vQXVkaW9ib29rcy9Sb290Rm9sZGVyUmVsb2NhdGlvblN0YXRlVGVzdHMuY3MiLAogICAgIlRhcmdldElkZW50aXR5RW5yb2xsbWVudF9DbGFzc2lmaWNhdGlvbklzRGV0ZXJtaW5pc3RpYyIsCiAgICBpbmNsdWRlX2F0dHJpYnV0ZXM9VHJ1ZSwKKQo=' | base64 -d > /tmp/cleanup.py + echo 'CmZyb20gcGF0aGxpYiBpbXBvcnQgUGF0aAoKZGVmIHJlYWQocGF0aCk6CiAgICByZXR1cm4gUGF0aChwYXRoKS5yZWFkX3RleHQoKQoKZGVmIHdyaXRlKHBhdGgsIHRleHQpOgogICAgUGF0aChwYXRoKS53cml0ZV90ZXh0KHRleHQpCgpkZWYgcmVwbGFjZV9vbmNlKHBhdGgsIG9sZCwgbmV3KToKICAgIHRleHQgPSByZWFkKHBhdGgpCiAgICBpZiBvbGQgbm90IGluIHRleHQ6CiAgICAgICAgcmFpc2UgU3lzdGVtRXhpdChmIm1pc3NpbmcgZXhwZWN0ZWQgYmxvY2sgaW4ge3BhdGh9OiB7b2xkWzoxMDBdIXJ9IikKICAgIHdyaXRlKHBhdGgsIHRleHQucmVwbGFjZShvbGQsIG5ldywgMSkpCgpkZWYgZmluZF9tYXRjaGluZ19icmFjZSh0ZXh0LCBicmFjZSk6CiAgICBkZXB0aCA9IDAKICAgIHF1b3RlID0gTm9uZQogICAgZXNjID0gRmFsc2UKICAgIGkgPSBicmFjZQogICAgd2hpbGUgaSA8IGxlbih0ZXh0KToKICAgICAgICBjaCA9IHRleHRbaV0KICAgICAgICBpZiBxdW90ZToKICAgICAgICAgICAgaWYgZXNjOgogICAgICAgICAgICAgICAgZXNjID0gRmFsc2UKICAgICAgICAgICAgZWxpZiBjaCA9PSAnXFwnOgogICAgICAgICAgICAgICAgZXNjID0gVHJ1ZQogICAgICAgICAgICBlbGlmIGNoID09IHF1b3RlOgogICAgICAgICAgICAgICAgcXVvdGUgPSBOb25lCiAgICAgIGVsc2U6CiAgICAgICAgICAgIGlmIGNoIGluICgiJyIsICciJyk6CiAgICAgICAgICAgICAgICBxdW90ZSA9IGNoCiAgICAgICAgICAgIGVsaWYgY2ggPT0gJ3snOgogICAgICAgICAgICAgICAgZGVwdGggKz0gMQogICAgICAgICAgICBlbGlmIGNoID09ICd9JzoKICAgICAgICAgICAgICAgIGRlcHRoIC09IDEKICAgICAgICAgICAgICAgIGlmIGRlcHRoID09IDA6CiAgICAgICAgICAgICAgICAgICAgcmV0dXJuIGkgKyAxCiAgICAgICAgaSArPSAxCiAgICByYWlzZSBTeXN0ZW1FeGl0KCJ1bmJhbGFuY2VkIGJyYWNlIikKCmRlZiByZW1vdmVfZGVjbChwYXRoLCBuZWVkbGUsIGluY2x1ZGVfYXR0cmlidXRlcz1GYWxzZSk6CiAgICB0ZXh0ID0gcmVhZChwYXRoKQogICAgcG9zID0gdGV4dC5maW5kKG5lZWRsZSkKICAgIGlmIHBvcyA8IDA6CiAgICAgICAgcmFpc2UgU3lzdGVtRXhpdChmIm1pc3NpbmcgZGVjbGFyYXRpb24ge25lZWRsZSFyfSBpbiB7cGF0aH0iKQogICAgbGluZV9zdGFydCA9IHRleHQucmZpbmQoJ1xuJywgMCwgcG9zKSArIDEKICAgIHN0YXJ0ID0gbGluZV9zdGFydAogICAgaWYgaW5jbHVkZV9hdHRyaWJ1dGVzOgogICAgICAgIHdoaWxlIHN0YXJ0ID4gMDoKICAgICAgICAgICAgcHJldl9lbmQgPSBzdGFydCAtIDEKICAgICAgICAgICAgcHJldl9zdGFydCA9IHRleHQucmZpbmQoJ1xuJywgMCwgcHJldl9lbmQpICsgMQogICAgICAgICAgICBwcmV2ID0gdGV4dFtwcmV2X3N0YXJ0OnByZXZfZW5kKzFdLnN0cmlwKCkKICAgICAgICAgICAgaWYgcHJldi5zdGFydHN3aXRoKCdbJyk6CiAgICAgICAgICAgICAgICBzdGFydCA9IHByZXZfc3RhcnQKICAgICAgICAgICAgICAgIGNvbnRpbnVlCiAgICAgICAgICAgIGJyZWFrCiAgICBicmFjZSA9IHRleHQuZmluZCgneycsIHBvcykKICAgIGlmIGJyYWNlIDwgMDoKICAgICAgICByYWlzZSBTeXN0ZW1FeGl0KGYibm8gYnJhY2UgZm9yIHtuZWVkbGUhcn0iKQogICAgZW5kID0gZmluZF9tYXRjaGluZ19icmFjZSh0ZXh0LCBicmFjZSkKICAgIHdoaWxlIGVuZCA8IGxlbih0ZXh0KSBhbmQgdGV4dFtlbmRdIGluICcgXHRcclxuJzoKICAgICAgICBlbmQgKz0gMQogICAgd3JpdGUocGF0aCwgdGV4dFs6c3RhcnRdICsgdGV4dFtlbmQ6XSkKCmRlZiByZW1vdmVfdGVzdF9jYWxsKHBhdGgsIHRpdGxlKToKICAgIHRleHQgPSByZWFkKHBhdGgpCiAgICB0b2tlbiA9IGYiICBpdCgne3RpdGxlfSciCiAgICBwb3MgPSB0ZXh0LmZpbmQodG9rZW4pCiAgICBpZiBwb3MgPCAwOgogICAgICAgIHJhaXNlIFN5c3RlbUV4aXQoZiJtaXNzaW5nIHZpdGVzdCB7dGl0bGUhcn0iKQogICAgYXJyb3cgPSB0ZXh0LmZpbmQoJz0+JywgcG9zKQogICAgYnJhY2UgPSB0ZXh0LmZpbmQoJ3snLCBhcnJvdykKICAgIGVuZF9ib2R5ID0gZmluZF9tYXRjaGluZ19icmFjZSh0ZXh0LCBicmFjZSkKICAgIGVuZCA9IGVuZF9ib2R5CiAgICB3aGlsZSBlbmQgPCBsZW4odGV4dCkgYW5kIHRleHRbZW5kXSBpbiAnIFx0XHJcbic6CiAgICAgICAgZW5kICs9IDEKICAgIGlmIHRleHQuc3RhcnRzd2l0aCgnKScsIGVuZCk6CiAgICAgICAgZW5kICs9IDEKICAgIGlmIHRleHQuc3RhcnRzd2l0aCgnOycsIGVuZCk6CiAgICAgICAgZW5kICs9IDEKICAgIHdoaWxlIGVuZCA8IGxlbih0ZXh0KSBhbmQgdGV4dFtlbmRdIGluICcgXHRcclxuJzoKICAgICAgICBlbmQgKz0gMQogICAgd3JpdGUocGF0aCwgdGV4dFs6cG9zXSArIHRleHRbZW5kOl0pCgojIEFwcGxpY2F0aW9uIEFQSSBjb250cmFjdC4KcmVwbGFjZV9vbmNlKAogICAgImxpc3RlbmFyci5hcHBsaWNhdGlvbi9BdWRpb2Jvb2tzL0NvbnRyYWN0cy9JUm9vdEZvbGRlclJlbG9jYXRpb25TZXJ2aWNlLmNzIiwKICAgICIiIiAgICBUYXNrPFJvb3RGb2xkZXJQYXRoQ2hhbmdlUmVzdWx0PiBSZWF1dGhvcml6ZUxlZ2FjeVRhcmdldEFzeW5jKAogICAgICAgIEd1aWQgcmVsb2NhdGlvbklkLAogICAgICAgIHN0cmluZyBjb25maXJtZWRUYXJnZXRQYXRoLAogICAgICAgIENhbmNlbGxhdGlvblRva2VuIGNhbmNlbGxhdGlvblRva2VuID0gZGVmYXVsdCk7CgoiIiIsCiAgICAiIiwKKQpyZXBsYWNlX29uY2UoCiAgICAibGlzdGVuYXJyLmFwcGxpY2F0aW9uL0F1ZGlvYm9va3MvQ29udHJhY3RzL1Jvb3RGb2xkZXJSZWxvY2F0aW9uUHVibGljUHJvamVjdGlvbi5jcyIsCiAgICAiIiIgICAgICAgICAgICBUYXJnZXRJZGVudGl0eUVucm9sbG1lbnRTdGF0ZS5MZWdhY3lVbmVucm9sbGVkID0+CiAgICAgICAgICAgICAgICAiVGhlIHJlbG9jYXRpb24gdGFyZ2V0IG11c3QgYmUgcmVhdXRob3JpemVkIGJlZm9yZSB0aGUgcmVsb2NhdGlvbiBjYW4gY29udGludWUuIiwKIiIiLAogICAgIiIsCikKCiMgRG9tYWluIHN0YXRlOiBkZWxldGUgdGhlIGJyYW5jaC1vbmx5IGVudW0gc3RhdGUgYW5kIGNsYXNzaWZpZXIuCnJlcGxhY2Vfb25jZSgKICAgICJsaXN0ZW5hcnIuZG9tYWluL0F1ZGlvYm9va3MvUm9vdEZvbGRlclJlbG9jYXRpb24uY3MiLAogICAgIiAgICBMZWdhY3lVbmVucm9sbGVkLFxuIiwKICAgICIiLAopCnJlbW92ZV9kZWNsKAogICAgImxpc3RlbmFyci5kb21haW4vQXVkaW9ib29rcy9Sb290Rm9sZGVyUmVsb2NhdGlvbi5jcyIsCiAgICAicHVibGljIHN0YXRpYyBjbGFzcyBUYXJnZXRJZGVudGl0eUVucm9sbG1lbnQiLAopCgojIFN0YXJ0dXAgbm8gbG9uZ2VyIGNsYXNzaWZpZXMgcm93cyBmcm9tIGludGVybWVkaWF0ZSBQUiBzY2hlbWFzLgpyZXBsYWNlX29uY2UoCiAgICAibGlzdGVuYXJyLmluZnJhc3RydWN0dXJlL1BlcnNpc3RlbmNlL1Jvb3RGb2xkZXJPYmplY3RJZGVudGl0eVJlY29uY2lsZXIuY3MiLAogICAgIiIiICAgICAgICB2YXIgcmVsb2NhdGlvbnMgPSBhd2FpdCBkYi5Sb290Rm9sZGVyUmVsb2NhdGlvbnMKICAgICAgICAgICAgLlRvTGlzdEFzeW5jKGNhbmNlbGxhdGlvblRva2VuKTsKICAgICAgICBmb3JlYWNoICh2YXIgcmVsb2NhdGlvbiBpbiByZWxvY2F0aW9ucykKICAgICAgICB7CiAgICAgICAgICAgIHJlbG9jYXRpb24uVGFyZ2V0SWRlbnRpdHlFbnJvbGxtZW50U3RhdGUgPQogICAgICAgICAgICAgICAgVGFyZ2V0SWRlbnRpdHlFbnJvbGxtZW50LkNsYXNzaWZ5KHJlbG9jYXRpb24pOwogICAgICAgIH0KCiIiIiwKICAgICIiLAopCgojIFJldHJ5IG9ubHkgbmVlZHMgdGhlIGZpbmFsIHVuYXZhaWxhYmxlLXN0YXRlIGZlbmNlLgpyZXBsYWNlX29uY2UoCiAgICAibGlzdGVuYXJyLmluZnJhc3RydWN0dXJlL0xpYnJhcnkvTW92aW5nL1Jvb3RGb2xkZXJSZWxvY2F0aW9uU2VydmljZS5SZXRyeS5jcyIsCiAgICAiIiIgICAgICAgIGlmIChyZWxvY2F0aW9uLlRhcmdldElkZW50aXR5RW5yb2xsbWVudFN0YXRlCiAgICAgICAgICAgID09IFRhcmdldElkZW50aXR5RW5yb2xsbWVudFN0YXRlLkxlZ2FjeVVuZW5yb2xsZWQpCiAgICAgICAgewogICAgICAgICAgICB0aHJvdyBuZXcgSW52YWxpZE9wZXJhdGlvbkV4Y2VwdGlvbigKICAgICAgICAgICAgICAgICJUaGUgbGVnYWN5IHJlbG9jYXRpb24gdGFyZ2V0IG11c3QgYmUgZXhwbGljaXRseSByZWF1dGhvcml6ZWQgYmVmb3JlIHJldHJ5LiIpOwogICAgICAgIH0KIiIiLAogICAgIiIsCikKCiMgSFRUUCBlbmRwb2ludC4KcmVwbGFjZV9vbmNlKAogICAgImxpc3RlbmFyci5hcGkvRmVhdHVyZXMvTGlicmFyeS9Sb290Rm9sZGVyUmVsb2NhdGlvbnNDb250cm9sbGVyLmNzIiwKICAgICIgICAgcHVibGljIHNlYWxlZCByZWNvcmQgUmVhdXRob3JpemVMZWdhY3lUYXJnZXRSZXF1ZXN0KHN0cmluZyBDb25maXJtZWRUYXJnZXRQYXRoKTtcblxuIiwKICAgICIiLAopCnJlbW92ZV9kZWNsKAogICAgImxpc3RlbmFyci5hcGkvRmVhdHVyZXMvTGlicmFyeS9Sb290Rm9sZGVyUmVsb2NhdGlvbnNDb250cm9sbGVyLmNzIiwKICAgICJwdWJsaWMgYXN5bmMgVGFzazxJQWN0aW9uUmVzdWx0PiBSZWF1dGhvcml6ZUxlZ2FjeVRhcmdldCgiLAogICAgaW5jbHVkZV9hdHRyaWJ1dGVzPVRydWUsCikKCiMgRGVkaWNhdGVkIGJyYW5jaC1oaXN0b3J5IGltcGxlbWVudGF0aW9uLgpyZWF1dGggPSBQYXRoKCJsaXN0ZW5hcnIuaW5mcmFzdHJ1Y3R1cmUvTGlicmFyeS9Nb3ZpbmcvUm9vdEZvbGRlclJlbG9jYXRpb25TZXJ2aWNlLlJlYXV0aG9yaXphdGlvbi5jcyIpCmlmIG5vdCByZWF1dGguZXhpc3RzKCk6CiAgICByYWlzZSBTeXN0ZW1FeGl0KCJtaXNzaW5nIHJlbG9jYXRpb24gcmVhdXRob3JpemF0aW9uIGltcGxlbWVudGF0aW9uIikKcmVhdXRoLnVubGluaygpCgojIEZyb250ZW5kIGNvbnRyYWN0L0FQSS9zdG9yZS4KcmVwbGFjZV9vbmNlKAogICAgImZlL3NyYy90eXBlcy9pbmRleC50cyIsCiAgICAiICB0YXJnZXRJZGVudGl0eUVucm9sbG1lbnRTdGF0ZTogJ05vdFJlcXVpcmVkJyB8ICdBdXRob3JpemVkJyB8ICdMZWdhY3lVbmVucm9sbGVkJyB8ICdVbmF2YWlsYWJsZSdcbiIsCiAgICAiICB0YXJnZXRJZGVudGl0eUVucm9sbG1lbnRTdGF0ZTogJ05vdFJlcXVpcmVkJyB8ICdBdXRob3JpemVkJyB8ICdVbmF2YWlsYWJsZSdcbiIsCikKcmVtb3ZlX2RlY2woCiAgICAiZmUvc3JjL3NlcnZpY2VzL2FwaS50cyIsCiAgICAiYXN5bmMgcmVhdXRob3JpemVMZWdhY3lSb290Rm9sZGVyUmVsb2NhdGlvblRhcmdldCgiLAopCnJlbW92ZV9kZWNsKAogICAgImZlL3NyYy9zdG9yZXMvcm9vdEZvbGRlcnMudHMiLAogICAgImFzeW5jIGZ1bmN0aW9uIHJlYXV0aG9yaXplTGVnYWN5VGFyZ2V0KCIsCikKcmVwbGFjZV9vbmNlKAogICAgImZlL3NyYy9zdG9yZXMvcm9vdEZvbGRlcnMudHMiLAogICAgIiAgICByZWF1dGhvcml6ZUxlZ2FjeVRhcmdldCxcbiIsCiAgICAiIiwKKQoKIyBGcm9udGVuZCB2aWV3OiByZW1vdmUgb25seSByZWxvY2F0aW9uLXRhcmdldCByZWF1dGhvcml6YXRpb247IHJvb3QgaWRlbnRpdHkgcmVhdXRob3JpemF0aW9uIHJlbWFpbnMuCnZ1ZSA9ICJmZS9zcmMvY29tcG9uZW50cy9zZXR0aW5ncy9Sb290Rm9sZGVyc1NldHRpbmdzLnZ1ZSIKcmVwbGFjZV9vbmNlKAogICAgdnVlLAogICAgIiIiICAgICAgICAgICAgICA8YnV0dG9uCiAgICAgICAgICAgICAgICB2LWlmPSJjYW5SZWF1dGhvcml6ZUxlZ2FjeVRhcmdldChmb2xkZXIpIgogICAgICAgICAgICAgICAgdHlwZT0iYnV0dG9uIgogICAgICAgICAgICAgICAgY2xhc3M9ImJ0biBidG4tc2Vjb25kYXJ5IgogICAgICAgICAgICAgICAgZGF0YS1jeT0icmVhdXRob3JpemUtcmVsb2NhdGlvbi10YXJnZXQiCiAgICAgICAgICAgICAgICBAY2xpY2s9ImNvbmZpcm1MZWdhY3lUYXJnZXRSZWF1dGhvcml6YXRpb24oZm9sZGVyKSIKICAgICAgICAgICAgICA+CiAgICAgICAgICAgICAgICBSZWF1dGhvcml6ZSB0YXJnZXQKICAgICAgICAgICAgICA8L2J1dHRvbj4KIiIiLAogICAgIiIsCikKcmVwbGFjZV9vbmNlKAogICAgdnVlLAogICAgIiIiICAgIDxEZWxldGVDb25maXJtYXRpb25Nb2RhbAogICAgICA6dmlzaWJsZT0icmVsb2NhdGlvblRvUmVhdXRob3JpemUgIT09IG51bGwiCiAgICAgIHRpdGxlPSJSZWF1dGhvcml6ZSByZWxvY2F0aW9uIHRhcmdldCIKICAgICAgY29uZmlybS10ZXh0PSJSZWF1dGhvcml6ZSB0YXJnZXQiCiAgICAgIEBjbG9zZT0icmVsb2NhdGlvblRvUmVhdXRob3JpemUgPSBudWxsIgogICAgICBAY29uZmlybT0iZXhlY3V0ZUxlZ2FjeVRhcmdldFJlYXV0aG9yaXphdGlvbiIKICAgID4KICAgICAgPHRlbXBsYXRlICNjb25maXJtLWljb24+PFBoU2hpZWxkQ2hlY2sgLz48L3RlbXBsYXRlPgogICAgICA8dGVtcGxhdGUgI2RlZmF1bHQ+CiAgICAgICAgPHA+CiAgICAgICAgICBDb25maXJtIHRoYXQgdGhpcyBpcyB0aGUgZXhhY3QgdGFyZ2V0IGRpcmVjdG9yeSB5b3UgaW50ZW5kIHRvIGF1dGhvcml6ZSBmb3IgdGhlIHBlbmRpbmcKICAgICAgICAgIHJlbG9jYXRpb246CiAgICAgICAgPC9wPgogICAgICAgIDxwPgogICAgICAgICAgPGNvZGUgY2xhc3M9InJlYXV0aG9yaXphdGlvbi10YXJnZXQtcGF0aCIgZGF0YS10ZXN0aWQ9InJlYXV0aG9yaXphdGlvbi10YXJnZXQtcGF0aCI+e3sKICAgICAgICAgICAgcmVsb2NhdGlvblRvUmVhdXRob3JpemU/LnRhcmdldFBhdGgKICAgICAgICAgIH19PC9jb2RlPgogICAgICAgIDwvcD4KICAgICAgICA8cD5UaGlzIGF1dGhvcml6YXRpb24gYWxzbyByZXRyaWVzIHRoZSBwZW5kaW5nIHJlbG9jYXRpb24uPC9wPgogICAgICA8L3RlbXBsYXRlPgogICAgPC9EZWxldGVDb25maXJtYXRpb25Nb2RhbD4KIiIiLAogICAgIiIsCikKcmVwbGFjZV9vbmNlKAogICAgdnVlLAogICAgImltcG9ydCB0eXBlIHsgUm9vdEZvbGRlciwgUm9vdEZvbGRlclBhdGhDaGFuZ2VSZXN1bHQgfSBmcm9tICdAL3R5cGVzJ1xuIiwKICAgICJpbXBvcnQgdHlwZSB7IFJvb3RGb2xkZXIgfSBmcm9tICdAL3R5cGVzJ1xuIiwKKQpyZXBsYWNlX29uY2UoCiAgICB2dWUsCiAgICIiImNvbnN0IHJlbG9jYXRpb25Ub1JlYXV0aG9yaXplID0gcmVmPHsKICByZWxvY2F0aW9uSWQ6IHN0cmluZwogIHRhcmdldFBhdGg6IHN0cmluZwp9IHwgbnVsbD4obnVsbCkKIiIiLAogICAgIiIsCikKZm9yIG5lZWRsZSBpbiAoCiAgICAiZnVuY3Rpb24gY2FuUmVhdXRob3JpemVMZWdhY3lUYXJnZXQoIiwKICAgICJmdW5jdGlvbiBjb25maXJtTGVnYWN5VGFyZ2V0UmVhdXRob3JpemF0aW9uKCIsCiAgICAiYXN5bmMgZnVuY3Rpb24gZXhlY3V0ZUxlZ2FjeVRhcmdldFJlYXV0aG9yaXphdGlvbigiLAopOgogICAgcmVtb3ZlX2RlY2wodnVlLCBuZWVkbGUpCgojIEZyb250ZW5kIHRlc3RzIHNvbGVseSBmb3IgZGlzY2FyZGVkIGJlaGF2aW9yLgphcGlfdGVzdCA9IFBhdGgoImZlL3NyYy9fX3Rlc3RzX18vYXBpLnJvb3RGb2xkZXJSZWxvY2F0aW9uUmVhdXRob3JpemF0aW9uLnNwZWMudHMiKQppZiBub3QgYXBpX3Rlc3QuZXhpc3RzKCk6CiAgICByYWlzZSBTeXN0ZW1FeGl0KCJtaXNzaW5nIGRlZGljYXRlZCByZWxvY2F0aW9uIHJlYXV0aCBhcGkgdGVzdCIpCmFwaV90ZXN0LnVubGluaygpCnJlbW92ZV90ZXN0X2NhbGwoCiAgICAiZmUvc3JjL19fdGVzdHNfXy9Sb290Rm9sZGVyc1NldHRpbmdzLnNwZWMudHMiLAogICAgInNob3dzIGxlZ2FjeSByZWF1dGhvcml6YXRpb24gc2VwYXJhdGVseSBhbmQgY29uZmlybXMgdGhlIGV4YWN0IHRhcmdldCBwYXRoIiwKKQoKIyBCYWNrZW5kIHRlc3RzIHNvbGVseSBmb3IgZGlzY2FyZGVkIGVuZHBvaW50L3NlcnZpY2UuCmZvciBtZXRob2QgaW4gKAogICAgIlJlYXV0aG9yaXplTGVnYWN5VGFyZ2V0X0V4aXN0aW5nTW92ZUpvYl9CaW5kc0NvbmZpcm1lZFRhcmdldEdlbmVyYXRpb25CZWZvcmVSZXRyeSIsCiAgICAiUmVhdXRob3JpemVMZWdhY3lUYXJnZXRfQ29udHJhZGljdG9yeUNoaWxkQXV0aG9yaXphdGlvbl9SZWplY3RzQmVmb3JlVGFyZ2V0RW5yb2xsbWVudCIsCiAgICAiUmVhdXRob3JpemVMZWdhY3lUYXJnZXRfUmVxdWVzdENhbmNlbGxlZEFmdGVyQXV0aG9yaXphdGlvbl9Db21wbGV0ZXNSZXRyeSIsCik6CiAgICByZW1vdmVfZGVjbCgKICAgICAgICAidGVzdHMvRmVhdHVyZXMvSW5mcmFzdHJ1Y3R1cmUvTGlicmFyeS9Nb3ZpbmcvUm9vdEZvbGRlclJlbG9jYXRpb25TZXJ2aWNlVGVzdHMuY3MiLAogICAgICAgIG1ldGhvZCwKICAgICAgICBpbmNsdWRlX2F0dHJpYnV0ZXM9VHJ1ZSwKICAgICkKCiMgUmVtb3ZlIHRoZSBjb21wbGV0ZSBjbGFzc2lmaWVyIHRoZW9yeSwgcHJlc2VydmluZyB0aGUgY2xhc3MgY2xvc2luZyBicmFjZS4KcGF0aCA9IFBhdGgoInRlc3RzL0ZlYXR1cmVzL0RvbWFpbi9BdWRpb2Jvb2tzL1Jvb3RGb2xkZXJSZWxvY2F0aW9uU3RhdGVUZXN0cy5jcyIpCnRleHQgPSBwYXRoLnJlYWRfdGV4dCgpCnN0YXJ0ID0gdGV4dC5maW5kKCIgICAgW1RoZW9yeV1cbiAgICBbSW5saW5lRGF0YShcbiAgICAgICAgUm9vdEZvbGRlclJlbG9jYXRpb25TdGF0dXMuUGVuZGluZyIpCmlmIHN0YXJ0IDwgMDoKICAgIHJhaXNlIFN5c3RlbUV4aXQoIm1pc3NpbmcgdGFyZ2V0IGVucm9sbG1lbnQgY2xhc3NpZmllciB0aGVvcnkiKQpjbGFzc19jbG9zZSA9IHRleHQucmZpbmQoIn0iKQppZiBjbGFzc19jbG9zZSA8PSBzdGFydDoKICAgIHJhaXNlIFN5c3RlbUV4aXQoImludmFsaWQgY2xhc3NpZmllciB0ZXN0IGNsYXNzIHN0cnVjdHVyZSIpCnBhdGgud3JpdGVfdGV4dCh0ZXh0WzpzdGFydF0gKyB0ZXh0W2NsYXNzX2Nsb3NlOl0pCg==' | base64 -d > /tmp/cleanup.py python3 /tmp/cleanup.py - name: Restore and build From 9d5a5df459515301d333de1272dfda51af30f3dc Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Fri, 7 Aug 2026 10:51:09 -0400 Subject: [PATCH 419/464] chore(ci): add temporary PR cleanup script --- .github/scripts/pr717_cleanup.py | 270 +++++++++++++++++++++++++++++++ 1 file changed, 270 insertions(+) create mode 100644 .github/scripts/pr717_cleanup.py diff --git a/.github/scripts/pr717_cleanup.py b/.github/scripts/pr717_cleanup.py new file mode 100644 index 000000000..cf4b30953 --- /dev/null +++ b/.github/scripts/pr717_cleanup.py @@ -0,0 +1,270 @@ +from pathlib import Path + + +def read(path: str) -> str: + return Path(path).read_text() + + +def write(path: str, text: str) -> None: + Path(path).write_text(text) + + +def replace_once(path: str, old: str, new: str) -> None: + text = read(path) + if old not in text: + raise SystemExit(f"missing expected block in {path}: {old[:100]!r}") + write(path, text.replace(old, new, 1)) + + +def find_matching_brace(text: str, brace: int) -> int: + depth = 0 + quote = None + escaped = False + index = brace + while index < len(text): + char = text[index] + if quote: + if escaped: + escaped = False + elif char == "\\": + escaped = True + elif char == quote: + quote = None + else: + if char in ("'", '"'): + quote = char + elif char == "{": + depth += 1 + elif char == "}": + depth -= 1 + if depth == 0: + return index + 1 + index += 1 + raise SystemExit("unbalanced brace") + + +def remove_decl(path: str, needle: str, include_attributes: bool = False) -> None: + text = read(path) + position = text.find(needle) + if position < 0: + raise SystemExit(f"missing declaration {needle!r} in {path}") + line_start = text.rfind("\n", 0, position) + 1 + start = line_start + if include_attributes: + while start > 0: + previous_end = start - 1 + previous_start = text.rfind("\n", 0, previous_end) + 1 + previous = text[previous_start : previous_end + 1].strip() + if previous.startswith("["): + start = previous_start + continue + break + brace = text.find("{", position) + if brace < 0: + raise SystemExit(f"no brace for {needle!r}") + end = find_matching_brace(text, brace) + while end < len(text) and text[end] in " \t\r\n": + end += 1 + write(path, text[:start] + text[end:]) + + +def remove_test_call(path: str, title: str) -> None: + text = read(path) + token = f" it('{title}'" + position = text.find(token) + if position < 0: + raise SystemExit(f"missing vitest {title!r}") + arrow = text.find("=>", position) + brace = text.find("{", arrow) + end = find_matching_brace(text, brace) + while end < len(text) and text[end] in " \t\r\n": + end += 1 + if text.startswith(")", end): + end += 1 + if text.startswith(";", end): + end += 1 + while end < len(text) and text[end] in " \t\r\n": + end += 1 + write(path, text[:position] + text[end:]) + + +replace_once( + "listenarr.application/Audiobooks/Contracts/IRootFolderRelocationService.cs", + """ Task ReauthorizeLegacyTargetAsync( + Guid relocationId, + string confirmedTargetPath, + CancellationToken cancellationToken = default); + +""", + "", +) +replace_once( + "listenarr.application/Audiobooks/Contracts/RootFolderRelocationPublicProjection.cs", + """ TargetIdentityEnrollmentState.LegacyUnenrolled => + "The relocation target must be reauthorized before the relocation can continue.", +""", + "", +) +replace_once( + "listenarr.domain/Audiobooks/RootFolderRelocation.cs", + " LegacyUnenrolled,\n", + "", +) +remove_decl( + "listenarr.domain/Audiobooks/RootFolderRelocation.cs", + "public static class TargetIdentityEnrollment", +) +replace_once( + "listenarr.infrastructure/Persistence/RootFolderObjectIdentityReconciler.cs", + """ var relocations = await db.RootFolderRelocations + .ToListAsync(cancellationToken); + foreach (var relocation in relocations) + { + relocation.TargetIdentityEnrollmentState = + TargetIdentityEnrollment.Classify(relocation); + } + +""", + "", +) +replace_once( + "listenarr.infrastructure/Library/Moving/RootFolderRelocationService.Retry.cs", + """ if (relocation.TargetIdentityEnrollmentState + == TargetIdentityEnrollmentState.LegacyUnenrolled) + { + throw new InvalidOperationException( + "The legacy relocation target must be explicitly reauthorized before retry."); + } +""", + "", +) +replace_once( + "listenarr.api/Features/Library/RootFolderRelocationsController.cs", + " public sealed record ReauthorizeLegacyTargetRequest(string ConfirmedTargetPath);\n\n", + "", +) +remove_decl( + "listenarr.api/Features/Library/RootFolderRelocationsController.cs", + "public async Task ReauthorizeLegacyTarget(", + include_attributes=True, +) + +reauthorization = Path( + "listenarr.infrastructure/Library/Moving/RootFolderRelocationService.Reauthorization.cs" +) +if not reauthorization.exists(): + raise SystemExit("missing relocation reauthorization implementation") +reauthorization.unlink() + +replace_once( + "fe/src/types/index.ts", + " targetIdentityEnrollmentState: 'NotRequired' | 'Authorized' | 'LegacyUnenrolled' | 'Unavailable'\n", + " targetIdentityEnrollmentState: 'NotRequired' | 'Authorized' | 'Unavailable'\n", +) +remove_decl( + "fe/src/services/api.ts", + "async reauthorizeLegacyRootFolderRelocationTarget(", +) +remove_decl( + "fe/src/stores/rootFolders.ts", + "async function reauthorizeLegacyTarget(", +) +replace_once( + "fe/src/stores/rootFolders.ts", + " reauthorizeLegacyTarget,\n", + "", +) + +vue = "fe/src/components/settings/RootFoldersSettings.vue" +replace_once( + vue, + """ +""", + "", +) +replace_once( + vue, + """ + + + +""", + "", +) +replace_once( + vue, + "import type { RootFolder, RootFolderPathChangeResult } from '@/types'\n", + "import type { RootFolder } from '@/types'\n", +) +replace_once( + vue, + """const relocationToReauthorize = ref<{ + relocationId: string + targetPath: string +} | null>(null) +""", + "", +) +for needle in ( + "function canReauthorizeLegacyTarget(", + "function confirmLegacyTargetReauthorization(", + "async function executeLegacyTargetReauthorization(", +): + remove_decl(vue, needle) + +api_test = Path("fe/src/__tests__/api.rootFolderRelocationReauthorization.spec.ts") +if not api_test.exists(): + raise SystemExit("missing dedicated relocation reauth api test") +api_test.unlink() +remove_test_call( + "fe/src/__tests__/RootFoldersSettings.spec.ts", + "shows legacy reauthorization separately and confirms the exact target path", +) + +for method in ( + "ReauthorizeLegacyTarget_ExistingMoveJob_BindsConfirmedTargetGenerationBeforeRetry", + "ReauthorizeLegacyTarget_ContradictoryChildAuthorization_RejectsBeforeTargetEnrollment", + "ReauthorizeLegacyTarget_RequestCancelledAfterAuthorization_CompletesRetry", +): + remove_decl( + "tests/Features/Infrastructure/Library/Moving/RootFolderRelocationServiceTests.cs", + method, + include_attributes=True, + ) + +path = Path("tests/Features/Domain/Audiobooks/RootFolderRelocationStateTests.cs") +text = path.read_text() +start = text.find( + " [Theory]\n [InlineData(\n RootFolderRelocationStatus.Pending" +) +if start < 0: + raise SystemExit("missing target enrollment classifier theory") +class_close = text.rfind("}") +if class_close <= start: + raise SystemExit("invalid classifier test class structure") +path.write_text(text[:start] + text[class_close:]) From 46e186a982bc69c42fd12af3cebcae65d3b4ea7a Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Fri, 7 Aug 2026 10:51:32 -0400 Subject: [PATCH 420/464] chore(ci): use checked-in cleanup executor --- .github/workflows/pr717-cleanup-analysis.yml | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/.github/workflows/pr717-cleanup-analysis.yml b/.github/workflows/pr717-cleanup-analysis.yml index 5229ad66c..24a608033 100644 --- a/.github/workflows/pr717-cleanup-analysis.yml +++ b/.github/workflows/pr717-cleanup-analysis.yml @@ -18,11 +18,7 @@ jobs: fetch-depth: 0 - name: Remove intermediate root relocation compatibility - shell: bash - run: | - set -euo pipefail - echo 'CmZyb20gcGF0aGxpYiBpbXBvcnQgUGF0aAoKZGVmIHJlYWQocGF0aCk6CiAgICByZXR1cm4gUGF0aChwYXRoKS5yZWFkX3RleHQoKQoKZGVmIHdyaXRlKHBhdGgsIHRleHQpOgogICAgUGF0aChwYXRoKS53cml0ZV90ZXh0KHRleHQpCgpkZWYgcmVwbGFjZV9vbmNlKHBhdGgsIG9sZCwgbmV3KToKICAgIHRleHQgPSByZWFkKHBhdGgpCiAgICBpZiBvbGQgbm90IGluIHRleHQ6CiAgICAgICAgcmFpc2UgU3lzdGVtRXhpdChmIm1pc3NpbmcgZXhwZWN0ZWQgYmxvY2sgaW4ge3BhdGh9OiB7b2xkWzoxMDBdIXJ9IikKICAgIHdyaXRlKHBhdGgsIHRleHQucmVwbGFjZShvbGQsIG5ldywgMSkpCgpkZWYgZmluZF9tYXRjaGluZ19icmFjZSh0ZXh0LCBicmFjZSk6CiAgICBkZXB0aCA9IDAKICAgIHF1b3RlID0gTm9uZQogICAgZXNjID0gRmFsc2UKICAgIGkgPSBicmFjZQogICAgd2hpbGUgaSA8IGxlbih0ZXh0KToKICAgICAgICBjaCA9IHRleHRbaV0KICAgICAgICBpZiBxdW90ZToKICAgICAgICAgICAgaWYgZXNjOgogICAgICAgICAgICAgICAgZXNjID0gRmFsc2UKICAgICAgICAgICAgZWxpZiBjaCA9PSAnXFwnOgogICAgICAgICAgICAgICAgZXNjID0gVHJ1ZQogICAgICAgICAgICBlbGlmIGNoID09IHF1b3RlOgogICAgICAgICAgICAgICAgcXVvdGUgPSBOb25lCiAgICAgIGVsc2U6CiAgICAgICAgICAgIGlmIGNoIGluICgiJyIsICciJyk6CiAgICAgICAgICAgICAgICBxdW90ZSA9IGNoCiAgICAgICAgICAgIGVsaWYgY2ggPT0gJ3snOgogICAgICAgICAgICAgICAgZGVwdGggKz0gMQogICAgICAgICAgICBlbGlmIGNoID09ICd9JzoKICAgICAgICAgICAgICAgIGRlcHRoIC09IDEKICAgICAgICAgICAgICAgIGlmIGRlcHRoID09IDA6CiAgICAgICAgICAgICAgICAgICAgcmV0dXJuIGkgKyAxCiAgICAgICAgaSArPSAxCiAgICByYWlzZSBTeXN0ZW1FeGl0KCJ1bmJhbGFuY2VkIGJyYWNlIikKCmRlZiByZW1vdmVfZGVjbChwYXRoLCBuZWVkbGUsIGluY2x1ZGVfYXR0cmlidXRlcz1GYWxzZSk6CiAgICB0ZXh0ID0gcmVhZChwYXRoKQogICAgcG9zID0gdGV4dC5maW5kKG5lZWRsZSkKICAgIGlmIHBvcyA8IDA6CiAgICAgICAgcmFpc2UgU3lzdGVtRXhpdChmIm1pc3NpbmcgZGVjbGFyYXRpb24ge25lZWRsZSFyfSBpbiB7cGF0aH0iKQogICAgbGluZV9zdGFydCA9IHRleHQucmZpbmQoJ1xuJywgMCwgcG9zKSArIDEKICAgIHN0YXJ0ID0gbGluZV9zdGFydAogICAgaWYgaW5jbHVkZV9hdHRyaWJ1dGVzOgogICAgICAgIHdoaWxlIHN0YXJ0ID4gMDoKICAgICAgICAgICAgcHJldl9lbmQgPSBzdGFydCAtIDEKICAgICAgICAgICAgcHJldl9zdGFydCA9IHRleHQucmZpbmQoJ1xuJywgMCwgcHJldl9lbmQpICsgMQogICAgICAgICAgICBwcmV2ID0gdGV4dFtwcmV2X3N0YXJ0OnByZXZfZW5kKzFdLnN0cmlwKCkKICAgICAgICAgICAgaWYgcHJldi5zdGFydHN3aXRoKCdbJyk6CiAgICAgICAgICAgICAgICBzdGFydCA9IHByZXZfc3RhcnQKICAgICAgICAgICAgICAgIGNvbnRpbnVlCiAgICAgICAgICAgIGJyZWFrCiAgICBicmFjZSA9IHRleHQuZmluZCgneycsIHBvcykKICAgIGlmIGJyYWNlIDwgMDoKICAgICAgICByYWlzZSBTeXN0ZW1FeGl0KGYibm8gYnJhY2UgZm9yIHtuZWVkbGUhcn0iKQogICAgZW5kID0gZmluZF9tYXRjaGluZ19icmFjZSh0ZXh0LCBicmFjZSkKICAgIHdoaWxlIGVuZCA8IGxlbih0ZXh0KSBhbmQgdGV4dFtlbmRdIGluICcgXHRcclxuJzoKICAgICAgICBlbmQgKz0gMQogICAgd3JpdGUocGF0aCwgdGV4dFs6c3RhcnRdICsgdGV4dFtlbmQ6XSkKCmRlZiByZW1vdmVfdGVzdF9jYWxsKHBhdGgsIHRpdGxlKToKICAgIHRleHQgPSByZWFkKHBhdGgpCiAgICB0b2tlbiA9IGYiICBpdCgne3RpdGxlfSciCiAgICBwb3MgPSB0ZXh0LmZpbmQodG9rZW4pCiAgICBpZiBwb3MgPCAwOgogICAgICAgIHJhaXNlIFN5c3RlbUV4aXQoZiJtaXNzaW5nIHZpdGVzdCB7dGl0bGUhcn0iKQogICAgYXJyb3cgPSB0ZXh0LmZpbmQoJz0+JywgcG9zKQogICAgYnJhY2UgPSB0ZXh0LmZpbmQoJ3snLCBhcnJvdykKICAgIGVuZF9ib2R5ID0gZmluZF9tYXRjaGluZ19icmFjZSh0ZXh0LCBicmFjZSkKICAgIGVuZCA9IGVuZF9ib2R5CiAgICB3aGlsZSBlbmQgPCBsZW4odGV4dCkgYW5kIHRleHRbZW5kXSBpbiAnIFx0XHJcbic6CiAgICAgICAgZW5kICs9IDEKICAgIGlmIHRleHQuc3RhcnRzd2l0aCgnKScsIGVuZCk6CiAgICAgICAgZW5kICs9IDEKICAgIGlmIHRleHQuc3RhcnRzd2l0aCgnOycsIGVuZCk6CiAgICAgICAgZW5kICs9IDEKICAgIHdoaWxlIGVuZCA8IGxlbih0ZXh0KSBhbmQgdGV4dFtlbmRdIGluICcgXHRcclxuJzoKICAgICAgICBlbmQgKz0gMQogICAgd3JpdGUocGF0aCwgdGV4dFs6cG9zXSArIHRleHRbZW5kOl0pCgojIEFwcGxpY2F0aW9uIEFQSSBjb250cmFjdC4KcmVwbGFjZV9vbmNlKAogICAgImxpc3RlbmFyci5hcHBsaWNhdGlvbi9BdWRpb2Jvb2tzL0NvbnRyYWN0cy9JUm9vdEZvbGRlclJlbG9jYXRpb25TZXJ2aWNlLmNzIiwKICAgICIiIiAgICBUYXNrPFJvb3RGb2xkZXJQYXRoQ2hhbmdlUmVzdWx0PiBSZWF1dGhvcml6ZUxlZ2FjeVRhcmdldEFzeW5jKAogICAgICAgIEd1aWQgcmVsb2NhdGlvbklkLAogICAgICAgIHN0cmluZyBjb25maXJtZWRUYXJnZXRQYXRoLAogICAgICAgIENhbmNlbGxhdGlvblRva2VuIGNhbmNlbGxhdGlvblRva2VuID0gZGVmYXVsdCk7CgoiIiIsCiAgICAiIiwKKQpyZXBsYWNlX29uY2UoCiAgICAibGlzdGVuYXJyLmFwcGxpY2F0aW9uL0F1ZGlvYm9va3MvQ29udHJhY3RzL1Jvb3RGb2xkZXJSZWxvY2F0aW9uUHVibGljUHJvamVjdGlvbi5jcyIsCiAgICAiIiIgICAgICAgICAgICBUYXJnZXRJZGVudGl0eUVucm9sbG1lbnRTdGF0ZS5MZWdhY3lVbmVucm9sbGVkID0+CiAgICAgICAgICAgICAgICAiVGhlIHJlbG9jYXRpb24gdGFyZ2V0IG11c3QgYmUgcmVhdXRob3JpemVkIGJlZm9yZSB0aGUgcmVsb2NhdGlvbiBjYW4gY29udGludWUuIiwKIiIiLAogICAgIiIsCikKCiMgRG9tYWluIHN0YXRlOiBkZWxldGUgdGhlIGJyYW5jaC1vbmx5IGVudW0gc3RhdGUgYW5kIGNsYXNzaWZpZXIuCnJlcGxhY2Vfb25jZSgKICAgICJsaXN0ZW5hcnIuZG9tYWluL0F1ZGlvYm9va3MvUm9vdEZvbGRlclJlbG9jYXRpb24uY3MiLAogICAgIiAgICBMZWdhY3lVbmVucm9sbGVkLFxuIiwKICAgICIiLAopCnJlbW92ZV9kZWNsKAogICAgImxpc3RlbmFyci5kb21haW4vQXVkaW9ib29rcy9Sb290Rm9sZGVyUmVsb2NhdGlvbi5jcyIsCiAgICAicHVibGljIHN0YXRpYyBjbGFzcyBUYXJnZXRJZGVudGl0eUVucm9sbG1lbnQiLAopCgojIFN0YXJ0dXAgbm8gbG9uZ2VyIGNsYXNzaWZpZXMgcm93cyBmcm9tIGludGVybWVkaWF0ZSBQUiBzY2hlbWFzLgpyZXBsYWNlX29uY2UoCiAgICAibGlzdGVuYXJyLmluZnJhc3RydWN0dXJlL1BlcnNpc3RlbmNlL1Jvb3RGb2xkZXJPYmplY3RJZGVudGl0eVJlY29uY2lsZXIuY3MiLAogICAgIiIiICAgICAgICB2YXIgcmVsb2NhdGlvbnMgPSBhd2FpdCBkYi5Sb290Rm9sZGVyUmVsb2NhdGlvbnMKICAgICAgICAgICAgLlRvTGlzdEFzeW5jKGNhbmNlbGxhdGlvblRva2VuKTsKICAgICAgICBmb3JlYWNoICh2YXIgcmVsb2NhdGlvbiBpbiByZWxvY2F0aW9ucykKICAgICAgICB7CiAgICAgICAgICAgIHJlbG9jYXRpb24uVGFyZ2V0SWRlbnRpdHlFbnJvbGxtZW50U3RhdGUgPQogICAgICAgICAgICAgICAgVGFyZ2V0SWRlbnRpdHlFbnJvbGxtZW50LkNsYXNzaWZ5KHJlbG9jYXRpb24pOwogICAgICAgIH0KCiIiIiwKICAgICIiLAopCgojIFJldHJ5IG9ubHkgbmVlZHMgdGhlIGZpbmFsIHVuYXZhaWxhYmxlLXN0YXRlIGZlbmNlLgpyZXBsYWNlX29uY2UoCiAgICAibGlzdGVuYXJyLmluZnJhc3RydWN0dXJlL0xpYnJhcnkvTW92aW5nL1Jvb3RGb2xkZXJSZWxvY2F0aW9uU2VydmljZS5SZXRyeS5jcyIsCiAgICAiIiIgICAgICAgIGlmIChyZWxvY2F0aW9uLlRhcmdldElkZW50aXR5RW5yb2xsbWVudFN0YXRlCiAgICAgICAgICAgID09IFRhcmdldElkZW50aXR5RW5yb2xsbWVudFN0YXRlLkxlZ2FjeVVuZW5yb2xsZWQpCiAgICAgICAgewogICAgICAgICAgICB0aHJvdyBuZXcgSW52YWxpZE9wZXJhdGlvbkV4Y2VwdGlvbigKICAgICAgICAgICAgICAgICJUaGUgbGVnYWN5IHJlbG9jYXRpb24gdGFyZ2V0IG11c3QgYmUgZXhwbGljaXRseSByZWF1dGhvcml6ZWQgYmVmb3JlIHJldHJ5LiIpOwogICAgICAgIH0KIiIiLAogICAgIiIsCikKCiMgSFRUUCBlbmRwb2ludC4KcmVwbGFjZV9vbmNlKAogICAgImxpc3RlbmFyci5hcGkvRmVhdHVyZXMvTGlicmFyeS9Sb290Rm9sZGVyUmVsb2NhdGlvbnNDb250cm9sbGVyLmNzIiwKICAgICIgICAgcHVibGljIHNlYWxlZCByZWNvcmQgUmVhdXRob3JpemVMZWdhY3lUYXJnZXRSZXF1ZXN0KHN0cmluZyBDb25maXJtZWRUYXJnZXRQYXRoKTtcblxuIiwKICAgICIiLAopCnJlbW92ZV9kZWNsKAogICAgImxpc3RlbmFyci5hcGkvRmVhdHVyZXMvTGlicmFyeS9Sb290Rm9sZGVyUmVsb2NhdGlvbnNDb250cm9sbGVyLmNzIiwKICAgICJwdWJsaWMgYXN5bmMgVGFzazxJQWN0aW9uUmVzdWx0PiBSZWF1dGhvcml6ZUxlZ2FjeVRhcmdldCgiLAogICAgaW5jbHVkZV9hdHRyaWJ1dGVzPVRydWUsCikKCiMgRGVkaWNhdGVkIGJyYW5jaC1oaXN0b3J5IGltcGxlbWVudGF0aW9uLgpyZWF1dGggPSBQYXRoKCJsaXN0ZW5hcnIuaW5mcmFzdHJ1Y3R1cmUvTGlicmFyeS9Nb3ZpbmcvUm9vdEZvbGRlclJlbG9jYXRpb25TZXJ2aWNlLlJlYXV0aG9yaXphdGlvbi5jcyIpCmlmIG5vdCByZWF1dGguZXhpc3RzKCk6CiAgICByYWlzZSBTeXN0ZW1FeGl0KCJtaXNzaW5nIHJlbG9jYXRpb24gcmVhdXRob3JpemF0aW9uIGltcGxlbWVudGF0aW9uIikKcmVhdXRoLnVubGluaygpCgojIEZyb250ZW5kIGNvbnRyYWN0L0FQSS9zdG9yZS4KcmVwbGFjZV9vbmNlKAogICAgImZlL3NyYy90eXBlcy9pbmRleC50cyIsCiAgICAiICB0YXJnZXRJZGVudGl0eUVucm9sbG1lbnRTdGF0ZTogJ05vdFJlcXVpcmVkJyB8ICdBdXRob3JpemVkJyB8ICdMZWdhY3lVbmVucm9sbGVkJyB8ICdVbmF2YWlsYWJsZSdcbiIsCiAgICAiICB0YXJnZXRJZGVudGl0eUVucm9sbG1lbnRTdGF0ZTogJ05vdFJlcXVpcmVkJyB8ICdBdXRob3JpemVkJyB8ICdVbmF2YWlsYWJsZSdcbiIsCikKcmVtb3ZlX2RlY2woCiAgICAiZmUvc3JjL3NlcnZpY2VzL2FwaS50cyIsCiAgICAiYXN5bmMgcmVhdXRob3JpemVMZWdhY3lSb290Rm9sZGVyUmVsb2NhdGlvblRhcmdldCgiLAopCnJlbW92ZV9kZWNsKAogICAgImZlL3NyYy9zdG9yZXMvcm9vdEZvbGRlcnMudHMiLAogICAgImFzeW5jIGZ1bmN0aW9uIHJlYXV0aG9yaXplTGVnYWN5VGFyZ2V0KCIsCikKcmVwbGFjZV9vbmNlKAogICAgImZlL3NyYy9zdG9yZXMvcm9vdEZvbGRlcnMudHMiLAogICAgIiAgICByZWF1dGhvcml6ZUxlZ2FjeVRhcmdldCxcbiIsCiAgICAiIiwKKQoKIyBGcm9udGVuZCB2aWV3OiByZW1vdmUgb25seSByZWxvY2F0aW9uLXRhcmdldCByZWF1dGhvcml6YXRpb247IHJvb3QgaWRlbnRpdHkgcmVhdXRob3JpemF0aW9uIHJlbWFpbnMuCnZ1ZSA9ICJmZS9zcmMvY29tcG9uZW50cy9zZXR0aW5ncy9Sb290Rm9sZGVyc1NldHRpbmdzLnZ1ZSIKcmVwbGFjZV9vbmNlKAogICAgdnVlLAogICAgIiIiICAgICAgICAgICAgICA8YnV0dG9uCiAgICAgICAgICAgICAgICB2LWlmPSJjYW5SZWF1dGhvcml6ZUxlZ2FjeVRhcmdldChmb2xkZXIpIgogICAgICAgICAgICAgICAgdHlwZT0iYnV0dG9uIgogICAgICAgICAgICAgICAgY2xhc3M9ImJ0biBidG4tc2Vjb25kYXJ5IgogICAgICAgICAgICAgICAgZGF0YS1jeT0icmVhdXRob3JpemUtcmVsb2NhdGlvbi10YXJnZXQiCiAgICAgICAgICAgICAgICBAY2xpY2s9ImNvbmZpcm1MZWdhY3lUYXJnZXRSZWF1dGhvcml6YXRpb24oZm9sZGVyKSIKICAgICAgICAgICAgICA+CiAgICAgICAgICAgICAgICBSZWF1dGhvcml6ZSB0YXJnZXQKICAgICAgICAgICAgICA8L2J1dHRvbj4KIiIiLAogICAgIiIsCikKcmVwbGFjZV9vbmNlKAogICAgdnVlLAogICAgIiIiICAgIDxEZWxldGVDb25maXJtYXRpb25Nb2RhbAogICAgICA6dmlzaWJsZT0icmVsb2NhdGlvblRvUmVhdXRob3JpemUgIT09IG51bGwiCiAgICAgIHRpdGxlPSJSZWF1dGhvcml6ZSByZWxvY2F0aW9uIHRhcmdldCIKICAgICAgY29uZmlybS10ZXh0PSJSZWF1dGhvcml6ZSB0YXJnZXQiCiAgICAgIEBjbG9zZT0icmVsb2NhdGlvblRvUmVhdXRob3JpemUgPSBudWxsIgogICAgICBAY29uZmlybT0iZXhlY3V0ZUxlZ2FjeVRhcmdldFJlYXV0aG9yaXphdGlvbiIKICAgID4KICAgICAgPHRlbXBsYXRlICNjb25maXJtLWljb24+PFBoU2hpZWxkQ2hlY2sgLz48L3RlbXBsYXRlPgogICAgICA8dGVtcGxhdGUgI2RlZmF1bHQ+CiAgICAgICAgPHA+CiAgICAgICAgICBDb25maXJtIHRoYXQgdGhpcyBpcyB0aGUgZXhhY3QgdGFyZ2V0IGRpcmVjdG9yeSB5b3UgaW50ZW5kIHRvIGF1dGhvcml6ZSBmb3IgdGhlIHBlbmRpbmcKICAgICAgICAgIHJlbG9jYXRpb246CiAgICAgICAgPC9wPgogICAgICAgIDxwPgogICAgICAgICAgPGNvZGUgY2xhc3M9InJlYXV0aG9yaXphdGlvbi10YXJnZXQtcGF0aCIgZGF0YS10ZXN0aWQ9InJlYXV0aG9yaXphdGlvbi10YXJnZXQtcGF0aCI+e3sKICAgICAgICAgICAgcmVsb2NhdGlvblRvUmVhdXRob3JpemU/LnRhcmdldFBhdGgKICAgICAgICAgIH19PC9jb2RlPgogICAgICAgIDwvcD4KICAgICAgICA8cD5UaGlzIGF1dGhvcml6YXRpb24gYWxzbyByZXRyaWVzIHRoZSBwZW5kaW5nIHJlbG9jYXRpb24uPC9wPgogICAgICA8L3RlbXBsYXRlPgogICAgPC9EZWxldGVDb25maXJtYXRpb25Nb2RhbD4KIiIiLAogICAgIiIsCikKcmVwbGFjZV9vbmNlKAogICAgdnVlLAogICAgImltcG9ydCB0eXBlIHsgUm9vdEZvbGRlciwgUm9vdEZvbGRlclBhdGhDaGFuZ2VSZXN1bHQgfSBmcm9tICdAL3R5cGVzJ1xuIiwKICAgICJpbXBvcnQgdHlwZSB7IFJvb3RGb2xkZXIgfSBmcm9tICdAL3R5cGVzJ1xuIiwKKQpyZXBsYWNlX29uY2UoCiAgICB2dWUsCiAgICIiImNvbnN0IHJlbG9jYXRpb25Ub1JlYXV0aG9yaXplID0gcmVmPHsKICByZWxvY2F0aW9uSWQ6IHN0cmluZwogIHRhcmdldFBhdGg6IHN0cmluZwp9IHwgbnVsbD4obnVsbCkKIiIiLAogICAgIiIsCikKZm9yIG5lZWRsZSBpbiAoCiAgICAiZnVuY3Rpb24gY2FuUmVhdXRob3JpemVMZWdhY3lUYXJnZXQoIiwKICAgICJmdW5jdGlvbiBjb25maXJtTGVnYWN5VGFyZ2V0UmVhdXRob3JpemF0aW9uKCIsCiAgICAiYXN5bmMgZnVuY3Rpb24gZXhlY3V0ZUxlZ2FjeVRhcmdldFJlYXV0aG9yaXphdGlvbigiLAopOgogICAgcmVtb3ZlX2RlY2wodnVlLCBuZWVkbGUpCgojIEZyb250ZW5kIHRlc3RzIHNvbGVseSBmb3IgZGlzY2FyZGVkIGJlaGF2aW9yLgphcGlfdGVzdCA9IFBhdGgoImZlL3NyYy9fX3Rlc3RzX18vYXBpLnJvb3RGb2xkZXJSZWxvY2F0aW9uUmVhdXRob3JpemF0aW9uLnNwZWMudHMiKQppZiBub3QgYXBpX3Rlc3QuZXhpc3RzKCk6CiAgICByYWlzZSBTeXN0ZW1FeGl0KCJtaXNzaW5nIGRlZGljYXRlZCByZWxvY2F0aW9uIHJlYXV0aCBhcGkgdGVzdCIpCmFwaV90ZXN0LnVubGluaygpCnJlbW92ZV90ZXN0X2NhbGwoCiAgICAiZmUvc3JjL19fdGVzdHNfXy9Sb290Rm9sZGVyc1NldHRpbmdzLnNwZWMudHMiLAogICAgInNob3dzIGxlZ2FjeSByZWF1dGhvcml6YXRpb24gc2VwYXJhdGVseSBhbmQgY29uZmlybXMgdGhlIGV4YWN0IHRhcmdldCBwYXRoIiwKKQoKIyBCYWNrZW5kIHRlc3RzIHNvbGVseSBmb3IgZGlzY2FyZGVkIGVuZHBvaW50L3NlcnZpY2UuCmZvciBtZXRob2QgaW4gKAogICAgIlJlYXV0aG9yaXplTGVnYWN5VGFyZ2V0X0V4aXN0aW5nTW92ZUpvYl9CaW5kc0NvbmZpcm1lZFRhcmdldEdlbmVyYXRpb25CZWZvcmVSZXRyeSIsCiAgICAiUmVhdXRob3JpemVMZWdhY3lUYXJnZXRfQ29udHJhZGljdG9yeUNoaWxkQXV0aG9yaXphdGlvbl9SZWplY3RzQmVmb3JlVGFyZ2V0RW5yb2xsbWVudCIsCiAgICAiUmVhdXRob3JpemVMZWdhY3lUYXJnZXRfUmVxdWVzdENhbmNlbGxlZEFmdGVyQXV0aG9yaXphdGlvbl9Db21wbGV0ZXNSZXRyeSIsCik6CiAgICByZW1vdmVfZGVjbCgKICAgICAgICAidGVzdHMvRmVhdHVyZXMvSW5mcmFzdHJ1Y3R1cmUvTGlicmFyeS9Nb3ZpbmcvUm9vdEZvbGRlclJlbG9jYXRpb25TZXJ2aWNlVGVzdHMuY3MiLAogICAgICAgIG1ldGhvZCwKICAgICAgICBpbmNsdWRlX2F0dHJpYnV0ZXM9VHJ1ZSwKICAgICkKCiMgUmVtb3ZlIHRoZSBjb21wbGV0ZSBjbGFzc2lmaWVyIHRoZW9yeSwgcHJlc2VydmluZyB0aGUgY2xhc3MgY2xvc2luZyBicmFjZS4KcGF0aCA9IFBhdGgoInRlc3RzL0ZlYXR1cmVzL0RvbWFpbi9BdWRpb2Jvb2tzL1Jvb3RGb2xkZXJSZWxvY2F0aW9uU3RhdGVUZXN0cy5jcyIpCnRleHQgPSBwYXRoLnJlYWRfdGV4dCgpCnN0YXJ0ID0gdGV4dC5maW5kKCIgICAgW1RoZW9yeV1cbiAgICBbSW5saW5lRGF0YShcbiAgICAgICAgUm9vdEZvbGRlclJlbG9jYXRpb25TdGF0dXMuUGVuZGluZyIpCmlmIHN0YXJ0IDwgMDoKICAgIHJhaXNlIFN5c3RlbUV4aXQoIm1pc3NpbmcgdGFyZ2V0IGVucm9sbG1lbnQgY2xhc3NpZmllciB0aGVvcnkiKQpjbGFzc19jbG9zZSA9IHRleHQucmZpbmQoIn0iKQppZiBjbGFzc19jbG9zZSA8PSBzdGFydDoKICAgIHJhaXNlIFN5c3RlbUV4aXQoImludmFsaWQgY2xhc3NpZmllciB0ZXN0IGNsYXNzIHN0cnVjdHVyZSIpCnBhdGgud3JpdGVfdGV4dCh0ZXh0WzpzdGFydF0gKyB0ZXh0W2NsYXNzX2Nsb3NlOl0pCg==' | base64 -d > /tmp/cleanup.py - python3 /tmp/cleanup.py + run: python3 .github/scripts/pr717_cleanup.py - name: Restore and build shell: bash @@ -51,7 +47,7 @@ jobs: run: | set -euo pipefail git diff --check - if git grep -n -E 'LegacyUnenrolled|reauthorize-legacy-target|ReauthorizeLegacyTarget|reauthorizeLegacyRootFolderRelocationTarget|reauthorizeLegacyTarget' -- ':!listenarr.infrastructure/Persistence/Migrations/**' ':!.github/workflows/pr717-cleanup-analysis.yml'; then + if git grep -n -E 'LegacyUnenrolled|reauthorize-legacy-target|ReauthorizeLegacyTarget|reauthorizeLegacyRootFolderRelocationTarget|reauthorizeLegacyTarget' -- ':!listenarr.infrastructure/Persistence/Migrations/**' ':!.github/workflows/pr717-cleanup-analysis.yml' ':!.github/scripts/pr717_cleanup.py'; then echo 'Intermediate relocation compatibility remains' >&2 exit 1 fi @@ -65,6 +61,6 @@ jobs: set -euo pipefail git config user.name 'github-actions[bot]' git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add -A + git add -A ':!.github/workflows/pr717-cleanup-analysis.yml' ':!.github/scripts/pr717_cleanup.py' git commit -m 'refactor(relocation): remove intermediate target reauthorization' git push origin HEAD:bugfix/unix-folder-name-space From 4efc658c271853688b4ae4996dfedd91ad28ba01 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Fri, 7 Aug 2026 10:52:45 -0400 Subject: [PATCH 421/464] chore(ci): trigger PR 717 cleanup executor --- .github/pr717-cleanup-trigger | 1 + 1 file changed, 1 insertion(+) create mode 100644 .github/pr717-cleanup-trigger diff --git a/.github/pr717-cleanup-trigger b/.github/pr717-cleanup-trigger new file mode 100644 index 000000000..672dbcb04 --- /dev/null +++ b/.github/pr717-cleanup-trigger @@ -0,0 +1 @@ +trigger step 3 From a9e3062d48e7563d310708b6467f59dfb1e6ba23 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Fri, 7 Aug 2026 10:56:43 -0400 Subject: [PATCH 422/464] chore(ci): extend relocation cleanup gate --- .github/workflows/pr717-cleanup-analysis.yml | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/pr717-cleanup-analysis.yml b/.github/workflows/pr717-cleanup-analysis.yml index 24a608033..9a0963709 100644 --- a/.github/workflows/pr717-cleanup-analysis.yml +++ b/.github/workflows/pr717-cleanup-analysis.yml @@ -18,7 +18,11 @@ jobs: fetch-depth: 0 - name: Remove intermediate root relocation compatibility - run: python3 .github/scripts/pr717_cleanup.py + shell: bash + run: | + set -euo pipefail + python3 .github/scripts/pr717_cleanup.py + python3 .github/scripts/pr717_cleanup_step3_extra.py - name: Restore and build shell: bash @@ -39,7 +43,7 @@ jobs: set -euo pipefail cd fe npm ci - npx vitest run src/__tests__/RootFoldersSettings.spec.ts + npx vitest run src/__tests__/RootFoldersSettings.spec.ts src/__tests__/rootFolders.reauthorization.store.spec.ts npm run type-check - name: Adversarial review removed surface @@ -47,7 +51,7 @@ jobs: run: | set -euo pipefail git diff --check - if git grep -n -E 'LegacyUnenrolled|reauthorize-legacy-target|ReauthorizeLegacyTarget|reauthorizeLegacyRootFolderRelocationTarget|reauthorizeLegacyTarget' -- ':!listenarr.infrastructure/Persistence/Migrations/**' ':!.github/workflows/pr717-cleanup-analysis.yml' ':!.github/scripts/pr717_cleanup.py'; then + if git grep -n -E 'LegacyUnenrolled|reauthorize-legacy-target|ReauthorizeLegacyTarget|reauthorizeLegacyRootFolderRelocationTarget|reauthorizeLegacyTarget' -- ':!listenarr.infrastructure/Persistence/Migrations/**' ':!.github/workflows/pr717-cleanup-analysis.yml' ':!.github/scripts/pr717_cleanup.py' ':!.github/scripts/pr717_cleanup_step3_extra.py'; then echo 'Intermediate relocation compatibility remains' >&2 exit 1 fi @@ -61,6 +65,6 @@ jobs: set -euo pipefail git config user.name 'github-actions[bot]' git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add -A ':!.github/workflows/pr717-cleanup-analysis.yml' ':!.github/scripts/pr717_cleanup.py' + git add -A ':!.github/workflows/pr717-cleanup-analysis.yml' ':!.github/scripts/pr717_cleanup.py' ':!.github/scripts/pr717_cleanup_step3_extra.py' ':!.github/pr717-cleanup-trigger' git commit -m 'refactor(relocation): remove intermediate target reauthorization' git push origin HEAD:bugfix/unix-folder-name-space From c0f0cbc620082cfddb55cbc1d82856a9b1f5771d Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Fri, 7 Aug 2026 10:56:56 -0400 Subject: [PATCH 423/464] chore(ci): add relocation cleanup test scrub --- .github/scripts/pr717_cleanup_step3_extra.py | 27 ++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 .github/scripts/pr717_cleanup_step3_extra.py diff --git a/.github/scripts/pr717_cleanup_step3_extra.py b/.github/scripts/pr717_cleanup_step3_extra.py new file mode 100644 index 000000000..fdd2c0f35 --- /dev/null +++ b/.github/scripts/pr717_cleanup_step3_extra.py @@ -0,0 +1,27 @@ +from pathlib import Path + + +def remove_block(path: str, start_marker: str, end_marker: str) -> None: + file = Path(path) + text = file.read_text() + start = text.find(start_marker) + if start < 0: + raise SystemExit(f"missing start marker in {path}: {start_marker!r}") + end = text.find(end_marker, start) + if end < 0: + raise SystemExit(f"missing end marker in {path}: {end_marker!r}") + file.write_text(text[:start] + text[end:]) + + +remove_block( + "fe/src/__tests__/rootFolders.reauthorization.store.spec.ts", + " it('passes the exact confirmed target path and reloads root folders', async () => {\n", + "})\n", +) + +setup = Path("fe/src/__tests__/test-setup.ts") +text = setup.read_text() +line = " reauthorizeLegacyRootFolderRelocationTarget: vi.fn(async () => ({})),\n" +if line not in text: + raise SystemExit("missing legacy relocation API mock") +setup.write_text(text.replace(line, "", 1)) From 9ade7cffc48ad3ed5fd469bffc47b534cd950d19 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Fri, 7 Aug 2026 11:00:36 -0400 Subject: [PATCH 424/464] chore(ci): make relocation test cleanup exact --- .github/scripts/pr717_cleanup_step3_extra.py | 47 +++++++++++++------- 1 file changed, 31 insertions(+), 16 deletions(-) diff --git a/.github/scripts/pr717_cleanup_step3_extra.py b/.github/scripts/pr717_cleanup_step3_extra.py index fdd2c0f35..bc60248b9 100644 --- a/.github/scripts/pr717_cleanup_step3_extra.py +++ b/.github/scripts/pr717_cleanup_step3_extra.py @@ -1,23 +1,38 @@ from pathlib import Path +store_test = Path("fe/src/__tests__/rootFolders.reauthorization.store.spec.ts") +text = store_test.read_text() +block = """ it('passes the exact confirmed target path and reloads root folders', async () => { + const targetPath = '/srv/Audiobooks ' + const result: RootFolderPathChangeResult = { + relocationId: 'relocation-1', + rootFolderId: 3, + currentPath: '/srv/Old', + targetPath, + status: 'Running', + totalJobs: 1, + completedJobs: 0, + targetIdentityEnrollmentState: 'Authorized', + } + vi.mocked(apiService.reauthorizeLegacyRootFolderRelocationTarget).mockResolvedValueOnce(result) + vi.mocked(apiService.getRootFolders).mockResolvedValueOnce([]) + const store = useRootFoldersStore() -def remove_block(path: str, start_marker: str, end_marker: str) -> None: - file = Path(path) - text = file.read_text() - start = text.find(start_marker) - if start < 0: - raise SystemExit(f"missing start marker in {path}: {start_marker!r}") - end = text.find(end_marker, start) - if end < 0: - raise SystemExit(f"missing end marker in {path}: {end_marker!r}") - file.write_text(text[:start] + text[end:]) + await expect(store.reauthorizeLegacyTarget('relocation-1', targetPath)).resolves.toEqual(result) - -remove_block( - "fe/src/__tests__/rootFolders.reauthorization.store.spec.ts", - " it('passes the exact confirmed target path and reloads root folders', async () => {\n", - "})\n", -) + expect(apiService.reauthorizeLegacyRootFolderRelocationTarget).toHaveBeenCalledWith( + 'relocation-1', + targetPath, + ) + expect(apiService.getRootFolders).toHaveBeenCalledTimes(1) + }) +""" +if block not in text: + raise SystemExit("missing exact legacy relocation store test") +text = text.replace(block, "", 1) +if text.count("RootFolderPathChangeResult") == 1: + text = text.replace("import type { RootFolderPathChangeResult } from '@/types'\n", "", 1) +store_test.write_text(text) setup = Path("fe/src/__tests__/test-setup.ts") text = setup.read_text() From 5e4d976c74c03a965b5ae7522bb9bea06cdbd03e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:03:13 +0000 Subject: [PATCH 425/464] refactor(relocation): remove intermediate target reauthorization --- fe/src/__tests__/RootFoldersSettings.spec.ts | 36 +-- ...ootFolderRelocationReauthorization.spec.ts | 85 ------ .../rootFolders.reauthorization.store.spec.ts | 25 -- fe/src/__tests__/test-setup.ts | 1 - .../settings/RootFoldersSettings.vue | 78 +---- fe/src/services/api.ts | 15 +- fe/src/stores/rootFolders.ts | 12 +- fe/src/types/index.ts | 2 +- .../RootFolderRelocationsController.cs | 35 --- .../Contracts/IRootFolderRelocationService.cs | 5 - .../RootFolderRelocationPublicProjection.cs | 2 - .../Audiobooks/RootFolderRelocation.cs | 36 --- ...FolderRelocationService.Reauthorization.cs | 216 -------------- .../RootFolderRelocationService.Retry.cs | 6 - .../RootFolderObjectIdentityReconciler.cs | 8 - .../RootFolderRelocationStateTests.cs | 49 ---- .../RootFolderRelocationServiceTests.cs | 270 +----------------- 17 files changed, 6 insertions(+), 875 deletions(-) delete mode 100644 fe/src/__tests__/api.rootFolderRelocationReauthorization.spec.ts delete mode 100644 listenarr.infrastructure/Library/Moving/RootFolderRelocationService.Reauthorization.cs diff --git a/fe/src/__tests__/RootFoldersSettings.spec.ts b/fe/src/__tests__/RootFoldersSettings.spec.ts index b76e0a9a3..ec0cdada9 100644 --- a/fe/src/__tests__/RootFoldersSettings.spec.ts +++ b/fe/src/__tests__/RootFoldersSettings.spec.ts @@ -139,41 +139,7 @@ describe('RootFoldersSettings', () => { expect(apiService.reauthorizeRootFolderIdentity).toHaveBeenCalledWith(folder.id, folder.path) }) - it('shows legacy reauthorization separately and confirms the exact target path', async () => { - const legacy = relocation('LegacyUnenrolled') - vi.mocked(apiService.getRootFolders).mockResolvedValue([rootFolder(legacy)]) - vi.mocked(apiService.reauthorizeLegacyRootFolderRelocationTarget).mockResolvedValue({ - ...legacy, - status: 'Running', - targetIdentityEnrollmentState: 'Authorized', - }) - const pinia = createPinia() - setActivePinia(pinia) - const wrapper = mount(RootFoldersSettings, { global: { plugins: [pinia] } }) - await flushPromises() - - const action = wrapper.get('[data-cy="reauthorize-relocation-target"]') - expect(action.text()).toContain('Reauthorize target') - expect(wrapper.findAll('button').some((button) => button.text().trim() === 'Retry')).toBe(false) - - await action.trigger('click') - - const displayedTarget = wrapper.get('[data-testid="reauthorization-target-path"]') - expect(displayedTarget.element.textContent).toBe(targetPath) - expect(displayedTarget.classes()).toContain('reauthorization-target-path') - const confirm = wrapper.get('.modal-delete-button') - expect(confirm.text()).toContain('Reauthorize target') - await confirm.trigger('click') - await flushPromises() - - expect(apiService.reauthorizeLegacyRootFolderRelocationTarget).toHaveBeenCalledWith( - 'relocation-1', - targetPath, - ) - expect(wrapper.emitted('close')).toBeUndefined() - }) - - it('keeps ordinary retry separate for an authorized relocation', async () => { +it('keeps ordinary retry separate for an authorized relocation', async () => { vi.mocked(apiService.getRootFolders).mockResolvedValue([rootFolder(relocation('Authorized'))]) const pinia = createPinia() setActivePinia(pinia) diff --git a/fe/src/__tests__/api.rootFolderRelocationReauthorization.spec.ts b/fe/src/__tests__/api.rootFolderRelocationReauthorization.spec.ts deleted file mode 100644 index fa6230a94..000000000 --- a/fe/src/__tests__/api.rootFolderRelocationReauthorization.spec.ts +++ /dev/null @@ -1,85 +0,0 @@ -/* - * Listenarr - Audiobook Management System - * Copyright (C) 2024-2026 Listenarr Contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published - * by the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - */ -import { afterEach, describe, expect, it, vi } from 'vitest' - -describe('ApiService root-folder reauthorization', () => { - afterEach(() => { - vi.restoreAllMocks() - vi.unstubAllGlobals() - }) - - it('posts the exact confirmed root path to the physical identity endpoint', async () => { - vi.resetModules() - const rootPath = '/srv/Library ' - const fetchMock = vi.fn(() => - Promise.resolve( - new Response( - JSON.stringify({ - id: 3, - name: 'Library', - path: rootPath, - isDefault: true, - }), - { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }, - ), - ), - ) - vi.stubGlobal('fetch', fetchMock) - - const actual = await vi.importActual('@/services/api') - await actual.apiService.reauthorizeRootFolderIdentity(3, rootPath) - - expect(fetchMock).toHaveBeenCalledTimes(1) - const [requestInfo, options] = fetchMock.mock.calls[0] as [RequestInfo, RequestInit] - expect(String(requestInfo)).toContain('/rootfolders/3/reauthorize-identity') - expect(options.method).toBe('POST') - expect(JSON.parse(String(options.body))).toEqual({ expectedCurrentPath: rootPath }) - }) - - it('posts the exact confirmed target path to the dedicated endpoint', async () => { - vi.resetModules() - const targetPath = '/srv/Audiobooks ' - const fetchMock = vi.fn(() => - Promise.resolve( - new Response( - JSON.stringify({ - relocationId: 'relocation-1', - rootFolderId: 3, - currentPath: '/srv/Old', - targetPath, - status: 'Running', - totalJobs: 1, - completedJobs: 0, - targetIdentityEnrollmentState: 'Authorized', - }), - { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }, - ), - ), - ) - vi.stubGlobal('fetch', fetchMock) - - const actual = await vi.importActual('@/services/api') - await actual.apiService.reauthorizeLegacyRootFolderRelocationTarget('relocation-1', targetPath) - - expect(fetchMock).toHaveBeenCalledTimes(1) - const [requestInfo, options] = fetchMock.mock.calls[0] as [RequestInfo, RequestInit] - expect(String(requestInfo)).toContain( - '/rootfolder-relocations/relocation-1/reauthorize-legacy-target', - ) - expect(options.method).toBe('POST') - expect(JSON.parse(String(options.body))).toEqual({ confirmedTargetPath: targetPath }) - }) -}) diff --git a/fe/src/__tests__/rootFolders.reauthorization.store.spec.ts b/fe/src/__tests__/rootFolders.reauthorization.store.spec.ts index ba11a1f8a..e6e8cb945 100644 --- a/fe/src/__tests__/rootFolders.reauthorization.store.spec.ts +++ b/fe/src/__tests__/rootFolders.reauthorization.store.spec.ts @@ -11,7 +11,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { createPinia, setActivePinia } from 'pinia' import { apiService } from '@/services/api' import { useRootFoldersStore } from '@/stores/rootFolders' -import type { RootFolderPathChangeResult } from '@/types' describe('root folder relocation store actions', () => { beforeEach(() => { @@ -218,28 +217,4 @@ describe('root folder relocation store actions', () => { expect(apiService.getRootFolders).toHaveBeenCalledTimes(1) }) - it('passes the exact confirmed target path and reloads root folders', async () => { - const targetPath = '/srv/Audiobooks ' - const result: RootFolderPathChangeResult = { - relocationId: 'relocation-1', - rootFolderId: 3, - currentPath: '/srv/Old', - targetPath, - status: 'Running', - totalJobs: 1, - completedJobs: 0, - targetIdentityEnrollmentState: 'Authorized', - } - vi.mocked(apiService.reauthorizeLegacyRootFolderRelocationTarget).mockResolvedValueOnce(result) - vi.mocked(apiService.getRootFolders).mockResolvedValueOnce([]) - const store = useRootFoldersStore() - - await expect(store.reauthorizeLegacyTarget('relocation-1', targetPath)).resolves.toEqual(result) - - expect(apiService.reauthorizeLegacyRootFolderRelocationTarget).toHaveBeenCalledWith( - 'relocation-1', - targetPath, - ) - expect(apiService.getRootFolders).toHaveBeenCalledTimes(1) - }) }) diff --git a/fe/src/__tests__/test-setup.ts b/fe/src/__tests__/test-setup.ts index 94d5b9887..c16fc7186 100644 --- a/fe/src/__tests__/test-setup.ts +++ b/fe/src/__tests__/test-setup.ts @@ -170,7 +170,6 @@ vi.mock('@/services/api', () => { changeRootFolderPath: vi.fn(async () => ({})), reauthorizeRootFolderIdentity: vi.fn(async () => ({})), retryRootFolderRelocation: vi.fn(async () => ({})), - reauthorizeLegacyRootFolderRelocationTarget: vi.fn(async () => ({})), // add checkVolume to apiService so components that call `apiService.checkVolume` in // unit tests have a sensible default value that matches the real API signature. diff --git a/fe/src/components/settings/RootFoldersSettings.vue b/fe/src/components/settings/RootFoldersSettings.vue index 58ea97fb5..0ee893703 100644 --- a/fe/src/components/settings/RootFoldersSettings.vue +++ b/fe/src/components/settings/RootFoldersSettings.vue @@ -128,15 +128,6 @@ > Retry -

{{ folder.activeRelocation.error }}

@@ -196,27 +187,6 @@ - - - - @@ -239,7 +209,7 @@ import { PhMagnifyingGlass, PhShieldCheck, } from '@phosphor-icons/vue' -import type { RootFolder, RootFolderPathChangeResult } from '@/types' +import type { RootFolder } from '@/types' import { signalRService } from '@/services/signalr' interface Props { @@ -258,10 +228,6 @@ const scanningFolder = ref(null) import { computed } from 'vue' const editingRoot = computed(() => editing.value as RootFolder | undefined) const toast = useToast() -const relocationToReauthorize = ref<{ - relocationId: string - targetPath: string -} | null>(null) const rootToReauthorize = ref<{ id: number name: string @@ -387,48 +353,6 @@ function canRetryRelocation(folder: RootFolder): boolean { ) } -function canReauthorizeLegacyTarget(folder: RootFolder): boolean { - return ( - folder.activeRelocation?.status === 'NeedsAttention' && - folder.activeRelocation.targetIdentityEnrollmentState === 'LegacyUnenrolled' - ) -} - -function confirmLegacyTargetReauthorization(folder: RootFolder) { - const relocation = folder.activeRelocation - if (!relocation?.relocationId || !canReauthorizeLegacyTarget(folder)) { - return - } - - relocationToReauthorize.value = { - relocationId: relocation.relocationId, - targetPath: relocation.targetPath, - } -} - -async function executeLegacyTargetReauthorization() { - const confirmation = relocationToReauthorize.value - if (!confirmation) return - relocationToReauthorize.value = null - try { - const result: RootFolderPathChangeResult = await store.reauthorizeLegacyTarget( - confirmation.relocationId, - confirmation.targetPath, - ) - toast.success( - 'Root relocation', - result.status === 'Completed' - ? 'Target reauthorized and relocation completed' - : 'Target reauthorized and relocation retry started', - ) - } catch (e: unknown) { - toast.error( - 'Reauthorization failed', - (e as Error)?.message || 'Failed to reauthorize relocation target', - ) - } -} - function close() { showForm.value = false } diff --git a/fe/src/services/api.ts b/fe/src/services/api.ts index ac326a1db..97b2856a2 100644 --- a/fe/src/services/api.ts +++ b/fe/src/services/api.ts @@ -950,20 +950,7 @@ class ApiService { ) } - async reauthorizeLegacyRootFolderRelocationTarget( - relocationId: string, - confirmedTargetPath: string, - ): Promise { - return this.request( - `/rootfolder-relocations/${relocationId}/reauthorize-legacy-target`, - { - method: 'POST', - body: JSON.stringify({ confirmedTargetPath }), - }, - ) - } - - async deleteRootFolder(id: number, reassignTo?: number): Promise<{ message?: string }> { +async deleteRootFolder(id: number, reassignTo?: number): Promise<{ message?: string }> { const qs = reassignTo ? `?reassignTo=${reassignTo}` : '' return this.request<{ message?: string }>(`/rootfolders/${id}${qs}`, { method: 'DELETE' }) } diff --git a/fe/src/stores/rootFolders.ts b/fe/src/stores/rootFolders.ts index 03562d674..202db7f29 100644 --- a/fe/src/stores/rootFolders.ts +++ b/fe/src/stores/rootFolders.ts @@ -140,16 +140,7 @@ export const useRootFoldersStore = defineStore('rootFolders', () => { return result } - async function reauthorizeLegacyTarget(relocationId: string, confirmedTargetPath: string) { - const result = await apiService.reauthorizeLegacyRootFolderRelocationTarget( - relocationId, - confirmedTargetPath, - ) - await load() - return result - } - - async function remove(id: number, reassignTo?: number) { +async function remove(id: number, reassignTo?: number) { const r = await apiService.deleteRootFolder(id, reassignTo) await load() return r @@ -164,7 +155,6 @@ export const useRootFoldersStore = defineStore('rootFolders', () => { update, reauthorizeIdentity, retryRelocation, - reauthorizeLegacyTarget, remove, } }) diff --git a/fe/src/types/index.ts b/fe/src/types/index.ts index 36057c0ee..94da731c9 100644 --- a/fe/src/types/index.ts +++ b/fe/src/types/index.ts @@ -308,7 +308,7 @@ export interface RootFolderPathChangeResult { totalJobs: number completedJobs: number error?: string | null - targetIdentityEnrollmentState: 'NotRequired' | 'Authorized' | 'LegacyUnenrolled' | 'Unavailable' + targetIdentityEnrollmentState: 'NotRequired' | 'Authorized' | 'Unavailable' } export interface TranslatePathRequest { diff --git a/listenarr.api/Features/Library/RootFolderRelocationsController.cs b/listenarr.api/Features/Library/RootFolderRelocationsController.cs index 751fbf001..e75793eaf 100644 --- a/listenarr.api/Features/Library/RootFolderRelocationsController.cs +++ b/listenarr.api/Features/Library/RootFolderRelocationsController.cs @@ -8,8 +8,6 @@ namespace Listenarr.Api.Features.Library; public sealed class RootFolderRelocationsController(IRootFolderRelocationService relocationService) : ControllerBase { - public sealed record ReauthorizeLegacyTargetRequest(string ConfirmedTargetPath); - [HttpGet("{id:guid}", Name = "GetRootFolderRelocation")] public async Task Get(Guid id, CancellationToken cancellationToken) { @@ -40,37 +38,4 @@ public async Task Retry(Guid id, CancellationToken cancellationTo } } - [HttpPost("{id:guid}/reauthorize-legacy-target")] - public async Task ReauthorizeLegacyTarget( - Guid id, - [FromBody] ReauthorizeLegacyTargetRequest request, - CancellationToken cancellationToken) - { - try - { - return Ok(RootFolderRelocationPublicProjection.Sanitize( - await relocationService.ReauthorizeLegacyTargetAsync( - id, - request.ConfirmedTargetPath, - cancellationToken))); - } - catch (KeyNotFoundException) - { - return NotFound(new { message = "Root folder relocation not found" }); - } - catch (InvalidOperationException) - { - return Conflict(new - { - message = "The relocation target cannot be reauthorized in its current state." - }); - } - catch (ArgumentException) - { - return BadRequest(new - { - message = "The confirmed relocation target is invalid." - }); - } - } } diff --git a/listenarr.application/Audiobooks/Contracts/IRootFolderRelocationService.cs b/listenarr.application/Audiobooks/Contracts/IRootFolderRelocationService.cs index b4aec0bbe..fa6dc52b9 100644 --- a/listenarr.application/Audiobooks/Contracts/IRootFolderRelocationService.cs +++ b/listenarr.application/Audiobooks/Contracts/IRootFolderRelocationService.cs @@ -47,11 +47,6 @@ Task RetryAsync( Guid relocationId, CancellationToken cancellationToken = default); - Task ReauthorizeLegacyTargetAsync( - Guid relocationId, - string confirmedTargetPath, - CancellationToken cancellationToken = default); - Task OnMoveJobStateChangedAsync( Guid moveJobId, CancellationToken cancellationToken = default); diff --git a/listenarr.application/Audiobooks/Contracts/RootFolderRelocationPublicProjection.cs b/listenarr.application/Audiobooks/Contracts/RootFolderRelocationPublicProjection.cs index 4f1f28138..503f37a0b 100644 --- a/listenarr.application/Audiobooks/Contracts/RootFolderRelocationPublicProjection.cs +++ b/listenarr.application/Audiobooks/Contracts/RootFolderRelocationPublicProjection.cs @@ -30,8 +30,6 @@ public static RootFolderPathChangeResult Sanitize( return enrollmentState switch { - TargetIdentityEnrollmentState.LegacyUnenrolled => - "The relocation target must be reauthorized before the relocation can continue.", TargetIdentityEnrollmentState.Unavailable => "The relocation target identity is unavailable. Review the target and retry.", _ => status switch diff --git a/listenarr.domain/Audiobooks/RootFolderRelocation.cs b/listenarr.domain/Audiobooks/RootFolderRelocation.cs index 6b2acd73e..4a48878b0 100644 --- a/listenarr.domain/Audiobooks/RootFolderRelocation.cs +++ b/listenarr.domain/Audiobooks/RootFolderRelocation.cs @@ -21,7 +21,6 @@ public enum RootFolderRelocationStatus public enum TargetIdentityEnrollmentState { Authorized, - LegacyUnenrolled, Unavailable, NotRequired } @@ -45,41 +44,6 @@ public enum RootFolderRelocationCreatedDirectoryState Removed } -public static class TargetIdentityEnrollment -{ - public static TargetIdentityEnrollmentState Classify( - RootFolderRelocation relocation) - { - ArgumentNullException.ThrowIfNull(relocation); - if (relocation.Status is - RootFolderRelocationStatus.Completed - or RootFolderRelocationStatus.Failed) - { - return TargetIdentityEnrollmentState.NotRequired; - } - - if (relocation.TargetDirectoryObjectIdentityVersion.HasValue - && !string.IsNullOrWhiteSpace( - relocation.TargetDirectoryObjectIdentity) - && string.IsNullOrWhiteSpace( - relocation.TargetDirectoryObjectIdentityUnavailableReason)) - { - return TargetIdentityEnrollmentState.Authorized; - } - - if (relocation.TargetDirectoryObjectIdentityVersion == null - && string.IsNullOrWhiteSpace( - relocation.TargetDirectoryObjectIdentity) - && string.IsNullOrWhiteSpace( - relocation.TargetDirectoryObjectIdentityUnavailableReason)) - { - return TargetIdentityEnrollmentState.LegacyUnenrolled; - } - - return TargetIdentityEnrollmentState.Unavailable; - } -} - public sealed class RootFolderRelocation { [Key] diff --git a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.Reauthorization.cs b/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.Reauthorization.cs deleted file mode 100644 index 6a823ee1f..000000000 --- a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.Reauthorization.cs +++ /dev/null @@ -1,216 +0,0 @@ -using Listenarr.Domain.Common; -using Microsoft.EntityFrameworkCore; - -namespace Listenarr.Infrastructure.Library.Moving; - -public sealed partial class RootFolderRelocationService -{ - internal Action? AfterLegacyTargetAuthorizationCommitForTest - { - get; - set; - } - - public async Task ReauthorizeLegacyTargetAsync( - Guid relocationId, - string confirmedTargetPath, - CancellationToken cancellationToken = default) - { - ArgumentException.ThrowIfNullOrWhiteSpace(confirmedTargetPath); - var result = await _mutationCoordinator.ExecuteExclusiveAsync( - token => ExecuteWithAllAudiobookLocksAsync( - async lockedToken => - { - await ReauthorizeLegacyTargetCoreAsync( - relocationId, - confirmedTargetPath, - lockedToken); - AfterLegacyTargetAuthorizationCommitForTest?.Invoke(); - return await RetryCoreAsync( - relocationId, - CancellationToken.None); - }, - token), - cancellationToken); - await BroadcastAsync(result, cancellationToken); - return result; - } - - private async Task ReauthorizeLegacyTargetCoreAsync( - Guid relocationId, - string confirmedTargetPath, - CancellationToken cancellationToken) - { - await using var db = - await dbContextFactory.CreateDbContextAsync(cancellationToken); - await using var transaction = - await db.Database.BeginTransactionAsync(cancellationToken); - var relocation = await db.RootFolderRelocations - .AsSplitQuery() - .Include(candidate => candidate.MoveJobs) - .ThenInclude(job => job.Entries) - .SingleOrDefaultAsync( - candidate => candidate.Id == relocationId, - cancellationToken) - ?? throw new KeyNotFoundException( - "Root folder relocation not found"); - if (relocation.Status != RootFolderRelocationStatus.NeedsAttention - || relocation.TargetIdentityEnrollmentState - != TargetIdentityEnrollmentState.LegacyUnenrolled) - { - throw new InvalidOperationException( - "Only a legacy-unenrolled relocation needing attention can be reauthorized."); - } - if (!string.Equals( - confirmedTargetPath, - relocation.TargetPath, - StringComparison.Ordinal)) - { - throw new ArgumentException( - "The confirmed target path must exactly match the pending relocation target.", - nameof(confirmedTargetPath)); - } - - var targetPathAvailable = - FileSystemPathIdentity.TryCanonicalizeUnambiguousStoredAbsolutePathForHost( - relocation.TargetPath, - out var canonicalTargetPath, - out var targetPathReason); - var sourcePathAvailable = - FileSystemPathIdentity.TryCanonicalizeUnambiguousStoredAbsolutePathForHost( - relocation.SourcePath, - out var canonicalSourcePath, - out var sourcePathReason); - if (!targetPathAvailable || !sourcePathAvailable) - { - throw new InvalidOperationException( - $"The relocation paths are unavailable for reauthorization: {targetPathReason}{sourcePathReason}"); - } - - var targetResolution = await semanticsResolver.ResolveAsync( - canonicalTargetPath, - relocation.TargetCaseSensitivityMode, - cancellationToken); - if (targetResolution.State != PathIdentityState.Valid) - { - throw new InvalidOperationException( - targetResolution.Reason - ?? "The relocation target filesystem identity is unavailable."); - } - var sourceResolution = await semanticsResolver.ResolveAsync( - canonicalSourcePath, - relocation.SourceCaseSensitivityMode, - cancellationToken); - if (sourceResolution.State != PathIdentityState.Valid) - { - throw new InvalidOperationException( - sourceResolution.Reason - ?? "The relocation source filesystem identity is unavailable."); - } - - using var target = - PinnedDirectoryCreation.OpenPinnedBoundary(canonicalTargetPath); - if (!target.VisiblePathMatches()) - { - throw new InvalidOperationException( - "The relocation target changed while it was being reauthorized."); - } - - ValidateLegacyReauthorizationEvidence( - relocation, - sourceResolution.Semantics, - targetResolution.Semantics); - cancellationToken.ThrowIfCancellationRequested(); - var targetNativeIdentity = target.GetDirectoryObjectIdentity(); - var targetObjectIdentity = new DirectoryObjectIdentityResolution( - ManagedDirectoryIdentity.CurrentVersion, - ManagedDirectoryIdentity.CreateMarkerless(targetNativeIdentity), - null); - if (!target.VisiblePathMatches()) - { - throw new InvalidOperationException( - "The relocation target changed while its physical identity was captured."); - } - - foreach (var job in relocation.MoveJobs) - { - job.Entries.Add( - MoveManifestIdentity.CreateTargetBoundaryAuthorization( - targetObjectIdentity.Version!.Value, - targetObjectIdentity.Value!)); - } - - relocation.TargetDirectoryObjectIdentityVersion = - targetObjectIdentity.Version; - relocation.TargetDirectoryObjectIdentity = targetObjectIdentity.Value; - relocation.TargetDirectoryObjectIdentityUnavailableReason = - targetObjectIdentity.UnavailableReason; - relocation.TargetIdentityEnrollmentState = - TargetIdentityEnrollmentState.Authorized; - relocation.Error = null; - relocation.UpdatedAt = timeProvider.GetUtcNow().UtcDateTime; - await db.SaveChangesAsync(cancellationToken); - cancellationToken.ThrowIfCancellationRequested(); - await transaction.CommitAsync(CancellationToken.None); - } - - private static void ValidateLegacyReauthorizationEvidence( - RootFolderRelocation relocation, - FileSystemPathSemantics sourceSemantics, - FileSystemPathSemantics targetSemantics) - { - foreach (var job in relocation.MoveJobs) - { - if (MoveManifestIdentity.TryGetTargetBoundaryAuthorization( - job.Entries, - out _, - out _)) - { - throw new InvalidOperationException( - "A legacy move job already contains target-boundary authorization evidence and cannot be reauthorized automatically."); - } - - if (string.IsNullOrWhiteSpace(job.SourcePath) - || string.IsNullOrWhiteSpace(job.RequestedPath) - || !job.TryGetSourceIdentity(out var sourceIdentity) - || !job.TryGetTargetIdentity(out var targetIdentity) - || job.Entries.Count == 0 - || job.Entries.All(entry => - entry.EntryType != MoveJobEntryType.File)) - { - throw new InvalidOperationException( - "A persisted move job lacks authoritative endpoint or manifest evidence."); - } - - sourceIdentity.ValidateForPath(job.SourcePath); - targetIdentity.ValidateForPath(job.RequestedPath); - var sourceRelationship = - FileSystemPathIdentity.EvaluateBoundaryConflict( - job.SourcePath, - sourceIdentity.Semantics, - relocation.SourcePath, - sourceSemantics); - if (sourceRelationship is not ( - FileSystemPathBoundaryConflict.Equivalent - or FileSystemPathBoundaryConflict.FirstInsideSecond)) - { - throw new InvalidOperationException( - "A persisted move source is outside the relocation source boundary."); - } - - var targetRelationship = - FileSystemPathIdentity.EvaluateBoundaryConflict( - job.RequestedPath, - targetIdentity.Semantics, - relocation.TargetPath, - targetSemantics); - if (targetRelationship is not ( - FileSystemPathBoundaryConflict.Equivalent - or FileSystemPathBoundaryConflict.FirstInsideSecond)) - { - throw new InvalidOperationException( - "A persisted move target is outside the relocation target boundary."); - } - } - } -} diff --git a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.Retry.cs b/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.Retry.cs index bb6cde87f..1f75e81ed 100644 --- a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.Retry.cs +++ b/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.Retry.cs @@ -84,12 +84,6 @@ private async Task RetryCoreAsync( { throw new InvalidOperationException("Only relocations needing attention can be retried."); } - if (relocation.TargetIdentityEnrollmentState - == TargetIdentityEnrollmentState.LegacyUnenrolled) - { - throw new InvalidOperationException( - "The legacy relocation target must be explicitly reauthorized before retry."); - } if (relocation.TargetIdentityEnrollmentState == TargetIdentityEnrollmentState.Unavailable) { diff --git a/listenarr.infrastructure/Persistence/RootFolderObjectIdentityReconciler.cs b/listenarr.infrastructure/Persistence/RootFolderObjectIdentityReconciler.cs index 0e15c386f..0bc7a8e22 100644 --- a/listenarr.infrastructure/Persistence/RootFolderObjectIdentityReconciler.cs +++ b/listenarr.infrastructure/Persistence/RootFolderObjectIdentityReconciler.cs @@ -101,14 +101,6 @@ private async Task ReconcileCoreAsync(CancellationToken cancellationToken) logger); } - var relocations = await db.RootFolderRelocations - .ToListAsync(cancellationToken); - foreach (var relocation in relocations) - { - relocation.TargetIdentityEnrollmentState = - TargetIdentityEnrollment.Classify(relocation); - } - await db.SaveChangesAsync(cancellationToken); } diff --git a/tests/Features/Domain/Audiobooks/RootFolderRelocationStateTests.cs b/tests/Features/Domain/Audiobooks/RootFolderRelocationStateTests.cs index caef7a5ef..feb5be83d 100644 --- a/tests/Features/Domain/Audiobooks/RootFolderRelocationStateTests.cs +++ b/tests/Features/Domain/Audiobooks/RootFolderRelocationStateTests.cs @@ -80,53 +80,4 @@ public void NewRelocation_HoldsPendingPathWithoutChangingRoot() Assert.Equal("/new-library", relocation.TargetPath); } - [Theory] - [InlineData( - RootFolderRelocationStatus.Pending, - null, - null, - null, - TargetIdentityEnrollmentState.LegacyUnenrolled)] - [InlineData( - RootFolderRelocationStatus.Running, - 1, - "object", - null, - TargetIdentityEnrollmentState.Authorized)] - [InlineData( - RootFolderRelocationStatus.NeedsAttention, - 1, - null, - "unavailable", - TargetIdentityEnrollmentState.Unavailable)] - [InlineData( - RootFolderRelocationStatus.Completed, - 1, - "object", - null, - TargetIdentityEnrollmentState.NotRequired)] - [InlineData( - RootFolderRelocationStatus.Failed, - null, - null, - null, - TargetIdentityEnrollmentState.NotRequired)] - public void TargetIdentityEnrollment_ClassificationIsDeterministic( - RootFolderRelocationStatus status, - int? version, - string? identity, - string? unavailableReason, - TargetIdentityEnrollmentState expected) - { - var relocation = new RootFolderRelocation - { - Status = status, - TargetDirectoryObjectIdentityVersion = version, - TargetDirectoryObjectIdentity = identity, - TargetDirectoryObjectIdentityUnavailableReason = - unavailableReason - }; - - Assert.Equal(expected, TargetIdentityEnrollment.Classify(relocation)); - } } diff --git a/tests/Features/Infrastructure/Library/Moving/RootFolderRelocationServiceTests.cs b/tests/Features/Infrastructure/Library/Moving/RootFolderRelocationServiceTests.cs index ade6cf9f8..644a95672 100644 --- a/tests/Features/Infrastructure/Library/Moving/RootFolderRelocationServiceTests.cs +++ b/tests/Features/Infrastructure/Library/Moving/RootFolderRelocationServiceTests.cs @@ -2091,275 +2091,7 @@ public async Task MetadataOnly_RequestCancelledAfterJournalCommit_CompletesAutho Assert.Empty(await verification.RootFolderRelocations.ToListAsync()); } - [Fact] - public async Task ReauthorizeLegacyTarget_ExistingMoveJob_BindsConfirmedTargetGenerationBeforeRetry() - { - var (rootId, _, _, target) = await SeedRelocationScenarioAsync(); - var service = CreateService(); - var started = await service.StartAsync( - rootId, - BuildRelocationCommand(target)); - Assert.NotNull(started.RelocationId); - - await using (var db = await _factory.CreateDbContextAsync()) - { - var relocation = await db.RootFolderRelocations - .Include(candidate => candidate.MoveJobs) - .ThenInclude(job => job.Entries) - .SingleAsync(); - var job = Assert.Single(relocation.MoveJobs); - var targetAuthorization = Assert.Single( - job.Entries, - MoveManifestIdentity.IsTargetBoundaryAuthorization); - db.MoveJobEntries.Remove(targetAuthorization); - job.Status = MoveJobStatus.NeedsAttention; - job.Error = "Legacy target identity must be reauthorized."; - job.ActiveDeduplicationKey = null; - relocation.Status = RootFolderRelocationStatus.NeedsAttention; - relocation.TargetIdentityEnrollmentState = - TargetIdentityEnrollmentState.LegacyUnenrolled; - relocation.TargetDirectoryObjectIdentityVersion = null; - relocation.TargetDirectoryObjectIdentity = null; - relocation.TargetDirectoryObjectIdentityUnavailableReason = - "Legacy relocation has no enrolled target generation."; - relocation.Error = job.Error; - await db.SaveChangesAsync(); - } - - var result = await service.ReauthorizeLegacyTargetAsync( - started.RelocationId!.Value, - target); - - Assert.Equal(RootFolderRelocationStatus.Running, result.Status); - Assert.Equal( - TargetIdentityEnrollmentState.Authorized, - result.TargetIdentityEnrollmentState); - await using var verification = await _factory.CreateDbContextAsync(); - var retried = await verification.MoveJobs - .Include(job => job.Entries) - .SingleAsync(); - Assert.Equal(MoveJobStatus.Queued, retried.Status); - Assert.True(MoveManifestIdentity.TryGetTargetBoundaryAuthorization( - retried.Entries, - out var authorizationVersion, - out var authorizationDigest)); - var relocationAfter = await verification.RootFolderRelocations.SingleAsync(); - Assert.Equal( - relocationAfter.TargetDirectoryObjectIdentityVersion, - authorizationVersion); - Assert.Equal( - MoveManifestIdentity.ComputeTargetBoundaryAuthorizationDigest( - relocationAfter.TargetDirectoryObjectIdentityVersion!.Value, - relocationAfter.TargetDirectoryObjectIdentity!), - authorizationDigest); - } - - [Fact] - public async Task ReauthorizeLegacyTarget_ContradictoryChildAuthorization_RejectsBeforeTargetEnrollment() - { - var source = Path.Join( - TempRoot, - $"legacy-contradictory-source-{Guid.NewGuid():N}"); - var target = Path.Join( - TempRoot, - $"legacy-contradictory-target-{Guid.NewGuid():N}"); - var sourceBook = Path.Join(source, "Book"); - var targetBook = Path.Join(target, "Book"); - Directory.CreateDirectory(sourceBook); - Directory.CreateDirectory(target); - - var semanticsResolver = new FileSystemSemanticsResolver(); - var sourceResolution = await semanticsResolver.ResolveAsync(source); - var targetResolution = await semanticsResolver.ResolveAsync(target); - Assert.Equal(PathIdentityState.Valid, sourceResolution.State); - Assert.Equal(PathIdentityState.Valid, targetResolution.State); - - Guid relocationId; - await using (var db = await _factory.CreateDbContextAsync()) - { - var root = new RootFolder - { - Name = "Library", - Path = source, - CaseSensitivityMode = FileSystemCaseSensitivityMode.Auto, - ResolvedCaseSensitivity = sourceResolution.Semantics.CaseSensitivity, - PathIdentityState = PathIdentityState.Valid, - PathIdentityKey = FileSystemPathIdentity.CreateKey( - "root", - source, - sourceResolution.Semantics) - }; - var audiobook = new Audiobook - { - Title = "Book", - BasePath = sourceBook - }; - db.RootFolders.Add(root); - db.Audiobooks.Add(audiobook); - await db.SaveChangesAsync(); - - var relocation = new RootFolderRelocation - { - RootFolderId = root.Id, - ActiveRootFolderId = root.Id, - SourcePath = source, - SourceCaseSensitivityMode = FileSystemCaseSensitivityMode.Auto, - TargetPath = target, - TargetCaseSensitivityMode = FileSystemCaseSensitivityMode.Auto, - TargetIdentityEnrollmentState = TargetIdentityEnrollmentState.LegacyUnenrolled, - Mode = RootFolderRelocationMode.Relocate, - Status = RootFolderRelocationStatus.NeedsAttention, - DesiredName = "Library", - DesiredIsDefault = false, - TotalJobs = 1, - Error = "Legacy target identity must be reauthorized." - }; - db.RootFolderRelocations.Add(relocation); - await db.SaveChangesAsync(); - relocationId = relocation.Id; - - var sourceIdentity = PathIdentitySnapshot.FromResolution( - sourceResolution.Semantics, - FileSystemCaseSensitivityMode.Auto, - source, - sourceBook); - var targetIdentity = PathIdentitySnapshot.FromResolution( - targetResolution.Semantics, - FileSystemCaseSensitivityMode.Auto, - target, - targetBook); - var job = new MoveJob - { - AudiobookId = audiobook.Id, - RelocationId = relocation.Id, - SourcePath = sourceBook, - RequestedPath = targetBook, - Status = MoveJobStatus.NeedsAttention, - IdentityKeyVersion = MoveManifestIdentity.Version, - Entries = - [ - new MoveJobEntry - { - RelativePath = "book.m4b", - EntryType = MoveJobEntryType.File, - Length = 1, - Sha256 = new string('A', 64) - }, - MoveManifestIdentity.CreateTargetBoundaryAuthorization( - ManagedDirectoryIdentity.CurrentVersion, - "contradictory-target-generation") - ] - }; - job.SetSourceIdentity(sourceIdentity); - job.SetTargetIdentity(targetIdentity); - db.MoveJobs.Add(job); - await db.SaveChangesAsync(); - } - - var service = CreateService(); - var exception = await Assert.ThrowsAsync(() => - service.ReauthorizeLegacyTargetAsync(relocationId, target)); - - Assert.Contains( - "already contains target-boundary authorization", - exception.Message, - StringComparison.OrdinalIgnoreCase); - Assert.False(File.Exists(Path.Join( - target, - ManagedDirectoryEnrollment.FileName))); - await using var verification = await _factory.CreateDbContextAsync(); - var relocationAfter = await verification.RootFolderRelocations - .SingleAsync(candidate => candidate.Id == relocationId); - Assert.Equal( - TargetIdentityEnrollmentState.LegacyUnenrolled, - relocationAfter.TargetIdentityEnrollmentState); - } - - [Fact] - public async Task ReauthorizeLegacyTarget_RequestCancelledAfterAuthorization_CompletesRetry() - { - var source = Path.Join( - TempRoot, - $"legacy-reauthorize-source-{Guid.NewGuid():N}"); - var target = Path.Join( - TempRoot, - $"legacy-reauthorize-target-{Guid.NewGuid():N}"); - Directory.CreateDirectory(source); - Directory.CreateDirectory(target); - var sourceResolution = await new FileSystemSemanticsResolver() - .ResolveAsync(source); - Assert.Equal(PathIdentityState.Valid, sourceResolution.State); - - Guid relocationId; - await using (var db = await _factory.CreateDbContextAsync()) - { - var root = new RootFolder - { - Name = "Library", - Path = source, - CaseSensitivityMode = FileSystemCaseSensitivityMode.Auto, - ResolvedCaseSensitivity = - sourceResolution.Semantics.CaseSensitivity, - PathIdentityState = PathIdentityState.Valid, - PathIdentityKey = FileSystemPathIdentity.CreateKey( - "root", - source, - sourceResolution.Semantics) - }; - db.RootFolders.Add(root); - await db.SaveChangesAsync(); - var relocation = new RootFolderRelocation - { - RootFolderId = root.Id, - ActiveRootFolderId = root.Id, - SourcePath = source, - SourceCaseSensitivityMode = - FileSystemCaseSensitivityMode.Auto, - TargetPath = target, - TargetCaseSensitivityMode = - FileSystemCaseSensitivityMode.Auto, - TargetIdentityEnrollmentState = - TargetIdentityEnrollmentState.LegacyUnenrolled, - Mode = RootFolderRelocationMode.Relocate, - Status = RootFolderRelocationStatus.NeedsAttention, - DesiredName = "Reauthorized Library", - DesiredIsDefault = false, - TotalJobs = 0, - Error = "Legacy target identity must be reauthorized." - }; - db.RootFolderRelocations.Add(relocation); - await db.SaveChangesAsync(); - relocationId = relocation.Id; - } - - using var cancellation = new CancellationTokenSource(); - var service = CreateService(); - service.AfterLegacyTargetAuthorizationCommitForTest = - cancellation.Cancel; - - var result = await service.ReauthorizeLegacyTargetAsync( - relocationId, - target, - cancellation.Token); - - Assert.Equal(RootFolderRelocationStatus.Completed, result.Status); - Assert.Equal( - TargetIdentityEnrollmentState.NotRequired, - result.TargetIdentityEnrollmentState); - await using var verification = - await _factory.CreateDbContextAsync(); - var rootAfter = await verification.RootFolders.SingleAsync(); - var relocationAfter = await verification.RootFolderRelocations - .SingleAsync(candidate => candidate.Id == relocationId); - Assert.Equal(target, rootAfter.Path); - Assert.Equal("Reauthorized Library", rootAfter.Name); - Assert.Equal( - RootFolderRelocationStatus.Completed, - relocationAfter.Status); - Assert.Null(relocationAfter.ActiveRootFolderId); - } - - [Fact] +[Fact] public async Task MetadataOnly_ExternallyRenamedOwnedTree_DoesNotRequireOldSourcePathForFreshMarkerlessCleanup() { var source = Path.Join( From 76630eb2c286d1cfd886692d9c1ad1df96e2d32d Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Fri, 7 Aug 2026 11:04:02 -0400 Subject: [PATCH 426/464] chore(ci): inventory obsolete root enrollment compatibility --- .github/workflows/pr717-cleanup-analysis.yml | 58 +++----------------- 1 file changed, 8 insertions(+), 50 deletions(-) diff --git a/.github/workflows/pr717-cleanup-analysis.yml b/.github/workflows/pr717-cleanup-analysis.yml index 9a0963709..dc398d2ce 100644 --- a/.github/workflows/pr717-cleanup-analysis.yml +++ b/.github/workflows/pr717-cleanup-analysis.yml @@ -9,62 +9,20 @@ permissions: contents: write jobs: - remove-legacy-relocation: + inventory-root-enrollment: if: github.actor != 'github-actions[bot]' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - - - name: Remove intermediate root relocation compatibility - shell: bash - run: | - set -euo pipefail - python3 .github/scripts/pr717_cleanup.py - python3 .github/scripts/pr717_cleanup_step3_extra.py - - - name: Restore and build - shell: bash - run: | - set -euo pipefail - dotnet restore listenarr.slnx - dotnet build listenarr.slnx --configuration Release --no-restore - - - name: Focused backend tests - shell: bash - run: | - set -euo pipefail - dotnet test tests/Listenarr.Tests.csproj --configuration Release --no-build --filter 'FullyQualifiedName~RootFolderRelocation|FullyQualifiedName~RootFolderObjectIdentityReconciler' - - - name: Focused frontend validation - shell: bash - run: | - set -euo pipefail - cd fe - npm ci - npx vitest run src/__tests__/RootFoldersSettings.spec.ts src/__tests__/rootFolders.reauthorization.store.spec.ts - npm run type-check - - - name: Adversarial review removed surface - shell: bash - run: | - set -euo pipefail - git diff --check - if git grep -n -E 'LegacyUnenrolled|reauthorize-legacy-target|ReauthorizeLegacyTarget|reauthorizeLegacyRootFolderRelocationTarget|reauthorizeLegacyTarget' -- ':!listenarr.infrastructure/Persistence/Migrations/**' ':!.github/workflows/pr717-cleanup-analysis.yml' ':!.github/scripts/pr717_cleanup.py' ':!.github/scripts/pr717_cleanup_step3_extra.py'; then - echo 'Intermediate relocation compatibility remains' >&2 - exit 1 - fi - test ! -e listenarr.infrastructure/Library/Moving/RootFolderRelocationService.Reauthorization.cs - test ! -e fe/src/__tests__/api.rootFolderRelocationReauthorization.spec.ts - git diff --stat - - - name: Commit cleanup + - name: Inventory obsolete root enrollment compatibility shell: bash run: | set -euo pipefail - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add -A ':!.github/workflows/pr717-cleanup-analysis.yml' ':!.github/scripts/pr717_cleanup.py' ':!.github/scripts/pr717_cleanup_step3_extra.py' ':!.github/pr717-cleanup-trigger' - git commit -m 'refactor(relocation): remove intermediate target reauthorization' - git push origin HEAD:bugfix/unix-folder-name-space + echo '=== ManagedDirectoryEnrollment references ===' + git grep -n -F 'ManagedDirectoryEnrollment' -- ':!.github/workflows/pr717-cleanup-analysis.yml' ':!.github/scripts/**' || true + echo '=== marker literal references ===' + git grep -n -F '.listenarr-root-enrollment.json' -- ':!.github/workflows/pr717-cleanup-analysis.yml' ':!.github/scripts/**' || true + echo '=== source file ===' + sed -n '1,240p' listenarr.infrastructure/FileSystem/ManagedDirectoryEnrollment.cs From d38d8259ad103975793d31cbe3d2236dcd617388 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Fri, 7 Aug 2026 11:04:18 -0400 Subject: [PATCH 427/464] chore(ci): trigger root enrollment inventory --- .github/pr717-cleanup-trigger | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/pr717-cleanup-trigger b/.github/pr717-cleanup-trigger index 672dbcb04..a0312decb 100644 --- a/.github/pr717-cleanup-trigger +++ b/.github/pr717-cleanup-trigger @@ -1 +1 @@ -trigger step 3 +trigger step 4 inventory From 968c3b12d906eca5a981b3649ed582a95572fe10 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Fri, 7 Aug 2026 11:09:12 -0400 Subject: [PATCH 428/464] chore(ci): add root enrollment compatibility cleanup --- .github/scripts/pr717_cleanup_step4.py | 347 +++++++++++++++++++++++++ 1 file changed, 347 insertions(+) create mode 100644 .github/scripts/pr717_cleanup_step4.py diff --git a/.github/scripts/pr717_cleanup_step4.py b/.github/scripts/pr717_cleanup_step4.py new file mode 100644 index 000000000..61b0718fb --- /dev/null +++ b/.github/scripts/pr717_cleanup_step4.py @@ -0,0 +1,347 @@ +from pathlib import Path +import re + + +def read(path: str) -> str: + return Path(path).read_text() + + +def write(path: str, text: str) -> None: + Path(path).write_text(text) + + +def replace_once(path: str, old: str, new: str = "") -> None: + text = read(path) + if old not in text: + raise SystemExit(f"missing expected block in {path}: {old[:120]!r}") + write(path, text.replace(old, new, 1)) + + +def find_matching_brace(text: str, brace: int) -> int: + depth = 0 + quote = None + escaped = False + index = brace + while index < len(text): + char = text[index] + if quote: + if escaped: + escaped = False + elif char == "\\": + escaped = True + elif char == quote: + quote = None + else: + if char in ("'", '"'): + quote = char + elif char == "{": + depth += 1 + elif char == "}": + depth -= 1 + if depth == 0: + return index + 1 + index += 1 + raise SystemExit("unbalanced brace") + + +def remove_decl(path: str, needle: str, include_attributes: bool = False) -> None: + text = read(path) + position = text.find(needle) + if position < 0: + raise SystemExit(f"missing declaration {needle!r} in {path}") + start = text.rfind("\n", 0, position) + 1 + if include_attributes: + while start > 0: + previous_end = start - 1 + previous_start = text.rfind("\n", 0, previous_end) + 1 + previous = text[previous_start : previous_end + 1].strip() + if previous.startswith("["): + start = previous_start + continue + break + brace = text.find("{", position) + if brace < 0: + raise SystemExit(f"no brace for {needle!r}") + end = find_matching_brace(text, brace) + while end < len(text) and text[end] in " \t\r\n": + end += 1 + write(path, text[:start] + text[end:]) + + +def remove_false_marker_assertions(path: str) -> None: + text = read(path) + pattern = re.compile( + r"\n\s*Assert\.False\(File\.Exists\(Path\.Join\(\s*[^,\n]+,\s*ManagedDirectoryEnrollment\.FileName\s*\)\)\);", + re.MULTILINE, + ) + write(path, pattern.sub("", text)) + + +# Production: remove the reader/retirer and every fallback that depends on it. +legacy_file = Path("listenarr.infrastructure/FileSystem/ManagedDirectoryEnrollment.cs") +if not legacy_file.exists(): + raise SystemExit("missing ManagedDirectoryEnrollment.cs") +legacy_file.unlink() + +replace_once( + "listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.SourceValidation.cs", + """ if (string.Equals( + entryName, + ManagedDirectoryEnrollment.FileName, + StringComparison.Ordinal) + && IsSourceCleanupBoundary( + source, + persistentManagedRootBoundary, + sourceSemantics) + && FileSystemPathIdentity.AreEquivalent( + Path.GetDirectoryName(entry)!, + source, + sourceSemantics)) + { + var enrollmentAttributes = File.GetAttributes(entry); + if ((enrollmentAttributes & (FileAttributes.Directory | FileAttributes.ReparsePoint)) != 0) + { + throw new MoveNeedsAttentionException( + "The managed-root enrollment artifact changed type or became linked."); + } + + // The root enrollment belongs to the persistent cleanup boundary, + // not to the audiobook. Leave it in place and exclude it from the + // move manifest/companion sweep. + continue; + } + +""", +) +replace_once( + "listenarr.infrastructure/Library/Moving/EfMoveExecutionStore.Helpers.cs", + """ if (!string.Equals( + currentDigest, + expectedDigest, + StringComparison.OrdinalIgnoreCase)) + { + // Last-resort compatibility for jobs created before a configured-root + // identity was available in the database. Read an existing legacy + // marker only; never create one. + var legacy = ManagedDirectoryEnrollment.ResolveExisting( + boundary, + nativeIdentity); + currentDigest = legacy.IsAvailable && legacy.Version == currentVersion + ? MoveManifestIdentity.ComputeTargetBoundaryAuthorizationDigest( + currentVersion, + legacy.Value!) + : currentDigest; + } + +""", +) +replace_once( + "listenarr.infrastructure/Library/Moving/MoveFilesystemArtifactNames.cs", + " || string.Equals(name, ManagedDirectoryEnrollment.FileName, StringComparison.Ordinal)\n", +) +replace_once( + "listenarr.infrastructure/Library/Moving/RootFolderRelocationService.TargetReservationPersistence.cs", + " ManagedDirectoryEnrollment.RetireValidMarker(directory);\n", +) +replace_once( + "listenarr.infrastructure/Persistence/RootFolderObjectIdentityReconciler.cs", + """ TryRetireLegacyRootEnrollmentMarker( + canonicalRootPath, + root, + logger); +""", +) +remove_decl( + "listenarr.infrastructure/Persistence/RootFolderObjectIdentityReconciler.cs", + "private static void TryRetireLegacyRootEnrollmentMarker(", +) + +# Root identity version 1 existed only in intermediate #717 builds. +replace_once( + "listenarr.application/Audiobooks/Contracts/IDirectoryObjectIdentityResolver.cs", + """ Task UpgradeLegacyAsync( + string path, + int legacyVersion, + string legacyValue, + CancellationToken cancellationToken = default); +""", +) +remove_decl( + "listenarr.infrastructure/FileSystem/DirectoryObjectIdentityResolver.cs", + "public Task UpgradeLegacyAsync(", +) +replace_once( + "listenarr.infrastructure/Persistence/RootFolderObjectIdentityReconciler.cs", + """ else if (root.DirectoryObjectIdentityVersion == 1) + { + current = await identityResolver.UpgradeLegacyAsync( + canonicalRootPath, + root.DirectoryObjectIdentityVersion.Value, + root.DirectoryObjectIdentity, + cancellationToken); + } +""", +) + +# Architecture gate becomes a negative production-surface rule. +replace_once( + "tests/Features/Architecture/BackendArchitectureTests.cs", + """ [Fact] + public void RootDirectoryIdentity_DoesNotPublishPermanentFilesystemEnrollment() + { + var legacyEnrollmentSource = File.ReadAllText(Path.Join( + RepositoryRoot, + "listenarr.infrastructure", + "FileSystem", + "ManagedDirectoryEnrollment.cs")); + var resolverSource = File.ReadAllText(Path.Join( + RepositoryRoot, + "listenarr.infrastructure", + "FileSystem", + "DirectoryObjectIdentityResolver.cs")); + + Assert.DoesNotContain( + "PublishNewFileAsync", + legacyEnrollmentSource, + StringComparison.Ordinal); + Assert.DoesNotContain( + "enrollIfMissing", + legacyEnrollmentSource, + StringComparison.Ordinal); + Assert.DoesNotContain( + "ManagedDirectoryEnrollment", + resolverSource, + StringComparison.Ordinal); + } + +""", + """ [Fact] + public void RootDirectoryIdentity_HasNoIntermediateFilesystemEnrollmentCompatibility() + { + Assert.False(File.Exists(Path.Join( + RepositoryRoot, + "listenarr.infrastructure", + "FileSystem", + "ManagedDirectoryEnrollment.cs"))); + + var productionRoots = new[] + { + Path.Join(RepositoryRoot, "listenarr.application"), + Path.Join(RepositoryRoot, "listenarr.domain"), + Path.Join(RepositoryRoot, "listenarr.infrastructure"), + Path.Join(RepositoryRoot, "listenarr.api") + }; + var violations = productionRoots + .SelectMany(root => Directory.EnumerateFiles(root, "*.cs", SearchOption.AllDirectories)) + .Where(file => + { + var source = File.ReadAllText(file); + return source.Contains("ManagedDirectoryEnrollment", StringComparison.Ordinal) + || source.Contains(".listenarr-root-enrollment.json", StringComparison.Ordinal) + || source.Contains("UpgradeLegacyAsync", StringComparison.Ordinal); + }) + .Select(file => Normalize(Path.GetRelativePath(RepositoryRoot, file))) + .ToList(); + + Assert.Empty(violations); + } + +""", +) + +# Tests dedicated to the discarded marker/version transition are removed. +remove_decl( + "tests/Features/Application/Audiobooks/RootFolders/RootFolderServiceTests.cs", + "public async Task ReauthorizeDirectoryIdentity_InvalidLegacyMarker_IsIgnoredAndPreserved()", + include_attributes=True, +) +remove_decl( + "tests/Features/Application/Audiobooks/RootFolders/RootFolderServiceTests.cs", + "public async Task Create_NestedRejectedRoot_DoesNotEnrollCandidateDirectory()", + include_attributes=True, +) +remove_decl( + "tests/Features/Infrastructure/FileSystem/DirectoryObjectIdentityResolverTests.cs", + "public async Task ResolveExistingAsync_ForeignMarkerCannotAuthorizeDifferentNativeGeneration()", + include_attributes=True, +) +remove_decl( + "tests/Features/Infrastructure/FileSystem/DirectoryObjectIdentityResolverTests.cs", + "public async Task UpgradeLegacyAsync_MatchingNativeIdentity_ProducesMarkerlessVersionTwo()", + include_attributes=True, +) +remove_decl( + "tests/Features/Infrastructure/FileSystem/DirectoryObjectIdentityResolverTests.cs", + "public async Task UpgradeLegacyAsync_MismatchedNativeIdentity_FailsClosedWithoutMarker()", + include_attributes=True, +) +# Foreign-syntax test should exercise only final APIs. +file = "tests/Features/Infrastructure/FileSystem/DirectoryObjectIdentityResolverTests.cs" +replace_once( + file, + """ var legacy = await resolver.UpgradeLegacyAsync( + foreignPath, + legacyVersion: 1, + legacyValue: "persisted-foreign-native-identity"); + + foreach (var candidate in new[] { resolution, existing, legacy }) +""", + """ foreach (var candidate in new[] { resolution, existing }) +""", +) +remove_decl( + "tests/Features/Infrastructure/Persistence/RootFolderObjectIdentityReconcilerTests.cs", + "public async Task ReconcileAsync_LegacyVersionTwoIdentityWithoutMarker_RemainsAuthorized()", + include_attributes=True, +) +remove_decl( + "tests/Features/Infrastructure/Persistence/RootFolderObjectIdentityReconcilerTests.cs", + "public async Task ReconcileAsync_MatchingLegacyEnrollmentMarker_RetiresMarkerAndKeepsDatabaseIdentity()", + include_attributes=True, +) +remove_decl( + "tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceTests.cs", + "public async Task MoveContents_SourceAtManagedRoot_DoesNotCreateRootEnrollmentMarker()", + include_attributes=True, +) +replace_once( + "tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceTests.cs", + " || string.Equals(name, ManagedDirectoryEnrollment.FileName, StringComparison.Ordinal)\n", +) + +# Keep useful final-behavior tests, but remove assertions that existed only to prove +# the discarded marker was not written. +for path in ( + "tests/Features/Application/Audiobooks/RootFolders/RootFolderServiceTests.cs", + "tests/Features/Infrastructure/FileSystem/DirectoryObjectIdentityResolverTests.cs", + "tests/Features/Infrastructure/Library/Moving/RootFolderRelocationServiceTests.cs", + "tests/Features/Infrastructure/Persistence/RootFolderObjectIdentityReconcilerTests.cs", +): + remove_false_marker_assertions(path) + +# One relocation test used the literal marker only as a negative side-effect assertion. +relocation_tests = "tests/Features/Infrastructure/Library/Moving/RootFolderRelocationServiceTests.cs" +text = read(relocation_tests) +text = text.replace( + """ var replacementEnrollment = Path.Join( + target, + ".listenarr-root-enrollment.json"); + Assert.False(File.Exists(replacementEnrollment)); + +""", + "", + 1, +) +text = text.replace(" Assert.False(File.Exists(replacementEnrollment));\n", "", 1) +text = text.replace( + "FinalizeCompletedRelocation_ReplacedTargetWithoutEnrollment_DoesNotEnrollReplacement", + "FinalizeCompletedRelocation_ReplacedTargetWithoutAuthorization_DoesNotCommitReplacement", + 1, +) +write(relocation_tests, text) + +# Remaining test-only references should be absent; fail early with a useful list. +for path in Path("tests").rglob("*.cs"): + source = path.read_text() + if "ManagedDirectoryEnrollment" in source or ".listenarr-root-enrollment.json" in source or "UpgradeLegacyAsync" in source: + raise SystemExit(f"stale root-enrollment compatibility remains in {path}") From 6c87bed6fa6417fd99c46b5c9627522375964767 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Fri, 7 Aug 2026 11:09:48 -0400 Subject: [PATCH 429/464] chore(ci): execute root enrollment compatibility cleanup --- .github/workflows/pr717-cleanup-analysis.yml | 57 +++++++++++++++++--- 1 file changed, 49 insertions(+), 8 deletions(-) diff --git a/.github/workflows/pr717-cleanup-analysis.yml b/.github/workflows/pr717-cleanup-analysis.yml index dc398d2ce..70c5ed95d 100644 --- a/.github/workflows/pr717-cleanup-analysis.yml +++ b/.github/workflows/pr717-cleanup-analysis.yml @@ -9,20 +9,61 @@ permissions: contents: write jobs: - inventory-root-enrollment: + remove-root-enrollment-compatibility: if: github.actor != 'github-actions[bot]' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - - name: Inventory obsolete root enrollment compatibility + + - name: Remove obsolete root enrollment compatibility + run: python3 .github/scripts/pr717_cleanup_step4.py + + - name: Static adversarial review + shell: bash + run: | + set -euo pipefail + git diff --check + if git grep -n -E 'ManagedDirectoryEnrollment|\.listenarr-root-enrollment\.json|UpgradeLegacyAsync' -- \ + 'listenarr.application' 'listenarr.domain' 'listenarr.infrastructure' 'listenarr.api' 'tests' \ + ':!.github/**'; then + echo 'Obsolete root enrollment compatibility remains' >&2 + exit 1 + fi + test ! -e listenarr.infrastructure/FileSystem/ManagedDirectoryEnrollment.cs + git diff --stat + + - name: Restore and build + shell: bash + run: | + set -euo pipefail + dotnet restore listenarr.slnx + dotnet build listenarr.slnx --configuration Release --no-restore + + - name: Focused identity and move tests + shell: bash + run: | + set -euo pipefail + dotnet test tests/Listenarr.Tests.csproj --configuration Release --no-build --filter 'FullyQualifiedName~RootFolderServiceTests|FullyQualifiedName~DirectoryObjectIdentityResolverTests|FullyQualifiedName~RootFolderObjectIdentityReconcilerTests|FullyQualifiedName~AudiobookContentMoveServiceTests|FullyQualifiedName~RootFolderRelocationServiceTests|FullyQualifiedName~BackendArchitectureTests' + + - name: Adversarial current-contract review + shell: bash + run: | + set -euo pipefail + git grep -n -F 'CreateMarkerless' -- listenarr.infrastructure tests | head -100 + git grep -n -F 'LibraryDirectoryOwnershipMarker' -- listenarr.infrastructure tests | head -100 + if git grep -n -E 'legacy managed-directory enrollment|legacy root enrollment|root enrollment marker' -- listenarr.application listenarr.domain listenarr.infrastructure listenarr.api tests; then + echo 'Legacy root-enrollment language remains in production/test contracts' >&2 + exit 1 + fi + + - name: Commit cleanup shell: bash run: | set -euo pipefail - echo '=== ManagedDirectoryEnrollment references ===' - git grep -n -F 'ManagedDirectoryEnrollment' -- ':!.github/workflows/pr717-cleanup-analysis.yml' ':!.github/scripts/**' || true - echo '=== marker literal references ===' - git grep -n -F '.listenarr-root-enrollment.json' -- ':!.github/workflows/pr717-cleanup-analysis.yml' ':!.github/scripts/**' || true - echo '=== source file ===' - sed -n '1,240p' listenarr.infrastructure/FileSystem/ManagedDirectoryEnrollment.cs + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add -A ':!.github/workflows/pr717-cleanup-analysis.yml' ':!.github/scripts/**' ':!.github/pr717-cleanup-trigger' + git commit -m 'refactor(identity): remove intermediate root enrollment compatibility' + git push origin HEAD:bugfix/unix-folder-name-space From 31437e3ba6d8b39f598211a1e2aca015c0f52d38 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Fri, 7 Aug 2026 11:10:05 -0400 Subject: [PATCH 430/464] chore(ci): trigger root enrollment cleanup --- .github/pr717-cleanup-trigger | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/pr717-cleanup-trigger b/.github/pr717-cleanup-trigger index a0312decb..582b0f4a5 100644 --- a/.github/pr717-cleanup-trigger +++ b/.github/pr717-cleanup-trigger @@ -1 +1 @@ -trigger step 4 inventory +trigger step 4 cleanup From 8ebda3fabd149ae4607e9fd7dabe54ac94193272 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Fri, 7 Aug 2026 11:13:41 -0400 Subject: [PATCH 431/464] chore(ci): exempt root enrollment negative gate --- .github/workflows/pr717-cleanup-analysis.yml | 22 ++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/.github/workflows/pr717-cleanup-analysis.yml b/.github/workflows/pr717-cleanup-analysis.yml index 70c5ed95d..de38c38a5 100644 --- a/.github/workflows/pr717-cleanup-analysis.yml +++ b/.github/workflows/pr717-cleanup-analysis.yml @@ -18,7 +18,20 @@ jobs: fetch-depth: 0 - name: Remove obsolete root enrollment compatibility - run: python3 .github/scripts/pr717_cleanup_step4.py + shell: bash + run: | + set -euo pipefail + python3 - <<'PY' + from pathlib import Path + path = Path('.github/scripts/pr717_cleanup_step4.py') + text = path.read_text() + old = '''for path in Path("tests").rglob("*.cs"):\n source = path.read_text()\n if "ManagedDirectoryEnrollment" in source or ".listenarr-root-enrollment.json" in source or "UpgradeLegacyAsync" in source:\n raise SystemExit(f"stale root-enrollment compatibility remains in {path}")\n''' + new = '''for path in Path("tests").rglob("*.cs"):\n if path.as_posix() == "tests/Features/Architecture/BackendArchitectureTests.cs":\n continue\n source = path.read_text()\n if "ManagedDirectoryEnrollment" in source or ".listenarr-root-enrollment.json" in source or "UpgradeLegacyAsync" in source:\n raise SystemExit(f"stale root-enrollment compatibility remains in {path}")\n''' + if old not in text: + raise SystemExit('missing step4 self-scan block') + path.write_text(text.replace(old, new, 1)) + PY + python3 .github/scripts/pr717_cleanup_step4.py - name: Static adversarial review shell: bash @@ -27,7 +40,7 @@ jobs: git diff --check if git grep -n -E 'ManagedDirectoryEnrollment|\.listenarr-root-enrollment\.json|UpgradeLegacyAsync' -- \ 'listenarr.application' 'listenarr.domain' 'listenarr.infrastructure' 'listenarr.api' 'tests' \ - ':!.github/**'; then + ':!tests/Features/Architecture/BackendArchitectureTests.cs' ':!.github/**'; then echo 'Obsolete root enrollment compatibility remains' >&2 exit 1 fi @@ -53,8 +66,8 @@ jobs: set -euo pipefail git grep -n -F 'CreateMarkerless' -- listenarr.infrastructure tests | head -100 git grep -n -F 'LibraryDirectoryOwnershipMarker' -- listenarr.infrastructure tests | head -100 - if git grep -n -E 'legacy managed-directory enrollment|legacy root enrollment|root enrollment marker' -- listenarr.application listenarr.domain listenarr.infrastructure listenarr.api tests; then - echo 'Legacy root-enrollment language remains in production/test contracts' >&2 + if git grep -n -E 'legacy managed-directory enrollment|legacy root enrollment|root enrollment marker' -- listenarr.application listenarr.domain listenarr.infrastructure listenarr.api; then + echo 'Legacy root-enrollment language remains in production contracts' >&2 exit 1 fi @@ -64,6 +77,7 @@ jobs: set -euo pipefail git config user.name 'github-actions[bot]' git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git restore .github/scripts/pr717_cleanup_step4.py git add -A ':!.github/workflows/pr717-cleanup-analysis.yml' ':!.github/scripts/**' ':!.github/pr717-cleanup-trigger' git commit -m 'refactor(identity): remove intermediate root enrollment compatibility' git push origin HEAD:bugfix/unix-folder-name-space From 61a26fcf1473cd88decf63cda9e8845e4767ed46 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Fri, 7 Aug 2026 11:14:00 -0400 Subject: [PATCH 432/464] chore(ci): rerun root enrollment cleanup --- .github/pr717-cleanup-trigger | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/pr717-cleanup-trigger b/.github/pr717-cleanup-trigger index 582b0f4a5..d182419ac 100644 --- a/.github/pr717-cleanup-trigger +++ b/.github/pr717-cleanup-trigger @@ -1 +1 @@ -trigger step 4 cleanup +trigger step 4 cleanup retry From 37486d84ceb5509fba1ba003ab655f9471fdca37 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Fri, 7 Aug 2026 11:17:19 -0400 Subject: [PATCH 433/464] chore(ci): fix root enrollment adversarial diagnostics --- .github/workflows/pr717-cleanup-analysis.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pr717-cleanup-analysis.yml b/.github/workflows/pr717-cleanup-analysis.yml index de38c38a5..d07bf89d9 100644 --- a/.github/workflows/pr717-cleanup-analysis.yml +++ b/.github/workflows/pr717-cleanup-analysis.yml @@ -64,8 +64,8 @@ jobs: shell: bash run: | set -euo pipefail - git grep -n -F 'CreateMarkerless' -- listenarr.infrastructure tests | head -100 - git grep -n -F 'LibraryDirectoryOwnershipMarker' -- listenarr.infrastructure tests | head -100 + git grep -n -F 'CreateMarkerless' -- listenarr.infrastructure tests || true + git grep -n -F 'LibraryDirectoryOwnershipMarker' -- listenarr.infrastructure tests || true if git grep -n -E 'legacy managed-directory enrollment|legacy root enrollment|root enrollment marker' -- listenarr.application listenarr.domain listenarr.infrastructure listenarr.api; then echo 'Legacy root-enrollment language remains in production contracts' >&2 exit 1 From c033d6dbe532902e57f6c0f435e80f7bb1de4c8d Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Fri, 7 Aug 2026 11:17:34 -0400 Subject: [PATCH 434/464] chore(ci): rerun root enrollment final gate --- .github/pr717-cleanup-trigger | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/pr717-cleanup-trigger b/.github/pr717-cleanup-trigger index d182419ac..294608651 100644 --- a/.github/pr717-cleanup-trigger +++ b/.github/pr717-cleanup-trigger @@ -1 +1 @@ -trigger step 4 cleanup retry +trigger step 4 final gate From 6cebebfe3d6345dd8579f346a93ba8898345b64e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:19:55 +0000 Subject: [PATCH 435/464] refactor(identity): remove intermediate root enrollment compatibility --- .../IDirectoryObjectIdentityResolver.cs | 5 - .../DirectoryObjectIdentityResolver.cs | 31 +--- .../FileSystem/ManagedDirectoryEnrollment.cs | 168 ------------------ ...bookContentMoveService.SourceValidation.cs | 26 --- .../Moving/EfMoveExecutionStore.Helpers.cs | 18 -- .../Moving/MoveFilesystemArtifactNames.cs | 1 - ...ionService.TargetReservationPersistence.cs | 1 - .../RootFolderObjectIdentityReconciler.cs | 41 ----- .../RootFolders/RootFolderServiceTests.cs | 138 +------------- .../Architecture/BackendArchitectureTests.cs | 43 ++--- .../DirectoryObjectIdentityResolverTests.cs | 84 +-------- .../AudiobookContentMoveServiceTests.cs | 37 +--- .../RootFolderRelocationServiceTests.cs | 11 +- ...RootFolderObjectIdentityReconcilerTests.cs | 107 +---------- 14 files changed, 31 insertions(+), 680 deletions(-) delete mode 100644 listenarr.infrastructure/FileSystem/ManagedDirectoryEnrollment.cs diff --git a/listenarr.application/Audiobooks/Contracts/IDirectoryObjectIdentityResolver.cs b/listenarr.application/Audiobooks/Contracts/IDirectoryObjectIdentityResolver.cs index d894aa20c..dc1c0086d 100644 --- a/listenarr.application/Audiobooks/Contracts/IDirectoryObjectIdentityResolver.cs +++ b/listenarr.application/Audiobooks/Contracts/IDirectoryObjectIdentityResolver.cs @@ -26,9 +26,4 @@ Task ResolveExistingAsync( string expectedValue, CancellationToken cancellationToken = default); - Task UpgradeLegacyAsync( - string path, - int legacyVersion, - string legacyValue, - CancellationToken cancellationToken = default); } diff --git a/listenarr.infrastructure/FileSystem/DirectoryObjectIdentityResolver.cs b/listenarr.infrastructure/FileSystem/DirectoryObjectIdentityResolver.cs index 4b847fcb2..fef1febc2 100644 --- a/listenarr.infrastructure/FileSystem/DirectoryObjectIdentityResolver.cs +++ b/listenarr.infrastructure/FileSystem/DirectoryObjectIdentityResolver.cs @@ -44,36 +44,7 @@ public Task ResolveExistingAsync( "The live directory no longer matches its persisted physical identity.")); } - public Task UpgradeLegacyAsync( - string path, - int legacyVersion, - string legacyValue, - CancellationToken cancellationToken = default) - { - ArgumentException.ThrowIfNullOrWhiteSpace(legacyValue); - if (legacyVersion != 1) - { - return Task.FromResult( - DirectoryObjectIdentityResolution.Unavailable( - $"Directory identity version {legacyVersion} cannot be upgraded automatically.")); - } - - return ResolvePinnedAsync( - path, - cancellationToken, - nativeIdentity => string.Equals( - nativeIdentity, - legacyValue, - StringComparison.Ordinal) - ? new DirectoryObjectIdentityResolution( - ManagedDirectoryIdentity.CurrentVersion, - ManagedDirectoryIdentity.CreateMarkerless(nativeIdentity), - null) - : DirectoryObjectIdentityResolution.Unavailable( - "The live directory no longer matches its legacy physical identity and cannot be upgraded automatically.")); - } - - private Task ResolvePinnedAsync( +private Task ResolvePinnedAsync( string path, CancellationToken cancellationToken, Func resolve) diff --git a/listenarr.infrastructure/FileSystem/ManagedDirectoryEnrollment.cs b/listenarr.infrastructure/FileSystem/ManagedDirectoryEnrollment.cs deleted file mode 100644 index 660906538..000000000 --- a/listenarr.infrastructure/FileSystem/ManagedDirectoryEnrollment.cs +++ /dev/null @@ -1,168 +0,0 @@ -using System.Text.Json; - -namespace Listenarr.Infrastructure.FileSystem; - -// Compatibility reader/retirer for the short-lived marker-backed root identity -// format. New code must never publish this file; root physical identity is persisted -// in SQLite and verified against the pinned OS-native directory generation. -internal static class ManagedDirectoryEnrollment -{ - internal const string FileName = ".listenarr-root-enrollment.json"; - private const int MarkerVersion = 1; - private const long MaximumBytes = 16 * 1024; - private static readonly JsonSerializerOptions JsonOptions = - new(JsonSerializerDefaults.Web); - - internal static DirectoryObjectIdentityResolution ResolveExisting( - PinnedDirectoryCreation.PinnedDirectoryAnchor anchor, - string nativeIdentity) - { - ArgumentNullException.ThrowIfNull(anchor); - ArgumentException.ThrowIfNullOrWhiteSpace(nativeIdentity); - - var existing = TryRead(anchor, nativeIdentity, out var markerMissing); - return existing - ?? DirectoryObjectIdentityResolution.Unavailable( - markerMissing - ? "The legacy managed-directory enrollment marker is missing." - : "The legacy managed-directory enrollment marker is invalid or identifies a different physical directory."); - } - - internal static void RetireValidMarker( - PinnedDirectoryCreation.PinnedDirectoryAnchor anchor) - { - ArgumentNullException.ThrowIfNull(anchor); - var nativeIdentity = anchor.GetDirectoryObjectIdentity(); - var current = TryRead(anchor, nativeIdentity, out var markerMissing); - if (markerMissing) - { - return; - } - if (current == null) - { - throw new InvalidOperationException( - "The legacy managed-directory enrollment marker is invalid and was preserved."); - } - - RetireVerifiedMarker(anchor, nativeIdentity, current.Value!); - } - - internal static bool TryRetireMatchingLegacyMarker( - PinnedDirectoryCreation.PinnedDirectoryAnchor anchor, - int? expectedVersion, - string? expectedValue) - { - ArgumentNullException.ThrowIfNull(anchor); - if (expectedVersion != ManagedDirectoryIdentity.CurrentVersion - || string.IsNullOrWhiteSpace(expectedValue)) - { - return false; - } - - var nativeIdentity = anchor.GetDirectoryObjectIdentity(); - var current = TryRead(anchor, nativeIdentity, out var markerMissing); - if (markerMissing) - { - return false; - } - if (current == null - || current.Version != expectedVersion - || !string.Equals(current.Value, expectedValue, StringComparison.Ordinal)) - { - return false; - } - - RetireVerifiedMarker(anchor, nativeIdentity, expectedValue); - return true; - } - - private static void RetireVerifiedMarker( - PinnedDirectoryCreation.PinnedDirectoryAnchor anchor, - string nativeIdentity, - string expectedValue) - { - using var marker = anchor.OpenExistingFile( - FileName, - requireDeleteAccess: true); - var current = TryRead(anchor, nativeIdentity, out _); - if (current == null - || !string.Equals(current.Value, expectedValue, StringComparison.Ordinal) - || !marker.VisiblePathMatches() - || !anchor.VisiblePathMatches()) - { - throw new InvalidOperationException( - "The legacy managed-directory enrollment marker changed before retirement."); - } - - marker.Delete(); - anchor.FlushDirectoryEntry(); - } - - private static DirectoryObjectIdentityResolution? TryRead( - PinnedDirectoryCreation.PinnedDirectoryAnchor anchor, - string nativeIdentity, - out bool markerMissing) - { - markerMissing = false; - using var marker = anchor.TryOpenExistingFile( - FileName, - requireDeleteAccess: false); - if (marker == null) - { - markerMissing = true; - return null; - } - - try - { - if (!anchor.VisiblePathMatches() || !marker.VisiblePathMatches()) - { - return null; - } - - using var stream = marker.OpenReadStream( - bufferSize: 4096, - asynchronous: false); - if (stream.Length <= 0 || stream.Length > MaximumBytes) - { - return null; - } - - var payload = JsonSerializer.Deserialize( - stream, - JsonOptions); - if (payload == null - || payload.Version != MarkerVersion - || !Guid.TryParseExact(payload.Token, "N", out _) - || string.IsNullOrWhiteSpace(payload.NativeIdentity) - || !string.Equals( - payload.NativeIdentity, - nativeIdentity, - StringComparison.Ordinal) - || !anchor.VisiblePathMatches() - || !marker.VisiblePathMatches()) - { - return null; - } - - return new DirectoryObjectIdentityResolution( - ManagedDirectoryIdentity.CurrentVersion, - ManagedDirectoryIdentity.Create( - payload.Token, - nativeIdentity), - null); - } - catch (Exception exception) when (exception is - IOException or UnauthorizedAccessException or JsonException - or NotSupportedException or InvalidOperationException) - { - return null; - } - } - - private sealed record EnrollmentPayload( - int Version, - string Token, - string NativeIdentity, - DateTimeOffset CreatedAtUtc); -} diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.SourceValidation.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.SourceValidation.cs index c6db0c69d..0eedcec76 100644 --- a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.SourceValidation.cs +++ b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.SourceValidation.cs @@ -88,32 +88,6 @@ private static IReadOnlyList ValidateSourceTreeForMove( continue; } - if (string.Equals( - entryName, - ManagedDirectoryEnrollment.FileName, - StringComparison.Ordinal) - && IsSourceCleanupBoundary( - source, - persistentManagedRootBoundary, - sourceSemantics) - && FileSystemPathIdentity.AreEquivalent( - Path.GetDirectoryName(entry)!, - source, - sourceSemantics)) - { - var enrollmentAttributes = File.GetAttributes(entry); - if ((enrollmentAttributes & (FileAttributes.Directory | FileAttributes.ReparsePoint)) != 0) - { - throw new MoveNeedsAttentionException( - "The managed-root enrollment artifact changed type or became linked."); - } - - // The root enrollment belongs to the persistent cleanup boundary, - // not to the audiobook. Leave it in place and exclude it from the - // move manifest/companion sweep. - continue; - } - throw new MoveNeedsAttentionException( $"Move source contains a reserved Listenarr recovery artifact that must be resolved before moving: {Path.GetRelativePath(source, entry)}"); } diff --git a/listenarr.infrastructure/Library/Moving/EfMoveExecutionStore.Helpers.cs b/listenarr.infrastructure/Library/Moving/EfMoveExecutionStore.Helpers.cs index 76854f5d4..885d28d93 100644 --- a/listenarr.infrastructure/Library/Moving/EfMoveExecutionStore.Helpers.cs +++ b/listenarr.infrastructure/Library/Moving/EfMoveExecutionStore.Helpers.cs @@ -172,24 +172,6 @@ private static async Task EnsureTargetBoundaryGenerationAuthorizedAsync( cancellationToken) ?? currentDigest; } - if (!string.Equals( - currentDigest, - expectedDigest, - StringComparison.OrdinalIgnoreCase)) - { - // Last-resort compatibility for jobs created before a configured-root - // identity was available in the database. Read an existing legacy - // marker only; never create one. - var legacy = ManagedDirectoryEnrollment.ResolveExisting( - boundary, - nativeIdentity); - currentDigest = legacy.IsAvailable && legacy.Version == currentVersion - ? MoveManifestIdentity.ComputeTargetBoundaryAuthorizationDigest( - currentVersion, - legacy.Value!) - : currentDigest; - } - if (!string.Equals( currentDigest, expectedDigest, diff --git a/listenarr.infrastructure/Library/Moving/MoveFilesystemArtifactNames.cs b/listenarr.infrastructure/Library/Moving/MoveFilesystemArtifactNames.cs index c78e30e05..f6c3f6af9 100644 --- a/listenarr.infrastructure/Library/Moving/MoveFilesystemArtifactNames.cs +++ b/listenarr.infrastructure/Library/Moving/MoveFilesystemArtifactNames.cs @@ -9,7 +9,6 @@ public static bool IsReserved(string name) => || string.Equals(name, ".listenarr-temp-owner.json", StringComparison.Ordinal) || string.Equals(name, ".listenarr-quarantine-owner.json", StringComparison.Ordinal) || string.Equals(name, LibraryDirectoryOwnershipMarker.FileName, StringComparison.Ordinal) - || string.Equals(name, ManagedDirectoryEnrollment.FileName, StringComparison.Ordinal) || name.StartsWith(".listenarr-directory-owner-", StringComparison.Ordinal) && name.EndsWith(".json", StringComparison.Ordinal) || name.Contains(".listenarr-", StringComparison.Ordinal) diff --git a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.TargetReservationPersistence.cs b/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.TargetReservationPersistence.cs index 6f00665ae..2c621019c 100644 --- a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.TargetReservationPersistence.cs +++ b/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.TargetReservationPersistence.cs @@ -251,7 +251,6 @@ private void RetireLegacyReservationMarkers( relocationId, reservation, parent); - ManagedDirectoryEnrollment.RetireValidMarker(directory); } private static void ValidateReservationDirectoryIdentity( diff --git a/listenarr.infrastructure/Persistence/RootFolderObjectIdentityReconciler.cs b/listenarr.infrastructure/Persistence/RootFolderObjectIdentityReconciler.cs index 0bc7a8e22..267fabdb8 100644 --- a/listenarr.infrastructure/Persistence/RootFolderObjectIdentityReconciler.cs +++ b/listenarr.infrastructure/Persistence/RootFolderObjectIdentityReconciler.cs @@ -43,14 +43,6 @@ private async Task ReconcileCoreAsync(CancellationToken cancellationToken) canonicalRootPath, cancellationToken); } - else if (root.DirectoryObjectIdentityVersion == 1) - { - current = await identityResolver.UpgradeLegacyAsync( - canonicalRootPath, - root.DirectoryObjectIdentityVersion.Value, - root.DirectoryObjectIdentity, - cancellationToken); - } else if (root.DirectoryObjectIdentityVersion == ManagedDirectoryIdentity.CurrentVersion) { @@ -95,42 +87,9 @@ private async Task ReconcileCoreAsync(CancellationToken cancellationToken) root.DirectoryObjectIdentityVersion = current.Version; root.DirectoryObjectIdentity = current.Value; root.DirectoryObjectIdentityUnavailableReason = null; - TryRetireLegacyRootEnrollmentMarker( - canonicalRootPath, - root, - logger); } await db.SaveChangesAsync(cancellationToken); } - private static void TryRetireLegacyRootEnrollmentMarker( - string rootPath, - RootFolder root, - ILogger logger) - { - try - { - using var anchor = PinnedDirectoryCreation.OpenPinnedBoundary(rootPath); - if (ManagedDirectoryEnrollment.TryRetireMatchingLegacyMarker( - anchor, - root.DirectoryObjectIdentityVersion, - root.DirectoryObjectIdentity)) - { - logger.LogInformation( - "Retired obsolete filesystem enrollment marker for root folder {RootFolderId}; physical identity is now database-only.", - root.Id); - } - } - catch (Exception exception) when (exception is - IOException or UnauthorizedAccessException - or InvalidOperationException or NotSupportedException - or System.ComponentModel.Win32Exception) - { - logger.LogWarning( - exception, - "Could not retire obsolete filesystem enrollment marker for root folder {RootFolderId}; the marker is not used for authorization and was preserved.", - root.Id); - } - } } diff --git a/tests/Features/Application/Audiobooks/RootFolders/RootFolderServiceTests.cs b/tests/Features/Application/Audiobooks/RootFolders/RootFolderServiceTests.cs index 54df976ee..e251a58c4 100644 --- a/tests/Features/Application/Audiobooks/RootFolders/RootFolderServiceTests.cs +++ b/tests/Features/Application/Audiobooks/RootFolders/RootFolderServiceTests.cs @@ -160,10 +160,6 @@ await Assert.ThrowsAsync(() => Name = "Library", Path = directory })); - - Assert.False(File.Exists(Path.Join( - directory, - ManagedDirectoryEnrollment.FileName))); } [Fact] @@ -179,9 +175,6 @@ public async Task ReauthorizeDirectoryIdentity_MissingLegacyMarker_UsesDatabaseO var identityResolver = new DirectoryObjectIdentityResolver(); var originalIdentity = await identityResolver.ResolveAsync(directory); Assert.True(originalIdentity.IsAvailable, originalIdentity.UnavailableReason); - Assert.False(File.Exists(Path.Join( - directory, - ManagedDirectoryEnrollment.FileName))); var semantics = FileSystemPathSemantics.CurrentHostDefault; var root = new RootFolder { @@ -203,8 +196,6 @@ public async Task ReauthorizeDirectoryIdentity_MissingLegacyMarker_UsesDatabaseO var updated = await service.ReauthorizeDirectoryIdentityAsync( root.Id, directory); - - Assert.False(File.Exists(Path.Join(directory, ManagedDirectoryEnrollment.FileName))); Assert.Equal(ManagedDirectoryIdentity.CurrentVersion, updated.DirectoryObjectIdentityVersion); Assert.Equal(originalIdentity.Value, updated.DirectoryObjectIdentity); Assert.Null(updated.DirectoryObjectIdentityUnavailableReason); @@ -260,10 +251,6 @@ public async Task ReauthorizeDirectoryIdentity_PersistenceFailure_DoesNotWriteFi await Assert.ThrowsAsync(() => service.ReauthorizeDirectoryIdentityAsync(7, directory)); - - Assert.False(File.Exists(Path.Join( - directory, - ManagedDirectoryEnrollment.FileName))); } [Fact] @@ -341,9 +328,6 @@ await Assert.ThrowsAsync(() => service.ReauthorizeDirectoryIdentityAsync(8, directory)); Assert.NotNull(durableRoot); - Assert.False(File.Exists(Path.Join( - directory, - ManagedDirectoryEnrollment.FileName))); var existing = await identityResolver.ResolveExistingAsync( directory, durableRoot!.DirectoryObjectIdentityVersion!.Value, @@ -380,52 +364,9 @@ await Assert.ThrowsAsync(() => service.ReauthorizeDirectoryIdentityAsync( root.Id, FileUtils.GetAbsolutePath("different-root"))); - - Assert.False(File.Exists(Path.Join(directory, ManagedDirectoryEnrollment.FileName))); - } - - [Fact] - public async Task ReauthorizeDirectoryIdentity_InvalidLegacyMarker_IsIgnoredAndPreserved() - { - var directory = CreateTempDirectory("root-identity-reauthorize-invalid"); - var markerPath = Path.Join(directory, ManagedDirectoryEnrollment.FileName); - await File.WriteAllTextAsync(markerPath, "{ invalid"); - var originalMarker = await File.ReadAllTextAsync(markerPath); - var options = new DbContextOptionsBuilder() - .UseInMemoryDatabase(Guid.NewGuid().ToString()) - .Options; - var repository = new EfRootFolderRepository( - new TestDbFactory(options), - Mock.Of>()); - var semantics = FileSystemPathSemantics.CurrentHostDefault; - var root = new RootFolder - { - Name = "Library", - Path = directory, - ResolvedCaseSensitivity = semantics.CaseSensitivity, - PathIdentityState = PathIdentityState.Valid, - PathIdentityKey = FileSystemPathIdentity.CreateKey("root", directory, semantics), - DirectoryObjectIdentityVersion = ManagedDirectoryIdentity.CurrentVersion, - DirectoryObjectIdentity = "listenarr-directory-v2:00000000000000000000000000000000:" - + new string('0', 64) - }; - await repository.AddAsync(root); - var service = new RootFolderService( - repository, - null, - directoryObjectIdentityResolver: new DirectoryObjectIdentityResolver()); - - var updated = await service.ReauthorizeDirectoryIdentityAsync( - root.Id, - directory); - - Assert.Equal(originalMarker, await File.ReadAllTextAsync(markerPath)); - Assert.Equal(ManagedDirectoryIdentity.CurrentVersion, updated.DirectoryObjectIdentityVersion); - Assert.NotEqual(root.DirectoryObjectIdentity, updated.DirectoryObjectIdentity); - Assert.Null(updated.DirectoryObjectIdentityUnavailableReason); } - [Fact] +[Fact] public async Task ReauthorizeDirectoryIdentity_ActiveMoveTouchingRoot_IsBlockedBeforeEnrollment() { var directory = CreateTempDirectory("root-identity-reauthorize-active-move"); @@ -465,8 +406,6 @@ public async Task ReauthorizeDirectoryIdentity_ActiveMoveTouchingRoot_IsBlockedB await Assert.ThrowsAsync(() => service.ReauthorizeDirectoryIdentityAsync(root.Id, directory)); - - Assert.False(File.Exists(Path.Join(directory, ManagedDirectoryEnrollment.FileName))); } [Fact] @@ -770,80 +709,7 @@ public async Task Create_Throws_WhenNestedInsideExistingRoot() Assert.Contains("nested", exception.Message, StringComparison.OrdinalIgnoreCase); } - [Fact] - public async Task Create_NestedRejectedRoot_DoesNotEnrollCandidateDirectory() - { - var tempRoot = Path.Join( - Path.GetTempPath(), - "listenarr-root-create-rejected-" + Guid.NewGuid().ToString("N")); - var nestedPath = Path.Join(tempRoot, "Nested"); - Directory.CreateDirectory(nestedPath); - try - { - var options = new DbContextOptionsBuilder() - .UseInMemoryDatabase(Guid.NewGuid().ToString()) - .Options; - await using (var db = new ListenArrDbContext(options)) - { - db.RootFolders.Add(new RootFolder - { - Name = "Existing", - Path = tempRoot, - ResolvedCaseSensitivity = - FileSystemPathSemantics.CurrentHostDefault.CaseSensitivity, - PathIdentityState = PathIdentityState.Valid - }); - await db.SaveChangesAsync(); - } - - var repository = new EfRootFolderRepository( - new TestDbFactory(options), - Mock.Of>()); - var relocation = new Mock(); - relocation.Setup(service => service.IsBoundaryProtectedAsync( - It.IsAny(), - It.IsAny(), - It.IsAny())) - .ReturnsAsync(false); - var service = new AppRootFolderService( - repository, - null, - new FileSystemSemanticsResolver(), - Mock.Of(), - relocation.Object, - new FilesystemMutationCoordinator(), - new AudiobookOperationCoordinator(), - new DirectoryObjectIdentityResolver()); - var enrollmentPath = Path.Join( - nestedPath, - ManagedDirectoryEnrollment.FileName); - Assert.False(File.Exists(enrollmentPath)); - - await Assert.ThrowsAsync(() => - service.CreateAsync(new RootFolder - { - Name = "Nested", - Path = nestedPath - })); - - Assert.False(File.Exists(enrollmentPath)); - } - finally - { - try - { - Directory.Delete(tempRoot, recursive: true); - } - catch (IOException) - { - } - catch (UnauthorizedAccessException) - { - } - } - } - - [LinuxFact] +[LinuxFact] public async Task Create_InsensitiveRequestedRootRejectsCaseVariantNestedExistingRoot() { diff --git a/tests/Features/Architecture/BackendArchitectureTests.cs b/tests/Features/Architecture/BackendArchitectureTests.cs index 7e08adc48..b82923b6a 100644 --- a/tests/Features/Architecture/BackendArchitectureTests.cs +++ b/tests/Features/Architecture/BackendArchitectureTests.cs @@ -850,31 +850,34 @@ public void LegacyDirectoryMover_IsNotConnectedToProductionWorkflows() } [Fact] - public void RootDirectoryIdentity_DoesNotPublishPermanentFilesystemEnrollment() + public void RootDirectoryIdentity_HasNoIntermediateFilesystemEnrollmentCompatibility() { - var legacyEnrollmentSource = File.ReadAllText(Path.Join( + Assert.False(File.Exists(Path.Join( RepositoryRoot, "listenarr.infrastructure", "FileSystem", - "ManagedDirectoryEnrollment.cs")); - var resolverSource = File.ReadAllText(Path.Join( - RepositoryRoot, - "listenarr.infrastructure", - "FileSystem", - "DirectoryObjectIdentityResolver.cs")); + "ManagedDirectoryEnrollment.cs"))); - Assert.DoesNotContain( - "PublishNewFileAsync", - legacyEnrollmentSource, - StringComparison.Ordinal); - Assert.DoesNotContain( - "enrollIfMissing", - legacyEnrollmentSource, - StringComparison.Ordinal); - Assert.DoesNotContain( - "ManagedDirectoryEnrollment", - resolverSource, - StringComparison.Ordinal); + var productionRoots = new[] + { + Path.Join(RepositoryRoot, "listenarr.application"), + Path.Join(RepositoryRoot, "listenarr.domain"), + Path.Join(RepositoryRoot, "listenarr.infrastructure"), + Path.Join(RepositoryRoot, "listenarr.api") + }; + var violations = productionRoots + .SelectMany(root => Directory.EnumerateFiles(root, "*.cs", SearchOption.AllDirectories)) + .Where(file => + { + var source = File.ReadAllText(file); + return source.Contains("ManagedDirectoryEnrollment", StringComparison.Ordinal) + || source.Contains(".listenarr-root-enrollment.json", StringComparison.Ordinal) + || source.Contains("UpgradeLegacyAsync", StringComparison.Ordinal); + }) + .Select(file => Normalize(Path.GetRelativePath(RepositoryRoot, file))) + .ToList(); + + Assert.Empty(violations); } [Fact] diff --git a/tests/Features/Infrastructure/FileSystem/DirectoryObjectIdentityResolverTests.cs b/tests/Features/Infrastructure/FileSystem/DirectoryObjectIdentityResolverTests.cs index 8f83a06c2..80e628a8f 100644 --- a/tests/Features/Infrastructure/FileSystem/DirectoryObjectIdentityResolverTests.cs +++ b/tests/Features/Infrastructure/FileSystem/DirectoryObjectIdentityResolverTests.cs @@ -18,9 +18,6 @@ public async Task ResolveAsync_IsStableWithoutFilesystemMarker() Assert.True(first.IsAvailable, first.UnavailableReason); Assert.Equal(ManagedDirectoryIdentity.CurrentVersion, first.Version); Assert.Equal(first, second); - Assert.False(File.Exists(Path.Join( - directory, - ManagedDirectoryEnrollment.FileName))); } [Fact] @@ -41,9 +38,6 @@ public async Task ResolveExistingAsync_LegacyVersionTwoValue_ValidatesFromNative Assert.True(existing.IsAvailable, existing.UnavailableReason); Assert.Equal(legacyPersisted, existing.Value); - Assert.False(File.Exists(Path.Join( - directory, - ManagedDirectoryEnrollment.FileName))); } [Fact] @@ -69,73 +63,7 @@ public async Task ResolveExistingAsync_DifferentNativeGeneration_IsUnavailable() StringComparison.OrdinalIgnoreCase); } - [Fact] - public async Task ResolveExistingAsync_ForeignMarkerCannotAuthorizeDifferentNativeGeneration() - { - var directory = FileService.GetTempDirectory("directory-object-identity-foreign-marker"); - var resolver = new DirectoryObjectIdentityResolver( - nativeIdentityResolver: static _ => "native-current"); - var expected = ManagedDirectoryIdentity.Create( - Guid.NewGuid().ToString("N"), - "native-original"); - await File.WriteAllTextAsync( - Path.Join(directory, ManagedDirectoryEnrollment.FileName), - "{\"version\":1,\"token\":\"00000000000000000000000000000000\"}"); - - var existing = await resolver.ResolveExistingAsync( - directory, - ManagedDirectoryIdentity.CurrentVersion, - expected); - - Assert.False(existing.IsAvailable); - Assert.True(File.Exists(Path.Join( - directory, - ManagedDirectoryEnrollment.FileName))); - } - - [Fact] - public async Task UpgradeLegacyAsync_MatchingNativeIdentity_ProducesMarkerlessVersionTwo() - { - var directory = FileService.GetTempDirectory("directory-object-identity-upgrade"); - var resolver = new DirectoryObjectIdentityResolver( - nativeIdentityResolver: static _ => "legacy-native"); - - var upgraded = await resolver.UpgradeLegacyAsync( - directory, - legacyVersion: 1, - legacyValue: "legacy-native"); - var existing = await resolver.ResolveExistingAsync( - directory, - upgraded.Version!.Value, - upgraded.Value!); - - Assert.True(upgraded.IsAvailable, upgraded.UnavailableReason); - Assert.Equal(ManagedDirectoryIdentity.CurrentVersion, upgraded.Version); - Assert.Equal(upgraded, existing); - Assert.False(File.Exists(Path.Join( - directory, - ManagedDirectoryEnrollment.FileName))); - } - - [Fact] - public async Task UpgradeLegacyAsync_MismatchedNativeIdentity_FailsClosedWithoutMarker() - { - var directory = FileService.GetTempDirectory("directory-object-identity-upgrade-mismatch"); - var resolver = new DirectoryObjectIdentityResolver( - nativeIdentityResolver: static _ => "current-native"); - - var upgraded = await resolver.UpgradeLegacyAsync( - directory, - legacyVersion: 1, - legacyValue: "different-native"); - - Assert.False(upgraded.IsAvailable); - Assert.False(File.Exists(Path.Join( - directory, - ManagedDirectoryEnrollment.FileName))); - } - - [Fact] +[Fact] public async Task ResolveAsync_ForeignPersistedSyntax_FailsClosedBeforeNativeProbeOrMarkerWrite() { var directory = FileService.GetTempDirectory("directory-object-identity-foreign-syntax"); @@ -160,12 +88,7 @@ public async Task ResolveAsync_ForeignPersistedSyntax_FailsClosedBeforeNativePro foreignPath, ManagedDirectoryIdentity.CurrentVersion, expected); - var legacy = await resolver.UpgradeLegacyAsync( - foreignPath, - legacyVersion: 1, - legacyValue: "persisted-foreign-native-identity"); - - foreach (var candidate in new[] { resolution, existing, legacy }) + foreach (var candidate in new[] { resolution, existing }) { Assert.False(candidate.IsAvailable); Assert.Contains( @@ -174,9 +97,6 @@ public async Task ResolveAsync_ForeignPersistedSyntax_FailsClosedBeforeNativePro StringComparison.Ordinal); } Assert.Equal(0, nativeProbeCount); - Assert.False(File.Exists(Path.Join( - directory, - ManagedDirectoryEnrollment.FileName))); } [Fact] diff --git a/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceTests.cs b/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceTests.cs index fe4e8f780..2b6a076a2 100644 --- a/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceTests.cs +++ b/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceTests.cs @@ -646,41 +646,7 @@ public async Task FinalizeMove_SourceEqualsCleanupBoundary_PreservesBoundaryDire Assert.True(File.Exists(Path.Join(target, "book.m4b"))); } - [Fact] - public async Task MoveContents_SourceAtManagedRoot_DoesNotCreateRootEnrollmentMarker() - { - var source = FileService.GetTempDirectory("content-move-managed-root-source"); - await FileService.GetFileAsync(source, "book.m4b", "audio"); - var identity = await new DirectoryObjectIdentityResolver().ResolveAsync(source); - Assert.True(identity.IsAvailable, identity.UnavailableReason); - var enrollmentMarker = Path.Join(source, ManagedDirectoryEnrollment.FileName); - Assert.False(File.Exists(enrollmentMarker)); - var target = Path.Join( - FileService.GetTempPath(), - $"content-move-managed-root-target-{Guid.NewGuid():N}"); - - var service = _provider.GetRequiredService(); - var request = await CreateLeasedMoveRequestAsync( - source, - target, - sourceCleanupBoundary: source); - var result = await service.MoveContentsAsync(request, CancellationToken.None); - - Assert.False(File.Exists(enrollmentMarker)); - Assert.False(File.Exists(Path.Join(target, ManagedDirectoryEnrollment.FileName))); - Assert.True(File.Exists(Path.Join(target, "book.m4b"))); - - await service.FinalizeMoveAsync(request, result, CancellationToken.None); - await service.CleanupCompletedMoveArtifactsAsync( - request, - result, - CancellationToken.None); - - Assert.False(File.Exists(enrollmentMarker)); - Assert.False(File.Exists(Path.Join(target, ManagedDirectoryEnrollment.FileName))); - } - - [Fact] +[Fact] public async Task FinalizeMove_ExistingEmptyTarget_PrunesSourceParentAfterNestedQuarantineCleanup() { var sourceRoot = FileService.GetTempDirectory("content-move-existing-target-root"); @@ -3540,7 +3506,6 @@ private static bool IsTestReservedMoveArtifact(string name) => || string.Equals(name, ".listenarr-temp-owner.json", StringComparison.Ordinal) || string.Equals(name, ".listenarr-quarantine-owner.json", StringComparison.Ordinal) || string.Equals(name, LibraryDirectoryOwnershipMarker.FileName, StringComparison.Ordinal) - || string.Equals(name, ManagedDirectoryEnrollment.FileName, StringComparison.Ordinal) || name.StartsWith(".listenarr-directory-owner-", StringComparison.Ordinal) && name.EndsWith(".json", StringComparison.Ordinal) || name.Contains(".listenarr-", StringComparison.Ordinal) diff --git a/tests/Features/Infrastructure/Library/Moving/RootFolderRelocationServiceTests.cs b/tests/Features/Infrastructure/Library/Moving/RootFolderRelocationServiceTests.cs index 644a95672..a1160217e 100644 --- a/tests/Features/Infrastructure/Library/Moving/RootFolderRelocationServiceTests.cs +++ b/tests/Features/Infrastructure/Library/Moving/RootFolderRelocationServiceTests.cs @@ -73,9 +73,6 @@ public async Task StartRelocation_ExpectedSourceChanged_RejectsBeforeCreatingSag Assert.Empty(verification.RootFolderRelocations); Assert.Empty(verification.MoveJobs); Assert.Equal(source, (await verification.RootFolders.SingleAsync()).Path); - Assert.False(File.Exists(Path.Join( - target, - ManagedDirectoryEnrollment.FileName))); } [Fact] @@ -3821,7 +3818,7 @@ await AddTrackedFileAsync( } [Fact] - public async Task FinalizeCompletedRelocation_ReplacedTargetWithoutEnrollment_DoesNotEnrollReplacement() + public async Task FinalizeCompletedRelocation_ReplacedTargetWithoutAuthorization_DoesNotCommitReplacement() { var (rootId, _, source, target) = await SeedRelocationScenarioAsync(); var service = CreateService(); @@ -3841,11 +3838,6 @@ public async Task FinalizeCompletedRelocation_ReplacedTargetWithoutEnrollment_Do var displacedTarget = target + "-displaced"; Directory.Move(target, displacedTarget); Directory.CreateDirectory(target); - var replacementEnrollment = Path.Join( - target, - ".listenarr-root-enrollment.json"); - Assert.False(File.Exists(replacementEnrollment)); - await service.OnMoveJobStateChangedAsync(jobId); await using var verification = await _factory.CreateDbContextAsync(); @@ -3854,7 +3846,6 @@ public async Task FinalizeCompletedRelocation_ReplacedTargetWithoutEnrollment_Do .SingleAsync(relocation => relocation.Id == started.RelocationId); Assert.Equal(source, rootAfter.Path); Assert.Equal(RootFolderRelocationStatus.NeedsAttention, relocationAfter.Status); - Assert.False(File.Exists(replacementEnrollment)); Assert.True(Directory.Exists(displacedTarget)); } diff --git a/tests/Features/Infrastructure/Persistence/RootFolderObjectIdentityReconcilerTests.cs b/tests/Features/Infrastructure/Persistence/RootFolderObjectIdentityReconcilerTests.cs index 9aaf8dcaf..91130fe3f 100644 --- a/tests/Features/Infrastructure/Persistence/RootFolderObjectIdentityReconcilerTests.cs +++ b/tests/Features/Infrastructure/Persistence/RootFolderObjectIdentityReconcilerTests.cs @@ -52,112 +52,7 @@ public async Task ReconcileAsync_AmbiguousPersistedRoot_DoesNotEnrollWindowsDevi StringComparison.OrdinalIgnoreCase); } - [Fact] - public async Task ReconcileAsync_LegacyVersionTwoIdentityWithoutMarker_RemainsAuthorized() - { - var rootPath = FileService.GetTempDirectory("root-object-identity-markerless-v2"); - using var anchor = PinnedDirectoryCreation.OpenPinnedBoundary(rootPath); - var nativeIdentity = anchor.GetDirectoryObjectIdentity(); - var persistedIdentity = ManagedDirectoryIdentity.Create( - Guid.NewGuid().ToString("N"), - nativeIdentity); - Assert.False(File.Exists(Path.Join( - rootPath, - ManagedDirectoryEnrollment.FileName))); - - var options = new DbContextOptionsBuilder() - .UseInMemoryDatabase(Guid.NewGuid().ToString()) - .Options; - await using (var setup = new ListenArrDbContext(options)) - { - setup.RootFolders.Add(new RootFolder - { - Id = 1, - Name = "Library", - Path = rootPath, - DirectoryObjectIdentityVersion = ManagedDirectoryIdentity.CurrentVersion, - DirectoryObjectIdentity = persistedIdentity - }); - await setup.SaveChangesAsync(); - } - - var reconciler = new RootFolderObjectIdentityReconciler( - new TestDbContextFactory(options), - new DirectoryObjectIdentityResolver(), - new FilesystemMutationCoordinator(), - NullLogger.Instance); - - await reconciler.ReconcileAsync(); - - await using var verification = new ListenArrDbContext(options); - var root = await verification.RootFolders.SingleAsync(); - Assert.Equal(ManagedDirectoryIdentity.CurrentVersion, root.DirectoryObjectIdentityVersion); - Assert.Equal(persistedIdentity, root.DirectoryObjectIdentity); - Assert.Null(root.DirectoryObjectIdentityUnavailableReason); - Assert.False(File.Exists(Path.Join( - rootPath, - ManagedDirectoryEnrollment.FileName))); - } - - [Fact] - public async Task ReconcileAsync_MatchingLegacyEnrollmentMarker_RetiresMarkerAndKeepsDatabaseIdentity() - { - var rootPath = FileService.GetTempDirectory("root-object-identity-retire-marker"); - string nativeIdentity; - using (var anchor = PinnedDirectoryCreation.OpenPinnedBoundary(rootPath)) - { - nativeIdentity = anchor.GetDirectoryObjectIdentity(); - } - var token = Guid.NewGuid().ToString("N"); - var legacyIdentity = new DirectoryObjectIdentityResolution( - ManagedDirectoryIdentity.CurrentVersion, - ManagedDirectoryIdentity.Create(token, nativeIdentity), - null); - var markerPath = Path.Join(rootPath, ManagedDirectoryEnrollment.FileName); - await File.WriteAllTextAsync( - markerPath, - System.Text.Json.JsonSerializer.Serialize(new - { - version = 1, - token, - nativeIdentity, - createdAtUtc = DateTimeOffset.UtcNow - })); - Assert.True(File.Exists(markerPath)); - - var options = new DbContextOptionsBuilder() - .UseInMemoryDatabase(Guid.NewGuid().ToString()) - .Options; - await using (var setup = new ListenArrDbContext(options)) - { - setup.RootFolders.Add(new RootFolder - { - Id = 1, - Name = "Library", - Path = rootPath, - DirectoryObjectIdentityVersion = legacyIdentity.Version, - DirectoryObjectIdentity = legacyIdentity.Value - }); - await setup.SaveChangesAsync(); - } - - var reconciler = new RootFolderObjectIdentityReconciler( - new TestDbContextFactory(options), - new DirectoryObjectIdentityResolver(), - new FilesystemMutationCoordinator(), - NullLogger.Instance); - - await reconciler.ReconcileAsync(); - - await using var verification = new ListenArrDbContext(options); - var root = await verification.RootFolders.SingleAsync(); - Assert.Equal(legacyIdentity.Version, root.DirectoryObjectIdentityVersion); - Assert.Equal(legacyIdentity.Value, root.DirectoryObjectIdentity); - Assert.Null(root.DirectoryObjectIdentityUnavailableReason); - Assert.False(File.Exists(markerPath)); - } - - private sealed class TestDbContextFactory( +private sealed class TestDbContextFactory( DbContextOptions options) : IDbContextFactory { From bff3d9c85ce2eb8dd3b4d5d51006e652140ef57f Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Fri, 7 Aug 2026 11:25:07 -0400 Subject: [PATCH 436/464] chore(ci): inventory ownership compatibility --- .github/workflows/pr717-cleanup-analysis.yml | 87 ++++++-------------- 1 file changed, 24 insertions(+), 63 deletions(-) diff --git a/.github/workflows/pr717-cleanup-analysis.yml b/.github/workflows/pr717-cleanup-analysis.yml index d07bf89d9..0a12aa43b 100644 --- a/.github/workflows/pr717-cleanup-analysis.yml +++ b/.github/workflows/pr717-cleanup-analysis.yml @@ -9,75 +9,36 @@ permissions: contents: write jobs: - remove-root-enrollment-compatibility: + inventory-ownership-compatibility: if: github.actor != 'github-actions[bot]' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - - - name: Remove obsolete root enrollment compatibility - shell: bash - run: | - set -euo pipefail - python3 - <<'PY' - from pathlib import Path - path = Path('.github/scripts/pr717_cleanup_step4.py') - text = path.read_text() - old = '''for path in Path("tests").rglob("*.cs"):\n source = path.read_text()\n if "ManagedDirectoryEnrollment" in source or ".listenarr-root-enrollment.json" in source or "UpgradeLegacyAsync" in source:\n raise SystemExit(f"stale root-enrollment compatibility remains in {path}")\n''' - new = '''for path in Path("tests").rglob("*.cs"):\n if path.as_posix() == "tests/Features/Architecture/BackendArchitectureTests.cs":\n continue\n source = path.read_text()\n if "ManagedDirectoryEnrollment" in source or ".listenarr-root-enrollment.json" in source or "UpgradeLegacyAsync" in source:\n raise SystemExit(f"stale root-enrollment compatibility remains in {path}")\n''' - if old not in text: - raise SystemExit('missing step4 self-scan block') - path.write_text(text.replace(old, new, 1)) - PY - python3 .github/scripts/pr717_cleanup_step4.py - - - name: Static adversarial review - shell: bash - run: | - set -euo pipefail - git diff --check - if git grep -n -E 'ManagedDirectoryEnrollment|\.listenarr-root-enrollment\.json|UpgradeLegacyAsync' -- \ - 'listenarr.application' 'listenarr.domain' 'listenarr.infrastructure' 'listenarr.api' 'tests' \ - ':!tests/Features/Architecture/BackendArchitectureTests.cs' ':!.github/**'; then - echo 'Obsolete root enrollment compatibility remains' >&2 - exit 1 - fi - test ! -e listenarr.infrastructure/FileSystem/ManagedDirectoryEnrollment.cs - git diff --stat - - - name: Restore and build - shell: bash - run: | - set -euo pipefail - dotnet restore listenarr.slnx - dotnet build listenarr.slnx --configuration Release --no-restore - - - name: Focused identity and move tests - shell: bash - run: | - set -euo pipefail - dotnet test tests/Listenarr.Tests.csproj --configuration Release --no-build --filter 'FullyQualifiedName~RootFolderServiceTests|FullyQualifiedName~DirectoryObjectIdentityResolverTests|FullyQualifiedName~RootFolderObjectIdentityReconcilerTests|FullyQualifiedName~AudiobookContentMoveServiceTests|FullyQualifiedName~RootFolderRelocationServiceTests|FullyQualifiedName~BackendArchitectureTests' - - - name: Adversarial current-contract review - shell: bash - run: | - set -euo pipefail - git grep -n -F 'CreateMarkerless' -- listenarr.infrastructure tests || true - git grep -n -F 'LibraryDirectoryOwnershipMarker' -- listenarr.infrastructure tests || true - if git grep -n -E 'legacy managed-directory enrollment|legacy root enrollment|root enrollment marker' -- listenarr.application listenarr.domain listenarr.infrastructure listenarr.api; then - echo 'Legacy root-enrollment language remains in production contracts' >&2 - exit 1 - fi - - - name: Commit cleanup + - name: Inventory ownership protocol compatibility shell: bash run: | set -euo pipefail - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git restore .github/scripts/pr717_cleanup_step4.py - git add -A ':!.github/workflows/pr717-cleanup-analysis.yml' ':!.github/scripts/**' ':!.github/pr717-cleanup-trigger' - git commit -m 'refactor(identity): remove intermediate root enrollment compatibility' - git push origin HEAD:bugfix/unix-folder-name-space + for token in \ + 'LegacyMarkerPayload' \ + 'MatchesLegacyPayload' \ + 'UpgradeLegacyMarkerAsync' \ + 'UpgradeLegacyAsync' \ + 'UpgradeLegacyRecordAsync' \ + 'DirectoryObjectIdentityVersion == 1' \ + 'DirectoryObjectIdentityVersion is null' \ + 'MarkerPayload' \ + 'TryRetireMatchingMarkerIfPresent' \ + 'PublishMigrationTargetAsync' \ + 'RecoverMigrationTargetPromotion' \ + 'migration.tmp'; do + echo "=== $token ===" + git grep -n -F "$token" -- listenarr.application listenarr.domain listenarr.infrastructure listenarr.api tests || true + done + echo '=== ownership store files ===' + find listenarr.infrastructure/Library/Moving -maxdepth 1 -type f -name '*Ownership*' -printf '%f\n' | sort + echo '=== claim marker calls ===' + git grep -n -E 'PinnedLibraryDirectoryOwnershipMarker\.(EnsureAsync|ReconcileAsync|PublishMigrationTargetAsync|UpgradeLegacyAsync|TryRetireMatchingMarkerIfPresent)' -- listenarr.infrastructure tests || true + echo '=== marker API calls ===' + git grep -n -E 'LibraryDirectoryOwnershipMarker\.(EnsureAsync|Validate|Delete|Contains|HasValid|SerializePayload|Matches)' -- listenarr.infrastructure tests || true From 406a9c418575e0c26242a0a84fb7344a1000a2d8 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Fri, 7 Aug 2026 11:25:21 -0400 Subject: [PATCH 437/464] chore(ci): trigger ownership compatibility inventory --- .github/pr717-cleanup-trigger | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/pr717-cleanup-trigger b/.github/pr717-cleanup-trigger index 294608651..8e5ff414d 100644 --- a/.github/pr717-cleanup-trigger +++ b/.github/pr717-cleanup-trigger @@ -1 +1 @@ -trigger step 4 final gate +trigger step 5 inventory From ea2903454dbf2e7cfb108e5b18522d4f3ea8e999 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Fri, 7 Aug 2026 11:29:47 -0400 Subject: [PATCH 438/464] chore(ci): add ownership compatibility cleanup --- .github/scripts/pr717_cleanup_step5.py | 402 +++++++++++++++++++++++++ 1 file changed, 402 insertions(+) create mode 100644 .github/scripts/pr717_cleanup_step5.py diff --git a/.github/scripts/pr717_cleanup_step5.py b/.github/scripts/pr717_cleanup_step5.py new file mode 100644 index 000000000..3bcce2bbc --- /dev/null +++ b/.github/scripts/pr717_cleanup_step5.py @@ -0,0 +1,402 @@ +from pathlib import Path + + +def read(path: str) -> str: + return Path(path).read_text() + + +def write(path: str, text: str) -> None: + Path(path).write_text(text) + + +def replace_once(path: str, old: str, new: str = "") -> None: + text = read(path) + if old not in text: + raise SystemExit(f"missing expected block in {path}: {old[:120]!r}") + write(path, text.replace(old, new, 1)) + + +def find_matching_brace(text: str, brace: int) -> int: + depth = 0 + quote = None + escaped = False + index = brace + while index < len(text): + char = text[index] + if quote: + if escaped: + escaped = False + elif char == "\\": + escaped = True + elif char == quote: + quote = None + else: + if char in ("'", '"'): + quote = char + elif char == "{": + depth += 1 + elif char == "}": + depth -= 1 + if depth == 0: + return index + 1 + index += 1 + raise SystemExit("unbalanced brace") + + +def remove_decl(path: str, needle: str, include_attributes: bool = False) -> None: + text = read(path) + position = text.find(needle) + if position < 0: + raise SystemExit(f"missing declaration {needle!r} in {path}") + start = text.rfind("\n", 0, position) + 1 + if include_attributes: + while start > 0: + previous_end = start - 1 + previous_start = text.rfind("\n", 0, previous_end) + 1 + previous = text[previous_start : previous_end + 1].strip() + if previous.startswith("["): + start = previous_start + continue + break + brace = text.find("{", position) + if brace < 0: + raise SystemExit(f"no brace for {needle!r}") + end = find_matching_brace(text, brace) + while end < len(text) and text[end] in " \t\r\n": + end += 1 + write(path, text[:start] + text[end:]) + + +# No version-1 ownership marker existed on canary. Keep only the final payload. +remove_decl( + "listenarr.infrastructure/Library/Moving/LibraryDirectoryOwnershipMarker.Payload.cs", + "internal static bool MatchesLegacyPayload(", +) + +# The marker reconciler/upgrade path exists only to upgrade older #717 marker formats. +for needle in ( + "internal static async Task ReconcileAsync(", + "private static async Task ReconcileMarkerAsync(", + "private static async Task UpgradeLegacyMarkerAsync(", +): + remove_decl( + "listenarr.infrastructure/Library/Moving/PinnedLibraryDirectoryOwnershipMarker.cs", + needle, + ) + +# Current relocation migration may retire either the source or target generation, +# but never a version-1 marker payload. +replace_once( + "listenarr.infrastructure/Library/Moving/LibraryDirectoryOwnershipMarker.MigrationCleanup.cs", + """ MatchesCurrentPayload(source, payload) + || MatchesLegacyPayload(source, payload) + || MatchesCurrentPayload(target, payload) + || MatchesLegacyPayload(target, payload);""", + """ MatchesCurrentPayload(source, payload) + || MatchesCurrentPayload(target, payload);""", +) +replace_once( + "listenarr.infrastructure/Library/Moving/PinnedLibraryDirectoryOwnershipMarker.Migration.cs", + """ LibraryDirectoryOwnershipMarker.MatchesCurrentPayload(source, payload) + || LibraryDirectoryOwnershipMarker.MatchesLegacyPayload(source, payload);""", + """ LibraryDirectoryOwnershipMarker.MatchesCurrentPayload(source, payload);""", +) + +# Marker retirement accepts only the final persisted payload shape. +path = "listenarr.infrastructure/Library/Moving/LibraryDirectoryOwnershipMarker.cs" +text = read(path) +text = text.replace( + """ if (!MatchesCurrentPayload(ownership, payload) + && !MatchesLegacyPayload(ownership, payload)) + { + throw new InvalidOperationException( + "A legacy directory ownership artifact does not match the persisted ownership claim."); + } +""", + """ if (!MatchesCurrentPayload(ownership, payload)) + { + throw new InvalidOperationException( + "A directory ownership artifact does not match the persisted ownership claim."); + } +""", + 1, +) +text = text.replace( + """ throw new InvalidOperationException( + "A legacy directory ownership artifact changed before retirement.");""", + """ throw new InvalidOperationException( + "A directory ownership artifact changed before retirement.");""", +) +text = text.replace( + """ if (!MatchesCurrentPayload(ownership, verifiedPayload) + && !MatchesLegacyPayload(ownership, verifiedPayload)) +""", + """ if (!MatchesCurrentPayload(ownership, verifiedPayload)) +""", + 1, +) +write(path, text) + +# Final ownership removal has no legacy quarantine or missing-both marker format. +path = "listenarr.infrastructure/Library/Moving/LibraryDirectoryOwnershipRemoval.cs" +remove_decl(path, "public static bool TryValidateLegacyMissingBothRecovery(") +text = read(path) +text = text.replace( + """ var quarantinePath = GetQuarantinePath(ownership); + var quarantineExists = Directory.Exists(quarantinePath); + var quarantineIsFile = File.Exists(quarantinePath); + if (originalIsFile || quarantineIsFile) + { + throw new InvalidOperationException( + "An owned directory recovery path is occupied by a file."); + } + if (originalExists && quarantineExists) + { + throw new InvalidOperationException( + "Both the owned directory and its removal quarantine exist."); + } + + if (!originalExists && !quarantineExists) +""", + """ if (originalIsFile) + { + throw new InvalidOperationException( + "The owned directory recovery path is occupied by a file."); + } + + if (!originalExists) +""", + 1, +) +text = text.replace( + """ var visiblePath = originalExists + ? ownership.CanonicalPath + : quarantinePath; + var parentPath = Path.GetDirectoryName(visiblePath) +""", + """ var parentPath = Path.GetDirectoryName(ownership.CanonicalPath) +""", + 1, +) +text = text.replace( + """ using var directory = parent.OpenExistingChild(Path.GetFileName(visiblePath));""", + """ using var directory = parent.OpenExistingChild(Path.GetFileName(ownership.CanonicalPath));""", + 1, +) +# Remove quarantine state from actual deletion path. +old_start = """ var quarantinePath = GetQuarantinePath(ownership); + var originalExists = Directory.Exists(originalPath); + var originalIsFile = File.Exists(originalPath); + var quarantineExists = Directory.Exists(quarantinePath); + var quarantineIsFile = File.Exists(quarantinePath); + if (originalIsFile || quarantineIsFile) + { + throw new InvalidOperationException( + "An owned directory removal path is occupied by a file."); + } + if (originalExists && quarantineExists) + { + throw new InvalidOperationException( + "Both the owned directory and its removal quarantine exist."); + } +""" +new_start = """ var originalExists = Directory.Exists(originalPath); + var originalIsFile = File.Exists(originalPath); + if (originalIsFile) + { + throw new InvalidOperationException( + "An owned directory removal path is occupied by a file."); + } +""" +if old_start not in text: + raise SystemExit("missing removal quarantine preflight") +text = text.replace(old_start, new_start, 1) +text = text.replace( + """ if (!originalExists && !quarantineExists) + { + RetireLegacySiblingArtifacts(ownership, parentAnchor); + return LibraryDirectoryRemovalOutcome.AlreadyRemoved; + } + + if (originalExists) +""", + """ if (!originalExists) + { + RetireSiblingArtifacts(ownership, parentAnchor); + return LibraryDirectoryRemovalOutcome.AlreadyRemoved; + } + +""", + 1, +) +# Keep only the original-path removal block; discard the quarantine compatibility block. +compat_marker = """ // Compatibility only: older versions may have already renamed the directory + // into a job-shaped quarantine. New removals never create that pathname. +""" +compat_pos = text.find(compat_marker) +if compat_pos < 0: + raise SystemExit("missing quarantine compatibility block") +# The original branch immediately before compatibility is closed with eight spaces + }. +# Preserve method close after deleting compatibility branch by replacing tail from marker through RestorePinnedQuarantine helper. +method_tail_start = compat_pos +restore_pos = text.find(" private static void RestorePinnedQuarantine(", compat_pos) +if restore_pos < 0: + raise SystemExit("missing RestorePinnedQuarantine") +restore_brace = text.find("{", restore_pos) +restore_end = find_matching_brace(text, restore_brace) +while restore_end < len(text) and text[restore_end] in " \t\r\n": + restore_end += 1 +# Compatibility block occurs before helper methods; remove only it by finding the closing brace before RetireLegacyOwnershipArtifacts. +helpers_pos = text.find(" private static void RetireLegacyOwnershipArtifacts(", compat_pos) +if helpers_pos < 0 or helpers_pos > restore_pos: + raise SystemExit("missing ownership artifact helpers") +text = text[:method_tail_start] + " }\n\n" + text[helpers_pos:restore_pos] + text[restore_end:] +text = text.replace("RetireLegacyOwnershipArtifacts", "RetireOwnershipArtifacts") +text = text.replace("RetireLegacySiblingArtifacts", "RetireSiblingArtifacts") +text = text.replace("Legacy directory ownership artifacts", "Directory ownership artifacts") +text = text.replace("Legacy directory ownership sibling artifacts", "Directory ownership sibling artifacts") +write(path, text) + +# Startup reconciliation supports only ownership rows produced by the final model. +path = "listenarr.infrastructure/Library/Moving/LibraryDirectoryOwnershipReconciler.cs" +replace_once(path, " await BackfillLegacyRemovedOwnershipEvidenceAsync(db, cancellationToken);\n") +remove_decl(path, "private async Task BackfillLegacyRemovedOwnershipEvidenceAsync(") +text = read(path) +# Missing Removing path: no obsolete marker proof is needed; current deletion retires transient markers before namespace removal. +start = text.find(" LibraryDirectoryOwnershipMarker.MarkerPayload? legacyPayload = null;") +if start < 0: + raise SystemExit("missing legacy missing-removal reconciliation") +end_marker = " ownership.State = LibraryDirectoryOwnershipState.Removed;" +end = text.find(end_marker, start) +if end < 0: + raise SystemExit("missing removed-state convergence") +text = text[:start] + text[end:] +# Require final physical identity; no null/v1 upgrade. +legacy_identity_start = text.find(" var liveIdentity = directory.GetDirectoryObjectIdentity();") +legacy_identity_end = text.find(" ownership.ManagedRootFolderId = authorization.RootFolderId;", legacy_identity_start) +if legacy_identity_start < 0 or legacy_identity_end < 0: + raise SystemExit("missing identity reconciliation block") +replacement = """ var liveIdentity = directory.GetDirectoryObjectIdentity(); + if (ownership.DirectoryObjectIdentityVersion + != ManagedDirectoryIdentity.CurrentVersion + || !ManagedDirectoryIdentity.Matches( + ownership.DirectoryObjectIdentityVersion, + ownership.DirectoryObjectIdentity, + ownership.OwnershipToken, + liveIdentity)) + { + throw new InvalidOperationException( + "The persisted directory ownership identity is not the current supported generation."); + } + +""" +text = text[:legacy_identity_start] + replacement + text[legacy_identity_end:] +# Do not regenerate a different identity during reconciliation; validation above is authoritative. +text = text.replace( + """ ownership.DirectoryObjectIdentityVersion = + ManagedDirectoryIdentity.CurrentVersion; + ownership.DirectoryObjectIdentity = ManagedDirectoryIdentity.Create( + ownership.OwnershipToken, + liveIdentity); +""", + "", + 1, +) +text = text.replace( + """ // Older builds left these marker files permanently. The durable row, + // managed-root authorization, and pinned native directory generation + // now provide the at-rest proof. Retire only artifacts that still match + // this exact ownership; unrelated files are preserved. +""", + """ // Root relocation may leave transient ownership migration artifacts + // after a crash. Retire only artifacts that still match this exact final + // ownership generation; unrelated files are preserved. +""", + 1, +) +text = text.replace("Obsolete directory ownership artifacts", "Directory ownership migration artifacts") +write(path, text) + +# Retired marker evidence is final-format only. +path = "listenarr.infrastructure/Library/Moving/EfLibraryDirectoryOwnershipStore.State.cs" +remove_decl(path, "public static LibraryDirectoryOwnershipRetiredMarker CreateLegacyPending(") +text = read(path) +old = """ var payload = evidence.PayloadVersion == 1 + ? new LibraryDirectoryOwnershipMarker.MarkerPayload( + 1, + evidence.OwnershipToken, + evidence.CanonicalOwnershipPath) + : new LibraryDirectoryOwnershipMarker.MarkerPayload( + evidence.PayloadVersion, + evidence.OwnershipToken, + evidence.CanonicalOwnershipPath, + evidence.OriginalManagedRootFolderId, + evidence.DirectoryObjectIdentityVersion, + evidence.DirectoryObjectIdentity); +""" +new = """ if (evidence.PayloadVersion != LibraryDirectoryOwnershipMarker.Version) + { + throw new InvalidOperationException( + "The retired ownership marker evidence uses an unsupported payload version."); + } + var payload = new LibraryDirectoryOwnershipMarker.MarkerPayload( + evidence.PayloadVersion, + evidence.OwnershipToken, + evidence.CanonicalOwnershipPath, + evidence.OriginalManagedRootFolderId, + evidence.DirectoryObjectIdentityVersion, + evidence.DirectoryObjectIdentity); +""" +if old not in text: + raise SystemExit("missing retired evidence legacy materialization") +write(path, text.replace(old, new, 1)) + +# Tests dedicated to intermediate ownership versions/formats are development history. +for test_file in ( + "tests/Features/Infrastructure/Library/Moving/EfLibraryDirectoryOwnershipStoreTests.cs", + "tests/Features/Infrastructure/Library/Moving/LibraryDirectoryOwnershipReconcilerTests.cs", + "tests/Features/Infrastructure/Library/Moving/LibraryDirectoryOwnershipMarkerTests.cs", + "tests/Features/Infrastructure/Library/Moving/PinnedLibraryDirectoryOwnershipMarkerTests.cs", +): + p = Path(test_file) + if not p.exists(): + continue + text = p.read_text() + cursor = 0 + forbidden = ( + "MatchesLegacyPayload", + "CreateLegacyPending", + "TryValidateLegacyMissingBothRecovery", + "legacy physical identity", + "legacy marker", + "LegacyMarker", + "LegacyOwnership", + "LegacyRemoved", + "version one", + "VersionOne", + ) + while True: + positions = [text.find(token, cursor) for token in forbidden] + positions = [pos for pos in positions if pos >= 0] + if not positions: + break + pos = min(positions) + # Find containing test method attribute block. + attr = max(text.rfind(" [Fact]", 0, pos), text.rfind(" [Theory]", 0, pos)) + if attr < 0: + cursor = pos + 1 + continue + method_brace = text.find("{", attr) + if method_brace < 0 or method_brace > pos: + cursor = pos + 1 + continue + method_end = find_matching_brace(text, method_brace) + if pos > method_end: + cursor = pos + 1 + continue + end = method_end + while end < len(text) and text[end] in " \t\r\n": + end += 1 + text = text[:attr] + text[end:] + cursor = attr + p.write_text(text) From 186746a68e74a009233f44f29fa69694f338bc28 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Fri, 7 Aug 2026 11:30:15 -0400 Subject: [PATCH 439/464] chore(ci): execute ownership compatibility cleanup --- .github/workflows/pr717-cleanup-analysis.yml | 79 ++++++++++++++------ 1 file changed, 55 insertions(+), 24 deletions(-) diff --git a/.github/workflows/pr717-cleanup-analysis.yml b/.github/workflows/pr717-cleanup-analysis.yml index 0a12aa43b..8072c0300 100644 --- a/.github/workflows/pr717-cleanup-analysis.yml +++ b/.github/workflows/pr717-cleanup-analysis.yml @@ -9,36 +9,67 @@ permissions: contents: write jobs: - inventory-ownership-compatibility: + remove-ownership-version-compatibility: if: github.actor != 'github-actions[bot]' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - - name: Inventory ownership protocol compatibility + + - name: Remove intermediate ownership compatibility + run: python3 .github/scripts/pr717_cleanup_step5.py + + - name: Static adversarial review + shell: bash + run: | + set -euo pipefail + git diff --check + if git grep -n -E 'MatchesLegacyPayload|UpgradeLegacyMarkerAsync|TryValidateLegacyMissingBothRecovery|CreateLegacyPending|legacy physical identity|pre-physical-identity|Older builds left these marker' -- \ + listenarr.application listenarr.domain listenarr.infrastructure listenarr.api; then + echo 'Intermediate ownership compatibility remains in production' >&2 + exit 1 + fi + if git grep -n -E 'DirectoryObjectIdentityVersion[[:space:]]*==[[:space:]]*1|PayloadVersion[[:space:]]*==[[:space:]]*1' -- \ + listenarr.infrastructure; then + echo 'Version-one ownership compatibility remains' >&2 + exit 1 + fi + echo 'Current relocation marker migration callers:' + git grep -n -E 'PublishMigrationTargetAsync|TryRetireMigrationArtifacts' -- listenarr.infrastructure tests || true + git diff --stat + + - name: Restore and build + shell: bash + run: | + set -euo pipefail + dotnet restore listenarr.slnx + dotnet build listenarr.slnx --configuration Release --no-restore + + - name: Focused ownership and relocation tests + shell: bash + run: | + set -euo pipefail + dotnet test tests/Listenarr.Tests.csproj --configuration Release --no-build --filter 'FullyQualifiedName~LibraryDirectoryOwnership|FullyQualifiedName~RootFolderRelocation|FullyQualifiedName~AudiobookContentMoveService|FullyQualifiedName~AudiobookFilesystemDeleteService' + + - name: Adversarial live-marker contract review + shell: bash + run: | + set -euo pipefail + git grep -n -E 'PublishMigrationTargetAsync|\.migration\.tmp|TryRetireMigrationArtifacts|RecoverConditionalReplacement' -- listenarr.infrastructure tests || true + test -f listenarr.infrastructure/Library/Moving/PinnedLibraryDirectoryOwnershipMarker.Migration.cs + test -f listenarr.infrastructure/Library/Moving/PinnedLibraryDirectoryOwnershipMarker.Recovery.cs + if ! git grep -q -F 'PublishMigrationTargetAsync' -- listenarr.infrastructure/Library/Moving/RootFolderRelocationService.OwnershipMigration.cs; then + echo 'Current root-relocation marker migration contract was accidentally removed' >&2 + exit 1 + fi + + - name: Commit cleanup shell: bash run: | set -euo pipefail - for token in \ - 'LegacyMarkerPayload' \ - 'MatchesLegacyPayload' \ - 'UpgradeLegacyMarkerAsync' \ - 'UpgradeLegacyAsync' \ - 'UpgradeLegacyRecordAsync' \ - 'DirectoryObjectIdentityVersion == 1' \ - 'DirectoryObjectIdentityVersion is null' \ - 'MarkerPayload' \ - 'TryRetireMatchingMarkerIfPresent' \ - 'PublishMigrationTargetAsync' \ - 'RecoverMigrationTargetPromotion' \ - 'migration.tmp'; do - echo "=== $token ===" - git grep -n -F "$token" -- listenarr.application listenarr.domain listenarr.infrastructure listenarr.api tests || true - done - echo '=== ownership store files ===' - find listenarr.infrastructure/Library/Moving -maxdepth 1 -type f -name '*Ownership*' -printf '%f\n' | sort - echo '=== claim marker calls ===' - git grep -n -E 'PinnedLibraryDirectoryOwnershipMarker\.(EnsureAsync|ReconcileAsync|PublishMigrationTargetAsync|UpgradeLegacyAsync|TryRetireMatchingMarkerIfPresent)' -- listenarr.infrastructure tests || true - echo '=== marker API calls ===' - git grep -n -E 'LibraryDirectoryOwnershipMarker\.(EnsureAsync|Validate|Delete|Contains|HasValid|SerializePayload|Matches)' -- listenarr.infrastructure tests || true + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add -A ':!.github/workflows/pr717-cleanup-analysis.yml' ':!.github/scripts/**' ':!.github/pr717-cleanup-trigger' + git commit -m 'refactor(ownership): remove intermediate protocol compatibility' + git push origin HEAD:bugfix/unix-folder-name-space From feecd3dda675a9b1325a0a0fcaacf8b8148909d6 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Fri, 7 Aug 2026 11:30:30 -0400 Subject: [PATCH 440/464] chore(ci): trigger ownership compatibility cleanup --- .github/pr717-cleanup-trigger | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/pr717-cleanup-trigger b/.github/pr717-cleanup-trigger index 8e5ff414d..b4b6bb5c0 100644 --- a/.github/pr717-cleanup-trigger +++ b/.github/pr717-cleanup-trigger @@ -1 +1 @@ -trigger step 5 inventory +trigger step 5 cleanup From 3d9d4f058126824cb318c0ad4a4bd106d9a4498b Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Fri, 7 Aug 2026 11:31:52 -0400 Subject: [PATCH 441/464] chore(ci): fix ownership cleanup transformer --- .github/workflows/pr717-cleanup-analysis.yml | 45 +++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pr717-cleanup-analysis.yml b/.github/workflows/pr717-cleanup-analysis.yml index 8072c0300..0f75a382d 100644 --- a/.github/workflows/pr717-cleanup-analysis.yml +++ b/.github/workflows/pr717-cleanup-analysis.yml @@ -18,7 +18,49 @@ jobs: fetch-depth: 0 - name: Remove intermediate ownership compatibility - run: python3 .github/scripts/pr717_cleanup_step5.py + shell: bash + run: | + set -euo pipefail + python3 - <<'PY' + from pathlib import Path + + # The first cleanup target is expression-bodied, while the shared temporary + # cleanup helper is intentionally brace-oriented. Remove it exactly and + # suppress the helper call for this one member. + payload = Path('listenarr.infrastructure/Library/Moving/LibraryDirectoryOwnershipMarker.Payload.cs') + text = payload.read_text() + block = ''' internal static bool MatchesLegacyPayload( + LibraryDirectoryOwnership ownership, + MarkerPayload payload) => + payload.Version == 1 + && string.Equals( + payload.OwnershipToken, + ownership.OwnershipToken, + StringComparison.Ordinal) + && MarkerPathMatches( + payload.CanonicalPath, + ownership.CanonicalPath, + ownership.GetIdentity().Semantics); + ''' + # Normalize indentation from this temporary heredoc before matching source. + block = '\n'.join(line[10:] if line.startswith(' ') else line for line in block.splitlines()) + '\n' + if block not in text: + raise SystemExit('missing expression-bodied legacy marker matcher') + payload.write_text(text.replace(block, '', 1)) + + script = Path('.github/scripts/pr717_cleanup_step5.py') + source = script.read_text() + call = '''remove_decl( + "listenarr.infrastructure/Library/Moving/LibraryDirectoryOwnershipMarker.Payload.cs", + "internal static bool MatchesLegacyPayload(", + ) + ''' + call = '\n'.join(line[10:] if line.startswith(' ') else line for line in call.splitlines()) + '\n' + if call not in source: + raise SystemExit('missing legacy matcher cleanup call') + script.write_text(source.replace(call, '', 1)) + PY + python3 .github/scripts/pr717_cleanup_step5.py - name: Static adversarial review shell: bash @@ -43,6 +85,7 @@ jobs: shell: bash run: | set -euo pipefail + git restore .github/scripts/pr717_cleanup_step5.py dotnet restore listenarr.slnx dotnet build listenarr.slnx --configuration Release --no-restore From 2b0667c214411574c4f90bf9f77c6a6450bdd8f2 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Fri, 7 Aug 2026 11:32:06 -0400 Subject: [PATCH 442/464] chore(ci): rerun ownership compatibility cleanup --- .github/pr717-cleanup-trigger | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/pr717-cleanup-trigger b/.github/pr717-cleanup-trigger index b4b6bb5c0..109df6f68 100644 --- a/.github/pr717-cleanup-trigger +++ b/.github/pr717-cleanup-trigger @@ -1 +1 @@ -trigger step 5 cleanup +trigger step 5 cleanup retry From 1165faf8d3f89a36480b4759efbe6e59006bb09a Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Fri, 7 Aug 2026 11:33:40 -0400 Subject: [PATCH 443/464] chore(ci): add ownership cleanup runner --- .github/scripts/pr717_cleanup_step5_runner.py | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 .github/scripts/pr717_cleanup_step5_runner.py diff --git a/.github/scripts/pr717_cleanup_step5_runner.py b/.github/scripts/pr717_cleanup_step5_runner.py new file mode 100644 index 000000000..619683927 --- /dev/null +++ b/.github/scripts/pr717_cleanup_step5_runner.py @@ -0,0 +1,29 @@ +from pathlib import Path +import re + +payload = Path('listenarr.infrastructure/Library/Moving/LibraryDirectoryOwnershipMarker.Payload.cs') +text = payload.read_text() +text, count = re.subn( + r'\n\s*internal static bool MatchesLegacyPayload\(.*?ownership\.GetIdentity\(\)\.Semantics\);\n', + '\n', + text, + count=1, + flags=re.S, +) +if count != 1: + raise SystemExit('could not remove expression-bodied legacy marker matcher') +payload.write_text(text) + +script = Path('.github/scripts/pr717_cleanup_step5.py') +source = script.read_text() +source, count = re.subn( + r'\nremove_decl\(\n\s*"listenarr\.infrastructure/Library/Moving/LibraryDirectoryOwnershipMarker\.Payload\.cs",\n\s*"internal static bool MatchesLegacyPayload\(",\n\)\n', + '\n', + source, + count=1, +) +if count != 1: + raise SystemExit('could not suppress legacy matcher helper call') + +namespace = {'__name__': '__main__', '__file__': str(script)} +exec(compile(source, str(script), 'exec'), namespace) From c85c570b5f2fa82a9240198cd9fa8aef713b4ef5 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Fri, 7 Aug 2026 11:34:07 -0400 Subject: [PATCH 444/464] chore(ci): add ownership cleanup gate --- .github/workflows/pr717-cleanup-step5.yml | 63 +++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 .github/workflows/pr717-cleanup-step5.yml diff --git a/.github/workflows/pr717-cleanup-step5.yml b/.github/workflows/pr717-cleanup-step5.yml new file mode 100644 index 000000000..8d71fbd35 --- /dev/null +++ b/.github/workflows/pr717-cleanup-step5.yml @@ -0,0 +1,63 @@ +name: PR 717 cleanup step 5 + +on: + push: + branches: + - bugfix/unix-folder-name-space + +permissions: + contents: write + +jobs: + cleanup: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Transform + run: python3 .github/scripts/pr717_cleanup_step5_runner.py + - name: Static adversarial review + shell: bash + run: | + set -euo pipefail + git diff --check + if git grep -n -E 'MatchesLegacyPayload|UpgradeLegacyMarkerAsync|TryValidateLegacyMissingBothRecovery|CreateLegacyPending|legacy physical identity|pre-physical-identity|Older builds left these marker' -- listenarr.application listenarr.domain listenarr.infrastructure listenarr.api; then + echo 'Intermediate ownership compatibility remains in production' >&2 + exit 1 + fi + if git grep -n -E 'DirectoryObjectIdentityVersion[[:space:]]*==[[:space:]]*1|PayloadVersion[[:space:]]*==[[:space:]]*1' -- listenarr.infrastructure; then + echo 'Version-one ownership compatibility remains' >&2 + exit 1 + fi + git grep -n -E 'PublishMigrationTargetAsync|TryRetireMigrationArtifacts' -- listenarr.infrastructure tests || true + git diff --stat + - name: Build + shell: bash + run: | + set -euo pipefail + dotnet restore listenarr.slnx + dotnet build listenarr.slnx --configuration Release --no-restore + - name: Focused tests + shell: bash + run: | + set -euo pipefail + dotnet test tests/Listenarr.Tests.csproj --configuration Release --no-build --filter 'FullyQualifiedName~LibraryDirectoryOwnership|FullyQualifiedName~RootFolderRelocation|FullyQualifiedName~AudiobookContentMoveService|FullyQualifiedName~AudiobookFilesystemDeleteService' + - name: Live marker adversarial review + shell: bash + run: | + set -euo pipefail + test -f listenarr.infrastructure/Library/Moving/PinnedLibraryDirectoryOwnershipMarker.Migration.cs + test -f listenarr.infrastructure/Library/Moving/PinnedLibraryDirectoryOwnershipMarker.Recovery.cs + git grep -n -E 'PublishMigrationTargetAsync|\.migration\.tmp|TryRetireMigrationArtifacts|RecoverConditionalReplacement' -- listenarr.infrastructure tests || true + git grep -q -F 'PublishMigrationTargetAsync' -- listenarr.infrastructure/Library/Moving/RootFolderRelocationService.OwnershipMigration.cs + - name: Commit + shell: bash + run: | + set -euo pipefail + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add -A ':!.github/workflows/pr717-cleanup-analysis.yml' ':!.github/workflows/pr717-cleanup-step5.yml' ':!.github/scripts/**' ':!.github/pr717-cleanup-trigger' + git commit -m 'refactor(ownership): remove intermediate protocol compatibility' + git push origin HEAD:bugfix/unix-folder-name-space From 8f4d80aa34e2690856ae7dc028cbba8b3a709091 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Fri, 7 Aug 2026 11:34:24 -0400 Subject: [PATCH 445/464] chore(ci): trigger gated ownership cleanup --- .github/pr717-cleanup-trigger | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/pr717-cleanup-trigger b/.github/pr717-cleanup-trigger index 109df6f68..8ab01ba84 100644 --- a/.github/pr717-cleanup-trigger +++ b/.github/pr717-cleanup-trigger @@ -1 +1 @@ -trigger step 5 cleanup retry +trigger step 5 gated cleanup From 849a7a631df34b6dc871a7515b1ac4d47f1e0182 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Fri, 7 Aug 2026 11:37:47 -0400 Subject: [PATCH 446/464] chore(ci): remove markerless replacement legacy evidence fallback --- .github/scripts/pr717_cleanup_step5_runner.py | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/.github/scripts/pr717_cleanup_step5_runner.py b/.github/scripts/pr717_cleanup_step5_runner.py index 619683927..de3aa4435 100644 --- a/.github/scripts/pr717_cleanup_step5_runner.py +++ b/.github/scripts/pr717_cleanup_step5_runner.py @@ -27,3 +27,61 @@ namespace = {'__name__': '__main__', '__file__': str(script)} exec(compile(source, str(script), 'exec'), namespace) + +replacement = Path('listenarr.infrastructure/Library/Moving/EfLibraryDirectoryOwnershipStore.MarkerlessReplacement.cs') +text = replacement.read_text() +old = ''' if (!await db.LibraryDirectoryOwnershipRetiredMarkers.AnyAsync( + marker => marker.OwnershipId == stale.Id, + cancellationToken)) + { + if (stale.ManagedRootFolderId.HasValue + && stale.DirectoryObjectIdentityVersion.HasValue + && !string.IsNullOrWhiteSpace(stale.DirectoryObjectIdentity)) + { + db.LibraryDirectoryOwnershipRetiredMarkers.Add( + LibraryDirectoryOwnershipRetiredMarkerEvidence.Create( + stale, + new LibraryDirectoryOwnershipMarker.MarkerPayload( + LibraryDirectoryOwnershipMarker.Version, + stale.OwnershipToken, + stale.CanonicalPath, + stale.ManagedRootFolderId, + stale.DirectoryObjectIdentityVersion, + stale.DirectoryObjectIdentity), + now)); + } + else + { + db.LibraryDirectoryOwnershipRetiredMarkers.Add( + LibraryDirectoryOwnershipRetiredMarkerEvidence + .CreateLegacyPending(stale)); + } + } +''' +new = ''' if (!stale.ManagedRootFolderId.HasValue + || stale.DirectoryObjectIdentityVersion != ManagedDirectoryIdentity.CurrentVersion + || string.IsNullOrWhiteSpace(stale.DirectoryObjectIdentity)) + { + throw new InvalidOperationException( + "The stale ownership row does not contain the final durable identity required for retirement."); + } + if (!await db.LibraryDirectoryOwnershipRetiredMarkers.AnyAsync( + marker => marker.OwnershipId == stale.Id, + cancellationToken)) + { + db.LibraryDirectoryOwnershipRetiredMarkers.Add( + LibraryDirectoryOwnershipRetiredMarkerEvidence.Create( + stale, + new LibraryDirectoryOwnershipMarker.MarkerPayload( + LibraryDirectoryOwnershipMarker.Version, + stale.OwnershipToken, + stale.CanonicalPath, + stale.ManagedRootFolderId, + stale.DirectoryObjectIdentityVersion, + stale.DirectoryObjectIdentity), + now)); + } +''' +if old not in text: + raise SystemExit('missing markerless replacement legacy retired-evidence fallback') +replacement.write_text(text.replace(old, new, 1)) From 6805b099e1b0ed0c84b41df44937a2fb4fe252ce Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Fri, 7 Aug 2026 11:38:08 -0400 Subject: [PATCH 447/464] chore(ci): rerun ownership cleanup after adversarial finding --- .github/pr717-cleanup-trigger | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/pr717-cleanup-trigger b/.github/pr717-cleanup-trigger index 8ab01ba84..60d61ca8e 100644 --- a/.github/pr717-cleanup-trigger +++ b/.github/pr717-cleanup-trigger @@ -1 +1 @@ -trigger step 5 gated cleanup +trigger step 5 gated cleanup after finding From f0784b7915bf81622af8e5c4d9827a688f2aeb56 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Fri, 7 Aug 2026 11:39:58 -0400 Subject: [PATCH 448/464] chore(ci): fix ownership reconciler timestamp after legacy removal --- .github/scripts/pr717_cleanup_step5_runner.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/scripts/pr717_cleanup_step5_runner.py b/.github/scripts/pr717_cleanup_step5_runner.py index de3aa4435..7506393d9 100644 --- a/.github/scripts/pr717_cleanup_step5_runner.py +++ b/.github/scripts/pr717_cleanup_step5_runner.py @@ -85,3 +85,15 @@ if old not in text: raise SystemExit('missing markerless replacement legacy retired-evidence fallback') replacement.write_text(text.replace(old, new, 1)) + +reconciler = Path('listenarr.infrastructure/Library/Moving/LibraryDirectoryOwnershipReconciler.cs') +text = reconciler.read_text() +old = ''' ownership.State = LibraryDirectoryOwnershipState.Removed; + ownership.UpdatedAt = now; +''' +new = ''' ownership.State = LibraryDirectoryOwnershipState.Removed; + ownership.UpdatedAt = DateTime.UtcNow; +''' +if old not in text: + raise SystemExit('missing converged removal timestamp assignment') +reconciler.write_text(text.replace(old, new, 1)) From 471198c395e6fed555414dce237f857f71bec815 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Fri, 7 Aug 2026 11:40:16 -0400 Subject: [PATCH 449/464] chore(ci): rerun ownership cleanup after build finding --- .github/pr717-cleanup-trigger | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/pr717-cleanup-trigger b/.github/pr717-cleanup-trigger index 60d61ca8e..747266d1b 100644 --- a/.github/pr717-cleanup-trigger +++ b/.github/pr717-cleanup-trigger @@ -1 +1 @@ -trigger step 5 gated cleanup after finding +trigger step 5 gated cleanup after build finding From bc277faa057c19d4f5334a3e0c75107b7927668a Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Fri, 7 Aug 2026 13:09:45 -0400 Subject: [PATCH 450/464] chore(ci): fix Step 5 convergence patch --- .github/scripts/pr717_cleanup_step5_runner.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/scripts/pr717_cleanup_step5_runner.py b/.github/scripts/pr717_cleanup_step5_runner.py index 7506393d9..005a9a828 100644 --- a/.github/scripts/pr717_cleanup_step5_runner.py +++ b/.github/scripts/pr717_cleanup_step5_runner.py @@ -88,11 +88,11 @@ reconciler = Path('listenarr.infrastructure/Library/Moving/LibraryDirectoryOwnershipReconciler.cs') text = reconciler.read_text() -old = ''' ownership.State = LibraryDirectoryOwnershipState.Removed; - ownership.UpdatedAt = now; +old = ''' ownership.UpdatedAt = now; + await db.SaveChangesAsync(cancellationToken); ''' -new = ''' ownership.State = LibraryDirectoryOwnershipState.Removed; - ownership.UpdatedAt = DateTime.UtcNow; +new = ''' ownership.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(cancellationToken); ''' if old not in text: raise SystemExit('missing converged removal timestamp assignment') From 0fc6bfa6ffc80ab9c78c8e3198aecf7b3d75e212 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Fri, 7 Aug 2026 20:19:35 -0400 Subject: [PATCH 451/464] Harden markerless library move contracts --- .github/pr717-cleanup-trigger | 1 - .github/scripts/pr717_cleanup.py | 270 -- .github/scripts/pr717_cleanup_step3_extra.py | 42 - .github/scripts/pr717_cleanup_step4.py | 347 --- .github/scripts/pr717_cleanup_step5.py | 402 --- .github/scripts/pr717_cleanup_step5_runner.py | 99 - .github/workflows/pr717-cleanup-analysis.yml | 118 - .github/workflows/pr717-cleanup-step5.yml | 63 - fe/src/__tests__/RootFoldersSettings.spec.ts | 2 +- .../rootFolders.reauthorization.store.spec.ts | 1 - .../settings/RootFoldersSettings.vue | 1 - fe/src/services/api.ts | 2 +- fe/src/stores/rootFolders.ts | 2 +- .../Audiobooks/Jobs/MoveRecoveryPolicy.cs | 29 +- .../Audiobooks/LibraryDirectoryOwnership.cs | 40 - listenarr.domain/Audiobooks/MoveJob.cs | 9 +- .../Audiobooks/RootFolderRelocation.cs | 13 - ...rastructureStartupCompositionExtensions.cs | 16 - .../DirectoryObjectIdentityResolver.cs | 2 +- .../AudiobookContentMoveService.Atomic.cs | 267 -- ...ntentMoveService.AtomicMarkerRetirement.cs | 65 - ...ontentMoveService.CleanupMutationSafety.cs | 271 -- .../AudiobookContentMoveService.Copy.cs | 255 +- ...kContentMoveService.CopyDestinationRoot.cs | 69 - ...diobookContentMoveService.CopyStreaming.cs | 87 - ...okContentMoveService.DirectoryOwnership.cs | 55 +- ...iobookContentMoveService.FaultInjection.cs | 124 - ...udiobookContentMoveService.Finalization.cs | 304 +- ...ontentMoveService.FinalizedVerification.cs | 74 +- ...MoveService.IdenticalEndpointValidation.cs | 118 +- ...ContentMoveService.LegacyRecoverySafety.cs | 112 - .../AudiobookContentMoveService.Manifest.cs | 30 - ...bookContentMoveService.MarkerWriteFiles.cs | 139 - .../AudiobookContentMoveService.Markerless.cs | 20 +- ...ookContentMoveService.MarkerlessCleanup.cs | 22 +- ...ontentMoveService.MarkerlessDirectories.cs | 25 +- ...tMoveService.MarkerlessDirectoryCleanup.cs | 10 +- ...entMoveService.MarkerlessEntryPreflight.cs | 6 +- ...bookContentMoveService.MarkerlessRename.cs | 4 +- ...ntentMoveService.MarkerlessVerification.cs | 7 +- ...bookContentMoveService.OwnershipCleanup.cs | 479 --- ...MoveService.OwnershipCleanupPreparation.cs | 208 -- ...veService.OwnershipMarkerPinnedRecovery.cs | 118 - ...bookContentMoveService.OwnershipMarkers.cs | 452 --- ...ContentMoveService.OwnershipPublication.cs | 125 - ...tentMoveService.PersistedSourceManifest.cs | 47 - ...AudiobookContentMoveService.Persistence.cs | 15 +- ...entMoveService.PinnedArtifactRetirement.cs | 34 - .../AudiobookContentMoveService.PinnedCopy.cs | 447 --- ...ntentMoveService.PinnedOwnershipMarkers.cs | 67 - ...kContentMoveService.PinnedSourceCleanup.cs | 385 --- ...kContentMoveService.QuarantineOwnership.cs | 449 --- .../AudiobookContentMoveService.Recovery.cs | 262 -- ...obookContentMoveService.RecoveryMarkers.cs | 448 --- ...kContentMoveService.RecoveryPublication.cs | 200 -- ...bookContentMoveService.RecoveryWorkflow.cs | 255 +- ...diobookContentMoveService.SourceCleanup.cs | 481 --- ...ntMoveService.SourceCleanupVerification.cs | 58 +- ...ContentMoveService.SourceRootQuarantine.cs | 317 -- ...bookContentMoveService.SourceValidation.cs | 39 +- ...ntentMoveService.TargetPhysicalIdentity.cs | 34 + ...ookContentMoveService.TargetScaffolding.cs | 367 +-- ...entMoveService.TargetScaffoldingCleanup.cs | 434 +-- ...tentMoveService.TargetScaffoldingFaults.cs | 30 - ...entMoveService.TargetScaffoldingMarkers.cs | 177 -- ...oveService.TargetScaffoldingPreparation.cs | 260 -- ...tMoveService.TargetScaffoldingTombstone.cs | 431 --- ...diobookContentMoveService.TempOwnership.cs | 448 --- ...obookContentMoveService.TempPublication.cs | 82 - .../AudiobookContentMoveService.Validation.cs | 8 +- .../Moving/AudiobookContentMoveService.cs | 380 +-- ...ookFilesystemDeleteService.AuthorFolder.cs | 7 +- ...iobookFilesystemDeleteService.Ownership.cs | 28 - .../AudiobookFilesystemDeleteService.cs | 5 +- ...fLibraryDirectoryOwnershipStore.Helpers.cs | 50 +- ...oryOwnershipStore.MarkerlessReplacement.cs | 68 +- ...braryDirectoryOwnershipStore.Resolution.cs | 24 +- .../EfLibraryDirectoryOwnershipStore.State.cs | 136 - .../EfLibraryDirectoryOwnershipStore.cs | 35 +- .../Moving/EfMoveExecutionStore.Helpers.cs | 55 + .../Moving/EfMoveExecutionStore.Markerless.cs | 13 +- .../EfMoveExecutionStore.Scaffolding.cs | 56 +- .../Library/Moving/EfMoveExecutionStore.cs | 109 +- ...rectoryOwnershipMarker.MigrationCleanup.cs | 125 - ...LibraryDirectoryOwnershipMarker.Payload.cs | 61 - .../Moving/LibraryDirectoryOwnershipMarker.cs | 498 ---- .../LibraryDirectoryOwnershipReconciler.cs | 281 +- .../LibraryDirectoryOwnershipRemoval.cs | 233 +- .../Library/Moving/MoveExecutionStore.cs | 3 +- .../Moving/MoveFilesystemArtifactNames.cs | 16 - .../Library/Moving/MoveJobProcessor.Core.cs | 4 + .../Moving/MoveJobProcessor.Helpers.cs | 1 - .../MoveSourceCompanionManifestBuilder.cs | 5 - ...braryDirectoryOwnershipMarker.Migration.cs | 241 -- ...ibraryDirectoryOwnershipMarker.Recovery.cs | 51 - .../PinnedLibraryDirectoryOwnershipMarker.cs | 314 -- ...otFolderRelocationService.MetadataStart.cs | 263 +- ...derRelocationService.OwnershipMigration.cs | 215 +- ...rvice.OwnershipMigrationArtifactCleanup.cs | 53 - ...ationService.OwnershipMigrationRecovery.cs | 390 +-- ...ionService.OwnershipMigrationRetirement.cs | 371 --- ...tFolderRelocationService.TargetIdentity.cs | 73 +- .../AudiobookFileConfiguration.cs | 18 +- .../LibraryDirectoryOwnershipConfiguration.cs | 30 - .../Configurations/MoveJobConfiguration.cs | 13 +- .../Configurations/RootFolderConfiguration.cs | 17 +- ...aryDirectoryOwnershipMigrationPreflight.cs | 130 - .../Persistence/ListenArrDbContext.cs | 1 - .../ListenarrDatabaseMigrationPreflight.cs | 86 +- ...52_AddMoveJobDeleteEmptySource.Designer.cs | 1551 ---------- ...60703024452_AddMoveJobDeleteEmptySource.cs | 29 - ...3635_AddDurableFilesystemMoves.Designer.cs | 1762 ----------- ...0260708223635_AddDurableFilesystemMoves.cs | 253 -- ...AddMoveJobRelocationForeignKey.Designer.cs | 1777 ----------- ...4705_AddMoveJobLeaseGeneration.Designer.cs | 1782 ----------- ...0260708224705_AddMoveJobLeaseGeneration.cs | 29 - ...otFolderRelocationSkippedItems.Designer.cs | 1830 ------------ ...900_AddRootFolderRelocationSkippedItems.cs | 60 - ...otFolderRelocationRootNullable.Designer.cs | 1829 ------------ ...28_MakeRootFolderRelocationRootNullable.cs | 36 - ...FolderRelocationRootForeignKey.Designer.cs | 1814 ------------ ..._DropRootFolderRelocationRootForeignKey.cs | 30 - ...erRelocationRootDeleteBehavior.Designer.cs | 1829 ------------ ...tRootFolderRelocationRootDeleteBehavior.cs | 30 - ...ddMoveJobSourceCleanupBoundary.Designer.cs | 1833 ------------ ...0172532_AddMoveJobSourceCleanupBoundary.cs | 29 - ...enMoveExecutionAndScanHandoffs.Designer.cs | 1987 ------------- ...1804_HardenMoveExecutionAndScanHandoffs.cs | 202 -- ...3_AddLibraryDirectoryOwnership.Designer.cs | 2127 -------------- ...0717143713_AddLibraryDirectoryOwnership.cs | 205 -- ...oryObjectIdentityAuthorization.Designer.cs | 2170 -------------- ...AddDirectoryObjectIdentityAuthorization.cs | 144 - ..._AddOwnershipRecoveryProtocols.Designer.cs | 2469 ---------------- ...727000644_AddOwnershipRecoveryProtocols.cs | 184 -- ...entityAndMoveCleanupProtection.Designer.cs | 2486 ---------------- ...calFileIdentityAndMoveCleanupProtection.cs | 61 - ...LibraryDirectoryOwnershipRootForeignKey.cs | 30 - ...5192525_AddMarkerlessMoveExecutionState.cs | 96 - ...dMarkerlessFileMutationJournal.Designer.cs | 2595 ----------------- ...202154_AddMarkerlessFileMutationJournal.cs | 56 - ...DurableMarkerlessLibraryMoves.Designer.cs} | 257 +- ...200942_AddDurableMarkerlessLibraryMoves.cs | 972 ++++++ ...ddMoveJobRelocationForeignKey.Designer.cs} | 217 +- ...7204014_AddMoveJobRelocationForeignKey.cs} | 0 .../ListenArrDbContextModelSnapshot.cs | 142 +- .../EfMoveQueuePersistence.Reconciliation.cs | 59 +- ...QueuePersistence.ReconciliationEvidence.cs | 361 +-- ...LibraryController_DeleteFilesystemTests.cs | 13 - .../Library/LibraryController_MoveTests.cs | 2 +- .../Api/Services/FileMoverFallbackTests.cs | 2 +- .../Audiobooks/Jobs/MoveQueueServiceTests.cs | 2 +- .../Jobs/MoveRecoveryPolicyTests.cs | 46 +- .../RootFolders/RootFolderServiceTests.cs | 4 +- .../Core/StartupConfigServiceTests.cs | 14 +- ...uctureStartupCompositionExtensionsTests.cs | 71 +- .../DirectoryObjectIdentityResolverTests.cs | 2 +- ...obookContentMoveServiceCleanupRaceTests.cs | 667 ----- ...tentMoveServiceCopyDestinationRaceTests.cs | 330 --- ...kContentMoveServiceCopyPublicationTests.cs | 153 - ...ookContentMoveServiceEmptyManifestTests.cs | 8 +- ...okContentMoveServiceEndpointSafetyTests.cs | 46 +- ...bookContentMoveServiceLeaseFencingTests.cs | 324 -- ...iobookContentMoveServiceLinkSafetyTests.cs | 237 -- ...ontentMoveServiceMarkerPublicationTests.cs | 265 -- ...oveServiceNestedQuarantineRecoveryTests.cs | 66 - ...okContentMoveServiceObsoleteMarkerTests.cs | 135 - ...MoveServiceOwnershipMarkerRecoveryTests.cs | 454 --- ...diobookContentMoveServiceOwnershipTests.cs | 154 - ...ServiceQuarantineDirectoryCreationTests.cs | 159 - ...ContentMoveServiceQuarantineSafetyTests.cs | 368 --- ...okContentMoveServiceRecoverySafetyTests.cs | 440 --- ...ContentMoveServiceReservedArtifactTests.cs | 27 - ...ntentMoveServiceRootMutationSafetyTests.cs | 151 - ...veServiceSourceCleanupVerificationTests.cs | 176 -- ...veServiceTargetScaffoldingCreationTests.cs | 195 -- ...ontentMoveServiceTargetScaffoldingTests.cs | 678 +---- ...ntMoveServiceTempDirectoryCreationTests.cs | 309 -- .../AudiobookContentMoveServiceTests.cs | 917 +----- ...entMoveServiceTombstoneReplacementTests.cs | 103 - ...MoveServiceTruncatedMarkerRecoveryTests.cs | 566 ---- ...DirectoryCreationParentReplacementTests.cs | 8 - .../EfLibraryDirectoryOwnershipStoreTests.cs | 950 ++---- .../Moving/EfMoveExecutionStoreTests.cs | 142 +- .../MoveJobProcessorArtifactCleanupTests.cs | 340 --- .../MoveJobProcessorFinalizedRecoveryTests.cs | 6 - .../Library/Moving/MoveJobProcessorTests.cs | 205 +- ...yDirectoryOwnershipMarkerMigrationTests.cs | 116 - .../RootFolderRelocationServiceTests.cs | 612 +--- .../Migrations/MigrationMetadataTests.cs | 317 +- .../EfMoveQueuePersistenceTests.cs | 397 +-- ...RootFolderObjectIdentityReconcilerTests.cs | 2 +- .../Persistence/SqliteMigrationSchemaTests.cs | 1898 +++--------- 192 files changed, 3513 insertions(+), 57026 deletions(-) delete mode 100644 .github/pr717-cleanup-trigger delete mode 100644 .github/scripts/pr717_cleanup.py delete mode 100644 .github/scripts/pr717_cleanup_step3_extra.py delete mode 100644 .github/scripts/pr717_cleanup_step4.py delete mode 100644 .github/scripts/pr717_cleanup_step5.py delete mode 100644 .github/scripts/pr717_cleanup_step5_runner.py delete mode 100644 .github/workflows/pr717-cleanup-analysis.yml delete mode 100644 .github/workflows/pr717-cleanup-step5.yml delete mode 100644 listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.Atomic.cs delete mode 100644 listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.AtomicMarkerRetirement.cs delete mode 100644 listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.CleanupMutationSafety.cs delete mode 100644 listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.CopyDestinationRoot.cs delete mode 100644 listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.CopyStreaming.cs delete mode 100644 listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.LegacyRecoverySafety.cs delete mode 100644 listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.MarkerWriteFiles.cs delete mode 100644 listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.OwnershipCleanup.cs delete mode 100644 listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.OwnershipCleanupPreparation.cs delete mode 100644 listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.OwnershipMarkerPinnedRecovery.cs delete mode 100644 listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.OwnershipMarkers.cs delete mode 100644 listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.OwnershipPublication.cs delete mode 100644 listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.PinnedArtifactRetirement.cs delete mode 100644 listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.PinnedCopy.cs delete mode 100644 listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.PinnedOwnershipMarkers.cs delete mode 100644 listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.PinnedSourceCleanup.cs delete mode 100644 listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.QuarantineOwnership.cs delete mode 100644 listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.Recovery.cs delete mode 100644 listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.RecoveryMarkers.cs delete mode 100644 listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.RecoveryPublication.cs delete mode 100644 listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.SourceCleanup.cs delete mode 100644 listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.SourceRootQuarantine.cs delete mode 100644 listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.TargetScaffoldingFaults.cs delete mode 100644 listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.TargetScaffoldingMarkers.cs delete mode 100644 listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.TargetScaffoldingPreparation.cs delete mode 100644 listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.TargetScaffoldingTombstone.cs delete mode 100644 listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.TempOwnership.cs delete mode 100644 listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.TempPublication.cs delete mode 100644 listenarr.infrastructure/Library/Moving/LibraryDirectoryOwnershipMarker.MigrationCleanup.cs delete mode 100644 listenarr.infrastructure/Library/Moving/LibraryDirectoryOwnershipMarker.Payload.cs delete mode 100644 listenarr.infrastructure/Library/Moving/LibraryDirectoryOwnershipMarker.cs delete mode 100644 listenarr.infrastructure/Library/Moving/MoveFilesystemArtifactNames.cs delete mode 100644 listenarr.infrastructure/Library/Moving/PinnedLibraryDirectoryOwnershipMarker.Migration.cs delete mode 100644 listenarr.infrastructure/Library/Moving/PinnedLibraryDirectoryOwnershipMarker.Recovery.cs delete mode 100644 listenarr.infrastructure/Library/Moving/PinnedLibraryDirectoryOwnershipMarker.cs delete mode 100644 listenarr.infrastructure/Library/Moving/RootFolderRelocationService.OwnershipMigrationArtifactCleanup.cs delete mode 100644 listenarr.infrastructure/Library/Moving/RootFolderRelocationService.OwnershipMigrationRetirement.cs delete mode 100644 listenarr.infrastructure/Persistence/LibraryDirectoryOwnershipMigrationPreflight.cs delete mode 100644 listenarr.infrastructure/Persistence/Migrations/20260703024452_AddMoveJobDeleteEmptySource.Designer.cs delete mode 100644 listenarr.infrastructure/Persistence/Migrations/20260703024452_AddMoveJobDeleteEmptySource.cs delete mode 100644 listenarr.infrastructure/Persistence/Migrations/20260708223635_AddDurableFilesystemMoves.Designer.cs delete mode 100644 listenarr.infrastructure/Persistence/Migrations/20260708223635_AddDurableFilesystemMoves.cs delete mode 100644 listenarr.infrastructure/Persistence/Migrations/20260708224312_AddMoveJobRelocationForeignKey.Designer.cs delete mode 100644 listenarr.infrastructure/Persistence/Migrations/20260708224705_AddMoveJobLeaseGeneration.Designer.cs delete mode 100644 listenarr.infrastructure/Persistence/Migrations/20260708224705_AddMoveJobLeaseGeneration.cs delete mode 100644 listenarr.infrastructure/Persistence/Migrations/20260708224900_AddRootFolderRelocationSkippedItems.Designer.cs delete mode 100644 listenarr.infrastructure/Persistence/Migrations/20260708224900_AddRootFolderRelocationSkippedItems.cs delete mode 100644 listenarr.infrastructure/Persistence/Migrations/20260708225028_MakeRootFolderRelocationRootNullable.Designer.cs delete mode 100644 listenarr.infrastructure/Persistence/Migrations/20260708225028_MakeRootFolderRelocationRootNullable.cs delete mode 100644 listenarr.infrastructure/Persistence/Migrations/20260708225100_DropRootFolderRelocationRootForeignKey.Designer.cs delete mode 100644 listenarr.infrastructure/Persistence/Migrations/20260708225100_DropRootFolderRelocationRootForeignKey.cs delete mode 100644 listenarr.infrastructure/Persistence/Migrations/20260708225144_SetRootFolderRelocationRootDeleteBehavior.Designer.cs delete mode 100644 listenarr.infrastructure/Persistence/Migrations/20260708225144_SetRootFolderRelocationRootDeleteBehavior.cs delete mode 100644 listenarr.infrastructure/Persistence/Migrations/20260710172532_AddMoveJobSourceCleanupBoundary.Designer.cs delete mode 100644 listenarr.infrastructure/Persistence/Migrations/20260710172532_AddMoveJobSourceCleanupBoundary.cs delete mode 100644 listenarr.infrastructure/Persistence/Migrations/20260713181804_HardenMoveExecutionAndScanHandoffs.Designer.cs delete mode 100644 listenarr.infrastructure/Persistence/Migrations/20260713181804_HardenMoveExecutionAndScanHandoffs.cs delete mode 100644 listenarr.infrastructure/Persistence/Migrations/20260717143713_AddLibraryDirectoryOwnership.Designer.cs delete mode 100644 listenarr.infrastructure/Persistence/Migrations/20260717143713_AddLibraryDirectoryOwnership.cs delete mode 100644 listenarr.infrastructure/Persistence/Migrations/20260726042801_AddDirectoryObjectIdentityAuthorization.Designer.cs delete mode 100644 listenarr.infrastructure/Persistence/Migrations/20260726042801_AddDirectoryObjectIdentityAuthorization.cs delete mode 100644 listenarr.infrastructure/Persistence/Migrations/20260727000644_AddOwnershipRecoveryProtocols.Designer.cs delete mode 100644 listenarr.infrastructure/Persistence/Migrations/20260727000644_AddOwnershipRecoveryProtocols.cs delete mode 100644 listenarr.infrastructure/Persistence/Migrations/20260730033245_AddPhysicalFileIdentityAndMoveCleanupProtection.Designer.cs delete mode 100644 listenarr.infrastructure/Persistence/Migrations/20260730033245_AddPhysicalFileIdentityAndMoveCleanupProtection.cs delete mode 100644 listenarr.infrastructure/Persistence/Migrations/20260805034058_AddLibraryDirectoryOwnershipRootForeignKey.cs delete mode 100644 listenarr.infrastructure/Persistence/Migrations/20260805192525_AddMarkerlessMoveExecutionState.cs delete mode 100644 listenarr.infrastructure/Persistence/Migrations/20260805202154_AddMarkerlessFileMutationJournal.Designer.cs delete mode 100644 listenarr.infrastructure/Persistence/Migrations/20260805202154_AddMarkerlessFileMutationJournal.cs rename listenarr.infrastructure/Persistence/Migrations/{20260805034058_AddLibraryDirectoryOwnershipRootForeignKey.Designer.cs => 20260807200942_AddDurableMarkerlessLibraryMoves.Designer.cs} (95%) create mode 100644 listenarr.infrastructure/Persistence/Migrations/20260807200942_AddDurableMarkerlessLibraryMoves.cs rename listenarr.infrastructure/Persistence/Migrations/{20260805192525_AddMarkerlessMoveExecutionState.Designer.cs => 20260807204014_AddMoveJobRelocationForeignKey.Designer.cs} (96%) rename listenarr.infrastructure/Persistence/Migrations/{20260708224312_AddMoveJobRelocationForeignKey.cs => 20260807204014_AddMoveJobRelocationForeignKey.cs} (100%) delete mode 100644 tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceCleanupRaceTests.cs delete mode 100644 tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceCopyDestinationRaceTests.cs delete mode 100644 tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceCopyPublicationTests.cs delete mode 100644 tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceLeaseFencingTests.cs delete mode 100644 tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceMarkerPublicationTests.cs delete mode 100644 tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceNestedQuarantineRecoveryTests.cs delete mode 100644 tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceObsoleteMarkerTests.cs delete mode 100644 tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceOwnershipMarkerRecoveryTests.cs delete mode 100644 tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceOwnershipTests.cs delete mode 100644 tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceQuarantineDirectoryCreationTests.cs delete mode 100644 tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceQuarantineSafetyTests.cs delete mode 100644 tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceRecoverySafetyTests.cs delete mode 100644 tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceReservedArtifactTests.cs delete mode 100644 tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceRootMutationSafetyTests.cs delete mode 100644 tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceSourceCleanupVerificationTests.cs delete mode 100644 tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceTargetScaffoldingCreationTests.cs delete mode 100644 tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceTempDirectoryCreationTests.cs delete mode 100644 tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceTombstoneReplacementTests.cs delete mode 100644 tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceTruncatedMarkerRecoveryTests.cs delete mode 100644 tests/Features/Infrastructure/Library/Moving/PinnedLibraryDirectoryOwnershipMarkerMigrationTests.cs diff --git a/.github/pr717-cleanup-trigger b/.github/pr717-cleanup-trigger deleted file mode 100644 index 747266d1b..000000000 --- a/.github/pr717-cleanup-trigger +++ /dev/null @@ -1 +0,0 @@ -trigger step 5 gated cleanup after build finding diff --git a/.github/scripts/pr717_cleanup.py b/.github/scripts/pr717_cleanup.py deleted file mode 100644 index cf4b30953..000000000 --- a/.github/scripts/pr717_cleanup.py +++ /dev/null @@ -1,270 +0,0 @@ -from pathlib import Path - - -def read(path: str) -> str: - return Path(path).read_text() - - -def write(path: str, text: str) -> None: - Path(path).write_text(text) - - -def replace_once(path: str, old: str, new: str) -> None: - text = read(path) - if old not in text: - raise SystemExit(f"missing expected block in {path}: {old[:100]!r}") - write(path, text.replace(old, new, 1)) - - -def find_matching_brace(text: str, brace: int) -> int: - depth = 0 - quote = None - escaped = False - index = brace - while index < len(text): - char = text[index] - if quote: - if escaped: - escaped = False - elif char == "\\": - escaped = True - elif char == quote: - quote = None - else: - if char in ("'", '"'): - quote = char - elif char == "{": - depth += 1 - elif char == "}": - depth -= 1 - if depth == 0: - return index + 1 - index += 1 - raise SystemExit("unbalanced brace") - - -def remove_decl(path: str, needle: str, include_attributes: bool = False) -> None: - text = read(path) - position = text.find(needle) - if position < 0: - raise SystemExit(f"missing declaration {needle!r} in {path}") - line_start = text.rfind("\n", 0, position) + 1 - start = line_start - if include_attributes: - while start > 0: - previous_end = start - 1 - previous_start = text.rfind("\n", 0, previous_end) + 1 - previous = text[previous_start : previous_end + 1].strip() - if previous.startswith("["): - start = previous_start - continue - break - brace = text.find("{", position) - if brace < 0: - raise SystemExit(f"no brace for {needle!r}") - end = find_matching_brace(text, brace) - while end < len(text) and text[end] in " \t\r\n": - end += 1 - write(path, text[:start] + text[end:]) - - -def remove_test_call(path: str, title: str) -> None: - text = read(path) - token = f" it('{title}'" - position = text.find(token) - if position < 0: - raise SystemExit(f"missing vitest {title!r}") - arrow = text.find("=>", position) - brace = text.find("{", arrow) - end = find_matching_brace(text, brace) - while end < len(text) and text[end] in " \t\r\n": - end += 1 - if text.startswith(")", end): - end += 1 - if text.startswith(";", end): - end += 1 - while end < len(text) and text[end] in " \t\r\n": - end += 1 - write(path, text[:position] + text[end:]) - - -replace_once( - "listenarr.application/Audiobooks/Contracts/IRootFolderRelocationService.cs", - """ Task ReauthorizeLegacyTargetAsync( - Guid relocationId, - string confirmedTargetPath, - CancellationToken cancellationToken = default); - -""", - "", -) -replace_once( - "listenarr.application/Audiobooks/Contracts/RootFolderRelocationPublicProjection.cs", - """ TargetIdentityEnrollmentState.LegacyUnenrolled => - "The relocation target must be reauthorized before the relocation can continue.", -""", - "", -) -replace_once( - "listenarr.domain/Audiobooks/RootFolderRelocation.cs", - " LegacyUnenrolled,\n", - "", -) -remove_decl( - "listenarr.domain/Audiobooks/RootFolderRelocation.cs", - "public static class TargetIdentityEnrollment", -) -replace_once( - "listenarr.infrastructure/Persistence/RootFolderObjectIdentityReconciler.cs", - """ var relocations = await db.RootFolderRelocations - .ToListAsync(cancellationToken); - foreach (var relocation in relocations) - { - relocation.TargetIdentityEnrollmentState = - TargetIdentityEnrollment.Classify(relocation); - } - -""", - "", -) -replace_once( - "listenarr.infrastructure/Library/Moving/RootFolderRelocationService.Retry.cs", - """ if (relocation.TargetIdentityEnrollmentState - == TargetIdentityEnrollmentState.LegacyUnenrolled) - { - throw new InvalidOperationException( - "The legacy relocation target must be explicitly reauthorized before retry."); - } -""", - "", -) -replace_once( - "listenarr.api/Features/Library/RootFolderRelocationsController.cs", - " public sealed record ReauthorizeLegacyTargetRequest(string ConfirmedTargetPath);\n\n", - "", -) -remove_decl( - "listenarr.api/Features/Library/RootFolderRelocationsController.cs", - "public async Task ReauthorizeLegacyTarget(", - include_attributes=True, -) - -reauthorization = Path( - "listenarr.infrastructure/Library/Moving/RootFolderRelocationService.Reauthorization.cs" -) -if not reauthorization.exists(): - raise SystemExit("missing relocation reauthorization implementation") -reauthorization.unlink() - -replace_once( - "fe/src/types/index.ts", - " targetIdentityEnrollmentState: 'NotRequired' | 'Authorized' | 'LegacyUnenrolled' | 'Unavailable'\n", - " targetIdentityEnrollmentState: 'NotRequired' | 'Authorized' | 'Unavailable'\n", -) -remove_decl( - "fe/src/services/api.ts", - "async reauthorizeLegacyRootFolderRelocationTarget(", -) -remove_decl( - "fe/src/stores/rootFolders.ts", - "async function reauthorizeLegacyTarget(", -) -replace_once( - "fe/src/stores/rootFolders.ts", - " reauthorizeLegacyTarget,\n", - "", -) - -vue = "fe/src/components/settings/RootFoldersSettings.vue" -replace_once( - vue, - """ -""", - "", -) -replace_once( - vue, - """ - - - -""", - "", -) -replace_once( - vue, - "import type { RootFolder, RootFolderPathChangeResult } from '@/types'\n", - "import type { RootFolder } from '@/types'\n", -) -replace_once( - vue, - """const relocationToReauthorize = ref<{ - relocationId: string - targetPath: string -} | null>(null) -""", - "", -) -for needle in ( - "function canReauthorizeLegacyTarget(", - "function confirmLegacyTargetReauthorization(", - "async function executeLegacyTargetReauthorization(", -): - remove_decl(vue, needle) - -api_test = Path("fe/src/__tests__/api.rootFolderRelocationReauthorization.spec.ts") -if not api_test.exists(): - raise SystemExit("missing dedicated relocation reauth api test") -api_test.unlink() -remove_test_call( - "fe/src/__tests__/RootFoldersSettings.spec.ts", - "shows legacy reauthorization separately and confirms the exact target path", -) - -for method in ( - "ReauthorizeLegacyTarget_ExistingMoveJob_BindsConfirmedTargetGenerationBeforeRetry", - "ReauthorizeLegacyTarget_ContradictoryChildAuthorization_RejectsBeforeTargetEnrollment", - "ReauthorizeLegacyTarget_RequestCancelledAfterAuthorization_CompletesRetry", -): - remove_decl( - "tests/Features/Infrastructure/Library/Moving/RootFolderRelocationServiceTests.cs", - method, - include_attributes=True, - ) - -path = Path("tests/Features/Domain/Audiobooks/RootFolderRelocationStateTests.cs") -text = path.read_text() -start = text.find( - " [Theory]\n [InlineData(\n RootFolderRelocationStatus.Pending" -) -if start < 0: - raise SystemExit("missing target enrollment classifier theory") -class_close = text.rfind("}") -if class_close <= start: - raise SystemExit("invalid classifier test class structure") -path.write_text(text[:start] + text[class_close:]) diff --git a/.github/scripts/pr717_cleanup_step3_extra.py b/.github/scripts/pr717_cleanup_step3_extra.py deleted file mode 100644 index bc60248b9..000000000 --- a/.github/scripts/pr717_cleanup_step3_extra.py +++ /dev/null @@ -1,42 +0,0 @@ -from pathlib import Path - -store_test = Path("fe/src/__tests__/rootFolders.reauthorization.store.spec.ts") -text = store_test.read_text() -block = """ it('passes the exact confirmed target path and reloads root folders', async () => { - const targetPath = '/srv/Audiobooks ' - const result: RootFolderPathChangeResult = { - relocationId: 'relocation-1', - rootFolderId: 3, - currentPath: '/srv/Old', - targetPath, - status: 'Running', - totalJobs: 1, - completedJobs: 0, - targetIdentityEnrollmentState: 'Authorized', - } - vi.mocked(apiService.reauthorizeLegacyRootFolderRelocationTarget).mockResolvedValueOnce(result) - vi.mocked(apiService.getRootFolders).mockResolvedValueOnce([]) - const store = useRootFoldersStore() - - await expect(store.reauthorizeLegacyTarget('relocation-1', targetPath)).resolves.toEqual(result) - - expect(apiService.reauthorizeLegacyRootFolderRelocationTarget).toHaveBeenCalledWith( - 'relocation-1', - targetPath, - ) - expect(apiService.getRootFolders).toHaveBeenCalledTimes(1) - }) -""" -if block not in text: - raise SystemExit("missing exact legacy relocation store test") -text = text.replace(block, "", 1) -if text.count("RootFolderPathChangeResult") == 1: - text = text.replace("import type { RootFolderPathChangeResult } from '@/types'\n", "", 1) -store_test.write_text(text) - -setup = Path("fe/src/__tests__/test-setup.ts") -text = setup.read_text() -line = " reauthorizeLegacyRootFolderRelocationTarget: vi.fn(async () => ({})),\n" -if line not in text: - raise SystemExit("missing legacy relocation API mock") -setup.write_text(text.replace(line, "", 1)) diff --git a/.github/scripts/pr717_cleanup_step4.py b/.github/scripts/pr717_cleanup_step4.py deleted file mode 100644 index 61b0718fb..000000000 --- a/.github/scripts/pr717_cleanup_step4.py +++ /dev/null @@ -1,347 +0,0 @@ -from pathlib import Path -import re - - -def read(path: str) -> str: - return Path(path).read_text() - - -def write(path: str, text: str) -> None: - Path(path).write_text(text) - - -def replace_once(path: str, old: str, new: str = "") -> None: - text = read(path) - if old not in text: - raise SystemExit(f"missing expected block in {path}: {old[:120]!r}") - write(path, text.replace(old, new, 1)) - - -def find_matching_brace(text: str, brace: int) -> int: - depth = 0 - quote = None - escaped = False - index = brace - while index < len(text): - char = text[index] - if quote: - if escaped: - escaped = False - elif char == "\\": - escaped = True - elif char == quote: - quote = None - else: - if char in ("'", '"'): - quote = char - elif char == "{": - depth += 1 - elif char == "}": - depth -= 1 - if depth == 0: - return index + 1 - index += 1 - raise SystemExit("unbalanced brace") - - -def remove_decl(path: str, needle: str, include_attributes: bool = False) -> None: - text = read(path) - position = text.find(needle) - if position < 0: - raise SystemExit(f"missing declaration {needle!r} in {path}") - start = text.rfind("\n", 0, position) + 1 - if include_attributes: - while start > 0: - previous_end = start - 1 - previous_start = text.rfind("\n", 0, previous_end) + 1 - previous = text[previous_start : previous_end + 1].strip() - if previous.startswith("["): - start = previous_start - continue - break - brace = text.find("{", position) - if brace < 0: - raise SystemExit(f"no brace for {needle!r}") - end = find_matching_brace(text, brace) - while end < len(text) and text[end] in " \t\r\n": - end += 1 - write(path, text[:start] + text[end:]) - - -def remove_false_marker_assertions(path: str) -> None: - text = read(path) - pattern = re.compile( - r"\n\s*Assert\.False\(File\.Exists\(Path\.Join\(\s*[^,\n]+,\s*ManagedDirectoryEnrollment\.FileName\s*\)\)\);", - re.MULTILINE, - ) - write(path, pattern.sub("", text)) - - -# Production: remove the reader/retirer and every fallback that depends on it. -legacy_file = Path("listenarr.infrastructure/FileSystem/ManagedDirectoryEnrollment.cs") -if not legacy_file.exists(): - raise SystemExit("missing ManagedDirectoryEnrollment.cs") -legacy_file.unlink() - -replace_once( - "listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.SourceValidation.cs", - """ if (string.Equals( - entryName, - ManagedDirectoryEnrollment.FileName, - StringComparison.Ordinal) - && IsSourceCleanupBoundary( - source, - persistentManagedRootBoundary, - sourceSemantics) - && FileSystemPathIdentity.AreEquivalent( - Path.GetDirectoryName(entry)!, - source, - sourceSemantics)) - { - var enrollmentAttributes = File.GetAttributes(entry); - if ((enrollmentAttributes & (FileAttributes.Directory | FileAttributes.ReparsePoint)) != 0) - { - throw new MoveNeedsAttentionException( - "The managed-root enrollment artifact changed type or became linked."); - } - - // The root enrollment belongs to the persistent cleanup boundary, - // not to the audiobook. Leave it in place and exclude it from the - // move manifest/companion sweep. - continue; - } - -""", -) -replace_once( - "listenarr.infrastructure/Library/Moving/EfMoveExecutionStore.Helpers.cs", - """ if (!string.Equals( - currentDigest, - expectedDigest, - StringComparison.OrdinalIgnoreCase)) - { - // Last-resort compatibility for jobs created before a configured-root - // identity was available in the database. Read an existing legacy - // marker only; never create one. - var legacy = ManagedDirectoryEnrollment.ResolveExisting( - boundary, - nativeIdentity); - currentDigest = legacy.IsAvailable && legacy.Version == currentVersion - ? MoveManifestIdentity.ComputeTargetBoundaryAuthorizationDigest( - currentVersion, - legacy.Value!) - : currentDigest; - } - -""", -) -replace_once( - "listenarr.infrastructure/Library/Moving/MoveFilesystemArtifactNames.cs", - " || string.Equals(name, ManagedDirectoryEnrollment.FileName, StringComparison.Ordinal)\n", -) -replace_once( - "listenarr.infrastructure/Library/Moving/RootFolderRelocationService.TargetReservationPersistence.cs", - " ManagedDirectoryEnrollment.RetireValidMarker(directory);\n", -) -replace_once( - "listenarr.infrastructure/Persistence/RootFolderObjectIdentityReconciler.cs", - """ TryRetireLegacyRootEnrollmentMarker( - canonicalRootPath, - root, - logger); -""", -) -remove_decl( - "listenarr.infrastructure/Persistence/RootFolderObjectIdentityReconciler.cs", - "private static void TryRetireLegacyRootEnrollmentMarker(", -) - -# Root identity version 1 existed only in intermediate #717 builds. -replace_once( - "listenarr.application/Audiobooks/Contracts/IDirectoryObjectIdentityResolver.cs", - """ Task UpgradeLegacyAsync( - string path, - int legacyVersion, - string legacyValue, - CancellationToken cancellationToken = default); -""", -) -remove_decl( - "listenarr.infrastructure/FileSystem/DirectoryObjectIdentityResolver.cs", - "public Task UpgradeLegacyAsync(", -) -replace_once( - "listenarr.infrastructure/Persistence/RootFolderObjectIdentityReconciler.cs", - """ else if (root.DirectoryObjectIdentityVersion == 1) - { - current = await identityResolver.UpgradeLegacyAsync( - canonicalRootPath, - root.DirectoryObjectIdentityVersion.Value, - root.DirectoryObjectIdentity, - cancellationToken); - } -""", -) - -# Architecture gate becomes a negative production-surface rule. -replace_once( - "tests/Features/Architecture/BackendArchitectureTests.cs", - """ [Fact] - public void RootDirectoryIdentity_DoesNotPublishPermanentFilesystemEnrollment() - { - var legacyEnrollmentSource = File.ReadAllText(Path.Join( - RepositoryRoot, - "listenarr.infrastructure", - "FileSystem", - "ManagedDirectoryEnrollment.cs")); - var resolverSource = File.ReadAllText(Path.Join( - RepositoryRoot, - "listenarr.infrastructure", - "FileSystem", - "DirectoryObjectIdentityResolver.cs")); - - Assert.DoesNotContain( - "PublishNewFileAsync", - legacyEnrollmentSource, - StringComparison.Ordinal); - Assert.DoesNotContain( - "enrollIfMissing", - legacyEnrollmentSource, - StringComparison.Ordinal); - Assert.DoesNotContain( - "ManagedDirectoryEnrollment", - resolverSource, - StringComparison.Ordinal); - } - -""", - """ [Fact] - public void RootDirectoryIdentity_HasNoIntermediateFilesystemEnrollmentCompatibility() - { - Assert.False(File.Exists(Path.Join( - RepositoryRoot, - "listenarr.infrastructure", - "FileSystem", - "ManagedDirectoryEnrollment.cs"))); - - var productionRoots = new[] - { - Path.Join(RepositoryRoot, "listenarr.application"), - Path.Join(RepositoryRoot, "listenarr.domain"), - Path.Join(RepositoryRoot, "listenarr.infrastructure"), - Path.Join(RepositoryRoot, "listenarr.api") - }; - var violations = productionRoots - .SelectMany(root => Directory.EnumerateFiles(root, "*.cs", SearchOption.AllDirectories)) - .Where(file => - { - var source = File.ReadAllText(file); - return source.Contains("ManagedDirectoryEnrollment", StringComparison.Ordinal) - || source.Contains(".listenarr-root-enrollment.json", StringComparison.Ordinal) - || source.Contains("UpgradeLegacyAsync", StringComparison.Ordinal); - }) - .Select(file => Normalize(Path.GetRelativePath(RepositoryRoot, file))) - .ToList(); - - Assert.Empty(violations); - } - -""", -) - -# Tests dedicated to the discarded marker/version transition are removed. -remove_decl( - "tests/Features/Application/Audiobooks/RootFolders/RootFolderServiceTests.cs", - "public async Task ReauthorizeDirectoryIdentity_InvalidLegacyMarker_IsIgnoredAndPreserved()", - include_attributes=True, -) -remove_decl( - "tests/Features/Application/Audiobooks/RootFolders/RootFolderServiceTests.cs", - "public async Task Create_NestedRejectedRoot_DoesNotEnrollCandidateDirectory()", - include_attributes=True, -) -remove_decl( - "tests/Features/Infrastructure/FileSystem/DirectoryObjectIdentityResolverTests.cs", - "public async Task ResolveExistingAsync_ForeignMarkerCannotAuthorizeDifferentNativeGeneration()", - include_attributes=True, -) -remove_decl( - "tests/Features/Infrastructure/FileSystem/DirectoryObjectIdentityResolverTests.cs", - "public async Task UpgradeLegacyAsync_MatchingNativeIdentity_ProducesMarkerlessVersionTwo()", - include_attributes=True, -) -remove_decl( - "tests/Features/Infrastructure/FileSystem/DirectoryObjectIdentityResolverTests.cs", - "public async Task UpgradeLegacyAsync_MismatchedNativeIdentity_FailsClosedWithoutMarker()", - include_attributes=True, -) -# Foreign-syntax test should exercise only final APIs. -file = "tests/Features/Infrastructure/FileSystem/DirectoryObjectIdentityResolverTests.cs" -replace_once( - file, - """ var legacy = await resolver.UpgradeLegacyAsync( - foreignPath, - legacyVersion: 1, - legacyValue: "persisted-foreign-native-identity"); - - foreach (var candidate in new[] { resolution, existing, legacy }) -""", - """ foreach (var candidate in new[] { resolution, existing }) -""", -) -remove_decl( - "tests/Features/Infrastructure/Persistence/RootFolderObjectIdentityReconcilerTests.cs", - "public async Task ReconcileAsync_LegacyVersionTwoIdentityWithoutMarker_RemainsAuthorized()", - include_attributes=True, -) -remove_decl( - "tests/Features/Infrastructure/Persistence/RootFolderObjectIdentityReconcilerTests.cs", - "public async Task ReconcileAsync_MatchingLegacyEnrollmentMarker_RetiresMarkerAndKeepsDatabaseIdentity()", - include_attributes=True, -) -remove_decl( - "tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceTests.cs", - "public async Task MoveContents_SourceAtManagedRoot_DoesNotCreateRootEnrollmentMarker()", - include_attributes=True, -) -replace_once( - "tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceTests.cs", - " || string.Equals(name, ManagedDirectoryEnrollment.FileName, StringComparison.Ordinal)\n", -) - -# Keep useful final-behavior tests, but remove assertions that existed only to prove -# the discarded marker was not written. -for path in ( - "tests/Features/Application/Audiobooks/RootFolders/RootFolderServiceTests.cs", - "tests/Features/Infrastructure/FileSystem/DirectoryObjectIdentityResolverTests.cs", - "tests/Features/Infrastructure/Library/Moving/RootFolderRelocationServiceTests.cs", - "tests/Features/Infrastructure/Persistence/RootFolderObjectIdentityReconcilerTests.cs", -): - remove_false_marker_assertions(path) - -# One relocation test used the literal marker only as a negative side-effect assertion. -relocation_tests = "tests/Features/Infrastructure/Library/Moving/RootFolderRelocationServiceTests.cs" -text = read(relocation_tests) -text = text.replace( - """ var replacementEnrollment = Path.Join( - target, - ".listenarr-root-enrollment.json"); - Assert.False(File.Exists(replacementEnrollment)); - -""", - "", - 1, -) -text = text.replace(" Assert.False(File.Exists(replacementEnrollment));\n", "", 1) -text = text.replace( - "FinalizeCompletedRelocation_ReplacedTargetWithoutEnrollment_DoesNotEnrollReplacement", - "FinalizeCompletedRelocation_ReplacedTargetWithoutAuthorization_DoesNotCommitReplacement", - 1, -) -write(relocation_tests, text) - -# Remaining test-only references should be absent; fail early with a useful list. -for path in Path("tests").rglob("*.cs"): - source = path.read_text() - if "ManagedDirectoryEnrollment" in source or ".listenarr-root-enrollment.json" in source or "UpgradeLegacyAsync" in source: - raise SystemExit(f"stale root-enrollment compatibility remains in {path}") diff --git a/.github/scripts/pr717_cleanup_step5.py b/.github/scripts/pr717_cleanup_step5.py deleted file mode 100644 index 3bcce2bbc..000000000 --- a/.github/scripts/pr717_cleanup_step5.py +++ /dev/null @@ -1,402 +0,0 @@ -from pathlib import Path - - -def read(path: str) -> str: - return Path(path).read_text() - - -def write(path: str, text: str) -> None: - Path(path).write_text(text) - - -def replace_once(path: str, old: str, new: str = "") -> None: - text = read(path) - if old not in text: - raise SystemExit(f"missing expected block in {path}: {old[:120]!r}") - write(path, text.replace(old, new, 1)) - - -def find_matching_brace(text: str, brace: int) -> int: - depth = 0 - quote = None - escaped = False - index = brace - while index < len(text): - char = text[index] - if quote: - if escaped: - escaped = False - elif char == "\\": - escaped = True - elif char == quote: - quote = None - else: - if char in ("'", '"'): - quote = char - elif char == "{": - depth += 1 - elif char == "}": - depth -= 1 - if depth == 0: - return index + 1 - index += 1 - raise SystemExit("unbalanced brace") - - -def remove_decl(path: str, needle: str, include_attributes: bool = False) -> None: - text = read(path) - position = text.find(needle) - if position < 0: - raise SystemExit(f"missing declaration {needle!r} in {path}") - start = text.rfind("\n", 0, position) + 1 - if include_attributes: - while start > 0: - previous_end = start - 1 - previous_start = text.rfind("\n", 0, previous_end) + 1 - previous = text[previous_start : previous_end + 1].strip() - if previous.startswith("["): - start = previous_start - continue - break - brace = text.find("{", position) - if brace < 0: - raise SystemExit(f"no brace for {needle!r}") - end = find_matching_brace(text, brace) - while end < len(text) and text[end] in " \t\r\n": - end += 1 - write(path, text[:start] + text[end:]) - - -# No version-1 ownership marker existed on canary. Keep only the final payload. -remove_decl( - "listenarr.infrastructure/Library/Moving/LibraryDirectoryOwnershipMarker.Payload.cs", - "internal static bool MatchesLegacyPayload(", -) - -# The marker reconciler/upgrade path exists only to upgrade older #717 marker formats. -for needle in ( - "internal static async Task ReconcileAsync(", - "private static async Task ReconcileMarkerAsync(", - "private static async Task UpgradeLegacyMarkerAsync(", -): - remove_decl( - "listenarr.infrastructure/Library/Moving/PinnedLibraryDirectoryOwnershipMarker.cs", - needle, - ) - -# Current relocation migration may retire either the source or target generation, -# but never a version-1 marker payload. -replace_once( - "listenarr.infrastructure/Library/Moving/LibraryDirectoryOwnershipMarker.MigrationCleanup.cs", - """ MatchesCurrentPayload(source, payload) - || MatchesLegacyPayload(source, payload) - || MatchesCurrentPayload(target, payload) - || MatchesLegacyPayload(target, payload);""", - """ MatchesCurrentPayload(source, payload) - || MatchesCurrentPayload(target, payload);""", -) -replace_once( - "listenarr.infrastructure/Library/Moving/PinnedLibraryDirectoryOwnershipMarker.Migration.cs", - """ LibraryDirectoryOwnershipMarker.MatchesCurrentPayload(source, payload) - || LibraryDirectoryOwnershipMarker.MatchesLegacyPayload(source, payload);""", - """ LibraryDirectoryOwnershipMarker.MatchesCurrentPayload(source, payload);""", -) - -# Marker retirement accepts only the final persisted payload shape. -path = "listenarr.infrastructure/Library/Moving/LibraryDirectoryOwnershipMarker.cs" -text = read(path) -text = text.replace( - """ if (!MatchesCurrentPayload(ownership, payload) - && !MatchesLegacyPayload(ownership, payload)) - { - throw new InvalidOperationException( - "A legacy directory ownership artifact does not match the persisted ownership claim."); - } -""", - """ if (!MatchesCurrentPayload(ownership, payload)) - { - throw new InvalidOperationException( - "A directory ownership artifact does not match the persisted ownership claim."); - } -""", - 1, -) -text = text.replace( - """ throw new InvalidOperationException( - "A legacy directory ownership artifact changed before retirement.");""", - """ throw new InvalidOperationException( - "A directory ownership artifact changed before retirement.");""", -) -text = text.replace( - """ if (!MatchesCurrentPayload(ownership, verifiedPayload) - && !MatchesLegacyPayload(ownership, verifiedPayload)) -""", - """ if (!MatchesCurrentPayload(ownership, verifiedPayload)) -""", - 1, -) -write(path, text) - -# Final ownership removal has no legacy quarantine or missing-both marker format. -path = "listenarr.infrastructure/Library/Moving/LibraryDirectoryOwnershipRemoval.cs" -remove_decl(path, "public static bool TryValidateLegacyMissingBothRecovery(") -text = read(path) -text = text.replace( - """ var quarantinePath = GetQuarantinePath(ownership); - var quarantineExists = Directory.Exists(quarantinePath); - var quarantineIsFile = File.Exists(quarantinePath); - if (originalIsFile || quarantineIsFile) - { - throw new InvalidOperationException( - "An owned directory recovery path is occupied by a file."); - } - if (originalExists && quarantineExists) - { - throw new InvalidOperationException( - "Both the owned directory and its removal quarantine exist."); - } - - if (!originalExists && !quarantineExists) -""", - """ if (originalIsFile) - { - throw new InvalidOperationException( - "The owned directory recovery path is occupied by a file."); - } - - if (!originalExists) -""", - 1, -) -text = text.replace( - """ var visiblePath = originalExists - ? ownership.CanonicalPath - : quarantinePath; - var parentPath = Path.GetDirectoryName(visiblePath) -""", - """ var parentPath = Path.GetDirectoryName(ownership.CanonicalPath) -""", - 1, -) -text = text.replace( - """ using var directory = parent.OpenExistingChild(Path.GetFileName(visiblePath));""", - """ using var directory = parent.OpenExistingChild(Path.GetFileName(ownership.CanonicalPath));""", - 1, -) -# Remove quarantine state from actual deletion path. -old_start = """ var quarantinePath = GetQuarantinePath(ownership); - var originalExists = Directory.Exists(originalPath); - var originalIsFile = File.Exists(originalPath); - var quarantineExists = Directory.Exists(quarantinePath); - var quarantineIsFile = File.Exists(quarantinePath); - if (originalIsFile || quarantineIsFile) - { - throw new InvalidOperationException( - "An owned directory removal path is occupied by a file."); - } - if (originalExists && quarantineExists) - { - throw new InvalidOperationException( - "Both the owned directory and its removal quarantine exist."); - } -""" -new_start = """ var originalExists = Directory.Exists(originalPath); - var originalIsFile = File.Exists(originalPath); - if (originalIsFile) - { - throw new InvalidOperationException( - "An owned directory removal path is occupied by a file."); - } -""" -if old_start not in text: - raise SystemExit("missing removal quarantine preflight") -text = text.replace(old_start, new_start, 1) -text = text.replace( - """ if (!originalExists && !quarantineExists) - { - RetireLegacySiblingArtifacts(ownership, parentAnchor); - return LibraryDirectoryRemovalOutcome.AlreadyRemoved; - } - - if (originalExists) -""", - """ if (!originalExists) - { - RetireSiblingArtifacts(ownership, parentAnchor); - return LibraryDirectoryRemovalOutcome.AlreadyRemoved; - } - -""", - 1, -) -# Keep only the original-path removal block; discard the quarantine compatibility block. -compat_marker = """ // Compatibility only: older versions may have already renamed the directory - // into a job-shaped quarantine. New removals never create that pathname. -""" -compat_pos = text.find(compat_marker) -if compat_pos < 0: - raise SystemExit("missing quarantine compatibility block") -# The original branch immediately before compatibility is closed with eight spaces + }. -# Preserve method close after deleting compatibility branch by replacing tail from marker through RestorePinnedQuarantine helper. -method_tail_start = compat_pos -restore_pos = text.find(" private static void RestorePinnedQuarantine(", compat_pos) -if restore_pos < 0: - raise SystemExit("missing RestorePinnedQuarantine") -restore_brace = text.find("{", restore_pos) -restore_end = find_matching_brace(text, restore_brace) -while restore_end < len(text) and text[restore_end] in " \t\r\n": - restore_end += 1 -# Compatibility block occurs before helper methods; remove only it by finding the closing brace before RetireLegacyOwnershipArtifacts. -helpers_pos = text.find(" private static void RetireLegacyOwnershipArtifacts(", compat_pos) -if helpers_pos < 0 or helpers_pos > restore_pos: - raise SystemExit("missing ownership artifact helpers") -text = text[:method_tail_start] + " }\n\n" + text[helpers_pos:restore_pos] + text[restore_end:] -text = text.replace("RetireLegacyOwnershipArtifacts", "RetireOwnershipArtifacts") -text = text.replace("RetireLegacySiblingArtifacts", "RetireSiblingArtifacts") -text = text.replace("Legacy directory ownership artifacts", "Directory ownership artifacts") -text = text.replace("Legacy directory ownership sibling artifacts", "Directory ownership sibling artifacts") -write(path, text) - -# Startup reconciliation supports only ownership rows produced by the final model. -path = "listenarr.infrastructure/Library/Moving/LibraryDirectoryOwnershipReconciler.cs" -replace_once(path, " await BackfillLegacyRemovedOwnershipEvidenceAsync(db, cancellationToken);\n") -remove_decl(path, "private async Task BackfillLegacyRemovedOwnershipEvidenceAsync(") -text = read(path) -# Missing Removing path: no obsolete marker proof is needed; current deletion retires transient markers before namespace removal. -start = text.find(" LibraryDirectoryOwnershipMarker.MarkerPayload? legacyPayload = null;") -if start < 0: - raise SystemExit("missing legacy missing-removal reconciliation") -end_marker = " ownership.State = LibraryDirectoryOwnershipState.Removed;" -end = text.find(end_marker, start) -if end < 0: - raise SystemExit("missing removed-state convergence") -text = text[:start] + text[end:] -# Require final physical identity; no null/v1 upgrade. -legacy_identity_start = text.find(" var liveIdentity = directory.GetDirectoryObjectIdentity();") -legacy_identity_end = text.find(" ownership.ManagedRootFolderId = authorization.RootFolderId;", legacy_identity_start) -if legacy_identity_start < 0 or legacy_identity_end < 0: - raise SystemExit("missing identity reconciliation block") -replacement = """ var liveIdentity = directory.GetDirectoryObjectIdentity(); - if (ownership.DirectoryObjectIdentityVersion - != ManagedDirectoryIdentity.CurrentVersion - || !ManagedDirectoryIdentity.Matches( - ownership.DirectoryObjectIdentityVersion, - ownership.DirectoryObjectIdentity, - ownership.OwnershipToken, - liveIdentity)) - { - throw new InvalidOperationException( - "The persisted directory ownership identity is not the current supported generation."); - } - -""" -text = text[:legacy_identity_start] + replacement + text[legacy_identity_end:] -# Do not regenerate a different identity during reconciliation; validation above is authoritative. -text = text.replace( - """ ownership.DirectoryObjectIdentityVersion = - ManagedDirectoryIdentity.CurrentVersion; - ownership.DirectoryObjectIdentity = ManagedDirectoryIdentity.Create( - ownership.OwnershipToken, - liveIdentity); -""", - "", - 1, -) -text = text.replace( - """ // Older builds left these marker files permanently. The durable row, - // managed-root authorization, and pinned native directory generation - // now provide the at-rest proof. Retire only artifacts that still match - // this exact ownership; unrelated files are preserved. -""", - """ // Root relocation may leave transient ownership migration artifacts - // after a crash. Retire only artifacts that still match this exact final - // ownership generation; unrelated files are preserved. -""", - 1, -) -text = text.replace("Obsolete directory ownership artifacts", "Directory ownership migration artifacts") -write(path, text) - -# Retired marker evidence is final-format only. -path = "listenarr.infrastructure/Library/Moving/EfLibraryDirectoryOwnershipStore.State.cs" -remove_decl(path, "public static LibraryDirectoryOwnershipRetiredMarker CreateLegacyPending(") -text = read(path) -old = """ var payload = evidence.PayloadVersion == 1 - ? new LibraryDirectoryOwnershipMarker.MarkerPayload( - 1, - evidence.OwnershipToken, - evidence.CanonicalOwnershipPath) - : new LibraryDirectoryOwnershipMarker.MarkerPayload( - evidence.PayloadVersion, - evidence.OwnershipToken, - evidence.CanonicalOwnershipPath, - evidence.OriginalManagedRootFolderId, - evidence.DirectoryObjectIdentityVersion, - evidence.DirectoryObjectIdentity); -""" -new = """ if (evidence.PayloadVersion != LibraryDirectoryOwnershipMarker.Version) - { - throw new InvalidOperationException( - "The retired ownership marker evidence uses an unsupported payload version."); - } - var payload = new LibraryDirectoryOwnershipMarker.MarkerPayload( - evidence.PayloadVersion, - evidence.OwnershipToken, - evidence.CanonicalOwnershipPath, - evidence.OriginalManagedRootFolderId, - evidence.DirectoryObjectIdentityVersion, - evidence.DirectoryObjectIdentity); -""" -if old not in text: - raise SystemExit("missing retired evidence legacy materialization") -write(path, text.replace(old, new, 1)) - -# Tests dedicated to intermediate ownership versions/formats are development history. -for test_file in ( - "tests/Features/Infrastructure/Library/Moving/EfLibraryDirectoryOwnershipStoreTests.cs", - "tests/Features/Infrastructure/Library/Moving/LibraryDirectoryOwnershipReconcilerTests.cs", - "tests/Features/Infrastructure/Library/Moving/LibraryDirectoryOwnershipMarkerTests.cs", - "tests/Features/Infrastructure/Library/Moving/PinnedLibraryDirectoryOwnershipMarkerTests.cs", -): - p = Path(test_file) - if not p.exists(): - continue - text = p.read_text() - cursor = 0 - forbidden = ( - "MatchesLegacyPayload", - "CreateLegacyPending", - "TryValidateLegacyMissingBothRecovery", - "legacy physical identity", - "legacy marker", - "LegacyMarker", - "LegacyOwnership", - "LegacyRemoved", - "version one", - "VersionOne", - ) - while True: - positions = [text.find(token, cursor) for token in forbidden] - positions = [pos for pos in positions if pos >= 0] - if not positions: - break - pos = min(positions) - # Find containing test method attribute block. - attr = max(text.rfind(" [Fact]", 0, pos), text.rfind(" [Theory]", 0, pos)) - if attr < 0: - cursor = pos + 1 - continue - method_brace = text.find("{", attr) - if method_brace < 0 or method_brace > pos: - cursor = pos + 1 - continue - method_end = find_matching_brace(text, method_brace) - if pos > method_end: - cursor = pos + 1 - continue - end = method_end - while end < len(text) and text[end] in " \t\r\n": - end += 1 - text = text[:attr] + text[end:] - cursor = attr - p.write_text(text) diff --git a/.github/scripts/pr717_cleanup_step5_runner.py b/.github/scripts/pr717_cleanup_step5_runner.py deleted file mode 100644 index 005a9a828..000000000 --- a/.github/scripts/pr717_cleanup_step5_runner.py +++ /dev/null @@ -1,99 +0,0 @@ -from pathlib import Path -import re - -payload = Path('listenarr.infrastructure/Library/Moving/LibraryDirectoryOwnershipMarker.Payload.cs') -text = payload.read_text() -text, count = re.subn( - r'\n\s*internal static bool MatchesLegacyPayload\(.*?ownership\.GetIdentity\(\)\.Semantics\);\n', - '\n', - text, - count=1, - flags=re.S, -) -if count != 1: - raise SystemExit('could not remove expression-bodied legacy marker matcher') -payload.write_text(text) - -script = Path('.github/scripts/pr717_cleanup_step5.py') -source = script.read_text() -source, count = re.subn( - r'\nremove_decl\(\n\s*"listenarr\.infrastructure/Library/Moving/LibraryDirectoryOwnershipMarker\.Payload\.cs",\n\s*"internal static bool MatchesLegacyPayload\(",\n\)\n', - '\n', - source, - count=1, -) -if count != 1: - raise SystemExit('could not suppress legacy matcher helper call') - -namespace = {'__name__': '__main__', '__file__': str(script)} -exec(compile(source, str(script), 'exec'), namespace) - -replacement = Path('listenarr.infrastructure/Library/Moving/EfLibraryDirectoryOwnershipStore.MarkerlessReplacement.cs') -text = replacement.read_text() -old = ''' if (!await db.LibraryDirectoryOwnershipRetiredMarkers.AnyAsync( - marker => marker.OwnershipId == stale.Id, - cancellationToken)) - { - if (stale.ManagedRootFolderId.HasValue - && stale.DirectoryObjectIdentityVersion.HasValue - && !string.IsNullOrWhiteSpace(stale.DirectoryObjectIdentity)) - { - db.LibraryDirectoryOwnershipRetiredMarkers.Add( - LibraryDirectoryOwnershipRetiredMarkerEvidence.Create( - stale, - new LibraryDirectoryOwnershipMarker.MarkerPayload( - LibraryDirectoryOwnershipMarker.Version, - stale.OwnershipToken, - stale.CanonicalPath, - stale.ManagedRootFolderId, - stale.DirectoryObjectIdentityVersion, - stale.DirectoryObjectIdentity), - now)); - } - else - { - db.LibraryDirectoryOwnershipRetiredMarkers.Add( - LibraryDirectoryOwnershipRetiredMarkerEvidence - .CreateLegacyPending(stale)); - } - } -''' -new = ''' if (!stale.ManagedRootFolderId.HasValue - || stale.DirectoryObjectIdentityVersion != ManagedDirectoryIdentity.CurrentVersion - || string.IsNullOrWhiteSpace(stale.DirectoryObjectIdentity)) - { - throw new InvalidOperationException( - "The stale ownership row does not contain the final durable identity required for retirement."); - } - if (!await db.LibraryDirectoryOwnershipRetiredMarkers.AnyAsync( - marker => marker.OwnershipId == stale.Id, - cancellationToken)) - { - db.LibraryDirectoryOwnershipRetiredMarkers.Add( - LibraryDirectoryOwnershipRetiredMarkerEvidence.Create( - stale, - new LibraryDirectoryOwnershipMarker.MarkerPayload( - LibraryDirectoryOwnershipMarker.Version, - stale.OwnershipToken, - stale.CanonicalPath, - stale.ManagedRootFolderId, - stale.DirectoryObjectIdentityVersion, - stale.DirectoryObjectIdentity), - now)); - } -''' -if old not in text: - raise SystemExit('missing markerless replacement legacy retired-evidence fallback') -replacement.write_text(text.replace(old, new, 1)) - -reconciler = Path('listenarr.infrastructure/Library/Moving/LibraryDirectoryOwnershipReconciler.cs') -text = reconciler.read_text() -old = ''' ownership.UpdatedAt = now; - await db.SaveChangesAsync(cancellationToken); -''' -new = ''' ownership.UpdatedAt = DateTime.UtcNow; - await db.SaveChangesAsync(cancellationToken); -''' -if old not in text: - raise SystemExit('missing converged removal timestamp assignment') -reconciler.write_text(text.replace(old, new, 1)) diff --git a/.github/workflows/pr717-cleanup-analysis.yml b/.github/workflows/pr717-cleanup-analysis.yml deleted file mode 100644 index 0f75a382d..000000000 --- a/.github/workflows/pr717-cleanup-analysis.yml +++ /dev/null @@ -1,118 +0,0 @@ -name: PR 717 cleanup analysis - -on: - push: - branches: - - bugfix/unix-folder-name-space - -permissions: - contents: write - -jobs: - remove-ownership-version-compatibility: - if: github.actor != 'github-actions[bot]' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Remove intermediate ownership compatibility - shell: bash - run: | - set -euo pipefail - python3 - <<'PY' - from pathlib import Path - - # The first cleanup target is expression-bodied, while the shared temporary - # cleanup helper is intentionally brace-oriented. Remove it exactly and - # suppress the helper call for this one member. - payload = Path('listenarr.infrastructure/Library/Moving/LibraryDirectoryOwnershipMarker.Payload.cs') - text = payload.read_text() - block = ''' internal static bool MatchesLegacyPayload( - LibraryDirectoryOwnership ownership, - MarkerPayload payload) => - payload.Version == 1 - && string.Equals( - payload.OwnershipToken, - ownership.OwnershipToken, - StringComparison.Ordinal) - && MarkerPathMatches( - payload.CanonicalPath, - ownership.CanonicalPath, - ownership.GetIdentity().Semantics); - ''' - # Normalize indentation from this temporary heredoc before matching source. - block = '\n'.join(line[10:] if line.startswith(' ') else line for line in block.splitlines()) + '\n' - if block not in text: - raise SystemExit('missing expression-bodied legacy marker matcher') - payload.write_text(text.replace(block, '', 1)) - - script = Path('.github/scripts/pr717_cleanup_step5.py') - source = script.read_text() - call = '''remove_decl( - "listenarr.infrastructure/Library/Moving/LibraryDirectoryOwnershipMarker.Payload.cs", - "internal static bool MatchesLegacyPayload(", - ) - ''' - call = '\n'.join(line[10:] if line.startswith(' ') else line for line in call.splitlines()) + '\n' - if call not in source: - raise SystemExit('missing legacy matcher cleanup call') - script.write_text(source.replace(call, '', 1)) - PY - python3 .github/scripts/pr717_cleanup_step5.py - - - name: Static adversarial review - shell: bash - run: | - set -euo pipefail - git diff --check - if git grep -n -E 'MatchesLegacyPayload|UpgradeLegacyMarkerAsync|TryValidateLegacyMissingBothRecovery|CreateLegacyPending|legacy physical identity|pre-physical-identity|Older builds left these marker' -- \ - listenarr.application listenarr.domain listenarr.infrastructure listenarr.api; then - echo 'Intermediate ownership compatibility remains in production' >&2 - exit 1 - fi - if git grep -n -E 'DirectoryObjectIdentityVersion[[:space:]]*==[[:space:]]*1|PayloadVersion[[:space:]]*==[[:space:]]*1' -- \ - listenarr.infrastructure; then - echo 'Version-one ownership compatibility remains' >&2 - exit 1 - fi - echo 'Current relocation marker migration callers:' - git grep -n -E 'PublishMigrationTargetAsync|TryRetireMigrationArtifacts' -- listenarr.infrastructure tests || true - git diff --stat - - - name: Restore and build - shell: bash - run: | - set -euo pipefail - git restore .github/scripts/pr717_cleanup_step5.py - dotnet restore listenarr.slnx - dotnet build listenarr.slnx --configuration Release --no-restore - - - name: Focused ownership and relocation tests - shell: bash - run: | - set -euo pipefail - dotnet test tests/Listenarr.Tests.csproj --configuration Release --no-build --filter 'FullyQualifiedName~LibraryDirectoryOwnership|FullyQualifiedName~RootFolderRelocation|FullyQualifiedName~AudiobookContentMoveService|FullyQualifiedName~AudiobookFilesystemDeleteService' - - - name: Adversarial live-marker contract review - shell: bash - run: | - set -euo pipefail - git grep -n -E 'PublishMigrationTargetAsync|\.migration\.tmp|TryRetireMigrationArtifacts|RecoverConditionalReplacement' -- listenarr.infrastructure tests || true - test -f listenarr.infrastructure/Library/Moving/PinnedLibraryDirectoryOwnershipMarker.Migration.cs - test -f listenarr.infrastructure/Library/Moving/PinnedLibraryDirectoryOwnershipMarker.Recovery.cs - if ! git grep -q -F 'PublishMigrationTargetAsync' -- listenarr.infrastructure/Library/Moving/RootFolderRelocationService.OwnershipMigration.cs; then - echo 'Current root-relocation marker migration contract was accidentally removed' >&2 - exit 1 - fi - - - name: Commit cleanup - shell: bash - run: | - set -euo pipefail - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add -A ':!.github/workflows/pr717-cleanup-analysis.yml' ':!.github/scripts/**' ':!.github/pr717-cleanup-trigger' - git commit -m 'refactor(ownership): remove intermediate protocol compatibility' - git push origin HEAD:bugfix/unix-folder-name-space diff --git a/.github/workflows/pr717-cleanup-step5.yml b/.github/workflows/pr717-cleanup-step5.yml deleted file mode 100644 index 8d71fbd35..000000000 --- a/.github/workflows/pr717-cleanup-step5.yml +++ /dev/null @@ -1,63 +0,0 @@ -name: PR 717 cleanup step 5 - -on: - push: - branches: - - bugfix/unix-folder-name-space - -permissions: - contents: write - -jobs: - cleanup: - if: github.actor != 'github-actions[bot]' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: Transform - run: python3 .github/scripts/pr717_cleanup_step5_runner.py - - name: Static adversarial review - shell: bash - run: | - set -euo pipefail - git diff --check - if git grep -n -E 'MatchesLegacyPayload|UpgradeLegacyMarkerAsync|TryValidateLegacyMissingBothRecovery|CreateLegacyPending|legacy physical identity|pre-physical-identity|Older builds left these marker' -- listenarr.application listenarr.domain listenarr.infrastructure listenarr.api; then - echo 'Intermediate ownership compatibility remains in production' >&2 - exit 1 - fi - if git grep -n -E 'DirectoryObjectIdentityVersion[[:space:]]*==[[:space:]]*1|PayloadVersion[[:space:]]*==[[:space:]]*1' -- listenarr.infrastructure; then - echo 'Version-one ownership compatibility remains' >&2 - exit 1 - fi - git grep -n -E 'PublishMigrationTargetAsync|TryRetireMigrationArtifacts' -- listenarr.infrastructure tests || true - git diff --stat - - name: Build - shell: bash - run: | - set -euo pipefail - dotnet restore listenarr.slnx - dotnet build listenarr.slnx --configuration Release --no-restore - - name: Focused tests - shell: bash - run: | - set -euo pipefail - dotnet test tests/Listenarr.Tests.csproj --configuration Release --no-build --filter 'FullyQualifiedName~LibraryDirectoryOwnership|FullyQualifiedName~RootFolderRelocation|FullyQualifiedName~AudiobookContentMoveService|FullyQualifiedName~AudiobookFilesystemDeleteService' - - name: Live marker adversarial review - shell: bash - run: | - set -euo pipefail - test -f listenarr.infrastructure/Library/Moving/PinnedLibraryDirectoryOwnershipMarker.Migration.cs - test -f listenarr.infrastructure/Library/Moving/PinnedLibraryDirectoryOwnershipMarker.Recovery.cs - git grep -n -E 'PublishMigrationTargetAsync|\.migration\.tmp|TryRetireMigrationArtifacts|RecoverConditionalReplacement' -- listenarr.infrastructure tests || true - git grep -q -F 'PublishMigrationTargetAsync' -- listenarr.infrastructure/Library/Moving/RootFolderRelocationService.OwnershipMigration.cs - - name: Commit - shell: bash - run: | - set -euo pipefail - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add -A ':!.github/workflows/pr717-cleanup-analysis.yml' ':!.github/workflows/pr717-cleanup-step5.yml' ':!.github/scripts/**' ':!.github/pr717-cleanup-trigger' - git commit -m 'refactor(ownership): remove intermediate protocol compatibility' - git push origin HEAD:bugfix/unix-folder-name-space diff --git a/fe/src/__tests__/RootFoldersSettings.spec.ts b/fe/src/__tests__/RootFoldersSettings.spec.ts index ec0cdada9..0d3c528f9 100644 --- a/fe/src/__tests__/RootFoldersSettings.spec.ts +++ b/fe/src/__tests__/RootFoldersSettings.spec.ts @@ -139,7 +139,7 @@ describe('RootFoldersSettings', () => { expect(apiService.reauthorizeRootFolderIdentity).toHaveBeenCalledWith(folder.id, folder.path) }) -it('keeps ordinary retry separate for an authorized relocation', async () => { + it('keeps ordinary retry separate for an authorized relocation', async () => { vi.mocked(apiService.getRootFolders).mockResolvedValue([rootFolder(relocation('Authorized'))]) const pinia = createPinia() setActivePinia(pinia) diff --git a/fe/src/__tests__/rootFolders.reauthorization.store.spec.ts b/fe/src/__tests__/rootFolders.reauthorization.store.spec.ts index e6e8cb945..cd17e2750 100644 --- a/fe/src/__tests__/rootFolders.reauthorization.store.spec.ts +++ b/fe/src/__tests__/rootFolders.reauthorization.store.spec.ts @@ -216,5 +216,4 @@ describe('root folder relocation store actions', () => { expect(apiService.reauthorizeRootFolderIdentity).toHaveBeenCalledWith(current.id, current.path) expect(apiService.getRootFolders).toHaveBeenCalledTimes(1) }) - }) diff --git a/fe/src/components/settings/RootFoldersSettings.vue b/fe/src/components/settings/RootFoldersSettings.vue index 0ee893703..87dc2a683 100644 --- a/fe/src/components/settings/RootFoldersSettings.vue +++ b/fe/src/components/settings/RootFoldersSettings.vue @@ -186,7 +186,6 @@

- diff --git a/fe/src/services/api.ts b/fe/src/services/api.ts index 97b2856a2..277582395 100644 --- a/fe/src/services/api.ts +++ b/fe/src/services/api.ts @@ -950,7 +950,7 @@ class ApiService { ) } -async deleteRootFolder(id: number, reassignTo?: number): Promise<{ message?: string }> { + async deleteRootFolder(id: number, reassignTo?: number): Promise<{ message?: string }> { const qs = reassignTo ? `?reassignTo=${reassignTo}` : '' return this.request<{ message?: string }>(`/rootfolders/${id}${qs}`, { method: 'DELETE' }) } diff --git a/fe/src/stores/rootFolders.ts b/fe/src/stores/rootFolders.ts index 202db7f29..2f4a31abd 100644 --- a/fe/src/stores/rootFolders.ts +++ b/fe/src/stores/rootFolders.ts @@ -140,7 +140,7 @@ export const useRootFoldersStore = defineStore('rootFolders', () => { return result } -async function remove(id: number, reassignTo?: number) { + async function remove(id: number, reassignTo?: number) { const r = await apiService.deleteRootFolder(id, reassignTo) await load() return r diff --git a/listenarr.application/Audiobooks/Jobs/MoveRecoveryPolicy.cs b/listenarr.application/Audiobooks/Jobs/MoveRecoveryPolicy.cs index 559f2ae4d..5a9e05278 100644 --- a/listenarr.application/Audiobooks/Jobs/MoveRecoveryPolicy.cs +++ b/listenarr.application/Audiobooks/Jobs/MoveRecoveryPolicy.cs @@ -63,14 +63,22 @@ MoveCreatedDirectoryState.Created or public static bool BlocksFilesystemMutation(MoveJob job) { ArgumentNullException.ThrowIfNull(job); - if (job.Status.IsActive()) + if (job.Status is MoveJobStatus.Completed or MoveJobStatus.Superseded) + { + return false; + } + + if (!MoveExecutionProtocol.IsCurrent(job.ExecutionProtocolVersion)) { + // Released pre-durable jobs and unsupported development protocols cannot + // carry trustworthy manifest/generation evidence. Their absence of current + // evidence is therefore not proof that no filesystem mutation occurred. return true; } - if (job.Status is MoveJobStatus.Completed or MoveJobStatus.Superseded) + if (job.Status.IsActive()) { - return false; + return true; } return job.Status is MoveJobStatus.Failed or MoveJobStatus.NeedsAttention @@ -80,14 +88,19 @@ public static bool BlocksFilesystemMutation(MoveJob job) public static MoveRecoveryDisposition GetDisposition(MoveJob job) { ArgumentNullException.ThrowIfNull(job); - if (job.Status.IsActive()) + if (job.Status is MoveJobStatus.Completed or MoveJobStatus.Superseded) { - return MoveRecoveryDisposition.InProgress; + return MoveRecoveryDisposition.None; } - if (job.Status is MoveJobStatus.Completed or MoveJobStatus.Superseded) + if (!MoveExecutionProtocol.IsCurrent(job.ExecutionProtocolVersion)) { - return MoveRecoveryDisposition.None; + return MoveRecoveryDisposition.OperatorRepairRequired; + } + + if (job.Status.IsActive()) + { + return MoveRecoveryDisposition.InProgress; } if (job.Status == MoveJobStatus.Failed) @@ -121,7 +134,7 @@ public static MoveRecoveryDisposition GetDisposition(MoveJob job) private static bool HasCompletedMarkerlessRecoveryEvidence(MoveJob job) { - if (job.ExecutionProtocolVersion < MoveExecutionProtocol.MarkerlessDatabaseState + if (!MoveExecutionProtocol.IsCurrent(job.ExecutionProtocolVersion) || job.SourceDirectoryCleanupState != MoveJobEntryCleanupState.Deleted || string.IsNullOrWhiteSpace(job.TargetDirectoryObjectIdentity) || string.IsNullOrWhiteSpace(job.RequestedPath)) diff --git a/listenarr.domain/Audiobooks/LibraryDirectoryOwnership.cs b/listenarr.domain/Audiobooks/LibraryDirectoryOwnership.cs index 48d5281f1..4633cd93a 100644 --- a/listenarr.domain/Audiobooks/LibraryDirectoryOwnership.cs +++ b/listenarr.domain/Audiobooks/LibraryDirectoryOwnership.cs @@ -13,12 +13,6 @@ public enum LibraryDirectoryOwnershipState Unavailable } -public enum LibraryDirectoryOwnershipRetiredMarkerState -{ - Pending, - Removed -} - public sealed class LibraryDirectoryOwnership { public long Id { get; set; } @@ -70,43 +64,9 @@ public sealed class LibraryDirectoryOwnership public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; public ICollection PathMigrations { get; set; } = new List(); - public LibraryDirectoryOwnershipRetiredMarker? RetiredMarker { get; set; } - public PathIdentitySnapshot GetIdentity() => new( PathSyntax, PathCaseSensitivity, PathCaseSensitivityMode, PathIdentityBoundary); } - -public sealed class LibraryDirectoryOwnershipRetiredMarker -{ - [Key] - public long Id { get; set; } - public long OwnershipId { get; set; } - public LibraryDirectoryOwnership Ownership { get; set; } = null!; - [Required, MaxLength(64)] - public string OwnershipToken { get; set; } = string.Empty; - [MaxLength(4096)] - public string? CanonicalMarkerPath { get; set; } - [Required, MaxLength(4096)] - public string CanonicalOwnershipPath { get; set; } = string.Empty; - public FileSystemPathSyntax PathSyntax { get; set; } - public FileSystemCaseSensitivity PathCaseSensitivity { get; set; } - public FileSystemCaseSensitivityMode PathCaseSensitivityMode { get; set; } - [Required, MaxLength(4096)] - public string PathIdentityBoundary { get; set; } = string.Empty; - [MaxLength(16384)] - public string? CanonicalPayload { get; set; } - [MaxLength(64)] - public string? PayloadSha256 { get; set; } - public int PayloadVersion { get; set; } - public int? OriginalManagedRootFolderId { get; set; } - public int? DirectoryObjectIdentityVersion { get; set; } - [MaxLength(256)] - public string? DirectoryObjectIdentity { get; set; } - public LibraryDirectoryOwnershipRetiredMarkerState State { get; set; } = - LibraryDirectoryOwnershipRetiredMarkerState.Pending; - public DateTime CreatedAt { get; set; } = DateTime.UtcNow; - public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; -} diff --git a/listenarr.domain/Audiobooks/MoveJob.cs b/listenarr.domain/Audiobooks/MoveJob.cs index a7eb4cc24..73f487c3b 100644 --- a/listenarr.domain/Audiobooks/MoveJob.cs +++ b/listenarr.domain/Audiobooks/MoveJob.cs @@ -71,17 +71,18 @@ public enum MoveJobEntryCopyState public enum MoveJobEntryCleanupState { Pending, - Quarantined, - DeletionAuthorized, + DeleteAuthorized, Deleted, Retained } public static class MoveExecutionProtocol { - public const int LegacyFilesystemArtifacts = 1; + public const int PreDurableReleased = 0; public const int MarkerlessDatabaseState = 2; public const int Current = MarkerlessDatabaseState; + + public static bool IsCurrent(int version) => version == Current; } public static class MoveJobStatusExtensions @@ -102,7 +103,7 @@ public class MoveJob public MoveJobStatus Status { get; set; } = MoveJobStatus.Queued; public MoveJobPhase Phase { get; set; } = MoveJobPhase.None; public int ExecutionProtocolVersion { get; set; } = - MoveExecutionProtocol.LegacyFilesystemArtifacts; + MoveExecutionProtocol.Current; [MaxLength(512)] public string? SourceDirectoryObjectIdentity { get; set; } [MaxLength(512)] diff --git a/listenarr.domain/Audiobooks/RootFolderRelocation.cs b/listenarr.domain/Audiobooks/RootFolderRelocation.cs index 4a48878b0..4173033a7 100644 --- a/listenarr.domain/Audiobooks/RootFolderRelocation.cs +++ b/listenarr.domain/Audiobooks/RootFolderRelocation.cs @@ -25,17 +25,6 @@ public enum TargetIdentityEnrollmentState NotRequired } -public enum LibraryDirectoryOwnershipPathMigrationState -{ - Prepared, - MarkersPublished, - MetadataCommitted, - SourceMarkersRetired, - TargetValidated, - MarkerlessCommitted, - MarkerlessRetired -} - public enum RootFolderRelocationCreatedDirectoryState { Planned, @@ -127,8 +116,6 @@ public sealed class LibraryDirectoryOwnershipPathMigration public string TargetIdentityLookupKey { get; set; } = string.Empty; [Required, MaxLength(160)] public string TargetOwnershipKey { get; set; } = string.Empty; - public LibraryDirectoryOwnershipPathMigrationState State { get; set; } = - LibraryDirectoryOwnershipPathMigrationState.Prepared; public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; } diff --git a/listenarr.infrastructure/DependencyInjection/InfrastructureStartupCompositionExtensions.cs b/listenarr.infrastructure/DependencyInjection/InfrastructureStartupCompositionExtensions.cs index af79096f3..4ec42c510 100644 --- a/listenarr.infrastructure/DependencyInjection/InfrastructureStartupCompositionExtensions.cs +++ b/listenarr.infrastructure/DependencyInjection/InfrastructureStartupCompositionExtensions.cs @@ -71,16 +71,6 @@ public static void ApplyListenarrDatabaseMigrations(this IServiceProvider servic repairedLegacyData.DefaultRootsNormalized); } - var repairedOwnershipReferences = - LibraryDirectoryOwnershipMigrationPreflight - .RepairLegacyForeignKeyReferences(ctx); - if (repairedOwnershipReferences > 0) - { - Log.Logger.Warning( - "[Startup] Repaired {Count} legacy directory ownership root reference(s) before applying the ownership foreign key migration", - repairedOwnershipReferences); - } - ctx.Database.Migrate(); var repairedPostMigrationData = ListenarrDatabaseMigrationPreflight.RepairPostMigrationData(ctx); @@ -90,12 +80,6 @@ public static void ApplyListenarrDatabaseMigrations(this IServiceProvider servic "[Startup] Normalized {Count} legacy move job row(s) after applying durable move migrations", repairedPostMigrationData.MoveJobsRepaired); } - if (repairedPostMigrationData.AudiobookFilesRepaired > 0) - { - Log.Logger.Warning( - "[Startup] Normalized {Count} legacy audiobook file identity row(s) after applying ownership schema migrations", - repairedPostMigrationData.AudiobookFilesRepaired); - } Log.Logger.Information("[Startup] EF Core migrations applied successfully"); } catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) diff --git a/listenarr.infrastructure/FileSystem/DirectoryObjectIdentityResolver.cs b/listenarr.infrastructure/FileSystem/DirectoryObjectIdentityResolver.cs index fef1febc2..3fa10184d 100644 --- a/listenarr.infrastructure/FileSystem/DirectoryObjectIdentityResolver.cs +++ b/listenarr.infrastructure/FileSystem/DirectoryObjectIdentityResolver.cs @@ -44,7 +44,7 @@ public Task ResolveExistingAsync( "The live directory no longer matches its persisted physical identity.")); } -private Task ResolvePinnedAsync( + private Task ResolvePinnedAsync( string path, CancellationToken cancellationToken, Func resolve) diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.Atomic.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.Atomic.cs deleted file mode 100644 index f73bd7e18..000000000 --- a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.Atomic.cs +++ /dev/null @@ -1,267 +0,0 @@ -using Listenarr.Domain.Common; - -namespace Listenarr.Infrastructure.Library.Moving; - -internal sealed partial class AudiobookContentMoveService -{ - private async Task TryMoveByAtomicRenameAsync( - AudiobookContentMoveRequest request, - string source, - string target, - string tempName, - bool targetInsideSource, - bool sourceInsideTarget, - string? recoveryStage, - IReadOnlyList manifest, - FileSystemPathSemantics sourceSemantics, - FileSystemPathSemantics targetSemantics, - CancellationToken cancellationToken) - { - if (!OperatingSystem.IsWindows() - || targetInsideSource - || sourceInsideTarget - || (faultInjector != null && !faultInjector.AllowAtomicRename) - || request.SourcePhysicalObjectIdentities is { Count: > 0 } - || !request.DeleteEmptySource - || IsSourceCleanupBoundary(source, request.SourceCleanupBoundary, sourceSemantics) - || Directory.Exists(target) - || Directory.Exists(tempName) - || recoveryStage != null) - { - return null; - } - - await EnsureLeaseOwnedAsync(request.JobId, request.LeaseToken, cancellationToken); - await ValidatePersistedMoveIdentityAsync( - request.JobId, - source, - target, - sourceSemantics, - targetSemantics, - request.LeaseToken, - cancellationToken); - ValidateMoveSourceRoot(source); - ValidateMoveTargetRoot(target); - if (Directory.Exists(target)) - { - throw new MoveNeedsAttentionException( - "Atomic rename target appeared after validation; no filesystem mutation was performed."); - } - - var atomicMarkerPath = GetRecoveryMarkerPath(source, request.JobId); - await WriteRecoveryMarkerAsync( - source, - request, - source, - target, - AtomicRenameCompletedStage, - cancellationToken); - var renameCompleted = false; - try - { - // Recheck both roots after publishing the durable marker and immediately - // before the rename so a linked or newly occupied target is never followed. - await EnsureMutationAuthorizedAsync(request, source, target, cancellationToken); - ValidateMoveSourceRoot(source); - ValidateMoveTargetRoot(target); - if (Directory.Exists(target)) - { - throw new MoveNeedsAttentionException( - "Atomic rename target appeared before publication; no directory was moved."); - } - - faultInjector?.OnAtomicRename( - request.JobId, - AtomicRenameFaultPoint.BeforeSourceRevalidation); - var currentSource = await SnapshotSourceAsync( - request.JobId, - source, - target, - targetInsideSource: false, - sourceSemantics, - cancellationToken, - atomicMarkerPath); - var expectedSource = manifest - .Where(entry => !IsRootManifestEntry(entry)) - .ToList(); - if (!ManifestMatches(expectedSource, currentSource, sourceSemantics)) - { - throw new MoveNeedsAttentionException( - "Source content changed after the atomic move was planned; the directory was not moved."); - } - - await EnsureMutationAuthorizedAsync(request, source, target, cancellationToken); - ValidateMoveSourceRoot(source); - ValidateMoveTargetRoot(target); - ValidateExistingRecoveryMarkerForStage( - source, - atomicMarkerPath, - request, - source, - target, - AtomicRenameCompletedStage); - if (Directory.Exists(target)) - { - throw new MoveNeedsAttentionException( - "Atomic rename target appeared immediately before publication; no directory was moved."); - } - - var sourceParent = Path.GetDirectoryName(source) - ?? throw new MoveNeedsAttentionException("The atomic move source parent is unavailable."); - var targetParent = Path.GetDirectoryName(target) - ?? throw new MoveNeedsAttentionException("The atomic move target parent is unavailable."); - using var sourcePublication = PinnedDirectoryCreation.OpenExistingForPublication( - sourceParent, - Path.GetFileName(source)); - using var targetParentAnchor = PinnedDirectoryCreation.OpenPinnedDirectoryNoFollow( - targetParent); - if (!sourcePublication.VisiblePathMatches() - || !targetParentAnchor.VisiblePathMatches()) - { - throw new MoveNeedsAttentionException( - "The atomic move source or target parent changed while it was being pinned."); - } - - faultInjector?.OnAtomicRename( - request.JobId, - AtomicRenameFaultPoint.BeforeDirectoryPublication); - await EnsureMutationAuthorizedAsync(request, source, target, cancellationToken); - if (!sourcePublication.VisiblePathMatches() - || !targetParentAnchor.VisiblePathMatches()) - { - throw new MoveNeedsAttentionException( - "The atomic move source or target parent changed at publication."); - } - if (Directory.Exists(target) || File.Exists(target)) - { - throw new MoveNeedsAttentionException( - "The atomic move target appeared at publication; no directory was moved."); - } - - using var publishedAnchor = sourcePublication.PublishCreatedDirectoryTo( - targetParentAnchor, - Path.GetFileName(target)); - if (!publishedAnchor.VisiblePathMatches(target)) - { - throw new MoveNeedsAttentionException( - "The atomic move target does not identify the pinned source directory."); - } - - renameCompleted = true; - faultInjector?.OnAtomicRename( - request.JobId, - AtomicRenameFaultPoint.AfterDirectoryMoveBeforeVerification); - ValidateExistingDestinationContents( - source, - target, - manifest, - request.JobId, - targetSemantics, - allowPartialFiles: false); - await VerifyPublishedManifestAsync( - target, - manifest, - targetSemantics, - cancellationToken); - await UpdateCopyStateAsync( - request.JobId, - request.LeaseToken, - cancellationToken); - } - catch (MoveNeedsAttentionException) - { - await DeleteFailedAtomicMarkerAsync( - request, - atomicMarkerPath, - source, - target, - null, - cancellationToken); - throw; - } - catch (Exception exception) when ( - !renameCompleted - && exception is IOException or UnauthorizedAccessException) - { - await DeleteFailedAtomicMarkerAsync( - request, - atomicMarkerPath, - source, - target, - exception, - cancellationToken); - ValidateMoveTargetRoot(target); - if (!Directory.Exists(source) || Directory.Exists(target)) - { - throw new MoveNeedsAttentionException( - "Atomic rename failed with an ambiguous source or target state; copy fallback was blocked."); - } - - return null; - } - - await UpdateJobPhaseAsync( - request.JobId, - request.LeaseToken, - MoveJobPhase.Finalizing, - cancellationToken); - var targetPhysicalObjectIdentities = - await CapturePublishedTargetPhysicalIdentitiesAsync( - target, - manifest, - targetSemantics, - cancellationToken); - return new AudiobookContentMoveResult( - source, - target, - false, - false, - GetRecoveryMarkerPath(target, request.JobId), - SourceCleanupCompleted: true, - targetPhysicalObjectIdentities); - } - - private async Task DeleteFailedAtomicMarkerAsync( - AudiobookContentMoveRequest request, - string atomicMarkerPath, - string source, - string target, - Exception? renameException, - CancellationToken cancellationToken) - { - try - { - if (File.Exists(atomicMarkerPath)) - { - await RetirePinnedArtifactAsync( - atomicMarkerPath, - entry => - { - ValidateMoveSourceRoot(source); - ValidatePinnedAtomicMarker( - entry, - atomicMarkerPath, - request, - source, - target); - }, - () => EnsureMutationAuthorizedAsync( - request, - source, - target, - cancellationToken)); - } - } - catch (Exception exception) when (exception is MoveLeaseLostException or PersistenceException) - { - throw; - } - catch (Exception exception) when (WorkerExceptionClassifier.IsNonFatal(exception)) - { - throw new MoveNeedsAttentionException( - $"Atomic rename failed and its recovery marker could not be removed. " - + $"Rename error: {renameException?.Message ?? "precondition changed"}. " - + $"Marker cleanup error: {exception.Message}"); - } - } -} diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.AtomicMarkerRetirement.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.AtomicMarkerRetirement.cs deleted file mode 100644 index f60a18161..000000000 --- a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.AtomicMarkerRetirement.cs +++ /dev/null @@ -1,65 +0,0 @@ -namespace Listenarr.Infrastructure.Library.Moving; - -internal sealed partial class AudiobookContentMoveService -{ - private async Task RetireSourceAtomicMarkerBeforeCopyFallbackAsync( - AudiobookContentMoveRequest request, - string source, - string target, - string markerPath, - CancellationToken cancellationToken) - { - using var sourceAnchor = PinnedDirectoryCreation.OpenPinnedDirectoryNoFollow(source); - using var markerEntry = sourceAnchor.OpenExistingFile( - Path.GetFileName(markerPath), - requireDeleteAccess: true); - if (!sourceAnchor.VisiblePathMatches() - || !markerEntry.VisiblePathMatches()) - { - throw new MoveNeedsAttentionException( - "The source-side atomic recovery marker changed before copy fallback."); - } - - ValidatePinnedAtomicMarker( - markerEntry, - markerPath, - request, - source, - target); - await EnsureMutationAuthorizedAsync( - request, - source, - target, - cancellationToken); - if (!sourceAnchor.VisiblePathMatches() - || !markerEntry.VisiblePathMatches()) - { - throw new MoveNeedsAttentionException( - "The source-side atomic recovery marker changed at copy fallback."); - } - - ValidatePinnedAtomicMarker( - markerEntry, - markerPath, - request, - source, - target); - markerEntry.Delete(); - } - - private void ValidatePinnedAtomicMarker( - PinnedDirectoryCreation.PinnedFileEntry markerEntry, - string markerPath, - AudiobookContentMoveRequest request, - string source, - string target) - { - var parsed = ReadRecoveryMarker(markerEntry, markerPath); - ValidateRecoveryMarker(parsed, request, source, target); - if (!CanAdvanceRecoveryStage(parsed.Stage, AtomicRenameCompletedStage)) - { - throw new MoveNeedsAttentionException( - "The source-side recovery marker is not an authoritative atomic marker."); - } - } -} diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.CleanupMutationSafety.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.CleanupMutationSafety.cs deleted file mode 100644 index 03bb0db1c..000000000 --- a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.CleanupMutationSafety.cs +++ /dev/null @@ -1,271 +0,0 @@ -using Listenarr.Domain.Common; - -namespace Listenarr.Infrastructure.Library.Moving; - -internal sealed partial class AudiobookContentMoveService -{ - private async Task RevalidateSourceToQuarantineMoveAsync( - string source, - string target, - string sourceFile, - string quarantineFile, - string quarantineRoot, - string sourceParent, - Guid jobId, - MoveLeaseToken leaseToken, - MoveJobEntry manifestEntry, - IReadOnlyCollection manifest, - ValidatedTempOwnership? publishedTempOwnership, - LibraryDirectoryOwnership? targetDirectoryOwnership, - FileSystemPathSemantics sourceSemantics, - FileSystemPathSemantics targetSemantics, - CancellationToken cancellationToken) - { - await EnsureMutationAuthorizedAsync( - jobId, - leaseToken, - source, - target, - sourceSemantics, - targetSemantics, - cancellationToken); - ValidateMoveSourceRoot(source); - if (!FileSystemSafety.TryValidateMutationTarget( - sourceFile, - [source], - out sourceFile, - out var sourceReason)) - { - throw new MoveNeedsAttentionException(sourceReason); - } - - if (!File.Exists(sourceFile) - || (File.GetAttributes(sourceFile) & FileAttributes.ReparsePoint) != 0) - { - throw new MoveNeedsAttentionException( - $"Source cleanup entry is missing or linked: {manifestEntry.RelativePath}"); - } - - if (!await FileMatchesManifestAsync( - sourceFile, - manifestEntry, - cancellationToken)) - { - throw new MoveNeedsAttentionException( - $"Source cleanup entry changed after planning: {manifestEntry.RelativePath}"); - } - - var ownership = await ValidateOwnedQuarantineDirectoryAsync( - quarantineRoot, - sourceParent, - jobId, - source, - target, - sourceSemantics, - targetSemantics, - leaseToken, - cancellationToken); - ValidateMoveTargetRoot(target); - ValidateExistingDestinationContents( - source, - target, - manifest, - jobId, - targetSemantics, - publishedTempOwnership, - ownership, - allowPartialFiles: false, - targetDirectoryOwnership: targetDirectoryOwnership); - ValidateQuarantineMutationPath(ownership, quarantineFile); - if (File.Exists(quarantineFile) || Directory.Exists(quarantineFile)) - { - throw new MoveNeedsAttentionException( - $"The quarantine destination appeared before cleanup: {manifestEntry.RelativePath}"); - } - - return ownership; - } - - private async Task RevalidateQuarantineDeleteAsync( - string source, - string target, - string quarantineFile, - string quarantineRoot, - string sourceParent, - Guid jobId, - MoveLeaseToken leaseToken, - MoveJobEntry manifestEntry, - IReadOnlyCollection manifest, - ValidatedTempOwnership? publishedTempOwnership, - LibraryDirectoryOwnership? targetDirectoryOwnership, - FileSystemPathSemantics sourceSemantics, - FileSystemPathSemantics targetSemantics, - CancellationToken cancellationToken) - { - await EnsureMutationAuthorizedAsync( - jobId, - leaseToken, - source, - target, - sourceSemantics, - targetSemantics, - cancellationToken); - ValidateMoveTargetRoot(target); - var ownership = await ValidateOwnedQuarantineDirectoryAsync( - quarantineRoot, - sourceParent, - jobId, - source, - target, - sourceSemantics, - targetSemantics, - leaseToken, - cancellationToken); - ValidateExistingDestinationContents( - source, - target, - manifest, - jobId, - targetSemantics, - publishedTempOwnership, - ownership, - allowPartialFiles: false, - targetDirectoryOwnership: targetDirectoryOwnership); - ValidateQuarantineMutationPath(ownership, quarantineFile); - if (!File.Exists(quarantineFile) - || (File.GetAttributes(quarantineFile) & FileAttributes.ReparsePoint) != 0 - || !await FileMatchesManifestAsync( - quarantineFile, - manifestEntry, - cancellationToken)) - { - throw new MoveNeedsAttentionException( - $"Quarantined source bytes changed before deletion: {manifestEntry.RelativePath}"); - } - - if (!FileSystemPathIdentity.TryResolveRelativePathWithinBase( - target, - manifestEntry.RelativePath, - targetSemantics, - out var targetFile)) - { - throw new MoveNeedsAttentionException( - $"Published target path escaped before source deletion: {manifestEntry.RelativePath}"); - } - - ValidateCopyMutationPath(targetFile, target); - if (!File.Exists(targetFile) - || (File.GetAttributes(targetFile) & FileAttributes.ReparsePoint) != 0 - || !await FileMatchesManifestAsync( - targetFile, - manifestEntry, - cancellationToken)) - { - throw new MoveNeedsAttentionException( - $"Published target bytes changed before source deletion: {manifestEntry.RelativePath}"); - } - - await EnsureMutationAuthorizedAsync( - jobId, - leaseToken, - source, - target, - sourceSemantics, - targetSemantics, - cancellationToken); - ValidateMoveTargetRoot(target); - ownership = await ValidateOwnedQuarantineDirectoryAsync( - quarantineRoot, - sourceParent, - jobId, - source, - target, - sourceSemantics, - targetSemantics, - leaseToken, - cancellationToken); - ValidateQuarantineMutationPath(ownership, quarantineFile); - ValidateCopyMutationPath(targetFile, target); - if (!File.Exists(quarantineFile) - || (File.GetAttributes(quarantineFile) & FileAttributes.ReparsePoint) != 0 - || new FileInfo(quarantineFile).Length != manifestEntry.Length - || !File.Exists(targetFile) - || (File.GetAttributes(targetFile) & FileAttributes.ReparsePoint) != 0 - || new FileInfo(targetFile).Length != manifestEntry.Length) - { - throw new MoveNeedsAttentionException( - $"Source or target bytes changed after lease revalidation: {manifestEntry.RelativePath}"); - } - - return ownership; - } - - private static void DeleteValidatedEmptySourceDirectory( - string source, - string directory, - FileSystemPathSemantics sourceSemantics) - { - ValidateMoveSourceRoot(source); - var reason = string.Empty; - if (!FileSystemPathIdentity.IsSameOrInside( - Path.GetFullPath(directory), - Path.GetFullPath(source), - sourceSemantics) - || !FileSystemSafety.TryValidateMutationTarget( - directory, - [source], - out directory, - out reason)) - { - throw new MoveNeedsAttentionException( - string.IsNullOrWhiteSpace(reason) - ? "An empty source directory escaped the persisted source root." - : reason); - } - - if (!Directory.Exists(directory) - || (File.GetAttributes(directory) & FileAttributes.ReparsePoint) != 0 - || Directory.EnumerateFileSystemEntries(directory).Any()) - { - throw new MoveNeedsAttentionException( - "An empty source directory changed before deletion."); - } - - if (!FileSystemSafety.TryDeleteEmptyDirectory( - directory, - [source], - out reason)) - { - throw new MoveNeedsAttentionException( - string.IsNullOrWhiteSpace(reason) - ? "The empty source directory changed before pinned deletion." - : reason); - } - } - - private static void DeleteValidatedEmptyQuarantineDirectory( - ValidatedQuarantineOwnership ownership, - string directory) - { - ValidateQuarantineMutationPath(ownership, directory); - if (!Directory.Exists(directory) - || (File.GetAttributes(directory) & FileAttributes.ReparsePoint) != 0 - || Directory.EnumerateFileSystemEntries(directory).Any()) - { - throw new MoveNeedsAttentionException( - "An empty quarantine directory changed before deletion."); - } - - if (!FileSystemSafety.TryDeleteEmptyDirectory( - directory, - [ownership.DirectoryPath], - out var reason)) - { - throw new MoveNeedsAttentionException( - string.IsNullOrWhiteSpace(reason) - ? "The empty quarantine directory changed before pinned deletion." - : reason); - } - } - -} diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.Copy.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.Copy.cs index f1a1ba9a5..c567ff138 100644 --- a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.Copy.cs +++ b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.Copy.cs @@ -4,107 +4,12 @@ namespace Listenarr.Infrastructure.Library.Moving; internal sealed partial class AudiobookContentMoveService { - private async Task CopySourceContentsAsync( - AudiobookContentMoveRequest request, - string source, - string target, - string copyDestination, - IReadOnlyList manifest, - FileSystemPathSemantics sourceSemantics, - FileSystemPathSemantics targetSemantics, - ValidatedTempOwnership? tempOwnership, - bool directCopyOwnershipValidated, - CancellationToken cancellationToken) - { - ValidateExistingDestinationContents( - source, - copyDestination, - manifest, - request.JobId, - targetSemantics, - tempOwnership, - quarantineOwnership: null, - allowPartialFiles: tempOwnership != null || directCopyOwnershipValidated, - targetDirectoryOwnership: request.TargetDirectoryOwnership); - - foreach (var manifestEntry in manifest.OrderBy(entry => entry.EntryType)) - { - cancellationToken.ThrowIfCancellationRequested(); - if (IsRootManifestEntry(manifestEntry)) - { - ValidateExistingMoveDirectory(copyDestination, "copy destination root"); - continue; - } - - if (!FileSystemPathIdentity.TryResolveRelativePathWithinBase( - copyDestination, - manifestEntry.RelativePath, - targetSemantics, - out var destinationPath)) - { - throw new MoveNeedsAttentionException( - $"Move entry destination escaped target root: {manifestEntry.RelativePath}"); - } - - if (!FileSystemSafety.TryValidateMutationTarget( - destinationPath, - [copyDestination], - out destinationPath, - out var destinationReason)) - { - throw new MoveNeedsAttentionException(destinationReason); - } - - if (manifestEntry.EntryType == MoveJobEntryType.Directory) - { - await EnsurePinnedCopyDirectoryAsync( - request, - source, - target, - copyDestination, - manifestEntry.RelativePath, - targetSemantics, - cancellationToken); - continue; - } - - if (!FileSystemPathIdentity.TryResolveRelativePathWithinBase( - source, - manifestEntry.RelativePath, - sourceSemantics, - out var entry)) - { - throw new MoveNeedsAttentionException( - $"Move entry escaped source root: {manifestEntry.RelativePath}"); - } - - await CopyFileWithRetryAsync( - request, - source, - target, - entry, - destinationPath, - manifestEntry, - copyDestination, - tempOwnership != null, - tempOwnership != null || directCopyOwnershipValidated, - sourceSemantics, - targetSemantics, - cancellationToken); - } - } - private void ValidateExistingDestinationContents( string source, string destinationRoot, IReadOnlyCollection manifest, - Guid jobId, FileSystemPathSemantics targetSemantics, - ValidatedTempOwnership? tempOwnership = null, - ValidatedQuarantineOwnership? quarantineOwnership = null, - bool allowPartialFiles = true, - LibraryDirectoryOwnership? targetDirectoryOwnership = null, - bool allowRecoveryMarker = true) + LibraryDirectoryOwnership? targetDirectoryOwnership = null) { if (!Directory.Exists(destinationRoot)) { @@ -113,16 +18,15 @@ private void ValidateExistingDestinationContents( RevalidateTargetDirectoryOwnership(targetDirectoryOwnership); if (!FileSystemSafety.TryEnumerateTreeWithoutLinks( - destinationRoot, - out var files, - out var directories, - out var reason)) + destinationRoot, + out var files, + out var directories, + out var reason)) { throw new MoveNeedsAttentionException(reason); } var expectedPaths = new HashSet(StringComparer.Ordinal); - var retentionPaths = new HashSet(StringComparer.Ordinal); foreach (var entry in manifest) { if (IsRootManifestEntry(entry)) @@ -135,48 +39,39 @@ private void ValidateExistingDestinationContents( } if (!FileSystemPathIdentity.TryResolveRelativePathWithinBase( - destinationRoot, - entry.RelativePath, - targetSemantics, - out var expectedPath)) + destinationRoot, + entry.RelativePath, + targetSemantics, + out var expectedPath)) { - throw new MoveNeedsAttentionException("A manifest entry escaped the destination root."); + throw new MoveNeedsAttentionException( + "A manifest entry escaped the destination root."); } - expectedPaths.Add(FileSystemPathIdentity.CreateKey("move-target", expectedPath, targetSemantics)); - if (entry.EntryType == MoveJobEntryType.File - && Path.GetDirectoryName(expectedPath) is { } expectedParent) - { - retentionPaths.Add(FileSystemPathIdentity.CreateKey( - "move-target-retention", - Path.Join( - expectedParent, - PinnedDestinationRetentionGuard.CreateRetentionName( - jobId, - entry.RelativePath)), - targetSemantics)); - } + expectedPaths.Add(FileSystemPathIdentity.CreateKey( + "move-target", + expectedPath, + targetSemantics)); } - var markerPath = GetRecoveryMarkerPath(destinationRoot, jobId); - var partialSuffix = $".listenarr-{jobId:N}.partial"; - var sourceInsideDestination = IsSameOrInside(source, destinationRoot, targetSemantics); + var sourceInsideDestination = IsSameOrInside( + source, + destinationRoot, + targetSemantics); foreach (var directory in directories) { - if ((quarantineOwnership != null - && IsSameOrInside( - directory, - quarantineOwnership.DirectoryPath, - targetSemantics)) - || (sourceInsideDestination - && (IsSameOrInside(directory, source, targetSemantics) - || IsSameOrInside(source, directory, targetSemantics)))) + if (sourceInsideDestination + && (IsSameOrInside(directory, source, targetSemantics) + || IsSameOrInside(source, directory, targetSemantics))) { continue; } - var key = FileSystemPathIdentity.CreateKey("move-target", directory, targetSemantics); + var key = FileSystemPathIdentity.CreateKey( + "move-target", + directory, + targetSemantics); if (!expectedPaths.Contains(key)) { throw new MoveNeedsAttentionException( @@ -186,50 +81,16 @@ private void ValidateExistingDestinationContents( foreach (var file in files) { - if (IsValidatedTargetOwnershipMarker( - file, - targetDirectoryOwnership, - targetSemantics) - || (allowRecoveryMarker - && FileSystemPathIdentity.AreEquivalent( - file, - markerPath, - targetSemantics)) - || (tempOwnership != null - && FileSystemPathIdentity.AreEquivalent( - file, - tempOwnership.MarkerPath, - targetSemantics)) - || (quarantineOwnership != null - && IsSameOrInside( - file, - quarantineOwnership.DirectoryPath, - targetSemantics)) - || (sourceInsideDestination && IsSameOrInside(file, source, targetSemantics))) + if (sourceInsideDestination + && IsSameOrInside(file, source, targetSemantics)) { continue; } - var fileKey = FileSystemPathIdentity.CreateKey( - "move-target-retention", + var key = FileSystemPathIdentity.CreateKey( + "move-target", file, targetSemantics); - if (retentionPaths.Contains(fileKey)) - { - continue; - } - - var isPartialFile = file.EndsWith(partialSuffix, StringComparison.Ordinal); - if (isPartialFile && !allowPartialFiles) - { - throw new MoveNeedsAttentionException( - $"Finalized destination contains an incomplete copy artifact: {Path.GetRelativePath(destinationRoot, file)}"); - } - - var expectedFile = isPartialFile - ? file[..^partialSuffix.Length] - : file; - var key = FileSystemPathIdentity.CreateKey("move-target", expectedFile, targetSemantics); if (!expectedPaths.Contains(key)) { throw new MoveNeedsAttentionException( @@ -237,60 +98,4 @@ private void ValidateExistingDestinationContents( } } } - - private static void RejectUnownedPartialArtifacts( - string target, - Guid jobId, - bool hasStructuredRecoveryMarker) - { - if (hasStructuredRecoveryMarker || !Directory.Exists(target)) - { - return; - } - - if (!FileSystemSafety.TryEnumerateTreeWithoutLinks( - target, - out var files, - out _, - out var reason)) - { - throw new MoveNeedsAttentionException(reason); - } - - var partialSuffix = $".listenarr-{jobId:N}.partial"; - var partial = files.FirstOrDefault(path => - path.EndsWith(partialSuffix, StringComparison.Ordinal)); - if (partial != null) - { - throw new MoveNeedsAttentionException( - $"A job-shaped partial file exists without structured move ownership and was preserved: {Path.GetRelativePath(target, partial)}"); - } - } - - private Task CopyFileWithRetryAsync( - AudiobookContentMoveRequest request, - string sourceRoot, - string target, - string sourceFile, - string destinationFile, - MoveJobEntry manifestEntry, - string destinationRoot, - bool destinationIsJobOwnedTemp, - bool destinationHasStructuredOwnership, - FileSystemPathSemantics sourceSemantics, - FileSystemPathSemantics targetSemantics, - CancellationToken cancellationToken) => - CopyFileWithPinnedRetryAsync( - request, - sourceRoot, - target, - sourceFile, - destinationFile, - manifestEntry, - destinationRoot, - destinationIsJobOwnedTemp, - destinationHasStructuredOwnership, - sourceSemantics, - targetSemantics, - cancellationToken); } diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.CopyDestinationRoot.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.CopyDestinationRoot.cs deleted file mode 100644 index 258880058..000000000 --- a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.CopyDestinationRoot.cs +++ /dev/null @@ -1,69 +0,0 @@ -using Listenarr.Domain.Common; - -namespace Listenarr.Infrastructure.Library.Moving; - -internal sealed partial class AudiobookContentMoveService -{ - private async Task EnsureCopyDestinationRootAsync( - AudiobookContentMoveRequest request, - string source, - string target, - string copyDestination, - bool useTemp, - FileSystemPathSemantics targetSemantics, - CancellationToken cancellationToken) - { - faultInjector?.OnCopyMutation( - request.JobId, - CopyMutationFaultPoint.BeforeCopyRootValidation); - if (Directory.Exists(copyDestination)) - { - return; - } - - if (useTemp) - { - throw new MoveNeedsAttentionException( - "The validated move temporary directory disappeared before copying began."); - } - - var normalizedDestination = Path.GetFullPath(copyDestination); - if (!FileSystemPathIdentity.AreEquivalent( - normalizedDestination, - Path.GetFullPath(target), - targetSemantics)) - { - throw new MoveNeedsAttentionException( - "The direct copy destination does not match the validated move target."); - } - - var parent = Path.GetDirectoryName(normalizedDestination) - ?? throw new MoveNeedsAttentionException( - "The direct copy destination has no parent directory."); - var childName = Path.GetFileName(normalizedDestination); - await EnsureMutationAuthorizedAsync( - request, - source, - target, - cancellationToken); - ValidateMoveRootPath( - normalizedDestination, - mustExist: false, - "copy destination"); - using var creation = PinnedDirectoryCreation.TryCreate(parent, childName); - if (!creation.Created) - { - throw new MoveNeedsAttentionException( - "The direct copy destination appeared before Listenarr could claim it exclusively."); - } - if (!creation.VisiblePathMatches()) - { - throw new MoveNeedsAttentionException( - "The direct copy destination parent changed during exclusive creation."); - } - - ValidateExistingMoveDirectory( - normalizedDestination, - "copy destination root"); - } -} diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.CopyStreaming.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.CopyStreaming.cs deleted file mode 100644 index 6f5e1e031..000000000 --- a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.CopyStreaming.cs +++ /dev/null @@ -1,87 +0,0 @@ -using System.Buffers; -using System.Diagnostics; - -namespace Listenarr.Infrastructure.Library.Moving; - -internal sealed partial class AudiobookContentMoveService -{ - private const int MoveCopyBufferSize = 1024 * 1024; - private const long MoveCopyLeaseCheckBytes = 64L * 1024 * 1024; - private static readonly TimeSpan MoveCopyLeaseCheckInterval = TimeSpan.FromSeconds(5); - - private async Task CopyFileWithLeaseChecksAsync( - AudiobookContentMoveRequest request, - string sourceRoot, - string target, - PinnedDirectoryCreation.PinnedFileEntry sourceEntry, - PinnedDirectoryCreation.PinnedFileEntry destinationEntry, - CancellationToken cancellationToken) - { - var buffer = ArrayPool.Shared.Rent(MoveCopyBufferSize); - try - { - await using var sourceStream = sourceEntry.OpenIndependentReadStream( - MoveCopyBufferSize, - asynchronous: false); - await using var destinationStream = destinationEntry.OpenIndependentWriteStream( - MoveCopyBufferSize, - asynchronous: false); - - var bytesSinceLeaseCheck = 0L; - var leaseCheckTimer = Stopwatch.StartNew(); - var firstChunk = true; - while (true) - { - var bytesRead = await sourceStream.ReadAsync( - buffer.AsMemory(0, MoveCopyBufferSize), - cancellationToken); - if (bytesRead == 0) - { - break; - } - - await destinationStream.WriteAsync( - buffer.AsMemory(0, bytesRead), - cancellationToken); - bytesSinceLeaseCheck += bytesRead; - - if (firstChunk && faultInjector != null) - { - faultInjector.OnCopyMutation( - request.JobId, - CopyMutationFaultPoint.AfterChunkWritten); - await EnsureMutationAuthorizedAsync( - request, - sourceRoot, - target, - cancellationToken); - firstChunk = false; - bytesSinceLeaseCheck = 0; - leaseCheckTimer.Restart(); - } - else if (bytesSinceLeaseCheck >= MoveCopyLeaseCheckBytes - || leaseCheckTimer.Elapsed >= MoveCopyLeaseCheckInterval) - { - await EnsureMutationAuthorizedAsync( - request, - sourceRoot, - target, - cancellationToken); - bytesSinceLeaseCheck = 0; - leaseCheckTimer.Restart(); - } - } - - await EnsureMutationAuthorizedAsync( - request, - sourceRoot, - target, - cancellationToken); - destinationStream.Flush(flushToDisk: true); - } - finally - { - ArrayPool.Shared.Return(buffer); - } - } -} diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.DirectoryOwnership.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.DirectoryOwnership.cs index 3c8bba10c..10381fe4c 100644 --- a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.DirectoryOwnership.cs +++ b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.DirectoryOwnership.cs @@ -1,5 +1,4 @@ using Listenarr.Domain.Common; -using Microsoft.Extensions.Logging; namespace Listenarr.Infrastructure.Library.Moving; @@ -14,16 +13,10 @@ private async Task WithValidatedTargetDirectoryOwne return request; } - if (await GetExecutionProtocolVersionAsync( - request.JobId, - cancellationToken) - >= MoveExecutionProtocol.MarkerlessDatabaseState) - { - await TryRetireReplacedMarkerlessTargetOwnershipAsync( - request, - request.Target, - cancellationToken); - } + await TryRetireReplacedMarkerlessTargetOwnershipAsync( + request, + request.Target, + cancellationToken); var ownership = await LoadValidatedTargetDirectoryOwnershipAsync( request.Target, @@ -144,14 +137,6 @@ or InvalidOperationException or NotSupportedException } } - private static bool IsValidatedTargetOwnershipMarker( - string path, - LibraryDirectoryOwnership? ownership, - FileSystemPathSemantics semantics) => - ownership != null - && LibraryDirectoryOwnershipMarker.GetMarkerPaths(ownership) - .Any(marker => FileSystemPathIdentity.AreEquivalent(marker, path, semantics)); - private async Task> LoadValidatedOwnedSourceDirectoriesAsync( string source, FileSystemPathSemantics sourceSemantics, @@ -226,42 +211,13 @@ private async Task> LoadOwnedSourceDire catch (InvalidOperationException exception) { throw new MoveNeedsAttentionException( - $"A source-directory ownership marker is invalid during cleanup: {exception.Message}"); + $"Source-directory ownership is invalid during cleanup: {exception.Message}"); } } return ownerships; } - private static IReadOnlyCollection GetOwnedSourceMarkerPaths( - string source, - IReadOnlyCollection ownerships, - FileSystemPathSemantics sourceSemantics) => - ownerships - .SelectMany(LibraryDirectoryOwnershipMarker.GetMarkerPaths) - .Where(path => FileSystemPathIdentity.IsSameOrInside( - path, - source, - sourceSemantics)) - .Distinct(sourceSemantics.Comparer) - .ToList(); - - private void TryDeleteRetiredOwnershipMarker( - LibraryDirectoryOwnership ownership) - { - if (LibraryDirectoryOwnershipMarker.TryDeleteRetiredSiblingMarker( - ownership, - out var reason)) - { - return; - } - - logger.LogWarning( - "The retired directory ownership marker for {DirectoryPath} could not be deleted: {Reason}", - LogRedaction.SanitizeFilePath(ownership.CanonicalPath), - LogRedaction.SanitizeText(reason)); - } - private async Task ResolveMarkerlessSourceDirectoryOwnershipAsync( string path, @@ -379,7 +335,6 @@ await directoryOwnershipStore.MarkRemovedAsync( ownership.Id, ownershipKey, cancellationToken); - TryDeleteRetiredOwnershipMarker(ownership); return true; } diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.FaultInjection.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.FaultInjection.cs index 29b3fc8e7..48dcfa5fe 100644 --- a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.FaultInjection.cs +++ b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.FaultInjection.cs @@ -1,55 +1,13 @@ namespace Listenarr.Infrastructure.Library.Moving; -internal enum RecoveryMarkerWriteFaultPoint -{ - BeforeTemporaryFileCreation, - DuringJsonWrite, - DuringFlush, - AfterTemporaryFileWritten, - BeforePublication, - BeforeTemporaryFileDeletion -} - -internal enum OwnershipMarkerKind -{ - TemporaryDirectory, - QuarantineDirectory, - CleanupTombstone -} - -internal enum OwnershipMarkerWriteFaultPoint -{ - BeforeTemporaryFileCreation, - DuringJsonWrite, - DuringFlush, - AfterTemporaryFileWritten, - BeforePublication, - BeforeRecoveredPublication, - BeforeTemporaryFileDeletion -} - internal enum SourceCleanupFaultPoint { - BeforeSourceFileMove, - BeforeSourceFilePublication, - BeforeQuarantineFileDelete, - BeforeQuarantineFileRemoval, - BeforePinnedQuarantineDelete, - AfterPinnedQuarantineDelete, - BeforeEmptySourceDirectoryQuarantine, - AfterEmptySourceDirectoryQuarantine, - BeforeEmptySourceClaimDelete, - BeforeEmptySourceStateDelete, AfterMarkerlessSourceFileDeleteBeforeStateUpdate, AfterMarkerlessSourceFileStateUpdate } internal enum CopyMutationFaultPoint { - BeforeCopyRootValidation, - BeforePartialFileCreation, - AfterChunkWritten, - BeforePartialPublication, AfterMarkerlessFileCreationBeforeStateUpdate, AfterMarkerlessFileStateUpdate, AfterMarkerlessFileWriteBeforePublishedState, @@ -57,53 +15,12 @@ internal enum CopyMutationFaultPoint AfterMarkerlessNativeRenameBeforeStateUpdate } -internal enum AtomicRenameFaultPoint -{ - BeforeSourceRevalidation, - BeforeDirectoryPublication, - AfterDirectoryMoveBeforeVerification -} - -internal enum TempPublicationFaultPoint -{ - BeforeFinalValidation, - BeforePublication -} - -internal enum OwnershipCleanupFaultPoint -{ - BeforeCleanupDirectoryMove, - BeforeOwnershipMarkerDelete, - BeforeDirectoryDelete, - BeforeTombstoneDelete -} - -internal enum CompletedArtifactCleanupFaultPoint -{ - BeforeRecoveryMarkerDelete, - BeforeFinalDestinationOwnershipValidation -} - internal enum TargetScaffoldPreparationFaultPoint { - BeforePublication, - AfterPublication, AfterMarkerlessDirectoryCreationBeforeStateUpdate, AfterMarkerlessDirectoryStateUpdate } -internal enum TargetScaffoldCleanupFaultPoint -{ - BeforeQuarantineRename, - AfterQuarantineRename, - BeforeQuarantineValidation, - BeforeQuarantineDelete, - DuringQuarantineDelete, - BeforeCleanupIntentStateUpdate, - AfterQuarantineDelete, - BeforeRemovedStateUpdate -} - internal enum MoveFinalizationFaultPoint { BeforeSourceAncestorDelete @@ -122,33 +39,11 @@ internal enum FinalizedVerificationFaultPoint internal interface IMoveFaultInjector { - bool AllowAtomicRename => false; bool AllowMarkerlessFileRename => false; Task AfterPublishedAsync(Guid jobId, CancellationToken cancellationToken) => Task.CompletedTask; - void OnAtomicRename(Guid jobId, AtomicRenameFaultPoint faultPoint) - { - } - - void OnTempPublication(Guid jobId, TempPublicationFaultPoint faultPoint) - { - } - - void OnRecoveryMarkerWrite( - Guid jobId, - RecoveryMarkerWriteFaultPoint faultPoint) - { - } - - void OnOwnershipMarkerWrite( - Guid jobId, - OwnershipMarkerKind markerKind, - OwnershipMarkerWriteFaultPoint faultPoint) - { - } - void OnSourceCleanupMutation( Guid jobId, SourceCleanupFaultPoint faultPoint) @@ -159,31 +54,12 @@ void OnCopyMutation(Guid jobId, CopyMutationFaultPoint faultPoint) { } - void OnOwnershipCleanup( - Guid jobId, - OwnershipMarkerKind markerKind, - OwnershipCleanupFaultPoint faultPoint) - { - } - - void OnCompletedArtifactCleanup( - Guid jobId, - CompletedArtifactCleanupFaultPoint faultPoint) - { - } - void OnTargetScaffoldPreparation( Guid jobId, TargetScaffoldPreparationFaultPoint faultPoint) { } - void OnTargetScaffoldCleanup( - Guid jobId, - TargetScaffoldCleanupFaultPoint faultPoint) - { - } - void OnMoveFinalization( Guid jobId, MoveFinalizationFaultPoint faultPoint) diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.Finalization.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.Finalization.cs index 63cf85703..17c357dae 100644 --- a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.Finalization.cs +++ b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.Finalization.cs @@ -12,11 +12,60 @@ namespace Listenarr.Infrastructure.Library.Moving; internal sealed partial class AudiobookContentMoveService { + public async Task VerifyTargetBeforeMetadataRewriteAsync( + AudiobookContentMoveRequest request, + AudiobookContentMoveResult result, + CancellationToken cancellationToken) + { + await EnsureCurrentExecutionProtocolAsync(request.JobId, cancellationToken); + request = await WithValidatedTargetDirectoryOwnershipAsync( + request, + cancellationToken); + await EnsureLeaseOwnedAsync(request.JobId, request.LeaseToken, cancellationToken); + await ValidatePersistedMoveIdentityAsync( + request.JobId, + result.Source, + result.Target, + request.SourceSemantics, + request.TargetSemantics, + request.LeaseToken, + cancellationToken); + if (!result.SourceCleanupCompleted) + { + throw new InvalidOperationException( + "Target verification before metadata rewrite requires completed source cleanup."); + } + + var manifest = await LoadManifestAsync(request.JobId, cancellationToken); + if (manifest.Count == 0) + { + throw new MoveNeedsAttentionException( + "Target verification before metadata rewrite requires a persisted manifest."); + } + + ValidateTargetManifest( + result.Target, + manifest, + request.TargetSemantics); + await VerifyMarkerlessTargetAsync( + request, + result.Target, + manifest, + cancellationToken, + targetVerificationLease: result.TargetVerificationLease); + VerifySourceCleanupState( + request, + result.Source, + result.Target, + manifest); + } + public async Task FinalizeMoveAsync( AudiobookContentMoveRequest request, AudiobookContentMoveResult result, CancellationToken cancellationToken) { + await EnsureCurrentExecutionProtocolAsync(request.JobId, cancellationToken); request = await WithValidatedTargetDirectoryOwnershipAsync( request, cancellationToken); @@ -45,8 +94,6 @@ await UpdateJobPhaseAsync( && !Directory.Exists(result.Source) && !string.IsNullOrWhiteSpace(request.SourceCleanupBoundary)) { - // The boundary is only an upper fence. Every parent deletion still requires - // a durable ownership claim for the exact live directory identity. await RemoveEmptySourceAncestorsAsync( request, result.Source, @@ -56,35 +103,14 @@ await RemoveEmptySourceAncestorsAsync( cancellationToken); } - if (await GetExecutionProtocolVersionAsync( - request.JobId, - cancellationToken) - >= MoveExecutionProtocol.MarkerlessDatabaseState) - { - var manifest = await LoadManifestAsync( - request.JobId, - cancellationToken); - VerifySourceCleanupState( - request, - result.Source, - result.Target, - manifest); - return; - } - - var tempOwnership = await TryValidatePublishedTempOwnershipAsync( - result.Target, - request, - result.Source, - result.Target, + var manifest = await LoadManifestAsync( + request.JobId, cancellationToken); - await EnsureLeaseOwnedAsync(request.JobId, request.LeaseToken, cancellationToken); - await TryDeletePublishedTempOwnershipMarkerAsync( - tempOwnership, + VerifySourceCleanupState( request, result.Source, result.Target, - cancellationToken); + manifest); } public async Task CleanupCompletedMoveArtifactsAsync( @@ -92,6 +118,7 @@ public async Task CleanupCompletedMoveArtifactsAsync( AudiobookContentMoveResult result, CancellationToken cancellationToken) { + await EnsureCurrentExecutionProtocolAsync(request.JobId, cancellationToken); request = await WithValidatedTargetDirectoryOwnershipAsync( request, cancellationToken); @@ -107,222 +134,67 @@ await ValidatePersistedMoveIdentityAsync( if (!result.SourceCleanupCompleted) { throw new InvalidOperationException( - "Completed move artifacts cannot be cleaned before source cleanup completes."); + "Completed move artifact cleanup cannot run before source cleanup completes."); } var manifest = await LoadManifestAsync(request.JobId, cancellationToken); if (manifest.Count == 0) { throw new MoveNeedsAttentionException( - "Completed move artifact cleanup requires a persisted manifest."); + "Completed move verification requires a persisted manifest."); } ValidateTargetManifest( result.Target, manifest, request.TargetSemantics); - if (await GetExecutionProtocolVersionAsync( - request.JobId, - cancellationToken) - >= MoveExecutionProtocol.MarkerlessDatabaseState) - { - try - { - await VerifyMarkerlessTargetAsync( - request, - result.Target, - manifest, - cancellationToken, - progressStart: 92, - progressSpan: 5, - progressPhase: "Final verification", - targetVerificationLease: result.TargetVerificationLease); - VerifySourceCleanupState( - request, - result.Source, - result.Target, - manifest); - await UpdateJobPhaseAsync( - request.JobId, - request.LeaseToken, - MoveJobPhase.CleaningArtifacts, - cancellationToken); - foreach (var directory in await GetCreatedDirectoriesAsync( - request.JobId, - cancellationToken)) - { - if (directory.State == MoveCreatedDirectoryState.Created) - { - await UpdateCreatedDirectoryStateAsync( - request.JobId, - request.LeaseToken, - directory.Path, - MoveCreatedDirectoryState.Retained, - cancellationToken); - } - } - } - finally - { - result.TargetVerificationLease?.Dispose(); - } - return; - } - - var publishedTempOwnership = await TryValidatePublishedTempOwnershipAsync( - result.Target, - request, - result.Source, - result.Target, - cancellationToken); - ValidateExistingDestinationContents( - result.Source, - result.Target, - manifest, - request.JobId, - request.TargetSemantics, - publishedTempOwnership, - quarantineOwnership: null, - allowPartialFiles: false, - targetDirectoryOwnership: request.TargetDirectoryOwnership); - await VerifyPublishedManifestAsync( - result.Target, - manifest, - request.TargetSemantics, - cancellationToken); - VerifySourceCleanupState( - request, - result.Source, - result.Target, - manifest); - - if (!RecoveryMarkerEntryExists(result.RecoveryMarkerPath)) + try { + await VerifyMarkerlessTargetAsync( + request, + result.Target, + manifest, + cancellationToken, + progressStart: 92, + progressSpan: 5, + progressPhase: "Final verification", + targetVerificationLease: result.TargetVerificationLease); + VerifySourceCleanupState( + request, + result.Source, + result.Target, + manifest); await UpdateJobPhaseAsync( request.JobId, request.LeaseToken, MoveJobPhase.CleaningArtifacts, cancellationToken); - await RetainTargetScaffoldingAsync(request, cancellationToken); - return; - } - - ValidateMoveTargetRoot(result.Target); - var recoveryMarker = ReadRecoveryMarker(result.RecoveryMarkerPath); - if (recoveryMarker == null) - { - throw new MoveNeedsAttentionException( - "Completed move artifact cleanup requires a structured recovery marker."); + foreach (var directory in await GetCreatedDirectoriesAsync( + request.JobId, + cancellationToken)) + { + if (directory.State == MoveCreatedDirectoryState.Created) + { + await UpdateCreatedDirectoryStateAsync( + request.JobId, + request.LeaseToken, + directory.Path, + MoveCreatedDirectoryState.Retained, + cancellationToken); + } + } } - - ValidateRecoveryMarker( - recoveryMarker, - request, - result.Source, - result.Target); - ValidateRecoveryMarkerLocation( - result.RecoveryMarkerPath, - result.Target, - request.TargetSemantics); - ValidateMoveTargetRoot(result.Target); - ValidateRecoveryMarker( - ReadRecoveryMarker(result.RecoveryMarkerPath), - request, - result.Source, - result.Target); - ValidateRecoveryMarkerLocation( - result.RecoveryMarkerPath, - result.Target, - request.TargetSemantics); - if (RecoveryMarkerPathIsLinked(result.RecoveryMarkerPath)) + finally { - throw new MoveNeedsAttentionException( - "The completed recovery marker became a symbolic link or reparse point."); + result.TargetVerificationLease?.Dispose(); } - - await UpdateJobPhaseAsync( - request.JobId, - request.LeaseToken, - MoveJobPhase.CleaningArtifacts, - cancellationToken); - faultInjector?.OnCompletedArtifactCleanup( - request.JobId, - CompletedArtifactCleanupFaultPoint.BeforeRecoveryMarkerDelete); - VerifySourceCleanupState( - request, - result.Source, - result.Target, - manifest); - await EnsureLeaseOwnedAsync(request.JobId, request.LeaseToken, cancellationToken); - ValidateMoveTargetRoot(result.Target); - var finalTempOwnership = await TryValidatePublishedTempOwnershipAsync( - result.Target, - request, - result.Source, - result.Target, - cancellationToken); - ValidateExistingDestinationContents( - result.Source, - result.Target, - manifest, - request.JobId, - request.TargetSemantics, - finalTempOwnership, - quarantineOwnership: null, - allowPartialFiles: false, - targetDirectoryOwnership: request.TargetDirectoryOwnership); - await VerifyPublishedManifestAsync( - result.Target, - manifest, - request.TargetSemantics, - cancellationToken); - faultInjector?.OnCompletedArtifactCleanup( - request.JobId, - CompletedArtifactCleanupFaultPoint.BeforeFinalDestinationOwnershipValidation); - await EnsureMutationAuthorizedAsync( - request, - result.Source, - result.Target, - cancellationToken); - VerifySourceCleanupState( - request, - result.Source, - result.Target, - manifest); - ValidateMoveTargetRoot(result.Target); - ValidateExistingDestinationContents( - result.Source, - result.Target, - manifest, - request.JobId, - request.TargetSemantics, - finalTempOwnership, - quarantineOwnership: null, - allowPartialFiles: false, - targetDirectoryOwnership: request.TargetDirectoryOwnership); - ValidateRecoveryMarkerLocation( - result.RecoveryMarkerPath, - result.Target, - request.TargetSemantics); - await RetirePinnedArtifactAsync( - result.RecoveryMarkerPath, - entry => ValidateRecoveryMarker( - ReadRecoveryMarker(entry, result.RecoveryMarkerPath), - request, - result.Source, - result.Target), - () => EnsureMutationAuthorizedAsync( - request, - result.Source, - result.Target, - cancellationToken)); - await RetainTargetScaffoldingAsync(request, cancellationToken); } public async Task MarkCompletionRecordingAsync( AudiobookContentMoveRequest request, CancellationToken cancellationToken) { + await EnsureCurrentExecutionProtocolAsync(request.JobId, cancellationToken); await EnsureLeaseOwnedAsync(request.JobId, request.LeaseToken, cancellationToken); await UpdateJobPhaseAsync( request.JobId, diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.FinalizedVerification.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.FinalizedVerification.cs index 9cd717ed3..954981ebd 100644 --- a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.FinalizedVerification.cs +++ b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.FinalizedVerification.cs @@ -7,72 +7,42 @@ public async Task VerifyFinalizedMoveAsync( CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(request); - cancellationToken.ThrowIfCancellationRequested(); - request = await WithValidatedTargetDirectoryOwnershipAsync( - request, + await EnsureLeaseOwnedAsync( + request.JobId, + request.LeaseToken, + cancellationToken); + await EnsureCurrentExecutionProtocolAsync( + request.JobId, cancellationToken); - - var source = NormalizeMoveDirectoryEndpoint(request.Source); - var target = NormalizeMoveDirectoryEndpoint(request.Target); - await EnsureLeaseOwnedAsync(request.JobId, request.LeaseToken, cancellationToken); await ValidatePersistedMoveIdentityAsync( request.JobId, - source, - target, + request.Source, + request.Target, request.SourceSemantics, request.TargetSemantics, request.LeaseToken, cancellationToken); - - ValidateMoveRootPath(source, mustExist: false, "source recovery"); - ValidateMoveTargetRoot(target); - if (!Directory.Exists(target)) - { - throw new MoveNeedsAttentionException( - "The finalized move target no longer exists."); - } - - var manifest = await LoadManifestAsync(request.JobId, cancellationToken); + request = await WithValidatedTargetDirectoryOwnershipAsync( + request, + cancellationToken); + var manifest = await LoadManifestAsync( + request.JobId, + cancellationToken); if (manifest.Count == 0) { throw new MoveNeedsAttentionException( - "A markerless finalized move cannot be verified without a persisted manifest."); + "Finalized move verification requires a persisted manifest."); } - faultInjector?.OnFinalizedVerification( - request.JobId, - FinalizedVerificationFaultPoint.BeforeManifestVerification); - ValidateTargetManifest(target, manifest, request.TargetSemantics); - var tempOwnership = await TryValidatePublishedTempOwnershipAsync( - target, + await VerifyMarkerlessTargetAsync( request, - source, - target, - cancellationToken); - var quarantineOwnership = await TryValidateExistingQuarantineDirectoryAsync( - source, - target, - request.JobId, - request.SourceSemantics, - request.TargetSemantics, - request.LeaseToken, - cancellationToken); - ValidateExistingDestinationContents( - source, - target, - manifest, - request.JobId, - request.TargetSemantics, - tempOwnership, - quarantineOwnership, - allowPartialFiles: false, - targetDirectoryOwnership: request.TargetDirectoryOwnership); - await VerifyPublishedManifestAsync( - target, + request.Target, manifest, - request.TargetSemantics, cancellationToken); - - VerifySourceCleanupState(request, source, target, manifest); + VerifySourceCleanupState( + request, + request.Source, + request.Target, + manifest); } } diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.IdenticalEndpointValidation.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.IdenticalEndpointValidation.cs index 2bc37dc71..bbe04d816 100644 --- a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.IdenticalEndpointValidation.cs +++ b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.IdenticalEndpointValidation.cs @@ -8,10 +8,16 @@ public async Task VerifyNoFilesystemMoveStartedAsync( { ArgumentNullException.ThrowIfNull(request); cancellationToken.ThrowIfCancellationRequested(); + await EnsureLeaseOwnedAsync( + request.JobId, + request.LeaseToken, + cancellationToken); + await EnsureCurrentExecutionProtocolAsync( + request.JobId, + cancellationToken); var source = NormalizeMoveDirectoryEndpoint(request.Source); var target = NormalizeMoveDirectoryEndpoint(request.Target); - await EnsureLeaseOwnedAsync(request.JobId, request.LeaseToken, cancellationToken); await ValidatePersistedMoveIdentityAsync( request.JobId, source, @@ -22,7 +28,9 @@ await ValidatePersistedMoveIdentityAsync( cancellationToken); var manifest = await LoadManifestAsync(request.JobId, cancellationToken); - var scaffolding = await GetCreatedDirectoriesAsync(request.JobId, cancellationToken); + var scaffolding = await GetCreatedDirectoriesAsync( + request.JobId, + cancellationToken); var manifestHasExecutionState = manifest.Any(entry => entry.CopyState != MoveJobEntryCopyState.Pending || entry.CleanupState != MoveJobEntryCleanupState.Pending); @@ -31,111 +39,5 @@ await ValidatePersistedMoveIdentityAsync( throw new MoveNeedsAttentionException( "The identical-endpoint job has durable move execution state and cannot be superseded automatically."); } - - var endpoints = new HashSet(StringComparer.Ordinal) - { - source, - target - }; - foreach (var endpoint in endpoints) - { - VerifyEndpointContainsNoJobArtifacts(endpoint, request.JobId); - VerifyEndpointParentContainsNoJobArtifacts(endpoint, request.JobId); - } - } - - private static void VerifyEndpointContainsNoJobArtifacts( - string endpoint, - Guid jobId) - { - if (!TryGetExistingPathAttributes(endpoint, out var attributes)) - { - return; - } - - if ((attributes & FileAttributes.Directory) == 0 - || (attributes & FileAttributes.ReparsePoint) != 0) - { - throw new MoveNeedsAttentionException( - "The identical move endpoint is a file, symbolic link, or reparse point."); - } - - if (!FileSystemSafety.TryEnumerateTreeWithoutLinks( - endpoint, - out var files, - out _, - out var reason)) - { - throw new MoveNeedsAttentionException(reason); - } - - var markerName = $".listenarr-move-{jobId:N}.pending"; - var partialSuffix = $".listenarr-{jobId:N}.partial"; - if (files.Any(file => - string.Equals(Path.GetFileName(file), markerName, StringComparison.Ordinal) - || Path.GetFileName(file).StartsWith(markerName + ".writing-", StringComparison.Ordinal) - || file.EndsWith(partialSuffix, StringComparison.Ordinal))) - { - throw new MoveNeedsAttentionException( - "The identical-endpoint job has move-owned filesystem artifacts and cannot be superseded automatically."); - } - } - - private static void VerifyEndpointParentContainsNoJobArtifacts( - string endpoint, - Guid jobId) - { - var parent = Path.GetDirectoryName(endpoint); - if (string.IsNullOrWhiteSpace(parent)) - { - return; - } - - var tempDirectory = Path.Join( - parent, - Path.GetFileName(endpoint) + ".tmp-" + jobId.ToString("N")); - var quarantine = Path.Join(parent, $".listenarr-quarantine-{jobId:N}"); - var targetScaffoldTemporary = Path.Join( - parent, - $".listenarr-scaffold-{jobId:N}"); - var targetScaffoldQuarantine = Path.Join( - parent, - $".listenarr-scaffold-cleanup-{jobId:N}"); - var possibleArtifacts = new[] - { - tempDirectory, - quarantine, - targetScaffoldTemporary, - targetScaffoldQuarantine, - GetCleanupDirectoryPath(tempDirectory, TemporaryDirectoryArtifactType, jobId), - GetCleanupDirectoryPath(quarantine, QuarantineDirectoryArtifactType, jobId) - }; - var cleanupTombstones = new[] - { - GetCleanupTombstonePath(tempDirectory, TemporaryDirectoryArtifactType, jobId), - GetCleanupTombstonePath(quarantine, QuarantineDirectoryArtifactType, jobId), - GetCleanupTombstonePath( - targetScaffoldTemporary, - TargetScaffoldTemporaryArtifactType, - jobId), - GetCleanupTombstonePath( - targetScaffoldQuarantine, - TargetScaffoldQuarantineArtifactType, - jobId) - }; - var hasSiblingArtifact = possibleArtifacts.Any(path => - TryGetExistingPathAttributes(path, out _)); - var hasCleanupTombstone = false; - if (TryGetExistingPathAttributes(parent, out _)) - { - ValidateExistingMoveDirectory(parent, "identical-endpoint artifact directory"); - hasCleanupTombstone = cleanupTombstones.Any(HasCleanupTombstoneEvidence); - } - - if (hasSiblingArtifact || hasCleanupTombstone) - { - throw new MoveNeedsAttentionException( - "The identical-endpoint job has move-owned sibling artifacts and cannot be superseded automatically."); - } } } diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.LegacyRecoverySafety.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.LegacyRecoverySafety.cs deleted file mode 100644 index dc24c414c..000000000 --- a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.LegacyRecoverySafety.cs +++ /dev/null @@ -1,112 +0,0 @@ -namespace Listenarr.Infrastructure.Library.Moving; - -internal sealed partial class AudiobookContentMoveService -{ - private static bool HasLegacyFilesystemRecoveryArtifacts( - string source, - string target, - Guid jobId) - { - var sourceMarker = GetRecoveryMarkerPath(source, jobId); - var targetMarker = GetRecoveryMarkerPath(target, jobId); - if (HasMarkerPublicationEvidence(source, sourceMarker) - || HasMarkerPublicationEvidence(target, targetMarker)) - { - return true; - } - - var targetParent = Path.GetDirectoryName(target); - if (!string.IsNullOrWhiteSpace(targetParent)) - { - var tempDirectory = Path.Join( - targetParent, - Path.GetFileName(target) + ".tmp-" + jobId.ToString("N")); - if (ArtifactPathExists(tempDirectory) - || ArtifactPathExists(GetCleanupDirectoryPath( - tempDirectory, - TemporaryDirectoryArtifactType, - jobId)) - || HasMarkerPublicationEvidence( - targetParent, - GetCleanupTombstonePath( - tempDirectory, - TemporaryDirectoryArtifactType, - jobId))) - { - return true; - } - } - - var sourceParent = Path.GetDirectoryName(source); - if (!string.IsNullOrWhiteSpace(sourceParent)) - { - var quarantineDirectory = Path.Join( - sourceParent, - $".listenarr-quarantine-{jobId:N}"); - if (ArtifactPathExists(quarantineDirectory) - || ArtifactPathExists(GetCleanupDirectoryPath( - quarantineDirectory, - QuarantineDirectoryArtifactType, - jobId)) - || HasMarkerPublicationEvidence( - sourceParent, - GetCleanupTombstonePath( - quarantineDirectory, - QuarantineDirectoryArtifactType, - jobId))) - { - return true; - } - } - - if (!Directory.Exists(target)) - { - return false; - } - - ValidateExistingMoveDirectory(target, "legacy recovery target"); - var publishedTempMarker = Path.Join(target, TempOwnershipMarkerFileName); - if (HasMarkerPublicationEvidence(target, publishedTempMarker)) - { - return true; - } - - if (!FileSystemSafety.TryEnumerateTreeWithoutLinks( - target, - out var files, - out _, - out var reason)) - { - throw new MoveNeedsAttentionException( - $"Legacy move recovery artifacts could not be inspected safely: {reason}"); - } - - var partialSuffix = $".listenarr-{jobId:N}.partial"; - return files.Any(file => - file.EndsWith(partialSuffix, StringComparison.Ordinal)); - } - - private static bool HasMarkerPublicationEvidence( - string directory, - string markerPath) - { - if (ArtifactPathExists(markerPath)) - { - return true; - } - - if (!Directory.Exists(directory)) - { - return false; - } - - ValidateExistingMoveDirectory(directory, "legacy recovery artifact directory"); - return Directory.EnumerateFiles( - directory, - Path.GetFileName(markerPath) + ".writing-*", - SearchOption.TopDirectoryOnly).Any(); - } - - private static bool ArtifactPathExists(string path) => - File.Exists(path) || Directory.Exists(path); -} diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.Manifest.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.Manifest.cs index a30d62725..cc2581d34 100644 --- a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.Manifest.cs +++ b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.Manifest.cs @@ -4,36 +4,6 @@ namespace Listenarr.Infrastructure.Library.Moving; internal sealed partial class AudiobookContentMoveService { - private async Task> SnapshotSourceAsync( - Guid jobId, - string source, - string target, - bool targetInsideSource, - FileSystemPathSemantics sourceSemantics, - CancellationToken cancellationToken, - string? ownedRecoveryMarkerPath = null) - { - var scaffolding = await GetCreatedDirectoriesAsync(jobId, cancellationToken); - var ownedSourceDirectories = await LoadValidatedOwnedSourceDirectoriesAsync( - source, - sourceSemantics, - cancellationToken); - var ownedSourceMarkerPaths = GetOwnedSourceMarkerPaths( - source, - ownedSourceDirectories, - sourceSemantics); - var validatedEntries = ValidateSourceTreeForMove( - source, - target, - targetInsideSource, - sourceSemantics, - cancellationToken, - ownedRecoveryMarkerPath, - scaffolding.Select(directory => directory.Path).ToList(), - ownedDirectoryMarkerPaths: ownedSourceMarkerPaths); - return await BuildManifestAsync(jobId, validatedEntries, cancellationToken); - } - internal static void ValidateTargetManifest( string target, IReadOnlyCollection manifest, diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.MarkerWriteFiles.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.MarkerWriteFiles.cs deleted file mode 100644 index 644c44c7f..000000000 --- a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.MarkerWriteFiles.cs +++ /dev/null @@ -1,139 +0,0 @@ -namespace Listenarr.Infrastructure.Library.Moving; - -internal sealed partial class AudiobookContentMoveService -{ - private const long MaximumMarkerLength = 64 * 1024; - - private enum MarkerReadState - { - Missing, - Valid, - CorruptOrTruncated, - TemporarilyUnreadable, - Unsupported - } - - private readonly record struct MarkerReadResult( - MarkerReadState State, - T? Marker = default, - Exception? Error = null); - - private readonly record struct MarkerWriteIdentity( - Guid JobId, - int LeaseGeneration); - - private sealed class InterruptedOwnershipPublicationException(string message) - : IOException(message); - - private static MarkerReadResult ReadJsonMarker(string path) - { - if (!File.Exists(path)) - { - return new MarkerReadResult(MarkerReadState.Missing); - } - - try - { - var fileInfo = new FileInfo(path); - if (fileInfo.Length > MaximumMarkerLength) - { - return new MarkerReadResult(MarkerReadState.CorruptOrTruncated); - } - - var marker = System.Text.Json.JsonSerializer.Deserialize( - File.ReadAllText(path)); - return marker == null - ? new MarkerReadResult(MarkerReadState.CorruptOrTruncated) - : new MarkerReadResult(MarkerReadState.Valid, marker); - } - catch (System.Text.Json.JsonException exception) - { - return new MarkerReadResult( - MarkerReadState.CorruptOrTruncated, - Error: exception); - } - catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) - { - return new MarkerReadResult( - MarkerReadState.TemporarilyUnreadable, - Error: exception); - } - } - - private static MarkerReadResult ReadJsonMarker( - PinnedDirectoryCreation.PinnedFileEntry entry) - { - ArgumentNullException.ThrowIfNull(entry); - try - { - using var stream = entry.OpenReadStream( - bufferSize: 4096, - asynchronous: false); - if (stream.Length > MaximumMarkerLength) - { - return new MarkerReadResult(MarkerReadState.CorruptOrTruncated); - } - - stream.Position = 0; - var marker = System.Text.Json.JsonSerializer.Deserialize(stream); - return marker == null - ? new MarkerReadResult(MarkerReadState.CorruptOrTruncated) - : new MarkerReadResult(MarkerReadState.Valid, marker); - } - catch (System.Text.Json.JsonException exception) - { - return new MarkerReadResult( - MarkerReadState.CorruptOrTruncated, - Error: exception); - } - catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) - { - return new MarkerReadResult( - MarkerReadState.TemporarilyUnreadable, - Error: exception); - } - } - - private static string CreateMarkerWritePath( - string markerPath, - Guid jobId, - int leaseGeneration) => - markerPath - + $".writing-{jobId:N}-g{leaseGeneration}-{Guid.NewGuid():N}"; - - private static bool TryParseMarkerWriteIdentity( - string writePath, - string markerPath, - out MarkerWriteIdentity identity) - { - identity = default; - var fileName = Path.GetFileName(writePath); - var prefix = Path.GetFileName(markerPath) + ".writing-"; - if (!fileName.StartsWith(prefix, StringComparison.Ordinal)) - { - return false; - } - - var suffix = fileName[prefix.Length..]; - var generationSeparator = suffix.IndexOf("-g", StringComparison.Ordinal); - if (generationSeparator != 32 - || !Guid.TryParseExact(suffix[..generationSeparator], "N", out var jobId)) - { - return false; - } - - var uniqueSeparator = suffix.IndexOf('-', generationSeparator + 2); - if (uniqueSeparator <= generationSeparator + 2 - || !int.TryParse( - suffix.AsSpan(generationSeparator + 2, uniqueSeparator - generationSeparator - 2), - out var leaseGeneration) - || leaseGeneration <= 0 - || !Guid.TryParseExact(suffix[(uniqueSeparator + 1)..], "N", out _)) - { - return false; - } - - identity = new MarkerWriteIdentity(jobId, leaseGeneration); - return true; - } -} diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.Markerless.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.Markerless.cs index 0f4bb3627..a9efb5467 100644 --- a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.Markerless.cs +++ b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.Markerless.cs @@ -12,12 +12,6 @@ private async Task MoveContentsMarkerlessAsync( bool sourceInsideTarget, CancellationToken cancellationToken) { - if (HasLegacyFilesystemRecoveryArtifacts(source, target, request.JobId)) - { - throw new MoveNeedsAttentionException( - "A markerless move encountered legacy filesystem recovery artifacts. They were preserved for explicit recovery."); - } - await ReportProgressAsync(request, 2, "Preparing", cancellationToken); var manifest = await LoadManifestAsync(request.JobId, cancellationToken); if (manifest.Count == 0) @@ -94,11 +88,7 @@ await ValidatePersistedSourceManifestAsync( targetInsideSource, request.SourceSemantics, cancellationToken, - ownedRecoveryMarkerPath: null, - ownedScaffoldPaths: [], - structuralSpinePaths: targetStructuralSpine, - ownedDirectoryMarkerPaths: [], - request.SourceCleanupBoundary); + structuralSpinePaths: targetStructuralSpine); await ReportProgressAsync(request, 3, "Capturing source", cancellationToken); await CaptureMarkerlessSourceIdentitiesAsync( @@ -130,12 +120,8 @@ await TryRetireReplacedMarkerlessTargetOwnershipAsync( source, target, manifest, - request.JobId, request.TargetSemantics, - tempOwnership: null, - quarantineOwnership: null, - allowPartialFiles: false, - targetDirectoryOwnership: request.TargetDirectoryOwnership); + request.TargetDirectoryOwnership); await UpdateJobPhaseAsync( request.JobId, request.LeaseToken, @@ -287,7 +273,6 @@ private static AudiobookContentMoveResult CreateMarkerlessMoveResult( target, targetInsideSource, sourceInsideTarget, - RecoveryMarkerPath: string.Empty, SourceCleanupCompleted: true, targetIdentities, targetVerificationLease); @@ -354,7 +339,6 @@ await VerifyMarkerlessTargetAsync( target, IsSameOrInside(target, source, request.SourceSemantics), IsSameOrInside(source, target, request.TargetSemantics), - RecoveryMarkerPath: string.Empty, SourceCleanupCompleted: true, identities); } diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.MarkerlessCleanup.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.MarkerlessCleanup.cs index 2bd9d6950..e7e4f70bc 100644 --- a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.MarkerlessCleanup.cs +++ b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.MarkerlessCleanup.cs @@ -14,17 +14,11 @@ private async Task DeleteMarkerlessSourceAsync( .Where(candidate => candidate.EntryType == MoveJobEntryType.File) .Where(IsPhysicalManifestEntry) .ToList(); - var resumingCleanup = manifest - .Where(IsPhysicalManifestEntry) - .Any(entry => entry.CleanupState != MoveJobEntryCleanupState.Pending); - if (resumingCleanup) - { - await VerifyMarkerlessTargetAsync( - request, - target, - manifest, - cancellationToken); - } + await VerifyMarkerlessTargetAsync( + request, + target, + manifest, + cancellationToken); var totalUnits = files.Sum(GetProgressUnits); var completedUnits = files @@ -95,7 +89,7 @@ private async Task DeleteMarkerlessSourceFileAsync( "target"); if (!File.Exists(sourcePath)) { - if (entry.CleanupState == MoveJobEntryCleanupState.DeletionAuthorized) + if (entry.CleanupState == MoveJobEntryCleanupState.DeleteAuthorized) { await UpdateCleanupStateAsync( request.JobId, @@ -174,9 +168,9 @@ await UpdateCleanupStateAsync( request.JobId, request.LeaseToken, entry.RelativePath, - MoveJobEntryCleanupState.DeletionAuthorized, + MoveJobEntryCleanupState.DeleteAuthorized, cancellationToken); - entry.CleanupState = MoveJobEntryCleanupState.DeletionAuthorized; + entry.CleanupState = MoveJobEntryCleanupState.DeleteAuthorized; } await EnsureMutationAuthorizedAsync( request, diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.MarkerlessDirectories.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.MarkerlessDirectories.cs index bb1444319..d0d834ffa 100644 --- a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.MarkerlessDirectories.cs +++ b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.MarkerlessDirectories.cs @@ -92,14 +92,15 @@ await RetainUnexplainedMarkerlessDirectoryAsync( var parentPath = Path.GetDirectoryName(path) ?? throw new MoveNeedsAttentionException( "A markerless target directory has no parent."); + using var parent = PinnedDirectoryCreation.OpenPinnedBoundary(parentPath); await EnsureMutationAuthorizedAsync( request, request.Source, request.Target, cancellationToken); - using var creation = PinnedDirectoryCreation.TryCreate( - parentPath, - Path.GetFileName(path)); + using var creation = TryCreateMarkerlessTargetDirectoryForPublication( + parent, + path); if (!creation.Created) { throw new MoveNeedsAttentionException( @@ -278,6 +279,24 @@ private static void AddTargetDirectoryChain( } } + private static PinnedDirectoryCreation TryCreateMarkerlessTargetDirectoryForPublication( + PinnedDirectoryCreation.PinnedDirectoryAnchor parent, + string path) + { + try + { + return parent.TryCreateChildForPublication(Path.GetFileName(path)); + } + catch (Exception exception) when (exception is + IOException or UnauthorizedAccessException or InvalidOperationException + or NotSupportedException or PathTooLongException + or System.ComponentModel.Win32Exception) + { + throw new MoveNeedsAttentionException( + $"The markerless target directory parent changed before creation: {path}. {exception.Message}"); + } + } + private static void ValidateMarkerlessCreatedDirectory( MoveJobCreatedDirectory planned) { diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.MarkerlessDirectoryCleanup.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.MarkerlessDirectoryCleanup.cs index b36a4259f..e6fa8ec91 100644 --- a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.MarkerlessDirectoryCleanup.cs +++ b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.MarkerlessDirectoryCleanup.cs @@ -28,7 +28,7 @@ private async Task DeleteMarkerlessSourceDirectoryAsync( if (!Directory.Exists(sourcePath)) { if (entry.CleanupState is - MoveJobEntryCleanupState.DeletionAuthorized + MoveJobEntryCleanupState.DeleteAuthorized or MoveJobEntryCleanupState.Deleted) { if (ownership != null) @@ -105,9 +105,9 @@ await UpdateCleanupStateAsync( request.JobId, request.LeaseToken, entry.RelativePath, - MoveJobEntryCleanupState.DeletionAuthorized, + MoveJobEntryCleanupState.DeleteAuthorized, cancellationToken); - entry.CleanupState = MoveJobEntryCleanupState.DeletionAuthorized; + entry.CleanupState = MoveJobEntryCleanupState.DeleteAuthorized; } if (ownership == null) @@ -199,7 +199,7 @@ await UpdateSourceDirectoryCleanupStateAsync( if (!Directory.Exists(source)) { if (endpoints.SourceDirectoryCleanupState is - MoveJobEntryCleanupState.DeletionAuthorized + MoveJobEntryCleanupState.DeleteAuthorized or MoveJobEntryCleanupState.Deleted) { if (ownership != null) @@ -277,7 +277,7 @@ await UpdateSourceDirectoryCleanupStateAsync( await UpdateSourceDirectoryCleanupStateAsync( request.JobId, request.LeaseToken, - MoveJobEntryCleanupState.DeletionAuthorized, + MoveJobEntryCleanupState.DeleteAuthorized, cancellationToken); } diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.MarkerlessEntryPreflight.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.MarkerlessEntryPreflight.cs index eef44f979..d2c80e34b 100644 --- a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.MarkerlessEntryPreflight.cs +++ b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.MarkerlessEntryPreflight.cs @@ -5,18 +5,16 @@ internal sealed partial class AudiobookContentMoveService private async Task ValidateMoveSourceRootForExecutionAsync( Guid jobId, string source, - int executionProtocolVersion, CancellationToken cancellationToken) { - if (executionProtocolVersion >= MoveExecutionProtocol.MarkerlessDatabaseState - && !Directory.Exists(source) + if (!Directory.Exists(source) && !File.Exists(source)) { var endpoints = await GetEndpointObjectIdentitiesAsync( jobId, cancellationToken); if (endpoints.SourceDirectoryCleanupState is - MoveJobEntryCleanupState.DeletionAuthorized + MoveJobEntryCleanupState.DeleteAuthorized or MoveJobEntryCleanupState.Deleted) { return; diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.MarkerlessRename.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.MarkerlessRename.cs index 830d3aea5..1b391646f 100644 --- a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.MarkerlessRename.cs +++ b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.MarkerlessRename.cs @@ -275,9 +275,9 @@ await UpdateCleanupStateAsync( request.JobId, request.LeaseToken, entry.RelativePath, - MoveJobEntryCleanupState.DeletionAuthorized, + MoveJobEntryCleanupState.DeleteAuthorized, cancellationToken); - entry.CleanupState = MoveJobEntryCleanupState.DeletionAuthorized; + entry.CleanupState = MoveJobEntryCleanupState.DeleteAuthorized; await UpdateCleanupStateAsync( request.JobId, request.LeaseToken, diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.MarkerlessVerification.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.MarkerlessVerification.cs index a5805fbba..9b3236337 100644 --- a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.MarkerlessVerification.cs +++ b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.MarkerlessVerification.cs @@ -23,13 +23,8 @@ await CaptureOrValidateMarkerlessTargetRootAsync( request.Source, target, manifest, - request.JobId, request.TargetSemantics, - tempOwnership: null, - quarantineOwnership: null, - allowPartialFiles: false, - targetDirectoryOwnership: request.TargetDirectoryOwnership, - allowRecoveryMarker: false); + request.TargetDirectoryOwnership); var files = manifest .Where(IsPhysicalManifestEntry) .Where(entry => entry.EntryType == MoveJobEntryType.File) diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.OwnershipCleanup.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.OwnershipCleanup.cs deleted file mode 100644 index bb2931955..000000000 --- a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.OwnershipCleanup.cs +++ /dev/null @@ -1,479 +0,0 @@ -using Listenarr.Domain.Common; - -namespace Listenarr.Infrastructure.Library.Moving; - -internal sealed partial class AudiobookContentMoveService -{ - private async Task DeleteOwnedDirectoryWithTombstoneAsync( - string directoryPath, - string markerPath, - string ownedArtifactType, - Guid jobId, - string source, - string target, - FileSystemPathSemantics sourceSemantics, - FileSystemPathSemantics targetSemantics, - FileSystemPathSemantics directorySemantics, - MoveLeaseToken leaseToken, - Func authorizeMutation) - { - var fullDirectory = Path.GetFullPath(directoryPath); - var cleanupDirectory = GetCleanupDirectoryPath( - fullDirectory, - ownedArtifactType, - jobId); - var tombstonePath = GetCleanupTombstonePath( - fullDirectory, - ownedArtifactType, - jobId); - var expectedTombstone = CreateOwnershipMarker( - CleanupTombstoneArtifactType, - jobId, - source, - target, - cleanupDirectory, - ownedArtifactType, - fullDirectory); - - await EnsureCleanupTombstoneAsync( - tombstonePath, - expectedTombstone, - sourceSemantics, - targetSemantics, - directorySemantics, - leaseToken, - authorizeMutation); - var prepared = await PrepareOwnedDirectoryCleanupAsync( - fullDirectory, - markerPath, - expectedTombstone, - sourceSemantics, - targetSemantics, - directorySemantics, - authorizeMutation); - await CompleteOwnedDirectoryCleanupAsync( - prepared.DirectoryPath, - prepared.MarkerPath, - tombstonePath, - expectedTombstone, - sourceSemantics, - targetSemantics, - directorySemantics, - authorizeMutation); - } - - private async Task TryCompleteOwnedDirectoryCleanupAsync( - string directoryPath, - string markerPath, - string ownedArtifactType, - Guid jobId, - string source, - string target, - FileSystemPathSemantics sourceSemantics, - FileSystemPathSemantics targetSemantics, - FileSystemPathSemantics directorySemantics, - MoveLeaseToken leaseToken, - Func authorizeMutation) - { - var fullDirectory = Path.GetFullPath(directoryPath); - var cleanupDirectory = GetCleanupDirectoryPath( - fullDirectory, - ownedArtifactType, - jobId); - var tombstonePath = GetCleanupTombstonePath( - fullDirectory, - ownedArtifactType, - jobId); - var tombstoneWritePrefix = Path.GetFileName(tombstonePath) + ".writing-"; - var parent = Path.GetDirectoryName(tombstonePath) - ?? throw new MoveNeedsAttentionException("The cleanup tombstone parent is unavailable."); - var hasTombstoneEvidence = File.Exists(tombstonePath) - || Directory.EnumerateFiles( - parent, - tombstoneWritePrefix + "*", - SearchOption.TopDirectoryOnly).Any(); - if (!hasTombstoneEvidence) - { - return false; - } - - var expectedTombstone = CreateOwnershipMarker( - CleanupTombstoneArtifactType, - jobId, - source, - target, - cleanupDirectory, - ownedArtifactType, - fullDirectory); - await authorizeMutation(); - try - { - await RecoverOrReadOwnershipMarkerAsync( - tombstonePath, - expectedTombstone, - sourceSemantics, - targetSemantics, - directorySemantics, - leaseToken, - authorizeMutation); - } - catch (InterruptedOwnershipPublicationException) - { - return false; - } - - var prepared = await PrepareOwnedDirectoryCleanupAsync( - fullDirectory, - markerPath, - expectedTombstone, - sourceSemantics, - targetSemantics, - directorySemantics, - authorizeMutation); - await CompleteOwnedDirectoryCleanupAsync( - prepared.DirectoryPath, - prepared.MarkerPath, - tombstonePath, - expectedTombstone, - sourceSemantics, - targetSemantics, - directorySemantics, - authorizeMutation); - return true; - } - - private async Task EnsureCleanupTombstoneAsync( - string tombstonePath, - MoveOwnershipMarker expectedTombstone, - FileSystemPathSemantics sourceSemantics, - FileSystemPathSemantics targetSemantics, - FileSystemPathSemantics directorySemantics, - MoveLeaseToken leaseToken, - Func authorizeMutation) - { - var parent = Path.GetDirectoryName(tombstonePath) - ?? throw new MoveNeedsAttentionException("The cleanup tombstone parent is unavailable."); - var hasPublicationEvidence = File.Exists(tombstonePath) - || Directory.EnumerateFiles( - parent, - Path.GetFileName(tombstonePath) + ".writing-*", - SearchOption.TopDirectoryOnly).Any(); - if (!hasPublicationEvidence) - { - await authorizeMutation(); - await PublishOwnershipMarkerAsync( - tombstonePath, - expectedTombstone, - OwnershipMarkerKind.CleanupTombstone, - leaseToken, - authorizeMutation); - } - - await authorizeMutation(); - try - { - await RecoverOrReadOwnershipMarkerAsync( - tombstonePath, - expectedTombstone, - sourceSemantics, - targetSemantics, - directorySemantics, - leaseToken, - authorizeMutation); - } - catch (InterruptedOwnershipPublicationException) - { - await authorizeMutation(); - await PublishOwnershipMarkerAsync( - tombstonePath, - expectedTombstone, - OwnershipMarkerKind.CleanupTombstone, - leaseToken, - authorizeMutation); - await RecoverOrReadOwnershipMarkerAsync( - tombstonePath, - expectedTombstone, - sourceSemantics, - targetSemantics, - directorySemantics, - leaseToken, - authorizeMutation); - } - } - - private async Task CompleteOwnedDirectoryCleanupAsync( - string directoryPath, - string markerPath, - string tombstonePath, - MoveOwnershipMarker expectedTombstone, - FileSystemPathSemantics sourceSemantics, - FileSystemPathSemantics targetSemantics, - FileSystemPathSemantics directorySemantics, - Func authorizeMutation) - { - var markerKind = string.Equals( - expectedTombstone.OwnedArtifactType, - TemporaryDirectoryArtifactType, - StringComparison.Ordinal) - ? OwnershipMarkerKind.TemporaryDirectory - : OwnershipMarkerKind.QuarantineDirectory; - var tombstoneParent = Path.GetDirectoryName(Path.GetFullPath(tombstonePath)) - ?? throw new MoveNeedsAttentionException("The cleanup tombstone parent is unavailable."); - var originalOwnedDirectory = expectedTombstone.OwnedDirectoryPath - ?? throw new MoveNeedsAttentionException( - "The cleanup tombstone has no original owned directory identity."); - var expectedDirectoryMarker = CreateOwnershipMarker( - expectedTombstone.OwnedArtifactType - ?? throw new MoveNeedsAttentionException("The cleanup tombstone has no owned artifact type."), - expectedTombstone.JobId, - expectedTombstone.Source, - expectedTombstone.Target, - originalOwnedDirectory); - try - { - if (!FileSystemPathIdentity.AreEquivalent( - directoryPath, - expectedTombstone.DirectoryPath, - directorySemantics)) - { - throw new MoveNeedsAttentionException( - "The cleanup directory does not match the persisted tombstone identity."); - } - } - catch (Exception exception) when (exception is - ArgumentException or InvalidOperationException or NotSupportedException or PathTooLongException) - { - throw new MoveNeedsAttentionException( - "The cleanup tombstone contains an invalid cleanup directory identity."); - } - ValidateExistingMoveDirectory(tombstoneParent, "cleanup tombstone directory"); - var tombstone = ReadOwnershipMarker(tombstonePath); - ValidateOwnershipMarker( - tombstone, - expectedTombstone, - sourceSemantics, - targetSemantics, - directorySemantics); - - if (TryGetExistingPathAttributes(directoryPath, out var ownedPathAttributes) - && (ownedPathAttributes & FileAttributes.Directory) == 0) - { - throw new MoveNeedsAttentionException( - "The tombstoned owned directory path is occupied by a file and was preserved for operator review."); - } - - if (Directory.Exists(directoryPath)) - { - ValidateExistingMoveDirectory(directoryPath, "owned cleanup directory"); - if (!FileSystemSafety.TryEnumerateTreeWithoutLinks( - directoryPath, - out var files, - out var directories, - out var reason)) - { - throw new MoveNeedsAttentionException( - $"The owned directory could not be cleaned safely: {reason}"); - } - - var hasDirectoryMarker = File.Exists(markerPath); - MoveOwnershipMarker? directoryMarker = null; - if (hasDirectoryMarker) - { - ValidateOwnedCleanupEntry(markerPath, directoryPath); - directoryMarker = ReadOwnershipMarker(markerPath); - ValidateOwnershipMarker( - directoryMarker, - expectedDirectoryMarker, - sourceSemantics, - targetSemantics, - directorySemantics); - } - - var ownedFiles = files - .Where(file => !FileSystemPathIdentity.AreEquivalent( - file, - markerPath, - directorySemantics)) - .ToList(); - if (markerKind == OwnershipMarkerKind.QuarantineDirectory - && ownedFiles.Count > 0) - { - throw new MoveNeedsAttentionException( - "The quarantine cleanup directory contains unexpected content and was preserved."); - } - - if (!hasDirectoryMarker - && (ownedFiles.Count > 0 || directories.Count > 0)) - { - throw new MoveNeedsAttentionException( - "The tombstoned cleanup directory was recreated or changed after its ownership marker was removed."); - } - - foreach (var file in ownedFiles) - { - await RetirePinnedArtifactAsync( - file, - _ => ValidateOwnedCleanupEntry(file, directoryPath), - authorizeMutation); - } - - foreach (var directory in directories.OrderByDescending(path => path.Length)) - { - if (!Directory.Exists(directory)) - { - continue; - } - - await RetirePinnedEmptyDirectoryAsync( - directory, - "owned cleanup child directory", - () => - { - ValidateOwnedCleanupEntry(directory, directoryPath); - if (Directory.EnumerateFileSystemEntries(directory).Any()) - { - throw new MoveNeedsAttentionException( - "An owned cleanup child directory changed before retirement."); - } - }, - authorizeMutation); - } - - if (hasDirectoryMarker) - { - faultInjector?.OnOwnershipCleanup( - expectedTombstone.JobId, - markerKind, - OwnershipCleanupFaultPoint.BeforeOwnershipMarkerDelete); - ValidateExistingMoveDirectory(directoryPath, "owned cleanup directory"); - ValidateOwnedCleanupEntry(markerPath, directoryPath); - directoryMarker = ReadOwnershipMarker(markerPath); - ValidateOwnershipMarker( - directoryMarker, - expectedDirectoryMarker, - sourceSemantics, - targetSemantics, - directorySemantics); - await RetirePinnedArtifactAsync( - markerPath, - entry => - { - ValidateOwnedCleanupEntry(markerPath, directoryPath); - directoryMarker = ReadOwnershipMarker(entry, markerPath); - ValidateOwnershipMarker( - directoryMarker, - expectedDirectoryMarker, - sourceSemantics, - targetSemantics, - directorySemantics); - }, - authorizeMutation); - } - - ValidateExistingMoveDirectory(directoryPath, "owned cleanup directory"); - if (Directory.EnumerateFileSystemEntries(directoryPath).Any()) - { - throw new MoveNeedsAttentionException( - "The owned directory still contains unexpected content after cleanup."); - } - - await RetirePinnedEmptyDirectoryAsync( - directoryPath, - "owned cleanup directory", - () => - { - RejectRecreatedOriginalOwnedPath(expectedTombstone); - ValidateExistingMoveDirectory( - directoryPath, - "owned cleanup directory"); - if (Directory.EnumerateFileSystemEntries(directoryPath).Any()) - { - throw new MoveNeedsAttentionException( - "The owned cleanup directory changed before retirement."); - } - }, - authorizeMutation, - () => faultInjector?.OnOwnershipCleanup( - expectedTombstone.JobId, - markerKind, - OwnershipCleanupFaultPoint.BeforeDirectoryDelete)); - } - - ValidateExistingMoveDirectory(tombstoneParent, "cleanup tombstone directory"); - var validatedTombstone = ReadOwnershipMarker(tombstonePath); - ValidateOwnershipMarker( - validatedTombstone, - expectedTombstone, - sourceSemantics, - targetSemantics, - directorySemantics); - faultInjector?.OnOwnershipCleanup( - expectedTombstone.JobId, - markerKind, - OwnershipCleanupFaultPoint.BeforeTombstoneDelete); - ValidateExistingMoveDirectory(tombstoneParent, "cleanup tombstone directory"); - validatedTombstone = ReadOwnershipMarker(tombstonePath); - ValidateOwnershipMarker( - validatedTombstone, - expectedTombstone, - sourceSemantics, - targetSemantics, - directorySemantics); - await RetirePinnedArtifactAsync( - tombstonePath, - entry => - { - RejectRecreatedOriginalOwnedPath(expectedTombstone); - validatedTombstone = ReadOwnershipMarker(entry, tombstonePath); - ValidateOwnershipMarker( - validatedTombstone, - expectedTombstone, - sourceSemantics, - targetSemantics, - directorySemantics); - }, - authorizeMutation); - } - - private static bool TryGetExistingPathAttributes( - string path, - out FileAttributes attributes) - { - try - { - attributes = File.GetAttributes(path); - return true; - } - catch (FileNotFoundException) - { - attributes = default; - return false; - } - catch (DirectoryNotFoundException) - { - attributes = default; - return false; - } - } - - private static void ValidateOwnedCleanupEntry( - string entryPath, - string directoryPath) - { - if (!FileSystemSafety.TryValidateMutationTarget( - entryPath, - [directoryPath], - out entryPath, - out var reason)) - { - throw new MoveNeedsAttentionException(reason); - } - - if ((File.Exists(entryPath) || Directory.Exists(entryPath)) - && (File.GetAttributes(entryPath) & FileAttributes.ReparsePoint) != 0) - { - throw new MoveNeedsAttentionException( - "An owned cleanup entry is a symbolic link or reparse point."); - } - } -} diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.OwnershipCleanupPreparation.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.OwnershipCleanupPreparation.cs deleted file mode 100644 index 3068d0bda..000000000 --- a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.OwnershipCleanupPreparation.cs +++ /dev/null @@ -1,208 +0,0 @@ -using Listenarr.Domain.Common; - -namespace Listenarr.Infrastructure.Library.Moving; - -internal sealed partial class AudiobookContentMoveService -{ - private sealed record PreparedOwnedCleanup( - string DirectoryPath, - string MarkerPath); - - private async Task PrepareOwnedDirectoryCleanupAsync( - string originalDirectoryPath, - string originalMarkerPath, - MoveOwnershipMarker expectedTombstone, - FileSystemPathSemantics sourceSemantics, - FileSystemPathSemantics targetSemantics, - FileSystemPathSemantics directorySemantics, - Func authorizeMutation) - { - var originalDirectory = Path.GetFullPath(originalDirectoryPath); - var cleanupDirectory = Path.GetFullPath(expectedTombstone.DirectoryPath); - var ownedArtifactType = expectedTombstone.OwnedArtifactType - ?? throw new MoveNeedsAttentionException( - "The cleanup tombstone has no owned artifact type."); - var expectedOriginalDirectory = expectedTombstone.OwnedDirectoryPath - ?? throw new MoveNeedsAttentionException( - "The cleanup tombstone has no original owned directory identity."); - - try - { - if (!FileSystemPathIdentity.AreEquivalent( - originalDirectory, - expectedOriginalDirectory, - directorySemantics)) - { - throw new MoveNeedsAttentionException( - "The cleanup tombstone does not match the original owned directory."); - } - } - catch (Exception exception) when (exception is - ArgumentException or InvalidOperationException or NotSupportedException or PathTooLongException) - { - throw new MoveNeedsAttentionException( - "The cleanup tombstone contains an invalid original directory identity."); - } - - var parent = Path.GetDirectoryName(originalDirectory) - ?? throw new MoveNeedsAttentionException( - "The owned cleanup directory parent is unavailable."); - ValidateExistingMoveDirectory(parent, "owned cleanup parent directory"); - if (!FileSystemSafety.TryValidateMutationTarget( - cleanupDirectory, - [parent], - out cleanupDirectory, - out var cleanupReason)) - { - throw new MoveNeedsAttentionException(cleanupReason); - } - - var cleanupMarkerPath = Path.Join( - cleanupDirectory, - Path.GetFileName(originalMarkerPath)); - var expectedDirectoryMarker = CreateOwnershipMarker( - ownedArtifactType, - expectedTombstone.JobId, - expectedTombstone.Source, - expectedTombstone.Target, - originalDirectory); - - if (Directory.Exists(cleanupDirectory)) - { - RejectRecreatedOriginalOwnedPath(expectedTombstone); - - ValidateExistingMoveDirectory( - cleanupDirectory, - "renamed owned cleanup directory"); - if (File.Exists(cleanupMarkerPath)) - { - var marker = ReadOwnershipMarker(cleanupMarkerPath); - ValidateOwnershipMarker( - marker, - expectedDirectoryMarker, - sourceSemantics, - targetSemantics, - directorySemantics); - } - - return new PreparedOwnedCleanup( - cleanupDirectory, - cleanupMarkerPath); - } - - if (File.Exists(cleanupDirectory)) - { - throw new MoveNeedsAttentionException( - "The renamed cleanup path is occupied by a file and was preserved."); - } - - if (!Directory.Exists(originalDirectory)) - { - if (File.Exists(originalDirectory)) - { - throw new MoveNeedsAttentionException( - "The original owned directory path was replaced by a file and was preserved."); - } - - return new PreparedOwnedCleanup( - cleanupDirectory, - cleanupMarkerPath); - } - - ValidateExistingMoveDirectory( - originalDirectory, - "original owned cleanup directory"); - if (!File.Exists(originalMarkerPath)) - { - throw new MoveNeedsAttentionException( - "The original owned cleanup directory no longer has its ownership marker and was preserved."); - } - - var originalMarker = ReadOwnershipMarker(originalMarkerPath); - ValidateOwnershipMarker( - originalMarker, - expectedDirectoryMarker, - sourceSemantics, - targetSemantics, - directorySemantics); - if (!FileSystemSafety.TryEnumerateTreeWithoutLinks( - originalDirectory, - out _, - out _, - out var treeReason)) - { - throw new MoveNeedsAttentionException( - $"The owned directory could not be isolated safely: {treeReason}"); - } - - var markerKind = string.Equals( - ownedArtifactType, - TemporaryDirectoryArtifactType, - StringComparison.Ordinal) - ? OwnershipMarkerKind.TemporaryDirectory - : OwnershipMarkerKind.QuarantineDirectory; - faultInjector?.OnOwnershipCleanup( - expectedTombstone.JobId, - markerKind, - OwnershipCleanupFaultPoint.BeforeCleanupDirectoryMove); - - var originalParent = Path.GetDirectoryName(originalDirectory) - ?? throw new MoveNeedsAttentionException( - "The original owned cleanup directory has no parent."); - using (var publication = PinnedDirectoryCreation.OpenExistingForPublication( - originalParent, - Path.GetFileName(originalDirectory))) - { - using var originalAnchor = publication.OpenCreatedDirectoryAnchor(); - await authorizeMutation(); - ValidateExistingMoveDirectory( - originalDirectory, - "original owned cleanup directory"); - originalMarker = ReadOwnershipMarker(originalMarkerPath); - ValidateOwnershipMarker( - originalMarker, - expectedDirectoryMarker, - sourceSemantics, - targetSemantics, - directorySemantics); - if (Directory.Exists(cleanupDirectory) - || File.Exists(cleanupDirectory) - || !originalAnchor.VisiblePathMatches()) - { - throw new MoveNeedsAttentionException( - "The renamed cleanup path appeared or the owned directory changed before isolation."); - } - - using var cleanupAnchor = publication.RepublishPinnedDirectory( - Path.GetFileName(originalDirectory), - Path.GetFileName(cleanupDirectory)); - } - - ValidateExistingMoveDirectory( - cleanupDirectory, - "renamed owned cleanup directory"); - var movedMarker = ReadOwnershipMarker(cleanupMarkerPath); - ValidateOwnershipMarker( - movedMarker, - expectedDirectoryMarker, - sourceSemantics, - targetSemantics, - directorySemantics); - return new PreparedOwnedCleanup( - cleanupDirectory, - cleanupMarkerPath); - } - - private static void RejectRecreatedOriginalOwnedPath( - MoveOwnershipMarker expectedTombstone) - { - var originalDirectory = expectedTombstone.OwnedDirectoryPath - ?? throw new MoveNeedsAttentionException( - "The cleanup tombstone has no original owned directory identity."); - if (Directory.Exists(originalDirectory) || File.Exists(originalDirectory)) - { - throw new MoveNeedsAttentionException( - "The original owned directory path was recreated after cleanup isolation and was preserved."); - } - } -} diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.OwnershipMarkerPinnedRecovery.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.OwnershipMarkerPinnedRecovery.cs deleted file mode 100644 index 914c65730..000000000 --- a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.OwnershipMarkerPinnedRecovery.cs +++ /dev/null @@ -1,118 +0,0 @@ -using Listenarr.Domain.Common; - -namespace Listenarr.Infrastructure.Library.Moving; - -internal sealed partial class AudiobookContentMoveService -{ - private static Task RetireCorruptOwnershipWriteAsync( - string writePath, - string markerPath, - MarkerWriteIdentity expectedIdentity, - Func authorizeMutation) => - RetirePinnedArtifactAsync( - writePath, - entry => - { - var currentRead = ReadOwnershipMarkerResult(entry); - if (currentRead.State == MarkerReadState.TemporarilyUnreadable) - { - throw new IOException( - "A predecessor ownership-marker write file became temporarily unreadable and was preserved.", - currentRead.Error); - } - if (currentRead.State != MarkerReadState.CorruptOrTruncated - || !TryParseMarkerWriteIdentity( - entry.FullPath, - markerPath, - out var currentIdentity) - || currentIdentity != expectedIdentity) - { - throw new MoveNeedsAttentionException( - "A truncated ownership-marker write file changed before cleanup."); - } - }, - authorizeMutation); - - private static Task RetireValidatedOwnershipWriteAsync( - string writePath, - MoveOwnershipMarker expected, - FileSystemPathSemantics sourceSemantics, - FileSystemPathSemantics targetSemantics, - FileSystemPathSemantics directorySemantics, - Func authorizeMutation) => - RetirePinnedArtifactAsync( - writePath, - entry => - { - var marker = ReadOwnershipMarker(entry, entry.FullPath); - ValidateOwnershipMarker( - marker, - expected, - sourceSemantics, - targetSemantics, - directorySemantics); - }, - authorizeMutation); - - private async Task PublishRecoveredOwnershipWriteAsync( - string markerPath, - string writePath, - MoveOwnershipMarker expected, - FileSystemPathSemantics sourceSemantics, - FileSystemPathSemantics targetSemantics, - FileSystemPathSemantics directorySemantics, - Func authorizeMutation) - { - var markerDirectory = Path.GetDirectoryName(Path.GetFullPath(markerPath)) - ?? throw new MoveNeedsAttentionException( - "The ownership marker directory is unavailable."); - using var parent = PinnedDirectoryCreation.OpenPinnedDirectoryNoFollow( - markerDirectory); - using var entry = parent.OpenExistingFile( - Path.GetFileName(writePath), - requireDeleteAccess: true); - var marker = ReadOwnershipMarker(entry, writePath); - ValidateOwnershipMarker( - marker, - expected, - sourceSemantics, - targetSemantics, - directorySemantics); - - faultInjector?.OnOwnershipMarkerWrite( - expected.JobId, - GetOwnershipMarkerKind(expected.ArtifactType), - OwnershipMarkerWriteFaultPoint.BeforeRecoveredPublication); - await authorizeMutation(); - if (!parent.VisiblePathMatches() || !entry.VisiblePathMatches()) - { - throw new MoveNeedsAttentionException( - "The validated ownership-marker write changed before publication."); - } - if (File.Exists(markerPath) || Directory.Exists(markerPath)) - { - throw new MoveNeedsAttentionException( - "The authoritative ownership marker appeared before publication."); - } - - marker = ReadOwnershipMarker(entry, writePath); - ValidateOwnershipMarker( - marker, - expected, - sourceSemantics, - targetSemantics, - directorySemantics); - entry.MoveWithinParent(Path.GetFileName(markerPath)); - return marker; - } - - private static OwnershipMarkerKind GetOwnershipMarkerKind(string artifactType) => - artifactType switch - { - TemporaryDirectoryArtifactType => OwnershipMarkerKind.TemporaryDirectory, - QuarantineDirectoryArtifactType => OwnershipMarkerKind.QuarantineDirectory, - CleanupTombstoneArtifactType => OwnershipMarkerKind.CleanupTombstone, - _ => throw new MoveNeedsAttentionException( - "The ownership marker has an unsupported artifact type.") - }; -} diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.OwnershipMarkers.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.OwnershipMarkers.cs deleted file mode 100644 index b684ccd63..000000000 --- a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.OwnershipMarkers.cs +++ /dev/null @@ -1,452 +0,0 @@ -using Listenarr.Domain.Common; - -namespace Listenarr.Infrastructure.Library.Moving; - -internal sealed partial class AudiobookContentMoveService -{ - private const int OwnershipMarkerVersion = 1; - private const string TemporaryDirectoryArtifactType = "temporary-directory"; - private const string QuarantineDirectoryArtifactType = "quarantine-directory"; - private const string CleanupTombstoneArtifactType = "cleanup-tombstone"; - - private sealed record MoveOwnershipMarker( - int Version, - string ArtifactType, - Guid JobId, - string Source, - string Target, - string DirectoryPath, - string? OwnedArtifactType = null, - string? OwnedDirectoryPath = null); - - private MoveOwnershipMarker CreateOwnershipMarker( - string artifactType, - Guid jobId, - string source, - string target, - string directoryPath, - string? ownedArtifactType = null, - string? ownedDirectoryPath = null) => - new( - OwnershipMarkerVersion, - artifactType, - jobId, - Path.GetFullPath(source), - Path.GetFullPath(target), - Path.GetFullPath(directoryPath), - ownedArtifactType, - string.IsNullOrWhiteSpace(ownedDirectoryPath) - ? null - : Path.GetFullPath(ownedDirectoryPath)); - - private async Task RecoverOrReadOwnershipMarkerAsync( - string markerPath, - MoveOwnershipMarker expected, - FileSystemPathSemantics sourceSemantics, - FileSystemPathSemantics targetSemantics, - FileSystemPathSemantics directorySemantics, - MoveLeaseToken leaseToken, - Func authorizeMutation) - { - var markerDirectory = Path.GetDirectoryName(Path.GetFullPath(markerPath)) - ?? throw new MoveNeedsAttentionException("The ownership marker directory is unavailable."); - ValidateExistingMoveDirectory(markerDirectory, "ownership-marker directory"); - - if (File.Exists(markerPath)) - { - var marker = ReadOwnershipMarker(markerPath); - ValidateOwnershipMarker( - marker, - expected, - sourceSemantics, - targetSemantics, - directorySemantics); - await DeleteValidatedOwnershipWriteFilesAsync( - markerPath, - expected, - sourceSemantics, - targetSemantics, - directorySemantics, - leaseToken, - authorizeMutation); - return marker; - } - - var validWrites = new List<(string Path, MoveOwnershipMarker Marker)>(); - var discardedTruncatedPredecessor = false; - foreach (var writePath in Directory.EnumerateFiles( - markerDirectory, - Path.GetFileName(markerPath) + ".writing-*", - SearchOption.TopDirectoryOnly)) - { - ValidateOwnershipMarkerWritePath(writePath, markerDirectory); - if (!TryParseMarkerWriteIdentity(writePath, markerPath, out var writeIdentity) - || writeIdentity.JobId != expected.JobId) - { - throw new MoveNeedsAttentionException( - "An ownership-marker write filename does not match the active move job."); - } - - if (writeIdentity.LeaseGeneration > leaseToken.Generation) - { - throw new MoveNeedsAttentionException( - "A future-generation ownership-marker write file was preserved."); - } - - var recoveredRead = ReadOwnershipMarkerResult(writePath); - if (recoveredRead.State == MarkerReadState.TemporarilyUnreadable) - { - throw new IOException( - "An ownership-marker write file is temporarily unreadable and was preserved.", - recoveredRead.Error); - } - if (recoveredRead.State == MarkerReadState.Unsupported) - { - throw new MoveNeedsAttentionException( - "An ownership-marker write file uses an unsupported marker version and was preserved."); - } - if (recoveredRead.State == MarkerReadState.CorruptOrTruncated) - { - if (writeIdentity.LeaseGeneration >= leaseToken.Generation) - { - throw new MoveNeedsAttentionException( - "A current or future-generation ownership-marker write file is truncated and was preserved."); - } - - await RetireCorruptOwnershipWriteAsync( - writePath, - markerPath, - writeIdentity, - authorizeMutation); - discardedTruncatedPredecessor = true; - continue; - } - - var recovered = recoveredRead.Marker - ?? throw new MoveNeedsAttentionException("The ownership-marker write file is missing."); - ValidateOwnershipMarker( - recovered, - expected, - sourceSemantics, - targetSemantics, - directorySemantics); - validWrites.Add((writePath, recovered)); - } - - if (validWrites.Count == 0) - { - if (discardedTruncatedPredecessor) - { - throw new InterruptedOwnershipPublicationException( - "A truncated predecessor ownership marker was removed; the empty owned directory must be reclaimed."); - } - - throw new MoveNeedsAttentionException( - "The owned directory has no valid ownership marker."); - } - - if (validWrites.Count != 1) - { - throw new MoveNeedsAttentionException( - "The owned directory has multiple incomplete ownership marker publications."); - } - - var validWritePath = validWrites[0].Path; - return await PublishRecoveredOwnershipWriteAsync( - markerPath, - validWritePath, - expected, - sourceSemantics, - targetSemantics, - directorySemantics, - authorizeMutation); - } - - private static MoveOwnershipMarker ReadOwnershipMarker(string markerPath) - { - if (!FileSystemSafety.TryValidateMutationTarget( - markerPath, - [Path.GetDirectoryName(markerPath)], - out markerPath, - out var markerReason)) - { - throw new MoveNeedsAttentionException(markerReason); - } - - if (File.Exists(markerPath) - && (File.GetAttributes(markerPath) & FileAttributes.ReparsePoint) != 0) - { - throw new MoveNeedsAttentionException("The ownership marker is linked."); - } - - var result = ReadOwnershipMarkerResult(markerPath); - return result.State switch - { - MarkerReadState.Valid => result.Marker!, - MarkerReadState.TemporarilyUnreadable => throw new IOException( - "The ownership marker is temporarily unreadable.", - result.Error), - MarkerReadState.Unsupported => throw new MoveNeedsAttentionException( - "The ownership marker uses an unsupported marker version and was preserved."), - MarkerReadState.CorruptOrTruncated => throw new MoveNeedsAttentionException( - "The ownership marker is corrupt or truncated."), - _ => throw new MoveNeedsAttentionException("The ownership marker is missing.") - }; - } - - private static MarkerReadResult ReadOwnershipMarkerResult(string path) - { - var result = ReadJsonMarker(path); - if (result.State == MarkerReadState.Valid - && result.Marker!.Version != OwnershipMarkerVersion) - { - return new MarkerReadResult( - MarkerReadState.Unsupported, - result.Marker); - } - - return result; - } - - private static void ValidateOwnershipMarker( - MoveOwnershipMarker marker, - MoveOwnershipMarker expected, - FileSystemPathSemantics sourceSemantics, - FileSystemPathSemantics targetSemantics, - FileSystemPathSemantics directorySemantics) - { - if (marker.Version != OwnershipMarkerVersion - || marker.JobId != expected.JobId - || !string.Equals(marker.ArtifactType, expected.ArtifactType, StringComparison.Ordinal) - || !string.Equals(marker.OwnedArtifactType, expected.OwnedArtifactType, StringComparison.Ordinal) - || (marker.OwnedDirectoryPath == null) != (expected.OwnedDirectoryPath == null)) - { - throw new MoveNeedsAttentionException( - "The owned directory is owned by another job, artifact type, or unsupported marker version."); - } - - try - { - if (!FileSystemPathIdentity.TryCanonicalizeUnambiguousStoredAbsolutePathForHost( - marker.Source, - out var markerSource, - out _, - sourceSemantics.Syntax) - || !FileSystemPathIdentity.TryCanonicalizeUnambiguousStoredAbsolutePathForHost( - marker.Target, - out var markerTarget, - out _, - targetSemantics.Syntax) - || !FileSystemPathIdentity.TryCanonicalizeUnambiguousStoredAbsolutePathForHost( - marker.DirectoryPath, - out var markerDirectory, - out _, - directorySemantics.Syntax)) - { - throw new MoveNeedsAttentionException( - "The ownership marker contains a path that is unavailable on this host."); - } - - string? markerOwnedDirectory = null; - if (marker.OwnedDirectoryPath != null - && !FileSystemPathIdentity.TryCanonicalizeUnambiguousStoredAbsolutePathForHost( - marker.OwnedDirectoryPath, - out markerOwnedDirectory, - out _, - directorySemantics.Syntax)) - { - throw new MoveNeedsAttentionException( - "The ownership marker contains an owned-directory path that is unavailable on this host."); - } - - if (!FileSystemPathIdentity.AreEquivalent(markerSource, expected.Source, sourceSemantics) - || !FileSystemPathIdentity.AreEquivalent(markerTarget, expected.Target, targetSemantics) - || !FileSystemPathIdentity.AreEquivalent( - markerDirectory, - expected.DirectoryPath, - directorySemantics) - || (markerOwnedDirectory != null - && expected.OwnedDirectoryPath != null - && !FileSystemPathIdentity.AreEquivalent( - markerOwnedDirectory, - expected.OwnedDirectoryPath, - directorySemantics))) - { - throw new MoveNeedsAttentionException( - "The ownership marker does not match the persisted source, target, or owned directory."); - } - } - catch (Exception exception) when (exception is - ArgumentException or InvalidOperationException or NotSupportedException or PathTooLongException) - { - throw new MoveNeedsAttentionException( - "The ownership marker contains an invalid filesystem identity."); - } - } - - private static async Task DeleteValidatedOwnershipWriteFilesAsync( - string markerPath, - MoveOwnershipMarker expected, - FileSystemPathSemantics sourceSemantics, - FileSystemPathSemantics targetSemantics, - FileSystemPathSemantics directorySemantics, - MoveLeaseToken leaseToken, - Func authorizeMutation) - { - var markerDirectory = Path.GetDirectoryName(markerPath) - ?? throw new MoveNeedsAttentionException("The ownership marker directory is unavailable."); - foreach (var writePath in Directory.EnumerateFiles( - markerDirectory, - Path.GetFileName(markerPath) + ".writing-*", - SearchOption.TopDirectoryOnly)) - { - ValidateOwnershipMarkerWritePath(writePath, markerDirectory); - if (!TryParseMarkerWriteIdentity(writePath, markerPath, out var writeIdentity) - || writeIdentity.JobId != expected.JobId) - { - throw new MoveNeedsAttentionException( - "An ownership-marker write filename does not match the active move job."); - } - - if (writeIdentity.LeaseGeneration > leaseToken.Generation) - { - throw new MoveNeedsAttentionException( - "A future-generation ownership-marker write file was preserved."); - } - - var writeRead = ReadOwnershipMarkerResult(writePath); - if (writeRead.State == MarkerReadState.TemporarilyUnreadable) - { - throw new IOException( - "An ownership-marker write file is temporarily unreadable and was preserved.", - writeRead.Error); - } - if (writeRead.State == MarkerReadState.Unsupported) - { - throw new MoveNeedsAttentionException( - "An ownership-marker write file uses an unsupported marker version and was preserved."); - } - if (writeRead.State == MarkerReadState.CorruptOrTruncated) - { - if (writeIdentity.LeaseGeneration >= leaseToken.Generation) - { - throw new MoveNeedsAttentionException( - "A current or future-generation ownership-marker write file is truncated and was preserved."); - } - - await RetireCorruptOwnershipWriteAsync( - writePath, - markerPath, - writeIdentity, - authorizeMutation); - continue; - } - - var writeMarker = writeRead.Marker - ?? throw new MoveNeedsAttentionException("The ownership-marker write file is missing."); - ValidateOwnershipMarker( - writeMarker, - expected, - sourceSemantics, - targetSemantics, - directorySemantics); - await RetireValidatedOwnershipWriteAsync( - writePath, - expected, - sourceSemantics, - targetSemantics, - directorySemantics, - authorizeMutation); - } - } - - private static void ValidateNewOwnershipMarkerWritePath( - string writePath, - string markerDirectory) - { - ValidateExistingMoveDirectory(markerDirectory, "ownership-marker directory"); - if (!FileSystemSafety.TryValidateMutationTarget( - writePath, - [markerDirectory], - out writePath, - out var writeReason)) - { - throw new MoveNeedsAttentionException(writeReason); - } - - if (File.Exists(writePath) || Directory.Exists(writePath)) - { - throw new MoveNeedsAttentionException( - "The ownership-marker temporary path appeared before creation."); - } - } - - private static void ValidateOwnershipMarkerPublicationPaths( - string markerDirectory, - string writePath, - string markerPath) - { - ValidateExistingMoveDirectory(markerDirectory, "ownership-marker directory"); - ValidateOwnershipMarkerWritePath(writePath, markerDirectory); - if (!FileSystemSafety.TryValidateMutationTarget( - markerPath, - [markerDirectory], - out _, - out var markerReason)) - { - throw new MoveNeedsAttentionException(markerReason); - } - - if (File.Exists(markerPath) || Directory.Exists(markerPath)) - { - throw new MoveNeedsAttentionException( - "The authoritative ownership marker appeared before publication."); - } - } - - private static void ValidateOwnershipMarkerWritePath( - string writePath, - string markerDirectory) - { - ValidateExistingMoveDirectory(markerDirectory, "ownership-marker directory"); - if (!FileSystemSafety.TryValidateMutationTarget( - writePath, - [markerDirectory], - out writePath, - out var writeReason)) - { - throw new MoveNeedsAttentionException(writeReason); - } - - if (!File.Exists(writePath) - || (File.GetAttributes(writePath) & FileAttributes.ReparsePoint) != 0) - { - throw new MoveNeedsAttentionException( - "An ownership-marker temporary file is missing or linked."); - } - } - - private static string GetCleanupDirectoryPath( - string directoryPath, - string ownedArtifactType, - Guid jobId) - { - var parent = Path.GetDirectoryName(Path.GetFullPath(directoryPath)) - ?? throw new MoveNeedsAttentionException("The owned directory parent is unavailable."); - return Path.Join( - parent, - $".listenarr-{ownedArtifactType}-{jobId:N}.cleanup-dir"); - } - - private static string GetCleanupTombstonePath( - string directoryPath, - string ownedArtifactType, - Guid jobId) - { - var parent = Path.GetDirectoryName(Path.GetFullPath(directoryPath)) - ?? throw new MoveNeedsAttentionException("The owned directory parent is unavailable."); - return Path.Join( - parent, - $".listenarr-{ownedArtifactType}-{jobId:N}.cleanup.json"); - } -} diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.OwnershipPublication.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.OwnershipPublication.cs deleted file mode 100644 index 16be225ee..000000000 --- a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.OwnershipPublication.cs +++ /dev/null @@ -1,125 +0,0 @@ -using System.Text.Json; - -namespace Listenarr.Infrastructure.Library.Moving; - -internal sealed partial class AudiobookContentMoveService -{ - private async Task PublishOwnershipMarkerAsync( - string markerPath, - MoveOwnershipMarker marker, - OwnershipMarkerKind markerKind, - MoveLeaseToken leaseToken, - Func authorizeMutation, - PinnedDirectoryCreation.PinnedDirectoryAnchor? pinnedDirectory = null) - { - var markerDirectory = Path.GetDirectoryName(Path.GetFullPath(markerPath)) - ?? throw new MoveNeedsAttentionException("The ownership marker directory is unavailable."); - ValidateExistingMoveDirectory(markerDirectory, "ownership-marker directory"); - if (!FileSystemSafety.TryValidateMutationTarget( - markerPath, - [markerDirectory], - out markerPath, - out var markerReason)) - { - throw new MoveNeedsAttentionException(markerReason); - } - - if (File.Exists(markerPath)) - { - throw new MoveNeedsAttentionException( - "The ownership marker already exists and cannot be overwritten safely."); - } - - var payload = JsonSerializer.SerializeToUtf8Bytes(marker); - var writePath = CreateMarkerWritePath( - markerPath, - marker.JobId, - leaseToken.Generation); - if (pinnedDirectory != null) - { - await PublishPinnedOwnershipMarkerAsync( - pinnedDirectory, - markerPath, - writePath, - payload, - marker, - markerKind, - authorizeMutation); - return; - } - - using var markerDirectoryAnchor = - PinnedDirectoryCreation.OpenPinnedDirectoryNoFollow(markerDirectory); - await PublishPinnedOwnershipMarkerAsync( - markerDirectoryAnchor, - markerPath, - writePath, - payload, - marker, - markerKind, - authorizeMutation); - } - - private async Task PublishPinnedOwnershipMarkerAsync( - PinnedDirectoryCreation.PinnedDirectoryAnchor pinnedDirectory, - string markerPath, - string writePath, - byte[] payload, - MoveOwnershipMarker marker, - OwnershipMarkerKind markerKind, - Func authorizeMutation) - { - await pinnedDirectory.PublishNewFileAsync( - Path.GetFileName(writePath), - Path.GetFileName(markerPath), - async () => - { - faultInjector?.OnOwnershipMarkerWrite( - marker.JobId, - markerKind, - OwnershipMarkerWriteFaultPoint.BeforeTemporaryFileCreation); - await authorizeMutation(); - }, - async stream => - { - var split = Math.Max(1, payload.Length / 2); - stream.Write(payload.AsSpan(0, split)); - faultInjector?.OnOwnershipMarkerWrite( - marker.JobId, - markerKind, - OwnershipMarkerWriteFaultPoint.DuringJsonWrite); - stream.Write(payload.AsSpan(split)); - faultInjector?.OnOwnershipMarkerWrite( - marker.JobId, - markerKind, - OwnershipMarkerWriteFaultPoint.DuringFlush); - await authorizeMutation(); - stream.Flush(flushToDisk: true); - }, - async () => - { - faultInjector?.OnOwnershipMarkerWrite( - marker.JobId, - markerKind, - OwnershipMarkerWriteFaultPoint.AfterTemporaryFileWritten); - faultInjector?.OnOwnershipMarkerWrite( - marker.JobId, - markerKind, - OwnershipMarkerWriteFaultPoint.BeforePublication); - await authorizeMutation(); - }, - exception => - { - if (exception is MoveLeaseLostException or PersistenceException) - { - return true; - } - - faultInjector?.OnOwnershipMarkerWrite( - marker.JobId, - markerKind, - OwnershipMarkerWriteFaultPoint.BeforeTemporaryFileDeletion); - return false; - }); - } -} diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.PersistedSourceManifest.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.PersistedSourceManifest.cs index 235f37492..e38c31869 100644 --- a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.PersistedSourceManifest.cs +++ b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.PersistedSourceManifest.cs @@ -198,51 +198,4 @@ private static void ValidateManifestAncestorChain( "A manifest ancestor chain did not terminate at the authorized source root."); } - private static async Task SourceTreeExactlyMatchesManifestAsync( - Guid jobId, - string source, - string target, - bool targetInsideSource, - IReadOnlyCollection manifest, - FileSystemPathSemantics sourceSemantics, - string? ownedRecoveryMarkerPath, - IReadOnlyCollection ownedScaffoldPaths, - IReadOnlyCollection structuralSpinePaths, - IReadOnlyCollection ownedDirectoryMarkerPaths, - CancellationToken cancellationToken) - { - try - { - var validatedEntries = ValidateSourceTreeForMove( - source, - target, - targetInsideSource, - sourceSemantics, - cancellationToken, - ownedRecoveryMarkerPath, - ownedScaffoldPaths, - structuralSpinePaths, - ownedDirectoryMarkerPaths); - var expectedSourceManifest = manifest - .Where(entry => !IsRootManifestEntry(entry)) - .ToList(); - if (validatedEntries.Count != expectedSourceManifest.Count) - { - return false; - } - - var currentManifest = await BuildManifestAsync( - jobId, - validatedEntries, - cancellationToken); - return ManifestMatches( - expectedSourceManifest, - currentManifest, - sourceSemantics); - } - catch (MoveNeedsAttentionException) - { - return false; - } - } } diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.Persistence.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.Persistence.cs index c29ada04e..81485039c 100644 --- a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.Persistence.cs +++ b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.Persistence.cs @@ -52,16 +52,27 @@ private Task ValidatePersistedMoveIdentityAsync( FileSystemPathSemantics targetSemantics, MoveLeaseToken leaseToken, CancellationToken cancellationToken) => - executionStore.ValidateOrAdoptIdentityAsync( + executionStore.ValidateIdentityAsync( jobId, source, target, sourceSemantics, targetSemantics, leaseToken, - HasLegacyFilesystemRecoveryArtifacts(source, target, jobId), cancellationToken); + private async Task EnsureCurrentExecutionProtocolAsync( + Guid jobId, + CancellationToken cancellationToken) + { + var version = await GetExecutionProtocolVersionAsync(jobId, cancellationToken); + if (!MoveExecutionProtocol.IsCurrent(version)) + { + throw new MoveNeedsAttentionException( + "This move job does not use the current durable database execution protocol."); + } + } + private Task> LoadManifestAsync( Guid jobId, CancellationToken cancellationToken) => diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.PinnedArtifactRetirement.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.PinnedArtifactRetirement.cs deleted file mode 100644 index 99a5f5fc9..000000000 --- a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.PinnedArtifactRetirement.cs +++ /dev/null @@ -1,34 +0,0 @@ -namespace Listenarr.Infrastructure.Library.Moving; - -internal sealed partial class AudiobookContentMoveService -{ - private static async Task RetirePinnedArtifactAsync( - string artifactPath, - Action validate, - Func authorizeMutation) - { - ArgumentException.ThrowIfNullOrWhiteSpace(artifactPath); - ArgumentNullException.ThrowIfNull(validate); - ArgumentNullException.ThrowIfNull(authorizeMutation); - - var fullPath = Path.GetFullPath(artifactPath); - var parentPath = Path.GetDirectoryName(fullPath) - ?? throw new MoveNeedsAttentionException( - "The artifact parent directory is unavailable."); - using var parent = PinnedDirectoryCreation.OpenPinnedDirectoryNoFollow( - parentPath); - using var entry = parent.OpenExistingFile( - Path.GetFileName(fullPath), - requireDeleteAccess: true); - validate(entry); - await authorizeMutation(); - if (!parent.VisiblePathMatches() || !entry.VisiblePathMatches()) - { - throw new MoveNeedsAttentionException( - "The validated artifact changed before retirement."); - } - - validate(entry); - entry.Delete(); - } -} diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.PinnedCopy.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.PinnedCopy.cs deleted file mode 100644 index c6f85c636..000000000 --- a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.PinnedCopy.cs +++ /dev/null @@ -1,447 +0,0 @@ -using System.ComponentModel; -using Listenarr.Domain.Common; -using Microsoft.Extensions.Logging; - -namespace Listenarr.Infrastructure.Library.Moving; - -internal sealed partial class AudiobookContentMoveService -{ - private async Task CopyFileWithPinnedRetryAsync( - AudiobookContentMoveRequest request, - string sourceRoot, - string target, - string sourceFile, - string destinationFile, - MoveJobEntry manifestEntry, - string destinationRoot, - bool destinationIsJobOwnedTemp, - bool destinationHasStructuredOwnership, - FileSystemPathSemantics sourceSemantics, - FileSystemPathSemantics targetSemantics, - CancellationToken cancellationToken) - { - var (sourceSegments, sourceName) = SplitPinnedRelativeFilePath( - manifestEntry.RelativePath, - sourceSemantics); - var (destinationSegments, destinationName) = SplitPinnedRelativeFilePath( - manifestEntry.RelativePath, - targetSemantics); - var partialName = destinationName + $".listenarr-{request.JobId:N}.partial"; - var partialFile = Path.Join( - Path.GetDirectoryName(destinationFile) - ?? throw new MoveNeedsAttentionException( - "The copy destination file has no parent directory."), - partialName); - - try - { - using var sourcePath = PinnedMoveDirectoryPath.OpenExisting( - sourceRoot, - sourceSegments); - using var destinationPath = await PinnedMoveDirectoryPath.OpenOrCreateAsync( - destinationRoot, - destinationSegments, - () => EnsureMutationAuthorizedAsync( - request, - sourceRoot, - target, - cancellationToken)); - ValidatePinnedParentPath( - sourcePath.Current.FullPath, - sourceFile, - sourceSemantics, - "copy source"); - ValidatePinnedParentPath( - destinationPath.Current.FullPath, - destinationFile, - targetSemantics, - "copy destination"); - - for (var attempt = 1; attempt <= MaxCopyAttempts; attempt++) - { - PinnedDirectoryCreation.PinnedFileEntry? createdPartial = null; - try - { - sourcePath.EnsureVisibleHierarchy(); - destinationPath.EnsureVisibleHierarchy(); - using var sourceEntry = sourcePath.Current.OpenExistingFile( - sourceName, - requireDeleteAccess: false); - ValidatePinnedSourcePhysicalIdentity( - request, - manifestEntry, - sourceEntry); - if (!sourceEntry.VisiblePathMatches() - || !await sourceEntry.MatchesAsync( - manifestEntry.Length, - manifestEntry.Sha256, - cancellationToken)) - { - throw new MoveNeedsAttentionException( - $"Source file no longer matches the persisted move manifest: {manifestEntry.RelativePath}"); - } - - if (File.Exists(destinationFile)) - { - using var destinationEntry = destinationPath.Current.OpenExistingFile( - destinationName, - requireDeleteAccess: destinationIsJobOwnedTemp); - if (await destinationEntry.MatchesAsync( - manifestEntry.Length, - manifestEntry.Sha256, - cancellationToken)) - { - await RemovePinnedPartialIfPresentAsync( - request, - sourceRoot, - target, - destinationPath, - partialName, - partialFile, - manifestEntry, - destinationIsJobOwnedTemp, - destinationHasStructuredOwnership, - cancellationToken); - logger.LogInformation( - "Skipping copy for move job {JobId}; destination already matches the persisted manifest: {Destination}", - request.JobId, - LogRedaction.SanitizeFilePath(destinationFile)); - return; - } - - if (!destinationIsJobOwnedTemp) - { - throw new MoveNeedsAttentionException( - $"Destination file differs from the move manifest and will not be overwritten: {destinationName}"); - } - - await EnsureMutationAuthorizedAsync( - request, - sourceRoot, - target, - cancellationToken); - destinationPath.EnsureVisibleHierarchy(); - if (!destinationEntry.VisiblePathMatches()) - { - throw new MoveNeedsAttentionException( - "The owned destination file changed before replacement cleanup."); - } - destinationEntry.Delete(); - } - - if (File.Exists(partialFile)) - { - if (!destinationHasStructuredOwnership) - { - throw new MoveNeedsAttentionException( - "A job-shaped partial file exists without structured move ownership."); - } - - using var partialEntry = destinationPath.Current.OpenExistingFile( - partialName, - requireDeleteAccess: true); - if (await partialEntry.MatchesAsync( - manifestEntry.Length, - manifestEntry.Sha256, - cancellationToken)) - { - await PublishPinnedPartialAsync( - request, - sourceRoot, - target, - sourcePath, - sourceEntry, - destinationPath, - partialEntry, - destinationName, - destinationFile, - manifestEntry, - cancellationToken); - return; - } - - if (!destinationIsJobOwnedTemp) - { - throw new MoveNeedsAttentionException( - $"A direct-copy partial file does not match the persisted manifest and was preserved: {partialName}"); - } - - await EnsureMutationAuthorizedAsync( - request, - sourceRoot, - target, - cancellationToken); - destinationPath.EnsureVisibleHierarchy(); - if (!partialEntry.VisiblePathMatches()) - { - throw new MoveNeedsAttentionException( - "The owned partial file changed before cleanup."); - } - partialEntry.Delete(); - } - - await EnsureMutationAuthorizedAsync( - request, - sourceRoot, - target, - cancellationToken); - sourcePath.EnsureVisibleHierarchy(); - destinationPath.EnsureVisibleHierarchy(); - if (!sourceEntry.VisiblePathMatches() - || !await sourceEntry.MatchesAsync( - manifestEntry.Length, - manifestEntry.Sha256, - cancellationToken)) - { - throw new MoveNeedsAttentionException( - $"Source file changed before pinned copying: {manifestEntry.RelativePath}"); - } - - faultInjector?.OnCopyMutation( - request.JobId, - CopyMutationFaultPoint.BeforePartialFileCreation); - await EnsureMutationAuthorizedAsync( - request, - sourceRoot, - target, - cancellationToken); - sourcePath.EnsureVisibleHierarchy(); - destinationPath.EnsureVisibleHierarchy(); - if (!sourceEntry.VisiblePathMatches() - || File.Exists(partialFile) - || Directory.Exists(partialFile)) - { - throw new MoveNeedsAttentionException( - $"The copy source or partial destination changed at creation: {manifestEntry.RelativePath}"); - } - - createdPartial = destinationPath.Current.CreateNewFile(partialName); - await CopyFileWithLeaseChecksAsync( - request, - sourceRoot, - target, - sourceEntry, - createdPartial, - cancellationToken); - await EnsureMutationAuthorizedAsync( - request, - sourceRoot, - target, - cancellationToken); - TryPreservePinnedFileMetadata(sourceEntry, createdPartial, sourceFile); - if (!await createdPartial.MatchesAsync( - manifestEntry.Length, - manifestEntry.Sha256, - cancellationToken)) - { - createdPartial.Delete(); - throw new IOException( - "Temporary move copy failed persisted-manifest verification."); - } - - if (File.Exists(destinationFile)) - { - using var destinationEntry = destinationPath.Current.OpenExistingFile( - destinationName, - requireDeleteAccess: false); - if (await destinationEntry.MatchesAsync( - manifestEntry.Length, - manifestEntry.Sha256, - cancellationToken)) - { - await EnsureMutationAuthorizedAsync( - request, - sourceRoot, - target, - cancellationToken); - createdPartial.Delete(); - return; - } - - throw new MoveNeedsAttentionException( - $"Destination file appeared during the move and differs from the manifest: {destinationName}"); - } - - await PublishPinnedPartialAsync( - request, - sourceRoot, - target, - sourcePath, - sourceEntry, - destinationPath, - createdPartial, - destinationName, - destinationFile, - manifestEntry, - cancellationToken); - return; - } - catch (MoveNeedsAttentionException) - { - throw; - } - catch (IOException exception) when (attempt < MaxCopyAttempts) - { - if (createdPartial != null) - { - await EnsureMutationAuthorizedAsync( - request, - sourceRoot, - target, - cancellationToken); - destinationPath.EnsureVisibleHierarchy(); - if (createdPartial.VisiblePathMatches()) - { - createdPartial.Delete(); - } - } - - logger.LogWarning( - exception, - "IO error copying file {File} attempt {Attempt}", - LogRedaction.SanitizeFilePath(sourceFile), - attempt); - var delay = TimeSpan.FromSeconds( - Math.Min(8, Math.Pow(2, attempt - 1))); - await Task.Delay(delay, cancellationToken); - } - finally - { - createdPartial?.Dispose(); - } - } - } - catch (MoveNeedsAttentionException) - { - throw; - } - catch (Exception exception) when (exception is - InvalidOperationException or Win32Exception or UnauthorizedAccessException) - { - throw new MoveNeedsAttentionException( - $"The move file could not be copied through pinned filesystem handles: {exception.Message}"); - } - - throw new IOException( - $"Failed to copy file after {MaxCopyAttempts} attempts: {sourceFile}"); - } - - private async Task PublishPinnedPartialAsync( - AudiobookContentMoveRequest request, - string sourceRoot, - string target, - PinnedMoveDirectoryPath sourcePath, - PinnedDirectoryCreation.PinnedFileEntry sourceEntry, - PinnedMoveDirectoryPath destinationPath, - PinnedDirectoryCreation.PinnedFileEntry partialEntry, - string destinationName, - string destinationFile, - MoveJobEntry manifestEntry, - CancellationToken cancellationToken) - { - faultInjector?.OnCopyMutation( - request.JobId, - CopyMutationFaultPoint.BeforePartialPublication); - await EnsureMutationAuthorizedAsync( - request, - sourceRoot, - target, - cancellationToken); - sourcePath.EnsureVisibleHierarchy(); - destinationPath.EnsureVisibleHierarchy(); - if (!sourceEntry.VisiblePathMatches() - || !partialEntry.VisiblePathMatches() - || !await sourceEntry.MatchesAsync( - manifestEntry.Length, - manifestEntry.Sha256, - cancellationToken) - || !await partialEntry.MatchesAsync( - manifestEntry.Length, - manifestEntry.Sha256, - cancellationToken)) - { - throw new MoveNeedsAttentionException( - $"The source or partial copy changed before publication: {manifestEntry.RelativePath}"); - } - if (File.Exists(destinationFile) || Directory.Exists(destinationFile)) - { - throw new MoveNeedsAttentionException( - $"The copy destination appeared before publication: {destinationName}"); - } - - partialEntry.MoveWithinParent(destinationName); - } - - private async Task RemovePinnedPartialIfPresentAsync( - AudiobookContentMoveRequest request, - string sourceRoot, - string target, - PinnedMoveDirectoryPath destinationPath, - string partialName, - string partialFile, - MoveJobEntry manifestEntry, - bool destinationIsJobOwnedTemp, - bool destinationHasStructuredOwnership, - CancellationToken cancellationToken) - { - if (!File.Exists(partialFile)) - { - return; - } - if (!destinationHasStructuredOwnership) - { - throw new MoveNeedsAttentionException( - "A job-shaped partial file exists without structured move ownership."); - } - - using var partialEntry = destinationPath.Current.OpenExistingFile( - partialName, - requireDeleteAccess: true); - if (!destinationIsJobOwnedTemp - && !await partialEntry.MatchesAsync( - manifestEntry.Length, - manifestEntry.Sha256, - cancellationToken)) - { - throw new MoveNeedsAttentionException( - $"A direct-copy partial file does not match the persisted manifest and was preserved: {partialName}"); - } - - await EnsureMutationAuthorizedAsync( - request, - sourceRoot, - target, - cancellationToken); - destinationPath.EnsureVisibleHierarchy(); - if (!partialEntry.VisiblePathMatches() - || (!destinationIsJobOwnedTemp - && !await partialEntry.MatchesAsync( - manifestEntry.Length, - manifestEntry.Sha256, - cancellationToken))) - { - throw new MoveNeedsAttentionException( - $"The partial file changed before cleanup and was preserved: {partialName}"); - } - - partialEntry.Delete(); - } - - private void TryPreservePinnedFileMetadata( - PinnedDirectoryCreation.PinnedFileEntry sourceEntry, - PinnedDirectoryCreation.PinnedFileEntry destinationEntry, - string sourceFile) - { - try - { - sourceEntry.PreserveMetadataTo(destinationEntry); - } - catch (Exception exception) when (WorkerExceptionClassifier.IsNonFatal(exception)) - { - logger.LogDebug( - exception, - "Non-fatal: failed to preserve attributes for {File}", - LogRedaction.SanitizeFilePath(sourceFile)); - } - } -} diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.PinnedOwnershipMarkers.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.PinnedOwnershipMarkers.cs deleted file mode 100644 index 87efb9de4..000000000 --- a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.PinnedOwnershipMarkers.cs +++ /dev/null @@ -1,67 +0,0 @@ -namespace Listenarr.Infrastructure.Library.Moving; - -internal sealed partial class AudiobookContentMoveService -{ - private static MoveOwnershipMarker ReadOwnershipMarker( - PinnedDirectoryCreation.PinnedFileEntry markerEntry, - string markerPath) - { - var result = ReadOwnershipMarkerResult(markerEntry); - return result.State switch - { - MarkerReadState.Valid => result.Marker!, - MarkerReadState.TemporarilyUnreadable => throw new IOException( - $"The ownership marker '{markerPath}' is temporarily unreadable.", - result.Error), - MarkerReadState.Unsupported => throw new MoveNeedsAttentionException( - "The ownership marker uses an unsupported marker version and was preserved."), - MarkerReadState.CorruptOrTruncated => throw new MoveNeedsAttentionException( - "The ownership marker is corrupt or truncated."), - _ => throw new MoveNeedsAttentionException("The ownership marker is missing.") - }; - } - - private static MarkerReadResult ReadOwnershipMarkerResult( - PinnedDirectoryCreation.PinnedFileEntry markerEntry) - { - try - { - using var stream = markerEntry.OpenReadStream( - bufferSize: 4096, - asynchronous: false); - if (stream.Length > MaximumMarkerLength) - { - return new MarkerReadResult( - MarkerReadState.CorruptOrTruncated); - } - - stream.Position = 0; - var marker = System.Text.Json.JsonSerializer.Deserialize( - stream); - if (marker == null) - { - return new MarkerReadResult( - MarkerReadState.CorruptOrTruncated); - } - return marker.Version == OwnershipMarkerVersion - ? new MarkerReadResult( - MarkerReadState.Valid, - marker) - : new MarkerReadResult( - MarkerReadState.Unsupported, - marker); - } - catch (System.Text.Json.JsonException exception) - { - return new MarkerReadResult( - MarkerReadState.CorruptOrTruncated, - Error: exception); - } - catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) - { - return new MarkerReadResult( - MarkerReadState.TemporarilyUnreadable, - Error: exception); - } - } -} diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.PinnedSourceCleanup.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.PinnedSourceCleanup.cs deleted file mode 100644 index f27b13aa2..000000000 --- a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.PinnedSourceCleanup.cs +++ /dev/null @@ -1,385 +0,0 @@ -using System.ComponentModel; -using Listenarr.Domain.Common; - -namespace Listenarr.Infrastructure.Library.Moving; - -internal sealed partial class AudiobookContentMoveService -{ - private const int DestinationRetentionCleanupProtectionVersion = 1; - - private async Task MoveSourceFileToPinnedQuarantineAsync( - AudiobookContentMoveRequest request, - string source, - string target, - string sourceFile, - string quarantineFile, - string quarantineRoot, - MoveJobEntry manifestEntry, - FileSystemPathSemantics sourceSemantics, - CancellationToken cancellationToken) - { - var (directorySegments, fileName) = SplitPinnedRelativeFilePath( - manifestEntry.RelativePath, - sourceSemantics); - try - { - using var sourcePath = PinnedMoveDirectoryPath.OpenExisting( - source, - directorySegments); - using var quarantinePath = await PinnedMoveDirectoryPath.OpenOrCreateAsync( - quarantineRoot, - directorySegments, - () => EnsureMutationAuthorizedAsync( - request, - source, - target, - cancellationToken)); - ValidatePinnedParentPath( - sourcePath.Current.FullPath, - sourceFile, - sourceSemantics, - "source cleanup"); - ValidatePinnedParentPath( - quarantinePath.Current.FullPath, - quarantineFile, - sourceSemantics, - "quarantine cleanup"); - - using var sourceEntry = sourcePath.Current.OpenExistingFile( - fileName, - requireDeleteAccess: true); - ValidatePinnedSourcePhysicalIdentity( - request, - manifestEntry, - sourceEntry); - if (!sourceEntry.VisiblePathMatches() - || !await sourceEntry.MatchesAsync( - manifestEntry.Length, - manifestEntry.Sha256, - cancellationToken)) - { - throw new MoveNeedsAttentionException( - $"The pinned source cleanup entry changed after validation: {manifestEntry.RelativePath}"); - } - - sourcePath.EnsureVisibleHierarchy(); - quarantinePath.EnsureVisibleHierarchy(); - faultInjector?.OnSourceCleanupMutation( - request.JobId, - SourceCleanupFaultPoint.BeforeSourceFilePublication); - await EnsureMutationAuthorizedAsync( - request, - source, - target, - cancellationToken); - sourcePath.EnsureVisibleHierarchy(); - quarantinePath.EnsureVisibleHierarchy(); - if (!sourceEntry.VisiblePathMatches() - || !await sourceEntry.MatchesAsync( - manifestEntry.Length, - manifestEntry.Sha256, - cancellationToken)) - { - throw new MoveNeedsAttentionException( - $"The pinned source cleanup entry changed at publication: {manifestEntry.RelativePath}"); - } - - sourceEntry.MoveTo(quarantinePath.Current, fileName); - if (!await sourceEntry.MatchesAsync( - manifestEntry.Length, - manifestEntry.Sha256, - cancellationToken)) - { - throw new MoveNeedsAttentionException( - $"The pinned quarantine publication changed bytes: {manifestEntry.RelativePath}"); - } - } - catch (MoveNeedsAttentionException) - { - throw; - } - catch (Exception exception) when (exception is - InvalidOperationException or Win32Exception or UnauthorizedAccessException) - { - throw new MoveNeedsAttentionException( - $"The source file could not be quarantined through pinned directory handles " - + $"for '{manifestEntry.RelativePath}': {exception.Message}"); - } - } - - private async Task DeletePinnedQuarantineFileAsync( - AudiobookContentMoveRequest request, - string source, - string target, - string quarantineFile, - string quarantineRoot, - MoveJobEntry manifestEntry, - FileSystemPathSemantics sourceSemantics, - FileSystemPathSemantics targetSemantics, - CancellationToken cancellationToken) - { - var (quarantineDirectorySegments, fileName) = SplitPinnedRelativeFilePath( - manifestEntry.RelativePath, - sourceSemantics); - var (targetDirectorySegments, targetFileName) = SplitPinnedRelativeFilePath( - manifestEntry.RelativePath, - targetSemantics); - if (!FileSystemPathIdentity.TryResolveRelativePathWithinBase( - target, - manifestEntry.RelativePath, - targetSemantics, - out var targetFile)) - { - throw new MoveNeedsAttentionException( - $"Published target path escaped before pinned source deletion: {manifestEntry.RelativePath}"); - } - - try - { - using var quarantinePath = PinnedMoveDirectoryPath.OpenExisting( - quarantineRoot, - quarantineDirectorySegments); - using var targetPath = PinnedMoveDirectoryPath.OpenExisting( - target, - targetDirectorySegments); - ValidatePinnedParentPath( - quarantinePath.Current.FullPath, - quarantineFile, - sourceSemantics, - "quarantine deletion"); - ValidatePinnedParentPath( - targetPath.Current.FullPath, - targetFile, - targetSemantics, - "target verification"); - using var quarantineEntry = quarantinePath.Current.OpenExistingFile( - fileName, - requireDeleteAccess: true); - using var targetEntry = targetPath.Current.OpenExistingFile( - targetFileName, - requireDeleteAccess: false); - if (!quarantineEntry.VisiblePathMatches() - || !await quarantineEntry.MatchesAsync( - manifestEntry.Length, - manifestEntry.Sha256, - cancellationToken)) - { - throw new MoveNeedsAttentionException( - $"The pinned quarantine entry changed before deletion: {manifestEntry.RelativePath}"); - } - - quarantinePath.EnsureVisibleHierarchy(); - faultInjector?.OnSourceCleanupMutation( - request.JobId, - SourceCleanupFaultPoint.BeforeQuarantineFileRemoval); - await EnsureMutationAuthorizedAsync( - request, - source, - target, - cancellationToken); - quarantinePath.EnsureVisibleHierarchy(); - targetPath.EnsureVisibleHierarchy(); - if (!quarantineEntry.VisiblePathMatches() - || !await quarantineEntry.MatchesAsync( - manifestEntry.Length, - manifestEntry.Sha256, - cancellationToken)) - { - throw new MoveNeedsAttentionException( - $"The pinned quarantine entry changed at deletion: {manifestEntry.RelativePath}"); - } - - // The target is deliberately verified last. No callback, lease await, - // pathname reopen, or other asynchronous work may occur between this - // content check and retiring the only recoverable source generation. - if (!targetEntry.VisiblePathMatches() - || !await targetEntry.MatchesAsync( - manifestEntry.Length, - manifestEntry.Sha256, - cancellationToken)) - { - throw new MoveNeedsAttentionException( - $"The pinned published target changed at source deletion: {manifestEntry.RelativePath}"); - } - - quarantinePath.EnsureVisibleHierarchy(); - targetPath.EnsureVisibleHierarchy(); - if (!quarantineEntry.VisiblePathMatches() - || !targetEntry.VisiblePathMatches()) - { - throw new MoveNeedsAttentionException( - $"The pinned source or target generation changed at deletion: {manifestEntry.RelativePath}"); - } - - var retentionName = - PinnedDestinationRetentionGuard.CreateRetentionName( - request.JobId, - manifestEntry.RelativePath); - using var retention = - await PinnedDestinationRetentionGuard.OpenOrRepairOwnedAsync( - targetPath.Current, - targetFileName, - retentionName, - manifestEntry.Length, - manifestEntry.Sha256 - ?? throw new MoveNeedsAttentionException( - $"The move manifest has no content identity for {manifestEntry.RelativePath}."), - cancellationToken); - if (retention == null) - { - throw new MoveNeedsAttentionException( - $"A durable target retention guard could not be established for {manifestEntry.RelativePath}."); - } - - await UpdateCleanupProtectionVersionAsync( - request.JobId, - request.LeaseToken, - manifestEntry.RelativePath, - DestinationRetentionCleanupProtectionVersion, - cancellationToken); - manifestEntry.CleanupProtectionVersion = - DestinationRetentionCleanupProtectionVersion; - - faultInjector?.OnSourceCleanupMutation( - request.JobId, - SourceCleanupFaultPoint.BeforePinnedQuarantineDelete); - if (!await retention.TryLinearizePublicationAsync(cancellationToken)) - { - throw new MoveNeedsAttentionException( - $"The published target changed at the durable source-retirement boundary: {manifestEntry.RelativePath}"); - } - - quarantineEntry.Delete(); - quarantinePath.Current.FlushDirectoryEntry(); - faultInjector?.OnSourceCleanupMutation( - request.JobId, - SourceCleanupFaultPoint.AfterPinnedQuarantineDelete); - if (!await retention.CompleteAsync(cancellationToken)) - { - throw new MoveNeedsAttentionException( - $"The durable target retention guard could not be retired for {manifestEntry.RelativePath}."); - } - } - catch (MoveNeedsAttentionException) - { - throw; - } - catch (Exception exception) when (exception is - IOException or InvalidOperationException or Win32Exception - or UnauthorizedAccessException) - { - throw new MoveNeedsAttentionException( - $"The quarantine file could not be removed through pinned directory handles: {exception.Message}"); - } - } - - private async Task TryCompleteMissingQuarantineRetentionAsync( - AudiobookContentMoveRequest request, - string source, - string target, - MoveJobEntry manifestEntry, - FileSystemPathSemantics targetSemantics, - CancellationToken cancellationToken) - { - var (targetDirectorySegments, targetFileName) = SplitPinnedRelativeFilePath( - manifestEntry.RelativePath, - targetSemantics); - try - { - await EnsureMutationAuthorizedAsync( - request, - source, - target, - cancellationToken); - using var targetPath = PinnedMoveDirectoryPath.OpenExisting( - target, - targetDirectorySegments); - targetPath.EnsureVisibleHierarchy(); - var retentionName = - PinnedDestinationRetentionGuard.CreateRetentionName( - request.JobId, - manifestEntry.RelativePath); - using var retention = - await PinnedDestinationRetentionGuard.OpenExistingAsync( - targetPath.Current, - targetFileName, - retentionName, - manifestEntry.Length, - manifestEntry.Sha256 - ?? throw new MoveNeedsAttentionException( - $"The move manifest has no content identity for {manifestEntry.RelativePath}."), - cancellationToken); - if (retention == null) - { - return false; - } - - return await retention.TryLinearizePublicationAsync(cancellationToken) - && await retention.CompleteAsync(cancellationToken); - } - catch (MoveNeedsAttentionException) - { - throw; - } - catch (Exception exception) when (exception is - IOException or InvalidOperationException or Win32Exception - or UnauthorizedAccessException or PlatformNotSupportedException) - { - throw new MoveNeedsAttentionException( - $"The durable target retention could not be recovered for '{manifestEntry.RelativePath}': {exception.Message}"); - } - } - - private static void ValidatePinnedParentPath( - string pinnedParent, - string expectedFile, - FileSystemPathSemantics semantics, - string description) - { - var expectedParent = Path.GetDirectoryName(Path.GetFullPath(expectedFile)); - if (string.IsNullOrWhiteSpace(expectedParent) - || !FileSystemPathIdentity.AreEquivalent( - Path.GetFullPath(pinnedParent), - expectedParent, - semantics)) - { - throw new MoveNeedsAttentionException( - $"The pinned {description} parent does not match the manifest path."); - } - } - - private static (IReadOnlyList DirectorySegments, string FileName) - SplitPinnedRelativeFilePath( - string relativePath, - FileSystemPathSemantics semantics) - { - if (string.IsNullOrWhiteSpace(relativePath)) - { - throw new MoveNeedsAttentionException( - "A file manifest entry has no relative path."); - } - - var separators = semantics.Syntax == FileSystemPathSyntax.Windows - ? new[] { '\\', '/' } - : new[] { '/' }; - var lastSeparator = relativePath.LastIndexOfAny(separators); - var fileName = lastSeparator < 0 - ? relativePath - : relativePath[(lastSeparator + 1)..]; - var directoryPart = lastSeparator < 0 - ? string.Empty - : relativePath[..lastSeparator]; - var segments = directoryPart.Split( - separators, - StringSplitOptions.RemoveEmptyEntries); - if (string.IsNullOrWhiteSpace(fileName) - || segments.Any(segment => segment is "." or "..")) - { - throw new MoveNeedsAttentionException( - "A file manifest entry contains an invalid cleanup path segment."); - } - - return (segments, fileName); - } - -} diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.QuarantineOwnership.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.QuarantineOwnership.cs deleted file mode 100644 index 6c56ad46f..000000000 --- a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.QuarantineOwnership.cs +++ /dev/null @@ -1,449 +0,0 @@ -using Listenarr.Domain.Common; - -namespace Listenarr.Infrastructure.Library.Moving; - -internal sealed partial class AudiobookContentMoveService -{ - private const string QuarantineOwnershipMarkerFileName = ".listenarr-quarantine-owner.json"; - - private sealed record ValidatedQuarantineOwnership( - string DirectoryPath, - string MarkerPath, - MoveOwnershipMarker Marker); - - private async Task CreateOrValidateOwnedQuarantineDirectoryAsync( - string quarantineRoot, - string sourceParent, - Guid jobId, - string source, - string target, - FileSystemPathSemantics sourceSemantics, - FileSystemPathSemantics targetSemantics, - MoveLeaseToken leaseToken, - CancellationToken cancellationToken) - { - var markerPath = Path.Join( - quarantineRoot, - QuarantineOwnershipMarkerFileName); - if (await TryCompleteOwnedDirectoryCleanupAsync( - quarantineRoot, - markerPath, - QuarantineDirectoryArtifactType, - jobId, - source, - target, - sourceSemantics, - targetSemantics, - sourceSemantics, - leaseToken, - () => EnsureMutationAuthorizedAsync( - jobId, - leaseToken, - source, - target, - sourceSemantics, - targetSemantics, - cancellationToken))) - { - if (Directory.Exists(quarantineRoot) || File.Exists(quarantineRoot)) - { - throw new MoveNeedsAttentionException( - "The original move quarantine path was recreated during cleanup and was preserved."); - } - - // A prior completed cleanup left durable tombstone evidence. - } - else if (Directory.Exists(quarantineRoot)) - { - try - { - return await ValidateOwnedQuarantineDirectoryAsync( - quarantineRoot, - sourceParent, - jobId, - source, - target, - sourceSemantics, - targetSemantics, - leaseToken, - cancellationToken); - } - catch (InterruptedOwnershipPublicationException) - { - await RetirePinnedEmptyDirectoryAsync( - quarantineRoot, - "interrupted quarantine directory", - () => - { - ValidateExistingMoveDirectory( - quarantineRoot, - "interrupted quarantine directory"); - if (Directory.EnumerateFileSystemEntries(quarantineRoot).Any()) - { - throw new MoveNeedsAttentionException( - "An interrupted quarantine ownership publication left unexpected content."); - } - }, - () => EnsureMutationAuthorizedAsync( - jobId, - leaseToken, - source, - target, - sourceSemantics, - targetSemantics, - cancellationToken)); - } - } - - if (File.Exists(quarantineRoot)) - { - throw new MoveNeedsAttentionException( - "The move quarantine path is occupied by a file and cannot be claimed safely."); - } - - ValidateMoveRootPath(quarantineRoot, mustExist: false, "quarantine"); - var normalizedSourceParent = Path.GetFullPath(sourceParent); - var normalizedQuarantineRoot = Path.GetFullPath(quarantineRoot); - var quarantineParent = Path.GetDirectoryName(normalizedQuarantineRoot); - if (string.IsNullOrWhiteSpace(quarantineParent) - || !FileSystemPathIdentity.AreEquivalent( - normalizedSourceParent, - quarantineParent, - sourceSemantics)) - { - throw new MoveNeedsAttentionException( - "The move quarantine directory escaped its validated source parent."); - } - - await EnsureMutationAuthorizedAsync( - jobId, - leaseToken, - source, - target, - sourceSemantics, - targetSemantics, - cancellationToken); - ValidateMoveRootPath(quarantineRoot, mustExist: false, "quarantine"); - using var quarantineCreation = PinnedDirectoryCreation.TryCreate( - normalizedSourceParent, - Path.GetFileName(normalizedQuarantineRoot)); - if (!quarantineCreation.Created) - { - throw new MoveNeedsAttentionException( - "The move quarantine directory appeared before Listenarr could claim it exclusively."); - } - if (!quarantineCreation.VisiblePathMatches()) - { - throw new MoveNeedsAttentionException( - "The move quarantine parent changed during exclusive creation."); - } - - using var quarantineAnchor = quarantineCreation.OpenCreatedDirectoryAnchor(); - if (!quarantineAnchor.VisiblePathMatches()) - { - throw new MoveNeedsAttentionException( - "The move quarantine directory identity changed after exclusive creation."); - } - - ValidateExistingMoveDirectory(quarantineRoot, "quarantine directory"); - var marker = CreateOwnershipMarker( - QuarantineDirectoryArtifactType, - jobId, - source, - target, - quarantineRoot); - try - { - await EnsureMutationAuthorizedAsync( - jobId, - leaseToken, - source, - target, - sourceSemantics, - targetSemantics, - cancellationToken); - await PublishOwnershipMarkerAsync( - markerPath, - marker, - OwnershipMarkerKind.QuarantineDirectory, - leaseToken, - () => EnsureMutationAuthorizedAsync( - jobId, - leaseToken, - source, - target, - sourceSemantics, - targetSemantics, - cancellationToken), - quarantineAnchor); - return await ValidateOwnedQuarantineDirectoryAsync( - quarantineRoot, - sourceParent, - jobId, - source, - target, - sourceSemantics, - targetSemantics, - leaseToken, - cancellationToken); - } - catch (Exception exception) when (exception is MoveLeaseLostException or PersistenceException) - { - throw; - } - catch (MoveNeedsAttentionException) - { - throw; - } - catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) - { - await TryRemoveNewEmptyOwnershipDirectoryAsync( - quarantineRoot, - jobId, - "quarantine", - () => EnsureMutationAuthorizedAsync( - jobId, - leaseToken, - source, - target, - sourceSemantics, - targetSemantics, - cancellationToken)); - throw; - } - catch (Exception exception) when (WorkerExceptionClassifier.IsNonFatal(exception)) - { - await TryRemoveNewEmptyOwnershipDirectoryAsync( - quarantineRoot, - jobId, - "quarantine", - () => EnsureMutationAuthorizedAsync( - jobId, - leaseToken, - source, - target, - sourceSemantics, - targetSemantics, - cancellationToken)); - throw new MoveNeedsAttentionException( - $"The move quarantine directory could not be claimed safely: {exception.Message}"); - } - } - - private async Task ValidateOwnedQuarantineDirectoryAsync( - string quarantineRoot, - string sourceParent, - Guid jobId, - string source, - string target, - FileSystemPathSemantics sourceSemantics, - FileSystemPathSemantics targetSemantics, - MoveLeaseToken leaseToken, - CancellationToken cancellationToken) - { - if (!FileSystemSafety.TryValidateMutationTarget( - quarantineRoot, - [sourceParent], - out var safeQuarantineRoot, - out var quarantineReason)) - { - throw new MoveNeedsAttentionException(quarantineReason); - } - - ValidateExistingMoveDirectory( - safeQuarantineRoot, - "quarantine directory"); - var markerPath = Path.Join( - safeQuarantineRoot, - QuarantineOwnershipMarkerFileName); - var expectedMarker = CreateOwnershipMarker( - QuarantineDirectoryArtifactType, - jobId, - source, - target, - quarantineRoot); - var marker = await RecoverOrReadOwnershipMarkerAsync( - markerPath, - expectedMarker, - sourceSemantics, - targetSemantics, - sourceSemantics, - leaseToken, - () => EnsureMutationAuthorizedAsync( - jobId, - leaseToken, - source, - target, - sourceSemantics, - targetSemantics, - cancellationToken)); - - var ownership = new ValidatedQuarantineOwnership( - safeQuarantineRoot, - markerPath, - marker); - ValidateOwnedQuarantineTree(ownership); - return ownership; - } - - private async Task TryValidateExistingQuarantineDirectoryAsync( - string source, - string target, - Guid jobId, - FileSystemPathSemantics sourceSemantics, - FileSystemPathSemantics targetSemantics, - MoveLeaseToken leaseToken, - CancellationToken cancellationToken) - { - var sourceParent = Path.GetDirectoryName(Path.GetFullPath(source)) - ?? throw new MoveNeedsAttentionException("The source parent is unavailable."); - var quarantineRoot = Path.Join( - sourceParent, - $".listenarr-quarantine-{jobId:N}"); - var markerPath = Path.Join( - quarantineRoot, - QuarantineOwnershipMarkerFileName); - if (await TryCompleteOwnedDirectoryCleanupAsync( - quarantineRoot, - markerPath, - QuarantineDirectoryArtifactType, - jobId, - source, - target, - sourceSemantics, - targetSemantics, - sourceSemantics, - leaseToken, - () => EnsureMutationAuthorizedAsync( - jobId, - leaseToken, - source, - target, - sourceSemantics, - targetSemantics, - cancellationToken))) - { - return null; - } - - if (!Directory.Exists(quarantineRoot)) - { - if (File.Exists(quarantineRoot)) - { - throw new MoveNeedsAttentionException( - "The move quarantine path is occupied by a file and cannot be validated safely."); - } - - return null; - } - - return await ValidateOwnedQuarantineDirectoryAsync( - quarantineRoot, - sourceParent, - jobId, - source, - target, - sourceSemantics, - targetSemantics, - leaseToken, - cancellationToken); - } - - private static void ValidateOwnedQuarantineTree( - ValidatedQuarantineOwnership ownership) - { - ValidateExistingMoveDirectory( - ownership.DirectoryPath, - "quarantine directory"); - if (!FileSystemSafety.TryEnumerateTreeWithoutLinks( - ownership.DirectoryPath, - out _, - out _, - out var reason)) - { - throw new MoveNeedsAttentionException( - $"The move quarantine directory could not be traversed safely: {reason}"); - } - } - - private static void ValidateQuarantineMutationPath( - ValidatedQuarantineOwnership ownership, - string path) - { - ValidateOwnedQuarantineTree(ownership); - if (!FileSystemSafety.TryValidateMutationTarget( - path, - [ownership.DirectoryPath], - out path, - out var reason)) - { - throw new MoveNeedsAttentionException(reason); - } - - if ((File.Exists(path) || Directory.Exists(path)) - && (File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0) - { - throw new MoveNeedsAttentionException( - "A move quarantine entry is a symbolic link or reparse point."); - } - } - - private async Task DeleteEmptyOwnedQuarantineDirectoryAsync( - ValidatedQuarantineOwnership ownership, - Guid jobId, - string source, - string target, - FileSystemPathSemantics sourceSemantics, - FileSystemPathSemantics targetSemantics, - MoveLeaseToken leaseToken, - CancellationToken cancellationToken) - { - if (!FileSystemSafety.TryEnumerateTreeWithoutLinks( - ownership.DirectoryPath, - out var files, - out var directories, - out var reason)) - { - throw new MoveNeedsAttentionException( - $"The completed move quarantine could not be traversed safely: {reason}"); - } - - var unexpectedFile = files.FirstOrDefault(file => - !FileSystemPathIdentity.AreEquivalent( - file, - ownership.MarkerPath, - sourceSemantics)); - if (unexpectedFile != null) - { - throw new MoveNeedsAttentionException( - $"The completed move quarantine contains an unexpected file: {Path.GetFileName(unexpectedFile)}"); - } - - if (directories.Any(directory => - Directory.EnumerateFileSystemEntries(directory).Any())) - { - throw new MoveNeedsAttentionException( - "The completed move quarantine contains an unexpected non-empty directory."); - } - - await DeleteOwnedDirectoryWithTombstoneAsync( - ownership.DirectoryPath, - ownership.MarkerPath, - QuarantineDirectoryArtifactType, - jobId, - source, - target, - sourceSemantics, - targetSemantics, - sourceSemantics, - leaseToken, - () => EnsureMutationAuthorizedAsync( - jobId, - leaseToken, - source, - target, - sourceSemantics, - targetSemantics, - cancellationToken)); - } -} diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.Recovery.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.Recovery.cs deleted file mode 100644 index 4fd0d183e..000000000 --- a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.Recovery.cs +++ /dev/null @@ -1,262 +0,0 @@ -/* - * Listenarr - Audiobook Management System - * Copyright (C) 2024-2026 Listenarr Contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published - * by the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - */ - -using Microsoft.Extensions.Logging; - -namespace Listenarr.Infrastructure.Library.Moving; - -internal sealed partial class AudiobookContentMoveService -{ - private const int RecoveryMarkerVersion = 1; - private const string CopyStartedStage = "copy-started"; - private const string CopyCompletedStage = "copy-complete"; - private const string AtomicRenameCompletedStage = "atomic-rename-complete"; - private const string SourceCleanupCompletedStage = "source-cleanup-complete"; - - private sealed record MoveRecoveryMarker( - int Version, - Guid JobId, - string Source, - string Target, - string Stage); - - private sealed record ParsedRecoveryMarker( - MoveRecoveryMarker? StructuredMarker, - string? ObsoleteStage) - { - public string Stage => StructuredMarker?.Stage - ?? ObsoleteStage - ?? throw new InvalidOperationException("The recovery marker has no stage."); - - public bool IsObsolete => ObsoleteStage != null; - } - - private async Task RecoverRecoveryMarkerWriteFilesAsync( - string markerDirectory, - AudiobookContentMoveRequest request, - string source, - string target, - CancellationToken cancellationToken) - { - if (!Directory.Exists(markerDirectory)) - { - return; - } - - ValidateExistingMoveDirectory(markerDirectory, "recovery-marker cleanup directory"); - var authoritativeMarkerPath = GetRecoveryMarkerPath(markerDirectory, request.JobId); - var writeFilePrefix = Path.GetFileName(authoritativeMarkerPath) + ".writing-"; - foreach (var writePath in Directory.EnumerateFiles( - markerDirectory, - writeFilePrefix + "*", - SearchOption.TopDirectoryOnly) - .ToList()) - { - if (!FileSystemSafety.TryValidateMutationTarget( - writePath, - [markerDirectory], - out var safeWritePath, - out var writeReason)) - { - throw new MoveNeedsAttentionException(writeReason); - } - - if ((File.GetAttributes(safeWritePath) & FileAttributes.ReparsePoint) != 0) - { - throw new MoveNeedsAttentionException( - "A recovery-marker write-temporary file is a symbolic link or reparse point."); - } - - if (!TryParseMarkerWriteIdentity( - safeWritePath, - authoritativeMarkerPath, - out var writeIdentity) - || writeIdentity.JobId != request.JobId) - { - throw new MoveNeedsAttentionException( - "A recovery-marker write-temporary filename does not match the active move job."); - } - - var markerRead = ReadRecoveryMarkerWriteResult(safeWritePath); - if (markerRead.State == MarkerReadState.TemporarilyUnreadable) - { - throw new IOException( - "A recovery-marker write file is temporarily unreadable and was preserved.", - markerRead.Error); - } - if (markerRead.State == MarkerReadState.Unsupported) - { - throw new MoveNeedsAttentionException( - "A recovery-marker write file uses an unsupported marker version or stage and was preserved."); - } - if (markerRead.State == MarkerReadState.CorruptOrTruncated) - { - if (writeIdentity.LeaseGeneration >= request.LeaseGeneration) - { - throw new MoveNeedsAttentionException( - "A current or future-generation recovery-marker write file is truncated and was preserved."); - } - - ValidateRecoveryMarkerWritePath(safeWritePath, markerDirectory); - await RetirePinnedArtifactAsync( - safeWritePath, - entry => - { - var currentRead = ReadRecoveryMarkerWriteResult(entry); - if (currentRead.State == MarkerReadState.TemporarilyUnreadable) - { - throw new IOException( - "A predecessor recovery-marker write file became temporarily unreadable and was preserved.", - currentRead.Error); - } - if (currentRead.State != MarkerReadState.CorruptOrTruncated - || !TryParseMarkerWriteIdentity( - safeWritePath, - authoritativeMarkerPath, - out var currentIdentity) - || currentIdentity != writeIdentity) - { - throw new MoveNeedsAttentionException( - "A truncated recovery-marker write file changed before cleanup."); - } - }, - async () => - { - await EnsureMutationAuthorizedAsync( - request, - source, - target, - cancellationToken); - faultInjector?.OnRecoveryMarkerWrite( - request.JobId, - RecoveryMarkerWriteFaultPoint.BeforeTemporaryFileDeletion); - }); - logger.LogInformation( - "Removed truncated predecessor recovery-marker write file for move job {JobId}", - request.JobId); - continue; - } - - var marker = markerRead.Marker - ?? throw new MoveNeedsAttentionException("The recovery-marker write file is missing."); - - if (writeIdentity.LeaseGeneration > request.LeaseGeneration) - { - throw new MoveNeedsAttentionException( - "A future-generation recovery-marker write file was preserved."); - } - - ValidateRecoveryMarker( - new ParsedRecoveryMarker(marker, ObsoleteStage: null), - request, - source, - target); - var authoritativeMarker = ReadRecoveryMarker(authoritativeMarkerPath); - ValidateRecoveryMarker(authoritativeMarker, request, source, target); - if (authoritativeMarker == null) - { - await WriteRecoveryMarkerAsync( - markerDirectory, - request, - source, - target, - marker.Stage, - cancellationToken); - } - else if (!string.Equals( - authoritativeMarker.Stage, - marker.Stage, - StringComparison.Ordinal)) - { - if (CanAdvanceRecoveryStage(authoritativeMarker.Stage, marker.Stage)) - { - await WriteRecoveryMarkerAsync( - markerDirectory, - request, - source, - target, - marker.Stage, - cancellationToken); - } - else if (!CanAdvanceRecoveryStage(marker.Stage, authoritativeMarker.Stage)) - { - throw new MoveNeedsAttentionException( - "A recovery-marker write file belongs to an incompatible recovery workflow."); - } - } - - ValidateRecoveryMarkerWritePath(safeWritePath, markerDirectory); - await RetirePinnedArtifactAsync( - safeWritePath, - entry => - { - var currentRead = ReadRecoveryMarkerWriteResult(entry); - if (currentRead.State == MarkerReadState.TemporarilyUnreadable) - { - throw new IOException( - "A recovery-marker write file became temporarily unreadable and was preserved.", - currentRead.Error); - } - var currentMarker = currentRead.State == MarkerReadState.Valid - ? currentRead.Marker! - : throw new MoveNeedsAttentionException( - "A recovery-marker write-temporary file changed before deletion."); - ValidateRecoveryMarker( - new ParsedRecoveryMarker(currentMarker, ObsoleteStage: null), - request, - source, - target); - }, - async () => - { - await EnsureMutationAuthorizedAsync( - request, - source, - target, - cancellationToken); - faultInjector?.OnRecoveryMarkerWrite( - request.JobId, - RecoveryMarkerWriteFaultPoint.BeforeTemporaryFileDeletion); - }); - logger.LogInformation( - "Removed validated orphan recovery-marker write file for move job {JobId}", - request.JobId); - } - } - - private static MarkerReadResult ReadRecoveryMarkerWriteResult( - string writePath) - { - var result = ReadJsonMarker(writePath); - return ClassifyRecoveryMarkerWriteResult(result); - } - - private static MarkerReadResult ReadRecoveryMarkerWriteResult( - PinnedDirectoryCreation.PinnedFileEntry entry) - { - var result = ReadJsonMarker(entry); - return ClassifyRecoveryMarkerWriteResult(result); - } - - private static MarkerReadResult ClassifyRecoveryMarkerWriteResult( - MarkerReadResult result) - { - if (result.State == MarkerReadState.Valid - && (result.Marker!.Version != RecoveryMarkerVersion - || !IsKnownRecoveryStage(result.Marker.Stage))) - { - return new MarkerReadResult( - MarkerReadState.Unsupported, - result.Marker); - } - - return result; - } -} diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.RecoveryMarkers.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.RecoveryMarkers.cs deleted file mode 100644 index a59bee61c..000000000 --- a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.RecoveryMarkers.cs +++ /dev/null @@ -1,448 +0,0 @@ -using System.Text.Json; -using Listenarr.Domain.Common; -using Microsoft.Extensions.Logging; - -namespace Listenarr.Infrastructure.Library.Moving; - -internal sealed partial class AudiobookContentMoveService -{ - private void ValidateExistingRecoveryMarker( - string markerDirectory, - string markerPath, - AudiobookContentMoveRequest request, - string source, - string target) - { - _ = ReadValidatedExistingRecoveryMarker( - markerDirectory, - markerPath, - request, - source, - target); - } - - private void ValidateExistingRecoveryMarkerForStage( - string markerDirectory, - string markerPath, - AudiobookContentMoveRequest request, - string source, - string target, - string candidateStage) - { - var existing = ReadValidatedExistingRecoveryMarker( - markerDirectory, - markerPath, - request, - source, - target); - if (!CanAdvanceRecoveryStage(existing.Stage, candidateStage)) - { - throw new MoveNeedsAttentionException( - "The existing recovery marker is already at a later or incompatible stage."); - } - } - - private ParsedRecoveryMarker ReadValidatedExistingRecoveryMarker( - string markerDirectory, - string markerPath, - AudiobookContentMoveRequest request, - string source, - string target) - { - ValidateExistingMoveDirectory(markerDirectory, "recovery-marker directory"); - if (!FileSystemSafety.TryValidateMutationTarget( - markerPath, - [markerDirectory], - out markerPath, - out var markerReason)) - { - throw new MoveNeedsAttentionException(markerReason); - } - - if (!File.Exists(markerPath) - || (File.GetAttributes(markerPath) & FileAttributes.ReparsePoint) != 0) - { - throw new MoveNeedsAttentionException( - "The existing recovery marker is missing or linked."); - } - - var parsed = ReadRecoveryMarker(markerPath) - ?? throw new MoveNeedsAttentionException("The existing recovery marker disappeared."); - ValidateRecoveryMarker(parsed, request, source, target); - return parsed; - } - - private static void ValidateNewRecoveryMarkerWritePath( - string writePath, - string markerDirectory) - { - ValidateExistingMoveDirectory(markerDirectory, "recovery-marker directory"); - if (!FileSystemSafety.TryValidateMutationTarget( - writePath, - [markerDirectory], - out writePath, - out var writeReason)) - { - throw new MoveNeedsAttentionException(writeReason); - } - - if (File.Exists(writePath) || Directory.Exists(writePath)) - { - throw new MoveNeedsAttentionException( - "The recovery-marker temporary path appeared before creation."); - } - } - - private static void ValidateRecoveryMarkerPublicationPaths( - string markerDirectory, - string writePath, - string markerPath) - { - ValidateExistingMoveDirectory(markerDirectory, "recovery-marker directory"); - ValidateRecoveryMarkerWritePath(writePath, markerDirectory); - if (!FileSystemSafety.TryValidateMutationTarget( - markerPath, - [markerDirectory], - out markerPath, - out var markerReason)) - { - throw new MoveNeedsAttentionException(markerReason); - } - - if (File.Exists(markerPath) - && (File.GetAttributes(markerPath) & FileAttributes.ReparsePoint) != 0) - { - throw new MoveNeedsAttentionException( - "The authoritative recovery marker became a symbolic link or reparse point."); - } - } - - private static void ValidateRecoveryMarkerWritePath( - string writePath, - string markerDirectory) - { - ValidateExistingMoveDirectory(markerDirectory, "recovery-marker directory"); - if (!FileSystemSafety.TryValidateMutationTarget( - writePath, - [markerDirectory], - out writePath, - out var writeReason)) - { - throw new MoveNeedsAttentionException(writeReason); - } - - if (!File.Exists(writePath) - || (File.GetAttributes(writePath) & FileAttributes.ReparsePoint) != 0) - { - throw new MoveNeedsAttentionException( - "The recovery-marker write-temporary file is missing or linked."); - } - } - - private ParsedRecoveryMarker? ReadRecoveryMarker(string markerPath) - { - var markerDirectory = Path.GetDirectoryName(Path.GetFullPath(markerPath)); - if (string.IsNullOrWhiteSpace(markerDirectory) - || !Directory.Exists(markerDirectory)) - { - return null; - } - - try - { - using var directoryAnchor = - PinnedDirectoryCreation.OpenPinnedDirectoryNoFollow(markerDirectory); - if (!directoryAnchor.VisiblePathMatches()) - { - throw new MoveNeedsAttentionException( - "The move recovery marker directory changed while it was being inspected."); - } - - PinnedDirectoryCreation.PinnedFileEntry markerEntry; - try - { - markerEntry = directoryAnchor.OpenExistingFileForStableRead( - Path.GetFileName(markerPath)); - } - catch (System.ComponentModel.Win32Exception exception) when ( - exception.NativeErrorCode is 2 or 3) - { - return null; - } - catch (FileNotFoundException) - { - return null; - } - catch (DirectoryNotFoundException) - { - return null; - } - using (markerEntry) - { - if (!markerEntry.VisiblePathMatches()) - { - throw new MoveNeedsAttentionException( - "The move recovery marker changed while it was being inspected."); - } - - return ReadRecoveryMarker(markerEntry, markerPath); - } - } - catch (MoveNeedsAttentionException) - { - throw; - } - catch (Exception exception) when ( - (exception is InvalidOperationException - or IOException - or UnauthorizedAccessException - or System.ComponentModel.Win32Exception) - && RecoveryMarkerPathIsLinked(markerPath)) - { - logger.LogWarning( - exception, - "Move recovery marker {Marker} is linked and cannot be trusted", - LogRedaction.SanitizeFilePath(markerPath)); - throw new MoveNeedsAttentionException( - "The move recovery marker is a symbolic link or reparse point and was preserved for review."); - } - catch (Exception exception) when (exception is - IOException or UnauthorizedAccessException or System.ComponentModel.Win32Exception) - { - logger.LogWarning( - exception, - "Move recovery marker {Marker} is temporarily unreadable", - LogRedaction.SanitizeFilePath(markerPath)); - throw new IOException( - "The move recovery marker is temporarily unreadable and was preserved.", - exception); - } - } - - private static bool RecoveryMarkerEntryExists(string markerPath) - { - try - { - var marker = new FileInfo(markerPath); - return marker.Exists || !string.IsNullOrWhiteSpace(marker.LinkTarget); - } - catch (Exception exception) when (exception is - FileNotFoundException or DirectoryNotFoundException) - { - return false; - } - catch (Exception exception) when (exception is - IOException or UnauthorizedAccessException or NotSupportedException) - { - // Fail closed. The subsequent pinned read will classify the concrete - // unreadable state without treating an uncertain artifact as absent. - return true; - } - } - - private static bool RecoveryMarkerPathIsLinked(string markerPath) - { - try - { - var marker = new FileInfo(markerPath); - return !string.IsNullOrWhiteSpace(marker.LinkTarget) - || (marker.Attributes & FileAttributes.ReparsePoint) != 0; - } - catch (Exception exception) when (exception is - IOException or UnauthorizedAccessException or NotSupportedException) - { - return false; - } - } - - private ParsedRecoveryMarker ReadRecoveryMarker( - PinnedDirectoryCreation.PinnedFileEntry markerEntry, - string markerPath) - { - try - { - using var stream = markerEntry.OpenReadStream( - bufferSize: 4096, - asynchronous: false); - if (stream.Length > MaximumMarkerLength) - { - throw new MoveNeedsAttentionException( - "The move recovery marker exceeds the supported size and was preserved."); - } - - stream.Position = 0; - using var reader = new StreamReader( - stream, - System.Text.Encoding.UTF8, - detectEncodingFromByteOrderMarks: true, - bufferSize: 4096, - leaveOpen: false); - return ParseRecoveryMarkerContent(reader.ReadToEnd().Trim()); - } - catch (MoveNeedsAttentionException) - { - throw; - } - catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) - { - logger.LogWarning( - exception, - "Pinned move recovery marker {Marker} is temporarily unreadable", - LogRedaction.SanitizeFilePath(markerPath)); - throw new IOException( - "The move recovery marker is temporarily unreadable and was preserved.", - exception); - } - } - - private static ParsedRecoveryMarker ParseRecoveryMarkerContent(string content) - { - if (IsKnownRecoveryStage(content)) - { - return new ParsedRecoveryMarker( - StructuredMarker: null, - ObsoleteStage: content); - } - - MoveRecoveryMarker? marker; - try - { - marker = JsonSerializer.Deserialize(content); - } - catch (JsonException exception) - { - throw new MoveNeedsAttentionException( - $"The move recovery marker is corrupt or truncated: {exception.Message}"); - } - - if (marker == null) - { - throw new MoveNeedsAttentionException( - "The move recovery marker is empty or corrupt."); - } - - if (marker.Version != RecoveryMarkerVersion - || !IsKnownRecoveryStage(marker.Stage)) - { - throw new MoveNeedsAttentionException( - "The move recovery marker uses an unsupported version or stage and was preserved."); - } - - return new ParsedRecoveryMarker(marker, ObsoleteStage: null); - } - - private static void ValidateRecoveryMarker( - ParsedRecoveryMarker? parsedMarker, - AudiobookContentMoveRequest request, - string source, - string target) - { - if (parsedMarker == null) - { - return; - } - - if (parsedMarker.IsObsolete) - { - throw new MoveNeedsAttentionException( - "This move contains an obsolete pre-release recovery marker and cannot be resumed safely."); - } - - var marker = parsedMarker.StructuredMarker - ?? throw new MoveNeedsAttentionException("The move recovery marker is invalid."); - if (marker.Version != RecoveryMarkerVersion || marker.JobId != request.JobId) - { - throw new MoveNeedsAttentionException( - "Move recovery marker is owned by a different job or unsupported marker version."); - } - - try - { - if (!FileSystemPathIdentity.TryCanonicalizeUnambiguousStoredAbsolutePathForHost( - marker.Source, - out var markerSource, - out _, - request.SourceSemantics.Syntax) - || !FileSystemPathIdentity.TryCanonicalizeUnambiguousStoredAbsolutePathForHost( - marker.Target, - out var markerTarget, - out _, - request.TargetSemantics.Syntax) - || !FileSystemPathIdentity.AreEquivalent( - markerSource, - source, - request.SourceSemantics) - || !FileSystemPathIdentity.AreEquivalent( - markerTarget, - target, - request.TargetSemantics)) - { - throw new MoveNeedsAttentionException( - "Move recovery marker source or target identity does not match the persisted job."); - } - } - catch (Exception exception) when (exception is - ArgumentException or InvalidOperationException or NotSupportedException or PathTooLongException) - { - throw new MoveNeedsAttentionException( - "Move recovery marker contains an invalid source or target identity."); - } - } - - private static void ValidateRecoveryMarkerLocation( - string markerPath, - string target, - FileSystemPathSemantics targetSemantics) - { - var markerDirectory = Path.GetDirectoryName(Path.GetFullPath(markerPath)); - var reason = string.Empty; - if (string.IsNullOrWhiteSpace(markerDirectory) - || !FileSystemPathIdentity.AreEquivalent(markerDirectory, target, targetSemantics) - || !FileSystemSafety.TryValidateMutationTarget( - markerPath, - [target], - out _, - out reason)) - { - throw new MoveNeedsAttentionException( - string.IsNullOrWhiteSpace(reason) - ? "Move recovery marker is not located inside the persisted target directory." - : reason); - } - } - - private static bool CanAdvanceRecoveryStage(string currentStage, string candidateStage) - { - if (string.Equals(currentStage, candidateStage, StringComparison.Ordinal)) - { - return true; - } - - if (string.Equals(currentStage, AtomicRenameCompletedStage, StringComparison.Ordinal) - || string.Equals(candidateStage, AtomicRenameCompletedStage, StringComparison.Ordinal)) - { - return false; - } - - static int GetOrder(string stage) => stage switch - { - CopyStartedStage => 0, - CopyCompletedStage => 1, - SourceCleanupCompletedStage => 2, - _ => -1 - }; - - var currentOrder = GetOrder(currentStage); - var candidateOrder = GetOrder(candidateStage); - return currentOrder >= 0 && candidateOrder > currentOrder; - } - - private static bool IsKnownRecoveryStage(string? stage) => - stage is CopyStartedStage - or CopyCompletedStage - or AtomicRenameCompletedStage - or SourceCleanupCompletedStage; - - private static string GetRecoveryMarkerPath(string target, Guid jobId) => - Path.Join(target, $".listenarr-move-{jobId:N}.pending"); -} diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.RecoveryPublication.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.RecoveryPublication.cs deleted file mode 100644 index 56cc06ee8..000000000 --- a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.RecoveryPublication.cs +++ /dev/null @@ -1,200 +0,0 @@ -using System.Text.Json; - -namespace Listenarr.Infrastructure.Library.Moving; - -internal sealed partial class AudiobookContentMoveService -{ - private async Task WriteRecoveryMarkerAsync( - string markerDirectory, - AudiobookContentMoveRequest request, - string source, - string target, - string stage, - CancellationToken cancellationToken) - { - ValidateExistingMoveDirectory(markerDirectory, "recovery-marker directory"); - var markerPath = GetRecoveryMarkerPath(markerDirectory, request.JobId); - if (!FileSystemSafety.TryValidateMutationTarget( - markerPath, - [markerDirectory], - out markerPath, - out var markerReason)) - { - throw new MoveNeedsAttentionException(markerReason); - } - - var marker = new MoveRecoveryMarker( - RecoveryMarkerVersion, - request.JobId, - Path.GetFullPath(source), - Path.GetFullPath(target), - stage); - var payload = JsonSerializer.SerializeToUtf8Bytes(marker); - var writePath = CreateMarkerWritePath( - markerPath, - request.JobId, - request.LeaseGeneration); - var retiredExistingMarker = false; - PinnedDirectoryCreation.PinnedDirectoryAnchor? markerParent = null; - PinnedDirectoryCreation.PinnedFileEntry? writeEntry = null; - - faultInjector?.OnRecoveryMarkerWrite( - request.JobId, - RecoveryMarkerWriteFaultPoint.BeforeTemporaryFileCreation); - - try - { - await EnsureMutationAuthorizedAsync(request, source, target, cancellationToken); - ValidateNewRecoveryMarkerWritePath(writePath, markerDirectory); - markerParent = PinnedDirectoryCreation.OpenPinnedDirectoryNoFollow( - markerDirectory); - writeEntry = markerParent.CreateNewFile( - Path.GetFileName(writePath), - hiddenFile: OperatingSystem.IsWindows()); - using (var stream = writeEntry.OpenWriteStream( - bufferSize: 4096, - asynchronous: false)) - { - var split = Math.Max(1, payload.Length / 2); - stream.Write(payload.AsSpan(0, split)); - faultInjector?.OnRecoveryMarkerWrite( - request.JobId, - RecoveryMarkerWriteFaultPoint.DuringJsonWrite); - stream.Write(payload.AsSpan(split)); - faultInjector?.OnRecoveryMarkerWrite( - request.JobId, - RecoveryMarkerWriteFaultPoint.DuringFlush); - await EnsureMutationAuthorizedAsync(request, source, target, cancellationToken); - stream.Flush(flushToDisk: true); - } - - faultInjector?.OnRecoveryMarkerWrite( - request.JobId, - RecoveryMarkerWriteFaultPoint.AfterTemporaryFileWritten); - faultInjector?.OnRecoveryMarkerWrite( - request.JobId, - RecoveryMarkerWriteFaultPoint.BeforePublication); - await EnsureMutationAuthorizedAsync(request, source, target, cancellationToken); - - ValidateRecoveryMarkerPublicationPaths( - markerDirectory, - writePath, - markerPath); - var candidate = ReadRecoveryMarker(writeEntry, writePath); - ValidateRecoveryMarker(candidate, request, source, target); - if (!string.Equals(candidate.Stage, stage, StringComparison.Ordinal)) - { - throw new MoveNeedsAttentionException( - "The recovery-marker write file changed before publication."); - } - - await EnsureMutationAuthorizedAsync(request, source, target, cancellationToken); - if (File.Exists(markerPath)) - { - using (var existingEntry = markerParent.OpenExistingFile( - Path.GetFileName(markerPath), - requireDeleteAccess: true)) - { - var existing = ReadRecoveryMarker(existingEntry, markerPath); - ValidateRecoveryMarker(existing, request, source, target); - if (!CanAdvanceRecoveryStage(existing.Stage, stage)) - { - throw new MoveNeedsAttentionException( - "The existing recovery marker is already at a later or incompatible stage."); - } - if (!markerParent.VisiblePathMatches() - || !writeEntry.VisiblePathMatches() - || !existingEntry.VisiblePathMatches()) - { - throw new MoveNeedsAttentionException( - "Recovery-marker publication paths changed at the mutation boundary."); - } - - existingEntry.Delete(); - } - retiredExistingMarker = true; - } - - if (!markerParent.VisiblePathMatches() - || !writeEntry.VisiblePathMatches()) - { - throw new MoveNeedsAttentionException( - "The recovery-marker write file changed before publication."); - } - writeEntry.MoveWithinParent(Path.GetFileName(markerPath)); - } - catch (Exception exception) when (exception is MoveLeaseLostException or PersistenceException) - { - throw; - } - catch (Exception exception) when (WorkerExceptionClassifier.IsNonFatal(exception)) - { - Exception? cleanupException = null; - if (!retiredExistingMarker) - { - try - { - faultInjector?.OnRecoveryMarkerWrite( - request.JobId, - RecoveryMarkerWriteFaultPoint.BeforeTemporaryFileDeletion); - if (File.Exists(writePath)) - { - await EnsureMutationAuthorizedAsync( - request, - source, - target, - cancellationToken); - if (markerParent == null - || writeEntry == null - || !markerParent.VisiblePathMatches() - || !writeEntry.VisiblePathMatches()) - { - throw new MoveNeedsAttentionException( - "The recovery-marker write file changed before cleanup."); - } - - writeEntry.Delete(); - } - } - catch (Exception temporaryCleanupException) when (temporaryCleanupException is - MoveLeaseLostException or PersistenceException) - { - throw; - } - catch (Exception temporaryCleanupException) when (WorkerExceptionClassifier.IsNonFatal(temporaryCleanupException)) - { - cleanupException = temporaryCleanupException; - } - } - - if (exception is MoveNeedsAttentionException) - { - throw; - } - - if (cleanupException is MoveNeedsAttentionException) - { - throw new MoveNeedsAttentionException( - $"Recovery marker publication failed and recovery state became ambiguous. " - + $"Publication error: {exception.Message}. " - + $"Temporary cleanup error: {cleanupException?.Message ?? "none"}."); - } - - if (cleanupException != null) - { - throw new IOException( - $"Recovery marker publication failed and its validated recovery state could not be restored cleanly. " - + $"Publication error: {exception.Message}. " - + $"Temporary cleanup error: {cleanupException?.Message ?? "none"}.", - cleanupException); - } - - throw; - } - finally - { - writeEntry?.Dispose(); - markerParent?.Dispose(); - } - } -} diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.RecoveryWorkflow.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.RecoveryWorkflow.cs index c6b106332..ade01e234 100644 --- a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.RecoveryWorkflow.cs +++ b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.RecoveryWorkflow.cs @@ -10,58 +10,13 @@ internal sealed partial class AudiobookContentMoveService { ArgumentNullException.ThrowIfNull(request); cancellationToken.ThrowIfCancellationRequested(); - request = await WithValidatedTargetDirectoryOwnershipAsync( - request, - cancellationToken); + await EnsureLeaseOwnedAsync(request.JobId, request.LeaseToken, cancellationToken); + await EnsureCurrentExecutionProtocolAsync(request.JobId, cancellationToken); var source = NormalizeMoveDirectoryEndpoint(request.Source); var target = NormalizeMoveDirectoryEndpoint(request.Target); - var recoveryMarkerPath = GetRecoveryMarkerPath(target, request.JobId); var sourceSemantics = request.SourceSemantics; var targetSemantics = request.TargetSemantics; - - await EnsureLeaseOwnedAsync(request.JobId, request.LeaseToken, cancellationToken); - await ValidatePersistedMoveIdentityAsync( - request.JobId, - source, - target, - sourceSemantics, - targetSemantics, - request.LeaseToken, - cancellationToken); - if (await GetExecutionProtocolVersionAsync( - request.JobId, - cancellationToken) - >= MoveExecutionProtocol.MarkerlessDatabaseState) - { - return await GetMarkerlessRecoverableMoveAsync( - request, - source, - target, - cancellationToken); - } - await RecoverRecoveryMarkerWriteFilesAsync( - source, - request, - source, - target, - cancellationToken); - await RecoverRecoveryMarkerWriteFilesAsync( - target, - request, - source, - target, - cancellationToken); - - var recoveryMarker = ReadRecoveryMarker(recoveryMarkerPath); - if (recoveryMarker == null) - { - return null; - } - - ValidateRecoveryMarker(recoveryMarker, request, source, target); - ValidateRecoveryMarkerLocation(recoveryMarkerPath, target, targetSemantics); - if (IsFilesystemRoot(source, sourceSemantics) || IsFilesystemRoot(target, targetSemantics) || FileSystemPathIdentity.AreEquivalentEndpoints( @@ -71,147 +26,25 @@ await RecoverRecoveryMarkerWriteFilesAsync( targetSemantics)) { throw new MoveNeedsAttentionException( - "Move recovery artifacts reference a filesystem root or identical source and target."); - } - - if (!Directory.Exists(target)) - { - return null; - } - - if ((File.GetAttributes(target) & FileAttributes.ReparsePoint) != 0) - { - throw new MoveNeedsAttentionException( - "Move recovery target is a symbolic link or reparse point."); - } - - var manifest = await LoadManifestAsync(request.JobId, cancellationToken); - var recoveryStage = recoveryMarker.Stage; - if (string.Equals(recoveryStage, AtomicRenameCompletedStage, StringComparison.Ordinal)) - { - if (Directory.Exists(source)) - { - throw new MoveNeedsAttentionException( - "Both source and target exist for an atomic rename marker; completion cannot be proven and no files were changed."); - } - - if (manifest.Count == 0) - { - throw new MoveNeedsAttentionException( - "An atomic rename marker exists without a persisted manifest; target contents cannot be proven."); - } - - faultInjector?.OnFinalizedVerification( - request.JobId, - FinalizedVerificationFaultPoint.BeforeManifestVerification); - ValidateTargetManifest(target, manifest, targetSemantics); - ValidateExistingDestinationContents( - source, - target, - manifest, - request.JobId, - targetSemantics, - allowPartialFiles: false, - targetDirectoryOwnership: request.TargetDirectoryOwnership); - await VerifyPublishedManifestAsync( - target, - manifest, - targetSemantics, - cancellationToken); - var atomicTargetPhysicalObjectIdentities = - await CapturePublishedTargetPhysicalIdentitiesAsync( - target, - manifest, - targetSemantics, - cancellationToken); - return new AudiobookContentMoveResult( - source, - target, - TargetInsideSource: false, - SourceInsideTarget: false, - recoveryMarkerPath, - SourceCleanupCompleted: true, - atomicTargetPhysicalObjectIdentities); - } - - if (manifest.Count == 0) - { - throw new MoveNeedsAttentionException( - "A move recovery marker exists without a persisted manifest; destination ownership cannot be proven."); + "Move recovery requires distinct non-root source and target directories."); } - if (recoveryStage == CopyStartedStage) - { - return null; - } - - if (recoveryStage is not (CopyCompletedStage or SourceCleanupCompletedStage)) - { - throw new MoveNeedsAttentionException("The move recovery stage is not recoverable."); - } - - faultInjector?.OnFinalizedVerification( + await ValidatePersistedMoveIdentityAsync( request.JobId, - FinalizedVerificationFaultPoint.BeforeManifestVerification); - var tempOwnership = await TryValidatePublishedTempOwnershipAsync( - target, - request, source, target, - cancellationToken); - var quarantineOwnership = await TryValidateExistingQuarantineDirectoryAsync( - source, - target, - request.JobId, sourceSemantics, targetSemantics, request.LeaseToken, cancellationToken); - ValidateExistingDestinationContents( - source, - target, - manifest, - request.JobId, - targetSemantics, - tempOwnership, - quarantineOwnership, - allowPartialFiles: false, - targetDirectoryOwnership: request.TargetDirectoryOwnership); - await VerifyPublishedManifestAsync( - target, - manifest, - targetSemantics, + request = await WithValidatedTargetDirectoryOwnershipAsync( + request, cancellationToken); - - var targetInsideSource = IsSameOrInside(target, source, sourceSemantics); - var sourceInsideTarget = IsSameOrInside(source, target, targetSemantics); - var sourceCleanupCompleted = string.Equals( - recoveryStage, - SourceCleanupCompletedStage, - StringComparison.Ordinal); - if (sourceCleanupCompleted) - { - VerifySourceCleanupState( - request, - source, - target, - manifest); - } - - var targetPhysicalObjectIdentities = - await CapturePublishedTargetPhysicalIdentitiesAsync( - target, - manifest, - targetSemantics, - cancellationToken); - return new AudiobookContentMoveResult( + return await GetMarkerlessRecoverableMoveAsync( + request, source, target, - targetInsideSource, - sourceInsideTarget, - recoveryMarkerPath, - sourceCleanupCompleted, - targetPhysicalObjectIdentities); + cancellationToken); } public async Task ResumeSourceCleanupAsync( @@ -219,6 +52,10 @@ public async Task ResumeSourceCleanupAsync( AudiobookContentMoveResult result, CancellationToken cancellationToken) { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(result); + await EnsureLeaseOwnedAsync(request.JobId, request.LeaseToken, cancellationToken); + await EnsureCurrentExecutionProtocolAsync(request.JobId, cancellationToken); request = await WithValidatedTargetDirectoryOwnershipAsync( request, cancellationToken); @@ -227,6 +64,14 @@ public async Task ResumeSourceCleanupAsync( return result; } + await ValidatePersistedMoveIdentityAsync( + request.JobId, + result.Source, + result.Target, + request.SourceSemantics, + request.TargetSemantics, + request.LeaseToken, + cancellationToken); var manifest = await LoadManifestAsync(request.JobId, cancellationToken); if (manifest.Count == 0) { @@ -234,72 +79,26 @@ public async Task ResumeSourceCleanupAsync( "Source cleanup is blocked because no persisted move manifest is available."); } - await EnsureLeaseOwnedAsync(request.JobId, request.LeaseToken, cancellationToken); - if (await GetExecutionProtocolVersionAsync( - request.JobId, - cancellationToken) - >= MoveExecutionProtocol.MarkerlessDatabaseState) - { - await DeleteMarkerlessSourceAsync( - request, - result.Source, - result.Target, - result.TargetInsideSource, - manifest, - cancellationToken); - VerifySourceCleanupState( - request, - result.Source, - result.Target, - manifest); - var markerlessIdentities = CreatePersistedTargetPhysicalIdentityMap( - result.Target, - manifest, - request.TargetSemantics); - return result with - { - SourceCleanupCompleted = true, - RecoveryMarkerPath = string.Empty, - TargetPhysicalObjectIdentities = markerlessIdentities - }; - } - - await DeleteOriginalSourceAsync( + await DeleteMarkerlessSourceAsync( + request, result.Source, result.Target, result.TargetInsideSource, - request.DeleteEmptySource, - request.JobId, - request.LeaseToken, manifest, - request.SourceSemantics, - request.TargetSemantics, - request.TargetDirectoryOwnership, - request.SourceCleanupBoundary, - request.SourcePhysicalObjectIdentities, cancellationToken); VerifySourceCleanupState( request, result.Source, result.Target, manifest); - await WriteRecoveryMarkerAsync( + var identities = CreatePersistedTargetPhysicalIdentityMap( result.Target, - request, - result.Source, - result.Target, - SourceCleanupCompletedStage, - cancellationToken); - var targetPhysicalObjectIdentities = - await CapturePublishedTargetPhysicalIdentitiesAsync( - result.Target, - manifest, - request.TargetSemantics, - cancellationToken); + manifest, + request.TargetSemantics); return result with { SourceCleanupCompleted = true, - TargetPhysicalObjectIdentities = targetPhysicalObjectIdentities + TargetPhysicalObjectIdentities = identities }; } } diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.SourceCleanup.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.SourceCleanup.cs deleted file mode 100644 index d9cea5fdc..000000000 --- a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.SourceCleanup.cs +++ /dev/null @@ -1,481 +0,0 @@ -using Listenarr.Domain.Common; - -namespace Listenarr.Infrastructure.Library.Moving; - -internal sealed partial class AudiobookContentMoveService -{ - private async Task DeleteOriginalSourceAsync( - string source, - string target, - bool targetInsideSource, - bool deleteEmptySource, - Guid jobId, - MoveLeaseToken leaseToken, - IReadOnlyList manifest, - FileSystemPathSemantics sourceSemantics, - FileSystemPathSemantics targetSemantics, - LibraryDirectoryOwnership? targetDirectoryOwnership, - string? sourceCleanupBoundary, - IReadOnlyDictionary? sourcePhysicalObjectIdentities, - CancellationToken cancellationToken) - { - var sourceExists = Directory.Exists(source); - if (sourceExists && IsFilesystemRoot(source, sourceSemantics)) - { - throw new MoveNeedsAttentionException( - "Source path became invalid before cleanup."); - } - - var sourceParent = Path.GetDirectoryName(source) - ?? throw new MoveNeedsAttentionException( - "Source parent path is unavailable."); - var quarantineRoot = Path.Join(sourceParent, $".listenarr-quarantine-{jobId:N}"); - if (!FileSystemSafety.TryValidateMutationTarget( - quarantineRoot, - [sourceParent], - out quarantineRoot, - out var quarantineReason)) - { - throw new MoveNeedsAttentionException(quarantineReason); - } - - var cleanupRequest = new AudiobookContentMoveRequest( - source, - target, - jobId, - deleteEmptySource, - sourceSemantics, - targetSemantics, - leaseToken, - sourceCleanupBoundary, - targetDirectoryOwnership, - sourcePhysicalObjectIdentities); - var ownedSourceDirectories = await LoadOwnedSourceDirectoriesForCleanupAsync( - source, - sourceSemantics, - cancellationToken); - ValidatedQuarantineOwnership? quarantineOwnership = null; - if (Directory.Exists(quarantineRoot)) - { - quarantineOwnership = await CreateOrValidateOwnedQuarantineDirectoryAsync( - quarantineRoot, - sourceParent, - jobId, - source, - target, - sourceSemantics, - targetSemantics, - leaseToken, - cancellationToken); - } - else if (File.Exists(quarantineRoot)) - { - throw new MoveNeedsAttentionException( - "The move quarantine path is occupied by a file and cannot be used safely."); - } - - if (quarantineOwnership != null) - { - sourceExists = await RecoverEmptySourceDirectoryQuarantineAsync( - cleanupRequest, - quarantineOwnership, - sourceParent, - cancellationToken); - } - - var expectedAtSource = new List(); - foreach (var directoryEntry in manifest - .Where(entry => entry.EntryType == MoveJobEntryType.Directory) - .Where(entry => !IsRootManifestEntry(entry)) - .Where(entry => FileSystemPathIdentity.TryResolveRelativePathWithinBase( - source, - entry.RelativePath, - sourceSemantics, - out var sourceDirectory) - && Directory.Exists(sourceDirectory))) - { - expectedAtSource.Add(directoryEntry); - } - foreach (var entry in manifest.Where(entry => - entry.EntryType == MoveJobEntryType.File - && entry.CleanupState != MoveJobEntryCleanupState.Deleted)) - { - ResolveCleanupPaths( - source, - quarantineRoot, - entry.RelativePath, - sourceSemantics, - out var sourceFile, - out var quarantineFile); - if (quarantineOwnership != null) - { - ValidateQuarantineMutationPath(quarantineOwnership, quarantineFile); - } - - if (File.Exists(sourceFile)) - { - if (File.Exists(quarantineFile)) - { - throw new MoveNeedsAttentionException( - $"Both source and quarantine copies exist; cleanup is ambiguous: {entry.RelativePath}"); - } - - expectedAtSource.Add(entry); - continue; - } - - if (entry.CleanupState == MoveJobEntryCleanupState.Quarantined - && !File.Exists(quarantineFile)) - { - if (entry.CleanupProtectionVersion - >= DestinationRetentionCleanupProtectionVersion) - { - if (!await TryCompleteMissingQuarantineRetentionAsync( - cleanupRequest, - source, - target, - entry, - targetSemantics, - cancellationToken)) - { - throw new MoveNeedsAttentionException( - $"Source and quarantine files are absent without the persisted destination retention guard: {entry.RelativePath}"); - } - } - else - { - // Compatibility for active jobs persisted before destination - // retention was introduced. The source is already absent, so - // the only safe convergence available is to reverify the exact - // destination bytes before accepting the legacy transition. - await VerifyPublishedManifestAsync( - target, - [entry], - targetSemantics, - cancellationToken); - } - - await UpdateCleanupStateAsync( - jobId, - leaseToken, - entry.RelativePath, - MoveJobEntryCleanupState.Deleted, - cancellationToken); - entry.CleanupState = MoveJobEntryCleanupState.Deleted; - continue; - } - - if (!File.Exists(quarantineFile) - || !string.Equals( - await ComputeSha256Async(quarantineFile, cancellationToken), - entry.Sha256, - StringComparison.Ordinal)) - { - throw new MoveNeedsAttentionException( - $"Source file disappeared without a verified quarantine copy: {entry.RelativePath}"); - } - } - - if (sourceExists && expectedAtSource.Count > 0) - { - await ValidatePersistedSourceManifestAsync( - source, - target, - targetInsideSource, - expectedAtSource, - sourceSemantics, - cancellationToken, - requireTrackedFile: false); - } - - var publishedTempOwnership = await TryValidatePublishedTempOwnershipAsync( - target, - cleanupRequest, - source, - target, - cancellationToken); - ValidateExistingDestinationContents( - source, - target, - manifest, - jobId, - targetSemantics, - publishedTempOwnership, - quarantineOwnership, - allowPartialFiles: false, - targetDirectoryOwnership: targetDirectoryOwnership); - await VerifyPublishedManifestAsync( - target, - manifest, - targetSemantics, - cancellationToken); - quarantineOwnership ??= await CreateOrValidateOwnedQuarantineDirectoryAsync( - quarantineRoot, - sourceParent, - jobId, - source, - target, - sourceSemantics, - targetSemantics, - leaseToken, - cancellationToken); - foreach (var entry in manifest.Where(entry => - entry.EntryType == MoveJobEntryType.File - && entry.CleanupState != MoveJobEntryCleanupState.Deleted)) - { - cancellationToken.ThrowIfCancellationRequested(); - ResolveCleanupPaths( - source, - quarantineRoot, - entry.RelativePath, - sourceSemantics, - out var sourceFile, - out var quarantineFile); - ValidateQuarantineMutationPath(quarantineOwnership, quarantineFile); - - ValidateQuarantineMutationPath(quarantineOwnership, quarantineFile); - if (!File.Exists(quarantineFile)) - { - if (!File.Exists(sourceFile)) - { - if (entry.CleanupState == MoveJobEntryCleanupState.Quarantined - && await TryCompleteMissingQuarantineRetentionAsync( - cleanupRequest, - source, - target, - entry, - targetSemantics, - cancellationToken)) - { - await UpdateCleanupStateAsync( - jobId, - leaseToken, - entry.RelativePath, - MoveJobEntryCleanupState.Deleted, - cancellationToken); - continue; - } - - throw new MoveNeedsAttentionException( - $"Source file disappeared before cleanup without durable target retention: {entry.RelativePath}"); - } - - await RevalidateSourceToQuarantineMoveAsync( - source, - target, - sourceFile, - quarantineFile, - quarantineRoot, - sourceParent, - jobId, - leaseToken, - entry, - manifest, - publishedTempOwnership, - targetDirectoryOwnership, - sourceSemantics, - targetSemantics, - cancellationToken); - faultInjector?.OnSourceCleanupMutation( - jobId, - SourceCleanupFaultPoint.BeforeSourceFileMove); - quarantineOwnership = await RevalidateSourceToQuarantineMoveAsync( - source, - target, - sourceFile, - quarantineFile, - quarantineRoot, - sourceParent, - jobId, - leaseToken, - entry, - manifest, - publishedTempOwnership, - targetDirectoryOwnership, - sourceSemantics, - targetSemantics, - cancellationToken); - await MoveSourceFileToPinnedQuarantineAsync( - cleanupRequest, - source, - target, - sourceFile, - quarantineFile, - quarantineRoot, - entry, - sourceSemantics, - cancellationToken); - } - - ValidateQuarantineMutationPath(quarantineOwnership, quarantineFile); - var quarantinedHash = await ComputeSha256Async( - quarantineFile, - cancellationToken); - if (!string.Equals(quarantinedHash, entry.Sha256, StringComparison.Ordinal)) - { - throw new MoveNeedsAttentionException( - $"Quarantined source bytes changed before cleanup and were preserved: {entry.RelativePath}"); - } - - await VerifyPublishedManifestAsync( - target, - [entry], - targetSemantics, - cancellationToken); - await UpdateCleanupStateAsync( - jobId, - leaseToken, - entry.RelativePath, - MoveJobEntryCleanupState.Quarantined, - cancellationToken); - await RevalidateQuarantineDeleteAsync( - source, - target, - quarantineFile, - quarantineRoot, - sourceParent, - jobId, - leaseToken, - entry, - manifest, - publishedTempOwnership, - targetDirectoryOwnership, - sourceSemantics, - targetSemantics, - cancellationToken); - faultInjector?.OnSourceCleanupMutation( - jobId, - SourceCleanupFaultPoint.BeforeQuarantineFileDelete); - quarantineOwnership = await RevalidateQuarantineDeleteAsync( - source, - target, - quarantineFile, - quarantineRoot, - sourceParent, - jobId, - leaseToken, - entry, - manifest, - publishedTempOwnership, - targetDirectoryOwnership, - sourceSemantics, - targetSemantics, - cancellationToken); - await DeletePinnedQuarantineFileAsync( - cleanupRequest, - source, - target, - quarantineFile, - quarantineRoot, - entry, - sourceSemantics, - targetSemantics, - cancellationToken); - await UpdateCleanupStateAsync( - jobId, - leaseToken, - entry.RelativePath, - MoveJobEntryCleanupState.Deleted, - cancellationToken); - } - - foreach (var directoryEntry in manifest - .Where(entry => entry.EntryType == MoveJobEntryType.Directory) - .Where(entry => !IsRootManifestEntry(entry)) - .OrderByDescending(entry => entry.RelativePath.Length) - .Select(entry => new - { - Directory = FileSystemPathIdentity.TryResolveRelativePathWithinBase( - source, - entry.RelativePath, - sourceSemantics, - out var directory) - ? directory - : null - }) - .Where(entry => entry.Directory != null - && Directory.Exists(entry.Directory) - && !ownedSourceDirectories.Any(ownership => - FileSystemPathIdentity.AreEquivalent( - ownership.CanonicalPath, - entry.Directory, - sourceSemantics)) - && !Directory.EnumerateFileSystemEntries(entry.Directory).Any())) - { - await EnsureMutationAuthorizedAsync( - cleanupRequest, - source, - target, - cancellationToken); - DeleteValidatedEmptySourceDirectory( - source, - directoryEntry.Directory!, - sourceSemantics); - } - - await CleanupOwnedSourceDirectoriesAsync( - cleanupRequest, - source, - target, - ownedSourceDirectories, - sourceSemantics, - cancellationToken); - - if (deleteEmptySource - && sourceExists - && Directory.Exists(source) - && !ownedSourceDirectories.Any(ownership => - FileSystemPathIdentity.AreEquivalent( - ownership.CanonicalPath, - source, - sourceSemantics)) - && !IsSourceCleanupBoundary(source, sourceCleanupBoundary, sourceSemantics) - && !Directory.EnumerateFileSystemEntries(source).Any()) - { - await QuarantineAndDeleteEmptySourceDirectoryAsync( - cleanupRequest, - quarantineOwnership, - sourceParent, - cancellationToken); - } - - foreach (var directoryEntry in manifest - .Where(entry => entry.EntryType == MoveJobEntryType.Directory) - .Where(entry => !IsRootManifestEntry(entry)) - .OrderByDescending(entry => entry.RelativePath.Length)) - { - if (FileSystemPathIdentity.TryResolveRelativePathWithinBase( - quarantineRoot, - directoryEntry.RelativePath, - sourceSemantics, - out var quarantineDirectory) - && Directory.Exists(quarantineDirectory) - && !Directory.EnumerateFileSystemEntries(quarantineDirectory).Any()) - { - await EnsureMutationAuthorizedAsync( - cleanupRequest, - source, - target, - cancellationToken); - DeleteValidatedEmptyQuarantineDirectory( - quarantineOwnership, - quarantineDirectory); - } - } - - await EnsureMutationAuthorizedAsync( - cleanupRequest, - source, - target, - cancellationToken); - await DeleteEmptyOwnedQuarantineDirectoryAsync( - quarantineOwnership, - jobId, - source, - target, - sourceSemantics, - targetSemantics, - leaseToken, - cancellationToken); - } -} diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.SourceCleanupVerification.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.SourceCleanupVerification.cs index cc42200a2..076338d79 100644 --- a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.SourceCleanupVerification.cs +++ b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.SourceCleanupVerification.cs @@ -15,9 +15,6 @@ internal static bool CanAttemptFinalizedMoveVerification( return true; } - // Ordinary foreign content may remain in a shared source directory. - // The manifest-aware finalized verifier decides whether owned paths were - // cleaned; this preflight only rejects an unsafe linked source tree. return FileSystemSafety.TryEnumerateTreeWithoutLinks( source, out _, @@ -67,7 +64,6 @@ private static void VerifySourceCleanupState( throw new MoveNeedsAttentionException( $"The completed move source contains a recreated or uncleared owned file path: {entry.RelativePath}"); } - continue; } @@ -100,8 +96,7 @@ private static void VerifySourceCleanupState( .Concat(remainingDirectories) .Where(entry => !targetInsideSource || (!IsSameOrInside(entry, target, request.SourceSemantics) - && !IsSameOrInside(target, entry, request.SourceSemantics) - && !IsOwnedScaffoldMarker(entry, request, target))) + && !IsSameOrInside(target, entry, request.SourceSemantics))) .ToList(); if (ordinaryRemainingEntries.Count == 0 @@ -116,55 +111,4 @@ private static void VerifySourceCleanupState( "The completed move source directory was recreated after cleanup."); } } - - private static bool IsOwnedScaffoldMarker( - string entry, - AudiobookContentMoveRequest request, - string target) - { - if (!IsScaffoldMarkerOnTargetSpine(entry, target, request.SourceSemantics)) - { - return false; - } - - var publishedRoot = Path.GetDirectoryName(entry); - if (string.IsNullOrWhiteSpace(publishedRoot)) - { - return false; - } - - try - { - ValidateScaffoldMarker( - ReadScaffoldMarker(publishedRoot), - request.JobId, - target, - publishedRoot, - request.SourceSemantics); - return true; - } - catch (MoveNeedsAttentionException) - { - return false; - } - } - - private static bool IsScaffoldMarkerOnTargetSpine( - string entry, - string target, - FileSystemPathSemantics semantics) - { - if (!string.Equals( - Path.GetFileName(entry), - ScaffoldOwnerFileName, - StringComparison.Ordinal)) - { - return false; - } - - var directory = Path.GetDirectoryName(entry); - return !string.IsNullOrWhiteSpace(directory) - && !FileSystemPathIdentity.AreEquivalent(directory, target, semantics) - && FileSystemPathIdentity.IsSameOrInside(target, directory, semantics); - } } diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.SourceRootQuarantine.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.SourceRootQuarantine.cs deleted file mode 100644 index bf1c4d02d..000000000 --- a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.SourceRootQuarantine.cs +++ /dev/null @@ -1,317 +0,0 @@ -namespace Listenarr.Infrastructure.Library.Moving; - -internal sealed partial class AudiobookContentMoveService -{ - private const string LegacyEmptySourceQuarantineDirectoryName = - ".listenarr-empty-source"; - private const string EmptySourceQuarantineDirectoryName = - ".listenarr-empty-source.state"; - private const string EmptySourceClaimDirectoryName = "source.claim"; - - private async Task RecoverEmptySourceDirectoryQuarantineAsync( - AudiobookContentMoveRequest request, - ValidatedQuarantineOwnership ownership, - string sourceParent, - CancellationToken cancellationToken) - { - var legacyPath = Path.Join( - ownership.DirectoryPath, - LegacyEmptySourceQuarantineDirectoryName); - if (File.Exists(legacyPath) || Directory.Exists(legacyPath)) - { - throw new MoveNeedsAttentionException( - "A legacy empty-source quarantine lacks exact-object recovery evidence and was preserved."); - } - - var statePath = Path.Join( - ownership.DirectoryPath, - EmptySourceQuarantineDirectoryName); - ValidateQuarantineMutationPath(ownership, statePath); - if (File.Exists(statePath)) - { - throw new MoveNeedsAttentionException( - "The empty-source cleanup state path is occupied by a file."); - } - - var sourceExists = Directory.Exists(request.Source); - if (File.Exists(request.Source)) - { - throw new MoveNeedsAttentionException( - "The source path was recreated as a file while empty-source cleanup state exists; both were preserved."); - } - if (!Directory.Exists(statePath)) - { - return sourceExists; - } - - using var state = PinnedDirectoryCreation.OpenExistingForPublication( - ownership.DirectoryPath, - EmptySourceQuarantineDirectoryName); - using var stateAnchor = state.OpenCreatedDirectoryAnchor(); - if (!state.VisiblePathMatches() || !stateAnchor.VisiblePathMatches()) - { - throw new MoveNeedsAttentionException( - "The empty-source cleanup state changed while it was being pinned."); - } - - var entries = Directory.EnumerateFileSystemEntries(statePath).ToList(); - var claimPath = Path.Join(statePath, EmptySourceClaimDirectoryName); - if (entries.Count == 0) - { - await EnsureMutationAuthorizedAsync( - request, - request.Source, - request.Target, - cancellationToken); - if (File.Exists(request.Source) - || Directory.Exists(request.Source) != sourceExists - || !stateAnchor.VisiblePathMatches()) - { - throw new MoveNeedsAttentionException( - "The source or empty-source cleanup state changed before recovery."); - } - - DeleteEmptySourcePrivateDirectory( - request, - state, - EmptySourceQuarantineDirectoryName, - SourceCleanupFaultPoint.BeforeEmptySourceStateDelete); - return sourceExists; - } - if (sourceExists) - { - throw new MoveNeedsAttentionException( - "Both the source directory and its interrupted cleanup claim exist; both were preserved."); - } - if (entries.Count != 1 - || !string.Equals( - Path.GetFileName(entries[0]), - EmptySourceClaimDirectoryName, - StringComparison.Ordinal) - || File.Exists(claimPath) - || !Directory.Exists(claimPath)) - { - throw new MoveNeedsAttentionException( - "The empty-source cleanup state contains unexpected content."); - } - - using var claim = stateAnchor.OpenExistingChildForPublication( - EmptySourceClaimDirectoryName); - using var claimAnchor = claim.OpenCreatedDirectoryAnchor(); - if (!claim.VisiblePathMatches() || !claimAnchor.VisiblePathMatches()) - { - throw new MoveNeedsAttentionException( - "The interrupted empty-source claim changed while it was being pinned."); - } - - if (Directory.EnumerateFileSystemEntries(claimPath).Any()) - { - await RestorePinnedEmptySourceClaimAsync( - request, - sourceParent, - state, - claim, - cancellationToken); - return true; - } - - await EnsureMutationAuthorizedAsync( - request, - request.Source, - request.Target, - cancellationToken); - if (Directory.Exists(request.Source) - || Directory.EnumerateFileSystemEntries(claimPath).Any() - || !claimAnchor.VisiblePathMatches() - || !stateAnchor.VisiblePathMatches()) - { - throw new MoveNeedsAttentionException( - "The source path or empty-source claim changed before deletion."); - } - - DeleteEmptySourcePrivateDirectory( - request, - claim, - EmptySourceClaimDirectoryName, - SourceCleanupFaultPoint.BeforeEmptySourceClaimDelete); - claimAnchor.Dispose(); - claim.Dispose(); - DeleteEmptySourcePrivateDirectory( - request, - state, - EmptySourceQuarantineDirectoryName, - SourceCleanupFaultPoint.BeforeEmptySourceStateDelete); - return false; - } - - private async Task QuarantineAndDeleteEmptySourceDirectoryAsync( - AudiobookContentMoveRequest request, - ValidatedQuarantineOwnership ownership, - string sourceParent, - CancellationToken cancellationToken) - { - var statePath = Path.Join( - ownership.DirectoryPath, - EmptySourceQuarantineDirectoryName); - ValidateQuarantineMutationPath(ownership, statePath); - if (File.Exists(statePath) || Directory.Exists(statePath)) - { - throw new MoveNeedsAttentionException( - "The empty-source cleanup state path is already occupied."); - } - - if (!FileSystemSafety.TryValidateMutationTarget( - request.Source, - [sourceParent], - out var safeSource, - out var reason)) - { - throw new MoveNeedsAttentionException(reason); - } - - using var source = PinnedDirectoryCreation.OpenExistingForPublication( - sourceParent, - Path.GetFileName(safeSource)); - using var sourceAnchor = source.OpenCreatedDirectoryAnchor(); - if (Directory.EnumerateFileSystemEntries(safeSource).Any() - || !source.VisiblePathMatches() - || !sourceAnchor.VisiblePathMatches()) - { - throw new MoveNeedsAttentionException( - "The empty source directory changed before quarantine."); - } - - using var quarantineAnchor = - PinnedDirectoryCreation.OpenPinnedDirectoryNoFollow( - ownership.DirectoryPath); - using var state = quarantineAnchor.TryCreateChildForPublication( - EmptySourceQuarantineDirectoryName); - if (!state.Created || !state.VisiblePathMatches()) - { - throw new MoveNeedsAttentionException( - "The empty-source private cleanup state could not be created exclusively."); - } - state.RestrictToCurrentUser(); - - using var stateAnchor = state.OpenCreatedDirectoryAnchor(); - await EnsureMutationAuthorizedAsync( - request, - request.Source, - request.Target, - cancellationToken); - faultInjector?.OnSourceCleanupMutation( - request.JobId, - SourceCleanupFaultPoint.BeforeEmptySourceDirectoryQuarantine); - if (Directory.EnumerateFileSystemEntries(safeSource).Any() - || !sourceAnchor.VisiblePathMatches() - || !stateAnchor.VisiblePathMatches()) - { - throw new MoveNeedsAttentionException( - "The empty source directory changed during final authorization."); - } - - using var claim = source.MovePinnedDirectoryTo( - stateAnchor, - EmptySourceClaimDirectoryName); - using var claimAnchor = claim.OpenCreatedDirectoryAnchor(); - faultInjector?.OnSourceCleanupMutation( - request.JobId, - SourceCleanupFaultPoint.AfterEmptySourceDirectoryQuarantine); - if (Directory.EnumerateFileSystemEntries(claim.FullPath).Any()) - { - await RestorePinnedEmptySourceClaimAsync( - request, - sourceParent, - state, - claim, - cancellationToken); - throw new MoveNeedsAttentionException( - "The source directory gained content during quarantine and was restored."); - } - - await EnsureMutationAuthorizedAsync( - request, - request.Source, - request.Target, - cancellationToken); - if (Directory.Exists(safeSource) - || Directory.EnumerateFileSystemEntries(claim.FullPath).Any() - || !claimAnchor.VisiblePathMatches() - || !stateAnchor.VisiblePathMatches()) - { - throw new MoveNeedsAttentionException( - "The source path or empty-source claim changed before deletion."); - } - - DeleteEmptySourcePrivateDirectory( - request, - claim, - EmptySourceClaimDirectoryName, - SourceCleanupFaultPoint.BeforeEmptySourceClaimDelete); - claimAnchor.Dispose(); - claim.Dispose(); - sourceAnchor.Dispose(); - source.Dispose(); - DeleteEmptySourcePrivateDirectory( - request, - state, - EmptySourceQuarantineDirectoryName, - SourceCleanupFaultPoint.BeforeEmptySourceStateDelete); - } - - private async Task RestorePinnedEmptySourceClaimAsync( - AudiobookContentMoveRequest request, - string sourceParent, - PinnedDirectoryCreation state, - PinnedDirectoryCreation claim, - CancellationToken cancellationToken) - { - await EnsureMutationAuthorizedAsync( - request, - request.Source, - request.Target, - cancellationToken); - if (File.Exists(request.Source) || Directory.Exists(request.Source)) - { - throw new MoveNeedsAttentionException( - "The source path was recreated while its cleanup claim existed; both paths were preserved."); - } - - using var sourceParentAnchor = - PinnedDirectoryCreation.OpenPinnedDirectoryNoFollow(sourceParent); - using var restored = claim.MovePinnedDirectoryTo( - sourceParentAnchor, - Path.GetFileName(request.Source)); - if (!restored.VisiblePathMatches()) - { - throw new MoveNeedsAttentionException( - "The empty-source claim could not be restored to the source path."); - } - - DeleteEmptySourcePrivateDirectory( - request, - state, - EmptySourceQuarantineDirectoryName, - SourceCleanupFaultPoint.BeforeEmptySourceStateDelete); - } - - private void DeleteEmptySourcePrivateDirectory( - AudiobookContentMoveRequest request, - PinnedDirectoryCreation directory, - string directoryName, - SourceCleanupFaultPoint faultPoint) - { - try - { - faultInjector?.OnSourceCleanupMutation(request.JobId, faultPoint); - directory.RetirePinnedEmptyDirectoryFromNamespace( - directoryName); - } - catch (System.ComponentModel.Win32Exception exception) - { - throw new IOException( - "Verified empty-source cleanup state could not be retired; durable recovery state was preserved for retry.", - exception); - } - } -} diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.SourceValidation.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.SourceValidation.cs index 0eedcec76..f5b191046 100644 --- a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.SourceValidation.cs +++ b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.SourceValidation.cs @@ -18,11 +18,7 @@ private static IReadOnlyList ValidateSourceTreeForMove( bool targetInsideSource, FileSystemPathSemantics sourceSemantics, CancellationToken cancellationToken, - string? ownedRecoveryMarkerPath = null, - IReadOnlyCollection? ownedScaffoldPaths = null, - IReadOnlyCollection? structuralSpinePaths = null, - IReadOnlyCollection? ownedDirectoryMarkerPaths = null, - string? persistentManagedRootBoundary = null) + IReadOnlyCollection? structuralSpinePaths = null) { if (!Directory.Exists(source)) { @@ -48,11 +44,9 @@ private static IReadOnlyList ValidateSourceTreeForMove( continue; } - var isOwnedScaffold = ownedScaffoldPaths?.Any(path => - FileSystemPathIdentity.AreEquivalent(path, entry, sourceSemantics)) == true; var isStructuralSpine = structuralSpinePaths?.Any(path => FileSystemPathIdentity.AreEquivalent(path, entry, sourceSemantics)) == true; - if (isOwnedScaffold || isStructuralSpine) + if (isStructuralSpine) { if (!Directory.Exists(entry)) { @@ -63,35 +57,6 @@ private static IReadOnlyList ValidateSourceTreeForMove( continue; } - var isOwnedDirectoryMarker = ownedDirectoryMarkerPaths?.Any(path => - FileSystemPathIdentity.AreEquivalent(path, entry, sourceSemantics)) == true; - if (isOwnedDirectoryMarker) - { - if (!File.Exists(entry) - || (File.GetAttributes(entry) & FileAttributes.ReparsePoint) != 0) - { - throw new MoveNeedsAttentionException( - "A validated directory ownership marker changed type or became linked."); - } - continue; - } - - var entryName = Path.GetFileName(entry); - if (MoveFilesystemArtifactNames.IsReserved(entryName)) - { - if (!string.IsNullOrWhiteSpace(ownedRecoveryMarkerPath) - && FileSystemPathIdentity.AreEquivalent( - entry, - ownedRecoveryMarkerPath, - sourceSemantics)) - { - continue; - } - - throw new MoveNeedsAttentionException( - $"Move source contains a reserved Listenarr recovery artifact that must be resolved before moving: {Path.GetRelativePath(source, entry)}"); - } - var attributes = File.GetAttributes(entry); if ((attributes & FileAttributes.ReparsePoint) != 0) { diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.TargetPhysicalIdentity.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.TargetPhysicalIdentity.cs index ee23c650c..ab7062376 100644 --- a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.TargetPhysicalIdentity.cs +++ b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.TargetPhysicalIdentity.cs @@ -120,4 +120,38 @@ private static async Task> return identities; } + + private static (IReadOnlyList DirectorySegments, string FileName) + SplitPinnedRelativeFilePath( + string relativePath, + FileSystemPathSemantics semantics) + { + if (string.IsNullOrWhiteSpace(relativePath)) + { + throw new MoveNeedsAttentionException( + "A file manifest entry has no relative path."); + } + + var separators = semantics.Syntax == FileSystemPathSyntax.Windows + ? new[] { '\\', '/' } + : new[] { '/' }; + var lastSeparator = relativePath.LastIndexOfAny(separators); + var fileName = lastSeparator < 0 + ? relativePath + : relativePath[(lastSeparator + 1)..]; + var directoryPart = lastSeparator < 0 + ? string.Empty + : relativePath[..lastSeparator]; + var segments = directoryPart.Split( + separators, + StringSplitOptions.RemoveEmptyEntries); + if (string.IsNullOrWhiteSpace(fileName) + || segments.Any(segment => segment is "." or "..")) + { + throw new MoveNeedsAttentionException( + "A file manifest entry contains an invalid path segment."); + } + + return (segments, fileName); + } } diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.TargetScaffolding.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.TargetScaffolding.cs index a0def9808..e0213c1d7 100644 --- a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.TargetScaffolding.cs +++ b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.TargetScaffolding.cs @@ -4,223 +4,6 @@ namespace Listenarr.Infrastructure.Library.Moving; internal sealed partial class AudiobookContentMoveService { - private const string ScaffoldOwnerFileName = ".listenarr-scaffold-owner.json"; - private const int ScaffoldMarkerVersion = 1; - private const long MaximumScaffoldMarkerBytes = 64 * 1024; - - private async Task> PlanTargetScaffoldingAsync( - AudiobookContentMoveRequest request, - string source, - string target, - string targetParent, - CancellationToken cancellationToken) - { - var persisted = await GetCreatedDirectoriesAsync(request.JobId, cancellationToken); - if (persisted.Count == 0) - { - var missing = FindMissingTargetAncestors(targetParent); - await PersistCreatedDirectoriesAsync( - request.JobId, - request.LeaseToken, - missing, - cancellationToken); - persisted = await GetCreatedDirectoriesAsync(request.JobId, cancellationToken); - } - else if (persisted.All(directory => - !Directory.Exists(directory.Path) && !File.Exists(directory.Path))) - { - // Terminal cleanup can leave the durable ledger in Removed state. A manual - // retry of the same job must reacquire those absent paths before recreating - // them, otherwise a successful retry records live directories as Removed. - foreach (var directory in persisted.Where(directory => - directory.State != MoveCreatedDirectoryState.Planned)) - { - await UpdateCreatedDirectoryStateAsync( - request.JobId, - request.LeaseToken, - directory.Path, - MoveCreatedDirectoryState.Planned, - cancellationToken); - } - - persisted = await GetCreatedDirectoriesAsync(request.JobId, cancellationToken); - } - - foreach (var directory in persisted) - { - ValidateScaffoldIdentity(directory.Path, target, request.TargetSemantics); - } - - return persisted; - } - - private async Task CreateOrValidateTargetScaffoldingAsync( - AudiobookContentMoveRequest request, - string source, - string target, - IReadOnlyList scaffolding, - CancellationToken cancellationToken) - { - if (scaffolding.Count == 0) - { - return; - } - - var ordered = scaffolding - .OrderBy(directory => GetPathDepth(directory.Path)) - .ToList(); - foreach (var directory in ordered) - { - ValidateScaffoldIdentity(directory.Path, target, request.TargetSemantics); - } - - var publishedRoot = ordered[0].Path; - var parent = Path.GetDirectoryName(publishedRoot) - ?? throw new MoveNeedsAttentionException( - "The target scaffold root has no parent directory."); - ValidateExistingMoveDirectory(parent, "target scaffold parent"); - var temporaryRoot = GetTemporaryScaffoldRoot(parent, request.JobId); - if (Directory.Exists(publishedRoot)) - { - if (Directory.Exists(temporaryRoot) || File.Exists(temporaryRoot)) - { - throw new MoveNeedsAttentionException( - "Both prepared and published target scaffolding exist."); - } - - await AdoptOrValidatePublishedScaffoldingAsync( - request, - target, - publishedRoot, - ordered, - cancellationToken); - return; - } - - if (File.Exists(publishedRoot)) - { - throw new MoveNeedsAttentionException( - "The planned target scaffold root is occupied by a file."); - } - - using var preparedScaffolding = await PrepareScaffoldingAsync( - request, - source, - target, - publishedRoot, - temporaryRoot, - ordered, - cancellationToken); - await EnsureMutationAuthorizedAsync(request, source, target, cancellationToken); - if (Directory.Exists(publishedRoot) || File.Exists(publishedRoot)) - { - throw new MoveNeedsAttentionException( - "The target scaffold root appeared before Listenarr could publish its owned scaffolding."); - } - - ValidateExistingMoveDirectory(temporaryRoot, "prepared target scaffolding"); - ValidateScaffoldMarker( - ReadScaffoldMarker(temporaryRoot), - request.JobId, - target, - publishedRoot, - request.TargetSemantics); - preparedScaffolding.EnsureVisibleHierarchy(); - using var publishedAnchor = await PublishTargetScaffoldingForTestableBoundaryAsync( - request.JobId, - preparedScaffolding, - Path.GetFileName(publishedRoot), - () => EnsureMutationAuthorizedAsync( - request, - source, - target, - cancellationToken)); - if (!publishedAnchor.VisiblePathMatches(publishedRoot)) - { - throw new MoveNeedsAttentionException( - "The published target scaffolding no longer identifies the prepared directory."); - } - - ValidateExistingMoveDirectory(publishedRoot, "published target scaffolding"); - ValidatePublishedScaffoldTree( - publishedRoot, - ordered, - target, - request.TargetSemantics, - requireMarker: true); - foreach (var directory in ordered.Where(directory => - directory.State == MoveCreatedDirectoryState.Planned)) - { - await UpdateCreatedDirectoryStateAsync( - request.JobId, - request.LeaseToken, - directory.Path, - MoveCreatedDirectoryState.Created, - cancellationToken); - } - } - - private async Task AdoptOrValidatePublishedScaffoldingAsync( - AudiobookContentMoveRequest request, - string target, - string publishedRoot, - IReadOnlyList ordered, - CancellationToken cancellationToken) - { - ValidateExistingMoveDirectory(publishedRoot, "published target scaffolding"); - var marker = ReadScaffoldMarker(publishedRoot); - if (marker == null) - { - if (ordered.All(directory => directory.State == MoveCreatedDirectoryState.Retained)) - { - ValidatePublishedScaffoldTree( - publishedRoot, - ordered, - target, - request.TargetSemantics, - requireMarker: false); - return; - } - - foreach (var directory in ordered.Where(directory => - directory.State == MoveCreatedDirectoryState.Planned)) - { - await UpdateCreatedDirectoryStateAsync( - request.JobId, - request.LeaseToken, - directory.Path, - MoveCreatedDirectoryState.Retained, - cancellationToken); - } - - throw new MoveNeedsAttentionException( - "Published target scaffolding exists without its ownership marker and cannot be adopted safely."); - } - - ValidateScaffoldMarker( - marker, - request.JobId, - target, - publishedRoot, - request.TargetSemantics); - ValidatePublishedScaffoldTree( - publishedRoot, - ordered, - target, - request.TargetSemantics, - requireMarker: true); - foreach (var directory in ordered.Where(directory => - directory.State == MoveCreatedDirectoryState.Planned)) - { - await UpdateCreatedDirectoryStateAsync( - request.JobId, - request.LeaseToken, - directory.Path, - MoveCreatedDirectoryState.Created, - cancellationToken); - } - } - internal static IReadOnlyList GetTargetStructuralSpine( string source, string target, @@ -263,11 +46,16 @@ internal static void ValidateExistingTargetSpine( break; } - ValidateExistingMoveDirectory(directory, "nested target structural directory"); + ValidateExistingMoveDirectory( + directory, + "nested target structural directory"); var expectedChild = index + 1 < spine.Count ? spine[index + 1] : target; foreach (var entry in Directory.EnumerateFileSystemEntries(directory)) { - if (!FileSystemPathIdentity.AreEquivalent(entry, expectedChild, semantics)) + if (!FileSystemPathIdentity.AreEquivalent( + entry, + expectedChild, + semantics)) { throw new MoveNeedsAttentionException( "A nested target structural directory contains unexpected content unrelated to the target path."); @@ -298,139 +86,10 @@ private static IReadOnlyList FindMissingTargetAncestors(string targetPar return missing.ToList(); } - private static void ValidateScaffoldIdentity( - string scaffold, - string target, - FileSystemPathSemantics semantics) - { - try - { - if (FileSystemPathIdentity.AreEquivalent(scaffold, target, semantics) - || !FileSystemPathIdentity.IsSameOrInside(target, scaffold, semantics)) - { - throw new MoveNeedsAttentionException( - "A persisted move-created directory is not an ancestor of the target."); - } - } - catch (MoveNeedsAttentionException) - { - throw; - } - catch (Exception exception) when (exception is - ArgumentException or InvalidOperationException or NotSupportedException or PathTooLongException) - { - throw new MoveNeedsAttentionException( - "A persisted move-created directory has an invalid path identity."); - } - } - - private static void ValidatePreparedScaffoldTree( - string temporaryRoot, - string publishedRoot, - IReadOnlyList ordered, - FileSystemPathSemantics semantics) - { - for (var index = 0; index < ordered.Count; index++) - { - var finalPath = ordered[index].Path; - var actualPath = index == 0 - ? temporaryRoot - : ResolveScaffoldPath(temporaryRoot, publishedRoot, finalPath, semantics); - ValidateExistingMoveDirectory(actualPath, "prepared target scaffold directory"); - var allowedChild = index + 1 < ordered.Count - ? ResolveScaffoldPath( - temporaryRoot, - publishedRoot, - ordered[index + 1].Path, - semantics) - : null; - foreach (var entry in Directory.EnumerateFileSystemEntries(actualPath)) - { - if (index == 0 - && string.Equals( - Path.GetFileName(entry), - ScaffoldOwnerFileName, - StringComparison.Ordinal)) - { - continue; - } - - if (allowedChild == null - || !FileSystemPathIdentity.AreEquivalent(entry, allowedChild, semantics)) - { - throw new MoveNeedsAttentionException( - "Prepared target scaffolding contains unexpected content."); - } - } - } - } - - private static void ValidatePublishedScaffoldTree( - string publishedRoot, - IReadOnlyList ordered, - string target, - FileSystemPathSemantics semantics, - bool requireMarker) - { - for (var index = 0; index < ordered.Count; index++) - { - var directory = ordered[index].Path; - ValidateExistingMoveDirectory(directory, "published target scaffold directory"); - var expectedChild = index + 1 < ordered.Count ? ordered[index + 1].Path : target; - foreach (var entry in Directory.EnumerateFileSystemEntries(directory)) - { - if (index == 0 - && string.Equals( - Path.GetFileName(entry), - ScaffoldOwnerFileName, - StringComparison.Ordinal)) - { - continue; - } - - if (!FileSystemPathIdentity.AreEquivalent(entry, expectedChild, semantics)) - { - throw new MoveNeedsAttentionException( - "Published target scaffolding contains unexpected content."); - } - } - } - - var markerExists = File.Exists(Path.Join(publishedRoot, ScaffoldOwnerFileName)); - if (requireMarker != markerExists) - { - throw new MoveNeedsAttentionException( - requireMarker - ? "Published target scaffolding has no ownership marker." - : "Retained target scaffolding still has an ownership marker."); - } - } - - private static bool IsPublishedScaffoldEmpty( - string publishedRoot, - IReadOnlyList ordered, - string target, - FileSystemPathSemantics semantics) - { - if (Directory.Exists(target) || File.Exists(target)) - { - return false; - } - - try - { - ValidatePublishedScaffoldTree( - publishedRoot, - ordered, - target, - semantics, - requireMarker: true); - return true; - } - catch (MoveNeedsAttentionException) - { - return false; - } - } - + private static int GetPathDepth(string path) => + Path.GetFullPath(path) + .Split( + [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], + StringSplitOptions.RemoveEmptyEntries) + .Length; } diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.TargetScaffoldingCleanup.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.TargetScaffoldingCleanup.cs index 8184f6d69..e5e055495 100644 --- a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.TargetScaffoldingCleanup.cs +++ b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.TargetScaffoldingCleanup.cs @@ -1,444 +1,14 @@ -using Listenarr.Domain.Common; - namespace Listenarr.Infrastructure.Library.Moving; internal sealed partial class AudiobookContentMoveService { - public async Task RetainTargetScaffoldingAsync( - AudiobookContentMoveRequest request, - CancellationToken cancellationToken) - { - var scaffolding = (await GetCreatedDirectoriesAsync(request.JobId, cancellationToken)) - .OrderBy(directory => GetPathDepth(directory.Path)) - .ToList(); - if (scaffolding.Count == 0) - { - return; - } - - var publishedRoot = scaffolding[0].Path; - foreach (var directory in scaffolding.Where(directory => - directory.State is MoveCreatedDirectoryState.Created or MoveCreatedDirectoryState.Planned)) - { - await UpdateCreatedDirectoryStateAsync( - request.JobId, - request.LeaseToken, - directory.Path, - MoveCreatedDirectoryState.Retained, - cancellationToken); - directory.State = MoveCreatedDirectoryState.Retained; - } - - if (!Directory.Exists(publishedRoot)) - { - return; - } - - var marker = ReadScaffoldMarker(publishedRoot); - if (marker == null) - { - return; - } - - ValidateScaffoldMarker( - marker, - request.JobId, - request.Target, - publishedRoot, - request.TargetSemantics); - foreach (var directory in scaffolding.Where(directory => - directory.State == MoveCreatedDirectoryState.Retained - && !FileSystemPathIdentity.AreEquivalent( - directory.Path, - request.Target, - request.TargetSemantics))) - { - if (!Directory.Exists(directory.Path)) - { - throw new MoveNeedsAttentionException( - "A move-created retained directory disappeared before durable ownership could be recorded."); - } - - ValidateExistingMoveDirectory( - directory.Path, - "move-created retained directory"); - await directoryOwnershipStore.ClaimRetainedAsync( - new LibraryDirectoryOwnershipClaim( - directory.Path, - request.TargetSemantics, - "move", - request.JobId), - CancellationToken.None); - } - - await EnsureMutationAuthorizedAsync( - request, - request.Source, - request.Target, - cancellationToken); - var markerPath = Path.Join(publishedRoot, ScaffoldOwnerFileName); - if (File.Exists(markerPath)) - { - await RetirePinnedArtifactAsync( - markerPath, - entry => ValidateScaffoldMarker( - ReadScaffoldMarker(entry), - request.JobId, - request.Target, - publishedRoot, - request.TargetSemantics), - () => EnsureMutationAuthorizedAsync( - request, - request.Source, - request.Target, - cancellationToken)); - } - } - public async Task CleanupTerminalTargetScaffoldingAsync( AudiobookContentMoveRequest request, CancellationToken cancellationToken) { - if (await GetExecutionProtocolVersionAsync( - request.JobId, - cancellationToken) - >= MoveExecutionProtocol.MarkerlessDatabaseState) - { - await CleanupTerminalMarkerlessTargetDirectoriesAsync( - request, - cancellationToken); - return; - } - - var scaffolding = (await GetCreatedDirectoriesAsync(request.JobId, cancellationToken)) - .OrderBy(directory => GetPathDepth(directory.Path)) - .ToList(); - if (scaffolding.Count == 0) - { - return; - } - - var publishedRoot = scaffolding[0].Path; - var parent = Path.GetDirectoryName(publishedRoot) - ?? throw new MoveNeedsAttentionException( - "The target scaffold root has no parent directory."); - var temporaryRoot = GetTemporaryScaffoldRoot(parent, request.JobId); - var quarantine = Path.Join(parent, $".listenarr-scaffold-cleanup-{request.JobId:N}"); - await CleanupTargetScaffoldArtifactAsync( - temporaryRoot, - publishedRoot, - scaffolding, - request, - TargetScaffoldTemporaryArtifactType, - injectQuarantineDeleteFaults: false, - cancellationToken); - - var quarantineTombstonePath = GetCleanupTombstonePath( - quarantine, - TargetScaffoldQuarantineArtifactType, - request.JobId); - var hasQuarantineTombstone = HasCleanupTombstoneEvidence(quarantineTombstonePath); - var publishedExists = IsSafeExistingScaffoldDirectory( - publishedRoot, - "published target scaffold"); - var quarantineExists = IsSafeExistingScaffoldDirectory( - quarantine, - "target scaffold cleanup quarantine"); - - if (publishedExists && quarantineExists) - { - throw new MoveNeedsAttentionException( - "Both the published target scaffold and its cleanup quarantine exist."); - } - - if (quarantineExists) - { - await ResumeTargetScaffoldQuarantineAsync( - request, - scaffolding, - publishedRoot, - quarantine, - cancellationToken); - return; - } - - if (!publishedExists) - { - if (hasQuarantineTombstone) - { - await CleanupTargetScaffoldArtifactAsync( - quarantine, - publishedRoot, - scaffolding, - request, - TargetScaffoldQuarantineArtifactType, - injectQuarantineDeleteFaults: true, - cancellationToken); - } - - await MarkRemovedScaffoldingAsync( - request, - scaffolding, - publishedRoot, - quarantine, - cancellationToken); - return; - } - - var marker = ReadScaffoldMarker(publishedRoot); - if (marker == null) - { - if (scaffolding.All(directory => directory.State == MoveCreatedDirectoryState.Retained)) - { - if (hasQuarantineTombstone) - { - await CleanupTargetScaffoldArtifactAsync( - quarantine, - publishedRoot, - scaffolding, - request, - TargetScaffoldQuarantineArtifactType, - injectQuarantineDeleteFaults: true, - cancellationToken); - } - return; - } - - throw new MoveNeedsAttentionException( - "Target scaffolding cannot be cleaned because its ownership marker is missing."); - } - - ValidateScaffoldMarker( - marker, - request.JobId, - request.Target, - publishedRoot, - request.TargetSemantics); - if (!IsPublishedScaffoldEmpty( - publishedRoot, - scaffolding, - request.Target, - request.TargetSemantics)) - { - if (hasQuarantineTombstone) - { - await CleanupTargetScaffoldArtifactAsync( - quarantine, - publishedRoot, - scaffolding, - request, - TargetScaffoldQuarantineArtifactType, - injectQuarantineDeleteFaults: true, - cancellationToken); - } - - foreach (var directory in scaffolding.Where(directory => - directory.State != MoveCreatedDirectoryState.Retained)) - { - await UpdateCreatedDirectoryStateAsync( - request.JobId, - request.LeaseToken, - directory.Path, - MoveCreatedDirectoryState.Retained, - cancellationToken); - } - - await EnsureMutationAuthorizedAsync( - request, - request.Source, - request.Target, - cancellationToken); - var markerPath = Path.Join(publishedRoot, ScaffoldOwnerFileName); - if (File.Exists(markerPath)) - { - await RetirePinnedArtifactAsync( - markerPath, - entry => ValidateScaffoldMarker( - ReadScaffoldMarker(entry), - request.JobId, - request.Target, - publishedRoot, - request.TargetSemantics), - () => EnsureMutationAuthorizedAsync( - request, - request.Source, - request.Target, - cancellationToken)); - } - return; - } - - await MarkScaffoldingCleanupIntentAsync( - request, - scaffolding, - cancellationToken); - await EnsureTargetScaffoldCleanupTombstoneAsync( - quarantine, - publishedRoot, + await EnsureCurrentExecutionProtocolAsync(request.JobId, cancellationToken); + await CleanupTerminalMarkerlessTargetDirectoriesAsync( request, - TargetScaffoldQuarantineArtifactType, cancellationToken); - faultInjector?.OnTargetScaffoldCleanup( - request.JobId, - TargetScaffoldCleanupFaultPoint.BeforeQuarantineRename); - using (var publication = PinnedDirectoryCreation.OpenExistingForPublication( - parent, - Path.GetFileName(publishedRoot))) - { - using var publishedAnchor = publication.OpenCreatedDirectoryAnchor(); - await EnsureMutationAuthorizedAsync( - request, - request.Source, - request.Target, - cancellationToken); - IsSafeExistingScaffoldDirectory( - publishedRoot, - "published target scaffold"); - ValidateScaffoldMarker( - ReadScaffoldMarker(publishedRoot), - request.JobId, - request.Target, - publishedRoot, - request.TargetSemantics); - if (!IsPublishedScaffoldEmpty( - publishedRoot, - scaffolding, - request.Target, - request.TargetSemantics) - || !publishedAnchor.VisiblePathMatches()) - { - throw new MoveNeedsAttentionException( - "Published target scaffolding changed before cleanup quarantine publication."); - } - - using var quarantineAnchor = publication.RepublishPinnedDirectory( - Path.GetFileName(publishedRoot), - Path.GetFileName(quarantine)); - } - faultInjector?.OnTargetScaffoldCleanup( - request.JobId, - TargetScaffoldCleanupFaultPoint.AfterQuarantineRename); - await ResumeTargetScaffoldQuarantineAsync( - request, - scaffolding, - publishedRoot, - quarantine, - cancellationToken); - } - - private async Task ResumeTargetScaffoldQuarantineAsync( - AudiobookContentMoveRequest request, - IReadOnlyCollection scaffolding, - string publishedRoot, - string quarantine, - CancellationToken cancellationToken) - { - if (TryGetExistingPathAttributes(publishedRoot, out _)) - { - throw new MoveNeedsAttentionException( - "The published target scaffold was recreated after cleanup began."); - } - - faultInjector?.OnTargetScaffoldCleanup( - request.JobId, - TargetScaffoldCleanupFaultPoint.BeforeQuarantineValidation); - var tombstonePath = GetCleanupTombstonePath( - quarantine, - TargetScaffoldQuarantineArtifactType, - request.JobId); - ValidateTargetScaffoldArtifactTree( - quarantine, - publishedRoot, - scaffolding, - request, - requireScaffoldMarker: !HasCleanupTombstoneEvidence(tombstonePath)); - await MarkScaffoldingCleanupIntentAsync( - request, - scaffolding, - cancellationToken); - await EnsureMutationAuthorizedAsync( - request, - request.Source, - request.Target, - cancellationToken); - if (TryGetExistingPathAttributes(publishedRoot, out _)) - { - throw new MoveNeedsAttentionException( - "The published target scaffold was recreated while cleanup was being validated."); - } - - faultInjector?.OnTargetScaffoldCleanup( - request.JobId, - TargetScaffoldCleanupFaultPoint.BeforeQuarantineDelete); - await CleanupTargetScaffoldArtifactAsync( - quarantine, - publishedRoot, - scaffolding, - request, - TargetScaffoldQuarantineArtifactType, - injectQuarantineDeleteFaults: true, - cancellationToken); - faultInjector?.OnTargetScaffoldCleanup( - request.JobId, - TargetScaffoldCleanupFaultPoint.AfterQuarantineDelete); - await MarkRemovedScaffoldingAsync( - request, - scaffolding, - publishedRoot, - quarantine, - cancellationToken); - } - - private async Task MarkScaffoldingCleanupIntentAsync( - AudiobookContentMoveRequest request, - IEnumerable scaffolding, - CancellationToken cancellationToken) - { - foreach (var directory in scaffolding.Where(directory => - directory.State is MoveCreatedDirectoryState.Planned - or MoveCreatedDirectoryState.Retained)) - { - faultInjector?.OnTargetScaffoldCleanup( - request.JobId, - TargetScaffoldCleanupFaultPoint.BeforeCleanupIntentStateUpdate); - await UpdateCreatedDirectoryStateAsync( - request.JobId, - request.LeaseToken, - directory.Path, - MoveCreatedDirectoryState.Created, - cancellationToken); - directory.State = MoveCreatedDirectoryState.Created; - } - } - - private async Task MarkRemovedScaffoldingAsync( - AudiobookContentMoveRequest request, - IEnumerable scaffolding, - string publishedRoot, - string quarantine, - CancellationToken cancellationToken) - { - foreach (var directory in scaffolding.Where(directory => - directory.State is not ( - MoveCreatedDirectoryState.Removed or MoveCreatedDirectoryState.Retained))) - { - faultInjector?.OnTargetScaffoldCleanup( - request.JobId, - TargetScaffoldCleanupFaultPoint.BeforeRemovedStateUpdate); - if (TryGetExistingPathAttributes(publishedRoot, out _) - || TryGetExistingPathAttributes(quarantine, out _)) - { - throw new MoveNeedsAttentionException( - "Target scaffold cleanup state cannot be marked removed while an owned artifact still exists or the published path was recreated."); - } - - await UpdateCreatedDirectoryStateAsync( - request.JobId, - request.LeaseToken, - directory.Path, - MoveCreatedDirectoryState.Removed, - cancellationToken); - directory.State = MoveCreatedDirectoryState.Removed; - } } } diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.TargetScaffoldingFaults.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.TargetScaffoldingFaults.cs deleted file mode 100644 index 50f767c64..000000000 --- a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.TargetScaffoldingFaults.cs +++ /dev/null @@ -1,30 +0,0 @@ -namespace Listenarr.Infrastructure.Library.Moving; - -internal sealed partial class AudiobookContentMoveService -{ - private async Task - PublishTargetScaffoldingForTestableBoundaryAsync( - Guid jobId, - PreparedTargetScaffolding preparedScaffolding, - string finalName, - Func authorizeMutation) - { - faultInjector?.OnTargetScaffoldPreparation( - jobId, - TargetScaffoldPreparationFaultPoint.BeforePublication); - await authorizeMutation(); - var publishedAnchor = preparedScaffolding.PublishAs(finalName); - try - { - faultInjector?.OnTargetScaffoldPreparation( - jobId, - TargetScaffoldPreparationFaultPoint.AfterPublication); - return publishedAnchor; - } - catch - { - publishedAnchor.Dispose(); - throw; - } - } -} diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.TargetScaffoldingMarkers.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.TargetScaffoldingMarkers.cs deleted file mode 100644 index 1870450c6..000000000 --- a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.TargetScaffoldingMarkers.cs +++ /dev/null @@ -1,177 +0,0 @@ -using System.Text.Json; -using Listenarr.Domain.Common; - -namespace Listenarr.Infrastructure.Library.Moving; - -internal sealed partial class AudiobookContentMoveService -{ - private static string ResolveScaffoldPath( - string actualRoot, - string publishedRoot, - string finalPath, - FileSystemPathSemantics semantics) - { - if (!FileSystemPathIdentity.TryGetRelativePathWithinBase( - publishedRoot, - finalPath, - semantics, - out var relativePath) - || !FileSystemPathIdentity.TryResolveRelativePathWithinBase( - actualRoot, - relativePath, - semantics, - out var resolved)) - { - throw new MoveNeedsAttentionException( - "A target scaffold path escaped its publication root."); - } - - return resolved; - } - - private static string GetTemporaryScaffoldRoot(string parent, Guid jobId) => - Path.Join(parent, $".listenarr-scaffold-{jobId:N}"); - - private static void WriteScaffoldMarker( - string directory, - ScaffoldOwnershipMarker marker) - { - var markerPath = Path.Join(directory, ScaffoldOwnerFileName); - using var stream = new FileStream( - markerPath, - FileMode.CreateNew, - FileAccess.Write, - FileShare.None, - 4096, - FileOptions.WriteThrough); - JsonSerializer.Serialize(stream, marker); - stream.Flush(flushToDisk: true); - } - - private static ScaffoldOwnershipMarker? ReadScaffoldMarker(string directory) - { - if (!Directory.Exists(directory)) - { - return null; - } - - try - { - using var directoryAnchor = - PinnedDirectoryCreation.OpenPinnedDirectoryNoFollow(directory); - if (!directoryAnchor.VisiblePathMatches()) - { - throw new MoveNeedsAttentionException( - "The target scaffold directory changed while its ownership marker was being inspected."); - } - - PinnedDirectoryCreation.PinnedFileEntry markerEntry; - try - { - markerEntry = directoryAnchor.OpenExistingFileForStableRead( - ScaffoldOwnerFileName); - } - catch (System.ComponentModel.Win32Exception exception) when ( - exception.NativeErrorCode is 2 or 3) - { - return null; - } - catch (FileNotFoundException) - { - return null; - } - catch (DirectoryNotFoundException) - { - return null; - } - - using (markerEntry) - { - if (!markerEntry.VisiblePathMatches()) - { - throw new MoveNeedsAttentionException( - "The target scaffold ownership marker changed while it was being inspected."); - } - - return ReadScaffoldMarker(markerEntry); - } - } - catch (MoveNeedsAttentionException) - { - throw; - } - catch (Exception exception) when (exception is - IOException or UnauthorizedAccessException or System.ComponentModel.Win32Exception) - { - throw new MoveNeedsAttentionException( - $"The target scaffold ownership marker is unreadable: {exception.Message}"); - } - } - - private static ScaffoldOwnershipMarker ReadScaffoldMarker( - PinnedDirectoryCreation.PinnedFileEntry markerEntry) - { - try - { - using var stream = markerEntry.OpenReadStream( - bufferSize: 4096, - asynchronous: false); - if (stream.Length <= 0 || stream.Length > MaximumScaffoldMarkerBytes) - { - throw new MoveNeedsAttentionException( - "The target scaffold ownership marker has an invalid size."); - } - - stream.Position = 0; - return JsonSerializer.Deserialize(stream) - ?? throw new MoveNeedsAttentionException( - "The target scaffold ownership marker is invalid."); - } - catch (JsonException exception) - { - throw new MoveNeedsAttentionException( - $"The target scaffold ownership marker is invalid: {exception.Message}"); - } - } - - private static void ValidateScaffoldMarker( - ScaffoldOwnershipMarker? marker, - Guid jobId, - string target, - string publishedRoot, - FileSystemPathSemantics semantics) - { - if (marker == null - || marker.Version != ScaffoldMarkerVersion - || marker.JobId != jobId - || !FileSystemPathIdentity.TryCanonicalizeUnambiguousStoredAbsolutePathForHost( - marker.TargetPath, - out var markerTargetPath, - out _, - semantics.Syntax) - || !FileSystemPathIdentity.TryCanonicalizeUnambiguousStoredAbsolutePathForHost( - marker.PublishedRoot, - out var markerPublishedRoot, - out _, - semantics.Syntax) - || !FileSystemPathIdentity.AreEquivalent(markerTargetPath, target, semantics) - || !FileSystemPathIdentity.AreEquivalent(markerPublishedRoot, publishedRoot, semantics)) - { - throw new MoveNeedsAttentionException( - "The target scaffold ownership marker does not match this move job."); - } - } - - private static int GetPathDepth(string path) => - Path.GetFullPath(path) - .Split( - [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], - StringSplitOptions.RemoveEmptyEntries) - .Length; - - private sealed record ScaffoldOwnershipMarker( - int Version, - Guid JobId, - string TargetPath, - string PublishedRoot); -} diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.TargetScaffoldingPreparation.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.TargetScaffoldingPreparation.cs deleted file mode 100644 index e05a89c39..000000000 --- a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.TargetScaffoldingPreparation.cs +++ /dev/null @@ -1,260 +0,0 @@ -using System.Text.Json; -using Listenarr.Domain.Common; - -namespace Listenarr.Infrastructure.Library.Moving; - -internal sealed partial class AudiobookContentMoveService -{ - private async Task PrepareScaffoldingAsync( - AudiobookContentMoveRequest request, - string source, - string target, - string publishedRoot, - string temporaryRoot, - IReadOnlyList ordered, - CancellationToken cancellationToken) - { - if (File.Exists(temporaryRoot)) - { - throw new MoveNeedsAttentionException( - "The prepared target scaffold path is occupied by a file."); - } - - var temporaryParent = Path.GetDirectoryName(temporaryRoot) - ?? throw new MoveNeedsAttentionException( - "The prepared target scaffold root has no parent directory."); - var temporaryName = Path.GetFileName(temporaryRoot); - PinnedDirectoryCreation publication; - var createdByThisInvocation = !Directory.Exists(temporaryRoot); - if (createdByThisInvocation) - { - await EnsureMutationAuthorizedAsync(request, source, target, cancellationToken); - publication = PinnedDirectoryCreation.TryCreateForPublication( - temporaryParent, - temporaryName); - if (!publication.Created || !publication.VisiblePathMatches()) - { - publication.Dispose(); - throw new MoveNeedsAttentionException( - "The prepared target scaffold root could not be claimed exclusively."); - } - } - else - { - ValidateExistingMoveDirectory(temporaryRoot, "prepared target scaffolding"); - publication = PinnedDirectoryCreation.OpenExistingForPublication( - temporaryParent, - temporaryName); - } - - PinnedDirectoryCreation.PinnedDirectoryAnchor? rootAnchor = null; - PreparedTargetScaffolding? prepared = null; - try - { - rootAnchor = publication.OpenCreatedDirectoryAnchor(); - if (!rootAnchor.VisiblePathMatches()) - { - throw new MoveNeedsAttentionException( - "The prepared target scaffold root changed after it was pinned."); - } - - var markerPath = Path.Join(temporaryRoot, ScaffoldOwnerFileName); - if (createdByThisInvocation) - { - await EnsureMutationAuthorizedAsync(request, source, target, cancellationToken); - var marker = new ScaffoldOwnershipMarker( - ScaffoldMarkerVersion, - request.JobId, - target, - publishedRoot); - await publication.WriteInsideFileAsync( - ScaffoldOwnerFileName, - JsonSerializer.Serialize(marker), - CancellationToken.None, - hiddenFile: false); - } - else if (!File.Exists(markerPath)) - { - throw new MoveNeedsAttentionException( - "Existing prepared target scaffolding has no ownership marker and cannot be adopted safely."); - } - - if (!rootAnchor.VisiblePathMatches()) - { - throw new MoveNeedsAttentionException( - "The prepared target scaffold root changed during marker publication."); - } - ValidateScaffoldMarker( - ReadScaffoldMarker(temporaryRoot), - request.JobId, - target, - publishedRoot, - request.TargetSemantics); - if (!rootAnchor.VisiblePathMatches()) - { - throw new MoveNeedsAttentionException( - "The prepared target scaffold root changed during marker validation."); - } - - prepared = new PreparedTargetScaffolding(publication, rootAnchor); - publication = null!; - rootAnchor = null; - var currentAnchor = prepared.RootAnchor; - foreach (var directory in ordered.Skip(1)) - { - if (!FileSystemPathIdentity.TryGetRelativePathWithinBase( - publishedRoot, - directory.Path, - request.TargetSemantics, - out var relativePath) - || !FileSystemPathIdentity.TryResolveRelativePathWithinBase( - temporaryRoot, - relativePath, - request.TargetSemantics, - out var preparedPath)) - { - throw new MoveNeedsAttentionException( - "A target scaffold directory escaped the prepared scaffold root."); - } - - var preparedParent = Path.GetDirectoryName(preparedPath); - if (string.IsNullOrWhiteSpace(preparedParent) - || !FileSystemPathIdentity.AreEquivalent( - preparedParent, - currentAnchor.FullPath, - request.TargetSemantics)) - { - throw new MoveNeedsAttentionException( - "A prepared target scaffold directory is not a direct child of its pinned parent."); - } - if (File.Exists(preparedPath)) - { - throw new MoveNeedsAttentionException( - "A prepared target scaffold directory is occupied by a file."); - } - - var childName = Path.GetFileName(preparedPath); - PinnedDirectoryCreation.PinnedDirectoryAnchor childAnchor; - if (Directory.Exists(preparedPath)) - { - ValidateExistingMoveDirectory( - preparedPath, - "prepared target scaffold directory"); - childAnchor = currentAnchor.OpenExistingChild(childName); - } - else - { - await EnsureMutationAuthorizedAsync( - request, - source, - target, - cancellationToken); - using var childCreation = currentAnchor.TryCreateChild(childName); - if (!childCreation.Created || !childCreation.VisiblePathMatches()) - { - throw new MoveNeedsAttentionException( - "A prepared target scaffold child could not be claimed exclusively."); - } - - childAnchor = childCreation.OpenCreatedDirectoryAnchor(); - } - - if (!childAnchor.VisiblePathMatches()) - { - childAnchor.Dispose(); - throw new MoveNeedsAttentionException( - "A prepared target scaffold child changed after it was pinned."); - } - - prepared.AddDescendant(childAnchor); - currentAnchor = childAnchor; - } - - prepared.EnsureVisibleHierarchy(); - ValidatePreparedScaffoldTree( - temporaryRoot, - publishedRoot, - ordered, - request.TargetSemantics); - prepared.EnsureVisibleHierarchy(); - return prepared; - } - catch - { - prepared?.Dispose(); - rootAnchor?.Dispose(); - publication?.Dispose(); - throw; - } - } - - private sealed class PreparedTargetScaffolding : IDisposable - { - private readonly List _descendants = []; - private bool _disposed; - - internal PreparedTargetScaffolding( - PinnedDirectoryCreation publication, - PinnedDirectoryCreation.PinnedDirectoryAnchor rootAnchor) - { - Publication = publication; - RootAnchor = rootAnchor; - } - - internal PinnedDirectoryCreation Publication { get; } - - internal PinnedDirectoryCreation.PinnedDirectoryAnchor RootAnchor { get; } - - internal void AddDescendant( - PinnedDirectoryCreation.PinnedDirectoryAnchor descendant) => - _descendants.Add(descendant); - - internal void EnsureVisibleHierarchy() - { - ObjectDisposedException.ThrowIf(_disposed, this); - if (!Publication.VisiblePathMatches() - || !RootAnchor.VisiblePathMatches() - || _descendants.Any(anchor => !anchor.VisiblePathMatches())) - { - throw new MoveNeedsAttentionException( - "The prepared target scaffold hierarchy changed while pinned."); - } - } - - internal PinnedDirectoryCreation.PinnedDirectoryAnchor PublishAs(string finalName) - { - ObjectDisposedException.ThrowIf(_disposed, this); - EnsureVisibleHierarchy(); - ReleaseDescendantAnchors(); - if (!Publication.VisiblePathMatches() || !RootAnchor.VisiblePathMatches()) - { - throw new MoveNeedsAttentionException( - "The prepared target scaffold root changed before publication."); - } - - return Publication.PublishCreatedDirectoryAs(finalName); - } - - private void ReleaseDescendantAnchors() - { - for (var index = _descendants.Count - 1; index >= 0; index--) - { - _descendants[index].Dispose(); - } - _descendants.Clear(); - } - - public void Dispose() - { - if (_disposed) - { - return; - } - - ReleaseDescendantAnchors(); - RootAnchor.Dispose(); - Publication.Dispose(); - _disposed = true; - } - } -} diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.TargetScaffoldingTombstone.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.TargetScaffoldingTombstone.cs deleted file mode 100644 index 97ecf2445..000000000 --- a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.TargetScaffoldingTombstone.cs +++ /dev/null @@ -1,431 +0,0 @@ -using Listenarr.Domain.Common; - -namespace Listenarr.Infrastructure.Library.Moving; - -internal sealed partial class AudiobookContentMoveService -{ - private const string TargetScaffoldTemporaryArtifactType = "target-scaffold-temporary"; - private const string TargetScaffoldQuarantineArtifactType = "target-scaffold-quarantine"; - - private async Task EnsureTargetScaffoldCleanupTombstoneAsync( - string artifactRoot, - string publishedRoot, - AudiobookContentMoveRequest request, - string artifactType, - CancellationToken cancellationToken) - { - var tombstonePath = GetCleanupTombstonePath( - artifactRoot, - artifactType, - request.JobId); - var expectedTombstone = CreateTargetScaffoldCleanupTombstone( - artifactRoot, - publishedRoot, - request, - artifactType); - await EnsureCleanupTombstoneAsync( - tombstonePath, - expectedTombstone, - request.SourceSemantics, - request.TargetSemantics, - request.TargetSemantics, - request.LeaseToken, - () => EnsureMutationAuthorizedAsync( - request, - request.Source, - request.Target, - cancellationToken)); - } - - private async Task CleanupTargetScaffoldArtifactAsync( - string artifactRoot, - string publishedRoot, - IReadOnlyCollection scaffolding, - AudiobookContentMoveRequest request, - string artifactType, - bool injectQuarantineDeleteFaults, - CancellationToken cancellationToken) - { - var tombstonePath = GetCleanupTombstonePath( - artifactRoot, - artifactType, - request.JobId); - var expectedTombstone = CreateTargetScaffoldCleanupTombstone( - artifactRoot, - publishedRoot, - request, - artifactType); - var hasTombstoneEvidence = HasCleanupTombstoneEvidence(tombstonePath); - var artifactExists = IsSafeExistingScaffoldDirectory( - artifactRoot, - "target scaffold cleanup artifact"); - if (!hasTombstoneEvidence && !artifactExists) - { - return false; - } - - if (!hasTombstoneEvidence) - { - ValidateTargetScaffoldArtifactTree( - artifactRoot, - publishedRoot, - scaffolding, - request, - requireScaffoldMarker: true); - await EnsureTargetScaffoldCleanupTombstoneAsync( - artifactRoot, - publishedRoot, - request, - artifactType, - cancellationToken); - } - - await ValidateTargetScaffoldCleanupTombstoneAsync( - tombstonePath, - expectedTombstone, - request, - cancellationToken); - if (artifactExists) - { - ValidateTargetScaffoldArtifactTree( - artifactRoot, - publishedRoot, - scaffolding, - request, - requireScaffoldMarker: false); - await DeleteTargetScaffoldArtifactTreeAsync( - artifactRoot, - publishedRoot, - scaffolding, - request, - injectQuarantineDeleteFaults, - cancellationToken); - } - - await DeleteTargetScaffoldCleanupTombstoneAsync( - tombstonePath, - expectedTombstone, - request, - cancellationToken); - return true; - } - - private MoveOwnershipMarker CreateTargetScaffoldCleanupTombstone( - string artifactRoot, - string publishedRoot, - AudiobookContentMoveRequest request, - string artifactType) => - CreateOwnershipMarker( - CleanupTombstoneArtifactType, - request.JobId, - request.Source, - request.Target, - artifactRoot, - artifactType, - publishedRoot); - - private static bool IsSafeExistingScaffoldDirectory( - string path, - string description) - { - if (!TryGetExistingPathAttributes(path, out var attributes)) - { - return false; - } - - if ((attributes & FileAttributes.Directory) == 0 - || (attributes & FileAttributes.ReparsePoint) != 0) - { - throw new MoveNeedsAttentionException( - $"The {description} is a file, symbolic link, or reparse point."); - } - - return true; - } - - private static bool HasCleanupTombstoneEvidence(string markerPath) - { - var parent = Path.GetDirectoryName(Path.GetFullPath(markerPath)) - ?? throw new MoveNeedsAttentionException( - "The cleanup tombstone parent is unavailable."); - ValidateExistingMoveDirectory(parent, "cleanup tombstone directory"); - return File.Exists(markerPath) - || Directory.EnumerateFiles( - parent, - Path.GetFileName(markerPath) + ".writing-*", - SearchOption.TopDirectoryOnly).Any(); - } - - private async Task ValidateTargetScaffoldCleanupTombstoneAsync( - string tombstonePath, - MoveOwnershipMarker expectedTombstone, - AudiobookContentMoveRequest request, - CancellationToken cancellationToken) - { - await EnsureMutationAuthorizedAsync( - request, - request.Source, - request.Target, - cancellationToken); - await RecoverOrReadOwnershipMarkerAsync( - tombstonePath, - expectedTombstone, - request.SourceSemantics, - request.TargetSemantics, - request.TargetSemantics, - request.LeaseToken, - () => EnsureMutationAuthorizedAsync( - request, - request.Source, - request.Target, - cancellationToken)); - } - - private static void ValidateTargetScaffoldArtifactTree( - string artifactRoot, - string publishedRoot, - IReadOnlyCollection scaffolding, - AudiobookContentMoveRequest request, - bool requireScaffoldMarker) - { - ValidateExistingMoveDirectory(artifactRoot, "target scaffold cleanup artifact"); - var markerPath = Path.Join(artifactRoot, ScaffoldOwnerFileName); - var hasScaffoldMarker = File.Exists(markerPath); - if (hasScaffoldMarker) - { - ValidateScaffoldMarker( - ReadScaffoldMarker(artifactRoot), - request.JobId, - request.Target, - publishedRoot, - request.TargetSemantics); - } - else if (requireScaffoldMarker) - { - throw new MoveNeedsAttentionException( - "Target scaffolding cannot be cleaned because its ownership marker is missing."); - } - - if (!FileSystemSafety.TryEnumerateTreeWithoutLinks( - artifactRoot, - out var files, - out var directories, - out var reason)) - { - throw new MoveNeedsAttentionException(reason); - } - - if (!hasScaffoldMarker && directories.Count > 0) - { - throw new MoveNeedsAttentionException( - "Target scaffolding lost its ownership marker before nested cleanup completed."); - } - - var unexpectedFiles = files.Where(file => - !FileSystemPathIdentity.AreEquivalent( - file, - markerPath, - request.TargetSemantics)).ToList(); - if (unexpectedFiles.Count > 0) - { - throw new MoveNeedsAttentionException( - "Target scaffold cleanup quarantine contains unexpected file content."); - } - - var expectedDirectories = MapScaffoldDirectories( - artifactRoot, - publishedRoot, - scaffolding); - if (directories.Any(actual => - !expectedDirectories.Any(expected => - FileSystemPathIdentity.AreEquivalent( - actual, - expected, - request.TargetSemantics)))) - { - throw new MoveNeedsAttentionException( - "Target scaffold cleanup quarantine contains unexpected directory content."); - } - } - - private async Task DeleteTargetScaffoldArtifactTreeAsync( - string artifactRoot, - string publishedRoot, - IReadOnlyCollection scaffolding, - AudiobookContentMoveRequest request, - bool injectQuarantineDeleteFaults, - CancellationToken cancellationToken) - { - foreach (var directory in MapScaffoldDirectories( - artifactRoot, - publishedRoot, - scaffolding) - .OrderByDescending(GetPathDepth)) - { - if (!Directory.Exists(directory)) - { - continue; - } - - await RetirePinnedEmptyScaffoldDirectoryAsync( - directory, - () => - { - EnsurePublishedScaffoldNotRecreated( - publishedRoot, - artifactRoot, - injectQuarantineDeleteFaults); - if (Directory.EnumerateFileSystemEntries(directory).Any()) - { - throw new MoveNeedsAttentionException( - "Target scaffold cleanup directory contains unexpected content."); - } - }, - () => EnsureMutationAuthorizedAsync( - request, - request.Source, - request.Target, - cancellationToken), - () => InjectTargetScaffoldDeleteFault( - request.JobId, - injectQuarantineDeleteFaults)); - } - - await EnsureMutationAuthorizedAsync( - request, - request.Source, - request.Target, - cancellationToken); - EnsurePublishedScaffoldNotRecreated( - publishedRoot, - artifactRoot, - injectQuarantineDeleteFaults); - ValidateTargetScaffoldArtifactTree( - artifactRoot, - publishedRoot, - scaffolding, - request, - requireScaffoldMarker: false); - var markerPath = Path.Join(artifactRoot, ScaffoldOwnerFileName); - if (File.Exists(markerPath)) - { - await RetirePinnedArtifactAsync( - markerPath, - entry => ValidateScaffoldMarker( - ReadScaffoldMarker(entry), - request.JobId, - request.Target, - publishedRoot, - request.TargetSemantics), - async () => - { - await EnsureMutationAuthorizedAsync( - request, - request.Source, - request.Target, - cancellationToken); - InjectTargetScaffoldDeleteFault( - request.JobId, - injectQuarantineDeleteFaults); - }); - } - - await RetirePinnedEmptyScaffoldDirectoryAsync( - artifactRoot, - () => - { - EnsurePublishedScaffoldNotRecreated( - publishedRoot, - artifactRoot, - injectQuarantineDeleteFaults); - if (Directory.EnumerateFileSystemEntries(artifactRoot).Any()) - { - throw new MoveNeedsAttentionException( - "Target scaffold cleanup root contains unexpected content."); - } - }, - () => EnsureMutationAuthorizedAsync( - request, - request.Source, - request.Target, - cancellationToken), - () => InjectTargetScaffoldDeleteFault( - request.JobId, - injectQuarantineDeleteFaults)); - } - - private async Task DeleteTargetScaffoldCleanupTombstoneAsync( - string tombstonePath, - MoveOwnershipMarker expectedTombstone, - AudiobookContentMoveRequest request, - CancellationToken cancellationToken) - { - await ValidateTargetScaffoldCleanupTombstoneAsync( - tombstonePath, - expectedTombstone, - request, - cancellationToken); - var parent = Path.GetDirectoryName(Path.GetFullPath(tombstonePath)) - ?? throw new MoveNeedsAttentionException( - "The target scaffold cleanup tombstone parent is unavailable."); - ValidateExistingMoveDirectory(parent, "target scaffold cleanup tombstone directory"); - if (File.Exists(tombstonePath)) - { - await RetirePinnedArtifactAsync( - tombstonePath, - entry => - { - var marker = ReadOwnershipMarker(entry, tombstonePath); - ValidateOwnershipMarker( - marker, - expectedTombstone, - request.SourceSemantics, - request.TargetSemantics, - request.TargetSemantics); - }, - () => EnsureMutationAuthorizedAsync( - request, - request.Source, - request.Target, - cancellationToken)); - } - } - - private static IReadOnlyList MapScaffoldDirectories( - string artifactRoot, - string publishedRoot, - IEnumerable scaffolding) => - scaffolding - .Skip(1) - .Select(directory => Path.Join( - artifactRoot, - Path.GetRelativePath(publishedRoot, directory.Path))) - .ToList(); - - private static void EnsurePublishedScaffoldNotRecreated( - string publishedRoot, - string artifactRoot, - bool required) - { - if (required - && !string.Equals( - Path.GetFullPath(publishedRoot), - Path.GetFullPath(artifactRoot), - StringComparison.Ordinal) - && TryGetExistingPathAttributes(publishedRoot, out _)) - { - throw new MoveNeedsAttentionException( - "The published target scaffold was recreated during quarantine cleanup."); - } - } - - private void InjectTargetScaffoldDeleteFault(Guid jobId, bool enabled) - { - if (enabled) - { - faultInjector?.OnTargetScaffoldCleanup( - jobId, - TargetScaffoldCleanupFaultPoint.DuringQuarantineDelete); - } - } -} diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.TempOwnership.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.TempOwnership.cs deleted file mode 100644 index a5f63e1e7..000000000 --- a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.TempOwnership.cs +++ /dev/null @@ -1,448 +0,0 @@ -using Listenarr.Domain.Common; -using Microsoft.Extensions.Logging; - -namespace Listenarr.Infrastructure.Library.Moving; - -internal sealed partial class AudiobookContentMoveService -{ - private const string TempOwnershipMarkerFileName = ".listenarr-temp-owner.json"; - - private sealed record ValidatedTempOwnership( - string DirectoryPath, - string MarkerPath, - MoveOwnershipMarker Marker); - - private async Task CreateOrValidateOwnedTempDirectoryAsync( - string tempDirectory, - string targetParent, - AudiobookContentMoveRequest request, - string source, - string target, - CancellationToken cancellationToken) - { - var markerPath = Path.Join(tempDirectory, TempOwnershipMarkerFileName); - if (await TryCompleteOwnedDirectoryCleanupAsync( - tempDirectory, - markerPath, - TemporaryDirectoryArtifactType, - request.JobId, - source, - target, - request.SourceSemantics, - request.TargetSemantics, - request.TargetSemantics, - request.LeaseToken, - () => EnsureMutationAuthorizedAsync(request, source, target, cancellationToken))) - { - if (Directory.Exists(tempDirectory) || File.Exists(tempDirectory)) - { - throw new MoveNeedsAttentionException( - "The original move temporary path was recreated during cleanup and was preserved."); - } - - // A prior cleanup completed. A new temp directory may now be claimed. - } - else if (Directory.Exists(tempDirectory)) - { - try - { - return await ValidateOwnedTempDirectoryAsync( - tempDirectory, - targetParent, - request, - source, - target, - cancellationToken); - } - catch (InterruptedOwnershipPublicationException) - { - await RetirePinnedEmptyDirectoryAsync( - tempDirectory, - "interrupted temporary directory", - () => - { - ValidateExistingMoveDirectory( - tempDirectory, - "interrupted temporary directory"); - if (Directory.EnumerateFileSystemEntries(tempDirectory).Any()) - { - throw new MoveNeedsAttentionException( - "An interrupted temporary ownership publication left unexpected content."); - } - }, - () => EnsureMutationAuthorizedAsync( - request, - source, - target, - cancellationToken)); - } - } - - if (File.Exists(tempDirectory)) - { - throw new MoveNeedsAttentionException( - "The move temporary path is occupied by a file and cannot be claimed safely."); - } - - ValidateMoveRootPath(tempDirectory, mustExist: false, "temporary directory"); - var normalizedTargetParent = Path.GetFullPath(targetParent); - var normalizedTempDirectory = Path.GetFullPath(tempDirectory); - var tempParent = Path.GetDirectoryName(normalizedTempDirectory); - if (string.IsNullOrWhiteSpace(tempParent) - || !FileSystemPathIdentity.AreEquivalent( - normalizedTargetParent, - tempParent, - request.TargetSemantics)) - { - throw new MoveNeedsAttentionException( - "The move temporary directory escaped its validated target parent."); - } - - await EnsureMutationAuthorizedAsync(request, source, target, cancellationToken); - ValidateMoveRootPath(tempDirectory, mustExist: false, "temporary directory"); - using var tempCreation = PinnedDirectoryCreation.TryCreate( - normalizedTargetParent, - Path.GetFileName(normalizedTempDirectory)); - if (!tempCreation.Created) - { - throw new MoveNeedsAttentionException( - "The move temporary directory appeared before Listenarr could claim it exclusively."); - } - if (!tempCreation.VisiblePathMatches()) - { - throw new MoveNeedsAttentionException( - "The move temporary directory parent changed during exclusive creation."); - } - - using var tempAnchor = tempCreation.OpenCreatedDirectoryAnchor(); - if (!tempAnchor.VisiblePathMatches()) - { - throw new MoveNeedsAttentionException( - "The move temporary directory identity changed after exclusive creation."); - } - - ValidateExistingMoveDirectory(tempDirectory, "temporary directory"); - var marker = CreateOwnershipMarker( - TemporaryDirectoryArtifactType, - request.JobId, - source, - target, - tempDirectory); - try - { - await EnsureMutationAuthorizedAsync(request, source, target, cancellationToken); - await PublishOwnershipMarkerAsync( - markerPath, - marker, - OwnershipMarkerKind.TemporaryDirectory, - request.LeaseToken, - () => EnsureMutationAuthorizedAsync(request, source, target, cancellationToken), - tempAnchor); - return await ValidateOwnedTempDirectoryAsync( - tempDirectory, - targetParent, - request, - source, - target, - cancellationToken); - } - catch (Exception exception) when (exception is MoveLeaseLostException or PersistenceException) - { - throw; - } - catch (MoveNeedsAttentionException) - { - throw; - } - catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) - { - await TryRemoveNewEmptyOwnershipDirectoryAsync( - tempDirectory, - request.JobId, - "temp", - () => EnsureMutationAuthorizedAsync(request, source, target, cancellationToken)); - throw; - } - catch (Exception exception) when (WorkerExceptionClassifier.IsNonFatal(exception)) - { - await TryRemoveNewEmptyOwnershipDirectoryAsync( - tempDirectory, - request.JobId, - "temp", - () => EnsureMutationAuthorizedAsync(request, source, target, cancellationToken)); - throw new MoveNeedsAttentionException( - $"The move temporary directory could not be claimed safely: {exception.Message}"); - } - } - - private async Task ValidateOwnedTempDirectoryAsync( - string tempDirectory, - string targetParent, - AudiobookContentMoveRequest request, - string source, - string target, - CancellationToken cancellationToken) - { - if (!FileSystemSafety.TryValidateMutationTarget( - tempDirectory, - [targetParent], - out var safeTempDirectory, - out var tempReason)) - { - throw new MoveNeedsAttentionException(tempReason); - } - - ValidateExistingMoveDirectory(safeTempDirectory, "temporary directory"); - var markerPath = Path.Join(safeTempDirectory, TempOwnershipMarkerFileName); - var expectedMarker = CreateOwnershipMarker( - TemporaryDirectoryArtifactType, - request.JobId, - source, - target, - tempDirectory); - var marker = await RecoverOrReadOwnershipMarkerAsync( - markerPath, - expectedMarker, - request.SourceSemantics, - request.TargetSemantics, - request.TargetSemantics, - request.LeaseToken, - () => EnsureMutationAuthorizedAsync(request, source, target, cancellationToken)); - return new ValidatedTempOwnership( - safeTempDirectory, - markerPath, - marker); - } - - private async Task TryDeleteOwnedTempDirectoryAsync( - string tempDirectory, - string targetParent, - AudiobookContentMoveRequest request, - string source, - string target, - CancellationToken cancellationToken) - { - var markerPath = Path.Join(tempDirectory, TempOwnershipMarkerFileName); - try - { - await EnsureMutationAuthorizedAsync(request, source, target, cancellationToken); - if (await TryCompleteOwnedDirectoryCleanupAsync( - tempDirectory, - markerPath, - TemporaryDirectoryArtifactType, - request.JobId, - source, - target, - request.SourceSemantics, - request.TargetSemantics, - request.TargetSemantics, - request.LeaseToken, - () => EnsureMutationAuthorizedAsync(request, source, target, cancellationToken))) - { - return; - } - - if (!Directory.Exists(tempDirectory)) - { - return; - } - - await EnsureMutationAuthorizedAsync(request, source, target, cancellationToken); - var ownership = await ValidateOwnedTempDirectoryAsync( - tempDirectory, - targetParent, - request, - source, - target, - cancellationToken); - await DeleteOwnedDirectoryWithTombstoneAsync( - ownership.DirectoryPath, - ownership.MarkerPath, - TemporaryDirectoryArtifactType, - request.JobId, - source, - target, - request.SourceSemantics, - request.TargetSemantics, - request.TargetSemantics, - request.LeaseToken, - () => EnsureMutationAuthorizedAsync(request, source, target, cancellationToken)); - } - catch (MoveLeaseLostException) - { - throw; - } - catch (PersistenceException) - { - throw; - } - catch (MoveNeedsAttentionException exception) - { - logger.LogWarning( - exception, - "Preserved unowned or ambiguous move temp directory for job {JobId}", - request.JobId); - } - catch (Exception exception) when (WorkerExceptionClassifier.IsNonFatal(exception)) - { - logger.LogWarning( - exception, - "Failed to clean the validated move temp directory for job {JobId}", - request.JobId); - } - } - - private async Task TryValidatePublishedTempOwnershipAsync( - string destinationRoot, - AudiobookContentMoveRequest request, - string source, - string target, - CancellationToken cancellationToken) - { - var markerPath = Path.Join(destinationRoot, TempOwnershipMarkerFileName); - if (!File.Exists(markerPath)) - { - return null; - } - - var destinationParent = Path.GetDirectoryName(destinationRoot) - ?? throw new MoveNeedsAttentionException("The destination parent is unavailable."); - var originalTempDirectory = Path.Join( - destinationParent, - Path.GetFileName(target) + ".tmp-" + request.JobId.ToString("N")); - if (!FileSystemSafety.TryValidateMutationTarget( - destinationRoot, - [destinationParent], - out var safeDestination, - out var destinationReason)) - { - throw new MoveNeedsAttentionException(destinationReason); - } - - ValidateExistingMoveDirectory(safeDestination, "published temporary directory"); - var expectedMarker = CreateOwnershipMarker( - TemporaryDirectoryArtifactType, - request.JobId, - source, - target, - originalTempDirectory); - var marker = await RecoverOrReadOwnershipMarkerAsync( - markerPath, - expectedMarker, - request.SourceSemantics, - request.TargetSemantics, - request.TargetSemantics, - request.LeaseToken, - () => EnsureMutationAuthorizedAsync(request, source, target, cancellationToken)); - return new ValidatedTempOwnership( - safeDestination, - markerPath, - marker); - } - - private async Task TryDeletePublishedTempOwnershipMarkerAsync( - ValidatedTempOwnership? ownership, - AudiobookContentMoveRequest request, - string source, - string target, - CancellationToken cancellationToken) - { - if (ownership == null || !File.Exists(ownership.MarkerPath)) - { - return; - } - - ValidateExistingMoveDirectory( - ownership.DirectoryPath, - "published temporary directory"); - var marker = ReadOwnershipMarker(ownership.MarkerPath); - ValidateOwnershipMarker( - marker, - ownership.Marker, - request.SourceSemantics, - request.TargetSemantics, - request.TargetSemantics); - ValidateExistingMoveDirectory( - ownership.DirectoryPath, - "published temporary directory"); - var currentMarker = ReadOwnershipMarker(ownership.MarkerPath); - ValidateOwnershipMarker( - currentMarker, - ownership.Marker, - request.SourceSemantics, - request.TargetSemantics, - request.TargetSemantics); - await EnsureMutationAuthorizedAsync(request, source, target, cancellationToken); - ValidateExistingMoveDirectory( - ownership.DirectoryPath, - "published temporary directory"); - currentMarker = ReadOwnershipMarker(ownership.MarkerPath); - ValidateOwnershipMarker( - currentMarker, - ownership.Marker, - request.SourceSemantics, - request.TargetSemantics, - request.TargetSemantics); - await RetirePinnedArtifactAsync( - ownership.MarkerPath, - entry => - { - var pinnedMarker = ReadOwnershipMarker( - entry, - ownership.MarkerPath); - ValidateOwnershipMarker( - pinnedMarker, - ownership.Marker, - request.SourceSemantics, - request.TargetSemantics, - request.TargetSemantics); - }, - () => EnsureMutationAuthorizedAsync( - request, - source, - target, - cancellationToken)); - } - - private async Task TryRemoveNewEmptyOwnershipDirectoryAsync( - string directory, - Guid jobId, - string artifactName, - Func authorizeMutation) - { - try - { - if (Directory.Exists(directory)) - { - await RetirePinnedEmptyDirectoryAsync( - directory, - $"new {artifactName} directory", - () => - { - ValidateExistingMoveDirectory( - directory, - $"new {artifactName} directory"); - if (Directory.EnumerateFileSystemEntries(directory).Any()) - { - throw new MoveNeedsAttentionException( - $"The new {artifactName} directory changed before retirement."); - } - }, - authorizeMutation); - } - } - catch (Exception cleanupException) when (cleanupException is - MoveLeaseLostException or PersistenceException) - { - throw; - } - catch (Exception cleanupException) when (WorkerExceptionClassifier.IsNonFatal(cleanupException)) - { - logger.LogWarning( - cleanupException, - "Failed to remove newly created empty {ArtifactName} directory for move job {JobId}", - artifactName, - jobId); - } - } -} diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.TempPublication.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.TempPublication.cs deleted file mode 100644 index 05ce99083..000000000 --- a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.TempPublication.cs +++ /dev/null @@ -1,82 +0,0 @@ -namespace Listenarr.Infrastructure.Library.Moving; - -internal sealed partial class AudiobookContentMoveService -{ - private async Task PublishOwnedTempDirectoryAsync( - AudiobookContentMoveRequest request, - string source, - string target, - string tempName, - string targetParent, - CancellationToken cancellationToken) - { - using var tempPublication = PinnedDirectoryCreation.OpenExistingForPublication( - targetParent, - Path.GetFileName(tempName)); - if (!tempPublication.VisiblePathMatches()) - { - throw new MoveNeedsAttentionException( - "The move temporary directory changed before publication."); - } - - await ValidateOwnedTempDirectoryAsync( - tempName, - targetParent, - request, - source, - target, - cancellationToken); - ValidateMoveTargetRoot(target); - if (Directory.Exists(target)) - { - throw new MoveNeedsAttentionException( - "The move target appeared before temporary publication."); - } - - await EnsureMutationAuthorizedAsync(request, source, target, cancellationToken); - faultInjector?.OnTempPublication( - request.JobId, - TempPublicationFaultPoint.BeforeFinalValidation); - await ValidateOwnedTempDirectoryAsync( - tempName, - targetParent, - request, - source, - target, - cancellationToken); - ValidateMoveTargetRoot(target); - if (Directory.Exists(target)) - { - throw new MoveNeedsAttentionException( - "The move target appeared immediately before temporary publication."); - } - - faultInjector?.OnTempPublication( - request.JobId, - TempPublicationFaultPoint.BeforePublication); - await EnsureMutationAuthorizedAsync( - request, - source, - target, - cancellationToken); - if (!tempPublication.VisiblePathMatches()) - { - throw new MoveNeedsAttentionException( - "The move temporary directory or its parent changed at publication."); - } - ValidateMoveTargetRoot(target); - if (Directory.Exists(target) || File.Exists(target)) - { - throw new MoveNeedsAttentionException( - "The move target appeared at the final temporary publication boundary."); - } - - using var publishedTemp = tempPublication.PublishCreatedDirectoryAs( - Path.GetFileName(target)); - if (!publishedTemp.VisiblePathMatches(target)) - { - throw new MoveNeedsAttentionException( - "The published move target does not identify the validated temporary directory."); - } - } -} diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.Validation.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.Validation.cs index 15e719201..10ca13de0 100644 --- a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.Validation.cs +++ b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.Validation.cs @@ -32,12 +32,8 @@ private static void EnsureTargetCanReceiveContents( // the source subtree. That subtree is not a collision because it is the content being moved. var targetHasBlockingContent = Directory .EnumerateFileSystemEntries(target) - .Any(entry => !IsValidatedTargetOwnershipMarker( - entry, - targetDirectoryOwnership, - semantics) - && !(sourceInsideTarget - && IsTargetEntryAllowedBySourceSubtree(entry, source, semantics))); + .Any(entry => !(sourceInsideTarget + && IsTargetEntryAllowedBySourceSubtree(entry, source, semantics))); if (targetHasBlockingContent) { throw new MoveNeedsAttentionException(sourceInsideTarget diff --git a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.cs b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.cs index 70a19bfef..dcdf41310 100644 --- a/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.cs +++ b/listenarr.infrastructure/Library/Moving/AudiobookContentMoveService.cs @@ -39,7 +39,6 @@ internal sealed record AudiobookContentMoveResult( string Target, bool TargetInsideSource, bool SourceInsideTarget, - string RecoveryMarkerPath, bool SourceCleanupCompleted, IReadOnlyDictionary TargetPhysicalObjectIdentities, MarkerlessTargetVerificationLease? TargetVerificationLease = null); @@ -76,6 +75,7 @@ public async Task MoveContentsAsync( ArgumentNullException.ThrowIfNull(request); cancellationToken.ThrowIfCancellationRequested(); await EnsureLeaseOwnedAsync(request.JobId, request.LeaseToken, cancellationToken); + await EnsureCurrentExecutionProtocolAsync(request.JobId, cancellationToken); var source = NormalizeMoveDirectoryEndpoint(request.Source); var target = NormalizeMoveDirectoryEndpoint(request.Target); @@ -93,13 +93,9 @@ public async Task MoveContentsAsync( "Move source and target must be distinct non-root directories."); } - var executionProtocolVersion = await GetExecutionProtocolVersionAsync( - request.JobId, - cancellationToken); await ValidateMoveSourceRootForExecutionAsync( request.JobId, source, - executionProtocolVersion, cancellationToken); ValidateMoveTargetRoot(target); await ValidatePersistedMoveIdentityAsync( @@ -113,382 +109,12 @@ await ValidatePersistedMoveIdentityAsync( var targetInsideSource = IsSameOrInside(target, source, sourceSemantics); var sourceInsideTarget = IsSameOrInside(source, target, targetSemantics); - if (executionProtocolVersion >= MoveExecutionProtocol.MarkerlessDatabaseState) - { - return await MoveContentsMarkerlessAsync( - request, - source, - target, - targetInsideSource, - sourceInsideTarget, - cancellationToken); - } - - var targetParent = Path.GetDirectoryName(target); - if (string.IsNullOrEmpty(targetParent)) - { - throw new MoveNeedsAttentionException("Invalid target path"); - } - - var targetScaffolding = await PlanTargetScaffoldingAsync( + return await MoveContentsMarkerlessAsync( request, - source, - target, - targetParent, - cancellationToken); - ValidateMoveTargetRoot(target); - - await EnsureMutationAuthorizedAsync(request, source, target, cancellationToken); - await RecoverRecoveryMarkerWriteFilesAsync( - source, - request, - source, - target, - cancellationToken); - await EnsureMutationAuthorizedAsync(request, source, target, cancellationToken); - await RecoverRecoveryMarkerWriteFilesAsync( - target, - request, - source, - target, - cancellationToken); - - var sourceRecoveryMarkerPath = GetRecoveryMarkerPath(source, request.JobId); - var sourceRecoveryMarker = ReadRecoveryMarker(sourceRecoveryMarkerPath); - ValidateRecoveryMarker(sourceRecoveryMarker, request, source, target); - if (sourceRecoveryMarker != null - && !string.Equals( - sourceRecoveryMarker.Stage, - AtomicRenameCompletedStage, - StringComparison.Ordinal)) - { - throw new MoveNeedsAttentionException( - "A non-atomic recovery marker exists inside the move source and cannot be resumed safely."); - } - - var recoveryMarkerPath = GetRecoveryMarkerPath(target, request.JobId); - var recoveryMarker = ReadRecoveryMarker(recoveryMarkerPath); - ValidateRecoveryMarker(recoveryMarker, request, source, target); - if (sourceRecoveryMarker != null - && (recoveryMarker != null - || Directory.Exists(target) - || File.Exists(target))) - { - throw new MoveNeedsAttentionException( - "A source-side atomic recovery marker conflicts with existing target recovery state."); - } - - var recoveryStage = recoveryMarker?.Stage; - var persistedManifest = await LoadManifestAsync( - request.JobId, - cancellationToken); - if (persistedManifest.Count == 0) - { - throw new MoveNeedsAttentionException( - recoveryMarker != null - ? "A move recovery marker exists without a persisted tracked-file manifest; destination ownership cannot be proven." - : "The move job has no persisted tracked-file source manifest and cannot infer ownership from BasePath."); - } - - var resumingDirectCopy = recoveryStage == CopyStartedStage && persistedManifest.Count > 0; - var targetDirectoryOwnership = Directory.Exists(target) - ? await LoadValidatedTargetDirectoryOwnershipAsync( - target, - targetSemantics, - cancellationToken) - : null; - request = request with { TargetDirectoryOwnership = targetDirectoryOwnership }; - RejectUnownedPartialArtifacts( - target, - request.JobId, - recoveryMarker?.StructuredMarker != null); - EnsureTargetCanReceiveContents( - source, - target, - sourceInsideTarget, - resumingDirectCopy, - targetSemantics, - targetDirectoryOwnership); - var ownedSourceDirectories = await LoadValidatedOwnedSourceDirectoriesAsync( - source, - sourceSemantics, - cancellationToken); - var ownedSourceMarkerPaths = GetOwnedSourceMarkerPaths( - source, - ownedSourceDirectories, - sourceSemantics); - var targetStructuralSpine = GetTargetStructuralSpine( - source, - target, - sourceSemantics); - ValidateExistingTargetSpine( - targetStructuralSpine, - target, - sourceSemantics); - await ValidatePersistedSourceManifestAsync( source, target, targetInsideSource, - persistedManifest, - sourceSemantics, - cancellationToken); - // Foreign ordinary content may coexist with the tracked-file manifest, but - // links, reparse points, and stale Listenarr recovery artifacts anywhere in - // the source tree still invalidate mutation authority. - _ = ValidateSourceTreeForMove( - source, - target, - targetInsideSource, - sourceSemantics, - cancellationToken, - sourceRecoveryMarker == null ? null : sourceRecoveryMarkerPath, - targetScaffolding.Select(directory => directory.Path).ToList(), - targetStructuralSpine, - ownedSourceMarkerPaths, - request.SourceCleanupBoundary); - - var tempName = Path.Join(targetParent, Path.GetFileName(target) + ".tmp-" + request.JobId.ToString("N")); - if (!FileSystemSafety.TryValidateMutationTarget(tempName, [targetParent], out tempName, out var tempReason)) - { - logger.LogWarning("Blocked move temp path for job {JobId}: {Reason}", request.JobId, tempReason); - throw new MoveNeedsAttentionException(tempReason); - } - - var manifest = persistedManifest; - ValidateTargetManifest(target, manifest, targetSemantics); - await UpdateJobPhaseAsync( - request.JobId, - request.LeaseToken, - MoveJobPhase.Planned, - cancellationToken); - await CreateOrValidateTargetScaffoldingAsync( - request, - source, - target, - targetScaffolding, + sourceInsideTarget, cancellationToken); - - try - { - var sourceTreeIsExclusive = ownedSourceDirectories.Count == 0 - && await SourceTreeExactlyMatchesManifestAsync( - request.JobId, - source, - target, - targetInsideSource, - manifest, - sourceSemantics, - sourceRecoveryMarker == null - ? null - : sourceRecoveryMarkerPath, - targetScaffolding.Select(directory => directory.Path).ToList(), - targetStructuralSpine, - ownedSourceMarkerPaths, - cancellationToken); - var atomicResult = sourceTreeIsExclusive - ? await TryMoveByAtomicRenameAsync( - request, - source, - target, - tempName, - targetInsideSource, - sourceInsideTarget, - recoveryStage, - manifest, - sourceSemantics, - targetSemantics, - cancellationToken) - : null; - if (atomicResult != null) - { - return atomicResult; - } - - if (sourceRecoveryMarker != null) - { - await RetireSourceAtomicMarkerBeforeCopyFallbackAsync( - request, - source, - target, - sourceRecoveryMarkerPath, - cancellationToken); - } - - ValidateMoveSourceRoot(source); - ValidateMoveTargetRoot(target); - - // The move operation relocates the contents of the audiobook BasePath, not the - // BasePath directory itself. Child destinations must copy directly and skip their - // own subtree to avoid recursively copying the destination into itself. - var useTemp = !targetInsideSource && !Directory.Exists(target); - var copyDestination = useTemp ? tempName : target; - await EnsureMutationAuthorizedAsync(request, source, target, cancellationToken); - var tempOwnership = useTemp - ? await CreateOrValidateOwnedTempDirectoryAsync( - tempName, - targetParent, - request, - source, - target, - cancellationToken) - : null; - if (tempOwnership != null) - { - await RecoverRecoveryMarkerWriteFilesAsync( - copyDestination, - request, - source, - target, - cancellationToken); - } - - await EnsureCopyDestinationRootAsync( - request, - source, - target, - copyDestination, - useTemp, - targetSemantics, - cancellationToken); - if (!useTemp && !resumingDirectCopy) - { - await EnsureMutationAuthorizedAsync(request, source, target, cancellationToken); - await WriteRecoveryMarkerAsync( - copyDestination, - request, - source, - target, - CopyStartedStage, - cancellationToken); - } - - await UpdateJobPhaseAsync(request.JobId, request.LeaseToken, MoveJobPhase.Copying, cancellationToken); - await CopySourceContentsAsync( - request, - source, - target, - copyDestination, - manifest, - sourceSemantics, - targetSemantics, - tempOwnership, - directCopyOwnershipValidated: !useTemp, - cancellationToken); - - ValidateExistingDestinationContents( - source, - copyDestination, - manifest, - request.JobId, - targetSemantics, - tempOwnership, - quarantineOwnership: null, - allowPartialFiles: false, - targetDirectoryOwnership: request.TargetDirectoryOwnership); - await VerifyPublishedManifestAsync(copyDestination, manifest, targetSemantics, cancellationToken); - await UpdateCopyStateAsync(request.JobId, request.LeaseToken, cancellationToken); - - await EnsureMutationAuthorizedAsync(request, source, target, cancellationToken); - await WriteRecoveryMarkerAsync( - copyDestination, - request, - source, - target, - CopyCompletedStage, - cancellationToken); - - if (useTemp) - { - await PublishOwnedTempDirectoryAsync( - request, - source, - target, - tempName, - targetParent, - cancellationToken); - } - - await UpdateJobPhaseAsync(request.JobId, request.LeaseToken, MoveJobPhase.Published, cancellationToken); - - if (faultInjector != null) - { - await faultInjector.AfterPublishedAsync(request.JobId, cancellationToken); - } - - await UpdateJobPhaseAsync(request.JobId, request.LeaseToken, MoveJobPhase.CleaningSource, cancellationToken); - await DeleteOriginalSourceAsync( - source, - target, - targetInsideSource, - request.DeleteEmptySource, - request.JobId, - request.LeaseToken, - manifest, - sourceSemantics, - targetSemantics, - request.TargetDirectoryOwnership, - request.SourceCleanupBoundary, - request.SourcePhysicalObjectIdentities, - cancellationToken); - VerifySourceCleanupState(request, source, target, manifest); - await UpdateJobPhaseAsync(request.JobId, request.LeaseToken, MoveJobPhase.Finalizing, cancellationToken); - await EnsureMutationAuthorizedAsync(request, source, target, cancellationToken); - await WriteRecoveryMarkerAsync( - target, - request, - source, - target, - SourceCleanupCompletedStage, - cancellationToken); - var targetPhysicalObjectIdentities = - await CapturePublishedTargetPhysicalIdentitiesAsync( - target, - manifest, - targetSemantics, - cancellationToken); - - return new AudiobookContentMoveResult( - source, - target, - targetInsideSource, - sourceInsideTarget, - recoveryMarkerPath, - SourceCleanupCompleted: true, - targetPhysicalObjectIdentities); - } - catch (Exception exception) when (exception is MoveLeaseLostException or PersistenceException) - { - throw; - } - catch (MoveNeedsAttentionException) - { - await TryDeleteOwnedTempDirectoryAsync( - tempName, - targetParent, - request, - source, - target, - cancellationToken); - throw; - } - catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) - { - // The temp directory and its structured ownership marker are durable retry - // state. Preserve verified files so a transient failure resumes instead of - // restarting the entire copy. - throw; - } - catch (Exception exception) when (WorkerExceptionClassifier.IsNonFatal(exception)) - { - await TryDeleteOwnedTempDirectoryAsync( - tempName, - targetParent, - request, - source, - target, - cancellationToken); - throw; - } } - } diff --git a/listenarr.infrastructure/Library/Moving/AudiobookFilesystemDeleteService.AuthorFolder.cs b/listenarr.infrastructure/Library/Moving/AudiobookFilesystemDeleteService.AuthorFolder.cs index bd6dd6230..67f71fbcb 100644 --- a/listenarr.infrastructure/Library/Moving/AudiobookFilesystemDeleteService.AuthorFolder.cs +++ b/listenarr.infrastructure/Library/Moving/AudiobookFilesystemDeleteService.AuthorFolder.cs @@ -70,12 +70,7 @@ private async Task TryDeleteEmptyAuthorFolderAsync( { ValidateOwnedDirectoryForDelete(ownedParent); if (Directory.Exists(parentFolder) - && Directory.EnumerateFileSystemEntries(parentFolder).Any(path => - !LibraryDirectoryOwnershipMarker.GetMarkerPaths(ownedParent) - .Any(markerPath => FileSystemPathIdentity.AreEquivalent( - markerPath, - path, - semantics)))) + && Directory.EnumerateFileSystemEntries(parentFolder).Any()) { return; } diff --git a/listenarr.infrastructure/Library/Moving/AudiobookFilesystemDeleteService.Ownership.cs b/listenarr.infrastructure/Library/Moving/AudiobookFilesystemDeleteService.Ownership.cs index 7c4ce7406..d99a0c022 100644 --- a/listenarr.infrastructure/Library/Moving/AudiobookFilesystemDeleteService.Ownership.cs +++ b/listenarr.infrastructure/Library/Moving/AudiobookFilesystemDeleteService.Ownership.cs @@ -171,28 +171,11 @@ await _directoryOwnershipStore.MarkRemovedAsync( ownership.Id, ownershipKey, cancellationToken); - TryDeleteRetiredOwnershipMarker(ownership); ownership.State = LibraryDirectoryOwnershipState.Removed; ownership.PathOwnershipKey = null; return true; } - private void TryDeleteRetiredOwnershipMarker( - LibraryDirectoryOwnership ownership) - { - if (LibraryDirectoryOwnershipMarker.TryDeleteRetiredSiblingMarker( - ownership, - out var reason)) - { - return; - } - - _logger.LogWarning( - "The retired directory ownership marker for {DirectoryPath} could not be deleted: {Reason}", - LogRedaction.SanitizeFilePath(ownership.CanonicalPath), - LogRedaction.SanitizeText(reason)); - } - private async Task RecoverMissingOwnedDirectoryAsync( string? directoryPath, FileSystemPathSemantics semantics, @@ -270,17 +253,6 @@ private async Task RetireOwnedHierarchyAsync( return true; } - private static bool IsOwnershipMarkerPath( - string path, - IReadOnlyCollection ownerships, - FileSystemPathSemantics semantics) => - ownerships - .SelectMany(LibraryDirectoryOwnershipMarker.GetMarkerPaths) - .Any(markerPath => FileSystemPathIdentity.AreEquivalent( - markerPath, - path, - semantics)); - private static bool IsFilesystemRoot( string? path, FileSystemPathSemantics semantics) diff --git a/listenarr.infrastructure/Library/Moving/AudiobookFilesystemDeleteService.cs b/listenarr.infrastructure/Library/Moving/AudiobookFilesystemDeleteService.cs index b1d7391d9..db718b81d 100644 --- a/listenarr.infrastructure/Library/Moving/AudiobookFilesystemDeleteService.cs +++ b/listenarr.infrastructure/Library/Moving/AudiobookFilesystemDeleteService.cs @@ -450,9 +450,8 @@ private bool TryDeleteFolderContents( return true; } - var ownershipMarkerPaths = deleteTarget.OwnedDirectories - .SelectMany(LibraryDirectoryOwnershipMarker.GetMarkerPaths) - .ToHashSet(deleteTarget.Semantics.Comparer); + IReadOnlySet ownershipMarkerPaths = new HashSet( + deleteTarget.Semantics.Comparer); var preflightIdentities = new Dictionary( deleteTarget.Semantics.Comparer); if (!TryValidatePinnedDirectoryTree( diff --git a/listenarr.infrastructure/Library/Moving/EfLibraryDirectoryOwnershipStore.Helpers.cs b/listenarr.infrastructure/Library/Moving/EfLibraryDirectoryOwnershipStore.Helpers.cs index c33a76674..d20137a07 100644 --- a/listenarr.infrastructure/Library/Moving/EfLibraryDirectoryOwnershipStore.Helpers.cs +++ b/listenarr.infrastructure/Library/Moving/EfLibraryDirectoryOwnershipStore.Helpers.cs @@ -1,4 +1,5 @@ using Listenarr.Domain.Common; +using Microsoft.EntityFrameworkCore; namespace Listenarr.Infrastructure.Library.Moving; @@ -23,28 +24,41 @@ private static void ValidatePinnedOwnership( } } - private static void CleanupRetiredSiblingMarkers( - IEnumerable retiredCandidates, - string canonicalPath, - FileSystemPathSemantics semantics) + private async Task RevalidateCommittedOwnershipAsync( + LibraryDirectoryOwnership ownership, + PinnedDirectoryCreation creation, + CancellationToken cancellationToken) { - foreach (var retired in retiredCandidates) + try { - try - { - if (Compare(retired, canonicalPath, semantics) == OwnershipComparison.Compatible) - { - LibraryDirectoryOwnershipMarker.TryDeleteRetiredSiblingMarker( - retired, - out _); - } - } - catch (Exception exception) when (exception is - ArgumentException or InvalidOperationException or NotSupportedException or PathTooLongException) + AfterOwnershipCommitForTest?.Invoke(); + ValidatePinnedOwnership(ownership, creation); + } + catch (Exception exception) when (exception is not ( + OutOfMemoryException or StackOverflowException)) + { + await using var repairDb = + await dbContextFactory.CreateDbContextAsync(CancellationToken.None); + var persisted = await repairDb.LibraryDirectoryOwnerships + .SingleOrDefaultAsync( + candidate => candidate.Id == ownership.Id, + CancellationToken.None); + if (persisted != null + && persisted.State != LibraryDirectoryOwnershipState.Removed) { - // Removed rows are nonauthoritative. Corrupt retired metadata must not - // prevent a new, independently proven ownership claim for the live path. + var reason = + $"The committed ownership path changed physical generation before publication completed: {exception.Message}"; + persisted.State = LibraryDirectoryOwnershipState.Unavailable; + persisted.PathOwnershipKey = null; + persisted.StateReason = reason; + persisted.DirectoryObjectIdentityUnavailableReason = reason; + persisted.UpdatedAt = timeProvider.GetUtcNow().UtcDateTime; + await repairDb.SaveChangesAsync(CancellationToken.None); } + + throw new InvalidOperationException( + "The directory ownership claim committed, but its physical generation changed before publication completed.", + exception); } } diff --git a/listenarr.infrastructure/Library/Moving/EfLibraryDirectoryOwnershipStore.MarkerlessReplacement.cs b/listenarr.infrastructure/Library/Moving/EfLibraryDirectoryOwnershipStore.MarkerlessReplacement.cs index 035e0c21a..10d64b0d6 100644 --- a/listenarr.infrastructure/Library/Moving/EfLibraryDirectoryOwnershipStore.MarkerlessReplacement.cs +++ b/listenarr.infrastructure/Library/Moving/EfLibraryDirectoryOwnershipStore.MarkerlessReplacement.cs @@ -5,6 +5,12 @@ namespace Listenarr.Infrastructure.Library.Moving; internal sealed partial class EfLibraryDirectoryOwnershipStore { + internal Action? AfterMarkerlessReplacementCommitForTest + { + get; + set; + } + public async Task TryRetireReplacedByMarkerlessMoveAsync( string path, FileSystemPathSemantics semantics, @@ -69,6 +75,7 @@ or LibraryDirectoryOwnershipState.Retained } var stale = compatible[0]; + var originalManagedRootFolderId = stale.ManagedRootFolderId; if (string.IsNullOrWhiteSpace(stale.PathOwnershipKey)) { throw new InvalidOperationException( @@ -80,7 +87,7 @@ or LibraryDirectoryOwnershipState.Retained .Include(job => job.CreatedDirectories) .SingleOrDefaultAsync(job => job.Id == moveJobId, cancellationToken); if (move == null - || move.ExecutionProtocolVersion < MoveExecutionProtocol.MarkerlessDatabaseState + || !MoveExecutionProtocol.IsCurrent(move.ExecutionProtocolVersion) || string.IsNullOrWhiteSpace(move.RequestedPath) || !FileSystemPathIdentity.AreEquivalent( canonicalPath, @@ -146,34 +153,6 @@ or LibraryDirectoryOwnershipState.Retained ? await db.Database.BeginTransactionAsync(cancellationToken) : null; var now = timeProvider.GetUtcNow().UtcDateTime; - if (!await db.LibraryDirectoryOwnershipRetiredMarkers.AnyAsync( - marker => marker.OwnershipId == stale.Id, - cancellationToken)) - { - if (stale.ManagedRootFolderId.HasValue - && stale.DirectoryObjectIdentityVersion.HasValue - && !string.IsNullOrWhiteSpace(stale.DirectoryObjectIdentity)) - { - db.LibraryDirectoryOwnershipRetiredMarkers.Add( - LibraryDirectoryOwnershipRetiredMarkerEvidence.Create( - stale, - new LibraryDirectoryOwnershipMarker.MarkerPayload( - LibraryDirectoryOwnershipMarker.Version, - stale.OwnershipToken, - stale.CanonicalPath, - stale.ManagedRootFolderId, - stale.DirectoryObjectIdentityVersion, - stale.DirectoryObjectIdentity), - now)); - } - else - { - db.LibraryDirectoryOwnershipRetiredMarkers.Add( - LibraryDirectoryOwnershipRetiredMarkerEvidence - .CreateLegacyPending(stale)); - } - } - stale.State = LibraryDirectoryOwnershipState.Removed; stale.PathOwnershipKey = null; stale.ManagedRootFolderId = null; @@ -186,6 +165,37 @@ or LibraryDirectoryOwnershipState.Retained await transaction.CommitAsync(CancellationToken.None); } + AfterMarkerlessReplacementCommitForTest?.Invoke(); + if (!string.Equals( + liveDirectory.GetDirectoryObjectIdentity(), + replacementDirectoryObjectIdentity, + StringComparison.Ordinal) + || !liveDirectory.VisiblePathMatches() + || !authorization.ParentAnchor.VisiblePathMatches()) + { + var reason = + "The markerless replacement directory changed physical generation immediately after stale ownership retirement committed."; + await using var repairDb = await dbContextFactory.CreateDbContextAsync( + CancellationToken.None); + var persisted = await repairDb.LibraryDirectoryOwnerships + .SingleOrDefaultAsync( + candidate => candidate.Id == stale.Id, + CancellationToken.None); + if (persisted != null + && persisted.State == LibraryDirectoryOwnershipState.Removed) + { + persisted.State = LibraryDirectoryOwnershipState.Unavailable; + persisted.PathOwnershipKey = null; + persisted.ManagedRootFolderId = originalManagedRootFolderId; + persisted.StateReason = reason; + persisted.DirectoryObjectIdentityUnavailableReason = reason; + persisted.UpdatedAt = timeProvider.GetUtcNow().UtcDateTime; + await repairDb.SaveChangesAsync(CancellationToken.None); + } + + throw new InvalidOperationException(reason); + } + return true; } } diff --git a/listenarr.infrastructure/Library/Moving/EfLibraryDirectoryOwnershipStore.Resolution.cs b/listenarr.infrastructure/Library/Moving/EfLibraryDirectoryOwnershipStore.Resolution.cs index 336a524b3..954df0a91 100644 --- a/listenarr.infrastructure/Library/Moving/EfLibraryDirectoryOwnershipStore.Resolution.cs +++ b/listenarr.infrastructure/Library/Moving/EfLibraryDirectoryOwnershipStore.Resolution.cs @@ -61,7 +61,11 @@ private async Task ResolveOwnedCoreAsync( try { var comparison = Compare(candidate, canonicalPath, semantics); - if (comparison == OwnershipComparison.Compatible + if (candidate.State == LibraryDirectoryOwnershipState.Unavailable) + { + hasUnavailable = true; + } + else if (comparison == OwnershipComparison.Compatible && candidate.State is LibraryDirectoryOwnershipState.Owned or LibraryDirectoryOwnershipState.Retained or LibraryDirectoryOwnershipState.Removing) @@ -73,10 +77,6 @@ or LibraryDirectoryOwnershipState.Retained { hasConflict = true; } - else if (candidate.State == LibraryDirectoryOwnershipState.Unavailable) - { - hasUnavailable = true; - } } catch (Exception exception) when (exception is ArgumentException or InvalidOperationException) { @@ -149,11 +149,6 @@ await _boundaryAuthorizer.AuthorizeOwnershipAsync( throw new InvalidOperationException( "The owned directory changed after its physical identity was pinned."); } - _ = LibraryDirectoryOwnershipMarker.TryRetireMatchingMarkers( - resolved, - live, - authorization.ParentAnchor, - out _); } catch (Exception exception) when (exception is ArgumentException or IOException or UnauthorizedAccessException @@ -166,8 +161,8 @@ or InvalidOperationException or NotSupportedException } } - // Removing has separate restart semantics: its inside marker may already - // have been retired after the durable state transition. + // Removing has separate restart semantics: the durable state transition can + // outlive the final namespace deletion. return new LibraryDirectoryOwnershipResolution( LibraryDirectoryOwnershipResolutionState.Owned, resolved); @@ -253,11 +248,6 @@ await _boundaryAuthorizer.AuthorizeOwnershipAsync( throw new InvalidOperationException( "A durable ownership claim no longer matches its persisted physical directory generation."); } - _ = LibraryDirectoryOwnershipMarker.TryRetireMatchingMarkers( - candidate, - live, - authorization.ParentAnchor, - out _); } owned.Add(candidate); diff --git a/listenarr.infrastructure/Library/Moving/EfLibraryDirectoryOwnershipStore.State.cs b/listenarr.infrastructure/Library/Moving/EfLibraryDirectoryOwnershipStore.State.cs index 808149a4c..c2cc768e9 100644 --- a/listenarr.infrastructure/Library/Moving/EfLibraryDirectoryOwnershipStore.State.cs +++ b/listenarr.infrastructure/Library/Moving/EfLibraryDirectoryOwnershipStore.State.cs @@ -59,17 +59,6 @@ public async Task MarkRemovedAsync( } var now = timeProvider.GetUtcNow().UtcDateTime; - db.LibraryDirectoryOwnershipRetiredMarkers.Add( - LibraryDirectoryOwnershipRetiredMarkerEvidence.Create( - ownership, - new LibraryDirectoryOwnershipMarker.MarkerPayload( - LibraryDirectoryOwnershipMarker.Version, - ownership.OwnershipToken, - ownership.CanonicalPath, - ownership.ManagedRootFolderId, - ownership.DirectoryObjectIdentityVersion, - ownership.DirectoryObjectIdentity), - now)); ownership.State = LibraryDirectoryOwnershipState.Removed; ownership.PathOwnershipKey = null; ownership.ManagedRootFolderId = null; @@ -120,128 +109,3 @@ private async Task UpdateStateAsync( await db.SaveChangesAsync(cancellationToken); } } - -internal static class LibraryDirectoryOwnershipRetiredMarkerEvidence -{ - public static LibraryDirectoryOwnershipRetiredMarker CreateLegacyPending( - LibraryDirectoryOwnership ownership, - int? originalManagedRootFolderId = null) - { - ArgumentNullException.ThrowIfNull(ownership); - var managedRootFolderId = - originalManagedRootFolderId ?? ownership.ManagedRootFolderId; - var payloadVersion = managedRootFolderId.HasValue - && ownership.DirectoryObjectIdentityVersion.HasValue - && !string.IsNullOrWhiteSpace(ownership.DirectoryObjectIdentity) - ? LibraryDirectoryOwnershipMarker.Version - : 1; - return new LibraryDirectoryOwnershipRetiredMarker - { - OwnershipId = ownership.Id, - OwnershipToken = ownership.OwnershipToken, - CanonicalMarkerPath = null, - CanonicalOwnershipPath = ownership.CanonicalPath, - PathSyntax = ownership.PathSyntax, - PathCaseSensitivity = ownership.PathCaseSensitivity, - PathCaseSensitivityMode = ownership.PathCaseSensitivityMode, - PathIdentityBoundary = ownership.PathIdentityBoundary, - CanonicalPayload = null, - PayloadSha256 = null, - PayloadVersion = payloadVersion, - OriginalManagedRootFolderId = managedRootFolderId, - DirectoryObjectIdentityVersion = - ownership.DirectoryObjectIdentityVersion, - DirectoryObjectIdentity = ownership.DirectoryObjectIdentity, - State = LibraryDirectoryOwnershipRetiredMarkerState.Pending, - CreatedAt = ownership.CreatedAt, - UpdatedAt = ownership.UpdatedAt - }; - } - - public static LibraryDirectoryOwnershipRetiredMarker Create( - LibraryDirectoryOwnership ownership, - LibraryDirectoryOwnershipMarker.MarkerPayload payload, - DateTime now) - { - var canonicalPayload = - LibraryDirectoryOwnershipMarker.SerializePayload(payload); - var payloadSha256 = Convert.ToHexString( - System.Security.Cryptography.SHA256.HashData( - System.Text.Encoding.UTF8.GetBytes(canonicalPayload))); - return new LibraryDirectoryOwnershipRetiredMarker - { - OwnershipId = ownership.Id, - OwnershipToken = payload.OwnershipToken, - CanonicalMarkerPath = - LibraryDirectoryOwnershipMarker.GetMarkerPaths(ownership)[1], - CanonicalOwnershipPath = ownership.CanonicalPath, - PathSyntax = ownership.PathSyntax, - PathCaseSensitivity = ownership.PathCaseSensitivity, - PathCaseSensitivityMode = ownership.PathCaseSensitivityMode, - PathIdentityBoundary = ownership.PathIdentityBoundary, - CanonicalPayload = canonicalPayload, - PayloadSha256 = payloadSha256, - PayloadVersion = payload.Version, - OriginalManagedRootFolderId = ownership.ManagedRootFolderId, - DirectoryObjectIdentityVersion = - ownership.DirectoryObjectIdentityVersion, - DirectoryObjectIdentity = ownership.DirectoryObjectIdentity, - State = LibraryDirectoryOwnershipRetiredMarkerState.Pending, - CreatedAt = now, - UpdatedAt = now - }; - } - - public static bool Matches( - LibraryDirectoryOwnershipRetiredMarker evidence, - LibraryDirectoryOwnershipMarker.MarkerPayload payload) - { - var canonicalPayload = - LibraryDirectoryOwnershipMarker.SerializePayload(payload); - var checksum = Convert.ToHexString( - System.Security.Cryptography.SHA256.HashData( - System.Text.Encoding.UTF8.GetBytes(canonicalPayload))); - return string.Equals( - canonicalPayload, - evidence.CanonicalPayload, - StringComparison.Ordinal) - && string.Equals( - checksum, - evidence.PayloadSha256, - StringComparison.Ordinal); - } - - public static void MaterializeCanonicalPayload( - LibraryDirectoryOwnershipRetiredMarker evidence) - { - if (string.IsNullOrWhiteSpace(evidence.CanonicalMarkerPath)) - { - var parentPath = Path.GetDirectoryName( - evidence.CanonicalOwnershipPath) - ?? throw new InvalidOperationException( - "The retired ownership path has no parent directory."); - evidence.CanonicalMarkerPath = Path.Join( - parentPath, - $".listenarr-directory-owner-{evidence.OwnershipToken}.json"); - } - - var payload = evidence.PayloadVersion == 1 - ? new LibraryDirectoryOwnershipMarker.MarkerPayload( - 1, - evidence.OwnershipToken, - evidence.CanonicalOwnershipPath) - : new LibraryDirectoryOwnershipMarker.MarkerPayload( - evidence.PayloadVersion, - evidence.OwnershipToken, - evidence.CanonicalOwnershipPath, - evidence.OriginalManagedRootFolderId, - evidence.DirectoryObjectIdentityVersion, - evidence.DirectoryObjectIdentity); - var canonicalPayload = - LibraryDirectoryOwnershipMarker.SerializePayload(payload); - evidence.CanonicalPayload = canonicalPayload; - evidence.PayloadSha256 = Convert.ToHexString( - System.Security.Cryptography.SHA256.HashData( - System.Text.Encoding.UTF8.GetBytes(canonicalPayload))); - } -} diff --git a/listenarr.infrastructure/Library/Moving/EfLibraryDirectoryOwnershipStore.cs b/listenarr.infrastructure/Library/Moving/EfLibraryDirectoryOwnershipStore.cs index 6edec2db2..f6f636ca1 100644 --- a/listenarr.infrastructure/Library/Moving/EfLibraryDirectoryOwnershipStore.cs +++ b/listenarr.infrastructure/Library/Moving/EfLibraryDirectoryOwnershipStore.cs @@ -27,6 +27,12 @@ internal Action? AfterOwnedDirectoryPhysicalIdentityPinnedForTest set; } + internal Action? AfterOwnershipCommitForTest + { + get; + set; + } + public async Task RecordCreatedAsync( LibraryDirectoryOwnershipClaim claim, CancellationToken cancellationToken = default) @@ -142,11 +148,6 @@ private async Task RecordCreatedCoreAsync( await using var transaction = db.Database.IsRelational() ? await db.Database.BeginTransactionAsync(cancellationToken) : null; - var retiredCandidates = await db.LibraryDirectoryOwnerships - .AsNoTracking() - .Where(ownership => ownership.PathIdentityLookupKey == lookupKey - && ownership.State == LibraryDirectoryOwnershipState.Removed) - .ToListAsync(cancellationToken); var candidates = await db.LibraryDirectoryOwnerships .Where(ownership => ownership.PathIdentityLookupKey == lookupKey && ownership.State != LibraryDirectoryOwnershipState.Removed) @@ -194,10 +195,10 @@ or LibraryDirectoryOwnershipState.Retained { await transaction.CommitAsync(CancellationToken.None); } - CleanupRetiredSiblingMarkers( - retiredCandidates, - canonicalPath, - claim.Semantics); + await RevalidateCommittedOwnershipAsync( + existing, + markerCreation, + CancellationToken.None); return existing; } @@ -252,6 +253,10 @@ or LibraryDirectoryOwnershipState.Retained { await transaction.CommitAsync(CancellationToken.None); } + await RevalidateCommittedOwnershipAsync( + ownership, + markerCreation, + CancellationToken.None); } catch (UniqueConstraintViolationException) { @@ -284,19 +289,15 @@ or LibraryDirectoryOwnershipState.Retained concurrent.DirectoryObjectIdentityUnavailableReason = null; concurrent.UpdatedAt = timeProvider.GetUtcNow().UtcDateTime; await retryDb.SaveChangesAsync(CancellationToken.None); - CleanupRetiredSiblingMarkers( - retiredCandidates, - canonicalPath, - claim.Semantics); + await RevalidateCommittedOwnershipAsync( + concurrent, + markerCreation, + CancellationToken.None); return concurrent; } throw; } - CleanupRetiredSiblingMarkers( - retiredCandidates, - canonicalPath, - claim.Semantics); return ownership; } diff --git a/listenarr.infrastructure/Library/Moving/EfMoveExecutionStore.Helpers.cs b/listenarr.infrastructure/Library/Moving/EfMoveExecutionStore.Helpers.cs index 885d28d93..aec7d7157 100644 --- a/listenarr.infrastructure/Library/Moving/EfMoveExecutionStore.Helpers.cs +++ b/listenarr.infrastructure/Library/Moving/EfMoveExecutionStore.Helpers.cs @@ -247,6 +247,61 @@ ArgumentException or InvalidOperationException return null; } + private static MoveCreatedDirectoryState AdvanceCreatedDirectoryState( + MoveCreatedDirectoryState current, + MoveCreatedDirectoryState requested) + { + if (current == requested) + { + return current; + } + + if (current == MoveCreatedDirectoryState.Planned + && requested is MoveCreatedDirectoryState.Created + or MoveCreatedDirectoryState.Retained + or MoveCreatedDirectoryState.Removed) + { + return requested; + } + + if (current == MoveCreatedDirectoryState.Created + && requested is MoveCreatedDirectoryState.Retained + or MoveCreatedDirectoryState.Removed) + { + return requested; + } + + throw new MoveNeedsAttentionException( + $"The persisted move-created directory state cannot transition from {current} to {requested}."); + } + + private static MoveJobEntryCleanupState AdvanceCleanupState( + MoveJobEntryCleanupState current, + MoveJobEntryCleanupState requested) + { + if (current == requested) + { + return current; + } + + if (current == MoveJobEntryCleanupState.Pending + && requested is MoveJobEntryCleanupState.DeleteAuthorized + or MoveJobEntryCleanupState.Retained) + { + return requested; + } + + if (current == MoveJobEntryCleanupState.DeleteAuthorized + && requested is MoveJobEntryCleanupState.Deleted + or MoveJobEntryCleanupState.Retained) + { + return requested; + } + + throw new MoveNeedsAttentionException( + $"The persisted move cleanup state cannot transition from {current} to {requested}."); + } + private static async Task IsLeaseActiveAsync( ListenArrDbContext db, Guid jobId, diff --git a/listenarr.infrastructure/Library/Moving/EfMoveExecutionStore.Markerless.cs b/listenarr.infrastructure/Library/Moving/EfMoveExecutionStore.Markerless.cs index 1268b0d7e..acbfbe87e 100644 --- a/listenarr.infrastructure/Library/Moving/EfMoveExecutionStore.Markerless.cs +++ b/listenarr.infrastructure/Library/Moving/EfMoveExecutionStore.Markerless.cs @@ -133,9 +133,7 @@ public Task UpdateSourceDirectoryCleanupStateAsync( } var observedState = job.SourceDirectoryCleanupState; - var desiredState = observedState < cleanupState - ? cleanupState - : observedState; + var desiredState = AdvanceCleanupState(observedState, cleanupState); if (!db.Database.IsRelational()) { job.SourceDirectoryCleanupState = desiredState; @@ -343,17 +341,10 @@ public Task UpdateCreatedDirectoryPublicationAsync( throw new MoveNeedsAttentionException( "A move-created target directory changed physical generation."); } - if (state < directory.State - || directory.State == MoveCreatedDirectoryState.Removed) - { - throw new MoveNeedsAttentionException( - "A markerless target-directory state transition would regress durable state."); - } - var observedIdentity = directory.DirectoryObjectIdentity; var observedState = directory.State; var desiredIdentity = observedIdentity ?? directoryObjectIdentity; - var desiredState = observedState < state ? state : observedState; + var desiredState = AdvanceCreatedDirectoryState(observedState, state); if (!db.Database.IsRelational()) { directory.DirectoryObjectIdentity = desiredIdentity; diff --git a/listenarr.infrastructure/Library/Moving/EfMoveExecutionStore.Scaffolding.cs b/listenarr.infrastructure/Library/Moving/EfMoveExecutionStore.Scaffolding.cs index d47a29933..80ae00456 100644 --- a/listenarr.infrastructure/Library/Moving/EfMoveExecutionStore.Scaffolding.cs +++ b/listenarr.infrastructure/Library/Moving/EfMoveExecutionStore.Scaffolding.cs @@ -81,37 +81,49 @@ public Task UpdateCreatedDirectoryStateAsync( EnsureLeaseTokenProvided(jobId, leaseToken); var nowUtc = timeProvider.GetUtcNow().UtcDateTime; await using var db = await dbContextFactory.CreateDbContextAsync(cancellationToken); - if (!db.Database.IsRelational()) - { - var directory = await db.MoveJobCreatedDirectories.SingleOrDefaultAsync( + var directory = await db.MoveJobCreatedDirectories + .Include(candidate => candidate.MoveJob) + .SingleOrDefaultAsync( candidate => candidate.MoveJobId == jobId - && candidate.Path == path - && candidate.MoveJob.Status == MoveJobStatus.Running - && candidate.MoveJob.LeaseOwner == leaseToken.Owner - && candidate.MoveJob.LeaseGeneration == leaseToken.Generation - && candidate.MoveJob.LeaseExpiresAt != null - && candidate.MoveJob.LeaseExpiresAt > nowUtc, + && candidate.Path == path, cancellationToken); - if (directory == null) - { - throw new MoveLeaseLostException(jobId, leaseToken.Generation); - } + if (directory == null + || directory.MoveJob.Status != MoveJobStatus.Running + || !string.Equals( + directory.MoveJob.LeaseOwner, + leaseToken.Owner, + StringComparison.Ordinal) + || directory.MoveJob.LeaseGeneration != leaseToken.Generation + || directory.MoveJob.LeaseExpiresAt == null + || directory.MoveJob.LeaseExpiresAt <= nowUtc) + { + throw new MoveLeaseLostException(jobId, leaseToken.Generation); + } - directory.State = state; + var observedState = directory.State; + var desiredState = AdvanceCreatedDirectoryState(observedState, state); + if (!db.Database.IsRelational()) + { + directory.State = desiredState; await db.SaveChangesAsync(cancellationToken); return; } + db.Entry(directory).State = EntityState.Detached; + db.Entry(directory.MoveJob).State = EntityState.Detached; var affected = await db.MoveJobCreatedDirectories - .Where(directory => directory.MoveJobId == jobId - && directory.Path == path - && directory.MoveJob.Status == MoveJobStatus.Running - && directory.MoveJob.LeaseOwner == leaseToken.Owner - && directory.MoveJob.LeaseGeneration == leaseToken.Generation - && directory.MoveJob.LeaseExpiresAt != null - && directory.MoveJob.LeaseExpiresAt > nowUtc) + .Where(candidate => candidate.MoveJobId == jobId + && candidate.Path == path + && candidate.State == observedState + && candidate.MoveJob.Status == MoveJobStatus.Running + && candidate.MoveJob.LeaseOwner == leaseToken.Owner + && candidate.MoveJob.LeaseGeneration == leaseToken.Generation + && candidate.MoveJob.LeaseExpiresAt != null + && candidate.MoveJob.LeaseExpiresAt > nowUtc) .ExecuteUpdateAsync( - updates => updates.SetProperty(directory => directory.State, state), + updates => updates.SetProperty( + candidate => candidate.State, + desiredState), cancellationToken); if (affected != 1) { diff --git a/listenarr.infrastructure/Library/Moving/EfMoveExecutionStore.cs b/listenarr.infrastructure/Library/Moving/EfMoveExecutionStore.cs index 415b63521..e3ebcfa75 100644 --- a/listenarr.infrastructure/Library/Moving/EfMoveExecutionStore.cs +++ b/listenarr.infrastructure/Library/Moving/EfMoveExecutionStore.cs @@ -48,14 +48,13 @@ public Task GetExecutionProtocolVersionAsync( }, cancellationToken); - public Task ValidateOrAdoptIdentityAsync( + public Task ValidateIdentityAsync( Guid jobId, string source, string target, FileSystemPathSemantics sourceSemantics, FileSystemPathSemantics targetSemantics, MoveLeaseToken leaseToken, - bool hasFilesystemRecoveryArtifacts, CancellationToken cancellationToken) => ExecuteAsync( "validate the persisted move identity", @@ -84,54 +83,8 @@ public Task ValidateOrAdoptIdentityAsync( var persistedSource = identity.SourcePath; if (string.IsNullOrWhiteSpace(persistedSource)) { - var hasManifest = await db.MoveJobEntries.AnyAsync( - entry => entry.MoveJobId == jobId, - cancellationToken); - if (hasManifest || hasFilesystemRecoveryArtifacts) - { - throw new MoveNeedsAttentionException( - "A legacy move without a persisted source cannot own existing recovery artifacts."); - } - - var nowUtc = timeProvider.GetUtcNow().UtcDateTime; - if (!db.Database.IsRelational()) - { - var job = await db.MoveJobs.SingleOrDefaultAsync( - candidate => candidate.Id == jobId - && candidate.Status == MoveJobStatus.Running - && candidate.LeaseOwner == leaseToken.Owner - && candidate.LeaseGeneration == leaseToken.Generation - && candidate.LeaseExpiresAt != null - && candidate.LeaseExpiresAt > nowUtc, - cancellationToken); - if (job == null || !string.IsNullOrWhiteSpace(job.SourcePath)) - { - throw new MoveLeaseLostException(jobId, leaseToken.Generation); - } - - job.SourcePath = source; - await db.SaveChangesAsync(cancellationToken); - } - else - { - var affected = await db.MoveJobs - .Where(candidate => candidate.Id == jobId - && candidate.SourcePath == identity.SourcePath - && candidate.Status == MoveJobStatus.Running - && candidate.LeaseOwner == leaseToken.Owner - && candidate.LeaseGeneration == leaseToken.Generation - && candidate.LeaseExpiresAt != null - && candidate.LeaseExpiresAt > nowUtc) - .ExecuteUpdateAsync( - updates => updates.SetProperty(job => job.SourcePath, source), - cancellationToken); - if (affected != 1) - { - throw new MoveLeaseLostException(jobId, leaseToken.Generation); - } - } - - persistedSource = source; + throw new MoveNeedsAttentionException( + "Persisted move source identity is required before filesystem mutation."); } EnsureEquivalentIdentity( @@ -253,37 +206,49 @@ public Task UpdateCleanupStateAsync( EnsureLeaseTokenProvided(jobId, leaseToken); var nowUtc = timeProvider.GetUtcNow().UtcDateTime; await using var db = await dbContextFactory.CreateDbContextAsync(cancellationToken); - if (!db.Database.IsRelational()) - { - var entry = await db.MoveJobEntries.SingleOrDefaultAsync( + var entry = await db.MoveJobEntries + .Include(candidate => candidate.MoveJob) + .SingleOrDefaultAsync( candidate => candidate.MoveJobId == jobId - && candidate.RelativePath == relativePath - && candidate.MoveJob.Status == MoveJobStatus.Running - && candidate.MoveJob.LeaseOwner == leaseToken.Owner - && candidate.MoveJob.LeaseGeneration == leaseToken.Generation - && candidate.MoveJob.LeaseExpiresAt != null - && candidate.MoveJob.LeaseExpiresAt > nowUtc, + && candidate.RelativePath == relativePath, cancellationToken); - if (entry == null) - { - throw new MoveLeaseLostException(jobId, leaseToken.Generation); - } + if (entry == null + || entry.MoveJob.Status != MoveJobStatus.Running + || !string.Equals( + entry.MoveJob.LeaseOwner, + leaseToken.Owner, + StringComparison.Ordinal) + || entry.MoveJob.LeaseGeneration != leaseToken.Generation + || entry.MoveJob.LeaseExpiresAt == null + || entry.MoveJob.LeaseExpiresAt <= nowUtc) + { + throw new MoveLeaseLostException(jobId, leaseToken.Generation); + } - entry.CleanupState = cleanupState; + var observedState = entry.CleanupState; + var desiredState = AdvanceCleanupState(observedState, cleanupState); + if (!db.Database.IsRelational()) + { + entry.CleanupState = desiredState; await db.SaveChangesAsync(cancellationToken); return; } + db.Entry(entry).State = EntityState.Detached; + db.Entry(entry.MoveJob).State = EntityState.Detached; var affected = await db.MoveJobEntries - .Where(entry => entry.MoveJobId == jobId - && entry.RelativePath == relativePath - && entry.MoveJob.Status == MoveJobStatus.Running - && entry.MoveJob.LeaseOwner == leaseToken.Owner - && entry.MoveJob.LeaseGeneration == leaseToken.Generation - && entry.MoveJob.LeaseExpiresAt != null - && entry.MoveJob.LeaseExpiresAt > nowUtc) + .Where(candidate => candidate.MoveJobId == jobId + && candidate.RelativePath == relativePath + && candidate.CleanupState == observedState + && candidate.MoveJob.Status == MoveJobStatus.Running + && candidate.MoveJob.LeaseOwner == leaseToken.Owner + && candidate.MoveJob.LeaseGeneration == leaseToken.Generation + && candidate.MoveJob.LeaseExpiresAt != null + && candidate.MoveJob.LeaseExpiresAt > nowUtc) .ExecuteUpdateAsync( - updates => updates.SetProperty(entry => entry.CleanupState, cleanupState), + updates => updates.SetProperty( + candidate => candidate.CleanupState, + desiredState), cancellationToken); if (affected != 1) { diff --git a/listenarr.infrastructure/Library/Moving/LibraryDirectoryOwnershipMarker.MigrationCleanup.cs b/listenarr.infrastructure/Library/Moving/LibraryDirectoryOwnershipMarker.MigrationCleanup.cs deleted file mode 100644 index 3b34bca1a..000000000 --- a/listenarr.infrastructure/Library/Moving/LibraryDirectoryOwnershipMarker.MigrationCleanup.cs +++ /dev/null @@ -1,125 +0,0 @@ -namespace Listenarr.Infrastructure.Library.Moving; - -internal static partial class LibraryDirectoryOwnershipMarker -{ - internal static bool TryRetireMigrationArtifacts( - LibraryDirectoryOwnership source, - LibraryDirectoryOwnership target, - PinnedDirectoryCreation.PinnedDirectoryAnchor directory, - PinnedDirectoryCreation.PinnedDirectoryAnchor parent, - out string? reason) - { - ArgumentNullException.ThrowIfNull(source); - ArgumentNullException.ThrowIfNull(target); - ArgumentNullException.ThrowIfNull(directory); - ArgumentNullException.ThrowIfNull(parent); - try - { - var retiredInsideArtifact = false; - foreach (var fileName in GetInsideMigrationArtifactNames()) - { - retiredInsideArtifact |= RetireMigrationArtifactIfPresent( - source, - target, - directory, - fileName); - } - - var retiredSiblingArtifact = false; - var siblingName = - $".listenarr-directory-owner-{source.OwnershipToken}.json"; - foreach (var fileName in GetSiblingMigrationArtifactNames( - siblingName)) - { - retiredSiblingArtifact |= RetireMigrationArtifactIfPresent( - source, - target, - parent, - fileName); - } - - if (retiredInsideArtifact) - { - directory.FlushDirectoryEntry(); - } - if (retiredSiblingArtifact) - { - parent.FlushDirectoryEntry(); - } - reason = null; - return true; - } - catch (Exception exception) when (exception is - ArgumentException or IOException or UnauthorizedAccessException - or InvalidOperationException or NotSupportedException - or System.ComponentModel.Win32Exception) - { - reason = exception.Message; - return false; - } - } - - private static IEnumerable GetInsideMigrationArtifactNames() - { - yield return FileName; - yield return FileName + ".v2.tmp"; - yield return FileName + ".migration.tmp"; - yield return PinnedDirectoryCreation - .GetConditionalReplacementBackupName(FileName); - } - - private static IEnumerable GetSiblingMigrationArtifactNames( - string siblingName) - { - yield return siblingName; - yield return siblingName + ".v2.tmp"; - yield return siblingName + ".migration.tmp"; - yield return PinnedDirectoryCreation - .GetConditionalReplacementBackupName(siblingName); - } - - private static bool RetireMigrationArtifactIfPresent( - LibraryDirectoryOwnership source, - LibraryDirectoryOwnership target, - PinnedDirectoryCreation.PinnedDirectoryAnchor parent, - string fileName) - { - using var marker = parent.TryOpenExistingFile( - fileName, - requireDeleteAccess: true); - if (marker == null) - { - return false; - } - - var payload = ReadPayload(marker); - if (!MatchesMigrationPayload(source, target, payload) - || !parent.VisiblePathMatches() - || !marker.VisiblePathMatches()) - { - throw new InvalidOperationException( - "An ownership migration artifact does not match either persisted migration generation."); - } - - var verifiedPayload = ReadPayload(marker); - if (!MatchesMigrationPayload(source, target, verifiedPayload) - || !parent.VisiblePathMatches() - || !marker.VisiblePathMatches()) - { - throw new InvalidOperationException( - "An ownership migration artifact changed before retirement."); - } - - marker.Delete(); - return true; - } - - private static bool MatchesMigrationPayload( - LibraryDirectoryOwnership source, - LibraryDirectoryOwnership target, - MarkerPayload payload) => - MatchesCurrentPayload(source, payload) - || MatchesLegacyPayload(source, payload) - || MatchesCurrentPayload(target, payload) - || MatchesLegacyPayload(target, payload); -} diff --git a/listenarr.infrastructure/Library/Moving/LibraryDirectoryOwnershipMarker.Payload.cs b/listenarr.infrastructure/Library/Moving/LibraryDirectoryOwnershipMarker.Payload.cs deleted file mode 100644 index 1ecc7072d..000000000 --- a/listenarr.infrastructure/Library/Moving/LibraryDirectoryOwnershipMarker.Payload.cs +++ /dev/null @@ -1,61 +0,0 @@ -using Listenarr.Domain.Common; - -namespace Listenarr.Infrastructure.Library.Moving; - -internal static partial class LibraryDirectoryOwnershipMarker -{ - internal static bool MatchesLegacyPayload( - LibraryDirectoryOwnership ownership, - MarkerPayload payload) => - payload.Version == 1 - && string.Equals( - payload.OwnershipToken, - ownership.OwnershipToken, - StringComparison.Ordinal) - && MarkerPathMatches( - payload.CanonicalPath, - ownership.CanonicalPath, - ownership.GetIdentity().Semantics); - - internal static bool MatchesCurrentPayload( - LibraryDirectoryOwnership ownership, - MarkerPayload payload) => - payload.Version == Version - && string.Equals( - payload.OwnershipToken, - ownership.OwnershipToken, - StringComparison.Ordinal) - && MarkerPathMatches( - payload.CanonicalPath, - ownership.CanonicalPath, - ownership.GetIdentity().Semantics) - && payload.ManagedRootFolderId == ownership.ManagedRootFolderId - && payload.DirectoryObjectIdentityVersion - == ownership.DirectoryObjectIdentityVersion - && string.Equals( - payload.DirectoryObjectIdentity, - ownership.DirectoryObjectIdentity, - StringComparison.Ordinal); - - private static bool MarkerPathMatches( - string persistedPath, - string expectedPath, - FileSystemPathSemantics semantics) => - FileSystemPathIdentity.TryCanonicalizeUnambiguousStoredAbsolutePathForHost( - persistedPath, - out var canonicalPath, - out _, - semantics.Syntax) - && FileSystemPathIdentity.AreEquivalent( - canonicalPath, - expectedPath, - semantics); - - internal sealed record MarkerPayload( - int Version, - string OwnershipToken, - string CanonicalPath, - int? ManagedRootFolderId = null, - int? DirectoryObjectIdentityVersion = null, - string? DirectoryObjectIdentity = null); -} diff --git a/listenarr.infrastructure/Library/Moving/LibraryDirectoryOwnershipMarker.cs b/listenarr.infrastructure/Library/Moving/LibraryDirectoryOwnershipMarker.cs deleted file mode 100644 index 92bc1ae02..000000000 --- a/listenarr.infrastructure/Library/Moving/LibraryDirectoryOwnershipMarker.cs +++ /dev/null @@ -1,498 +0,0 @@ -using System.Text.Json; - -namespace Listenarr.Infrastructure.Library.Moving; - -internal static partial class LibraryDirectoryOwnershipMarker -{ - internal const string FileName = ".listenarr-directory-owner.json"; - internal const int Version = 2; - private const long MaximumBytes = 16 * 1024; - private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); - - public static void Validate( - LibraryDirectoryOwnership ownership, - string directory) - { - ArgumentNullException.ThrowIfNull(ownership); - var fullDirectory = Path.GetFullPath(directory); - var parentPath = Path.GetDirectoryName(fullDirectory) - ?? throw new InvalidOperationException( - "The durable directory ownership path has no parent."); - using var parent = PinnedDirectoryCreation.OpenPinnedBoundary(parentPath); - using var pinnedDirectory = parent.OpenExistingChild( - Path.GetFileName(fullDirectory)); - Validate(ownership, pinnedDirectory, parent); - } - - public static bool ContainsOnlyInsideMarker( - LibraryDirectoryOwnership ownership, - string directory) - { - ArgumentNullException.ThrowIfNull(ownership); - var fullDirectory = Path.GetFullPath(directory); - var parentPath = Path.GetDirectoryName(fullDirectory) - ?? throw new InvalidOperationException( - "The durable directory ownership path has no parent."); - using var parent = PinnedDirectoryCreation.OpenPinnedBoundary(parentPath); - using var pinnedDirectory = parent.OpenExistingChild( - Path.GetFileName(fullDirectory)); - return ContainsOnlyInsideMarker(ownership, pinnedDirectory, parent); - } - - public static bool ContainsOnlyInsideMarker( - LibraryDirectoryOwnership ownership, - PinnedDirectoryCreation.PinnedDirectoryAnchor directory, - PinnedDirectoryCreation.PinnedDirectoryAnchor parent) - { - Validate(ownership, directory, parent); - var markerPath = Path.Join(directory.FullPath, FileName); - var entries = Directory.EnumerateFileSystemEntries(directory.FullPath) - .Take(2) - .ToList(); - if (!directory.VisiblePathMatches() || !parent.VisiblePathMatches()) - { - throw new InvalidOperationException( - "The durable ownership directory changed during enumeration."); - } - - return entries.Count == 1 - && string.Equals(entries[0], markerPath, StringComparison.Ordinal); - } - - public static void DeleteInsideMarker( - LibraryDirectoryOwnership ownership, - string directory) - { - ArgumentNullException.ThrowIfNull(ownership); - var fullDirectory = Path.GetFullPath(directory); - var parentPath = Path.GetDirectoryName(fullDirectory) - ?? throw new InvalidOperationException( - "The durable directory ownership path has no parent."); - using var parent = PinnedDirectoryCreation.OpenPinnedBoundary(parentPath); - using var pinnedDirectory = parent.OpenExistingChild( - Path.GetFileName(fullDirectory)); - DeleteInsideMarker(ownership, pinnedDirectory, parent); - } - - public static void DeleteInsideMarker( - LibraryDirectoryOwnership ownership, - PinnedDirectoryCreation.PinnedDirectoryAnchor directory, - PinnedDirectoryCreation.PinnedDirectoryAnchor parent) - { - Validate(ownership, directory, parent); - using var marker = directory.OpenExistingFile( - FileName, - requireDeleteAccess: true); - ValidateMarkerFile(ownership, marker); - if (!directory.VisiblePathMatches() || !parent.VisiblePathMatches()) - { - throw new InvalidOperationException( - "The durable ownership directory changed before marker retirement."); - } - - ValidateMarkerFile(ownership, marker); - marker.Delete(); - parent.FlushDirectoryEntry(); - } - - public static void Validate( - LibraryDirectoryOwnership ownership, - PinnedDirectoryCreation.PinnedDirectoryAnchor directory, - PinnedDirectoryCreation.PinnedDirectoryAnchor parent) - { - try - { - ValidatePinnedCore(ownership, directory, parent); - } - catch (Exception exception) when (exception is - IOException or UnauthorizedAccessException - or System.ComponentModel.Win32Exception - or NotSupportedException) - { - throw new InvalidOperationException( - "The durable directory ownership marker could not be pinned.", - exception); - } - } - - public static void ValidateSiblingMarker( - LibraryDirectoryOwnership ownership, - PinnedDirectoryCreation.PinnedDirectoryAnchor parent) - { - try - { - using var sibling = parent.OpenExistingFile( - Path.GetFileName(GetSiblingPath(ownership)), - requireDeleteAccess: false); - ValidateMarkerFile(ownership, sibling); - if (!parent.VisiblePathMatches() || !sibling.VisiblePathMatches()) - { - throw new InvalidOperationException( - "The durable ownership sibling marker changed during validation."); - } - - ValidateMarkerFile(ownership, sibling); - } - catch (Exception exception) when (exception is - IOException or UnauthorizedAccessException - or System.ComponentModel.Win32Exception - or NotSupportedException) - { - throw new InvalidOperationException( - "The durable directory ownership sibling marker could not be pinned.", - exception); - } - } - - private static void ValidatePinnedCore( - LibraryDirectoryOwnership ownership, - PinnedDirectoryCreation.PinnedDirectoryAnchor directory, - PinnedDirectoryCreation.PinnedDirectoryAnchor parent) - { - ArgumentNullException.ThrowIfNull(ownership); - using var inside = directory.OpenExistingFile( - FileName, - requireDeleteAccess: false); - using var sibling = parent.OpenExistingFile( - Path.GetFileName(GetSiblingPath(ownership)), - requireDeleteAccess: false); - ValidateMarkerFile(ownership, inside); - ValidateMarkerFile(ownership, sibling); - if (!directory.VisiblePathMatches() || !parent.VisiblePathMatches()) - { - throw new InvalidOperationException( - "The durable ownership directory changed during marker validation."); - } - - ValidateMarkerFile(ownership, inside); - ValidateMarkerFile(ownership, sibling); - } - - public static void DeleteSiblingMarker(LibraryDirectoryOwnership ownership) - { - DeleteValidatedMarker(ownership, GetSiblingPath(ownership)); - } - - public static bool TryDeleteRetiredSiblingMarker( - LibraryDirectoryOwnership ownership, - out string? reason) - { - var markerPath = GetSiblingPath(ownership); - try - { - DeleteValidatedMarker(ownership, markerPath); - reason = null; - return true; - } - catch (Exception exception) when (exception is - ArgumentException or IOException or UnauthorizedAccessException - or InvalidOperationException or NotSupportedException - or System.ComponentModel.Win32Exception) - { - if (!File.Exists(markerPath)) - { - reason = null; - return true; - } - - reason = exception.Message; - return false; - } - } - - public static bool TryRetireMatchingSiblingArtifacts( - LibraryDirectoryOwnership ownership, - PinnedDirectoryCreation.PinnedDirectoryAnchor parent, - out string? reason) - { - ArgumentNullException.ThrowIfNull(ownership); - ArgumentNullException.ThrowIfNull(parent); - try - { - var siblingName = Path.GetFileName(GetSiblingPath(ownership)); - RetireMatchingMarkerIfPresent( - ownership, - parent, - siblingName); - RetireMatchingMarkerIfPresent( - ownership, - parent, - siblingName + ".v2.tmp"); - RetireMatchingMarkerIfPresent( - ownership, - parent, - siblingName + ".migration.tmp"); - RetireMatchingMarkerIfPresent( - ownership, - parent, - PinnedDirectoryCreation.GetConditionalReplacementBackupName( - siblingName)); - parent.FlushDirectoryEntry(); - reason = null; - return true; - } - catch (Exception exception) when (exception is - ArgumentException or IOException or UnauthorizedAccessException - or InvalidOperationException or NotSupportedException - or System.ComponentModel.Win32Exception) - { - reason = exception.Message; - return false; - } - } - - public static bool TryRetireMatchingMarkers( - LibraryDirectoryOwnership ownership, - PinnedDirectoryCreation.PinnedDirectoryAnchor directory, - PinnedDirectoryCreation.PinnedDirectoryAnchor parent, - out string? reason) - { - ArgumentNullException.ThrowIfNull(ownership); - ArgumentNullException.ThrowIfNull(directory); - ArgumentNullException.ThrowIfNull(parent); - try - { - RetireMatchingMarkerIfPresent( - ownership, - directory, - FileName); - RetireMatchingMarkerIfPresent( - ownership, - directory, - FileName + ".v2.tmp"); - RetireMatchingMarkerIfPresent( - ownership, - directory, - FileName + ".migration.tmp"); - RetireMatchingMarkerIfPresent( - ownership, - directory, - PinnedDirectoryCreation.GetConditionalReplacementBackupName( - FileName)); - if (!TryRetireMatchingSiblingArtifacts( - ownership, - parent, - out reason)) - { - return false; - } - directory.FlushDirectoryEntry(); - reason = null; - return true; - } - catch (Exception exception) when (exception is - ArgumentException or IOException or UnauthorizedAccessException - or InvalidOperationException or NotSupportedException - or System.ComponentModel.Win32Exception) - { - reason = exception.Message; - return false; - } - } - - private static void RetireMatchingMarkerIfPresent( - LibraryDirectoryOwnership ownership, - PinnedDirectoryCreation.PinnedDirectoryAnchor parent, - string fileName) - { - using var marker = parent.TryOpenExistingFile( - fileName, - requireDeleteAccess: true); - if (marker == null) - { - return; - } - - var payload = ReadPayload(marker); - if (!MatchesCurrentPayload(ownership, payload) - && !MatchesLegacyPayload(ownership, payload)) - { - throw new InvalidOperationException( - "A legacy directory ownership artifact does not match the persisted ownership claim."); - } - if (!parent.VisiblePathMatches() || !marker.VisiblePathMatches()) - { - throw new InvalidOperationException( - "A legacy directory ownership artifact changed before retirement."); - } - - var verifiedPayload = ReadPayload(marker); - if (!MatchesCurrentPayload(ownership, verifiedPayload) - && !MatchesLegacyPayload(ownership, verifiedPayload)) - { - throw new InvalidOperationException( - "A legacy directory ownership artifact changed before retirement."); - } - - marker.Delete(); - } - - public static IReadOnlyList GetMarkerPaths( - LibraryDirectoryOwnership ownership) => - [GetInsidePath(ownership.CanonicalPath), GetSiblingPath(ownership)]; - - public static bool HasValidSiblingMarker(LibraryDirectoryOwnership ownership) - { - try - { - var siblingPath = GetSiblingPath(ownership); - var parentPath = Path.GetDirectoryName(siblingPath) - ?? throw new InvalidOperationException( - "The durable directory ownership sibling marker has no parent."); - using var parent = PinnedDirectoryCreation.OpenPinnedBoundary(parentPath); - ValidateSiblingMarker(ownership, parent); - return true; - } - catch (Exception exception) when (exception is - ArgumentException or IOException or UnauthorizedAccessException - or InvalidOperationException or NotSupportedException - or System.ComponentModel.Win32Exception) - { - return false; - } - } - - private static void DeleteValidatedMarker( - LibraryDirectoryOwnership ownership, - string markerPath) - { - var parentPath = Path.GetDirectoryName(Path.GetFullPath(markerPath)) - ?? throw new InvalidOperationException( - "The durable directory ownership marker has no parent."); - using var parent = PinnedDirectoryCreation.OpenPinnedBoundary( - parentPath); - using var marker = parent.OpenExistingFile( - Path.GetFileName(markerPath), - requireDeleteAccess: true); - ValidateMarkerFile(ownership, marker); - if (!parent.VisiblePathMatches() || !marker.VisiblePathMatches()) - { - throw new InvalidOperationException( - "The durable directory ownership marker changed before retirement."); - } - - ValidateMarkerFile(ownership, marker); - marker.Delete(); - } - - internal static void ValidateMarkerFile( - LibraryDirectoryOwnership ownership, - PinnedDirectoryCreation.PinnedFileEntry markerEntry) - { - using var stream = markerEntry.OpenReadStream( - bufferSize: 4096, - asynchronous: false); - if (stream.Length <= 0 || stream.Length > MaximumBytes) - { - throw new InvalidOperationException( - "The durable directory ownership marker has an invalid size."); - } - - MarkerPayload? marker; - try - { - stream.Position = 0; - marker = JsonSerializer.Deserialize(stream, JsonOptions); - } - catch (JsonException exception) - { - throw new InvalidOperationException( - "The durable directory ownership marker is invalid.", - exception); - } - - var expected = new MarkerPayload( - Version, - ownership.OwnershipToken, - ownership.CanonicalPath, - ownership.ManagedRootFolderId, - ownership.DirectoryObjectIdentityVersion, - ownership.DirectoryObjectIdentity); - var semantics = ownership.GetIdentity().Semantics; - var pathsMatch = marker != null - && MarkerPathMatches( - marker.CanonicalPath, - expected.CanonicalPath, - semantics); - if (marker == null - || marker.Version != Version - || !string.Equals( - marker.OwnershipToken, - expected.OwnershipToken, - StringComparison.Ordinal) - || marker.ManagedRootFolderId != expected.ManagedRootFolderId - || marker.DirectoryObjectIdentityVersion - != expected.DirectoryObjectIdentityVersion - || !string.Equals( - marker.DirectoryObjectIdentity, - expected.DirectoryObjectIdentity, - StringComparison.Ordinal) - || !pathsMatch) - { - throw new InvalidOperationException( - "The durable directory ownership marker does not match the persisted ownership claim."); - } - } - - private static string GetInsidePath(string directory) => Path.Join(directory, FileName); - - internal static void ValidateOwnershipToken(string ownershipToken) - { - if (!Guid.TryParseExact(ownershipToken, "N", out _)) - { - throw new InvalidOperationException( - "The durable directory ownership token is invalid."); - } - } - - private static string GetSiblingPath(LibraryDirectoryOwnership ownership) - { - ValidateOwnershipToken(ownership.OwnershipToken); - var parent = Path.GetDirectoryName(ownership.CanonicalPath) - ?? throw new InvalidOperationException( - "The durable directory ownership path has no parent directory."); - return Path.Join( - parent, - $".listenarr-directory-owner-{ownership.OwnershipToken}.json"); - } - - internal static string SerializePayload(LibraryDirectoryOwnership ownership) => - SerializePayload( - new MarkerPayload( - Version, - ownership.OwnershipToken, - ownership.CanonicalPath, - ownership.ManagedRootFolderId, - ownership.DirectoryObjectIdentityVersion, - ownership.DirectoryObjectIdentity)); - - internal static string SerializePayload(MarkerPayload payload) => - JsonSerializer.Serialize(payload, JsonOptions); - - internal static MarkerPayload ReadPayload( - PinnedDirectoryCreation.PinnedFileEntry markerEntry) - { - using var stream = markerEntry.OpenReadStream( - bufferSize: 4096, - asynchronous: false); - if (stream.Length <= 0 || stream.Length > MaximumBytes) - { - throw new InvalidOperationException( - "The durable directory ownership marker has an invalid size."); - } - - try - { - return JsonSerializer.Deserialize(stream, JsonOptions) - ?? throw new InvalidOperationException( - "The durable directory ownership marker is empty."); - } - catch (JsonException exception) - { - throw new InvalidOperationException( - "The durable directory ownership marker is invalid.", - exception); - } - } - -} diff --git a/listenarr.infrastructure/Library/Moving/LibraryDirectoryOwnershipReconciler.cs b/listenarr.infrastructure/Library/Moving/LibraryDirectoryOwnershipReconciler.cs index 474572d82..d498519ce 100644 --- a/listenarr.infrastructure/Library/Moving/LibraryDirectoryOwnershipReconciler.cs +++ b/listenarr.infrastructure/Library/Moving/LibraryDirectoryOwnershipReconciler.cs @@ -20,45 +20,6 @@ public Task ReconcileAsync(CancellationToken cancellationToken = default) => private async Task ReconcileCoreAsync(CancellationToken cancellationToken) { await using var db = await dbContextFactory.CreateDbContextAsync(cancellationToken); - await BackfillLegacyRemovedOwnershipEvidenceAsync(db, cancellationToken); - var retiredMarkers = await db.LibraryDirectoryOwnershipRetiredMarkers - .Where(marker => - marker.State - == LibraryDirectoryOwnershipRetiredMarkerState.Pending) - .ToListAsync(cancellationToken); - foreach (var evidence in retiredMarkers) - { - cancellationToken.ThrowIfCancellationRequested(); - try - { - if (string.IsNullOrWhiteSpace(evidence.CanonicalPayload) - || string.IsNullOrWhiteSpace(evidence.PayloadSha256) - || string.IsNullOrWhiteSpace( - evidence.CanonicalMarkerPath)) - { - LibraryDirectoryOwnershipRetiredMarkerEvidence - .MaterializeCanonicalPayload(evidence); - evidence.UpdatedAt = DateTime.UtcNow; - await db.SaveChangesAsync(cancellationToken); - } - - ReconcileRetiredMarker(evidence); - evidence.State = - LibraryDirectoryOwnershipRetiredMarkerState.Removed; - evidence.UpdatedAt = DateTime.UtcNow; - await db.SaveChangesAsync(cancellationToken); - } - catch (Exception exception) when (exception is not ( - OperationCanceledException or OutOfMemoryException - or StackOverflowException)) - { - logger.LogWarning( - exception, - "Retired directory ownership marker evidence {EvidenceId} could not be reconciled safely.", - evidence.Id); - } - } - var ownerships = await db.LibraryDirectoryOwnerships .Where(ownership => ownership.State != LibraryDirectoryOwnershipState.Removed @@ -85,9 +46,7 @@ OperationCanceledException or OutOfMemoryException } if (ownership.State == LibraryDirectoryOwnershipState.Removing - && !Directory.Exists(ownership.CanonicalPath) - && !Directory.Exists( - LibraryDirectoryOwnershipRemoval.GetQuarantinePath(ownership))) + && !Directory.Exists(ownership.CanonicalPath)) { using var missingAuthorization = ownership.ManagedRootFolderId.HasValue @@ -98,33 +57,7 @@ OperationCanceledException or OutOfMemoryException ownership.CanonicalPath, ownership.GetIdentity().Semantics, cancellationToken); - LibraryDirectoryOwnershipMarker.MarkerPayload? legacyPayload = null; - try - { - LibraryDirectoryOwnershipRemoval.TryValidateLegacyMissingBothRecovery( - ownership, - missingAuthorization.ParentAnchor, - out legacyPayload); - } - catch (Exception exception) when (exception is not ( - OperationCanceledException or OutOfMemoryException - or StackOverflowException)) - { - logger.LogWarning( - exception, - "An obsolete directory ownership artifact for ownership {OwnershipId} was preserved because it could not be validated; the completed removal is still converging from durable database state.", - ownership.Id); - } - var now = DateTime.UtcNow; - if (legacyPayload != null) - { - db.LibraryDirectoryOwnershipRetiredMarkers.Add( - LibraryDirectoryOwnershipRetiredMarkerEvidence.Create( - ownership, - legacyPayload, - now)); - } ownership.State = LibraryDirectoryOwnershipState.Removed; ownership.PathOwnershipKey = null; ownership.ManagedRootFolderId = null; @@ -143,60 +76,26 @@ OperationCanceledException or OutOfMemoryException ownership.GetIdentity().Semantics, cancellationToken); var directoryName = Path.GetFileName(ownership.CanonicalPath); - var quarantineName = - $".listenarr-directory-removing-{ownership.OwnershipToken}"; using var publication = authorization.ParentAnchor.TryOpenExistingChildForPublication( directoryName) - ?? (ownership.State == LibraryDirectoryOwnershipState.Removing - ? authorization.ParentAnchor.TryOpenExistingChildForPublication( - quarantineName) - : null) ?? throw new InvalidOperationException( - "The owned directory and its recovery quarantine are missing."); + "The owned directory is missing."); using var directory = publication.OpenCreatedDirectoryAnchor(); var liveIdentity = directory.GetDirectoryObjectIdentity(); if (ownership.DirectoryObjectIdentityVersion - == ManagedDirectoryIdentity.CurrentVersion) - { - if (!ManagedDirectoryIdentity.Matches( - ownership.DirectoryObjectIdentityVersion, - ownership.DirectoryObjectIdentity, - ownership.OwnershipToken, - liveIdentity)) - { - throw new InvalidOperationException( - "The live directory differs from its persisted Listenarr enrollment identity."); - } - } - else if (ownership.DirectoryObjectIdentityVersion == 1) - { - if (!string.Equals( - ownership.DirectoryObjectIdentity, - liveIdentity, - StringComparison.Ordinal)) - { - throw new InvalidOperationException( - "The live directory differs from its legacy physical identity."); - } - } - else if (ownership.DirectoryObjectIdentityVersion.HasValue) + != ManagedDirectoryIdentity.CurrentVersion + || !ManagedDirectoryIdentity.Matches( + ownership.DirectoryObjectIdentityVersion, + ownership.DirectoryObjectIdentity, + ownership.OwnershipToken, + liveIdentity)) { throw new InvalidOperationException( - "The persisted directory identity version cannot be reconciled automatically."); - } - else - { - // A pre-physical-identity claim can be upgraded only after the - // exact live directory is pinned through its managed root. + "The persisted directory ownership identity is not the current supported generation."); } ownership.ManagedRootFolderId = authorization.RootFolderId; - ownership.DirectoryObjectIdentityVersion = - ManagedDirectoryIdentity.CurrentVersion; - ownership.DirectoryObjectIdentity = ManagedDirectoryIdentity.Create( - ownership.OwnershipToken, - liveIdentity); ownership.DirectoryObjectIdentityUnavailableReason = null; ownership.StateReason = null; if (ownership.State == LibraryDirectoryOwnershipState.Unavailable) @@ -205,22 +104,6 @@ OperationCanceledException or OutOfMemoryException } ownership.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(cancellationToken); - - // Older builds left these marker files permanently. The durable row, - // managed-root authorization, and pinned native directory generation - // now provide the at-rest proof. Retire only artifacts that still match - // this exact ownership; unrelated files are preserved. - if (!LibraryDirectoryOwnershipMarker.TryRetireMatchingMarkers( - ownership, - directory, - authorization.ParentAnchor, - out var markerRetirementReason)) - { - logger.LogWarning( - "Obsolete directory ownership artifacts for ownership {OwnershipId} could not be retired safely and were preserved: {Reason}", - ownership.Id, - markerRetirementReason); - } } catch (Exception exception) when (exception is not ( OperationCanceledException or OutOfMemoryException @@ -240,150 +123,4 @@ OperationCanceledException or OutOfMemoryException } } - - private async Task BackfillLegacyRemovedOwnershipEvidenceAsync( - ListenArrDbContext db, - CancellationToken cancellationToken) - { - var legacyRemoved = await db.LibraryDirectoryOwnerships - .Where(ownership => - ownership.State == LibraryDirectoryOwnershipState.Removed - && (ownership.ManagedRootFolderId != null - || ownership.StateReason != null - && ownership.StateReason.StartsWith( - LibraryDirectoryOwnershipMigrationPreflight - .LegacyRemovedRootStateReasonPrefix) - || !db.LibraryDirectoryOwnershipRetiredMarkers.Any( - marker => marker.OwnershipId == ownership.Id))) - .ToListAsync(cancellationToken); - - foreach (var ownership in legacyRemoved) - { - cancellationToken.ThrowIfCancellationRequested(); - var evidenceExists = await db.LibraryDirectoryOwnershipRetiredMarkers - .AnyAsync( - marker => marker.OwnershipId == ownership.Id, - cancellationToken); - var hasPreservedRoot = - LibraryDirectoryOwnershipMigrationPreflight - .TryReadLegacyRemovedRootState( - ownership.StateReason, - out var preservedRootFolderId, - out var originalStateReason); - if (!evidenceExists) - { - db.LibraryDirectoryOwnershipRetiredMarkers.Add( - LibraryDirectoryOwnershipRetiredMarkerEvidence - .CreateLegacyPending( - ownership, - hasPreservedRoot ? preservedRootFolderId : null)); - } - - ownership.ManagedRootFolderId = null; - if (hasPreservedRoot) - { - ownership.StateReason = originalStateReason; - } - try - { - await db.SaveChangesAsync(cancellationToken); - } - catch (DbUpdateException) - { - // A second process may have materialized the same unique evidence - // row after our read. Reload and accept that race only when the - // evidence now exists; otherwise preserve the original failure. - db.ChangeTracker.Clear(); - var persistedEvidence = await db - .LibraryDirectoryOwnershipRetiredMarkers - .AnyAsync( - marker => marker.OwnershipId == ownership.Id, - cancellationToken); - if (!persistedEvidence) - { - throw; - } - - var persistedOwnership = await db.LibraryDirectoryOwnerships - .SingleAsync( - candidate => candidate.Id == ownership.Id, - cancellationToken); - var requiresOwnershipCleanup = - persistedOwnership.ManagedRootFolderId.HasValue; - if (persistedOwnership.ManagedRootFolderId.HasValue) - { - persistedOwnership.ManagedRootFolderId = null; - } - - if (LibraryDirectoryOwnershipMigrationPreflight - .TryReadLegacyRemovedRootState( - persistedOwnership.StateReason, - out _, - out var persistedOriginalStateReason)) - { - persistedOwnership.StateReason = persistedOriginalStateReason; - requiresOwnershipCleanup = true; - } - - if (requiresOwnershipCleanup) - { - await db.SaveChangesAsync(cancellationToken); - } - } - } - } - - private static void ReconcileRetiredMarker( - LibraryDirectoryOwnershipRetiredMarker evidence) - { - if (string.IsNullOrWhiteSpace(evidence.CanonicalMarkerPath)) - { - throw new InvalidOperationException( - "The retired ownership marker path has not been materialized."); - } - - if (!FileSystemPathIdentity.TryCanonicalizeUnambiguousStoredAbsolutePathForHost( - evidence.CanonicalMarkerPath, - out var canonicalMarkerPath, - out var reason) - || !FileSystemPathIdentity.TryDetectAbsoluteSyntax( - canonicalMarkerPath, - out var markerSyntax) - || markerSyntax != evidence.PathSyntax) - { - throw new InvalidOperationException( - string.IsNullOrWhiteSpace(reason) - ? "The retired ownership marker path syntax does not match its persisted evidence." - : reason); - } - - var parentPath = Path.GetDirectoryName(canonicalMarkerPath) - ?? throw new InvalidOperationException( - "The retired ownership marker has no parent directory."); - using var parent = - PinnedDirectoryCreation.OpenPinnedDirectoryNoFollow(parentPath); - using var marker = parent.TryOpenExistingFile( - Path.GetFileName(canonicalMarkerPath), - requireDeleteAccess: true); - if (marker == null) - { - return; - } - - var payload = LibraryDirectoryOwnershipMarker.ReadPayload(marker); - if (!LibraryDirectoryOwnershipRetiredMarkerEvidence.Matches( - evidence, - payload) - || !parent.VisiblePathMatches() - || !marker.VisiblePathMatches() - || !LibraryDirectoryOwnershipRetiredMarkerEvidence.Matches( - evidence, - LibraryDirectoryOwnershipMarker.ReadPayload(marker))) - { - throw new InvalidOperationException( - "The retired ownership marker does not match its immutable cleanup evidence."); - } - - marker.Delete(); - } } diff --git a/listenarr.infrastructure/Library/Moving/LibraryDirectoryOwnershipRemoval.cs b/listenarr.infrastructure/Library/Moving/LibraryDirectoryOwnershipRemoval.cs index f9ae068e0..eb8667efb 100644 --- a/listenarr.infrastructure/Library/Moving/LibraryDirectoryOwnershipRemoval.cs +++ b/listenarr.infrastructure/Library/Moving/LibraryDirectoryOwnershipRemoval.cs @@ -11,54 +11,30 @@ internal enum LibraryDirectoryRemovalOutcome internal static class LibraryDirectoryOwnershipRemoval { - private const string QuarantinePrefix = ".listenarr-directory-removing-"; - - public static string GetQuarantinePath(LibraryDirectoryOwnership ownership) - { - ArgumentNullException.ThrowIfNull(ownership); - LibraryDirectoryOwnershipMarker.ValidateOwnershipToken( - ownership.OwnershipToken); - var parent = Path.GetDirectoryName(ownership.CanonicalPath) - ?? throw new InvalidOperationException( - "The durable directory ownership path has no parent directory."); - return Path.Join(parent, $"{QuarantinePrefix}{ownership.OwnershipToken}"); - } - public static void ValidateRecoverableState(LibraryDirectoryOwnership ownership) { ArgumentNullException.ThrowIfNull(ownership); var originalExists = Directory.Exists(ownership.CanonicalPath); var originalIsFile = File.Exists(ownership.CanonicalPath); - var quarantinePath = GetQuarantinePath(ownership); - var quarantineExists = Directory.Exists(quarantinePath); - var quarantineIsFile = File.Exists(quarantinePath); - if (originalIsFile || quarantineIsFile) - { - throw new InvalidOperationException( - "An owned directory recovery path is occupied by a file."); - } - if (originalExists && quarantineExists) + if (originalIsFile) { throw new InvalidOperationException( - "Both the owned directory and its removal quarantine exist."); + "The owned directory recovery path is occupied by a file."); } - if (!originalExists && !quarantineExists) + if (!originalExists) { - // The committed Removing state is the durable deletion intent. If neither - // pathname exists, the physical retirement already completed and the - // database can safely converge to Removed without a permanent marker. + // The committed Removing state is the durable deletion intent. If the + // pathname is gone, physical retirement already completed and the database + // can safely converge to Removed. return; } - var visiblePath = originalExists - ? ownership.CanonicalPath - : quarantinePath; - var parentPath = Path.GetDirectoryName(visiblePath) + var parentPath = Path.GetDirectoryName(ownership.CanonicalPath) ?? throw new InvalidOperationException( "The owned directory recovery path has no parent directory."); using var parent = PinnedDirectoryCreation.OpenPinnedBoundary(parentPath); - using var directory = parent.OpenExistingChild(Path.GetFileName(visiblePath)); + using var directory = parent.OpenExistingChild(Path.GetFileName(ownership.CanonicalPath)); EnsurePhysicalIdentity(ownership, directory); if (!parent.VisiblePathMatches()) { @@ -67,86 +43,6 @@ public static void ValidateRecoverableState(LibraryDirectoryOwnership ownership) } } - // Compatibility for an older interrupted-removal format where the directory and - // quarantine are already absent but a durable sibling marker remains. New removal - // operations do not require or create this marker. - public static bool TryValidateLegacyMissingBothRecovery( - LibraryDirectoryOwnership ownership, - PinnedDirectoryCreation.PinnedDirectoryAnchor parent, - out LibraryDirectoryOwnershipMarker.MarkerPayload? legacyPayload) - { - ArgumentNullException.ThrowIfNull(ownership); - ArgumentNullException.ThrowIfNull(parent); - legacyPayload = null; - var originalPath = ownership.CanonicalPath; - var quarantinePath = GetQuarantinePath(ownership); - if (Directory.Exists(originalPath) - || File.Exists(originalPath) - || Directory.Exists(quarantinePath) - || File.Exists(quarantinePath)) - { - return false; - } - - var parentPath = Path.GetDirectoryName(originalPath) - ?? throw new InvalidOperationException( - "The durable directory ownership path has no parent directory."); - if (!FileSystemPathIdentity.AreEquivalent( - parent.FullPath, - parentPath, - ownership.GetIdentity().Semantics) - || !parent.VisiblePathMatches()) - { - throw new InvalidOperationException( - "The authorized ownership parent no longer matches the legacy recovery path."); - } - - var siblingPath = LibraryDirectoryOwnershipMarker - .GetMarkerPaths(ownership)[1]; - var temporaryName = Path.GetFileName(siblingPath) + ".v2.tmp"; - using var temporary = parent.TryOpenExistingFile( - temporaryName, - requireDeleteAccess: false); - if (temporary != null || Directory.Exists(Path.Join(parentPath, temporaryName))) - { - throw new InvalidOperationException( - "Legacy ownership removal proof is mixed with an incomplete marker upgrade."); - } - - using var sibling = parent.TryOpenExistingFile( - Path.GetFileName(siblingPath), - requireDeleteAccess: false); - if (sibling == null) - { - return false; - } - - var payload = LibraryDirectoryOwnershipMarker.ReadPayload(sibling); - if (!LibraryDirectoryOwnershipMarker.MatchesCurrentPayload( - ownership, - payload) - && !LibraryDirectoryOwnershipMarker.MatchesLegacyPayload( - ownership, - payload)) - { - throw new InvalidOperationException( - "The missing owned directory has no exact legacy removal proof."); - } - if (!parent.VisiblePathMatches() - || !sibling.VisiblePathMatches() - || Directory.Exists(originalPath) - || File.Exists(originalPath) - || Directory.Exists(quarantinePath) - || File.Exists(quarantinePath)) - { - throw new InvalidOperationException( - "The legacy ownership removal proof changed during validation."); - } - - legacyPayload = payload; - return true; - } - public static LibraryDirectoryRemovalOutcome RemoveEmptyDirectory( LibraryDirectoryOwnership ownership, PinnedDirectoryCreation.PinnedDirectoryAnchor parentAnchor, @@ -158,21 +54,13 @@ public static LibraryDirectoryRemovalOutcome RemoveEmptyDirectory( var parentPath = Path.GetDirectoryName(originalPath) ?? throw new InvalidOperationException( "The durable directory ownership path has no parent directory."); - var quarantinePath = GetQuarantinePath(ownership); var originalExists = Directory.Exists(originalPath); var originalIsFile = File.Exists(originalPath); - var quarantineExists = Directory.Exists(quarantinePath); - var quarantineIsFile = File.Exists(quarantinePath); - if (originalIsFile || quarantineIsFile) + if (originalIsFile) { throw new InvalidOperationException( "An owned directory removal path is occupied by a file."); } - if (originalExists && quarantineExists) - { - throw new InvalidOperationException( - "Both the owned directory and its removal quarantine exist."); - } if (!FileSystemPathIdentity.AreEquivalent( parentAnchor.FullPath, parentPath, @@ -183,87 +71,27 @@ public static LibraryDirectoryRemovalOutcome RemoveEmptyDirectory( "The authorized ownership parent no longer matches the persisted path."); } - if (!originalExists && !quarantineExists) + if (!originalExists) { - RetireLegacySiblingArtifacts(ownership, parentAnchor); return LibraryDirectoryRemovalOutcome.AlreadyRemoved; } - if (originalExists) - { - using var publication = parentAnchor.OpenExistingChildForPublication( - Path.GetFileName(originalPath)); - using var directory = publication.OpenCreatedDirectoryAnchor(); - EnsurePhysicalIdentity(ownership, directory); - RetireLegacyOwnershipArtifacts(ownership, directory, parentAnchor); - if (Directory.EnumerateFileSystemEntries(originalPath).Any() - || !directory.VisiblePathMatches() - || !parentAnchor.VisiblePathMatches()) - { - return LibraryDirectoryRemovalOutcome.Retained; - } - - cancellationToken.ThrowIfCancellationRequested(); - EnsurePhysicalIdentity(ownership, directory); - publication.RetirePinnedEmptyDirectoryFromNamespace( - Path.GetFileName(originalPath)); - RetireLegacySiblingArtifacts(ownership, parentAnchor); - return LibraryDirectoryRemovalOutcome.Removed; - } - - // Compatibility only: older versions may have already renamed the directory - // into a job-shaped quarantine. New removals never create that pathname. - using (var publication = parentAnchor.OpenExistingChildForPublication( - Path.GetFileName(quarantinePath))) - using (var directory = publication.OpenCreatedDirectoryAnchor()) - { - EnsurePhysicalIdentity(ownership, directory); - RetireLegacyOwnershipArtifacts(ownership, directory, parentAnchor); - if (Directory.EnumerateFileSystemEntries(quarantinePath).Any() - || !directory.VisiblePathMatches() - || !parentAnchor.VisiblePathMatches()) - { - RestorePinnedQuarantine(publication, originalPath, quarantinePath); - return LibraryDirectoryRemovalOutcome.Retained; - } - - cancellationToken.ThrowIfCancellationRequested(); - EnsurePhysicalIdentity(ownership, directory); - publication.RetirePinnedEmptyDirectoryFromNamespace( - Path.GetFileName(quarantinePath)); - RetireLegacySiblingArtifacts(ownership, parentAnchor); - return LibraryDirectoryRemovalOutcome.Removed; - } - } - - private static void RetireLegacyOwnershipArtifacts( - LibraryDirectoryOwnership ownership, - PinnedDirectoryCreation.PinnedDirectoryAnchor directory, - PinnedDirectoryCreation.PinnedDirectoryAnchor parent) - { - if (!LibraryDirectoryOwnershipMarker.TryRetireMatchingMarkers( - ownership, - directory, - parent, - out var reason)) + using var publication = parentAnchor.OpenExistingChildForPublication( + Path.GetFileName(originalPath)); + using var directory = publication.OpenCreatedDirectoryAnchor(); + EnsurePhysicalIdentity(ownership, directory); + if (Directory.EnumerateFileSystemEntries(originalPath).Any() + || !directory.VisiblePathMatches() + || !parentAnchor.VisiblePathMatches()) { - throw new InvalidOperationException( - $"Legacy directory ownership artifacts could not be retired safely: {reason}"); + return LibraryDirectoryRemovalOutcome.Retained; } - } - private static void RetireLegacySiblingArtifacts( - LibraryDirectoryOwnership ownership, - PinnedDirectoryCreation.PinnedDirectoryAnchor parent) - { - if (!LibraryDirectoryOwnershipMarker.TryRetireMatchingSiblingArtifacts( - ownership, - parent, - out var reason)) - { - throw new InvalidOperationException( - $"Legacy directory ownership sibling artifacts could not be retired safely: {reason}"); - } + cancellationToken.ThrowIfCancellationRequested(); + EnsurePhysicalIdentity(ownership, directory); + publication.RetirePinnedEmptyDirectoryFromNamespace( + Path.GetFileName(originalPath)); + return LibraryDirectoryRemovalOutcome.Removed; } private static void EnsurePhysicalIdentity( @@ -282,19 +110,4 @@ private static void EnsurePhysicalIdentity( } } - private static void RestorePinnedQuarantine( - PinnedDirectoryCreation pinnedDirectory, - string originalPath, - string quarantinePath) - { - if (File.Exists(originalPath) || Directory.Exists(originalPath)) - { - throw new InvalidOperationException( - "The original owned directory path was recreated while its quarantine was active."); - } - - using var restored = pinnedDirectory.RepublishPinnedDirectory( - Path.GetFileName(quarantinePath), - Path.GetFileName(originalPath)); - } } diff --git a/listenarr.infrastructure/Library/Moving/MoveExecutionStore.cs b/listenarr.infrastructure/Library/Moving/MoveExecutionStore.cs index 7932ccce6..5db6cf234 100644 --- a/listenarr.infrastructure/Library/Moving/MoveExecutionStore.cs +++ b/listenarr.infrastructure/Library/Moving/MoveExecutionStore.cs @@ -32,14 +32,13 @@ Task UpdateSourceDirectoryCleanupStateAsync( MoveJobEntryCleanupState cleanupState, CancellationToken cancellationToken); - Task ValidateOrAdoptIdentityAsync( + Task ValidateIdentityAsync( Guid jobId, string source, string target, FileSystemPathSemantics sourceSemantics, FileSystemPathSemantics targetSemantics, MoveLeaseToken leaseToken, - bool hasFilesystemRecoveryArtifacts, CancellationToken cancellationToken); Task EnsureMutationAuthorizedAsync( diff --git a/listenarr.infrastructure/Library/Moving/MoveFilesystemArtifactNames.cs b/listenarr.infrastructure/Library/Moving/MoveFilesystemArtifactNames.cs deleted file mode 100644 index f6c3f6af9..000000000 --- a/listenarr.infrastructure/Library/Moving/MoveFilesystemArtifactNames.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace Listenarr.Infrastructure.Library.Moving; - -internal static class MoveFilesystemArtifactNames -{ - public static bool IsReserved(string name) => - name.StartsWith(".listenarr-move-", StringComparison.Ordinal) - || name.StartsWith(".listenarr-quarantine-", StringComparison.Ordinal) - || name.StartsWith(".listenarr-temporary-directory-", StringComparison.Ordinal) - || string.Equals(name, ".listenarr-temp-owner.json", StringComparison.Ordinal) - || string.Equals(name, ".listenarr-quarantine-owner.json", StringComparison.Ordinal) - || string.Equals(name, LibraryDirectoryOwnershipMarker.FileName, StringComparison.Ordinal) - || name.StartsWith(".listenarr-directory-owner-", StringComparison.Ordinal) - && name.EndsWith(".json", StringComparison.Ordinal) - || name.Contains(".listenarr-", StringComparison.Ordinal) - && name.EndsWith(".partial", StringComparison.Ordinal); -} diff --git a/listenarr.infrastructure/Library/Moving/MoveJobProcessor.Core.cs b/listenarr.infrastructure/Library/Moving/MoveJobProcessor.Core.cs index 644ba9bc8..bf4b98e09 100644 --- a/listenarr.infrastructure/Library/Moving/MoveJobProcessor.Core.cs +++ b/listenarr.infrastructure/Library/Moving/MoveJobProcessor.Core.cs @@ -384,6 +384,10 @@ private async Task ExecuteFilesystemMoveAsync( await contentMoveService.EnsureMutationAuthorizedAsync( moveRequest, stoppingToken); + await contentMoveService.VerifyTargetBeforeMetadataRewriteAsync( + moveRequest, + moveResult, + stoppingToken); using (var rewriteScope = scopeFactory.CreateScope()) { diff --git a/listenarr.infrastructure/Library/Moving/MoveJobProcessor.Helpers.cs b/listenarr.infrastructure/Library/Moving/MoveJobProcessor.Helpers.cs index 060dec1f6..1aa6ed03a 100644 --- a/listenarr.infrastructure/Library/Moving/MoveJobProcessor.Helpers.cs +++ b/listenarr.infrastructure/Library/Moving/MoveJobProcessor.Helpers.cs @@ -178,7 +178,6 @@ await contentMoveService.CapturePublishedTargetPhysicalIdentitiesAsync( target, targetInsideSource, sourceInsideTarget, - Path.Join(target, $".listenarr-move-{job.Id:N}.pending"), SourceCleanupCompleted: true, targetPhysicalObjectIdentities)); } diff --git a/listenarr.infrastructure/Library/Moving/MoveSourceCompanionManifestBuilder.cs b/listenarr.infrastructure/Library/Moving/MoveSourceCompanionManifestBuilder.cs index 3239ce5ab..280b5d60d 100644 --- a/listenarr.infrastructure/Library/Moving/MoveSourceCompanionManifestBuilder.cs +++ b/listenarr.infrastructure/Library/Moving/MoveSourceCompanionManifestBuilder.cs @@ -247,11 +247,6 @@ private static async Task CaptureDirectoryAsync( foreach (var entryName in beforeNames) { cancellationToken.ThrowIfCancellationRequested(); - if (MoveFilesystemArtifactNames.IsReserved(entryName)) - { - continue; - } - var entryPath = Path.Join(current.FullPath, entryName); var attributes = File.GetAttributes(entryPath); if ((attributes & FileAttributes.ReparsePoint) != 0) diff --git a/listenarr.infrastructure/Library/Moving/PinnedLibraryDirectoryOwnershipMarker.Migration.cs b/listenarr.infrastructure/Library/Moving/PinnedLibraryDirectoryOwnershipMarker.Migration.cs deleted file mode 100644 index 944a560c7..000000000 --- a/listenarr.infrastructure/Library/Moving/PinnedLibraryDirectoryOwnershipMarker.Migration.cs +++ /dev/null @@ -1,241 +0,0 @@ -namespace Listenarr.Infrastructure.Library.Moving; - -internal static partial class PinnedLibraryDirectoryOwnershipMarker -{ - public static async Task PublishMigrationTargetAsync( - LibraryDirectoryOwnership source, - LibraryDirectoryOwnership target, - PinnedDirectoryCreation.PinnedDirectoryAnchor parent, - CancellationToken cancellationToken, - bool allowPublication = true) - { - ArgumentNullException.ThrowIfNull(source); - ArgumentNullException.ThrowIfNull(target); - ArgumentNullException.ThrowIfNull(parent); - using var publication = parent.OpenExistingChildForPublication( - Path.GetFileName(target.CanonicalPath)); - using var directory = publication.OpenCreatedDirectoryAnchor(); - if (!allowPublication) - { - ValidatePublishedMigrationTarget( - target, - directory, - parent); - return; - } - - var targetNativeIdentity = directory.GetDirectoryObjectIdentity(); - if (!ManagedDirectoryIdentity.Matches( - source.DirectoryObjectIdentityVersion, - source.DirectoryObjectIdentity, - source.OwnershipToken, - targetNativeIdentity)) - { - throw new InvalidOperationException( - "Metadata-only relocation cannot transfer destructive directory ownership to a different physical directory generation."); - } - - target.DirectoryObjectIdentityVersion = - ManagedDirectoryIdentity.CurrentVersion; - target.DirectoryObjectIdentity = ManagedDirectoryIdentity.Create( - target.OwnershipToken, - targetNativeIdentity); - target.DirectoryObjectIdentityUnavailableReason = null; - await PublishIdentityMigrationAsync( - source, - target, - directory, - parent, - cancellationToken); - } - - private static void ValidatePublishedMigrationTarget( - LibraryDirectoryOwnership target, - PinnedDirectoryCreation.PinnedDirectoryAnchor directory, - PinnedDirectoryCreation.PinnedDirectoryAnchor parent) - { - using var insideMarker = directory.OpenExistingFile( - LibraryDirectoryOwnershipMarker.FileName, - requireDeleteAccess: false); - using var siblingMarker = parent.OpenExistingFile( - $".listenarr-directory-owner-{target.OwnershipToken}.json", - requireDeleteAccess: false); - var insidePayload = - LibraryDirectoryOwnershipMarker.ReadPayload(insideMarker); - var siblingPayload = - LibraryDirectoryOwnershipMarker.ReadPayload(siblingMarker); - if (insidePayload.DirectoryObjectIdentityVersion - != ManagedDirectoryIdentity.CurrentVersion - || string.IsNullOrWhiteSpace( - insidePayload.DirectoryObjectIdentity) - || siblingPayload != insidePayload) - { - throw new InvalidOperationException( - "The published ownership migration markers do not identify one target generation."); - } - - target.DirectoryObjectIdentityVersion = - insidePayload.DirectoryObjectIdentityVersion; - target.DirectoryObjectIdentity = - insidePayload.DirectoryObjectIdentity; - target.DirectoryObjectIdentityUnavailableReason = null; - if (!LibraryDirectoryOwnershipMarker.MatchesCurrentPayload( - target, - insidePayload) - || !ManagedDirectoryIdentity.Matches( - target.DirectoryObjectIdentityVersion, - target.DirectoryObjectIdentity, - target.OwnershipToken, - directory.GetDirectoryObjectIdentity()) - || !insideMarker.VisiblePathMatches() - || !siblingMarker.VisiblePathMatches() - || !directory.VisiblePathMatches() - || !parent.VisiblePathMatches()) - { - throw new InvalidOperationException( - "The published ownership migration target no longer matches its enrolled directory generation."); - } - } - - internal static async Task PublishIdentityMigrationAsync( - LibraryDirectoryOwnership source, - LibraryDirectoryOwnership target, - PinnedDirectoryCreation.PinnedDirectoryAnchor directory, - PinnedDirectoryCreation.PinnedDirectoryAnchor parent, - CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(source); - ArgumentNullException.ThrowIfNull(target); - ArgumentNullException.ThrowIfNull(directory); - ArgumentNullException.ThrowIfNull(parent); - if (!ManagedDirectoryIdentity.Matches( - target.DirectoryObjectIdentityVersion, - target.DirectoryObjectIdentity, - target.OwnershipToken, - directory.GetDirectoryObjectIdentity()) - || !parent.VisiblePathMatches() - || !directory.VisiblePathMatches()) - { - throw new InvalidOperationException( - "The migrated ownership target does not match the persisted physical directory generation."); - } - - await PublishMigratedMarkerAsync( - source, - target, - directory, - LibraryDirectoryOwnershipMarker.FileName, - cancellationToken); - await PublishMigratedMarkerAsync( - source, - target, - parent, - $".listenarr-directory-owner-{target.OwnershipToken}.json", - cancellationToken); - directory.FlushDirectoryEntry(); - parent.FlushDirectoryEntry(); - if (!parent.VisiblePathMatches() || !directory.VisiblePathMatches()) - { - throw new InvalidOperationException( - "The migrated ownership target changed during marker publication."); - } - } - - private static async Task PublishMigratedMarkerAsync( - LibraryDirectoryOwnership source, - LibraryDirectoryOwnership target, - PinnedDirectoryCreation.PinnedDirectoryAnchor parent, - string fileName, - CancellationToken cancellationToken) - { - RecoverConditionalReplacement( - parent, - fileName, - payload => MatchesSourcePayload(source, payload), - payload => LibraryDirectoryOwnershipMarker.MatchesCurrentPayload( - target, - payload)); - using var existing = parent.TryOpenExistingFile( - fileName, - requireDeleteAccess: false); - if (existing != null) - { - var payload = LibraryDirectoryOwnershipMarker.ReadPayload(existing); - if (LibraryDirectoryOwnershipMarker.MatchesCurrentPayload( - target, - payload)) - { - RetireCompletedReplacementTemporary( - parent, - fileName + ".migration.tmp", - temporaryPayload => - LibraryDirectoryOwnershipMarker.MatchesCurrentPayload( - target, - temporaryPayload) - || MatchesSourcePayload(source, temporaryPayload)); - return; - } - if (!MatchesSourcePayload(source, payload)) - { - throw new InvalidOperationException( - "The ownership migration target contains an unrelated marker."); - } - } - - var temporaryName = fileName + ".migration.tmp"; - using var interrupted = parent.TryOpenExistingFile( - temporaryName, - requireDeleteAccess: true); - if (interrupted != null) - { - var interruptedPayload = - LibraryDirectoryOwnershipMarker.ReadPayload(interrupted); - if (!LibraryDirectoryOwnershipMarker.MatchesCurrentPayload( - target, - interruptedPayload)) - { - throw new InvalidOperationException( - "The ownership migration temporary marker is unrelated."); - } - - if (existing != null) - { - interrupted.ReplaceWithinParent(fileName, existing); - } - else - { - interrupted.MoveWithinParent(fileName); - } - return; - } - - using var temporary = parent.CreateNewFile( - temporaryName, - hiddenFile: true); - await using (var stream = temporary.OpenWriteStream( - bufferSize: 4096, - asynchronous: false)) - { - var bytes = System.Text.Encoding.UTF8.GetBytes( - LibraryDirectoryOwnershipMarker.SerializePayload(target)); - await stream.WriteAsync(bytes, cancellationToken); - await stream.FlushAsync(cancellationToken); - stream.Flush(flushToDisk: true); - } - - if (existing != null) - { - temporary.ReplaceWithinParent(fileName, existing); - } - else - { - temporary.MoveWithinParent(fileName); - } - } - - private static bool MatchesSourcePayload( - LibraryDirectoryOwnership source, - LibraryDirectoryOwnershipMarker.MarkerPayload payload) => - LibraryDirectoryOwnershipMarker.MatchesCurrentPayload(source, payload) - || LibraryDirectoryOwnershipMarker.MatchesLegacyPayload(source, payload); -} diff --git a/listenarr.infrastructure/Library/Moving/PinnedLibraryDirectoryOwnershipMarker.Recovery.cs b/listenarr.infrastructure/Library/Moving/PinnedLibraryDirectoryOwnershipMarker.Recovery.cs deleted file mode 100644 index f8218b564..000000000 --- a/listenarr.infrastructure/Library/Moving/PinnedLibraryDirectoryOwnershipMarker.Recovery.cs +++ /dev/null @@ -1,51 +0,0 @@ -namespace Listenarr.Infrastructure.Library.Moving; - -internal static partial class PinnedLibraryDirectoryOwnershipMarker -{ - private static void RecoverConditionalReplacement( - PinnedDirectoryCreation.PinnedDirectoryAnchor parent, - string fileName, - Func - isExpectedPredecessor, - Func - isPublishedGeneration) - { - var backupName = - PinnedDirectoryCreation.GetConditionalReplacementBackupName(fileName); - using var backup = parent.TryOpenExistingFile( - backupName, - requireDeleteAccess: true); - if (backup == null) - { - return; - } - - var backupPayload = LibraryDirectoryOwnershipMarker.ReadPayload(backup); - if (!isExpectedPredecessor(backupPayload)) - { - throw new InvalidOperationException( - "The conditional marker replacement backup is unrelated."); - } - - using var published = parent.TryOpenExistingFile( - fileName, - requireDeleteAccess: false); - if (published == null) - { - backup.MoveWithinParent(fileName); - parent.FlushDirectoryEntry(); - return; - } - - var publishedPayload = - LibraryDirectoryOwnershipMarker.ReadPayload(published); - if (!isPublishedGeneration(publishedPayload)) - { - throw new InvalidOperationException( - "The marker destination changed while a predecessor backup remained."); - } - - backup.Delete(immediateWindows: true); - parent.FlushDirectoryEntry(); - } -} diff --git a/listenarr.infrastructure/Library/Moving/PinnedLibraryDirectoryOwnershipMarker.cs b/listenarr.infrastructure/Library/Moving/PinnedLibraryDirectoryOwnershipMarker.cs deleted file mode 100644 index 864ad179c..000000000 --- a/listenarr.infrastructure/Library/Moving/PinnedLibraryDirectoryOwnershipMarker.cs +++ /dev/null @@ -1,314 +0,0 @@ -namespace Listenarr.Infrastructure.Library.Moving; - -internal static partial class PinnedLibraryDirectoryOwnershipMarker -{ - public static async Task EnsureAsync( - LibraryDirectoryOwnership ownership, - PinnedDirectoryCreation creation, - CancellationToken cancellationToken, - Action? afterInsideMarkerPublication = null) - { - ArgumentNullException.ThrowIfNull(ownership); - ArgumentNullException.ThrowIfNull(creation); - LibraryDirectoryOwnershipMarker.ValidateOwnershipToken(ownership.OwnershipToken); - if (!creation.Created || !creation.VisiblePathMatches()) - { - throw new InvalidOperationException( - "The pinned directory is no longer reachable through its validated pathname."); - } - - var payload = LibraryDirectoryOwnershipMarker.SerializePayload(ownership); - using var directory = creation.OpenCreatedDirectoryAnchor(); - using var parent = creation.OpenParentDirectoryAnchor(); - await EnsureMarkerAsync( - ownership, - directory, - LibraryDirectoryOwnershipMarker.FileName, - payload, - cancellationToken); - afterInsideMarkerPublication?.Invoke(); - await EnsureMarkerAsync( - ownership, - parent, - $".listenarr-directory-owner-{ownership.OwnershipToken}.json", - payload, - cancellationToken); - - if (!creation.VisiblePathMatches() - || !directory.VisiblePathMatches() - || !parent.VisiblePathMatches()) - { - throw new InvalidOperationException( - "The pinned directory pathname changed during ownership publication."); - } - } - - private static async Task EnsureMarkerAsync( - LibraryDirectoryOwnership ownership, - PinnedDirectoryCreation.PinnedDirectoryAnchor parent, - string fileName, - string payload, - CancellationToken cancellationToken) - { - using var existing = parent.TryOpenExistingFile( - fileName, - requireDeleteAccess: false); - if (existing != null) - { - LibraryDirectoryOwnershipMarker.ValidateMarkerFile(ownership, existing); - return; - } - - var temporaryName = fileName + ".v2.tmp"; - using var interruptedTemporary = parent.TryOpenExistingFile( - temporaryName, - requireDeleteAccess: true); - if (interruptedTemporary != null) - { - var interruptedPayload = - LibraryDirectoryOwnershipMarker.ReadPayload(interruptedTemporary); - if (!LibraryDirectoryOwnershipMarker.MatchesCurrentPayload( - ownership, - interruptedPayload)) - { - throw new InvalidOperationException( - "A durable ownership marker temporary file is stale or mismatched."); - } - - interruptedTemporary.MoveWithinParent(fileName); - ValidateExistingMarker(ownership, parent, fileName); - return; - } - - try - { - await parent.PublishNewFileAsync( - temporaryName, - fileName, - () => - { - cancellationToken.ThrowIfCancellationRequested(); - return Task.CompletedTask; - }, - async stream => - { - var bytes = System.Text.Encoding.UTF8.GetBytes(payload); - await stream.WriteAsync(bytes, cancellationToken); - await stream.FlushAsync(cancellationToken); - stream.Flush(flushToDisk: true); - }, - () => - { - cancellationToken.ThrowIfCancellationRequested(); - return Task.CompletedTask; - }, - _ => false); - } - catch (Exception exception) when ( - (exception is IOException - or InvalidOperationException - or System.ComponentModel.Win32Exception) - && parent.TryOpenExistingFile(fileName, requireDeleteAccess: false) is { } published) - { - using (published) - { - LibraryDirectoryOwnershipMarker.ValidateMarkerFile( - ownership, - published); - } - return; - } - - ValidateExistingMarker(ownership, parent, fileName); - } - - internal static async Task ReconcileAsync( - LibraryDirectoryOwnership ownership, - PinnedDirectoryCreation.PinnedDirectoryAnchor directory, - PinnedDirectoryCreation.PinnedDirectoryAnchor parent, - CancellationToken cancellationToken) - { - await ReconcileMarkerAsync( - ownership, - directory, - LibraryDirectoryOwnershipMarker.FileName, - cancellationToken); - await ReconcileMarkerAsync( - ownership, - parent, - $".listenarr-directory-owner-{ownership.OwnershipToken}.json", - cancellationToken); - } - - private static async Task ReconcileMarkerAsync( - LibraryDirectoryOwnership ownership, - PinnedDirectoryCreation.PinnedDirectoryAnchor parent, - string fileName, - CancellationToken cancellationToken) - { - RecoverConditionalReplacement( - parent, - fileName, - payload => LibraryDirectoryOwnershipMarker.MatchesLegacyPayload( - ownership, - payload), - payload => LibraryDirectoryOwnershipMarker.MatchesCurrentPayload( - ownership, - payload)); - var existing = parent.TryOpenExistingFile( - fileName, - requireDeleteAccess: false); - if (existing == null) - { - await EnsureMarkerAsync( - ownership, - parent, - fileName, - LibraryDirectoryOwnershipMarker.SerializePayload(ownership), - cancellationToken); - return; - } - - existing.Dispose(); - await UpgradeLegacyMarkerAsync( - ownership, - parent, - fileName, - cancellationToken); - } - - private static async Task UpgradeLegacyMarkerAsync( - LibraryDirectoryOwnership ownership, - PinnedDirectoryCreation.PinnedDirectoryAnchor parent, - string fileName, - CancellationToken cancellationToken) - { - RecoverConditionalReplacement( - parent, - fileName, - payload => LibraryDirectoryOwnershipMarker.MatchesLegacyPayload( - ownership, - payload), - payload => LibraryDirectoryOwnershipMarker.MatchesCurrentPayload( - ownership, - payload)); - var temporaryName = fileName + ".v2.tmp"; - using var predecessor = parent.TryOpenExistingFile( - fileName, - requireDeleteAccess: false) - ?? throw new InvalidOperationException( - "The ownership marker predecessor is missing."); - var predecessorPayload = LibraryDirectoryOwnershipMarker.ReadPayload(predecessor); - using var existingTemporary = parent.TryOpenExistingFile( - temporaryName, - requireDeleteAccess: true); - if (existingTemporary != null) - { - var temporaryPayload = - LibraryDirectoryOwnershipMarker.ReadPayload(existingTemporary); - if (LibraryDirectoryOwnershipMarker.MatchesCurrentPayload( - ownership, - predecessorPayload)) - { - if (!LibraryDirectoryOwnershipMarker.MatchesCurrentPayload( - ownership, - temporaryPayload) - && !LibraryDirectoryOwnershipMarker.MatchesLegacyPayload( - ownership, - temporaryPayload)) - { - throw new InvalidOperationException( - "The completed ownership marker replacement left an unrelated temporary file."); - } - - existingTemporary.Delete(); - parent.FlushDirectoryEntry(); - return; - } - if (!LibraryDirectoryOwnershipMarker.MatchesCurrentPayload( - ownership, - temporaryPayload)) - { - throw new InvalidOperationException( - "The ownership marker temporary file is stale or mismatched."); - } - if (!LibraryDirectoryOwnershipMarker.MatchesLegacyPayload( - ownership, - predecessorPayload)) - { - throw new InvalidOperationException( - "The ownership marker predecessor is not the expected legacy marker."); - } - - existingTemporary.ReplaceWithinParent(fileName, predecessor); - return; - } - - if (LibraryDirectoryOwnershipMarker.MatchesCurrentPayload( - ownership, - predecessorPayload)) - { - return; - } - if (!LibraryDirectoryOwnershipMarker.MatchesLegacyPayload( - ownership, - predecessorPayload)) - { - throw new InvalidOperationException( - "The ownership marker predecessor is not upgradeable."); - } - - using var temporary = parent.CreateNewFile(temporaryName, hiddenFile: true); - await using (var stream = temporary.OpenWriteStream( - bufferSize: 4096, - asynchronous: false)) - { - var bytes = System.Text.Encoding.UTF8.GetBytes( - LibraryDirectoryOwnershipMarker.SerializePayload(ownership)); - await stream.WriteAsync(bytes, cancellationToken); - await stream.FlushAsync(cancellationToken); - stream.Flush(flushToDisk: true); - } - temporary.ReplaceWithinParent(fileName, predecessor); - } - - private static void RetireCompletedReplacementTemporary( - PinnedDirectoryCreation.PinnedDirectoryAnchor parent, - string temporaryName, - Func isExpected) - { - using var temporary = parent.TryOpenExistingFile( - temporaryName, - requireDeleteAccess: true); - if (temporary == null) - { - return; - } - - var payload = LibraryDirectoryOwnershipMarker.ReadPayload(temporary); - if (!isExpected(payload)) - { - throw new InvalidOperationException( - "The completed ownership marker replacement left an unrelated temporary file."); - } - - temporary.Delete(); - parent.FlushDirectoryEntry(); - } - - private static void ValidateExistingMarker( - LibraryDirectoryOwnership ownership, - PinnedDirectoryCreation.PinnedDirectoryAnchor parent, - string fileName) - { - using var marker = parent.OpenExistingFile( - fileName, - requireDeleteAccess: false); - LibraryDirectoryOwnershipMarker.ValidateMarkerFile(ownership, marker); - if (!parent.VisiblePathMatches() || !marker.VisiblePathMatches()) - { - throw new InvalidOperationException( - "The durable ownership marker changed during pinned validation."); - } - } -} diff --git a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.MetadataStart.cs b/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.MetadataStart.cs index ced9bd8c2..8b580d7cb 100644 --- a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.MetadataStart.cs +++ b/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.MetadataStart.cs @@ -1,7 +1,6 @@ using Listenarr.Domain.Common; using Listenarr.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Storage; namespace Listenarr.Infrastructure.Library.Moving; @@ -13,7 +12,13 @@ internal Action? AfterMetadataOnlyJournalCommitForTest set; } - internal Action? AfterMetadataOnlyCommitForTest + internal Action? BeforeMetadataOnlyAtomicCommitForTest + { + get; + set; + } + + internal Action? AfterMetadataOnlyAtomicCommitForTest { get; set; @@ -21,7 +26,7 @@ internal Action? AfterMetadataOnlyCommitForTest private async Task StartMetadataOnlyAsync( ListenArrDbContext db, - IDbContextTransaction transaction, + Microsoft.EntityFrameworkCore.Storage.IDbContextTransaction transaction, RootFolder root, RootFolderPathChangeCommand command, string targetPath, @@ -139,35 +144,20 @@ private async Task StartMetadataOnlyAsync( cancellationToken); await transaction.CommitAsync(completionToken); AfterMetadataOnlyJournalCommitForTest?.Invoke(); + PinnedDirectoryCreation.PinnedDirectoryAnchor? targetGenerationLease = null; + IReadOnlyList ownershipGenerationLeases = []; try { if (targetObjectIdentity.IsAvailable) { - await RequireTargetDirectoryGenerationAsync( + targetGenerationLease = PinTargetDirectoryGeneration( targetPath, - targetObjectIdentity, + targetObjectIdentity.Version, + targetObjectIdentity.Value, + targetObjectIdentity.UnavailableReason, completionToken); } - ValidateMarkerlessOwnershipMigrationTargets( - ownershipPlans, - targetPath, - completionToken); - foreach (var plan in ownershipPlans) - { - plan.Journal.State = - LibraryDirectoryOwnershipPathMigrationState - .TargetValidated; - plan.Journal.UpdatedAt = DateTime.UtcNow; - } - await db.SaveChangesAsync(completionToken); - if (targetObjectIdentity.IsAvailable) - { - await RequireTargetDirectoryGenerationAsync( - targetPath, - targetObjectIdentity, - completionToken); - } - ValidateMarkerlessOwnershipMigrationTargets( + ownershipGenerationLeases = PinOwnershipMigrationTargets( ownershipPlans, targetPath, completionToken); @@ -175,163 +165,140 @@ await RequireTargetDirectoryGenerationAsync( catch (Exception exception) when (exception is not ( OutOfMemoryException or StackOverflowException)) { + DisposeOwnershipMigrationTargetLeases(ownershipGenerationLeases); + targetGenerationLease?.Dispose(); metadataRelocation.Status = RootFolderRelocationStatus.NeedsAttention; metadataRelocation.Error = - $"Directory ownership marker migration requires attention: {exception.Message}"; + $"Directory ownership migration requires attention: {exception.Message}"; metadataRelocation.UpdatedAt = timeProvider.GetUtcNow().UtcDateTime; await db.SaveChangesAsync(completionToken); throw; } - await using var metadataTransaction = - await db.Database.BeginTransactionAsync(completionToken); - foreach (var plan in metadataPlans) - { - AudiobookPathReferenceRewriter.Rewrite( - plan.Candidate.Audiobook, - plan.Candidate.StoredBasePath, - plan.Destination, - metadataSourceSemantics!.Value, - targetResolution.Semantics, - command.TargetCaseSensitivityMode); - completed++; - } - - RejectDuplicateAudiobookFileOwnership(db); - ApplyOwnershipMigrationMetadata(ownershipPlans, nowUtc); - await db.SaveChangesAsync(completionToken); - AssignOwnershipMigrationKeys( - ownershipPlans, - nowUtc, - LibraryDirectoryOwnershipPathMigrationState - .MarkerlessCommitted); - ApplyRootMetadata( - root, - command, - targetPath, - targetResolution, - targetIdentityKey); - ApplyRootDirectoryObjectIdentity(root, targetObjectIdentity); - if (command.DesiredIsDefault) - { - await ClearOtherDefaultsAsync( - db, - rootFolderId, - completionToken); - } - - metadataRelocation.CompletedJobs = completed; - metadataRelocation.Status = skipped.Count > 0 - ? RootFolderRelocationStatus.NeedsAttention - : RootFolderRelocationStatus.Completed; - metadataRelocation.ActiveRootFolderId = - skipped.Count > 0 ? root.Id : null; - metadataRelocation.CompletedAt = - skipped.Count > 0 ? null : nowUtc; - metadataRelocation.Error = skipped.Count > 0 - ? BuildSkippedMetadataError(skipped.Count) - : null; - metadataRelocation.TargetIdentityEnrollmentState = - skipped.Count > 0 - ? metadataRelocation.TargetIdentityEnrollmentState - : TargetIdentityEnrollmentState.NotRequired; - metadataRelocation.UpdatedAt = nowUtc; - await db.SaveChangesAsync(completionToken); - await metadataTransaction.CommitAsync(CancellationToken.None); - try { - AfterMetadataOnlyCommitForTest?.Invoke(); - if (targetObjectIdentity.IsAvailable) + await using var metadataTransaction = + await db.Database.BeginTransactionAsync(completionToken); + foreach (var plan in metadataPlans) { - await RequireTargetDirectoryGenerationAsync( - targetPath, - targetObjectIdentity, - CancellationToken.None); + AudiobookPathReferenceRewriter.Rewrite( + plan.Candidate.Audiobook, + plan.Candidate.StoredBasePath, + plan.Destination, + metadataSourceSemantics!.Value, + targetResolution.Semantics, + command.TargetCaseSensitivityMode); + completed++; } - ValidateMarkerlessOwnershipMigrationTargets( + + RejectDuplicateAudiobookFileOwnership(db); + ApplyOwnershipMigrationMetadata(ownershipPlans, nowUtc); + await db.SaveChangesAsync(completionToken); + AssignOwnershipMigrationKeys( ownershipPlans, + nowUtc); + ApplyRootMetadata( + root, + command, targetPath, - CancellationToken.None); - TryRetireMarkerlessOwnershipMigrationSourceArtifacts( - ownershipPlans, - sourcePath, - CancellationToken.None); - foreach (var plan in ownershipPlans) + targetResolution, + targetIdentityKey); + ApplyRootDirectoryObjectIdentity(root, targetObjectIdentity); + if (command.DesiredIsDefault) { - plan.Journal.State = - LibraryDirectoryOwnershipPathMigrationState - .MarkerlessRetired; - plan.Journal.UpdatedAt = DateTime.UtcNow; + await ClearOtherDefaultsAsync( + db, + rootFolderId, + completionToken); } - await db.SaveChangesAsync(CancellationToken.None); - await RetireOwnershipMigrationTargetsAsync( - ownershipPlans, - targetPath, - targetObjectIdentity.Version, - targetObjectIdentity.Value, - targetObjectIdentity.UnavailableReason, - CancellationToken.None); + + metadataRelocation.CompletedJobs = completed; + metadataRelocation.Status = skipped.Count > 0 + ? RootFolderRelocationStatus.NeedsAttention + : RootFolderRelocationStatus.Completed; + metadataRelocation.ActiveRootFolderId = + skipped.Count > 0 ? root.Id : null; + metadataRelocation.CompletedAt = + skipped.Count > 0 ? null : nowUtc; + metadataRelocation.Error = skipped.Count > 0 + ? BuildSkippedMetadataError(skipped.Count) + : null; + metadataRelocation.TargetIdentityEnrollmentState = + skipped.Count > 0 + ? metadataRelocation.TargetIdentityEnrollmentState + : TargetIdentityEnrollmentState.NotRequired; + metadataRelocation.UpdatedAt = nowUtc; db.LibraryDirectoryOwnershipPathMigrations.RemoveRange( ownershipPlans.Select(plan => plan.Journal)); var completedWithoutAttention = skipped.Count == 0; - if (completedWithoutAttention) - { - db.RootFolderRelocations.Remove(metadataRelocation); - } - await db.SaveChangesAsync(CancellationToken.None); var metadataResult = new RootFolderPathChangeResult( completedWithoutAttention ? null : metadataRelocation.Id, root.Id, - root.Path, + targetPath, targetPath, metadataRelocation.Status, metadataTotal, completed, metadataRelocation.Error, metadataRelocation.TargetIdentityEnrollmentState); + await db.SaveChangesAsync(completionToken); + BeforeMetadataOnlyAtomicCommitForTest?.Invoke(); + if (targetGenerationLease != null) + { + RevalidatePinnedTargetDirectoryGeneration( + targetGenerationLease, + targetObjectIdentity.Version, + targetObjectIdentity.Value, + targetObjectIdentity.UnavailableReason, + completionToken); + } + RevalidateOwnershipMigrationTargetLeases( + ownershipGenerationLeases, + completionToken); + await metadataTransaction.CommitAsync(CancellationToken.None); + AfterMetadataOnlyAtomicCommitForTest?.Invoke(); + if (targetGenerationLease != null) + { + RevalidatePinnedTargetDirectoryGeneration( + targetGenerationLease, + targetObjectIdentity.Version, + targetObjectIdentity.Value, + targetObjectIdentity.UnavailableReason, + CancellationToken.None); + } + RevalidateOwnershipMigrationTargetLeases( + ownershipGenerationLeases, + CancellationToken.None); + if (completedWithoutAttention) + { + db.RootFolderRelocations.Remove(metadataRelocation); + await db.SaveChangesAsync(CancellationToken.None); + } return new StartOutcome(metadataResult, true); } catch (Exception exception) when (exception is not ( OutOfMemoryException or StackOverflowException)) { - return await PersistMetadataOnlyPostCommitAttentionAsync( - metadataRelocation.Id, - root.Id, - exception, - CancellationToken.None); + db.ChangeTracker.Clear(); + var persistedRelocation = await db.RootFolderRelocations + .SingleAsync( + candidate => candidate.Id == metadataRelocation.Id, + CancellationToken.None); + persistedRelocation.Status = RootFolderRelocationStatus.NeedsAttention; + persistedRelocation.ActiveRootFolderId = rootFolderId; + persistedRelocation.CompletedAt = null; + persistedRelocation.Error = + $"Directory ownership migration completion requires attention: {exception.Message}"; + persistedRelocation.UpdatedAt = timeProvider.GetUtcNow().UtcDateTime; + await db.SaveChangesAsync(CancellationToken.None); + throw; + } + finally + { + DisposeOwnershipMigrationTargetLeases(ownershipGenerationLeases); + targetGenerationLease?.Dispose(); } - } - - private async Task - PersistMetadataOnlyPostCommitAttentionAsync( - Guid relocationId, - int rootFolderId, - Exception exception, - CancellationToken cancellationToken) - { - await using var recoveryDb = - await dbContextFactory.CreateDbContextAsync(cancellationToken); - var relocation = await recoveryDb.RootFolderRelocations - .SingleAsync( - candidate => candidate.Id == relocationId, - cancellationToken); - relocation.Status = RootFolderRelocationStatus.NeedsAttention; - relocation.ActiveRootFolderId = rootFolderId; - relocation.CompletedAt = null; - relocation.Error = - $"Directory ownership migration cleanup requires attention: {exception.Message}"; - relocation.UpdatedAt = timeProvider.GetUtcNow().UtcDateTime; - await recoveryDb.SaveChangesAsync(cancellationToken); - var currentPath = await recoveryDb.RootFolders - .AsNoTracking() - .Where(root => root.Id == rootFolderId) - .Select(root => root.Path) - .SingleAsync(cancellationToken); - return new StartOutcome( - Map(relocation, currentPath), - Broadcast: true); } } diff --git a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.OwnershipMigration.cs b/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.OwnershipMigration.cs index aa8985c8b..e3981f472 100644 --- a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.OwnershipMigration.cs +++ b/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.OwnershipMigration.cs @@ -12,6 +12,68 @@ private sealed record OwnershipMigrationPlan( LibraryDirectoryOwnership Target, LibraryDirectoryOwnershipPathMigration Journal); + private sealed class OwnershipMigrationTargetLease : IDisposable + { + private readonly OwnershipMigrationPlan _plan; + private readonly PinnedDirectoryCreation.PinnedDirectoryAnchor _parent; + private readonly PinnedDirectoryCreation.PinnedDirectoryAnchor _directory; + + public OwnershipMigrationTargetLease( + OwnershipMigrationPlan plan, + string targetBoundary) + { + _plan = plan; + var targetParentPath = Path.GetDirectoryName( + plan.Target.CanonicalPath) + ?? throw new InvalidOperationException( + "The migrated ownership target has no parent directory."); + _parent = OpenDirectoryParentWithinBoundary( + targetBoundary, + targetParentPath, + plan.Target.GetIdentity().Semantics); + try + { + _directory = _parent.OpenExistingChild( + Path.GetFileName(plan.Target.CanonicalPath)); + ValidateAndCapture(); + } + catch + { + _parent.Dispose(); + throw; + } + } + + public void ValidateAndCapture() + { + var nativeIdentity = _directory.GetDirectoryObjectIdentity(); + if (!ManagedDirectoryIdentity.Matches( + _plan.Source.DirectoryObjectIdentityVersion, + _plan.Source.DirectoryObjectIdentity, + _plan.Source.OwnershipToken, + nativeIdentity) + || !_directory.VisiblePathMatches() + || !_parent.VisiblePathMatches()) + { + throw new InvalidOperationException( + "Metadata-only relocation cannot transfer directory ownership to a different physical generation."); + } + + _plan.Target.DirectoryObjectIdentityVersion = + ManagedDirectoryIdentity.CurrentVersion; + _plan.Target.DirectoryObjectIdentity = ManagedDirectoryIdentity.Create( + _plan.Target.OwnershipToken, + nativeIdentity); + _plan.Target.DirectoryObjectIdentityUnavailableReason = null; + } + + public void Dispose() + { + _directory.Dispose(); + _parent.Dispose(); + } + } + private sealed record MetadataRewriteSnapshot( Audiobook Audiobook, string? BasePath, @@ -166,8 +228,6 @@ private async Task> TargetIdentityLookupKey = target.PathIdentityLookupKey, TargetOwnershipKey = target.PathOwnershipKey!, - State = - LibraryDirectoryOwnershipPathMigrationState.Prepared, CreatedAt = now, UpdatedAt = now }; @@ -208,70 +268,48 @@ private async Task> return plans; } - private static void ValidateMarkerlessOwnershipMigrationTargets( - IReadOnlyList plans, - string targetBoundary, - CancellationToken cancellationToken) + private static IReadOnlyList + PinOwnershipMigrationTargets( + IReadOnlyList plans, + string targetBoundary, + CancellationToken cancellationToken) { - foreach (var plan in plans) + var leases = new List(plans.Count); + try { - cancellationToken.ThrowIfCancellationRequested(); - var targetParentPath = Path.GetDirectoryName( - plan.Target.CanonicalPath) - ?? throw new InvalidOperationException( - "The migrated ownership target has no parent directory."); - using var targetParent = OpenMarkerParentWithinBoundary( - targetBoundary, - targetParentPath, - plan.Target.GetIdentity().Semantics); - using var directory = targetParent.OpenExistingChild( - Path.GetFileName(plan.Target.CanonicalPath)); - var nativeIdentity = directory.GetDirectoryObjectIdentity(); - if (!ManagedDirectoryIdentity.Matches( - plan.Source.DirectoryObjectIdentityVersion, - plan.Source.DirectoryObjectIdentity, - plan.Source.OwnershipToken, - nativeIdentity) - || !directory.VisiblePathMatches() - || !targetParent.VisiblePathMatches()) + foreach (var plan in plans) { - throw new InvalidOperationException( - "Metadata-only relocation cannot transfer directory ownership to a different physical generation."); + cancellationToken.ThrowIfCancellationRequested(); + leases.Add(new OwnershipMigrationTargetLease( + plan, + targetBoundary)); } - - plan.Target.DirectoryObjectIdentityVersion = - ManagedDirectoryIdentity.CurrentVersion; - plan.Target.DirectoryObjectIdentity = ManagedDirectoryIdentity.Create( - plan.Target.OwnershipToken, - nativeIdentity); - plan.Target.DirectoryObjectIdentityUnavailableReason = null; + return leases; + } + catch + { + DisposeOwnershipMigrationTargetLeases(leases); + throw; } } - private static async Task PublishOwnershipMigrationTargetsAsync( - IReadOnlyList plans, - string targetBoundary, - CancellationToken cancellationToken, - bool allowPublication = true) + private static void RevalidateOwnershipMigrationTargetLeases( + IReadOnlyList leases, + CancellationToken cancellationToken) { - foreach (var plan in plans) + foreach (var lease in leases) { cancellationToken.ThrowIfCancellationRequested(); - var targetParentPath = Path.GetDirectoryName( - plan.Target.CanonicalPath) - ?? throw new InvalidOperationException( - "The migrated ownership target has no parent directory."); - using var targetParent = OpenMarkerParentWithinBoundary( - targetBoundary, - targetParentPath, - plan.Target.GetIdentity().Semantics); - await PinnedLibraryDirectoryOwnershipMarker - .PublishMigrationTargetAsync( - plan.Source, - plan.Target, - targetParent, - cancellationToken, - allowPublication); + lease.ValidateAndCapture(); + } + } + + private static void DisposeOwnershipMigrationTargetLeases( + IEnumerable leases) + { + foreach (var lease in leases.Reverse()) + { + lease.Dispose(); } } @@ -313,15 +351,12 @@ private static void ApplyOwnershipMigrationMetadata( private static void AssignOwnershipMigrationKeys( IReadOnlyList plans, - DateTime now, - LibraryDirectoryOwnershipPathMigrationState committedState = - LibraryDirectoryOwnershipPathMigrationState.MetadataCommitted) + DateTime now) { foreach (var plan in plans) { plan.Tracked.PathOwnershipKey = plan.Target.PathOwnershipKey; - plan.Journal.State = committedState; plan.Journal.UpdatedAt = now; } } @@ -357,4 +392,64 @@ private static LibraryDirectoryOwnership SnapshotOwnership( CreatedAt = source.CreatedAt, UpdatedAt = source.UpdatedAt }; + + private static PinnedDirectoryCreation.PinnedDirectoryAnchor + OpenDirectoryParentWithinBoundary( + string boundaryPath, + string parentPath, + FileSystemPathSemantics semantics) + { + var canonicalBoundary = FileSystemPathIdentity.Canonicalize( + boundaryPath, + semantics.Syntax); + var canonicalParent = FileSystemPathIdentity.Canonicalize( + parentPath, + semantics.Syntax); + if (!FileSystemPathIdentity.IsSameOrInside( + canonicalParent, + canonicalBoundary, + semantics)) + { + throw new InvalidOperationException( + "An ownership migration directory escaped its authorized root boundary."); + } + + var current = PinnedDirectoryCreation.OpenPinnedBoundary( + canonicalBoundary); + try + { + if (FileSystemPathIdentity.AreEquivalent( + canonicalParent, + canonicalBoundary, + semantics)) + { + return current; + } + + var relative = Path.GetRelativePath( + canonicalBoundary, + canonicalParent); + foreach (var segment in relative.Split( + [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], + StringSplitOptions.RemoveEmptyEntries)) + { + if (segment is "." or "..") + { + throw new InvalidOperationException( + "An ownership migration directory contains navigation segments."); + } + + var next = current.OpenExistingChild(segment); + current.Dispose(); + current = next; + } + + return current; + } + catch + { + current.Dispose(); + throw; + } + } } diff --git a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.OwnershipMigrationArtifactCleanup.cs b/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.OwnershipMigrationArtifactCleanup.cs deleted file mode 100644 index e38d41677..000000000 --- a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.OwnershipMigrationArtifactCleanup.cs +++ /dev/null @@ -1,53 +0,0 @@ -namespace Listenarr.Infrastructure.Library.Moving; - -public sealed partial class RootFolderRelocationService -{ - private static async Task RetireOwnershipMigrationTargetsAsync( - IReadOnlyList plans, - string targetBoundary, - int? targetIdentityVersion, - string? targetIdentityValue, - string? targetIdentityUnavailableReason, - CancellationToken cancellationToken) - { - foreach (var plan in plans) - { - cancellationToken.ThrowIfCancellationRequested(); - var targetParentPath = Path.GetDirectoryName(plan.Target.CanonicalPath) - ?? throw new InvalidOperationException( - "The ownership migration target has no parent directory."); - using var targetParent = await OpenVerifiedMarkerParentWithinBoundaryAsync( - targetBoundary, - targetParentPath, - plan.Target.GetIdentity().Semantics, - targetIdentityVersion, - targetIdentityValue, - targetIdentityUnavailableReason, - cancellationToken); - using var targetDirectory = targetParent.OpenExistingChild( - Path.GetFileName(plan.Target.CanonicalPath)); - if (!ManagedDirectoryIdentity.Matches( - plan.Target.DirectoryObjectIdentityVersion, - plan.Target.DirectoryObjectIdentity, - plan.Target.OwnershipToken, - targetDirectory.GetDirectoryObjectIdentity()) - || !targetDirectory.VisiblePathMatches() - || !targetParent.VisiblePathMatches()) - { - throw new InvalidOperationException( - "The ownership migration target changed before temporary artifact cleanup."); - } - - if (!LibraryDirectoryOwnershipMarker.TryRetireMigrationArtifacts( - plan.Source, - plan.Target, - targetDirectory, - targetParent, - out var reason)) - { - throw new InvalidOperationException( - $"Temporary ownership migration artifacts could not be retired safely: {reason}"); - } - } - } -} diff --git a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.OwnershipMigrationRecovery.cs b/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.OwnershipMigrationRecovery.cs index 2fb466439..214f9b0a7 100644 --- a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.OwnershipMigrationRecovery.cs +++ b/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.OwnershipMigrationRecovery.cs @@ -12,6 +12,18 @@ internal Action? BeforeOwnershipMigrationMetadataSaveForTest set; } + internal Action? BeforeOwnershipMigrationAtomicCommitForTest + { + get; + set; + } + + internal Action? AfterOwnershipMigrationAtomicCommitForTest + { + get; + set; + } + private async Task> ReconcileOwnershipPathMigrationsAsync( CancellationToken cancellationToken, @@ -51,153 +63,11 @@ private async Task> var plans = RehydrateOwnershipMigrationPlans(relocation); try { - await RequireTargetDirectoryGenerationAsync( - relocation.TargetPath, - relocation.TargetDirectoryObjectIdentityVersion, - relocation.TargetDirectoryObjectIdentity, - relocation.TargetDirectoryObjectIdentityUnavailableReason, + await CompleteOwnershipMigrationMetadataAsync( + db, + relocation, + plans, cancellationToken); - var preparedPlans = plans - .Where(plan => plan.Journal.State - == LibraryDirectoryOwnershipPathMigrationState.Prepared) - .ToList(); - if (preparedPlans.Count > 0) - { - ValidateMarkerlessOwnershipMigrationTargets( - preparedPlans, - relocation.TargetPath, - cancellationToken); - foreach (var plan in preparedPlans) - { - plan.Journal.State = - LibraryDirectoryOwnershipPathMigrationState - .TargetValidated; - plan.Journal.UpdatedAt = - timeProvider.GetUtcNow().UtcDateTime; - } - await db.SaveChangesAsync(cancellationToken); - } - - var publishedPlans = plans - .Where(plan => plan.Journal.State - == LibraryDirectoryOwnershipPathMigrationState - .MarkersPublished) - .ToList(); - if (publishedPlans.Count > 0) - { - // Existing MarkersPublished rows can only have been created by - // the legacy sidecar protocol. Re-prove those artifacts without - // publishing any new files before completing their old journal. - await PublishOwnershipMigrationTargetsAsync( - publishedPlans, - relocation.TargetPath, - cancellationToken, - allowPublication: false); - await CompleteOwnershipMigrationMetadataAsync( - db, - relocation, - plans, - cancellationToken); - } - - var markerlessValidatedPlans = plans - .Where(plan => plan.Journal.State - == LibraryDirectoryOwnershipPathMigrationState - .TargetValidated) - .ToList(); - if (markerlessValidatedPlans.Count > 0) - { - ValidateMarkerlessOwnershipMigrationTargets( - markerlessValidatedPlans, - relocation.TargetPath, - cancellationToken); - await CompleteOwnershipMigrationMetadataAsync( - db, - relocation, - plans, - cancellationToken, - LibraryDirectoryOwnershipPathMigrationState - .MarkerlessCommitted); - } - - if (plans.All(plan => - plan.Journal.State - == LibraryDirectoryOwnershipPathMigrationState - .MetadataCommitted)) - { - await PublishOwnershipMigrationTargetsAsync( - plans, - relocation.TargetPath, - CancellationToken.None, - allowPublication: false); - await RetireOwnershipMigrationSourcesAsync( - plans, - relocation.SourcePath, - relocation.TargetPath, - relocation.TargetDirectoryObjectIdentityVersion, - relocation.TargetDirectoryObjectIdentity, - relocation.TargetDirectoryObjectIdentityUnavailableReason, - CancellationToken.None); - foreach (var plan in plans) - { - plan.Journal.State = - LibraryDirectoryOwnershipPathMigrationState.SourceMarkersRetired; - plan.Journal.UpdatedAt = - timeProvider.GetUtcNow().UtcDateTime; - } - await db.SaveChangesAsync(CancellationToken.None); - } - - if (plans.All(plan => - plan.Journal.State - == LibraryDirectoryOwnershipPathMigrationState - .MarkerlessCommitted)) - { - await RequireTargetDirectoryGenerationAsync( - relocation.TargetPath, - relocation.TargetDirectoryObjectIdentityVersion, - relocation.TargetDirectoryObjectIdentity, - relocation.TargetDirectoryObjectIdentityUnavailableReason, - CancellationToken.None); - ValidateMarkerlessOwnershipMigrationTargets( - plans, - relocation.TargetPath, - CancellationToken.None); - TryRetireMarkerlessOwnershipMigrationSourceArtifacts( - plans, - relocation.SourcePath, - CancellationToken.None); - foreach (var plan in plans) - { - plan.Journal.State = - LibraryDirectoryOwnershipPathMigrationState - .MarkerlessRetired; - plan.Journal.UpdatedAt = - timeProvider.GetUtcNow().UtcDateTime; - } - await db.SaveChangesAsync(CancellationToken.None); - } - - if (plans.All(plan => - plan.Journal.State is - LibraryDirectoryOwnershipPathMigrationState.SourceMarkersRetired - or LibraryDirectoryOwnershipPathMigrationState - .MarkerlessRetired)) - { - await RetireOwnershipMigrationTargetsAsync( - plans, - relocation.TargetPath, - relocation.TargetDirectoryObjectIdentityVersion, - relocation.TargetDirectoryObjectIdentity, - relocation.TargetDirectoryObjectIdentityUnavailableReason, - CancellationToken.None); - db.LibraryDirectoryOwnershipPathMigrations - .RemoveRange(plans.Select(plan => plan.Journal)); - FinalizeRecoveredMetadataOnlyRelocation( - relocation, - timeProvider.GetUtcNow().UtcDateTime); - await db.SaveChangesAsync(CancellationToken.None); - } } catch (Exception exception) when (exception is not ( OperationCanceledException or OutOfMemoryException @@ -238,39 +108,11 @@ OperationCanceledException or OutOfMemoryException return results; } - private static void FinalizeRecoveredMetadataOnlyRelocation( - RootFolderRelocation relocation, - DateTime now) - { - if (relocation.Mode != RootFolderRelocationMode.MetadataOnly) - { - return; - } - - var skippedCount = relocation.SkippedItems.Count; - relocation.Status = skippedCount == 0 - ? RootFolderRelocationStatus.Completed - : RootFolderRelocationStatus.NeedsAttention; - relocation.ActiveRootFolderId = skippedCount == 0 - ? null - : relocation.RootFolderId; - relocation.CompletedAt = skippedCount == 0 ? now : null; - relocation.Error = skippedCount == 0 - ? null - : BuildSkippedMetadataError(skippedCount); - relocation.TargetIdentityEnrollmentState = skippedCount == 0 - ? TargetIdentityEnrollmentState.NotRequired - : relocation.TargetIdentityEnrollmentState; - relocation.UpdatedAt = now; - } - private async Task CompleteOwnershipMigrationMetadataAsync( ListenArrDbContext db, RootFolderRelocation relocation, IReadOnlyList plans, - CancellationToken cancellationToken, - LibraryDirectoryOwnershipPathMigrationState committedState = - LibraryDirectoryOwnershipPathMigrationState.MetadataCommitted) + CancellationToken cancellationToken) { var rootId = relocation.RootFolderId ?? throw new InvalidOperationException( @@ -340,91 +182,131 @@ await db.AudiobookFiles sourceSemantics, detectAmbiguousCaseMatches: false); - await using var transaction = - await db.Database.BeginTransactionAsync(cancellationToken); - foreach (var candidate in affected) + var targetGenerationLease = PinTargetDirectoryGeneration( + relocation.TargetPath, + relocation.TargetDirectoryObjectIdentityVersion, + relocation.TargetDirectoryObjectIdentity, + relocation.TargetDirectoryObjectIdentityUnavailableReason, + cancellationToken); + IReadOnlyList ownershipGenerationLeases = []; + try { - var destination = MapTargetPath( - relocation.SourcePath, + ownershipGenerationLeases = PinOwnershipMigrationTargets( + plans, relocation.TargetPath, - candidate.StoredBasePath, - sourceSemantics, - targetSemantics); - AudiobookPathReferenceRewriter.Rewrite( - candidate.Audiobook, - candidate.StoredBasePath, - destination, - sourceSemantics, - targetSemantics, - relocation.TargetCaseSensitivityMode); - } - foreach (var candidate in invalid) - { - if (relocation.SkippedItems.All(item => - item.AudiobookId != candidate.Audiobook.Id)) + cancellationToken); + await using var transaction = + await db.Database.BeginTransactionAsync(cancellationToken); + foreach (var candidate in affected) { - relocation.SkippedItems.Add( - new RootFolderRelocationSkippedItem - { - AudiobookId = candidate.Audiobook.Id, - Reason = - "Stored audiobook base path is invalid or case-ambiguous and could not be compared safely with the source root.", - CreatedAt = - timeProvider.GetUtcNow() - }); + var destination = MapTargetPath( + relocation.SourcePath, + relocation.TargetPath, + candidate.StoredBasePath, + sourceSemantics, + targetSemantics); + AudiobookPathReferenceRewriter.Rewrite( + candidate.Audiobook, + candidate.StoredBasePath, + destination, + sourceSemantics, + targetSemantics, + relocation.TargetCaseSensitivityMode); + } + foreach (var candidate in invalid) + { + if (relocation.SkippedItems.All(item => + item.AudiobookId != candidate.Audiobook.Id)) + { + relocation.SkippedItems.Add( + new RootFolderRelocationSkippedItem + { + AudiobookId = candidate.Audiobook.Id, + Reason = + "Stored audiobook base path is invalid or case-ambiguous and could not be compared safely with the source root.", + CreatedAt = + timeProvider.GetUtcNow() + }); + } } - } - var now = timeProvider.GetUtcNow().UtcDateTime; - ApplyOwnershipMigrationMetadata(plans, now); - BeforeOwnershipMigrationMetadataSaveForTest?.Invoke(); - await db.SaveChangesAsync(cancellationToken); - AssignOwnershipMigrationKeys( - plans, - now, - committedState); - var command = new RootFolderPathChangeCommand( - relocation.TargetPath, - relocation.Mode, - relocation.DeleteEmptySource, - relocation.DesiredName, - relocation.DesiredIsDefault, - relocation.TargetCaseSensitivityMode); - ApplyRootMetadata( - root, - command, - relocation.TargetPath, - targetResolution, - FileSystemPathIdentity.CreateKey( - "root", + var now = timeProvider.GetUtcNow().UtcDateTime; + ApplyOwnershipMigrationMetadata(plans, now); + BeforeOwnershipMigrationMetadataSaveForTest?.Invoke(); + await db.SaveChangesAsync(cancellationToken); + AssignOwnershipMigrationKeys( + plans, + now); + var command = new RootFolderPathChangeCommand( relocation.TargetPath, - targetSemantics)); - root.DirectoryObjectIdentityVersion = - relocation.TargetDirectoryObjectIdentityVersion; - root.DirectoryObjectIdentity = - relocation.TargetDirectoryObjectIdentity; - root.DirectoryObjectIdentityUnavailableReason = - relocation.TargetDirectoryObjectIdentityUnavailableReason; - relocation.CompletedJobs = affected.Count; - relocation.Status = relocation.SkippedItems.Count == 0 - ? RootFolderRelocationStatus.Completed - : RootFolderRelocationStatus.NeedsAttention; - relocation.ActiveRootFolderId = - relocation.SkippedItems.Count == 0 ? null : root.Id; - relocation.CompletedAt = - relocation.SkippedItems.Count == 0 ? now : null; - relocation.Error = relocation.SkippedItems.Count == 0 - ? null - : BuildSkippedMetadataError( - relocation.SkippedItems.Count); - relocation.TargetIdentityEnrollmentState = - relocation.SkippedItems.Count == 0 - ? TargetIdentityEnrollmentState.NotRequired - : relocation.TargetIdentityEnrollmentState; - relocation.UpdatedAt = now; - await db.SaveChangesAsync(cancellationToken); - cancellationToken.ThrowIfCancellationRequested(); - await transaction.CommitAsync(CancellationToken.None); + relocation.Mode, + relocation.DeleteEmptySource, + relocation.DesiredName, + relocation.DesiredIsDefault, + relocation.TargetCaseSensitivityMode); + ApplyRootMetadata( + root, + command, + relocation.TargetPath, + targetResolution, + FileSystemPathIdentity.CreateKey( + "root", + relocation.TargetPath, + targetSemantics)); + root.DirectoryObjectIdentityVersion = + relocation.TargetDirectoryObjectIdentityVersion; + root.DirectoryObjectIdentity = + relocation.TargetDirectoryObjectIdentity; + root.DirectoryObjectIdentityUnavailableReason = + relocation.TargetDirectoryObjectIdentityUnavailableReason; + relocation.CompletedJobs = affected.Count; + relocation.Status = relocation.SkippedItems.Count == 0 + ? RootFolderRelocationStatus.Completed + : RootFolderRelocationStatus.NeedsAttention; + relocation.ActiveRootFolderId = + relocation.SkippedItems.Count == 0 ? null : root.Id; + relocation.CompletedAt = + relocation.SkippedItems.Count == 0 ? now : null; + relocation.Error = relocation.SkippedItems.Count == 0 + ? null + : BuildSkippedMetadataError( + relocation.SkippedItems.Count); + relocation.TargetIdentityEnrollmentState = + relocation.SkippedItems.Count == 0 + ? TargetIdentityEnrollmentState.NotRequired + : relocation.TargetIdentityEnrollmentState; + relocation.UpdatedAt = now; + db.LibraryDirectoryOwnershipPathMigrations.RemoveRange( + plans.Select(plan => plan.Journal)); + await db.SaveChangesAsync(cancellationToken); + BeforeOwnershipMigrationAtomicCommitForTest?.Invoke(); + RevalidatePinnedTargetDirectoryGeneration( + targetGenerationLease, + relocation.TargetDirectoryObjectIdentityVersion, + relocation.TargetDirectoryObjectIdentity, + relocation.TargetDirectoryObjectIdentityUnavailableReason, + cancellationToken); + RevalidateOwnershipMigrationTargetLeases( + ownershipGenerationLeases, + cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + await transaction.CommitAsync(CancellationToken.None); + AfterOwnershipMigrationAtomicCommitForTest?.Invoke(); + RevalidatePinnedTargetDirectoryGeneration( + targetGenerationLease, + relocation.TargetDirectoryObjectIdentityVersion, + relocation.TargetDirectoryObjectIdentity, + relocation.TargetDirectoryObjectIdentityUnavailableReason, + CancellationToken.None); + RevalidateOwnershipMigrationTargetLeases( + ownershipGenerationLeases, + CancellationToken.None); + } + finally + { + DisposeOwnershipMigrationTargetLeases(ownershipGenerationLeases); + targetGenerationLease.Dispose(); + } } private static IReadOnlyList diff --git a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.OwnershipMigrationRetirement.cs b/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.OwnershipMigrationRetirement.cs deleted file mode 100644 index b954a303d..000000000 --- a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.OwnershipMigrationRetirement.cs +++ /dev/null @@ -1,371 +0,0 @@ -using Listenarr.Domain.Common; - -namespace Listenarr.Infrastructure.Library.Moving; - -public sealed partial class RootFolderRelocationService -{ - internal Action? BeforeOwnershipMigrationSourceRetirementForTest - { - get; - set; - } - - private static void TryRetireMarkerlessOwnershipMigrationSourceArtifacts( - IReadOnlyList plans, - string sourceBoundary, - CancellationToken cancellationToken) - { - foreach (var plan in plans) - { - cancellationToken.ThrowIfCancellationRequested(); - try - { - var sourceParentPath = Path.GetDirectoryName( - plan.Source.CanonicalPath) - ?? throw new InvalidOperationException( - "The migrated ownership source has no parent directory."); - using var sourceParent = OpenMarkerParentWithinBoundary( - sourceBoundary, - sourceParentPath, - plan.Source.GetIdentity().Semantics); - using var sourceDirectory = sourceParent.OpenExistingChild( - Path.GetFileName(plan.Source.CanonicalPath)); - if (!ManagedDirectoryIdentity.Matches( - plan.Source.DirectoryObjectIdentityVersion, - plan.Source.DirectoryObjectIdentity, - plan.Source.OwnershipToken, - sourceDirectory.GetDirectoryObjectIdentity()) - || !sourceDirectory.VisiblePathMatches() - || !sourceParent.VisiblePathMatches()) - { - continue; - } - - _ = LibraryDirectoryOwnershipMarker.TryRetireMigrationArtifacts( - plan.Source, - plan.Target, - sourceDirectory, - sourceParent, - out _); - } - catch (Exception exception) when (exception is not ( - OperationCanceledException or OutOfMemoryException - or StackOverflowException)) - { - // Fresh markerless migration never publishes source artifacts. - // Any source-side marker here is legacy cleanup only. The old path - // may legitimately be absent after an external rename, so failure - // to reach or prove it cannot invalidate the committed relocation. - } - } - } - - private async Task RetireOwnershipMigrationSourcesAsync( - IReadOnlyList plans, - string sourceBoundary, - string targetBoundary, - int? targetIdentityVersion, - string? targetIdentityValue, - string? targetIdentityUnavailableReason, - CancellationToken cancellationToken) - { - BeforeOwnershipMigrationSourceRetirementForTest?.Invoke(); - if (plans.Count > 0) - { - using var targetBoundaryAnchor = await OpenVerifiedMarkerParentWithinBoundaryAsync( - targetBoundary, - targetBoundary, - plans[0].Target.GetIdentity().Semantics, - targetIdentityVersion, - targetIdentityValue, - targetIdentityUnavailableReason, - cancellationToken); - } - - foreach (var plan in plans) - { - var sourceSiblingMarker = - LibraryDirectoryOwnershipMarker.GetMarkerPaths( - plan.Source)[1]; - var targetSiblingMarker = - LibraryDirectoryOwnershipMarker.GetMarkerPaths( - plan.Target)[1]; - if (FileSystemPathIdentity.AreEquivalentEndpoints( - sourceSiblingMarker, - plan.Source.GetIdentity().Semantics, - targetSiblingMarker, - plan.Target.GetIdentity().Semantics)) - { - continue; - } - - var sourceParentPath = Path.GetDirectoryName(sourceSiblingMarker) - ?? throw new InvalidOperationException( - "The retired ownership marker has no source parent."); - var targetParentPath = Path.GetDirectoryName(targetSiblingMarker) - ?? throw new InvalidOperationException( - "The active ownership marker has no target parent."); - using var sourceParent = OpenMarkerParentWithinBoundary( - sourceBoundary, - sourceParentPath, - plan.Source.GetIdentity().Semantics); - using var targetParent = await OpenVerifiedMarkerParentWithinBoundaryAsync( - targetBoundary, - targetParentPath, - plan.Target.GetIdentity().Semantics, - targetIdentityVersion, - targetIdentityValue, - targetIdentityUnavailableReason, - cancellationToken); - using var targetMarker = targetParent.OpenExistingFileForStableRead( - Path.GetFileName(targetSiblingMarker)); - ValidateRetirementTarget(plan, targetParent, targetMarker); - - if (!sourceParent.VisiblePathMatches()) - { - throw new InvalidOperationException( - "The retired ownership marker parent changed before source retirement."); - } - - if (string.Equals( - Path.GetFileName(sourceSiblingMarker), - Path.GetFileName(targetSiblingMarker), - StringComparison.Ordinal) - && string.Equals( - sourceParent.GetDirectoryObjectIdentity(), - targetParent.GetDirectoryObjectIdentity(), - StringComparison.Ordinal)) - { - // The source and target are lexical aliases for the same physical - // namespace entry. Opening the same Windows file for deletion while - // its stable-read handle is active would create a false sharing - // violation; there is no obsolete source name to retire. - continue; - } - - var sourceOpen = sourceParent.TryOpenExistingFileWithOutcome( - Path.GetFileName(sourceSiblingMarker), - requireDeleteAccess: true, - out var openedSourceMarker); - if (sourceOpen == PinnedFileOpenOutcome.NotFound) - { - ValidateRetirementTarget(plan, targetParent, targetMarker); - continue; - } - if (sourceOpen == PinnedFileOpenOutcome.Unavailable - || openedSourceMarker == null) - { - throw new IOException( - "The retired ownership marker is temporarily unavailable; its migration journal was preserved for retry."); - } - - using var sourceMarker = openedSourceMarker; - if (sourceMarker.IdentifiesSameEntry(targetMarker)) - { - if (!sourceParent.VisiblePathMatches() - || !targetParent.VisiblePathMatches() - || !sourceMarker.VisiblePathMatches() - || !targetMarker.VisiblePathMatches()) - { - throw new InvalidOperationException( - "A shared ownership marker generation changed before source-name retirement."); - } - - sourceMarker.Delete(); - sourceParent.FlushDirectoryEntry(); - ValidateRetirementTarget(plan, targetParent, targetMarker); - continue; - } - - LibraryDirectoryOwnershipMarker.ValidateMarkerFile( - plan.Source, - sourceMarker); - - if (string.Equals( - sourceParent.GetDirectoryObjectIdentity(), - targetParent.GetDirectoryObjectIdentity(), - StringComparison.Ordinal)) - { - throw new InvalidOperationException( - "Equivalent ownership marker parents exposed different marker generations."); - } - - if (!sourceParent.VisiblePathMatches() - || !targetParent.VisiblePathMatches() - || !sourceMarker.VisiblePathMatches() - || !targetMarker.VisiblePathMatches()) - { - throw new InvalidOperationException( - "An ownership migration marker changed before source retirement."); - } - - LibraryDirectoryOwnershipMarker.ValidateMarkerFile( - plan.Source, - sourceMarker); - LibraryDirectoryOwnershipMarker.ValidateMarkerFile( - plan.Target, - targetMarker); - sourceMarker.Delete(); - sourceParent.FlushDirectoryEntry(); - ValidateRetirementTarget(plan, targetParent, targetMarker); - } - } - - private static void ValidateRetirementTarget( - OwnershipMigrationPlan plan, - PinnedDirectoryCreation.PinnedDirectoryAnchor targetParent, - PinnedDirectoryCreation.PinnedFileEntry targetMarker) - { - if (!targetParent.VisiblePathMatches() - || !targetMarker.VisiblePathMatches()) - { - throw new InvalidOperationException( - "The active ownership marker changed during source retirement."); - } - - LibraryDirectoryOwnershipMarker.ValidateMarkerFile( - plan.Target, - targetMarker); - } - - private static PinnedDirectoryCreation.PinnedDirectoryAnchor - OpenMarkerParentWithinBoundary( - string boundaryPath, - string parentPath, - FileSystemPathSemantics semantics) - { - var canonicalBoundary = FileSystemPathIdentity.Canonicalize( - boundaryPath, - semantics.Syntax); - var canonicalParent = FileSystemPathIdentity.Canonicalize( - parentPath, - semantics.Syntax); - if (!FileSystemPathIdentity.IsSameOrInside( - canonicalParent, - canonicalBoundary, - semantics)) - { - throw new InvalidOperationException( - "An ownership migration marker escaped its authorized root boundary."); - } - - var current = PinnedDirectoryCreation.OpenPinnedBoundary( - canonicalBoundary); - try - { - if (FileSystemPathIdentity.AreEquivalent( - canonicalParent, - canonicalBoundary, - semantics)) - { - return current; - } - - var relative = Path.GetRelativePath( - canonicalBoundary, - canonicalParent); - foreach (var segment in relative.Split( - [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], - StringSplitOptions.RemoveEmptyEntries)) - { - if (segment is "." or "..") - { - throw new InvalidOperationException( - "An ownership marker parent contains navigation segments."); - } - - var next = current.OpenExistingChild(segment); - current.Dispose(); - current = next; - } - - return current; - } - catch - { - current.Dispose(); - throw; - } - } - - private static async Task - OpenVerifiedMarkerParentWithinBoundaryAsync( - string boundaryPath, - string parentPath, - FileSystemPathSemantics semantics, - int? expectedBoundaryIdentityVersion = null, - string? expectedBoundaryIdentityValue = null, - string? boundaryIdentityUnavailableReason = null, - CancellationToken cancellationToken = default) - { - var canonicalBoundary = FileSystemPathIdentity.Canonicalize( - boundaryPath, - semantics.Syntax); - var canonicalParent = FileSystemPathIdentity.Canonicalize( - parentPath, - semantics.Syntax); - if (!FileSystemPathIdentity.IsSameOrInside( - canonicalParent, - canonicalBoundary, - semantics)) - { - throw new InvalidOperationException( - "An ownership migration marker escaped its authorized root boundary."); - } - - var current = PinnedDirectoryCreation.OpenPinnedBoundary( - canonicalBoundary); - try - { - if (expectedBoundaryIdentityVersion.HasValue - || !string.IsNullOrWhiteSpace(expectedBoundaryIdentityValue) - || !string.IsNullOrWhiteSpace(boundaryIdentityUnavailableReason)) - { - cancellationToken.ThrowIfCancellationRequested(); - if (!string.IsNullOrWhiteSpace(boundaryIdentityUnavailableReason) - || !ManagedDirectoryIdentity.MatchesNativeIdentity( - expectedBoundaryIdentityVersion, - expectedBoundaryIdentityValue, - current.GetDirectoryObjectIdentity()) - || !current.VisiblePathMatches()) - { - throw new InvalidOperationException( - "The ownership migration boundary no longer identifies its authorized physical generation."); - } - } - - if (FileSystemPathIdentity.AreEquivalent( - canonicalParent, - canonicalBoundary, - semantics)) - { - return current; - } - - var relative = Path.GetRelativePath( - canonicalBoundary, - canonicalParent); - foreach (var segment in relative.Split( - [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], - StringSplitOptions.RemoveEmptyEntries)) - { - if (segment is "." or "..") - { - throw new InvalidOperationException( - "An ownership marker parent contains navigation segments."); - } - - var next = current.OpenExistingChild(segment); - current.Dispose(); - current = next; - } - - return current; - } - catch - { - current.Dispose(); - throw; - } - } -} diff --git a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.TargetIdentity.cs b/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.TargetIdentity.cs index 05e3ebe5f..f62dc3b19 100644 --- a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.TargetIdentity.cs +++ b/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.TargetIdentity.cs @@ -29,12 +29,13 @@ private static void RejectTargetNavigationSegments(string targetPath) } } - private static Task RequireTargetDirectoryGenerationAsync( - string targetPath, - int? expectedVersion, - string? expectedValue, - string? unavailableReason, - CancellationToken cancellationToken) + private static PinnedDirectoryCreation.PinnedDirectoryAnchor + PinTargetDirectoryGeneration( + string targetPath, + int? expectedVersion, + string? expectedValue, + string? unavailableReason, + CancellationToken cancellationToken) { if (!FileSystemPathIdentity.TryCanonicalizeUnambiguousStoredAbsolutePathForHost( targetPath, @@ -44,35 +45,67 @@ private static Task RequireTargetDirectoryGenerationAsync( throw new InvalidOperationException(pathReason); } + PinnedDirectoryCreation.PinnedDirectoryAnchor? target = null; try { - using var target = PinnedDirectoryCreation.OpenPinnedBoundary( + target = PinnedDirectoryCreation.OpenPinnedBoundary( canonicalTargetPath); - cancellationToken.ThrowIfCancellationRequested(); - if (!string.IsNullOrWhiteSpace(unavailableReason) - || !ManagedDirectoryIdentity.MatchesNativeIdentity( - expectedVersion, - expectedValue, - target.GetDirectoryObjectIdentity()) - || !target.VisiblePathMatches()) - { - throw new InvalidOperationException( - "The managed directory no longer identifies its authorized physical generation."); - } - - return Task.CompletedTask; + RevalidatePinnedTargetDirectoryGeneration( + target, + expectedVersion, + expectedValue, + unavailableReason, + cancellationToken); + return target; } catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or InvalidOperationException or NotSupportedException or System.ComponentModel.Win32Exception) { + target?.Dispose(); throw new InvalidOperationException( "The relocation target no longer identifies its authorized physical directory generation.", exception); } } + private static void RevalidatePinnedTargetDirectoryGeneration( + PinnedDirectoryCreation.PinnedDirectoryAnchor target, + int? expectedVersion, + string? expectedValue, + string? unavailableReason, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!string.IsNullOrWhiteSpace(unavailableReason) + || !ManagedDirectoryIdentity.MatchesNativeIdentity( + expectedVersion, + expectedValue, + target.GetDirectoryObjectIdentity()) + || !target.VisiblePathMatches()) + { + throw new InvalidOperationException( + "The managed directory no longer identifies its authorized physical generation."); + } + } + + private static Task RequireTargetDirectoryGenerationAsync( + string targetPath, + int? expectedVersion, + string? expectedValue, + string? unavailableReason, + CancellationToken cancellationToken) + { + using var target = PinTargetDirectoryGeneration( + targetPath, + expectedVersion, + expectedValue, + unavailableReason, + cancellationToken); + return Task.CompletedTask; + } + private static Task RequireTargetDirectoryGenerationAsync( string targetPath, DirectoryObjectIdentityResolution expectedIdentity, diff --git a/listenarr.infrastructure/Persistence/Configurations/AudiobookFileConfiguration.cs b/listenarr.infrastructure/Persistence/Configurations/AudiobookFileConfiguration.cs index fac884411..9ea1baa14 100644 --- a/listenarr.infrastructure/Persistence/Configurations/AudiobookFileConfiguration.cs +++ b/listenarr.infrastructure/Persistence/Configurations/AudiobookFileConfiguration.cs @@ -1,3 +1,4 @@ +using Listenarr.Domain.Common; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; @@ -9,13 +10,24 @@ public void Configure(EntityTypeBuilder builder) { builder.Property(file => file.CanonicalPath).HasMaxLength(4096); builder.Property(file => file.PathSyntax).HasConversion().HasMaxLength(16); - builder.Property(file => file.PathCaseSensitivity).HasConversion().HasMaxLength(16); - builder.Property(file => file.PathCaseSensitivityMode).HasConversion().HasMaxLength(16); + builder.Property(file => file.PathCaseSensitivity) + .HasConversion() + .HasMaxLength(16) + .HasDefaultValue(FileSystemCaseSensitivity.Unknown); + builder.Property(file => file.PathCaseSensitivityMode) + .HasConversion() + .HasMaxLength(16) + .HasDefaultValue(FileSystemCaseSensitivityMode.Auto); builder.Property(file => file.PathIdentityBoundary).HasMaxLength(4096); builder.Property(file => file.PathIdentityLookupKey).HasMaxLength(160); builder.Property(file => file.PathOwnershipKey).HasMaxLength(160); - builder.Property(file => file.PathIdentityState).HasConversion().HasMaxLength(16); + builder.Property(file => file.PathIdentityState) + .HasConversion() + .HasMaxLength(16) + .HasDefaultValue(PathIdentityState.Unavailable) + .HasSentinel(PathIdentityState.Unavailable); builder.Property(file => file.PathIdentityReason).HasMaxLength(1024); + builder.Property(file => file.PathIdentityVersion).HasDefaultValue(1); builder.Property(file => file.PhysicalObjectIdentity).HasMaxLength(512); builder.Property(file => file.PhysicalIdentityVersion).HasDefaultValue(1); diff --git a/listenarr.infrastructure/Persistence/Configurations/LibraryDirectoryOwnershipConfiguration.cs b/listenarr.infrastructure/Persistence/Configurations/LibraryDirectoryOwnershipConfiguration.cs index e1c2aa533..f1a6b589f 100644 --- a/listenarr.infrastructure/Persistence/Configurations/LibraryDirectoryOwnershipConfiguration.cs +++ b/listenarr.infrastructure/Persistence/Configurations/LibraryDirectoryOwnershipConfiguration.cs @@ -41,33 +41,3 @@ public void Configure(EntityTypeBuilder builder) }); } } - -public sealed class LibraryDirectoryOwnershipRetiredMarkerConfiguration - : IEntityTypeConfiguration -{ - public void Configure( - EntityTypeBuilder builder) - { - builder.ToTable("LibraryDirectoryOwnershipRetiredMarkers"); - builder.Property(marker => marker.OwnershipToken).HasMaxLength(64); - builder.Property(marker => marker.CanonicalMarkerPath).HasMaxLength(4096); - builder.Property(marker => marker.CanonicalOwnershipPath).HasMaxLength(4096); - builder.Property(marker => marker.PathSyntax).HasConversion().HasMaxLength(16); - builder.Property(marker => marker.PathCaseSensitivity).HasConversion().HasMaxLength(16); - builder.Property(marker => marker.PathCaseSensitivityMode).HasConversion().HasMaxLength(16); - builder.Property(marker => marker.PathIdentityBoundary).HasMaxLength(4096); - builder.Property(marker => marker.CanonicalPayload).HasMaxLength(16384); - builder.Property(marker => marker.PayloadSha256).HasMaxLength(64); - builder.Property(marker => marker.DirectoryObjectIdentity).HasMaxLength(256); - builder.Property(marker => marker.State) - .HasConversion() - .HasMaxLength(16); - builder.HasIndex(marker => marker.OwnershipId).IsUnique(); - builder.HasIndex(marker => marker.CanonicalMarkerPath).IsUnique(); - builder.HasOne(marker => marker.Ownership) - .WithOne(ownership => ownership.RetiredMarker) - .HasForeignKey( - marker => marker.OwnershipId) - .OnDelete(DeleteBehavior.Cascade); - } -} diff --git a/listenarr.infrastructure/Persistence/Configurations/MoveJobConfiguration.cs b/listenarr.infrastructure/Persistence/Configurations/MoveJobConfiguration.cs index d0a83f8a3..76b35f5ec 100644 --- a/listenarr.infrastructure/Persistence/Configurations/MoveJobConfiguration.cs +++ b/listenarr.infrastructure/Persistence/Configurations/MoveJobConfiguration.cs @@ -19,16 +19,22 @@ public sealed class MoveJobConfiguration : IEntityTypeConfiguration public void Configure(EntityTypeBuilder builder) { builder.Property(job => job.Status).HasConversion().HasMaxLength(32); - builder.Property(job => job.Phase).HasConversion().HasMaxLength(32); + builder.Property(job => job.Phase) + .HasConversion() + .HasMaxLength(32) + .HasDefaultValue(MoveJobPhase.None); builder.Property(job => job.ExecutionProtocolVersion) - .HasDefaultValue(MoveExecutionProtocol.LegacyFilesystemArtifacts); + .HasDefaultValue(MoveExecutionProtocol.PreDurableReleased); builder.Property(job => job.SourceDirectoryObjectIdentity).HasMaxLength(512); builder.Property(job => job.TargetDirectoryObjectIdentity).HasMaxLength(512); builder.Property(job => job.SourceDirectoryCleanupState) .HasConversion() .HasMaxLength(24) .HasDefaultValue(MoveJobEntryCleanupState.Pending); - builder.Property(job => job.FailureKind).HasConversion().HasMaxLength(32); + builder.Property(job => job.FailureKind) + .HasConversion() + .HasMaxLength(32) + .HasDefaultValue(MoveFailureKind.None); builder.Property(job => job.ActiveDeduplicationKey).HasMaxLength(1024); builder.Property(job => job.SourcePathSyntax).HasConversion().HasMaxLength(16); builder.Property(job => job.SourceCaseSensitivity).HasConversion().HasMaxLength(16); @@ -113,7 +119,6 @@ public void Configure( builder.Property(item => item.TargetIdentityBoundary).HasMaxLength(4096); builder.Property(item => item.TargetIdentityLookupKey).HasMaxLength(160); builder.Property(item => item.TargetOwnershipKey).HasMaxLength(160); - builder.Property(item => item.State).HasConversion().HasMaxLength(24); builder.HasIndex(item => new { item.OwnershipId, item.RelocationId }) .IsUnique(); builder.HasIndex(item => item.TargetOwnershipKey).IsUnique(); diff --git a/listenarr.infrastructure/Persistence/Configurations/RootFolderConfiguration.cs b/listenarr.infrastructure/Persistence/Configurations/RootFolderConfiguration.cs index 64211a0d9..995d49b92 100644 --- a/listenarr.infrastructure/Persistence/Configurations/RootFolderConfiguration.cs +++ b/listenarr.infrastructure/Persistence/Configurations/RootFolderConfiguration.cs @@ -15,6 +15,7 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ +using Listenarr.Domain.Common; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; @@ -36,9 +37,19 @@ public void Configure(EntityTypeBuilder builder) builder.Property(r => r.Name).HasMaxLength(200).IsRequired(); builder.Property(r => r.Path).HasMaxLength(1000).IsRequired(); builder.Property(r => r.IsDefault).HasDefaultValue(false); - builder.Property(r => r.CaseSensitivityMode).HasConversion().HasMaxLength(16); - builder.Property(r => r.ResolvedCaseSensitivity).HasConversion().HasMaxLength(16); - builder.Property(r => r.PathIdentityState).HasConversion().HasMaxLength(16); + builder.Property(r => r.CaseSensitivityMode) + .HasConversion() + .HasMaxLength(16) + .HasDefaultValue(FileSystemCaseSensitivityMode.Auto); + builder.Property(r => r.ResolvedCaseSensitivity) + .HasConversion() + .HasMaxLength(16) + .HasDefaultValue(FileSystemCaseSensitivity.Unknown); + builder.Property(r => r.PathIdentityState) + .HasConversion() + .HasMaxLength(16) + .HasDefaultValue(PathIdentityState.Unavailable) + .HasSentinel(PathIdentityState.Unavailable); builder.Property(r => r.DirectoryObjectIdentity).HasMaxLength(256); builder.Property(r => r.DirectoryObjectIdentityUnavailableReason).HasMaxLength(1024); builder.HasIndex(r => r.PathIdentityKey) diff --git a/listenarr.infrastructure/Persistence/LibraryDirectoryOwnershipMigrationPreflight.cs b/listenarr.infrastructure/Persistence/LibraryDirectoryOwnershipMigrationPreflight.cs deleted file mode 100644 index 8116d9511..000000000 --- a/listenarr.infrastructure/Persistence/LibraryDirectoryOwnershipMigrationPreflight.cs +++ /dev/null @@ -1,130 +0,0 @@ -using Microsoft.EntityFrameworkCore; - -namespace Listenarr.Infrastructure.Persistence; - -internal static class LibraryDirectoryOwnershipMigrationPreflight -{ - internal const string LegacyRemovedRootStateReasonPrefix = - "migration:original-managed-root:"; - - internal const string PredecessorMigrationId = - "20260726042801_AddDirectoryObjectIdentityAuthorization"; - internal const string ForeignKeyMigrationId = - "20260805034058_AddLibraryDirectoryOwnershipRootForeignKey"; - - public static int RepairLegacyForeignKeyReferences(ListenArrDbContext context) - { - ArgumentNullException.ThrowIfNull(context); - - var applied = context.Database.GetAppliedMigrations() - .ToHashSet(StringComparer.Ordinal); - if (!applied.Contains(PredecessorMigrationId) - || applied.Contains(ForeignKeyMigrationId)) - { - return 0; - } - - using var transaction = context.Database.BeginTransaction(); - - // Removed legacy rows no longer own their directory. Clear only a stale - // reference to a root that no longer exists so the upcoming FK rebuild - // can copy the row. Post-migration reconciliation materializes durable - // retired-marker evidence before attempting any marker cleanup. - var removedRows = context.Database.ExecuteSqlInterpolated( - $""" - UPDATE "LibraryDirectoryOwnerships" - SET - "StateReason" = {LegacyRemovedRootStateReasonPrefix} - || "ManagedRootFolderId" - || CASE - WHEN "StateReason" IS NULL THEN '' - ELSE char(10) || "StateReason" - END, - "ManagedRootFolderId" = NULL - WHERE "State" = 'Removed' - AND "ManagedRootFolderId" IS NOT NULL - AND NOT EXISTS ( - SELECT 1 - FROM "RootFolders" AS "root" - WHERE "root"."Id" = - "LibraryDirectoryOwnerships"."ManagedRootFolderId"); - """); - - // A live ownership pointing at a deleted root must fail closed. This is - // a data-integrity repair only; it grants no filesystem authority and - // deliberately removes the ownership key before the FK is introduced. - var unavailableRows = context.Database.ExecuteSqlRaw( - """ - UPDATE "LibraryDirectoryOwnerships" - SET - "State" = 'Unavailable', - "PathOwnershipKey" = NULL, - "ManagedRootFolderId" = NULL, - "StateReason" = 'The persisted managed root no longer exists.', - "DirectoryObjectIdentityUnavailableReason" = - coalesce( - "DirectoryObjectIdentityUnavailableReason", - 'The persisted managed root no longer exists.') - WHERE "State" <> 'Removed' - AND "ManagedRootFolderId" IS NOT NULL - AND NOT EXISTS ( - SELECT 1 - FROM "RootFolders" AS "root" - WHERE "root"."Id" = - "LibraryDirectoryOwnerships"."ManagedRootFolderId"); - """); - - transaction.Commit(); - return removedRows + unavailableRows; - } - - internal static string CreateLegacyRemovedRootStateReason( - int rootFolderId, - string? originalStateReason = null) - { - if (rootFolderId <= 0) - { - throw new ArgumentOutOfRangeException(nameof(rootFolderId)); - } - - return LegacyRemovedRootStateReasonPrefix - + rootFolderId.ToString( - System.Globalization.CultureInfo.InvariantCulture) - + (originalStateReason == null - ? string.Empty - : "\n" + originalStateReason); - } - - internal static bool TryReadLegacyRemovedRootState( - string? stateReason, - out int rootFolderId, - out string? originalStateReason) - { - rootFolderId = default; - originalStateReason = null; - if (stateReason == null - || !stateReason.StartsWith( - LegacyRemovedRootStateReasonPrefix, - StringComparison.Ordinal)) - { - return false; - } - - var payload = stateReason[LegacyRemovedRootStateReasonPrefix.Length..]; - var separator = payload.IndexOf('\n'); - var rootFolderIdText = separator < 0 ? payload : payload[..separator]; - if (!int.TryParse( - rootFolderIdText, - System.Globalization.NumberStyles.None, - System.Globalization.CultureInfo.InvariantCulture, - out rootFolderId) - || rootFolderId <= 0) - { - rootFolderId = default; - return false; - } - - originalStateReason = separator < 0 ? null : payload[(separator + 1)..]; - return true; - } -} diff --git a/listenarr.infrastructure/Persistence/ListenArrDbContext.cs b/listenarr.infrastructure/Persistence/ListenArrDbContext.cs index 542e6481f..6fef05594 100644 --- a/listenarr.infrastructure/Persistence/ListenArrDbContext.cs +++ b/listenarr.infrastructure/Persistence/ListenArrDbContext.cs @@ -31,7 +31,6 @@ public class ListenArrDbContext : DbContext public DbSet MoveJobCreatedDirectories { get; set; } = null!; public DbSet LibraryDirectoryOwnerships { get; set; } = null!; public DbSet LibraryDirectoryOwnershipPathMigrations { get; set; } = null!; - public DbSet LibraryDirectoryOwnershipRetiredMarkers { get; set; } = null!; public DbSet MoveScanHandoffs { get; set; } = null!; public DbSet ApplicationSettings { get; set; } = null!; public DbSet History { get; set; } = null!; diff --git a/listenarr.infrastructure/Persistence/ListenarrDatabaseMigrationPreflight.cs b/listenarr.infrastructure/Persistence/ListenarrDatabaseMigrationPreflight.cs index 6f818c527..7eff0acb9 100644 --- a/listenarr.infrastructure/Persistence/ListenarrDatabaseMigrationPreflight.cs +++ b/listenarr.infrastructure/Persistence/ListenarrDatabaseMigrationPreflight.cs @@ -4,14 +4,10 @@ namespace Listenarr.Infrastructure.Persistence; internal static class ListenarrDatabaseMigrationPreflight { - internal const string DurableMoveSchemaMigrationId = - "20260708223635_AddDurableFilesystemMoves"; + internal const string DurableMarkerlessLibraryMovesMigrationId = + "20260807200942_AddDurableMarkerlessLibraryMoves"; internal const string RootFoldersMigrationId = "20260101172733_AddRootFolders"; - internal const string DirectoryObjectIdentityMigrationId = - "20260726042801_AddDirectoryObjectIdentityAuthorization"; - internal const string AudiobookFileOwnershipMigrationId = - "20260717143713_AddLibraryDirectoryOwnership"; public static ListenarrDatabaseMigrationPreflightResult RepairLegacyData( ListenArrDbContext context) @@ -22,7 +18,7 @@ public static ListenarrDatabaseMigrationPreflightResult RepairLegacyData( .ToHashSet(StringComparer.Ordinal); var normalizeDefaultRoots = applied.Contains(RootFoldersMigrationId) - && !applied.Contains(DirectoryObjectIdentityMigrationId); + && !applied.Contains(DurableMarkerlessLibraryMovesMigrationId); if (!normalizeDefaultRoots) { return default; @@ -52,64 +48,33 @@ public static ListenarrDatabasePostMigrationRepairResult RepairPostMigrationData var applied = context.Database.GetAppliedMigrations() .ToHashSet(StringComparer.Ordinal); - var repairLegacyMoveJobs = applied.Contains(DurableMoveSchemaMigrationId); - var repairAudiobookFileIdentityDefaults = - applied.Contains(AudiobookFileOwnershipMigrationId); - if (!repairLegacyMoveJobs && !repairAudiobookFileIdentityDefaults) + if (!applied.Contains(DurableMarkerlessLibraryMovesMigrationId)) { return default; } using var transaction = context.Database.BeginTransaction(); - // AddDurableFilesystemMoves gives pre-existing rows IdentityKeyVersion = 0. - // Treat that generated default as the durable one-time repair marker so normal - // startup retries cannot overwrite identity keys created by current code. - var moveJobsRepaired = repairLegacyMoveJobs - ? context.Database.ExecuteSqlRaw( - """ - UPDATE "MoveJobs" - SET - "Status" = CASE - WHEN "Status" = 'Processing' THEN 'Running' - ELSE "Status" - END, - "IdentityKeyVersion" = 1, - "ActiveDeduplicationKey" = 'legacy:' || "Id" - WHERE "IdentityKeyVersion" = 0 - AND "Status" IN ('Queued', 'Processing', 'Running', 'RetryScheduled'); - """) - : 0; - var audiobookFilesRepaired = repairAudiobookFileIdentityDefaults - ? context.Database.ExecuteSqlRaw( - """ - UPDATE "AudiobookFiles" - SET - "PathCaseSensitivity" = CASE - WHEN "PathCaseSensitivity" = '' THEN 'Unknown' - ELSE "PathCaseSensitivity" - END, - "PathCaseSensitivityMode" = CASE - WHEN "PathCaseSensitivityMode" = '' THEN 'Auto' - ELSE "PathCaseSensitivityMode" - END, - "PathIdentityVersion" = CASE - WHEN "PathIdentityVersion" = 0 THEN 1 - ELSE "PathIdentityVersion" - END, - "PathIdentityState" = CASE - WHEN "PathIdentityState" = '' THEN 'Unavailable' - ELSE "PathIdentityState" - END - WHERE "PathCaseSensitivity" = '' - OR "PathCaseSensitivityMode" = '' - OR "PathIdentityVersion" = 0 - OR "PathIdentityState" = ''; - """) - : 0; + var moveJobsRepaired = context.Database.ExecuteSqlRaw( + """ + UPDATE "MoveJobs" + SET + "Status" = 'NeedsAttention', + "Error" = 'This move job was created by a pre-durable released version and cannot be resumed safely after upgrade.', + "FailureKind" = 'Verification', + "ActiveDeduplicationKey" = NULL, + "UpdatedAt" = CURRENT_TIMESTAMP + WHERE "ExecutionProtocolVersion" = 0 + AND "Status" NOT IN ('Completed', 'Failed') + AND ( + "Status" <> 'NeedsAttention' + OR "ActiveDeduplicationKey" IS NOT NULL + OR "FailureKind" <> 'Verification' + OR "Error" IS NULL + ); + """); transaction.Commit(); - return new ListenarrDatabasePostMigrationRepairResult( - moveJobsRepaired, - audiobookFilesRepaired); + + return new ListenarrDatabasePostMigrationRepairResult(moveJobsRepaired); } } @@ -117,5 +82,4 @@ internal readonly record struct ListenarrDatabaseMigrationPreflightResult( int DefaultRootsNormalized); internal readonly record struct ListenarrDatabasePostMigrationRepairResult( - int MoveJobsRepaired, - int AudiobookFilesRepaired); + int MoveJobsRepaired); diff --git a/listenarr.infrastructure/Persistence/Migrations/20260703024452_AddMoveJobDeleteEmptySource.Designer.cs b/listenarr.infrastructure/Persistence/Migrations/20260703024452_AddMoveJobDeleteEmptySource.Designer.cs deleted file mode 100644 index be8ce79db..000000000 --- a/listenarr.infrastructure/Persistence/Migrations/20260703024452_AddMoveJobDeleteEmptySource.Designer.cs +++ /dev/null @@ -1,1551 +0,0 @@ -// -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("20260703024452_AddMoveJobDeleteEmptySource")] - partial class AddMoveJobDeleteEmptySource - { - /// - 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("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("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.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("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("SampleRate") - .HasColumnType("INTEGER"); - - b.Property("Size") - .HasColumnType("INTEGER"); - - b.Property("Source") - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("AudiobookId"); - - 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.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("RequestedPath") - .HasColumnType("TEXT"); - - b.Property("SourcePath") - .HasColumnType("TEXT"); - - b.Property("Status") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("UpdatedAt") - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("ActiveDeduplicationKey") - .IsUnique() - .HasFilter("\"ActiveDeduplicationKey\" IS NOT NULL"); - - b.HasIndex("AudiobookId", "Status"); - - b.ToTable("MoveJobs"); - }); - - 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("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - 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("UpdatedAt") - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("Name"); - - b.HasIndex("Path") - .IsUnique(); - - b.ToTable("RootFolders", (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.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.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.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.Audiobook", b => - { - b.Navigation("ExternalIdentifiers"); - - b.Navigation("Files"); - - b.Navigation("SeriesMemberships"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/listenarr.infrastructure/Persistence/Migrations/20260703024452_AddMoveJobDeleteEmptySource.cs b/listenarr.infrastructure/Persistence/Migrations/20260703024452_AddMoveJobDeleteEmptySource.cs deleted file mode 100644 index ae65a0f86..000000000 --- a/listenarr.infrastructure/Persistence/Migrations/20260703024452_AddMoveJobDeleteEmptySource.cs +++ /dev/null @@ -1,29 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Listenarr.Infrastructure.Persistence.Migrations -{ - /// - public partial class AddMoveJobDeleteEmptySource : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "DeleteEmptySource", - table: "MoveJobs", - type: "INTEGER", - nullable: false, - defaultValue: true); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn( - name: "DeleteEmptySource", - table: "MoveJobs"); - } - } -} diff --git a/listenarr.infrastructure/Persistence/Migrations/20260708223635_AddDurableFilesystemMoves.Designer.cs b/listenarr.infrastructure/Persistence/Migrations/20260708223635_AddDurableFilesystemMoves.Designer.cs deleted file mode 100644 index 5d1539096..000000000 --- a/listenarr.infrastructure/Persistence/Migrations/20260708223635_AddDurableFilesystemMoves.Designer.cs +++ /dev/null @@ -1,1762 +0,0 @@ -// -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("20260708223635_AddDurableFilesystemMoves")] - partial class AddDurableFilesystemMoves - { - /// - 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("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("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.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("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("SampleRate") - .HasColumnType("INTEGER"); - - b.Property("Size") - .HasColumnType("INTEGER"); - - b.Property("Source") - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("AudiobookId"); - - 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.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("FailureKind") - .IsRequired() - .HasMaxLength(32) - .HasColumnType("TEXT"); - - b.Property("IdentityKeyVersion") - .HasColumnType("INTEGER"); - - b.Property("LeaseExpiresAt") - .HasColumnType("TEXT"); - - b.Property("LeaseOwner") - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.Property("NextAttemptAt") - .HasColumnType("TEXT"); - - b.Property("Phase") - .IsRequired() - .HasMaxLength(32) - .HasColumnType("TEXT"); - - b.Property("RelocationId") - .HasColumnType("TEXT"); - - b.Property("RequestedPath") - .HasColumnType("TEXT"); - - b.Property("SourcePath") - .HasColumnType("TEXT"); - - b.Property("Status") - .IsRequired() - .HasMaxLength(32) - .HasColumnType("TEXT"); - - 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.MoveJobEntry", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - 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.HasKey("Id"); - - b.HasIndex("MoveJobId", "RelativePath") - .IsUnique(); - - b.ToTable("MoveJobEntries", (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() - .HasMaxLength(16) - .HasColumnType("TEXT"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - 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() - .HasMaxLength(16) - .HasColumnType("TEXT"); - - b.Property("ResolvedCaseSensitivity") - .IsRequired() - .HasMaxLength(16) - .HasColumnType("TEXT"); - - b.Property("UpdatedAt") - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - 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("SourcePath") - .IsRequired() - .HasMaxLength(1000) - .HasColumnType("TEXT"); - - b.Property("Status") - .IsRequired() - .HasMaxLength(24) - .HasColumnType("TEXT"); - - b.Property("TargetCaseSensitivityMode") - .IsRequired() - .HasMaxLength(16) - .HasColumnType("TEXT"); - - 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.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.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.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.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.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.RootFolderRelocation", b => - { - b.HasOne("Listenarr.Domain.Audiobooks.RootFolder", "RootFolder") - .WithMany("Relocations") - .HasForeignKey("RootFolderId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("RootFolder"); - }); - - modelBuilder.Entity("Listenarr.Domain.Audiobooks.Audiobook", b => - { - b.Navigation("ExternalIdentifiers"); - - b.Navigation("Files"); - - b.Navigation("SeriesMemberships"); - }); - - modelBuilder.Entity("Listenarr.Domain.Audiobooks.MoveJob", b => - { - b.Navigation("Entries"); - }); - - modelBuilder.Entity("Listenarr.Domain.Audiobooks.RootFolder", b => - { - b.Navigation("Relocations"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/listenarr.infrastructure/Persistence/Migrations/20260708223635_AddDurableFilesystemMoves.cs b/listenarr.infrastructure/Persistence/Migrations/20260708223635_AddDurableFilesystemMoves.cs deleted file mode 100644 index c8fb84a12..000000000 --- a/listenarr.infrastructure/Persistence/Migrations/20260708223635_AddDurableFilesystemMoves.cs +++ /dev/null @@ -1,253 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Listenarr.Infrastructure.Persistence.Migrations -{ - /// - public partial class AddDurableFilesystemMoves : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "CaseSensitivityMode", - table: "RootFolders", - type: "TEXT", - maxLength: 16, - nullable: false, - defaultValue: "Auto"); - - migrationBuilder.AddColumn( - name: "PathIdentityKey", - table: "RootFolders", - type: "TEXT", - maxLength: 128, - nullable: true); - - migrationBuilder.AddColumn( - name: "PathIdentityState", - table: "RootFolders", - type: "TEXT", - maxLength: 16, - nullable: false, - defaultValue: "Unavailable"); - - migrationBuilder.AddColumn( - name: "ResolvedCaseSensitivity", - table: "RootFolders", - type: "TEXT", - maxLength: 16, - nullable: false, - defaultValue: "Unknown"); - - migrationBuilder.AddColumn( - name: "FailureKind", - table: "MoveJobs", - type: "TEXT", - maxLength: 32, - nullable: false, - defaultValue: "None"); - - migrationBuilder.AddColumn( - name: "IdentityKeyVersion", - table: "MoveJobs", - type: "INTEGER", - nullable: false, - defaultValue: 0); - - migrationBuilder.AddColumn( - name: "LeaseExpiresAt", - table: "MoveJobs", - type: "TEXT", - nullable: true); - - migrationBuilder.AddColumn( - name: "LeaseOwner", - table: "MoveJobs", - type: "TEXT", - maxLength: 200, - nullable: true); - - migrationBuilder.AddColumn( - name: "NextAttemptAt", - table: "MoveJobs", - type: "TEXT", - nullable: true); - - migrationBuilder.AddColumn( - name: "Phase", - table: "MoveJobs", - type: "TEXT", - maxLength: 32, - nullable: false, - defaultValue: "None"); - - migrationBuilder.AddColumn( - name: "RelocationId", - table: "MoveJobs", - type: "TEXT", - nullable: true); - - migrationBuilder.CreateTable( - name: "MoveJobEntries", - columns: table => new - { - Id = table.Column(type: "INTEGER", nullable: false) - .Annotation("Sqlite:Autoincrement", true), - MoveJobId = table.Column(type: "TEXT", nullable: false), - RelativePath = table.Column(type: "TEXT", maxLength: 2000, nullable: false), - EntryType = table.Column(type: "TEXT", maxLength: 16, nullable: false), - Length = table.Column(type: "INTEGER", nullable: false), - LastWriteTimeUtc = table.Column(type: "TEXT", nullable: false), - Sha256 = table.Column(type: "TEXT", maxLength: 64, nullable: true), - CopyState = table.Column(type: "TEXT", maxLength: 16, nullable: false), - CleanupState = table.Column(type: "TEXT", maxLength: 16, nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_MoveJobEntries", x => x.Id); - table.ForeignKey( - name: "FK_MoveJobEntries_MoveJobs_MoveJobId", - column: x => x.MoveJobId, - principalTable: "MoveJobs", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "RootFolderRelocations", - columns: table => new - { - Id = table.Column(type: "TEXT", nullable: false), - RootFolderId = table.Column(type: "INTEGER", nullable: false), - ActiveRootFolderId = table.Column(type: "INTEGER", nullable: true), - SourcePath = table.Column(type: "TEXT", maxLength: 1000, nullable: false), - TargetPath = table.Column(type: "TEXT", maxLength: 1000, nullable: false), - Mode = table.Column(type: "TEXT", maxLength: 24, nullable: false), - Status = table.Column(type: "TEXT", maxLength: 24, nullable: false), - DeleteEmptySource = table.Column(type: "INTEGER", nullable: false), - DesiredName = table.Column(type: "TEXT", maxLength: 200, nullable: false), - DesiredIsDefault = table.Column(type: "INTEGER", nullable: false), - TargetCaseSensitivityMode = table.Column(type: "TEXT", maxLength: 16, nullable: false), - TotalJobs = table.Column(type: "INTEGER", nullable: false), - CompletedJobs = table.Column(type: "INTEGER", nullable: false), - Error = table.Column(type: "TEXT", maxLength: 4000, nullable: true), - CreatedAt = table.Column(type: "TEXT", nullable: false), - UpdatedAt = table.Column(type: "TEXT", nullable: true), - CompletedAt = table.Column(type: "TEXT", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_RootFolderRelocations", x => x.Id); - table.ForeignKey( - name: "FK_RootFolderRelocations_RootFolders_RootFolderId", - column: x => x.RootFolderId, - principalTable: "RootFolders", - principalColumn: "Id", - onDelete: ReferentialAction.Restrict); - }); - - migrationBuilder.CreateIndex( - name: "IX_RootFolders_PathIdentityKey", - table: "RootFolders", - column: "PathIdentityKey", - unique: true, - filter: "\"PathIdentityKey\" IS NOT NULL"); - - migrationBuilder.CreateIndex( - name: "IX_MoveJobs_RelocationId", - table: "MoveJobs", - column: "RelocationId"); - - migrationBuilder.CreateIndex( - name: "IX_MoveJobs_Status_NextAttemptAt_LeaseExpiresAt", - table: "MoveJobs", - columns: new[] { "Status", "NextAttemptAt", "LeaseExpiresAt" }); - - migrationBuilder.CreateIndex( - name: "IX_MoveJobEntries_MoveJobId_RelativePath", - table: "MoveJobEntries", - columns: new[] { "MoveJobId", "RelativePath" }, - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_RootFolderRelocations_ActiveRootFolderId", - table: "RootFolderRelocations", - column: "ActiveRootFolderId", - unique: true, - filter: "\"ActiveRootFolderId\" IS NOT NULL"); - - migrationBuilder.CreateIndex( - name: "IX_RootFolderRelocations_RootFolderId", - table: "RootFolderRelocations", - column: "RootFolderId"); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "MoveJobEntries"); - - migrationBuilder.DropTable( - name: "RootFolderRelocations"); - - migrationBuilder.DropIndex( - name: "IX_RootFolders_PathIdentityKey", - table: "RootFolders"); - - migrationBuilder.DropIndex( - name: "IX_MoveJobs_RelocationId", - table: "MoveJobs"); - - migrationBuilder.DropIndex( - name: "IX_MoveJobs_Status_NextAttemptAt_LeaseExpiresAt", - table: "MoveJobs"); - - migrationBuilder.DropColumn( - name: "CaseSensitivityMode", - table: "RootFolders"); - - migrationBuilder.DropColumn( - name: "PathIdentityKey", - table: "RootFolders"); - - migrationBuilder.DropColumn( - name: "PathIdentityState", - table: "RootFolders"); - - migrationBuilder.DropColumn( - name: "ResolvedCaseSensitivity", - table: "RootFolders"); - - migrationBuilder.DropColumn( - name: "FailureKind", - table: "MoveJobs"); - - migrationBuilder.DropColumn( - name: "IdentityKeyVersion", - table: "MoveJobs"); - - migrationBuilder.DropColumn( - name: "LeaseExpiresAt", - table: "MoveJobs"); - - migrationBuilder.DropColumn( - name: "LeaseOwner", - table: "MoveJobs"); - - migrationBuilder.DropColumn( - name: "NextAttemptAt", - table: "MoveJobs"); - - migrationBuilder.DropColumn( - name: "Phase", - table: "MoveJobs"); - - migrationBuilder.DropColumn( - name: "RelocationId", - table: "MoveJobs"); - } - } -} diff --git a/listenarr.infrastructure/Persistence/Migrations/20260708224312_AddMoveJobRelocationForeignKey.Designer.cs b/listenarr.infrastructure/Persistence/Migrations/20260708224312_AddMoveJobRelocationForeignKey.Designer.cs deleted file mode 100644 index ed62cf83c..000000000 --- a/listenarr.infrastructure/Persistence/Migrations/20260708224312_AddMoveJobRelocationForeignKey.Designer.cs +++ /dev/null @@ -1,1777 +0,0 @@ -// -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("20260708224312_AddMoveJobRelocationForeignKey")] - partial class AddMoveJobRelocationForeignKey - { - /// - 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("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("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.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("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("SampleRate") - .HasColumnType("INTEGER"); - - b.Property("Size") - .HasColumnType("INTEGER"); - - b.Property("Source") - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("AudiobookId"); - - 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.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("FailureKind") - .IsRequired() - .HasMaxLength(32) - .HasColumnType("TEXT"); - - b.Property("IdentityKeyVersion") - .HasColumnType("INTEGER"); - - b.Property("LeaseExpiresAt") - .HasColumnType("TEXT"); - - b.Property("LeaseOwner") - .HasMaxLength(200) - .HasColumnType("TEXT"); - - b.Property("NextAttemptAt") - .HasColumnType("TEXT"); - - b.Property("Phase") - .IsRequired() - .HasMaxLength(32) - .HasColumnType("TEXT"); - - b.Property("RelocationId") - .HasColumnType("TEXT"); - - b.Property("RequestedPath") - .HasColumnType("TEXT"); - - b.Property("SourcePath") - .HasColumnType("TEXT"); - - b.Property("Status") - .IsRequired() - .HasMaxLength(32) - .HasColumnType("TEXT"); - - 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.MoveJobEntry", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - 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.HasKey("Id"); - - b.HasIndex("MoveJobId", "RelativePath") - .IsUnique(); - - b.ToTable("MoveJobEntries", (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() - .HasMaxLength(16) - .HasColumnType("TEXT"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - 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() - .HasMaxLength(16) - .HasColumnType("TEXT"); - - b.Property("ResolvedCaseSensitivity") - .IsRequired() - .HasMaxLength(16) - .HasColumnType("TEXT"); - - b.Property("UpdatedAt") - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - 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("SourcePath") - .IsRequired() - .HasMaxLength(1000) - .HasColumnType("TEXT"); - - b.Property("Status") - .IsRequired() - .HasMaxLength(24) - .HasColumnType("TEXT"); - - b.Property("TargetCaseSensitivityMode") - .IsRequired() - .HasMaxLength(16) - .HasColumnType("TEXT"); - - 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.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.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.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.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.MoveJob", b => - { - b.HasOne("Listenarr.Domain.Audiobooks.RootFolderRelocation", "Relocation") - .WithMany("MoveJobs") - .HasForeignKey("RelocationId") - .OnDelete(DeleteBehavior.Restrict); - - b.Navigation("Relocation"); - }); - - 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.RootFolderRelocation", b => - { - b.HasOne("Listenarr.Domain.Audiobooks.RootFolder", "RootFolder") - .WithMany("Relocations") - .HasForeignKey("RootFolderId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("RootFolder"); - }); - - modelBuilder.Entity("Listenarr.Domain.Audiobooks.Audiobook", b => - { - b.Navigation("ExternalIdentifiers"); - - b.Navigation("Files"); - - b.Navigation("SeriesMemberships"); - }); - - modelBuilder.Entity("Listenarr.Domain.Audiobooks.MoveJob", b => - { - b.Navigation("Entries"); - }); - - modelBuilder.Entity("Listenarr.Domain.Audiobooks.RootFolder", b => - { - b.Navigation("Relocations"); - }); - - modelBuilder.Entity("Listenarr.Domain.Audiobooks.RootFolderRelocation", b => - { - b.Navigation("MoveJobs"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/listenarr.infrastructure/Persistence/Migrations/20260708224705_AddMoveJobLeaseGeneration.Designer.cs b/listenarr.infrastructure/Persistence/Migrations/20260708224705_AddMoveJobLeaseGeneration.Designer.cs deleted file mode 100644 index 2fe32e9ef..000000000 --- a/listenarr.infrastructure/Persistence/Migrations/20260708224705_AddMoveJobLeaseGeneration.Designer.cs +++ /dev/null @@ -1,1782 +0,0 @@ -// -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("20260708224705_AddMoveJobLeaseGeneration")] - partial class AddMoveJobLeaseGeneration - { - /// - 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("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("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.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("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("SampleRate") - .HasColumnType("INTEGER"); - - b.Property("Size") - .HasColumnType("INTEGER"); - - b.Property("Source") - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("AudiobookId"); - - 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.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("FailureKind") - .IsRequired() - .HasMaxLength(32) - .HasColumnType("TEXT"); - - 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() - .HasMaxLength(32) - .HasColumnType("TEXT"); - - b.Property("RelocationId") - .HasColumnType("TEXT"); - - b.Property("RequestedPath") - .HasColumnType("TEXT"); - - b.Property("SourcePath") - .HasColumnType("TEXT"); - - b.Property("Status") - .IsRequired() - .HasMaxLength(32) - .HasColumnType("TEXT"); - - 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.MoveJobEntry", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - 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.HasKey("Id"); - - b.HasIndex("MoveJobId", "RelativePath") - .IsUnique(); - - b.ToTable("MoveJobEntries", (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() - .HasMaxLength(16) - .HasColumnType("TEXT"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - 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() - .HasMaxLength(16) - .HasColumnType("TEXT"); - - b.Property("ResolvedCaseSensitivity") - .IsRequired() - .HasMaxLength(16) - .HasColumnType("TEXT"); - - b.Property("UpdatedAt") - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - 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("SourcePath") - .IsRequired() - .HasMaxLength(1000) - .HasColumnType("TEXT"); - - b.Property("Status") - .IsRequired() - .HasMaxLength(24) - .HasColumnType("TEXT"); - - b.Property("TargetCaseSensitivityMode") - .IsRequired() - .HasMaxLength(16) - .HasColumnType("TEXT"); - - 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.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.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.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.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.MoveJob", b => - { - b.HasOne("Listenarr.Domain.Audiobooks.RootFolderRelocation", "Relocation") - .WithMany("MoveJobs") - .HasForeignKey("RelocationId") - .OnDelete(DeleteBehavior.Restrict); - - b.Navigation("Relocation"); - }); - - 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.RootFolderRelocation", b => - { - b.HasOne("Listenarr.Domain.Audiobooks.RootFolder", "RootFolder") - .WithMany("Relocations") - .HasForeignKey("RootFolderId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("RootFolder"); - }); - - modelBuilder.Entity("Listenarr.Domain.Audiobooks.Audiobook", b => - { - b.Navigation("ExternalIdentifiers"); - - b.Navigation("Files"); - - b.Navigation("SeriesMemberships"); - }); - - modelBuilder.Entity("Listenarr.Domain.Audiobooks.MoveJob", b => - { - b.Navigation("Entries"); - }); - - modelBuilder.Entity("Listenarr.Domain.Audiobooks.RootFolder", b => - { - b.Navigation("Relocations"); - }); - - modelBuilder.Entity("Listenarr.Domain.Audiobooks.RootFolderRelocation", b => - { - b.Navigation("MoveJobs"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/listenarr.infrastructure/Persistence/Migrations/20260708224705_AddMoveJobLeaseGeneration.cs b/listenarr.infrastructure/Persistence/Migrations/20260708224705_AddMoveJobLeaseGeneration.cs deleted file mode 100644 index ec9d2e278..000000000 --- a/listenarr.infrastructure/Persistence/Migrations/20260708224705_AddMoveJobLeaseGeneration.cs +++ /dev/null @@ -1,29 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Listenarr.Infrastructure.Persistence.Migrations -{ - /// - public partial class AddMoveJobLeaseGeneration : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "LeaseGeneration", - table: "MoveJobs", - type: "INTEGER", - nullable: false, - defaultValue: 0); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn( - name: "LeaseGeneration", - table: "MoveJobs"); - } - } -} diff --git a/listenarr.infrastructure/Persistence/Migrations/20260708224900_AddRootFolderRelocationSkippedItems.Designer.cs b/listenarr.infrastructure/Persistence/Migrations/20260708224900_AddRootFolderRelocationSkippedItems.Designer.cs deleted file mode 100644 index efa8a089b..000000000 --- a/listenarr.infrastructure/Persistence/Migrations/20260708224900_AddRootFolderRelocationSkippedItems.Designer.cs +++ /dev/null @@ -1,1830 +0,0 @@ -// -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("20260708224900_AddRootFolderRelocationSkippedItems")] - partial class AddRootFolderRelocationSkippedItems - { - /// - 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("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("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.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("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("SampleRate") - .HasColumnType("INTEGER"); - - b.Property("Size") - .HasColumnType("INTEGER"); - - b.Property("Source") - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("AudiobookId"); - - 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.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("FailureKind") - .IsRequired() - .HasMaxLength(32) - .HasColumnType("TEXT"); - - 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() - .HasMaxLength(32) - .HasColumnType("TEXT"); - - b.Property("RelocationId") - .HasColumnType("TEXT"); - - b.Property("RequestedPath") - .HasColumnType("TEXT"); - - b.Property("SourcePath") - .HasColumnType("TEXT"); - - b.Property("Status") - .IsRequired() - .HasMaxLength(32) - .HasColumnType("TEXT"); - - 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.MoveJobEntry", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - 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.HasKey("Id"); - - b.HasIndex("MoveJobId", "RelativePath") - .IsUnique(); - - b.ToTable("MoveJobEntries", (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() - .HasMaxLength(16) - .HasColumnType("TEXT"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - 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() - .HasMaxLength(16) - .HasColumnType("TEXT"); - - b.Property("ResolvedCaseSensitivity") - .IsRequired() - .HasMaxLength(16) - .HasColumnType("TEXT"); - - b.Property("UpdatedAt") - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - 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("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.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.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.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.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.MoveJob", b => - { - b.HasOne("Listenarr.Domain.Audiobooks.RootFolderRelocation", "Relocation") - .WithMany("MoveJobs") - .HasForeignKey("RelocationId") - .OnDelete(DeleteBehavior.Restrict); - - b.Navigation("Relocation"); - }); - - 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.RootFolderRelocation", b => - { - b.HasOne("Listenarr.Domain.Audiobooks.RootFolder", "RootFolder") - .WithMany("Relocations") - .HasForeignKey("RootFolderId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("RootFolder"); - }); - - 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.MoveJob", b => - { - b.Navigation("Entries"); - }); - - modelBuilder.Entity("Listenarr.Domain.Audiobooks.RootFolder", b => - { - b.Navigation("Relocations"); - }); - - modelBuilder.Entity("Listenarr.Domain.Audiobooks.RootFolderRelocation", b => - { - b.Navigation("MoveJobs"); - - b.Navigation("SkippedItems"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/listenarr.infrastructure/Persistence/Migrations/20260708224900_AddRootFolderRelocationSkippedItems.cs b/listenarr.infrastructure/Persistence/Migrations/20260708224900_AddRootFolderRelocationSkippedItems.cs deleted file mode 100644 index 8b133552f..000000000 --- a/listenarr.infrastructure/Persistence/Migrations/20260708224900_AddRootFolderRelocationSkippedItems.cs +++ /dev/null @@ -1,60 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Listenarr.Infrastructure.Persistence.Migrations -{ - /// - public partial class AddRootFolderRelocationSkippedItems : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "SourceCaseSensitivityMode", - table: "RootFolderRelocations", - type: "TEXT", - maxLength: 16, - nullable: false, - defaultValue: "Auto"); - - migrationBuilder.CreateTable( - name: "RootFolderRelocationSkippedItems", - columns: table => new - { - Id = table.Column(type: "TEXT", nullable: false), - RelocationId = table.Column(type: "TEXT", nullable: false), - AudiobookId = table.Column(type: "INTEGER", nullable: false), - Reason = table.Column(type: "TEXT", maxLength: 4000, nullable: false), - CreatedAt = table.Column(type: "TEXT", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_RootFolderRelocationSkippedItems", x => x.Id); - table.ForeignKey( - name: "FK_RootFolderRelocationSkippedItems_RootFolderRelocations_RelocationId", - column: x => x.RelocationId, - principalTable: "RootFolderRelocations", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateIndex( - name: "IX_RootFolderRelocationSkippedItems_RelocationId_AudiobookId", - table: "RootFolderRelocationSkippedItems", - columns: new[] { "RelocationId", "AudiobookId" }, - unique: true); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "RootFolderRelocationSkippedItems"); - - migrationBuilder.DropColumn( - name: "SourceCaseSensitivityMode", - table: "RootFolderRelocations"); - } - } -} diff --git a/listenarr.infrastructure/Persistence/Migrations/20260708225028_MakeRootFolderRelocationRootNullable.Designer.cs b/listenarr.infrastructure/Persistence/Migrations/20260708225028_MakeRootFolderRelocationRootNullable.Designer.cs deleted file mode 100644 index e8b01da45..000000000 --- a/listenarr.infrastructure/Persistence/Migrations/20260708225028_MakeRootFolderRelocationRootNullable.Designer.cs +++ /dev/null @@ -1,1829 +0,0 @@ -// -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("20260708225028_MakeRootFolderRelocationRootNullable")] - partial class MakeRootFolderRelocationRootNullable - { - /// - 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("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("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.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("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("SampleRate") - .HasColumnType("INTEGER"); - - b.Property("Size") - .HasColumnType("INTEGER"); - - b.Property("Source") - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("AudiobookId"); - - 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.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("FailureKind") - .IsRequired() - .HasMaxLength(32) - .HasColumnType("TEXT"); - - 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() - .HasMaxLength(32) - .HasColumnType("TEXT"); - - b.Property("RelocationId") - .HasColumnType("TEXT"); - - b.Property("RequestedPath") - .HasColumnType("TEXT"); - - b.Property("SourcePath") - .HasColumnType("TEXT"); - - b.Property("Status") - .IsRequired() - .HasMaxLength(32) - .HasColumnType("TEXT"); - - 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.MoveJobEntry", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - 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.HasKey("Id"); - - b.HasIndex("MoveJobId", "RelativePath") - .IsUnique(); - - b.ToTable("MoveJobEntries", (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() - .HasMaxLength(16) - .HasColumnType("TEXT"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - 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() - .HasMaxLength(16) - .HasColumnType("TEXT"); - - b.Property("ResolvedCaseSensitivity") - .IsRequired() - .HasMaxLength(16) - .HasColumnType("TEXT"); - - b.Property("UpdatedAt") - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - 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("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.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.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.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.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.MoveJob", b => - { - b.HasOne("Listenarr.Domain.Audiobooks.RootFolderRelocation", "Relocation") - .WithMany("MoveJobs") - .HasForeignKey("RelocationId") - .OnDelete(DeleteBehavior.Restrict); - - b.Navigation("Relocation"); - }); - - 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.RootFolderRelocation", b => - { - b.HasOne("Listenarr.Domain.Audiobooks.RootFolder", "RootFolder") - .WithMany("Relocations") - .HasForeignKey("RootFolderId") - .OnDelete(DeleteBehavior.Restrict); - - b.Navigation("RootFolder"); - }); - - 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.MoveJob", b => - { - b.Navigation("Entries"); - }); - - modelBuilder.Entity("Listenarr.Domain.Audiobooks.RootFolder", b => - { - b.Navigation("Relocations"); - }); - - modelBuilder.Entity("Listenarr.Domain.Audiobooks.RootFolderRelocation", b => - { - b.Navigation("MoveJobs"); - - b.Navigation("SkippedItems"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/listenarr.infrastructure/Persistence/Migrations/20260708225028_MakeRootFolderRelocationRootNullable.cs b/listenarr.infrastructure/Persistence/Migrations/20260708225028_MakeRootFolderRelocationRootNullable.cs deleted file mode 100644 index ba1086816..000000000 --- a/listenarr.infrastructure/Persistence/Migrations/20260708225028_MakeRootFolderRelocationRootNullable.cs +++ /dev/null @@ -1,36 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Listenarr.Infrastructure.Persistence.Migrations -{ - /// - public partial class MakeRootFolderRelocationRootNullable : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AlterColumn( - name: "RootFolderId", - table: "RootFolderRelocations", - type: "INTEGER", - nullable: true, - oldClrType: typeof(int), - oldType: "INTEGER"); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.AlterColumn( - name: "RootFolderId", - table: "RootFolderRelocations", - type: "INTEGER", - nullable: false, - defaultValue: 0, - oldClrType: typeof(int), - oldType: "INTEGER", - oldNullable: true); - } - } -} diff --git a/listenarr.infrastructure/Persistence/Migrations/20260708225100_DropRootFolderRelocationRootForeignKey.Designer.cs b/listenarr.infrastructure/Persistence/Migrations/20260708225100_DropRootFolderRelocationRootForeignKey.Designer.cs deleted file mode 100644 index cbdd5485b..000000000 --- a/listenarr.infrastructure/Persistence/Migrations/20260708225100_DropRootFolderRelocationRootForeignKey.Designer.cs +++ /dev/null @@ -1,1814 +0,0 @@ -// -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("20260708225100_DropRootFolderRelocationRootForeignKey")] - partial class DropRootFolderRelocationRootForeignKey - { - /// - 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("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("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.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("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("SampleRate") - .HasColumnType("INTEGER"); - - b.Property("Size") - .HasColumnType("INTEGER"); - - b.Property("Source") - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("AudiobookId"); - - 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.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("FailureKind") - .IsRequired() - .HasMaxLength(32) - .HasColumnType("TEXT"); - - 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() - .HasMaxLength(32) - .HasColumnType("TEXT"); - - b.Property("RelocationId") - .HasColumnType("TEXT"); - - b.Property("RequestedPath") - .HasColumnType("TEXT"); - - b.Property("SourcePath") - .HasColumnType("TEXT"); - - b.Property("Status") - .IsRequired() - .HasMaxLength(32) - .HasColumnType("TEXT"); - - 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.MoveJobEntry", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - 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.HasKey("Id"); - - b.HasIndex("MoveJobId", "RelativePath") - .IsUnique(); - - b.ToTable("MoveJobEntries", (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() - .HasMaxLength(16) - .HasColumnType("TEXT"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - 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() - .HasMaxLength(16) - .HasColumnType("TEXT"); - - b.Property("ResolvedCaseSensitivity") - .IsRequired() - .HasMaxLength(16) - .HasColumnType("TEXT"); - - b.Property("UpdatedAt") - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - 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("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.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.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.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.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.MoveJob", b => - { - b.HasOne("Listenarr.Domain.Audiobooks.RootFolderRelocation", "Relocation") - .WithMany("MoveJobs") - .HasForeignKey("RelocationId") - .OnDelete(DeleteBehavior.Restrict); - - b.Navigation("Relocation"); - }); - - 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.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.MoveJob", b => - { - b.Navigation("Entries"); - }); - - modelBuilder.Entity("Listenarr.Domain.Audiobooks.RootFolderRelocation", b => - { - b.Navigation("MoveJobs"); - - b.Navigation("SkippedItems"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/listenarr.infrastructure/Persistence/Migrations/20260708225100_DropRootFolderRelocationRootForeignKey.cs b/listenarr.infrastructure/Persistence/Migrations/20260708225100_DropRootFolderRelocationRootForeignKey.cs deleted file mode 100644 index 46e174211..000000000 --- a/listenarr.infrastructure/Persistence/Migrations/20260708225100_DropRootFolderRelocationRootForeignKey.cs +++ /dev/null @@ -1,30 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Listenarr.Infrastructure.Persistence.Migrations -{ - /// - public partial class DropRootFolderRelocationRootForeignKey : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropForeignKey( - name: "FK_RootFolderRelocations_RootFolders_RootFolderId", - table: "RootFolderRelocations"); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddForeignKey( - name: "FK_RootFolderRelocations_RootFolders_RootFolderId", - table: "RootFolderRelocations", - column: "RootFolderId", - principalTable: "RootFolders", - principalColumn: "Id", - onDelete: ReferentialAction.Restrict); - } - } -} diff --git a/listenarr.infrastructure/Persistence/Migrations/20260708225144_SetRootFolderRelocationRootDeleteBehavior.Designer.cs b/listenarr.infrastructure/Persistence/Migrations/20260708225144_SetRootFolderRelocationRootDeleteBehavior.Designer.cs deleted file mode 100644 index b6007e955..000000000 --- a/listenarr.infrastructure/Persistence/Migrations/20260708225144_SetRootFolderRelocationRootDeleteBehavior.Designer.cs +++ /dev/null @@ -1,1829 +0,0 @@ -// -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("20260708225144_SetRootFolderRelocationRootDeleteBehavior")] - partial class SetRootFolderRelocationRootDeleteBehavior - { - /// - 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("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("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.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("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("SampleRate") - .HasColumnType("INTEGER"); - - b.Property("Size") - .HasColumnType("INTEGER"); - - b.Property("Source") - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("AudiobookId"); - - 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.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("FailureKind") - .IsRequired() - .HasMaxLength(32) - .HasColumnType("TEXT"); - - 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() - .HasMaxLength(32) - .HasColumnType("TEXT"); - - b.Property("RelocationId") - .HasColumnType("TEXT"); - - b.Property("RequestedPath") - .HasColumnType("TEXT"); - - b.Property("SourcePath") - .HasColumnType("TEXT"); - - b.Property("Status") - .IsRequired() - .HasMaxLength(32) - .HasColumnType("TEXT"); - - 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.MoveJobEntry", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - 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.HasKey("Id"); - - b.HasIndex("MoveJobId", "RelativePath") - .IsUnique(); - - b.ToTable("MoveJobEntries", (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() - .HasMaxLength(16) - .HasColumnType("TEXT"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - 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() - .HasMaxLength(16) - .HasColumnType("TEXT"); - - b.Property("ResolvedCaseSensitivity") - .IsRequired() - .HasMaxLength(16) - .HasColumnType("TEXT"); - - b.Property("UpdatedAt") - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - 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("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.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.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.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.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.MoveJob", b => - { - b.HasOne("Listenarr.Domain.Audiobooks.RootFolderRelocation", "Relocation") - .WithMany("MoveJobs") - .HasForeignKey("RelocationId") - .OnDelete(DeleteBehavior.Restrict); - - b.Navigation("Relocation"); - }); - - 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.RootFolderRelocation", b => - { - b.HasOne("Listenarr.Domain.Audiobooks.RootFolder", "RootFolder") - .WithMany("Relocations") - .HasForeignKey("RootFolderId") - .OnDelete(DeleteBehavior.SetNull); - - b.Navigation("RootFolder"); - }); - - 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.MoveJob", b => - { - b.Navigation("Entries"); - }); - - modelBuilder.Entity("Listenarr.Domain.Audiobooks.RootFolder", b => - { - b.Navigation("Relocations"); - }); - - modelBuilder.Entity("Listenarr.Domain.Audiobooks.RootFolderRelocation", b => - { - b.Navigation("MoveJobs"); - - b.Navigation("SkippedItems"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/listenarr.infrastructure/Persistence/Migrations/20260708225144_SetRootFolderRelocationRootDeleteBehavior.cs b/listenarr.infrastructure/Persistence/Migrations/20260708225144_SetRootFolderRelocationRootDeleteBehavior.cs deleted file mode 100644 index 25bd8caa8..000000000 --- a/listenarr.infrastructure/Persistence/Migrations/20260708225144_SetRootFolderRelocationRootDeleteBehavior.cs +++ /dev/null @@ -1,30 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Listenarr.Infrastructure.Persistence.Migrations -{ - /// - public partial class SetRootFolderRelocationRootDeleteBehavior : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddForeignKey( - name: "FK_RootFolderRelocations_RootFolders_RootFolderId", - table: "RootFolderRelocations", - column: "RootFolderId", - principalTable: "RootFolders", - principalColumn: "Id", - onDelete: ReferentialAction.SetNull); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropForeignKey( - name: "FK_RootFolderRelocations_RootFolders_RootFolderId", - table: "RootFolderRelocations"); - } - } -} diff --git a/listenarr.infrastructure/Persistence/Migrations/20260710172532_AddMoveJobSourceCleanupBoundary.Designer.cs b/listenarr.infrastructure/Persistence/Migrations/20260710172532_AddMoveJobSourceCleanupBoundary.Designer.cs deleted file mode 100644 index be6a553e6..000000000 --- a/listenarr.infrastructure/Persistence/Migrations/20260710172532_AddMoveJobSourceCleanupBoundary.Designer.cs +++ /dev/null @@ -1,1833 +0,0 @@ -// -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("20260710172532_AddMoveJobSourceCleanupBoundary")] - partial class AddMoveJobSourceCleanupBoundary - { - /// - 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("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("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.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("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("SampleRate") - .HasColumnType("INTEGER"); - - b.Property("Size") - .HasColumnType("INTEGER"); - - b.Property("Source") - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("AudiobookId"); - - 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.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("FailureKind") - .IsRequired() - .HasMaxLength(32) - .HasColumnType("TEXT"); - - 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() - .HasMaxLength(32) - .HasColumnType("TEXT"); - - b.Property("RelocationId") - .HasColumnType("TEXT"); - - b.Property("RequestedPath") - .HasColumnType("TEXT"); - - b.Property("SourceCleanupBoundary") - .HasMaxLength(2000) - .HasColumnType("TEXT"); - - b.Property("SourcePath") - .HasColumnType("TEXT"); - - b.Property("Status") - .IsRequired() - .HasMaxLength(32) - .HasColumnType("TEXT"); - - 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.MoveJobEntry", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - 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.HasKey("Id"); - - b.HasIndex("MoveJobId", "RelativePath") - .IsUnique(); - - b.ToTable("MoveJobEntries", (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() - .HasMaxLength(16) - .HasColumnType("TEXT"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - 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() - .HasMaxLength(16) - .HasColumnType("TEXT"); - - b.Property("ResolvedCaseSensitivity") - .IsRequired() - .HasMaxLength(16) - .HasColumnType("TEXT"); - - b.Property("UpdatedAt") - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - 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("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.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.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.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.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.MoveJob", b => - { - b.HasOne("Listenarr.Domain.Audiobooks.RootFolderRelocation", "Relocation") - .WithMany("MoveJobs") - .HasForeignKey("RelocationId") - .OnDelete(DeleteBehavior.Restrict); - - b.Navigation("Relocation"); - }); - - 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.RootFolderRelocation", b => - { - b.HasOne("Listenarr.Domain.Audiobooks.RootFolder", "RootFolder") - .WithMany("Relocations") - .HasForeignKey("RootFolderId") - .OnDelete(DeleteBehavior.SetNull); - - b.Navigation("RootFolder"); - }); - - 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.MoveJob", b => - { - b.Navigation("Entries"); - }); - - modelBuilder.Entity("Listenarr.Domain.Audiobooks.RootFolder", b => - { - b.Navigation("Relocations"); - }); - - modelBuilder.Entity("Listenarr.Domain.Audiobooks.RootFolderRelocation", b => - { - b.Navigation("MoveJobs"); - - b.Navigation("SkippedItems"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/listenarr.infrastructure/Persistence/Migrations/20260710172532_AddMoveJobSourceCleanupBoundary.cs b/listenarr.infrastructure/Persistence/Migrations/20260710172532_AddMoveJobSourceCleanupBoundary.cs deleted file mode 100644 index bd93254fe..000000000 --- a/listenarr.infrastructure/Persistence/Migrations/20260710172532_AddMoveJobSourceCleanupBoundary.cs +++ /dev/null @@ -1,29 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Listenarr.Infrastructure.Persistence.Migrations -{ - /// - public partial class AddMoveJobSourceCleanupBoundary : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "SourceCleanupBoundary", - table: "MoveJobs", - type: "TEXT", - maxLength: 2000, - nullable: true); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn( - name: "SourceCleanupBoundary", - table: "MoveJobs"); - } - } -} diff --git a/listenarr.infrastructure/Persistence/Migrations/20260713181804_HardenMoveExecutionAndScanHandoffs.Designer.cs b/listenarr.infrastructure/Persistence/Migrations/20260713181804_HardenMoveExecutionAndScanHandoffs.Designer.cs deleted file mode 100644 index 5d910a63c..000000000 --- a/listenarr.infrastructure/Persistence/Migrations/20260713181804_HardenMoveExecutionAndScanHandoffs.Designer.cs +++ /dev/null @@ -1,1987 +0,0 @@ -// -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("20260713181804_HardenMoveExecutionAndScanHandoffs")] - partial class HardenMoveExecutionAndScanHandoffs - { - /// - 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.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("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("SampleRate") - .HasColumnType("INTEGER"); - - b.Property("Size") - .HasColumnType("INTEGER"); - - b.Property("Source") - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("AudiobookId"); - - 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.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("FailureKind") - .IsRequired() - .HasMaxLength(32) - .HasColumnType("TEXT"); - - 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() - .HasMaxLength(32) - .HasColumnType("TEXT"); - - 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("SourceIdentityBoundary") - .HasMaxLength(2000) - .HasColumnType("TEXT"); - - b.Property("SourcePath") - .HasColumnType("TEXT"); - - b.Property("SourcePathSyntax") - .HasMaxLength(16) - .HasColumnType("TEXT"); - - b.Property("Status") - .IsRequired() - .HasMaxLength(32) - .HasColumnType("TEXT"); - - b.Property("TargetCaseSensitivity") - .HasMaxLength(16) - .HasColumnType("TEXT"); - - b.Property("TargetCaseSensitivityMode") - .HasMaxLength(16) - .HasColumnType("TEXT"); - - b.Property("TargetIdentityBoundary") - .HasMaxLength(2000) - .HasColumnType("TEXT"); - - b.Property("TargetPathSyntax") - .HasMaxLength(16) - .HasColumnType("TEXT"); - - 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("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("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.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() - .HasMaxLength(16) - .HasColumnType("TEXT"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - 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() - .HasMaxLength(16) - .HasColumnType("TEXT"); - - b.Property("ResolvedCaseSensitivity") - .IsRequired() - .HasMaxLength(16) - .HasColumnType("TEXT"); - - b.Property("UpdatedAt") - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - 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("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.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.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.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.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.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.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.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("MoveJobs"); - - b.Navigation("SkippedItems"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/listenarr.infrastructure/Persistence/Migrations/20260713181804_HardenMoveExecutionAndScanHandoffs.cs b/listenarr.infrastructure/Persistence/Migrations/20260713181804_HardenMoveExecutionAndScanHandoffs.cs deleted file mode 100644 index 2312a068c..000000000 --- a/listenarr.infrastructure/Persistence/Migrations/20260713181804_HardenMoveExecutionAndScanHandoffs.cs +++ /dev/null @@ -1,202 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Listenarr.Infrastructure.Persistence.Migrations -{ - /// - public partial class HardenMoveExecutionAndScanHandoffs : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "SourceCaseSensitivity", - table: "MoveJobs", - type: "TEXT", - maxLength: 16, - nullable: true); - - migrationBuilder.AddColumn( - name: "SourceCaseSensitivityMode", - table: "MoveJobs", - type: "TEXT", - maxLength: 16, - nullable: true); - - migrationBuilder.AddColumn( - name: "SourceIdentityBoundary", - table: "MoveJobs", - type: "TEXT", - maxLength: 2000, - nullable: true); - - migrationBuilder.AddColumn( - name: "SourcePathSyntax", - table: "MoveJobs", - type: "TEXT", - maxLength: 16, - nullable: true); - - migrationBuilder.AddColumn( - name: "TargetCaseSensitivity", - table: "MoveJobs", - type: "TEXT", - maxLength: 16, - nullable: true); - - migrationBuilder.AddColumn( - name: "TargetCaseSensitivityMode", - table: "MoveJobs", - type: "TEXT", - maxLength: 16, - nullable: true); - - migrationBuilder.AddColumn( - name: "TargetIdentityBoundary", - table: "MoveJobs", - type: "TEXT", - maxLength: 2000, - nullable: true); - - migrationBuilder.AddColumn( - name: "TargetPathSyntax", - table: "MoveJobs", - type: "TEXT", - maxLength: 16, - nullable: true); - - migrationBuilder.AddColumn( - name: "IdempotencyKey", - table: "History", - type: "TEXT", - maxLength: 200, - nullable: true); - - migrationBuilder.CreateTable( - name: "MoveJobCreatedDirectories", - columns: table => new - { - Id = table.Column(type: "INTEGER", nullable: false) - .Annotation("Sqlite:Autoincrement", true), - MoveJobId = table.Column(type: "TEXT", nullable: false), - Path = table.Column(type: "TEXT", maxLength: 2000, nullable: false), - State = table.Column(type: "TEXT", maxLength: 16, nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_MoveJobCreatedDirectories", x => x.Id); - table.ForeignKey( - name: "FK_MoveJobCreatedDirectories_MoveJobs_MoveJobId", - column: x => x.MoveJobId, - principalTable: "MoveJobs", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "MoveScanHandoffs", - columns: table => new - { - Id = table.Column(type: "TEXT", nullable: false), - MoveJobId = table.Column(type: "TEXT", nullable: false), - AudiobookId = table.Column(type: "INTEGER", nullable: false), - TargetPath = table.Column(type: "TEXT", maxLength: 2000, nullable: false), - Status = table.Column(type: "TEXT", maxLength: 24, nullable: false), - AttemptGeneration = table.Column(type: "INTEGER", nullable: false), - LeaseOwner = table.Column(type: "TEXT", maxLength: 200, nullable: true), - LeaseGeneration = table.Column(type: "INTEGER", nullable: false), - LeaseExpiresAt = table.Column(type: "TEXT", nullable: true), - NextAttemptAt = table.Column(type: "TEXT", nullable: true), - ActiveScanJobId = table.Column(type: "TEXT", nullable: true), - LastError = table.Column(type: "TEXT", maxLength: 4000, nullable: true), - CreatedAt = table.Column(type: "TEXT", nullable: false), - UpdatedAt = table.Column(type: "TEXT", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_MoveScanHandoffs", x => x.Id); - table.ForeignKey( - name: "FK_MoveScanHandoffs_MoveJobs_MoveJobId", - column: x => x.MoveJobId, - principalTable: "MoveJobs", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateIndex( - name: "IX_History_IdempotencyKey", - table: "History", - column: "IdempotencyKey", - unique: true, - filter: "\"IdempotencyKey\" IS NOT NULL"); - - migrationBuilder.CreateIndex( - name: "IX_MoveJobCreatedDirectories_MoveJobId_Path", - table: "MoveJobCreatedDirectories", - columns: new[] { "MoveJobId", "Path" }, - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_MoveScanHandoffs_MoveJobId", - table: "MoveScanHandoffs", - column: "MoveJobId", - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_MoveScanHandoffs_Status_NextAttemptAt_LeaseExpiresAt", - table: "MoveScanHandoffs", - columns: new[] { "Status", "NextAttemptAt", "LeaseExpiresAt" }); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "MoveJobCreatedDirectories"); - - migrationBuilder.DropTable( - name: "MoveScanHandoffs"); - - migrationBuilder.DropIndex( - name: "IX_History_IdempotencyKey", - table: "History"); - - migrationBuilder.DropColumn( - name: "SourceCaseSensitivity", - table: "MoveJobs"); - - migrationBuilder.DropColumn( - name: "SourceCaseSensitivityMode", - table: "MoveJobs"); - - migrationBuilder.DropColumn( - name: "SourceIdentityBoundary", - table: "MoveJobs"); - - migrationBuilder.DropColumn( - name: "SourcePathSyntax", - table: "MoveJobs"); - - migrationBuilder.DropColumn( - name: "TargetCaseSensitivity", - table: "MoveJobs"); - - migrationBuilder.DropColumn( - name: "TargetCaseSensitivityMode", - table: "MoveJobs"); - - migrationBuilder.DropColumn( - name: "TargetIdentityBoundary", - table: "MoveJobs"); - - migrationBuilder.DropColumn( - name: "TargetPathSyntax", - table: "MoveJobs"); - - migrationBuilder.DropColumn( - name: "IdempotencyKey", - table: "History"); - } - } -} diff --git a/listenarr.infrastructure/Persistence/Migrations/20260717143713_AddLibraryDirectoryOwnership.Designer.cs b/listenarr.infrastructure/Persistence/Migrations/20260717143713_AddLibraryDirectoryOwnership.Designer.cs deleted file mode 100644 index 21a27e2a5..000000000 --- a/listenarr.infrastructure/Persistence/Migrations/20260717143713_AddLibraryDirectoryOwnership.Designer.cs +++ /dev/null @@ -1,2127 +0,0 @@ -// -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("20260717143713_AddLibraryDirectoryOwnership")] - partial class AddLibraryDirectoryOwnership - { - /// - 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.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() - .HasMaxLength(16) - .HasColumnType("TEXT"); - - b.Property("PathCaseSensitivityMode") - .IsRequired() - .HasMaxLength(16) - .HasColumnType("TEXT"); - - 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() - .HasMaxLength(16) - .HasColumnType("TEXT"); - - b.Property("PathIdentityVersion") - .HasColumnType("INTEGER"); - - b.Property("PathOwnershipKey") - .HasMaxLength(160) - .HasColumnType("TEXT"); - - b.Property("PathSyntax") - .HasMaxLength(16) - .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("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("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.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("FailureKind") - .IsRequired() - .HasMaxLength(32) - .HasColumnType("TEXT"); - - 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() - .HasMaxLength(32) - .HasColumnType("TEXT"); - - 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("SourceIdentityBoundary") - .HasMaxLength(2000) - .HasColumnType("TEXT"); - - b.Property("SourcePath") - .HasColumnType("TEXT"); - - b.Property("SourcePathSyntax") - .HasMaxLength(16) - .HasColumnType("TEXT"); - - b.Property("Status") - .IsRequired() - .HasMaxLength(32) - .HasColumnType("TEXT"); - - b.Property("TargetCaseSensitivity") - .HasMaxLength(16) - .HasColumnType("TEXT"); - - b.Property("TargetCaseSensitivityMode") - .HasMaxLength(16) - .HasColumnType("TEXT"); - - b.Property("TargetIdentityBoundary") - .HasMaxLength(2000) - .HasColumnType("TEXT"); - - b.Property("TargetPathSyntax") - .HasMaxLength(16) - .HasColumnType("TEXT"); - - 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("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("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.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() - .HasMaxLength(16) - .HasColumnType("TEXT"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - 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() - .HasMaxLength(16) - .HasColumnType("TEXT"); - - b.Property("ResolvedCaseSensitivity") - .IsRequired() - .HasMaxLength(16) - .HasColumnType("TEXT"); - - b.Property("UpdatedAt") - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - 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("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.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.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.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.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.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.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.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("MoveJobs"); - - b.Navigation("SkippedItems"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/listenarr.infrastructure/Persistence/Migrations/20260717143713_AddLibraryDirectoryOwnership.cs b/listenarr.infrastructure/Persistence/Migrations/20260717143713_AddLibraryDirectoryOwnership.cs deleted file mode 100644 index 3ab762c1a..000000000 --- a/listenarr.infrastructure/Persistence/Migrations/20260717143713_AddLibraryDirectoryOwnership.cs +++ /dev/null @@ -1,205 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Listenarr.Infrastructure.Persistence.Migrations -{ - /// - public partial class AddLibraryDirectoryOwnership : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "CanonicalPath", - table: "AudiobookFiles", - type: "TEXT", - maxLength: 4096, - nullable: true); - - migrationBuilder.AddColumn( - name: "PathCaseSensitivity", - table: "AudiobookFiles", - type: "TEXT", - maxLength: 16, - nullable: false, - defaultValue: ""); - - migrationBuilder.AddColumn( - name: "PathCaseSensitivityMode", - table: "AudiobookFiles", - type: "TEXT", - maxLength: 16, - nullable: false, - defaultValue: ""); - - migrationBuilder.AddColumn( - name: "PathIdentityBoundary", - table: "AudiobookFiles", - type: "TEXT", - maxLength: 4096, - nullable: true); - - migrationBuilder.AddColumn( - name: "PathIdentityLookupKey", - table: "AudiobookFiles", - type: "TEXT", - maxLength: 160, - nullable: true); - - migrationBuilder.AddColumn( - name: "PathIdentityReason", - table: "AudiobookFiles", - type: "TEXT", - maxLength: 1024, - nullable: true); - - migrationBuilder.AddColumn( - name: "PathIdentityState", - table: "AudiobookFiles", - type: "TEXT", - maxLength: 16, - nullable: false, - defaultValue: ""); - - migrationBuilder.AddColumn( - name: "PathIdentityVersion", - table: "AudiobookFiles", - type: "INTEGER", - nullable: false, - defaultValue: 0); - - migrationBuilder.AddColumn( - name: "PathOwnershipKey", - table: "AudiobookFiles", - type: "TEXT", - maxLength: 160, - nullable: true); - - migrationBuilder.AddColumn( - name: "PathSyntax", - table: "AudiobookFiles", - type: "TEXT", - maxLength: 16, - nullable: true); - - migrationBuilder.CreateTable( - name: "LibraryDirectoryOwnerships", - columns: table => new - { - Id = table.Column(type: "INTEGER", nullable: false) - .Annotation("Sqlite:Autoincrement", true), - Path = table.Column(type: "TEXT", maxLength: 2000, nullable: false), - CanonicalPath = table.Column(type: "TEXT", maxLength: 4096, nullable: false), - PathSyntax = table.Column(type: "TEXT", maxLength: 16, nullable: false), - PathCaseSensitivity = table.Column(type: "TEXT", maxLength: 16, nullable: false), - PathCaseSensitivityMode = table.Column(type: "TEXT", maxLength: 16, nullable: false), - PathIdentityBoundary = table.Column(type: "TEXT", maxLength: 4096, nullable: false), - PathIdentityLookupKey = table.Column(type: "TEXT", maxLength: 160, nullable: false), - PathOwnershipKey = table.Column(type: "TEXT", maxLength: 160, nullable: true), - OwnershipToken = table.Column(type: "TEXT", maxLength: 64, nullable: false), - State = table.Column(type: "TEXT", maxLength: 16, nullable: false), - CreationWorkflow = table.Column(type: "TEXT", maxLength: 64, nullable: false), - CreationOperationId = table.Column(type: "TEXT", nullable: true), - AudiobookId = table.Column(type: "INTEGER", nullable: true), - StateReason = table.Column(type: "TEXT", maxLength: 1024, nullable: true), - CreatedAt = table.Column(type: "TEXT", nullable: false), - UpdatedAt = table.Column(type: "TEXT", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_LibraryDirectoryOwnerships", x => x.Id); - }); - - migrationBuilder.CreateIndex( - name: "IX_AudiobookFiles_PathIdentityLookupKey", - table: "AudiobookFiles", - column: "PathIdentityLookupKey"); - - migrationBuilder.CreateIndex( - name: "IX_AudiobookFiles_PathOwnershipKey", - table: "AudiobookFiles", - column: "PathOwnershipKey", - unique: true, - filter: "\"PathOwnershipKey\" IS NOT NULL"); - - migrationBuilder.CreateIndex( - name: "IX_LibraryDirectoryOwnerships_CreationOperationId_State", - table: "LibraryDirectoryOwnerships", - columns: new[] { "CreationOperationId", "State" }); - - migrationBuilder.CreateIndex( - name: "IX_LibraryDirectoryOwnerships_OwnershipToken", - table: "LibraryDirectoryOwnerships", - column: "OwnershipToken", - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_LibraryDirectoryOwnerships_PathIdentityLookupKey", - table: "LibraryDirectoryOwnerships", - column: "PathIdentityLookupKey"); - - migrationBuilder.CreateIndex( - name: "IX_LibraryDirectoryOwnerships_PathOwnershipKey", - table: "LibraryDirectoryOwnerships", - column: "PathOwnershipKey", - unique: true, - filter: "\"PathOwnershipKey\" IS NOT NULL"); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "LibraryDirectoryOwnerships"); - - migrationBuilder.DropIndex( - name: "IX_AudiobookFiles_PathIdentityLookupKey", - table: "AudiobookFiles"); - - migrationBuilder.DropIndex( - name: "IX_AudiobookFiles_PathOwnershipKey", - table: "AudiobookFiles"); - - migrationBuilder.DropColumn( - name: "CanonicalPath", - table: "AudiobookFiles"); - - migrationBuilder.DropColumn( - name: "PathCaseSensitivity", - table: "AudiobookFiles"); - - migrationBuilder.DropColumn( - name: "PathCaseSensitivityMode", - table: "AudiobookFiles"); - - migrationBuilder.DropColumn( - name: "PathIdentityBoundary", - table: "AudiobookFiles"); - - migrationBuilder.DropColumn( - name: "PathIdentityLookupKey", - table: "AudiobookFiles"); - - migrationBuilder.DropColumn( - name: "PathIdentityReason", - table: "AudiobookFiles"); - - migrationBuilder.DropColumn( - name: "PathIdentityState", - table: "AudiobookFiles"); - - migrationBuilder.DropColumn( - name: "PathIdentityVersion", - table: "AudiobookFiles"); - - migrationBuilder.DropColumn( - name: "PathOwnershipKey", - table: "AudiobookFiles"); - - migrationBuilder.DropColumn( - name: "PathSyntax", - table: "AudiobookFiles"); - } - } -} diff --git a/listenarr.infrastructure/Persistence/Migrations/20260726042801_AddDirectoryObjectIdentityAuthorization.Designer.cs b/listenarr.infrastructure/Persistence/Migrations/20260726042801_AddDirectoryObjectIdentityAuthorization.Designer.cs deleted file mode 100644 index d75a6b749..000000000 --- a/listenarr.infrastructure/Persistence/Migrations/20260726042801_AddDirectoryObjectIdentityAuthorization.Designer.cs +++ /dev/null @@ -1,2170 +0,0 @@ -// -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("20260726042801_AddDirectoryObjectIdentityAuthorization")] - partial class AddDirectoryObjectIdentityAuthorization - { - /// - 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.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() - .HasMaxLength(16) - .HasColumnType("TEXT"); - - b.Property("PathCaseSensitivityMode") - .IsRequired() - .HasMaxLength(16) - .HasColumnType("TEXT"); - - 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() - .HasMaxLength(16) - .HasColumnType("TEXT"); - - b.Property("PathIdentityVersion") - .HasColumnType("INTEGER"); - - b.Property("PathOwnershipKey") - .HasMaxLength(160) - .HasColumnType("TEXT"); - - b.Property("PathSyntax") - .HasMaxLength(16) - .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.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("FailureKind") - .IsRequired() - .HasMaxLength(32) - .HasColumnType("TEXT"); - - 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() - .HasMaxLength(32) - .HasColumnType("TEXT"); - - 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("SourceIdentityBoundary") - .HasMaxLength(2000) - .HasColumnType("TEXT"); - - b.Property("SourcePath") - .HasColumnType("TEXT"); - - b.Property("SourcePathSyntax") - .HasMaxLength(16) - .HasColumnType("TEXT"); - - b.Property("Status") - .IsRequired() - .HasMaxLength(32) - .HasColumnType("TEXT"); - - b.Property("TargetCaseSensitivity") - .HasMaxLength(16) - .HasColumnType("TEXT"); - - b.Property("TargetCaseSensitivityMode") - .HasMaxLength(16) - .HasColumnType("TEXT"); - - b.Property("TargetIdentityBoundary") - .HasMaxLength(2000) - .HasColumnType("TEXT"); - - b.Property("TargetPathSyntax") - .HasMaxLength(16) - .HasColumnType("TEXT"); - - 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("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("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.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() - .HasMaxLength(16) - .HasColumnType("TEXT"); - - 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() - .HasMaxLength(16) - .HasColumnType("TEXT"); - - b.Property("ResolvedCaseSensitivity") - .IsRequired() - .HasMaxLength(16) - .HasColumnType("TEXT"); - - b.Property("UpdatedAt") - .HasColumnType("TEXT"); - - 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("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.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.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.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.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.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.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.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("MoveJobs"); - - b.Navigation("SkippedItems"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/listenarr.infrastructure/Persistence/Migrations/20260726042801_AddDirectoryObjectIdentityAuthorization.cs b/listenarr.infrastructure/Persistence/Migrations/20260726042801_AddDirectoryObjectIdentityAuthorization.cs deleted file mode 100644 index 71ab9caf0..000000000 --- a/listenarr.infrastructure/Persistence/Migrations/20260726042801_AddDirectoryObjectIdentityAuthorization.cs +++ /dev/null @@ -1,144 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Listenarr.Infrastructure.Persistence.Migrations -{ - /// - public partial class AddDirectoryObjectIdentityAuthorization : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "DirectoryObjectIdentity", - table: "RootFolders", - type: "TEXT", - maxLength: 256, - nullable: true); - - migrationBuilder.AddColumn( - name: "DirectoryObjectIdentityUnavailableReason", - table: "RootFolders", - type: "TEXT", - maxLength: 1024, - nullable: true); - - migrationBuilder.AddColumn( - name: "DirectoryObjectIdentityVersion", - table: "RootFolders", - type: "INTEGER", - nullable: true); - - migrationBuilder.AddColumn( - name: "TargetDirectoryObjectIdentity", - table: "RootFolderRelocations", - type: "TEXT", - maxLength: 256, - nullable: true); - - migrationBuilder.AddColumn( - name: "TargetDirectoryObjectIdentityUnavailableReason", - table: "RootFolderRelocations", - type: "TEXT", - maxLength: 1024, - nullable: true); - - migrationBuilder.AddColumn( - name: "TargetDirectoryObjectIdentityVersion", - table: "RootFolderRelocations", - type: "INTEGER", - nullable: true); - - migrationBuilder.AddColumn( - name: "DirectoryObjectIdentity", - table: "LibraryDirectoryOwnerships", - type: "TEXT", - maxLength: 256, - nullable: true); - - migrationBuilder.AddColumn( - name: "DirectoryObjectIdentityUnavailableReason", - table: "LibraryDirectoryOwnerships", - type: "TEXT", - maxLength: 1024, - nullable: true); - - migrationBuilder.AddColumn( - name: "DirectoryObjectIdentityVersion", - table: "LibraryDirectoryOwnerships", - type: "INTEGER", - nullable: true); - - migrationBuilder.AddColumn( - name: "ManagedRootFolderId", - table: "LibraryDirectoryOwnerships", - type: "INTEGER", - nullable: true); - - migrationBuilder.CreateIndex( - name: "IX_RootFolders_SingleDefault", - table: "RootFolders", - column: "IsDefault", - unique: true, - filter: "\"IsDefault\" = 1"); - - migrationBuilder.CreateIndex( - name: "IX_LibraryDirectoryOwnerships_ManagedRootFolderId", - table: "LibraryDirectoryOwnerships", - column: "ManagedRootFolderId"); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropIndex( - name: "IX_RootFolders_SingleDefault", - table: "RootFolders"); - - migrationBuilder.DropIndex( - name: "IX_LibraryDirectoryOwnerships_ManagedRootFolderId", - table: "LibraryDirectoryOwnerships"); - - migrationBuilder.DropColumn( - name: "DirectoryObjectIdentity", - table: "RootFolders"); - - migrationBuilder.DropColumn( - name: "DirectoryObjectIdentityUnavailableReason", - table: "RootFolders"); - - migrationBuilder.DropColumn( - name: "DirectoryObjectIdentityVersion", - table: "RootFolders"); - - migrationBuilder.DropColumn( - name: "TargetDirectoryObjectIdentity", - table: "RootFolderRelocations"); - - migrationBuilder.DropColumn( - name: "TargetDirectoryObjectIdentityUnavailableReason", - table: "RootFolderRelocations"); - - migrationBuilder.DropColumn( - name: "TargetDirectoryObjectIdentityVersion", - table: "RootFolderRelocations"); - - migrationBuilder.DropColumn( - name: "DirectoryObjectIdentity", - table: "LibraryDirectoryOwnerships"); - - migrationBuilder.DropColumn( - name: "DirectoryObjectIdentityUnavailableReason", - table: "LibraryDirectoryOwnerships"); - - migrationBuilder.DropColumn( - name: "DirectoryObjectIdentityVersion", - table: "LibraryDirectoryOwnerships"); - - migrationBuilder.DropColumn( - name: "ManagedRootFolderId", - table: "LibraryDirectoryOwnerships"); - } - } -} diff --git a/listenarr.infrastructure/Persistence/Migrations/20260727000644_AddOwnershipRecoveryProtocols.Designer.cs b/listenarr.infrastructure/Persistence/Migrations/20260727000644_AddOwnershipRecoveryProtocols.Designer.cs deleted file mode 100644 index 52919234c..000000000 --- a/listenarr.infrastructure/Persistence/Migrations/20260727000644_AddOwnershipRecoveryProtocols.Designer.cs +++ /dev/null @@ -1,2469 +0,0 @@ -// -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("20260727000644_AddOwnershipRecoveryProtocols")] - partial class AddOwnershipRecoveryProtocols - { - /// - 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.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() - .HasMaxLength(16) - .HasColumnType("TEXT"); - - b.Property("PathCaseSensitivityMode") - .IsRequired() - .HasMaxLength(16) - .HasColumnType("TEXT"); - - 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() - .HasMaxLength(16) - .HasColumnType("TEXT"); - - b.Property("PathIdentityVersion") - .HasColumnType("INTEGER"); - - b.Property("PathOwnershipKey") - .HasMaxLength(160) - .HasColumnType("TEXT"); - - b.Property("PathSyntax") - .HasMaxLength(16) - .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("State") - .IsRequired() - .HasMaxLength(24) - .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.LibraryDirectoryOwnershipRetiredMarker", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("CanonicalMarkerPath") - .HasMaxLength(4096) - .HasColumnType("TEXT"); - - b.Property("CanonicalOwnershipPath") - .IsRequired() - .HasMaxLength(4096) - .HasColumnType("TEXT"); - - b.Property("CanonicalPayload") - .HasMaxLength(16384) - .HasColumnType("TEXT"); - - b.Property("CreatedAt") - .HasColumnType("TEXT"); - - b.Property("DirectoryObjectIdentity") - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.Property("DirectoryObjectIdentityVersion") - .HasColumnType("INTEGER"); - - b.Property("OriginalManagedRootFolderId") - .HasColumnType("INTEGER"); - - b.Property("OwnershipId") - .HasColumnType("INTEGER"); - - b.Property("OwnershipToken") - .IsRequired() - .HasMaxLength(64) - .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("PathSyntax") - .IsRequired() - .HasMaxLength(16) - .HasColumnType("TEXT"); - - b.Property("PayloadSha256") - .HasMaxLength(64) - .HasColumnType("TEXT"); - - b.Property("PayloadVersion") - .HasColumnType("INTEGER"); - - b.Property("State") - .IsRequired() - .HasMaxLength(16) - .HasColumnType("TEXT"); - - b.Property("UpdatedAt") - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("CanonicalMarkerPath") - .IsUnique(); - - b.HasIndex("OwnershipId") - .IsUnique(); - - b.ToTable("LibraryDirectoryOwnershipRetiredMarkers", (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("FailureKind") - .IsRequired() - .HasMaxLength(32) - .HasColumnType("TEXT"); - - 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() - .HasMaxLength(32) - .HasColumnType("TEXT"); - - 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("SourceIdentityBoundary") - .HasMaxLength(2000) - .HasColumnType("TEXT"); - - b.Property("SourcePath") - .HasColumnType("TEXT"); - - b.Property("SourcePathSyntax") - .HasMaxLength(16) - .HasColumnType("TEXT"); - - b.Property("Status") - .IsRequired() - .HasMaxLength(32) - .HasColumnType("TEXT"); - - b.Property("TargetCaseSensitivity") - .HasMaxLength(16) - .HasColumnType("TEXT"); - - b.Property("TargetCaseSensitivityMode") - .HasMaxLength(16) - .HasColumnType("TEXT"); - - b.Property("TargetIdentityBoundary") - .HasMaxLength(2000) - .HasColumnType("TEXT"); - - b.Property("TargetPathSyntax") - .HasMaxLength(16) - .HasColumnType("TEXT"); - - 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("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("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.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() - .HasMaxLength(16) - .HasColumnType("TEXT"); - - 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() - .HasMaxLength(16) - .HasColumnType("TEXT"); - - b.Property("ResolvedCaseSensitivity") - .IsRequired() - .HasMaxLength(16) - .HasColumnType("TEXT"); - - b.Property("UpdatedAt") - .HasColumnType("TEXT"); - - 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.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.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.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.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.LibraryDirectoryOwnershipRetiredMarker", b => - { - b.HasOne("Listenarr.Domain.Audiobooks.LibraryDirectoryOwnership", "Ownership") - .WithOne("RetiredMarker") - .HasForeignKey("Listenarr.Domain.Audiobooks.LibraryDirectoryOwnershipRetiredMarker", "OwnershipId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Ownership"); - }); - - 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"); - - b.Navigation("RetiredMarker"); - }); - - 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/20260727000644_AddOwnershipRecoveryProtocols.cs b/listenarr.infrastructure/Persistence/Migrations/20260727000644_AddOwnershipRecoveryProtocols.cs deleted file mode 100644 index 2b98ae981..000000000 --- a/listenarr.infrastructure/Persistence/Migrations/20260727000644_AddOwnershipRecoveryProtocols.cs +++ /dev/null @@ -1,184 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Listenarr.Infrastructure.Persistence.Migrations -{ - /// - public partial class AddOwnershipRecoveryProtocols : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "TargetIdentityEnrollmentState", - table: "RootFolderRelocations", - type: "TEXT", - maxLength: 24, - nullable: false, - defaultValue: "Authorized"); - - migrationBuilder.CreateTable( - name: "LibraryDirectoryOwnershipPathMigrations", - columns: table => new - { - Id = table.Column(type: "INTEGER", nullable: false) - .Annotation("Sqlite:Autoincrement", true), - OwnershipId = table.Column(type: "INTEGER", nullable: false), - RelocationId = table.Column(type: "TEXT", nullable: false), - SourceCanonicalPath = table.Column(type: "TEXT", maxLength: 4096, nullable: false), - SourcePathSyntax = table.Column(type: "TEXT", maxLength: 16, nullable: false), - SourceCaseSensitivity = table.Column(type: "TEXT", maxLength: 16, nullable: false), - SourceCaseSensitivityMode = table.Column(type: "TEXT", maxLength: 16, nullable: false), - SourceIdentityBoundary = table.Column(type: "TEXT", maxLength: 4096, nullable: false), - SourceIdentityLookupKey = table.Column(type: "TEXT", maxLength: 160, nullable: false), - SourceOwnershipKey = table.Column(type: "TEXT", maxLength: 160, nullable: false), - TargetCanonicalPath = table.Column(type: "TEXT", maxLength: 4096, nullable: false), - TargetPathSyntax = table.Column(type: "TEXT", maxLength: 16, nullable: false), - TargetCaseSensitivity = table.Column(type: "TEXT", maxLength: 16, nullable: false), - TargetCaseSensitivityMode = table.Column(type: "TEXT", maxLength: 16, nullable: false), - TargetIdentityBoundary = table.Column(type: "TEXT", maxLength: 4096, nullable: false), - TargetIdentityLookupKey = table.Column(type: "TEXT", maxLength: 160, nullable: false), - TargetOwnershipKey = table.Column(type: "TEXT", maxLength: 160, nullable: false), - State = table.Column(type: "TEXT", maxLength: 24, nullable: false), - CreatedAt = table.Column(type: "TEXT", nullable: false), - UpdatedAt = table.Column(type: "TEXT", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_LibraryDirectoryOwnershipPathMigrations", x => x.Id); - table.ForeignKey( - name: "FK_LibraryDirectoryOwnershipPathMigrations_LibraryDirectoryOwnerships_OwnershipId", - column: x => x.OwnershipId, - principalTable: "LibraryDirectoryOwnerships", - principalColumn: "Id", - onDelete: ReferentialAction.Restrict); - table.ForeignKey( - name: "FK_LibraryDirectoryOwnershipPathMigrations_RootFolderRelocations_RelocationId", - column: x => x.RelocationId, - principalTable: "RootFolderRelocations", - principalColumn: "Id", - onDelete: ReferentialAction.Restrict); - }); - - migrationBuilder.CreateTable( - name: "LibraryDirectoryOwnershipRetiredMarkers", - columns: table => new - { - Id = table.Column(type: "INTEGER", nullable: false) - .Annotation("Sqlite:Autoincrement", true), - OwnershipId = table.Column(type: "INTEGER", nullable: false), - OwnershipToken = table.Column(type: "TEXT", maxLength: 64, nullable: false), - CanonicalMarkerPath = table.Column(type: "TEXT", maxLength: 4096, nullable: true), - CanonicalOwnershipPath = table.Column(type: "TEXT", maxLength: 4096, nullable: false), - PathSyntax = table.Column(type: "TEXT", maxLength: 16, nullable: false), - PathCaseSensitivity = table.Column(type: "TEXT", maxLength: 16, nullable: false), - PathCaseSensitivityMode = table.Column(type: "TEXT", maxLength: 16, nullable: false), - PathIdentityBoundary = table.Column(type: "TEXT", maxLength: 4096, nullable: false), - CanonicalPayload = table.Column(type: "TEXT", maxLength: 16384, nullable: true), - PayloadSha256 = table.Column(type: "TEXT", maxLength: 64, nullable: true), - PayloadVersion = table.Column(type: "INTEGER", nullable: false), - OriginalManagedRootFolderId = table.Column(type: "INTEGER", nullable: true), - DirectoryObjectIdentityVersion = table.Column(type: "INTEGER", nullable: true), - DirectoryObjectIdentity = table.Column(type: "TEXT", maxLength: 256, nullable: true), - State = table.Column(type: "TEXT", maxLength: 16, nullable: false), - CreatedAt = table.Column(type: "TEXT", nullable: false), - UpdatedAt = table.Column(type: "TEXT", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_LibraryDirectoryOwnershipRetiredMarkers", x => x.Id); - table.ForeignKey( - name: "FK_LibraryDirectoryOwnershipRetiredMarkers_LibraryDirectoryOwnerships_OwnershipId", - column: x => x.OwnershipId, - principalTable: "LibraryDirectoryOwnerships", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "RootFolderRelocationCreatedDirectories", - columns: table => new - { - Id = table.Column(type: "INTEGER", nullable: false) - .Annotation("Sqlite:Autoincrement", true), - RelocationId = table.Column(type: "TEXT", nullable: false), - CanonicalPath = table.Column(type: "TEXT", maxLength: 4096, nullable: false), - OwnershipToken = table.Column(type: "TEXT", maxLength: 64, nullable: false), - State = table.Column(type: "TEXT", maxLength: 16, nullable: false), - DirectoryObjectIdentityVersion = table.Column(type: "INTEGER", nullable: true), - DirectoryObjectIdentity = table.Column(type: "TEXT", maxLength: 256, nullable: true), - CreatedAt = table.Column(type: "TEXT", nullable: false), - UpdatedAt = table.Column(type: "TEXT", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_RootFolderRelocationCreatedDirectories", x => x.Id); - table.ForeignKey( - name: "FK_RootFolderRelocationCreatedDirectories_RootFolderRelocations_RelocationId", - column: x => x.RelocationId, - principalTable: "RootFolderRelocations", - principalColumn: "Id", - onDelete: ReferentialAction.Restrict); - }); - - migrationBuilder.CreateIndex( - name: "IX_LibraryDirectoryOwnershipPathMigrations_OwnershipId_RelocationId", - table: "LibraryDirectoryOwnershipPathMigrations", - columns: new[] { "OwnershipId", "RelocationId" }, - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_LibraryDirectoryOwnershipPathMigrations_RelocationId", - table: "LibraryDirectoryOwnershipPathMigrations", - column: "RelocationId"); - - migrationBuilder.CreateIndex( - name: "IX_LibraryDirectoryOwnershipPathMigrations_TargetOwnershipKey", - table: "LibraryDirectoryOwnershipPathMigrations", - column: "TargetOwnershipKey", - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_LibraryDirectoryOwnershipRetiredMarkers_CanonicalMarkerPath", - table: "LibraryDirectoryOwnershipRetiredMarkers", - column: "CanonicalMarkerPath", - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_LibraryDirectoryOwnershipRetiredMarkers_OwnershipId", - table: "LibraryDirectoryOwnershipRetiredMarkers", - column: "OwnershipId", - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_RootFolderRelocationCreatedDirectories_OwnershipToken", - table: "RootFolderRelocationCreatedDirectories", - column: "OwnershipToken", - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_RootFolderRelocationCreatedDirectories_RelocationId_CanonicalPath", - table: "RootFolderRelocationCreatedDirectories", - columns: new[] { "RelocationId", "CanonicalPath" }, - unique: true); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "LibraryDirectoryOwnershipPathMigrations"); - - migrationBuilder.DropTable( - name: "LibraryDirectoryOwnershipRetiredMarkers"); - - migrationBuilder.DropTable( - name: "RootFolderRelocationCreatedDirectories"); - - migrationBuilder.DropColumn( - name: "TargetIdentityEnrollmentState", - table: "RootFolderRelocations"); - } - } -} diff --git a/listenarr.infrastructure/Persistence/Migrations/20260730033245_AddPhysicalFileIdentityAndMoveCleanupProtection.Designer.cs b/listenarr.infrastructure/Persistence/Migrations/20260730033245_AddPhysicalFileIdentityAndMoveCleanupProtection.Designer.cs deleted file mode 100644 index 5747385ad..000000000 --- a/listenarr.infrastructure/Persistence/Migrations/20260730033245_AddPhysicalFileIdentityAndMoveCleanupProtection.Designer.cs +++ /dev/null @@ -1,2486 +0,0 @@ -// -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("20260730033245_AddPhysicalFileIdentityAndMoveCleanupProtection")] - partial class AddPhysicalFileIdentityAndMoveCleanupProtection - { - /// - 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.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() - .HasMaxLength(16) - .HasColumnType("TEXT"); - - b.Property("PathCaseSensitivityMode") - .IsRequired() - .HasMaxLength(16) - .HasColumnType("TEXT"); - - 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() - .HasMaxLength(16) - .HasColumnType("TEXT"); - - b.Property("PathIdentityVersion") - .HasColumnType("INTEGER"); - - 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("State") - .IsRequired() - .HasMaxLength(24) - .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.LibraryDirectoryOwnershipRetiredMarker", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("CanonicalMarkerPath") - .HasMaxLength(4096) - .HasColumnType("TEXT"); - - b.Property("CanonicalOwnershipPath") - .IsRequired() - .HasMaxLength(4096) - .HasColumnType("TEXT"); - - b.Property("CanonicalPayload") - .HasMaxLength(16384) - .HasColumnType("TEXT"); - - b.Property("CreatedAt") - .HasColumnType("TEXT"); - - b.Property("DirectoryObjectIdentity") - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.Property("DirectoryObjectIdentityVersion") - .HasColumnType("INTEGER"); - - b.Property("OriginalManagedRootFolderId") - .HasColumnType("INTEGER"); - - b.Property("OwnershipId") - .HasColumnType("INTEGER"); - - b.Property("OwnershipToken") - .IsRequired() - .HasMaxLength(64) - .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("PathSyntax") - .IsRequired() - .HasMaxLength(16) - .HasColumnType("TEXT"); - - b.Property("PayloadSha256") - .HasMaxLength(64) - .HasColumnType("TEXT"); - - b.Property("PayloadVersion") - .HasColumnType("INTEGER"); - - b.Property("State") - .IsRequired() - .HasMaxLength(16) - .HasColumnType("TEXT"); - - b.Property("UpdatedAt") - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("CanonicalMarkerPath") - .IsUnique(); - - b.HasIndex("OwnershipId") - .IsUnique(); - - b.ToTable("LibraryDirectoryOwnershipRetiredMarkers", (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("FailureKind") - .IsRequired() - .HasMaxLength(32) - .HasColumnType("TEXT"); - - 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() - .HasMaxLength(32) - .HasColumnType("TEXT"); - - 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("SourceIdentityBoundary") - .HasMaxLength(2000) - .HasColumnType("TEXT"); - - b.Property("SourcePath") - .HasColumnType("TEXT"); - - b.Property("SourcePathSyntax") - .HasMaxLength(16) - .HasColumnType("TEXT"); - - b.Property("Status") - .IsRequired() - .HasMaxLength(32) - .HasColumnType("TEXT"); - - b.Property("TargetCaseSensitivity") - .HasMaxLength(16) - .HasColumnType("TEXT"); - - b.Property("TargetCaseSensitivityMode") - .HasMaxLength(16) - .HasColumnType("TEXT"); - - b.Property("TargetIdentityBoundary") - .HasMaxLength(2000) - .HasColumnType("TEXT"); - - b.Property("TargetPathSyntax") - .HasMaxLength(16) - .HasColumnType("TEXT"); - - 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("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.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() - .HasMaxLength(16) - .HasColumnType("TEXT"); - - 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() - .HasMaxLength(16) - .HasColumnType("TEXT"); - - b.Property("ResolvedCaseSensitivity") - .IsRequired() - .HasMaxLength(16) - .HasColumnType("TEXT"); - - b.Property("UpdatedAt") - .HasColumnType("TEXT"); - - 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.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.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.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.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.LibraryDirectoryOwnershipRetiredMarker", b => - { - b.HasOne("Listenarr.Domain.Audiobooks.LibraryDirectoryOwnership", "Ownership") - .WithOne("RetiredMarker") - .HasForeignKey("Listenarr.Domain.Audiobooks.LibraryDirectoryOwnershipRetiredMarker", "OwnershipId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Ownership"); - }); - - 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"); - - b.Navigation("RetiredMarker"); - }); - - 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/20260730033245_AddPhysicalFileIdentityAndMoveCleanupProtection.cs b/listenarr.infrastructure/Persistence/Migrations/20260730033245_AddPhysicalFileIdentityAndMoveCleanupProtection.cs deleted file mode 100644 index 8484d3b26..000000000 --- a/listenarr.infrastructure/Persistence/Migrations/20260730033245_AddPhysicalFileIdentityAndMoveCleanupProtection.cs +++ /dev/null @@ -1,61 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Listenarr.Infrastructure.Persistence.Migrations -{ - /// - public partial class AddPhysicalFileIdentityAndMoveCleanupProtection : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "CleanupProtectionVersion", - table: "MoveJobEntries", - type: "INTEGER", - nullable: false, - defaultValue: 0); - - migrationBuilder.AddColumn( - name: "PhysicalIdentityObservedAtUtc", - table: "AudiobookFiles", - type: "TEXT", - nullable: true); - - migrationBuilder.AddColumn( - name: "PhysicalIdentityVersion", - table: "AudiobookFiles", - type: "INTEGER", - nullable: false, - defaultValue: 1); - - migrationBuilder.AddColumn( - name: "PhysicalObjectIdentity", - table: "AudiobookFiles", - type: "TEXT", - maxLength: 512, - nullable: true); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn( - name: "CleanupProtectionVersion", - table: "MoveJobEntries"); - - migrationBuilder.DropColumn( - name: "PhysicalIdentityObservedAtUtc", - table: "AudiobookFiles"); - - migrationBuilder.DropColumn( - name: "PhysicalIdentityVersion", - table: "AudiobookFiles"); - - migrationBuilder.DropColumn( - name: "PhysicalObjectIdentity", - table: "AudiobookFiles"); - } - } -} diff --git a/listenarr.infrastructure/Persistence/Migrations/20260805034058_AddLibraryDirectoryOwnershipRootForeignKey.cs b/listenarr.infrastructure/Persistence/Migrations/20260805034058_AddLibraryDirectoryOwnershipRootForeignKey.cs deleted file mode 100644 index 77eb28e6a..000000000 --- a/listenarr.infrastructure/Persistence/Migrations/20260805034058_AddLibraryDirectoryOwnershipRootForeignKey.cs +++ /dev/null @@ -1,30 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Listenarr.Infrastructure.Persistence.Migrations -{ - /// - public partial class AddLibraryDirectoryOwnershipRootForeignKey : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddForeignKey( - name: "FK_LibraryDirectoryOwnerships_RootFolders_ManagedRootFolderId", - table: "LibraryDirectoryOwnerships", - column: "ManagedRootFolderId", - principalTable: "RootFolders", - principalColumn: "Id", - onDelete: ReferentialAction.SetNull); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropForeignKey( - name: "FK_LibraryDirectoryOwnerships_RootFolders_ManagedRootFolderId", - table: "LibraryDirectoryOwnerships"); - } - } -} diff --git a/listenarr.infrastructure/Persistence/Migrations/20260805192525_AddMarkerlessMoveExecutionState.cs b/listenarr.infrastructure/Persistence/Migrations/20260805192525_AddMarkerlessMoveExecutionState.cs deleted file mode 100644 index b6fa2d075..000000000 --- a/listenarr.infrastructure/Persistence/Migrations/20260805192525_AddMarkerlessMoveExecutionState.cs +++ /dev/null @@ -1,96 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Listenarr.Infrastructure.Persistence.Migrations -{ - /// - public partial class AddMarkerlessMoveExecutionState : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "ExecutionProtocolVersion", - table: "MoveJobs", - type: "INTEGER", - nullable: false, - defaultValue: 1); - - migrationBuilder.AddColumn( - name: "SourceDirectoryCleanupState", - table: "MoveJobs", - type: "TEXT", - maxLength: 24, - nullable: false, - defaultValue: "Pending"); - - migrationBuilder.AddColumn( - name: "SourceDirectoryObjectIdentity", - table: "MoveJobs", - type: "TEXT", - maxLength: 512, - nullable: true); - - migrationBuilder.AddColumn( - name: "TargetDirectoryObjectIdentity", - table: "MoveJobs", - type: "TEXT", - maxLength: 512, - nullable: true); - - migrationBuilder.AddColumn( - name: "SourcePhysicalObjectIdentity", - table: "MoveJobEntries", - type: "TEXT", - maxLength: 512, - nullable: true); - - migrationBuilder.AddColumn( - name: "TargetPhysicalObjectIdentity", - table: "MoveJobEntries", - type: "TEXT", - maxLength: 512, - nullable: true); - - migrationBuilder.AddColumn( - name: "DirectoryObjectIdentity", - table: "MoveJobCreatedDirectories", - type: "TEXT", - maxLength: 512, - nullable: true); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn( - name: "ExecutionProtocolVersion", - table: "MoveJobs"); - - migrationBuilder.DropColumn( - name: "SourceDirectoryCleanupState", - table: "MoveJobs"); - - migrationBuilder.DropColumn( - name: "SourceDirectoryObjectIdentity", - table: "MoveJobs"); - - migrationBuilder.DropColumn( - name: "TargetDirectoryObjectIdentity", - table: "MoveJobs"); - - migrationBuilder.DropColumn( - name: "SourcePhysicalObjectIdentity", - table: "MoveJobEntries"); - - migrationBuilder.DropColumn( - name: "TargetPhysicalObjectIdentity", - table: "MoveJobEntries"); - - migrationBuilder.DropColumn( - name: "DirectoryObjectIdentity", - table: "MoveJobCreatedDirectories"); - } - } -} diff --git a/listenarr.infrastructure/Persistence/Migrations/20260805202154_AddMarkerlessFileMutationJournal.Designer.cs b/listenarr.infrastructure/Persistence/Migrations/20260805202154_AddMarkerlessFileMutationJournal.Designer.cs deleted file mode 100644 index 392bee746..000000000 --- a/listenarr.infrastructure/Persistence/Migrations/20260805202154_AddMarkerlessFileMutationJournal.Designer.cs +++ /dev/null @@ -1,2595 +0,0 @@ -// -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("20260805202154_AddMarkerlessFileMutationJournal")] - partial class AddMarkerlessFileMutationJournal - { - /// - 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.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() - .HasMaxLength(16) - .HasColumnType("TEXT"); - - b.Property("PathCaseSensitivityMode") - .IsRequired() - .HasMaxLength(16) - .HasColumnType("TEXT"); - - 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() - .HasMaxLength(16) - .HasColumnType("TEXT"); - - b.Property("PathIdentityVersion") - .HasColumnType("INTEGER"); - - 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("State") - .IsRequired() - .HasMaxLength(24) - .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.LibraryDirectoryOwnershipRetiredMarker", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("CanonicalMarkerPath") - .HasMaxLength(4096) - .HasColumnType("TEXT"); - - b.Property("CanonicalOwnershipPath") - .IsRequired() - .HasMaxLength(4096) - .HasColumnType("TEXT"); - - b.Property("CanonicalPayload") - .HasMaxLength(16384) - .HasColumnType("TEXT"); - - b.Property("CreatedAt") - .HasColumnType("TEXT"); - - b.Property("DirectoryObjectIdentity") - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.Property("DirectoryObjectIdentityVersion") - .HasColumnType("INTEGER"); - - b.Property("OriginalManagedRootFolderId") - .HasColumnType("INTEGER"); - - b.Property("OwnershipId") - .HasColumnType("INTEGER"); - - b.Property("OwnershipToken") - .IsRequired() - .HasMaxLength(64) - .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("PathSyntax") - .IsRequired() - .HasMaxLength(16) - .HasColumnType("TEXT"); - - b.Property("PayloadSha256") - .HasMaxLength(64) - .HasColumnType("TEXT"); - - b.Property("PayloadVersion") - .HasColumnType("INTEGER"); - - b.Property("State") - .IsRequired() - .HasMaxLength(16) - .HasColumnType("TEXT"); - - b.Property("UpdatedAt") - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("CanonicalMarkerPath") - .IsUnique(); - - b.HasIndex("OwnershipId") - .IsUnique(); - - b.ToTable("LibraryDirectoryOwnershipRetiredMarkers", (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(1); - - b.Property("FailureKind") - .IsRequired() - .HasMaxLength(32) - .HasColumnType("TEXT"); - - 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() - .HasMaxLength(32) - .HasColumnType("TEXT"); - - 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("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("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("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() - .HasMaxLength(16) - .HasColumnType("TEXT"); - - 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() - .HasMaxLength(16) - .HasColumnType("TEXT"); - - b.Property("ResolvedCaseSensitivity") - .IsRequired() - .HasMaxLength(16) - .HasColumnType("TEXT"); - - b.Property("UpdatedAt") - .HasColumnType("TEXT"); - - 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.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.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("AudiobookId") - .HasColumnType("INTEGER"); - - b.Property("CreatedAt") - .HasColumnType("TEXT"); - - b.Property("DestinationPath") - .IsRequired() - .HasMaxLength(4096) - .HasColumnType("TEXT"); - - b.Property("Error") - .HasMaxLength(2048) - .HasColumnType("TEXT"); - - b.Property("ProtocolVersion") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(2); - - b.Property("SourceLength") - .HasColumnType("INTEGER"); - - 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.LibraryDirectoryOwnershipRetiredMarker", b => - { - b.HasOne("Listenarr.Domain.Audiobooks.LibraryDirectoryOwnership", "Ownership") - .WithOne("RetiredMarker") - .HasForeignKey("Listenarr.Domain.Audiobooks.LibraryDirectoryOwnershipRetiredMarker", "OwnershipId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Ownership"); - }); - - 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"); - - b.Navigation("RetiredMarker"); - }); - - 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/20260805202154_AddMarkerlessFileMutationJournal.cs b/listenarr.infrastructure/Persistence/Migrations/20260805202154_AddMarkerlessFileMutationJournal.cs deleted file mode 100644 index cfad1fe52..000000000 --- a/listenarr.infrastructure/Persistence/Migrations/20260805202154_AddMarkerlessFileMutationJournal.cs +++ /dev/null @@ -1,56 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Listenarr.Infrastructure.Persistence.Migrations -{ - /// - public partial class AddMarkerlessFileMutationJournal : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "FileMutationJournals", - columns: table => new - { - OperationId = table.Column(type: "TEXT", nullable: false), - ProtocolVersion = table.Column(type: "INTEGER", nullable: false, defaultValue: 2), - Action = table.Column(type: "TEXT", maxLength: 24, nullable: false), - SourcePath = table.Column(type: "TEXT", maxLength: 4096, nullable: false), - DestinationPath = table.Column(type: "TEXT", maxLength: 4096, nullable: false), - SourcePhysicalObjectIdentity = table.Column(type: "TEXT", maxLength: 512, nullable: false), - TargetPhysicalObjectIdentity = table.Column(type: "TEXT", maxLength: 512, nullable: true), - SourceLength = table.Column(type: "INTEGER", nullable: false), - SourceSha256 = table.Column(type: "TEXT", maxLength: 64, nullable: true), - State = table.Column(type: "TEXT", maxLength: 32, nullable: false), - AudiobookId = table.Column(type: "INTEGER", nullable: true), - 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_FileMutationJournals", x => x.OperationId); - }); - - migrationBuilder.CreateIndex( - name: "IX_FileMutationJournals_State", - table: "FileMutationJournals", - column: "State"); - - migrationBuilder.CreateIndex( - name: "IX_FileMutationJournals_UpdatedAt", - table: "FileMutationJournals", - column: "UpdatedAt"); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "FileMutationJournals"); - } - } -} diff --git a/listenarr.infrastructure/Persistence/Migrations/20260805034058_AddLibraryDirectoryOwnershipRootForeignKey.Designer.cs b/listenarr.infrastructure/Persistence/Migrations/20260807200942_AddDurableMarkerlessLibraryMoves.Designer.cs similarity index 95% rename from listenarr.infrastructure/Persistence/Migrations/20260805034058_AddLibraryDirectoryOwnershipRootForeignKey.Designer.cs rename to listenarr.infrastructure/Persistence/Migrations/20260807200942_AddDurableMarkerlessLibraryMoves.Designer.cs index 6f9520bd9..35d03802d 100644 --- a/listenarr.infrastructure/Persistence/Migrations/20260805034058_AddLibraryDirectoryOwnershipRootForeignKey.Designer.cs +++ b/listenarr.infrastructure/Persistence/Migrations/20260807200942_AddDurableMarkerlessLibraryMoves.Designer.cs @@ -11,8 +11,8 @@ namespace Listenarr.Infrastructure.Persistence.Migrations { [DbContext(typeof(ListenArrDbContext))] - [Migration("20260805034058_AddLibraryDirectoryOwnershipRootForeignKey")] - partial class AddLibraryDirectoryOwnershipRootForeignKey + [Migration("20260807200942_AddDurableMarkerlessLibraryMoves")] + partial class AddDurableMarkerlessLibraryMoves { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) @@ -423,13 +423,17 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("PathCaseSensitivity") .IsRequired() + .ValueGeneratedOnAdd() .HasMaxLength(16) - .HasColumnType("TEXT"); + .HasColumnType("TEXT") + .HasDefaultValue("Unknown"); b.Property("PathCaseSensitivityMode") .IsRequired() + .ValueGeneratedOnAdd() .HasMaxLength(16) - .HasColumnType("TEXT"); + .HasColumnType("TEXT") + .HasDefaultValue("Auto"); b.Property("PathIdentityBoundary") .HasMaxLength(4096) @@ -445,11 +449,15 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("PathIdentityState") .IsRequired() + .ValueGeneratedOnAdd() .HasMaxLength(16) - .HasColumnType("TEXT"); + .HasColumnType("TEXT") + .HasDefaultValue("Unavailable"); b.Property("PathIdentityVersion") - .HasColumnType("INTEGER"); + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(1); b.Property("PathOwnershipKey") .HasMaxLength(160) @@ -746,11 +754,6 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasMaxLength(16) .HasColumnType("TEXT"); - b.Property("State") - .IsRequired() - .HasMaxLength(24) - .HasColumnType("TEXT"); - b.Property("TargetCanonicalPath") .IsRequired() .HasMaxLength(4096) @@ -802,92 +805,6 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("LibraryDirectoryOwnershipPathMigrations", (string)null); }); - modelBuilder.Entity("Listenarr.Domain.Audiobooks.LibraryDirectoryOwnershipRetiredMarker", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("CanonicalMarkerPath") - .HasMaxLength(4096) - .HasColumnType("TEXT"); - - b.Property("CanonicalOwnershipPath") - .IsRequired() - .HasMaxLength(4096) - .HasColumnType("TEXT"); - - b.Property("CanonicalPayload") - .HasMaxLength(16384) - .HasColumnType("TEXT"); - - b.Property("CreatedAt") - .HasColumnType("TEXT"); - - b.Property("DirectoryObjectIdentity") - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.Property("DirectoryObjectIdentityVersion") - .HasColumnType("INTEGER"); - - b.Property("OriginalManagedRootFolderId") - .HasColumnType("INTEGER"); - - b.Property("OwnershipId") - .HasColumnType("INTEGER"); - - b.Property("OwnershipToken") - .IsRequired() - .HasMaxLength(64) - .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("PathSyntax") - .IsRequired() - .HasMaxLength(16) - .HasColumnType("TEXT"); - - b.Property("PayloadSha256") - .HasMaxLength(64) - .HasColumnType("TEXT"); - - b.Property("PayloadVersion") - .HasColumnType("INTEGER"); - - b.Property("State") - .IsRequired() - .HasMaxLength(16) - .HasColumnType("TEXT"); - - b.Property("UpdatedAt") - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("CanonicalMarkerPath") - .IsUnique(); - - b.HasIndex("OwnershipId") - .IsUnique(); - - b.ToTable("LibraryDirectoryOwnershipRetiredMarkers", (string)null); - }); - modelBuilder.Entity("Listenarr.Domain.Audiobooks.MonitoredAuthor", b => { b.Property("Id") @@ -1025,10 +942,17 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("Error") .HasColumnType("TEXT"); + b.Property("ExecutionProtocolVersion") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + b.Property("FailureKind") .IsRequired() + .ValueGeneratedOnAdd() .HasMaxLength(32) - .HasColumnType("TEXT"); + .HasColumnType("TEXT") + .HasDefaultValue("None"); b.Property("IdentityKeyVersion") .HasColumnType("INTEGER"); @@ -1050,8 +974,10 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("Phase") .IsRequired() + .ValueGeneratedOnAdd() .HasMaxLength(32) - .HasColumnType("TEXT"); + .HasColumnType("TEXT") + .HasDefaultValue("None"); b.Property("RelocationId") .HasColumnType("TEXT"); @@ -1071,6 +997,17 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasMaxLength(2000) .HasColumnType("TEXT"); + 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"); @@ -1095,6 +1032,10 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasMaxLength(16) .HasColumnType("TEXT"); + b.Property("TargetDirectoryObjectIdentity") + .HasMaxLength(512) + .HasColumnType("TEXT"); + b.Property("TargetIdentityBoundary") .HasMaxLength(2000) .HasColumnType("TEXT"); @@ -1127,6 +1068,10 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .ValueGeneratedOnAdd() .HasColumnType("INTEGER"); + b.Property("DirectoryObjectIdentity") + .HasMaxLength(512) + .HasColumnType("TEXT"); + b.Property("MoveJobId") .HasColumnType("TEXT"); @@ -1192,6 +1137,14 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .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") @@ -1351,8 +1304,10 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("CaseSensitivityMode") .IsRequired() + .ValueGeneratedOnAdd() .HasMaxLength(16) - .HasColumnType("TEXT"); + .HasColumnType("TEXT") + .HasDefaultValue("Auto"); b.Property("CreatedAt") .ValueGeneratedOnAdd() @@ -1391,13 +1346,17 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("PathIdentityState") .IsRequired() + .ValueGeneratedOnAdd() .HasMaxLength(16) - .HasColumnType("TEXT"); + .HasColumnType("TEXT") + .HasDefaultValue("Unavailable"); b.Property("ResolvedCaseSensitivity") .IsRequired() + .ValueGeneratedOnAdd() .HasMaxLength(16) - .HasColumnType("TEXT"); + .HasColumnType("TEXT") + .HasDefaultValue("Unknown"); b.Property("UpdatedAt") .HasColumnType("TEXT"); @@ -2108,6 +2067,75 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) 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("AudiobookId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DestinationPath") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("Error") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("ProtocolVersion") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(2); + + b.Property("SourceLength") + .HasColumnType("INTEGER"); + + 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") @@ -2362,27 +2390,6 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Navigation("Relocation"); }); - modelBuilder.Entity("Listenarr.Domain.Audiobooks.LibraryDirectoryOwnershipRetiredMarker", b => - { - b.HasOne("Listenarr.Domain.Audiobooks.LibraryDirectoryOwnership", "Ownership") - .WithOne("RetiredMarker") - .HasForeignKey("Listenarr.Domain.Audiobooks.LibraryDirectoryOwnershipRetiredMarker", "OwnershipId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Ownership"); - }); - - 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") @@ -2460,8 +2467,6 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) modelBuilder.Entity("Listenarr.Domain.Audiobooks.LibraryDirectoryOwnership", b => { b.Navigation("PathMigrations"); - - b.Navigation("RetiredMarker"); }); modelBuilder.Entity("Listenarr.Domain.Audiobooks.MoveJob", b => @@ -2482,8 +2487,6 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) { b.Navigation("CreatedDirectories"); - b.Navigation("MoveJobs"); - b.Navigation("OwnershipPathMigrations"); b.Navigation("SkippedItems"); diff --git a/listenarr.infrastructure/Persistence/Migrations/20260807200942_AddDurableMarkerlessLibraryMoves.cs b/listenarr.infrastructure/Persistence/Migrations/20260807200942_AddDurableMarkerlessLibraryMoves.cs new file mode 100644 index 000000000..8c9285cea --- /dev/null +++ b/listenarr.infrastructure/Persistence/Migrations/20260807200942_AddDurableMarkerlessLibraryMoves.cs @@ -0,0 +1,972 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Listenarr.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddDurableMarkerlessLibraryMoves : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "CaseSensitivityMode", + table: "RootFolders", + type: "TEXT", + maxLength: 16, + nullable: false, + defaultValue: "Auto"); + + migrationBuilder.AddColumn( + name: "DirectoryObjectIdentity", + table: "RootFolders", + type: "TEXT", + maxLength: 256, + nullable: true); + + migrationBuilder.AddColumn( + name: "DirectoryObjectIdentityUnavailableReason", + table: "RootFolders", + type: "TEXT", + maxLength: 1024, + nullable: true); + + migrationBuilder.AddColumn( + name: "DirectoryObjectIdentityVersion", + table: "RootFolders", + type: "INTEGER", + nullable: true); + + migrationBuilder.AddColumn( + name: "PathIdentityKey", + table: "RootFolders", + type: "TEXT", + maxLength: 128, + nullable: true); + + migrationBuilder.AddColumn( + name: "PathIdentityState", + table: "RootFolders", + type: "TEXT", + maxLength: 16, + nullable: false, + defaultValue: "Unavailable"); + + migrationBuilder.AddColumn( + name: "ResolvedCaseSensitivity", + table: "RootFolders", + type: "TEXT", + maxLength: 16, + nullable: false, + defaultValue: "Unknown"); + + migrationBuilder.AddColumn( + name: "DeleteEmptySource", + table: "MoveJobs", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "ExecutionProtocolVersion", + table: "MoveJobs", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "FailureKind", + table: "MoveJobs", + type: "TEXT", + maxLength: 32, + nullable: false, + defaultValue: "None"); + + migrationBuilder.AddColumn( + name: "IdentityKeyVersion", + table: "MoveJobs", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "LeaseExpiresAt", + table: "MoveJobs", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "LeaseGeneration", + table: "MoveJobs", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "LeaseOwner", + table: "MoveJobs", + type: "TEXT", + maxLength: 200, + nullable: true); + + migrationBuilder.AddColumn( + name: "NextAttemptAt", + table: "MoveJobs", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "Phase", + table: "MoveJobs", + type: "TEXT", + maxLength: 32, + nullable: false, + defaultValue: "None"); + + migrationBuilder.AddColumn( + name: "RelocationId", + table: "MoveJobs", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "SourceCaseSensitivity", + table: "MoveJobs", + type: "TEXT", + maxLength: 16, + nullable: true); + + migrationBuilder.AddColumn( + name: "SourceCaseSensitivityMode", + table: "MoveJobs", + type: "TEXT", + maxLength: 16, + nullable: true); + + migrationBuilder.AddColumn( + name: "SourceCleanupBoundary", + table: "MoveJobs", + type: "TEXT", + maxLength: 2000, + nullable: true); + + migrationBuilder.AddColumn( + name: "SourceDirectoryCleanupState", + table: "MoveJobs", + type: "TEXT", + maxLength: 24, + nullable: false, + defaultValue: "Pending"); + + migrationBuilder.AddColumn( + name: "SourceDirectoryObjectIdentity", + table: "MoveJobs", + type: "TEXT", + maxLength: 512, + nullable: true); + + migrationBuilder.AddColumn( + name: "SourceIdentityBoundary", + table: "MoveJobs", + type: "TEXT", + maxLength: 2000, + nullable: true); + + migrationBuilder.AddColumn( + name: "SourcePathSyntax", + table: "MoveJobs", + type: "TEXT", + maxLength: 16, + nullable: true); + + migrationBuilder.AddColumn( + name: "TargetCaseSensitivity", + table: "MoveJobs", + type: "TEXT", + maxLength: 16, + nullable: true); + + migrationBuilder.AddColumn( + name: "TargetCaseSensitivityMode", + table: "MoveJobs", + type: "TEXT", + maxLength: 16, + nullable: true); + + migrationBuilder.AddColumn( + name: "TargetDirectoryObjectIdentity", + table: "MoveJobs", + type: "TEXT", + maxLength: 512, + nullable: true); + + migrationBuilder.AddColumn( + name: "TargetIdentityBoundary", + table: "MoveJobs", + type: "TEXT", + maxLength: 2000, + nullable: true); + + migrationBuilder.AddColumn( + name: "TargetPathSyntax", + table: "MoveJobs", + type: "TEXT", + maxLength: 16, + nullable: true); + + migrationBuilder.AddColumn( + name: "IdempotencyKey", + table: "History", + type: "TEXT", + maxLength: 200, + nullable: true); + + migrationBuilder.AddColumn( + name: "CanonicalPath", + table: "AudiobookFiles", + type: "TEXT", + maxLength: 4096, + nullable: true); + + migrationBuilder.AddColumn( + name: "PathCaseSensitivity", + table: "AudiobookFiles", + type: "TEXT", + maxLength: 16, + nullable: false, + defaultValue: "Unknown"); + + migrationBuilder.AddColumn( + name: "PathCaseSensitivityMode", + table: "AudiobookFiles", + type: "TEXT", + maxLength: 16, + nullable: false, + defaultValue: "Auto"); + + migrationBuilder.AddColumn( + name: "PathIdentityBoundary", + table: "AudiobookFiles", + type: "TEXT", + maxLength: 4096, + nullable: true); + + migrationBuilder.AddColumn( + name: "PathIdentityLookupKey", + table: "AudiobookFiles", + type: "TEXT", + maxLength: 160, + nullable: true); + + migrationBuilder.AddColumn( + name: "PathIdentityReason", + table: "AudiobookFiles", + type: "TEXT", + maxLength: 1024, + nullable: true); + + migrationBuilder.AddColumn( + name: "PathIdentityState", + table: "AudiobookFiles", + type: "TEXT", + maxLength: 16, + nullable: false, + defaultValue: "Unavailable"); + + migrationBuilder.AddColumn( + name: "PathIdentityVersion", + table: "AudiobookFiles", + type: "INTEGER", + nullable: false, + defaultValue: 1); + + migrationBuilder.AddColumn( + name: "PathOwnershipKey", + table: "AudiobookFiles", + type: "TEXT", + maxLength: 160, + nullable: true); + + migrationBuilder.AddColumn( + name: "PathSyntax", + table: "AudiobookFiles", + type: "TEXT", + maxLength: 16, + nullable: true); + + migrationBuilder.AddColumn( + name: "PhysicalIdentityObservedAtUtc", + table: "AudiobookFiles", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "PhysicalIdentityVersion", + table: "AudiobookFiles", + type: "INTEGER", + nullable: false, + defaultValue: 1); + + migrationBuilder.AddColumn( + name: "PhysicalObjectIdentity", + table: "AudiobookFiles", + type: "TEXT", + maxLength: 512, + nullable: true); + + migrationBuilder.CreateTable( + name: "FileMutationJournals", + columns: table => new + { + OperationId = table.Column(type: "TEXT", nullable: false), + ProtocolVersion = table.Column(type: "INTEGER", nullable: false, defaultValue: 2), + Action = table.Column(type: "TEXT", maxLength: 24, nullable: false), + SourcePath = table.Column(type: "TEXT", maxLength: 4096, nullable: false), + DestinationPath = table.Column(type: "TEXT", maxLength: 4096, nullable: false), + SourcePhysicalObjectIdentity = table.Column(type: "TEXT", maxLength: 512, nullable: false), + TargetPhysicalObjectIdentity = table.Column(type: "TEXT", maxLength: 512, nullable: true), + SourceLength = table.Column(type: "INTEGER", nullable: false), + SourceSha256 = table.Column(type: "TEXT", maxLength: 64, nullable: true), + State = table.Column(type: "TEXT", maxLength: 32, nullable: false), + AudiobookId = table.Column(type: "INTEGER", nullable: true), + 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_FileMutationJournals", x => x.OperationId); + }); + + migrationBuilder.CreateTable( + name: "LibraryDirectoryOwnerships", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Path = table.Column(type: "TEXT", maxLength: 2000, nullable: false), + CanonicalPath = table.Column(type: "TEXT", maxLength: 4096, nullable: false), + PathSyntax = table.Column(type: "TEXT", maxLength: 16, nullable: false), + PathCaseSensitivity = table.Column(type: "TEXT", maxLength: 16, nullable: false), + PathCaseSensitivityMode = table.Column(type: "TEXT", maxLength: 16, nullable: false), + PathIdentityBoundary = table.Column(type: "TEXT", maxLength: 4096, nullable: false), + PathIdentityLookupKey = table.Column(type: "TEXT", maxLength: 160, nullable: false), + PathOwnershipKey = table.Column(type: "TEXT", maxLength: 160, nullable: true), + OwnershipToken = table.Column(type: "TEXT", maxLength: 64, nullable: false), + State = table.Column(type: "TEXT", maxLength: 16, nullable: false), + CreationWorkflow = table.Column(type: "TEXT", maxLength: 64, nullable: false), + CreationOperationId = table.Column(type: "TEXT", nullable: true), + AudiobookId = table.Column(type: "INTEGER", nullable: true), + ManagedRootFolderId = table.Column(type: "INTEGER", nullable: true), + DirectoryObjectIdentityVersion = table.Column(type: "INTEGER", nullable: true), + DirectoryObjectIdentity = table.Column(type: "TEXT", maxLength: 256, nullable: true), + DirectoryObjectIdentityUnavailableReason = table.Column(type: "TEXT", maxLength: 1024, nullable: true), + StateReason = table.Column(type: "TEXT", maxLength: 1024, nullable: true), + CreatedAt = table.Column(type: "TEXT", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_LibraryDirectoryOwnerships", x => x.Id); + table.ForeignKey( + name: "FK_LibraryDirectoryOwnerships_RootFolders_ManagedRootFolderId", + column: x => x.ManagedRootFolderId, + principalTable: "RootFolders", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + }); + + migrationBuilder.CreateTable( + name: "MoveJobCreatedDirectories", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + MoveJobId = table.Column(type: "TEXT", nullable: false), + Path = table.Column(type: "TEXT", maxLength: 2000, nullable: false), + State = table.Column(type: "TEXT", maxLength: 16, nullable: false), + DirectoryObjectIdentity = table.Column(type: "TEXT", maxLength: 512, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_MoveJobCreatedDirectories", x => x.Id); + table.ForeignKey( + name: "FK_MoveJobCreatedDirectories_MoveJobs_MoveJobId", + column: x => x.MoveJobId, + principalTable: "MoveJobs", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "MoveJobEntries", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + MoveJobId = table.Column(type: "TEXT", nullable: false), + RelativePath = table.Column(type: "TEXT", maxLength: 2000, nullable: false), + EntryType = table.Column(type: "TEXT", maxLength: 16, nullable: false), + Length = table.Column(type: "INTEGER", nullable: false), + LastWriteTimeUtc = table.Column(type: "TEXT", nullable: false), + Sha256 = table.Column(type: "TEXT", maxLength: 64, nullable: true), + CopyState = table.Column(type: "TEXT", maxLength: 16, nullable: false), + CleanupState = table.Column(type: "TEXT", maxLength: 16, nullable: false), + CleanupProtectionVersion = table.Column(type: "INTEGER", nullable: false, defaultValue: 0), + SourcePhysicalObjectIdentity = table.Column(type: "TEXT", maxLength: 512, nullable: true), + TargetPhysicalObjectIdentity = table.Column(type: "TEXT", maxLength: 512, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_MoveJobEntries", x => x.Id); + table.ForeignKey( + name: "FK_MoveJobEntries_MoveJobs_MoveJobId", + column: x => x.MoveJobId, + principalTable: "MoveJobs", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "MoveScanHandoffs", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + MoveJobId = table.Column(type: "TEXT", nullable: false), + AudiobookId = table.Column(type: "INTEGER", nullable: false), + TargetPath = table.Column(type: "TEXT", maxLength: 2000, nullable: false), + Status = table.Column(type: "TEXT", maxLength: 24, nullable: false), + AttemptGeneration = table.Column(type: "INTEGER", nullable: false), + LeaseOwner = table.Column(type: "TEXT", maxLength: 200, nullable: true), + LeaseGeneration = table.Column(type: "INTEGER", nullable: false), + LeaseExpiresAt = table.Column(type: "TEXT", nullable: true), + NextAttemptAt = table.Column(type: "TEXT", nullable: true), + ActiveScanJobId = table.Column(type: "TEXT", nullable: true), + LastError = table.Column(type: "TEXT", maxLength: 4000, nullable: true), + CreatedAt = table.Column(type: "TEXT", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_MoveScanHandoffs", x => x.Id); + table.ForeignKey( + name: "FK_MoveScanHandoffs_MoveJobs_MoveJobId", + column: x => x.MoveJobId, + principalTable: "MoveJobs", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "RootFolderRelocations", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + RootFolderId = table.Column(type: "INTEGER", nullable: true), + ActiveRootFolderId = table.Column(type: "INTEGER", nullable: true), + SourcePath = table.Column(type: "TEXT", maxLength: 1000, nullable: false), + SourceCaseSensitivityMode = table.Column(type: "TEXT", maxLength: 16, nullable: false, defaultValue: "Auto"), + TargetPath = table.Column(type: "TEXT", maxLength: 1000, nullable: false), + Mode = table.Column(type: "TEXT", maxLength: 24, nullable: false), + Status = table.Column(type: "TEXT", maxLength: 24, nullable: false), + DeleteEmptySource = table.Column(type: "INTEGER", nullable: false), + DesiredName = table.Column(type: "TEXT", maxLength: 200, nullable: false), + DesiredIsDefault = table.Column(type: "INTEGER", nullable: false), + TargetCaseSensitivityMode = table.Column(type: "TEXT", maxLength: 16, nullable: false), + TargetIdentityEnrollmentState = table.Column(type: "TEXT", maxLength: 24, nullable: false, defaultValue: "Authorized"), + TargetDirectoryObjectIdentityVersion = table.Column(type: "INTEGER", nullable: true), + TargetDirectoryObjectIdentity = table.Column(type: "TEXT", maxLength: 256, nullable: true), + TargetDirectoryObjectIdentityUnavailableReason = table.Column(type: "TEXT", maxLength: 1024, nullable: true), + TotalJobs = table.Column(type: "INTEGER", nullable: false), + CompletedJobs = table.Column(type: "INTEGER", nullable: false), + Error = table.Column(type: "TEXT", maxLength: 4000, nullable: true), + CreatedAt = table.Column(type: "TEXT", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: true), + CompletedAt = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_RootFolderRelocations", x => x.Id); + table.ForeignKey( + name: "FK_RootFolderRelocations_RootFolders_RootFolderId", + column: x => x.RootFolderId, + principalTable: "RootFolders", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + }); + + migrationBuilder.CreateTable( + name: "LibraryDirectoryOwnershipPathMigrations", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + OwnershipId = table.Column(type: "INTEGER", nullable: false), + RelocationId = table.Column(type: "TEXT", nullable: false), + SourceCanonicalPath = table.Column(type: "TEXT", maxLength: 4096, nullable: false), + SourcePathSyntax = table.Column(type: "TEXT", maxLength: 16, nullable: false), + SourceCaseSensitivity = table.Column(type: "TEXT", maxLength: 16, nullable: false), + SourceCaseSensitivityMode = table.Column(type: "TEXT", maxLength: 16, nullable: false), + SourceIdentityBoundary = table.Column(type: "TEXT", maxLength: 4096, nullable: false), + SourceIdentityLookupKey = table.Column(type: "TEXT", maxLength: 160, nullable: false), + SourceOwnershipKey = table.Column(type: "TEXT", maxLength: 160, nullable: false), + TargetCanonicalPath = table.Column(type: "TEXT", maxLength: 4096, nullable: false), + TargetPathSyntax = table.Column(type: "TEXT", maxLength: 16, nullable: false), + TargetCaseSensitivity = table.Column(type: "TEXT", maxLength: 16, nullable: false), + TargetCaseSensitivityMode = table.Column(type: "TEXT", maxLength: 16, nullable: false), + TargetIdentityBoundary = table.Column(type: "TEXT", maxLength: 4096, nullable: false), + TargetIdentityLookupKey = table.Column(type: "TEXT", maxLength: 160, nullable: false), + TargetOwnershipKey = table.Column(type: "TEXT", maxLength: 160, nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_LibraryDirectoryOwnershipPathMigrations", x => x.Id); + table.ForeignKey( + name: "FK_LibraryDirectoryOwnershipPathMigrations_LibraryDirectoryOwnerships_OwnershipId", + column: x => x.OwnershipId, + principalTable: "LibraryDirectoryOwnerships", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_LibraryDirectoryOwnershipPathMigrations_RootFolderRelocations_RelocationId", + column: x => x.RelocationId, + principalTable: "RootFolderRelocations", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "RootFolderRelocationCreatedDirectories", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + RelocationId = table.Column(type: "TEXT", nullable: false), + CanonicalPath = table.Column(type: "TEXT", maxLength: 4096, nullable: false), + OwnershipToken = table.Column(type: "TEXT", maxLength: 64, nullable: false), + State = table.Column(type: "TEXT", maxLength: 16, nullable: false), + DirectoryObjectIdentityVersion = table.Column(type: "INTEGER", nullable: true), + DirectoryObjectIdentity = table.Column(type: "TEXT", maxLength: 256, nullable: true), + CreatedAt = table.Column(type: "TEXT", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_RootFolderRelocationCreatedDirectories", x => x.Id); + table.ForeignKey( + name: "FK_RootFolderRelocationCreatedDirectories_RootFolderRelocations_RelocationId", + column: x => x.RelocationId, + principalTable: "RootFolderRelocations", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "RootFolderRelocationSkippedItems", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + RelocationId = table.Column(type: "TEXT", nullable: false), + AudiobookId = table.Column(type: "INTEGER", nullable: false), + Reason = table.Column(type: "TEXT", maxLength: 4000, nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_RootFolderRelocationSkippedItems", x => x.Id); + table.ForeignKey( + name: "FK_RootFolderRelocationSkippedItems_RootFolderRelocations_RelocationId", + column: x => x.RelocationId, + principalTable: "RootFolderRelocations", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_RootFolders_PathIdentityKey", + table: "RootFolders", + column: "PathIdentityKey", + unique: true, + filter: "\"PathIdentityKey\" IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_RootFolders_SingleDefault", + table: "RootFolders", + column: "IsDefault", + unique: true, + filter: "\"IsDefault\" = 1"); + + migrationBuilder.CreateIndex( + name: "IX_MoveJobs_RelocationId", + table: "MoveJobs", + column: "RelocationId"); + + migrationBuilder.CreateIndex( + name: "IX_MoveJobs_Status_NextAttemptAt_LeaseExpiresAt", + table: "MoveJobs", + columns: new[] { "Status", "NextAttemptAt", "LeaseExpiresAt" }); + + migrationBuilder.CreateIndex( + name: "IX_History_IdempotencyKey", + table: "History", + column: "IdempotencyKey", + unique: true, + filter: "\"IdempotencyKey\" IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_AudiobookFiles_PathIdentityLookupKey", + table: "AudiobookFiles", + column: "PathIdentityLookupKey"); + + migrationBuilder.CreateIndex( + name: "IX_AudiobookFiles_PathOwnershipKey", + table: "AudiobookFiles", + column: "PathOwnershipKey", + unique: true, + filter: "\"PathOwnershipKey\" IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_FileMutationJournals_State", + table: "FileMutationJournals", + column: "State"); + + migrationBuilder.CreateIndex( + name: "IX_FileMutationJournals_UpdatedAt", + table: "FileMutationJournals", + column: "UpdatedAt"); + + migrationBuilder.CreateIndex( + name: "IX_LibraryDirectoryOwnershipPathMigrations_OwnershipId_RelocationId", + table: "LibraryDirectoryOwnershipPathMigrations", + columns: new[] { "OwnershipId", "RelocationId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_LibraryDirectoryOwnershipPathMigrations_RelocationId", + table: "LibraryDirectoryOwnershipPathMigrations", + column: "RelocationId"); + + migrationBuilder.CreateIndex( + name: "IX_LibraryDirectoryOwnershipPathMigrations_TargetOwnershipKey", + table: "LibraryDirectoryOwnershipPathMigrations", + column: "TargetOwnershipKey", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_LibraryDirectoryOwnerships_CreationOperationId_State", + table: "LibraryDirectoryOwnerships", + columns: new[] { "CreationOperationId", "State" }); + + migrationBuilder.CreateIndex( + name: "IX_LibraryDirectoryOwnerships_ManagedRootFolderId", + table: "LibraryDirectoryOwnerships", + column: "ManagedRootFolderId"); + + migrationBuilder.CreateIndex( + name: "IX_LibraryDirectoryOwnerships_OwnershipToken", + table: "LibraryDirectoryOwnerships", + column: "OwnershipToken", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_LibraryDirectoryOwnerships_PathIdentityLookupKey", + table: "LibraryDirectoryOwnerships", + column: "PathIdentityLookupKey"); + + migrationBuilder.CreateIndex( + name: "IX_LibraryDirectoryOwnerships_PathOwnershipKey", + table: "LibraryDirectoryOwnerships", + column: "PathOwnershipKey", + unique: true, + filter: "\"PathOwnershipKey\" IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_MoveJobCreatedDirectories_MoveJobId_Path", + table: "MoveJobCreatedDirectories", + columns: new[] { "MoveJobId", "Path" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_MoveJobEntries_MoveJobId_RelativePath", + table: "MoveJobEntries", + columns: new[] { "MoveJobId", "RelativePath" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_MoveScanHandoffs_MoveJobId", + table: "MoveScanHandoffs", + column: "MoveJobId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_MoveScanHandoffs_Status_NextAttemptAt_LeaseExpiresAt", + table: "MoveScanHandoffs", + columns: new[] { "Status", "NextAttemptAt", "LeaseExpiresAt" }); + + migrationBuilder.CreateIndex( + name: "IX_RootFolderRelocationCreatedDirectories_OwnershipToken", + table: "RootFolderRelocationCreatedDirectories", + column: "OwnershipToken", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_RootFolderRelocationCreatedDirectories_RelocationId_CanonicalPath", + table: "RootFolderRelocationCreatedDirectories", + columns: new[] { "RelocationId", "CanonicalPath" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_RootFolderRelocations_ActiveRootFolderId", + table: "RootFolderRelocations", + column: "ActiveRootFolderId", + unique: true, + filter: "\"ActiveRootFolderId\" IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_RootFolderRelocations_RootFolderId", + table: "RootFolderRelocations", + column: "RootFolderId"); + + migrationBuilder.CreateIndex( + name: "IX_RootFolderRelocationSkippedItems_RelocationId_AudiobookId", + table: "RootFolderRelocationSkippedItems", + columns: new[] { "RelocationId", "AudiobookId" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "FileMutationJournals"); + + migrationBuilder.DropTable( + name: "LibraryDirectoryOwnershipPathMigrations"); + + migrationBuilder.DropTable( + name: "MoveJobCreatedDirectories"); + + migrationBuilder.DropTable( + name: "MoveJobEntries"); + + migrationBuilder.DropTable( + name: "MoveScanHandoffs"); + + migrationBuilder.DropTable( + name: "RootFolderRelocationCreatedDirectories"); + + migrationBuilder.DropTable( + name: "RootFolderRelocationSkippedItems"); + + migrationBuilder.DropTable( + name: "LibraryDirectoryOwnerships"); + + migrationBuilder.DropTable( + name: "RootFolderRelocations"); + + migrationBuilder.DropIndex( + name: "IX_RootFolders_PathIdentityKey", + table: "RootFolders"); + + migrationBuilder.DropIndex( + name: "IX_RootFolders_SingleDefault", + table: "RootFolders"); + + migrationBuilder.DropIndex( + name: "IX_MoveJobs_RelocationId", + table: "MoveJobs"); + + migrationBuilder.DropIndex( + name: "IX_MoveJobs_Status_NextAttemptAt_LeaseExpiresAt", + table: "MoveJobs"); + + migrationBuilder.DropIndex( + name: "IX_History_IdempotencyKey", + table: "History"); + + migrationBuilder.DropIndex( + name: "IX_AudiobookFiles_PathIdentityLookupKey", + table: "AudiobookFiles"); + + migrationBuilder.DropIndex( + name: "IX_AudiobookFiles_PathOwnershipKey", + table: "AudiobookFiles"); + + migrationBuilder.DropColumn( + name: "CaseSensitivityMode", + table: "RootFolders"); + + migrationBuilder.DropColumn( + name: "DirectoryObjectIdentity", + table: "RootFolders"); + + migrationBuilder.DropColumn( + name: "DirectoryObjectIdentityUnavailableReason", + table: "RootFolders"); + + migrationBuilder.DropColumn( + name: "DirectoryObjectIdentityVersion", + table: "RootFolders"); + + migrationBuilder.DropColumn( + name: "PathIdentityKey", + table: "RootFolders"); + + migrationBuilder.DropColumn( + name: "PathIdentityState", + table: "RootFolders"); + + migrationBuilder.DropColumn( + name: "ResolvedCaseSensitivity", + table: "RootFolders"); + + migrationBuilder.DropColumn( + name: "DeleteEmptySource", + table: "MoveJobs"); + + migrationBuilder.DropColumn( + name: "ExecutionProtocolVersion", + table: "MoveJobs"); + + migrationBuilder.DropColumn( + name: "FailureKind", + table: "MoveJobs"); + + migrationBuilder.DropColumn( + name: "IdentityKeyVersion", + table: "MoveJobs"); + + migrationBuilder.DropColumn( + name: "LeaseExpiresAt", + table: "MoveJobs"); + + migrationBuilder.DropColumn( + name: "LeaseGeneration", + table: "MoveJobs"); + + migrationBuilder.DropColumn( + name: "LeaseOwner", + table: "MoveJobs"); + + migrationBuilder.DropColumn( + name: "NextAttemptAt", + table: "MoveJobs"); + + migrationBuilder.DropColumn( + name: "Phase", + table: "MoveJobs"); + + migrationBuilder.DropColumn( + name: "RelocationId", + table: "MoveJobs"); + + migrationBuilder.DropColumn( + name: "SourceCaseSensitivity", + table: "MoveJobs"); + + migrationBuilder.DropColumn( + name: "SourceCaseSensitivityMode", + table: "MoveJobs"); + + migrationBuilder.DropColumn( + name: "SourceCleanupBoundary", + table: "MoveJobs"); + + migrationBuilder.DropColumn( + name: "SourceDirectoryCleanupState", + table: "MoveJobs"); + + migrationBuilder.DropColumn( + name: "SourceDirectoryObjectIdentity", + table: "MoveJobs"); + + migrationBuilder.DropColumn( + name: "SourceIdentityBoundary", + table: "MoveJobs"); + + migrationBuilder.DropColumn( + name: "SourcePathSyntax", + table: "MoveJobs"); + + migrationBuilder.DropColumn( + name: "TargetCaseSensitivity", + table: "MoveJobs"); + + migrationBuilder.DropColumn( + name: "TargetCaseSensitivityMode", + table: "MoveJobs"); + + migrationBuilder.DropColumn( + name: "TargetDirectoryObjectIdentity", + table: "MoveJobs"); + + migrationBuilder.DropColumn( + name: "TargetIdentityBoundary", + table: "MoveJobs"); + + migrationBuilder.DropColumn( + name: "TargetPathSyntax", + table: "MoveJobs"); + + migrationBuilder.DropColumn( + name: "IdempotencyKey", + table: "History"); + + migrationBuilder.DropColumn( + name: "CanonicalPath", + table: "AudiobookFiles"); + + migrationBuilder.DropColumn( + name: "PathCaseSensitivity", + table: "AudiobookFiles"); + + migrationBuilder.DropColumn( + name: "PathCaseSensitivityMode", + table: "AudiobookFiles"); + + migrationBuilder.DropColumn( + name: "PathIdentityBoundary", + table: "AudiobookFiles"); + + migrationBuilder.DropColumn( + name: "PathIdentityLookupKey", + table: "AudiobookFiles"); + + migrationBuilder.DropColumn( + name: "PathIdentityReason", + table: "AudiobookFiles"); + + migrationBuilder.DropColumn( + name: "PathIdentityState", + table: "AudiobookFiles"); + + migrationBuilder.DropColumn( + name: "PathIdentityVersion", + table: "AudiobookFiles"); + + migrationBuilder.DropColumn( + name: "PathOwnershipKey", + table: "AudiobookFiles"); + + migrationBuilder.DropColumn( + name: "PathSyntax", + table: "AudiobookFiles"); + + migrationBuilder.DropColumn( + name: "PhysicalIdentityObservedAtUtc", + table: "AudiobookFiles"); + + migrationBuilder.DropColumn( + name: "PhysicalIdentityVersion", + table: "AudiobookFiles"); + + migrationBuilder.DropColumn( + name: "PhysicalObjectIdentity", + table: "AudiobookFiles"); + } + } +} diff --git a/listenarr.infrastructure/Persistence/Migrations/20260805192525_AddMarkerlessMoveExecutionState.Designer.cs b/listenarr.infrastructure/Persistence/Migrations/20260807204014_AddMoveJobRelocationForeignKey.Designer.cs similarity index 96% rename from listenarr.infrastructure/Persistence/Migrations/20260805192525_AddMarkerlessMoveExecutionState.Designer.cs rename to listenarr.infrastructure/Persistence/Migrations/20260807204014_AddMoveJobRelocationForeignKey.Designer.cs index f3bf1ae5d..d132ea811 100644 --- a/listenarr.infrastructure/Persistence/Migrations/20260805192525_AddMarkerlessMoveExecutionState.Designer.cs +++ b/listenarr.infrastructure/Persistence/Migrations/20260807204014_AddMoveJobRelocationForeignKey.Designer.cs @@ -1,4 +1,4 @@ -// +// using System; using Listenarr.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; @@ -11,8 +11,8 @@ namespace Listenarr.Infrastructure.Persistence.Migrations { [DbContext(typeof(ListenArrDbContext))] - [Migration("20260805192525_AddMarkerlessMoveExecutionState")] - partial class AddMarkerlessMoveExecutionState + [Migration("20260807204014_AddMoveJobRelocationForeignKey")] + partial class AddMoveJobRelocationForeignKey { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) @@ -423,13 +423,17 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("PathCaseSensitivity") .IsRequired() + .ValueGeneratedOnAdd() .HasMaxLength(16) - .HasColumnType("TEXT"); + .HasColumnType("TEXT") + .HasDefaultValue("Unknown"); b.Property("PathCaseSensitivityMode") .IsRequired() + .ValueGeneratedOnAdd() .HasMaxLength(16) - .HasColumnType("TEXT"); + .HasColumnType("TEXT") + .HasDefaultValue("Auto"); b.Property("PathIdentityBoundary") .HasMaxLength(4096) @@ -445,11 +449,15 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("PathIdentityState") .IsRequired() + .ValueGeneratedOnAdd() .HasMaxLength(16) - .HasColumnType("TEXT"); + .HasColumnType("TEXT") + .HasDefaultValue("Unavailable"); b.Property("PathIdentityVersion") - .HasColumnType("INTEGER"); + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(1); b.Property("PathOwnershipKey") .HasMaxLength(160) @@ -746,11 +754,6 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasMaxLength(16) .HasColumnType("TEXT"); - b.Property("State") - .IsRequired() - .HasMaxLength(24) - .HasColumnType("TEXT"); - b.Property("TargetCanonicalPath") .IsRequired() .HasMaxLength(4096) @@ -802,92 +805,6 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("LibraryDirectoryOwnershipPathMigrations", (string)null); }); - modelBuilder.Entity("Listenarr.Domain.Audiobooks.LibraryDirectoryOwnershipRetiredMarker", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("CanonicalMarkerPath") - .HasMaxLength(4096) - .HasColumnType("TEXT"); - - b.Property("CanonicalOwnershipPath") - .IsRequired() - .HasMaxLength(4096) - .HasColumnType("TEXT"); - - b.Property("CanonicalPayload") - .HasMaxLength(16384) - .HasColumnType("TEXT"); - - b.Property("CreatedAt") - .HasColumnType("TEXT"); - - b.Property("DirectoryObjectIdentity") - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.Property("DirectoryObjectIdentityVersion") - .HasColumnType("INTEGER"); - - b.Property("OriginalManagedRootFolderId") - .HasColumnType("INTEGER"); - - b.Property("OwnershipId") - .HasColumnType("INTEGER"); - - b.Property("OwnershipToken") - .IsRequired() - .HasMaxLength(64) - .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("PathSyntax") - .IsRequired() - .HasMaxLength(16) - .HasColumnType("TEXT"); - - b.Property("PayloadSha256") - .HasMaxLength(64) - .HasColumnType("TEXT"); - - b.Property("PayloadVersion") - .HasColumnType("INTEGER"); - - b.Property("State") - .IsRequired() - .HasMaxLength(16) - .HasColumnType("TEXT"); - - b.Property("UpdatedAt") - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("CanonicalMarkerPath") - .IsUnique(); - - b.HasIndex("OwnershipId") - .IsUnique(); - - b.ToTable("LibraryDirectoryOwnershipRetiredMarkers", (string)null); - }); - modelBuilder.Entity("Listenarr.Domain.Audiobooks.MonitoredAuthor", b => { b.Property("Id") @@ -1028,12 +945,14 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("ExecutionProtocolVersion") .ValueGeneratedOnAdd() .HasColumnType("INTEGER") - .HasDefaultValue(1); + .HasDefaultValue(0); b.Property("FailureKind") .IsRequired() + .ValueGeneratedOnAdd() .HasMaxLength(32) - .HasColumnType("TEXT"); + .HasColumnType("TEXT") + .HasDefaultValue("None"); b.Property("IdentityKeyVersion") .HasColumnType("INTEGER"); @@ -1055,8 +974,10 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("Phase") .IsRequired() + .ValueGeneratedOnAdd() .HasMaxLength(32) - .HasColumnType("TEXT"); + .HasColumnType("TEXT") + .HasDefaultValue("None"); b.Property("RelocationId") .HasColumnType("TEXT"); @@ -1383,8 +1304,10 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("CaseSensitivityMode") .IsRequired() + .ValueGeneratedOnAdd() .HasMaxLength(16) - .HasColumnType("TEXT"); + .HasColumnType("TEXT") + .HasDefaultValue("Auto"); b.Property("CreatedAt") .ValueGeneratedOnAdd() @@ -1423,13 +1346,17 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("PathIdentityState") .IsRequired() + .ValueGeneratedOnAdd() .HasMaxLength(16) - .HasColumnType("TEXT"); + .HasColumnType("TEXT") + .HasDefaultValue("Unavailable"); b.Property("ResolvedCaseSensitivity") .IsRequired() + .ValueGeneratedOnAdd() .HasMaxLength(16) - .HasColumnType("TEXT"); + .HasColumnType("TEXT") + .HasDefaultValue("Unknown"); b.Property("UpdatedAt") .HasColumnType("TEXT"); @@ -2140,6 +2067,75 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) 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("AudiobookId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DestinationPath") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("Error") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("ProtocolVersion") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(2); + + b.Property("SourceLength") + .HasColumnType("INTEGER"); + + 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") @@ -2394,17 +2390,6 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Navigation("Relocation"); }); - modelBuilder.Entity("Listenarr.Domain.Audiobooks.LibraryDirectoryOwnershipRetiredMarker", b => - { - b.HasOne("Listenarr.Domain.Audiobooks.LibraryDirectoryOwnership", "Ownership") - .WithOne("RetiredMarker") - .HasForeignKey("Listenarr.Domain.Audiobooks.LibraryDirectoryOwnershipRetiredMarker", "OwnershipId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Ownership"); - }); - modelBuilder.Entity("Listenarr.Domain.Audiobooks.MoveJob", b => { b.HasOne("Listenarr.Domain.Audiobooks.RootFolderRelocation", "Relocation") @@ -2492,8 +2477,6 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) modelBuilder.Entity("Listenarr.Domain.Audiobooks.LibraryDirectoryOwnership", b => { b.Navigation("PathMigrations"); - - b.Navigation("RetiredMarker"); }); modelBuilder.Entity("Listenarr.Domain.Audiobooks.MoveJob", b => diff --git a/listenarr.infrastructure/Persistence/Migrations/20260708224312_AddMoveJobRelocationForeignKey.cs b/listenarr.infrastructure/Persistence/Migrations/20260807204014_AddMoveJobRelocationForeignKey.cs similarity index 100% rename from listenarr.infrastructure/Persistence/Migrations/20260708224312_AddMoveJobRelocationForeignKey.cs rename to listenarr.infrastructure/Persistence/Migrations/20260807204014_AddMoveJobRelocationForeignKey.cs diff --git a/listenarr.infrastructure/Persistence/Migrations/ListenArrDbContextModelSnapshot.cs b/listenarr.infrastructure/Persistence/Migrations/ListenArrDbContextModelSnapshot.cs index 9f873e84f..99c4e25e2 100644 --- a/listenarr.infrastructure/Persistence/Migrations/ListenArrDbContextModelSnapshot.cs +++ b/listenarr.infrastructure/Persistence/Migrations/ListenArrDbContextModelSnapshot.cs @@ -420,13 +420,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("PathCaseSensitivity") .IsRequired() + .ValueGeneratedOnAdd() .HasMaxLength(16) - .HasColumnType("TEXT"); + .HasColumnType("TEXT") + .HasDefaultValue("Unknown"); b.Property("PathCaseSensitivityMode") .IsRequired() + .ValueGeneratedOnAdd() .HasMaxLength(16) - .HasColumnType("TEXT"); + .HasColumnType("TEXT") + .HasDefaultValue("Auto"); b.Property("PathIdentityBoundary") .HasMaxLength(4096) @@ -442,11 +446,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("PathIdentityState") .IsRequired() + .ValueGeneratedOnAdd() .HasMaxLength(16) - .HasColumnType("TEXT"); + .HasColumnType("TEXT") + .HasDefaultValue("Unavailable"); b.Property("PathIdentityVersion") - .HasColumnType("INTEGER"); + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(1); b.Property("PathOwnershipKey") .HasMaxLength(160) @@ -743,11 +751,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(16) .HasColumnType("TEXT"); - b.Property("State") - .IsRequired() - .HasMaxLength(24) - .HasColumnType("TEXT"); - b.Property("TargetCanonicalPath") .IsRequired() .HasMaxLength(4096) @@ -799,92 +802,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("LibraryDirectoryOwnershipPathMigrations", (string)null); }); - modelBuilder.Entity("Listenarr.Domain.Audiobooks.LibraryDirectoryOwnershipRetiredMarker", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("CanonicalMarkerPath") - .HasMaxLength(4096) - .HasColumnType("TEXT"); - - b.Property("CanonicalOwnershipPath") - .IsRequired() - .HasMaxLength(4096) - .HasColumnType("TEXT"); - - b.Property("CanonicalPayload") - .HasMaxLength(16384) - .HasColumnType("TEXT"); - - b.Property("CreatedAt") - .HasColumnType("TEXT"); - - b.Property("DirectoryObjectIdentity") - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.Property("DirectoryObjectIdentityVersion") - .HasColumnType("INTEGER"); - - b.Property("OriginalManagedRootFolderId") - .HasColumnType("INTEGER"); - - b.Property("OwnershipId") - .HasColumnType("INTEGER"); - - b.Property("OwnershipToken") - .IsRequired() - .HasMaxLength(64) - .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("PathSyntax") - .IsRequired() - .HasMaxLength(16) - .HasColumnType("TEXT"); - - b.Property("PayloadSha256") - .HasMaxLength(64) - .HasColumnType("TEXT"); - - b.Property("PayloadVersion") - .HasColumnType("INTEGER"); - - b.Property("State") - .IsRequired() - .HasMaxLength(16) - .HasColumnType("TEXT"); - - b.Property("UpdatedAt") - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("CanonicalMarkerPath") - .IsUnique(); - - b.HasIndex("OwnershipId") - .IsUnique(); - - b.ToTable("LibraryDirectoryOwnershipRetiredMarkers", (string)null); - }); - modelBuilder.Entity("Listenarr.Domain.Audiobooks.MonitoredAuthor", b => { b.Property("Id") @@ -1025,12 +942,14 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("ExecutionProtocolVersion") .ValueGeneratedOnAdd() .HasColumnType("INTEGER") - .HasDefaultValue(1); + .HasDefaultValue(0); b.Property("FailureKind") .IsRequired() + .ValueGeneratedOnAdd() .HasMaxLength(32) - .HasColumnType("TEXT"); + .HasColumnType("TEXT") + .HasDefaultValue("None"); b.Property("IdentityKeyVersion") .HasColumnType("INTEGER"); @@ -1052,8 +971,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Phase") .IsRequired() + .ValueGeneratedOnAdd() .HasMaxLength(32) - .HasColumnType("TEXT"); + .HasColumnType("TEXT") + .HasDefaultValue("None"); b.Property("RelocationId") .HasColumnType("TEXT"); @@ -1380,8 +1301,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("CaseSensitivityMode") .IsRequired() + .ValueGeneratedOnAdd() .HasMaxLength(16) - .HasColumnType("TEXT"); + .HasColumnType("TEXT") + .HasDefaultValue("Auto"); b.Property("CreatedAt") .ValueGeneratedOnAdd() @@ -1420,13 +1343,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("PathIdentityState") .IsRequired() + .ValueGeneratedOnAdd() .HasMaxLength(16) - .HasColumnType("TEXT"); + .HasColumnType("TEXT") + .HasDefaultValue("Unavailable"); b.Property("ResolvedCaseSensitivity") .IsRequired() + .ValueGeneratedOnAdd() .HasMaxLength(16) - .HasColumnType("TEXT"); + .HasColumnType("TEXT") + .HasDefaultValue("Unknown"); b.Property("UpdatedAt") .HasColumnType("TEXT"); @@ -2460,17 +2387,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Relocation"); }); - modelBuilder.Entity("Listenarr.Domain.Audiobooks.LibraryDirectoryOwnershipRetiredMarker", b => - { - b.HasOne("Listenarr.Domain.Audiobooks.LibraryDirectoryOwnership", "Ownership") - .WithOne("RetiredMarker") - .HasForeignKey("Listenarr.Domain.Audiobooks.LibraryDirectoryOwnershipRetiredMarker", "OwnershipId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Ownership"); - }); - modelBuilder.Entity("Listenarr.Domain.Audiobooks.MoveJob", b => { b.HasOne("Listenarr.Domain.Audiobooks.RootFolderRelocation", "Relocation") @@ -2558,8 +2474,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("Listenarr.Domain.Audiobooks.LibraryDirectoryOwnership", b => { b.Navigation("PathMigrations"); - - b.Navigation("RetiredMarker"); }); modelBuilder.Entity("Listenarr.Domain.Audiobooks.MoveJob", b => diff --git a/listenarr.infrastructure/Persistence/Repositories/EfMoveQueuePersistence.Reconciliation.cs b/listenarr.infrastructure/Persistence/Repositories/EfMoveQueuePersistence.Reconciliation.cs index 9b9e57aef..95551e55c 100644 --- a/listenarr.infrastructure/Persistence/Repositories/EfMoveQueuePersistence.Reconciliation.cs +++ b/listenarr.infrastructure/Persistence/Repositories/EfMoveQueuePersistence.Reconciliation.cs @@ -6,10 +6,6 @@ namespace Listenarr.Infrastructure.Persistence.Repositories; public sealed partial class EfMoveQueuePersistence { - private const int MaximumEvidenceEntries = 10_000; - private const int MaximumEvidenceDepth = 128; - private const long MaximumOwnershipMarkerBytes = 64 * 1024; - public async Task ReconcileIdentityKeysAsync(CancellationToken cancellationToken = default) { try @@ -38,6 +34,13 @@ public async Task ReconcileIdentityKeysAsync(CancellationToken cancellationToken var resolvedJobs = new List<(MoveJob Job, string Key, PathIdentitySnapshot TargetIdentity)>(); foreach (var job in activeJobs) { + if (!MoveExecutionProtocol.IsCurrent(job.ExecutionProtocolVersion)) + { + MarkIdentityConflict( + job, + "This move job predates the durable database execution protocol and cannot resume filesystem mutation safely."); + continue; + } if (job.Entries.Count == 0 || job.Entries.All(entry => entry.EntryType != MoveJobEntryType.File)) { @@ -156,52 +159,12 @@ public async Task ReconcileIdentityKeysAsync(CancellationToken cancellationToken foreach (var group in resolvedJobs.GroupBy(item => item.Key, StringComparer.Ordinal)) { var candidates = group.ToList(); - var markerEvidence = ReadTargetOwnershipEvidence(candidates); - if (markerEvidence.State == OwnershipEvidenceState.Ambiguous - || (markerEvidence.OwnerJobId is { } ownerJobId - && candidates.All(candidate => candidate.Job.Id != ownerJobId))) - { - foreach (var candidate in candidates) - { - MarkIdentityConflict( - candidate.Job, - markerEvidence.Error - ?? "The destination contains ambiguous or foreign ownership evidence."); - } - continue; - } - - var evidenceBearing = new List<(MoveJob Job, string Key, PathIdentitySnapshot TargetIdentity)>(); - var evidenceAmbiguous = false; - foreach (var candidate in candidates) - { - var evidence = CollectJobSpecificRecoveryEvidence( + var evidenceBearing = candidates + .Where(candidate => HasDurableExecutionEvidence( candidate.Job, manifestEvidence, - scaffoldEvidence); - if (evidence == JobEvidenceState.Ambiguous) - { - evidenceAmbiguous = true; - break; - } - - if (evidence == JobEvidenceState.Owned - || markerEvidence.OwnerJobId == candidate.Job.Id) - { - evidenceBearing.Add(candidate); - } - } - - if (evidenceAmbiguous) - { - foreach (var candidate in candidates) - { - MarkIdentityConflict( - candidate.Job, - "Move recovery evidence could not be inspected safely."); - } - continue; - } + scaffoldEvidence)) + .ToList(); if (evidenceBearing.Count > 1) { diff --git a/listenarr.infrastructure/Persistence/Repositories/EfMoveQueuePersistence.ReconciliationEvidence.cs b/listenarr.infrastructure/Persistence/Repositories/EfMoveQueuePersistence.ReconciliationEvidence.cs index c666de3f7..6abeb7854 100644 --- a/listenarr.infrastructure/Persistence/Repositories/EfMoveQueuePersistence.ReconciliationEvidence.cs +++ b/listenarr.infrastructure/Persistence/Repositories/EfMoveQueuePersistence.ReconciliationEvidence.cs @@ -1,363 +1,12 @@ -using System.Text.Json; -using Listenarr.Domain.Common; - namespace Listenarr.Infrastructure.Persistence.Repositories; public sealed partial class EfMoveQueuePersistence { - private static OwnershipEvidenceResult ReadTargetOwnershipEvidence( - IReadOnlyCollection<(MoveJob Job, string Key, PathIdentitySnapshot TargetIdentity)> candidates) - { - var sample = candidates.First(); - var target = sample.Job.RequestedPath!; - if (!Directory.Exists(target)) - { - return OwnershipEvidenceResult.None; - } - - try - { - using var targetAnchor = PinnedDirectoryCreation.OpenPinnedDirectoryNoFollow(target); - if (!targetAnchor.VisiblePathMatches()) - { - return OwnershipEvidenceResult.Ambiguous( - "The move target changed while ownership evidence was being inspected."); - } - - const string markerName = ".listenarr-temp-owner.json"; - using var markerEntry = targetAnchor.TryOpenExistingFile( - markerName, - requireDeleteAccess: false); - if (markerEntry != null) - { - if (!markerEntry.VisiblePathMatches()) - { - return OwnershipEvidenceResult.Ambiguous( - "The target ownership marker changed while it was being inspected."); - } - - using var stream = markerEntry.OpenReadStream( - bufferSize: 4096, - asynchronous: false); - if (stream.Length <= 0 || stream.Length > MaximumOwnershipMarkerBytes) - { - return OwnershipEvidenceResult.Ambiguous("The target ownership marker has an invalid size."); - } - - stream.Position = 0; - var marker = JsonSerializer.Deserialize(stream); - if (marker == null - || marker.Version != 1 - || marker.JobId == Guid.Empty - || !string.Equals(marker.ArtifactType, "temporary-directory", StringComparison.Ordinal) - || string.IsNullOrWhiteSpace(marker.Source) - || string.IsNullOrWhiteSpace(marker.Target) - || string.IsNullOrWhiteSpace(marker.DirectoryPath) - || marker.OwnedArtifactType != null - || marker.OwnedDirectoryPath != null) - { - return OwnershipEvidenceResult.Ambiguous("The target ownership marker is corrupt or unsupported."); - } - - var owners = candidates - .Where(candidate => candidate.Job.Id == marker.JobId) - .ToList(); - if (owners.Count != 1 - || !owners[0].Job.TryGetSourceIdentity(out var sourceIdentity) - || string.IsNullOrWhiteSpace(owners[0].Job.SourcePath) - || string.IsNullOrWhiteSpace(owners[0].Job.RequestedPath)) - { - return OwnershipEvidenceResult.Ambiguous( - "The target ownership marker cannot be attributed to one active move identity."); - } - - var owner = owners[0]; - var sourcePath = owner.Job.SourcePath!; - var requestedPath = owner.Job.RequestedPath!; - var markerSource = marker.Source!; - var markerTarget = marker.Target!; - var markerDirectoryPath = marker.DirectoryPath!; - var targetParent = Path.GetDirectoryName(requestedPath); - if (string.IsNullOrWhiteSpace(targetParent)) - { - return OwnershipEvidenceResult.Ambiguous( - "The target ownership marker has no valid destination parent."); - } - - var expectedDirectory = Path.Join( - targetParent, - Path.GetFileName(requestedPath) - + ".tmp-" - + owner.Job.Id.ToString("N")); - if (!FileSystemPathIdentity.AreEquivalent( - markerSource, - sourcePath, - sourceIdentity.Semantics) - || !FileSystemPathIdentity.AreEquivalent( - markerTarget, - requestedPath, - owner.TargetIdentity.Semantics) - || !FileSystemPathIdentity.AreEquivalent( - markerDirectoryPath, - expectedDirectory, - owner.TargetIdentity.Semantics)) - { - return OwnershipEvidenceResult.Ambiguous( - "The target ownership marker does not match the persisted source, target, or temporary directory."); - } - - return OwnershipEvidenceResult.Valid(marker.JobId); - } - - if (Directory.EnumerateFiles( - target, - ".listenarr-temp-owner.json.writing-*", - SearchOption.TopDirectoryOnly) - .Any()) - { - return OwnershipEvidenceResult.Ambiguous( - "An incomplete target ownership-marker publication exists and cannot establish an owner."); - } - - return OwnershipEvidenceResult.None; - } - catch (Exception exception) when (exception is - IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException or PathTooLongException or JsonException) - { - return OwnershipEvidenceResult.Ambiguous( - $"Target ownership evidence is unreadable: {exception.Message}"); - } - } - - private static JobEvidenceState CollectJobSpecificRecoveryEvidence( + private static bool HasDurableExecutionEvidence( MoveJob job, IReadOnlySet manifestEvidence, - IReadOnlySet scaffoldEvidence) - { - if (job.Phase > MoveJobPhase.Planned - || manifestEvidence.Contains(job.Id) - || scaffoldEvidence.Contains(job.Id)) - { - return JobEvidenceState.Owned; - } - - try - { - if (!TryResolveRecoveryEvidenceEndpoint( - job, - target: false, - out var source) - || !TryResolveRecoveryEvidenceEndpoint( - job, - target: true, - out var target)) - { - return JobEvidenceState.Ambiguous; - } - if (target != null) - { - var targetMarker = Path.Join( - target, - $".listenarr-move-{job.Id:N}.pending"); - var targetParent = Path.GetDirectoryName(target); - if (HasMarkerOrWriteFile(targetMarker) - || HasCleanupEvidence(targetParent, job.Id) - || (targetParent != null - && Directory.Exists(Path.Join( - targetParent, - Path.GetFileName(target) + ".tmp-" + job.Id.ToString("N")))) - || HasOwnedPartialFile(target, job.Id)) - { - return JobEvidenceState.Ambiguous; - } - } - - if (source != null) - { - var sourceParent = Path.GetDirectoryName(source); - var sourceMarker = Path.Join( - source, - $".listenarr-move-{job.Id:N}.pending"); - if (HasMarkerOrWriteFile(sourceMarker) - || HasCleanupEvidence(sourceParent, job.Id) - || (sourceParent != null - && Directory.Exists(Path.Join( - sourceParent, - $".listenarr-quarantine-{job.Id:N}")))) - { - return JobEvidenceState.Ambiguous; - } - } - } - catch (Exception exception) when (exception is - IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException or PathTooLongException) - { - return JobEvidenceState.Ambiguous; - } - - return JobEvidenceState.None; - } - - private static bool TryResolveRecoveryEvidenceEndpoint( - MoveJob job, - bool target, - out string? resolvedPath) - { - var storedPath = target ? job.RequestedPath : job.SourcePath; - if (string.IsNullOrWhiteSpace(storedPath)) - { - resolvedPath = null; - return true; - } - - var hasIdentity = target - ? job.TryGetTargetIdentity(out var identity) - : job.TryGetSourceIdentity(out identity); - if (hasIdentity) - { - var resolved = FileSystemPathIdentity.TryCanonicalizeStoredPathWithIdentityForHost( - storedPath, - identity, - out var canonicalPath, - out _); - resolvedPath = resolved ? canonicalPath : null; - return resolved; - } - - var legacyResolved = - FileSystemPathIdentity.TryCanonicalizeUnambiguousStoredAbsolutePathForHost( - storedPath, - out var legacyCanonicalPath, - out _); - resolvedPath = legacyResolved ? legacyCanonicalPath : null; - return legacyResolved; - } - - private static bool HasMarkerOrWriteFile(string markerPath) - { - if (File.Exists(markerPath)) - { - return true; - } - - var directory = Path.GetDirectoryName(markerPath); - return directory != null - && Directory.Exists(directory) - && Directory.EnumerateFiles( - directory, - Path.GetFileName(markerPath) + ".writing-*", - SearchOption.TopDirectoryOnly) - .Any(); - } - - private static bool HasCleanupEvidence(string? parent, Guid jobId) => - parent != null - && Directory.Exists(parent) - && (Directory.EnumerateFiles( - parent, - $".listenarr-*-{jobId:N}.cleanup.json", - SearchOption.TopDirectoryOnly) - .Any() - || Directory.EnumerateDirectories( - parent, - $".listenarr-*-{jobId:N}.cleanup-dir", - SearchOption.TopDirectoryOnly) - .Any()); - - private static bool HasOwnedPartialFile(string target, Guid jobId) - { - if (!Directory.Exists(target)) - { - return false; - } - - var suffix = $".listenarr-{jobId:N}.partial"; - var pending = new Stack<(string Path, int Depth)>(); - pending.Push((target, 0)); - var inspected = 0; - while (pending.Count > 0) - { - var (directory, depth) = pending.Pop(); - if (depth > MaximumEvidenceDepth) - { - throw new IOException("Move evidence exceeded the maximum directory depth."); - } - - if ((File.GetAttributes(directory) & FileAttributes.ReparsePoint) != 0) - { - throw new IOException("Move evidence contains a linked directory."); - } - - foreach (var entry in Directory.EnumerateFileSystemEntries(directory)) - { - inspected++; - if (inspected > MaximumEvidenceEntries) - { - throw new IOException("Move evidence exceeded the maximum entry count."); - } - - var attributes = File.GetAttributes(entry); - if ((attributes & FileAttributes.ReparsePoint) != 0) - { - throw new IOException("Move evidence contains a linked entry."); - } - - if ((attributes & FileAttributes.Directory) != 0) - { - pending.Push((entry, depth + 1)); - } - else if (entry.EndsWith(suffix, StringComparison.Ordinal)) - { - return true; - } - } - } - - return false; - } - - private enum JobEvidenceState - { - None, - Owned, - Ambiguous - } - - private enum OwnershipEvidenceState - { - None, - Valid, - Ambiguous - } - - private sealed record OwnershipEvidenceResult( - OwnershipEvidenceState State, - Guid? OwnerJobId, - string? Error) - { - public static OwnershipEvidenceResult None { get; } = new( - OwnershipEvidenceState.None, - null, - null); - - public static OwnershipEvidenceResult Valid(Guid ownerJobId) => new( - OwnershipEvidenceState.Valid, - ownerJobId, - null); - - public static OwnershipEvidenceResult Ambiguous(string error) => new( - OwnershipEvidenceState.Ambiguous, - null, - error); - } - - private sealed record OwnershipMarkerIdentity( - int Version, - string? ArtifactType, - Guid JobId, - string? Source, - string? Target, - string? DirectoryPath, - string? OwnedArtifactType = null, - string? OwnedDirectoryPath = null); + IReadOnlySet scaffoldEvidence) => + job.Phase > MoveJobPhase.Planned + || manifestEvidence.Contains(job.Id) + || scaffoldEvidence.Contains(job.Id); } diff --git a/tests/Features/Api/Features/Library/LibraryController_DeleteFilesystemTests.cs b/tests/Features/Api/Features/Library/LibraryController_DeleteFilesystemTests.cs index af6def75c..5eedd5a61 100644 --- a/tests/Features/Api/Features/Library/LibraryController_DeleteFilesystemTests.cs +++ b/tests/Features/Api/Features/Library/LibraryController_DeleteFilesystemTests.cs @@ -1096,10 +1096,6 @@ await AddAuthorizedRootAsync(new RootFolder Assert.Equal(LibraryDirectoryOwnershipState.Removed, ownership.State); Assert.Null(ownership.PathOwnershipKey); }); - Assert.All(ownerships, ownership => - Assert.All( - LibraryDirectoryOwnershipMarker.GetMarkerPaths(ownership), - markerPath => Assert.False(File.Exists(markerPath)))); } [Fact] @@ -1581,19 +1577,11 @@ await AddAuthorizedRootAsync(new RootFolder new FailingNthMarkRemovedOwnershipStore(ownershipStore, failOnCall: 2), _provider.GetRequiredService>(), _provider.GetRequiredService()); - var authorSiblingMarker = LibraryDirectoryOwnershipMarker - .GetMarkerPaths(authorOwnership) - .Single(path => !FileSystemPathIdentity.IsSameOrInside( - path, - authorFolder, - FileSystemPathSemantics.CurrentHostDefault)); - await Assert.ThrowsAsync(() => failingService.DeleteAsync(audiobook, deleteFolder: true)); Assert.False(Directory.Exists(bookFolder)); Assert.False(Directory.Exists(authorFolder)); - Assert.False(File.Exists(authorSiblingMarker)); var factory = _provider.GetRequiredService>(); await using (var interruptedDb = await factory.CreateDbContextAsync()) { @@ -1609,7 +1597,6 @@ await Assert.ThrowsAsync(() => var normalService = _provider.GetRequiredService(); await normalService.DeleteAsync(audiobook, deleteFolder: true); - Assert.False(File.Exists(authorSiblingMarker)); await using var recoveredDb = await factory.CreateDbContextAsync(); var recoveredAuthor = await recoveredDb.LibraryDirectoryOwnerships.AsNoTracking() .SingleAsync(candidate => candidate.Id == authorOwnership.Id); diff --git a/tests/Features/Api/Features/Library/LibraryController_MoveTests.cs b/tests/Features/Api/Features/Library/LibraryController_MoveTests.cs index 09974f975..3596d8296 100644 --- a/tests/Features/Api/Features/Library/LibraryController_MoveTests.cs +++ b/tests/Features/Api/Features/Library/LibraryController_MoveTests.cs @@ -202,7 +202,7 @@ public async Task GetMoveJobStatus_NeedsAttentionVerification_ReportsOperatorRep RelativePath = "book.m4b", EntryType = MoveJobEntryType.File, CopyState = MoveJobEntryCopyState.Verified, - CleanupState = MoveJobEntryCleanupState.Quarantined + CleanupState = MoveJobEntryCleanupState.DeleteAuthorized } ] }; diff --git a/tests/Features/Api/Services/FileMoverFallbackTests.cs b/tests/Features/Api/Services/FileMoverFallbackTests.cs index 6ff269595..1b6598a71 100644 --- a/tests/Features/Api/Services/FileMoverFallbackTests.cs +++ b/tests/Features/Api/Services/FileMoverFallbackTests.cs @@ -2052,7 +2052,7 @@ public async Task MoveFileAsync_VerifiedCopyFallback_RemovesSourceBeforeReportin Assert.Equal("content", await File.ReadAllTextAsync(destinationFile)); Assert.Empty(Directory.EnumerateFiles( _root, - "*.listenarr-move-*.partial", + "*.partial", SearchOption.TopDirectoryOnly)); } diff --git a/tests/Features/Application/Audiobooks/Jobs/MoveQueueServiceTests.cs b/tests/Features/Application/Audiobooks/Jobs/MoveQueueServiceTests.cs index 1edb1f01f..d42c0d409 100644 --- a/tests/Features/Application/Audiobooks/Jobs/MoveQueueServiceTests.cs +++ b/tests/Features/Application/Audiobooks/Jobs/MoveQueueServiceTests.cs @@ -1245,7 +1245,7 @@ public async Task RequeueMoveAsync_NeedsAttentionVerificationWithValidEvidence_R LastWriteTimeUtc = DateTime.UnixEpoch, Sha256 = new string('A', 64), CopyState = MoveJobEntryCopyState.Verified, - CleanupState = MoveJobEntryCleanupState.Quarantined + CleanupState = MoveJobEntryCleanupState.DeleteAuthorized }, MoveManifestIdentity.CreateTargetBoundaryAuthorization( 2, diff --git a/tests/Features/Application/Audiobooks/Jobs/MoveRecoveryPolicyTests.cs b/tests/Features/Application/Audiobooks/Jobs/MoveRecoveryPolicyTests.cs index f69190264..e9429cca3 100644 --- a/tests/Features/Application/Audiobooks/Jobs/MoveRecoveryPolicyTests.cs +++ b/tests/Features/Application/Audiobooks/Jobs/MoveRecoveryPolicyTests.cs @@ -121,7 +121,7 @@ public void ClassifyAudiobookJobs_NeedsAttentionVerification_IsOperatorRepairOnl MoveJobPhase.CleaningSource, MoveFailureKind.Verification, MoveJobEntryCopyState.Verified, - MoveJobEntryCleanupState.Quarantined); + MoveJobEntryCleanupState.DeleteAuthorized); var state = MoveRecoveryPolicy.ClassifyAudiobookJobs([job]); @@ -130,6 +130,48 @@ public void ClassifyAudiobookJobs_NeedsAttentionVerification_IsOperatorRepairOnl Assert.False(state.CanRetry); } + [Theory] + [InlineData(MoveJobStatus.NeedsAttention)] + [InlineData(MoveJobStatus.Failed)] + [InlineData(MoveJobStatus.Queued)] + public void ClassifyAudiobookJobs_PreDurableUnresolvedJob_BlocksWithoutCurrentExecutionEvidence( + MoveJobStatus status) + { + var job = CreateJob( + status, + MoveJobPhase.None, + MoveFailureKind.Verification, + MoveJobEntryCopyState.Pending, + MoveJobEntryCleanupState.Pending); + job.ExecutionProtocolVersion = MoveExecutionProtocol.PreDurableReleased; + job.Entries.Clear(); + + var state = MoveRecoveryPolicy.ClassifyAudiobookJobs([job]); + + Assert.Equal(MoveRecoveryDisposition.OperatorRepairRequired, state.Disposition); + Assert.True(state.BlocksFilesystemMutation); + Assert.False(state.CanRetry); + Assert.Equal(job.Id, state.JobId); + } + + [Fact] + public void ClassifyAudiobookJobs_PreDurableCompletedJob_DoesNotBlock() + { + var job = CreateJob( + MoveJobStatus.Completed, + MoveJobPhase.None, + MoveFailureKind.None, + MoveJobEntryCopyState.Pending, + MoveJobEntryCleanupState.Pending); + job.ExecutionProtocolVersion = MoveExecutionProtocol.PreDurableReleased; + job.Entries.Clear(); + + var state = MoveRecoveryPolicy.ClassifyAudiobookJobs([job]); + + Assert.Equal(MoveRecoveryDisposition.None, state.Disposition); + Assert.False(state.BlocksFilesystemMutation); + } + [Fact] public void ClassifyAudiobookJobs_MultipleUnresolvedExecutions_FailsClosedAsAmbiguous() { @@ -144,7 +186,7 @@ public void ClassifyAudiobookJobs_MultipleUnresolvedExecutions_FailsClosedAsAmbi MoveJobPhase.CleaningSource, MoveFailureKind.Transient, MoveJobEntryCopyState.Verified, - MoveJobEntryCleanupState.Quarantined); + MoveJobEntryCleanupState.DeleteAuthorized); var state = MoveRecoveryPolicy.ClassifyAudiobookJobs([first, second]); diff --git a/tests/Features/Application/Audiobooks/RootFolders/RootFolderServiceTests.cs b/tests/Features/Application/Audiobooks/RootFolders/RootFolderServiceTests.cs index e251a58c4..b37b441c0 100644 --- a/tests/Features/Application/Audiobooks/RootFolders/RootFolderServiceTests.cs +++ b/tests/Features/Application/Audiobooks/RootFolders/RootFolderServiceTests.cs @@ -366,7 +366,7 @@ await Assert.ThrowsAsync(() => FileUtils.GetAbsolutePath("different-root"))); } -[Fact] + [Fact] public async Task ReauthorizeDirectoryIdentity_ActiveMoveTouchingRoot_IsBlockedBeforeEnrollment() { var directory = CreateTempDirectory("root-identity-reauthorize-active-move"); @@ -709,7 +709,7 @@ public async Task Create_Throws_WhenNestedInsideExistingRoot() Assert.Contains("nested", exception.Message, StringComparison.OrdinalIgnoreCase); } -[LinuxFact] + [LinuxFact] public async Task Create_InsensitiveRequestedRootRejectsCaseVariantNestedExistingRoot() { diff --git a/tests/Features/Application/Configuration/Core/StartupConfigServiceTests.cs b/tests/Features/Application/Configuration/Core/StartupConfigServiceTests.cs index a5a06d3f1..4261a3d5d 100644 --- a/tests/Features/Application/Configuration/Core/StartupConfigServiceTests.cs +++ b/tests/Features/Application/Configuration/Core/StartupConfigServiceTests.cs @@ -22,14 +22,18 @@ public class StartupConfigServiceTests [Fact] public async Task SaveAsync_PreservesAuthenticationRequired() { - // arrange - ensure no existing config on disk - var baseDir = AppContext.BaseDirectory; + // arrange - isolate startup configuration from the shared test-runner + // application directory, which can concurrently host runtime lock files. + var baseDir = Path.Join( + Path.GetTempPath(), + "listenarr-startup-config-tests", + Guid.NewGuid().ToString("N")); var cfgDir = Path.Join(baseDir, "config"); using var loggerFactory = new LoggerFactory(); var logger = loggerFactory.CreateLogger(); var pathServiceMock = new Moq.Mock(); - pathServiceMock.Setup(e => e.ContentRootPath).Returns(AppContext.BaseDirectory); + pathServiceMock.Setup(e => e.ContentRootPath).Returns(baseDir); try { @@ -84,8 +88,8 @@ public async Task SaveAsync_PreservesAuthenticationRequired() } finally { - if (Directory.Exists(cfgDir)) - Directory.Delete(cfgDir, recursive: true); + if (Directory.Exists(baseDir)) + Directory.Delete(baseDir, recursive: true); } } } diff --git a/tests/Features/Infrastructure/DependencyInjection/InfrastructureStartupCompositionExtensionsTests.cs b/tests/Features/Infrastructure/DependencyInjection/InfrastructureStartupCompositionExtensionsTests.cs index 818abb3e3..a4f19b352 100644 --- a/tests/Features/Infrastructure/DependencyInjection/InfrastructureStartupCompositionExtensionsTests.cs +++ b/tests/Features/Infrastructure/DependencyInjection/InfrastructureStartupCompositionExtensionsTests.cs @@ -86,11 +86,15 @@ public void ApplyListenarrDatabaseMigrations_NormalizesLegacyCanaryDataBeforeCon .GetRequiredService>() .CreateDbContext(); var moveJob = verification.MoveJobs.AsNoTracking().Single(job => job.Id == moveJobId); - Assert.Equal(MoveJobStatus.Running, moveJob.Status); - Assert.Equal(1, moveJob.IdentityKeyVersion); - Assert.Equal( - $"legacy:{moveJobId.ToString().ToUpperInvariant()}", - moveJob.ActiveDeduplicationKey); + Assert.Equal(MoveJobStatus.NeedsAttention, moveJob.Status); + Assert.Equal(MoveFailureKind.Verification, moveJob.FailureKind); + Assert.Equal(MoveExecutionProtocol.PreDurableReleased, moveJob.ExecutionProtocolVersion); + Assert.Equal(0, moveJob.IdentityKeyVersion); + Assert.Null(moveJob.ActiveDeduplicationKey); + Assert.Contains( + "pre-durable released version", + moveJob.Error, + StringComparison.Ordinal); Assert.Equal([10], verification.RootFolders .AsNoTracking() .Where(root => root.IsDefault) @@ -147,66 +151,11 @@ public void ApplyListenarrDatabaseMigrations_RepeatedStartupPreservesCurrentMove .CreateDbContext(); var moveJob = verification.MoveJobs.AsNoTracking().Single(job => job.Id == moveJobId); Assert.Equal(MoveJobStatus.Running, moveJob.Status); + Assert.Equal(MoveExecutionProtocol.Current, moveJob.ExecutionProtocolVersion); Assert.Equal(MoveManifestIdentity.Version, moveJob.IdentityKeyVersion); Assert.Equal(currentKey, moveJob.ActiveDeduplicationKey); } - [Fact] - [Trait("Scenario", "LegacyOwnershipForeignKeyMigration")] - public void ApplyListenarrDatabaseMigrations_RepairsLegacyOwnershipBeforeForeignKey() - { - using var connection = new SqliteConnection("DataSource=:memory:"); - connection.Open(); - var baselineOptions = new DbContextOptionsBuilder() - .UseSqlite(connection, sqlite => - sqlite.MigrationsAssembly( - typeof(ListenArrDbContext).Assembly.GetName().Name)) - .Options; - using (var baseline = new ListenArrDbContext(baselineOptions)) - { - baseline.GetService().Migrate( - LibraryDirectoryOwnershipMigrationPreflight.PredecessorMigrationId); - baseline.Database.ExecuteSqlRaw( - """ - INSERT INTO "LibraryDirectoryOwnerships" ( - "Id", "Path", "CanonicalPath", "PathSyntax", - "PathCaseSensitivity", "PathCaseSensitivityMode", - "PathIdentityBoundary", "PathIdentityLookupKey", - "PathOwnershipKey", "OwnershipToken", "State", - "CreationWorkflow", "CreatedAt", "UpdatedAt", - "ManagedRootFolderId") - VALUES ( - 404, '/orphan', '/orphan', 'Unix', 'Sensitive', - 'Sensitive', '/orphan', 'lookup-404', 'ownership-404', - '40440440440440440440440440440440', 'Owned', 'test', - '2026-08-01T00:00:00Z', '2026-08-01T00:00:00Z', 999); - """); - } - - var services = new ServiceCollection(); - services.AddDbContextFactory(options => - options - .UseSqlite(connection, sqlite => - sqlite.MigrationsAssembly( - typeof(ListenArrDbContext).Assembly.GetName().Name)) - .ConfigureWarnings(warnings => warnings.Throw( - RelationalEventId.NonTransactionalMigrationOperationWarning))); - using var provider = services.BuildServiceProvider(); - - provider.ApplyListenarrDatabaseMigrations(); - - var factory = provider.GetRequiredService>(); - using var verification = factory.CreateDbContext(); - var ownership = verification.LibraryDirectoryOwnerships.Single( - candidate => candidate.Id == 404); - Assert.Equal(LibraryDirectoryOwnershipState.Unavailable, ownership.State); - Assert.Null(ownership.ManagedRootFolderId); - Assert.Null(ownership.PathOwnershipKey); - Assert.Contains( - LibraryDirectoryOwnershipMigrationPreflight.ForeignKeyMigrationId, - verification.Database.GetAppliedMigrations()); - } - [Fact] [Trait("Scenario", "MigrationFailureFailsStartupClosed")] public void ApplyListenarrDatabaseMigrations_MigrationFailurePropagates() diff --git a/tests/Features/Infrastructure/FileSystem/DirectoryObjectIdentityResolverTests.cs b/tests/Features/Infrastructure/FileSystem/DirectoryObjectIdentityResolverTests.cs index 80e628a8f..6dbdfd662 100644 --- a/tests/Features/Infrastructure/FileSystem/DirectoryObjectIdentityResolverTests.cs +++ b/tests/Features/Infrastructure/FileSystem/DirectoryObjectIdentityResolverTests.cs @@ -63,7 +63,7 @@ public async Task ResolveExistingAsync_DifferentNativeGeneration_IsUnavailable() StringComparison.OrdinalIgnoreCase); } -[Fact] + [Fact] public async Task ResolveAsync_ForeignPersistedSyntax_FailsClosedBeforeNativeProbeOrMarkerWrite() { var directory = FileService.GetTempDirectory("directory-object-identity-foreign-syntax"); diff --git a/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceCleanupRaceTests.cs b/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceCleanupRaceTests.cs deleted file mode 100644 index 00a991df9..000000000 --- a/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceCleanupRaceTests.cs +++ /dev/null @@ -1,667 +0,0 @@ -using Listenarr.Tests.Common; -using Microsoft.EntityFrameworkCore; - -namespace Listenarr.Tests.Features.Infrastructure.Library.Moving; - -public partial class AudiobookContentMoveServiceTests -{ - [Fact] - public async Task MoveContentsAsync_EmptySourceGenerationReplacedBeforeQuarantine_IsPreserved() - { - var source = FileService.GetTempDirectory("content-move-empty-root-replacement-src"); - await FileService.GetFileAsync(source, "book.m4b", "verified audio"); - var originalGeneration = source + $"-original-{Guid.NewGuid():N}"; - var target = Path.Join( - FileService.GetTempPath(), - $"content-move-empty-root-replacement-dst-{Guid.NewGuid():N}"); - var request = await CreateLeasedMoveRequestAsync(source, target); - var service = new AudiobookContentMoveService( - _provider.GetRequiredService>(), - _provider.GetRequiredService>(), - TimeProvider.System, - new ReplaceEmptySourceBeforeQuarantine(source, originalGeneration)); - - await Assert.ThrowsAsync(() => - service.MoveContentsAsync(request, CancellationToken.None)); - - Assert.True(Directory.Exists(source)); - Assert.Empty(Directory.EnumerateFileSystemEntries(source)); - Assert.True(Directory.Exists(originalGeneration)); - Assert.True(File.Exists(Path.Join(target, "book.m4b"))); - } - - [DirectoryLinkFact] - public async Task MoveContentsAsync_SourceRootReplacedBeforeQuarantineMove_PreservesExternalFile() - { - NativeTestCapabilityPolicy.RequireAvailable( - NativeTestCapability.DirectorySymbolicLinks); - var source = FileService.GetTempDirectory("content-move-cleanup-race-src"); - await FileService.GetFileAsync(source, "book.m4b", "verified audio"); - var sourceBackup = source + $"-backup-{Guid.NewGuid():N}"; - var target = Path.Join( - FileService.GetTempPath(), - $"content-move-cleanup-race-dst-{Guid.NewGuid():N}"); - var external = FileService.GetTempDirectory("content-move-cleanup-race-external"); - var externalFile = await FileService.GetFileAsync( - external, - "book.m4b", - "verified audio"); - var request = await CreateLeasedMoveRequestAsync(source, target); - var injector = new ReplaceSourceRootBeforeCleanupMove( - source, - sourceBackup, - external); - var service = new AudiobookContentMoveService( - _provider.GetRequiredService>(), - _provider.GetRequiredService>(), - TimeProvider.System, - injector); - - try - { - await Assert.ThrowsAsync(() => - service.MoveContentsAsync(request, CancellationToken.None)); - - Assert.Equal("verified audio", await File.ReadAllTextAsync(externalFile)); - Assert.True(File.Exists(Path.Join(sourceBackup, "book.m4b"))); - Assert.True(File.Exists(Path.Join(target, "book.m4b"))); - } - finally - { - TryRemoveDirectoryLink(source); - if (Directory.Exists(sourceBackup) && !Directory.Exists(source)) - { - Directory.Move(sourceBackup, source); - } - } - } - - [DirectoryLinkFact] - public async Task MoveContentsAsync_NestedSourceParentReplacedAfterRevalidation_DoesNotConsumeExternalFile() - { - NativeTestCapabilityPolicy.RequireAvailable( - NativeTestCapability.DirectorySymbolicLinks); - var source = FileService.GetTempDirectory("content-move-nested-cleanup-race-src"); - var nestedSource = Path.Join(source, "extras"); - Directory.CreateDirectory(nestedSource); - var sourceFile = await FileService.GetFileAsync( - nestedSource, - "book.m4b", - "verified audio"); - var nestedSourceBackup = nestedSource + $"-backup-{Guid.NewGuid():N}"; - var target = Path.Join( - FileService.GetTempPath(), - $"content-move-nested-cleanup-race-dst-{Guid.NewGuid():N}"); - var external = FileService.GetTempDirectory("content-move-nested-cleanup-race-external"); - var externalFile = await FileService.GetFileAsync( - external, - "book.m4b", - "verified audio"); - var request = await CreateLeasedMoveRequestAsync(source, target); - var service = new AudiobookContentMoveService( - _provider.GetRequiredService>(), - _provider.GetRequiredService>(), - TimeProvider.System, - new ReplaceNestedSourceParentAfterRevalidation( - nestedSource, - nestedSourceBackup, - external)); - - try - { - await Assert.ThrowsAnyAsync(() => - service.MoveContentsAsync(request, CancellationToken.None)); - - Assert.Equal("verified audio", await File.ReadAllTextAsync(externalFile)); - var displacedSourceFile = Path.Join(nestedSourceBackup, "book.m4b"); - Assert.True(File.Exists(sourceFile) || File.Exists(displacedSourceFile)); - Assert.Equal( - "verified audio", - await File.ReadAllTextAsync( - File.Exists(sourceFile) ? sourceFile : displacedSourceFile)); - Assert.True(File.Exists(Path.Join(target, "extras", "book.m4b"))); - } - finally - { - TryRemoveDirectoryLink(nestedSource); - if (Directory.Exists(nestedSourceBackup) && !Directory.Exists(nestedSource)) - { - Directory.Move(nestedSourceBackup, nestedSource); - } - } - } - - [Fact] - public async Task MoveContentsAsync_UnownedTargetEntryAppearsBeforeSourceMove_PreservesSource() - { - var source = FileService.GetTempDirectory("content-move-unowned-target-cleanup-src"); - await FileService.GetFileAsync(source, "book.m4b", "verified audio"); - var target = Path.Join( - FileService.GetTempPath(), - $"content-move-unowned-target-cleanup-dst-{Guid.NewGuid():N}"); - var request = await CreateLeasedMoveRequestAsync(source, target); - var service = new AudiobookContentMoveService( - _provider.GetRequiredService>(), - _provider.GetRequiredService>(), - TimeProvider.System, - new AddUnownedTargetEntryBeforeSourceMove(target)); - - var exception = await Assert.ThrowsAsync(() => - service.MoveContentsAsync(request, CancellationToken.None)); - - Assert.Contains("unowned file", exception.Message, StringComparison.OrdinalIgnoreCase); - Assert.True(File.Exists(Path.Join(source, "book.m4b"))); - Assert.True(File.Exists(Path.Join(target, "book.m4b"))); - Assert.Equal( - "preserve me", - await File.ReadAllTextAsync(Path.Join(target, "operator-note.txt"))); - var quarantineRoot = Path.Join( - Path.GetDirectoryName(source)!, - $".listenarr-quarantine-{request.JobId:N}"); - Assert.False(File.Exists(Path.Join(quarantineRoot, "book.m4b"))); - } - - [Fact] - public async Task MoveContentsAsync_QuarantineFileReplacedAfterRevalidation_DoesNotDeleteExternalBytes() - { - var source = FileService.GetTempDirectory("content-move-quarantine-file-race-src"); - await FileService.GetFileAsync(source, "book.m4b", "verified audio"); - var target = Path.Join( - FileService.GetTempPath(), - $"content-move-quarantine-file-race-dst-{Guid.NewGuid():N}"); - var external = FileService.GetTempDirectory("content-move-quarantine-file-race-external"); - var externalFile = await FileService.GetFileAsync( - external, - "external.m4b", - "external bytes"); - var request = await CreateLeasedMoveRequestAsync(source, target); - var quarantineRoot = Path.Join( - Path.GetDirectoryName(source)!, - $".listenarr-quarantine-{request.JobId:N}"); - var quarantineFile = Path.Join(quarantineRoot, "book.m4b"); - var quarantineBackup = Path.Join(quarantineRoot, "book.original.m4b"); - var service = new AudiobookContentMoveService( - _provider.GetRequiredService>(), - _provider.GetRequiredService>(), - TimeProvider.System, - new ReplaceQuarantineFileAfterRevalidation( - quarantineFile, - quarantineBackup, - externalFile)); - - await Assert.ThrowsAnyAsync(() => - service.MoveContentsAsync(request, CancellationToken.None)); - - Assert.True(File.Exists(quarantineBackup)); - Assert.Equal("verified audio", await File.ReadAllTextAsync(quarantineBackup)); - var preservedExternalPath = File.Exists(externalFile) - ? externalFile - : quarantineFile; - Assert.True(File.Exists(preservedExternalPath)); - Assert.Equal("external bytes", await File.ReadAllTextAsync(preservedExternalPath)); - Assert.True(File.Exists(Path.Join(target, "book.m4b"))); - } - - [FileLinkFact] - public async Task MoveContentsAsync_TargetFileReplacedBeforeQuarantineDelete_PreservesQuarantineAndExternalFile() - { - NativeTestCapabilityPolicy.RequireAvailable( - NativeTestCapability.FileSymbolicLinks); - var source = FileService.GetTempDirectory("content-move-target-race-src"); - await FileService.GetFileAsync(source, "book.m4b", "verified audio"); - var target = Path.Join( - FileService.GetTempPath(), - $"content-move-target-race-dst-{Guid.NewGuid():N}"); - var external = FileService.GetTempDirectory("content-move-target-race-external"); - var externalFile = await FileService.GetFileAsync( - external, - "book.m4b", - "verified audio"); - var request = await CreateLeasedMoveRequestAsync(source, target); - var injector = new ReplaceTargetFileBeforeQuarantineDelete( - Path.Join(target, "book.m4b"), - externalFile); - var service = new AudiobookContentMoveService( - _provider.GetRequiredService>(), - _provider.GetRequiredService>(), - TimeProvider.System, - injector); - - try - { - await Assert.ThrowsAsync(() => - service.MoveContentsAsync(request, CancellationToken.None)); - - var quarantineRoot = Path.Join( - Path.GetDirectoryName(source)!, - $".listenarr-quarantine-{request.JobId:N}"); - Assert.Equal("verified audio", await File.ReadAllTextAsync(externalFile)); - Assert.True(File.Exists(Path.Join(quarantineRoot, "book.m4b"))); - Assert.True(File.Exists(Path.Join(target, "book.m4b"))); - } - finally - { - var linkedTargetFile = Path.Join(target, "book.m4b"); - if (File.Exists(linkedTargetFile)) - { - File.Delete(linkedTargetFile); - } - } - } - - [Fact] - public async Task MoveContentsAsync_TargetReplacedAtPinnedQuarantineDelete_PreservesRecoverableGeneration() - { - var source = FileService.GetTempDirectory("content-move-target-final-delete-src"); - await FileService.GetFileAsync(source, "book.m4b", "verified audio"); - var target = Path.Join( - FileService.GetTempPath(), - $"content-move-target-final-delete-dst-{Guid.NewGuid():N}"); - var targetFile = Path.Join(target, "book.m4b"); - var request = await CreateLeasedMoveRequestAsync(source, target); - var injector = new ReplaceTargetAtPinnedQuarantineDelete(targetFile); - var service = new AudiobookContentMoveService( - _provider.GetRequiredService>(), - _provider.GetRequiredService>(), - TimeProvider.System, - injector); - - await Assert.ThrowsAsync(() => - service.MoveContentsAsync(request, CancellationToken.None)); - - Assert.Equal( - injector.Replaced ? "replacement audio" : "verified audio", - await File.ReadAllTextAsync(targetFile)); - var quarantineRoot = Path.Join( - Path.GetDirectoryName(source)!, - $".listenarr-quarantine-{request.JobId:N}"); - var recoverableOriginals = Directory.Exists(quarantineRoot) - ? Directory.EnumerateFiles( - quarantineRoot, - "*", - SearchOption.AllDirectories) - .Where(path => File.ReadAllText(path) == "verified audio") - .ToList() - : []; - Assert.NotEmpty(recoverableOriginals); - } - - [Fact] - public async Task MoveContentsAsync_TargetReplacedAfterPinnedQuarantineDelete_PreservesRetention() - { - var source = FileService.GetTempDirectory("content-move-post-delete-race-src"); - await FileService.GetFileAsync(source, "book.m4b", "verified audio"); - var target = Path.Join( - FileService.GetTempPath(), - $"content-move-post-delete-race-dst-{Guid.NewGuid():N}"); - var targetFile = Path.Join(target, "book.m4b"); - var request = await CreateLeasedMoveRequestAsync(source, target); - var injector = new ReplaceTargetAfterPinnedQuarantineDelete(targetFile); - var service = new AudiobookContentMoveService( - _provider.GetRequiredService>(), - _provider.GetRequiredService>(), - TimeProvider.System, - injector); - - var failure = await Record.ExceptionAsync(() => - service.MoveContentsAsync(request, CancellationToken.None)); - - if (!injector.Replaced) - { - Assert.Null(failure); - Assert.Equal("verified audio", await File.ReadAllTextAsync(targetFile)); - Assert.Empty(Directory.EnumerateFiles( - target, - ".listenarr-destination-retention-*.bin", - SearchOption.AllDirectories)); - } - else - { - Assert.IsType(failure); - Assert.Equal("replacement audio", await File.ReadAllTextAsync(targetFile)); - var retention = Assert.Single(Directory.EnumerateFiles( - target, - ".listenarr-destination-retention-*.bin", - SearchOption.AllDirectories)); - Assert.Equal("verified audio", await File.ReadAllTextAsync(retention)); - } - } - - [Fact] - public async Task MoveContentsAsync_InterruptedAfterPinnedQuarantineDelete_RecoversFromDestinationRetention() - { - var source = FileService.GetTempDirectory("content-move-retention-restart-src"); - await FileService.GetFileAsync(source, "book.m4b", "restart audio"); - var target = Path.Join( - FileService.GetTempPath(), - $"content-move-retention-restart-dst-{Guid.NewGuid():N}"); - var request = await CreateLeasedMoveRequestAsync(source, target); - var interrupted = new AudiobookContentMoveService( - _provider.GetRequiredService>(), - _provider.GetRequiredService>(), - TimeProvider.System, - new StopAfterPinnedQuarantineDelete()); - - await Assert.ThrowsAsync(() => - interrupted.MoveContentsAsync(request, CancellationToken.None)); - - var quarantineRoot = Path.Join( - Path.GetDirectoryName(source)!, - $".listenarr-quarantine-{request.JobId:N}"); - Assert.False(File.Exists(Path.Join(quarantineRoot, "book.m4b"))); - var initialRetention = Assert.Single(Directory.EnumerateFiles( - target, - ".listenarr-destination-retention-*.bin", - SearchOption.AllDirectories)); - await using (var state = await _provider - .GetRequiredService>() - .CreateDbContextAsync()) - { - var entry = await state.MoveJobEntries.SingleAsync(candidate => - candidate.MoveJobId == request.JobId - && candidate.EntryType == MoveJobEntryType.File); - Assert.Equal(MoveJobEntryCleanupState.Quarantined, entry.CleanupState); - Assert.Equal(1, entry.CleanupProtectionVersion); - } - - var recoveryService = new AudiobookContentMoveService( - _provider.GetRequiredService>(), - _provider.GetRequiredService>(), - TimeProvider.System); - var recoverable = await recoveryService.GetRecoverableMoveAsync( - request, - CancellationToken.None); - Assert.NotNull(recoverable); - Assert.False(recoverable!.SourceCleanupCompleted); - var result = await recoveryService.ResumeSourceCleanupAsync( - request, - recoverable!, - CancellationToken.None); - - Assert.True(result.SourceCleanupCompleted); - await using (var state = await _provider - .GetRequiredService>() - .CreateDbContextAsync()) - { - var entry = await state.MoveJobEntries.SingleAsync(candidate => - candidate.MoveJobId == request.JobId - && candidate.EntryType == MoveJobEntryType.File); - Assert.Equal(MoveJobEntryCleanupState.Deleted, entry.CleanupState); - } - Assert.Equal( - "restart audio", - await File.ReadAllTextAsync(Path.Join(target, "book.m4b"))); - var remainingFiles = Directory.EnumerateFiles( - target, - "*", - SearchOption.AllDirectories) - .Select(Path.GetFileName) - .ToArray(); - Assert.False( - File.Exists(initialRetention), - string.Join(" | ", remainingFiles)); - Assert.Empty(Directory.EnumerateFiles( - target, - ".listenarr-destination-retention-*.bin", - SearchOption.AllDirectories)); - } - - [Fact] - public async Task MoveContentsAsync_TargetBytesChangeAtFinalRemoval_PreservesQuarantine() - { - var source = FileService.GetTempDirectory("content-move-target-final-race-src"); - await FileService.GetFileAsync(source, "book.m4b", "verified audio"); - var target = Path.Join( - FileService.GetTempPath(), - $"content-move-target-final-race-dst-{Guid.NewGuid():N}"); - var request = await CreateLeasedMoveRequestAsync(source, target); - var injector = new ReplaceTargetBytesBeforeQuarantineRemoval( - Path.Join(target, "book.m4b")); - var service = new AudiobookContentMoveService( - _provider.GetRequiredService>(), - _provider.GetRequiredService>(), - TimeProvider.System, - injector); - - await Assert.ThrowsAsync(() => - service.MoveContentsAsync(request, CancellationToken.None)); - - var quarantineRoot = Path.Join( - Path.GetDirectoryName(source)!, - $".listenarr-quarantine-{request.JobId:N}"); - Assert.Equal( - "verified audio", - await File.ReadAllTextAsync(Path.Join(quarantineRoot, "book.m4b"))); - Assert.Equal( - "tampered audio", - await File.ReadAllTextAsync(Path.Join(target, "book.m4b"))); - } - - private sealed class ReplaceNestedSourceParentAfterRevalidation( - string nestedSource, - string nestedSourceBackup, - string external) : IMoveFaultInjector - { - private bool _replaced; - - public void OnSourceCleanupMutation( - Guid jobId, - SourceCleanupFaultPoint faultPoint) - { - if (!_replaced - && faultPoint == SourceCleanupFaultPoint.BeforeSourceFilePublication) - { - Directory.Move(nestedSource, nestedSourceBackup); - if (!TryCreateDirectoryLink(nestedSource, external)) - { - throw new IOException("The nested source replacement link could not be created."); - } - - _replaced = true; - return; - } - - if (_replaced && faultPoint == SourceCleanupFaultPoint.BeforeQuarantineFileDelete) - { - throw new IOException("Stop after quarantine publication for inspection."); - } - } - } - - private sealed class AddUnownedTargetEntryBeforeSourceMove( - string target) : IMoveFaultInjector - { - private bool _added; - - public void OnSourceCleanupMutation( - Guid jobId, - SourceCleanupFaultPoint faultPoint) - { - if (_added || faultPoint != SourceCleanupFaultPoint.BeforeSourceFileMove) - { - return; - } - - File.WriteAllText(Path.Join(target, "operator-note.txt"), "preserve me"); - _added = true; - } - } - - private sealed class ReplaceQuarantineFileAfterRevalidation( - string quarantineFile, - string quarantineBackup, - string externalFile) : IMoveFaultInjector - { - private bool _replaced; - - public void OnSourceCleanupMutation( - Guid jobId, - SourceCleanupFaultPoint faultPoint) - { - if (_replaced || faultPoint != SourceCleanupFaultPoint.BeforeQuarantineFileRemoval) - { - return; - } - - File.Move(quarantineFile, quarantineBackup); - File.Move(externalFile, quarantineFile); - _replaced = true; - } - } - - private sealed class ReplaceTargetFileBeforeQuarantineDelete( - string targetFile, - string externalFile) : IMoveFaultInjector - { - private bool _replaced; - - public void OnSourceCleanupMutation( - Guid jobId, - SourceCleanupFaultPoint faultPoint) - { - if (_replaced || faultPoint != SourceCleanupFaultPoint.BeforeQuarantineFileDelete) - { - return; - } - - File.Delete(targetFile); - File.CreateSymbolicLink(targetFile, externalFile); - _replaced = true; - } - } - - private sealed class ReplaceTargetAtPinnedQuarantineDelete( - string targetFile) : IMoveFaultInjector - { - public bool Replaced { get; private set; } - - public void OnSourceCleanupMutation( - Guid jobId, - SourceCleanupFaultPoint faultPoint) - { - if (Replaced - || faultPoint != SourceCleanupFaultPoint.BeforePinnedQuarantineDelete) - { - return; - } - - File.Delete(targetFile); - File.WriteAllText(targetFile, "replacement audio"); - Replaced = true; - } - } - - private sealed class ReplaceTargetAfterPinnedQuarantineDelete( - string targetFile) : IMoveFaultInjector - { - public bool Replaced { get; private set; } - - public void OnSourceCleanupMutation( - Guid jobId, - SourceCleanupFaultPoint faultPoint) - { - if (Replaced - || faultPoint != SourceCleanupFaultPoint.AfterPinnedQuarantineDelete) - { - return; - } - - try - { - File.Delete(targetFile); - File.WriteAllText(targetFile, "replacement audio"); - Replaced = true; - } - catch (IOException) - { - // Windows holds a non-delete-sharing handle through completion. - } - catch (UnauthorizedAccessException) - { - // Some Windows filesystems report sharing denial as access denied. - } - } - } - - private sealed class StopAfterPinnedQuarantineDelete : IMoveFaultInjector - { - public void OnSourceCleanupMutation( - Guid jobId, - SourceCleanupFaultPoint faultPoint) - { - if (faultPoint == SourceCleanupFaultPoint.AfterPinnedQuarantineDelete) - { - throw new IOException( - "Simulated interruption after pinned quarantine deletion."); - } - } - } - - private sealed class ReplaceTargetBytesBeforeQuarantineRemoval( - string targetFile) : IMoveFaultInjector - { - private bool _replaced; - - public void OnSourceCleanupMutation( - Guid jobId, - SourceCleanupFaultPoint faultPoint) - { - if (_replaced - || faultPoint != SourceCleanupFaultPoint.BeforeQuarantineFileRemoval) - { - return; - } - - File.WriteAllText(targetFile, "tampered audio"); - _replaced = true; - } - } - - private sealed class ReplaceSourceRootBeforeCleanupMove( - string source, - string sourceBackup, - string external) : IMoveFaultInjector - { - private bool _replaced; - - public void OnSourceCleanupMutation( - Guid jobId, - SourceCleanupFaultPoint faultPoint) - { - if (_replaced || faultPoint != SourceCleanupFaultPoint.BeforeSourceFileMove) - { - return; - } - - Directory.Move(source, sourceBackup); - if (!TryCreateDirectoryLink(source, external)) - { - throw new IOException("The source replacement link could not be created."); - } - - _replaced = true; - } - } - - private sealed class ReplaceEmptySourceBeforeQuarantine( - string source, - string originalGeneration) : IMoveFaultInjector - { - private bool _replaced; - - public void OnSourceCleanupMutation( - Guid jobId, - SourceCleanupFaultPoint faultPoint) - { - if (_replaced - || faultPoint != SourceCleanupFaultPoint.BeforeEmptySourceDirectoryQuarantine) - { - return; - } - - Directory.Move(source, originalGeneration); - Directory.CreateDirectory(source); - _replaced = true; - } - } -} diff --git a/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceCopyDestinationRaceTests.cs b/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceCopyDestinationRaceTests.cs deleted file mode 100644 index c53806e7e..000000000 --- a/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceCopyDestinationRaceTests.cs +++ /dev/null @@ -1,330 +0,0 @@ -using Listenarr.Tests.Common; -using Microsoft.EntityFrameworkCore; - -namespace Listenarr.Tests.Features.Infrastructure.Library.Moving; - -public partial class AudiobookContentMoveServiceTests -{ - [Fact] - public async Task MoveContentsAsync_ValidPersistedPartial_DoesNotConsumeSourceDuringPublication() - { - var source = FileService.GetTempDirectory("content-move-persisted-partial-source-check"); - var sourceFile = await FileService.GetFileAsync(source, "book.m4b", "verified audio"); - var target = FileService.GetTempDirectory("content-move-persisted-partial-target-check"); - var request = await CreateLeasedMoveRequestAsync(source, target); - await PersistFileManifestAsync(request.JobId, "book.m4b", sourceFile); - var partial = Path.Join( - target, - $"book.m4b.listenarr-{request.JobId:N}.partial"); - await File.WriteAllTextAsync(partial, "verified audio"); - await WriteRecoveryMarkerAsync( - target, - request.JobId, - source, - target, - "copy-started"); - var stopAfterPublished = new StopAfterPublished(source); - var service = new AudiobookContentMoveService( - _provider.GetRequiredService>(), - _provider.GetRequiredService>(), - TimeProvider.System, - stopAfterPublished); - - await Assert.ThrowsAsync(() => - service.MoveContentsAsync(request, CancellationToken.None)); - - Assert.True(File.Exists(sourceFile)); - Assert.Null(stopAfterPublished.DeleteAccessError); - Assert.Equal("verified audio", await File.ReadAllTextAsync(sourceFile)); - Assert.True(File.Exists(Path.Join(target, "book.m4b"))); - } - - [DirectoryLinkFact] - public async Task MoveContentsAsync_CopyParentReplacedBeforePartialCreation_DoesNotWriteExternalPartial() - { - var root = FileService.GetTempDirectory("content-move-copy-parent-race-root"); - var targetParent = Path.Join(root, "destination-parent"); - var external = FileService.GetTempDirectory("content-move-copy-parent-race-external"); - var probe = Path.Join(root, "link-probe"); - Directory.CreateDirectory(targetParent); - Assert.True( - TryCreateTempDirectoryLink(probe, external), - "The required directory link could not be created."); - Directory.Delete(probe); - - var source = FileService.GetTempDirectory("content-move-copy-parent-race-source"); - var nestedSource = Path.Join(source, "extras"); - Directory.CreateDirectory(nestedSource); - var sourceFile = await FileService.GetFileAsync( - nestedSource, - "book.m4b", - "verified audio"); - var target = Path.Join(targetParent, "Book"); - var request = await CreateLeasedMoveRequestAsync(source, target); - var tempRoot = Path.Join( - targetParent, - Path.GetFileName(target) + ".tmp-" + request.JobId.ToString("N")); - var destinationParent = Path.Join(tempRoot, "extras"); - var displacedDestinationParent = destinationParent + ".original"; - var externalPartial = Path.Join( - external, - $"book.m4b.listenarr-{request.JobId:N}.partial"); - var injector = new ReplaceCopyParentBeforePartialCreation( - destinationParent, - displacedDestinationParent, - external); - var service = new AudiobookContentMoveService( - _provider.GetRequiredService>(), - _provider.GetRequiredService>(), - TimeProvider.System, - injector); - - try - { - await Assert.ThrowsAnyAsync(() => - service.MoveContentsAsync(request, CancellationToken.None)); - - Assert.True(injector.ReplacementRan); - Assert.True(File.Exists(sourceFile)); - Assert.False(File.Exists(externalPartial)); - } - finally - { - TryDeleteTempDirectoryLink(destinationParent); - if (Directory.Exists(displacedDestinationParent) - && !Directory.Exists(destinationParent)) - { - Directory.Move(displacedDestinationParent, destinationParent); - } - } - } - - [DirectoryLinkFact] - public async Task MoveContentsAsync_DirectCopyParentReplacedAfterHandleOpen_DoesNotCreateExternalTarget() - { - var source = FileService.GetTempDirectory("content-move-direct-root-race-source"); - var displacedSource = source + ".original"; - var external = FileService.GetTempDirectory("content-move-direct-root-race-external"); - var probe = Path.Join(Path.GetDirectoryName(source)!, $"link-probe-{Guid.NewGuid():N}"); - Assert.True( - TryCreateTempDirectoryLink(probe, external), - "The required directory link could not be created."); - Directory.Delete(probe); - - var sourceFile = await FileService.GetFileAsync(source, "book.m4b", "verified audio"); - var target = Path.Join(source, "published"); - var request = await CreateLeasedMoveRequestAsync(source, target); - var hookRan = false; - void ReplaceParent(string path) - { - if (hookRan || !string.Equals(path, target, StringComparison.Ordinal)) - { - return; - } - - hookRan = true; - Directory.Move(source, displacedSource); - if (!TryCreateTempDirectoryLink(source, external)) - { - throw new IOException("The direct-copy parent replacement link could not be created."); - } - } - - using var hook = ExclusiveDirectoryCreator.PushBeforeCreateHook(ReplaceParent); - try - { - var service = _provider.GetRequiredService(); - await Assert.ThrowsAnyAsync(() => - service.MoveContentsAsync(request, CancellationToken.None)); - - Assert.True(hookRan); - Assert.True(File.Exists(Path.Join(displacedSource, Path.GetFileName(sourceFile)))); - Assert.Empty(Directory.EnumerateFileSystemEntries(external)); - } - finally - { - TryDeleteTempDirectoryLink(source); - if (Directory.Exists(displacedSource) && !Directory.Exists(source)) - { - Directory.Move(displacedSource, source); - } - } - } - - [Fact] - public async Task MoveContentsAsync_SourceGenerationReplacedAfterManifestValidation_FailsClosed() - { - var source = FileService.GetTempDirectory("content-move-source-generation-race"); - var sourceFile = await FileService.GetFileAsync(source, "book.m4b", "verified audio"); - var target = Path.Join( - FileService.GetTempPath(), - $"content-move-source-generation-target-{Guid.NewGuid():N}"); - var request = await CreateLeasedMoveRequestAsync(source, target); - string physicalIdentity; - using (var parent = PinnedDirectoryCreation.OpenPinnedHierarchyNoFollow( - source, - createMissing: false)) - using (var file = parent.OpenExistingFileForStableRead("book.m4b")) - { - physicalIdentity = file.GetObjectIdentity(); - } - request = request with - { - SourcePhysicalObjectIdentities = new Dictionary( - request.SourceSemantics.Comparer) - { - ["book.m4b"] = physicalIdentity - } - }; - var displaced = sourceFile + ".original"; - var injector = new ReplaceSourceBeforeCopy( - sourceFile, - displaced, - "verified audio"); - var service = new AudiobookContentMoveService( - _provider.GetRequiredService>(), - _provider.GetRequiredService>(), - TimeProvider.System, - injector); - - await Assert.ThrowsAsync(() => - service.MoveContentsAsync(request, CancellationToken.None)); - - Assert.True(injector.ReplacementRan); - Assert.True(File.Exists(sourceFile)); - Assert.True(File.Exists(displaced)); - Assert.Equal("verified audio", await File.ReadAllTextAsync(sourceFile)); - Assert.Equal("verified audio", await File.ReadAllTextAsync(displaced)); - } - - [Fact] - public async Task MoveContentsAsync_OwnedTempDisappearsBeforeCopy_DoesNotRecreateUnmarkedDirectory() - { - var source = FileService.GetTempDirectory("content-move-temp-disappears-source"); - var sourceFile = await FileService.GetFileAsync(source, "book.m4b", "verified audio"); - var target = Path.Join( - FileService.GetTempPath(), - $"content-move-temp-disappears-target-{Guid.NewGuid():N}"); - var request = await CreateLeasedMoveRequestAsync(source, target); - var tempRoot = Path.Join( - Path.GetDirectoryName(target)!, - Path.GetFileName(target) + ".tmp-" + request.JobId.ToString("N")); - var injector = new DeleteOwnedTempBeforeCopyRootValidation(tempRoot); - var service = new AudiobookContentMoveService( - _provider.GetRequiredService>(), - _provider.GetRequiredService>(), - TimeProvider.System, - injector); - - var exception = await Assert.ThrowsAsync(() => - service.MoveContentsAsync(request, CancellationToken.None)); - - Assert.True(injector.DeletionRan); - Assert.Contains("disappeared", exception.Message, StringComparison.OrdinalIgnoreCase); - Assert.True(File.Exists(sourceFile)); - Assert.False(Directory.Exists(tempRoot)); - Assert.False(Directory.Exists(target)); - } - - private sealed class StopAfterPublished(string source) : IMoveFaultInjector - { - public Exception? DeleteAccessError { get; private set; } - - public Task AfterPublishedAsync( - Guid jobId, - CancellationToken cancellationToken) - { - try - { - using var sourceAnchor = PinnedDirectoryCreation.OpenPinnedDirectoryNoFollow(source); - using var sourceEntry = sourceAnchor.OpenExistingFile( - "book.m4b", - requireDeleteAccess: true); - } - catch (Exception exception) - { - DeleteAccessError = exception; - } - - throw new MoveNeedsAttentionException("Stop after publication for source inspection."); - } - } - - private sealed class DeleteOwnedTempBeforeCopyRootValidation(string tempRoot) - : IMoveFaultInjector - { - public bool AllowAtomicRename => false; - - public bool DeletionRan { get; private set; } - - public void OnCopyMutation(Guid jobId, CopyMutationFaultPoint faultPoint) - { - if (DeletionRan || faultPoint != CopyMutationFaultPoint.BeforeCopyRootValidation) - { - return; - } - - Assert.True(File.Exists(Path.Join(tempRoot, ".listenarr-temp-owner.json"))); - Directory.Delete(tempRoot, recursive: true); - DeletionRan = true; - } - } - - private sealed class ReplaceSourceBeforeCopy( - string sourceFile, - string displaced, - string contents) : IMoveFaultInjector - { - public bool AllowAtomicRename => false; - - public bool ReplacementRan { get; private set; } - - public void OnCopyMutation(Guid jobId, CopyMutationFaultPoint faultPoint) - { - if (ReplacementRan - || faultPoint != CopyMutationFaultPoint.BeforeCopyRootValidation) - { - return; - } - - File.Move(sourceFile, displaced); - File.WriteAllText(sourceFile, contents); - ReplacementRan = true; - } - } - - private sealed class ReplaceCopyParentBeforePartialCreation( - string destinationParent, - string displacedDestinationParent, - string external) : IMoveFaultInjector - { - private bool _replaced; - - public bool AllowAtomicRename => false; - - public bool ReplacementRan => _replaced; - - public void OnCopyMutation( - Guid jobId, - CopyMutationFaultPoint faultPoint) - { - if (!_replaced && faultPoint == CopyMutationFaultPoint.BeforePartialFileCreation) - { - Directory.Move(destinationParent, displacedDestinationParent); - if (!TryCreateTempDirectoryLink(destinationParent, external)) - { - throw new IOException("The copy destination replacement link could not be created."); - } - - _replaced = true; - return; - } - - if (_replaced && faultPoint == CopyMutationFaultPoint.AfterChunkWritten) - { - throw new MoveNeedsAttentionException( - "Stop after the first copy chunk for external-path inspection."); - } - } - } -} diff --git a/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceCopyPublicationTests.cs b/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceCopyPublicationTests.cs deleted file mode 100644 index 9d86c7498..000000000 --- a/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceCopyPublicationTests.cs +++ /dev/null @@ -1,153 +0,0 @@ -using Microsoft.EntityFrameworkCore; - -namespace Listenarr.Tests.Features.Infrastructure.Library.Moving; - -public partial class AudiobookContentMoveServiceTests -{ - [Fact] - public async Task MoveContentsAsync_PartialChangesBeforePublication_PreservesSourceAndBlocksDestination() - { - var source = FileService.GetTempDirectory("content-move-partial-publish-src"); - await FileService.GetFileAsync(source, "book.m4b", "verified audio"); - var target = Path.Join(source, "published"); - var request = await CreateLeasedMoveRequestAsync(source, target); - var partialPath = Path.Join( - target, - $"book.m4b.listenarr-{request.JobId:N}.partial"); - var service = new AudiobookContentMoveService( - _provider.GetRequiredService>(), - _provider.GetRequiredService>(), - TimeProvider.System, - new ReplacePartialBeforePublication(partialPath)); - - var exception = await Assert.ThrowsAsync(() => - service.MoveContentsAsync(request, CancellationToken.None)); - - Assert.Contains("changed before publication", exception.Message, StringComparison.OrdinalIgnoreCase); - Assert.True(File.Exists(Path.Join(source, "book.m4b"))); - Assert.False(File.Exists(Path.Join(target, "book.m4b"))); - Assert.True(File.Exists(partialPath)); - Assert.Equal("corrupted audio", await File.ReadAllTextAsync(partialPath)); - Assert.Equal( - "verified audio", - await File.ReadAllTextAsync(partialPath + ".replaced")); - } - - [Fact] - public async Task MoveContentsAsync_TransientCopyFailure_PreservesVerifiedTempProgressForRetry() - { - var source = FileService.GetTempDirectory("content-move-temp-progress-src"); - await FileService.GetFileAsync(source, "first.m4b", "first audio"); - await FileService.GetFileAsync(source, "second.m4b", "second audio"); - var target = Path.Join( - FileService.GetTempPath(), - $"content-move-temp-progress-dst-{Guid.NewGuid():N}"); - var request = await CreateLeasedMoveRequestAsync(source, target); - var tempDirectory = Path.Join( - Path.GetDirectoryName(target)!, - Path.GetFileName(target) + ".tmp-" + request.JobId.ToString("N")); - var faultingService = new AudiobookContentMoveService( - _provider.GetRequiredService>(), - _provider.GetRequiredService>(), - TimeProvider.System, - new FailCopiesAfterOneFileCompletes(tempDirectory)); - - var exception = await Assert.ThrowsAnyAsync(() => - faultingService.MoveContentsAsync(request, CancellationToken.None)); - - Assert.IsNotType(exception); - Assert.True(Directory.Exists(tempDirectory)); - Assert.True(File.Exists(Path.Join(tempDirectory, ".listenarr-temp-owner.json"))); - Assert.Single(Directory.EnumerateFiles(tempDirectory, "*.m4b", SearchOption.TopDirectoryOnly)); - Assert.True(Directory.Exists(source)); - Assert.False(Directory.Exists(target)); - - var service = _provider.GetRequiredService(); - var result = await service.MoveContentsAsync(request, CancellationToken.None); - - Assert.True(result.SourceCleanupCompleted); - Assert.False(Directory.Exists(tempDirectory)); - Assert.False(Directory.Exists(source)); - Assert.Equal("first audio", await File.ReadAllTextAsync(Path.Join(target, "first.m4b"))); - Assert.Equal("second audio", await File.ReadAllTextAsync(Path.Join(target, "second.m4b"))); - } - - [Fact] - public async Task MoveContentsAsync_TargetAppearsAtTempPublicationBoundary_PreservesOperatorContent() - { - var source = FileService.GetTempDirectory("content-move-temp-target-race-src"); - await FileService.GetFileAsync(source, "book.m4b", "audio"); - var target = Path.Join( - FileService.GetTempPath(), - $"content-move-temp-target-race-dst-{Guid.NewGuid():N}"); - var request = await CreateLeasedMoveRequestAsync(source, target); - var tempDirectory = Path.Join( - Path.GetDirectoryName(target)!, - Path.GetFileName(target) + ".tmp-" + request.JobId.ToString("N")); - var service = new AudiobookContentMoveService( - _provider.GetRequiredService>(), - _provider.GetRequiredService>(), - TimeProvider.System, - new CreateTargetBeforeTempPublication(target)); - - var exception = await Assert.ThrowsAsync(() => - service.MoveContentsAsync(request, CancellationToken.None)); - - Assert.Contains("target appeared", exception.Message, StringComparison.OrdinalIgnoreCase); - Assert.True(File.Exists(Path.Join(source, "book.m4b"))); - Assert.Equal( - "preserve me", - await File.ReadAllTextAsync(Path.Join(target, "operator-note.txt"))); - Assert.False(Directory.Exists(tempDirectory)); - } - - private sealed class CreateTargetBeforeTempPublication(string target) : IMoveFaultInjector - { - private bool _created; - - public void OnTempPublication(Guid jobId, TempPublicationFaultPoint faultPoint) - { - if (_created || faultPoint != TempPublicationFaultPoint.BeforeFinalValidation) - { - return; - } - - Directory.CreateDirectory(target); - File.WriteAllText(Path.Join(target, "operator-note.txt"), "preserve me"); - _created = true; - } - } - - private sealed class FailCopiesAfterOneFileCompletes(string tempDirectory) : IMoveFaultInjector - { - public void OnCopyMutation(Guid jobId, CopyMutationFaultPoint faultPoint) - { - if (faultPoint == CopyMutationFaultPoint.AfterChunkWritten - && Directory.Exists(tempDirectory) - && Directory.EnumerateFiles( - tempDirectory, - "*.m4b", - SearchOption.TopDirectoryOnly).Any()) - { - throw new IOException("Simulated transient copy interruption."); - } - } - } - - private sealed class ReplacePartialBeforePublication(string partialPath) : IMoveFaultInjector - { - private bool _replaced; - - public void OnCopyMutation(Guid jobId, CopyMutationFaultPoint faultPoint) - { - if (_replaced || faultPoint != CopyMutationFaultPoint.BeforePartialPublication) - { - return; - } - - File.Move(partialPath, partialPath + ".replaced"); - File.WriteAllText(partialPath, "corrupted audio"); - _replaced = true; - } - } -} diff --git a/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceEmptyManifestTests.cs b/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceEmptyManifestTests.cs index 2ebab3dc4..9c8b4b4b2 100644 --- a/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceEmptyManifestTests.cs +++ b/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceEmptyManifestTests.cs @@ -70,7 +70,7 @@ public async Task VerifyFinalizedMoveAsync_PhaseOnlyMarkerlessAtomicState_Requir var exception = await Assert.ThrowsAsync(() => service.VerifyFinalizedMoveAsync(request, CancellationToken.None)); - Assert.Contains("without a persisted manifest", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("requires a persisted manifest", exception.Message, StringComparison.OrdinalIgnoreCase); Assert.True(File.Exists(Path.Join(target, "book.m4b"))); } @@ -84,8 +84,7 @@ public async Task VerifyFinalizedMoveAsync_MarkerlessAtomicTarget_RemainsVerifia $"content-move-atomic-dst-{Guid.NewGuid():N}"); var request = await CreateLeasedMoveRequestAsync(source, target); var service = _provider.GetRequiredService(); - var result = await service.MoveContentsAsync(request, CancellationToken.None); - File.Delete(result.RecoveryMarkerPath); + await service.MoveContentsAsync(request, CancellationToken.None); await service.VerifyFinalizedMoveAsync(request, CancellationToken.None); @@ -103,8 +102,7 @@ public async Task VerifyFinalizedMoveAsync_MarkerlessAtomicTargetWithUnownedCont $"content-move-atomic-tampered-dst-{Guid.NewGuid():N}"); var request = await CreateLeasedMoveRequestAsync(source, target); var service = _provider.GetRequiredService(); - var result = await service.MoveContentsAsync(request, CancellationToken.None); - File.Delete(result.RecoveryMarkerPath); + await service.MoveContentsAsync(request, CancellationToken.None); var unrelated = await FileService.GetFileAsync( target, "operator-note.txt", diff --git a/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceEndpointSafetyTests.cs b/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceEndpointSafetyTests.cs index a642b06f3..b2c08750f 100644 --- a/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceEndpointSafetyTests.cs +++ b/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceEndpointSafetyTests.cs @@ -5,14 +5,11 @@ namespace Listenarr.Tests.Features.Infrastructure.Library.Moving; public partial class AudiobookContentMoveServiceTests { [Fact] - public async Task MoveContentsAsync_IdenticalEndpoints_RejectsBeforeMarkerCreation() + public async Task MoveContentsAsync_IdenticalEndpoints_RejectsBeforeMutation() { var source = FileService.GetTempDirectory("content-move-identical-endpoint"); await FileService.GetFileAsync(source, "book.m4b", "audio"); var request = await CreateLeasedMoveRequestAsync(source, source); - var markerPath = Path.Join( - source, - $".listenarr-move-{request.JobId:N}.pending"); var service = _provider.GetRequiredService(); var exception = await Assert.ThrowsAsync(() => @@ -20,11 +17,10 @@ public async Task MoveContentsAsync_IdenticalEndpoints_RejectsBeforeMarkerCreati Assert.Contains("distinct non-root", exception.Message, StringComparison.OrdinalIgnoreCase); Assert.True(File.Exists(Path.Join(source, "book.m4b"))); - Assert.False(File.Exists(markerPath)); } [Fact] - public async Task MoveContentsAsync_TrailingSeparator_NormalizesEndpointAndRecoveryIdentity() + public async Task MoveContentsAsync_TrailingSeparator_NormalizesEndpointAndIdentity() { var source = FileService.GetTempDirectory("content-move-trailing-endpoint-src"); await FileService.GetFileAsync(source, "book.m4b", "audio"); @@ -47,36 +43,6 @@ public async Task MoveContentsAsync_TrailingSeparator_NormalizesEndpointAndRecov Assert.True(File.Exists(Path.Join(target, "book.m4b"))); } - [Fact] - public async Task MoveContentsAsync_LegacyJobWithUnownedPartial_DoesNotPersistSourceIdentity() - { - var source = FileService.GetTempDirectory("content-move-legacy-partial-src"); - await FileService.GetFileAsync(source, "book.m4b", "audio"); - var target = FileService.GetTempDirectory("content-move-legacy-partial-dst"); - var request = await CreateLeasedMoveRequestAsync(source, target); - var partialPath = Path.Join( - target, - $"book.m4b.listenarr-{request.JobId:N}.partial"); - await File.WriteAllTextAsync(partialPath, "partial audio"); - var factory = _provider.GetRequiredService>(); - await using (var db = await factory.CreateDbContextAsync()) - { - var job = await db.MoveJobs.SingleAsync(candidate => candidate.Id == request.JobId); - job.SourcePath = null; - await db.SaveChangesAsync(); - } - - var service = _provider.GetRequiredService(); - var exception = await Assert.ThrowsAsync(() => - service.MoveContentsAsync(request, CancellationToken.None)); - - Assert.Contains("legacy move", exception.Message, StringComparison.OrdinalIgnoreCase); - Assert.Equal("partial audio", await File.ReadAllTextAsync(partialPath)); - await using var verificationDb = await factory.CreateDbContextAsync(); - Assert.Null((await verificationDb.MoveJobs.SingleAsync( - candidate => candidate.Id == request.JobId)).SourcePath); - } - [Fact] public async Task MoveContentsAsync_PathTooLongPersistedTarget_RequiresAttentionWithoutMutation() { @@ -111,9 +77,6 @@ public async Task MoveContentsAsync_FilesystemRootSource_RejectsBeforeTargetMuta FileService.GetTempPath(), $"content-move-root-source-dst-{Guid.NewGuid():N}"); var request = await CreateLeasedMoveRequestAsync(filesystemRoot, target); - var sourceMarkerPath = Path.Join( - filesystemRoot, - $".listenarr-move-{request.JobId:N}.pending"); var service = _provider.GetRequiredService(); var exception = await Assert.ThrowsAsync(() => @@ -121,7 +84,6 @@ public async Task MoveContentsAsync_FilesystemRootSource_RejectsBeforeTargetMuta Assert.Contains("distinct non-root", exception.Message, StringComparison.OrdinalIgnoreCase); Assert.False(Directory.Exists(target)); - Assert.False(File.Exists(sourceMarkerPath)); } [Fact] @@ -131,9 +93,6 @@ public async Task MoveContentsAsync_FilesystemRootTarget_RejectsBeforeSourceMuta await FileService.GetFileAsync(source, "book.m4b", "audio"); var filesystemRoot = Path.GetPathRoot(source)!; var request = await CreateLeasedMoveRequestAsync(source, filesystemRoot); - var sourceMarkerPath = Path.Join( - source, - $".listenarr-move-{request.JobId:N}.pending"); var service = _provider.GetRequiredService(); var exception = await Assert.ThrowsAsync(() => @@ -141,6 +100,5 @@ public async Task MoveContentsAsync_FilesystemRootTarget_RejectsBeforeSourceMuta Assert.Contains("distinct non-root", exception.Message, StringComparison.OrdinalIgnoreCase); Assert.True(File.Exists(Path.Join(source, "book.m4b"))); - Assert.False(File.Exists(sourceMarkerPath)); } } diff --git a/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceLeaseFencingTests.cs b/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceLeaseFencingTests.cs deleted file mode 100644 index e738920fc..000000000 --- a/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceLeaseFencingTests.cs +++ /dev/null @@ -1,324 +0,0 @@ -using Microsoft.EntityFrameworkCore; - -namespace Listenarr.Tests.Features.Infrastructure.Library.Moving; - -public partial class AudiobookContentMoveServiceTests -{ - [Fact] - public async Task MoveContentsAsync_LeaseReplacedBeforeTempPublication_PreservesReplacementWorkersArtifacts() - { - var source = FileService.GetTempDirectory("content-move-stale-lease-src"); - await FileService.GetFileAsync(source, "book.m4b", "verified audio"); - var target = Path.Join( - FileService.GetTempPath(), - $"content-move-stale-lease-dst-{Guid.NewGuid():N}"); - var request = await CreateLeasedMoveRequestAsync(source, target); - var targetParent = Path.GetDirectoryName(target)!; - var tempDirectory = Path.Join( - targetParent, - Path.GetFileName(target) + ".tmp-" + request.JobId.ToString("N")); - var factory = _provider.GetRequiredService>(); - var service = new AudiobookContentMoveService( - _provider.GetRequiredService>(), - factory, - TimeProvider.System, - new ReplaceLeaseBeforeTempPublication(factory, tempDirectory)); - - await Assert.ThrowsAsync(() => - service.MoveContentsAsync(request, CancellationToken.None)); - - Assert.False(Directory.Exists(target)); - Assert.True(Directory.Exists(tempDirectory)); - Assert.Equal( - "verified audio", - await File.ReadAllTextAsync(Path.Join(tempDirectory, "book.m4b"))); - Assert.True(File.Exists(Path.Join(tempDirectory, ".listenarr-temp-owner.json"))); - Assert.False(File.Exists(Path.Join( - tempDirectory, - $".listenarr-move-{request.JobId:N}.pending"))); - Assert.Single(Directory.EnumerateFiles( - tempDirectory, - $".listenarr-move-{request.JobId:N}.pending.writing-*", - SearchOption.TopDirectoryOnly)); - Assert.True(File.Exists(Path.Join(source, "book.m4b"))); - } - - [Fact] - public async Task MoveContentsAsync_LeaseReplacedDuringLargeCopy_StopsStaleWriter() - { - var source = FileService.GetTempDirectory("content-move-copy-lease-src"); - var sourceFile = Path.Join(source, "book.m4b"); - await File.WriteAllBytesAsync(sourceFile, new byte[2 * 1024 * 1024]); - var target = Path.Join( - FileService.GetTempPath(), - $"content-move-copy-lease-dst-{Guid.NewGuid():N}"); - var request = await CreateLeasedMoveRequestAsync(source, target); - var tempDirectory = Path.Join( - Path.GetDirectoryName(target)!, - Path.GetFileName(target) + ".tmp-" + request.JobId.ToString("N")); - var partialFile = Path.Join( - tempDirectory, - $"book.m4b.listenarr-{request.JobId:N}.partial"); - var factory = _provider.GetRequiredService>(); - var service = new AudiobookContentMoveService( - _provider.GetRequiredService>(), - factory, - TimeProvider.System, - new ReplaceLeaseAfterFirstCopyChunk(factory)); - - await Assert.ThrowsAsync(() => - service.MoveContentsAsync(request, CancellationToken.None)); - - Assert.False(Directory.Exists(target)); - Assert.True(File.Exists(partialFile)); - Assert.Equal(1024 * 1024, new FileInfo(partialFile).Length); - Assert.Equal(2 * 1024 * 1024, new FileInfo(sourceFile).Length); - Assert.True(File.Exists(Path.Join(tempDirectory, ".listenarr-temp-owner.json"))); - } - - [Fact] - public async Task MoveContentsAsync_LeaseReplacedBeforeTempOwnershipPublication_PreservesWriteEvidence() - { - var source = FileService.GetTempDirectory("content-move-owner-lease-src"); - await FileService.GetFileAsync(source, "book.m4b", "verified audio"); - var target = Path.Join( - FileService.GetTempPath(), - $"content-move-owner-lease-dst-{Guid.NewGuid():N}"); - var request = await CreateLeasedMoveRequestAsync(source, target); - var tempDirectory = Path.Join( - Path.GetDirectoryName(target)!, - Path.GetFileName(target) + ".tmp-" + request.JobId.ToString("N")); - var factory = _provider.GetRequiredService>(); - var service = new AudiobookContentMoveService( - _provider.GetRequiredService>(), - factory, - TimeProvider.System, - new ReplaceLeaseBeforeTempOwnershipPublication(factory)); - - await Assert.ThrowsAsync(() => - service.MoveContentsAsync(request, CancellationToken.None)); - - Assert.False(Directory.Exists(target)); - Assert.True(Directory.Exists(tempDirectory)); - Assert.False(File.Exists(Path.Join(tempDirectory, ".listenarr-temp-owner.json"))); - Assert.Single(Directory.EnumerateFiles( - tempDirectory, - ".listenarr-temp-owner.json.writing-*", - SearchOption.TopDirectoryOnly)); - Assert.True(File.Exists(Path.Join(source, "book.m4b"))); - } - - [Fact] - public async Task MoveContentsAsync_LeaseReplacedAtScaffoldPublication_DoesNotPublishPreparedHierarchy() - { - var scaffoldParent = FileService.GetTempDirectory("content-move-scaffold-lease-root"); - var source = FileService.GetTempDirectory("content-move-scaffold-lease-source"); - var sourceFile = await FileService.GetFileAsync(source, "book.m4b", "verified audio"); - var target = Path.Join(scaffoldParent, "Author", "Book"); - var request = await CreateLeasedMoveRequestAsync(source, target); - var temporaryRoot = Path.Join( - scaffoldParent, - $".listenarr-scaffold-{request.JobId:N}"); - var factory = _provider.GetRequiredService>(); - var service = new AudiobookContentMoveService( - _provider.GetRequiredService>(), - factory, - TimeProvider.System, - new ReplaceLeaseBeforeScaffoldPublication(factory)); - - await Assert.ThrowsAsync(() => - service.MoveContentsAsync(request, CancellationToken.None)); - - Assert.False(Directory.Exists(Path.Join(scaffoldParent, "Author"))); - Assert.True(Directory.Exists(temporaryRoot)); - Assert.True(File.Exists(Path.Join(temporaryRoot, ".listenarr-scaffold-owner.json"))); - Assert.True(File.Exists(sourceFile)); - } - - [Fact] - public async Task MoveContentsAsync_LeaseReplacedDuringOwnershipCleanup_PreservesTombstoneForNewWorker() - { - var source = FileService.GetTempDirectory("content-move-cleanup-lease-src"); - await FileService.GetFileAsync(source, "book.m4b", "verified audio"); - var target = Path.Join( - FileService.GetTempPath(), - $"content-move-cleanup-lease-dst-{Guid.NewGuid():N}"); - Directory.CreateDirectory(target); - var request = await CreateLeasedMoveRequestAsync(source, target); - var factory = _provider.GetRequiredService>(); - var service = new AudiobookContentMoveService( - _provider.GetRequiredService>(), - factory, - TimeProvider.System, - new ReplaceLeaseBeforeOwnedDirectoryDelete(factory)); - - await Assert.ThrowsAsync(() => - service.MoveContentsAsync(request, CancellationToken.None)); - - var sourceParent = Path.GetDirectoryName(source)!; - var quarantineRoot = Path.Join( - sourceParent, - $".listenarr-quarantine-{request.JobId:N}"); - var cleanupDirectory = Path.Join( - sourceParent, - $".listenarr-quarantine-directory-{request.JobId:N}.cleanup-dir"); - var tombstonePath = Path.Join( - sourceParent, - $".listenarr-quarantine-directory-{request.JobId:N}.cleanup.json"); - Assert.False(Directory.Exists(quarantineRoot)); - Assert.True(Directory.Exists(cleanupDirectory)); - Assert.Empty(Directory.EnumerateFileSystemEntries(cleanupDirectory)); - Assert.True(File.Exists(tombstonePath)); - Assert.False(Directory.Exists(source)); - Assert.True(File.Exists(Path.Join(target, "book.m4b"))); - - var replacementRequest = request with - { - LeaseToken = new MoveLeaseToken("replacement-worker", 2) - }; - var recovered = await service.GetRecoverableMoveAsync( - replacementRequest, - CancellationToken.None); - Assert.NotNull(recovered); - var completed = await service.ResumeSourceCleanupAsync( - replacementRequest, - recovered!, - CancellationToken.None); - - Assert.True(completed.SourceCleanupCompleted); - Assert.False(Directory.Exists(quarantineRoot)); - Assert.False(Directory.Exists(cleanupDirectory)); - Assert.False(File.Exists(tombstonePath)); - } - - private sealed class ReplaceLeaseBeforeTempPublication( - IDbContextFactory factory, - string tempDirectory) : IMoveFaultInjector - { - private bool _replaced; - - public void OnRecoveryMarkerWrite( - Guid jobId, - RecoveryMarkerWriteFaultPoint faultPoint) - { - if (_replaced - || faultPoint != RecoveryMarkerWriteFaultPoint.BeforePublication - || !File.Exists(Path.Join(tempDirectory, "book.m4b"))) - { - return; - } - - using var db = factory.CreateDbContext(); - var job = db.MoveJobs.Single(candidate => candidate.Id == jobId); - job.LeaseOwner = "replacement-worker"; - job.LeaseGeneration++; - job.LeaseExpiresAt = DateTime.UtcNow.AddMinutes(5); - db.SaveChanges(); - _replaced = true; - } - } - - private sealed class ReplaceLeaseAfterFirstCopyChunk( - IDbContextFactory factory) : IMoveFaultInjector - { - private bool _replaced; - - public void OnCopyMutation( - Guid jobId, - CopyMutationFaultPoint faultPoint) - { - if (_replaced || faultPoint != CopyMutationFaultPoint.AfterChunkWritten) - { - return; - } - - using var db = factory.CreateDbContext(); - var job = db.MoveJobs.Single(candidate => candidate.Id == jobId); - job.LeaseOwner = "replacement-worker"; - job.LeaseGeneration++; - job.LeaseExpiresAt = DateTime.UtcNow.AddMinutes(5); - db.SaveChanges(); - _replaced = true; - } - } - - private sealed class ReplaceLeaseBeforeTempOwnershipPublication( - IDbContextFactory factory) : IMoveFaultInjector - { - private bool _replaced; - - public void OnOwnershipMarkerWrite( - Guid jobId, - OwnershipMarkerKind markerKind, - OwnershipMarkerWriteFaultPoint faultPoint) - { - if (_replaced - || markerKind != OwnershipMarkerKind.TemporaryDirectory - || faultPoint != OwnershipMarkerWriteFaultPoint.BeforePublication) - { - return; - } - - using var db = factory.CreateDbContext(); - var job = db.MoveJobs.Single(candidate => candidate.Id == jobId); - job.LeaseOwner = "replacement-worker"; - job.LeaseGeneration++; - job.LeaseExpiresAt = DateTime.UtcNow.AddMinutes(5); - db.SaveChanges(); - _replaced = true; - } - } - - private sealed class ReplaceLeaseBeforeScaffoldPublication( - IDbContextFactory factory) : IMoveFaultInjector - { - private bool _replaced; - - public bool AllowAtomicRename => false; - - public void OnTargetScaffoldPreparation( - Guid jobId, - TargetScaffoldPreparationFaultPoint faultPoint) - { - if (_replaced || faultPoint != TargetScaffoldPreparationFaultPoint.BeforePublication) - { - return; - } - - using var db = factory.CreateDbContext(); - var job = db.MoveJobs.Single(candidate => candidate.Id == jobId); - job.LeaseOwner = "replacement-worker"; - job.LeaseGeneration++; - job.LeaseExpiresAt = DateTime.UtcNow.AddMinutes(5); - db.SaveChanges(); - _replaced = true; - } - } - - private sealed class ReplaceLeaseBeforeOwnedDirectoryDelete( - IDbContextFactory factory) : IMoveFaultInjector - { - private bool _replaced; - - public void OnOwnershipCleanup( - Guid jobId, - OwnershipMarkerKind markerKind, - OwnershipCleanupFaultPoint faultPoint) - { - if (_replaced - || markerKind != OwnershipMarkerKind.QuarantineDirectory - || faultPoint != OwnershipCleanupFaultPoint.BeforeDirectoryDelete) - { - return; - } - - using var db = factory.CreateDbContext(); - var job = db.MoveJobs.Single(candidate => candidate.Id == jobId); - job.LeaseOwner = "replacement-worker"; - job.LeaseGeneration++; - job.LeaseExpiresAt = DateTime.UtcNow.AddMinutes(5); - db.SaveChanges(); - _replaced = true; - } - } -} diff --git a/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceLinkSafetyTests.cs b/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceLinkSafetyTests.cs index f371f64a9..3486e1877 100644 --- a/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceLinkSafetyTests.cs +++ b/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceLinkSafetyTests.cs @@ -1,6 +1,5 @@ using System.Diagnostics; using Listenarr.Tests.Common; -using Microsoft.EntityFrameworkCore; namespace Listenarr.Tests.Features.Infrastructure.Library.Moving; @@ -140,242 +139,6 @@ await Assert.ThrowsAsync(() => } } - [WindowsFact] - public async Task MoveContentsAsync_AtomicSourceChangesAfterPlanning_DoesNotMoveDirectory() - { - - var source = FileService.GetTempDirectory("content-move-atomic-drift-src"); - await FileService.GetFileAsync(source, "book.m4b", "audio"); - var target = Path.Join( - Path.GetDirectoryName(source)!, - $"content-move-atomic-drift-dst-{Guid.NewGuid():N}"); - var request = await CreateLeasedMoveRequestAsync(source, target); - var service = new AudiobookContentMoveService( - _provider.GetRequiredService>(), - _provider.GetRequiredService>(), - TimeProvider.System, - new AddAtomicSourceFileBeforeRevalidation(source)); - - var exception = await Assert.ThrowsAsync(() => - service.MoveContentsAsync(request, CancellationToken.None)); - - Assert.Contains("changed after the atomic move was planned", exception.Message, StringComparison.OrdinalIgnoreCase); - Assert.True(Directory.Exists(source)); - Assert.True(File.Exists(Path.Join(source, "book.m4b"))); - Assert.True(File.Exists(Path.Join(source, "arrived-late.txt"))); - Assert.False(Directory.Exists(target)); - } - - [WindowsFact] - public async Task MoveContentsAsync_AtomicSourceReplacedAtPublication_DoesNotMoveReplacement() - { - - var root = FileService.GetTempDirectory("content-move-atomic-publication-race"); - var source = Path.Join(root, "source"); - var displacedSource = Path.Join(root, "source.original"); - var target = Path.Join(root, "target"); - var external = FileService.GetTempDirectory("content-move-atomic-publication-external"); - Directory.CreateDirectory(source); - await FileService.GetFileAsync(source, "book.m4b", "audio"); - await FileService.GetFileAsync(external, "external.txt", "external"); - var request = await CreateLeasedMoveRequestAsync(source, target); - var injector = new ReplaceAtomicSourceAtPublication( - source, - displacedSource, - external); - var service = new AudiobookContentMoveService( - _provider.GetRequiredService>(), - _provider.GetRequiredService>(), - TimeProvider.System, - injector); - - try - { - await Assert.ThrowsAnyAsync(() => - service.MoveContentsAsync(request, CancellationToken.None)); - - Assert.True(injector.ReplacementRan); - Assert.True(File.Exists(Path.Join(displacedSource, "book.m4b"))); - Assert.False(Directory.Exists(target)); - Assert.Equal("external", await File.ReadAllTextAsync(Path.Join(external, "external.txt"))); - } - finally - { - TryRemoveDirectoryLink(target); - TryRemoveDirectoryLink(source); - if (Directory.Exists(displacedSource) && !Directory.Exists(source)) - { - Directory.Move(displacedSource, source); - } - } - } - - [WindowsFact] - public async Task MoveContentsAsync_AtomicVerificationIoFailure_PreservesRecoverableState() - { - - var source = FileService.GetTempDirectory("content-move-atomic-verify-retry-src"); - await FileService.GetFileAsync(source, "book.m4b", "audio"); - var target = Path.Join( - Path.GetDirectoryName(source)!, - $"content-move-atomic-verify-retry-dst-{Guid.NewGuid():N}"); - var request = await CreateLeasedMoveRequestAsync(source, target); - var service = new AudiobookContentMoveService( - _provider.GetRequiredService>(), - _provider.GetRequiredService>(), - TimeProvider.System, - new ThrowAfterAtomicDirectoryMove()); - - await Assert.ThrowsAsync(() => - service.MoveContentsAsync(request, CancellationToken.None)); - - var markerPath = Path.Join( - target, - $".listenarr-move-{request.JobId:N}.pending"); - Assert.False(Directory.Exists(source)); - Assert.True(File.Exists(Path.Join(target, "book.m4b"))); - Assert.True(File.Exists(markerPath)); - var factory = _provider.GetRequiredService>(); - await using (var db = await factory.CreateDbContextAsync()) - { - var job = await db.MoveJobs.SingleAsync(candidate => candidate.Id == request.JobId); - job.LeaseOwner = "atomic-recovery-worker"; - job.LeaseGeneration++; - job.LeaseExpiresAt = DateTime.UtcNow.AddMinutes(5); - await db.SaveChangesAsync(); - request = request with - { - LeaseToken = new MoveLeaseToken(job.LeaseOwner, job.LeaseGeneration) - }; - } - - var recovered = await _provider.GetRequiredService() - .GetRecoverableMoveAsync(request, CancellationToken.None); - - Assert.NotNull(recovered); - Assert.True(recovered!.SourceCleanupCompleted); - Assert.Equal(markerPath, recovered.RecoveryMarkerPath); - } - - [Fact] - public async Task MoveContentsAsync_AtomicAccessFailureBeforeRename_FallsBackWithoutStaleMarker() - { - var source = FileService.GetTempDirectory("content-move-atomic-access-src"); - await FileService.GetFileAsync(source, "book.m4b", "audio"); - var target = Path.Join( - Path.GetDirectoryName(source)!, - $"content-move-atomic-access-dst-{Guid.NewGuid():N}"); - var request = await CreateLeasedMoveRequestAsync(source, target); - var service = new AudiobookContentMoveService( - _provider.GetRequiredService>(), - _provider.GetRequiredService>(), - TimeProvider.System, - new DenyAtomicRenameBeforeSourceRevalidation()); - - var result = await service.MoveContentsAsync(request, CancellationToken.None); - - Assert.True(result.SourceCleanupCompleted); - Assert.False(Directory.Exists(source)); - Assert.True(File.Exists(Path.Join(target, "book.m4b"))); - Assert.False(File.Exists(Path.Join( - source, - $".listenarr-move-{request.JobId:N}.pending"))); - } - - [Fact] - public async Task MoveContentsAsync_NormalSameVolumeSource_UsesAtomicRename() - { - var source = FileService.GetTempDirectory("content-move-atomic-normal-src"); - await FileService.GetFileAsync(source, "book.m4b", "audio"); - var target = Path.Join( - Path.GetDirectoryName(source)!, - $"content-move-atomic-normal-dst-{Guid.NewGuid():N}"); - var jobId = Guid.NewGuid(); - var request = await CreateLeasedMoveRequestAsync(source, target, jobId); - - var service = _provider.GetRequiredService(); - var result = await service.MoveContentsAsync(request, CancellationToken.None); - - Assert.False(Directory.Exists(source)); - Assert.True(File.Exists(Path.Join(target, "book.m4b"))); - Assert.True(File.Exists(result.RecoveryMarkerPath)); - var factory = _provider.GetRequiredService>(); - await using var db = await factory.CreateDbContextAsync(); - Assert.True(await db.MoveJobEntries.AnyAsync(entry => entry.MoveJobId == jobId)); - } - - private sealed class ReplaceAtomicSourceAtPublication( - string source, - string displacedSource, - string external) : IMoveFaultInjector - { - public bool AllowAtomicRename => true; - - public bool ReplacementRan { get; private set; } - - public void OnAtomicRename(Guid jobId, AtomicRenameFaultPoint faultPoint) - { - if (ReplacementRan || faultPoint != AtomicRenameFaultPoint.BeforeDirectoryPublication) - { - return; - } - - Directory.Move(source, displacedSource); - if (!TryCreateWindowsJunction(source, external)) - { - throw new IOException("The atomic source replacement junction could not be created."); - } - - ReplacementRan = true; - } - } - - private sealed class DenyAtomicRenameBeforeSourceRevalidation : IMoveFaultInjector - { - public bool AllowAtomicRename => true; - - public void OnAtomicRename( - Guid jobId, - AtomicRenameFaultPoint faultPoint) - { - if (faultPoint == AtomicRenameFaultPoint.BeforeSourceRevalidation) - { - throw new UnauthorizedAccessException("Simulated atomic rename access denial."); - } - } - } - - private sealed class ThrowAfterAtomicDirectoryMove : IMoveFaultInjector - { - public bool AllowAtomicRename => true; - - public void OnAtomicRename( - Guid jobId, - AtomicRenameFaultPoint faultPoint) - { - if (faultPoint == AtomicRenameFaultPoint.AfterDirectoryMoveBeforeVerification) - { - throw new IOException("Simulated transient verification failure."); - } - } - } - - private sealed class AddAtomicSourceFileBeforeRevalidation( - string source) : IMoveFaultInjector - { - public bool AllowAtomicRename => true; - - public void OnAtomicRename( - Guid jobId, - AtomicRenameFaultPoint faultPoint) - { - if (faultPoint == AtomicRenameFaultPoint.BeforeSourceRevalidation) - { - File.WriteAllText(Path.Join(source, "arrived-late.txt"), "new content"); - } - } - } - private static bool TryCreateDirectoryLink(string linkPath, string targetPath) { try diff --git a/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceMarkerPublicationTests.cs b/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceMarkerPublicationTests.cs deleted file mode 100644 index f9564ede0..000000000 --- a/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceMarkerPublicationTests.cs +++ /dev/null @@ -1,265 +0,0 @@ -using Listenarr.Tests.Common; -using Microsoft.EntityFrameworkCore; - -namespace Listenarr.Tests.Features.Infrastructure.Library.Moving; - -public partial class AudiobookContentMoveServiceTests -{ - [Theory] - [InlineData(nameof(RecoveryMarkerWriteFaultPoint.BeforeTemporaryFileCreation))] - [InlineData(nameof(RecoveryMarkerWriteFaultPoint.DuringJsonWrite))] - [InlineData(nameof(RecoveryMarkerWriteFaultPoint.DuringFlush))] - [InlineData(nameof(RecoveryMarkerWriteFaultPoint.AfterTemporaryFileWritten))] - [InlineData(nameof(RecoveryMarkerWriteFaultPoint.BeforePublication))] - public async Task MoveContentsAsync_RecoveryMarkerPublicationFailure_PreservesPreviousMarker( - string faultPointName) - { - var faultPoint = Enum.Parse(faultPointName); - var source = FileService.GetTempDirectory($"content-move-marker-{faultPoint}-src"); - var sourceFile = await FileService.GetFileAsync(source, "book.m4b", "verified audio"); - var target = FileService.GetTempDirectory($"content-move-marker-{faultPoint}-dst"); - var jobId = Guid.NewGuid(); - var request = await CreateLeasedMoveRequestAsync(source, target, jobId); - await PersistFileManifestAsync(jobId, "book.m4b", sourceFile); - await WriteRecoveryMarkerAsync(target, jobId, source, target, "copy-started"); - var markerPath = Path.Join(target, $".listenarr-move-{jobId:N}.pending"); - var previousMarker = await File.ReadAllTextAsync(markerPath); - var injector = new RecoveryMarkerFaultInjector(faultPoint); - var service = CreateMoveService(injector); - - await Assert.ThrowsAnyAsync(() => - service.MoveContentsAsync(request, CancellationToken.None)); - - Assert.Equal(previousMarker, await File.ReadAllTextAsync(markerPath)); - Assert.Contains("copy-started", await File.ReadAllTextAsync(markerPath)); - Assert.Empty(Directory.EnumerateFiles(target, $".listenarr-move-{jobId:N}.pending.writing-*")); - Assert.True(File.Exists(sourceFile)); - - var retryService = _provider.GetRequiredService(); - await retryService.MoveContentsAsync(request, CancellationToken.None); - Assert.False(Directory.Exists(source)); - Assert.Equal("verified audio", await File.ReadAllTextAsync(Path.Join(target, "book.m4b"))); - } - - [Fact] - public async Task MoveContentsAsync_RecoveryMarkerTempCleanupFailure_LeavesNonAuthoritativeOrphan() - { - var source = FileService.GetTempDirectory("content-move-marker-cleanup-src"); - var sourceFile = await FileService.GetFileAsync(source, "book.m4b", "verified audio"); - var target = FileService.GetTempDirectory("content-move-marker-cleanup-dst"); - var jobId = Guid.NewGuid(); - var request = await CreateLeasedMoveRequestAsync(source, target, jobId); - await PersistFileManifestAsync(jobId, "book.m4b", sourceFile); - await WriteRecoveryMarkerAsync(target, jobId, source, target, "copy-started"); - var markerPath = Path.Join(target, $".listenarr-move-{jobId:N}.pending"); - var previousMarker = await File.ReadAllTextAsync(markerPath); - var injector = new RecoveryMarkerFaultInjector( - RecoveryMarkerWriteFaultPoint.BeforePublication, - RecoveryMarkerWriteFaultPoint.BeforeTemporaryFileDeletion); - var service = CreateMoveService(injector); - - var exception = await Assert.ThrowsAnyAsync(() => - service.MoveContentsAsync(request, CancellationToken.None)); - - Assert.IsNotType(exception); - Assert.Contains("could not be restored cleanly", exception.Message, StringComparison.OrdinalIgnoreCase); - Assert.Equal(previousMarker, await File.ReadAllTextAsync(markerPath)); - var orphan = Assert.Single( - Directory.EnumerateFiles(target, $".listenarr-move-{jobId:N}.pending.writing-*")); - Assert.NotEmpty(await File.ReadAllTextAsync(orphan)); - - var recoveryService = _provider.GetRequiredService(); - var recovery = await recoveryService.GetRecoverableMoveAsync(request); - Assert.NotNull(recovery); - Assert.False(recovery!.SourceCleanupCompleted); - Assert.True(File.Exists(sourceFile)); - Assert.False(File.Exists(orphan)); - - var completed = await recoveryService.ResumeSourceCleanupAsync( - request, - recovery, - CancellationToken.None); - Assert.True(completed.SourceCleanupCompleted); - Assert.False(Directory.Exists(source)); - Assert.Equal("verified audio", await File.ReadAllTextAsync(Path.Join(target, "book.m4b"))); - } - - [Fact] - public async Task MoveContentsAsync_RecoveryMarkerReplacedBeforePinnedRead_IsPreservedAndRequiresAttention() - { - var source = FileService.GetTempDirectory("content-move-marker-read-swap-src"); - var sourceFile = await FileService.GetFileAsync(source, "book.m4b", "verified audio"); - var target = FileService.GetTempDirectory("content-move-marker-read-swap-dst"); - var jobId = Guid.NewGuid(); - var request = await CreateLeasedMoveRequestAsync(source, target, jobId); - await PersistFileManifestAsync(jobId, "book.m4b", sourceFile); - await WriteRecoveryMarkerAsync(target, jobId, source, target, "copy-started"); - var markerPath = Path.Join(target, $".listenarr-move-{jobId:N}.pending"); - var replacement = System.Text.Json.JsonSerializer.Serialize(new - { - Version = 1, - JobId = Guid.NewGuid(), - Source = Path.GetFullPath(source), - Target = Path.GetFullPath(target), - Stage = "copy-started" - }); - var replaced = false; - using var hook = ExclusiveDirectoryCreator.PushBeforeOpenParentHook(path => - { - if (replaced - || !string.Equals( - Path.GetFullPath(path), - Path.GetFullPath(target), - StringComparison.OrdinalIgnoreCase)) - { - return; - } - - replaced = true; - File.Delete(markerPath); - File.WriteAllText(markerPath, replacement); - }); - var service = _provider.GetRequiredService(); - - var exception = await Assert.ThrowsAsync(() => - service.MoveContentsAsync(request, CancellationToken.None)); - - Assert.True(replaced); - Assert.Contains("different job", exception.Message, StringComparison.OrdinalIgnoreCase); - Assert.Equal(replacement, await File.ReadAllTextAsync(markerPath)); - Assert.True(File.Exists(sourceFile)); - Assert.False(File.Exists(Path.Join(target, "book.m4b"))); - } - - [Fact] - public async Task MoveContentsAsync_RecoveryMarkerReplacedBeforeStageUpdate_IsPreservedAndRequiresAttention() - { - var source = FileService.GetTempDirectory("content-move-marker-swap-src"); - var sourceFile = await FileService.GetFileAsync(source, "book.m4b", "verified audio"); - var target = FileService.GetTempDirectory("content-move-marker-swap-dst"); - var jobId = Guid.NewGuid(); - var request = await CreateLeasedMoveRequestAsync(source, target, jobId); - await PersistFileManifestAsync(jobId, "book.m4b", sourceFile); - await WriteRecoveryMarkerAsync(target, jobId, source, target, "copy-started"); - var markerPath = Path.Join(target, $".listenarr-move-{jobId:N}.pending"); - var replacement = System.Text.Json.JsonSerializer.Serialize(new - { - Version = 1, - JobId = Guid.NewGuid(), - Source = Path.GetFullPath(source), - Target = Path.GetFullPath(target), - Stage = "copy-started" - }); - var service = CreateMoveService( - new ReplaceRecoveryMarkerBeforePublication(markerPath, replacement)); - - var exception = await Assert.ThrowsAsync(() => - service.MoveContentsAsync(request, CancellationToken.None)); - - Assert.Contains("different job", exception.Message, StringComparison.OrdinalIgnoreCase); - Assert.Equal(replacement, await File.ReadAllTextAsync(markerPath)); - Assert.True(File.Exists(sourceFile)); - Assert.Equal("verified audio", await File.ReadAllTextAsync(Path.Join(target, "book.m4b"))); - Assert.Empty(Directory.EnumerateFiles( - target, - $".listenarr-move-{jobId:N}.pending.writing-*")); - } - - [Fact] - public async Task MoveContentsAsync_RecoveryMarkerAdvancedBeforeStageUpdate_IsPreserved() - { - var source = FileService.GetTempDirectory("content-move-marker-advanced-src"); - var sourceFile = await FileService.GetFileAsync(source, "book.m4b", "verified audio"); - var target = FileService.GetTempDirectory("content-move-marker-advanced-dst"); - var jobId = Guid.NewGuid(); - var request = await CreateLeasedMoveRequestAsync(source, target, jobId); - await PersistFileManifestAsync(jobId, "book.m4b", sourceFile); - await WriteRecoveryMarkerAsync(target, jobId, source, target, "copy-started"); - var markerPath = Path.Join(target, $".listenarr-move-{jobId:N}.pending"); - var replacement = System.Text.Json.JsonSerializer.Serialize(new - { - Version = 1, - JobId = jobId, - Source = Path.GetFullPath(source), - Target = Path.GetFullPath(target), - Stage = "source-cleanup-complete" - }); - var service = CreateMoveService( - new ReplaceRecoveryMarkerBeforePublication(markerPath, replacement)); - - var exception = await Assert.ThrowsAsync(() => - service.MoveContentsAsync(request, CancellationToken.None)); - - Assert.Contains("later or incompatible stage", exception.Message, StringComparison.OrdinalIgnoreCase); - Assert.Equal(replacement, await File.ReadAllTextAsync(markerPath)); - Assert.True(File.Exists(sourceFile)); - Assert.True(File.Exists(Path.Join(target, "book.m4b"))); - } - - [WindowsFact] - public async Task MoveContentsAsync_ReplacesExistingHiddenRecoveryMarkerAtomicallyOnWindows() - { - - var source = FileService.GetTempDirectory("content-move-hidden-marker-src"); - var sourceFile = await FileService.GetFileAsync(source, "book.m4b", "verified audio"); - var target = FileService.GetTempDirectory("content-move-hidden-marker-dst"); - var jobId = Guid.NewGuid(); - var request = await CreateLeasedMoveRequestAsync(source, target, jobId); - await PersistFileManifestAsync(jobId, "book.m4b", sourceFile); - await WriteRecoveryMarkerAsync(target, jobId, source, target, "copy-started"); - var markerPath = Path.Join(target, $".listenarr-move-{jobId:N}.pending"); - File.SetAttributes(markerPath, File.GetAttributes(markerPath) | FileAttributes.Hidden); - - var service = _provider.GetRequiredService(); - await service.MoveContentsAsync(request, CancellationToken.None); - - Assert.False(Directory.Exists(source)); - Assert.Contains("source-cleanup-complete", await File.ReadAllTextAsync(markerPath)); - Assert.True((File.GetAttributes(markerPath) & FileAttributes.Hidden) != 0); - } - - private AudiobookContentMoveService CreateMoveService(IMoveFaultInjector faultInjector) - { - return new AudiobookContentMoveService( - _provider.GetRequiredService>(), - _provider.GetRequiredService>(), - TimeProvider.System, - faultInjector); - } - - private sealed class ReplaceRecoveryMarkerBeforePublication( - string markerPath, - string replacement) : IMoveFaultInjector - { - private bool _replaced; - - public void OnRecoveryMarkerWrite( - Guid jobId, - RecoveryMarkerWriteFaultPoint faultPoint) - { - if (_replaced || faultPoint != RecoveryMarkerWriteFaultPoint.BeforePublication) - { - return; - } - - File.WriteAllText(markerPath, replacement); - _replaced = true; - } - } - - private sealed class RecoveryMarkerFaultInjector( - params RecoveryMarkerWriteFaultPoint[] faultPoints) : IMoveFaultInjector - { - private readonly HashSet _faultPoints = [.. faultPoints]; - - public void OnRecoveryMarkerWrite( - Guid jobId, - RecoveryMarkerWriteFaultPoint faultPoint) - { - if (_faultPoints.Contains(faultPoint)) - { - throw new IOException($"Injected recovery marker failure at {faultPoint}."); - } - } - } -} diff --git a/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceNestedQuarantineRecoveryTests.cs b/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceNestedQuarantineRecoveryTests.cs deleted file mode 100644 index bd29e165f..000000000 --- a/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceNestedQuarantineRecoveryTests.cs +++ /dev/null @@ -1,66 +0,0 @@ -using Microsoft.EntityFrameworkCore; - -namespace Listenarr.Tests.Features.Infrastructure.Library.Moving; - -public partial class AudiobookContentMoveServiceTests -{ - [Fact] - public async Task ResumeSourceCleanup_SourceInsideTargetWithOwnedQuarantine_RecoversAfterCrash() - { - var target = FileService.GetTempDirectory("content-move-nested-quarantine-target"); - var source = Path.Join(target, "OldChild"); - Directory.CreateDirectory(source); - await FileService.GetFileAsync(source, "book.m4b", "verified audio"); - var jobId = Guid.NewGuid(); - var request = await CreateLeasedMoveRequestAsync(source, target, jobId); - var faultingService = new AudiobookContentMoveService( - _provider.GetRequiredService>(), - _provider.GetRequiredService>(), - TimeProvider.System, - new StopBeforeFirstQuarantineDelete()); - - await Assert.ThrowsAsync(() => - faultingService.MoveContentsAsync(request, CancellationToken.None)); - - var quarantineRoot = Path.Join( - target, - $".listenarr-quarantine-{jobId:N}"); - Assert.True(Directory.Exists(quarantineRoot)); - Assert.True(File.Exists(Path.Join(quarantineRoot, "book.m4b"))); - Assert.True(File.Exists(Path.Join(target, "book.m4b"))); - - var service = _provider.GetRequiredService(); - var recovered = await service.GetRecoverableMoveAsync( - request, - CancellationToken.None); - - Assert.NotNull(recovered); - var completed = await service.ResumeSourceCleanupAsync( - request, - recovered!, - CancellationToken.None); - - Assert.True(completed.SourceCleanupCompleted); - Assert.False(Directory.Exists(source)); - Assert.False(Directory.Exists(quarantineRoot)); - Assert.True(File.Exists(Path.Join(target, "book.m4b"))); - } - - private sealed class StopBeforeFirstQuarantineDelete : IMoveFaultInjector - { - private bool _stopped; - - public void OnSourceCleanupMutation( - Guid jobId, - SourceCleanupFaultPoint faultPoint) - { - if (_stopped || faultPoint != SourceCleanupFaultPoint.BeforeQuarantineFileDelete) - { - return; - } - - _stopped = true; - throw new IOException("Simulated process stop before quarantine deletion."); - } - } -} diff --git a/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceObsoleteMarkerTests.cs b/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceObsoleteMarkerTests.cs deleted file mode 100644 index 0fef0c80b..000000000 --- a/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceObsoleteMarkerTests.cs +++ /dev/null @@ -1,135 +0,0 @@ -using System.Text.Json; -using Listenarr.Tests.Common; - -namespace Listenarr.Tests.Features.Infrastructure.Library.Moving; - -public partial class AudiobookContentMoveServiceTests -{ - [Theory] - [InlineData("copy-started")] - [InlineData("copy-complete")] - [InlineData("source-cleanup-complete")] - [InlineData("atomic-rename-complete")] - public async Task GetRecoverableMoveAsync_ObsoleteMarker_PreservesAllFilesystemState( - string stage) - { - var source = FileService.GetTempDirectory($"content-move-obsolete-{stage}-src"); - var sourceFile = await FileService.GetFileAsync(source, "book.m4b", "source audio"); - var target = FileService.GetTempDirectory($"content-move-obsolete-{stage}-dst"); - var targetFile = await FileService.GetFileAsync(target, "book.m4b", "target audio"); - var jobId = Guid.NewGuid(); - var request = await CreateLeasedMoveRequestAsync(source, target, jobId); - await PersistFileManifestAsync(jobId, "book.m4b", sourceFile); - var markerPath = Path.Join(target, $".listenarr-move-{jobId:N}.pending"); - await File.WriteAllTextAsync(markerPath, stage); - - var service = _provider.GetRequiredService(); - var exception = await Assert.ThrowsAsync(() => - service.GetRecoverableMoveAsync(request)); - - Assert.Contains("obsolete pre-release", exception.Message, StringComparison.OrdinalIgnoreCase); - Assert.Equal("source audio", await File.ReadAllTextAsync(sourceFile)); - Assert.Equal("target audio", await File.ReadAllTextAsync(targetFile)); - Assert.Equal(stage, await File.ReadAllTextAsync(markerPath)); - } - - [Fact] - public async Task MoveContentsAsync_MatchingJobShapedPartialWithoutMarker_IsPreserved() - { - var source = FileService.GetTempDirectory("content-move-unmarked-partial-src"); - var sourceFile = await FileService.GetFileAsync(source, "book.m4b", "verified audio"); - var target = FileService.GetTempDirectory("content-move-unmarked-partial-dst"); - var jobId = Guid.NewGuid(); - var request = await CreateLeasedMoveRequestAsync(source, target, jobId); - await PersistFileManifestAsync(jobId, "book.m4b", sourceFile); - var partialPath = Path.Join( - target, - $"book.m4b.listenarr-{jobId:N}.partial"); - await File.WriteAllTextAsync(partialPath, "verified audio"); - var service = _provider.GetRequiredService(); - - var exception = await Assert.ThrowsAsync(() => - service.MoveContentsAsync(request, CancellationToken.None)); - - Assert.Contains("without structured move ownership", exception.Message, StringComparison.OrdinalIgnoreCase); - Assert.Equal("verified audio", await File.ReadAllTextAsync(partialPath)); - Assert.Equal("verified audio", await File.ReadAllTextAsync(sourceFile)); - Assert.False(File.Exists(Path.Join(target, "book.m4b"))); - } - - [FileLinkFact] - public async Task MoveContentsAsync_LinkedJobShapedPartialWithoutMarker_IsPreserved() - { - var source = FileService.GetTempDirectory("content-move-linked-partial-src"); - var sourceFile = await FileService.GetFileAsync(source, "book.m4b", "verified audio"); - var target = FileService.GetTempDirectory("content-move-linked-partial-dst"); - var external = FileService.GetTempDirectory("content-move-linked-partial-external"); - var externalFile = await FileService.GetFileAsync(external, "partial.bin", "external bytes"); - var jobId = Guid.NewGuid(); - var request = await CreateLeasedMoveRequestAsync(source, target, jobId); - var partialPath = Path.Join( - target, - $"book.m4b.listenarr-{jobId:N}.partial"); - try - { - File.CreateSymbolicLink(partialPath, externalFile); - } - catch (Exception exception) when (exception is - IOException or UnauthorizedAccessException or PlatformNotSupportedException) - { - throw new Xunit.Sdk.XunitException( - $"This native filesystem regression requires symbolic-link support: {exception.Message}"); - } - - try - { - var service = _provider.GetRequiredService(); - await Assert.ThrowsAsync(() => - service.MoveContentsAsync(request, CancellationToken.None)); - - Assert.Equal("external bytes", await File.ReadAllTextAsync(externalFile)); - Assert.True(File.Exists(partialPath)); - Assert.Equal("verified audio", await File.ReadAllTextAsync(sourceFile)); - Assert.False(File.Exists(Path.Join(target, "book.m4b"))); - } - finally - { - if (File.Exists(partialPath)) - { - File.Delete(partialPath); - } - } - } - - [Fact] - public async Task GetRecoverableMoveAsync_JsonIsLegacyProperty_DoesNotBypassStructuredIdentityValidation() - { - var source = FileService.GetTempDirectory("content-move-json-legacy-src"); - var sourceFile = await FileService.GetFileAsync(source, "book.m4b", "source audio"); - var target = FileService.GetTempDirectory("content-move-json-legacy-dst"); - await FileService.GetFileAsync(target, "book.m4b", "source audio"); - var jobId = Guid.NewGuid(); - var request = await CreateLeasedMoveRequestAsync(source, target, jobId); - await PersistFileManifestAsync(jobId, "book.m4b", sourceFile); - var markerPath = Path.Join(target, $".listenarr-move-{jobId:N}.pending"); - await File.WriteAllTextAsync( - markerPath, - JsonSerializer.Serialize(new - { - Version = 1, - JobId = Guid.NewGuid(), - Source = Path.GetFullPath(source), - Target = Path.GetFullPath(target), - Stage = "copy-complete", - IsLegacy = true - })); - - var service = _provider.GetRequiredService(); - var exception = await Assert.ThrowsAsync(() => - service.GetRecoverableMoveAsync(request)); - - Assert.Contains("different job", exception.Message, StringComparison.OrdinalIgnoreCase); - Assert.True(File.Exists(sourceFile)); - Assert.True(File.Exists(markerPath)); - } -} diff --git a/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceOwnershipMarkerRecoveryTests.cs b/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceOwnershipMarkerRecoveryTests.cs deleted file mode 100644 index 7d2281463..000000000 --- a/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceOwnershipMarkerRecoveryTests.cs +++ /dev/null @@ -1,454 +0,0 @@ -using Listenarr.Tests.Common; -using Microsoft.EntityFrameworkCore; - -namespace Listenarr.Tests.Features.Infrastructure.Library.Moving; - -public partial class AudiobookContentMoveServiceTests -{ - [Theory] - [InlineData((int)OwnershipMarkerWriteFaultPoint.BeforeTemporaryFileCreation)] - [InlineData((int)OwnershipMarkerWriteFaultPoint.DuringJsonWrite)] - [InlineData((int)OwnershipMarkerWriteFaultPoint.DuringFlush)] - [InlineData((int)OwnershipMarkerWriteFaultPoint.AfterTemporaryFileWritten)] - [InlineData((int)OwnershipMarkerWriteFaultPoint.BeforePublication)] - public async Task MoveContentsAsync_TempOwnershipPublicationFailure_RetriesCleanly( - int faultPointValue) - { - var faultPoint = (OwnershipMarkerWriteFaultPoint)faultPointValue; - var source = FileService.GetTempDirectory("content-move-temp-marker-fault-src"); - await FileService.GetFileAsync(source, "book.m4b", "verified audio"); - var target = Path.Join( - FileService.GetTempPath(), - $"content-move-temp-marker-fault-dst-{Guid.NewGuid():N}"); - var request = await CreateLeasedMoveRequestAsync(source, target); - var faultingService = new AudiobookContentMoveService( - _provider.GetRequiredService>(), - _provider.GetRequiredService>(), - TimeProvider.System, - new SingleOwnershipPublicationFaultInjector( - OwnershipMarkerKind.TemporaryDirectory, - faultPoint)); - - var publicationException = await Assert.ThrowsAnyAsync(() => - faultingService.MoveContentsAsync(request, CancellationToken.None)); - Assert.IsNotType(publicationException); - - Assert.True(Directory.Exists(source)); - Assert.False(Directory.Exists(target)); - var service = _provider.GetRequiredService(); - var result = await service.MoveContentsAsync(request, CancellationToken.None); - - Assert.True(result.SourceCleanupCompleted); - Assert.False(Directory.Exists(source)); - Assert.True(File.Exists(Path.Join(target, "book.m4b"))); - } - - [Fact] - public async Task MoveContentsAsync_InterruptedTempOwnershipPublication_RetriesWithoutManualCleanup() - { - var source = FileService.GetTempDirectory("content-move-temp-marker-recovery-src"); - await FileService.GetFileAsync(source, "book.m4b", "verified audio"); - var target = Path.Join( - FileService.GetTempPath(), - $"content-move-temp-marker-recovery-dst-{Guid.NewGuid():N}"); - var request = await CreateLeasedMoveRequestAsync(source, target); - var faultingService = CreateOwnershipFaultingService( - OwnershipMarkerKind.TemporaryDirectory); - - var publicationException = await Assert.ThrowsAnyAsync(() => - faultingService.MoveContentsAsync(request, CancellationToken.None)); - Assert.IsNotType(publicationException); - - Assert.True(Directory.Exists(source)); - var service = _provider.GetRequiredService(); - var result = await service.MoveContentsAsync(request, CancellationToken.None); - - Assert.True(File.Exists(Path.Join(target, "book.m4b"))); - Assert.False(Directory.Exists(source)); - Assert.True(result.SourceCleanupCompleted); - } - - [WindowsFact] - public async Task MoveContentsAsync_RecoveredOwnershipWrite_DoesNotMutateAttributesBeforePinnedPublication() - { - var source = FileService.GetTempDirectory( - "content-move-recovered-marker-attributes-src"); - await FileService.GetFileAsync(source, "book.m4b", "verified audio"); - var target = Path.Join( - FileService.GetTempPath(), - $"content-move-recovered-marker-attributes-dst-{Guid.NewGuid():N}"); - var request = await CreateLeasedMoveRequestAsync(source, target); - var faultingService = CreateOwnershipFaultingService( - OwnershipMarkerKind.TemporaryDirectory); - await Assert.ThrowsAnyAsync(() => - faultingService.MoveContentsAsync(request, CancellationToken.None)); - var tempDirectory = Path.Join( - Path.GetDirectoryName(target)!, - Path.GetFileName(target) + ".tmp-" + request.JobId.ToString("N")); - var writePath = Assert.Single(Directory.EnumerateFiles( - tempDirectory, - ".listenarr-temp-owner.json.writing-*")); - File.SetAttributes(writePath, FileAttributes.Normal); - var recoveryService = CreateMoveService( - new SingleOwnershipPublicationFaultInjector( - OwnershipMarkerKind.TemporaryDirectory, - OwnershipMarkerWriteFaultPoint.BeforeRecoveredPublication)); - - await Assert.ThrowsAsync(() => - recoveryService.MoveContentsAsync(request, CancellationToken.None)); - - Assert.False( - File.GetAttributes(writePath).HasFlag(FileAttributes.Hidden)); - Assert.True(File.Exists(Path.Join(source, "book.m4b"))); - Assert.False(File.Exists(Path.Join(tempDirectory, ".listenarr-temp-owner.json"))); - } - - [Fact] - public async Task MoveContentsAsync_ReplacedRecoveredOwnershipWrite_IsPreserved() - { - var source = FileService.GetTempDirectory("content-move-recovered-marker-replacement-src"); - await FileService.GetFileAsync(source, "book.m4b", "verified audio"); - var target = Path.Join( - FileService.GetTempPath(), - $"content-move-recovered-marker-replacement-dst-{Guid.NewGuid():N}"); - var request = await CreateLeasedMoveRequestAsync(source, target); - var faultingService = CreateOwnershipFaultingService( - OwnershipMarkerKind.TemporaryDirectory); - await Assert.ThrowsAnyAsync(() => - faultingService.MoveContentsAsync(request, CancellationToken.None)); - var tempDirectory = Path.Join( - Path.GetDirectoryName(target)!, - Path.GetFileName(target) + ".tmp-" + request.JobId.ToString("N")); - var writePath = Assert.Single(Directory.EnumerateFiles( - tempDirectory, - ".listenarr-temp-owner.json.writing-*")); - var originalGeneration = writePath + ".original"; - var replacement = await File.ReadAllTextAsync(writePath); - var recoveryService = CreateMoveService( - new ReplaceRecoveredOwnershipWrite( - writePath, - originalGeneration, - replacement)); - - await Assert.ThrowsAsync(() => - recoveryService.MoveContentsAsync(request, CancellationToken.None)); - - Assert.Equal(replacement, await File.ReadAllTextAsync(writePath)); - Assert.Equal(replacement, await File.ReadAllTextAsync(originalGeneration)); - Assert.False(File.Exists(Path.Join(tempDirectory, ".listenarr-temp-owner.json"))); - Assert.True(File.Exists(Path.Join(source, "book.m4b"))); - } - - [Fact] - public async Task ResumeSourceCleanup_InterruptedQuarantineOwnershipPublication_RecoversOrphanWriteMarker() - { - var source = FileService.GetTempDirectory("content-move-quarantine-marker-recovery-src"); - await FileService.GetFileAsync(source, "book.m4b", "verified audio"); - var target = Path.Join( - FileService.GetTempPath(), - $"content-move-quarantine-marker-recovery-dst-{Guid.NewGuid():N}"); - var request = await CreateLeasedMoveRequestAsync(source, target); - var faultingService = CreateOwnershipFaultingService( - OwnershipMarkerKind.QuarantineDirectory); - - var publicationException = await Assert.ThrowsAnyAsync(() => - faultingService.MoveContentsAsync(request, CancellationToken.None)); - Assert.IsNotType(publicationException); - - var quarantineRoot = Path.Join( - Path.GetDirectoryName(source)!, - $".listenarr-quarantine-{request.JobId:N}"); - Assert.True(Directory.Exists(quarantineRoot)); - Assert.Single(Directory.EnumerateFiles( - quarantineRoot, - ".listenarr-quarantine-owner.json.writing-*")); - - var service = _provider.GetRequiredService(); - var recovered = await service.GetRecoverableMoveAsync( - request, - CancellationToken.None); - Assert.NotNull(recovered); - - var completed = await service.ResumeSourceCleanupAsync( - request, - recovered!, - CancellationToken.None); - - Assert.True(completed.SourceCleanupCompleted); - Assert.False(Directory.Exists(source)); - Assert.False(Directory.Exists(quarantineRoot)); - Assert.True(File.Exists(Path.Join(target, "book.m4b"))); - } - - [Theory] - [InlineData((int)OwnershipCleanupFaultPoint.BeforeCleanupDirectoryMove)] - [InlineData((int)OwnershipCleanupFaultPoint.BeforeOwnershipMarkerDelete)] - [InlineData((int)OwnershipCleanupFaultPoint.BeforeDirectoryDelete)] - [InlineData((int)OwnershipCleanupFaultPoint.BeforeTombstoneDelete)] - public async Task ResumeSourceCleanup_InterruptedQuarantineCleanup_UsesCleanupTombstone( - int faultPointValue) - { - var faultPoint = (OwnershipCleanupFaultPoint)faultPointValue; - var source = FileService.GetTempDirectory("content-move-quarantine-tombstone-src"); - await FileService.GetFileAsync(source, "book.m4b", "verified audio"); - var target = Path.Join( - FileService.GetTempPath(), - $"content-move-quarantine-tombstone-dst-{Guid.NewGuid():N}"); - var request = await CreateLeasedMoveRequestAsync(source, target); - var faultingService = new AudiobookContentMoveService( - _provider.GetRequiredService>(), - _provider.GetRequiredService>(), - TimeProvider.System, - new OwnershipCleanupFaultInjector(faultPoint)); - - await Assert.ThrowsAsync(() => - faultingService.MoveContentsAsync(request, CancellationToken.None)); - - var sourceParent = Path.GetDirectoryName(source)!; - var quarantineRoot = Path.Join( - sourceParent, - $".listenarr-quarantine-{request.JobId:N}"); - var cleanupDirectory = Path.Join( - sourceParent, - $".listenarr-quarantine-directory-{request.JobId:N}.cleanup-dir"); - var tombstonePath = Path.Join( - sourceParent, - $".listenarr-quarantine-directory-{request.JobId:N}.cleanup.json"); - Assert.Equal( - faultPoint == OwnershipCleanupFaultPoint.BeforeCleanupDirectoryMove, - Directory.Exists(quarantineRoot)); - Assert.Equal( - faultPoint is OwnershipCleanupFaultPoint.BeforeOwnershipMarkerDelete - or OwnershipCleanupFaultPoint.BeforeDirectoryDelete, - Directory.Exists(cleanupDirectory)); - Assert.True(File.Exists(tombstonePath)); - Assert.False(Directory.Exists(source)); - Assert.True(File.Exists(Path.Join(target, "book.m4b"))); - - var service = _provider.GetRequiredService(); - var recovered = await service.GetRecoverableMoveAsync( - request, - CancellationToken.None); - Assert.NotNull(recovered); - - var completed = await service.ResumeSourceCleanupAsync( - request, - recovered!, - CancellationToken.None); - - Assert.True(completed.SourceCleanupCompleted); - Assert.False(Directory.Exists(quarantineRoot)); - Assert.False(Directory.Exists(cleanupDirectory)); - Assert.False(File.Exists(tombstonePath)); - Assert.False(Directory.Exists(source)); - Assert.True(File.Exists(Path.Join(target, "book.m4b"))); - } - - [Theory] - [InlineData((int)OwnershipCleanupFaultPoint.BeforeDirectoryDelete)] - [InlineData((int)OwnershipCleanupFaultPoint.BeforeTombstoneDelete)] - public async Task MoveContentsAsync_OriginalOwnedPathRecreatedDuringCleanup_PreservesEvidence( - int faultPointValue) - { - var faultPoint = (OwnershipCleanupFaultPoint)faultPointValue; - var source = FileService.GetTempDirectory("content-move-cleanup-recreated-path-src"); - await FileService.GetFileAsync(source, "book.m4b", "verified audio"); - var target = Path.Join( - FileService.GetTempPath(), - $"content-move-cleanup-recreated-path-dst-{Guid.NewGuid():N}"); - var request = await CreateLeasedMoveRequestAsync(source, target); - var sourceParent = Path.GetDirectoryName(source)!; - var quarantineRoot = Path.Join( - sourceParent, - $".listenarr-quarantine-{request.JobId:N}"); - var cleanupDirectory = Path.Join( - sourceParent, - $".listenarr-quarantine-directory-{request.JobId:N}.cleanup-dir"); - var tombstonePath = Path.Join( - sourceParent, - $".listenarr-quarantine-directory-{request.JobId:N}.cleanup.json"); - var service = new AudiobookContentMoveService( - _provider.GetRequiredService>(), - _provider.GetRequiredService>(), - TimeProvider.System, - new RecreateOwnedPathDuringCleanup(quarantineRoot, faultPoint)); - - var exception = await Assert.ThrowsAsync(() => - service.MoveContentsAsync(request, CancellationToken.None)); - - Assert.Contains("recreated", exception.Message, StringComparison.OrdinalIgnoreCase); - Assert.True(Directory.Exists(quarantineRoot)); - Assert.Empty(Directory.EnumerateFileSystemEntries(quarantineRoot)); - Assert.Equal( - faultPoint == OwnershipCleanupFaultPoint.BeforeDirectoryDelete, - Directory.Exists(cleanupDirectory)); - Assert.True(File.Exists(tombstonePath)); - Assert.False(Directory.Exists(source)); - Assert.True(File.Exists(Path.Join(target, "book.m4b"))); - } - - [Fact] - public async Task GetRecoverableMoveAsync_TombstonedDirectoryRecreatedWithContent_IsPreserved() - { - var source = FileService.GetTempDirectory("content-move-tombstone-recreated-src"); - await FileService.GetFileAsync(source, "book.m4b", "verified audio"); - var target = Path.Join( - FileService.GetTempPath(), - $"content-move-tombstone-recreated-dst-{Guid.NewGuid():N}"); - var request = await CreateLeasedMoveRequestAsync(source, target); - var faultingService = new AudiobookContentMoveService( - _provider.GetRequiredService>(), - _provider.GetRequiredService>(), - TimeProvider.System, - new OwnershipCleanupFaultInjector( - OwnershipCleanupFaultPoint.BeforeDirectoryDelete)); - - await Assert.ThrowsAsync(() => - faultingService.MoveContentsAsync(request, CancellationToken.None)); - - var sourceParent = Path.GetDirectoryName(source)!; - var quarantineRoot = Path.Join( - sourceParent, - $".listenarr-quarantine-{request.JobId:N}"); - var cleanupDirectory = Path.Join( - sourceParent, - $".listenarr-quarantine-directory-{request.JobId:N}.cleanup-dir"); - var tombstonePath = Path.Join( - sourceParent, - $".listenarr-quarantine-directory-{request.JobId:N}.cleanup.json"); - Directory.CreateDirectory(quarantineRoot); - var unexpectedFile = await FileService.GetFileAsync( - quarantineRoot, - "operator-note.txt", - "preserve me"); - var service = _provider.GetRequiredService(); - - var exception = await Assert.ThrowsAsync(() => - service.GetRecoverableMoveAsync(request, CancellationToken.None)); - - Assert.Contains("recreated", exception.Message, StringComparison.OrdinalIgnoreCase); - Assert.Equal("preserve me", await File.ReadAllTextAsync(unexpectedFile)); - Assert.True(File.Exists(tombstonePath)); - Assert.True(Directory.Exists(quarantineRoot)); - Assert.True(Directory.Exists(cleanupDirectory)); - Assert.True(File.Exists(Path.Join(target, "book.m4b"))); - } - - private AudiobookContentMoveService CreateOwnershipFaultingService( - OwnershipMarkerKind markerKind) => - new( - _provider.GetRequiredService>(), - _provider.GetRequiredService>(), - TimeProvider.System, - new OwnershipPublicationFaultInjector(markerKind)); - - private sealed class RecreateOwnedPathDuringCleanup( - string originalDirectory, - OwnershipCleanupFaultPoint expectedFaultPoint) : IMoveFaultInjector - { - private bool _recreated; - - public void OnOwnershipCleanup( - Guid jobId, - OwnershipMarkerKind markerKind, - OwnershipCleanupFaultPoint faultPoint) - { - if (_recreated - || markerKind != OwnershipMarkerKind.QuarantineDirectory - || faultPoint != expectedFaultPoint) - { - return; - } - - Directory.CreateDirectory(originalDirectory); - _recreated = true; - } - } - - private sealed class OwnershipCleanupFaultInjector( - OwnershipCleanupFaultPoint expectedFaultPoint) : IMoveFaultInjector - { - private bool _failed; - - public void OnOwnershipCleanup( - Guid jobId, - OwnershipMarkerKind markerKind, - OwnershipCleanupFaultPoint faultPoint) - { - if (_failed - || markerKind != OwnershipMarkerKind.QuarantineDirectory - || faultPoint != expectedFaultPoint) - { - return; - } - - _failed = true; - throw new IOException("Simulated process stop before owned directory deletion."); - } - } - - private sealed class SingleOwnershipPublicationFaultInjector( - OwnershipMarkerKind expectedMarkerKind, - OwnershipMarkerWriteFaultPoint expectedFaultPoint) : IMoveFaultInjector - { - private bool _failed; - - public void OnOwnershipMarkerWrite( - Guid jobId, - OwnershipMarkerKind markerKind, - OwnershipMarkerWriteFaultPoint faultPoint) - { - if (_failed - || markerKind != expectedMarkerKind - || faultPoint != expectedFaultPoint) - { - return; - } - - _failed = true; - throw new IOException($"Simulated ownership publication failure at {faultPoint}."); - } - } - - private sealed class OwnershipPublicationFaultInjector( - OwnershipMarkerKind markerKind) : IMoveFaultInjector - { - public void OnOwnershipMarkerWrite( - Guid jobId, - OwnershipMarkerKind currentMarkerKind, - OwnershipMarkerWriteFaultPoint faultPoint) - { - if (currentMarkerKind == markerKind - && faultPoint is OwnershipMarkerWriteFaultPoint.BeforePublication - or OwnershipMarkerWriteFaultPoint.BeforeTemporaryFileDeletion) - { - throw new IOException($"Simulated ownership publication failure at {faultPoint}."); - } - } - } - - private sealed class ReplaceRecoveredOwnershipWrite( - string writePath, - string originalGeneration, - string replacement) : IMoveFaultInjector - { - private bool _replaced; - - public void OnOwnershipMarkerWrite( - Guid jobId, - OwnershipMarkerKind markerKind, - OwnershipMarkerWriteFaultPoint faultPoint) - { - if (_replaced - || markerKind != OwnershipMarkerKind.TemporaryDirectory - || faultPoint != OwnershipMarkerWriteFaultPoint.BeforeRecoveredPublication) - { - return; - } - - File.Move(writePath, originalGeneration); - File.WriteAllText(writePath, replacement); - _replaced = true; - } - } -} diff --git a/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceOwnershipTests.cs b/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceOwnershipTests.cs deleted file mode 100644 index 5c8044bc2..000000000 --- a/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceOwnershipTests.cs +++ /dev/null @@ -1,154 +0,0 @@ -using System.Text.Json; -using Listenarr.Tests.Common; - -namespace Listenarr.Tests.Features.Infrastructure.Library.Moving; - -public partial class AudiobookContentMoveServiceTests -{ - [Fact] - public async Task MoveContentsAsync_TempMarkerOwnedByAnotherJob_IsRejectedAndPreserved() - { - var source = FileService.GetTempDirectory("content-move-temp-other-src"); - var sourceFile = await FileService.GetFileAsync(source, "book.m4b", "source audio"); - var target = Path.Join( - FileService.GetTempPath(), - $"content-move-temp-other-dst-{Guid.NewGuid():N}"); - var jobId = Guid.NewGuid(); - var request = await CreateLeasedMoveRequestAsync(source, target, jobId); - var tempName = GetTempMovePath(target, jobId); - Directory.CreateDirectory(tempName); - var existingFile = await FileService.GetFileAsync(tempName, "unrelated.txt", "unrelated bytes"); - await WriteTempOwnershipMarkerAsync( - tempName, - Guid.NewGuid(), - source, - target); - - var service = _provider.GetRequiredService(); - var exception = await Assert.ThrowsAsync(() => - service.MoveContentsAsync(request, CancellationToken.None)); - - Assert.Contains("owned by another job", exception.Message, StringComparison.OrdinalIgnoreCase); - Assert.True(File.Exists(sourceFile)); - Assert.Equal("source audio", await File.ReadAllTextAsync(sourceFile)); - Assert.True(Directory.Exists(tempName)); - Assert.Equal("unrelated bytes", await File.ReadAllTextAsync(existingFile)); - Assert.False(Directory.Exists(target)); - } - - [DirectoryLinkFact] - public async Task MoveContentsAsync_OwnedTempWithLinkedChild_IsPreserved() - { - var source = FileService.GetTempDirectory("content-move-temp-linked-src"); - var sourceFile = await FileService.GetFileAsync(source, "book.m4b", "source audio"); - var target = Path.Join( - FileService.GetTempPath(), - $"content-move-temp-linked-dst-{Guid.NewGuid():N}"); - var jobId = Guid.NewGuid(); - var request = await CreateLeasedMoveRequestAsync(source, target, jobId); - var tempName = GetTempMovePath(target, jobId); - Directory.CreateDirectory(tempName); - await WriteTempOwnershipMarkerAsync(tempName, jobId, source, target); - var external = FileService.GetTempDirectory("content-move-temp-linked-external"); - var externalFile = await FileService.GetFileAsync(external, "external.txt", "external bytes"); - var linkedChild = Path.Join(tempName, "linked-child"); - Assert.True( - TryCreateDirectoryLink(linkedChild, external), - "The required directory link could not be created."); - - try - { - var service = _provider.GetRequiredService(); - await Assert.ThrowsAsync(() => - service.MoveContentsAsync(request, CancellationToken.None)); - - Assert.True(File.Exists(sourceFile)); - Assert.True(Directory.Exists(tempName)); - Assert.True(File.Exists(externalFile)); - Assert.Equal("external bytes", await File.ReadAllTextAsync(externalFile)); - Assert.False(Directory.Exists(target)); - } - finally - { - TryRemoveDirectoryLink(linkedChild); - } - } - - [Fact] - public async Task MoveContentsAsync_ValidOwnedTempDirectory_ResumesPartialCopy() - { - var source = FileService.GetTempDirectory("content-move-temp-resume-src"); - var sourceFile = await FileService.GetFileAsync(source, "book.m4b", "verified audio"); - var target = Path.Join( - FileService.GetTempPath(), - $"content-move-temp-resume-dst-{Guid.NewGuid():N}"); - var jobId = Guid.NewGuid(); - var request = await CreateLeasedMoveRequestAsync(source, target, jobId); - await PersistFileManifestAsync(jobId, "book.m4b", sourceFile); - var tempName = GetTempMovePath(target, jobId); - Directory.CreateDirectory(tempName); - await WriteTempOwnershipMarkerAsync(tempName, jobId, source, target); - var partialPath = Path.Join(tempName, $"book.m4b.listenarr-{jobId:N}.partial"); - await File.WriteAllTextAsync(partialPath, "verified audio"); - - var service = _provider.GetRequiredService(); - var result = await service.MoveContentsAsync(request, CancellationToken.None); - - Assert.False(Directory.Exists(source)); - Assert.False(Directory.Exists(tempName)); - Assert.True(File.Exists(Path.Join(target, "book.m4b"))); - Assert.Equal("verified audio", await File.ReadAllTextAsync(Path.Join(target, "book.m4b"))); - await service.FinalizeMoveAsync(request, result, CancellationToken.None); - Assert.False(File.Exists(Path.Join(target, ".listenarr-temp-owner.json"))); - } - - [Fact] - public async Task MoveContentsAsync_ValidOwnedTempDirectory_MayReplaceDifferingCompletedFile() - { - var source = FileService.GetTempDirectory("content-move-temp-replace-src"); - await FileService.GetFileAsync(source, "book.m4b", "verified audio"); - var target = Path.Join( - FileService.GetTempPath(), - $"content-move-temp-replace-dst-{Guid.NewGuid():N}"); - var jobId = Guid.NewGuid(); - var request = await CreateLeasedMoveRequestAsync(source, target, jobId); - var tempName = GetTempMovePath(target, jobId); - Directory.CreateDirectory(tempName); - await WriteTempOwnershipMarkerAsync(tempName, jobId, source, target); - await File.WriteAllTextAsync(Path.Join(tempName, "book.m4b"), "stale bytes"); - - var service = _provider.GetRequiredService(); - await service.MoveContentsAsync(request, CancellationToken.None); - - Assert.False(Directory.Exists(source)); - Assert.Equal("verified audio", await File.ReadAllTextAsync(Path.Join(target, "book.m4b"))); - } - - private static string GetTempMovePath(string target, Guid jobId) - { - var targetParent = Path.GetDirectoryName(target)!; - return Path.Join( - targetParent, - Path.GetFileName(target) + ".tmp-" + jobId.ToString("N")); - } - - private static Task WriteTempOwnershipMarkerAsync( - string directory, - Guid jobId, - string source, - string target) - { - return File.WriteAllTextAsync( - Path.Join(directory, ".listenarr-temp-owner.json"), - JsonSerializer.Serialize(new - { - Version = 1, - ArtifactType = "temporary-directory", - JobId = jobId, - Source = Path.GetFullPath(source), - Target = Path.GetFullPath(target), - DirectoryPath = Path.GetFullPath(directory), - OwnedArtifactType = (string?)null - })); - } -} diff --git a/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceQuarantineDirectoryCreationTests.cs b/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceQuarantineDirectoryCreationTests.cs deleted file mode 100644 index 5338f786d..000000000 --- a/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceQuarantineDirectoryCreationTests.cs +++ /dev/null @@ -1,159 +0,0 @@ -using Listenarr.Tests.Common; -using Microsoft.EntityFrameworkCore; - -namespace Listenarr.Tests.Features.Infrastructure.Library.Moving; - -public partial class AudiobookContentMoveServiceTests -{ - [DirectoryLinkFact] - public async Task MoveContentsAsync_QuarantineParentReplacedAfterHandleOpen_DoesNotCreateOutsideBoundary() - { - var root = FileService.GetTempDirectory("content-move-quarantine-parent-race-root"); - var sourceParent = Path.Join(root, "source-parent"); - var displacedParent = Path.Join(root, "source-parent.original"); - var external = FileService.GetTempDirectory("content-move-quarantine-parent-race-external"); - var probe = Path.Join(root, "link-probe"); - Directory.CreateDirectory(sourceParent); - Assert.True( - TryCreateTempDirectoryLink(probe, external), - "The required directory link could not be created."); - Directory.Delete(probe); - - var source = Path.Join(sourceParent, "Book"); - Directory.CreateDirectory(source); - await FileService.GetFileAsync(source, "book.m4b", "audio"); - var target = Path.Join( - FileService.GetTempDirectory("content-move-quarantine-parent-race-target"), - "Book"); - var request = await CreateLeasedMoveRequestAsync(source, target); - var quarantineRoot = Path.Join( - sourceParent, - $".listenarr-quarantine-{request.JobId:N}"); - var hookRan = false; - var quarantineAlreadyExistedAtHook = false; - void ReplaceParent(string path) - { - if (hookRan || !string.Equals(path, quarantineRoot, StringComparison.Ordinal)) - { - return; - } - - hookRan = true; - quarantineAlreadyExistedAtHook = Directory.Exists(quarantineRoot); - Directory.Move(sourceParent, displacedParent); - Directory.CreateSymbolicLink(sourceParent, external); - } - - using var hook = ExclusiveDirectoryCreator.PushBeforeCreateHook(ReplaceParent); - try - { - var service = new AudiobookContentMoveService( - _provider.GetRequiredService>(), - _provider.GetRequiredService>(), - TimeProvider.System, - new DisableAtomicRename()); - await Assert.ThrowsAnyAsync(() => - service.MoveContentsAsync(request, CancellationToken.None)); - - Assert.True(hookRan); - Assert.False(quarantineAlreadyExistedAtHook); - Assert.Empty(Directory.EnumerateFileSystemEntries(external)); - Assert.True(File.Exists(Path.Join( - displacedParent, - Path.GetFileName(source), - "book.m4b"))); - } - finally - { - TryDeleteTempDirectoryLink(sourceParent); - if (Directory.Exists(displacedParent) && !Directory.Exists(sourceParent)) - { - Directory.Move(displacedParent, sourceParent); - } - } - } - - [DirectoryLinkFact] - public async Task MoveContentsAsync_QuarantineParentReplacedBeforeMarkerCreation_DoesNotWriteOutsideBoundary() - { - var root = FileService.GetTempDirectory("content-move-quarantine-marker-race-root"); - var sourceParent = Path.Join(root, "source-parent"); - var displacedParent = Path.Join(root, "source-parent.original"); - var external = FileService.GetTempDirectory("content-move-quarantine-marker-race-external"); - var probe = Path.Join(root, "link-probe"); - Directory.CreateDirectory(sourceParent); - Assert.True( - TryCreateTempDirectoryLink(probe, external), - "The required directory link could not be created."); - Directory.Delete(probe); - - var source = Path.Join(sourceParent, "Book"); - Directory.CreateDirectory(source); - await FileService.GetFileAsync(source, "book.m4b", "audio"); - var target = Path.Join( - FileService.GetTempDirectory("content-move-quarantine-marker-race-target"), - "Book"); - var request = await CreateLeasedMoveRequestAsync(source, target); - var quarantineRoot = Path.Join( - sourceParent, - $".listenarr-quarantine-{request.JobId:N}"); - var externalQuarantine = Path.Join(external, Path.GetFileName(quarantineRoot)); - var replacementRan = false; - void ReplaceParent() - { - replacementRan = true; - Directory.CreateDirectory(externalQuarantine); - Directory.Move(sourceParent, displacedParent); - Directory.CreateSymbolicLink(sourceParent, external); - } - - try - { - var service = new AudiobookContentMoveService( - _provider.GetRequiredService>(), - _provider.GetRequiredService>(), - TimeProvider.System, - new ReplaceQuarantineParentBeforeMarkerCreation(ReplaceParent)); - await Assert.ThrowsAnyAsync(() => - service.MoveContentsAsync(request, CancellationToken.None)); - - Assert.True(replacementRan); - Assert.False(File.Exists(Path.Join( - externalQuarantine, - ".listenarr-quarantine-owner.json"))); - Assert.Equal("audio", await File.ReadAllTextAsync(Path.Join(target, "book.m4b"))); - } - finally - { - TryDeleteTempDirectoryLink(sourceParent); - if (Directory.Exists(displacedParent) && !Directory.Exists(sourceParent)) - { - Directory.Move(displacedParent, sourceParent); - } - } - } - - private sealed class ReplaceQuarantineParentBeforeMarkerCreation(Action replaceParent) - : IMoveFaultInjector - { - private bool _replaced; - - public bool AllowAtomicRename => false; - - public void OnOwnershipMarkerWrite( - Guid jobId, - OwnershipMarkerKind markerKind, - OwnershipMarkerWriteFaultPoint faultPoint) - { - if (_replaced - || markerKind != OwnershipMarkerKind.QuarantineDirectory - || faultPoint != OwnershipMarkerWriteFaultPoint.BeforeTemporaryFileCreation) - { - return; - } - - _replaced = true; - replaceParent(); - } - } -} diff --git a/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceQuarantineSafetyTests.cs b/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceQuarantineSafetyTests.cs deleted file mode 100644 index a3c2d0ea9..000000000 --- a/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceQuarantineSafetyTests.cs +++ /dev/null @@ -1,368 +0,0 @@ -using Listenarr.Tests.Common; -using Microsoft.EntityFrameworkCore; - -namespace Listenarr.Tests.Features.Infrastructure.Library.Moving; - -public partial class AudiobookContentMoveServiceTests -{ - [Fact] - public async Task ResumeSourceCleanup_UnmarkedQuarantine_PreservesMatchingFile() - { - var source = FileService.GetTempDirectory("content-move-unmarked-quarantine-src"); - var target = FileService.GetTempDirectory("content-move-unmarked-quarantine-dst"); - var jobId = Guid.NewGuid(); - var quarantineRoot = Path.Join( - Path.GetDirectoryName(source)!, - $".listenarr-quarantine-{jobId:N}"); - Directory.CreateDirectory(quarantineRoot); - var destination = await FileService.GetFileAsync(target, "book.m4b", "verified audio"); - var quarantineFile = await FileService.GetFileAsync( - quarantineRoot, - "book.m4b", - "verified audio"); - await PersistQuarantinedEntryAsync(jobId, source, target, "book.m4b", destination); - var service = _provider.GetRequiredService(); - - await Assert.ThrowsAsync(() => - service.ResumeSourceCleanupAsync( - CreateCleanupRequest(source, target, jobId), - CreateIncompleteCleanupResult(source, target, jobId), - CancellationToken.None)); - - Assert.True(File.Exists(quarantineFile)); - Assert.Equal("verified audio", await File.ReadAllTextAsync(quarantineFile)); - } - - [Theory] - [InlineData("job")] - [InlineData("source")] - [InlineData("target")] - [InlineData("version")] - [InlineData("malformed")] - public async Task ResumeSourceCleanup_InvalidQuarantineMarker_PreservesMatchingFile( - string invalidField) - { - var source = FileService.GetTempDirectory($"content-move-invalid-quarantine-{invalidField}-src"); - var target = FileService.GetTempDirectory($"content-move-invalid-quarantine-{invalidField}-dst"); - var jobId = Guid.NewGuid(); - var quarantineRoot = Path.Join( - Path.GetDirectoryName(source)!, - $".listenarr-quarantine-{jobId:N}"); - Directory.CreateDirectory(quarantineRoot); - await WriteInvalidQuarantineOwnershipMarkerAsync( - quarantineRoot, - invalidField, - jobId, - source, - target); - var destination = await FileService.GetFileAsync(target, "book.m4b", "verified audio"); - var quarantineFile = await FileService.GetFileAsync( - quarantineRoot, - "book.m4b", - "verified audio"); - await PersistQuarantinedEntryAsync(jobId, source, target, "book.m4b", destination); - var service = _provider.GetRequiredService(); - - await Assert.ThrowsAsync(() => - service.ResumeSourceCleanupAsync( - CreateCleanupRequest(source, target, jobId), - CreateIncompleteCleanupResult(source, target, jobId), - CancellationToken.None)); - - Assert.True(File.Exists(quarantineFile)); - Assert.True(File.Exists(Path.Join( - quarantineRoot, - ".listenarr-quarantine-owner.json"))); - } - - [LinuxFact] - public async Task ResumeSourceCleanup_AmbiguousPersistedQuarantineMarkerPath_PreservesMatchingFile() - { - var source = FileService.GetTempDirectory("content-move-ambiguous-quarantine-src"); - var target = FileService.GetTempDirectory("content-move-ambiguous-quarantine-dst"); - var jobId = Guid.NewGuid(); - var quarantineRoot = Path.Join( - Path.GetDirectoryName(source)!, - $".listenarr-quarantine-{jobId:N}"); - Directory.CreateDirectory(quarantineRoot); - var ambiguousSource = "/" + Path.GetFullPath(source); - Assert.False(FileSystemPathIdentity.TryDetectAbsoluteSyntax( - ambiguousSource, - out _)); - await File.WriteAllTextAsync( - Path.Join(quarantineRoot, ".listenarr-quarantine-owner.json"), - System.Text.Json.JsonSerializer.Serialize(new - { - Version = 1, - ArtifactType = "quarantine-directory", - JobId = jobId, - Source = ambiguousSource, - Target = Path.GetFullPath(target), - DirectoryPath = Path.GetFullPath(quarantineRoot), - OwnedArtifactType = (string?)null - })); - var destination = await FileService.GetFileAsync( - target, - "book.m4b", - "verified audio"); - var quarantineFile = await FileService.GetFileAsync( - quarantineRoot, - "book.m4b", - "verified audio"); - await PersistQuarantinedEntryAsync( - jobId, - source, - target, - "book.m4b", - destination); - - await Assert.ThrowsAsync(() => - _provider.GetRequiredService() - .ResumeSourceCleanupAsync( - CreateCleanupRequest(source, target, jobId), - CreateIncompleteCleanupResult(source, target, jobId), - CancellationToken.None)); - - Assert.True(File.Exists(quarantineFile)); - Assert.True(File.Exists(Path.Join( - quarantineRoot, - ".listenarr-quarantine-owner.json"))); - } - - [Fact] - public async Task ResumeSourceCleanup_UnexpectedOwnedQuarantineContent_PreservesOwnershipEvidence() - { - var source = FileService.GetTempDirectory("content-move-unexpected-quarantine-src"); - var target = FileService.GetTempDirectory("content-move-unexpected-quarantine-dst"); - var jobId = Guid.NewGuid(); - var quarantineRoot = Path.Join( - Path.GetDirectoryName(source)!, - $".listenarr-quarantine-{jobId:N}"); - Directory.CreateDirectory(quarantineRoot); - await WriteQuarantineOwnershipMarkerAsync( - quarantineRoot, - jobId, - source, - target); - var destination = await FileService.GetFileAsync(target, "book.m4b", "verified audio"); - await FileService.GetFileAsync(quarantineRoot, "book.m4b", "verified audio"); - var unexpectedFile = await FileService.GetFileAsync( - quarantineRoot, - "operator-note.txt", - "preserve me"); - await PersistQuarantinedEntryAsync(jobId, source, target, "book.m4b", destination); - var markerPath = Path.Join(quarantineRoot, ".listenarr-quarantine-owner.json"); - var service = _provider.GetRequiredService(); - - await Assert.ThrowsAsync(() => - service.ResumeSourceCleanupAsync( - CreateCleanupRequest(source, target, jobId), - CreateIncompleteCleanupResult(source, target, jobId), - CancellationToken.None)); - - Assert.True(File.Exists(markerPath)); - Assert.True(File.Exists(unexpectedFile)); - Assert.Equal("preserve me", await File.ReadAllTextAsync(unexpectedFile)); - } - - [DirectoryLinkFact] - public async Task ResumeSourceCleanup_LinkedQuarantineRoot_PreservesExternalFile() - { - var source = FileService.GetTempDirectory("content-move-linked-quarantine-root-src"); - var target = FileService.GetTempDirectory("content-move-linked-quarantine-root-dst"); - var jobId = Guid.NewGuid(); - var quarantineRoot = Path.Join( - Path.GetDirectoryName(source)!, - $".listenarr-quarantine-{jobId:N}"); - var external = FileService.GetTempDirectory("content-move-linked-quarantine-root-external"); - await WriteQuarantineOwnershipMarkerAsync( - external, - jobId, - source, - target); - var externalFile = await FileService.GetFileAsync( - external, - "book.m4b", - "verified audio"); - Assert.True( - TryCreateDirectoryLink(quarantineRoot, external), - "The required directory link could not be created."); - - try - { - var destination = await FileService.GetFileAsync( - target, - "book.m4b", - "verified audio"); - await PersistQuarantinedEntryAsync( - jobId, - source, - target, - "book.m4b", - destination); - var service = _provider.GetRequiredService(); - - await Assert.ThrowsAsync(() => - service.ResumeSourceCleanupAsync( - CreateCleanupRequest(source, target, jobId), - CreateIncompleteCleanupResult(source, target, jobId), - CancellationToken.None)); - - Assert.True(File.Exists(externalFile)); - Assert.Equal("verified audio", await File.ReadAllTextAsync(externalFile)); - } - finally - { - TryRemoveDirectoryLink(quarantineRoot); - } - } - - [DirectoryLinkFact] - public async Task ResumeSourceCleanup_LinkedQuarantineEntry_PreservesExternalFile() - { - var source = FileService.GetTempDirectory("content-move-linked-quarantine-src"); - var target = FileService.GetTempDirectory("content-move-linked-quarantine-dst"); - var jobId = Guid.NewGuid(); - var quarantineRoot = Path.Join( - Path.GetDirectoryName(source)!, - $".listenarr-quarantine-{jobId:N}"); - Directory.CreateDirectory(quarantineRoot); - await WriteQuarantineOwnershipMarkerAsync( - quarantineRoot, - jobId, - source, - target); - var external = FileService.GetTempDirectory("content-move-linked-quarantine-external"); - var externalFile = await FileService.GetFileAsync(external, "book.m4b", "verified audio"); - var linkedDirectory = Path.Join(quarantineRoot, "nested"); - Assert.True( - TryCreateDirectoryLink(linkedDirectory, external), - "The required directory link could not be created."); - - try - { - var destinationDirectory = Path.Join(target, "nested"); - Directory.CreateDirectory(destinationDirectory); - var destination = await FileService.GetFileAsync( - destinationDirectory, - "book.m4b", - "verified audio"); - await PersistQuarantinedEntryAsync( - jobId, - source, - target, - Path.Join("nested", "book.m4b"), - destination); - var service = _provider.GetRequiredService(); - - await Assert.ThrowsAsync(() => - service.ResumeSourceCleanupAsync( - CreateCleanupRequest(source, target, jobId), - CreateIncompleteCleanupResult(source, target, jobId), - CancellationToken.None)); - - Assert.True(File.Exists(externalFile)); - Assert.Equal("verified audio", await File.ReadAllTextAsync(externalFile)); - } - finally - { - TryRemoveDirectoryLink(linkedDirectory); - } - } - - private static Task WriteInvalidQuarantineOwnershipMarkerAsync( - string quarantineRoot, - string invalidField, - Guid jobId, - string source, - string target) - { - var markerPath = Path.Join( - quarantineRoot, - ".listenarr-quarantine-owner.json"); - if (invalidField == "malformed") - { - return File.WriteAllTextAsync(markerPath, "{invalid-json"); - } - - var marker = System.Text.Json.JsonSerializer.Serialize(new - { - Version = invalidField == "version" ? 2 : 1, - ArtifactType = "quarantine-directory", - JobId = invalidField == "job" ? Guid.NewGuid() : jobId, - Source = invalidField == "source" - ? Path.Join(Path.GetDirectoryName(source)!, "other-source") - : Path.GetFullPath(source), - Target = invalidField == "target" - ? Path.Join(Path.GetDirectoryName(target)!, "other-target") - : Path.GetFullPath(target), - DirectoryPath = Path.GetFullPath(quarantineRoot), - OwnedArtifactType = (string?)null - }); - return File.WriteAllTextAsync(markerPath, marker); - } - - private async Task PersistQuarantinedEntryAsync( - Guid jobId, - string source, - string target, - string relativePath, - string destination) - { - var hash = Convert.ToHexString( - System.Security.Cryptography.SHA256.HashData( - await File.ReadAllBytesAsync(destination))); - var factory = _provider.GetRequiredService>(); - await using var db = await factory.CreateDbContextAsync(); - db.MoveJobs.Add(new MoveJob - { - Id = jobId, - AudiobookId = 1, - RequestedPath = target, - SourcePath = source, - Status = MoveJobStatus.Running, - LeaseOwner = TestLeaseOwner, - LeaseGeneration = 1, - LeaseExpiresAt = DateTime.UtcNow.AddMinutes(5), - ActiveDeduplicationKey = $"test:{jobId:N}" - }); - db.MoveJobEntries.Add(new MoveJobEntry - { - MoveJobId = jobId, - RelativePath = relativePath, - EntryType = MoveJobEntryType.File, - Length = new FileInfo(destination).Length, - Sha256 = hash, - CopyState = MoveJobEntryCopyState.Verified, - CleanupState = MoveJobEntryCleanupState.Quarantined - }); - await db.SaveChangesAsync(); - await AuthorizeExistingMoveJobTargetAsync(jobId, target); - } - - private static AudiobookContentMoveRequest CreateCleanupRequest( - string source, - string target, - Guid jobId) => - new( - source, - target, - jobId, - true, - FileSystemPathSemantics.CurrentHostDefault, - FileSystemPathSemantics.CurrentHostDefault, - LeaseToken(1)); - - private static AudiobookContentMoveResult CreateIncompleteCleanupResult( - string source, - string target, - Guid jobId) => - new( - source, - target, - false, - false, - Path.Join(target, $".listenarr-move-{jobId:N}.pending"), - false, - new Dictionary()); -} diff --git a/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceRecoverySafetyTests.cs b/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceRecoverySafetyTests.cs deleted file mode 100644 index 5db5e62a1..000000000 --- a/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceRecoverySafetyTests.cs +++ /dev/null @@ -1,440 +0,0 @@ -using System.Text.Json; -using Listenarr.Tests.Common; -using Microsoft.EntityFrameworkCore; - -namespace Listenarr.Tests.Features.Infrastructure.Library.Moving; - -public partial class AudiobookContentMoveServiceTests -{ - [Fact] - public async Task GetRecoverableMoveAsync_AtomicMarkerWithSourceAndTarget_RequiresAttention() - { - var source = FileService.GetTempDirectory("content-move-atomic-both-src"); - await FileService.GetFileAsync(source, "book.m4b", "source audio"); - var target = FileService.GetTempDirectory("content-move-atomic-both-dst"); - await FileService.GetFileAsync(target, "book.m4b", "target audio"); - var jobId = Guid.NewGuid(); - var request = await CreateLeasedMoveRequestAsync(source, target, jobId); - await WriteRecoveryMarkerAsync(target, jobId, source, target, "atomic-rename-complete"); - - var service = _provider.GetRequiredService(); - var exception = await Assert.ThrowsAsync(() => - service.GetRecoverableMoveAsync(request)); - - Assert.Contains("Both source and target exist", exception.Message, StringComparison.OrdinalIgnoreCase); - Assert.Equal("source audio", await File.ReadAllTextAsync(Path.Join(source, "book.m4b"))); - Assert.Equal("target audio", await File.ReadAllTextAsync(Path.Join(target, "book.m4b"))); - } - - [Fact] - public async Task GetRecoverableMoveAsync_AtomicMarkerBeforeRename_DoesNotRecover() - { - var source = FileService.GetTempDirectory("content-move-atomic-before-src"); - await FileService.GetFileAsync(source, "book.m4b", "audio"); - var target = Path.Join(FileService.GetTempPath(), $"content-move-atomic-before-dst-{Guid.NewGuid():N}"); - var jobId = Guid.NewGuid(); - var request = await CreateLeasedMoveRequestAsync(source, target, jobId); - await WriteRecoveryMarkerAsync(source, jobId, source, target, "atomic-rename-complete"); - - var service = _provider.GetRequiredService(); - var result = await service.GetRecoverableMoveAsync(request); - - Assert.Null(result); - Assert.True(Directory.Exists(source)); - Assert.False(Directory.Exists(target)); - } - - [Fact] - public async Task MoveContentsAsync_AuthoritativeAtomicMarkerBeforeRename_ResumesSafely() - { - var source = FileService.GetTempDirectory("content-move-atomic-before-resume-src"); - await FileService.GetFileAsync(source, "book.m4b", "audio"); - var target = Path.Join( - Path.GetDirectoryName(source)!, - $"content-move-atomic-before-resume-dst-{Guid.NewGuid():N}"); - var jobId = Guid.NewGuid(); - var request = await CreateLeasedMoveRequestAsync(source, target, jobId); - await WriteRecoveryMarkerAsync( - source, - jobId, - source, - target, - "atomic-rename-complete"); - - var service = new AudiobookContentMoveService( - _provider.GetRequiredService>(), - _provider.GetRequiredService>(), - TimeProvider.System, - new DisableAtomicRename()); - var result = await service.MoveContentsAsync(request, CancellationToken.None); - - Assert.True(result.SourceCleanupCompleted); - Assert.False(Directory.Exists(source)); - Assert.True(File.Exists(Path.Join(target, "book.m4b"))); - Assert.True(File.Exists(result.RecoveryMarkerPath)); - var factory = _provider.GetRequiredService>(); - await using var db = await factory.CreateDbContextAsync(); - Assert.NotEmpty(await db.MoveJobEntries - .Where(entry => entry.MoveJobId == jobId) - .ToListAsync()); - } - - [Fact] - public async Task MoveContentsAsync_SourceAtomicMarkerWithExistingTarget_RequiresAttention() - { - var source = FileService.GetTempDirectory("content-move-atomic-conflict-src"); - await FileService.GetFileAsync(source, "book.m4b", "source audio"); - var target = FileService.GetTempDirectory("content-move-atomic-conflict-dst"); - await FileService.GetFileAsync(target, "operator-note.txt", "preserve me"); - var jobId = Guid.NewGuid(); - var request = await CreateLeasedMoveRequestAsync(source, target, jobId); - await WriteRecoveryMarkerAsync( - source, - jobId, - source, - target, - "atomic-rename-complete"); - - var service = _provider.GetRequiredService(); - var exception = await Assert.ThrowsAsync(() => - service.MoveContentsAsync(request, CancellationToken.None)); - - Assert.Contains("conflicts with existing target", exception.Message, StringComparison.OrdinalIgnoreCase); - Assert.True(File.Exists(Path.Join(source, "book.m4b"))); - Assert.Equal("preserve me", await File.ReadAllTextAsync(Path.Join(target, "operator-note.txt"))); - } - - [Fact] - public async Task GetRecoverableMoveAsync_MissingSourceAndTarget_DoesNotRecover() - { - var source = FileService.GetTempDirectory("content-move-atomic-neither-src"); - var target = Path.Join(FileService.GetTempPath(), $"content-move-atomic-neither-dst-{Guid.NewGuid():N}"); - var jobId = Guid.NewGuid(); - var request = await CreateLeasedMoveRequestAsync(source, target, jobId); - Directory.Delete(source, recursive: true); - - var service = _provider.GetRequiredService(); - var result = await service.GetRecoverableMoveAsync(request); - - Assert.Null(result); - } - - [Fact] - public async Task GetRecoverableMoveAsync_LegacyAtomicMarker_RequiresAttention() - { - var source = FileService.GetTempDirectory("content-move-legacy-atomic-src"); - await FileService.GetFileAsync(source, "book.m4b", "audio"); - var target = Path.Join(FileService.GetTempPath(), $"content-move-legacy-atomic-dst-{Guid.NewGuid():N}"); - var jobId = Guid.NewGuid(); - var request = await CreateLeasedMoveRequestAsync(source, target, jobId); - await File.WriteAllTextAsync( - Path.Join(source, $".listenarr-move-{jobId:N}.pending"), - "atomic-rename-complete"); - Directory.Move(source, target); - - var service = _provider.GetRequiredService(); - var exception = await Assert.ThrowsAsync(() => - service.GetRecoverableMoveAsync(request)); - - Assert.Contains("obsolete pre-release", exception.Message, StringComparison.OrdinalIgnoreCase); - Assert.True(File.Exists(Path.Join(target, "book.m4b"))); - } - - [Fact] - public async Task GetRecoverableMoveAsync_AtomicMarkerWithWrongIdentity_RequiresAttention() - { - var source = FileService.GetTempDirectory("content-move-atomic-wrong-src"); - await FileService.GetFileAsync(source, "book.m4b", "audio"); - var target = Path.Join(FileService.GetTempPath(), $"content-move-atomic-wrong-dst-{Guid.NewGuid():N}"); - var jobId = Guid.NewGuid(); - var request = await CreateLeasedMoveRequestAsync(source, target, jobId); - await WriteRecoveryMarkerAsync( - source, - Guid.NewGuid(), - source, - target, - "atomic-rename-complete", - markerFileJobId: jobId); - Directory.Move(source, target); - - var service = _provider.GetRequiredService(); - await Assert.ThrowsAsync(() => - service.GetRecoverableMoveAsync(request)); - } - - [LinuxFact] - public async Task GetRecoverableMoveAsync_AmbiguousPersistedMarkerPath_RequiresAttention() - { - var source = FileService.GetTempDirectory("content-move-ambiguous-recovery-src"); - var sourceFile = await FileService.GetFileAsync(source, "book.m4b", "audio"); - var target = Path.Join( - FileService.GetTempPath(), - $"content-move-ambiguous-recovery-dst-{Guid.NewGuid():N}"); - var jobId = Guid.NewGuid(); - var request = await CreateLeasedMoveRequestAsync(source, target, jobId); - await PersistFileManifestAsync(jobId, "book.m4b", sourceFile); - var ambiguousSource = "/" + Path.GetFullPath(source); - Assert.False(FileSystemPathIdentity.TryDetectAbsoluteSyntax( - ambiguousSource, - out _)); - await File.WriteAllTextAsync( - Path.Join(source, $".listenarr-move-{jobId:N}.pending"), - JsonSerializer.Serialize(new - { - Version = 1, - JobId = jobId, - Source = ambiguousSource, - Target = Path.GetFullPath(target), - Stage = "atomic-rename-complete" - })); - Directory.Move(source, target); - - await Assert.ThrowsAsync(() => - _provider.GetRequiredService() - .GetRecoverableMoveAsync(request)); - - Assert.True(File.Exists(Path.Join(target, "book.m4b"))); - Assert.True(File.Exists(Path.Join( - target, - $".listenarr-move-{jobId:N}.pending"))); - } - - [Fact] - public async Task GetRecoverableMoveAsync_AtomicMarkerWithPersistedManifest_Recovers() - { - var source = FileService.GetTempDirectory("content-move-atomic-manifest-src"); - var sourceFile = await FileService.GetFileAsync(source, "book.m4b", "audio"); - var target = Path.Join(FileService.GetTempPath(), $"content-move-atomic-manifest-dst-{Guid.NewGuid():N}"); - var jobId = Guid.NewGuid(); - var request = await CreateLeasedMoveRequestAsync(source, target, jobId); - await PersistFileManifestAsync(jobId, "book.m4b", sourceFile); - await WriteRecoveryMarkerAsync(source, jobId, source, target, "atomic-rename-complete"); - Directory.Move(source, target); - - var service = _provider.GetRequiredService(); - var recovered = await service.GetRecoverableMoveAsync(request); - - Assert.NotNull(recovered); - Assert.True(recovered.SourceCleanupCompleted); - Assert.True(File.Exists(Path.Join(target, "book.m4b"))); - } - - [Fact] - public async Task GetRecoverableMoveAsync_UnreadableMarker_RequiresAttention() - { - var source = FileService.GetTempDirectory("content-move-unreadable-marker-src"); - await FileService.GetFileAsync(source, "book.m4b", "audio"); - var target = FileService.GetTempDirectory("content-move-unreadable-marker-dst"); - var jobId = Guid.NewGuid(); - var request = await CreateLeasedMoveRequestAsync(source, target, jobId); - await File.WriteAllTextAsync( - Path.Join(target, $".listenarr-move-{jobId:N}.pending"), - "{ truncated"); - - var service = _provider.GetRequiredService(); - var exception = await Assert.ThrowsAsync(() => - service.GetRecoverableMoveAsync(request)); - - Assert.Contains("corrupt or truncated", exception.Message, StringComparison.OrdinalIgnoreCase); - Assert.True(File.Exists(Path.Join(source, "book.m4b"))); - } - - [FileLinkFact] - public async Task GetRecoverableMoveAsync_LinkedRecoveryMarker_PreservesExternalMarker() - { - var source = FileService.GetTempDirectory("content-move-linked-marker-src"); - await FileService.GetFileAsync(source, "book.m4b", "audio"); - var target = FileService.GetTempDirectory("content-move-linked-marker-dst"); - var external = FileService.GetTempDirectory("content-move-linked-marker-external"); - var jobId = Guid.NewGuid(); - var request = await CreateLeasedMoveRequestAsync(source, target, jobId); - var externalMarker = Path.Join(external, "marker.json"); - await File.WriteAllTextAsync( - externalMarker, - JsonSerializer.Serialize(new - { - Version = 1, - JobId = jobId, - Source = Path.GetFullPath(source), - Target = Path.GetFullPath(target), - Stage = "copy-started" - })); - var markerPath = Path.Join(target, $".listenarr-move-{jobId:N}.pending"); - try - { - File.CreateSymbolicLink(markerPath, externalMarker); - } - catch (Exception exception) when (exception is - IOException or UnauthorizedAccessException or PlatformNotSupportedException) - { - throw new Xunit.Sdk.XunitException( - $"This native filesystem regression requires symbolic-link support: {exception.Message}"); - } - - try - { - var original = await File.ReadAllTextAsync(externalMarker); - var service = _provider.GetRequiredService(); - var exception = await Assert.ThrowsAsync(() => - service.GetRecoverableMoveAsync(request)); - - Assert.Contains("symbolic link or reparse point", exception.Message, StringComparison.OrdinalIgnoreCase); - Assert.Equal(original, await File.ReadAllTextAsync(externalMarker)); - Assert.True(File.Exists(markerPath)); - Assert.True(File.Exists(Path.Join(source, "book.m4b"))); - } - finally - { - if (File.Exists(markerPath)) - { - File.Delete(markerPath); - } - } - } - - [FileLinkFact] - public async Task GetRecoverableMoveAsync_DanglingRecoveryMarkerLink_PreservesLinkAndRequiresAttention() - { - var source = FileService.GetTempDirectory("content-move-dangling-marker-src"); - await FileService.GetFileAsync(source, "book.m4b", "audio"); - var target = FileService.GetTempDirectory("content-move-dangling-marker-dst"); - var external = FileService.GetTempDirectory("content-move-dangling-marker-external"); - var jobId = Guid.NewGuid(); - var request = await CreateLeasedMoveRequestAsync(source, target, jobId); - var missingTarget = Path.Join(external, "missing-marker.json"); - var markerPath = Path.Join(target, $".listenarr-move-{jobId:N}.pending"); - try - { - File.CreateSymbolicLink(markerPath, missingTarget); - } - catch (Exception exception) when (exception is - IOException or UnauthorizedAccessException or PlatformNotSupportedException) - { - throw new Xunit.Sdk.XunitException( - $"This native filesystem regression requires symbolic-link support: {exception.Message}"); - } - - try - { - var service = _provider.GetRequiredService(); - var exception = await Assert.ThrowsAsync(() => - service.GetRecoverableMoveAsync(request)); - - Assert.Contains("symbolic link or reparse point", exception.Message, StringComparison.OrdinalIgnoreCase); - Assert.NotNull(new FileInfo(markerPath).LinkTarget); - Assert.False(File.Exists(missingTarget)); - Assert.True(File.Exists(Path.Join(source, "book.m4b"))); - } - finally - { - if (!string.IsNullOrWhiteSpace(new FileInfo(markerPath).LinkTarget)) - { - File.Delete(markerPath); - } - } - } - - [FileLinkFact] - public async Task CleanupCompletedMoveArtifactsAsync_DanglingRecoveryMarkerLink_PreservesLinkAndRequiresAttention() - { - var source = FileService.GetTempDirectory("content-move-dangling-cleanup-src"); - await FileService.GetFileAsync(source, "book.m4b", "audio"); - var target = Path.Join( - FileService.GetTempPath(), - $"content-move-dangling-cleanup-dst-{Guid.NewGuid():N}"); - var external = FileService.GetTempDirectory("content-move-dangling-cleanup-external"); - var missingTarget = Path.Join(external, "missing-marker.json"); - - var service = _provider.GetRequiredService(); - var request = await CreateLeasedMoveRequestAsync(source, target); - var result = await service.MoveContentsAsync(request, CancellationToken.None); - await service.FinalizeMoveAsync(request, result, CancellationToken.None); - File.Delete(result.RecoveryMarkerPath); - try - { - File.CreateSymbolicLink(result.RecoveryMarkerPath, missingTarget); - } - catch (Exception exception) when (exception is - IOException or UnauthorizedAccessException or PlatformNotSupportedException) - { - throw new Xunit.Sdk.XunitException( - $"This native filesystem regression requires symbolic-link support: {exception.Message}"); - } - - try - { - var exception = await Assert.ThrowsAsync(() => - service.CleanupCompletedMoveArtifactsAsync( - request, - result, - CancellationToken.None)); - - Assert.Contains( - "Linked filesystem entry blocked safe traversal", - exception.Message, - StringComparison.OrdinalIgnoreCase); - Assert.NotNull(new FileInfo(result.RecoveryMarkerPath).LinkTarget); - Assert.False(File.Exists(missingTarget)); - Assert.True(File.Exists(Path.Join(target, "book.m4b"))); - } - finally - { - if (!string.IsNullOrWhiteSpace(new FileInfo(result.RecoveryMarkerPath).LinkTarget)) - { - File.Delete(result.RecoveryMarkerPath); - } - } - } - - [DirectoryLinkFact] - public async Task GetRecoverableMoveAsync_AtomicMarkerWithLinkedTarget_RequiresAttention() - { - var source = FileService.GetTempDirectory("content-move-atomic-linked-src"); - await FileService.GetFileAsync(source, "book.m4b", "audio"); - var targetParent = FileService.GetTempDirectory("content-move-atomic-linked-parent"); - var target = Path.Join(targetParent, "linked-target"); - var externalTarget = FileService.GetTempDirectory("content-move-atomic-linked-external"); - var externalFile = await FileService.GetFileAsync(externalTarget, "book.m4b", "external audio"); - var jobId = Guid.NewGuid(); - var request = await CreateLeasedMoveRequestAsync(source, target, jobId); - await WriteRecoveryMarkerAsync(externalTarget, jobId, source, target, "atomic-rename-complete"); - Directory.Delete(source, recursive: true); - Assert.True( - TryCreateDirectoryLink(target, externalTarget), - "The required directory link could not be created."); - - try - { - var service = _provider.GetRequiredService(); - await Assert.ThrowsAsync(() => - service.GetRecoverableMoveAsync(request)); - - Assert.Equal("external audio", await File.ReadAllTextAsync(externalFile)); - } - finally - { - TryRemoveDirectoryLink(target); - } - } - - private static Task WriteRecoveryMarkerAsync( - string directory, - Guid markerJobId, - string source, - string target, - string stage, - Guid? markerFileJobId = null) - { - var fileJobId = markerFileJobId ?? markerJobId; - return File.WriteAllTextAsync( - Path.Join(directory, $".listenarr-move-{fileJobId:N}.pending"), - JsonSerializer.Serialize(new - { - Version = 1, - JobId = markerJobId, - Source = Path.GetFullPath(source), - Target = Path.GetFullPath(target), - Stage = stage - })); - } -} diff --git a/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceReservedArtifactTests.cs b/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceReservedArtifactTests.cs deleted file mode 100644 index aabad31b4..000000000 --- a/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceReservedArtifactTests.cs +++ /dev/null @@ -1,27 +0,0 @@ -namespace Listenarr.Tests.Features.Infrastructure.Library.Moving; - -public partial class AudiobookContentMoveServiceTests -{ - [Fact] - public async Task MoveContentsAsync_SourceContainsOldRecoveryMarker_PreservesAndRequiresAttention() - { - var source = FileService.GetTempDirectory("content-move-old-marker-src"); - await FileService.GetFileAsync(source, "book.m4b", "audio"); - var oldMarker = await FileService.GetFileAsync( - source, - $".listenarr-move-{Guid.NewGuid():N}.pending", - "obsolete recovery evidence"); - var target = Path.Join( - FileService.GetTempPath(), - $"content-move-old-marker-dst-{Guid.NewGuid():N}"); - var request = await CreateLeasedMoveRequestAsync(source, target); - var service = _provider.GetRequiredService(); - - var exception = await Assert.ThrowsAsync(() => - service.MoveContentsAsync(request, CancellationToken.None)); - - Assert.Contains("reserved Listenarr recovery artifact", exception.Message); - Assert.Equal("obsolete recovery evidence", await File.ReadAllTextAsync(oldMarker)); - Assert.False(Directory.Exists(target)); - } -} diff --git a/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceRootMutationSafetyTests.cs b/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceRootMutationSafetyTests.cs deleted file mode 100644 index e41353846..000000000 --- a/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceRootMutationSafetyTests.cs +++ /dev/null @@ -1,151 +0,0 @@ -using Listenarr.Tests.Common; - -namespace Listenarr.Tests.Features.Infrastructure.Library.Moving; - -public partial class AudiobookContentMoveServiceTests -{ - [DirectoryLinkFact] - public async Task MoveContentsAsync_ExistingLinkedTarget_DoesNotWriteExternalRecoveryMarker() - { - var source = FileService.GetTempDirectory("content-move-linked-target-src"); - var sourceFile = await FileService.GetFileAsync(source, "book.m4b", "audio"); - var externalTarget = FileService.GetTempDirectory("content-move-linked-target-external"); - var linkParent = FileService.GetTempDirectory("content-move-linked-target-parent"); - var targetLink = Path.Join(linkParent, "linked-target"); - Assert.True( - TryCreateDirectoryLink(targetLink, externalTarget), - "The required directory link could not be created."); - - try - { - var request = await CreateLeasedMoveRequestAsync(source, targetLink); - var service = _provider.GetRequiredService(); - - await Assert.ThrowsAsync(() => - service.MoveContentsAsync(request, CancellationToken.None)); - - Assert.True(File.Exists(sourceFile)); - Assert.Empty(Directory.EnumerateFileSystemEntries(externalTarget)); - } - finally - { - TryRemoveDirectoryLink(targetLink); - } - } - - [WindowsFact] - public async Task MoveContentsAsync_WindowsTargetJunction_DoesNotWriteExternalRecoveryMarker() - { - - var source = FileService.GetTempDirectory("content-move-target-junction-src"); - var sourceFile = await FileService.GetFileAsync(source, "book.m4b", "audio"); - var externalTarget = FileService.GetTempDirectory("content-move-target-junction-external"); - var linkParent = FileService.GetTempDirectory("content-move-target-junction-parent"); - var targetJunction = Path.Join(linkParent, "junction-target"); - Assert.True( - TryCreateWindowsJunction(targetJunction, externalTarget), - "The required Windows junction could not be created."); - - try - { - var request = await CreateLeasedMoveRequestAsync(source, targetJunction); - var service = _provider.GetRequiredService(); - - await Assert.ThrowsAsync(() => - service.MoveContentsAsync(request, CancellationToken.None)); - - Assert.True(File.Exists(sourceFile)); - Assert.Empty(Directory.EnumerateFileSystemEntries(externalTarget)); - } - finally - { - TryRemoveDirectoryLink(targetJunction); - } - } - - [DirectoryLinkFact] - public async Task MoveContentsAsync_LinkedSource_PreservesExternalOrphanMarkerWriteFile() - { - var externalSource = FileService.GetTempDirectory("content-move-orphan-linked-source-external"); - var sourceFile = await FileService.GetFileAsync(externalSource, "book.m4b", "audio"); - var linkParent = FileService.GetTempDirectory("content-move-orphan-linked-source-parent"); - var sourceLink = Path.Join(linkParent, "linked-source"); - Assert.True( - TryCreateDirectoryLink(sourceLink, externalSource), - "The required directory link could not be created."); - - try - { - var target = Path.Join(linkParent, $"target-{Guid.NewGuid():N}"); - var jobId = Guid.NewGuid(); - var request = await CreateLeasedMoveRequestAsync(sourceLink, target, jobId); - var orphanPath = Path.Join( - externalSource, - $".listenarr-move-{jobId:N}.pending.writing-{Guid.NewGuid():N}"); - await WriteRecoveryMarkerPayloadAsync(orphanPath, jobId, sourceLink, target); - var service = _provider.GetRequiredService(); - - await Assert.ThrowsAsync(() => - service.MoveContentsAsync(request, CancellationToken.None)); - - Assert.True(File.Exists(sourceFile)); - Assert.True(File.Exists(orphanPath)); - Assert.False(Directory.Exists(target)); - } - finally - { - TryRemoveDirectoryLink(sourceLink); - } - } - - [DirectoryLinkFact] - public async Task MoveContentsAsync_LinkedTarget_PreservesExternalOrphanMarkerWriteFile() - { - var source = FileService.GetTempDirectory("content-move-orphan-linked-target-src"); - var sourceFile = await FileService.GetFileAsync(source, "book.m4b", "audio"); - var externalTarget = FileService.GetTempDirectory("content-move-orphan-linked-target-external"); - var linkParent = FileService.GetTempDirectory("content-move-orphan-linked-target-parent"); - var targetLink = Path.Join(linkParent, "linked-target"); - Assert.True( - TryCreateDirectoryLink(targetLink, externalTarget), - "The required directory link could not be created."); - - try - { - var jobId = Guid.NewGuid(); - var request = await CreateLeasedMoveRequestAsync(source, targetLink, jobId); - var orphanPath = Path.Join( - externalTarget, - $".listenarr-move-{jobId:N}.pending.writing-{Guid.NewGuid():N}"); - await WriteRecoveryMarkerPayloadAsync(orphanPath, jobId, source, targetLink); - var service = _provider.GetRequiredService(); - - await Assert.ThrowsAsync(() => - service.MoveContentsAsync(request, CancellationToken.None)); - - Assert.True(File.Exists(sourceFile)); - Assert.True(File.Exists(orphanPath)); - } - finally - { - TryRemoveDirectoryLink(targetLink); - } - } - - private static Task WriteRecoveryMarkerPayloadAsync( - string path, - Guid jobId, - string source, - string target) - { - var payload = System.Text.Json.JsonSerializer.Serialize(new - { - Version = 1, - JobId = jobId, - Source = Path.GetFullPath(source), - Target = Path.GetFullPath(target), - Stage = "copy-started" - }); - return File.WriteAllTextAsync(path, payload); - } -} diff --git a/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceSourceCleanupVerificationTests.cs b/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceSourceCleanupVerificationTests.cs deleted file mode 100644 index af2b3d41b..000000000 --- a/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceSourceCleanupVerificationTests.cs +++ /dev/null @@ -1,176 +0,0 @@ -using Microsoft.EntityFrameworkCore; - -namespace Listenarr.Tests.Features.Infrastructure.Library.Moving; - -public partial class AudiobookContentMoveServiceTests -{ - [Fact] - public async Task MoveContentsAsync_SourceGenerationReplacedAfterPublication_PreservesReplacement() - { - var source = FileService.GetTempDirectory("content-move-source-cleanup-generation-race"); - var sourceFile = await FileService.GetFileAsync(source, "book.m4b", "verified audio"); - var target = Path.Join( - FileService.GetTempPath(), - $"content-move-source-cleanup-generation-target-{Guid.NewGuid():N}"); - var request = await CreateLeasedMoveRequestAsync(source, target); - string physicalIdentity; - using (var parent = PinnedDirectoryCreation.OpenPinnedHierarchyNoFollow( - source, - createMissing: false)) - using (var file = parent.OpenExistingFileForStableRead("book.m4b")) - { - physicalIdentity = file.GetObjectIdentity(); - } - request = request with - { - SourcePhysicalObjectIdentities = new Dictionary( - request.SourceSemantics.Comparer) - { - ["book.m4b"] = physicalIdentity - } - }; - var displaced = sourceFile + ".original"; - var injector = new ReplaceSourceAfterPublication( - sourceFile, - displaced, - "verified audio"); - var service = new AudiobookContentMoveService( - _provider.GetRequiredService>(), - _provider.GetRequiredService>(), - TimeProvider.System, - injector); - - await Assert.ThrowsAsync(() => - service.MoveContentsAsync(request, CancellationToken.None)); - - Assert.True(injector.ReplacementRan); - Assert.True(File.Exists(sourceFile)); - Assert.True(File.Exists(displaced)); - Assert.Equal("verified audio", await File.ReadAllTextAsync(sourceFile)); - Assert.Equal("verified audio", await File.ReadAllTextAsync(displaced)); - Assert.Equal( - "verified audio", - await File.ReadAllTextAsync(Path.Join(target, "book.m4b"))); - } - - [Fact] - public async Task GetRecoverableMoveAsync_SourceCleanupCompleteWithRecreatedOwnedFile_RequiresAttention() - { - var state = await CreateSourceCleanupCompletedStateAsync(deleteEmptySource: true); - Directory.CreateDirectory(state.Source); - await FileService.GetFileAsync(state.Source, "book.m4b", "do not delete"); - var service = _provider.GetRequiredService(); - - var exception = await Assert.ThrowsAsync(() => - service.GetRecoverableMoveAsync(state.Request)); - - Assert.Contains("owned file path", exception.Message, StringComparison.OrdinalIgnoreCase); - Assert.Equal( - "do not delete", - await File.ReadAllTextAsync(Path.Join(state.Source, "book.m4b"))); - Assert.True(File.Exists(state.MarkerPath)); - } - - [Fact] - public async Task GetRecoverableMoveAsync_SourceCleanupCompleteWithForeignFile_RemainsRecoverable() - { - var state = await CreateSourceCleanupCompletedStateAsync(deleteEmptySource: true); - Directory.CreateDirectory(state.Source); - var foreignFile = await FileService.GetFileAsync( - state.Source, - "operator-note.txt", - "preserve me"); - var service = _provider.GetRequiredService(); - - var result = await service.GetRecoverableMoveAsync(state.Request); - - Assert.NotNull(result); - Assert.True(result.SourceCleanupCompleted); - Assert.Equal("preserve me", await File.ReadAllTextAsync(foreignFile)); - Assert.True(File.Exists(state.MarkerPath)); - } - - [Fact] - public async Task GetRecoverableMoveAsync_SourceCleanupCompleteWithRecreatedEmptySource_RequiresAttention() - { - var state = await CreateSourceCleanupCompletedStateAsync(deleteEmptySource: true); - Directory.CreateDirectory(state.Source); - var service = _provider.GetRequiredService(); - - var exception = await Assert.ThrowsAsync(() => - service.GetRecoverableMoveAsync(state.Request)); - - Assert.Contains("recreated after cleanup", exception.Message, StringComparison.OrdinalIgnoreCase); - Assert.True(Directory.Exists(state.Source)); - Assert.True(File.Exists(state.MarkerPath)); - } - - [Fact] - public async Task GetRecoverableMoveAsync_SourceCleanupCompleteRetainedEmptySource_IsValidWhenConfigured() - { - var state = await CreateSourceCleanupCompletedStateAsync(deleteEmptySource: false); - Directory.CreateDirectory(state.Source); - var service = _provider.GetRequiredService(); - - var result = await service.GetRecoverableMoveAsync(state.Request); - - Assert.NotNull(result); - Assert.True(result.SourceCleanupCompleted); - Assert.True(Directory.Exists(state.Source)); - Assert.True(File.Exists(state.MarkerPath)); - } - - private async Task CreateSourceCleanupCompletedStateAsync( - bool deleteEmptySource) - { - var source = FileService.GetTempDirectory("content-move-source-cleanup-state-src"); - var sourceFile = await FileService.GetFileAsync(source, "book.m4b", "verified audio"); - var target = FileService.GetTempDirectory("content-move-source-cleanup-state-dst"); - await FileService.GetFileAsync(target, "book.m4b", "verified audio"); - var jobId = Guid.NewGuid(); - var request = await CreateLeasedMoveRequestAsync( - source, - target, - jobId, - deleteEmptySource); - await PersistFileManifestAsync(jobId, "book.m4b", sourceFile); - Directory.Delete(source, recursive: true); - await WriteRecoveryMarkerAsync( - target, - jobId, - source, - target, - "source-cleanup-complete"); - return new SourceCleanupCompletedState( - source, - target, - Path.Join(target, $".listenarr-move-{jobId:N}.pending"), - request); - } - - private sealed class ReplaceSourceAfterPublication( - string sourceFile, - string displaced, - string contents) : IMoveFaultInjector - { - public bool AllowAtomicRename => false; - - public bool ReplacementRan { get; private set; } - - public Task AfterPublishedAsync( - Guid jobId, - CancellationToken cancellationToken) - { - File.Move(sourceFile, displaced); - File.WriteAllText(sourceFile, contents); - ReplacementRan = true; - return Task.CompletedTask; - } - } - - private sealed record SourceCleanupCompletedState( - string Source, - string Target, - string MarkerPath, - AudiobookContentMoveRequest Request); -} diff --git a/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceTargetScaffoldingCreationTests.cs b/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceTargetScaffoldingCreationTests.cs deleted file mode 100644 index 5bec3ce0a..000000000 --- a/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceTargetScaffoldingCreationTests.cs +++ /dev/null @@ -1,195 +0,0 @@ -using Listenarr.Tests.Common; -using Microsoft.EntityFrameworkCore; - -namespace Listenarr.Tests.Features.Infrastructure.Library.Moving; - -public partial class AudiobookContentMoveServiceTests -{ - [DirectoryLinkFact] - public async Task MoveContentsAsync_ScaffoldParentReplacedAfterHandleOpen_DoesNotCreateOutsideBoundary() - { - var root = FileService.GetTempDirectory("content-move-scaffold-parent-race-root"); - var scaffoldParent = Path.Join(root, "library"); - var displacedParent = Path.Join(root, "library.original"); - var external = FileService.GetTempDirectory("content-move-scaffold-parent-race-external"); - var probe = Path.Join(root, "link-probe"); - Directory.CreateDirectory(scaffoldParent); - Assert.True( - TryCreateTempDirectoryLink(probe, external), - "The required directory link could not be created."); - Directory.Delete(probe); - - var source = FileService.GetTempDirectory("content-move-scaffold-parent-race-source"); - var sourceFile = await FileService.GetFileAsync(source, "book.m4b", "audio"); - var target = Path.Join(scaffoldParent, "Author", "Book"); - var request = await CreateLeasedMoveRequestAsync(source, target); - var temporaryRoot = Path.Join( - scaffoldParent, - $".listenarr-scaffold-{request.JobId:N}"); - var hookRan = false; - var temporaryRootAlreadyExisted = false; - void ReplaceParent(string path) - { - if (hookRan || !string.Equals(path, temporaryRoot, StringComparison.Ordinal)) - { - return; - } - - hookRan = true; - temporaryRootAlreadyExisted = Directory.Exists(temporaryRoot); - Directory.Move(scaffoldParent, displacedParent); - Directory.CreateSymbolicLink(scaffoldParent, external); - } - - using var hook = ExclusiveDirectoryCreator.PushBeforeCreateHook(ReplaceParent); - try - { - var service = new AudiobookContentMoveService( - _provider.GetRequiredService>(), - _provider.GetRequiredService>(), - TimeProvider.System, - new DisableAtomicRename()); - await Assert.ThrowsAnyAsync(() => - service.MoveContentsAsync(request, CancellationToken.None)); - - Assert.True(hookRan); - Assert.False(temporaryRootAlreadyExisted); - Assert.Empty(Directory.EnumerateFileSystemEntries(external)); - Assert.True(File.Exists(sourceFile)); - } - finally - { - TryDeleteTempDirectoryLink(scaffoldParent); - if (Directory.Exists(displacedParent) && !Directory.Exists(scaffoldParent)) - { - Directory.Move(displacedParent, scaffoldParent); - } - } - } - - [Fact] - public async Task MoveContentsAsync_UnownedPreparedScaffold_IsNotAdoptedOrMarked() - { - var scaffoldParent = FileService.GetTempDirectory("content-move-unowned-prepared-scaffold-root"); - var source = FileService.GetTempDirectory("content-move-unowned-prepared-scaffold-source"); - var sourceFile = await FileService.GetFileAsync(source, "book.m4b", "audio"); - var target = Path.Join(scaffoldParent, "Author", "Book"); - var request = await CreateLeasedMoveRequestAsync(source, target); - var temporaryRoot = Path.Join( - scaffoldParent, - $".listenarr-scaffold-{request.JobId:N}"); - Directory.CreateDirectory(temporaryRoot); - - var service = new AudiobookContentMoveService( - _provider.GetRequiredService>(), - _provider.GetRequiredService>(), - TimeProvider.System, - new DisableAtomicRename()); - - await Assert.ThrowsAsync(() => - service.MoveContentsAsync(request, CancellationToken.None)); - - Assert.True(Directory.Exists(temporaryRoot)); - Assert.Empty(Directory.EnumerateFileSystemEntries(temporaryRoot)); - Assert.False(File.Exists(Path.Join(temporaryRoot, ".listenarr-scaffold-owner.json"))); - Assert.True(File.Exists(sourceFile)); - Assert.False(Directory.Exists(Path.Join(scaffoldParent, "Author"))); - } - - [DirectoryLinkFact] - public async Task MoveContentsAsync_ScaffoldParentReplacedAtPublication_DoesNotPublishSubstituteTree() - { - var root = FileService.GetTempDirectory("content-move-scaffold-publication-race-root"); - var scaffoldParent = Path.Join(root, "library"); - var displacedParent = Path.Join(root, "library.original"); - var external = FileService.GetTempDirectory("content-move-scaffold-publication-race-external"); - var probe = Path.Join(root, "link-probe"); - Directory.CreateDirectory(scaffoldParent); - Assert.True( - TryCreateTempDirectoryLink(probe, external), - "The required directory link could not be created."); - Directory.Delete(probe); - - var source = FileService.GetTempDirectory("content-move-scaffold-publication-race-source"); - var sourceFile = await FileService.GetFileAsync(source, "book.m4b", "audio"); - var target = Path.Join(scaffoldParent, "Author", "Book"); - var request = await CreateLeasedMoveRequestAsync(source, target); - var temporaryName = $".listenarr-scaffold-{request.JobId:N}"; - var temporaryRoot = Path.Join(scaffoldParent, temporaryName); - var externalTemporaryRoot = Path.Join(external, temporaryName); - var externalPublishedRoot = Path.Join(external, "Author"); - var publicationRan = false; - var substitutePublished = false; - void ReplaceParentAndSubstitutePreparedTree() - { - publicationRan = true; - Directory.Move(scaffoldParent, displacedParent); - if (!TryCreateTempDirectoryJunction(scaffoldParent, external)) - { - throw new IOException("Could not create the scaffold parent replacement junction."); - } - Directory.CreateDirectory(externalTemporaryRoot); - File.Copy( - Path.Join(displacedParent, temporaryName, ".listenarr-scaffold-owner.json"), - Path.Join(externalTemporaryRoot, ".listenarr-scaffold-owner.json")); - } - - try - { - var service = new AudiobookContentMoveService( - _provider.GetRequiredService>(), - _provider.GetRequiredService>(), - TimeProvider.System, - new ReplaceScaffoldParentAtPublication( - ReplaceParentAndSubstitutePreparedTree, - () => substitutePublished = Directory.Exists(externalPublishedRoot))); - await Assert.ThrowsAnyAsync(() => - service.MoveContentsAsync(request, CancellationToken.None)); - - Assert.True(publicationRan); - Assert.False(substitutePublished); - Assert.False(Directory.Exists(externalPublishedRoot)); - Assert.True(File.Exists(sourceFile)); - Assert.False(Directory.Exists(Path.Join(external, "Author", "Book"))); - } - finally - { - TryDeleteTempDirectoryLink(scaffoldParent); - if (Directory.Exists(displacedParent) && !Directory.Exists(scaffoldParent)) - { - Directory.Move(displacedParent, scaffoldParent); - } - } - } - - private sealed class ReplaceScaffoldParentAtPublication( - Action replaceParent, - Action observePublishedSubstitute) : IMoveFaultInjector - { - private bool _replaced; - private bool _observed; - - public bool AllowAtomicRename => false; - - public void OnTargetScaffoldPreparation( - Guid jobId, - TargetScaffoldPreparationFaultPoint faultPoint) - { - if (!_replaced && faultPoint == TargetScaffoldPreparationFaultPoint.BeforePublication) - { - _replaced = true; - replaceParent(); - return; - } - - if (_observed || faultPoint != TargetScaffoldPreparationFaultPoint.AfterPublication) - { - return; - } - - _observed = true; - observePublishedSubstitute(); - throw new IOException("Stop after observing target scaffold publication."); - } - } -} diff --git a/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceTargetScaffoldingTests.cs b/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceTargetScaffoldingTests.cs index 267b928a9..a56cc3a51 100644 --- a/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceTargetScaffoldingTests.cs +++ b/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceTargetScaffoldingTests.cs @@ -1,4 +1,3 @@ -using Listenarr.Tests.Common; using Microsoft.EntityFrameworkCore; namespace Listenarr.Tests.Features.Infrastructure.Library.Moving; @@ -28,56 +27,47 @@ public async Task MoveContentsAsync_MissingNestedTargetAncestors_AreNotCopiedAsC .Where(directory => directory.MoveJobId == request.JobId) .OrderBy(directory => directory.Path) .ToListAsync(); - Assert.Equal(2, scaffolding.Count); + Assert.Equal(3, scaffolding.Count); Assert.All(scaffolding, directory => Assert.Equal(MoveCreatedDirectoryState.Created, directory.State)); } [Fact] - public async Task MoveContentsAsync_RetryAfterRemovedScaffolding_ReacquiresAndRetainsLedger() + public async Task MoveContentsAsync_TargetParentReplacedAfterAuthorization_DoesNotCreateInReplacement() { - var source = FileService.GetTempDirectory("content-move-scaffold-retry-src"); - await FileService.GetFileAsync(source, "book.m4b", "audio"); - var firstScaffold = Path.Join(source, "container"); - var secondScaffold = Path.Join(firstScaffold, "nested"); - var target = Path.Join(secondScaffold, "target"); + var source = FileService.GetTempDirectory("content-move-scaffold-parent-race-src"); + var sourceFile = await FileService.GetFileAsync(source, "book.m4b", "audio"); + var targetParent = FileService.GetTempDirectory("content-move-scaffold-parent-race-dst"); + var displacedParent = targetParent + ".original"; + var target = Path.Join(targetParent, "Book"); var request = await CreateLeasedMoveRequestAsync(source, target); - await using (var db = await _provider - .GetRequiredService>() - .CreateDbContextAsync()) + var hookRan = false; + using var hook = ExclusiveDirectoryCreator.PushBeforeCreateHook(path => { - db.MoveJobCreatedDirectories.AddRange( - new MoveJobCreatedDirectory - { - MoveJobId = request.JobId, - Path = firstScaffold, - State = MoveCreatedDirectoryState.Removed - }, - new MoveJobCreatedDirectory - { - MoveJobId = request.JobId, - Path = secondScaffold, - State = MoveCreatedDirectoryState.Removed - }); - await db.SaveChangesAsync(); - } + if (hookRan || !string.Equals(path, target, StringComparison.Ordinal)) + { + return; + } + + hookRan = true; + Directory.Move(targetParent, displacedParent); + Directory.CreateDirectory(targetParent); + File.WriteAllText(Path.Join(targetParent, "foreign.txt"), "replacement generation"); + }); var service = _provider.GetRequiredService(); - await service.MoveContentsAsync(request, CancellationToken.None); - await service.RetainTargetScaffoldingAsync(request, CancellationToken.None); + var exception = await Assert.ThrowsAsync(() => + service.MoveContentsAsync(request, CancellationToken.None)); - Assert.True(File.Exists(Path.Join(target, "book.m4b"))); - Assert.False(File.Exists(Path.Join(firstScaffold, ".listenarr-scaffold-owner.json"))); - await using var verification = await _provider - .GetRequiredService>() - .CreateDbContextAsync(); - var scaffolding = await verification.MoveJobCreatedDirectories - .AsNoTracking() - .Where(directory => directory.MoveJobId == request.JobId) - .ToListAsync(); - Assert.Equal(2, scaffolding.Count); - Assert.All(scaffolding, directory => - Assert.Equal(MoveCreatedDirectoryState.Retained, directory.State)); + Assert.Contains("parent changed", exception.Message, StringComparison.OrdinalIgnoreCase); + + Assert.True(hookRan); + Assert.True(File.Exists(sourceFile)); + Assert.False(Directory.Exists(target)); + Assert.Equal( + "replacement generation", + await File.ReadAllTextAsync(Path.Join(targetParent, "foreign.txt"))); + Assert.True(Directory.Exists(displacedParent)); } [Fact] @@ -111,612 +101,4 @@ public async Task MoveContentsAsync_PersistedScaffoldWithUnexpectedContent_Fails Assert.True(File.Exists(Path.Join(scaffold, "operator-note.txt"))); Assert.True(File.Exists(Path.Join(source, "book.m4b"))); } - - [Theory] - [InlineData(nameof(TargetScaffoldCleanupFaultPoint.BeforeQuarantineRename))] - [InlineData(nameof(TargetScaffoldCleanupFaultPoint.AfterQuarantineRename))] - [InlineData(nameof(TargetScaffoldCleanupFaultPoint.BeforeQuarantineValidation))] - [InlineData(nameof(TargetScaffoldCleanupFaultPoint.BeforeQuarantineDelete))] - [InlineData(nameof(TargetScaffoldCleanupFaultPoint.AfterQuarantineDelete))] - [InlineData(nameof(TargetScaffoldCleanupFaultPoint.BeforeRemovedStateUpdate))] - public async Task CleanupTerminalTargetScaffoldingAsync_RetryRecoversEveryMutationBoundary( - string faultPointName) - { - var faultPoint = Enum.Parse(faultPointName); - var state = await CreateEmptyTargetScaffoldAsync(); - var failingService = CreateMoveService( - new ThrowTargetScaffoldCleanupOnce(faultPoint)); - - await Assert.ThrowsAsync(() => - failingService.CleanupTerminalTargetScaffoldingAsync( - state.Request, - CancellationToken.None)); - - var recoveryService = _provider.GetRequiredService(); - await recoveryService.CleanupTerminalTargetScaffoldingAsync( - state.Request, - CancellationToken.None); - - Assert.False(Directory.Exists(state.PublishedRoot)); - Assert.False(Directory.Exists(state.Quarantine)); - await AssertScaffoldingStateAsync( - state.Request.JobId, - MoveCreatedDirectoryState.Removed); - } - - [Fact] - public async Task CleanupTerminalTargetScaffoldingAsync_ContentAddedAfterRename_IsPreserved() - { - var state = await CreateEmptyTargetScaffoldAsync(); - var unexpectedFile = Path.Join(state.Quarantine, "operator-note.txt"); - var service = CreateMoveService( - new AddUnexpectedQuarantineContentAfterRename(unexpectedFile)); - - var exception = await Assert.ThrowsAsync(() => - service.CleanupTerminalTargetScaffoldingAsync( - state.Request, - CancellationToken.None)); - - Assert.Contains("unexpected file content", exception.Message, StringComparison.OrdinalIgnoreCase); - Assert.True(Directory.Exists(state.Quarantine)); - Assert.Equal("preserve", await File.ReadAllTextAsync(unexpectedFile)); - await AssertScaffoldingNotRemovedAsync(state.Request.JobId); - } - - [Fact] - public async Task CleanupTerminalTargetScaffoldingAsync_ContentAddedBeforeRename_RetainsAndRemovesUnusedTombstone() - { - var state = await CreateEmptyTargetScaffoldAsync(); - var operatorFile = Path.Join(state.Request.Target, "operator-note.txt"); - var tombstone = Path.Join( - Path.GetDirectoryName(state.Quarantine)!, - $".listenarr-target-scaffold-quarantine-{state.Request.JobId:N}.cleanup.json"); - var service = CreateMoveService( - new AddPublishedContentBeforeQuarantineRename(operatorFile)); - - await Assert.ThrowsAsync(() => - service.CleanupTerminalTargetScaffoldingAsync( - state.Request, - CancellationToken.None)); - - Assert.True(File.Exists(tombstone)); - Assert.True(File.Exists(operatorFile)); - await _provider.GetRequiredService() - .CleanupTerminalTargetScaffoldingAsync( - state.Request, - CancellationToken.None); - - Assert.True(File.Exists(operatorFile)); - Assert.False(File.Exists(tombstone)); - await AssertScaffoldingStateAsync( - state.Request.JobId, - MoveCreatedDirectoryState.Retained); - } - - [Fact] - public async Task CleanupTerminalTargetScaffoldingAsync_PartialQuarantineDeletion_ResumesFromTombstone() - { - var state = await CreateEmptyTargetScaffoldAsync(); - var tombstone = Path.Join( - Path.GetDirectoryName(state.Quarantine)!, - $".listenarr-target-scaffold-quarantine-{state.Request.JobId:N}.cleanup.json"); - var service = CreateMoveService( - new ThrowOnTargetScaffoldFaultInvocation( - TargetScaffoldCleanupFaultPoint.DuringQuarantineDelete, - throwOnInvocation: 2)); - - await Assert.ThrowsAsync(() => - service.CleanupTerminalTargetScaffoldingAsync( - state.Request, - CancellationToken.None)); - - Assert.True(Directory.Exists(state.Quarantine)); - Assert.True(File.Exists(tombstone)); - await AssertScaffoldingNotRemovedAsync(state.Request.JobId); - - await _provider.GetRequiredService() - .CleanupTerminalTargetScaffoldingAsync( - state.Request, - CancellationToken.None); - - Assert.False(Directory.Exists(state.Quarantine)); - Assert.False(File.Exists(tombstone)); - await AssertScaffoldingStateAsync( - state.Request.JobId, - MoveCreatedDirectoryState.Removed); - } - - [Fact] - public async Task CleanupTerminalTargetScaffoldingAsync_ReplacedQuarantineGeneration_IsPreserved() - { - var state = await CreateEmptyTargetScaffoldAsync(); - var originalGeneration = state.Quarantine + $"-original-{Guid.NewGuid():N}"; - var replacementFile = Path.Join(state.Quarantine, "operator-file.txt"); - var service = CreateMoveService( - new ReplaceScaffoldRootBeforeRetirement( - state.Quarantine, - originalGeneration, - replacementFile)); - - await Assert.ThrowsAsync(() => - service.CleanupTerminalTargetScaffoldingAsync( - state.Request, - CancellationToken.None)); - - Assert.True(Directory.Exists(originalGeneration)); - Assert.Equal("preserve", await File.ReadAllTextAsync(replacementFile)); - await AssertScaffoldingNotRemovedAsync(state.Request.JobId); - } - - [Fact] - public async Task CleanupTerminalTargetScaffoldingAsync_PartialStateUpdate_ResumesRemainingRows() - { - var state = await CreateEmptyTargetScaffoldAsync(); - var failingService = CreateMoveService( - new ThrowOnTargetScaffoldFaultInvocation( - TargetScaffoldCleanupFaultPoint.BeforeRemovedStateUpdate, - throwOnInvocation: 2)); - - await Assert.ThrowsAsync(() => - failingService.CleanupTerminalTargetScaffoldingAsync( - state.Request, - CancellationToken.None)); - - Assert.False(Directory.Exists(state.Quarantine)); - await using (var db = await _provider - .GetRequiredService>() - .CreateDbContextAsync()) - { - var states = await db.MoveJobCreatedDirectories - .AsNoTracking() - .Where(directory => directory.MoveJobId == state.Request.JobId) - .Select(directory => directory.State) - .ToListAsync(); - Assert.Contains(MoveCreatedDirectoryState.Removed, states); - Assert.Contains(states, candidate => candidate is - MoveCreatedDirectoryState.Created or MoveCreatedDirectoryState.Planned); - } - - await _provider.GetRequiredService() - .CleanupTerminalTargetScaffoldingAsync( - state.Request, - CancellationToken.None); - - await AssertScaffoldingStateAsync( - state.Request.JobId, - MoveCreatedDirectoryState.Removed); - } - - [Fact] - public async Task CleanupTerminalTargetScaffoldingAsync_PartialCleanupIntentPersistence_RecoversBeforeRename() - { - var state = await CreateEmptyTargetScaffoldAsync(); - await SetScaffoldingStateAsync( - state.Request.JobId, - MoveCreatedDirectoryState.Retained); - var failingService = CreateMoveService( - new ThrowOnTargetScaffoldFaultInvocation( - TargetScaffoldCleanupFaultPoint.BeforeCleanupIntentStateUpdate, - throwOnInvocation: 2)); - - await Assert.ThrowsAsync(() => - failingService.CleanupTerminalTargetScaffoldingAsync( - state.Request, - CancellationToken.None)); - - Assert.True(Directory.Exists(state.PublishedRoot)); - Assert.False(Directory.Exists(state.Quarantine)); - await _provider.GetRequiredService() - .CleanupTerminalTargetScaffoldingAsync( - state.Request, - CancellationToken.None); - - Assert.False(Directory.Exists(state.PublishedRoot)); - Assert.False(Directory.Exists(state.Quarantine)); - await AssertScaffoldingStateAsync( - state.Request.JobId, - MoveCreatedDirectoryState.Removed); - } - - [Fact] - public async Task CleanupTerminalTargetScaffoldingAsync_RetainedRowsAreNormalizedBeforeQuarantineDeletion() - { - var state = await CreateQuarantinedTargetScaffoldAsync(); - await SetScaffoldingStateAsync( - state.Request.JobId, - MoveCreatedDirectoryState.Retained); - var failingService = CreateMoveService( - new ThrowOnTargetScaffoldFaultInvocation( - TargetScaffoldCleanupFaultPoint.BeforeRemovedStateUpdate, - throwOnInvocation: 1)); - - await Assert.ThrowsAsync(() => - failingService.CleanupTerminalTargetScaffoldingAsync( - state.Request, - CancellationToken.None)); - - Assert.False(Directory.Exists(state.PublishedRoot)); - Assert.False(Directory.Exists(state.Quarantine)); - await AssertScaffoldingStateAsync( - state.Request.JobId, - MoveCreatedDirectoryState.Created); - await _provider.GetRequiredService() - .CleanupTerminalTargetScaffoldingAsync( - state.Request, - CancellationToken.None); - await AssertScaffoldingStateAsync( - state.Request.JobId, - MoveCreatedDirectoryState.Removed); - } - - [Fact] - public async Task CleanupTerminalTargetScaffoldingAsync_RecreatedPublishedRoot_PreservesBothArtifacts() - { - var state = await CreateQuarantinedTargetScaffoldAsync(); - Directory.CreateDirectory(state.PublishedRoot); - await File.WriteAllTextAsync( - Path.Join(state.PublishedRoot, "operator-file.txt"), - "preserve"); - - var exception = await Assert.ThrowsAsync(() => - _provider.GetRequiredService() - .CleanupTerminalTargetScaffoldingAsync( - state.Request, - CancellationToken.None)); - - Assert.Contains("both", exception.Message, StringComparison.OrdinalIgnoreCase); - Assert.True(Directory.Exists(state.Quarantine)); - Assert.True(File.Exists(Path.Join(state.PublishedRoot, "operator-file.txt"))); - await AssertScaffoldingNotRemovedAsync(state.Request.JobId); - } - - [Theory] - [InlineData("missing")] - [InlineData("foreign")] - public async Task CleanupTerminalTargetScaffoldingAsync_InvalidQuarantineMarker_PreservesArtifact( - string markerState) - { - var state = await CreateQuarantinedTargetScaffoldAsync(); - var markerPath = Path.Join(state.Quarantine, ".listenarr-scaffold-owner.json"); - if (markerState == "missing") - { - File.Delete(markerPath); - } - else - { - await File.WriteAllTextAsync( - markerPath, - System.Text.Json.JsonSerializer.Serialize(new - { - Version = 1, - JobId = Guid.NewGuid(), - TargetPath = state.Request.Target, - PublishedRoot = state.PublishedRoot - })); - } - - await Assert.ThrowsAsync(() => - _provider.GetRequiredService() - .CleanupTerminalTargetScaffoldingAsync( - state.Request, - CancellationToken.None)); - - Assert.True(Directory.Exists(state.Quarantine)); - await AssertScaffoldingNotRemovedAsync(state.Request.JobId); - } - - [Fact] - public async Task CleanupTerminalTargetScaffoldingAsync_MarkerReplacedBeforePinnedRead_PreservesArtifact() - { - var state = await CreateQuarantinedTargetScaffoldAsync(); - var markerPath = Path.Join(state.Quarantine, ".listenarr-scaffold-owner.json"); - var replacement = System.Text.Json.JsonSerializer.Serialize(new - { - Version = 1, - JobId = Guid.NewGuid(), - TargetPath = state.Request.Target, - PublishedRoot = state.PublishedRoot - }); - var replaced = false; - using var hook = ExclusiveDirectoryCreator.PushBeforeOpenParentHook(path => - { - if (replaced - || !string.Equals( - Path.GetFullPath(path), - Path.GetFullPath(state.Quarantine), - StringComparison.OrdinalIgnoreCase)) - { - return; - } - - replaced = true; - File.Delete(markerPath); - File.WriteAllText(markerPath, replacement); - }); - - await Assert.ThrowsAsync(() => - _provider.GetRequiredService() - .CleanupTerminalTargetScaffoldingAsync( - state.Request, - CancellationToken.None)); - - Assert.True(replaced); - Assert.Equal(replacement, await File.ReadAllTextAsync(markerPath)); - Assert.True(Directory.Exists(state.Quarantine)); - await AssertScaffoldingNotRemovedAsync(state.Request.JobId); - } - - [LinuxFact] - public async Task CleanupTerminalTargetScaffoldingAsync_AmbiguousPersistedMarkerPath_PreservesArtifact() - { - var state = await CreateQuarantinedTargetScaffoldAsync(); - var markerPath = Path.Join(state.Quarantine, ".listenarr-scaffold-owner.json"); - var ambiguousTarget = "/" + Path.GetFullPath(state.Request.Target); - Assert.False(FileSystemPathIdentity.TryDetectAbsoluteSyntax( - ambiguousTarget, - out _)); - await File.WriteAllTextAsync( - markerPath, - System.Text.Json.JsonSerializer.Serialize(new - { - Version = 1, - JobId = state.Request.JobId, - TargetPath = ambiguousTarget, - PublishedRoot = Path.GetFullPath(state.PublishedRoot) - })); - - await Assert.ThrowsAsync(() => - _provider.GetRequiredService() - .CleanupTerminalTargetScaffoldingAsync( - state.Request, - CancellationToken.None)); - - Assert.True(Directory.Exists(state.Quarantine)); - Assert.True(File.Exists(markerPath)); - await AssertScaffoldingNotRemovedAsync(state.Request.JobId); - } - - [DirectoryLinkFact] - public async Task CleanupTerminalTargetScaffoldingAsync_DanglingQuarantineLink_IsNotTreatedAsRemoved() - { - var state = await CreateQuarantinedTargetScaffoldAsync(); - Directory.Delete(state.Quarantine, recursive: true); - var external = FileService.GetTempDirectory("content-move-scaffold-dangling-link"); - Assert.True( - TryCreateDirectoryLink(state.Quarantine, external), - "The required directory link could not be created."); - Directory.Delete(external, recursive: true); - - try - { - await Assert.ThrowsAsync(() => - _provider.GetRequiredService() - .CleanupTerminalTargetScaffoldingAsync( - state.Request, - CancellationToken.None)); - - await AssertScaffoldingNotRemovedAsync(state.Request.JobId); - } - finally - { - TryRemoveDirectoryLink(state.Quarantine); - } - } - - [DirectoryLinkFact] - public async Task CleanupTerminalTargetScaffoldingAsync_LinkInsideQuarantine_PreservesExternalTree() - { - var state = await CreateQuarantinedTargetScaffoldAsync(); - var external = FileService.GetTempDirectory("content-move-scaffold-link-external"); - var externalFile = await FileService.GetFileAsync(external, "keep.txt", "preserve"); - var link = Path.Join(state.Quarantine, "linked"); - Assert.True( - TryCreateDirectoryLink(link, external), - "The required directory link could not be created."); - - try - { - await Assert.ThrowsAsync(() => - _provider.GetRequiredService() - .CleanupTerminalTargetScaffoldingAsync( - state.Request, - CancellationToken.None)); - - Assert.True(Directory.Exists(state.Quarantine)); - Assert.True(File.Exists(externalFile)); - await AssertScaffoldingNotRemovedAsync(state.Request.JobId); - } - finally - { - TryRemoveDirectoryLink(link); - } - } - - private async Task CreateEmptyTargetScaffoldAsync() - { - var source = FileService.GetTempDirectory("content-move-scaffold-cleanup-src"); - await FileService.GetFileAsync(source, "book.m4b", "audio"); - var publishedRoot = Path.Join(source, "container"); - var target = Path.Join(publishedRoot, "nested", "target"); - var request = await CreateLeasedMoveRequestAsync(source, target); - await _provider.GetRequiredService() - .MoveContentsAsync(request, CancellationToken.None); - Directory.Delete(target, recursive: true); - var quarantine = Path.Join( - source, - $".listenarr-scaffold-cleanup-{request.JobId:N}"); - return new TargetScaffoldCleanupState( - request, - publishedRoot, - quarantine); - } - - private async Task CreateQuarantinedTargetScaffoldAsync() - { - var state = await CreateEmptyTargetScaffoldAsync(); - var service = CreateMoveService( - new ThrowTargetScaffoldCleanupOnce( - TargetScaffoldCleanupFaultPoint.AfterQuarantineRename)); - await Assert.ThrowsAsync(() => - service.CleanupTerminalTargetScaffoldingAsync( - state.Request, - CancellationToken.None)); - Assert.False(Directory.Exists(state.PublishedRoot)); - Assert.True(Directory.Exists(state.Quarantine)); - return state; - } - - private async Task SetScaffoldingStateAsync( - Guid jobId, - MoveCreatedDirectoryState state) - { - await using var db = await _provider - .GetRequiredService>() - .CreateDbContextAsync(); - var directories = await db.MoveJobCreatedDirectories - .Where(directory => directory.MoveJobId == jobId) - .ToListAsync(); - Assert.NotEmpty(directories); - foreach (var directory in directories) - { - directory.State = state; - } - await db.SaveChangesAsync(); - } - - private async Task AssertScaffoldingStateAsync( - Guid jobId, - MoveCreatedDirectoryState expected) - { - await using var db = await _provider - .GetRequiredService>() - .CreateDbContextAsync(); - var states = await db.MoveJobCreatedDirectories - .AsNoTracking() - .Where(directory => directory.MoveJobId == jobId) - .Select(directory => directory.State) - .ToListAsync(); - Assert.NotEmpty(states); - Assert.All(states, state => Assert.Equal(expected, state)); - } - - private async Task AssertScaffoldingNotRemovedAsync(Guid jobId) - { - await using var db = await _provider - .GetRequiredService>() - .CreateDbContextAsync(); - var states = await db.MoveJobCreatedDirectories - .AsNoTracking() - .Where(directory => directory.MoveJobId == jobId) - .Select(directory => directory.State) - .ToListAsync(); - Assert.NotEmpty(states); - Assert.DoesNotContain(MoveCreatedDirectoryState.Removed, states); - } - - private sealed record TargetScaffoldCleanupState( - AudiobookContentMoveRequest Request, - string PublishedRoot, - string Quarantine); - - private sealed class AddPublishedContentBeforeQuarantineRename( - string operatorFile) : IMoveFaultInjector - { - public void OnTargetScaffoldCleanup( - Guid jobId, - TargetScaffoldCleanupFaultPoint faultPoint) - { - if (faultPoint != TargetScaffoldCleanupFaultPoint.BeforeQuarantineRename) - { - return; - } - - Directory.CreateDirectory(Path.GetDirectoryName(operatorFile)!); - File.WriteAllText(operatorFile, "preserve"); - } - } - - private sealed class AddUnexpectedQuarantineContentAfterRename( - string unexpectedFile) : IMoveFaultInjector - { - public void OnTargetScaffoldCleanup( - Guid jobId, - TargetScaffoldCleanupFaultPoint faultPoint) - { - if (faultPoint == TargetScaffoldCleanupFaultPoint.AfterQuarantineRename) - { - File.WriteAllText(unexpectedFile, "preserve"); - } - } - } - - private sealed class ThrowTargetScaffoldCleanupOnce( - TargetScaffoldCleanupFaultPoint expected) : IMoveFaultInjector - { - private bool _thrown; - - public void OnTargetScaffoldCleanup( - Guid jobId, - TargetScaffoldCleanupFaultPoint faultPoint) - { - if (_thrown || faultPoint != expected) - { - return; - } - - _thrown = true; - throw new IOException($"Injected target scaffold cleanup failure at {faultPoint}."); - } - } - - private sealed class ThrowOnTargetScaffoldFaultInvocation( - TargetScaffoldCleanupFaultPoint expected, - int throwOnInvocation) : IMoveFaultInjector - { - private int _invocations; - - public void OnTargetScaffoldCleanup( - Guid jobId, - TargetScaffoldCleanupFaultPoint faultPoint) - { - if (faultPoint != expected) - { - return; - } - - _invocations++; - if (_invocations == throwOnInvocation) - { - throw new IOException("Injected partial target scaffold state update failure."); - } - } - } - - private sealed class ReplaceScaffoldRootBeforeRetirement( - string quarantinePath, - string originalGeneration, - string replacementFile) : IMoveFaultInjector - { - private int _invocations; - - public void OnTargetScaffoldCleanup( - Guid jobId, - TargetScaffoldCleanupFaultPoint faultPoint) - { - if (faultPoint != TargetScaffoldCleanupFaultPoint.DuringQuarantineDelete) - { - return; - } - - _invocations++; - if (_invocations != 3) - { - return; - } - - Directory.Move(quarantinePath, originalGeneration); - Directory.CreateDirectory(quarantinePath); - File.WriteAllText(replacementFile, "preserve"); - } - } } diff --git a/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceTempDirectoryCreationTests.cs b/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceTempDirectoryCreationTests.cs deleted file mode 100644 index a9e268553..000000000 --- a/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceTempDirectoryCreationTests.cs +++ /dev/null @@ -1,309 +0,0 @@ -using Listenarr.Tests.Common; -using Microsoft.EntityFrameworkCore; - -namespace Listenarr.Tests.Features.Infrastructure.Library.Moving; - -public partial class AudiobookContentMoveServiceTests -{ - [DirectoryLinkFact] - public async Task MoveContentsAsync_TempParentReplacedAfterHandleOpen_DoesNotCreateOutsideBoundary() - { - var root = FileService.GetTempDirectory("content-move-temp-parent-race-root"); - var targetParent = Path.Join(root, "destination-parent"); - var displacedParent = Path.Join(root, "destination-parent.original"); - var external = FileService.GetTempDirectory("content-move-temp-parent-race-external"); - var probe = Path.Join(root, "link-probe"); - Directory.CreateDirectory(targetParent); - Assert.True( - TryCreateTempDirectoryLink(probe, external), - "The required directory link could not be created."); - Directory.Delete(probe); - - var source = FileService.GetTempDirectory("content-move-temp-parent-race-source"); - var sourceFile = await FileService.GetFileAsync(source, "book.m4b", "audio"); - var target = Path.Join(targetParent, "Book"); - var request = await CreateLeasedMoveRequestAsync(source, target); - var tempDirectory = Path.Join( - targetParent, - Path.GetFileName(target) + ".tmp-" + request.JobId.ToString("N")); - var hookRan = false; - var tempAlreadyExistedAtHook = false; - void ReplaceParent(string path) - { - if (hookRan || !string.Equals(path, tempDirectory, StringComparison.Ordinal)) - { - return; - } - - hookRan = true; - tempAlreadyExistedAtHook = Directory.Exists(tempDirectory); - Directory.Move(targetParent, displacedParent); - Directory.CreateSymbolicLink(targetParent, external); - } - - using var hook = ExclusiveDirectoryCreator.PushBeforeCreateHook(ReplaceParent); - try - { - var service = new AudiobookContentMoveService( - _provider.GetRequiredService>(), - _provider.GetRequiredService>(), - TimeProvider.System, - new DisableAtomicRename()); - await Assert.ThrowsAnyAsync(() => - service.MoveContentsAsync(request, CancellationToken.None)); - - Assert.True(hookRan); - Assert.False(tempAlreadyExistedAtHook); - Assert.True(File.Exists(sourceFile)); - Assert.Empty(Directory.EnumerateFileSystemEntries(external)); - Assert.False(Directory.Exists(Path.Join(external, Path.GetFileName(target)))); - Assert.False(Directory.Exists(Path.Join( - external, - Path.GetFileName(tempDirectory)))); - } - finally - { - TryDeleteTempDirectoryLink(targetParent); - if (Directory.Exists(displacedParent) && !Directory.Exists(targetParent)) - { - Directory.Move(displacedParent, targetParent); - } - } - } - - [DirectoryLinkFact] - public async Task MoveContentsAsync_TempParentReplacedAtPublication_DoesNotPublishSubstituteTree() - { - var root = FileService.GetTempDirectory("content-move-temp-publication-race-root"); - var targetParent = Path.Join(root, "destination-parent"); - var displacedParent = Path.Join(root, "destination-parent.original"); - var external = FileService.GetTempDirectory("content-move-temp-publication-race-external"); - var probe = Path.Join(root, "link-probe"); - Directory.CreateDirectory(targetParent); - Assert.True( - TryCreateTempDirectoryLink(probe, external), - "The required directory link could not be created."); - Directory.Delete(probe); - - var source = FileService.GetTempDirectory("content-move-temp-publication-race-source"); - var sourceFile = await FileService.GetFileAsync(source, "book.m4b", "audio"); - var target = Path.Join(targetParent, "Book"); - var request = await CreateLeasedMoveRequestAsync(source, target); - var tempName = Path.GetFileName(target) + ".tmp-" + request.JobId.ToString("N"); - var externalTemp = Path.Join(external, tempName); - var externalTarget = Path.Join(external, Path.GetFileName(target)); - var publicationRan = false; - void ReplaceParentAndSubstituteTemp() - { - publicationRan = true; - Directory.Move(targetParent, displacedParent); - var originalTemp = Path.Join(displacedParent, tempName); - Directory.CreateDirectory(externalTemp); - foreach (var file in Directory.EnumerateFiles(originalTemp)) - { - File.Copy(file, Path.Join(externalTemp, Path.GetFileName(file))); - } - - if (OperatingSystem.IsWindows()) - { - if (!TryCreateTempDirectoryJunction(targetParent, external)) - { - throw new IOException("The target parent replacement junction could not be created."); - } - } - else - { - Directory.CreateSymbolicLink(targetParent, external); - } - } - - try - { - var service = new AudiobookContentMoveService( - _provider.GetRequiredService>(), - _provider.GetRequiredService>(), - TimeProvider.System, - new ReplaceTempParentAtPublication(ReplaceParentAndSubstituteTemp)); - await Assert.ThrowsAnyAsync(() => - service.MoveContentsAsync(request, CancellationToken.None)); - - Assert.True(publicationRan); - Assert.False(Directory.Exists(externalTarget)); - Assert.True(File.Exists(sourceFile)); - } - finally - { - TryDeleteTempDirectoryLink(targetParent); - if (Directory.Exists(displacedParent) && !Directory.Exists(targetParent)) - { - Directory.Move(displacedParent, targetParent); - } - } - } - - [DirectoryLinkFact] - public async Task MoveContentsAsync_TempParentReplacedBeforeMarkerCreation_DoesNotWriteOutsideBoundary() - { - var root = FileService.GetTempDirectory("content-move-temp-marker-parent-race-root"); - var targetParent = Path.Join(root, "destination-parent"); - var displacedParent = Path.Join(root, "destination-parent.original"); - var external = FileService.GetTempDirectory("content-move-temp-marker-parent-race-external"); - var probe = Path.Join(root, "link-probe"); - Directory.CreateDirectory(targetParent); - Assert.True( - TryCreateTempDirectoryLink(probe, external), - "The required directory link could not be created."); - Directory.Delete(probe); - - var source = FileService.GetTempDirectory("content-move-temp-marker-parent-race-source"); - var sourceFile = await FileService.GetFileAsync(source, "book.m4b", "audio"); - var target = Path.Join(targetParent, "Book"); - var request = await CreateLeasedMoveRequestAsync(source, target); - var tempDirectory = Path.Join( - targetParent, - Path.GetFileName(target) + ".tmp-" + request.JobId.ToString("N")); - var replacementRan = false; - var tempExistedBeforeReplacement = false; - void ReplaceParent() - { - replacementRan = true; - tempExistedBeforeReplacement = Directory.Exists(tempDirectory); - Directory.Move(targetParent, displacedParent); - Directory.CreateSymbolicLink(targetParent, external); - } - - try - { - var service = new AudiobookContentMoveService( - _provider.GetRequiredService>(), - _provider.GetRequiredService>(), - TimeProvider.System, - new ReplaceTempParentBeforeMarkerCreation(ReplaceParent)); - await Assert.ThrowsAnyAsync(() => - service.MoveContentsAsync(request, CancellationToken.None)); - - Assert.True(replacementRan); - Assert.True(tempExistedBeforeReplacement); - Assert.True(File.Exists(sourceFile)); - Assert.Empty(Directory.EnumerateFileSystemEntries(external)); - Assert.False(File.Exists(Path.Join( - external, - Path.GetFileName(tempDirectory), - ".listenarr-temp-owner.json"))); - } - finally - { - TryDeleteTempDirectoryLink(targetParent); - if (Directory.Exists(displacedParent) && !Directory.Exists(targetParent)) - { - Directory.Move(displacedParent, targetParent); - } - } - } - - private sealed class DisableAtomicRename : IMoveFaultInjector - { - public bool AllowAtomicRename => false; - } - - private sealed class ReplaceTempParentAtPublication(Action replaceParent) - : IMoveFaultInjector - { - private bool _replaced; - - public bool AllowAtomicRename => false; - - public void OnTempPublication( - Guid jobId, - TempPublicationFaultPoint faultPoint) - { - if (_replaced || faultPoint != TempPublicationFaultPoint.BeforePublication) - { - return; - } - - _replaced = true; - replaceParent(); - } - } - - private sealed class ReplaceTempParentBeforeMarkerCreation(Action replaceParent) - : IMoveFaultInjector - { - private bool _replaced; - - public bool AllowAtomicRename => false; - - public void OnOwnershipMarkerWrite( - Guid jobId, - OwnershipMarkerKind markerKind, - OwnershipMarkerWriteFaultPoint faultPoint) - { - if (_replaced - || markerKind != OwnershipMarkerKind.TemporaryDirectory - || faultPoint != OwnershipMarkerWriteFaultPoint.BeforeTemporaryFileCreation) - { - return; - } - - _replaced = true; - replaceParent(); - } - } - - private static bool TryCreateTempDirectoryLink(string linkPath, string targetPath) - { - try - { - Directory.CreateSymbolicLink(linkPath, targetPath); - return true; - } - catch (Exception exception) when (exception is - IOException or UnauthorizedAccessException or PlatformNotSupportedException) - { - return OperatingSystem.IsWindows() - && TryCreateTempDirectoryJunction(linkPath, targetPath); - } - } - - private static bool TryCreateTempDirectoryJunction(string linkPath, string targetPath) - { - try - { - using var process = System.Diagnostics.Process.Start( - new System.Diagnostics.ProcessStartInfo - { - FileName = "cmd.exe", - Arguments = $"/d /c mklink /J \"{linkPath}\" \"{targetPath}\"", - CreateNoWindow = true, - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true - }); - process?.WaitForExit(); - return process?.ExitCode == 0 && Directory.Exists(linkPath); - } - catch (Exception exception) when (exception is - IOException or UnauthorizedAccessException) - { - return false; - } - } - - private static void TryDeleteTempDirectoryLink(string path) - { - try - { - if (Directory.Exists(path) - && (File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0) - { - Directory.Delete(path); - } - } - catch (Exception exception) when (exception is - IOException or UnauthorizedAccessException) - { - // Best effort test cleanup. BaseTests removes the temporary roots. - } - } -} diff --git a/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceTests.cs b/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceTests.cs index 2b6a076a2..20793bc42 100644 --- a/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceTests.cs +++ b/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceTests.cs @@ -405,58 +405,6 @@ await Assert.ThrowsAsync(() => service.MoveContentsAsync Assert.False(Directory.Exists(target)); } - [Fact] - public async Task MoveContentsAsync_UnmarkedJobShapedTempDirectory_IsPreservedAndRequiresAttention() - { - var source = FileService.GetTempDirectory("content-move-partial-src"); - var sourceFile = await FileService.GetFileAsync(source, "book.m4b", "complete audio"); - var target = Path.Join(FileService.GetTempPath(), $"content-move-partial-dst-{Guid.NewGuid():N}"); - var jobId = Guid.NewGuid(); - var targetParent = Path.GetDirectoryName(target)!; - var tempName = Path.Join(targetParent, Path.GetFileName(target) + ".tmp-" + jobId.ToString("N")); - Directory.CreateDirectory(tempName); - var unrelatedFile = Path.Join(tempName, "book.m4b"); - await File.WriteAllTextAsync(unrelatedFile, "unrelated bytes"); - - var request = await CreateLeasedMoveRequestAsync(source, target, jobId); - var service = _provider.GetRequiredService(); - var exception = await Assert.ThrowsAsync(() => - service.MoveContentsAsync(request, CancellationToken.None)); - - Assert.Contains("ownership marker", exception.Message, StringComparison.OrdinalIgnoreCase); - Assert.True(File.Exists(sourceFile)); - Assert.Equal("complete audio", await File.ReadAllTextAsync(sourceFile)); - Assert.True(Directory.Exists(tempName)); - Assert.Equal("unrelated bytes", await File.ReadAllTextAsync(unrelatedFile)); - Assert.False(Directory.Exists(target)); - } - - [Fact] - public async Task MoveContentsAsync_DirectCopyMarkerWithoutManifest_BlocksRecovery() - { - var source = FileService.GetTempDirectory("content-move-direct-retry-src"); - await FileService.GetFileAsync(source, "book.m4b", "complete audio"); - var target = FileService.GetTempDirectory("content-move-direct-retry-dst"); - await FileService.GetFileAsync(target, "book.m4b", "partial"); - var jobId = Guid.NewGuid(); - await WriteRecoveryMarkerAsync( - target, - jobId, - source, - target, - "copy-started"); - - var service = _provider.GetRequiredService(); - var request = await CreateLeasedMoveRequestAsync(source, target, jobId); - await ClearPersistedManifestAsync(jobId); - var exception = await Assert.ThrowsAsync(() => - service.MoveContentsAsync(request, CancellationToken.None)); - - Assert.Contains("without a persisted tracked-file manifest", exception.Message); - Assert.True(Directory.Exists(source)); - Assert.Equal("partial", await File.ReadAllTextAsync(Path.Join(target, "book.m4b"))); - } - [Fact] public async Task MoveContentsAsync_TargetInsideSource_MovesContentsIntoChildAndKeepsTarget() { @@ -540,7 +488,7 @@ public async Task MoveContentsAsync_SourceInsideTarget_WithUnrelatedSibling_Fail var ex = await Assert.ThrowsAsync(() => service.MoveContentsAsync(request, CancellationToken.None)); - Assert.Contains("unrelated content", ex.Message); + Assert.Contains("unowned directory", ex.Message, StringComparison.OrdinalIgnoreCase); Assert.True(File.Exists(Path.Join(source, "book.m4b"))); Assert.True(File.Exists(Path.Join(sibling, "other.m4b"))); } @@ -640,13 +588,11 @@ public async Task FinalizeMove_SourceEqualsCleanupBoundary_PreservesBoundaryDire await service.FinalizeMoveAsync(request, result, CancellationToken.None); Assert.True(Directory.Exists(source)); - Assert.True(File.Exists(result.RecoveryMarkerPath)); await service.CleanupCompletedMoveArtifactsAsync(request, result, CancellationToken.None); - Assert.False(File.Exists(result.RecoveryMarkerPath)); Assert.True(File.Exists(Path.Join(target, "book.m4b"))); } -[Fact] + [Fact] public async Task FinalizeMove_ExistingEmptyTarget_PrunesSourceParentAfterNestedQuarantineCleanup() { var sourceRoot = FileService.GetTempDirectory("content-move-existing-target-root"); @@ -670,14 +616,11 @@ public async Task FinalizeMove_ExistingEmptyTarget_PrunesSourceParentAfterNested Assert.False(Directory.Exists(source)); Assert.True(Directory.Exists(oldTitle)); Assert.Empty(Directory.EnumerateFileSystemEntries(oldTitle)); - Assert.True(File.Exists(result.RecoveryMarkerPath)); await service.FinalizeMoveAsync(request, result, CancellationToken.None); Assert.False(Directory.Exists(oldTitle)); - Assert.True(File.Exists(result.RecoveryMarkerPath)); await service.CleanupCompletedMoveArtifactsAsync(request, result, CancellationToken.None); - Assert.False(File.Exists(result.RecoveryMarkerPath)); Assert.True(File.Exists(Path.Join(target, "Disc 01", "book.m4b"))); Assert.True(Directory.Exists(sourceRoot)); } @@ -764,11 +707,9 @@ public async Task FinalizeMove_MissingCleanupBoundary_PreservesUnownedParentsAnd await service.FinalizeMoveAsync(request, result, CancellationToken.None); - Assert.True(File.Exists(result.RecoveryMarkerPath)); Assert.True(Directory.Exists(oldTitle)); Assert.False(Directory.Exists(source)); await service.CleanupCompletedMoveArtifactsAsync(request, result, CancellationToken.None); - Assert.False(File.Exists(result.RecoveryMarkerPath)); } [Fact] @@ -805,9 +746,7 @@ public async Task FinalizeMove_RetryAfterImmediateParentRemoved_PrunesHigherEmpt Assert.False(Directory.Exists(author)); Assert.True(Directory.Exists(sourceRoot)); - Assert.True(File.Exists(result.RecoveryMarkerPath)); await service.CleanupCompletedMoveArtifactsAsync(request, result, CancellationToken.None); - Assert.False(File.Exists(result.RecoveryMarkerPath)); } [Fact] @@ -850,86 +789,6 @@ public async Task FinalizeMove_LiveRemovingEmptyPath_CompletesWithoutMarkerProof Assert.Null(persisted.PathOwnershipKey); } - [Fact] - public async Task FinalizeMove_RetryAfterOwnedParentQuarantined_ResumesDeletion() - { - var sourceRoot = FileService.GetTempDirectory("content-move-finalize-quarantine-retry-root"); - var oldTitle = Path.Join(sourceRoot, "Author", "Old Title"); - var source = Path.Join(oldTitle, "test"); - Directory.CreateDirectory(source); - await FileService.GetFileAsync(source, "book.m4b", "audio"); - await ClaimOwnedDirectoriesAsync(oldTitle); - var target = Path.Join( - FileService.GetTempPath(), - $"content-move-finalize-quarantine-retry-dst-{Guid.NewGuid():N}"); - - var service = _provider.GetRequiredService(); - var request = await CreateLeasedMoveRequestAsync( - source, - target, - sourceCleanupBoundary: sourceRoot); - var result = await service.MoveContentsAsync(request, CancellationToken.None); - var ownershipStore = _provider.GetRequiredService(); - var resolution = await ownershipStore.ResolveOwnedAsync( - oldTitle, - FileSystemPathSemantics.CurrentHostDefault); - var ownership = Assert.IsType(resolution.Ownership); - var ownershipKey = Assert.IsType(ownership.PathOwnershipKey); - await ownershipStore.BeginRemovalAsync(ownership.Id, ownershipKey); - var quarantinePath = LibraryDirectoryOwnershipRemoval.GetQuarantinePath(ownership); - Directory.Move(oldTitle, quarantinePath); - - await service.FinalizeMoveAsync(request, result, CancellationToken.None); - - Assert.False(Directory.Exists(oldTitle)); - Assert.False(Directory.Exists(quarantinePath)); - var factory = _provider.GetRequiredService>(); - await using var db = await factory.CreateDbContextAsync(); - var persisted = await db.LibraryDirectoryOwnerships.SingleAsync( - candidate => candidate.Id == ownership.Id); - Assert.Equal(LibraryDirectoryOwnershipState.Removed, persisted.State); - Assert.Null(persisted.PathOwnershipKey); - } - - [Fact] - public async Task FinalizeMove_RecreatedOriginalBesideQuarantineRequiresAttention() - { - var sourceRoot = FileService.GetTempDirectory("content-move-finalize-quarantine-recreated-root"); - var oldTitle = Path.Join(sourceRoot, "Author", "Old Title"); - var source = Path.Join(oldTitle, "test"); - Directory.CreateDirectory(source); - await FileService.GetFileAsync(source, "book.m4b", "audio"); - await ClaimOwnedDirectoriesAsync(oldTitle); - var target = Path.Join( - FileService.GetTempPath(), - $"content-move-finalize-quarantine-recreated-dst-{Guid.NewGuid():N}"); - - var service = _provider.GetRequiredService(); - var request = await CreateLeasedMoveRequestAsync( - source, - target, - sourceCleanupBoundary: sourceRoot); - var result = await service.MoveContentsAsync(request, CancellationToken.None); - var ownershipStore = _provider.GetRequiredService(); - var resolution = await ownershipStore.ResolveOwnedAsync( - oldTitle, - FileSystemPathSemantics.CurrentHostDefault); - var ownership = Assert.IsType(resolution.Ownership); - var ownershipKey = Assert.IsType(ownership.PathOwnershipKey); - await ownershipStore.BeginRemovalAsync(ownership.Id, ownershipKey); - var quarantinePath = LibraryDirectoryOwnershipRemoval.GetQuarantinePath(ownership); - Directory.Move(oldTitle, quarantinePath); - Directory.CreateDirectory(oldTitle); - await File.WriteAllTextAsync(Path.Join(oldTitle, "user-content.txt"), "keep"); - - var exception = await Assert.ThrowsAsync(() => - service.FinalizeMoveAsync(request, result, CancellationToken.None)); - - Assert.Contains("both the owned directory", exception.Message, StringComparison.OrdinalIgnoreCase); - Assert.True(File.Exists(Path.Join(oldTitle, "user-content.txt"))); - Assert.True(Directory.Exists(quarantinePath)); - } - [Fact] public async Task FinalizeMove_MarkRemovedFailure_RetriesFromDatabaseIntent() { @@ -1081,7 +940,6 @@ public async Task FinalizeMove_MarkerlessOwnership_PrunesDirectory() await service.FinalizeMoveAsync(request, result, CancellationToken.None); Assert.False(Directory.Exists(oldTitle)); - Assert.True(File.Exists(result.RecoveryMarkerPath)); } [Fact] @@ -1133,190 +991,11 @@ public async Task FinalizeMove_MissingBoundaryWithNonEmptyParent_CompletesAtNatu await service.FinalizeMoveAsync(request, result, CancellationToken.None); - Assert.True(File.Exists(result.RecoveryMarkerPath)); await service.CleanupCompletedMoveArtifactsAsync(request, result, CancellationToken.None); - Assert.False(File.Exists(result.RecoveryMarkerPath)); Assert.True(Directory.Exists(sourceParent)); Assert.True(File.Exists(Path.Join(sourceParent, "keep.txt"))); } - [Fact] - public async Task MoveContentsAsync_RetryAfterEmptySourceQuarantineCompletesSafely() - { - var source = FileService.GetTempDirectory("content-move-source-root-quarantine"); - await FileService.GetFileAsync(source, "book.m4b", "audio"); - var target = Path.Join( - FileService.GetTempPath(), - $"content-move-source-root-quarantine-dst-{Guid.NewGuid():N}"); - var request = await CreateLeasedMoveRequestAsync(source, target); - var failingService = new AudiobookContentMoveService( - _provider.GetRequiredService>(), - _provider.GetRequiredService>(), - TimeProvider.System, - new InterruptAfterEmptySourceQuarantine(source, recreateSource: false)); - - await Assert.ThrowsAsync(() => failingService.MoveContentsAsync( - request, - CancellationToken.None)); - - var quarantinedSource = Path.Join( - Path.GetDirectoryName(source)!, - $".listenarr-quarantine-{request.JobId:N}", - ".listenarr-empty-source.state", - "source.claim"); - Assert.False(Directory.Exists(source)); - Assert.True(Directory.Exists(quarantinedSource)); - - var recoveryService = _provider.GetRequiredService(); - var recovered = Assert.IsType( - await recoveryService.GetRecoverableMoveAsync( - request, - CancellationToken.None)); - await recoveryService.ResumeSourceCleanupAsync( - request, - recovered, - CancellationToken.None); - - Assert.False(Directory.Exists(source)); - Assert.False(Directory.Exists(quarantinedSource)); - Assert.True(File.Exists(Path.Join(target, "book.m4b"))); - } - - [Fact] - public async Task MoveContentsAsync_RetryWithRestoredSourceAndEmptyCleanupState_CompletesSafely() - { - var source = FileService.GetTempDirectory("content-move-source-restored-state-retry"); - await FileService.GetFileAsync(source, "book.m4b", "audio"); - var target = Path.Join( - FileService.GetTempPath(), - $"content-move-source-restored-state-retry-dst-{Guid.NewGuid():N}"); - var request = await CreateLeasedMoveRequestAsync(source, target); - var failingService = new AudiobookContentMoveService( - _provider.GetRequiredService>(), - _provider.GetRequiredService>(), - TimeProvider.System, - new InterruptAfterEmptySourceQuarantine(source, recreateSource: false)); - - await Assert.ThrowsAsync(() => failingService.MoveContentsAsync( - request, - CancellationToken.None)); - - var statePath = Path.Join( - Path.GetDirectoryName(source)!, - $".listenarr-quarantine-{request.JobId:N}", - ".listenarr-empty-source.state"); - var claimPath = Path.Join(statePath, "source.claim"); - Assert.False(Directory.Exists(source)); - Assert.True(Directory.Exists(claimPath)); - Assert.Empty(Directory.EnumerateFileSystemEntries(claimPath)); - Directory.Move(claimPath, source); - Assert.True(Directory.Exists(source)); - Assert.Empty(Directory.EnumerateFileSystemEntries(source)); - Assert.Empty(Directory.EnumerateFileSystemEntries(statePath)); - - var recoveryService = _provider.GetRequiredService(); - var recovered = Assert.IsType( - await recoveryService.GetRecoverableMoveAsync( - request, - CancellationToken.None)); - await recoveryService.ResumeSourceCleanupAsync( - request, - recovered, - CancellationToken.None); - - Assert.False(Directory.Exists(source)); - Assert.False(Directory.Exists(statePath)); - Assert.True(File.Exists(Path.Join(target, "book.m4b"))); - } - - [Fact] - public async Task MoveContentsAsync_EmptySourceStateNativeDeleteFailure_RemainsRecoverable() - { - var source = FileService.GetTempDirectory("content-move-source-state-delete-retry"); - await FileService.GetFileAsync(source, "book.m4b", "audio"); - var target = Path.Join( - FileService.GetTempPath(), - $"content-move-source-state-delete-retry-dst-{Guid.NewGuid():N}"); - var request = await CreateLeasedMoveRequestAsync(source, target); - var failingService = new AudiobookContentMoveService( - _provider.GetRequiredService>(), - _provider.GetRequiredService>(), - TimeProvider.System, - new FailEmptySourceStateDeleteOnce()); - - var exception = await Assert.ThrowsAsync(() => failingService.MoveContentsAsync( - request, - CancellationToken.None)); - - Assert.IsType(exception.InnerException); - var statePath = Path.Join( - Path.GetDirectoryName(source)!, - $".listenarr-quarantine-{request.JobId:N}", - ".listenarr-empty-source.state"); - Assert.False(Directory.Exists(source)); - Assert.True(Directory.Exists(statePath)); - Assert.Empty(Directory.EnumerateFileSystemEntries(statePath)); - Assert.True(File.Exists(Path.Join(target, "book.m4b"))); - - var recoveryService = _provider.GetRequiredService(); - var recovered = Assert.IsType( - await recoveryService.GetRecoverableMoveAsync( - request, - CancellationToken.None)); - await recoveryService.ResumeSourceCleanupAsync( - request, - recovered, - CancellationToken.None); - - Assert.False(Directory.Exists(source)); - Assert.False(Directory.Exists(statePath)); - Assert.True(File.Exists(Path.Join(target, "book.m4b"))); - } - - [Fact] - public async Task MoveContentsAsync_RecreatedSourceDuringQuarantineIsPreserved() - { - var source = FileService.GetTempDirectory("content-move-recreated-source-root"); - await FileService.GetFileAsync(source, "book.m4b", "audio"); - var target = Path.Join( - FileService.GetTempPath(), - $"content-move-recreated-source-root-dst-{Guid.NewGuid():N}"); - var request = await CreateLeasedMoveRequestAsync(source, target); - var failingService = new AudiobookContentMoveService( - _provider.GetRequiredService>(), - _provider.GetRequiredService>(), - TimeProvider.System, - new InterruptAfterEmptySourceQuarantine(source, recreateSource: true)); - - await Assert.ThrowsAsync(() => failingService.MoveContentsAsync( - request, - CancellationToken.None)); - - var quarantinedSource = Path.Join( - Path.GetDirectoryName(source)!, - $".listenarr-quarantine-{request.JobId:N}", - ".listenarr-empty-source.state", - "source.claim"); - Assert.True(Directory.Exists(source)); - Assert.True(Directory.Exists(quarantinedSource)); - - var recoveryService = _provider.GetRequiredService(); - var recovered = Assert.IsType( - await recoveryService.GetRecoverableMoveAsync( - request, - CancellationToken.None)); - var exception = await Assert.ThrowsAsync(() => - recoveryService.ResumeSourceCleanupAsync( - request, - recovered, - CancellationToken.None)); - - Assert.Contains("both", exception.Message, StringComparison.OrdinalIgnoreCase); - Assert.True(Directory.Exists(source)); - Assert.True(Directory.Exists(quarantinedSource)); - Assert.True(File.Exists(Path.Join(target, "book.m4b"))); - } - [Fact] public async Task MoveContentsAsync_DeleteEmptySourceFalse_KeepsEmptySourceDirectory() { @@ -1368,7 +1047,7 @@ public async Task MoveContentsAsync_TargetContainsUnrelatedFiles_Fails() var ex = await Assert.ThrowsAsync(() => service.MoveContentsAsync(request, CancellationToken.None)); - Assert.Contains("contains files", ex.Message); + Assert.Contains("unowned file", ex.Message, StringComparison.OrdinalIgnoreCase); Assert.True(Directory.Exists(source)); Assert.True(File.Exists(Path.Join(source, "book.m4b"))); Assert.True(File.Exists(Path.Join(target, "existing.txt"))); @@ -1407,7 +1086,7 @@ public async Task MoveContentsAsync_TargetInsideSource_TargetAlreadyContainsFile var ex = await Assert.ThrowsAsync(() => service.MoveContentsAsync(request, CancellationToken.None)); - Assert.Contains("contains files", ex.Message); + Assert.Contains("overlaps", ex.Message, StringComparison.OrdinalIgnoreCase); Assert.True(Directory.Exists(source)); Assert.True(File.Exists(Path.Join(source, "book.m4b"))); Assert.True(File.Exists(Path.Join(target, "existing.txt"))); @@ -1549,7 +1228,6 @@ await service.CleanupCompletedMoveArtifactsAsync( result, CancellationToken.None); - Assert.Equal(string.Empty, result.RecoveryMarkerPath); Assert.False(Directory.Exists(source)); Assert.Equal("audio", await File.ReadAllTextAsync( Path.Join(target, "Disc 01", "book.m4b"))); @@ -1691,7 +1369,7 @@ await Assert.ThrowsAsync(() => LibraryDirectoryOwnershipState.Removing, interruptedOwnership.State); Assert.Equal( - MoveJobEntryCleanupState.DeletionAuthorized, + MoveJobEntryCleanupState.DeleteAuthorized, interruptedJob.SourceDirectoryCleanupState); } @@ -2553,7 +2231,7 @@ public async Task MoveContentsAsync_MarkerlessRetryAfterSourceDeleteBeforeStateU await AssertMarkerlessSourceCleanupRetryAsync( SourceCleanupFaultPoint .AfterMarkerlessSourceFileDeleteBeforeStateUpdate, - MoveJobEntryCleanupState.DeletionAuthorized); + MoveJobEntryCleanupState.DeleteAuthorized); } [Fact] @@ -2639,7 +2317,7 @@ await service.CleanupCompletedMoveArtifactsAsync( } [Fact] - public async Task MoveContentsAsync_OwnedSourceMarkersAreRetiredAndNeverPublished() + public async Task MoveContentsAsync_OwnedSourceMovesWithoutPublishingOwnershipSidecars() { var source = FileService.GetTempDirectory("content-move-owned-source"); await FileService.GetFileAsync(source, "book.m4b", "audio"); @@ -2647,7 +2325,7 @@ public async Task MoveContentsAsync_OwnedSourceMarkersAreRetiredAndNeverPublishe FileService.GetTempPath(), $"content-move-owned-source-dst-{Guid.NewGuid():N}"); var ownershipStore = _provider.GetRequiredService(); - var ownership = await ownershipStore.RecordCreatedAsync( + _ = await ownershipStore.RecordCreatedAsync( new LibraryDirectoryOwnershipClaim( source, FileSystemPathSemantics.CurrentHostDefault, @@ -2656,7 +2334,6 @@ public async Task MoveContentsAsync_OwnedSourceMarkersAreRetiredAndNeverPublishe _provider.GetRequiredService>(), _provider.GetRequiredService>(), TimeProvider.System, - new AllowAtomicRenameInjector(), directoryOwnershipStore: ownershipStore); var request = await CreateLeasedMoveRequestAsync(source, target); @@ -2664,11 +2341,6 @@ public async Task MoveContentsAsync_OwnedSourceMarkersAreRetiredAndNeverPublishe Assert.False(Directory.Exists(source)); Assert.True(File.Exists(Path.Join(target, "book.m4b"))); - Assert.False(File.Exists(Path.Join(target, LibraryDirectoryOwnershipMarker.FileName))); - Assert.Empty(Directory.EnumerateFiles( - Path.GetDirectoryName(target)!, - $".listenarr-directory-owner-{ownership.OwnershipToken}.json", - SearchOption.TopDirectoryOnly)); var resolution = await ownershipStore.ResolveOwnedAsync( source, FileSystemPathSemantics.CurrentHostDefault); @@ -2682,7 +2354,7 @@ public async Task OwnedEmptyTarget_IsRevalidatedAcrossMoveFinalizationAndArtifac await FileService.GetFileAsync(source, "book.m4b", "audio"); var target = FileService.GetTempDirectory("content-move-owned-target-dst"); var ownershipStore = _provider.GetRequiredService(); - var ownership = await ownershipStore.RecordCreatedAsync( + _ = await ownershipStore.RecordCreatedAsync( new LibraryDirectoryOwnershipClaim( target, FileSystemPathSemantics.CurrentHostDefault, @@ -2699,7 +2371,6 @@ await service.CleanupCompletedMoveArtifactsAsync( Assert.False(Directory.Exists(source)); Assert.True(File.Exists(Path.Join(target, "book.m4b"))); - Assert.False(File.Exists(Path.Join(target, LibraryDirectoryOwnershipMarker.FileName))); var resolution = await ownershipStore.ResolveOwnedAsync( target, FileSystemPathSemantics.CurrentHostDefault); @@ -2729,490 +2400,12 @@ await ownershipStore.RecordCreatedAsync( var exception = await Assert.ThrowsAsync(() => service.MoveContentsAsync(request, CancellationToken.None)); - Assert.Contains("ownership changed", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("changed physical generation", exception.Message, StringComparison.OrdinalIgnoreCase); Assert.True(File.Exists(Path.Join(source, "book.m4b"))); Assert.False(File.Exists(Path.Join(target, "book.m4b"))); Assert.True(File.Exists(Path.Join(target + ".original", "book.m4b"))); } - [Fact] - public async Task MoveContentsAsync_UnclaimedDirectoryOwnershipMarkerBlocksMove() - { - var source = FileService.GetTempDirectory("content-move-unclaimed-owner-marker"); - await FileService.GetFileAsync(source, "book.m4b", "audio"); - await File.WriteAllTextAsync( - Path.Join(source, LibraryDirectoryOwnershipMarker.FileName), - "foreign marker"); - var target = Path.Join( - FileService.GetTempPath(), - $"content-move-unclaimed-owner-marker-dst-{Guid.NewGuid():N}"); - var service = _provider.GetRequiredService(); - var request = await CreateLeasedMoveRequestAsync(source, target); - - var exception = await Assert.ThrowsAsync(() => - service.MoveContentsAsync(request, CancellationToken.None)); - - Assert.Contains("reserved Listenarr recovery artifact", exception.Message, StringComparison.OrdinalIgnoreCase); - Assert.True(File.Exists(Path.Join(source, "book.m4b"))); - Assert.False(Directory.Exists(target)); - } - - [Fact] - public async Task LegacyCopyCompleteMarker_WithoutManifest_NeverAuthorizesDeletion() - { - var source = FileService.GetTempDirectory("content-move-legacy-marker-src"); - await FileService.GetFileAsync(source, "book.m4b", "audio"); - var target = FileService.GetTempDirectory("content-move-legacy-marker-dst"); - await FileService.GetFileAsync(target, "book.m4b", "audio"); - var jobId = Guid.NewGuid(); - var request = await CreateLeasedMoveRequestAsync(source, target, jobId); - await File.WriteAllTextAsync( - Path.Join(target, $".listenarr-move-{jobId:N}.pending"), - "copy-complete"); - var service = _provider.GetRequiredService(); - - var exception = await Assert.ThrowsAsync(() => - service.GetRecoverableMoveAsync(request)); - - Assert.Contains("obsolete pre-release", exception.Message, StringComparison.OrdinalIgnoreCase); - Assert.True(File.Exists(Path.Join(source, "book.m4b"))); - Assert.True(File.Exists(Path.Join(target, $".listenarr-move-{jobId:N}.pending"))); - } - - [Fact] - public async Task AtomicRenameMarker_RecoversBeforePhasePersistence() - { - var source = FileService.GetTempDirectory("content-move-atomic-recovery-src"); - var sourceFile = await FileService.GetFileAsync(source, "book.m4b", "audio"); - var target = Path.Join( - FileService.GetTempPath(), - $"content-move-atomic-recovery-dst-{Guid.NewGuid():N}"); - var jobId = Guid.NewGuid(); - var request = await CreateLeasedMoveRequestAsync(source, target, jobId); - await PersistFileManifestAsync(jobId, "book.m4b", sourceFile); - await File.WriteAllTextAsync( - Path.Join(source, $".listenarr-move-{jobId:N}.pending"), - System.Text.Json.JsonSerializer.Serialize(new - { - Version = 1, - JobId = jobId, - Source = Path.GetFullPath(source), - Target = Path.GetFullPath(target), - Stage = "atomic-rename-complete" - })); - Directory.Move(source, target); - - var service = _provider.GetRequiredService(); - var result = await service.GetRecoverableMoveAsync(request); - - Assert.NotNull(result); - Assert.True(result.SourceCleanupCompleted); - Assert.True(File.Exists(Path.Join(target, "book.m4b"))); - } - - [Fact] - public async Task GetRecoverableMoveAsync_PropagatesCancellationDuringManifestVerification() - { - var source = FileService.GetTempDirectory("content-move-cancel-recovery-src"); - var target = FileService.GetTempDirectory("content-move-cancel-recovery-dst"); - var destination = await FileService.GetFileAsync(target, "book.m4b", "audio"); - var jobId = Guid.NewGuid(); - await WriteRecoveryMarkerAsync( - target, - jobId, - source, - target, - "copy-complete"); - var hash = Convert.ToHexString( - System.Security.Cryptography.SHA256.HashData(await File.ReadAllBytesAsync(destination))); - var factory = _provider.GetRequiredService>(); - await using (var db = await factory.CreateDbContextAsync()) - { - db.MoveJobs.Add(new MoveJob - { - Id = jobId, - AudiobookId = 1, - RequestedPath = target, - SourcePath = source, - Status = MoveJobStatus.Running, - LeaseOwner = TestLeaseOwner, - LeaseGeneration = 1, - LeaseExpiresAt = DateTime.UtcNow.AddMinutes(5), - ActiveDeduplicationKey = $"test:{jobId:N}" - }); - db.MoveJobEntries.Add(new MoveJobEntry - { - MoveJobId = jobId, - RelativePath = "book.m4b", - EntryType = MoveJobEntryType.File, - Length = new FileInfo(destination).Length, - Sha256 = hash, - CopyState = MoveJobEntryCopyState.Verified - }); - await db.SaveChangesAsync(); - } - - using var cancellation = new CancellationTokenSource(); - cancellation.Cancel(); - var service = _provider.GetRequiredService(); - - await Assert.ThrowsAnyAsync(() => - service.GetRecoverableMoveAsync( - new AudiobookContentMoveRequest( - source, - target, - jobId, - true, - FileSystemPathSemantics.CurrentHostDefault, - FileSystemPathSemantics.CurrentHostDefault, - LeaseToken(1)), - cancellation.Token)); - } - - [Fact] - public async Task ResumeSourceCleanup_VerifiedQuarantine_ConvergesAfterCrash() - { - var source = FileService.GetTempDirectory("content-move-quarantine-src"); - var target = FileService.GetTempDirectory("content-move-quarantine-dst"); - var jobId = Guid.NewGuid(); - var quarantineRoot = Path.Join( - Path.GetDirectoryName(source)!, - $".listenarr-quarantine-{jobId:N}"); - Directory.CreateDirectory(quarantineRoot); - await WriteQuarantineOwnershipMarkerAsync( - quarantineRoot, - jobId, - source, - target); - var destination = Path.Join(target, "book.m4b"); - var quarantineFile = Path.Join(quarantineRoot, "book.m4b"); - await File.WriteAllTextAsync(destination, "verified audio"); - await File.WriteAllTextAsync(quarantineFile, "verified audio"); - var hash = Convert.ToHexString( - System.Security.Cryptography.SHA256.HashData(await File.ReadAllBytesAsync(destination))); - var factory = _provider.GetRequiredService>(); - await using (var db = await factory.CreateDbContextAsync()) - { - db.MoveJobs.Add(new MoveJob - { - Id = jobId, - AudiobookId = 1, - RequestedPath = target, - SourcePath = source, - Status = MoveJobStatus.Running, - LeaseOwner = TestLeaseOwner, - LeaseGeneration = 1, - LeaseExpiresAt = DateTime.UtcNow.AddMinutes(5), - ActiveDeduplicationKey = $"test:{jobId:N}" - }); - db.MoveJobEntries.Add(new MoveJobEntry - { - MoveJobId = jobId, - RelativePath = "book.m4b", - EntryType = MoveJobEntryType.File, - Length = new FileInfo(destination).Length, - Sha256 = hash, - CopyState = MoveJobEntryCopyState.Verified, - CleanupState = MoveJobEntryCleanupState.Quarantined - }); - await db.SaveChangesAsync(); - } - - await AuthorizeExistingMoveJobTargetAsync(jobId, target); - var service = _provider.GetRequiredService(); - var resumed = await service.ResumeSourceCleanupAsync( - new AudiobookContentMoveRequest( - source, - target, - jobId, - true, - FileSystemPathSemantics.CurrentHostDefault, - FileSystemPathSemantics.CurrentHostDefault, - LeaseToken(1)), - new AudiobookContentMoveResult( - source, - target, - false, - false, - Path.Join(target, $".listenarr-move-{jobId:N}.pending"), - false, - new Dictionary()), - CancellationToken.None); - - Assert.True(resumed.SourceCleanupCompleted); - Assert.False(File.Exists(quarantineFile)); - Assert.False(Directory.Exists(quarantineRoot)); - Assert.False(Directory.Exists(source)); - await using var verification = await factory.CreateDbContextAsync(); - var persistedEntries = await verification.MoveJobEntries.ToListAsync(); - Assert.Equal( - MoveJobEntryCleanupState.Deleted, - persistedEntries.Single(entry => - !MoveManifestIdentity.IsTargetBoundaryAuthorization(entry)).CleanupState); - } - - [Fact] - public async Task ResumeSourceCleanup_DeletedQuarantine_ConvergesAfterCrash() - { - var source = FileService.GetTempDirectory("content-move-deleted-quarantine-src"); - var target = FileService.GetTempDirectory("content-move-deleted-quarantine-dst"); - var jobId = Guid.NewGuid(); - var destination = Path.Join(target, "book.m4b"); - await File.WriteAllTextAsync(destination, "verified audio"); - var hash = Convert.ToHexString( - System.Security.Cryptography.SHA256.HashData(await File.ReadAllBytesAsync(destination))); - var factory = _provider.GetRequiredService>(); - await using (var db = await factory.CreateDbContextAsync()) - { - db.MoveJobs.Add(new MoveJob - { - Id = jobId, - AudiobookId = 1, - RequestedPath = target, - SourcePath = source, - Status = MoveJobStatus.Running, - LeaseOwner = TestLeaseOwner, - LeaseGeneration = 1, - LeaseExpiresAt = DateTime.UtcNow.AddMinutes(5), - ActiveDeduplicationKey = $"test:{jobId:N}" - }); - db.MoveJobEntries.Add(new MoveJobEntry - { - MoveJobId = jobId, - RelativePath = "book.m4b", - EntryType = MoveJobEntryType.File, - Length = new FileInfo(destination).Length, - Sha256 = hash, - CopyState = MoveJobEntryCopyState.Verified, - CleanupState = MoveJobEntryCleanupState.Quarantined - }); - await db.SaveChangesAsync(); - } - - await AuthorizeExistingMoveJobTargetAsync(jobId, target); - var service = _provider.GetRequiredService(); - var resumed = await service.ResumeSourceCleanupAsync( - new AudiobookContentMoveRequest( - source, - target, - jobId, - true, - FileSystemPathSemantics.CurrentHostDefault, - FileSystemPathSemantics.CurrentHostDefault, - LeaseToken(1)), - new AudiobookContentMoveResult( - source, - target, - false, - false, - Path.Join(target, $".listenarr-move-{jobId:N}.pending"), - false, - new Dictionary()), - CancellationToken.None); - - Assert.True(resumed.SourceCleanupCompleted); - Assert.False(Directory.Exists(source)); - await using var verification = await factory.CreateDbContextAsync(); - var persistedEntries = await verification.MoveJobEntries.ToListAsync(); - Assert.Equal( - MoveJobEntryCleanupState.Deleted, - persistedEntries.Single(entry => - !MoveManifestIdentity.IsTargetBoundaryAuthorization(entry)).CleanupState); - } - - [Fact] - public async Task ResumeSourceCleanup_SourceAndQuarantineBothExist_BlocksCleanup() - { - var source = FileService.GetTempDirectory("content-move-ambiguous-quarantine-src"); - var target = FileService.GetTempDirectory("content-move-ambiguous-quarantine-dst"); - var jobId = Guid.NewGuid(); - var quarantineRoot = Path.Join( - Path.GetDirectoryName(source)!, - $".listenarr-quarantine-{jobId:N}"); - Directory.CreateDirectory(quarantineRoot); - await WriteQuarantineOwnershipMarkerAsync( - quarantineRoot, - jobId, - source, - target); - var sourceFile = Path.Join(source, "book.m4b"); - var destination = Path.Join(target, "book.m4b"); - var quarantineFile = Path.Join(quarantineRoot, "book.m4b"); - await File.WriteAllTextAsync(sourceFile, "verified audio"); - await File.WriteAllTextAsync(destination, "verified audio"); - await File.WriteAllTextAsync(quarantineFile, "verified audio"); - var hash = Convert.ToHexString( - System.Security.Cryptography.SHA256.HashData(await File.ReadAllBytesAsync(destination))); - var factory = _provider.GetRequiredService>(); - await using (var db = await factory.CreateDbContextAsync()) - { - db.MoveJobs.Add(new MoveJob - { - Id = jobId, - AudiobookId = 1, - RequestedPath = target, - SourcePath = source, - Status = MoveJobStatus.Running, - LeaseOwner = TestLeaseOwner, - LeaseGeneration = 1, - LeaseExpiresAt = DateTime.UtcNow.AddMinutes(5), - ActiveDeduplicationKey = $"test:{jobId:N}" - }); - db.MoveJobEntries.Add(new MoveJobEntry - { - MoveJobId = jobId, - RelativePath = "book.m4b", - EntryType = MoveJobEntryType.File, - Length = new FileInfo(destination).Length, - Sha256 = hash, - CopyState = MoveJobEntryCopyState.Verified, - CleanupState = MoveJobEntryCleanupState.Quarantined - }); - await db.SaveChangesAsync(); - } - - await AuthorizeExistingMoveJobTargetAsync(jobId, target); - var service = _provider.GetRequiredService(); - await Assert.ThrowsAsync(() => service.ResumeSourceCleanupAsync( - new AudiobookContentMoveRequest( - source, - target, - jobId, - true, - FileSystemPathSemantics.CurrentHostDefault, - FileSystemPathSemantics.CurrentHostDefault, - LeaseToken(1)), - new AudiobookContentMoveResult( - source, - target, - false, - false, - Path.Join(target, $".listenarr-move-{jobId:N}.pending"), - false, - new Dictionary()), - CancellationToken.None)); - - Assert.True(File.Exists(sourceFile)); - Assert.True(File.Exists(quarantineFile)); - } - - [Fact] - public async Task MoveContentsAsync_CopyStartedMarkerOwnedByAnotherJob_BlocksRecovery() - { - var source = FileService.GetTempDirectory("content-move-wrong-marker-src"); - var sourceFile = await FileService.GetFileAsync(source, "book.m4b", "verified audio"); - var target = FileService.GetTempDirectory("content-move-wrong-marker-dst"); - var jobId = Guid.NewGuid(); - await CreateLeasedMoveRequestAsync(source, target, jobId); - await PersistFileManifestAsync(jobId, "book.m4b", sourceFile); - await File.WriteAllTextAsync( - Path.Join(target, $".listenarr-move-{jobId:N}.pending"), - System.Text.Json.JsonSerializer.Serialize(new - { - Version = 1, - JobId = Guid.NewGuid(), - Source = source, - Target = target, - Stage = "copy-started" - })); - - var service = _provider.GetRequiredService(); - var request = new AudiobookContentMoveRequest( - source, - target, - jobId, - true, - FileSystemPathSemantics.CurrentHostDefault, - FileSystemPathSemantics.CurrentHostDefault, - LeaseToken(1)); - - await Assert.ThrowsAsync(() => - service.MoveContentsAsync(request, CancellationToken.None)); - - Assert.True(File.Exists(sourceFile)); - } - - [Fact] - public async Task MoveContentsAsync_CopyStartedWithUnknownDestinationFile_BlocksRecovery() - { - var source = FileService.GetTempDirectory("content-move-unowned-target-src"); - var sourceFile = await FileService.GetFileAsync(source, "book.m4b", "verified audio"); - var target = FileService.GetTempDirectory("content-move-unowned-target-dst"); - await FileService.GetFileAsync(target, "unrelated.txt", "not owned"); - var jobId = Guid.NewGuid(); - var request = await CreateLeasedMoveRequestAsync(source, target, jobId); - await PersistFileManifestAsync(jobId, "book.m4b", sourceFile); - await WriteRecoveryMarkerAsync( - target, - jobId, - source, - target, - "copy-started"); - - var service = _provider.GetRequiredService(); - var exception = await Assert.ThrowsAsync(() => - service.MoveContentsAsync(request, CancellationToken.None)); - - Assert.Contains("unowned file", exception.Message, StringComparison.OrdinalIgnoreCase); - Assert.True(File.Exists(sourceFile)); - Assert.True(File.Exists(Path.Join(target, "unrelated.txt"))); - } - - [Fact] - public async Task MoveContentsAsync_ValidOwnedPartial_PublishesFromPersistedManifest() - { - var source = FileService.GetTempDirectory("content-move-valid-partial-src"); - var sourceFile = await FileService.GetFileAsync(source, "book.m4b", "verified audio"); - var target = FileService.GetTempDirectory("content-move-valid-partial-dst"); - var jobId = Guid.NewGuid(); - var request = await CreateLeasedMoveRequestAsync(source, target, jobId); - await PersistFileManifestAsync(jobId, "book.m4b", sourceFile); - var partial = Path.Join(target, $"book.m4b.listenarr-{jobId:N}.partial"); - await File.WriteAllTextAsync(partial, "verified audio"); - await WriteRecoveryMarkerAsync( - target, - jobId, - source, - target, - "copy-started"); - - var service = _provider.GetRequiredService(); - await service.MoveContentsAsync(request, CancellationToken.None); - - Assert.False(File.Exists(partial)); - Assert.True(File.Exists(Path.Join(target, "book.m4b"))); - Assert.Equal("verified audio", await File.ReadAllTextAsync(Path.Join(target, "book.m4b"))); - Assert.False(Directory.Exists(source)); - } - - [Fact] - public async Task MoveContentsAsync_InvalidOwnedPartial_IsPreservedAndRequiresAttention() - { - var source = FileService.GetTempDirectory("content-move-invalid-partial-src"); - var sourceFile = await FileService.GetFileAsync(source, "book.m4b", "verified audio"); - var target = FileService.GetTempDirectory("content-move-invalid-partial-dst"); - var jobId = Guid.NewGuid(); - var request = await CreateLeasedMoveRequestAsync(source, target, jobId); - await PersistFileManifestAsync(jobId, "book.m4b", sourceFile); - var partial = Path.Join(target, $"book.m4b.listenarr-{jobId:N}.partial"); - await File.WriteAllTextAsync(partial, "invalid bytes"); - await WriteRecoveryMarkerAsync( - target, - jobId, - source, - target, - "copy-started"); - - var service = _provider.GetRequiredService(); - var exception = await Assert.ThrowsAsync(() => - service.MoveContentsAsync(request, CancellationToken.None)); - - Assert.Contains("partial file does not match", exception.Message, StringComparison.OrdinalIgnoreCase); - Assert.Equal("invalid bytes", await File.ReadAllTextAsync(partial)); - Assert.False(File.Exists(Path.Join(target, "book.m4b"))); - Assert.True(Directory.Exists(source)); - } - private async Task ClaimOwnedDirectoriesAsync(params string[] directories) { var ownershipStore = _provider.GetRequiredService(); @@ -3253,27 +2446,6 @@ private async Task PersistFileManifestAsync( await db.SaveChangesAsync(); } - private static Task WriteQuarantineOwnershipMarkerAsync( - string quarantineRoot, - Guid jobId, - string source, - string target) - { - var marker = System.Text.Json.JsonSerializer.Serialize(new - { - Version = 1, - ArtifactType = "quarantine-directory", - JobId = jobId, - Source = Path.GetFullPath(source), - Target = Path.GetFullPath(target), - DirectoryPath = Path.GetFullPath(quarantineRoot), - OwnedArtifactType = (string?)null - }); - return File.WriteAllTextAsync( - Path.Join(quarantineRoot, ".listenarr-quarantine-owner.json"), - marker); - } - private async Task CreateLeasedMoveRequestAsync( string source, string target, @@ -3283,7 +2455,7 @@ private async Task CreateLeasedMoveRequestAsync( FileSystemPathSemantics? targetSemantics = null, string? sourceCleanupBoundary = null, int executionProtocolVersion = - MoveExecutionProtocol.LegacyFilesystemArtifacts) + MoveExecutionProtocol.Current) { var id = jobId ?? Guid.NewGuid(); var effectiveTargetSemantics = @@ -3454,8 +2626,7 @@ private async Task PersistCurrentSourceManifestAsync( foreach (var path in Directory.EnumerateFileSystemEntries(directory)) { var attributes = File.GetAttributes(path); - if ((attributes & FileAttributes.ReparsePoint) != 0 - || IsTestReservedMoveArtifact(Path.GetFileName(path))) + if ((attributes & FileAttributes.ReparsePoint) != 0) { continue; } @@ -3499,18 +2670,6 @@ private async Task PersistCurrentSourceManifestAsync( await db.SaveChangesAsync(); } - private static bool IsTestReservedMoveArtifact(string name) => - name.StartsWith(".listenarr-move-", StringComparison.Ordinal) - || name.StartsWith(".listenarr-quarantine-", StringComparison.Ordinal) - || name.StartsWith(".listenarr-temporary-directory-", StringComparison.Ordinal) - || string.Equals(name, ".listenarr-temp-owner.json", StringComparison.Ordinal) - || string.Equals(name, ".listenarr-quarantine-owner.json", StringComparison.Ordinal) - || string.Equals(name, LibraryDirectoryOwnershipMarker.FileName, StringComparison.Ordinal) - || name.StartsWith(".listenarr-directory-owner-", StringComparison.Ordinal) - && name.EndsWith(".json", StringComparison.Ordinal) - || name.Contains(".listenarr-", StringComparison.Ordinal) - && name.EndsWith(".partial", StringComparison.Ordinal); - private sealed class SuppressMarkerlessReplacementRetirementOwnershipStore( ILibraryDirectoryOwnershipStore inner) : ILibraryDirectoryOwnershipStore { @@ -3663,53 +2822,6 @@ public Task MarkRemovedAsync( throw new InvalidOperationException("Injected ownership-state persistence failure."); } - private sealed class InterruptAfterEmptySourceQuarantine( - string source, - bool recreateSource) : IMoveFaultInjector - { - private bool _interrupted; - - public void OnSourceCleanupMutation( - Guid jobId, - SourceCleanupFaultPoint faultPoint) - { - if (_interrupted - || faultPoint != SourceCleanupFaultPoint.AfterEmptySourceDirectoryQuarantine) - { - return; - } - - _interrupted = true; - if (recreateSource) - { - Directory.CreateDirectory(source); - } - - throw new IOException("Injected interruption after source-root quarantine."); - } - } - - private sealed class FailEmptySourceStateDeleteOnce : IMoveFaultInjector - { - private bool _failed; - - public void OnSourceCleanupMutation( - Guid jobId, - SourceCleanupFaultPoint faultPoint) - { - if (_failed - || faultPoint != SourceCleanupFaultPoint.BeforeEmptySourceStateDelete) - { - return; - } - - _failed = true; - throw new System.ComponentModel.Win32Exception( - 145, - "Injected empty-source state retirement failure."); - } - } - private static void AssertNoListenarrArtifacts(string root) { Assert.DoesNotContain( @@ -3889,11 +3001,6 @@ public Task AfterPublishedAsync(Guid jobId, CancellationToken cancellationToken) cancellationToken); } - private sealed class AllowAtomicRenameInjector : IMoveFaultInjector - { - public bool AllowAtomicRename => true; - } - private sealed class ReplaceTargetDirectoryAfterPublish(string target) : IMoveFaultInjector { public Task AfterPublishedAsync(Guid jobId, CancellationToken cancellationToken) diff --git a/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceTombstoneReplacementTests.cs b/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceTombstoneReplacementTests.cs deleted file mode 100644 index d28abdabc..000000000 --- a/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceTombstoneReplacementTests.cs +++ /dev/null @@ -1,103 +0,0 @@ -using Microsoft.EntityFrameworkCore; - -namespace Listenarr.Tests.Features.Infrastructure.Library.Moving; - -public partial class AudiobookContentMoveServiceTests -{ - [Fact] - public async Task MoveContentsAsync_CleanupDirectoryReplacedBeforeRetirement_PreservesBothGenerations() - { - var source = FileService.GetTempDirectory("content-move-cleanup-directory-swap-src"); - await FileService.GetFileAsync(source, "book.m4b", "verified audio"); - var target = Path.Join( - FileService.GetTempPath(), - $"content-move-cleanup-directory-swap-dst-{Guid.NewGuid():N}"); - var request = await CreateLeasedMoveRequestAsync(source, target); - var sourceParent = Path.GetDirectoryName(source)!; - var cleanupDirectory = Path.Join( - sourceParent, - $".listenarr-quarantine-directory-{request.JobId:N}.cleanup-dir"); - var displacedDirectory = cleanupDirectory + ".validated"; - var service = new AudiobookContentMoveService( - _provider.GetRequiredService>(), - _provider.GetRequiredService>(), - TimeProvider.System, - new ReplaceCleanupDirectoryBeforeRetirement( - cleanupDirectory, - displacedDirectory)); - - await Assert.ThrowsAsync(() => - service.MoveContentsAsync(request, CancellationToken.None)); - - Assert.True(Directory.Exists(cleanupDirectory)); - Assert.True(Directory.Exists(displacedDirectory)); - Assert.Empty(Directory.EnumerateFileSystemEntries(cleanupDirectory)); - Assert.Empty(Directory.EnumerateFileSystemEntries(displacedDirectory)); - Assert.True(File.Exists(Path.Join(target, "book.m4b"))); - } - - [Fact] - public async Task GetRecoverableMoveAsync_TombstonedQuarantineReplacedByFile_PreservesEvidence() - { - var source = FileService.GetTempDirectory("content-move-tombstone-file-src"); - await FileService.GetFileAsync(source, "book.m4b", "verified audio"); - var target = Path.Join( - FileService.GetTempPath(), - $"content-move-tombstone-file-dst-{Guid.NewGuid():N}"); - var request = await CreateLeasedMoveRequestAsync(source, target); - var faultingService = new AudiobookContentMoveService( - _provider.GetRequiredService>(), - _provider.GetRequiredService>(), - TimeProvider.System, - new OwnershipCleanupFaultInjector( - OwnershipCleanupFaultPoint.BeforeDirectoryDelete)); - - await Assert.ThrowsAsync(() => - faultingService.MoveContentsAsync(request, CancellationToken.None)); - - var sourceParent = Path.GetDirectoryName(source)!; - var quarantineRoot = Path.Join( - sourceParent, - $".listenarr-quarantine-{request.JobId:N}"); - var cleanupDirectory = Path.Join( - sourceParent, - $".listenarr-quarantine-directory-{request.JobId:N}.cleanup-dir"); - var tombstonePath = Path.Join( - sourceParent, - $".listenarr-quarantine-directory-{request.JobId:N}.cleanup.json"); - await File.WriteAllTextAsync(quarantineRoot, "replacement file"); - var service = _provider.GetRequiredService(); - - var exception = await Assert.ThrowsAsync(() => - service.GetRecoverableMoveAsync(request, CancellationToken.None)); - - Assert.Contains("recreated", exception.Message, StringComparison.OrdinalIgnoreCase); - Assert.Equal("replacement file", await File.ReadAllTextAsync(quarantineRoot)); - Assert.True(Directory.Exists(cleanupDirectory)); - Assert.True(File.Exists(tombstonePath)); - Assert.True(File.Exists(Path.Join(target, "book.m4b"))); - } - - private sealed class ReplaceCleanupDirectoryBeforeRetirement( - string cleanupDirectory, - string displacedDirectory) : IMoveFaultInjector - { - private bool _replaced; - - public void OnOwnershipCleanup( - Guid jobId, - OwnershipMarkerKind markerKind, - OwnershipCleanupFaultPoint faultPoint) - { - if (_replaced - || faultPoint != OwnershipCleanupFaultPoint.BeforeDirectoryDelete) - { - return; - } - - _replaced = true; - Directory.Move(cleanupDirectory, displacedDirectory); - Directory.CreateDirectory(cleanupDirectory); - } - } -} diff --git a/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceTruncatedMarkerRecoveryTests.cs b/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceTruncatedMarkerRecoveryTests.cs deleted file mode 100644 index 6e742ef9b..000000000 --- a/tests/Features/Infrastructure/Library/Moving/AudiobookContentMoveServiceTruncatedMarkerRecoveryTests.cs +++ /dev/null @@ -1,566 +0,0 @@ -using Listenarr.Tests.Common; -using Microsoft.EntityFrameworkCore; - -namespace Listenarr.Tests.Features.Infrastructure.Library.Moving; - -public partial class AudiobookContentMoveServiceTests -{ - [Fact] - public async Task MoveContentsAsync_TruncatedPredecessorRecoveryWrite_IsDiscardedSafely() - { - var source = FileService.GetTempDirectory("content-move-truncated-recovery-src"); - await FileService.GetFileAsync(source, "book.m4b", "audio"); - var target = Path.Join( - FileService.GetTempPath(), - $"content-move-truncated-recovery-dst-{Guid.NewGuid():N}"); - var initialRequest = await CreateLeasedMoveRequestAsync(source, target); - var request = await ReplaceMarkerTestLeaseAsync(initialRequest); - var markerPath = Path.Join( - source, - $".listenarr-move-{request.JobId:N}.pending"); - var writePath = CreateTruncatedMarkerWritePath( - markerPath, - request.JobId, - initialRequest.LeaseGeneration); - await File.WriteAllTextAsync(writePath, "{\"Version\":1"); - - var service = _provider.GetRequiredService(); - var result = await service.MoveContentsAsync(request, CancellationToken.None); - - Assert.True(result.SourceCleanupCompleted); - Assert.False(File.Exists(writePath)); - Assert.True(File.Exists(Path.Join(target, "book.m4b"))); - } - - [Fact] - public async Task MoveContentsAsync_PredecessorRecoveryWriteReplacedBeforeDeletion_PreservesReplacement() - { - var source = FileService.GetTempDirectory("content-move-replaced-recovery-write-src"); - await FileService.GetFileAsync(source, "book.m4b", "audio"); - var target = Path.Join( - FileService.GetTempPath(), - $"content-move-replaced-recovery-write-dst-{Guid.NewGuid():N}"); - var initialRequest = await CreateLeasedMoveRequestAsync(source, target); - var request = await ReplaceMarkerTestLeaseAsync(initialRequest); - var markerPath = Path.Join( - source, - $".listenarr-move-{request.JobId:N}.pending"); - var writePath = CreateTruncatedMarkerWritePath( - markerPath, - request.JobId, - initialRequest.LeaseGeneration); - var displacedPath = writePath + ".validated"; - await File.WriteAllTextAsync(writePath, "{\"Version\":1"); - var service = new AudiobookContentMoveService( - _provider.GetRequiredService>(), - _provider.GetRequiredService>(), - TimeProvider.System, - new ReplaceRecoveryWriteBeforeDeletion(writePath, displacedPath)); - - await Assert.ThrowsAsync(() => - service.MoveContentsAsync(request, CancellationToken.None)); - - Assert.Equal("replacement", await File.ReadAllTextAsync(writePath)); - Assert.True(File.Exists(displacedPath)); - Assert.True(File.Exists(Path.Join(source, "book.m4b"))); - } - - [Fact] - public async Task GetRecoverableMoveAsync_CompletePredecessorRecoveryWrite_IsPublished() - { - var source = FileService.GetTempDirectory("content-move-complete-recovery-write-src"); - var sourceFile = await FileService.GetFileAsync(source, "book.m4b", "audio"); - var target = FileService.GetTempDirectory("content-move-complete-recovery-write-dst"); - await FileService.GetFileAsync(target, "book.m4b", "audio"); - var initialRequest = await CreateLeasedMoveRequestAsync(source, target); - await PersistFileManifestAsync(initialRequest.JobId, "book.m4b", sourceFile); - var markerPath = Path.Join( - target, - $".listenarr-move-{initialRequest.JobId:N}.pending"); - var writePath = CreateTruncatedMarkerWritePath( - markerPath, - initialRequest.JobId, - initialRequest.LeaseGeneration); - await File.WriteAllTextAsync( - writePath, - System.Text.Json.JsonSerializer.Serialize(new - { - Version = 1, - JobId = initialRequest.JobId, - Source = Path.GetFullPath(source), - Target = Path.GetFullPath(target), - Stage = "copy-complete" - })); - var request = await ReplaceMarkerTestLeaseAsync(initialRequest); - - var service = _provider.GetRequiredService(); - var recovered = await service.GetRecoverableMoveAsync( - request, - CancellationToken.None); - - Assert.NotNull(recovered); - Assert.False(recovered!.SourceCleanupCompleted); - Assert.True(File.Exists(markerPath)); - Assert.False(File.Exists(writePath)); - Assert.Contains("copy-complete", await File.ReadAllTextAsync(markerPath)); - - var completed = await service.ResumeSourceCleanupAsync( - request, - recovered, - CancellationToken.None); - Assert.True(completed.SourceCleanupCompleted); - Assert.False(Directory.Exists(source)); - Assert.True(File.Exists(Path.Join(target, "book.m4b"))); - } - - [Fact] - public async Task GetRecoverableMoveAsync_MultipleCompatiblePredecessorWrites_UsesLatestStage() - { - var source = FileService.GetTempDirectory("content-move-multiple-writes-src"); - var sourceFile = await FileService.GetFileAsync(source, "book.m4b", "audio"); - var target = FileService.GetTempDirectory("content-move-multiple-writes-dst"); - await FileService.GetFileAsync(target, "book.m4b", "audio"); - var initialRequest = await CreateLeasedMoveRequestAsync(source, target); - await PersistFileManifestAsync(initialRequest.JobId, "book.m4b", sourceFile); - var markerPath = Path.Join( - target, - $".listenarr-move-{initialRequest.JobId:N}.pending"); - var startedWrite = CreateTruncatedMarkerWritePath( - markerPath, - initialRequest.JobId, - initialRequest.LeaseGeneration); - var completedWrite = CreateTruncatedMarkerWritePath( - markerPath, - initialRequest.JobId, - initialRequest.LeaseGeneration); - await WriteStructuredRecoveryWriteAsync( - startedWrite, - initialRequest, - "copy-started"); - await WriteStructuredRecoveryWriteAsync( - completedWrite, - initialRequest, - "copy-complete"); - var request = await ReplaceMarkerTestLeaseAsync(initialRequest); - - var service = _provider.GetRequiredService(); - var recovered = await service.GetRecoverableMoveAsync( - request, - CancellationToken.None); - - Assert.NotNull(recovered); - Assert.False(recovered!.SourceCleanupCompleted); - Assert.False(File.Exists(startedWrite)); - Assert.False(File.Exists(completedWrite)); - Assert.Contains("copy-complete", await File.ReadAllTextAsync(markerPath)); - } - - [Fact] - public async Task MoveContentsAsync_CompleteTempRecoveryWrite_IsPublishedAndResumed() - { - var source = FileService.GetTempDirectory("content-move-complete-temp-write-src"); - await FileService.GetFileAsync(source, "book.m4b", "audio"); - var target = Path.Join( - FileService.GetTempPath(), - $"content-move-complete-temp-write-dst-{Guid.NewGuid():N}"); - var initialRequest = await CreateLeasedMoveRequestAsync(source, target); - var faultingService = new AudiobookContentMoveService( - _provider.GetRequiredService>(), - _provider.GetRequiredService>(), - TimeProvider.System, - new StopBeforeRecoveryMarkerPublication()); - - await Assert.ThrowsAsync(() => - faultingService.MoveContentsAsync(initialRequest, CancellationToken.None)); - - var tempDirectory = Path.Join( - Path.GetDirectoryName(target)!, - Path.GetFileName(target) + ".tmp-" + initialRequest.JobId.ToString("N")); - var markerPath = Path.Join( - tempDirectory, - $".listenarr-move-{initialRequest.JobId:N}.pending"); - var writePath = Assert.Single(Directory.EnumerateFiles( - tempDirectory, - Path.GetFileName(markerPath) + ".writing-*")); - Assert.False(File.Exists(markerPath)); - var request = await ReplaceMarkerTestLeaseAsync(initialRequest); - - var service = _provider.GetRequiredService(); - var result = await service.MoveContentsAsync(request, CancellationToken.None); - - Assert.True(result.SourceCleanupCompleted); - Assert.False(File.Exists(writePath)); - Assert.False(Directory.Exists(tempDirectory)); - Assert.False(Directory.Exists(source)); - Assert.True(File.Exists(Path.Join(target, "book.m4b"))); - } - - [Fact] - public async Task MoveContentsAsync_UnownedTempRecoveryWrite_IsPreservedWithoutPublication() - { - var source = FileService.GetTempDirectory("content-move-unowned-temp-write-src"); - await FileService.GetFileAsync(source, "book.m4b", "audio"); - var target = Path.Join( - FileService.GetTempPath(), - $"content-move-unowned-temp-write-dst-{Guid.NewGuid():N}"); - var request = await CreateLeasedMoveRequestAsync(source, target); - var tempDirectory = Path.Join( - Path.GetDirectoryName(target)!, - Path.GetFileName(target) + ".tmp-" + request.JobId.ToString("N")); - Directory.CreateDirectory(tempDirectory); - var markerPath = Path.Join( - tempDirectory, - $".listenarr-move-{request.JobId:N}.pending"); - var writePath = CreateTruncatedMarkerWritePath( - markerPath, - request.JobId, - request.LeaseGeneration); - await WriteStructuredRecoveryWriteAsync( - writePath, - request, - "copy-started"); - - var service = _provider.GetRequiredService(); - await Assert.ThrowsAsync(() => - service.MoveContentsAsync(request, CancellationToken.None)); - - Assert.True(File.Exists(writePath)); - Assert.False(File.Exists(markerPath)); - Assert.True(File.Exists(Path.Join(source, "book.m4b"))); - } - - [Fact] - public async Task MoveContentsAsync_TruncatedPredecessorTempOwnershipWrite_ReclaimsEmptyDirectory() - { - var source = FileService.GetTempDirectory("content-move-truncated-temp-src"); - await FileService.GetFileAsync(source, "book.m4b", "audio"); - var target = Path.Join( - FileService.GetTempPath(), - $"content-move-truncated-temp-dst-{Guid.NewGuid():N}"); - var initialRequest = await CreateLeasedMoveRequestAsync(source, target); - var request = await ReplaceMarkerTestLeaseAsync(initialRequest); - var tempDirectory = Path.Join( - Path.GetDirectoryName(target)!, - Path.GetFileName(target) + ".tmp-" + request.JobId.ToString("N")); - Directory.CreateDirectory(tempDirectory); - var markerPath = Path.Join(tempDirectory, ".listenarr-temp-owner.json"); - var writePath = CreateTruncatedMarkerWritePath( - markerPath, - request.JobId, - initialRequest.LeaseGeneration); - await File.WriteAllTextAsync(writePath, "{\"Version\":1"); - - var service = _provider.GetRequiredService(); - var result = await service.MoveContentsAsync(request, CancellationToken.None); - - Assert.True(result.SourceCleanupCompleted); - Assert.False(Directory.Exists(tempDirectory)); - Assert.True(File.Exists(Path.Join(target, "book.m4b"))); - } - - [Fact] - public async Task MoveContentsAsync_TruncatedPredecessorQuarantineOwnershipWrite_ReclaimsEmptyDirectory() - { - var source = FileService.GetTempDirectory("content-move-truncated-quarantine-src"); - await FileService.GetFileAsync(source, "book.m4b", "audio"); - var target = FileService.GetTempDirectory("content-move-truncated-quarantine-dst"); - var initialRequest = await CreateLeasedMoveRequestAsync(source, target); - var request = await ReplaceMarkerTestLeaseAsync(initialRequest); - var quarantineRoot = Path.Join( - Path.GetDirectoryName(source)!, - $".listenarr-quarantine-{request.JobId:N}"); - Directory.CreateDirectory(quarantineRoot); - var markerPath = Path.Join( - quarantineRoot, - ".listenarr-quarantine-owner.json"); - var writePath = CreateTruncatedMarkerWritePath( - markerPath, - request.JobId, - initialRequest.LeaseGeneration); - await File.WriteAllTextAsync(writePath, "{\"Version\":1"); - - var service = _provider.GetRequiredService(); - var result = await service.MoveContentsAsync(request, CancellationToken.None); - - Assert.True(result.SourceCleanupCompleted); - Assert.False(Directory.Exists(quarantineRoot)); - Assert.True(File.Exists(Path.Join(target, "book.m4b"))); - } - - [Fact] - public async Task ResumeSourceCleanup_TruncatedPredecessorTombstoneWrite_IsRepublished() - { - var source = FileService.GetTempDirectory("content-move-truncated-tombstone-src"); - await FileService.GetFileAsync(source, "book.m4b", "audio"); - var target = FileService.GetTempDirectory("content-move-truncated-tombstone-dst"); - var initialRequest = await CreateLeasedMoveRequestAsync(source, target); - var factory = _provider.GetRequiredService>(); - var faultingService = new AudiobookContentMoveService( - _provider.GetRequiredService>(), - factory, - TimeProvider.System, - new StopBeforeTombstonePublication()); - await Assert.ThrowsAsync(() => - faultingService.MoveContentsAsync(initialRequest, CancellationToken.None)); - - var sourceParent = Path.GetDirectoryName(source)!; - var quarantineRoot = Path.Join( - sourceParent, - $".listenarr-quarantine-{initialRequest.JobId:N}"); - var tombstonePath = Path.Join( - sourceParent, - $".listenarr-quarantine-directory-{initialRequest.JobId:N}.cleanup.json"); - Assert.True(File.Exists(Path.Join( - quarantineRoot, - ".listenarr-quarantine-owner.json"))); - Assert.False(File.Exists(tombstonePath)); - var writePath = CreateTruncatedMarkerWritePath( - tombstonePath, - initialRequest.JobId, - initialRequest.LeaseGeneration); - await File.WriteAllTextAsync(writePath, "{\"Version\":1"); - var request = await ReplaceMarkerTestLeaseAsync(initialRequest); - - var service = _provider.GetRequiredService(); - var recovered = await service.GetRecoverableMoveAsync( - request, - CancellationToken.None); - Assert.NotNull(recovered); - var completed = await service.ResumeSourceCleanupAsync( - request, - recovered!, - CancellationToken.None); - - Assert.True(completed.SourceCleanupCompleted); - Assert.False(Directory.Exists(quarantineRoot)); - Assert.False(File.Exists(tombstonePath)); - Assert.False(File.Exists(writePath)); - } - - [WindowsFact] - public async Task GetRecoverableMoveAsync_LockedAuthoritativeMarker_IsRetryableAndPreserved() - { - - var source = FileService.GetTempDirectory("content-move-locked-marker-src"); - var sourceFile = await FileService.GetFileAsync(source, "book.m4b", "audio"); - var target = FileService.GetTempDirectory("content-move-locked-marker-dst"); - await FileService.GetFileAsync(target, "book.m4b", "audio"); - var request = await CreateLeasedMoveRequestAsync(source, target); - await PersistFileManifestAsync(request.JobId, "book.m4b", sourceFile); - var markerPath = Path.Join( - target, - $".listenarr-move-{request.JobId:N}.pending"); - await File.WriteAllTextAsync( - markerPath, - System.Text.Json.JsonSerializer.Serialize(new - { - Version = 1, - JobId = request.JobId, - Source = Path.GetFullPath(source), - Target = Path.GetFullPath(target), - Stage = "copy-complete" - })); - await using var lockStream = new FileStream( - markerPath, - FileMode.Open, - FileAccess.ReadWrite, - FileShare.None); - var service = _provider.GetRequiredService(); - - var exception = await Assert.ThrowsAsync(() => - service.GetRecoverableMoveAsync(request, CancellationToken.None)); - - Assert.Contains("temporarily unreadable", exception.Message, StringComparison.OrdinalIgnoreCase); - Assert.True(File.Exists(markerPath)); - } - - [Fact] - public async Task GetRecoverableMoveAsync_OversizedAuthoritativeMarker_IsPreservedForReview() - { - var source = FileService.GetTempDirectory("content-move-oversized-marker-src"); - var sourceFile = await FileService.GetFileAsync(source, "book.m4b", "audio"); - var target = FileService.GetTempDirectory("content-move-oversized-marker-dst"); - await FileService.GetFileAsync(target, "book.m4b", "audio"); - var request = await CreateLeasedMoveRequestAsync(source, target); - await PersistFileManifestAsync(request.JobId, "book.m4b", sourceFile); - var markerPath = Path.Join( - target, - $".listenarr-move-{request.JobId:N}.pending"); - await File.WriteAllTextAsync(markerPath, new string('x', 70 * 1024)); - var service = _provider.GetRequiredService(); - - var exception = await Assert.ThrowsAsync(() => - service.GetRecoverableMoveAsync(request, CancellationToken.None)); - - Assert.Contains("supported size", exception.Message, StringComparison.OrdinalIgnoreCase); - Assert.True(File.Exists(markerPath)); - } - - [Fact] - public async Task GetRecoverableMoveAsync_UnsupportedAuthoritativeMarker_IsPreservedForReview() - { - var source = FileService.GetTempDirectory("content-move-unsupported-marker-src"); - var sourceFile = await FileService.GetFileAsync(source, "book.m4b", "audio"); - var target = FileService.GetTempDirectory("content-move-unsupported-marker-dst"); - await FileService.GetFileAsync(target, "book.m4b", "audio"); - var request = await CreateLeasedMoveRequestAsync(source, target); - await PersistFileManifestAsync(request.JobId, "book.m4b", sourceFile); - var markerPath = Path.Join( - target, - $".listenarr-move-{request.JobId:N}.pending"); - await File.WriteAllTextAsync( - markerPath, - System.Text.Json.JsonSerializer.Serialize(new - { - Version = 2, - JobId = request.JobId, - Source = Path.GetFullPath(source), - Target = Path.GetFullPath(target), - Stage = "copy-complete" - })); - var service = _provider.GetRequiredService(); - - var exception = await Assert.ThrowsAsync(() => - service.GetRecoverableMoveAsync(request, CancellationToken.None)); - - Assert.Contains("unsupported", exception.Message, StringComparison.OrdinalIgnoreCase); - Assert.True(File.Exists(markerPath)); - } - - [Fact] - public async Task GetRecoverableMoveAsync_UnsupportedPredecessorWrite_IsPreservedForReview() - { - var source = FileService.GetTempDirectory("content-move-unsupported-write-src"); - var sourceFile = await FileService.GetFileAsync(source, "book.m4b", "audio"); - var target = FileService.GetTempDirectory("content-move-unsupported-write-dst"); - await FileService.GetFileAsync(target, "book.m4b", "audio"); - var initialRequest = await CreateLeasedMoveRequestAsync(source, target); - await PersistFileManifestAsync(initialRequest.JobId, "book.m4b", sourceFile); - var markerPath = Path.Join( - target, - $".listenarr-move-{initialRequest.JobId:N}.pending"); - var writePath = CreateTruncatedMarkerWritePath( - markerPath, - initialRequest.JobId, - initialRequest.LeaseGeneration); - await File.WriteAllTextAsync( - writePath, - System.Text.Json.JsonSerializer.Serialize(new - { - Version = 2, - JobId = initialRequest.JobId, - Source = Path.GetFullPath(source), - Target = Path.GetFullPath(target), - Stage = "copy-complete" - })); - var request = await ReplaceMarkerTestLeaseAsync(initialRequest); - var service = _provider.GetRequiredService(); - - var exception = await Assert.ThrowsAsync(() => - service.GetRecoverableMoveAsync(request, CancellationToken.None)); - - Assert.Contains("unsupported", exception.Message, StringComparison.OrdinalIgnoreCase); - Assert.True(File.Exists(writePath)); - } - - private static Task WriteStructuredRecoveryWriteAsync( - string writePath, - AudiobookContentMoveRequest request, - string stage) => - File.WriteAllTextAsync( - writePath, - System.Text.Json.JsonSerializer.Serialize(new - { - Version = 1, - JobId = request.JobId, - Source = Path.GetFullPath(request.Source), - Target = Path.GetFullPath(request.Target), - Stage = stage - })); - - private async Task ReplaceMarkerTestLeaseAsync( - AudiobookContentMoveRequest request) - { - var factory = _provider.GetRequiredService>(); - await using var db = await factory.CreateDbContextAsync(); - var job = await db.MoveJobs.SingleAsync(candidate => candidate.Id == request.JobId); - job.LeaseOwner = "replacement-marker-worker"; - job.LeaseGeneration++; - job.LeaseExpiresAt = DateTime.UtcNow.AddMinutes(5); - await db.SaveChangesAsync(); - return request with - { - LeaseToken = new MoveLeaseToken( - job.LeaseOwner, - job.LeaseGeneration) - }; - } - - private static string CreateTruncatedMarkerWritePath( - string markerPath, - Guid jobId, - int leaseGeneration) => - markerPath - + $".writing-{jobId:N}-g{leaseGeneration}-{Guid.NewGuid():N}"; - - private sealed class ReplaceRecoveryWriteBeforeDeletion( - string writePath, - string displacedPath) : IMoveFaultInjector - { - private bool _replaced; - - public void OnRecoveryMarkerWrite( - Guid jobId, - RecoveryMarkerWriteFaultPoint faultPoint) - { - if (_replaced - || faultPoint != RecoveryMarkerWriteFaultPoint.BeforeTemporaryFileDeletion) - { - return; - } - - _replaced = true; - File.Move(writePath, displacedPath); - File.WriteAllText(writePath, "replacement"); - } - } - - private sealed class StopBeforeRecoveryMarkerPublication : IMoveFaultInjector - { - private bool _failed; - - public void OnRecoveryMarkerWrite( - Guid jobId, - RecoveryMarkerWriteFaultPoint faultPoint) - { - if (_failed || faultPoint != RecoveryMarkerWriteFaultPoint.BeforePublication) - { - return; - } - - _failed = true; - throw new MoveLeaseLostException(jobId, 1); - } - } - - private sealed class StopBeforeTombstonePublication : IMoveFaultInjector - { - private bool _failed; - - public void OnOwnershipMarkerWrite( - Guid jobId, - OwnershipMarkerKind markerKind, - OwnershipMarkerWriteFaultPoint faultPoint) - { - if (_failed - || markerKind != OwnershipMarkerKind.CleanupTombstone - || faultPoint != OwnershipMarkerWriteFaultPoint.BeforeTemporaryFileCreation) - { - return; - } - - _failed = true; - throw new MoveLeaseLostException(jobId, 1); - } - } -} diff --git a/tests/Features/Infrastructure/Library/Moving/DirectoryCreationParentReplacementTests.cs b/tests/Features/Infrastructure/Library/Moving/DirectoryCreationParentReplacementTests.cs index 5a1365c25..52691debf 100644 --- a/tests/Features/Infrastructure/Library/Moving/DirectoryCreationParentReplacementTests.cs +++ b/tests/Features/Infrastructure/Library/Moving/DirectoryCreationParentReplacementTests.cs @@ -39,11 +39,6 @@ public async Task EnsureCreatedHierarchyAsync_LinkedManagedBoundary_CreatesInsid Assert.Equal(2, created.Count); Assert.True(Directory.Exists(Path.Join(physicalBoundary, "Author", "Book"))); - Assert.False(File.Exists(Path.Join( - physicalBoundary, - "Author", - "Book", - ".listenarr-directory-owner.json"))); var resolution = await store.ResolveOwnedAsync( destination, semantics, @@ -196,9 +191,6 @@ await Assert.ThrowsAnyAsync(() => Assert.True(hookRan); Assert.Null(hookFailure); Assert.Empty(Directory.EnumerateFileSystemEntries(external)); - Assert.False(File.Exists(Path.Join( - external, - ".listenarr-directory-owner.json"))); Assert.False(Directory.Exists(Path.Join(external, "Book"))); var resolution = await store.ResolveOwnedAsync( destination, diff --git a/tests/Features/Infrastructure/Library/Moving/EfLibraryDirectoryOwnershipStoreTests.cs b/tests/Features/Infrastructure/Library/Moving/EfLibraryDirectoryOwnershipStoreTests.cs index 94572abc2..687a41444 100644 --- a/tests/Features/Infrastructure/Library/Moving/EfLibraryDirectoryOwnershipStoreTests.cs +++ b/tests/Features/Infrastructure/Library/Moving/EfLibraryDirectoryOwnershipStoreTests.cs @@ -1,6 +1,5 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Abstractions; -using System.Text.Json; using Listenarr.Tests.Common; @@ -102,7 +101,7 @@ public async Task BoundaryAuthorizer_ForeignPersistedUnixRoot_CannotAuthorizeWin } [Fact] - public async Task RecordCreatedAsync_PersistsIdentityWithoutPermanentFilesystemMarkers() + public async Task RecordCreatedAsync_PersistsDatabaseIdentity() { var directory = Path.Join(_root, "Author"); Directory.CreateDirectory(directory); @@ -118,7 +117,6 @@ public async Task RecordCreatedAsync_PersistsIdentityWithoutPermanentFilesystemM Assert.NotEqual(0, ownership.Id); Assert.False(string.IsNullOrWhiteSpace(ownership.PathOwnershipKey)); Assert.False(string.IsNullOrWhiteSpace(ownership.OwnershipToken)); - AssertNoPersistentOwnershipArtifacts(ownership); var resolution = await _store.ResolveOwnedAsync( directory, @@ -174,6 +172,43 @@ public async Task RecordCreatedAsync_InterruptedBeforeCommit_LeavesNoClaimOrArti Assert.Empty(await db.LibraryDirectoryOwnerships.ToListAsync()); } + [Fact] + public async Task RecordCreatedAsync_PathReplacedImmediatelyAfterCommit_DemotesCommittedOwnership() + { + var directory = Path.Join(_root, "ReplacedAfterCommit"); + var displaced = directory + ".original"; + Directory.CreateDirectory(directory); + using var cancellation = new CancellationTokenSource(); + _store.AfterOwnershipCommitForTest = () => + { + Directory.Move(directory, displaced); + Directory.CreateDirectory(directory); + cancellation.Cancel(); + }; + + var exception = await Assert.ThrowsAsync(() => + _store.RecordCreatedAsync( + new LibraryDirectoryOwnershipClaim( + directory, + FileSystemPathSemantics.CurrentHostDefault, + "test", + Guid.NewGuid(), + AudiobookId: 14), + cancellation.Token)); + + Assert.Contains( + "physical generation changed", + exception.Message, + StringComparison.OrdinalIgnoreCase); + await using var db = await _factory.CreateDbContextAsync(); + var persisted = await db.LibraryDirectoryOwnerships.SingleAsync(); + Assert.Equal(LibraryDirectoryOwnershipState.Unavailable, persisted.State); + Assert.Null(persisted.PathOwnershipKey); + Assert.NotNull(persisted.DirectoryObjectIdentityUnavailableReason); + Assert.True(Directory.Exists(displaced)); + Assert.True(Directory.Exists(directory)); + } + [Fact] public async Task RecordCreatedAsync_RetryAfterPreCommitInterruption_CreatesSingleClaim() { @@ -196,7 +231,6 @@ await Assert.ThrowsAsync(() => Assert.Equal(LibraryDirectoryOwnershipState.Owned, repaired.State); Assert.Null(repaired.DirectoryObjectIdentityUnavailableReason); - AssertNoPersistentOwnershipArtifacts(repaired); await using var verification = await _factory.CreateDbContextAsync(); Assert.Single(await verification.LibraryDirectoryOwnerships.ToListAsync()); } @@ -255,7 +289,7 @@ await _store.RecordCreatedAsync( } [Fact] - public async Task PhysicalPathReplacementWithoutMarkerFailsNativeGenerationValidation() + public async Task PhysicalPathReplacementFailsNativeGenerationValidation() { var directory = Path.Join(_root, "Author"); Directory.CreateDirectory(directory); @@ -264,9 +298,6 @@ await _store.RecordCreatedAsync( directory, FileSystemPathSemantics.CurrentHostDefault, "test")); - Assert.False(File.Exists(Path.Join( - directory, - LibraryDirectoryOwnershipMarker.FileName))); Directory.Delete(directory, recursive: false); Directory.CreateDirectory(directory); @@ -283,42 +314,8 @@ await _store.RecordCreatedAsync( StringComparison.OrdinalIgnoreCase); } - [Fact] - public async Task PhysicalPathReplacementWithCopiedMarkersFailsClosed() - { - var directory = Path.Join(_root, "ReplacedAuthor"); - Directory.CreateDirectory(directory); - var ownership = await _store.RecordCreatedAsync( - new LibraryDirectoryOwnershipClaim( - directory, - FileSystemPathSemantics.CurrentHostDefault, - "test")); - await PublishLegacyOwnershipMarkersAsync(ownership); - var insideMarker = Path.Join( - directory, - LibraryDirectoryOwnershipMarker.FileName); - var insidePayload = await File.ReadAllTextAsync(insideMarker); - - File.Delete(insideMarker); - Directory.Delete(directory); - Directory.CreateDirectory(directory); - await File.WriteAllTextAsync(insideMarker, insidePayload); - - var resolution = await _store.ResolveOwnedAsync( - directory, - FileSystemPathSemantics.CurrentHostDefault); - - Assert.Equal( - LibraryDirectoryOwnershipResolutionState.Unavailable, - resolution.State); - Assert.Contains( - "physical identity", - resolution.Reason, - StringComparison.OrdinalIgnoreCase); - } - [LinuxFact] - public async Task ResolveOwnedAsync_DirectoryReplacedAfterPhysicalIdentityPin_DoesNotMixMarkerGeneration() + public async Task ResolveOwnedAsync_DirectoryReplacedAfterPhysicalIdentityPin_FailsClosed() { var directory = Path.Join(_root, "PinnedGenerationReplacement"); var displacedDirectory = directory + ".original"; @@ -328,19 +325,6 @@ public async Task ResolveOwnedAsync_DirectoryReplacedAfterPhysicalIdentityPin_Do directory, FileSystemPathSemantics.CurrentHostDefault, "test")); - using (var parent = PinnedDirectoryCreation.OpenPinnedBoundary(_root)) - using (var publication = parent.OpenExistingChildForPublication( - Path.GetFileName(directory))) - { - await PinnedLibraryDirectoryOwnershipMarker.EnsureAsync( - ownership, - publication, - CancellationToken.None); - } - var insideMarker = Path.Join( - directory, - LibraryDirectoryOwnershipMarker.FileName); - var insidePayload = await File.ReadAllTextAsync(insideMarker); var replaced = false; _store.AfterOwnedDirectoryPhysicalIdentityPinnedForTest = () => { @@ -352,7 +336,6 @@ await PinnedLibraryDirectoryOwnershipMarker.EnsureAsync( replaced = true; Directory.Move(directory, displacedDirectory); Directory.CreateDirectory(directory); - File.WriteAllText(insideMarker, insidePayload); }; var resolution = await _store.ResolveOwnedAsync( @@ -368,7 +351,6 @@ await PinnedLibraryDirectoryOwnershipMarker.EnsureAsync( resolution.Reason, StringComparison.OrdinalIgnoreCase); Assert.True(Directory.Exists(displacedDirectory)); - Assert.Equal(insidePayload, await File.ReadAllTextAsync(insideMarker)); Assert.Null(resolution.Ownership); await using var verification = await _factory.CreateDbContextAsync(); var persisted = await verification.LibraryDirectoryOwnerships @@ -395,10 +377,6 @@ public async Task EnsureCreatedHierarchyAsync_ClaimsOnlyDirectoriesCreatedExclus _root, FileSystemPathSemantics.CurrentHostDefault); Assert.Equal(LibraryDirectoryOwnershipResolutionState.Unowned, rootResolution.State); - foreach (var ownership in ownerships) - { - AssertNoPersistentOwnershipArtifacts(ownership); - } } [Fact] @@ -422,13 +400,6 @@ public async Task EnrolledDestinationRemovedBeforePublication_IsNotRecreatedAndO ownership.CanonicalPath, destinationDirectory, FileSystemPathSemantics.CurrentHostDefault)); - var siblingMarker = LibraryDirectoryOwnershipMarker - .GetMarkerPaths(destinationOwnership) - .Single(marker => !FileSystemPathIdentity.IsSameOrInside( - marker, - destinationDirectory, - FileSystemPathSemantics.CurrentHostDefault)); - Assert.False(File.Exists(siblingMarker)); Directory.Delete(destinationDirectory, recursive: true); var mover = new FileMover( new NullLogger(), @@ -440,7 +411,6 @@ public async Task EnrolledDestinationRemovedBeforePublication_IsNotRecreatedAndO Assert.True(File.Exists(source)); Assert.False(Directory.Exists(destinationDirectory)); Assert.False(File.Exists(destination)); - Assert.False(File.Exists(siblingMarker)); var resolution = await _store.ResolveOwnedAsync( destinationDirectory, FileSystemPathSemantics.CurrentHostDefault); @@ -525,7 +495,6 @@ public async Task EnsureCreatedHierarchyAsync_CancellationAfterExclusiveCreation Assert.True(cancellation.IsCancellationRequested); var ownership = Assert.Single(ownerships); - AssertNoPersistentOwnershipArtifacts(ownership); var resolution = await _store.ResolveOwnedAsync( destination, FileSystemPathSemantics.CurrentHostDefault); @@ -534,7 +503,7 @@ public async Task EnsureCreatedHierarchyAsync_CancellationAfterExclusiveCreation } [Fact] - public async Task EnsureCreatedHierarchyAsync_ExistingDurableClaimDoesNotRequireMarkers() + public async Task EnsureCreatedHierarchyAsync_ExistingDurableClaimResolvesFromDatabaseState() { var destination = Path.Join(_root, "Author", "Book"); var ownerships = await _store.EnsureCreatedHierarchyAsync( @@ -547,7 +516,6 @@ public async Task EnsureCreatedHierarchyAsync_ExistingDurableClaimDoesNotRequire item.CanonicalPath, destination, FileSystemPathSemantics.CurrentHostDefault)); - AssertNoPersistentOwnershipArtifacts(ownership); var repaired = await _store.EnsureCreatedHierarchyAsync( destination, @@ -556,7 +524,6 @@ public async Task EnsureCreatedHierarchyAsync_ExistingDurableClaimDoesNotRequire "test-retry"); Assert.Empty(repaired); - AssertNoPersistentOwnershipArtifacts(ownership); var resolution = await _store.ResolveOwnedAsync( destination, FileSystemPathSemantics.CurrentHostDefault); @@ -614,7 +581,6 @@ public async Task RemovingDirectory_CanCompleteAfterDirectoryDeletionAndRestart( var ownershipKey = Assert.IsType(ownership.PathOwnershipKey); await _store.BeginRemovalAsync(ownership.Id, ownershipKey); - AssertNoPersistentOwnershipArtifacts(ownership); Directory.Delete(directory, recursive: false); var restartedStore = new EfLibraryDirectoryOwnershipStore(_factory, TimeProvider.System); @@ -624,29 +590,14 @@ public async Task RemovingDirectory_CanCompleteAfterDirectoryDeletionAndRestart( var removing = Assert.IsType(resolution.Ownership); Assert.Equal(LibraryDirectoryOwnershipState.Removing, removing.State); await restartedStore.MarkRemovedAsync(removing.Id, ownershipKey); - await using (var evidenceDb = await _factory.CreateDbContextAsync()) + await using (var verificationDb = await _factory.CreateDbContextAsync()) { - var retired = await evidenceDb.LibraryDirectoryOwnerships + var retired = await verificationDb.LibraryDirectoryOwnerships .SingleAsync(candidate => candidate.Id == removing.Id); - var evidence = await evidenceDb - .LibraryDirectoryOwnershipRetiredMarkers - .SingleAsync(candidate => - candidate.OwnershipId == removing.Id); Assert.Null(retired.ManagedRootFolderId); Assert.Null(retired.PathOwnershipKey); - Assert.Equal( - LibraryDirectoryOwnershipRetiredMarkerState.Pending, - evidence.State); - Assert.False(string.IsNullOrWhiteSpace( - evidence.CanonicalPayload)); - Assert.False(string.IsNullOrWhiteSpace( - evidence.PayloadSha256)); - Assert.Equal(ownership.ManagedRootFolderId, - evidence.OriginalManagedRootFolderId); + Assert.Equal(LibraryDirectoryOwnershipState.Removed, retired.State); } - Assert.True(LibraryDirectoryOwnershipMarker.TryDeleteRetiredSiblingMarker( - removing, - out var markerDeleteReason), markerDeleteReason); var removed = await restartedStore.ResolveOwnedAsync( directory, @@ -654,78 +605,6 @@ public async Task RemovingDirectory_CanCompleteAfterDirectoryDeletionAndRestart( Assert.Equal(LibraryDirectoryOwnershipResolutionState.Unowned, removed.State); } - [Fact] - public async Task RecordCreatedAsync_RemovesRetiredSiblingMarkerFromPriorOwnership() - { - var directory = Path.Join(_root, "Recreated"); - Directory.CreateDirectory(directory); - var prior = await _store.RecordCreatedAsync( - new LibraryDirectoryOwnershipClaim( - directory, - FileSystemPathSemantics.CurrentHostDefault, - "test")); - var ownershipKey = Assert.IsType(prior.PathOwnershipKey); - await PublishLegacyOwnershipMarkersAsync(prior); - var retiredSiblingMarker = LibraryDirectoryOwnershipMarker.GetMarkerPaths(prior) - .Single(path => !FileSystemPathIdentity.IsSameOrInside( - path, - directory, - FileSystemPathSemantics.CurrentHostDefault)); - - await _store.BeginRemovalAsync(prior.Id, ownershipKey); - LibraryDirectoryOwnershipMarker.DeleteInsideMarker(prior, directory); - Directory.Delete(directory, recursive: false); - await _store.MarkRemovedAsync(prior.Id, ownershipKey); - Assert.True(File.Exists(retiredSiblingMarker)); - await CreateOwnershipReconciler().ReconcileAsync(); - Assert.False(File.Exists(retiredSiblingMarker)); - Directory.CreateDirectory(directory); - - var recreated = await _store.RecordCreatedAsync( - new LibraryDirectoryOwnershipClaim( - directory, - FileSystemPathSemantics.CurrentHostDefault, - "test-recreated")); - - Assert.NotEqual(prior.Id, recreated.Id); - Assert.NotEqual(prior.OwnershipToken, recreated.OwnershipToken); - Assert.False(File.Exists(retiredSiblingMarker)); - AssertNoPersistentOwnershipArtifacts(recreated); - } - - [Fact] - public async Task RemovalPath_InvalidOwnershipTokenCannotEscapeParent() - { - var directory = Path.Join(_root, "InvalidToken"); - Directory.CreateDirectory(directory); - var ownership = await _store.RecordCreatedAsync( - new LibraryDirectoryOwnershipClaim( - directory, - FileSystemPathSemantics.CurrentHostDefault, - "test")); - var insideMarker = Path.Join( - directory, - LibraryDirectoryOwnershipMarker.FileName); - Assert.False(File.Exists(insideMarker)); - ownership.OwnershipToken = $"..{Path.DirectorySeparatorChar}outside"; - - var quarantineException = Assert.Throws(() => - LibraryDirectoryOwnershipRemoval.GetQuarantinePath(ownership)); - using var publication = PinnedDirectoryCreation.OpenExistingForPublication( - Path.GetDirectoryName(directory)!, - Path.GetFileName(directory)); - var markerException = await Assert.ThrowsAsync(() => - PinnedLibraryDirectoryOwnershipMarker.EnsureAsync( - ownership, - publication, - CancellationToken.None)); - - Assert.Contains("token is invalid", quarantineException.Message, StringComparison.OrdinalIgnoreCase); - Assert.Contains("token is invalid", markerException.Message, StringComparison.OrdinalIgnoreCase); - Assert.False(File.Exists(insideMarker)); - Assert.True(Directory.Exists(directory)); - } - [Fact] public async Task RemovalPath_FileReplacementAtOriginalPathFailsClosed() { @@ -750,60 +629,6 @@ public async Task RemovalPath_FileReplacementAtOriginalPathFailsClosed() Assert.Equal("user file", await File.ReadAllTextAsync(directory)); } - [Fact] - public async Task RemovalPath_FileAtQuarantinePathFailsClosed() - { - var directory = Path.Join(_root, "QuarantineFileReplacement"); - Directory.CreateDirectory(directory); - var ownership = await _store.RecordCreatedAsync( - new LibraryDirectoryOwnershipClaim( - directory, - FileSystemPathSemantics.CurrentHostDefault, - "test")); - var ownershipKey = Assert.IsType(ownership.PathOwnershipKey); - await _store.BeginRemovalAsync(ownership.Id, ownershipKey); - ownership.State = LibraryDirectoryOwnershipState.Removing; - Directory.Delete(directory, recursive: false); - var quarantinePath = LibraryDirectoryOwnershipRemoval.GetQuarantinePath(ownership); - await File.WriteAllTextAsync(quarantinePath, "foreign file"); - - using var parent = PinnedDirectoryCreation.OpenPinnedDirectoryNoFollow(_root); - var exception = Assert.Throws(() => - LibraryDirectoryOwnershipRemoval.RemoveEmptyDirectory(ownership, parent)); - - Assert.Contains("occupied by a file", exception.Message, StringComparison.OrdinalIgnoreCase); - Assert.Equal("foreign file", await File.ReadAllTextAsync(quarantinePath)); - } - - [Fact] - public async Task RemovalPath_EmptyQuarantineAfterInsideMarkerRetirementCompletes() - { - var directory = Path.Join(_root, "InterruptedAfterMarkerRetirement"); - Directory.CreateDirectory(directory); - var ownership = await _store.RecordCreatedAsync( - new LibraryDirectoryOwnershipClaim( - directory, - FileSystemPathSemantics.CurrentHostDefault, - "test")); - var ownershipKey = Assert.IsType(ownership.PathOwnershipKey); - await _store.BeginRemovalAsync(ownership.Id, ownershipKey); - ownership.State = LibraryDirectoryOwnershipState.Removing; - var quarantinePath = - LibraryDirectoryOwnershipRemoval.GetQuarantinePath(ownership); - Directory.Move(directory, quarantinePath); - - LibraryDirectoryOwnershipRemoval.ValidateRecoverableState(ownership); - using var parent = - PinnedDirectoryCreation.OpenPinnedDirectoryNoFollow(_root); - var outcome = LibraryDirectoryOwnershipRemoval.RemoveEmptyDirectory( - ownership, - parent); - - Assert.Equal(LibraryDirectoryRemovalOutcome.Removed, outcome); - Assert.False(Directory.Exists(quarantinePath)); - AssertNoPersistentOwnershipArtifacts(ownership); - } - [Fact] public async Task RecordCreatedAsync_CorruptRemovedIdentityDoesNotBlockNewClaim() { @@ -835,397 +660,158 @@ public async Task RecordCreatedAsync_CorruptRemovedIdentityDoesNotBlockNewClaim( "test-recreated")); Assert.NotEqual(prior.Id, recreated.Id); - AssertNoPersistentOwnershipArtifacts(recreated); } [Fact] - public async Task Reconciler_TransientRootOutage_PreservesAndRecoversClaim() + public async Task MarkerlessReplacement_PathReplacedImmediatelyAfterCommit_PersistsUnavailableBlocker() { - var directory = Path.Join(_root, "TransientOutage"); + var directory = Path.Join(_root, "MarkerlessReplacementCommitRace"); + var displacedStale = directory + ".stale"; + var displacedReplacement = directory + ".replacement"; Directory.CreateDirectory(directory); - var ownership = await _store.RecordCreatedAsync( + var stale = await _store.RecordCreatedAsync( new LibraryDirectoryOwnershipClaim( directory, FileSystemPathSemantics.CurrentHostDefault, - "test")); - var ownershipKey = Assert.IsType(ownership.PathOwnershipKey); - var unavailableRoot = $"{_root}-offline"; - Directory.Move(_root, unavailableRoot); - var reconciler = new LibraryDirectoryOwnershipReconciler( - _factory, - new LibraryDirectoryOwnershipBoundaryAuthorizer(_factory), - new FilesystemMutationCoordinator(), - NullLogger.Instance); - - await reconciler.ReconcileAsync(); + "test-stale")); + Directory.Move(directory, displacedStale); + Directory.CreateDirectory(directory); + string replacementIdentity; + using (var replacement = PinnedDirectoryCreation.OpenPinnedBoundary(directory)) + { + replacementIdentity = replacement.GetDirectoryObjectIdentity(); + } + Guid moveJobId; await using (var db = await _factory.CreateDbContextAsync()) { - var unavailable = await db.LibraryDirectoryOwnerships.SingleAsync(); - Assert.Equal( - LibraryDirectoryOwnershipState.Owned, - unavailable.State); - Assert.Equal(ownershipKey, unavailable.PathOwnershipKey); - Assert.False(string.IsNullOrWhiteSpace( - unavailable.DirectoryObjectIdentityUnavailableReason)); + var audiobook = new Audiobook + { + Title = "Markerless replacement commit race", + BasePath = displacedStale + }; + db.Audiobooks.Add(audiobook); + await db.SaveChangesAsync(); + var move = new MoveJob + { + Id = Guid.NewGuid(), + AudiobookId = audiobook.Id, + SourcePath = displacedStale, + RequestedPath = directory, + ExecutionProtocolVersion = MoveExecutionProtocol.Current, + Status = MoveJobStatus.Running, + TargetDirectoryObjectIdentity = replacementIdentity + }; + move.CreatedDirectories.Add(new MoveJobCreatedDirectory + { + Path = directory, + State = MoveCreatedDirectoryState.Created, + DirectoryObjectIdentity = replacementIdentity + }); + db.MoveJobs.Add(move); + await db.SaveChangesAsync(); + moveJobId = move.Id; } - Directory.Move(unavailableRoot, _root); - await reconciler.ReconcileAsync(); - - await using var verification = await _factory.CreateDbContextAsync(); - var recovered = await verification.LibraryDirectoryOwnerships.SingleAsync(); - Assert.Equal(LibraryDirectoryOwnershipState.Owned, recovered.State); - Assert.Equal(ownershipKey, recovered.PathOwnershipKey); - Assert.Null(recovered.DirectoryObjectIdentityUnavailableReason); - Assert.Null(recovered.StateReason); - } + _store.AfterMarkerlessReplacementCommitForTest = () => + { + Directory.Move(directory, displacedReplacement); + Directory.Move(displacedStale, directory); + }; - [Fact] - public async Task Reconciler_MissingRemovingDirectoryConvergesWithoutMarkerProof() - { - var directory = Path.Join(_root, "SiblingOnlyRemoval"); - Directory.CreateDirectory(directory); - var ownership = await _store.RecordCreatedAsync( - new LibraryDirectoryOwnershipClaim( + var exception = await Assert.ThrowsAsync(() => + _store.TryRetireReplacedByMarkerlessMoveAsync( directory, FileSystemPathSemantics.CurrentHostDefault, - "test")); - var ownershipKey = Assert.IsType(ownership.PathOwnershipKey); - await _store.BeginRemovalAsync(ownership.Id, ownershipKey); - Directory.Delete(directory); - var reconciler = new LibraryDirectoryOwnershipReconciler( - _factory, - new LibraryDirectoryOwnershipBoundaryAuthorizer(_factory), - new FilesystemMutationCoordinator(), - NullLogger.Instance); - - await reconciler.ReconcileAsync(); + moveJobId, + replacementIdentity)); + Assert.Contains("changed physical generation", exception.Message, StringComparison.OrdinalIgnoreCase); await using var verification = await _factory.CreateDbContextAsync(); - var persisted = await verification.LibraryDirectoryOwnerships.SingleAsync(); - Assert.Equal( - LibraryDirectoryOwnershipState.Removed, - persisted.State); + var persisted = await verification.LibraryDirectoryOwnerships + .SingleAsync(candidate => candidate.Id == stale.Id); + Assert.Equal(LibraryDirectoryOwnershipState.Unavailable, persisted.State); Assert.Null(persisted.PathOwnershipKey); - AssertNoPersistentOwnershipArtifacts(persisted); + Assert.NotNull(persisted.ManagedRootFolderId); + Assert.False(string.IsNullOrWhiteSpace(persisted.StateReason)); + var resolution = await _store.ResolveOwnedAsync( + directory, + FileSystemPathSemantics.CurrentHostDefault); + Assert.Equal( + LibraryDirectoryOwnershipResolutionState.Unavailable, + resolution.State); + Assert.True(Directory.Exists(directory)); + Assert.True(Directory.Exists(displacedReplacement)); } [Fact] - public async Task Reconciler_LegacyRemovedRowWithoutEvidence_BackfillsAndRetiresSiblingMarker() + public async Task MarkerlessReplacement_UnknownFutureProtocol_CannotRetireOwnership() { - var directory = Path.Join(_root, "LegacyRemovedWithoutEvidence"); + var directory = Path.Join(_root, "MarkerlessReplacementFutureProtocol"); + var displacedStale = directory + ".stale"; Directory.CreateDirectory(directory); - var ownership = await _store.RecordCreatedAsync( + var stale = await _store.RecordCreatedAsync( new LibraryDirectoryOwnershipClaim( directory, FileSystemPathSemantics.CurrentHostDefault, - "legacy-test")); - var originalRootId = Assert.IsType(ownership.ManagedRootFolderId); - var ownershipKey = Assert.IsType(ownership.PathOwnershipKey); - var siblingMarker = LibraryDirectoryOwnershipMarker - .GetMarkerPaths(ownership)[1]; - const string originalStateReason = - "Legacy cleanup completed.\nPreserve this diagnostic."; - - await PublishLegacyOwnershipMarkersAsync(ownership); - await _store.BeginRemovalAsync(ownership.Id, ownershipKey); - LibraryDirectoryOwnershipMarker.DeleteInsideMarker(ownership, directory); - Directory.Delete(directory); - await using (var db = await _factory.CreateDbContextAsync()) - { - var legacy = await db.LibraryDirectoryOwnerships.SingleAsync( - candidate => candidate.Id == ownership.Id); - legacy.State = LibraryDirectoryOwnershipState.Removed; - legacy.PathOwnershipKey = null; - legacy.ManagedRootFolderId = null; - legacy.StateReason = - LibraryDirectoryOwnershipMigrationPreflight - .CreateLegacyRemovedRootStateReason( - originalRootId, - originalStateReason); - await db.SaveChangesAsync(); - Assert.Empty(await db.LibraryDirectoryOwnershipRetiredMarkers - .Where(marker => marker.OwnershipId == ownership.Id) - .ToListAsync()); - } - Assert.True(File.Exists(siblingMarker)); - - await CreateOwnershipReconciler().ReconcileAsync(); - - await using (var verification = await _factory.CreateDbContextAsync()) - { - var persisted = await verification.LibraryDirectoryOwnerships - .SingleAsync(candidate => candidate.Id == ownership.Id); - Assert.Equal(LibraryDirectoryOwnershipState.Removed, persisted.State); - Assert.Null(persisted.ManagedRootFolderId); - Assert.Equal(originalStateReason, persisted.StateReason); - var evidence = await verification - .LibraryDirectoryOwnershipRetiredMarkers - .SingleAsync(marker => marker.OwnershipId == ownership.Id); - Assert.Equal(originalRootId, evidence.OriginalManagedRootFolderId); - Assert.Equal( - LibraryDirectoryOwnershipMarker.Version, - evidence.PayloadVersion); - Assert.Equal( - LibraryDirectoryOwnershipRetiredMarkerState.Removed, - evidence.State); - } - Assert.False(File.Exists(siblingMarker)); - - await using (var interruptedRace = await _factory.CreateDbContextAsync()) + "test-stale")); + Directory.Move(directory, displacedStale); + Directory.CreateDirectory(directory); + string replacementIdentity; + using (var replacement = PinnedDirectoryCreation.OpenPinnedBoundary(directory)) { - var persisted = await interruptedRace.LibraryDirectoryOwnerships - .SingleAsync(candidate => candidate.Id == ownership.Id); - persisted.StateReason = - LibraryDirectoryOwnershipMigrationPreflight - .CreateLegacyRemovedRootStateReason( - originalRootId, - originalStateReason); - await interruptedRace.SaveChangesAsync(); + replacementIdentity = replacement.GetDirectoryObjectIdentity(); } - await CreateOwnershipReconciler().ReconcileAsync(); - await using var repeated = await _factory.CreateDbContextAsync(); - Assert.Equal( - originalStateReason, - (await repeated.LibraryDirectoryOwnerships - .SingleAsync(candidate => candidate.Id == ownership.Id)).StateReason); - Assert.Single(await repeated.LibraryDirectoryOwnershipRetiredMarkers - .Where(marker => marker.OwnershipId == ownership.Id) - .ToListAsync()); - } - - [Fact] - public async Task Reconciler_LegacyMissingBothProofMarksOnlyDatabaseRowRemoved() - { - var fixture = await PrepareLegacyMissingBothAsync("LegacyMissingBoth"); - var reconciler = CreateOwnershipReconciler(); - - await reconciler.ReconcileAsync(); - - await using var verification = await _factory.CreateDbContextAsync(); - var persisted = await verification.LibraryDirectoryOwnerships - .SingleAsync(candidate => candidate.Id == fixture.Ownership.Id); - Assert.Equal(LibraryDirectoryOwnershipState.Removed, persisted.State); - Assert.Null(persisted.PathOwnershipKey); - Assert.True(File.Exists(fixture.SiblingMarkerPath)); - Assert.False(Directory.Exists(fixture.DirectoryPath)); - Assert.False(Directory.Exists(fixture.QuarantinePath)); - - await CreateOwnershipReconciler().ReconcileAsync(); - Assert.False(File.Exists(fixture.SiblingMarkerPath)); - var evidence = await verification - .LibraryDirectoryOwnershipRetiredMarkers - .SingleAsync(candidate => - candidate.OwnershipId == fixture.Ownership.Id); - Assert.Equal( - LibraryDirectoryOwnershipRetiredMarkerState.Removed, - evidence.State); - } - - [Fact] - public async Task Reconciler_LegacyMissingBothCorruptMarker_PreservesArtifactAndConvergesRemoval() - { - var fixture = await PrepareLegacyMissingBothAsync( - "LegacyMissingBothCorrupt"); - File.SetAttributes( - fixture.SiblingMarkerPath, - FileAttributes.Normal); - await File.WriteAllTextAsync(fixture.SiblingMarkerPath, "{invalid"); - - await CreateOwnershipReconciler().ReconcileAsync(); - - await AssertRemovalConvergedWithPreservedLegacyArtifactAsync(fixture); - } - - [Fact] - public async Task Reconciler_LegacyMissingBothWrongToken_PreservesArtifactAndConvergesRemoval() - { - var fixture = await PrepareLegacyMissingBothAsync( - "LegacyMissingBothWrongToken"); - File.SetAttributes( - fixture.SiblingMarkerPath, - FileAttributes.Normal); - await File.WriteAllTextAsync( - fixture.SiblingMarkerPath, - System.Text.Json.JsonSerializer.Serialize( - new LibraryDirectoryOwnershipMarker.MarkerPayload( - 1, - Guid.NewGuid().ToString("N"), - fixture.Ownership.CanonicalPath))); - - await CreateOwnershipReconciler().ReconcileAsync(); - - await AssertRemovalConvergedWithPreservedLegacyArtifactAsync(fixture); - } - - [Fact] - public async Task Reconciler_PreUpgradeLegacyMissingBothWithoutV2IdentityMarksRemoved() - { - var fixture = await PrepareLegacyMissingBothAsync( - "LegacyMissingBothNoIdentity"); + Guid moveJobId; await using (var db = await _factory.CreateDbContextAsync()) { - var persisted = await db.LibraryDirectoryOwnerships - .SingleAsync(candidate => - candidate.Id == fixture.Ownership.Id); - persisted.ManagedRootFolderId = null; - persisted.DirectoryObjectIdentityVersion = null; - persisted.DirectoryObjectIdentity = null; - persisted.DirectoryObjectIdentityUnavailableReason = null; + var audiobook = new Audiobook + { + Title = "Markerless replacement future protocol", + BasePath = displacedStale + }; + db.Audiobooks.Add(audiobook); await db.SaveChangesAsync(); + var move = new MoveJob + { + Id = Guid.NewGuid(), + AudiobookId = audiobook.Id, + SourcePath = displacedStale, + RequestedPath = directory, + ExecutionProtocolVersion = MoveExecutionProtocol.Current + 1, + Status = MoveJobStatus.Running, + TargetDirectoryObjectIdentity = replacementIdentity + }; + move.CreatedDirectories.Add(new MoveJobCreatedDirectory + { + Path = directory, + State = MoveCreatedDirectoryState.Created, + DirectoryObjectIdentity = replacementIdentity + }); + db.MoveJobs.Add(move); + await db.SaveChangesAsync(); + moveJobId = move.Id; } - await CreateOwnershipReconciler().ReconcileAsync(); - - await using var verification = await _factory.CreateDbContextAsync(); - var recovered = await verification.LibraryDirectoryOwnerships - .SingleAsync(candidate => - candidate.Id == fixture.Ownership.Id); - Assert.Equal(LibraryDirectoryOwnershipState.Removed, recovered.State); - Assert.Null(recovered.PathOwnershipKey); - Assert.True(File.Exists(fixture.SiblingMarkerPath)); - } - - [Fact] - public async Task Reconciler_PredecessorDisplacedBeforeUpgradePublication_CompletesInOnePass() - { - var directory = Path.Join(_root, "UpgradePredecessorDisplaced"); - Directory.CreateDirectory(directory); - var ownership = await _store.RecordCreatedAsync( - new LibraryDirectoryOwnershipClaim( - directory, - FileSystemPathSemantics.CurrentHostDefault, - "test")); - await PublishLegacyOwnershipMarkersAsync(ownership); - var markerPath = Path.Join( - directory, - LibraryDirectoryOwnershipMarker.FileName); - var currentPayload = await File.ReadAllTextAsync(markerPath); - File.SetAttributes(markerPath, FileAttributes.Normal); - await File.WriteAllTextAsync( - markerPath, - JsonSerializer.Serialize( - new LibraryDirectoryOwnershipMarker.MarkerPayload( - 1, - ownership.OwnershipToken, - ownership.CanonicalPath))); - var backupPath = Path.Join( + var retired = await _store.TryRetireReplacedByMarkerlessMoveAsync( directory, - PinnedDirectoryCreation.GetConditionalReplacementBackupName( - LibraryDirectoryOwnershipMarker.FileName)); - File.Move(markerPath, backupPath); - var temporaryPath = markerPath + ".v2.tmp"; - await File.WriteAllTextAsync(temporaryPath, currentPayload); - - await CreateOwnershipReconciler().ReconcileAsync(); - - Assert.False(File.Exists(backupPath)); - Assert.False(File.Exists(temporaryPath)); - AssertNoPersistentOwnershipArtifacts(ownership); - } - - [Fact] - public async Task Reconciler_UpgradePublishedBeforePredecessorCleanup_RetiresBackupInOnePass() - { - var directory = Path.Join(_root, "UpgradePublishedBackupRetained"); - Directory.CreateDirectory(directory); - var ownership = await _store.RecordCreatedAsync( - new LibraryDirectoryOwnershipClaim( - directory, - FileSystemPathSemantics.CurrentHostDefault, - "test")); - await PublishLegacyOwnershipMarkersAsync(ownership); - var backupPath = Path.Join( - directory, - PinnedDirectoryCreation.GetConditionalReplacementBackupName( - LibraryDirectoryOwnershipMarker.FileName)); - await File.WriteAllTextAsync( - backupPath, - JsonSerializer.Serialize( - new LibraryDirectoryOwnershipMarker.MarkerPayload( - 1, - ownership.OwnershipToken, - ownership.CanonicalPath))); - - await CreateOwnershipReconciler().ReconcileAsync(); - - Assert.False(File.Exists(backupPath)); - AssertNoPersistentOwnershipArtifacts(ownership); - } + FileSystemPathSemantics.CurrentHostDefault, + moveJobId, + replacementIdentity); - [Fact] - public async Task Reconciler_CurrentMarkerWithDisplacedLegacyTemporary_RetiresCrashArtifact() - { - var directory = Path.Join(_root, "CompletedLegacyUpgrade"); - Directory.CreateDirectory(directory); - var ownership = await _store.RecordCreatedAsync( - new LibraryDirectoryOwnershipClaim( - directory, - FileSystemPathSemantics.CurrentHostDefault, - "test")); - await PublishLegacyOwnershipMarkersAsync(ownership); - var temporaryPath = Path.Join( - directory, - LibraryDirectoryOwnershipMarker.FileName + ".v2.tmp"); - await File.WriteAllTextAsync( - temporaryPath, - LibraryDirectoryOwnershipMarker.SerializePayload( - new LibraryDirectoryOwnershipMarker.MarkerPayload( - 1, - ownership.OwnershipToken, - ownership.CanonicalPath))); - - await CreateOwnershipReconciler().ReconcileAsync(); - - Assert.False(File.Exists(temporaryPath)); - AssertNoPersistentOwnershipArtifacts(ownership); + Assert.False(retired); await using var verification = await _factory.CreateDbContextAsync(); var persisted = await verification.LibraryDirectoryOwnerships - .SingleAsync(candidate => candidate.Id == ownership.Id); + .SingleAsync(candidate => candidate.Id == stale.Id); Assert.Equal(LibraryDirectoryOwnershipState.Owned, persisted.State); + Assert.False(string.IsNullOrWhiteSpace(persisted.PathOwnershipKey)); } [Fact] - public async Task Reconciler_LegacyMissingBothMixedUpgradeMarkers_PreservesArtifactsAndConvergesRemoval() - { - var fixture = await PrepareLegacyMissingBothAsync( - "LegacyMissingBothMixed"); - await File.WriteAllTextAsync( - fixture.SiblingMarkerPath + ".v2.tmp", - LibraryDirectoryOwnershipMarker.SerializePayload( - fixture.Ownership)); - - await CreateOwnershipReconciler().ReconcileAsync(); - - await AssertRemovalConvergedWithPreservedLegacyArtifactAsync(fixture); - Assert.True(File.Exists(fixture.SiblingMarkerPath + ".v2.tmp")); - } - - [Fact] - public async Task Reconciler_LegacyMissingBothReplacementDirectoryFailsClosed() - { - var fixture = await PrepareLegacyMissingBothAsync( - "LegacyMissingBothReplacement"); - Directory.CreateDirectory(fixture.DirectoryPath); - await File.WriteAllTextAsync( - Path.Join(fixture.DirectoryPath, "foreign.txt"), - "user content"); - - await CreateOwnershipReconciler().ReconcileAsync(); - - await AssertLegacyRecoveryRejectedAsync(fixture); - Assert.Equal( - "user content", - await File.ReadAllTextAsync( - Path.Join(fixture.DirectoryPath, "foreign.txt"))); - } - - [WindowsFact] - public async Task Reconciler_ForeignRetiredMarkerPath_DoesNotDeleteWindowsAlias() + public async Task Reconciler_TransientRootOutage_PreservesAndRecoversClaim() { - var directory = Path.Join(_root, "ForeignRetiredMarkerPath"); + var directory = Path.Join(_root, "TransientOutage"); Directory.CreateDirectory(directory); var ownership = await _store.RecordCreatedAsync( new LibraryDirectoryOwnershipClaim( @@ -1233,41 +819,42 @@ public async Task Reconciler_ForeignRetiredMarkerPath_DoesNotDeleteWindowsAlias( FileSystemPathSemantics.CurrentHostDefault, "test")); var ownershipKey = Assert.IsType(ownership.PathOwnershipKey); - await PublishLegacyOwnershipMarkersAsync(ownership); - await _store.BeginRemovalAsync(ownership.Id, ownershipKey); - LibraryDirectoryOwnershipMarker.DeleteInsideMarker(ownership, directory); - Directory.Delete(directory); - await _store.MarkRemovedAsync(ownership.Id, ownershipKey); + var unavailableRoot = $"{_root}-offline"; + Directory.Move(_root, unavailableRoot); + var reconciler = new LibraryDirectoryOwnershipReconciler( + _factory, + new LibraryDirectoryOwnershipBoundaryAuthorizer(_factory), + new FilesystemMutationCoordinator(), + NullLogger.Instance); - var siblingMarkerPath = - LibraryDirectoryOwnershipMarker.GetMarkerPaths(ownership)[1]; - Assert.True(File.Exists(siblingMarkerPath)); - var foreignMarkerPath = WindowsPathTestFixture - .GetRootRelativeForeignAlias(siblingMarkerPath); + await reconciler.ReconcileAsync(); await using (var db = await _factory.CreateDbContextAsync()) { - var evidence = await db.LibraryDirectoryOwnershipRetiredMarkers - .SingleAsync(candidate => candidate.OwnershipId == ownership.Id); - evidence.CanonicalMarkerPath = foreignMarkerPath; - await db.SaveChangesAsync(); + var unavailable = await db.LibraryDirectoryOwnerships.SingleAsync(); + Assert.Equal( + LibraryDirectoryOwnershipState.Owned, + unavailable.State); + Assert.Equal(ownershipKey, unavailable.PathOwnershipKey); + Assert.False(string.IsNullOrWhiteSpace( + unavailable.DirectoryObjectIdentityUnavailableReason)); } - await CreateOwnershipReconciler().ReconcileAsync(); + Directory.Move(unavailableRoot, _root); + await reconciler.ReconcileAsync(); - Assert.True(File.Exists(siblingMarkerPath)); await using var verification = await _factory.CreateDbContextAsync(); - var persisted = await verification.LibraryDirectoryOwnershipRetiredMarkers - .SingleAsync(candidate => candidate.OwnershipId == ownership.Id); - Assert.Equal( - LibraryDirectoryOwnershipRetiredMarkerState.Pending, - persisted.State); + var recovered = await verification.LibraryDirectoryOwnerships.SingleAsync(); + Assert.Equal(LibraryDirectoryOwnershipState.Owned, recovered.State); + Assert.Equal(ownershipKey, recovered.PathOwnershipKey); + Assert.Null(recovered.DirectoryObjectIdentityUnavailableReason); + Assert.Null(recovered.StateReason); } - [LinuxFact] - public async Task TryDeleteRetiredSiblingMarker_AmbiguousPersistedPayload_PreservesMarker() + [Fact] + public async Task Reconciler_MissingRemovingDirectoryConvergesWithoutMarkerProof() { - var directory = Path.Join(_root, "AmbiguousRetiredMarkerPayload"); + var directory = Path.Join(_root, "SiblingOnlyRemoval"); Directory.CreateDirectory(directory); var ownership = await _store.RecordCreatedAsync( new LibraryDirectoryOwnershipClaim( @@ -1275,117 +862,22 @@ public async Task TryDeleteRetiredSiblingMarker_AmbiguousPersistedPayload_Preser FileSystemPathSemantics.CurrentHostDefault, "test")); var ownershipKey = Assert.IsType(ownership.PathOwnershipKey); - await PublishLegacyOwnershipMarkersAsync(ownership); await _store.BeginRemovalAsync(ownership.Id, ownershipKey); - LibraryDirectoryOwnershipMarker.DeleteInsideMarker(ownership, directory); Directory.Delete(directory); - await _store.MarkRemovedAsync(ownership.Id, ownershipKey); - - var siblingMarkerPath = - LibraryDirectoryOwnershipMarker.GetMarkerPaths(ownership)[1]; - Assert.True(File.Exists(siblingMarkerPath)); - var ambiguousCanonicalPath = "/" + ownership.CanonicalPath; - Assert.False(FileSystemPathIdentity.TryDetectAbsoluteSyntax( - ambiguousCanonicalPath, - out _)); - File.SetAttributes(siblingMarkerPath, FileAttributes.Normal); - await File.WriteAllTextAsync( - siblingMarkerPath, - LibraryDirectoryOwnershipMarker.SerializePayload( - new LibraryDirectoryOwnershipMarker.MarkerPayload( - LibraryDirectoryOwnershipMarker.Version, - ownership.OwnershipToken, - ambiguousCanonicalPath, - ownership.ManagedRootFolderId, - ownership.DirectoryObjectIdentityVersion, - ownership.DirectoryObjectIdentity))); - - Assert.False(LibraryDirectoryOwnershipMarker.TryDeleteRetiredSiblingMarker( - ownership, - out var reason)); - Assert.False(string.IsNullOrWhiteSpace(reason)); - Assert.True(File.Exists(siblingMarkerPath)); - } + var reconciler = new LibraryDirectoryOwnershipReconciler( + _factory, + new LibraryDirectoryOwnershipBoundaryAuthorizer(_factory), + new FilesystemMutationCoordinator(), + NullLogger.Instance); + + await reconciler.ReconcileAsync(); - [Fact] - public async Task Reconciler_RetiredMarkerReplacementRemainsPending() - { - var directory = Path.Join(_root, "RetiredMarkerReplacement"); - Directory.CreateDirectory(directory); - var ownership = await _store.RecordCreatedAsync( - new LibraryDirectoryOwnershipClaim( - directory, - FileSystemPathSemantics.CurrentHostDefault, - "test")); - var ownershipKey = Assert.IsType( - ownership.PathOwnershipKey); - await PublishLegacyOwnershipMarkersAsync(ownership); - await _store.BeginRemovalAsync(ownership.Id, ownershipKey); - LibraryDirectoryOwnershipMarker.DeleteInsideMarker( - ownership, - directory); - Directory.Delete(directory); - await _store.MarkRemovedAsync(ownership.Id, ownershipKey); - var siblingMarkerPath = - LibraryDirectoryOwnershipMarker.GetMarkerPaths(ownership)[1]; - File.SetAttributes(siblingMarkerPath, FileAttributes.Normal); - await File.WriteAllTextAsync( - siblingMarkerPath, - LibraryDirectoryOwnershipMarker.SerializePayload( - new LibraryDirectoryOwnershipMarker.MarkerPayload( - LibraryDirectoryOwnershipMarker.Version, - Guid.NewGuid().ToString("N"), - ownership.CanonicalPath, - ownership.ManagedRootFolderId, - ownership.DirectoryObjectIdentityVersion, - ownership.DirectoryObjectIdentity))); - - await CreateOwnershipReconciler().ReconcileAsync(); - - Assert.True(File.Exists(siblingMarkerPath)); await using var verification = await _factory.CreateDbContextAsync(); - var evidence = await verification - .LibraryDirectoryOwnershipRetiredMarkers - .SingleAsync(candidate => - candidate.OwnershipId == ownership.Id); + var persisted = await verification.LibraryDirectoryOwnerships.SingleAsync(); Assert.Equal( - LibraryDirectoryOwnershipRetiredMarkerState.Pending, - evidence.State); - } - - private static void AssertNoPersistentOwnershipArtifacts( - LibraryDirectoryOwnership ownership) - { - var markerPaths = LibraryDirectoryOwnershipMarker.GetMarkerPaths(ownership); - Assert.False(File.Exists(markerPaths[0])); - Assert.False(File.Exists(markerPaths[0] + ".v2.tmp")); - Assert.False(File.Exists(markerPaths[0] + ".migration.tmp")); - Assert.False(File.Exists(Path.Join( - Path.GetDirectoryName(markerPaths[0])!, - PinnedDirectoryCreation.GetConditionalReplacementBackupName( - Path.GetFileName(markerPaths[0]))))); - Assert.False(File.Exists(markerPaths[1])); - Assert.False(File.Exists(markerPaths[1] + ".v2.tmp")); - Assert.False(File.Exists(markerPaths[1] + ".migration.tmp")); - Assert.False(File.Exists(Path.Join( - Path.GetDirectoryName(markerPaths[1])!, - PinnedDirectoryCreation.GetConditionalReplacementBackupName( - Path.GetFileName(markerPaths[1]))))); - } - - private static async Task PublishLegacyOwnershipMarkersAsync( - LibraryDirectoryOwnership ownership) - { - var parentPath = Path.GetDirectoryName(ownership.CanonicalPath) - ?? throw new InvalidOperationException( - "The test ownership path has no parent directory."); - using var parent = PinnedDirectoryCreation.OpenPinnedBoundary(parentPath); - using var publication = parent.OpenExistingChildForPublication( - Path.GetFileName(ownership.CanonicalPath)); - await PinnedLibraryDirectoryOwnershipMarker.EnsureAsync( - ownership, - publication, - CancellationToken.None); + LibraryDirectoryOwnershipState.Removed, + persisted.State); + Assert.Null(persisted.PathOwnershipKey); } private LibraryDirectoryOwnershipReconciler CreateOwnershipReconciler() => @@ -1395,72 +887,6 @@ private LibraryDirectoryOwnershipReconciler CreateOwnershipReconciler() => new FilesystemMutationCoordinator(), NullLogger.Instance); - private async Task PrepareLegacyMissingBothAsync( - string directoryName) - { - var directory = Path.Join(_root, directoryName); - Directory.CreateDirectory(directory); - var ownership = await _store.RecordCreatedAsync( - new LibraryDirectoryOwnershipClaim( - directory, - FileSystemPathSemantics.CurrentHostDefault, - "test")); - var ownershipKey = Assert.IsType(ownership.PathOwnershipKey); - await PublishLegacyOwnershipMarkersAsync(ownership); - await _store.BeginRemovalAsync(ownership.Id, ownershipKey); - LibraryDirectoryOwnershipMarker.DeleteInsideMarker( - ownership, - directory); - Directory.Delete(directory); - var siblingMarkerPath = - LibraryDirectoryOwnershipMarker.GetMarkerPaths(ownership)[1]; - File.SetAttributes(siblingMarkerPath, FileAttributes.Normal); - await File.WriteAllTextAsync( - siblingMarkerPath, - System.Text.Json.JsonSerializer.Serialize( - new LibraryDirectoryOwnershipMarker.MarkerPayload( - 1, - ownership.OwnershipToken, - ownership.CanonicalPath))); - return new LegacyRemovalFixture( - ownership, - ownershipKey, - directory, - LibraryDirectoryOwnershipRemoval.GetQuarantinePath(ownership), - siblingMarkerPath); - } - - private async Task AssertRemovalConvergedWithPreservedLegacyArtifactAsync( - LegacyRemovalFixture fixture) - { - await using var verification = await _factory.CreateDbContextAsync(); - var persisted = await verification.LibraryDirectoryOwnerships - .SingleAsync(candidate => candidate.Id == fixture.Ownership.Id); - Assert.Equal(LibraryDirectoryOwnershipState.Removed, persisted.State); - Assert.Null(persisted.PathOwnershipKey); - Assert.True(File.Exists(fixture.SiblingMarkerPath)); - Assert.False(Directory.Exists(fixture.DirectoryPath)); - Assert.False(Directory.Exists(fixture.QuarantinePath)); - } - - private async Task AssertLegacyRecoveryRejectedAsync( - LegacyRemovalFixture fixture) - { - await using var verification = await _factory.CreateDbContextAsync(); - var persisted = await verification.LibraryDirectoryOwnerships - .SingleAsync(candidate => candidate.Id == fixture.Ownership.Id); - Assert.Equal(LibraryDirectoryOwnershipState.Removing, persisted.State); - Assert.Equal(fixture.OwnershipKey, persisted.PathOwnershipKey); - Assert.False(string.IsNullOrWhiteSpace(persisted.StateReason)); - } - - private sealed record LegacyRemovalFixture( - LibraryDirectoryOwnership Ownership, - string OwnershipKey, - string DirectoryPath, - string QuarantinePath, - string SiblingMarkerPath); - private sealed class FailFirstContextCreationFactory( IDbContextFactory inner, Action? beforeFailure = null) diff --git a/tests/Features/Infrastructure/Library/Moving/EfMoveExecutionStoreTests.cs b/tests/Features/Infrastructure/Library/Moving/EfMoveExecutionStoreTests.cs index 301ecf1a5..43431a3db 100644 --- a/tests/Features/Infrastructure/Library/Moving/EfMoveExecutionStoreTests.cs +++ b/tests/Features/Infrastructure/Library/Moving/EfMoveExecutionStoreTests.cs @@ -211,6 +211,145 @@ await store.EnsureMutationAuthorizedAsync( CancellationToken.None); } + [Fact] + public async Task CleanupStateTransitions_AreMonotonicAndTerminal() + { + var jobId = Guid.NewGuid(); + var lease = new MoveLeaseToken("worker", 1); + var factory = _provider.GetRequiredService>(); + await using (var db = await factory.CreateDbContextAsync()) + { + db.MoveJobs.Add(new MoveJob + { + Id = jobId, + AudiobookId = 1, + RequestedPath = Path.Join(FileService.GetTempPath(), "cleanup-target"), + SourcePath = Path.Join(FileService.GetTempPath(), "cleanup-source"), + Status = MoveJobStatus.Running, + LeaseOwner = lease.Owner, + LeaseGeneration = lease.Generation, + LeaseExpiresAt = DateTime.UtcNow.AddMinutes(5), + ActiveDeduplicationKey = $"test:{jobId:N}", + Entries = + [ + new MoveJobEntry + { + RelativePath = "book.m4b", + EntryType = MoveJobEntryType.File, + Length = 5, + Sha256 = new string('A', 64) + } + ] + }); + await db.SaveChangesAsync(); + } + + var store = new EfMoveExecutionStore(factory, TimeProvider.System); + await store.UpdateCleanupStateAsync( + jobId, + lease, + "book.m4b", + MoveJobEntryCleanupState.DeleteAuthorized, + CancellationToken.None); + await store.UpdateCleanupStateAsync( + jobId, + lease, + "book.m4b", + MoveJobEntryCleanupState.Deleted, + CancellationToken.None); + await Assert.ThrowsAsync(() => + store.UpdateCleanupStateAsync( + jobId, + lease, + "book.m4b", + MoveJobEntryCleanupState.Retained, + CancellationToken.None)); + + await store.UpdateSourceDirectoryCleanupStateAsync( + jobId, + lease, + MoveJobEntryCleanupState.Retained, + CancellationToken.None); + await Assert.ThrowsAsync(() => + store.UpdateSourceDirectoryCleanupStateAsync( + jobId, + lease, + MoveJobEntryCleanupState.DeleteAuthorized, + CancellationToken.None)); + + await using var verification = await factory.CreateDbContextAsync(); + var persisted = await verification.MoveJobs + .Include(candidate => candidate.Entries) + .SingleAsync(candidate => candidate.Id == jobId); + Assert.Equal( + MoveJobEntryCleanupState.Retained, + persisted.SourceDirectoryCleanupState); + Assert.Equal( + MoveJobEntryCleanupState.Deleted, + Assert.Single(persisted.Entries).CleanupState); + } + + [Fact] + public async Task CreatedDirectoryStateTransitions_PreserveTerminalRetainedState() + { + var jobId = Guid.NewGuid(); + var lease = new MoveLeaseToken("worker", 1); + var path = Path.Join(FileService.GetTempPath(), $"created-directory-{Guid.NewGuid():N}"); + var factory = _provider.GetRequiredService>(); + await using (var db = await factory.CreateDbContextAsync()) + { + db.MoveJobs.Add(new MoveJob + { + Id = jobId, + AudiobookId = 1, + RequestedPath = Path.Join(FileService.GetTempPath(), "created-target"), + SourcePath = Path.Join(FileService.GetTempPath(), "created-source"), + Status = MoveJobStatus.Running, + LeaseOwner = lease.Owner, + LeaseGeneration = lease.Generation, + LeaseExpiresAt = DateTime.UtcNow.AddMinutes(5), + ActiveDeduplicationKey = $"test:{jobId:N}", + CreatedDirectories = + [ + new MoveJobCreatedDirectory + { + Path = path, + State = MoveCreatedDirectoryState.Planned + } + ] + }); + await db.SaveChangesAsync(); + } + + var store = new EfMoveExecutionStore(factory, TimeProvider.System); + await store.UpdateCreatedDirectoryPublicationAsync( + jobId, + lease, + path, + MoveCreatedDirectoryState.Created, + "directory-generation", + CancellationToken.None); + await store.UpdateCreatedDirectoryStateAsync( + jobId, + lease, + path, + MoveCreatedDirectoryState.Retained, + CancellationToken.None); + await Assert.ThrowsAsync(() => + store.UpdateCreatedDirectoryStateAsync( + jobId, + lease, + path, + MoveCreatedDirectoryState.Removed, + CancellationToken.None)); + + await using var verification = await factory.CreateDbContextAsync(); + var persisted = await verification.MoveJobCreatedDirectories + .SingleAsync(candidate => candidate.MoveJobId == jobId); + Assert.Equal(MoveCreatedDirectoryState.Retained, persisted.State); + Assert.Equal("directory-generation", persisted.DirectoryObjectIdentity); + } + [Fact] public async Task ProviderFailures_AreTranslatedAcrossMoveExecutionBoundary() { @@ -225,14 +364,13 @@ public async Task ProviderFailures_AreTranslatedAcrossMoveExecutionBoundary() var operations = new Func[] { () => store.EnsureLeaseOwnedAsync(jobId, lease, CancellationToken.None), - () => store.ValidateOrAdoptIdentityAsync( + () => store.ValidateIdentityAsync( jobId, source, target, semantics, semantics, lease, - hasFilesystemRecoveryArtifacts: false, CancellationToken.None), () => store.EnsureMutationAuthorizedAsync( jobId, diff --git a/tests/Features/Infrastructure/Library/Moving/MoveJobProcessorArtifactCleanupTests.cs b/tests/Features/Infrastructure/Library/Moving/MoveJobProcessorArtifactCleanupTests.cs index a20d3a122..0c5da22c6 100644 --- a/tests/Features/Infrastructure/Library/Moving/MoveJobProcessorArtifactCleanupTests.cs +++ b/tests/Features/Infrastructure/Library/Moving/MoveJobProcessorArtifactCleanupTests.cs @@ -4,238 +4,6 @@ namespace Listenarr.Tests.Features.Infrastructure.Library.Moving; public partial class MoveJobProcessorTests { - [Fact] - public async Task ProcessJobAsync_ArtifactCleanupFailsOnce_SchedulesAndCompletesRetry() - { - var source = FileService.GetTempDirectory("move-processor-artifact-retry-src"); - await FileService.GetFileAsync(source, "book.m4b", "audio"); - var target = Path.Join( - FileService.GetTempPath(), - $"move-processor-artifact-retry-dst-{Guid.NewGuid():N}"); - var audiobook = await _audiobookRepository.AddAsync(new Audiobook - { - Title = "Artifact Cleanup Retry", - BasePath = source - }); - var (queue, job) = await CreateQueuedMoveJobAsync( - audiobook, - target, - source, - executionProtocolVersion: MoveExecutionProtocol.LegacyFilesystemArtifacts); - var faultingContentMoveService = new AudiobookContentMoveService( - _provider.GetRequiredService>(), - _provider.GetRequiredService>(), - TimeProvider.System, - new FailCompletedArtifactCleanupOnce()); - var faultingProcessor = ActivatorUtilities.CreateInstance( - _provider, - faultingContentMoveService); - - await faultingProcessor.ProcessJobAsync(job, CancellationToken.None); - - var retryJob = Assert.IsType( - await queue.GetJobAsync(job.Id)); - Assert.Equal(MoveJobStatus.RetryScheduled, retryJob.Status); - Assert.Equal(MoveJobPhase.CleaningArtifacts, retryJob.Phase); - var markerPath = Path.Join(target, $".listenarr-move-{job.Id:N}.pending"); - Assert.True(File.Exists(markerPath)); - Assert.Empty(await _historyRepository.GetByCorrelationIdAsync($"move:{job.Id:N}")); - Assert.NotNull(retryJob.NextAttemptAt); - Assert.Null(await queue.TryClaimJobAsync(job.Id, LeaseOwner)); - await MakeRetryDueAsync(job.Id); - - var retryGeneration = Assert.IsType( - await queue.TryClaimJobAsync(job.Id, LeaseOwner)); - retryJob.LeaseOwner = LeaseOwner; - retryJob.LeaseGeneration = retryGeneration; - var retryProcessor = _provider.GetRequiredService(); - - await retryProcessor.ProcessJobAsync(retryJob, CancellationToken.None); - - Assert.Equal(MoveJobStatus.Completed, (await queue.GetJobAsync(job.Id))?.Status); - Assert.False(File.Exists(markerPath)); - Assert.Single( - await _historyRepository.GetByCorrelationIdAsync($"move:{job.Id:N}"), - entry => entry.EventType == "Moved"); - } - - [Fact] - public async Task ProcessJobAsync_EmptySourceStateNativeDeleteFailure_SchedulesAndCompletesRetry() - { - var source = FileService.GetTempDirectory("move-processor-source-state-delete-retry-src"); - await FileService.GetFileAsync(source, "book.m4b", "audio"); - var target = Path.Join( - FileService.GetTempPath(), - $"move-processor-source-state-delete-retry-dst-{Guid.NewGuid():N}"); - var audiobook = await _audiobookRepository.AddAsync(new Audiobook - { - Title = "Source State Delete Retry", - BasePath = source - }); - var (queue, job) = await CreateQueuedMoveJobAsync( - audiobook, - target, - source, - executionProtocolVersion: MoveExecutionProtocol.LegacyFilesystemArtifacts); - var faultingContentMoveService = new AudiobookContentMoveService( - _provider.GetRequiredService>(), - _provider.GetRequiredService>(), - TimeProvider.System, - new FailEmptySourceStateDeleteOnce()); - var faultingProcessor = ActivatorUtilities.CreateInstance( - _provider, - faultingContentMoveService); - - await faultingProcessor.ProcessJobAsync(job, CancellationToken.None); - - var retryJob = Assert.IsType(await queue.GetJobAsync(job.Id)); - Assert.Equal(MoveJobStatus.RetryScheduled, retryJob.Status); - Assert.NotNull(retryJob.NextAttemptAt); - Assert.False(Directory.Exists(source)); - var statePath = Path.Join( - Path.GetDirectoryName(source)!, - $".listenarr-quarantine-{job.Id:N}", - ".listenarr-empty-source.state"); - Assert.True(Directory.Exists(statePath)); - Assert.Empty(Directory.EnumerateFileSystemEntries(statePath)); - Assert.True(File.Exists(Path.Join(target, "book.m4b"))); - await MakeRetryDueAsync(job.Id); - - var retryGeneration = Assert.IsType( - await queue.TryClaimJobAsync(job.Id, LeaseOwner)); - retryJob.LeaseOwner = LeaseOwner; - retryJob.LeaseGeneration = retryGeneration; - await _provider.GetRequiredService() - .ProcessJobAsync(retryJob, CancellationToken.None); - - var completed = Assert.IsType(await queue.GetJobAsync(job.Id)); - Assert.Equal(MoveJobStatus.Completed, completed.Status); - Assert.False(Directory.Exists(source)); - Assert.False(Directory.Exists(statePath)); - Assert.True(File.Exists(Path.Join(target, "book.m4b"))); - } - - [Fact] - public async Task ProcessJobAsync_ForeignSourceFileBeforeMarkerDelete_PreservesFileAndCompletes() - { - var source = FileService.GetTempDirectory("move-processor-recreated-source-src"); - await FileService.GetFileAsync(source, "book.m4b", "audio"); - var target = Path.Join( - FileService.GetTempPath(), - $"move-processor-recreated-source-dst-{Guid.NewGuid():N}"); - var audiobook = await _audiobookRepository.AddAsync(new Audiobook - { - Title = "Recreated Source", - BasePath = source - }); - var (queue, job) = await CreateQueuedMoveJobAsync( - audiobook, - target, - source, - executionProtocolVersion: MoveExecutionProtocol.LegacyFilesystemArtifacts); - var faultingContentMoveService = new AudiobookContentMoveService( - _provider.GetRequiredService>(), - _provider.GetRequiredService>(), - TimeProvider.System, - new RecreateSourceBeforeMarkerDelete(source)); - var processor = ActivatorUtilities.CreateInstance( - _provider, - faultingContentMoveService); - - await processor.ProcessJobAsync(job, CancellationToken.None); - - var persisted = Assert.IsType( - await queue.GetJobAsync(job.Id)); - Assert.Equal(MoveJobStatus.Completed, persisted.Status); - Assert.Equal( - "preserve me", - await File.ReadAllTextAsync(Path.Join(source, "operator-note.txt"))); - Assert.False(File.Exists(Path.Join( - target, - $".listenarr-move-{job.Id:N}.pending"))); - } - - [Fact] - public async Task ProcessJobAsync_TargetChangesBeforeMarkerDelete_RequiresAttentionAndPreservesMarker() - { - var source = FileService.GetTempDirectory("move-processor-mutated-target-src"); - await FileService.GetFileAsync(source, "book.m4b", "audio"); - var target = Path.Join( - FileService.GetTempPath(), - $"move-processor-mutated-target-dst-{Guid.NewGuid():N}"); - var audiobook = await _audiobookRepository.AddAsync(new Audiobook - { - Title = "Mutated Target", - BasePath = source - }); - var (queue, job) = await CreateQueuedMoveJobAsync( - audiobook, - target, - source, - executionProtocolVersion: MoveExecutionProtocol.LegacyFilesystemArtifacts); - var faultingContentMoveService = new AudiobookContentMoveService( - _provider.GetRequiredService>(), - _provider.GetRequiredService>(), - TimeProvider.System, - new MutateTargetBeforeMarkerDelete(target)); - var processor = ActivatorUtilities.CreateInstance( - _provider, - faultingContentMoveService); - - await processor.ProcessJobAsync(job, CancellationToken.None); - - var persisted = Assert.IsType( - await queue.GetJobAsync(job.Id)); - Assert.Equal(MoveJobStatus.NeedsAttention, persisted.Status); - Assert.Equal( - "corrupted after cleanup validation", - await File.ReadAllTextAsync(Path.Join(target, "book.m4b"))); - Assert.True(File.Exists(Path.Join( - target, - $".listenarr-move-{job.Id:N}.pending"))); - } - - [Fact] - public async Task ProcessJobAsync_UnownedFileAppearsAfterFinalHash_PreservesMarkerAndRequiresAttention() - { - var source = FileService.GetTempDirectory("move-processor-final-hash-race-src"); - await FileService.GetFileAsync(source, "book.m4b", "audio"); - var target = Path.Join( - FileService.GetTempPath(), - $"move-processor-final-hash-race-dst-{Guid.NewGuid():N}"); - var audiobook = await _audiobookRepository.AddAsync(new Audiobook - { - Title = "Final Hash Ownership Race", - BasePath = source - }); - var (queue, job) = await CreateQueuedMoveJobAsync( - audiobook, - target, - source, - executionProtocolVersion: MoveExecutionProtocol.LegacyFilesystemArtifacts); - var contentMoveService = new AudiobookContentMoveService( - _provider.GetRequiredService>(), - _provider.GetRequiredService>(), - TimeProvider.System, - new AddUnownedFileAfterFinalHash(target)); - var processor = ActivatorUtilities.CreateInstance( - _provider, - contentMoveService); - - await processor.ProcessJobAsync(job, CancellationToken.None); - - var persisted = Assert.IsType( - await queue.GetJobAsync(job.Id)); - Assert.Equal(MoveJobStatus.NeedsAttention, persisted.Status); - Assert.Equal( - "preserve me", - await File.ReadAllTextAsync(Path.Join(target, "operator-note.txt"))); - Assert.True(File.Exists(Path.Join(target, "book.m4b"))); - Assert.True(File.Exists(Path.Join( - target, - $".listenarr-move-{job.Id:N}.pending"))); - } - [Fact] public async Task ProcessJobAsync_FinalizationIoFailure_SchedulesAndCompletesRetry() { @@ -270,7 +38,6 @@ public async Task ProcessJobAsync_FinalizationIoFailure_SchedulesAndCompletesRet Assert.Equal(MoveJobStatus.RetryScheduled, retryJob.Status); Assert.Equal(MoveJobPhase.Finalizing, retryJob.Phase); Assert.True(Directory.Exists(sourceParent)); - Assert.False(File.Exists(Path.Join(target, $".listenarr-move-{job.Id:N}.pending"))); Assert.NotNull(retryJob.NextAttemptAt); Assert.Null(await queue.TryClaimJobAsync(job.Id, LeaseOwner)); await MakeRetryDueAsync(job.Id); @@ -285,7 +52,6 @@ await _provider.GetRequiredService() Assert.Equal(MoveJobStatus.Completed, (await queue.GetJobAsync(job.Id))?.Status); Assert.False(Directory.Exists(sourceParent)); Assert.True(Directory.Exists(sourceRoot)); - Assert.False(File.Exists(Path.Join(target, $".listenarr-move-{job.Id:N}.pending"))); } [Fact] @@ -391,9 +157,6 @@ await RecordOwnedDirectoryHierarchyAsync( retryDelays); Assert.All(retryDelays, delay => Assert.True(delay <= MoveTimingPolicy.MaxRetryDelay)); - Assert.False(File.Exists(Path.Join( - target, - $".listenarr-move-{initialJob.Id:N}.pending"))); } private async Task MakeRetryDueAsync(Guid jobId) @@ -405,109 +168,6 @@ private async Task MakeRetryDueAsync(Guid jobId) await db.SaveChangesAsync(); } - private sealed class FailEmptySourceStateDeleteOnce : IMoveFaultInjector - { - private bool _failed; - - public void OnSourceCleanupMutation( - Guid jobId, - SourceCleanupFaultPoint faultPoint) - { - if (_failed - || faultPoint != SourceCleanupFaultPoint.BeforeEmptySourceStateDelete) - { - return; - } - - _failed = true; - throw new System.ComponentModel.Win32Exception( - 145, - "Injected empty-source state retirement failure."); - } - } - - private sealed class RecreateSourceBeforeMarkerDelete( - string source) : IMoveFaultInjector - { - private bool _recreated; - - public void OnCompletedArtifactCleanup( - Guid jobId, - CompletedArtifactCleanupFaultPoint faultPoint) - { - if (_recreated - || faultPoint != CompletedArtifactCleanupFaultPoint.BeforeRecoveryMarkerDelete) - { - return; - } - - Directory.CreateDirectory(source); - File.WriteAllText(Path.Join(source, "operator-note.txt"), "preserve me"); - _recreated = true; - } - } - - private sealed class MutateTargetBeforeMarkerDelete( - string target) : IMoveFaultInjector - { - private bool _mutated; - - public void OnCompletedArtifactCleanup( - Guid jobId, - CompletedArtifactCleanupFaultPoint faultPoint) - { - if (_mutated - || faultPoint != CompletedArtifactCleanupFaultPoint.BeforeRecoveryMarkerDelete) - { - return; - } - - File.WriteAllText( - Path.Join(target, "book.m4b"), - "corrupted after cleanup validation"); - _mutated = true; - } - } - - private sealed class AddUnownedFileAfterFinalHash( - string target) : IMoveFaultInjector - { - private bool _added; - - public void OnCompletedArtifactCleanup( - Guid jobId, - CompletedArtifactCleanupFaultPoint faultPoint) - { - if (_added - || faultPoint != CompletedArtifactCleanupFaultPoint.BeforeFinalDestinationOwnershipValidation) - { - return; - } - - File.WriteAllText(Path.Join(target, "operator-note.txt"), "preserve me"); - _added = true; - } - } - - private sealed class FailCompletedArtifactCleanupOnce : IMoveFaultInjector - { - private bool _failed; - - public void OnCompletedArtifactCleanup( - Guid jobId, - CompletedArtifactCleanupFaultPoint faultPoint) - { - if (_failed - || faultPoint != CompletedArtifactCleanupFaultPoint.BeforeRecoveryMarkerDelete) - { - return; - } - - _failed = true; - throw new IOException("Simulated transient recovery marker lock."); - } - } - private sealed class AddFileBeforeSourceAncestorDelete( string sourceParent) : IMoveFaultInjector { diff --git a/tests/Features/Infrastructure/Library/Moving/MoveJobProcessorFinalizedRecoveryTests.cs b/tests/Features/Infrastructure/Library/Moving/MoveJobProcessorFinalizedRecoveryTests.cs index 5758c0841..cba65afb9 100644 --- a/tests/Features/Infrastructure/Library/Moving/MoveJobProcessorFinalizedRecoveryTests.cs +++ b/tests/Features/Infrastructure/Library/Moving/MoveJobProcessorFinalizedRecoveryTests.cs @@ -224,7 +224,6 @@ public async Task ProcessJobAsync_MarkerlessPublishedCopy_ResumesFullFinalizatio var service = _provider.GetRequiredService(); var request = CreateMoveRequest(source, target, job, deleteEmptySource: false); var result = await service.MoveContentsAsync(request, CancellationToken.None); - Assert.Empty(result.RecoveryMarkerPath); var persistedJob = Assert.IsType( await queue.GetJobAsync(job.Id)); Assert.Equal(MoveJobPhase.Finalizing, persistedJob.Phase); @@ -243,8 +242,6 @@ public async Task ProcessJobAsync_MarkerlessPublishedCopy_ResumesFullFinalizatio Assert.Equal( Path.GetFullPath(target), Path.GetFullPath(Assert.IsType(updated.BasePath))); - Assert.False(File.Exists(Path.Join(target, ".listenarr-temp-owner.json"))); - Assert.False(File.Exists(result.RecoveryMarkerPath)); Assert.Single( await _historyRepository.GetByCorrelationIdAsync($"move:{job.Id:N}"), entry => entry.EventType == "Moved"); @@ -270,7 +267,6 @@ public async Task ProcessJobAsync_MarkerlessAtomicMove_WithPersistedManifest_Com audiobook.BasePath = target; await _audiobookRepository.UpdateAsync(audiobook); await service.FinalizeMoveAsync(request, result, CancellationToken.None); - Assert.Empty(result.RecoveryMarkerPath); var processor = _provider.GetRequiredService(); await processor.ProcessJobAsync(job, CancellationToken.None); @@ -305,7 +301,6 @@ public async Task ProcessJobAsync_MarkerlessAtomicTargetChanged_RequiresAttentio audiobook.BasePath = target; await _audiobookRepository.UpdateAsync(audiobook); await service.FinalizeMoveAsync(request, result, CancellationToken.None); - Assert.Empty(result.RecoveryMarkerPath); Directory.Delete(target, recursive: true); if (!string.Equals(mutation, "deleted", StringComparison.Ordinal)) { @@ -351,7 +346,6 @@ private async Task CreateMarkerlessFinalizedCopySt await _audiobookRepository.UpdateAsync(audiobook); await service.FinalizeMoveAsync(request, result, CancellationToken.None); result.TargetVerificationLease?.Dispose(); - Assert.Empty(result.RecoveryMarkerPath); return new MarkerlessFinalizedCopyState(queue, job, source, target); } diff --git a/tests/Features/Infrastructure/Library/Moving/MoveJobProcessorTests.cs b/tests/Features/Infrastructure/Library/Moving/MoveJobProcessorTests.cs index 07f6b3260..b5b0fc9b9 100644 --- a/tests/Features/Infrastructure/Library/Moving/MoveJobProcessorTests.cs +++ b/tests/Features/Infrastructure/Library/Moving/MoveJobProcessorTests.cs @@ -247,10 +247,6 @@ await Assert.ThrowsAsync(() => processor.ProcessJobAsync( metrics.Verify( service => service.Increment("worker.move.job.completed", It.IsAny()), Times.Never); - Assert.Empty(Directory.EnumerateFiles( - target, - $".listenarr-move-{job.Id:N}.pending", - SearchOption.TopDirectoryOnly)); var persistedJob = Assert.IsType( await durableQueue.GetJobAsync(job.Id)); Assert.Equal(MoveJobPhase.RecordingCompletion, persistedJob.Phase); @@ -612,6 +608,52 @@ await Assert.ThrowsAsync(() => Assert.Equal(MoveJobStatus.Running, persistedJob.Status); } + [Fact] + public async Task ProcessJobAsync_TargetReplacedAfterSourceCleanup_DoesNotRewriteAudiobookMetadata() + { + var source = FileService.GetTempDirectory( + "move-processor-target-replaced-before-rewrite-src"); + await FileService.GetFileAsync(source, "book.m4b", "audio"); + var target = Path.Join( + FileService.GetTempPath(), + $"move-processor-target-replaced-before-rewrite-dst-{Guid.NewGuid():N}"); + var audiobook = await _audiobookRepository.AddAsync(new Audiobook + { + Title = "Move Processor Target Replaced Before Rewrite", + BasePath = source + }); + var (queue, job) = await CreateQueuedMoveJobAsync(audiobook, target, source); + var factory = _provider + .GetRequiredService>(); + var processor = _provider.GetRequiredService(); + processor.AfterSourceCleanupBeforeMetadataRewriteForTest = async _ => + { + var targetFile = Path.Join(target, "book.m4b"); + File.Delete(targetFile); + await File.WriteAllTextAsync(targetFile, "replacement"); + }; + + await processor.ProcessJobAsync(job, CancellationToken.None); + + await using var verification = await factory.CreateDbContextAsync(); + var audiobookAfter = await verification.Audiobooks + .AsNoTracking() + .SingleAsync(candidate => candidate.Id == audiobook.Id); + Assert.Equal(source, audiobookAfter.BasePath); + Assert.False(File.Exists(Path.Join(source, "book.m4b"))); + Assert.Equal( + "replacement", + await File.ReadAllTextAsync(Path.Join(target, "book.m4b"))); + var persistedJob = await verification.MoveJobs + .AsNoTracking() + .SingleAsync(candidate => candidate.Id == job.Id); + Assert.Equal(MoveJobStatus.NeedsAttention, persistedJob.Status); + Assert.Contains( + "target", + persistedJob.Error, + StringComparison.OrdinalIgnoreCase); + } + [Fact] public async Task ProcessJobAsync_CanceledToken_ThrowsBeforeStateChange() { @@ -688,88 +730,6 @@ await _provider.GetRequiredService() .AnyAsync(handoff => handoff.MoveJobId == legacyJob.Id)); } - [Theory] - [InlineData("temporary-directory", false)] - [InlineData("temporary-directory", true)] - [InlineData("quarantine-directory", false)] - [InlineData("quarantine-directory", true)] - [InlineData("target-scaffold-temporary", false)] - [InlineData("target-scaffold-temporary", true)] - [InlineData("target-scaffold-quarantine", false)] - [InlineData("target-scaffold-quarantine", true)] - public async Task ProcessJobAsync_LegacyIdenticalEndpointWithCleanupTombstone_PreservesForAttention( - string artifactType, - bool interruptedWrite) - { - var src = FileService.GetTempDirectory("move-processor-identical-tombstone"); - var sourceFile = await FileService.GetFileAsync(src, "book.m4b", "audio"); - var audiobook = await _audiobookRepository.AddAsync(new Audiobook - { - Title = "Move Processor Identical Tombstone", - BasePath = src - }); - var identity = new PathIdentitySnapshot( - FileSystemPathSemantics.CurrentHostDefault.Syntax, - FileSystemPathSemantics.CurrentHostDefault.CaseSensitivity, - FileSystemCaseSensitivityMode.Auto, - src); - var legacyJob = new MoveJob - { - Id = Guid.NewGuid(), - AudiobookId = audiobook.Id, - SourcePath = src, - RequestedPath = src, - Status = MoveJobStatus.Queued, - ActiveDeduplicationKey = $"legacy-identical-tombstone:{Guid.NewGuid():N}" - }; - legacyJob.SetSourceIdentity(identity); - legacyJob.SetTargetIdentity(identity); - var parent = Path.GetDirectoryName(src)!; - var tombstonePath = Path.Join( - parent, - $".listenarr-{artifactType}-{legacyJob.Id:N}.cleanup.json"); - var evidencePath = interruptedWrite - ? tombstonePath + $".writing-{Guid.NewGuid():N}" - : tombstonePath; - await File.WriteAllTextAsync(evidencePath, "{}"); - - try - { - var factory = _provider.GetRequiredService>(); - await using (var db = await factory.CreateDbContextAsync()) - { - db.MoveJobs.Add(legacyJob); - await db.SaveChangesAsync(); - } - - var queue = _provider.GetRequiredService(); - var job = Assert.IsType( - await queue.GetJobAsync(legacyJob.Id)); - await PrepareJobForProcessingAsync(queue, job); - - await _provider.GetRequiredService() - .ProcessJobAsync(job, CancellationToken.None); - - var updatedJob = Assert.IsType( - await queue.GetJobAsync(legacyJob.Id)); - Assert.Equal(MoveJobStatus.NeedsAttention, updatedJob.Status); - Assert.Contains("sibling artifacts", updatedJob.Error, StringComparison.OrdinalIgnoreCase); - Assert.True(File.Exists(sourceFile)); - Assert.True(File.Exists(evidencePath)); - Assert.Empty(await _historyRepository.GetByCorrelationIdAsync($"move:{legacyJob.Id:N}")); - await using var verification = await factory.CreateDbContextAsync(); - Assert.False(await verification.MoveScanHandoffs - .AnyAsync(handoff => handoff.MoveJobId == legacyJob.Id)); - } - finally - { - if (File.Exists(evidencePath)) - { - File.Delete(evidencePath); - } - } - } - [Fact] public async Task ProcessJobAsync_LegacyIdenticalEndpointWithExecutionState_PreservesForAttention() { @@ -834,79 +794,6 @@ await _provider.GetRequiredService() .AnyAsync(handoff => handoff.MoveJobId == legacyJob.Id)); } - [Fact] - public async Task ProcessJobAsync_AtomicMarkerWithRecreatedSource_MarksNeedsAttention() - { - var src = FileService.GetTempDirectory("move-processor-recovery-src"); - await FileService.GetFileAsync(src, "book.m4b", "audio"); - var dst = Path.Join(FileService.GetTempPath(), $"move-processor-recovery-dst-{Guid.NewGuid():N}"); - var audiobook = await _audiobookRepository.AddAsync(new Audiobook - { - Title = "Move Processor Recovery", - BasePath = src - }); - var (queue, job) = await CreateQueuedMoveJobAsync(audiobook, dst, src); - await File.WriteAllTextAsync( - Path.Join(src, $".listenarr-move-{job.Id:N}.pending"), - System.Text.Json.JsonSerializer.Serialize(new - { - Version = 1, - JobId = job.Id, - Source = Path.GetFullPath(src), - Target = Path.GetFullPath(dst), - Stage = "atomic-rename-complete" - })); - Directory.Move(src, dst); - - Assert.False(Directory.Exists(src)); - Assert.Single(Directory.EnumerateFiles(dst, ".listenarr-move-*.pending")); - Directory.CreateDirectory(src); - await FileService.GetFileAsync(src, "new-content.txt", "do not delete"); - - var processor = _provider.GetRequiredService(); - await processor.ProcessJobAsync(job, CancellationToken.None); - - var updatedJob = Assert.IsType( - await queue.GetJobAsync(job.Id)); - Assert.Equal(MoveJobStatus.NeedsAttention, updatedJob.Status); - - using var verificationScope = _provider.CreateScope(); - var verificationRepository = verificationScope.ServiceProvider.GetRequiredService(); - var updatedAudiobook = Assert.IsType( - await verificationRepository.GetByIdAsync(audiobook.Id)); - Assert.Equal(src, updatedAudiobook.BasePath); - Assert.True(File.Exists(Path.Join(dst, "book.m4b"))); - Assert.Equal("do not delete", await File.ReadAllTextAsync(Path.Join(src, "new-content.txt"))); - Assert.Single(Directory.EnumerateFiles(dst, ".listenarr-move-*.pending")); - } - - [Fact] - public async Task ProcessJobAsync_CopyCompletedMarkerWithoutManifest_BlocksSourceCleanup() - { - var src = FileService.GetTempDirectory("move-processor-copy-complete-src"); - await FileService.GetFileAsync(src, "book.m4b", "audio"); - var dst = FileService.GetTempDirectory("move-processor-copy-complete-dst"); - await FileService.GetFileAsync(dst, "book.m4b", "audio"); - var audiobook = await _audiobookRepository.AddAsync(new Audiobook - { - Title = "Move Processor Copy Complete", - BasePath = src - }); - var (queue, job) = await CreateQueuedMoveJobAsync(audiobook, dst, src); - await File.WriteAllTextAsync( - Path.Join(dst, $".listenarr-move-{job.Id:N}.pending"), - "copy-complete"); - - var processor = _provider.GetRequiredService(); - await processor.ProcessJobAsync(job, CancellationToken.None); - - var completedJob = Assert.IsType( - await queue.GetJobAsync(job.Id)); - Assert.Equal(MoveJobStatus.NeedsAttention, completedJob.Status); - Assert.True(Directory.Exists(src)); - Assert.True(File.Exists(Path.Join(dst, "book.m4b"))); - } - [Fact] public async Task ProcessJobAsync_MissingSourceAndTargetWithTargetMetadata_MarksNeedsAttention() { diff --git a/tests/Features/Infrastructure/Library/Moving/PinnedLibraryDirectoryOwnershipMarkerMigrationTests.cs b/tests/Features/Infrastructure/Library/Moving/PinnedLibraryDirectoryOwnershipMarkerMigrationTests.cs deleted file mode 100644 index 579a15ef2..000000000 --- a/tests/Features/Infrastructure/Library/Moving/PinnedLibraryDirectoryOwnershipMarkerMigrationTests.cs +++ /dev/null @@ -1,116 +0,0 @@ -namespace Listenarr.Tests.Features.Infrastructure.Library.Moving; - -public sealed partial class PinnedDirectoryCreationTests -{ - [Fact] - public async Task PublishMigrationTargetAsync_DifferentPhysicalGeneration_DoesNotGrantOwnership() - { - // Given - var root = FileService.GetTempDirectory("ownership-migration-generation"); - var sourceDirectory = Path.Join(root, "source", "Book"); - var targetDirectory = Path.Join(root, "target", "Book"); - Directory.CreateDirectory(sourceDirectory); - Directory.CreateDirectory(targetDirectory); - var ownershipToken = Guid.NewGuid().ToString("N"); - using var sourceAnchor = PinnedDirectoryCreation.OpenPinnedBoundary(sourceDirectory); - using var targetAnchor = PinnedDirectoryCreation.OpenPinnedBoundary(targetDirectory); - Assert.NotEqual( - sourceAnchor.GetDirectoryObjectIdentity(), - targetAnchor.GetDirectoryObjectIdentity()); - var source = CreateOwnership( - sourceDirectory, - ownershipToken, - sourceAnchor.GetDirectoryObjectIdentity()); - var target = CreateOwnership( - targetDirectory, - ownershipToken, - sourceAnchor.GetDirectoryObjectIdentity()); - using var targetParent = PinnedDirectoryCreation.OpenPinnedBoundary( - Path.GetDirectoryName(targetDirectory)!); - - // When - var exception = await Assert.ThrowsAsync(() => - PinnedLibraryDirectoryOwnershipMarker.PublishMigrationTargetAsync( - source, - target, - targetParent, - CancellationToken.None)); - - // Then - Assert.Contains( - "different physical directory generation", - exception.Message, - StringComparison.OrdinalIgnoreCase); - Assert.False(File.Exists(Path.Join( - targetDirectory, - LibraryDirectoryOwnershipMarker.FileName))); - Assert.False(File.Exists(Path.Join( - Path.GetDirectoryName(targetDirectory)!, - $".listenarr-directory-owner-{ownershipToken}.json"))); - } - - [Fact] - public async Task PublishMigrationTargetAsync_SamePhysicalGeneration_CanPublishOwnership() - { - // Given - var directory = Path.Join( - FileService.GetTempDirectory("ownership-migration-same-generation"), - "Book"); - Directory.CreateDirectory(directory); - var ownershipToken = Guid.NewGuid().ToString("N"); - using var directoryAnchor = PinnedDirectoryCreation.OpenPinnedBoundary(directory); - var nativeIdentity = directoryAnchor.GetDirectoryObjectIdentity(); - var source = CreateOwnership(directory, ownershipToken, nativeIdentity); - var target = CreateOwnership(directory, ownershipToken, nativeIdentity); - using var parent = PinnedDirectoryCreation.OpenPinnedBoundary( - Path.GetDirectoryName(directory)!); - - // When - await PinnedLibraryDirectoryOwnershipMarker.PublishMigrationTargetAsync( - source, - target, - parent, - CancellationToken.None); - - // Then - LibraryDirectoryOwnershipMarker.Validate(target, directory); - Assert.True(ManagedDirectoryIdentity.Matches( - target.DirectoryObjectIdentityVersion, - target.DirectoryObjectIdentity, - ownershipToken, - nativeIdentity)); - } - - private static LibraryDirectoryOwnership CreateOwnership( - string path, - string ownershipToken, - string nativeIdentity) - { - var semantics = FileSystemPathSemantics.CurrentHostDefault; - return new LibraryDirectoryOwnership - { - Path = path, - CanonicalPath = path, - PathSyntax = semantics.Syntax, - PathCaseSensitivity = semantics.CaseSensitivity, - PathCaseSensitivityMode = FileSystemCaseSensitivityMode.Auto, - PathIdentityBoundary = path, - PathIdentityLookupKey = FileSystemPathIdentity.CreateLookupKey( - "library-directory", - path, - semantics.Syntax), - PathOwnershipKey = FileSystemPathIdentity.CreateKey( - "library-directory", - path, - semantics), - OwnershipToken = ownershipToken, - State = LibraryDirectoryOwnershipState.Owned, - CreationWorkflow = "Test", - ManagedRootFolderId = 1, - DirectoryObjectIdentityVersion = ManagedDirectoryIdentity.CurrentVersion, - DirectoryObjectIdentity = ManagedDirectoryIdentity.Create( - ownershipToken, - nativeIdentity) - }; - } -} diff --git a/tests/Features/Infrastructure/Library/Moving/RootFolderRelocationServiceTests.cs b/tests/Features/Infrastructure/Library/Moving/RootFolderRelocationServiceTests.cs index a1160217e..32e00b55c 100644 --- a/tests/Features/Infrastructure/Library/Moving/RootFolderRelocationServiceTests.cs +++ b/tests/Features/Infrastructure/Library/Moving/RootFolderRelocationServiceTests.cs @@ -1978,7 +1978,7 @@ await AddTrackedFileAsync( } [Fact] - public async Task MetadataOnly_TargetRootReplacedAfterJournalCommit_DoesNotCommitStaleGeneration() + public async Task MetadataOnly_TargetRootReplacedAtAtomicCommit_DoesNotCommitStaleGeneration() { var source = Path.Join( TempRoot, @@ -2003,7 +2003,7 @@ public async Task MetadataOnly_TargetRootReplacedAfterJournalCommit_DoesNotCommi } var service = CreateService(); - service.AfterMetadataOnlyJournalCommitForTest = () => + service.BeforeMetadataOnlyAtomicCommitForTest = () => { Directory.Move(target, displacedTarget); Directory.CreateDirectory(target); @@ -2035,6 +2035,68 @@ await Assert.ThrowsAsync(() => await File.ReadAllTextAsync(Path.Join(target, "foreign.txt"))); } + [Fact] + public async Task MetadataOnly_TargetRootReplacedImmediatelyAfterAtomicCommit_MarksNeedsAttention() + { + var source = Path.Join( + TempRoot, + $"metadata-post-commit-generation-source-{Guid.NewGuid():N}"); + var target = Path.Join( + TempRoot, + $"metadata-post-commit-generation-target-{Guid.NewGuid():N}"); + var displacedTarget = target + ".original"; + Directory.CreateDirectory(source); + Directory.CreateDirectory(target); + int rootId; + await using (var db = await _factory.CreateDbContextAsync()) + { + var root = new RootFolder + { + Name = "Library", + Path = source + }; + db.RootFolders.Add(root); + await db.SaveChangesAsync(); + rootId = root.Id; + } + + var service = CreateService(); + service.AfterMetadataOnlyAtomicCommitForTest = () => + { + Directory.Move(target, displacedTarget); + Directory.CreateDirectory(target); + File.WriteAllText( + Path.Join(target, "foreign.txt"), + "replacement generation"); + }; + + await Assert.ThrowsAsync(() => + service.StartAsync( + rootId, + new RootFolderPathChangeCommand( + target, + RootFolderRelocationMode.MetadataOnly, + false, + "Metadata Library", + false, + FileSystemCaseSensitivityMode.Auto))); + + await using var verification = await _factory.CreateDbContextAsync(); + var rootAfter = await verification.RootFolders.SingleAsync(); + var relocation = await verification.RootFolderRelocations.SingleAsync(); + Assert.Equal(target, rootAfter.Path); + Assert.Equal(RootFolderRelocationStatus.NeedsAttention, relocation.Status); + Assert.Equal(rootId, relocation.ActiveRootFolderId); + Assert.Contains( + "completion requires attention", + relocation.Error, + StringComparison.OrdinalIgnoreCase); + Assert.True(Directory.Exists(displacedTarget)); + Assert.Equal( + "replacement generation", + await File.ReadAllTextAsync(Path.Join(target, "foreign.txt"))); + } + [Fact] public async Task MetadataOnly_RequestCancelledAfterJournalCommit_CompletesAuthoritatively() { @@ -2088,7 +2150,7 @@ public async Task MetadataOnly_RequestCancelledAfterJournalCommit_CompletesAutho Assert.Empty(await verification.RootFolderRelocations.ToListAsync()); } -[Fact] + [Fact] public async Task MetadataOnly_ExternallyRenamedOwnedTree_DoesNotRequireOldSourcePathForFreshMarkerlessCleanup() { var source = Path.Join( @@ -2308,157 +2370,6 @@ public async Task MetadataOnly_RealReadOnlyBindMount_UsesDatabaseOnlyOwnershipMi StringComparison.OrdinalIgnoreCase)); } - [Fact] - public async Task MetadataOnly_PostCommitOwnershipCleanupFailure_ReturnsProtectedAttentionAndRecovers() - { - var rootPath = Path.Join( - TempRoot, - $"metadata-post-commit-root-{Guid.NewGuid():N}"); - var ownedPath = Path.Join(rootPath, "Book"); - Directory.CreateDirectory(ownedPath); - var semantics = await new FileSystemSemanticsResolver() - .ResolveAsync(rootPath); - Assert.Equal(PathIdentityState.Valid, semantics.State); - var rootObjectIdentity = await new DirectoryObjectIdentityResolver() - .ResolveAsync(rootPath); - var ownedObjectIdentity = await new DirectoryObjectIdentityResolver() - .ResolveAsync(ownedPath); - Assert.True(rootObjectIdentity.IsAvailable); - Assert.True(ownedObjectIdentity.IsAvailable); - var ownershipToken = Guid.NewGuid().ToString("N"); - using var ownedAnchor = - PinnedDirectoryCreation.OpenPinnedBoundary(ownedPath); - var ownershipIdentity = ManagedDirectoryIdentity.Create( - ownershipToken, - ownedAnchor.GetDirectoryObjectIdentity()); - - int rootId; - string targetOwnershipKey; - await using (var db = await _factory.CreateDbContextAsync()) - { - var root = new RootFolder - { - Name = "Library", - Path = rootPath, - CaseSensitivityMode = FileSystemCaseSensitivityMode.Auto, - ResolvedCaseSensitivity = semantics.Semantics.CaseSensitivity, - PathIdentityState = PathIdentityState.Valid, - PathIdentityKey = FileSystemPathIdentity.CreateKey( - "root", - rootPath, - semantics.Semantics), - DirectoryObjectIdentityVersion = rootObjectIdentity.Version, - DirectoryObjectIdentity = rootObjectIdentity.Value - }; - var audiobook = new Audiobook - { - Title = "Book", - BasePath = ownedPath - }; - db.RootFolders.Add(root); - db.Audiobooks.Add(audiobook); - await db.SaveChangesAsync(); - rootId = root.Id; - - var targetSemantics = new FileSystemPathSemantics( - semantics.Semantics.Syntax, - FileSystemCaseSensitivity.Sensitive); - targetOwnershipKey = FileSystemPathIdentity.CreateKey( - "library-directory", - ownedPath, - targetSemantics); - db.LibraryDirectoryOwnerships.Add( - new LibraryDirectoryOwnership - { - Path = ownedPath, - CanonicalPath = ownedPath, - PathSyntax = semantics.Semantics.Syntax, - PathCaseSensitivity = semantics.Semantics.CaseSensitivity, - PathCaseSensitivityMode = - semantics.Semantics.CaseSensitivity - == FileSystemCaseSensitivity.Sensitive - ? FileSystemCaseSensitivityMode.Sensitive - : FileSystemCaseSensitivityMode.Insensitive, - PathIdentityBoundary = ownedPath, - PathIdentityLookupKey = - FileSystemPathIdentity.CreateLookupKey( - "library-directory", - ownedPath, - semantics.Semantics.Syntax), - PathOwnershipKey = FileSystemPathIdentity.CreateKey( - "library-directory", - ownedPath, - semantics.Semantics), - OwnershipToken = ownershipToken, - State = LibraryDirectoryOwnershipState.Owned, - CreationWorkflow = "Test", - AudiobookId = audiobook.Id, - ManagedRootFolderId = root.Id, - DirectoryObjectIdentityVersion = - ManagedDirectoryIdentity.CurrentVersion, - DirectoryObjectIdentity = ownershipIdentity - }); - await db.SaveChangesAsync(); - } - - var interrupted = CreateService(); - interrupted.AfterMetadataOnlyCommitForTest = () => - throw new IOException( - "Injected failure after metadata-only transaction commit."); - - var result = await interrupted.StartAsync( - rootId, - new RootFolderPathChangeCommand( - rootPath, - RootFolderRelocationMode.MetadataOnly, - false, - "Renamed Library", - false, - FileSystemCaseSensitivityMode.Sensitive)); - - Assert.Equal( - RootFolderRelocationStatus.NeedsAttention, - result.Status); - Assert.NotNull(result.RelocationId); - await using (var verification = - await _factory.CreateDbContextAsync()) - { - var root = await verification.RootFolders.SingleAsync(); - var relocation = await verification.RootFolderRelocations - .SingleAsync(); - var ownership = await verification - .LibraryDirectoryOwnerships.SingleAsync(); - var journal = await verification - .LibraryDirectoryOwnershipPathMigrations.SingleAsync(); - Assert.Equal("Renamed Library", root.Name); - Assert.Equal(targetOwnershipKey, ownership.PathOwnershipKey); - Assert.Equal( - LibraryDirectoryOwnershipPathMigrationState - .MarkerlessCommitted, - journal.State); - Assert.Equal(rootId, relocation.ActiveRootFolderId); - } - Assert.Empty(Directory.EnumerateFiles( - rootPath, - ".listenarr-*", - SearchOption.AllDirectories)); - - var retried = await CreateService().RetryAsync( - result.RelocationId!.Value); - - Assert.Equal( - RootFolderRelocationStatus.Completed, - retried.Status); - await using var recovered = await _factory.CreateDbContextAsync(); - var completed = await recovered.RootFolderRelocations.SingleAsync(); - Assert.Equal( - RootFolderRelocationStatus.Completed, - completed.Status); - Assert.Null(completed.ActiveRootFolderId); - Assert.False(await recovered - .LibraryDirectoryOwnershipPathMigrations.AnyAsync()); - } - [Fact] public async Task ReconcileOwnershipMigration_MetadataRollback_DoesNotLeakTrackedChanges() { @@ -2477,8 +2388,8 @@ public async Task ReconcileOwnershipMigration_MetadataRollback_DoesNotLeakTracke .LibraryDirectoryOwnerships.SingleAsync(); var relocationAfter = await verification .RootFolderRelocations.SingleAsync(); - var journalAfter = await verification - .LibraryDirectoryOwnershipPathMigrations.SingleAsync(); + Assert.True(await verification + .LibraryDirectoryOwnershipPathMigrations.AnyAsync()); Assert.Equal("Library", rootAfter.Name); Assert.Equal( scenario.SourceOwnershipKey, @@ -2489,116 +2400,73 @@ public async Task ReconcileOwnershipMigration_MetadataRollback_DoesNotLeakTracke Assert.Equal( RootFolderRelocationStatus.NeedsAttention, relocationAfter.Status); - Assert.Equal( - LibraryDirectoryOwnershipPathMigrationState.MarkersPublished, - journalAfter.State); - } - - [Fact] - public async Task ReconcileOwnershipMigration_SourceRetirementBlocked_PreservesJournalForRetry() - { - var scenario = await SeedPublishedOwnershipMigrationAsync(); - var blocked = CreateService(); - blocked.BeforeOwnershipMigrationSourceRetirementForTest = () => - throw new IOException("simulated locked source ownership marker"); - - await blocked.ReconcileActiveAsync(); - - await using (var verification = await _factory.CreateDbContextAsync()) - { - var relocation = await verification.RootFolderRelocations - .SingleAsync(candidate => candidate.Id == scenario.RelocationId); - var journal = await verification - .LibraryDirectoryOwnershipPathMigrations - .SingleAsync(candidate => - candidate.RelocationId == scenario.RelocationId); - Assert.Equal( - RootFolderRelocationStatus.NeedsAttention, - relocation.Status); - Assert.Contains( - "locked source ownership marker", - relocation.Error ?? string.Empty, - StringComparison.OrdinalIgnoreCase); - Assert.Equal( - LibraryDirectoryOwnershipPathMigrationState.MetadataCommitted, - journal.State); - } - - await CreateService().ReconcileActiveAsync(); - - await using var recovered = await _factory.CreateDbContextAsync(); - var completed = await recovered.RootFolderRelocations - .SingleAsync(candidate => candidate.Id == scenario.RelocationId); - Assert.Equal(RootFolderRelocationStatus.Completed, completed.Status); - Assert.False(await recovered.LibraryDirectoryOwnershipPathMigrations - .AnyAsync(candidate => - candidate.RelocationId == scenario.RelocationId)); } [Fact] - public async Task ReconcileOwnershipMigration_TargetGenerationReplacedAtSourceRetirement_PreservesSourceEvidence() + public async Task ReconcileOwnershipMigration_TargetGenerationReplacedAtAtomicCommit_BlocksCommit() { var scenario = await SeedPublishedOwnershipMigrationAsync(); - var displacedRoot = scenario.RootPath + ".original"; + var displacedPath = scenario.OwnedPath + ".original"; var service = CreateService(); - service.BeforeOwnershipMigrationSourceRetirementForTest = () => + service.BeforeOwnershipMigrationAtomicCommitForTest = () => { - Directory.Move(scenario.RootPath, displacedRoot); - Directory.CreateDirectory(scenario.RootPath); + Directory.Move(scenario.OwnedPath, displacedPath); + Directory.CreateDirectory(scenario.OwnedPath); }; await service.ReconcileActiveAsync(); await using var verification = await _factory.CreateDbContextAsync(); - var relocation = await verification.RootFolderRelocations - .SingleAsync(candidate => candidate.Id == scenario.RelocationId); - var journal = await verification.LibraryDirectoryOwnershipPathMigrations - .SingleAsync(candidate => candidate.RelocationId == scenario.RelocationId); - Assert.Equal(RootFolderRelocationStatus.NeedsAttention, relocation.Status); - Assert.Contains( - "physical generation", - relocation.Error ?? string.Empty, - StringComparison.OrdinalIgnoreCase); + var ownershipAfter = await verification + .LibraryDirectoryOwnerships.SingleAsync(); + var relocationAfter = await verification + .RootFolderRelocations.SingleAsync(); + Assert.True(await verification + .LibraryDirectoryOwnershipPathMigrations.AnyAsync()); Assert.Equal( - LibraryDirectoryOwnershipPathMigrationState.MetadataCommitted, - journal.State); - Assert.True(File.Exists(Path.Join( - displacedRoot, - $".listenarr-directory-owner-{scenario.OwnershipToken}.json"))); - Assert.False(File.Exists(Path.Join( - scenario.RootPath, - $".listenarr-directory-owner-{scenario.OwnershipToken}.json"))); + scenario.SourceOwnershipKey, + ownershipAfter.PathOwnershipKey); + Assert.Equal( + FileSystemCaseSensitivityMode.Auto, + ownershipAfter.PathCaseSensitivityMode); + Assert.Equal( + RootFolderRelocationStatus.NeedsAttention, + relocationAfter.Status); + Assert.True(Directory.Exists(displacedPath)); + Assert.True(Directory.Exists(scenario.OwnedPath)); } [Fact] - public async Task ReconcileOwnershipMigration_TargetGenerationReplaced_BlocksBeforeMetadataCommit() + public async Task ReconcileOwnershipMigration_TargetGenerationReplacedImmediatelyAfterCommit_MarksNeedsAttention() { var scenario = await SeedPublishedOwnershipMigrationAsync(); - var displacedPath = scenario.OwnedPath + ".original"; - Directory.Move(scenario.OwnedPath, displacedPath); - Directory.CreateDirectory(scenario.OwnedPath); + var displacedPath = scenario.OwnedPath + ".post-commit-original"; + var service = CreateService(); + service.AfterOwnershipMigrationAtomicCommitForTest = () => + { + Directory.Move(scenario.OwnedPath, displacedPath); + Directory.CreateDirectory(scenario.OwnedPath); + }; - await CreateService().ReconcileActiveAsync(); + await service.ReconcileActiveAsync(); await using var verification = await _factory.CreateDbContextAsync(); var ownershipAfter = await verification .LibraryDirectoryOwnerships.SingleAsync(); var relocationAfter = await verification .RootFolderRelocations.SingleAsync(); - var journalAfter = await verification - .LibraryDirectoryOwnershipPathMigrations.SingleAsync(); + Assert.False(await verification + .LibraryDirectoryOwnershipPathMigrations.AnyAsync()); Assert.Equal( - scenario.SourceOwnershipKey, + scenario.TargetOwnershipKey, ownershipAfter.PathOwnershipKey); - Assert.Equal( - FileSystemCaseSensitivityMode.Auto, - ownershipAfter.PathCaseSensitivityMode); Assert.Equal( RootFolderRelocationStatus.NeedsAttention, relocationAfter.Status); - Assert.Equal( - LibraryDirectoryOwnershipPathMigrationState.MarkersPublished, - journalAfter.State); + Assert.Contains( + "ownership migration recovery is blocked", + relocationAfter.Error, + StringComparison.OrdinalIgnoreCase); Assert.True(Directory.Exists(displacedPath)); Assert.True(Directory.Exists(scenario.OwnedPath)); } @@ -2652,153 +2520,8 @@ public async Task ReconcileOwnershipMigration_FirstFailure_DoesNotPoisonLaterSag candidate.RelocationId == second.RelocationId)); } - [Fact] - public async Task ReconcileOwnershipMigration_DistinctHardlinkedMarkersAreFullyRetired() - { - var scenario = await SeedPublishedOwnershipMigrationAsync(); - var sourceRoot = Path.Join( - TempRoot, - $"ownership-hardlink-source-{Guid.NewGuid():N}"); - var sourceOwnedPath = Path.Join(sourceRoot, "Book"); - Directory.CreateDirectory(sourceRoot); - await using (var db = await _factory.CreateDbContextAsync()) - { - var relocation = await db.RootFolderRelocations - .SingleAsync(candidate => candidate.Id == scenario.RelocationId); - var migration = await db.LibraryDirectoryOwnershipPathMigrations - .SingleAsync(candidate => - candidate.RelocationId == scenario.RelocationId); - var sourceResolution = await new FileSystemSemanticsResolver() - .ResolveAsync(sourceRoot); - Assert.Equal(PathIdentityState.Valid, sourceResolution.State); - relocation.SourcePath = sourceRoot; - migration.SourceCanonicalPath = sourceOwnedPath; - migration.SourcePathSyntax = sourceResolution.Semantics.Syntax; - migration.SourceCaseSensitivity = - sourceResolution.Semantics.CaseSensitivity; - migration.SourceCaseSensitivityMode = - FileSystemCaseSensitivityMode.Auto; - migration.SourceIdentityBoundary = sourceOwnedPath; - migration.SourceIdentityLookupKey = - FileSystemPathIdentity.CreateLookupKey( - "library-directory", - sourceOwnedPath, - sourceResolution.Semantics.Syntax); - migration.SourceOwnershipKey = - FileSystemPathIdentity.CreateKey( - "library-directory", - sourceOwnedPath, - sourceResolution.Semantics); - migration.State = - LibraryDirectoryOwnershipPathMigrationState.MetadataCommitted; - await db.SaveChangesAsync(); - - var ownership = await db.LibraryDirectoryOwnerships - .SingleAsync(candidate => candidate.Id == scenario.OwnershipId); - var payload = LibraryDirectoryOwnershipMarker.SerializePayload( - ownership); - await File.WriteAllTextAsync( - Path.Join( - scenario.OwnedPath, - LibraryDirectoryOwnershipMarker.FileName), - payload); - var targetSibling = Path.Join( - scenario.RootPath, - $".listenarr-directory-owner-{scenario.OwnershipToken}.json"); - await File.WriteAllTextAsync(targetSibling, payload); - using var targetParent = - PinnedDirectoryCreation.OpenPinnedBoundary(scenario.RootPath); - using var targetMarker = targetParent.OpenExistingFile( - Path.GetFileName(targetSibling), - requireDeleteAccess: false); - using var sourceParent = - PinnedDirectoryCreation.OpenPinnedBoundary(sourceRoot); - using var sourceMarker = targetMarker.CreateHardLinkTo( - sourceParent, - Path.GetFileName(targetSibling)); - Assert.True(sourceMarker.IdentifiesSameEntry(targetMarker)); - } - - await CreateService().ReconcileActiveAsync(); - - var sourceSibling = Path.Join( - sourceRoot, - $".listenarr-directory-owner-{scenario.OwnershipToken}.json"); - var targetSiblingAfter = Path.Join( - scenario.RootPath, - $".listenarr-directory-owner-{scenario.OwnershipToken}.json"); - Assert.False(File.Exists(sourceSibling)); - Assert.False(File.Exists(targetSiblingAfter)); - await using var verification = await _factory.CreateDbContextAsync(); - Assert.False(await verification - .LibraryDirectoryOwnershipPathMigrations.AnyAsync()); - } - - [Fact] - public async Task ReconcileOwnershipMigration_SourceAlreadyRetired_TargetReplaced_PreservesJournal() - { - var scenario = await SeedPublishedOwnershipMigrationAsync(); - var (sourceSibling, targetSibling) = - await PrepareDistinctOwnershipMigrationMarkersAsync(scenario); - var service = CreateService(); - service.BeforeOwnershipMigrationSourceRetirementForTest = () => - { - File.Delete(sourceSibling); - File.WriteAllText(targetSibling, "replacement marker"); - }; - - await service.ReconcileActiveAsync(); - - Assert.False(File.Exists(sourceSibling)); - Assert.Equal( - "replacement marker", - await File.ReadAllTextAsync(targetSibling)); - await using var verification = await _factory.CreateDbContextAsync(); - var relocation = await verification.RootFolderRelocations - .SingleAsync(candidate => candidate.Id == scenario.RelocationId); - var journal = await verification - .LibraryDirectoryOwnershipPathMigrations - .SingleAsync(candidate => - candidate.RelocationId == scenario.RelocationId); - Assert.Equal( - RootFolderRelocationStatus.NeedsAttention, - relocation.Status); - Assert.Contains( - "ownership marker", - relocation.Error ?? string.Empty, - StringComparison.OrdinalIgnoreCase); - Assert.Equal( - LibraryDirectoryOwnershipPathMigrationState.MetadataCommitted, - journal.State); - } - - [Fact] - public async Task ReconcileOwnershipMigration_EquivalentLegacyMarkersAreRetiredAfterCompletion() - { - var scenario = await SeedPublishedOwnershipMigrationAsync(); - - await CreateService().ReconcileActiveAsync(); - - var siblingMarker = Path.Join( - scenario.RootPath, - $".listenarr-directory-owner-{scenario.OwnershipToken}.json"); - var insideMarker = Path.Join( - scenario.OwnedPath, - LibraryDirectoryOwnershipMarker.FileName); - Assert.False(File.Exists(siblingMarker)); - Assert.False(File.Exists(insideMarker)); - await using var verification = await _factory.CreateDbContextAsync(); - var ownershipAfter = await verification - .LibraryDirectoryOwnerships.SingleAsync(); - Assert.Equal( - scenario.TargetOwnershipKey, - ownershipAfter.PathOwnershipKey); - Assert.False(await verification - .LibraryDirectoryOwnershipPathMigrations.AnyAsync()); - } - [DirectoryLinkFact] - public async Task MetadataOnly_LinkedSourceAndPhysicalTarget_RetiresLegacyOwnershipMarkers() + public async Task MetadataOnly_LinkedSourceAndPhysicalTarget_PreservesPhysicalIdentityWithoutSidecars() { var root = Path.Join( TempRoot, @@ -2894,17 +2617,6 @@ public async Task MetadataOnly_LinkedSourceAndPhysicalTarget_RetiresLegacyOwners await db.SaveChangesAsync(); } - using (var boundary = - PinnedDirectoryCreation.OpenPinnedBoundary(linkedRoot)) - using (var publication = - boundary.OpenExistingChildForPublication("Book")) - { - await PinnedLibraryDirectoryOwnershipMarker.EnsureAsync( - ownership, - publication, - CancellationToken.None); - } - var result = await CreateService().StartAsync( rootId, new RootFolderPathChangeCommand( @@ -2928,12 +2640,6 @@ await PinnedLibraryDirectoryOwnershipMarker.EnsureAsync( ownershipAfter.DirectoryObjectIdentity, ownershipAfter.OwnershipToken, ownedNativeIdentity)); - Assert.False(File.Exists(Path.Join( - physicalOwnedPath, - LibraryDirectoryOwnershipMarker.FileName))); - Assert.False(File.Exists(Path.Join( - physicalRoot, - $".listenarr-directory-owner-{ownership.OwnershipToken}.json"))); Assert.False(await verification .LibraryDirectoryOwnershipPathMigrations.AnyAsync()); } @@ -2949,7 +2655,7 @@ await PinnedLibraryDirectoryOwnershipMarker.EnsureAsync( } [DirectoryLinkFact] - public async Task MetadataOnly_PhysicalSourceAndLinkedTarget_RetiresLegacyOwnershipMarkers() + public async Task MetadataOnly_PhysicalSourceAndLinkedTarget_PreservesPhysicalIdentityWithoutSidecars() { var root = Path.Join( TempRoot, @@ -3045,17 +2751,6 @@ public async Task MetadataOnly_PhysicalSourceAndLinkedTarget_RetiresLegacyOwners await db.SaveChangesAsync(); } - using (var boundary = - PinnedDirectoryCreation.OpenPinnedBoundary(physicalRoot)) - using (var publication = - boundary.OpenExistingChildForPublication("Book")) - { - await PinnedLibraryDirectoryOwnershipMarker.EnsureAsync( - ownership, - publication, - CancellationToken.None); - } - var result = await CreateService().StartAsync( rootId, new RootFolderPathChangeCommand( @@ -3079,12 +2774,6 @@ await PinnedLibraryDirectoryOwnershipMarker.EnsureAsync( ownershipAfter.DirectoryObjectIdentity, ownershipAfter.OwnershipToken, ownedNativeIdentity)); - Assert.False(File.Exists(Path.Join( - linkedOwnedPath, - LibraryDirectoryOwnershipMarker.FileName))); - Assert.False(File.Exists(Path.Join( - physicalRoot, - $".listenarr-directory-owner-{ownership.OwnershipToken}.json"))); Assert.False(await verification .LibraryDirectoryOwnershipPathMigrations.AnyAsync()); } @@ -5016,82 +4705,11 @@ private async Task SeedActiveRelocationAsync( await db.SaveChangesAsync(); } - private async Task<(string SourceSibling, string TargetSibling)> - PrepareDistinctOwnershipMigrationMarkersAsync( - OwnershipMigrationScenario scenario) - { - var sourceRoot = Path.Join( - TempRoot, - $"ownership-retired-source-{Guid.NewGuid():N}"); - var sourceOwnedPath = Path.Join(sourceRoot, "Book"); - Directory.CreateDirectory(sourceRoot); - await using var db = await _factory.CreateDbContextAsync(); - var relocation = await db.RootFolderRelocations - .SingleAsync(candidate => candidate.Id == scenario.RelocationId); - var migration = await db.LibraryDirectoryOwnershipPathMigrations - .SingleAsync(candidate => - candidate.RelocationId == scenario.RelocationId); - var sourceResolution = await new FileSystemSemanticsResolver() - .ResolveAsync(sourceRoot); - Assert.Equal(PathIdentityState.Valid, sourceResolution.State); - relocation.SourcePath = sourceRoot; - migration.SourceCanonicalPath = sourceOwnedPath; - migration.SourcePathSyntax = sourceResolution.Semantics.Syntax; - migration.SourceCaseSensitivity = - sourceResolution.Semantics.CaseSensitivity; - migration.SourceCaseSensitivityMode = - FileSystemCaseSensitivityMode.Auto; - migration.SourceIdentityBoundary = sourceOwnedPath; - migration.SourceIdentityLookupKey = - FileSystemPathIdentity.CreateLookupKey( - "library-directory", - sourceOwnedPath, - sourceResolution.Semantics.Syntax); - migration.SourceOwnershipKey = - FileSystemPathIdentity.CreateKey( - "library-directory", - sourceOwnedPath, - sourceResolution.Semantics); - migration.State = - LibraryDirectoryOwnershipPathMigrationState.MetadataCommitted; - await db.SaveChangesAsync(); - - var ownership = await db.LibraryDirectoryOwnerships - .SingleAsync(candidate => candidate.Id == scenario.OwnershipId); - var payload = LibraryDirectoryOwnershipMarker.SerializePayload( - ownership); - await File.WriteAllTextAsync( - Path.Join( - scenario.OwnedPath, - LibraryDirectoryOwnershipMarker.FileName), - payload); - var targetSibling = Path.Join( - scenario.RootPath, - $".listenarr-directory-owner-{scenario.OwnershipToken}.json"); - await File.WriteAllTextAsync(targetSibling, payload); - using var targetParent = - PinnedDirectoryCreation.OpenPinnedBoundary(scenario.RootPath); - using var targetMarker = targetParent.OpenExistingFile( - Path.GetFileName(targetSibling), - requireDeleteAccess: false); - using var sourceParent = - PinnedDirectoryCreation.OpenPinnedBoundary(sourceRoot); - var sourceSibling = Path.Join( - sourceRoot, - Path.GetFileName(targetSibling)); - using var sourceMarker = targetMarker.CreateHardLinkTo( - sourceParent, - Path.GetFileName(targetSibling)); - Assert.True(sourceMarker.IdentifiesSameEntry(targetMarker)); - return (sourceSibling, targetSibling); - } - private sealed record OwnershipMigrationScenario( long OwnershipId, Guid RelocationId, string RootPath, string OwnedPath, - string OwnershipToken, string SourceOwnershipKey, string TargetOwnershipKey); @@ -5218,30 +4836,14 @@ private async Task "library-directory", ownedPath, targetSemantics.Syntax), - TargetOwnershipKey = targetOwnershipKey, - State = - LibraryDirectoryOwnershipPathMigrationState - .MarkersPublished + TargetOwnershipKey = targetOwnershipKey }); await db.SaveChangesAsync(); - var publishedPayload = - LibraryDirectoryOwnershipMarker.SerializePayload(ownership); - await File.WriteAllTextAsync( - Path.Join( - ownedPath, - LibraryDirectoryOwnershipMarker.FileName), - publishedPayload); - await File.WriteAllTextAsync( - Path.Join( - rootPath, - $".listenarr-directory-owner-{ownership.OwnershipToken}.json"), - publishedPayload); return new OwnershipMigrationScenario( ownership.Id, relocation.Id, rootPath, ownedPath, - ownership.OwnershipToken, sourceOwnershipKey, targetOwnershipKey); } diff --git a/tests/Features/Infrastructure/Migrations/MigrationMetadataTests.cs b/tests/Features/Infrastructure/Migrations/MigrationMetadataTests.cs index 448b24c2b..e6712940d 100644 --- a/tests/Features/Infrastructure/Migrations/MigrationMetadataTests.cs +++ b/tests/Features/Infrastructure/Migrations/MigrationMetadataTests.cs @@ -6,210 +6,133 @@ * it under the terms of the GNU Affero General Public License as published * by the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . */ using System.Reflection; using Listenarr.Infrastructure.Persistence.Migrations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations.Operations; -namespace Listenarr.Tests.Features.Infrastructure.Migrations +namespace Listenarr.Tests.Features.Infrastructure.Migrations; + +public class MigrationMetadataTests { - public class MigrationMetadataTests + [Fact] + public void AddImportBlacklistExtensionsMigration_IsDiscoverableByEf() + { + AssertMigrationId( + "20260317123000_AddImportBlacklistExtensionsToApplicationSettings"); + } + + [Fact] + public void AddMoveJobSourcePathHistoryRepair_IsDiscoverableByEf() + { + AssertMigrationId( + "20251124102000_AddMoveJobSourcePath"); + } + + [Fact] + public void AddProcessExecutionLogsHistoryRepair_IsDiscoverableByEf() + { + AssertMigrationId( + "20260702200000_AddProcessExecutionLogs"); + } + + [Fact] + public void AddDurableMarkerlessLibraryMoves_IsDiscoverableAndConsolidated() + { + AssertMigrationId( + "20260807200942_AddDurableMarkerlessLibraryMoves"); + + var migration = new AddDurableMarkerlessLibraryMoves(); + var upBuilder = BuildOperations(migration, "Up"); + var downBuilder = BuildOperations(migration, "Down"); + + Assert.Equal(78, upBuilder.Operations.Count); + Assert.Equal(59, downBuilder.Operations.Count); + Assert.Empty(upBuilder.Operations.OfType()); + + var createdTables = upBuilder.Operations + .OfType() + .Select(operation => operation.Name) + .ToHashSet(StringComparer.Ordinal); + Assert.Contains("FileMutationJournals", createdTables); + Assert.Contains("LibraryDirectoryOwnerships", createdTables); + Assert.Contains("MoveJobEntries", createdTables); + Assert.Contains("MoveScanHandoffs", createdTables); + Assert.Contains("RootFolderRelocations", createdTables); + Assert.DoesNotContain("LibraryDirectoryOwnershipRetiredMarkers", createdTables); + + Assert.DoesNotContain(upBuilder.Operations, operation => + operation is DropTableOperation or DropColumnOperation); + } + + [Fact] + public void AddMoveJobRelocationForeignKey_IsDiscoverableAndIsolated() + { + AssertMigrationId( + "20260807204014_AddMoveJobRelocationForeignKey"); + + var migration = new AddMoveJobRelocationForeignKey(); + var upBuilder = BuildOperations(migration, "Up"); + var downBuilder = BuildOperations(migration, "Down"); + + var add = Assert.Single(upBuilder.Operations.OfType()); + Assert.Equal("FK_MoveJobs_RootFolderRelocations_RelocationId", add.Name); + Assert.Equal("MoveJobs", add.Table); + Assert.Equal("RootFolderRelocations", add.PrincipalTable); + Assert.Equal("RelocationId", Assert.Single(add.Columns)); + Assert.Equal(ReferentialAction.Restrict, add.OnDelete); + Assert.Single(upBuilder.Operations); + + var drop = Assert.Single(downBuilder.Operations.OfType()); + Assert.Equal("FK_MoveJobs_RootFolderRelocations_RelocationId", drop.Name); + Assert.Equal("MoveJobs", drop.Table); + Assert.Single(downBuilder.Operations); + } + + [Fact] + public void AddDurableMarkerlessLibraryMoves_TargetModelMatchesFinalContracts() + { + var model = new AddDurableMarkerlessLibraryMoves().TargetModel; + + var moveJob = AssertEntity(model, "Listenarr.Domain.Audiobooks.MoveJob"); + Assert.Equal(0, moveJob.FindProperty("ExecutionProtocolVersion")?.GetDefaultValue()); + Assert.Equal("None", moveJob.FindProperty("FailureKind")?.GetDefaultValue()); + Assert.Equal("None", moveJob.FindProperty("Phase")?.GetDefaultValue()); + + var rootFolder = AssertEntity(model, "Listenarr.Domain.Audiobooks.RootFolder"); + Assert.Equal("Auto", rootFolder.FindProperty("CaseSensitivityMode")?.GetDefaultValue()); + Assert.Equal("Unknown", rootFolder.FindProperty("ResolvedCaseSensitivity")?.GetDefaultValue()); + Assert.Equal("Unavailable", rootFolder.FindProperty("PathIdentityState")?.GetDefaultValue()); + + var audiobookFile = AssertEntity(model, "Listenarr.Domain.Audiobooks.AudiobookFile"); + Assert.Equal("Auto", audiobookFile.FindProperty("PathCaseSensitivityMode")?.GetDefaultValue()); + Assert.Equal("Unknown", audiobookFile.FindProperty("PathCaseSensitivity")?.GetDefaultValue()); + Assert.Equal("Unavailable", audiobookFile.FindProperty("PathIdentityState")?.GetDefaultValue()); + + Assert.Null(model.FindEntityType( + "Listenarr.Domain.Audiobooks.LibraryDirectoryOwnershipRetiredMarker")); + } + + private static MigrationBuilder BuildOperations(Migration migration, string methodName) + { + var builder = new MigrationBuilder("Microsoft.EntityFrameworkCore.Sqlite"); + migration.GetType() + .GetMethod(methodName, BindingFlags.Instance | BindingFlags.NonPublic)! + .Invoke(migration, [builder]); + return builder; + } + + private static IEntityType AssertEntity(IModel model, string name) => + Assert.IsAssignableFrom(model.FindEntityType(name)); + + private static void AssertMigrationId(string expected) + where TMigration : Migration { - [Fact] - public void AddImportBlacklistExtensionsMigration_IsDiscoverableByEf() - { - var attribute = typeof(AddImportBlacklistExtensionsToApplicationSettings) - .GetCustomAttribute(); - - Assert.NotNull(attribute); - Assert.Equal("20260317123000_AddImportBlacklistExtensionsToApplicationSettings", attribute!.Id); - } - - [Fact] - public void AddRootFolderRelocationSkippedItemsMigration_IsDiscoverableByEf() - { - var attribute = typeof(AddRootFolderRelocationSkippedItems) - .GetCustomAttribute(); - - Assert.NotNull(attribute); - Assert.Equal("20260708224900_AddRootFolderRelocationSkippedItems", attribute!.Id); - } - - [Fact] - public void AddLibraryDirectoryOwnershipRootForeignKey_IsDiscoverableAndIsolated() - { - var attribute = typeof(AddLibraryDirectoryOwnershipRootForeignKey) - .GetCustomAttribute(); - Assert.NotNull(attribute); - Assert.Equal( - "20260805034058_AddLibraryDirectoryOwnershipRootForeignKey", - attribute!.Id); - - var migration = new AddLibraryDirectoryOwnershipRootForeignKey(); - var upBuilder = new MigrationBuilder( - "Microsoft.EntityFrameworkCore.Sqlite"); - var downBuilder = new MigrationBuilder( - "Microsoft.EntityFrameworkCore.Sqlite"); - typeof(AddLibraryDirectoryOwnershipRootForeignKey) - .GetMethod( - "Up", - BindingFlags.Instance | BindingFlags.NonPublic)! - .Invoke(migration, [upBuilder]); - typeof(AddLibraryDirectoryOwnershipRootForeignKey) - .GetMethod( - "Down", - BindingFlags.Instance | BindingFlags.NonPublic)! - .Invoke(migration, [downBuilder]); - - var addForeignKey = Assert.Single(upBuilder.Operations); - Assert.Equal( - "FK_LibraryDirectoryOwnerships_RootFolders_ManagedRootFolderId", - Assert.IsType(addForeignKey).Name); - var dropForeignKey = Assert.Single(downBuilder.Operations); - Assert.Equal( - "FK_LibraryDirectoryOwnerships_RootFolders_ManagedRootFolderId", - Assert.IsType(dropForeignKey).Name); - } - - [Fact] - public void AddMarkerlessMoveExecutionState_IsDiscoverableAndContainsOnlyExpectedColumns() - { - var attribute = typeof(AddMarkerlessMoveExecutionState) - .GetCustomAttribute(); - Assert.NotNull(attribute); - Assert.Equal( - "20260805192525_AddMarkerlessMoveExecutionState", - attribute!.Id); - - var migration = new AddMarkerlessMoveExecutionState(); - var upBuilder = new MigrationBuilder( - "Microsoft.EntityFrameworkCore.Sqlite"); - var downBuilder = new MigrationBuilder( - "Microsoft.EntityFrameworkCore.Sqlite"); - typeof(AddMarkerlessMoveExecutionState) - .GetMethod( - "Up", - BindingFlags.Instance | BindingFlags.NonPublic)! - .Invoke(migration, [upBuilder]); - typeof(AddMarkerlessMoveExecutionState) - .GetMethod( - "Down", - BindingFlags.Instance | BindingFlags.NonPublic)! - .Invoke(migration, [downBuilder]); - - var expectedColumns = new[] - { - "DirectoryObjectIdentity", - "ExecutionProtocolVersion", - "SourceDirectoryCleanupState", - "SourceDirectoryObjectIdentity", - "SourcePhysicalObjectIdentity", - "TargetDirectoryObjectIdentity", - "TargetPhysicalObjectIdentity" - }; - Assert.Equal( - expectedColumns, - upBuilder.Operations - .Select(operation => Assert.IsType(operation).Name) - .OrderBy(name => name, StringComparer.Ordinal) - .ToArray()); - Assert.Equal( - expectedColumns, - downBuilder.Operations - .Select(operation => Assert.IsType(operation).Name) - .OrderBy(name => name, StringComparer.Ordinal) - .ToArray()); - } - - [Fact] - public void AddMarkerlessFileMutationJournal_IsDiscoverableAndIsolated() - { - var attribute = typeof(AddMarkerlessFileMutationJournal) - .GetCustomAttribute(); - Assert.NotNull(attribute); - Assert.Equal( - "20260805202154_AddMarkerlessFileMutationJournal", - attribute!.Id); - - var migration = new AddMarkerlessFileMutationJournal(); - var upBuilder = new MigrationBuilder( - "Microsoft.EntityFrameworkCore.Sqlite"); - var downBuilder = new MigrationBuilder( - "Microsoft.EntityFrameworkCore.Sqlite"); - typeof(AddMarkerlessFileMutationJournal) - .GetMethod( - "Up", - BindingFlags.Instance | BindingFlags.NonPublic)! - .Invoke(migration, [upBuilder]); - typeof(AddMarkerlessFileMutationJournal) - .GetMethod( - "Down", - BindingFlags.Instance | BindingFlags.NonPublic)! - .Invoke(migration, [downBuilder]); - - var create = Assert.Single( - upBuilder.Operations.OfType()); - Assert.Equal("FileMutationJournals", create.Name); - Assert.Equal( - [ - "Action", - "AudiobookId", - "CreatedAt", - "DestinationPath", - "Error", - "OperationId", - "ProtocolVersion", - "SourceLength", - "SourcePath", - "SourcePhysicalObjectIdentity", - "SourceSha256", - "State", - "TargetPhysicalObjectIdentity", - "UpdatedAt" - ], - create.Columns - .Select(column => column.Name) - .OrderBy(name => name, StringComparer.Ordinal) - .ToArray()); - Assert.Equal(2, upBuilder.Operations.OfType().Count()); - Assert.Equal(3, upBuilder.Operations.Count); - Assert.Equal( - "FileMutationJournals", - Assert.Single(downBuilder.Operations.OfType()).Name); - Assert.Single(downBuilder.Operations); - } - - [Fact] - public void OwnershipRecoveryProtocols_ContainsNoRawSqlOperations() - { - var migration = new AddOwnershipRecoveryProtocols(); - var upBuilder = new MigrationBuilder( - "Microsoft.EntityFrameworkCore.Sqlite"); - var downBuilder = new MigrationBuilder( - "Microsoft.EntityFrameworkCore.Sqlite"); - - typeof(AddOwnershipRecoveryProtocols) - .GetMethod( - "Up", - BindingFlags.Instance | BindingFlags.NonPublic)! - .Invoke(migration, [upBuilder]); - typeof(AddOwnershipRecoveryProtocols) - .GetMethod( - "Down", - BindingFlags.Instance | BindingFlags.NonPublic)! - .Invoke(migration, [downBuilder]); - - Assert.Empty(upBuilder.Operations.OfType()); - Assert.Empty(downBuilder.Operations.OfType()); - } + var attribute = typeof(TMigration).GetCustomAttribute(); + Assert.NotNull(attribute); + Assert.Equal(expected, attribute!.Id); } } diff --git a/tests/Features/Infrastructure/Persistence/EfMoveQueuePersistenceTests.cs b/tests/Features/Infrastructure/Persistence/EfMoveQueuePersistenceTests.cs index 12d1095d5..da46cea16 100644 --- a/tests/Features/Infrastructure/Persistence/EfMoveQueuePersistenceTests.cs +++ b/tests/Features/Infrastructure/Persistence/EfMoveQueuePersistenceTests.cs @@ -8,7 +8,6 @@ * (at your option) any later version. */ -using System.Text.Json; using Listenarr.Infrastructure.Persistence.Repositories; using Listenarr.Tests.Mocks; using Microsoft.EntityFrameworkCore; @@ -189,6 +188,43 @@ public async Task ReconcileIdentityKeys_SelectsMostAdvancedLegacyDuplicate() Assert.StartsWith("v6:move-source:42:", jobs[1].ActiveDeduplicationKey); } + [Theory] + [InlineData(MoveExecutionProtocol.PreDurableReleased)] + [InlineData(1)] + public async Task ReconcileIdentityKeys_UnsupportedExecutionProtocol_RequiresAttention( + int executionProtocolVersion) + { + var job = new MoveJob + { + AudiobookId = 42, + SourcePath = Path.GetFullPath(Path.Join(Path.GetTempPath(), "unsupported-source")), + RequestedPath = Path.GetFullPath(Path.Join(Path.GetTempPath(), "unsupported-target")), + Status = MoveJobStatus.Queued, + Phase = MoveJobPhase.Published, + ExecutionProtocolVersion = executionProtocolVersion, + ActiveDeduplicationKey = $"unsupported:{Guid.NewGuid():N}", + Entries = CreateAuthorizedManifestEntries() + }; + await using (var db = await _factory.CreateDbContextAsync()) + { + db.MoveJobs.Add(job); + await db.SaveChangesAsync(); + } + + await CreatePersistence().ReconcileIdentityKeysAsync(); + + await using var verification = await _factory.CreateDbContextAsync(); + var persisted = await verification.MoveJobs.AsNoTracking() + .SingleAsync(candidate => candidate.Id == job.Id); + Assert.Equal(MoveJobStatus.NeedsAttention, persisted.Status); + Assert.Equal(MoveFailureKind.Verification, persisted.FailureKind); + Assert.Null(persisted.ActiveDeduplicationKey); + Assert.Contains( + "predates the durable database execution protocol", + persisted.Error ?? string.Empty, + StringComparison.OrdinalIgnoreCase); + } + [Fact] public async Task ReconcileIdentityKeys_LegacyActiveJobWithoutTargetGeneration_RequiresAttention() { @@ -618,365 +654,6 @@ public async Task ReconcileIdentityKeysAsync_SameManifestWithExecutionStateOnBot }); } - [Fact] - public async Task ReconcileIdentityKeysAsync_UnstructuredRecoveryMarkerRequiresAttention() - { - var target = Path.Join( - Path.GetTempPath(), - "listenarr-tests", - $"move-reconcile-marker-{Guid.NewGuid():N}"); - Directory.CreateDirectory(target); - MoveJob owner; - MoveJob duplicate; - await using (var db = await _factory.CreateDbContextAsync()) - { - owner = new MoveJob - { - AudiobookId = 42, - RequestedPath = target, - SourcePath = target + "-source", - Status = MoveJobStatus.Queued, - Phase = MoveJobPhase.Planned, - IdentityKeyVersion = 1, - ActiveDeduplicationKey = "legacy:owner", - Entries = CreateAuthorizedManifestEntries() - }; - duplicate = new MoveJob - { - AudiobookId = 42, - RequestedPath = target, - SourcePath = target + "-source", - Status = MoveJobStatus.Queued, - Phase = MoveJobPhase.Planned, - IdentityKeyVersion = 1, - ActiveDeduplicationKey = "legacy:duplicate", - Entries = CreateAuthorizedManifestEntries() - }; - db.MoveJobs.AddRange(owner, duplicate); - await db.SaveChangesAsync(); - } - await File.WriteAllTextAsync( - Path.Join(target, $".listenarr-move-{owner.Id:N}.pending"), - "owned evidence"); - - await CreatePersistence().ReconcileIdentityKeysAsync(); - - await using var verification = await _factory.CreateDbContextAsync(); - var persistedOwner = await verification.MoveJobs.AsNoTracking() - .SingleAsync(job => job.Id == owner.Id); - var persistedDuplicate = await verification.MoveJobs.AsNoTracking() - .SingleAsync(job => job.Id == duplicate.Id); - Assert.Equal(MoveJobStatus.NeedsAttention, persistedOwner.Status); - Assert.Null(persistedOwner.ActiveDeduplicationKey); - Assert.Equal(MoveJobStatus.NeedsAttention, persistedDuplicate.Status); - Assert.Null(persistedDuplicate.ActiveDeduplicationKey); - } - - [Fact] - public async Task ReconcileIdentityKeysAsync_MismatchedTargetOwnershipMarkerRequiresAttention() - { - var target = Path.Join( - Path.GetTempPath(), - "listenarr-tests", - $"move-reconcile-marker-mismatch-{Guid.NewGuid():N}"); - Directory.CreateDirectory(target); - MoveJob owner; - MoveJob duplicate; - await using (var db = await _factory.CreateDbContextAsync()) - { - owner = new MoveJob - { - AudiobookId = 42, - RequestedPath = target, - SourcePath = target + "-source", - Status = MoveJobStatus.Queued, - Phase = MoveJobPhase.Planned, - IdentityKeyVersion = 1, - ActiveDeduplicationKey = "legacy:owner", - Entries = CreateAuthorizedManifestEntries() - }; - duplicate = new MoveJob - { - AudiobookId = 42, - RequestedPath = target, - SourcePath = owner.SourcePath, - Status = MoveJobStatus.Queued, - Phase = MoveJobPhase.Planned, - IdentityKeyVersion = 1, - ActiveDeduplicationKey = "legacy:duplicate", - Entries = CreateAuthorizedManifestEntries() - }; - db.MoveJobs.AddRange(owner, duplicate); - await db.SaveChangesAsync(); - } - - var targetParent = Path.GetDirectoryName(target)!; - await File.WriteAllTextAsync( - Path.Join(target, ".listenarr-temp-owner.json"), - JsonSerializer.Serialize(new - { - Version = 1, - ArtifactType = "temporary-directory", - JobId = owner.Id, - Source = owner.SourcePath + "-wrong", - Target = target, - DirectoryPath = Path.Join( - targetParent, - Path.GetFileName(target) + ".tmp-" + owner.Id.ToString("N")) - })); - - await CreatePersistence().ReconcileIdentityKeysAsync(); - - await using var verification = await _factory.CreateDbContextAsync(); - var jobs = await verification.MoveJobs.AsNoTracking().ToListAsync(); - Assert.Equal(2, jobs.Count); - Assert.All(jobs, job => - { - Assert.Equal(MoveJobStatus.NeedsAttention, job.Status); - Assert.Contains("ownership marker", job.Error, StringComparison.OrdinalIgnoreCase); - Assert.Null(job.ActiveDeduplicationKey); - }); - } - - [Fact] - public async Task ReconcileIdentityKeysAsync_StructuredTargetOwnershipMarkerPreservesOwner() - { - var target = Path.Join( - Path.GetTempPath(), - "listenarr-tests", - $"move-reconcile-structured-marker-{Guid.NewGuid():N}"); - Directory.CreateDirectory(target); - MoveJob owner; - MoveJob duplicate; - await using (var db = await _factory.CreateDbContextAsync()) - { - owner = new MoveJob - { - AudiobookId = 42, - RequestedPath = target, - SourcePath = target + "-source", - Status = MoveJobStatus.Queued, - Phase = MoveJobPhase.Planned, - IdentityKeyVersion = 1, - ActiveDeduplicationKey = "legacy:owner", - Entries = CreateAuthorizedManifestEntries() - }; - duplicate = new MoveJob - { - AudiobookId = 42, - RequestedPath = target, - SourcePath = owner.SourcePath, - Status = MoveJobStatus.Queued, - Phase = MoveJobPhase.Planned, - IdentityKeyVersion = 1, - ActiveDeduplicationKey = "legacy:duplicate", - Entries = CreateAuthorizedManifestEntries() - }; - db.MoveJobs.AddRange(owner, duplicate); - await db.SaveChangesAsync(); - } - - var targetParent = Path.GetDirectoryName(target)!; - await File.WriteAllTextAsync( - Path.Join(target, ".listenarr-temp-owner.json"), - JsonSerializer.Serialize(new - { - Version = 1, - ArtifactType = "temporary-directory", - JobId = owner.Id, - Source = owner.SourcePath, - Target = target, - DirectoryPath = Path.Join( - targetParent, - Path.GetFileName(target) + ".tmp-" + owner.Id.ToString("N")) - })); - - await CreatePersistence().ReconcileIdentityKeysAsync(); - - await using var verification = await _factory.CreateDbContextAsync(); - var persistedOwner = await verification.MoveJobs.AsNoTracking() - .SingleAsync(job => job.Id == owner.Id); - var persistedDuplicate = await verification.MoveJobs.AsNoTracking() - .SingleAsync(job => job.Id == duplicate.Id); - Assert.Equal(MoveJobStatus.Queued, persistedOwner.Status); - Assert.NotNull(persistedOwner.ActiveDeduplicationKey); - Assert.Equal(MoveJobStatus.Superseded, persistedDuplicate.Status); - Assert.Null(persistedDuplicate.ActiveDeduplicationKey); - } - - [Fact] - public async Task ReconcileIdentityKeysAsync_TargetOwnershipMarkerReplacedDuringPinnedOpen_RequiresAttention() - { - var target = Path.Join( - Path.GetTempPath(), - "listenarr-tests", - $"move-reconcile-marker-race-{Guid.NewGuid():N}"); - Directory.CreateDirectory(target); - MoveJob owner; - MoveJob duplicate; - await using (var db = await _factory.CreateDbContextAsync()) - { - owner = new MoveJob - { - AudiobookId = 42, - RequestedPath = target, - SourcePath = target + "-source", - Status = MoveJobStatus.Queued, - Phase = MoveJobPhase.Planned, - IdentityKeyVersion = 1, - ActiveDeduplicationKey = "legacy:owner", - Entries = CreateAuthorizedManifestEntries() - }; - duplicate = new MoveJob - { - AudiobookId = 42, - RequestedPath = target, - SourcePath = owner.SourcePath, - Status = MoveJobStatus.Queued, - Phase = MoveJobPhase.Planned, - IdentityKeyVersion = 1, - ActiveDeduplicationKey = "legacy:duplicate", - Entries = CreateAuthorizedManifestEntries() - }; - db.MoveJobs.AddRange(owner, duplicate); - await db.SaveChangesAsync(); - } - - var markerPath = Path.Join(target, ".listenarr-temp-owner.json"); - var targetParent = Path.GetDirectoryName(target)!; - await File.WriteAllTextAsync( - markerPath, - JsonSerializer.Serialize(new - { - Version = 1, - ArtifactType = "temporary-directory", - JobId = owner.Id, - Source = owner.SourcePath, - Target = target, - DirectoryPath = Path.Join( - targetParent, - Path.GetFileName(target) + ".tmp-" + owner.Id.ToString("N")) - })); - var replaced = false; - using var hook = ExclusiveDirectoryCreator.PushBeforeOpenParentHook(path => - { - if (replaced - || !string.Equals( - Path.GetFullPath(path), - Path.GetFullPath(markerPath), - StringComparison.OrdinalIgnoreCase)) - { - return; - } - - replaced = true; - File.Delete(markerPath); - File.WriteAllText(markerPath, "{\"Version\":1,\"ArtifactType\":\"temporary-directory\"}"); - }); - - await CreatePersistence().ReconcileIdentityKeysAsync(); - - Assert.True(replaced); - await using var verification = await _factory.CreateDbContextAsync(); - var jobs = await verification.MoveJobs.AsNoTracking().ToListAsync(); - Assert.All(jobs, job => - { - Assert.Equal(MoveJobStatus.NeedsAttention, job.Status); - Assert.Null(job.ActiveDeduplicationKey); - }); - } - - [Theory] - [InlineData("source-marker-write")] - [InlineData("target-partial")] - [InlineData("target-ownership-write")] - [InlineData("target-cleanup")] - public async Task ReconcileIdentityKeysAsync_ResidualFilesystemEvidenceRequiresAttention( - string evidenceKind) - { - var root = Path.Join( - Path.GetTempPath(), - "listenarr-tests", - $"move-reconcile-residual-{Guid.NewGuid():N}"); - var source = Path.Join(root, "source"); - var target = Path.Join(root, "target"); - MoveJob owner; - MoveJob duplicate; - await using (var db = await _factory.CreateDbContextAsync()) - { - owner = new MoveJob - { - AudiobookId = 42, - RequestedPath = target, - SourcePath = source, - Status = MoveJobStatus.Queued, - Phase = MoveJobPhase.Planned, - IdentityKeyVersion = 1, - ActiveDeduplicationKey = "legacy:owner", - Entries = CreateAuthorizedManifestEntries() - }; - duplicate = new MoveJob - { - AudiobookId = 42, - RequestedPath = target, - SourcePath = source, - Status = MoveJobStatus.Queued, - Phase = MoveJobPhase.Planned, - IdentityKeyVersion = 1, - ActiveDeduplicationKey = "legacy:duplicate", - Entries = CreateAuthorizedManifestEntries() - }; - db.MoveJobs.AddRange(owner, duplicate); - await db.SaveChangesAsync(); - } - - Directory.CreateDirectory(root); - switch (evidenceKind) - { - case "source-marker-write": - Directory.CreateDirectory(source); - await File.WriteAllTextAsync( - Path.Join( - source, - $".listenarr-move-{owner.Id:N}.pending.writing-{owner.Id:N}-g1-{Guid.NewGuid():N}"), - "incomplete marker"); - break; - case "target-partial": - Directory.CreateDirectory(Path.Join(target, "nested")); - await File.WriteAllTextAsync( - Path.Join(target, "nested", $"book.m4b.listenarr-{owner.Id:N}.partial"), - "partial"); - break; - case "target-ownership-write": - Directory.CreateDirectory(target); - await File.WriteAllTextAsync( - Path.Join( - target, - $".listenarr-temp-owner.json.writing-{owner.Id:N}-g1-{Guid.NewGuid():N}"), - "incomplete ownership marker"); - break; - case "target-cleanup": - await File.WriteAllTextAsync( - Path.Join(root, $".listenarr-temp-directory-{owner.Id:N}.cleanup.json"), - "cleanup evidence"); - break; - default: - throw new InvalidOperationException($"Unknown evidence kind: {evidenceKind}"); - } - - await CreatePersistence().ReconcileIdentityKeysAsync(); - - await using var verification = await _factory.CreateDbContextAsync(); - var persistedOwner = await verification.MoveJobs.AsNoTracking() - .SingleAsync(job => job.Id == owner.Id); - var persistedDuplicate = await verification.MoveJobs.AsNoTracking() - .SingleAsync(job => job.Id == duplicate.Id); - Assert.Equal(MoveJobStatus.NeedsAttention, persistedOwner.Status); - Assert.Null(persistedOwner.ActiveDeduplicationKey); - Assert.Equal(MoveJobStatus.NeedsAttention, persistedDuplicate.Status); - Assert.Null(persistedDuplicate.ActiveDeduplicationKey); - } - [Fact] public async Task ReconcileIdentityKeysAsync_MalformedLegacyJobMarksNeedsAttentionAndContinues() { diff --git a/tests/Features/Infrastructure/Persistence/RootFolderObjectIdentityReconcilerTests.cs b/tests/Features/Infrastructure/Persistence/RootFolderObjectIdentityReconcilerTests.cs index 91130fe3f..10835d028 100644 --- a/tests/Features/Infrastructure/Persistence/RootFolderObjectIdentityReconcilerTests.cs +++ b/tests/Features/Infrastructure/Persistence/RootFolderObjectIdentityReconcilerTests.cs @@ -52,7 +52,7 @@ public async Task ReconcileAsync_AmbiguousPersistedRoot_DoesNotEnrollWindowsDevi StringComparison.OrdinalIgnoreCase); } -private sealed class TestDbContextFactory( + private sealed class TestDbContextFactory( DbContextOptions options) : IDbContextFactory { diff --git a/tests/Features/Infrastructure/Persistence/SqliteMigrationSchemaTests.cs b/tests/Features/Infrastructure/Persistence/SqliteMigrationSchemaTests.cs index 51f711de0..5e74e15f2 100644 --- a/tests/Features/Infrastructure/Persistence/SqliteMigrationSchemaTests.cs +++ b/tests/Features/Infrastructure/Persistence/SqliteMigrationSchemaTests.cs @@ -6,1558 +6,464 @@ * it under the terms of the GNU Affero General Public License as published * by the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . */ +using Listenarr.Infrastructure.DependencyInjection; +using Listenarr.Tests.Common; using Microsoft.Data.Sqlite; -using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; -using System.Data.Common; -using Listenarr.Tests.Common; +namespace Listenarr.Tests.Features.Infrastructure.Persistence; -namespace Listenarr.Tests.Features.Infrastructure.Persistence +/// +/// Exercises the real SQLite migration pipeline. These tests intentionally +/// validate final migration contracts rather than intermediate PR-only schema. +/// +[Trait("Area", "Persistence")] +[Trait("Name", "SqliteMigrationSchemaTests")] +[Trait("Category", "Infrastructure")] +public class SqliteMigrationSchemaTests : BaseTests { - /// - /// Migrations must be scaffolded with dotnet ef, and many tests still run - /// on the EF InMemory provider — which never executes them. Missing migration - /// metadata once let a migration ship without its [Migration] attribute + Designer - /// (20251124102000_AddMoveJobSourcePath): EF discovery never saw it, so every - /// SQLite install was missing MoveJobs.SourcePath while the model mapped it, - /// and the first full-entity query failed at runtime ("no such column"). - /// These tests migrate a REAL SQLite database and verify the outcome so that - /// class of drift fails CI instead of production. - /// - [Trait("Area", "Persistence")] - [Trait("Name", "SqliteMigrationSchemaTests")] - [Trait("Category", "Infrastructure")] - public class SqliteMigrationSchemaTests : BaseTests + private const string CanaryMigrationFrontierId = + "20260621002226_AddApplicationSettingsConcurrency"; + private const string MoveJobSourcePathRepairId = + "20251124102000_AddMoveJobSourcePath"; + private const string ProcessExecutionLogRepairId = + "20260702200000_AddProcessExecutionLogs"; + private const string ConsolidatedMigrationId = + "20260807200942_AddDurableMarkerlessLibraryMoves"; + private const string MoveJobRelocationForeignKeyMigrationId = + "20260807204014_AddMoveJobRelocationForeignKey"; + + private static (SqliteConnection Connection, ListenArrDbContext Context) + CreateMigratedSqliteContext() { - private const string CanaryMigrationFrontierId = - "20260621002226_AddApplicationSettingsConcurrency"; - private const string PhysicalIdentityMigrationId = - "20260730033245_AddPhysicalFileIdentityAndMoveCleanupProtection"; - private const string PhysicalIdentityMigrationPredecessorId = - "20260727000644_AddOwnershipRecoveryProtocols"; - private const string MarkerlessMoveMigrationId = - "20260805192525_AddMarkerlessMoveExecutionState"; - private const string MarkerlessFileMutationMigrationId = - "20260805202154_AddMarkerlessFileMutationJournal"; - - public static TheoryData ChangedMigrationIds => new() - { - "20251124102000_AddMoveJobSourcePath", - "20260702200000_AddProcessExecutionLogs", - "20260703024452_AddMoveJobDeleteEmptySource", - "20260708223635_AddDurableFilesystemMoves", - "20260708224312_AddMoveJobRelocationForeignKey", - "20260708224705_AddMoveJobLeaseGeneration", - "20260708224900_AddRootFolderRelocationSkippedItems", - "20260708225028_MakeRootFolderRelocationRootNullable", - "20260708225100_DropRootFolderRelocationRootForeignKey", - "20260708225144_SetRootFolderRelocationRootDeleteBehavior", - "20260710172532_AddMoveJobSourceCleanupBoundary", - "20260713181804_HardenMoveExecutionAndScanHandoffs", - "20260717143713_AddLibraryDirectoryOwnership", - "20260726042801_AddDirectoryObjectIdentityAuthorization", - "20260727000644_AddOwnershipRecoveryProtocols", - PhysicalIdentityMigrationId, - MarkerlessMoveMigrationId, - MarkerlessFileMutationMigrationId - }; - - private static (SqliteConnection Connection, ListenArrDbContext Context) CreateMigratedSqliteContext() - { - // Shared in-memory database lives as long as the connection is open. - var connection = new SqliteConnection("DataSource=:memory:"); - connection.Open(); - - var options = new DbContextOptionsBuilder() - .UseSqlite(connection, sqlite => - sqlite.MigrationsAssembly(typeof(ListenArrDbContext).Assembly.GetName().Name)) - .Options; - - var context = new ListenArrDbContext(options); - context.Database.Migrate(); - return (connection, context); - } - - [Fact] - [Trait("Scenario", "EveryModelColumnExistsAfterMigrate")] - public void EveryMappedColumn_ExistsInMigratedSqliteSchema() - { - var (connection, context) = CreateMigratedSqliteContext(); - using var _conn = connection; - using var _ctx = context; - - var failures = new List(); - - foreach (var entityType in context.Model.GetEntityTypes()) - { - var tableName = entityType.GetTableName(); - if (string.IsNullOrEmpty(tableName)) - { - continue; // not mapped to a table (owned/view/keyless) - } - - var storeObject = Microsoft.EntityFrameworkCore.Metadata.StoreObjectIdentifier.Table(tableName, entityType.GetSchema()); - var columns = entityType.GetProperties() - .Select(p => p.GetColumnName(storeObject)) - .Where(c => !string.IsNullOrEmpty(c)) - .Distinct() - .ToList(); - if (columns.Count == 0) - { - continue; - } - - // SELECT every mapped column with LIMIT 0: succeeds only when the - // migrated schema actually contains each one. - var columnList = string.Join(", ", columns.Select(c => $"\"{c}\"")); - using var command = connection.CreateCommand(); - command.CommandText = $"SELECT {columnList} FROM \"{tableName}\" LIMIT 0"; - try - { - using var reader = command.ExecuteReader(); - } - catch (SqliteException ex) - { - failures.Add($"{tableName}: {ex.Message}"); - } - } - - Assert.True(failures.Count == 0, - "Model maps columns the migrated SQLite schema does not have — a migration is missing, " - + "not discovered (missing [Migration] attribute / Designer), or out of sync:\n" - + string.Join("\n", failures)); - } - - [Fact] - [Trait("Scenario", "PullRequestMigrationsHaveNoNonTransactionalOperationWarnings")] - public async Task PullRequestMigrations_AfterCanaryFrontier_HaveNoNonTransactionalOperationWarnings() - { - await using var connection = new SqliteConnection("DataSource=:memory:"); - await connection.OpenAsync(); - - var baselineOptions = new DbContextOptionsBuilder() - .UseSqlite(connection, sqlite => - sqlite.MigrationsAssembly(typeof(ListenArrDbContext).Assembly.GetName().Name)) - .Options; - await using (var baseline = new ListenArrDbContext(baselineOptions)) - { - await baseline.GetService().MigrateAsync( - CanaryMigrationFrontierId); - } - - var guardedOptions = new DbContextOptionsBuilder() - .UseSqlite(connection, sqlite => - sqlite.MigrationsAssembly(typeof(ListenArrDbContext).Assembly.GetName().Name)) - .ConfigureWarnings(warnings => warnings.Throw( - RelationalEventId.NonTransactionalMigrationOperationWarning)) - .Options; - await using var guarded = new ListenArrDbContext(guardedOptions); - - await guarded.Database.MigrateAsync(); - } - - [Fact] - [Trait("Scenario", "MigrationHistoryMatchesModel")] - public async Task MigrationHistory_HasNoPendingModelChanges() - { - await using var connection = new SqliteConnection("DataSource=:memory:"); - await connection.OpenAsync(); - - var options = new DbContextOptionsBuilder() - .UseSqlite(connection, sqlite => - sqlite.MigrationsAssembly(typeof(ListenArrDbContext).Assembly.GetName().Name)) - .Options; - - await using var context = new ListenArrDbContext(options); - await context.Database.MigrateAsync(); - - Assert.False( - context.Database.HasPendingModelChanges(), - "The configured EF model differs from the accumulated migration snapshots. " - + "Regenerate migrations with dotnet ef migrations add instead of hand-authoring them."); - } - - [Fact] - [Trait("Scenario", "AudiobookFileOwnershipIdentityDefaultsAndIndexes")] - public async Task AudiobookFileOwnershipMigration_PreservesRowsAndCreatesOwnershipIndexes() - { - await using var connection = new SqliteConnection("DataSource=:memory:"); - await connection.OpenAsync(); - - var options = new DbContextOptionsBuilder() - .UseSqlite(connection, sqlite => - sqlite.MigrationsAssembly(typeof(ListenArrDbContext).Assembly.GetName().Name)) - .Options; - - await using var context = new ListenArrDbContext(options); - var migrator = context.GetService(); - await migrator.MigrateAsync("20260713181804_HardenMoveExecutionAndScanHandoffs"); - await context.Database.ExecuteSqlRawAsync( - """ - INSERT INTO "Audiobooks" ("Id", "Explicit", "Abridged", "Monitored") - VALUES (1, 0, 0, 1) - """); - await context.Database.ExecuteSqlRawAsync( - """ - INSERT INTO "AudiobookFiles" ("AudiobookId", "Path", "CreatedAt") - VALUES (1, '/library/book-one.m4b', CURRENT_TIMESTAMP), - (1, '/library/book-two.m4b', CURRENT_TIMESTAMP) - """); - - await migrator.MigrateAsync("20260717143713_AddLibraryDirectoryOwnership"); - var repairResult = ListenarrDatabaseMigrationPreflight - .RepairPostMigrationData(context); - Assert.Equal(0, repairResult.MoveJobsRepaired); - Assert.Equal(2, repairResult.AudiobookFilesRepaired); - - await using (var command = connection.CreateCommand()) - { - command.CommandText = - """ - SELECT "PathCaseSensitivity", "PathCaseSensitivityMode", - "PathIdentityVersion", "PathIdentityState" - FROM "AudiobookFiles" - ORDER BY "Id" - LIMIT 1 - """; - await using var reader = await command.ExecuteReaderAsync(); - Assert.True(await reader.ReadAsync()); - Assert.Equal("Unknown", reader.GetString(0)); - Assert.Equal("Auto", reader.GetString(1)); - Assert.Equal(1, reader.GetInt32(2)); - Assert.Equal("Unavailable", reader.GetString(3)); - } - - await using (var command = connection.CreateCommand()) - { - command.CommandText = - """ - SELECT COUNT(*) - FROM pragma_index_list('AudiobookFiles') - WHERE name IN ( - 'IX_AudiobookFiles_PathIdentityLookupKey', - 'IX_AudiobookFiles_PathOwnershipKey') - """; - Assert.Equal(2L, (long)(await command.ExecuteScalarAsync())!); - } - - await context.Database.ExecuteSqlRawAsync( - "UPDATE \"AudiobookFiles\" SET \"PathOwnershipKey\" = 'owned:path' WHERE \"Id\" = 1"); - await Assert.ThrowsAsync(() => - context.Database.ExecuteSqlRawAsync( - "UPDATE \"AudiobookFiles\" SET \"PathOwnershipKey\" = 'owned:path' WHERE \"Id\" = 2")); - } - - [Fact] - [Trait("Scenario", "PhysicalIdentityAndCleanupProtectionMigration")] - public async Task PhysicalIdentityMigration_PreservesLegacyRowsAcrossUpgradeAndDowngrade() - { - await using var connection = new SqliteConnection("DataSource=:memory:"); - await connection.OpenAsync(); - - var options = new DbContextOptionsBuilder() - .UseSqlite(connection, sqlite => - sqlite.MigrationsAssembly(typeof(ListenArrDbContext).Assembly.GetName().Name)) - .Options; - var moveJobId = Guid.NewGuid(); - - await using var context = new ListenArrDbContext(options); - var migrations = context.Database.GetMigrations().ToList(); - Assert.Single(migrations, migration => migration == PhysicalIdentityMigrationId); - Assert.DoesNotContain( - "20260728190000_AddAudiobookFilePhysicalIdentity", - migrations); - Assert.DoesNotContain( - "20260728193000_AddMoveCleanupProtectionVersion", - migrations); - - var migrator = context.GetService(); - await migrator.MigrateAsync(PhysicalIdentityMigrationPredecessorId); - Assert.False(await ColumnExistsAsync( - connection, - "AudiobookFiles", - "PhysicalObjectIdentity")); - Assert.False(await ColumnExistsAsync( - connection, - "MoveJobEntries", - "CleanupProtectionVersion")); - - await context.Database.ExecuteSqlRawAsync( - """ - INSERT INTO "Audiobooks" ("Id", "Explicit", "Abridged", "Monitored") - VALUES (101, 0, 0, 1) - """); - await context.Database.ExecuteSqlRawAsync( - """ - INSERT INTO "AudiobookFiles" ("Id", "AudiobookId", "Path", "CreatedAt", - "PathCaseSensitivity", "PathCaseSensitivityMode", "PathIdentityVersion", - "PathIdentityState") - VALUES (201, 101, '/library/legacy.m4b', CURRENT_TIMESTAMP, - 'Unknown', 'Auto', 1, 'Unavailable') - """); - await context.Database.ExecuteSqlRawAsync( - """ - INSERT INTO "MoveJobs" ("Id", "AudiobookId", "EnqueuedAt", "Status", - "AttemptCount", "DeleteEmptySource", "FailureKind", "IdentityKeyVersion", - "LeaseGeneration", "Phase") - VALUES ({0}, 101, CURRENT_TIMESTAMP, 'Queued', 0, 0, 'None', 5, 0, 'None') - """, - moveJobId); - await context.Database.ExecuteSqlRawAsync( - """ - INSERT INTO "MoveJobEntries" ("Id", "MoveJobId", "RelativePath", "EntryType", - "Length", "LastWriteTimeUtc", "CopyState", "CleanupState") - VALUES (301, {0}, 'legacy.m4b', 'File', 1234, CURRENT_TIMESTAMP, - 'Pending', 'Pending') - """, - moveJobId); - - await migrator.MigrateAsync(PhysicalIdentityMigrationId); - - await using (var command = connection.CreateCommand()) - { - command.CommandText = - """ - SELECT "PhysicalIdentityVersion", "PhysicalObjectIdentity", - "PhysicalIdentityObservedAtUtc" - FROM "AudiobookFiles" - WHERE "Id" = 201 - """; - await using var reader = await command.ExecuteReaderAsync(); - Assert.True(await reader.ReadAsync()); - Assert.Equal(1, reader.GetInt32(0)); - Assert.True(reader.IsDBNull(1)); - Assert.True(reader.IsDBNull(2)); - } - - Assert.Equal( - 0L, - (long)(await ExecuteScalarAsync( - connection, - "SELECT \"CleanupProtectionVersion\" FROM \"MoveJobEntries\" WHERE \"Id\" = 301"))!); - Assert.True(await IndexExistsAsync( - connection, - "MoveJobEntries", - "IX_MoveJobEntries_MoveJobId_RelativePath")); - - await migrator.MigrateAsync(PhysicalIdentityMigrationPredecessorId); - - Assert.False(await ColumnExistsAsync( - connection, - "AudiobookFiles", - "PhysicalIdentityObservedAtUtc")); - Assert.False(await ColumnExistsAsync( - connection, - "AudiobookFiles", - "PhysicalIdentityVersion")); - Assert.False(await ColumnExistsAsync( - connection, - "AudiobookFiles", - "PhysicalObjectIdentity")); - Assert.False(await ColumnExistsAsync( - connection, - "MoveJobEntries", - "CleanupProtectionVersion")); - Assert.Equal( - "/library/legacy.m4b", - (string)(await ExecuteScalarAsync( - connection, - "SELECT \"Path\" FROM \"AudiobookFiles\" WHERE \"Id\" = 201"))!); - Assert.Equal( - "legacy.m4b", - (string)(await ExecuteScalarAsync( - connection, - "SELECT \"RelativePath\" FROM \"MoveJobEntries\" WHERE \"Id\" = 301"))!); - Assert.True(await IndexExistsAsync( - connection, - "MoveJobEntries", - "IX_MoveJobEntries_MoveJobId_RelativePath")); - Assert.True(await IndexExistsAsync( - connection, - "AudiobookFiles", - "IX_AudiobookFiles_PathIdentityLookupKey")); - Assert.True(await IndexExistsAsync( - connection, - "AudiobookFiles", - "IX_AudiobookFiles_PathOwnershipKey")); - - await using var foreignKeyCheck = connection.CreateCommand(); - foreignKeyCheck.CommandText = "PRAGMA foreign_key_check;"; - await using var foreignKeyReader = await foreignKeyCheck.ExecuteReaderAsync(); - Assert.False(await foreignKeyReader.ReadAsync()); - } - - [Fact] - [Trait("Scenario", "LibraryDirectoryOwnershipIndexes")] - public async Task LibraryDirectoryOwnershipMigration_CreatesDurableOwnershipIndexes() - { - await using var connection = new SqliteConnection("DataSource=:memory:"); - await connection.OpenAsync(); - - var options = new DbContextOptionsBuilder() - .UseSqlite(connection, sqlite => - sqlite.MigrationsAssembly(typeof(ListenArrDbContext).Assembly.GetName().Name)) - .Options; - - await using var context = new ListenArrDbContext(options); - await context.Database.MigrateAsync(); - - await using (var command = connection.CreateCommand()) - { - command.CommandText = - """ - SELECT COUNT(*) - FROM pragma_index_list('LibraryDirectoryOwnerships') - WHERE name IN ( - 'IX_LibraryDirectoryOwnerships_CreationOperationId_State', - 'IX_LibraryDirectoryOwnerships_OwnershipToken', - 'IX_LibraryDirectoryOwnerships_PathIdentityLookupKey', - 'IX_LibraryDirectoryOwnerships_PathOwnershipKey') - """; - Assert.Equal(4L, (long)(await command.ExecuteScalarAsync())!); - } - - const string insertSql = - """ - INSERT INTO "LibraryDirectoryOwnerships" ( - "Path", "CanonicalPath", "PathSyntax", "PathCaseSensitivity", - "PathCaseSensitivityMode", "PathIdentityBoundary", - "PathIdentityLookupKey", "PathOwnershipKey", "OwnershipToken", - "State", "CreationWorkflow", "CreatedAt", "UpdatedAt") - VALUES ({0}, {0}, 'Unix', 'Sensitive', 'Sensitive', '/library', - {1}, {2}, {3}, 'Owned', 'migration-test', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) - """; - await context.Database.ExecuteSqlRawAsync( - insertSql, - "/library/author-one", - "lookup:one", - "ownership:one", - "11111111111111111111111111111111"); - await context.Database.ExecuteSqlRawAsync( - insertSql, - "/library/author-two", - "lookup:two", - null, - "22222222222222222222222222222222"); - - await Assert.ThrowsAsync(() => - context.Database.ExecuteSqlRawAsync( - "UPDATE \"LibraryDirectoryOwnerships\" SET \"PathOwnershipKey\" = 'ownership:one' WHERE \"OwnershipToken\" = '22222222222222222222222222222222'")); - await Assert.ThrowsAsync(() => - context.Database.ExecuteSqlRawAsync( - insertSql, - "/library/author-three", - "lookup:three", - null, - "11111111111111111111111111111111")); - } - - [Fact] - [Trait("Scenario", "SingleDefaultRootFolderInvariant")] - public async Task SingleDefaultRootFolderMigration_ReconcilesDuplicatesAndEnforcesUniqueness() - { - await using var connection = new SqliteConnection("DataSource=:memory:"); - await connection.OpenAsync(); - - var options = new DbContextOptionsBuilder() - .UseSqlite(connection, sqlite => - sqlite.MigrationsAssembly(typeof(ListenArrDbContext).Assembly.GetName().Name)) - .Options; - - await using var context = new ListenArrDbContext(options); - var migrator = context.GetService(); - await migrator.MigrateAsync("20260717143713_AddLibraryDirectoryOwnership"); - await context.Database.ExecuteSqlRawAsync( - """ - INSERT INTO "RootFolders" ("Id", "Name", "Path", "IsDefault") - VALUES (10, 'First', '/library/first', 1), - (20, 'Second', '/library/second', 1), - (30, 'Third', '/library/third', 0) - """); - - var repaired = ListenarrDatabaseMigrationPreflight.RepairLegacyData(context); - Assert.Equal(1, repaired.DefaultRootsNormalized); - await migrator.MigrateAsync(); - - var defaults = await context.RootFolders - .AsNoTracking() - .Where(root => root.IsDefault) - .Select(root => root.Id) - .ToListAsync(); - Assert.Equal([10], defaults); - await Assert.ThrowsAsync(() => - context.Database.ExecuteSqlRawAsync( - "UPDATE \"RootFolders\" SET \"IsDefault\" = 1 WHERE \"Id\" = 20")); - } - - [Fact] - [Trait("Scenario", "OwnershipRootForeignKeyUpgrade")] - public async Task OwnershipRootForeignKeyMigration_AddsSetNullRelationship() - { - await using var connection = new SqliteConnection("DataSource=:memory:"); - await connection.OpenAsync(); - var options = new DbContextOptionsBuilder() - .UseSqlite(connection, sqlite => - sqlite.MigrationsAssembly( - typeof(ListenArrDbContext).Assembly.GetName().Name)) - .Options; - await using var context = new ListenArrDbContext(options); - var migrator = context.GetService(); - await migrator.MigrateAsync( - "20260726042801_AddDirectoryObjectIdentityAuthorization"); - await context.Database.ExecuteSqlRawAsync( - """ - INSERT INTO "RootFolders" ("Id", "Name", "Path", "IsDefault") - VALUES (1, 'Library', '/library', 0); - - INSERT INTO "LibraryDirectoryOwnerships" ( - "Id", "Path", "CanonicalPath", "PathSyntax", - "PathCaseSensitivity", "PathCaseSensitivityMode", - "PathIdentityBoundary", "PathIdentityLookupKey", - "PathOwnershipKey", "OwnershipToken", "State", - "CreationWorkflow", "CreatedAt", "UpdatedAt", - "ManagedRootFolderId") - VALUES ( - 10, '/library/book', '/library/book', 'Unix', - 'Sensitive', 'Sensitive', '/library/book', 'lookup-10', - 'ownership-10', '10101010101010101010101010101010', - 'Owned', 'test', '2026-07-27T00:00:00Z', - '2026-07-27T00:00:00Z', 1); - """); - - await migrator.MigrateAsync( - "20260805034058_AddLibraryDirectoryOwnershipRootForeignKey"); + var connection = new SqliteConnection("DataSource=:memory:"); + connection.Open(); + var context = new ListenArrDbContext(CreateOptions(connection)); + context.Database.Migrate(); + return (connection, context); + } - await using (var foreignKeyCommand = connection.CreateCommand()) - { - foreignKeyCommand.CommandText = - """ - SELECT "on_delete" - FROM pragma_foreign_key_list('LibraryDirectoryOwnerships') - WHERE "table" = 'RootFolders' - AND "from" = 'ManagedRootFolderId' - """; - Assert.Equal( - "SET NULL", - (await foreignKeyCommand.ExecuteScalarAsync())?.ToString()); - } + private static DbContextOptions CreateOptions( + SqliteConnection connection) => + new DbContextOptionsBuilder() + .UseSqlite(connection, sqlite => + sqlite.MigrationsAssembly(typeof(ListenArrDbContext).Assembly.GetName().Name)) + .Options; - await context.Database.ExecuteSqlRawAsync( - "DELETE FROM \"RootFolders\" WHERE \"Id\" = 1"); - await using var ownershipCommand = connection.CreateCommand(); - ownershipCommand.CommandText = - "SELECT \"ManagedRootFolderId\" FROM \"LibraryDirectoryOwnerships\" WHERE \"Id\" = 10"; - Assert.Equal(DBNull.Value, await ownershipCommand.ExecuteScalarAsync()); - } + [Fact] + [Trait("Scenario", "EveryModelColumnExistsAfterMigrate")] + public void EveryMappedColumn_ExistsInMigratedSqliteSchema() + { + var (connection, context) = CreateMigratedSqliteContext(); + using var _conn = connection; + using var _ctx = context; + var failures = new List(); - [Fact] - [Trait("Scenario", "MarkerlessMoveExecutionStateUpgrade")] - public async Task MarkerlessMoveExecutionStateMigration_PreservesLegacyRowsAndDefaults() + foreach (var entityType in context.Model.GetEntityTypes()) { - await using var connection = new SqliteConnection("DataSource=:memory:"); - await connection.OpenAsync(); - var options = new DbContextOptionsBuilder() - .UseSqlite(connection, sqlite => - sqlite.MigrationsAssembly( - typeof(ListenArrDbContext).Assembly.GetName().Name)) - .Options; - await using var context = new ListenArrDbContext(options); - var migrator = context.GetService(); - await migrator.MigrateAsync( - "20260805034058_AddLibraryDirectoryOwnershipRootForeignKey"); - - var moveJobId = Guid.NewGuid(); - await context.Database.ExecuteSqlRawAsync( - """ - INSERT INTO "MoveJobs" ( - "Id", "AudiobookId", "EnqueuedAt", "Status", - "AttemptCount", "DeleteEmptySource", "FailureKind", - "IdentityKeyVersion", "LeaseGeneration", "Phase") - VALUES ({0}, 501, CURRENT_TIMESTAMP, 'Queued', 0, 1, - 'None', 5, 0, 'None') - """, - moveJobId); - await context.Database.ExecuteSqlRawAsync( - """ - INSERT INTO "MoveJobEntries" ( - "MoveJobId", "RelativePath", "EntryType", "Length", - "LastWriteTimeUtc", "CopyState", "CleanupState", - "CleanupProtectionVersion") - VALUES ({0}, 'book.m4b', 'File', 1234, CURRENT_TIMESTAMP, - 'Pending', 'Pending', 0) - """, - moveJobId); - await context.Database.ExecuteSqlRawAsync( - """ - INSERT INTO "MoveJobCreatedDirectories" ( - "MoveJobId", "Path", "State") - VALUES ({0}, '/library/author/book', 'Planned') - """, - moveJobId); - - await migrator.MigrateAsync(MarkerlessMoveMigrationId); - - await using (var command = connection.CreateCommand()) + var tableName = entityType.GetTableName(); + if (string.IsNullOrEmpty(tableName)) { - command.CommandText = - """ - SELECT "ExecutionProtocolVersion", - "SourceDirectoryCleanupState", - "SourceDirectoryObjectIdentity", - "TargetDirectoryObjectIdentity" - FROM "MoveJobs" - WHERE "Id" = $jobId - """; - command.Parameters.AddWithValue("$jobId", moveJobId); - await using var reader = await command.ExecuteReaderAsync(); - Assert.True(await reader.ReadAsync()); - Assert.Equal(MoveExecutionProtocol.LegacyFilesystemArtifacts, reader.GetInt32(0)); - Assert.Equal("Pending", reader.GetString(1)); - Assert.True(reader.IsDBNull(2)); - Assert.True(reader.IsDBNull(3)); + continue; } - await using (var command = connection.CreateCommand()) + var storeObject = Microsoft.EntityFrameworkCore.Metadata.StoreObjectIdentifier.Table( + tableName, + entityType.GetSchema()); + var columns = entityType.GetProperties() + .Select(property => property.GetColumnName(storeObject)) + .Where(column => !string.IsNullOrEmpty(column)) + .Distinct() + .ToList(); + if (columns.Count == 0) { - command.CommandText = - """ - SELECT "SourcePhysicalObjectIdentity", - "TargetPhysicalObjectIdentity" - FROM "MoveJobEntries" - WHERE "MoveJobId" = $jobId - """; - command.Parameters.AddWithValue("$jobId", moveJobId); - await using var reader = await command.ExecuteReaderAsync(); - Assert.True(await reader.ReadAsync()); - Assert.True(reader.IsDBNull(0)); - Assert.True(reader.IsDBNull(1)); + continue; } - Assert.Equal( - DBNull.Value, - await ExecuteScalarAsync( - connection, - "SELECT \"DirectoryObjectIdentity\" FROM \"MoveJobCreatedDirectories\"")); - - await migrator.MigrateAsync( - "20260805034058_AddLibraryDirectoryOwnershipRootForeignKey"); - Assert.False(await ColumnExistsAsync( - connection, - "MoveJobs", - "ExecutionProtocolVersion")); - Assert.False(await ColumnExistsAsync( - connection, - "MoveJobEntries", - "TargetPhysicalObjectIdentity")); - Assert.False(await ColumnExistsAsync( - connection, - "MoveJobCreatedDirectories", - "DirectoryObjectIdentity")); - } - - [Fact] - [Trait("Scenario", "MarkerlessFileMutationJournalUpgrade")] - public async Task MarkerlessFileMutationJournalMigration_CreatesDurableDefaultsAndIndexes() - { - await using var connection = new SqliteConnection("DataSource=:memory:"); - await connection.OpenAsync(); - var options = new DbContextOptionsBuilder() - .UseSqlite(connection, sqlite => - sqlite.MigrationsAssembly( - typeof(ListenArrDbContext).Assembly.GetName().Name)) - .Options; - await using var context = new ListenArrDbContext(options); - var migrator = context.GetService(); - await migrator.MigrateAsync(MarkerlessMoveMigrationId); - Assert.False(await TableExistsAsync( - connection, - "FileMutationJournals")); - - await migrator.MigrateAsync(MarkerlessFileMutationMigrationId); - Assert.True(await TableExistsAsync( - connection, - "FileMutationJournals")); - var operationId = Guid.NewGuid(); - await context.Database.ExecuteSqlRawAsync( - """ - INSERT INTO "FileMutationJournals" ( - "OperationId", "Action", "SourcePath", "DestinationPath", - "SourcePhysicalObjectIdentity", "SourceLength", "State", - "CreatedAt", "UpdatedAt") - VALUES ({0}, 'Move', '/source/book.m4b', - '/library/book.m4b', 'source-generation', 123, - 'Planned', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) - """, - operationId); - - await using (var command = connection.CreateCommand()) + using var command = connection.CreateCommand(); + command.CommandText = $"SELECT {string.Join(", ", columns.Select(column => $"\"{column}\""))} FROM \"{tableName}\" LIMIT 0"; + try { - command.CommandText = - """ - SELECT "ProtocolVersion", "State", - "TargetPhysicalObjectIdentity", "AudiobookId" - FROM "FileMutationJournals" - WHERE "OperationId" = $operationId - """; - command.Parameters.AddWithValue("$operationId", operationId); - await using var reader = await command.ExecuteReaderAsync(); - Assert.True(await reader.ReadAsync()); - Assert.Equal( - FileMutationProtocol.MarkerlessDatabaseState, - reader.GetInt32(0)); - Assert.Equal("Planned", reader.GetString(1)); - Assert.True(reader.IsDBNull(2)); - Assert.True(reader.IsDBNull(3)); + using var reader = command.ExecuteReader(); } - - await using (var command = connection.CreateCommand()) + catch (SqliteException exception) { - command.CommandText = - """ - SELECT group_concat("name", ',') - FROM ( - SELECT "name" - FROM pragma_index_list('FileMutationJournals') - WHERE "name" LIKE 'IX_FileMutationJournals_%' - ORDER BY "name") - """; - Assert.Equal( - "IX_FileMutationJournals_State," - + "IX_FileMutationJournals_UpdatedAt", - (await command.ExecuteScalarAsync())?.ToString()); + failures.Add($"{tableName}: {exception.Message}"); } - - await migrator.MigrateAsync(MarkerlessMoveMigrationId); - Assert.False(await TableExistsAsync( - connection, - "FileMutationJournals")); } - [Fact] - [Trait("Scenario", "IntermediatePrDatabaseOrphanRepair")] - public async Task MigrationPreflight_RepairsOrphanOwnershipReferencesBeforeForeignKey() - { - await using var connection = new SqliteConnection("DataSource=:memory:"); - await connection.OpenAsync(); - var options = new DbContextOptionsBuilder() - .UseSqlite(connection, sqlite => - sqlite.MigrationsAssembly( - typeof(ListenArrDbContext).Assembly.GetName().Name)) - .Options; - await using var context = new ListenArrDbContext(options); - await context.GetService().MigrateAsync( - LibraryDirectoryOwnershipMigrationPreflight.PredecessorMigrationId); - await context.Database.ExecuteSqlRawAsync( - """ - INSERT INTO "LibraryDirectoryOwnerships" ( - "Id", "Path", "CanonicalPath", "PathSyntax", - "PathCaseSensitivity", "PathCaseSensitivityMode", - "PathIdentityBoundary", "PathIdentityLookupKey", - "PathOwnershipKey", "OwnershipToken", "State", - "CreationWorkflow", "CreatedAt", "UpdatedAt", - "ManagedRootFolderId", "StateReason") - VALUES - (101, '/removed', '/removed', 'Unix', 'Sensitive', - 'Sensitive', '/removed', 'lookup-101', NULL, - '10110110110110110110110110110110', 'Removed', 'test', - '2026-08-01T00:00:00Z', '2026-08-01T00:00:00Z', 999, - 'Legacy diagnostic' || char(10) || 'second line'), - (202, '/owned', '/owned', 'Unix', 'Sensitive', - 'Sensitive', '/owned', 'lookup-202', 'ownership-202', - '20220220220220220220220220220220', 'Owned', 'test', - '2026-08-01T00:00:00Z', '2026-08-01T00:00:00Z', 999, - NULL); - """); - - Assert.Equal( - 2, - LibraryDirectoryOwnershipMigrationPreflight - .RepairLegacyForeignKeyReferences(context)); - Assert.Equal( - 0, - LibraryDirectoryOwnershipMigrationPreflight - .RepairLegacyForeignKeyReferences(context)); - - await using (var verifyCommand = connection.CreateCommand()) - { - verifyCommand.CommandText = - """ - SELECT group_concat( - "Id" || ':' || "State" || ':' - || coalesce("ManagedRootFolderId", '') || ':' - || coalesce("PathOwnershipKey", '') || ':' - || coalesce("StateReason", ''), ',') - FROM ( - SELECT "Id", "State", "ManagedRootFolderId", - "PathOwnershipKey", "StateReason" - FROM "LibraryDirectoryOwnerships" - ORDER BY "Id") - """; - Assert.Equal( - "101:Removed:::migration:original-managed-root:999\n" - + "Legacy diagnostic\nsecond line," - + "202:Unavailable:::The persisted managed root no longer exists.", - (await verifyCommand.ExecuteScalarAsync())?.ToString()); - } - - var guardedOptions = new DbContextOptionsBuilder() - .UseSqlite(connection, sqlite => - sqlite.MigrationsAssembly( - typeof(ListenArrDbContext).Assembly.GetName().Name)) - .ConfigureWarnings(warnings => warnings.Throw( - RelationalEventId.NonTransactionalMigrationOperationWarning)) - .Options; - await using var guarded = new ListenArrDbContext(guardedOptions); - await guarded.Database.MigrateAsync(); - - await using var foreignKeyCheckCommand = connection.CreateCommand(); - foreignKeyCheckCommand.CommandText = "PRAGMA foreign_key_check;"; - await using var reader = await foreignKeyCheckCommand.ExecuteReaderAsync(); - Assert.False(await reader.ReadAsync()); - } - - [Fact] - [Trait("Scenario", "IntermediatePrDatabaseCompatibility")] - public async Task IntermediatePrDatabase_RetiredForeignKeyHistory_IsTolerated() - { - await using var connection = new SqliteConnection("DataSource=:memory:"); - await connection.OpenAsync(); - - var baselineOptions = new DbContextOptionsBuilder() - .UseSqlite(connection, sqlite => - sqlite.MigrationsAssembly( - typeof(ListenArrDbContext).Assembly.GetName().Name)) - .Options; - await using (var baseline = new ListenArrDbContext(baselineOptions)) - { - await baseline.Database.MigrateAsync(); - await baseline.Database.ExecuteSqlRawAsync( - """ - DELETE FROM "__EFMigrationsHistory" - WHERE "MigrationId" = - '20260805034058_AddLibraryDirectoryOwnershipRootForeignKey'; - - INSERT OR IGNORE INTO "__EFMigrationsHistory" ( - "MigrationId", "ProductVersion") - VALUES ( - '20260726500000_AddLibraryDirectoryOwnershipRootForeignKey', - '10.0.8'); - """); - } - - var guardedOptions = new DbContextOptionsBuilder() - .UseSqlite(connection, sqlite => - sqlite.MigrationsAssembly( - typeof(ListenArrDbContext).Assembly.GetName().Name)) - .ConfigureWarnings(warnings => warnings.Throw( - RelationalEventId.NonTransactionalMigrationOperationWarning)) - .Options; - await using (var guarded = new ListenArrDbContext(guardedOptions)) - { - await guarded.Database.MigrateAsync(); - } - - await using var historyCommand = connection.CreateCommand(); - historyCommand.CommandText = - """ - SELECT COUNT(*) - FROM "__EFMigrationsHistory" - WHERE "MigrationId" IN ( - '20260726500000_AddLibraryDirectoryOwnershipRootForeignKey', - '20260727000644_AddOwnershipRecoveryProtocols', - '20260805034058_AddLibraryDirectoryOwnershipRootForeignKey') - """; - Assert.Equal(3L, (long)(await historyCommand.ExecuteScalarAsync())!); - - await using var integrityCommand = connection.CreateCommand(); - integrityCommand.CommandText = "PRAGMA integrity_check;"; - Assert.Equal("ok", (await integrityCommand.ExecuteScalarAsync())?.ToString()); - - await using var foreignKeyCheckCommand = connection.CreateCommand(); - foreignKeyCheckCommand.CommandText = "PRAGMA foreign_key_check;"; - await using var reader = await foreignKeyCheckCommand.ExecuteReaderAsync(); - Assert.False(await reader.ReadAsync()); - } - - [Fact] - [Trait("Scenario", "OwnershipRecoveryProtocolRetry")] - public async Task OwnershipRecoveryMigration_InterruptedSchemaTransaction_RetriesCleanly() - { - await using var connection = - new SqliteConnection("DataSource=:memory:"); - await connection.OpenAsync(); - var interruption = new InterruptOwnershipRecoveryMigration(); - var options = new DbContextOptionsBuilder() - .UseSqlite(connection, sqlite => - sqlite.MigrationsAssembly( - typeof(ListenArrDbContext).Assembly.GetName().Name)) - .AddInterceptors(interruption) - .Options; - await using var context = new ListenArrDbContext(options); - var migrator = context.GetService(); - await migrator.MigrateAsync( - "20260726042801_AddDirectoryObjectIdentityAuthorization"); - - await Assert.ThrowsAsync(() => - migrator.MigrateAsync( - "20260727000644_AddOwnershipRecoveryProtocols")); - Assert.False(await ColumnExistsAsync( - connection, - "RootFolderRelocations", - "TargetIdentityEnrollmentState")); - Assert.False(await TableExistsAsync( - connection, - "LibraryDirectoryOwnershipRetiredMarkers")); - - interruption.Enabled = false; - await migrator.MigrateAsync( - "20260727000644_AddOwnershipRecoveryProtocols"); + Assert.True( + failures.Count == 0, + "The EF model maps columns absent from the migrated SQLite schema:\n" + + string.Join("\n", failures)); + } - Assert.True(await ColumnExistsAsync( - connection, - "RootFolderRelocations", - "TargetIdentityEnrollmentState")); - Assert.True(await TableExistsAsync( - connection, - "LibraryDirectoryOwnershipRetiredMarkers")); - } + [Fact] + [Trait("Scenario", "PullRequestMigrationsHaveNoNonTransactionalOperationWarnings")] + public async Task PullRequestMigrations_AfterCanaryFrontier_HaveNoNonTransactionalOperationWarnings() + { + await using var connection = new SqliteConnection("DataSource=:memory:"); + await connection.OpenAsync(); - [Fact] - [Trait("Scenario", "OwnershipRecoveryProtocolDowngrade")] - public async Task OwnershipRecoveryMigration_DowngradeRevertsRecoverySchema() + await using (var baseline = new ListenArrDbContext(CreateOptions(connection))) { - await using var connection = - new SqliteConnection("DataSource=:memory:"); - await connection.OpenAsync(); - var options = new DbContextOptionsBuilder() - .UseSqlite(connection, sqlite => - sqlite.MigrationsAssembly( - typeof(ListenArrDbContext).Assembly.GetName().Name)) - .Options; - await using var context = new ListenArrDbContext(options); - var migrator = context.GetService(); - await migrator.MigrateAsync( - "20260727000644_AddOwnershipRecoveryProtocols"); - - await migrator.MigrateAsync( - "20260726042801_AddDirectoryObjectIdentityAuthorization"); - - Assert.False(await ColumnExistsAsync( - connection, - "RootFolderRelocations", - "TargetIdentityEnrollmentState")); - Assert.False(await TableExistsAsync( - connection, - "LibraryDirectoryOwnershipRetiredMarkers")); - await using var foreignKeyCommand = connection.CreateCommand(); - foreignKeyCommand.CommandText = - """ - SELECT "on_delete" - FROM pragma_foreign_key_list('LibraryDirectoryOwnerships') - WHERE "table" = 'RootFolders' - AND "from" = 'ManagedRootFolderId' - """; - Assert.Null(await foreignKeyCommand.ExecuteScalarAsync()); - - await migrator.MigrateAsync( - "20260727000644_AddOwnershipRecoveryProtocols"); - Assert.True(await TableExistsAsync( - connection, - "LibraryDirectoryOwnershipRetiredMarkers")); + await baseline.GetService().MigrateAsync(CanaryMigrationFrontierId); } - [Fact] - [Trait("Scenario", "OwnershipRootForeignKeyRetry")] - public async Task OwnershipRootForeignKeyMigration_DowngradeAndReapply_IsolatedCleanly() - { - await using var connection = - new SqliteConnection("DataSource=:memory:"); - await connection.OpenAsync(); - var options = new DbContextOptionsBuilder() - .UseSqlite(connection, sqlite => - sqlite.MigrationsAssembly( - typeof(ListenArrDbContext).Assembly.GetName().Name)) - .Options; - await using var context = new ListenArrDbContext(options); - var migrator = context.GetService(); - - await migrator.MigrateAsync( - "20260805034058_AddLibraryDirectoryOwnershipRootForeignKey"); - Assert.Equal( - "SET NULL", - await GetOwnershipRootForeignKeyDeleteBehaviorAsync(connection)); + var guardedOptions = new DbContextOptionsBuilder() + .UseSqlite(connection, sqlite => + sqlite.MigrationsAssembly(typeof(ListenArrDbContext).Assembly.GetName().Name)) + .ConfigureWarnings(warnings => warnings.Throw( + RelationalEventId.NonTransactionalMigrationOperationWarning)) + .Options; + await using var guarded = new ListenArrDbContext(guardedOptions); - await migrator.MigrateAsync(PhysicalIdentityMigrationId); - Assert.Null(await GetOwnershipRootForeignKeyDeleteBehaviorAsync(connection)); - - await migrator.MigrateAsync( - "20260805034058_AddLibraryDirectoryOwnershipRootForeignKey"); - Assert.Equal( - "SET NULL", - await GetOwnershipRootForeignKeyDeleteBehaviorAsync(connection)); - } - - [Fact] - [Trait("Scenario", "ConcurrentDefaultRootPromotions")] - public async Task ConcurrentDefaultRootPromotions_CannotCommitTwoDefaults() - { - var databasePath = Path.Join( - FileService.GetTempPath(), - $"single-default-{Guid.NewGuid():N}.db"); - var options = new DbContextOptionsBuilder() - .UseSqlite( - $"Data Source={databasePath};Default Timeout=5", - sqlite => sqlite.MigrationsAssembly( - typeof(ListenArrDbContext).Assembly.GetName().Name)) - .Options; - - await using (var setup = new ListenArrDbContext(options)) - { - await setup.Database.MigrateAsync(); - setup.RootFolders.AddRange( - new RootFolder { Id = 101, Name = "First", Path = "/library/first" }, - new RootFolder { Id = 202, Name = "Second", Path = "/library/second" }); - await setup.SaveChangesAsync(); - } - - var start = new TaskCompletionSource( - TaskCreationOptions.RunContinuationsAsynchronously); - async Task PromoteAsync(int rootId) - { - await using var context = new ListenArrDbContext(options); - var root = await context.RootFolders.SingleAsync(candidate => candidate.Id == rootId); - root.IsDefault = true; - await start.Task; - try - { - await context.SaveChangesAsync(); - return true; - } - catch (Exception exception) when (exception is - PersistenceException or DbUpdateException or SqliteException) - { - return false; - } - } - - var firstPromotion = PromoteAsync(101); - var secondPromotion = PromoteAsync(202); - start.SetResult(); - var outcomes = await Task.WhenAll(firstPromotion, secondPromotion); - - Assert.Single(outcomes, committed => committed); - await using var verification = new ListenArrDbContext(options); - Assert.Single(await verification.RootFolders - .AsNoTracking() - .Where(root => root.IsDefault) - .ToListAsync()); - } - - [Theory] - [MemberData(nameof(ChangedMigrationIds))] - [Trait("Scenario", "ChangedMigrationsDowngradeAndReapply")] - public async Task ChangedMigration_CanDowngradeOneStepAndReapply(string migrationId) - { - await using var connection = new SqliteConnection("DataSource=:memory:"); - await connection.OpenAsync(); + await guarded.Database.MigrateAsync(); + } - var options = new DbContextOptionsBuilder() - .UseSqlite(connection, sqlite => - sqlite.MigrationsAssembly(typeof(ListenArrDbContext).Assembly.GetName().Name)) - .Options; + [Fact] + [Trait("Scenario", "MigrationHistoryMatchesModel")] + public async Task MigrationHistory_HasNoPendingModelChanges() + { + await using var connection = new SqliteConnection("DataSource=:memory:"); + await connection.OpenAsync(); + await using var context = new ListenArrDbContext(CreateOptions(connection)); - await using var context = new ListenArrDbContext(options); - var migrations = context.Database.GetMigrations().ToList(); - var migrationIndex = migrations.IndexOf(migrationId); - Assert.True( - migrationIndex > 0, - $"Migration '{migrationId}' was not discovered or has no predecessor."); + await context.Database.MigrateAsync(); - var migrator = context.GetService(); - await migrator.MigrateAsync(migrationId); - Assert.Contains(migrationId, await context.Database.GetAppliedMigrationsAsync()); + Assert.False( + context.Database.HasPendingModelChanges(), + "The configured EF model differs from the final migration snapshot."); + } - await migrator.MigrateAsync(migrations[migrationIndex - 1]); - Assert.DoesNotContain(migrationId, await context.Database.GetAppliedMigrationsAsync()); + [Fact] + [Trait("Scenario", "FinalMigrationHistoryIsConsolidated")] + public async Task MigrationHistory_ContainsOnlyRetainedRepairsAndConsolidatedPrMigrationAfterCanary() + { + await using var connection = new SqliteConnection("DataSource=:memory:"); + await connection.OpenAsync(); + await using var context = new ListenArrDbContext(CreateOptions(connection)); + + await context.Database.MigrateAsync(); + var applied = (await context.Database.GetAppliedMigrationsAsync()).ToList(); + var postCanary = applied + .Where(id => string.CompareOrdinal(id, CanaryMigrationFrontierId) > 0) + .ToArray(); + + Assert.Equal( + [ + ProcessExecutionLogRepairId, + ConsolidatedMigrationId, + MoveJobRelocationForeignKeyMigrationId + ], + postCanary); + Assert.Contains("20251124102000_AddMoveJobSourcePath", applied); + } - await migrator.MigrateAsync(migrationId); - Assert.Contains(migrationId, await context.Database.GetAppliedMigrationsAsync()); - } + [Fact] + [Trait("Scenario", "ExactCanaryUpgrade")] + public async Task ExactCanarySchema_UpgradesAndFencesReleasedActiveMoveJobs() + { + await using var connection = new SqliteConnection("DataSource=:memory:"); + await connection.OpenAsync(); - [Fact] - [Trait("Scenario", "ExistingRowsReceiveValidEnumDefaults")] - public async Task ExistingRows_MaterializeAfterDurableMoveMigrationAddsEnumColumns() + await using (var canary = new ListenArrDbContext(CreateOptions(connection))) { - await using var connection = new SqliteConnection("DataSource=:memory:"); - await connection.OpenAsync(); - - var options = new DbContextOptionsBuilder() - .UseSqlite(connection, sqlite => - sqlite.MigrationsAssembly(typeof(ListenArrDbContext).Assembly.GetName().Name)) - .Options; - var moveJobId = Guid.NewGuid(); - var enqueuedAt = DateTime.UtcNow; - - await using (var seedingContext = new ListenArrDbContext(options)) - { - var migrator = seedingContext.GetService(); - await migrator.MigrateAsync("20260703024452_AddMoveJobDeleteEmptySource"); - await seedingContext.Database.ExecuteSqlRawAsync( - """ - INSERT INTO "RootFolders" ("Name", "Path", "IsDefault") - VALUES ({0}, {1}, {2}) - """, - "Library", - "/library", - true); - await seedingContext.Database.ExecuteSqlRawAsync( - """ - INSERT INTO "MoveJobs" ( - "Id", "AudiobookId", "RequestedPath", "EnqueuedAt", "Status", - "AttemptCount", "DeleteEmptySource", "SourcePath") - VALUES ({0}, {1}, {2}, {3}, {4}, {5}, {6}, {7}) - """, - moveJobId, - 42, - "/library/New Title", - enqueuedAt, - nameof(MoveJobStatus.Queued), - 0, - true, - "/library/Old Title"); - - await migrator.MigrateAsync(); - } - - await using var verification = new ListenArrDbContext(options); - var root = await verification.RootFolders.SingleAsync(); - var moveJob = await verification.MoveJobs.SingleAsync(); - - Assert.Equal(FileSystemCaseSensitivityMode.Auto, root.CaseSensitivityMode); - Assert.Equal(PathIdentityState.Unavailable, root.PathIdentityState); - Assert.Equal(FileSystemCaseSensitivity.Unknown, root.ResolvedCaseSensitivity); - Assert.Equal(MoveFailureKind.None, moveJob.FailureKind); - Assert.Equal(MoveJobPhase.None, moveJob.Phase); + await canary.GetService().MigrateAsync(CanaryMigrationFrontierId); } - [Fact] - [Trait("Scenario", "RootRelocationForeignKeySplitRestart")] - public async Task RootRelocationForeignKeySplit_RestartPreservesRowsAndFinalSetNullContract() - { - var databasePath = Path.Join( - FileService.GetTempPath(), - $"relocation-fk-split-{Guid.NewGuid():N}.db"); - var options = new DbContextOptionsBuilder() - .UseSqlite( - $"Data Source={databasePath}", - sqlite => sqlite.MigrationsAssembly( - typeof(ListenArrDbContext).Assembly.GetName().Name)) - .Options; - var relocationId = Guid.NewGuid(); - var moveJobId = Guid.NewGuid(); - var skippedItemId = Guid.NewGuid(); - - try - { - await using (var beforeRestart = new ListenArrDbContext(options)) - { - var migrator = beforeRestart.GetService(); - await migrator.MigrateAsync( - "20260708225028_MakeRootFolderRelocationRootNullable"); - await beforeRestart.Database.ExecuteSqlInterpolatedAsync( - $""" - INSERT INTO "RootFolders" ("Id", "Name", "Path", "IsDefault", "CreatedAt") - VALUES ({101}, {"Library"}, {"/library"}, {true}, {DateTime.UtcNow}); - """); - await beforeRestart.Database.ExecuteSqlInterpolatedAsync( - $""" - INSERT INTO "RootFolderRelocations" ( - "Id", "RootFolderId", "SourcePath", "TargetPath", "Mode", "Status", - "DesiredName", "DesiredIsDefault", "CreatedAt", "CompletedJobs", - "DeleteEmptySource", "SourceCaseSensitivityMode", - "TargetCaseSensitivityMode", "TotalJobs") - VALUES ( - {relocationId}, {101}, {"/library"}, {"/library-new"}, - {nameof(RootFolderRelocationMode.Relocate)}, - {nameof(RootFolderRelocationStatus.Running)}, - {"Library"}, {true}, {DateTime.UtcNow}, {0}, {false}, - {nameof(FileSystemCaseSensitivityMode.Auto)}, - {nameof(FileSystemCaseSensitivityMode.Auto)}, {1}); - """); - await beforeRestart.Database.ExecuteSqlInterpolatedAsync( - $""" - INSERT INTO "MoveJobs" ( - "Id", "AudiobookId", "EnqueuedAt", "Status", "AttemptCount", - "DeleteEmptySource", "FailureKind", "IdentityKeyVersion", "Phase", - "RelocationId") - VALUES ( - {moveJobId}, {501}, {DateTime.UtcNow}, - {nameof(MoveJobStatus.Queued)}, {0}, {false}, - {nameof(MoveFailureKind.None)}, {0}, {nameof(MoveJobPhase.None)}, - {relocationId}); - """); - await beforeRestart.Database.ExecuteSqlInterpolatedAsync( - $""" - INSERT INTO "RootFolderRelocationSkippedItems" ( - "Id", "RelocationId", "AudiobookId", "Reason", "CreatedAt") - VALUES ( - {skippedItemId}, {relocationId}, {502}, {"Skipped for test"}, - {DateTimeOffset.UtcNow}); - """); - - await migrator.MigrateAsync( - "20260708225100_DropRootFolderRelocationRootForeignKey"); - await beforeRestart.Database.OpenConnectionAsync(); - var beforeRestartConnection = - (SqliteConnection)beforeRestart.Database.GetDbConnection(); - - Assert.Equal( - 0L, - (long)(await ExecuteScalarAsync( - beforeRestartConnection, - """ - SELECT COUNT(*) - FROM pragma_foreign_key_list('RootFolderRelocations') - WHERE "table" = 'RootFolders' - AND "from" = 'RootFolderId'; - """))!); - Assert.Equal( - 1L, - (long)(await ExecuteScalarAsync( - beforeRestartConnection, - "SELECT COUNT(*) FROM \"RootFolderRelocations\";"))!); - } - - await using var afterRestart = new ListenArrDbContext(options); - var resumedMigrator = afterRestart.GetService(); - await resumedMigrator.MigrateAsync( - "20260708225144_SetRootFolderRelocationRootDeleteBehavior"); - await afterRestart.Database.OpenConnectionAsync(); - var afterRestartConnection = - (SqliteConnection)afterRestart.Database.GetDbConnection(); - - Assert.Equal( - "SET NULL", - (await ExecuteScalarAsync( - afterRestartConnection, - """ - SELECT "on_delete" - FROM pragma_foreign_key_list('RootFolderRelocations') - WHERE "table" = 'RootFolders' - AND "from" = 'RootFolderId'; - """))?.ToString()); - Assert.Equal( - relocationId, - Guid.Parse((await ExecuteScalarAsync( - afterRestartConnection, - "SELECT \"Id\" FROM \"RootFolderRelocations\" LIMIT 1;"))!.ToString()!)); - Assert.Equal( - moveJobId, - Guid.Parse((await ExecuteScalarAsync( - afterRestartConnection, - "SELECT \"Id\" FROM \"MoveJobs\" WHERE \"RelocationId\" IS NOT NULL LIMIT 1;"))!.ToString()!)); - Assert.Equal( - skippedItemId, - Guid.Parse((await ExecuteScalarAsync( - afterRestartConnection, - "SELECT \"Id\" FROM \"RootFolderRelocationSkippedItems\" LIMIT 1;"))!.ToString()!)); + // Exact canary did not discover AddMoveJobSourcePath because it shipped + // without migration metadata. Recreate that released schema/history gap. + await ExecuteNonQueryAsync( + connection, + $""" + ALTER TABLE "MoveJobs" DROP COLUMN "SourcePath"; + DELETE FROM "__EFMigrationsHistory" + WHERE "MigrationId" = '{MoveJobSourcePathRepairId}'; + """); + Assert.False(await ColumnExistsAsync(connection, "MoveJobs", "SourcePath")); + Assert.False(await TableExistsAsync(connection, "ProcessExecutionLogs")); + + var queuedId = Guid.NewGuid(); + var processingId = Guid.NewGuid(); + var completedId = Guid.NewGuid(); + var failedId = Guid.NewGuid(); + await InsertCanaryMoveJobAsync(connection, queuedId, 1001, "Queued", "1001:queued"); + await InsertCanaryMoveJobAsync(connection, processingId, 1002, "Processing", "1002:processing"); + await InsertCanaryMoveJobAsync(connection, completedId, 1003, "Completed", null); + await InsertCanaryMoveJobAsync(connection, failedId, 1004, "Failed", null); + + var services = new ServiceCollection(); + services.AddDbContextFactory(options => + options.UseSqlite(connection, sqlite => + sqlite.MigrationsAssembly(typeof(ListenArrDbContext).Assembly.GetName().Name))); + await using var provider = services.BuildServiceProvider(); + provider.ApplyListenarrDatabaseMigrations(); + var factory = provider.GetRequiredService>(); + await using var upgraded = await factory.CreateDbContextAsync(); + + Assert.True(await ColumnExistsAsync(connection, "MoveJobs", "SourcePath")); + Assert.True(await TableExistsAsync(connection, "ProcessExecutionLogs")); + Assert.Equal( + ("NeedsAttention", "Verification", 0, null), + await ReadMoveJobUpgradeStateAsync(connection, queuedId)); + Assert.Equal( + ("NeedsAttention", "Verification", 0, null), + await ReadMoveJobUpgradeStateAsync(connection, processingId)); + Assert.Equal( + ("Completed", "None", 0, (string?)null), + await ReadMoveJobUpgradeStateAsync(connection, completedId)); + Assert.Equal( + ("Failed", "None", 0, (string?)null), + await ReadMoveJobUpgradeStateAsync(connection, failedId)); + + var materialized = await upgraded.MoveJobs + .OrderBy(job => job.AudiobookId) + .ToListAsync(); + Assert.Equal(4, materialized.Count); + Assert.False(upgraded.Database.HasPendingModelChanges()); + } - await afterRestart.Database.ExecuteSqlRawAsync( - "DELETE FROM \"RootFolders\" WHERE \"Id\" = 101;"); - Assert.Equal( - DBNull.Value, - await ExecuteScalarAsync( - afterRestartConnection, - "SELECT \"RootFolderId\" FROM \"RootFolderRelocations\" LIMIT 1;")); - Assert.Equal( - 1L, - (long)(await ExecuteScalarAsync( - afterRestartConnection, - "SELECT COUNT(*) FROM \"MoveJobs\" WHERE \"Id\" IS NOT NULL;"))!); - Assert.Equal( - 1L, - (long)(await ExecuteScalarAsync( - afterRestartConnection, - "SELECT COUNT(*) FROM \"RootFolderRelocationSkippedItems\";"))!); - Assert.Equal( - 0L, - (long)(await ExecuteScalarAsync( - afterRestartConnection, - "SELECT COUNT(*) FROM pragma_foreign_key_check;"))!); - Assert.Equal( - "ok", - (await ExecuteScalarAsync( - afterRestartConnection, - "PRAGMA integrity_check;"))?.ToString()); - } - finally - { - SqliteConnection.ClearAllPools(); - File.Delete(databasePath); - } - } + [Fact] + [Trait("Scenario", "ConsolidatedMigrationDowngradeReapply")] + public async Task ConsolidatedMigration_DowngradesOneStepAndReappliesCleanly() + { + await using var connection = new SqliteConnection("DataSource=:memory:"); + await connection.OpenAsync(); + await using var context = new ListenArrDbContext(CreateOptions(connection)); + var migrator = context.GetService(); + + await migrator.MigrateAsync(); + Assert.True(await TableExistsAsync(connection, "FileMutationJournals")); + Assert.True(await ColumnExistsAsync(connection, "MoveJobs", "ExecutionProtocolVersion")); + + await migrator.MigrateAsync(ProcessExecutionLogRepairId); + Assert.False(await TableExistsAsync(connection, "FileMutationJournals")); + Assert.False(await ColumnExistsAsync(connection, "MoveJobs", "ExecutionProtocolVersion")); + Assert.True(await ColumnExistsAsync(connection, "MoveJobs", "SourcePath")); + Assert.True(await TableExistsAsync(connection, "ProcessExecutionLogs")); + + await migrator.MigrateAsync(); + Assert.True(await TableExistsAsync(connection, "FileMutationJournals")); + Assert.True(await ColumnExistsAsync(connection, "MoveJobs", "ExecutionProtocolVersion")); + Assert.False(context.Database.HasPendingModelChanges()); + } - [Fact] - [Trait("Scenario", "NullableRelocationRootDowngradeFailsClosed")] - public async Task NullableRelocationRoot_DowngradeRejectsOrphanHistoryWithoutCorruption() + [Fact] + [Trait("Scenario", "PathIdentityDefaultSentinels")] + public async Task ExplicitValidPathIdentity_IsNotReplacedByUpgradeDefaultsOnInsert() + { + await using var connection = new SqliteConnection("DataSource=:memory:"); + await connection.OpenAsync(); + await using var context = new ListenArrDbContext(CreateOptions(connection)); + await context.Database.MigrateAsync(); + + var rootPath = Path.Join(Path.GetTempPath(), $"sentinel-root-{Guid.NewGuid():N}"); + var semantics = FileSystemPathSemantics.CurrentHostDefault; + var root = new RootFolder { - await using var connection = new SqliteConnection("DataSource=:memory:"); - await connection.OpenAsync(); - - var options = new DbContextOptionsBuilder() - .UseSqlite(connection, sqlite => - sqlite.MigrationsAssembly(typeof(ListenArrDbContext).Assembly.GetName().Name)) - .Options; - var relocationId = Guid.NewGuid(); - - await using var context = new ListenArrDbContext(options); - var migrator = context.GetService(); - await migrator.MigrateAsync("20260708225144_SetRootFolderRelocationRootDeleteBehavior"); - - await context.Database.ExecuteSqlInterpolatedAsync( - $""" - INSERT INTO "RootFolders" ("Name", "Path", "IsDefault", "CreatedAt") - VALUES ({"Deleted Library"}, {"/library"}, {true}, {DateTime.UtcNow}); - """); - var rootId = (long)(await ExecuteScalarAsync( - connection, - "SELECT last_insert_rowid();"))!; - await context.Database.ExecuteSqlInterpolatedAsync( - $""" - INSERT INTO "RootFolderRelocations" ( - "Id", "RootFolderId", "SourcePath", "TargetPath", "Mode", "Status", - "DesiredName", "DesiredIsDefault", "CompletedAt", "CreatedAt", - "UpdatedAt", "CompletedJobs", "DeleteEmptySource", - "SourceCaseSensitivityMode", "TargetCaseSensitivityMode", "TotalJobs") - VALUES ( - {relocationId}, {rootId}, {"/library"}, {"/new-library"}, - {nameof(RootFolderRelocationMode.MetadataOnly)}, - {nameof(RootFolderRelocationStatus.Completed)}, - {"Deleted Library"}, {true}, {DateTime.UtcNow}, {DateTime.UtcNow}, - {DateTime.UtcNow}, {0}, {false}, - {nameof(FileSystemCaseSensitivityMode.Auto)}, - {nameof(FileSystemCaseSensitivityMode.Auto)}, {0}); - """); - - await context.Database.ExecuteSqlInterpolatedAsync( - $"DELETE FROM \"RootFolders\" WHERE \"Id\" = {rootId};"); - context.ChangeTracker.Clear(); - - await Assert.ThrowsAsync(() => - migrator.MigrateAsync("20260708224900_AddRootFolderRelocationSkippedItems")); - - Assert.Equal( - DBNull.Value, - await ExecuteScalarAsync( - connection, - "SELECT \"RootFolderId\" FROM \"RootFolderRelocations\" LIMIT 1;")); - Assert.Equal( - 0L, - (long)(await ExecuteScalarAsync( - connection, - "SELECT COUNT(*) FROM pragma_foreign_key_check;"))!); - Assert.Equal( - "ok", - (await ExecuteScalarAsync(connection, "PRAGMA integrity_check;"))?.ToString()); - - await migrator.MigrateAsync(); - Assert.Contains( - "20260708225144_SetRootFolderRelocationRootDeleteBehavior", - await context.Database.GetAppliedMigrationsAsync()); - } - - [Fact] - [Trait("Scenario", "NullableRelocationRootDowngradePreservesValidHistory")] - public async Task NullableRelocationRoot_DowngradePreservesHistoryWithExistingRoot() + Name = "Sentinel Root", + Path = rootPath, + CaseSensitivityMode = FileSystemCaseSensitivityMode.Auto, + ResolvedCaseSensitivity = semantics.CaseSensitivity, + PathIdentityKey = $"sentinel-root-{Guid.NewGuid():N}", + PathIdentityState = PathIdentityState.Valid + }; + var audiobook = new Audiobook { - await using var connection = new SqliteConnection("DataSource=:memory:"); - await connection.OpenAsync(); - - var options = new DbContextOptionsBuilder() - .UseSqlite(connection, sqlite => - sqlite.MigrationsAssembly(typeof(ListenArrDbContext).Assembly.GetName().Name)) - .Options; - - await using var context = new ListenArrDbContext(options); - var migrator = context.GetService(); - await migrator.MigrateAsync("20260708225144_SetRootFolderRelocationRootDeleteBehavior"); - - await context.Database.ExecuteSqlInterpolatedAsync( - $""" - INSERT INTO "RootFolders" ("Name", "Path", "IsDefault", "CreatedAt") - VALUES ({"Retained Library"}, {"/library"}, {true}, {DateTime.UtcNow}); - """); - var rootId = (long)(await ExecuteScalarAsync( - connection, - "SELECT last_insert_rowid();"))!; - await context.Database.ExecuteSqlInterpolatedAsync( - $""" - INSERT INTO "RootFolderRelocations" ( - "Id", "RootFolderId", "SourcePath", "TargetPath", "Mode", "Status", - "DesiredName", "DesiredIsDefault", "CompletedAt", "CreatedAt", - "UpdatedAt", "CompletedJobs", "DeleteEmptySource", - "SourceCaseSensitivityMode", "TargetCaseSensitivityMode", "TotalJobs") - VALUES ( - {Guid.NewGuid()}, {rootId}, {"/library"}, {"/new-library"}, - {nameof(RootFolderRelocationMode.MetadataOnly)}, - {nameof(RootFolderRelocationStatus.Completed)}, - {"Retained Library"}, {true}, {DateTime.UtcNow}, {DateTime.UtcNow}, - {DateTime.UtcNow}, {0}, {false}, - {nameof(FileSystemCaseSensitivityMode.Auto)}, - {nameof(FileSystemCaseSensitivityMode.Auto)}, {0}); - """); - - await migrator.MigrateAsync("20260708224900_AddRootFolderRelocationSkippedItems"); - - await using (var rootIdCommand = connection.CreateCommand()) - { - rootIdCommand.CommandText = - "SELECT \"RootFolderId\" FROM \"RootFolderRelocations\" LIMIT 1;"; - Assert.Equal(rootId, await rootIdCommand.ExecuteScalarAsync()); - } - - await using (var foreignKeyCheck = connection.CreateCommand()) - { - foreignKeyCheck.CommandText = "PRAGMA foreign_key_check;"; - await using var reader = await foreignKeyCheck.ExecuteReaderAsync(); - Assert.False(await reader.ReadAsync()); - } + Title = "Sentinel Audiobook", + BasePath = Path.Join(rootPath, "Author", "Title") + }; + context.RootFolders.Add(root); + context.Audiobooks.Add(audiobook); + await context.SaveChangesAsync(); + + var filePath = Path.Join(audiobook.BasePath!, "book.m4b"); + var trackedFile = AudiobookFile.CreateUnresolved(filePath); + trackedFile.AudiobookId = audiobook.Id; + trackedFile.ApplyPathIdentity( + filePath, + AudiobookFilePathIdentity.CreateValid( + filePath, + semantics, + FileSystemCaseSensitivityMode.Auto, + rootPath)); + context.AudiobookFiles.Add(trackedFile); + await context.SaveChangesAsync(); + context.ChangeTracker.Clear(); + + var persistedRoot = await context.RootFolders.SingleAsync(); + var persistedFile = await context.AudiobookFiles.SingleAsync(); + Assert.Equal(PathIdentityState.Valid, persistedRoot.PathIdentityState); + Assert.Equal(PathIdentityState.Valid, persistedFile.PathIdentityState); + Assert.Equal(semantics.CaseSensitivity, persistedRoot.ResolvedCaseSensitivity); + Assert.Equal(semantics.CaseSensitivity, persistedFile.PathCaseSensitivity); + } - await migrator.MigrateAsync(); - Assert.Contains( - "20260708225144_SetRootFolderRelocationRootDeleteBehavior", - await context.Database.GetAppliedMigrationsAsync()); - } + [Fact] + [Trait("Scenario", "FinalSchemaContracts")] + public async Task FinalSchema_HasDurableDefaultsIndexesAndSetNullOwnershipRootForeignKey() + { + await using var connection = new SqliteConnection("DataSource=:memory:"); + await connection.OpenAsync(); + await using var context = new ListenArrDbContext(CreateOptions(connection)); + await context.Database.MigrateAsync(); + + Assert.Equal("0", await ColumnDefaultAsync(connection, "MoveJobs", "ExecutionProtocolVersion")); + Assert.Equal("'None'", await ColumnDefaultAsync(connection, "MoveJobs", "FailureKind")); + Assert.Equal("'Auto'", await ColumnDefaultAsync(connection, "RootFolders", "CaseSensitivityMode")); + Assert.Equal("'Unknown'", await ColumnDefaultAsync(connection, "RootFolders", "ResolvedCaseSensitivity")); + Assert.Equal("'Unavailable'", await ColumnDefaultAsync(connection, "RootFolders", "PathIdentityState")); + Assert.Equal("'Auto'", await ColumnDefaultAsync(connection, "AudiobookFiles", "PathCaseSensitivityMode")); + Assert.Equal("'Unknown'", await ColumnDefaultAsync(connection, "AudiobookFiles", "PathCaseSensitivity")); + Assert.Equal("'Unavailable'", await ColumnDefaultAsync(connection, "AudiobookFiles", "PathIdentityState")); + + Assert.True(await IndexExistsAsync(connection, "IX_RootFolders_SingleDefault")); + Assert.True(await IndexExistsAsync(connection, "IX_AudiobookFiles_PathOwnershipKey")); + Assert.True(await IndexExistsAsync(connection, "IX_LibraryDirectoryOwnerships_PathOwnershipKey")); + Assert.True(await ForeignKeyHasDeleteActionAsync( + connection, + "LibraryDirectoryOwnerships", + "RootFolders", + "ManagedRootFolderId", + "SET NULL")); + Assert.True(await ForeignKeyHasDeleteActionAsync( + connection, + "MoveJobs", + "RootFolderRelocations", + "RelocationId", + "RESTRICT")); + } - [Fact] - [Trait("Scenario", "MoveJobsSourcePathRegression")] - public void MoveJobs_SourcePathColumn_ExistsAfterMigrate() - { - var (connection, context) = CreateMigratedSqliteContext(); - using var _conn = connection; - using var _ctx = context; + [Fact] + [Trait("Scenario", "MoveJobsSourcePathRepair")] + public async Task MoveJobs_SourcePathColumn_ExistsAfterMigrate() + { + await using var connection = new SqliteConnection("DataSource=:memory:"); + await connection.OpenAsync(); + await using var context = new ListenArrDbContext(CreateOptions(connection)); - using var command = connection.CreateCommand(); - command.CommandText = "SELECT name FROM pragma_table_info('MoveJobs')"; - var columns = new List(); - using (var reader = command.ExecuteReader()) - { - while (reader.Read()) - { - columns.Add(reader.GetString(0)); - } - } + await context.Database.MigrateAsync(); - Assert.Contains("SourcePath", columns); - } + Assert.True(await ColumnExistsAsync(connection, "MoveJobs", "SourcePath")); + } - private static async Task GetOwnershipRootForeignKeyDeleteBehaviorAsync( - SqliteConnection connection) - { - await using var command = connection.CreateCommand(); - command.CommandText = - """ - SELECT "on_delete" - FROM pragma_foreign_key_list('LibraryDirectoryOwnerships') - WHERE "table" = 'RootFolders' - AND "from" = 'ManagedRootFolderId' - """; - return (await command.ExecuteScalarAsync())?.ToString(); - } + private static async Task ExecuteNonQueryAsync( + SqliteConnection connection, + string sql) + { + await using var command = connection.CreateCommand(); + command.CommandText = sql; + await command.ExecuteNonQueryAsync(); + } - private static async Task ExecuteScalarAsync( - SqliteConnection connection, - string commandText) - { - await using var command = connection.CreateCommand(); - command.CommandText = commandText; - return await command.ExecuteScalarAsync(); - } + private static async Task InsertCanaryMoveJobAsync( + SqliteConnection connection, + Guid id, + int audiobookId, + string status, + string? activeDeduplicationKey) + { + await using var command = connection.CreateCommand(); + command.CommandText = """ + INSERT INTO "MoveJobs" + ("Id", "AudiobookId", "RequestedPath", "EnqueuedAt", "Status", + "Error", "AttemptCount", "UpdatedAt", "ActiveDeduplicationKey") + VALUES + ($id, $audiobookId, $requestedPath, CURRENT_TIMESTAMP, $status, + NULL, 0, CURRENT_TIMESTAMP, $activeDeduplicationKey); + """; + command.Parameters.AddWithValue("$id", id.ToString()); + command.Parameters.AddWithValue("$audiobookId", audiobookId); + command.Parameters.AddWithValue("$requestedPath", $"/library/{audiobookId}"); + command.Parameters.AddWithValue("$status", status); + command.Parameters.AddWithValue( + "$activeDeduplicationKey", + activeDeduplicationKey is null ? DBNull.Value : activeDeduplicationKey); + await command.ExecuteNonQueryAsync(); + } - private static async Task TableExistsAsync( + private static async Task<(string Status, string FailureKind, int Protocol, string? ActiveDeduplicationKey)> + ReadMoveJobUpgradeStateAsync( SqliteConnection connection, - string tableName) - { - await using var command = connection.CreateCommand(); - command.CommandText = - """ - SELECT COUNT(*) - FROM sqlite_master - WHERE type = 'table' AND name = $name - """; - command.Parameters.AddWithValue("$name", tableName); - return Convert.ToInt32(await command.ExecuteScalarAsync()) == 1; - } + Guid id) + { + await using var command = connection.CreateCommand(); + command.CommandText = """ + SELECT "Status", "FailureKind", "ExecutionProtocolVersion", "ActiveDeduplicationKey" + FROM "MoveJobs" + WHERE "Id" = $id; + """; + command.Parameters.AddWithValue("$id", id.ToString()); + await using var reader = await command.ExecuteReaderAsync(); + Assert.True(await reader.ReadAsync()); + return ( + reader.GetString(0), + reader.GetString(1), + reader.GetInt32(2), + reader.IsDBNull(3) ? null : reader.GetString(3)); + } - private static async Task ColumnExistsAsync( - SqliteConnection connection, - string tableName, - string columnName) - { - await using var command = connection.CreateCommand(); - command.CommandText = - """ - SELECT COUNT(*) - FROM pragma_table_info($table) - WHERE name = $column - """; - command.Parameters.AddWithValue("$table", tableName); - command.Parameters.AddWithValue("$column", columnName); - return Convert.ToInt32(await command.ExecuteScalarAsync()) == 1; - } + private static async Task TableExistsAsync( + SqliteConnection connection, + string table) + { + await using var command = connection.CreateCommand(); + command.CommandText = + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=$name"; + command.Parameters.AddWithValue("$name", table); + return Convert.ToInt32(await command.ExecuteScalarAsync()) == 1; + } - private static async Task IndexExistsAsync( - SqliteConnection connection, - string tableName, - string indexName) - { - await using var command = connection.CreateCommand(); - command.CommandText = - """ - SELECT COUNT(*) - FROM pragma_index_list($table) - WHERE name = $index - """; - command.Parameters.AddWithValue("$table", tableName); - command.Parameters.AddWithValue("$index", indexName); - return Convert.ToInt32(await command.ExecuteScalarAsync()) == 1; - } + private static async Task ColumnExistsAsync( + SqliteConnection connection, + string table, + string column) + { + await using var command = connection.CreateCommand(); + command.CommandText = $"SELECT COUNT(*) FROM pragma_table_info('{table}') WHERE name=$name"; + command.Parameters.AddWithValue("$name", column); + return Convert.ToInt32(await command.ExecuteScalarAsync()) == 1; + } - private sealed class InterruptOwnershipRecoveryMigration - : DbCommandInterceptor - { - public bool Enabled { get; set; } = true; + private static async Task ColumnDefaultAsync( + SqliteConnection connection, + string table, + string column) + { + await using var command = connection.CreateCommand(); + command.CommandText = $"SELECT dflt_value FROM pragma_table_info('{table}') WHERE name=$name"; + command.Parameters.AddWithValue("$name", column); + return Convert.ToString(await command.ExecuteScalarAsync()); + } - public override ValueTask> - NonQueryExecutingAsync( - DbCommand command, - CommandEventData eventData, - InterceptionResult result, - CancellationToken cancellationToken = default) - { - if (Enabled - && command.CommandText.Contains( - "ALTER TABLE \"RootFolderRelocations\" ADD \"TargetIdentityEnrollmentState\"", - StringComparison.Ordinal)) - { - throw new InvalidOperationException( - "Injected interruption after the ownership foreign-key rebuild."); - } + private static async Task IndexExistsAsync( + SqliteConnection connection, + string index) + { + await using var command = connection.CreateCommand(); + command.CommandText = + "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name=$name"; + command.Parameters.AddWithValue("$name", index); + return Convert.ToInt32(await command.ExecuteScalarAsync()) == 1; + } - return ValueTask.FromResult(result); - } - } + private static async Task ForeignKeyHasDeleteActionAsync( + SqliteConnection connection, + string table, + string principalTable, + string fromColumn, + string deleteAction) + { + await using var command = connection.CreateCommand(); + command.CommandText = $"SELECT COUNT(*) FROM pragma_foreign_key_list('{table}') WHERE \"table\"=$principal AND \"from\"=$column AND on_delete=$delete"; + command.Parameters.AddWithValue("$principal", principalTable); + command.Parameters.AddWithValue("$column", fromColumn); + command.Parameters.AddWithValue("$delete", deleteAction); + return Convert.ToInt32(await command.ExecuteScalarAsync()) == 1; } } From 2e418f4406bf2cec27a388598349a0125d404c60 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Sat, 8 Aug 2026 20:18:06 -0400 Subject: [PATCH 452/464] fix: harden root storage authorization --- fe/src/App.vue | 215 ++++- fe/src/__tests__/AppActivityBadge.spec.ts | 364 ++++++++- fe/src/__tests__/AudiobookDetailView.spec.ts | 118 ++- fe/src/__tests__/ProgressBar.spec.ts | 14 + fe/src/__tests__/RootFolderFormModal.spec.ts | 122 ++- fe/src/__tests__/RootFoldersSettings.spec.ts | 76 +- .../library.deleteOperations.spec.ts | 151 ++++ .../rootFolders.reauthorization.store.spec.ts | 67 +- fe/src/__tests__/scanNotifications.spec.ts | 143 ++++ fe/src/__tests__/test-setup.ts | 2 +- fe/src/__tests__/utils/rootFolderPath.spec.ts | 1 - fe/src/components/base/ProgressBar.vue | 30 +- .../feedback/MoveAudiobookModal.vue | 89 +- .../settings/RootFolderFormModal.vue | 24 +- .../settings/RootFoldersSettings.vue | 106 ++- fe/src/services/api.ts | 18 +- fe/src/services/signalr.ts | 4 +- fe/src/stores/library.ts | 52 +- fe/src/stores/libraryDeleteOperations.ts | 177 ++++ fe/src/stores/rootFolders.ts | 19 +- fe/src/stores/scanNotifications.ts | 125 +++ fe/src/types/index.ts | 20 +- fe/src/views/activity/ActivityView.vue | 9 +- fe/src/views/library/AudiobookDetailView.vue | 66 +- .../Features/Library/LibraryDeleteWorkflow.cs | 5 +- .../Library/LibraryMoveWorkflow.Paths.cs | 81 +- .../Library/LibraryMoveWorkflow.Physical.cs | 3 +- .../Library/RootFoldersController.Mapping.cs | 45 ++ .../Features/Library/RootFoldersController.cs | 71 +- .../IAudiobookDeletionCommitService.cs | 5 + .../IDirectoryObjectIdentityResolver.cs | 25 +- .../Contracts/IRootFolderService.cs | 4 - .../IRootFolderStorageConfirmationService.cs | 10 + .../IRootFolderStorageHealthResolver.cs | 42 + .../AudiobookDeletionCommitService.cs | 37 +- .../RootFolderService.DirectoryIdentity.cs | 110 +-- .../RootFolders/RootFolderService.cs | 41 +- .../Library/LibraryRegistrationExtensions.cs | 2 + .../DirectoryObjectIdentityResolver.cs | 41 +- .../FileSystem/PinnedDirectoryCreation.cs | 70 +- .../RootFolderStorageConfirmationService.cs | 339 ++++++++ .../RootFolderStorageHealthResolver.cs | 243 ++++++ ...obookFilesystemDeleteService.PinnedTree.cs | 116 +++ .../AudiobookFilesystemDeleteService.cs | 80 +- .../Moving/EfMoveExecutionStore.Helpers.cs | 22 +- .../Library/Moving/EfMoveExecutionStore.cs | 27 +- ...ryOwnershipBoundaryAuthorizer.Semantics.cs | 48 ++ ...aryDirectoryOwnershipBoundaryAuthorizer.cs | 156 ++-- .../RootFolderRelocationService.Helpers.cs | 13 + ...tFolderRelocationService.Reconciliation.cs | 60 +- .../RootFolderRelocationService.Retry.cs | 6 - ...rvice.TargetReservationMarkerRetirement.cs | 108 --- ...ionService.TargetReservationPersistence.cs | 254 ------ ...derRelocationService.TargetReservations.cs | 80 +- ...FolderRelocationService.TargetSemantics.cs | 88 ++ .../Moving/RootFolderRelocationService.cs | 6 - .../Scanning/ScanPathAuthorizationService.cs | 26 +- .../Metadata/Jobs/MetadataRescanService.cs | 31 +- .../Repositories/AudiobookRepository.cs | 5 +- .../Repositories/EfAudiobookFileRepository.cs | 92 ++- .../RootFolderObjectIdentityReconciler.cs | 49 +- tests/Common/BaseTests.cs | 13 +- .../LibraryBulkDeleteCancellationTests.cs | 10 +- .../LibraryController_BulkUpdateTests.cs | 4 +- ...LibraryController_DeleteFilesystemTests.cs | 289 ++++++- .../Library/LibraryController_MoveTests.cs | 180 ++++- .../Library/RootFoldersControllerTests.cs | 112 +-- .../ProductionCompositionValidationTests.cs | 4 +- .../AudiobookDeletionCommitServiceTests.cs | 88 +- .../RootFolders/RootFolderServiceTests.cs | 403 ++++------ ...otFolderStorageConfirmationServiceTests.cs | 757 ++++++++++++++++++ .../RootFolderStorageHealthResolverTests.cs | 314 ++++++++ .../AudiobookContentMoveServiceTests.cs | 7 + .../EfLibraryDirectoryOwnershipStoreTests.cs | 92 +++ .../Moving/EfMoveExecutionStoreTests.cs | 199 +++++ .../Moving/PinnedDirectoryPublicationTests.cs | 16 +- .../RootFolderRelocationServiceTests.cs | 324 ++++++-- .../ScanPathAuthorizationServiceTests.cs | 60 ++ .../Jobs/MetadataRescanProcessorTests.cs | 108 +++ ...RootFolderObjectIdentityReconcilerTests.cs | 134 ++++ .../AudiobookRepositoryDeleteTests.cs | 52 ++ .../EfAudiobookFileRepositoryTests.cs | 82 ++ 82 files changed, 6284 insertions(+), 1417 deletions(-) create mode 100644 fe/src/__tests__/library.deleteOperations.spec.ts create mode 100644 fe/src/__tests__/scanNotifications.spec.ts create mode 100644 fe/src/stores/libraryDeleteOperations.ts create mode 100644 fe/src/stores/scanNotifications.ts create mode 100644 listenarr.api/Features/Library/RootFoldersController.Mapping.cs create mode 100644 listenarr.application/Audiobooks/Contracts/IRootFolderStorageConfirmationService.cs create mode 100644 listenarr.application/Audiobooks/Contracts/IRootFolderStorageHealthResolver.cs create mode 100644 listenarr.infrastructure/FileSystem/RootFolderStorageConfirmationService.cs create mode 100644 listenarr.infrastructure/FileSystem/RootFolderStorageHealthResolver.cs create mode 100644 listenarr.infrastructure/Library/Moving/LibraryDirectoryOwnershipBoundaryAuthorizer.Semantics.cs delete mode 100644 listenarr.infrastructure/Library/Moving/RootFolderRelocationService.TargetReservationMarkerRetirement.cs create mode 100644 listenarr.infrastructure/Library/Moving/RootFolderRelocationService.TargetSemantics.cs create mode 100644 tests/Features/Infrastructure/FileSystem/RootFolderStorageConfirmationServiceTests.cs create mode 100644 tests/Features/Infrastructure/FileSystem/RootFolderStorageHealthResolverTests.cs create mode 100644 tests/Features/Infrastructure/Repositories/AudiobookRepositoryDeleteTests.cs diff --git a/fe/src/App.vue b/fe/src/App.vue index 47e0bee56..3c4840756 100644 --- a/fe/src/App.vue +++ b/fe/src/App.vue @@ -139,7 +139,7 @@