diff --git a/fe/src/services/api.ts b/fe/src/services/api.ts index 91c699d90..3669a29ee 100644 --- a/fe/src/services/api.ts +++ b/fe/src/services/api.ts @@ -22,6 +22,8 @@ import type { DownloadClientConfiguration, ApplicationSettings, ProwlarrImportConnectionSettings, + AudiobookshelfConnectionSettings, + AudiobookshelfActionResult, Audiobook, History, Indexer, @@ -878,6 +880,40 @@ class ApiService { return this.request('/configuration/prowlarr-import') } + // Audiobookshelf integration + async getAudiobookshelfSettings(): Promise { + return this.request('/configuration/audiobookshelf') + } + + async saveAudiobookshelfSettings( + settings: AudiobookshelfConnectionSettings, + ): Promise { + return this.request('/configuration/audiobookshelf', { + method: 'POST', + body: JSON.stringify(settings), + }) + } + + async testAudiobookshelfConnection(payload: { + url?: string + apiKey?: string + }): Promise { + return this.request('/audiobookshelf/test', { + method: 'POST', + body: JSON.stringify(payload), + }) + } + + async getAudiobookshelfLibraries(): Promise { + return this.request('/audiobookshelf/libraries') + } + + async triggerAudiobookshelfScan(): Promise { + return this.request('/audiobookshelf/scan', { + method: 'POST', + }) + } + // Root Folders async getRootFolders(): Promise { return this.request('/rootfolders') diff --git a/fe/src/types/index.ts b/fe/src/types/index.ts index 596251121..cc668a410 100644 --- a/fe/src/types/index.ts +++ b/fe/src/types/index.ts @@ -377,6 +377,26 @@ export interface ProwlarrImportConnectionSettings { hasSavedApiKey: boolean } +export interface AudiobookshelfConnectionSettings { + url: string + apiKey?: string | null + libraryId?: string | null + notifyOnImport: boolean + hasSavedApiKey: boolean +} + +export interface AudiobookshelfLibrary { + id: string + name: string + mediaType: string +} + +export interface AudiobookshelfActionResult { + success: boolean + message: string + libraries?: AudiobookshelfLibrary[] | null +} + export interface StartupConfig { logLevel?: string enableSsl?: boolean diff --git a/fe/src/views/SettingsView.vue b/fe/src/views/SettingsView.vue index 11e4e2ca5..9a1a2b9e6 100644 --- a/fe/src/views/SettingsView.vue +++ b/fe/src/views/SettingsView.vue @@ -84,6 +84,14 @@ Discord Bot + +
@@ -879,6 +890,8 @@ const seriesLookup = ref(null) const seriesLookupLoading = ref(false) const seriesLookupRequestId = ref(0) const seriesMetadataRefreshBusy = ref(false) +const audiobookshelfScanning = ref(false) +const audiobookshelfConfigured = ref(false) const seriesMonitoringBusy = ref(false) const seriesMonitoringStatus = ref(null) const seriesMonitoringStatusRequestId = ref(0) @@ -1970,6 +1983,28 @@ async function refreshSeriesMetadata() { } } +async function updateAudiobookshelf() { + if (audiobookshelfScanning.value) return + audiobookshelfScanning.value = true + try { + const result = await apiService.triggerAudiobookshelfScan() + if (result.success) { + toast.success('Audiobookshelf', result.message) + } else { + toast.error('Audiobookshelf scan failed', result.message) + } + } catch (err) { + errorTracking.captureException(err as Error, { + component: 'CollectionView', + operation: 'updateAudiobookshelf', + metadata: { series: name.value }, + }) + toast.error('Audiobookshelf scan failed', 'Could not reach the Listenarr API') + } finally { + audiobookshelfScanning.value = false + } +} + const goBack = () => { router.back() } @@ -2287,6 +2322,18 @@ function handleCheckboxKeydown(audiobook: CollectionDisplayItem, event: Keyboard } onMounted(async () => { + // Show the Audiobookshelf action only when the integration is configured + // (typeof guard keeps partial apiService mocks in tests working) + if (typeof apiService.getAudiobookshelfSettings === 'function') { + void apiService + .getAudiobookshelfSettings() + .then((settings) => { + audiobookshelfConfigured.value = Boolean(settings.url && settings.hasSavedApiKey) + }) + .catch(() => { + audiobookshelfConfigured.value = false + }) + } await loadCollectionData(false) }) diff --git a/fe/src/views/settings/AudiobookshelfTab.vue b/fe/src/views/settings/AudiobookshelfTab.vue new file mode 100644 index 000000000..7114b715e --- /dev/null +++ b/fe/src/views/settings/AudiobookshelfTab.vue @@ -0,0 +1,280 @@ + + + + + + diff --git a/listenarr.api/Features/Audiobookshelf/AudiobookshelfController.cs b/listenarr.api/Features/Audiobookshelf/AudiobookshelfController.cs new file mode 100644 index 000000000..5d409f1a0 --- /dev/null +++ b/listenarr.api/Features/Audiobookshelf/AudiobookshelfController.cs @@ -0,0 +1,77 @@ +/* + * 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.Api.Attributes; +using Listenarr.Application.Integrations.Audiobookshelf.Contracts; +using Microsoft.AspNetCore.Mvc; + +namespace Listenarr.Api.Features.Audiobookshelf +{ + [ApiController] + [Route("api/v{version:apiVersion}/audiobookshelf")] + [RequireAdminOrApiKey] + [Tags("Audiobookshelf")] + public class AudiobookshelfController : ControllerBase + { + private readonly IAudiobookshelfService _audiobookshelfService; + + public AudiobookshelfController(IAudiobookshelfService audiobookshelfService) + { + _audiobookshelfService = audiobookshelfService; + } + + /// + /// Test connectivity to an Audiobookshelf server. Blank fields fall back to the + /// saved connection, so a saved configuration can be re-tested without re-entering + /// the API token. Returns the server's libraries on success. + /// + [HttpPost("test")] + public async Task> TestConnection([FromBody] AudiobookshelfTestRequestDto? request, CancellationToken cancellationToken) + { + var result = await _audiobookshelfService.TestConnectionAsync(request?.Url, request?.ApiKey, cancellationToken); + return Ok(new { success = result.Success, message = result.Message, libraries = result.Libraries }); + } + + /// + /// List libraries from the configured Audiobookshelf server (for the settings library picker). + /// + [HttpGet("libraries")] + public async Task> GetLibraries(CancellationToken cancellationToken) + { + var result = await _audiobookshelfService.GetLibrariesAsync(cancellationToken); + return Ok(new { success = result.Success, message = result.Message, libraries = result.Libraries }); + } + + /// + /// Request an Audiobookshelf scan of the configured library (or all book libraries), + /// so newly imported files show up without a manual scan in Audiobookshelf. + /// + [HttpPost("scan")] + public async Task> TriggerScan(CancellationToken cancellationToken) + { + var result = await _audiobookshelfService.TriggerScanAsync(cancellationToken); + return Ok(new { success = result.Success, message = result.Message }); + } + } + + public sealed class AudiobookshelfTestRequestDto + { + public string? Url { get; set; } + public string? ApiKey { get; set; } + } +} diff --git a/listenarr.api/Features/Configuration/SettingsController.cs b/listenarr.api/Features/Configuration/SettingsController.cs index 130be42ba..b03c065fc 100644 --- a/listenarr.api/Features/Configuration/SettingsController.cs +++ b/listenarr.api/Features/Configuration/SettingsController.cs @@ -114,9 +114,51 @@ private static ApplicationSettings PrepareApplicationSettingsResponse(Applicatio clone.AdminUsername = null; clone.AdminPassword = null; clone.ProwlarrApiKeyEncrypted = null; + clone.AudiobookshelfApiKeyEncrypted = null; return clone; } + /// + /// Get the saved Audiobookshelf connection metadata. + /// The API token itself is never returned; callers only receive whether a saved token exists. + /// + [Tags("Settings")] + [HttpGet("audiobookshelf")] + public async Task> GetAudiobookshelfSettings() + { + try + { + var settings = await _configurationService.GetAudiobookshelfSettingsAsync(); + settings.ApiKey = null; + return Ok(settings); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogError(ex, "Error retrieving saved Audiobookshelf settings"); + return StatusCode(500, "Internal server error"); + } + } + + /// + /// Save the Audiobookshelf connection settings. A blank or redacted API token keeps the saved one. + /// + [Tags("Settings")] + [HttpPost("audiobookshelf")] + public async Task> SaveAudiobookshelfSettings([FromBody] AudiobookshelfConnectionSettings settings) + { + try + { + var saved = await _configurationService.SaveAudiobookshelfSettingsAsync(settings); + saved.ApiKey = null; + return Ok(saved); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogError(ex, "Error saving Audiobookshelf settings"); + return StatusCode(500, new { error = "Failed to save Audiobookshelf settings", message = ex.Message }); + } + } + /// /// Get the saved Prowlarr import connection metadata used by the Indexers tab. /// The API key itself is never returned; callers only receive whether a saved key exists. diff --git a/listenarr.api/GlobalUsings.cs b/listenarr.api/GlobalUsings.cs index 9080d5199..6d3f8e481 100644 --- a/listenarr.api/GlobalUsings.cs +++ b/listenarr.api/GlobalUsings.cs @@ -47,6 +47,7 @@ global using Listenarr.Domain.Audiobooks.Rules; global using Listenarr.Domain.Configuration; global using Listenarr.Domain.Downloads; +global using Listenarr.Domain.Integrations; global using Listenarr.Domain.Search; global using Listenarr.Domain.SystemDiagnostics; global using Listenarr.Domain.SystemDiagnostics.Exceptions; diff --git a/listenarr.application/Configuration/Contracts/IConfigurationService.cs b/listenarr.application/Configuration/Contracts/IConfigurationService.cs index 7b770931b..f1de6129d 100644 --- a/listenarr.application/Configuration/Contracts/IConfigurationService.cs +++ b/listenarr.application/Configuration/Contracts/IConfigurationService.cs @@ -84,6 +84,18 @@ public interface IConfigurationService /// The connection settings to save. Task SaveProwlarrImportSettingsAsync(ProwlarrImportConnectionSettings settings); + /// + /// Gets the saved Audiobookshelf connection settings. + /// + /// When true, includes the decrypted API token for server-side use. + Task GetAudiobookshelfSettingsAsync(bool includeSecret = false); + + /// + /// Saves the Audiobookshelf connection settings. + /// + /// The connection settings to save. + Task SaveAudiobookshelfSettingsAsync(AudiobookshelfConnectionSettings settings); + /// /// Gets the startup configuration /// diff --git a/listenarr.application/Configuration/Core/ConfigurationService.Audiobookshelf.cs b/listenarr.application/Configuration/Core/ConfigurationService.Audiobookshelf.cs new file mode 100644 index 000000000..80ee28c5e --- /dev/null +++ b/listenarr.application/Configuration/Core/ConfigurationService.Audiobookshelf.cs @@ -0,0 +1,111 @@ +/* + * 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.Extensions.Logging; + +namespace Listenarr.Application.Configuration.Core +{ + public partial class ConfigurationService + { + public async Task GetAudiobookshelfSettingsAsync(bool includeSecret = false) + { + try + { + var settings = await settingsRepository.GetAsync(); + + if (settings == null) + { + return new AudiobookshelfConnectionSettings(); + } + + var result = new AudiobookshelfConnectionSettings + { + Url = settings.AudiobookshelfUrl?.Trim() ?? string.Empty, + LibraryId = settings.AudiobookshelfLibraryId?.Trim(), + NotifyOnImport = settings.AudiobookshelfNotifyOnImport == true, + HasSavedApiKey = !string.IsNullOrWhiteSpace(settings.AudiobookshelfApiKeyEncrypted), + }; + + if (includeSecret && result.HasSavedApiKey) + { + result.ApiKey = TryUnprotectAudiobookshelfApiKey(settings.AudiobookshelfApiKeyEncrypted); + if (string.IsNullOrWhiteSpace(result.ApiKey)) + { + result.HasSavedApiKey = false; + } + } + + return result; + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + logger.LogError(ex, "Error loading saved Audiobookshelf settings"); + return new AudiobookshelfConnectionSettings(); + } + } + + public async Task SaveAudiobookshelfSettingsAsync(AudiobookshelfConnectionSettings settings) + { + try + { + var existing = await settingsRepository.GetAsync() ?? new ApplicationSettings { Id = 1 }; + + existing.AudiobookshelfUrl = string.IsNullOrWhiteSpace(settings.Url) ? string.Empty : settings.Url.Trim(); + existing.AudiobookshelfLibraryId = string.IsNullOrWhiteSpace(settings.LibraryId) ? null : settings.LibraryId.Trim(); + existing.AudiobookshelfNotifyOnImport = settings.NotifyOnImport; + + if (!string.IsNullOrWhiteSpace(settings.ApiKey) + && !string.Equals(settings.ApiKey, ApiResponseRedactor.RedactedValue, StringComparison.Ordinal)) + { + existing.AudiobookshelfApiKeyEncrypted = secretProtector.Protect(settings.ApiKey.Trim()); + } + else if (string.IsNullOrWhiteSpace(existing.AudiobookshelfUrl)) + { + // Clearing the URL disconnects the integration; drop the stored token with it. + existing.AudiobookshelfApiKeyEncrypted = null; + } + + await settingsRepository.SaveAsync(existing); + return await GetAudiobookshelfSettingsAsync(); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + logger.LogError(ex, "Error saving Audiobookshelf settings"); + throw; + } + } + + private string? TryUnprotectAudiobookshelfApiKey(string? encryptedApiKey) + { + if (string.IsNullOrWhiteSpace(encryptedApiKey)) + { + return null; + } + + try + { + return secretProtector.Unprotect(encryptedApiKey); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + logger.LogWarning(ex, "Failed to decrypt saved Audiobookshelf API token"); + return null; + } + } + } +} diff --git a/listenarr.application/Configuration/Core/ConfigurationService.Prowlarr.cs b/listenarr.application/Configuration/Core/ConfigurationService.Prowlarr.cs new file mode 100644 index 000000000..b873b1b90 --- /dev/null +++ b/listenarr.application/Configuration/Core/ConfigurationService.Prowlarr.cs @@ -0,0 +1,106 @@ +/* + * 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.Extensions.Logging; + +namespace Listenarr.Application.Configuration.Core +{ + public partial class ConfigurationService + { + public async Task GetProwlarrImportSettingsAsync(bool includeSecret = false) + { + try + { + var settings = await settingsRepository.GetAsync(); + + if (settings == null) + { + return new ProwlarrImportConnectionSettings(); + } + + var result = new ProwlarrImportConnectionSettings + { + Url = settings.ProwlarrUrl?.Trim() ?? string.Empty, + Port = settings.ProwlarrPort, + TagFilter = settings.ProwlarrTagFilter?.Trim(), + HasSavedApiKey = !string.IsNullOrWhiteSpace(settings.ProwlarrApiKeyEncrypted), + }; + + if (includeSecret && result.HasSavedApiKey) + { + result.ApiKey = TryUnprotectProwlarrApiKey(settings.ProwlarrApiKeyEncrypted); + if (string.IsNullOrWhiteSpace(result.ApiKey)) + { + result.HasSavedApiKey = false; + } + } + + return result; + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + logger.LogError(ex, "Error loading saved Prowlarr import settings"); + return new ProwlarrImportConnectionSettings(); + } + } + + public async Task SaveProwlarrImportSettingsAsync(ProwlarrImportConnectionSettings settings) + { + try + { + var existing = await settingsRepository.GetAsync() ?? new ApplicationSettings { Id = 1 }; + + existing.ProwlarrUrl = string.IsNullOrWhiteSpace(settings.Url) ? string.Empty : settings.Url.Trim(); + existing.ProwlarrPort = settings.Port; + existing.ProwlarrTagFilter = string.IsNullOrWhiteSpace(settings.TagFilter) ? null : settings.TagFilter.Trim(); + + if (!string.IsNullOrWhiteSpace(settings.ApiKey) + && !string.Equals(settings.ApiKey, ApiResponseRedactor.RedactedValue, StringComparison.Ordinal)) + { + existing.ProwlarrApiKeyEncrypted = secretProtector.Protect(settings.ApiKey.Trim()); + } + + await settingsRepository.SaveAsync(existing); + return await GetProwlarrImportSettingsAsync(); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + logger.LogError(ex, "Error saving Prowlarr import settings"); + throw; + } + } + + private string? TryUnprotectProwlarrApiKey(string? encryptedApiKey) + { + if (string.IsNullOrWhiteSpace(encryptedApiKey)) + { + return null; + } + + try + { + return secretProtector.Unprotect(encryptedApiKey); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + logger.LogWarning(ex, "Failed to decrypt saved Prowlarr import API key"); + return null; + } + } + } +} diff --git a/listenarr.application/Configuration/Core/ConfigurationService.cs b/listenarr.application/Configuration/Core/ConfigurationService.cs index 44675a641..faef44ed3 100644 --- a/listenarr.application/Configuration/Core/ConfigurationService.cs +++ b/listenarr.application/Configuration/Core/ConfigurationService.cs @@ -21,16 +21,37 @@ namespace Listenarr.Application.Configuration.Core { - public class ConfigurationService( - IApplicationSettingsRepository settingsRepository, - IApiConfigurationRepository apiConfigRepository, - IDownloadClientConfigurationRepository downloadClientRepository, - ILogger logger, - IUserService userService, - IStartupConfigService startupConfigService, - IRootFolderRepository rootFolderRepository, - ISecretProtector secretProtector) : IConfigurationService + public partial class ConfigurationService : IConfigurationService { + private readonly IApplicationSettingsRepository settingsRepository; + private readonly IApiConfigurationRepository apiConfigRepository; + private readonly IDownloadClientConfigurationRepository downloadClientRepository; + private readonly ILogger logger; + private readonly IUserService userService; + private readonly IStartupConfigService startupConfigService; + private readonly IRootFolderRepository rootFolderRepository; + private readonly ISecretProtector secretProtector; + + public ConfigurationService( + IApplicationSettingsRepository settingsRepository, + IApiConfigurationRepository apiConfigRepository, + IDownloadClientConfigurationRepository downloadClientRepository, + ILogger logger, + IUserService userService, + IStartupConfigService startupConfigService, + IRootFolderRepository rootFolderRepository, + ISecretProtector secretProtector) + { + this.settingsRepository = settingsRepository; + this.apiConfigRepository = apiConfigRepository; + this.downloadClientRepository = downloadClientRepository; + this.logger = logger; + this.userService = userService; + this.startupConfigService = startupConfigService; + this.rootFolderRepository = rootFolderRepository; + this.secretProtector = secretProtector; + } + // API Configuration methods public async Task> GetApiConfigurationsAsync() { @@ -205,6 +226,17 @@ public async Task SaveApplicationSettingsAsync(ApplicationSettings settings) { settings.ProwlarrApiKeyEncrypted = existing.ProwlarrApiKeyEncrypted; } + if (settings.AudiobookshelfUrl == null) + settings.AudiobookshelfUrl = existing.AudiobookshelfUrl; + if (settings.AudiobookshelfLibraryId == null) + settings.AudiobookshelfLibraryId = existing.AudiobookshelfLibraryId; + if (settings.AudiobookshelfNotifyOnImport == null) + settings.AudiobookshelfNotifyOnImport = existing.AudiobookshelfNotifyOnImport; + if (string.IsNullOrWhiteSpace(settings.AudiobookshelfApiKeyEncrypted) + || string.Equals(settings.AudiobookshelfApiKeyEncrypted, ApiResponseRedactor.RedactedValue, StringComparison.Ordinal)) + { + settings.AudiobookshelfApiKeyEncrypted = existing.AudiobookshelfApiKeyEncrypted; + } if (settings.EnabledNotificationTriggers == null) settings.EnabledNotificationTriggers = existing.EnabledNotificationTriggers; if (settings.Webhooks == null) @@ -281,87 +313,6 @@ public async Task SaveApplicationSettingsAsync(ApplicationSettings settings) } } - public async Task GetProwlarrImportSettingsAsync(bool includeSecret = false) - { - try - { - var settings = await settingsRepository.GetAsync(); - - if (settings == null) - { - return new ProwlarrImportConnectionSettings(); - } - - var result = new ProwlarrImportConnectionSettings - { - Url = settings.ProwlarrUrl?.Trim() ?? string.Empty, - Port = settings.ProwlarrPort, - TagFilter = settings.ProwlarrTagFilter?.Trim(), - HasSavedApiKey = !string.IsNullOrWhiteSpace(settings.ProwlarrApiKeyEncrypted), - }; - - if (includeSecret && result.HasSavedApiKey) - { - result.ApiKey = TryUnprotectProwlarrApiKey(settings.ProwlarrApiKeyEncrypted); - if (string.IsNullOrWhiteSpace(result.ApiKey)) - { - result.HasSavedApiKey = false; - } - } - - return result; - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - logger.LogError(ex, "Error loading saved Prowlarr import settings"); - return new ProwlarrImportConnectionSettings(); - } - } - - public async Task SaveProwlarrImportSettingsAsync(ProwlarrImportConnectionSettings settings) - { - try - { - var existing = await settingsRepository.GetAsync() ?? new ApplicationSettings { Id = 1 }; - - existing.ProwlarrUrl = string.IsNullOrWhiteSpace(settings.Url) ? string.Empty : settings.Url.Trim(); - existing.ProwlarrPort = settings.Port; - existing.ProwlarrTagFilter = string.IsNullOrWhiteSpace(settings.TagFilter) ? null : settings.TagFilter.Trim(); - - if (!string.IsNullOrWhiteSpace(settings.ApiKey) - && !string.Equals(settings.ApiKey, ApiResponseRedactor.RedactedValue, StringComparison.Ordinal)) - { - existing.ProwlarrApiKeyEncrypted = secretProtector.Protect(settings.ApiKey.Trim()); - } - - await settingsRepository.SaveAsync(existing); - return await GetProwlarrImportSettingsAsync(); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - logger.LogError(ex, "Error saving Prowlarr import settings"); - throw; - } - } - - private string? TryUnprotectProwlarrApiKey(string? encryptedApiKey) - { - if (string.IsNullOrWhiteSpace(encryptedApiKey)) - { - return null; - } - - try - { - return secretProtector.Unprotect(encryptedApiKey); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - logger.LogWarning(ex, "Failed to decrypt saved Prowlarr import API key"); - return null; - } - } - private static List? NormalizeTriggerList(List? list) { if (list == null) return null; diff --git a/listenarr.application/GlobalUsings.cs b/listenarr.application/GlobalUsings.cs index 464e217a8..a0a6bf38c 100644 --- a/listenarr.application/GlobalUsings.cs +++ b/listenarr.application/GlobalUsings.cs @@ -48,6 +48,7 @@ global using Listenarr.Domain.Configuration; global using Listenarr.Domain.Downloads; global using Listenarr.Domain.Identity; +global using Listenarr.Domain.Integrations; global using Listenarr.Domain.Search; global using Listenarr.Domain.SystemDiagnostics; global using Listenarr.Domain.SystemDiagnostics.Exceptions; diff --git a/listenarr.application/Integrations/Audiobookshelf/Contracts/IAudiobookshelfScanScheduler.cs b/listenarr.application/Integrations/Audiobookshelf/Contracts/IAudiobookshelfScanScheduler.cs new file mode 100644 index 000000000..74a89e6e6 --- /dev/null +++ b/listenarr.application/Integrations/Audiobookshelf/Contracts/IAudiobookshelfScanScheduler.cs @@ -0,0 +1,29 @@ +/* + * 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 . + */ +namespace Listenarr.Application.Integrations.Audiobookshelf.Contracts +{ + /// + /// Debounced post-import Audiobookshelf scan requests. Signalling is cheap and + /// never throws; whether a scan is actually sent is decided by the scheduler + /// (integration configured, notify-on-import enabled) when the debounce window closes. + /// + public interface IAudiobookshelfScanScheduler + { + void RequestScan(string reason); + } +} diff --git a/listenarr.application/Integrations/Audiobookshelf/Contracts/IAudiobookshelfService.cs b/listenarr.application/Integrations/Audiobookshelf/Contracts/IAudiobookshelfService.cs new file mode 100644 index 000000000..cea71a4b2 --- /dev/null +++ b/listenarr.application/Integrations/Audiobookshelf/Contracts/IAudiobookshelfService.cs @@ -0,0 +1,42 @@ +/* + * 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.Integrations.Audiobookshelf.Models; + +namespace Listenarr.Application.Integrations.Audiobookshelf.Contracts +{ + public interface IAudiobookshelfService + { + /// + /// Verifies connectivity by listing libraries. Falls back to the saved URL and/or + /// API token when the supplied values are blank or redacted. + /// + Task TestConnectionAsync(string? url, string? apiKey, CancellationToken cancellationToken = default); + + /// + /// Lists libraries from the configured Audiobookshelf server. + /// + Task GetLibrariesAsync(CancellationToken cancellationToken = default); + + /// + /// Requests a scan of the configured library (or of every book library when + /// no library id is configured). + /// + Task TriggerScanAsync(CancellationToken cancellationToken = default); + } +} diff --git a/listenarr.application/Integrations/Audiobookshelf/Models/AudiobookshelfModels.cs b/listenarr.application/Integrations/Audiobookshelf/Models/AudiobookshelfModels.cs new file mode 100644 index 000000000..eb20747d3 --- /dev/null +++ b/listenarr.application/Integrations/Audiobookshelf/Models/AudiobookshelfModels.cs @@ -0,0 +1,28 @@ +/* + * 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 . + */ +namespace Listenarr.Application.Integrations.Audiobookshelf.Models +{ + public sealed record AudiobookshelfLibrary(string Id, string Name, string MediaType); + + /// + /// Outcome of an Audiobookshelf API operation. Failures are reported through + /// and rather than exceptions so + /// callers (controllers, background workers) need no broad catch blocks. + /// + public sealed record AudiobookshelfActionResult(bool Success, string Message, List? Libraries = null); +} diff --git a/listenarr.application/Security/Redaction/ApiResponseRedactor.cs b/listenarr.application/Security/Redaction/ApiResponseRedactor.cs index fbe8610c4..94573c85b 100644 --- a/listenarr.application/Security/Redaction/ApiResponseRedactor.cs +++ b/listenarr.application/Security/Redaction/ApiResponseRedactor.cs @@ -75,6 +75,11 @@ public static ApplicationSettings RedactApplicationSettings(ApplicationSettings clone.ProwlarrApiKeyEncrypted = RedactedValue; } + if (!string.IsNullOrWhiteSpace(clone.AudiobookshelfApiKeyEncrypted)) + { + clone.AudiobookshelfApiKeyEncrypted = RedactedValue; + } + if (clone.Webhooks != null) { foreach (var webhook in clone.Webhooks.Where(w => !string.IsNullOrWhiteSpace(w.Url))) diff --git a/listenarr.domain/Configuration/ApplicationSettings.cs b/listenarr.domain/Configuration/ApplicationSettings.cs index 0142acd74..1e9be1946 100644 --- a/listenarr.domain/Configuration/ApplicationSettings.cs +++ b/listenarr.domain/Configuration/ApplicationSettings.cs @@ -199,6 +199,27 @@ public List ImportBlacklistExtensions /// public string? ProwlarrTagFilter { get; set; } + /// + /// Saved Audiobookshelf server URL used by the Audiobookshelf integration. + /// + public string? AudiobookshelfUrl { get; set; } + + /// + /// Encrypted Audiobookshelf API token used by the Audiobookshelf integration. + /// + public string? AudiobookshelfApiKeyEncrypted { get; set; } + + /// + /// Optional Audiobookshelf library id to scan. When empty, all book libraries are scanned. + /// + public string? AudiobookshelfLibraryId { get; set; } + + /// + /// When true, an Audiobookshelf scan is requested automatically after imports. + /// Nullable so partial settings payloads preserve the saved value. + /// + public bool? AudiobookshelfNotifyOnImport { get; set; } + /// /// Primary command group name (e.g. "request"). We'll create a slash command with this group and /// a subcommand for specific request types (e.g. "audiobook"). diff --git a/listenarr.domain/Integrations/AudiobookshelfConnectionSettings.cs b/listenarr.domain/Integrations/AudiobookshelfConnectionSettings.cs new file mode 100644 index 000000000..4e293db5c --- /dev/null +++ b/listenarr.domain/Integrations/AudiobookshelfConnectionSettings.cs @@ -0,0 +1,39 @@ +/* + * 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 . + */ +namespace Listenarr.Domain.Integrations +{ + public class AudiobookshelfConnectionSettings + { + public string Url { get; set; } = string.Empty; + public string? ApiKey { get; set; } + + /// + /// Optional Audiobookshelf library id to scan. When empty, every library + /// with mediaType "book" is scanned. + /// + public string? LibraryId { get; set; } + + /// + /// When true, Listenarr requests an Audiobookshelf scan automatically after + /// files are imported into the library (downloads and manual uploads alike). + /// + public bool NotifyOnImport { get; set; } + + public bool HasSavedApiKey { get; set; } + } +} diff --git a/listenarr.infrastructure/DependencyInjection/AppServiceRegistrationExtensions.cs b/listenarr.infrastructure/DependencyInjection/AppServiceRegistrationExtensions.cs index 4de77a2c9..fcd6372f1 100644 --- a/listenarr.infrastructure/DependencyInjection/AppServiceRegistrationExtensions.cs +++ b/listenarr.infrastructure/DependencyInjection/AppServiceRegistrationExtensions.cs @@ -8,6 +8,7 @@ * (at your option) any later version. */ using Listenarr.Infrastructure.DependencyInjection.Downloads; +using Listenarr.Infrastructure.DependencyInjection.Integrations; using Listenarr.Infrastructure.DependencyInjection.Library; using Listenarr.Infrastructure.DependencyInjection.Metadata; using Listenarr.Infrastructure.DependencyInjection.Notifications; @@ -34,6 +35,7 @@ public static IServiceCollection AddListenarrAppServices( services.AddLibraryServices(); services.AddDownloadServices(configuration); services.AddNotificationAndRealtimeServices(); + services.AddIntegrationServices(); services.AddSystemDiagnosticServices(); return services; } diff --git a/listenarr.infrastructure/DependencyInjection/Integrations/IntegrationRegistrationExtensions.cs b/listenarr.infrastructure/DependencyInjection/Integrations/IntegrationRegistrationExtensions.cs new file mode 100644 index 000000000..6aacf3197 --- /dev/null +++ b/listenarr.infrastructure/DependencyInjection/Integrations/IntegrationRegistrationExtensions.cs @@ -0,0 +1,40 @@ +/* + * 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 System.Net; +using Listenarr.Application.Integrations.Audiobookshelf.Contracts; +using Listenarr.Infrastructure.Integrations.Audiobookshelf; +using Microsoft.Extensions.DependencyInjection; + +namespace Listenarr.Infrastructure.DependencyInjection.Integrations; + +internal static class IntegrationRegistrationExtensions +{ + public static IServiceCollection AddIntegrationServices(this IServiceCollection services) + { + // Redirects are validated hop-by-hop via OutboundRequestSecurity, so the + // handler must not follow them on its own. + services.AddHttpClient(client => client.Timeout = TimeSpan.FromSeconds(30)) + .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler + { + AutomaticDecompression = DecompressionMethods.All, + UseProxy = false, + AllowAutoRedirect = false + }); + services.AddTransient(provider => + provider.GetRequiredService()); + + services.AddSingleton(); + services.AddSingleton(provider => + provider.GetRequiredService()); + services.AddHostedService(provider => provider.GetRequiredService()); + return services; + } +} diff --git a/listenarr.infrastructure/Integrations/Audiobookshelf/AudiobookshelfScanScheduler.cs b/listenarr.infrastructure/Integrations/Audiobookshelf/AudiobookshelfScanScheduler.cs new file mode 100644 index 000000000..72456f823 --- /dev/null +++ b/listenarr.infrastructure/Integrations/Audiobookshelf/AudiobookshelfScanScheduler.cs @@ -0,0 +1,114 @@ +/* + * 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.Integrations.Audiobookshelf.Contracts; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Infrastructure.Integrations.Audiobookshelf; + +/// +/// Coalesces post-import scan requests into a single Audiobookshelf scan. +/// Imports often arrive in bursts (multi-book grabs, bulk manual imports); the +/// trailing-edge debounce waits for a quiet period so one scan covers the whole +/// burst, and the max-delay cap keeps a long steady trickle from deferring the +/// scan forever. +/// +public sealed class AudiobookshelfScanScheduler : BackgroundService, IAudiobookshelfScanScheduler +{ + private static readonly TimeSpan QuietWindow = TimeSpan.FromSeconds(15); + private static readonly TimeSpan MaxDelay = TimeSpan.FromMinutes(2); + + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + private readonly SemaphoreSlim _signal = new(0); + + public AudiobookshelfScanScheduler(IServiceScopeFactory scopeFactory, ILogger logger) + { + _scopeFactory = scopeFactory; + _logger = logger; + } + + public void RequestScan(string reason) + { + _logger.LogDebug("Audiobookshelf scan requested: {Reason}", reason); + _signal.Release(); + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + try + { + await _signal.WaitAsync(stoppingToken); + + // Trailing-edge debounce: each further request restarts the quiet + // window, up to MaxDelay from the first request. + var deadline = DateTime.UtcNow + MaxDelay; + while (DateTime.UtcNow < deadline) + { + var moreRequests = await _signal.WaitAsync(QuietWindow, stoppingToken); + if (!moreRequests) + { + break; + } + } + + await TriggerScanIfEnabledAsync(stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + return; + } + } + } + + private async Task TriggerScanIfEnabledAsync(CancellationToken cancellationToken) + { + try + { + using var scope = _scopeFactory.CreateScope(); + var configurationService = scope.ServiceProvider.GetRequiredService(); + var settings = await configurationService.GetAudiobookshelfSettingsAsync(); + if (!settings.NotifyOnImport || string.IsNullOrWhiteSpace(settings.Url) || !settings.HasSavedApiKey) + { + _logger.LogDebug("Skipping Audiobookshelf post-import scan; integration disabled or not configured"); + return; + } + + var audiobookshelfService = scope.ServiceProvider.GetRequiredService(); + var result = await audiobookshelfService.TriggerScanAsync(cancellationToken); + if (!result.Success) + { + _logger.LogWarning("Audiobookshelf post-import scan failed: {Message}", result.Message); + } + } + catch (Exception exception) when (exception is not (OperationCanceledException or OutOfMemoryException or StackOverflowException)) + { + _logger.LogWarning(exception, "Audiobookshelf post-import scan failed unexpectedly"); + } + } + + public override void Dispose() + { + _signal.Dispose(); + base.Dispose(); + } +} diff --git a/listenarr.infrastructure/Integrations/Audiobookshelf/AudiobookshelfService.cs b/listenarr.infrastructure/Integrations/Audiobookshelf/AudiobookshelfService.cs new file mode 100644 index 000000000..9481c6ee5 --- /dev/null +++ b/listenarr.infrastructure/Integrations/Audiobookshelf/AudiobookshelfService.cs @@ -0,0 +1,249 @@ +/* + * 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 System.Net.Http.Headers; +using System.Text.Json; +using Listenarr.Application.Integrations.Audiobookshelf.Contracts; +using Listenarr.Application.Integrations.Audiobookshelf.Models; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Infrastructure.Integrations.Audiobookshelf; + +/// +/// Client for the Audiobookshelf server API (https://api.audiobookshelf.org/). +/// Used to verify connectivity, enumerate libraries, and request library scans so +/// Audiobookshelf picks up files imported by Listenarr without a manual scan. +/// +public class AudiobookshelfService : IAudiobookshelfService +{ + private readonly HttpClient _httpClientNoRedirect; + private readonly IConfigurationService _configurationService; + private readonly ILogger _logger; + + public AudiobookshelfService( + HttpClient httpClient, + IConfigurationService configurationService, + ILogger logger) + { + _httpClientNoRedirect = httpClient; + _configurationService = configurationService; + _logger = logger; + } + + public async Task TestConnectionAsync(string? url, string? apiKey, CancellationToken cancellationToken = default) + { + var saved = await _configurationService.GetAudiobookshelfSettingsAsync(includeSecret: true); + var effectiveUrl = string.IsNullOrWhiteSpace(url) ? saved.Url : url.Trim(); + var effectiveApiKey = string.IsNullOrWhiteSpace(apiKey) || string.Equals(apiKey, ApiResponseRedactor.RedactedValue, StringComparison.Ordinal) + ? saved.ApiKey + : apiKey.Trim(); + + return await FetchLibrariesAsync(effectiveUrl, effectiveApiKey, cancellationToken); + } + + public async Task GetLibrariesAsync(CancellationToken cancellationToken = default) + { + var saved = await _configurationService.GetAudiobookshelfSettingsAsync(includeSecret: true); + return await FetchLibrariesAsync(saved.Url, saved.ApiKey, cancellationToken); + } + + public async Task TriggerScanAsync(CancellationToken cancellationToken = default) + { + var saved = await _configurationService.GetAudiobookshelfSettingsAsync(includeSecret: true); + var librariesResult = await FetchLibrariesAsync(saved.Url, saved.ApiKey, cancellationToken); + if (!librariesResult.Success || librariesResult.Libraries == null) + { + return librariesResult; + } + + var targets = string.IsNullOrWhiteSpace(saved.LibraryId) + ? librariesResult.Libraries.Where(l => string.Equals(l.MediaType, "book", StringComparison.OrdinalIgnoreCase)).ToList() + : librariesResult.Libraries.Where(l => string.Equals(l.Id, saved.LibraryId.Trim(), StringComparison.Ordinal)).ToList(); + + if (targets.Count == 0) + { + return new AudiobookshelfActionResult(false, string.IsNullOrWhiteSpace(saved.LibraryId) + ? "No book libraries found on the Audiobookshelf server" + : "The configured Audiobookshelf library no longer exists"); + } + + var baseUrl = NormalizeBaseUrl(saved.Url); + var scanned = new List(); + var failed = new List(); + + foreach (var library in targets) + { + var scanUrl = $"{baseUrl}/api/libraries/{Uri.EscapeDataString(library.Id)}/scan"; + try + { + using var response = await SendAsync(HttpMethod.Post, scanUrl, saved.ApiKey!, cancellationToken); + if (response.IsSuccessStatusCode) + { + scanned.Add(library.Name); + } + else + { + _logger.LogWarning( + "Audiobookshelf scan request for library {LibraryName} returned {StatusCode}", + library.Name, + (int)response.StatusCode); + failed.Add($"{library.Name} (HTTP {(int)response.StatusCode})"); + } + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or UriFormatException or InvalidOperationException) + { + _logger.LogWarning(ex, "Failed to request Audiobookshelf scan for library {LibraryName}", library.Name); + failed.Add(library.Name); + } + } + + if (scanned.Count == 0) + { + return new AudiobookshelfActionResult(false, $"Audiobookshelf scan failed for {string.Join(", ", failed)}"); + } + + var message = failed.Count == 0 + ? $"Audiobookshelf scan started for {string.Join(", ", scanned)}" + : $"Audiobookshelf scan started for {string.Join(", ", scanned)}; failed for {string.Join(", ", failed)}"; + _logger.LogInformation("{Message}", message); + return new AudiobookshelfActionResult(true, message, librariesResult.Libraries); + } + + private async Task FetchLibrariesAsync(string? url, string? apiKey, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(url)) + { + return new AudiobookshelfActionResult(false, "Audiobookshelf URL is not configured"); + } + + if (string.IsNullOrWhiteSpace(apiKey)) + { + return new AudiobookshelfActionResult(false, "Audiobookshelf API token is not configured"); + } + + var baseUrl = NormalizeBaseUrl(url); + if (!OutboundRequestSecurity.TryValidateExternalHttpUrl(baseUrl, out var blockedReason, allowPrivateTargets: true)) + { + return new AudiobookshelfActionResult(false, $"Blocked Audiobookshelf target: {blockedReason}"); + } + + try + { + using var response = await SendAsync(HttpMethod.Get, $"{baseUrl}/api/libraries", apiKey, cancellationToken); + var body = await response.Content.ReadAsStringAsync(cancellationToken); + + if (response.StatusCode is System.Net.HttpStatusCode.Unauthorized or System.Net.HttpStatusCode.Forbidden) + { + return new AudiobookshelfActionResult(false, "Audiobookshelf rejected the API token"); + } + + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning( + "Audiobookshelf API at {Url} returned {StatusCode}", + LogRedaction.SanitizeUrl(baseUrl), + (int)response.StatusCode); + return new AudiobookshelfActionResult(false, $"Audiobookshelf API error (HTTP {(int)response.StatusCode})"); + } + + var libraries = ParseLibraries(body); + if (libraries == null) + { + return new AudiobookshelfActionResult(false, "Unexpected response from Audiobookshelf; is the URL an Audiobookshelf server?"); + } + + return new AudiobookshelfActionResult(true, $"Connected; found {libraries.Count} libraries", libraries); + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or UriFormatException or InvalidOperationException or JsonException) + { + _logger.LogWarning(ex, "Failed to reach Audiobookshelf at {Url}", LogRedaction.SanitizeUrl(baseUrl)); + return new AudiobookshelfActionResult(false, $"Failed to reach Audiobookshelf: {ex.Message}"); + } + } + + private async Task SendAsync(HttpMethod method, string url, string apiKey, CancellationToken cancellationToken) + { + var (response, _) = await OutboundRequestSecurity.SendWithValidatedRedirectsAsync( + currentUri => + { + var request = new HttpRequestMessage(method, currentUri); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); + return request; + }, + new Uri(url), + _httpClientNoRedirect, + _logger, + allowPrivateTargets: true, + cancellationToken: cancellationToken); + return response; + } + + private static List? ParseLibraries(string payload) + { + using var doc = JsonDocument.Parse(payload); + + // Current servers return { "libraries": [...] }; very old ones returned a bare array. + var root = doc.RootElement; + JsonElement array; + if (root.ValueKind == JsonValueKind.Object && root.TryGetProperty("libraries", out var librariesProp) && librariesProp.ValueKind == JsonValueKind.Array) + { + array = librariesProp; + } + else if (root.ValueKind == JsonValueKind.Array) + { + array = root; + } + else + { + return null; + } + + var result = new List(); + foreach (var element in array.EnumerateArray()) + { + if (element.ValueKind != JsonValueKind.Object + || !element.TryGetProperty("id", out var idProp) + || idProp.ValueKind != JsonValueKind.String) + { + continue; + } + + var name = element.TryGetProperty("name", out var nameProp) && nameProp.ValueKind == JsonValueKind.String + ? nameProp.GetString() ?? string.Empty + : string.Empty; + var mediaType = element.TryGetProperty("mediaType", out var mediaTypeProp) && mediaTypeProp.ValueKind == JsonValueKind.String + ? mediaTypeProp.GetString() ?? string.Empty + : string.Empty; + + result.Add(new AudiobookshelfLibrary(idProp.GetString()!, name, mediaType)); + } + + return result; + } + + private static string NormalizeBaseUrl(string url) + { + var trimmed = url.Trim().TrimEnd('/'); + if (!trimmed.Contains("://", StringComparison.Ordinal)) + { + trimmed = $"http://{trimmed}"; + } + + return trimmed; + } +} diff --git a/listenarr.infrastructure/Library/Scanning/ScanJobProcessor.Audiobookshelf.cs b/listenarr.infrastructure/Library/Scanning/ScanJobProcessor.Audiobookshelf.cs new file mode 100644 index 000000000..e08612a02 --- /dev/null +++ b/listenarr.infrastructure/Library/Scanning/ScanJobProcessor.Audiobookshelf.cs @@ -0,0 +1,34 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + */ +using Listenarr.Application.Integrations.Audiobookshelf.Contracts; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Infrastructure.Library.Scanning; + +public partial class ScanJobProcessor +{ + private void RequestAudiobookshelfScan(Audiobook audiobook, int createdFiles) + { + if (createdFiles <= 0) + { + return; + } + + try + { + using var scope = _scopeFactory.CreateScope(); + var scheduler = scope.ServiceProvider.GetService(); + scheduler?.RequestScan($"imported {createdFiles} file(s) for audiobook {audiobook.Id}"); + } + catch (Exception exception) when (exception is not (OperationCanceledException or OutOfMemoryException or StackOverflowException)) + { + _logger.LogWarning( + exception, + "Failed to request Audiobookshelf scan for audiobook {AudiobookId} in background scan", + audiobook.Id); + } + } +} diff --git a/listenarr.infrastructure/Library/Scanning/ScanJobProcessor.cs b/listenarr.infrastructure/Library/Scanning/ScanJobProcessor.cs index 3eca476ae..cc268116e 100644 --- a/listenarr.infrastructure/Library/Scanning/ScanJobProcessor.cs +++ b/listenarr.infrastructure/Library/Scanning/ScanJobProcessor.cs @@ -401,6 +401,7 @@ public async Task ProcessJobAsync(ScanJob job, CancellationToken stoppingToken) } await NotifyAvailableAsync(audiobook, createdFiles); + RequestAudiobookshelfScan(audiobook, createdFiles); var updated = await audiobookRepository.GetByIdAsync(audiobook.Id); if (updated != null) diff --git a/listenarr.infrastructure/Persistence/Migrations/20260809100000_AddAudiobookshelfSettingsToApplicationSettings.Designer.cs b/listenarr.infrastructure/Persistence/Migrations/20260809100000_AddAudiobookshelfSettingsToApplicationSettings.Designer.cs new file mode 100644 index 000000000..0582b79a2 --- /dev/null +++ b/listenarr.infrastructure/Persistence/Migrations/20260809100000_AddAudiobookshelfSettingsToApplicationSettings.Designer.cs @@ -0,0 +1,1560 @@ +// +using System; +using Listenarr.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Listenarr.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(ListenArrDbContext))] + [Migration("20260809100000_AddAudiobookshelfSettingsToApplicationSettings")] + partial class AddAudiobookshelfSettingsToApplicationSettings + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.8"); + + modelBuilder.Entity("Listenarr.Domain.ActivityHistory.DownloadHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AudiobookId") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("DownloadClient") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DownloadClientId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DownloadId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("EventDate") + .HasColumnType("TEXT"); + + b.Property("EventType") + .HasColumnType("INTEGER"); + + b.Property("ImportedAt") + .HasColumnType("TEXT"); + + b.Property("OutputPath") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("Protocol") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("WasImported") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.HasKey("Id"); + + b.HasIndex("AudiobookId"); + + b.HasIndex("DownloadId"); + + b.HasIndex("EventDate"); + + b.HasIndex("DownloadId", "EventType"); + + b.ToTable("DownloadHistories", (string)null); + }); + + modelBuilder.Entity("Listenarr.Domain.ActivityHistory.History", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AudiobookExternalId") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("AudiobookId") + .HasColumnType("INTEGER"); + + b.Property("AudiobookTitle") + .HasColumnType("TEXT"); + + b.Property("CorrelationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("DownloadClientId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DownloadId") + .HasMaxLength(150) + .HasColumnType("TEXT"); + + b.Property("Error") + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Message") + .HasColumnType("TEXT"); + + b.Property("NotificationSent") + .HasColumnType("INTEGER"); + + b.Property("Outcome") + .HasColumnType("INTEGER"); + + b.Property("ParentEventId") + .HasColumnType("INTEGER"); + + b.Property("Source") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SourceTitle") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AudiobookExternalId"); + + b.HasIndex("CorrelationId"); + + b.HasIndex("DownloadClientId"); + + b.HasIndex("DownloadId"); + + b.HasIndex("EventType"); + + b.HasIndex("Outcome"); + + b.HasIndex("Timestamp"); + + b.ToTable("History"); + }); + + modelBuilder.Entity("Listenarr.Domain.ActivityHistory.ProcessExecutionLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Arguments") + .HasColumnType("TEXT"); + + b.Property("DurationMs") + .HasColumnType("INTEGER"); + + b.Property("ExitCode") + .HasColumnType("INTEGER"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("TEXT"); + + b.Property("Stderr") + .HasColumnType("TEXT"); + + b.Property("Stdout") + .HasColumnType("TEXT"); + + b.Property("TimedOut") + .HasColumnType("INTEGER"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ProcessExecutionLogs"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.Audiobook", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Abridged") + .HasColumnType("INTEGER"); + + b.Property("Asin") + .HasColumnType("TEXT"); + + b.Property("AuthorAsins") + .HasColumnType("TEXT"); + + b.Property("Authors") + .HasColumnType("TEXT"); + + b.Property("BasePath") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("Edition") + .HasColumnType("TEXT"); + + b.Property("Explicit") + .HasColumnType("INTEGER"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("FileSize") + .HasColumnType("INTEGER"); + + b.Property("Genres") + .HasColumnType("TEXT"); + + b.Property("ImageUrl") + .HasColumnType("TEXT"); + + b.Property("Isbn") + .HasColumnType("TEXT"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LastSearchTime") + .HasColumnType("TEXT"); + + b.Property("Monitored") + .HasColumnType("INTEGER"); + + b.Property("Narrators") + .HasColumnType("TEXT"); + + b.Property("OpenLibraryId") + .HasColumnType("TEXT"); + + b.Property("PublishYear") + .HasColumnType("TEXT"); + + b.Property("PublishedDate") + .HasColumnType("TEXT"); + + b.Property("Publisher") + .HasColumnType("TEXT"); + + b.Property("Quality") + .HasColumnType("TEXT"); + + b.Property("QualityProfileId") + .HasColumnType("INTEGER"); + + b.Property("Runtime") + .HasColumnType("INTEGER"); + + b.Property("Series") + .HasColumnType("TEXT"); + + b.Property("SeriesNumber") + .HasColumnType("TEXT"); + + b.Property("Subtitle") + .HasColumnType("TEXT"); + + b.Property("Tags") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LastSearchTime"); + + b.HasIndex("Monitored"); + + b.HasIndex("QualityProfileId"); + + b.ToTable("Audiobooks"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.AudiobookExternalIdentifier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AudiobookId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("IsPrimary") + .HasColumnType("INTEGER"); + + b.Property("Region") + .HasMaxLength(8) + .HasColumnType("TEXT"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("ValueNormalized") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("ValueRaw") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AudiobookId"); + + b.HasIndex("Type", "ValueNormalized"); + + b.HasIndex("AudiobookId", "Type", "IsPrimary"); + + b.HasIndex("Type", "ValueNormalized", "Region"); + + b.ToTable("AudiobookExternalIdentifiers", (string)null); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.AudiobookFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AudiobookId") + .HasColumnType("INTEGER"); + + b.Property("Bitrate") + .HasColumnType("INTEGER"); + + b.Property("Channels") + .HasColumnType("INTEGER"); + + b.Property("Codec") + .HasColumnType("TEXT"); + + b.Property("Container") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DurationSeconds") + .HasColumnType("REAL"); + + b.Property("Format") + .HasColumnType("TEXT"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.Property("SampleRate") + .HasColumnType("INTEGER"); + + b.Property("Size") + .HasColumnType("INTEGER"); + + b.Property("Source") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AudiobookId"); + + b.ToTable("AudiobookFiles"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.AudiobookSeriesMembership", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AudiobookId") + .HasColumnType("INTEGER"); + + b.Property("IsPrimary") + .HasColumnType("INTEGER"); + + b.Property("SeriesAsin") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SeriesName") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("SeriesNumber") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AudiobookId"); + + b.HasIndex("AudiobookId", "IsPrimary"); + + b.HasIndex("AudiobookId", "SortOrder"); + + b.ToTable("AudiobookSeriesMemberships", (string)null); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.AuthorCacheEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AuthorAsin") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("AuthorName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("AuthorNameNormalized") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("CatalogBooks") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("ImageUrl") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("LastFetchedAt") + .HasColumnType("TEXT"); + + b.Property("Region") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("SimilarAuthors") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AuthorAsin", "Region"); + + b.HasIndex("AuthorNameNormalized", "Region") + .IsUnique(); + + b.ToTable("AuthorCacheEntries"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.MonitoredAuthor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AuthorAsin") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("AuthorName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("AuthorNameNormalized") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Language") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("LastCheckedAt") + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("LastSuccessfulSyncAt") + .HasColumnType("TEXT"); + + b.Property("Region") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LastCheckedAt"); + + b.HasIndex("AuthorNameNormalized", "Region", "Language") + .IsUnique(); + + b.ToTable("MonitoredAuthors"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.MonitoredSeries", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Language") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("LastCheckedAt") + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("LastSuccessfulSyncAt") + .HasColumnType("TEXT"); + + b.Property("Region") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("SeriesAsin") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("SeriesName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("SeriesNameNormalized") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LastCheckedAt"); + + b.HasIndex("SeriesNameNormalized", "Region", "Language") + .IsUnique(); + + b.ToTable("MonitoredSeries"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.MoveJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ActiveDeduplicationKey") + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("AttemptCount") + .HasColumnType("INTEGER"); + + b.Property("AudiobookId") + .HasColumnType("INTEGER"); + + b.Property("EnqueuedAt") + .HasColumnType("TEXT"); + + b.Property("Error") + .HasColumnType("TEXT"); + + b.Property("RequestedPath") + .HasColumnType("TEXT"); + + b.Property("SourcePath") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ActiveDeduplicationKey") + .IsUnique() + .HasFilter("\"ActiveDeduplicationKey\" IS NOT NULL"); + + b.HasIndex("AudiobookId", "Status"); + + b.ToTable("MoveJobs"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.QualityProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CustomGroupNames") + .HasColumnType("TEXT") + .HasColumnName("CustomGroupNames"); + + b.Property("CutoffQuality") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("MaximumAge") + .HasColumnType("INTEGER"); + + b.Property("MaximumSize") + .HasColumnType("INTEGER"); + + b.Property("MinimumScore") + .HasColumnType("INTEGER"); + + b.Property("MinimumSeeders") + .HasColumnType("INTEGER"); + + b.Property("MinimumSize") + .HasColumnType("INTEGER"); + + b.Property("MustContain") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("MustContain"); + + b.Property("MustNotContain") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("MustNotContain"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("PreferNewerReleases") + .HasColumnType("INTEGER"); + + b.Property("PreferredFormats") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("PreferredFormats"); + + b.Property("PreferredLanguages") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("PreferredLanguages"); + + b.PrimitiveCollection("PreferredWords") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Qualities") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("Qualities"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("QualityProfiles"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.RootFolder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("IsDefault") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.HasIndex("Path") + .IsUnique(); + + b.ToTable("RootFolders", (string)null); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.SeriesCacheEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CatalogBooks") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("ImageUrl") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("LastFetchedAt") + .HasColumnType("TEXT"); + + b.Property("Region") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("SeriesAsin") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("SeriesName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("SeriesNameNormalized") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesAsin", "Region"); + + b.HasIndex("SeriesNameNormalized", "Region") + .IsUnique(); + + b.ToTable("SeriesCacheEntries"); + }); + + modelBuilder.Entity("Listenarr.Domain.Configuration.ApiConfiguration", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("BaseUrl") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Headers") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("HeadersJson"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("LastUsed") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("ParametersJson"); + + b.Property("Priority") + .HasColumnType("INTEGER"); + + b.Property("RateLimitPerMinute") + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ApiConfigurations"); + }); + + modelBuilder.Entity("Listenarr.Domain.Configuration.ApplicationSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowedFileExtensions") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("AudiobookshelfApiKeyEncrypted") + .HasColumnType("TEXT"); + + b.Property("AudiobookshelfLibraryId") + .HasColumnType("TEXT"); + + b.Property("AudiobookshelfNotifyOnImport") + .HasColumnType("INTEGER"); + + b.Property("AudiobookshelfUrl") + .HasColumnType("TEXT"); + + b.Property("AudnexusApiUrl") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CompletedFileAction") + .HasColumnType("INTEGER"); + + b.Property("DefaultSearchLanguage") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DefaultSearchRegion") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DiscordApplicationId") + .HasColumnType("TEXT"); + + b.Property("DiscordBotAvatar") + .HasColumnType("TEXT"); + + b.Property("DiscordBotEnabled") + .HasColumnType("INTEGER"); + + b.Property("DiscordBotToken") + .HasColumnType("TEXT"); + + b.Property("DiscordBotUsername") + .HasColumnType("TEXT"); + + b.Property("DiscordChannelId") + .HasColumnType("TEXT"); + + b.Property("DiscordCommandGroupName") + .HasColumnType("TEXT"); + + b.Property("DiscordCommandSubcommandName") + .HasColumnType("TEXT"); + + b.Property("DiscordGuildId") + .HasColumnType("TEXT"); + + b.Property("DownloadCompletionStabilitySeconds") + .HasColumnType("INTEGER"); + + b.Property("EnableAmazonSearch") + .HasColumnType("INTEGER"); + + b.Property("EnableAudibleSearch") + .HasColumnType("INTEGER"); + + b.Property("EnableCoverArtDownload") + .HasColumnType("INTEGER"); + + b.Property("EnableMetadataProcessing") + .HasColumnType("INTEGER"); + + b.Property("EnableNotifications") + .HasColumnType("INTEGER"); + + b.Property("EnableOpenLibrarySearch") + .HasColumnType("INTEGER"); + + b.Property("EnabledNotificationTriggers") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ExtractArchives") + .HasColumnType("INTEGER"); + + b.Property("FailedDownloadAutoSearch") + .HasColumnType("INTEGER"); + + b.Property("FailedDownloadHandlingEnabled") + .HasColumnType("INTEGER"); + + b.Property("FileNamingPattern") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FolderNamingPattern") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("HistoryRetentionDays") + .HasColumnType("INTEGER"); + + b.Property("ImportBlacklistExtensions") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("MaxConcurrentDownloads") + .HasColumnType("INTEGER"); + + b.Property("MissingSourceMaxRetries") + .HasColumnType("INTEGER"); + + b.Property("MissingSourceRetryInitialDelaySeconds") + .HasColumnType("INTEGER"); + + b.Property("MultiFileNamingPattern") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OutputPath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PollingIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("ProwlarrApiKeyEncrypted") + .HasColumnType("TEXT"); + + b.Property("ProwlarrPort") + .HasColumnType("INTEGER"); + + b.Property("ProwlarrTagFilter") + .HasColumnType("TEXT"); + + b.Property("ProwlarrUrl") + .HasColumnType("TEXT"); + + b.Property("ShowCompletedExternalDownloads") + .HasColumnType("INTEGER"); + + b.Property("UnmatchedScanConcurrency") + .HasColumnType("INTEGER"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("WebhookUrl") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Webhooks") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ApplicationSettings"); + }); + + modelBuilder.Entity("Listenarr.Domain.Downloads.Download", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ActiveAudiobookDeduplicationKey") + .HasColumnType("INTEGER"); + + b.Property("Album") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Artist") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Asin") + .HasColumnType("TEXT"); + + b.Property("AudiobookId") + .HasColumnType("INTEGER"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("DownloadClientId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DownloadPath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DownloadedSize") + .HasColumnType("INTEGER"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("ExpectedFileSize") + .HasColumnType("INTEGER"); + + b.Property("FinalPath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("HistoryId") + .HasColumnType("INTEGER"); + + b.Property("ImportAttempts") + .HasColumnType("INTEGER"); + + b.PrimitiveCollection("ImportBlockMessages") + .HasColumnType("TEXT"); + + b.Property("ImportBlockReason") + .HasColumnType("TEXT"); + + b.Property("Isbn") + .HasColumnType("TEXT"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LastImportedAt") + .HasColumnType("TEXT"); + + b.Property("Metadata") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("Metadata"); + + b.Property("OriginalUrl") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Progress") + .HasColumnType("TEXT"); + + b.Property("Publisher") + .HasColumnType("TEXT"); + + b.Property("Runtime") + .HasColumnType("INTEGER"); + + b.Property("Series") + .HasColumnType("TEXT"); + + b.Property("SeriesNumber") + .HasColumnType("TEXT"); + + b.Property("StartedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TotalSize") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ActiveAudiobookDeduplicationKey") + .IsUnique() + .HasFilter("\"ActiveAudiobookDeduplicationKey\" IS NOT NULL"); + + b.HasIndex("CompletedAt"); + + b.HasIndex("DownloadClientId"); + + b.HasIndex("Status"); + + b.ToTable("Downloads"); + }); + + modelBuilder.Entity("Listenarr.Domain.Downloads.DownloadClientConfiguration", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DownloadPath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Host") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Password") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("RemoveCompletedDownloads") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Settings") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("SettingsJson"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UseSSL") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("DownloadClientConfigurations"); + }); + + modelBuilder.Entity("Listenarr.Domain.Downloads.DownloadProcessingJob", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ActiveDeduplicationKey") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DestinationPath") + .HasColumnType("TEXT"); + + b.Property("DownloadClientId") + .HasColumnType("TEXT"); + + b.Property("DownloadId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("JobData") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("JobData"); + + b.Property("JobType") + .HasColumnType("INTEGER"); + + b.Property("MaxRetries") + .HasColumnType("INTEGER"); + + b.Property("NextRetryAt") + .HasColumnType("TEXT"); + + b.Property("Priority") + .HasColumnType("INTEGER"); + + b.PrimitiveCollection("ProcessingLog") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RetryCount") + .HasColumnType("INTEGER"); + + b.Property("SourcePath") + .HasColumnType("TEXT"); + + b.Property("StartedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ActiveDeduplicationKey") + .IsUnique() + .HasFilter("\"ActiveDeduplicationKey\" IS NOT NULL"); + + b.HasIndex("Status"); + + b.HasIndex("DownloadId", "Status"); + + b.ToTable("DownloadProcessingJobs"); + }); + + modelBuilder.Entity("Listenarr.Domain.Downloads.RemotePathMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DownloadClientId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LocalPath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("RemotePath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("RemotePathMappings"); + }); + + modelBuilder.Entity("Listenarr.Domain.Identity.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasColumnType("TEXT"); + + b.Property("IsAdmin") + .HasColumnType("INTEGER"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Listenarr.Domain.Identity.UserSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("IsAdmin") + .HasColumnType("INTEGER"); + + b.Property("LastAccessed") + .HasColumnType("TEXT"); + + b.Property("RememberMe") + .HasColumnType("INTEGER"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("Username"); + + b.ToTable("UserSessions"); + }); + + modelBuilder.Entity("Listenarr.Domain.Search.Indexer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AdditionalSettings") + .HasColumnType("TEXT"); + + b.Property("AnimeCategories") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("Categories") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EnableAnimeStandardSearch") + .HasColumnType("INTEGER"); + + b.Property("EnableAutomaticSearch") + .HasColumnType("INTEGER"); + + b.Property("EnableInteractiveSearch") + .HasColumnType("INTEGER"); + + b.Property("EnableRss") + .HasColumnType("INTEGER"); + + b.Property("Implementation") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("LastTestError") + .HasColumnType("TEXT"); + + b.Property("LastTestSuccessful") + .HasColumnType("INTEGER"); + + b.Property("LastTestedAt") + .HasColumnType("TEXT"); + + b.Property("MaximumSize") + .HasColumnType("INTEGER"); + + b.Property("MinimumAge") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Priority") + .HasColumnType("INTEGER"); + + b.Property("Retention") + .HasColumnType("INTEGER"); + + b.Property("Tags") + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Url") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Indexers"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.Audiobook", b => + { + b.HasOne("Listenarr.Domain.Audiobooks.QualityProfile", "QualityProfile") + .WithMany() + .HasForeignKey("QualityProfileId"); + + b.Navigation("QualityProfile"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.AudiobookExternalIdentifier", b => + { + b.HasOne("Listenarr.Domain.Audiobooks.Audiobook", null) + .WithMany("ExternalIdentifiers") + .HasForeignKey("AudiobookId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.AudiobookFile", b => + { + b.HasOne("Listenarr.Domain.Audiobooks.Audiobook", "Audiobook") + .WithMany("Files") + .HasForeignKey("AudiobookId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Audiobook"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.AudiobookSeriesMembership", b => + { + b.HasOne("Listenarr.Domain.Audiobooks.Audiobook", "Audiobook") + .WithMany("SeriesMemberships") + .HasForeignKey("AudiobookId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Audiobook"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.Audiobook", b => + { + b.Navigation("ExternalIdentifiers"); + + b.Navigation("Files"); + + b.Navigation("SeriesMemberships"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/listenarr.infrastructure/Persistence/Migrations/20260809100000_AddAudiobookshelfSettingsToApplicationSettings.cs b/listenarr.infrastructure/Persistence/Migrations/20260809100000_AddAudiobookshelfSettingsToApplicationSettings.cs new file mode 100644 index 000000000..dc4d2fc91 --- /dev/null +++ b/listenarr.infrastructure/Persistence/Migrations/20260809100000_AddAudiobookshelfSettingsToApplicationSettings.cs @@ -0,0 +1,75 @@ +/* + * 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.Migrations; + +#nullable disable + +namespace Listenarr.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddAudiobookshelfSettingsToApplicationSettings : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "AudiobookshelfApiKeyEncrypted", + table: "ApplicationSettings", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "AudiobookshelfLibraryId", + table: "ApplicationSettings", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "AudiobookshelfNotifyOnImport", + table: "ApplicationSettings", + type: "INTEGER", + nullable: true); + + migrationBuilder.AddColumn( + name: "AudiobookshelfUrl", + table: "ApplicationSettings", + type: "TEXT", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "AudiobookshelfApiKeyEncrypted", + table: "ApplicationSettings"); + + migrationBuilder.DropColumn( + name: "AudiobookshelfLibraryId", + table: "ApplicationSettings"); + + migrationBuilder.DropColumn( + name: "AudiobookshelfNotifyOnImport", + table: "ApplicationSettings"); + + migrationBuilder.DropColumn( + name: "AudiobookshelfUrl", + table: "ApplicationSettings"); + } + } +} diff --git a/listenarr.infrastructure/Persistence/Migrations/ListenArrDbContextModelSnapshot.cs b/listenarr.infrastructure/Persistence/Migrations/ListenArrDbContextModelSnapshot.cs index d00df3bd5..d2d4e85ac 100644 --- a/listenarr.infrastructure/Persistence/Migrations/ListenArrDbContextModelSnapshot.cs +++ b/listenarr.infrastructure/Persistence/Migrations/ListenArrDbContextModelSnapshot.cs @@ -911,6 +911,18 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired() .HasColumnType("TEXT"); + b.Property("AudiobookshelfApiKeyEncrypted") + .HasColumnType("TEXT"); + + b.Property("AudiobookshelfLibraryId") + .HasColumnType("TEXT"); + + b.Property("AudiobookshelfNotifyOnImport") + .HasColumnType("INTEGER"); + + b.Property("AudiobookshelfUrl") + .HasColumnType("TEXT"); + b.Property("AudnexusApiUrl") .IsRequired() .HasColumnType("TEXT"); diff --git a/tests/Features/Application/Configuration/Core/ConfigurationServiceAudiobookshelfTests.cs b/tests/Features/Application/Configuration/Core/ConfigurationServiceAudiobookshelfTests.cs new file mode 100644 index 000000000..d6d62ff5a --- /dev/null +++ b/tests/Features/Application/Configuration/Core/ConfigurationServiceAudiobookshelfTests.cs @@ -0,0 +1,133 @@ +/* + * 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.Domain.Integrations; +using Listenarr.Tests.Common; + +namespace Listenarr.Tests.Features.Application.Configuration.Core +{ + [Trait("Name", "ConfigurationServiceAudiobookshelfTests")] + [Trait("Category", "ConfigurationService")] + public class ConfigurationServiceAudiobookshelfTests : BaseTests + { + [Fact] + public async Task SaveAudiobookshelfSettings_RoundTripsAndProtectsApiKey() + { + var svc = _provider.GetRequiredService(); + + var saved = await svc.SaveAudiobookshelfSettingsAsync(new AudiobookshelfConnectionSettings + { + Url = "http://abs.local:13378", + ApiKey = "secret-token", + LibraryId = "lib_123", + NotifyOnImport = true, + }); + + Assert.Equal("http://abs.local:13378", saved.Url); + Assert.Equal("lib_123", saved.LibraryId); + Assert.True(saved.NotifyOnImport); + Assert.True(saved.HasSavedApiKey); + Assert.Null(saved.ApiKey); + + // The token is stored encrypted, never verbatim + var raw = await _applicationSettingsRepository.GetAsync(); + Assert.NotNull(raw!.AudiobookshelfApiKeyEncrypted); + Assert.NotEqual("secret-token", raw.AudiobookshelfApiKeyEncrypted); + + var withSecret = await svc.GetAudiobookshelfSettingsAsync(includeSecret: true); + Assert.Equal("secret-token", withSecret.ApiKey); + } + + [Fact] + public async Task SaveAudiobookshelfSettings_BlankOrRedactedApiKey_KeepsSavedToken() + { + var svc = _provider.GetRequiredService(); + + await svc.SaveAudiobookshelfSettingsAsync(new AudiobookshelfConnectionSettings + { + Url = "http://abs.local:13378", + ApiKey = "secret-token", + }); + + await svc.SaveAudiobookshelfSettingsAsync(new AudiobookshelfConnectionSettings + { + Url = "http://abs.local:13378", + ApiKey = null, + NotifyOnImport = true, + }); + + await svc.SaveAudiobookshelfSettingsAsync(new AudiobookshelfConnectionSettings + { + Url = "http://abs.local:13378", + ApiKey = ApiResponseRedactor.RedactedValue, + NotifyOnImport = true, + }); + + var result = await svc.GetAudiobookshelfSettingsAsync(includeSecret: true); + Assert.True(result.HasSavedApiKey); + Assert.Equal("secret-token", result.ApiKey); + Assert.True(result.NotifyOnImport); + } + + [Fact] + public async Task SaveApplicationSettings_PartialPayload_PreservesAudiobookshelfSettings() + { + var svc = _provider.GetRequiredService(); + + await svc.SaveAudiobookshelfSettingsAsync(new AudiobookshelfConnectionSettings + { + Url = "http://abs.local:13378", + ApiKey = "secret-token", + LibraryId = "lib_123", + NotifyOnImport = true, + }); + + // Simulate a settings save from a UI payload that omits the Audiobookshelf fields + await svc.SaveApplicationSettingsAsync(new ApplicationSettings + { + Id = 1, + OutputPath = FileUtils.GetAbsolutePath("partial-update"), + }); + + var result = await svc.GetAudiobookshelfSettingsAsync(includeSecret: true); + Assert.Equal("http://abs.local:13378", result.Url); + Assert.Equal("lib_123", result.LibraryId); + Assert.True(result.NotifyOnImport); + Assert.Equal("secret-token", result.ApiKey); + } + + [Fact] + public async Task SaveAudiobookshelfSettings_ClearingUrl_DropsSavedToken() + { + var svc = _provider.GetRequiredService(); + + await svc.SaveAudiobookshelfSettingsAsync(new AudiobookshelfConnectionSettings + { + Url = "http://abs.local:13378", + ApiKey = "secret-token", + }); + + var cleared = await svc.SaveAudiobookshelfSettingsAsync(new AudiobookshelfConnectionSettings + { + Url = string.Empty, + }); + + Assert.Equal(string.Empty, cleared.Url); + Assert.False(cleared.HasSavedApiKey); + } + } +} diff --git a/tests/Features/Infrastructure/Integrations/Audiobookshelf/AudiobookshelfServiceTests.cs b/tests/Features/Infrastructure/Integrations/Audiobookshelf/AudiobookshelfServiceTests.cs new file mode 100644 index 000000000..5a1c84beb --- /dev/null +++ b/tests/Features/Infrastructure/Integrations/Audiobookshelf/AudiobookshelfServiceTests.cs @@ -0,0 +1,173 @@ +/* + * 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 System.Net; +using System.Text; +using Listenarr.Domain.Integrations; +using Listenarr.Infrastructure.Integrations.Audiobookshelf; +using Listenarr.Tests.Common; + +namespace Listenarr.Tests.Features.Infrastructure.Integrations.Audiobookshelf +{ + [Trait("Name", "AudiobookshelfServiceTests")] + [Trait("Category", "Audiobookshelf")] + public class AudiobookshelfServiceTests : BaseTests + { + private const string LibrariesPayload = """ + { + "libraries": [ + { "id": "lib_books", "name": "Audiobooks", "mediaType": "book" }, + { "id": "lib_books2", "name": "More Audiobooks", "mediaType": "book" }, + { "id": "lib_pods", "name": "Podcasts", "mediaType": "podcast" } + ] + } + """; + + private AudiobookshelfService CreateService(Func> handler) + { + var httpClient = new HttpClient(new DelegatingHandlerMock(handler)); + return new AudiobookshelfService( + httpClient, + _provider.GetRequiredService(), + _provider.GetRequiredService>()); + } + + private async Task SaveConnectionAsync(string? libraryId = null) + { + var svc = _provider.GetRequiredService(); + await svc.SaveAudiobookshelfSettingsAsync(new AudiobookshelfConnectionSettings + { + Url = "http://localhost:13378", + ApiKey = "abs-token", + LibraryId = libraryId, + }); + } + + private static HttpResponseMessage Json(string payload) => new(HttpStatusCode.OK) + { + Content = new StringContent(payload, Encoding.UTF8, "application/json"), + }; + + [Fact] + public async Task TestConnection_ParsesLibraries_AndSendsBearerToken() + { + await SaveConnectionAsync(); + var requests = new List(); + var service = CreateService((request, _) => + { + requests.Add(request); + return Task.FromResult(Json(LibrariesPayload)); + }); + + var result = await service.TestConnectionAsync(null, null); + + Assert.True(result.Success); + Assert.NotNull(result.Libraries); + Assert.Equal(3, result.Libraries!.Count); + Assert.Equal("Audiobooks", result.Libraries[0].Name); + + var request = Assert.Single(requests); + Assert.Equal("/api/libraries", request.RequestUri!.AbsolutePath); + Assert.Equal("Bearer", request.Headers.Authorization?.Scheme); + Assert.Equal("abs-token", request.Headers.Authorization?.Parameter); + } + + [Fact] + public async Task TestConnection_Unauthorized_ReportsRejectedToken() + { + await SaveConnectionAsync(); + var service = CreateService((_, _) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.Unauthorized))); + + var result = await service.TestConnectionAsync(null, null); + + Assert.False(result.Success); + Assert.Contains("rejected", result.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task TestConnection_WithoutConfiguration_Fails() + { + var service = CreateService((_, _) => Task.FromResult(Json(LibrariesPayload))); + + var result = await service.TestConnectionAsync(null, null); + + Assert.False(result.Success); + } + + [Fact] + public async Task TriggerScan_ScansOnlyConfiguredLibrary() + { + await SaveConnectionAsync(libraryId: "lib_books2"); + var scanRequests = new List(); + var service = CreateService((request, _) => + { + if (request.Method == HttpMethod.Post) + { + scanRequests.Add(request.RequestUri!.AbsolutePath); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)); + } + + return Task.FromResult(Json(LibrariesPayload)); + }); + + var result = await service.TriggerScanAsync(); + + Assert.True(result.Success); + Assert.Equal(["/api/libraries/lib_books2/scan"], scanRequests); + Assert.Contains("More Audiobooks", result.Message); + } + + [Fact] + public async Task TriggerScan_WithoutConfiguredLibrary_ScansAllBookLibraries() + { + await SaveConnectionAsync(); + var scanRequests = new List(); + var service = CreateService((request, _) => + { + if (request.Method == HttpMethod.Post) + { + scanRequests.Add(request.RequestUri!.AbsolutePath); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)); + } + + return Task.FromResult(Json(LibrariesPayload)); + }); + + var result = await service.TriggerScanAsync(); + + Assert.True(result.Success); + Assert.Equal(["/api/libraries/lib_books/scan", "/api/libraries/lib_books2/scan"], scanRequests); + // Podcast libraries are not scanned + Assert.DoesNotContain("/api/libraries/lib_pods/scan", scanRequests); + } + + [Fact] + public async Task TriggerScan_AllScanRequestsFail_ReportsFailure() + { + await SaveConnectionAsync(libraryId: "lib_books"); + var service = CreateService((request, _) => + Task.FromResult(request.Method == HttpMethod.Post + ? new HttpResponseMessage(HttpStatusCode.InternalServerError) + : Json(LibrariesPayload))); + + var result = await service.TriggerScanAsync(); + + Assert.False(result.Success); + } + } +}