Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions listenarr.api/Controllers/ManualImportController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,7 @@ private async Task<string> 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
Expand Down
1 change: 0 additions & 1 deletion listenarr.api/Program.Testing.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ static partial void ApplyTestHostPatches(WebApplicationBuilder builder)
var inMemory = new Dictionary<string, string?>()
{
["Listenarr:SqliteDbPath"] = sqliteDbPath,
["Listenarr:DisableHostedServices"] = "true"
};
builder.Configuration.AddInMemoryCollection(inMemory);
}
Expand Down
24 changes: 1 addition & 23 deletions listenarr.api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<bool>("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<IUnmatchedScanQueueService, UnmatchedScanQueueService>();
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"));
Expand Down
2 changes: 1 addition & 1 deletion listenarr.application/Audiobooks/RenameService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
208 changes: 208 additions & 0 deletions listenarr.application/Audiobooks/RootFolderService.cs
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<RootFolderService> logger,
IMoveQueueService moveQueueService) : IRootFolderService
{
public async Task<RootFolder?> GetDefaultAsync()
{
return await rootFolderRepository.GetDefaultAsync();
}

private async Task<bool> HasDuplicate(RootFolder root)
{
var rootFolders = await rootFolderRepository.GetAllAsync();
return rootFolders.Any(r => r.Path == root.Path && r.Id != root.Id);
}

public async Task<RootFolder> 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);
Comment thread
therobbiedavis marked this conversation as resolved.
}

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)));
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The trailing separator fix helps, but I’d still avoid raw StartsWith for path ownership. This comparison is case-sensitive, so Windows-style paths with different casing can be treated as unrelated and mark a real child audiobook as orphaned.


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<List<RootFolder>> GetAllAsync() => await rootFolderRepository.GetAllAsync();

public async Task<RootFolder?> GetByIdAsync(int id) => await rootFolderRepository.GetByIdAsync(id);

public async Task<RootFolder> 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<List<(int audiobookId, string original, string target)>> 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;
}
}
}
Loading
Loading