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
48 changes: 33 additions & 15 deletions listenarr.api/Features/Library/LibraryAddWorkflow.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,11 @@ public async Task<IActionResult> AddAsync(LibraryController.AddToLibraryRequest
HistoryMessage = $"Audiobook '{request.Metadata.Title}' added to library from Add New page"
});

if (result.Rejected)
{
return new BadRequestObjectResult(new { message = result.Message });
}

if (result.AlreadyExists)
{
return new ConflictObjectResult(new { message = result.Message, audiobook = result.Audiobook });
Expand Down Expand Up @@ -129,7 +134,11 @@ public async Task<IActionResult> AddAsync(LibraryController.AddToLibraryRequest
_logger.LogInformation("Created Audiobook entity: Title={Title}, Asin={Asin}, PublishYear={PublishYear}",
LogRedaction.SanitizeText(audiobook.Title), LogRedaction.SanitizeText(audiobook.Asin), LogRedaction.SanitizeText(audiobook.PublishYear));

await AssignQualityProfileAsync(audiobook, request);
var profileError = await AssignQualityProfileAsync(audiobook, request);
if (profileError != null)
{
return profileError;
}

if (!string.IsNullOrWhiteSpace(request.DestinationPath))
{
Expand Down Expand Up @@ -251,29 +260,38 @@ private void TryExtractPublishYear(LibraryController.AddToLibraryRequest request
return fallbackImageUrl;
}

private async Task AssignQualityProfileAsync(Audiobook audiobook, LibraryController.AddToLibraryRequest request)
private async Task<IActionResult?> AssignQualityProfileAsync(Audiobook audiobook, LibraryController.AddToLibraryRequest request)
{
using var scope = _scopeFactory.CreateScope();
var qualityProfileService = scope.ServiceProvider.GetRequiredService<IQualityProfileService>();

if (request.QualityProfileId.HasValue)
{
audiobook.QualityProfileId = request.QualityProfileId.Value;
var suppliedProfile = await qualityProfileService.GetByIdAsync(request.QualityProfileId.Value);
if (suppliedProfile == null)
{
_logger.LogWarning("Rejected library add for '{Title}': quality profile {ProfileId} does not exist.",
LogRedaction.SanitizeText(audiobook.Title), request.QualityProfileId.Value);
return new BadRequestObjectResult(new { message = $"Quality profile with ID {request.QualityProfileId.Value} does not exist." });
}

audiobook.QualityProfileId = suppliedProfile.Id;
_logger.LogInformation("Assigned custom quality profile ID {ProfileId} to new audiobook '{Title}'",
request.QualityProfileId.Value, LogRedaction.SanitizeText(audiobook.Title));
return;
suppliedProfile.Id, LogRedaction.SanitizeText(audiobook.Title));
return null;
}

using var scope = _scopeFactory.CreateScope();
var qualityProfileService = scope.ServiceProvider.GetRequiredService<IQualityProfileService>();
var defaultProfile = await qualityProfileService.GetDefaultAsync();
if (defaultProfile != null)
{
audiobook.QualityProfileId = defaultProfile.Id;
_logger.LogInformation("Assigned default quality profile '{ProfileName}' (ID: {ProfileId}) to new audiobook '{Title}'",
defaultProfile.Name, defaultProfile.Id, audiobook.Title);
}
else
if (defaultProfile == null)
{
_logger.LogWarning("No default quality profile found. New audiobook '{Title}' will not have a quality profile assigned.", LogRedaction.SanitizeText(audiobook.Title));
_logger.LogWarning("Rejected library add for '{Title}': no quality profile supplied and no default profile is configured.", LogRedaction.SanitizeText(audiobook.Title));
return new BadRequestObjectResult(new { message = "No quality profile was supplied and no default quality profile is configured." });
}

audiobook.QualityProfileId = defaultProfile.Id;
_logger.LogInformation("Assigned default quality profile '{ProfileName}' (ID: {ProfileId}) to new audiobook '{Title}'",
defaultProfile.Name, defaultProfile.Id, audiobook.Title);
return null;
}

private async Task ResolveAuthorAsinsAsync(Audiobook audiobook)
Expand Down
66 changes: 48 additions & 18 deletions listenarr.application/Audiobooks/Catalog/LibraryAddService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,14 @@ public async Task<LibraryAddOperationResult> AddToLibraryAsync(
}
}

// Resolve and validate the quality profile before doing any persistence-side work.
// A profile-less audiobook is never searched, so refuse the add instead of storing it silently.
var resolvedProfile = await ResolveQualityProfileAsync(request, metadata.Title);
if (resolvedProfile.Rejected)
{
return resolvedProfile.Result!;
}

var imageUrl = await MoveImageToLibraryStorageAsync(metadata, request.SearchResult, firstIsbn);

var audiobook = metadata.ToAudiobook();
Expand All @@ -128,24 +136,7 @@ public async Task<LibraryAddOperationResult> AddToLibraryAsync(

AudiobookIdentifierMapper.SyncImportedIdentifiersFromLegacyFields(audiobook, metadata.Region);

if (request.QualityProfileId.HasValue)
{
audiobook.QualityProfileId = request.QualityProfileId.Value;
}
else
{
var defaultProfile = await _qualityProfileService.GetDefaultAsync();
if (defaultProfile != null)
{
audiobook.QualityProfileId = defaultProfile.Id;
}
else
{
_logger.LogWarning(
"No default quality profile found. New audiobook '{Title}' will not have a quality profile assigned.",
audiobook.Title);
}
}
audiobook.QualityProfileId = resolvedProfile.ProfileId;

var settings = await _configurationService.GetApplicationSettingsAsync();

Expand Down Expand Up @@ -198,6 +189,45 @@ public async Task<LibraryAddOperationResult> AddToLibraryAsync(
};
}

private async Task<(bool Rejected, int ProfileId, LibraryAddOperationResult? Result)> ResolveQualityProfileAsync(
LibraryAddOperationRequest request,
string? title)
{
if (request.QualityProfileId.HasValue)
{
var suppliedProfile = await _qualityProfileService.GetByIdAsync(request.QualityProfileId.Value);
if (suppliedProfile == null)
{
_logger.LogWarning(
"Rejected library add for '{Title}': quality profile {ProfileId} does not exist.",
title,
request.QualityProfileId.Value);
return (true, 0, new LibraryAddOperationResult
{
Rejected = true,
Message = $"Quality profile with ID {request.QualityProfileId.Value} does not exist."
});
}

return (false, suppliedProfile.Id, null);
}

var defaultProfile = await _qualityProfileService.GetDefaultAsync();
if (defaultProfile == null)
{
_logger.LogWarning(
"Rejected library add for '{Title}': no quality profile supplied and no default profile is configured.",
title);
return (true, 0, new LibraryAddOperationResult
{
Rejected = true,
Message = "No quality profile was supplied and no default quality profile is configured."
});
}

return (false, defaultProfile.Id, null);
}

private async Task<string?> MoveImageToLibraryStorageAsync(
AudibleBookMetadata metadata,
SearchResult? searchResult,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@ public sealed class LibraryAddOperationResult

public bool AlreadyExists { get; set; }

/// <summary>
/// True when the add was refused (e.g. an invalid quality profile id, or no profile
/// could be resolved). The caller should surface this as a 400-style validation error.
/// </summary>
public bool Rejected { get; set; }

public string Message { get; set; } = string.Empty;

public Audiobook? Audiobook { get; set; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,5 +27,11 @@ public interface IQualityProfileRepository
Task<QualityProfile> UpdateAsync(QualityProfile profile);
Task<bool> DeleteAsync(int id);
Task<int> CountAudiobooksUsingProfileAsync(int profileId);

/// <summary>
/// Seeds a single default quality profile when the table is empty. Idempotent:
/// does nothing when any profile already exists. Returns true if a profile was seeded.
/// </summary>
Task<bool> SeedDefaultProfileIfMissingAsync(CancellationToken cancellationToken = default);
}
}
29 changes: 29 additions & 0 deletions listenarr.domain/Audiobooks/QualityProfile.cs
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,35 @@ public class QualityProfile

public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;

/// <summary>
/// Builds the permissive "Any Quality" profile used to seed a fresh install so that
/// monitored audiobooks always resolve a quality profile and become searchable.
/// Every codec/bitrate rung is allowed and there is no cutoff, so no release is rejected on quality.
/// </summary>
public static QualityProfile CreateDefault()
{
return new QualityProfile
{
Name = "Any Quality",
Description = "Default profile seeded automatically. Accepts any audio quality.",
IsDefault = true,
Qualities = new List<QualityDefinition>
{
new() { Quality = "AAC 320kbps", Allowed = true, Priority = 0, Codec = "AAC", Bitrate = 320 },
new() { Quality = "AAC 256kbps", Allowed = true, Priority = 1, Codec = "AAC", Bitrate = 256 },
new() { Quality = "AAC 192kbps", Allowed = true, Priority = 2, Codec = "AAC", Bitrate = 192 },
new() { Quality = "AAC 128kbps", Allowed = true, Priority = 3, Codec = "AAC", Bitrate = 128 },
new() { Quality = "AAC 64kbps", Allowed = true, Priority = 4, Codec = "AAC", Bitrate = 64 },
new() { Quality = "MP3 320kbps", Allowed = true, Priority = 5, Codec = "MP3", Bitrate = 320 },
new() { Quality = "MP3 256kbps", Allowed = true, Priority = 6, Codec = "MP3", Bitrate = 256 },
new() { Quality = "MP3 VBR", Allowed = true, Priority = 7, Codec = "MP3" },
new() { Quality = "MP3 192kbps", Allowed = true, Priority = 8, Codec = "MP3", Bitrate = 192 },
new() { Quality = "MP3 128kbps", Allowed = true, Priority = 9, Codec = "MP3", Bitrate = 128 },
new() { Quality = "MP3 64kbps", Allowed = true, Priority = 10, Codec = "MP3", Bitrate = 64 },
}
};
}
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,5 +128,17 @@ public async Task<int> CountAudiobooksUsingProfileAsync(int profileId)
{
return await _db.Audiobooks.CountAsync(a => a.QualityProfileId == profileId);
}

public async Task<bool> SeedDefaultProfileIfMissingAsync(CancellationToken cancellationToken = default)
{
if (await _db.QualityProfiles.AnyAsync(cancellationToken))
{
return false;
}

_db.QualityProfiles.Add(QualityProfile.CreateDefault());
await _db.SaveChangesAsync(cancellationToken);
return true;
}
}
}
32 changes: 31 additions & 1 deletion listenarr.infrastructure/Persistence/StartupDbNormalizer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,19 @@ public StartupDbNormalizer(IServiceProvider provider, ILogger<StartupDbNormalize
}

