From b01b621f827079553509567310fcb03cbb5bdf52 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Sun, 8 Mar 2026 14:55:05 -0400 Subject: [PATCH 1/9] Compute wanted from DB records; simplify GetAll Stop probing the filesystem when computing an audiobook's wanted flag and treat DB file records as the source of truth. Make ComputeWantedFlag static and return wanted based on presence of file records (no File.Exists checks or per-file logging). Simplify the GetAll endpoint to AsNoTracking, include Files, and order results by Title (removed QualityProfile include + retry fallback). Update frontend comment to trust server semantics for wanted status and add a unit test to prevent regression where a DB file record would be treated as desired only if the file exists on disk. --- fe/src/stores/library.ts | 2 +- .../Controllers/LibraryController.cs | 61 +++-------------- ...aryController_WantedFlagRegressionTests.cs | 67 +++++++++++++++++++ 3 files changed, 77 insertions(+), 53 deletions(-) create mode 100644 tests/Listenarr.Api.Tests/LibraryController_WantedFlagRegressionTests.cs diff --git a/fe/src/stores/library.ts b/fe/src/stores/library.ts index d291bc6..c982a56 100644 --- a/fe/src/stores/library.ts +++ b/fe/src/stores/library.ts @@ -36,7 +36,7 @@ export const useLibraryStore = defineStore('library', () => { error.value = null try { const serverList = await apiService.getLibrary() - // Always trust server data - it includes accurate wanted flags based on File.Exists() checks + // Always trust server data for wanted status so the store stays aligned with API semantics. audiobooks.value = serverList.map(normalizeLibraryImageUrl) } catch (err) { error.value = err instanceof Error ? err.message : 'Failed to fetch library' diff --git a/listenarr.api/Controllers/LibraryController.cs b/listenarr.api/Controllers/LibraryController.cs index 1497a4e..7f0a029 100644 --- a/listenarr.api/Controllers/LibraryController.cs +++ b/listenarr.api/Controllers/LibraryController.cs @@ -99,46 +99,17 @@ public LibraryController( _rootFolderService = rootFolderService; } - private bool ComputeWantedFlag(Audiobook audiobook) + private static bool ComputeWantedFlag(Audiobook audiobook) { if (!audiobook.Monitored) { return false; } + // The library list endpoint should not hit the filesystem for every book. + // Treat existing DB file records as the source of truth for wanted status. var files = audiobook.Files; - if (files == null || files.Count == 0) - { - return true; - } - - foreach (var file in files) - { - if (string.IsNullOrWhiteSpace(file.Path)) - { - continue; - } - - try - { - var fullPath = ResolvePathWithOptionalBase(audiobook.BasePath, file.Path); - - if (System.IO.File.Exists(fullPath)) - { - return false; - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning( - ex, - "Failed to evaluate file path while computing wanted flag for audiobook {AudiobookId}, file {FileId}. Treating file as missing.", - audiobook.Id, - file.Id); - } - } - - return true; + return files == null || files.Count == 0; } private static string ResolvePathWithOptionalBase(string? basePath, string candidatePath) @@ -608,25 +579,11 @@ public async Task PreviewPath([FromBody] PreviewPathRequest reque public async Task GetAll() { // Return audiobooks including files and an explicit 'wanted' flag - List audiobooks; - try - { - audiobooks = await _dbContext.Audiobooks - .Include(a => a.QualityProfile) - .Include(a => a.Files) - .ToListAsync(); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) { - // Defensive fallback: if any JSON-backed column or related navigation - // causes materialization errors (EF's JSON reader or coercion), log and - // retry without the QualityProfile include so the library view can still - // render basic audiobook and file information. - _logger.LogWarning(ex, "Error retrieving audiobooks with QualityProfile; retrying without QualityProfile include to avoid malformed JSON or mapping issues in DB columns."); - - audiobooks = await _dbContext.Audiobooks - .Include(a => a.Files) - .ToListAsync(); - } + var audiobooks = await _dbContext.Audiobooks + .AsNoTracking() + .Include(a => a.Files) + .OrderBy(a => a.Title) + .ToListAsync(); var dto = audiobooks.Select(a => new { diff --git a/tests/Listenarr.Api.Tests/LibraryController_WantedFlagRegressionTests.cs b/tests/Listenarr.Api.Tests/LibraryController_WantedFlagRegressionTests.cs new file mode 100644 index 0000000..bdf715a --- /dev/null +++ b/tests/Listenarr.Api.Tests/LibraryController_WantedFlagRegressionTests.cs @@ -0,0 +1,67 @@ +using System; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Listenarr.Api.Controllers; +using Listenarr.Api.Services; +using Listenarr.Domain.Models; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Listenarr.Api.Tests +{ + public class LibraryController_WantedFlagRegressionTests + { + [Fact] + public async Task GetAll_TreatsDbFileRecordAsNotWanted_EvenIfPathIsMissing() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + + using var db = new ListenArrDbContext(options); + + var book = new Audiobook + { + Title = "Controller Book", + Monitored = true + }; + db.Audiobooks.Add(book); + await db.SaveChangesAsync(); + + db.AudiobookFiles.Add(new AudiobookFile + { + AudiobookId = book.Id, + Path = $@"Z:\definitely-missing\{Guid.NewGuid():N}.m4b", + Size = 1024, + CreatedAt = DateTime.UtcNow + }); + await db.SaveChangesAsync(); + + using var provider = new ServiceCollection().BuildServiceProvider(); + var controller = new LibraryController( + Mock.Of(), + Mock.Of(), + NullLogger.Instance, + db, + provider.GetRequiredService(), + Mock.Of()); + + var actionResult = await controller.GetAll(); + var ok = Assert.IsType(actionResult); + + using var doc = JsonDocument.Parse(JsonSerializer.Serialize(ok.Value)); + var wanted = doc.RootElement + .EnumerateArray() + .Single(item => item.GetProperty("id").GetInt32() == book.Id) + .GetProperty("wanted") + .GetBoolean(); + + Assert.False(wanted); + } + } +} From f86e2022c8edb80172d1427a440a1d90f25e79d5 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Sun, 8 Mar 2026 15:34:00 -0400 Subject: [PATCH 2/9] Deduplicate library fetch and use store in App.vue Introduce request deduplication for library fetches by adding an inFlightFetch promise to the library Pinia store so concurrent fetchLibrary() calls share a single API request. Update App.vue to use the library store (fetchLibrary and audiobooks) instead of calling the API directly to avoid duplicate calls and keep wanted badge/search logic aligned with store state. Update tests to initialize Pinia when mounting App.vue and add a new library-fetch.spec.ts that verifies concurrent fetch deduplication. Bump project versions to 0.2.56. --- fe/package-lock.json | 4 +- fe/package.json | 2 +- fe/src/App.vue | 14 +++--- fe/src/__tests__/AppActivityBadge.spec.ts | 8 ++-- fe/src/__tests__/library-fetch.spec.ts | 54 +++++++++++++++++++++++ fe/src/stores/library.ts | 36 +++++++++------ package-lock.json | 4 +- package.json | 2 +- 8 files changed, 96 insertions(+), 28 deletions(-) create mode 100644 fe/src/__tests__/library-fetch.spec.ts diff --git a/fe/package-lock.json b/fe/package-lock.json index 2c4bbfa..209b0d1 100644 --- a/fe/package-lock.json +++ b/fe/package-lock.json @@ -1,12 +1,12 @@ { "name": "listenarr-fe", - "version": "0.2.55", + "version": "0.2.56", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "listenarr-fe", - "version": "0.2.55", + "version": "0.2.56", "hasInstallScript": true, "dependencies": { "@material/material-color-utilities": "^0.4.0", diff --git a/fe/package.json b/fe/package.json index e1171f0..74d478c 100644 --- a/fe/package.json +++ b/fe/package.json @@ -1,6 +1,6 @@ { "name": "listenarr-fe", - "version": "0.2.55", + "version": "0.2.56", "private": true, "type": "module", "engines": { diff --git a/fe/src/App.vue b/fe/src/App.vue index a49ef69..75af1df 100644 --- a/fe/src/App.vue +++ b/fe/src/App.vue @@ -496,6 +496,7 @@ import ConfirmDialog from '@/components/feedback/ConfirmDialog.vue' import { useConfirmService } from '@/composables/confirmService' import { useNotification } from '@/composables/useNotification' import { useDownloadsStore } from '@/stores/downloads' +import { useLibraryStore } from '@/stores/library' import { useAuthStore } from '@/stores/auth' import { apiService } from '@/services/api' import { getStartupConfigCached } from '@/services/startupConfigCache' @@ -522,6 +523,7 @@ const STARTUP_CONFIG_UPDATED_EVENT = 'listenarr-startup-config-updated' const { notification, close: closeNotification } = useNotification() const { getProtectedImageSrc } = useProtectedImages() const downloadsStore = useDownloadsStore() +const libraryStore = useLibraryStore() const auth = useAuthStore() const authEnabled = ref(false) const startupConfigLoaded = ref(false) @@ -872,10 +874,8 @@ function stopWantedBadgePolling() { // Fetch wanted badge count (library changes less frequently - minimal polling) const refreshWantedBadge = async () => { try { - // Wanted badge: rely exclusively on the server-provided `wanted` flag. - // Treat only audiobooks where server returns wanted === true as wanted. - const library = await apiService.getLibrary() - wantedCount.value = library.filter((book) => { + await libraryStore.fetchLibrary() + wantedCount.value = libraryStore.audiobooks.filter((book) => { const serverWanted = (book as unknown as Record)['wanted'] return serverWanted === true }).length @@ -936,8 +936,10 @@ const onSearchInput = async () => { searchDebounceTimer = window.setTimeout(async () => { searching.value = true try { - // First try to match local library entries - const lib = await apiService.getLibrary() + if (libraryStore.audiobooks.length === 0) { + await libraryStore.fetchLibrary() + } + const lib = libraryStore.audiobooks const lower = q.toLowerCase() const localMatches = lib.filter( (b) => diff --git a/fe/src/__tests__/AppActivityBadge.spec.ts b/fe/src/__tests__/AppActivityBadge.spec.ts index 346f6e4..930472e 100644 --- a/fe/src/__tests__/AppActivityBadge.spec.ts +++ b/fe/src/__tests__/AppActivityBadge.spec.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { mount } from '@vue/test-utils' import { computed, ref } from 'vue' +import { createPinia, setActivePinia } from 'pinia' // Mock the downloads store so App.vue picks up the activeDownloads correctly vi.mock('@/stores/downloads', () => ({ @@ -50,6 +51,7 @@ describe('App.vue activity badge', () => { beforeEach(() => { // reset mocks between tests vi.resetModules() + setActivePinia(createPinia()) }) // Ensure localStorage APIs exist in the test environment for App.vue session debug helpers @@ -111,7 +113,7 @@ describe('App.vue activity badge', () => { await router.isReady().catch(() => {}) const wrapper = mount(AppComponent, { - global: { stubs: ['RouterLink', 'RouterView'], plugins: [router] }, + global: { stubs: ['RouterLink', 'RouterView'], plugins: [createPinia(), router] }, }) // Wait a tick for computed properties in mounted hook @@ -160,7 +162,7 @@ describe('App.vue activity badge', () => { await router.isReady().catch(() => {}) const wrapper = mount(AppComponent, { - global: { stubs: ['RouterLink', 'RouterView'], plugins: [router] }, + global: { stubs: ['RouterLink', 'RouterView'], plugins: [createPinia(), router] }, }) // Allow async onMounted tasks to settle @@ -223,7 +225,7 @@ describe('App.vue activity badge', () => { await router.isReady().catch(() => {}) const wrapper = mount(AppComponent, { - global: { stubs: ['RouterLink', 'RouterView'], plugins: [router] }, + global: { stubs: ['RouterLink', 'RouterView'], plugins: [createPinia(), router] }, }) // Allow async onMounted tasks (SignalR/connect, api fetches) to settle diff --git a/fe/src/__tests__/library-fetch.spec.ts b/fe/src/__tests__/library-fetch.spec.ts new file mode 100644 index 0000000..8f3fb3c --- /dev/null +++ b/fe/src/__tests__/library-fetch.spec.ts @@ -0,0 +1,54 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' + +const { getLibraryMock } = vi.hoisted(() => ({ + getLibraryMock: vi.fn(), +})) + +vi.mock('@/services/api', () => ({ + apiService: { + getLibrary: getLibraryMock, + }, +})) + +vi.mock('@/services/signalr', () => ({ + signalRService: { + onFilesRemoved: vi.fn(() => undefined), + onAudiobookUpdate: vi.fn(() => undefined), + }, +})) + +import { useLibraryStore } from '@/stores/library' + +describe('library store fetchLibrary', () => { + beforeEach(() => { + setActivePinia(createPinia()) + getLibraryMock.mockReset() + }) + + it('dedupes concurrent fetches into one API request', async () => { + let resolveLibrary: ((value: Array<{ id: number; title: string }>) => void) | null = null + getLibraryMock.mockImplementation( + () => + new Promise>((resolve) => { + resolveLibrary = resolve + }), + ) + + const store = useLibraryStore() + + const first = store.fetchLibrary() + const second = store.fetchLibrary() + + expect(getLibraryMock).toHaveBeenCalledTimes(1) + expect(store.loading).toBe(true) + + resolveLibrary?.([{ id: 1, title: 'Book 1' }]) + await Promise.all([first, second]) + + expect(getLibraryMock).toHaveBeenCalledTimes(1) + expect(store.loading).toBe(false) + expect(store.audiobooks).toHaveLength(1) + expect(store.audiobooks[0]?.title).toBe('Book 1') + }) +}) diff --git a/fe/src/stores/library.ts b/fe/src/stores/library.ts index c982a56..65babfc 100644 --- a/fe/src/stores/library.ts +++ b/fe/src/stores/library.ts @@ -11,6 +11,7 @@ export const useLibraryStore = defineStore('library', () => { const loading = ref(false) const error = ref(null) const selectedIds = ref>(new Set()) + let inFlightFetch: Promise | null = null function normalizeLibraryImageUrl(book: Audiobook): Audiobook { const current = (book.imageUrl || '').trim() @@ -32,21 +33,30 @@ export const useLibraryStore = defineStore('library', () => { } async function fetchLibrary() { + if (inFlightFetch) { + return inFlightFetch + } + loading.value = true error.value = null - try { - const serverList = await apiService.getLibrary() - // Always trust server data for wanted status so the store stays aligned with API semantics. - audiobooks.value = serverList.map(normalizeLibraryImageUrl) - } catch (err) { - error.value = err instanceof Error ? err.message : 'Failed to fetch library' - errorTracking.captureException(err as Error, { - component: 'LibraryStore', - operation: 'fetchLibrary', - }) - } finally { - loading.value = false - } + inFlightFetch = (async () => { + try { + const serverList = await apiService.getLibrary() + // Always trust server data for wanted status so the store stays aligned with API semantics. + audiobooks.value = serverList.map(normalizeLibraryImageUrl) + } catch (err) { + error.value = err instanceof Error ? err.message : 'Failed to fetch library' + errorTracking.captureException(err as Error, { + component: 'LibraryStore', + operation: 'fetchLibrary', + }) + } finally { + loading.value = false + inFlightFetch = null + } + })() + + return inFlightFetch } async function removeFromLibrary(id: number) { diff --git a/package-lock.json b/package-lock.json index 033776c..daaa248 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "listenarr", - "version": "0.2.55", + "version": "0.2.56", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "listenarr", - "version": "0.2.55", + "version": "0.2.56", "dependencies": { "concurrently": "^9.2.1" } diff --git a/package.json b/package.json index 6dc8b49..6a2636f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "listenarr", - "version": "0.2.55", + "version": "0.2.56", "private": true, "description": "Listenarr - Automated audiobook downloading and management", "scripts": { From 677cd72c986167b16455756e680031738527cdfb Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Sun, 8 Mar 2026 18:43:47 -0400 Subject: [PATCH 3/9] Add audiobook status utils & download URI builder Frontend: add computeAudiobookStatus util, AudiobookStatus type and format helper; integrate into AudiobooksView and CollectionView; derive wantedCount from hydrated library store (remove polling) and sync via SignalR onConnected; set wanted/status when files change in library store; normalize download client host input in DownloadClientFormModal and update tests; add unit tests for audiobook status and activity badge. Backend: add LibraryAudiobookListItemDto and change LibraryController.GetAll to return a slim payload with Wanted and Status computed (uses new AudiobookStatusEvaluator); add DownloadClientUriBuilder to centralize URI/authority construction and update Nzbget/Qbittorrent/Sabnzbd/Transmission adapters to use it; various logging and request improvements. Tests updated/added for adapters and controller behaviors. --- CHANGELOG.md | 19 ++ fe/src/App.vue | 96 ++-------- fe/src/__tests__/AppActivityBadge.spec.ts | 41 +++- .../__tests__/DownloadClientFormModal.spec.ts | 2 +- fe/src/__tests__/audiobookStatus.spec.ts | 69 +++++++ .../download/DownloadClientFormModal.vue | 17 +- fe/src/stores/library.ts | 10 +- fe/src/types/index.ts | 4 + fe/src/utils/audiobookStatus.ts | 145 ++++++++++++++ fe/src/views/library/AudiobooksView.vue | 150 ++------------- fe/src/views/library/CollectionView.vue | 150 ++------------- .../Controllers/LibraryController.cs | 179 ++++++++++++------ .../Models/LibraryAudiobookListItemDto.cs | 28 +++ .../Adapters/DownloadClientUriBuilder.cs | 77 ++++++++ .../Services/Adapters/NzbgetAdapter.cs | 22 +-- .../Services/Adapters/QbittorrentAdapter.cs | 16 +- .../Services/Adapters/SabnzbdAdapter.cs | 16 +- .../Services/Adapters/TransmissionAdapter.cs | 3 +- .../Services/AudiobookStatusEvaluator.cs | 174 +++++++++++++++++ .../Services/DownloadMonitorService.cs | 9 +- listenarr.api/Services/DownloadService.cs | 3 +- ...yController_LibraryListSlimPayloadTests.cs | 98 ++++++++++ ...aryController_WantedFlagRegressionTests.cs | 3 +- .../Listenarr.Api.Tests/NzbgetAdapterTests.cs | 109 +++++++++++ .../QbittorrentAdapterTests.cs | 36 ++++ .../SabnzbdAdapterTests.cs | 73 +++++++ .../TransmissionAdapterTests.cs | 45 +++++ 27 files changed, 1145 insertions(+), 449 deletions(-) create mode 100644 fe/src/__tests__/audiobookStatus.spec.ts create mode 100644 fe/src/utils/audiobookStatus.ts create mode 100644 listenarr.api/Models/LibraryAudiobookListItemDto.cs create mode 100644 listenarr.api/Services/Adapters/DownloadClientUriBuilder.cs create mode 100644 listenarr.api/Services/AudiobookStatusEvaluator.cs create mode 100644 tests/Listenarr.Api.Tests/LibraryController_LibraryListSlimPayloadTests.cs create mode 100644 tests/Listenarr.Api.Tests/NzbgetAdapterTests.cs create mode 100644 tests/Listenarr.Api.Tests/SabnzbdAdapterTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 8526ee7..c2e82fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,25 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.2.57] - 2026-03-08 + +### Added +- **Library payload and status regression coverage:** Added focused backend and frontend regression tests covering slim `/library` payload behavior, wanted-flag correctness, shared audiobook status calculation, and download-client host normalization across NZBGet, qBittorrent, SABnzbd, and Transmission. + +### Changed +- **Slimmed `/library` list contract:** Converted `GET /library` from a hybrid list/detail response into a lighter list payload, while keeping `GET /library/{id}` as the rich single-audiobook detail endpoint. +- **Server-side library status evaluation:** Moved list status calculation into shared backend/frontend helpers so library and collection views no longer depend on full file metadata from the list response to derive status. +- **Event-driven library badge updates:** Removed periodic full-library polling for the Wanted badge in favor of store-driven updates backed by existing SignalR events, keeping a reconnect refresh path instead of a timer. +- **Deduplicated library fetches and client-side lookups:** Updated the frontend library store and app shell to collapse concurrent `/library` requests into a single in-flight fetch and reuse cached library state for header search and related UI lookups. +- **Download client URI handling normalization:** Standardized host/scheme/port/path handling across all download clients through a shared URI builder so adapters and monitor paths all interpret download-client connection settings consistently. + +### Fixed +- **Slow `/library` responses on large or remote libraries:** Removed per-audiobook filesystem existence checks from the library list path and replaced them with DB-backed wanted-state evaluation, eliminating expensive synchronous disk/network probes during list loads. +- **Duplicate `/library` requests during app startup:** Fixed overlapping library fetches from the app shell and library views so initial navigation no longer issues redundant full-library requests. +- **Library polling churn:** Stopped the app from re-fetching the full library every 60 seconds just to refresh the Wanted badge. +- **NZBGet host parsing failures (`http:80` / name resolution errors):** Fixed malformed URL construction when users paste a scheme or path into download-client host fields, and applied the same normalization to qBittorrent, SABnzbd, and Transmission to prevent the same bug class across clients. +- **Extra database work in audiobook detail loading:** Collapsed the audiobook detail route from a two-query existence-check/fetch pattern into a single no-tracking detail query. + ## [0.2.56] - 2026-03-05 ### Added diff --git a/fe/src/App.vue b/fe/src/App.vue index 75af1df..84aa19c 100644 --- a/fe/src/App.vue +++ b/fe/src/App.vue @@ -699,7 +699,12 @@ const closeMobileMenu = () => { // Reactive state for badges and counters const notificationCount = computed(() => recentNotifications.filter((n) => !n.dismissed).length) const queueItems = ref([]) -const wantedCount = ref(0) +const wantedCount = computed(() => + libraryStore.audiobooks.filter((book) => { + const serverWanted = (book as unknown as Record)['wanted'] + return serverWanted === true + }).length, +) const systemIssues = ref(0) // Activity count: Optimized with memoized intermediate computations @@ -830,57 +835,15 @@ function notificationIconComponent(icon?: string) { } } -let wantedBadgeRefreshInterval: number | undefined let unsubscribeQueue: (() => void) | null = null let unsubscribeFilesRemoved: (() => void) | null = null -let wantedBadgeVisibilityHandler: (() => void) | null = null - -function startWantedBadgePolling() { - if (wantedBadgeRefreshInterval) return - // Refresh immediately then start interval (only when page is visible) - if (!document.hidden) { - refreshWantedBadge() - wantedBadgeRefreshInterval = window.setInterval(refreshWantedBadge, 60000) - } - - if (!wantedBadgeVisibilityHandler) { - wantedBadgeVisibilityHandler = () => { - if (document.hidden) { - if (wantedBadgeRefreshInterval) { - clearInterval(wantedBadgeRefreshInterval) - wantedBadgeRefreshInterval = undefined - } - } else { - if (!wantedBadgeRefreshInterval) { - refreshWantedBadge() - wantedBadgeRefreshInterval = window.setInterval(refreshWantedBadge, 60000) - } - } - } - // Use VueUse for automatic cleanup - useEventListener(document, 'visibilitychange', wantedBadgeVisibilityHandler) - } -} - -function stopWantedBadgePolling() { - if (wantedBadgeRefreshInterval) { - clearInterval(wantedBadgeRefreshInterval) - wantedBadgeRefreshInterval = undefined - } - // Event listener is automatically cleaned up by VueUse - wantedBadgeVisibilityHandler = null -} +let unsubscribeSignalRConnected: (() => void) | null = null -// Fetch wanted badge count (library changes less frequently - minimal polling) -const refreshWantedBadge = async () => { +const syncLibrarySnapshot = async () => { try { await libraryStore.fetchLibrary() - wantedCount.value = libraryStore.audiobooks.filter((book) => { - const serverWanted = (book as unknown as Record)['wanted'] - return serverWanted === true - }).length } catch (err) { - logger.error('Failed to refresh wanted badge:', err) + logger.error('Failed to sync library snapshot:', err) } } @@ -1101,8 +1064,14 @@ onMounted(async () => { // If authenticated, load protected resources and enable real-time updates if (auth.user.authenticated) { - // Load initial downloads - await downloadsStore.loadDownloads() + // Hydrate the app once, then keep it current from SignalR updates. + await Promise.all([downloadsStore.loadDownloads(), syncLibrarySnapshot()]) + + unsubscribeSignalRConnected = signalRService.onConnected(() => { + if (auth.user.authenticated && libraryStore.audiobooks.length > 0) { + void syncLibrarySnapshot() + } + }) // Subscribe to queue updates via SignalR (real-time, no polling!) unsubscribeQueue = signalRService.onQueueUpdate((queue) => { @@ -1120,8 +1089,6 @@ onMounted(async () => { const display = removed.length > 0 ? removed.join(', ') : 'Files were removed from a library item.' toast.info('Files removed', display, 6000) - // Refresh wanted badge in case monitored items lost files - refreshWantedBadge() // Push into recent notifications pushNotification({ id: `files-removed-${Date.now()}`, @@ -1160,25 +1127,6 @@ onMounted(async () => { } }) - // Subscribe to audiobook updates (for wanted badge refresh only, no notifications) - signalRService.onAudiobookUpdate((ab) => { - try { - if (!ab) return - - // If server provided a wanted flag, refresh the wanted badge using the authoritative value - try { - const serverWanted = (ab as unknown as Record)['wanted'] - if (typeof serverWanted === 'boolean') { - // Recompute wantedCount by fetching library DTOs and trusting server 'wanted' - // This is a targeted refresh to avoid stale counts; call refreshWantedBadge() - refreshWantedBadge() - } - } catch {} - } catch (err) { - logger.error('AudiobookUpdate error', err) - } - }) - // Subscribe to download updates for notification purposes. // Only create notifications for meaningful lifecycle events: start (Queued) // and completion (Completed). Do not create notifications for continuous @@ -1216,9 +1164,6 @@ onMounted(async () => { recentDownloadTitles.value.delete(title) }, 30000) } - } else if (status === 'moved') { - // Download was successfully imported - refresh wanted badge to reflect the change - refreshWantedBadge() } else { // Ignore progress/other transient updates } @@ -1252,9 +1197,6 @@ onMounted(async () => { logger.debug('Fallback queue fetch failed (non-fatal)', err) } - // Only poll "Wanted" badge (library changes infrequently) - startWantedBadgePolling() - logger.info('✅ Real-time updates enabled - Activity badge updates automatically via SignalR!') await refreshAuthPresentationFromStartupConfig(true) @@ -1299,7 +1241,9 @@ onUnmounted(() => { if (unsubscribeFilesRemoved) { unsubscribeFilesRemoved() } - stopWantedBadgePolling() + if (unsubscribeSignalRConnected) { + unsubscribeSignalRConnected() + } // Event listeners are automatically cleaned up by VueUse }) diff --git a/fe/src/__tests__/AppActivityBadge.spec.ts b/fe/src/__tests__/AppActivityBadge.spec.ts index 930472e..097a10c 100644 --- a/fe/src/__tests__/AppActivityBadge.spec.ts +++ b/fe/src/__tests__/AppActivityBadge.spec.ts @@ -25,10 +25,10 @@ vi.mock('@/stores/auth', () => ({ vi.mock('@/services/signalr', () => ({ signalRService: { connect: vi.fn(async () => undefined), + onConnected: vi.fn(() => () => undefined), onQueueUpdate: vi.fn(() => () => undefined), onFilesRemoved: vi.fn(() => () => undefined), onToast: vi.fn(() => () => undefined), - onAudiobookUpdate: vi.fn(() => () => undefined), onDownloadUpdate: vi.fn(() => () => undefined), onDownloadsList: vi.fn(() => () => undefined), onNotification: vi.fn(() => () => undefined), @@ -192,6 +192,7 @@ describe('App.vue activity badge', () => { vi.doMock('@/services/signalr', () => ({ signalRService: { connect: vi.fn(async () => undefined), + onConnected: vi.fn(() => () => undefined), onQueueUpdate: (cb: (items: unknown[]) => void) => { cb([ { id: 'q1', status: 'queued' }, @@ -201,7 +202,6 @@ describe('App.vue activity badge', () => { }, onFilesRemoved: vi.fn(() => () => undefined), onToast: vi.fn(() => () => undefined), - onAudiobookUpdate: vi.fn(() => () => undefined), onDownloadUpdate: vi.fn(() => () => undefined), onDownloadsList: vi.fn(() => () => undefined), onNotification: vi.fn(() => () => undefined), @@ -235,4 +235,41 @@ describe('App.vue activity badge', () => { // With zero active downloads and two queue items, activityCount should reflect the queue expect(vm.activityCount).toBe(2) }, 20000) + + it('derives wantedCount from the hydrated library store without polling timers', async () => { + const setIntervalSpy = vi.spyOn(window, 'setInterval') + + vi.doMock('@/services/api', () => ({ + apiService: { + getQueue: async () => [], + getServiceHealth: async () => ({ version: '0.0.0' }), + getStartupConfig: async () => ({ authenticationRequired: false }), + getLibrary: async () => [ + { id: 1, title: 'Wanted Book', wanted: true }, + { id: 2, title: 'Present Book', wanted: false }, + ], + }, + })) + + const { default: AppComponent } = await import('@/App.vue') + + const router = createRouter({ + history: createMemoryHistory(), + routes: [{ path: '/', name: 'home', component: { template: '
' } }], + }) + await router.push('/') + await router.isReady().catch(() => {}) + + const wrapper = mount(AppComponent, { + global: { stubs: ['RouterLink', 'RouterView'], plugins: [createPinia(), router] }, + }) + + await new Promise((r) => setTimeout(r, 20)) + + const vm = wrapper.vm as unknown as { wantedCount: number } + expect(vm.wantedCount).toBe(1) + expect(setIntervalSpy).not.toHaveBeenCalled() + + setIntervalSpy.mockRestore() + }) }) diff --git a/fe/src/__tests__/DownloadClientFormModal.spec.ts b/fe/src/__tests__/DownloadClientFormModal.spec.ts index 878198c..dbb6a85 100644 --- a/fe/src/__tests__/DownloadClientFormModal.spec.ts +++ b/fe/src/__tests__/DownloadClientFormModal.spec.ts @@ -88,7 +88,7 @@ describe('DownloadClientFormModal', () => { // change host input to a new value before testing const hostInput = wrapper.find('input[id="host"]') - await hostInput.setValue('edited.local') + await hostInput.setValue('http://edited.local/nzbget') // click the Test button (use class selector to reliably find the correct button) const testButton = wrapper.find('button.btn-info') diff --git a/fe/src/__tests__/audiobookStatus.spec.ts b/fe/src/__tests__/audiobookStatus.spec.ts new file mode 100644 index 0000000..93dab41 --- /dev/null +++ b/fe/src/__tests__/audiobookStatus.spec.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest' +import { computeAudiobookStatus } from '@/utils/audiobookStatus' +import type { Audiobook, QualityProfile } from '@/types' + +describe('computeAudiobookStatus', () => { + it('uses the server-provided slim status when files are not present', () => { + const audiobook = { + id: 1, + title: 'Slim Book', + status: 'quality-match', + wanted: false, + } as Audiobook + + expect(computeAudiobookStatus(audiobook, new Set(), [])).toBe('quality-match') + }) + + it('lets active downloads override the cached list status', () => { + const audiobook = { + id: 2, + title: 'Downloading Book', + status: 'quality-match', + wanted: false, + } as Audiobook + + expect(computeAudiobookStatus(audiobook, new Set([2]), [])).toBe('downloading') + }) + + it('recomputes from files when a richer audiobook payload is available', () => { + const audiobook = { + id: 3, + title: 'Detailed Book', + qualityProfileId: 10, + files: [{ id: 100, format: 'm4b', bitrate: 320000 }], + } as Audiobook + + const profiles: QualityProfile[] = [ + { + id: 10, + name: 'High Quality', + cutoffQuality: '320kbps', + preferredFormats: ['m4b'], + qualities: [{ quality: '320kbps', allowed: true, priority: 0 }], + }, + ] + + expect(computeAudiobookStatus(audiobook, new Set(), profiles)).toBe('quality-match') + }) + + it('handles bitrate values stored in bits per second', () => { + const audiobook = { + id: 4, + title: 'Bitrate Book', + qualityProfileId: 10, + files: [{ id: 101, format: 'm4b', bitrate: 256000 }], + } as Audiobook + + const profiles: QualityProfile[] = [ + { + id: 10, + name: 'High Quality', + cutoffQuality: '256kbps', + preferredFormats: ['m4b'], + qualities: [{ quality: '256kbps', allowed: true, priority: 0 }], + }, + ] + + expect(computeAudiobookStatus(audiobook, new Set(), profiles)).toBe('quality-match') + }) +}) diff --git a/fe/src/components/domain/download/DownloadClientFormModal.vue b/fe/src/components/domain/download/DownloadClientFormModal.vue index aecac21..69cd093 100644 --- a/fe/src/components/domain/download/DownloadClientFormModal.vue +++ b/fe/src/components/domain/download/DownloadClientFormModal.vue @@ -388,6 +388,17 @@ const loadRemotePathMappings = async () => { } } +const normalizeHost = (value: string): string => { + const trimmed = (value || '').trim() + if (!trimmed) return '' + + const withoutScheme = trimmed.replace(/^[a-z]+:\/\//i, '') + const withoutTrailingSlashes = withoutScheme.replace(/\/+$/, '') + const firstSlash = withoutTrailingSlashes.indexOf('/') + + return firstSlash >= 0 ? withoutTrailingSlashes.slice(0, firstSlash) : withoutTrailingSlashes +} + const isUsenet = computed(() => { return formData.value.type === 'sabnzbd' || formData.value.type === 'nzbget' }) @@ -464,7 +475,7 @@ watch( formData.value = { name: newClient.name, type: newClient.type, - host: newClient.host, + host: normalizeHost(newClient.host), port: newClient.port, username: newClient.username || '', password: newClient.password || '', @@ -515,7 +526,7 @@ const testConnection = async () => { ...(props.editingClient?.id ? { id: props.editingClient.id } : {}), name: formData.value.name, type: formData.value.type, - host: formData.value.host, + host: normalizeHost(formData.value.host), port: formData.value.port, username: formData.value.username || '', password: formData.value.password || '', @@ -575,7 +586,7 @@ const handleSubmit = async () => { id: props.editingClient?.id || generateId(), name: formData.value.name, type: formData.value.type, - host: formData.value.host, + host: normalizeHost(formData.value.host), port: formData.value.port, username: formData.value.username || '', password: formData.value.password || '', diff --git a/fe/src/stores/library.ts b/fe/src/stores/library.ts index 65babfc..406127b 100644 --- a/fe/src/stores/library.ts +++ b/fe/src/stores/library.ts @@ -138,7 +138,11 @@ export const useLibraryStore = defineStore('library', () => { }) // Clone the audiobook object and update files safely so reactivity notices the change - const updated: Audiobook = { ...book, files: newFiles } + const updated: Audiobook = { + ...book, + files: newFiles, + wanted: Boolean(book.monitored) && newFiles.length === 0, + } // If the current primary filePath was one of the removed paths, clear it (safe behavior) if (book.filePath) { @@ -149,6 +153,10 @@ export const useLibraryStore = defineStore('library', () => { } } + if (updated.wanted) { + updated.status = 'no-file' + } + // Replace the item in the array immutably to ensure watchers pick up the change audiobooks.value = audiobooks.value.slice() audiobooks.value[bookIndex] = updated diff --git a/fe/src/types/index.ts b/fe/src/types/index.ts index d8154ed..8846a5b 100644 --- a/fe/src/types/index.ts +++ b/fe/src/types/index.ts @@ -394,6 +394,8 @@ export interface AudiobookExternalIdentifierInput { source?: AudiobookExternalIdentifierSource } +export type AudiobookStatus = 'downloading' | 'no-file' | 'quality-mismatch' | 'quality-match' + export interface Audiobook { id: number title: string @@ -442,6 +444,8 @@ export interface Audiobook { identifiers?: AudiobookExternalIdentifier[] // Server-computed flag indicating if this audiobook is wanted (monitored and missing files) wanted?: boolean + // Server-computed list status used by slim /library responses. + status?: AudiobookStatus } export interface History { diff --git a/fe/src/utils/audiobookStatus.ts b/fe/src/utils/audiobookStatus.ts new file mode 100644 index 0000000..5763773 --- /dev/null +++ b/fe/src/utils/audiobookStatus.ts @@ -0,0 +1,145 @@ +import type { Audiobook, AudiobookStatus, QualityProfile } from '@/types' + +const AUDIOBOOK_STATUSES: AudiobookStatus[] = [ + 'downloading', + 'no-file', + 'quality-mismatch', + 'quality-match', +] + +const normalize = (value?: string): string => (value || '').toString().trim().toLowerCase() + +function isAudiobookStatus(value: unknown): value is AudiobookStatus { + return typeof value === 'string' && AUDIOBOOK_STATUSES.includes(value as AudiobookStatus) +} + +export function formatAudiobookStatus(status: AudiobookStatus): string { + switch (status) { + case 'downloading': + return 'Downloading' + case 'no-file': + return 'Missing' + case 'quality-mismatch': + return 'Below Cutoff' + case 'quality-match': + return 'Downloaded' + default: + return '' + } +} + +export function computeAudiobookStatus( + audiobook: Audiobook, + activeDownloadAudiobookIds: ReadonlySet, + qualityProfiles: QualityProfile[], +): AudiobookStatus { + if (activeDownloadAudiobookIds.has(audiobook.id)) { + return 'downloading' + } + + const hasFiles = Array.isArray(audiobook.files) && audiobook.files.length > 0 + if (hasFiles) { + const profile = qualityProfiles.find((item) => item.id === audiobook.qualityProfileId) + + if (!profile) { + const hasFileSummary = + !!(audiobook.filePath && audiobook.fileSize && audiobook.fileSize > 0) || hasFiles + return hasFileSummary ? 'quality-match' : 'no-file' + } + + const preferredFormats = (profile.preferredFormats || []).map((item) => normalize(item)) + const candidateFiles = audiobook.files!.filter((file) => { + if (!file) return false + const fileFormat = normalize(file.format) || normalize(file.container) || '' + if (preferredFormats.length === 0) return true + return ( + preferredFormats.includes(fileFormat) || + preferredFormats.some((preferredFormat) => fileFormat.includes(preferredFormat)) + ) + }) + + if (candidateFiles.length === 0) { + return 'quality-mismatch' + } + + if (!profile.cutoffQuality || !profile.qualities || profile.qualities.length === 0) { + return 'quality-match' + } + + const qualityPriority = new Map() + for (const quality of profile.qualities) { + if (!quality || !quality.quality) continue + qualityPriority.set(normalize(quality.quality), quality.priority) + } + + const cutoff = normalize(profile.cutoffQuality) + const cutoffPriority = qualityPriority.has(cutoff) + ? qualityPriority.get(cutoff)! + : Number.POSITIVE_INFINITY + + for (const file of candidateFiles) { + const derivedQuality = deriveQualityLabel(audiobook, file) + if (!derivedQuality) continue + + const priority = qualityPriority.has(derivedQuality) + ? qualityPriority.get(derivedQuality)! + : Number.POSITIVE_INFINITY + + if (priority <= cutoffPriority) { + return 'quality-match' + } + } + + return 'quality-mismatch' + } + + if (isAudiobookStatus(audiobook.status)) { + return audiobook.status + } + + if (audiobook.wanted === true) { + return 'no-file' + } + + return 'no-file' +} + +function deriveQualityLabel( + audiobook: Audiobook, + file: + | { + bitrate?: number + container?: string + codec?: string + format?: string + } + | undefined, +): string { + if (audiobook.quality) return normalize(audiobook.quality) + + if (file && file.bitrate) { + const bitrate = Number(file.bitrate) + if (!Number.isNaN(bitrate)) { + const bitrateKbps = bitrate >= 1000 ? bitrate / 1000 : bitrate + if (bitrateKbps >= 320) return '320kbps' + if (bitrateKbps >= 256) return '256kbps' + if (bitrateKbps >= 192) return '192kbps' + return `${Math.round(bitrateKbps)}kbps` + } + } + + const container = normalize(file?.container) + const codec = normalize(file?.codec) + if ( + container.includes('flac') || + codec.includes('flac') || + codec.includes('alac') || + codec.includes('wav') + ) { + return 'lossless' + } + + if (file?.format) return normalize(file.format) + + return '' +} diff --git a/fe/src/views/library/AudiobooksView.vue b/fe/src/views/library/AudiobooksView.vue index 6d655a0..ce95251 100644 --- a/fe/src/views/library/AudiobooksView.vue +++ b/fe/src/views/library/AudiobooksView.vue @@ -690,9 +690,10 @@ import FiltersDropdown from '@/components/ui/FiltersDropdown.vue' import CustomFilterModal from '@/components/domain/collection/CustomFilterModal.vue' import { EmptyState } from '@/components/base' import { showConfirm } from '@/composables/useConfirm' -import type { Audiobook, QualityProfile } from '@/types' +import type { Audiobook, AudiobookStatus, QualityProfile } from '@/types' import { evaluateRules } from '@/utils/customFilterEvaluator' import type { RuleLike } from '@/utils/customFilterEvaluator' +import { computeAudiobookStatus, formatAudiobookStatus } from '@/utils/audiobookStatus' import { safeText } from '@/utils/textUtils' import { getPlaceholderUrl } from '@/utils/placeholder' import { observeLazyImages } from '@/utils/lazyLoad' @@ -1521,132 +1522,18 @@ const showEditModal = ref(false) const editAudiobook = ref(null) const lastClickedIndex = ref(null) -type AudiobookStatus = 'downloading' | 'no-file' | 'quality-mismatch' | 'quality-match' - -// Get the download status for an audiobook -// Returns: -// - 'downloading': Currently being downloaded (blue border) -// - 'no-file': No file downloaded yet (red border) -// - 'quality-mismatch': Has file but doesn't meet quality cutoff (blue border) -// - 'quality-match': Has file and meets quality cutoff (green border) -function computeAudiobookStatusRaw(audiobook: Audiobook): AudiobookStatus { - // Check if this audiobook is currently being downloaded - const isDownloading = downloadsStore.activeDownloads.some((d) => d.audiobookId === audiobook.id) - if (isDownloading) { - return 'downloading' - } - - // Use server's wanted flag if available (it checks File.Exists() on backend) - // wanted === true means files are missing or invalid - // wanted === false means files exist and are valid - if (audiobook.wanted === true) { - return 'no-file' - } - - // If there are no files at all, treat as no-file - if (!audiobook.files || !Array.isArray(audiobook.files) || audiobook.files.length === 0) { - return 'no-file' - } - - const profile = qualityProfiles.value.find((p) => p.id === audiobook.qualityProfileId) - - // If no profile or no preferredFormats defined, fall back to the simple existing behavior - if (!profile) { - const hasFile = audiobook.filePath && audiobook.fileSize && audiobook.fileSize > 0 - return hasFile ? 'quality-match' : 'no-file' - } - - // Helper: normalize strings - const normalize = (s?: string) => (s || '').toString().toLowerCase() - - // Find any file that matches one of the profile's preferred formats - const preferredFormats = (profile.preferredFormats || []).map((f) => normalize(f)) - - // If no preferred formats configured, treat any file as a candidate - const candidateFiles = audiobook.files.filter((f) => { - if (!f) return false - const fileFormat = normalize(f.format) || normalize(f.container) || '' - if (preferredFormats.length === 0) return true - return ( - preferredFormats.includes(fileFormat) || - preferredFormats.some((pf) => fileFormat.includes(pf)) - ) - }) - - if (candidateFiles.length === 0) { - // Files exist but none match preferred formats; treat as mismatch instead of missing. - return 'quality-mismatch' - } - - // If no cutoff defined, assume match - if (!profile.cutoffQuality || !profile.qualities || profile.qualities.length === 0) { - return 'quality-match' - } - - // Build a map of quality -> priority for quick lookup - const qualityPriority = new Map() - for (const q of profile.qualities) { - if (!q || !q.quality) continue - qualityPriority.set(normalize(q.quality), q.priority) - } - - const cutoff = normalize(profile.cutoffQuality) - const cutoffPriority = qualityPriority.has(cutoff) - ? qualityPriority.get(cutoff)! - : Number.POSITIVE_INFINITY - - // Helper to derive a quality string for a given file/audiobook - type FileInfo = { - bitrate?: number | string - container?: string - codec?: string - format?: string - } - - function deriveQualityLabel(file: FileInfo | undefined): string { - // Prefer the denormalized audiobook.quality if present - if (audiobook.quality) return normalize(audiobook.quality) - - if (file && file.bitrate) { - const br = Number(file.bitrate) - if (!isNaN(br)) { - if (br >= 320) return '320kbps' - if (br >= 256) return '256kbps' - if (br >= 192) return '192kbps' - return `${Math.round(br)}kbps` - } - } - - // If container or codec suggests lossless - const container = normalize(file?.container) - const codec = normalize(file?.codec) - if ( - container.includes('flac') || - codec.includes('flac') || - codec.includes('alac') || - codec.includes('wav') - ) { - return 'lossless' - } - - // Fallback: use format string - if (file && file.format) return normalize(file.format) - - return '' - } - - // If any candidate file meets or exceeds the cutoff (lower priority number == better), return match - for (const f of candidateFiles) { - const label = deriveQualityLabel(f) - if (!label) continue - const p = qualityPriority.has(label) ? qualityPriority.get(label)! : Number.POSITIVE_INFINITY - if (p <= cutoffPriority) { - return 'quality-match' +const activeDownloadAudiobookIds = computed(() => { + const ids = new Set() + for (const download of downloadsStore.activeDownloads || []) { + if (typeof download?.audiobookId === 'number') { + ids.add(download.audiobookId) } } + return ids +}) - // Otherwise at least one preferred-format file exists but doesn't meet cutoff - return 'quality-mismatch' +function computeAudiobookStatusRaw(audiobook: Audiobook): AudiobookStatus { + return computeAudiobookStatus(audiobook, activeDownloadAudiobookIds.value, qualityProfiles.value) } const audiobookStatusById = computed(() => { @@ -1823,20 +1710,9 @@ function getQualityProfileName(profileId?: number): string | null { } function statusText( - status: 'downloading' | 'no-file' | 'quality-mismatch' | 'quality-match', + status: AudiobookStatus, ): string { - switch (status) { - case 'downloading': - return 'Downloading' - case 'no-file': - return 'Missing' - case 'quality-mismatch': - return 'Below Cutoff' - case 'quality-match': - return 'Downloaded' - default: - return '' - } + return formatAudiobookStatus(status) } function openStatusDetails(audiobook: Audiobook) { diff --git a/fe/src/views/library/CollectionView.vue b/fe/src/views/library/CollectionView.vue index 99ab1a0..fb86eaa 100644 --- a/fe/src/views/library/CollectionView.vue +++ b/fe/src/views/library/CollectionView.vue @@ -388,7 +388,8 @@ import { showConfirm } from '@/composables/useConfirm' import { getPlaceholderUrl } from '@/utils/placeholder' import CustomSelect from '@/components/form/CustomSelect.vue' import { EmptyState, LoadingState } from '@/components/base' -import type { Audiobook } from '@/types' +import type { Audiobook, AudiobookStatus } from '@/types' +import { computeAudiobookStatus, formatAudiobookStatus } from '@/utils/audiobookStatus' import { safeText } from '@/utils/textUtils' import { useProtectedImages } from '@/composables/useProtectedImages' @@ -650,143 +651,24 @@ function getQualityProfileName(profileId?: number): string | null { return profile?.name ?? null } -function statusText( - status: 'downloading' | 'no-file' | 'quality-mismatch' | 'quality-match', -): string { - switch (status) { - case 'downloading': - return 'Downloading' - case 'no-file': - return 'Missing' - case 'quality-mismatch': - return 'Below Cutoff' - case 'quality-match': - return 'Downloaded' - default: - return '' - } -} - -function getAudiobookStatus( - audiobook: Audiobook, -): 'downloading' | 'no-file' | 'quality-mismatch' | 'quality-match' { - // Check if this audiobook is currently being downloaded - const isDownloading = downloadsStore.activeDownloads.some((d) => d.audiobookId === audiobook.id) - if (isDownloading) { - return 'downloading' - } - - // Use server's wanted flag if available (it checks File.Exists() on backend) - // wanted === true means files are missing or invalid - // wanted === false means files exist and are valid - if (audiobook.wanted === true) { - return 'no-file' - } - - // If there are no files at all, treat as no-file - if (!audiobook.files || !Array.isArray(audiobook.files) || audiobook.files.length === 0) { - return 'no-file' - } - - const profile = qualityProfiles.value.find((p) => p.id === audiobook.qualityProfileId) - - // If no profile or no preferredFormats defined, fall back to the simple existing behavior - if (!profile) { - const hasFile = audiobook.filePath && audiobook.fileSize && audiobook.fileSize > 0 - return hasFile ? 'quality-match' : 'no-file' - } - - // Helper: normalize strings - const normalize = (s?: string) => (s || '').toString().toLowerCase() - - // Find any file that matches one of the profile's preferred formats - const preferredFormats = (profile.preferredFormats || []).map((f) => normalize(f)) - - // If no preferred formats configured, treat any file as a candidate - const candidateFiles = audiobook.files.filter((f) => { - if (!f) return false - const fileFormat = normalize(f.format) || normalize(f.container) || '' - if (preferredFormats.length === 0) return true - return ( - preferredFormats.includes(fileFormat) || - preferredFormats.some((pf) => fileFormat.includes(pf)) - ) - }) - - if (candidateFiles.length === 0) { - // Files exist but none match preferred formats; treat as mismatch instead of missing. - return 'quality-mismatch' - } - - // If no cutoff defined, assume match - if (!profile.cutoffQuality || !profile.qualities || profile.qualities.length === 0) { - return 'quality-match' - } - - // Build a map of quality -> priority for quick lookup - const qualityPriority = new Map() - for (const q of profile.qualities) { - if (!q || !q.quality) continue - qualityPriority.set(normalize(q.quality), q.priority) - } - - const cutoff = normalize(profile.cutoffQuality) - const cutoffPriority = qualityPriority.has(cutoff) - ? qualityPriority.get(cutoff)! - : Number.POSITIVE_INFINITY - - // Helper to derive a quality string for a given file/audiobook - type FileInfo = { - bitrate?: number | string - container?: string - codec?: string - format?: string - } - - function deriveQualityLabel(file: FileInfo | undefined): string { - // Prefer the denormalized audiobook.quality if present - if (audiobook.quality) return normalize(audiobook.quality) - - if (file && file.bitrate) { - const br = Number(file.bitrate) - if (!isNaN(br)) { - if (br >= 320) return '320kbps' - if (br >= 256) return '256kbps' - if (br >= 192) return '192kbps' - return `${Math.round(br)}kbps` - } - } - - // If container or codec suggests lossless - const container = normalize(file?.container) - const codec = normalize(file?.codec) - if ( - container.includes('flac') || - codec.includes('flac') || - codec.includes('alac') || - codec.includes('wav') - ) { - return 'lossless' +const activeDownloadAudiobookIds = computed(() => { + const ids = new Set() + for (const download of downloadsStore.activeDownloads || []) { + if (typeof download?.audiobookId === 'number') { + ids.add(download.audiobookId) } - - // Fallback: use format string - if (file && file.format) return normalize(file.format) - - return '' } + return ids +}) - // If any candidate file meets or exceeds the cutoff (lower priority number == better), return match - for (const f of candidateFiles) { - const label = deriveQualityLabel(f) - if (!label) continue - const p = qualityPriority.has(label) ? qualityPriority.get(label)! : Number.POSITIVE_INFINITY - if (p <= cutoffPriority) { - return 'quality-match' - } - } +function statusText( + status: AudiobookStatus, +): string { + return formatAudiobookStatus(status) +} - // Otherwise at least one preferred-format file exists but doesn't meet cutoff - return 'quality-mismatch' +function getAudiobookStatus(audiobook: Audiobook): AudiobookStatus { + return computeAudiobookStatus(audiobook, activeDownloadAudiobookIds.value, qualityProfiles.value) } function handleCheckboxKeydown(audiobook: Audiobook, event: KeyboardEvent) { diff --git a/listenarr.api/Controllers/LibraryController.cs b/listenarr.api/Controllers/LibraryController.cs index 7f0a029..10bb1ff 100644 --- a/listenarr.api/Controllers/LibraryController.cs +++ b/listenarr.api/Controllers/LibraryController.cs @@ -24,6 +24,7 @@ using Microsoft.Extensions.Caching.Memory; using Listenarr.Domain.Models; using Listenarr.Infrastructure.Models; +using Listenarr.Api.Models; using Listenarr.Api.Services; using Microsoft.EntityFrameworkCore; using System.Collections.Generic; @@ -573,65 +574,135 @@ public async Task PreviewPath([FromBody] PreviewPathRequest reque } /// - /// Get all audiobooks in the library, including file info and wanted status. + /// Get all audiobooks in the library using a slim list payload. /// [HttpGet] public async Task GetAll() { - // Return audiobooks including files and an explicit 'wanted' flag var audiobooks = await _dbContext.Audiobooks .AsNoTracking() - .Include(a => a.Files) .OrderBy(a => a.Title) + .Select(a => new + { + a.Id, + a.Title, + a.Authors, + a.Narrators, + a.PublishYear, + a.PublishedDate, + a.Series, + a.SeriesNumber, + a.Asin, + a.OpenLibraryId, + a.Publisher, + a.Language, + a.Runtime, + a.ImageUrl, + a.Monitored, + a.Quality, + a.QualityProfileId, + a.AuthorAsins + }) .ToListAsync(); - var dto = audiobooks.Select(a => new - { - id = a.Id, - title = a.Title, - subtitle = a.Subtitle, - authors = a.Authors, - publishYear = a.PublishYear, - publishedDate = a.PublishedDate, - series = a.Series, - seriesNumber = a.SeriesNumber, - description = a.Description, - genres = a.Genres, - tags = a.Tags, - narrators = a.Narrators, - isbn = a.Isbn, - asin = a.Asin, - openLibraryId = a.OpenLibraryId, - publisher = a.Publisher, - language = a.Language, - runtime = a.Runtime, - version = a.Version, - @explicit = a.Explicit, - abridged = a.Abridged, - imageUrl = a.ImageUrl, - filePath = a.FilePath, - fileSize = a.FileSize, - basePath = a.BasePath, - monitored = a.Monitored, - quality = a.Quality, - qualityProfileId = a.QualityProfileId, - files = a.Files?.Select(f => new - { - id = f.Id, - path = f.Path, - size = f.Size, - durationSeconds = f.DurationSeconds, - format = f.Format, - container = f.Container, - codec = f.Codec, - bitrate = f.Bitrate, - sampleRate = f.SampleRate, - channels = f.Channels, - source = f.Source, - createdAt = f.CreatedAt - }).ToList(), - wanted = ComputeWantedFlag(a) - }); + if (audiobooks.Count == 0) + { + return Ok(Array.Empty()); + } + + var audiobookIds = audiobooks.Select(a => a.Id).ToArray(); + var fileSummaries = await _dbContext.AudiobookFiles + .AsNoTracking() + .Where(f => audiobookIds.Contains(f.AudiobookId)) + .Select(f => new AudiobookFileStatusInfo + { + AudiobookId = f.AudiobookId, + Path = f.Path, + Format = f.Format, + Container = f.Container, + Codec = f.Codec, + Bitrate = f.Bitrate + }) + .ToListAsync(); + + var filesByAudiobookId = fileSummaries + .GroupBy(f => f.AudiobookId) + .ToDictionary(g => g.Key, g => (IReadOnlyList)g.ToList()); + + var qualityProfileIds = audiobooks + .Where(a => a.QualityProfileId.HasValue) + .Select(a => a.QualityProfileId!.Value) + .Distinct() + .ToArray(); + + var qualityProfiles = qualityProfileIds.Length == 0 + ? new List() + : await _dbContext.QualityProfiles + .AsNoTracking() + .Where(q => qualityProfileIds.Contains(q.Id)) + .ToListAsync(); + + var qualityProfilesById = qualityProfiles.ToDictionary(q => q.Id); + + var activeDownloadStatuses = new[] + { + DownloadStatus.Queued, + DownloadStatus.Downloading, + DownloadStatus.Paused, + DownloadStatus.Processing, + DownloadStatus.ImportPending + }; + + var activeDownloadAudiobookIds = await _dbContext.Downloads + .AsNoTracking() + .Where(d => d.AudiobookId.HasValue && activeDownloadStatuses.Contains(d.Status)) + .Select(d => d.AudiobookId!.Value) + .Distinct() + .ToListAsync(); + + var activeDownloadAudiobookIdSet = activeDownloadAudiobookIds.ToHashSet(); + + var dto = audiobooks.Select(a => + { + filesByAudiobookId.TryGetValue(a.Id, out var files); + var hasFiles = files != null && files.Count > 0; + var wanted = a.Monitored && !hasFiles; + QualityProfile? qualityProfile = null; + if (a.QualityProfileId.HasValue) + { + qualityProfilesById.TryGetValue(a.QualityProfileId.Value, out qualityProfile); + } + + return new LibraryAudiobookListItemDto + { + Id = a.Id, + Title = a.Title, + Authors = a.Authors?.ToArray(), + Narrators = a.Narrators?.ToArray(), + PublishYear = a.PublishYear, + PublishedDate = a.PublishedDate, + Series = a.Series, + SeriesNumber = a.SeriesNumber, + Asin = a.Asin, + OpenLibraryId = a.OpenLibraryId, + Publisher = a.Publisher, + Language = a.Language, + Runtime = a.Runtime, + ImageUrl = a.ImageUrl, + Monitored = a.Monitored, + Quality = a.Quality, + QualityProfileId = a.QualityProfileId, + AuthorAsins = a.AuthorAsins?.ToArray(), + Wanted = wanted, + Status = AudiobookStatusEvaluator.ComputeStatus( + activeDownloadAudiobookIdSet.Contains(a.Id), + wanted, + hasFiles, + a.Quality, + qualityProfile, + files) + }; + }).ToList(); return Ok(dto); } @@ -667,14 +738,8 @@ public async Task GetByIsbn(string isbn) [HttpGet("{id}")] public async Task> GetAudiobook(int id) { - var audiobook = await _repo.GetByIdAsync(id); - if (audiobook == null) - { - return NotFound(new { message = "Audiobook not found" }); - } - // Include QualityProfile and Files in the query var updated = await _dbContext.Audiobooks - .Include(a => a.QualityProfile) + .AsNoTracking() .Include(a => a.Files) .Include(a => a.ExternalIdentifiers) .FirstOrDefaultAsync(a => a.Id == id); diff --git a/listenarr.api/Models/LibraryAudiobookListItemDto.cs b/listenarr.api/Models/LibraryAudiobookListItemDto.cs new file mode 100644 index 0000000..2636a98 --- /dev/null +++ b/listenarr.api/Models/LibraryAudiobookListItemDto.cs @@ -0,0 +1,28 @@ +using System; + +namespace Listenarr.Api.Models +{ + public class LibraryAudiobookListItemDto + { + public int Id { get; set; } + public string? Title { get; set; } + public string[]? Authors { get; set; } + public string[]? Narrators { get; set; } + public string? PublishYear { get; set; } + public string? PublishedDate { get; set; } + public string? Series { get; set; } + public string? SeriesNumber { get; set; } + public string? Asin { get; set; } + public string? OpenLibraryId { get; set; } + public string? Publisher { get; set; } + public string? Language { get; set; } + public int? Runtime { get; set; } + public string? ImageUrl { get; set; } + public bool Monitored { get; set; } + public string? Quality { get; set; } + public int? QualityProfileId { get; set; } + public string[]? AuthorAsins { get; set; } + public bool Wanted { get; set; } + public string Status { get; set; } = string.Empty; + } +} diff --git a/listenarr.api/Services/Adapters/DownloadClientUriBuilder.cs b/listenarr.api/Services/Adapters/DownloadClientUriBuilder.cs new file mode 100644 index 0000000..916f2ef --- /dev/null +++ b/listenarr.api/Services/Adapters/DownloadClientUriBuilder.cs @@ -0,0 +1,77 @@ +using System; +using Listenarr.Domain.Models; + +namespace Listenarr.Api.Services.Adapters +{ + internal static class DownloadClientUriBuilder + { + public static string BuildAuthority(DownloadClientConfiguration client) + { + return BuildUri(client, "/").GetLeftPart(UriPartial.Authority); + } + + public static Uri BuildUri( + DownloadClientConfiguration client, + string path, + bool includeCredentials = false) + { + if (client == null) + { + throw new ArgumentNullException(nameof(client)); + } + + var rawHost = (client.Host ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(rawHost)) + { + throw new InvalidOperationException("Download client host is required."); + } + + var scheme = client.UseSSL ? Uri.UriSchemeHttps : Uri.UriSchemeHttp; + var hostWithScheme = rawHost.Contains("://", StringComparison.Ordinal) + ? rawHost + : $"{scheme}://{rawHost}"; + + if (!Uri.TryCreate(hostWithScheme, UriKind.Absolute, out var parsedHost) || string.IsNullOrWhiteSpace(parsedHost.Host)) + { + throw new InvalidOperationException($"Invalid download client host '{rawHost}'."); + } + + var builder = new UriBuilder(parsedHost) + { + Scheme = scheme, + Port = client.Port > 0 ? client.Port : (parsedHost.IsDefaultPort ? -1 : parsedHost.Port), + Path = NormalizePath(path), + Query = string.Empty, + Fragment = string.Empty + }; + + if (includeCredentials + && !string.IsNullOrWhiteSpace(client.Username) + && !string.IsNullOrWhiteSpace(client.Password)) + { + builder.UserName = client.Username; + builder.Password = client.Password; + } + else + { + builder.UserName = string.Empty; + builder.Password = string.Empty; + } + + return builder.Uri; + } + + private static string NormalizePath(string path) + { + var trimmed = (path ?? string.Empty).Trim(); + if (trimmed.Length == 0) + { + return "/"; + } + + return trimmed.StartsWith("/", StringComparison.Ordinal) + ? trimmed + : "/" + trimmed; + } + } +} diff --git a/listenarr.api/Services/Adapters/NzbgetAdapter.cs b/listenarr.api/Services/Adapters/NzbgetAdapter.cs index b96aafc..150baf5 100644 --- a/listenarr.api/Services/Adapters/NzbgetAdapter.cs +++ b/listenarr.api/Services/Adapters/NzbgetAdapter.cs @@ -133,8 +133,7 @@ private static bool IsVersion25OrNewer(string version) var nzbBytes = await DownloadNzbAsync(nzbUrl, indexerApiKey, ct); var nzbFileName = BuildNzbFileName(result); - var scheme = client.UseSSL ? "https" : "http"; - var uploadUrl = $"{scheme}://{client.Host}:{client.Port}/api/v2/nzb"; + var uploadUrl = DownloadClientUriBuilder.BuildUri(client, "/api/v2/nzb"); using var httpClient = _httpClientFactory.CreateClient(); using var content = new MultipartFormDataContent(); @@ -168,7 +167,7 @@ private static bool IsVersion25OrNewer(string version) request.Headers.Authorization = authHeader; } - _logger.LogDebug("NZBGet REST API POST to {Url} with file {FileName}", LogRedaction.SanitizeUrl(uploadUrl), LogRedaction.SanitizeText(nzbFileName)); + _logger.LogDebug("NZBGet REST API POST to {Url} with file {FileName}", LogRedaction.SanitizeUrl(uploadUrl.ToString()), LogRedaction.SanitizeText(nzbFileName)); var response = await httpClient.SendAsync(request, ct); var responseBody = await response.Content.ReadAsStringAsync(ct); @@ -941,17 +940,12 @@ private XElement SerializeValue(object value) private static string BuildBaseUrl(DownloadClientConfiguration client) { - var scheme = client.UseSSL ? "https" : "http"; - - // NZBGet XML-RPC requires authentication in URL: http://username:password@host:port/xmlrpc - if (!string.IsNullOrWhiteSpace(client.Username) && !string.IsNullOrWhiteSpace(client.Password)) - { - var encodedUsername = Uri.EscapeDataString(client.Username); - var encodedPassword = Uri.EscapeDataString(client.Password); - return $"{scheme}://{encodedUsername}:{encodedPassword}@{client.Host}:{client.Port}/xmlrpc"; - } - - return $"{scheme}://{client.Host}:{client.Port}/xmlrpc"; + return DownloadClientUriBuilder + .BuildUri( + client, + "/xmlrpc", + includeCredentials: !string.IsNullOrWhiteSpace(client.Username) && !string.IsNullOrWhiteSpace(client.Password)) + .ToString(); } private async Task DownloadNzbAsync(string nzbUrl, string? indexerApiKey, CancellationToken ct) diff --git a/listenarr.api/Services/Adapters/QbittorrentAdapter.cs b/listenarr.api/Services/Adapters/QbittorrentAdapter.cs index fe18ab8..af95180 100644 --- a/listenarr.api/Services/Adapters/QbittorrentAdapter.cs +++ b/listenarr.api/Services/Adapters/QbittorrentAdapter.cs @@ -39,7 +39,7 @@ public QbittorrentAdapter(IHttpClientFactory httpFactory, IRemotePathMappingServ { try { - var baseUrl = $"{(client.UseSSL ? "https" : "http")}://{client.Host}:{client.Port}"; + var baseUrl = DownloadClientUriBuilder.BuildAuthority(client); // Prefer the IHttpClientFactory-created client so unit tests can inject // a DelegatingHandler mock. Fall back to a local cookie-enabled client @@ -225,7 +225,7 @@ async Task PostLoginWithAgent(string userAgent) if (string.IsNullOrEmpty(torrentUrl)) throw new ArgumentException("No magnet link or torrent URL provided", nameof(result)); - var baseUrl = $"{(client.UseSSL ? "https" : "http")}://{client.Host}:{client.Port}"; + var baseUrl = DownloadClientUriBuilder.BuildAuthority(client); string? extractedHash = null; if (!string.IsNullOrEmpty(result.MagnetLink) && result.MagnetLink.Contains("xt=urn:btih:", StringComparison.OrdinalIgnoreCase)) @@ -463,7 +463,7 @@ public async Task MarkItemAsImportedAsync(DownloadClientConfiguration clie return true; // No-op is success } - var baseUrl = $"{(client.UseSSL ? "https" : "http")}://{client.Host}:{client.Port}"; + var baseUrl = DownloadClientUriBuilder.BuildAuthority(client); try { var cookieJar = new CookieContainer(); @@ -507,7 +507,7 @@ public async Task RemoveAsync(DownloadClientConfiguration client, string i if (client == null) throw new ArgumentNullException(nameof(client)); if (string.IsNullOrEmpty(id)) throw new ArgumentNullException(nameof(id)); - var baseUrl = $"{(client.UseSSL ? "https" : "http")}://{client.Host}:{client.Port}"; + var baseUrl = DownloadClientUriBuilder.BuildAuthority(client); try { @@ -564,7 +564,7 @@ public async Task> GetQueueAsync(DownloadClientConfiguration cli var items = new List(); if (client == null) return items; - var baseUrl = $"{(client.UseSSL ? "https" : "http")}://{client.Host}:{client.Port}"; + var baseUrl = DownloadClientUriBuilder.BuildAuthority(client); try { @@ -714,7 +714,7 @@ public async Task> GetItemsAsync(DownloadClientConfigur var items = new List(); if (client == null) return items; - var baseUrl = $"{(client.UseSSL ? "https" : "http")}://{client.Host}:{client.Port}"; + var baseUrl = DownloadClientUriBuilder.BuildAuthority(client); var categoryFilter = QBittorrentHelpers.BuildCategoryParameter(client.Settings, "&"); try @@ -992,7 +992,7 @@ public async Task GetImportItemAsync( // Otherwise, resolve path from qBittorrent API var hash = result.DownloadId.ToLowerInvariant(); - var baseUrl = $"{(client.UseSSL ? "https" : "http")}://{client.Host}:{client.Port}"; + var baseUrl = DownloadClientUriBuilder.BuildAuthority(client); try { @@ -1104,7 +1104,7 @@ public async Task GetImportItemAsync( return result; } - var baseUrl = $"{(client.UseSSL ? "https" : "http")}://{client.Host}:{client.Port}"; + var baseUrl = DownloadClientUriBuilder.BuildAuthority(client); try { diff --git a/listenarr.api/Services/Adapters/SabnzbdAdapter.cs b/listenarr.api/Services/Adapters/SabnzbdAdapter.cs index 97164d1..07393fd 100644 --- a/listenarr.api/Services/Adapters/SabnzbdAdapter.cs +++ b/listenarr.api/Services/Adapters/SabnzbdAdapter.cs @@ -41,7 +41,7 @@ public SabnzbdAdapter( { if (client == null) throw new ArgumentNullException(nameof(client)); - var baseUrl = $"{(client.UseSSL ? "https" : "http")}://{client.Host}:{client.Port}/api"; + var baseUrl = DownloadClientUriBuilder.BuildUri(client, "/api").ToString(); var apiKey = ""; if (client.Settings != null && client.Settings.TryGetValue("apiKey", out var apiKeyObj)) apiKey = apiKeyObj?.ToString() ?? ""; @@ -94,7 +94,7 @@ public SabnzbdAdapter( try { - var baseUrl = $"{(client.UseSSL ? "https" : "http")}://{client.Host}:{client.Port}/api"; + var baseUrl = DownloadClientUriBuilder.BuildUri(client, "/api").ToString(); // Get API key var apiKey = ""; @@ -208,7 +208,7 @@ public async Task RemoveAsync(DownloadClientConfiguration client, string i try { - var baseUrl = $"{(client.UseSSL ? "https" : "http")}://{client.Host}:{client.Port}/api"; + var baseUrl = DownloadClientUriBuilder.BuildUri(client, "/api").ToString(); var apiKey = ""; if (client.Settings != null && client.Settings.TryGetValue("apiKey", out var apiKeyObj)) @@ -298,7 +298,7 @@ public async Task> GetQueueAsync(DownloadClientConfiguration cli try { - var baseUrl = $"{(client.UseSSL ? "https" : "http")}://{client.Host}:{client.Port}/api"; + var baseUrl = DownloadClientUriBuilder.BuildUri(client, "/api").ToString(); var apiKey = ""; if (client.Settings != null && client.Settings.TryGetValue("apiKey", out var apiKeyObj)) { @@ -453,7 +453,7 @@ double ParseNumericValue(JsonElement element) try { - var baseUrl = $"{(client.UseSSL ? "https" : "http")}://{client.Host}:{client.Port}/api"; + var baseUrl = DownloadClientUriBuilder.BuildUri(client, "/api").ToString(); var apiKey = ""; if (client.Settings != null && client.Settings.TryGetValue("apiKey", out var apiKeyObj)) { @@ -499,7 +499,7 @@ public async Task> GetItemsAsync(DownloadClientConfigur try { - var baseUrl = $"{(client.UseSSL ? "https" : "http")}://{client.Host}:{client.Port}/api"; + var baseUrl = DownloadClientUriBuilder.BuildUri(client, "/api").ToString(); var apiKey = ""; if (client.Settings != null && client.Settings.TryGetValue("apiKey", out var apiKeyObj)) { @@ -671,7 +671,7 @@ public async Task GetImportItemAsync( try { // Query SABnzbd history for the download - var baseUrl = $"{(client.UseSSL ? "https" : "http")}://{client.Host}:{client.Port}/api"; + var baseUrl = DownloadClientUriBuilder.BuildUri(client, "/api").ToString(); var apiKey = ""; if (client.Settings != null && client.Settings.TryGetValue("apiKey", out var apiKeyObj)) { @@ -834,7 +834,7 @@ public async Task GetImportItemAsync( try { // Query SABnzbd history for the download - var baseUrl = $"{(client.UseSSL ? "https" : "http")}://{client.Host}:{client.Port}/api"; + var baseUrl = DownloadClientUriBuilder.BuildUri(client, "/api").ToString(); var apiKey = ""; if (client.Settings != null && client.Settings.TryGetValue("apiKey", out var apiKeyObj)) { diff --git a/listenarr.api/Services/Adapters/TransmissionAdapter.cs b/listenarr.api/Services/Adapters/TransmissionAdapter.cs index 9621dd7..c3f1ce5 100644 --- a/listenarr.api/Services/Adapters/TransmissionAdapter.cs +++ b/listenarr.api/Services/Adapters/TransmissionAdapter.cs @@ -1049,7 +1049,6 @@ private async Task InvokeRpcAsync(DownloadClientConfiguration clien private static string BuildBaseUrl(DownloadClientConfiguration client) { - var scheme = client.UseSSL ? "https" : "http"; var rpcPath = "/transmission/rpc"; if (client.Settings?.TryGetValue("urlBase", out var urlBaseObj) is true) { @@ -1059,7 +1058,7 @@ private static string BuildBaseUrl(DownloadClientConfiguration client) rpcPath = custom.StartsWith('/') ? custom : "/" + custom; } } - return $"{scheme}://{client.Host}:{client.Port}{rpcPath}"; + return DownloadClientUriBuilder.BuildUri(client, rpcPath).ToString(); } private static string NormalizeMagnetUriForTransmission(string magnetUri) diff --git a/listenarr.api/Services/AudiobookStatusEvaluator.cs b/listenarr.api/Services/AudiobookStatusEvaluator.cs new file mode 100644 index 0000000..cd1deb2 --- /dev/null +++ b/listenarr.api/Services/AudiobookStatusEvaluator.cs @@ -0,0 +1,174 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Listenarr.Domain.Models; + +namespace Listenarr.Api.Services +{ + public sealed class AudiobookFileStatusInfo + { + public int AudiobookId { get; set; } + public string? Path { get; set; } + public string? Format { get; set; } + public string? Container { get; set; } + public string? Codec { get; set; } + public int? Bitrate { get; set; } + } + + public static class AudiobookStatusEvaluator + { + public const string Downloading = "downloading"; + public const string NoFile = "no-file"; + public const string QualityMismatch = "quality-mismatch"; + public const string QualityMatch = "quality-match"; + + public static string ComputeStatus( + bool isDownloading, + bool wanted, + bool hasAnyFile, + string? audiobookQuality, + QualityProfile? qualityProfile, + IReadOnlyList? files) + { + if (isDownloading) + { + return Downloading; + } + + if (wanted) + { + return NoFile; + } + + if (!hasAnyFile) + { + return NoFile; + } + + if (qualityProfile == null) + { + return QualityMatch; + } + + var preferredFormats = (qualityProfile.PreferredFormats ?? new List()) + .Select(Normalize) + .Where(v => v.Length > 0) + .ToList(); + + var candidateFiles = (files ?? Array.Empty()) + .Where(f => + { + var fileFormat = Normalize(f.Format); + if (fileFormat.Length == 0) + { + fileFormat = Normalize(f.Container); + } + + if (preferredFormats.Count == 0) + { + return true; + } + + return preferredFormats.Contains(fileFormat) + || preferredFormats.Any(pf => fileFormat.Contains(pf, StringComparison.Ordinal)); + }) + .ToList(); + + if (candidateFiles.Count == 0) + { + return QualityMismatch; + } + + if (string.IsNullOrWhiteSpace(qualityProfile.CutoffQuality) + || qualityProfile.Qualities == null + || qualityProfile.Qualities.Count == 0) + { + return QualityMatch; + } + + var qualityPriority = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var quality in qualityProfile.Qualities) + { + if (quality == null || string.IsNullOrWhiteSpace(quality.Quality)) + { + continue; + } + + qualityPriority[Normalize(quality.Quality)] = quality.Priority; + } + + var cutoff = Normalize(qualityProfile.CutoffQuality); + var cutoffPriority = qualityPriority.TryGetValue(cutoff, out var foundCutoffPriority) + ? foundCutoffPriority + : int.MaxValue; + + foreach (var file in candidateFiles) + { + var derivedQuality = DeriveQualityLabel(file, audiobookQuality); + if (derivedQuality.Length == 0) + { + continue; + } + + var priority = qualityPriority.TryGetValue(derivedQuality, out var foundPriority) + ? foundPriority + : int.MaxValue; + + if (priority <= cutoffPriority) + { + return QualityMatch; + } + } + + return QualityMismatch; + } + + private static string DeriveQualityLabel(AudiobookFileStatusInfo? file, string? audiobookQuality) + { + var normalizedAudiobookQuality = Normalize(audiobookQuality); + if (normalizedAudiobookQuality.Length > 0) + { + return normalizedAudiobookQuality; + } + + if (file?.Bitrate is int bitrate) + { + var bitrateKbps = bitrate >= 1000 ? bitrate / 1000d : bitrate; + + if (bitrateKbps >= 320) + { + return "320kbps"; + } + + if (bitrateKbps >= 256) + { + return "256kbps"; + } + + if (bitrateKbps >= 192) + { + return "192kbps"; + } + + return $"{Math.Round(bitrateKbps)}kbps"; + } + + var container = Normalize(file?.Container); + var codec = Normalize(file?.Codec); + if (container.Contains("flac", StringComparison.Ordinal) + || codec.Contains("flac", StringComparison.Ordinal) + || codec.Contains("alac", StringComparison.Ordinal) + || codec.Contains("wav", StringComparison.Ordinal)) + { + return "lossless"; + } + + return Normalize(file?.Format); + } + + private static string Normalize(string? value) + { + return (value ?? string.Empty).Trim().ToLowerInvariant(); + } + } +} diff --git a/listenarr.api/Services/DownloadMonitorService.cs b/listenarr.api/Services/DownloadMonitorService.cs index 90de7d8..cbf4ee5 100644 --- a/listenarr.api/Services/DownloadMonitorService.cs +++ b/listenarr.api/Services/DownloadMonitorService.cs @@ -28,6 +28,7 @@ using System.Text.Encodings.Web; using Microsoft.Extensions.Caching.Memory; using Listenarr.Application.Services; +using Listenarr.Api.Services.Adapters; namespace Listenarr.Api.Services { @@ -784,7 +785,7 @@ private Task PollQBittorrentAsync( return; } - var baseUrl = $"{(client.UseSSL ? "https" : "http")}://{client.Host}:{client.Port}"; + var baseUrl = DownloadClientUriBuilder.BuildAuthority(client); _logger.LogInformation("Polling qBittorrent client {ClientName} at {BaseUrl}", client.Name, baseUrl); // Create an HttpClient with its own CookieContainer so the qBittorrent @@ -1266,7 +1267,7 @@ private Task PollTransmissionAsync( rpcPath = custom.StartsWith('/') ? custom : "/" + custom; } } - var baseUrl = $"{(client.UseSSL ? "https" : "http")}://{client.Host}:{client.Port}{rpcPath}"; + var baseUrl = DownloadClientUriBuilder.BuildUri(client, rpcPath).ToString(); using var http = _httpClientFactory.CreateClient("DownloadClient"); // Resolve removeCompletedDownloads for CanMoveFiles/CanBeRemoved evaluation @@ -2049,7 +2050,7 @@ private Task PollSABnzbdAsync( return; } - var baseUrl = $"{(client.UseSSL ? "https" : "http")}://{client.Host}:{client.Port}/api"; + var baseUrl = DownloadClientUriBuilder.BuildUri(client, "/api").ToString(); using var http = _httpClientFactory.CreateClient("DownloadClient"); @@ -2396,7 +2397,7 @@ private Task PollNZBGetAsync( return; } - var baseUrl = $"{(client.UseSSL ? "https" : "http")}://{client.Host}:{client.Port}/jsonrpc"; + var baseUrl = DownloadClientUriBuilder.BuildUri(client, "/jsonrpc"); using var http = _httpClientFactory.CreateClient("nzbget"); diff --git a/listenarr.api/Services/DownloadService.cs b/listenarr.api/Services/DownloadService.cs index a29b15a..bcf2f9f 100644 --- a/listenarr.api/Services/DownloadService.cs +++ b/listenarr.api/Services/DownloadService.cs @@ -31,6 +31,7 @@ using System.Net; using System.Net.Http; using System.Net.Http.Headers; +using Listenarr.Api.Services.Adapters; namespace Listenarr.Api.Services { @@ -1931,7 +1932,7 @@ public async Task RemoveFromQueueAsync(string downloadId, string? download private async Task> GetQBittorrentQueueAsync(DownloadClientConfiguration client) { - var baseUrl = $"{(client.UseSSL ? "https" : "http")}://{client.Host}:{client.Port}"; + var baseUrl = DownloadClientUriBuilder.BuildAuthority(client); var items = new List(); try diff --git a/tests/Listenarr.Api.Tests/LibraryController_LibraryListSlimPayloadTests.cs b/tests/Listenarr.Api.Tests/LibraryController_LibraryListSlimPayloadTests.cs new file mode 100644 index 0000000..5844d9b --- /dev/null +++ b/tests/Listenarr.Api.Tests/LibraryController_LibraryListSlimPayloadTests.cs @@ -0,0 +1,98 @@ +using System; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Listenarr.Api.Controllers; +using Listenarr.Api.Services; +using Listenarr.Domain.Models; +using Listenarr.Infrastructure.Models; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Listenarr.Api.Tests +{ + public class LibraryController_LibraryListSlimPayloadTests + { + [Fact] + public async Task GetAll_ReturnsSlimPayload_WithServerComputedStatus() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + + using var db = new ListenArrDbContext(options); + + var book = new Audiobook + { + Title = "Slim Book", + Authors = new System.Collections.Generic.List { "Author One" }, + Monitored = true, + Description = "Detail-only field", + Subtitle = "Detail Subtitle", + BasePath = @"C:\library\Slim Book", + FilePath = @"C:\library\Slim Book\book.m4b", + FileSize = 12345, + OpenLibraryId = "OL123", + AuthorAsins = new System.Collections.Generic.List { "AUTHORASIN1" } + }; + db.Audiobooks.Add(book); + await db.SaveChangesAsync(); + + db.AudiobookFiles.Add(new AudiobookFile + { + AudiobookId = book.Id, + Path = book.FilePath, + Size = book.FileSize, + Format = "m4b", + CreatedAt = DateTime.UtcNow + }); + db.Downloads.Add(new Download + { + AudiobookId = book.Id, + Title = book.Title ?? string.Empty, + Artist = "Author One", + Album = book.Title ?? string.Empty, + DownloadClientId = "TEST", + OriginalUrl = "https://example.invalid", + DownloadPath = @"C:\downloads", + FinalPath = book.FilePath ?? string.Empty, + StartedAt = DateTime.UtcNow, + Status = DownloadStatus.Downloading + }); + await db.SaveChangesAsync(); + + using var provider = new ServiceCollection().BuildServiceProvider(); + var controller = new LibraryController( + Mock.Of(), + Mock.Of(), + NullLogger.Instance, + db, + provider.GetRequiredService(), + Mock.Of()); + + var actionResult = await controller.GetAll(); + var ok = Assert.IsType(actionResult); + + var json = JsonSerializer.Serialize(ok.Value, new JsonSerializerOptions(JsonSerializerDefaults.Web)); + using var doc = JsonDocument.Parse(json); + var item = doc.RootElement + .EnumerateArray() + .Single(element => element.GetProperty("id").GetInt32() == book.Id); + + Assert.Equal("downloading", item.GetProperty("status").GetString()); + Assert.False(item.GetProperty("wanted").GetBoolean()); + Assert.True(item.TryGetProperty("openLibraryId", out var openLibraryId)); + Assert.Equal("OL123", openLibraryId.GetString()); + + Assert.False(item.TryGetProperty("files", out _)); + Assert.False(item.TryGetProperty("description", out _)); + Assert.False(item.TryGetProperty("subtitle", out _)); + Assert.False(item.TryGetProperty("filePath", out _)); + Assert.False(item.TryGetProperty("basePath", out _)); + } + } +} diff --git a/tests/Listenarr.Api.Tests/LibraryController_WantedFlagRegressionTests.cs b/tests/Listenarr.Api.Tests/LibraryController_WantedFlagRegressionTests.cs index bdf715a..4f54d4e 100644 --- a/tests/Listenarr.Api.Tests/LibraryController_WantedFlagRegressionTests.cs +++ b/tests/Listenarr.Api.Tests/LibraryController_WantedFlagRegressionTests.cs @@ -54,7 +54,8 @@ public async Task GetAll_TreatsDbFileRecordAsNotWanted_EvenIfPathIsMissing() var actionResult = await controller.GetAll(); var ok = Assert.IsType(actionResult); - using var doc = JsonDocument.Parse(JsonSerializer.Serialize(ok.Value)); + var json = JsonSerializer.Serialize(ok.Value, new JsonSerializerOptions(JsonSerializerDefaults.Web)); + using var doc = JsonDocument.Parse(json); var wanted = doc.RootElement .EnumerateArray() .Single(item => item.GetProperty("id").GetInt32() == book.Id) diff --git a/tests/Listenarr.Api.Tests/NzbgetAdapterTests.cs b/tests/Listenarr.Api.Tests/NzbgetAdapterTests.cs new file mode 100644 index 0000000..8fcd70a --- /dev/null +++ b/tests/Listenarr.Api.Tests/NzbgetAdapterTests.cs @@ -0,0 +1,109 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using Listenarr.Api.Services; +using Listenarr.Api.Services.Adapters; +using Listenarr.Domain.Models; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Listenarr.Api.Tests +{ + public class NzbgetAdapterTests + { + private sealed class TestHttpClientFactory : IHttpClientFactory + { + private readonly HttpClient _client; + + public TestHttpClientFactory(HttpClient client) + { + _client = client; + } + + public HttpClient CreateClient(string name) => _client; + } + + [Fact] + public async Task TestConnectionAsync_NormalizesHostWithSchemeAndPath() + { + Uri? capturedUri = null; + var handler = new DelegatingHandlerMock((req, _) => + { + capturedUri = req.RequestUri; + + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + "25.4") + }); + }); + + using var http = new HttpClient(handler); + var adapter = new NzbgetAdapter( + new TestHttpClientFactory(http), + Mock.Of(), + Mock.Of(), + NullLogger.Instance); + + var client = new DownloadClientConfiguration + { + Host = "http://192.168.50.111/nzbget", + Port = 6789, + UseSSL = false, + Username = "Talis", + Password = "secret" + }; + + var (success, message) = await adapter.TestConnectionAsync(client); + + Assert.True(success); + Assert.Contains("connected", message, StringComparison.OrdinalIgnoreCase); + Assert.NotNull(capturedUri); + Assert.Equal("http", capturedUri!.Scheme); + Assert.Equal("192.168.50.111", capturedUri.Host); + Assert.Equal(6789, capturedUri.Port); + Assert.Equal("/xmlrpc", capturedUri.AbsolutePath); + } + + [Fact] + public async Task TestConnectionAsync_PrefersExplicitPortAndSslOverEmbeddedHostUri() + { + Uri? capturedUri = null; + var handler = new DelegatingHandlerMock((req, _) => + { + capturedUri = req.RequestUri; + + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + "25.4") + }); + }); + + using var http = new HttpClient(handler); + var adapter = new NzbgetAdapter( + new TestHttpClientFactory(http), + Mock.Of(), + Mock.Of(), + NullLogger.Instance); + + var client = new DownloadClientConfiguration + { + Host = "http://192.168.50.111:9999/legacy", + Port = 6789, + UseSSL = true + }; + + var (success, _) = await adapter.TestConnectionAsync(client); + + Assert.True(success); + Assert.NotNull(capturedUri); + Assert.Equal("https", capturedUri!.Scheme); + Assert.Equal("192.168.50.111", capturedUri.Host); + Assert.Equal(6789, capturedUri.Port); + Assert.Equal("/xmlrpc", capturedUri.AbsolutePath); + } + } +} diff --git a/tests/Listenarr.Api.Tests/QbittorrentAdapterTests.cs b/tests/Listenarr.Api.Tests/QbittorrentAdapterTests.cs index 92cff87..f2a74da 100644 --- a/tests/Listenarr.Api.Tests/QbittorrentAdapterTests.cs +++ b/tests/Listenarr.Api.Tests/QbittorrentAdapterTests.cs @@ -114,6 +114,42 @@ public async Task TestConnection_When_VersionForbidden_And_NoCredentials_Returns Assert.Contains("Forbidden", message, StringComparison.OrdinalIgnoreCase); } + [Fact] + public async Task TestConnection_NormalizesHostWithSchemeAndPath() + { + Uri? capturedUri = null; + var handler = new DelegatingHandlerMock((req, ct) => + { + capturedUri = req.RequestUri; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("v5.0.2") + }); + }); + + using var http = new HttpClient(handler); + var factory = new TestHttpClientFactory(http); + var pathMapMock = new Mock(); + var adapter = new QbittorrentAdapter(factory, pathMapMock.Object, Mock.Of(), NullLogger.Instance); + + var cfg = new DownloadClientConfiguration + { + Host = "http://192.168.50.111/qbt", + Port = 8080, + UseSSL = false + }; + + var (success, message) = await adapter.TestConnectionAsync(cfg); + + Assert.True(success); + Assert.Contains("connected", message, StringComparison.OrdinalIgnoreCase); + Assert.NotNull(capturedUri); + Assert.Equal("http", capturedUri!.Scheme); + Assert.Equal("192.168.50.111", capturedUri.Host); + Assert.Equal(8080, capturedUri.Port); + Assert.Equal("/api/v2/app/version", capturedUri.AbsolutePath); + } + [Fact] [Trait("Area", "QbittorrentImportPathResolution")] [Trait("Scenario", "SingleFileResolvesContentFilePath")] diff --git a/tests/Listenarr.Api.Tests/SabnzbdAdapterTests.cs b/tests/Listenarr.Api.Tests/SabnzbdAdapterTests.cs new file mode 100644 index 0000000..3ed818e --- /dev/null +++ b/tests/Listenarr.Api.Tests/SabnzbdAdapterTests.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using Listenarr.Api.Services; +using Listenarr.Api.Services.Adapters; +using Listenarr.Domain.Models; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Listenarr.Api.Tests +{ + public class SabnzbdAdapterTests + { + private sealed class TestHttpClientFactory : IHttpClientFactory + { + private readonly HttpClient _client; + + public TestHttpClientFactory(HttpClient client) + { + _client = client; + } + + public HttpClient CreateClient(string name) => _client; + } + + [Fact] + public async Task TestConnectionAsync_NormalizesHostWithSchemeAndPath() + { + Uri? capturedUri = null; + var handler = new DelegatingHandlerMock((req, ct) => + { + capturedUri = req.RequestUri; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("""{"version":"4.4.1"}""") + }); + }); + + using var http = new HttpClient(handler); + var adapter = new SabnzbdAdapter( + new TestHttpClientFactory(http), + Mock.Of(), + Mock.Of(), + NullLogger.Instance); + + var client = new DownloadClientConfiguration + { + Host = "http://192.168.50.111/sab", + Port = 8080, + UseSSL = false, + Settings = new Dictionary + { + ["apiKey"] = "secret" + } + }; + + var (success, message) = await adapter.TestConnectionAsync(client); + + Assert.True(success); + Assert.Contains("connected", message, StringComparison.OrdinalIgnoreCase); + Assert.NotNull(capturedUri); + Assert.Equal("http", capturedUri!.Scheme); + Assert.Equal("192.168.50.111", capturedUri.Host); + Assert.Equal(8080, capturedUri.Port); + Assert.Equal("/api", capturedUri.AbsolutePath); + Assert.Contains("mode=version", capturedUri.Query, StringComparison.Ordinal); + Assert.Contains("output=json", capturedUri.Query, StringComparison.Ordinal); + } + } +} diff --git a/tests/Listenarr.Api.Tests/TransmissionAdapterTests.cs b/tests/Listenarr.Api.Tests/TransmissionAdapterTests.cs index 6747cc5..16ab62f 100644 --- a/tests/Listenarr.Api.Tests/TransmissionAdapterTests.cs +++ b/tests/Listenarr.Api.Tests/TransmissionAdapterTests.cs @@ -77,5 +77,50 @@ public async Task AddAsync_MagnetWithEncodedTrackerSeparators_DoesNotCorruptTrac "magnet:?xt=urn:btih:ABCDEF1234567890&tr=http%3A%2F%2Ftracker.example.com%2Fannounce%3Ffoo%3D1%26bar%3D2&dn=Book Title", postedFilename); } + + [Fact] + public async Task TestConnectionAsync_NormalizesHostAndRespectsConfiguredRpcPath() + { + Uri? capturedUri = null; + var handler = new DelegatingHandlerMock((req, ct) => + { + capturedUri = req.RequestUri; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("""{"result":"success","arguments":{}}""") + }); + }); + + using var httpClient = new HttpClient(handler); + var adapter = new TransmissionAdapter( + new TestHttpClientFactory(httpClient), + Mock.Of(), + Mock.Of(), + NullLogger.Instance); + + var client = new DownloadClientConfiguration + { + Id = "tr-1", + Name = "Transmission", + Type = "transmission", + Host = "http://192.168.50.111:9999/legacy", + Port = 9091, + UseSSL = true, + Settings = new System.Collections.Generic.Dictionary + { + ["urlBase"] = "/rpc" + } + }; + + var (success, message) = await adapter.TestConnectionAsync(client); + + Assert.True(success); + Assert.Contains("connected", message, StringComparison.OrdinalIgnoreCase); + Assert.NotNull(capturedUri); + Assert.Equal("https", capturedUri!.Scheme); + Assert.Equal("192.168.50.111", capturedUri.Host); + Assert.Equal(9091, capturedUri.Port); + Assert.Equal("/rpc", capturedUri.AbsolutePath); + } } } From 5a17e3182da7df294bf087398781532de107f1a1 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Sun, 8 Mar 2026 18:55:18 -0400 Subject: [PATCH 4/9] Use specific downloads type in test Replace a broad `as any` assertion with `ReturnType['downloads']` in fe/src/__tests__/WantedView.spec.ts to improve type safety for the mocked downloads array. This makes the test's typings accurate and helps catch type-related issues earlier. --- fe/src/__tests__/WantedView.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fe/src/__tests__/WantedView.spec.ts b/fe/src/__tests__/WantedView.spec.ts index c8d3b96..d4bdea9 100644 --- a/fe/src/__tests__/WantedView.spec.ts +++ b/fe/src/__tests__/WantedView.spec.ts @@ -85,7 +85,7 @@ describe('WantedView image recache behavior', () => { startedAt: new Date().toISOString(), metadata: {}, }, - ] as any + ] as ReturnType['downloads'] const wrapper = mount(WantedView, { global: { plugins: [pinia] } }) await new Promise((r) => setTimeout(r, 10)) From 4a6506172face032e2730f5b2f1c445c4e81d922 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Sun, 8 Mar 2026 19:02:13 -0400 Subject: [PATCH 5/9] Add NzbgetAdapter host normalization test Adds GetQueueAsync_NormalizesHostWithSchemeAndPath unit test to NzbgetAdapterTests.cs. The test uses a DelegatingHandlerMock and TestHttpClientFactory to capture the outgoing request URI when the DownloadClientConfiguration.Host contains a scheme and path (e.g. "http://192.168.50.111/nzbget"). It asserts the adapter normalizes the URI to the provided scheme/host, uses the configured port, and targets the "/xmlrpc" path, and that the returned queue is empty. --- .../Listenarr.Api.Tests/NzbgetAdapterTests.cs | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/tests/Listenarr.Api.Tests/NzbgetAdapterTests.cs b/tests/Listenarr.Api.Tests/NzbgetAdapterTests.cs index 8fcd70a..592141d 100644 --- a/tests/Listenarr.Api.Tests/NzbgetAdapterTests.cs +++ b/tests/Listenarr.Api.Tests/NzbgetAdapterTests.cs @@ -105,5 +105,47 @@ public async Task TestConnectionAsync_PrefersExplicitPortAndSslOverEmbeddedHostU Assert.Equal(6789, capturedUri.Port); Assert.Equal("/xmlrpc", capturedUri.AbsolutePath); } + + [Fact] + public async Task GetQueueAsync_NormalizesHostWithSchemeAndPath() + { + Uri? capturedUri = null; + var handler = new DelegatingHandlerMock((req, _) => + { + capturedUri = req.RequestUri; + + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + "") + }); + }); + + using var http = new HttpClient(handler); + var adapter = new NzbgetAdapter( + new TestHttpClientFactory(http), + Mock.Of(), + Mock.Of(), + NullLogger.Instance); + + var client = new DownloadClientConfiguration + { + Host = "http://192.168.50.111/nzbget", + Port = 6789, + UseSSL = false, + Username = "Talis", + Password = "secret" + }; + + var queue = await adapter.GetQueueAsync(client); + + Assert.NotNull(queue); + Assert.Empty(queue); + Assert.NotNull(capturedUri); + Assert.Equal("http", capturedUri!.Scheme); + Assert.Equal("192.168.50.111", capturedUri.Host); + Assert.Equal(6789, capturedUri.Port); + Assert.Equal("/xmlrpc", capturedUri.AbsolutePath); + } } } From fdc3cee5e28a0b7b90eac866c1762658ab53a92e Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Sun, 8 Mar 2026 20:03:22 -0400 Subject: [PATCH 6/9] Add fileCount, torrent helpers, and tests Add support for fileCount across frontend and backend, improve torrent/magnet handling, and expand tests. Frontend: add Audiobook.fileCount, update library store to compute fileCount and wanted/status logic, use fileCount in filter evaluators and custom filters, simplify wantedCount compute, and retry library sync on SignalR reconnect even if initial hydrate failed (new tests added/updated). Backend: include FilePath, FileSize, and FileCount in Library DTO and controller slim list payloads. Download client: introduce DownloadClientUriBuilder helpers (ResolveTorrentAddTarget, NormalizeMagnetLink, TryParseHttpOrHttpsAbsoluteUri) and update qBittorrent/Transmission adapters to uniformly handle magnet vs .torrent URLs and pre-download logic. Tests: add unit tests for audiobook status evaluator and DownloadClientUriBuilder and update existing adapter tests to reuse response objects. --- fe/src/App.vue | 9 +- fe/src/__tests__/AppActivityBadge.spec.ts | 58 +++++++++++++ .../__tests__/customFilterEvaluator.spec.ts | 18 ++++ fe/src/stores/library.ts | 17 +++- fe/src/types/index.ts | 1 + fe/src/utils/audiobookStatus.ts | 4 - fe/src/utils/customFilterEvaluator.ts | 14 +++- fe/src/utils/filterEvaluator.ts | 17 +++- .../Controllers/LibraryController.cs | 7 +- .../Models/LibraryAudiobookListItemDto.cs | 3 + .../Adapters/DownloadClientUriBuilder.cs | 69 ++++++++++++++++ .../Services/Adapters/QbittorrentAdapter.cs | 25 +++--- .../Services/Adapters/TransmissionAdapter.cs | 32 ++++---- .../AudiobookStatusEvaluatorTests.cs | 82 +++++++++++++++++++ .../DownloadClientUriBuilderTests.cs | 51 ++++++++++++ ...yController_LibraryListSlimPayloadTests.cs | 4 +- .../Listenarr.Api.Tests/NzbgetAdapterTests.cs | 36 ++++---- .../QbittorrentAdapterTests.cs | 9 +- .../SabnzbdAdapterTests.cs | 9 +- .../TransmissionAdapterTests.cs | 9 +- 20 files changed, 394 insertions(+), 80 deletions(-) create mode 100644 tests/Listenarr.Api.Tests/AudiobookStatusEvaluatorTests.cs create mode 100644 tests/Listenarr.Api.Tests/DownloadClientUriBuilderTests.cs diff --git a/fe/src/App.vue b/fe/src/App.vue index 84aa19c..9ea996c 100644 --- a/fe/src/App.vue +++ b/fe/src/App.vue @@ -699,12 +699,7 @@ const closeMobileMenu = () => { // Reactive state for badges and counters const notificationCount = computed(() => recentNotifications.filter((n) => !n.dismissed).length) const queueItems = ref([]) -const wantedCount = computed(() => - libraryStore.audiobooks.filter((book) => { - const serverWanted = (book as unknown as Record)['wanted'] - return serverWanted === true - }).length, -) +const wantedCount = computed(() => libraryStore.audiobooks.filter((book) => book.wanted === true).length) const systemIssues = ref(0) // Activity count: Optimized with memoized intermediate computations @@ -1068,7 +1063,7 @@ onMounted(async () => { await Promise.all([downloadsStore.loadDownloads(), syncLibrarySnapshot()]) unsubscribeSignalRConnected = signalRService.onConnected(() => { - if (auth.user.authenticated && libraryStore.audiobooks.length > 0) { + if (auth.user.authenticated) { void syncLibrarySnapshot() } }) diff --git a/fe/src/__tests__/AppActivityBadge.spec.ts b/fe/src/__tests__/AppActivityBadge.spec.ts index 097a10c..5de27b5 100644 --- a/fe/src/__tests__/AppActivityBadge.spec.ts +++ b/fe/src/__tests__/AppActivityBadge.spec.ts @@ -272,4 +272,62 @@ describe('App.vue activity badge', () => { setIntervalSpy.mockRestore() }) + + it('retries library sync on SignalR reconnect even when the initial hydrate fails', async () => { + const getLibrary = vi + .fn() + .mockRejectedValueOnce(new Error('initial load failed')) + .mockResolvedValueOnce([{ id: 1, title: 'Recovered Book', wanted: true }]) + const connectedCallbacks: Array<() => void> = [] + + vi.doMock('@/services/api', () => ({ + apiService: { + getQueue: async () => [], + getServiceHealth: async () => ({ version: '0.0.0' }), + getStartupConfig: async () => ({ authenticationRequired: false }), + getLibrary, + }, + })) + + vi.doMock('@/services/signalr', () => ({ + signalRService: { + connect: vi.fn(async () => undefined), + onConnected: vi.fn((cb: () => void) => { + connectedCallbacks.push(cb) + return () => undefined + }), + onQueueUpdate: vi.fn(() => () => undefined), + onFilesRemoved: vi.fn(() => () => undefined), + onToast: vi.fn(() => () => undefined), + onDownloadUpdate: vi.fn(() => () => undefined), + onDownloadsList: vi.fn(() => () => undefined), + onNotification: vi.fn(() => () => undefined), + }, + })) + + const { default: AppComponent } = await import('@/App.vue') + + const router = createRouter({ + history: createMemoryHistory(), + routes: [{ path: '/', name: 'home', component: { template: '
' } }], + }) + await router.push('/') + await router.isReady().catch(() => {}) + + const wrapper = mount(AppComponent, { + global: { stubs: ['RouterLink', 'RouterView'], plugins: [createPinia(), router] }, + }) + + await new Promise((r) => setTimeout(r, 20)) + + expect(getLibrary).toHaveBeenCalledTimes(1) + expect(connectedCallbacks).toHaveLength(1) + + connectedCallbacks[0]!() + await new Promise((r) => setTimeout(r, 20)) + + const vm = wrapper.vm as unknown as { wantedCount: number } + expect(getLibrary).toHaveBeenCalledTimes(2) + expect(vm.wantedCount).toBe(1) + }) }) diff --git a/fe/src/__tests__/customFilterEvaluator.spec.ts b/fe/src/__tests__/customFilterEvaluator.spec.ts index 14e3f24..8be77d6 100644 --- a/fe/src/__tests__/customFilterEvaluator.spec.ts +++ b/fe/src/__tests__/customFilterEvaluator.spec.ts @@ -52,4 +52,22 @@ describe('customFilterEvaluator - grouping and precedence', () => { const b4 = { ...base, title: 'Gamma', authors: ['No One'] } expect(evaluateRules(b4 as Audiobook, rules)).toBe(false) }) + + it('uses slim list file summary fields for path, filesize, and file count filters', () => { + const slimBook = { + ...base, + files: undefined, + fileCount: 2, + filePath: '/library/Alpha Tales/book.m4b', + fileSize: 5242880, + } as Audiobook + + expect( + evaluateRules(slimBook, [ + { field: 'path', operator: 'contains', value: '/library/alpha tales' }, + { field: 'files', operator: 'eq', value: '2', conjunction: 'and' }, + { field: 'filesize', operator: 'gt', value: '1048576', conjunction: 'and' }, + ]), + ).toBe(true) + }) }) diff --git a/fe/src/stores/library.ts b/fe/src/stores/library.ts index 406127b..718945b 100644 --- a/fe/src/stores/library.ts +++ b/fe/src/stores/library.ts @@ -137,11 +137,22 @@ export const useLibraryStore = defineStore('library', () => { return true }) + const currentFileCount = + typeof book.fileCount === 'number' + ? book.fileCount + : Array.isArray(book.files) + ? book.files.length + : 0 + const nextFileCount = Array.isArray(book.files) + ? newFiles.length + : Math.max(0, currentFileCount - removed.length) + // Clone the audiobook object and update files safely so reactivity notices the change const updated: Audiobook = { ...book, - files: newFiles, - wanted: Boolean(book.monitored) && newFiles.length === 0, + files: Array.isArray(book.files) ? newFiles : undefined, + fileCount: nextFileCount, + wanted: Boolean(book.monitored) && nextFileCount === 0, } // If the current primary filePath was one of the removed paths, clear it (safe behavior) @@ -153,7 +164,7 @@ export const useLibraryStore = defineStore('library', () => { } } - if (updated.wanted) { + if (nextFileCount === 0) { updated.status = 'no-file' } diff --git a/fe/src/types/index.ts b/fe/src/types/index.ts index 8846a5b..8961646 100644 --- a/fe/src/types/index.ts +++ b/fe/src/types/index.ts @@ -422,6 +422,7 @@ export interface Audiobook { monitored?: boolean filePath?: string fileSize?: number + fileCount?: number basePath?: string files?: { id: number diff --git a/fe/src/utils/audiobookStatus.ts b/fe/src/utils/audiobookStatus.ts index 5763773..a9ca114 100644 --- a/fe/src/utils/audiobookStatus.ts +++ b/fe/src/utils/audiobookStatus.ts @@ -97,10 +97,6 @@ export function computeAudiobookStatus( return audiobook.status } - if (audiobook.wanted === true) { - return 'no-file' - } - return 'no-file' } diff --git a/fe/src/utils/customFilterEvaluator.ts b/fe/src/utils/customFilterEvaluator.ts index a9c546e..ddacd53 100644 --- a/fe/src/utils/customFilterEvaluator.ts +++ b/fe/src/utils/customFilterEvaluator.ts @@ -13,6 +13,18 @@ function normalizeString(s: unknown) { return (s ?? '').toString().toLowerCase() } +function resolveFileCount(a: Audiobook): number { + if (Array.isArray(a.files)) { + return a.files.length + } + + if (typeof a.fileCount === 'number' && Number.isFinite(a.fileCount)) { + return a.fileCount + } + + return 0 +} + function evalSingle(a: Audiobook, r: RuleLike): boolean { const field = r.field const op = r.operator @@ -54,7 +66,7 @@ function evalSingle(a: Audiobook, r: RuleLike): boolean { ) break case 'files': - left = String(a.files && a.files.length ? a.files.length : 0) + left = String(resolveFileCount(a)) break case 'filesize': left = String((a as unknown as Record)['fileSize'] ?? '') diff --git a/fe/src/utils/filterEvaluator.ts b/fe/src/utils/filterEvaluator.ts index d5ce5de..5735e35 100644 --- a/fe/src/utils/filterEvaluator.ts +++ b/fe/src/utils/filterEvaluator.ts @@ -15,6 +15,20 @@ function toLower(s: unknown) { const numericFields = new Set(['publishYear', 'publishedYear', 'files', 'filesize']) +function resolveFileCount(b: Record): number { + const files = b['files'] + if (Array.isArray(files)) { + return files.length + } + + const fileCount = b['fileCount'] + if (typeof fileCount === 'number' && Number.isFinite(fileCount)) { + return fileCount + } + + return 0 +} + function evalSingleRule(rule: Rule, b: Record): boolean { const field = rule.field || '' const op = rule.operator || 'contains' @@ -59,8 +73,7 @@ function evalSingleRule(rule: Rule, b: Record): boolean { ) break case 'files': { - const files = ((b as Record)['files'] as unknown[]) || [] - left = String(files.length) + left = String(resolveFileCount(b)) break } case 'filesize': diff --git a/listenarr.api/Controllers/LibraryController.cs b/listenarr.api/Controllers/LibraryController.cs index 10bb1ff..bb64f3d 100644 --- a/listenarr.api/Controllers/LibraryController.cs +++ b/listenarr.api/Controllers/LibraryController.cs @@ -599,6 +599,8 @@ public async Task GetAll() a.Runtime, a.ImageUrl, a.Monitored, + a.FilePath, + a.FileSize, a.Quality, a.QualityProfileId, a.AuthorAsins @@ -690,6 +692,9 @@ public async Task GetAll() Runtime = a.Runtime, ImageUrl = a.ImageUrl, Monitored = a.Monitored, + FilePath = a.FilePath, + FileSize = a.FileSize, + FileCount = files?.Count ?? 0, Quality = a.Quality, QualityProfileId = a.QualityProfileId, AuthorAsins = a.AuthorAsins?.ToArray(), @@ -732,7 +737,7 @@ public async Task GetByIsbn(string isbn) } /// - /// Get a single audiobook by its database ID, including files, quality profile, external identifiers, and wanted status. + /// Get a single audiobook by its database ID, including files, external identifiers, and wanted status. /// /// Audiobook ID. [HttpGet("{id}")] diff --git a/listenarr.api/Models/LibraryAudiobookListItemDto.cs b/listenarr.api/Models/LibraryAudiobookListItemDto.cs index 2636a98..064e5e8 100644 --- a/listenarr.api/Models/LibraryAudiobookListItemDto.cs +++ b/listenarr.api/Models/LibraryAudiobookListItemDto.cs @@ -19,6 +19,9 @@ public class LibraryAudiobookListItemDto public int? Runtime { get; set; } public string? ImageUrl { get; set; } public bool Monitored { get; set; } + public string? FilePath { get; set; } + public long? FileSize { get; set; } + public int FileCount { get; set; } public string? Quality { get; set; } public int? QualityProfileId { get; set; } public string[]? AuthorAsins { get; set; } diff --git a/listenarr.api/Services/Adapters/DownloadClientUriBuilder.cs b/listenarr.api/Services/Adapters/DownloadClientUriBuilder.cs index 916f2ef..0d8fc55 100644 --- a/listenarr.api/Services/Adapters/DownloadClientUriBuilder.cs +++ b/listenarr.api/Services/Adapters/DownloadClientUriBuilder.cs @@ -3,6 +3,8 @@ namespace Listenarr.Api.Services.Adapters { + internal readonly record struct TorrentAddTarget(string Value, bool IsMagnet); + internal static class DownloadClientUriBuilder { public static string BuildAuthority(DownloadClientConfiguration client) @@ -10,6 +12,73 @@ public static string BuildAuthority(DownloadClientConfiguration client) return BuildUri(client, "/").GetLeftPart(UriPartial.Authority); } + public static TorrentAddTarget ResolveTorrentAddTarget(SearchResult result) + { + if (result == null) + { + throw new ArgumentNullException(nameof(result)); + } + + var magnetLink = NormalizeMagnetLink(result.MagnetLink); + if (!string.IsNullOrEmpty(magnetLink)) + { + return new TorrentAddTarget(magnetLink, true); + } + + var torrentUrl = (result.TorrentUrl ?? string.Empty).Trim(); + if (string.IsNullOrEmpty(torrentUrl)) + { + throw new ArgumentException("No magnet link or torrent URL provided", nameof(result)); + } + + if (!TryParseHttpOrHttpsAbsoluteUri(torrentUrl, out var torrentUri)) + { + throw new ArgumentException("Torrent URL must be an absolute HTTP or HTTPS URL.", nameof(result)); + } + + return new TorrentAddTarget(torrentUri!.ToString(), false); + } + + public static string NormalizeMagnetLink(string? magnetLink) + { + var trimmed = (magnetLink ?? string.Empty).Trim(); + if (trimmed.Length == 0) + { + return string.Empty; + } + + if (!trimmed.StartsWith("magnet:", StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException("Magnet link must use the magnet scheme.", nameof(magnetLink)); + } + + return trimmed; + } + + public static bool TryParseHttpOrHttpsAbsoluteUri(string? value, out Uri? uri) + { + uri = null; + var trimmed = (value ?? string.Empty).Trim(); + if (trimmed.Length == 0) + { + return false; + } + + if (!Uri.TryCreate(trimmed, UriKind.Absolute, out var parsed)) + { + return false; + } + + if (!parsed.Scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) && + !parsed.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + uri = parsed; + return true; + } + public static Uri BuildUri( DownloadClientConfiguration client, string path, diff --git a/listenarr.api/Services/Adapters/QbittorrentAdapter.cs b/listenarr.api/Services/Adapters/QbittorrentAdapter.cs index af95180..c004896 100644 --- a/listenarr.api/Services/Adapters/QbittorrentAdapter.cs +++ b/listenarr.api/Services/Adapters/QbittorrentAdapter.cs @@ -221,19 +221,19 @@ async Task PostLoginWithAgent(string userAgent) if (client == null) throw new ArgumentNullException(nameof(client)); if (result == null) throw new ArgumentNullException(nameof(result)); - var torrentUrl = !string.IsNullOrEmpty(result.MagnetLink) ? result.MagnetLink : result.TorrentUrl; - if (string.IsNullOrEmpty(torrentUrl)) - throw new ArgumentException("No magnet link or torrent URL provided", nameof(result)); + var torrentTarget = DownloadClientUriBuilder.ResolveTorrentAddTarget(result); + string torrentUrl = torrentTarget.Value; + var isMagnetTarget = torrentTarget.IsMagnet; var baseUrl = DownloadClientUriBuilder.BuildAuthority(client); string? extractedHash = null; - if (!string.IsNullOrEmpty(result.MagnetLink) && result.MagnetLink.Contains("xt=urn:btih:", StringComparison.OrdinalIgnoreCase)) + if (isMagnetTarget && torrentUrl.Contains("xt=urn:btih:", StringComparison.OrdinalIgnoreCase)) { - var start = result.MagnetLink.IndexOf("xt=urn:btih:", StringComparison.OrdinalIgnoreCase) + "xt=urn:btih:".Length; - var end = result.MagnetLink.IndexOf('&', start); - if (end == -1) end = result.MagnetLink.Length; - extractedHash = result.MagnetLink[start..end].ToLowerInvariant(); + var start = torrentUrl.IndexOf("xt=urn:btih:", StringComparison.OrdinalIgnoreCase) + "xt=urn:btih:".Length; + var end = torrentUrl.IndexOf('&', start); + if (end == -1) end = torrentUrl.Length; + extractedHash = torrentUrl[start..end].ToLowerInvariant(); } try @@ -326,10 +326,8 @@ async Task PostLoginWithAgent(string userAgent) // which can fail if the source requires authentication the client lacks. byte[]? torrentFileData = result.TorrentFileContent; if ((torrentFileData == null || torrentFileData.Length == 0) && - !torrentUrl.StartsWith("magnet:", StringComparison.OrdinalIgnoreCase) && - Uri.TryCreate(torrentUrl, UriKind.Absolute, out var torrentUri) && - (torrentUri.Scheme.Equals("http", StringComparison.OrdinalIgnoreCase) || - torrentUri.Scheme.Equals("https", StringComparison.OrdinalIgnoreCase))) + !isMagnetTarget && + DownloadClientUriBuilder.TryParseHttpOrHttpsAbsoluteUri(torrentUrl, out _)) { try { @@ -343,7 +341,8 @@ async Task PostLoginWithAgent(string userAgent) else if (downloadResult.HasMagnet) { // Indexer redirected to a magnet link — use it as the torrent URL instead - torrentUrl = downloadResult.MagnetUri!; + torrentUrl = DownloadClientUriBuilder.NormalizeMagnetLink(downloadResult.MagnetUri); + isMagnetTarget = true; _logger.LogInformation("Indexer redirected to magnet link for '{Title}'", LogRedaction.SanitizeText(result.Title)); } } diff --git a/listenarr.api/Services/Adapters/TransmissionAdapter.cs b/listenarr.api/Services/Adapters/TransmissionAdapter.cs index c3f1ce5..2654a48 100644 --- a/listenarr.api/Services/Adapters/TransmissionAdapter.cs +++ b/listenarr.api/Services/Adapters/TransmissionAdapter.cs @@ -90,13 +90,15 @@ public TransmissionAdapter(IHttpClientFactory httpClientFactory, IRemotePathMapp // Prefer cached torrent file data over URL (required for private trackers with authentication) byte[]? torrentFileData = result.TorrentFileContent; - var torrentUrl = !string.IsNullOrEmpty(result.MagnetLink) ? result.MagnetLink : result.TorrentUrl; + var torrentTarget = DownloadClientUriBuilder.ResolveTorrentAddTarget(result); + string torrentUrl = torrentTarget.Value; + var isMagnetTarget = torrentTarget.IsMagnet; _logger.LogDebug("AddAsync entry for '{Title}': TorrentFileContent={HasContent}, MagnetLink={HasMagnet}, TorrentUrl={Url}", LogRedaction.SanitizeText(result.Title), result.TorrentFileContent != null && result.TorrentFileContent.Length > 0 ? $"{result.TorrentFileContent.Length} bytes" : "null", - !string.IsNullOrEmpty(result.MagnetLink) ? "yes" : "no", - LogRedaction.SanitizeUrl(torrentUrl ?? "(null)")); + isMagnetTarget ? "yes" : "no", + LogRedaction.SanitizeUrl(torrentUrl)); // Transmission's magnet link handling is less reliable than qBittorrent's — it // often stalls at "Downloading metadata..." because its DHT/tracker resolution is @@ -105,19 +107,15 @@ public TransmissionAdapter(IHttpClientFactory httpClientFactory, IRemotePathMapp // full tracker lists and piece hashes, giving Transmission everything it needs to // start immediately without metadata resolution. if ((torrentFileData == null || torrentFileData.Length == 0) && - !string.IsNullOrEmpty(torrentUrl) && - torrentUrl.StartsWith("magnet:", StringComparison.OrdinalIgnoreCase) && - !string.IsNullOrEmpty(result.TorrentUrl) && - !result.TorrentUrl.StartsWith("magnet:", StringComparison.OrdinalIgnoreCase) && - Uri.TryCreate(result.TorrentUrl, UriKind.Absolute, out var altUri) && - (altUri.Scheme.Equals("http", StringComparison.OrdinalIgnoreCase) || - altUri.Scheme.Equals("https", StringComparison.OrdinalIgnoreCase))) + isMagnetTarget && + DownloadClientUriBuilder.TryParseHttpOrHttpsAbsoluteUri(result.TorrentUrl, out _)) { + var alternateTorrentUrl = result.TorrentUrl!.Trim(); _logger.LogDebug("Magnet link available but TorrentUrl also present — attempting .torrent pre-download from {Url} for better Transmission compatibility", - LogRedaction.SanitizeUrl(result.TorrentUrl)); + LogRedaction.SanitizeUrl(alternateTorrentUrl)); try { - var altResult = await _torrentFileDownloader.DownloadAsync(result.TorrentUrl, ct); + var altResult = await _torrentFileDownloader.DownloadAsync(alternateTorrentUrl, ct); if (altResult.HasBytes) { torrentFileData = altResult.TorrentBytes; @@ -140,11 +138,8 @@ public TransmissionAdapter(IHttpClientFactory httpClientFactory, IRemotePathMapp // (e.g. Prowlarr returning 301), so we fetch the .torrent file ourselves and send // the raw bytes via the metainfo field instead. if ((torrentFileData == null || torrentFileData.Length == 0) && - !string.IsNullOrEmpty(torrentUrl) && - !torrentUrl.StartsWith("magnet:", StringComparison.OrdinalIgnoreCase) && - Uri.TryCreate(torrentUrl, UriKind.Absolute, out var torrentUri) && - (torrentUri.Scheme.Equals("http", StringComparison.OrdinalIgnoreCase) || - torrentUri.Scheme.Equals("https", StringComparison.OrdinalIgnoreCase))) + !isMagnetTarget && + DownloadClientUriBuilder.TryParseHttpOrHttpsAbsoluteUri(torrentUrl, out _)) { _logger.LogDebug("Attempting pre-download of torrent file from {Url}", LogRedaction.SanitizeUrl(torrentUrl)); try @@ -159,7 +154,8 @@ public TransmissionAdapter(IHttpClientFactory httpClientFactory, IRemotePathMapp else if (downloadResult.HasMagnet) { // Indexer redirected to a magnet link — use it directly - torrentUrl = downloadResult.MagnetUri!; + torrentUrl = DownloadClientUriBuilder.NormalizeMagnetLink(downloadResult.MagnetUri); + isMagnetTarget = true; _logger.LogInformation("Indexer redirected to magnet link for '{Title}'", LogRedaction.SanitizeText(result.Title)); } else diff --git a/tests/Listenarr.Api.Tests/AudiobookStatusEvaluatorTests.cs b/tests/Listenarr.Api.Tests/AudiobookStatusEvaluatorTests.cs new file mode 100644 index 0000000..ea73a24 --- /dev/null +++ b/tests/Listenarr.Api.Tests/AudiobookStatusEvaluatorTests.cs @@ -0,0 +1,82 @@ +using System.Collections.Generic; +using Listenarr.Api.Services; +using Listenarr.Domain.Models; +using Xunit; + +namespace Listenarr.Api.Tests +{ + public class AudiobookStatusEvaluatorTests + { + [Fact] + public void ComputeStatus_ReturnsNoFile_WhenWanted() + { + var status = AudiobookStatusEvaluator.ComputeStatus( + isDownloading: false, + wanted: true, + hasAnyFile: false, + audiobookQuality: null, + qualityProfile: null, + files: null); + + Assert.Equal(AudiobookStatusEvaluator.NoFile, status); + } + + [Fact] + public void ComputeStatus_ReturnsQualityMismatch_WhenNoFilesMatchPreferredFormats() + { + var profile = CreateProfile(cutoffQuality: "256kbps", preferredFormats: new List { "m4b" }); + var files = new List + { + new() { Format = "mp3", Bitrate = 320000 } + }; + + var status = AudiobookStatusEvaluator.ComputeStatus(false, false, true, null, profile, files); + + Assert.Equal(AudiobookStatusEvaluator.QualityMismatch, status); + } + + [Fact] + public void ComputeStatus_ReturnsQualityMatch_WhenDerivedQualityMeetsCutoffBoundary() + { + var profile = CreateProfile(cutoffQuality: "256kbps", preferredFormats: new List { "m4b" }); + var files = new List + { + new() { Format = "m4b", Bitrate = 256000 } + }; + + var status = AudiobookStatusEvaluator.ComputeStatus(false, false, true, null, profile, files); + + Assert.Equal(AudiobookStatusEvaluator.QualityMatch, status); + } + + [Fact] + public void ComputeStatus_ReturnsQualityMismatch_WhenDerivedQualityIsBelowCutoff() + { + var profile = CreateProfile(cutoffQuality: "256kbps", preferredFormats: new List { "m4b" }); + var files = new List + { + new() { Format = "m4b", Bitrate = 192000 } + }; + + var status = AudiobookStatusEvaluator.ComputeStatus(false, false, true, null, profile, files); + + Assert.Equal(AudiobookStatusEvaluator.QualityMismatch, status); + } + + private static QualityProfile CreateProfile(string cutoffQuality, List preferredFormats) + { + return new QualityProfile + { + Name = "Test Profile", + CutoffQuality = cutoffQuality, + PreferredFormats = preferredFormats, + Qualities = new List + { + new() { Quality = "320kbps", Priority = 0 }, + new() { Quality = "256kbps", Priority = 1 }, + new() { Quality = "192kbps", Priority = 2 } + } + }; + } + } +} diff --git a/tests/Listenarr.Api.Tests/DownloadClientUriBuilderTests.cs b/tests/Listenarr.Api.Tests/DownloadClientUriBuilderTests.cs new file mode 100644 index 0000000..24568e2 --- /dev/null +++ b/tests/Listenarr.Api.Tests/DownloadClientUriBuilderTests.cs @@ -0,0 +1,51 @@ +using System; +using Listenarr.Api.Services.Adapters; +using Listenarr.Domain.Models; +using Xunit; + +namespace Listenarr.Api.Tests +{ + public class DownloadClientUriBuilderTests + { + [Fact] + public void ResolveTorrentAddTarget_PrefersValidatedMagnetLink() + { + var result = new SearchResult + { + MagnetLink = " magnet:?xt=urn:btih:abc123 ", + TorrentUrl = "https://example.com/file.torrent" + }; + + var target = DownloadClientUriBuilder.ResolveTorrentAddTarget(result); + + Assert.True(target.IsMagnet); + Assert.Equal("magnet:?xt=urn:btih:abc123", target.Value); + } + + [Fact] + public void ResolveTorrentAddTarget_RejectsInvalidMagnetScheme() + { + var result = new SearchResult + { + MagnetLink = "https://example.com/not-a-magnet" + }; + + var ex = Assert.Throws(() => DownloadClientUriBuilder.ResolveTorrentAddTarget(result)); + + Assert.Contains("magnet scheme", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ResolveTorrentAddTarget_RejectsNonHttpTorrentUrl() + { + var result = new SearchResult + { + TorrentUrl = "ftp://example.com/file.torrent" + }; + + var ex = Assert.Throws(() => DownloadClientUriBuilder.ResolveTorrentAddTarget(result)); + + Assert.Contains("HTTP or HTTPS", ex.Message, StringComparison.OrdinalIgnoreCase); + } + } +} diff --git a/tests/Listenarr.Api.Tests/LibraryController_LibraryListSlimPayloadTests.cs b/tests/Listenarr.Api.Tests/LibraryController_LibraryListSlimPayloadTests.cs index 5844d9b..b26f379 100644 --- a/tests/Listenarr.Api.Tests/LibraryController_LibraryListSlimPayloadTests.cs +++ b/tests/Listenarr.Api.Tests/LibraryController_LibraryListSlimPayloadTests.cs @@ -87,11 +87,13 @@ public async Task GetAll_ReturnsSlimPayload_WithServerComputedStatus() Assert.False(item.GetProperty("wanted").GetBoolean()); Assert.True(item.TryGetProperty("openLibraryId", out var openLibraryId)); Assert.Equal("OL123", openLibraryId.GetString()); + Assert.Equal(book.FilePath, item.GetProperty("filePath").GetString()); + Assert.Equal(book.FileSize, item.GetProperty("fileSize").GetInt64()); + Assert.Equal(1, item.GetProperty("fileCount").GetInt32()); Assert.False(item.TryGetProperty("files", out _)); Assert.False(item.TryGetProperty("description", out _)); Assert.False(item.TryGetProperty("subtitle", out _)); - Assert.False(item.TryGetProperty("filePath", out _)); Assert.False(item.TryGetProperty("basePath", out _)); } } diff --git a/tests/Listenarr.Api.Tests/NzbgetAdapterTests.cs b/tests/Listenarr.Api.Tests/NzbgetAdapterTests.cs index 592141d..7072240 100644 --- a/tests/Listenarr.Api.Tests/NzbgetAdapterTests.cs +++ b/tests/Listenarr.Api.Tests/NzbgetAdapterTests.cs @@ -29,15 +29,15 @@ public TestHttpClientFactory(HttpClient client) public async Task TestConnectionAsync_NormalizesHostWithSchemeAndPath() { Uri? capturedUri = null; + using var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + "25.4") + }; var handler = new DelegatingHandlerMock((req, _) => { capturedUri = req.RequestUri; - - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent( - "25.4") - }); + return Task.FromResult(response); }); using var http = new HttpClient(handler); @@ -71,15 +71,15 @@ public async Task TestConnectionAsync_NormalizesHostWithSchemeAndPath() public async Task TestConnectionAsync_PrefersExplicitPortAndSslOverEmbeddedHostUri() { Uri? capturedUri = null; + using var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + "25.4") + }; var handler = new DelegatingHandlerMock((req, _) => { capturedUri = req.RequestUri; - - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent( - "25.4") - }); + return Task.FromResult(response); }); using var http = new HttpClient(handler); @@ -110,15 +110,15 @@ public async Task TestConnectionAsync_PrefersExplicitPortAndSslOverEmbeddedHostU public async Task GetQueueAsync_NormalizesHostWithSchemeAndPath() { Uri? capturedUri = null; + using var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + "") + }; var handler = new DelegatingHandlerMock((req, _) => { capturedUri = req.RequestUri; - - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent( - "") - }); + return Task.FromResult(response); }); using var http = new HttpClient(handler); diff --git a/tests/Listenarr.Api.Tests/QbittorrentAdapterTests.cs b/tests/Listenarr.Api.Tests/QbittorrentAdapterTests.cs index f2a74da..989c435 100644 --- a/tests/Listenarr.Api.Tests/QbittorrentAdapterTests.cs +++ b/tests/Listenarr.Api.Tests/QbittorrentAdapterTests.cs @@ -118,13 +118,14 @@ public async Task TestConnection_When_VersionForbidden_And_NoCredentials_Returns public async Task TestConnection_NormalizesHostWithSchemeAndPath() { Uri? capturedUri = null; + using var okResponse = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("v5.0.2") + }; var handler = new DelegatingHandlerMock((req, ct) => { capturedUri = req.RequestUri; - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent("v5.0.2") - }); + return Task.FromResult(okResponse); }); using var http = new HttpClient(handler); diff --git a/tests/Listenarr.Api.Tests/SabnzbdAdapterTests.cs b/tests/Listenarr.Api.Tests/SabnzbdAdapterTests.cs index 3ed818e..7ad2a1d 100644 --- a/tests/Listenarr.Api.Tests/SabnzbdAdapterTests.cs +++ b/tests/Listenarr.Api.Tests/SabnzbdAdapterTests.cs @@ -30,13 +30,14 @@ public TestHttpClientFactory(HttpClient client) public async Task TestConnectionAsync_NormalizesHostWithSchemeAndPath() { Uri? capturedUri = null; + using var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("""{"version":"4.4.1"}""") + }; var handler = new DelegatingHandlerMock((req, ct) => { capturedUri = req.RequestUri; - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent("""{"version":"4.4.1"}""") - }); + return Task.FromResult(response); }); using var http = new HttpClient(handler); diff --git a/tests/Listenarr.Api.Tests/TransmissionAdapterTests.cs b/tests/Listenarr.Api.Tests/TransmissionAdapterTests.cs index 16ab62f..ecd806b 100644 --- a/tests/Listenarr.Api.Tests/TransmissionAdapterTests.cs +++ b/tests/Listenarr.Api.Tests/TransmissionAdapterTests.cs @@ -82,13 +82,14 @@ public async Task AddAsync_MagnetWithEncodedTrackerSeparators_DoesNotCorruptTrac public async Task TestConnectionAsync_NormalizesHostAndRespectsConfiguredRpcPath() { Uri? capturedUri = null; + using var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("""{"result":"success","arguments":{}}""") + }; var handler = new DelegatingHandlerMock((req, ct) => { capturedUri = req.RequestUri; - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent("""{"result":"success","arguments":{}}""") - }); + return Task.FromResult(response); }); using var httpClient = new HttpClient(handler); From f6eb78b704541f5d666ff203a036ffee858b95cb Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Sun, 8 Mar 2026 20:14:35 -0400 Subject: [PATCH 7/9] Don't set isMagnetTarget for magnet redirects Clear isMagnetTarget assignment when an indexer redirects to a magnet link in QbittorrentAdapter and TransmissionAdapter. The code now normalizes and logs the magnet URL without toggling the isMagnetTarget flag to avoid carrying redundant or potentially incorrect state into downstream logic. --- listenarr.api/Services/Adapters/QbittorrentAdapter.cs | 1 - listenarr.api/Services/Adapters/TransmissionAdapter.cs | 1 - 2 files changed, 2 deletions(-) diff --git a/listenarr.api/Services/Adapters/QbittorrentAdapter.cs b/listenarr.api/Services/Adapters/QbittorrentAdapter.cs index c004896..79aacd6 100644 --- a/listenarr.api/Services/Adapters/QbittorrentAdapter.cs +++ b/listenarr.api/Services/Adapters/QbittorrentAdapter.cs @@ -342,7 +342,6 @@ async Task PostLoginWithAgent(string userAgent) { // Indexer redirected to a magnet link — use it as the torrent URL instead torrentUrl = DownloadClientUriBuilder.NormalizeMagnetLink(downloadResult.MagnetUri); - isMagnetTarget = true; _logger.LogInformation("Indexer redirected to magnet link for '{Title}'", LogRedaction.SanitizeText(result.Title)); } } diff --git a/listenarr.api/Services/Adapters/TransmissionAdapter.cs b/listenarr.api/Services/Adapters/TransmissionAdapter.cs index 2654a48..5aeb074 100644 --- a/listenarr.api/Services/Adapters/TransmissionAdapter.cs +++ b/listenarr.api/Services/Adapters/TransmissionAdapter.cs @@ -155,7 +155,6 @@ public TransmissionAdapter(IHttpClientFactory httpClientFactory, IRemotePathMapp { // Indexer redirected to a magnet link — use it directly torrentUrl = DownloadClientUriBuilder.NormalizeMagnetLink(downloadResult.MagnetUri); - isMagnetTarget = true; _logger.LogInformation("Indexer redirected to magnet link for '{Title}'", LogRedaction.SanitizeText(result.Title)); } else From 9fdade9d4b325afec0eb72de731cc9d9b1ee2867 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Sun, 8 Mar 2026 20:22:16 -0400 Subject: [PATCH 8/9] Prefer normalized magnet or HTTP torrent and extract hash Normalize and prefer a validated magnet link or an HTTP(S) torrent URL when adding torrents. Parse and normalize the magnet link, attempt to parse an absolute HTTP/HTTPS torrent URL, and prefer the HTTP URL for pre-downloading torrent bytes (so authenticated/private-tracker content can be added via file data). Use the downloaded result's magnet redirect when necessary and set torrentUrl appropriately. Move inline magnet-hash extraction into a new TryExtractMagnetHash helper and throw if neither a magnet nor a torrent URL is available. Adjusted download call to use the validated httpTorrentUrl and simplified related logic. --- .../Services/Adapters/QbittorrentAdapter.cs | 58 +++++++++++++------ 1 file changed, 39 insertions(+), 19 deletions(-) diff --git a/listenarr.api/Services/Adapters/QbittorrentAdapter.cs b/listenarr.api/Services/Adapters/QbittorrentAdapter.cs index 79aacd6..98de441 100644 --- a/listenarr.api/Services/Adapters/QbittorrentAdapter.cs +++ b/listenarr.api/Services/Adapters/QbittorrentAdapter.cs @@ -221,21 +221,13 @@ async Task PostLoginWithAgent(string userAgent) if (client == null) throw new ArgumentNullException(nameof(client)); if (result == null) throw new ArgumentNullException(nameof(result)); - var torrentTarget = DownloadClientUriBuilder.ResolveTorrentAddTarget(result); - string torrentUrl = torrentTarget.Value; - var isMagnetTarget = torrentTarget.IsMagnet; + var magnetLink = DownloadClientUriBuilder.NormalizeMagnetLink(result.MagnetLink); + var hasHttpTorrentUrl = DownloadClientUriBuilder.TryParseHttpOrHttpsAbsoluteUri(result.TorrentUrl, out var torrentUri); + string? httpTorrentUrl = torrentUri?.ToString(); + string torrentUrl = magnetLink.Length > 0 ? magnetLink : httpTorrentUrl ?? string.Empty; var baseUrl = DownloadClientUriBuilder.BuildAuthority(client); - string? extractedHash = null; - if (isMagnetTarget && torrentUrl.Contains("xt=urn:btih:", StringComparison.OrdinalIgnoreCase)) - { - var start = torrentUrl.IndexOf("xt=urn:btih:", StringComparison.OrdinalIgnoreCase) + "xt=urn:btih:".Length; - var end = torrentUrl.IndexOf('&', start); - if (end == -1) end = torrentUrl.Length; - extractedHash = torrentUrl[start..end].ToLowerInvariant(); - } - try { var cookieJar = new CookieContainer(); @@ -321,17 +313,17 @@ async Task PostLoginWithAgent(string userAgent) } HttpResponseMessage addResponse; - // Pre-download torrent file if not cached and URL is HTTP(S) (not magnet). - // This avoids the download client needing to fetch from the source URL, - // which can fail if the source requires authentication the client lacks. + // Prefer a validated HTTP(S) torrent URL when one exists so we can add + // authenticated/private-tracker content via bytes and only fall back to + // a magnet when no file data can be obtained. byte[]? torrentFileData = result.TorrentFileContent; if ((torrentFileData == null || torrentFileData.Length == 0) && - !isMagnetTarget && - DownloadClientUriBuilder.TryParseHttpOrHttpsAbsoluteUri(torrentUrl, out _)) + hasHttpTorrentUrl && + !string.IsNullOrEmpty(httpTorrentUrl)) { try { - var downloadResult = await _torrentFileDownloader.DownloadAsync(torrentUrl, ct); + var downloadResult = await _torrentFileDownloader.DownloadAsync(httpTorrentUrl, ct); if (downloadResult.HasBytes) { torrentFileData = downloadResult.TorrentBytes; @@ -341,7 +333,7 @@ async Task PostLoginWithAgent(string userAgent) else if (downloadResult.HasMagnet) { // Indexer redirected to a magnet link — use it as the torrent URL instead - torrentUrl = DownloadClientUriBuilder.NormalizeMagnetLink(downloadResult.MagnetUri); + magnetLink = DownloadClientUriBuilder.NormalizeMagnetLink(downloadResult.MagnetUri); _logger.LogInformation("Indexer redirected to magnet link for '{Title}'", LogRedaction.SanitizeText(result.Title)); } } @@ -351,6 +343,20 @@ async Task PostLoginWithAgent(string userAgent) } } + if (magnetLink.Length > 0) + { + torrentUrl = magnetLink; + } + else if (!string.IsNullOrEmpty(httpTorrentUrl)) + { + torrentUrl = httpTorrentUrl; + } + + if ((torrentFileData == null || torrentFileData.Length == 0) && string.IsNullOrEmpty(torrentUrl)) + throw new ArgumentException("No magnet link or torrent URL provided", nameof(result)); + + var extractedHash = TryExtractMagnetHash(torrentUrl); + if (torrentFileData != null && torrentFileData.Length > 0) { using var multipart = new MultipartFormDataContent(); @@ -444,6 +450,20 @@ async Task PostLoginWithAgent(string userAgent) } } + private static string? TryExtractMagnetHash(string? torrentUrl) + { + if (string.IsNullOrEmpty(torrentUrl) || + !torrentUrl.Contains("xt=urn:btih:", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + var start = torrentUrl.IndexOf("xt=urn:btih:", StringComparison.OrdinalIgnoreCase) + "xt=urn:btih:".Length; + var end = torrentUrl.IndexOf('&', start); + if (end == -1) end = torrentUrl.Length; + return torrentUrl[start..end].ToLowerInvariant(); + } + /// /// Marks a torrent as imported by changing its category to the configured post-import category. /// This allows users to differentiate imported vs active torrents in qBittorrent. From 90e44a8c5154ccdf12797c29db11ed1f27aa43c3 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Sun, 8 Mar 2026 20:43:16 -0400 Subject: [PATCH 9/9] Normalize torrent URLs and pre-download logic Introduce NormalizeTorrentUrl helper in qBittorrent and Transmission adapters to validate/normalize HTTP(S) torrent URLs and centralize parsing. Refactor pre-download flow: prefer pre-downloading a valid HTTP(S) .torrent when present (even if a magnet exists), consolidate torrentUrl selection, and add TryPredownloadTorrentFileAsync with safe exception handling and logging. Update tests to cover pre-download behavior and invalid-scheme validation, plus add test helpers for HTTP listener and response writing. This prevents accidental network calls for invalid schemes and reduces duplicated logic. --- .../Services/Adapters/QbittorrentAdapter.cs | 77 ++++++---- .../Services/Adapters/TransmissionAdapter.cs | 36 +++-- .../QbittorrentAdapterTests.cs | 133 ++++++++++++++++++ .../TransmissionAdapterTests.cs | 89 ++++++++++++ 4 files changed, 295 insertions(+), 40 deletions(-) diff --git a/listenarr.api/Services/Adapters/QbittorrentAdapter.cs b/listenarr.api/Services/Adapters/QbittorrentAdapter.cs index 98de441..f44f852 100644 --- a/listenarr.api/Services/Adapters/QbittorrentAdapter.cs +++ b/listenarr.api/Services/Adapters/QbittorrentAdapter.cs @@ -222,9 +222,7 @@ async Task PostLoginWithAgent(string userAgent) if (result == null) throw new ArgumentNullException(nameof(result)); var magnetLink = DownloadClientUriBuilder.NormalizeMagnetLink(result.MagnetLink); - var hasHttpTorrentUrl = DownloadClientUriBuilder.TryParseHttpOrHttpsAbsoluteUri(result.TorrentUrl, out var torrentUri); - string? httpTorrentUrl = torrentUri?.ToString(); - string torrentUrl = magnetLink.Length > 0 ? magnetLink : httpTorrentUrl ?? string.Empty; + var httpTorrentUrl = NormalizeTorrentUrl(result.TorrentUrl); var baseUrl = DownloadClientUriBuilder.BuildAuthority(client); @@ -317,40 +315,25 @@ async Task PostLoginWithAgent(string userAgent) // authenticated/private-tracker content via bytes and only fall back to // a magnet when no file data can be obtained. byte[]? torrentFileData = result.TorrentFileContent; - if ((torrentFileData == null || torrentFileData.Length == 0) && - hasHttpTorrentUrl && - !string.IsNullOrEmpty(httpTorrentUrl)) + if (torrentFileData == null || torrentFileData.Length == 0) { - try + var downloadResult = await TryPredownloadTorrentFileAsync(httpTorrentUrl, result.Title, ct); + if (downloadResult.HasBytes) { - var downloadResult = await _torrentFileDownloader.DownloadAsync(httpTorrentUrl, ct); - if (downloadResult.HasBytes) - { - torrentFileData = downloadResult.TorrentBytes; - _logger.LogInformation("Pre-downloaded torrent file ({Bytes} bytes) for '{Title}'", - torrentFileData!.Length, LogRedaction.SanitizeText(result.Title)); - } - else if (downloadResult.HasMagnet) - { - // Indexer redirected to a magnet link — use it as the torrent URL instead - magnetLink = DownloadClientUriBuilder.NormalizeMagnetLink(downloadResult.MagnetUri); - _logger.LogInformation("Indexer redirected to magnet link for '{Title}'", LogRedaction.SanitizeText(result.Title)); - } + torrentFileData = downloadResult.TorrentBytes; + _logger.LogInformation("Pre-downloaded torrent file ({Bytes} bytes) for '{Title}'", + torrentFileData!.Length, LogRedaction.SanitizeText(result.Title)); } - catch (Exception ex) when (ex is not OutOfMemoryException && ex is not StackOverflowException) + else if (downloadResult.HasMagnet) { - _logger.LogWarning(ex, "Failed to pre-download torrent file for '{Title}', falling back to URL", LogRedaction.SanitizeText(result.Title)); + // Indexer redirected to a magnet link — use it as the torrent URL instead + magnetLink = DownloadClientUriBuilder.NormalizeMagnetLink(downloadResult.MagnetUri); + _logger.LogInformation("Indexer redirected to magnet link for '{Title}'", LogRedaction.SanitizeText(result.Title)); } } - if (magnetLink.Length > 0) - { - torrentUrl = magnetLink; - } - else if (!string.IsNullOrEmpty(httpTorrentUrl)) - { - torrentUrl = httpTorrentUrl; - } + var torrentUrl = new[] { magnetLink, httpTorrentUrl } + .FirstOrDefault(static url => !string.IsNullOrEmpty(url)) ?? string.Empty; if ((torrentFileData == null || torrentFileData.Length == 0) && string.IsNullOrEmpty(torrentUrl)) throw new ArgumentException("No magnet link or torrent URL provided", nameof(result)); @@ -464,6 +447,40 @@ async Task PostLoginWithAgent(string userAgent) return torrentUrl[start..end].ToLowerInvariant(); } + private static string? NormalizeTorrentUrl(string? torrentUrl) + { + var trimmed = (torrentUrl ?? string.Empty).Trim(); + if (trimmed.Length == 0) + { + return null; + } + + if (!DownloadClientUriBuilder.TryParseHttpOrHttpsAbsoluteUri(trimmed, out var torrentUri)) + { + throw new ArgumentException("Torrent URL must be an absolute HTTP or HTTPS URL.", nameof(torrentUrl)); + } + + return torrentUri!.ToString(); + } + + private async Task TryPredownloadTorrentFileAsync(string? torrentUrl, string? title, CancellationToken ct) + { + if (string.IsNullOrEmpty(torrentUrl)) + { + return TorrentDownloadResult.Empty; + } + + try + { + return await _torrentFileDownloader.DownloadAsync(torrentUrl, ct); + } + catch (Exception ex) when (ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning(ex, "Failed to pre-download torrent file for '{Title}', falling back to URL", LogRedaction.SanitizeText(title)); + return TorrentDownloadResult.Empty; + } + } + /// /// Marks a torrent as imported by changing its category to the configured post-import category. /// This allows users to differentiate imported vs active torrents in qBittorrent. diff --git a/listenarr.api/Services/Adapters/TransmissionAdapter.cs b/listenarr.api/Services/Adapters/TransmissionAdapter.cs index 5aeb074..9ce83ce 100644 --- a/listenarr.api/Services/Adapters/TransmissionAdapter.cs +++ b/listenarr.api/Services/Adapters/TransmissionAdapter.cs @@ -90,9 +90,10 @@ public TransmissionAdapter(IHttpClientFactory httpClientFactory, IRemotePathMapp // Prefer cached torrent file data over URL (required for private trackers with authentication) byte[]? torrentFileData = result.TorrentFileContent; - var torrentTarget = DownloadClientUriBuilder.ResolveTorrentAddTarget(result); - string torrentUrl = torrentTarget.Value; - var isMagnetTarget = torrentTarget.IsMagnet; + var magnetLink = DownloadClientUriBuilder.NormalizeMagnetLink(result.MagnetLink); + var httpTorrentUrl = NormalizeTorrentUrl(result.TorrentUrl); + var torrentUrl = magnetLink.Length > 0 ? magnetLink : httpTorrentUrl ?? string.Empty; + var isMagnetTarget = magnetLink.Length > 0; _logger.LogDebug("AddAsync entry for '{Title}': TorrentFileContent={HasContent}, MagnetLink={HasMagnet}, TorrentUrl={Url}", LogRedaction.SanitizeText(result.Title), @@ -108,14 +109,13 @@ public TransmissionAdapter(IHttpClientFactory httpClientFactory, IRemotePathMapp // start immediately without metadata resolution. if ((torrentFileData == null || torrentFileData.Length == 0) && isMagnetTarget && - DownloadClientUriBuilder.TryParseHttpOrHttpsAbsoluteUri(result.TorrentUrl, out _)) + !string.IsNullOrEmpty(httpTorrentUrl)) { - var alternateTorrentUrl = result.TorrentUrl!.Trim(); _logger.LogDebug("Magnet link available but TorrentUrl also present — attempting .torrent pre-download from {Url} for better Transmission compatibility", - LogRedaction.SanitizeUrl(alternateTorrentUrl)); + LogRedaction.SanitizeUrl(httpTorrentUrl)); try { - var altResult = await _torrentFileDownloader.DownloadAsync(alternateTorrentUrl, ct); + var altResult = await _torrentFileDownloader.DownloadAsync(httpTorrentUrl, ct); if (altResult.HasBytes) { torrentFileData = altResult.TorrentBytes; @@ -139,12 +139,12 @@ public TransmissionAdapter(IHttpClientFactory httpClientFactory, IRemotePathMapp // the raw bytes via the metainfo field instead. if ((torrentFileData == null || torrentFileData.Length == 0) && !isMagnetTarget && - DownloadClientUriBuilder.TryParseHttpOrHttpsAbsoluteUri(torrentUrl, out _)) + !string.IsNullOrEmpty(httpTorrentUrl)) { - _logger.LogDebug("Attempting pre-download of torrent file from {Url}", LogRedaction.SanitizeUrl(torrentUrl)); + _logger.LogDebug("Attempting pre-download of torrent file from {Url}", LogRedaction.SanitizeUrl(httpTorrentUrl)); try { - var downloadResult = await _torrentFileDownloader.DownloadAsync(torrentUrl, ct); + var downloadResult = await _torrentFileDownloader.DownloadAsync(httpTorrentUrl, ct); if (downloadResult.HasBytes) { torrentFileData = downloadResult.TorrentBytes; @@ -1056,6 +1056,22 @@ private static string BuildBaseUrl(DownloadClientConfiguration client) return DownloadClientUriBuilder.BuildUri(client, rpcPath).ToString(); } + private static string? NormalizeTorrentUrl(string? torrentUrl) + { + var trimmed = (torrentUrl ?? string.Empty).Trim(); + if (trimmed.Length == 0) + { + return null; + } + + if (!DownloadClientUriBuilder.TryParseHttpOrHttpsAbsoluteUri(trimmed, out var torrentUri)) + { + throw new ArgumentException("Torrent URL must be an absolute HTTP or HTTPS URL.", nameof(torrentUrl)); + } + + return torrentUri!.ToString(); + } + private static string NormalizeMagnetUriForTransmission(string magnetUri) { var queryStart = magnetUri.IndexOf('?'); diff --git a/tests/Listenarr.Api.Tests/QbittorrentAdapterTests.cs b/tests/Listenarr.Api.Tests/QbittorrentAdapterTests.cs index 989c435..e6de78a 100644 --- a/tests/Listenarr.Api.Tests/QbittorrentAdapterTests.cs +++ b/tests/Listenarr.Api.Tests/QbittorrentAdapterTests.cs @@ -2,7 +2,10 @@ using System.Collections.Generic; using System.Net; using System.Net.Http; +using System.Net.Sockets; +using System.Text; using System.Text.Json; +using System.Threading; using System.Threading.Tasks; using Listenarr.Api.Services.Adapters; using Listenarr.Domain.Models; @@ -151,6 +154,119 @@ public async Task TestConnection_NormalizesHostWithSchemeAndPath() Assert.Equal("/api/v2/app/version", capturedUri.AbsolutePath); } + [Fact] + public async Task AddAsync_WhenMagnetAndTorrentUrlAreProvided_PredownloadsTorrentUrlFirst() + { + string? requestedTorrentUrl = null; + var port = GetAvailablePort(); + using var listener = new HttpListener(); + listener.Prefixes.Add($"http://127.0.0.1:{port}/"); + listener.Start(); + + var serverTask = Task.Run(async () => + { + for (var requestIndex = 0; requestIndex < 4; requestIndex++) + { + var context = await listener.GetContextAsync(); + var pathAndQuery = context.Request.Url!.PathAndQuery; + + if (context.Request.HttpMethod == HttpMethod.Post.Method && + pathAndQuery.StartsWith("/api/v2/auth/login", StringComparison.OrdinalIgnoreCase)) + { + await WriteResponseAsync(context.Response, HttpStatusCode.OK, "Ok", "text/plain"); + continue; + } + + if (context.Request.HttpMethod == HttpMethod.Get.Method && + pathAndQuery.StartsWith("/api/v2/torrents/info?fields=hash,name", StringComparison.OrdinalIgnoreCase)) + { + await WriteResponseAsync(context.Response, HttpStatusCode.OK, """[{"hash":"NEWHASH","name":"Book"}]"""); + continue; + } + + if (context.Request.HttpMethod == HttpMethod.Get.Method && + pathAndQuery.StartsWith("/api/v2/torrents/info?fields=hash", StringComparison.OrdinalIgnoreCase)) + { + await WriteResponseAsync(context.Response, HttpStatusCode.OK, "[]"); + continue; + } + + if (context.Request.HttpMethod == HttpMethod.Post.Method && + pathAndQuery.StartsWith("/api/v2/torrents/add", StringComparison.OrdinalIgnoreCase)) + { + await WriteResponseAsync(context.Response, HttpStatusCode.OK, "Ok.", "text/plain"); + continue; + } + + throw new InvalidOperationException($"Unexpected qBittorrent request: {context.Request.HttpMethod} {context.Request.Url}"); + } + }); + + using var http = new HttpClient(); + var downloader = new Mock(MockBehavior.Strict); + downloader + .Setup(x => x.DownloadAsync("https://indexer.example.com/book.torrent", It.IsAny())) + .Callback((url, _) => requestedTorrentUrl = url) + .ReturnsAsync(TorrentDownloadResult.Empty); + + var adapter = new QbittorrentAdapter( + new TestHttpClientFactory(http), + Mock.Of(), + downloader.Object, + NullLogger.Instance); + + var client = new DownloadClientConfiguration + { + Host = "127.0.0.1", + Port = port, + Username = "admin", + Password = "admin" + }; + + var searchResult = new SearchResult + { + Title = "Book", + MagnetLink = "magnet:?xt=urn:btih:ABCDEF1234567890", + TorrentUrl = "https://indexer.example.com/book.torrent" + }; + + var addedId = await adapter.AddAsync(client, searchResult); + await serverTask; + + Assert.Equal("NEWHASH", addedId); + Assert.Equal("https://indexer.example.com/book.torrent", requestedTorrentUrl); + downloader.Verify(x => x.DownloadAsync("https://indexer.example.com/book.torrent", It.IsAny()), Times.Once); + } + + [Fact] + public async Task AddAsync_WhenTorrentUrlUsesInvalidScheme_ThrowsArgumentException() + { + using var http = new HttpClient(new DelegatingHandlerMock((_, _) => + throw new InvalidOperationException("Network should not be hit for invalid torrent URLs."))); + + var adapter = new QbittorrentAdapter( + new TestHttpClientFactory(http), + Mock.Of(), + Mock.Of(), + NullLogger.Instance); + + var client = new DownloadClientConfiguration + { + Host = "localhost", + Port = 8080 + }; + + var searchResult = new SearchResult + { + Title = "Book", + TorrentUrl = "ftp://indexer.example.com/book.torrent" + }; + + var ex = await Assert.ThrowsAsync(() => adapter.AddAsync(client, searchResult)); + + Assert.Contains("HTTP or HTTPS", ex.Message, StringComparison.OrdinalIgnoreCase); + } + [Fact] [Trait("Area", "QbittorrentImportPathResolution")] [Trait("Scenario", "SingleFileResolvesContentFilePath")] @@ -195,5 +311,22 @@ private static string NormalizePath(string path) { return (path ?? string.Empty).Replace('\\', '/'); } + + private static int GetAvailablePort() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + return ((IPEndPoint)listener.LocalEndpoint).Port; + } + + private static async Task WriteResponseAsync(HttpListenerResponse response, HttpStatusCode statusCode, string body, string contentType = "application/json") + { + var payload = Encoding.UTF8.GetBytes(body); + response.StatusCode = (int)statusCode; + response.ContentType = contentType; + response.ContentLength64 = payload.Length; + await response.OutputStream.WriteAsync(payload, 0, payload.Length); + response.Close(); + } } } diff --git a/tests/Listenarr.Api.Tests/TransmissionAdapterTests.cs b/tests/Listenarr.Api.Tests/TransmissionAdapterTests.cs index ecd806b..334cc80 100644 --- a/tests/Listenarr.Api.Tests/TransmissionAdapterTests.cs +++ b/tests/Listenarr.Api.Tests/TransmissionAdapterTests.cs @@ -2,6 +2,7 @@ using System.Net; using System.Net.Http; using System.Text.Json; +using System.Threading; using System.Threading.Tasks; using Listenarr.Api.Services; using Listenarr.Api.Services.Adapters; @@ -123,5 +124,93 @@ public async Task TestConnectionAsync_NormalizesHostAndRespectsConfiguredRpcPath Assert.Equal(9091, capturedUri.Port); Assert.Equal("/rpc", capturedUri.AbsolutePath); } + + [Fact] + public async Task AddAsync_WhenMagnetAndTorrentUrlAreProvided_PredownloadsTorrentUrlFirst() + { + string? downloadedUrl = null; + string? metainfo = null; + using var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("""{"result":"success","arguments":{"torrent-added":{"id":1,"hashString":"HASH1","name":"Book"}}}""") + }; + + var handler = new DelegatingHandlerMock(async (req, ct) => + { + var body = await req.Content!.ReadAsStringAsync(ct); + using var doc = JsonDocument.Parse(body); + var arguments = doc.RootElement.GetProperty("arguments"); + metainfo = arguments.TryGetProperty("metainfo", out var metainfoProp) ? metainfoProp.GetString() : null; + return response; + }); + + using var httpClient = new HttpClient(handler); + var downloader = new Mock(MockBehavior.Strict); + downloader + .Setup(x => x.DownloadAsync("https://indexer.example.com/book.torrent", It.IsAny())) + .Callback((url, _) => downloadedUrl = url) + .ReturnsAsync(TorrentDownloadResult.FromBytes(new byte[] { (byte)'d', (byte)'e' })); + + var adapter = new TransmissionAdapter( + new TestHttpClientFactory(httpClient), + Mock.Of(), + downloader.Object, + NullLogger.Instance); + + var client = new DownloadClientConfiguration + { + Id = "tr-1", + Name = "Transmission", + Type = "transmission", + Host = "localhost", + Port = 9091 + }; + + var searchResult = new SearchResult + { + Title = "Book", + MagnetLink = "magnet:?xt=urn:btih:ABCDEF1234567890", + TorrentUrl = "https://indexer.example.com/book.torrent" + }; + + var addedId = await adapter.AddAsync(client, searchResult); + + Assert.Equal("HASH1", addedId); + Assert.Equal("https://indexer.example.com/book.torrent", downloadedUrl); + Assert.Equal(Convert.ToBase64String(new byte[] { (byte)'d', (byte)'e' }), metainfo); + downloader.Verify(x => x.DownloadAsync("https://indexer.example.com/book.torrent", It.IsAny()), Times.Once); + } + + [Fact] + public async Task AddAsync_WhenTorrentUrlUsesInvalidScheme_ThrowsArgumentException() + { + using var httpClient = new HttpClient(new DelegatingHandlerMock((_, _) => + throw new InvalidOperationException("Network should not be hit for invalid torrent URLs."))); + + var adapter = new TransmissionAdapter( + new TestHttpClientFactory(httpClient), + Mock.Of(), + Mock.Of(), + NullLogger.Instance); + + var client = new DownloadClientConfiguration + { + Id = "tr-1", + Name = "Transmission", + Type = "transmission", + Host = "localhost", + Port = 9091 + }; + + var searchResult = new SearchResult + { + Title = "Book", + TorrentUrl = "ftp://indexer.example.com/book.torrent" + }; + + var ex = await Assert.ThrowsAsync(() => adapter.AddAsync(client, searchResult)); + + Assert.Contains("HTTP or HTTPS", ex.Message, StringComparison.OrdinalIgnoreCase); + } } }