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: 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 @@ -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;
Expand Down Expand Up @@ -545,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<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
192 changes: 192 additions & 0 deletions listenarr.application/Audiobooks/RootFolderService.cs
Original file line number Diff line number Diff line change
@@ -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 <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();
}

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");

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<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");

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<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!;
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;
}
}
}
155 changes: 0 additions & 155 deletions listenarr.application/Downloads/RootFolderService.cs

This file was deleted.

Loading
Loading