public async Task StartAsync(CancellationToken cancellationToken)
{
using var scope = _provider.CreateScope();

// Each pass is guarded independently: a failure normalizing legacy columns must not
// prevent seeding the default quality profile, which fresh installs rely on to search at all.
await NormalizeJsonColumnsAsync(scope, cancellationToken);
await SeedDefaultQualityProfileAsync(scope, cancellationToken);
}

private async Task NormalizeJsonColumnsAsync(IServiceScope scope, CancellationToken cancellationToken)
{
try
{
using var scope = _provider.CreateScope();
var audiobookRepository = scope.ServiceProvider.GetRequiredService<IAudiobookRepository>();
await audiobookRepository.NormalizeJsonColumnsAsync(cancellationToken);
_logger.LogInformation("StartupDbNormalizer: normalization pass complete.");
Expand All @@ -60,6 +69,27 @@ public async Task StartAsync(CancellationToken cancellationToken)
}
}

private async Task SeedDefaultQualityProfileAsync(IServiceScope scope, CancellationToken cancellationToken)
{
try
{
var qualityProfileRepository = scope.ServiceProvider.GetRequiredService<IQualityProfileRepository>();
var seededDefaultProfile = await qualityProfileRepository.SeedDefaultProfileIfMissingAsync(cancellationToken);
if (seededDefaultProfile)
{
_logger.LogInformation("StartupDbNormalizer: seeded default quality profile (none existed).");
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
Debug.WriteLine("Suppressed non-fatal exception in catch block.");
}
catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException)
{
_logger.LogError(ex, "StartupDbNormalizer: unexpected error while seeding default quality profile");
}
}

