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/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..9ea996c 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) @@ -697,7 +699,7 @@ 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) => book.wanted === true).length) const systemIssues = ref(0) // Activity count: Optimized with memoized intermediate computations @@ -828,59 +830,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 { - // 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) => { - const serverWanted = (book as unknown as Record)['wanted'] - return serverWanted === true - }).length + await libraryStore.fetchLibrary() } catch (err) { - logger.error('Failed to refresh wanted badge:', err) + logger.error('Failed to sync library snapshot:', err) } } @@ -936,8 +894,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) => @@ -1099,8 +1059,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) { + void syncLibrarySnapshot() + } + }) // Subscribe to queue updates via SignalR (real-time, no polling!) unsubscribeQueue = signalRService.onQueueUpdate((queue) => { @@ -1118,8 +1084,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()}`, @@ -1158,25 +1122,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 @@ -1214,9 +1159,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 } @@ -1250,9 +1192,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) @@ -1297,7 +1236,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 346f6e4..5de27b5 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', () => ({ @@ -24,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), @@ -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 @@ -190,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' }, @@ -199,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), @@ -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 @@ -233,4 +235,99 @@ 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() + }) + + 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__/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__/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)) 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/__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/__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/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 d291bc6..718945b 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 - it includes accurate wanted flags based on File.Exists() checks - 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) { @@ -127,8 +137,23 @@ 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 } + const updated: Audiobook = { + ...book, + 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) if (book.filePath) { @@ -139,6 +164,10 @@ export const useLibraryStore = defineStore('library', () => { } } + if (nextFileCount === 0) { + 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..8961646 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 @@ -420,6 +422,7 @@ export interface Audiobook { monitored?: boolean filePath?: string fileSize?: number + fileCount?: number basePath?: string files?: { id: number @@ -442,6 +445,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..a9ca114 --- /dev/null +++ b/fe/src/utils/audiobookStatus.ts @@ -0,0 +1,141 @@ +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 + } + + 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/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/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 1497a4e..bb64f3d 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; @@ -99,46 +100,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) @@ -602,79 +574,140 @@ 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 - List audiobooks; - try + var audiobooks = await _dbContext.Audiobooks + .AsNoTracking() + .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.FilePath, + a.FileSize, + a.Quality, + a.QualityProfileId, + a.AuthorAsins + }) + .ToListAsync(); + + if (audiobooks.Count == 0) { - 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(); + return Ok(Array.Empty()); } - 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 + var audiobookIds = audiobooks.Select(a => a.Id).ToArray(); + var fileSummaries = await _dbContext.AudiobookFiles + .AsNoTracking() + .Where(f => audiobookIds.Contains(f.AudiobookId)) + .Select(f => new AudiobookFileStatusInfo { - 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) - }); + 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, + FilePath = a.FilePath, + FileSize = a.FileSize, + FileCount = files?.Count ?? 0, + 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); } @@ -704,20 +737,14 @@ 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}")] 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..064e5e8 --- /dev/null +++ b/listenarr.api/Models/LibraryAudiobookListItemDto.cs @@ -0,0 +1,31 @@ +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? 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; } + 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..0d8fc55 --- /dev/null +++ b/listenarr.api/Services/Adapters/DownloadClientUriBuilder.cs @@ -0,0 +1,146 @@ +using System; +using Listenarr.Domain.Models; + +namespace Listenarr.Api.Services.Adapters +{ + internal readonly record struct TorrentAddTarget(string Value, bool IsMagnet); + + internal static class DownloadClientUriBuilder + { + 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, + 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..f44f852 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 @@ -221,20 +221,10 @@ 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 baseUrl = $"{(client.UseSSL ? "https" : "http")}://{client.Host}:{client.Port}"; + var magnetLink = DownloadClientUriBuilder.NormalizeMagnetLink(result.MagnetLink); + var httpTorrentUrl = NormalizeTorrentUrl(result.TorrentUrl); - string? extractedHash = null; - if (!string.IsNullOrEmpty(result.MagnetLink) && result.MagnetLink.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 baseUrl = DownloadClientUriBuilder.BuildAuthority(client); try { @@ -321,38 +311,35 @@ 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) && - !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))) + if (torrentFileData == null || torrentFileData.Length == 0) { - try + var downloadResult = await TryPredownloadTorrentFileAsync(httpTorrentUrl, result.Title, ct); + if (downloadResult.HasBytes) { - var downloadResult = await _torrentFileDownloader.DownloadAsync(torrentUrl, 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 - torrentUrl = 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)); } } + 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)); + + var extractedHash = TryExtractMagnetHash(torrentUrl); + if (torrentFileData != null && torrentFileData.Length > 0) { using var multipart = new MultipartFormDataContent(); @@ -446,6 +433,54 @@ 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(); + } + + 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. @@ -463,7 +498,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 +542,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 +599,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 +749,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 +1027,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 +1139,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..9ce83ce 100644 --- a/listenarr.api/Services/Adapters/TransmissionAdapter.cs +++ b/listenarr.api/Services/Adapters/TransmissionAdapter.cs @@ -90,13 +90,16 @@ 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 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), 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 +108,14 @@ 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 && + !string.IsNullOrEmpty(httpTorrentUrl)) { _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(httpTorrentUrl)); try { - var altResult = await _torrentFileDownloader.DownloadAsync(result.TorrentUrl, ct); + var altResult = await _torrentFileDownloader.DownloadAsync(httpTorrentUrl, ct); if (altResult.HasBytes) { torrentFileData = altResult.TorrentBytes; @@ -140,16 +138,13 @@ 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 && + !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; @@ -159,7 +154,7 @@ 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); _logger.LogInformation("Indexer redirected to magnet link for '{Title}'", LogRedaction.SanitizeText(result.Title)); } else @@ -1049,7 +1044,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 +1053,23 @@ 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? 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) 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/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": { 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 new file mode 100644 index 0000000..b26f379 --- /dev/null +++ b/tests/Listenarr.Api.Tests/LibraryController_LibraryListSlimPayloadTests.cs @@ -0,0 +1,100 @@ +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.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("basePath", out _)); + } + } +} diff --git a/tests/Listenarr.Api.Tests/LibraryController_WantedFlagRegressionTests.cs b/tests/Listenarr.Api.Tests/LibraryController_WantedFlagRegressionTests.cs new file mode 100644 index 0000000..4f54d4e --- /dev/null +++ b/tests/Listenarr.Api.Tests/LibraryController_WantedFlagRegressionTests.cs @@ -0,0 +1,68 @@ +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); + + 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) + .GetProperty("wanted") + .GetBoolean(); + + Assert.False(wanted); + } + } +} diff --git a/tests/Listenarr.Api.Tests/NzbgetAdapterTests.cs b/tests/Listenarr.Api.Tests/NzbgetAdapterTests.cs new file mode 100644 index 0000000..7072240 --- /dev/null +++ b/tests/Listenarr.Api.Tests/NzbgetAdapterTests.cs @@ -0,0 +1,151 @@ +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; + using var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + "25.4") + }; + var handler = new DelegatingHandlerMock((req, _) => + { + capturedUri = req.RequestUri; + return Task.FromResult(response); + }); + + 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; + using var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + "25.4") + }; + var handler = new DelegatingHandlerMock((req, _) => + { + capturedUri = req.RequestUri; + return Task.FromResult(response); + }); + + 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); + } + + [Fact] + 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(response); + }); + + 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); + } + } +} diff --git a/tests/Listenarr.Api.Tests/QbittorrentAdapterTests.cs b/tests/Listenarr.Api.Tests/QbittorrentAdapterTests.cs index 92cff87..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; @@ -114,6 +117,156 @@ public async Task TestConnection_When_VersionForbidden_And_NoCredentials_Returns Assert.Contains("Forbidden", message, StringComparison.OrdinalIgnoreCase); } + [Fact] + 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(okResponse); + }); + + 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] + 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")] @@ -158,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/SabnzbdAdapterTests.cs b/tests/Listenarr.Api.Tests/SabnzbdAdapterTests.cs new file mode 100644 index 0000000..7ad2a1d --- /dev/null +++ b/tests/Listenarr.Api.Tests/SabnzbdAdapterTests.cs @@ -0,0 +1,74 @@ +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; + 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(response); + }); + + 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..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; @@ -77,5 +78,139 @@ 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; + 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(response); + }); + + 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); + } + + [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); + } } }