From 62e44ba0ce04fe8e051917c1762f6ce983ccd730 Mon Sep 17 00:00:00 2001 From: T4g1 Date: Mon, 18 May 2026 14:53:29 +0200 Subject: [PATCH 1/2] [fix] Remove root folder contained within another one --- listenarr.api/Program.cs | 1 - .../Audiobooks/RootFolderService.cs | 192 +++++++++++++++++ .../Downloads/RootFolderService.cs | 155 -------------- .../Interfaces/IRootFolderService.cs | 8 + .../Repositories/IRootFolderRepository.cs | 5 +- .../Repositories/EfRootFolderRepository.cs | 60 +----- .../Api/Services/RootFolderServiceTests.cs | 198 ----------------- .../Audiobooks/RootFolderServiceTests.cs | 201 ++++++++++++++++++ 8 files changed, 405 insertions(+), 415 deletions(-) create mode 100644 listenarr.application/Audiobooks/RootFolderService.cs delete mode 100644 listenarr.application/Downloads/RootFolderService.cs delete mode 100644 tests/Features/Api/Services/RootFolderServiceTests.cs create mode 100644 tests/Features/Application/Audiobooks/RootFolderServiceTests.cs diff --git a/listenarr.api/Program.cs b/listenarr.api/Program.cs index 89faa7c32..8e5c5e8da 100644 --- a/listenarr.api/Program.cs +++ b/listenarr.api/Program.cs @@ -30,7 +30,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; diff --git a/listenarr.application/Audiobooks/RootFolderService.cs b/listenarr.application/Audiobooks/RootFolderService.cs new file mode 100644 index 000000000..916060e45 --- /dev/null +++ b/listenarr.application/Audiobooks/RootFolderService.cs @@ -0,0 +1,192 @@ +/* + * 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(); + } + + 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"); + + var existingByPath = await rootFolderRepository.GetByPathAsync(root.Path); + if (existingByPath != null) 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; + } + + rootFolders = [.. rootFolders.Where(r => r.Id != id)]; + + var audiobooks = await audiobookRepository.GetAllAsync(); + var rootedAudiobooks = audiobooks.Where(a => !string.IsNullOrEmpty(a.BasePath) && !rootFolders.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"); + } + + if (reassignRootId != null) + { + var newRoot = await rootFolderRepository.GetByIdAsync(reassignRootId!.Value) ?? throw new KeyNotFoundException("Reassign root not found"); + await MigrateAudiobookPathsAsync(rootFolder.Path, newRoot.Path); + } + + 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"); + + var duplicate = await rootFolderRepository.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 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!; + 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; + + 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..88f0ee20b 100644 --- a/listenarr.application/Interfaces/Repositories/IRootFolderRepository.cs +++ b/listenarr.application/Interfaces/Repositories/IRootFolderRepository.cs @@ -24,14 +24,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.infrastructure/Persistence/Repositories/EfRootFolderRepository.cs b/listenarr.infrastructure/Persistence/Repositories/EfRootFolderRepository.cs index 1e1c76612..c91f2e51d 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() @@ -90,62 +92,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/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..b02e3ee90 --- /dev/null +++ b/tests/Features/Application/Audiobooks/RootFolderServiceTests.cs @@ -0,0 +1,201 @@ +/* + * 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.GetAbsolutePath("books"); + private readonly string rootPath = FileUtils.GetAbsolutePath("root"); + private readonly string newRootPath = FileUtils.GetAbsolutePath("newroot"); + private readonly string rootAuthorTitlePath = FileUtils.GetAbsolutePath("root", "Author", "Title"); + private readonly string newRootAuthorTitlePath = FileUtils.GetAbsolutePath("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("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: 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)); + } + } +} From 5da5f3f00d2eef03b259c0ba1d0e384b073408b6 Mon Sep 17 00:00:00 2001 From: T4g1 Date: Mon, 18 May 2026 15:04:52 +0200 Subject: [PATCH 2/2] [fix] Adapt tests to run with full context --- listenarr.api/Program.Testing.cs | 1 - listenarr.api/Program.cs | 23 +------------------ .../HostedServiceRegistrationExtensions.cs | 2 ++ tests/Mocks/ListenarrWebApplicationFactory.cs | 1 - 4 files changed, 3 insertions(+), 24 deletions(-) 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 8e5c5e8da..5735beca2 100644 --- a/listenarr.api/Program.cs +++ b/listenarr.api/Program.cs @@ -544,29 +544,8 @@ ex is IOException sqliteOptions.MigrationsAssembly(typeof(QualityProfileRepository).Assembly.GetName().Name))); // 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.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/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);