public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ await _rootFolderRepository.AddAsync(new RootFolderBuilder()
.WithIsDefault()
.WithPath(tempRoot)
.Build());

// Adds now require a resolvable quality profile; production seeds this at startup.
await _qualityProfileRepository.AddAsync(QualityProfile.CreateDefault());
}

[Fact]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/*
* Listenarr - Audiobook Management System
* Copyright (C) 2024-2026 Listenarr Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*/

using Microsoft.AspNetCore.Mvc;
using Listenarr.Tests.Common;
using Listenarr.Tests.Builders;

namespace Listenarr.Tests.Features.Api.Features.Library
{
public class LibraryController_QualityProfileValidationTests : BaseTests
{
private readonly Mock<IImageCacheService> _imageCacheServiceMock = new();
private string _tempRoot = null!;
private int _defaultProfileId;

public override async Task InitializeAsync()
{
_imageCacheServiceMock
.Setup(m => m.MoveToLibraryStorageAsync(It.IsAny<string>(), It.IsAny<string>()))
.ReturnsAsync((string?)null);

Init(services => services.WithSingleton(_imageCacheServiceMock.Object));

_tempRoot = FileService.GetTempDirectory("listenarr-test");

await _applicationSettingsRepository.SaveAsync(new ApplicationSettingsBuilder()
.WithFolderNamingPattern("{Author}")
.WithFileNamingPattern("{Title}")
.Build());

await _rootFolderRepository.AddAsync(new RootFolderBuilder()
.WithIsDefault()
.WithPath(_tempRoot)
.Build());

var defaultProfile = await _qualityProfileRepository.AddAsync(QualityProfile.CreateDefault());
_defaultProfileId = defaultProfile.Id;
}

[Fact]
public async Task AddToLibrary_WithBogusQualityProfileId_ReturnsBadRequestAndPersistsNothing()
{
var controller = _provider.GetRequiredService<LibraryController>();

var request = new LibraryController.AddToLibraryRequest
{
Metadata = new AudibleBookMetadata
{
Title = "Bogus Profile Title",
Author = "Some Author"
},
Monitored = true,
QualityProfileId = 999999
};

// Act
var actionResult = await controller.AddToLibrary(request);

// Assert
Assert.IsType<BadRequestObjectResult>(actionResult);
Assert.Empty(await _audiobookRepository.GetAllAsync());
}

[Fact]
public async Task AddToLibrary_WithNoQualityProfileId_AssignsDefaultAndPersists()
{
var controller = _provider.GetRequiredService<LibraryController>();

var request = new LibraryController.AddToLibraryRequest
{
Metadata = new AudibleBookMetadata
{
Title = "Default Profile Title",
Author = "Some Author"
},
Monitored = true
};

// Act
var actionResult = await controller.AddToLibrary(request);

// Assert
Assert.IsType<OkObjectResult>(actionResult);
var stored = Assert.Single(await _audiobookRepository.GetAllAsync());
Assert.Equal(_defaultProfileId, stored.QualityProfileId);
}
}
}
Loading