diff --git a/listenarr.api/Controllers/ManualImportController.cs b/listenarr.api/Controllers/ManualImportController.cs index 37dccd6b9..e3d323670 100644 --- a/listenarr.api/Controllers/ManualImportController.cs +++ b/listenarr.api/Controllers/ManualImportController.cs @@ -427,6 +427,7 @@ private async Task GenerateManualImportPathAsync(Audiobook audiobook, Au { var baseFull = FileUtils.NormalizeStoredPath(basePath); var configuredFull = string.IsNullOrWhiteSpace(configuredOutput) ? string.Empty : Path.GetFullPath(configuredOutput); + configuredFull = FileUtils.EnsureTrailingSeparator(configuredFull); isCustomBasePath = !string.Equals(baseFull, configuredFull, StringComparison.OrdinalIgnoreCase); // Even if it differs from OutputPath, don't treat it as custom when it diff --git a/listenarr.api/Program.Testing.cs b/listenarr.api/Program.Testing.cs index 55e6fafa6..fa4795c9a 100644 --- a/listenarr.api/Program.Testing.cs +++ b/listenarr.api/Program.Testing.cs @@ -22,7 +22,6 @@ static partial void ApplyTestHostPatches(WebApplicationBuilder builder) var inMemory = new Dictionary() { ["Listenarr:SqliteDbPath"] = sqliteDbPath, - ["Listenarr:DisableHostedServices"] = "true" }; builder.Configuration.AddInMemoryCollection(inMemory); } diff --git a/listenarr.api/Program.cs b/listenarr.api/Program.cs index 048655425..9452be1b7 100644 --- a/listenarr.api/Program.cs +++ b/listenarr.api/Program.cs @@ -32,7 +32,6 @@ using Listenarr.Infrastructure.Extensions; using Listenarr.Application.Interfaces; using Listenarr.Infrastructure.SignalR; -using Listenarr.Application.Downloads; using Listenarr.Infrastructure.Persistence; using Listenarr.Application.Common; using Listenarr.Application.Search.Filters; @@ -555,29 +554,8 @@ ex is IOException builder.Environment.ContentRootPath); // Register application-level services (moved from Program.cs to keep startup focused) builder.Services.AddListenarrAppServices(builder.Configuration); -// Register hosted/background services (moved from Program.cs). Allow tests to disable these. -// Hosted services are ENABLED by default in local development because download monitoring -// and import processing rely on these background workers. -// Use explicit config/env override only when intentionally disabling them. -var disableHostedServices = - builder.Configuration.GetValue("Listenarr:DisableHostedServices") || - string.Equals(Environment.GetEnvironmentVariable("LISTENARR_DISABLE_HOSTED_SERVICES"), "true", StringComparison.OrdinalIgnoreCase); -if (disableHostedServices) -{ - Log.Logger.Warning("[Startup] Hosted/background services are disabled by configuration override"); -} -else -{ - Log.Logger.Information("[Startup] Hosted/background services are enabled"); -} -// Register the queue singleton outside the hosted-services guard so controllers -// (e.g. RootFoldersController) can resolve it even when hosted services are disabled (tests). -builder.Services.AddSingleton(); -if (!disableHostedServices) -{ - builder.Services.AddListenarrHostedServices(builder.Configuration); -} +builder.Services.AddListenarrHostedServices(builder.Configuration); // FIXME: Required for ConfigurationService, what was planned with this feature ? builder.Services.AddSingleton(new EphemeralDataProtectionProvider().CreateProtector("Listenarr.ConfigurationService.ProwlarrImport")); diff --git a/listenarr.application/Audiobooks/RenameService.cs b/listenarr.application/Audiobooks/RenameService.cs index 3791266d8..2e56ab3da 100644 --- a/listenarr.application/Audiobooks/RenameService.cs +++ b/listenarr.application/Audiobooks/RenameService.cs @@ -115,7 +115,7 @@ private RenamePreview BuildPreview(Audiobook audiobook, ApplicationSettings sett }); } - preview.NewFolderPath = ComputeCommonBasePath(expectedPaths); + preview.NewFolderPath = FileUtils.EnsureTrailingSeparator(ComputeCommonBasePath(expectedPaths)); preview.FolderChanged = !PathsEqual(preview.CurrentFolderPath, preview.NewFolderPath); preview.HasChanges = preview.FolderChanged || preview.FileRenames.Any(f => f.Changed); return preview; diff --git a/listenarr.application/Audiobooks/RootFolderService.cs b/listenarr.application/Audiobooks/RootFolderService.cs new file mode 100644 index 000000000..c98912b2e --- /dev/null +++ b/listenarr.application/Audiobooks/RootFolderService.cs @@ -0,0 +1,208 @@ +/* + * 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 Listenarr.Application.Interfaces; +using Listenarr.Application.Interfaces.Repositories; +using Listenarr.Domain.Models; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Application.Audiobooks +{ + public class RootFolderService( + IRootFolderRepository rootFolderRepository, + IAudiobookRepository audiobookRepository, + ILogger logger, + IMoveQueueService moveQueueService) : IRootFolderService + { + public async Task GetDefaultAsync() + { + return await rootFolderRepository.GetDefaultAsync(); + } + + private async Task HasDuplicate(RootFolder root) + { + var rootFolders = await rootFolderRepository.GetAllAsync(); + return rootFolders.Any(r => r.Path == root.Path && r.Id != root.Id); + } + + public async Task CreateAsync(RootFolder root) + { + root.Path ??= string.Empty; + root.Name = root.Name?.Trim() ?? string.Empty; + + if (string.IsNullOrWhiteSpace(root.Path)) throw new ArgumentException("Path is required"); + if (string.IsNullOrWhiteSpace(root.Name)) throw new ArgumentException("Name is required"); + + if (await HasDuplicate(root)) throw new InvalidOperationException("A root folder with that path already exists."); + + if (root.IsDefault) + { + await rootFolderRepository.ClearDefaultExceptAsync(excludeId: null); + } + + await rootFolderRepository.AddAsync(root); + return root; + } + + public async Task DeleteAsync(int id, int? reassignRootId = null) + { + var rootFolders = await rootFolderRepository.GetAllAsync(); + var rootFolder = rootFolders.FirstOrDefault(r => r.Id == id); + if (rootFolder == null) + { + logger.LogWarning($"Root folder with id {id} cannot be found, assuming it's deleted"); + return; + } + + var rootFoldersAfterDelete = rootFolders + .Where(r => r.Id != id) + .ToList(); + + if (reassignRootId != null) + { + var newRoot = await rootFolderRepository.GetByIdAsync(reassignRootId!.Value) ?? throw new KeyNotFoundException("Reassign root not found"); + await MigrateAudiobookPathsAsync(rootFolder.Path, newRoot.Path); + } + + var audiobooks = await audiobookRepository.GetAllAsync(); + + // Audiobooks are considered orphaned if base path is empty or no root folder can be linked to it + var orphanedAudiobooks = audiobooks + .Where(a => string.IsNullOrEmpty(a.BasePath) || !rootFolders.Any(r => a.BasePath!.StartsWith(r.Path))); + + if (orphanedAudiobooks.Any()) + { + var formattedList = string.Join(", ", orphanedAudiobooks.Select(a => a.Title)); + + logger.LogWarning($"The following audiobooks are orphaned: {formattedList}"); + } + + var rootedAudiobooks = audiobooks + .Where(a => !orphanedAudiobooks.Any(o => o.Id == a.Id)) // Check only audiobooks that are not orphaned + .Where(a => !rootFoldersAfterDelete.Any(r => a.BasePath!.StartsWith(r.Path))); + if (rootedAudiobooks.Any()) + { + throw new InvalidOperationException($"Root folder is in use by {rootedAudiobooks.Count()} audiobooks, we cannot remove it"); + } + + await rootFolderRepository.RemoveAsync(id); + } + + public async Task> GetAllAsync() => await rootFolderRepository.GetAllAsync(); + + public async Task GetByIdAsync(int id) => await rootFolderRepository.GetByIdAsync(id); + + public async Task UpdateAsync(RootFolder root, bool moveFiles = false, bool deleteEmptySource = true) + { + ArgumentNullException.ThrowIfNull(root); + + root.Path ??= string.Empty; + root.Name = root.Name?.Trim() ?? string.Empty; + + var existing = await rootFolderRepository.GetByIdAsync(root.Id) ?? throw new KeyNotFoundException("Root folder not found"); + + if (await HasDuplicate(root)) throw new InvalidOperationException("A root folder with that path already exists."); + + if (root.IsDefault) + { + await rootFolderRepository.ClearDefaultExceptAsync(excludeId: root.Id); + } + + var oldPath = existing.Path; + var newPath = root.Path; + + List<(int audiobookId, string original, string target)> moves = []; + if (!string.Equals(oldPath, newPath, StringComparison.OrdinalIgnoreCase)) + { + moves = await MigrateAudiobookPathsAsync(oldPath, newPath); + + try + { + logger.LogInformation("Root rename from {OldPath} to {NewPath}: {Count} audiobooks affected", oldPath, newPath, moves.Count); + foreach (var m in moves) + { + logger.LogInformation("Root rename move prep: AudiobookId={AudiobookId} Original={Original} Target={Target}", m.audiobookId, m.original, m.target); + } + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + logger.LogDebug(ex, "Failed to emit diagnostics for root rename"); + } + } + + existing.Name = root.Name; + existing.Path = root.Path; + existing.IsDefault = root.IsDefault; + existing.UpdatedAt = DateTime.UtcNow; + await rootFolderRepository.UpdateAsync(existing); + + if (moveFiles) + { + foreach (var m in moves) + { + try + { + _ = moveQueueService.EnqueueMoveAsync(m.audiobookId, m.target, m.original); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + logger.LogWarning(ex, "Failed to enqueue move for audiobook {AudiobookId} during root rename", m.audiobookId); + } + } + } + + return existing; + } + + // FIXME: Should be in audibook service + // FIXME: Can produce unexpected results (on the user side) when some root folder are contained within each other (/data/media and /data/media/library) and one of them gets moved + private async Task> MigrateAudiobookPathsAsync(string oldRootPath, string newRootPath, CancellationToken ct = default) + { + var all = await audiobookRepository.GetAllAsync(); + all = [.. all.Where(a => !string.IsNullOrEmpty(a.BasePath))]; + + const char backslash = '\\'; + const char slash = '/'; + string NormalizeForCompare(string s) => (s ?? string.Empty).Replace(slash, backslash).TrimEnd(backslash).ToLowerInvariant(); + var oldNorm = NormalizeForCompare(oldRootPath); + + var affected = all.Where(a => + { + var bpNorm = NormalizeForCompare(a.BasePath!); + return bpNorm == oldNorm || bpNorm.StartsWith(oldNorm + backslash); + }).ToList(); + + var moves = new List<(int audiobookId, string original, string target)>(); + foreach (var a in affected) + { + var original = a.BasePath!; + var suffix = original.Length > oldRootPath.Length + ? original.Substring(oldRootPath.Length).TrimStart(backslash, slash) + : string.Empty; + var target = string.IsNullOrEmpty(suffix) + ? newRootPath + : Path.Combine(newRootPath, suffix); + moves.Add((a.Id, original, target)); + a.BasePath = target; + + await audiobookRepository.UpdateAsync(a); + } + + return moves; + } + } +} diff --git a/listenarr.application/Downloads/RootFolderService.cs b/listenarr.application/Downloads/RootFolderService.cs deleted file mode 100644 index c618efdb7..000000000 --- a/listenarr.application/Downloads/RootFolderService.cs +++ /dev/null @@ -1,155 +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. - * - * 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.Application.Interfaces; -using Listenarr.Application.Interfaces.Repositories; -using Listenarr.Domain.Models; -using Microsoft.Extensions.Logging; - -namespace Listenarr.Application.Downloads -{ - public class RootFolderService : IRootFolderService - { - private readonly IRootFolderRepository _repo; - private readonly ILogger? _logger; - private readonly IMoveQueueService? _moveQueue; - - public RootFolderService(IRootFolderRepository repo, ILogger? logger, IMoveQueueService? moveQueue = null) - { - _repo = repo; - _logger = logger; - _moveQueue = moveQueue; - } - - public async Task GetDefaultAsync() - { - return await _repo.GetDefaultAsync(); - } - - public async Task CreateAsync(RootFolder root) - { - root.Path = root.Path?.Trim() ?? string.Empty; - root.Name = root.Name?.Trim() ?? string.Empty; - - 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); - if (existingByPath != null) throw new InvalidOperationException("A root folder with that path already exists."); - - if (root.IsDefault) - { - await _repo.ClearDefaultExceptAsync(excludeId: null); - } - - await _repo.AddAsync(root); - return root; - } - - public async Task DeleteAsync(int id, int? reassignRootId = null) - { - var root = await _repo.GetByIdAsync(id); - if (root == null) throw new KeyNotFoundException("Root folder not found"); - - var hasReferenced = await _repo.HasAudiobooksUnderPathAsync(root.Path); - if (hasReferenced && !reassignRootId.HasValue) - { - throw new InvalidOperationException("Root folder is in use by audiobooks; reassign before deletion or provide reassignRootId."); - } - - if (hasReferenced) - { - var newRoot = await _repo.GetByIdAsync(reassignRootId!.Value); - if (newRoot == null) throw new KeyNotFoundException("Reassign root not found"); - await _repo.MigrateAudiobookPathsAsync(root.Path, newRoot.Path); - } - - await _repo.RemoveAsync(id); - } - - public async Task> GetAllAsync() => await _repo.GetAllAsync(); - - public async Task GetByIdAsync(int id) => await _repo.GetByIdAsync(id); - - 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; - - var existing = await _repo.GetByIdAsync(root.Id); - if (existing == null) throw new KeyNotFoundException("Root folder not found"); - - if (!string.Equals(existing.Path, root.Path, StringComparison.OrdinalIgnoreCase)) - { - var duplicate = await _repo.GetByPathAsync(root.Path); - if (duplicate != null && duplicate.Id != root.Id) - throw new InvalidOperationException("Another root folder with that path already exists."); - } - - if (root.IsDefault) - { - await _repo.ClearDefaultExceptAsync(excludeId: root.Id); - } - - var oldPath = existing.Path; - var newPath = root.Path; - - List<(int audiobookId, string original, string target)> moves = new(); - if (!string.Equals(oldPath, newPath, StringComparison.OrdinalIgnoreCase)) - { - moves = await _repo.MigrateAudiobookPathsAsync(oldPath, newPath); - - try - { - _logger?.LogInformation("Root rename from {OldPath} to {NewPath}: {Count} audiobooks affected", oldPath, newPath, moves.Count); - foreach (var m in moves) - { - _logger?.LogInformation("Root rename move prep: AudiobookId={AudiobookId} Original={Original} Target={Target}", m.audiobookId, m.original, m.target); - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger?.LogDebug(ex, "Failed to emit diagnostics for root rename"); - } - } - - existing.Name = root.Name; - existing.Path = root.Path; - existing.IsDefault = root.IsDefault; - existing.UpdatedAt = DateTime.UtcNow; - await _repo.UpdateAsync(existing); - - if (moveFiles && _moveQueue != null) - { - foreach (var m in moves) - { - try - { - _ = _moveQueue.EnqueueMoveAsync(m.audiobookId, m.target, m.original); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger?.LogWarning(ex, "Failed to enqueue move for audiobook {AudiobookId} during root rename", m.audiobookId); - } - } - } - - return existing; - } - } -} diff --git a/listenarr.application/Interfaces/IRootFolderService.cs b/listenarr.application/Interfaces/IRootFolderService.cs index 63a6439d0..bbf0b5cc2 100644 --- a/listenarr.application/Interfaces/IRootFolderService.cs +++ b/listenarr.application/Interfaces/IRootFolderService.cs @@ -27,6 +27,14 @@ public interface IRootFolderService Task CreateAsync(RootFolder root); // moveFiles: when true, enqueue move jobs for affected audiobooks; when false, perform DB-only reassign Task UpdateAsync(RootFolder root, bool moveFiles = false, bool deleteEmptySource = true); + + /// + /// Removes an unused root folder + /// + /// ID of the root folder to remove + /// + /// + /// When other audiobooks still use this root folder Task DeleteAsync(int id, int? reassignRootId = null); } } diff --git a/listenarr.application/Interfaces/Repositories/IRootFolderRepository.cs b/listenarr.application/Interfaces/Repositories/IRootFolderRepository.cs index 80885c16b..f1f4d4d35 100644 --- a/listenarr.application/Interfaces/Repositories/IRootFolderRepository.cs +++ b/listenarr.application/Interfaces/Repositories/IRootFolderRepository.cs @@ -23,15 +23,11 @@ public interface IRootFolderRepository { Task> GetAllAsync(); Task GetByIdAsync(int id); - Task GetByPathAsync(string path); - Task AddAsync(RootFolder root); + Task AddAsync(RootFolder root); Task UpdateAsync(RootFolder root); Task RemoveAsync(int id); Task GetDefaultAsync(); Task ClearDefaultExceptAsync(int? excludeId, CancellationToken ct = default); - Task HasAudiobooksUnderPathAsync(string rootPath, CancellationToken ct = default); - Task> GetAudiobooksUnderPathAsync(string rootPath, CancellationToken ct = default); - Task> MigrateAudiobookPathsAsync(string oldRootPath, string newRootPath, CancellationToken ct = default); Task SaveChangesAsync(CancellationToken ct = default); } } diff --git a/listenarr.domain/Common/FileUtils.cs b/listenarr.domain/Common/FileUtils.cs index e5e9059dc..34a96f035 100644 --- a/listenarr.domain/Common/FileUtils.cs +++ b/listenarr.domain/Common/FileUtils.cs @@ -675,6 +675,15 @@ public static string GetAbsolutePath(params string[] segments) return Path.Combine(root, Path.Combine(segments)); } + /// + /// Same as but adds a trailing directory separator + /// + public static string GetAbsoluteDirectoryPath(params string[] segments) + { + string path = GetAbsolutePath(segments); + return EnsureTrailingSeparator(path); + } + /// /// Joins relative path segments onto a base path without allowing rooted child segments. /// Leading separators on child segments are treated as relative separators. diff --git a/listenarr.domain/Models/Audiobook.cs b/listenarr.domain/Models/Audiobook.cs index 834a5746e..d5dcc40ab 100644 --- a/listenarr.domain/Models/Audiobook.cs +++ b/listenarr.domain/Models/Audiobook.cs @@ -65,10 +65,14 @@ public string? BasePath { get { - // TODO: Should be put on the set operation with appropriate DB migration to normalize existing data - return FileUtils.NormalizeStoredPath(field); + field = FileUtils.NormalizeStoredPath(field); + return FileUtils.EnsureTrailingSeparator(field); + } + set + { + var normalized = FileUtils.NormalizeStoredPath(value); + field = Path.TrimEndingDirectorySeparator(normalized); } - set; } // Multi-file support: store zero or more file records for this audiobook diff --git a/listenarr.domain/Models/RootFolder.cs b/listenarr.domain/Models/RootFolder.cs index 066e3deb6..8e0506d2a 100644 --- a/listenarr.domain/Models/RootFolder.cs +++ b/listenarr.domain/Models/RootFolder.cs @@ -16,6 +16,7 @@ * along with this program. If not, see . */ using System.ComponentModel.DataAnnotations; +using Listenarr.Domain.Common; namespace Listenarr.Domain.Models { @@ -30,7 +31,14 @@ public class RootFolder [Required] [MaxLength(1000)] - public string Path { get; set; } = string.Empty; + public string Path + { + get + { + return FileUtils.EnsureTrailingSeparator(field); + } + set; + } = string.Empty; public bool IsDefault { get; set; } = false; diff --git a/listenarr.infrastructure/Extensions/HostedServiceRegistrationExtensions.cs b/listenarr.infrastructure/Extensions/HostedServiceRegistrationExtensions.cs index 6ddf5c36d..b476be4ca 100644 --- a/listenarr.infrastructure/Extensions/HostedServiceRegistrationExtensions.cs +++ b/listenarr.infrastructure/Extensions/HostedServiceRegistrationExtensions.cs @@ -82,6 +82,8 @@ public static IServiceCollection AddListenarrHostedServices(this IServiceCollect // Background worker that processes unmatched-file scan jobs services.AddHostedService(); + services.AddSingleton(); + return services; } } diff --git a/listenarr.infrastructure/Persistence/Repositories/AudiobookRepository.cs b/listenarr.infrastructure/Persistence/Repositories/AudiobookRepository.cs index 1914e3756..ff993c3f1 100644 --- a/listenarr.infrastructure/Persistence/Repositories/AudiobookRepository.cs +++ b/listenarr.infrastructure/Persistence/Repositories/AudiobookRepository.cs @@ -33,7 +33,6 @@ public async Task> GetAllAsync() { // Omits Include(Files) — use when file data is fetched separately return await _db.Audiobooks - .AsNoTracking() .OrderBy(a => a.Title) .ToListAsync(); } @@ -120,7 +119,6 @@ public async Task UpdateAsync(Audiobook audiobook) System.Diagnostics.Debug.WriteLine("Suppressed non-fatal exception in catch block."); } - _db.Audiobooks.Update(audiobook); await _db.SaveChangesAsync(); return true; } diff --git a/listenarr.infrastructure/Persistence/Repositories/EfRootFolderRepository.cs b/listenarr.infrastructure/Persistence/Repositories/EfRootFolderRepository.cs index 1e1c76612..4ef2fff6e 100644 --- a/listenarr.infrastructure/Persistence/Repositories/EfRootFolderRepository.cs +++ b/listenarr.infrastructure/Persistence/Repositories/EfRootFolderRepository.cs @@ -33,11 +33,13 @@ public EfRootFolderRepository(IDbContextFactory dbFactory, I _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } - public async Task AddAsync(RootFolder root) + public async Task AddAsync(RootFolder root) { await using var ctx = await _dbFactory.CreateDbContextAsync(); ctx.RootFolders.Add(root); await ctx.SaveChangesAsync(); + + return root; } public async Task> GetAllAsync() @@ -52,12 +54,6 @@ public async Task> GetAllAsync() return await ctx.RootFolders.FindAsync(id); } - public async Task GetByPathAsync(string path) - { - await using var ctx = await _dbFactory.CreateDbContextAsync(); - return await ctx.RootFolders.FirstOrDefaultAsync(r => r.Path == path); - } - public async Task RemoveAsync(int id) { await using var ctx = await _dbFactory.CreateDbContextAsync(); @@ -90,62 +86,6 @@ public async Task ClearDefaultExceptAsync(int? excludeId, CancellationToken ct = if (others.Count > 0) await ctx.SaveChangesAsync(ct); } - public async Task HasAudiobooksUnderPathAsync(string rootPath, CancellationToken ct = default) - { - await using var ctx = await _dbFactory.CreateDbContextAsync(); - return await ctx.Audiobooks.AnyAsync(a => - a.BasePath != null && (a.BasePath == rootPath || a.BasePath.StartsWith(rootPath + Path.DirectorySeparatorChar)), - ct); - } - - public async Task> GetAudiobooksUnderPathAsync(string rootPath, CancellationToken ct = default) - { - await using var ctx = await _dbFactory.CreateDbContextAsync(); - return await ctx.Audiobooks - .Where(a => a.BasePath != null && (a.BasePath == rootPath || a.BasePath.StartsWith(rootPath + Path.DirectorySeparatorChar))) - .ToListAsync(ct); - } - - public async Task> MigrateAudiobookPathsAsync(string oldRootPath, string newRootPath, CancellationToken ct = default) - { - await using var ctx = await _dbFactory.CreateDbContextAsync(); - var all = await ctx.Audiobooks.Where(a => a.BasePath != null).ToListAsync(ct); - - const char backslash = '\\'; - const char slash = '/'; - string NormalizeForCompare(string s) => (s ?? string.Empty).Replace(slash, backslash).TrimEnd(backslash).ToLowerInvariant(); - var oldNorm = NormalizeForCompare(oldRootPath); - - var affected = all.Where(a => - { - var bpNorm = NormalizeForCompare(a.BasePath!); - return bpNorm == oldNorm || bpNorm.StartsWith(oldNorm + backslash); - }).ToList(); - - var moves = new List<(int audiobookId, string original, string target)>(); - foreach (var a in affected) - { - var original = a.BasePath!; - char sepToUse = original.Contains(backslash) ? backslash : slash; - var suffix = original.Length > oldRootPath.Length - ? original.Substring(oldRootPath.Length).TrimStart(backslash, slash) - : string.Empty; - var target = string.IsNullOrEmpty(suffix) - ? newRootPath - : newRootPath + sepToUse + suffix.Replace(backslash, sepToUse).Replace(slash, sepToUse); - moves.Add((a.Id, original, target)); - a.BasePath = target; - } - - if (affected.Count > 0) - { - ctx.Audiobooks.UpdateRange(affected); - await ctx.SaveChangesAsync(ct); - } - - return moves; - } - public async Task SaveChangesAsync(CancellationToken ct = default) { // No-op for factory-based repo; each method manages its own context diff --git a/tests/Features/Api/Controllers/LibraryController_AddToLibraryTests.cs b/tests/Features/Api/Controllers/LibraryController_AddToLibraryTests.cs index b21b3bd48..04fc0e4b2 100644 --- a/tests/Features/Api/Controllers/LibraryController_AddToLibraryTests.cs +++ b/tests/Features/Api/Controllers/LibraryController_AddToLibraryTests.cs @@ -24,6 +24,7 @@ using Listenarr.Tests.Common; using Listenarr.Tests.Builders; using Listenarr.Application.Interfaces; +using Listenarr.Domain.Common; namespace Listenarr.Tests.Features.Api.Controllers { @@ -83,7 +84,7 @@ public async Task AddToLibrary_UsesLegacyAuthorField_PopulatesAuthorsAndBasePath Assert.NotNull(stored); Assert.NotNull(stored.Authors); Assert.Contains("Legacy Author", stored.Authors); - Assert.Equal(Path.Join(tempRoot, "Legacy Author"), stored.BasePath); + Assert.Equal(FileUtils.GetAbsoluteDirectoryPath(tempRoot, "Legacy Author"), stored.BasePath); } [Fact] @@ -228,7 +229,7 @@ public async Task AddToLibrary_WithCustomPath_StoresCustomPathAsBasePath() 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); + var expectedPath = FileUtils.EnsureTrailingSeparator(Path.GetFullPath(customPath)); Assert.Equal(expectedPath, stored.BasePath); } @@ -260,7 +261,7 @@ public async Task AddToLibrary_HandlesWrongCustomPath() 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); + Assert.Equal(FileUtils.GetAbsoluteDirectoryPath(tempRoot, "Custom Author"), stored.BasePath); } } } diff --git a/tests/Features/Api/Controllers/LibraryController_MoveTests.cs b/tests/Features/Api/Controllers/LibraryController_MoveTests.cs index e2d743a42..425bd02b8 100644 --- a/tests/Features/Api/Controllers/LibraryController_MoveTests.cs +++ b/tests/Features/Api/Controllers/LibraryController_MoveTests.cs @@ -116,7 +116,7 @@ public async Task MoveAudiobook_UpdatesBasePath_WhenMoveFilesFalse() // Ensure DB was updated var updated = await _audiobookRepository.GetByIdAsync(ab.Id); - Assert.Equal(FileUtils.NormalizeStoredPath(target), updated!.BasePath); + Assert.Equal(FileUtils.EnsureTrailingSeparator(target), updated!.BasePath); // Ensure move queue was NOT enqueued mockMoveQueue.Verify(m => m.EnqueueMoveAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); @@ -151,9 +151,8 @@ await _applicationSettingsRepository.SaveAsync(new ApplicationSettingsBuilder() Assert.Equal(200, okObj.StatusCode); var updated = await _audiobookRepository.GetByIdAsync(audiobook.Id); - Assert.NotNull(updated); - Assert.Equal(FileUtils.NormalizeStoredPath(Path.Join(outputPath, relativeTarget)), updated.BasePath); - Assert.StartsWith(" listenarr-move-dst-", Path.GetFileName(updated.BasePath), StringComparison.Ordinal); + Assert.Equal(FileUtils.GetAbsoluteDirectoryPath(outputPath, relativeTarget), updated.BasePath); + Assert.Contains(relativeTarget, updated.BasePath, StringComparison.Ordinal); } } } diff --git a/tests/Features/Api/Controllers/ManualImportControllerTests.cs b/tests/Features/Api/Controllers/ManualImportControllerTests.cs index 0b479c691..e8e201d5c 100644 --- a/tests/Features/Api/Controllers/ManualImportControllerTests.cs +++ b/tests/Features/Api/Controllers/ManualImportControllerTests.cs @@ -27,6 +27,7 @@ using Listenarr.Application.Common; using Listenarr.Infrastructure.FileSystem; using Listenarr.Api.Dtos.ManualImport; +using Listenarr.Domain.Common; namespace Listenarr.Tests.Features.Api.Controllers { @@ -276,7 +277,7 @@ public async Task InteractiveManualImport_MultiFileBatch_EnqueuesSingleCommonDir var outputRoot = CreateTempDirectory("listenarr-manual-scan-root"); var srcDir = CreateTempDirectory("listenarr-manual-scan-src"); - var book = new Audiobook { Id = 222, Title = "Jack of Shadows", Authors = new System.Collections.Generic.List { "Roger Zelazny" }, BasePath = outputRoot }; + var book = new Audiobook { Id = 222, Title = "Jack of Shadows", Authors = ["Roger Zelazny"], BasePath = outputRoot }; var disc1 = Path.Join(srcDir, "Disc 1.mp3"); var disc2 = Path.Join(srcDir, "Disc 2.mp3"); @@ -312,10 +313,10 @@ public async Task InteractiveManualImport_MultiFileBatch_EnqueuesSingleCommonDir var action = await controller.Start(request); Assert.IsType(action.Result); - Assert.Equal(expectedScanPath, book.BasePath); + Assert.Equal(FileUtils.EnsureTrailingSeparator(expectedScanPath), book.BasePath); scanMock.Verify(s => s.EnqueueScanAsync(book, expectedScanPath), Times.Once); scanMock.Verify(s => s.EnqueueScanAsync(book, It.IsAny()), Times.Once); - repoMock.Verify(r => r.UpdateAsync(It.Is(a => a.Id == book.Id && a.BasePath == expectedScanPath)), Times.AtLeastOnce); + repoMock.Verify(r => r.UpdateAsync(It.Is(a => a.Id == book.Id && a.BasePath == FileUtils.EnsureTrailingSeparator(expectedScanPath))), Times.AtLeastOnce); } [Fact] diff --git a/tests/Features/Api/Services/ImportServiceTests.cs b/tests/Features/Api/Services/ImportServiceTests.cs index 0b7120cc2..30982344b 100644 --- a/tests/Features/Api/Services/ImportServiceTests.cs +++ b/tests/Features/Api/Services/ImportServiceTests.cs @@ -26,6 +26,7 @@ using Listenarr.Tests.Builders; using Listenarr.Domain.Models.Configurations; using Listenarr.Domain.Models.Enumerations; +using Listenarr.Domain.Common; namespace Listenarr.Tests.Features.Api.Services { @@ -398,7 +399,7 @@ public async Task ImportSingleFile_WithWindowsShortBasePath_NormalizesFinalPath( var stored = await _audiobookRepository.GetByIdAsync(321); Assert.NotNull(stored); - Assert.Equal(longBasePath, stored!.BasePath); + Assert.Equal(FileUtils.EnsureTrailingSeparator(longBasePath), stored!.BasePath); } [Fact] diff --git a/tests/Features/Api/Services/LegacyOutputPathMigratorTests.cs b/tests/Features/Api/Services/LegacyOutputPathMigratorTests.cs index 7ec4448ae..ef48fe3a4 100644 --- a/tests/Features/Api/Services/LegacyOutputPathMigratorTests.cs +++ b/tests/Features/Api/Services/LegacyOutputPathMigratorTests.cs @@ -28,8 +28,8 @@ namespace Listenarr.Tests.Features.Api.Services { public class LegacyOutputPathMigratorTests { - private string booksPath = FileUtils.GetAbsolutePath("books"); - private string otherPath = FileUtils.GetAbsolutePath("other"); + private string booksPath = FileUtils.GetAbsoluteDirectoryPath("books"); + private string otherPath = FileUtils.GetAbsoluteDirectoryPath("other"); [Fact] public async Task Migrate_CreatesRoot_WhenNoExistingAndOutputPathPresent() diff --git a/tests/Features/Api/Services/MoveBackgroundService_BroadcastTests.cs b/tests/Features/Api/Services/MoveBackgroundService_BroadcastTests.cs index ea5046db1..17838309d 100644 --- a/tests/Features/Api/Services/MoveBackgroundService_BroadcastTests.cs +++ b/tests/Features/Api/Services/MoveBackgroundService_BroadcastTests.cs @@ -24,6 +24,7 @@ using Listenarr.Application.Notification; using Listenarr.Application.Interfaces; using Listenarr.Infrastructure.FileSystem; +using Listenarr.Domain.Common; namespace Listenarr.Tests.Features.Api.Services { @@ -138,7 +139,7 @@ public async Task MoveBackgroundService_BroadcastsFullAudiobookDto_AfterSuccessf var basePathProp = dtoObj.GetType().GetProperty("BasePath") ?? dtoObj.GetType().GetProperty("basePath"); Assert.NotNull(basePathProp); var val = basePathProp.GetValue(dtoObj)?.ToString(); - Assert.Equal(Path.GetFullPath(dst), val); + Assert.Equal(FileUtils.EnsureTrailingSeparator(Path.GetFullPath(dst)), val); } } } diff --git a/tests/Features/Api/Services/RenameServiceTests.cs b/tests/Features/Api/Services/RenameServiceTests.cs index 08986b155..44d69f6b5 100644 --- a/tests/Features/Api/Services/RenameServiceTests.cs +++ b/tests/Features/Api/Services/RenameServiceTests.cs @@ -18,6 +18,7 @@ using Listenarr.Application.Audiobooks; using Listenarr.Application.Common; using Listenarr.Application.Interfaces; +using Listenarr.Domain.Common; using Listenarr.Domain.Models; using Listenarr.Domain.Models.Configurations; using Listenarr.Domain.Models.Enumerations; @@ -127,11 +128,11 @@ public async Task PreviewRename_PreservesCustomBasePath() }); await db.SaveChangesAsync(); - var previews = await service.PreviewRenameAsync(new[] { 2 }); + var previews = await service.PreviewRenameAsync([2]); var preview = Assert.Single(previews); Assert.False(preview.FolderChanged); - Assert.Equal(NormalizePath(customBase), preview.NewFolderPath); + Assert.Equal(FileUtils.EnsureTrailingSeparator(NormalizePath(customBase)), preview.NewFolderPath); Assert.All(preview.FileRenames, file => Assert.StartsWith(NormalizePath(customBase), file.NewPath!, StringComparison.OrdinalIgnoreCase)); } @@ -383,7 +384,7 @@ public async Task ExecuteRename_RecomputesBasePathAfterPartialFileFailures() await using var verifyDb = CreateContext(dbName); var saved = await verifyDb.Audiobooks.Include(a => a.Files).SingleAsync(a => a.Id == 7); - Assert.Equal(NormalizePath(libraryRoot), NormalizePath(saved.BasePath)); + Assert.Equal(FileUtils.EnsureTrailingSeparator(NormalizePath(libraryRoot)), NormalizePath(saved.BasePath)); Assert.NotEqual(NormalizePath(targetFolder), NormalizePath(saved.BasePath)); Assert.Contains(saved.Files!, file => file.Id == 71 && NormalizePath(file.Path) == NormalizePath(firstTargetPath)); Assert.Contains(saved.Files!, file => file.Id == 72 && NormalizePath(file.Path) == NormalizePath(secondSourcePath)); @@ -397,7 +398,7 @@ public async Task ExecuteRename_MovesFileAndUpdatesDatabasePaths() { var libraryRoot = Path.Join(_tempRoot, "library"); var sourceFolder = Path.Join(libraryRoot, "Old"); - var targetFolder = Path.Join(libraryRoot, "Author", "Book"); + var targetFolder = FileUtils.GetAbsoluteDirectoryPath(libraryRoot, "Author", "Book"); Directory.CreateDirectory(sourceFolder); var sourcePath = Path.Join(sourceFolder, "old-name.m4b"); var targetPath = Path.Join(targetFolder, "Book.m4b"); diff --git a/tests/Features/Api/Services/RootFolderServiceTests.cs b/tests/Features/Api/Services/RootFolderServiceTests.cs deleted file mode 100644 index 858caffac..000000000 --- a/tests/Features/Api/Services/RootFolderServiceTests.cs +++ /dev/null @@ -1,198 +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. - * - * 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 Microsoft.EntityFrameworkCore; -using Xunit; -using Xunit.Abstractions; -using Microsoft.Extensions.Logging; -using Moq; -using Listenarr.Infrastructure.Persistence.Repositories; -using Listenarr.Domain.Models; -using Listenarr.Domain.Common; -using Listenarr.Infrastructure.Persistence; -using Listenarr.Application.Downloads; -using Listenarr.Application.Interfaces; - -namespace Listenarr.Tests.Features.Api.Services -{ - public class RootFolderServiceTests - { - private string booksPath = FileUtils.GetAbsolutePath("books"); - private string rootPath = FileUtils.GetAbsolutePath("root"); - private string newRootPath = FileUtils.GetAbsolutePath("newroot"); - private string rootAuthorTitlePath = FileUtils.GetAbsolutePath("root", "Author", "Title"); - private string newRootAuthorTitlePath = FileUtils.GetAbsolutePath("newroot", "Author", "Title"); - - private readonly ITestOutputHelper _output; - public RootFolderServiceTests(ITestOutputHelper output) { _output = output; } - - [Fact] - public async Task Create_Throws_WhenPathDuplicate() - { - var options = new DbContextOptionsBuilder() - .UseInMemoryDatabase(Guid.NewGuid().ToString()) - .Options; - - var db = new ListenArrDbContext(options); - db.RootFolders.Add(new RootFolder { Name = "A", Path = booksPath }); - 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 = booksPath })); - } - - [Fact] - public async Task Delete_Throws_WhenReferencedWithoutReassign() - { - var options = new DbContextOptionsBuilder() - .UseInMemoryDatabase(Guid.NewGuid().ToString()) - .Options; - - var db = new ListenArrDbContext(options); - var root = new RootFolder { Name = "A", Path = booksPath }; - db.RootFolders.Add(root); - db.Audiobooks.Add(new Audiobook { Title = "T", BasePath = booksPath }); - 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.DeleteAsync(root.Id)); - } - - [Fact] - public async Task Update_RenameWithoutMove_UpdatesAudiobookBasePaths() - { - 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); - db.Audiobooks.Add(new Audiobook { Title = "A1", BasePath = rootAuthorTitlePath }); - db.Audiobooks.Add(new Audiobook { Title = "A2", BasePath = rootPath }); - 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); - - using (var pre = new ListenArrDbContext(options)) - { - var dumpPre = string.Join("; ", pre.Audiobooks.Select(a => $"{a.Title} => {a.BasePath}")); - _output.WriteLine("Before update: " + dumpPre); - } - - await svc.UpdateAsync(new RootFolder { Id = root.Id, Name = "R2", Path = newRootPath }, moveFiles: false); - - using (var verifyDb = new ListenArrDbContext(options)) - { - var dumpAfter = string.Join("; ", verifyDb.Audiobooks.Select(a => $"{a.Title} => {a.BasePath}")); - _output.WriteLine("After update: " + dumpAfter); - - var a1 = verifyDb.Audiobooks.First(a => a.Title == "A1").BasePath; - var a2 = verifyDb.Audiobooks.First(a => a.Title == "A2").BasePath; - if (a1 != newRootAuthorTitlePath || a2 != newRootPath) - { - var dump = string.Join("; ", verifyDb.Audiobooks.Select(a => $"{a.Title} => {a.BasePath}")); - throw new Xunit.Sdk.XunitException($"Unexpected audiobook base paths after root update. Dump: {dump}"); - } - Assert.Equal(newRootAuthorTitlePath, a1); - Assert.Equal(newRootPath, a2); - } - } - - [Fact] - public async Task Update_RenameWithMove_EnqueuesMovesAndUpdatesDB() - { - 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); - var ab1 = new Audiobook { Id = 1, Title = "A1", BasePath = rootAuthorTitlePath }; - var ab2 = new Audiobook { Id = 2, Title = "A2", BasePath = rootPath }; - db.Audiobooks.AddRange(ab1, ab2); - await db.SaveChangesAsync(); - - var dbFactory = new TestDbFactory(options); - var repo = new EfRootFolderRepository(dbFactory, Mock.Of>()); - - var mockMove = new Moq.Mock(); - mockMove.Setup(m => m.EnqueueMoveAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(Guid.NewGuid()); - - var logger = new TestLogger(_output); - var svc = new RootFolderService(repo, logger, mockMove.Object); - - using (var pre = new ListenArrDbContext(options)) - { - var dumpPre = string.Join("; ", pre.Audiobooks.Select(a => $"{a.Title} => {a.BasePath}")); - _output.WriteLine("Before update (with move): " + dumpPre); - } - - await svc.UpdateAsync(new RootFolder { Id = root.Id, Name = "R2", Path = newRootPath }, moveFiles: true); - - using (var verifyDb = new ListenArrDbContext(options)) - { - var dumpAfter = string.Join("; ", verifyDb.Audiobooks.Select(a => $"{a.Title} => {a.BasePath}")); - _output.WriteLine("After update (with move): " + dumpAfter); - - var a1 = verifyDb.Audiobooks.First(a => a.Title == "A1").BasePath; - var a2 = verifyDb.Audiobooks.First(a => a.Title == "A2").BasePath; - if (a1 != newRootAuthorTitlePath || a2 != newRootPath) - { - var dump = string.Join("; ", verifyDb.Audiobooks.Select(a => $"{a.Title} => {a.BasePath}")); - throw new Xunit.Sdk.XunitException($"Unexpected audiobook base paths after root update (with move). Dump: {dump}"); - } - Assert.Equal(newRootAuthorTitlePath, a1); - Assert.Equal(newRootPath, a2); - } - - mockMove.Verify(m => m.EnqueueMoveAsync(1, newRootAuthorTitlePath, rootAuthorTitlePath), Times.Once); - mockMove.Verify(m => m.EnqueueMoveAsync(2, newRootPath, rootPath), Times.Once); - } - - private class TestDbFactory : IDbContextFactory - { - private readonly DbContextOptions _options; - public TestDbFactory(DbContextOptions options) { _options = options; } - public Task CreateDbContextAsync() => Task.FromResult(new ListenArrDbContext(_options)); - public ListenArrDbContext CreateDbContext() => new ListenArrDbContext(_options); - } - - private class TestLogger : ILogger - { - private readonly ITestOutputHelper _out; - public TestLogger(ITestOutputHelper output) { _out = output; } - 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) - { - _out.WriteLine($"[{logLevel}] {formatter(state, exception)}{(exception != null ? " Exception: " + exception : "")}"); - } - } - } -} diff --git a/tests/Features/Application/Audiobooks/RootFolderServiceTests.cs b/tests/Features/Application/Audiobooks/RootFolderServiceTests.cs new file mode 100644 index 000000000..ca7558446 --- /dev/null +++ b/tests/Features/Application/Audiobooks/RootFolderServiceTests.cs @@ -0,0 +1,247 @@ +/* + * 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 Xunit; +using Xunit.Abstractions; +using Moq; +using Listenarr.Domain.Common; +using Listenarr.Application.Interfaces; +using Listenarr.Tests.Common; +using Listenarr.Tests.Builders; +using Microsoft.Extensions.DependencyInjection; + +namespace Listenarr.Tests.Features.Application.Audiobooks +{ + public class RootFolderServiceTests : BaseTests + { + private readonly string booksPath = FileUtils.GetAbsoluteDirectoryPath("books"); + private readonly string rootPath = FileUtils.GetAbsoluteDirectoryPath("root"); + private readonly string newRootPath = FileUtils.GetAbsoluteDirectoryPath("newroot"); + private readonly string rootAuthorTitlePath = FileUtils.GetAbsoluteDirectoryPath("root", "Author", "Title"); + private readonly string newRootAuthorTitlePath = FileUtils.GetAbsoluteDirectoryPath("newroot", "Author", "Title"); + + private readonly ITestOutputHelper _output; + public RootFolderServiceTests(ITestOutputHelper output) { _output = output; } + + [Fact] + public async Task Create_Throws_WhenPathDuplicate() + { + await _rootFolderRepository.AddAsync(new RootFolderBuilder() + .WithName("A") + .WithPath(booksPath) + .Build()); + + var rootFolderService = _provider.GetRequiredService(); + + await Assert.ThrowsAsync(() => rootFolderService.CreateAsync(new RootFolderBuilder() + .WithName("B") + .WithPath(booksPath) + .Build())); + } + + [Fact] + public async Task Delete_Throws_WhenReferencedWithoutReassign() + { + var root = await _rootFolderRepository.AddAsync(new RootFolderBuilder() + .WithName("A") + .WithPath(booksPath) + .Build()); + + await _audiobookRepository.AddAsync(new AudiobookBuilder() + .WithTitle("T") + .WithBasePath(booksPath) + .Build()); + + var rootFolderService = _provider.GetRequiredService(); + + await Assert.ThrowsAsync(() => rootFolderService.DeleteAsync(root.Id)); + } + + [Fact] + public async Task Update_RenameWithoutMove_UpdatesAudiobookBasePaths() + { + var root = await _rootFolderRepository.AddAsync(new RootFolderBuilder() + .WithName("R") + .WithPath(rootPath) + .Build()); + + var a1 = await _audiobookRepository.AddAsync(new AudiobookBuilder() + .WithTitle("A1") + .WithBasePath(rootAuthorTitlePath) + .Build()); + + var a2 = await _audiobookRepository.AddAsync(new AudiobookBuilder() + .WithTitle("A2") + .WithBasePath(rootPath) + .Build()); + + var rootFolderService = _provider.GetRequiredService(); + + await rootFolderService.UpdateAsync(new RootFolderBuilder() + .WithId(root.Id) + .WithName("R2") + .WithPath(newRootPath) + .Build(), moveFiles: false); + + a1 = await _audiobookRepository.GetByIdAsync(a1.Id); + a2 = await _audiobookRepository.GetByIdAsync(a2.Id); + Assert.Equal(newRootAuthorTitlePath, a1.BasePath); + Assert.Equal(newRootPath, a2.BasePath); + } + + [Fact] + public async Task Update_RenameWithMove_EnqueuesMovesAndUpdatesDB() + { + var mockMove = new Mock(); + mockMove.Setup(m => m.EnqueueMoveAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(Guid.NewGuid()); + _services.AddSingleton(mockMove.Object); + Init(); + + var root = await _rootFolderRepository.AddAsync(new RootFolderBuilder() + .WithName("R1") + .WithPath(rootPath) + .Build()); + + var a1 = await _audiobookRepository.AddAsync(new AudiobookBuilder() + .WithTitle("A1") + .WithBasePath(rootAuthorTitlePath) + .Build()); + + var a2 = await _audiobookRepository.AddAsync(new AudiobookBuilder() + .WithTitle("A2") + .WithBasePath(rootPath) + .Build()); + + var rootFolderService = _provider.GetRequiredService(); + await rootFolderService.UpdateAsync(new RootFolderBuilder() + .WithId(root.Id) + .WithName("R2") + .WithPath(newRootPath) + .Build(), moveFiles: true); + + a1 = await _audiobookRepository.GetByIdAsync(a1.Id); + a2 = await _audiobookRepository.GetByIdAsync(a2.Id); + Assert.Equal(newRootAuthorTitlePath, a1.BasePath); + Assert.Equal(newRootPath, a2.BasePath); + + mockMove.Verify(m => m.EnqueueMoveAsync(a1.Id, newRootAuthorTitlePath, rootAuthorTitlePath), Times.Once); + mockMove.Verify(m => m.EnqueueMoveAsync(a2.Id, newRootPath, rootPath), Times.Once); + } + + [Fact] + public async Task Delete_WhenThereIsOtherRootWithinThatOne_StillUsed() + { + var r1 = await _rootFolderRepository.AddAsync(new RootFolderBuilder() + .WithName("R") + .WithPath(FileUtils.GetAbsolutePath("data", "media", "books")) + .Build()); + + var r2 = await _rootFolderRepository.AddAsync(new RootFolderBuilder() + .WithName("R") + .WithPath(FileUtils.GetAbsolutePath("data", "media", "books", "audiobooks")) + .Build()); + + var a1 = await _audiobookRepository.AddAsync(new AudiobookBuilder() + .WithTitle("A1") + .WithBasePath(FileUtils.GetAbsolutePath(r1.Path, "a1")) + .Build()); + + var a2 = await _audiobookRepository.AddAsync(new AudiobookBuilder() + .WithTitle("A2") + .WithBasePath(FileUtils.GetAbsolutePath(r2.Path, "a2")) + .Build()); + + var rootFolderService = _provider.GetRequiredService(); + await Assert.ThrowsAsync(() => rootFolderService.DeleteAsync(r1.Id)); + } + + [Fact] + public async Task Delete_WhenThereIsOtherRootWithinThatOne_Unused() + { + var r1 = await _rootFolderRepository.AddAsync(new RootFolderBuilder() + .WithName("R") + .WithPath(FileUtils.GetAbsolutePath("data", "media", "books")) + .Build()); + + var r2 = await _rootFolderRepository.AddAsync(new RootFolderBuilder() + .WithName("R") + .WithPath(FileUtils.GetAbsolutePath("data", "media", "books", "audiobooks")) + .Build()); + + var a1 = await _audiobookRepository.AddAsync(new AudiobookBuilder() + .WithTitle("A1") + .WithBasePath(FileUtils.GetAbsolutePath(r2.Path, "a1")) + .Build()); + + var a2 = await _audiobookRepository.AddAsync(new AudiobookBuilder() + .WithTitle("A2") + .WithBasePath(FileUtils.GetAbsolutePath(r2.Path, "a2")) + .Build()); + + var rootFolderService = _provider.GetRequiredService(); + await rootFolderService.DeleteAsync(r1.Id); + Assert.Null(await _rootFolderRepository.GetByIdAsync(r1.Id)); + } + + [Fact] + public async Task Delete_EvenIfThereIsAlreadyOrphanedAudiobooks() + { + var r1 = await _rootFolderRepository.AddAsync(new RootFolderBuilder() + .WithName("R") + .WithPath(FileUtils.GetAbsolutePath("data", "media", "books")) + .Build()); + + var a1 = await _audiobookRepository.AddAsync(new AudiobookBuilder() + .WithTitle("A1") + .Build()); + + var rootFolderService = _provider.GetRequiredService(); + await rootFolderService.DeleteAsync(r1.Id); + Assert.Null(await _rootFolderRepository.GetByIdAsync(r1.Id)); + } + + [Fact] + public async Task Delete_WithSiblingsPath() + { + var r1 = await _rootFolderRepository.AddAsync(new RootFolderBuilder() + .WithName("R1") + .WithPath(FileUtils.GetAbsolutePath("data", "media", "books")) + .Build()); + + var r2 = await _rootFolderRepository.AddAsync(new RootFolderBuilder() + .WithName("R2") + .WithPath(FileUtils.GetAbsolutePath("data", "media", "book")) + .Build()); + + var a1 = await _audiobookRepository.AddAsync(new AudiobookBuilder() + .WithTitle("A1") + .WithBasePath(FileUtils.GetAbsolutePath(r1.Path, "a1")) + .Build()); + + var a2 = await _audiobookRepository.AddAsync(new AudiobookBuilder() + .WithTitle("A2") + .WithBasePath(FileUtils.GetAbsolutePath(r2.Path, "a2")) + .Build()); + + var rootFolderService = _provider.GetRequiredService(); + + // Should throw as R2 /book should not be detected as root path for A1 (/books) + await Assert.ThrowsAsync(() => rootFolderService.DeleteAsync(r1.Id)); + } + } +} diff --git a/tests/Mocks/ListenarrWebApplicationFactory.cs b/tests/Mocks/ListenarrWebApplicationFactory.cs index 8fbae2c07..ac12df8cb 100644 --- a/tests/Mocks/ListenarrWebApplicationFactory.cs +++ b/tests/Mocks/ListenarrWebApplicationFactory.cs @@ -63,7 +63,6 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) var overrides = new Dictionary { ["Listenarr:SqliteDbPath"] = _sqliteDbPath, - ["Listenarr:DisableHostedServices"] = "true" }; config.AddInMemoryCollection(overrides);