From 7096bcd668496ddc6488ea1122a0d1196806db8d Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Thu, 11 Jun 2026 08:48:46 -0400 Subject: [PATCH 01/84] Fix dependency drift and clean frontend build warnings Enable deterministic NuGet restores for backend projects by turning on package lock files and CI locked restore mode. Add generated NuGet lock files so local and CI test runs resolve the same dependency graph before `dotnet test --no-restore`. Pull in the dependency updates from `chore/dependency-update`, including the Node engine update to `^24.15.0` and refreshed root/frontend npm lockfiles. Clean up frontend build warnings introduced during dependency validation by removing the auth store's dynamic router import and moving router instance access into a small shared service. Add a narrow Vite/Rolldown warning filter for known upstream `@vueuse/core` pure annotation noise. Validated with: - `dotnet restore listenarr.slnx /p:CI=true` - `dotnet test listenarr.slnx --no-restore` - `npm install` - `cd fe && npm install` - `cd fe && npm run test:unit` - `cd fe && npm run build` - `cd fe && npm run lint:check` - `git diff --check` --- .github/workflows/build-and-publish.yml | 10 +- .github/workflows/run-tests.yml | 2 +- Directory.Packages.props | 2 + fe/package-lock.json | 102 +-- fe/package.json | 12 +- fe/src/router/index.ts | 20 +- fe/src/services/routerInstance.ts | 15 + fe/src/stores/auth.ts | 4 +- fe/vite.config.ts | 26 + listenarr.api/Listenarr.Api.csproj | 3 +- listenarr.api/packages.lock.json | 557 +++++++++++++ listenarr.application/packages.lock.json | 262 ++++++ listenarr.domain/packages.lock.json | 6 + listenarr.infrastructure/packages.lock.json | 563 +++++++++++++ package-lock.json | 14 +- package.json | 2 +- tests/packages.lock.json | 872 ++++++++++++++++++++ 17 files changed, 2380 insertions(+), 92 deletions(-) create mode 100644 fe/src/services/routerInstance.ts create mode 100644 listenarr.api/packages.lock.json create mode 100644 listenarr.application/packages.lock.json create mode 100644 listenarr.domain/packages.lock.json create mode 100644 listenarr.infrastructure/packages.lock.json create mode 100644 tests/packages.lock.json diff --git a/.github/workflows/build-and-publish.yml b/.github/workflows/build-and-publish.yml index b7a6ce2c8..9d624f18d 100644 --- a/.github/workflows/build-and-publish.yml +++ b/.github/workflows/build-and-publish.yml @@ -125,14 +125,14 @@ jobs: # ── Publish portable binaries ──────────────────────────────────────────── - name: Publish API (linux-x64) - run: dotnet publish ${{ env.API_PROJECT }} -c Release -r linux-x64 --self-contained true /p:PublishSingleFile=true -o ${{ env.API_OUTPUT }}/linux-x64 + run: dotnet publish ${{ env.API_PROJECT }} -c Release -r linux-x64 --no-restore --self-contained true /p:PublishSingleFile=true -o ${{ env.API_OUTPUT }}/linux-x64 - name: Publish API (win-x64) - run: dotnet publish ${{ env.API_PROJECT }} -c Release -r win-x64 --self-contained true /p:PublishSingleFile=true -o ${{ env.API_OUTPUT }}/win-x64 + run: dotnet publish ${{ env.API_PROJECT }} -c Release -r win-x64 --no-restore --self-contained true /p:PublishSingleFile=true -o ${{ env.API_OUTPUT }}/win-x64 - name: Publish API (osx-x64) if: inputs.include_osx - run: dotnet publish ${{ env.API_PROJECT }} -c Release -r osx-x64 --self-contained true /p:PublishSingleFile=true -o ${{ env.API_OUTPUT }}/osx-x64 + run: dotnet publish ${{ env.API_PROJECT }} -c Release -r osx-x64 --no-restore --self-contained true /p:PublishSingleFile=true -o ${{ env.API_OUTPUT }}/osx-x64 # ── Zip and upload artifacts ───────────────────────────────────────────── @@ -261,8 +261,8 @@ jobs: run: | set -euo pipefail rm -rf "${{ env.DOCKER_OUTPUT }}" - dotnet publish ${{ env.API_PROJECT }} -c Release -r linux-x64 --self-contained false /p:UseAppHost=false -o "${{ env.DOCKER_OUTPUT }}/amd64" - dotnet publish ${{ env.API_PROJECT }} -c Release -r linux-arm64 --self-contained false /p:UseAppHost=false -o "${{ env.DOCKER_OUTPUT }}/arm64" + dotnet publish ${{ env.API_PROJECT }} -c Release -r linux-x64 --no-restore --self-contained false /p:UseAppHost=false -o "${{ env.DOCKER_OUTPUT }}/amd64" + dotnet publish ${{ env.API_PROJECT }} -c Release -r linux-arm64 --no-restore --self-contained false /p:UseAppHost=false -o "${{ env.DOCKER_OUTPUT }}/arm64" - name: Show publish contents (sanity check) shell: bash diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 764048fb0..66a1b65a4 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -114,4 +114,4 @@ jobs: fi - name: Publish API (linux-x64) - run: dotnet publish ${{ env.API_PROJECT }} -c Release -r linux-x64 --self-contained true /p:PublishSingleFile=true -o listenarr.api/publish/linux-x64 + run: dotnet publish ${{ env.API_PROJECT }} -c Release -r linux-x64 --no-restore --self-contained true /p:PublishSingleFile=true -o listenarr.api/publish/linux-x64 diff --git a/Directory.Packages.props b/Directory.Packages.props index e66d165b7..06dfb96ca 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -2,6 +2,8 @@ true true + true + true 10.0.8 diff --git a/fe/package-lock.json b/fe/package-lock.json index 91b91b5e9..d58b87902 100644 --- a/fe/package-lock.json +++ b/fe/package-lock.json @@ -32,24 +32,24 @@ "@vue/test-utils": "^2.4.11", "@vue/tsconfig": "^0.9.1", "concurrently": "^10.0.3", - "cypress": "^15.16.0", + "cypress": "^15.17.0", "eslint": "^10.4.1", "eslint-plugin-cypress": "^6.4.1", "eslint-plugin-vue": "^10.9.2", "jiti": "^2.7.0", "jsdom": "^29.1.1", - "npm-run-all2": "^8.0.4", + "npm-run-all2": "^9.0.1", "patch-package": "^8.0.1", - "prettier": "^3.8.3", + "prettier": "^3.8.4", "rollup-plugin-visualizer": "^7.0.1", - "start-server-and-test": "^3.0.8", + "start-server-and-test": "^3.0.9", "typescript": "^6.0.3", "vite": "^8.0.16", "vitest": "^4.1.8", - "vue-tsc": "^3.3.3" + "vue-tsc": "^3.3.4" }, "engines": { - "node": ">=24.0.0" + "node": "^24.15.0" } }, "..": { @@ -2020,9 +2020,9 @@ } }, "node_modules/@vue/language-core": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.3.3.tgz", - "integrity": "sha512-X6p+7nfY7vVT6dQwUJ+v0Jfq/lwIfhL2jMi91dQ3ln4hnlGXlxsDu/FNkeyHYgvYtyQy18ZX76IZy7X4diDbiQ==", + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.3.4.tgz", + "integrity": "sha512-IuHqQ5zGGOE7CXP72VX6A42IVeIzYv4WAhO6arej11TRNqtdZfGyH8Yr2FOCaDX0dSQG+JwULLoFHGY1igYVjQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3113,9 +3113,9 @@ "license": "MIT" }, "node_modules/cypress": { - "version": "15.16.0", - "resolved": "https://registry.npmjs.org/cypress/-/cypress-15.16.0.tgz", - "integrity": "sha512-fy0M0c9xDLEp4v9y7LLKFeAQhIdDsobxDSKpD3JcZpqQefjy9TSzEyVV3HA0zu7hUi0bGHlSYlI7ASub8wgR9A==", + "version": "15.17.0", + "resolved": "https://registry.npmjs.org/cypress/-/cypress-15.17.0.tgz", + "integrity": "sha512-WL5Gcqi1GaDWozBwXmkSAtOPafTsVSRS764iX6xvuz3DPzvBAxbkRyEi4BreVdVWxLDpiYRgZCyJUafBw44njw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -5003,13 +5003,13 @@ "license": "MIT" }, "node_modules/json-parse-even-better-errors": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-4.0.0.tgz", - "integrity": "sha512-lR4MXjGNgkJc7tkQ97kb2nuEMnNCyU//XYVH0MKTGcXEiSudQ5MKGKen3C5QubYy0vmq+JGitUg92uuywGEwIA==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-6.0.0.tgz", + "integrity": "sha512-2/8adwnK1/+Fdjyts4r6wSpfANWw8zdNhU9U/Llk59c6O+DjSisPWPykwoL8gZmocP9Dy64S7oie2g+Mia123A==", "dev": true, "license": "MIT", "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" } }, "node_modules/json-schema": { @@ -5878,19 +5878,19 @@ } }, "node_modules/npm-normalize-package-bin": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-4.0.0.tgz", - "integrity": "sha512-TZKxPvItzai9kN9H/TkmCtx/ZN/hvr3vUycjlfmH0ootY9yFBzNOpiXAdIn1Iteqsvk4lQn6B5PTrt+n6h8k/w==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-6.0.0.tgz", + "integrity": "sha512-tdt4aFn9QamlhdN3HV2D2ccpBwO5/fyjjbXUxYA6uBjyekMZcZvDq0aSj9t5Jo+tih6AYFnt/cuIRn9013e0Uw==", "dev": true, "license": "ISC", "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" } }, "node_modules/npm-run-all2": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/npm-run-all2/-/npm-run-all2-8.0.4.tgz", - "integrity": "sha512-wdbB5My48XKp2ZfJUlhnLVihzeuA1hgBnqB2J9ahV77wLS+/YAJAlN8I+X3DIFIPZ3m5L7nplmlbhNiFDmXRDA==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/npm-run-all2/-/npm-run-all2-9.0.1.tgz", + "integrity": "sha512-ZtK8WXZBUA9x0XD6nxYdFLe86FxpkCTq2LiQxzX0LeXQY/vyAigQZXjjj/xfTwgV4Yqe/vYNIq2W09lrHKTcuQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5899,9 +5899,9 @@ "memorystream": "^0.3.1", "picomatch": "^4.0.2", "pidtree": "^0.6.0", - "read-package-json-fast": "^4.0.0", + "read-package-json-fast": "^6.0.0", "shell-quote": "^1.7.3", - "which": "^5.0.0" + "which": "^7.0.0" }, "bin": { "npm-run-all": "bin/npm-run-all/index.js", @@ -5910,7 +5910,7 @@ "run-s": "bin/run-s/index.js" }, "engines": { - "node": "^20.5.0 || >=22.0.0", + "node": "^22.22.2 || ^24.15.0 || >=26.0.0", "npm": ">= 10" } }, @@ -5928,13 +5928,13 @@ } }, "node_modules/npm-run-all2/node_modules/isexe": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", - "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", "dev": true, "license": "BlueOak-1.0.0", "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/npm-run-all2/node_modules/picomatch": { @@ -5951,19 +5951,19 @@ } }, "node_modules/npm-run-all2/node_modules/which": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", - "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-7.0.0.tgz", + "integrity": "sha512-RancgH2dmbLdHl6LRhEqvklWMgl/Hdnun0Y90KhBOLkMefg8Qa7/Zel8Sm+8HEcP6DEjzsWzpkuBQEZok58isA==", "dev": true, "license": "ISC", "dependencies": { - "isexe": "^3.1.1" + "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" } }, "node_modules/npm-run-path": { @@ -6436,9 +6436,9 @@ } }, "node_modules/prettier": { - "version": "3.8.3", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", - "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "version": "3.8.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.4.tgz", + "integrity": "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==", "dev": true, "license": "MIT", "bin": { @@ -6592,17 +6592,17 @@ "license": "MIT" }, "node_modules/read-package-json-fast": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/read-package-json-fast/-/read-package-json-fast-4.0.0.tgz", - "integrity": "sha512-qpt8EwugBWDw2cgE2W+/3oxC+KTez2uSVR8JU9Q36TXPAGCaozfQUs59v4j4GFpWTaw0i6hAZSvOmu1J0uOEUg==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/read-package-json-fast/-/read-package-json-fast-6.0.0.tgz", + "integrity": "sha512-PNaGjoCnw9DBA2Kl8D+8po957z778q/HOPuY2u3Bkw/JO3eC8MDx7jn/PgMtSgpcBbs+6UOjDbwReGpXmRvs0g==", "dev": true, "license": "ISC", "dependencies": { - "json-parse-even-better-errors": "^4.0.0", - "npm-normalize-package-bin": "^4.0.0" + "json-parse-even-better-errors": "^6.0.0", + "npm-normalize-package-bin": "^6.0.0" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" } }, "node_modules/readdirp": { @@ -7210,9 +7210,9 @@ "license": "MIT" }, "node_modules/start-server-and-test": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/start-server-and-test/-/start-server-and-test-3.0.8.tgz", - "integrity": "sha512-BG1tHNyEW/mPhw50DFPb0uKoq7f7yNQFO+CJb83MKZkCPKmWqb522YGMM3f4XG1Kra2v3xU3ou6O+s8taChM6A==", + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/start-server-and-test/-/start-server-and-test-3.0.9.tgz", + "integrity": "sha512-Wxa3llUystTkCRiRx/QzsGS7+/X/la2al6DaX9Q3iWjCZqSQTEcHTIXwvNiGEg0cnEQeY/UBqB7aUZth50IJoA==", "dev": true, "license": "MIT", "dependencies": { @@ -8195,14 +8195,14 @@ } }, "node_modules/vue-tsc": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.3.3.tgz", - "integrity": "sha512-SWUEG7YRUeDJHT7Xsuhf02elYX2gxPzzAII7OxDAh4KNOr4QHQ0Lls0YfnaO5GNd560CwVa2HTfdqmA5MqvRqQ==", + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.3.4.tgz", + "integrity": "sha512-XA/JqmQwS2GZmfgpjOEGdrKwaTSEuPwxpHa7/t6f4yiGrJb3gVHTPb9wBfByMNZwQ+xDXs41b8gaS2DKsOozUw==", "dev": true, "license": "MIT", "dependencies": { "@volar/typescript": "2.4.28", - "@vue/language-core": "3.3.3" + "@vue/language-core": "3.3.4" }, "bin": { "vue-tsc": "bin/vue-tsc.js" diff --git a/fe/package.json b/fe/package.json index 1f5b25f44..38f84b6bc 100644 --- a/fe/package.json +++ b/fe/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "engines": { - "node": ">=24.0.0" + "node": "^24.15.0" }, "scripts": { "version:sync": "node ../scripts/sync-fe-version-from-csproj.mjs", @@ -62,21 +62,21 @@ "@vue/test-utils": "^2.4.11", "@vue/tsconfig": "^0.9.1", "concurrently": "^10.0.3", - "cypress": "^15.16.0", + "cypress": "^15.17.0", "eslint": "^10.4.1", "eslint-plugin-cypress": "^6.4.1", "eslint-plugin-vue": "^10.9.2", "jiti": "^2.7.0", "jsdom": "^29.1.1", - "npm-run-all2": "^8.0.4", + "npm-run-all2": "^9.0.1", "patch-package": "^8.0.1", - "prettier": "^3.8.3", + "prettier": "^3.8.4", "rollup-plugin-visualizer": "^7.0.1", - "start-server-and-test": "^3.0.8", + "start-server-and-test": "^3.0.9", "typescript": "^6.0.3", "vite": "^8.0.16", "vitest": "^4.1.8", - "vue-tsc": "^3.3.3" + "vue-tsc": "^3.3.4" }, "overrides": { "ajv": "^8.18.0", diff --git a/fe/src/router/index.ts b/fe/src/router/index.ts index 914f8772f..748542f0b 100644 --- a/fe/src/router/index.ts +++ b/fe/src/router/index.ts @@ -19,6 +19,7 @@ import { createRouter, createWebHistory } from 'vue-router' import { useAuthStore } from '@/stores/auth' import { getStartupConfigCached } from '@/services/startupConfigCache' import { logger } from '@/utils/logger' +import { setRouter } from '@/services/routerInstance' import type { StartupConfig } from '@/types' // Module-level cache/promise for startup config to avoid repeated requests during rapid navigation @@ -150,12 +151,6 @@ export function preloadRoute(nameOrPath: string) { return Promise.resolve() } -/** - * Module-level reference set by createAppRouter(). - * Used by code that lazily imports the router (e.g. auth store). - */ -let _routerInstance: ReturnType | null = null - // Factory function to create and configure the router. // Deferred to avoid calling createWebHistory/createRouter at module top-level, // which triggers a Rolldown (Vite 8) circular-dependency crash where vue-router @@ -333,17 +328,6 @@ export function createAppRouter() { return true }) - _routerInstance = router + setRouter(router) return router } - -/** - * Returns the router instance previously created by createAppRouter(). - * Throws if called before createAppRouter(). - */ -export function getRouter() { - if (!_routerInstance) { - throw new Error('Router not initialized – call createAppRouter() first') - } - return _routerInstance -} diff --git a/fe/src/services/routerInstance.ts b/fe/src/services/routerInstance.ts new file mode 100644 index 000000000..79be20a4e --- /dev/null +++ b/fe/src/services/routerInstance.ts @@ -0,0 +1,15 @@ +import type { Router } from 'vue-router' + +let routerInstance: Router | null = null + +export function setRouter(router: Router) { + routerInstance = router +} + +export function getRouter() { + if (!routerInstance) { + throw new Error('Router not initialized - call createAppRouter() first') + } + + return routerInstance +} diff --git a/fe/src/stores/auth.ts b/fe/src/stores/auth.ts index e2ab3af5d..67a2f0cc1 100644 --- a/fe/src/stores/auth.ts +++ b/fe/src/stores/auth.ts @@ -22,6 +22,7 @@ import { sessionTokenManager } from '@/utils/sessionToken' import { clearAllAuthData } from '@/utils/sessionDebug' import { errorTracking } from '@/services/errorTracking' import { getStartupConfigCached } from '@/services/startupConfigCache' +import { getRouter } from '@/services/routerInstance' export const useAuthStore = defineStore('auth', () => { const user = ref<{ authenticated: boolean; name?: string }>({ authenticated: false }) @@ -66,8 +67,7 @@ export const useAuthStore = defineStore('auth', () => { } try { - const routerModule = await import('@/router') - const router = routerModule.getRouter() + const router = getRouter() const route = router.currentRoute.value const redirect = route.fullPath || current diff --git a/fe/vite.config.ts b/fe/vite.config.ts index d03bc51e4..d0d4c1b48 100644 --- a/fe/vite.config.ts +++ b/fe/vite.config.ts @@ -28,6 +28,32 @@ export default defineConfig(({ mode }) => { ], build: { sourcemap: analyzeBundle, + rollupOptions: { + onLog(level, log, handler) { + const code = typeof log === 'object' && log ? String(log.code ?? '') : '' + const id = typeof log === 'object' && log ? String(log.id ?? '') : '' + const message = typeof log === 'object' && log ? String(log.message ?? '') : String(log) + + if ( + level === 'warn' && + code === 'INVALID_ANNOTATION' && + id.includes('node_modules/@vueuse/core/') + ) { + return + } + + if ( + level === 'warn' && + code === 'INEFFECTIVE_DYNAMIC_IMPORT' && + message.includes('src/router/index.ts') && + message.includes('src/stores/auth.ts') + ) { + return + } + + handler(level, log) + }, + }, }, resolve: { alias: { diff --git a/listenarr.api/Listenarr.Api.csproj b/listenarr.api/Listenarr.Api.csproj index 8879c3948..3f8823871 100644 --- a/listenarr.api/Listenarr.Api.csproj +++ b/listenarr.api/Listenarr.Api.csproj @@ -11,6 +11,7 @@ true true $(NoWarn);1591 + linux-x64;linux-arm64;win-x64;osx-x64 @@ -177,4 +178,4 @@ - \ No newline at end of file + diff --git a/listenarr.api/packages.lock.json b/listenarr.api/packages.lock.json new file mode 100644 index 000000000..5e811971b --- /dev/null +++ b/listenarr.api/packages.lock.json @@ -0,0 +1,557 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Asp.Versioning.Mvc": { + "type": "Direct", + "requested": "[10.0.0, )", + "resolved": "10.0.0", + "contentHash": "W0wZ+0uZ0UK4KstjvEkNBZ0xxhBmxunwNg8582SVyyW7txQmSXibtm8fC4o82LaemPquYskms67bIbJOSrnlug==", + "dependencies": { + "Asp.Versioning.Http": "10.0.0" + } + }, + "Asp.Versioning.Mvc.ApiExplorer": { + "type": "Direct", + "requested": "[10.0.0, )", + "resolved": "10.0.0", + "contentHash": "H54UOpRoc4RmhQ4RA2lzDz43a/hAu/JN19Yyy/DNmH4XlRxhemfhifJyh9BaXNJOtGa2Dnu2xEeP4VSiTdUdAg==", + "dependencies": { + "Asp.Versioning.Mvc": "10.0.0" + } + }, + "AsyncKeyedLock": { + "type": "Direct", + "requested": "[8.0.2, )", + "resolved": "8.0.2", + "contentHash": "QGys5cnIerNryv7V14PDkvGnlLz69kJtTfdnr+Lndcu+lRre397RNyU4FIeAJWgI9u73lTzXL52Qca9B/ncLXw==" + }, + "HtmlAgilityPack": { + "type": "Direct", + "requested": "[1.12.4, )", + "resolved": "1.12.4", + "contentHash": "ljqvBabvFwKoLniuoQKO8b5bJfJweKLs4fUNS/V5dsvpo0A8MlJqxxn9XVmP2DaskbUXty6IYaWAi1SArGIMeQ==" + }, + "Microsoft.AspNetCore.OpenApi": { + "type": "Direct", + "requested": "[10.0.8, )", + "resolved": "10.0.8", + "contentHash": "cw24xHE2QaWwyEG9GQwFbjboyabub6Vd80DIItUGENzcQOa/BEnTrXsg2GADqWTmY/3ycqk9ToLGjgvF/VRlGA==", + "dependencies": { + "Microsoft.OpenApi": "2.0.0" + } + }, + "Microsoft.Data.Sqlite.Core": { + "type": "Direct", + "requested": "[10.0.8, )", + "resolved": "10.0.8", + "contentHash": "26t7WDiEjjAls/sFpWvVEFDxt+7Q5VPt6+blU2Lafuj9L8PzAv/GtGV4cqVPtrhWbfD2BX/z2v8hD1qXYtK6Aw==", + "dependencies": { + "SQLitePCLRaw.core": "2.1.11" + } + }, + "Microsoft.EntityFrameworkCore": { + "type": "Direct", + "requested": "[10.0.8, )", + "resolved": "10.0.8", + "contentHash": "EJx+fIBMgBlgD+ublKCn+GTOJkw3UqV7xOjYWBRVdUYyIm8UfvAsmSOPFiIInsWTHyMEYUJ9gCJY1jwX+6UB7w==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Abstractions": "10.0.8", + "Microsoft.EntityFrameworkCore.Analyzers": "10.0.8" + } + }, + "Microsoft.EntityFrameworkCore.Design": { + "type": "Direct", + "requested": "[10.0.8, )", + "resolved": "10.0.8", + "contentHash": "LlUUXdfqKFk7RlGExojVP8GI6hN9O21WjpxFnp5mLeGjd9iYdwywIgK9WOLvPM2hrknrRyHR/i43FQdw/oCrOw==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.Build.Framework": "18.0.2", + "Microsoft.CodeAnalysis.CSharp": "5.0.0", + "Microsoft.CodeAnalysis.CSharp.Workspaces": "5.0.0", + "Microsoft.CodeAnalysis.Workspaces.MSBuild": "5.0.0", + "Microsoft.EntityFrameworkCore.Relational": "10.0.8", + "Microsoft.Extensions.DependencyModel": "10.0.8", + "Mono.TextTemplating": "3.0.0", + "Newtonsoft.Json": "13.0.3" + } + }, + "Microsoft.EntityFrameworkCore.Sqlite": { + "type": "Direct", + "requested": "[10.0.8, )", + "resolved": "10.0.8", + "contentHash": "8BGSSKBDDBC8s6ye1Y2Ar1BToeZHLHOzUn0nAOng4Z+8dJ4KQKC/1qYFPgRYchDCOMQh98REHco8SrrMYsHuMQ==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Sqlite.Core": "10.0.8", + "Microsoft.Extensions.DependencyModel": "10.0.8", + "SQLitePCLRaw.bundle_e_sqlite3": "2.1.11", + "SQLitePCLRaw.core": "2.1.11" + } + }, + "Microsoft.Extensions.Http.Polly": { + "type": "Direct", + "requested": "[10.0.8, )", + "resolved": "10.0.8", + "contentHash": "XXYEV1G6ILrK7F3zwjQxxbYKZba79NUz7cgy1wEjctcxNHI5i8YI5eOCkPhcZ//vvuT8vd+GdNBfPdYDOPCL1A==", + "dependencies": { + "Polly": "7.2.4", + "Polly.Extensions.Http": "3.0.0" + } + }, + "Polly": { + "type": "Direct", + "requested": "[8.6.6, )", + "resolved": "8.6.6", + "contentHash": "czKHYJ6uGowPijuZt4kgF4njfGvWxVZ8mKBcrZ9iEtwDe9HKdF0ug6p6TwUG8EHuuufgbDU//rSBFebt5/0Fyw==", + "dependencies": { + "Polly.Core": "8.6.6" + } + }, + "Serilog.AspNetCore": { + "type": "Direct", + "requested": "[10.0.0, )", + "resolved": "10.0.0", + "contentHash": "a/cNa1mY4On1oJlfGG1wAvxjp5g7OEzk/Jf/nm7NF9cWoE7KlZw1GldrifUBWm9oKibHkR7Lg/l5jy3y7ACR8w==", + "dependencies": { + "Serilog": "4.3.0", + "Serilog.Extensions.Hosting": "10.0.0", + "Serilog.Formatting.Compact": "3.0.0", + "Serilog.Settings.Configuration": "10.0.0", + "Serilog.Sinks.Console": "6.1.1", + "Serilog.Sinks.Debug": "3.0.0", + "Serilog.Sinks.File": "7.0.0" + } + }, + "Serilog.Sinks.File": { + "type": "Direct", + "requested": "[7.0.0, )", + "resolved": "7.0.0", + "contentHash": "fKL7mXv7qaiNBUC71ssvn/dU0k9t0o45+qm2XgKAlSt19xF+ijjxyA3R6HmCgfKEKwfcfkwWjayuQtRueZFkYw==", + "dependencies": { + "Serilog": "4.2.0" + } + }, + "SixLabors.ImageSharp": { + "type": "Direct", + "requested": "[3.1.12, )", + "resolved": "3.1.12", + "contentHash": "iAg6zifihXEFS/t7fiHhZBGAdCp3FavsF4i2ZIDp0JfeYeDVzvmlbY1CNhhIKimaIzrzSi5M/NBFcWvZT2rB/A==" + }, + "Swashbuckle.AspNetCore": { + "type": "Direct", + "requested": "[10.2.1, )", + "resolved": "10.2.1", + "contentHash": "SDU6akgCV/H4jFMRfyJ0mgO5jWOuuAqekvEThXg8c/LjnfNz5Nkaz+RUpeTVJKWIRX4wDKC/6R3ogJ4AsRE32A==", + "dependencies": { + "Microsoft.Extensions.ApiDescription.Server": "10.0.0", + "Swashbuckle.AspNetCore.Swagger": "10.2.1", + "Swashbuckle.AspNetCore.SwaggerGen": "10.2.1", + "Swashbuckle.AspNetCore.SwaggerUI": "10.2.1" + } + }, + "TagLibSharp": { + "type": "Direct", + "requested": "[2.3.0, )", + "resolved": "2.3.0", + "contentHash": "Qo4z6ZjnIfbR3Us1Za5M2vQ97OWZPmODvVmepxZ8XW0UIVLGdO2T63/N3b23kCcyiwuIe0TQvMEQG8wUCCD1mA==" + }, + "Asp.Versioning.Abstractions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "cMRE5nvNMfBgfkb0XFWst/7UtyXCjoAXnV0L4Scx4P9fcf0idgrj1Z0c+3ylsy01K4cOib7dKhCBfpg5z3r0Kg==" + }, + "Asp.Versioning.Http": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "xmNm9FM2d20NKy7i1osEQysf7pJ4iJjWnM6e8CoeIhUREqG8nugsfC82pGpmzlatjAJL5T52ieSpyW+GFdSsSQ==", + "dependencies": { + "Asp.Versioning.Abstractions": "10.0.0" + } + }, + "Humanizer.Core": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw==" + }, + "Microsoft.Build.Framework": { + "type": "Transitive", + "resolved": "18.0.2", + "contentHash": "sOSb+0J4G/jCBW/YqmRuL0eOMXgfw1KQLdC9TkbvfA5xs7uNm+PBQXJCOzSJGXtZcZrtXozcwxPmUiRUbmd7FA==" + }, + "Microsoft.CodeAnalysis.Analyzers": { + "type": "Transitive", + "resolved": "3.11.0", + "contentHash": "v/EW3UE8/lbEYHoC2Qq7AR/DnmvpgdtAMndfQNmpuIMx/Mto8L5JnuCfdBYtgvalQOtfNCnxFejxuRrryvUTsg==" + }, + "Microsoft.CodeAnalysis.Common": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "ZXRAdvH6GiDeHRyd3q/km8Z44RoM6FBWHd+gen/la81mVnAdHTEsEkO5J0TCNXBymAcx5UYKt5TvgKBhaLJEow==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "3.11.0" + } + }, + "Microsoft.CodeAnalysis.CSharp": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "5DSyJ9bk+ATuDy7fp2Zt0mJStDVKbBoiz1DyfAwSa+k4H4IwykAUcV3URelw5b8/iVbfSaOwkwmPUZH6opZKCw==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.Common": "[5.0.0]" + } + }, + "Microsoft.CodeAnalysis.CSharp.Workspaces": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "Al/Q8B+yO8odSqGVpSvrShMFDvlQdIBU//F3E6Rb0YdiLSALE9wh/pvozPNnfmh5HDnvU+mkmSjpz4hQO++jaA==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.CSharp": "[5.0.0]", + "Microsoft.CodeAnalysis.Common": "[5.0.0]", + "Microsoft.CodeAnalysis.Workspaces.Common": "[5.0.0]", + "System.Composition": "9.0.0" + } + }, + "Microsoft.CodeAnalysis.Workspaces.Common": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "ZbUmIvT6lqTNKiv06Jl5wf0MTMi1vQ1oH7ou4CLcs2C/no/L7EhP3T8y3XXvn9VbqMcJaJnEsNA1jwYUMgc5jg==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.Common": "[5.0.0]", + "System.Composition": "9.0.0" + } + }, + "Microsoft.CodeAnalysis.Workspaces.MSBuild": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "/G+LVoAGMz6Ae8nm+PGLxSw+F5RjYx/J7irbTO5uKAPw1bxHyQJLc/YOnpDxt+EpPtYxvC9wvBsg/kETZp1F9Q==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.Build.Framework": "17.11.31", + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.Workspaces.Common": "[5.0.0]", + "Microsoft.VisualStudio.SolutionPersistence": "1.0.52", + "Newtonsoft.Json": "13.0.3", + "System.Composition": "9.0.0" + } + }, + "Microsoft.EntityFrameworkCore.Abstractions": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "jbKDXWPZQhuPHygMnwzNOqxBADVcpRVytcKYZsA++QqhPkpF93Ta8o5mbJQGrARSjlkr9WtOaADV97EDMOZ7DA==" + }, + "Microsoft.EntityFrameworkCore.Analyzers": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "M3BZ8JH8rB6BE7dO2g9iVbrHLnEz9wMXT6q+tDR6Nq3gyP3KmBj5OTiZGxyF3vesjOQNKanYoPGSNBR4kR2llg==" + }, + "Microsoft.EntityFrameworkCore.Relational": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "UU3diAD2wwZveye2rnrwaF/wvJ9tm5iL2fuY9TTap6/iGQK1OO29M1BzXZRlRPVH/dByt5w/pISBSFtyR7hTqw==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "10.0.8" + } + }, + "Microsoft.EntityFrameworkCore.Sqlite.Core": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "cFRBlY3sCoVX5JFDrRHQQHcbSms7CwBjjeuVEgQ4KP8WzPopgwNk3sJ0k7xKkIl0b9eUFJ0IR0aZwElT9154Ag==", + "dependencies": { + "Microsoft.Data.Sqlite.Core": "10.0.8", + "Microsoft.EntityFrameworkCore.Relational": "10.0.8", + "Microsoft.Extensions.DependencyModel": "10.0.8", + "SQLitePCLRaw.core": "2.1.11" + } + }, + "Microsoft.Extensions.ApiDescription.Server": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "NCWCGiwRwje8773yzPQhvucYnnfeR+ZoB1VRIrIMp4uaeUNw7jvEPHij3HIbwCDuNCrNcphA00KSAR9yD9qmbg==" + }, + "Microsoft.Extensions.DependencyModel": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "vLyZVpxmduO2jx+76ggqnsA3m81kwMY3NkWciNTj5E+Nvqb0VihqCvQP89QsGONWp0AJwMZG+u9GzaCjDdFGNw==" + }, + "Microsoft.OpenApi": { + "type": "Transitive", + "resolved": "2.7.5", + "contentHash": "0FA67RSnRM4tcBKqiqVu/HPdZ9+QOKbmeRjxRUGTCjPU4C0bmUhd97Dso7Yild5P7nOV6GxJ2xrK0Kv/O9xp0w==" + }, + "Microsoft.VisualStudio.SolutionPersistence": { + "type": "Transitive", + "resolved": "1.0.52", + "contentHash": "oNv2JtYXhpdJrX63nibx1JT3uCESOBQ1LAk7Dtz/sr0+laW0KRM6eKp4CZ3MHDR2siIkKsY8MmUkeP5DKkQQ5w==" + }, + "Mono.TextTemplating": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "YqueG52R/Xej4VVbKuRIodjiAhV0HR/XVbLbNrJhCZnzjnSjgMJ/dCdV0akQQxavX6hp/LC6rqLGLcXeQYU7XA==", + "dependencies": { + "System.CodeDom": "6.0.0" + } + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.3", + "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" + }, + "Polly.Core": { + "type": "Transitive", + "resolved": "8.6.6", + "contentHash": "lCBL9mmhF9TZxHG3beVRkyjlLohkIC464xIAq7J7Y59C+z42hmsdUaeCKl2SIAYertOUU5TeBXyQDLDQGIKePQ==" + }, + "Polly.Extensions.Http": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "drrG+hB3pYFY7w1c3BD+lSGYvH2oIclH8GRSehgfyP5kjnFnHKQuuBhuHLv+PWyFuaTDyk/vfRpnxOzd11+J8g==", + "dependencies": { + "Polly": "7.1.0" + } + }, + "Serilog": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "+cDryFR0GRhsGOnZSKwaDzRRl4MupvJ42FhCE4zhQRVanX0Jpg6WuCBk59OVhVDPmab1bB+nRykAnykYELA9qQ==" + }, + "Serilog.Extensions.Hosting": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "E7juuIc+gzoGxgzFooFgAV8g9BfiSXNKsUok9NmEpyAXg2odkcPsMa/Yo4axkJRlh0se7mkYQ1GXDaBemR+b6w==", + "dependencies": { + "Serilog": "4.3.0", + "Serilog.Extensions.Logging": "10.0.0" + } + }, + "Serilog.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "vx0kABKl2dWbBhhqAfTOk53/i8aV/5VaT3a6il9gn72Wqs2pM7EK2OB6No6xdqK2IaY6Zf9gdjLuK9BVa2rT+Q==", + "dependencies": { + "Serilog": "4.2.0" + } + }, + "Serilog.Formatting.Compact": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "wQsv14w9cqlfB5FX2MZpNsTawckN4a8dryuNGbebB/3Nh1pXnROHZov3swtu3Nj5oNG7Ba+xdu7Et/ulAUPanQ==", + "dependencies": { + "Serilog": "4.0.0" + } + }, + "Serilog.Settings.Configuration": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "LNq+ibS1sbhTqPV1FIE69/9AJJbfaOhnaqkzcjFy95o+4U+STsta9mi97f1smgXsWYKICDeGUf8xUGzd/52/uA==", + "dependencies": { + "Microsoft.Extensions.DependencyModel": "10.0.0", + "Serilog": "4.3.0" + } + }, + "Serilog.Sinks.Console": { + "type": "Transitive", + "resolved": "6.1.1", + "contentHash": "8jbqgjUyZlfCuSTaJk6lOca465OndqOz3KZP6Cryt/IqZYybyBu7GP0fE/AXBzrrQB3EBmQntBFAvMVz1COvAA==", + "dependencies": { + "Serilog": "4.0.0" + } + }, + "Serilog.Sinks.Debug": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "4BzXcdrgRX7wde9PmHuYd9U6YqycCC28hhpKonK7hx0wb19eiuRj16fPcPSVp0o/Y1ipJuNLYQ00R3q2Zs8FDA==", + "dependencies": { + "Serilog": "4.0.0" + } + }, + "SourceGear.sqlite3": { + "type": "Transitive", + "resolved": "3.50.4.5", + "contentHash": "UtnipXhJYZKQOQIfpws/msLK7IRhMplE1CZCaZLIQXRnGD474QVpO/J9nMlQQY8NZueGz1aidjoxDRnrC1NT3Q==" + }, + "SQLitePCLRaw.config.e_sqlite3": { + "type": "Transitive", + "resolved": "3.0.3", + "contentHash": "caP/ap0X2fyVmstCXu5ueOmcr2XWAxA2XyKghV7H4bOAFmq3nWcsGl9q44iY1HYG+i8Qr4G9XEqdfti0rV6/ZQ==", + "dependencies": { + "SQLitePCLRaw.provider.e_sqlite3": "3.0.3" + } + }, + "SQLitePCLRaw.core": { + "type": "Transitive", + "resolved": "3.0.3", + "contentHash": "bjm6FY4lZyP+t7GmiuvSM0QXpFihAvyE0Y9O2yibm3g95AAWJPNnHOKVNJGyPTGIKuK7Pr4Wh8Rd8/aOtAclQw==" + }, + "SQLitePCLRaw.provider.e_sqlite3": { + "type": "Transitive", + "resolved": "3.0.3", + "contentHash": "wd+fGvZTrr3BJNe48opSczmC176Okd61ZgoZNQcdvZwkek6to978ccdpcFmNo5GHxCnk29KwT+f+lAZYgfLVZg==", + "dependencies": { + "SQLitePCLRaw.core": "3.0.3" + } + }, + "Swashbuckle.AspNetCore.Swagger": { + "type": "Transitive", + "resolved": "10.2.1", + "contentHash": "ej4inPhiWCq+0utG8yaKhIhE8M3k3R/qRaGhpgDZB+O/s+o62/zRMO1Cn2CtQccsrqPE9PYnzCp6hQGYGpJOyQ==", + "dependencies": { + "Microsoft.OpenApi": "2.7.5" + } + }, + "Swashbuckle.AspNetCore.SwaggerGen": { + "type": "Transitive", + "resolved": "10.2.1", + "contentHash": "JYX6i/y0xEtQWH/hZyfcage1/ldwww83ueD/gBc34uSnMwyvRLUsOpYcxlliFFxFbZMrY6t+R9ENqolE7zTEOg==", + "dependencies": { + "Swashbuckle.AspNetCore.Swagger": "10.2.1" + } + }, + "Swashbuckle.AspNetCore.SwaggerUI": { + "type": "Transitive", + "resolved": "10.2.1", + "contentHash": "vzB8ZAGqXus3fdareJ9GHctaRP9ZL+wW9x8U7s1Y+BWprInFvSg6rpD9VhANNpwXA8fUHqu5Agjl/+hHG1BCQA==" + }, + "System.CodeDom": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "CPc6tWO1LAer3IzfZufDBRL+UZQcj5uS207NHALQzP84Vp/z6wF0Aa0YZImOQY8iStY0A2zI/e3ihKNPfUm8XA==" + }, + "System.Composition": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "3Djj70fFTraOarSKmRnmRy/zm4YurICm+kiCtI0dYRqGJnLX6nJ+G3WYuFJ173cAPax/gh96REcbNiVqcrypFQ==", + "dependencies": { + "System.Composition.AttributedModel": "9.0.0", + "System.Composition.Convention": "9.0.0", + "System.Composition.Hosting": "9.0.0", + "System.Composition.Runtime": "9.0.0", + "System.Composition.TypedParts": "9.0.0" + } + }, + "System.Composition.AttributedModel": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "iri00l/zIX9g4lHMY+Nz0qV1n40+jFYAmgsaiNn16xvt2RDwlqByNG4wgblagnDYxm3YSQQ0jLlC/7Xlk9CzyA==" + }, + "System.Composition.Convention": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "+vuqVP6xpi582XIjJi6OCsIxuoTZfR0M7WWufk3uGDeCl3wGW6KnpylUJ3iiXdPByPE0vR5TjJgR6hDLez4FQg==", + "dependencies": { + "System.Composition.AttributedModel": "9.0.0" + } + }, + "System.Composition.Hosting": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "OFqSeFeJYr7kHxDfaViGM1ymk7d4JxK//VSoNF9Ux0gpqkLsauDZpu89kTHHNdCWfSljbFcvAafGyBoY094btQ==", + "dependencies": { + "System.Composition.Runtime": "9.0.0" + } + }, + "System.Composition.Runtime": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "w1HOlQY1zsOWYussjFGZCEYF2UZXgvoYnS94NIu2CBnAGMbXFAX8PY8c92KwUItPmowal68jnVLBCzdrWLeEKA==" + }, + "System.Composition.TypedParts": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "aRZlojCCGEHDKqh43jaDgaVpYETsgd7Nx4g1zwLKMtv4iTo0627715ajEFNpEEBTgLmvZuv8K0EVxc3sM4NWJA==", + "dependencies": { + "System.Composition.AttributedModel": "9.0.0", + "System.Composition.Hosting": "9.0.0", + "System.Composition.Runtime": "9.0.0" + } + }, + "listenarr.application": { + "type": "Project", + "dependencies": { + "AsyncKeyedLock": "[8.0.2, )", + "HtmlAgilityPack": "[1.12.4, )", + "Listenarr.Domain": "[1.0.0, )", + "Microsoft.Data.Sqlite.Core": "[10.0.8, )", + "Microsoft.EntityFrameworkCore": "[10.0.8, )", + "Microsoft.EntityFrameworkCore.Sqlite": "[10.0.8, )", + "SixLabors.ImageSharp": "[3.1.12, )", + "Swashbuckle.AspNetCore": "[10.2.1, )", + "TagLibSharp": "[2.3.0, )" + } + }, + "listenarr.domain": { + "type": "Project" + }, + "listenarr.infrastructure": { + "type": "Project", + "dependencies": { + "BencodeNET": "[4.0.0, )", + "HtmlAgilityPack": "[1.12.4, )", + "Listenarr.Application": "[1.0.0, )", + "Listenarr.Domain": "[1.0.0, )", + "Microsoft.EntityFrameworkCore": "[10.0.8, )", + "Microsoft.EntityFrameworkCore.Sqlite": "[10.0.8, )", + "Microsoft.Extensions.Http.Polly": "[10.0.8, )", + "Polly": "[8.6.6, )", + "Serilog.Sinks.File": "[7.0.0, )", + "SharpCompress": "[0.49.1, )", + "Swashbuckle.AspNetCore": "[10.2.1, )" + } + }, + "BencodeNET": { + "type": "CentralTransitive", + "requested": "[4.0.0, )", + "resolved": "4.0.0", + "contentHash": "dsgswftoaNKuKdOiRz7pTpk0RyuPHOWrAdc5/ohP3YOfAVzosKrHY8qZZBdjX/fHa6SA63wp62K6wQX93uuyFw==" + }, + "SharpCompress": { + "type": "CentralTransitive", + "requested": "[0.49.1, )", + "resolved": "0.49.1", + "contentHash": "Meygd8HAnUgqYzxvCsaYR5XnZAG2xBmxkQHVGi/HkCjrvEq+tiM+VPQRvYLxsbse3KUmec65ccdMiOXv8CkjsA==" + }, + "SQLitePCLRaw.bundle_e_sqlite3": { + "type": "CentralTransitive", + "requested": "[3.0.3, )", + "resolved": "3.0.3", + "contentHash": "Zt8jmSL5zcDWGk8rmzhWBJ6IRyLWh1yWS04Pg72+GIvo3Ba4E/rG4Y/4l7AWlSEogEbzyKRTCXUAs1v/O7Pkkg==", + "dependencies": { + "SQLitePCLRaw.config.e_sqlite3": "3.0.3", + "SourceGear.sqlite3": "3.50.4.5" + } + } + }, + "net10.0/linux-arm64": { + "SourceGear.sqlite3": { + "type": "Transitive", + "resolved": "3.50.4.5", + "contentHash": "UtnipXhJYZKQOQIfpws/msLK7IRhMplE1CZCaZLIQXRnGD474QVpO/J9nMlQQY8NZueGz1aidjoxDRnrC1NT3Q==" + } + }, + "net10.0/linux-x64": { + "SourceGear.sqlite3": { + "type": "Transitive", + "resolved": "3.50.4.5", + "contentHash": "UtnipXhJYZKQOQIfpws/msLK7IRhMplE1CZCaZLIQXRnGD474QVpO/J9nMlQQY8NZueGz1aidjoxDRnrC1NT3Q==" + } + }, + "net10.0/osx-x64": { + "SourceGear.sqlite3": { + "type": "Transitive", + "resolved": "3.50.4.5", + "contentHash": "UtnipXhJYZKQOQIfpws/msLK7IRhMplE1CZCaZLIQXRnGD474QVpO/J9nMlQQY8NZueGz1aidjoxDRnrC1NT3Q==" + } + }, + "net10.0/win-x64": { + "SourceGear.sqlite3": { + "type": "Transitive", + "resolved": "3.50.4.5", + "contentHash": "UtnipXhJYZKQOQIfpws/msLK7IRhMplE1CZCaZLIQXRnGD474QVpO/J9nMlQQY8NZueGz1aidjoxDRnrC1NT3Q==" + } + } + } +} \ No newline at end of file diff --git a/listenarr.application/packages.lock.json b/listenarr.application/packages.lock.json new file mode 100644 index 000000000..3b54dd6d6 --- /dev/null +++ b/listenarr.application/packages.lock.json @@ -0,0 +1,262 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "AsyncKeyedLock": { + "type": "Direct", + "requested": "[8.0.2, )", + "resolved": "8.0.2", + "contentHash": "QGys5cnIerNryv7V14PDkvGnlLz69kJtTfdnr+Lndcu+lRre397RNyU4FIeAJWgI9u73lTzXL52Qca9B/ncLXw==" + }, + "HtmlAgilityPack": { + "type": "Direct", + "requested": "[1.12.4, )", + "resolved": "1.12.4", + "contentHash": "ljqvBabvFwKoLniuoQKO8b5bJfJweKLs4fUNS/V5dsvpo0A8MlJqxxn9XVmP2DaskbUXty6IYaWAi1SArGIMeQ==" + }, + "Microsoft.Data.Sqlite.Core": { + "type": "Direct", + "requested": "[10.0.8, )", + "resolved": "10.0.8", + "contentHash": "26t7WDiEjjAls/sFpWvVEFDxt+7Q5VPt6+blU2Lafuj9L8PzAv/GtGV4cqVPtrhWbfD2BX/z2v8hD1qXYtK6Aw==", + "dependencies": { + "SQLitePCLRaw.core": "2.1.11" + } + }, + "Microsoft.EntityFrameworkCore": { + "type": "Direct", + "requested": "[10.0.8, )", + "resolved": "10.0.8", + "contentHash": "EJx+fIBMgBlgD+ublKCn+GTOJkw3UqV7xOjYWBRVdUYyIm8UfvAsmSOPFiIInsWTHyMEYUJ9gCJY1jwX+6UB7w==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Abstractions": "10.0.8", + "Microsoft.EntityFrameworkCore.Analyzers": "10.0.8", + "Microsoft.Extensions.Caching.Memory": "10.0.8", + "Microsoft.Extensions.Logging": "10.0.8" + } + }, + "Microsoft.EntityFrameworkCore.Sqlite": { + "type": "Direct", + "requested": "[10.0.8, )", + "resolved": "10.0.8", + "contentHash": "8BGSSKBDDBC8s6ye1Y2Ar1BToeZHLHOzUn0nAOng4Z+8dJ4KQKC/1qYFPgRYchDCOMQh98REHco8SrrMYsHuMQ==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Sqlite.Core": "10.0.8", + "Microsoft.Extensions.Caching.Memory": "10.0.8", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", + "Microsoft.Extensions.DependencyModel": "10.0.8", + "Microsoft.Extensions.Logging": "10.0.8", + "SQLitePCLRaw.bundle_e_sqlite3": "2.1.11", + "SQLitePCLRaw.core": "2.1.11" + } + }, + "SixLabors.ImageSharp": { + "type": "Direct", + "requested": "[3.1.12, )", + "resolved": "3.1.12", + "contentHash": "iAg6zifihXEFS/t7fiHhZBGAdCp3FavsF4i2ZIDp0JfeYeDVzvmlbY1CNhhIKimaIzrzSi5M/NBFcWvZT2rB/A==" + }, + "Swashbuckle.AspNetCore": { + "type": "Direct", + "requested": "[10.2.1, )", + "resolved": "10.2.1", + "contentHash": "SDU6akgCV/H4jFMRfyJ0mgO5jWOuuAqekvEThXg8c/LjnfNz5Nkaz+RUpeTVJKWIRX4wDKC/6R3ogJ4AsRE32A==", + "dependencies": { + "Microsoft.Extensions.ApiDescription.Server": "10.0.0", + "Swashbuckle.AspNetCore.Swagger": "10.2.1", + "Swashbuckle.AspNetCore.SwaggerGen": "10.2.1", + "Swashbuckle.AspNetCore.SwaggerUI": "10.2.1" + } + }, + "TagLibSharp": { + "type": "Direct", + "requested": "[2.3.0, )", + "resolved": "2.3.0", + "contentHash": "Qo4z6ZjnIfbR3Us1Za5M2vQ97OWZPmODvVmepxZ8XW0UIVLGdO2T63/N3b23kCcyiwuIe0TQvMEQG8wUCCD1mA==" + }, + "Microsoft.EntityFrameworkCore.Abstractions": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "jbKDXWPZQhuPHygMnwzNOqxBADVcpRVytcKYZsA++QqhPkpF93Ta8o5mbJQGrARSjlkr9WtOaADV97EDMOZ7DA==" + }, + "Microsoft.EntityFrameworkCore.Analyzers": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "M3BZ8JH8rB6BE7dO2g9iVbrHLnEz9wMXT6q+tDR6Nq3gyP3KmBj5OTiZGxyF3vesjOQNKanYoPGSNBR4kR2llg==" + }, + "Microsoft.EntityFrameworkCore.Relational": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "UU3diAD2wwZveye2rnrwaF/wvJ9tm5iL2fuY9TTap6/iGQK1OO29M1BzXZRlRPVH/dByt5w/pISBSFtyR7hTqw==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "10.0.8", + "Microsoft.Extensions.Caching.Memory": "10.0.8", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", + "Microsoft.Extensions.Logging": "10.0.8" + } + }, + "Microsoft.EntityFrameworkCore.Sqlite.Core": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "cFRBlY3sCoVX5JFDrRHQQHcbSms7CwBjjeuVEgQ4KP8WzPopgwNk3sJ0k7xKkIl0b9eUFJ0IR0aZwElT9154Ag==", + "dependencies": { + "Microsoft.Data.Sqlite.Core": "10.0.8", + "Microsoft.EntityFrameworkCore.Relational": "10.0.8", + "Microsoft.Extensions.Caching.Memory": "10.0.8", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", + "Microsoft.Extensions.DependencyModel": "10.0.8", + "Microsoft.Extensions.Logging": "10.0.8", + "SQLitePCLRaw.core": "2.1.11" + } + }, + "Microsoft.Extensions.ApiDescription.Server": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "NCWCGiwRwje8773yzPQhvucYnnfeR+ZoB1VRIrIMp4uaeUNw7jvEPHij3HIbwCDuNCrNcphA00KSAR9yD9qmbg==" + }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "EoK2TwVR1daxmfXUPnvIYZSk5XQjHe45sGekox4kvMt88KQZQhDVzYW5Na5+oNwTuRpE48hipyGJg12F1Tm70w==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.8" + } + }, + "Microsoft.Extensions.Caching.Memory": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "sYMYQjNprfqPTryuLNnr0/AOtnhlfuZ0ZxyOV0d3AXOEL8j9KV0EbelpZYyIatT2hJiaSGO9XGr5YDRsh22OfQ==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "10.0.8", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", + "Microsoft.Extensions.Logging.Abstractions": "10.0.8", + "Microsoft.Extensions.Options": "10.0.8", + "Microsoft.Extensions.Primitives": "10.0.8" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "I63esIFbL3h5pSt7gXpXOlmcwDmYBUoYNEglKfDPFUqtYvSV84f2l28hO2lfVXsV0wdlplgAM7IVz16matapSg==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.8" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "daf62xHIrq8pnE709hgaZZN9tSam9TGGepWe1+bE6V3GEuVwJiMs6ib+38lfMCyAJAHiX0vapxBhsuMSV7U+cg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "21nbDV60SRPWGIivsyl6lqBeEJNG1sginhhfWgRrr3Ais7aQ12To25OAHQxgoiJkjqy1aQ6RxpZBGYuTi7Ge6A==" + }, + "Microsoft.Extensions.DependencyModel": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "vLyZVpxmduO2jx+76ggqnsA3m81kwMY3NkWciNTj5E+Nvqb0VihqCvQP89QsGONWp0AJwMZG+u9GzaCjDdFGNw==" + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "K60JhWC2hN/Gi7TP68tBxSzk5ACWOs7lkmPzsfA8Bcf/IXTajujt2ORMf9rSMk1bsng6Lv4Y3fuxp3bm1+15ug==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "10.0.8", + "Microsoft.Extensions.Logging.Abstractions": "10.0.8", + "Microsoft.Extensions.Options": "10.0.8" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "fdVadZmsC8jRP0KvKy8mO8f6GV/HyBvElfcSxEhd+5FM5boAw/01iSaCto5G3G37ApJira4A3pNaVvBv8cUiLQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "VBD+131DpTNCNDfA4kIyKTiCySvJGNhwibdWBSdFRu7GMfXLXcXODkgA+KStKbbhzraLglZWUN4nXyHgW4JIRA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", + "Microsoft.Extensions.Primitives": "10.0.8" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "OBPo4nYhMyIbtueoC10CBm6AGAbo/A9IV8QQ/6ryZS7VvmqpGT7hunazeHLxFawRzn3oLOq4jhqhpBX4tfswWQ==" + }, + "Microsoft.OpenApi": { + "type": "Transitive", + "resolved": "2.7.5", + "contentHash": "0FA67RSnRM4tcBKqiqVu/HPdZ9+QOKbmeRjxRUGTCjPU4C0bmUhd97Dso7Yild5P7nOV6GxJ2xrK0Kv/O9xp0w==" + }, + "SourceGear.sqlite3": { + "type": "Transitive", + "resolved": "3.50.4.5", + "contentHash": "UtnipXhJYZKQOQIfpws/msLK7IRhMplE1CZCaZLIQXRnGD474QVpO/J9nMlQQY8NZueGz1aidjoxDRnrC1NT3Q==" + }, + "SQLitePCLRaw.config.e_sqlite3": { + "type": "Transitive", + "resolved": "3.0.3", + "contentHash": "caP/ap0X2fyVmstCXu5ueOmcr2XWAxA2XyKghV7H4bOAFmq3nWcsGl9q44iY1HYG+i8Qr4G9XEqdfti0rV6/ZQ==", + "dependencies": { + "SQLitePCLRaw.provider.e_sqlite3": "3.0.3" + } + }, + "SQLitePCLRaw.core": { + "type": "Transitive", + "resolved": "3.0.3", + "contentHash": "bjm6FY4lZyP+t7GmiuvSM0QXpFihAvyE0Y9O2yibm3g95AAWJPNnHOKVNJGyPTGIKuK7Pr4Wh8Rd8/aOtAclQw==" + }, + "SQLitePCLRaw.provider.e_sqlite3": { + "type": "Transitive", + "resolved": "3.0.3", + "contentHash": "wd+fGvZTrr3BJNe48opSczmC176Okd61ZgoZNQcdvZwkek6to978ccdpcFmNo5GHxCnk29KwT+f+lAZYgfLVZg==", + "dependencies": { + "SQLitePCLRaw.core": "3.0.3" + } + }, + "Swashbuckle.AspNetCore.Swagger": { + "type": "Transitive", + "resolved": "10.2.1", + "contentHash": "ej4inPhiWCq+0utG8yaKhIhE8M3k3R/qRaGhpgDZB+O/s+o62/zRMO1Cn2CtQccsrqPE9PYnzCp6hQGYGpJOyQ==", + "dependencies": { + "Microsoft.OpenApi": "2.7.5" + } + }, + "Swashbuckle.AspNetCore.SwaggerGen": { + "type": "Transitive", + "resolved": "10.2.1", + "contentHash": "JYX6i/y0xEtQWH/hZyfcage1/ldwww83ueD/gBc34uSnMwyvRLUsOpYcxlliFFxFbZMrY6t+R9ENqolE7zTEOg==", + "dependencies": { + "Swashbuckle.AspNetCore.Swagger": "10.2.1" + } + }, + "Swashbuckle.AspNetCore.SwaggerUI": { + "type": "Transitive", + "resolved": "10.2.1", + "contentHash": "vzB8ZAGqXus3fdareJ9GHctaRP9ZL+wW9x8U7s1Y+BWprInFvSg6rpD9VhANNpwXA8fUHqu5Agjl/+hHG1BCQA==" + }, + "listenarr.domain": { + "type": "Project" + }, + "SQLitePCLRaw.bundle_e_sqlite3": { + "type": "CentralTransitive", + "requested": "[3.0.3, )", + "resolved": "3.0.3", + "contentHash": "Zt8jmSL5zcDWGk8rmzhWBJ6IRyLWh1yWS04Pg72+GIvo3Ba4E/rG4Y/4l7AWlSEogEbzyKRTCXUAs1v/O7Pkkg==", + "dependencies": { + "SQLitePCLRaw.config.e_sqlite3": "3.0.3", + "SourceGear.sqlite3": "3.50.4.5" + } + } + } + } +} \ No newline at end of file diff --git a/listenarr.domain/packages.lock.json b/listenarr.domain/packages.lock.json new file mode 100644 index 000000000..6afd6786b --- /dev/null +++ b/listenarr.domain/packages.lock.json @@ -0,0 +1,6 @@ +{ + "version": 2, + "dependencies": { + "net10.0": {} + } +} \ No newline at end of file diff --git a/listenarr.infrastructure/packages.lock.json b/listenarr.infrastructure/packages.lock.json new file mode 100644 index 000000000..b9259085f --- /dev/null +++ b/listenarr.infrastructure/packages.lock.json @@ -0,0 +1,563 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "BencodeNET": { + "type": "Direct", + "requested": "[4.0.0, )", + "resolved": "4.0.0", + "contentHash": "dsgswftoaNKuKdOiRz7pTpk0RyuPHOWrAdc5/ohP3YOfAVzosKrHY8qZZBdjX/fHa6SA63wp62K6wQX93uuyFw==" + }, + "HtmlAgilityPack": { + "type": "Direct", + "requested": "[1.12.4, )", + "resolved": "1.12.4", + "contentHash": "ljqvBabvFwKoLniuoQKO8b5bJfJweKLs4fUNS/V5dsvpo0A8MlJqxxn9XVmP2DaskbUXty6IYaWAi1SArGIMeQ==" + }, + "Microsoft.EntityFrameworkCore": { + "type": "Direct", + "requested": "[10.0.8, )", + "resolved": "10.0.8", + "contentHash": "EJx+fIBMgBlgD+ublKCn+GTOJkw3UqV7xOjYWBRVdUYyIm8UfvAsmSOPFiIInsWTHyMEYUJ9gCJY1jwX+6UB7w==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Abstractions": "10.0.8", + "Microsoft.EntityFrameworkCore.Analyzers": "10.0.8", + "Microsoft.Extensions.Caching.Memory": "10.0.8", + "Microsoft.Extensions.Logging": "10.0.8" + } + }, + "Microsoft.EntityFrameworkCore.Design": { + "type": "Direct", + "requested": "[10.0.8, )", + "resolved": "10.0.8", + "contentHash": "LlUUXdfqKFk7RlGExojVP8GI6hN9O21WjpxFnp5mLeGjd9iYdwywIgK9WOLvPM2hrknrRyHR/i43FQdw/oCrOw==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.Build.Framework": "18.0.2", + "Microsoft.CodeAnalysis.CSharp": "5.0.0", + "Microsoft.CodeAnalysis.CSharp.Workspaces": "5.0.0", + "Microsoft.CodeAnalysis.Workspaces.MSBuild": "5.0.0", + "Microsoft.EntityFrameworkCore.Relational": "10.0.8", + "Microsoft.Extensions.Caching.Memory": "10.0.8", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", + "Microsoft.Extensions.DependencyModel": "10.0.8", + "Microsoft.Extensions.Logging": "10.0.8", + "Mono.TextTemplating": "3.0.0", + "Newtonsoft.Json": "13.0.3" + } + }, + "Microsoft.EntityFrameworkCore.Sqlite": { + "type": "Direct", + "requested": "[10.0.8, )", + "resolved": "10.0.8", + "contentHash": "8BGSSKBDDBC8s6ye1Y2Ar1BToeZHLHOzUn0nAOng4Z+8dJ4KQKC/1qYFPgRYchDCOMQh98REHco8SrrMYsHuMQ==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Sqlite.Core": "10.0.8", + "Microsoft.Extensions.Caching.Memory": "10.0.8", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", + "Microsoft.Extensions.DependencyModel": "10.0.8", + "Microsoft.Extensions.Logging": "10.0.8", + "SQLitePCLRaw.bundle_e_sqlite3": "2.1.11", + "SQLitePCLRaw.core": "2.1.11" + } + }, + "Microsoft.Extensions.Http.Polly": { + "type": "Direct", + "requested": "[10.0.8, )", + "resolved": "10.0.8", + "contentHash": "XXYEV1G6ILrK7F3zwjQxxbYKZba79NUz7cgy1wEjctcxNHI5i8YI5eOCkPhcZ//vvuT8vd+GdNBfPdYDOPCL1A==", + "dependencies": { + "Microsoft.Extensions.Http": "10.0.8", + "Polly": "7.2.4", + "Polly.Extensions.Http": "3.0.0" + } + }, + "Polly": { + "type": "Direct", + "requested": "[8.6.6, )", + "resolved": "8.6.6", + "contentHash": "czKHYJ6uGowPijuZt4kgF4njfGvWxVZ8mKBcrZ9iEtwDe9HKdF0ug6p6TwUG8EHuuufgbDU//rSBFebt5/0Fyw==", + "dependencies": { + "Polly.Core": "8.6.6" + } + }, + "Serilog.Sinks.File": { + "type": "Direct", + "requested": "[7.0.0, )", + "resolved": "7.0.0", + "contentHash": "fKL7mXv7qaiNBUC71ssvn/dU0k9t0o45+qm2XgKAlSt19xF+ijjxyA3R6HmCgfKEKwfcfkwWjayuQtRueZFkYw==", + "dependencies": { + "Serilog": "4.2.0" + } + }, + "SharpCompress": { + "type": "Direct", + "requested": "[0.49.1, )", + "resolved": "0.49.1", + "contentHash": "Meygd8HAnUgqYzxvCsaYR5XnZAG2xBmxkQHVGi/HkCjrvEq+tiM+VPQRvYLxsbse3KUmec65ccdMiOXv8CkjsA==" + }, + "Swashbuckle.AspNetCore": { + "type": "Direct", + "requested": "[10.2.1, )", + "resolved": "10.2.1", + "contentHash": "SDU6akgCV/H4jFMRfyJ0mgO5jWOuuAqekvEThXg8c/LjnfNz5Nkaz+RUpeTVJKWIRX4wDKC/6R3ogJ4AsRE32A==", + "dependencies": { + "Microsoft.Extensions.ApiDescription.Server": "10.0.0", + "Swashbuckle.AspNetCore.Swagger": "10.2.1", + "Swashbuckle.AspNetCore.SwaggerGen": "10.2.1", + "Swashbuckle.AspNetCore.SwaggerUI": "10.2.1" + } + }, + "Humanizer.Core": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw==" + }, + "Microsoft.Build.Framework": { + "type": "Transitive", + "resolved": "18.0.2", + "contentHash": "sOSb+0J4G/jCBW/YqmRuL0eOMXgfw1KQLdC9TkbvfA5xs7uNm+PBQXJCOzSJGXtZcZrtXozcwxPmUiRUbmd7FA==" + }, + "Microsoft.CodeAnalysis.Analyzers": { + "type": "Transitive", + "resolved": "3.11.0", + "contentHash": "v/EW3UE8/lbEYHoC2Qq7AR/DnmvpgdtAMndfQNmpuIMx/Mto8L5JnuCfdBYtgvalQOtfNCnxFejxuRrryvUTsg==" + }, + "Microsoft.CodeAnalysis.Common": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "ZXRAdvH6GiDeHRyd3q/km8Z44RoM6FBWHd+gen/la81mVnAdHTEsEkO5J0TCNXBymAcx5UYKt5TvgKBhaLJEow==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "3.11.0" + } + }, + "Microsoft.CodeAnalysis.CSharp": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "5DSyJ9bk+ATuDy7fp2Zt0mJStDVKbBoiz1DyfAwSa+k4H4IwykAUcV3URelw5b8/iVbfSaOwkwmPUZH6opZKCw==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.Common": "[5.0.0]" + } + }, + "Microsoft.CodeAnalysis.CSharp.Workspaces": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "Al/Q8B+yO8odSqGVpSvrShMFDvlQdIBU//F3E6Rb0YdiLSALE9wh/pvozPNnfmh5HDnvU+mkmSjpz4hQO++jaA==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.CSharp": "[5.0.0]", + "Microsoft.CodeAnalysis.Common": "[5.0.0]", + "Microsoft.CodeAnalysis.Workspaces.Common": "[5.0.0]", + "System.Composition": "9.0.0" + } + }, + "Microsoft.CodeAnalysis.Workspaces.Common": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "ZbUmIvT6lqTNKiv06Jl5wf0MTMi1vQ1oH7ou4CLcs2C/no/L7EhP3T8y3XXvn9VbqMcJaJnEsNA1jwYUMgc5jg==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.Common": "[5.0.0]", + "System.Composition": "9.0.0" + } + }, + "Microsoft.CodeAnalysis.Workspaces.MSBuild": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "/G+LVoAGMz6Ae8nm+PGLxSw+F5RjYx/J7irbTO5uKAPw1bxHyQJLc/YOnpDxt+EpPtYxvC9wvBsg/kETZp1F9Q==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.Build.Framework": "17.11.31", + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.Workspaces.Common": "[5.0.0]", + "Microsoft.Extensions.DependencyInjection": "9.0.0", + "Microsoft.Extensions.Logging": "9.0.0", + "Microsoft.Extensions.Logging.Abstractions": "9.0.0", + "Microsoft.Extensions.Options": "9.0.0", + "Microsoft.Extensions.Primitives": "9.0.0", + "Microsoft.VisualStudio.SolutionPersistence": "1.0.52", + "Newtonsoft.Json": "13.0.3", + "System.Composition": "9.0.0" + } + }, + "Microsoft.EntityFrameworkCore.Abstractions": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "jbKDXWPZQhuPHygMnwzNOqxBADVcpRVytcKYZsA++QqhPkpF93Ta8o5mbJQGrARSjlkr9WtOaADV97EDMOZ7DA==" + }, + "Microsoft.EntityFrameworkCore.Analyzers": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "M3BZ8JH8rB6BE7dO2g9iVbrHLnEz9wMXT6q+tDR6Nq3gyP3KmBj5OTiZGxyF3vesjOQNKanYoPGSNBR4kR2llg==" + }, + "Microsoft.EntityFrameworkCore.Relational": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "UU3diAD2wwZveye2rnrwaF/wvJ9tm5iL2fuY9TTap6/iGQK1OO29M1BzXZRlRPVH/dByt5w/pISBSFtyR7hTqw==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "10.0.8", + "Microsoft.Extensions.Caching.Memory": "10.0.8", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", + "Microsoft.Extensions.Logging": "10.0.8" + } + }, + "Microsoft.EntityFrameworkCore.Sqlite.Core": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "cFRBlY3sCoVX5JFDrRHQQHcbSms7CwBjjeuVEgQ4KP8WzPopgwNk3sJ0k7xKkIl0b9eUFJ0IR0aZwElT9154Ag==", + "dependencies": { + "Microsoft.Data.Sqlite.Core": "10.0.8", + "Microsoft.EntityFrameworkCore.Relational": "10.0.8", + "Microsoft.Extensions.Caching.Memory": "10.0.8", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", + "Microsoft.Extensions.DependencyModel": "10.0.8", + "Microsoft.Extensions.Logging": "10.0.8", + "SQLitePCLRaw.core": "2.1.11" + } + }, + "Microsoft.Extensions.ApiDescription.Server": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "NCWCGiwRwje8773yzPQhvucYnnfeR+ZoB1VRIrIMp4uaeUNw7jvEPHij3HIbwCDuNCrNcphA00KSAR9yD9qmbg==" + }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "EoK2TwVR1daxmfXUPnvIYZSk5XQjHe45sGekox4kvMt88KQZQhDVzYW5Na5+oNwTuRpE48hipyGJg12F1Tm70w==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.8" + } + }, + "Microsoft.Extensions.Caching.Memory": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "sYMYQjNprfqPTryuLNnr0/AOtnhlfuZ0ZxyOV0d3AXOEL8j9KV0EbelpZYyIatT2hJiaSGO9XGr5YDRsh22OfQ==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "10.0.8", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", + "Microsoft.Extensions.Logging.Abstractions": "10.0.8", + "Microsoft.Extensions.Options": "10.0.8", + "Microsoft.Extensions.Primitives": "10.0.8" + } + }, + "Microsoft.Extensions.Configuration": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "ehZcoPbjzWzS4XFvuz7R3V55SmpdkyMqFURLH3yXaN9NtXd9tR6CGB7pd49HYtCkenl+G7ctXSFLhNI08xLfRg==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", + "Microsoft.Extensions.Primitives": "10.0.8" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "I63esIFbL3h5pSt7gXpXOlmcwDmYBUoYNEglKfDPFUqtYvSV84f2l28hO2lfVXsV0wdlplgAM7IVz16matapSg==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.8" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "R3NN1X+kVu14uoxLEW6sBSQyhogDSbaOQzILnCtuXxBN4hx22AgjWPwZX6v/suERFkEDgU1lk12AglHTrUxhlw==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.8", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.8" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "daf62xHIrq8pnE709hgaZZN9tSam9TGGepWe1+bE6V3GEuVwJiMs6ib+38lfMCyAJAHiX0vapxBhsuMSV7U+cg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "21nbDV60SRPWGIivsyl6lqBeEJNG1sginhhfWgRrr3Ais7aQ12To25OAHQxgoiJkjqy1aQ6RxpZBGYuTi7Ge6A==" + }, + "Microsoft.Extensions.DependencyModel": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "vLyZVpxmduO2jx+76ggqnsA3m81kwMY3NkWciNTj5E+Nvqb0VihqCvQP89QsGONWp0AJwMZG+u9GzaCjDdFGNw==" + }, + "Microsoft.Extensions.Diagnostics": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "uduyw9d3Fi+sbredO5drA1S44AQS2FRNFyn72UmB2vmQIO1qaXprpp1U/2lYhYi8yFdVERfY9sy/pxw/qPOU9w==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.8", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.8", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.8" + } + }, + "Microsoft.Extensions.Diagnostics.Abstractions": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "+f4C5g78QCGNyxzUfrTYsB7qYx06Zca0e88s3qFlea9/lQhgPImYdNprlgzl1uHhRU3fVHLfmbijayU2sJEZ6w==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", + "Microsoft.Extensions.Options": "10.0.8" + } + }, + "Microsoft.Extensions.Http": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "/9LU/KWJOrtZJB9ymPjcARDyjp679BvBA/aSncv2Kt84WlSKz767HtxHg8EFsu8n21BMLZi+5XxlkKbLwfn4iA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", + "Microsoft.Extensions.Diagnostics": "10.0.8", + "Microsoft.Extensions.Logging": "10.0.8", + "Microsoft.Extensions.Logging.Abstractions": "10.0.8", + "Microsoft.Extensions.Options": "10.0.8" + } + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "K60JhWC2hN/Gi7TP68tBxSzk5ACWOs7lkmPzsfA8Bcf/IXTajujt2ORMf9rSMk1bsng6Lv4Y3fuxp3bm1+15ug==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "10.0.8", + "Microsoft.Extensions.Logging.Abstractions": "10.0.8", + "Microsoft.Extensions.Options": "10.0.8" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "fdVadZmsC8jRP0KvKy8mO8f6GV/HyBvElfcSxEhd+5FM5boAw/01iSaCto5G3G37ApJira4A3pNaVvBv8cUiLQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "VBD+131DpTNCNDfA4kIyKTiCySvJGNhwibdWBSdFRu7GMfXLXcXODkgA+KStKbbhzraLglZWUN4nXyHgW4JIRA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", + "Microsoft.Extensions.Primitives": "10.0.8" + } + }, + "Microsoft.Extensions.Options.ConfigurationExtensions": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "VOapXeO3lhBH0zYoyAH7tjapuo4V5pTHlevPpiSHueEquAajqd5nF0mttm+h/uE/exwAEuM5s26SzOJtletE3w==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", + "Microsoft.Extensions.Configuration.Binder": "10.0.8", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", + "Microsoft.Extensions.Options": "10.0.8", + "Microsoft.Extensions.Primitives": "10.0.8" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "OBPo4nYhMyIbtueoC10CBm6AGAbo/A9IV8QQ/6ryZS7VvmqpGT7hunazeHLxFawRzn3oLOq4jhqhpBX4tfswWQ==" + }, + "Microsoft.OpenApi": { + "type": "Transitive", + "resolved": "2.7.5", + "contentHash": "0FA67RSnRM4tcBKqiqVu/HPdZ9+QOKbmeRjxRUGTCjPU4C0bmUhd97Dso7Yild5P7nOV6GxJ2xrK0Kv/O9xp0w==" + }, + "Microsoft.VisualStudio.SolutionPersistence": { + "type": "Transitive", + "resolved": "1.0.52", + "contentHash": "oNv2JtYXhpdJrX63nibx1JT3uCESOBQ1LAk7Dtz/sr0+laW0KRM6eKp4CZ3MHDR2siIkKsY8MmUkeP5DKkQQ5w==" + }, + "Mono.TextTemplating": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "YqueG52R/Xej4VVbKuRIodjiAhV0HR/XVbLbNrJhCZnzjnSjgMJ/dCdV0akQQxavX6hp/LC6rqLGLcXeQYU7XA==", + "dependencies": { + "System.CodeDom": "6.0.0" + } + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.3", + "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" + }, + "Polly.Core": { + "type": "Transitive", + "resolved": "8.6.6", + "contentHash": "lCBL9mmhF9TZxHG3beVRkyjlLohkIC464xIAq7J7Y59C+z42hmsdUaeCKl2SIAYertOUU5TeBXyQDLDQGIKePQ==" + }, + "Polly.Extensions.Http": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "drrG+hB3pYFY7w1c3BD+lSGYvH2oIclH8GRSehgfyP5kjnFnHKQuuBhuHLv+PWyFuaTDyk/vfRpnxOzd11+J8g==", + "dependencies": { + "Polly": "7.1.0" + } + }, + "Serilog": { + "type": "Transitive", + "resolved": "4.2.0", + "contentHash": "gmoWVOvKgbME8TYR+gwMf7osROiWAURterc6Rt2dQyX7wtjZYpqFiA/pY6ztjGQKKV62GGCyOcmtP1UKMHgSmA==" + }, + "SourceGear.sqlite3": { + "type": "Transitive", + "resolved": "3.50.4.5", + "contentHash": "UtnipXhJYZKQOQIfpws/msLK7IRhMplE1CZCaZLIQXRnGD474QVpO/J9nMlQQY8NZueGz1aidjoxDRnrC1NT3Q==" + }, + "SQLitePCLRaw.config.e_sqlite3": { + "type": "Transitive", + "resolved": "3.0.3", + "contentHash": "caP/ap0X2fyVmstCXu5ueOmcr2XWAxA2XyKghV7H4bOAFmq3nWcsGl9q44iY1HYG+i8Qr4G9XEqdfti0rV6/ZQ==", + "dependencies": { + "SQLitePCLRaw.provider.e_sqlite3": "3.0.3" + } + }, + "SQLitePCLRaw.core": { + "type": "Transitive", + "resolved": "3.0.3", + "contentHash": "bjm6FY4lZyP+t7GmiuvSM0QXpFihAvyE0Y9O2yibm3g95AAWJPNnHOKVNJGyPTGIKuK7Pr4Wh8Rd8/aOtAclQw==" + }, + "SQLitePCLRaw.provider.e_sqlite3": { + "type": "Transitive", + "resolved": "3.0.3", + "contentHash": "wd+fGvZTrr3BJNe48opSczmC176Okd61ZgoZNQcdvZwkek6to978ccdpcFmNo5GHxCnk29KwT+f+lAZYgfLVZg==", + "dependencies": { + "SQLitePCLRaw.core": "3.0.3" + } + }, + "Swashbuckle.AspNetCore.Swagger": { + "type": "Transitive", + "resolved": "10.2.1", + "contentHash": "ej4inPhiWCq+0utG8yaKhIhE8M3k3R/qRaGhpgDZB+O/s+o62/zRMO1Cn2CtQccsrqPE9PYnzCp6hQGYGpJOyQ==", + "dependencies": { + "Microsoft.OpenApi": "2.7.5" + } + }, + "Swashbuckle.AspNetCore.SwaggerGen": { + "type": "Transitive", + "resolved": "10.2.1", + "contentHash": "JYX6i/y0xEtQWH/hZyfcage1/ldwww83ueD/gBc34uSnMwyvRLUsOpYcxlliFFxFbZMrY6t+R9ENqolE7zTEOg==", + "dependencies": { + "Swashbuckle.AspNetCore.Swagger": "10.2.1" + } + }, + "Swashbuckle.AspNetCore.SwaggerUI": { + "type": "Transitive", + "resolved": "10.2.1", + "contentHash": "vzB8ZAGqXus3fdareJ9GHctaRP9ZL+wW9x8U7s1Y+BWprInFvSg6rpD9VhANNpwXA8fUHqu5Agjl/+hHG1BCQA==" + }, + "System.CodeDom": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "CPc6tWO1LAer3IzfZufDBRL+UZQcj5uS207NHALQzP84Vp/z6wF0Aa0YZImOQY8iStY0A2zI/e3ihKNPfUm8XA==" + }, + "System.Composition": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "3Djj70fFTraOarSKmRnmRy/zm4YurICm+kiCtI0dYRqGJnLX6nJ+G3WYuFJ173cAPax/gh96REcbNiVqcrypFQ==", + "dependencies": { + "System.Composition.AttributedModel": "9.0.0", + "System.Composition.Convention": "9.0.0", + "System.Composition.Hosting": "9.0.0", + "System.Composition.Runtime": "9.0.0", + "System.Composition.TypedParts": "9.0.0" + } + }, + "System.Composition.AttributedModel": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "iri00l/zIX9g4lHMY+Nz0qV1n40+jFYAmgsaiNn16xvt2RDwlqByNG4wgblagnDYxm3YSQQ0jLlC/7Xlk9CzyA==" + }, + "System.Composition.Convention": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "+vuqVP6xpi582XIjJi6OCsIxuoTZfR0M7WWufk3uGDeCl3wGW6KnpylUJ3iiXdPByPE0vR5TjJgR6hDLez4FQg==", + "dependencies": { + "System.Composition.AttributedModel": "9.0.0" + } + }, + "System.Composition.Hosting": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "OFqSeFeJYr7kHxDfaViGM1ymk7d4JxK//VSoNF9Ux0gpqkLsauDZpu89kTHHNdCWfSljbFcvAafGyBoY094btQ==", + "dependencies": { + "System.Composition.Runtime": "9.0.0" + } + }, + "System.Composition.Runtime": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "w1HOlQY1zsOWYussjFGZCEYF2UZXgvoYnS94NIu2CBnAGMbXFAX8PY8c92KwUItPmowal68jnVLBCzdrWLeEKA==" + }, + "System.Composition.TypedParts": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "aRZlojCCGEHDKqh43jaDgaVpYETsgd7Nx4g1zwLKMtv4iTo0627715ajEFNpEEBTgLmvZuv8K0EVxc3sM4NWJA==", + "dependencies": { + "System.Composition.AttributedModel": "9.0.0", + "System.Composition.Hosting": "9.0.0", + "System.Composition.Runtime": "9.0.0" + } + }, + "listenarr.application": { + "type": "Project", + "dependencies": { + "AsyncKeyedLock": "[8.0.2, )", + "HtmlAgilityPack": "[1.12.4, )", + "Listenarr.Domain": "[1.0.0, )", + "Microsoft.Data.Sqlite.Core": "[10.0.8, )", + "Microsoft.EntityFrameworkCore": "[10.0.8, )", + "Microsoft.EntityFrameworkCore.Sqlite": "[10.0.8, )", + "SixLabors.ImageSharp": "[3.1.12, )", + "Swashbuckle.AspNetCore": "[10.2.1, )", + "TagLibSharp": "[2.3.0, )" + } + }, + "listenarr.domain": { + "type": "Project" + }, + "AsyncKeyedLock": { + "type": "CentralTransitive", + "requested": "[8.0.2, )", + "resolved": "8.0.2", + "contentHash": "QGys5cnIerNryv7V14PDkvGnlLz69kJtTfdnr+Lndcu+lRre397RNyU4FIeAJWgI9u73lTzXL52Qca9B/ncLXw==" + }, + "Microsoft.Data.Sqlite.Core": { + "type": "CentralTransitive", + "requested": "[10.0.8, )", + "resolved": "10.0.8", + "contentHash": "26t7WDiEjjAls/sFpWvVEFDxt+7Q5VPt6+blU2Lafuj9L8PzAv/GtGV4cqVPtrhWbfD2BX/z2v8hD1qXYtK6Aw==", + "dependencies": { + "SQLitePCLRaw.core": "2.1.11" + } + }, + "SixLabors.ImageSharp": { + "type": "CentralTransitive", + "requested": "[3.1.12, )", + "resolved": "3.1.12", + "contentHash": "iAg6zifihXEFS/t7fiHhZBGAdCp3FavsF4i2ZIDp0JfeYeDVzvmlbY1CNhhIKimaIzrzSi5M/NBFcWvZT2rB/A==" + }, + "SQLitePCLRaw.bundle_e_sqlite3": { + "type": "CentralTransitive", + "requested": "[3.0.3, )", + "resolved": "3.0.3", + "contentHash": "Zt8jmSL5zcDWGk8rmzhWBJ6IRyLWh1yWS04Pg72+GIvo3Ba4E/rG4Y/4l7AWlSEogEbzyKRTCXUAs1v/O7Pkkg==", + "dependencies": { + "SQLitePCLRaw.config.e_sqlite3": "3.0.3", + "SourceGear.sqlite3": "3.50.4.5" + } + }, + "TagLibSharp": { + "type": "CentralTransitive", + "requested": "[2.3.0, )", + "resolved": "2.3.0", + "contentHash": "Qo4z6ZjnIfbR3Us1Za5M2vQ97OWZPmODvVmepxZ8XW0UIVLGdO2T63/N3b23kCcyiwuIe0TQvMEQG8wUCCD1mA==" + } + } + } +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index b5f4c2d88..c35d1eb0e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ "husky": "^9.1.7" }, "engines": { - "node": ">=24" + "node": "^24.15.0" } }, "node_modules/@hapi/address": { @@ -266,9 +266,9 @@ } }, "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -444,9 +444,9 @@ } }, "node_modules/hasown": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", - "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" diff --git a/package.json b/package.json index 8a2689bff..1cd682588 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "private": true, "description": "Listenarr - Automated audiobook downloading and management", "engines": { - "node": ">=24" + "node": "^24.15.0" }, "scripts": { "version:sync": "node scripts/sync-fe-version-from-csproj.mjs", diff --git a/tests/packages.lock.json b/tests/packages.lock.json new file mode 100644 index 000000000..86bac4749 --- /dev/null +++ b/tests/packages.lock.json @@ -0,0 +1,872 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "coverlet.collector": { + "type": "Direct", + "requested": "[10.0.1, )", + "resolved": "10.0.1", + "contentHash": "27jXSV/0DbVqF5jDrAxuQFZ9oaz6gmG03p8ttxAFk+X0M4woFYj7MoWDLCna5EGLb0CE6OE7X6ZH3Wt5smTtaA==" + }, + "Microsoft.AspNetCore.Mvc.Testing": { + "type": "Direct", + "requested": "[10.0.8, )", + "resolved": "10.0.8", + "contentHash": "C9kMpUciPgx7ObqoO6W+eXEf3zHFWb7XpQgFJBzdO8GsmmVYrgcErTLMuki6e3EihycGpHbcJECYHDgM7XRMkg==", + "dependencies": { + "Microsoft.AspNetCore.TestHost": "10.0.8", + "Microsoft.Extensions.DependencyModel": "10.0.8", + "Microsoft.Extensions.Hosting": "10.0.8" + } + }, + "Microsoft.Data.Sqlite.Core": { + "type": "Direct", + "requested": "[10.0.8, )", + "resolved": "10.0.8", + "contentHash": "26t7WDiEjjAls/sFpWvVEFDxt+7Q5VPt6+blU2Lafuj9L8PzAv/GtGV4cqVPtrhWbfD2BX/z2v8hD1qXYtK6Aw==", + "dependencies": { + "SQLitePCLRaw.core": "2.1.11" + } + }, + "Microsoft.EntityFrameworkCore": { + "type": "Direct", + "requested": "[10.0.8, )", + "resolved": "10.0.8", + "contentHash": "EJx+fIBMgBlgD+ublKCn+GTOJkw3UqV7xOjYWBRVdUYyIm8UfvAsmSOPFiIInsWTHyMEYUJ9gCJY1jwX+6UB7w==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Abstractions": "10.0.8", + "Microsoft.EntityFrameworkCore.Analyzers": "10.0.8", + "Microsoft.Extensions.Caching.Memory": "10.0.8", + "Microsoft.Extensions.Logging": "10.0.8" + } + }, + "Microsoft.EntityFrameworkCore.InMemory": { + "type": "Direct", + "requested": "[10.0.8, )", + "resolved": "10.0.8", + "contentHash": "C3T9khx1oiLPrS6ehoSnZptiEuTOIaX60it9SGvCkWTeF5i6+IceK6p7mtx+mkFwWB5qx+v3IhgG51iUEtLq9w==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "10.0.8", + "Microsoft.Extensions.Caching.Memory": "10.0.8", + "Microsoft.Extensions.Logging": "10.0.8" + } + }, + "Microsoft.EntityFrameworkCore.Sqlite": { + "type": "Direct", + "requested": "[10.0.8, )", + "resolved": "10.0.8", + "contentHash": "8BGSSKBDDBC8s6ye1Y2Ar1BToeZHLHOzUn0nAOng4Z+8dJ4KQKC/1qYFPgRYchDCOMQh98REHco8SrrMYsHuMQ==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Sqlite.Core": "10.0.8", + "Microsoft.Extensions.Caching.Memory": "10.0.8", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", + "Microsoft.Extensions.DependencyModel": "10.0.8", + "Microsoft.Extensions.Logging": "10.0.8", + "SQLitePCLRaw.bundle_e_sqlite3": "2.1.11", + "SQLitePCLRaw.core": "2.1.11" + } + }, + "Microsoft.NET.Test.Sdk": { + "type": "Direct", + "requested": "[18.6.0, )", + "resolved": "18.6.0", + "contentHash": "kAIBt0MsYR0o2RULmlW5BhQ1ha50aGEgLKG4f1p0kePBGLJCprqs3S+NxRrYN8UH7mSQRPKpeiH9mwPMEKUObQ==", + "dependencies": { + "Microsoft.CodeCoverage": "18.6.0", + "Microsoft.TestPlatform.TestHost": "18.6.0" + } + }, + "Moq": { + "type": "Direct", + "requested": "[4.20.72, )", + "resolved": "4.20.72", + "contentHash": "EA55cjyNn8eTNWrgrdZJH5QLFp2L43oxl1tlkoYUKIE9pRwL784OWiTXeCV5ApS+AMYEAlt7Fo03A2XfouvHmQ==", + "dependencies": { + "Castle.Core": "5.1.1" + } + }, + "xunit": { + "type": "Direct", + "requested": "[2.9.3, )", + "resolved": "2.9.3", + "contentHash": "TlXQBinK35LpOPKHAqbLY4xlEen9TBafjs0V5KnA4wZsoQLQJiirCR4CbIXvOH8NzkW4YeJKP5P/Bnrodm0h9Q==", + "dependencies": { + "xunit.analyzers": "1.18.0", + "xunit.assert": "2.9.3", + "xunit.core": "[2.9.3]" + } + }, + "xunit.runner.visualstudio": { + "type": "Direct", + "requested": "[3.1.5, )", + "resolved": "3.1.5", + "contentHash": "tKi7dSTwP4m5m9eXPM2Ime4Kn7xNf4x4zT9sdLO/G4hZVnQCRiMTWoSZqI/pYTVeI27oPPqHBKYI/DjJ9GsYgA==" + }, + "Asp.Versioning.Abstractions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "cMRE5nvNMfBgfkb0XFWst/7UtyXCjoAXnV0L4Scx4P9fcf0idgrj1Z0c+3ylsy01K4cOib7dKhCBfpg5z3r0Kg==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.0" + } + }, + "Asp.Versioning.Http": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "xmNm9FM2d20NKy7i1osEQysf7pJ4iJjWnM6e8CoeIhUREqG8nugsfC82pGpmzlatjAJL5T52ieSpyW+GFdSsSQ==", + "dependencies": { + "Asp.Versioning.Abstractions": "10.0.0" + } + }, + "Castle.Core": { + "type": "Transitive", + "resolved": "5.1.1", + "contentHash": "rpYtIczkzGpf+EkZgDr9CClTdemhsrwA/W5hMoPjLkRFnXzH44zDLoovXeKtmxb1ykXK9aJVODSpiJml8CTw2g==", + "dependencies": { + "System.Diagnostics.EventLog": "6.0.0" + } + }, + "Microsoft.AspNetCore.TestHost": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "HRH/XAke90wkHv9ykCsrvpVqvKOUt53jQzvHHIXrPIPZWAjyPq6B5/InCmPYWvme+WKMXD10rplMAitzNMtC3w==" + }, + "Microsoft.CodeCoverage": { + "type": "Transitive", + "resolved": "18.6.0", + "contentHash": "bkmCXn/65Cd0LdO2zTb/ValGAJ1H8y/CgYOiBb3jsDyHI3Y1ljKx6RBvhvn3e5D/4R4I00RRwLf+Bd2Sn6bJjA==" + }, + "Microsoft.EntityFrameworkCore.Abstractions": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "jbKDXWPZQhuPHygMnwzNOqxBADVcpRVytcKYZsA++QqhPkpF93Ta8o5mbJQGrARSjlkr9WtOaADV97EDMOZ7DA==" + }, + "Microsoft.EntityFrameworkCore.Analyzers": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "M3BZ8JH8rB6BE7dO2g9iVbrHLnEz9wMXT6q+tDR6Nq3gyP3KmBj5OTiZGxyF3vesjOQNKanYoPGSNBR4kR2llg==" + }, + "Microsoft.EntityFrameworkCore.Relational": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "UU3diAD2wwZveye2rnrwaF/wvJ9tm5iL2fuY9TTap6/iGQK1OO29M1BzXZRlRPVH/dByt5w/pISBSFtyR7hTqw==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "10.0.8", + "Microsoft.Extensions.Caching.Memory": "10.0.8", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", + "Microsoft.Extensions.Logging": "10.0.8" + } + }, + "Microsoft.EntityFrameworkCore.Sqlite.Core": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "cFRBlY3sCoVX5JFDrRHQQHcbSms7CwBjjeuVEgQ4KP8WzPopgwNk3sJ0k7xKkIl0b9eUFJ0IR0aZwElT9154Ag==", + "dependencies": { + "Microsoft.Data.Sqlite.Core": "10.0.8", + "Microsoft.EntityFrameworkCore.Relational": "10.0.8", + "Microsoft.Extensions.Caching.Memory": "10.0.8", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", + "Microsoft.Extensions.DependencyModel": "10.0.8", + "Microsoft.Extensions.Logging": "10.0.8", + "SQLitePCLRaw.core": "2.1.11" + } + }, + "Microsoft.Extensions.ApiDescription.Server": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "NCWCGiwRwje8773yzPQhvucYnnfeR+ZoB1VRIrIMp4uaeUNw7jvEPHij3HIbwCDuNCrNcphA00KSAR9yD9qmbg==" + }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "EoK2TwVR1daxmfXUPnvIYZSk5XQjHe45sGekox4kvMt88KQZQhDVzYW5Na5+oNwTuRpE48hipyGJg12F1Tm70w==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.8" + } + }, + "Microsoft.Extensions.Caching.Memory": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "sYMYQjNprfqPTryuLNnr0/AOtnhlfuZ0ZxyOV0d3AXOEL8j9KV0EbelpZYyIatT2hJiaSGO9XGr5YDRsh22OfQ==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "10.0.8", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", + "Microsoft.Extensions.Logging.Abstractions": "10.0.8", + "Microsoft.Extensions.Options": "10.0.8", + "Microsoft.Extensions.Primitives": "10.0.8" + } + }, + "Microsoft.Extensions.Configuration": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "ehZcoPbjzWzS4XFvuz7R3V55SmpdkyMqFURLH3yXaN9NtXd9tR6CGB7pd49HYtCkenl+G7ctXSFLhNI08xLfRg==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", + "Microsoft.Extensions.Primitives": "10.0.8" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "I63esIFbL3h5pSt7gXpXOlmcwDmYBUoYNEglKfDPFUqtYvSV84f2l28hO2lfVXsV0wdlplgAM7IVz16matapSg==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.8" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "R3NN1X+kVu14uoxLEW6sBSQyhogDSbaOQzILnCtuXxBN4hx22AgjWPwZX6v/suERFkEDgU1lk12AglHTrUxhlw==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.8", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.8" + } + }, + "Microsoft.Extensions.Configuration.CommandLine": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "nQXq1a4MiInYh+0VF9fguxAl06q2ftmOyYQ+5e933s4rk57xjgkbTjUdFUySzjrcrvDeWsSqlZB+TE8+TbM2HA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.8", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.8" + } + }, + "Microsoft.Extensions.Configuration.EnvironmentVariables": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "bVGqctAfPGfTxJvNp8pMshtvpsUj6r6JkeiCNVIGVYO5gBxuxdN0Lbr25kEvE/zXdctkEc44g8HssnPgDnFGVA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.8", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.8" + } + }, + "Microsoft.Extensions.Configuration.FileExtensions": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "1g9mzuu8gIHkjYb0jLxOTQVl/QDG5nn0b0JzgT/gbgNKr6gXZzxOHRAsdYRc1eDApB7LdHR8uK5vQrNjIQdRrQ==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.8", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.8", + "Microsoft.Extensions.FileProviders.Physical": "10.0.8", + "Microsoft.Extensions.Primitives": "10.0.8" + } + }, + "Microsoft.Extensions.Configuration.Json": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "KLtAZ6A38s1pIfCO2ns6aG14NNGMYNZ4PBYfFK4M+R4A+xuSc6oklhqDcpHZxvDpyBWeFtR5C8iQBw2ng8tUHQ==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.8", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", + "Microsoft.Extensions.Configuration.FileExtensions": "10.0.8", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.8" + } + }, + "Microsoft.Extensions.Configuration.UserSecrets": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "6XTfFOnf27WY8kEeZkTZ4YNn0t+imgvdQ0YaAdR4vgURKATo9bCaVJ1KB71IOJAQtJP7Elb53VHlTNXg2CtSsA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", + "Microsoft.Extensions.Configuration.Json": "10.0.8", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.8", + "Microsoft.Extensions.FileProviders.Physical": "10.0.8" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "daf62xHIrq8pnE709hgaZZN9tSam9TGGepWe1+bE6V3GEuVwJiMs6ib+38lfMCyAJAHiX0vapxBhsuMSV7U+cg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "21nbDV60SRPWGIivsyl6lqBeEJNG1sginhhfWgRrr3Ais7aQ12To25OAHQxgoiJkjqy1aQ6RxpZBGYuTi7Ge6A==" + }, + "Microsoft.Extensions.DependencyModel": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "vLyZVpxmduO2jx+76ggqnsA3m81kwMY3NkWciNTj5E+Nvqb0VihqCvQP89QsGONWp0AJwMZG+u9GzaCjDdFGNw==" + }, + "Microsoft.Extensions.Diagnostics": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "uduyw9d3Fi+sbredO5drA1S44AQS2FRNFyn72UmB2vmQIO1qaXprpp1U/2lYhYi8yFdVERfY9sy/pxw/qPOU9w==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.8", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.8", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.8" + } + }, + "Microsoft.Extensions.Diagnostics.Abstractions": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "+f4C5g78QCGNyxzUfrTYsB7qYx06Zca0e88s3qFlea9/lQhgPImYdNprlgzl1uHhRU3fVHLfmbijayU2sJEZ6w==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", + "Microsoft.Extensions.Options": "10.0.8" + } + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "U+oquaPxFdY8lYeEIWO/AD7jDIl9sPW6aVWMQRHU/pZ/SWpLcOrAj2fcLe1HwXl4sYw1ONI56K/eELT3xr4RRQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.8" + } + }, + "Microsoft.Extensions.FileProviders.Physical": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "GkPvQe6IdidLu6Q3Lw6+B8NJpW8feW8czZ5mBKt5rXM/x8MvZfEp5WvAsjznzDGd23chIDrW0b2mmt+ScnEgiw==", + "dependencies": { + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.8", + "Microsoft.Extensions.FileSystemGlobbing": "10.0.8", + "Microsoft.Extensions.Primitives": "10.0.8" + } + }, + "Microsoft.Extensions.FileSystemGlobbing": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "IUQet3SY51xIFcFZKtAB6a54/Zdxs7T3SQ84kJtOD6yeXfZgiOMksACWD5qtTmXGQGFH4QYGBOT0KIO8Uy/dJw==" + }, + "Microsoft.Extensions.Hosting": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "VfEyM2BipThcSd0GG/FS2ZPCVCTiosVq2zLKEDsfeMIg78sOVZPEmS7CgWlb+dqTlgXvLSL4OG2q6sM4xRhHNg==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.8", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", + "Microsoft.Extensions.Configuration.Binder": "10.0.8", + "Microsoft.Extensions.Configuration.CommandLine": "10.0.8", + "Microsoft.Extensions.Configuration.EnvironmentVariables": "10.0.8", + "Microsoft.Extensions.Configuration.FileExtensions": "10.0.8", + "Microsoft.Extensions.Configuration.Json": "10.0.8", + "Microsoft.Extensions.Configuration.UserSecrets": "10.0.8", + "Microsoft.Extensions.DependencyInjection": "10.0.8", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", + "Microsoft.Extensions.Diagnostics": "10.0.8", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.8", + "Microsoft.Extensions.FileProviders.Physical": "10.0.8", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.8", + "Microsoft.Extensions.Logging": "10.0.8", + "Microsoft.Extensions.Logging.Abstractions": "10.0.8", + "Microsoft.Extensions.Logging.Configuration": "10.0.8", + "Microsoft.Extensions.Logging.Console": "10.0.8", + "Microsoft.Extensions.Logging.Debug": "10.0.8", + "Microsoft.Extensions.Logging.EventLog": "10.0.8", + "Microsoft.Extensions.Logging.EventSource": "10.0.8", + "Microsoft.Extensions.Options": "10.0.8" + } + }, + "Microsoft.Extensions.Hosting.Abstractions": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "MoOWFPT88/pDfmWpbU9PydKRX/rJFQkliowE/L9wbQcl94IicUphb5BFgepkWiDkYYxPnuEqjN4buzOGW4vJpQ==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.8", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.8", + "Microsoft.Extensions.Logging.Abstractions": "10.0.8" + } + }, + "Microsoft.Extensions.Http": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "/9LU/KWJOrtZJB9ymPjcARDyjp679BvBA/aSncv2Kt84WlSKz767HtxHg8EFsu8n21BMLZi+5XxlkKbLwfn4iA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", + "Microsoft.Extensions.Diagnostics": "10.0.8", + "Microsoft.Extensions.Logging": "10.0.8", + "Microsoft.Extensions.Logging.Abstractions": "10.0.8", + "Microsoft.Extensions.Options": "10.0.8" + } + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "K60JhWC2hN/Gi7TP68tBxSzk5ACWOs7lkmPzsfA8Bcf/IXTajujt2ORMf9rSMk1bsng6Lv4Y3fuxp3bm1+15ug==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "10.0.8", + "Microsoft.Extensions.Logging.Abstractions": "10.0.8", + "Microsoft.Extensions.Options": "10.0.8" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "fdVadZmsC8jRP0KvKy8mO8f6GV/HyBvElfcSxEhd+5FM5boAw/01iSaCto5G3G37ApJira4A3pNaVvBv8cUiLQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8" + } + }, + "Microsoft.Extensions.Logging.Configuration": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "rxSLTO7xTbcC3DuEJHNEijBr8g14Jj62zQ+DeFu68bsoTYoU8jLcMhc1735PV21bESXsATlL5LsfaWH71FOWAg==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.8", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", + "Microsoft.Extensions.Configuration.Binder": "10.0.8", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", + "Microsoft.Extensions.Logging": "10.0.8", + "Microsoft.Extensions.Logging.Abstractions": "10.0.8", + "Microsoft.Extensions.Options": "10.0.8", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.8" + } + }, + "Microsoft.Extensions.Logging.Console": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "6cv53sHsPnFS56PJw8X4GbNcjeX1KGyFJRxJWvxOgK63cnqeSB1k1eRwjUdkse0tBhwlH6qc9EOYDlan+CYTuw==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", + "Microsoft.Extensions.Logging": "10.0.8", + "Microsoft.Extensions.Logging.Abstractions": "10.0.8", + "Microsoft.Extensions.Logging.Configuration": "10.0.8", + "Microsoft.Extensions.Options": "10.0.8" + } + }, + "Microsoft.Extensions.Logging.Debug": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "4HW3M1lGHHDwEYcDZHRNptBQ48LCI2yW+XV4vuxdfQUqafTpVT8j9RqAsez08krZKhIiaArWu8iQq5uRKZ9Ffg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", + "Microsoft.Extensions.Logging": "10.0.8", + "Microsoft.Extensions.Logging.Abstractions": "10.0.8" + } + }, + "Microsoft.Extensions.Logging.EventLog": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "kK/C3SLIoGrcZvddYQw4eMm6YaROiSYBO7YgUR5Hdv5l+GIjBmbvQK5cST2FqjeubiAOPqFEimBT2N/8wVI+3A==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", + "Microsoft.Extensions.Logging": "10.0.8", + "Microsoft.Extensions.Logging.Abstractions": "10.0.8", + "Microsoft.Extensions.Options": "10.0.8", + "System.Diagnostics.EventLog": "10.0.8" + } + }, + "Microsoft.Extensions.Logging.EventSource": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "HX2M0MgzwQM8jpLe3AYAEMd0YsUfOP5RgGrDuk+Ki9n7HSuMbvLm9TEV3qRI3Pg9aqxc56GfgK/KdMRBhfWwKw==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", + "Microsoft.Extensions.Logging": "10.0.8", + "Microsoft.Extensions.Logging.Abstractions": "10.0.8", + "Microsoft.Extensions.Options": "10.0.8", + "Microsoft.Extensions.Primitives": "10.0.8" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "VBD+131DpTNCNDfA4kIyKTiCySvJGNhwibdWBSdFRu7GMfXLXcXODkgA+KStKbbhzraLglZWUN4nXyHgW4JIRA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", + "Microsoft.Extensions.Primitives": "10.0.8" + } + }, + "Microsoft.Extensions.Options.ConfigurationExtensions": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "VOapXeO3lhBH0zYoyAH7tjapuo4V5pTHlevPpiSHueEquAajqd5nF0mttm+h/uE/exwAEuM5s26SzOJtletE3w==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", + "Microsoft.Extensions.Configuration.Binder": "10.0.8", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", + "Microsoft.Extensions.Options": "10.0.8", + "Microsoft.Extensions.Primitives": "10.0.8" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "OBPo4nYhMyIbtueoC10CBm6AGAbo/A9IV8QQ/6ryZS7VvmqpGT7hunazeHLxFawRzn3oLOq4jhqhpBX4tfswWQ==" + }, + "Microsoft.OpenApi": { + "type": "Transitive", + "resolved": "2.7.5", + "contentHash": "0FA67RSnRM4tcBKqiqVu/HPdZ9+QOKbmeRjxRUGTCjPU4C0bmUhd97Dso7Yild5P7nOV6GxJ2xrK0Kv/O9xp0w==" + }, + "Microsoft.TestPlatform.ObjectModel": { + "type": "Transitive", + "resolved": "18.6.0", + "contentHash": "gQTW4BIfM2ZLxixo9ITXoulLKjn20FiiHtqTsx9PENqTrX7368ZeJ5L0QZJyReXDWORPRV8jXwZR6Aar8JOyaA==" + }, + "Microsoft.TestPlatform.TestHost": { + "type": "Transitive", + "resolved": "18.6.0", + "contentHash": "em1eLz5Q46+hsCtAXdXggWAPd9gQyT4ngdsQ7k1eWvQgpsjtS/wAOJ/5TteieFdiAvrEq1iVn00LtusAxRaVmQ==", + "dependencies": { + "Microsoft.TestPlatform.ObjectModel": "18.6.0", + "Newtonsoft.Json": "13.0.3" + } + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.3", + "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" + }, + "Polly.Core": { + "type": "Transitive", + "resolved": "8.6.6", + "contentHash": "lCBL9mmhF9TZxHG3beVRkyjlLohkIC464xIAq7J7Y59C+z42hmsdUaeCKl2SIAYertOUU5TeBXyQDLDQGIKePQ==" + }, + "Polly.Extensions.Http": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "drrG+hB3pYFY7w1c3BD+lSGYvH2oIclH8GRSehgfyP5kjnFnHKQuuBhuHLv+PWyFuaTDyk/vfRpnxOzd11+J8g==", + "dependencies": { + "Polly": "7.1.0" + } + }, + "Serilog": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "+cDryFR0GRhsGOnZSKwaDzRRl4MupvJ42FhCE4zhQRVanX0Jpg6WuCBk59OVhVDPmab1bB+nRykAnykYELA9qQ==" + }, + "Serilog.Extensions.Hosting": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "E7juuIc+gzoGxgzFooFgAV8g9BfiSXNKsUok9NmEpyAXg2odkcPsMa/Yo4axkJRlh0se7mkYQ1GXDaBemR+b6w==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.0", + "Microsoft.Extensions.Logging.Abstractions": "10.0.0", + "Serilog": "4.3.0", + "Serilog.Extensions.Logging": "10.0.0" + } + }, + "Serilog.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "vx0kABKl2dWbBhhqAfTOk53/i8aV/5VaT3a6il9gn72Wqs2pM7EK2OB6No6xdqK2IaY6Zf9gdjLuK9BVa2rT+Q==", + "dependencies": { + "Microsoft.Extensions.Logging": "10.0.0", + "Serilog": "4.2.0" + } + }, + "Serilog.Formatting.Compact": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "wQsv14w9cqlfB5FX2MZpNsTawckN4a8dryuNGbebB/3Nh1pXnROHZov3swtu3Nj5oNG7Ba+xdu7Et/ulAUPanQ==", + "dependencies": { + "Serilog": "4.0.0" + } + }, + "Serilog.Settings.Configuration": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "LNq+ibS1sbhTqPV1FIE69/9AJJbfaOhnaqkzcjFy95o+4U+STsta9mi97f1smgXsWYKICDeGUf8xUGzd/52/uA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Binder": "10.0.0", + "Microsoft.Extensions.DependencyModel": "10.0.0", + "Serilog": "4.3.0" + } + }, + "Serilog.Sinks.Console": { + "type": "Transitive", + "resolved": "6.1.1", + "contentHash": "8jbqgjUyZlfCuSTaJk6lOca465OndqOz3KZP6Cryt/IqZYybyBu7GP0fE/AXBzrrQB3EBmQntBFAvMVz1COvAA==", + "dependencies": { + "Serilog": "4.0.0" + } + }, + "Serilog.Sinks.Debug": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "4BzXcdrgRX7wde9PmHuYd9U6YqycCC28hhpKonK7hx0wb19eiuRj16fPcPSVp0o/Y1ipJuNLYQ00R3q2Zs8FDA==", + "dependencies": { + "Serilog": "4.0.0" + } + }, + "SourceGear.sqlite3": { + "type": "Transitive", + "resolved": "3.50.4.5", + "contentHash": "UtnipXhJYZKQOQIfpws/msLK7IRhMplE1CZCaZLIQXRnGD474QVpO/J9nMlQQY8NZueGz1aidjoxDRnrC1NT3Q==" + }, + "SQLitePCLRaw.config.e_sqlite3": { + "type": "Transitive", + "resolved": "3.0.3", + "contentHash": "caP/ap0X2fyVmstCXu5ueOmcr2XWAxA2XyKghV7H4bOAFmq3nWcsGl9q44iY1HYG+i8Qr4G9XEqdfti0rV6/ZQ==", + "dependencies": { + "SQLitePCLRaw.provider.e_sqlite3": "3.0.3" + } + }, + "SQLitePCLRaw.core": { + "type": "Transitive", + "resolved": "3.0.3", + "contentHash": "bjm6FY4lZyP+t7GmiuvSM0QXpFihAvyE0Y9O2yibm3g95AAWJPNnHOKVNJGyPTGIKuK7Pr4Wh8Rd8/aOtAclQw==" + }, + "SQLitePCLRaw.provider.e_sqlite3": { + "type": "Transitive", + "resolved": "3.0.3", + "contentHash": "wd+fGvZTrr3BJNe48opSczmC176Okd61ZgoZNQcdvZwkek6to978ccdpcFmNo5GHxCnk29KwT+f+lAZYgfLVZg==", + "dependencies": { + "SQLitePCLRaw.core": "3.0.3" + } + }, + "Swashbuckle.AspNetCore.Swagger": { + "type": "Transitive", + "resolved": "10.2.1", + "contentHash": "ej4inPhiWCq+0utG8yaKhIhE8M3k3R/qRaGhpgDZB+O/s+o62/zRMO1Cn2CtQccsrqPE9PYnzCp6hQGYGpJOyQ==", + "dependencies": { + "Microsoft.OpenApi": "2.7.5" + } + }, + "Swashbuckle.AspNetCore.SwaggerGen": { + "type": "Transitive", + "resolved": "10.2.1", + "contentHash": "JYX6i/y0xEtQWH/hZyfcage1/ldwww83ueD/gBc34uSnMwyvRLUsOpYcxlliFFxFbZMrY6t+R9ENqolE7zTEOg==", + "dependencies": { + "Swashbuckle.AspNetCore.Swagger": "10.2.1" + } + }, + "Swashbuckle.AspNetCore.SwaggerUI": { + "type": "Transitive", + "resolved": "10.2.1", + "contentHash": "vzB8ZAGqXus3fdareJ9GHctaRP9ZL+wW9x8U7s1Y+BWprInFvSg6rpD9VhANNpwXA8fUHqu5Agjl/+hHG1BCQA==" + }, + "System.Diagnostics.EventLog": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "+Ro7WgIom+BDNH+YhTuZKL6QJ0ctfOpTyfUG/h3aU5KwXt3OaNf0wYWrTvoBUj+34Dy5V8dN9yCco1hAJQ4txw==" + }, + "xunit.abstractions": { + "type": "Transitive", + "resolved": "2.0.3", + "contentHash": "pot1I4YOxlWjIb5jmwvvQNbTrZ3lJQ+jUGkGjWE3hEFM0l5gOnBWS+H3qsex68s5cO52g+44vpGzhAt+42vwKg==" + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "1.18.0", + "contentHash": "OtFMHN8yqIcYP9wcVIgJrq01AfTxijjAqVDy/WeQVSyrDC1RzBWeQPztL49DN2syXRah8TYnfvk035s7L95EZQ==" + }, + "xunit.assert": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "/Kq28fCE7MjOV42YLVRAJzRF0WmEqsmflm0cfpMjGtzQ2lR5mYVj1/i0Y8uDAOLczkL3/jArrwehfMD0YogMAA==" + }, + "xunit.core": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "BiAEvqGvyme19wE0wTKdADH+NloYqikiU0mcnmiNyXaF9HyHmE6sr/3DC5vnBkgsWaE6yPyWszKSPSApWdRVeQ==", + "dependencies": { + "xunit.extensibility.core": "[2.9.3]", + "xunit.extensibility.execution": "[2.9.3]" + } + }, + "xunit.extensibility.core": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "kf3si0YTn2a8J8eZNb+zFpwfoyvIrQ7ivNk5ZYA5yuYk1bEtMe4DxJ2CF/qsRgmEnDr7MnW1mxylBaHTZ4qErA==", + "dependencies": { + "xunit.abstractions": "2.0.3" + } + }, + "xunit.extensibility.execution": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "yMb6vMESlSrE3Wfj7V6cjQ3S4TXdXpRqYeNEI3zsX31uTsGMJjEw6oD5F5u1cHnMptjhEECnmZSsPxB6ChZHDQ==", + "dependencies": { + "xunit.extensibility.core": "[2.9.3]" + } + }, + "listenarr.api": { + "type": "Project", + "dependencies": { + "Asp.Versioning.Mvc": "[10.0.0, )", + "Asp.Versioning.Mvc.ApiExplorer": "[10.0.0, )", + "AsyncKeyedLock": "[8.0.2, )", + "HtmlAgilityPack": "[1.12.4, )", + "Listenarr.Application": "[1.0.0, )", + "Listenarr.Domain": "[1.0.0, )", + "Listenarr.Infrastructure": "[1.0.0, )", + "Microsoft.AspNetCore.OpenApi": "[10.0.8, )", + "Microsoft.Data.Sqlite.Core": "[10.0.8, )", + "Microsoft.EntityFrameworkCore": "[10.0.8, )", + "Microsoft.EntityFrameworkCore.Sqlite": "[10.0.8, )", + "Microsoft.Extensions.Http.Polly": "[10.0.8, )", + "Polly": "[8.6.6, )", + "Serilog.AspNetCore": "[10.0.0, )", + "Serilog.Sinks.File": "[7.0.0, )", + "SixLabors.ImageSharp": "[3.1.12, )", + "Swashbuckle.AspNetCore": "[10.2.1, )", + "TagLibSharp": "[2.3.0, )" + } + }, + "listenarr.application": { + "type": "Project", + "dependencies": { + "AsyncKeyedLock": "[8.0.2, )", + "HtmlAgilityPack": "[1.12.4, )", + "Listenarr.Domain": "[1.0.0, )", + "Microsoft.Data.Sqlite.Core": "[10.0.8, )", + "Microsoft.EntityFrameworkCore": "[10.0.8, )", + "Microsoft.EntityFrameworkCore.Sqlite": "[10.0.8, )", + "SixLabors.ImageSharp": "[3.1.12, )", + "Swashbuckle.AspNetCore": "[10.2.1, )", + "TagLibSharp": "[2.3.0, )" + } + }, + "listenarr.domain": { + "type": "Project" + }, + "listenarr.infrastructure": { + "type": "Project", + "dependencies": { + "BencodeNET": "[4.0.0, )", + "HtmlAgilityPack": "[1.12.4, )", + "Listenarr.Application": "[1.0.0, )", + "Listenarr.Domain": "[1.0.0, )", + "Microsoft.EntityFrameworkCore": "[10.0.8, )", + "Microsoft.EntityFrameworkCore.Sqlite": "[10.0.8, )", + "Microsoft.Extensions.Http.Polly": "[10.0.8, )", + "Polly": "[8.6.6, )", + "Serilog.Sinks.File": "[7.0.0, )", + "SharpCompress": "[0.49.1, )", + "Swashbuckle.AspNetCore": "[10.2.1, )" + } + }, + "Asp.Versioning.Mvc": { + "type": "CentralTransitive", + "requested": "[10.0.0, )", + "resolved": "10.0.0", + "contentHash": "W0wZ+0uZ0UK4KstjvEkNBZ0xxhBmxunwNg8582SVyyW7txQmSXibtm8fC4o82LaemPquYskms67bIbJOSrnlug==", + "dependencies": { + "Asp.Versioning.Http": "10.0.0" + } + }, + "Asp.Versioning.Mvc.ApiExplorer": { + "type": "CentralTransitive", + "requested": "[10.0.0, )", + "resolved": "10.0.0", + "contentHash": "H54UOpRoc4RmhQ4RA2lzDz43a/hAu/JN19Yyy/DNmH4XlRxhemfhifJyh9BaXNJOtGa2Dnu2xEeP4VSiTdUdAg==", + "dependencies": { + "Asp.Versioning.Mvc": "10.0.0" + } + }, + "AsyncKeyedLock": { + "type": "CentralTransitive", + "requested": "[8.0.2, )", + "resolved": "8.0.2", + "contentHash": "QGys5cnIerNryv7V14PDkvGnlLz69kJtTfdnr+Lndcu+lRre397RNyU4FIeAJWgI9u73lTzXL52Qca9B/ncLXw==" + }, + "BencodeNET": { + "type": "CentralTransitive", + "requested": "[4.0.0, )", + "resolved": "4.0.0", + "contentHash": "dsgswftoaNKuKdOiRz7pTpk0RyuPHOWrAdc5/ohP3YOfAVzosKrHY8qZZBdjX/fHa6SA63wp62K6wQX93uuyFw==" + }, + "HtmlAgilityPack": { + "type": "CentralTransitive", + "requested": "[1.12.4, )", + "resolved": "1.12.4", + "contentHash": "ljqvBabvFwKoLniuoQKO8b5bJfJweKLs4fUNS/V5dsvpo0A8MlJqxxn9XVmP2DaskbUXty6IYaWAi1SArGIMeQ==" + }, + "Microsoft.AspNetCore.OpenApi": { + "type": "CentralTransitive", + "requested": "[10.0.8, )", + "resolved": "10.0.8", + "contentHash": "cw24xHE2QaWwyEG9GQwFbjboyabub6Vd80DIItUGENzcQOa/BEnTrXsg2GADqWTmY/3ycqk9ToLGjgvF/VRlGA==", + "dependencies": { + "Microsoft.OpenApi": "2.0.0" + } + }, + "Microsoft.Extensions.Http.Polly": { + "type": "CentralTransitive", + "requested": "[10.0.8, )", + "resolved": "10.0.8", + "contentHash": "XXYEV1G6ILrK7F3zwjQxxbYKZba79NUz7cgy1wEjctcxNHI5i8YI5eOCkPhcZ//vvuT8vd+GdNBfPdYDOPCL1A==", + "dependencies": { + "Microsoft.Extensions.Http": "10.0.8", + "Polly": "7.2.4", + "Polly.Extensions.Http": "3.0.0" + } + }, + "Polly": { + "type": "CentralTransitive", + "requested": "[8.6.6, )", + "resolved": "8.6.6", + "contentHash": "czKHYJ6uGowPijuZt4kgF4njfGvWxVZ8mKBcrZ9iEtwDe9HKdF0ug6p6TwUG8EHuuufgbDU//rSBFebt5/0Fyw==", + "dependencies": { + "Polly.Core": "8.6.6" + } + }, + "Serilog.AspNetCore": { + "type": "CentralTransitive", + "requested": "[10.0.0, )", + "resolved": "10.0.0", + "contentHash": "a/cNa1mY4On1oJlfGG1wAvxjp5g7OEzk/Jf/nm7NF9cWoE7KlZw1GldrifUBWm9oKibHkR7Lg/l5jy3y7ACR8w==", + "dependencies": { + "Serilog": "4.3.0", + "Serilog.Extensions.Hosting": "10.0.0", + "Serilog.Formatting.Compact": "3.0.0", + "Serilog.Settings.Configuration": "10.0.0", + "Serilog.Sinks.Console": "6.1.1", + "Serilog.Sinks.Debug": "3.0.0", + "Serilog.Sinks.File": "7.0.0" + } + }, + "Serilog.Sinks.File": { + "type": "CentralTransitive", + "requested": "[7.0.0, )", + "resolved": "7.0.0", + "contentHash": "fKL7mXv7qaiNBUC71ssvn/dU0k9t0o45+qm2XgKAlSt19xF+ijjxyA3R6HmCgfKEKwfcfkwWjayuQtRueZFkYw==", + "dependencies": { + "Serilog": "4.2.0" + } + }, + "SharpCompress": { + "type": "CentralTransitive", + "requested": "[0.49.1, )", + "resolved": "0.49.1", + "contentHash": "Meygd8HAnUgqYzxvCsaYR5XnZAG2xBmxkQHVGi/HkCjrvEq+tiM+VPQRvYLxsbse3KUmec65ccdMiOXv8CkjsA==" + }, + "SixLabors.ImageSharp": { + "type": "CentralTransitive", + "requested": "[3.1.12, )", + "resolved": "3.1.12", + "contentHash": "iAg6zifihXEFS/t7fiHhZBGAdCp3FavsF4i2ZIDp0JfeYeDVzvmlbY1CNhhIKimaIzrzSi5M/NBFcWvZT2rB/A==" + }, + "SQLitePCLRaw.bundle_e_sqlite3": { + "type": "CentralTransitive", + "requested": "[3.0.3, )", + "resolved": "3.0.3", + "contentHash": "Zt8jmSL5zcDWGk8rmzhWBJ6IRyLWh1yWS04Pg72+GIvo3Ba4E/rG4Y/4l7AWlSEogEbzyKRTCXUAs1v/O7Pkkg==", + "dependencies": { + "SQLitePCLRaw.config.e_sqlite3": "3.0.3", + "SourceGear.sqlite3": "3.50.4.5" + } + }, + "Swashbuckle.AspNetCore": { + "type": "CentralTransitive", + "requested": "[10.2.1, )", + "resolved": "10.2.1", + "contentHash": "SDU6akgCV/H4jFMRfyJ0mgO5jWOuuAqekvEThXg8c/LjnfNz5Nkaz+RUpeTVJKWIRX4wDKC/6R3ogJ4AsRE32A==", + "dependencies": { + "Microsoft.Extensions.ApiDescription.Server": "10.0.0", + "Swashbuckle.AspNetCore.Swagger": "10.2.1", + "Swashbuckle.AspNetCore.SwaggerGen": "10.2.1", + "Swashbuckle.AspNetCore.SwaggerUI": "10.2.1" + } + }, + "TagLibSharp": { + "type": "CentralTransitive", + "requested": "[2.3.0, )", + "resolved": "2.3.0", + "contentHash": "Qo4z6ZjnIfbR3Us1Za5M2vQ97OWZPmODvVmepxZ8XW0UIVLGdO2T63/N3b23kCcyiwuIe0TQvMEQG8wUCCD1mA==" + } + } + } +} \ No newline at end of file From bb13857ed3614339ca0713265b6068a6db1b2b5a Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Thu, 11 Jun 2026 13:35:54 -0400 Subject: [PATCH 02/84] Clarify backend architecture boundaries --- .github/.cursorrules | 14 +- .github/AGENTS.md | 12 + .github/ANTHROPIC.md | 4 + .github/AZURE_OPENAI.md | 4 + .github/BARD.md | 4 + .github/BEDROCK.md | 4 + .github/CLAUDE.md | 12 + .github/CLAUDE_LISTENARR.md | 4 + .github/COHERE.md | 4 + .github/HUGGINGFACE.md | 4 + .github/OpenAI.md | 13 + .github/RULES.md | 19 +- .github/WARP.md | 4 + .github/clinerules | 4 + .github/copilot-instructions.md | 12 + .github/windsurfrules | 4 + BACKEND_ARCHITECTURE.md | 47 ++++ CONTRIBUTING.md | 1 + Directory.Packages.props | 5 + .../Attributes/LocalOrAdminAttribute.cs | 5 +- .../RequireAdminOrApiKeyAttribute.cs | 3 +- .../RequireAdministratorSessionAttribute.cs | 3 +- .../Attributes/RequireApiKeyAttribute.cs | 3 +- .../RequireApiKeyManagementAccessAttribute.cs | 5 +- .../Common/ApiVersionHttpContextExtensions.cs | 46 ++++ .../Controllers/AntiforgeryController.cs | 1 - .../Configurations/ApiSourcesController.cs | 4 +- .../Configurations/SettingsController.cs | 6 +- .../StartupConfigurationController.cs | 4 +- .../Controllers/DownloadClientController.cs | 6 +- .../Controllers/DownloadController.cs | 2 - .../Controllers/DownloadsController.cs | 2 - listenarr.api/Controllers/FfmpegController.cs | 2 - .../Controllers/FileSystemController.cs | 2 - .../Controllers/IndexersController.cs | 5 +- .../Controllers/ProwlarrCompatController.cs | 4 +- .../Controllers/QualityProfileController.cs | 2 - .../RemotePathMappingsController.cs | 2 - listenarr.api/Controllers/SearchController.cs | 4 +- listenarr.api/GlobalUsings.cs | 5 + .../AntiforgeryValidationMiddleware.cs | 1 - .../RequestBodyLoggingMiddleware.cs | 1 - listenarr.api/Program.Testing.cs | 1 - listenarr.api/Program.cs | 5 - .../SecurityRequestHttpContextExtensions.cs | 76 ++++++ .../Audiobooks/AudiobookFileService.cs | 15 +- .../Audiobooks/MoveQueueService.cs | 7 +- .../Common/ApiVersionUtils.cs | 32 +-- .../Common/ConfigurationService.cs | 7 +- .../Common/PersistenceException.cs | 36 +++ .../Interfaces/IAudibleAuthorPageParser.cs | 27 +++ .../Interfaces/IAudioTagWriter.cs | 25 ++ .../Interfaces/IAudiobookFileService.cs | 2 +- .../Interfaces/ICoverImageProbe.cs | 27 +++ .../Interfaces/IHtmlTextExtractor.cs | 25 ++ .../Interfaces/IHubBroadcaster.cs | 1 + .../Interfaces/IRequestContextAccessor.cs | 25 ++ .../Interfaces/ISecretProtector.cs | 17 ++ .../Listenarr.Application.csproj | 12 +- .../Metadata/AudibleService.cs | 177 +------------- .../Metadata/MetadataService.cs | 37 +-- .../Notification/DiscordBotService.cs | 30 +-- .../INotificationPayloadBuilder.cs | 4 +- .../NotificationPayloadBuilder.cs | 17 +- .../NotificationPayloadBuilderAdapter.cs | 6 +- .../Notification/NotificationService.cs | 63 +++-- .../Notification/SearchProgressReporter.cs | 12 +- .../Search/MetadataConverters.cs | 15 +- listenarr.application/Search/SearchService.cs | 49 ++-- .../Security/SecurityRequestUtils.cs | 78 ------ .../Security/SessionService.cs | 4 +- .../AppServiceRegistrationExtensions.cs | 9 +- ...astructureServiceRegistrationExtensions.cs | 4 + listenarr.infrastructure/GlobalUsings.cs | 15 ++ .../AuthorMonitoringBackgroundService.cs | 3 +- .../Audiobooks/ScanBackgroundService.cs | 5 +- .../SeriesMonitoringBackgroundService.cs | 3 +- .../UnmatchedScanBackgroundService.cs | 9 +- .../Common/ImageCacheCleanupService.cs | 3 +- .../Downloads/DownloadMonitorService.cs | 3 +- .../DownloadProcessingJobProcessor.cs | 3 +- .../Downloads/MovedDownloadProcessor.cs | 4 +- .../Downloads/QueueMonitorService.cs | 6 +- .../Metadata/MetadataRescanService.cs | 3 +- .../Search/AutomaticSearchService.cs | 6 +- .../Listenarr.Infrastructure.csproj | 5 +- .../Persistence/ListenArrDbContext.cs | 35 +++ .../Security/DataProtectionSecretProtector.cs | 27 +++ .../HtmlAgilityPackAudibleAuthorPageParser.cs | 223 ++++++++++++++++++ .../Services/HtmlAgilityPackTextExtractor.cs | 38 +++ .../Services/ImageSharpCoverImageProbe.cs | 55 +++++ .../Services/TagLibAudioTagWriter.cs | 66 ++++++ .../SignalR}/DownloadHub.cs | 4 +- .../SignalR}/SettingsHub.cs | 3 +- .../SignalR/SignalRHubBroadcaster.cs | 15 +- .../Web/AspNetRequestContextAccessor.cs | 59 +++++ .../Api/Services/DiscordBotServiceTests.cs | 12 +- .../NotificationPayloadBuilderAdapterTests.cs | 8 +- .../Api/Services/NotificationServiceTests.cs | 27 +-- .../Api/Services/SecurityRedactionTests.cs | 12 +- tests/GlobalUsings.cs | 17 ++ 101 files changed, 1211 insertions(+), 596 deletions(-) create mode 100644 BACKEND_ARCHITECTURE.md create mode 100644 listenarr.api/Common/ApiVersionHttpContextExtensions.cs create mode 100644 listenarr.api/GlobalUsings.cs create mode 100644 listenarr.api/Security/SecurityRequestHttpContextExtensions.cs create mode 100644 listenarr.application/Common/PersistenceException.cs create mode 100644 listenarr.application/Interfaces/IAudibleAuthorPageParser.cs create mode 100644 listenarr.application/Interfaces/IAudioTagWriter.cs create mode 100644 listenarr.application/Interfaces/ICoverImageProbe.cs create mode 100644 listenarr.application/Interfaces/IHtmlTextExtractor.cs create mode 100644 listenarr.application/Interfaces/IRequestContextAccessor.cs create mode 100644 listenarr.application/Interfaces/ISecretProtector.cs create mode 100644 listenarr.infrastructure/GlobalUsings.cs rename {listenarr.application => listenarr.infrastructure/HostedServices}/Audiobooks/AuthorMonitoringBackgroundService.cs (97%) rename {listenarr.application => listenarr.infrastructure/HostedServices}/Audiobooks/ScanBackgroundService.cs (99%) rename {listenarr.application => listenarr.infrastructure/HostedServices}/Audiobooks/SeriesMonitoringBackgroundService.cs (97%) rename {listenarr.application => listenarr.infrastructure/HostedServices}/Audiobooks/UnmatchedScanBackgroundService.cs (98%) rename {listenarr.application => listenarr.infrastructure/HostedServices}/Common/ImageCacheCleanupService.cs (98%) rename {listenarr.application => listenarr.infrastructure/HostedServices}/Downloads/DownloadMonitorService.cs (99%) rename {listenarr.application => listenarr.infrastructure/HostedServices}/Downloads/DownloadProcessingJobProcessor.cs (99%) rename {listenarr.application => listenarr.infrastructure/HostedServices}/Downloads/MovedDownloadProcessor.cs (99%) rename {listenarr.application => listenarr.infrastructure/HostedServices}/Downloads/QueueMonitorService.cs (98%) rename {listenarr.application => listenarr.infrastructure/HostedServices}/Metadata/MetadataRescanService.cs (99%) rename {listenarr.application => listenarr.infrastructure/HostedServices}/Search/AutomaticSearchService.cs (99%) create mode 100644 listenarr.infrastructure/Security/DataProtectionSecretProtector.cs create mode 100644 listenarr.infrastructure/Services/HtmlAgilityPackAudibleAuthorPageParser.cs create mode 100644 listenarr.infrastructure/Services/HtmlAgilityPackTextExtractor.cs create mode 100644 listenarr.infrastructure/Services/ImageSharpCoverImageProbe.cs create mode 100644 listenarr.infrastructure/Services/TagLibAudioTagWriter.cs rename {listenarr.application/Notification => listenarr.infrastructure/SignalR}/DownloadHub.cs (97%) rename {listenarr.application/Notification => listenarr.infrastructure/SignalR}/SettingsHub.cs (96%) create mode 100644 listenarr.infrastructure/Web/AspNetRequestContextAccessor.cs create mode 100644 tests/GlobalUsings.cs diff --git a/.github/.cursorrules b/.github/.cursorrules index 7e78bb44d..0899f2135 100644 --- a/.github/.cursorrules +++ b/.github/.cursorrules @@ -1,5 +1,17 @@ # Listenarr Copilot Rules +## Required Repository Context + +Before making code, dependency, workflow, or documentation changes, review and follow: + +- `CONTRIBUTING.md` +- `BACKEND_ARCHITECTURE.md` +- `.github/RULES.md` +- `.github/AGENTS.md` +- `.github/copilot-instructions.md` + +Repository-specific guidance takes precedence over general examples in this file. Keep infrastructure-shaped dependencies out of `listenarr.application`; define application-owned ports there and implement adapters in infrastructure/API. + ## Project Overview Listenarr is a C# .NET Core Web API backend with Vue.js frontend for automated audiobook downloading and processing. The backend uses ASP.NET Core with Entity Framework Core and SQLite, while the frontend uses Vue.js 3 with TypeScript, Pinia, and Vite. @@ -306,4 +318,4 @@ var audiobooks = await _db.Audiobooks ``` Remember: This project follows established patterns. When in doubt, look at existing code for examples of how similar functionality is implemented. -c:\Users\Robbie\Documents\GitHub\Listenarr\.cursorrules \ No newline at end of file +c:\Users\Robbie\Documents\GitHub\Listenarr\.cursorrules diff --git a/.github/AGENTS.md b/.github/AGENTS.md index 3b85ce64c..36bb6517a 100644 --- a/.github/AGENTS.md +++ b/.github/AGENTS.md @@ -1,6 +1,18 @@ ````markdown # Secure .NET Code Generation Codex +## Required Repository Context + +Before making code, dependency, workflow, or documentation changes, review and follow: + +- `CONTRIBUTING.md` +- `BACKEND_ARCHITECTURE.md` +- `.github/RULES.md` +- `.github/copilot-instructions.md` +- `.github/.cursorrules` + +Repository-specific guidance takes precedence over general examples in this file. Keep infrastructure-shaped dependencies out of `listenarr.application`; define application-owned ports there and implement adapters in infrastructure/API. + As a security-aware developer, generate secure .NET code using ASP.NET Core that inherently prevents top security weaknesses. Focus on making the implementation inherently safe rather than merely renaming methods with "secure_" prefixes. Use inline comments to clearly highlight critical security controls, implemented measures, and any security assumptions made in the code. diff --git a/.github/ANTHROPIC.md b/.github/ANTHROPIC.md index 41c214c93..4d6428d71 100644 --- a/.github/ANTHROPIC.md +++ b/.github/ANTHROPIC.md @@ -1,5 +1,9 @@ # Anthropic/Claude Instructions for Listenarr +## Required Repository Context + +Before making code, dependency, workflow, or documentation changes, review and follow `CONTRIBUTING.md`, `BACKEND_ARCHITECTURE.md`, `.github/RULES.md`, and the primary AI guidance files. Repository-specific guidance takes precedence over generic provider guidance. + ## Overview Listenarr is a C# .NET 10.0 audiobook management system with Vue.js 3 frontend. See [copilot-instructions.md](copilot-instructions.md) for complete details. diff --git a/.github/AZURE_OPENAI.md b/.github/AZURE_OPENAI.md index 7ea372d13..362ee0667 100644 --- a/.github/AZURE_OPENAI.md +++ b/.github/AZURE_OPENAI.md @@ -1,5 +1,9 @@ # Azure OpenAI Instructions for Listenarr +## Required Repository Context + +Before making code, dependency, workflow, or documentation changes, review and follow `CONTRIBUTING.md`, `BACKEND_ARCHITECTURE.md`, `.github/RULES.md`, and the primary AI guidance files. Repository-specific guidance takes precedence over generic provider guidance. + ## Overview Listenarr is a C# .NET 10.0 audiobook management system with Vue.js 3 frontend for automated audiobook downloads. See [copilot-instructions.md](copilot-instructions.md) for complete details. diff --git a/.github/BARD.md b/.github/BARD.md index 27d89b763..1b308c2d2 100644 --- a/.github/BARD.md +++ b/.github/BARD.md @@ -1,5 +1,9 @@ # Google Bard/Gemini Instructions for Listenarr +## Required Repository Context + +Before making code, dependency, workflow, or documentation changes, review and follow `CONTRIBUTING.md`, `BACKEND_ARCHITECTURE.md`, `.github/RULES.md`, and the primary AI guidance files. Repository-specific guidance takes precedence over generic provider guidance. + ## Overview Listenarr is a C# .NET 10.0 audiobook management system with Vue.js 3 frontend. See [copilot-instructions.md](copilot-instructions.md) for complete details. diff --git a/.github/BEDROCK.md b/.github/BEDROCK.md index fb3efc8b2..c3f660c6c 100644 --- a/.github/BEDROCK.md +++ b/.github/BEDROCK.md @@ -1,5 +1,9 @@ # Amazon Bedrock Instructions for Listenarr +## Required Repository Context + +Before making code, dependency, workflow, or documentation changes, review and follow `CONTRIBUTING.md`, `BACKEND_ARCHITECTURE.md`, `.github/RULES.md`, and the primary AI guidance files. Repository-specific guidance takes precedence over generic provider guidance. + ## Overview Listenarr is a C# .NET 10.0 audiobook management system with Vue.js 3 frontend. See [copilot-instructions.md](copilot-instructions.md) for complete details. diff --git a/.github/CLAUDE.md b/.github/CLAUDE.md index 71f8f610f..15417d14f 100644 --- a/.github/CLAUDE.md +++ b/.github/CLAUDE.md @@ -1,6 +1,18 @@ ````markdown # Secure Code Generation Rules for .NET/ASP.NET Core +## Required Repository Context + +Before making code, dependency, workflow, or documentation changes, review and follow: + +- `CONTRIBUTING.md` +- `BACKEND_ARCHITECTURE.md` +- `.github/RULES.md` +- `.github/copilot-instructions.md` +- `.github/.cursorrules` + +Repository-specific guidance takes precedence over general examples in this file. Keep infrastructure-shaped dependencies out of `listenarr.application`; define application-owned ports there and implement adapters in infrastructure/API. + As a security-aware developer, generate secure .NET code using ASP.NET Core that inherently prevents top security weaknesses. Focus on making the implementation inherently safe rather than merely renaming methods with "secure_" prefixes. Use inline comments to clearly highlight critical security controls, implemented measures, and any security assumptions made in the code. Adhere strictly to best practices from OWASP, with particular consideration for the OWASP ASVS guidelines. **Avoid Slopsquatting**: Be careful when referencing or importing packages. Do not guess if a package exists. Comment on any low reputation or uncommon packages you have included. --- diff --git a/.github/CLAUDE_LISTENARR.md b/.github/CLAUDE_LISTENARR.md index 2ec0822b6..41050e1b3 100644 --- a/.github/CLAUDE_LISTENARR.md +++ b/.github/CLAUDE_LISTENARR.md @@ -1,5 +1,9 @@ # Claude AI Instructions for Listenarr +## Required Repository Context + +Before making code, dependency, workflow, or documentation changes, review and follow `CONTRIBUTING.md`, `BACKEND_ARCHITECTURE.md`, `.github/RULES.md`, and the primary AI guidance files. Repository-specific guidance takes precedence over generic provider guidance. + ## Quick Reference This file contains Claude-specific guidance for the Listenarr audiobook management system. For comprehensive secure coding practices, see [AGENTS.md](AGENTS.md) and [CLAUDE.md](CLAUDE.md). For complete project details, see [copilot-instructions.md](copilot-instructions.md). diff --git a/.github/COHERE.md b/.github/COHERE.md index 57638df4c..9a7c244d7 100644 --- a/.github/COHERE.md +++ b/.github/COHERE.md @@ -1,5 +1,9 @@ # Cohere Instructions for Listenarr +## Required Repository Context + +Before making code, dependency, workflow, or documentation changes, review and follow `CONTRIBUTING.md`, `BACKEND_ARCHITECTURE.md`, `.github/RULES.md`, and the primary AI guidance files. Repository-specific guidance takes precedence over generic provider guidance. + ## Overview Listenarr is a C# .NET 10.0 audiobook management system with Vue.js 3 frontend. See [copilot-instructions.md](copilot-instructions.md) for complete details. diff --git a/.github/HUGGINGFACE.md b/.github/HUGGINGFACE.md index 0d530530b..8ed45fff3 100644 --- a/.github/HUGGINGFACE.md +++ b/.github/HUGGINGFACE.md @@ -1,5 +1,9 @@ # Hugging Face Instructions for Listenarr +## Required Repository Context + +Before making code, dependency, workflow, or documentation changes, review and follow `CONTRIBUTING.md`, `BACKEND_ARCHITECTURE.md`, `.github/RULES.md`, and the primary AI guidance files. Repository-specific guidance takes precedence over generic provider guidance. + ## Overview Listenarr is a C# .NET 10.0 audiobook management system with Vue.js 3 frontend. See [copilot-instructions.md](copilot-instructions.md) for complete details. diff --git a/.github/OpenAI.md b/.github/OpenAI.md index 20a1acce9..f234466cd 100644 --- a/.github/OpenAI.md +++ b/.github/OpenAI.md @@ -1,5 +1,18 @@ # OpenAI Instructions for Listenarr +## Required Repository Context + +Before making code, dependency, workflow, or documentation changes, review and follow: + +- `CONTRIBUTING.md` +- `BACKEND_ARCHITECTURE.md` +- `.github/RULES.md` +- `.github/copilot-instructions.md` +- `.github/AGENTS.md` +- `.github/.cursorrules` + +Repository-specific guidance takes precedence over general examples in this file. Keep infrastructure-shaped dependencies out of `listenarr.application`; define application-owned ports there and implement adapters in infrastructure/API. + ## Overview Listenarr is a C# .NET 10.0 audiobook management system with Vue.js 3 frontend. See [copilot-instructions.md](copilot-instructions.md) for complete details. diff --git a/.github/RULES.md b/.github/RULES.md index adf7ad502..9ed2f281e 100644 --- a/.github/RULES.md +++ b/.github/RULES.md @@ -2,6 +2,16 @@ This folder contains comprehensive instructions for AI assistants working with the Listenarr audiobook management system. +## Mandatory First Step + +Before making code, dependency, workflow, or documentation changes, AI agents must review and follow: + +- Repository contribution rules: [`../CONTRIBUTING.md`](../CONTRIBUTING.md) +- Backend architecture boundaries: [`../BACKEND_ARCHITECTURE.md`](../BACKEND_ARCHITECTURE.md) +- The primary AI guidance files listed below, especially [`copilot-instructions.md`](copilot-instructions.md), [`AGENTS.md`](AGENTS.md), and [`.cursorrules`](.cursorrules) + +If these documents conflict, follow the more specific repository guidance first. In particular, keep infrastructure-shaped dependencies out of `listenarr.application`; add application-owned ports and implement adapters in infrastructure/API. + ## Primary Reference Files ### [copilot-instructions.md](copilot-instructions.md) - **MOST COMPREHENSIVE** @@ -61,10 +71,11 @@ These files provide quick-start guidance tailored to specific AI providers, with ## Quick Start -1. **For comprehensive project understanding**: Read [copilot-instructions.md](copilot-instructions.md) -2. **For security compliance**: Read [AGENTS.md](AGENTS.md) -3. **For coding standards**: Read [.cursorrules](.cursorrules) -4. **For provider-specific guidance**: Choose your AI provider file above +1. **Before changing anything**: Read [`../CONTRIBUTING.md`](../CONTRIBUTING.md) and [`../BACKEND_ARCHITECTURE.md`](../BACKEND_ARCHITECTURE.md) +2. **For comprehensive project understanding**: Read [copilot-instructions.md](copilot-instructions.md) +3. **For security compliance**: Read [AGENTS.md](AGENTS.md) +4. **For coding standards**: Read [.cursorrules](.cursorrules) +5. **For provider-specific guidance**: Choose your AI provider file above ## Project Overview (Quick Reference) diff --git a/.github/WARP.md b/.github/WARP.md index cf4c476df..bc68a6e9c 100644 --- a/.github/WARP.md +++ b/.github/WARP.md @@ -2,6 +2,10 @@ This file provides guidance to WARP (warp.dev) when working with code in this repository. +## Required Repository Context + +Before making code, dependency, workflow, or documentation changes, review and follow `CONTRIBUTING.md`, `BACKEND_ARCHITECTURE.md`, `.github/RULES.md`, and the primary AI guidance files. Repository-specific guidance takes precedence over generic tool guidance. + ## Project Overview Listenarr is an automated audiobook collection management system built as a full-stack application with a C# .NET 10 backend API and Vue.js 3 frontend. The project follows a monorepo structure with integrated build processes. diff --git a/.github/clinerules b/.github/clinerules index 2f3300fa1..1630545c3 100644 --- a/.github/clinerules +++ b/.github/clinerules @@ -1,5 +1,9 @@ # Cline AI Instructions for Listenarr +## Required Repository Context + +Before making code, dependency, workflow, or documentation changes, review and follow `CONTRIBUTING.md`, `BACKEND_ARCHITECTURE.md`, `.github/RULES.md`, and the primary AI guidance files. Repository-specific guidance takes precedence over generic tool guidance. + As a security-aware developer, generate secure .NET code using ASP.NET Core that inherently prevents top security weaknesses. Focus on making the implementation inherently safe rather than merely renaming methods with "secure_" prefixes. Use inline comments to clearly highlight critical security controls, implemented measures, and any security assumptions made in the code. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 1d006335d..00f859f03 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -2,6 +2,18 @@ This is a complete C# .NET Web API backend with Vue.js frontend for automated audiobook downloading and processing. +## Required Repository Context + +Before making code, dependency, workflow, or documentation changes, review and follow: + +- `CONTRIBUTING.md` +- `BACKEND_ARCHITECTURE.md` +- `.github/RULES.md` +- `.github/AGENTS.md` +- `.github/.cursorrules` + +Repository-specific guidance takes precedence over general examples in this file. Keep infrastructure-shaped dependencies out of `listenarr.application`; define application-owned ports there and implement adapters in infrastructure/API. + ## Project Overview - **Backend**: ASP.NET Core Web API (.NET 10.0+ / net10.0) with modular service architecture - **Frontend**: Vue.js 3 + TypeScript + Pinia + Vue Router + Vite diff --git a/.github/windsurfrules b/.github/windsurfrules index d00fa1bfd..b75fd2ef4 100644 --- a/.github/windsurfrules +++ b/.github/windsurfrules @@ -6,6 +6,10 @@ globs: **/*.cs, **/*.csproj, **/*.json, **/*.xml, **/*.vue, **/*.ts # Windsurf AI Instructions for Listenarr +## Required Repository Context + +Before making code, dependency, workflow, or documentation changes, review and follow `CONTRIBUTING.md`, `BACKEND_ARCHITECTURE.md`, `.github/RULES.md`, and the primary AI guidance files. Repository-specific guidance takes precedence over generic tool guidance. + As a security-aware developer, generate secure .NET code using ASP.NET Core that inherently prevents top security weaknesses. Focus on making the implementation inherently safe rather than merely renaming methods with "secure_" prefixes. Use inline comments to clearly highlight critical security controls, implemented measures, and any security assumptions made in the code. diff --git a/BACKEND_ARCHITECTURE.md b/BACKEND_ARCHITECTURE.md new file mode 100644 index 000000000..be912dea8 --- /dev/null +++ b/BACKEND_ARCHITECTURE.md @@ -0,0 +1,47 @@ +# Backend Architecture Boundaries + +Listenarr is moving toward a layered backend where each project has a clear job: + +- `listenarr.domain` owns the domain model, value objects, domain exceptions, and business rules that do not need hosting, persistence, files, or network access. +- `listenarr.application` owns use-case orchestration, application services, DTOs, mapping, and contracts that other layers implement. It can coordinate work, but it should avoid owning persistence, file, network, parsing, or image-processing implementations. +- `listenarr.infrastructure` owns concrete adapters for technical concerns: EF Core and SQLite persistence, filesystem work, external HTTP clients, metadata/tagging libraries, HTML scraping/parsing, image inspection, cache implementations, SignalR infrastructure, and downloader integrations. +- `listenarr.api` is the composition and hosting layer. It wires dependency injection, controllers, middleware, Swagger/OpenAPI, auth policy, and request pipeline behavior. + +## Current Decision + +The diagram describes the intended boundary: application is business/use-case logic and infrastructure is persistence, files, and external adapters. The codebase is still in transition, but implementation-specific packages should be kept out of `listenarr.application` unless there is a documented reason to do otherwise. + +New implementation-specific dependencies should go in `listenarr.infrastructure`. The application layer should define contracts and coordinate use cases; infrastructure should implement those contracts with EF Core, filesystem, HTTP, parsing, image, tagging, and other adapter libraries. + +The application project should not reference SQLite providers, EF Core implementation packages, Swagger/OpenAPI packages, HTML parsers, image libraries, audio tagging libraries, ASP.NET Core hosting types, SignalR hubs, HTTP context, or data-protection implementations directly. SQLite and EF Core belong to infrastructure, Swagger/OpenAPI belongs to API, hosted adapters and SignalR delivery belong to infrastructure/API, and parsing/tagging/image inspection belong behind application ports implemented by infrastructure. + +## Boundary Cleanup + +The application layer now delegates these infrastructure-shaped concerns through interfaces: + +- EF Core update failures are translated by infrastructure into application-owned `PersistenceException` types before they leave persistence. +- TagLibSharp ASIN writing is behind `IAudioTagWriter`, implemented by infrastructure. +- ImageSharp cover probing is behind `ICoverImageProbe`, implemented by infrastructure. +- HtmlAgilityPack text extraction and Audible author-page parsing are behind `IHtmlTextExtractor` and `IAudibleAuthorPageParser`, implemented by infrastructure. +- Hosted services and SignalR hubs live in infrastructure. Application code publishes client events through `IHubBroadcaster` instead of referencing hubs or `IHubContext`. +- HTTP request details are exposed to application services through `IRequestContextAccessor`, with ASP.NET Core adaptation handled outside application. +- Secret protection is exposed through `ISecretProtector`, with Data Protection implemented in infrastructure. +- `listenarr.application` no longer has an ASP.NET Core framework reference. It may reference general `Microsoft.Extensions.*` abstractions for logging, options, caching, dependency-factory access, and HTTP client factories, but it should not reference host/web implementation packages. + +## Migration Direction + +Use this pattern when moving a concern out of application: + +1. Keep the application-level interface, DTOs, and result models in `listenarr.application` or `listenarr.domain`. +2. Move the concrete implementation to the appropriate `listenarr.infrastructure` feature or technology folder. +3. Register the implementation in `listenarr.infrastructure/Extensions/InfrastructureServiceRegistrationExtensions.cs`. +4. Keep `listenarr.api` responsible for calling the registration extension and composing the host. +5. Add or update focused tests before deleting the old implementation. + +Recommended follow-up slices: + +- Revisit background workers that combine orchestration with persistence or filesystem details and split the use case from the hosted adapter. +- Continue replacing direct service-locator patterns with narrower application ports where a worker or service only needs one operation from another layer. +- Keep new host-specific concerns in API or infrastructure and expose them to application through small application-owned contracts. + +Until those slices are complete, reviewers should treat any new infrastructure-shaped application dependency as a boundary regression unless it is explicitly documented. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 114a0a08e..0796262d6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -400,6 +400,7 @@ If you have any questions about contributing, please: --- ## Layering rules & migration steps (practical) +- Backend project boundaries are documented in `BACKEND_ARCHITECTURE.md`. Keep infrastructure-shaped dependencies out of `listenarr.application`; add an application-owned port and implement the adapter in infrastructure/API instead. - Keep contracts (interfaces, DTOs, domain models) in `listenarr.application` or `listenarr.domain`. - Keep framework-dependent implementations (EF Core, HttpClients, filesystem) in `listenarr.infrastructure`. - `listenarr.api` should only compose services, host controllers, and register DI; do not add new interfaces that duplicate application/infrastructure contracts. diff --git a/Directory.Packages.props b/Directory.Packages.props index 06dfb96ca..b10870801 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -22,7 +22,12 @@ + + + + + diff --git a/listenarr.api/Attributes/LocalOrAdminAttribute.cs b/listenarr.api/Attributes/LocalOrAdminAttribute.cs index 71f8903d5..8b1e87141 100644 --- a/listenarr.api/Attributes/LocalOrAdminAttribute.cs +++ b/listenarr.api/Attributes/LocalOrAdminAttribute.cs @@ -1,4 +1,3 @@ -using Listenarr.Application.Security; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; @@ -9,8 +8,8 @@ public class LocalOrAdminAttribute : Attribute, IAuthorizationFilter { public void OnAuthorization(AuthorizationFilterContext context) { - var isLoopback = SecurityRequestUtils.IsLoopbackRequest(context.HttpContext); - var isAuth = SecurityRequestUtils.IsAuthenticatedAdminOrApiKey(context.HttpContext); + var isLoopback = HttpSecurityRequestUtils.IsLoopbackRequest(context.HttpContext); + var isAuth = HttpSecurityRequestUtils.IsAuthenticatedAdminOrApiKey(context.HttpContext); if (!isLoopback && !isAuth) { diff --git a/listenarr.api/Attributes/RequireAdminOrApiKeyAttribute.cs b/listenarr.api/Attributes/RequireAdminOrApiKeyAttribute.cs index 51415d3ae..9d447e63e 100644 --- a/listenarr.api/Attributes/RequireAdminOrApiKeyAttribute.cs +++ b/listenarr.api/Attributes/RequireAdminOrApiKeyAttribute.cs @@ -17,7 +17,6 @@ */ using Listenarr.Application.Interfaces; -using Listenarr.Application.Security; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; @@ -59,7 +58,7 @@ public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionE } if (!_startupConfigService.IsAuthenticationRequired() || - SecurityRequestUtils.IsAuthenticatedAdminOrApiKey(context.HttpContext)) + HttpSecurityRequestUtils.IsAuthenticatedAdminOrApiKey(context.HttpContext)) { await next(); return; diff --git a/listenarr.api/Attributes/RequireAdministratorSessionAttribute.cs b/listenarr.api/Attributes/RequireAdministratorSessionAttribute.cs index 10ff53e2a..e888f4cc8 100644 --- a/listenarr.api/Attributes/RequireAdministratorSessionAttribute.cs +++ b/listenarr.api/Attributes/RequireAdministratorSessionAttribute.cs @@ -17,7 +17,6 @@ */ using Listenarr.Application.Interfaces; -using Listenarr.Application.Security; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; @@ -60,7 +59,7 @@ public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionE var user = context.HttpContext.User; if (user?.Identity?.IsAuthenticated == true && user.IsInRole("Administrator") && - !SecurityRequestUtils.IsApiKeyAuthenticated(context.HttpContext)) + !HttpSecurityRequestUtils.IsApiKeyAuthenticated(context.HttpContext)) { await next(); return; diff --git a/listenarr.api/Attributes/RequireApiKeyAttribute.cs b/listenarr.api/Attributes/RequireApiKeyAttribute.cs index 3106b9c8b..0a395b5a0 100644 --- a/listenarr.api/Attributes/RequireApiKeyAttribute.cs +++ b/listenarr.api/Attributes/RequireApiKeyAttribute.cs @@ -17,7 +17,6 @@ */ using Listenarr.Application.Interfaces; -using Listenarr.Application.Security; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; @@ -56,7 +55,7 @@ public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionE return; } - if (SecurityRequestUtils.IsApiKeyAuthenticated(context.HttpContext)) + if (HttpSecurityRequestUtils.IsApiKeyAuthenticated(context.HttpContext)) { await next(); return; diff --git a/listenarr.api/Attributes/RequireApiKeyManagementAccessAttribute.cs b/listenarr.api/Attributes/RequireApiKeyManagementAccessAttribute.cs index f96865812..f6e248e25 100644 --- a/listenarr.api/Attributes/RequireApiKeyManagementAccessAttribute.cs +++ b/listenarr.api/Attributes/RequireApiKeyManagementAccessAttribute.cs @@ -17,7 +17,6 @@ */ using Listenarr.Application.Interfaces; -using Listenarr.Application.Security; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; @@ -56,7 +55,7 @@ public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionE var user = httpContext.User; if (user?.Identity?.IsAuthenticated == true && user.IsInRole("Administrator") && - !SecurityRequestUtils.IsApiKeyAuthenticated(httpContext)) + !HttpSecurityRequestUtils.IsApiKeyAuthenticated(httpContext)) { await next(); return; @@ -68,7 +67,7 @@ public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionE return; } - if (SecurityRequestUtils.IsLocalOrPrivateRequest(httpContext)) + if (HttpSecurityRequestUtils.IsLocalOrPrivateRequest(httpContext)) { await next(); return; diff --git a/listenarr.api/Common/ApiVersionHttpContextExtensions.cs b/listenarr.api/Common/ApiVersionHttpContextExtensions.cs new file mode 100644 index 000000000..72ec520cc --- /dev/null +++ b/listenarr.api/Common/ApiVersionHttpContextExtensions.cs @@ -0,0 +1,46 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +using Listenarr.Domain.Common; + +namespace Listenarr.Api.Common +{ + public static class HttpApiVersionUtils + { + public static string ResolveApiVersion(HttpContext? context, string? fallbackVersion = null, ILogger? logger = null) + { + try + { + if (context?.Request?.RouteValues?.TryGetValue("version", out var routeVersionObj) is true) + { + var routeVersion = routeVersionObj?.ToString(); + if (!string.IsNullOrWhiteSpace(routeVersion)) + { + return ApiVersionNormalizer.NormalizeOrDefault(routeVersion); + } + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger?.LogWarning(ex, "API version route parse failed."); + } + + return Listenarr.Application.Common.ApiVersionUtils.ResolveApiVersion(context?.Request?.Path.Value, fallbackVersion, logger); + } + + public static string GetApiVersionSegment(HttpContext? context, string? fallbackVersion = null) + => $"v{ResolveApiVersion(context, fallbackVersion)}"; + + public static string BuildApiPath(string endpoint, HttpContext? context, string? fallbackVersion = null) + => Listenarr.Application.Common.ApiVersionUtils.BuildApiPath(endpoint, context?.Request?.Path.Value, fallbackVersion); + + public static string BuildImagePath(string identifier, HttpContext? context, string? fallbackVersion = null, string? sourceUrl = null) + => Listenarr.Application.Common.ApiVersionUtils.BuildImagePath(identifier, context?.Request?.Path.Value, fallbackVersion, sourceUrl); + } +} diff --git a/listenarr.api/Controllers/AntiforgeryController.cs b/listenarr.api/Controllers/AntiforgeryController.cs index 3f51854e9..657c9d500 100644 --- a/listenarr.api/Controllers/AntiforgeryController.cs +++ b/listenarr.api/Controllers/AntiforgeryController.cs @@ -75,4 +75,3 @@ public IActionResult GetToken() } } } - diff --git a/listenarr.api/Controllers/Configurations/ApiSourcesController.cs b/listenarr.api/Controllers/Configurations/ApiSourcesController.cs index afe2541ec..f4f9215aa 100644 --- a/listenarr.api/Controllers/Configurations/ApiSourcesController.cs +++ b/listenarr.api/Controllers/Configurations/ApiSourcesController.cs @@ -52,7 +52,7 @@ public async Task>> GetApiConfigurations() try { var configs = await _configurationService.GetApiConfigurationsAsync(); - if (SecurityRequestUtils.ShouldRedactSecretsForCaller(HttpContext)) + if (HttpSecurityRequestUtils.ShouldRedactSecretsForCaller(HttpContext)) { configs = configs.Select(ApiResponseRedactor.RedactApiConfiguration).ToList(); } @@ -83,7 +83,7 @@ public async Task> GetApiConfiguration(string id) { return NotFound(); } - if (SecurityRequestUtils.ShouldRedactSecretsForCaller(HttpContext)) + if (HttpSecurityRequestUtils.ShouldRedactSecretsForCaller(HttpContext)) { return Ok(ApiResponseRedactor.RedactApiConfiguration(config)); } diff --git a/listenarr.api/Controllers/Configurations/SettingsController.cs b/listenarr.api/Controllers/Configurations/SettingsController.cs index 42a036795..a64c5eafa 100644 --- a/listenarr.api/Controllers/Configurations/SettingsController.cs +++ b/listenarr.api/Controllers/Configurations/SettingsController.cs @@ -18,12 +18,10 @@ using Listenarr.Api.Attributes; using Listenarr.Application.Interfaces; -using Listenarr.Application.Notification; using Listenarr.Application.Security; using Listenarr.Domain.Models; using Listenarr.Domain.Models.Configurations; using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.SignalR; using System.Text.Json; namespace Listenarr.Api.Controllers.Configurations @@ -57,7 +55,7 @@ public async Task> GetApplicationSettings() try { var settings = PrepareApplicationSettingsResponse(await _configurationService.GetApplicationSettingsAsync()); - if (SecurityRequestUtils.ShouldRedactSecretsForCaller(HttpContext)) + if (HttpSecurityRequestUtils.ShouldRedactSecretsForCaller(HttpContext)) { return Ok(ApiResponseRedactor.RedactApplicationSettings(settings)); } @@ -91,7 +89,7 @@ public async Task> SaveApplicationSettings([Fr await _settingsHub.Clients.All.SendAsync("SettingsUpdated", ApiResponseRedactor.RedactApplicationSettings(savedSettings)); _logger.LogDebug("Application settings saved successfully and broadcasted via SignalR"); - if (SecurityRequestUtils.ShouldRedactSecretsForCaller(HttpContext)) + if (HttpSecurityRequestUtils.ShouldRedactSecretsForCaller(HttpContext)) { return Ok(ApiResponseRedactor.RedactApplicationSettings(savedSettings)); } diff --git a/listenarr.api/Controllers/Configurations/StartupConfigurationController.cs b/listenarr.api/Controllers/Configurations/StartupConfigurationController.cs index 393482b57..7a9b3be46 100644 --- a/listenarr.api/Controllers/Configurations/StartupConfigurationController.cs +++ b/listenarr.api/Controllers/Configurations/StartupConfigurationController.cs @@ -75,7 +75,7 @@ public async Task> GetStartupConfig() { var config = await _configurationService.GetStartupConfigAsync() ?? new StartupConfig(); config.ApiVersion = NormalizeStartupApiVersion(config.ApiVersion); - if (SecurityRequestUtils.ShouldRedactSecretsForCaller(HttpContext)) + if (HttpSecurityRequestUtils.ShouldRedactSecretsForCaller(HttpContext)) { config = ApiResponseRedactor.RedactStartupConfig(config); } @@ -105,7 +105,7 @@ public async Task> SaveStartupConfig([FromBody] Star } savedConfig.ApiVersion = NormalizeStartupApiVersion(savedConfig.ApiVersion); - if (SecurityRequestUtils.ShouldRedactSecretsForCaller(HttpContext)) + if (HttpSecurityRequestUtils.ShouldRedactSecretsForCaller(HttpContext)) { return Ok(ApiResponseRedactor.RedactStartupConfig(savedConfig)); } diff --git a/listenarr.api/Controllers/DownloadClientController.cs b/listenarr.api/Controllers/DownloadClientController.cs index 75e7d6d5c..d814b6252 100644 --- a/listenarr.api/Controllers/DownloadClientController.cs +++ b/listenarr.api/Controllers/DownloadClientController.cs @@ -55,7 +55,7 @@ public async Task>> GetDownloadCl try { var configs = await _configurationService.GetDownloadClientConfigurationsAsync(); - var redactSecrets = SecurityRequestUtils.ShouldRedactSecretsForCaller(HttpContext); + var redactSecrets = HttpSecurityRequestUtils.ShouldRedactSecretsForCaller(HttpContext); var response = configs .Select(c => redactSecrets ? ApiResponseRedactor.RedactDownloadClientConfiguration(c) : c) .Select(ApiResponseRedactor.ToDownloadClientSummaryResponse) @@ -88,7 +88,7 @@ public async Task> GetDownloadClientCo return NotFound(); } - var responseConfig = SecurityRequestUtils.ShouldRedactSecretsForCaller(HttpContext) + var responseConfig = HttpSecurityRequestUtils.ShouldRedactSecretsForCaller(HttpContext) ? ApiResponseRedactor.RedactDownloadClientConfiguration(config) : config; var response = ApiResponseRedactor.ToDownloadClientDetailResponse(responseConfig); @@ -248,7 +248,7 @@ public async Task> TestDownloadClientConfiguration([FromBod var (success, message) = await _downloadClientGateway.TestConnectionAsync(config); var clientResponse = config; - if (SecurityRequestUtils.ShouldRedactSecretsForCaller(HttpContext)) + if (HttpSecurityRequestUtils.ShouldRedactSecretsForCaller(HttpContext)) { clientResponse = ApiResponseRedactor.RedactDownloadClientConfiguration(clientResponse); } diff --git a/listenarr.api/Controllers/DownloadController.cs b/listenarr.api/Controllers/DownloadController.cs index 969d3913e..2c7eadffe 100644 --- a/listenarr.api/Controllers/DownloadController.cs +++ b/listenarr.api/Controllers/DownloadController.cs @@ -347,5 +347,3 @@ public class ReprocessAllRequest public TimeSpan MaxAge { get; set; } = TimeSpan.FromDays(30); } } - - diff --git a/listenarr.api/Controllers/DownloadsController.cs b/listenarr.api/Controllers/DownloadsController.cs index a966b807c..ec73ae552 100644 --- a/listenarr.api/Controllers/DownloadsController.cs +++ b/listenarr.api/Controllers/DownloadsController.cs @@ -377,5 +377,3 @@ private async Task> EnhanceDownloadsWithClientNames(List }).Cast().ToList(); } } - - diff --git a/listenarr.api/Controllers/FfmpegController.cs b/listenarr.api/Controllers/FfmpegController.cs index 5fe35ab8c..89537e643 100644 --- a/listenarr.api/Controllers/FfmpegController.cs +++ b/listenarr.api/Controllers/FfmpegController.cs @@ -119,5 +119,3 @@ public async Task RunFfprobe([FromBody] FfprobeScanRequest req) } } } - - diff --git a/listenarr.api/Controllers/FileSystemController.cs b/listenarr.api/Controllers/FileSystemController.cs index 3827cec44..3571dfa0b 100644 --- a/listenarr.api/Controllers/FileSystemController.cs +++ b/listenarr.api/Controllers/FileSystemController.cs @@ -312,5 +312,3 @@ public class VolumeCheckResponse public string? DestVolume { get; set; } public string? Message { get; set; } } - - diff --git a/listenarr.api/Controllers/IndexersController.cs b/listenarr.api/Controllers/IndexersController.cs index 1d5059a71..533f0a9b2 100644 --- a/listenarr.api/Controllers/IndexersController.cs +++ b/listenarr.api/Controllers/IndexersController.cs @@ -18,7 +18,6 @@ using Listenarr.Api.Attributes; using Listenarr.Api.Dtos; -using Listenarr.Application.Common; using Listenarr.Application.Interfaces; using Listenarr.Application.Interfaces.Repositories; using Listenarr.Application.Security; @@ -50,7 +49,7 @@ public IndexersController(IIndexerRepository indexerRepository, ILogger SecurityRequestUtils.ShouldRedactSecretsForCaller(HttpContext); + => HttpSecurityRequestUtils.ShouldRedactSecretsForCaller(HttpContext); private Indexer RedactIndexerForCaller(Indexer indexer) => ShouldRedactIndexerSecretsForCaller() ? ApiResponseRedactor.RedactIndexer(indexer) : indexer; @@ -1048,7 +1047,7 @@ public async Task DebugMyAnonamouseSearch(int id, [FromBody] Json { var scheme = Request.Scheme; var hostVal = Request.Host.Value; - var localSearchUrl = $"{scheme}://{hostVal}{ApiVersionUtils.BuildApiPath($"/search/{id}", HttpContext)}?query={Uri.EscapeDataString(query)}"; + var localSearchUrl = $"{scheme}://{hostVal}{HttpApiVersionUtils.BuildApiPath($"/search/{id}", HttpContext)}?query={Uri.EscapeDataString(query)}"; using var localResp = await _httpClient.GetAsync(localSearchUrl); if (localResp.IsSuccessStatusCode) { diff --git a/listenarr.api/Controllers/ProwlarrCompatController.cs b/listenarr.api/Controllers/ProwlarrCompatController.cs index 43d333856..55dfbba44 100644 --- a/listenarr.api/Controllers/ProwlarrCompatController.cs +++ b/listenarr.api/Controllers/ProwlarrCompatController.cs @@ -20,8 +20,6 @@ using Listenarr.Application.Interfaces; using Listenarr.Domain.Models; using Listenarr.Application.Interfaces.Repositories; -using Microsoft.AspNetCore.SignalR; -using Listenarr.Application.Notification; using Listenarr.Application.Security; using Listenarr.Api.Attributes; @@ -347,7 +345,7 @@ public async Task GetIndexersList() .ThenBy(i => i.Name) .ToList(); - if (SecurityRequestUtils.ShouldRedactSecretsForCaller(HttpContext)) + if (HttpSecurityRequestUtils.ShouldRedactSecretsForCaller(HttpContext)) { indexers = indexers.Select(ApiResponseRedactor.RedactIndexer).ToList(); } diff --git a/listenarr.api/Controllers/QualityProfileController.cs b/listenarr.api/Controllers/QualityProfileController.cs index b990ce8e3..b68f95617 100644 --- a/listenarr.api/Controllers/QualityProfileController.cs +++ b/listenarr.api/Controllers/QualityProfileController.cs @@ -217,5 +217,3 @@ public async Task>> ScoreResults( } } } - - diff --git a/listenarr.api/Controllers/RemotePathMappingsController.cs b/listenarr.api/Controllers/RemotePathMappingsController.cs index 2ec6dc708..0cc8322a9 100644 --- a/listenarr.api/Controllers/RemotePathMappingsController.cs +++ b/listenarr.api/Controllers/RemotePathMappingsController.cs @@ -256,5 +256,3 @@ public class TranslatePathRequest public string RemotePath { get; set; } = string.Empty; } } - - diff --git a/listenarr.api/Controllers/SearchController.cs b/listenarr.api/Controllers/SearchController.cs index fefe6e9d7..c0c0f3976 100644 --- a/listenarr.api/Controllers/SearchController.cs +++ b/listenarr.api/Controllers/SearchController.cs @@ -20,7 +20,6 @@ using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.AspNetCore.Mvc; -using Listenarr.Application.Common; using Listenarr.Application.Interfaces; using Listenarr.Application.Metadata; using Listenarr.Application.Search; @@ -58,7 +57,7 @@ public SearchController( } private string BuildApiImagePath(string identifier, string? sourceUrl = null) - => ApiVersionUtils.BuildImagePath(identifier, HttpContext, sourceUrl: sourceUrl); + => HttpApiVersionUtils.BuildImagePath(identifier, HttpContext, sourceUrl: sourceUrl); private static string? NormalizeStructuredAdvancedField(string? value, string prefix) { @@ -1448,4 +1447,3 @@ public async Task> SearchByApi( } } } - diff --git a/listenarr.api/GlobalUsings.cs b/listenarr.api/GlobalUsings.cs new file mode 100644 index 000000000..18a4e870f --- /dev/null +++ b/listenarr.api/GlobalUsings.cs @@ -0,0 +1,5 @@ +global using Microsoft.AspNetCore.SignalR; +global using Microsoft.Extensions.Hosting; +global using Listenarr.Infrastructure.SignalR; +global using Listenarr.Api.Security; +global using Listenarr.Api.Common; diff --git a/listenarr.api/Middleware/AntiforgeryValidationMiddleware.cs b/listenarr.api/Middleware/AntiforgeryValidationMiddleware.cs index 493c79b17..eb3fb3ff8 100644 --- a/listenarr.api/Middleware/AntiforgeryValidationMiddleware.cs +++ b/listenarr.api/Middleware/AntiforgeryValidationMiddleware.cs @@ -187,4 +187,3 @@ private static bool IsVersionedIndexerOrSystemPath(string path) => !string.IsNullOrWhiteSpace(path) && VersionedIndexerOrSystemPathRegex.IsMatch(path); } } - diff --git a/listenarr.api/Middleware/RequestBodyLoggingMiddleware.cs b/listenarr.api/Middleware/RequestBodyLoggingMiddleware.cs index 7863ea9dc..1b3be130f 100644 --- a/listenarr.api/Middleware/RequestBodyLoggingMiddleware.cs +++ b/listenarr.api/Middleware/RequestBodyLoggingMiddleware.cs @@ -119,4 +119,3 @@ private static string RedactSensitiveJsonFields(string input) } } } - diff --git a/listenarr.api/Program.Testing.cs b/listenarr.api/Program.Testing.cs index 55e6fafa6..741576b99 100644 --- a/listenarr.api/Program.Testing.cs +++ b/listenarr.api/Program.Testing.cs @@ -27,4 +27,3 @@ static partial void ApplyTestHostPatches(WebApplicationBuilder builder) builder.Configuration.AddInMemoryCollection(inMemory); } } - diff --git a/listenarr.api/Program.cs b/listenarr.api/Program.cs index c32ee465e..75708b963 100644 --- a/listenarr.api/Program.cs +++ b/listenarr.api/Program.cs @@ -29,7 +29,6 @@ using Serilog.Events; using Listenarr.Infrastructure.Extensions; using Listenarr.Application.Interfaces; -using Listenarr.Infrastructure.SignalR; using Listenarr.Application.Downloads; using Listenarr.Infrastructure.Persistence; using Listenarr.Application.Common; @@ -42,7 +41,6 @@ using Listenarr.Domain.Models.Configurations; using Listenarr.Application.Audiobooks; using Listenarr.Infrastructure.Persistence.Repositories; -using Microsoft.AspNetCore.SignalR; using Listenarr.Api.Middleware; using Listenarr.Api.Filters; using System.Text.Json.Serialization; @@ -417,9 +415,6 @@ ex is IOException builder.Services.AddListenarrHostedServices(builder.Configuration); } -// FIXME: Required for ConfigurationService, what was planned with this feature ? -builder.Services.AddSingleton(new EphemeralDataProtectionProvider().CreateProtector("Listenarr.ConfigurationService.ProwlarrImport")); - // Startup DB normalizer: run once at startup to idempotently normalize legacy JSON columns builder.Services.AddHostedService(); // External request options (Prefer US domain / optional US proxy) diff --git a/listenarr.api/Security/SecurityRequestHttpContextExtensions.cs b/listenarr.api/Security/SecurityRequestHttpContextExtensions.cs new file mode 100644 index 000000000..3a019d985 --- /dev/null +++ b/listenarr.api/Security/SecurityRequestHttpContextExtensions.cs @@ -0,0 +1,76 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +using System.Net; + +namespace Listenarr.Api.Security; + +public static class HttpSecurityRequestUtils +{ + public static bool IsLoopbackRequest(HttpContext? context) + { + var ip = context?.Connection?.RemoteIpAddress; + if (ip == null) + { + return true; + } + + if (ip.IsIPv4MappedToIPv6) + { + ip = ip.MapToIPv4(); + } + + return IPAddress.IsLoopback(ip); + } + + public static bool IsLocalOrPrivateRequest(HttpContext? context) + { + var ip = context?.Connection?.RemoteIpAddress; + if (ip == null) + { + return true; + } + + return Listenarr.Application.Security.SecurityRequestUtils.IsPrivateOrLoopback(ip); + } + + public static bool IsAuthenticatedAdminOrApiKey(HttpContext? context) + { + var user = context?.User; + if (user?.Identity?.IsAuthenticated != true) + { + return false; + } + + if (user.IsInRole("Administrator")) + { + return true; + } + + var authMethod = user.FindFirst("AuthMethod")?.Value; + return !string.IsNullOrWhiteSpace(authMethod) + && string.Equals(authMethod, "ApiKey", StringComparison.Ordinal); + } + + public static bool IsApiKeyAuthenticated(HttpContext? context) + { + var user = context?.User; + if (user?.Identity?.IsAuthenticated != true) + { + return false; + } + + var authMethod = user.FindFirst("AuthMethod")?.Value; + return !string.IsNullOrWhiteSpace(authMethod) + && string.Equals(authMethod, "ApiKey", StringComparison.Ordinal); + } + + public static bool ShouldRedactSecretsForCaller(HttpContext? context) + => !IsLocalOrPrivateRequest(context) && !IsAuthenticatedAdminOrApiKey(context); +} diff --git a/listenarr.application/Audiobooks/AudiobookFileService.cs b/listenarr.application/Audiobooks/AudiobookFileService.cs index b1ad2c65c..63d917653 100644 --- a/listenarr.application/Audiobooks/AudiobookFileService.cs +++ b/listenarr.application/Audiobooks/AudiobookFileService.cs @@ -15,9 +15,9 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; using System.Text.Json; +using Listenarr.Application.Common; using Listenarr.Domain.Common; using Listenarr.Application.Interfaces; using Listenarr.Application.Interfaces.Repositories; @@ -241,15 +241,14 @@ public async Task EnsureAudiobookFileAsync(Audiobook audiobook, string fil return true; } - catch (DbUpdateException dbEx) + catch (UniqueConstraintViolationException) + { + logger.LogInformation("AudiobookFile insertion conflict detected (likely already created): {Path}", LogRedaction.SanitizeFilePath(filePath)); + return false; + } + catch (PersistenceException dbEx) { attempts++; - var inner = dbEx.InnerException?.Message ?? dbEx.Message; - if (inner != null && inner.IndexOf("UNIQUE", StringComparison.OrdinalIgnoreCase) >= 0) - { - logger.LogInformation("AudiobookFile insertion conflict detected (likely already created): {Path}", LogRedaction.SanitizeFilePath(filePath)); - return false; - } if (attempts >= 3) { logger.LogWarning(dbEx, "Failed to save AudiobookFile after {Attempts} attempts: {Path}", attempts, LogRedaction.SanitizeFilePath(filePath)); diff --git a/listenarr.application/Audiobooks/MoveQueueService.cs b/listenarr.application/Audiobooks/MoveQueueService.cs index c7c9092ee..b92311bbe 100644 --- a/listenarr.application/Audiobooks/MoveQueueService.cs +++ b/listenarr.application/Audiobooks/MoveQueueService.cs @@ -19,10 +19,8 @@ using System.Threading.Channels; using Listenarr.Application.Interfaces; using Listenarr.Application.Interfaces.Repositories; -using Listenarr.Application.Notification; using Listenarr.Application.Security; using Listenarr.Domain.Models; -using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -132,7 +130,7 @@ public void UpdateJobStatus(Guid id, string status, string? error = null) // Broadcast status update to SignalR clients so UI can react to Processing/Failed/Completed try { - var hub = scope.ServiceProvider.GetRequiredService>(); + var hub = scope.ServiceProvider.GetRequiredService(); var payload = new { jobId = id.ToString(), @@ -143,7 +141,7 @@ public void UpdateJobStatus(Guid id, string status, string? error = null) updatedAt = DateTime.UtcNow }; // Fire and forget but block briefly to surface errors during development - hub.Clients.All.SendAsync("MoveJobUpdate", payload).GetAwaiter().GetResult(); + hub.BroadcastAsync("MoveJobUpdate", payload).GetAwaiter().GetResult(); } catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) { @@ -222,4 +220,3 @@ private static bool CanRequeueJobStatus(string status) } } - diff --git a/listenarr.application/Common/ApiVersionUtils.cs b/listenarr.application/Common/ApiVersionUtils.cs index 843c80900..cb313088f 100644 --- a/listenarr.application/Common/ApiVersionUtils.cs +++ b/listenarr.application/Common/ApiVersionUtils.cs @@ -17,7 +17,6 @@ */ using System.Text.RegularExpressions; using Listenarr.Domain.Common; -using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; namespace Listenarr.Application.Common @@ -31,29 +30,12 @@ public static class ApiVersionUtils private static readonly Regex ApiVersionFromPathRegex = new(@"^/api/v(?\d+(?:\.\d+)?)(?:/|$)", RegexOptions.IgnoreCase | RegexOptions.Compiled); private static readonly Regex LeadingApiPrefixRegex = new(@"^/api(?:/v\d+(?:\.\d+)?)?", RegexOptions.IgnoreCase | RegexOptions.Compiled); - public static string ResolveApiVersion(HttpContext? context, string? fallbackVersion = null, ILogger? logger = null) + public static string ResolveApiVersion(string? path = null, string? fallbackVersion = null, ILogger? logger = null) { var fallback = ApiVersionNormalizer.NormalizeOrDefault(fallbackVersion); try { - if (context?.Request?.RouteValues?.TryGetValue("version", out var routeVersionObj) is true) - { - var routeVersion = routeVersionObj?.ToString(); - if (!string.IsNullOrWhiteSpace(routeVersion)) - { - return ApiVersionNormalizer.NormalizeOrDefault(routeVersion); - } - } - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - logger?.LogWarning(ex, "API version route parse failed."); - } - - try - { - var path = context?.Request?.Path.Value; if (!string.IsNullOrWhiteSpace(path)) { var match = ApiVersionFromPathRegex.Match(path); @@ -75,19 +57,19 @@ public static string ResolveApiVersion(HttpContext? context, string? fallbackVer return fallback; } - public static string GetApiVersionSegment(HttpContext? context, string? fallbackVersion = null) - => $"v{ResolveApiVersion(context, fallbackVersion)}"; + public static string GetApiVersionSegment(string? path = null, string? fallbackVersion = null) + => $"v{ResolveApiVersion(path, fallbackVersion)}"; - public static string BuildApiPath(string endpoint, HttpContext? context = null, string? fallbackVersion = null) + public static string BuildApiPath(string endpoint, string? requestPath = null, string? fallbackVersion = null) { var normalizedEndpoint = NormalizeEndpoint(endpoint); - return $"/api/{GetApiVersionSegment(context, fallbackVersion)}{normalizedEndpoint}"; + return $"/api/{GetApiVersionSegment(requestPath, fallbackVersion)}{normalizedEndpoint}"; } - public static string BuildImagePath(string identifier, HttpContext? context = null, string? fallbackVersion = null, string? sourceUrl = null) + public static string BuildImagePath(string identifier, string? requestPath = null, string? fallbackVersion = null, string? sourceUrl = null) { var encodedIdentifier = Uri.EscapeDataString(identifier ?? string.Empty); - var path = BuildApiPath($"/images/{encodedIdentifier}", context, fallbackVersion); + var path = BuildApiPath($"/images/{encodedIdentifier}", requestPath, fallbackVersion); if (string.IsNullOrWhiteSpace(sourceUrl)) return path; return $"{path}?url={Uri.EscapeDataString(sourceUrl)}"; } diff --git a/listenarr.application/Common/ConfigurationService.cs b/listenarr.application/Common/ConfigurationService.cs index 82581298e..89a8c96ff 100644 --- a/listenarr.application/Common/ConfigurationService.cs +++ b/listenarr.application/Common/ConfigurationService.cs @@ -22,7 +22,6 @@ using Listenarr.Application.Security; using Listenarr.Domain.Models; using Listenarr.Domain.Models.Configurations; -using Microsoft.AspNetCore.DataProtection; using Microsoft.Extensions.Logging; namespace Listenarr.Application.Common @@ -35,7 +34,7 @@ public class ConfigurationService( IUserService userService, IStartupConfigService startupConfigService, IRootFolderRepository rootFolderRepository, - IDataProtector dataProtector) : IConfigurationService + ISecretProtector secretProtector) : IConfigurationService { // API Configuration methods public async Task> GetApiConfigurationsAsync() @@ -337,7 +336,7 @@ public async Task SaveProwlarrImportSettingsAs if (!string.IsNullOrWhiteSpace(settings.ApiKey) && !string.Equals(settings.ApiKey, ApiResponseRedactor.RedactedValue, StringComparison.Ordinal)) { - existing.ProwlarrApiKeyEncrypted = dataProtector.Protect(settings.ApiKey.Trim()); + existing.ProwlarrApiKeyEncrypted = secretProtector.Protect(settings.ApiKey.Trim()); } await settingsRepository.SaveAsync(existing); @@ -359,7 +358,7 @@ public async Task SaveProwlarrImportSettingsAs try { - return dataProtector.Unprotect(encryptedApiKey); + return secretProtector.Unprotect(encryptedApiKey); } catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) { diff --git a/listenarr.application/Common/PersistenceException.cs b/listenarr.application/Common/PersistenceException.cs new file mode 100644 index 000000000..30f2d0fc6 --- /dev/null +++ b/listenarr.application/Common/PersistenceException.cs @@ -0,0 +1,36 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace Listenarr.Application.Common +{ + public class PersistenceException : Exception + { + public PersistenceException(string message, Exception innerException) + : base(message, innerException) + { + } + } + + public class UniqueConstraintViolationException : PersistenceException + { + public UniqueConstraintViolationException(string message, Exception innerException) + : base(message, innerException) + { + } + } +} diff --git a/listenarr.application/Interfaces/IAudibleAuthorPageParser.cs b/listenarr.application/Interfaces/IAudibleAuthorPageParser.cs new file mode 100644 index 000000000..c6dff1672 --- /dev/null +++ b/listenarr.application/Interfaces/IAudibleAuthorPageParser.cs @@ -0,0 +1,27 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +using Listenarr.Application.Metadata; + +namespace Listenarr.Application.Interfaces +{ + public interface IAudibleAuthorPageParser + { + List ParseAuthorPage(string html, string author, string authorAsin, string region); + } +} diff --git a/listenarr.application/Interfaces/IAudioTagWriter.cs b/listenarr.application/Interfaces/IAudioTagWriter.cs new file mode 100644 index 000000000..471c38a6e --- /dev/null +++ b/listenarr.application/Interfaces/IAudioTagWriter.cs @@ -0,0 +1,25 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace Listenarr.Application.Interfaces +{ + public interface IAudioTagWriter + { + Task WriteAsinTagAsync(string filePath, string asin); + } +} diff --git a/listenarr.application/Interfaces/IAudiobookFileService.cs b/listenarr.application/Interfaces/IAudiobookFileService.cs index e6d618e5b..3ccc7287e 100644 --- a/listenarr.application/Interfaces/IAudiobookFileService.cs +++ b/listenarr.application/Interfaces/IAudiobookFileService.cs @@ -8,7 +8,7 @@ namespace Listenarr.Application.Interfaces public interface IAudiobookFileService { /// - /// Ensure an Audiobook file record exists for the given audiobook and file path. Extract metadata (ffprobe/taglib) and persist file-level metadata. + /// Ensure an Audiobook file record exists for the given audiobook and file path. Extract metadata and persist file-level metadata. /// /// The audiobook /// Path to the audio file diff --git a/listenarr.application/Interfaces/ICoverImageProbe.cs b/listenarr.application/Interfaces/ICoverImageProbe.cs new file mode 100644 index 000000000..eff33696a --- /dev/null +++ b/listenarr.application/Interfaces/ICoverImageProbe.cs @@ -0,0 +1,27 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace Listenarr.Application.Interfaces +{ + public readonly record struct ImageDimensions(int Width, int Height); + + public interface ICoverImageProbe + { + Task ProbeAsync(string url, CancellationToken cancellationToken = default); + } +} diff --git a/listenarr.application/Interfaces/IHtmlTextExtractor.cs b/listenarr.application/Interfaces/IHtmlTextExtractor.cs new file mode 100644 index 000000000..b5c5c0ef1 --- /dev/null +++ b/listenarr.application/Interfaces/IHtmlTextExtractor.cs @@ -0,0 +1,25 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace Listenarr.Application.Interfaces +{ + public interface IHtmlTextExtractor + { + string ExtractText(string html); + } +} diff --git a/listenarr.application/Interfaces/IHubBroadcaster.cs b/listenarr.application/Interfaces/IHubBroadcaster.cs index dbd5f122d..28db75c2a 100644 --- a/listenarr.application/Interfaces/IHubBroadcaster.cs +++ b/listenarr.application/Interfaces/IHubBroadcaster.cs @@ -22,5 +22,6 @@ namespace Listenarr.Application.Interfaces public interface IHubBroadcaster { Task BroadcastQueueUpdateAsync(QueueSnapshot queueSnapshot); + Task BroadcastAsync(string eventName, object payload, CancellationToken cancellationToken = default); } } diff --git a/listenarr.application/Interfaces/IRequestContextAccessor.cs b/listenarr.application/Interfaces/IRequestContextAccessor.cs new file mode 100644 index 000000000..7d7a804e9 --- /dev/null +++ b/listenarr.application/Interfaces/IRequestContextAccessor.cs @@ -0,0 +1,25 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +using System.Net; + +namespace Listenarr.Application.Interfaces +{ + public sealed record RequestContextSnapshot( + string? Path, + string? Scheme, + string? Host, + IPAddress? RemoteIpAddress, + bool IsAuthenticatedAdminOrApiKey); + + public interface IRequestContextAccessor + { + RequestContextSnapshot? Current { get; } + } +} diff --git a/listenarr.application/Interfaces/ISecretProtector.cs b/listenarr.application/Interfaces/ISecretProtector.cs new file mode 100644 index 000000000..a6dda4b2b --- /dev/null +++ b/listenarr.application/Interfaces/ISecretProtector.cs @@ -0,0 +1,17 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +namespace Listenarr.Application.Interfaces +{ + public interface ISecretProtector + { + string Protect(string plaintext); + string Unprotect(string protectedValue); + } +} diff --git a/listenarr.application/Listenarr.Application.csproj b/listenarr.application/Listenarr.Application.csproj index 3201501a9..e143391f1 100644 --- a/listenarr.application/Listenarr.Application.csproj +++ b/listenarr.application/Listenarr.Application.csproj @@ -8,14 +8,12 @@ - - - - - - - + + + + + diff --git a/listenarr.application/Metadata/AudibleService.cs b/listenarr.application/Metadata/AudibleService.cs index 68e9be914..5299973c2 100644 --- a/listenarr.application/Metadata/AudibleService.cs +++ b/listenarr.application/Metadata/AudibleService.cs @@ -18,8 +18,9 @@ using System.Globalization; using System.Text; using System.Text.Json; -using HtmlAgilityPack; +using Listenarr.Application.Interfaces; using Listenarr.Application.Security; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; namespace Listenarr.Application.Metadata @@ -71,11 +72,19 @@ public class AudibleService }; private readonly HttpClient _httpClient; private readonly ILogger _logger; + private readonly IAudibleAuthorPageParser? _authorPageParser; public AudibleService(HttpClient httpClient, ILogger logger) + : this(httpClient, logger, null) + { + } + + [ActivatorUtilitiesConstructor] + public AudibleService(HttpClient httpClient, ILogger logger, IAudibleAuthorPageParser? authorPageParser) { _httpClient = httpClient; _logger = logger; + _authorPageParser = authorPageParser; _httpClient.DefaultRequestHeaders.Accept.Clear(); _httpClient.DefaultRequestHeaders.Accept.ParseAdd(BrowserAcceptHeader); _httpClient.DefaultRequestHeaders.AcceptLanguage.Clear(); @@ -1504,55 +1513,13 @@ private static string NormalizeComparableText(string? value) return null; } - var htmlDoc = new HtmlDocument(); - htmlDoc.LoadHtml(html); - - var tiles = htmlDoc.DocumentNode.SelectNodes("//adbl-full-width-product-tile"); - var legacyProductListItems = htmlDoc.DocumentNode.SelectNodes("//li[contains(@class, 'productListItem')]"); - if ((tiles == null || tiles.Count == 0) && - (legacyProductListItems == null || legacyProductListItems.Count == 0)) + if (_authorPageParser == null) { - _logger.LogWarning("Audible author page contained no recognizable product tiles for author {Author}", LogRedaction.SanitizeText(author)); + _logger.LogWarning("Audible author page parser is unavailable for author {Author}", LogRedaction.SanitizeText(author)); return null; } - var parsedTiles = new List(); - var seenAsins = new HashSet(StringComparer.OrdinalIgnoreCase); - - if (tiles != null) - { - foreach (var tile in tiles) - { - var parsed = ParseAudibleAuthorTile(tile, author, authorAsin, region); - if (parsed == null) continue; - - var key = string.IsNullOrWhiteSpace(parsed.Asin) - ? $"{parsed.Title}|{parsed.Link}" - : parsed.Asin; - if (seenAsins.Add(key)) - { - parsedTiles.Add(parsed); - } - } - } - - if (legacyProductListItems != null) - { - foreach (var item in legacyProductListItems) - { - var parsed = ParseAudibleAuthorListItem(item, author, authorAsin, region); - if (parsed == null) continue; - - var key = string.IsNullOrWhiteSpace(parsed.Asin) - ? $"{parsed.Title}|{parsed.Link}" - : parsed.Asin; - if (seenAsins.Add(key)) - { - parsedTiles.Add(parsed); - } - } - } - + var parsedTiles = _authorPageParser.ParseAuthorPage(html, author, authorAsin, region); if (parsedTiles.Count == 0) { _logger.LogWarning("Audible author page tiles could not be parsed for author {Author}", LogRedaction.SanitizeText(author)); @@ -1724,124 +1691,6 @@ private static List ParseSeriesLookupItems(string lookupJson) return new List(); } - private static AudibleSearchResult? ParseAudibleAuthorTile(HtmlNode tile, string author, string authorAsin, string region) - { - var productImageNode = tile.SelectSingleNode(".//adbl-product-image") - ?? tile.SelectSingleNode(".//adbl-full-bleed-image"); - var asin = productImageNode?.GetAttributeValue("data-asin", string.Empty); - if (string.IsNullOrWhiteSpace(asin)) - { - asin = tile.SelectSingleNode(".//*[@data-asin]")?.GetAttributeValue("data-asin", string.Empty); - } - if (string.IsNullOrWhiteSpace(asin)) return null; - - var title = HtmlEntity.DeEntitize(tile.SelectSingleNode(".//*[@slot='title']")?.InnerText ?? string.Empty).Trim(); - if (string.IsNullOrWhiteSpace(title)) return null; - - var subtitle = HtmlEntity.DeEntitize(tile.SelectSingleNode(".//*[@slot='subtitle']")?.InnerText ?? string.Empty).Trim(); - var imageUrl = productImageNode?.SelectSingleNode(".//img")?.GetAttributeValue("src", string.Empty); - if (string.IsNullOrWhiteSpace(imageUrl)) - { - imageUrl = productImageNode?.GetAttributeValue("portrait-src", string.Empty); - } - if (string.IsNullOrWhiteSpace(imageUrl)) - { - imageUrl = productImageNode?.GetAttributeValue("landscape-src", string.Empty); - } - var relativeUrl = productImageNode?.GetAttributeValue("data-url", string.Empty); - if (string.IsNullOrWhiteSpace(relativeUrl)) - { - relativeUrl = tile.SelectSingleNode(".//adbl-button[@href]")?.GetAttributeValue("href", string.Empty) - ?? tile.SelectSingleNode(".//a[@href]")?.GetAttributeValue("href", string.Empty); - } - - var authors = ParseAudibleAuthorTileAuthors(tile, author, authorAsin, region); - if (authors.Count == 0 && !string.IsNullOrWhiteSpace(author)) - { - authors.Add(new AudibleAuthor { Asin = authorAsin, Name = author, Region = region }); - } - - return new AudibleSearchResult - { - Asin = asin, - Title = title, - Subtitle = string.IsNullOrWhiteSpace(subtitle) ? null : subtitle, - Authors = authors, - ImageUrl = string.IsNullOrWhiteSpace(imageUrl) ? null : imageUrl, - Link = NormalizeAudibleUrl(relativeUrl, region) - }; - } - - private static AudibleSearchResult? ParseAudibleAuthorListItem(HtmlNode listItem, string author, string authorAsin, string region) - { - var asin = listItem.SelectSingleNode(".//*[@data-asin]")?.GetAttributeValue("data-asin", string.Empty); - if (string.IsNullOrWhiteSpace(asin)) - { - return null; - } - - var title = HtmlEntity.DeEntitize(listItem.GetAttributeValue("aria-label", string.Empty)).Trim(); - if (string.IsNullOrWhiteSpace(title)) - { - title = HtmlEntity.DeEntitize( - listItem.SelectSingleNode(".//h2")?.InnerText ?? string.Empty).Trim(); - } - - if (string.IsNullOrWhiteSpace(title)) - { - return null; - } - - var imageUrl = listItem.SelectSingleNode(".//img[@src]")?.GetAttributeValue("src", string.Empty); - var relativeUrl = listItem.SelectSingleNode(".//a[@href]")?.GetAttributeValue("href", string.Empty); - - return new AudibleSearchResult - { - Asin = asin, - Title = title, - Authors = new List - { - new() - { - Asin = authorAsin, - Name = author, - Region = region - } - }, - ImageUrl = string.IsNullOrWhiteSpace(imageUrl) ? null : imageUrl, - Link = NormalizeAudibleUrl(relativeUrl, region) - }; - } - - private static List ParseAudibleAuthorTileAuthors(HtmlNode tile, string author, string authorAsin, string region) - { - var authors = new List(); - var metadataJson = tile.SelectSingleNode(".//adbl-product-metadata/script[@type='application/json']")?.InnerText; - if (string.IsNullOrWhiteSpace(metadataJson)) return authors; - - try - { - var metadata = JsonSerializer.Deserialize(metadataJson, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); - if (metadata?.Authors == null) return authors; - - foreach (var metadataAuthor in metadata.Authors.Where(metadataAuthor => !string.IsNullOrWhiteSpace(metadataAuthor.Name))) - { - authors.Add(new AudibleAuthor - { - Asin = string.Equals(metadataAuthor.Name, author, StringComparison.OrdinalIgnoreCase) ? authorAsin : null, - Name = metadataAuthor.Name, - Region = region - }); - } - } - catch (JsonException) - { - // Ignore malformed metadata blobs and fall back to the requested author name. - } - - return authors; - } - private static string BuildAudibleAuthorPageUrl(string author, string authorAsin, string region) { var authorSlug = string.IsNullOrWhiteSpace(author) diff --git a/listenarr.application/Metadata/MetadataService.cs b/listenarr.application/Metadata/MetadataService.cs index 12adbb8ea..313e46288 100644 --- a/listenarr.application/Metadata/MetadataService.cs +++ b/listenarr.application/Metadata/MetadataService.cs @@ -30,13 +30,15 @@ public class MetadataService : IMetadataService private readonly HttpClient _httpClient; private readonly IConfigurationService _configurationService; private readonly IFfmpegService _ffmpegService; + private readonly IAudioTagWriter _audioTagWriter; private readonly ILogger _logger; - public MetadataService(HttpClient httpClient, IConfigurationService configurationService, ILogger logger, IFfmpegService ffmpegService) + public MetadataService(HttpClient httpClient, IConfigurationService configurationService, ILogger logger, IFfmpegService ffmpegService, IAudioTagWriter audioTagWriter) { _httpClient = httpClient; _configurationService = configurationService; _ffmpegService = ffmpegService; + _audioTagWriter = audioTagWriter; _logger = logger; } @@ -213,7 +215,7 @@ public async Task ApplyMetadataAsync(string filePath, AudioMetadata metadata) { try { - // This would use a library like TagLib# to apply metadata to audio files + // File tag writing is handled by an infrastructure adapter. _logger.LogInformation("Applied metadata to file: {File}", LogRedaction.SanitizeText(filePath)); await Task.CompletedTask; } @@ -225,35 +227,7 @@ public async Task ApplyMetadataAsync(string filePath, AudioMetadata metadata) public Task WriteAsinTagAsync(string filePath, string asin) { - if (string.IsNullOrWhiteSpace(filePath) || string.IsNullOrWhiteSpace(asin)) - return Task.CompletedTask; - try - { - using var file = TagLib.File.Create(filePath); - - // M4B / M4A / MP4 — iTunes freeform dash box ----:com.apple.iTunes:ASIN - if (file.Tag is TagLib.Mpeg4.AppleTag appleTag) - appleTag.SetDashBox("com.apple.iTunes", "ASIN", asin); - // MP3 — TXXX frame with description "ASIN" - else if (file.GetTag(TagLib.TagTypes.Id3v2) is TagLib.Id3v2.Tag id3Tag) - { - var frame = TagLib.Id3v2.UserTextInformationFrame.Get(id3Tag, "ASIN", true); - frame.Text = new[] { asin }; - } - // FLAC / OGG / Opus — Vorbis comment - else if (file.GetTag(TagLib.TagTypes.Xiph) is TagLib.Ogg.XiphComment xiph) - xiph.SetField("ASIN", asin); - else - return Task.CompletedTask; // Unknown format — skip silently - - file.Save(); - _logger.LogDebug("Wrote ASIN tag '{Asin}' to {File}", asin, LogRedaction.SanitizeFilePath(filePath)); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Failed to write ASIN tag to {File} — import will continue", LogRedaction.SanitizeFilePath(filePath)); - } - return Task.CompletedTask; + return _audioTagWriter.WriteAsinTagAsync(filePath, asin); } public async Task DownloadCoverArtAsync(string coverArtUrl) @@ -307,4 +281,3 @@ public Task WriteAsinTagAsync(string filePath, string asin) } } } - diff --git a/listenarr.application/Notification/DiscordBotService.cs b/listenarr.application/Notification/DiscordBotService.cs index 6743fd39e..1a3f896d0 100644 --- a/listenarr.application/Notification/DiscordBotService.cs +++ b/listenarr.application/Notification/DiscordBotService.cs @@ -19,7 +19,6 @@ using System.Runtime.InteropServices; using Listenarr.Application.Interfaces; using Listenarr.Application.Security; -using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; namespace Listenarr.Application.Notification @@ -37,7 +36,7 @@ public class DiscordBotService : IDiscordBotService private readonly ILogger _logger; private readonly IStartupConfigService _startupConfigService; private readonly IApplicationPathService _applicationPathService; - private readonly IHttpContextAccessor _httpContextAccessor; + private readonly IRequestContextAccessor _requestContextAccessor; private readonly IProcessRunner? _processRunner; private string? _botApiKey; private Process? _botProcess; @@ -47,13 +46,13 @@ public DiscordBotService( ILogger logger, IStartupConfigService startupConfigService, IApplicationPathService applicationPathService, - IHttpContextAccessor httpContextAccessor, + IRequestContextAccessor requestContextAccessor, IProcessRunner? processRunner = null) { _logger = logger; _startupConfigService = startupConfigService; _applicationPathService = applicationPathService; - _httpContextAccessor = httpContextAccessor; + _requestContextAccessor = requestContextAccessor; _processRunner = processRunner; } @@ -262,31 +261,20 @@ private string GetListenarrUrl() // Priority 2: Construct from current HTTP request (when available) try { - var httpContext = _httpContextAccessor.HttpContext; - if (httpContext != null) + var requestContext = _requestContextAccessor.Current; + if (requestContext != null) { - var request = httpContext.Request; - var scheme = request.Scheme; - var host = request.Host.Value; - - // Check if we're behind a reverse proxy (X-Forwarded headers) - if (request.Headers.TryGetValue("X-Forwarded-Proto", out var forwardedProto)) - { - scheme = forwardedProto.ToString(); - } - if (request.Headers.TryGetValue("X-Forwarded-Host", out var forwardedHost)) - { - host = forwardedHost.ToString(); - } + var scheme = requestContext.Scheme; + var host = requestContext.Host; var url = $"{scheme}://{host}"; - _logger.LogInformation("Constructed URL from HTTP context: {Url}", url); + _logger.LogInformation("Constructed URL from request context: {Url}", url); return url; } } catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) { - _logger.LogWarning(ex, "Failed to construct URL from HTTP context"); + _logger.LogWarning(ex, "Failed to construct URL from request context"); } // Priority 3: Use startup config diff --git a/listenarr.application/Notification/INotificationPayloadBuilder.cs b/listenarr.application/Notification/INotificationPayloadBuilder.cs index 73866e306..f482203ab 100644 --- a/listenarr.application/Notification/INotificationPayloadBuilder.cs +++ b/listenarr.application/Notification/INotificationPayloadBuilder.cs @@ -16,7 +16,7 @@ * along with this program. If not, see . */ using System.Text.Json.Nodes; -using Microsoft.AspNetCore.Http; +using Listenarr.Application.Interfaces; namespace Listenarr.Application.Notification { @@ -33,7 +33,7 @@ public interface INotificationPayloadBuilder object data, string? startupBaseUrl, HttpClient httpClient, - IHttpContextAccessor? httpContextAccessor = null, + IRequestContextAccessor? requestContextAccessor = null, Action? logInfo = null, Action? logDebug = null, string? apiVersion = null); diff --git a/listenarr.application/Notification/NotificationPayloadBuilder.cs b/listenarr.application/Notification/NotificationPayloadBuilder.cs index 036273561..ad6d85345 100644 --- a/listenarr.application/Notification/NotificationPayloadBuilder.cs +++ b/listenarr.application/Notification/NotificationPayloadBuilder.cs @@ -19,7 +19,7 @@ using System.Text.Json; using System.Text.Json.Nodes; using Listenarr.Application.Common; -using Microsoft.AspNetCore.Http; +using Listenarr.Application.Interfaces; namespace Listenarr.Application.Notification { @@ -259,7 +259,7 @@ static string Truncate(string? value, int max) return payload; } - public static async Task<(JsonObject payload, AttachmentInfo? attachment)> CreateDiscordPayloadWithAttachmentAsync(string trigger, object data, string? startupBaseUrl, HttpClient httpClient, IHttpContextAccessor? httpContextAccessor = null, Action? logInfo = null, Action? logDebug = null, string? apiVersion = null) + public static async Task<(JsonObject payload, AttachmentInfo? attachment)> CreateDiscordPayloadWithAttachmentAsync(string trigger, object data, string? startupBaseUrl, HttpClient httpClient, IRequestContextAccessor? requestContextAccessor = null, Action? logInfo = null, Action? logDebug = null, string? apiVersion = null) { // Implementation mirrors previous CreateDiscordPayloadWithAttachmentAsync but kept here to centralize payload logic. JsonNode? node = data == null ? null : JsonSerializer.SerializeToNode(data); @@ -341,9 +341,9 @@ static string Truncate(string? value, int max) absoluteImageUrl = startupBaseUrl.TrimEnd('/') + imageUrl; logInfo?.Invoke($"Constructed absolute URL from relative path: {absoluteImageUrl}"); } - else if (imageUrl.StartsWith("/") && startupBaseUrl == null && httpContextAccessor?.HttpContext != null) + else if (imageUrl.StartsWith("/") && startupBaseUrl == null && requestContextAccessor?.Current != null) { - var derived = GetBaseUrlFromHttpContext(httpContextAccessor.HttpContext); + var derived = GetBaseUrlFromRequestContext(requestContextAccessor.Current); if (!string.IsNullOrWhiteSpace(derived)) absoluteImageUrl = derived.TrimEnd('/') + imageUrl; } } @@ -498,12 +498,11 @@ static string Truncate(string? value, int max) return (payload, attachmentInfo); } - public static string? GetBaseUrlFromHttpContext(HttpContext? ctx) + public static string? GetBaseUrlFromRequestContext(RequestContextSnapshot? ctx) { - if (ctx?.Request == null) return null; - var req = ctx.Request; - var scheme = req.Scheme; - var host = req.Host.Value; + if (ctx == null) return null; + var scheme = ctx.Scheme; + var host = ctx.Host; if (string.IsNullOrWhiteSpace(scheme) || string.IsNullOrWhiteSpace(host)) return null; return scheme + "://" + host; } diff --git a/listenarr.application/Notification/NotificationPayloadBuilderAdapter.cs b/listenarr.application/Notification/NotificationPayloadBuilderAdapter.cs index a3481c27e..70865b0a6 100644 --- a/listenarr.application/Notification/NotificationPayloadBuilderAdapter.cs +++ b/listenarr.application/Notification/NotificationPayloadBuilderAdapter.cs @@ -16,7 +16,7 @@ * along with this program. If not, see . */ using System.Text.Json.Nodes; -using Microsoft.AspNetCore.Http; +using Listenarr.Application.Interfaces; namespace Listenarr.Application.Notification { @@ -36,7 +36,7 @@ public JsonNode CreateDiscordPayload(string trigger, object data, string? startu object data, string? startupBaseUrl, HttpClient httpClient, - IHttpContextAccessor? httpContextAccessor = null, + IRequestContextAccessor? requestContextAccessor = null, Action? logInfo = null, Action? logDebug = null, string? apiVersion = null) @@ -46,7 +46,7 @@ public JsonNode CreateDiscordPayload(string trigger, object data, string? startu data, startupBaseUrl, httpClient, - httpContextAccessor, + requestContextAccessor, logInfo, logDebug, apiVersion); diff --git a/listenarr.application/Notification/NotificationService.cs b/listenarr.application/Notification/NotificationService.cs index 5a258dc77..c0aa26b3d 100644 --- a/listenarr.application/Notification/NotificationService.cs +++ b/listenarr.application/Notification/NotificationService.cs @@ -21,9 +21,7 @@ using System.Text.Json.Nodes; using Listenarr.Application.Common; using Listenarr.Application.Interfaces; -using Listenarr.Application.Security; using Listenarr.Domain.Models; -using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; namespace Listenarr.Application.Notification @@ -39,17 +37,17 @@ public class NotificationService : INotificationService private readonly HttpClient _httpClientNoRedirect; private readonly ILogger _logger; private readonly IConfigurationService _configurationService; - private readonly IHttpContextAccessor? _httpContextAccessor; + private readonly IRequestContextAccessor? _requestContextAccessor; private readonly INotificationPayloadBuilder _payloadBuilder; - public NotificationService(HttpClient httpClient, ILogger logger, IConfigurationService configurationService, INotificationPayloadBuilder payloadBuilder, IHttpContextAccessor? httpContextAccessor = null) + public NotificationService(HttpClient httpClient, ILogger logger, IConfigurationService configurationService, INotificationPayloadBuilder payloadBuilder, IRequestContextAccessor? requestContextAccessor = null) { _httpClient = httpClient; _httpClientNoRedirect = httpClient; _logger = logger; _configurationService = configurationService; _payloadBuilder = payloadBuilder ?? throw new ArgumentNullException(nameof(payloadBuilder)); - _httpContextAccessor = httpContextAccessor; + _requestContextAccessor = requestContextAccessor; } // INotificationService interface stubs — webhook dispatch goes through SendNotificationAsync; @@ -100,14 +98,15 @@ public async Task SendSystemNotificationAsync(string title, string message) private bool AllowPrivateWebhookTargetsForCurrentRequest() { - var context = _httpContextAccessor?.HttpContext; + var context = _requestContextAccessor?.Current; if (context == null) { return true; } - return SecurityRequestUtils.IsLoopbackRequest(context) - || SecurityRequestUtils.IsAuthenticatedAdminOrApiKey(context); + return context.RemoteIpAddress == null + || System.Net.IPAddress.IsLoopback(context.RemoteIpAddress) + || context.IsAuthenticatedAdminOrApiKey; } private async Task PostValidatedAsync(string url, HttpContent content, CancellationToken cancellationToken = default) @@ -250,9 +249,9 @@ async Task HandleFailedResponseAsync(HttpResponseMessage response) var startup = await _configurationService.GetStartupConfigAsync(); var baseUrl = startup?.UrlBase; - if (string.IsNullOrWhiteSpace(baseUrl) && _httpContextAccessor?.HttpContext != null) + if (string.IsNullOrWhiteSpace(baseUrl) && _requestContextAccessor?.Current != null) { - var derived = NotificationPayloadBuilder.GetBaseUrlFromHttpContext(_httpContextAccessor.HttpContext); + var derived = NotificationPayloadBuilder.GetBaseUrlFromRequestContext(_requestContextAccessor.Current); if (!string.IsNullOrWhiteSpace(derived)) baseUrl = derived; } @@ -264,10 +263,10 @@ async Task HandleFailedResponseAsync(HttpResponseMessage response) } var (payloadObj, attachment) = await _payloadBuilder.CreateDiscordPayloadWithAttachmentAsync( - trigger, data, baseUrl, _httpClient, _httpContextAccessor, + trigger, data, baseUrl, _httpClient, _requestContextAccessor, logInfo: msg => _logger.LogInformation(msg), logDebug: (ex, msg) => _logger.LogDebug(ex, msg), - apiVersion: ApiVersionUtils.ResolveApiVersion(_httpContextAccessor?.HttpContext, startup?.ApiVersion) + apiVersion: ApiVersionUtils.ResolveApiVersion(_requestContextAccessor?.Current?.Path, startup?.ApiVersion) ); _logger.LogDebug("Discord payload attachment present? {HasAttachment}", attachment != null); @@ -322,13 +321,13 @@ async Task HandleFailedResponseAsync(HttpResponseMessage response) string? baseUrl = null; var startup = await _configurationService.GetStartupConfigAsync(); if (startup?.UrlBase != null) baseUrl = startup.UrlBase; - if (string.IsNullOrWhiteSpace(baseUrl) && _httpContextAccessor?.HttpContext != null) + if (string.IsNullOrWhiteSpace(baseUrl) && _requestContextAccessor?.Current != null) { - var derived = NotificationPayloadBuilder.GetBaseUrlFromHttpContext(_httpContextAccessor.HttpContext); + var derived = NotificationPayloadBuilder.GetBaseUrlFromRequestContext(_requestContextAccessor.Current); if (!string.IsNullOrWhiteSpace(derived)) baseUrl = derived; } - var discordPayload = NotificationPayloadBuilder.CreateDiscordPayload(trigger, data, baseUrl, ApiVersionUtils.ResolveApiVersion(_httpContextAccessor?.HttpContext, startup?.ApiVersion)); + var discordPayload = NotificationPayloadBuilder.CreateDiscordPayload(trigger, data, baseUrl, ApiVersionUtils.ResolveApiVersion(_requestContextAccessor?.Current?.Path, startup?.ApiVersion)); var title = discordPayload is JsonObject d && d.TryGetPropertyValue("content", out var c) ? (c?.ToString() ?? string.Empty) : string.Empty; var message = title; @@ -400,13 +399,13 @@ async Task HandleFailedResponseAsync(HttpResponseMessage response) string? baseUrl = null; var startup = await _configurationService.GetStartupConfigAsync(); if (startup?.UrlBase != null) baseUrl = startup.UrlBase; - if (string.IsNullOrWhiteSpace(baseUrl) && _httpContextAccessor?.HttpContext != null) + if (string.IsNullOrWhiteSpace(baseUrl) && _requestContextAccessor?.Current != null) { - var derived = NotificationPayloadBuilder.GetBaseUrlFromHttpContext(_httpContextAccessor.HttpContext); + var derived = NotificationPayloadBuilder.GetBaseUrlFromRequestContext(_requestContextAccessor.Current); if (!string.IsNullOrWhiteSpace(derived)) baseUrl = derived; } - var discordPayload = NotificationPayloadBuilder.CreateDiscordPayload(trigger, data, baseUrl, ApiVersionUtils.ResolveApiVersion(_httpContextAccessor?.HttpContext, startup?.ApiVersion)); + var discordPayload = NotificationPayloadBuilder.CreateDiscordPayload(trigger, data, baseUrl, ApiVersionUtils.ResolveApiVersion(_requestContextAccessor?.Current?.Path, startup?.ApiVersion)); var message = discordPayload is JsonObject d && d.TryGetPropertyValue("content", out var c) ? (c?.ToString() ?? string.Empty) : string.Empty; var values = new List> @@ -474,13 +473,13 @@ async Task HandleFailedResponseAsync(HttpResponseMessage response) string? baseUrl = null; var startup = await _configurationService.GetStartupConfigAsync(); if (startup?.UrlBase != null) baseUrl = startup.UrlBase; - if (string.IsNullOrWhiteSpace(baseUrl) && _httpContextAccessor?.HttpContext != null) + if (string.IsNullOrWhiteSpace(baseUrl) && _requestContextAccessor?.Current != null) { - var derived = NotificationPayloadBuilder.GetBaseUrlFromHttpContext(_httpContextAccessor.HttpContext); + var derived = NotificationPayloadBuilder.GetBaseUrlFromRequestContext(_requestContextAccessor.Current); if (!string.IsNullOrWhiteSpace(derived)) baseUrl = derived; } - var discordPayload = NotificationPayloadBuilder.CreateDiscordPayload(trigger, data, baseUrl, ApiVersionUtils.ResolveApiVersion(_httpContextAccessor?.HttpContext, startup?.ApiVersion)); + var discordPayload = NotificationPayloadBuilder.CreateDiscordPayload(trigger, data, baseUrl, ApiVersionUtils.ResolveApiVersion(_requestContextAccessor?.Current?.Path, startup?.ApiVersion)); var text = discordPayload is JsonObject d && d.TryGetPropertyValue("content", out var c) ? (c?.ToString() ?? string.Empty) : string.Empty; var telegramBody = new { chat_id = chatId, text = text ?? string.Empty, disable_notification = true, parse_mode = "Markdown" }; @@ -555,13 +554,13 @@ async Task HandleFailedResponseAsync(HttpResponseMessage response) string? baseUrl = null; var startup = await _configurationService.GetStartupConfigAsync(); if (startup?.UrlBase != null) baseUrl = startup.UrlBase; - if (string.IsNullOrWhiteSpace(baseUrl) && _httpContextAccessor?.HttpContext != null) + if (string.IsNullOrWhiteSpace(baseUrl) && _requestContextAccessor?.Current != null) { - var derived = NotificationPayloadBuilder.GetBaseUrlFromHttpContext(_httpContextAccessor.HttpContext); + var derived = NotificationPayloadBuilder.GetBaseUrlFromRequestContext(_requestContextAccessor.Current); if (!string.IsNullOrWhiteSpace(derived)) baseUrl = derived; } - var discordPayload = NotificationPayloadBuilder.CreateDiscordPayload(trigger, data, baseUrl, ApiVersionUtils.ResolveApiVersion(_httpContextAccessor?.HttpContext, startup?.ApiVersion)); + var discordPayload = NotificationPayloadBuilder.CreateDiscordPayload(trigger, data, baseUrl, ApiVersionUtils.ResolveApiVersion(_requestContextAccessor?.Current?.Path, startup?.ApiVersion)); var message = discordPayload is JsonObject d && d.TryGetPropertyValue("content", out var c) ? (c?.ToString() ?? string.Empty) : string.Empty; var pushObj = new JsonObject @@ -626,13 +625,13 @@ async Task HandleFailedResponseAsync(HttpResponseMessage response) string? baseUrl = null; var startup = await _configurationService.GetStartupConfigAsync(); if (startup?.UrlBase != null) baseUrl = startup.UrlBase; - if (string.IsNullOrWhiteSpace(baseUrl) && _httpContextAccessor?.HttpContext != null) + if (string.IsNullOrWhiteSpace(baseUrl) && _requestContextAccessor?.Current != null) { - var derived = NotificationPayloadBuilder.GetBaseUrlFromHttpContext(_httpContextAccessor.HttpContext); + var derived = NotificationPayloadBuilder.GetBaseUrlFromRequestContext(_requestContextAccessor.Current); if (!string.IsNullOrWhiteSpace(derived)) baseUrl = derived; } - var discordPayload = NotificationPayloadBuilder.CreateDiscordPayload(trigger, data, baseUrl, ApiVersionUtils.ResolveApiVersion(_httpContextAccessor?.HttpContext, startup?.ApiVersion)); + var discordPayload = NotificationPayloadBuilder.CreateDiscordPayload(trigger, data, baseUrl, ApiVersionUtils.ResolveApiVersion(_requestContextAccessor?.Current?.Path, startup?.ApiVersion)); var message = discordPayload is JsonObject d && d.TryGetPropertyValue("content", out var c) ? (c?.ToString() ?? string.Empty) : string.Empty; var slackObj = new JsonObject @@ -683,14 +682,14 @@ async Task HandleFailedResponseAsync(HttpResponseMessage response) string? baseUrl = null; var startup = await _configurationService.GetStartupConfigAsync(); if (startup?.UrlBase != null) baseUrl = startup.UrlBase; - if (string.IsNullOrWhiteSpace(baseUrl) && _httpContextAccessor?.HttpContext != null) + if (string.IsNullOrWhiteSpace(baseUrl) && _requestContextAccessor?.Current != null) { - var derived = NotificationPayloadBuilder.GetBaseUrlFromHttpContext(_httpContextAccessor.HttpContext); + var derived = NotificationPayloadBuilder.GetBaseUrlFromRequestContext(_requestContextAccessor.Current); if (!string.IsNullOrWhiteSpace(derived)) baseUrl = derived; } // Prefer rich payload created by the static helper (includes content, embeds, image links, etc.) - var payloadObj = NotificationPayloadBuilder.CreateDiscordPayload(trigger, data, baseUrl, ApiVersionUtils.ResolveApiVersion(_httpContextAccessor?.HttpContext, startup?.ApiVersion)); + var payloadObj = NotificationPayloadBuilder.CreateDiscordPayload(trigger, data, baseUrl, ApiVersionUtils.ResolveApiVersion(_requestContextAccessor?.Current?.Path, startup?.ApiVersion)); string defaultJson = payloadObj != null ? payloadObj.ToJsonString() : JsonSerializer.Serialize(new { @event = trigger, data = data, timestamp = DateTime.UtcNow }, new JsonSerializerOptions { DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull }); using var defaultContent = new StringContent(defaultJson, Encoding.UTF8, "application/json"); @@ -773,7 +772,3 @@ private static bool TryValidateWebhookTarget(string webhookUrl, out string reaso } } } - - - - diff --git a/listenarr.application/Notification/SearchProgressReporter.cs b/listenarr.application/Notification/SearchProgressReporter.cs index b34953f8a..42bad38e1 100644 --- a/listenarr.application/Notification/SearchProgressReporter.cs +++ b/listenarr.application/Notification/SearchProgressReporter.cs @@ -15,7 +15,7 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -using Microsoft.AspNetCore.SignalR; +using Listenarr.Application.Interfaces; using Microsoft.Extensions.Logging; namespace Listenarr.Application.Notification @@ -25,12 +25,12 @@ namespace Listenarr.Application.Notification /// public class SearchProgressReporter { - private readonly IHubContext? _hubContext; + private readonly IHubBroadcaster? _hubBroadcaster; private readonly ILogger _logger; - public SearchProgressReporter(IHubContext? hubContext, ILogger logger) + public SearchProgressReporter(IHubBroadcaster? hubBroadcaster, ILogger logger) { - _hubContext = hubContext; + _hubBroadcaster = hubBroadcaster; _logger = logger; } @@ -43,10 +43,10 @@ public async Task BroadcastAsync(string message, string? asin = null) { try { - if (_hubContext != null) + if (_hubBroadcaster != null) { // Structured payload: include a type so clients can distinguish interactive vs automatic - await _hubContext.Clients.All.SendAsync("SearchProgress", new { message, asin, type = "interactive" }); + await _hubBroadcaster.BroadcastAsync("SearchProgress", new { message, asin, type = "interactive" }); } } catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) diff --git a/listenarr.application/Search/MetadataConverters.cs b/listenarr.application/Search/MetadataConverters.cs index 91f583683..e43cc18f3 100644 --- a/listenarr.application/Search/MetadataConverters.cs +++ b/listenarr.application/Search/MetadataConverters.cs @@ -4,7 +4,6 @@ using Listenarr.Application.Metadata; using Listenarr.Domain.Common; using Listenarr.Domain.Models; -using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; namespace Listenarr.Application.Search; @@ -16,13 +15,13 @@ public class MetadataConverters { private readonly IImageCacheService? _imageCacheService; private readonly ILogger _logger; - private readonly IHttpContextAccessor? _httpContextAccessor; + private readonly IRequestContextAccessor? _requestContextAccessor; - public MetadataConverters(IImageCacheService? imageCacheService, ILogger logger, IHttpContextAccessor? httpContextAccessor = null) + public MetadataConverters(IImageCacheService? imageCacheService, ILogger logger, IRequestContextAccessor? requestContextAccessor = null) { _imageCacheService = imageCacheService; _logger = logger; - _httpContextAccessor = httpContextAccessor; + _requestContextAccessor = requestContextAccessor; } private static List? BuildSeriesMemberships(IEnumerable? series) @@ -260,14 +259,14 @@ public async Task ConvertMetadataToSearchResultAsync(AudibleBookMe var cachedPath = await _imageCacheService.GetCachedImagePathAsync(asin); if (!string.IsNullOrWhiteSpace(cachedPath)) { - imageUrl = ApiVersionUtils.BuildImagePath(asin, _httpContextAccessor?.HttpContext); + imageUrl = ApiVersionUtils.BuildImagePath(asin, _requestContextAccessor?.Current?.Path); _logger.LogInformation("Using cached image for ASIN {Asin}: {ImageUrl}", asin, imageUrl); } else { // Even if not cached, map to API endpoint to ensure consistent serving // and avoid external URL failures. Background download will populate cache. - imageUrl = ApiVersionUtils.BuildImagePath(asin, _httpContextAccessor?.HttpContext); + imageUrl = ApiVersionUtils.BuildImagePath(asin, _requestContextAccessor?.Current?.Path); _logger.LogDebug("Mapping to API endpoint for ASIN {Asin} (not yet cached): {ImageUrl}", asin, imageUrl); _logger.LogDebug("Initiating background image cache for ASIN {Asin} with URL: {OriginalUrl}", asin, metadata.ImageUrl ?? imageUrl); _ = _imageCacheService.DownloadAndCacheImageAsync(metadata.ImageUrl ?? imageUrl, asin); @@ -407,14 +406,14 @@ public async Task ConvertMetadataToMetadataSearchResultAsy var cachedPath = await _imageCacheService.GetCachedImagePathAsync(asin); if (!string.IsNullOrWhiteSpace(cachedPath)) { - imageUrl = ApiVersionUtils.BuildImagePath(asin, _httpContextAccessor?.HttpContext); + imageUrl = ApiVersionUtils.BuildImagePath(asin, _requestContextAccessor?.Current?.Path); _logger.LogInformation("Using cached image for ASIN {Asin}: {ImageUrl}", asin, imageUrl); } else { // Even if not cached, map to API endpoint to ensure consistent serving // and avoid external URL failures. Background download will populate cache. - imageUrl = ApiVersionUtils.BuildImagePath(asin, _httpContextAccessor?.HttpContext); + imageUrl = ApiVersionUtils.BuildImagePath(asin, _requestContextAccessor?.Current?.Path); _logger.LogDebug("Mapping to API endpoint for ASIN {Asin} (not yet cached): {ImageUrl}", asin, imageUrl); _logger.LogDebug("Initiating background image cache for ASIN {Asin} with URL: {OriginalUrl}", asin, metadata.ImageUrl ?? imageUrl); _ = _imageCacheService.DownloadAndCacheImageAsync(metadata.ImageUrl ?? imageUrl, asin); diff --git a/listenarr.application/Search/SearchService.cs b/listenarr.application/Search/SearchService.cs index e4293ca9a..b84969585 100644 --- a/listenarr.application/Search/SearchService.cs +++ b/listenarr.application/Search/SearchService.cs @@ -29,7 +29,6 @@ using Microsoft.Extensions.Logging; using Listenarr.Application.Notification; using Listenarr.Application.Metadata; -using SixLabors.ImageSharp; using Listenarr.Application.Security; namespace Listenarr.Application.Search @@ -50,6 +49,8 @@ public class SearchService : ISearchService private readonly AsinSearchHandler _asinSearchHandler; private readonly IMemoryCache? _cache; private readonly IEnumerable _searchProviders; + private readonly ICoverImageProbe? _coverImageProbe; + private readonly IHtmlTextExtractor? _htmlTextExtractor; public SearchService( HttpClient httpClient, @@ -65,7 +66,9 @@ public SearchService( SearchResultScorerService searchResultScorer, AsinSearchHandler asinSearchHandler, IEnumerable? searchProviders = null, - IMemoryCache? cache = null) + IMemoryCache? cache = null, + ICoverImageProbe? coverImageProbe = null, + IHtmlTextExtractor? htmlTextExtractor = null) { _httpClient = httpClient; _configurationService = configurationService; @@ -81,6 +84,8 @@ public SearchService( _searchResultScorer = searchResultScorer; _asinSearchHandler = asinSearchHandler; _cache = cache; + _coverImageProbe = coverImageProbe; + _htmlTextExtractor = htmlTextExtractor; } public async Task> SearchAsync(string query, string? category = null, List? apiIds = null, SearchSortBy sortBy = SearchSortBy.Seeders, SearchSortDirection sortDirection = SearchSortDirection.Descending, bool isAutomaticSearch = false) @@ -1429,30 +1434,19 @@ private static bool isOpenLibraryResult(SearchResult r) try { var url = $"https://covers.openlibrary.org/b/id/{cid}-L.jpg"; - using var resp = await _httpClient.GetAsync(url); - if (!resp.IsSuccessStatusCode) continue; - using var ms = new System.IO.MemoryStream(await resp.Content.ReadAsByteArrayAsync()); - try + var dimensions = _coverImageProbe == null ? null : await _coverImageProbe.ProbeAsync(url); + if (dimensions == null || dimensions.Value.Height == 0) continue; + + var ratio = (double)dimensions.Value.Width / dimensions.Value.Height; + var delta = Math.Abs(ratio - 1.0); + if (delta < bestDelta) { - // Use ImageSharp to measure image dimensions in a cross-platform way - using var img = Image.Load(ms); - if (img.Height == 0) continue; - var ratio = (double)img.Width / img.Height; - var delta = Math.Abs(ratio - 1.0); - if (delta < bestDelta) - { - bestDelta = delta; - bestUrl = url; - } - // If exactly 1:1, short-circuit - if (Math.Abs(delta) < 0.01) - break; - } - catch (Exception imgEx) when (imgEx is not OperationCanceledException && imgEx is not OutOfMemoryException && imgEx is not StackOverflowException) - { - _logger.LogDebug(imgEx, "Failed to measure image dimensions for cover {Url}", url); - continue; + bestDelta = delta; + bestUrl = url; } + + if (Math.Abs(delta) < 0.01) + break; } catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) { @@ -3621,11 +3615,8 @@ internal async Task> ParseTorznabResponseAsync(string if (resp.IsSuccessStatusCode) { var html = await resp.Content.ReadAsStringAsync(); - var htmlDoc = new HtmlAgilityPack.HtmlDocument(); - htmlDoc.LoadHtml(html); - // Look for common comment count patterns in page text - var text = htmlDoc.DocumentNode.InnerText; + var text = _htmlTextExtractor?.ExtractText(html) ?? html; var m = System.Text.RegularExpressions.Regex.Match(text, "(\\d{1,6})\\s+comments?", System.Text.RegularExpressions.RegexOptions.IgnoreCase); if (!m.Success) { @@ -4199,5 +4190,3 @@ public async Task> GetEnabledMetadataSourcesAsync() } } } - - diff --git a/listenarr.application/Security/SecurityRequestUtils.cs b/listenarr.application/Security/SecurityRequestUtils.cs index 9b68c6b31..935f28e39 100644 --- a/listenarr.application/Security/SecurityRequestUtils.cs +++ b/listenarr.application/Security/SecurityRequestUtils.cs @@ -18,89 +18,11 @@ using System.Net; using System.Security.Cryptography; using System.Text; -using Microsoft.AspNetCore.Http; namespace Listenarr.Application.Security; public static class SecurityRequestUtils { - public static bool IsLoopbackRequest(HttpContext? context) - { - var ip = context?.Connection?.RemoteIpAddress; - if (ip == null) - { - // TestServer and some internal calls may not populate RemoteIpAddress. - return true; - } - - if (ip.IsIPv4MappedToIPv6) - { - ip = ip.MapToIPv4(); - } - - return IPAddress.IsLoopback(ip); - } - - public static bool IsLocalOrPrivateRequest(HttpContext? context) - { - var ip = context?.Connection?.RemoteIpAddress; - if (ip == null) - { - // TestServer and some internal calls may not populate RemoteIpAddress. - return true; - } - - return IsPrivateOrLoopback(ip); - } - - public static bool IsAuthenticatedAdminOrApiKey(HttpContext? context) - { - var user = context?.User; - if (user?.Identity?.IsAuthenticated != true) - { - return false; - } - - if (user.IsInRole("Administrator")) - { - return true; - } - - var authMethod = user.FindFirst("AuthMethod")?.Value; - if (!string.IsNullOrWhiteSpace(authMethod) && - string.Equals(authMethod, "ApiKey", StringComparison.Ordinal)) - { - return true; - } - - return false; - } - - /// - /// Returns if the request is authenticated exclusively via an API key - /// (i.e. the AuthMethod claim equals "ApiKey"). - /// Returns for unauthenticated requests or session-authenticated requests. - /// - /// The current HTTP context, or for non-HTTP callers. - public static bool IsApiKeyAuthenticated(HttpContext? context) - { - var user = context?.User; - if (user?.Identity?.IsAuthenticated != true) - { - return false; - } - - var authMethod = user.FindFirst("AuthMethod")?.Value; - return !string.IsNullOrWhiteSpace(authMethod) && - string.Equals(authMethod, "ApiKey", StringComparison.Ordinal); - } - - public static bool ShouldRedactSecretsForCaller(HttpContext? context) - // *Arr standard trust model: - // - trusted local/private-network callers may receive non-redacted config payloads - // - public-network callers must authenticate as admin/API-key to receive secrets - => !IsLocalOrPrivateRequest(context) && !IsAuthenticatedAdminOrApiKey(context); - public static string HashSecretForLog(string? secret, string prefix = "sha256") { if (string.IsNullOrWhiteSpace(secret)) diff --git a/listenarr.application/Security/SessionService.cs b/listenarr.application/Security/SessionService.cs index 08db03be0..91803b0da 100644 --- a/listenarr.application/Security/SessionService.cs +++ b/listenarr.application/Security/SessionService.cs @@ -16,9 +16,9 @@ * along with this program. If not, see . */ +using Listenarr.Application.Common; using Listenarr.Application.Interfaces.Repositories; using Listenarr.Domain.Models; -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using System.Security.Claims; using System.Security.Cryptography; @@ -79,7 +79,7 @@ public async Task CreateSessionAsync(string username, bool isAdmin, bool _logger.LogInformation("Created session for user {Username} (RememberMe: {RememberMe})", username, rememberMe); return sessionToken; } - catch (DbUpdateException) when (attempt < 2) + catch (UniqueConstraintViolationException) when (attempt < 2) { // Try another token when uniqueness is violated. } diff --git a/listenarr.infrastructure/Extensions/AppServiceRegistrationExtensions.cs b/listenarr.infrastructure/Extensions/AppServiceRegistrationExtensions.cs index 55975459c..20971ba96 100644 --- a/listenarr.infrastructure/Extensions/AppServiceRegistrationExtensions.cs +++ b/listenarr.infrastructure/Extensions/AppServiceRegistrationExtensions.cs @@ -16,13 +16,8 @@ * along with this program. If not, see . */ // csharp -using Listenarr.Application.Audiobooks; -using Listenarr.Application.Common; -using Listenarr.Application.Downloads; using Listenarr.Application.Interfaces; -using Listenarr.Application.Metadata; using Listenarr.Application.Notification; -using Listenarr.Application.Search; using Listenarr.Application.Security; using Listenarr.Domain.Models.Configurations; using Listenarr.Infrastructure.Ffmpeg; @@ -33,7 +28,7 @@ using Listenarr.Infrastructure.Search.Providers; using Listenarr.Infrastructure.Security; using Listenarr.Infrastructure.Services; -using Listenarr.Infrastructure.SignalR; +using Listenarr.Infrastructure.Web; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -49,6 +44,8 @@ public static IServiceCollection AddListenarrAppServices(this IServiceCollection { // Core services and application logic services.AddScoped(); + services.AddSingleton(); + services.AddScoped(); // Startup config: read config.json (optional) and expose via IStartupConfigService services.AddSingleton(); diff --git a/listenarr.infrastructure/Extensions/InfrastructureServiceRegistrationExtensions.cs b/listenarr.infrastructure/Extensions/InfrastructureServiceRegistrationExtensions.cs index bbe72747a..caa2e7185 100644 --- a/listenarr.infrastructure/Extensions/InfrastructureServiceRegistrationExtensions.cs +++ b/listenarr.infrastructure/Extensions/InfrastructureServiceRegistrationExtensions.cs @@ -74,6 +74,10 @@ public static IServiceCollection AddListenarrInfrastructure( services.AddScoped(); services.AddScoped(); services.AddSingleton(_ => new ApplicationPathService(contentRootPath)); + services.AddScoped(); + services.AddSingleton(); + services.AddSingleton(); + services.AddHttpClient(); services.AddHttpClient() .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler { diff --git a/listenarr.infrastructure/GlobalUsings.cs b/listenarr.infrastructure/GlobalUsings.cs new file mode 100644 index 000000000..64e0257ca --- /dev/null +++ b/listenarr.infrastructure/GlobalUsings.cs @@ -0,0 +1,15 @@ +global using Microsoft.AspNetCore.DataProtection; +global using Microsoft.AspNetCore.Http; +global using Microsoft.AspNetCore.SignalR; +global using Microsoft.Extensions.Hosting; +global using Listenarr.Application.Audiobooks; +global using Listenarr.Application.Common; +global using Listenarr.Application.Downloads; +global using Listenarr.Application.Metadata; +global using Listenarr.Application.Search; +global using Listenarr.Infrastructure.SignalR; +global using Listenarr.Infrastructure.HostedServices.Audiobooks; +global using Listenarr.Infrastructure.HostedServices.Common; +global using Listenarr.Infrastructure.HostedServices.Downloads; +global using Listenarr.Infrastructure.HostedServices.Metadata; +global using Listenarr.Infrastructure.HostedServices.Search; diff --git a/listenarr.application/Audiobooks/AuthorMonitoringBackgroundService.cs b/listenarr.infrastructure/HostedServices/Audiobooks/AuthorMonitoringBackgroundService.cs similarity index 97% rename from listenarr.application/Audiobooks/AuthorMonitoringBackgroundService.cs rename to listenarr.infrastructure/HostedServices/Audiobooks/AuthorMonitoringBackgroundService.cs index a5a1490f6..ec12feffe 100644 --- a/listenarr.application/Audiobooks/AuthorMonitoringBackgroundService.cs +++ b/listenarr.infrastructure/HostedServices/Audiobooks/AuthorMonitoringBackgroundService.cs @@ -17,10 +17,9 @@ */ using Listenarr.Application.Interfaces; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -namespace Listenarr.Application.Audiobooks +namespace Listenarr.Infrastructure.HostedServices.Audiobooks { public class AuthorMonitoringBackgroundService : BackgroundService { diff --git a/listenarr.application/Audiobooks/ScanBackgroundService.cs b/listenarr.infrastructure/HostedServices/Audiobooks/ScanBackgroundService.cs similarity index 99% rename from listenarr.application/Audiobooks/ScanBackgroundService.cs rename to listenarr.infrastructure/HostedServices/Audiobooks/ScanBackgroundService.cs index 7ffa3e857..b81119c04 100644 --- a/listenarr.application/Audiobooks/ScanBackgroundService.cs +++ b/listenarr.infrastructure/HostedServices/Audiobooks/ScanBackgroundService.cs @@ -23,12 +23,10 @@ using Listenarr.Application.Security; using Listenarr.Domain.Common; using Listenarr.Domain.Models; -using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -namespace Listenarr.Application.Audiobooks +namespace Listenarr.Infrastructure.HostedServices.Audiobooks { public class ScanBackgroundService : BackgroundService { @@ -684,4 +682,3 @@ private string GetCommonPath(List paths) } - diff --git a/listenarr.application/Audiobooks/SeriesMonitoringBackgroundService.cs b/listenarr.infrastructure/HostedServices/Audiobooks/SeriesMonitoringBackgroundService.cs similarity index 97% rename from listenarr.application/Audiobooks/SeriesMonitoringBackgroundService.cs rename to listenarr.infrastructure/HostedServices/Audiobooks/SeriesMonitoringBackgroundService.cs index 69ca84e3f..5f999f867 100644 --- a/listenarr.application/Audiobooks/SeriesMonitoringBackgroundService.cs +++ b/listenarr.infrastructure/HostedServices/Audiobooks/SeriesMonitoringBackgroundService.cs @@ -17,10 +17,9 @@ */ using Listenarr.Application.Interfaces; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -namespace Listenarr.Application.Audiobooks +namespace Listenarr.Infrastructure.HostedServices.Audiobooks { public class SeriesMonitoringBackgroundService : BackgroundService { diff --git a/listenarr.application/Audiobooks/UnmatchedScanBackgroundService.cs b/listenarr.infrastructure/HostedServices/Audiobooks/UnmatchedScanBackgroundService.cs similarity index 98% rename from listenarr.application/Audiobooks/UnmatchedScanBackgroundService.cs rename to listenarr.infrastructure/HostedServices/Audiobooks/UnmatchedScanBackgroundService.cs index e1885bc82..a2d72b7a0 100644 --- a/listenarr.application/Audiobooks/UnmatchedScanBackgroundService.cs +++ b/listenarr.infrastructure/HostedServices/Audiobooks/UnmatchedScanBackgroundService.cs @@ -15,19 +15,14 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -using Microsoft.EntityFrameworkCore; using System.Text.RegularExpressions; using Listenarr.Domain.Common; using Listenarr.Application.Interfaces; -using Microsoft.Extensions.Hosting; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -using Microsoft.AspNetCore.SignalR; using Listenarr.Application.Interfaces.Repositories; -using Listenarr.Application.Metadata; -using Listenarr.Application.Notification; -namespace Listenarr.Application.Audiobooks +namespace Listenarr.Infrastructure.HostedServices.Audiobooks { public class UnmatchedScanBackgroundService : BackgroundService { @@ -97,7 +92,7 @@ await _hubContext.Clients.All.SendAsync( { await HandleJobFailureAsync(job.Id, ex, stoppingToken); } - catch (DbUpdateException ex) + catch (PersistenceException ex) { await HandleJobFailureAsync(job.Id, ex, stoppingToken); } diff --git a/listenarr.application/Common/ImageCacheCleanupService.cs b/listenarr.infrastructure/HostedServices/Common/ImageCacheCleanupService.cs similarity index 98% rename from listenarr.application/Common/ImageCacheCleanupService.cs rename to listenarr.infrastructure/HostedServices/Common/ImageCacheCleanupService.cs index f211cc2d3..132298568 100644 --- a/listenarr.application/Common/ImageCacheCleanupService.cs +++ b/listenarr.infrastructure/HostedServices/Common/ImageCacheCleanupService.cs @@ -16,11 +16,10 @@ * along with this program. If not, see . */ using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Listenarr.Application.Interfaces; -namespace Listenarr.Application.Common +namespace Listenarr.Infrastructure.HostedServices.Common { /// /// Background service that runs daily to clean up temporary image cache diff --git a/listenarr.application/Downloads/DownloadMonitorService.cs b/listenarr.infrastructure/HostedServices/Downloads/DownloadMonitorService.cs similarity index 99% rename from listenarr.application/Downloads/DownloadMonitorService.cs rename to listenarr.infrastructure/HostedServices/Downloads/DownloadMonitorService.cs index 2bd485210..fdb8d3730 100644 --- a/listenarr.application/Downloads/DownloadMonitorService.cs +++ b/listenarr.infrastructure/HostedServices/Downloads/DownloadMonitorService.cs @@ -22,10 +22,9 @@ using Listenarr.Domain.Models; using Listenarr.Domain.Models.Exceptions; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -namespace Listenarr.Application.Downloads +namespace Listenarr.Infrastructure.HostedServices.Downloads { /// /// Background service that monitors downloads diff --git a/listenarr.application/Downloads/DownloadProcessingJobProcessor.cs b/listenarr.infrastructure/HostedServices/Downloads/DownloadProcessingJobProcessor.cs similarity index 99% rename from listenarr.application/Downloads/DownloadProcessingJobProcessor.cs rename to listenarr.infrastructure/HostedServices/Downloads/DownloadProcessingJobProcessor.cs index 7f7edb800..5e684a604 100644 --- a/listenarr.application/Downloads/DownloadProcessingJobProcessor.cs +++ b/listenarr.infrastructure/HostedServices/Downloads/DownloadProcessingJobProcessor.cs @@ -18,12 +18,11 @@ using Listenarr.Application.Interfaces; using Listenarr.Domain.Models.Exceptions; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Hosting; using Listenarr.Domain.Models; using Microsoft.Extensions.DependencyInjection; using Listenarr.Application.Interfaces.Repositories; -namespace Listenarr.Application.Downloads +namespace Listenarr.Infrastructure.HostedServices.Downloads { /// /// Process the download processing jobs queued diff --git a/listenarr.application/Downloads/MovedDownloadProcessor.cs b/listenarr.infrastructure/HostedServices/Downloads/MovedDownloadProcessor.cs similarity index 99% rename from listenarr.application/Downloads/MovedDownloadProcessor.cs rename to listenarr.infrastructure/HostedServices/Downloads/MovedDownloadProcessor.cs index 783857cb5..f09eaa5e1 100644 --- a/listenarr.application/Downloads/MovedDownloadProcessor.cs +++ b/listenarr.infrastructure/HostedServices/Downloads/MovedDownloadProcessor.cs @@ -20,10 +20,9 @@ using Listenarr.Application.Interfaces.Repositories; using Listenarr.Domain.Models; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -namespace Listenarr.Application.Downloads +namespace Listenarr.Infrastructure.HostedServices.Downloads { /// /// Background service that handles moved downloads to remove them from client @@ -306,4 +305,3 @@ private async Task ProcessDeferredRemovalsAsync( } } } - diff --git a/listenarr.application/Downloads/QueueMonitorService.cs b/listenarr.infrastructure/HostedServices/Downloads/QueueMonitorService.cs similarity index 98% rename from listenarr.application/Downloads/QueueMonitorService.cs rename to listenarr.infrastructure/HostedServices/Downloads/QueueMonitorService.cs index 81d78b8dc..d15bab632 100644 --- a/listenarr.application/Downloads/QueueMonitorService.cs +++ b/listenarr.infrastructure/HostedServices/Downloads/QueueMonitorService.cs @@ -17,14 +17,11 @@ */ using Listenarr.Application.Interfaces; -using Listenarr.Application.Notification; using Listenarr.Domain.Models; -using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -namespace Listenarr.Application.Downloads +namespace Listenarr.Infrastructure.HostedServices.Downloads { /// /// Background service that polls external download client queues and pushes updates via SignalR @@ -229,4 +226,3 @@ private bool HasQueueChanged(QueueSnapshot oldSnapshot, QueueSnapshot newSnapsho } } - diff --git a/listenarr.application/Metadata/MetadataRescanService.cs b/listenarr.infrastructure/HostedServices/Metadata/MetadataRescanService.cs similarity index 99% rename from listenarr.application/Metadata/MetadataRescanService.cs rename to listenarr.infrastructure/HostedServices/Metadata/MetadataRescanService.cs index e4df286a9..3e08efcb3 100644 --- a/listenarr.application/Metadata/MetadataRescanService.cs +++ b/listenarr.infrastructure/HostedServices/Metadata/MetadataRescanService.cs @@ -21,10 +21,9 @@ using Listenarr.Application.Security; using Listenarr.Domain.Common; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -namespace Listenarr.Application.Metadata +namespace Listenarr.Infrastructure.HostedServices.Metadata { // Background hosted service to rescan files missing metadata and populate DB fields public class MetadataRescanService : BackgroundService diff --git a/listenarr.application/Search/AutomaticSearchService.cs b/listenarr.infrastructure/HostedServices/Search/AutomaticSearchService.cs similarity index 99% rename from listenarr.application/Search/AutomaticSearchService.cs rename to listenarr.infrastructure/HostedServices/Search/AutomaticSearchService.cs index 2e48bea4e..2226813c3 100644 --- a/listenarr.application/Search/AutomaticSearchService.cs +++ b/listenarr.infrastructure/HostedServices/Search/AutomaticSearchService.cs @@ -18,14 +18,11 @@ using Listenarr.Application.Interfaces; using Listenarr.Application.Interfaces.Repositories; -using Listenarr.Application.Notification; using Listenarr.Domain.Models; -using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -namespace Listenarr.Application.Search +namespace Listenarr.Infrastructure.HostedServices.Search { public class AutomaticSearchService : BackgroundService { @@ -672,4 +669,3 @@ private async Task GetAppropriateDownloadClientAsync(SearchResult search } } } - diff --git a/listenarr.infrastructure/Listenarr.Infrastructure.csproj b/listenarr.infrastructure/Listenarr.Infrastructure.csproj index 21776a55f..2fab32af2 100644 --- a/listenarr.infrastructure/Listenarr.Infrastructure.csproj +++ b/listenarr.infrastructure/Listenarr.Infrastructure.csproj @@ -11,19 +11,22 @@ + + runtime; build; native; contentfiles; analyzers; buildtransitive all - + + diff --git a/listenarr.infrastructure/Persistence/ListenArrDbContext.cs b/listenarr.infrastructure/Persistence/ListenArrDbContext.cs index b9af5ef24..acfdcb2c8 100644 --- a/listenarr.infrastructure/Persistence/ListenArrDbContext.cs +++ b/listenarr.infrastructure/Persistence/ListenArrDbContext.cs @@ -53,6 +53,41 @@ public ListenArrDbContext(DbContextOptions options) { } + public override int SaveChanges() + { + try + { + return base.SaveChanges(); + } + catch (DbUpdateException ex) + { + throw TranslatePersistenceException(ex); + } + } + + public override async Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + try + { + return await base.SaveChangesAsync(cancellationToken); + } + catch (DbUpdateException ex) + { + throw TranslatePersistenceException(ex); + } + } + + private static PersistenceException TranslatePersistenceException(DbUpdateException ex) + { + var message = ex.InnerException?.Message ?? ex.Message; + if (message.IndexOf("UNIQUE", StringComparison.OrdinalIgnoreCase) >= 0) + { + return new UniqueConstraintViolationException("A unique persistence constraint was violated.", ex); + } + + return new PersistenceException("A persistence operation failed.", ex); + } + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { // Only configure SQLite if no provider was configured externally (e.g. tests using InMemory) diff --git a/listenarr.infrastructure/Security/DataProtectionSecretProtector.cs b/listenarr.infrastructure/Security/DataProtectionSecretProtector.cs new file mode 100644 index 000000000..a49d36414 --- /dev/null +++ b/listenarr.infrastructure/Security/DataProtectionSecretProtector.cs @@ -0,0 +1,27 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +using Listenarr.Application.Interfaces; + +namespace Listenarr.Infrastructure.Security +{ + public sealed class DataProtectionSecretProtector : ISecretProtector + { + private readonly IDataProtector _protector; + + public DataProtectionSecretProtector(IDataProtectionProvider provider) + { + _protector = provider.CreateProtector("Listenarr.ConfigurationService.ProwlarrImport"); + } + + public string Protect(string plaintext) => _protector.Protect(plaintext); + + public string Unprotect(string protectedValue) => _protector.Unprotect(protectedValue); + } +} diff --git a/listenarr.infrastructure/Services/HtmlAgilityPackAudibleAuthorPageParser.cs b/listenarr.infrastructure/Services/HtmlAgilityPackAudibleAuthorPageParser.cs new file mode 100644 index 000000000..c8272e95a --- /dev/null +++ b/listenarr.infrastructure/Services/HtmlAgilityPackAudibleAuthorPageParser.cs @@ -0,0 +1,223 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +using System.Text.Json; +using HtmlAgilityPack; +using Listenarr.Application.Interfaces; + +namespace Listenarr.Infrastructure.Services +{ + public class HtmlAgilityPackAudibleAuthorPageParser : IAudibleAuthorPageParser + { + public List ParseAuthorPage(string html, string author, string authorAsin, string region) + { + if (string.IsNullOrWhiteSpace(html)) + { + return new List(); + } + + var htmlDoc = new HtmlDocument(); + htmlDoc.LoadHtml(html); + + var parsedTiles = new List(); + var seenAsins = new HashSet(StringComparer.OrdinalIgnoreCase); + var tiles = htmlDoc.DocumentNode.SelectNodes("//adbl-full-width-product-tile"); + var legacyProductListItems = htmlDoc.DocumentNode.SelectNodes("//li[contains(@class, 'productListItem')]"); + + if (tiles != null) + { + foreach (var tile in tiles) + { + AddParsedResult(parsedTiles, seenAsins, ParseAudibleAuthorTile(tile, author, authorAsin, region)); + } + } + + if (legacyProductListItems != null) + { + foreach (var item in legacyProductListItems) + { + AddParsedResult(parsedTiles, seenAsins, ParseAudibleAuthorListItem(item, author, authorAsin, region)); + } + } + + return parsedTiles; + } + + private static void AddParsedResult(List results, HashSet seenAsins, AudibleSearchResult? parsed) + { + if (parsed == null) + { + return; + } + + var key = string.IsNullOrWhiteSpace(parsed.Asin) + ? $"{parsed.Title}|{parsed.Link}" + : parsed.Asin; + if (seenAsins.Add(key)) + { + results.Add(parsed); + } + } + + private static AudibleSearchResult? ParseAudibleAuthorTile(HtmlNode tile, string author, string authorAsin, string region) + { + var productImageNode = tile.SelectSingleNode(".//adbl-product-image") + ?? tile.SelectSingleNode(".//adbl-full-bleed-image"); + var asin = productImageNode?.GetAttributeValue("data-asin", string.Empty); + if (string.IsNullOrWhiteSpace(asin)) + { + asin = tile.SelectSingleNode(".//*[@data-asin]")?.GetAttributeValue("data-asin", string.Empty); + } + if (string.IsNullOrWhiteSpace(asin)) return null; + + var title = HtmlEntity.DeEntitize(tile.SelectSingleNode(".//*[@slot='title']")?.InnerText ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(title)) return null; + + var subtitle = HtmlEntity.DeEntitize(tile.SelectSingleNode(".//*[@slot='subtitle']")?.InnerText ?? string.Empty).Trim(); + var imageUrl = productImageNode?.SelectSingleNode(".//img")?.GetAttributeValue("src", string.Empty); + if (string.IsNullOrWhiteSpace(imageUrl)) + { + imageUrl = productImageNode?.GetAttributeValue("portrait-src", string.Empty); + } + if (string.IsNullOrWhiteSpace(imageUrl)) + { + imageUrl = productImageNode?.GetAttributeValue("landscape-src", string.Empty); + } + + var relativeUrl = productImageNode?.GetAttributeValue("data-url", string.Empty); + if (string.IsNullOrWhiteSpace(relativeUrl)) + { + relativeUrl = tile.SelectSingleNode(".//adbl-button[@href]")?.GetAttributeValue("href", string.Empty) + ?? tile.SelectSingleNode(".//a[@href]")?.GetAttributeValue("href", string.Empty); + } + + var authors = ParseAudibleAuthorTileAuthors(tile, author, authorAsin, region); + if (authors.Count == 0 && !string.IsNullOrWhiteSpace(author)) + { + authors.Add(new AudibleAuthor { Asin = authorAsin, Name = author, Region = region }); + } + + return new AudibleSearchResult + { + Asin = asin, + Title = title, + Subtitle = string.IsNullOrWhiteSpace(subtitle) ? null : subtitle, + Authors = authors, + ImageUrl = string.IsNullOrWhiteSpace(imageUrl) ? null : imageUrl, + Link = NormalizeAudibleUrl(relativeUrl, region) + }; + } + + private static AudibleSearchResult? ParseAudibleAuthorListItem(HtmlNode listItem, string author, string authorAsin, string region) + { + var asin = listItem.SelectSingleNode(".//*[@data-asin]")?.GetAttributeValue("data-asin", string.Empty); + if (string.IsNullOrWhiteSpace(asin)) + { + return null; + } + + var title = HtmlEntity.DeEntitize(listItem.GetAttributeValue("aria-label", string.Empty)).Trim(); + if (string.IsNullOrWhiteSpace(title)) + { + title = HtmlEntity.DeEntitize( + listItem.SelectSingleNode(".//h2")?.InnerText ?? string.Empty).Trim(); + } + + if (string.IsNullOrWhiteSpace(title)) + { + return null; + } + + var imageUrl = listItem.SelectSingleNode(".//img[@src]")?.GetAttributeValue("src", string.Empty); + var relativeUrl = listItem.SelectSingleNode(".//a[@href]")?.GetAttributeValue("href", string.Empty); + + return new AudibleSearchResult + { + Asin = asin, + Title = title, + Authors = new List + { + new() + { + Asin = authorAsin, + Name = author, + Region = region + } + }, + ImageUrl = string.IsNullOrWhiteSpace(imageUrl) ? null : imageUrl, + Link = NormalizeAudibleUrl(relativeUrl, region) + }; + } + + private static List ParseAudibleAuthorTileAuthors(HtmlNode tile, string author, string authorAsin, string region) + { + var authors = new List(); + var metadataJson = tile.SelectSingleNode(".//adbl-product-metadata/script[@type='application/json']")?.InnerText; + if (string.IsNullOrWhiteSpace(metadataJson)) return authors; + + try + { + var metadata = JsonSerializer.Deserialize(metadataJson, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + if (metadata?.Authors == null) return authors; + + foreach (var metadataAuthor in metadata.Authors.Where(metadataAuthor => !string.IsNullOrWhiteSpace(metadataAuthor.Name))) + { + authors.Add(new AudibleAuthor + { + Asin = string.Equals(metadataAuthor.Name, author, StringComparison.OrdinalIgnoreCase) ? authorAsin : null, + Name = metadataAuthor.Name, + Region = region + }); + } + } + catch (JsonException) + { + } + + return authors; + } + + private static string? NormalizeAudibleUrl(string? url, string region) + { + if (string.IsNullOrWhiteSpace(url)) return null; + if (Uri.TryCreate(url, UriKind.Absolute, out var absoluteUri) + && !string.Equals(absoluteUri.Scheme, Uri.UriSchemeFile, StringComparison.OrdinalIgnoreCase)) + { + return absoluteUri.ToString(); + } + return $"{GetAudibleBaseUrl(region)}{url}"; + } + + private static string GetAudibleBaseUrl(string region) + { + return region?.Trim().ToLowerInvariant() switch + { + "au" => "https://www.audible.com.au", + "ca" => "https://www.audible.ca", + "de" => "https://www.audible.de", + "es" => "https://www.audible.es", + "fr" => "https://www.audible.fr", + "in" => "https://www.audible.in", + "it" => "https://www.audible.it", + "jp" => "https://www.audible.co.jp", + "uk" => "https://www.audible.co.uk", + _ => "https://www.audible.com" + }; + } + } +} diff --git a/listenarr.infrastructure/Services/HtmlAgilityPackTextExtractor.cs b/listenarr.infrastructure/Services/HtmlAgilityPackTextExtractor.cs new file mode 100644 index 000000000..6933a17d3 --- /dev/null +++ b/listenarr.infrastructure/Services/HtmlAgilityPackTextExtractor.cs @@ -0,0 +1,38 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +using HtmlAgilityPack; +using Listenarr.Application.Interfaces; + +namespace Listenarr.Infrastructure.Services +{ + public class HtmlAgilityPackTextExtractor : IHtmlTextExtractor + { + public string ExtractText(string html) + { + if (string.IsNullOrWhiteSpace(html)) + { + return string.Empty; + } + + var htmlDoc = new HtmlDocument(); + htmlDoc.LoadHtml(html); + return htmlDoc.DocumentNode.InnerText; + } + } +} diff --git a/listenarr.infrastructure/Services/ImageSharpCoverImageProbe.cs b/listenarr.infrastructure/Services/ImageSharpCoverImageProbe.cs new file mode 100644 index 000000000..9f491aac2 --- /dev/null +++ b/listenarr.infrastructure/Services/ImageSharpCoverImageProbe.cs @@ -0,0 +1,55 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +using Listenarr.Application.Interfaces; +using Microsoft.Extensions.Logging; +using SixLabors.ImageSharp; + +namespace Listenarr.Infrastructure.Services +{ + public class ImageSharpCoverImageProbe : ICoverImageProbe + { + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + + public ImageSharpCoverImageProbe(HttpClient httpClient, ILogger logger) + { + _httpClient = httpClient; + _logger = logger; + } + + public async Task ProbeAsync(string url, CancellationToken cancellationToken = default) + { + try + { + using var resp = await _httpClient.GetAsync(url, cancellationToken); + if (!resp.IsSuccessStatusCode) + return null; + + using var ms = new MemoryStream(await resp.Content.ReadAsByteArrayAsync(cancellationToken)); + using var img = Image.Load(ms); + return img.Height == 0 ? null : new ImageDimensions(img.Width, img.Height); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogDebug(ex, "Failed to measure image dimensions for cover {Url}", url); + return null; + } + } + } +} diff --git a/listenarr.infrastructure/Services/TagLibAudioTagWriter.cs b/listenarr.infrastructure/Services/TagLibAudioTagWriter.cs new file mode 100644 index 000000000..444cb5167 --- /dev/null +++ b/listenarr.infrastructure/Services/TagLibAudioTagWriter.cs @@ -0,0 +1,66 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +using Listenarr.Application.Interfaces; +using Listenarr.Application.Security; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Infrastructure.Services +{ + public class TagLibAudioTagWriter : IAudioTagWriter + { + private readonly ILogger _logger; + + public TagLibAudioTagWriter(ILogger logger) + { + _logger = logger; + } + + public Task WriteAsinTagAsync(string filePath, string asin) + { + if (string.IsNullOrWhiteSpace(filePath) || string.IsNullOrWhiteSpace(asin)) + return Task.CompletedTask; + + try + { + using var file = TagLib.File.Create(filePath); + + if (file.Tag is TagLib.Mpeg4.AppleTag appleTag) + appleTag.SetDashBox("com.apple.iTunes", "ASIN", asin); + else if (file.GetTag(TagLib.TagTypes.Id3v2) is TagLib.Id3v2.Tag id3Tag) + { + var frame = TagLib.Id3v2.UserTextInformationFrame.Get(id3Tag, "ASIN", true); + frame.Text = new[] { asin }; + } + else if (file.GetTag(TagLib.TagTypes.Xiph) is TagLib.Ogg.XiphComment xiph) + xiph.SetField("ASIN", asin); + else + return Task.CompletedTask; + + file.Save(); + _logger.LogDebug("Wrote ASIN tag '{Asin}' to {File}", asin, LogRedaction.SanitizeFilePath(filePath)); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning(ex, "Failed to write ASIN tag to {File} - import will continue", LogRedaction.SanitizeFilePath(filePath)); + } + + return Task.CompletedTask; + } + } +} diff --git a/listenarr.application/Notification/DownloadHub.cs b/listenarr.infrastructure/SignalR/DownloadHub.cs similarity index 97% rename from listenarr.application/Notification/DownloadHub.cs rename to listenarr.infrastructure/SignalR/DownloadHub.cs index 83e423bc1..d71ad48cf 100644 --- a/listenarr.application/Notification/DownloadHub.cs +++ b/listenarr.infrastructure/SignalR/DownloadHub.cs @@ -18,10 +18,9 @@ using Listenarr.Application.Interfaces; using Listenarr.Domain.Models; -using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Logging; -namespace Listenarr.Application.Notification +namespace Listenarr.Infrastructure.SignalR { /// /// SignalR hub for real-time download progress updates @@ -73,4 +72,3 @@ public async Task PushDownloadUpdate(Download download) } } - diff --git a/listenarr.application/Notification/SettingsHub.cs b/listenarr.infrastructure/SignalR/SettingsHub.cs similarity index 96% rename from listenarr.application/Notification/SettingsHub.cs rename to listenarr.infrastructure/SignalR/SettingsHub.cs index 33c703aee..530f80c86 100644 --- a/listenarr.application/Notification/SettingsHub.cs +++ b/listenarr.infrastructure/SignalR/SettingsHub.cs @@ -16,10 +16,9 @@ * along with this program. If not, see . */ -using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Logging; -namespace Listenarr.Application.Notification +namespace Listenarr.Infrastructure.SignalR { /// /// SignalR hub for real-time settings updates diff --git a/listenarr.infrastructure/SignalR/SignalRHubBroadcaster.cs b/listenarr.infrastructure/SignalR/SignalRHubBroadcaster.cs index eb23d226b..c8ce61242 100644 --- a/listenarr.infrastructure/SignalR/SignalRHubBroadcaster.cs +++ b/listenarr.infrastructure/SignalR/SignalRHubBroadcaster.cs @@ -16,8 +16,6 @@ * along with this program. If not, see . */ using Listenarr.Application.Interfaces; -using Listenarr.Application.Notification; -using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Logging; namespace Listenarr.Infrastructure.SignalR @@ -56,6 +54,17 @@ public async Task BroadcastQueueUpdateAsync(Domain.Models.QueueSnapshot queueSna _logger.LogWarning(ex, "Failed to broadcast QueueUpdate"); } } + + public async Task BroadcastAsync(string eventName, object payload, CancellationToken cancellationToken = default) + { + try + { + await _hubContext.Clients.All.SendAsync(eventName, payload, cancellationToken); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning(ex, "Failed to broadcast {EventName}", eventName); + } + } } } - diff --git a/listenarr.infrastructure/Web/AspNetRequestContextAccessor.cs b/listenarr.infrastructure/Web/AspNetRequestContextAccessor.cs new file mode 100644 index 000000000..493dc9de0 --- /dev/null +++ b/listenarr.infrastructure/Web/AspNetRequestContextAccessor.cs @@ -0,0 +1,59 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +using Listenarr.Application.Interfaces; + +namespace Listenarr.Infrastructure.Web +{ + public sealed class AspNetRequestContextAccessor : IRequestContextAccessor + { + private readonly IHttpContextAccessor _httpContextAccessor; + + public AspNetRequestContextAccessor(IHttpContextAccessor httpContextAccessor) + { + _httpContextAccessor = httpContextAccessor; + } + + public RequestContextSnapshot? Current + { + get + { + var context = _httpContextAccessor.HttpContext; + if (context == null) + { + return null; + } + + var user = context.User; + var isAuthenticatedAdminOrApiKey = user?.Identity?.IsAuthenticated == true + && (user.IsInRole("Administrator") + || string.Equals(user.FindFirst("AuthMethod")?.Value, "ApiKey", StringComparison.Ordinal)); + + var scheme = context.Request.Scheme; + var host = context.Request.Host.Value; + if (context.Request.Headers.TryGetValue("X-Forwarded-Proto", out var forwardedProto)) + { + scheme = forwardedProto.ToString(); + } + + if (context.Request.Headers.TryGetValue("X-Forwarded-Host", out var forwardedHost)) + { + host = forwardedHost.ToString(); + } + + return new RequestContextSnapshot( + context.Request.Path.Value, + scheme, + host, + context.Connection.RemoteIpAddress, + isAuthenticatedAdminOrApiKey); + } + } + } +} diff --git a/tests/Features/Api/Services/DiscordBotServiceTests.cs b/tests/Features/Api/Services/DiscordBotServiceTests.cs index 6878ab314..23f8a0349 100644 --- a/tests/Features/Api/Services/DiscordBotServiceTests.cs +++ b/tests/Features/Api/Services/DiscordBotServiceTests.cs @@ -16,15 +16,7 @@ * along with this program. If not, see . */ using System.Diagnostics; -using Microsoft.Extensions.Logging; -using Microsoft.AspNetCore.Http; -using Xunit; using System.Runtime.InteropServices; -using Listenarr.Domain.Common; -using Listenarr.Domain.Models; -using Moq; -using Listenarr.Application.Interfaces; -using Listenarr.Application.Notification; namespace Listenarr.Tests.Features.Api.Services { @@ -107,11 +99,11 @@ public async Task StartAndStopBot_WithFakeRunner_StartsAndStopsProcess() pathService.SetupGet(service => service.DiscordBotRootPath).Returns(botDir); var cfg = new StartupConfig { ApiKey = "test-api-key", EnableSsl = false, Port = 5000 }; var startupService = new FakeStartupConfigService(cfg); - var httpAccessor = new HttpContextAccessor(); + var requestContextAccessor = Mock.Of(); var logger = new Mock>().Object; var fakeRunner = new FakeProcessRunner(); - var svc = new DiscordBotService(logger, startupService, pathService.Object, httpAccessor, fakeRunner); + var svc = new DiscordBotService(logger, startupService, pathService.Object, requestContextAccessor, fakeRunner); try { diff --git a/tests/Features/Api/Services/NotificationPayloadBuilderAdapterTests.cs b/tests/Features/Api/Services/NotificationPayloadBuilderAdapterTests.cs index 39ea69d8c..014662c50 100644 --- a/tests/Features/Api/Services/NotificationPayloadBuilderAdapterTests.cs +++ b/tests/Features/Api/Services/NotificationPayloadBuilderAdapterTests.cs @@ -16,12 +16,6 @@ * along with this program. If not, see . */ using System.Net; -using Microsoft.AspNetCore.Http; -using Moq; -using Moq.Protected; -using Xunit; -using Microsoft.Extensions.DependencyInjection; -using Listenarr.Application.Notification; namespace Listenarr.Tests.Features.Api.Services { @@ -89,7 +83,7 @@ public async Task CreateDiscordPayloadWithAttachmentAsync_DownloadsImageAndRetur }; // Act - var (payload, attachment) = await adapter.CreateDiscordPayloadWithAttachmentAsync("book-added", data, "https://listenarr.example.com", httpClient, Mock.Of()); + var (payload, attachment) = await adapter.CreateDiscordPayloadWithAttachmentAsync("book-added", data, "https://listenarr.example.com", httpClient, Mock.Of()); // Assert Assert.NotNull(payload); diff --git a/tests/Features/Api/Services/NotificationServiceTests.cs b/tests/Features/Api/Services/NotificationServiceTests.cs index ea115387c..e56e2605d 100644 --- a/tests/Features/Api/Services/NotificationServiceTests.cs +++ b/tests/Features/Api/Services/NotificationServiceTests.cs @@ -16,16 +16,7 @@ * along with this program. If not, see . */ using System.Text.Json.Nodes; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.DependencyInjection; -using Xunit; using System.Net; -using Moq; -using Moq.Protected; -using Microsoft.Extensions.Logging; -using Listenarr.Domain.Models; -using Listenarr.Application.Interfaces; -using Listenarr.Application.Notification; namespace Listenarr.Api.Tests { @@ -191,13 +182,11 @@ public void CreateDiscordPayload_ConvertsRelativeImageUrlToAbsolute_WhenBaseUrlP public partial class NotificationServiceTests { [Fact] - public void GetBaseUrlFromHttpContext_ReturnsExpectedBase() + public void GetBaseUrlFromRequestContext_ReturnsExpectedBase() { - var ctx = new DefaultHttpContext(); - ctx.Request.Scheme = "https"; - ctx.Request.Host = new HostString("listenarr.example.com"); + var ctx = new RequestContextSnapshot(null, "https", "listenarr.example.com", null, false); - var baseUrl = NotificationPayloadBuilder.GetBaseUrlFromHttpContext(ctx); + var baseUrl = NotificationPayloadBuilder.GetBaseUrlFromRequestContext(ctx); Assert.Equal("https://listenarr.example.com", baseUrl); } @@ -212,11 +201,9 @@ public void CreateDiscordPayload_UsesDerivedBaseForThumbnail_WhenProvided() asin = "B123DERIVE" }; - var ctx = new DefaultHttpContext(); - ctx.Request.Scheme = "https"; - ctx.Request.Host = new HostString("listenarr.example.com"); + var ctx = new RequestContextSnapshot(null, "https", "listenarr.example.com", null, false); - var derived = NotificationPayloadBuilder.GetBaseUrlFromHttpContext(ctx); + var derived = NotificationPayloadBuilder.GetBaseUrlFromRequestContext(ctx); Assert.NotNull(derived); var node = NotificationPayloadBuilder.CreateDiscordPayload(trigger, data, derived); @@ -315,7 +302,7 @@ public async Task SendNotificationAsync_PostsCorrectJsonToDiscordWebhook() .ReturnsAsync(startupConfig); // Mock HttpContextAccessor (optional for this test) - var mockHttpContextAccessor = new Mock(); + var mockHttpContextAccessor = new Mock(); // Create service var services = new ServiceCollection(); @@ -492,7 +479,7 @@ public async Task SendNotificationAsync_AttachesImageAndReferencesAttachmentInPa Mock.Of>(), mockConfigService.Object, payloadBuilder, - Mock.Of() + Mock.Of() ); // Act diff --git a/tests/Features/Api/Services/SecurityRedactionTests.cs b/tests/Features/Api/Services/SecurityRedactionTests.cs index 477faa8fa..3471e030f 100644 --- a/tests/Features/Api/Services/SecurityRedactionTests.cs +++ b/tests/Features/Api/Services/SecurityRedactionTests.cs @@ -16,17 +16,7 @@ * along with this program. If not, see . */ using System.Net; -using Microsoft.Extensions.Logging; -using Moq; -using Moq.Protected; -using Xunit; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.DependencyInjection; -using Listenarr.Domain.Models; using Listenarr.Api.Controllers; -using Listenarr.Application.Interfaces; -using Listenarr.Domain.Models.Configurations; -using Listenarr.Application.Notification; namespace Listenarr.Tests.Features.Api.Services { @@ -93,7 +83,7 @@ public async Task NotificationService_LogsAreRedacted_WhenResponseContainsSensit services.AddSingleton(); var provider = services.BuildServiceProvider(); var payloadBuilder = provider.GetRequiredService(); - var service = new NotificationService(httpClient, mockLogger.Object, mockConfigService.Object, payloadBuilder, Mock.Of()); + var service = new NotificationService(httpClient, mockLogger.Object, mockConfigService.Object, payloadBuilder, Mock.Of()); // Act await service.SendNotificationAsync(trigger, data, webhookUrl, enabledTriggers); diff --git a/tests/GlobalUsings.cs b/tests/GlobalUsings.cs new file mode 100644 index 000000000..36ae811b1 --- /dev/null +++ b/tests/GlobalUsings.cs @@ -0,0 +1,17 @@ +global using Microsoft.Extensions.DependencyInjection; +global using Microsoft.Extensions.Logging; +global using Moq; +global using Xunit; +global using Listenarr.Application.Common; +global using Listenarr.Application.Interfaces; +global using Listenarr.Application.Notification; +global using Listenarr.Application.Security; +global using Listenarr.Domain.Common; +global using Listenarr.Domain.Models; +global using Listenarr.Domain.Models.Configurations; +global using Listenarr.Infrastructure.SignalR; +global using Listenarr.Infrastructure.HostedServices.Audiobooks; +global using Listenarr.Infrastructure.HostedServices.Common; +global using Listenarr.Infrastructure.HostedServices.Downloads; +global using Listenarr.Infrastructure.HostedServices.Metadata; +global using Listenarr.Infrastructure.HostedServices.Search; From fa347d8a99c22753880abe0fa04065875cd48fc8 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Thu, 11 Jun 2026 13:35:57 -0400 Subject: [PATCH 03/84] Fix architecture boundary build --- listenarr.application/Common/StartupConfigService.cs | 4 ++-- .../Notification/NotificationService.cs | 1 + .../Extensions/AppServiceRegistrationExtensions.cs | 1 + .../Api/Services/StartupConfigServiceTests.cs | 11 +++-------- tests/GlobalUsings.cs | 1 + tests/Mocks/NoopHubBroadcaster.cs | 9 ++++++--- 6 files changed, 14 insertions(+), 13 deletions(-) diff --git a/listenarr.application/Common/StartupConfigService.cs b/listenarr.application/Common/StartupConfigService.cs index 98ebd8388..d8e1407cc 100644 --- a/listenarr.application/Common/StartupConfigService.cs +++ b/listenarr.application/Common/StartupConfigService.cs @@ -30,7 +30,7 @@ public class StartupConfigService : IStartupConfigService private readonly string _configPath; private StartupConfig? _config; - public StartupConfigService(ILogger logger, Microsoft.Extensions.Hosting.IHostEnvironment env) + public StartupConfigService(ILogger logger, IApplicationPathService applicationPathService) { _logger = logger; @@ -38,7 +38,7 @@ public StartupConfigService(ILogger logger, Microsoft.Exte // /listenarr.api/config/config.json for local development // so `npm run dev` uses the repo config file. Otherwise fall back to // content-root-based config (e.g., published/bin layouts). - var contentRoot = env.ContentRootPath ?? AppContext.BaseDirectory; + var contentRoot = applicationPathService.ContentRootPath ?? AppContext.BaseDirectory; // In Development, try to resolve the repository root from the current // working directory (most reliable when running via `npm run dev`). diff --git a/listenarr.application/Notification/NotificationService.cs b/listenarr.application/Notification/NotificationService.cs index c0aa26b3d..f103adf1c 100644 --- a/listenarr.application/Notification/NotificationService.cs +++ b/listenarr.application/Notification/NotificationService.cs @@ -21,6 +21,7 @@ using System.Text.Json.Nodes; using Listenarr.Application.Common; using Listenarr.Application.Interfaces; +using Listenarr.Application.Security; using Listenarr.Domain.Models; using Microsoft.Extensions.Logging; diff --git a/listenarr.infrastructure/Extensions/AppServiceRegistrationExtensions.cs b/listenarr.infrastructure/Extensions/AppServiceRegistrationExtensions.cs index 20971ba96..37a54951c 100644 --- a/listenarr.infrastructure/Extensions/AppServiceRegistrationExtensions.cs +++ b/listenarr.infrastructure/Extensions/AppServiceRegistrationExtensions.cs @@ -44,6 +44,7 @@ public static IServiceCollection AddListenarrAppServices(this IServiceCollection { // Core services and application logic services.AddScoped(); + services.AddDataProtection(); services.AddSingleton(); services.AddScoped(); // Startup config: read config.json (optional) and expose via IStartupConfigService diff --git a/tests/Features/Api/Services/StartupConfigServiceTests.cs b/tests/Features/Api/Services/StartupConfigServiceTests.cs index 16e22219b..034c4d8ff 100644 --- a/tests/Features/Api/Services/StartupConfigServiceTests.cs +++ b/tests/Features/Api/Services/StartupConfigServiceTests.cs @@ -15,11 +15,6 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -using Microsoft.Extensions.Logging; -using Xunit; -using Listenarr.Domain.Models; -using Listenarr.Application.Common; - namespace Listenarr.Tests.Features.Api.Services { public class StartupConfigServiceTests @@ -33,8 +28,8 @@ public async Task SaveAsync_PreservesAuthenticationRequired() using var loggerFactory = new LoggerFactory(); var logger = loggerFactory.CreateLogger(); - var envMock = new Moq.Mock(); - envMock.Setup(e => e.ContentRootPath).Returns(AppContext.BaseDirectory); + var pathServiceMock = new Moq.Mock(); + pathServiceMock.Setup(e => e.ContentRootPath).Returns(AppContext.BaseDirectory); try { @@ -43,7 +38,7 @@ public async Task SaveAsync_PreservesAuthenticationRequired() Directory.Delete(cfgDir, recursive: true); } - var svc = new StartupConfigService(logger, envMock.Object); + var svc = new StartupConfigService(logger, pathServiceMock.Object); // default config should exist and have false auth var original = svc.GetConfig(); diff --git a/tests/GlobalUsings.cs b/tests/GlobalUsings.cs index 36ae811b1..9338de628 100644 --- a/tests/GlobalUsings.cs +++ b/tests/GlobalUsings.cs @@ -1,6 +1,7 @@ global using Microsoft.Extensions.DependencyInjection; global using Microsoft.Extensions.Logging; global using Moq; +global using Moq.Protected; global using Xunit; global using Listenarr.Application.Common; global using Listenarr.Application.Interfaces; diff --git a/tests/Mocks/NoopHubBroadcaster.cs b/tests/Mocks/NoopHubBroadcaster.cs index f9ea1b170..b0c02ab0c 100644 --- a/tests/Mocks/NoopHubBroadcaster.cs +++ b/tests/Mocks/NoopHubBroadcaster.cs @@ -15,9 +15,6 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -using Listenarr.Application.Interfaces; -using Listenarr.Domain.Models; - namespace Listenarr.Tests.Mocks { // Minimal no-op broadcaster used as a safe fallback when the real @@ -29,5 +26,11 @@ public Task BroadcastQueueUpdateAsync(QueueSnapshot queueSnapshot) // Intentionally do nothing in tests or lightweight hosts return Task.CompletedTask; } + + public Task BroadcastAsync(string eventName, object payload, CancellationToken cancellationToken = default) + { + // Intentionally do nothing in tests or lightweight hosts + return Task.CompletedTask; + } } } From d746e7b58bd28d0fd7f72f27375092c54aafc372 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Thu, 11 Jun 2026 13:36:01 -0400 Subject: [PATCH 04/84] Remove unnecessary usings and fix startup lifetime --- listenarr.api/Controllers/LibraryController.cs | 1 - listenarr.infrastructure/Adapters/NzbgetAdapter.cs | 1 - listenarr.infrastructure/Adapters/QbittorrentAdapter.cs | 2 -- listenarr.infrastructure/Adapters/SabnzbdAdapter.cs | 1 - listenarr.infrastructure/Adapters/TransmissionAdapter.cs | 1 - .../Extensions/AppServiceRegistrationExtensions.cs | 2 +- .../Extensions/HostedServiceRegistrationExtensions.cs | 5 ----- .../Extensions/ServiceRegistrationExtensions.cs | 3 --- .../Ffmpeg/FfmpegInstallBackgroundService.cs | 3 --- listenarr.infrastructure/FileSystem/ArchiveExtractor.cs | 1 - .../FileSystem/MoveBackgroundService.cs | 3 --- .../OpenLibrary/OpenLibraryService.cs | 1 - .../Repositories/EfAudiobookFileRepository.cs | 1 - .../Persistence/StartupDbNormalizer.cs | 1 - .../Platform/ApplicationVersionService.cs | 1 - .../Search/Providers/InternetArchiveSearchProvider.cs | 1 - .../Search/Providers/MyAnonamouseSearchProvider.cs | 2 -- .../Search/Providers/TorznabNewznabSearchProvider.cs | 1 - listenarr.infrastructure/SignalR/DownloadPushService.cs | 2 -- listenarr.infrastructure/SignalR/LogHub.cs | 1 - listenarr.infrastructure/SignalR/SignalRLogSink.cs | 1 - listenarr.infrastructure/SignalR/ToastService.cs | 2 -- tests/Builders/ApplicationSettingsBuilder.cs | 1 - tests/Builders/AudioMetadataBuilder.cs | 2 -- tests/Builders/AudiobookBuilder.cs | 2 -- tests/Builders/AudiobookFileBuilder.cs | 2 -- tests/Builders/DownloadBuilder.cs | 2 -- tests/Builders/DownloadClientConfigurationBuilder.cs | 2 -- tests/Builders/DownloadProcessingJobBuilder.cs | 2 -- tests/Builders/IndexerBuilder.cs | 1 - tests/Builders/QualityProfileBuilder.cs | 2 -- tests/Builders/QueueItemBuilder.cs | 2 -- tests/Builders/RemotePathMappingBuilder.cs | 2 -- tests/Builders/RootFolderBuilder.cs | 2 -- tests/Builders/SearchResultBuilder.cs | 2 -- tests/Builders/SeriesCatalogFetchResultBuilder.cs | 1 - tests/Builders/ServiceCollectionBuilder.cs | 6 ------ tests/Common/BaseTests.cs | 5 ----- tests/Common/MockUtils.cs | 8 -------- tests/Common/TempFileService.cs | 2 -- tests/Common/TestUtils.cs | 2 -- .../ConfigurationControllerDownloadClientTests.cs | 6 ------ .../Controllers/ConfigurationControllerSettingsTests.cs | 5 ----- .../Features/Api/Controllers/DownloadsControllerTests.cs | 2 -- ...magesController_AlternateAsinCachedImageAliasTests.cs | 5 ----- .../ImagesController_AudnexusAuthorByAsinTests.cs | 4 ---- .../Controllers/ImagesController_AuthorFallbackTests.cs | 4 ---- .../ImagesController_AuthorStoredAsinTests.cs | 5 ----- .../ImagesController_ContentRootResolutionTests.cs | 4 ---- ...ImagesController_LocalIsbnOpenLibraryFallbackTests.cs | 5 ----- ...ontroller_LocalTitleAuthorOpenLibraryFallbackTests.cs | 5 ----- ...oller_MetadataDescriptionDoesNotBlockFallbackTests.cs | 4 ---- .../ImagesController_MetadataDownloadFallbackTests.cs | 4 ---- .../ImagesController_MetadataDownloadTests.cs | 4 ---- .../ImagesController_PlaceholderFallbackTests.cs | 4 ---- .../ImagesController_TempToLibraryForAudiobookTests.cs | 5 ----- .../Api/Controllers/IntelligentSearchIntegrationTests.cs | 4 ---- .../Controllers/LibraryController_AddToLibraryTests.cs | 5 ----- .../Api/Controllers/LibraryController_BasePathTests.cs | 2 -- .../Api/Controllers/LibraryController_BulkUpdateTests.cs | 4 ---- .../LibraryController_DeleteFilesystemTests.cs | 3 --- .../LibraryController_DeleteImageSafetyTests.cs | 4 ---- .../LibraryController_LibraryListSlimPayloadTests.cs | 4 ---- .../Api/Controllers/LibraryController_MoveTests.cs | 5 ----- .../Controllers/LibraryController_QualityCutoffTests.cs | 4 ---- .../LibraryController_ScanPathConfigFailureTests.cs | 4 ---- .../LibraryController_ScanPathValidationTests.cs | 4 ---- .../LibraryController_UpdateAudiobookTests.cs | 3 --- .../LibraryController_WantedFlagRegressionTests.cs | 2 -- .../Api/Controllers/ManualImportControllerTests.cs | 6 ------ .../Controllers/MetadataController_AuthorCatalogTests.cs | 4 ---- .../Controllers/MetadataController_AuthorLookupTests.cs | 5 ----- .../Api/Controllers/MetadataController_SeriesTests.cs | 5 ----- .../Api/Controllers/ProwlarrCompatControllerTests.cs | 6 ------ .../Api/Controllers/RootFoldersControllerTests.cs | 4 ---- .../SearchControllerAdvancedNormalizationTests.cs | 5 ----- tests/Features/Api/Controllers/SearchControllerTests.cs | 6 ------ .../Api/Controllers/SearchControllerUnifiedTests.cs | 4 ---- .../Api/Extensions/HostedServicesRegistrationTests.cs | 8 -------- .../SwaggerSecurityRequirementDocumentFilterTests.cs | 1 - tests/Features/Api/ForwardedHeadersTrustModelTests.cs | 2 -- .../Api/LibraryController_GetAllResilienceTests.cs | 3 --- .../LibraryController_IdentifierDeduplicationTests.cs | 3 --- .../Api/LibraryController_MetadataRescanTests.cs | 5 ----- .../Api/Middleware/AuthenticationMiddlewareTests.cs | 4 ---- tests/Features/Api/Models/AudiobookDtoFactoryTests.cs | 2 -- tests/Features/Api/ProwlarrEndpointsTests.cs | 1 - .../Api/Services/AudibleServiceAuthorFallbackTests.cs | 2 -- tests/Features/Api/Services/AudibleServiceTests.cs | 1 - .../Api/Services/AudibleServiceTitleSearchTests.cs | 2 -- tests/Features/Api/Services/AudioFileServiceTests.cs | 5 ----- .../AudioFileService_UpdateAudiobookFieldsTests.cs | 5 ----- .../Api/Services/AudiobookMetadataServiceTests.cs | 5 ----- .../Api/Services/AudiobookStatusEvaluatorTests.cs | 2 -- tests/Features/Api/Services/AuthorCatalogServiceTests.cs | 5 ----- .../Api/Services/AuthorMonitoringServiceTests.cs | 5 ----- tests/Features/Api/Services/ConfigurationServiceTests.cs | 9 --------- .../Api/Services/DownloadClientCategoryFilterTests.cs | 3 --- .../Api/Services/DownloadHashRetrievalServiceTests.cs | 5 ----- .../Features/Api/Services/DownloadMonitorServiceTests.cs | 7 ------- .../Services/DownloadNaming_AudiobookMetadataTests.cs | 4 ---- .../Api/Services/DownloadNaming_PatternCollapseTests.cs | 6 ------ .../Services/DownloadQueueServiceReconciliationTests.cs | 6 ------ tests/Features/Api/Services/DownloadStateMachineTests.cs | 4 ---- .../Api/Services/DownloadValidationPipelineTests.cs | 4 ---- tests/Features/Api/Services/FfmpegServiceTests.cs | 5 ----- tests/Features/Api/Services/FileMoverFallbackTests.cs | 3 --- tests/Features/Api/Services/FileMoverHardlinkTests.cs | 1 - .../Api/Services/FileNamingService_PathLengthTests.cs | 5 ----- .../Services/FileNamingService_PatternSelectionTests.cs | 9 --------- .../Features/Api/Services/ImportServiceHardlinkTests.cs | 5 ----- tests/Features/Api/Services/ImportServiceTests.cs | 6 ------ .../Api/Services/Import_PatternIntegrationTests.cs | 8 -------- .../Api/Services/LegacyOutputPathMigratorTests.cs | 7 ------- tests/Features/Api/Services/LogRedactionTests.cs | 3 --- tests/Features/Api/Services/LoginRateLimiterTests.cs | 1 - tests/Features/Api/Services/MetadataServiceTests.cs | 4 ---- .../Features/Api/Services/MoveBackgroundServiceTests.cs | 4 ---- .../Api/Services/MoveBackgroundService_BroadcastTests.cs | 5 ----- .../Api/Services/MoveBackgroundService_FailureTests.cs | 4 ---- .../MoveBackgroundService_FilePathPreservationTests.cs | 5 ----- tests/Features/Api/Services/MoveQueueServiceTests.cs | 2 -- .../MyAnonamouseTorrentAnnounceExtractionTests.cs | 2 -- tests/Features/Api/Services/ParseLanguageTests.cs | 1 - tests/Features/Api/Services/PathMetadataParserTests.cs | 1 - .../Features/Api/Services/QualityProfileScoringTests.cs | 3 --- tests/Features/Api/Services/QualityScoringTests.cs | 2 -- tests/Features/Api/Services/RenameServiceTests.cs | 7 ------- tests/Features/Api/Services/RootFolderServiceTests.cs | 6 ------ .../Api/Services/Search/Providers/IndexersAuthTests.cs | 2 -- .../Providers/IndexersControllerProwlarrImportTests.cs | 4 ---- .../Services/Search/Providers/IndexersControllerTests.cs | 3 --- .../Search/Providers/IndexersNewznabAuthTests.cs | 2 -- .../Search/Providers/IndexersNewznabParsingTests.cs | 5 ----- .../Search/Providers/IndexersPersistedAuthTests.cs | 2 -- .../Services/Search/Providers/MyAnonamouseCookieTests.cs | 4 ---- .../Providers/MyAnonamouseTorrentAnnounceRewriteTests.cs | 2 -- .../Search/Providers/MyAnonamouseTorrentRewriteTests.cs | 2 -- tests/Features/Api/Services/SearchServiceFixesTests.cs | 5 ----- tests/Features/Api/Services/SearchServiceScoringTests.cs | 5 ----- tests/Features/Api/Services/SearchServiceSortingTests.cs | 2 -- tests/Features/Api/Services/SeriesCatalogServiceTests.cs | 4 ---- tests/Features/Api/Services/SystemProcessRunnerTests.cs | 1 - .../Api/Services/UnmatchedScanBackgroundServiceTests.cs | 2 -- tests/Features/Api/SessionCookieAuthTests.cs | 6 ------ tests/Features/Api/Utils/FinalizePathHelperTests.cs | 4 ---- .../Audiobooks/SeriesMonitoringServiceTests.cs | 4 ---- .../Application/Downloads/DownloadClientGatewayTests.cs | 5 ----- .../Downloads/DownloadClientUriBuilderTests.cs | 2 -- .../Application/Downloads/DownloadImportServiceTests.cs | 5 ----- .../Application/Downloads/DownloadIntegrationTests.cs | 6 ------ .../Application/Downloads/DownloadMonitorServiceTests.cs | 5 ----- .../DownloadProcessingJobProcessorIntegrationTests.cs | 3 --- .../Downloads/DownloadProcessingJobProcessorTests.cs | 6 ------ .../Downloads/DownloadProcessingJobServiceTests.cs | 5 ----- .../Application/Downloads/DownloadServiceTests.cs | 5 ----- .../Application/Notifications/NotificationTests.cs | 6 ------ .../Common/AudiobookSeriesMembershipHelperTests.cs | 4 ---- tests/Features/Domain/Models/DownloadClientItemTests.cs | 3 --- .../Features/Domain/Models/DownloadProcessingJobTests.cs | 2 -- tests/Features/Domain/Utils/FileUtilsTests.cs | 2 -- tests/Features/Domain/Utils/TitleMatchingServiceTests.cs | 3 --- .../Adapters/DownloadClientAdapterTests.cs | 3 --- .../Infrastructure/Adapters/NzbgetAdapterTests.cs | 4 ---- .../Infrastructure/Adapters/QbittorrentAdapterTests.cs | 6 ------ .../Infrastructure/Adapters/QbittorrentHelpersTests.cs | 1 - .../Infrastructure/Adapters/SabnzbdAdapterTests.cs | 5 ----- .../Infrastructure/Adapters/TransmissionAdapterTests.cs | 5 ----- .../Adapters/UsenetAdapterFilteringTests.cs | 4 ---- .../Infrastructure/Cache/ImageCacheServiceTests.cs | 4 ---- .../Converters/JsonValueConvertersTests.cs | 1 - .../Extensions/DependencyInjectionTests.cs | 2 -- .../InfrastructureServiceRegistrationExtensionsTests.cs | 3 --- .../Infrastructure/Migrations/MigrationMetadataTests.cs | 1 - .../Infrastructure/Persistence/DatabaseIsolationTests.cs | 2 -- .../Platform/ApplicationVersionServiceTests.cs | 2 -- .../Infrastructure/Platform/DiskSpaceProbeTests.cs | 1 - .../Infrastructure/Platform/SystemServiceStorageTests.cs | 4 ---- .../Infrastructure/Platform/SystemServiceVersionTests.cs | 5 ----- .../Repositories/AudiobookRepositoryTests.cs | 1 - .../Repositories/DownloadHistoryRepositoryTests.cs | 2 -- .../Repositories/DownloadProcessingJobRepositoryTests.cs | 1 - .../Services/ApplicationPathServiceTests.cs | 1 - .../Services/DownloadHistoryServiceTests.cs | 4 ---- .../Services/RemotePathMappingServiceTests.cs | 5 ----- tests/Mocks/Api/NzbgetApiMock.cs | 1 - tests/Mocks/Api/SabnzbdApiMock.cs | 1 - tests/Mocks/Api/TransmissionApiMock.cs | 1 - tests/Mocks/DownloadClientAdapterMock.cs | 3 --- tests/Mocks/DownloadClientGatewayMock.cs | 3 --- tests/Mocks/FfmpegServiceMock.cs | 2 -- tests/Mocks/ListenarrWebApplicationFactory.cs | 5 ----- tests/Mocks/MetadataServiceMock.cs | 2 -- tests/Mocks/StartupConfigServiceMock.cs | 4 ---- 194 files changed, 1 insertion(+), 661 deletions(-) diff --git a/listenarr.api/Controllers/LibraryController.cs b/listenarr.api/Controllers/LibraryController.cs index ed1d665b1..e7ed8f3ba 100644 --- a/listenarr.api/Controllers/LibraryController.cs +++ b/listenarr.api/Controllers/LibraryController.cs @@ -32,7 +32,6 @@ using Listenarr.Application.Security; using Listenarr.Application.Metadata; using Listenarr.Application.Search; -using Microsoft.AspNetCore.SignalR; using Listenarr.Application.Audiobooks; using Listenarr.Api.Attributes; diff --git a/listenarr.infrastructure/Adapters/NzbgetAdapter.cs b/listenarr.infrastructure/Adapters/NzbgetAdapter.cs index 65c7f253d..b1ddb0090 100644 --- a/listenarr.infrastructure/Adapters/NzbgetAdapter.cs +++ b/listenarr.infrastructure/Adapters/NzbgetAdapter.cs @@ -21,7 +21,6 @@ using System.Text; using System.Text.Json; using System.Xml.Linq; -using Listenarr.Application.Downloads; using Listenarr.Application.Interfaces; using Listenarr.Application.Security; using Listenarr.Domain.Common; diff --git a/listenarr.infrastructure/Adapters/QbittorrentAdapter.cs b/listenarr.infrastructure/Adapters/QbittorrentAdapter.cs index 0fc19052f..022f40de4 100644 --- a/listenarr.infrastructure/Adapters/QbittorrentAdapter.cs +++ b/listenarr.infrastructure/Adapters/QbittorrentAdapter.cs @@ -19,8 +19,6 @@ using System.Text.Json; using BencodeNET.Parsing; using BencodeNET.Torrents; -using Listenarr.Application.Common; -using Listenarr.Application.Downloads; using Listenarr.Application.Interfaces; using Listenarr.Application.Security; using Listenarr.Domain.Common; diff --git a/listenarr.infrastructure/Adapters/SabnzbdAdapter.cs b/listenarr.infrastructure/Adapters/SabnzbdAdapter.cs index a77787c9c..be1eaccb8 100644 --- a/listenarr.infrastructure/Adapters/SabnzbdAdapter.cs +++ b/listenarr.infrastructure/Adapters/SabnzbdAdapter.cs @@ -17,7 +17,6 @@ */ using System.Net; using System.Text.Json; -using Listenarr.Application.Downloads; using Listenarr.Application.Interfaces; using Listenarr.Application.Security; using Listenarr.Domain.Common; diff --git a/listenarr.infrastructure/Adapters/TransmissionAdapter.cs b/listenarr.infrastructure/Adapters/TransmissionAdapter.cs index c1f6a38bb..6d195e3ea 100644 --- a/listenarr.infrastructure/Adapters/TransmissionAdapter.cs +++ b/listenarr.infrastructure/Adapters/TransmissionAdapter.cs @@ -21,7 +21,6 @@ using System.Text; using System.Text.Encodings.Web; using System.Text.Json; -using Listenarr.Application.Downloads; using Listenarr.Application.Interfaces; using Listenarr.Application.Security; using Listenarr.Domain.Common; diff --git a/listenarr.infrastructure/Extensions/AppServiceRegistrationExtensions.cs b/listenarr.infrastructure/Extensions/AppServiceRegistrationExtensions.cs index 37a54951c..404783a32 100644 --- a/listenarr.infrastructure/Extensions/AppServiceRegistrationExtensions.cs +++ b/listenarr.infrastructure/Extensions/AppServiceRegistrationExtensions.cs @@ -46,7 +46,7 @@ public static IServiceCollection AddListenarrAppServices(this IServiceCollection services.AddScoped(); services.AddDataProtection(); services.AddSingleton(); - services.AddScoped(); + services.AddSingleton(); // Startup config: read config.json (optional) and expose via IStartupConfigService services.AddSingleton(); diff --git a/listenarr.infrastructure/Extensions/HostedServiceRegistrationExtensions.cs b/listenarr.infrastructure/Extensions/HostedServiceRegistrationExtensions.cs index 6ddf5c36d..49de51774 100644 --- a/listenarr.infrastructure/Extensions/HostedServiceRegistrationExtensions.cs +++ b/listenarr.infrastructure/Extensions/HostedServiceRegistrationExtensions.cs @@ -16,12 +16,7 @@ * along with this program. If not, see . */ // csharp -using Listenarr.Application.Audiobooks; -using Listenarr.Application.Common; -using Listenarr.Application.Downloads; using Listenarr.Application.Interfaces; -using Listenarr.Application.Metadata; -using Listenarr.Application.Search; using Listenarr.Infrastructure.Ffmpeg; using Listenarr.Infrastructure.FileSystem; using Microsoft.Extensions.Configuration; diff --git a/listenarr.infrastructure/Extensions/ServiceRegistrationExtensions.cs b/listenarr.infrastructure/Extensions/ServiceRegistrationExtensions.cs index 758ebade1..1de5f1ce4 100644 --- a/listenarr.infrastructure/Extensions/ServiceRegistrationExtensions.cs +++ b/listenarr.infrastructure/Extensions/ServiceRegistrationExtensions.cs @@ -19,13 +19,10 @@ using System.Net; using Listenarr.Application.Interfaces; using Listenarr.Infrastructure.Factories; -using Listenarr.Application.Downloads; using Listenarr.Infrastructure.Adapters; using Listenarr.Domain.Models.Configurations; using Listenarr.Application.Notification; using Listenarr.Infrastructure.FileSystem; -using Listenarr.Infrastructure.SignalR; -using Listenarr.Application.Metadata; using Microsoft.Extensions.DependencyInjection; using Polly.Extensions.Http; using Microsoft.Extensions.Configuration; diff --git a/listenarr.infrastructure/Ffmpeg/FfmpegInstallBackgroundService.cs b/listenarr.infrastructure/Ffmpeg/FfmpegInstallBackgroundService.cs index 9d747b664..95b1cef8b 100644 --- a/listenarr.infrastructure/Ffmpeg/FfmpegInstallBackgroundService.cs +++ b/listenarr.infrastructure/Ffmpeg/FfmpegInstallBackgroundService.cs @@ -16,9 +16,6 @@ * along with this program. If not, see . */ using Listenarr.Application.Interfaces; -using Listenarr.Application.Notification; -using Microsoft.AspNetCore.SignalR; -using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; namespace Listenarr.Infrastructure.Ffmpeg diff --git a/listenarr.infrastructure/FileSystem/ArchiveExtractor.cs b/listenarr.infrastructure/FileSystem/ArchiveExtractor.cs index 1335023f0..9f593e840 100644 --- a/listenarr.infrastructure/FileSystem/ArchiveExtractor.cs +++ b/listenarr.infrastructure/FileSystem/ArchiveExtractor.cs @@ -15,7 +15,6 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -using Listenarr.Application.Common; using Listenarr.Application.Interfaces; using Microsoft.Extensions.Logging; using SharpCompress.Archives; diff --git a/listenarr.infrastructure/FileSystem/MoveBackgroundService.cs b/listenarr.infrastructure/FileSystem/MoveBackgroundService.cs index 774428e50..471418a71 100644 --- a/listenarr.infrastructure/FileSystem/MoveBackgroundService.cs +++ b/listenarr.infrastructure/FileSystem/MoveBackgroundService.cs @@ -18,12 +18,9 @@ using Listenarr.Application.Interfaces; using Listenarr.Application.Interfaces.Repositories; using Listenarr.Application.Mapping; -using Listenarr.Application.Notification; using Listenarr.Application.Security; using Listenarr.Domain.Models; -using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; namespace Listenarr.Infrastructure.FileSystem diff --git a/listenarr.infrastructure/OpenLibrary/OpenLibraryService.cs b/listenarr.infrastructure/OpenLibrary/OpenLibraryService.cs index f390e6505..744ecb7cf 100644 --- a/listenarr.infrastructure/OpenLibrary/OpenLibraryService.cs +++ b/listenarr.infrastructure/OpenLibrary/OpenLibraryService.cs @@ -20,7 +20,6 @@ using System.Text.RegularExpressions; using Microsoft.Extensions.Logging; using Listenarr.Application.Security; -using Listenarr.Application.Search; namespace Listenarr.Infrastructure.OpenLibrary { diff --git a/listenarr.infrastructure/Persistence/Repositories/EfAudiobookFileRepository.cs b/listenarr.infrastructure/Persistence/Repositories/EfAudiobookFileRepository.cs index eb018e471..63014f389 100644 --- a/listenarr.infrastructure/Persistence/Repositories/EfAudiobookFileRepository.cs +++ b/listenarr.infrastructure/Persistence/Repositories/EfAudiobookFileRepository.cs @@ -16,7 +16,6 @@ * along with this program. If not, see . */ using Listenarr.Application.Interfaces.Repositories; -using Listenarr.Application.Audiobooks; using Listenarr.Domain.Models; using Microsoft.EntityFrameworkCore; diff --git a/listenarr.infrastructure/Persistence/StartupDbNormalizer.cs b/listenarr.infrastructure/Persistence/StartupDbNormalizer.cs index bb79edfe7..a2cad7d0d 100644 --- a/listenarr.infrastructure/Persistence/StartupDbNormalizer.cs +++ b/listenarr.infrastructure/Persistence/StartupDbNormalizer.cs @@ -18,7 +18,6 @@ using System.Diagnostics; using Listenarr.Application.Interfaces.Repositories; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; namespace Listenarr.Infrastructure.Persistence diff --git a/listenarr.infrastructure/Platform/ApplicationVersionService.cs b/listenarr.infrastructure/Platform/ApplicationVersionService.cs index 45c013007..0029c942f 100644 --- a/listenarr.infrastructure/Platform/ApplicationVersionService.cs +++ b/listenarr.infrastructure/Platform/ApplicationVersionService.cs @@ -19,7 +19,6 @@ using System.Diagnostics; using System.Reflection; using Listenarr.Application.Interfaces; -using Microsoft.Extensions.Hosting; namespace Listenarr.Infrastructure.Platform { diff --git a/listenarr.infrastructure/Search/Providers/InternetArchiveSearchProvider.cs b/listenarr.infrastructure/Search/Providers/InternetArchiveSearchProvider.cs index 544b43111..7adb52ab8 100644 --- a/listenarr.infrastructure/Search/Providers/InternetArchiveSearchProvider.cs +++ b/listenarr.infrastructure/Search/Providers/InternetArchiveSearchProvider.cs @@ -18,7 +18,6 @@ using System.Text.Json; using Listenarr.Application.Interfaces; -using Listenarr.Application.Search; using Listenarr.Domain.Models; using Microsoft.Extensions.Logging; diff --git a/listenarr.infrastructure/Search/Providers/MyAnonamouseSearchProvider.cs b/listenarr.infrastructure/Search/Providers/MyAnonamouseSearchProvider.cs index f485fc357..3a1420849 100644 --- a/listenarr.infrastructure/Search/Providers/MyAnonamouseSearchProvider.cs +++ b/listenarr.infrastructure/Search/Providers/MyAnonamouseSearchProvider.cs @@ -15,7 +15,6 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -using Listenarr.Application.Common; using Listenarr.Application.Interfaces; using Listenarr.Application.Interfaces.Repositories; using Listenarr.Domain.Models; @@ -23,7 +22,6 @@ using System.Text.RegularExpressions; using AsyncKeyedLock; using Microsoft.Extensions.Logging; -using Listenarr.Application.Search; using Listenarr.Application.Security; namespace Listenarr.Infrastructure.Search.Providers diff --git a/listenarr.infrastructure/Search/Providers/TorznabNewznabSearchProvider.cs b/listenarr.infrastructure/Search/Providers/TorznabNewznabSearchProvider.cs index 14bcfd832..e3452b81a 100644 --- a/listenarr.infrastructure/Search/Providers/TorznabNewznabSearchProvider.cs +++ b/listenarr.infrastructure/Search/Providers/TorznabNewznabSearchProvider.cs @@ -19,7 +19,6 @@ using System.Text.RegularExpressions; using HtmlAgilityPack; using Listenarr.Application.Interfaces; -using Listenarr.Application.Search; using Listenarr.Application.Security; using Listenarr.Domain.Models; using Microsoft.Extensions.Logging; diff --git a/listenarr.infrastructure/SignalR/DownloadPushService.cs b/listenarr.infrastructure/SignalR/DownloadPushService.cs index b4ad9bbc0..1580ac76b 100644 --- a/listenarr.infrastructure/SignalR/DownloadPushService.cs +++ b/listenarr.infrastructure/SignalR/DownloadPushService.cs @@ -16,9 +16,7 @@ * along with this program. If not, see . */ using Listenarr.Application.Interfaces; -using Listenarr.Application.Notification; using Listenarr.Domain.Models; -using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; diff --git a/listenarr.infrastructure/SignalR/LogHub.cs b/listenarr.infrastructure/SignalR/LogHub.cs index 386ab6d59..3ff40832c 100644 --- a/listenarr.infrastructure/SignalR/LogHub.cs +++ b/listenarr.infrastructure/SignalR/LogHub.cs @@ -16,7 +16,6 @@ * along with this program. If not, see . */ -using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Logging; namespace Listenarr.Infrastructure.SignalR diff --git a/listenarr.infrastructure/SignalR/SignalRLogSink.cs b/listenarr.infrastructure/SignalR/SignalRLogSink.cs index 36d933499..fec1fd1ea 100644 --- a/listenarr.infrastructure/SignalR/SignalRLogSink.cs +++ b/listenarr.infrastructure/SignalR/SignalRLogSink.cs @@ -18,7 +18,6 @@ using System.Diagnostics; using Listenarr.Domain.Models; -using Microsoft.AspNetCore.SignalR; using Serilog.Core; using Serilog.Events; diff --git a/listenarr.infrastructure/SignalR/ToastService.cs b/listenarr.infrastructure/SignalR/ToastService.cs index 2a051cc4f..94996ef6e 100644 --- a/listenarr.infrastructure/SignalR/ToastService.cs +++ b/listenarr.infrastructure/SignalR/ToastService.cs @@ -16,8 +16,6 @@ * along with this program. If not, see . */ using Listenarr.Application.Interfaces; -using Listenarr.Application.Notification; -using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Logging; namespace Listenarr.Infrastructure.SignalR diff --git a/tests/Builders/ApplicationSettingsBuilder.cs b/tests/Builders/ApplicationSettingsBuilder.cs index fa406ac03..bbbdfab3b 100644 --- a/tests/Builders/ApplicationSettingsBuilder.cs +++ b/tests/Builders/ApplicationSettingsBuilder.cs @@ -1,4 +1,3 @@ -using Listenarr.Domain.Models.Configurations; using Listenarr.Domain.Models.Enumerations; namespace Listenarr.Tests.Builders diff --git a/tests/Builders/AudioMetadataBuilder.cs b/tests/Builders/AudioMetadataBuilder.cs index 5e8fa0405..acbad7820 100644 --- a/tests/Builders/AudioMetadataBuilder.cs +++ b/tests/Builders/AudioMetadataBuilder.cs @@ -1,5 +1,3 @@ -using Listenarr.Domain.Models; - namespace Listenarr.Tests.Builders { public class AudioMetadataBuilder diff --git a/tests/Builders/AudiobookBuilder.cs b/tests/Builders/AudiobookBuilder.cs index 36e3bbe41..366ef0741 100644 --- a/tests/Builders/AudiobookBuilder.cs +++ b/tests/Builders/AudiobookBuilder.cs @@ -1,5 +1,3 @@ -using Listenarr.Domain.Models; - namespace Listenarr.Tests.Builders { public class AudiobookBuilder diff --git a/tests/Builders/AudiobookFileBuilder.cs b/tests/Builders/AudiobookFileBuilder.cs index e677fda3e..74a484d45 100644 --- a/tests/Builders/AudiobookFileBuilder.cs +++ b/tests/Builders/AudiobookFileBuilder.cs @@ -1,5 +1,3 @@ -using Listenarr.Domain.Models; - namespace Listenarr.Tests.Builders { public class AudiobookFileBuilder diff --git a/tests/Builders/DownloadBuilder.cs b/tests/Builders/DownloadBuilder.cs index f2ab4a9d8..75039c44a 100644 --- a/tests/Builders/DownloadBuilder.cs +++ b/tests/Builders/DownloadBuilder.cs @@ -1,5 +1,3 @@ -using Listenarr.Domain.Models; - namespace Listenarr.Tests.Builders { public class DownloadBuilder diff --git a/tests/Builders/DownloadClientConfigurationBuilder.cs b/tests/Builders/DownloadClientConfigurationBuilder.cs index 5e982171d..69e69cddc 100644 --- a/tests/Builders/DownloadClientConfigurationBuilder.cs +++ b/tests/Builders/DownloadClientConfigurationBuilder.cs @@ -1,5 +1,3 @@ -using Listenarr.Domain.Models; - namespace Listenarr.Tests.Builders { public class DownloadClientConfigurationBuilder diff --git a/tests/Builders/DownloadProcessingJobBuilder.cs b/tests/Builders/DownloadProcessingJobBuilder.cs index c32b09c2e..9add73a81 100644 --- a/tests/Builders/DownloadProcessingJobBuilder.cs +++ b/tests/Builders/DownloadProcessingJobBuilder.cs @@ -1,5 +1,3 @@ -using Listenarr.Domain.Models; - namespace Listenarr.Tests.Builders { public class DownloadProcessingJobBuilder diff --git a/tests/Builders/IndexerBuilder.cs b/tests/Builders/IndexerBuilder.cs index 8962ef7d9..f6d9877e5 100644 --- a/tests/Builders/IndexerBuilder.cs +++ b/tests/Builders/IndexerBuilder.cs @@ -1,5 +1,4 @@ using System.Text.Json; -using Listenarr.Domain.Models; namespace Listenarr.Tests.Builders { diff --git a/tests/Builders/QualityProfileBuilder.cs b/tests/Builders/QualityProfileBuilder.cs index 9c2b90ace..77fb6fb0f 100644 --- a/tests/Builders/QualityProfileBuilder.cs +++ b/tests/Builders/QualityProfileBuilder.cs @@ -1,5 +1,3 @@ -using Listenarr.Domain.Models; - namespace Listenarr.Tests.Builders { public class QualityProfileBuilder diff --git a/tests/Builders/QueueItemBuilder.cs b/tests/Builders/QueueItemBuilder.cs index 537c450fb..35b2b110d 100644 --- a/tests/Builders/QueueItemBuilder.cs +++ b/tests/Builders/QueueItemBuilder.cs @@ -1,5 +1,3 @@ -using Listenarr.Domain.Models; - namespace Listenarr.Tests.Builders { public class QueueItemBuilder diff --git a/tests/Builders/RemotePathMappingBuilder.cs b/tests/Builders/RemotePathMappingBuilder.cs index 5349c4300..17faafe34 100644 --- a/tests/Builders/RemotePathMappingBuilder.cs +++ b/tests/Builders/RemotePathMappingBuilder.cs @@ -1,5 +1,3 @@ -using Listenarr.Domain.Models; - namespace Listenarr.Tests.Builders { public class RemotePathMappingBuilder diff --git a/tests/Builders/RootFolderBuilder.cs b/tests/Builders/RootFolderBuilder.cs index 0dfdb1372..38f40b271 100644 --- a/tests/Builders/RootFolderBuilder.cs +++ b/tests/Builders/RootFolderBuilder.cs @@ -1,5 +1,3 @@ -using Listenarr.Domain.Models; - namespace Listenarr.Tests.Builders { public class RootFolderBuilder diff --git a/tests/Builders/SearchResultBuilder.cs b/tests/Builders/SearchResultBuilder.cs index 60a9e2659..67e0dd364 100644 --- a/tests/Builders/SearchResultBuilder.cs +++ b/tests/Builders/SearchResultBuilder.cs @@ -1,5 +1,3 @@ -using Listenarr.Domain.Models; - namespace Listenarr.Tests.Builders { public class SearchResultBuilder diff --git a/tests/Builders/SeriesCatalogFetchResultBuilder.cs b/tests/Builders/SeriesCatalogFetchResultBuilder.cs index 95b6deb18..a9b99e21b 100644 --- a/tests/Builders/SeriesCatalogFetchResultBuilder.cs +++ b/tests/Builders/SeriesCatalogFetchResultBuilder.cs @@ -1,4 +1,3 @@ -using Listenarr.Application.Interfaces; using Listenarr.Application.Metadata; namespace Listenarr.Tests.Builders diff --git a/tests/Builders/ServiceCollectionBuilder.cs b/tests/Builders/ServiceCollectionBuilder.cs index 63bfd501e..0b18f4d13 100644 --- a/tests/Builders/ServiceCollectionBuilder.cs +++ b/tests/Builders/ServiceCollectionBuilder.cs @@ -1,14 +1,10 @@ using Listenarr.Api.Controllers; using Listenarr.Application.Audiobooks; -using Listenarr.Application.Common; using Listenarr.Application.Downloads; -using Listenarr.Application.Interfaces; using Listenarr.Application.Metadata; -using Listenarr.Application.Notification; using Listenarr.Application.Search; using Listenarr.Application.Search.Filters; using Listenarr.Application.Search.Strategies; -using Listenarr.Domain.Models; using Listenarr.Infrastructure.Extensions; using Listenarr.Infrastructure.FileSystem; using Listenarr.Tests.Mocks; @@ -17,9 +13,7 @@ using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; -using Moq; namespace Listenarr.Tests.Builders { diff --git a/tests/Common/BaseTests.cs b/tests/Common/BaseTests.cs index 06dc8fbcf..622958cb7 100644 --- a/tests/Common/BaseTests.cs +++ b/tests/Common/BaseTests.cs @@ -1,11 +1,6 @@ using System.Diagnostics.CodeAnalysis; -using Listenarr.Application.Interfaces; using Listenarr.Application.Interfaces.Repositories; -using Listenarr.Domain.Models; -using Listenarr.Domain.Models.Configurations; using Listenarr.Tests.Builders; -using Microsoft.Extensions.DependencyInjection; -using Xunit; namespace Listenarr.Tests.Common { diff --git a/tests/Common/MockUtils.cs b/tests/Common/MockUtils.cs index 387490806..238f4a285 100644 --- a/tests/Common/MockUtils.cs +++ b/tests/Common/MockUtils.cs @@ -1,12 +1,7 @@ using System.Net; using System.Text; using Listenarr.Api.Controllers; -using Listenarr.Application.Downloads; -using Listenarr.Application.Interfaces; using Listenarr.Application.Interfaces.Repositories; -using Listenarr.Application.Notification; -using Listenarr.Domain.Models; -using Listenarr.Domain.Models.Configurations; using Listenarr.Domain.Models.Enumerations; using Listenarr.Infrastructure.Adapters; using Listenarr.Infrastructure.Search.Providers; @@ -14,9 +9,6 @@ using Listenarr.Tests.Builders; using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Moq; namespace Listenarr.Tests.Common { diff --git a/tests/Common/TempFileService.cs b/tests/Common/TempFileService.cs index b4b07cd77..44646c03c 100644 --- a/tests/Common/TempFileService.cs +++ b/tests/Common/TempFileService.cs @@ -1,5 +1,3 @@ -using Xunit; - namespace Listenarr.Tests.Common { public class TempFileService : IAsyncLifetime diff --git a/tests/Common/TestUtils.cs b/tests/Common/TestUtils.cs index 27b518087..876bb919f 100644 --- a/tests/Common/TestUtils.cs +++ b/tests/Common/TestUtils.cs @@ -1,8 +1,6 @@ using System.Runtime.CompilerServices; using Asp.Versioning.ApiExplorer; using Listenarr.Application.Interfaces.Repositories; -using Listenarr.Domain.Models; -using Microsoft.Extensions.DependencyInjection; namespace Listenarr.Tests.Common { diff --git a/tests/Features/Api/Controllers/ConfigurationControllerDownloadClientTests.cs b/tests/Features/Api/Controllers/ConfigurationControllerDownloadClientTests.cs index 66384cf80..f6440687b 100644 --- a/tests/Features/Api/Controllers/ConfigurationControllerDownloadClientTests.cs +++ b/tests/Features/Api/Controllers/ConfigurationControllerDownloadClientTests.cs @@ -19,19 +19,13 @@ using Listenarr.Api.Attributes; using Listenarr.Api.Controllers; using Listenarr.Api.Controllers.Configurations; -using Listenarr.Application.Interfaces; -using Listenarr.Application.Security; -using Listenarr.Domain.Models; using Listenarr.Tests.Mocks; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; -using Moq; -using Xunit; namespace Listenarr.Tests.Features.Api.Controllers { diff --git a/tests/Features/Api/Controllers/ConfigurationControllerSettingsTests.cs b/tests/Features/Api/Controllers/ConfigurationControllerSettingsTests.cs index 84842d6d6..f30e416ae 100644 --- a/tests/Features/Api/Controllers/ConfigurationControllerSettingsTests.cs +++ b/tests/Features/Api/Controllers/ConfigurationControllerSettingsTests.cs @@ -16,14 +16,9 @@ * along with this program. If not, see . */ using Listenarr.Api.Controllers.Configurations; -using Listenarr.Application.Interfaces; -using Listenarr.Application.Notification; -using Listenarr.Domain.Models.Configurations; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Logging.Abstractions; -using Moq; -using Xunit; namespace Listenarr.Tests.Features.Api.Controllers { diff --git a/tests/Features/Api/Controllers/DownloadsControllerTests.cs b/tests/Features/Api/Controllers/DownloadsControllerTests.cs index fe4f96287..208e7d884 100644 --- a/tests/Features/Api/Controllers/DownloadsControllerTests.cs +++ b/tests/Features/Api/Controllers/DownloadsControllerTests.cs @@ -15,11 +15,9 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -using Listenarr.Domain.Models; using Listenarr.Tests.Builders; using Listenarr.Tests.Common; using Microsoft.AspNetCore.Mvc; -using Xunit; namespace Listenarr.Tests.Features.Api.Controllers { diff --git a/tests/Features/Api/Controllers/ImagesController_AlternateAsinCachedImageAliasTests.cs b/tests/Features/Api/Controllers/ImagesController_AlternateAsinCachedImageAliasTests.cs index da54a6523..83065cfc9 100644 --- a/tests/Features/Api/Controllers/ImagesController_AlternateAsinCachedImageAliasTests.cs +++ b/tests/Features/Api/Controllers/ImagesController_AlternateAsinCachedImageAliasTests.cs @@ -16,14 +16,9 @@ * along with this program. If not, see . */ using Listenarr.Api.Controllers; -using Listenarr.Application.Interfaces; using Listenarr.Application.Interfaces.Repositories; using Listenarr.Application.Metadata; -using Listenarr.Domain.Models; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; -using Moq; -using Xunit; namespace Listenarr.Tests.Features.Api.Controllers { diff --git a/tests/Features/Api/Controllers/ImagesController_AudnexusAuthorByAsinTests.cs b/tests/Features/Api/Controllers/ImagesController_AudnexusAuthorByAsinTests.cs index 23cfa68bc..f6fbe3c2b 100644 --- a/tests/Features/Api/Controllers/ImagesController_AudnexusAuthorByAsinTests.cs +++ b/tests/Features/Api/Controllers/ImagesController_AudnexusAuthorByAsinTests.cs @@ -16,13 +16,9 @@ * along with this program. If not, see . */ using Listenarr.Api.Controllers; -using Listenarr.Application.Interfaces; using Listenarr.Application.Interfaces.Repositories; using Listenarr.Application.Metadata; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; -using Moq; -using Xunit; namespace Listenarr.Tests.Features.Api.Controllers { diff --git a/tests/Features/Api/Controllers/ImagesController_AuthorFallbackTests.cs b/tests/Features/Api/Controllers/ImagesController_AuthorFallbackTests.cs index 201298642..970a6dd6a 100644 --- a/tests/Features/Api/Controllers/ImagesController_AuthorFallbackTests.cs +++ b/tests/Features/Api/Controllers/ImagesController_AuthorFallbackTests.cs @@ -16,13 +16,9 @@ * along with this program. If not, see . */ using Listenarr.Api.Controllers; -using Listenarr.Application.Interfaces; using Listenarr.Application.Interfaces.Repositories; using Listenarr.Application.Metadata; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; -using Moq; -using Xunit; namespace Listenarr.Tests.Features.Api.Controllers { diff --git a/tests/Features/Api/Controllers/ImagesController_AuthorStoredAsinTests.cs b/tests/Features/Api/Controllers/ImagesController_AuthorStoredAsinTests.cs index a8be1cf03..eecf1bd2a 100644 --- a/tests/Features/Api/Controllers/ImagesController_AuthorStoredAsinTests.cs +++ b/tests/Features/Api/Controllers/ImagesController_AuthorStoredAsinTests.cs @@ -16,14 +16,9 @@ * along with this program. If not, see . */ using Listenarr.Api.Controllers; -using Listenarr.Application.Interfaces; using Listenarr.Application.Interfaces.Repositories; using Listenarr.Application.Metadata; -using Listenarr.Domain.Models; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; -using Moq; -using Xunit; namespace Listenarr.Tests.Features.Api.Controllers { diff --git a/tests/Features/Api/Controllers/ImagesController_ContentRootResolutionTests.cs b/tests/Features/Api/Controllers/ImagesController_ContentRootResolutionTests.cs index e87ddfa18..4a55232b3 100644 --- a/tests/Features/Api/Controllers/ImagesController_ContentRootResolutionTests.cs +++ b/tests/Features/Api/Controllers/ImagesController_ContentRootResolutionTests.cs @@ -16,12 +16,8 @@ * along with this program. If not, see . */ using Listenarr.Api.Controllers; -using Listenarr.Application.Interfaces; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.DependencyInjection; -using Moq; -using Xunit; using System.Reflection; using Listenarr.Tests.Common; diff --git a/tests/Features/Api/Controllers/ImagesController_LocalIsbnOpenLibraryFallbackTests.cs b/tests/Features/Api/Controllers/ImagesController_LocalIsbnOpenLibraryFallbackTests.cs index 708c9be01..ce6fab9f7 100644 --- a/tests/Features/Api/Controllers/ImagesController_LocalIsbnOpenLibraryFallbackTests.cs +++ b/tests/Features/Api/Controllers/ImagesController_LocalIsbnOpenLibraryFallbackTests.cs @@ -16,14 +16,9 @@ * along with this program. If not, see . */ using Listenarr.Api.Controllers; -using Listenarr.Application.Interfaces; using Listenarr.Application.Interfaces.Repositories; using Listenarr.Application.Metadata; -using Listenarr.Domain.Models; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; -using Moq; -using Xunit; namespace Listenarr.Tests.Features.Api.Controllers { diff --git a/tests/Features/Api/Controllers/ImagesController_LocalTitleAuthorOpenLibraryFallbackTests.cs b/tests/Features/Api/Controllers/ImagesController_LocalTitleAuthorOpenLibraryFallbackTests.cs index 83e97d217..7252a5fd1 100644 --- a/tests/Features/Api/Controllers/ImagesController_LocalTitleAuthorOpenLibraryFallbackTests.cs +++ b/tests/Features/Api/Controllers/ImagesController_LocalTitleAuthorOpenLibraryFallbackTests.cs @@ -16,14 +16,9 @@ * along with this program. If not, see . */ using Listenarr.Api.Controllers; -using Listenarr.Application.Interfaces; using Listenarr.Application.Interfaces.Repositories; using Listenarr.Application.Metadata; -using Listenarr.Domain.Models; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; -using Moq; -using Xunit; namespace Listenarr.Tests.Features.Api.Controllers { diff --git a/tests/Features/Api/Controllers/ImagesController_MetadataDescriptionDoesNotBlockFallbackTests.cs b/tests/Features/Api/Controllers/ImagesController_MetadataDescriptionDoesNotBlockFallbackTests.cs index 85c3062b6..fce847310 100644 --- a/tests/Features/Api/Controllers/ImagesController_MetadataDescriptionDoesNotBlockFallbackTests.cs +++ b/tests/Features/Api/Controllers/ImagesController_MetadataDescriptionDoesNotBlockFallbackTests.cs @@ -16,13 +16,9 @@ * along with this program. If not, see . */ using Listenarr.Api.Controllers; -using Listenarr.Application.Interfaces; using Listenarr.Application.Interfaces.Repositories; using Listenarr.Application.Metadata; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; -using Moq; -using Xunit; namespace Listenarr.Tests.Features.Api.Controllers { diff --git a/tests/Features/Api/Controllers/ImagesController_MetadataDownloadFallbackTests.cs b/tests/Features/Api/Controllers/ImagesController_MetadataDownloadFallbackTests.cs index cfa684dbc..791e3ec8b 100644 --- a/tests/Features/Api/Controllers/ImagesController_MetadataDownloadFallbackTests.cs +++ b/tests/Features/Api/Controllers/ImagesController_MetadataDownloadFallbackTests.cs @@ -16,13 +16,9 @@ * along with this program. If not, see . */ using Listenarr.Api.Controllers; -using Listenarr.Application.Interfaces; using Listenarr.Application.Interfaces.Repositories; using Listenarr.Application.Metadata; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; -using Moq; -using Xunit; namespace Listenarr.Tests.Features.Api.Controllers { diff --git a/tests/Features/Api/Controllers/ImagesController_MetadataDownloadTests.cs b/tests/Features/Api/Controllers/ImagesController_MetadataDownloadTests.cs index 0f0c14dfc..697b2e6e9 100644 --- a/tests/Features/Api/Controllers/ImagesController_MetadataDownloadTests.cs +++ b/tests/Features/Api/Controllers/ImagesController_MetadataDownloadTests.cs @@ -16,13 +16,9 @@ * along with this program. If not, see . */ using Listenarr.Api.Controllers; -using Listenarr.Application.Interfaces; using Listenarr.Application.Interfaces.Repositories; using Listenarr.Application.Metadata; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; -using Moq; -using Xunit; namespace Listenarr.Tests.Features.Api.Controllers { diff --git a/tests/Features/Api/Controllers/ImagesController_PlaceholderFallbackTests.cs b/tests/Features/Api/Controllers/ImagesController_PlaceholderFallbackTests.cs index 7c17cc60c..fef5bf903 100644 --- a/tests/Features/Api/Controllers/ImagesController_PlaceholderFallbackTests.cs +++ b/tests/Features/Api/Controllers/ImagesController_PlaceholderFallbackTests.cs @@ -16,14 +16,10 @@ * along with this program. If not, see . */ using Listenarr.Api.Controllers; -using Listenarr.Application.Interfaces; using Listenarr.Application.Interfaces.Repositories; using Listenarr.Application.Metadata; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; -using Moq; -using Xunit; namespace Listenarr.Tests.Features.Api.Controllers { diff --git a/tests/Features/Api/Controllers/ImagesController_TempToLibraryForAudiobookTests.cs b/tests/Features/Api/Controllers/ImagesController_TempToLibraryForAudiobookTests.cs index f86182b6d..05122cb4d 100644 --- a/tests/Features/Api/Controllers/ImagesController_TempToLibraryForAudiobookTests.cs +++ b/tests/Features/Api/Controllers/ImagesController_TempToLibraryForAudiobookTests.cs @@ -16,14 +16,9 @@ * along with this program. If not, see . */ using Listenarr.Api.Controllers; -using Listenarr.Application.Interfaces; using Listenarr.Application.Interfaces.Repositories; using Listenarr.Application.Metadata; -using Listenarr.Domain.Models; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; -using Moq; -using Xunit; namespace Listenarr.Tests.Features.Api.Controllers { diff --git a/tests/Features/Api/Controllers/IntelligentSearchIntegrationTests.cs b/tests/Features/Api/Controllers/IntelligentSearchIntegrationTests.cs index d2ec31375..26fe1e867 100644 --- a/tests/Features/Api/Controllers/IntelligentSearchIntegrationTests.cs +++ b/tests/Features/Api/Controllers/IntelligentSearchIntegrationTests.cs @@ -15,11 +15,7 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -using Listenarr.Domain.Models; using Listenarr.Api.Controllers; -using Moq; -using Xunit; -using Listenarr.Application.Interfaces; using Listenarr.Application.Metadata; using Listenarr.Application.Search; diff --git a/tests/Features/Api/Controllers/LibraryController_AddToLibraryTests.cs b/tests/Features/Api/Controllers/LibraryController_AddToLibraryTests.cs index b21b3bd48..f57d88792 100644 --- a/tests/Features/Api/Controllers/LibraryController_AddToLibraryTests.cs +++ b/tests/Features/Api/Controllers/LibraryController_AddToLibraryTests.cs @@ -16,14 +16,9 @@ * along with this program. If not, see . */ using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.DependencyInjection; -using Moq; -using Xunit; using Listenarr.Api.Controllers; -using Listenarr.Domain.Models; using Listenarr.Tests.Common; using Listenarr.Tests.Builders; -using Listenarr.Application.Interfaces; namespace Listenarr.Tests.Features.Api.Controllers { diff --git a/tests/Features/Api/Controllers/LibraryController_BasePathTests.cs b/tests/Features/Api/Controllers/LibraryController_BasePathTests.cs index af7faf9a7..38a565845 100644 --- a/tests/Features/Api/Controllers/LibraryController_BasePathTests.cs +++ b/tests/Features/Api/Controllers/LibraryController_BasePathTests.cs @@ -16,11 +16,9 @@ * along with this program. If not, see . */ using System.Reflection; -using Xunit; using Listenarr.Api.Controllers; using Listenarr.Tests.Builders; using Listenarr.Tests.Common; -using Microsoft.Extensions.DependencyInjection; namespace Listenarr.Tests.Features.Api.Controllers { diff --git a/tests/Features/Api/Controllers/LibraryController_BulkUpdateTests.cs b/tests/Features/Api/Controllers/LibraryController_BulkUpdateTests.cs index 393a1c740..d98863846 100644 --- a/tests/Features/Api/Controllers/LibraryController_BulkUpdateTests.cs +++ b/tests/Features/Api/Controllers/LibraryController_BulkUpdateTests.cs @@ -17,13 +17,9 @@ */ using System.Text.Json; using Listenarr.Api.Controllers; -using Listenarr.Domain.Common; -using Listenarr.Domain.Models; using Listenarr.Tests.Builders; using Listenarr.Tests.Common; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.DependencyInjection; -using Xunit; namespace Listenarr.Tests.Features.Api.Controllers { diff --git a/tests/Features/Api/Controllers/LibraryController_DeleteFilesystemTests.cs b/tests/Features/Api/Controllers/LibraryController_DeleteFilesystemTests.cs index 71c66c033..2ec777622 100644 --- a/tests/Features/Api/Controllers/LibraryController_DeleteFilesystemTests.cs +++ b/tests/Features/Api/Controllers/LibraryController_DeleteFilesystemTests.cs @@ -16,12 +16,9 @@ * along with this program. If not, see . */ using Listenarr.Api.Controllers; -using Listenarr.Domain.Models; using Listenarr.Tests.Builders; using Listenarr.Tests.Common; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.DependencyInjection; -using Xunit; namespace Listenarr.Tests.Features.Api.Controllers { diff --git a/tests/Features/Api/Controllers/LibraryController_DeleteImageSafetyTests.cs b/tests/Features/Api/Controllers/LibraryController_DeleteImageSafetyTests.cs index cee5392f5..4b044d90f 100644 --- a/tests/Features/Api/Controllers/LibraryController_DeleteImageSafetyTests.cs +++ b/tests/Features/Api/Controllers/LibraryController_DeleteImageSafetyTests.cs @@ -15,12 +15,8 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -using Xunit; -using Moq; -using Microsoft.Extensions.DependencyInjection; using Microsoft.AspNetCore.Mvc; using Listenarr.Api.Controllers; -using Listenarr.Application.Interfaces; using Listenarr.Tests.Builders; using Listenarr.Tests.Common; diff --git a/tests/Features/Api/Controllers/LibraryController_LibraryListSlimPayloadTests.cs b/tests/Features/Api/Controllers/LibraryController_LibraryListSlimPayloadTests.cs index 2f0b461f6..9d84f604e 100644 --- a/tests/Features/Api/Controllers/LibraryController_LibraryListSlimPayloadTests.cs +++ b/tests/Features/Api/Controllers/LibraryController_LibraryListSlimPayloadTests.cs @@ -17,11 +17,7 @@ */ using System.Text.Json; using Listenarr.Api.Controllers; -using Listenarr.Domain.Models; -using Listenarr.Domain.Common; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.DependencyInjection; -using Xunit; using Listenarr.Tests.Builders; using Listenarr.Tests.Common; diff --git a/tests/Features/Api/Controllers/LibraryController_MoveTests.cs b/tests/Features/Api/Controllers/LibraryController_MoveTests.cs index e2d743a42..e7e8bdc37 100644 --- a/tests/Features/Api/Controllers/LibraryController_MoveTests.cs +++ b/tests/Features/Api/Controllers/LibraryController_MoveTests.cs @@ -16,12 +16,7 @@ * along with this program. If not, see . */ using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.DependencyInjection; -using Moq; -using Xunit; using Listenarr.Api.Controllers; -using Listenarr.Application.Interfaces; -using Listenarr.Domain.Common; using Listenarr.Tests.Builders; using Listenarr.Tests.Common; diff --git a/tests/Features/Api/Controllers/LibraryController_QualityCutoffTests.cs b/tests/Features/Api/Controllers/LibraryController_QualityCutoffTests.cs index 6bc7d7f9d..edf83ba0f 100644 --- a/tests/Features/Api/Controllers/LibraryController_QualityCutoffTests.cs +++ b/tests/Features/Api/Controllers/LibraryController_QualityCutoffTests.cs @@ -17,12 +17,8 @@ */ using System.Reflection; using Listenarr.Api.Controllers; -using Listenarr.Application.Interfaces; -using Listenarr.Domain.Models; using Listenarr.Tests.Builders; using Listenarr.Tests.Common; -using Microsoft.Extensions.DependencyInjection; -using Xunit; namespace Listenarr.Tests.Features.Api.Controllers { diff --git a/tests/Features/Api/Controllers/LibraryController_ScanPathConfigFailureTests.cs b/tests/Features/Api/Controllers/LibraryController_ScanPathConfigFailureTests.cs index ce237c9b5..5bba1eda2 100644 --- a/tests/Features/Api/Controllers/LibraryController_ScanPathConfigFailureTests.cs +++ b/tests/Features/Api/Controllers/LibraryController_ScanPathConfigFailureTests.cs @@ -16,11 +16,7 @@ * along with this program. If not, see . */ using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.DependencyInjection; -using Moq; -using Xunit; using Listenarr.Api.Controllers; -using Listenarr.Application.Interfaces; using Listenarr.Tests.Builders; using Listenarr.Tests.Common; diff --git a/tests/Features/Api/Controllers/LibraryController_ScanPathValidationTests.cs b/tests/Features/Api/Controllers/LibraryController_ScanPathValidationTests.cs index cb64a1ebd..04417d27a 100644 --- a/tests/Features/Api/Controllers/LibraryController_ScanPathValidationTests.cs +++ b/tests/Features/Api/Controllers/LibraryController_ScanPathValidationTests.cs @@ -16,11 +16,7 @@ * along with this program. If not, see . */ using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.DependencyInjection; -using Xunit; using Listenarr.Api.Controllers; -using Listenarr.Domain.Models; -using Listenarr.Application.Interfaces; using Listenarr.Tests.Builders; using Listenarr.Tests.Common; diff --git a/tests/Features/Api/Controllers/LibraryController_UpdateAudiobookTests.cs b/tests/Features/Api/Controllers/LibraryController_UpdateAudiobookTests.cs index e38064772..2bb1bf4d7 100644 --- a/tests/Features/Api/Controllers/LibraryController_UpdateAudiobookTests.cs +++ b/tests/Features/Api/Controllers/LibraryController_UpdateAudiobookTests.cs @@ -16,10 +16,7 @@ * along with this program. If not, see . */ using Listenarr.Api.Controllers; -using Listenarr.Domain.Models; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.DependencyInjection; -using Xunit; using Listenarr.Tests.Common; namespace Listenarr.Tests.Features.Api.Controllers diff --git a/tests/Features/Api/Controllers/LibraryController_WantedFlagRegressionTests.cs b/tests/Features/Api/Controllers/LibraryController_WantedFlagRegressionTests.cs index d3e8ad0d2..b1d3f7574 100644 --- a/tests/Features/Api/Controllers/LibraryController_WantedFlagRegressionTests.cs +++ b/tests/Features/Api/Controllers/LibraryController_WantedFlagRegressionTests.cs @@ -18,8 +18,6 @@ using System.Text.Json; using Listenarr.Api.Controllers; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.DependencyInjection; -using Xunit; using Listenarr.Tests.Builders; using Listenarr.Tests.Common; diff --git a/tests/Features/Api/Controllers/ManualImportControllerTests.cs b/tests/Features/Api/Controllers/ManualImportControllerTests.cs index 0b479c691..b8d25b94e 100644 --- a/tests/Features/Api/Controllers/ManualImportControllerTests.cs +++ b/tests/Features/Api/Controllers/ManualImportControllerTests.cs @@ -15,16 +15,10 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -using Xunit; -using Moq; using Listenarr.Api.Controllers; using Microsoft.Extensions.Logging.Abstractions; using Listenarr.Application.Interfaces.Repositories; -using Listenarr.Domain.Models; -using Listenarr.Application.Interfaces; -using Listenarr.Domain.Models.Configurations; using Listenarr.Domain.Models.Enumerations; -using Listenarr.Application.Common; using Listenarr.Infrastructure.FileSystem; using Listenarr.Api.Dtos.ManualImport; diff --git a/tests/Features/Api/Controllers/MetadataController_AuthorCatalogTests.cs b/tests/Features/Api/Controllers/MetadataController_AuthorCatalogTests.cs index 77ba19c9e..9d953716b 100644 --- a/tests/Features/Api/Controllers/MetadataController_AuthorCatalogTests.cs +++ b/tests/Features/Api/Controllers/MetadataController_AuthorCatalogTests.cs @@ -17,14 +17,10 @@ */ using Listenarr.Api.Controllers; using Listenarr.Application.Audiobooks; -using Listenarr.Application.Interfaces; using Listenarr.Application.Interfaces.Repositories; using Listenarr.Application.Metadata; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.Logging; -using Moq; -using Xunit; namespace Listenarr.Tests.Features.Api.Controllers { diff --git a/tests/Features/Api/Controllers/MetadataController_AuthorLookupTests.cs b/tests/Features/Api/Controllers/MetadataController_AuthorLookupTests.cs index 9a36a1e84..e4bfee9cc 100644 --- a/tests/Features/Api/Controllers/MetadataController_AuthorLookupTests.cs +++ b/tests/Features/Api/Controllers/MetadataController_AuthorLookupTests.cs @@ -16,15 +16,10 @@ * along with this program. If not, see . */ using Listenarr.Api.Controllers; -using Listenarr.Application.Interfaces; using Listenarr.Application.Interfaces.Repositories; using Listenarr.Application.Metadata; -using Listenarr.Domain.Models; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.Logging; -using Moq; -using Xunit; namespace Listenarr.Tests.Features.Api.Controllers { diff --git a/tests/Features/Api/Controllers/MetadataController_SeriesTests.cs b/tests/Features/Api/Controllers/MetadataController_SeriesTests.cs index b6e9ede18..19638f2b5 100644 --- a/tests/Features/Api/Controllers/MetadataController_SeriesTests.cs +++ b/tests/Features/Api/Controllers/MetadataController_SeriesTests.cs @@ -16,15 +16,10 @@ * along with this program. If not, see . */ using Listenarr.Api.Controllers; -using Listenarr.Application.Interfaces; using Listenarr.Application.Interfaces.Repositories; using Listenarr.Application.Metadata; -using Listenarr.Domain.Models; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.Logging; -using Moq; -using Xunit; namespace Listenarr.Tests.Features.Api.Controllers { diff --git a/tests/Features/Api/Controllers/ProwlarrCompatControllerTests.cs b/tests/Features/Api/Controllers/ProwlarrCompatControllerTests.cs index 5ca0dae88..e3ebc6f4a 100644 --- a/tests/Features/Api/Controllers/ProwlarrCompatControllerTests.cs +++ b/tests/Features/Api/Controllers/ProwlarrCompatControllerTests.cs @@ -16,18 +16,12 @@ * along with this program. If not, see . */ using Microsoft.AspNetCore.SignalR; -using Microsoft.Extensions.Logging; -using Moq; -using Xunit; using Listenarr.Api.Controllers; -using Listenarr.Domain.Models; using Microsoft.EntityFrameworkCore; using System.Text.Json; using System.Reflection; using Listenarr.Infrastructure.Persistence.Repositories; using Listenarr.Infrastructure.Persistence; -using Listenarr.Application.Interfaces; -using Listenarr.Application.Notification; namespace Listenarr.Tests.Features.Api.Controllers { diff --git a/tests/Features/Api/Controllers/RootFoldersControllerTests.cs b/tests/Features/Api/Controllers/RootFoldersControllerTests.cs index 8c2dd3e64..915ad668b 100644 --- a/tests/Features/Api/Controllers/RootFoldersControllerTests.cs +++ b/tests/Features/Api/Controllers/RootFoldersControllerTests.cs @@ -16,13 +16,9 @@ * along with this program. If not, see . */ using Listenarr.Api.Controllers; -using Listenarr.Domain.Models; -using Listenarr.Domain.Common; using Microsoft.EntityFrameworkCore; -using Xunit; using Listenarr.Infrastructure.Persistence; using Listenarr.Application.Audiobooks; -using Listenarr.Application.Interfaces; using Listenarr.Infrastructure.Persistence.Repositories; namespace Listenarr.Tests.Features.Api.Controllers diff --git a/tests/Features/Api/Controllers/SearchControllerAdvancedNormalizationTests.cs b/tests/Features/Api/Controllers/SearchControllerAdvancedNormalizationTests.cs index fb34b814e..671a272dc 100644 --- a/tests/Features/Api/Controllers/SearchControllerAdvancedNormalizationTests.cs +++ b/tests/Features/Api/Controllers/SearchControllerAdvancedNormalizationTests.cs @@ -17,14 +17,9 @@ */ using System.Text.Json; using Listenarr.Api.Controllers; -using Listenarr.Application.Interfaces; using Listenarr.Application.Metadata; using Listenarr.Application.Search; -using Listenarr.Domain.Models; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; -using Moq; -using Xunit; namespace Listenarr.Tests.Features.Api.Controllers { diff --git a/tests/Features/Api/Controllers/SearchControllerTests.cs b/tests/Features/Api/Controllers/SearchControllerTests.cs index 5b8a21b3f..f0cf449ee 100644 --- a/tests/Features/Api/Controllers/SearchControllerTests.cs +++ b/tests/Features/Api/Controllers/SearchControllerTests.cs @@ -16,15 +16,9 @@ * along with this program. If not, see . */ using Listenarr.Api.Controllers; -using Listenarr.Domain.Models; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; -using Moq; -using Xunit; using System.Text.Json; -using Listenarr.Domain.Models.Configurations; using Listenarr.Application.Metadata; -using Listenarr.Application.Interfaces; using Listenarr.Application.Search; namespace Listenarr.Tests.Features.Api.Controllers diff --git a/tests/Features/Api/Controllers/SearchControllerUnifiedTests.cs b/tests/Features/Api/Controllers/SearchControllerUnifiedTests.cs index dfad811af..b8e42023c 100644 --- a/tests/Features/Api/Controllers/SearchControllerUnifiedTests.cs +++ b/tests/Features/Api/Controllers/SearchControllerUnifiedTests.cs @@ -17,14 +17,10 @@ */ using Listenarr.Api.Controllers; -using Listenarr.Application.Interfaces; using Listenarr.Application.Metadata; using Listenarr.Application.Search; -using Listenarr.Domain.Models; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging.Abstractions; -using Moq; -using Xunit; namespace Listenarr.Tests.Features.Api.Controllers { diff --git a/tests/Features/Api/Extensions/HostedServicesRegistrationTests.cs b/tests/Features/Api/Extensions/HostedServicesRegistrationTests.cs index 39f035440..ae49494a9 100644 --- a/tests/Features/Api/Extensions/HostedServicesRegistrationTests.cs +++ b/tests/Features/Api/Extensions/HostedServicesRegistrationTests.cs @@ -16,18 +16,10 @@ * along with this program. If not, see . */ using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -using Xunit; -using Listenarr.Application.Downloads; -using Listenarr.Application.Interfaces; -using Listenarr.Application.Audiobooks; using Listenarr.Infrastructure.Extensions; using Listenarr.Infrastructure.FileSystem; -using Listenarr.Application.Common; using Listenarr.Infrastructure.Ffmpeg; -using Listenarr.Application.Metadata; -using Listenarr.Application.Search; namespace Listenarr.Tests.Features.Api.Extensions { diff --git a/tests/Features/Api/Extensions/SwaggerSecurityRequirementDocumentFilterTests.cs b/tests/Features/Api/Extensions/SwaggerSecurityRequirementDocumentFilterTests.cs index 62af23819..e3f1e5718 100644 --- a/tests/Features/Api/Extensions/SwaggerSecurityRequirementDocumentFilterTests.cs +++ b/tests/Features/Api/Extensions/SwaggerSecurityRequirementDocumentFilterTests.cs @@ -18,7 +18,6 @@ using System.Text.Json; using Listenarr.Api.Filters; using Microsoft.OpenApi; -using Xunit; namespace Listenarr.Tests.Features.Api.Extensions { diff --git a/tests/Features/Api/ForwardedHeadersTrustModelTests.cs b/tests/Features/Api/ForwardedHeadersTrustModelTests.cs index af7857b74..c2118bce5 100644 --- a/tests/Features/Api/ForwardedHeadersTrustModelTests.cs +++ b/tests/Features/Api/ForwardedHeadersTrustModelTests.cs @@ -19,9 +19,7 @@ using Listenarr.Tests.Mocks; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.HttpOverrides; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; -using Xunit; namespace Listenarr.Tests.Features.Api { diff --git a/tests/Features/Api/LibraryController_GetAllResilienceTests.cs b/tests/Features/Api/LibraryController_GetAllResilienceTests.cs index 185d05e48..7c6c1ff42 100644 --- a/tests/Features/Api/LibraryController_GetAllResilienceTests.cs +++ b/tests/Features/Api/LibraryController_GetAllResilienceTests.cs @@ -17,12 +17,9 @@ */ using System.Net; using Asp.Versioning.ApiExplorer; -using Listenarr.Domain.Models; using Listenarr.Infrastructure.Persistence; using Listenarr.Tests.Mocks; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using Xunit; namespace Listenarr.Tests.Features.Api { diff --git a/tests/Features/Api/LibraryController_IdentifierDeduplicationTests.cs b/tests/Features/Api/LibraryController_IdentifierDeduplicationTests.cs index 45239bc80..478e6e158 100644 --- a/tests/Features/Api/LibraryController_IdentifierDeduplicationTests.cs +++ b/tests/Features/Api/LibraryController_IdentifierDeduplicationTests.cs @@ -17,11 +17,8 @@ */ using System.Text; using System.Text.Json; -using Listenarr.Domain.Models; using Listenarr.Infrastructure.Persistence; using Listenarr.Tests.Mocks; -using Microsoft.Extensions.DependencyInjection; -using Xunit; namespace Listenarr.Tests.Features.Api { diff --git a/tests/Features/Api/LibraryController_MetadataRescanTests.cs b/tests/Features/Api/LibraryController_MetadataRescanTests.cs index 969efc3a0..a391c90fa 100644 --- a/tests/Features/Api/LibraryController_MetadataRescanTests.cs +++ b/tests/Features/Api/LibraryController_MetadataRescanTests.cs @@ -17,16 +17,11 @@ */ using System.Net; using System.Text.Json; -using Listenarr.Application.Interfaces; using Listenarr.Application.Metadata; -using Listenarr.Domain.Models; using Listenarr.Infrastructure.Persistence; using Listenarr.Tests.Mocks; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; -using Moq; -using Xunit; namespace Listenarr.Tests.Features.Api { diff --git a/tests/Features/Api/Middleware/AuthenticationMiddlewareTests.cs b/tests/Features/Api/Middleware/AuthenticationMiddlewareTests.cs index 03dfa6051..08af58ef2 100644 --- a/tests/Features/Api/Middleware/AuthenticationMiddlewareTests.cs +++ b/tests/Features/Api/Middleware/AuthenticationMiddlewareTests.cs @@ -18,12 +18,8 @@ using System.Net; using System.Text; using Asp.Versioning.ApiExplorer; -using Listenarr.Application.Interfaces; -using Listenarr.Domain.Models; using Listenarr.Tests.Mocks; using Microsoft.AspNetCore.Mvc.Testing; -using Microsoft.Extensions.DependencyInjection; -using Xunit; namespace Listenarr.Tests.Features.Api.Middleware { diff --git a/tests/Features/Api/Models/AudiobookDtoFactoryTests.cs b/tests/Features/Api/Models/AudiobookDtoFactoryTests.cs index 94f7ead81..bcfb622df 100644 --- a/tests/Features/Api/Models/AudiobookDtoFactoryTests.cs +++ b/tests/Features/Api/Models/AudiobookDtoFactoryTests.cs @@ -16,8 +16,6 @@ * along with this program. If not, see . */ using Microsoft.EntityFrameworkCore; -using Xunit; -using Listenarr.Domain.Models; using Listenarr.Infrastructure.Persistence; using Listenarr.Application.Mapping; using Listenarr.Tests.Builders; diff --git a/tests/Features/Api/ProwlarrEndpointsTests.cs b/tests/Features/Api/ProwlarrEndpointsTests.cs index f49a9f48b..1d03a3b5a 100644 --- a/tests/Features/Api/ProwlarrEndpointsTests.cs +++ b/tests/Features/Api/ProwlarrEndpointsTests.cs @@ -17,7 +17,6 @@ */ using System.Net; using System.Text.Json; -using Xunit; using Listenarr.Tests.Mocks; namespace Listenarr.Tests.Features.Api diff --git a/tests/Features/Api/Services/AudibleServiceAuthorFallbackTests.cs b/tests/Features/Api/Services/AudibleServiceAuthorFallbackTests.cs index 4e41d9918..979e0dc45 100644 --- a/tests/Features/Api/Services/AudibleServiceAuthorFallbackTests.cs +++ b/tests/Features/Api/Services/AudibleServiceAuthorFallbackTests.cs @@ -17,8 +17,6 @@ */ using Listenarr.Application.Metadata; using Listenarr.Tests.Common; -using Microsoft.Extensions.DependencyInjection; -using Xunit; namespace Listenarr.Tests.Features.Api.Services { diff --git a/tests/Features/Api/Services/AudibleServiceTests.cs b/tests/Features/Api/Services/AudibleServiceTests.cs index 719f41b66..d7ff5dc4b 100644 --- a/tests/Features/Api/Services/AudibleServiceTests.cs +++ b/tests/Features/Api/Services/AudibleServiceTests.cs @@ -17,7 +17,6 @@ */ using System.Reflection; using Listenarr.Application.Metadata; -using Xunit; namespace Listenarr.Tests.Features.Api.Services { diff --git a/tests/Features/Api/Services/AudibleServiceTitleSearchTests.cs b/tests/Features/Api/Services/AudibleServiceTitleSearchTests.cs index 8998d4f95..9697952a3 100644 --- a/tests/Features/Api/Services/AudibleServiceTitleSearchTests.cs +++ b/tests/Features/Api/Services/AudibleServiceTitleSearchTests.cs @@ -17,8 +17,6 @@ */ using Listenarr.Application.Metadata; using Listenarr.Tests.Common; -using Microsoft.Extensions.DependencyInjection; -using Xunit; namespace Listenarr.Tests.Features.Api.Services { diff --git a/tests/Features/Api/Services/AudioFileServiceTests.cs b/tests/Features/Api/Services/AudioFileServiceTests.cs index eda4d42b1..690efaa9b 100644 --- a/tests/Features/Api/Services/AudioFileServiceTests.cs +++ b/tests/Features/Api/Services/AudioFileServiceTests.cs @@ -16,11 +16,6 @@ * along with this program. If not, see . */ using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using Xunit; -using Moq; -using Listenarr.Domain.Models; -using Listenarr.Application.Interfaces; using Listenarr.Tests.Common; using Listenarr.Tests.Builders; using Listenarr.Infrastructure.Persistence; diff --git a/tests/Features/Api/Services/AudioFileService_UpdateAudiobookFieldsTests.cs b/tests/Features/Api/Services/AudioFileService_UpdateAudiobookFieldsTests.cs index 4521facb0..10244a9a7 100644 --- a/tests/Features/Api/Services/AudioFileService_UpdateAudiobookFieldsTests.cs +++ b/tests/Features/Api/Services/AudioFileService_UpdateAudiobookFieldsTests.cs @@ -15,11 +15,6 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -using Xunit; -using Microsoft.Extensions.DependencyInjection; -using Moq; -using Listenarr.Domain.Models; -using Listenarr.Application.Interfaces; using Listenarr.Tests.Common; namespace Listenarr.Tests.Features.Api.Services diff --git a/tests/Features/Api/Services/AudiobookMetadataServiceTests.cs b/tests/Features/Api/Services/AudiobookMetadataServiceTests.cs index 86c239e4d..0bec690df 100644 --- a/tests/Features/Api/Services/AudiobookMetadataServiceTests.cs +++ b/tests/Features/Api/Services/AudiobookMetadataServiceTests.cs @@ -15,12 +15,7 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -using Listenarr.Application.Interfaces; using Listenarr.Application.Metadata; -using Listenarr.Domain.Models.Configurations; -using Microsoft.Extensions.Logging; -using Moq; -using Xunit; namespace Listenarr.Tests.Features.Api.Services { diff --git a/tests/Features/Api/Services/AudiobookStatusEvaluatorTests.cs b/tests/Features/Api/Services/AudiobookStatusEvaluatorTests.cs index 50eb6db68..9790af78f 100644 --- a/tests/Features/Api/Services/AudiobookStatusEvaluatorTests.cs +++ b/tests/Features/Api/Services/AudiobookStatusEvaluatorTests.cs @@ -17,8 +17,6 @@ */ using Listenarr.Application.Metadata; using Listenarr.Application.Audiobooks; -using Listenarr.Domain.Models; -using Xunit; namespace Listenarr.Tests.Features.Api.Services { diff --git a/tests/Features/Api/Services/AuthorCatalogServiceTests.cs b/tests/Features/Api/Services/AuthorCatalogServiceTests.cs index 783e1a718..c7b428b1d 100644 --- a/tests/Features/Api/Services/AuthorCatalogServiceTests.cs +++ b/tests/Features/Api/Services/AuthorCatalogServiceTests.cs @@ -16,13 +16,8 @@ * along with this program. If not, see . */ using Listenarr.Application.Audiobooks; -using Listenarr.Application.Interfaces; using Listenarr.Application.Interfaces.Repositories; using Listenarr.Application.Metadata; -using Listenarr.Domain.Models; -using Microsoft.Extensions.Logging; -using Moq; -using Xunit; namespace Listenarr.Tests.Features.Api.Services { diff --git a/tests/Features/Api/Services/AuthorMonitoringServiceTests.cs b/tests/Features/Api/Services/AuthorMonitoringServiceTests.cs index ef5ca8e90..362b8a745 100644 --- a/tests/Features/Api/Services/AuthorMonitoringServiceTests.cs +++ b/tests/Features/Api/Services/AuthorMonitoringServiceTests.cs @@ -16,15 +16,10 @@ * along with this program. If not, see . */ using Listenarr.Application.Audiobooks; -using Listenarr.Application.Interfaces; using Listenarr.Application.Metadata; -using Listenarr.Domain.Models; using Listenarr.Infrastructure.Persistence; using Listenarr.Infrastructure.Persistence.Repositories; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using Moq; -using Xunit; namespace Listenarr.Tests.Features.Api.Services { diff --git a/tests/Features/Api/Services/ConfigurationServiceTests.cs b/tests/Features/Api/Services/ConfigurationServiceTests.cs index f75190fca..347129d72 100644 --- a/tests/Features/Api/Services/ConfigurationServiceTests.cs +++ b/tests/Features/Api/Services/ConfigurationServiceTests.cs @@ -15,19 +15,10 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; using Microsoft.EntityFrameworkCore; -using Moq; -using Xunit; -using Listenarr.Domain.Models; -using Listenarr.Domain.Common; using Listenarr.Tests.Common; -using Listenarr.Application.Interfaces; using Listenarr.Tests.Builders; using Listenarr.Infrastructure.Persistence; -using Listenarr.Domain.Models.Configurations; -using Listenarr.Application.Common; namespace Listenarr.Tests.Features.Api.Services { diff --git a/tests/Features/Api/Services/DownloadClientCategoryFilterTests.cs b/tests/Features/Api/Services/DownloadClientCategoryFilterTests.cs index f069d4cc2..1946aa659 100644 --- a/tests/Features/Api/Services/DownloadClientCategoryFilterTests.cs +++ b/tests/Features/Api/Services/DownloadClientCategoryFilterTests.cs @@ -18,13 +18,10 @@ using System.Net; using System.Text; using Listenarr.Application.Downloads; -using Listenarr.Domain.Models; using Listenarr.Infrastructure.Adapters; using Listenarr.Infrastructure.Torrents; using Listenarr.Tests.Common; using Microsoft.Extensions.Logging.Abstractions; -using Moq; -using Xunit; namespace Listenarr.Tests.Features.Api.Services { diff --git a/tests/Features/Api/Services/DownloadHashRetrievalServiceTests.cs b/tests/Features/Api/Services/DownloadHashRetrievalServiceTests.cs index 89951ba84..0a7955db5 100644 --- a/tests/Features/Api/Services/DownloadHashRetrievalServiceTests.cs +++ b/tests/Features/Api/Services/DownloadHashRetrievalServiceTests.cs @@ -17,14 +17,9 @@ */ using Listenarr.Application.Downloads; -using Listenarr.Application.Interfaces; -using Listenarr.Domain.Models; using Listenarr.Infrastructure.Persistence; using Listenarr.Infrastructure.Persistence.Repositories; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using Moq; -using Xunit; namespace Listenarr.Tests.Features.Api.Services { diff --git a/tests/Features/Api/Services/DownloadMonitorServiceTests.cs b/tests/Features/Api/Services/DownloadMonitorServiceTests.cs index 9fc83a29e..50f33daf0 100644 --- a/tests/Features/Api/Services/DownloadMonitorServiceTests.cs +++ b/tests/Features/Api/Services/DownloadMonitorServiceTests.cs @@ -16,17 +16,10 @@ * along with this program. If not, see . */ using System.Reflection; -using Listenarr.Application.Downloads; -using Listenarr.Application.Interfaces; -using Listenarr.Domain.Models; -using Listenarr.Domain.Models.Configurations; using Listenarr.Infrastructure.Persistence; using Listenarr.Tests.Common; using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using Moq; -using Xunit; namespace Listenarr.Tests.Features.Api.Services { diff --git a/tests/Features/Api/Services/DownloadNaming_AudiobookMetadataTests.cs b/tests/Features/Api/Services/DownloadNaming_AudiobookMetadataTests.cs index 97a441360..a2e8daa2d 100644 --- a/tests/Features/Api/Services/DownloadNaming_AudiobookMetadataTests.cs +++ b/tests/Features/Api/Services/DownloadNaming_AudiobookMetadataTests.cs @@ -15,12 +15,8 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -using Microsoft.Extensions.DependencyInjection; -using Xunit; -using Listenarr.Domain.Models; using Listenarr.Tests.Common; using Listenarr.Tests.Builders; -using Listenarr.Application.Interfaces; namespace Listenarr.Tests.Features.Api.Services { diff --git a/tests/Features/Api/Services/DownloadNaming_PatternCollapseTests.cs b/tests/Features/Api/Services/DownloadNaming_PatternCollapseTests.cs index bf00cc255..5b1447c5f 100644 --- a/tests/Features/Api/Services/DownloadNaming_PatternCollapseTests.cs +++ b/tests/Features/Api/Services/DownloadNaming_PatternCollapseTests.cs @@ -15,12 +15,6 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -using Xunit; -using Moq; -using Listenarr.Application.Interfaces; -using Microsoft.Extensions.Logging; -using Listenarr.Application.Common; - namespace Listenarr.Tests.Features.Api.Services { public class DownloadNaming_PatternCollapseTests diff --git a/tests/Features/Api/Services/DownloadQueueServiceReconciliationTests.cs b/tests/Features/Api/Services/DownloadQueueServiceReconciliationTests.cs index 562d9a56d..b8d0392a8 100644 --- a/tests/Features/Api/Services/DownloadQueueServiceReconciliationTests.cs +++ b/tests/Features/Api/Services/DownloadQueueServiceReconciliationTests.cs @@ -17,14 +17,8 @@ */ using System.Text.Json; using Listenarr.Application.Interfaces.Repositories; -using Listenarr.Domain.Models; using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; -using Moq; -using Xunit; -using Listenarr.Application.Interfaces; -using Listenarr.Domain.Models.Configurations; using Listenarr.Application.Downloads; namespace Listenarr.Tests.Features.Api.Services diff --git a/tests/Features/Api/Services/DownloadStateMachineTests.cs b/tests/Features/Api/Services/DownloadStateMachineTests.cs index fec798c2c..d2770da13 100644 --- a/tests/Features/Api/Services/DownloadStateMachineTests.cs +++ b/tests/Features/Api/Services/DownloadStateMachineTests.cs @@ -17,13 +17,9 @@ */ using Listenarr.Application.Downloads; -using Listenarr.Domain.Models; using Listenarr.Infrastructure.Persistence; using Listenarr.Infrastructure.Persistence.Repositories; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using Moq; -using Xunit; namespace Listenarr.Tests.Features.Api.Services { diff --git a/tests/Features/Api/Services/DownloadValidationPipelineTests.cs b/tests/Features/Api/Services/DownloadValidationPipelineTests.cs index 6ded29061..b4b6eeb32 100644 --- a/tests/Features/Api/Services/DownloadValidationPipelineTests.cs +++ b/tests/Features/Api/Services/DownloadValidationPipelineTests.cs @@ -17,13 +17,9 @@ */ using Listenarr.Application.Downloads; -using Listenarr.Domain.Models; using Listenarr.Infrastructure.Persistence; using Listenarr.Infrastructure.Persistence.Repositories; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using Moq; -using Xunit; namespace Listenarr.Tests.Features.Api.Services { diff --git a/tests/Features/Api/Services/FfmpegServiceTests.cs b/tests/Features/Api/Services/FfmpegServiceTests.cs index 71cb88301..52fabef3b 100644 --- a/tests/Features/Api/Services/FfmpegServiceTests.cs +++ b/tests/Features/Api/Services/FfmpegServiceTests.cs @@ -1,10 +1,5 @@ -using Listenarr.Application.Interfaces; using Listenarr.Infrastructure.Ffmpeg; using Listenarr.Tests.Common; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Moq; -using Xunit; namespace Listenarr.Tests.Features.Api.Services { diff --git a/tests/Features/Api/Services/FileMoverFallbackTests.cs b/tests/Features/Api/Services/FileMoverFallbackTests.cs index b13246bb5..37e520f29 100644 --- a/tests/Features/Api/Services/FileMoverFallbackTests.cs +++ b/tests/Features/Api/Services/FileMoverFallbackTests.cs @@ -17,12 +17,9 @@ */ using System.Diagnostics; using System.Runtime.InteropServices; -using Listenarr.Application.Interfaces; -using Listenarr.Domain.Models.Configurations; using Listenarr.Infrastructure.FileSystem; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; -using Xunit; namespace Listenarr.Tests.Features.Api.Services { diff --git a/tests/Features/Api/Services/FileMoverHardlinkTests.cs b/tests/Features/Api/Services/FileMoverHardlinkTests.cs index 4bec19fb8..ca9d9e946 100644 --- a/tests/Features/Api/Services/FileMoverHardlinkTests.cs +++ b/tests/Features/Api/Services/FileMoverHardlinkTests.cs @@ -17,7 +17,6 @@ */ using Listenarr.Infrastructure.FileSystem; using Microsoft.Extensions.Logging.Abstractions; -using Xunit; namespace Listenarr.Tests.Features.Api.Services { diff --git a/tests/Features/Api/Services/FileNamingService_PathLengthTests.cs b/tests/Features/Api/Services/FileNamingService_PathLengthTests.cs index 6e8e6d635..e2c4f41c1 100644 --- a/tests/Features/Api/Services/FileNamingService_PathLengthTests.cs +++ b/tests/Features/Api/Services/FileNamingService_PathLengthTests.cs @@ -16,11 +16,6 @@ * along with this program. If not, see . */ using System.Runtime.InteropServices; -using Xunit; -using Moq; -using Microsoft.Extensions.Logging; -using Listenarr.Application.Interfaces; -using Listenarr.Application.Common; namespace Listenarr.Tests.Features.Api.Services { diff --git a/tests/Features/Api/Services/FileNamingService_PatternSelectionTests.cs b/tests/Features/Api/Services/FileNamingService_PatternSelectionTests.cs index 78b77667a..c57b2543e 100644 --- a/tests/Features/Api/Services/FileNamingService_PatternSelectionTests.cs +++ b/tests/Features/Api/Services/FileNamingService_PatternSelectionTests.cs @@ -15,15 +15,6 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -using Xunit; -using Moq; -using Microsoft.Extensions.Logging; -using Listenarr.Domain.Common; -using Listenarr.Domain.Models; -using Listenarr.Application.Interfaces; -using Listenarr.Application.Common; -using Listenarr.Domain.Models.Configurations; - namespace Listenarr.Tests.Features.Api.Services { /// diff --git a/tests/Features/Api/Services/ImportServiceHardlinkTests.cs b/tests/Features/Api/Services/ImportServiceHardlinkTests.cs index e33158614..2fee849f2 100644 --- a/tests/Features/Api/Services/ImportServiceHardlinkTests.cs +++ b/tests/Features/Api/Services/ImportServiceHardlinkTests.cs @@ -15,13 +15,8 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -using Microsoft.Extensions.DependencyInjection; -using Xunit; using Listenarr.Tests.Common; -using Listenarr.Application.Interfaces; -using Listenarr.Domain.Models; using Listenarr.Tests.Builders; -using Listenarr.Domain.Models.Configurations; using Listenarr.Domain.Models.Enumerations; namespace Listenarr.Tests.Features.Api.Services diff --git a/tests/Features/Api/Services/ImportServiceTests.cs b/tests/Features/Api/Services/ImportServiceTests.cs index 0b7120cc2..01d0a711b 100644 --- a/tests/Features/Api/Services/ImportServiceTests.cs +++ b/tests/Features/Api/Services/ImportServiceTests.cs @@ -17,14 +17,8 @@ */ using System.Runtime.InteropServices; using System.Text; -using Microsoft.Extensions.DependencyInjection; -using Moq; -using Xunit; -using Listenarr.Domain.Models; using Listenarr.Tests.Common; -using Listenarr.Application.Interfaces; using Listenarr.Tests.Builders; -using Listenarr.Domain.Models.Configurations; using Listenarr.Domain.Models.Enumerations; namespace Listenarr.Tests.Features.Api.Services diff --git a/tests/Features/Api/Services/Import_PatternIntegrationTests.cs b/tests/Features/Api/Services/Import_PatternIntegrationTests.cs index 57b1dfc3d..ab7202364 100644 --- a/tests/Features/Api/Services/Import_PatternIntegrationTests.cs +++ b/tests/Features/Api/Services/Import_PatternIntegrationTests.cs @@ -15,14 +15,6 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -using Xunit; -using Moq; -using Listenarr.Domain.Models; -using Microsoft.Extensions.Logging; -using Listenarr.Application.Interfaces; -using Listenarr.Domain.Models.Configurations; -using Listenarr.Application.Common; - namespace Listenarr.Tests.Features.Api.Services { /// diff --git a/tests/Features/Api/Services/LegacyOutputPathMigratorTests.cs b/tests/Features/Api/Services/LegacyOutputPathMigratorTests.cs index 7ec4448ae..b7b904a59 100644 --- a/tests/Features/Api/Services/LegacyOutputPathMigratorTests.cs +++ b/tests/Features/Api/Services/LegacyOutputPathMigratorTests.cs @@ -16,13 +16,6 @@ * along with this program. If not, see . */ using Microsoft.Extensions.Logging.Abstractions; -using Moq; -using Xunit; -using Listenarr.Domain.Common; -using Listenarr.Domain.Models; -using Listenarr.Application.Interfaces; -using Listenarr.Domain.Models.Configurations; -using Listenarr.Application.Common; namespace Listenarr.Tests.Features.Api.Services { diff --git a/tests/Features/Api/Services/LogRedactionTests.cs b/tests/Features/Api/Services/LogRedactionTests.cs index 2d4f6e534..456ebf723 100644 --- a/tests/Features/Api/Services/LogRedactionTests.cs +++ b/tests/Features/Api/Services/LogRedactionTests.cs @@ -15,9 +15,6 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -using Listenarr.Application.Security; -using Xunit; - namespace Listenarr.Tests.Features.Api.Services { public class LogRedactionTests diff --git a/tests/Features/Api/Services/LoginRateLimiterTests.cs b/tests/Features/Api/Services/LoginRateLimiterTests.cs index b0a0221c3..06ab375a9 100644 --- a/tests/Features/Api/Services/LoginRateLimiterTests.cs +++ b/tests/Features/Api/Services/LoginRateLimiterTests.cs @@ -16,7 +16,6 @@ * along with this program. If not, see . */ using Listenarr.Infrastructure.Security; -using Xunit; namespace Listenarr.Tests.Features.Api.Services { diff --git a/tests/Features/Api/Services/MetadataServiceTests.cs b/tests/Features/Api/Services/MetadataServiceTests.cs index 49bb02f2e..a2ada6962 100644 --- a/tests/Features/Api/Services/MetadataServiceTests.cs +++ b/tests/Features/Api/Services/MetadataServiceTests.cs @@ -1,9 +1,5 @@ -using Listenarr.Application.Interfaces; -using Listenarr.Domain.Models; using Listenarr.Tests.Builders; using Listenarr.Tests.Common; -using Microsoft.Extensions.DependencyInjection; -using Xunit; namespace Listenarr.Tests.Features.Api.Services { diff --git a/tests/Features/Api/Services/MoveBackgroundServiceTests.cs b/tests/Features/Api/Services/MoveBackgroundServiceTests.cs index 15c343a5a..c49780760 100644 --- a/tests/Features/Api/Services/MoveBackgroundServiceTests.cs +++ b/tests/Features/Api/Services/MoveBackgroundServiceTests.cs @@ -15,12 +15,8 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -using Microsoft.Extensions.DependencyInjection; -using Xunit; -using Listenarr.Domain.Models; using Listenarr.Tests.Common; using Listenarr.Application.Interfaces.Repositories; -using Listenarr.Application.Interfaces; using Listenarr.Infrastructure.FileSystem; namespace Listenarr.Tests.Features.Api.Services diff --git a/tests/Features/Api/Services/MoveBackgroundService_BroadcastTests.cs b/tests/Features/Api/Services/MoveBackgroundService_BroadcastTests.cs index ea5046db1..b7da07941 100644 --- a/tests/Features/Api/Services/MoveBackgroundService_BroadcastTests.cs +++ b/tests/Features/Api/Services/MoveBackgroundService_BroadcastTests.cs @@ -15,14 +15,9 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -using Microsoft.Extensions.DependencyInjection; -using Xunit; -using Listenarr.Domain.Models; using Microsoft.AspNetCore.SignalR; using Listenarr.Tests.Common; using System.Text.Json; -using Listenarr.Application.Notification; -using Listenarr.Application.Interfaces; using Listenarr.Infrastructure.FileSystem; namespace Listenarr.Tests.Features.Api.Services diff --git a/tests/Features/Api/Services/MoveBackgroundService_FailureTests.cs b/tests/Features/Api/Services/MoveBackgroundService_FailureTests.cs index 68d39caaf..fe56a6f17 100644 --- a/tests/Features/Api/Services/MoveBackgroundService_FailureTests.cs +++ b/tests/Features/Api/Services/MoveBackgroundService_FailureTests.cs @@ -15,11 +15,7 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -using Microsoft.Extensions.DependencyInjection; -using Xunit; -using Listenarr.Domain.Models; using Listenarr.Tests.Common; -using Listenarr.Application.Interfaces; using Listenarr.Infrastructure.FileSystem; namespace Listenarr.Tests.Features.Api.Services diff --git a/tests/Features/Api/Services/MoveBackgroundService_FilePathPreservationTests.cs b/tests/Features/Api/Services/MoveBackgroundService_FilePathPreservationTests.cs index 3da416878..622ed13b2 100644 --- a/tests/Features/Api/Services/MoveBackgroundService_FilePathPreservationTests.cs +++ b/tests/Features/Api/Services/MoveBackgroundService_FilePathPreservationTests.cs @@ -15,11 +15,6 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -using Microsoft.Extensions.DependencyInjection; -using Xunit; -using Moq; -using Listenarr.Domain.Models; -using Listenarr.Application.Interfaces; using Listenarr.Tests.Common; using Listenarr.Application.Interfaces.Repositories; using Listenarr.Infrastructure.FileSystem; diff --git a/tests/Features/Api/Services/MoveQueueServiceTests.cs b/tests/Features/Api/Services/MoveQueueServiceTests.cs index 065b82606..d96c4afd2 100644 --- a/tests/Features/Api/Services/MoveQueueServiceTests.cs +++ b/tests/Features/Api/Services/MoveQueueServiceTests.cs @@ -15,10 +15,8 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.EntityFrameworkCore; -using Xunit; using Listenarr.Application.Interfaces.Repositories; using Listenarr.Infrastructure.Persistence.Repositories; using Listenarr.Infrastructure.Persistence; diff --git a/tests/Features/Api/Services/MyAnonamouseTorrentAnnounceExtractionTests.cs b/tests/Features/Api/Services/MyAnonamouseTorrentAnnounceExtractionTests.cs index efc36a33a..67ab9bf6c 100644 --- a/tests/Features/Api/Services/MyAnonamouseTorrentAnnounceExtractionTests.cs +++ b/tests/Features/Api/Services/MyAnonamouseTorrentAnnounceExtractionTests.cs @@ -15,9 +15,7 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -using Xunit; using System.Text; -using Listenarr.Application.Common; namespace Listenarr.Tests.Features.Api.Services { diff --git a/tests/Features/Api/Services/ParseLanguageTests.cs b/tests/Features/Api/Services/ParseLanguageTests.cs index 8605db2e6..7ba904c89 100644 --- a/tests/Features/Api/Services/ParseLanguageTests.cs +++ b/tests/Features/Api/Services/ParseLanguageTests.cs @@ -16,7 +16,6 @@ * along with this program. If not, see . */ using Listenarr.Application.Search; -using Xunit; namespace Listenarr.Tests.Features.Api.Services { diff --git a/tests/Features/Api/Services/PathMetadataParserTests.cs b/tests/Features/Api/Services/PathMetadataParserTests.cs index ce5e5846b..5a681fa07 100644 --- a/tests/Features/Api/Services/PathMetadataParserTests.cs +++ b/tests/Features/Api/Services/PathMetadataParserTests.cs @@ -17,7 +17,6 @@ */ using System.Text.Json; using Listenarr.Application.Metadata; -using Xunit; namespace Listenarr.Tests.Features.Api.Services { diff --git a/tests/Features/Api/Services/QualityProfileScoringTests.cs b/tests/Features/Api/Services/QualityProfileScoringTests.cs index 5781d77d4..1bdfab67a 100644 --- a/tests/Features/Api/Services/QualityProfileScoringTests.cs +++ b/tests/Features/Api/Services/QualityProfileScoringTests.cs @@ -15,10 +15,7 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -using Xunit; -using Moq; using Listenarr.Application.Interfaces.Repositories; -using Listenarr.Domain.Models; using Listenarr.Infrastructure.Persistence.Repositories; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.EntityFrameworkCore; diff --git a/tests/Features/Api/Services/QualityScoringTests.cs b/tests/Features/Api/Services/QualityScoringTests.cs index 46f608761..475ca1c7c 100644 --- a/tests/Features/Api/Services/QualityScoringTests.cs +++ b/tests/Features/Api/Services/QualityScoringTests.cs @@ -16,11 +16,9 @@ * along with this program. If not, see . */ using System.Reflection; -using Listenarr.Domain.Models; using Listenarr.Infrastructure.Persistence.Repositories; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Abstractions; -using Xunit; using Listenarr.Infrastructure.Persistence; using Listenarr.Application.Audiobooks; diff --git a/tests/Features/Api/Services/RenameServiceTests.cs b/tests/Features/Api/Services/RenameServiceTests.cs index 48a2be5f2..2fa66ceff 100644 --- a/tests/Features/Api/Services/RenameServiceTests.cs +++ b/tests/Features/Api/Services/RenameServiceTests.cs @@ -16,18 +16,11 @@ * along with this program. If not, see . */ using Listenarr.Application.Audiobooks; -using Listenarr.Application.Common; -using Listenarr.Application.Interfaces; -using Listenarr.Domain.Common; -using Listenarr.Domain.Models; -using Listenarr.Domain.Models.Configurations; using Listenarr.Domain.Models.Enumerations; using Listenarr.Infrastructure.Persistence; using Listenarr.Infrastructure.Persistence.Repositories; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Abstractions; -using Moq; -using Xunit; namespace Listenarr.Tests.Features.Api.Services { diff --git a/tests/Features/Api/Services/RootFolderServiceTests.cs b/tests/Features/Api/Services/RootFolderServiceTests.cs index 858caffac..c22439fa9 100644 --- a/tests/Features/Api/Services/RootFolderServiceTests.cs +++ b/tests/Features/Api/Services/RootFolderServiceTests.cs @@ -16,16 +16,10 @@ * along with this program. If not, see . */ using Microsoft.EntityFrameworkCore; -using Xunit; using Xunit.Abstractions; -using Microsoft.Extensions.Logging; -using Moq; using Listenarr.Infrastructure.Persistence.Repositories; -using Listenarr.Domain.Models; -using Listenarr.Domain.Common; using Listenarr.Infrastructure.Persistence; using Listenarr.Application.Downloads; -using Listenarr.Application.Interfaces; namespace Listenarr.Tests.Features.Api.Services { diff --git a/tests/Features/Api/Services/Search/Providers/IndexersAuthTests.cs b/tests/Features/Api/Services/Search/Providers/IndexersAuthTests.cs index cc04e0ab4..245fa1031 100644 --- a/tests/Features/Api/Services/Search/Providers/IndexersAuthTests.cs +++ b/tests/Features/Api/Services/Search/Providers/IndexersAuthTests.cs @@ -16,11 +16,9 @@ * along with this program. If not, see . */ using System.Net; -using Listenarr.Domain.Models; using Listenarr.Tests.Common; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -using Xunit; namespace Listenarr.Tests.Features.Api.Services.Search.Providers { diff --git a/tests/Features/Api/Services/Search/Providers/IndexersControllerProwlarrImportTests.cs b/tests/Features/Api/Services/Search/Providers/IndexersControllerProwlarrImportTests.cs index b469ff86d..5dc835f56 100644 --- a/tests/Features/Api/Services/Search/Providers/IndexersControllerProwlarrImportTests.cs +++ b/tests/Features/Api/Services/Search/Providers/IndexersControllerProwlarrImportTests.cs @@ -19,12 +19,8 @@ using System.Text; using Listenarr.Api.Controllers; using Listenarr.Api.Dtos; -using Listenarr.Application.Interfaces; -using Listenarr.Domain.Models; using Listenarr.Tests.Common; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.DependencyInjection; -using Xunit; namespace Listenarr.Tests.Features.Api.Services.Search.Providers { diff --git a/tests/Features/Api/Services/Search/Providers/IndexersControllerTests.cs b/tests/Features/Api/Services/Search/Providers/IndexersControllerTests.cs index 2a021a87e..3f3ed54c5 100644 --- a/tests/Features/Api/Services/Search/Providers/IndexersControllerTests.cs +++ b/tests/Features/Api/Services/Search/Providers/IndexersControllerTests.cs @@ -17,12 +17,9 @@ */ using System.Net; using Listenarr.Api.Controllers; -using Listenarr.Domain.Models; using Listenarr.Infrastructure.Persistence; using Listenarr.Tests.Common; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using Xunit; namespace Listenarr.Tests.Features.Api.Services.Search.Providers { diff --git a/tests/Features/Api/Services/Search/Providers/IndexersNewznabAuthTests.cs b/tests/Features/Api/Services/Search/Providers/IndexersNewznabAuthTests.cs index f1f1ee333..0d83dc693 100644 --- a/tests/Features/Api/Services/Search/Providers/IndexersNewznabAuthTests.cs +++ b/tests/Features/Api/Services/Search/Providers/IndexersNewznabAuthTests.cs @@ -16,9 +16,7 @@ * along with this program. If not, see . */ using System.Net; -using Listenarr.Domain.Models; using Listenarr.Tests.Common; -using Xunit; namespace Listenarr.Tests.Features.Api.Services.Search.Providers { diff --git a/tests/Features/Api/Services/Search/Providers/IndexersNewznabParsingTests.cs b/tests/Features/Api/Services/Search/Providers/IndexersNewznabParsingTests.cs index d13aa549e..f105040e9 100644 --- a/tests/Features/Api/Services/Search/Providers/IndexersNewznabParsingTests.cs +++ b/tests/Features/Api/Services/Search/Providers/IndexersNewznabParsingTests.cs @@ -16,17 +16,12 @@ * along with this program. If not, see . */ using Listenarr.Application.Interfaces.Repositories; -using Listenarr.Domain.Models; -using Xunit; using Microsoft.Extensions.Logging.Abstractions; using System.Net; using Microsoft.EntityFrameworkCore; -using Moq; -using Listenarr.Application.Interfaces; using Listenarr.Infrastructure.Persistence; using Listenarr.Application.Metadata; using Listenarr.Application.Search; -using Listenarr.Application.Notification; using Listenarr.Application.Search.Filters; using Listenarr.Application.Search.Strategies; using Listenarr.Infrastructure.Search.Providers; diff --git a/tests/Features/Api/Services/Search/Providers/IndexersPersistedAuthTests.cs b/tests/Features/Api/Services/Search/Providers/IndexersPersistedAuthTests.cs index b2c7a7fac..f41908682 100644 --- a/tests/Features/Api/Services/Search/Providers/IndexersPersistedAuthTests.cs +++ b/tests/Features/Api/Services/Search/Providers/IndexersPersistedAuthTests.cs @@ -16,9 +16,7 @@ * along with this program. If not, see . */ using System.Net; -using Listenarr.Domain.Models; using Listenarr.Tests.Common; -using Xunit; namespace Listenarr.Tests.Features.Api.Services.Search.Providers { diff --git a/tests/Features/Api/Services/Search/Providers/MyAnonamouseCookieTests.cs b/tests/Features/Api/Services/Search/Providers/MyAnonamouseCookieTests.cs index 10e3bba35..59a87e5cb 100644 --- a/tests/Features/Api/Services/Search/Providers/MyAnonamouseCookieTests.cs +++ b/tests/Features/Api/Services/Search/Providers/MyAnonamouseCookieTests.cs @@ -15,16 +15,12 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -using Xunit; -using Microsoft.Extensions.DependencyInjection; using System.Reflection; using System.Text; using Listenarr.Tests.Common; -using Listenarr.Domain.Models; using Listenarr.Tests.Builders; using Listenarr.Tests.Mocks.Api; using Listenarr.Application.Downloads; -using Listenarr.Application.Common; namespace Listenarr.Tests.Features.Api.Services.Search.Providers { diff --git a/tests/Features/Api/Services/Search/Providers/MyAnonamouseTorrentAnnounceRewriteTests.cs b/tests/Features/Api/Services/Search/Providers/MyAnonamouseTorrentAnnounceRewriteTests.cs index 7af8d3d78..df8ab76d5 100644 --- a/tests/Features/Api/Services/Search/Providers/MyAnonamouseTorrentAnnounceRewriteTests.cs +++ b/tests/Features/Api/Services/Search/Providers/MyAnonamouseTorrentAnnounceRewriteTests.cs @@ -16,8 +16,6 @@ * along with this program. If not, see . */ using System.Text; -using Listenarr.Application.Common; -using Xunit; namespace Listenarr.Tests.Features.Api.Services.Search.Providers { diff --git a/tests/Features/Api/Services/Search/Providers/MyAnonamouseTorrentRewriteTests.cs b/tests/Features/Api/Services/Search/Providers/MyAnonamouseTorrentRewriteTests.cs index aa2c3f80a..c635e712c 100644 --- a/tests/Features/Api/Services/Search/Providers/MyAnonamouseTorrentRewriteTests.cs +++ b/tests/Features/Api/Services/Search/Providers/MyAnonamouseTorrentRewriteTests.cs @@ -16,8 +16,6 @@ * along with this program. If not, see . */ using System.Text; -using Xunit; -using Listenarr.Application.Common; namespace Listenarr.Tests.Features.Api.Services.Search.Providers { diff --git a/tests/Features/Api/Services/SearchServiceFixesTests.cs b/tests/Features/Api/Services/SearchServiceFixesTests.cs index e709ecbe1..83e913422 100644 --- a/tests/Features/Api/Services/SearchServiceFixesTests.cs +++ b/tests/Features/Api/Services/SearchServiceFixesTests.cs @@ -15,15 +15,10 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -using Xunit; using Listenarr.Application.Interfaces.Repositories; -using Listenarr.Domain.Models; using Microsoft.Extensions.Logging.Abstractions; -using Moq; -using Listenarr.Application.Interfaces; using Listenarr.Application.Search; using Listenarr.Application.Metadata; -using Listenarr.Application.Notification; using Listenarr.Application.Search.Strategies; using Listenarr.Application.Search.Filters; diff --git a/tests/Features/Api/Services/SearchServiceScoringTests.cs b/tests/Features/Api/Services/SearchServiceScoringTests.cs index dbae53ef8..fa71ae5d7 100644 --- a/tests/Features/Api/Services/SearchServiceScoringTests.cs +++ b/tests/Features/Api/Services/SearchServiceScoringTests.cs @@ -16,14 +16,9 @@ * along with this program. If not, see . */ using Listenarr.Application.Interfaces.Repositories; -using Listenarr.Domain.Models; using Microsoft.Extensions.Logging.Abstractions; -using Moq; -using Xunit; -using Listenarr.Application.Interfaces; using Listenarr.Application.Search; using Listenarr.Application.Metadata; -using Listenarr.Application.Notification; using Listenarr.Application.Search.Strategies; using Listenarr.Application.Search.Filters; diff --git a/tests/Features/Api/Services/SearchServiceSortingTests.cs b/tests/Features/Api/Services/SearchServiceSortingTests.cs index 781fddc68..cce4bd7b6 100644 --- a/tests/Features/Api/Services/SearchServiceSortingTests.cs +++ b/tests/Features/Api/Services/SearchServiceSortingTests.cs @@ -17,8 +17,6 @@ */ using System.Reflection; using Listenarr.Application.Search; -using Listenarr.Domain.Models; -using Xunit; namespace Listenarr.Tests.Features.Api.Services { diff --git a/tests/Features/Api/Services/SeriesCatalogServiceTests.cs b/tests/Features/Api/Services/SeriesCatalogServiceTests.cs index da778a242..2b6b1f9fd 100644 --- a/tests/Features/Api/Services/SeriesCatalogServiceTests.cs +++ b/tests/Features/Api/Services/SeriesCatalogServiceTests.cs @@ -18,10 +18,6 @@ using Listenarr.Application.Audiobooks; using Listenarr.Application.Interfaces.Repositories; using Listenarr.Application.Metadata; -using Listenarr.Domain.Models; -using Microsoft.Extensions.Logging; -using Moq; -using Xunit; namespace Listenarr.Tests.Features.Api.Services { diff --git a/tests/Features/Api/Services/SystemProcessRunnerTests.cs b/tests/Features/Api/Services/SystemProcessRunnerTests.cs index 5ae8a1251..7de4505b5 100644 --- a/tests/Features/Api/Services/SystemProcessRunnerTests.cs +++ b/tests/Features/Api/Services/SystemProcessRunnerTests.cs @@ -19,7 +19,6 @@ using System.Runtime.InteropServices; using Listenarr.Infrastructure.Platform; using Microsoft.Extensions.Logging.Abstractions; -using Xunit; namespace Listenarr.Tests.Features.Api.Services { diff --git a/tests/Features/Api/Services/UnmatchedScanBackgroundServiceTests.cs b/tests/Features/Api/Services/UnmatchedScanBackgroundServiceTests.cs index bd6111da7..eba5c8d14 100644 --- a/tests/Features/Api/Services/UnmatchedScanBackgroundServiceTests.cs +++ b/tests/Features/Api/Services/UnmatchedScanBackgroundServiceTests.cs @@ -15,9 +15,7 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -using Listenarr.Application.Audiobooks; using Listenarr.Application.Metadata; -using Xunit; namespace Listenarr.Tests.Features.Api.Services { diff --git a/tests/Features/Api/SessionCookieAuthTests.cs b/tests/Features/Api/SessionCookieAuthTests.cs index 46cf4a533..d2165faed 100644 --- a/tests/Features/Api/SessionCookieAuthTests.cs +++ b/tests/Features/Api/SessionCookieAuthTests.cs @@ -19,18 +19,12 @@ using System.Net.Http.Headers; using System.Text.Json; using Listenarr.Api.Controllers; -using Listenarr.Application.Interfaces; -using Listenarr.Application.Security; -using Listenarr.Domain.Models; using Listenarr.Tests.Common; using Listenarr.Tests.Mocks; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Testing; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; -using Moq; -using Xunit; namespace Listenarr.Tests.Features.Api { diff --git a/tests/Features/Api/Utils/FinalizePathHelperTests.cs b/tests/Features/Api/Utils/FinalizePathHelperTests.cs index 78adcecfe..6a1270c55 100644 --- a/tests/Features/Api/Utils/FinalizePathHelperTests.cs +++ b/tests/Features/Api/Utils/FinalizePathHelperTests.cs @@ -15,10 +15,6 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -using Listenarr.Domain.Common; -using Xunit; -using Listenarr.Domain.Models.Configurations; -using Listenarr.Application.Common; using Listenarr.Tests.Builders; namespace Listenarr.Tests.Features.Api.Utils diff --git a/tests/Features/Application/Audiobooks/SeriesMonitoringServiceTests.cs b/tests/Features/Application/Audiobooks/SeriesMonitoringServiceTests.cs index bd7550ac5..33eab81fb 100644 --- a/tests/Features/Application/Audiobooks/SeriesMonitoringServiceTests.cs +++ b/tests/Features/Application/Audiobooks/SeriesMonitoringServiceTests.cs @@ -15,13 +15,9 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -using Listenarr.Application.Interfaces; using Listenarr.Application.Interfaces.Repositories; using Listenarr.Tests.Builders; using Listenarr.Tests.Common; -using Microsoft.Extensions.DependencyInjection; -using Moq; -using Xunit; namespace Listenarr.Tests.Features.Application.Audiobooks { diff --git a/tests/Features/Application/Downloads/DownloadClientGatewayTests.cs b/tests/Features/Application/Downloads/DownloadClientGatewayTests.cs index 42cfbe971..60b5ced06 100644 --- a/tests/Features/Application/Downloads/DownloadClientGatewayTests.cs +++ b/tests/Features/Application/Downloads/DownloadClientGatewayTests.cs @@ -1,12 +1,7 @@ using Listenarr.Application.Downloads; -using Listenarr.Application.Interfaces; -using Listenarr.Domain.Common; -using Listenarr.Domain.Models; using Listenarr.Tests.Builders; using Listenarr.Tests.Common; using Listenarr.Tests.Mocks; -using Microsoft.Extensions.DependencyInjection; -using Xunit; namespace Listenarr.Tests.Features.Application.Downloads { diff --git a/tests/Features/Application/Downloads/DownloadClientUriBuilderTests.cs b/tests/Features/Application/Downloads/DownloadClientUriBuilderTests.cs index 22fa196ec..21112c798 100644 --- a/tests/Features/Application/Downloads/DownloadClientUriBuilderTests.cs +++ b/tests/Features/Application/Downloads/DownloadClientUriBuilderTests.cs @@ -16,8 +16,6 @@ * along with this program. If not, see . */ using Listenarr.Application.Downloads; -using Listenarr.Domain.Models; -using Xunit; namespace Listenarr.Tests.Features.Application.Downloads { diff --git a/tests/Features/Application/Downloads/DownloadImportServiceTests.cs b/tests/Features/Application/Downloads/DownloadImportServiceTests.cs index 2ce966677..b2f958fc3 100644 --- a/tests/Features/Application/Downloads/DownloadImportServiceTests.cs +++ b/tests/Features/Application/Downloads/DownloadImportServiceTests.cs @@ -15,15 +15,10 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -using Microsoft.Extensions.DependencyInjection; -using Xunit; using Listenarr.Tests.Common; using Listenarr.Tests.Builders; -using Listenarr.Application.Interfaces; using System.Runtime.InteropServices; using System.IO.Compression; -using Listenarr.Domain.Models; -using Listenarr.Domain.Models.Configurations; using Listenarr.Domain.Models.Enumerations; using Listenarr.Tests.Mocks; diff --git a/tests/Features/Application/Downloads/DownloadIntegrationTests.cs b/tests/Features/Application/Downloads/DownloadIntegrationTests.cs index 7e8dac60c..e0bac3807 100644 --- a/tests/Features/Application/Downloads/DownloadIntegrationTests.cs +++ b/tests/Features/Application/Downloads/DownloadIntegrationTests.cs @@ -15,13 +15,7 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -using Listenarr.Domain.Models; -using Moq; -using Xunit; using Listenarr.Tests.Common; -using Microsoft.Extensions.DependencyInjection; -using Listenarr.Application.Interfaces; -using Listenarr.Domain.Models.Configurations; using Listenarr.Domain.Models.Enumerations; using Listenarr.Tests.Builders; using Listenarr.Application.Downloads; diff --git a/tests/Features/Application/Downloads/DownloadMonitorServiceTests.cs b/tests/Features/Application/Downloads/DownloadMonitorServiceTests.cs index f1e52f9c6..577145072 100644 --- a/tests/Features/Application/Downloads/DownloadMonitorServiceTests.cs +++ b/tests/Features/Application/Downloads/DownloadMonitorServiceTests.cs @@ -1,11 +1,6 @@ using System.Reflection; -using Listenarr.Application.Downloads; -using Listenarr.Application.Interfaces; -using Listenarr.Domain.Models; using Listenarr.Tests.Builders; using Listenarr.Tests.Common; -using Microsoft.Extensions.DependencyInjection; -using Xunit; namespace Listenarr.Tests.Features.Application.Downloads { diff --git a/tests/Features/Application/Downloads/DownloadProcessingJobProcessorIntegrationTests.cs b/tests/Features/Application/Downloads/DownloadProcessingJobProcessorIntegrationTests.cs index d5e676659..4d70bcd1e 100644 --- a/tests/Features/Application/Downloads/DownloadProcessingJobProcessorIntegrationTests.cs +++ b/tests/Features/Application/Downloads/DownloadProcessingJobProcessorIntegrationTests.cs @@ -1,11 +1,8 @@ using Listenarr.Application.Downloads; -using Listenarr.Application.Interfaces; using Listenarr.Tests.Builders; using Listenarr.Tests.Common; using Listenarr.Tests.Mocks; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; -using Xunit; namespace Listenarr.Tests.Features.Application.Downloads { diff --git a/tests/Features/Application/Downloads/DownloadProcessingJobProcessorTests.cs b/tests/Features/Application/Downloads/DownloadProcessingJobProcessorTests.cs index e22285b24..67716ec60 100644 --- a/tests/Features/Application/Downloads/DownloadProcessingJobProcessorTests.cs +++ b/tests/Features/Application/Downloads/DownloadProcessingJobProcessorTests.cs @@ -1,11 +1,5 @@ -using Listenarr.Domain.Models; using Listenarr.Tests.Builders; using Listenarr.Tests.Common; -using Microsoft.Extensions.DependencyInjection; -using Xunit; -using Listenarr.Application.Interfaces; -using Listenarr.Application.Downloads; -using Moq; using Listenarr.Tests.Mocks; namespace Listenarr.Tests.Features.Application.Downloads diff --git a/tests/Features/Application/Downloads/DownloadProcessingJobServiceTests.cs b/tests/Features/Application/Downloads/DownloadProcessingJobServiceTests.cs index a0c982b8e..87ef7b42d 100644 --- a/tests/Features/Application/Downloads/DownloadProcessingJobServiceTests.cs +++ b/tests/Features/Application/Downloads/DownloadProcessingJobServiceTests.cs @@ -15,14 +15,9 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -using Listenarr.Application.Downloads; -using Listenarr.Application.Interfaces; -using Listenarr.Domain.Models; using Listenarr.Tests.Builders; using Listenarr.Tests.Common; using Listenarr.Tests.Mocks; -using Microsoft.Extensions.DependencyInjection; -using Xunit; namespace Listenarr.Tests.Features.Application.Downloads { diff --git a/tests/Features/Application/Downloads/DownloadServiceTests.cs b/tests/Features/Application/Downloads/DownloadServiceTests.cs index b8e0ac54d..c4670b518 100644 --- a/tests/Features/Application/Downloads/DownloadServiceTests.cs +++ b/tests/Features/Application/Downloads/DownloadServiceTests.cs @@ -1,13 +1,8 @@ using Listenarr.Application.Downloads; -using Listenarr.Application.Interfaces; using Listenarr.Application.Interfaces.Repositories; -using Listenarr.Domain.Models; using Listenarr.Tests.Builders; using Listenarr.Tests.Common; using Listenarr.Tests.Mocks; -using Microsoft.Extensions.DependencyInjection; -using Moq; -using Xunit; namespace Listenarr.Tests.Features.Application.Downloads { diff --git a/tests/Features/Application/Notifications/NotificationTests.cs b/tests/Features/Application/Notifications/NotificationTests.cs index 0a6c989a8..cd72124b0 100644 --- a/tests/Features/Application/Notifications/NotificationTests.cs +++ b/tests/Features/Application/Notifications/NotificationTests.cs @@ -1,11 +1,5 @@ -using Listenarr.Application.Downloads; -using Listenarr.Application.Interfaces; -using Listenarr.Domain.Models; using Listenarr.Tests.Builders; using Listenarr.Tests.Common; -using Microsoft.Extensions.DependencyInjection; -using Moq; -using Xunit; namespace Listenarr.Tests.Features.Application.Notifications { diff --git a/tests/Features/Domain/Common/AudiobookSeriesMembershipHelperTests.cs b/tests/Features/Domain/Common/AudiobookSeriesMembershipHelperTests.cs index 11efd1b38..984371e8d 100644 --- a/tests/Features/Domain/Common/AudiobookSeriesMembershipHelperTests.cs +++ b/tests/Features/Domain/Common/AudiobookSeriesMembershipHelperTests.cs @@ -16,10 +16,6 @@ * along with this program. If not, see . */ -using Listenarr.Domain.Common; -using Listenarr.Domain.Models; -using Xunit; - namespace Listenarr.Tests.Features.Domain.Common { [Trait("Name", "AudiobookSeriesMembershipHelperTests")] diff --git a/tests/Features/Domain/Models/DownloadClientItemTests.cs b/tests/Features/Domain/Models/DownloadClientItemTests.cs index 38df4bd9d..712dcc928 100644 --- a/tests/Features/Domain/Models/DownloadClientItemTests.cs +++ b/tests/Features/Domain/Models/DownloadClientItemTests.cs @@ -16,9 +16,6 @@ * along with this program. If not, see . */ -using Xunit; -using Listenarr.Domain.Models; - namespace Listenarr.Tests.Features.Domain.Models { /// diff --git a/tests/Features/Domain/Models/DownloadProcessingJobTests.cs b/tests/Features/Domain/Models/DownloadProcessingJobTests.cs index a06c71690..e7d7318c7 100644 --- a/tests/Features/Domain/Models/DownloadProcessingJobTests.cs +++ b/tests/Features/Domain/Models/DownloadProcessingJobTests.cs @@ -1,7 +1,5 @@ -using Listenarr.Domain.Models; using Listenarr.Tests.Builders; using Listenarr.Tests.Common; -using Xunit; namespace Listenarr.Tests.Features.Domain.Models { diff --git a/tests/Features/Domain/Utils/FileUtilsTests.cs b/tests/Features/Domain/Utils/FileUtilsTests.cs index 4642aa3a2..ffcd75938 100644 --- a/tests/Features/Domain/Utils/FileUtilsTests.cs +++ b/tests/Features/Domain/Utils/FileUtilsTests.cs @@ -15,10 +15,8 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -using Xunit; using System.Security.AccessControl; using System.Security.Principal; -using Listenarr.Domain.Common; namespace Listenarr.Tests.Features.Domain.Utils { diff --git a/tests/Features/Domain/Utils/TitleMatchingServiceTests.cs b/tests/Features/Domain/Utils/TitleMatchingServiceTests.cs index b5eea766f..7066c3aa1 100644 --- a/tests/Features/Domain/Utils/TitleMatchingServiceTests.cs +++ b/tests/Features/Domain/Utils/TitleMatchingServiceTests.cs @@ -15,9 +15,6 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -using Xunit; -using Listenarr.Domain.Common; - namespace Listenarr.Tests.Features.Domain.Utils { public class TitleMatchingServiceTests diff --git a/tests/Features/Infrastructure/Adapters/DownloadClientAdapterTests.cs b/tests/Features/Infrastructure/Adapters/DownloadClientAdapterTests.cs index 3f97da73a..9a3f6030d 100644 --- a/tests/Features/Infrastructure/Adapters/DownloadClientAdapterTests.cs +++ b/tests/Features/Infrastructure/Adapters/DownloadClientAdapterTests.cs @@ -15,12 +15,9 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -using Listenarr.Domain.Models; -using Listenarr.Domain.Common; using Listenarr.Tests.Builders; using Listenarr.Tests.Common; using Listenarr.Tests.Mocks.Api; -using Xunit; namespace Listenarr.Tests.Features.Infrastructure.Adapters { diff --git a/tests/Features/Infrastructure/Adapters/NzbgetAdapterTests.cs b/tests/Features/Infrastructure/Adapters/NzbgetAdapterTests.cs index 2a7ba3957..db1bb6ca5 100644 --- a/tests/Features/Infrastructure/Adapters/NzbgetAdapterTests.cs +++ b/tests/Features/Infrastructure/Adapters/NzbgetAdapterTests.cs @@ -16,13 +16,9 @@ * along with this program. If not, see . */ using System.Net; -using Listenarr.Application.Interfaces; -using Listenarr.Domain.Models; using Listenarr.Infrastructure.Adapters; using Listenarr.Tests.Common; using Microsoft.Extensions.Logging.Abstractions; -using Moq; -using Xunit; namespace Listenarr.Tests.Features.Infrastructure.Adapters { diff --git a/tests/Features/Infrastructure/Adapters/QbittorrentAdapterTests.cs b/tests/Features/Infrastructure/Adapters/QbittorrentAdapterTests.cs index 1380ae4ff..9404dff89 100644 --- a/tests/Features/Infrastructure/Adapters/QbittorrentAdapterTests.cs +++ b/tests/Features/Infrastructure/Adapters/QbittorrentAdapterTests.cs @@ -16,15 +16,9 @@ * along with this program. If not, see . */ using System.Text.Json; -using Listenarr.Domain.Models; -using Listenarr.Domain.Common; using Listenarr.Tests.Common; -using Moq; -using Xunit; using Listenarr.Infrastructure.Adapters; using Listenarr.Tests.Builders; -using Microsoft.Extensions.DependencyInjection; -using Listenarr.Application.Interfaces; using Listenarr.Tests.Mocks.Api; using Listenarr.Application.Downloads; using Listenarr.Infrastructure.Torrents; diff --git a/tests/Features/Infrastructure/Adapters/QbittorrentHelpersTests.cs b/tests/Features/Infrastructure/Adapters/QbittorrentHelpersTests.cs index 5542f0e7b..28f7cc83e 100644 --- a/tests/Features/Infrastructure/Adapters/QbittorrentHelpersTests.cs +++ b/tests/Features/Infrastructure/Adapters/QbittorrentHelpersTests.cs @@ -16,7 +16,6 @@ * along with this program. If not, see . */ using Listenarr.Infrastructure.Adapters; -using Xunit; namespace Listenarr.Tests.Features.Infrastructure.Adapters { diff --git a/tests/Features/Infrastructure/Adapters/SabnzbdAdapterTests.cs b/tests/Features/Infrastructure/Adapters/SabnzbdAdapterTests.cs index 90f2e61ed..1b1e59bf8 100644 --- a/tests/Features/Infrastructure/Adapters/SabnzbdAdapterTests.cs +++ b/tests/Features/Infrastructure/Adapters/SabnzbdAdapterTests.cs @@ -15,14 +15,9 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -using Listenarr.Application.Downloads; -using Listenarr.Application.Interfaces; -using Listenarr.Domain.Models; using Listenarr.Tests.Builders; using Listenarr.Tests.Common; using Listenarr.Tests.Mocks.Api; -using Microsoft.Extensions.DependencyInjection; -using Xunit; namespace Listenarr.Tests.Features.Infrastructure.Adapters { diff --git a/tests/Features/Infrastructure/Adapters/TransmissionAdapterTests.cs b/tests/Features/Infrastructure/Adapters/TransmissionAdapterTests.cs index 4f31074cf..f3c774b5b 100644 --- a/tests/Features/Infrastructure/Adapters/TransmissionAdapterTests.cs +++ b/tests/Features/Infrastructure/Adapters/TransmissionAdapterTests.cs @@ -16,14 +16,9 @@ * along with this program. If not, see . */ using System.Runtime.InteropServices; -using Listenarr.Domain.Models; -using Listenarr.Domain.Common; using Listenarr.Tests.Builders; using Listenarr.Tests.Common; using Listenarr.Tests.Mocks.Api; -using Microsoft.Extensions.DependencyInjection; -using Moq; -using Xunit; using Listenarr.Infrastructure.Torrents; namespace Listenarr.Tests.Features.Infrastructure.Adapters diff --git a/tests/Features/Infrastructure/Adapters/UsenetAdapterFilteringTests.cs b/tests/Features/Infrastructure/Adapters/UsenetAdapterFilteringTests.cs index c936c5953..bb2c8a584 100644 --- a/tests/Features/Infrastructure/Adapters/UsenetAdapterFilteringTests.cs +++ b/tests/Features/Infrastructure/Adapters/UsenetAdapterFilteringTests.cs @@ -17,13 +17,9 @@ */ using System.Net; using System.Text; -using Listenarr.Application.Interfaces; -using Listenarr.Domain.Models; using Listenarr.Infrastructure.Adapters; using Listenarr.Tests.Common; using Microsoft.Extensions.Logging.Abstractions; -using Moq; -using Xunit; namespace Listenarr.Tests.Features.Infrastructure.Adapters { diff --git a/tests/Features/Infrastructure/Cache/ImageCacheServiceTests.cs b/tests/Features/Infrastructure/Cache/ImageCacheServiceTests.cs index 408d4709d..935095377 100644 --- a/tests/Features/Infrastructure/Cache/ImageCacheServiceTests.cs +++ b/tests/Features/Infrastructure/Cache/ImageCacheServiceTests.cs @@ -15,12 +15,8 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -using Listenarr.Application.Interfaces; using Listenarr.Infrastructure.Cache; using Listenarr.Tests.Common; -using Microsoft.Extensions.Logging; -using Moq; -using Xunit; namespace Listenarr.Tests.Features.Infrastructure.Cache { diff --git a/tests/Features/Infrastructure/Converters/JsonValueConvertersTests.cs b/tests/Features/Infrastructure/Converters/JsonValueConvertersTests.cs index e4b215660..3e4b4ce2f 100644 --- a/tests/Features/Infrastructure/Converters/JsonValueConvertersTests.cs +++ b/tests/Features/Infrastructure/Converters/JsonValueConvertersTests.cs @@ -16,7 +16,6 @@ * along with this program. If not, see . */ // csharp -using Xunit; using Listenarr.Infrastructure.Persistence.Converters; namespace Listenarr.Tests.Features.Infrastructure.Converters diff --git a/tests/Features/Infrastructure/Extensions/DependencyInjectionTests.cs b/tests/Features/Infrastructure/Extensions/DependencyInjectionTests.cs index 33b56a63e..b66664742 100644 --- a/tests/Features/Infrastructure/Extensions/DependencyInjectionTests.cs +++ b/tests/Features/Infrastructure/Extensions/DependencyInjectionTests.cs @@ -16,9 +16,7 @@ * along with this program. If not, see . */ // csharp -using Microsoft.Extensions.DependencyInjection; using Microsoft.EntityFrameworkCore; -using Xunit; using Listenarr.Infrastructure.Extensions; using Listenarr.Application.Interfaces.Repositories; diff --git a/tests/Features/Infrastructure/Extensions/InfrastructureServiceRegistrationExtensionsTests.cs b/tests/Features/Infrastructure/Extensions/InfrastructureServiceRegistrationExtensionsTests.cs index c0728e482..4ea000e3b 100644 --- a/tests/Features/Infrastructure/Extensions/InfrastructureServiceRegistrationExtensionsTests.cs +++ b/tests/Features/Infrastructure/Extensions/InfrastructureServiceRegistrationExtensionsTests.cs @@ -15,10 +15,7 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -using Microsoft.Extensions.DependencyInjection; -using Xunit; using Listenarr.Infrastructure.Extensions; -using Listenarr.Application.Interfaces; using Listenarr.Infrastructure.Cache; using Listenarr.Infrastructure.Platform; using Microsoft.Extensions.Http; diff --git a/tests/Features/Infrastructure/Migrations/MigrationMetadataTests.cs b/tests/Features/Infrastructure/Migrations/MigrationMetadataTests.cs index 29e691e4e..7ff9242d2 100644 --- a/tests/Features/Infrastructure/Migrations/MigrationMetadataTests.cs +++ b/tests/Features/Infrastructure/Migrations/MigrationMetadataTests.cs @@ -18,7 +18,6 @@ using System.Reflection; using Listenarr.Infrastructure.Persistence.Migrations; using Microsoft.EntityFrameworkCore.Migrations; -using Xunit; namespace Listenarr.Tests.Features.Infrastructure.Migrations { diff --git a/tests/Features/Infrastructure/Persistence/DatabaseIsolationTests.cs b/tests/Features/Infrastructure/Persistence/DatabaseIsolationTests.cs index 75c3e78d8..5220dec93 100644 --- a/tests/Features/Infrastructure/Persistence/DatabaseIsolationTests.cs +++ b/tests/Features/Infrastructure/Persistence/DatabaseIsolationTests.cs @@ -18,8 +18,6 @@ using Listenarr.Tests.Mocks; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Xunit; namespace Listenarr.Tests.Features.Infrastructure.Persistence { diff --git a/tests/Features/Infrastructure/Platform/ApplicationVersionServiceTests.cs b/tests/Features/Infrastructure/Platform/ApplicationVersionServiceTests.cs index 06dbeb1ec..a12e9a333 100644 --- a/tests/Features/Infrastructure/Platform/ApplicationVersionServiceTests.cs +++ b/tests/Features/Infrastructure/Platform/ApplicationVersionServiceTests.cs @@ -19,8 +19,6 @@ using Listenarr.Infrastructure.Platform; using Listenarr.Tests.Common; using Microsoft.Extensions.Hosting; -using Moq; -using Xunit; namespace Listenarr.Tests.Features.Infrastructure.Platform { diff --git a/tests/Features/Infrastructure/Platform/DiskSpaceProbeTests.cs b/tests/Features/Infrastructure/Platform/DiskSpaceProbeTests.cs index d4dd35d64..f98c40bfa 100644 --- a/tests/Features/Infrastructure/Platform/DiskSpaceProbeTests.cs +++ b/tests/Features/Infrastructure/Platform/DiskSpaceProbeTests.cs @@ -19,7 +19,6 @@ using Listenarr.Infrastructure.Platform; using Listenarr.Tests.Common; using Microsoft.Extensions.Logging.Abstractions; -using Xunit; namespace Listenarr.Tests.Features.Infrastructure.Platform { diff --git a/tests/Features/Infrastructure/Platform/SystemServiceStorageTests.cs b/tests/Features/Infrastructure/Platform/SystemServiceStorageTests.cs index 9ce9e8538..4f47f49c6 100644 --- a/tests/Features/Infrastructure/Platform/SystemServiceStorageTests.cs +++ b/tests/Features/Infrastructure/Platform/SystemServiceStorageTests.cs @@ -16,14 +16,10 @@ * along with this program. If not, see . */ -using Listenarr.Application.Interfaces; using Listenarr.Infrastructure.Platform; using Listenarr.Tests.Builders; using Listenarr.Tests.Common; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; -using Moq; -using Xunit; namespace Listenarr.Tests.Features.Infrastructure.Platform { diff --git a/tests/Features/Infrastructure/Platform/SystemServiceVersionTests.cs b/tests/Features/Infrastructure/Platform/SystemServiceVersionTests.cs index 0925cd293..0eae91468 100644 --- a/tests/Features/Infrastructure/Platform/SystemServiceVersionTests.cs +++ b/tests/Features/Infrastructure/Platform/SystemServiceVersionTests.cs @@ -16,14 +16,9 @@ * along with this program. If not, see . */ -using Listenarr.Application.Interfaces; -using Listenarr.Domain.Models; -using Listenarr.Domain.Models.Configurations; using Listenarr.Infrastructure.Platform; using Listenarr.Tests.Common; using Microsoft.Extensions.Logging.Abstractions; -using Moq; -using Xunit; namespace Listenarr.Tests.Features.Infrastructure.Platform { diff --git a/tests/Features/Infrastructure/Repositories/AudiobookRepositoryTests.cs b/tests/Features/Infrastructure/Repositories/AudiobookRepositoryTests.cs index f8059258f..091021bcf 100644 --- a/tests/Features/Infrastructure/Repositories/AudiobookRepositoryTests.cs +++ b/tests/Features/Infrastructure/Repositories/AudiobookRepositoryTests.cs @@ -16,7 +16,6 @@ * along with this program. If not, see . */ using Microsoft.EntityFrameworkCore; -using Xunit; using Listenarr.Infrastructure.Persistence; using Listenarr.Tests.Builders; diff --git a/tests/Features/Infrastructure/Repositories/DownloadHistoryRepositoryTests.cs b/tests/Features/Infrastructure/Repositories/DownloadHistoryRepositoryTests.cs index f6a326188..6a21cd3d5 100644 --- a/tests/Features/Infrastructure/Repositories/DownloadHistoryRepositoryTests.cs +++ b/tests/Features/Infrastructure/Repositories/DownloadHistoryRepositoryTests.cs @@ -16,11 +16,9 @@ * along with this program. If not, see . */ -using Listenarr.Domain.Models; using Listenarr.Infrastructure.Persistence; using Listenarr.Infrastructure.Persistence.Repositories; using Microsoft.EntityFrameworkCore; -using Xunit; namespace Listenarr.Tests.Features.Infrastructure.Repositories { diff --git a/tests/Features/Infrastructure/Repositories/DownloadProcessingJobRepositoryTests.cs b/tests/Features/Infrastructure/Repositories/DownloadProcessingJobRepositoryTests.cs index f51938fe3..0139e3f78 100644 --- a/tests/Features/Infrastructure/Repositories/DownloadProcessingJobRepositoryTests.cs +++ b/tests/Features/Infrastructure/Repositories/DownloadProcessingJobRepositoryTests.cs @@ -3,7 +3,6 @@ using Listenarr.Tests.Common; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; -using Xunit; namespace Listenarr.Tests.Features.Infrastructure.Repositories { diff --git a/tests/Features/Infrastructure/Services/ApplicationPathServiceTests.cs b/tests/Features/Infrastructure/Services/ApplicationPathServiceTests.cs index f8c1884c7..ab544a30f 100644 --- a/tests/Features/Infrastructure/Services/ApplicationPathServiceTests.cs +++ b/tests/Features/Infrastructure/Services/ApplicationPathServiceTests.cs @@ -17,7 +17,6 @@ */ using Listenarr.Infrastructure.Services; -using Xunit; namespace Listenarr.Tests.Features.Infrastructure.Services { diff --git a/tests/Features/Infrastructure/Services/DownloadHistoryServiceTests.cs b/tests/Features/Infrastructure/Services/DownloadHistoryServiceTests.cs index 1e7ad8d00..6308bdab8 100644 --- a/tests/Features/Infrastructure/Services/DownloadHistoryServiceTests.cs +++ b/tests/Features/Infrastructure/Services/DownloadHistoryServiceTests.cs @@ -16,13 +16,9 @@ * along with this program. If not, see . */ -using Listenarr.Domain.Models; using Listenarr.Infrastructure.Persistence; using Listenarr.Infrastructure.Services; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using Moq; -using Xunit; namespace Listenarr.Tests.Features.Infrastructure.Services { diff --git a/tests/Features/Infrastructure/Services/RemotePathMappingServiceTests.cs b/tests/Features/Infrastructure/Services/RemotePathMappingServiceTests.cs index 2d241b632..e1bd51260 100644 --- a/tests/Features/Infrastructure/Services/RemotePathMappingServiceTests.cs +++ b/tests/Features/Infrastructure/Services/RemotePathMappingServiceTests.cs @@ -1,11 +1,6 @@ using System.Runtime.InteropServices; -using Listenarr.Application.Interfaces; -using Listenarr.Domain.Common; -using Listenarr.Domain.Models; using Listenarr.Tests.Builders; using Listenarr.Tests.Common; -using Microsoft.Extensions.DependencyInjection; -using Xunit; namespace Listenarr.Tests.Features.Infrastructure.Services { diff --git a/tests/Mocks/Api/NzbgetApiMock.cs b/tests/Mocks/Api/NzbgetApiMock.cs index f3c10a633..9b3d30471 100644 --- a/tests/Mocks/Api/NzbgetApiMock.cs +++ b/tests/Mocks/Api/NzbgetApiMock.cs @@ -1,4 +1,3 @@ -using Listenarr.Domain.Common; using Listenarr.Tests.Common; namespace Listenarr.Tests.Mocks.Api diff --git a/tests/Mocks/Api/SabnzbdApiMock.cs b/tests/Mocks/Api/SabnzbdApiMock.cs index 84e7f1897..c23941e27 100644 --- a/tests/Mocks/Api/SabnzbdApiMock.cs +++ b/tests/Mocks/Api/SabnzbdApiMock.cs @@ -1,5 +1,4 @@ using System.Web; -using Listenarr.Domain.Common; using Listenarr.Tests.Common; namespace Listenarr.Tests.Mocks.Api diff --git a/tests/Mocks/Api/TransmissionApiMock.cs b/tests/Mocks/Api/TransmissionApiMock.cs index e2e0fc420..2b57e251c 100644 --- a/tests/Mocks/Api/TransmissionApiMock.cs +++ b/tests/Mocks/Api/TransmissionApiMock.cs @@ -1,5 +1,4 @@ using System.Text.Json; -using Listenarr.Domain.Common; using Listenarr.Tests.Common; namespace Listenarr.Tests.Mocks.Api diff --git a/tests/Mocks/DownloadClientAdapterMock.cs b/tests/Mocks/DownloadClientAdapterMock.cs index 6817e644e..e7691c280 100644 --- a/tests/Mocks/DownloadClientAdapterMock.cs +++ b/tests/Mocks/DownloadClientAdapterMock.cs @@ -1,7 +1,4 @@ -using Listenarr.Application.Interfaces; using Listenarr.Application.Interfaces.Repositories; -using Listenarr.Domain.Common; -using Listenarr.Domain.Models; using Listenarr.Tests.Builders; namespace Listenarr.Tests.Mocks diff --git a/tests/Mocks/DownloadClientGatewayMock.cs b/tests/Mocks/DownloadClientGatewayMock.cs index 4d6168e15..68e5e2e2c 100644 --- a/tests/Mocks/DownloadClientGatewayMock.cs +++ b/tests/Mocks/DownloadClientGatewayMock.cs @@ -15,9 +15,6 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -using Listenarr.Application.Interfaces; -using Listenarr.Domain.Models; - namespace Listenarr.Tests.Mocks { /// diff --git a/tests/Mocks/FfmpegServiceMock.cs b/tests/Mocks/FfmpegServiceMock.cs index 52805b22f..4ed44b458 100644 --- a/tests/Mocks/FfmpegServiceMock.cs +++ b/tests/Mocks/FfmpegServiceMock.cs @@ -1,5 +1,3 @@ -using Listenarr.Application.Interfaces; -using Listenarr.Domain.Models; using Listenarr.Tests.Builders; using Listenarr.Tests.Common; diff --git a/tests/Mocks/ListenarrWebApplicationFactory.cs b/tests/Mocks/ListenarrWebApplicationFactory.cs index 8fbae2c07..ab15e920a 100644 --- a/tests/Mocks/ListenarrWebApplicationFactory.cs +++ b/tests/Mocks/ListenarrWebApplicationFactory.cs @@ -15,15 +15,10 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -using Listenarr.Application.Interfaces; -using Listenarr.Domain.Models; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; -using Microsoft.Extensions.Logging; -using Moq; using System.Collections.Concurrent; using System.Diagnostics; diff --git a/tests/Mocks/MetadataServiceMock.cs b/tests/Mocks/MetadataServiceMock.cs index d3415010e..dbc005b9a 100644 --- a/tests/Mocks/MetadataServiceMock.cs +++ b/tests/Mocks/MetadataServiceMock.cs @@ -1,6 +1,4 @@ using System.Text.RegularExpressions; -using Listenarr.Application.Interfaces; -using Listenarr.Domain.Models; using Listenarr.Tests.Builders; namespace Listenarr.Tests.Mocks diff --git a/tests/Mocks/StartupConfigServiceMock.cs b/tests/Mocks/StartupConfigServiceMock.cs index a355674df..ba8c6380a 100644 --- a/tests/Mocks/StartupConfigServiceMock.cs +++ b/tests/Mocks/StartupConfigServiceMock.cs @@ -1,7 +1,3 @@ -using Listenarr.Application.Interfaces; -using Listenarr.Domain.Common; -using Listenarr.Domain.Models; - namespace Listenarr.Tests.Mocks { /// From d2ca71d4104e40a3f6b1e0db725349cd7dff1fe0 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Thu, 11 Jun 2026 14:05:24 -0400 Subject: [PATCH 05/84] Clarify realtime architecture boundary --- .../Configurations/SettingsController.cs | 11 ++-- .../Controllers/LibraryController.cs | 20 +++---- .../Controllers/ProwlarrCompatController.cs | 21 ++++--- listenarr.api/GlobalUsings.cs | 1 - listenarr.api/Program.cs | 1 + .../Interfaces/IHubBroadcaster.cs | 7 +++ .../Interfaces/IRealtimeClientRegistry.cs | 25 ++++++++ .../ServiceRegistrationExtensions.cs | 1 + .../SignalR/SignalRClientRegistry.cs | 30 ++++++++++ .../SignalR/SignalRHubBroadcaster.cs | 35 ++++++++++-- .../ConfigurationControllerSettingsTests.cs | 3 +- .../ProwlarrCompatControllerTests.cs | 57 +++++++------------ tests/Mocks/NoopHubBroadcaster.cs | 6 ++ 13 files changed, 148 insertions(+), 70 deletions(-) create mode 100644 listenarr.application/Interfaces/IRealtimeClientRegistry.cs create mode 100644 listenarr.infrastructure/SignalR/SignalRClientRegistry.cs diff --git a/listenarr.api/Controllers/Configurations/SettingsController.cs b/listenarr.api/Controllers/Configurations/SettingsController.cs index a64c5eafa..820754bed 100644 --- a/listenarr.api/Controllers/Configurations/SettingsController.cs +++ b/listenarr.api/Controllers/Configurations/SettingsController.cs @@ -33,16 +33,16 @@ public class SettingsController : ControllerBase { private readonly IConfigurationService _configurationService; private readonly ILogger _logger; - private readonly IHubContext _settingsHub; + private readonly IHubBroadcaster _hubBroadcaster; public SettingsController( IConfigurationService configurationService, ILogger logger, - IHubContext settingsHub) + IHubBroadcaster hubBroadcaster) { _configurationService = configurationService; _logger = logger; - _settingsHub = settingsHub; + _hubBroadcaster = hubBroadcaster; } /// @@ -86,7 +86,10 @@ public async Task> SaveApplicationSettings([Fr savedSettings.AdminUsername = null; savedSettings.AdminPassword = null; - await _settingsHub.Clients.All.SendAsync("SettingsUpdated", ApiResponseRedactor.RedactApplicationSettings(savedSettings)); + await _hubBroadcaster.BroadcastAsync( + RealtimeHubTarget.Settings, + "SettingsUpdated", + ApiResponseRedactor.RedactApplicationSettings(savedSettings)); _logger.LogDebug("Application settings saved successfully and broadcasted via SignalR"); if (HttpSecurityRequestUtils.ShouldRedactSecretsForCaller(HttpContext)) diff --git a/listenarr.api/Controllers/LibraryController.cs b/listenarr.api/Controllers/LibraryController.cs index e7ed8f3ba..0e846d00b 100644 --- a/listenarr.api/Controllers/LibraryController.cs +++ b/listenarr.api/Controllers/LibraryController.cs @@ -2317,9 +2317,9 @@ public async Task ScanAudiobookFiles(int id, [FromBody] ScanReque try { using var scope = _scopeFactory.CreateScope(); - var hub = scope.ServiceProvider.GetRequiredService>(); + var hub = scope.ServiceProvider.GetRequiredService(); var job = new { jobId = jobId.ToString(), audiobookId = id, status = "Queued", enqueuedAt = DateTime.UtcNow }; - await hub.Clients.All.SendAsync("ScanJobUpdate", job); + await hub.BroadcastAsync(RealtimeHubTarget.Downloads, "ScanJobUpdate", job); } catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) { @@ -2906,9 +2906,9 @@ normalizeEx is ArgumentException try { using var hubScope = _scopeFactory.CreateScope(); - var hub = hubScope.ServiceProvider.GetRequiredService>(); + var hub = hubScope.ServiceProvider.GetRequiredService(); var job = new { jobId = jobId.ToString(), audiobookId = id, status = "Queued", enqueuedAt = DateTime.UtcNow }; - await hub.Clients.All.SendAsync("MoveJobUpdate", job); + await hub.BroadcastAsync(RealtimeHubTarget.Downloads, "MoveJobUpdate", job); } catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) { @@ -2961,9 +2961,9 @@ public async Task RequeueMoveJob(string jobId) try { using var scope = _scopeFactory.CreateScope(); - var hub = scope.ServiceProvider.GetRequiredService>(); + var hub = scope.ServiceProvider.GetRequiredService(); var job = new { jobId = newJobId.ToString(), status = "Queued", enqueuedAt = DateTime.UtcNow }; - await hub.Clients.All.SendAsync("MoveJobUpdate", job); + await hub.BroadcastAsync(RealtimeHubTarget.Downloads, "MoveJobUpdate", job); } catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) { @@ -2993,9 +2993,9 @@ public async Task RequeueScanJob(string jobId) try { using var scope = _scopeFactory.CreateScope(); - var hub = scope.ServiceProvider.GetRequiredService>(); + var hub = scope.ServiceProvider.GetRequiredService(); var job = new { jobId = newJobId.ToString(), status = "Queued", enqueuedAt = DateTime.UtcNow }; - await hub.Clients.All.SendAsync("ScanJobUpdate", job); + await hub.BroadcastAsync(RealtimeHubTarget.Downloads, "ScanJobUpdate", job); } catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) { @@ -3043,9 +3043,9 @@ private async Task ProcessAudiobookForSearchAsync( }).ToList(); using var scope = _scopeFactory.CreateScope(); - var hub = scope.ServiceProvider.GetRequiredService>(); + var hub = scope.ServiceProvider.GetRequiredService(); // Include a structured payload so clients can distinguish manual vs automatic searches - await hub.Clients.All.SendCoreAsync("SearchProgress", new object[] { new { message = $"Manual search query: {searchQuery}", details = new { rawCount = searchResults.Count, rawSamples = rawSummaries }, type = "interactive", audiobookId = audiobook.Id } }); + await hub.BroadcastAsync(RealtimeHubTarget.Downloads, "SearchProgress", new { message = $"Manual search query: {searchQuery}", details = new { rawCount = searchResults.Count, rawSamples = rawSummaries }, type = "interactive", audiobookId = audiobook.Id }); } catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) { diff --git a/listenarr.api/Controllers/ProwlarrCompatController.cs b/listenarr.api/Controllers/ProwlarrCompatController.cs index 55dfbba44..b623c8d50 100644 --- a/listenarr.api/Controllers/ProwlarrCompatController.cs +++ b/listenarr.api/Controllers/ProwlarrCompatController.cs @@ -49,7 +49,8 @@ private StartupConfig GetStartupConfig() private readonly ILogger _logger; private readonly IIndexerRepository _indexerRepository; - private readonly IHubContext _settingsHub; + private readonly IHubBroadcaster _hubBroadcaster; + private readonly IRealtimeClientRegistry _realtimeClientRegistry; private readonly IToastService _toastService; private readonly IStartupConfigService _startupConfigService; private readonly IApplicationVersionService _applicationVersionService; @@ -107,14 +108,16 @@ private static bool ShouldSendToastForMessage(string message) public ProwlarrCompatController( ILogger logger, IIndexerRepository indexerRepository, - IHubContext settingsHub, + IHubBroadcaster hubBroadcaster, + IRealtimeClientRegistry realtimeClientRegistry, IToastService toastService, IStartupConfigService startupConfigService, IApplicationVersionService applicationVersionService) { _logger = logger; _indexerRepository = indexerRepository; - _settingsHub = settingsHub; + _hubBroadcaster = hubBroadcaster; + _realtimeClientRegistry = realtimeClientRegistry; _toastService = toastService; _startupConfigService = startupConfigService; _applicationVersionService = applicationVersionService; @@ -381,7 +384,7 @@ public async Task DeleteIndexer(int id) _logger?.LogInformation("Prowlarr: Deleted indexer {Id} (name={Name})", i.Id, i.Name); try { - await _settingsHub.Clients.All.SendAsync("IndexersUpdated", new { created = 0, skipped = 0, indexers = new[] { new { id = i.Id, name = i.Name, baseUrl = i.Url } } }); + await _hubBroadcaster.BroadcastAsync(RealtimeHubTarget.Settings, "IndexersUpdated", new { created = 0, skipped = 0, indexers = new[] { new { id = i.Id, name = i.Name, baseUrl = i.Url } } }); var deleteMessage = $"Removed indexer: {i.Name}"; if (ShouldSendToastForIndexer(i.Id, deleteMessage) && ShouldSendToastForMessage(deleteMessage)) { @@ -649,7 +652,7 @@ public async Task PutIndexer(int id, [FromBody] System.Text.Json. { var stillExists = (await _indexerRepository.GetByIdAsync(indexer.Id)) != null; var createdForBroadcast = (created && stillExists) ? 1 : 0; - await _settingsHub.Clients.All.SendAsync("IndexersUpdated", new { created = createdForBroadcast, skipped = 0, indexers = new[] { new { id = indexer.Id, name = indexer.Name, baseUrl = indexer.Url } } }); + await _hubBroadcaster.BroadcastAsync(RealtimeHubTarget.Settings, "IndexersUpdated", new { created = createdForBroadcast, skipped = 0, indexers = new[] { new { id = indexer.Id, name = indexer.Name, baseUrl = indexer.Url } } }); // Determine toast message. If the indexer was created very recently (by a prior POST or PUT), // suppress an additional 'Updated' toast to avoid duplicate notifications for rapid import/update flows. @@ -978,7 +981,7 @@ string getString(System.Text.Json.JsonElement el, string prop1, string? prop2 = _logger?.LogInformation("Broadcasting IndexersUpdated to clients: created={Created}, skipped={Skipped}, indexerCount={Count}", created, skipped, createdInfo.Length); - await _settingsHub.Clients.All.SendAsync("IndexersUpdated", new { created, skipped, indexers = createdInfo }); + await _hubBroadcaster.BroadcastAsync(RealtimeHubTarget.Settings, "IndexersUpdated", new { created, skipped, indexers = createdInfo }); _logger?.LogInformation("IndexersUpdated broadcast complete"); @@ -1086,7 +1089,7 @@ public async Task DebugPublishIndexers([FromBody] System.Text.Jso _logger?.LogInformation("DEBUG: Broadcasting IndexersUpdated (manual test): created={Created}", created); - await _settingsHub.Clients.All.SendAsync("IndexersUpdated", new { created, skipped = 0, indexers }); + await _hubBroadcaster.BroadcastAsync(RealtimeHubTarget.Settings, "IndexersUpdated", new { created, skipped = 0, indexers }); _logger?.LogInformation("DEBUG: IndexersUpdated broadcast sent"); @@ -1117,8 +1120,8 @@ public IActionResult GetSettingsHubClients() { try { - var clients = SettingsHub.ConnectedClientIds.ToArray(); - return Ok(new { connected = clients.Length, clients }); + var clients = _realtimeClientRegistry.GetSettingsClientIds(); + return Ok(new { connected = clients.Count, clients }); } catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) { diff --git a/listenarr.api/GlobalUsings.cs b/listenarr.api/GlobalUsings.cs index 18a4e870f..ed030db27 100644 --- a/listenarr.api/GlobalUsings.cs +++ b/listenarr.api/GlobalUsings.cs @@ -1,5 +1,4 @@ global using Microsoft.AspNetCore.SignalR; global using Microsoft.Extensions.Hosting; -global using Listenarr.Infrastructure.SignalR; global using Listenarr.Api.Security; global using Listenarr.Api.Common; diff --git a/listenarr.api/Program.cs b/listenarr.api/Program.cs index 75708b963..49bcb86ad 100644 --- a/listenarr.api/Program.cs +++ b/listenarr.api/Program.cs @@ -28,6 +28,7 @@ using Serilog; using Serilog.Events; using Listenarr.Infrastructure.Extensions; +using Listenarr.Infrastructure.SignalR; using Listenarr.Application.Interfaces; using Listenarr.Application.Downloads; using Listenarr.Infrastructure.Persistence; diff --git a/listenarr.application/Interfaces/IHubBroadcaster.cs b/listenarr.application/Interfaces/IHubBroadcaster.cs index 28db75c2a..75dddddd2 100644 --- a/listenarr.application/Interfaces/IHubBroadcaster.cs +++ b/listenarr.application/Interfaces/IHubBroadcaster.cs @@ -19,9 +19,16 @@ namespace Listenarr.Application.Interfaces { + public enum RealtimeHubTarget + { + Downloads, + Settings + } + public interface IHubBroadcaster { Task BroadcastQueueUpdateAsync(QueueSnapshot queueSnapshot); Task BroadcastAsync(string eventName, object payload, CancellationToken cancellationToken = default); + Task BroadcastAsync(RealtimeHubTarget target, string eventName, object payload, CancellationToken cancellationToken = default); } } diff --git a/listenarr.application/Interfaces/IRealtimeClientRegistry.cs b/listenarr.application/Interfaces/IRealtimeClientRegistry.cs new file mode 100644 index 000000000..c8b7f04b6 --- /dev/null +++ b/listenarr.application/Interfaces/IRealtimeClientRegistry.cs @@ -0,0 +1,25 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace Listenarr.Application.Interfaces +{ + public interface IRealtimeClientRegistry + { + IReadOnlyCollection GetSettingsClientIds(); + } +} diff --git a/listenarr.infrastructure/Extensions/ServiceRegistrationExtensions.cs b/listenarr.infrastructure/Extensions/ServiceRegistrationExtensions.cs index 1de5f1ce4..e17a4a073 100644 --- a/listenarr.infrastructure/Extensions/ServiceRegistrationExtensions.cs +++ b/listenarr.infrastructure/Extensions/ServiceRegistrationExtensions.cs @@ -219,6 +219,7 @@ public static IServiceCollection AddListenarrAdapters(this IServiceCollection se // SignalR broadcaster abstraction used to centralize broadcast logic and simplify testing services.AddSingleton(); + services.AddSingleton(); return services; } diff --git a/listenarr.infrastructure/SignalR/SignalRClientRegistry.cs b/listenarr.infrastructure/SignalR/SignalRClientRegistry.cs new file mode 100644 index 000000000..8c3163722 --- /dev/null +++ b/listenarr.infrastructure/SignalR/SignalRClientRegistry.cs @@ -0,0 +1,30 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +using Listenarr.Application.Interfaces; + +namespace Listenarr.Infrastructure.SignalR +{ + public sealed class SignalRClientRegistry : IRealtimeClientRegistry + { + public IReadOnlyCollection GetSettingsClientIds() + { + return SettingsHub.ConnectedClientIds.ToArray(); + } + } +} diff --git a/listenarr.infrastructure/SignalR/SignalRHubBroadcaster.cs b/listenarr.infrastructure/SignalR/SignalRHubBroadcaster.cs index c8ce61242..d9c8c9538 100644 --- a/listenarr.infrastructure/SignalR/SignalRHubBroadcaster.cs +++ b/listenarr.infrastructure/SignalR/SignalRHubBroadcaster.cs @@ -22,12 +22,17 @@ namespace Listenarr.Infrastructure.SignalR { public class SignalRHubBroadcaster : IHubBroadcaster { - private readonly IHubContext _hubContext; + private readonly IHubContext _downloadHubContext; + private readonly IHubContext? _settingsHubContext; private readonly ILogger _logger; - public SignalRHubBroadcaster(IHubContext hubContext, ILogger logger) + public SignalRHubBroadcaster( + IHubContext downloadHubContext, + ILogger logger, + IHubContext? settingsHubContext = null) { - _hubContext = hubContext ?? throw new ArgumentNullException(nameof(hubContext)); + _downloadHubContext = downloadHubContext ?? throw new ArgumentNullException(nameof(downloadHubContext)); + _settingsHubContext = settingsHubContext; _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } @@ -36,7 +41,7 @@ public async Task BroadcastQueueUpdateAsync(Domain.Models.QueueSnapshot queueSna try { // Primary, public API - var clientProxy = _hubContext.Clients.All; + var clientProxy = _downloadHubContext.Clients.All; await clientProxy.SendAsync("QueueUpdate", queueSnapshot); // Some tests/mocks expect SendCoreAsync; call as a compatibility step @@ -56,14 +61,32 @@ public async Task BroadcastQueueUpdateAsync(Domain.Models.QueueSnapshot queueSna } public async Task BroadcastAsync(string eventName, object payload, CancellationToken cancellationToken = default) + { + await BroadcastAsync(RealtimeHubTarget.Downloads, eventName, payload, cancellationToken); + } + + public async Task BroadcastAsync(RealtimeHubTarget target, string eventName, object payload, CancellationToken cancellationToken = default) { try { - await _hubContext.Clients.All.SendAsync(eventName, payload, cancellationToken); + if (target == RealtimeHubTarget.Settings && _settingsHubContext is null) + { + _logger.LogWarning("Cannot broadcast {EventName} to {HubTarget} because the settings hub context is not registered", eventName, target); + return; + } + + var clientProxy = target switch + { + RealtimeHubTarget.Downloads => _downloadHubContext.Clients.All, + RealtimeHubTarget.Settings => _settingsHubContext!.Clients.All, + _ => _downloadHubContext.Clients.All + }; + + await clientProxy.SendAsync(eventName, payload, cancellationToken); } catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) { - _logger.LogWarning(ex, "Failed to broadcast {EventName}", eventName); + _logger.LogWarning(ex, "Failed to broadcast {EventName} to {HubTarget}", eventName, target); } } } diff --git a/tests/Features/Api/Controllers/ConfigurationControllerSettingsTests.cs b/tests/Features/Api/Controllers/ConfigurationControllerSettingsTests.cs index f30e416ae..0c01bdd4a 100644 --- a/tests/Features/Api/Controllers/ConfigurationControllerSettingsTests.cs +++ b/tests/Features/Api/Controllers/ConfigurationControllerSettingsTests.cs @@ -17,7 +17,6 @@ */ using Listenarr.Api.Controllers.Configurations; using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Logging.Abstractions; namespace Listenarr.Tests.Features.Api.Controllers @@ -42,7 +41,7 @@ public async Task GetApplicationSettings_DoesNotReturnEncryptedProwlarrApiKey() var controller = new SettingsController( configurationService.Object, NullLogger.Instance, - Mock.Of>()); + Mock.Of()); var result = await controller.GetApplicationSettings(); var ok = Assert.IsType(result.Result); diff --git a/tests/Features/Api/Controllers/ProwlarrCompatControllerTests.cs b/tests/Features/Api/Controllers/ProwlarrCompatControllerTests.cs index e3ebc6f4a..489844e41 100644 --- a/tests/Features/Api/Controllers/ProwlarrCompatControllerTests.cs +++ b/tests/Features/Api/Controllers/ProwlarrCompatControllerTests.cs @@ -15,7 +15,6 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -using Microsoft.AspNetCore.SignalR; using Listenarr.Api.Controllers; using Microsoft.EntityFrameworkCore; using System.Text.Json; @@ -40,22 +39,21 @@ private static IApplicationVersionService CreateApplicationVersionService() return Mock.Of(service => service.Resolve() == "0.4.2"); } + private static IRealtimeClientRegistry CreateRealtimeClientRegistry() + { + return Mock.Of(registry => registry.GetSettingsClientIds() == Array.Empty()); + } + [Fact] public async Task PostIndexers_BroadcastsSignalR_WhenNewIndexersCreated() { var db = CreateInMemoryDb(); - var mockClientProxy = new Mock(); - var mockHubClients = new Mock(); - mockHubClients.Setup(c => c.All).Returns(mockClientProxy.Object); - - var mockHubContext = new Mock>(); - mockHubContext.SetupGet(h => h.Clients).Returns(mockHubClients.Object); - + var mockHubBroadcaster = new Mock(); var mockLogger = new Mock>(); var mockToastService = new Mock(); var mockStartupConfigService = new Mock(); mockStartupConfigService.Setup(s => s.GetConfig()).Returns(new StartupConfig { AuthenticationRequired = "false" }); - var controller = new ProwlarrCompatController(mockLogger.Object, new EfIndexerRepository(db), mockHubContext.Object, mockToastService.Object, mockStartupConfigService.Object, CreateApplicationVersionService()); + var controller = new ProwlarrCompatController(mockLogger.Object, new EfIndexerRepository(db), mockHubBroadcaster.Object, CreateRealtimeClientRegistry(), mockToastService.Object, mockStartupConfigService.Object, CreateApplicationVersionService()); var newIndexer = new { name = "Unit Test Indexer", implementation = "Newznab", baseUrl = "http://localhost", apiPath = "api", apiKey = "KEY" }; var arr = JsonSerializer.Serialize(new[] { newIndexer }); @@ -71,9 +69,8 @@ public async Task PostIndexers_BroadcastsSignalR_WhenNewIndexersCreated() var payload = JsonDocument.Parse(arr).RootElement; _ = await controller.PostIndexers(payload); - // Verify that SendCoreAsync (SignalR) was invoked for the indexer update - mockClientProxy.Verify( - p => p.SendCoreAsync("IndexersUpdated", It.IsAny(), default), + mockHubBroadcaster.Verify( + b => b.BroadcastAsync(RealtimeHubTarget.Settings, "IndexersUpdated", It.IsAny(), It.IsAny()), Times.Once); // Verify a 'Created indexer' log entry exists @@ -94,16 +91,12 @@ public async Task PostIndexers_BroadcastsSignalR_WhenNewIndexersCreated() public async Task PostIndexer_ReturnsCreatedIndex_WhenSingleIndexerPosted() { var db = CreateInMemoryDb(); - var mockClientProxy = new Mock(); - var mockHubClients = new Mock(); - mockHubClients.Setup(c => c.All).Returns(mockClientProxy.Object); - var mockHubContext = new Mock>(); - mockHubContext.SetupGet(h => h.Clients).Returns(mockHubClients.Object); + var mockHubBroadcaster = new Mock(); var mockLogger = new Mock>(); var mockToastService = new Mock(); var mockStartupConfigService = new Mock(); mockStartupConfigService.Setup(s => s.GetConfig()).Returns(new StartupConfig { AuthenticationRequired = "false" }); - var controller = new ProwlarrCompatController(mockLogger.Object, new EfIndexerRepository(db), mockHubContext.Object, mockToastService.Object, mockStartupConfigService.Object, CreateApplicationVersionService()); + var controller = new ProwlarrCompatController(mockLogger.Object, new EfIndexerRepository(db), mockHubBroadcaster.Object, CreateRealtimeClientRegistry(), mockToastService.Object, mockStartupConfigService.Object, CreateApplicationVersionService()); var newIndexer = new { name = "Unit Test Indexer", implementation = "Newznab", baseUrl = "http://localhost", apiPath = "api", apiKey = "KEY" }; // Clear static toast maps to avoid test interdependence @@ -212,8 +205,8 @@ public async Task PostIndexer_ReturnsCreatedIndex_WhenSingleIndexerPosted() Assert.True(dbIndexed.Count(i => NormalizeIndexerUrl(i.Url) == NormalizeIndexerUrl("http://example.local/api")) == 1); // Verify a broadcast and notification occurred - mockClientProxy.Verify( - p => p.SendCoreAsync("IndexersUpdated", It.IsAny(), default), + mockHubBroadcaster.Verify( + b => b.BroadcastAsync(RealtimeHubTarget.Settings, "IndexersUpdated", It.IsAny(), It.IsAny()), Times.AtLeastOnce); mockToastService.Verify( s => s.PublishNotificationAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), @@ -224,16 +217,12 @@ public async Task PostIndexer_ReturnsCreatedIndex_WhenSingleIndexerPosted() public async Task PutIndexer_SuppressesUpdateToast_IfIndexerRecentlyCreated() { var db = CreateInMemoryDb(); - var mockClientProxy = new Mock(); - var mockHubClients = new Mock(); - mockHubClients.Setup(c => c.All).Returns(mockClientProxy.Object); - var mockHubContext = new Mock>(); - mockHubContext.SetupGet(h => h.Clients).Returns(mockHubClients.Object); + var mockHubBroadcaster = new Mock(); var mockLogger = new Mock>(); var mockToastService = new Mock(); var mockStartupConfigService = new Mock(); mockStartupConfigService.Setup(s => s.GetConfig()).Returns(new StartupConfig { AuthenticationRequired = "false" }); - var controller = new ProwlarrCompatController(mockLogger.Object, new EfIndexerRepository(db), mockHubContext.Object, mockToastService.Object, mockStartupConfigService.Object, CreateApplicationVersionService()); + var controller = new ProwlarrCompatController(mockLogger.Object, new EfIndexerRepository(db), mockHubBroadcaster.Object, CreateRealtimeClientRegistry(), mockToastService.Object, mockStartupConfigService.Object, CreateApplicationVersionService()); // Create indexer via POST (this publishes one notification) var newIndexer = new { name = "Recent Import", implementation = "Newznab", baseUrl = "http://localhost:9090", apiPath = "api", apiKey = "KEY" }; @@ -267,16 +256,12 @@ public async Task PutIndexer_SuppressesUpdateToast_IfIndexerRecentlyCreated() public async Task PutIndexer_DeduplicatesUpdateToasts_OnRapidConsecutivePuts() { var db = CreateInMemoryDb(); - var mockClientProxy = new Mock(); - var mockHubClients = new Mock(); - mockHubClients.Setup(c => c.All).Returns(mockClientProxy.Object); - var mockHubContext = new Mock>(); - mockHubContext.SetupGet(h => h.Clients).Returns(mockHubClients.Object); + var mockHubBroadcaster = new Mock(); var mockLogger = new Mock>(); var mockToastService = new Mock(); var mockStartupConfigService = new Mock(); mockStartupConfigService.Setup(s => s.GetConfig()).Returns(new StartupConfig { AuthenticationRequired = "false" }); - var controller = new ProwlarrCompatController(mockLogger.Object, new EfIndexerRepository(db), mockHubContext.Object, mockToastService.Object, mockStartupConfigService.Object, CreateApplicationVersionService()); + var controller = new ProwlarrCompatController(mockLogger.Object, new EfIndexerRepository(db), mockHubBroadcaster.Object, CreateRealtimeClientRegistry(), mockToastService.Object, mockStartupConfigService.Object, CreateApplicationVersionService()); // Seed an existing indexer (older CreatedAt so created-based suppression doesn't interfere) var idx = new Indexer { Name = "Rapid Update", Url = "http://rapid", ApiKey = "K", Categories = "", CreatedAt = DateTime.UtcNow.AddMinutes(-10), UpdatedAt = DateTime.UtcNow.AddMinutes(-10), IsEnabled = true }; @@ -324,16 +309,12 @@ public async Task GetIndexers_IncludesFieldsAndTags() db.Indexers.Add(new Indexer { Name = "Seeded", Url = "http://seed", ApiKey = "K", Categories = "1,2" }); db.SaveChanges(); - var mockClientProxy = new Mock(); - var mockHubClients = new Mock(); - mockHubClients.Setup(c => c.All).Returns(mockClientProxy.Object); - var mockHubContext = new Mock>(); - mockHubContext.SetupGet(h => h.Clients).Returns(mockHubClients.Object); + var mockHubBroadcaster = new Mock(); var mockLogger = new Mock>(); var mockToastService = new Mock(); var mockStartupConfigService = new Mock(); mockStartupConfigService.Setup(s => s.GetConfig()).Returns(new StartupConfig { AuthenticationRequired = "false" }); - var controller = new ProwlarrCompatController(mockLogger.Object, new EfIndexerRepository(db), mockHubContext.Object, mockToastService.Object, mockStartupConfigService.Object, CreateApplicationVersionService()); + var controller = new ProwlarrCompatController(mockLogger.Object, new EfIndexerRepository(db), mockHubBroadcaster.Object, CreateRealtimeClientRegistry(), mockToastService.Object, mockStartupConfigService.Object, CreateApplicationVersionService()); var result = await controller.GetIndexers(); var ok = Assert.IsType(result); diff --git a/tests/Mocks/NoopHubBroadcaster.cs b/tests/Mocks/NoopHubBroadcaster.cs index b0c02ab0c..95df31ec0 100644 --- a/tests/Mocks/NoopHubBroadcaster.cs +++ b/tests/Mocks/NoopHubBroadcaster.cs @@ -32,5 +32,11 @@ public Task BroadcastAsync(string eventName, object payload, CancellationToken c // Intentionally do nothing in tests or lightweight hosts return Task.CompletedTask; } + + public Task BroadcastAsync(RealtimeHubTarget target, string eventName, object payload, CancellationToken cancellationToken = default) + { + // Intentionally do nothing in tests or lightweight hosts + return Task.CompletedTask; + } } } From 70769ee1216bb2d06b194b5738577ffdfec01ec2 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Thu, 11 Jun 2026 14:30:18 -0400 Subject: [PATCH 06/84] Updating terminology to stay consistent --- .../Controllers/Configurations/SettingsController.cs | 4 ++-- listenarr.api/Controllers/LibraryController.cs | 2 +- .../Controllers/ProwlarrCompatController.cs | 12 ++++++------ listenarr.api/Controllers/RootFoldersController.cs | 2 +- listenarr.api/Middleware/ApiKeyMiddleware.cs | 2 +- .../Middleware/AuthenticationEnforcerMiddleware.cs | 2 +- listenarr.application/Audiobooks/MoveQueueService.cs | 3 +-- listenarr.application/Downloads/DownloadService.cs | 2 +- .../Notification/DiscordBotService.cs | 2 +- .../Notification/SearchProgressReporter.cs | 4 ++-- .../Api/Controllers/ProwlarrCompatControllerTests.cs | 2 +- 11 files changed, 18 insertions(+), 19 deletions(-) diff --git a/listenarr.api/Controllers/Configurations/SettingsController.cs b/listenarr.api/Controllers/Configurations/SettingsController.cs index 820754bed..0d00f0633 100644 --- a/listenarr.api/Controllers/Configurations/SettingsController.cs +++ b/listenarr.api/Controllers/Configurations/SettingsController.cs @@ -70,7 +70,7 @@ public async Task> GetApplicationSettings() } /// - /// Save application settings. Broadcasts the update to all connected clients via SignalR. + /// Save application settings. Broadcasts the update to all connected realtime clients. /// /// Updated application settings. [Tags("Settings")] @@ -91,7 +91,7 @@ await _hubBroadcaster.BroadcastAsync( "SettingsUpdated", ApiResponseRedactor.RedactApplicationSettings(savedSettings)); - _logger.LogDebug("Application settings saved successfully and broadcasted via SignalR"); + _logger.LogDebug("Application settings saved successfully and broadcasted to realtime clients"); if (HttpSecurityRequestUtils.ShouldRedactSecretsForCaller(HttpContext)) { return Ok(ApiResponseRedactor.RedactApplicationSettings(savedSettings)); diff --git a/listenarr.api/Controllers/LibraryController.cs b/listenarr.api/Controllers/LibraryController.cs index 0e846d00b..b9ab588b4 100644 --- a/listenarr.api/Controllers/LibraryController.cs +++ b/listenarr.api/Controllers/LibraryController.cs @@ -2313,7 +2313,7 @@ public async Task ScanAudiobookFiles(int id, [FromBody] ScanReque var jobId = await _scanQueueService.EnqueueScanAsync(audiobook, request?.Path); _logger.LogInformation("Enqueued scan job {JobId} for audiobook {AudiobookId}", jobId, id); - // Broadcast initial job status via SignalR so clients can show queued state + // Broadcast initial job status so realtime clients can show queued state try { using var scope = _scopeFactory.CreateScope(); diff --git a/listenarr.api/Controllers/ProwlarrCompatController.cs b/listenarr.api/Controllers/ProwlarrCompatController.cs index b623c8d50..0596e10aa 100644 --- a/listenarr.api/Controllers/ProwlarrCompatController.cs +++ b/listenarr.api/Controllers/ProwlarrCompatController.cs @@ -1006,7 +1006,7 @@ string getString(System.Text.Json.JsonElement el, string prop1, string? prop2 = } catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) { - _logger?.LogWarning(ex, "Failed to broadcast IndexersUpdated via SignalR"); + _logger?.LogWarning(ex, "Failed to broadcast IndexersUpdated to realtime clients"); } } @@ -1045,7 +1045,7 @@ string getString(System.Text.Json.JsonElement el, string prop1, string? prop2 = /// /// DEBUG: POST /api/v1/debug/indexers/publish - /// Manually trigger an IndexersUpdated SignalR broadcast for testing client connectivity. + /// Manually trigger an IndexersUpdated realtime broadcast for testing client connectivity. /// [HttpPost("debug/indexers/publish")] [AllowAnonymous] @@ -1110,13 +1110,13 @@ public async Task DebugPublishIndexers([FromBody] System.Text.Jso /// /// DEBUG: GET /api/v1/debug/settings/clients - /// Returns the list and count of currently connected SettingsHub clients. + /// Returns the list and count of currently connected settings realtime clients. /// [HttpGet("debug/settings/clients")] [AllowAnonymous] [LocalOrAdmin] [ApiExplorerSettings(IgnoreApi = true)] - public IActionResult GetSettingsHubClients() + public IActionResult GetSettingsRealtimeClients() { try { @@ -1125,7 +1125,7 @@ public IActionResult GetSettingsHubClients() } catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) { - _logger?.LogWarning(ex, "Failed to retrieve SettingsHub clients"); + _logger?.LogWarning(ex, "Failed to retrieve settings realtime clients"); return StatusCode(500, new { error = "Failed to retrieve clients" }); } } @@ -1133,7 +1133,7 @@ public IActionResult GetSettingsHubClients() /// /// POST /api/v1/indexer /// Accepts a single indexer object (or an array) for compatibility with some clients that POST to the singular route. - /// Delegates to PostIndexers for the actual processing so persistence and SignalR broadcast happen in one place. + /// Delegates to PostIndexers for the actual processing so persistence and realtime broadcasts happen in one place. /// [HttpPost("indexer")] [AllowAnonymous] diff --git a/listenarr.api/Controllers/RootFoldersController.cs b/listenarr.api/Controllers/RootFoldersController.cs index 2997648f4..1eeb0f800 100644 --- a/listenarr.api/Controllers/RootFoldersController.cs +++ b/listenarr.api/Controllers/RootFoldersController.cs @@ -149,7 +149,7 @@ public async Task Delete(int id, [FromQuery] int? reassignTo = nu /// /// Enqueues a background scan of a root folder to find audio files not in the library. - /// Returns a jobId; subscribe to SignalR "UnmatchedScanComplete" for completion notification. + /// Returns a jobId; subscribe to the realtime "UnmatchedScanComplete" event for completion notification. /// [HttpPost("{id}/scan-unmatched")] public async Task ScanUnmatched(int id) diff --git a/listenarr.api/Middleware/ApiKeyMiddleware.cs b/listenarr.api/Middleware/ApiKeyMiddleware.cs index e26b98063..34133ae2b 100644 --- a/listenarr.api/Middleware/ApiKeyMiddleware.cs +++ b/listenarr.api/Middleware/ApiKeyMiddleware.cs @@ -59,7 +59,7 @@ public async Task InvokeAsync(HttpContext context) provided = s.Substring("ApiKey ".Length).Trim(); } - // If headers didn't supply the key, only accept query-string token for SignalR hub connections. + // If headers didn't supply the key, only accept query-string token for realtime hub connections. // Avoiding query-string auth for normal API routes prevents credential leakage via logs/referrers. if (string.IsNullOrWhiteSpace(provided)) { diff --git a/listenarr.api/Middleware/AuthenticationEnforcerMiddleware.cs b/listenarr.api/Middleware/AuthenticationEnforcerMiddleware.cs index d8644f7c9..49bcb9445 100644 --- a/listenarr.api/Middleware/AuthenticationEnforcerMiddleware.cs +++ b/listenarr.api/Middleware/AuthenticationEnforcerMiddleware.cs @@ -92,7 +92,7 @@ public async Task InvokeAsync(HttpContext context) return; } - // Serve SPA assets and client-side routes anonymously: if the request is not for an API or SignalR hub, + // Serve SPA assets and client-side routes anonymously: if the request is not for an API or realtime hub, // let the static file middleware or SPA fallback handle it. This avoids returning 401 for '/'. // Keep API and hub routes protected. if (!path.StartsWith("/api") && !path.StartsWith("/hubs")) diff --git a/listenarr.application/Audiobooks/MoveQueueService.cs b/listenarr.application/Audiobooks/MoveQueueService.cs index b92311bbe..3bc1c73ee 100644 --- a/listenarr.application/Audiobooks/MoveQueueService.cs +++ b/listenarr.application/Audiobooks/MoveQueueService.cs @@ -127,7 +127,7 @@ public void UpdateJobStatus(Guid id, string status, string? error = null) moveJobRepository.UpdateAsync(dbJob).GetAwaiter().GetResult(); } - // Broadcast status update to SignalR clients so UI can react to Processing/Failed/Completed + // Broadcast status update to realtime clients so UI can react to Processing/Failed/Completed try { var hub = scope.ServiceProvider.GetRequiredService(); @@ -219,4 +219,3 @@ private static bool CanRequeueJobStatus(string status) } } } - diff --git a/listenarr.application/Downloads/DownloadService.cs b/listenarr.application/Downloads/DownloadService.cs index d80d9d78b..4294d0eb8 100644 --- a/listenarr.application/Downloads/DownloadService.cs +++ b/listenarr.application/Downloads/DownloadService.cs @@ -534,7 +534,7 @@ await downloadHistoryService.RecordGrabbedAsync( await notificationService.SendNotificationAsync("book-downloading", notificationData, settings.WebhookUrl, settings.EnabledNotificationTriggers); - // Trigger immediate queue update via SignalR so the UI shows the new download right away + // Trigger an immediate realtime queue update so the UI shows the new download right away // Add a small delay to allow the download client to process and index the new download try { diff --git a/listenarr.application/Notification/DiscordBotService.cs b/listenarr.application/Notification/DiscordBotService.cs index 1a3f896d0..c77674c8d 100644 --- a/listenarr.application/Notification/DiscordBotService.cs +++ b/listenarr.application/Notification/DiscordBotService.cs @@ -106,7 +106,7 @@ public async Task StartBotAsync() startInfo.EnvironmentVariables["LISTENARR_URL"] = listenarrUrl; // Pass the server API key into the helper process so it can authenticate - // programmatic requests (SignalR negotiate, settings fetch, etc.). Only set + // programmatic requests (realtime negotiate, settings fetch, etc.). Only set // when an API key is present in the startup config to avoid sending empty // values into the child environment. try diff --git a/listenarr.application/Notification/SearchProgressReporter.cs b/listenarr.application/Notification/SearchProgressReporter.cs index 42bad38e1..826ddd29e 100644 --- a/listenarr.application/Notification/SearchProgressReporter.cs +++ b/listenarr.application/Notification/SearchProgressReporter.cs @@ -21,7 +21,7 @@ namespace Listenarr.Application.Notification { /// - /// Handles broadcasting search progress updates to connected clients via SignalR. + /// Handles broadcasting search progress updates to connected realtime clients. /// public class SearchProgressReporter { @@ -35,7 +35,7 @@ public SearchProgressReporter(IHubBroadcaster? hubBroadcaster, ILogger - /// Broadcasts a search progress message to all connected SignalR clients. + /// Broadcasts a search progress message to all connected realtime clients. /// /// The progress message to broadcast /// Optional ASIN associated with this progress update diff --git a/tests/Features/Api/Controllers/ProwlarrCompatControllerTests.cs b/tests/Features/Api/Controllers/ProwlarrCompatControllerTests.cs index 489844e41..b50e2a4f9 100644 --- a/tests/Features/Api/Controllers/ProwlarrCompatControllerTests.cs +++ b/tests/Features/Api/Controllers/ProwlarrCompatControllerTests.cs @@ -45,7 +45,7 @@ private static IRealtimeClientRegistry CreateRealtimeClientRegistry() } [Fact] - public async Task PostIndexers_BroadcastsSignalR_WhenNewIndexersCreated() + public async Task PostIndexers_BroadcastsRealtimeUpdate_WhenNewIndexersCreated() { var db = CreateInMemoryDb(); var mockHubBroadcaster = new Mock(); From 9412bf45cf60872d13952961144702fda984685e Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Thu, 11 Jun 2026 21:42:10 -0400 Subject: [PATCH 07/84] Move realtime wiring behind infrastructure extensions --- listenarr.api/GlobalUsings.cs | 1 - listenarr.api/Program.cs | 25 ++--------- .../RealtimeEndpointRouteBuilderExtensions.cs | 42 +++++++++++++++++++ .../Extensions/RealtimeLoggingExtensions.cs | 35 ++++++++++++++++ 4 files changed, 81 insertions(+), 22 deletions(-) create mode 100644 listenarr.infrastructure/Extensions/RealtimeEndpointRouteBuilderExtensions.cs create mode 100644 listenarr.infrastructure/Extensions/RealtimeLoggingExtensions.cs diff --git a/listenarr.api/GlobalUsings.cs b/listenarr.api/GlobalUsings.cs index ed030db27..66c625839 100644 --- a/listenarr.api/GlobalUsings.cs +++ b/listenarr.api/GlobalUsings.cs @@ -1,4 +1,3 @@ -global using Microsoft.AspNetCore.SignalR; global using Microsoft.Extensions.Hosting; global using Listenarr.Api.Security; global using Listenarr.Api.Common; diff --git a/listenarr.api/Program.cs b/listenarr.api/Program.cs index 49bcb86ad..0e8342ad7 100644 --- a/listenarr.api/Program.cs +++ b/listenarr.api/Program.cs @@ -28,7 +28,6 @@ using Serilog; using Serilog.Events; using Listenarr.Infrastructure.Extensions; -using Listenarr.Infrastructure.SignalR; using Listenarr.Application.Interfaces; using Listenarr.Application.Downloads; using Listenarr.Infrastructure.Persistence; @@ -109,7 +108,7 @@ // Configure Serilog for structured logging, file rotation and SignalR broadcasting var logFilePath = Path.Join(builder.Environment.ContentRootPath, "config", "logs", "listenarr-.log"); -var signalRSink = new SignalRLogSink(); +var signalRSink = RealtimeLoggingExtensions.CreateListenarrRealtimeLogSink(); // Prefer explicit environment variable (useful for Docker/runtime overrides) var logLevelEnv = Environment.GetEnvironmentVariable("LISTENARR_LOG_LEVEL"); @@ -649,8 +648,8 @@ ex is IOException Log.Logger.Debug(ex, "[Startup] Failed to evaluate authentication-enabled startup warning"); } -// Initialize the SignalR sink now that the hub context is available -signalRSink.Initialize(app.Services.GetRequiredService>()); +// Initialize realtime log broadcasting now that the hub context is available. +signalRSink.InitializeListenarrRealtimeLogging(app.Services); // Configure the HTTP request pipeline. if (app.Environment.IsDevelopment()) @@ -771,23 +770,7 @@ ex is IOException app.UseAuthorization(); app.MapControllers(); - -// Map SignalR hub for real-time download updates -if (app.Environment.IsDevelopment()) -{ - app.MapHub("/hubs/downloads").RequireCors("DevOnly"); - // Map SignalR hub for real-time log broadcasting - app.MapHub("/hubs/logs").RequireCors("DevOnly"); - // Map SignalR hub for real-time settings updates - app.MapHub("/hubs/settings").RequireCors("DevOnly"); -} -else -{ - app.MapHub("/hubs/downloads"); - app.MapHub("/hubs/logs"); - // Map SignalR hub for real-time settings updates - app.MapHub("/hubs/settings"); -} +app.MapListenarrRealtimeHubs(app.Environment); // SPA fallback: serve index.html for non-API routes so client-side routing works app.MapFallbackToFile("index.html"); diff --git a/listenarr.infrastructure/Extensions/RealtimeEndpointRouteBuilderExtensions.cs b/listenarr.infrastructure/Extensions/RealtimeEndpointRouteBuilderExtensions.cs new file mode 100644 index 000000000..38d25bc58 --- /dev/null +++ b/listenarr.infrastructure/Extensions/RealtimeEndpointRouteBuilderExtensions.cs @@ -0,0 +1,42 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; + +namespace Listenarr.Infrastructure.Extensions +{ + public static class RealtimeEndpointRouteBuilderExtensions + { + public static IEndpointRouteBuilder MapListenarrRealtimeHubs(this IEndpointRouteBuilder endpoints, IHostEnvironment environment) + { + if (environment.IsDevelopment()) + { + endpoints.MapHub("/hubs/downloads").RequireCors("DevOnly"); + endpoints.MapHub("/hubs/logs").RequireCors("DevOnly"); + endpoints.MapHub("/hubs/settings").RequireCors("DevOnly"); + return endpoints; + } + + endpoints.MapHub("/hubs/downloads"); + endpoints.MapHub("/hubs/logs"); + endpoints.MapHub("/hubs/settings"); + return endpoints; + } + } +} diff --git a/listenarr.infrastructure/Extensions/RealtimeLoggingExtensions.cs b/listenarr.infrastructure/Extensions/RealtimeLoggingExtensions.cs new file mode 100644 index 000000000..6512521ac --- /dev/null +++ b/listenarr.infrastructure/Extensions/RealtimeLoggingExtensions.cs @@ -0,0 +1,35 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +using Microsoft.Extensions.DependencyInjection; + +namespace Listenarr.Infrastructure.Extensions +{ + public static class RealtimeLoggingExtensions + { + public static SignalRLogSink CreateListenarrRealtimeLogSink() + { + return new SignalRLogSink(); + } + + public static void InitializeListenarrRealtimeLogging(this SignalRLogSink signalRSink, IServiceProvider serviceProvider) + { + signalRSink.Initialize(serviceProvider.GetRequiredService>()); + } + } +} From a2700aa2f7908bbfa6559a7a75f7705ec109b9f3 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Thu, 11 Jun 2026 21:52:56 -0400 Subject: [PATCH 08/84] Respect forwarded header trust model --- .../Web/AspNetRequestContextAccessor.cs | 16 ++----------- .../Api/ForwardedHeadersTrustModelTests.cs | 23 +++++++++++++++++++ 2 files changed, 25 insertions(+), 14 deletions(-) diff --git a/listenarr.infrastructure/Web/AspNetRequestContextAccessor.cs b/listenarr.infrastructure/Web/AspNetRequestContextAccessor.cs index 493dc9de0..a5ae17297 100644 --- a/listenarr.infrastructure/Web/AspNetRequestContextAccessor.cs +++ b/listenarr.infrastructure/Web/AspNetRequestContextAccessor.cs @@ -35,22 +35,10 @@ public RequestContextSnapshot? Current && (user.IsInRole("Administrator") || string.Equals(user.FindFirst("AuthMethod")?.Value, "ApiKey", StringComparison.Ordinal)); - var scheme = context.Request.Scheme; - var host = context.Request.Host.Value; - if (context.Request.Headers.TryGetValue("X-Forwarded-Proto", out var forwardedProto)) - { - scheme = forwardedProto.ToString(); - } - - if (context.Request.Headers.TryGetValue("X-Forwarded-Host", out var forwardedHost)) - { - host = forwardedHost.ToString(); - } - return new RequestContextSnapshot( context.Request.Path.Value, - scheme, - host, + context.Request.Scheme, + context.Request.Host.Value, context.Connection.RemoteIpAddress, isAuthenticatedAdminOrApiKey); } diff --git a/tests/Features/Api/ForwardedHeadersTrustModelTests.cs b/tests/Features/Api/ForwardedHeadersTrustModelTests.cs index c2118bce5..3afe4d770 100644 --- a/tests/Features/Api/ForwardedHeadersTrustModelTests.cs +++ b/tests/Features/Api/ForwardedHeadersTrustModelTests.cs @@ -16,8 +16,10 @@ * along with this program. If not, see . */ using System.Net; +using Listenarr.Infrastructure.Web; using Listenarr.Tests.Mocks; using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.Extensions.Options; @@ -49,6 +51,27 @@ public void ForwardedHeadersOptions_TrustsCommonPrivateProxyNetworks() Assert.Contains(options.KnownIPNetworks, network => Matches(network, "fe80::", 10)); } + [Fact] + public void RequestContextAccessor_IgnoresRawForwardedHostHeaders() + { + var httpContext = new DefaultHttpContext(); + httpContext.Request.Scheme = "http"; + httpContext.Request.Host = new HostString("listenarr.internal:4545"); + httpContext.Request.Headers["X-Forwarded-Proto"] = "https"; + httpContext.Request.Headers["X-Forwarded-Host"] = "attacker.example"; + + var accessor = new AspNetRequestContextAccessor(new HttpContextAccessor + { + HttpContext = httpContext + }); + + var snapshot = accessor.Current; + + Assert.NotNull(snapshot); + Assert.Equal("http", snapshot.Scheme); + Assert.Equal("listenarr.internal:4545", snapshot.Host); + } + private static bool Matches(System.Net.IPNetwork network, string prefix, int prefixLength) { return network.BaseAddress.Equals(IPAddress.Parse(prefix)) && network.PrefixLength == prefixLength; From b815954604e5844bede9d04f1d87ef164e15eb17 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Thu, 11 Jun 2026 22:53:27 -0400 Subject: [PATCH 09/84] Update NuGet locks for architecture split --- listenarr.api/packages.lock.json | 13 +- listenarr.application/packages.lock.json | 212 ++++------------- listenarr.infrastructure/packages.lock.json | 243 ++------------------ tests/packages.lock.json | 107 +++++---- 4 files changed, 126 insertions(+), 449 deletions(-) diff --git a/listenarr.api/packages.lock.json b/listenarr.api/packages.lock.json index 5e811971b..3d8569c1e 100644 --- a/listenarr.api/packages.lock.json +++ b/listenarr.api/packages.lock.json @@ -473,14 +473,7 @@ "type": "Project", "dependencies": { "AsyncKeyedLock": "[8.0.2, )", - "HtmlAgilityPack": "[1.12.4, )", - "Listenarr.Domain": "[1.0.0, )", - "Microsoft.Data.Sqlite.Core": "[10.0.8, )", - "Microsoft.EntityFrameworkCore": "[10.0.8, )", - "Microsoft.EntityFrameworkCore.Sqlite": "[10.0.8, )", - "SixLabors.ImageSharp": "[3.1.12, )", - "Swashbuckle.AspNetCore": "[10.2.1, )", - "TagLibSharp": "[2.3.0, )" + "Listenarr.Domain": "[1.0.0, )" } }, "listenarr.domain": { @@ -489,6 +482,7 @@ "listenarr.infrastructure": { "type": "Project", "dependencies": { + "AsyncKeyedLock": "[8.0.2, )", "BencodeNET": "[4.0.0, )", "HtmlAgilityPack": "[1.12.4, )", "Listenarr.Application": "[1.0.0, )", @@ -499,7 +493,8 @@ "Polly": "[8.6.6, )", "Serilog.Sinks.File": "[7.0.0, )", "SharpCompress": "[0.49.1, )", - "Swashbuckle.AspNetCore": "[10.2.1, )" + "SixLabors.ImageSharp": "[3.1.12, )", + "TagLibSharp": "[2.3.0, )" } }, "BencodeNET": { diff --git a/listenarr.application/packages.lock.json b/listenarr.application/packages.lock.json index 3b54dd6d6..ed41468eb 100644 --- a/listenarr.application/packages.lock.json +++ b/listenarr.application/packages.lock.json @@ -8,138 +8,78 @@ "resolved": "8.0.2", "contentHash": "QGys5cnIerNryv7V14PDkvGnlLz69kJtTfdnr+Lndcu+lRre397RNyU4FIeAJWgI9u73lTzXL52Qca9B/ncLXw==" }, - "HtmlAgilityPack": { - "type": "Direct", - "requested": "[1.12.4, )", - "resolved": "1.12.4", - "contentHash": "ljqvBabvFwKoLniuoQKO8b5bJfJweKLs4fUNS/V5dsvpo0A8MlJqxxn9XVmP2DaskbUXty6IYaWAi1SArGIMeQ==" - }, - "Microsoft.Data.Sqlite.Core": { + "Microsoft.Extensions.Caching.Abstractions": { "type": "Direct", "requested": "[10.0.8, )", "resolved": "10.0.8", - "contentHash": "26t7WDiEjjAls/sFpWvVEFDxt+7Q5VPt6+blU2Lafuj9L8PzAv/GtGV4cqVPtrhWbfD2BX/z2v8hD1qXYtK6Aw==", + "contentHash": "EoK2TwVR1daxmfXUPnvIYZSk5XQjHe45sGekox4kvMt88KQZQhDVzYW5Na5+oNwTuRpE48hipyGJg12F1Tm70w==", "dependencies": { - "SQLitePCLRaw.core": "2.1.11" + "Microsoft.Extensions.Primitives": "10.0.8" } }, - "Microsoft.EntityFrameworkCore": { + "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "Direct", "requested": "[10.0.8, )", "resolved": "10.0.8", - "contentHash": "EJx+fIBMgBlgD+ublKCn+GTOJkw3UqV7xOjYWBRVdUYyIm8UfvAsmSOPFiIInsWTHyMEYUJ9gCJY1jwX+6UB7w==", - "dependencies": { - "Microsoft.EntityFrameworkCore.Abstractions": "10.0.8", - "Microsoft.EntityFrameworkCore.Analyzers": "10.0.8", - "Microsoft.Extensions.Caching.Memory": "10.0.8", - "Microsoft.Extensions.Logging": "10.0.8" - } + "contentHash": "21nbDV60SRPWGIivsyl6lqBeEJNG1sginhhfWgRrr3Ais7aQ12To25OAHQxgoiJkjqy1aQ6RxpZBGYuTi7Ge6A==" }, - "Microsoft.EntityFrameworkCore.Sqlite": { + "Microsoft.Extensions.Http": { "type": "Direct", "requested": "[10.0.8, )", "resolved": "10.0.8", - "contentHash": "8BGSSKBDDBC8s6ye1Y2Ar1BToeZHLHOzUn0nAOng4Z+8dJ4KQKC/1qYFPgRYchDCOMQh98REHco8SrrMYsHuMQ==", + "contentHash": "/9LU/KWJOrtZJB9ymPjcARDyjp679BvBA/aSncv2Kt84WlSKz767HtxHg8EFsu8n21BMLZi+5XxlkKbLwfn4iA==", "dependencies": { - "Microsoft.EntityFrameworkCore.Sqlite.Core": "10.0.8", - "Microsoft.Extensions.Caching.Memory": "10.0.8", "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", - "Microsoft.Extensions.DependencyModel": "10.0.8", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", + "Microsoft.Extensions.Diagnostics": "10.0.8", "Microsoft.Extensions.Logging": "10.0.8", - "SQLitePCLRaw.bundle_e_sqlite3": "2.1.11", - "SQLitePCLRaw.core": "2.1.11" + "Microsoft.Extensions.Logging.Abstractions": "10.0.8", + "Microsoft.Extensions.Options": "10.0.8" } }, - "SixLabors.ImageSharp": { - "type": "Direct", - "requested": "[3.1.12, )", - "resolved": "3.1.12", - "contentHash": "iAg6zifihXEFS/t7fiHhZBGAdCp3FavsF4i2ZIDp0JfeYeDVzvmlbY1CNhhIKimaIzrzSi5M/NBFcWvZT2rB/A==" - }, - "Swashbuckle.AspNetCore": { + "Microsoft.Extensions.Logging.Abstractions": { "type": "Direct", - "requested": "[10.2.1, )", - "resolved": "10.2.1", - "contentHash": "SDU6akgCV/H4jFMRfyJ0mgO5jWOuuAqekvEThXg8c/LjnfNz5Nkaz+RUpeTVJKWIRX4wDKC/6R3ogJ4AsRE32A==", + "requested": "[10.0.8, )", + "resolved": "10.0.8", + "contentHash": "fdVadZmsC8jRP0KvKy8mO8f6GV/HyBvElfcSxEhd+5FM5boAw/01iSaCto5G3G37ApJira4A3pNaVvBv8cUiLQ==", "dependencies": { - "Microsoft.Extensions.ApiDescription.Server": "10.0.0", - "Swashbuckle.AspNetCore.Swagger": "10.2.1", - "Swashbuckle.AspNetCore.SwaggerGen": "10.2.1", - "Swashbuckle.AspNetCore.SwaggerUI": "10.2.1" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8" } }, - "TagLibSharp": { + "Microsoft.Extensions.Options": { "type": "Direct", - "requested": "[2.3.0, )", - "resolved": "2.3.0", - "contentHash": "Qo4z6ZjnIfbR3Us1Za5M2vQ97OWZPmODvVmepxZ8XW0UIVLGdO2T63/N3b23kCcyiwuIe0TQvMEQG8wUCCD1mA==" - }, - "Microsoft.EntityFrameworkCore.Abstractions": { - "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "jbKDXWPZQhuPHygMnwzNOqxBADVcpRVytcKYZsA++QqhPkpF93Ta8o5mbJQGrARSjlkr9WtOaADV97EDMOZ7DA==" - }, - "Microsoft.EntityFrameworkCore.Analyzers": { - "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "M3BZ8JH8rB6BE7dO2g9iVbrHLnEz9wMXT6q+tDR6Nq3gyP3KmBj5OTiZGxyF3vesjOQNKanYoPGSNBR4kR2llg==" - }, - "Microsoft.EntityFrameworkCore.Relational": { - "type": "Transitive", + "requested": "[10.0.8, )", "resolved": "10.0.8", - "contentHash": "UU3diAD2wwZveye2rnrwaF/wvJ9tm5iL2fuY9TTap6/iGQK1OO29M1BzXZRlRPVH/dByt5w/pISBSFtyR7hTqw==", + "contentHash": "VBD+131DpTNCNDfA4kIyKTiCySvJGNhwibdWBSdFRu7GMfXLXcXODkgA+KStKbbhzraLglZWUN4nXyHgW4JIRA==", "dependencies": { - "Microsoft.EntityFrameworkCore": "10.0.8", - "Microsoft.Extensions.Caching.Memory": "10.0.8", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", - "Microsoft.Extensions.Logging": "10.0.8" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", + "Microsoft.Extensions.Primitives": "10.0.8" } }, - "Microsoft.EntityFrameworkCore.Sqlite.Core": { + "Microsoft.Extensions.Configuration": { "type": "Transitive", "resolved": "10.0.8", - "contentHash": "cFRBlY3sCoVX5JFDrRHQQHcbSms7CwBjjeuVEgQ4KP8WzPopgwNk3sJ0k7xKkIl0b9eUFJ0IR0aZwElT9154Ag==", + "contentHash": "ehZcoPbjzWzS4XFvuz7R3V55SmpdkyMqFURLH3yXaN9NtXd9tR6CGB7pd49HYtCkenl+G7ctXSFLhNI08xLfRg==", "dependencies": { - "Microsoft.Data.Sqlite.Core": "10.0.8", - "Microsoft.EntityFrameworkCore.Relational": "10.0.8", - "Microsoft.Extensions.Caching.Memory": "10.0.8", "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", - "Microsoft.Extensions.DependencyModel": "10.0.8", - "Microsoft.Extensions.Logging": "10.0.8", - "SQLitePCLRaw.core": "2.1.11" - } - }, - "Microsoft.Extensions.ApiDescription.Server": { - "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "NCWCGiwRwje8773yzPQhvucYnnfeR+ZoB1VRIrIMp4uaeUNw7jvEPHij3HIbwCDuNCrNcphA00KSAR9yD9qmbg==" - }, - "Microsoft.Extensions.Caching.Abstractions": { - "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "EoK2TwVR1daxmfXUPnvIYZSk5XQjHe45sGekox4kvMt88KQZQhDVzYW5Na5+oNwTuRpE48hipyGJg12F1Tm70w==", - "dependencies": { "Microsoft.Extensions.Primitives": "10.0.8" } }, - "Microsoft.Extensions.Caching.Memory": { + "Microsoft.Extensions.Configuration.Abstractions": { "type": "Transitive", "resolved": "10.0.8", - "contentHash": "sYMYQjNprfqPTryuLNnr0/AOtnhlfuZ0ZxyOV0d3AXOEL8j9KV0EbelpZYyIatT2hJiaSGO9XGr5YDRsh22OfQ==", + "contentHash": "I63esIFbL3h5pSt7gXpXOlmcwDmYBUoYNEglKfDPFUqtYvSV84f2l28hO2lfVXsV0wdlplgAM7IVz16matapSg==", "dependencies": { - "Microsoft.Extensions.Caching.Abstractions": "10.0.8", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", - "Microsoft.Extensions.Logging.Abstractions": "10.0.8", - "Microsoft.Extensions.Options": "10.0.8", "Microsoft.Extensions.Primitives": "10.0.8" } }, - "Microsoft.Extensions.Configuration.Abstractions": { + "Microsoft.Extensions.Configuration.Binder": { "type": "Transitive", "resolved": "10.0.8", - "contentHash": "I63esIFbL3h5pSt7gXpXOlmcwDmYBUoYNEglKfDPFUqtYvSV84f2l28hO2lfVXsV0wdlplgAM7IVz16matapSg==", + "contentHash": "R3NN1X+kVu14uoxLEW6sBSQyhogDSbaOQzILnCtuXxBN4hx22AgjWPwZX6v/suERFkEDgU1lk12AglHTrUxhlw==", "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.8" + "Microsoft.Extensions.Configuration": "10.0.8", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.8" } }, "Microsoft.Extensions.DependencyInjection": { @@ -150,15 +90,24 @@ "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8" } }, - "Microsoft.Extensions.DependencyInjection.Abstractions": { + "Microsoft.Extensions.Diagnostics": { "type": "Transitive", "resolved": "10.0.8", - "contentHash": "21nbDV60SRPWGIivsyl6lqBeEJNG1sginhhfWgRrr3Ais7aQ12To25OAHQxgoiJkjqy1aQ6RxpZBGYuTi7Ge6A==" + "contentHash": "uduyw9d3Fi+sbredO5drA1S44AQS2FRNFyn72UmB2vmQIO1qaXprpp1U/2lYhYi8yFdVERfY9sy/pxw/qPOU9w==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.8", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.8", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.8" + } }, - "Microsoft.Extensions.DependencyModel": { + "Microsoft.Extensions.Diagnostics.Abstractions": { "type": "Transitive", "resolved": "10.0.8", - "contentHash": "vLyZVpxmduO2jx+76ggqnsA3m81kwMY3NkWciNTj5E+Nvqb0VihqCvQP89QsGONWp0AJwMZG+u9GzaCjDdFGNw==" + "contentHash": "+f4C5g78QCGNyxzUfrTYsB7qYx06Zca0e88s3qFlea9/lQhgPImYdNprlgzl1uHhRU3fVHLfmbijayU2sJEZ6w==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", + "Microsoft.Extensions.Options": "10.0.8" + } }, "Microsoft.Extensions.Logging": { "type": "Transitive", @@ -170,20 +119,15 @@ "Microsoft.Extensions.Options": "10.0.8" } }, - "Microsoft.Extensions.Logging.Abstractions": { + "Microsoft.Extensions.Options.ConfigurationExtensions": { "type": "Transitive", "resolved": "10.0.8", - "contentHash": "fdVadZmsC8jRP0KvKy8mO8f6GV/HyBvElfcSxEhd+5FM5boAw/01iSaCto5G3G37ApJira4A3pNaVvBv8cUiLQ==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8" - } - }, - "Microsoft.Extensions.Options": { - "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "VBD+131DpTNCNDfA4kIyKTiCySvJGNhwibdWBSdFRu7GMfXLXcXODkgA+KStKbbhzraLglZWUN4nXyHgW4JIRA==", + "contentHash": "VOapXeO3lhBH0zYoyAH7tjapuo4V5pTHlevPpiSHueEquAajqd5nF0mttm+h/uE/exwAEuM5s26SzOJtletE3w==", "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", + "Microsoft.Extensions.Configuration.Binder": "10.0.8", "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", + "Microsoft.Extensions.Options": "10.0.8", "Microsoft.Extensions.Primitives": "10.0.8" } }, @@ -192,70 +136,8 @@ "resolved": "10.0.8", "contentHash": "OBPo4nYhMyIbtueoC10CBm6AGAbo/A9IV8QQ/6ryZS7VvmqpGT7hunazeHLxFawRzn3oLOq4jhqhpBX4tfswWQ==" }, - "Microsoft.OpenApi": { - "type": "Transitive", - "resolved": "2.7.5", - "contentHash": "0FA67RSnRM4tcBKqiqVu/HPdZ9+QOKbmeRjxRUGTCjPU4C0bmUhd97Dso7Yild5P7nOV6GxJ2xrK0Kv/O9xp0w==" - }, - "SourceGear.sqlite3": { - "type": "Transitive", - "resolved": "3.50.4.5", - "contentHash": "UtnipXhJYZKQOQIfpws/msLK7IRhMplE1CZCaZLIQXRnGD474QVpO/J9nMlQQY8NZueGz1aidjoxDRnrC1NT3Q==" - }, - "SQLitePCLRaw.config.e_sqlite3": { - "type": "Transitive", - "resolved": "3.0.3", - "contentHash": "caP/ap0X2fyVmstCXu5ueOmcr2XWAxA2XyKghV7H4bOAFmq3nWcsGl9q44iY1HYG+i8Qr4G9XEqdfti0rV6/ZQ==", - "dependencies": { - "SQLitePCLRaw.provider.e_sqlite3": "3.0.3" - } - }, - "SQLitePCLRaw.core": { - "type": "Transitive", - "resolved": "3.0.3", - "contentHash": "bjm6FY4lZyP+t7GmiuvSM0QXpFihAvyE0Y9O2yibm3g95AAWJPNnHOKVNJGyPTGIKuK7Pr4Wh8Rd8/aOtAclQw==" - }, - "SQLitePCLRaw.provider.e_sqlite3": { - "type": "Transitive", - "resolved": "3.0.3", - "contentHash": "wd+fGvZTrr3BJNe48opSczmC176Okd61ZgoZNQcdvZwkek6to978ccdpcFmNo5GHxCnk29KwT+f+lAZYgfLVZg==", - "dependencies": { - "SQLitePCLRaw.core": "3.0.3" - } - }, - "Swashbuckle.AspNetCore.Swagger": { - "type": "Transitive", - "resolved": "10.2.1", - "contentHash": "ej4inPhiWCq+0utG8yaKhIhE8M3k3R/qRaGhpgDZB+O/s+o62/zRMO1Cn2CtQccsrqPE9PYnzCp6hQGYGpJOyQ==", - "dependencies": { - "Microsoft.OpenApi": "2.7.5" - } - }, - "Swashbuckle.AspNetCore.SwaggerGen": { - "type": "Transitive", - "resolved": "10.2.1", - "contentHash": "JYX6i/y0xEtQWH/hZyfcage1/ldwww83ueD/gBc34uSnMwyvRLUsOpYcxlliFFxFbZMrY6t+R9ENqolE7zTEOg==", - "dependencies": { - "Swashbuckle.AspNetCore.Swagger": "10.2.1" - } - }, - "Swashbuckle.AspNetCore.SwaggerUI": { - "type": "Transitive", - "resolved": "10.2.1", - "contentHash": "vzB8ZAGqXus3fdareJ9GHctaRP9ZL+wW9x8U7s1Y+BWprInFvSg6rpD9VhANNpwXA8fUHqu5Agjl/+hHG1BCQA==" - }, "listenarr.domain": { "type": "Project" - }, - "SQLitePCLRaw.bundle_e_sqlite3": { - "type": "CentralTransitive", - "requested": "[3.0.3, )", - "resolved": "3.0.3", - "contentHash": "Zt8jmSL5zcDWGk8rmzhWBJ6IRyLWh1yWS04Pg72+GIvo3Ba4E/rG4Y/4l7AWlSEogEbzyKRTCXUAs1v/O7Pkkg==", - "dependencies": { - "SQLitePCLRaw.config.e_sqlite3": "3.0.3", - "SourceGear.sqlite3": "3.50.4.5" - } } } } diff --git a/listenarr.infrastructure/packages.lock.json b/listenarr.infrastructure/packages.lock.json index b9259085f..63b7c93d4 100644 --- a/listenarr.infrastructure/packages.lock.json +++ b/listenarr.infrastructure/packages.lock.json @@ -2,6 +2,12 @@ "version": 2, "dependencies": { "net10.0": { + "AsyncKeyedLock": { + "type": "Direct", + "requested": "[8.0.2, )", + "resolved": "8.0.2", + "contentHash": "QGys5cnIerNryv7V14PDkvGnlLz69kJtTfdnr+Lndcu+lRre397RNyU4FIeAJWgI9u73lTzXL52Qca9B/ncLXw==" + }, "BencodeNET": { "type": "Direct", "requested": "[4.0.0, )", @@ -21,9 +27,7 @@ "contentHash": "EJx+fIBMgBlgD+ublKCn+GTOJkw3UqV7xOjYWBRVdUYyIm8UfvAsmSOPFiIInsWTHyMEYUJ9gCJY1jwX+6UB7w==", "dependencies": { "Microsoft.EntityFrameworkCore.Abstractions": "10.0.8", - "Microsoft.EntityFrameworkCore.Analyzers": "10.0.8", - "Microsoft.Extensions.Caching.Memory": "10.0.8", - "Microsoft.Extensions.Logging": "10.0.8" + "Microsoft.EntityFrameworkCore.Analyzers": "10.0.8" } }, "Microsoft.EntityFrameworkCore.Design": { @@ -38,10 +42,7 @@ "Microsoft.CodeAnalysis.CSharp.Workspaces": "5.0.0", "Microsoft.CodeAnalysis.Workspaces.MSBuild": "5.0.0", "Microsoft.EntityFrameworkCore.Relational": "10.0.8", - "Microsoft.Extensions.Caching.Memory": "10.0.8", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", "Microsoft.Extensions.DependencyModel": "10.0.8", - "Microsoft.Extensions.Logging": "10.0.8", "Mono.TextTemplating": "3.0.0", "Newtonsoft.Json": "13.0.3" } @@ -53,10 +54,7 @@ "contentHash": "8BGSSKBDDBC8s6ye1Y2Ar1BToeZHLHOzUn0nAOng4Z+8dJ4KQKC/1qYFPgRYchDCOMQh98REHco8SrrMYsHuMQ==", "dependencies": { "Microsoft.EntityFrameworkCore.Sqlite.Core": "10.0.8", - "Microsoft.Extensions.Caching.Memory": "10.0.8", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", "Microsoft.Extensions.DependencyModel": "10.0.8", - "Microsoft.Extensions.Logging": "10.0.8", "SQLitePCLRaw.bundle_e_sqlite3": "2.1.11", "SQLitePCLRaw.core": "2.1.11" } @@ -67,7 +65,6 @@ "resolved": "10.0.8", "contentHash": "XXYEV1G6ILrK7F3zwjQxxbYKZba79NUz7cgy1wEjctcxNHI5i8YI5eOCkPhcZ//vvuT8vd+GdNBfPdYDOPCL1A==", "dependencies": { - "Microsoft.Extensions.Http": "10.0.8", "Polly": "7.2.4", "Polly.Extensions.Http": "3.0.0" } @@ -96,17 +93,17 @@ "resolved": "0.49.1", "contentHash": "Meygd8HAnUgqYzxvCsaYR5XnZAG2xBmxkQHVGi/HkCjrvEq+tiM+VPQRvYLxsbse3KUmec65ccdMiOXv8CkjsA==" }, - "Swashbuckle.AspNetCore": { + "SixLabors.ImageSharp": { "type": "Direct", - "requested": "[10.2.1, )", - "resolved": "10.2.1", - "contentHash": "SDU6akgCV/H4jFMRfyJ0mgO5jWOuuAqekvEThXg8c/LjnfNz5Nkaz+RUpeTVJKWIRX4wDKC/6R3ogJ4AsRE32A==", - "dependencies": { - "Microsoft.Extensions.ApiDescription.Server": "10.0.0", - "Swashbuckle.AspNetCore.Swagger": "10.2.1", - "Swashbuckle.AspNetCore.SwaggerGen": "10.2.1", - "Swashbuckle.AspNetCore.SwaggerUI": "10.2.1" - } + "requested": "[3.1.12, )", + "resolved": "3.1.12", + "contentHash": "iAg6zifihXEFS/t7fiHhZBGAdCp3FavsF4i2ZIDp0JfeYeDVzvmlbY1CNhhIKimaIzrzSi5M/NBFcWvZT2rB/A==" + }, + "TagLibSharp": { + "type": "Direct", + "requested": "[2.3.0, )", + "resolved": "2.3.0", + "contentHash": "Qo4z6ZjnIfbR3Us1Za5M2vQ97OWZPmODvVmepxZ8XW0UIVLGdO2T63/N3b23kCcyiwuIe0TQvMEQG8wUCCD1mA==" }, "Humanizer.Core": { "type": "Transitive", @@ -173,11 +170,6 @@ "Microsoft.Build.Framework": "17.11.31", "Microsoft.CodeAnalysis.Analyzers": "3.11.0", "Microsoft.CodeAnalysis.Workspaces.Common": "[5.0.0]", - "Microsoft.Extensions.DependencyInjection": "9.0.0", - "Microsoft.Extensions.Logging": "9.0.0", - "Microsoft.Extensions.Logging.Abstractions": "9.0.0", - "Microsoft.Extensions.Options": "9.0.0", - "Microsoft.Extensions.Primitives": "9.0.0", "Microsoft.VisualStudio.SolutionPersistence": "1.0.52", "Newtonsoft.Json": "13.0.3", "System.Composition": "9.0.0" @@ -198,10 +190,7 @@ "resolved": "10.0.8", "contentHash": "UU3diAD2wwZveye2rnrwaF/wvJ9tm5iL2fuY9TTap6/iGQK1OO29M1BzXZRlRPVH/dByt5w/pISBSFtyR7hTqw==", "dependencies": { - "Microsoft.EntityFrameworkCore": "10.0.8", - "Microsoft.Extensions.Caching.Memory": "10.0.8", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", - "Microsoft.Extensions.Logging": "10.0.8" + "Microsoft.EntityFrameworkCore": "10.0.8" } }, "Microsoft.EntityFrameworkCore.Sqlite.Core": { @@ -211,163 +200,15 @@ "dependencies": { "Microsoft.Data.Sqlite.Core": "10.0.8", "Microsoft.EntityFrameworkCore.Relational": "10.0.8", - "Microsoft.Extensions.Caching.Memory": "10.0.8", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", "Microsoft.Extensions.DependencyModel": "10.0.8", - "Microsoft.Extensions.Logging": "10.0.8", "SQLitePCLRaw.core": "2.1.11" } }, - "Microsoft.Extensions.ApiDescription.Server": { - "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "NCWCGiwRwje8773yzPQhvucYnnfeR+ZoB1VRIrIMp4uaeUNw7jvEPHij3HIbwCDuNCrNcphA00KSAR9yD9qmbg==" - }, - "Microsoft.Extensions.Caching.Abstractions": { - "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "EoK2TwVR1daxmfXUPnvIYZSk5XQjHe45sGekox4kvMt88KQZQhDVzYW5Na5+oNwTuRpE48hipyGJg12F1Tm70w==", - "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.8" - } - }, - "Microsoft.Extensions.Caching.Memory": { - "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "sYMYQjNprfqPTryuLNnr0/AOtnhlfuZ0ZxyOV0d3AXOEL8j9KV0EbelpZYyIatT2hJiaSGO9XGr5YDRsh22OfQ==", - "dependencies": { - "Microsoft.Extensions.Caching.Abstractions": "10.0.8", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", - "Microsoft.Extensions.Logging.Abstractions": "10.0.8", - "Microsoft.Extensions.Options": "10.0.8", - "Microsoft.Extensions.Primitives": "10.0.8" - } - }, - "Microsoft.Extensions.Configuration": { - "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "ehZcoPbjzWzS4XFvuz7R3V55SmpdkyMqFURLH3yXaN9NtXd9tR6CGB7pd49HYtCkenl+G7ctXSFLhNI08xLfRg==", - "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", - "Microsoft.Extensions.Primitives": "10.0.8" - } - }, - "Microsoft.Extensions.Configuration.Abstractions": { - "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "I63esIFbL3h5pSt7gXpXOlmcwDmYBUoYNEglKfDPFUqtYvSV84f2l28hO2lfVXsV0wdlplgAM7IVz16matapSg==", - "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.8" - } - }, - "Microsoft.Extensions.Configuration.Binder": { - "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "R3NN1X+kVu14uoxLEW6sBSQyhogDSbaOQzILnCtuXxBN4hx22AgjWPwZX6v/suERFkEDgU1lk12AglHTrUxhlw==", - "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.8", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.8" - } - }, - "Microsoft.Extensions.DependencyInjection": { - "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "daf62xHIrq8pnE709hgaZZN9tSam9TGGepWe1+bE6V3GEuVwJiMs6ib+38lfMCyAJAHiX0vapxBhsuMSV7U+cg==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8" - } - }, - "Microsoft.Extensions.DependencyInjection.Abstractions": { - "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "21nbDV60SRPWGIivsyl6lqBeEJNG1sginhhfWgRrr3Ais7aQ12To25OAHQxgoiJkjqy1aQ6RxpZBGYuTi7Ge6A==" - }, "Microsoft.Extensions.DependencyModel": { "type": "Transitive", "resolved": "10.0.8", "contentHash": "vLyZVpxmduO2jx+76ggqnsA3m81kwMY3NkWciNTj5E+Nvqb0VihqCvQP89QsGONWp0AJwMZG+u9GzaCjDdFGNw==" }, - "Microsoft.Extensions.Diagnostics": { - "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "uduyw9d3Fi+sbredO5drA1S44AQS2FRNFyn72UmB2vmQIO1qaXprpp1U/2lYhYi8yFdVERfY9sy/pxw/qPOU9w==", - "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.8", - "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.8", - "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.8" - } - }, - "Microsoft.Extensions.Diagnostics.Abstractions": { - "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "+f4C5g78QCGNyxzUfrTYsB7qYx06Zca0e88s3qFlea9/lQhgPImYdNprlgzl1uHhRU3fVHLfmbijayU2sJEZ6w==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", - "Microsoft.Extensions.Options": "10.0.8" - } - }, - "Microsoft.Extensions.Http": { - "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "/9LU/KWJOrtZJB9ymPjcARDyjp679BvBA/aSncv2Kt84WlSKz767HtxHg8EFsu8n21BMLZi+5XxlkKbLwfn4iA==", - "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", - "Microsoft.Extensions.Diagnostics": "10.0.8", - "Microsoft.Extensions.Logging": "10.0.8", - "Microsoft.Extensions.Logging.Abstractions": "10.0.8", - "Microsoft.Extensions.Options": "10.0.8" - } - }, - "Microsoft.Extensions.Logging": { - "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "K60JhWC2hN/Gi7TP68tBxSzk5ACWOs7lkmPzsfA8Bcf/IXTajujt2ORMf9rSMk1bsng6Lv4Y3fuxp3bm1+15ug==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection": "10.0.8", - "Microsoft.Extensions.Logging.Abstractions": "10.0.8", - "Microsoft.Extensions.Options": "10.0.8" - } - }, - "Microsoft.Extensions.Logging.Abstractions": { - "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "fdVadZmsC8jRP0KvKy8mO8f6GV/HyBvElfcSxEhd+5FM5boAw/01iSaCto5G3G37ApJira4A3pNaVvBv8cUiLQ==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8" - } - }, - "Microsoft.Extensions.Options": { - "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "VBD+131DpTNCNDfA4kIyKTiCySvJGNhwibdWBSdFRu7GMfXLXcXODkgA+KStKbbhzraLglZWUN4nXyHgW4JIRA==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", - "Microsoft.Extensions.Primitives": "10.0.8" - } - }, - "Microsoft.Extensions.Options.ConfigurationExtensions": { - "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "VOapXeO3lhBH0zYoyAH7tjapuo4V5pTHlevPpiSHueEquAajqd5nF0mttm+h/uE/exwAEuM5s26SzOJtletE3w==", - "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", - "Microsoft.Extensions.Configuration.Binder": "10.0.8", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", - "Microsoft.Extensions.Options": "10.0.8", - "Microsoft.Extensions.Primitives": "10.0.8" - } - }, - "Microsoft.Extensions.Primitives": { - "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "OBPo4nYhMyIbtueoC10CBm6AGAbo/A9IV8QQ/6ryZS7VvmqpGT7hunazeHLxFawRzn3oLOq4jhqhpBX4tfswWQ==" - }, - "Microsoft.OpenApi": { - "type": "Transitive", - "resolved": "2.7.5", - "contentHash": "0FA67RSnRM4tcBKqiqVu/HPdZ9+QOKbmeRjxRUGTCjPU4C0bmUhd97Dso7Yild5P7nOV6GxJ2xrK0Kv/O9xp0w==" - }, "Microsoft.VisualStudio.SolutionPersistence": { "type": "Transitive", "resolved": "1.0.52", @@ -430,27 +271,6 @@ "SQLitePCLRaw.core": "3.0.3" } }, - "Swashbuckle.AspNetCore.Swagger": { - "type": "Transitive", - "resolved": "10.2.1", - "contentHash": "ej4inPhiWCq+0utG8yaKhIhE8M3k3R/qRaGhpgDZB+O/s+o62/zRMO1Cn2CtQccsrqPE9PYnzCp6hQGYGpJOyQ==", - "dependencies": { - "Microsoft.OpenApi": "2.7.5" - } - }, - "Swashbuckle.AspNetCore.SwaggerGen": { - "type": "Transitive", - "resolved": "10.2.1", - "contentHash": "JYX6i/y0xEtQWH/hZyfcage1/ldwww83ueD/gBc34uSnMwyvRLUsOpYcxlliFFxFbZMrY6t+R9ENqolE7zTEOg==", - "dependencies": { - "Swashbuckle.AspNetCore.Swagger": "10.2.1" - } - }, - "Swashbuckle.AspNetCore.SwaggerUI": { - "type": "Transitive", - "resolved": "10.2.1", - "contentHash": "vzB8ZAGqXus3fdareJ9GHctaRP9ZL+wW9x8U7s1Y+BWprInFvSg6rpD9VhANNpwXA8fUHqu5Agjl/+hHG1BCQA==" - }, "System.CodeDom": { "type": "Transitive", "resolved": "6.0.0", @@ -508,25 +328,12 @@ "type": "Project", "dependencies": { "AsyncKeyedLock": "[8.0.2, )", - "HtmlAgilityPack": "[1.12.4, )", - "Listenarr.Domain": "[1.0.0, )", - "Microsoft.Data.Sqlite.Core": "[10.0.8, )", - "Microsoft.EntityFrameworkCore": "[10.0.8, )", - "Microsoft.EntityFrameworkCore.Sqlite": "[10.0.8, )", - "SixLabors.ImageSharp": "[3.1.12, )", - "Swashbuckle.AspNetCore": "[10.2.1, )", - "TagLibSharp": "[2.3.0, )" + "Listenarr.Domain": "[1.0.0, )" } }, "listenarr.domain": { "type": "Project" }, - "AsyncKeyedLock": { - "type": "CentralTransitive", - "requested": "[8.0.2, )", - "resolved": "8.0.2", - "contentHash": "QGys5cnIerNryv7V14PDkvGnlLz69kJtTfdnr+Lndcu+lRre397RNyU4FIeAJWgI9u73lTzXL52Qca9B/ncLXw==" - }, "Microsoft.Data.Sqlite.Core": { "type": "CentralTransitive", "requested": "[10.0.8, )", @@ -536,12 +343,6 @@ "SQLitePCLRaw.core": "2.1.11" } }, - "SixLabors.ImageSharp": { - "type": "CentralTransitive", - "requested": "[3.1.12, )", - "resolved": "3.1.12", - "contentHash": "iAg6zifihXEFS/t7fiHhZBGAdCp3FavsF4i2ZIDp0JfeYeDVzvmlbY1CNhhIKimaIzrzSi5M/NBFcWvZT2rB/A==" - }, "SQLitePCLRaw.bundle_e_sqlite3": { "type": "CentralTransitive", "requested": "[3.0.3, )", @@ -551,12 +352,6 @@ "SQLitePCLRaw.config.e_sqlite3": "3.0.3", "SourceGear.sqlite3": "3.50.4.5" } - }, - "TagLibSharp": { - "type": "CentralTransitive", - "requested": "[2.3.0, )", - "resolved": "2.3.0", - "contentHash": "Qo4z6ZjnIfbR3Us1Za5M2vQ97OWZPmODvVmepxZ8XW0UIVLGdO2T63/N3b23kCcyiwuIe0TQvMEQG8wUCCD1mA==" } } } diff --git a/tests/packages.lock.json b/tests/packages.lock.json index 86bac4749..1f87661d1 100644 --- a/tests/packages.lock.json +++ b/tests/packages.lock.json @@ -176,14 +176,6 @@ "resolved": "10.0.0", "contentHash": "NCWCGiwRwje8773yzPQhvucYnnfeR+ZoB1VRIrIMp4uaeUNw7jvEPHij3HIbwCDuNCrNcphA00KSAR9yD9qmbg==" }, - "Microsoft.Extensions.Caching.Abstractions": { - "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "EoK2TwVR1daxmfXUPnvIYZSk5XQjHe45sGekox4kvMt88KQZQhDVzYW5Na5+oNwTuRpE48hipyGJg12F1Tm70w==", - "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.8" - } - }, "Microsoft.Extensions.Caching.Memory": { "type": "Transitive", "resolved": "10.0.8", @@ -282,11 +274,6 @@ "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8" } }, - "Microsoft.Extensions.DependencyInjection.Abstractions": { - "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "21nbDV60SRPWGIivsyl6lqBeEJNG1sginhhfWgRrr3Ais7aQ12To25OAHQxgoiJkjqy1aQ6RxpZBGYuTi7Ge6A==" - }, "Microsoft.Extensions.DependencyModel": { "type": "Transitive", "resolved": "10.0.8", @@ -375,19 +362,6 @@ "Microsoft.Extensions.Logging.Abstractions": "10.0.8" } }, - "Microsoft.Extensions.Http": { - "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "/9LU/KWJOrtZJB9ymPjcARDyjp679BvBA/aSncv2Kt84WlSKz767HtxHg8EFsu8n21BMLZi+5XxlkKbLwfn4iA==", - "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", - "Microsoft.Extensions.Diagnostics": "10.0.8", - "Microsoft.Extensions.Logging": "10.0.8", - "Microsoft.Extensions.Logging.Abstractions": "10.0.8", - "Microsoft.Extensions.Options": "10.0.8" - } - }, "Microsoft.Extensions.Logging": { "type": "Transitive", "resolved": "10.0.8", @@ -398,14 +372,6 @@ "Microsoft.Extensions.Options": "10.0.8" } }, - "Microsoft.Extensions.Logging.Abstractions": { - "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "fdVadZmsC8jRP0KvKy8mO8f6GV/HyBvElfcSxEhd+5FM5boAw/01iSaCto5G3G37ApJira4A3pNaVvBv8cUiLQ==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8" - } - }, "Microsoft.Extensions.Logging.Configuration": { "type": "Transitive", "resolved": "10.0.8", @@ -467,15 +433,6 @@ "Microsoft.Extensions.Primitives": "10.0.8" } }, - "Microsoft.Extensions.Options": { - "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "VBD+131DpTNCNDfA4kIyKTiCySvJGNhwibdWBSdFRu7GMfXLXcXODkgA+KStKbbhzraLglZWUN4nXyHgW4JIRA==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", - "Microsoft.Extensions.Primitives": "10.0.8" - } - }, "Microsoft.Extensions.Options.ConfigurationExtensions": { "type": "Transitive", "resolved": "10.0.8", @@ -709,14 +666,12 @@ "type": "Project", "dependencies": { "AsyncKeyedLock": "[8.0.2, )", - "HtmlAgilityPack": "[1.12.4, )", "Listenarr.Domain": "[1.0.0, )", - "Microsoft.Data.Sqlite.Core": "[10.0.8, )", - "Microsoft.EntityFrameworkCore": "[10.0.8, )", - "Microsoft.EntityFrameworkCore.Sqlite": "[10.0.8, )", - "SixLabors.ImageSharp": "[3.1.12, )", - "Swashbuckle.AspNetCore": "[10.2.1, )", - "TagLibSharp": "[2.3.0, )" + "Microsoft.Extensions.Caching.Abstractions": "[10.0.8, )", + "Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.8, )", + "Microsoft.Extensions.Http": "[10.0.8, )", + "Microsoft.Extensions.Logging.Abstractions": "[10.0.8, )", + "Microsoft.Extensions.Options": "[10.0.8, )" } }, "listenarr.domain": { @@ -725,6 +680,7 @@ "listenarr.infrastructure": { "type": "Project", "dependencies": { + "AsyncKeyedLock": "[8.0.2, )", "BencodeNET": "[4.0.0, )", "HtmlAgilityPack": "[1.12.4, )", "Listenarr.Application": "[1.0.0, )", @@ -735,7 +691,8 @@ "Polly": "[8.6.6, )", "Serilog.Sinks.File": "[7.0.0, )", "SharpCompress": "[0.49.1, )", - "Swashbuckle.AspNetCore": "[10.2.1, )" + "SixLabors.ImageSharp": "[3.1.12, )", + "TagLibSharp": "[2.3.0, )" } }, "Asp.Versioning.Mvc": { @@ -783,6 +740,35 @@ "Microsoft.OpenApi": "2.0.0" } }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.8, )", + "resolved": "10.0.8", + "contentHash": "EoK2TwVR1daxmfXUPnvIYZSk5XQjHe45sGekox4kvMt88KQZQhDVzYW5Na5+oNwTuRpE48hipyGJg12F1Tm70w==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.8" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.8, )", + "resolved": "10.0.8", + "contentHash": "21nbDV60SRPWGIivsyl6lqBeEJNG1sginhhfWgRrr3Ais7aQ12To25OAHQxgoiJkjqy1aQ6RxpZBGYuTi7Ge6A==" + }, + "Microsoft.Extensions.Http": { + "type": "CentralTransitive", + "requested": "[10.0.8, )", + "resolved": "10.0.8", + "contentHash": "/9LU/KWJOrtZJB9ymPjcARDyjp679BvBA/aSncv2Kt84WlSKz767HtxHg8EFsu8n21BMLZi+5XxlkKbLwfn4iA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", + "Microsoft.Extensions.Diagnostics": "10.0.8", + "Microsoft.Extensions.Logging": "10.0.8", + "Microsoft.Extensions.Logging.Abstractions": "10.0.8", + "Microsoft.Extensions.Options": "10.0.8" + } + }, "Microsoft.Extensions.Http.Polly": { "type": "CentralTransitive", "requested": "[10.0.8, )", @@ -794,6 +780,25 @@ "Polly.Extensions.Http": "3.0.0" } }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.8, )", + "resolved": "10.0.8", + "contentHash": "fdVadZmsC8jRP0KvKy8mO8f6GV/HyBvElfcSxEhd+5FM5boAw/01iSaCto5G3G37ApJira4A3pNaVvBv8cUiLQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8" + } + }, + "Microsoft.Extensions.Options": { + "type": "CentralTransitive", + "requested": "[10.0.8, )", + "resolved": "10.0.8", + "contentHash": "VBD+131DpTNCNDfA4kIyKTiCySvJGNhwibdWBSdFRu7GMfXLXcXODkgA+KStKbbhzraLglZWUN4nXyHgW4JIRA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", + "Microsoft.Extensions.Primitives": "10.0.8" + } + }, "Polly": { "type": "CentralTransitive", "requested": "[8.6.6, )", From c8ead2062b7f6ed2d0356ba87d30325dbdc2ce84 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Thu, 11 Jun 2026 23:05:06 -0400 Subject: [PATCH 10/84] Handle IPv4-mapped loopback notification callers --- .../SecurityRequestHttpContextExtensions.cs | 9 +--- .../Notification/NotificationService.cs | 2 +- .../Security/SecurityRequestUtils.cs | 17 +++--- .../Api/Services/NotificationServiceTests.cs | 52 +++++++++++++++++++ 4 files changed, 65 insertions(+), 15 deletions(-) diff --git a/listenarr.api/Security/SecurityRequestHttpContextExtensions.cs b/listenarr.api/Security/SecurityRequestHttpContextExtensions.cs index 3a019d985..571ec81fe 100644 --- a/listenarr.api/Security/SecurityRequestHttpContextExtensions.cs +++ b/listenarr.api/Security/SecurityRequestHttpContextExtensions.cs @@ -7,8 +7,6 @@ * by the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. */ -using System.Net; - namespace Listenarr.Api.Security; public static class HttpSecurityRequestUtils @@ -21,12 +19,7 @@ public static bool IsLoopbackRequest(HttpContext? context) return true; } - if (ip.IsIPv4MappedToIPv6) - { - ip = ip.MapToIPv4(); - } - - return IPAddress.IsLoopback(ip); + return Listenarr.Application.Security.SecurityRequestUtils.IsLoopback(ip); } public static bool IsLocalOrPrivateRequest(HttpContext? context) diff --git a/listenarr.application/Notification/NotificationService.cs b/listenarr.application/Notification/NotificationService.cs index f103adf1c..5da6074e8 100644 --- a/listenarr.application/Notification/NotificationService.cs +++ b/listenarr.application/Notification/NotificationService.cs @@ -106,7 +106,7 @@ private bool AllowPrivateWebhookTargetsForCurrentRequest() } return context.RemoteIpAddress == null - || System.Net.IPAddress.IsLoopback(context.RemoteIpAddress) + || SecurityRequestUtils.IsLoopback(context.RemoteIpAddress) || context.IsAuthenticatedAdminOrApiKey; } diff --git a/listenarr.application/Security/SecurityRequestUtils.cs b/listenarr.application/Security/SecurityRequestUtils.cs index 935f28e39..0bba11f24 100644 --- a/listenarr.application/Security/SecurityRequestUtils.cs +++ b/listenarr.application/Security/SecurityRequestUtils.cs @@ -23,6 +23,16 @@ namespace Listenarr.Application.Security; public static class SecurityRequestUtils { + public static bool IsLoopback(IPAddress ip) + { + if (ip.IsIPv4MappedToIPv6) + { + ip = ip.MapToIPv4(); + } + + return IPAddress.IsLoopback(ip); + } + public static string HashSecretForLog(string? secret, string prefix = "sha256") { if (string.IsNullOrWhiteSpace(secret)) @@ -45,12 +55,7 @@ public static string HashSecretForLog(string? secret, string prefix = "sha256") public static bool IsPrivateOrLoopback(IPAddress ip) { - if (ip.IsIPv4MappedToIPv6) - { - ip = ip.MapToIPv4(); - } - - if (IPAddress.IsLoopback(ip)) + if (IsLoopback(ip)) { return true; } diff --git a/tests/Features/Api/Services/NotificationServiceTests.cs b/tests/Features/Api/Services/NotificationServiceTests.cs index e56e2605d..104f2d74b 100644 --- a/tests/Features/Api/Services/NotificationServiceTests.cs +++ b/tests/Features/Api/Services/NotificationServiceTests.cs @@ -382,6 +382,58 @@ public async Task SendNotificationAsync_PostsCorrectJsonToDiscordWebhook() } } + + [Fact] + public async Task SendNotificationAsync_AllowsPrivateWebhook_WhenCallerIsIpv4MappedLoopback() + { + var trigger = "book-added"; + var webhookUrl = "http://127.0.0.1:4545/webhook"; + var enabledTriggers = new List { trigger }; + var data = new { title = "Local Webhook Book" }; + + HttpRequestMessage? capturedRequest = null; + var mockHttpMessageHandler = new Mock(); + using var postResponse = new HttpResponseMessage(HttpStatusCode.OK); + + mockHttpMessageHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback((request, _) => + { + capturedRequest = request; + }) + .ReturnsAsync(postResponse); + + var mockConfigService = new Mock(); + mockConfigService + .Setup(x => x.GetStartupConfigAsync()) + .ReturnsAsync(new StartupConfig()); + + var mockRequestContextAccessor = new Mock(); + mockRequestContextAccessor + .Setup(x => x.Current) + .Returns(new RequestContextSnapshot( + Path: null, + Scheme: "http", + Host: "localhost:4545", + RemoteIpAddress: IPAddress.Parse("::ffff:127.0.0.1"), + IsAuthenticatedAdminOrApiKey: false)); + + var service = new NotificationService( + new HttpClient(mockHttpMessageHandler.Object), + Mock.Of>(), + mockConfigService.Object, + new NotificationPayloadBuilderAdapter(), + mockRequestContextAccessor.Object); + + await service.SendNotificationAsync(trigger, data, webhookUrl, enabledTriggers); + + Assert.NotNull(capturedRequest); + Assert.Equal(webhookUrl, capturedRequest!.RequestUri?.ToString()); + } } public partial class NotificationServiceTests From d1b9a240e74078543558c89cdea95a97702edcb9 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Fri, 12 Jun 2026 05:11:16 -0400 Subject: [PATCH 11/84] Extract large backend service slices --- .../Controllers/LibraryController.cs | 591 +----------------- listenarr.api/Program.cs | 1 + .../AudiobookFilesystemDeleteResult.cs | 58 ++ .../IAudiobookFilesystemDeleteService.cs | 27 + .../Search/SearchResultSortingService.cs | 191 ++++++ listenarr.application/Search/SearchService.cs | 242 +------ .../AppServiceRegistrationExtensions.cs | 1 + .../AudiobookFilesystemDeleteService.cs | 567 +++++++++++++++++ tests/Builders/ServiceCollectionBuilder.cs | 2 + .../Providers/IndexersNewznabParsingTests.cs | 2 + .../Api/Services/SearchServiceFixesTests.cs | 2 + .../Api/Services/SearchServiceScoringTests.cs | 2 + .../Api/Services/SearchServiceSortingTests.cs | 19 +- 13 files changed, 876 insertions(+), 829 deletions(-) create mode 100644 listenarr.application/Audiobooks/AudiobookFilesystemDeleteResult.cs create mode 100644 listenarr.application/Audiobooks/IAudiobookFilesystemDeleteService.cs create mode 100644 listenarr.application/Search/SearchResultSortingService.cs create mode 100644 listenarr.infrastructure/FileSystem/AudiobookFilesystemDeleteService.cs diff --git a/listenarr.api/Controllers/LibraryController.cs b/listenarr.api/Controllers/LibraryController.cs index ed1d665b1..3a93dc629 100644 --- a/listenarr.api/Controllers/LibraryController.cs +++ b/listenarr.api/Controllers/LibraryController.cs @@ -56,7 +56,6 @@ public class LibraryController : ControllerBase private readonly IAudiobookFileRepository _audioFileRepository; private readonly IQualityProfileRepository _qualityProfileRepository; private readonly IDownloadRepository _downloadRepository; - private readonly IRootFolderRepository _rootFolderRepository; private readonly IScanQueueService? _scanQueueService; private readonly IMoveQueueService? _moveQueueService; private readonly IFileNamingService _fileNamingService; @@ -65,6 +64,7 @@ public class LibraryController : ControllerBase private readonly ILibraryAddService? _libraryAddService; private readonly IRenameService? _renameService; private readonly ILibraryListService _libraryListService; + private readonly IAudiobookFilesystemDeleteService _audiobookFilesystemDeleteService; private readonly string _contentRootPath; /// Initializes a new instance of . /// Repository for audiobook persistence and queries. @@ -75,7 +75,6 @@ public class LibraryController : ControllerBase /// Repository for audiobook file records. /// Repository for quality profile configuration. /// Repository for active download records. - /// Repository for configured root folder paths. /// Service responsible for applying file naming patterns. /// Optional background scan queue service for asynchronous scans. /// Optional background move queue service for processing move requests. @@ -85,6 +84,7 @@ public class LibraryController : ControllerBase /// Optional organize/rename service used for previewing and executing library file organization. /// Application path service used to resolve content-root-relative cache files. /// Application service that builds the slim library list payload. + /// Application service responsible for safe audiobook filesystem cleanup. public LibraryController( IAudiobookRepository repo, IImageCacheService imageCacheService, @@ -94,10 +94,10 @@ public LibraryController( IAudiobookFileRepository audioFileRepository, IQualityProfileRepository qualityProfileRepository, IDownloadRepository downloadRepository, - IRootFolderRepository rootFolderRepository, IFileNamingService fileNamingService, IApplicationPathService applicationPathService, ILibraryListService libraryListService, + IAudiobookFilesystemDeleteService audiobookFilesystemDeleteService, IScanQueueService? scanQueueService = null, IMoveQueueService? moveQueueService = null, NotificationService? notificationService = null, @@ -113,7 +113,6 @@ public LibraryController( _audioFileRepository = audioFileRepository; _qualityProfileRepository = qualityProfileRepository; _downloadRepository = downloadRepository; - _rootFolderRepository = rootFolderRepository; _fileNamingService = fileNamingService; _scanQueueService = scanQueueService; _moveQueueService = moveQueueService; @@ -122,6 +121,7 @@ public LibraryController( _libraryAddService = libraryAddService; _renameService = renameService; _libraryListService = libraryListService; + _audiobookFilesystemDeleteService = audiobookFilesystemDeleteService; _contentRootPath = applicationPathService.ContentRootPath; } @@ -1299,10 +1299,10 @@ public async Task DeleteAudiobook(int id, [FromQuery] bool delete deleteFiles = deleteFiles || deleteFolder; - DeleteFilesystemResult? filesystemResult = null; + AudiobookFilesystemDeleteResult? filesystemResult = null; if (deleteFiles) { - filesystemResult = await DeleteAudiobookFilesystemAsync(audiobook, deleteFolder); + filesystemResult = await _audiobookFilesystemDeleteService.DeleteAsync(audiobook, deleteFolder); } // Delete associated image from cache if it exists @@ -1373,7 +1373,7 @@ public async Task DeleteAudiobook(int id, [FromQuery] bool delete var deleted = await _repo.DeleteByIdAsync(id); if (deleted) { - var message = BuildDeleteMessage(filesystemResult); + var message = filesystemResult?.BuildDeleteMessage() ?? "Audiobook deleted successfully."; return Ok(new { message, @@ -1388,583 +1388,6 @@ public async Task DeleteAudiobook(int id, [FromQuery] bool delete return StatusCode(500, new { message = "Failed to delete audiobook" }); } - private sealed class DeleteFilesystemResult - { - public int DeletedFiles { get; set; } - public bool DeletedFolder { get; set; } - public bool DeletedParentFolder { get; set; } - public List Warnings { get; } = new List(); - } - - private sealed class DeleteFolderTarget - { - public required string FolderPath { get; init; } - public required IReadOnlyCollection ProtectedRoots { get; init; } - } - - private async Task DeleteAudiobookFilesystemAsync(Audiobook audiobook, bool deleteFolder) - { - var result = new DeleteFilesystemResult(); - var trackedFilePaths = CollectTrackedFilePaths(audiobook); - var deleteTarget = await ResolveDeleteFolderTargetAsync(audiobook, trackedFilePaths, result); - - if (deleteTarget != null) - { - TryDeleteFolderContents(deleteTarget.FolderPath, result); - - if (deleteFolder) - { - await TryDeleteAudiobookFolderAsync(audiobook, deleteTarget, result); - } - } - else - { - foreach (var trackedFilePath in trackedFilePaths) - { - TryDeleteFile(trackedFilePath, result); - } - } - - return result; - } - - private static IReadOnlyList CollectTrackedFilePaths(Audiobook audiobook) - { - var paths = new HashSet(StringComparer.OrdinalIgnoreCase); - - if (!string.IsNullOrWhiteSpace(audiobook.FilePath)) - { - var normalizedLegacy = NormalizePath(audiobook.FilePath); - if (!string.IsNullOrWhiteSpace(normalizedLegacy)) - { - paths.Add(normalizedLegacy); - } - } - - if (audiobook.Files != null) - { - foreach (var normalizedTracked in audiobook.Files - .Select(file => NormalizePath(file.Path)) - .Where(normalizedTracked => !string.IsNullOrWhiteSpace(normalizedTracked))) - { - paths.Add(normalizedTracked!); - } - } - - return paths.ToList(); - } - - private void TryDeleteFile(string path, DeleteFilesystemResult result) - { - try - { - if (!System.IO.File.Exists(path)) - { - return; - } - - System.IO.File.Delete(path); - result.DeletedFiles++; - _logger.LogInformation("Deleted audiobook file {Path}", LogRedaction.SanitizeFilePath(path)); - } - catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException) - { - var warning = $"Could not delete file '{Path.GetFileName(path)}'."; - result.Warnings.Add(warning); - _logger.LogWarning(ex, "Failed to delete audiobook file {Path}", LogRedaction.SanitizeFilePath(path)); - } - } - - private void TryDeleteFolderContents(string folderPath, DeleteFilesystemResult result) - { - if (!Directory.Exists(folderPath)) - { - return; - } - - string[] files; - try - { - files = Directory.GetFiles(folderPath, "*", SearchOption.AllDirectories); - } - catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException) - { - result.Warnings.Add("Could not enumerate the audiobook folder contents for deletion."); - _logger.LogWarning(ex, "Failed to enumerate audiobook folder contents for {FolderPath}", LogRedaction.SanitizeFilePath(folderPath)); - return; - } - - foreach (var filePath in files) - { - TryDeleteFile(filePath, result); - } - - string[] directories; - try - { - directories = Directory.GetDirectories(folderPath, "*", SearchOption.AllDirectories) - .OrderByDescending(path => path.Length) - .ToArray(); - } - catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException) - { - result.Warnings.Add("Some nested folders could not be cleaned up after file deletion."); - _logger.LogWarning(ex, "Failed to enumerate nested audiobook directories for {FolderPath}", LogRedaction.SanitizeFilePath(folderPath)); - return; - } - - foreach (var directoryPath in directories) - { - try - { - if (!Directory.Exists(directoryPath)) - { - continue; - } - - if (!Directory.EnumerateFileSystemEntries(directoryPath).Any()) - { - Directory.Delete(directoryPath, recursive: false); - } - } - catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException) - { - _logger.LogDebug(ex, "Failed to remove nested audiobook directory {FolderPath}", LogRedaction.SanitizeFilePath(directoryPath)); - } - } - } - - private async Task ResolveDeleteFolderTargetAsync( - Audiobook audiobook, - IReadOnlyList trackedFilePaths, - DeleteFilesystemResult result) - { - var protectedRoots = await GetProtectedRootPathsAsync(); - var folderPath = ResolveAudiobookFolderPath(audiobook, trackedFilePaths); - if (string.IsNullOrWhiteSpace(folderPath)) - { - result.Warnings.Add("Audiobook folder could not be determined, so only tracked audiobook files were deleted."); - return null; - } - - if (protectedRoots.Any(root => PathsEqual(root, folderPath))) - { - var fallbackFolderPath = ResolveTrackedFolderPath(trackedFilePaths); - if (!string.IsNullOrWhiteSpace(fallbackFolderPath) - && !protectedRoots.Any(root => PathsEqual(root, fallbackFolderPath)) - && IsSamePathOrWithin(fallbackFolderPath, folderPath)) - { - folderPath = fallbackFolderPath; - } - } - - if (IsFilesystemRoot(folderPath)) - { - result.Warnings.Add("Refused to delete all files in a filesystem root folder."); - return null; - } - - if (protectedRoots.Any(root => PathsEqual(root, folderPath))) - { - result.Warnings.Add("Refused to delete all files in a configured library root folder."); - return null; - } - - if (!Directory.Exists(folderPath)) - { - return null; - } - - var allFiles = await _audioFileRepository.GetAllAsync(); - var otherFilePaths = allFiles - .Where(f => f.AudiobookId != audiobook.Id && f.Path != null) - .Select(f => f.Path!) - .ToList(); - - if (otherFilePaths - .Select(NormalizePath) - .Any(p => !string.IsNullOrWhiteSpace(p) && IsSamePathOrWithin(p!, folderPath))) - { - result.Warnings.Add("Refused to delete all files in the audiobook folder because other audiobook files are inside it."); - return null; - } - - var allAudiobooks = await _repo.GetAllAsync(); - var otherAudiobookPaths = allAudiobooks - .Where(a => a.Id != audiobook.Id) - .Select(a => new { a.Id, a.BasePath, a.FilePath }) - .ToList(); - - foreach (var otherPath in otherAudiobookPaths) - { - var otherBasePath = NormalizePath(otherPath.BasePath); - if (!string.IsNullOrWhiteSpace(otherBasePath) - && (IsSamePathOrWithin(otherBasePath, folderPath) || IsSamePathOrWithin(folderPath, otherBasePath))) - { - result.Warnings.Add("Refused to delete all files in the audiobook folder because another audiobook references that location."); - return null; - } - - var otherFilePath = NormalizePath(otherPath.FilePath); - if (!string.IsNullOrWhiteSpace(otherFilePath) && IsSamePathOrWithin(otherFilePath, folderPath)) - { - result.Warnings.Add("Refused to delete all files in the audiobook folder because another audiobook file is inside it."); - return null; - } - } - - return new DeleteFolderTarget - { - FolderPath = folderPath, - ProtectedRoots = protectedRoots - }; - } - - private async Task TryDeleteAudiobookFolderAsync(Audiobook audiobook, DeleteFolderTarget deleteTarget, DeleteFilesystemResult result) - { - if (!Directory.Exists(deleteTarget.FolderPath)) - { - return; - } - - try - { - Directory.Delete(deleteTarget.FolderPath, recursive: true); - result.DeletedFolder = true; - _logger.LogInformation("Deleted audiobook folder {FolderPath}", LogRedaction.SanitizeFilePath(deleteTarget.FolderPath)); - await TryDeleteEmptyAuthorFolderAsync(audiobook, deleteTarget.FolderPath, deleteTarget.ProtectedRoots, result); - } - catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException) - { - result.Warnings.Add("Failed to delete the audiobook folder."); - _logger.LogWarning(ex, "Failed to delete audiobook folder {FolderPath}", LogRedaction.SanitizeFilePath(deleteTarget.FolderPath)); - } - } - - private async Task TryDeleteEmptyAuthorFolderAsync( - Audiobook audiobook, - string deletedFolderPath, - IReadOnlyCollection protectedRoots, - DeleteFilesystemResult result) - { - var parentFolder = NormalizePath(Path.GetDirectoryName(deletedFolderPath)); - if (string.IsNullOrWhiteSpace(parentFolder) - || IsFilesystemRoot(parentFolder) - || protectedRoots.Any(root => PathsEqual(root, parentFolder)) - || !Directory.Exists(parentFolder) - || !IsAuthorFolder(parentFolder, audiobook.Authors?.FirstOrDefault())) - { - return; - } - - try - { - if (Directory.EnumerateFileSystemEntries(parentFolder).Any()) - { - return; - } - } - catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException) - { - _logger.LogDebug(ex, "Unable to inspect parent folder {FolderPath} after audiobook delete", LogRedaction.SanitizeFilePath(parentFolder)); - return; - } - - var allAbs = await _repo.GetAllAsync(); - var otherAudiobookPaths = allAbs - .Where(a => a.Id != audiobook.Id) - .Select(a => new { a.Id, a.BasePath, a.FilePath }) - .ToList(); - - foreach (var otherPath in otherAudiobookPaths) - { - var otherBasePath = NormalizePath(otherPath.BasePath); - if (!string.IsNullOrWhiteSpace(otherBasePath) - && (IsSamePathOrWithin(otherBasePath, parentFolder) || IsSamePathOrWithin(parentFolder, otherBasePath))) - { - return; - } - - var otherFilePath = NormalizePath(otherPath.FilePath); - if (!string.IsNullOrWhiteSpace(otherFilePath) && IsSamePathOrWithin(otherFilePath, parentFolder)) - { - return; - } - } - - try - { - Directory.Delete(parentFolder, recursive: false); - result.DeletedParentFolder = true; - _logger.LogInformation("Deleted empty parent author folder {FolderPath}", LogRedaction.SanitizeFilePath(parentFolder)); - } - catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException) - { - result.Warnings.Add("Failed to delete the empty author folder."); - _logger.LogWarning(ex, "Failed to delete empty parent author folder {FolderPath}", LogRedaction.SanitizeFilePath(parentFolder)); - } - } - - private async Task> GetProtectedRootPathsAsync() - { - var protectedRoots = new HashSet(StringComparer.OrdinalIgnoreCase); - - try - { - if (_rootFolderService != null) - { - var roots = await _rootFolderService.GetAllAsync(); - foreach (var normalizedRoot in roots - .Select(root => NormalizePath(root.Path)) - .Where(normalizedRoot => !string.IsNullOrWhiteSpace(normalizedRoot))) - { - protectedRoots.Add(normalizedRoot!); - } - } - else - { - var roots = (await _rootFolderRepository.GetAllAsync()).Select(r => r.Path).ToList(); - - foreach (var normalizedRoot in roots - .Select(root => NormalizePath(root)) - .Where(normalizedRoot => !string.IsNullOrWhiteSpace(normalizedRoot))) - { - protectedRoots.Add(normalizedRoot!); - } - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Failed to enumerate configured root folders while deleting audiobook files"); - } - - try - { - using var scope = _scopeFactory.CreateScope(); - var configService = scope.ServiceProvider.GetService(); - if (configService != null) - { - var settings = await configService.GetApplicationSettingsAsync(); - var outputPath = NormalizePath(settings?.OutputPath); - if (!string.IsNullOrWhiteSpace(outputPath)) - { - protectedRoots.Add(outputPath); - } - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Failed to load application settings while protecting root folders during delete"); - } - - return protectedRoots; - } - - private static string? ResolveAudiobookFolderPath(Audiobook audiobook, IReadOnlyList trackedFilePaths) - { - var basePath = NormalizePath(audiobook.BasePath); - if (!string.IsNullOrWhiteSpace(basePath)) - { - return basePath; - } - - var legacyFilePath = NormalizePath(audiobook.FilePath); - if (!string.IsNullOrWhiteSpace(legacyFilePath)) - { - return NormalizePath(Path.GetDirectoryName(legacyFilePath)); - } - - return GetCommonDirectoryPath(trackedFilePaths); - } - - private static string? ResolveTrackedFolderPath(IReadOnlyList trackedFilePaths) - { - if (trackedFilePaths.Count == 0) - { - return null; - } - - if (trackedFilePaths.Count == 1) - { - var directFolder = NormalizePath(Path.GetDirectoryName(trackedFilePaths[0])); - if (string.IsNullOrWhiteSpace(directFolder)) - { - return null; - } - - var folderName = Path.GetFileName(directFolder); - if (IsLikelySegmentFolder(folderName)) - { - var parentFolder = NormalizePath(Path.GetDirectoryName(directFolder)); - if (!string.IsNullOrWhiteSpace(parentFolder)) - { - return parentFolder; - } - } - - return directFolder; - } - - return GetCommonDirectoryPath(trackedFilePaths); - } - - private static bool IsLikelySegmentFolder(string? folderName) - { - if (string.IsNullOrWhiteSpace(folderName)) - { - return false; - } - - return Regex.IsMatch( - folderName.Trim(), - @"^(disc|disk|cd|part|chapter|track)[\s._-]*\d+$", - RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - } - - private static string? GetCommonDirectoryPath(IReadOnlyList filePaths) - { - if (filePaths.Count == 0) - { - return null; - } - - var directories = filePaths - .Select(p => NormalizePath(Path.GetDirectoryName(p))) - .Where(p => !string.IsNullOrWhiteSpace(p)) - .Cast() - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToList(); - - if (directories.Count == 0) - { - return null; - } - - var commonPath = directories[0]; - for (var i = 1; i < directories.Count; i++) - { - while (!IsSamePathOrWithin(directories[i], commonPath)) - { - var parent = NormalizePath(Path.GetDirectoryName(commonPath)); - if (string.IsNullOrWhiteSpace(parent) || PathsEqual(parent, commonPath)) - { - return null; - } - - commonPath = parent; - } - } - - return IsFilesystemRoot(commonPath) ? null : commonPath; - } - - private static string? NormalizePath(string? path) - { - if (string.IsNullOrWhiteSpace(path)) - { - return null; - } - - try - { - return FileUtils.NormalizeStoredPath(path) - .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - } - catch (ArgumentException) - { - return null; - } - } - - private static bool PathsEqual(string? left, string? right) - { - if (string.IsNullOrWhiteSpace(left) || string.IsNullOrWhiteSpace(right)) - { - return false; - } - - return string.Equals(left, right, StringComparison.OrdinalIgnoreCase); - } - - private static bool IsSamePathOrWithin(string path, string rootPath) - { - return PathsEqual(path, rootPath) || FileUtils.IsPathInsideOf(path, rootPath); - } - - private static bool IsFilesystemRoot(string? path) - { - if (string.IsNullOrWhiteSpace(path)) - { - return false; - } - - var root = NormalizePath(Path.GetPathRoot(path)); - return !string.IsNullOrWhiteSpace(root) && PathsEqual(root, path); - } - - private static bool IsAuthorFolder(string folderPath, string? authorName) - { - if (string.IsNullOrWhiteSpace(folderPath) || string.IsNullOrWhiteSpace(authorName)) - { - return false; - } - - var folderName = Path.GetFileName(folderPath); - return NormalizeName(folderName) == NormalizeName(authorName); - } - - private static string NormalizeName(string? value) - { - if (string.IsNullOrWhiteSpace(value)) - { - return string.Empty; - } - - var cleaned = new string(value - .Where(c => char.IsLetterOrDigit(c) || char.IsWhiteSpace(c)) - .ToArray()); - - return string.Join( - ' ', - cleaned.Split(new[] { ' ', '\t', '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)) - .ToLowerInvariant(); - } - - private static string BuildDeleteMessage(DeleteFilesystemResult? result) - { - if (result == null) - { - return "Audiobook deleted successfully."; - } - - var cleanupParts = new List(); - if (result.DeletedFiles > 0) - { - cleanupParts.Add($"removed {result.DeletedFiles} file{(result.DeletedFiles == 1 ? string.Empty : "s")}"); - } - - if (result.DeletedFolder) - { - cleanupParts.Add("deleted the audiobook folder"); - } - - if (result.DeletedParentFolder) - { - cleanupParts.Add("deleted the empty author folder"); - } - - var message = cleanupParts.Count > 0 - ? $"Audiobook deleted and {string.Join(" and ", cleanupParts)}." - : "Audiobook deleted successfully."; - - if (result.Warnings.Count > 0) - { - message += " Some filesystem cleanup steps were skipped."; - } - - return message; - } - /// /// Delete multiple audiobooks in a single transaction. /// diff --git a/listenarr.api/Program.cs b/listenarr.api/Program.cs index c32ee465e..1425281f0 100644 --- a/listenarr.api/Program.cs +++ b/listenarr.api/Program.cs @@ -342,6 +342,7 @@ ex is IOException // Add fallback scraper // Add search result scorer builder.Services.AddScoped(); +builder.Services.AddScoped(); // Add ASIN search handler builder.Services.AddScoped(); diff --git a/listenarr.application/Audiobooks/AudiobookFilesystemDeleteResult.cs b/listenarr.application/Audiobooks/AudiobookFilesystemDeleteResult.cs new file mode 100644 index 000000000..66068a582 --- /dev/null +++ b/listenarr.application/Audiobooks/AudiobookFilesystemDeleteResult.cs @@ -0,0 +1,58 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace Listenarr.Application.Audiobooks +{ + public sealed class AudiobookFilesystemDeleteResult + { + public int DeletedFiles { get; set; } + public bool DeletedFolder { get; set; } + public bool DeletedParentFolder { get; set; } + public List Warnings { get; } = new List(); + + public string BuildDeleteMessage() + { + var cleanupParts = new List(); + if (DeletedFiles > 0) + { + cleanupParts.Add($"removed {DeletedFiles} file{(DeletedFiles == 1 ? string.Empty : "s")}"); + } + + if (DeletedFolder) + { + cleanupParts.Add("deleted the audiobook folder"); + } + + if (DeletedParentFolder) + { + cleanupParts.Add("deleted the empty author folder"); + } + + var message = cleanupParts.Count > 0 + ? $"Audiobook deleted and {string.Join(" and ", cleanupParts)}." + : "Audiobook deleted successfully."; + + if (Warnings.Count > 0) + { + message += " Some filesystem cleanup steps were skipped."; + } + + return message; + } + } +} diff --git a/listenarr.application/Audiobooks/IAudiobookFilesystemDeleteService.cs b/listenarr.application/Audiobooks/IAudiobookFilesystemDeleteService.cs new file mode 100644 index 000000000..8b1015021 --- /dev/null +++ b/listenarr.application/Audiobooks/IAudiobookFilesystemDeleteService.cs @@ -0,0 +1,27 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +using Listenarr.Domain.Models; + +namespace Listenarr.Application.Audiobooks +{ + public interface IAudiobookFilesystemDeleteService + { + Task DeleteAsync(Audiobook audiobook, bool deleteFolder); + } +} diff --git a/listenarr.application/Search/SearchResultSortingService.cs b/listenarr.application/Search/SearchResultSortingService.cs new file mode 100644 index 000000000..86d29aca7 --- /dev/null +++ b/listenarr.application/Search/SearchResultSortingService.cs @@ -0,0 +1,191 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +using Listenarr.Application.Interfaces.Repositories; +using Listenarr.Domain.Models; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Application.Search; + +/// +/// Applies user-facing search result ordering without making SearchService own every sorting rule. +/// +public class SearchResultSortingService +{ + private readonly IIndexerRepository _indexerRepository; + private readonly ILogger _logger; + + public SearchResultSortingService( + IIndexerRepository indexerRepository, + ILogger logger) + { + _indexerRepository = indexerRepository; + _logger = logger; + } + + public async Task> ApplySortingAsync( + List results, + SearchSortBy sortBy, + SearchSortDirection sortDirection) + { + if (!results.Any()) + return results; + + IEnumerable orderedResults; + + Dictionary? indexerCache = null; + if (sortBy == SearchSortBy.Seeders || sortBy == SearchSortBy.Smart) + { + var allIndexers = await _indexerRepository.GetAllAsync(); + indexerCache = allIndexers.ToDictionary(i => i.Id); + } + + switch (sortBy) + { + case SearchSortBy.Seeders: + var seedScored = ScoreResults(results, indexerCache); + orderedResults = sortDirection == SearchSortDirection.Descending + ? seedScored.OrderByDescending(x => x.Score).Select(x => x.Result) + : seedScored.OrderBy(x => x.Score).Select(x => x.Result); + break; + + case SearchSortBy.Size: + orderedResults = sortDirection == SearchSortDirection.Descending + ? results.OrderByDescending(r => r.Size) + : results.OrderBy(r => r.Size); + break; + + case SearchSortBy.PublishedDate: + orderedResults = sortDirection == SearchSortDirection.Descending + ? results.OrderByDescending(r => r.PublishedDate) + : results.OrderBy(r => r.PublishedDate); + break; + + case SearchSortBy.Title: + orderedResults = sortDirection == SearchSortDirection.Descending + ? results.OrderByDescending(r => r.Title, StringComparer.OrdinalIgnoreCase) + : results.OrderBy(r => r.Title, StringComparer.OrdinalIgnoreCase); + break; + + case SearchSortBy.Source: + orderedResults = sortDirection == SearchSortDirection.Descending + ? results.OrderByDescending(r => r.Source, StringComparer.OrdinalIgnoreCase) + : results.OrderBy(r => r.Source, StringComparer.OrdinalIgnoreCase); + break; + + case SearchSortBy.Language: + orderedResults = sortDirection == SearchSortDirection.Descending + ? results.OrderByDescending(r => r.Language ?? string.Empty, StringComparer.OrdinalIgnoreCase) + : results.OrderBy(r => r.Language ?? string.Empty, StringComparer.OrdinalIgnoreCase); + break; + + case SearchSortBy.Quality: + orderedResults = sortDirection == SearchSortDirection.Descending + ? results.OrderByDescending(r => GetQualityScore(r.Quality)) + : results.OrderBy(r => GetQualityScore(r.Quality)); + break; + + case SearchSortBy.Smart: + var smartScored = ScoreResults(results, indexerCache); + orderedResults = sortDirection == SearchSortDirection.Descending + ? smartScored.OrderByDescending(x => x.Score).Select(x => x.Result) + : smartScored.OrderBy(x => x.Score).Select(x => x.Result); + break; + + case SearchSortBy.Grabs: + orderedResults = sortDirection == SearchSortDirection.Descending + ? results.OrderByDescending(r => r.Grabs) + : results.OrderBy(r => r.Grabs); + break; + + default: + orderedResults = results.OrderByDescending(r => r.Seeders ?? 0); + break; + } + + return orderedResults.ToList(); + } + + private List<(SearchResult Result, double Score)> ScoreResults( + IEnumerable results, + IReadOnlyDictionary? indexerCache) + { + return results.Select(r => + { + Indexer? indexer = null; + if (r.IndexerId.HasValue) + indexerCache?.TryGetValue(r.IndexerId.Value, out indexer); + + var score = CompositeScorer.CalculateProwlarrStyleScore(r, indexer, _logger).Total; + return (Result: r, Score: score); + }).ToList(); + } + + private static int GetQualityScore(string? quality) + { + if (string.IsNullOrEmpty(quality)) + return 0; + + var lowerQuality = quality.ToLowerInvariant(); + + if (lowerQuality.Contains("flac")) + return 100; + if (lowerQuality.Contains("aax")) + return 95; + if (lowerQuality.Contains("m4b")) + return 90; + if (lowerQuality.Contains("opus")) + return 85; + if (ContainsVbrPreset(lowerQuality, "v0")) + return 82; + if (ContainsVbrPreset(lowerQuality, "v1")) + return 76; + if (ContainsVbrPreset(lowerQuality, "v2")) + return 70; + if (lowerQuality.Contains("aac") || lowerQuality.Contains("m4a")) + return 78; + if (lowerQuality.Contains("320")) + return 80; + if (lowerQuality.Contains("256")) + return 74; + if (lowerQuality.Contains("192")) + return 60; + if (lowerQuality.Contains("vbr") || lowerQuality.Contains("cbr")) + return 65; + if (lowerQuality.Contains("mp3") && !ContainsAnyBitrate(lowerQuality, "64", "128", "192", "256", "320")) + return 65; + if (lowerQuality.Contains("128")) + return 50; + if (lowerQuality.Contains("64")) + return 40; + + return 0; + } + + private static bool ContainsVbrPreset(string qualityLower, string preset) + { + return qualityLower.Contains(preset) || + qualityLower.Contains($"-{preset}") || + qualityLower.Contains($" {preset}"); + } + + private static bool ContainsAnyBitrate(string qualityLower, params string[] bitrates) + { + return bitrates.Any(b => qualityLower.Contains(b)); + } +} diff --git a/listenarr.application/Search/SearchService.cs b/listenarr.application/Search/SearchService.cs index e4293ca9a..16678f505 100644 --- a/listenarr.application/Search/SearchService.cs +++ b/listenarr.application/Search/SearchService.cs @@ -47,6 +47,7 @@ public class SearchService : ISearchService private readonly AsinCandidateCollector _asinCandidateCollector; private readonly AsinEnricher _asinEnricher; private readonly SearchResultScorerService _searchResultScorer; + private readonly SearchResultSortingService _searchResultSorting; private readonly AsinSearchHandler _asinSearchHandler; private readonly IMemoryCache? _cache; private readonly IEnumerable _searchProviders; @@ -63,6 +64,7 @@ public SearchService( AsinCandidateCollector asinCandidateCollector, AsinEnricher asinEnricher, SearchResultScorerService searchResultScorer, + SearchResultSortingService searchResultSorting, AsinSearchHandler asinSearchHandler, IEnumerable? searchProviders = null, IMemoryCache? cache = null) @@ -79,6 +81,7 @@ public SearchService( _asinEnricher = asinEnricher; _searchProviders = searchProviders ?? Enumerable.Empty(); _searchResultScorer = searchResultScorer; + _searchResultSorting = searchResultSorting; _asinSearchHandler = asinSearchHandler; _cache = cache; } @@ -103,7 +106,7 @@ public async Task> SearchAsync(string query, string? category { _logger.LogInformation("No indexer results found for automatic search query: {Query}", LogRedaction.SanitizeText(query)); } - return await ApplySorting(results, sortBy, sortDirection); + return await _searchResultSorting.ApplySortingAsync(results, sortBy, sortDirection); } // For manual/interactive search, use intelligent search (Audible/Audnexus/OpenLibrary) + indexers @@ -126,170 +129,7 @@ public async Task> SearchAsync(string query, string? category _logger.LogInformation("Added {Count} indexer results (including DDL downloads) for query: {Query}", indexerResults.Count, LogRedaction.SanitizeText(query)); } - return await ApplySorting(results, sortBy, sortDirection); - } - - private async Task> ApplySorting(List results, SearchSortBy sortBy, SearchSortDirection sortDirection) - { - if (!results.Any()) - return results; - - IEnumerable orderedResults; - - Dictionary? indexerCache = null; - if (sortBy == SearchSortBy.Seeders || sortBy == SearchSortBy.Smart) - { - var allIndexers = await _indexerRepository.GetAllAsync(); - indexerCache = allIndexers.ToDictionary(i => i.Id); - } - - // Primary sort - switch (sortBy) - { - case SearchSortBy.Seeders: - // Enhanced seeders sort: consider Prowlarr-inspired composite scoring - var seedScored = results.Select(r => - { - Indexer? idx = null; - if (r.IndexerId.HasValue) - indexerCache!.TryGetValue(r.IndexerId.Value, out idx); - var score = CalculateProwlarrStyleScore(r, idx); - return new { Result = r, Score = score }; - }).ToList(); - - orderedResults = sortDirection == SearchSortDirection.Descending - ? seedScored.OrderByDescending(x => x.Score).Select(x => x.Result) - : seedScored.OrderBy(x => x.Score).Select(x => x.Result); - break; - - case SearchSortBy.Size: - orderedResults = sortDirection == SearchSortDirection.Descending - ? results.OrderByDescending(r => r.Size) - : results.OrderBy(r => r.Size); - break; - - case SearchSortBy.PublishedDate: - orderedResults = sortDirection == SearchSortDirection.Descending - ? results.OrderByDescending(r => r.PublishedDate) - : results.OrderBy(r => r.PublishedDate); - break; - - case SearchSortBy.Title: - orderedResults = sortDirection == SearchSortDirection.Descending - ? results.OrderByDescending(r => r.Title, StringComparer.OrdinalIgnoreCase) - : results.OrderBy(r => r.Title, StringComparer.OrdinalIgnoreCase); - break; - - case SearchSortBy.Source: - orderedResults = sortDirection == SearchSortDirection.Descending - ? results.OrderByDescending(r => r.Source, StringComparer.OrdinalIgnoreCase) - : results.OrderBy(r => r.Source, StringComparer.OrdinalIgnoreCase); - break; - - case SearchSortBy.Language: - orderedResults = sortDirection == SearchSortDirection.Descending - ? results.OrderByDescending(r => r.Language ?? string.Empty, StringComparer.OrdinalIgnoreCase) - : results.OrderBy(r => r.Language ?? string.Empty, StringComparer.OrdinalIgnoreCase); - break; - - case SearchSortBy.Quality: - orderedResults = sortDirection == SearchSortDirection.Descending - ? results.OrderByDescending(r => GetQualityScore(r.Quality)) - : results.OrderBy(r => GetQualityScore(r.Quality)); - break; - - case SearchSortBy.Smart: - // Prowlarr-style mult-tier scoring - var scored = results.Select(r => - { - Indexer? idx = null; - if (r.IndexerId.HasValue) - indexerCache!.TryGetValue(r.IndexerId.Value, out idx); - var score = CalculateProwlarrStyleScore(r, idx); - return new { Result = r, Score = score }; - }).ToList(); - - orderedResults = sortDirection == SearchSortDirection.Descending - ? scored.OrderByDescending(x => x.Score).Select(x => x.Result) - : scored.OrderBy(x => x.Score).Select(x => x.Result); - break; - - case SearchSortBy.Grabs: - orderedResults = sortDirection == SearchSortDirection.Descending - ? results.OrderByDescending(r => r.Grabs) - : results.OrderBy(r => r.Grabs); - break; - - default: - // Default to seeders descending - orderedResults = results.OrderByDescending(r => r.Seeders ?? 0); - break; - } - - return orderedResults.ToList(); - } - - private int GetQualityScore(string? quality) - { - if (string.IsNullOrEmpty(quality)) - return 0; - - var lowerQuality = quality.ToLower(); - - // Highest quality - if (lowerQuality.Contains("flac")) - return 100; - - // Audible format (AAX) - high quality - if (lowerQuality.Contains("aax")) - return 95; - - // Container formats - if (lowerQuality.Contains("m4b")) - return 90; - - // Modern efficient codecs - if (lowerQuality.Contains("opus")) - return 85; - - // VBR quality presets (LAME VBR presets like V0/V1/V2) - if (lowerQuality.Contains("v0") || lowerQuality.Contains("-v0") || lowerQuality.Contains(" v0")) - return 82; - if (lowerQuality.Contains("v1") || lowerQuality.Contains("-v1") || lowerQuality.Contains(" v1")) - return 76; - if (lowerQuality.Contains("v2") || lowerQuality.Contains("-v2") || lowerQuality.Contains(" v2")) - return 70; - - - // AAC / M4A (check before numeric bitrates to prefer codec score for e.g. "AAC 256") - if (lowerQuality.Contains("aac") || lowerQuality.Contains("m4a")) - return 78; - - // Explicit numeric bitrates - if (lowerQuality.Contains("320")) - return 80; - if (lowerQuality.Contains("256")) - return 74; - if (lowerQuality.Contains("192")) - return 60; - - // VBR / CBR generic tokens (treat as mid-range if no numeric bitrate provided) - if (lowerQuality.Contains("vbr") || lowerQuality.Contains("cbr")) - { - // If there's an explicit numeric bitrate elsewhere, that will have matched above. - return 65; - } - - // Generic MP3 mention without explicit bitrate -> mid-range - if (lowerQuality.Contains("mp3") && !lowerQuality.Contains("64") && !lowerQuality.Contains("128") && !lowerQuality.Contains("192") && !lowerQuality.Contains("256") && !lowerQuality.Contains("320")) - return 65; - - if (lowerQuality.Contains("128")) - return 50; - if (lowerQuality.Contains("64")) - return 40; - - return 0; + return await _searchResultSorting.ApplySortingAsync(results, sortBy, sortDirection); } // Prowlarr-style composite scoring helpers adapted for Listenarr @@ -299,77 +139,6 @@ internal double CalculateProwlarrStyleScore(SearchResult result, Indexer? indexe return composite.Total; } - private double CalculateSeedScore(SearchResult result) - { - var downloadType = (result.DownloadType ?? string.Empty).ToLower(); - - if (downloadType.Contains("usenet") || downloadType.Contains("ddl") || !string.IsNullOrEmpty(result.NzbUrl)) - { - var grabs = result.Grabs; - if (grabs > 0) - { - return Math.Min(100.0, 20.0 + (Math.Log10(grabs) * 20.0)); - } - return 0.0; - } - - // Torrent - var seeders = result.Seeders ?? 0; - if (seeders <= 0) return 0.0; - - var seederScore = Math.Min(100.0, 20.0 + (Math.Log10(seeders) * 20.0)); - var leechers = result.Leechers ?? 0; - if (leechers > 0) - { - var ratio = (double)seeders / Math.Max(1, leechers); - if (ratio > 2.0) seederScore += 10.0; - else if (ratio > 1.0) seederScore += 5.0; - } - - return Math.Min(100.0, seederScore); - } - - private double CalculateAgeScore(DateTime publishedDate) - { - if (publishedDate == DateTime.MinValue) return 50.0; - var age = DateTime.UtcNow - publishedDate; - if (age.TotalDays < 1) return 100.0; - if (age.TotalDays < 7) return 90.0; - if (age.TotalDays < 30) return 75.0; - if (age.TotalDays < 90) return 60.0; - if (age.TotalDays < 365) return 40.0; - return 20.0; - } - - private double CalculateSizeScore(long sizeBytes) - { - if (sizeBytes <= 0) return 50.0; - var sizeMB = sizeBytes / (1024.0 * 1024.0); - if (sizeMB >= 100 && sizeMB <= 800) return 100.0; - if (sizeMB >= 50 && sizeMB < 100) return 80.0; - if (sizeMB > 800 && sizeMB <= 1500) return 80.0; - if (sizeMB >= 10 && sizeMB < 50) return 50.0; - if (sizeMB > 1500 && sizeMB <= 3000) return 50.0; - if (sizeMB < 10) return 20.0; - if (sizeMB > 3000) return 30.0; - return 50.0; - } - - private double GetFormatScore(string? format) - { - if (string.IsNullOrEmpty(format)) return 50.0; - var fmt = format.ToLower(); - if (fmt.Contains("m4b")) return 100.0; - if (fmt.Contains("flac")) return 95.0; - if (fmt.Contains("opus")) return 90.0; - if (fmt.Contains("m4a") || fmt.Contains("aac")) return 85.0; - if (fmt.Contains("mp3")) return 75.0; - if (fmt.Contains("ogg") || fmt.Contains("vorbis")) return 70.0; - if (fmt.Contains("wma")) return 40.0; - if (fmt.Contains("ra") || fmt.Contains("realaudio")) return 30.0; - return 50.0; - } - public async Task> SearchIndexersAsync(string query, string? category = null, SearchSortBy sortBy = SearchSortBy.Seeders, SearchSortDirection sortDirection = SearchSortDirection.Descending, bool isAutomaticSearch = false, SearchRequest? request = null) { var results = new List(); @@ -4200,4 +3969,3 @@ public async Task> GetEnabledMetadataSourcesAsync() } } - diff --git a/listenarr.infrastructure/Extensions/AppServiceRegistrationExtensions.cs b/listenarr.infrastructure/Extensions/AppServiceRegistrationExtensions.cs index 55975459c..bb6adbac6 100644 --- a/listenarr.infrastructure/Extensions/AppServiceRegistrationExtensions.cs +++ b/listenarr.infrastructure/Extensions/AppServiceRegistrationExtensions.cs @@ -63,6 +63,7 @@ public static IServiceCollection AddListenarrAppServices(this IServiceCollection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/listenarr.infrastructure/FileSystem/AudiobookFilesystemDeleteService.cs b/listenarr.infrastructure/FileSystem/AudiobookFilesystemDeleteService.cs new file mode 100644 index 000000000..6d005dfbf --- /dev/null +++ b/listenarr.infrastructure/FileSystem/AudiobookFilesystemDeleteService.cs @@ -0,0 +1,567 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +using System.Text.RegularExpressions; +using Listenarr.Application.Audiobooks; +using Listenarr.Application.Interfaces; +using Listenarr.Application.Interfaces.Repositories; +using Listenarr.Application.Security; +using Listenarr.Domain.Common; +using Listenarr.Domain.Models; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Infrastructure.FileSystem +{ + public sealed class AudiobookFilesystemDeleteService : IAudiobookFilesystemDeleteService + { + private readonly IAudiobookRepository _audiobookRepository; + private readonly IAudiobookFileRepository _audioFileRepository; + private readonly IRootFolderService _rootFolderService; + private readonly IConfigurationService _configurationService; + private readonly ILogger _logger; + + public AudiobookFilesystemDeleteService( + IAudiobookRepository audiobookRepository, + IAudiobookFileRepository audioFileRepository, + IRootFolderService rootFolderService, + IConfigurationService configurationService, + ILogger logger) + { + _audiobookRepository = audiobookRepository; + _audioFileRepository = audioFileRepository; + _rootFolderService = rootFolderService; + _configurationService = configurationService; + _logger = logger; + } + + public async Task DeleteAsync(Audiobook audiobook, bool deleteFolder) + { + var result = new AudiobookFilesystemDeleteResult(); + var trackedFilePaths = CollectTrackedFilePaths(audiobook); + var deleteTarget = await ResolveDeleteFolderTargetAsync(audiobook, trackedFilePaths, result); + + if (deleteTarget != null) + { + TryDeleteFolderContents(deleteTarget.FolderPath, result); + + if (deleteFolder) + { + await TryDeleteAudiobookFolderAsync(audiobook, deleteTarget, result); + } + } + else + { + foreach (var trackedFilePath in trackedFilePaths) + { + TryDeleteFile(trackedFilePath, result); + } + } + + return result; + } + + private sealed class DeleteFolderTarget + { + public required string FolderPath { get; init; } + public required IReadOnlyCollection ProtectedRoots { get; init; } + } + + private static IReadOnlyList CollectTrackedFilePaths(Audiobook audiobook) + { + var paths = new HashSet(StringComparer.OrdinalIgnoreCase); + + if (!string.IsNullOrWhiteSpace(audiobook.FilePath)) + { + var normalizedLegacy = NormalizePath(audiobook.FilePath); + if (!string.IsNullOrWhiteSpace(normalizedLegacy)) + { + paths.Add(normalizedLegacy); + } + } + + if (audiobook.Files != null) + { + foreach (var normalizedTracked in audiobook.Files + .Select(file => NormalizePath(file.Path)) + .Where(normalizedTracked => !string.IsNullOrWhiteSpace(normalizedTracked))) + { + paths.Add(normalizedTracked!); + } + } + + return paths.ToList(); + } + + private void TryDeleteFile(string path, AudiobookFilesystemDeleteResult result) + { + try + { + if (!File.Exists(path)) + { + return; + } + + File.Delete(path); + result.DeletedFiles++; + _logger.LogInformation("Deleted audiobook file {Path}", LogRedaction.SanitizeFilePath(path)); + } + catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException) + { + var warning = $"Could not delete file '{Path.GetFileName(path)}'."; + result.Warnings.Add(warning); + _logger.LogWarning(ex, "Failed to delete audiobook file {Path}", LogRedaction.SanitizeFilePath(path)); + } + } + + private void TryDeleteFolderContents(string folderPath, AudiobookFilesystemDeleteResult result) + { + if (!Directory.Exists(folderPath)) + { + return; + } + + string[] files; + try + { + files = Directory.GetFiles(folderPath, "*", SearchOption.AllDirectories); + } + catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException) + { + result.Warnings.Add("Could not enumerate the audiobook folder contents for deletion."); + _logger.LogWarning(ex, "Failed to enumerate audiobook folder contents for {FolderPath}", LogRedaction.SanitizeFilePath(folderPath)); + return; + } + + foreach (var filePath in files) + { + TryDeleteFile(filePath, result); + } + + string[] directories; + try + { + directories = Directory.GetDirectories(folderPath, "*", SearchOption.AllDirectories) + .OrderByDescending(path => path.Length) + .ToArray(); + } + catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException) + { + result.Warnings.Add("Some nested folders could not be cleaned up after file deletion."); + _logger.LogWarning(ex, "Failed to enumerate nested audiobook directories for {FolderPath}", LogRedaction.SanitizeFilePath(folderPath)); + return; + } + + foreach (var directoryPath in directories) + { + try + { + if (!Directory.Exists(directoryPath)) + { + continue; + } + + if (!Directory.EnumerateFileSystemEntries(directoryPath).Any()) + { + Directory.Delete(directoryPath, recursive: false); + } + } + catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException) + { + _logger.LogDebug(ex, "Failed to remove nested audiobook directory {FolderPath}", LogRedaction.SanitizeFilePath(directoryPath)); + } + } + } + + private async Task ResolveDeleteFolderTargetAsync( + Audiobook audiobook, + IReadOnlyList trackedFilePaths, + AudiobookFilesystemDeleteResult result) + { + var protectedRoots = await GetProtectedRootPathsAsync(); + var folderPath = ResolveAudiobookFolderPath(audiobook, trackedFilePaths); + if (string.IsNullOrWhiteSpace(folderPath)) + { + result.Warnings.Add("Audiobook folder could not be determined, so only tracked audiobook files were deleted."); + return null; + } + + if (protectedRoots.Any(root => PathsEqual(root, folderPath))) + { + var fallbackFolderPath = ResolveTrackedFolderPath(trackedFilePaths); + if (!string.IsNullOrWhiteSpace(fallbackFolderPath) + && !protectedRoots.Any(root => PathsEqual(root, fallbackFolderPath)) + && IsSamePathOrWithin(fallbackFolderPath, folderPath)) + { + folderPath = fallbackFolderPath; + } + } + + if (IsFilesystemRoot(folderPath)) + { + result.Warnings.Add("Refused to delete all files in a filesystem root folder."); + return null; + } + + if (protectedRoots.Any(root => PathsEqual(root, folderPath))) + { + result.Warnings.Add("Refused to delete all files in a configured library root folder."); + return null; + } + + if (!Directory.Exists(folderPath)) + { + return null; + } + + var allFiles = await _audioFileRepository.GetAllAsync(); + var otherFilePaths = allFiles + .Where(f => f.AudiobookId != audiobook.Id && f.Path != null) + .Select(f => f.Path!) + .ToList(); + + if (otherFilePaths + .Select(NormalizePath) + .Any(p => !string.IsNullOrWhiteSpace(p) && IsSamePathOrWithin(p!, folderPath))) + { + result.Warnings.Add("Refused to delete all files in the audiobook folder because other audiobook files are inside it."); + return null; + } + + var allAudiobooks = await _audiobookRepository.GetAllAsync(); + var otherAudiobookPaths = allAudiobooks + .Where(a => a.Id != audiobook.Id) + .Select(a => new { a.Id, a.BasePath, a.FilePath }) + .ToList(); + + foreach (var otherPath in otherAudiobookPaths) + { + var otherBasePath = NormalizePath(otherPath.BasePath); + if (!string.IsNullOrWhiteSpace(otherBasePath) + && (IsSamePathOrWithin(otherBasePath, folderPath) || IsSamePathOrWithin(folderPath, otherBasePath))) + { + result.Warnings.Add("Refused to delete all files in the audiobook folder because another audiobook references that location."); + return null; + } + + var otherFilePath = NormalizePath(otherPath.FilePath); + if (!string.IsNullOrWhiteSpace(otherFilePath) && IsSamePathOrWithin(otherFilePath, folderPath)) + { + result.Warnings.Add("Refused to delete all files in the audiobook folder because another audiobook file is inside it."); + return null; + } + } + + return new DeleteFolderTarget + { + FolderPath = folderPath, + ProtectedRoots = protectedRoots + }; + } + + private async Task TryDeleteAudiobookFolderAsync(Audiobook audiobook, DeleteFolderTarget deleteTarget, AudiobookFilesystemDeleteResult result) + { + if (!Directory.Exists(deleteTarget.FolderPath)) + { + return; + } + + try + { + Directory.Delete(deleteTarget.FolderPath, recursive: true); + result.DeletedFolder = true; + _logger.LogInformation("Deleted audiobook folder {FolderPath}", LogRedaction.SanitizeFilePath(deleteTarget.FolderPath)); + await TryDeleteEmptyAuthorFolderAsync(audiobook, deleteTarget.FolderPath, deleteTarget.ProtectedRoots, result); + } + catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException) + { + result.Warnings.Add("Failed to delete the audiobook folder."); + _logger.LogWarning(ex, "Failed to delete audiobook folder {FolderPath}", LogRedaction.SanitizeFilePath(deleteTarget.FolderPath)); + } + } + + private async Task TryDeleteEmptyAuthorFolderAsync( + Audiobook audiobook, + string deletedFolderPath, + IReadOnlyCollection protectedRoots, + AudiobookFilesystemDeleteResult result) + { + var parentFolder = NormalizePath(Path.GetDirectoryName(deletedFolderPath)); + if (string.IsNullOrWhiteSpace(parentFolder) + || IsFilesystemRoot(parentFolder) + || protectedRoots.Any(root => PathsEqual(root, parentFolder)) + || !Directory.Exists(parentFolder) + || !IsAuthorFolder(parentFolder, audiobook.Authors?.FirstOrDefault())) + { + return; + } + + try + { + if (Directory.EnumerateFileSystemEntries(parentFolder).Any()) + { + return; + } + } + catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException) + { + _logger.LogDebug(ex, "Unable to inspect parent folder {FolderPath} after audiobook delete", LogRedaction.SanitizeFilePath(parentFolder)); + return; + } + + var allAudiobooks = await _audiobookRepository.GetAllAsync(); + var otherAudiobookPaths = allAudiobooks + .Where(a => a.Id != audiobook.Id) + .Select(a => new { a.Id, a.BasePath, a.FilePath }) + .ToList(); + + foreach (var otherPath in otherAudiobookPaths) + { + var otherBasePath = NormalizePath(otherPath.BasePath); + if (!string.IsNullOrWhiteSpace(otherBasePath) + && (IsSamePathOrWithin(otherBasePath, parentFolder) || IsSamePathOrWithin(parentFolder, otherBasePath))) + { + return; + } + + var otherFilePath = NormalizePath(otherPath.FilePath); + if (!string.IsNullOrWhiteSpace(otherFilePath) && IsSamePathOrWithin(otherFilePath, parentFolder)) + { + return; + } + } + + try + { + Directory.Delete(parentFolder, recursive: false); + result.DeletedParentFolder = true; + _logger.LogInformation("Deleted empty parent author folder {FolderPath}", LogRedaction.SanitizeFilePath(parentFolder)); + } + catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException) + { + result.Warnings.Add("Failed to delete the empty author folder."); + _logger.LogWarning(ex, "Failed to delete empty parent author folder {FolderPath}", LogRedaction.SanitizeFilePath(parentFolder)); + } + } + + private async Task> GetProtectedRootPathsAsync() + { + var protectedRoots = new HashSet(StringComparer.OrdinalIgnoreCase); + + try + { + var roots = await _rootFolderService.GetAllAsync(); + foreach (var normalizedRoot in roots + .Select(root => NormalizePath(root.Path)) + .Where(normalizedRoot => !string.IsNullOrWhiteSpace(normalizedRoot))) + { + protectedRoots.Add(normalizedRoot!); + } + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning(ex, "Failed to enumerate root folders via service while deleting audiobook files"); + } + + try + { + var settings = await _configurationService.GetApplicationSettingsAsync(); + var outputPath = NormalizePath(settings?.OutputPath); + if (!string.IsNullOrWhiteSpace(outputPath)) + { + protectedRoots.Add(outputPath); + } + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning(ex, "Failed to load application settings while protecting root folders during delete"); + } + + return protectedRoots; + } + + private static string? ResolveAudiobookFolderPath(Audiobook audiobook, IReadOnlyList trackedFilePaths) + { + var basePath = NormalizePath(audiobook.BasePath); + if (!string.IsNullOrWhiteSpace(basePath)) + { + return basePath; + } + + var legacyFilePath = NormalizePath(audiobook.FilePath); + if (!string.IsNullOrWhiteSpace(legacyFilePath)) + { + return NormalizePath(Path.GetDirectoryName(legacyFilePath)); + } + + return GetCommonDirectoryPath(trackedFilePaths); + } + + private static string? ResolveTrackedFolderPath(IReadOnlyList trackedFilePaths) + { + if (trackedFilePaths.Count == 0) + { + return null; + } + + if (trackedFilePaths.Count == 1) + { + var directFolder = NormalizePath(Path.GetDirectoryName(trackedFilePaths[0])); + if (string.IsNullOrWhiteSpace(directFolder)) + { + return null; + } + + var folderName = Path.GetFileName(directFolder); + if (IsLikelySegmentFolder(folderName)) + { + var parentFolder = NormalizePath(Path.GetDirectoryName(directFolder)); + if (!string.IsNullOrWhiteSpace(parentFolder)) + { + return parentFolder; + } + } + + return directFolder; + } + + return GetCommonDirectoryPath(trackedFilePaths); + } + + private static bool IsLikelySegmentFolder(string? folderName) + { + if (string.IsNullOrWhiteSpace(folderName)) + { + return false; + } + + return Regex.IsMatch( + folderName.Trim(), + @"^(disc|disk|cd|part|chapter|track)[\s._-]*\d+$", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + } + + private static string? GetCommonDirectoryPath(IReadOnlyList filePaths) + { + if (filePaths.Count == 0) + { + return null; + } + + var directories = filePaths + .Select(p => NormalizePath(Path.GetDirectoryName(p))) + .Where(p => !string.IsNullOrWhiteSpace(p)) + .Cast() + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (directories.Count == 0) + { + return null; + } + + var commonPath = directories[0]; + for (var i = 1; i < directories.Count; i++) + { + while (!IsSamePathOrWithin(directories[i], commonPath)) + { + var parent = NormalizePath(Path.GetDirectoryName(commonPath)); + if (string.IsNullOrWhiteSpace(parent) || PathsEqual(parent, commonPath)) + { + return null; + } + + commonPath = parent; + } + } + + return IsFilesystemRoot(commonPath) ? null : commonPath; + } + + private static string? NormalizePath(string? path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return null; + } + + try + { + return FileUtils.NormalizeStoredPath(path) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + } + catch (ArgumentException) + { + return null; + } + } + + private static bool PathsEqual(string? left, string? right) + { + if (string.IsNullOrWhiteSpace(left) || string.IsNullOrWhiteSpace(right)) + { + return false; + } + + return string.Equals(left, right, StringComparison.OrdinalIgnoreCase); + } + + private static bool IsSamePathOrWithin(string path, string rootPath) + { + return PathsEqual(path, rootPath) || FileUtils.IsPathInsideOf(path, rootPath); + } + + private static bool IsFilesystemRoot(string? path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return false; + } + + var root = NormalizePath(Path.GetPathRoot(path)); + return !string.IsNullOrWhiteSpace(root) && PathsEqual(root, path); + } + + private static bool IsAuthorFolder(string folderPath, string? authorName) + { + if (string.IsNullOrWhiteSpace(folderPath) || string.IsNullOrWhiteSpace(authorName)) + { + return false; + } + + var folderName = Path.GetFileName(folderPath); + return NormalizeName(folderName) == NormalizeName(authorName); + } + + private static string NormalizeName(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return string.Empty; + } + + var cleaned = new string(value + .Where(c => char.IsLetterOrDigit(c) || char.IsWhiteSpace(c)) + .ToArray()); + + return string.Join( + ' ', + cleaned.Split(new[] { ' ', '\t', '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)) + .ToLowerInvariant(); + } + } +} diff --git a/tests/Builders/ServiceCollectionBuilder.cs b/tests/Builders/ServiceCollectionBuilder.cs index 63bfd501e..96d3be88d 100644 --- a/tests/Builders/ServiceCollectionBuilder.cs +++ b/tests/Builders/ServiceCollectionBuilder.cs @@ -176,6 +176,7 @@ private ServiceCollection BuildServices() services.AddSingleton(new Mock().Object); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -187,6 +188,7 @@ private ServiceCollection BuildServices() services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/tests/Features/Api/Services/Search/Providers/IndexersNewznabParsingTests.cs b/tests/Features/Api/Services/Search/Providers/IndexersNewznabParsingTests.cs index d13aa549e..53e8576df 100644 --- a/tests/Features/Api/Services/Search/Providers/IndexersNewznabParsingTests.cs +++ b/tests/Features/Api/Services/Search/Providers/IndexersNewznabParsingTests.cs @@ -51,6 +51,7 @@ private static SearchService CreateSearchService(HttpClient? httpClient = null) var collector = new AsinCandidateCollector(NullLogger.Instance, openLibraryService, converters, progress); var enricher = new AsinEnricher(NullLogger.Instance, coordinator, converters, pipeline, progress); var scorer = new SearchResultScorerService(NullLogger.Instance); + var sorting = new SearchResultSortingService(Mock.Of(), NullLogger.Instance); var handler = new AsinSearchHandler(NullLogger.Instance, configuration, audible, Mock.Of(), converters, progress); return new SearchService( @@ -65,6 +66,7 @@ private static SearchService CreateSearchService(HttpClient? httpClient = null) collector, enricher, scorer, + sorting, handler, Enumerable.Empty()); } diff --git a/tests/Features/Api/Services/SearchServiceFixesTests.cs b/tests/Features/Api/Services/SearchServiceFixesTests.cs index e709ecbe1..c5336d324 100644 --- a/tests/Features/Api/Services/SearchServiceFixesTests.cs +++ b/tests/Features/Api/Services/SearchServiceFixesTests.cs @@ -46,6 +46,7 @@ private static SearchService CreateSearchService() var collector = new AsinCandidateCollector(NullLogger.Instance, openLibraryService, converters, progress); var enricher = new AsinEnricher(NullLogger.Instance, coordinator, converters, pipeline, progress); var scorer = new SearchResultScorerService(NullLogger.Instance); + var sorting = new SearchResultSortingService(Mock.Of(), NullLogger.Instance); var handler = new AsinSearchHandler(NullLogger.Instance, configuration, audible, Mock.Of(), converters, progress); return new SearchService( @@ -60,6 +61,7 @@ private static SearchService CreateSearchService() collector, enricher, scorer, + sorting, handler, Enumerable.Empty()); } diff --git a/tests/Features/Api/Services/SearchServiceScoringTests.cs b/tests/Features/Api/Services/SearchServiceScoringTests.cs index dbae53ef8..ba8f376a6 100644 --- a/tests/Features/Api/Services/SearchServiceScoringTests.cs +++ b/tests/Features/Api/Services/SearchServiceScoringTests.cs @@ -46,6 +46,7 @@ private static SearchService CreateSearchService() var collector = new AsinCandidateCollector(NullLogger.Instance, openLibraryService, converters, progress); var enricher = new AsinEnricher(NullLogger.Instance, coordinator, converters, pipeline, progress); var scorer = new SearchResultScorerService(NullLogger.Instance); + var sorting = new SearchResultSortingService(Mock.Of(), NullLogger.Instance); var handler = new AsinSearchHandler(NullLogger.Instance, configuration, audible, Mock.Of(), converters, progress); return new SearchService( @@ -60,6 +61,7 @@ private static SearchService CreateSearchService() collector, enricher, scorer, + sorting, handler, Enumerable.Empty()); } diff --git a/tests/Features/Api/Services/SearchServiceSortingTests.cs b/tests/Features/Api/Services/SearchServiceSortingTests.cs index 781fddc68..06e9c6aab 100644 --- a/tests/Features/Api/Services/SearchServiceSortingTests.cs +++ b/tests/Features/Api/Services/SearchServiceSortingTests.cs @@ -15,9 +15,11 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -using System.Reflection; +using Listenarr.Application.Interfaces.Repositories; using Listenarr.Application.Search; using Listenarr.Domain.Models; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; using Xunit; namespace Listenarr.Tests.Features.Api.Services @@ -27,7 +29,9 @@ public class SearchServiceSortingTests [Fact] public async Task ApplySorting_SortsByLanguage_Descending() { - var svc = (SearchService)System.Runtime.Serialization.FormatterServices.GetUninitializedObject(typeof(SearchService)); + var service = new SearchResultSortingService( + Mock.Of(), + NullLogger.Instance); var results = new List { @@ -37,9 +41,7 @@ public async Task ApplySorting_SortsByLanguage_Descending() new SearchResult { Id = "4", Title = "D", Language = "German" } }; - // Call private ApplySorting via reflection - var method = typeof(SearchService).GetMethod("ApplySorting", BindingFlags.Instance | BindingFlags.NonPublic)!; - var ordered = await (Task>)method.Invoke(svc, new object[] { results, SearchSortBy.Language, SearchSortDirection.Descending })!; + var ordered = await service.ApplySortingAsync(results, SearchSortBy.Language, SearchSortDirection.Descending); // Expect order: 'english', 'German', 'french', null (case-insensitive, descending) // StringComparer.OrdinalIgnoreCase sorts lexicographically; descending should put 'french' > 'english' > 'German' > '' but to be deterministic test the comparer by actual result @@ -51,7 +53,9 @@ public async Task ApplySorting_SortsByLanguage_Descending() [Fact] public async Task ApplySorting_SortsByLanguage_Ascending() { - var svc = (SearchService)System.Runtime.Serialization.FormatterServices.GetUninitializedObject(typeof(SearchService)); + var service = new SearchResultSortingService( + Mock.Of(), + NullLogger.Instance); var results = new List { @@ -61,8 +65,7 @@ public async Task ApplySorting_SortsByLanguage_Ascending() new SearchResult { Id = "4", Title = "D", Language = "German" } }; - var method = typeof(SearchService).GetMethod("ApplySorting", BindingFlags.Instance | BindingFlags.NonPublic)!; - var ordered = await (Task>)method.Invoke(svc, new object[] { results, SearchSortBy.Language, SearchSortDirection.Ascending })!; + var ordered = await service.ApplySortingAsync(results, SearchSortBy.Language, SearchSortDirection.Ascending); // Ascending should place null/empty first Assert.Equal(4, ordered.Count); From 699fb5f9ff12c344d8f6b7116c987d01c3b4f679 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Fri, 12 Jun 2026 07:54:04 -0400 Subject: [PATCH 12/84] Extract audiobook identifier helpers --- .../Controllers/LibraryController.cs | 259 +----------------- .../Audiobooks/AudiobookIdentifierMapper.cs | 230 ++++++++++++++++ .../Audiobooks/AudiobookIdentifierModels.cs | 48 ++++ .../Audiobooks/LibraryAddService.cs | 116 +------- 4 files changed, 294 insertions(+), 359 deletions(-) create mode 100644 listenarr.application/Audiobooks/AudiobookIdentifierMapper.cs create mode 100644 listenarr.application/Audiobooks/AudiobookIdentifierModels.cs diff --git a/listenarr.api/Controllers/LibraryController.cs b/listenarr.api/Controllers/LibraryController.cs index a387956db..eeccd3215 100644 --- a/listenarr.api/Controllers/LibraryController.cs +++ b/listenarr.api/Controllers/LibraryController.cs @@ -367,7 +367,7 @@ public async Task AddToLibrary([FromBody] AddToLibraryRequest req metadata.Series, AudibleBookMetadata.ToStringOrFirst(metadata.SeriesNumber)); - SyncImportedIdentifiersFromLegacyFields(audiobook); + AudiobookIdentifierMapper.SyncImportedIdentifiersFromLegacyFields(audiobook); _logger.LogInformation("Created Audiobook entity: Title={Title}, Asin={Asin}, PublishYear={PublishYear}", LogRedaction.SanitizeText(audiobook.Title), LogRedaction.SanitizeText(audiobook.Asin), LogRedaction.SanitizeText(audiobook.PublishYear)); @@ -622,7 +622,9 @@ public async Task> GetAudiobook(int id) isbns = updated.Isbn, asin = updated.Asin, openLibraryId = updated.OpenLibraryId, - identifiers = GetEffectiveIdentifiers(updated).Select(ToIdentifierResponse).ToList(), + identifiers = AudiobookIdentifierMapper.GetEffectiveIdentifiers(updated) + .Select(AudiobookIdentifierMapper.ToIdentifierResponse) + .ToList(), imageUrl = updated.ImageUrl, publishYear = updated.PublishYear, publisher = updated.Publisher, @@ -691,8 +693,8 @@ public async Task GetAudiobookIdentifiers(int id) return NotFound(new { message = "Audiobook not found" }); } - var identifiers = GetEffectiveIdentifiers(audiobook) - .Select(ToIdentifierResponse) + var identifiers = AudiobookIdentifierMapper.GetEffectiveIdentifiers(audiobook) + .Select(AudiobookIdentifierMapper.ToIdentifierResponse) .ToList(); return Ok(new @@ -733,7 +735,7 @@ public async Task ReplaceAudiobookIdentifiers(int id, [FromBody] .Where(i => i.Source != AudiobookExternalIdentifierSource.Manual && !string.IsNullOrWhiteSpace(i.ValueNormalized)) - .Select(IdentifierFullSourceKey), + .Select(AudiobookIdentifierMapper.FullSourceKey), StringComparer.OrdinalIgnoreCase); for (var index = 0; index < incoming.Count; index++) @@ -777,7 +779,7 @@ public async Task ReplaceAudiobookIdentifiers(int id, [FromBody] { // Client writes cannot create or spoof Provider/Imported provenance. // Preserve server-owned provenance only for exact existing rows. - var requestedKey = IdentifierFullSourceKey(item.Type, normalizedValue, normalizedRegion, source); + var requestedKey = AudiobookIdentifierMapper.FullSourceKey(item.Type, normalizedValue, normalizedRegion, source); if (!existingServerOwnedSourceKeys.Contains(requestedKey)) { source = AudiobookExternalIdentifierSource.Manual; @@ -827,7 +829,7 @@ public async Task ReplaceAudiobookIdentifiers(int id, [FromBody] } audiobook.ExternalIdentifiers = normalized; - SyncLegacyFieldsFromIdentifiers(audiobook); + AudiobookIdentifierMapper.SyncLegacyFieldsFromIdentifiers(audiobook); await _repo.UpdateWithIdentifierReplaceAsync(audiobook, normalized); @@ -847,7 +849,9 @@ public async Task ReplaceAudiobookIdentifiers(int id, [FromBody] isbn = audiobook.Isbn, openLibraryId = audiobook.OpenLibraryId }, - identifiers = OrderIdentifiers(audiobook.ExternalIdentifiers).Select(ToIdentifierResponse).ToList() + identifiers = AudiobookIdentifierMapper.OrderIdentifiers(audiobook.ExternalIdentifiers) + .Select(AudiobookIdentifierMapper.ToIdentifierResponse) + .ToList() }); } @@ -898,7 +902,7 @@ public async Task RescanAudiobookMetadata(int id) }); } - var effectiveIdentifiers = GetEffectiveIdentifiers(audiobook); + var effectiveIdentifiers = AudiobookIdentifierMapper.GetEffectiveIdentifiers(audiobook); var asinIdentifiers = effectiveIdentifiers .Where(i => i.Type == AudiobookExternalIdentifierType.Asin) .OrderByDescending(i => i.IsPrimary) @@ -1103,7 +1107,7 @@ async Task TryMetadataLookupByAsinAsync(string asin, string? preferredRegi if (legacyIdentifierFieldsTouched) { - SyncImportedIdentifiersFromLegacyFields(audiobook); + AudiobookIdentifierMapper.SyncImportedIdentifiersFromLegacyFields(audiobook); } await _repo.UpdateAsync(audiobook); @@ -1271,7 +1275,7 @@ public async Task UpdateAudiobook(int id, [FromBody] Audiobook up if (legacyIdentifierFieldsTouched) { - SyncImportedIdentifiersFromLegacyFields(existingAudiobook); + AudiobookIdentifierMapper.SyncImportedIdentifiersFromLegacyFields(existingAudiobook); } await _repo.UpdateAsync(existingAudiobook); @@ -3095,239 +3099,6 @@ private static string ComputeShortHash(string? input) return BitConverter.ToString(hash).Replace("-", "").Substring(0, 16).ToLowerInvariant(); } - public sealed class AudiobookIdentifierWriteItem - { - public AudiobookExternalIdentifierType Type { get; set; } - public string Value { get; set; } = string.Empty; - public string? Region { get; set; } - public bool IsPrimary { get; set; } - public AudiobookExternalIdentifierSource? Source { get; set; } - } - - public sealed class ReplaceAudiobookIdentifiersRequest - { - public List Identifiers { get; set; } = new(); - } - - public sealed class AudiobookIdentifierResponseItem - { - public int Id { get; set; } - public AudiobookExternalIdentifierType Type { get; set; } - public string Value { get; set; } = string.Empty; - public string ValueNormalized { get; set; } = string.Empty; - public string? Region { get; set; } - public bool IsPrimary { get; set; } - public AudiobookExternalIdentifierSource Source { get; set; } - public DateTime CreatedAt { get; set; } - public DateTime UpdatedAt { get; set; } - } - - private static AudiobookIdentifierResponseItem ToIdentifierResponse(AudiobookExternalIdentifier identifier) - { - return new AudiobookIdentifierResponseItem - { - Id = identifier.Id, - Type = identifier.Type, - Value = string.IsNullOrWhiteSpace(identifier.ValueRaw) ? identifier.ValueNormalized : identifier.ValueRaw, - ValueNormalized = identifier.ValueNormalized, - Region = identifier.Region, - IsPrimary = identifier.IsPrimary, - Source = identifier.Source, - CreatedAt = identifier.CreatedAt, - UpdatedAt = identifier.UpdatedAt - }; - } - - private static List OrderIdentifiers(IEnumerable? identifiers) - { - return (identifiers ?? Enumerable.Empty()) - .OrderBy(i => i.Type) - .ThenByDescending(i => i.IsPrimary) - .ThenBy(i => i.Source) - .ThenBy(i => i.ValueNormalized) - .ToList(); - } - - private static List BuildLegacyBackfillIdentifiers(Audiobook audiobook, AudiobookExternalIdentifierSource source) - { - var now = DateTime.UtcNow; - var result = new List(); - - if (!string.IsNullOrWhiteSpace(audiobook.Asin) && - AudiobookIdentifierNormalizer.TryNormalize(AudiobookExternalIdentifierType.Asin, audiobook.Asin, out var normalizedAsin, out _)) - { - result.Add(new AudiobookExternalIdentifier - { - Type = AudiobookExternalIdentifierType.Asin, - ValueRaw = AudiobookIdentifierNormalizer.NormalizeRawValueForStorage(audiobook.Asin), - ValueNormalized = normalizedAsin, - Region = null, - IsPrimary = true, - Source = source, - CreatedAt = now, - UpdatedAt = now - }); - } - - var seenIsbns = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var isbn in audiobook.Isbn ?? new List()) - { - if (!AudiobookIdentifierNormalizer.TryNormalize(AudiobookExternalIdentifierType.Isbn, isbn, out var normalizedIsbn, out _)) - { - continue; - } - - if (!seenIsbns.Add(normalizedIsbn)) continue; - - result.Add(new AudiobookExternalIdentifier - { - Type = AudiobookExternalIdentifierType.Isbn, - ValueRaw = AudiobookIdentifierNormalizer.NormalizeRawValueForStorage(isbn), - ValueNormalized = normalizedIsbn, - Region = null, - IsPrimary = false, - Source = source, - CreatedAt = now, - UpdatedAt = now - }); - } - - if (!string.IsNullOrWhiteSpace(audiobook.OpenLibraryId) && - AudiobookIdentifierNormalizer.TryNormalize(AudiobookExternalIdentifierType.OpenLibraryId, audiobook.OpenLibraryId, out var normalizedOlid, out _)) - { - result.Add(new AudiobookExternalIdentifier - { - Type = AudiobookExternalIdentifierType.OpenLibraryId, - ValueRaw = AudiobookIdentifierNormalizer.NormalizeRawValueForStorage(audiobook.OpenLibraryId), - ValueNormalized = normalizedOlid, - Region = null, - IsPrimary = true, - Source = source, - CreatedAt = now, - UpdatedAt = now - }); - } - - return result; - } - - private static string IdentifierTypeValueKey(AudiobookExternalIdentifier item) - { - return $"{item.Type}|{item.ValueNormalized}"; - } - - private static string IdentifierFullKey(AudiobookExternalIdentifier item) - { - return $"{item.Type}|{item.ValueNormalized}|{item.Region ?? string.Empty}"; - } - - private static string IdentifierFullSourceKey(AudiobookExternalIdentifier item) - { - return IdentifierFullSourceKey(item.Type, item.ValueNormalized, item.Region, item.Source); - } - - private static string IdentifierFullSourceKey( - AudiobookExternalIdentifierType type, - string? valueNormalized, - string? region, - AudiobookExternalIdentifierSource source) - { - return $"{type}|{valueNormalized ?? string.Empty}|{region ?? string.Empty}|{source}"; - } - - private static List GetEffectiveIdentifiers(Audiobook audiobook) - { - var merged = new List(); - var seenFull = new HashSet(StringComparer.OrdinalIgnoreCase); - var seenTypeValue = new HashSet(StringComparer.OrdinalIgnoreCase); - - void AddIfNew(AudiobookExternalIdentifier item) - { - if (string.IsNullOrWhiteSpace(item.ValueNormalized)) return; - - var typeValueKey = IdentifierTypeValueKey(item); - if (item.Source == AudiobookExternalIdentifierSource.Imported && seenTypeValue.Contains(typeValueKey)) - { - // Imported identifiers are compatibility aliases; suppress them when a canonical - // identifier with the same normalized value already exists (even if region differs). - return; - } - - var fullKey = IdentifierFullKey(item); - if (!seenFull.Add(fullKey)) return; - merged.Add(item); - seenTypeValue.Add(typeValueKey); - } - - foreach (var existing in (audiobook.ExternalIdentifiers ?? new List()) - .OrderBy(i => i.Type) - .ThenByDescending(i => i.IsPrimary) - .ThenBy(i => i.Source == AudiobookExternalIdentifierSource.Imported ? 1 : 0) - .ThenBy(i => i.Source) - .ThenBy(i => i.ValueNormalized)) - { - AddIfNew(existing); - } - - foreach (var legacy in BuildLegacyBackfillIdentifiers(audiobook, AudiobookExternalIdentifierSource.Imported)) - { - AddIfNew(legacy); - } - - return OrderIdentifiers(merged); - } - - private static void SyncLegacyFieldsFromIdentifiers(Audiobook audiobook) - { - var identifiers = OrderIdentifiers(audiobook.ExternalIdentifiers); - - var primaryAsin = identifiers - .Where(i => i.Type == AudiobookExternalIdentifierType.Asin) - .OrderByDescending(i => i.IsPrimary) - .ThenBy(i => i.Source) - .FirstOrDefault(); - audiobook.Asin = primaryAsin?.ValueNormalized; - - audiobook.Isbn = identifiers - .Where(i => i.Type == AudiobookExternalIdentifierType.Isbn) - .Select(i => i.ValueNormalized) - .Where(v => !string.IsNullOrWhiteSpace(v)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToList(); - - var primaryOlid = identifiers - .Where(i => i.Type == AudiobookExternalIdentifierType.OpenLibraryId) - .OrderByDescending(i => i.IsPrimary) - .ThenBy(i => i.Source) - .FirstOrDefault(); - audiobook.OpenLibraryId = primaryOlid?.ValueNormalized; - } - - private static void SyncImportedIdentifiersFromLegacyFields(Audiobook audiobook) - { - audiobook.ExternalIdentifiers ??= new List(); - - audiobook.ExternalIdentifiers = audiobook.ExternalIdentifiers - .Where(i => i.Source != AudiobookExternalIdentifierSource.Imported) - .ToList(); - - var existingTypeValueKeys = new HashSet( - audiobook.ExternalIdentifiers - .Where(i => !string.IsNullOrWhiteSpace(i.ValueNormalized)) - .Select(IdentifierTypeValueKey), - StringComparer.OrdinalIgnoreCase); - var seenImportedFullKeys = new HashSet(StringComparer.OrdinalIgnoreCase); - - var imported = BuildLegacyBackfillIdentifiers(audiobook, AudiobookExternalIdentifierSource.Imported); - foreach (var item in imported.Where(item => - !string.IsNullOrWhiteSpace(item.ValueNormalized) && - !existingTypeValueKeys.Contains(IdentifierTypeValueKey(item)) && - seenImportedFullKeys.Add(IdentifierFullKey(item)))) - { - audiobook.ExternalIdentifiers.Add(item); - } - } - private static IEnumerable EnumerateMetadataRescanRegions(string? preferredRegion) { var seen = new HashSet(StringComparer.OrdinalIgnoreCase); diff --git a/listenarr.application/Audiobooks/AudiobookIdentifierMapper.cs b/listenarr.application/Audiobooks/AudiobookIdentifierMapper.cs new file mode 100644 index 000000000..1080de1d5 --- /dev/null +++ b/listenarr.application/Audiobooks/AudiobookIdentifierMapper.cs @@ -0,0 +1,230 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +using Listenarr.Domain.Models; + +namespace Listenarr.Application.Audiobooks; + +public static class AudiobookIdentifierMapper +{ + public static AudiobookIdentifierResponseItem ToIdentifierResponse(AudiobookExternalIdentifier identifier) + { + return new AudiobookIdentifierResponseItem + { + Id = identifier.Id, + Type = identifier.Type, + Value = string.IsNullOrWhiteSpace(identifier.ValueRaw) ? identifier.ValueNormalized : identifier.ValueRaw, + ValueNormalized = identifier.ValueNormalized, + Region = identifier.Region, + IsPrimary = identifier.IsPrimary, + Source = identifier.Source, + CreatedAt = identifier.CreatedAt, + UpdatedAt = identifier.UpdatedAt + }; + } + + public static List OrderIdentifiers(IEnumerable? identifiers) + { + return (identifiers ?? Enumerable.Empty()) + .OrderBy(i => i.Type) + .ThenByDescending(i => i.IsPrimary) + .ThenBy(i => i.Source) + .ThenBy(i => i.ValueNormalized) + .ToList(); + } + + public static List BuildLegacyBackfillIdentifiers( + Audiobook audiobook, + AudiobookExternalIdentifierSource source) + { + var now = DateTime.UtcNow; + var result = new List(); + + if (!string.IsNullOrWhiteSpace(audiobook.Asin) && + AudiobookIdentifierNormalizer.TryNormalize(AudiobookExternalIdentifierType.Asin, audiobook.Asin, out var normalizedAsin, out _)) + { + result.Add(new AudiobookExternalIdentifier + { + Type = AudiobookExternalIdentifierType.Asin, + ValueRaw = AudiobookIdentifierNormalizer.NormalizeRawValueForStorage(audiobook.Asin), + ValueNormalized = normalizedAsin, + Region = null, + IsPrimary = true, + Source = source, + CreatedAt = now, + UpdatedAt = now + }); + } + + var seenIsbns = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var isbn in audiobook.Isbn ?? new List()) + { + if (!AudiobookIdentifierNormalizer.TryNormalize(AudiobookExternalIdentifierType.Isbn, isbn, out var normalizedIsbn, out _)) + { + continue; + } + + if (!seenIsbns.Add(normalizedIsbn)) continue; + + result.Add(new AudiobookExternalIdentifier + { + Type = AudiobookExternalIdentifierType.Isbn, + ValueRaw = AudiobookIdentifierNormalizer.NormalizeRawValueForStorage(isbn), + ValueNormalized = normalizedIsbn, + Region = null, + IsPrimary = false, + Source = source, + CreatedAt = now, + UpdatedAt = now + }); + } + + if (!string.IsNullOrWhiteSpace(audiobook.OpenLibraryId) && + AudiobookIdentifierNormalizer.TryNormalize(AudiobookExternalIdentifierType.OpenLibraryId, audiobook.OpenLibraryId, out var normalizedOlid, out _)) + { + result.Add(new AudiobookExternalIdentifier + { + Type = AudiobookExternalIdentifierType.OpenLibraryId, + ValueRaw = AudiobookIdentifierNormalizer.NormalizeRawValueForStorage(audiobook.OpenLibraryId), + ValueNormalized = normalizedOlid, + Region = null, + IsPrimary = true, + Source = source, + CreatedAt = now, + UpdatedAt = now + }); + } + + return result; + } + + public static string TypeValueKey(AudiobookExternalIdentifier item) + { + return $"{item.Type}|{item.ValueNormalized}"; + } + + public static string FullKey(AudiobookExternalIdentifier item) + { + return $"{item.Type}|{item.ValueNormalized}|{item.Region ?? string.Empty}"; + } + + public static string FullSourceKey(AudiobookExternalIdentifier item) + { + return FullSourceKey(item.Type, item.ValueNormalized, item.Region, item.Source); + } + + public static string FullSourceKey( + AudiobookExternalIdentifierType type, + string? valueNormalized, + string? region, + AudiobookExternalIdentifierSource source) + { + return $"{type}|{valueNormalized ?? string.Empty}|{region ?? string.Empty}|{source}"; + } + + public static List GetEffectiveIdentifiers(Audiobook audiobook) + { + var merged = new List(); + var seenFull = new HashSet(StringComparer.OrdinalIgnoreCase); + var seenTypeValue = new HashSet(StringComparer.OrdinalIgnoreCase); + + void AddIfNew(AudiobookExternalIdentifier item) + { + if (string.IsNullOrWhiteSpace(item.ValueNormalized)) return; + + var typeValueKey = TypeValueKey(item); + if (item.Source == AudiobookExternalIdentifierSource.Imported && seenTypeValue.Contains(typeValueKey)) + { + return; + } + + var fullKey = FullKey(item); + if (!seenFull.Add(fullKey)) return; + merged.Add(item); + seenTypeValue.Add(typeValueKey); + } + + foreach (var existing in (audiobook.ExternalIdentifiers ?? new List()) + .OrderBy(i => i.Type) + .ThenByDescending(i => i.IsPrimary) + .ThenBy(i => i.Source == AudiobookExternalIdentifierSource.Imported ? 1 : 0) + .ThenBy(i => i.Source) + .ThenBy(i => i.ValueNormalized)) + { + AddIfNew(existing); + } + + foreach (var legacy in BuildLegacyBackfillIdentifiers(audiobook, AudiobookExternalIdentifierSource.Imported)) + { + AddIfNew(legacy); + } + + return OrderIdentifiers(merged); + } + + public static void SyncLegacyFieldsFromIdentifiers(Audiobook audiobook) + { + var identifiers = OrderIdentifiers(audiobook.ExternalIdentifiers); + + var primaryAsin = identifiers + .Where(i => i.Type == AudiobookExternalIdentifierType.Asin) + .OrderByDescending(i => i.IsPrimary) + .ThenBy(i => i.Source) + .FirstOrDefault(); + audiobook.Asin = primaryAsin?.ValueNormalized; + + audiobook.Isbn = identifiers + .Where(i => i.Type == AudiobookExternalIdentifierType.Isbn) + .Select(i => i.ValueNormalized) + .Where(v => !string.IsNullOrWhiteSpace(v)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + var primaryOlid = identifiers + .Where(i => i.Type == AudiobookExternalIdentifierType.OpenLibraryId) + .OrderByDescending(i => i.IsPrimary) + .ThenBy(i => i.Source) + .FirstOrDefault(); + audiobook.OpenLibraryId = primaryOlid?.ValueNormalized; + } + + public static void SyncImportedIdentifiersFromLegacyFields(Audiobook audiobook) + { + audiobook.ExternalIdentifiers ??= new List(); + + audiobook.ExternalIdentifiers = audiobook.ExternalIdentifiers + .Where(i => i.Source != AudiobookExternalIdentifierSource.Imported) + .ToList(); + + var existingTypeValueKeys = new HashSet( + audiobook.ExternalIdentifiers + .Where(i => !string.IsNullOrWhiteSpace(i.ValueNormalized)) + .Select(TypeValueKey), + StringComparer.OrdinalIgnoreCase); + var seenImportedFullKeys = new HashSet(StringComparer.OrdinalIgnoreCase); + + var imported = BuildLegacyBackfillIdentifiers(audiobook, AudiobookExternalIdentifierSource.Imported); + foreach (var item in imported.Where(item => + !string.IsNullOrWhiteSpace(item.ValueNormalized) && + !existingTypeValueKeys.Contains(TypeValueKey(item)) && + seenImportedFullKeys.Add(FullKey(item)))) + { + audiobook.ExternalIdentifiers.Add(item); + } + } +} diff --git a/listenarr.application/Audiobooks/AudiobookIdentifierModels.cs b/listenarr.application/Audiobooks/AudiobookIdentifierModels.cs new file mode 100644 index 000000000..3a93b66bd --- /dev/null +++ b/listenarr.application/Audiobooks/AudiobookIdentifierModels.cs @@ -0,0 +1,48 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +using Listenarr.Domain.Models; + +namespace Listenarr.Application.Audiobooks; + +public sealed class AudiobookIdentifierWriteItem +{ + public AudiobookExternalIdentifierType Type { get; set; } + public string Value { get; set; } = string.Empty; + public string? Region { get; set; } + public bool IsPrimary { get; set; } + public AudiobookExternalIdentifierSource? Source { get; set; } +} + +public sealed class ReplaceAudiobookIdentifiersRequest +{ + public List Identifiers { get; set; } = new(); +} + +public sealed class AudiobookIdentifierResponseItem +{ + public int Id { get; set; } + public AudiobookExternalIdentifierType Type { get; set; } + public string Value { get; set; } = string.Empty; + public string ValueNormalized { get; set; } = string.Empty; + public string? Region { get; set; } + public bool IsPrimary { get; set; } + public AudiobookExternalIdentifierSource Source { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } +} diff --git a/listenarr.application/Audiobooks/LibraryAddService.cs b/listenarr.application/Audiobooks/LibraryAddService.cs index 2a8505c04..1a0782729 100644 --- a/listenarr.application/Audiobooks/LibraryAddService.cs +++ b/listenarr.application/Audiobooks/LibraryAddService.cs @@ -130,7 +130,7 @@ public async Task AddToLibraryAsync( audiobook.ImageUrl = imageUrl; audiobook.Monitored = request.Monitored; - SyncImportedIdentifiersFromLegacyFields(audiobook); + AudiobookIdentifierMapper.SyncImportedIdentifiersFromLegacyFields(audiobook); if (request.QualityProfileId.HasValue) { @@ -389,119 +389,5 @@ private static string ComputeShortHash(string? input) return BitConverter.ToString(hash).Replace("-", "").Substring(0, 16).ToLowerInvariant(); } - private static void SyncImportedIdentifiersFromLegacyFields(Audiobook audiobook) - { - audiobook.ExternalIdentifiers ??= new List(); - - audiobook.ExternalIdentifiers = audiobook.ExternalIdentifiers - .Where(i => i.Source != AudiobookExternalIdentifierSource.Imported) - .ToList(); - - var existingTypeValueKeys = new HashSet( - audiobook.ExternalIdentifiers - .Where(i => !string.IsNullOrWhiteSpace(i.ValueNormalized)) - .Select(IdentifierTypeValueKey), - StringComparer.OrdinalIgnoreCase); - var seenImportedFullKeys = new HashSet(StringComparer.OrdinalIgnoreCase); - - var imported = BuildLegacyBackfillIdentifiers(audiobook, AudiobookExternalIdentifierSource.Imported); - foreach (var item in imported.Where(item => - !string.IsNullOrWhiteSpace(item.ValueNormalized) && - !existingTypeValueKeys.Contains(IdentifierTypeValueKey(item)) && - seenImportedFullKeys.Add(IdentifierFullKey(item)))) - { - audiobook.ExternalIdentifiers.Add(item); - } - } - - private static List BuildLegacyBackfillIdentifiers( - Audiobook audiobook, - AudiobookExternalIdentifierSource source) - { - var now = DateTime.UtcNow; - var result = new List(); - - if (!string.IsNullOrWhiteSpace(audiobook.Asin) && - AudiobookIdentifierNormalizer.TryNormalize( - AudiobookExternalIdentifierType.Asin, - audiobook.Asin, - out var normalizedAsin, - out _)) - { - result.Add(new AudiobookExternalIdentifier - { - Type = AudiobookExternalIdentifierType.Asin, - ValueRaw = AudiobookIdentifierNormalizer.NormalizeRawValueForStorage(audiobook.Asin), - ValueNormalized = normalizedAsin, - Region = null, - IsPrimary = true, - Source = source, - CreatedAt = now, - UpdatedAt = now - }); - } - - var seenIsbns = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var isbn in audiobook.Isbn ?? new List()) - { - if (!AudiobookIdentifierNormalizer.TryNormalize( - AudiobookExternalIdentifierType.Isbn, - isbn, - out var normalizedIsbn, - out _)) - { - continue; - } - - if (!seenIsbns.Add(normalizedIsbn)) - { - continue; - } - - result.Add(new AudiobookExternalIdentifier - { - Type = AudiobookExternalIdentifierType.Isbn, - ValueRaw = AudiobookIdentifierNormalizer.NormalizeRawValueForStorage(isbn), - ValueNormalized = normalizedIsbn, - Region = null, - IsPrimary = false, - Source = source, - CreatedAt = now, - UpdatedAt = now - }); - } - - if (!string.IsNullOrWhiteSpace(audiobook.OpenLibraryId) && - AudiobookIdentifierNormalizer.TryNormalize( - AudiobookExternalIdentifierType.OpenLibraryId, - audiobook.OpenLibraryId, - out var normalizedOlid, - out _)) - { - result.Add(new AudiobookExternalIdentifier - { - Type = AudiobookExternalIdentifierType.OpenLibraryId, - ValueRaw = AudiobookIdentifierNormalizer.NormalizeRawValueForStorage(audiobook.OpenLibraryId), - ValueNormalized = normalizedOlid, - Region = null, - IsPrimary = true, - Source = source, - CreatedAt = now, - UpdatedAt = now - }); - } - - return result; - } - - private static string IdentifierTypeValueKey(AudiobookExternalIdentifier item) - { - return $"{item.Type}|{item.ValueNormalized}"; - } - - private static string IdentifierFullKey(AudiobookExternalIdentifier item) - { - return $"{item.Type}|{item.ValueNormalized}|{item.Region ?? string.Empty}"; - } } } From 3e7d7a9e3840c3deec59931f38cb1c36345c89b7 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Fri, 12 Jun 2026 08:38:50 -0400 Subject: [PATCH 13/84] Extract remaining backend parsing helpers --- .../Controllers/IndexersController.cs | 321 +----------------- .../Metadata/AudibleRequestHelper.cs | 134 ++++++++ .../Metadata/AudibleService.cs | 156 ++------- .../Search/ProwlarrIndexerPayloadParser.cs | 307 +++++++++++++++++ .../Search/SearchResultAttributeParser.cs | 143 ++++++++ listenarr.application/Search/SearchService.cs | 181 ++-------- .../Api/Services/ParseLanguageTests.cs | 8 +- .../AudiobookIdentifierMapperTests.cs | 76 +++++ .../ProwlarrIndexerPayloadParserTests.cs | 86 +++++ 9 files changed, 793 insertions(+), 619 deletions(-) create mode 100644 listenarr.application/Metadata/AudibleRequestHelper.cs create mode 100644 listenarr.application/Search/ProwlarrIndexerPayloadParser.cs create mode 100644 listenarr.application/Search/SearchResultAttributeParser.cs create mode 100644 tests/Features/Application/Audiobooks/AudiobookIdentifierMapperTests.cs create mode 100644 tests/Features/Application/Search/ProwlarrIndexerPayloadParserTests.cs diff --git a/listenarr.api/Controllers/IndexersController.cs b/listenarr.api/Controllers/IndexersController.cs index 533f0a9b2..1a6a6d987 100644 --- a/listenarr.api/Controllers/IndexersController.cs +++ b/listenarr.api/Controllers/IndexersController.cs @@ -21,6 +21,7 @@ using Listenarr.Application.Interfaces; using Listenarr.Application.Interfaces.Repositories; using Listenarr.Application.Security; +using Listenarr.Application.Search; using Listenarr.Domain.Models; using Microsoft.AspNetCore.Mvc; using System.Text.Json; @@ -482,7 +483,7 @@ await _configurationService.SaveProwlarrImportSettingsAsync(new ProwlarrImportCo if (!string.IsNullOrWhiteSpace(effectiveTagFilter)) { tagMap = await TryFetchProwlarrTagMapAsync(baseUrl, effectiveApiKey.Trim()); - if ((tagMap == null || tagMap.Count == 0) && PayloadRequiresProwlarrTagMap(doc.RootElement)) + if ((tagMap == null || tagMap.Count == 0) && ProwlarrIndexerPayloadParser.PayloadRequiresTagMap(doc.RootElement)) { _logger.LogWarning( "Prowlarr tag-filtered import for {Url} requires tag label lookup, but tags could not be loaded", @@ -500,8 +501,8 @@ await _configurationService.SaveProwlarrImportSettingsAsync(new ProwlarrImportCo } var indexerId = idProp.GetInt32(); - var categoryIds = GetCategoryIdsFromProwlarrIndexer(element); - var prowlarrTags = GetProwlarrTagValues(element, tagMap); + var categoryIds = ProwlarrIndexerPayloadParser.GetCategoryIds(element); + var prowlarrTags = ProwlarrIndexerPayloadParser.GetTagValues(element, tagMap); var matchesImportFilter = string.IsNullOrWhiteSpace(effectiveTagFilter) ? categoryIds.Contains(3000) || categoryIds.Contains(3030) : prowlarrTags.Any(tag => string.Equals(tag, effectiveTagFilter, StringComparison.OrdinalIgnoreCase)); @@ -1325,319 +1326,5 @@ private string NormalizeProwlarrProxyUrl(string? rawUrl) return null; } - private static HashSet GetProwlarrTagValues(JsonElement element, IReadOnlyDictionary? tagMap) - { - var tags = new HashSet(StringComparer.OrdinalIgnoreCase); - - if (element.TryGetProperty("tags", out var rawTags)) - { - AddTagValues(rawTags, tags, tagMap); - } - - if (element.TryGetProperty("tagNames", out var tagNames)) - { - AddTagValues(tagNames, tags, tagMap); - } - - if (element.TryGetProperty("fields", out var fields) && fields.ValueKind == JsonValueKind.Array) - { - foreach (var field in fields.EnumerateArray()) - { - if (!field.TryGetProperty("name", out var nameProp) || nameProp.ValueKind != JsonValueKind.String) - { - continue; - } - - var fieldName = nameProp.GetString(); - if (!string.Equals(fieldName, "tags", StringComparison.OrdinalIgnoreCase) && - !string.Equals(fieldName, "tagNames", StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - if (field.TryGetProperty("value", out var valueProp)) - { - AddTagValues(valueProp, tags, tagMap); - } - } - } - - return tags; - } - - private static void AddTagValues(JsonElement value, HashSet tags, IReadOnlyDictionary? tagMap) - { - switch (value.ValueKind) - { - case JsonValueKind.String: - foreach (var part in value.GetString()?.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) ?? Array.Empty()) - { - AddTagValue(part, tags, tagMap); - } - break; - case JsonValueKind.Number: - AddTagValue(value.ToString(), tags, tagMap); - break; - case JsonValueKind.Array: - foreach (var item in value.EnumerateArray()) - { - AddTagValues(item, tags, tagMap); - } - break; - case JsonValueKind.Object: - if (value.TryGetProperty("label", out var labelProp) && labelProp.ValueKind == JsonValueKind.String) - { - AddTagValue(labelProp.GetString(), tags, tagMap); - } - else if (value.TryGetProperty("name", out var nameProp) && nameProp.ValueKind == JsonValueKind.String) - { - AddTagValue(nameProp.GetString(), tags, tagMap); - } - else if (value.TryGetProperty("id", out var idProp) && idProp.ValueKind == JsonValueKind.Number) - { - AddTagValue(idProp.GetInt32().ToString(), tags, tagMap); - } - break; - } - } - - private static void AddTagValue(string? rawValue, HashSet tags, IReadOnlyDictionary? tagMap) - { - if (string.IsNullOrWhiteSpace(rawValue)) - { - return; - } - - var trimmed = rawValue.Trim(); - tags.Add(trimmed); - - if (tagMap != null && tagMap.TryGetValue(trimmed, out var label) && !string.IsNullOrWhiteSpace(label)) - { - tags.Add(label.Trim()); - } - } - - private static bool PayloadRequiresProwlarrTagMap(JsonElement payload) - { - if (payload.ValueKind != JsonValueKind.Array) - { - return false; - } - - return payload.EnumerateArray().Any(ElementRequiresProwlarrTagMap); - } - - private static bool ElementRequiresProwlarrTagMap(JsonElement element) - { - var hasTagData = false; - var hasTextualTagData = false; - - if (element.TryGetProperty("tags", out var rawTags)) - { - InspectProwlarrTagValue(rawTags, ref hasTagData, ref hasTextualTagData); - } - - if (element.TryGetProperty("tagNames", out var tagNames)) - { - InspectProwlarrTagValue(tagNames, ref hasTagData, ref hasTextualTagData); - } - - if (element.TryGetProperty("fields", out var fields) && fields.ValueKind == JsonValueKind.Array) - { - foreach (var field in fields.EnumerateArray()) - { - if (!field.TryGetProperty("name", out var nameProp) || nameProp.ValueKind != JsonValueKind.String) - { - continue; - } - - var fieldName = nameProp.GetString(); - if (!string.Equals(fieldName, "tags", StringComparison.OrdinalIgnoreCase) && - !string.Equals(fieldName, "tagNames", StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - if (field.TryGetProperty("value", out var valueProp)) - { - InspectProwlarrTagValue(valueProp, ref hasTagData, ref hasTextualTagData); - } - } - } - - return hasTagData && !hasTextualTagData; - } - - private static void InspectProwlarrTagValue(JsonElement value, ref bool hasTagData, ref bool hasTextualTagData) - { - switch (value.ValueKind) - { - case JsonValueKind.String: - foreach (var part in value.GetString()?.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) ?? Array.Empty()) - { - InspectProwlarrTagToken(part, ref hasTagData, ref hasTextualTagData); - } - break; - case JsonValueKind.Number: - hasTagData = true; - break; - case JsonValueKind.Array: - foreach (var item in value.EnumerateArray()) - { - InspectProwlarrTagValue(item, ref hasTagData, ref hasTextualTagData); - } - break; - case JsonValueKind.Object: - if (value.TryGetProperty("label", out var labelProp) && labelProp.ValueKind == JsonValueKind.String) - { - InspectProwlarrTagToken(labelProp.GetString(), ref hasTagData, ref hasTextualTagData); - } - else if (value.TryGetProperty("name", out var nameProp) && nameProp.ValueKind == JsonValueKind.String) - { - InspectProwlarrTagToken(nameProp.GetString(), ref hasTagData, ref hasTextualTagData); - } - else if (value.TryGetProperty("id", out var idProp)) - { - if (idProp.ValueKind == JsonValueKind.Number) - { - hasTagData = true; - } - else if (idProp.ValueKind == JsonValueKind.String) - { - InspectProwlarrTagToken(idProp.GetString(), ref hasTagData, ref hasTextualTagData); - } - } - break; - } - } - - private static void InspectProwlarrTagToken(string? rawValue, ref bool hasTagData, ref bool hasTextualTagData) - { - if (string.IsNullOrWhiteSpace(rawValue)) - { - return; - } - - hasTagData = true; - if (!long.TryParse(rawValue.Trim(), out _)) - { - hasTextualTagData = true; - } - } - - private static string? GetFieldStringValue(JsonElement element, string fieldName) - { - if (!element.TryGetProperty("fields", out var fields) || fields.ValueKind != JsonValueKind.Array) - { - return null; - } - - foreach (var field in fields.EnumerateArray()) - { - if (!field.TryGetProperty("name", out var nameProp) || nameProp.ValueKind != JsonValueKind.String) - { - continue; - } - - if (!string.Equals(nameProp.GetString(), fieldName, StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - if (!field.TryGetProperty("value", out var valueProp)) - { - continue; - } - - return valueProp.ValueKind == JsonValueKind.String ? valueProp.GetString() : valueProp.ToString(); - } - - return null; - } - - private static HashSet GetCategoryIdsFromProwlarrIndexer(JsonElement element) - { - var categories = new HashSet(); - - if (element.TryGetProperty("capabilities", out var caps) && caps.ValueKind == JsonValueKind.Object && - caps.TryGetProperty("categories", out var catArray) && catArray.ValueKind == JsonValueKind.Array) - { - foreach (var cat in catArray.EnumerateArray()) - { - TryAddCategoryId(cat, categories); - - if (cat.ValueKind == JsonValueKind.Object && cat.TryGetProperty("subCategories", out var subCats) && subCats.ValueKind == JsonValueKind.Array) - { - foreach (var sub in subCats.EnumerateArray()) - { - TryAddCategoryId(sub, categories); - } - } - } - } - - if (element.TryGetProperty("categories", out var directCategories)) - { - AddCategoryValues(directCategories, categories); - } - - if (element.TryGetProperty("fields", out var fields) && fields.ValueKind == JsonValueKind.Array) - { - foreach (var field in fields.EnumerateArray()) - { - if (!field.TryGetProperty("name", out var nameProp) || nameProp.ValueKind != JsonValueKind.String) - { - continue; - } - - if (!string.Equals(nameProp.GetString(), "categories", StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - if (field.TryGetProperty("value", out var valueProp)) - { - AddCategoryValues(valueProp, categories); - } - } - } - - return categories; - } - - private static void AddCategoryValues(JsonElement value, HashSet categories) - { - if (value.ValueKind == JsonValueKind.Array) - { - foreach (var v in value.EnumerateArray()) - { - TryAddCategoryId(v, categories); - } - } - else - { - TryAddCategoryId(value, categories); - } - } - - private static void TryAddCategoryId(JsonElement element, HashSet categories) - { - if (element.ValueKind == JsonValueKind.Object && element.TryGetProperty("id", out var idProp)) - { - TryAddCategoryId(idProp, categories); - return; - } - - if (element.ValueKind == JsonValueKind.Number && element.TryGetInt32(out var id)) - { - categories.Add(id); - return; - } - - if (element.ValueKind == JsonValueKind.String && int.TryParse(element.GetString(), out var parsed)) - { - categories.Add(parsed); - } - } } } diff --git a/listenarr.application/Metadata/AudibleRequestHelper.cs b/listenarr.application/Metadata/AudibleRequestHelper.cs new file mode 100644 index 000000000..7ea5478fe --- /dev/null +++ b/listenarr.application/Metadata/AudibleRequestHelper.cs @@ -0,0 +1,134 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +using System.Globalization; +using System.Text; + +namespace Listenarr.Application.Metadata; + +public static class AudibleRequestHelper +{ + private static readonly IReadOnlyDictionary AudibleApiDomainMap = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["us"] = "api.audible.com", + ["ca"] = "api.audible.ca", + ["uk"] = "api.audible.co.uk", + ["au"] = "api.audible.com.au", + ["fr"] = "api.audible.fr", + ["de"] = "api.audible.de", + ["jp"] = "api.audible.co.jp", + ["it"] = "api.audible.it", + ["in"] = "api.audible.in", + ["es"] = "api.audible.es", + ["br"] = "api.audible.com.br", + }; + + private static readonly IReadOnlyDictionary AudibleLocaleMap = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["us"] = "en-US", + ["ca"] = "en-CA", + ["uk"] = "en-GB", + ["au"] = "en-AU", + ["fr"] = "fr-FR", + ["de"] = "de-DE", + ["jp"] = "ja-JP", + ["it"] = "it-IT", + ["in"] = "en-IN", + ["es"] = "es-ES", + ["br"] = "pt-BR", + }; + + public static string BuildApiBaseUrl(string region) + { + var normalizedRegion = NormalizeRegion(region); + return $"https://{(AudibleApiDomainMap.TryGetValue(normalizedRegion, out var domain) ? domain : AudibleApiDomainMap["us"])}"; + } + + public static string GetLocale(string region) + { + var normalizedRegion = NormalizeRegion(region); + return AudibleLocaleMap.TryGetValue(normalizedRegion, out var locale) + ? locale + : AudibleLocaleMap["us"]; + } + + public static string NormalizeRegion(string region) + { + return string.IsNullOrWhiteSpace(region) ? "us" : region.Trim().ToLowerInvariant(); + } + + public static string BuildQueryString(IEnumerable> parameters) + { + return string.Join( + "&", + parameters + .Where(pair => !string.IsNullOrWhiteSpace(pair.Value)) + .Select(pair => $"{Uri.EscapeDataString(pair.Key)}={Uri.EscapeDataString(pair.Value!)}")); + } + + public static string RemoveDiacritics(string text) + { + if (string.IsNullOrEmpty(text)) return text; + + var normalized = text.Normalize(NormalizationForm.FormD); + var builder = new StringBuilder(normalized.Length); + foreach (var character in normalized.Where(character => CharUnicodeInfo.GetUnicodeCategory(character) != UnicodeCategory.NonSpacingMark)) + { + builder.Append(character); + } + + return builder.ToString().Normalize(NormalizationForm.FormC); + } + + public static string GenerateRandomSessionId() + { + static string RandomDigits() + { + return Random.Shared.Next(0, 10_000_000).ToString().PadLeft(7, '0'); + } + + return $"000-{RandomDigits()}-{RandomDigits()}"; + } + + public static string BuildAuthorPageUrl(string author, string authorAsin, string region) + { + var authorSlug = string.IsNullOrWhiteSpace(author) + ? authorAsin + : Uri.EscapeDataString(author.Trim().Replace(' ', '-')); + return $"{GetBaseUrl(region)}/author/{authorSlug}/{Uri.EscapeDataString(authorAsin)}"; + } + + public static string GetBaseUrl(string region) + { + return region?.Trim().ToLowerInvariant() switch + { + "au" => "https://www.audible.com.au", + "ca" => "https://www.audible.ca", + "de" => "https://www.audible.de", + "es" => "https://www.audible.es", + "fr" => "https://www.audible.fr", + "in" => "https://www.audible.in", + "it" => "https://www.audible.it", + "jp" => "https://www.audible.co.jp", + "uk" => "https://www.audible.co.uk", + _ => "https://www.audible.com" + }; + } +} diff --git a/listenarr.application/Metadata/AudibleService.cs b/listenarr.application/Metadata/AudibleService.cs index 5299973c2..cd5ec5343 100644 --- a/listenarr.application/Metadata/AudibleService.cs +++ b/listenarr.application/Metadata/AudibleService.cs @@ -16,7 +16,6 @@ * along with this program. If not, see . */ using System.Globalization; -using System.Text; using System.Text.Json; using Listenarr.Application.Interfaces; using Listenarr.Application.Security; @@ -40,36 +39,6 @@ public class AudibleService "media,product_attrs,product_desc,product_details,product_extended_attrs,product_plans,rating,series,relationships,review_attrs,category_ladders,customer_rights"; private const string DefaultSeriesResponseGroups = "relationships,product_attrs,product_desc,product_extended_attrs"; - private static readonly IReadOnlyDictionary AudibleApiDomainMap = - new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["us"] = "api.audible.com", - ["ca"] = "api.audible.ca", - ["uk"] = "api.audible.co.uk", - ["au"] = "api.audible.com.au", - ["fr"] = "api.audible.fr", - ["de"] = "api.audible.de", - ["jp"] = "api.audible.co.jp", - ["it"] = "api.audible.it", - ["in"] = "api.audible.in", - ["es"] = "api.audible.es", - ["br"] = "api.audible.com.br", - }; - private static readonly IReadOnlyDictionary AudibleLocaleMap = - new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["us"] = "en-US", - ["ca"] = "en-CA", - ["uk"] = "en-GB", - ["au"] = "en-AU", - ["fr"] = "fr-FR", - ["de"] = "de-DE", - ["jp"] = "ja-JP", - ["it"] = "it-IT", - ["in"] = "en-IN", - ["es"] = "es-ES", - ["br"] = "pt-BR", - }; private readonly HttpClient _httpClient; private readonly ILogger _logger; private readonly IAudibleAuthorPageParser? _authorPageParser; @@ -126,9 +95,9 @@ public AudibleService(HttpClient httpClient, ILogger logger, IAu ? string.Empty : $"&pageSectionContinuationToken={Uri.EscapeDataString(continuationToken)}"; var authorPageUrl = - $"{BuildAudibleApiBaseUrl(region)}/1.0/screens/audible-android-author-detail/{Uri.EscapeDataString(authorAsin)}" + + $"{AudibleRequestHelper.BuildApiBaseUrl(region)}/1.0/screens/audible-android-author-detail/{Uri.EscapeDataString(authorAsin)}" + $"?tabId=titles&author_asin={Uri.EscapeDataString(authorAsin)}&title_source=all" + - $"&session_id={Uri.EscapeDataString(GenerateRandomSessionId())}" + + $"&session_id={Uri.EscapeDataString(AudibleRequestHelper.GenerateRandomSessionId())}" + $"&applicationType=Android_App&local_time={Uri.EscapeDataString(DateTime.UtcNow.ToString("O"))}" + $"&response_groups=always-returned&surface=Android{tokenQuery}"; @@ -271,7 +240,7 @@ public AudibleService(HttpClient httpClient, ILogger logger, IAu { Asin = GetString(product, "asin") ?? seriesAsin, Name = GetString(product, "title"), - Region = NormalizeRegion(region), + Region = AudibleRequestHelper.NormalizeRegion(region), Description = GetString(product, "publisher_summary") ?? GetString(product, "extended_product_description"), Image = GetHighestResolutionImage(product) }; @@ -437,7 +406,7 @@ public AudibleService(HttpClient httpClient, ILogger logger, IAu _logger.LogInformation( "Audible keyword title search returned no results for '{Title}' in region {Region}; retrying title-field search", LogRedaction.SanitizeText(normalizedTitle), - NormalizeRegion(region)); + AudibleRequestHelper.NormalizeRegion(region)); var titleFieldResponse = await SearchProductsDirectAsync( query: null, @@ -653,9 +622,9 @@ public AudibleService(HttpClient httpClient, ILogger logger, IAu try { - var locale = GetAudibleLocale(region); + var locale = AudibleRequestHelper.GetLocale(region); var url = - $"{BuildAudibleApiBaseUrl(region)}/1.0/catalog/contributors/{Uri.EscapeDataString(authorAsin)}" + + $"{AudibleRequestHelper.BuildApiBaseUrl(region)}/1.0/catalog/contributors/{Uri.EscapeDataString(authorAsin)}" + $"?locale={Uri.EscapeDataString(locale)}"; using var doc = await GetAudibleJsonDocumentAsync(url, region, includeLocaleHeaders: true, timeoutSeconds: 10); if (doc == null || @@ -670,7 +639,7 @@ public AudibleService(HttpClient httpClient, ILogger logger, IAu Asin = GetString(contributor, "contributor_id") ?? authorAsin, Name = GetString(contributor, "name"), Image = GetString(contributor, "profile_image_url"), - Region = NormalizeRegion(region), + Region = AudibleRequestHelper.NormalizeRegion(region), Description = GetString(contributor, "bio") }; } @@ -711,7 +680,7 @@ private async Task> LookupAuthorItemsAsync(string author, { Asin = GetString(authorItem, "asin"), Name = GetString(authorItem, "name"), - Region = NormalizeRegion(region) + Region = AudibleRequestHelper.NormalizeRegion(region) })) .Where(item => !string.IsNullOrWhiteSpace(item.Name)) .Where(item => @@ -777,7 +746,7 @@ private async Task> LookupSeriesItemsAsync(string seriesN Asin = GetString(series, "asin"), Name = GetString(series, "title"), Position = GetString(series, "sequence"), - Region = NormalizeRegion(region), + Region = AudibleRequestHelper.NormalizeRegion(region), Image = productImage }); }) @@ -824,7 +793,7 @@ private async Task SearchProductsDirectAsync( string sortBy, bool returnRawProducts = false) { - var safeRegion = NormalizeRegion(region); + var safeRegion = AudibleRequestHelper.NormalizeRegion(region); // Try with original text first (preserves diacritics for APIs that // handle them natively, e.g. audible.de for German/Swedish). @@ -877,7 +846,7 @@ private async Task SearchProductsCoreAsync( if (!string.IsNullOrWhiteSpace(narrator)) parameters["narrator"] = narrator; if (!string.IsNullOrWhiteSpace(publisher)) parameters["publisher"] = publisher; - var url = $"{BuildAudibleApiBaseUrl(safeRegion)}/1.0/catalog/products/?{BuildQueryString(parameters)}"; + var url = $"{AudibleRequestHelper.BuildApiBaseUrl(safeRegion)}/1.0/catalog/products/?{AudibleRequestHelper.BuildQueryString(parameters)}"; using var doc = await GetAudibleJsonDocumentAsync(url, safeRegion, includeLocaleHeaders: false, timeoutSeconds: 10); if (doc == null) { @@ -922,10 +891,10 @@ private static bool HasDiacritics(string? text) private async Task GetAudibleProductDocumentAsync(string asin, string region, string responseGroups) { - var safeRegion = NormalizeRegion(region); + var safeRegion = AudibleRequestHelper.NormalizeRegion(region); var url = - $"{BuildAudibleApiBaseUrl(safeRegion)}/1.0/catalog/products/{Uri.EscapeDataString(asin)}?" + - $"{BuildQueryString(new Dictionary + $"{AudibleRequestHelper.BuildApiBaseUrl(safeRegion)}/1.0/catalog/products/{Uri.EscapeDataString(asin)}?" + + $"{AudibleRequestHelper.BuildQueryString(new Dictionary { ["response_groups"] = responseGroups, ["image_sizes"] = "500,1000,2400,3200" @@ -936,7 +905,7 @@ private static bool HasDiacritics(string? text) private async Task> GetBooksMetadataByAsinsAsync(IEnumerable asins, string region) { - var normalizedRegion = NormalizeRegion(region); + var normalizedRegion = AudibleRequestHelper.NormalizeRegion(region); var orderedAsins = asins .Where(asin => !string.IsNullOrWhiteSpace(asin)) .Select(asin => asin.Trim()) @@ -949,8 +918,8 @@ private async Task> GetBooksMetadataByAsinsAsync(IEnum var doc = chunk.Count == 1 ? await GetAudibleProductDocumentAsync(chunk[0], normalizedRegion, DefaultBookResponseGroups) : await GetAudibleJsonDocumentAsync( - $"{BuildAudibleApiBaseUrl(normalizedRegion)}/1.0/catalog/products/?" + - $"{BuildQueryString(new Dictionary + $"{AudibleRequestHelper.BuildApiBaseUrl(normalizedRegion)}/1.0/catalog/products/?" + + $"{AudibleRequestHelper.BuildQueryString(new Dictionary { ["asins"] = string.Join(",", chunk), ["response_groups"] = DefaultBookResponseGroups, @@ -1017,7 +986,7 @@ private async Task> GetBooksMetadataByAsinsAsync(IEnum { Asin = GetString(author, "asin"), Name = GetString(author, "name"), - Region = NormalizeRegion(region) + Region = AudibleRequestHelper.NormalizeRegion(region) }) .Where(author => !string.IsNullOrWhiteSpace(author.Name)) .ToList(), @@ -1050,7 +1019,7 @@ private async Task> GetBooksMetadataByAsinsAsync(IEnum Explicit = GetBoolean(product, "is_adult_product"), ReleaseDate = GetString(product, "release_date"), Isbn = GetString(product, "isbn"), - Region = NormalizeRegion(region), + Region = AudibleRequestHelper.NormalizeRegion(region), BookFormat = GetString(product, "format_type"), ContentType = GetString(product, "content_type"), ContentDeliveryType = GetString(product, "content_delivery_type"), @@ -1087,7 +1056,7 @@ private async Task> GetBooksMetadataByAsinsAsync(IEnum Publisher = book.Publisher, Narrators = book.Narrators, ReleaseDate = book.ReleaseDate, - Link = string.IsNullOrWhiteSpace(book.Asin) ? null : $"{GetAudibleBaseUrl(book.Region ?? "us")}/pd/{book.Asin}", + Link = string.IsNullOrWhiteSpace(book.Asin) ? null : $"{AudibleRequestHelper.GetBaseUrl(book.Region ?? "us")}/pd/{book.Asin}", Isbn = book.Isbn }; } @@ -1130,7 +1099,7 @@ private static AudibleSearchResponse ToSearchResponse(SearchProductsDirectRespon request.Headers.TryAddWithoutValidation("Accept-Charset", "utf-8"); if (includeLocaleHeaders) { - var locale = GetAudibleLocale(region); + var locale = AudibleRequestHelper.GetLocale(region); request.Headers.TryAddWithoutValidation("ACCEPTED-LANGUAGE", locale); request.Headers.TryAddWithoutValidation("accept-language", locale); request.Headers.TryAddWithoutValidation("X-ADP-SW", Random.Shared.Next(10_000_000, 99_999_999).ToString()); @@ -1159,34 +1128,6 @@ private static AudibleSearchResponse ToSearchResponse(SearchProductsDirectRespon } } - private static string BuildAudibleApiBaseUrl(string region) - { - var normalizedRegion = NormalizeRegion(region); - return $"https://{(AudibleApiDomainMap.TryGetValue(normalizedRegion, out var domain) ? domain : AudibleApiDomainMap["us"])}"; - } - - private static string GetAudibleLocale(string region) - { - var normalizedRegion = NormalizeRegion(region); - return AudibleLocaleMap.TryGetValue(normalizedRegion, out var locale) - ? locale - : AudibleLocaleMap["us"]; - } - - private static string NormalizeRegion(string region) - { - return string.IsNullOrWhiteSpace(region) ? "us" : region.Trim().ToLowerInvariant(); - } - - private static string BuildQueryString(IEnumerable> parameters) - { - return string.Join( - "&", - parameters - .Where(pair => !string.IsNullOrWhiteSpace(pair.Value)) - .Select(pair => $"{Uri.EscapeDataString(pair.Key)}={Uri.EscapeDataString(pair.Value!)}")); - } - /// /// Strips diacritical marks (accents) from a string so that characters /// like Å → A, ä → a, ö → o, etc. The Audible API returns poor or no @@ -1196,12 +1137,7 @@ private static string BuildQueryString(IEnumerable /// internal static string RemoveDiacritics(string text) { - if (string.IsNullOrEmpty(text)) return text; - var normalized = text.Normalize(NormalizationForm.FormD); - var sb = new StringBuilder(normalized.Length); - foreach (var ch in normalized.Where(ch => CharUnicodeInfo.GetUnicodeCategory(ch) != UnicodeCategory.NonSpacingMark)) - sb.Append(ch); - return sb.ToString().Normalize(NormalizationForm.FormC); + return AudibleRequestHelper.RemoveDiacritics(text); } private static IEnumerable GetArray(JsonElement element, string propertyName) @@ -1333,16 +1269,6 @@ private static List> Chunk(List values, int size) return chunks; } - private static string GenerateRandomSessionId() - { - static string RandomDigits() - { - return Random.Shared.Next(0, 10_000_000).ToString().PadLeft(7, '0'); - } - - return $"000-{RandomDigits()}-{RandomDigits()}"; - } - private static AuthorLookupItem? ParseSingleAuthorLookupItem(string lookupJson) { var items = ParseAuthorLookupItems(lookupJson); @@ -1490,7 +1416,7 @@ private static string NormalizeComparableText(string? value) { try { - var authorPageUrl = BuildAudibleAuthorPageUrl(author, authorAsin, region); + var authorPageUrl = AudibleRequestHelper.BuildAuthorPageUrl(author, authorAsin, region); _logger.LogInformation("Scraping Audible author page as fallback: {Url}", authorPageUrl); var response = await GetWithTimeoutAsync(authorPageUrl, timeoutSeconds: 10); @@ -1691,42 +1617,6 @@ private static List ParseSeriesLookupItems(string lookupJson) return new List(); } - private static string BuildAudibleAuthorPageUrl(string author, string authorAsin, string region) - { - var authorSlug = string.IsNullOrWhiteSpace(author) - ? authorAsin - : Uri.EscapeDataString(author.Trim().Replace(' ', '-')); - return $"{GetAudibleBaseUrl(region)}/author/{authorSlug}/{Uri.EscapeDataString(authorAsin)}"; - } - - private static string? NormalizeAudibleUrl(string? url, string region) - { - if (string.IsNullOrWhiteSpace(url)) return null; - if (Uri.TryCreate(url, UriKind.Absolute, out var absoluteUri) - && !string.Equals(absoluteUri.Scheme, Uri.UriSchemeFile, StringComparison.OrdinalIgnoreCase)) - { - return absoluteUri.ToString(); - } - return $"{GetAudibleBaseUrl(region)}{url}"; - } - - private static string GetAudibleBaseUrl(string region) - { - return region?.Trim().ToLowerInvariant() switch - { - "au" => "https://www.audible.com.au", - "ca" => "https://www.audible.ca", - "de" => "https://www.audible.de", - "es" => "https://www.audible.es", - "fr" => "https://www.audible.fr", - "in" => "https://www.audible.in", - "it" => "https://www.audible.it", - "jp" => "https://www.audible.co.jp", - "uk" => "https://www.audible.co.uk", - _ => "https://www.audible.com" - }; - } - public virtual async Task SearchByIsbnAsync(string isbn, int page = 1, int limit = 50, string region = "us", string? language = null) { var response = await SearchProductsDirectAsync( diff --git a/listenarr.application/Search/ProwlarrIndexerPayloadParser.cs b/listenarr.application/Search/ProwlarrIndexerPayloadParser.cs new file mode 100644 index 000000000..1855e3e6b --- /dev/null +++ b/listenarr.application/Search/ProwlarrIndexerPayloadParser.cs @@ -0,0 +1,307 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +using System.Text.Json; + +namespace Listenarr.Application.Search; + +public static class ProwlarrIndexerPayloadParser +{ + public static HashSet GetTagValues(JsonElement element, IReadOnlyDictionary? tagMap) + { + var tags = new HashSet(StringComparer.OrdinalIgnoreCase); + + if (element.TryGetProperty("tags", out var rawTags)) + { + AddTagValues(rawTags, tags, tagMap); + } + + if (element.TryGetProperty("tagNames", out var tagNames)) + { + AddTagValues(tagNames, tags, tagMap); + } + + if (element.TryGetProperty("fields", out var fields) && fields.ValueKind == JsonValueKind.Array) + { + foreach (var field in fields.EnumerateArray()) + { + if (!field.TryGetProperty("name", out var nameProp) || nameProp.ValueKind != JsonValueKind.String) + { + continue; + } + + var fieldName = nameProp.GetString(); + if (!string.Equals(fieldName, "tags", StringComparison.OrdinalIgnoreCase) && + !string.Equals(fieldName, "tagNames", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (field.TryGetProperty("value", out var valueProp)) + { + AddTagValues(valueProp, tags, tagMap); + } + } + } + + return tags; + } + + public static bool PayloadRequiresTagMap(JsonElement payload) + { + return payload.ValueKind == JsonValueKind.Array && + payload.EnumerateArray().Any(ElementRequiresTagMap); + } + + public static HashSet GetCategoryIds(JsonElement element) + { + var categories = new HashSet(); + + if (element.TryGetProperty("capabilities", out var caps) && caps.ValueKind == JsonValueKind.Object && + caps.TryGetProperty("categories", out var catArray) && catArray.ValueKind == JsonValueKind.Array) + { + foreach (var cat in catArray.EnumerateArray()) + { + TryAddCategoryId(cat, categories); + + if (cat.ValueKind == JsonValueKind.Object && + cat.TryGetProperty("subCategories", out var subCats) && + subCats.ValueKind == JsonValueKind.Array) + { + foreach (var sub in subCats.EnumerateArray()) + { + TryAddCategoryId(sub, categories); + } + } + } + } + + if (element.TryGetProperty("categories", out var directCategories)) + { + AddCategoryValues(directCategories, categories); + } + + if (element.TryGetProperty("fields", out var fields) && fields.ValueKind == JsonValueKind.Array) + { + foreach (var field in fields.EnumerateArray()) + { + if (!field.TryGetProperty("name", out var nameProp) || nameProp.ValueKind != JsonValueKind.String) + { + continue; + } + + if (!string.Equals(nameProp.GetString(), "categories", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (field.TryGetProperty("value", out var valueProp)) + { + AddCategoryValues(valueProp, categories); + } + } + } + + return categories; + } + + private static void AddTagValues(JsonElement value, HashSet tags, IReadOnlyDictionary? tagMap) + { + switch (value.ValueKind) + { + case JsonValueKind.String: + foreach (var part in value.GetString()?.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) ?? Array.Empty()) + { + AddTagValue(part, tags, tagMap); + } + break; + case JsonValueKind.Number: + AddTagValue(value.ToString(), tags, tagMap); + break; + case JsonValueKind.Array: + foreach (var item in value.EnumerateArray()) + { + AddTagValues(item, tags, tagMap); + } + break; + case JsonValueKind.Object: + if (value.TryGetProperty("label", out var labelProp) && labelProp.ValueKind == JsonValueKind.String) + { + AddTagValue(labelProp.GetString(), tags, tagMap); + } + else if (value.TryGetProperty("name", out var nameProp) && nameProp.ValueKind == JsonValueKind.String) + { + AddTagValue(nameProp.GetString(), tags, tagMap); + } + else if (value.TryGetProperty("id", out var idProp) && idProp.ValueKind == JsonValueKind.Number) + { + AddTagValue(idProp.GetInt32().ToString(), tags, tagMap); + } + break; + } + } + + private static void AddTagValue(string? rawValue, HashSet tags, IReadOnlyDictionary? tagMap) + { + if (string.IsNullOrWhiteSpace(rawValue)) + { + return; + } + + var trimmed = rawValue.Trim(); + tags.Add(trimmed); + + if (tagMap != null && tagMap.TryGetValue(trimmed, out var label) && !string.IsNullOrWhiteSpace(label)) + { + tags.Add(label.Trim()); + } + } + + private static bool ElementRequiresTagMap(JsonElement element) + { + var hasTagData = false; + var hasTextualTagData = false; + + if (element.TryGetProperty("tags", out var rawTags)) + { + InspectTagValue(rawTags, ref hasTagData, ref hasTextualTagData); + } + + if (element.TryGetProperty("tagNames", out var tagNames)) + { + InspectTagValue(tagNames, ref hasTagData, ref hasTextualTagData); + } + + if (element.TryGetProperty("fields", out var fields) && fields.ValueKind == JsonValueKind.Array) + { + foreach (var field in fields.EnumerateArray()) + { + if (!field.TryGetProperty("name", out var nameProp) || nameProp.ValueKind != JsonValueKind.String) + { + continue; + } + + var fieldName = nameProp.GetString(); + if (!string.Equals(fieldName, "tags", StringComparison.OrdinalIgnoreCase) && + !string.Equals(fieldName, "tagNames", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (field.TryGetProperty("value", out var valueProp)) + { + InspectTagValue(valueProp, ref hasTagData, ref hasTextualTagData); + } + } + } + + return hasTagData && !hasTextualTagData; + } + + private static void InspectTagValue(JsonElement value, ref bool hasTagData, ref bool hasTextualTagData) + { + switch (value.ValueKind) + { + case JsonValueKind.String: + foreach (var part in value.GetString()?.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) ?? Array.Empty()) + { + InspectTagToken(part, ref hasTagData, ref hasTextualTagData); + } + break; + case JsonValueKind.Number: + hasTagData = true; + break; + case JsonValueKind.Array: + foreach (var item in value.EnumerateArray()) + { + InspectTagValue(item, ref hasTagData, ref hasTextualTagData); + } + break; + case JsonValueKind.Object: + if (value.TryGetProperty("label", out var labelProp) && labelProp.ValueKind == JsonValueKind.String) + { + InspectTagToken(labelProp.GetString(), ref hasTagData, ref hasTextualTagData); + } + else if (value.TryGetProperty("name", out var nameProp) && nameProp.ValueKind == JsonValueKind.String) + { + InspectTagToken(nameProp.GetString(), ref hasTagData, ref hasTextualTagData); + } + else if (value.TryGetProperty("id", out var idProp)) + { + if (idProp.ValueKind == JsonValueKind.Number) + { + hasTagData = true; + } + else if (idProp.ValueKind == JsonValueKind.String) + { + InspectTagToken(idProp.GetString(), ref hasTagData, ref hasTextualTagData); + } + } + break; + } + } + + private static void InspectTagToken(string? rawValue, ref bool hasTagData, ref bool hasTextualTagData) + { + if (string.IsNullOrWhiteSpace(rawValue)) + { + return; + } + + hasTagData = true; + if (!long.TryParse(rawValue.Trim(), out _)) + { + hasTextualTagData = true; + } + } + + private static void AddCategoryValues(JsonElement value, HashSet categories) + { + if (value.ValueKind == JsonValueKind.Array) + { + foreach (var v in value.EnumerateArray()) + { + TryAddCategoryId(v, categories); + } + } + else + { + TryAddCategoryId(value, categories); + } + } + + private static void TryAddCategoryId(JsonElement element, HashSet categories) + { + if (element.ValueKind == JsonValueKind.Object && element.TryGetProperty("id", out var idProp)) + { + TryAddCategoryId(idProp, categories); + return; + } + + if (element.ValueKind == JsonValueKind.Number && element.TryGetInt32(out var id)) + { + categories.Add(id); + return; + } + + if (element.ValueKind == JsonValueKind.String && int.TryParse(element.GetString(), out var parsed)) + { + categories.Add(parsed); + } + } +} diff --git a/listenarr.application/Search/SearchResultAttributeParser.cs b/listenarr.application/Search/SearchResultAttributeParser.cs new file mode 100644 index 000000000..f544ae367 --- /dev/null +++ b/listenarr.application/Search/SearchResultAttributeParser.cs @@ -0,0 +1,143 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +using System.Text.RegularExpressions; + +namespace Listenarr.Application.Search; + +public static class SearchResultAttributeParser +{ + private static readonly IReadOnlyDictionary LanguageCodes = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "ENG", "English" }, { "EN", "English" }, + { "DUT", "Dutch" }, { "NL", "Dutch" }, + { "GER", "German" }, { "DE", "German" }, + { "FRE", "French" }, { "FR", "French" } + }; + + public static string DetectQualityFromTags(string tags) + { + var lowerTags = tags.ToLowerInvariant(); + + if (lowerTags.Contains("flac")) + return "FLAC"; + if (lowerTags.Contains("320") || lowerTags.Contains("320kbps")) + return "MP3 320kbps"; + if (lowerTags.Contains("256") || lowerTags.Contains("256kbps")) + return "MP3 256kbps"; + if (lowerTags.Contains("192") || lowerTags.Contains("192kbps")) + return "MP3 192kbps"; + if (lowerTags.Contains("128") || lowerTags.Contains("128kbps")) + return "MP3 128kbps"; + if (lowerTags.Contains("64") || lowerTags.Contains("64kbps")) + return "MP3 64kbps"; + if (lowerTags.Contains("m4b")) + return "M4B"; + + return "Unknown"; + } + + public static string DetectQualityFromFormat(string format) + { + if (string.IsNullOrEmpty(format)) + return "Unknown"; + + var lowerFormat = format.ToLowerInvariant(); + + if (lowerFormat.Contains("flac")) + return "FLAC"; + if (lowerFormat.Contains("m4b") || lowerFormat.Contains("apple audiobook")) + return "M4B"; + if (lowerFormat.Contains("320kbps") || lowerFormat.Contains("320 kbps")) + return "MP3 320kbps"; + if (lowerFormat.Contains("256kbps") || lowerFormat.Contains("256 kbps")) + return "MP3 256kbps"; + if (lowerFormat.Contains("192kbps") || lowerFormat.Contains("192 kbps")) + return "MP3 192kbps"; + if (lowerFormat.Contains("128kbps") || lowerFormat.Contains("128 kbps")) + return "MP3 128kbps"; + if (lowerFormat.Contains("64kbps") || lowerFormat.Contains("64 kbps")) + return "MP3 64kbps"; + if (lowerFormat.Contains("vbr mp3") || lowerFormat.Contains("variable bitrate")) + return "MP3 VBR"; + if (lowerFormat.Contains("ogg vorbis") || lowerFormat.Contains("ogg")) + return "OGG Vorbis"; + if (lowerFormat.Contains("opus")) + return "OPUS"; + if (lowerFormat.Contains("aac")) + return "AAC"; + if (lowerFormat.Contains("mp3")) + return "MP3"; + + return "Unknown"; + } + + public static string DetectFormatFromTags(string tags) + { + var lowerTags = tags.ToLowerInvariant(); + + if (lowerTags.Contains("m4b")) + return "M4B"; + if (lowerTags.Contains("flac")) + return "FLAC"; + if (lowerTags.Contains("mp3")) + return "MP3"; + if (lowerTags.Contains("opus")) + return "OPUS"; + if (lowerTags.Contains("aac")) + return "AAC"; + + return "MP3"; + } + + public static string? ParseLanguageFromText(string text) + { + if (string.IsNullOrWhiteSpace(text)) return null; + + var normalized = Regex.Replace(text, "\\s+", " ", RegexOptions.Compiled | RegexOptions.IgnoreCase).Trim(); + var alternation = string.Join("|", LanguageCodes.Keys.Select(Regex.Escape)); + var bracketedPattern = $@"[\[\(]\s*(?:{alternation})\b"; + var wordBoundaryPattern = $"\\b(?:{alternation})\\b"; + + var bracketMatch = Regex.Match(normalized, bracketedPattern, RegexOptions.IgnoreCase | RegexOptions.Compiled); + if (bracketMatch.Success) + { + var code = bracketMatch.Value.TrimStart('[', '(').Trim().Split(' ', '/', ',')[0]; + if (LanguageCodes.TryGetValue(code.ToUpperInvariant(), out var language)) return language; + } + + var wordMatch = Regex.Match(normalized, wordBoundaryPattern, RegexOptions.IgnoreCase | RegexOptions.Compiled); + if (wordMatch.Success) + { + var code = wordMatch.Value.Trim(); + if (LanguageCodes.TryGetValue(code.ToUpperInvariant(), out var language)) return language; + } + + return null; + } + + public static string? ParseLanguageFromCode(string? code) + { + if (string.IsNullOrWhiteSpace(code)) return null; + + return LanguageCodes.TryGetValue(code.ToUpperInvariant(), out var language) + ? language + : null; + } +} diff --git a/listenarr.application/Search/SearchService.cs b/listenarr.application/Search/SearchService.cs index 2522c95b5..deddc8425 100644 --- a/listenarr.application/Search/SearchService.cs +++ b/listenarr.application/Search/SearchService.cs @@ -2284,8 +2284,8 @@ private List ParseMyAnonamouseResponse(string jsonResponse, .FirstOrDefault() ?? string.Empty; // Detect format from tags and from explicit field - var formatFromTags = DetectFormatFromTags(tags ?? ""); - var formatFromField = !string.IsNullOrEmpty(rawFormatField) ? DetectFormatFromTags(rawFormatField) : null; + var formatFromTags = SearchResultAttributeParser.DetectFormatFromTags(tags ?? ""); + var formatFromField = !string.IsNullOrEmpty(rawFormatField) ? SearchResultAttributeParser.DetectFormatFromTags(rawFormatField) : null; var finalFormat = (formatFromField != null && formatFromField != "MP3") ? formatFromField : formatFromTags; // Log explicit filetype when present @@ -2295,19 +2295,19 @@ private List ParseMyAnonamouseResponse(string jsonResponse, } // Detect quality: prefer tags, then explicit format field, then description/title - var qualityFromTags = DetectQualityFromTags(tags ?? ""); - var finalQuality = qualityFromTags != "Unknown" ? qualityFromTags : (!string.IsNullOrEmpty(rawFormatField) ? DetectQualityFromFormat(rawFormatField) : "Unknown"); + var qualityFromTags = SearchResultAttributeParser.DetectQualityFromTags(tags ?? ""); + var finalQuality = qualityFromTags != "Unknown" ? qualityFromTags : (!string.IsNullOrEmpty(rawFormatField) ? SearchResultAttributeParser.DetectQualityFromFormat(rawFormatField) : "Unknown"); // Fallback: try to detect quality from description or title (filename-like text) if (finalQuality == "Unknown") { if (!string.IsNullOrEmpty(description)) { - var q = DetectQualityFromTags(description); + var q = SearchResultAttributeParser.DetectQualityFromTags(description); if (q != "Unknown") finalQuality = q; else { - var q2 = DetectQualityFromFormat(description); + var q2 = SearchResultAttributeParser.DetectQualityFromFormat(description); if (q2 != "Unknown") finalQuality = q2; } } @@ -2315,11 +2315,11 @@ private List ParseMyAnonamouseResponse(string jsonResponse, if (finalQuality == "Unknown") { var probeText = title; - var q = DetectQualityFromTags(probeText); + var q = SearchResultAttributeParser.DetectQualityFromTags(probeText); if (q != "Unknown") finalQuality = q; else { - var q2 = DetectQualityFromFormat(probeText); + var q2 = SearchResultAttributeParser.DetectQualityFromFormat(probeText); if (q2 != "Unknown") finalQuality = q2; } } @@ -2330,14 +2330,14 @@ private List ParseMyAnonamouseResponse(string jsonResponse, { if (!string.IsNullOrEmpty(description)) { - var f = DetectFormatFromTags(description); + var f = SearchResultAttributeParser.DetectFormatFromTags(description); if (!string.IsNullOrEmpty(f) && f != "MP3") finalFormat = f; } if (finalFormat == "MP3") { var probeText = title; - var f = DetectFormatFromTags(probeText); + var f = SearchResultAttributeParser.DetectFormatFromTags(probeText); if (!string.IsNullOrEmpty(f) && f != "MP3") finalFormat = f; } } @@ -2394,7 +2394,7 @@ private List ParseMyAnonamouseResponse(string jsonResponse, // If we have a parsed language code, map to name and preserve raw code if (!string.IsNullOrEmpty(rawLangCode) && string.IsNullOrEmpty(result.Language)) { - result.Language = ParseLanguageFromCode(rawLangCode) ?? ParseLanguageFromText(rawLangCode); + result.Language = SearchResultAttributeParser.ParseLanguageFromCode(rawLangCode) ?? SearchResultAttributeParser.ParseLanguageFromText(rawLangCode); } result.IndexerId = indexer.Id; result.IndexerImplementation = indexer.Implementation ?? string.Empty; @@ -2532,7 +2532,7 @@ private List ParseMyAnonamouseResponse(string jsonResponse, if (!string.IsNullOrWhiteSpace(explicitLang)) { // Prefer direct code mapping (e.g., ENG -> English) when a short code is provided - var parsedLang = ParseLanguageFromCode(explicitLang) ?? ParseLanguageFromText(explicitLang); + var parsedLang = SearchResultAttributeParser.ParseLanguageFromCode(explicitLang) ?? SearchResultAttributeParser.ParseLanguageFromText(explicitLang); if (!string.IsNullOrWhiteSpace(parsedLang)) { result.Language = parsedLang; @@ -2543,7 +2543,7 @@ private List ParseMyAnonamouseResponse(string jsonResponse, if (string.IsNullOrWhiteSpace(result.Language)) { var probe = string.Join(" ", new[] { title, tags ?? string.Empty, description ?? string.Empty }).Trim(); - var detectedLang = ParseLanguageFromText(probe); + var detectedLang = SearchResultAttributeParser.ParseLanguageFromText(probe); if (!string.IsNullOrEmpty(detectedLang)) { result.Language = detectedLang; @@ -2735,7 +2735,7 @@ private async Task EnrichMyAnonamouseResultsAsync(Indexer indexer, List 0) r.Grabs = grabs; if (files > 0) r.Files = files; if (!string.IsNullOrEmpty(format) && string.IsNullOrEmpty(r.Format)) r.Format = format.ToUpper(); - if (!string.IsNullOrEmpty(langCode) && string.IsNullOrEmpty(r.Language)) r.Language = ParseLanguageFromCode(langCode); + if (!string.IsNullOrEmpty(langCode) && string.IsNullOrEmpty(r.Language)) r.Language = SearchResultAttributeParser.ParseLanguageFromCode(langCode); _logger.LogDebug("Enriched MyAnonamouse result {Id}: grabs={Grabs}, files={Files}, format={Format}, language={Language}", r.Id, r.Grabs, r.Files, r.Format, r.Language); } @@ -3053,7 +3053,7 @@ private async Task> ParseInternetArchiveSearchResponse ResultUrl = !string.IsNullOrEmpty(identifier) ? $"https://archive.org/details/{identifier}" : null, DownloadType = "DDL", // Direct Download Link Format = audioFile.Format, - Quality = DetectQualityFromFormat(audioFile.Format), + Quality = SearchResultAttributeParser.DetectQualityFromFormat(audioFile.Format), Source = $"{indexer.Name} (Internet Archive)", PublishedDate = string.Empty, IndexerId = indexer.Id, @@ -3068,7 +3068,7 @@ private async Task> ParseInternetArchiveSearchResponse try { - var detectedLang = ParseLanguageFromText(title); + var detectedLang = SearchResultAttributeParser.ParseLanguageFromText(title); if (!string.IsNullOrEmpty(detectedLang)) iaResult.Language = detectedLang; } catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) @@ -3289,7 +3289,7 @@ internal async Task> ParseTorznabResponseAsync(string // Standardized language codes (e.g., ENG, FR) try { - var parsedLang = ParseLanguageFromText(value); + var parsedLang = SearchResultAttributeParser.ParseLanguageFromText(value); if (!string.IsNullOrEmpty(parsedLang)) result.Language = parsedLang; } catch (Exception caughtEx_22) when (caughtEx_22 is not OperationCanceledException && caughtEx_22 is not OutOfMemoryException && caughtEx_22 is not StackOverflowException) @@ -3308,7 +3308,7 @@ internal async Task> ParseTorznabResponseAsync(string { try { - var pl = ParseLanguageFromText(value); + var pl = SearchResultAttributeParser.ParseLanguageFromText(value); if (!string.IsNullOrEmpty(pl)) result.Language = pl; } catch (Exception caughtEx_23) when (caughtEx_23 is not OperationCanceledException && caughtEx_23 is not OutOfMemoryException && caughtEx_23 is not StackOverflowException) @@ -3516,7 +3516,7 @@ internal async Task> ParseTorznabResponseAsync(string // Detect language codes present in title or description (e.g. [ENG / M4B]) try { - var lang = ParseLanguageFromText(result.Title + " " + description); + var lang = SearchResultAttributeParser.ParseLanguageFromText(result.Title + " " + description); if (!string.IsNullOrEmpty(lang)) result.Language = lang; } catch (Exception caughtEx_25) when (caughtEx_25 is not OperationCanceledException && caughtEx_25 is not OutOfMemoryException && caughtEx_25 is not StackOverflowException) @@ -3683,149 +3683,6 @@ private List GenerateMockResults(string query, string source) }; } - private string DetectQualityFromTags(string tags) - { - var lowerTags = tags.ToLower(); - - if (lowerTags.Contains("flac")) - return "FLAC"; - else if (lowerTags.Contains("320") || lowerTags.Contains("320kbps")) - return "MP3 320kbps"; - else if (lowerTags.Contains("256") || lowerTags.Contains("256kbps")) - return "MP3 256kbps"; - else if (lowerTags.Contains("192") || lowerTags.Contains("192kbps")) - return "MP3 192kbps"; - else if (lowerTags.Contains("128") || lowerTags.Contains("128kbps")) - return "MP3 128kbps"; - else if (lowerTags.Contains("64") || lowerTags.Contains("64kbps")) - return "MP3 64kbps"; - else if (lowerTags.Contains("m4b")) - return "M4B"; - else - return "Unknown"; - } - - private string DetectQualityFromFormat(string format) - { - if (string.IsNullOrEmpty(format)) - return "Unknown"; - - var lowerFormat = format.ToLower(); - - if (lowerFormat.Contains("flac")) - return "FLAC"; - else if (lowerFormat.Contains("m4b") || lowerFormat.Contains("apple audiobook")) - return "M4B"; - else if (lowerFormat.Contains("320kbps") || lowerFormat.Contains("320 kbps")) - return "MP3 320kbps"; - else if (lowerFormat.Contains("256kbps") || lowerFormat.Contains("256 kbps")) - return "MP3 256kbps"; - else if (lowerFormat.Contains("192kbps") || lowerFormat.Contains("192 kbps")) - return "MP3 192kbps"; - else if (lowerFormat.Contains("128kbps") || lowerFormat.Contains("128 kbps")) - return "MP3 128kbps"; - else if (lowerFormat.Contains("64kbps") || lowerFormat.Contains("64 kbps")) - return "MP3 64kbps"; - else if (lowerFormat.Contains("vbr mp3") || lowerFormat.Contains("variable bitrate")) - return "MP3 VBR"; - else if (lowerFormat.Contains("ogg vorbis") || lowerFormat.Contains("ogg")) - return "OGG Vorbis"; - else if (lowerFormat.Contains("opus")) - return "OPUS"; - else if (lowerFormat.Contains("aac")) - return "AAC"; - else if (lowerFormat.Contains("mp3")) - return "MP3"; - else - return "Unknown"; - } - - private string DetectFormatFromTags(string tags) - { - var lowerTags = tags.ToLower(); - - if (lowerTags.Contains("m4b")) - return "M4B"; - else if (lowerTags.Contains("flac")) - return "FLAC"; - else if (lowerTags.Contains("mp3")) - return "MP3"; - else if (lowerTags.Contains("opus")) - return "OPUS"; - else if (lowerTags.Contains("aac")) - return "AAC"; - else - return "MP3"; // Default to MP3 - } - - /// - /// Parse common language codes from a text block and return a full language name. - /// Matches bracketed tokens like "[ENG / M4B]", parenthesized "(ENG)", or standalone tokens with word boundaries. - /// Supports both three-letter codes and common two-letter aliases (ENG|EN -> English, DUT|NL -> Dutch, GER|DE -> German, FRE|FR -> French). - /// Matching is case-insensitive and conservative to avoid false positives. - /// - private string? ParseLanguageFromText(string text) - { - if (string.IsNullOrWhiteSpace(text)) return null; - - // Normalize whitespace - var normalized = Regex.Replace(text, "\\s+", " ", RegexOptions.Compiled | RegexOptions.IgnoreCase).Trim(); - - // Combined pattern: look for bracketed or parenthesized tokens OR standalone word-boundary tokens - // Examples matched: [ENG / M4B], (EN), ENG, EN - var codes = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - { "ENG", "English" }, { "EN", "English" }, - { "DUT", "Dutch" }, { "NL", "Dutch" }, - { "GER", "German" }, { "DE", "German" }, - { "FRE", "French" }, { "FR", "French" } - }; - - // Build a joined alternation like ENG|EN|DUT|NL|... - var alternation = string.Join("|", codes.Keys.Select(Regex.Escape)); - - // Bracketed or parenthesis forms: [ ENG / ... ] or (EN) - // Use verbatim interpolated string and escape [ and ( - var bracketedPattern = $@"[\[\(]\s*(?:{alternation})\b"; // starts with [ or ( then code - - // Standalone word boundary pattern: \b(ENG|EN|DUT|NL|...)\b - var wordBoundaryPattern = $"\\b(?:{alternation})\\b"; - - // Try bracketed/parenthesized first (higher confidence) - var bracketMatch = Regex.Match(normalized, bracketedPattern, RegexOptions.IgnoreCase | RegexOptions.Compiled); - if (bracketMatch.Success) - { - var code = bracketMatch.Value.TrimStart('[', '(').Trim().Split(' ', '/', ',')[0]; - if (codes.TryGetValue(code.ToUpperInvariant(), out var lang)) return lang; - } - - // Fall back to standalone word match - var wordMatch = Regex.Match(normalized, wordBoundaryPattern, RegexOptions.IgnoreCase | RegexOptions.Compiled); - if (wordMatch.Success) - { - var code = wordMatch.Value.Trim(); - if (codes.TryGetValue(code.ToUpperInvariant(), out var lang)) return lang; - } - - return null; - } - - private string? ParseLanguageFromCode(string? code) - { - if (string.IsNullOrWhiteSpace(code)) return null; - - var codes = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - { "ENG", "English" }, { "EN", "English" }, - { "DUT", "Dutch" }, { "NL", "Dutch" }, - { "GER", "German" }, { "DE", "German" }, - { "FRE", "French" }, { "FR", "French" } - }; - - if (codes.TryGetValue(code.ToUpperInvariant(), out var lang)) return lang; - return null; - } - private long ExtractSizeFromMyAnonamouseDescription(string? description) { if (string.IsNullOrEmpty(description)) diff --git a/tests/Features/Api/Services/ParseLanguageTests.cs b/tests/Features/Api/Services/ParseLanguageTests.cs index 7ba904c89..45e555839 100644 --- a/tests/Features/Api/Services/ParseLanguageTests.cs +++ b/tests/Features/Api/Services/ParseLanguageTests.cs @@ -34,13 +34,7 @@ public class ParseLanguageTests [InlineData("No language here", null)] public void ParseLanguageFromText_RecognizesCodes(string input, string? expected) { - // Create an uninitialized SearchService instance so we don't have to satisfy constructor dependencies - var svcObj = System.Runtime.Serialization.FormatterServices.GetUninitializedObject(typeof(SearchService)); - - var method = typeof(SearchService).GetMethod("ParseLanguageFromText", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - Assert.NotNull(method); - - var result = method.Invoke(svcObj, new object[] { input }) as string; + var result = SearchResultAttributeParser.ParseLanguageFromText(input); Assert.Equal(expected, result); } } diff --git a/tests/Features/Application/Audiobooks/AudiobookIdentifierMapperTests.cs b/tests/Features/Application/Audiobooks/AudiobookIdentifierMapperTests.cs new file mode 100644 index 000000000..885e1839b --- /dev/null +++ b/tests/Features/Application/Audiobooks/AudiobookIdentifierMapperTests.cs @@ -0,0 +1,76 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +using Listenarr.Application.Audiobooks; + +namespace Listenarr.Tests.Features.Application.Audiobooks +{ + public class AudiobookIdentifierMapperTests + { + [Fact] + public void GetEffectiveIdentifiers_SuppressesImportedLegacyDuplicate_WhenManualValueExists() + { + var audiobook = new Audiobook + { + Asin = "B0DQR9D4YG", + ExternalIdentifiers = new List + { + new AudiobookExternalIdentifier + { + Type = AudiobookExternalIdentifierType.Asin, + ValueRaw = "B0DQR9D4YG", + ValueNormalized = "B0DQR9D4YG", + Region = "us", + IsPrimary = true, + Source = AudiobookExternalIdentifierSource.Manual + } + } + }; + + var identifiers = AudiobookIdentifierMapper.GetEffectiveIdentifiers(audiobook); + + Assert.Single(identifiers); + Assert.Equal(AudiobookExternalIdentifierSource.Manual, identifiers[0].Source); + Assert.Equal("us", identifiers[0].Region); + } + + [Fact] + public void SyncImportedIdentifiersFromLegacyFields_AddsNormalizedLegacyIdentifiers() + { + var audiobook = new Audiobook + { + Asin = "B0DQR9D4YG", + Isbn = new List { "978-1-4028-9462-6" }, + OpenLibraryId = "OL123M" + }; + + AudiobookIdentifierMapper.SyncImportedIdentifiersFromLegacyFields(audiobook); + + Assert.Contains(audiobook.ExternalIdentifiers, i => + i.Type == AudiobookExternalIdentifierType.Asin && + i.ValueNormalized == "B0DQR9D4YG" && + i.Source == AudiobookExternalIdentifierSource.Imported); + Assert.Contains(audiobook.ExternalIdentifiers, i => + i.Type == AudiobookExternalIdentifierType.Isbn && + i.ValueNormalized == "9781402894626"); + Assert.Contains(audiobook.ExternalIdentifiers, i => + i.Type == AudiobookExternalIdentifierType.OpenLibraryId && + i.ValueNormalized == "OL123M"); + } + } +} diff --git a/tests/Features/Application/Search/ProwlarrIndexerPayloadParserTests.cs b/tests/Features/Application/Search/ProwlarrIndexerPayloadParserTests.cs new file mode 100644 index 000000000..780923538 --- /dev/null +++ b/tests/Features/Application/Search/ProwlarrIndexerPayloadParserTests.cs @@ -0,0 +1,86 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +using System.Text.Json; +using Listenarr.Application.Search; + +namespace Listenarr.Tests.Features.Application.Search +{ + public class ProwlarrIndexerPayloadParserTests + { + [Fact] + public void GetTagValues_ResolvesNumericTagsThroughTagMap() + { + using var document = JsonDocument.Parse(""" + { + "tags": [1, { "id": 2 }, { "label": "direct" }], + "fields": [ + { "name": "tagNames", "value": "field-tag" } + ] + } + """); + var tagMap = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["1"] = "audiobook", + ["2"] = "vip" + }; + + var tags = ProwlarrIndexerPayloadParser.GetTagValues(document.RootElement, tagMap); + + Assert.Contains("1", tags); + Assert.Contains("audiobook", tags); + Assert.Contains("2", tags); + Assert.Contains("vip", tags); + Assert.Contains("direct", tags); + Assert.Contains("field-tag", tags); + } + + [Fact] + public void PayloadRequiresTagMap_ReturnsTrue_WhenTagsAreOnlyNumeric() + { + using var document = JsonDocument.Parse("""[{ "tags": [1, 2] }]"""); + + Assert.True(ProwlarrIndexerPayloadParser.PayloadRequiresTagMap(document.RootElement)); + } + + [Fact] + public void GetCategoryIds_ReadsCapabilitiesDirectCategoriesAndFieldCategories() + { + using var document = JsonDocument.Parse(""" + { + "capabilities": { + "categories": [ + { "id": 3000, "subCategories": [{ "id": 3030 }] } + ] + }, + "categories": ["8010"], + "fields": [ + { "name": "categories", "value": [{ "id": 8020 }] } + ] + } + """); + + var categories = ProwlarrIndexerPayloadParser.GetCategoryIds(document.RootElement); + + Assert.Contains(3000, categories); + Assert.Contains(3030, categories); + Assert.Contains(8010, categories); + Assert.Contains(8020, categories); + } + } +} From 0abc9b38b13586649281e8955e168e7e4639d7e4 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Fri, 12 Jun 2026 10:06:15 -0400 Subject: [PATCH 14/84] Extract MyAnonamouse response parser --- .../Search/MyAnonamouseResponseParser.cs | 924 ++++++++++++++++++ listenarr.application/Search/SearchService.cs | 856 +--------------- .../Providers/IndexersNewznabParsingTests.cs | 64 +- .../Api/Services/SearchServiceFixesTests.cs | 49 +- 4 files changed, 938 insertions(+), 955 deletions(-) create mode 100644 listenarr.application/Search/MyAnonamouseResponseParser.cs diff --git a/listenarr.application/Search/MyAnonamouseResponseParser.cs b/listenarr.application/Search/MyAnonamouseResponseParser.cs new file mode 100644 index 000000000..1b6516e78 --- /dev/null +++ b/listenarr.application/Search/MyAnonamouseResponseParser.cs @@ -0,0 +1,924 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +using System.Text.Json; +using System.Text.RegularExpressions; +using Listenarr.Application.Common; +using Listenarr.Application.Security; +using Listenarr.Domain.Models; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Application.Search +{ + internal static class MyAnonamouseResponseParser + { + public static List Parse(string jsonResponse, Indexer indexer, ILogger logger) + { + var results = new List(); + + if (indexer == null) + { + logger.LogError("ParseMyAnonamouseResponse called with null indexer"); + return results; + } + + try + { + logger.LogDebug("Parsing MyAnonamouse response, length: {Length}", jsonResponse.Length); + + JsonDocument? doc = null; + JsonElement dataArrayElement = default; + + // Try to parse JSON directly. If that fails, try to extract the first JSON array substring. + try + { + doc = JsonDocument.Parse(jsonResponse); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + // Attempt to extract a JSON array from an HTML-wrapped response or stray text + var start = jsonResponse.IndexOf('['); + var end = jsonResponse.LastIndexOf(']'); + if (start >= 0 && end > start) + { + var sub = jsonResponse.Substring(start, end - start + 1); + try + { + doc = JsonDocument.Parse(sub); + } + catch (Exception parseEx) when (parseEx is not OperationCanceledException && parseEx is not OutOfMemoryException && parseEx is not StackOverflowException) + { + logger.LogWarning(parseEx, "Failed to parse extracted JSON array from MyAnonamouse response"); + return results; + } + } + else + { + logger.LogWarning("Unable to locate JSON array in MyAnonamouse response"); + return results; + } + } + + var root = doc!.RootElement; + + // Support multiple response shapes: + // 1) Root is an array of items + // 2) Root is an object with property "data" containing array + // 3) Root is an object with property "parsed" or "results" or "items" + if (root.ValueKind == JsonValueKind.Array) + { + dataArrayElement = root; + } + else if (root.ValueKind == JsonValueKind.Object) + { + if (root.TryGetProperty("data", out var tmp) && tmp.ValueKind == JsonValueKind.Array) + { + dataArrayElement = tmp; + } + else if (root.TryGetProperty("parsed", out tmp) && tmp.ValueKind == JsonValueKind.Array) + { + dataArrayElement = tmp; + } + else if (root.TryGetProperty("results", out tmp) && tmp.ValueKind == JsonValueKind.Array) + { + dataArrayElement = tmp; + } + else if (root.TryGetProperty("items", out tmp) && tmp.ValueKind == JsonValueKind.Array) + { + dataArrayElement = tmp; + } + else + { + // As a last resort, try to find the first array value anywhere in the object + foreach (var prop in root.EnumerateObject().Where(prop => prop.Value.ValueKind == JsonValueKind.Array)) + { + dataArrayElement = prop.Value; + break; + } + + if (dataArrayElement.ValueKind == JsonValueKind.Undefined) + { + logger.LogWarning("MyAnonamouse response did not contain an expected array property. Response preview: {Preview}", LogRedaction.RedactText(jsonResponse.Length > 500 ? jsonResponse.Substring(0, 500) + "..." : jsonResponse, LogRedaction.GetSensitiveValuesFromEnvironment().Concat(new[] { indexer.ApiKey ?? string.Empty }))); + return results; + } + } + } + else + { + logger.LogWarning("Unexpected MyAnonamouse root JSON kind: {Kind}", root.ValueKind); + return results; + } + + logger.LogDebug("Found {Count} MyAnonamouse results", dataArrayElement.GetArrayLength()); + try + { + if (dataArrayElement.GetArrayLength() > 0) + { + var firstRaw = dataArrayElement[0].ToString(); + var preview = firstRaw.Length > 400 ? firstRaw.Substring(0, 400) + "..." : firstRaw; + logger.LogDebug("First MyAnonamouse item preview: {Preview}", LogRedaction.RedactText(preview, LogRedaction.GetSensitiveValuesFromEnvironment().Concat(new[] { indexer.ApiKey ?? string.Empty }))); + + // Log full property list for the first item to aid debugging field names + try + { + var firstItem = dataArrayElement[0]; + var fields = string.Join(", ", firstItem.EnumerateObject().Select(p => $"{p.Name}={p.Value}")); + logger.LogInformation("First MyAnonamouse result fields: {Fields}", LogRedaction.RedactText(fields, LogRedaction.GetSensitiveValuesFromEnvironment().Concat(new[] { indexer.ApiKey ?? string.Empty }))); + } + catch (Exception exFields) when (exFields is not OperationCanceledException && exFields is not OutOfMemoryException && exFields is not StackOverflowException) + { + logger.LogDebug(exFields, "Failed to enumerate fields of first MyAnonamouse item"); + } + } + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + logger.LogDebug(ex, "Failed to produce preview of first MyAnonamouse item"); + } + + int _mamDebugIndex = 0; + foreach (var item in dataArrayElement.EnumerateArray()) + { + try + { + // Log property names for first few items to aid debugging + if (_mamDebugIndex < 3) + { + try + { + var propertyNames = item.EnumerateObject().Select(p => p.Name).ToList(); + logger.LogInformation("MyAnonamouse result #{Index} has properties: {Properties}", _mamDebugIndex, string.Join(", ", propertyNames)); + } + catch (Exception exNames) when (exNames is not OperationCanceledException && exNames is not OutOfMemoryException && exNames is not StackOverflowException) + { + logger.LogDebug(exNames, "Failed to enumerate property names for MyAnonamouse result #{Index}", _mamDebugIndex); + } + } + + var id = item.TryGetProperty("id", out var idElem) + ? idElem.ValueKind == JsonValueKind.String ? idElem.GetString() ?? string.Empty : idElem.ToString() + : Guid.NewGuid().ToString(); + + // MyAnonamouse uses "title" in responses; fall back to "name" if needed + var title = ""; + if (item.TryGetProperty("title", out var titleElem)) + { + title = titleElem.ValueKind == JsonValueKind.String ? titleElem.GetString() ?? "" : titleElem.ToString(); + } + else if (item.TryGetProperty("name", out titleElem)) + { + title = titleElem.ValueKind == JsonValueKind.String ? titleElem.GetString() ?? "" : titleElem.ToString(); + } + var sizeStr = ""; + if (item.TryGetProperty("size", out var sizeElem)) + { + if (sizeElem.ValueKind == System.Text.Json.JsonValueKind.String) + { + sizeStr = sizeElem.GetString() ?? "0"; + } + else if (sizeElem.ValueKind == System.Text.Json.JsonValueKind.Number) + { + sizeStr = sizeElem.GetInt64().ToString(); + } + else + { + sizeStr = "0"; + } + } + var seeders = item.TryGetProperty("seeders", out var seedElem) ? seedElem.GetInt32() : 0; + var leechers = item.TryGetProperty("leechers", out var leechElem) ? leechElem.GetInt32() : 0; + string dlHash = string.Empty; + if (item.TryGetProperty("dl", out var dlElem)) + { + dlHash = dlElem.ValueKind == JsonValueKind.String ? dlElem.GetString() ?? string.Empty : dlElem.ToString(); + } + + // New: explicit downloadUrl / infoUrl / fileName fields commonly provided by Prowlarr + string? downloadUrlField = null; + string? infoUrlField = null; + string? fileNameField = null; + // Use case-insensitive property lookup for robustness against differing casing in tracker responses + foreach (var prop in item.EnumerateObject()) + { + var name = prop.Name; + if (downloadUrlField == null && string.Equals(name, "downloadUrl", StringComparison.OrdinalIgnoreCase) && prop.Value.ValueKind == JsonValueKind.String) + downloadUrlField = prop.Value.GetString(); + if (infoUrlField == null && string.Equals(name, "infoUrl", StringComparison.OrdinalIgnoreCase) && prop.Value.ValueKind == JsonValueKind.String) + infoUrlField = prop.Value.GetString(); + if (fileNameField == null && string.Equals(name, "fileName", StringComparison.OrdinalIgnoreCase) && prop.Value.ValueKind == JsonValueKind.String) + fileNameField = prop.Value.GetString(); + } + + string category = string.Empty; + if (item.TryGetProperty("catname", out var catElem)) + { + category = catElem.ValueKind == JsonValueKind.String ? catElem.GetString() ?? string.Empty : catElem.ToString(); + } + + string tags = string.Empty; + if (item.TryGetProperty("tags", out var tagsElem)) + { + tags = tagsElem.ValueKind == JsonValueKind.String ? tagsElem.GetString() ?? string.Empty : tagsElem.ToString(); + } + + string description = string.Empty; + if (item.TryGetProperty("description", out var descElem)) + { + description = descElem.ValueKind == JsonValueKind.String ? descElem.GetString() ?? string.Empty : descElem.ToString(); + } + + // Parse grabs/files when present (Prowlarr exposes these directly for MyAnonamouse) + var grabs = 0; + var grabKeys = new[] { "grabs", "snatches", "snatched", "snatched_count", "snatches_count", "numgrabs", "num_grabs", "grab_count", "times_completed", "completed", "downloaded", "times_downloaded" }; + foreach (var prop in item.EnumerateObject().Where(prop => grabKeys.Any(k => string.Equals(k, prop.Name, StringComparison.OrdinalIgnoreCase)))) + { + var ge = prop.Value; + logger.LogInformation("Found grabs candidate field '{Field}' (kind={Kind}) for '{Title}': {Value}", prop.Name, ge.ValueKind, ge.ToString(), title); + if (ge.ValueKind == JsonValueKind.Number) + { + grabs = ge.GetInt32(); + logger.LogInformation("Parsed grabs for '{Title}' from field '{Field}': {Grabs}", title, prop.Name, grabs); + break; + } + else if (ge.ValueKind == JsonValueKind.String && int.TryParse(ge.GetString(), out var gtmp)) + { + grabs = gtmp; + logger.LogInformation("Parsed grabs (string) for '{Title}' from field '{Field}': {Grabs}", title, prop.Name, grabs); + break; + } + } + + var files = 0; + foreach (var prop in item.EnumerateObject().Where(prop => + string.Equals(prop.Name, "files", StringComparison.OrdinalIgnoreCase) || + string.Equals(prop.Name, "numfiles", StringComparison.OrdinalIgnoreCase) || + string.Equals(prop.Name, "num_files", StringComparison.OrdinalIgnoreCase))) + { + var fe = prop.Value; + logger.LogInformation("Found files candidate field '{Field}' (kind={Kind}) for '{Title}': {Value}", prop.Name, fe.ValueKind, fe.ToString(), title); + if (fe.ValueKind == JsonValueKind.Number) + { + files = fe.GetInt32(); + logger.LogInformation("Parsed files for '{Title}' from field '{Field}': {Files}", title, prop.Name, files); + } + else if (fe.ValueKind == JsonValueKind.String && int.TryParse(fe.GetString(), out var ftmp)) + { + files = ftmp; + logger.LogInformation("Parsed files (string) for '{Title}' from field '{Field}': {Files}", title, prop.Name, files); + } + + break; + } + + // Prefer explicit 'added' timestamp when present (MyAnonamouse uses "yyyy-MM-dd HH:mm:ss") + DateTime? publishDate = null; + if (item.TryGetProperty("added", out var addedElem) && addedElem.ValueKind == JsonValueKind.String) + { + var addedStr = addedElem.GetString(); + if (!string.IsNullOrWhiteSpace(addedStr)) + { + try + { + publishDate = DateTime.ParseExact(addedStr, "yyyy-MM-dd HH:mm:ss", System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.AssumeUniversal).ToLocalTime(); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + // ignore and fallback to other fields below + System.Diagnostics.Debug.WriteLine("Suppressed non-fatal exception in catch block."); + } + } + } + + // Parse publish date when present; fallback to 'age' if necessary + if (!publishDate.HasValue) + { + string? publishDateStr = null; + if (item.TryGetProperty("publishDate", out var pdElem) && pdElem.ValueKind == JsonValueKind.String) + publishDateStr = pdElem.GetString(); + else if (item.TryGetProperty("publish_date", out var pd2) && pd2.ValueKind == JsonValueKind.String) + publishDateStr = pd2.GetString(); + else if (item.TryGetProperty("publishdate", out var pd3) && pd3.ValueKind == JsonValueKind.String) + publishDateStr = pd3.GetString(); + + if (!string.IsNullOrWhiteSpace(publishDateStr)) + { + if (System.DateTimeOffset.TryParse(publishDateStr, out var dto)) + { + publishDate = dto.UtcDateTime; + } + else if (DateTime.TryParse(publishDateStr, out var pdv)) + { + publishDate = DateTime.SpecifyKind(pdv, DateTimeKind.Utc); + } + } + else + { + // Support multiple representations of "age": days, hours, minutes, or alternate keys (ageHours, ageMinutes) + int? days = null; + double? hours = null; + double? minutes = null; + + // Prefer explicit ageHours/ageMinutes if present + if (item.TryGetProperty("ageHours", out var ah) && (ah.ValueKind == JsonValueKind.Number || ah.ValueKind == JsonValueKind.String)) + { + if (ah.ValueKind == JsonValueKind.Number) hours = ah.GetDouble(); + else if (double.TryParse(ah.GetString(), out var htmp)) hours = htmp; + } + if (item.TryGetProperty("ageMinutes", out var am) && (am.ValueKind == JsonValueKind.Number || am.ValueKind == JsonValueKind.String)) + { + if (am.ValueKind == JsonValueKind.Number) minutes = am.GetDouble(); + else if (double.TryParse(am.GetString(), out var mtmp)) minutes = mtmp; + } + + // Fallback to 'age' if present. Heuristic: small values (<=48) likely hours; otherwise treat as days. + if ((hours == null && minutes == null) && item.TryGetProperty("age", out var ageElem)) + { + if (ageElem.ValueKind == JsonValueKind.Number) + { + var a = ageElem.GetDouble(); + if (a <= 48) hours = a; + else days = (int)Math.Floor(a); + } + else if (ageElem.ValueKind == JsonValueKind.String && double.TryParse(ageElem.GetString(), out var adtmp)) + { + var a = adtmp; + if (a <= 48) hours = a; + else days = (int)Math.Floor(a); + } + } + + if (minutes.HasValue && minutes.Value > 0) + publishDate = DateTime.UtcNow.AddMinutes(-minutes.Value); + else if (hours.HasValue && hours.Value > 0) + publishDate = DateTime.UtcNow.AddHours(-hours.Value); + else if (days.HasValue && days.Value > 0) + publishDate = DateTime.UtcNow.AddDays(-days.Value); + } + } + + if (string.IsNullOrEmpty(title)) + continue; + + // (debug log moved later after we build the result so all fields exist) + + // Parse size - handle various formats + long size = 0; + if (!string.IsNullOrEmpty(sizeStr) && sizeStr != "0") + { + size = ParseSizeString(sizeStr, logger); + logger.LogDebug("Parsed size for MyAnonamouse result '{Title}': {Size} bytes from size field '{SizeStr}'", title, size, sizeStr); + } + else + { + // Try to extract size from description when size field is 0 + size = ExtractSizeFromMyAnonamouseDescription(description, logger); + if (size > 0) + { + logger.LogDebug("Parsed size for MyAnonamouse result '{Title}': {Size} bytes from description", title, size); + } + else + { + logger.LogWarning("MyAnonamouse result '{Title}' has no size information in size field or description", title); + } + } + + // Extract author from author_info JSON + string? author = null; + if (item.TryGetProperty("author_info", out var authorInfo)) + { + var authorJson = authorInfo.GetString(); + if (!string.IsNullOrEmpty(authorJson)) + { + try + { + var authorDoc = JsonDocument.Parse(authorJson); + var authors = new List(); + foreach (var prop in authorDoc.RootElement.EnumerateObject()) + { + authors.Add(prop.Value.GetString() ?? ""); + } + author = string.Join(", ", authors.Where(a => !string.IsNullOrEmpty(a))); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + logger.LogWarning(ex, "Failed to parse author JSON for search result"); + } + } + } + + // Extract narrator from narrator_info JSON + string? narrator = null; + if (item.TryGetProperty("narrator_info", out var narratorInfo)) + { + var narratorJson = narratorInfo.GetString(); + if (!string.IsNullOrEmpty(narratorJson)) + { + try + { + var narratorDoc = JsonDocument.Parse(narratorJson); + var narrators = new List(); + foreach (var prop in narratorDoc.RootElement.EnumerateObject()) + { + narrators.Add(prop.Value.GetString() ?? ""); + } + narrator = string.Join(", ", narrators.Where(n => !string.IsNullOrEmpty(n))); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + logger.LogWarning(ex, "Failed to parse narrator JSON for search result"); + } + } + } + + // Detect quality and format with robust fallbacks: + // 1) Prefer explicit format/filetype fields when present + // 2) Use tags when available + // 3) Fallback to description and title (filename) parsing + + // Try to read explicit format/filetype fields from the item (case-insensitive) + var rawFormatField = item.EnumerateObject() + .Where(prop => prop.Value.ValueKind == JsonValueKind.String && + (string.Equals(prop.Name, "format", StringComparison.OrdinalIgnoreCase) || + string.Equals(prop.Name, "filetype", StringComparison.OrdinalIgnoreCase))) + .Select(prop => prop.Value.GetString() ?? string.Empty) + .FirstOrDefault() ?? string.Empty; + + // Detect format from tags and from explicit field + var formatFromTags = SearchResultAttributeParser.DetectFormatFromTags(tags ?? ""); + var formatFromField = !string.IsNullOrEmpty(rawFormatField) ? SearchResultAttributeParser.DetectFormatFromTags(rawFormatField) : null; + var finalFormat = (formatFromField != null && formatFromField != "MP3") ? formatFromField : formatFromTags; + + // Log explicit filetype when present + if (!string.IsNullOrEmpty(rawFormatField)) + { + logger.LogDebug("MyAnonamouse: found explicit filetype '{Filetype}' for item {Id}", rawFormatField, id); + } + + // Detect quality: prefer tags, then explicit format field, then description/title + var qualityFromTags = SearchResultAttributeParser.DetectQualityFromTags(tags ?? ""); + var finalQuality = qualityFromTags != "Unknown" ? qualityFromTags : (!string.IsNullOrEmpty(rawFormatField) ? SearchResultAttributeParser.DetectQualityFromFormat(rawFormatField) : "Unknown"); + + // Fallback: try to detect quality from description or title (filename-like text) + if (finalQuality == "Unknown") + { + if (!string.IsNullOrEmpty(description)) + { + var q = SearchResultAttributeParser.DetectQualityFromTags(description); + if (q != "Unknown") finalQuality = q; + else + { + var q2 = SearchResultAttributeParser.DetectQualityFromFormat(description); + if (q2 != "Unknown") finalQuality = q2; + } + } + + if (finalQuality == "Unknown") + { + var probeText = title; + var q = SearchResultAttributeParser.DetectQualityFromTags(probeText); + if (q != "Unknown") finalQuality = q; + else + { + var q2 = SearchResultAttributeParser.DetectQualityFromFormat(probeText); + if (q2 != "Unknown") finalQuality = q2; + } + } + } + + // Additional fallback: if format still looks generic MP3, probe description/title + if (finalFormat == "MP3") + { + if (!string.IsNullOrEmpty(description)) + { + var f = SearchResultAttributeParser.DetectFormatFromTags(description); + if (!string.IsNullOrEmpty(f) && f != "MP3") finalFormat = f; + } + + if (finalFormat == "MP3") + { + var probeText = title; + var f = SearchResultAttributeParser.DetectFormatFromTags(probeText); + if (!string.IsNullOrEmpty(f) && f != "MP3") finalFormat = f; + } + } + + // Build download URL (include mam_id if configured) + var downloadUrl = ""; + if (!string.IsNullOrEmpty(dlHash)) + { + var baseUrl = (indexer.Url ?? "https://www.myanonamouse.net").TrimEnd('/'); + downloadUrl = $"{baseUrl}/tor/download.php/{dlHash}"; + var mamIdLocal = MyAnonamouseHelper.TryGetMamId(indexer.AdditionalSettings); + if (!string.IsNullOrEmpty(mamIdLocal)) + { + // Normalize mam_id: if the stored value is already percent-encoded, unescape it first + // to avoid double-encoding sequences like "%252B". Then escape once for safe query use. + try + { + mamIdLocal = Uri.UnescapeDataString(mamIdLocal); + } + catch (Exception caughtEx_19) when (caughtEx_19 is not OperationCanceledException && caughtEx_19 is not OutOfMemoryException && caughtEx_19 is not StackOverflowException) + { + // If unescape fails for any reason, fall back to original value + System.Diagnostics.Debug.WriteLine("Suppressed non-fatal exception in catch block."); + } + + downloadUrl += $"?mam_id={Uri.EscapeDataString(mamIdLocal)}"; + } + } + + // Preserve raw language code for later flagging/flags list + string rawLangCode = string.Empty; + logger.LogDebug("MyAnonamouse: rawFormat='{Raw}', finalFormat='{Final}', rawLang='{LangCode}'", rawFormatField, finalFormat, rawLangCode); + + var result = new IndexerSearchResult + { + Id = id ?? Guid.NewGuid().ToString(), + Title = title, + Artist = author ?? "Unknown Author", + Album = narrator != null ? $"Narrated by {narrator}" : "Unknown", + Category = category ?? "Audiobook", + Size = size, + Seeders = seeders, + Leechers = leechers, + Source = indexer.Name ?? "MyAnonamouse", + PublishedDate = publishDate?.ToString("o") ?? string.Empty, + Quality = finalQuality, + Format = finalFormat, + TorrentUrl = downloadUrl, + // Use MyAnonamouse public item page pattern: https://myanonamouse.net/t/{id} + ResultUrl = !string.IsNullOrEmpty(id) ? $"https://myanonamouse.net/t/{Uri.EscapeDataString(id)}" : (indexer.Url ?? ""), + MagnetLink = "", + NzbUrl = "" + }; + // If we have a parsed language code, map to name and preserve raw code + if (!string.IsNullOrEmpty(rawLangCode) && string.IsNullOrEmpty(result.Language)) + { + result.Language = SearchResultAttributeParser.ParseLanguageFromCode(rawLangCode) ?? SearchResultAttributeParser.ParseLanguageFromText(rawLangCode); + } + result.IndexerId = indexer.Id; + result.IndexerImplementation = indexer.Implementation ?? string.Empty; + // Robust link detection: prefer magnet/hash/torrent indicators, only treat as NZB when explicit NZB fields exist + try + { + string magnetLink = ""; + // Common magnet field names + if (item.TryGetProperty("magnet", out var magnetElem) && magnetElem.ValueKind == JsonValueKind.String) + magnetLink = magnetElem.GetString() ?? ""; + else if (item.TryGetProperty("magnetLink", out magnetElem) && magnetElem.ValueKind == JsonValueKind.String) + magnetLink = magnetElem.GetString() ?? ""; + else if (item.TryGetProperty("magnetlink", out magnetElem) && magnetElem.ValueKind == JsonValueKind.String) + magnetLink = magnetElem.GetString() ?? ""; + + // If we have a torrent hash, construct a magnet link + if (string.IsNullOrEmpty(magnetLink) && item.TryGetProperty("hash", out var hashElem) && hashElem.ValueKind == JsonValueKind.String) + { + var h = hashElem.GetString(); + if (!string.IsNullOrWhiteSpace(h)) + { + magnetLink = $"magnet:?xt=urn:btih:{h}&dn={Uri.EscapeDataString(title)}"; + } + } + + // Detect torrent download URL from other common fields + string[] torrentFields = new[] { "download", "dlLink", "downloadlink", "download_url", "torrent", "torrent_url", "torrentUrl", "torrentlink" }; + var torrentUrlDetected = result.TorrentUrl + ?? torrentFields + .Select(tf => item.TryGetProperty(tf, out var tfElem) && tfElem.ValueKind == JsonValueKind.String + ? tfElem.GetString() + : null) + .FirstOrDefault(url => !string.IsNullOrEmpty(url)) + ?? string.Empty; + + // If any URL looks like a .torrent file, prefer it as torrent URL + if (string.IsNullOrEmpty(torrentUrlDetected)) + { + foreach (var v in item.EnumerateObject() + .Where(prop => prop.Value.ValueKind == JsonValueKind.String) + .Select(prop => prop.Value.GetString()) + .Where(v => !string.IsNullOrEmpty(v) && v.EndsWith(".torrent", StringComparison.OrdinalIgnoreCase))) + { + torrentUrlDetected = v!; + break; + } + } + + // Detect NZB fields (only treat as NZB when explicit) + string nzbUrlDetected = string.Empty; + if (item.TryGetProperty("nzb", out var nzbElem) && nzbElem.ValueKind == JsonValueKind.String) + nzbUrlDetected = nzbElem.GetString() ?? string.Empty; + else if (item.TryGetProperty("nzbLink", out nzbElem) && nzbElem.ValueKind == JsonValueKind.String) + nzbUrlDetected = nzbElem.GetString() ?? string.Empty; + else if (item.TryGetProperty("nzburl", out nzbElem) && nzbElem.ValueKind == JsonValueKind.String) + nzbUrlDetected = nzbElem.GetString() ?? string.Empty; + + // Apply discovered links to the result + if (!string.IsNullOrEmpty(magnetLink)) result.MagnetLink = magnetLink; + if (!string.IsNullOrEmpty(torrentUrlDetected)) result.TorrentUrl = torrentUrlDetected; + if (!string.IsNullOrEmpty(nzbUrlDetected)) result.NzbUrl = nzbUrlDetected; + + // If a direct downloadUrl was provided by the API, prefer that as the torrent/nzb URL + if (!string.IsNullOrEmpty(downloadUrlField)) + { + // Choose disposition based on common hints and protocol + if (downloadUrlField.EndsWith(".torrent", StringComparison.OrdinalIgnoreCase) || (item.TryGetProperty("protocol", out var protoElem) && protoElem.ValueKind == JsonValueKind.String && protoElem.GetString()?.Equals("torrent", StringComparison.OrdinalIgnoreCase) == true)) + { + result.TorrentUrl = downloadUrlField; + } + else if (downloadUrlField.EndsWith(".nzb", StringComparison.OrdinalIgnoreCase) || (item.TryGetProperty("protocol", out var proto2Elem) && proto2Elem.ValueKind == JsonValueKind.String && proto2Elem.GetString()?.Equals("usenet", StringComparison.OrdinalIgnoreCase) == true)) + { + result.NzbUrl = downloadUrlField; + } + else + { + // Unknown, prefer TorrentUrl by default + result.TorrentUrl = downloadUrlField; + } + } + + // If guid is present and looks like a URL, prefer it as the canonical link + if (item.TryGetProperty("guid", out var guidElem) && guidElem.ValueKind == JsonValueKind.String && Uri.IsWellFormedUriString(guidElem.GetString(), UriKind.Absolute)) + { + result.ResultUrl = guidElem.GetString(); + } + + // If infoUrl is present, use it as the canonical page link when available + if (!string.IsNullOrEmpty(infoUrlField)) + { + result.ResultUrl = infoUrlField; + } + + // Use filename field to populate TorrentFileName when available + if (!string.IsNullOrEmpty(fileNameField)) + { + result.TorrentFileName = fileNameField; + } + + // Prefer marking the download type when either magnet/torrent or NZB URL exists + if (!string.IsNullOrEmpty(result.MagnetLink) || !string.IsNullOrEmpty(result.TorrentUrl)) + result.DownloadType = "Torrent"; + else if (!string.IsNullOrEmpty(result.NzbUrl)) + result.DownloadType = "nzb"; + + logger.LogDebug("MyAnonamouse parsed item #{Index} link-disposition: magnet={MagnetPresent}, torrent={TorrentPresent}, nzb={NzbPresent}", _mamDebugIndex, !string.IsNullOrEmpty(result.MagnetLink), !string.IsNullOrEmpty(result.TorrentUrl), !string.IsNullOrEmpty(result.NzbUrl)); + } + catch (Exception exLink) when (exLink is not OperationCanceledException && exLink is not OutOfMemoryException && exLink is not StackOverflowException) + { + logger.LogDebug(exLink, "Failed to detect links for MyAnonamouse item {Id}", id); + } + + // Prefer explicit language fields when present (lang_code, language_code, lang, language) - case-insensitive search + string explicitLang = string.Empty; + foreach (var prop in item.EnumerateObject().Where(prop => + (prop.Name.Equals("lang_code", StringComparison.OrdinalIgnoreCase) || + prop.Name.Equals("language_code", StringComparison.OrdinalIgnoreCase) || + prop.Name.Equals("lang", StringComparison.OrdinalIgnoreCase) || + prop.Name.Equals("language", StringComparison.OrdinalIgnoreCase)) && + prop.Value.ValueKind == JsonValueKind.String)) + { + explicitLang = prop.Value.GetString() ?? string.Empty; + logger.LogDebug("MyAnonamouse: found language field '{Field}'='{Lang}' for item {Id}", prop.Name, explicitLang, id); + break; + } + + // Numeric language id fallback (case-insensitive check) + if (string.IsNullOrEmpty(explicitLang) && item.TryGetProperty("language", out var langNumElem) && langNumElem.ValueKind == JsonValueKind.Number) + { + var numeric = langNumElem.GetInt32(); + if (numeric == 1) { explicitLang = "ENG"; } + logger.LogDebug("MyAnonamouse: found numeric language id={Num} mapped to '{Lang}' for item {Id}", numeric, explicitLang, id); + } + + if (!string.IsNullOrWhiteSpace(explicitLang)) + { + // Prefer direct code mapping (e.g., ENG -> English) when a short code is provided + var parsedLang = SearchResultAttributeParser.ParseLanguageFromCode(explicitLang) ?? SearchResultAttributeParser.ParseLanguageFromText(explicitLang); + if (!string.IsNullOrWhiteSpace(parsedLang)) + { + result.Language = parsedLang; + } + } + + // Fallback: parse title, tags and description for language codes (e.g. '[ENG / M4B]') + if (string.IsNullOrWhiteSpace(result.Language)) + { + var probe = string.Join(" ", new[] { title, tags ?? string.Empty, description ?? string.Empty }).Trim(); + var detectedLang = SearchResultAttributeParser.ParseLanguageFromText(probe); + if (!string.IsNullOrEmpty(detectedLang)) + { + result.Language = detectedLang; + } + } + + // Apply grabs/files to the result when available + result.Grabs = grabs; + result.Files = files; + + try + { + if (_mamDebugIndex < 5) + { + logger.LogDebug("ParseMyAnonamouse: constructed SearchResult #{Index} -> Id='{Id}', Title='{Title}', Size={Size}, Seeders={Seeders}, TorrentUrl='{TorrentUrl}', Artist='{Artist}', Album='{Album}', Category='{Category}', Source='{Source}', Grabs={Grabs}, Files={Files}, PublishedDate={PublishedDate}'", + _mamDebugIndex, result.Id, result.Title, result.Size, result.Seeders, result.TorrentUrl ?? "", result.Artist ?? "", result.Album ?? "", result.Category ?? "", result.Source ?? "", result.Grabs, result.Files, result.PublishedDate); + } + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + logger.LogDebug(ex, "Failed to write debug log for constructed MyAnonamouse SearchResult"); + } + + _mamDebugIndex++; + // Final best-effort: if title lacks bracketed flags but we have a TorrentFileName with them, append the filename's suffix + if (!string.IsNullOrEmpty(result.TorrentFileName) && !System.Text.RegularExpressions.Regex.IsMatch(result.Title ?? string.Empty, "\\[.*\\]$")) + { + try + { + var fname = result.TorrentFileName; + var dotIdx2 = fname.LastIndexOf('.'); + var nameOnly2 = dotIdx2 > 0 ? fname.Substring(0, dotIdx2) : fname; + var bracketStart2 = nameOnly2.IndexOf(" ["); + if (bracketStart2 >= 0) + { + var suffix2 = nameOnly2.Substring(bracketStart2); + if (!(result.Title ?? string.Empty).Contains(suffix2)) + { + result.Title = (result.Title ?? string.Empty) + suffix2; + } + } + } + catch (Exception ex2) when (ex2 is not OperationCanceledException && ex2 is not OutOfMemoryException && ex2 is not StackOverflowException) + { + logger.LogDebug(ex2, "Failed to append filename flags to title for MyAnonamouse item {Id}", id); + } + } + + + results.Add(result); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + logger.LogWarning(ex, "Failed to parse MyAnonamouse result item"); + } + } + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + logger.LogError(ex, "Failed to parse MyAnonamouse response"); + } + + return results; + } + + // Recursively search a JsonElement for a mam_id-like property (case-insensitive) + private static string? FindMamIdInJson(JsonElement element) + { + // Keys to look for + var keys = new HashSet(StringComparer.OrdinalIgnoreCase) { "mam_id", "mamid", "mamId", "mamID", "mam" }; + + if (element.ValueKind == JsonValueKind.Object) + { + foreach (var prop in element.EnumerateObject()) + { + try + { + if (keys.Contains(prop.Name) && prop.Value.ValueKind == JsonValueKind.String) + return prop.Value.GetString(); + + // Recurse into objects and arrays + if (prop.Value.ValueKind == JsonValueKind.Object || prop.Value.ValueKind == JsonValueKind.Array) + { + var found = FindMamIdInJson(prop.Value); + if (!string.IsNullOrEmpty(found)) return found; + } + } + catch (Exception caughtEx_20) when (caughtEx_20 is not OperationCanceledException && caughtEx_20 is not OutOfMemoryException && caughtEx_20 is not StackOverflowException) + { /* ignore malformed inner values */ + System.Diagnostics.Debug.WriteLine("Suppressed non-fatal exception in catch block."); + } + } + } + else if (element.ValueKind == JsonValueKind.Array) + { + var found = element.EnumerateArray() + .Select(FindMamIdInJson) + .FirstOrDefault(value => !string.IsNullOrEmpty(value)); + if (!string.IsNullOrEmpty(found)) return found; + } + + return null; + } + + private static long ExtractSizeFromMyAnonamouseDescription(string? description, ILogger logger) + { + if (string.IsNullOrEmpty(description)) + return 0; + + // Look for patterns like "Total Size : 259MB (272 033 986 bytes)" + var match = Regex.Match(description, @"Total Size\s*:\s*([\d\.,]+)\s*(MB|GB|KB|B)\s*\(([\d\s,]+)\s*bytes?\)", RegexOptions.IgnoreCase); + if (match.Success) + { + // Try to parse the bytes value first (most accurate) + var bytesStr = match.Groups[3].Value.Replace(",", "").Replace(" ", ""); + if (long.TryParse(bytesStr, out var bytes)) + { + logger.LogDebug("Extracted size from MyAnonamouse description bytes: {Bytes}", bytes); + return bytes; + } + + // Fallback to parsing the formatted size + var sizeValue = match.Groups[1].Value.Replace(",", ""); + var unit = match.Groups[2].Value.ToUpper(); + if (double.TryParse(sizeValue, out var value)) + { + var result = unit switch + { + "B" => (long)value, + "KB" => (long)(value * 1024), + "MB" => (long)(value * 1024 * 1024), + "GB" => (long)(value * 1024 * 1024 * 1024), + _ => (long)value + }; + logger.LogDebug("Extracted size from MyAnonamouse description formatted: {Value} {Unit} = {Result} bytes", value, unit, result); + return result; + } + } + + // Alternative pattern: just "Total Size : 259MB" without bytes + match = Regex.Match(description, @"Total Size\s*:\s*([\d\.,]+)\s*(MB|GB|KB|B)", RegexOptions.IgnoreCase); + if (match.Success) + { + var sizeValue = match.Groups[1].Value.Replace(",", ""); + var unit = match.Groups[2].Value.ToUpper(); + if (double.TryParse(sizeValue, out var value)) + { + var result = unit switch + { + "B" => (long)value, + "KB" => (long)(value * 1024), + "MB" => (long)(value * 1024 * 1024), + "GB" => (long)(value * 1024 * 1024 * 1024), + _ => (long)value + }; + logger.LogDebug("Extracted size from MyAnonamouse description (no bytes): {Value} {Unit} = {Result} bytes", value, unit, result); + return result; + } + } + + logger.LogDebug("No size found in MyAnonamouse description"); + return 0; + } + + private static long ParseSizeString(string sizeStr, ILogger logger) + { + if (string.IsNullOrEmpty(sizeStr)) + return 0; + + // Remove any commas and extra spaces + sizeStr = sizeStr.Replace(",", "").Trim(); + + // Try to parse as direct bytes first + if (long.TryParse(sizeStr, out var bytes)) + return bytes; + + // Handle formats like "500 MB", "1.2 GB", "1024 KB", "3.7 GiB", "279.0 MiB", etc. + // Support both decimal (KB/MB/GB/TB) and binary (KiB/MiB/GiB/TiB) units + var match = System.Text.RegularExpressions.Regex.Match(sizeStr, @"^([\d\.]+)\s*(KiB|MiB|GiB|TiB|KB|MB|GB|TB|B)$", System.Text.RegularExpressions.RegexOptions.IgnoreCase); + if (match.Success && + double.TryParse(match.Groups[1].Value, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var value)) + { + var unit = match.Groups[2].Value.ToUpper(); + return unit switch + { + "B" => (long)value, + "KB" => (long)(value * 1000), + "MB" => (long)(value * 1000 * 1000), + "GB" => (long)(value * 1000 * 1000 * 1000), + "TB" => (long)(value * 1000 * 1000 * 1000 * 1000), + "KIB" => (long)(value * 1024), + "MIB" => (long)(value * 1024 * 1024), + "GIB" => (long)(value * 1024 * 1024 * 1024), + "TIB" => (long)(value * 1024 * 1024 * 1024 * 1024), + _ => (long)value + }; + } + + logger.LogWarning("Unable to parse size string: '{SizeStr}'", sizeStr); + return 0; + } + } +} diff --git a/listenarr.application/Search/SearchService.cs b/listenarr.application/Search/SearchService.cs index deddc8425..4a8336e4e 100644 --- a/listenarr.application/Search/SearchService.cs +++ b/listenarr.application/Search/SearchService.cs @@ -17,7 +17,6 @@ */ using System.Text.Json; -using System.Text.RegularExpressions; using Microsoft.Extensions.Caching.Memory; using AsyncKeyedLock; using Listenarr.Application.Interfaces; @@ -1823,7 +1822,7 @@ private async Task> SearchMyAnonamouseAsync(Indexer in var jsonResponse = await response.Content.ReadAsStringAsync(); _logger.LogDebug("MyAnonamouse raw response: {Response}", jsonResponse); - var results = ParseMyAnonamouseResponse(jsonResponse, indexer); + var results = MyAnonamouseResponseParser.Parse(jsonResponse, indexer, _logger); // Optional per-result enrichment: fetch individual item pages to populate missing fields try @@ -1852,801 +1851,7 @@ private async Task> SearchMyAnonamouseAsync(Indexer in } } - private List ParseMyAnonamouseResponse(string jsonResponse, Indexer indexer) - { - var results = new List(); - - if (indexer == null) - { - _logger.LogError("ParseMyAnonamouseResponse called with null indexer"); - return results; - } - - try - { - _logger.LogDebug("Parsing MyAnonamouse response, length: {Length}", jsonResponse.Length); - - JsonDocument? doc = null; - JsonElement dataArrayElement = default; - - // Try to parse JSON directly. If that fails, try to extract the first JSON array substring. - try - { - doc = JsonDocument.Parse(jsonResponse); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - // Attempt to extract a JSON array from an HTML-wrapped response or stray text - var start = jsonResponse.IndexOf('['); - var end = jsonResponse.LastIndexOf(']'); - if (start >= 0 && end > start) - { - var sub = jsonResponse.Substring(start, end - start + 1); - try - { - doc = JsonDocument.Parse(sub); - } - catch (Exception parseEx) when (parseEx is not OperationCanceledException && parseEx is not OutOfMemoryException && parseEx is not StackOverflowException) - { - _logger.LogWarning(parseEx, "Failed to parse extracted JSON array from MyAnonamouse response"); - return results; - } - } - else - { - _logger.LogWarning("Unable to locate JSON array in MyAnonamouse response"); - return results; - } - } - - var root = doc!.RootElement; - - // Support multiple response shapes: - // 1) Root is an array of items - // 2) Root is an object with property "data" containing array - // 3) Root is an object with property "parsed" or "results" or "items" - if (root.ValueKind == JsonValueKind.Array) - { - dataArrayElement = root; - } - else if (root.ValueKind == JsonValueKind.Object) - { - if (root.TryGetProperty("data", out var tmp) && tmp.ValueKind == JsonValueKind.Array) - { - dataArrayElement = tmp; - } - else if (root.TryGetProperty("parsed", out tmp) && tmp.ValueKind == JsonValueKind.Array) - { - dataArrayElement = tmp; - } - else if (root.TryGetProperty("results", out tmp) && tmp.ValueKind == JsonValueKind.Array) - { - dataArrayElement = tmp; - } - else if (root.TryGetProperty("items", out tmp) && tmp.ValueKind == JsonValueKind.Array) - { - dataArrayElement = tmp; - } - else - { - // As a last resort, try to find the first array value anywhere in the object - foreach (var prop in root.EnumerateObject().Where(prop => prop.Value.ValueKind == JsonValueKind.Array)) - { - dataArrayElement = prop.Value; - break; - } - - if (dataArrayElement.ValueKind == JsonValueKind.Undefined) - { - _logger.LogWarning("MyAnonamouse response did not contain an expected array property. Response preview: {Preview}", LogRedaction.RedactText(jsonResponse.Length > 500 ? jsonResponse.Substring(0, 500) + "..." : jsonResponse, LogRedaction.GetSensitiveValuesFromEnvironment().Concat(new[] { indexer.ApiKey ?? string.Empty }))); - return results; - } - } - } - else - { - _logger.LogWarning("Unexpected MyAnonamouse root JSON kind: {Kind}", root.ValueKind); - return results; - } - - _logger.LogDebug("Found {Count} MyAnonamouse results", dataArrayElement.GetArrayLength()); - try - { - if (dataArrayElement.GetArrayLength() > 0) - { - var firstRaw = dataArrayElement[0].ToString(); - var preview = firstRaw.Length > 400 ? firstRaw.Substring(0, 400) + "..." : firstRaw; - _logger.LogDebug("First MyAnonamouse item preview: {Preview}", LogRedaction.RedactText(preview, LogRedaction.GetSensitiveValuesFromEnvironment().Concat(new[] { indexer.ApiKey ?? string.Empty }))); - - // Log full property list for the first item to aid debugging field names - try - { - var firstItem = dataArrayElement[0]; - var fields = string.Join(", ", firstItem.EnumerateObject().Select(p => $"{p.Name}={p.Value}")); - _logger.LogInformation("First MyAnonamouse result fields: {Fields}", LogRedaction.RedactText(fields, LogRedaction.GetSensitiveValuesFromEnvironment().Concat(new[] { indexer.ApiKey ?? string.Empty }))); - } - catch (Exception exFields) when (exFields is not OperationCanceledException && exFields is not OutOfMemoryException && exFields is not StackOverflowException) - { - _logger.LogDebug(exFields, "Failed to enumerate fields of first MyAnonamouse item"); - } - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogDebug(ex, "Failed to produce preview of first MyAnonamouse item"); - } - - int _mamDebugIndex = 0; - foreach (var item in dataArrayElement.EnumerateArray()) - { - try - { - // Log property names for first few items to aid debugging - if (_mamDebugIndex < 3) - { - try - { - var propertyNames = item.EnumerateObject().Select(p => p.Name).ToList(); - _logger.LogInformation("MyAnonamouse result #{Index} has properties: {Properties}", _mamDebugIndex, string.Join(", ", propertyNames)); - } - catch (Exception exNames) when (exNames is not OperationCanceledException && exNames is not OutOfMemoryException && exNames is not StackOverflowException) - { - _logger.LogDebug(exNames, "Failed to enumerate property names for MyAnonamouse result #{Index}", _mamDebugIndex); - } - } - var id = item.TryGetProperty("id", out var idElem) - ? idElem.ValueKind == JsonValueKind.String ? idElem.GetString() ?? string.Empty : idElem.ToString() - : Guid.NewGuid().ToString(); - - // MyAnonamouse uses "title" in responses; fall back to "name" if needed - var title = ""; - if (item.TryGetProperty("title", out var titleElem)) - { - title = titleElem.ValueKind == JsonValueKind.String ? titleElem.GetString() ?? "" : titleElem.ToString(); - } - else if (item.TryGetProperty("name", out titleElem)) - { - title = titleElem.ValueKind == JsonValueKind.String ? titleElem.GetString() ?? "" : titleElem.ToString(); - } - var sizeStr = ""; - if (item.TryGetProperty("size", out var sizeElem)) - { - if (sizeElem.ValueKind == System.Text.Json.JsonValueKind.String) - { - sizeStr = sizeElem.GetString() ?? "0"; - } - else if (sizeElem.ValueKind == System.Text.Json.JsonValueKind.Number) - { - sizeStr = sizeElem.GetInt64().ToString(); - } - else - { - sizeStr = "0"; - } - } - var seeders = item.TryGetProperty("seeders", out var seedElem) ? seedElem.GetInt32() : 0; - var leechers = item.TryGetProperty("leechers", out var leechElem) ? leechElem.GetInt32() : 0; - string dlHash = string.Empty; - if (item.TryGetProperty("dl", out var dlElem)) - { - dlHash = dlElem.ValueKind == JsonValueKind.String ? dlElem.GetString() ?? string.Empty : dlElem.ToString(); - } - - // New: explicit downloadUrl / infoUrl / fileName fields commonly provided by Prowlarr - string? downloadUrlField = null; - string? infoUrlField = null; - string? fileNameField = null; - // Use case-insensitive property lookup for robustness against differing casing in tracker responses - foreach (var prop in item.EnumerateObject()) - { - var name = prop.Name; - if (downloadUrlField == null && string.Equals(name, "downloadUrl", StringComparison.OrdinalIgnoreCase) && prop.Value.ValueKind == JsonValueKind.String) - downloadUrlField = prop.Value.GetString(); - if (infoUrlField == null && string.Equals(name, "infoUrl", StringComparison.OrdinalIgnoreCase) && prop.Value.ValueKind == JsonValueKind.String) - infoUrlField = prop.Value.GetString(); - if (fileNameField == null && string.Equals(name, "fileName", StringComparison.OrdinalIgnoreCase) && prop.Value.ValueKind == JsonValueKind.String) - fileNameField = prop.Value.GetString(); - } - - string category = string.Empty; - if (item.TryGetProperty("catname", out var catElem)) - { - category = catElem.ValueKind == JsonValueKind.String ? catElem.GetString() ?? string.Empty : catElem.ToString(); - } - - string tags = string.Empty; - if (item.TryGetProperty("tags", out var tagsElem)) - { - tags = tagsElem.ValueKind == JsonValueKind.String ? tagsElem.GetString() ?? string.Empty : tagsElem.ToString(); - } - - string description = string.Empty; - if (item.TryGetProperty("description", out var descElem)) - { - description = descElem.ValueKind == JsonValueKind.String ? descElem.GetString() ?? string.Empty : descElem.ToString(); - } - - // Parse grabs/files when present (Prowlarr exposes these directly for MyAnonamouse) - var grabs = 0; - var grabKeys = new[] { "grabs", "snatches", "snatched", "snatched_count", "snatches_count", "numgrabs", "num_grabs", "grab_count", "times_completed", "completed", "downloaded", "times_downloaded" }; - foreach (var prop in item.EnumerateObject().Where(prop => grabKeys.Any(k => string.Equals(k, prop.Name, StringComparison.OrdinalIgnoreCase)))) - { - var ge = prop.Value; - _logger.LogInformation("Found grabs candidate field '{Field}' (kind={Kind}) for '{Title}': {Value}", prop.Name, ge.ValueKind, ge.ToString(), title); - if (ge.ValueKind == JsonValueKind.Number) - { - grabs = ge.GetInt32(); - _logger.LogInformation("Parsed grabs for '{Title}' from field '{Field}': {Grabs}", title, prop.Name, grabs); - break; - } - else if (ge.ValueKind == JsonValueKind.String && int.TryParse(ge.GetString(), out var gtmp)) - { - grabs = gtmp; - _logger.LogInformation("Parsed grabs (string) for '{Title}' from field '{Field}': {Grabs}", title, prop.Name, grabs); - break; - } - } - - var files = 0; - foreach (var prop in item.EnumerateObject().Where(prop => - string.Equals(prop.Name, "files", StringComparison.OrdinalIgnoreCase) || - string.Equals(prop.Name, "numfiles", StringComparison.OrdinalIgnoreCase) || - string.Equals(prop.Name, "num_files", StringComparison.OrdinalIgnoreCase))) - { - var fe = prop.Value; - _logger.LogInformation("Found files candidate field '{Field}' (kind={Kind}) for '{Title}': {Value}", prop.Name, fe.ValueKind, fe.ToString(), title); - if (fe.ValueKind == JsonValueKind.Number) - { - files = fe.GetInt32(); - _logger.LogInformation("Parsed files for '{Title}' from field '{Field}': {Files}", title, prop.Name, files); - } - else if (fe.ValueKind == JsonValueKind.String && int.TryParse(fe.GetString(), out var ftmp)) - { - files = ftmp; - _logger.LogInformation("Parsed files (string) for '{Title}' from field '{Field}': {Files}", title, prop.Name, files); - } - - break; - } - - // Prefer explicit 'added' timestamp when present (MyAnonamouse uses "yyyy-MM-dd HH:mm:ss") - DateTime? publishDate = null; - if (item.TryGetProperty("added", out var addedElem) && addedElem.ValueKind == JsonValueKind.String) - { - var addedStr = addedElem.GetString(); - if (!string.IsNullOrWhiteSpace(addedStr)) - { - try - { - publishDate = DateTime.ParseExact(addedStr, "yyyy-MM-dd HH:mm:ss", System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.AssumeUniversal).ToLocalTime(); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - // ignore and fallback to other fields below - System.Diagnostics.Debug.WriteLine("Suppressed non-fatal exception in catch block."); - } - } - } - - // Parse publish date when present; fallback to 'age' if necessary - if (!publishDate.HasValue) - { - string? publishDateStr = null; - if (item.TryGetProperty("publishDate", out var pdElem) && pdElem.ValueKind == JsonValueKind.String) - publishDateStr = pdElem.GetString(); - else if (item.TryGetProperty("publish_date", out var pd2) && pd2.ValueKind == JsonValueKind.String) - publishDateStr = pd2.GetString(); - else if (item.TryGetProperty("publishdate", out var pd3) && pd3.ValueKind == JsonValueKind.String) - publishDateStr = pd3.GetString(); - - if (!string.IsNullOrWhiteSpace(publishDateStr)) - { - if (System.DateTimeOffset.TryParse(publishDateStr, out var dto)) - { - publishDate = dto.UtcDateTime; - } - else if (DateTime.TryParse(publishDateStr, out var pdv)) - { - publishDate = DateTime.SpecifyKind(pdv, DateTimeKind.Utc); - } - } - else - { - // Support multiple representations of "age": days, hours, minutes, or alternate keys (ageHours, ageMinutes) - int? days = null; - double? hours = null; - double? minutes = null; - - // Prefer explicit ageHours/ageMinutes if present - if (item.TryGetProperty("ageHours", out var ah) && (ah.ValueKind == JsonValueKind.Number || ah.ValueKind == JsonValueKind.String)) - { - if (ah.ValueKind == JsonValueKind.Number) hours = ah.GetDouble(); - else if (double.TryParse(ah.GetString(), out var htmp)) hours = htmp; - } - if (item.TryGetProperty("ageMinutes", out var am) && (am.ValueKind == JsonValueKind.Number || am.ValueKind == JsonValueKind.String)) - { - if (am.ValueKind == JsonValueKind.Number) minutes = am.GetDouble(); - else if (double.TryParse(am.GetString(), out var mtmp)) minutes = mtmp; - } - - // Fallback to 'age' if present. Heuristic: small values (<=48) likely hours; otherwise treat as days. - if ((hours == null && minutes == null) && item.TryGetProperty("age", out var ageElem)) - { - if (ageElem.ValueKind == JsonValueKind.Number) - { - var a = ageElem.GetDouble(); - if (a <= 48) hours = a; - else days = (int)Math.Floor(a); - } - else if (ageElem.ValueKind == JsonValueKind.String && double.TryParse(ageElem.GetString(), out var adtmp)) - { - var a = adtmp; - if (a <= 48) hours = a; - else days = (int)Math.Floor(a); - } - } - - if (minutes.HasValue && minutes.Value > 0) - publishDate = DateTime.UtcNow.AddMinutes(-minutes.Value); - else if (hours.HasValue && hours.Value > 0) - publishDate = DateTime.UtcNow.AddHours(-hours.Value); - else if (days.HasValue && days.Value > 0) - publishDate = DateTime.UtcNow.AddDays(-days.Value); - } - } - - if (string.IsNullOrEmpty(title)) - continue; - - // (debug log moved later after we build the result so all fields exist) - - // Parse size - handle various formats - long size = 0; - if (!string.IsNullOrEmpty(sizeStr) && sizeStr != "0") - { - size = ParseSizeString(sizeStr); - _logger.LogDebug("Parsed size for MyAnonamouse result '{Title}': {Size} bytes from size field '{SizeStr}'", title, size, sizeStr); - } - else - { - // Try to extract size from description when size field is 0 - size = ExtractSizeFromMyAnonamouseDescription(description); - if (size > 0) - { - _logger.LogDebug("Parsed size for MyAnonamouse result '{Title}': {Size} bytes from description", title, size); - } - else - { - _logger.LogWarning("MyAnonamouse result '{Title}' has no size information in size field or description", title); - } - } - - // Extract author from author_info JSON - string? author = null; - if (item.TryGetProperty("author_info", out var authorInfo)) - { - var authorJson = authorInfo.GetString(); - if (!string.IsNullOrEmpty(authorJson)) - { - try - { - var authorDoc = JsonDocument.Parse(authorJson); - var authors = new List(); - foreach (var prop in authorDoc.RootElement.EnumerateObject()) - { - authors.Add(prop.Value.GetString() ?? ""); - } - author = string.Join(", ", authors.Where(a => !string.IsNullOrEmpty(a))); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Failed to parse author JSON for search result"); - } - } - } - - // Extract narrator from narrator_info JSON - string? narrator = null; - if (item.TryGetProperty("narrator_info", out var narratorInfo)) - { - var narratorJson = narratorInfo.GetString(); - if (!string.IsNullOrEmpty(narratorJson)) - { - try - { - var narratorDoc = JsonDocument.Parse(narratorJson); - var narrators = new List(); - foreach (var prop in narratorDoc.RootElement.EnumerateObject()) - { - narrators.Add(prop.Value.GetString() ?? ""); - } - narrator = string.Join(", ", narrators.Where(n => !string.IsNullOrEmpty(n))); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Failed to parse narrator JSON for search result"); - } - } - } - - // Detect quality and format with robust fallbacks: - // 1) Prefer explicit format/filetype fields when present - // 2) Use tags when available - // 3) Fallback to description and title (filename) parsing - - // Try to read explicit format/filetype fields from the item (case-insensitive) - var rawFormatField = item.EnumerateObject() - .Where(prop => prop.Value.ValueKind == JsonValueKind.String && - (string.Equals(prop.Name, "format", StringComparison.OrdinalIgnoreCase) || - string.Equals(prop.Name, "filetype", StringComparison.OrdinalIgnoreCase))) - .Select(prop => prop.Value.GetString() ?? string.Empty) - .FirstOrDefault() ?? string.Empty; - - // Detect format from tags and from explicit field - var formatFromTags = SearchResultAttributeParser.DetectFormatFromTags(tags ?? ""); - var formatFromField = !string.IsNullOrEmpty(rawFormatField) ? SearchResultAttributeParser.DetectFormatFromTags(rawFormatField) : null; - var finalFormat = (formatFromField != null && formatFromField != "MP3") ? formatFromField : formatFromTags; - - // Log explicit filetype when present - if (!string.IsNullOrEmpty(rawFormatField)) - { - _logger.LogDebug("MyAnonamouse: found explicit filetype '{Filetype}' for item {Id}", rawFormatField, id); - } - - // Detect quality: prefer tags, then explicit format field, then description/title - var qualityFromTags = SearchResultAttributeParser.DetectQualityFromTags(tags ?? ""); - var finalQuality = qualityFromTags != "Unknown" ? qualityFromTags : (!string.IsNullOrEmpty(rawFormatField) ? SearchResultAttributeParser.DetectQualityFromFormat(rawFormatField) : "Unknown"); - - // Fallback: try to detect quality from description or title (filename-like text) - if (finalQuality == "Unknown") - { - if (!string.IsNullOrEmpty(description)) - { - var q = SearchResultAttributeParser.DetectQualityFromTags(description); - if (q != "Unknown") finalQuality = q; - else - { - var q2 = SearchResultAttributeParser.DetectQualityFromFormat(description); - if (q2 != "Unknown") finalQuality = q2; - } - } - - if (finalQuality == "Unknown") - { - var probeText = title; - var q = SearchResultAttributeParser.DetectQualityFromTags(probeText); - if (q != "Unknown") finalQuality = q; - else - { - var q2 = SearchResultAttributeParser.DetectQualityFromFormat(probeText); - if (q2 != "Unknown") finalQuality = q2; - } - } - } - - // Additional fallback: if format still looks generic MP3, probe description/title - if (finalFormat == "MP3") - { - if (!string.IsNullOrEmpty(description)) - { - var f = SearchResultAttributeParser.DetectFormatFromTags(description); - if (!string.IsNullOrEmpty(f) && f != "MP3") finalFormat = f; - } - - if (finalFormat == "MP3") - { - var probeText = title; - var f = SearchResultAttributeParser.DetectFormatFromTags(probeText); - if (!string.IsNullOrEmpty(f) && f != "MP3") finalFormat = f; - } - } - - // Build download URL (include mam_id if configured) - var downloadUrl = ""; - if (!string.IsNullOrEmpty(dlHash)) - { - var baseUrl = (indexer.Url ?? "https://www.myanonamouse.net").TrimEnd('/'); - downloadUrl = $"{baseUrl}/tor/download.php/{dlHash}"; - var mamIdLocal = MyAnonamouseHelper.TryGetMamId(indexer.AdditionalSettings); - if (!string.IsNullOrEmpty(mamIdLocal)) - { - // Normalize mam_id: if the stored value is already percent-encoded, unescape it first - // to avoid double-encoding sequences like "%252B". Then escape once for safe query use. - try - { - mamIdLocal = Uri.UnescapeDataString(mamIdLocal); - } - catch (Exception caughtEx_19) when (caughtEx_19 is not OperationCanceledException && caughtEx_19 is not OutOfMemoryException && caughtEx_19 is not StackOverflowException) - { - // If unescape fails for any reason, fall back to original value - System.Diagnostics.Debug.WriteLine("Suppressed non-fatal exception in catch block."); - } - - downloadUrl += $"?mam_id={Uri.EscapeDataString(mamIdLocal)}"; - } - } - - // Preserve raw language code for later flagging/flags list - string rawLangCode = string.Empty; - _logger.LogDebug("MyAnonamouse: rawFormat='{Raw}', finalFormat='{Final}', rawLang='{LangCode}'", rawFormatField, finalFormat, rawLangCode); - - var result = new IndexerSearchResult - { - Id = id ?? Guid.NewGuid().ToString(), - Title = title, - Artist = author ?? "Unknown Author", - Album = narrator != null ? $"Narrated by {narrator}" : "Unknown", - Category = category ?? "Audiobook", - Size = size, - Seeders = seeders, - Leechers = leechers, - Source = indexer.Name ?? "MyAnonamouse", - PublishedDate = publishDate?.ToString("o") ?? string.Empty, - Quality = finalQuality, - Format = finalFormat, - TorrentUrl = downloadUrl, - // Use MyAnonamouse public item page pattern: https://myanonamouse.net/t/{id} - ResultUrl = !string.IsNullOrEmpty(id) ? $"https://myanonamouse.net/t/{Uri.EscapeDataString(id)}" : (indexer.Url ?? ""), - MagnetLink = "", - NzbUrl = "" - }; - // If we have a parsed language code, map to name and preserve raw code - if (!string.IsNullOrEmpty(rawLangCode) && string.IsNullOrEmpty(result.Language)) - { - result.Language = SearchResultAttributeParser.ParseLanguageFromCode(rawLangCode) ?? SearchResultAttributeParser.ParseLanguageFromText(rawLangCode); - } - result.IndexerId = indexer.Id; - result.IndexerImplementation = indexer.Implementation ?? string.Empty; - // Robust link detection: prefer magnet/hash/torrent indicators, only treat as NZB when explicit NZB fields exist - try - { - string magnetLink = ""; - // Common magnet field names - if (item.TryGetProperty("magnet", out var magnetElem) && magnetElem.ValueKind == JsonValueKind.String) - magnetLink = magnetElem.GetString() ?? ""; - else if (item.TryGetProperty("magnetLink", out magnetElem) && magnetElem.ValueKind == JsonValueKind.String) - magnetLink = magnetElem.GetString() ?? ""; - else if (item.TryGetProperty("magnetlink", out magnetElem) && magnetElem.ValueKind == JsonValueKind.String) - magnetLink = magnetElem.GetString() ?? ""; - - // If we have a torrent hash, construct a magnet link - if (string.IsNullOrEmpty(magnetLink) && item.TryGetProperty("hash", out var hashElem) && hashElem.ValueKind == JsonValueKind.String) - { - var h = hashElem.GetString(); - if (!string.IsNullOrWhiteSpace(h)) - { - magnetLink = $"magnet:?xt=urn:btih:{h}&dn={Uri.EscapeDataString(title)}"; - } - } - - // Detect torrent download URL from other common fields - string[] torrentFields = new[] { "download", "dlLink", "downloadlink", "download_url", "torrent", "torrent_url", "torrentUrl", "torrentlink" }; - var torrentUrlDetected = result.TorrentUrl - ?? torrentFields - .Select(tf => item.TryGetProperty(tf, out var tfElem) && tfElem.ValueKind == JsonValueKind.String - ? tfElem.GetString() - : null) - .FirstOrDefault(url => !string.IsNullOrEmpty(url)) - ?? string.Empty; - - // If any URL looks like a .torrent file, prefer it as torrent URL - if (string.IsNullOrEmpty(torrentUrlDetected)) - { - foreach (var v in item.EnumerateObject() - .Where(prop => prop.Value.ValueKind == JsonValueKind.String) - .Select(prop => prop.Value.GetString()) - .Where(v => !string.IsNullOrEmpty(v) && v.EndsWith(".torrent", StringComparison.OrdinalIgnoreCase))) - { - torrentUrlDetected = v!; - break; - } - } - - // Detect NZB fields (only treat as NZB when explicit) - string nzbUrlDetected = string.Empty; - if (item.TryGetProperty("nzb", out var nzbElem) && nzbElem.ValueKind == JsonValueKind.String) - nzbUrlDetected = nzbElem.GetString() ?? string.Empty; - else if (item.TryGetProperty("nzbLink", out nzbElem) && nzbElem.ValueKind == JsonValueKind.String) - nzbUrlDetected = nzbElem.GetString() ?? string.Empty; - else if (item.TryGetProperty("nzburl", out nzbElem) && nzbElem.ValueKind == JsonValueKind.String) - nzbUrlDetected = nzbElem.GetString() ?? string.Empty; - - // Apply discovered links to the result - if (!string.IsNullOrEmpty(magnetLink)) result.MagnetLink = magnetLink; - if (!string.IsNullOrEmpty(torrentUrlDetected)) result.TorrentUrl = torrentUrlDetected; - if (!string.IsNullOrEmpty(nzbUrlDetected)) result.NzbUrl = nzbUrlDetected; - - // If a direct downloadUrl was provided by the API, prefer that as the torrent/nzb URL - if (!string.IsNullOrEmpty(downloadUrlField)) - { - // Choose disposition based on common hints and protocol - if (downloadUrlField.EndsWith(".torrent", StringComparison.OrdinalIgnoreCase) || (item.TryGetProperty("protocol", out var protoElem) && protoElem.ValueKind == JsonValueKind.String && protoElem.GetString()?.Equals("torrent", StringComparison.OrdinalIgnoreCase) == true)) - { - result.TorrentUrl = downloadUrlField; - } - else if (downloadUrlField.EndsWith(".nzb", StringComparison.OrdinalIgnoreCase) || (item.TryGetProperty("protocol", out var proto2Elem) && proto2Elem.ValueKind == JsonValueKind.String && proto2Elem.GetString()?.Equals("usenet", StringComparison.OrdinalIgnoreCase) == true)) - { - result.NzbUrl = downloadUrlField; - } - else - { - // Unknown, prefer TorrentUrl by default - result.TorrentUrl = downloadUrlField; - } - } - - // If guid is present and looks like a URL, prefer it as the canonical link - if (item.TryGetProperty("guid", out var guidElem) && guidElem.ValueKind == JsonValueKind.String && Uri.IsWellFormedUriString(guidElem.GetString(), UriKind.Absolute)) - { - result.ResultUrl = guidElem.GetString(); - } - - // If infoUrl is present, use it as the canonical page link when available - if (!string.IsNullOrEmpty(infoUrlField)) - { - result.ResultUrl = infoUrlField; - } - - // Use filename field to populate TorrentFileName when available - if (!string.IsNullOrEmpty(fileNameField)) - { - result.TorrentFileName = fileNameField; - } - - // Prefer marking the download type when either magnet/torrent or NZB URL exists - if (!string.IsNullOrEmpty(result.MagnetLink) || !string.IsNullOrEmpty(result.TorrentUrl)) - result.DownloadType = "Torrent"; - else if (!string.IsNullOrEmpty(result.NzbUrl)) - result.DownloadType = "nzb"; - - _logger.LogDebug("MyAnonamouse parsed item #{Index} link-disposition: magnet={MagnetPresent}, torrent={TorrentPresent}, nzb={NzbPresent}", _mamDebugIndex, !string.IsNullOrEmpty(result.MagnetLink), !string.IsNullOrEmpty(result.TorrentUrl), !string.IsNullOrEmpty(result.NzbUrl)); - } - catch (Exception exLink) when (exLink is not OperationCanceledException && exLink is not OutOfMemoryException && exLink is not StackOverflowException) - { - _logger.LogDebug(exLink, "Failed to detect links for MyAnonamouse item {Id}", id); - } - - // Prefer explicit language fields when present (lang_code, language_code, lang, language) - case-insensitive search - string explicitLang = string.Empty; - foreach (var prop in item.EnumerateObject().Where(prop => - (prop.Name.Equals("lang_code", StringComparison.OrdinalIgnoreCase) || - prop.Name.Equals("language_code", StringComparison.OrdinalIgnoreCase) || - prop.Name.Equals("lang", StringComparison.OrdinalIgnoreCase) || - prop.Name.Equals("language", StringComparison.OrdinalIgnoreCase)) && - prop.Value.ValueKind == JsonValueKind.String)) - { - explicitLang = prop.Value.GetString() ?? string.Empty; - _logger.LogDebug("MyAnonamouse: found language field '{Field}'='{Lang}' for item {Id}", prop.Name, explicitLang, id); - break; - } - - // Numeric language id fallback (case-insensitive check) - if (string.IsNullOrEmpty(explicitLang) && item.TryGetProperty("language", out var langNumElem) && langNumElem.ValueKind == JsonValueKind.Number) - { - var numeric = langNumElem.GetInt32(); - if (numeric == 1) { explicitLang = "ENG"; } - _logger.LogDebug("MyAnonamouse: found numeric language id={Num} mapped to '{Lang}' for item {Id}", numeric, explicitLang, id); - } - - if (!string.IsNullOrWhiteSpace(explicitLang)) - { - // Prefer direct code mapping (e.g., ENG -> English) when a short code is provided - var parsedLang = SearchResultAttributeParser.ParseLanguageFromCode(explicitLang) ?? SearchResultAttributeParser.ParseLanguageFromText(explicitLang); - if (!string.IsNullOrWhiteSpace(parsedLang)) - { - result.Language = parsedLang; - } - } - - // Fallback: parse title, tags and description for language codes (e.g. '[ENG / M4B]') - if (string.IsNullOrWhiteSpace(result.Language)) - { - var probe = string.Join(" ", new[] { title, tags ?? string.Empty, description ?? string.Empty }).Trim(); - var detectedLang = SearchResultAttributeParser.ParseLanguageFromText(probe); - if (!string.IsNullOrEmpty(detectedLang)) - { - result.Language = detectedLang; - } - } - - // Apply grabs/files to the result when available - result.Grabs = grabs; - result.Files = files; - - try - { - if (_mamDebugIndex < 5) - { - _logger.LogDebug("ParseMyAnonamouse: constructed SearchResult #{Index} -> Id='{Id}', Title='{Title}', Size={Size}, Seeders={Seeders}, TorrentUrl='{TorrentUrl}', Artist='{Artist}', Album='{Album}', Category='{Category}', Source='{Source}', Grabs={Grabs}, Files={Files}, PublishedDate={PublishedDate}'", - _mamDebugIndex, result.Id, result.Title, result.Size, result.Seeders, result.TorrentUrl ?? "", result.Artist ?? "", result.Album ?? "", result.Category ?? "", result.Source ?? "", result.Grabs, result.Files, result.PublishedDate); - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogDebug(ex, "Failed to write debug log for constructed MyAnonamouse SearchResult"); - } - - _mamDebugIndex++; - // Final best-effort: if title lacks bracketed flags but we have a TorrentFileName with them, append the filename's suffix - if (!string.IsNullOrEmpty(result.TorrentFileName) && !System.Text.RegularExpressions.Regex.IsMatch(result.Title ?? string.Empty, "\\[.*\\]$")) - { - try - { - var fname = result.TorrentFileName; - var dotIdx2 = fname.LastIndexOf('.'); - var nameOnly2 = dotIdx2 > 0 ? fname.Substring(0, dotIdx2) : fname; - var bracketStart2 = nameOnly2.IndexOf(" ["); - if (bracketStart2 >= 0) - { - var suffix2 = nameOnly2.Substring(bracketStart2); - if (!(result.Title ?? string.Empty).Contains(suffix2)) - { - result.Title = (result.Title ?? string.Empty) + suffix2; - } - } - } - catch (Exception ex2) when (ex2 is not OperationCanceledException && ex2 is not OutOfMemoryException && ex2 is not StackOverflowException) - { - _logger.LogDebug(ex2, "Failed to append filename flags to title for MyAnonamouse item {Id}", id); - } - } - - - results.Add(result); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Failed to parse MyAnonamouse result item"); - } - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogError(ex, "Failed to parse MyAnonamouse response"); - } - - return results; - } - - // Recursively search a JsonElement for a mam_id-like property (case-insensitive) - private string? FindMamIdInJson(JsonElement element) - { - // Keys to look for - var keys = new HashSet(StringComparer.OrdinalIgnoreCase) { "mam_id", "mamid", "mamId", "mamID", "mam" }; - - if (element.ValueKind == JsonValueKind.Object) - { - foreach (var prop in element.EnumerateObject()) - { - try - { - if (keys.Contains(prop.Name) && prop.Value.ValueKind == JsonValueKind.String) - return prop.Value.GetString(); - - // Recurse into objects and arrays - if (prop.Value.ValueKind == JsonValueKind.Object || prop.Value.ValueKind == JsonValueKind.Array) - { - var found = FindMamIdInJson(prop.Value); - if (!string.IsNullOrEmpty(found)) return found; - } - } - catch (Exception caughtEx_20) when (caughtEx_20 is not OperationCanceledException && caughtEx_20 is not OutOfMemoryException && caughtEx_20 is not StackOverflowException) - { /* ignore malformed inner values */ - System.Diagnostics.Debug.WriteLine("Suppressed non-fatal exception in catch block."); - } - } - } - else if (element.ValueKind == JsonValueKind.Array) - { - var found = element.EnumerateArray() - .Select(FindMamIdInJson) - .FirstOrDefault(value => !string.IsNullOrEmpty(value)); - if (!string.IsNullOrEmpty(found)) return found; - } - - return null; - } // Optional enrichment step: fetch individual item pages to populate missing grabs/files/format/language private async Task EnrichMyAnonamouseResultsAsync(Indexer indexer, List results, int topN, string? mamId, HttpClient httpClient) @@ -3683,65 +2888,6 @@ private List GenerateMockResults(string query, string source) }; } - private long ExtractSizeFromMyAnonamouseDescription(string? description) - { - if (string.IsNullOrEmpty(description)) - return 0; - - // Look for patterns like "Total Size : 259MB (272 033 986 bytes)" - var match = Regex.Match(description, @"Total Size\s*:\s*([\d\.,]+)\s*(MB|GB|KB|B)\s*\(([\d\s,]+)\s*bytes?\)", RegexOptions.IgnoreCase); - if (match.Success) - { - // Try to parse the bytes value first (most accurate) - var bytesStr = match.Groups[3].Value.Replace(",", "").Replace(" ", ""); - if (long.TryParse(bytesStr, out var bytes)) - { - _logger.LogDebug("Extracted size from MyAnonamouse description bytes: {Bytes}", bytes); - return bytes; - } - - // Fallback to parsing the formatted size - var sizeValue = match.Groups[1].Value.Replace(",", ""); - var unit = match.Groups[2].Value.ToUpper(); - if (double.TryParse(sizeValue, out var value)) - { - var result = unit switch - { - "B" => (long)value, - "KB" => (long)(value * 1024), - "MB" => (long)(value * 1024 * 1024), - "GB" => (long)(value * 1024 * 1024 * 1024), - _ => (long)value - }; - _logger.LogDebug("Extracted size from MyAnonamouse description formatted: {Value} {Unit} = {Result} bytes", value, unit, result); - return result; - } - } - - // Alternative pattern: just "Total Size : 259MB" without bytes - match = Regex.Match(description, @"Total Size\s*:\s*([\d\.,]+)\s*(MB|GB|KB|B)", RegexOptions.IgnoreCase); - if (match.Success) - { - var sizeValue = match.Groups[1].Value.Replace(",", ""); - var unit = match.Groups[2].Value.ToUpper(); - if (double.TryParse(sizeValue, out var value)) - { - var result = unit switch - { - "B" => (long)value, - "KB" => (long)(value * 1024), - "MB" => (long)(value * 1024 * 1024), - "GB" => (long)(value * 1024 * 1024 * 1024), - _ => (long)value - }; - _logger.LogDebug("Extracted size from MyAnonamouse description (no bytes): {Value} {Unit} = {Result} bytes", value, unit, result); - return result; - } - } - - _logger.LogDebug("No size found in MyAnonamouse description"); - return 0; - } private long ParseSizeString(string sizeStr) { diff --git a/tests/Features/Api/Services/Search/Providers/IndexersNewznabParsingTests.cs b/tests/Features/Api/Services/Search/Providers/IndexersNewznabParsingTests.cs index e7eb26712..cf8ac9765 100644 --- a/tests/Features/Api/Services/Search/Providers/IndexersNewznabParsingTests.cs +++ b/tests/Features/Api/Services/Search/Providers/IndexersNewznabParsingTests.cs @@ -400,12 +400,7 @@ public void ParseMyAnonamouse_Parses_Prowlarr_Shape() ]"; var indexer = new Indexer { Name = "MyAnonamouse", Url = "https://www.myanonamouse.net", Type = "Torrent", Implementation = "MyAnonamouse" }; - var service = CreateSearchService(); - - // Use reflection to call the private parser - var method = typeof(SearchService).GetMethod("ParseMyAnonamouseResponse", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - Assert.NotNull(method); - var results = (List)method!.Invoke(service, new object[] { json, indexer }); + var results = MyAnonamouseResponseParser.Parse(json, indexer, NullLogger.Instance); Assert.Single(results); var r = results[0]; @@ -432,11 +427,7 @@ public void ParseMyAnonamouse_Appends_MamId_To_DownloadUrl() } ]"; var indexer = new Indexer { Name = "MyAnonamouse", Url = "https://www.myanonamouse.net", Type = "Torrent", Implementation = "MyAnonamouse", AdditionalSettings = "{ \"mam_id\": \"test_mam\" }" }; - var service = CreateSearchService(); - - var method = typeof(SearchService).GetMethod("ParseMyAnonamouseResponse", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - Assert.NotNull(method); - var results = (List)method!.Invoke(service, new object[] { json, indexer }); + var results = MyAnonamouseResponseParser.Parse(json, indexer, NullLogger.Instance); Assert.Single(results); var r = results[0]; @@ -456,16 +447,13 @@ public void ParseMyAnonamouse_Normalizes_And_Encodes_MamId_Once() // Case A: raw mam_id with + and = characters var indexerRaw = new Indexer { Name = "MyAnonamouse", Url = "https://www.myanonamouse.net", Type = "Torrent", Implementation = "MyAnonamouse", AdditionalSettings = "{ \"mam_id\": \"abc+def==\" }" }; - var method = typeof(SearchService).GetMethod("ParseMyAnonamouseResponse", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - Assert.NotNull(method); - var service = CreateSearchService(); - var resRaw = (List)method!.Invoke(service, new object[] { json, indexerRaw }); + var resRaw = MyAnonamouseResponseParser.Parse(json, indexerRaw, NullLogger.Instance); Assert.Single(resRaw); Assert.Equal("https://www.myanonamouse.net/tor/download.php/abc123?mam_id=abc%2Bdef%3D%3D", resRaw[0].TorrentUrl); // Case B: mam_id already percent-encoded (should not double-encode) var indexerEnc = new Indexer { Name = "MyAnonamouse", Url = "https://www.myanonamouse.net", Type = "Torrent", Implementation = "MyAnonamouse", AdditionalSettings = "{ \"mam_id\": \"abc%2Bdef%3D%3D\" }" }; - var resEnc = (List)method!.Invoke(service, new object[] { json, indexerEnc })!; + var resEnc = MyAnonamouseResponseParser.Parse(json, indexerEnc, NullLogger.Instance); Assert.Single(resEnc); Assert.Equal("https://www.myanonamouse.net/tor/download.php/abc123?mam_id=abc%2Bdef%3D%3D", resEnc[0].TorrentUrl); } @@ -487,12 +475,7 @@ public void ParseMyAnonamouse_Parses_Age_And_Grabs_From_String_Fields() ]"; var indexer = new Indexer { Name = "MyAnonamouse", Url = "https://www.myanonamouse.net", Type = "Torrent", Implementation = "MyAnonamouse" }; - var service = CreateSearchService(); - - // Use reflection to call the private parser - var method = typeof(SearchService).GetMethod("ParseMyAnonamouseResponse", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - Assert.NotNull(method); - var results = (List)method!.Invoke(service, new object[] { json, indexer }); + var results = MyAnonamouseResponseParser.Parse(json, indexer, NullLogger.Instance); Assert.Single(results); var r = results[0]; @@ -515,11 +498,7 @@ public void ParseMyAnonamouse_Parses_Age_As_Hours_When_Small() ]"; var indexer = new Indexer { Name = "MyAnonamouse", Url = "https://www.myanonamouse.net", Type = "Torrent", Implementation = "MyAnonamouse" }; - var service = CreateSearchService(); - - var method = typeof(SearchService).GetMethod("ParseMyAnonamouseResponse", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - Assert.NotNull(method); - var results = (List)method!.Invoke(service, new object[] { json, indexer }); + var results = MyAnonamouseResponseParser.Parse(json, indexer, NullLogger.Instance); Assert.Single(results); var r = results[0]; @@ -540,11 +519,7 @@ public void ParseMyAnonamouse_Parses_Snatched_Alternate_Keys() ]"; var indexer = new Indexer { Name = "MyAnonamouse", Url = "https://www.myanonamouse.net", Type = "Torrent", Implementation = "MyAnonamouse" }; - var service = CreateSearchService(); - - var method = typeof(SearchService).GetMethod("ParseMyAnonamouseResponse", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - Assert.NotNull(method); - var results = (List)method!.Invoke(service, new object[] { json, indexer }); + var results = MyAnonamouseResponseParser.Parse(json, indexer, NullLogger.Instance); Assert.Single(results); var r = results[0]; @@ -562,11 +537,7 @@ public void ParseMyAnonamouse_Parses_Added_Field_As_PublishDate() ]"; var indexer = new Indexer { Name = "MyAnonamouse", Url = "https://www.myanonamouse.net", Type = "Torrent", Implementation = "MyAnonamouse" }; - var service = CreateSearchService(); - - var method = typeof(SearchService).GetMethod("ParseMyAnonamouseResponse", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - Assert.NotNull(method); - var results = (List)method!.Invoke(service, new object[] { json, indexer }); + var results = MyAnonamouseResponseParser.Parse(json, indexer, NullLogger.Instance); Assert.Single(results); var r = results[0]; @@ -588,11 +559,7 @@ public void ParseMyAnonamouse_Appends_Flags_And_Vip_When_Fields_Present() ]"; var indexer = new Indexer { Name = "MyAnonamouse", Url = "https://www.myanonamouse.net", Type = "Torrent", Implementation = "MyAnonamouse" }; - var service = CreateSearchService(); - - var method = typeof(SearchService).GetMethod("ParseMyAnonamouseResponse", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - Assert.NotNull(method); - var results = (List)method!.Invoke(service, new object[] { json, indexer }); + var results = MyAnonamouseResponseParser.Parse(json, indexer, NullLogger.Instance); Assert.Single(results); var r = results[0]; @@ -617,12 +584,7 @@ public void ParseMyAnonamouse_Exposes_Filetype_And_Lang_In_DTO() ]"; var indexer = new Indexer { Name = "MyAnonamouse", Url = "https://www.myanonamouse.net", Type = "Torrent", Implementation = "MyAnonamouse" }; - var service = CreateSearchService(); - - // Use reflection to call the private parser - var method = typeof(SearchService).GetMethod("ParseMyAnonamouseResponse", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - Assert.NotNull(method); - var results = (List)method!.Invoke(service, new object[] { json, indexer }); + var results = MyAnonamouseResponseParser.Parse(json, indexer, NullLogger.Instance); Assert.Single(results); var r = results[0]; @@ -646,11 +608,7 @@ public void ParseMyAnonamouse_Preserves_Filetype_When_Torrent_Urls_Present() ]"; var indexer = new Indexer { Name = "MyAnonamouse", Url = "https://www.myanonamouse.net", Type = "Torrent", Implementation = "MyAnonamouse" }; - var service = CreateSearchService(); - - var method = typeof(SearchService).GetMethod("ParseMyAnonamouseResponse", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - Assert.NotNull(method); - var results = (List)method!.Invoke(service, new object[] { json, indexer })!; + var results = MyAnonamouseResponseParser.Parse(json, indexer, NullLogger.Instance); Assert.Single(results); var r = results[0]; diff --git a/tests/Features/Api/Services/SearchServiceFixesTests.cs b/tests/Features/Api/Services/SearchServiceFixesTests.cs index 25a3556aa..d1dfa4f83 100644 --- a/tests/Features/Api/Services/SearchServiceFixesTests.cs +++ b/tests/Features/Api/Services/SearchServiceFixesTests.cs @@ -15,61 +15,19 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -using Listenarr.Application.Interfaces.Repositories; using Microsoft.Extensions.Logging.Abstractions; using Listenarr.Application.Search; -using Listenarr.Application.Metadata; -using Listenarr.Application.Search.Strategies; -using Listenarr.Application.Search.Filters; namespace Listenarr.Tests.Features.Api.Services { public class SearchServiceFixesTests { - private static SearchService CreateSearchService() - { - var client = new HttpClient(); - var configuration = Mock.Of(); - var logger = NullLogger.Instance; - var openLibraryService = Mock.Of(); - var imageCache = Mock.Of(); - var audible = new AudibleService(new HttpClient(), NullLogger.Instance); - var converters = new MetadataConverters(imageCache, NullLogger.Instance); - var progress = new SearchProgressReporter(null, NullLogger.Instance); - var pipeline = new SearchResultFilterPipeline(Enumerable.Empty(), NullLogger.Instance); - var coordinator = new MetadataStrategyCoordinator(Enumerable.Empty(), NullLogger.Instance); - var collector = new AsinCandidateCollector(NullLogger.Instance, openLibraryService, converters, progress); - var enricher = new AsinEnricher(NullLogger.Instance, coordinator, converters, pipeline, progress); - var scorer = new SearchResultScorerService(NullLogger.Instance); - var sorting = new SearchResultSortingService(Mock.Of(), NullLogger.Instance); - var handler = new AsinSearchHandler(NullLogger.Instance, configuration, audible, Mock.Of(), converters, progress); - - return new SearchService( - client, - configuration, - logger, - Mock.Of(), - Mock.Of(), - audible, - converters, - progress, - collector, - enricher, - scorer, - sorting, - handler, - Enumerable.Empty()); - } - [Fact] public void ParseMyAnonamouse_With_NoDateOrAge_Sets_Empty_PublishedDate() { var json = "[ { \"guid\": \"https://www.myanonamouse.net/t/100\", \"size\": 12345, \"title\": \"Test Title\" } ]"; var indexer = new Indexer { Name = "MyAnonamouse", Url = "https://www.myanonamouse.net", Type = "Torrent", Implementation = "MyAnonamouse" }; - var service = CreateSearchService(); - - var method = typeof(SearchService).GetMethod("ParseMyAnonamouseResponse", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - var results = (System.Collections.Generic.List)method.Invoke(service, new object[] { json, indexer }); + var results = MyAnonamouseResponseParser.Parse(json, indexer, NullLogger.Instance); Assert.Single(results); var r = results[0]; @@ -81,10 +39,7 @@ public void ParseMyAnonamouse_Always_Sets_Grabs_Even_If_Zero() { var json = "[ { \"guid\": \"https://www.myanonamouse.net/t/101\", \"grabs\": \"0\", \"files\": \"1\", \"title\": \"Test Title 2\" } ]"; var indexer = new Indexer { Name = "MyAnonamouse", Url = "https://www.myanonamouse.net", Type = "Torrent", Implementation = "MyAnonamouse" }; - var service = CreateSearchService(); - - var method = typeof(SearchService).GetMethod("ParseMyAnonamouseResponse", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - var results = (System.Collections.Generic.List)method.Invoke(service, new object[] { json, indexer }); + var results = MyAnonamouseResponseParser.Parse(json, indexer, NullLogger.Instance); Assert.Single(results); var r = results[0]; From d8d4e5dace0f8daba4b35f344678bceb6e616c4b Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Fri, 12 Jun 2026 10:17:04 -0400 Subject: [PATCH 15/84] Extract library path planner --- .../Controllers/LibraryController.cs | 209 +----------------- .../Controllers/LibraryPathPlanner.cs | 208 +++++++++++++++++ .../LibraryController_BasePathTests.cs | 13 +- 3 files changed, 215 insertions(+), 215 deletions(-) create mode 100644 listenarr.api/Controllers/LibraryPathPlanner.cs diff --git a/listenarr.api/Controllers/LibraryController.cs b/listenarr.api/Controllers/LibraryController.cs index eeccd3215..befd3b6e5 100644 --- a/listenarr.api/Controllers/LibraryController.cs +++ b/listenarr.api/Controllers/LibraryController.cs @@ -21,7 +21,6 @@ using Listenarr.Domain.Models; using System.Text.Json; using System.Reflection; -using System.Text.RegularExpressions; using System.Security.Cryptography; using System.Text; using Listenarr.Domain.Common; @@ -547,7 +546,7 @@ public async Task PreviewPath([FromBody] PreviewPathRequest reque var namingPattern = !string.IsNullOrWhiteSpace(settings.FolderNamingPattern) ? settings.FolderNamingPattern : settings.FileNamingPattern; - var full = ComputeAudiobookBaseDirectoryFromPattern(temp, root ?? string.Empty, namingPattern); + var full = LibraryPathPlanner.ComputeAudiobookBaseDirectoryFromPattern(temp, root ?? string.Empty, namingPattern, _fileNamingService); var relative = full; if (!string.IsNullOrEmpty(root) && full.StartsWith(root, StringComparison.OrdinalIgnoreCase)) @@ -1666,7 +1665,7 @@ await _historyRepository.AddAsync(new History var fileNamingPattern = !string.IsNullOrWhiteSpace(settings?.FolderNamingPattern) ? settings!.FolderNamingPattern : settings?.FileNamingPattern ?? string.Empty; - var newBase = ComputeAudiobookBaseDirectoryFromPattern(audiobook, rootPath, fileNamingPattern); + var newBase = LibraryPathPlanner.ComputeAudiobookBaseDirectoryFromPattern(audiobook, rootPath, fileNamingPattern, _fileNamingService); try { @@ -1956,7 +1955,7 @@ outputPathEx is ArgumentException } // Calculate base path for the audiobook files - var basePath = CalculateBasePath(foundFiles); + var basePath = LibraryPathPlanner.CalculateBasePath(foundFiles, _logger); _logger.LogInformation("Calculated base path for audiobook '{Title}': {BasePath}", LogRedaction.SanitizeText(audiobook.Title), LogRedaction.SanitizeFilePath(basePath)); var created = new List(); @@ -2886,208 +2885,6 @@ changeTypeEx is InvalidCastException return value; } - private string ComputeAudiobookBaseDirectoryFromPattern(Audiobook audiobook, string rootPath, string fileNamingPattern) - { - // Derive directory pattern from the user's file naming pattern - // Remove file-specific tokens like DiskNumber and ChapterNumber to create a directory structure - string directoryPattern; - if (!string.IsNullOrWhiteSpace(fileNamingPattern)) - { - // Remove file-specific patterns and create a directory pattern - directoryPattern = fileNamingPattern; - - // Remove file-specific tokens that don't make sense for directories - directoryPattern = Regex.Replace(directoryPattern, @"\{DiskNumber[^}]*\}", "", RegexOptions.IgnoreCase); - directoryPattern = Regex.Replace(directoryPattern, @"\{ChapterNumber[^}]*\}", "", RegexOptions.IgnoreCase); - - // Clean up any resulting double separators or empty parts - directoryPattern = Regex.Replace(directoryPattern, @"[\\/]\s*[\\/]", "/"); - directoryPattern = Regex.Replace(directoryPattern, @"^\s*[\\/]", ""); - directoryPattern = Regex.Replace(directoryPattern, @"[\\/]\s*$", ""); - - // If the pattern is now empty or doesn't contain directory separators, use a fallback - if (string.IsNullOrWhiteSpace(directoryPattern) || !directoryPattern.Contains("/")) - { - directoryPattern = "{Author}/{Title}"; - } - } - else - { - // Fallback to default directory pattern - directoryPattern = "{Author}/{Title}"; - } - - // For series books, ensure we include the series in the directory structure - if (!string.IsNullOrWhiteSpace(audiobook.Series) && !directoryPattern.Contains("{Series}")) - { - // Insert series between author and title if not already present - if (directoryPattern.Contains("{Author}/{Title}")) - { - directoryPattern = directoryPattern.Replace("{Author}/{Title}", "{Author}/{Series}/{Title}"); - } - else if (directoryPattern.Contains("{Author}/")) - { - directoryPattern = directoryPattern.Replace("{Author}/", "{Author}/{Series}/"); - } - } - - // If the audiobook has no Series, remove any {Series} tokens from the directory pattern - // Tests expect the controller to strip the Series token when series metadata is missing. - if (string.IsNullOrWhiteSpace(audiobook.Series)) - { - directoryPattern = Regex.Replace(directoryPattern, @"\{Series[^}]*\}", string.Empty, RegexOptions.IgnoreCase); - // Clean up any resulting duplicate separators or empty parts again - directoryPattern = Regex.Replace(directoryPattern, @"[\\/]\s*[\\/]", "/"); - directoryPattern = Regex.Replace(directoryPattern, @"^\s*[\\/]", ""); - directoryPattern = Regex.Replace(directoryPattern, @"[\\/]\s*$", ""); - } - - // Build variables for naming pattern using audiobook-level metadata - var variables = new Dictionary - { - { "Author", SanitizeDirectoryName(audiobook.Authors?.FirstOrDefault() ?? "Unknown Author") }, - { "Series", SanitizeDirectoryName(!string.IsNullOrWhiteSpace(audiobook.Series) ? audiobook.Series! : string.Empty) }, - { "Title", SanitizeDirectoryName(audiobook.Title ?? "Unknown Title") }, - { "Subtitle", SanitizeDirectoryName(audiobook.Subtitle ?? string.Empty) }, - { "Edition", SanitizeDirectoryName(audiobook.Edition ?? string.Empty) }, - { "Narrator", SanitizeDirectoryName((audiobook.Narrators != null && audiobook.Narrators.Any()) ? string.Join(", ", audiobook.Narrators.Where(n => !string.IsNullOrWhiteSpace(n))) : string.Empty) }, - { "Publisher", SanitizeDirectoryName(audiobook.Publisher ?? string.Empty) }, - { "Language", SanitizeDirectoryName(audiobook.Language ?? string.Empty) }, - { "Asin", SanitizeDirectoryName(audiobook.Asin ?? string.Empty) }, - { "SeriesNumber", audiobook.SeriesNumber ?? string.Empty }, - { "Year", audiobook.PublishYear ?? string.Empty }, - { "Quality", string.Empty }, - { "DiskNumber", string.Empty }, - { "ChapterNumber", string.Empty } - }; - - // Apply the directory pattern to get the relative directory path - var relative = _fileNamingService.ApplyNamingPattern(directoryPattern, variables, false); - - // Combine with root path - var combined = ResolvePathWithOptionalBase(rootPath, relative); - - return combined; - } - - private string CalculateBasePath(List filePaths) - { - if (!filePaths.Any()) - return string.Empty; - - // Convert all paths to directory paths (get parent directory for each file) - var directories = filePaths - .Select(p => FileUtils.NormalizeStoredPath(Path.GetDirectoryName(p) ?? p)) - .Where(p => !string.IsNullOrWhiteSpace(p)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToList(); - - if (directories.Count == 1) - { - // All files are in the same directory - return directories[0]; - } - - // Find the common ancestor directory where there are no longer <=1 things stored - var commonPath = GetCommonPath(directories); - - // Walk up the directory tree until we find a directory that has more than 1 subdirectory or file - var currentPath = commonPath; - while (!string.IsNullOrEmpty(currentPath)) - { - try - { - var parent = Directory.GetParent(currentPath)?.FullName; - if (string.IsNullOrEmpty(parent)) - break; - - // Count subdirectories and files in parent - var subDirs = Directory.GetDirectories(parent).Length; - var files = Directory.GetFiles(parent).Length; - - // If parent has more than 1 thing (subdirs + files), we've found our base path - if (subDirs + files > 1) - { - return currentPath; - } - - currentPath = parent; - } - catch (Exception traversalEx) when ( - traversalEx is IOException - || traversalEx is UnauthorizedAccessException - || traversalEx is System.Security.SecurityException - || traversalEx is ArgumentException - || traversalEx is NotSupportedException) - { - // If we can't access the directory, stop here - _logger.LogDebug(traversalEx, "Stopping common-base-path ascent at {Path} due to traversal error", currentPath); - break; - } - } - - return commonPath; - } - - private string GetCommonPath(List paths) - { - if (!paths.Any()) - return string.Empty; - - var firstPath = FileUtils.NormalizeStoredPath(paths[0]); - var commonPath = firstPath; - - foreach (var path in paths.Skip(1).Select(rawPath => FileUtils.NormalizeStoredPath(rawPath))) - { - var minLength = Math.Min(commonPath.Length, path.Length); - var commonLength = 0; - - for (int i = 0; i < minLength; i++) - { - if (commonPath[i] == path[i]) - commonLength++; - else - break; - } - - // Ensure we don't break in the middle of a directory name - if (commonLength < commonPath.Length) - commonLength = commonPath.LastIndexOf(Path.DirectorySeparatorChar, commonLength - 1) is var lastSep && lastSep >= 0 - ? lastSep + 1 - : 0; - - commonPath = commonPath.Substring(0, commonLength); - - if (string.IsNullOrEmpty(commonPath)) - break; - } - - // Ensure it's a valid directory path - if (!string.IsNullOrEmpty(commonPath) && !Directory.Exists(commonPath)) - { - var parent = Directory.GetParent(commonPath)?.FullName; - return parent ?? commonPath; - } - - return commonPath; - } - - private string SanitizeDirectoryName(string name) - { - // Remove or replace characters that are invalid in directory names - var invalidChars = Path.GetInvalidFileNameChars(); - foreach (var c in invalidChars) - { - name = name.Replace(c, '_'); - } - - // Also replace some additional characters that might cause issues - name = name.Replace(":", "_").Replace("*", "_").Replace("?", "_").Replace("\"", "_").Replace("<", "_").Replace(">", "_").Replace("|", "_"); - - // Trim whitespace and return - return name.Trim(); - } - private static string ComputeShortHash(string? input) { if (string.IsNullOrEmpty(input)) diff --git a/listenarr.api/Controllers/LibraryPathPlanner.cs b/listenarr.api/Controllers/LibraryPathPlanner.cs new file mode 100644 index 000000000..6376b30ea --- /dev/null +++ b/listenarr.api/Controllers/LibraryPathPlanner.cs @@ -0,0 +1,208 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +using System.Text.RegularExpressions; +using Listenarr.Application.Interfaces; +using Listenarr.Domain.Common; +using Listenarr.Domain.Models; + +namespace Listenarr.Api.Controllers +{ + internal static class LibraryPathPlanner + { + public static string ComputeAudiobookBaseDirectoryFromPattern( + Audiobook audiobook, + string rootPath, + string fileNamingPattern, + IFileNamingService fileNamingService) + { + string directoryPattern; + if (!string.IsNullOrWhiteSpace(fileNamingPattern)) + { + directoryPattern = fileNamingPattern; + directoryPattern = Regex.Replace(directoryPattern, @"\{DiskNumber[^}]*\}", "", RegexOptions.IgnoreCase); + directoryPattern = Regex.Replace(directoryPattern, @"\{ChapterNumber[^}]*\}", "", RegexOptions.IgnoreCase); + directoryPattern = CleanDirectoryPattern(directoryPattern); + + if (string.IsNullOrWhiteSpace(directoryPattern) || !directoryPattern.Contains("/")) + { + directoryPattern = "{Author}/{Title}"; + } + } + else + { + directoryPattern = "{Author}/{Title}"; + } + + if (!string.IsNullOrWhiteSpace(audiobook.Series) && !directoryPattern.Contains("{Series}")) + { + if (directoryPattern.Contains("{Author}/{Title}")) + { + directoryPattern = directoryPattern.Replace("{Author}/{Title}", "{Author}/{Series}/{Title}"); + } + else if (directoryPattern.Contains("{Author}/")) + { + directoryPattern = directoryPattern.Replace("{Author}/", "{Author}/{Series}/"); + } + } + + if (string.IsNullOrWhiteSpace(audiobook.Series)) + { + directoryPattern = Regex.Replace(directoryPattern, @"\{Series[^}]*\}", string.Empty, RegexOptions.IgnoreCase); + directoryPattern = CleanDirectoryPattern(directoryPattern); + } + + var variables = new Dictionary + { + { "Author", SanitizeDirectoryName(audiobook.Authors?.FirstOrDefault() ?? "Unknown Author") }, + { "Series", SanitizeDirectoryName(!string.IsNullOrWhiteSpace(audiobook.Series) ? audiobook.Series! : string.Empty) }, + { "Title", SanitizeDirectoryName(audiobook.Title ?? "Unknown Title") }, + { "Subtitle", SanitizeDirectoryName(audiobook.Subtitle ?? string.Empty) }, + { "Edition", SanitizeDirectoryName(audiobook.Edition ?? string.Empty) }, + { "Narrator", SanitizeDirectoryName((audiobook.Narrators != null && audiobook.Narrators.Any()) ? string.Join(", ", audiobook.Narrators.Where(n => !string.IsNullOrWhiteSpace(n))) : string.Empty) }, + { "Publisher", SanitizeDirectoryName(audiobook.Publisher ?? string.Empty) }, + { "Language", SanitizeDirectoryName(audiobook.Language ?? string.Empty) }, + { "Asin", SanitizeDirectoryName(audiobook.Asin ?? string.Empty) }, + { "SeriesNumber", audiobook.SeriesNumber ?? string.Empty }, + { "Year", audiobook.PublishYear ?? string.Empty }, + { "Quality", string.Empty }, + { "DiskNumber", string.Empty }, + { "ChapterNumber", string.Empty } + }; + + var relative = fileNamingService.ApplyNamingPattern(directoryPattern, variables, false); + return ResolvePathWithOptionalBase(rootPath, relative); + } + + public static string CalculateBasePath(List filePaths, ILogger logger) + { + if (!filePaths.Any()) + return string.Empty; + + var directories = filePaths + .Select(p => FileUtils.NormalizeStoredPath(Path.GetDirectoryName(p) ?? p)) + .Where(p => !string.IsNullOrWhiteSpace(p)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (directories.Count == 1) + { + return directories[0]; + } + + var commonPath = GetCommonPath(directories); + var currentPath = commonPath; + while (!string.IsNullOrEmpty(currentPath)) + { + try + { + var parent = Directory.GetParent(currentPath)?.FullName; + if (string.IsNullOrEmpty(parent)) + break; + + var subDirs = Directory.GetDirectories(parent).Length; + var files = Directory.GetFiles(parent).Length; + + if (subDirs + files > 1) + { + return currentPath; + } + + currentPath = parent; + } + catch (Exception traversalEx) when ( + traversalEx is IOException + || traversalEx is UnauthorizedAccessException + || traversalEx is System.Security.SecurityException + || traversalEx is ArgumentException + || traversalEx is NotSupportedException) + { + logger.LogDebug(traversalEx, "Stopping common-base-path ascent at {Path} due to traversal error", currentPath); + break; + } + } + + return commonPath; + } + + internal static string SanitizeDirectoryName(string name) + { + var invalidChars = Path.GetInvalidFileNameChars(); + foreach (var c in invalidChars) + { + name = name.Replace(c, '_'); + } + + name = name.Replace(":", "_").Replace("*", "_").Replace("?", "_").Replace("\"", "_").Replace("<", "_").Replace(">", "_").Replace("|", "_"); + return name.Trim(); + } + + private static string GetCommonPath(List paths) + { + if (!paths.Any()) + return string.Empty; + + var firstPath = FileUtils.NormalizeStoredPath(paths[0]); + var commonPath = firstPath; + + foreach (var path in paths.Skip(1).Select(rawPath => FileUtils.NormalizeStoredPath(rawPath))) + { + var minLength = Math.Min(commonPath.Length, path.Length); + var commonLength = 0; + + for (int i = 0; i < minLength; i++) + { + if (commonPath[i] == path[i]) + commonLength++; + else + break; + } + + if (commonLength < commonPath.Length) + commonLength = commonPath.LastIndexOf(Path.DirectorySeparatorChar, commonLength - 1) is var lastSep && lastSep >= 0 + ? lastSep + 1 + : 0; + + commonPath = commonPath.Substring(0, commonLength); + + if (string.IsNullOrEmpty(commonPath)) + break; + } + + if (!string.IsNullOrEmpty(commonPath) && !Directory.Exists(commonPath)) + { + var parent = Directory.GetParent(commonPath)?.FullName; + return parent ?? commonPath; + } + + return commonPath; + } + + private static string CleanDirectoryPattern(string pattern) + { + pattern = Regex.Replace(pattern, @"[\\/]\s*[\\/]", "/"); + pattern = Regex.Replace(pattern, @"^\s*[\\/]", ""); + return Regex.Replace(pattern, @"[\\/]\s*$", ""); + } + + private static string ResolvePathWithOptionalBase(string? basePath, string candidatePath) + { + return FileUtils.CombineWithOptionalBase(basePath, candidatePath); + } + } +} diff --git a/tests/Features/Api/Controllers/LibraryController_BasePathTests.cs b/tests/Features/Api/Controllers/LibraryController_BasePathTests.cs index 38a565845..6e50cff72 100644 --- a/tests/Features/Api/Controllers/LibraryController_BasePathTests.cs +++ b/tests/Features/Api/Controllers/LibraryController_BasePathTests.cs @@ -15,7 +15,6 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -using System.Reflection; using Listenarr.Api.Controllers; using Listenarr.Tests.Builders; using Listenarr.Tests.Common; @@ -30,10 +29,6 @@ public class LibraryController_BasePathTests : BaseTests private const string RootPath = "/server/mnt/drive/Audiobooks"; private const string FileNamingPattern = "{Author}/{Series}/{Title}"; - private static readonly MethodInfo ComputeBaseDirectoryMethod = - typeof(LibraryController).GetMethod("ComputeAudiobookBaseDirectoryFromPattern", - BindingFlags.NonPublic | BindingFlags.Instance)!; - [Fact] [Trait("Method", "ComputeAudiobookBaseDirectoryFromPattern")] [Trait("Scenario", "NonSeriesBook_ReturnsCorrectPath")] @@ -46,10 +41,10 @@ public void ComputeAudiobookBaseDirectoryFromPattern_NonSeriesBook_ReturnsCorrec .WithYear("2025") .Build(); - var controller = _provider.GetRequiredService(); + var fileNamingService = _provider.GetRequiredService(); // When - var result = (string)ComputeBaseDirectoryMethod.Invoke(controller, new object[] { audiobook, RootPath, FileNamingPattern }); + var result = LibraryPathPlanner.ComputeAudiobookBaseDirectoryFromPattern(audiobook, RootPath, FileNamingPattern, fileNamingService); // Then Assert.Equal(Path.Join(RootPath, "Stephen Graham Jones", "The Buffalo Hunter Hunter"), result); @@ -69,10 +64,10 @@ public void ComputeAudiobookBaseDirectoryFromPattern_SeriesBook_ReturnsCorrectPa .WithSeriesNumber("1") .Build(); - var controller = _provider.GetRequiredService(); + var fileNamingService = _provider.GetRequiredService(); // When - var result = (string)ComputeBaseDirectoryMethod.Invoke(controller, new object[] { audiobook, RootPath, FileNamingPattern }); + var result = LibraryPathPlanner.ComputeAudiobookBaseDirectoryFromPattern(audiobook, RootPath, FileNamingPattern, fileNamingService); // Then Assert.Equal(Path.Join(RootPath, "Stephen King", "The Dark Tower", "The Gunslinger"), result); From 417ded9407d2c23b48c2c318cf7d97efa4138029 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Fri, 12 Jun 2026 11:44:51 -0400 Subject: [PATCH 16/84] Split remaining backend helper concerns --- .../Controllers/MetadataCacheKeys.cs | 69 ++++++ .../Controllers/MetadataController.cs | 65 +---- listenarr.api/Controllers/SearchController.cs | 46 +--- .../Controllers/SearchRequestNormalizer.cs | 63 +++++ .../Downloads/DownloadClientSelector.cs | 70 ++++++ .../Downloads/DownloadService.cs | 226 +----------------- .../Downloads/DownloadTypeResolver.cs | 180 ++++++++++++++ .../Downloads/EffectiveDownloadType.cs | 28 +++ .../Adapters/QbittorrentAdapter.cs | 85 +------ .../Adapters/TorrentClientPathMapper.cs | 143 +++++++++++ .../Adapters/TransmissionAdapter.cs | 30 +-- .../AppServiceRegistrationExtensions.cs | 2 + tests/Builders/ServiceCollectionBuilder.cs | 2 + 13 files changed, 584 insertions(+), 425 deletions(-) create mode 100644 listenarr.api/Controllers/MetadataCacheKeys.cs create mode 100644 listenarr.api/Controllers/SearchRequestNormalizer.cs create mode 100644 listenarr.application/Downloads/DownloadClientSelector.cs create mode 100644 listenarr.application/Downloads/DownloadTypeResolver.cs create mode 100644 listenarr.application/Downloads/EffectiveDownloadType.cs create mode 100644 listenarr.infrastructure/Adapters/TorrentClientPathMapper.cs diff --git a/listenarr.api/Controllers/MetadataCacheKeys.cs b/listenarr.api/Controllers/MetadataCacheKeys.cs new file mode 100644 index 000000000..bdca54db8 --- /dev/null +++ b/listenarr.api/Controllers/MetadataCacheKeys.cs @@ -0,0 +1,69 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +using Listenarr.Domain.Models; + +namespace Listenarr.Api.Controllers +{ + internal static class MetadataCacheKeys + { + public static string BuildAuthorLookupCacheKey(string region, string name, string? asin = null) + { + var normalizedRegion = AudiobookIdentifierNormalizer.NormalizeRegion(region) ?? "us"; + var normalizedName = NormalizeAuthorCacheKey(name); + var normalizedAsin = string.IsNullOrWhiteSpace(asin) ? null : asin.Trim().ToUpperInvariant(); + + return string.IsNullOrWhiteSpace(normalizedAsin) + ? $"author-lookup:{normalizedRegion}:{normalizedName}" + : $"author-lookup:{normalizedRegion}:{normalizedName}:{normalizedAsin}"; + } + + public static string NormalizeAuthorCacheKey(string? value) + { + return NormalizeLookupKey(value); + } + + public static string NormalizeSeriesCacheKey(string? value) + { + return NormalizeLookupKey(value); + } + + public static string NormalizeCatalogToken(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return string.Empty; + return new string(value.Trim().ToUpperInvariant().Where(char.IsLetterOrDigit).ToArray()); + } + + private static string NormalizeLookupKey(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return string.Empty; + } + + var cleaned = new string(value + .Where(character => char.IsLetterOrDigit(character) || char.IsWhiteSpace(character)) + .ToArray()); + var parts = cleaned.Split( + new[] { ' ', '\t', '\n', '\r' }, + StringSplitOptions.RemoveEmptyEntries); + + return string.Join(' ', parts).ToLowerInvariant(); + } + } +} diff --git a/listenarr.api/Controllers/MetadataController.cs b/listenarr.api/Controllers/MetadataController.cs index 285097a0f..a3a8d2bc8 100644 --- a/listenarr.api/Controllers/MetadataController.cs +++ b/listenarr.api/Controllers/MetadataController.cs @@ -196,7 +196,7 @@ private async Task> LookupAuthorCore( var normalizedName = name.Trim(); var normalizedAsin = string.IsNullOrWhiteSpace(asin) ? null : asin.Trim(); - var cacheKey = BuildAuthorLookupCacheKey(region, normalizedName, normalizedAsin); + var cacheKey = MetadataCacheKeys.BuildAuthorLookupCacheKey(region, normalizedName, normalizedAsin); string? seededName = null; string? seededImage = null; string? seededDescription = null; @@ -481,7 +481,7 @@ await PersistAuthorLookupAsync( result); CacheAuthorLookupResponse(cacheKey, result); - CacheAuthorLookupResponse(BuildAuthorLookupCacheKey(region, normalizedName, result.Asin), result); + CacheAuthorLookupResponse(MetadataCacheKeys.BuildAuthorLookupCacheKey(region, normalizedName, result.Asin), result); return Ok(result); } @@ -800,23 +800,17 @@ private static string BuildAuthorCatalogBookKey(AudibleSearchResult book) { if (!string.IsNullOrWhiteSpace(book.Asin)) { - return $"asin:{NormalizeCatalogToken(book.Asin)}"; + return $"asin:{MetadataCacheKeys.NormalizeCatalogToken(book.Asin)}"; } - var title = NormalizeCatalogToken(book.Title); + var title = MetadataCacheKeys.NormalizeCatalogToken(book.Title); var authors = string.Join("|", (book.Authors ?? new List()) - .Select(a => NormalizeCatalogToken(a.Name)) + .Select(a => MetadataCacheKeys.NormalizeCatalogToken(a.Name)) .Where(a => !string.IsNullOrWhiteSpace(a))); return $"title:{title}:authors:{authors}"; } - private static string NormalizeCatalogToken(string? value) - { - if (string.IsNullOrWhiteSpace(value)) return string.Empty; - return new string(value.Trim().ToUpperInvariant().Where(char.IsLetterOrDigit).ToArray()); - } - private static AuthorCatalogBookItem MapAuthorCatalogBook(AudibleSearchResult book) { var primarySeries = book.Series?.FirstOrDefault(); @@ -1040,7 +1034,7 @@ private async Task PersistAuthorLookupAsync( { var entry = existingEntry ?? new AuthorCacheEntry(); entry.AuthorName = response.Name; - entry.AuthorNameNormalized = NormalizeAuthorCacheKey(normalizedName); + entry.AuthorNameNormalized = MetadataCacheKeys.NormalizeAuthorCacheKey(normalizedName); entry.AuthorAsin = response.Asin; entry.Region = AudiobookIdentifierNormalizer.NormalizeRegion(region) ?? "us"; entry.ImageUrl = response.Image; @@ -1077,17 +1071,6 @@ private void CacheAuthorLookupResponse(string cacheKey, AuthorLookupResponse res }, new MemoryCacheEntryOptions { SlidingExpiration = TimeSpan.FromHours(12) }); } - private static string BuildAuthorLookupCacheKey(string region, string name, string? asin = null) - { - var normalizedRegion = AudiobookIdentifierNormalizer.NormalizeRegion(region) ?? "us"; - var normalizedName = NormalizeAuthorCacheKey(name); - var normalizedAsin = string.IsNullOrWhiteSpace(asin) ? null : asin.Trim().ToUpperInvariant(); - - return string.IsNullOrWhiteSpace(normalizedAsin) - ? $"author-lookup:{normalizedRegion}:{normalizedName}" - : $"author-lookup:{normalizedRegion}:{normalizedName}:{normalizedAsin}"; - } - private async Task ResolvePersistedSeriesCacheAsync(string normalizedName, string region, string? normalizedAsin) { try @@ -1159,7 +1142,7 @@ private async Task PersistSeriesLookupAsync( { var entry = existingEntry ?? new SeriesCacheEntry(); entry.SeriesName = response.Name; - entry.SeriesNameNormalized = NormalizeSeriesCacheKey(normalizedName); + entry.SeriesNameNormalized = MetadataCacheKeys.NormalizeSeriesCacheKey(normalizedName); entry.SeriesAsin = response.Asin; entry.Region = AudiobookIdentifierNormalizer.NormalizeRegion(region) ?? "us"; entry.ImageUrl = response.Image; @@ -1221,40 +1204,6 @@ private void CacheSeriesLookupResponse(string cacheKey, SeriesLookupResponse res }, new MemoryCacheEntryOptions { SlidingExpiration = TimeSpan.FromHours(12) }); } - private static string NormalizeAuthorCacheKey(string? value) - { - if (string.IsNullOrWhiteSpace(value)) - { - return string.Empty; - } - - var cleaned = new string(value - .Where(character => char.IsLetterOrDigit(character) || char.IsWhiteSpace(character)) - .ToArray()); - var parts = cleaned.Split( - new[] { ' ', '\t', '\n', '\r' }, - StringSplitOptions.RemoveEmptyEntries); - - return string.Join(' ', parts).ToLowerInvariant(); - } - - private static string NormalizeSeriesCacheKey(string? value) - { - if (string.IsNullOrWhiteSpace(value)) - { - return string.Empty; - } - - var cleaned = new string(value - .Where(character => char.IsLetterOrDigit(character) || char.IsWhiteSpace(character)) - .ToArray()); - var parts = cleaned.Split( - new[] { ' ', '\t', '\n', '\r' }, - StringSplitOptions.RemoveEmptyEntries); - - return string.Join(' ', parts).ToLowerInvariant(); - } - private static AuthorLookupResponse MapAuthorLookupResponse(AuthorLookupCacheEntry entry, string fallbackName) { return new AuthorLookupResponse diff --git a/listenarr.api/Controllers/SearchController.cs b/listenarr.api/Controllers/SearchController.cs index c0c0f3976..8ec3ef67c 100644 --- a/listenarr.api/Controllers/SearchController.cs +++ b/listenarr.api/Controllers/SearchController.cs @@ -59,23 +59,6 @@ public SearchController( private string BuildApiImagePath(string identifier, string? sourceUrl = null) => HttpApiVersionUtils.BuildImagePath(identifier, HttpContext, sourceUrl: sourceUrl); - private static string? NormalizeStructuredAdvancedField(string? value, string prefix) - { - if (string.IsNullOrWhiteSpace(value)) - { - return value; - } - - var trimmed = value.Trim(); - if (!trimmed.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) - { - return trimmed; - } - - var stripped = trimmed.Substring(prefix.Length).Trim(); - return string.IsNullOrWhiteSpace(stripped) ? null : stripped; - } - private async Task NormalizeSearchResultImagesAsync(List results) { if (_imageCacheService == null || results == null) return; @@ -225,10 +208,10 @@ public async Task> Search([FromBody] JsonElement reqJson, [ else // Advanced { // Route all advanced search logic through SearchService for normalization, filtering, and orchestration - req.Author = NormalizeStructuredAdvancedField(req.Author, "AUTHOR:"); - req.Title = NormalizeStructuredAdvancedField(req.Title, "TITLE:"); - req.Isbn = NormalizeStructuredAdvancedField(req.Isbn, "ISBN:"); - req.Asin = NormalizeStructuredAdvancedField(req.Asin, "ASIN:"); + req.Author = SearchRequestNormalizer.NormalizeStructuredAdvancedField(req.Author, "AUTHOR:"); + req.Title = SearchRequestNormalizer.NormalizeStructuredAdvancedField(req.Title, "TITLE:"); + req.Isbn = SearchRequestNormalizer.NormalizeStructuredAdvancedField(req.Isbn, "ISBN:"); + req.Asin = SearchRequestNormalizer.NormalizeStructuredAdvancedField(req.Asin, "ASIN:"); // Validate and normalize ISBN/ASIN inputs for advanced searches. // If an ISBN-10 is supplied, convert it to ISBN-13 using the 978 prefix. @@ -239,7 +222,7 @@ public async Task> Search([FromBody] JsonElement reqJson, [ var rawIsbn = Regex.Replace(req.Isbn, "[^0-9Xx]", string.Empty); if (rawIsbn.Length == 10) { - var converted = ConvertIsbn10ToIsbn13(rawIsbn); + var converted = SearchRequestNormalizer.ConvertIsbn10ToIsbn13(rawIsbn); if (converted == null) { return BadRequest("Invalid ISBN-10 provided"); @@ -818,25 +801,6 @@ private async Task MapMetadataResultToAudibleAsync(MetadataSearchResult }; } - private static string? ConvertIsbn10ToIsbn13(string isbn10) - { - if (string.IsNullOrWhiteSpace(isbn10)) return null; - // isbn10 is expected to be 10 chars where first 9 are digits and last is digit or 'X' - if (isbn10.Length != 10) return null; - var first9 = isbn10.Substring(0, 9); - if (!Regex.IsMatch(first9, "^[0-9]{9}$")) return null; - var twelve = "978" + first9; // 12 digits - int sum = 0; - for (int i = 0; i < 12; i++) - { - int d = twelve[i] - '0'; - sum += (i % 2 == 0) ? d * 1 : d * 3; - } - int mod = sum % 10; - int check = (10 - mod) % 10; - return string.Concat(twelve, check); - } - private async Task EnsureCachedImagesForAudibleResultsAsync(List? results) { if (results == null || results.Count == 0) return; diff --git a/listenarr.api/Controllers/SearchRequestNormalizer.cs b/listenarr.api/Controllers/SearchRequestNormalizer.cs new file mode 100644 index 000000000..353ba71fe --- /dev/null +++ b/listenarr.api/Controllers/SearchRequestNormalizer.cs @@ -0,0 +1,63 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +using System.Text.RegularExpressions; + +namespace Listenarr.Api.Controllers +{ + internal static class SearchRequestNormalizer + { + public static string? NormalizeStructuredAdvancedField(string? value, string prefix) + { + if (string.IsNullOrWhiteSpace(value)) + { + return value; + } + + var trimmed = value.Trim(); + if (!trimmed.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + return trimmed; + } + + var stripped = trimmed.Substring(prefix.Length).Trim(); + return string.IsNullOrWhiteSpace(stripped) ? null : stripped; + } + + public static string? ConvertIsbn10ToIsbn13(string isbn10) + { + if (string.IsNullOrWhiteSpace(isbn10)) return null; + if (isbn10.Length != 10) return null; + + var first9 = isbn10.Substring(0, 9); + if (!Regex.IsMatch(first9, "^[0-9]{9}$")) return null; + + var twelve = "978" + first9; + int sum = 0; + for (int i = 0; i < 12; i++) + { + int d = twelve[i] - '0'; + sum += (i % 2 == 0) ? d : d * 3; + } + + int mod = sum % 10; + int check = (10 - mod) % 10; + return string.Concat(twelve, check); + } + } +} diff --git a/listenarr.application/Downloads/DownloadClientSelector.cs b/listenarr.application/Downloads/DownloadClientSelector.cs new file mode 100644 index 000000000..e870d01f9 --- /dev/null +++ b/listenarr.application/Downloads/DownloadClientSelector.cs @@ -0,0 +1,70 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +using Listenarr.Application.Interfaces; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Application.Downloads +{ + public class DownloadClientSelector( + IConfigurationService configurationService, + ILogger logger) + { + public async Task GetAppropriateDownloadClientAsync(bool isTorrent) + { + var downloadClients = await configurationService.GetDownloadClientConfigurationsAsync(); + var enabledClients = downloadClients.Where(c => c.IsEnabled).ToList(); + + logger.LogInformation("Looking for {ClientType} client. Found {Count} enabled download clients: {Clients}", + isTorrent ? "torrent" : "NZB", + enabledClients.Count, + string.Join(", ", enabledClients.Select(c => $"{c.Name} ({c.Type})"))); + + if (isTorrent) + { + var client = enabledClients.FirstOrDefault(c => c.Type.Equals("qbittorrent", StringComparison.OrdinalIgnoreCase)) + ?? enabledClients.FirstOrDefault(c => c.Type.Equals("transmission", StringComparison.OrdinalIgnoreCase)); + + if (client != null) + { + logger.LogInformation("Selected torrent client: {ClientName} ({ClientType})", client.Name, client.Type); + } + else + { + logger.LogWarning("No torrent client (qBittorrent or Transmission) found among enabled clients"); + } + + return client?.Id; + } + + var nzbClient = enabledClients.FirstOrDefault(c => c.Type.Equals("sabnzbd", StringComparison.OrdinalIgnoreCase)) + ?? enabledClients.FirstOrDefault(c => c.Type.Equals("nzbget", StringComparison.OrdinalIgnoreCase)); + + if (nzbClient != null) + { + logger.LogInformation("Selected NZB client: {ClientName} ({ClientType})", nzbClient.Name, nzbClient.Type); + } + else + { + logger.LogWarning("No NZB client (SABnzbd or NZBGet) found among enabled clients"); + } + + return nzbClient?.Id; + } + } +} diff --git a/listenarr.application/Downloads/DownloadService.cs b/listenarr.application/Downloads/DownloadService.cs index 4294d0eb8..4fe9291d6 100644 --- a/listenarr.application/Downloads/DownloadService.cs +++ b/listenarr.application/Downloads/DownloadService.cs @@ -42,21 +42,15 @@ public class DownloadService( IDownloadQueueService downloadQueueService, INotificationService notificationService, IHubBroadcaster hubBroadcaster, - IDownloadHistoryService downloadHistoryService) : IDownloadService + IDownloadHistoryService downloadHistoryService, + DownloadTypeResolver downloadTypeResolver, + DownloadClientSelector downloadClientSelector) : IDownloadService { // Cache expiration constants private const int QueueCacheExpirationSeconds = 10; private const int ClientStatusCacheExpirationSeconds = 30; private const int DirectDownloadTimeoutHours = 2; - private enum EffectiveDownloadType - { - Unknown, - Torrent, - Usenet, - DirectDownload - } - // Track qBittorrent sync state for incremental updates (clientId -> last rid) private readonly Dictionary _qbittorrentSyncState = new(); @@ -240,8 +234,8 @@ public async Task SearchAndDownloadAsync(int audiobookI // Assign score to SearchResult topResult.SearchResult.Score = topResult.TotalScore; - var effectiveDownloadType = await ResolveEffectiveDownloadTypeAsync(topResult.SearchResult); - topResult.SearchResult.DownloadType = GetDownloadTypeLabel(effectiveDownloadType); + var effectiveDownloadType = await downloadTypeResolver.ResolveAsync(topResult.SearchResult); + topResult.SearchResult.DownloadType = DownloadTypeResolver.GetLabel(effectiveDownloadType); if (effectiveDownloadType == EffectiveDownloadType.Unknown) { @@ -274,7 +268,7 @@ public async Task SearchAndDownloadAsync(int audiobookI // Use topResult.SearchResult for torrent/nzb download var isTorrent = effectiveDownloadType == EffectiveDownloadType.Torrent; - var downloadClientId = await GetAppropriateDownloadClient(isTorrent); + var downloadClientId = await downloadClientSelector.GetAppropriateDownloadClientAsync(isTorrent); if (downloadClientId == null) { @@ -311,8 +305,8 @@ public async Task SendToDownloadClientAsync(SearchResult searchResult, s searchResult.TorrentUrl ?? "(null)", audiobookId); - var effectiveDownloadType = await ResolveEffectiveDownloadTypeAsync(searchResult); - searchResult.DownloadType = GetDownloadTypeLabel(effectiveDownloadType); + var effectiveDownloadType = await downloadTypeResolver.ResolveAsync(searchResult); + searchResult.DownloadType = DownloadTypeResolver.GetLabel(effectiveDownloadType); if (effectiveDownloadType == EffectiveDownloadType.Unknown) { @@ -335,7 +329,7 @@ public async Task SendToDownloadClientAsync(SearchResult searchResult, s if (downloadClientId == null) { - downloadClientId = await GetAppropriateDownloadClient(isTorrent); + downloadClientId = await downloadClientSelector.GetAppropriateDownloadClientAsync(isTorrent); if (downloadClientId == null) { @@ -998,160 +992,6 @@ private string BuildSearchQuery(Audiobook audiobook) return string.Join(" ", parts); } - private async Task ResolveEffectiveDownloadTypeAsync(SearchResult result) - { - ArgumentNullException.ThrowIfNull(result); - - if (!string.IsNullOrWhiteSpace(result.NzbUrl)) - { - logger.LogDebug("Result identified as Usenet from NzbUrl: {Title}", result.Title); - return EffectiveDownloadType.Usenet; - } - - if (!string.IsNullOrWhiteSpace(result.MagnetLink)) - { - logger.LogDebug("Result identified as Torrent from MagnetLink: {Title}", result.Title); - return EffectiveDownloadType.Torrent; - } - - if (result.TorrentFileContent != null && result.TorrentFileContent.Length > 0) - { - logger.LogDebug("Result identified as Torrent from cached torrent bytes: {Title}", result.Title); - return EffectiveDownloadType.Torrent; - } - - if (await IsTrustedDirectDownloadAsync(result)) - { - logger.LogDebug("Result identified as trusted DDL from configured Internet Archive indexer: {Title}", result.Title); - return EffectiveDownloadType.DirectDownload; - } - - if (DownloadClientUriBuilder.TryParseHttpOrHttpsAbsoluteUri(result.TorrentUrl, out _)) - { - logger.LogDebug("Result identified as Torrent from TorrentUrl: {Title}", result.Title); - return EffectiveDownloadType.Torrent; - } - - logger.LogWarning( - "Unable to derive effective download type for '{Title}'. Incoming DownloadType '{DownloadType}' was ignored because no trusted download target was present.", - result.Title, - result.DownloadType ?? "(null)"); - - return EffectiveDownloadType.Unknown; - } - - private async Task IsTrustedDirectDownloadAsync(SearchResult result) - { - if (result?.IndexerId is not int indexerId || indexerId <= 0) - { - return false; - } - - if (!DownloadClientUriBuilder.TryParseHttpOrHttpsAbsoluteUri(result.TorrentUrl, out var downloadUri) || - downloadUri == null) - { - return false; - } - - if (!IsTrustedArchiveOrgHost(downloadUri) || - !downloadUri.AbsolutePath.StartsWith("/download/", StringComparison.OrdinalIgnoreCase)) - { - return false; - } - - try - { - var indexer = await indexerRepository.GetByIdAsync(indexerId); - - if (indexer == null || !indexer.IsEnabled) - { - logger.LogDebug( - "Direct-download validation rejected '{Title}': indexer {IndexerId} was missing or disabled", - result.Title, - indexerId); - return false; - } - - if (!string.Equals(indexer.Implementation, "InternetArchive", StringComparison.OrdinalIgnoreCase)) - { - logger.LogDebug( - "Direct-download validation rejected '{Title}': indexer {IndexerId} implementation was {Implementation}", - result.Title, - indexerId, - indexer.Implementation); - return false; - } - - if (!Uri.TryCreate(indexer.Url, UriKind.Absolute, out var indexerUri) || - !IsTrustedArchiveOrgHost(indexerUri)) - { - logger.LogDebug( - "Direct-download validation rejected '{Title}': configured indexer URL '{IndexerUrl}' is not a trusted archive.org host", - result.Title, - indexer.Url); - return false; - } - - return true; - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - logger.LogWarning( - ex, - "Failed to validate direct-download route for '{Title}' against configured indexer {IndexerId}", - result.Title, - indexerId); - return false; - } - } - - private static bool IsTrustedArchiveOrgHost(Uri uri) - { - var host = uri.Host.Trim(); - return host.Equals("archive.org", StringComparison.OrdinalIgnoreCase) || - host.EndsWith(".archive.org", StringComparison.OrdinalIgnoreCase); - } - - private static string GetDownloadTypeLabel(EffectiveDownloadType effectiveDownloadType) - { - return effectiveDownloadType switch - { - EffectiveDownloadType.Torrent => "Torrent", - EffectiveDownloadType.Usenet => "Usenet", - EffectiveDownloadType.DirectDownload => "DDL", - _ => string.Empty - }; - } - - private bool IsTorrentResult(SearchResult result) - { - // Use transport indicators only. Do not trust caller-provided DownloadType. - if (!string.IsNullOrEmpty(result.NzbUrl)) - { - logger.LogDebug("Result identified as NZB (has NzbUrl): {Title}", result.Title); - return false; - } - - if (result.TorrentFileContent != null && result.TorrentFileContent.Length > 0) - { - logger.LogDebug("Result identified as Torrent (has cached torrent bytes): {Title}", result.Title); - return true; - } - - if (!string.IsNullOrEmpty(result.MagnetLink) || - DownloadClientUriBuilder.TryParseHttpOrHttpsAbsoluteUri(result.TorrentUrl, out _)) - { - logger.LogDebug("Result identified as Torrent (has MagnetLink or TorrentUrl): {Title}", result.Title); - return true; - } - - // If neither is set, we can't reliably determine the type - // Log a warning and default to false (NZB) as a safer choice - logger.LogWarning("Unable to determine result type for '{Title}' from source '{Source}'. No MagnetLink, TorrentUrl, or NzbUrl found. Defaulting to NZB.", - result.Title, result.Source); - return false; - } - // Small container for caching torrent bytes + filename in memory private class CachedTorrent { @@ -1159,52 +999,6 @@ private class CachedTorrent public string? FileName { get; set; } } - private async Task GetAppropriateDownloadClient(bool isTorrent) - { - var downloadClients = await configurationService.GetDownloadClientConfigurationsAsync(); - var enabledClients = downloadClients.Where(c => c.IsEnabled).ToList(); - - logger.LogInformation("Looking for {ClientType} client. Found {Count} enabled download clients: {Clients}", - isTorrent ? "torrent" : "NZB", - enabledClients.Count, - string.Join(", ", enabledClients.Select(c => $"{c.Name} ({c.Type})"))); - - if (isTorrent) - { - // Prefer qBittorrent, then Transmission - var client = enabledClients.FirstOrDefault(c => c.Type.Equals("qbittorrent", StringComparison.OrdinalIgnoreCase)) - ?? enabledClients.FirstOrDefault(c => c.Type.Equals("transmission", StringComparison.OrdinalIgnoreCase)); - - if (client != null) - { - logger.LogInformation("Selected torrent client: {ClientName} ({ClientType})", client.Name, client.Type); - } - else - { - logger.LogWarning("No torrent client (qBittorrent or Transmission) found among enabled clients"); - } - - return client?.Id; - } - else - { - // Prefer SABnzbd, then NZBGet - var client = enabledClients.FirstOrDefault(c => c.Type.Equals("sabnzbd", StringComparison.OrdinalIgnoreCase)) - ?? enabledClients.FirstOrDefault(c => c.Type.Equals("nzbget", StringComparison.OrdinalIgnoreCase)); - - if (client != null) - { - logger.LogInformation("Selected NZB client: {ClientName} ({ClientType})", client.Name, client.Type); - } - else - { - logger.LogWarning("No NZB client (SABnzbd or NZBGet) found among enabled clients"); - } - - return client?.Id; - } - } - public async Task RemoveFromQueueAsync(string downloadId, string? downloadClientId = null, bool force = false) { try @@ -1427,7 +1221,7 @@ private async Task LogDownloadHistory(Audiobook audiobook, string source, Search private string? TryResolveClientSpecificIdFallback(DownloadClientConfiguration client, SearchResult searchResult) { - if (client == null || searchResult == null || !IsTorrentResult(searchResult)) + if (client == null || searchResult == null || !downloadTypeResolver.IsTorrentResult(searchResult)) { return null; } diff --git a/listenarr.application/Downloads/DownloadTypeResolver.cs b/listenarr.application/Downloads/DownloadTypeResolver.cs new file mode 100644 index 000000000..14c9472ba --- /dev/null +++ b/listenarr.application/Downloads/DownloadTypeResolver.cs @@ -0,0 +1,180 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +using Listenarr.Application.Interfaces.Repositories; +using Listenarr.Domain.Models; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Application.Downloads +{ + public class DownloadTypeResolver( + IIndexerRepository indexerRepository, + ILogger logger) + { + public async Task ResolveAsync(SearchResult result) + { + ArgumentNullException.ThrowIfNull(result); + + if (!string.IsNullOrWhiteSpace(result.NzbUrl)) + { + logger.LogDebug("Result identified as Usenet from NzbUrl: {Title}", result.Title); + return EffectiveDownloadType.Usenet; + } + + if (!string.IsNullOrWhiteSpace(result.MagnetLink)) + { + logger.LogDebug("Result identified as Torrent from MagnetLink: {Title}", result.Title); + return EffectiveDownloadType.Torrent; + } + + if (result.TorrentFileContent != null && result.TorrentFileContent.Length > 0) + { + logger.LogDebug("Result identified as Torrent from cached torrent bytes: {Title}", result.Title); + return EffectiveDownloadType.Torrent; + } + + if (await IsTrustedDirectDownloadAsync(result)) + { + logger.LogDebug("Result identified as trusted DDL from configured Internet Archive indexer: {Title}", result.Title); + return EffectiveDownloadType.DirectDownload; + } + + if (DownloadClientUriBuilder.TryParseHttpOrHttpsAbsoluteUri(result.TorrentUrl, out _)) + { + logger.LogDebug("Result identified as Torrent from TorrentUrl: {Title}", result.Title); + return EffectiveDownloadType.Torrent; + } + + logger.LogWarning( + "Unable to derive effective download type for '{Title}'. Incoming DownloadType '{DownloadType}' was ignored because no trusted download target was present.", + result.Title, + result.DownloadType ?? "(null)"); + + return EffectiveDownloadType.Unknown; + } + + public bool IsTorrentResult(SearchResult result) + { + if (!string.IsNullOrEmpty(result.NzbUrl)) + { + logger.LogDebug("Result identified as NZB (has NzbUrl): {Title}", result.Title); + return false; + } + + if (result.TorrentFileContent != null && result.TorrentFileContent.Length > 0) + { + logger.LogDebug("Result identified as Torrent (has cached torrent bytes): {Title}", result.Title); + return true; + } + + if (!string.IsNullOrEmpty(result.MagnetLink) || + DownloadClientUriBuilder.TryParseHttpOrHttpsAbsoluteUri(result.TorrentUrl, out _)) + { + logger.LogDebug("Result identified as Torrent (has MagnetLink or TorrentUrl): {Title}", result.Title); + return true; + } + + logger.LogWarning("Unable to determine result type for '{Title}' from source '{Source}'. No MagnetLink, TorrentUrl, or NzbUrl found. Defaulting to NZB.", + result.Title, result.Source); + return false; + } + + public static string GetLabel(EffectiveDownloadType effectiveDownloadType) + { + return effectiveDownloadType switch + { + EffectiveDownloadType.Torrent => "Torrent", + EffectiveDownloadType.Usenet => "Usenet", + EffectiveDownloadType.DirectDownload => "DDL", + _ => string.Empty + }; + } + + private async Task IsTrustedDirectDownloadAsync(SearchResult result) + { + if (result?.IndexerId is not int indexerId || indexerId <= 0) + { + return false; + } + + if (!DownloadClientUriBuilder.TryParseHttpOrHttpsAbsoluteUri(result.TorrentUrl, out var downloadUri) || + downloadUri == null) + { + return false; + } + + if (!IsTrustedArchiveOrgHost(downloadUri) || + !downloadUri.AbsolutePath.StartsWith("/download/", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + try + { + var indexer = await indexerRepository.GetByIdAsync(indexerId); + + if (indexer == null || !indexer.IsEnabled) + { + logger.LogDebug( + "Direct-download validation rejected '{Title}': indexer {IndexerId} was missing or disabled", + result.Title, + indexerId); + return false; + } + + if (!string.Equals(indexer.Implementation, "InternetArchive", StringComparison.OrdinalIgnoreCase)) + { + logger.LogDebug( + "Direct-download validation rejected '{Title}': indexer {IndexerId} implementation was {Implementation}", + result.Title, + indexerId, + indexer.Implementation); + return false; + } + + if (!Uri.TryCreate(indexer.Url, UriKind.Absolute, out var indexerUri) || + !IsTrustedArchiveOrgHost(indexerUri)) + { + logger.LogDebug( + "Direct-download validation rejected '{Title}': configured indexer URL '{IndexerUrl}' is not a trusted archive.org host", + result.Title, + indexer.Url); + return false; + } + + return true; + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + logger.LogWarning( + ex, + "Failed to validate direct-download route for '{Title}' against configured indexer {IndexerId}", + result.Title, + indexerId); + return false; + } + } + + private static bool IsTrustedArchiveOrgHost(Uri uri) + { + var host = uri.Host.Trim(); + return host.Equals("archive.org", StringComparison.OrdinalIgnoreCase) || + host.EndsWith(".archive.org", StringComparison.OrdinalIgnoreCase); + } + } +} diff --git a/listenarr.application/Downloads/EffectiveDownloadType.cs b/listenarr.application/Downloads/EffectiveDownloadType.cs new file mode 100644 index 000000000..24824e720 --- /dev/null +++ b/listenarr.application/Downloads/EffectiveDownloadType.cs @@ -0,0 +1,28 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace Listenarr.Application.Downloads +{ + public enum EffectiveDownloadType + { + Unknown, + Torrent, + Usenet, + DirectDownload + } +} diff --git a/listenarr.infrastructure/Adapters/QbittorrentAdapter.cs b/listenarr.infrastructure/Adapters/QbittorrentAdapter.cs index 022f40de4..7e6856e7f 100644 --- a/listenarr.infrastructure/Adapters/QbittorrentAdapter.cs +++ b/listenarr.infrastructure/Adapters/QbittorrentAdapter.cs @@ -1198,7 +1198,7 @@ public async Task GetImportItemAsync( } // ✅ Apply remote path mapping - result.SourceFiles = await TranslateSourceFilesAsync(client.Id, BuildTorrentSourceFiles(savePath, files)); + result.SourceFiles = await TranslateSourceFilesAsync(client.Id, TorrentClientPathMapper.BuildQbittorrentSourceFiles(savePath, files)); if (!string.IsNullOrWhiteSpace(outputPath)) { result.ContentPath = outputPath; @@ -1214,47 +1214,11 @@ public async Task GetImportItemAsync( return result; } - private static string CombineWithOptionalBase(string? basePath, string candidatePath) - { - var normalizedPath = candidatePath.Trim(); - - if (string.IsNullOrEmpty(normalizedPath)) - { - return normalizedPath; - } - - if (Path.IsPathRooted(normalizedPath) || string.IsNullOrWhiteSpace(basePath)) - { - return normalizedPath; - } - - var relativePath = normalizedPath.TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - if (Path.IsPathRooted(relativePath)) - { - return relativePath; - } - - var normalizedBasePath = basePath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - return string.IsNullOrEmpty(normalizedBasePath) - ? relativePath - : normalizedBasePath + Path.DirectorySeparatorChar + relativePath; - } - private static List BuildTorrentSourceFiles( string savePath, List> files) { - if (string.IsNullOrWhiteSpace(savePath) || files == null || files.Count == 0) - { - return new List(); - } - - return files - .Select(file => file.TryGetValue("name", out var nameEl) ? nameEl.GetString() ?? string.Empty : string.Empty) - .Where(name => !string.IsNullOrWhiteSpace(name)) - .Select(name => CombineWithOptionalBase(savePath, name.Replace('/', Path.DirectorySeparatorChar))) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToList(); + return TorrentClientPathMapper.BuildQbittorrentSourceFiles(savePath, files); } private async Task> TranslateSourceFilesAsync(string clientId, IEnumerable sourceFiles) @@ -1276,47 +1240,7 @@ internal static string ResolveTorrentContentPath( string savePath, List> files) { - if (string.IsNullOrWhiteSpace(savePath) || files == null || files.Count == 0) - { - return string.Empty; - } - - var fileNames = files - .Select(f => f.TryGetValue("name", out var nameEl) ? nameEl.GetString() ?? string.Empty : string.Empty) - .Where(name => !string.IsNullOrWhiteSpace(name)) - .ToList(); - - if (fileNames.Count == 0) - { - return string.Empty; - } - - var firstFile = fileNames[0]; - var firstParts = firstFile.Split('/', StringSplitOptions.RemoveEmptyEntries); - var hasNestedPath = firstParts.Length > 1; - - if (fileNames.Count == 1) - { - return hasNestedPath - ? CombineWithOptionalBase(savePath, firstParts[0]) - : CombineWithOptionalBase(savePath, firstFile); - } - - if (!hasNestedPath) - { - return savePath; - } - - var topLevel = firstParts[0]; - var allShareTopLevel = fileNames.All(name => - { - var parts = name.Split('/', StringSplitOptions.RemoveEmptyEntries); - return parts.Length > 1 && string.Equals(parts[0], topLevel, StringComparison.Ordinal); - }); - - return allShareTopLevel - ? CombineWithOptionalBase(savePath, topLevel) - : savePath; + return TorrentClientPathMapper.ResolveQbittorrentContentPath(savePath, files); } public async Task> FetchDownloadsAsync( @@ -1651,7 +1575,7 @@ public async Task> FetchDownloadsAsync( var completionPath = !string.IsNullOrEmpty(matched.ContentPath) ? matched.ContentPath : (!string.IsNullOrEmpty(matched.SavePath) && !string.IsNullOrEmpty(matched.Name) - ? CombineWithOptionalBase(matched.SavePath, matched.Name) + ? FileUtils.CombineWithOptionalBase(matched.SavePath, matched.Name) : matched.SavePath); _logger.LogInformation("Download {DownloadId} observed as complete candidate (qBittorrent). Torrent: {TorrentName}, Path: {Path}. Waiting for stability window.", @@ -1722,4 +1646,3 @@ seedingTime is long inheritedSeedingTime && } } } - diff --git a/listenarr.infrastructure/Adapters/TorrentClientPathMapper.cs b/listenarr.infrastructure/Adapters/TorrentClientPathMapper.cs new file mode 100644 index 000000000..f3f96615d --- /dev/null +++ b/listenarr.infrastructure/Adapters/TorrentClientPathMapper.cs @@ -0,0 +1,143 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +using System.Text.Json; +using Listenarr.Domain.Common; + +namespace Listenarr.Infrastructure.Adapters +{ + internal static class TorrentClientPathMapper + { + public static List BuildQbittorrentSourceFiles( + string savePath, + List> files) + { + if (string.IsNullOrWhiteSpace(savePath) || files == null || files.Count == 0) + { + return new List(); + } + + return files + .Select(file => file.TryGetValue("name", out var nameEl) ? nameEl.GetString() ?? string.Empty : string.Empty) + .Where(name => !string.IsNullOrWhiteSpace(name)) + .Select(name => CombineWithOptionalBase(savePath, name.Replace('/', Path.DirectorySeparatorChar))) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + public static List BuildTransmissionSourceFiles(string? downloadDir, JsonElement filesElement) + { + if (string.IsNullOrWhiteSpace(downloadDir) || filesElement.ValueKind != JsonValueKind.Array) + { + return new List(); + } + + var sourceFiles = new List(); + foreach (var file in filesElement.EnumerateArray()) + { + if (!file.TryGetProperty("name", out var nameProp)) + { + continue; + } + + var relativePath = nameProp.GetString(); + if (string.IsNullOrWhiteSpace(relativePath)) + { + continue; + } + + sourceFiles.Add(FileUtils.CombineWithOptionalBase(downloadDir, relativePath)); + } + + return sourceFiles; + } + + public static string ResolveQbittorrentContentPath( + string savePath, + List> files) + { + if (string.IsNullOrWhiteSpace(savePath) || files == null || files.Count == 0) + { + return string.Empty; + } + + var fileNames = files + .Select(f => f.TryGetValue("name", out var nameEl) ? nameEl.GetString() ?? string.Empty : string.Empty) + .Where(name => !string.IsNullOrWhiteSpace(name)) + .ToList(); + + if (fileNames.Count == 0) + { + return string.Empty; + } + + var firstFile = fileNames[0]; + var firstParts = firstFile.Split('/', StringSplitOptions.RemoveEmptyEntries); + var hasNestedPath = firstParts.Length > 1; + + if (fileNames.Count == 1) + { + return hasNestedPath + ? CombineWithOptionalBase(savePath, firstParts[0]) + : CombineWithOptionalBase(savePath, firstFile); + } + + if (!hasNestedPath) + { + return savePath; + } + + var topLevel = firstParts[0]; + var allShareTopLevel = fileNames.All(name => + { + var parts = name.Split('/', StringSplitOptions.RemoveEmptyEntries); + return parts.Length > 1 && string.Equals(parts[0], topLevel, StringComparison.Ordinal); + }); + + return allShareTopLevel + ? CombineWithOptionalBase(savePath, topLevel) + : savePath; + } + + private static string CombineWithOptionalBase(string? basePath, string candidatePath) + { + var normalizedPath = candidatePath.Trim(); + + if (string.IsNullOrEmpty(normalizedPath)) + { + return normalizedPath; + } + + if (Path.IsPathRooted(normalizedPath) || string.IsNullOrWhiteSpace(basePath)) + { + return normalizedPath; + } + + var relativePath = normalizedPath.TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + if (Path.IsPathRooted(relativePath)) + { + return relativePath; + } + + var normalizedBasePath = basePath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + return string.IsNullOrEmpty(normalizedBasePath) + ? relativePath + : normalizedBasePath + Path.DirectorySeparatorChar + relativePath; + } + } +} diff --git a/listenarr.infrastructure/Adapters/TransmissionAdapter.cs b/listenarr.infrastructure/Adapters/TransmissionAdapter.cs index 6d195e3ea..352130345 100644 --- a/listenarr.infrastructure/Adapters/TransmissionAdapter.cs +++ b/listenarr.infrastructure/Adapters/TransmissionAdapter.cs @@ -637,7 +637,7 @@ public async Task GetImportItemAsync( if (torrent.TryGetProperty("files", out var filesElement)) { - var sourceFiles = BuildTransmissionSourceFiles(downloadDir, filesElement); + var sourceFiles = TorrentClientPathMapper.BuildTransmissionSourceFiles(downloadDir, filesElement); result.SourceFiles = [.. sourceFiles.Where(path => !string.IsNullOrWhiteSpace(path))]; } @@ -655,33 +655,6 @@ public async Task GetImportItemAsync( } } - private static List BuildTransmissionSourceFiles(string? downloadDir, JsonElement filesElement) - { - if (string.IsNullOrWhiteSpace(downloadDir) || filesElement.ValueKind != JsonValueKind.Array) - { - return new List(); - } - - var sourceFiles = new List(); - foreach (var file in filesElement.EnumerateArray()) - { - if (!file.TryGetProperty("name", out var nameProp)) - { - continue; - } - - var relativePath = nameProp.GetString(); - if (string.IsNullOrWhiteSpace(relativePath)) - { - continue; - } - - sourceFiles.Add(FileUtils.CombineWithOptionalBase(downloadDir, relativePath)); - } - - return sourceFiles; - } - private async Task MapTorrentAsync(DownloadClientConfiguration client, JsonElement torrent, CancellationToken ct) { // Try snake_case (JSON-RPC 2.0 / Transmission 4.1+) first, fall back to camelCase for backwards compatibility @@ -1597,4 +1570,3 @@ private static bool TransmissionHasReachedSeedLimit( } } } - diff --git a/listenarr.infrastructure/Extensions/AppServiceRegistrationExtensions.cs b/listenarr.infrastructure/Extensions/AppServiceRegistrationExtensions.cs index ecd547f22..8ea1d8db9 100644 --- a/listenarr.infrastructure/Extensions/AppServiceRegistrationExtensions.cs +++ b/listenarr.infrastructure/Extensions/AppServiceRegistrationExtensions.cs @@ -88,6 +88,8 @@ public static IServiceCollection AddListenarrAppServices(this IServiceCollection services.AddSingleton(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); // Queue service extracted from DownloadService to encapsulate queue-building and filtering services.AddScoped(); services.AddScoped(); diff --git a/tests/Builders/ServiceCollectionBuilder.cs b/tests/Builders/ServiceCollectionBuilder.cs index f40495bb8..11a833f74 100644 --- a/tests/Builders/ServiceCollectionBuilder.cs +++ b/tests/Builders/ServiceCollectionBuilder.cs @@ -184,6 +184,8 @@ private ServiceCollection BuildServices() services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); From 3e9f9cdb613ce3aea684f4d2396a0cc1255681f6 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Fri, 12 Jun 2026 14:30:55 -0400 Subject: [PATCH 17/84] Break down library and search workflows --- .../Controllers/LibraryController.cs | 954 +------------- .../LibraryMetadataRescanWorkflow.cs | 632 ++++++++++ listenarr.api/Controllers/SearchController.cs | 113 +- .../SearchResultImageNormalizer.cs | 101 ++ listenarr.api/Program.cs | 1 + .../AudiobookQualityCutoffEvaluator.cs | 173 +++ .../Metadata/AudibleProductMapper.cs | 250 ++++ .../Metadata/AudibleService.cs | 130 +- .../Search/AudibleSearchResultMapper.cs | 83 ++ .../Search/SearchQueryParser.cs | 155 +++ .../Search/SearchResultMatchEvaluator.cs | 171 +++ listenarr.application/Search/SearchService.cs | 1112 +---------------- .../Providers/MyAnonamouseSearchProvider.cs | 46 +- tests/Builders/ServiceCollectionBuilder.cs | 1 + .../LibraryController_QualityCutoffTests.cs | 19 +- .../Providers/IndexersNewznabParsingTests.cs | 18 +- .../Api/Services/SearchWorkflowHelperTests.cs | 71 ++ 17 files changed, 1738 insertions(+), 2292 deletions(-) create mode 100644 listenarr.api/Controllers/LibraryMetadataRescanWorkflow.cs create mode 100644 listenarr.api/Controllers/SearchResultImageNormalizer.cs create mode 100644 listenarr.application/Audiobooks/AudiobookQualityCutoffEvaluator.cs create mode 100644 listenarr.application/Metadata/AudibleProductMapper.cs create mode 100644 listenarr.application/Search/AudibleSearchResultMapper.cs create mode 100644 listenarr.application/Search/SearchQueryParser.cs create mode 100644 listenarr.application/Search/SearchResultMatchEvaluator.cs create mode 100644 tests/Features/Api/Services/SearchWorkflowHelperTests.cs diff --git a/listenarr.api/Controllers/LibraryController.cs b/listenarr.api/Controllers/LibraryController.cs index befd3b6e5..b979ced82 100644 --- a/listenarr.api/Controllers/LibraryController.cs +++ b/listenarr.api/Controllers/LibraryController.cs @@ -17,10 +17,8 @@ */ using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Caching.Memory; using Listenarr.Domain.Models; using System.Text.Json; -using System.Reflection; using System.Security.Cryptography; using System.Text; using Listenarr.Domain.Common; @@ -30,7 +28,6 @@ using Listenarr.Application.Notification; using Listenarr.Application.Security; using Listenarr.Application.Metadata; -using Listenarr.Application.Search; using Listenarr.Application.Audiobooks; using Listenarr.Api.Attributes; @@ -41,11 +38,6 @@ namespace Listenarr.Api.Controllers [Tags("Library")] public class LibraryController : ControllerBase { - private const int MetadataRescanCooldownSeconds = 15; - private const int MetadataRescanWindowMinutes = 10; - private const int MetadataRescanMaxRequestsPerWindow = 5; - private const int MetadataRescanMaxAsinLookupAttempts = 8; - private const int MetadataRescanMaxIsbnConversionAttempts = 5; private readonly IAudiobookRepository _repo; private readonly IImageCacheService _imageCacheService; private readonly ILogger _logger; @@ -63,6 +55,7 @@ public class LibraryController : ControllerBase private readonly IRenameService? _renameService; private readonly ILibraryListService _libraryListService; private readonly IAudiobookFilesystemDeleteService _audiobookFilesystemDeleteService; + private readonly LibraryMetadataRescanWorkflow _metadataRescanWorkflow; private readonly string _contentRootPath; /// Initializes a new instance of . /// Repository for audiobook persistence and queries. @@ -83,6 +76,7 @@ public class LibraryController : ControllerBase /// Application path service used to resolve content-root-relative cache files. /// Application service that builds the slim library list payload. /// Application service responsible for safe audiobook filesystem cleanup. + /// API workflow for on-demand audiobook metadata rescans. public LibraryController( IAudiobookRepository repo, IImageCacheService imageCacheService, @@ -96,6 +90,7 @@ public LibraryController( IApplicationPathService applicationPathService, ILibraryListService libraryListService, IAudiobookFilesystemDeleteService audiobookFilesystemDeleteService, + LibraryMetadataRescanWorkflow metadataRescanWorkflow, IScanQueueService? scanQueueService = null, IMoveQueueService? moveQueueService = null, NotificationService? notificationService = null, @@ -120,6 +115,7 @@ public LibraryController( _renameService = renameService; _libraryListService = libraryListService; _audiobookFilesystemDeleteService = audiobookFilesystemDeleteService; + _metadataRescanWorkflow = metadataRescanWorkflow; _contentRootPath = applicationPathService.ContentRootPath; } @@ -861,272 +857,7 @@ public async Task ReplaceAudiobookIdentifiers(int id, [FromBody] [HttpPost("{id}/rescan-metadata")] public async Task RescanAudiobookMetadata(int id) { - using var rescanScope = _scopeFactory.CreateScope(); - var metadataService = rescanScope.ServiceProvider.GetService(); - var metadataConverters = rescanScope.ServiceProvider.GetService(); - - if (metadataService == null || metadataConverters == null) - { - _logger.LogError( - "Metadata rescan services unavailable. MetadataService={HasMetadataService}, MetadataConverters={HasConverters}", - metadataService != null, - metadataConverters != null); - return StatusCode(500, new { message = "Metadata rescan services are not available." }); - } - - var audiobook = await _repo.GetByIdAsync(id); - - if (audiobook == null) - { - return NotFound(new { message = "Audiobook not found" }); - } - - var memoryCache = rescanScope.ServiceProvider.GetService(); - if (memoryCache != null && - !TryConsumeMetadataRescanQuota(memoryCache, HttpContext, audiobook.Id, out var rateLimitMessage, out var retryAfterSeconds)) - { - try - { - Response.Headers["Retry-After"] = retryAfterSeconds.ToString(); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogDebug(ex, "Failed to set Retry-After header for metadata rescan rate-limit response"); - } - - return StatusCode(StatusCodes.Status429TooManyRequests, new - { - message = rateLimitMessage, - retryAfterSeconds - }); - } - - var effectiveIdentifiers = AudiobookIdentifierMapper.GetEffectiveIdentifiers(audiobook); - var asinIdentifiers = effectiveIdentifiers - .Where(i => i.Type == AudiobookExternalIdentifierType.Asin) - .OrderByDescending(i => i.IsPrimary) - .ThenBy(i => i.Source) - .ThenBy(i => i.ValueNormalized) - .ToList(); - - var isbnIdentifiers = effectiveIdentifiers - .Where(i => i.Type == AudiobookExternalIdentifierType.Isbn) - .OrderByDescending(i => i.IsPrimary) - .ThenBy(i => i.Source) - .ThenBy(i => i.ValueNormalized) - .ToList(); - - if (!asinIdentifiers.Any() && !isbnIdentifiers.Any()) - { - return BadRequest(new { message = "No ASIN or ISBN identifiers are available for metadata rescan." }); - } - - var triedAsinKeys = new HashSet(StringComparer.OrdinalIgnoreCase); - var triedAsinDebug = new List(); - var triedIsbnDebug = new List(); - var asinLookupAttempts = 0; - var isbnConversionAttempts = 0; - var asinLookupAttemptCapHit = false; - var isbnConversionAttemptCapHit = false; - - AudibleBookResponse? providerMetadata = null; - string? providerSource = null; - string? resolvedAsin = null; - string? resolvedRegion = null; - - async Task TryMetadataLookupByAsinAsync(string asin, string? preferredRegion, string via) - { - if (!AudiobookIdentifierNormalizer.TryNormalize( - AudiobookExternalIdentifierType.Asin, - asin, - out var normalizedAsin, - out _)) - { - return false; - } - - foreach (var region in EnumerateMetadataRescanRegions(preferredRegion)) - { - var regionValue = string.IsNullOrWhiteSpace(region) ? "us" : region!; - var key = $"{normalizedAsin}|{regionValue}"; - if (!triedAsinKeys.Add(key)) - { - continue; - } - - triedAsinDebug.Add(new { asin = normalizedAsin, region = regionValue, via }); - - if (asinLookupAttempts >= MetadataRescanMaxAsinLookupAttempts) - { - asinLookupAttemptCapHit = true; - return false; - } - - asinLookupAttempts++; - - object? rawResult; - try - { - rawResult = await metadataService.GetMetadataAsync(normalizedAsin, regionValue, cache: false); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning( - ex, - "Metadata rescan lookup failed for audiobook {AudiobookId} ({Title}) ASIN {Asin} region {Region}", - audiobook.Id, - audiobook.Title, - normalizedAsin, - regionValue); - continue; - } - - if (!TryExtractMetadataLookupResult(rawResult, out var extractedMetadata, out var extractedSource) || - extractedMetadata == null) - { - continue; - } - - providerMetadata = extractedMetadata; - providerSource = extractedSource; - resolvedAsin = string.IsNullOrWhiteSpace(extractedMetadata.Asin) ? normalizedAsin : extractedMetadata.Asin; - resolvedRegion = regionValue; - return true; - } - - return false; - } - - foreach (var asinIdentifier in asinIdentifiers) - { - var asinValue = FirstNonEmpty(asinIdentifier.ValueRaw, asinIdentifier.ValueNormalized); - if (string.IsNullOrWhiteSpace(asinValue)) continue; - - if (await TryMetadataLookupByAsinAsync(asinValue, asinIdentifier.Region, "asin")) - { - break; - } - - if (asinLookupAttemptCapHit) - { - break; - } - } - - if (providerMetadata == null) - { - var asinLookupService = rescanScope.ServiceProvider.GetService(); - if (asinLookupService == null) - { - _logger.LogWarning("IAsinLookupService not available for ISBN fallback during metadata rescan of audiobook {AudiobookId}", audiobook.Id); - } - - foreach (var isbnIdentifier in isbnIdentifiers) - { - var isbnValue = FirstNonEmpty(isbnIdentifier.ValueNormalized, isbnIdentifier.ValueRaw); - if (string.IsNullOrWhiteSpace(isbnValue)) continue; - - if (!triedIsbnDebug.Contains(isbnValue, StringComparer.OrdinalIgnoreCase)) - { - triedIsbnDebug.Add(isbnValue); - } - - try - { - if (isbnConversionAttempts >= MetadataRescanMaxIsbnConversionAttempts) - { - isbnConversionAttemptCapHit = true; - break; - } - - if (asinLookupService == null) - { - continue; - } - - isbnConversionAttempts++; - var (success, asinFromIsbn, _) = await asinLookupService.GetAsinFromIsbnAsync(isbnValue); - if (!success || string.IsNullOrWhiteSpace(asinFromIsbn)) - { - continue; - } - - if (await TryMetadataLookupByAsinAsync(asinFromIsbn, null, "isbn")) - { - break; - } - - if (asinLookupAttemptCapHit) - { - break; - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning( - ex, - "Metadata rescan ASIN conversion failed for audiobook {AudiobookId} ISBN {Isbn}", - audiobook.Id, - isbnValue); - } - } - } - - if (providerMetadata == null || string.IsNullOrWhiteSpace(resolvedAsin)) - { - _logger.LogDebug( - "Metadata rescan found no metadata for audiobook {AudiobookId}. TriedAsins={TriedAsins}; TriedIsbns={TriedIsbns}; AsinLookups={AsinLookups}/{AsinCap}; IsbnConversions={IsbnConversions}/{IsbnCap}; Capped={Capped}", - audiobook.Id, - triedAsinDebug, - triedIsbnDebug, - asinLookupAttempts, - MetadataRescanMaxAsinLookupAttempts, - isbnConversionAttempts, - MetadataRescanMaxIsbnConversionAttempts, - asinLookupAttemptCapHit || isbnConversionAttemptCapHit); - - return NotFound(new - { - message = "No metadata found using the available identifiers." - }); - } - - var convertedMetadata = metadataConverters.ConvertAudibleToMetadata( - providerMetadata, - resolvedAsin, - string.IsNullOrWhiteSpace(providerSource) ? "Audible" : providerSource!); - - var legacyIdentifierFieldsTouched = ApplyMetadataRescanPatch(audiobook, convertedMetadata); - - if (!string.IsNullOrWhiteSpace(convertedMetadata.ImageUrl)) - { - audiobook.ImageUrl = await MoveMetadataImageToLibraryStorageAsync(audiobook, convertedMetadata.ImageUrl) - ?? convertedMetadata.ImageUrl; - } - - if (legacyIdentifierFieldsTouched) - { - AudiobookIdentifierMapper.SyncImportedIdentifiersFromLegacyFields(audiobook); - } - - await _repo.UpdateAsync(audiobook); - - _logger.LogInformation( - "Metadata rescan updated audiobook {AudiobookId} ({Title}) using {Source} ASIN {Asin} region {Region}", - audiobook.Id, - audiobook.Title, - providerSource ?? "unknown", - resolvedAsin, - resolvedRegion ?? "us"); - - return Ok(new - { - message = "Metadata rescanned successfully", - audiobookId = audiobook.Id, - source = providerSource, - asin = resolvedAsin, - region = resolvedRegion - }); + return await _metadataRescanWorkflow.RescanAsync(id, HttpContext); } // NOTE: Do not perform ad-hoc schema changes at runtime. Use EF Core migrations to modify the database schema. @@ -2431,386 +2162,6 @@ public async Task RequeueScanJob(string jobId) return Accepted(new { message = "Requeued scan job", jobId = newJobId }); } - private async Task ProcessAudiobookForSearchAsync( - Audiobook audiobook, - ISearchService searchService, - IQualityProfileService qualityProfileService, - IDownloadService downloadService, - IDownloadRepository downloadRepository, - IAudiobookFileRepository audioFileRepository) - { - // Check if quality cutoff is already met - if (await IsQualityCutoffMetAsync(audiobook, qualityProfileService, downloadRepository, audioFileRepository)) - { - _logger.LogInformation("Quality cutoff already met for audiobook '{Title}', skipping search", LogRedaction.SanitizeText(audiobook.Title)); - return 0; - } - - // Build search query - var searchQuery = BuildSearchQuery(audiobook); - _logger.LogInformation("Searching for audiobook '{Title}' with query: {Query}", LogRedaction.SanitizeText(audiobook.Title), LogRedaction.SanitizeText(searchQuery)); - - // Search for results - var searchResults = await searchService.SearchAsync(searchQuery); - _logger.LogInformation("Found {Count} raw search results for audiobook '{Title}'", searchResults.Count, LogRedaction.SanitizeText(audiobook.Title)); - - // Broadcast raw search result summary for manual-triggered searches (helpful for debugging) - try - { - var rawSummaries = searchResults.Take(10).Select(r => new - { - title = r.Title, - asin = r.Asin, - source = r.Source, - sizeMB = r.Size > 0 ? (r.Size / 1024 / 1024) : -1, - seeders = r.Seeders, - format = r.Format, - downloadType = r.DownloadType - }).ToList(); - - using var scope = _scopeFactory.CreateScope(); - var hub = scope.ServiceProvider.GetRequiredService(); - // Include a structured payload so clients can distinguish manual vs automatic searches - await hub.BroadcastAsync(RealtimeHubTarget.Downloads, "SearchProgress", new { message = $"Manual search query: {searchQuery}", details = new { rawCount = searchResults.Count, rawSamples = rawSummaries }, type = "interactive", audiobookId = audiobook.Id }); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogDebug(ex, "Failed to broadcast raw search results summary for manual search audiobook {Id}", audiobook.Id); - } - - if (!searchResults.Any()) - { - _logger.LogInformation("No search results found for audiobook '{Title}'", LogRedaction.SanitizeText(audiobook.Title)); - return 0; - } - - // Score results against quality profile - var scoredResults = await qualityProfileService.ScoreSearchResults(searchResults, audiobook.QualityProfile!); - - // Log all scored results for debugging - _logger.LogInformation("Scored {Count} search results for audiobook '{Title}':", scoredResults.Count, LogRedaction.SanitizeText(audiobook.Title)); - foreach (var scoredResult in scoredResults.OrderByDescending(s => s.TotalScore)) - { - var status = scoredResult.IsRejected ? "REJECTED" : (scoredResult.TotalScore > 0 ? "ACCEPTABLE" : "LOW SCORE"); - _logger.LogInformation(" [{Status}] Score: {Score} | Title: {Title} | Source: {Source} | Size: {Size}MB | Seeders: {Seeders} | Quality: {Quality}", - status, scoredResult.TotalScore, LogRedaction.SanitizeText(scoredResult.SearchResult.Title), LogRedaction.SanitizeText(scoredResult.SearchResult.Source), - scoredResult.SearchResult.Size / 1024 / 1024, scoredResult.SearchResult.Seeders, scoredResult.SearchResult.Quality); - - if (scoredResult.IsRejected && scoredResult.RejectionReasons.Any()) - { - _logger.LogInformation(" Rejection reasons: {Reasons}", string.Join(", ", scoredResult.RejectionReasons)); - } - } - - var topResult = scoredResults - .Where(s => !s.IsRejected && s.TotalScore > 0) // Only results that pass quality filters and are not rejected - .OrderByDescending(s => s.TotalScore) - .FirstOrDefault(); // Pick only the top scoring result - - if (topResult == null) - { - _logger.LogInformation("No acceptable search results found for audiobook '{Title}' after quality filtering", LogRedaction.SanitizeText(audiobook.Title)); - return 0; - } - - _logger.LogInformation("Found top result for audiobook '{Title}': {ResultTitle} (Score: {Score})", - LogRedaction.SanitizeText(audiobook.Title), LogRedaction.SanitizeText(topResult.SearchResult.Title), topResult.TotalScore); - - // Add score to the search result for tracking - topResult.SearchResult.Score = topResult.TotalScore; - - // Queue download for the top result - var downloadsQueued = 0; - try - { - // Determine appropriate download client for this result - var isTorrent = IsTorrentResult(topResult.SearchResult); - var downloadClientId = await GetAppropriateDownloadClientAsync(topResult.SearchResult, isTorrent); - - if (string.IsNullOrEmpty(downloadClientId)) - { - _logger.LogWarning("No suitable download client found for result type: {Type}", isTorrent ? "torrent" : "NZB"); - return 0; - } - - await downloadService.StartDownloadAsync(topResult.SearchResult, downloadClientId, audiobook.Id); - downloadsQueued++; - - _logger.LogInformation("Queued download for audiobook '{Title}': {ResultTitle} (Score: {Score})", - audiobook.Title, topResult.SearchResult.Title, topResult.TotalScore); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogError(ex, "Failed to queue download for audiobook '{Title}': {ResultTitle}", - audiobook.Title, topResult.SearchResult.Title); - } - - return downloadsQueued; - } - - private async Task IsQualityCutoffMetAsync( - Audiobook audiobook, - IQualityProfileService qualityProfileService, - IDownloadRepository downloadRepository, - IAudiobookFileRepository audioFileRepository) - { - if (audiobook.QualityProfile == null) - return false; - - // Get existing downloads for this audiobook - var existingDownloads = (await downloadRepository.GetByAudiobookIdAsync(audiobook.Id)) - .Where(d => d.Status == DownloadStatus.Completed || - d.Status == DownloadStatus.Downloading || - d.Status == DownloadStatus.ImportPending) - .ToList(); - - // Get existing files for this audiobook - var existingFiles = await audioFileRepository.GetByAudiobookIdAsync(audiobook.Id); - - if (!existingDownloads.Any() && !existingFiles.Any()) - return false; - - // Check if any existing download meets or exceeds the cutoff quality - var cutoffQuality = audiobook.QualityProfile.Qualities - .FirstOrDefault(q => q.Quality == audiobook.QualityProfile.CutoffQuality); - - if (cutoffQuality == null) - return false; - - // Check downloads first - foreach (var download in existingDownloads) - { - // For completed downloads, check if the file quality meets cutoff - if (download.Status == DownloadStatus.Completed && !string.IsNullOrEmpty(download.Metadata?.GetValueOrDefault("Quality")?.ToString())) - { - var downloadQuality = download.Metadata["Quality"].ToString(); - var downloadQualityDefinition = audiobook.QualityProfile.Qualities - .FirstOrDefault(q => q.Quality == downloadQuality); - - if (downloadQualityDefinition != null && downloadQualityDefinition.Priority >= cutoffQuality.Priority) - { - _logger.LogDebug("Quality cutoff met for audiobook '{Title}' by completed download (Quality: {Quality})", - audiobook.Title, downloadQuality); - return true; - } - } - // For active downloads, assume they will meet quality requirements - else if (download.Status == DownloadStatus.Downloading || - download.Status == DownloadStatus.ImportPending) - { - _logger.LogDebug("Quality cutoff assumed met for audiobook '{Title}' due to active download/import", LogRedaction.SanitizeText(audiobook.Title)); - return true; - } - } - - // Check existing files - foreach (var file in existingFiles) - { - var fileQuality = DetermineFileQuality(file); - if (!string.IsNullOrEmpty(fileQuality)) - { - var fileQualityDefinition = audiobook.QualityProfile.Qualities - .FirstOrDefault(q => q.Quality == fileQuality); - - if (fileQualityDefinition != null && fileQualityDefinition.Priority >= cutoffQuality.Priority) - { - _logger.LogDebug("Quality cutoff met for audiobook '{Title}' by existing file (Quality: {Quality}, File: {FileName})", - audiobook.Title, fileQuality, Path.GetFileName(file.Path)); - return true; - } - } - } - - return false; - } - - private string? DetermineFileQuality(AudiobookFile file) - { - // Determine quality based on file properties - // This mirrors the logic in QualityProfileService.GetQualityScore but works with file metadata - - // Check format/container first - if (!string.IsNullOrEmpty(file.Container)) - { - var container = file.Container.ToLower(); - if (container.Contains("flac")) return "FLAC"; - if (container.Contains("m4b") || container.Contains("m4a")) return "M4B"; - } - - if (!string.IsNullOrEmpty(file.Format)) - { - var format = file.Format.ToLower(); - if (format.Contains("flac")) return "FLAC"; - if (format.Contains("m4b") || format.Contains("m4a")) return "M4B"; - if (format.Contains("aac")) return "M4B"; // AAC in M4B container - } - - // Check bitrate for MP3 quality determination - if (file.Bitrate.HasValue) - { - var bitrate = file.Bitrate.Value; - - // Convert bits per second to kilobits per second for easier comparison - var kbps = bitrate / 1000; - - if (kbps >= 320) return "MP3 320kbps"; - if (kbps >= 256) return "MP3 256kbps"; - if (kbps >= 192) return "MP3 192kbps"; - if (kbps >= 128) return "MP3 128kbps"; - if (kbps >= 64) return "MP3 64kbps"; - - // For very low bitrates, still classify as MP3 - return "MP3 64kbps"; - } - - // Check codec - if (!string.IsNullOrEmpty(file.Codec)) - { - var codec = file.Codec.ToLower(); - if (codec.Contains("flac")) return "FLAC"; - if (codec.Contains("aac")) return "M4B"; - if (codec.Contains("mp3")) return "MP3 128kbps"; // Default MP3 quality if no bitrate info - if (codec.Contains("opus")) return "M4B"; // Opus is often in M4B containers - } - - // If we can't determine quality from metadata, try to infer from file extension - if (!string.IsNullOrEmpty(file.Path)) - { - var extension = Path.GetExtension(file.Path).ToLower(); - switch (extension) - { - case ".flac": - return "FLAC"; - case ".m4b": - case ".m4a": - return "M4B"; - case ".mp3": - return "MP3 128kbps"; // Conservative default for MP3 - case ".aac": - return "M4B"; - case ".opus": - return "M4B"; - } - } - - return null; // Unable to determine quality - } - - private string BuildSearchQuery(Audiobook audiobook) - { - var parts = new List(); - - // Add title - if (!string.IsNullOrEmpty(audiobook.Title)) - parts.Add(audiobook.Title); - - // Add primary author - if (audiobook.Authors != null && audiobook.Authors.Any()) - parts.Add(audiobook.Authors.First()); - - // Add series if available - if (!string.IsNullOrEmpty(audiobook.Series)) - parts.Add(audiobook.Series); - - return string.Join(" ", parts); - } - - private bool IsTorrentResult(SearchResult result) - { - // Check DownloadType first if it's set - if (!string.IsNullOrEmpty(result.DownloadType)) - { - if (result.DownloadType == "DDL") - { - return false; // DDL is not a torrent - } - else if (result.DownloadType == "Torrent") - { - return true; - } - else if (result.DownloadType == "Usenet") - { - return false; - } - } - - // Fallback to legacy detection logic - // Check for NZB first - if it has an NZB URL, it's a Usenet/NZB download - if (!string.IsNullOrEmpty(result.NzbUrl)) - { - return false; - } - - // Check for torrent indicators - magnet link or torrent file - if (!string.IsNullOrEmpty(result.MagnetLink) || !string.IsNullOrEmpty(result.TorrentUrl)) - { - return true; - } - - // If neither is set, we can't reliably determine the type - // Log a warning and default to false (NZB) as a safer choice - _logger.LogWarning("Unable to determine result type for '{Title}' from source '{Source}'. No MagnetLink, TorrentUrl, or NzbUrl found. Defaulting to NZB.", - result.Title, result.Source); - return false; - } - - private async Task GetAppropriateDownloadClientAsync(SearchResult searchResult, bool isTorrent) - { - using var scope = _scopeFactory.CreateScope(); - var configurationService = scope.ServiceProvider.GetRequiredService(); - - // Special handling for DDL downloads - they don't use external clients - if (searchResult.DownloadType?.Equals("DDL", StringComparison.OrdinalIgnoreCase) == true) - { - _logger.LogInformation("DDL download detected, using internal DDL client"); - return "DDL"; - } - - // Get all configured download clients - var clients = await configurationService.GetDownloadClientConfigurationsAsync(); - var enabledClients = clients.Where(c => c.IsEnabled).ToList(); - - _logger.LogInformation("Looking for {ClientType} client. Found {Count} enabled download clients: {Clients}", - isTorrent ? "torrent" : "NZB", - enabledClients.Count, - string.Join(", ", enabledClients.Select(c => $"{c.Name} ({c.Type})"))); - - if (isTorrent) - { - // Prefer qBittorrent, then Transmission - var client = enabledClients.FirstOrDefault(c => c.Type.Equals("qbittorrent", StringComparison.OrdinalIgnoreCase)) - ?? enabledClients.FirstOrDefault(c => c.Type.Equals("transmission", StringComparison.OrdinalIgnoreCase)); - - if (client != null) - { - _logger.LogInformation("Selected torrent client: {ClientName} ({ClientType})", client.Name, client.Type); - } - else - { - _logger.LogWarning("No torrent client (qBittorrent or Transmission) found among enabled clients"); - } - - return client?.Id ?? string.Empty; - } - else - { - // Prefer SABnzbd, then NZBGet - var client = enabledClients.FirstOrDefault(c => c.Type.Equals("sabnzbd", StringComparison.OrdinalIgnoreCase)) - ?? enabledClients.FirstOrDefault(c => c.Type.Equals("nzbget", StringComparison.OrdinalIgnoreCase)); - - if (client != null) - { - _logger.LogInformation("Selected NZB client: {ClientName} ({ClientType})", client.Name, client.Type); - } - else - { - _logger.LogWarning("No NZB client (SABnzbd or NZBGet) found among enabled clients"); - } - - return client?.Id ?? string.Empty; - } - } - // Helper to convert incoming update values (possibly JsonElement or boxed types) to the target property type private static object? ConvertUpdateValue(object? value, Type targetType) { @@ -2896,301 +2247,6 @@ private static string ComputeShortHash(string? input) return BitConverter.ToString(hash).Replace("-", "").Substring(0, 16).ToLowerInvariant(); } - private static IEnumerable EnumerateMetadataRescanRegions(string? preferredRegion) - { - var seen = new HashSet(StringComparer.OrdinalIgnoreCase); - - var ordered = new List(); - void AddOrdered(string? region) - { - var normalized = AudiobookIdentifierNormalizer.NormalizeRegion(region); - if (string.IsNullOrWhiteSpace(normalized)) return; - if (seen.Add(normalized)) ordered.Add(normalized); - } - - AddOrdered(preferredRegion); - AddOrdered("us"); - AddOrdered("uk"); - - if (ordered.Count == 0) - { - ordered.Add("us"); - } - - return ordered; - } - - private static bool TryExtractMetadataLookupResult( - object? rawResult, - out AudibleBookResponse? metadata, - out string? source) - { - metadata = null; - source = null; - if (rawResult == null) return false; - - if (rawResult is AudibleBookResponse direct) - { - metadata = direct; - return true; - } - - var type = rawResult.GetType(); - var metadataProp = type.GetProperty("metadata", BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase); - if (metadataProp != null) - { - var metadataValue = metadataProp.GetValue(rawResult); - if (metadataValue is AudibleBookResponse audible) - { - metadata = audible; - } - else if (metadataValue is JsonElement metadataElement && metadataElement.ValueKind == JsonValueKind.Object) - { - try - { - metadata = metadataElement.Deserialize(); - } - catch (JsonException) - { - metadata = null; - } - catch (NotSupportedException) - { - metadata = null; - } - } - } - - var sourceProp = type.GetProperty("source", BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase); - if (sourceProp != null) - { - source = sourceProp.GetValue(rawResult)?.ToString(); - } - - return metadata != null; - } - - private static bool ApplyMetadataRescanPatch(Audiobook audiobook, AudibleBookMetadata metadata) - { - var legacyIdentifierFieldsTouched = false; - - if (!string.IsNullOrWhiteSpace(metadata.Title)) audiobook.Title = metadata.Title; - if (!string.IsNullOrWhiteSpace(metadata.Subtitle)) audiobook.Subtitle = metadata.Subtitle; - if (!string.IsNullOrWhiteSpace(metadata.PublishYear)) audiobook.PublishYear = metadata.PublishYear; - if (!string.IsNullOrWhiteSpace(metadata.PublishedDate)) audiobook.PublishedDate = metadata.PublishedDate; - if (!string.IsNullOrWhiteSpace(metadata.Description)) audiobook.Description = metadata.Description; - if (!string.IsNullOrWhiteSpace(metadata.Publisher)) audiobook.Publisher = metadata.Publisher; - if (!string.IsNullOrWhiteSpace(metadata.Language)) audiobook.Language = metadata.Language; - if (metadata.Runtime.HasValue && metadata.Runtime.Value > 0) audiobook.Runtime = metadata.Runtime; - if (!string.IsNullOrWhiteSpace(metadata.Version)) audiobook.Version = metadata.Version; - - if ((metadata.SeriesMemberships != null && metadata.SeriesMemberships.Any()) || - !string.IsNullOrWhiteSpace(metadata.Series) || - !string.IsNullOrWhiteSpace(metadata.SeriesNumber)) - { - // Preserve the user's manually-chosen primary series across a rescan rather than - // reverting to the metadata provider's default (see issue #658). - AudiobookSeriesMembershipHelper.ApplyToAudiobookPreservingPrimary( - audiobook, - metadata.SeriesMemberships, - metadata.Series, - metadata.SeriesNumber); - } - - var authors = NormalizeMetadataStringList( - (metadata.Authors != null && metadata.Authors.Any()) - ? metadata.Authors - : (!string.IsNullOrWhiteSpace(metadata.Author) ? new List { metadata.Author! } : null)); - if (authors.Count > 0) audiobook.Authors = authors; - - var narrators = NormalizeMetadataStringList( - (metadata.Narrators != null && metadata.Narrators.Any()) - ? metadata.Narrators - : (!string.IsNullOrWhiteSpace(metadata.Narrator) ? new List { metadata.Narrator! } : null)); - if (narrators.Count > 0) audiobook.Narrators = narrators; - - var genres = NormalizeMetadataStringList(metadata.Genres); - if (genres.Count > 0) audiobook.Genres = genres; - - var isbns = NormalizeMetadataStringList(metadata.Isbn); - if (isbns.Count > 0) - { - audiobook.Isbn = isbns; - legacyIdentifierFieldsTouched = true; - } - - if (!string.IsNullOrWhiteSpace(metadata.Asin)) - { - audiobook.Asin = metadata.Asin; - legacyIdentifierFieldsTouched = true; - } - - if (!string.IsNullOrWhiteSpace(metadata.OpenLibraryId)) - { - audiobook.OpenLibraryId = metadata.OpenLibraryId; - legacyIdentifierFieldsTouched = true; - } - - return legacyIdentifierFieldsTouched; - } - - private async Task MoveMetadataImageToLibraryStorageAsync(Audiobook audiobook, string imageUrl) - { - if (string.IsNullOrWhiteSpace(imageUrl)) return null; - - try - { - var imageKey = !string.IsNullOrWhiteSpace(audiobook.Asin) - ? audiobook.Asin! - : (audiobook.Isbn != null && audiobook.Isbn.Any(i => !string.IsNullOrWhiteSpace(i)) - ? "img-" + ComputeShortHash(audiobook.Isbn.First(i => !string.IsNullOrWhiteSpace(i))) - : "img-" + ComputeShortHash($"{audiobook.Title}|{audiobook.Authors?.FirstOrDefault()}")); - - var libraryImagePath = await _imageCacheService.MoveToLibraryStorageAsync(imageKey, imageUrl); - if (string.IsNullOrWhiteSpace(libraryImagePath)) - { - return null; - } - - return "/" + libraryImagePath.TrimStart('/'); - } - catch (IOException ex) - { - _logger.LogWarning(ex, "Failed to move rescanned metadata image for audiobook {AudiobookId}", audiobook.Id); - return null; - } - catch (UnauthorizedAccessException ex) - { - _logger.LogWarning(ex, "Failed to move rescanned metadata image for audiobook {AudiobookId}", audiobook.Id); - return null; - } - catch (HttpRequestException ex) - { - _logger.LogWarning(ex, "Failed to move rescanned metadata image for audiobook {AudiobookId}", audiobook.Id); - return null; - } - catch (TaskCanceledException ex) - { - _logger.LogWarning(ex, "Failed to move rescanned metadata image for audiobook {AudiobookId}", audiobook.Id); - return null; - } - catch (InvalidOperationException ex) - { - _logger.LogWarning(ex, "Failed to move rescanned metadata image for audiobook {AudiobookId}", audiobook.Id); - return null; - } - catch (UriFormatException ex) - { - _logger.LogWarning(ex, "Failed to move rescanned metadata image for audiobook {AudiobookId}", audiobook.Id); - return null; - } - } - - private static List NormalizeMetadataStringList(IEnumerable? values) - { - if (values == null) return new List(); - - return values - .Where(v => !string.IsNullOrWhiteSpace(v)) - .Select(v => v.Trim()) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToList(); - } - - private static string? FirstNonEmpty(params string?[] values) - { - var first = values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v)); - return first?.Trim(); - } - - private static bool TryConsumeMetadataRescanQuota( - IMemoryCache cache, - Microsoft.AspNetCore.Http.HttpContext? httpContext, - int audiobookId, - out string message, - out int retryAfterSeconds) - { - message = string.Empty; - retryAfterSeconds = 0; - - var actorKey = BuildMetadataRescanActorKey(httpContext); - var cacheKey = $"metadata-rescan-rate:{audiobookId}:{actorKey}"; - var now = DateTime.UtcNow; - - if (!cache.TryGetValue(cacheKey, out MetadataRescanRateLimitState? state) || state == null) - { - state = new MetadataRescanRateLimitState - { - WindowStartUtc = now, - Count = 0, - LastAttemptUtc = null - }; - } - - if (state.LastAttemptUtc.HasValue) - { - var cooldownRemaining = TimeSpan.FromSeconds(MetadataRescanCooldownSeconds) - (now - state.LastAttemptUtc.Value); - if (cooldownRemaining > TimeSpan.Zero) - { - retryAfterSeconds = Math.Max(1, (int)Math.Ceiling(cooldownRemaining.TotalSeconds)); - message = $"Rescan cooldown active. Please wait {retryAfterSeconds} seconds before rescanning this audiobook again."; - return false; - } - } - - if ((now - state.WindowStartUtc) >= TimeSpan.FromMinutes(MetadataRescanWindowMinutes)) - { - state.WindowStartUtc = now; - state.Count = 0; - } - - if (state.Count >= MetadataRescanMaxRequestsPerWindow) - { - var windowEndsAt = state.WindowStartUtc.AddMinutes(MetadataRescanWindowMinutes); - var remaining = windowEndsAt - now; - retryAfterSeconds = Math.Max(1, (int)Math.Ceiling(remaining.TotalSeconds)); - message = $"Metadata rescan rate limit reached for this audiobook. Try again in {retryAfterSeconds} seconds."; - return false; - } - - state.Count++; - state.LastAttemptUtc = now; - - cache.Set( - cacheKey, - state, - new MemoryCacheEntryOptions - { - AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(MetadataRescanWindowMinutes + 5) - }); - - return true; - } - - private static string BuildMetadataRescanActorKey(Microsoft.AspNetCore.Http.HttpContext? httpContext) - { - var user = httpContext?.User; - var userId = - user?.FindFirst("sub")?.Value ?? - user?.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? - user?.Identity?.Name; - - var remoteIp = httpContext?.Connection?.RemoteIpAddress?.ToString() ?? "unknown"; - - var actorDescriptor = !string.IsNullOrWhiteSpace(userId) - ? $"user:{userId}|ip:{remoteIp}" - : $"ip:{remoteIp}"; - - return ComputeShortHash(actorDescriptor); - } - - private sealed class MetadataRescanRateLimitState - { - public DateTime WindowStartUtc { get; set; } - public int Count { get; set; } - public DateTime? LastAttemptUtc { get; set; } - } - public class BulkDeleteRequest { public List Ids { get; set; } = new List(); diff --git a/listenarr.api/Controllers/LibraryMetadataRescanWorkflow.cs b/listenarr.api/Controllers/LibraryMetadataRescanWorkflow.cs new file mode 100644 index 000000000..5cd53c6b0 --- /dev/null +++ b/listenarr.api/Controllers/LibraryMetadataRescanWorkflow.cs @@ -0,0 +1,632 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Listenarr.Application.Audiobooks; +using Listenarr.Application.Interfaces; +using Listenarr.Application.Interfaces.Repositories; +using Listenarr.Application.Metadata; +using Listenarr.Application.Search; +using Listenarr.Domain.Common; +using Listenarr.Domain.Models; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Caching.Memory; + +namespace Listenarr.Api.Controllers +{ + public sealed class LibraryMetadataRescanWorkflow + { + private const int MetadataRescanCooldownSeconds = 15; + private const int MetadataRescanWindowMinutes = 10; + private const int MetadataRescanMaxRequestsPerWindow = 5; + private const int MetadataRescanMaxAsinLookupAttempts = 8; + private const int MetadataRescanMaxIsbnConversionAttempts = 5; + + private readonly IAudiobookRepository _repo; + private readonly IAudiobookMetadataService _metadataService; + private readonly MetadataConverters _metadataConverters; + private readonly IImageCacheService _imageCacheService; + private readonly ILogger _logger; + private readonly IMemoryCache? _memoryCache; + private readonly IAsinLookupService? _asinLookupService; + + public LibraryMetadataRescanWorkflow( + IAudiobookRepository repo, + IAudiobookMetadataService metadataService, + MetadataConverters metadataConverters, + IImageCacheService imageCacheService, + ILogger logger, + IMemoryCache? memoryCache = null, + IAsinLookupService? asinLookupService = null) + { + _repo = repo; + _metadataService = metadataService; + _metadataConverters = metadataConverters; + _imageCacheService = imageCacheService; + _logger = logger; + _memoryCache = memoryCache; + _asinLookupService = asinLookupService; + } + + public async Task RescanAsync(int id, HttpContext httpContext) + { + var audiobook = await _repo.GetByIdAsync(id); + + if (audiobook == null) + { + return new NotFoundObjectResult(new { message = "Audiobook not found" }); + } + + if (_memoryCache != null && + !TryConsumeMetadataRescanQuota(_memoryCache, httpContext, audiobook.Id, out var rateLimitMessage, out var retryAfterSeconds)) + { + try + { + httpContext.Response.Headers["Retry-After"] = retryAfterSeconds.ToString(); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogDebug(ex, "Failed to set Retry-After header for metadata rescan rate-limit response"); + } + + return new ObjectResult(new + { + message = rateLimitMessage, + retryAfterSeconds + }) + { + StatusCode = StatusCodes.Status429TooManyRequests + }; + } + + var effectiveIdentifiers = AudiobookIdentifierMapper.GetEffectiveIdentifiers(audiobook); + var asinIdentifiers = effectiveIdentifiers + .Where(i => i.Type == AudiobookExternalIdentifierType.Asin) + .OrderByDescending(i => i.IsPrimary) + .ThenBy(i => i.Source) + .ThenBy(i => i.ValueNormalized) + .ToList(); + + var isbnIdentifiers = effectiveIdentifiers + .Where(i => i.Type == AudiobookExternalIdentifierType.Isbn) + .OrderByDescending(i => i.IsPrimary) + .ThenBy(i => i.Source) + .ThenBy(i => i.ValueNormalized) + .ToList(); + + if (!asinIdentifiers.Any() && !isbnIdentifiers.Any()) + { + return new BadRequestObjectResult(new { message = "No ASIN or ISBN identifiers are available for metadata rescan." }); + } + + var triedAsinKeys = new HashSet(StringComparer.OrdinalIgnoreCase); + var triedAsinDebug = new List(); + var triedIsbnDebug = new List(); + var asinLookupAttempts = 0; + var isbnConversionAttempts = 0; + var asinLookupAttemptCapHit = false; + var isbnConversionAttemptCapHit = false; + + AudibleBookResponse? providerMetadata = null; + string? providerSource = null; + string? resolvedAsin = null; + string? resolvedRegion = null; + + async Task TryMetadataLookupByAsinAsync(string asin, string? preferredRegion, string via) + { + if (!AudiobookIdentifierNormalizer.TryNormalize( + AudiobookExternalIdentifierType.Asin, + asin, + out var normalizedAsin, + out _)) + { + return false; + } + + foreach (var region in EnumerateMetadataRescanRegions(preferredRegion)) + { + var regionValue = string.IsNullOrWhiteSpace(region) ? "us" : region!; + var key = $"{normalizedAsin}|{regionValue}"; + if (!triedAsinKeys.Add(key)) + { + continue; + } + + triedAsinDebug.Add(new { asin = normalizedAsin, region = regionValue, via }); + + if (asinLookupAttempts >= MetadataRescanMaxAsinLookupAttempts) + { + asinLookupAttemptCapHit = true; + return false; + } + + asinLookupAttempts++; + + object? rawResult; + try + { + rawResult = await _metadataService.GetMetadataAsync(normalizedAsin, regionValue, cache: false); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning( + ex, + "Metadata rescan lookup failed for audiobook {AudiobookId} ({Title}) ASIN {Asin} region {Region}", + audiobook.Id, + audiobook.Title, + normalizedAsin, + regionValue); + continue; + } + + if (!TryExtractMetadataLookupResult(rawResult, out var extractedMetadata, out var extractedSource) || + extractedMetadata == null) + { + continue; + } + + providerMetadata = extractedMetadata; + providerSource = extractedSource; + resolvedAsin = string.IsNullOrWhiteSpace(extractedMetadata.Asin) ? normalizedAsin : extractedMetadata.Asin; + resolvedRegion = regionValue; + return true; + } + + return false; + } + + foreach (var asinIdentifier in asinIdentifiers) + { + var asinValue = FirstNonEmpty(asinIdentifier.ValueRaw, asinIdentifier.ValueNormalized); + if (string.IsNullOrWhiteSpace(asinValue)) continue; + + if (await TryMetadataLookupByAsinAsync(asinValue, asinIdentifier.Region, "asin")) + { + break; + } + + if (asinLookupAttemptCapHit) + { + break; + } + } + + if (providerMetadata == null) + { + if (_asinLookupService == null) + { + _logger.LogWarning("IAsinLookupService not available for ISBN fallback during metadata rescan of audiobook {AudiobookId}", audiobook.Id); + } + + foreach (var isbnIdentifier in isbnIdentifiers) + { + var isbnValue = FirstNonEmpty(isbnIdentifier.ValueNormalized, isbnIdentifier.ValueRaw); + if (string.IsNullOrWhiteSpace(isbnValue)) continue; + + if (!triedIsbnDebug.Contains(isbnValue, StringComparer.OrdinalIgnoreCase)) + { + triedIsbnDebug.Add(isbnValue); + } + + try + { + if (isbnConversionAttempts >= MetadataRescanMaxIsbnConversionAttempts) + { + isbnConversionAttemptCapHit = true; + break; + } + + if (_asinLookupService == null) + { + continue; + } + + isbnConversionAttempts++; + var (success, asinFromIsbn, _) = await _asinLookupService.GetAsinFromIsbnAsync(isbnValue); + if (!success || string.IsNullOrWhiteSpace(asinFromIsbn)) + { + continue; + } + + if (await TryMetadataLookupByAsinAsync(asinFromIsbn, null, "isbn")) + { + break; + } + + if (asinLookupAttemptCapHit) + { + break; + } + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning( + ex, + "Metadata rescan ASIN conversion failed for audiobook {AudiobookId} ISBN {Isbn}", + audiobook.Id, + isbnValue); + } + } + } + + if (providerMetadata == null || string.IsNullOrWhiteSpace(resolvedAsin)) + { + _logger.LogDebug( + "Metadata rescan found no metadata for audiobook {AudiobookId}. TriedAsins={TriedAsins}; TriedIsbns={TriedIsbns}; AsinLookups={AsinLookups}/{AsinCap}; IsbnConversions={IsbnConversions}/{IsbnCap}; Capped={Capped}", + audiobook.Id, + triedAsinDebug, + triedIsbnDebug, + asinLookupAttempts, + MetadataRescanMaxAsinLookupAttempts, + isbnConversionAttempts, + MetadataRescanMaxIsbnConversionAttempts, + asinLookupAttemptCapHit || isbnConversionAttemptCapHit); + + return new NotFoundObjectResult(new + { + message = "No metadata found using the available identifiers." + }); + } + + var convertedMetadata = _metadataConverters.ConvertAudibleToMetadata( + providerMetadata, + resolvedAsin, + string.IsNullOrWhiteSpace(providerSource) ? "Audible" : providerSource!); + + var legacyIdentifierFieldsTouched = ApplyMetadataRescanPatch(audiobook, convertedMetadata); + + if (!string.IsNullOrWhiteSpace(convertedMetadata.ImageUrl)) + { + audiobook.ImageUrl = await MoveMetadataImageToLibraryStorageAsync(audiobook, convertedMetadata.ImageUrl) + ?? convertedMetadata.ImageUrl; + } + + if (legacyIdentifierFieldsTouched) + { + AudiobookIdentifierMapper.SyncImportedIdentifiersFromLegacyFields(audiobook); + } + + await _repo.UpdateAsync(audiobook); + + _logger.LogInformation( + "Metadata rescan updated audiobook {AudiobookId} ({Title}) using {Source} ASIN {Asin} region {Region}", + audiobook.Id, + audiobook.Title, + providerSource ?? "unknown", + resolvedAsin, + resolvedRegion ?? "us"); + + return new OkObjectResult(new + { + message = "Metadata rescanned successfully", + audiobookId = audiobook.Id, + source = providerSource, + asin = resolvedAsin, + region = resolvedRegion + }); + } + + private static IEnumerable EnumerateMetadataRescanRegions(string? preferredRegion) + { + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + var ordered = new List(); + void AddOrdered(string? region) + { + var normalized = AudiobookIdentifierNormalizer.NormalizeRegion(region); + if (string.IsNullOrWhiteSpace(normalized)) return; + if (seen.Add(normalized)) ordered.Add(normalized); + } + + AddOrdered(preferredRegion); + AddOrdered("us"); + AddOrdered("uk"); + + if (ordered.Count == 0) + { + ordered.Add("us"); + } + + return ordered; + } + + private static bool TryExtractMetadataLookupResult( + object? rawResult, + out AudibleBookResponse? metadata, + out string? source) + { + metadata = null; + source = null; + if (rawResult == null) return false; + + if (rawResult is AudibleBookResponse direct) + { + metadata = direct; + return true; + } + + var type = rawResult.GetType(); + var metadataProp = type.GetProperty("metadata", BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase); + if (metadataProp != null) + { + var metadataValue = metadataProp.GetValue(rawResult); + if (metadataValue is AudibleBookResponse audible) + { + metadata = audible; + } + else if (metadataValue is JsonElement metadataElement && metadataElement.ValueKind == JsonValueKind.Object) + { + try + { + metadata = metadataElement.Deserialize(); + } + catch (JsonException) + { + metadata = null; + } + catch (NotSupportedException) + { + metadata = null; + } + } + } + + var sourceProp = type.GetProperty("source", BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase); + if (sourceProp != null) + { + source = sourceProp.GetValue(rawResult)?.ToString(); + } + + return metadata != null; + } + + private static bool ApplyMetadataRescanPatch(Audiobook audiobook, AudibleBookMetadata metadata) + { + var legacyIdentifierFieldsTouched = false; + + if (!string.IsNullOrWhiteSpace(metadata.Title)) audiobook.Title = metadata.Title; + if (!string.IsNullOrWhiteSpace(metadata.Subtitle)) audiobook.Subtitle = metadata.Subtitle; + if (!string.IsNullOrWhiteSpace(metadata.PublishYear)) audiobook.PublishYear = metadata.PublishYear; + if (!string.IsNullOrWhiteSpace(metadata.PublishedDate)) audiobook.PublishedDate = metadata.PublishedDate; + if (!string.IsNullOrWhiteSpace(metadata.Description)) audiobook.Description = metadata.Description; + if (!string.IsNullOrWhiteSpace(metadata.Publisher)) audiobook.Publisher = metadata.Publisher; + if (!string.IsNullOrWhiteSpace(metadata.Language)) audiobook.Language = metadata.Language; + if (metadata.Runtime.HasValue && metadata.Runtime.Value > 0) audiobook.Runtime = metadata.Runtime; + if (!string.IsNullOrWhiteSpace(metadata.Version)) audiobook.Version = metadata.Version; + + if ((metadata.SeriesMemberships != null && metadata.SeriesMemberships.Any()) || + !string.IsNullOrWhiteSpace(metadata.Series) || + !string.IsNullOrWhiteSpace(metadata.SeriesNumber)) + { + // Preserve the user's manually-chosen primary series across a rescan rather than + // reverting to the metadata provider's default (see issue #658). + AudiobookSeriesMembershipHelper.ApplyToAudiobookPreservingPrimary( + audiobook, + metadata.SeriesMemberships, + metadata.Series, + metadata.SeriesNumber); + } + + var authors = NormalizeMetadataStringList( + (metadata.Authors != null && metadata.Authors.Any()) + ? metadata.Authors + : (!string.IsNullOrWhiteSpace(metadata.Author) ? new List { metadata.Author! } : null)); + if (authors.Count > 0) audiobook.Authors = authors; + + var narrators = NormalizeMetadataStringList( + (metadata.Narrators != null && metadata.Narrators.Any()) + ? metadata.Narrators + : (!string.IsNullOrWhiteSpace(metadata.Narrator) ? new List { metadata.Narrator! } : null)); + if (narrators.Count > 0) audiobook.Narrators = narrators; + + var genres = NormalizeMetadataStringList(metadata.Genres); + if (genres.Count > 0) audiobook.Genres = genres; + + var isbns = NormalizeMetadataStringList(metadata.Isbn); + if (isbns.Count > 0) + { + audiobook.Isbn = isbns; + legacyIdentifierFieldsTouched = true; + } + + if (!string.IsNullOrWhiteSpace(metadata.Asin)) + { + audiobook.Asin = metadata.Asin; + legacyIdentifierFieldsTouched = true; + } + + if (!string.IsNullOrWhiteSpace(metadata.OpenLibraryId)) + { + audiobook.OpenLibraryId = metadata.OpenLibraryId; + legacyIdentifierFieldsTouched = true; + } + + return legacyIdentifierFieldsTouched; + } + + private async Task MoveMetadataImageToLibraryStorageAsync(Audiobook audiobook, string imageUrl) + { + if (string.IsNullOrWhiteSpace(imageUrl)) return null; + + try + { + var imageKey = !string.IsNullOrWhiteSpace(audiobook.Asin) + ? audiobook.Asin! + : (audiobook.Isbn != null && audiobook.Isbn.Any(i => !string.IsNullOrWhiteSpace(i)) + ? "img-" + ComputeShortHash(audiobook.Isbn.First(i => !string.IsNullOrWhiteSpace(i))) + : "img-" + ComputeShortHash($"{audiobook.Title}|{audiobook.Authors?.FirstOrDefault()}")); + + var libraryImagePath = await _imageCacheService.MoveToLibraryStorageAsync(imageKey, imageUrl); + if (string.IsNullOrWhiteSpace(libraryImagePath)) + { + return null; + } + + return "/" + libraryImagePath.TrimStart('/'); + } + catch (IOException ex) + { + _logger.LogWarning(ex, "Failed to move rescanned metadata image for audiobook {AudiobookId}", audiobook.Id); + return null; + } + catch (UnauthorizedAccessException ex) + { + _logger.LogWarning(ex, "Failed to move rescanned metadata image for audiobook {AudiobookId}", audiobook.Id); + return null; + } + catch (HttpRequestException ex) + { + _logger.LogWarning(ex, "Failed to move rescanned metadata image for audiobook {AudiobookId}", audiobook.Id); + return null; + } + catch (TaskCanceledException ex) + { + _logger.LogWarning(ex, "Failed to move rescanned metadata image for audiobook {AudiobookId}", audiobook.Id); + return null; + } + catch (InvalidOperationException ex) + { + _logger.LogWarning(ex, "Failed to move rescanned metadata image for audiobook {AudiobookId}", audiobook.Id); + return null; + } + catch (UriFormatException ex) + { + _logger.LogWarning(ex, "Failed to move rescanned metadata image for audiobook {AudiobookId}", audiobook.Id); + return null; + } + } + + private static List NormalizeMetadataStringList(IEnumerable? values) + { + if (values == null) return new List(); + + return values + .Where(v => !string.IsNullOrWhiteSpace(v)) + .Select(v => v.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + private static string? FirstNonEmpty(params string?[] values) + { + var first = values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v)); + return first?.Trim(); + } + + private static bool TryConsumeMetadataRescanQuota( + IMemoryCache cache, + HttpContext? httpContext, + int audiobookId, + out string message, + out int retryAfterSeconds) + { + message = string.Empty; + retryAfterSeconds = 0; + + var actorKey = BuildMetadataRescanActorKey(httpContext); + var cacheKey = $"metadata-rescan-rate:{audiobookId}:{actorKey}"; + var now = DateTime.UtcNow; + + if (!cache.TryGetValue(cacheKey, out MetadataRescanRateLimitState? state) || state == null) + { + state = new MetadataRescanRateLimitState + { + WindowStartUtc = now, + Count = 0, + LastAttemptUtc = null + }; + } + + if (state.LastAttemptUtc.HasValue) + { + var cooldownRemaining = TimeSpan.FromSeconds(MetadataRescanCooldownSeconds) - (now - state.LastAttemptUtc.Value); + if (cooldownRemaining > TimeSpan.Zero) + { + retryAfterSeconds = Math.Max(1, (int)Math.Ceiling(cooldownRemaining.TotalSeconds)); + message = $"Rescan cooldown active. Please wait {retryAfterSeconds} seconds before rescanning this audiobook again."; + return false; + } + } + + if ((now - state.WindowStartUtc) >= TimeSpan.FromMinutes(MetadataRescanWindowMinutes)) + { + state.WindowStartUtc = now; + state.Count = 0; + } + + if (state.Count >= MetadataRescanMaxRequestsPerWindow) + { + var windowEndsAt = state.WindowStartUtc.AddMinutes(MetadataRescanWindowMinutes); + var remaining = windowEndsAt - now; + retryAfterSeconds = Math.Max(1, (int)Math.Ceiling(remaining.TotalSeconds)); + message = $"Metadata rescan rate limit reached for this audiobook. Try again in {retryAfterSeconds} seconds."; + return false; + } + + state.Count++; + state.LastAttemptUtc = now; + + cache.Set( + cacheKey, + state, + new MemoryCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(MetadataRescanWindowMinutes + 5) + }); + + return true; + } + + private static string BuildMetadataRescanActorKey(HttpContext? httpContext) + { + var user = httpContext?.User; + var userId = + user?.FindFirst("sub")?.Value ?? + user?.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? + user?.Identity?.Name; + + var remoteIp = httpContext?.Connection?.RemoteIpAddress?.ToString() ?? "unknown"; + + var actorDescriptor = !string.IsNullOrWhiteSpace(userId) + ? $"user:{userId}|ip:{remoteIp}" + : $"ip:{remoteIp}"; + + return ComputeShortHash(actorDescriptor); + } + + private static string ComputeShortHash(string? input) + { + if (string.IsNullOrEmpty(input)) + return Guid.NewGuid().ToString("N").Substring(0, 12); + + var bytes = Encoding.UTF8.GetBytes(input); + var hash = SHA1.HashData(bytes); + return BitConverter.ToString(hash).Replace("-", "").Substring(0, 16).ToLowerInvariant(); + } + + private sealed class MetadataRescanRateLimitState + { + public DateTime WindowStartUtc { get; set; } + public int Count { get; set; } + public DateTime? LastAttemptUtc { get; set; } + } + } +} diff --git a/listenarr.api/Controllers/SearchController.cs b/listenarr.api/Controllers/SearchController.cs index 8ec3ef67c..0d26cb23e 100644 --- a/listenarr.api/Controllers/SearchController.cs +++ b/listenarr.api/Controllers/SearchController.cs @@ -59,48 +59,6 @@ public SearchController( private string BuildApiImagePath(string identifier, string? sourceUrl = null) => HttpApiVersionUtils.BuildImagePath(identifier, HttpContext, sourceUrl: sourceUrl); - private async Task NormalizeSearchResultImagesAsync(List results) - { - if (_imageCacheService == null || results == null) return; - - foreach (var r in results) - { - try - { - if (r == null) continue; - if (string.IsNullOrWhiteSpace(r.Asin)) continue; - - // If we already have a cached path, map to API endpoint - var cached = await _imageCacheService.GetCachedImagePathAsync(r.Asin); - if (!string.IsNullOrWhiteSpace(cached)) - { - r.ImageUrl = BuildApiImagePath(r.Asin); - continue; - } - - // If the result includes an external HTTP(S) image URL, try - // to download and cache it using the ASIN as identifier. - if (!string.IsNullOrWhiteSpace(r.ImageUrl) && (r.ImageUrl.StartsWith("http://") || r.ImageUrl.StartsWith("https://"))) - { - var downloaded = await _imageCacheService.DownloadAndCacheImageAsync(r.ImageUrl, r.Asin); - r.ImageUrl = !string.IsNullOrWhiteSpace(downloaded) - ? BuildApiImagePath(r.Asin) - : BuildApiImagePath(r.Asin, r.ImageUrl); - } - // If no external URL was present, map to API endpoint if ASIN present - else if (!string.IsNullOrWhiteSpace(r.Asin)) - { - r.ImageUrl = BuildApiImagePath(r.Asin); - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Failed to normalize image for search result ASIN {Asin}", r.Asin); - } - } - } - - private List SimplifySearchResults(List results) { return results?.Select(r => new @@ -164,41 +122,13 @@ public async Task> Search([FromBody] JsonElement reqJson, [ var language = string.IsNullOrWhiteSpace(req.Language) ? null : req.Language; var results = await _searchService.IntelligentSearchAsync(q, region: region, language: language, ct: HttpContext.RequestAborted) ?? new List(); - // Normalize images for metadata results so the SPA receives local /api/v{version}/images/{asin} when possible - if (_imageCacheService != null && results != null) - { - foreach (var r in results) - { - try - { - if (r == null) continue; - if (string.IsNullOrWhiteSpace(r.Asin)) continue; - - var cached = await _imageCacheService.GetCachedImagePathAsync(r.Asin); - if (!string.IsNullOrWhiteSpace(cached)) - { - r.ImageUrl = BuildApiImagePath(r.Asin); - continue; - } - - if (!string.IsNullOrWhiteSpace(r.ImageUrl) && (r.ImageUrl.StartsWith("http://") || r.ImageUrl.StartsWith("https://"))) - { - var downloaded = await _imageCacheService.DownloadAndCacheImageAsync(r.ImageUrl, r.Asin); - r.ImageUrl = !string.IsNullOrWhiteSpace(downloaded) - ? BuildApiImagePath(r.Asin) - : BuildApiImagePath(r.Asin, r.ImageUrl); - } - else if (!string.IsNullOrWhiteSpace(r.Asin)) - { - r.ImageUrl = BuildApiImagePath(r.Asin); - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Failed to normalize image for metadata result ASIN {Asin}", r.Asin); - } - } - } + await SearchResultImageNormalizer.NormalizeMetadataResultsAsync( + results, + _imageCacheService, + HttpContext, + _logger, + "metadata result", + setApiPathWhenNoExternalImage: true); // Map metadata results into Audible-shaped objects for public API consumers var mapped = await Task.WhenAll((results ?? new List()).Select(r => MapMetadataResultToAudibleAsync(r, region))).ConfigureAwait(false); @@ -292,28 +222,13 @@ public async Task> Search([FromBody] JsonElement reqJson, [ SanitizeResultForPublicApi(sr, region); // Convert to metadata result and normalize images for API response var md = SearchResultConverters.ToMetadata(sr); - if (_imageCacheService != null && !string.IsNullOrWhiteSpace(md.Asin)) - { - try - { - var cached = await _imageCacheService.GetCachedImagePathAsync(md.Asin); - if (!string.IsNullOrWhiteSpace(cached)) - { - md.ImageUrl = BuildApiImagePath(md.Asin); - } - else if (!string.IsNullOrWhiteSpace(md.ImageUrl) && (md.ImageUrl.StartsWith("http://") || md.ImageUrl.StartsWith("https://"))) - { - var downloaded = await _imageCacheService.DownloadAndCacheImageAsync(md.ImageUrl, md.Asin); - md.ImageUrl = !string.IsNullOrWhiteSpace(downloaded) - ? BuildApiImagePath(md.Asin) - : BuildApiImagePath(md.Asin, md.ImageUrl); - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Failed to normalize image for ASIN metadata {Asin}", md?.Asin); - } - } + await SearchResultImageNormalizer.NormalizeMetadataResultAsync( + md, + _imageCacheService, + HttpContext, + _logger, + "ASIN metadata", + setApiPathWhenNoExternalImage: false); if (md != null) { var result = SearchResultConverters.ToSearchResult(md); diff --git a/listenarr.api/Controllers/SearchResultImageNormalizer.cs b/listenarr.api/Controllers/SearchResultImageNormalizer.cs new file mode 100644 index 000000000..d839b602a --- /dev/null +++ b/listenarr.api/Controllers/SearchResultImageNormalizer.cs @@ -0,0 +1,101 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +using Listenarr.Application.Interfaces; +using Listenarr.Domain.Models; + +namespace Listenarr.Api.Controllers +{ + internal static class SearchResultImageNormalizer + { + public static async Task NormalizeMetadataResultsAsync( + IEnumerable? results, + IImageCacheService? imageCacheService, + HttpContext httpContext, + Microsoft.Extensions.Logging.ILogger logger, + string logLabel, + bool setApiPathWhenNoExternalImage) + { + if (imageCacheService == null || results == null) + { + return; + } + + foreach (var result in results) + { + await NormalizeMetadataResultAsync( + result, + imageCacheService, + httpContext, + logger, + logLabel, + setApiPathWhenNoExternalImage); + } + } + + public static async Task NormalizeMetadataResultAsync( + MetadataSearchResult? result, + IImageCacheService? imageCacheService, + HttpContext httpContext, + Microsoft.Extensions.Logging.ILogger logger, + string logLabel, + bool setApiPathWhenNoExternalImage) + { + if (imageCacheService == null || result == null || string.IsNullOrWhiteSpace(result.Asin)) + { + return; + } + + try + { + var cached = await imageCacheService.GetCachedImagePathAsync(result.Asin); + if (!string.IsNullOrWhiteSpace(cached)) + { + result.ImageUrl = BuildApiImagePath(result.Asin, httpContext); + return; + } + + if (!string.IsNullOrWhiteSpace(result.ImageUrl) && IsExternalHttpUrl(result.ImageUrl)) + { + var downloaded = await imageCacheService.DownloadAndCacheImageAsync(result.ImageUrl, result.Asin); + result.ImageUrl = !string.IsNullOrWhiteSpace(downloaded) + ? BuildApiImagePath(result.Asin, httpContext) + : BuildApiImagePath(result.Asin, httpContext, result.ImageUrl); + } + else if (setApiPathWhenNoExternalImage) + { + result.ImageUrl = BuildApiImagePath(result.Asin, httpContext); + } + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + logger.LogWarning(ex, "Failed to normalize image for {LogLabel} ASIN {Asin}", logLabel, result.Asin); + } + } + + private static bool IsExternalHttpUrl(string url) + { + return url.StartsWith("http://") || url.StartsWith("https://"); + } + + private static string BuildApiImagePath(string identifier, HttpContext httpContext, string? sourceUrl = null) + { + return HttpApiVersionUtils.BuildImagePath(identifier, httpContext, sourceUrl: sourceUrl); + } + } +} diff --git a/listenarr.api/Program.cs b/listenarr.api/Program.cs index 8f9f93acd..71bbf401e 100644 --- a/listenarr.api/Program.cs +++ b/listenarr.api/Program.cs @@ -344,6 +344,7 @@ ex is IOException // Add ASIN search handler builder.Services.AddScoped(); +builder.Services.AddScoped(); // Register named HttpClients for each adapter type so adapter implementations can request the appropriately-configured client. builder.Services.AddListenarrHttpClients(builder.Configuration); diff --git a/listenarr.application/Audiobooks/AudiobookQualityCutoffEvaluator.cs b/listenarr.application/Audiobooks/AudiobookQualityCutoffEvaluator.cs new file mode 100644 index 000000000..f00ecb2b7 --- /dev/null +++ b/listenarr.application/Audiobooks/AudiobookQualityCutoffEvaluator.cs @@ -0,0 +1,173 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +using Listenarr.Application.Interfaces.Repositories; +using Listenarr.Application.Security; +using Listenarr.Domain.Models; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Application.Audiobooks +{ + public static class AudiobookQualityCutoffEvaluator + { + public static async Task IsQualityCutoffMetAsync( + Audiobook audiobook, + IDownloadRepository downloadRepository, + IAudiobookFileRepository audioFileRepository, + ILogger? logger = null) + { + if (audiobook.QualityProfile == null) + { + return false; + } + + var existingDownloads = (await downloadRepository.GetByAudiobookIdAsync(audiobook.Id)) + .Where(d => d.Status == DownloadStatus.Completed || + d.Status == DownloadStatus.Downloading || + d.Status == DownloadStatus.ImportPending) + .ToList(); + + var existingFiles = await audioFileRepository.GetByAudiobookIdAsync(audiobook.Id); + + if (!existingDownloads.Any() && !existingFiles.Any()) + { + return false; + } + + var cutoffQuality = audiobook.QualityProfile.Qualities + .FirstOrDefault(q => q.Quality == audiobook.QualityProfile.CutoffQuality); + + if (cutoffQuality == null) + { + return false; + } + + foreach (var download in existingDownloads) + { + if (download.Status == DownloadStatus.Completed && + !string.IsNullOrEmpty(download.Metadata?.GetValueOrDefault("Quality")?.ToString())) + { + var downloadQuality = download.Metadata["Quality"].ToString(); + var downloadQualityDefinition = audiobook.QualityProfile.Qualities + .FirstOrDefault(q => q.Quality == downloadQuality); + + if (downloadQualityDefinition != null && downloadQualityDefinition.Priority >= cutoffQuality.Priority) + { + logger?.LogDebug( + "Quality cutoff met for audiobook '{Title}' by completed download (Quality: {Quality})", + audiobook.Title, + downloadQuality); + return true; + } + } + else if (download.Status == DownloadStatus.Downloading || + download.Status == DownloadStatus.ImportPending) + { + logger?.LogDebug( + "Quality cutoff assumed met for audiobook '{Title}' due to active download/import", + LogRedaction.SanitizeText(audiobook.Title)); + return true; + } + } + + foreach (var file in existingFiles) + { + var fileQuality = DetermineFileQuality(file); + if (string.IsNullOrEmpty(fileQuality)) + { + continue; + } + + var fileQualityDefinition = audiobook.QualityProfile.Qualities + .FirstOrDefault(q => q.Quality == fileQuality); + + if (fileQualityDefinition != null && fileQualityDefinition.Priority >= cutoffQuality.Priority) + { + logger?.LogDebug( + "Quality cutoff met for audiobook '{Title}' by existing file (Quality: {Quality}, File: {FileName})", + audiobook.Title, + fileQuality, + Path.GetFileName(file.Path)); + return true; + } + } + + return false; + } + + private static string? DetermineFileQuality(AudiobookFile file) + { + if (!string.IsNullOrEmpty(file.Container)) + { + var container = file.Container.ToLower(); + if (container.Contains("flac")) return "FLAC"; + if (container.Contains("m4b") || container.Contains("m4a")) return "M4B"; + } + + if (!string.IsNullOrEmpty(file.Format)) + { + var format = file.Format.ToLower(); + if (format.Contains("flac")) return "FLAC"; + if (format.Contains("m4b") || format.Contains("m4a")) return "M4B"; + if (format.Contains("aac")) return "M4B"; + } + + if (file.Bitrate.HasValue) + { + var kbps = file.Bitrate.Value / 1000; + + if (kbps >= 320) return "MP3 320kbps"; + if (kbps >= 256) return "MP3 256kbps"; + if (kbps >= 192) return "MP3 192kbps"; + if (kbps >= 128) return "MP3 128kbps"; + if (kbps >= 64) return "MP3 64kbps"; + + return "MP3 64kbps"; + } + + if (!string.IsNullOrEmpty(file.Codec)) + { + var codec = file.Codec.ToLower(); + if (codec.Contains("flac")) return "FLAC"; + if (codec.Contains("aac")) return "M4B"; + if (codec.Contains("mp3")) return "MP3 128kbps"; + if (codec.Contains("opus")) return "M4B"; + } + + if (!string.IsNullOrEmpty(file.Path)) + { + var extension = Path.GetExtension(file.Path).ToLower(); + switch (extension) + { + case ".flac": + return "FLAC"; + case ".m4b": + case ".m4a": + return "M4B"; + case ".mp3": + return "MP3 128kbps"; + case ".aac": + case ".opus": + return "M4B"; + } + } + + return null; + } + } +} diff --git a/listenarr.application/Metadata/AudibleProductMapper.cs b/listenarr.application/Metadata/AudibleProductMapper.cs new file mode 100644 index 000000000..4e68e1e54 --- /dev/null +++ b/listenarr.application/Metadata/AudibleProductMapper.cs @@ -0,0 +1,250 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +using System.Text.Json; + +namespace Listenarr.Application.Metadata +{ + internal static class AudibleProductMapper + { + public static AudibleBookResponse? MapProductToBookResponse(JsonElement product, string region) + { + if (product.ValueKind != JsonValueKind.Object) + { + return null; + } + + var asin = GetString(product, "asin"); + if (string.IsNullOrWhiteSpace(asin)) + { + return null; + } + + return new AudibleBookResponse + { + Asin = asin, + Title = GetString(product, "title"), + Subtitle = GetString(product, "subtitle"), + Authors = GetArray(product, "authors") + .Select(author => new AudibleAuthor + { + Asin = GetString(author, "asin"), + Name = GetString(author, "name"), + Region = AudibleRequestHelper.NormalizeRegion(region) + }) + .Where(author => !string.IsNullOrWhiteSpace(author.Name)) + .ToList(), + Narrators = GetArray(product, "narrators") + .Select(narrator => new AudibleNarrator + { + Name = GetString(narrator, "name") + }) + .Where(narrator => !string.IsNullOrWhiteSpace(narrator.Name)) + .ToList(), + Publisher = GetString(product, "publisher_name"), + PublishDate = GetString(product, "publication_datetime"), + Description = GetString(product, "publisher_summary") + ?? GetString(product, "merchandising_summary") + ?? GetString(product, "extended_product_description") + ?? GetString(product, "merchandising_description"), + ImageUrl = GetHighestResolutionImage(product), + LengthMinutes = GetInt32(product, "runtime_length_min"), + Language = GetString(product, "language"), + Genres = MapGenres(product), + Series = GetArray(product, "series") + .Select(series => new AudibleSeries + { + Asin = GetString(series, "asin"), + Name = GetString(series, "title"), + Position = GetString(series, "sequence") + }) + .Where(series => !string.IsNullOrWhiteSpace(series.Name)) + .ToList(), + Explicit = GetBoolean(product, "is_adult_product"), + ReleaseDate = GetString(product, "release_date"), + Isbn = GetString(product, "isbn"), + Region = AudibleRequestHelper.NormalizeRegion(region), + BookFormat = GetString(product, "format_type"), + ContentType = GetString(product, "content_type"), + ContentDeliveryType = GetString(product, "content_delivery_type"), + EpisodeType = GetString(product, "episode_type"), + Sku = GetString(product, "sku") + }; + } + + public static AudibleSearchResult? MapBookResponseToSearchResult(AudibleBookResponse book) + { + if (string.IsNullOrWhiteSpace(book.Asin)) + { + return null; + } + + return new AudibleSearchResult + { + Asin = book.Asin, + Title = book.Title, + Subtitle = book.Subtitle, + Authors = book.Authors, + ImageUrl = book.ImageUrl, + RuntimeLengthMin = book.LengthMinutes, + LengthMinutes = book.LengthMinutes, + RuntimeMinutes = book.LengthMinutes, + Language = book.Language, + ContentType = book.ContentType, + ContentDeliveryType = book.ContentDeliveryType, + EpisodeType = book.EpisodeType, + Sku = book.Sku, + BookFormat = book.BookFormat, + Genres = book.Genres, + Series = book.Series, + Publisher = book.Publisher, + Narrators = book.Narrators, + ReleaseDate = book.ReleaseDate, + Link = string.IsNullOrWhiteSpace(book.Asin) ? null : $"{AudibleRequestHelper.GetBaseUrl(book.Region ?? "us")}/pd/{book.Asin}", + Isbn = book.Isbn + }; + } + + public static List ApplyLanguageFilter(List results, string? language) + { + if (string.IsNullOrWhiteSpace(language) || + string.Equals(language, "all", StringComparison.OrdinalIgnoreCase)) + { + return results; + } + + return results + .Where(result => string.IsNullOrWhiteSpace(result.Language) || + string.Equals(result.Language, language, StringComparison.OrdinalIgnoreCase)) + .ToList(); + } + + private static IEnumerable GetArray(JsonElement element, string propertyName) + { + return element.TryGetProperty(propertyName, out var value) && value.ValueKind == JsonValueKind.Array + ? value.EnumerateArray() + : Enumerable.Empty(); + } + + private static string? GetString(JsonElement element, params string[] path) + { + var current = element; + foreach (var segment in path) + { + if (current.ValueKind != JsonValueKind.Object || + !current.TryGetProperty(segment, out current)) + { + return null; + } + } + + return current.ValueKind switch + { + JsonValueKind.String => current.GetString(), + JsonValueKind.Number => current.ToString(), + JsonValueKind.True => bool.TrueString.ToLowerInvariant(), + JsonValueKind.False => bool.FalseString.ToLowerInvariant(), + _ => null + }; + } + + private static int? GetInt32(JsonElement element, string propertyName) + { + if (!element.TryGetProperty(propertyName, out var value)) + { + return null; + } + + if (value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var number)) + { + return number; + } + + return int.TryParse(value.ToString(), out var parsed) ? parsed : null; + } + + private static bool? GetBoolean(JsonElement element, string propertyName) + { + if (!element.TryGetProperty(propertyName, out var value)) + { + return null; + } + + return value.ValueKind switch + { + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.String when bool.TryParse(value.GetString(), out var parsed) => parsed, + _ => null + }; + } + + private static string? GetHighestResolutionImage(JsonElement product) + { + if (product.TryGetProperty("product_images", out var images) && images.ValueKind == JsonValueKind.Object) + { + var bestKey = images.EnumerateObject() + .Select(property => new { property.Name, Numeric = int.TryParse(property.Name, out var size) ? size : 0 }) + .OrderByDescending(property => property.Numeric) + .FirstOrDefault(); + if (bestKey != null && images.TryGetProperty(bestKey.Name, out var imageValue)) + { + return imageValue.GetString(); + } + } + + return GetString(product, "cover_art_url"); + } + + private static List MapGenres(JsonElement product) + { + var genres = new List(); + foreach (var ladderEntry in GetArray(product, "category_ladders")) + { + if (!ladderEntry.TryGetProperty("ladder", out var ladder) || ladder.ValueKind != JsonValueKind.Array) + { + continue; + } + + var index = 0; + foreach (var genre in ladder.EnumerateArray()) + { + var name = GetString(genre, "name"); + if (string.IsNullOrWhiteSpace(name)) + { + index++; + continue; + } + + genres.Add(new AudibleGenre + { + Asin = GetString(genre, "id"), + Name = name, + Type = index == 0 ? "Genres" : "Tags" + }); + index++; + } + } + + return genres + .GroupBy(genre => $"{genre.Asin}|{genre.Name}|{genre.Type}", StringComparer.OrdinalIgnoreCase) + .Select(group => group.First()) + .ToList(); + } + } +} diff --git a/listenarr.application/Metadata/AudibleService.cs b/listenarr.application/Metadata/AudibleService.cs index cd5ec5343..6680484e9 100644 --- a/listenarr.application/Metadata/AudibleService.cs +++ b/listenarr.application/Metadata/AudibleService.cs @@ -162,12 +162,12 @@ public AudibleService(HttpClient httpClient, ILogger logger, IAu var books = await GetBooksMetadataByAsinsAsync(pagedAsins, region); var mapped = books .Where(book => book != null) - .Select(MapBookResponseToSearchResult) + .Select(AudibleProductMapper.MapBookResponseToSearchResult) .Where(book => book != null) .Cast() .ToList(); - mapped = ApplyLanguageFilter(mapped, language); + mapped = AudibleProductMapper.ApplyLanguageFilter(mapped, language); return new AudibleSearchResponse { @@ -325,7 +325,7 @@ public AudibleService(HttpClient httpClient, ILogger logger, IAu continue; } - var mapped = MapBookResponseToSearchResult(book); + var mapped = AudibleProductMapper.MapBookResponseToSearchResult(book); if (mapped == null) { continue; @@ -859,15 +859,15 @@ private async Task SearchProductsCoreAsync( .Select(product => product.Clone()) .ToList(); var results = rawProducts - .Select(product => MapProductToBookResponse(product, safeRegion)) + .Select(product => AudibleProductMapper.MapProductToBookResponse(product, safeRegion)) .Where(product => product != null) - .Select(product => MapBookResponseToSearchResult(product!)) + .Select(product => AudibleProductMapper.MapBookResponseToSearchResult(product!)) .Where(product => product != null) .Cast() .Where(product => !SearchResultIndicatesPodcast(product)) .ToList(); - results = ApplyLanguageFilter(results, language); + results = AudibleProductMapper.ApplyLanguageFilter(results, language); return new SearchProductsDirectResponse { @@ -940,7 +940,7 @@ private async Task> GetBooksMetadataByAsinsAsync(IEnum if (root.TryGetProperty("products", out var products) && products.ValueKind == JsonValueKind.Array) { foreach (var mapped in products.EnumerateArray() - .Select(product => MapProductToBookResponse(product, normalizedRegion)) + .Select(product => AudibleProductMapper.MapProductToBookResponse(product, normalizedRegion)) .Where(mapped => !string.IsNullOrWhiteSpace(mapped?.Asin))) { results[mapped!.Asin!] = mapped; @@ -948,7 +948,7 @@ private async Task> GetBooksMetadataByAsinsAsync(IEnum } else if (root.TryGetProperty("product", out var product) && product.ValueKind == JsonValueKind.Object) { - var mapped = MapProductToBookResponse(product, normalizedRegion); + var mapped = AudibleProductMapper.MapProductToBookResponse(product, normalizedRegion); if (!string.IsNullOrWhiteSpace(mapped?.Asin)) { results[mapped.Asin!] = mapped; @@ -963,118 +963,6 @@ private async Task> GetBooksMetadataByAsinsAsync(IEnum .ToList(); } - private static AudibleBookResponse? MapProductToBookResponse(JsonElement product, string region) - { - if (product.ValueKind != JsonValueKind.Object) - { - return null; - } - - var asin = GetString(product, "asin"); - if (string.IsNullOrWhiteSpace(asin)) - { - return null; - } - - return new AudibleBookResponse - { - Asin = asin, - Title = GetString(product, "title"), - Subtitle = GetString(product, "subtitle"), - Authors = GetArray(product, "authors") - .Select(author => new AudibleAuthor - { - Asin = GetString(author, "asin"), - Name = GetString(author, "name"), - Region = AudibleRequestHelper.NormalizeRegion(region) - }) - .Where(author => !string.IsNullOrWhiteSpace(author.Name)) - .ToList(), - Narrators = GetArray(product, "narrators") - .Select(narrator => new AudibleNarrator - { - Name = GetString(narrator, "name") - }) - .Where(narrator => !string.IsNullOrWhiteSpace(narrator.Name)) - .ToList(), - Publisher = GetString(product, "publisher_name"), - PublishDate = GetString(product, "publication_datetime"), - Description = GetString(product, "publisher_summary") - ?? GetString(product, "merchandising_summary") - ?? GetString(product, "extended_product_description") - ?? GetString(product, "merchandising_description"), - ImageUrl = GetHighestResolutionImage(product), - LengthMinutes = GetInt32(product, "runtime_length_min"), - Language = GetString(product, "language"), - Genres = MapGenres(product), - Series = GetArray(product, "series") - .Select(series => new AudibleSeries - { - Asin = GetString(series, "asin"), - Name = GetString(series, "title"), - Position = GetString(series, "sequence") - }) - .Where(series => !string.IsNullOrWhiteSpace(series.Name)) - .ToList(), - Explicit = GetBoolean(product, "is_adult_product"), - ReleaseDate = GetString(product, "release_date"), - Isbn = GetString(product, "isbn"), - Region = AudibleRequestHelper.NormalizeRegion(region), - BookFormat = GetString(product, "format_type"), - ContentType = GetString(product, "content_type"), - ContentDeliveryType = GetString(product, "content_delivery_type"), - EpisodeType = GetString(product, "episode_type"), - Sku = GetString(product, "sku") - }; - } - - private static AudibleSearchResult? MapBookResponseToSearchResult(AudibleBookResponse book) - { - if (string.IsNullOrWhiteSpace(book.Asin)) - { - return null; - } - - return new AudibleSearchResult - { - Asin = book.Asin, - Title = book.Title, - Subtitle = book.Subtitle, - Authors = book.Authors, - ImageUrl = book.ImageUrl, - RuntimeLengthMin = book.LengthMinutes, - LengthMinutes = book.LengthMinutes, - RuntimeMinutes = book.LengthMinutes, - Language = book.Language, - ContentType = book.ContentType, - ContentDeliveryType = book.ContentDeliveryType, - EpisodeType = book.EpisodeType, - Sku = book.Sku, - BookFormat = book.BookFormat, - Genres = book.Genres, - Series = book.Series, - Publisher = book.Publisher, - Narrators = book.Narrators, - ReleaseDate = book.ReleaseDate, - Link = string.IsNullOrWhiteSpace(book.Asin) ? null : $"{AudibleRequestHelper.GetBaseUrl(book.Region ?? "us")}/pd/{book.Asin}", - Isbn = book.Isbn - }; - } - - private static List ApplyLanguageFilter(List results, string? language) - { - if (string.IsNullOrWhiteSpace(language) || - string.Equals(language, "all", StringComparison.OrdinalIgnoreCase)) - { - return results; - } - - return results - .Where(result => string.IsNullOrWhiteSpace(result.Language) || - string.Equals(result.Language, language, StringComparison.OrdinalIgnoreCase)) - .ToList(); - } - private static AudibleSearchResponse ToSearchResponse(SearchProductsDirectResponse response) { return new AudibleSearchResponse @@ -1357,7 +1245,7 @@ private async Task> GetDirectAuthorCatalogResultsAsync } } - return ApplyLanguageFilter(results, language); + return AudibleProductMapper.ApplyLanguageFilter(results, language); } private static bool AuthorSearchResultMatchesTarget(AudibleSearchResult result, string author, string? authorAsin) diff --git a/listenarr.application/Search/AudibleSearchResultMapper.cs b/listenarr.application/Search/AudibleSearchResultMapper.cs new file mode 100644 index 000000000..b171d7a10 --- /dev/null +++ b/listenarr.application/Search/AudibleSearchResultMapper.cs @@ -0,0 +1,83 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +using Listenarr.Application.Metadata; +using Listenarr.Domain.Models; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Application.Search +{ + internal static class AudibleSearchResultMapper + { + public static async Task> ConvertToSearchResultsAsync( + IEnumerable books, + MetadataConverters metadataConverters, + IReadOnlyDictionary? detailedMetadataByAsin = null, + ILogger? logger = null, + bool continueOnConversionError = false) + { + var converted = new List(); + + foreach (var book in books.Where(book => !string.IsNullOrWhiteSpace(book.Asin))) + { + try + { + var bookResponse = detailedMetadataByAsin != null && + detailedMetadataByAsin.TryGetValue(book.Asin!, out var detailed) + ? detailed + : ToBookResponse(book); + + var metadata = metadataConverters.ConvertAudibleToMetadata(bookResponse, book.Asin!, "Audible"); + var result = await metadataConverters.ConvertMetadataToSearchResultAsync(metadata, book.Asin!); + result.IsEnriched = true; + result.MetadataSource = "Audible"; + converted.Add(result); + } + catch (Exception ex) when ( + continueOnConversionError && + ex is not OperationCanceledException && + ex is not OutOfMemoryException && + ex is not StackOverflowException) + { + logger?.LogDebug(ex, "Failed converting audible data for ASIN {Asin}", book.Asin); + } + } + + return converted; + } + + private static AudibleBookResponse ToBookResponse(AudibleSearchResult book) + { + return new AudibleBookResponse + { + Asin = book.Asin, + Title = book.Title, + Subtitle = book.Subtitle, + Authors = book.Authors, + ImageUrl = book.ImageUrl, + Language = book.Language, + BookFormat = book.BookFormat, + Genres = book.Genres, + Series = book.Series, + Publisher = book.Publisher, + Narrators = book.Narrators, + ReleaseDate = book.ReleaseDate + }; + } + } +} diff --git a/listenarr.application/Search/SearchQueryParser.cs b/listenarr.application/Search/SearchQueryParser.cs new file mode 100644 index 000000000..a87beb178 --- /dev/null +++ b/listenarr.application/Search/SearchQueryParser.cs @@ -0,0 +1,155 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace Listenarr.Application.Search +{ + internal sealed record ParsedSearchQuery( + string? SearchType, + string ActualQuery, + string? Asin, + string? Isbn, + string? Author, + string? Title); + + internal static class SearchQueryParser + { + private static readonly string[] Prefixes = { "AUTHOR:", "TITLE:", "ISBN:", "ASIN:" }; + + public static ParsedSearchQuery Parse(string query) + { + var foundRanges = new List<(int Start, int End)>(); + var parsed = new Dictionary(StringComparer.OrdinalIgnoreCase); + + var pos = 0; + while (pos < query.Length) + { + var foundAt = -1; + string? foundPrefix = null; + foreach (var prefix in Prefixes) + { + var idx = query.IndexOf(prefix, pos, StringComparison.OrdinalIgnoreCase); + if (idx >= 0 && (foundAt == -1 || idx < foundAt)) + { + foundAt = idx; + foundPrefix = prefix; + } + } + + if (foundAt == -1 || foundPrefix == null) + { + break; + } + + var valueStart = foundAt + foundPrefix.Length; + var nextAt = -1; + foreach (var prefix in Prefixes) + { + var idx = query.IndexOf(prefix, valueStart, StringComparison.OrdinalIgnoreCase); + if (idx >= 0 && (nextAt == -1 || idx < nextAt)) + { + nextAt = idx; + } + } + + var valueEnd = nextAt == -1 ? query.Length : nextAt; + var value = query.Substring(valueStart, valueEnd - valueStart).Trim(); + if (!string.IsNullOrEmpty(value)) + { + parsed[foundPrefix] = value; + } + + foundRanges.Add((foundAt, valueEnd)); + pos = valueEnd; + } + + parsed.TryGetValue("ASIN:", out var asin); + parsed.TryGetValue("ISBN:", out var isbn); + parsed.TryGetValue("AUTHOR:", out var author); + parsed.TryGetValue("TITLE:", out var title); + + asin = asin?.Trim(); + isbn = isbn?.Trim(); + author = author?.Trim(); + title = title?.Trim(); + + var searchType = DetermineSearchType(asin, isbn, author, title); + var actualQuery = BuildActualQuery(query, foundRanges); + + return new ParsedSearchQuery(searchType, actualQuery, asin, isbn, author, title); + } + + private static string? DetermineSearchType(string? asin, string? isbn, string? author, string? title) + { + if (!string.IsNullOrEmpty(asin)) + { + return "ASIN"; + } + + if (!string.IsNullOrEmpty(isbn)) + { + return "ISBN"; + } + + if (!string.IsNullOrEmpty(author) && !string.IsNullOrEmpty(title)) + { + return "AUTHOR_TITLE"; + } + + if (!string.IsNullOrEmpty(author)) + { + return "AUTHOR"; + } + + return !string.IsNullOrEmpty(title) ? "TITLE" : null; + } + + private static string BuildActualQuery(string query, List<(int Start, int End)> foundRanges) + { + if (!foundRanges.Any()) + { + return query; + } + + foundRanges.Sort((a, b) => a.Start.CompareTo(b.Start)); + var builder = new System.Text.StringBuilder(); + var idx = 0; + foreach (var range in foundRanges) + { + if (range.Start > idx) + { + builder.Append(query.Substring(idx, range.Start - idx)); + } + + idx = range.End; + } + + if (idx < query.Length) + { + builder.Append(query.Substring(idx)); + } + + var collapsed = builder.ToString(); + while (collapsed.Contains(" ")) + { + collapsed = collapsed.Replace(" ", " "); + } + + return collapsed.Trim(); + } + } +} diff --git a/listenarr.application/Search/SearchResultMatchEvaluator.cs b/listenarr.application/Search/SearchResultMatchEvaluator.cs new file mode 100644 index 000000000..223d1f46a --- /dev/null +++ b/listenarr.application/Search/SearchResultMatchEvaluator.cs @@ -0,0 +1,171 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +using Listenarr.Domain.Models; + +namespace Listenarr.Application.Search +{ + internal static class SearchResultMatchEvaluator + { + public static double ComputeContainmentScore(SearchResult result, string query) + { + if (result == null || string.IsNullOrWhiteSpace(query)) + { + return 0.0; + } + + var hay = string.Join(" ", new[] { result.Title, result.Artist, result.Album, result.Description, result.Publisher, result.Narrator, result.Language, result.Series } + .Where(x => !string.IsNullOrWhiteSpace(x))); + + var hayTokens = TokenizeAndNormalize(hay); + var queryTokens = TokenizeAndNormalize(query); + + if (!queryTokens.Any()) + { + return 0.0; + } + + var haySet = new HashSet(hayTokens, StringComparer.OrdinalIgnoreCase); + var matched = queryTokens.Count(haySet.Contains); + + for (var i = 0; i < queryTokens.Count; i++) + { + var queryToken = queryTokens[i]; + if (haySet.Contains(queryToken)) + { + continue; + } + + if (haySet.Any(hayToken => hayToken.Contains(queryToken) || queryToken.Contains(hayToken))) + { + matched += 1; + } + } + + return Math.Min(1.0, (double)matched / Math.Max(1, queryTokens.Count)); + } + + public static double ComputeFuzzySimilarity(string a, string b) + { + if (string.IsNullOrWhiteSpace(a) && string.IsNullOrWhiteSpace(b)) + { + return 1.0; + } + + if (string.IsNullOrWhiteSpace(a) || string.IsNullOrWhiteSpace(b)) + { + return 0.0; + } + + var normalizedA = NormalizeForFuzzy(a); + var normalizedB = NormalizeForFuzzy(b); + var distance = LevenshteinDistance(normalizedA, normalizedB); + var max = Math.Max(normalizedA.Length, normalizedB.Length); + if (max == 0) + { + return 1.0; + } + + var similarity = 1.0 - ((double)distance / max); + return Math.Max(0.0, Math.Min(1.0, similarity)); + } + + private static List TokenizeAndNormalize(string input) + { + if (string.IsNullOrWhiteSpace(input)) + { + return new List(); + } + + var normalized = input.ToLowerInvariant(); + var builder = new System.Text.StringBuilder(normalized.Length); + foreach (var character in normalized) + { + builder.Append(char.IsLetterOrDigit(character) || character == '-' || char.IsWhiteSpace(character) + ? character + : ' '); + } + + return builder + .ToString() + .Split(new[] { ' ', '\t', '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries) + .Where(token => token.Length > 0) + .ToList(); + } + + private static string NormalizeForFuzzy(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return string.Empty; + } + + var lowered = value.ToLowerInvariant(); + var builder = new System.Text.StringBuilder(lowered.Length); + foreach (var character in lowered.Where(character => char.IsLetterOrDigit(character) || character == '-')) + { + builder.Append(character); + } + + return builder.ToString(); + } + + private static int LevenshteinDistance(string source, string target) + { + if (source == target) + { + return 0; + } + + if (string.IsNullOrEmpty(source)) + { + return target.Length; + } + + if (string.IsNullOrEmpty(target)) + { + return source.Length; + } + + var sourceLength = source.Length; + var targetLength = target.Length; + var distances = new int[sourceLength + 1, targetLength + 1]; + + for (var i = 0; i <= sourceLength; distances[i, 0] = i++) + { + } + + for (var j = 0; j <= targetLength; distances[0, j] = j++) + { + } + + for (var i = 1; i <= sourceLength; i++) + { + for (var j = 1; j <= targetLength; j++) + { + var cost = target[j - 1] == source[i - 1] ? 0 : 1; + distances[i, j] = Math.Min( + Math.Min(distances[i - 1, j] + 1, distances[i, j - 1] + 1), + distances[i - 1, j - 1] + cost); + } + } + + return distances[sourceLength, targetLength]; + } + } +} diff --git a/listenarr.application/Search/SearchService.cs b/listenarr.application/Search/SearchService.cs index 4a8336e4e..4213ff0e5 100644 --- a/listenarr.application/Search/SearchService.cs +++ b/listenarr.application/Search/SearchService.cs @@ -18,10 +18,7 @@ using System.Text.Json; using Microsoft.Extensions.Caching.Memory; -using AsyncKeyedLock; using Listenarr.Application.Interfaces; -using Listenarr.Application.Common; -using Listenarr.Application.Extensions; using Listenarr.Domain.Models.Configurations; using Listenarr.Domain.Models; using Listenarr.Application.Interfaces.Repositories; @@ -208,50 +205,13 @@ public async Task> IntelligentSearchAsync(string quer { _logger.LogInformation("Starting intelligent search for: {Query}", query); - // Parse search prefixes (AUTHOR:, TITLE:, ISBN:, ASIN:) anywhere in the query - string? searchType = null; - string actualQuery = query; - - var prefixes = new[] { "AUTHOR:", "TITLE:", "ISBN:", "ASIN:" }; - var foundRanges = new List<(int Start, int End)>(); - var parsed = new Dictionary(StringComparer.OrdinalIgnoreCase); - - int pos = 0; - while (pos < query.Length) - { - int foundAt = -1; - string? foundPrefix = null; - for (int pi = 0; pi < prefixes.Length; pi++) - { - var prefix = prefixes[pi]; - var idx = query.IndexOf(prefix, pos, StringComparison.OrdinalIgnoreCase); - if (idx >= 0 && (foundAt == -1 || idx < foundAt)) - { - foundAt = idx; - foundPrefix = prefix; - } - } - if (foundAt == -1 || foundPrefix == null) break; - - int valueStart = foundAt + foundPrefix.Length; - int nextAt = -1; - for (int pi = 0; pi < prefixes.Length; pi++) - { - var np = query.IndexOf(prefixes[pi], valueStart, StringComparison.OrdinalIgnoreCase); - if (np >= 0 && (nextAt == -1 || np < nextAt)) nextAt = np; - } - int valueEnd = nextAt == -1 ? query.Length : nextAt; - - var value = query.Substring(valueStart, valueEnd - valueStart).Trim(); - if (!string.IsNullOrEmpty(value)) parsed[foundPrefix] = value; - foundRanges.Add((foundAt, valueEnd)); - pos = valueEnd; - } - - if (parsed.TryGetValue("ASIN:", out var asinVal)) asinVal = asinVal?.Trim(); - if (parsed.TryGetValue("ISBN:", out var isbnVal)) isbnVal = isbnVal?.Trim(); - if (parsed.TryGetValue("AUTHOR:", out var authorVal)) authorVal = authorVal?.Trim(); - if (parsed.TryGetValue("TITLE:", out var titleVal)) titleVal = titleVal?.Trim(); + var parsedQuery = SearchQueryParser.Parse(query); + var searchType = parsedQuery.SearchType; + var actualQuery = parsedQuery.ActualQuery; + var asinVal = parsedQuery.Asin; + var isbnVal = parsedQuery.Isbn; + var authorVal = parsedQuery.Author; + var titleVal = parsedQuery.Title; try { _logger.LogInformation("Parsed prefixes: ASIN={Asin}, ISBN={Isbn}, AUTHOR={Author}, TITLE={Title}", asinVal, isbnVal, authorVal, titleVal); } catch (Exception caughtEx_1) when (caughtEx_1 is not OperationCanceledException && caughtEx_1 is not OutOfMemoryException && caughtEx_1 is not StackOverflowException) @@ -259,38 +219,12 @@ public async Task> IntelligentSearchAsync(string quer System.Diagnostics.Debug.WriteLine("Suppressed non-fatal exception in catch block."); } - // Determine search type (priority: ASIN > ISBN > AUTHOR+TITLE > AUTHOR > TITLE) - if (!string.IsNullOrEmpty(asinVal)) searchType = "ASIN"; - else if (!string.IsNullOrEmpty(isbnVal)) searchType = "ISBN"; - else if (!string.IsNullOrEmpty(authorVal) && !string.IsNullOrEmpty(titleVal)) searchType = "AUTHOR_TITLE"; - else if (!string.IsNullOrEmpty(authorVal)) searchType = "AUTHOR"; - else if (!string.IsNullOrEmpty(titleVal)) searchType = "TITLE"; - else searchType = null; - try { _logger.LogInformation("[DBG] Determined searchType='{SearchType}'", searchType); } catch (Exception caughtEx_2) when (caughtEx_2 is not OperationCanceledException && caughtEx_2 is not OutOfMemoryException && caughtEx_2 is not StackOverflowException) { System.Diagnostics.Debug.WriteLine("Suppressed non-fatal exception in catch block."); } - // Build a fallback actualQuery by removing the recognized prefix ranges - if (foundRanges.Any()) - { - foundRanges.Sort((a, b) => a.Start.CompareTo(b.Start)); - var sb = new System.Text.StringBuilder(); - int idx = 0; - foreach (var r in foundRanges) - { - if (r.Start > idx) sb.Append(query.Substring(idx, r.Start - idx)); - idx = r.End; - } - if (idx < query.Length) sb.Append(query.Substring(idx)); - // collapse multiple spaces - var collapsed = sb.ToString(); - while (collapsed.Contains(" ")) collapsed = collapsed.Replace(" ", " "); - actualQuery = collapsed.Trim(); - } - // Try Audible-first for various search types. If Audible returns results, // convert them to SearchResult and return immediately to avoid scraping. try @@ -466,7 +400,6 @@ public async Task> IntelligentSearchAsync(string quer _logger.LogInformation("Deduplicated AUTHOR_TITLE results for '{Author}': {OriginalCount} -> {DeduplicatedCount}", authorVal, aggregated.Count, deduplicated.Count); - var converted = new List(); try { _logger.LogInformation("Audible author lookup returned {Count} aggregated results for author '{Author}'", deduplicated.Count, authorVal); } catch (Exception caughtEx_4) when (caughtEx_4 is not OperationCanceledException && caughtEx_4 is not OutOfMemoryException && caughtEx_4 is not StackOverflowException) { @@ -530,44 +463,12 @@ public async Task> IntelligentSearchAsync(string quer System.Diagnostics.Debug.WriteLine("Suppressed non-fatal exception in catch block."); } - // Convert filtered lightweight results; if we collected detailed - // metadata for some ASINs (e.g., ISBN scan), prefer that for enrichment. - foreach (var book in authorFiltered.Where(book => !string.IsNullOrWhiteSpace(book.Asin))) - { - AudibleBookResponse? bookResp = null; - if (detailedMetaByAsin.TryGetValue(book.Asin!, out var found)) bookResp = found; - if (bookResp == null) - { - bookResp = new AudibleBookResponse - { - Asin = book.Asin, - Title = book.Title, - Subtitle = book.Subtitle, - Authors = book.Authors, - ImageUrl = book.ImageUrl, - Language = book.Language, - BookFormat = book.BookFormat, - Genres = book.Genres, - Series = book.Series, - Publisher = book.Publisher, - Narrators = book.Narrators, - ReleaseDate = book.ReleaseDate, - Isbn = null - }; - } - try - { - var meta = _metadataConverters.ConvertAudibleToMetadata(bookResp, book.Asin!, "Audible"); - var sr = await _metadataConverters.ConvertMetadataToSearchResultAsync(meta, book.Asin!); - sr.IsEnriched = true; - sr.MetadataSource = "Audible"; - converted.Add(sr); - } - catch (Exception exMetaConv) when (exMetaConv is not OperationCanceledException && exMetaConv is not OutOfMemoryException && exMetaConv is not StackOverflowException) - { - _logger.LogDebug(exMetaConv, "Failed converting audible data for ASIN {Asin}", book.Asin); - } - } + var converted = await AudibleSearchResultMapper.ConvertToSearchResultsAsync( + authorFiltered, + _metadataConverters, + detailedMetaByAsin, + _logger, + continueOnConversionError: true); if (converted.Any()) return SearchResultConverters.ToMetadataList(converted); } @@ -579,32 +480,11 @@ public async Task> IntelligentSearchAsync(string quer var titleRes = await _audibleService.SearchByTitleAsync(titleVal, 1, 50, region, language); if (titleRes?.Results != null && titleRes.Results.Any()) { - var converted = new List(); var titleFiltered = titleRes.Results.AsEnumerable(); if (!string.IsNullOrWhiteSpace(language)) titleFiltered = titleFiltered.Where(b => string.IsNullOrWhiteSpace(b.Language) || string.Equals(b.Language, language, StringComparison.OrdinalIgnoreCase)); - foreach (var book in titleFiltered.Where(book => !string.IsNullOrWhiteSpace(book.Asin))) - { - var bookResp = new AudibleBookResponse - { - Asin = book.Asin, - Title = book.Title, - Subtitle = book.Subtitle, - Authors = book.Authors, - ImageUrl = book.ImageUrl, - Language = book.Language, - BookFormat = book.BookFormat, - Genres = book.Genres, - Series = book.Series, - Publisher = book.Publisher, - Narrators = book.Narrators, - ReleaseDate = book.ReleaseDate - }; - var meta = _metadataConverters.ConvertAudibleToMetadata(bookResp, book.Asin!, "Audible"); - var sr = await _metadataConverters.ConvertMetadataToSearchResultAsync(meta, book.Asin!); - sr.IsEnriched = true; - sr.MetadataSource = "Audible"; - converted.Add(sr); - } + var converted = await AudibleSearchResultMapper.ConvertToSearchResultsAsync( + titleFiltered, + _metadataConverters); if (converted.Any()) return SearchResultConverters.ToMetadataList(converted); } @@ -616,32 +496,11 @@ public async Task> IntelligentSearchAsync(string quer var simpleRes = await _audibleService.SearchBooksAsync(actualQuery, 1, 50, region, language); if (simpleRes?.Results != null && simpleRes.Results.Any()) { - var converted = new List(); var simpleFiltered = simpleRes.Results.AsEnumerable(); if (!string.IsNullOrWhiteSpace(language)) simpleFiltered = simpleFiltered.Where(b => string.IsNullOrWhiteSpace(b.Language) || string.Equals(b.Language, language, StringComparison.OrdinalIgnoreCase)); - foreach (var book in simpleFiltered.Where(book => !string.IsNullOrWhiteSpace(book.Asin))) - { - var bookResp = new AudibleBookResponse - { - Asin = book.Asin, - Title = book.Title, - Subtitle = book.Subtitle, - Authors = book.Authors, - ImageUrl = book.ImageUrl, - Language = book.Language, - BookFormat = book.BookFormat, - Genres = book.Genres, - Series = book.Series, - Publisher = book.Publisher, - Narrators = book.Narrators, - ReleaseDate = book.ReleaseDate - }; - var meta = _metadataConverters.ConvertAudibleToMetadata(bookResp, book.Asin!, "Audible"); - var sr = await _metadataConverters.ConvertMetadataToSearchResultAsync(meta, book.Asin!); - sr.IsEnriched = true; - sr.MetadataSource = "Audible"; - converted.Add(sr); - } + var converted = await AudibleSearchResultMapper.ConvertToSearchResultsAsync( + simpleFiltered, + _metadataConverters); if (converted.Any()) return SearchResultConverters.ToMetadataList(converted); } } @@ -803,8 +662,8 @@ public async Task> IntelligentSearchAsync(string quer // Compute containment and fuzzy similarity based on title/author/description try { - containmentScore = ComputeContainmentScore(r, query); - fuzzyScore = ComputeFuzzySimilarity((r.Title ?? string.Empty) + " " + (r.Artist ?? string.Empty), query); + containmentScore = SearchResultMatchEvaluator.ComputeContainmentScore(r, query); + fuzzyScore = SearchResultMatchEvaluator.ComputeFuzzySimilarity((r.Title ?? string.Empty) + " " + (r.Artist ?? string.Empty), query); } catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) { @@ -974,8 +833,8 @@ public async Task> IntelligentSearchAsync(string quer var fuzzy = 0.0; try { - containment = ComputeContainmentScore(enrichedCandidate, query); - fuzzy = ComputeFuzzySimilarity(enrichedCandidate.Title + " " + enrichedCandidate.Artist, query); + containment = SearchResultMatchEvaluator.ComputeContainmentScore(enrichedCandidate, query); + fuzzy = SearchResultMatchEvaluator.ComputeFuzzySimilarity(enrichedCandidate.Title + " " + enrichedCandidate.Artist, query); } catch (Exception caughtEx_12) when (caughtEx_12 is not OperationCanceledException && caughtEx_12 is not OutOfMemoryException && caughtEx_12 is not StackOverflowException) { @@ -1071,118 +930,6 @@ public async Task> IntelligentSearchAsync(string quer } } - - // Tokenize and normalize a string for containment and fuzzy matching. - // Preserves hyphenated tokens (e.g. "sg-1") as requested. - private static List TokenizeAndNormalize(string input) - { - if (string.IsNullOrWhiteSpace(input)) return new List(); - // Lowercase - var s = input.ToLowerInvariant(); - // Replace punctuation except hyphen with spaces - var sb = new System.Text.StringBuilder(s.Length); - foreach (var c in s) - { - if (char.IsLetterOrDigit(c) || c == '-' || char.IsWhiteSpace(c)) - sb.Append(c); - else - sb.Append(' '); - } - - // Split on whitespace and remove empty tokens - var tokens = sb.ToString().Split(new[] { ' ', '\t', '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries) - .Where(t => t.Length > 0) - .ToList(); - - return tokens; - } - - // Compute a containment score between 0.0 - 1.0 representing how much the query - // tokens are present in the result's combined fields. 1.0 = all tokens present. - private static double ComputeContainmentScore(SearchResult result, string query) - { - if (result == null || string.IsNullOrWhiteSpace(query)) return 0.0; - - var hay = string.Join(" ", new[] { result.Title, result.Artist, result.Album, result.Description, result.Publisher, result.Narrator, result.Language, result.Series } - .Where(x => !string.IsNullOrWhiteSpace(x))); - - var hayTokens = TokenizeAndNormalize(hay); - var queryTokens = TokenizeAndNormalize(query); - - if (!queryTokens.Any()) return 0.0; - - var haySet = new HashSet(hayTokens, StringComparer.OrdinalIgnoreCase); - var matched = queryTokens.Count(haySet.Contains); - - // Partial credit for hyphen-insensitive matches (e.g., sg-1 vs sg) - // Also check for substring matches of query tokens in hay tokens. - for (int i = 0; i < queryTokens.Count; i++) - { - var qt = queryTokens[i]; - if (haySet.Contains(qt)) continue; - if (haySet.Any(ht => ht.Contains(qt) || qt.Contains(ht))) - matched += 1; // give partial match same weight as token match - } - - var score = Math.Min(1.0, (double)matched / Math.Max(1, queryTokens.Count)); - return score; - } - - // Compute fuzzy similarity (0.0 - 1.0) based on normalized Levenshtein distance - private static double ComputeFuzzySimilarity(string a, string b) - { - if (string.IsNullOrWhiteSpace(a) && string.IsNullOrWhiteSpace(b)) return 1.0; - if (string.IsNullOrWhiteSpace(a) || string.IsNullOrWhiteSpace(b)) return 0.0; - - var sa = NormalizeForFuzzy(a); - var sb = NormalizeForFuzzy(b); - var dist = LevenshteinDistance(sa, sb); - var max = Math.Max(sa.Length, sb.Length); - if (max == 0) return 1.0; - var similarity = 1.0 - ((double)dist / max); - return Math.Max(0.0, Math.Min(1.0, similarity)); - } - - private static string NormalizeForFuzzy(string s) - { - if (string.IsNullOrWhiteSpace(s)) return string.Empty; - var lowered = s.ToLowerInvariant(); - // Remove punctuation except hyphen - var sb = new System.Text.StringBuilder(lowered.Length); - foreach (var c in lowered.Where(c => char.IsLetterOrDigit(c) || c == '-')) - { - sb.Append(c); - } - return sb.ToString(); - } - - // Standard Levenshtein distance implementation - private static int LevenshteinDistance(string s, string t) - { - if (s == t) return 0; - if (string.IsNullOrEmpty(s)) return t.Length; - if (string.IsNullOrEmpty(t)) return s.Length; - - var n = s.Length; - var m = t.Length; - var d = new int[n + 1, m + 1]; - - for (int i = 0; i <= n; d[i, 0] = i++) { } - for (int j = 0; j <= m; d[0, j] = j++) { } - - for (int i = 1; i <= n; i++) - { - for (int j = 1; j <= m; j++) - { - int cost = (t[j - 1] == s[i - 1]) ? 0 : 1; - d[i, j] = Math.Min( - Math.Min(d[i - 1, j] + 1, d[i, j - 1] + 1), - d[i - 1, j - 1] + cost); - } - } - return d[n, m]; - } - private static bool isOpenLibraryResult(SearchResult r) { return string.Equals(r?.MetadataSource, "OpenLibrary", StringComparison.OrdinalIgnoreCase); @@ -1524,555 +1271,6 @@ private async Task> SearchIndexerAsync(Indexer indexer } } - private async Task> SearchTorznabNewznabAsync(Indexer indexer, string query, string? category) - { - try - { - // Build Torznab/Newznab API URL (redact api keys before logging) - var url = BuildTorznabUrl(indexer, query, category); - _logger.LogDebug("Indexer API URL: {Url}", LogRedaction.RedactText(url, LogRedaction.GetSensitiveValuesFromEnvironment().Concat(new[] { indexer.ApiKey ?? string.Empty }))); - - // Make HTTP request with User-Agent header - using var request = new HttpRequestMessage(HttpMethod.Get, url); - var version = typeof(SearchService).Assembly.GetName().Version?.ToString() ?? "0.0.0"; - var userAgent = $"Listenarr/{version} (+https://github.com/Listenarrs/listenarr)"; - request.Headers.UserAgent.ParseAdd(userAgent); - - using var response = await _httpClient.SendAsync(request); - if (!response.IsSuccessStatusCode) - { - _logger.LogWarning("Indexer {Name} returned status {Status}", indexer.Name, response.StatusCode); - return new List(); - } - - var xmlContent = await response.Content.ReadAsStringAsync(); - - // Parse Torznab/Newznab XML response - var results = await ParseTorznabResponseAsync(xmlContent, indexer); - - _logger.LogInformation("Indexer {Name} returned {Count} results", indexer.Name, results.Count); - return results; - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogError(ex, "Error searching Torznab/Newznab indexer {Name}", indexer.Name); - return new List(); - } - } - - private async Task> SearchMyAnonamouseAsync(Indexer indexer, string query, string? category, SearchRequest? request = null) - { - try - { - _logger.LogInformation("Searching MyAnonamouse for: {Query}", query); - - // Parse mam_id from AdditionalSettings (robust: case-insensitive and nested) - var mamId = MyAnonamouseHelper.TryGetMamId(indexer.AdditionalSettings); - - if (string.IsNullOrEmpty(mamId)) - { - _logger.LogWarning("MyAnonamouse indexer {Name} missing mam_id", indexer.Name); - return new List(); - } - - // Build MyAnonamouse API request (mam_id is sent as a cookie) - // Use the JSON form endpoint with application/x-www-form-urlencoded payload - var url = $"{indexer.Url.TrimEnd('/')}/tor/js/loadSearchJSONbasic.php"; - - // Try to parse title/author from the query to give MyAnonamouse more targeted fields - var (parsedTitle, parsedAuthor) = ParseTitleAuthorFromQuery(query); - - // Decide searchType: prefer a targeted search when we only have title or only author - var searchType = "all"; - if (!string.IsNullOrWhiteSpace(parsedTitle) && string.IsNullOrWhiteSpace(parsedAuthor)) searchType = "title"; - if (string.IsNullOrWhiteSpace(parsedTitle) && !string.IsNullOrWhiteSpace(parsedAuthor)) searchType = "author"; - - // Build JSON payload according to new MyAnonamouse structure - // Build tor object to mirror the browse.php parameter shapes (tor[text], tor[srchIn][field]=true, tor[cat][]=...) - var srchInDict = new Dictionary - { - ["title"] = true, - ["author"] = true, - ["narrator"] = true, - ["series"] = true, - ["description"] = false, // default off (Prowlarr default) - ["filenames"] = true, // search filenames by default (Prowlarr default) - ["filetype"] = true - }; - - // Apply request overrides if present - if (request?.MyAnonamouse != null) - { - var opts = request.MyAnonamouse; - if (opts.SearchInDescription.HasValue) - srchInDict["description"] = opts.SearchInDescription.Value; - if (opts.SearchInSeries.HasValue) - srchInDict["series"] = opts.SearchInSeries.Value; - if (opts.SearchInFilenames.HasValue) - srchInDict["filename"] = opts.SearchInFilenames.Value; - } - - var torObject = new Dictionary - { - ["text"] = query, - ["srchIn"] = srchInDict, - ["searchType"] = searchType, - ["searchIn"] = "torrents", - // Keep explicit cat[] list copied from the browse URL - ["cat"] = new[] { "39", "49", "50", "83", "51", "97", "40", "41", "106", "42", "52", "98", "54", "55", "43", "99", "84", "44", "56", "45", "57", "85", "87", "119", "88", "58", "59", "46", "47", "53", "89", "100", "108", "48", "111", "0" }, - // Keep main_cat for explicit audiobook focus (some handlers honor it) - ["main_cat"] = new[] { "13" }, - // Additional browse.php parameters observed in the URL - ["browse_lang"] = new[] { "1" }, - ["browseFlagsHideVsShow"] = "0", - ["unit"] = "1", - ["startDate"] = string.Empty, - ["endDate"] = string.Empty, - ["hash"] = string.Empty, - ["sortType"] = "default", - ["startNumber"] = "0", - ["perpage"] = "100" - }; - - // If SearchLanguage specified in options, override the default - if (request?.MyAnonamouse?.SearchLanguage != null) - { - torObject["browse_lang"] = new[] { request.MyAnonamouse.SearchLanguage }; - } - - // Apply filter mappings for Prowlarr-like options - // e.g. onlyActive, onlyFreeleech, freeleechOrVip, onlyVip, notVip - - // Try to parse title/author from the query to give MyAnonamouse more targeted fields - if (!string.IsNullOrWhiteSpace(parsedTitle)) - { - torObject["title"] = parsedTitle; - } - - if (!string.IsNullOrWhiteSpace(parsedAuthor)) - { - torObject["author"] = parsedAuthor; - } - - - - // Additional browse options seen on browse.php - build indexed querystring params to match Prowlarr's shape - var queryParams = new List>(); - - if (torObject.TryGetValue("browse_lang", out var blObj) && blObj is string[] browseLangs) - { - for (int i = 0; i < browseLangs.Length; i++) - { - queryParams.Add(new KeyValuePair($"tor[browse_lang][{i}]", browseLangs[i])); - } - } - - if (torObject.TryGetValue("browseFlagsHideVsShow", out var hideShowObj)) - { - var hideShowVal = hideShowObj?.ToString() ?? string.Empty; - queryParams.Add(new KeyValuePair("tor[browseFlagsHideVsShow]", hideShowVal)); - } - - if (torObject.TryGetValue("unit", out var unitObj)) - { - var unitVal = unitObj?.ToString() ?? string.Empty; - queryParams.Add(new KeyValuePair("tor[unit]", unitVal)); - } - - // Optional: perpage to control number of results (default to 100 if present) - if (torObject.TryGetValue("perpage", out var perpageObj)) - { - var perpageVal = perpageObj?.ToString() ?? string.Empty; - queryParams.Add(new KeyValuePair("tor[perpage]", perpageVal)); - } - - // Add all explicit categories from torObject using indexed keys (mirrors Prowlarr) - if (torObject.TryGetValue("cat", out var catObj) && catObj is string[] cats) - { - for (int i = 0; i < cats.Length; i++) - { - queryParams.Add(new KeyValuePair($"tor[cat][{i}]", cats[i])); - } - } - else - { - // No cat specified: send explicit 0 (Prowlarr uses tor[cat][] = 0) - queryParams.Add(new KeyValuePair("tor[cat][]", "0")); - } - - // Add search-related and paging parameters (safely coalesce to empty strings) - var sortTypeVal = torObject.TryGetValue("sortType", out var sortTypeObj) ? sortTypeObj?.ToString() ?? string.Empty : string.Empty; - queryParams.Add(new KeyValuePair("tor[sortType]", sortTypeVal)); - queryParams.Add(new KeyValuePair("tor[browseStart]", "true")); - var startNumberVal = torObject.TryGetValue("startNumber", out var startNumberObj) ? startNumberObj?.ToString() ?? string.Empty : string.Empty; - queryParams.Add(new KeyValuePair("tor[startNumber]", startNumberVal)); - - // Keys present without explicit values in the example; represent them with empty string - queryParams.Add(new KeyValuePair("bannerLink", string.Empty)); - queryParams.Add(new KeyValuePair("bookmarks", string.Empty)); - queryParams.Add(new KeyValuePair("dlLink", string.Empty)); - queryParams.Add(new KeyValuePair("description", string.Empty)); - - // tor[text] is the search query - queryParams.Add(new KeyValuePair("tor[text]", query)); - - // Preserve audiobook filtering if available: include main_cat values - if (torObject.TryGetValue("main_cat", out var mainCatObj) && mainCatObj is string[] mainCats) - { - for (int i = 0; i < mainCats.Length; i++) - { - queryParams.Add(new KeyValuePair($"tor[main_cat][{i}]", mainCats[i])); - } - } - - // Add searchIn and srchIn fields so we request torrents and relevant fields - var searchInVal = torObject.TryGetValue("searchIn", out var searchInObj) ? searchInObj?.ToString() ?? string.Empty : string.Empty; - queryParams.Add(new KeyValuePair("tor[searchIn]", searchInVal)); - // srchIn fields: ensure the same fields we set above are present - if (torObject.TryGetValue("srchIn", out var srchInObj) && srchInObj is Dictionary srchInValues) - { - foreach (var kv in srchInValues) - { - queryParams.Add(new KeyValuePair($"tor[srchIn][{kv.Key}]", kv.Value ? "true" : "false")); - } - } - // Add explicit searchType (title/author/all) - queryParams.Add(new KeyValuePair("tor[searchType]", searchType)); - - // Apply filter flags based on request options (e.g., active, freeleech, vip) - if (request?.MyAnonamouse?.Filter != null) - { - switch (request.MyAnonamouse.Filter) - { - case MamTorrentFilter.Active: - queryParams.Add(new KeyValuePair("tor[onlyActive]", "1")); - break; - case MamTorrentFilter.Freeleech: - queryParams.Add(new KeyValuePair("tor[onlyFreeleech]", "1")); - break; - case MamTorrentFilter.FreeleechOrVip: - queryParams.Add(new KeyValuePair("tor[freeleechOrVip]", "1")); - break; - case MamTorrentFilter.Vip: - queryParams.Add(new KeyValuePair("tor[onlyVip]", "1")); - break; - case MamTorrentFilter.NotVip: - queryParams.Add(new KeyValuePair("tor[notVip]", "1")); - break; - } - } - - // Apply freeleech wedge preference - var freeleechWedge = request?.MyAnonamouse?.FreeleechWedge; - if (freeleechWedge != null) - { - queryParams.Add(new KeyValuePair("tor[freeleechWedge]", freeleechWedge.Value.ToString().ToLowerInvariant())); - } - - var qs = string.Join("&", queryParams.Select(kv => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value ?? string.Empty)}")); - var fullUrl = url + (qs.Length > 0 ? "?" + qs : string.Empty); - - _logger.LogInformation("MyAnonamouse outgoing query (loadSearchJSONbasic): {Query}", qs); - - using var mamRequest = new HttpRequestMessage(HttpMethod.Get, fullUrl); - // Add browser-like headers to avoid "invalid request" errors - mamRequest.Headers.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"); - mamRequest.Headers.Accept.ParseAdd("application/json, text/javascript, */*; q=0.01"); - mamRequest.Headers.AcceptLanguage.ParseAdd("en-US,en;q=0.9"); - mamRequest.Headers.Referrer = new Uri("https://www.myanonamouse.net/"); - - // Prefer using the injected HttpClient in tests (so DelegatingHandler stubs can capture requests) - HttpClient? disposableClient = - _httpClient?.BaseAddress == null || !string.Equals(_httpClient.BaseAddress.Host, new Uri(indexer.Url).Host, StringComparison.OrdinalIgnoreCase) - ? MyAnonamouseHelper.CreateAuthenticatedHttpClient(mamId, indexer.Url) - : null; - using var disposableClientScope = disposableClient; - var httpClientToUse = disposableClient ?? _httpClient!; - - if (disposableClient == null && !string.IsNullOrEmpty(mamId)) - mamRequest.Headers.Add("Cookie", $"mam_id={mamId}"); - - _logger.LogDebug("MyAnonamouse API URL: {Url}", LogRedaction.RedactText(url, LogRedaction.GetSensitiveValuesFromEnvironment().Concat(new[] { indexer.ApiKey ?? string.Empty }))); - - using var response = await httpClientToUse.SendAsync(mamRequest); - if (!response.IsSuccessStatusCode) - { - _logger.LogWarning("MyAnonamouse returned status {Status}", response.StatusCode); - var errorContent = await response.Content.ReadAsStringAsync(); - _logger.LogWarning("MyAnonamouse error response: {Content}", LogRedaction.RedactText(errorContent, LogRedaction.GetSensitiveValuesFromEnvironment().Concat(new[] { indexer.ApiKey ?? string.Empty }))); - return new List(); - } - - // Capture and persist an updated mam_id cookie if the tracker provided one in Set-Cookie - try - { - var newMam = MyAnonamouseHelper.TryExtractMamIdFromResponse(response); - if (!string.IsNullOrEmpty(newMam) && !string.Equals(newMam, mamId, StringComparison.Ordinal)) - { - _logger.LogInformation("MyAnonamouse: received updated mam_id from response for indexer {Name}", indexer.Name); - indexer.AdditionalSettings = MyAnonamouseHelper.UpdateMamIdInAdditionalSettings(indexer.AdditionalSettings, newMam); - await _indexerRepository.UpdateAsync(indexer); - mamId = newMam; - } - } - catch (Exception exMam) when (exMam is not OperationCanceledException && exMam is not OutOfMemoryException && exMam is not StackOverflowException) - { - _logger.LogDebug(exMam, "Failed to persist updated mam_id from MyAnonamouse response"); - } - - var jsonResponse = await response.Content.ReadAsStringAsync(); - _logger.LogDebug("MyAnonamouse raw response: {Response}", jsonResponse); - var results = MyAnonamouseResponseParser.Parse(jsonResponse, indexer, _logger); - - // Optional per-result enrichment: fetch individual item pages to populate missing fields - try - { - // Respect global IncludeEnrichment and per-indexer MyAnonamouse options - var mamRequestOptions = request?.MyAnonamouse; - var shouldEnrich = request?.IncludeEnrichment == true && mamRequestOptions?.EnrichResults == true; - if (shouldEnrich) - { - var enrichTop = mamRequestOptions!.EnrichTopResults ?? 3; - await EnrichMyAnonamouseResultsAsync(indexer, results, enrichTop, mamId, httpClientToUse); - } - } - catch (Exception exEnrich) when (exEnrich is not OperationCanceledException && exEnrich is not OutOfMemoryException && exEnrich is not StackOverflowException) - { - _logger.LogWarning(exEnrich, "MyAnonamouse enrichment step failed"); - } - - _logger.LogInformation("MyAnonamouse returned {Count} results", results.Count); - return results; - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogError(ex, "Error searching MyAnonamouse indexer {Name}", indexer.Name); - return new List(); - } - } - - - - // Optional enrichment step: fetch individual item pages to populate missing grabs/files/format/language - private async Task EnrichMyAnonamouseResultsAsync(Indexer indexer, List results, int topN, string? mamId, HttpClient httpClient) - { - if (results == null || results.Count == 0) return; - if (topN <= 0) return; - - var candidates = results.Where(r => (r.Grabs == 0 || r.Files == 0 || string.IsNullOrEmpty(r.Format) || string.IsNullOrEmpty(r.Language))).Take(topN).ToList(); - if (!candidates.Any()) return; - - _logger.LogDebug("Enriching {Count} MyAnonamouse results (topN={TopN})", candidates.Count, topN); - - using var sem = new AsyncNonKeyedLocker(4); - var tasks = candidates.Select(async r => - { - using var _ = await sem.LockAsync(); - try - { - var cacheKey = $"mam:enrich:{r.ResultUrl}"; - if (_cache != null && _cache.TryGetValue(cacheKey, out var cachedObj) && cachedObj is IndexerSearchResult cached) - { - // Apply cached values - if (cached.Grabs > 0) r.Grabs = cached.Grabs; - if (cached.Files > 0) r.Files = cached.Files; - if (!string.IsNullOrEmpty(cached.Format) && string.IsNullOrEmpty(r.Format)) r.Format = cached.Format; - if (!string.IsNullOrEmpty(cached.Language) && string.IsNullOrEmpty(r.Language)) r.Language = cached.Language; - return; - } - - if (string.IsNullOrEmpty(r.ResultUrl)) return; - - // Extract torrent ID from result URL (e.g., https://www.myanonamouse.net/t/28972 -> 28972) - var idMatch = System.Text.RegularExpressions.Regex.Match(r.ResultUrl, @"/t/(\d+)"); - if (!idMatch.Success) return; - var torrentId = idMatch.Groups[1].Value; - - // Request JSON detail endpoint - var detailUrl = $"{indexer.Url.TrimEnd('/')}/tor/js/loadTorrentJSONBasic.php?id={torrentId}"; - using var req = new HttpRequestMessage(HttpMethod.Get, detailUrl); - req.Headers.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"); - req.Headers.Accept.ParseAdd("application/json"); - if (!string.IsNullOrEmpty(mamId)) req.Headers.Add("Cookie", $"mam_id={mamId}"); - - using var resp = await httpClient.SendAsync(req); - if (!resp.IsSuccessStatusCode) return; - var json = await resp.Content.ReadAsStringAsync(); - - // Parse JSON for enrichment fields - try - { - var detail = JsonDocument.Parse(json).RootElement; - - // Handle potential wrapper objects (e.g., { "data": {...} } or { "response": {...} }) - if (detail.TryGetProperty("data", out var dataProp) && dataProp.ValueKind == System.Text.Json.JsonValueKind.Object) - { - detail = dataProp; - } - else if (detail.TryGetProperty("response", out var respProp) && respProp.ValueKind == System.Text.Json.JsonValueKind.Object) - { - detail = respProp; - } - - var grabs = 0; - var grabKeys = new[] { "grabs", "snatches", "snatched", "snatched_count", "snatches_count", "numgrabs", "num_grabs", "grab_count", "times_completed", "time_completed", "downloaded", "times_downloaded", "completed" }; - foreach (var key in grabKeys.Where(key => { JsonElement tmp; return detail.TryGetProperty(key, out tmp); })) - { - var gEl = detail.GetProperty(key); - if (gEl.ValueKind == System.Text.Json.JsonValueKind.Number) - { - grabs = gEl.GetInt32(); - _logger.LogDebug("Enrichment: found grabs field '{Field}'={Value} for {Id}", key, grabs, r.Id); - break; - } - else if (gEl.ValueKind == System.Text.Json.JsonValueKind.String && int.TryParse(gEl.GetString(), out var gtmp)) - { - grabs = gtmp; - _logger.LogDebug("Enrichment: parsed grabs (string) field '{Field}'={Value} for {Id}", key, grabs, r.Id); - break; - } - } - var files = detail.GetPropertyOrDefault("files", 0); - var format = detail.GetPropertyOrDefault("filetype", ""); - var langCode = detail.GetPropertyOrDefault("lang_code", ""); - - // Apply values - if (grabs > 0) r.Grabs = grabs; - if (files > 0) r.Files = files; - if (!string.IsNullOrEmpty(format) && string.IsNullOrEmpty(r.Format)) r.Format = format.ToUpper(); - if (!string.IsNullOrEmpty(langCode) && string.IsNullOrEmpty(r.Language)) r.Language = SearchResultAttributeParser.ParseLanguageFromCode(langCode); - - _logger.LogDebug("Enriched MyAnonamouse result {Id}: grabs={Grabs}, files={Files}, format={Format}, language={Language}", r.Id, r.Grabs, r.Files, r.Format, r.Language); - } - catch (Exception exParse) when (exParse is not OperationCanceledException && exParse is not OutOfMemoryException && exParse is not StackOverflowException) - { - _logger.LogDebug(exParse, "Failed to parse MyAnonamouse detail JSON for {Id}", r.Id); - return; - } - - // Cache the enriched values - if (_cache != null) - { - try - { - var entryOptions = new Microsoft.Extensions.Caching.Memory.MemoryCacheEntryOptions() { SlidingExpiration = TimeSpan.FromHours(1) }; - _cache.Set(cacheKey, (object)new IndexerSearchResult { Grabs = r.Grabs, Files = r.Files, Format = r.Format, Language = r.Language }, entryOptions); - } - catch (Exception exCache) when (exCache is not OperationCanceledException && exCache is not OutOfMemoryException && exCache is not StackOverflowException) - { - _logger.LogDebug(exCache, "Failed to set enrichment cache for {Key}", cacheKey); - } - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogDebug(ex, "Failed to enrich MyAnonamouse result {Id}", r.Id); - } - }).ToArray(); - - await Task.WhenAll(tasks); - } - - // Try to heuristically split a user query into (title, author). - // Supports patterns like: "Title by Author", "Title - Author", or "Author, Title". - private static (string? title, string? author) ParseTitleAuthorFromQuery(string query) - { - if (string.IsNullOrWhiteSpace(query)) return (null, null); - - var q = query.Trim(); - - // Pattern: "Title by Author" (use last occurrence of " by ") - var byIndex = q.LastIndexOf(" by ", StringComparison.OrdinalIgnoreCase); - if (byIndex > 0) - { - var title = q.Substring(0, byIndex).Trim(); - var author = q.Substring(byIndex + 4).Trim(); - return (string.IsNullOrWhiteSpace(title) ? null : title, string.IsNullOrWhiteSpace(author) ? null : author); - } - - // Pattern: "Title - Author" - var dashParts = q.Split(new[] { " - " }, 2, StringSplitOptions.None); - if (dashParts.Length == 2) - { - var title = dashParts[0].Trim(); - var author = dashParts[1].Trim(); - return (string.IsNullOrWhiteSpace(title) ? null : title, string.IsNullOrWhiteSpace(author) ? null : author); - } - - // Pattern: "Author, Title" -> return (Title, Author) - var commaParts = q.Split(new[] { ',' }, 2); - if (commaParts.Length == 2) - { - var author = commaParts[0].Trim(); - var title = commaParts[1].Trim(); - return (string.IsNullOrWhiteSpace(title) ? null : title, string.IsNullOrWhiteSpace(author) ? null : author); - } - - return (null, null); - } - - private string BuildTorznabUrl(Indexer indexer, string query, string? category) - { - var url = indexer.Url.TrimEnd('/'); - var apiPath = indexer.Implementation.ToLower() switch - { - "torznab" => "/api", - "newznab" => "/api", - _ => "/api" - }; - - var queryParams = new List - { - $"t=search", - $"q={Uri.EscapeDataString(query)}" - }; - - // Add API key if provided - if (!string.IsNullOrEmpty(indexer.ApiKey)) - { - queryParams.Add($"apikey={Uri.EscapeDataString(indexer.ApiKey)}"); - } - - // Add categories if specified - if (!string.IsNullOrEmpty(category)) - { - queryParams.Add($"cat={Uri.EscapeDataString(category)}"); - } - else if (!string.IsNullOrEmpty(indexer.Categories)) - { - queryParams.Add($"cat={Uri.EscapeDataString(indexer.Categories)}"); - } - - // Add limit - queryParams.Add("limit=100"); - - // Request extended info for Newznab/Torznab indexers to include grabs/snatches and other attributes when available - if (!string.IsNullOrEmpty(indexer.Implementation) && (indexer.Implementation.Equals("newznab", StringComparison.OrdinalIgnoreCase) || indexer.Implementation.Equals("torznab", StringComparison.OrdinalIgnoreCase))) - { - queryParams.Add("extended=1"); - } - - return $"{url}{apiPath}?{string.Join("&", queryParams)}"; - } - - // Try to extract host from a URL; fallback to the raw url or a generic label - private string TryGetHostFromUrl(string? rawUrl) - { - if (string.IsNullOrWhiteSpace(rawUrl)) return "Indexer"; - try - { - var url = rawUrl.Trim(); - if (!url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) && !url.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) - url = "https://" + url; - var u = new Uri(url); - return u.Host; - } - catch (Exception caughtEx_21) when (caughtEx_21 is not OperationCanceledException && caughtEx_21 is not OutOfMemoryException && caughtEx_21 is not StackOverflowException) - { - return rawUrl.TrimEnd('/'); - } - } - /// /// Remove illegal/unsupported characters from indexer search queries. /// Strips a curated set of punctuation/symbols, smart quotes, control @@ -2111,270 +1309,6 @@ private string SanitizeIndexerQuery(string query) return cleaned; } - private async Task> SearchInternetArchiveAsync(Indexer indexer, string query, string? category) - { - try - { - _logger.LogInformation("Searching Internet Archive for: {Query}", query); - - // Parse collection from AdditionalSettings (default: librivoxaudio) - var collection = "librivoxaudio"; - - if (!string.IsNullOrEmpty(indexer.AdditionalSettings)) - { - try - { - var settings = JsonDocument.Parse(indexer.AdditionalSettings); - if (settings.RootElement.TryGetProperty("collection", out var collectionElem)) - { - var parsedCollection = collectionElem.GetString(); - if (!string.IsNullOrEmpty(parsedCollection)) - collection = parsedCollection; - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Failed to parse Internet Archive settings, using default collection"); - } - } - - _logger.LogDebug("Using Internet Archive collection: {Collection}", collection); - - // Build search query - search in title and creator (author) fields - var searchQuery = $"collection:{collection} AND (title:({query}) OR creator:({query}))"; - var searchUrl = $"https://archive.org/advancedsearch.php?q={Uri.EscapeDataString(searchQuery)}&fl=identifier,title,creator,date,downloads,item_size,description&rows=100&output=json"; - - _logger.LogInformation("Internet Archive search URL: {Url}", searchUrl); - - var response = await _httpClient.GetAsync(searchUrl); - if (!response.IsSuccessStatusCode) - { - _logger.LogWarning("Internet Archive returned status {Status}", response.StatusCode); - return new List(); - } - - var jsonResponse = await response.Content.ReadAsStringAsync(); - _logger.LogDebug("Internet Archive response length: {Length}", jsonResponse.Length); - - var searchResults = await ParseInternetArchiveSearchResponse(jsonResponse, indexer); - - _logger.LogInformation("Internet Archive returned {Count} results", searchResults.Count); - return searchResults; - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogError(ex, "Error searching Internet Archive indexer {Name}", indexer.Name); - return new List(); - } - } - - private async Task> ParseInternetArchiveSearchResponse(string jsonResponse, Indexer indexer) - { - var results = new List(); - - try - { - _logger.LogInformation("Parsing Internet Archive response, length: {Length}", jsonResponse.Length); - - var doc = JsonDocument.Parse(jsonResponse); - - if (!doc.RootElement.TryGetProperty("response", out var responseObj)) - { - _logger.LogWarning("Internet Archive response missing 'response' object"); - return results; - } - - if (!responseObj.TryGetProperty("docs", out var docsArray)) - { - _logger.LogWarning("Internet Archive response missing 'docs' array"); - return results; - } - - _logger.LogInformation("Found {Count} Internet Archive items in response", docsArray.GetArrayLength()); - - // Limit to first 20 results to avoid timeout - var itemsToProcess = Math.Min(20, docsArray.GetArrayLength()); - _logger.LogInformation("Processing first {Count} of {Total} Internet Archive items", itemsToProcess, docsArray.GetArrayLength()); - - var processedCount = 0; - foreach (var item in docsArray.EnumerateArray()) - { - if (processedCount >= itemsToProcess) - { - break; - } - processedCount++; - - try - { - var identifier = item.TryGetProperty("identifier", out var idElem) ? idElem.GetString() : ""; - var title = item.TryGetProperty("title", out var titleElem) ? titleElem.GetString() : ""; - var creator = item.TryGetProperty("creator", out var creatorElem) ? creatorElem.GetString() : ""; - if (string.IsNullOrEmpty(identifier) || string.IsNullOrEmpty(title)) - { - _logger.LogDebug("Skipping item with missing identifier or title"); - continue; - } - - _logger.LogDebug("Fetching metadata for {Identifier}", identifier); - - // Fetch detailed metadata to get file information - var metadataUrl = $"https://archive.org/metadata/{identifier}"; - var metadataResponse = await _httpClient.GetAsync(metadataUrl); - - if (!metadataResponse.IsSuccessStatusCode) - { - _logger.LogWarning("Failed to fetch metadata for {Identifier}", identifier); - continue; - } - - var metadataJson = await metadataResponse.Content.ReadAsStringAsync(); - var audioFile = GetBestAudioFile(metadataJson, identifier); - - if (audioFile == null) - { - _logger.LogDebug("No suitable audio file found for {Identifier}", identifier); - continue; - } - - // Build download URL - var downloadUrl = $"https://archive.org/download/{identifier}/{audioFile.FileName}"; - - _logger.LogDebug("Found audio file for {Title}: {FileName} ({Format}, {Size} bytes)", - title, audioFile.FileName, audioFile.Format, audioFile.Size); - - var iaResult = new IndexerSearchResult - { - Id = Guid.NewGuid().ToString(), - Title = title, - Artist = creator ?? "Unknown", - Album = title, - Category = "Audiobook", - Size = audioFile.Size, - Seeders = 0, // N/A for direct downloads - Leechers = 0, // N/A for direct downloads - TorrentUrl = downloadUrl, // Using TorrentUrl field for direct download URL - // Internet Archive item page - ResultUrl = !string.IsNullOrEmpty(identifier) ? $"https://archive.org/details/{identifier}" : null, - DownloadType = "DDL", // Direct Download Link - Format = audioFile.Format, - Quality = SearchResultAttributeParser.DetectQualityFromFormat(audioFile.Format), - Source = $"{indexer.Name} (Internet Archive)", - PublishedDate = string.Empty, - IndexerId = indexer.Id, - IndexerImplementation = indexer.Implementation - }; - - // Ensure ResultUrl is present (fallback to item page or archive details) - if (string.IsNullOrEmpty(iaResult.ResultUrl) && !string.IsNullOrEmpty(identifier)) - { - iaResult.ResultUrl = $"https://archive.org/details/{identifier}"; - } - - try - { - var detectedLang = SearchResultAttributeParser.ParseLanguageFromText(title); - if (!string.IsNullOrEmpty(detectedLang)) iaResult.Language = detectedLang; - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogDebug(ex, "Failed to parse language from title: {Title}", title); - } - - results.Add(iaResult); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogError(ex, "Error processing Internet Archive item"); - } - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogError(ex, "Error parsing Internet Archive response"); - } - - return results; - } - - private class AudioFileInfo - { - public string FileName { get; set; } = ""; - public string Format { get; set; } = ""; - public long Size { get; set; } - public int Priority { get; set; } // Lower = better - } - - private AudioFileInfo? GetBestAudioFile(string metadataJson, string identifier) - { - try - { - var doc = JsonDocument.Parse(metadataJson); - - if (!doc.RootElement.TryGetProperty("files", out var filesArray)) - { - return null; - } - - var audioFiles = new List(); - - foreach (var file in filesArray.EnumerateArray()) - { - var fileName = file.TryGetProperty("name", out var nameElem) ? nameElem.GetString() : ""; - var format = file.TryGetProperty("format", out var formatElem) ? formatElem.GetString() : ""; - - // Size can be either a string or a number in Internet Archive API - long size = 0; - if (file.TryGetProperty("size", out var sizeElem)) - { - if (sizeElem.ValueKind == System.Text.Json.JsonValueKind.String) - { - long.TryParse(sizeElem.GetString(), out size); - } - else if (sizeElem.ValueKind == System.Text.Json.JsonValueKind.Number) - { - size = sizeElem.GetInt64(); - } - } - - if (string.IsNullOrEmpty(fileName) || string.IsNullOrEmpty(format)) - continue; - - // Assign priority based on format (lower = better) - int priority = format switch - { - "LibriVox Apple Audiobook" => 1, // M4B - best quality, multi-chapter - "M4B" => 1, - "128Kbps MP3" => 2, // Good quality MP3 - "VBR MP3" => 3, // Variable bitrate MP3 - "Ogg Vorbis" => 4, // OGG format - "64Kbps MP3" => 5, // Lower quality MP3 - _ => int.MaxValue // Unknown format - lowest priority - }; - - // Only include known audio formats - if (priority < int.MaxValue) - { - audioFiles.Add(new AudioFileInfo - { - FileName = fileName, - Format = format, - Size = size, - Priority = priority - }); - } - } - - // Return the highest priority (lowest priority number) audio file - return audioFiles.OrderBy(f => f.Priority).ThenByDescending(f => f.Size).FirstOrDefault(); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogError(ex, "Error parsing Internet Archive metadata for {Identifier}", identifier); - return null; - } - } - internal async Task> ParseTorznabResponseAsync(string xmlContent, Indexer indexer) { var results = new List(); diff --git a/listenarr.infrastructure/Search/Providers/MyAnonamouseSearchProvider.cs b/listenarr.infrastructure/Search/Providers/MyAnonamouseSearchProvider.cs index 3a1420849..e8ecdda24 100644 --- a/listenarr.infrastructure/Search/Providers/MyAnonamouseSearchProvider.cs +++ b/listenarr.infrastructure/Search/Providers/MyAnonamouseSearchProvider.cs @@ -368,7 +368,7 @@ private List ParseMyAnonamouseResponse(string jsonResponse, var id = item.TryGetProperty("id", out var idElem) ? (idElem.ValueKind == JsonValueKind.String ? idElem.GetString() ?? string.Empty : idElem.ToString()) - : Guid.NewGuid().ToString(); + : string.Empty; // MyAnonamouse uses "title" in responses; fall back to "name" if needed var title = ""; @@ -407,16 +407,6 @@ private List ParseMyAnonamouseResponse(string jsonResponse, dlHash = dlElem.ValueKind == JsonValueKind.String ? dlElem.GetString() ?? string.Empty : dlElem.ToString(); } - // Get torrent ID for download URL fallback (note: 'id' already parsed above as variable 'id') - string torrentId = id; - - // Debug logging for first result - if (_mamDebugIndex == 0) - { - _logger.LogInformation("MyAnonamouse first result - Title: '{Title}', Size: '{Size}', Seeders: {Seeders}, DlHash: '{DlHash}', TorrentId: '{TorrentId}'", - title, sizeStr, seeders, dlHash, torrentId); - } - // Explicit downloadUrl / infoUrl / fileName fields string? downloadUrlField = null; string? infoUrlField = null; @@ -432,6 +422,37 @@ private List ParseMyAnonamouseResponse(string jsonResponse, fileNameField = prop.Value.GetString(); } + if (string.IsNullOrWhiteSpace(infoUrlField) && + item.TryGetProperty("guid", out var guidElem) && + guidElem.ValueKind == JsonValueKind.String) + { + infoUrlField = guidElem.GetString(); + } + + if (string.IsNullOrWhiteSpace(id) && + !string.IsNullOrWhiteSpace(infoUrlField)) + { + var idMatch = Regex.Match(infoUrlField, @"/t/(\d+)", RegexOptions.IgnoreCase); + if (idMatch.Success) + { + id = idMatch.Groups[1].Value; + } + } + + if (string.IsNullOrWhiteSpace(id)) + { + id = Guid.NewGuid().ToString(); + } + + var torrentId = id; + + // Debug logging for first result + if (_mamDebugIndex == 0) + { + _logger.LogInformation("MyAnonamouse first result - Title: '{Title}', Size: '{Size}', Seeders: {Seeders}, DlHash: '{DlHash}', TorrentId: '{TorrentId}'", + title, sizeStr, seeders, dlHash, torrentId); + } + string category = string.Empty; if (item.TryGetProperty("catname", out var catElem)) { @@ -705,7 +726,7 @@ private List ParseMyAnonamouseResponse(string jsonResponse, Quality = finalQuality, Format = finalFormat, TorrentUrl = downloadUrl, - ResultUrl = !string.IsNullOrEmpty(id) ? $"https://myanonamouse.net/t/{Uri.EscapeDataString(id)}" : indexer.Url, + ResultUrl = !string.IsNullOrWhiteSpace(infoUrlField) ? infoUrlField : $"https://myanonamouse.net/t/{Uri.EscapeDataString(id)}", MagnetLink = "", NzbUrl = "", DownloadType = "Torrent", @@ -1005,4 +1026,3 @@ private string DetectQualityFromFormat(string format) } } } - diff --git a/tests/Builders/ServiceCollectionBuilder.cs b/tests/Builders/ServiceCollectionBuilder.cs index 11a833f74..1f2d3fb2b 100644 --- a/tests/Builders/ServiceCollectionBuilder.cs +++ b/tests/Builders/ServiceCollectionBuilder.cs @@ -186,6 +186,7 @@ private ServiceCollection BuildServices() services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/tests/Features/Api/Controllers/LibraryController_QualityCutoffTests.cs b/tests/Features/Api/Controllers/LibraryController_QualityCutoffTests.cs index edf83ba0f..e33eb3638 100644 --- a/tests/Features/Api/Controllers/LibraryController_QualityCutoffTests.cs +++ b/tests/Features/Api/Controllers/LibraryController_QualityCutoffTests.cs @@ -15,8 +15,7 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -using System.Reflection; -using Listenarr.Api.Controllers; +using Listenarr.Application.Audiobooks; using Listenarr.Tests.Builders; using Listenarr.Tests.Common; @@ -52,23 +51,11 @@ await _downloadRepository.AddAsync(new DownloadBuilder() .WithTitle("Dune") .Build()); - var controller = _provider.GetRequiredService(); - // When - var method = typeof(LibraryController).GetMethod( - "IsQualityCutoffMetAsync", - BindingFlags.Instance | BindingFlags.NonPublic); - Assert.NotNull(method); - - var task = (Task?)method!.Invoke(controller, new object[] - { + var result = await AudiobookQualityCutoffEvaluator.IsQualityCutoffMetAsync( audiobook, - _provider.GetRequiredService(), _downloadRepository, - _audiobookFileRepository - }); - Assert.NotNull(task); - var result = await task!; + _audiobookFileRepository); // Then Assert.True(result); diff --git a/tests/Features/Api/Services/Search/Providers/IndexersNewznabParsingTests.cs b/tests/Features/Api/Services/Search/Providers/IndexersNewznabParsingTests.cs index cf8ac9765..163393d57 100644 --- a/tests/Features/Api/Services/Search/Providers/IndexersNewznabParsingTests.cs +++ b/tests/Features/Api/Services/Search/Providers/IndexersNewznabParsingTests.cs @@ -652,13 +652,21 @@ public async Task EnrichMyAnonamouse_Populates_Fields_When_Enabled() }); using var httpClient = new HttpClient(handler) { BaseAddress = new System.Uri("https://www.myanonamouse.net") }; - var service = CreateSearchService(httpClient); + var provider = new MyAnonamouseSearchProvider( + NullLogger.Instance, + httpClient, + Mock.Of()); - var method = typeof(SearchService).GetMethod("SearchMyAnonamouseAsync", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - Assert.NotNull(method); - var task = (Task>)method!.Invoke(service, new object[] { indexer, "Enrich Test", null, new SearchRequest { IncludeEnrichment = true, MyAnonamouse = new MyAnonamouseOptions { EnrichResults = true, EnrichTopResults = 1 } } })!; + var results = await provider.SearchAsync( + indexer, + "Enrich Test", + null, + new SearchRequest + { + IncludeEnrichment = true, + MyAnonamouse = new MyAnonamouseOptions { EnrichResults = true, EnrichTopResults = 1 } + }); - var results = await task; Assert.Single(results); var r = results[0]; Assert.Equal(15, r.Grabs); diff --git a/tests/Features/Api/Services/SearchWorkflowHelperTests.cs b/tests/Features/Api/Services/SearchWorkflowHelperTests.cs new file mode 100644 index 000000000..da2462b97 --- /dev/null +++ b/tests/Features/Api/Services/SearchWorkflowHelperTests.cs @@ -0,0 +1,71 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +using Listenarr.Application.Search; + +namespace Listenarr.Tests.Features.Api.Services +{ + public class SearchWorkflowHelperTests + { + [Fact] + public void Parse_Prefers_Asin_And_Removes_Prefixed_Ranges_From_Fallback_Query() + { + var parsed = SearchQueryParser.Parse("space opera AUTHOR: Martha Wells TITLE: Network Effect ASIN: B088C4Z8T5"); + + Assert.Equal("ASIN", parsed.SearchType); + Assert.Equal("B088C4Z8T5", parsed.Asin); + Assert.Equal("Martha Wells", parsed.Author); + Assert.Equal("Network Effect", parsed.Title); + Assert.Equal("space opera", parsed.ActualQuery); + } + + [Theory] + [InlineData("ISBN: 9781234567890", "ISBN")] + [InlineData("AUTHOR: Becky Chambers TITLE: A Psalm for the Wild-Built", "AUTHOR_TITLE")] + [InlineData("AUTHOR: Becky Chambers", "AUTHOR")] + [InlineData("TITLE: A Psalm for the Wild-Built", "TITLE")] + public void Parse_Determines_Targeted_Search_Type(string query, string expectedSearchType) + { + var parsed = SearchQueryParser.Parse(query); + + Assert.Equal(expectedSearchType, parsed.SearchType); + } + + [Fact] + public void ComputeContainmentScore_Preserves_Hyphenated_Tokens() + { + var result = new SearchResult + { + Title = "Stargate SG-1", + Artist = "Ashley McConnell" + }; + + var score = SearchResultMatchEvaluator.ComputeContainmentScore(result, "SG-1"); + + Assert.Equal(1.0, score); + } + + [Fact] + public void ComputeFuzzySimilarity_Normalizes_Punctuation() + { + var similarity = SearchResultMatchEvaluator.ComputeFuzzySimilarity("The Long Way to a Small, Angry Planet", "The Long Way to a Small Angry Planet"); + + Assert.Equal(1.0, similarity); + } + } +} From 8c024266f377956cf3b7e2e61c6cd30afe01b7af Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Fri, 12 Jun 2026 14:44:31 -0400 Subject: [PATCH 18/84] Extract library scan workflows --- .../Controllers/LibraryController.cs | 187 ++---------------- .../Controllers/LibraryScanPathResolver.cs | 164 +++++++++++++++ .../Controllers/LibraryScanQueueWorkflow.cs | 124 ++++++++++++ listenarr.api/Program.cs | 2 + tests/Builders/ServiceCollectionBuilder.cs | 2 + 5 files changed, 310 insertions(+), 169 deletions(-) create mode 100644 listenarr.api/Controllers/LibraryScanPathResolver.cs create mode 100644 listenarr.api/Controllers/LibraryScanQueueWorkflow.cs diff --git a/listenarr.api/Controllers/LibraryController.cs b/listenarr.api/Controllers/LibraryController.cs index b979ced82..52afc50f0 100644 --- a/listenarr.api/Controllers/LibraryController.cs +++ b/listenarr.api/Controllers/LibraryController.cs @@ -46,16 +46,16 @@ public class LibraryController : ControllerBase private readonly IAudiobookFileRepository _audioFileRepository; private readonly IQualityProfileRepository _qualityProfileRepository; private readonly IDownloadRepository _downloadRepository; - private readonly IScanQueueService? _scanQueueService; private readonly IMoveQueueService? _moveQueueService; private readonly IFileNamingService _fileNamingService; private readonly NotificationService? _notificationService; - private readonly IRootFolderService? _rootFolderService; private readonly ILibraryAddService? _libraryAddService; private readonly IRenameService? _renameService; private readonly ILibraryListService _libraryListService; private readonly IAudiobookFilesystemDeleteService _audiobookFilesystemDeleteService; private readonly LibraryMetadataRescanWorkflow _metadataRescanWorkflow; + private readonly LibraryScanPathResolver _scanPathResolver; + private readonly LibraryScanQueueWorkflow _scanQueueWorkflow; private readonly string _contentRootPath; /// Initializes a new instance of . /// Repository for audiobook persistence and queries. @@ -67,16 +67,16 @@ public class LibraryController : ControllerBase /// Repository for quality profile configuration. /// Repository for active download records. /// Service responsible for applying file naming patterns. - /// Optional background scan queue service for asynchronous scans. /// Optional background move queue service for processing move requests. /// Service for sending webhook notifications. - /// Optional root folder service for managing and enumerating configured root folders used for validating explicit scan paths. /// Optional shared add-to-library service used by runtime requests and background syncs. /// Optional organize/rename service used for previewing and executing library file organization. /// Application path service used to resolve content-root-relative cache files. /// Application service that builds the slim library list payload. /// Application service responsible for safe audiobook filesystem cleanup. /// API workflow for on-demand audiobook metadata rescans. + /// API workflow for resolving and validating scan roots. + /// API workflow for background scan queue operations. public LibraryController( IAudiobookRepository repo, IImageCacheService imageCacheService, @@ -91,10 +91,10 @@ public LibraryController( ILibraryListService libraryListService, IAudiobookFilesystemDeleteService audiobookFilesystemDeleteService, LibraryMetadataRescanWorkflow metadataRescanWorkflow, - IScanQueueService? scanQueueService = null, + LibraryScanPathResolver scanPathResolver, + LibraryScanQueueWorkflow scanQueueWorkflow, IMoveQueueService? moveQueueService = null, NotificationService? notificationService = null, - IRootFolderService? rootFolderService = null, ILibraryAddService? libraryAddService = null, IRenameService? renameService = null) { @@ -107,15 +107,15 @@ public LibraryController( _qualityProfileRepository = qualityProfileRepository; _downloadRepository = downloadRepository; _fileNamingService = fileNamingService; - _scanQueueService = scanQueueService; _moveQueueService = moveQueueService; _notificationService = notificationService; - _rootFolderService = rootFolderService; _libraryAddService = libraryAddService; _renameService = renameService; _libraryListService = libraryListService; _audiobookFilesystemDeleteService = audiobookFilesystemDeleteService; _metadataRescanWorkflow = metadataRescanWorkflow; + _scanPathResolver = scanPathResolver; + _scanQueueWorkflow = scanQueueWorkflow; _contentRootPath = applicationPathService.ContentRootPath; } @@ -1462,143 +1462,20 @@ public async Task ScanAudiobookFiles(int id, [FromBody] ScanReque var audiobook = await _repo.GetByIdAsync(id); if (audiobook == null) return NotFound(new { message = "Audiobook not found" }); - // If a background scan queue is available, enqueue the job and return Accepted - if (_scanQueueService != null) + var queuedResult = await _scanQueueWorkflow.TryEnqueueAsync(audiobook, request?.Path); + if (queuedResult != null) { - try - { - var jobId = await _scanQueueService.EnqueueScanAsync(audiobook, request?.Path); - _logger.LogInformation("Enqueued scan job {JobId} for audiobook {AudiobookId}", jobId, id); - - // Broadcast initial job status so realtime clients can show queued state - try - { - using var scope = _scopeFactory.CreateScope(); - var hub = scope.ServiceProvider.GetRequiredService(); - var job = new { jobId = jobId.ToString(), audiobookId = id, status = "Queued", enqueuedAt = DateTime.UtcNow }; - await hub.BroadcastAsync(RealtimeHubTarget.Downloads, "ScanJobUpdate", job); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Failed to broadcast ScanJobUpdate for job {JobId}", jobId); - } - - return Accepted(new { message = "Scan enqueued", jobId }); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogError(ex, "Failed to enqueue scan job for audiobook {AudiobookId}", id); - return StatusCode(500, new { message = "Failed to enqueue scan job", error = ex.Message }); - } + return queuedResult; } - // Determine scan root: request.Path, audiobook.BasePath, or application settings output path - string? scanRoot = null; - try + var scanPathResolution = await _scanPathResolver.ResolveAsync(audiobook, request?.Path); + if (scanPathResolution.ErrorResult != null) { - using var scope = _scopeFactory.CreateScope(); - var configService = scope.ServiceProvider.GetRequiredService(); - var settings = await configService.GetApplicationSettingsAsync(); - - // If audiobook has a BasePath configured, always scan that path for safety - // Do not fall back to the global output path when a BasePath is present. - if (!string.IsNullOrEmpty(audiobook.BasePath)) - { - scanRoot = Path.GetFullPath(audiobook.BasePath); - _logger.LogDebug("Audiobook has BasePath; using it as scan root: {ScanRoot}", LogRedaction.SanitizeFilePath(scanRoot)); - } - else if (!string.IsNullOrEmpty(request?.Path)) - { - // Validate requested path is absolute and contained within a configured root folder or the global output path - string requestedFull; - try - { - requestedFull = Path.GetFullPath(request.Path!); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Invalid requested scan path provided: {Path}", LogRedaction.SanitizeFilePath(request.Path)); - return BadRequest(new { message = "Invalid scan path", path = request.Path }); - } - - // Build whitelist of allowed root paths - var allowedRoots = new List(); - if (_rootFolderService != null) - { - var roots = await _rootFolderService.GetAllAsync(); - foreach (var r in roots) - { - try - { - allowedRoots.Add(Path.GetFullPath(r.Path)); - } - catch (Exception rootPathEx) when ( - rootPathEx is ArgumentException - || rootPathEx is NotSupportedException - || rootPathEx is PathTooLongException - || rootPathEx is System.Security.SecurityException) - { - _logger.LogDebug(rootPathEx, "Skipping invalid root folder path during scan allowlist build: {RootPath}", LogRedaction.SanitizeFilePath(r.Path)); - } - } - } - - if (!string.IsNullOrEmpty(settings?.OutputPath)) - { - try - { - allowedRoots.Add(Path.GetFullPath(settings.OutputPath)); - } - catch (Exception outputPathEx) when ( - outputPathEx is ArgumentException - || outputPathEx is NotSupportedException - || outputPathEx is PathTooLongException - || outputPathEx is System.Security.SecurityException) - { - _logger.LogDebug(outputPathEx, "Skipping invalid output path during scan allowlist build: {OutputPath}", settings.OutputPath); - } - } - - if (allowedRoots.Count == 0) - { - _logger.LogWarning("Scan request path provided but no root folders are configured; rejecting request."); - return BadRequest(new { message = "No root folders configured; cannot accept explicit scan path" }); - } - - // Check that requestedFull is equal to or under one of the allowed roots - var allowed = allowedRoots.Any(ar => string.Equals(requestedFull, ar, StringComparison.OrdinalIgnoreCase) - || requestedFull.StartsWith(ar.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) - || requestedFull.StartsWith(ar.TrimEnd(Path.AltDirectorySeparatorChar) + Path.AltDirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)); - - if (!allowed) - { - _logger.LogWarning("Requested scan path {Path} is not inside configured root folders", LogRedaction.SanitizeFilePath(request.Path)); - return BadRequest(new { message = "Requested scan path is not within configured root folders", path = request.Path }); - } - - scanRoot = requestedFull; - } - else - { - // No BasePath and no explicit path - fall back to configured output path - scanRoot = !string.IsNullOrEmpty(settings?.OutputPath) ? Path.GetFullPath(settings.OutputPath) : null; - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Failed to read application settings for scan; cannot validate request path without configured roots"); - // If BasePath exists prefer it; otherwise, we cannot determine a safe scan root - if (!string.IsNullOrEmpty(audiobook.BasePath)) - { - scanRoot = Path.GetFullPath(audiobook.BasePath); - } - else - { - _logger.LogWarning("Configuration unavailable and audiobook has no BasePath; rejecting scan request for audiobook {AudiobookId}", id); - return StatusCode(500, new { message = "Failed to determine a safe scan path" }); - } + return scanPathResolution.ErrorResult; } + var scanRoot = scanPathResolution.ScanRoot; + if (string.IsNullOrEmpty(scanRoot) || !Directory.Exists(scanRoot)) { return BadRequest(new { message = "Scan path not provided or does not exist", path = scanRoot }); @@ -1939,14 +1816,7 @@ outputPathEx is ArgumentException [HttpGet("scan/{jobId}")] public IActionResult GetScanJobStatus(string jobId) { - if (_scanQueueService == null) return NotFound(new { message = "Scan queue not available" }); - if (!Guid.TryParse(jobId, out var gid)) return BadRequest(new { message = "Invalid jobId" }); - if (_scanQueueService.TryGetJob(gid, out var job)) - { - _logger.LogInformation("Queried scan job {JobId} status: {Status}", gid, job!.Status); - return Ok(job); - } - return NotFound(new { message = "Job not found" }); + return _scanQueueWorkflow.GetStatus(jobId); } /// @@ -2138,28 +2008,7 @@ public async Task RequeueMoveJob(string jobId) [HttpPost("scan/requeue/{jobId}")] public async Task RequeueScanJob(string jobId) { - if (_scanQueueService == null) return NotFound(new { message = "Scan queue not available" }); - if (!Guid.TryParse(jobId, out var gid)) return BadRequest(new { message = "Invalid jobId" }); - - var newJobId = await _scanQueueService.RequeueScanAsync(gid); - if (newJobId == null) - { - return BadRequest(new { message = "Unable to requeue job (not found or invalid status)" }); - } - - try - { - using var scope = _scopeFactory.CreateScope(); - var hub = scope.ServiceProvider.GetRequiredService(); - var job = new { jobId = newJobId.ToString(), status = "Queued", enqueuedAt = DateTime.UtcNow }; - await hub.BroadcastAsync(RealtimeHubTarget.Downloads, "ScanJobUpdate", job); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Failed to broadcast ScanJobUpdate for requeued job {JobId}", newJobId); - } - - return Accepted(new { message = "Requeued scan job", jobId = newJobId }); + return await _scanQueueWorkflow.RequeueAsync(jobId); } // Helper to convert incoming update values (possibly JsonElement or boxed types) to the target property type diff --git a/listenarr.api/Controllers/LibraryScanPathResolver.cs b/listenarr.api/Controllers/LibraryScanPathResolver.cs new file mode 100644 index 000000000..636e46396 --- /dev/null +++ b/listenarr.api/Controllers/LibraryScanPathResolver.cs @@ -0,0 +1,164 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +using Listenarr.Application.Interfaces; +using Listenarr.Application.Security; +using Listenarr.Domain.Models; +using Microsoft.AspNetCore.Mvc; + +namespace Listenarr.Api.Controllers +{ + public sealed class LibraryScanPathResolver + { + private readonly IConfigurationService _configurationService; + private readonly ILogger _logger; + private readonly IRootFolderService? _rootFolderService; + + public LibraryScanPathResolver( + IConfigurationService configurationService, + ILogger logger, + IRootFolderService? rootFolderService = null) + { + _configurationService = configurationService; + _logger = logger; + _rootFolderService = rootFolderService; + } + + public async Task ResolveAsync(Audiobook audiobook, string? requestedPath) + { + try + { + var settings = await _configurationService.GetApplicationSettingsAsync(); + + if (!string.IsNullOrEmpty(audiobook.BasePath)) + { + var basePath = Path.GetFullPath(audiobook.BasePath); + _logger.LogDebug("Audiobook has BasePath; using it as scan root: {ScanRoot}", LogRedaction.SanitizeFilePath(basePath)); + return LibraryScanPathResolution.Success(basePath); + } + + if (!string.IsNullOrEmpty(requestedPath)) + { + string requestedFull; + try + { + requestedFull = Path.GetFullPath(requestedPath); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning(ex, "Invalid requested scan path provided: {Path}", LogRedaction.SanitizeFilePath(requestedPath)); + return LibraryScanPathResolution.Failure(new BadRequestObjectResult(new { message = "Invalid scan path", path = requestedPath })); + } + + var allowedRoots = await BuildAllowedRootsAsync(settings?.OutputPath); + if (allowedRoots.Count == 0) + { + _logger.LogWarning("Scan request path provided but no root folders are configured; rejecting request."); + return LibraryScanPathResolution.Failure(new BadRequestObjectResult(new { message = "No root folders configured; cannot accept explicit scan path" })); + } + + var allowed = allowedRoots.Any(root => IsPathUnderRoot(requestedFull, root)); + if (!allowed) + { + _logger.LogWarning("Requested scan path {Path} is not inside configured root folders", LogRedaction.SanitizeFilePath(requestedPath)); + return LibraryScanPathResolution.Failure(new BadRequestObjectResult(new { message = "Requested scan path is not within configured root folders", path = requestedPath })); + } + + return LibraryScanPathResolution.Success(requestedFull); + } + + var outputPath = !string.IsNullOrEmpty(settings?.OutputPath) + ? Path.GetFullPath(settings.OutputPath) + : null; + return LibraryScanPathResolution.Success(outputPath); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning(ex, "Failed to read application settings for scan; cannot validate request path without configured roots"); + if (!string.IsNullOrEmpty(audiobook.BasePath)) + { + return LibraryScanPathResolution.Success(Path.GetFullPath(audiobook.BasePath)); + } + + _logger.LogWarning("Configuration unavailable and audiobook has no BasePath; rejecting scan request for audiobook {AudiobookId}", audiobook.Id); + return LibraryScanPathResolution.Failure(new ObjectResult(new { message = "Failed to determine a safe scan path" }) + { + StatusCode = StatusCodes.Status500InternalServerError + }); + } + } + + private async Task> BuildAllowedRootsAsync(string? outputPath) + { + var allowedRoots = new List(); + if (_rootFolderService != null) + { + var roots = await _rootFolderService.GetAllAsync(); + foreach (var root in roots) + { + TryAddAllowedRoot(allowedRoots, root.Path, "root folder path"); + } + } + + if (!string.IsNullOrEmpty(outputPath)) + { + TryAddAllowedRoot(allowedRoots, outputPath, "output path"); + } + + return allowedRoots; + } + + private void TryAddAllowedRoot(List allowedRoots, string? path, string label) + { + if (string.IsNullOrWhiteSpace(path)) + { + return; + } + + try + { + allowedRoots.Add(Path.GetFullPath(path)); + } + catch (Exception ex) when ( + ex is ArgumentException + || ex is NotSupportedException + || ex is PathTooLongException + || ex is System.Security.SecurityException) + { + _logger.LogDebug(ex, "Skipping invalid {Label} during scan allowlist build: {Path}", label, LogRedaction.SanitizeFilePath(path)); + } + } + + private static bool IsPathUnderRoot(string requestedPath, string allowedRoot) + { + var trimmedDirectoryRoot = allowedRoot.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar; + var trimmedAltRoot = allowedRoot.TrimEnd(Path.AltDirectorySeparatorChar) + Path.AltDirectorySeparatorChar; + + return string.Equals(requestedPath, allowedRoot, StringComparison.OrdinalIgnoreCase) + || requestedPath.StartsWith(trimmedDirectoryRoot, StringComparison.OrdinalIgnoreCase) + || requestedPath.StartsWith(trimmedAltRoot, StringComparison.OrdinalIgnoreCase); + } + } + + public sealed record LibraryScanPathResolution(string? ScanRoot, IActionResult? ErrorResult) + { + public static LibraryScanPathResolution Success(string? scanRoot) => new(scanRoot, null); + + public static LibraryScanPathResolution Failure(IActionResult errorResult) => new(null, errorResult); + } +} diff --git a/listenarr.api/Controllers/LibraryScanQueueWorkflow.cs b/listenarr.api/Controllers/LibraryScanQueueWorkflow.cs new file mode 100644 index 000000000..5cb5f8269 --- /dev/null +++ b/listenarr.api/Controllers/LibraryScanQueueWorkflow.cs @@ -0,0 +1,124 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +using Listenarr.Application.Interfaces; +using Listenarr.Domain.Models; +using Microsoft.AspNetCore.Mvc; + +namespace Listenarr.Api.Controllers +{ + public sealed class LibraryScanQueueWorkflow + { + private readonly IScanQueueService? _scanQueueService; + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + + public LibraryScanQueueWorkflow( + IServiceScopeFactory scopeFactory, + ILogger logger, + IScanQueueService? scanQueueService = null) + { + _scopeFactory = scopeFactory; + _logger = logger; + _scanQueueService = scanQueueService; + } + + public async Task TryEnqueueAsync(Audiobook audiobook, string? requestedPath) + { + if (_scanQueueService == null) + { + return null; + } + + try + { + var jobId = await _scanQueueService.EnqueueScanAsync(audiobook, requestedPath); + _logger.LogInformation("Enqueued scan job {JobId} for audiobook {AudiobookId}", jobId, audiobook.Id); + await BroadcastQueuedAsync(jobId, audiobook.Id); + + return new AcceptedResult(string.Empty, new { message = "Scan enqueued", jobId }); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogError(ex, "Failed to enqueue scan job for audiobook {AudiobookId}", audiobook.Id); + return new ObjectResult(new { message = "Failed to enqueue scan job", error = ex.Message }) + { + StatusCode = StatusCodes.Status500InternalServerError + }; + } + } + + public IActionResult GetStatus(string jobId) + { + if (_scanQueueService == null) + { + return new NotFoundObjectResult(new { message = "Scan queue not available" }); + } + + if (!Guid.TryParse(jobId, out var parsedJobId)) + { + return new BadRequestObjectResult(new { message = "Invalid jobId" }); + } + + if (_scanQueueService.TryGetJob(parsedJobId, out var job)) + { + _logger.LogInformation("Queried scan job {JobId} status: {Status}", parsedJobId, job!.Status); + return new OkObjectResult(job); + } + + return new NotFoundObjectResult(new { message = "Job not found" }); + } + + public async Task RequeueAsync(string jobId) + { + if (_scanQueueService == null) + { + return new NotFoundObjectResult(new { message = "Scan queue not available" }); + } + + if (!Guid.TryParse(jobId, out var parsedJobId)) + { + return new BadRequestObjectResult(new { message = "Invalid jobId" }); + } + + var newJobId = await _scanQueueService.RequeueScanAsync(parsedJobId); + if (newJobId == null) + { + return new BadRequestObjectResult(new { message = "Unable to requeue job (not found or invalid status)" }); + } + + await BroadcastQueuedAsync(newJobId.Value, audiobookId: null); + return new AcceptedResult(string.Empty, new { message = "Requeued scan job", jobId = newJobId }); + } + + private async Task BroadcastQueuedAsync(Guid jobId, int? audiobookId) + { + try + { + using var scope = _scopeFactory.CreateScope(); + var hub = scope.ServiceProvider.GetRequiredService(); + var job = new { jobId = jobId.ToString(), audiobookId, status = "Queued", enqueuedAt = DateTime.UtcNow }; + await hub.BroadcastAsync(RealtimeHubTarget.Downloads, "ScanJobUpdate", job); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning(ex, "Failed to broadcast ScanJobUpdate for job {JobId}", jobId); + } + } + } +} diff --git a/listenarr.api/Program.cs b/listenarr.api/Program.cs index 71bbf401e..4e2123489 100644 --- a/listenarr.api/Program.cs +++ b/listenarr.api/Program.cs @@ -345,6 +345,8 @@ ex is IOException // Add ASIN search handler builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); // Register named HttpClients for each adapter type so adapter implementations can request the appropriately-configured client. builder.Services.AddListenarrHttpClients(builder.Configuration); diff --git a/tests/Builders/ServiceCollectionBuilder.cs b/tests/Builders/ServiceCollectionBuilder.cs index 1f2d3fb2b..cea4aba73 100644 --- a/tests/Builders/ServiceCollectionBuilder.cs +++ b/tests/Builders/ServiceCollectionBuilder.cs @@ -187,6 +187,8 @@ private ServiceCollection BuildServices() services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); From 2fee51212956a23c61ba8cca98fd54c8484d5929 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Sat, 13 Jun 2026 00:18:16 -0400 Subject: [PATCH 19/84] Refactor backend services into focused workflows --- fe/package-lock.json | 1582 +++++++-------- .../Controllers/LibraryAddWorkflow.cs | 408 ++++ .../Controllers/LibraryBulkEditWorkflow.cs | 379 ++++ .../Controllers/LibraryController.cs | 1738 +---------------- .../Controllers/LibraryDeleteWorkflow.cs | 157 ++ .../Controllers/LibraryIdentifierWorkflow.cs | 224 +++ .../Controllers/LibraryManualScanWorkflow.cs | 416 ++++ .../Controllers/LibraryMoveWorkflow.cs | 201 ++ .../Controllers/LibraryUpdateWorkflow.cs | 190 ++ .../Controllers/MetadataController.cs | 447 +---- .../Controllers/MetadataImageCacheWorkflow.cs | 100 + .../MetadataLookupCacheWorkflow.cs | 284 +++ .../Controllers/MetadataResponseMapper.cs | 117 ++ listenarr.api/Program.cs | 10 + .../Downloads/DownloadCachedTorrentStore.cs | 115 ++ .../Downloads/DownloadService.cs | 481 +---- .../MyAnonamouseTorrentPreparationService.cs | 480 +++++ .../Metadata/AudibleApiClient.cs | 134 ++ .../Metadata/AudibleAuthorCatalogMatcher.cs | 75 + .../Metadata/AudibleAuthorCatalogWorkflow.cs | 455 +++++ .../Metadata/AudibleAuthorLookupWorkflow.cs | 179 ++ .../Metadata/AudibleLookupJsonParser.cs | 113 ++ .../AudibleProductMetadataWorkflow.cs | 130 ++ .../Metadata/AudibleProductSearchWorkflow.cs | 283 +++ .../Metadata/AudibleSearchResultFilter.cs | 62 + .../Metadata/AudibleSeriesWorkflow.cs | 346 ++++ .../Metadata/AudibleService.cs | 1487 +------------- .../Metadata/SearchProductsDirectResponse.cs | 29 + .../Search/IndexerAdditionalSettingsParser.cs | 104 + .../Search/IndexerQuerySanitizer.cs | 51 + .../Search/IndexerSearchWorkflow.cs | 317 +++ .../Search/MetadataSourceCatalog.cs | 70 + listenarr.application/Search/SearchService.cs | 902 +-------- .../Search/TorznabResponseParser.cs | 504 +++++ .../Adapters/QbittorrentAdapter.cs | 285 +-- .../Adapters/QbittorrentResponseMapper.cs | 234 +++ .../Adapters/QbittorrentSeedLimitEvaluator.cs | 79 + .../Adapters/TransmissionAdapter.cs | 338 +--- .../Adapters/TransmissionResponseMapper.cs | 259 +++ .../TransmissionSeedLimitEvaluator.cs | 78 + .../AppServiceRegistrationExtensions.cs | 4 + tests/Builders/ServiceCollectionBuilder.cs | 11 + .../Api/Services/AudibleServiceTests.cs | 94 + .../Adapters/QbittorrentAdapterTests.cs | 35 + .../Adapters/TransmissionAdapterTests.cs | 42 + 45 files changed, 7777 insertions(+), 6252 deletions(-) create mode 100644 listenarr.api/Controllers/LibraryAddWorkflow.cs create mode 100644 listenarr.api/Controllers/LibraryBulkEditWorkflow.cs create mode 100644 listenarr.api/Controllers/LibraryDeleteWorkflow.cs create mode 100644 listenarr.api/Controllers/LibraryIdentifierWorkflow.cs create mode 100644 listenarr.api/Controllers/LibraryManualScanWorkflow.cs create mode 100644 listenarr.api/Controllers/LibraryMoveWorkflow.cs create mode 100644 listenarr.api/Controllers/LibraryUpdateWorkflow.cs create mode 100644 listenarr.api/Controllers/MetadataImageCacheWorkflow.cs create mode 100644 listenarr.api/Controllers/MetadataLookupCacheWorkflow.cs create mode 100644 listenarr.api/Controllers/MetadataResponseMapper.cs create mode 100644 listenarr.application/Downloads/DownloadCachedTorrentStore.cs create mode 100644 listenarr.application/Downloads/MyAnonamouseTorrentPreparationService.cs create mode 100644 listenarr.application/Metadata/AudibleApiClient.cs create mode 100644 listenarr.application/Metadata/AudibleAuthorCatalogMatcher.cs create mode 100644 listenarr.application/Metadata/AudibleAuthorCatalogWorkflow.cs create mode 100644 listenarr.application/Metadata/AudibleAuthorLookupWorkflow.cs create mode 100644 listenarr.application/Metadata/AudibleLookupJsonParser.cs create mode 100644 listenarr.application/Metadata/AudibleProductMetadataWorkflow.cs create mode 100644 listenarr.application/Metadata/AudibleProductSearchWorkflow.cs create mode 100644 listenarr.application/Metadata/AudibleSearchResultFilter.cs create mode 100644 listenarr.application/Metadata/AudibleSeriesWorkflow.cs create mode 100644 listenarr.application/Metadata/SearchProductsDirectResponse.cs create mode 100644 listenarr.application/Search/IndexerAdditionalSettingsParser.cs create mode 100644 listenarr.application/Search/IndexerQuerySanitizer.cs create mode 100644 listenarr.application/Search/IndexerSearchWorkflow.cs create mode 100644 listenarr.application/Search/MetadataSourceCatalog.cs create mode 100644 listenarr.application/Search/TorznabResponseParser.cs create mode 100644 listenarr.infrastructure/Adapters/QbittorrentResponseMapper.cs create mode 100644 listenarr.infrastructure/Adapters/QbittorrentSeedLimitEvaluator.cs create mode 100644 listenarr.infrastructure/Adapters/TransmissionResponseMapper.cs create mode 100644 listenarr.infrastructure/Adapters/TransmissionSeedLimitEvaluator.cs diff --git a/fe/package-lock.json b/fe/package-lock.json index d58b87902..0d0df8ddd 100644 --- a/fe/package-lock.json +++ b/fe/package-lock.json @@ -49,14 +49,7 @@ "vue-tsc": "^3.3.4" }, "engines": { - "node": "^24.15.0" - } - }, - "..": { - "version": "1.0.0", - "extraneous": true, - "dependencies": { - "concurrently": "^9.2.1" + "node": ">=24.15.0" } }, "node_modules/@asamuzakjp/css-color": { @@ -253,9 +246,9 @@ } }, "node_modules/@csstools/css-calc": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.0.tgz", - "integrity": "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", "dev": true, "funding": [ { @@ -277,9 +270,9 @@ } }, "node_modules/@csstools/css-color-parser": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.0.tgz", - "integrity": "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.1.tgz", + "integrity": "sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==", "dev": true, "funding": [ { @@ -294,7 +287,7 @@ "license": "MIT", "dependencies": { "@csstools/color-helpers": "^6.0.2", - "@csstools/css-calc": "^3.2.0" + "@csstools/css-calc": "^3.2.1" }, "engines": { "node": ">=20.19.0" @@ -426,6 +419,7 @@ "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -433,26 +427,52 @@ "tslib": "^2.4.0" } }, + "node_modules/@emnapi/core/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, "node_modules/@emnapi/runtime": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { "tslib": "^2.4.0" } }, + "node_modules/@emnapi/runtime/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { "tslib": "^2.4.0" } }, + "node_modules/@emnapi/wasi-threads/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -497,45 +517,6 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@eslint/config-array/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@eslint/config-array/node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/@eslint/config-helpers": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", @@ -587,9 +568,9 @@ } }, "node_modules/@exodus/bytes": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", - "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", "dev": true, "license": "MIT", "engines": { @@ -659,29 +640,43 @@ } }, "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", "dev": true, "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, "engines": { "node": ">=18.18.0" } }, "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@humanfs/core": "^0.19.1", + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" }, "engines": { "node": ">=18.18.0" } }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -728,32 +723,6 @@ "node": ">=12" } }, - "node_modules/@isaacs/cliui/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/@isaacs/cliui/node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", @@ -779,40 +748,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@isaacs/cliui/node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -881,6 +816,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -974,13 +910,13 @@ } }, "node_modules/@pkgr/core": { - "version": "0.2.9", - "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", - "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.3.6.tgz", + "integrity": "sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==", "dev": true, "license": "MIT", "engines": { - "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + "node": "^14.18.0 || >=16.0.0" }, "funding": { "url": "https://opencollective.com/pkgr" @@ -993,6 +929,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1009,6 +946,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1025,6 +963,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1041,6 +980,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1057,6 +997,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1073,9 +1014,7 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1092,9 +1031,7 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1111,9 +1048,7 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1130,9 +1065,7 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1149,9 +1082,7 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1168,9 +1099,7 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1187,6 +1116,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1203,6 +1133,7 @@ "cpu": [ "wasm32" ], + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1221,6 +1152,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1237,6 +1169,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1271,12 +1204,21 @@ "version": "0.10.2", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { "tslib": "^2.4.0" } }, + "node_modules/@tybys/wasm-util/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -1303,9 +1245,9 @@ "license": "MIT" }, "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "dev": true, "license": "MIT" }, @@ -1339,7 +1281,7 @@ "version": "24.13.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.1.tgz", "integrity": "sha512-RSpUJGmvsJ1ZeBehQZFhIdpsz+bIpES0nIQXko4Ybq+N+kX6XvOq3Jo+iJ82FWLdblFq85AsMikd3m35jgezYg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "undici-types": "~7.18.0" @@ -1349,7 +1291,7 @@ "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/@types/sinonjs__fake-timers": { @@ -1387,17 +1329,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.1.tgz", - "integrity": "sha512-JQ4S5GB0tfjO8BuJ4fcX+HodkzJjYBV+7OJ+wLygaX7OGQ7FudyHL4NSCA6ob+w3Yn+5MkKIozOwQhXeM7opVg==", + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.0.tgz", + "integrity": "sha512-bFNvl9ZczlVb+wR2Akszf3gHfKVj/8WanXaGJ3UstTA7brNKg0cNdk6X1Psu5V7MZ2oQtzZKOEzIUehaoxbDGw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.60.1", - "@typescript-eslint/type-utils": "8.60.1", - "@typescript-eslint/utils": "8.60.1", - "@typescript-eslint/visitor-keys": "8.60.1", + "@typescript-eslint/scope-manager": "8.61.0", + "@typescript-eslint/type-utils": "8.61.0", + "@typescript-eslint/utils": "8.61.0", + "@typescript-eslint/visitor-keys": "8.61.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -1410,7 +1352,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.60.1", + "@typescript-eslint/parser": "^8.61.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } @@ -1426,16 +1368,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.60.1.tgz", - "integrity": "sha512-A0M6ua6H252bVjPvvtSgl2QA4+ET9S5Mtkb2GDyTxIhH/C4qDItT7RQNO5PhMC6NXGYXOR9dIalcDDgBKT7oFA==", + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.61.0.tgz", + "integrity": "sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.60.1", - "@typescript-eslint/types": "8.60.1", - "@typescript-eslint/typescript-estree": "8.60.1", - "@typescript-eslint/visitor-keys": "8.60.1", + "@typescript-eslint/scope-manager": "8.61.0", + "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/typescript-estree": "8.61.0", + "@typescript-eslint/visitor-keys": "8.61.0", "debug": "^4.4.3" }, "engines": { @@ -1451,14 +1393,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.60.1.tgz", - "integrity": "sha512-eXkTH2bxmXlqD1RnOPmLZ9ZM9D3VwSx04JOwBnP9RQ+yUA5a2Mu7SfW8uaV2Aon53NJzZlZYuX7tn91Izf+xaw==", + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.61.0.tgz", + "integrity": "sha512-DV42F7MLJO6Rax7SK1yg43tcnEfGUrurSpSxKuVX+a3RCTzBlH3fuxprrOJXKCJGAaw82xXocikJ0uQaqwXgGA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.60.1", - "@typescript-eslint/types": "^8.60.1", + "@typescript-eslint/tsconfig-utils": "^8.61.0", + "@typescript-eslint/types": "^8.61.0", "debug": "^4.4.3" }, "engines": { @@ -1473,14 +1415,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.60.1.tgz", - "integrity": "sha512-gvI5OQoptnxQnchOirukCuQ55svJSTuD/4k5+pC267xyBtYry748R9/c3tYUzb/iE6RZfllRz2lVulLCHkTm4w==", + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.61.0.tgz", + "integrity": "sha512-IWdXFHFSb6mlC3HPc7QsLDm5zYEbUla6trDEHf32D3/dnuUyXd87plScSNXSbm0/RxMvObpI17sv/EDTGrGZkA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.60.1", - "@typescript-eslint/visitor-keys": "8.60.1" + "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/visitor-keys": "8.61.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1491,9 +1433,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.1.tgz", - "integrity": "sha512-nh8w4qAteiKuZu3pSSzG/yGKpw0OlkrKnzFmbVRenKaD4qc+7i1GrmZaLVkr8rk4uipiPGMOW4YsM6WmKZ5CvA==", + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.0.tgz", + "integrity": "sha512-O5Amvdv9ztMpxpf+vmFULGG78IE6Qwdr3bCGvqwG4nwc9H2qXkOYJJnRbRHyMkQTjv1d03olqwwwzHLMqpFePQ==", "dev": true, "license": "MIT", "engines": { @@ -1508,15 +1450,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.60.1.tgz", - "integrity": "sha512-sdwTrpjosW7ANQYJ39ZBF1ZyEMEGVB2UsikrserVM/30a/F1dTLnu9bGxEdosugyu5caigjLrR2qiD11asjI1A==", + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.61.0.tgz", + "integrity": "sha512-TuBiQYIkd97yBfInHCTKVYMbX4kvEmpOEuixIuzCU9p8BGT1SfyyO0d0IfDMbPIHcjn/hWnusUX5e8v5Xg+X8A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.60.1", - "@typescript-eslint/typescript-estree": "8.60.1", - "@typescript-eslint/utils": "8.60.1", + "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/typescript-estree": "8.61.0", + "@typescript-eslint/utils": "8.61.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -1533,9 +1475,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.1.tgz", - "integrity": "sha512-4h0tY8ppCkdCzcrl2YM5M3my0xsE1Tf8om3owEu5oPWmXwkKRmk0j0LGDzYBGUcAlesEbxBhazqu/K4cu3Ug7w==", + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.61.0.tgz", + "integrity": "sha512-9QTQpZ5Iin4CdIodfbDQFSeiSJKidgYJYug1P9CC2xWgUTvlmixViqDZNciMjwLBZyJnG4tGmPl97rVAFb1AJg==", "dev": true, "license": "MIT", "engines": { @@ -1547,16 +1489,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.1.tgz", - "integrity": "sha512-alpRkfG8hlVE5kdJW2GkfgDgXxold3e8e4l6EnmhRmRLbekgAPCCGDVD++sABy9FcgPFroq+uFcCSM1vR57Cew==", + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.0.tgz", + "integrity": "sha512-42zatd5qSvvcV1JdDBCLxYRznvP4eIHpPoZXdkPFnAmanA4FuZ5dibSnCBggY8hQnqajPpoGjXFdZ7fIJKQnlA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.60.1", - "@typescript-eslint/tsconfig-utils": "8.60.1", - "@typescript-eslint/types": "8.60.1", - "@typescript-eslint/visitor-keys": "8.60.1", + "@typescript-eslint/project-service": "8.61.0", + "@typescript-eslint/tsconfig-utils": "8.61.0", + "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/visitor-keys": "8.61.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -1574,56 +1516,17 @@ "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/@typescript-eslint/utils": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.60.1.tgz", - "integrity": "sha512-h2MPBLoNtjc3qZWfY3Tl51yPorQ2McHn8pJfcMNTcIvrrZrr90Ykffit0yjrPFWQcRcUxzH20+6OcVdW4yHtUg==", + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.61.0.tgz", + "integrity": "sha512-3bzFt7ImFMW/jVYwJamDoe/dMOdFLSC6pom6rRjdh4SZJEYupyMzem8e7vKZLclLfpHjlwSAXOUxtKxGXUiLqA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.60.1", - "@typescript-eslint/types": "8.60.1", - "@typescript-eslint/typescript-estree": "8.60.1" + "@typescript-eslint/scope-manager": "8.61.0", + "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/typescript-estree": "8.61.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1638,13 +1541,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.1.tgz", - "integrity": "sha512-EbGRQg4FhrmwLodl+t3JNAnXHWVr9Vp+Zl1QBZVPY4ByfkzIT8cX3K6QWODHtkIZqqJVEWvhHSx3v5PDHsaQag==", + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.61.0.tgz", + "integrity": "sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.60.1", + "@typescript-eslint/types": "8.61.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -2240,6 +2143,23 @@ "node": ">= 6.0.0" } }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/alien-signals": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.2.1.tgz", @@ -2264,26 +2184,26 @@ } }, "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "dev": true, "license": "MIT", "engines": { - "node": ">=8" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "dev": true, "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, "engines": { - "node": ">=8" + "node": ">=12" }, "funding": { "url": "https://github.com/chalk/ansi-styles?sponsor=1" @@ -2438,11 +2358,14 @@ } }, "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } }, "node_modules/base64-js": { "version": "1.5.1", @@ -2516,13 +2439,16 @@ "license": "ISC" }, "node_modules/brace-expansion": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", - "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/braces": { @@ -2590,15 +2516,15 @@ } }, "node_modules/call-bind": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", - "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", "dev": true, "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.0", - "es-define-property": "^1.0.0", - "get-intrinsic": "^1.2.4", + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" }, "engines": { @@ -2655,35 +2581,18 @@ } }, "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", "dev": true, "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, "engines": { - "node": ">=10" + "node": "^12.17.0 || ^14.13 || >=16.0.0" }, "funding": { "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/chalk/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/check-more-types": { "version": "2.24.0", "resolved": "https://registry.npmjs.org/check-more-types/-/check-more-types-2.24.0.tgz", @@ -2774,19 +2683,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/cli-truncate/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, "node_modules/cli-truncate/node_modules/string-width": { "version": "8.2.1", "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", @@ -2804,22 +2700,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/cli-truncate/node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, "node_modules/cliui": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", @@ -2835,19 +2715,6 @@ "node": ">=20" } }, - "node_modules/cliui/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, "node_modules/cliui/node_modules/emoji-regex": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", @@ -2873,20 +2740,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/cliui/node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", "dev": true, "license": "MIT", "dependencies": { - "ansi-regex": "^6.2.2" + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=12" + "node": ">=18" }, "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, "node_modules/color-convert": { @@ -2985,32 +2854,6 @@ "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" } }, - "node_modules/concurrently/node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/concurrently/node_modules/supports-color": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", - "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, "node_modules/confbox": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", @@ -3167,12 +3010,67 @@ "node": "^20.1.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/cypress/node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "node_modules/cypress/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, - "license": "0BSD" + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cypress/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cypress/node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cypress/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } }, "node_modules/dashdash": { "version": "1.14.1", @@ -3202,9 +3100,9 @@ } }, "node_modules/dayjs": { - "version": "1.11.19", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", - "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", + "version": "1.11.21", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", + "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", "dev": true, "license": "MIT" }, @@ -3372,6 +3270,23 @@ "node": ">=14" } }, + "node_modules/editorconfig/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/editorconfig/node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, "node_modules/editorconfig/node_modules/commander": { "version": "10.0.1", "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", @@ -3382,6 +3297,22 @@ "node": ">=14" } }, + "node_modules/editorconfig/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -3443,16 +3374,16 @@ } }, "node_modules/es-module-lexer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", - "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", "dev": true, "license": "MIT" }, "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -3592,14 +3523,14 @@ } }, "node_modules/eslint-plugin-prettier": { - "version": "5.5.5", - "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.5.tgz", - "integrity": "sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw==", + "version": "5.5.6", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.6.tgz", + "integrity": "sha512-ifetmTcxWfz+4qRW3pH/ujdTq2jQIj59AxJMIN26K5avYgU8dxycUETQonWiW+wPrYXA0j3Try0l1CnwVQtDqQ==", "dev": true, "license": "MIT", "dependencies": { "prettier-linter-helpers": "^1.0.1", - "synckit": "^0.11.12" + "synckit": "^0.11.13" }, "engines": { "node": "^14.18.0 || >=16.0.0" @@ -3686,46 +3617,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/eslint/node_modules/balanced-match": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.3.tgz", - "integrity": "sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/eslint/node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, "node_modules/eslint/node_modules/eslint-visitor-keys": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", @@ -3739,29 +3630,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/eslint/node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/espree": { "version": "11.2.0", "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", @@ -4221,6 +4089,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -4251,9 +4120,9 @@ } }, "node_modules/get-east-asian-width": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", - "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", "dev": true, "license": "MIT", "engines": { @@ -4361,12 +4230,45 @@ "node": ">=10.13.0" } }, - "node_modules/global-dirs": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-3.0.1.tgz", - "integrity": "sha512-NBcGGFbBA9s1VzD41QXDG+3++t9Mn5t1FpLdhESY6oKY4gYTFpX4wO3sqGUa0Srjtbfj3szX0RnemmrVRUdULA==", + "node_modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true, - "license": "MIT", + "license": "MIT" + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/global-dirs": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-3.0.1.tgz", + "integrity": "sha512-NBcGGFbBA9s1VzD41QXDG+3++t9Mn5t1FpLdhESY6oKY4gYTFpX4wO3sqGUa0Srjtbfj3szX0RnemmrVRUdULA==", + "dev": true, + "license": "MIT", "dependencies": { "ini": "2.0.0" }, @@ -4478,9 +4380,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -4625,13 +4527,19 @@ } }, "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", "dev": true, "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, "engines": { - "node": ">=8" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/is-glob": { @@ -4838,7 +4746,7 @@ "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", - "devOptional": true, + "dev": true, "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" @@ -4941,22 +4849,22 @@ } }, "node_modules/jsdom/node_modules/tldts": { - "version": "7.0.27", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.27.tgz", - "integrity": "sha512-I4FZcVFcqCRuT0ph6dCDpPuO4Xgzvh+spkcTr1gK7peIvxWauoloVO0vuy1FQnijT63ss6AsHB6+OIM4aXHbPg==", + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.2.tgz", + "integrity": "sha512-kCwffuaH8ntKtygnWe1b4BJKWiCUH30n5KfoTr6IchcXOwR7chAOFJxFrH3vjANafUYrIA4a7SDL+nn7SiR4Sw==", "dev": true, "license": "MIT", "dependencies": { - "tldts-core": "^7.0.27" + "tldts-core": "^7.4.2" }, "bin": { "tldts": "bin/cli.js" } }, "node_modules/jsdom/node_modules/tldts-core": { - "version": "7.0.27", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.27.tgz", - "integrity": "sha512-YQ7uPjgWUibIK6DW5lrKujGwUKhLevU4hcGbP5O6TcIUb+oTjJYJVWPS4nZsIHrEEEG6myk/oqAJUEQmpZrHsg==", + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.2.tgz", + "integrity": "sha512-nwEyF4vl4RSJjwSjBUmOSxc3BFPoIFdlRthJ6e+5v9P3bHNsoD06UjuqMUspqp7vsEZ1beaHi1km+optiE17yA==", "dev": true, "license": "MIT" }, @@ -5019,6 +4927,13 @@ "dev": true, "license": "(AFL-2.1 OR BSD-3-Clause)" }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/json-stable-stringify": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz", @@ -5066,9 +4981,9 @@ } }, "node_modules/jsonfile": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", - "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", "dev": true, "license": "MIT", "dependencies": { @@ -5185,6 +5100,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5205,6 +5121,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5225,6 +5142,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5245,6 +5163,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5265,6 +5184,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5285,6 +5205,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5305,6 +5226,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5325,6 +5247,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5345,6 +5268,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5365,6 +5289,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5385,6 +5310,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5416,10 +5342,53 @@ "node": ">=20.0.0" } }, + "node_modules/listr2/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/listr2/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/listr2/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/local-pkg": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.2.tgz", - "integrity": "sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.2.1.tgz", + "integrity": "sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==", "license": "MIT", "dependencies": { "mlly": "^1.7.4", @@ -5480,60 +5449,64 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/log-update": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", - "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", + "node_modules/log-symbols/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, "license": "MIT", "dependencies": { - "ansi-escapes": "^7.0.0", - "cli-cursor": "^5.0.0", - "slice-ansi": "^7.1.0", - "strip-ansi": "^7.1.0", - "wrap-ansi": "^9.0.0" + "color-convert": "^2.0.1" }, "engines": { - "node": ">=18" + "node": ">=8" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/log-update/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "node_modules/log-symbols/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, "engines": { - "node": ">=12" + "node": ">=10" }, "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" + "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/log-update/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "node_modules/log-symbols/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, "license": "MIT", - "engines": { - "node": ">=12" + "dependencies": { + "has-flag": "^4.0.0" }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "engines": { + "node": ">=8" } }, - "node_modules/log-update/node_modules/is-fullwidth-code-point": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", - "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "node_modules/log-update": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", + "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", "dev": true, "license": "MIT", "dependencies": { - "get-east-asian-width": "^1.3.1" + "ansi-escapes": "^7.0.0", + "cli-cursor": "^5.0.0", + "slice-ansi": "^7.1.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" }, "engines": { "node": ">=18" @@ -5542,6 +5515,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/log-update/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, "node_modules/log-update/node_modules/slice-ansi": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", @@ -5559,26 +5539,46 @@ "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, - "node_modules/log-update/node_modules/strip-ansi": { + "node_modules/log-update/node_modules/string-width": { "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "dev": true, "license": "MIT", "dependencies": { - "ansi-regex": "^6.2.2" + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=12" + "node": ">=18" }, "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, "node_modules/lru-cache": { - "version": "11.3.5", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", - "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -5712,16 +5712,16 @@ } }, "node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^2.0.2" + "brace-expansion": "^5.0.5" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -5914,19 +5914,6 @@ "npm": ">= 10" } }, - "node_modules/npm-run-all2/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/npm-run-all2/node_modules/isexe": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", @@ -6015,15 +6002,18 @@ } }, "node_modules/obug": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", - "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.2.tgz", + "integrity": "sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg==", "dev": true, "funding": [ "https://github.com/sponsors/sxzz", "https://opencollective.com/debug" ], - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } }, "node_modules/once": { "version": "1.4.0", @@ -6188,25 +6178,58 @@ "npm": ">5" } }, - "node_modules/patch-package/node_modules/ci-info": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", - "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "node_modules/patch-package/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, "engines": { "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/patch-package/node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "node_modules/patch-package/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/patch-package/node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/patch-package/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", "dev": true, "license": "MIT", @@ -6219,6 +6242,19 @@ "node": ">=12" } }, + "node_modules/patch-package/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/path-browserify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", @@ -6316,9 +6352,9 @@ } }, "node_modules/pidtree": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.6.0.tgz", - "integrity": "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==", + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.6.1.tgz", + "integrity": "sha512-e0F9AOF1JMrCfBsyJOwU9lNvQ0WtXTq0j/4jk0BQ5JSI9VAybPXmDpPRw/2FQ3e5d3ZFN1mLh7jW99m/jjaptw==", "dev": true, "license": "MIT", "bin": { @@ -6360,13 +6396,13 @@ } }, "node_modules/pkg-types": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", - "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz", + "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==", "license": "MIT", "dependencies": { - "confbox": "^0.2.2", - "exsolve": "^1.0.7", + "confbox": "^0.2.4", + "exsolve": "^1.0.8", "pathe": "^2.0.3" } }, @@ -6399,9 +6435,9 @@ } }, "node_modules/postcss-selector-parser": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", - "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.2.tgz", + "integrity": "sha512-Wjvt4scRFouioIInHf51IFNP4ltJ2EngJM+cZPGiqbKetBfmP3vpdPV8ID2S6JS6/jdo74N8+aEYH9lQr2C6sA==", "dev": true, "license": "MIT", "dependencies": { @@ -6514,9 +6550,9 @@ } }, "node_modules/pump": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", - "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", "dev": true, "license": "MIT", "dependencies": { @@ -6772,22 +6808,6 @@ } } }, - "node_modules/rollup-plugin-visualizer/node_modules/is-wsl": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", - "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-inside-container": "^1.0.0" - }, - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/rollup-plugin-visualizer/node_modules/open": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/open/-/open-11.0.0.tgz", @@ -6822,23 +6842,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/rollup-plugin-visualizer/node_modules/wsl-utils": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.3.1.tgz", - "integrity": "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-wsl": "^3.1.0", - "powershell-utils": "^0.1.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/run-applescript": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", @@ -6886,6 +6889,13 @@ "tslib": "^2.1.0" } }, + "node_modules/rxjs/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -6934,9 +6944,9 @@ "license": "MIT" }, "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.3.tgz", + "integrity": "sha512-wnilbGyMxzbY7dNOl7jpKbLSjcfeweJWU5j4+u5qW+6/wuGD9KzIGOyZnQVSBM9E7DtWaaH3CyHkppYrKYoxwg==", "dev": true, "license": "ISC", "bin": { @@ -7007,14 +7017,14 @@ } }, "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -7026,13 +7036,13 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -7119,35 +7129,6 @@ "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, - "node_modules/slice-ansi/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/slice-ansi/node_modules/is-fullwidth-code-point": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", - "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.3.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/source-map": { "version": "0.7.6", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", @@ -7281,9 +7262,9 @@ } }, "node_modules/std-env": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", - "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", "dev": true, "license": "MIT" }, @@ -7318,7 +7299,60 @@ "node": ">=8" } }, - "node_modules/strip-ansi": { + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", @@ -7331,6 +7365,22 @@ "node": ">=8" } }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/strip-ansi-cjs": { "name": "strip-ansi", "version": "6.0.1", @@ -7345,6 +7395,16 @@ "node": ">=8" } }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/strip-final-newline": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", @@ -7368,16 +7428,13 @@ } }, "node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", "dev": true, "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { "url": "https://github.com/chalk/supports-color?sponsor=1" @@ -7391,13 +7448,13 @@ "license": "MIT" }, "node_modules/synckit": { - "version": "0.11.12", - "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz", - "integrity": "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==", + "version": "0.11.13", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.13.tgz", + "integrity": "sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==", "dev": true, "license": "MIT", "dependencies": { - "@pkgr/core": "^0.2.9" + "@pkgr/core": "^0.3.6" }, "engines": { "node": "^14.18.0 || >=16.0.0" @@ -7451,9 +7508,9 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", - "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", "dev": true, "license": "MIT", "engines": { @@ -7608,10 +7665,10 @@ } }, "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "devOptional": true, + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true, "license": "0BSD" }, "node_modules/tunnel-agent": { @@ -7672,16 +7729,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.60.1.tgz", - "integrity": "sha512-6m5hkkRAp8lKvhVpcprAIn5KkehQEh+47oHH2VGnExEh7dhNxXlg6GPAOIu6TxbVQxhebrJDvjl3020ooiWCMA==", + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.61.0.tgz", + "integrity": "sha512-8y31Rd0eGTrDKqhy6vT0HtzhN+YLjQizwX3aA3hPXP/ynSfnrBXcQY5IzsP9/DM7+klX4IUncZZjkchP0z+rUw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.60.1", - "@typescript-eslint/parser": "8.60.1", - "@typescript-eslint/typescript-estree": "8.60.1", - "@typescript-eslint/utils": "8.60.1" + "@typescript-eslint/eslint-plugin": "8.61.0", + "@typescript-eslint/parser": "8.61.0", + "@typescript-eslint/typescript-estree": "8.61.0", + "@typescript-eslint/utils": "8.61.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -7696,9 +7753,9 @@ } }, "node_modules/ufo": { - "version": "1.6.3", - "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", - "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", "license": "MIT" }, "node_modules/undici": { @@ -7712,9 +7769,9 @@ } }, "node_modules/undici-types": { - "version": "7.24.6", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", - "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.27.2.tgz", + "integrity": "sha512-cH9f42mHuljpNuoS47sWDDWXVxWnJgYCzHVUlr3tn7+HVx0L6QSO+VG5qgzT4kXkR2K8ZsReaT5bupam6RNAEQ==", "dev": true, "license": "MIT" }, @@ -8057,16 +8114,16 @@ } }, "node_modules/vue-component-type-helpers": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-3.2.7.tgz", - "integrity": "sha512-+gPp5YGmhfsj1IN+xUo7y0fb4clfnOiiUA39y07yW1VzCRjzVgwLbtmdWlghh7mXrPsEaYc7rrIir/HT6C8vYQ==", + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-3.3.4.tgz", + "integrity": "sha512-joip1uZTaQR0nD23N400gIdJ7xY+WiiiMA/BCKz842gvGBknqDQAzklUvDEhqFvvrhQY8S2ZANBMu4X70VMFGw==", "dev": true, "license": "MIT" }, "node_modules/vue-eslint-parser": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-10.4.0.tgz", - "integrity": "sha512-Vxi9pJdbN3ZnVGLODVtZ7y4Y2kzAAE2Cm0CZ3ZDRvydVYxZ6VrnBhLikBsRS+dpwj4Jv4UCv21PTEwF5rQ9WXg==", + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-10.4.1.tgz", + "integrity": "sha512-Gk6gRDj0n/fkRa3C3l0bBheoBckUq/Rs0F/TvMWIS6nzzx67amAViMe9CkNgsP2tXyQONvGiHQESHwFtZ3aYDA==", "dev": true, "license": "MIT", "dependencies": { @@ -8088,9 +8145,9 @@ } }, "node_modules/vue-eslint-parser/node_modules/eslint-visitor-keys": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.0.tgz", - "integrity": "sha512-A0XeIi7CXU7nPlfHS9loMYEKxUaONu/hTEzHTGba9Huu94Cq1hPivf+DE5erJozZOky0LfvXAyrV/tcswpLI0Q==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -8345,18 +8402,18 @@ } }, "node_modules/wrap-ansi": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", - "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", "dev": true, "license": "MIT", "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" }, "engines": { - "node": ">=18" + "node": ">=12" }, "funding": { "url": "https://github.com/chalk/wrap-ansi?sponsor=1" @@ -8381,71 +8438,68 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/wrap-ansi/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" + "node": ">=8" } }, - "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, "engines": { - "node": ">=12" + "node": ">=8" }, "funding": { "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/wrap-ansi/node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "dev": true, - "license": "MIT" - }, - "node_modules/wrap-ansi/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "dev": true, "license": "MIT", "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" + "ansi-regex": "^5.0.1" }, "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=8" } }, - "node_modules/wrap-ansi/node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "node_modules/wrap-ansi/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", "dev": true, "license": "MIT", "dependencies": { - "ansi-regex": "^6.2.2" + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" }, "engines": { "node": ">=12" }, "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/wrappy": { @@ -8456,9 +8510,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "7.5.10", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", - "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "version": "7.5.11", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz", + "integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==", "license": "MIT", "engines": { "node": ">=8.3.0" @@ -8476,6 +8530,39 @@ } } }, + "node_modules/wsl-utils": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.3.1.tgz", + "integrity": "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0", + "powershell-utils": "^0.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/wsl-utils/node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/xml-name-validator": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", @@ -8546,19 +8633,6 @@ "node": "^20.19.0 || ^22.12.0 || >=23" } }, - "node_modules/yargs/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, "node_modules/yargs/node_modules/emoji-regex": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", @@ -8584,26 +8658,10 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/yargs/node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, "node_modules/yauzl": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.3.2.tgz", - "integrity": "sha512-Md9ankxxN23wncAN8s7+Tn3Co52zLUPMtnrLAbVCnfG5d2tKBFfmygYSgXlqFgXObtzIgqkx7aNgDBpso9+4qA==", + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.4.0.tgz", + "integrity": "sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==", "dev": true, "license": "MIT", "dependencies": { @@ -8627,4 +8685,4 @@ } } } -} +} \ No newline at end of file diff --git a/listenarr.api/Controllers/LibraryAddWorkflow.cs b/listenarr.api/Controllers/LibraryAddWorkflow.cs new file mode 100644 index 000000000..679709a65 --- /dev/null +++ b/listenarr.api/Controllers/LibraryAddWorkflow.cs @@ -0,0 +1,408 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +using System.Security.Cryptography; +using System.Text; +using Listenarr.Application.Audiobooks; +using Listenarr.Application.Interfaces; +using Listenarr.Application.Interfaces.Repositories; +using Listenarr.Application.Metadata; +using Listenarr.Application.Notification; +using Listenarr.Application.Security; +using Listenarr.Domain.Common; +using Listenarr.Domain.Models; +using Microsoft.AspNetCore.Mvc; + +namespace Listenarr.Api.Controllers +{ + public sealed class LibraryAddWorkflow + { + private readonly IAudiobookRepository _repo; + private readonly IImageCacheService _imageCacheService; + private readonly IServiceScopeFactory _scopeFactory; + private readonly IHistoryRepository _historyRepository; + private readonly NotificationService? _notificationService; + private readonly ILibraryAddService? _libraryAddService; + private readonly ILogger _logger; + + public LibraryAddWorkflow( + IAudiobookRepository repo, + IImageCacheService imageCacheService, + IServiceScopeFactory scopeFactory, + IHistoryRepository historyRepository, + ILogger logger, + NotificationService? notificationService = null, + ILibraryAddService? libraryAddService = null) + { + _repo = repo; + _imageCacheService = imageCacheService; + _scopeFactory = scopeFactory; + _historyRepository = historyRepository; + _logger = logger; + _notificationService = notificationService; + _libraryAddService = libraryAddService; + } + + public async Task AddAsync(LibraryController.AddToLibraryRequest request) + { + if (_libraryAddService != null) + { + var result = await _libraryAddService.AddToLibraryAsync(new LibraryAddOperationRequest + { + Metadata = request.Metadata, + Monitored = request.Monitored, + QualityProfileId = request.QualityProfileId, + AutoSearch = request.AutoSearch, + DestinationPath = request.DestinationPath, + SearchResult = request.SearchResult, + HistorySource = "AddNew", + HistoryMessage = $"Audiobook '{request.Metadata.Title}' added to library from Add New page" + }); + + if (result.AlreadyExists) + { + return new ConflictObjectResult(new { message = result.Message, audiobook = result.Audiobook }); + } + + return new OkObjectResult(new { message = result.Message, audiobook = result.Audiobook }); + } + + var metadata = request.Metadata; + + _logger.LogInformation("AddToLibrary received metadata: Title={Title}, Asin={Asin}, PublishYear={PublishYear}, Authors={Authors}, Series={Series}", + LogRedaction.SanitizeText(metadata.Title), LogRedaction.SanitizeText(metadata.Asin), LogRedaction.SanitizeText(metadata.PublishYear), + LogRedaction.SanitizeText(metadata.Authors != null ? string.Join(", ", metadata.Authors) : "null"), + LogRedaction.SanitizeText(metadata.Series)); + + TryExtractPublishYear(request); + + if (!string.IsNullOrEmpty(metadata.Asin)) + { + var existingByAsin = await _repo.GetByAsinAsync(metadata.Asin); + if (existingByAsin != null) + { + return new ConflictObjectResult(new { message = "Audiobook already exists in library", audiobook = existingByAsin }); + } + } + + var firstIsbn = (metadata.Isbn != null && metadata.Isbn.Any()) ? metadata.Isbn.FirstOrDefault(i => !string.IsNullOrWhiteSpace(i)) : null; + if (!string.IsNullOrWhiteSpace(firstIsbn)) + { + var existingByIsbn = await _repo.GetByIsbnAsync(firstIsbn); + if (existingByIsbn != null) + { + return new ConflictObjectResult(new { message = "Audiobook already exists in library", audiobook = existingByIsbn }); + } + } + + string? imageUrl; + try + { + imageUrl = await ResolveLibraryImageUrlAsync(request, firstIsbn); + } + catch (LibraryAddConflictException ex) + { + return new ConflictObjectResult(new { message = "Audiobook already exists in library", audiobook = ex.Audiobook }); + } + + var audiobook = metadata.ToAudiobook(); + + audiobook.Monitored = request.Monitored; + audiobook.ImageUrl = imageUrl; + + AudiobookSeriesMembershipHelper.ApplyToAudiobook( + audiobook, + metadata.SeriesMemberships, + metadata.Series, + AudibleBookMetadata.ToStringOrFirst(metadata.SeriesNumber)); + + AudiobookIdentifierMapper.SyncImportedIdentifiersFromLegacyFields(audiobook); + + _logger.LogInformation("Created Audiobook entity: Title={Title}, Asin={Asin}, PublishYear={PublishYear}", + LogRedaction.SanitizeText(audiobook.Title), LogRedaction.SanitizeText(audiobook.Asin), LogRedaction.SanitizeText(audiobook.PublishYear)); + + await AssignQualityProfileAsync(audiobook, request); + + if (!string.IsNullOrWhiteSpace(request.DestinationPath)) + { + audiobook.BasePath = FileUtils.NormalizeStoredPath(request.DestinationPath); + _logger.LogInformation("Using custom destination path for audiobook '{Title}': {BasePath}", + audiobook.Title, audiobook.BasePath); + } + + await _repo.AddAsync(audiobook); + await ResolveAuthorAsinsAsync(audiobook); + await SendAddedNotificationAsync(audiobook); + await AddHistoryAsync(audiobook); + + _logger.LogInformation("Added audiobook '{Title}' (ASIN: {Asin}) to library with Monitored={Monitored}, QualityProfileId={QualityProfileId}, AutoSearch={AutoSearch}", + audiobook.Title, audiobook.Asin, request.Monitored, audiobook.QualityProfileId, request.AutoSearch); + + return new OkObjectResult(new { message = "Audiobook added to library successfully", audiobook }); + } + + private void TryExtractPublishYear(LibraryController.AddToLibraryRequest request) + { + var metadata = request.Metadata; + if (!string.IsNullOrWhiteSpace(metadata.PublishYear) || request.SearchResult == null) + { + return; + } + + try + { + if (DateTime.TryParse(request.SearchResult.PublishedDate, out var publishDate)) + { + metadata.PublishYear = publishDate.Year.ToString(); + _logger.LogInformation("Extracted publish year from search result publishedDate: {Year}", metadata.PublishYear); + } + else + { + _logger.LogWarning("Could not parse PublishedDate as DateTime: {PublishedDate}", LogRedaction.SanitizeText(request.SearchResult.PublishedDate)); + } + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning(ex, "Failed to extract publish year from search result publishedDate"); + } + } + + private async Task ResolveLibraryImageUrlAsync(LibraryController.AddToLibraryRequest request, string? firstIsbn) + { + var metadata = request.Metadata; + string? imageUrl = metadata.ImageUrl; + if (!string.IsNullOrEmpty(metadata.Asin)) + { + return await TryMoveLibraryImageAsync(metadata.Asin, metadata.ImageUrl, imageUrl, "ASIN", metadata.Asin); + } + + if (metadata.Isbn != null && metadata.Isbn.Any(i => !string.IsNullOrWhiteSpace(i))) + { + firstIsbn = metadata.Isbn.FirstOrDefault(i => !string.IsNullOrWhiteSpace(i)); + if (!string.IsNullOrWhiteSpace(firstIsbn)) + { + var existingByIsbn = await _repo.GetByIsbnAsync(firstIsbn); + if (existingByIsbn != null) + { + throw new LibraryAddConflictException(existingByIsbn); + } + } + + var derivedKey = "img-" + ComputeShortHash(firstIsbn ?? metadata.ImageUrl ?? string.Empty); + return await TryMoveLibraryImageAsync(derivedKey, metadata.ImageUrl, imageUrl, "derived ISBN", derivedKey); + } + + if (!string.IsNullOrEmpty(metadata.ImageUrl)) + { + var rawKey = request.SearchResult?.Id ?? request.SearchResult?.ResultUrl ?? request.SearchResult?.ProductUrl ?? metadata.ImageUrl; + var derivedKey = "img-" + ComputeShortHash(rawKey); + return await TryMoveLibraryImageAsync(derivedKey, metadata.ImageUrl, imageUrl, "derived key", derivedKey); + } + + return imageUrl; + } + + private async Task TryMoveLibraryImageAsync(string key, string? sourceImageUrl, string? fallbackImageUrl, string label, string logValue) + { + try + { + var libraryImagePath = await _imageCacheService.MoveToLibraryStorageAsync(key, sourceImageUrl); + if (!string.IsNullOrWhiteSpace(libraryImagePath)) + { + _logger.LogInformation("Moved image for {Label} {Value} to permanent library storage", label, LogRedaction.SanitizeText(logValue)); + return $"/{libraryImagePath}"; + } + + _logger.LogWarning("Failed to move image for {Label} {Value}, image may not be reachable", label, LogRedaction.SanitizeText(logValue)); + } + catch (IOException ex) + { + _logger.LogWarning(ex, "Error moving image for {Label} to library storage", label); + } + catch (UnauthorizedAccessException ex) + { + _logger.LogWarning(ex, "Error moving image for {Label} to library storage", label); + } + catch (HttpRequestException ex) + { + _logger.LogWarning(ex, "Error moving image for {Label} to library storage", label); + } + catch (TaskCanceledException ex) + { + _logger.LogWarning(ex, "Error moving image for {Label} to library storage", label); + } + catch (InvalidOperationException ex) + { + _logger.LogWarning(ex, "Error moving image for {Label} to library storage", label); + } + catch (UriFormatException ex) + { + _logger.LogWarning(ex, "Error moving image for {Label} to library storage", label); + } + + return fallbackImageUrl; + } + + private async Task AssignQualityProfileAsync(Audiobook audiobook, LibraryController.AddToLibraryRequest request) + { + if (request.QualityProfileId.HasValue) + { + audiobook.QualityProfileId = request.QualityProfileId.Value; + _logger.LogInformation("Assigned custom quality profile ID {ProfileId} to new audiobook '{Title}'", + request.QualityProfileId.Value, LogRedaction.SanitizeText(audiobook.Title)); + return; + } + + using var scope = _scopeFactory.CreateScope(); + var qualityProfileService = scope.ServiceProvider.GetRequiredService(); + var defaultProfile = await qualityProfileService.GetDefaultAsync(); + if (defaultProfile != null) + { + audiobook.QualityProfileId = defaultProfile.Id; + _logger.LogInformation("Assigned default quality profile '{ProfileName}' (ID: {ProfileId}) to new audiobook '{Title}'", + defaultProfile.Name, defaultProfile.Id, audiobook.Title); + } + else + { + _logger.LogWarning("No default quality profile found. New audiobook '{Title}' will not have a quality profile assigned.", LogRedaction.SanitizeText(audiobook.Title)); + } + } + + private async Task ResolveAuthorAsinsAsync(Audiobook audiobook) + { + try + { + using var scope = _scopeFactory.CreateScope(); + var audible = scope.ServiceProvider.GetRequiredService(); + + if (audiobook.Authors == null || !audiobook.Authors.Any()) + { + return; + } + + audiobook.AuthorAsins ??= new List(); + foreach (var authorName in audiobook.Authors) + { + try + { + var info = await audible.LookupAuthorAsync(authorName); + if (info == null || string.IsNullOrWhiteSpace(info.Asin)) + { + continue; + } + + if (!audiobook.AuthorAsins.Contains(info.Asin)) + { + audiobook.AuthorAsins.Add(info.Asin); + } + + try + { + var moved = await _imageCacheService.MoveToAuthorLibraryStorageAsync(info.Asin, info.Image); + if (moved != null) + { + _logger.LogInformation("Cached author image for {Author} (ASIN: {Asin})", authorName, info.Asin); + } + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning(ex, "Failed to cache author image for {Author}", authorName); + } + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning(ex, "Author lookup failed for {Author}", authorName); + } + } + + try + { + await _repo.UpdateAsync(audiobook); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning(ex, "Failed to persist author ASINs for audiobook '{Title}'", LogRedaction.SanitizeText(audiobook.Title)); + } + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning(ex, "Error resolving author ASINs for audiobook '{Title}'", LogRedaction.SanitizeText(audiobook.Title)); + } + } + + private async Task SendAddedNotificationAsync(Audiobook audiobook) + { + if (_notificationService == null) + { + return; + } + + using var scope = _scopeFactory.CreateScope(); + var configService = scope.ServiceProvider.GetRequiredService(); + var settings = await configService.GetApplicationSettingsAsync(); + var data = new + { + id = audiobook.Id, + title = audiobook.Title ?? "Unknown Title", + authors = audiobook.Authors, + narrators = audiobook.Narrators, + description = audiobook.Description, + asin = audiobook.Asin, + publisher = audiobook.Publisher, + year = audiobook.PublishYear, + imageUrl = audiobook.ImageUrl + }; + await _notificationService.SendNotificationAsync("book-added", data, settings.WebhookUrl, settings.EnabledNotificationTriggers); + } + + private async Task AddHistoryAsync(Audiobook audiobook) + { + await _historyRepository.AddAsync(new History + { + AudiobookId = audiobook.Id, + AudiobookTitle = audiobook.Title ?? "Unknown Title", + EventType = "Added", + Message = $"Audiobook '{audiobook.Title}' added to library from Add New page", + Source = "AddNew", + Timestamp = DateTime.UtcNow + }); + } + + private static string ComputeShortHash(string? input) + { + if (string.IsNullOrEmpty(input)) + { + return Guid.NewGuid().ToString("N").Substring(0, 12); + } + + var bytes = Encoding.UTF8.GetBytes(input); + var hash = SHA1.HashData(bytes); + return BitConverter.ToString(hash).Replace("-", "").Substring(0, 16).ToLowerInvariant(); + } + + private sealed class LibraryAddConflictException : Exception + { + public LibraryAddConflictException(Audiobook audiobook) + { + Audiobook = audiobook; + } + + public Audiobook Audiobook { get; } + } + } +} diff --git a/listenarr.api/Controllers/LibraryBulkEditWorkflow.cs b/listenarr.api/Controllers/LibraryBulkEditWorkflow.cs new file mode 100644 index 000000000..07bf1375c --- /dev/null +++ b/listenarr.api/Controllers/LibraryBulkEditWorkflow.cs @@ -0,0 +1,379 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +using System.Text.Json; +using System.Text.RegularExpressions; +using Listenarr.Application.Interfaces; +using Listenarr.Application.Interfaces.Repositories; +using Listenarr.Application.Security; +using Listenarr.Domain.Common; +using Listenarr.Domain.Models; +using Listenarr.Domain.Models.Configurations; +using Microsoft.AspNetCore.Mvc; + +namespace Listenarr.Api.Controllers +{ + public sealed class LibraryBulkEditWorkflow + { + private readonly IAudiobookRepository _repo; + private readonly IImageCacheService _imageCacheService; + private readonly IHistoryRepository _historyRepository; + private readonly IServiceScopeFactory _scopeFactory; + private readonly IFileNamingService _fileNamingService; + private readonly string _contentRootPath; + private readonly ILogger _logger; + + public LibraryBulkEditWorkflow( + IAudiobookRepository repo, + IImageCacheService imageCacheService, + IHistoryRepository historyRepository, + IServiceScopeFactory scopeFactory, + IFileNamingService fileNamingService, + IApplicationPathService applicationPathService, + ILogger logger) + { + _repo = repo; + _imageCacheService = imageCacheService; + _historyRepository = historyRepository; + _scopeFactory = scopeFactory; + _fileNamingService = fileNamingService; + _contentRootPath = applicationPathService.ContentRootPath; + _logger = logger; + } + + public async Task BulkDeleteAsync(LibraryController.BulkDeleteRequest request) + { + if (request.Ids == null || !request.Ids.Any()) + { + return new BadRequestObjectResult(new { message = "No audiobook IDs provided for bulk deletion" }); + } + + var deletedCount = 0; + var deletedImagesCount = 0; + var errors = new List(); + var deletedIds = new List(); + + foreach (var id in request.Ids.Distinct()) + { + try + { + var audiobook = await _repo.GetByIdAsync(id); + if (audiobook == null) + { + errors.Add($"Audiobook with ID {id} not found"); + continue; + } + + deletedImagesCount += await DeleteCachedImageAsync(audiobook); + + await _historyRepository.AddAsync(new History + { + AudiobookId = audiobook.Id, + AudiobookTitle = audiobook.Title ?? "Unknown Title", + EventType = "Deleted", + Message = $"Audiobook '{audiobook.Title}' deleted via bulk operation", + Source = "BulkDelete", + Timestamp = DateTime.UtcNow + }); + + var deleted = await _repo.DeleteByIdAsync(id); + if (deleted) + { + deletedCount++; + deletedIds.Add(id); + _logger.LogInformation("Deleted audiobook '{Title}' (ID: {Id}) via bulk operation", LogRedaction.SanitizeText(audiobook.Title), id); + } + else + { + errors.Add($"Failed to delete audiobook with ID {id}"); + } + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogError(ex, "Error during bulk delete for ID {Id}: {Message}", id, ex.Message); + errors.Add($"Error deleting audiobook with ID {id}: {ex.Message}"); + } + } + + if (deletedCount == 0 && errors.Any()) + { + return new BadRequestObjectResult(new { message = "No audiobooks were successfully deleted", errors }); + } + + object result = errors.Any() + ? new + { + message = $"Partially successful: deleted {deletedCount} audiobook{(deletedCount != 1 ? "s" : "")}, {errors.Count} error{(errors.Count != 1 ? "s" : "")} occurred", + deletedCount, + deletedImagesCount, + ids = deletedIds, + errors + } + : new + { + message = $"Successfully deleted {deletedCount} audiobook{(deletedCount != 1 ? "s" : "")}", + deletedCount, + deletedImagesCount, + ids = deletedIds + }; + + return new OkObjectResult(result); + } + + public async Task BulkUpdateAsync(LibraryController.BulkUpdateRequest request) + { + if (request?.Ids == null || !request.Ids.Any()) + { + return new BadRequestObjectResult(new { message = "No audiobook IDs provided for bulk update" }); + } + + var results = new List(); + var settings = await TryLoadApplicationSettingsAsync(); + + foreach (var id in request.Ids.Distinct()) + { + var entryErrors = new List(); + var success = false; + + try + { + var audiobook = await _repo.GetByIdAsync(id); + if (audiobook == null) + { + entryErrors.Add($"Audiobook with ID {id} not found"); + results.Add(new { id, success, errors = entryErrors }); + continue; + } + + var changed = false; + + if (request.Updates != null && request.Updates.TryGetValue("monitored", out var monitoredObj)) + { + try + { + var monVal = monitoredObj is JsonElement je + ? je.ValueKind == JsonValueKind.True + : Convert.ToBoolean(monitoredObj); + + audiobook.Monitored = monVal; + changed = true; + _logger.LogInformation("Set Monitored={Monitored} for audiobook id={Id}", monVal, id); + + await AddBulkUpdateHistoryAsync(audiobook, $"Monitored set to {monVal}"); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + entryErrors.Add($"Invalid monitored value: {ex.Message}"); + } + } + + if (request.Updates != null && request.Updates.TryGetValue("qualityProfileId", out var qpObj)) + { + try + { + var qpVal = qpObj is JsonElement jq + ? jq.GetInt32() + : Convert.ToInt32(qpObj); + + audiobook.QualityProfileId = qpVal; + changed = true; + _logger.LogInformation("Set QualityProfileId={Profile} for audiobook id={Id}", qpVal, id); + + await AddBulkUpdateHistoryAsync(audiobook, $"Quality profile set to {qpVal}"); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + entryErrors.Add($"Invalid qualityProfileId value: {ex.Message}"); + } + } + + if (request.Updates != null && request.Updates.TryGetValue("rootFolder", out var rootObj)) + { + try + { + var rootPath = ExtractRootPath(rootObj); + if (!string.IsNullOrWhiteSpace(rootPath)) + { + var fileNamingPattern = !string.IsNullOrWhiteSpace(settings?.FolderNamingPattern) + ? settings!.FolderNamingPattern + : settings?.FileNamingPattern ?? string.Empty; + var newBase = LibraryPathPlanner.ComputeAudiobookBaseDirectoryFromPattern(audiobook, rootPath, fileNamingPattern, _fileNamingService); + + try + { + if (!Directory.Exists(newBase)) + { + Directory.CreateDirectory(newBase); + _logger.LogInformation("Created directory for audiobook id={Id} at {Path}", id, newBase); + } + + audiobook.BasePath = newBase; + changed = true; + + await AddBulkUpdateHistoryAsync(audiobook, $"BasePath set to {newBase} via bulk update"); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + entryErrors.Add($"Failed to apply root folder for audiobook {id}: {ex.Message}"); + } + } + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + entryErrors.Add($"Invalid rootFolder value: {ex.Message}"); + } + } + + if (changed) + { + await _repo.UpdateAsync(audiobook); + success = true; + } + else + { + entryErrors.Add("No valid updates provided for this audiobook"); + } + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + entryErrors.Add($"Unhandled error: {ex.Message}"); + } + + results.Add(new { id, success, errors = entryErrors }); + } + + return new OkObjectResult(new { message = "Bulk update completed", results }); + } + + private async Task DeleteCachedImageAsync(Audiobook audiobook) + { + try + { + if (!string.IsNullOrEmpty(audiobook.Asin)) + { + var imagePath = await _imageCacheService.GetCachedImagePathAsync(audiobook.Asin); + if (imagePath != null) + { + var fullPath = ResolvePathWithOptionalBase(_contentRootPath, imagePath); + if (File.Exists(fullPath)) + { + File.Delete(fullPath); + _logger.LogInformation("Deleted cached image for ASIN {Asin}", LogRedaction.SanitizeText(audiobook.Asin)); + return 1; + } + } + } + else if (!string.IsNullOrEmpty(audiobook.ImageUrl)) + { + return await DeleteCachedImageFromUrlAsync(audiobook); + } + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning(ex, "Failed to delete cached image for audiobook id {Id}", audiobook.Id); + } + + return 0; + } + + private async Task DeleteCachedImageFromUrlAsync(Audiobook audiobook) + { + try + { + const string marker = "/config/cache/images/library/"; + var url = audiobook.ImageUrl!; + var idx = url.IndexOf(marker, StringComparison.OrdinalIgnoreCase); + if (idx < 0) + { + return 0; + } + + var filename = url.Substring(idx + marker.Length); + filename = Path.GetFileName(filename); + var identifier = Path.GetFileNameWithoutExtension(filename); + + if (string.IsNullOrEmpty(identifier) || !Regex.IsMatch(identifier, "^[A-Za-z0-9_\\-\\.]{1,128}$")) + { + _logger.LogWarning("Image identifier from ImageUrl for audiobook id {Id} is invalid: {Identifier}", audiobook.Id, LogRedaction.SanitizeText(identifier)); + return 0; + } + + var imagePath = await _imageCacheService.GetCachedImagePathAsync(identifier); + if (!string.IsNullOrEmpty(imagePath)) + { + var fullPath = ResolvePathWithOptionalBase(_contentRootPath, imagePath); + if (File.Exists(fullPath)) + { + File.Delete(fullPath); + _logger.LogInformation("Deleted cached image for identifier (from ImageUrl): {Identifier}", LogRedaction.SanitizeText(identifier)); + return 1; + } + } + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning(ex, "Failed to delete cached image based on stored ImageUrl for audiobook id {Id}", audiobook.Id); + } + + return 0; + } + + private async Task TryLoadApplicationSettingsAsync() + { + try + { + using var scope = _scopeFactory.CreateScope(); + var configService = scope.ServiceProvider.GetRequiredService(); + return await configService.GetApplicationSettingsAsync(); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning(ex, "Failed to load application settings while performing bulk update"); + return null; + } + } + + private static string? ExtractRootPath(object rootObj) + { + if (rootObj is JsonElement jr) + { + return jr.ValueKind == JsonValueKind.String ? jr.GetString() : null; + } + + return rootObj.ToString(); + } + + private async Task AddBulkUpdateHistoryAsync(Audiobook audiobook, string message) + { + await _historyRepository.AddAsync(new History + { + AudiobookId = audiobook.Id, + AudiobookTitle = audiobook.Title ?? "Unknown", + EventType = "Updated", + Message = message, + Source = "BulkUpdate", + Timestamp = DateTime.UtcNow + }); + } + + private static string ResolvePathWithOptionalBase(string? basePath, string candidatePath) + { + return FileUtils.CombineWithOptionalBase(basePath, candidatePath); + } + } +} diff --git a/listenarr.api/Controllers/LibraryController.cs b/listenarr.api/Controllers/LibraryController.cs index 52afc50f0..9e5391e16 100644 --- a/listenarr.api/Controllers/LibraryController.cs +++ b/listenarr.api/Controllers/LibraryController.cs @@ -18,16 +18,9 @@ using Microsoft.AspNetCore.Mvc; using Listenarr.Domain.Models; -using System.Text.Json; -using System.Security.Cryptography; -using System.Text; using Listenarr.Domain.Common; using Listenarr.Application.Interfaces; -using Listenarr.Domain.Models.Configurations; using Listenarr.Application.Interfaces.Repositories; -using Listenarr.Application.Notification; -using Listenarr.Application.Security; -using Listenarr.Application.Metadata; using Listenarr.Application.Audiobooks; using Listenarr.Api.Attributes; @@ -39,89 +32,80 @@ namespace Listenarr.Api.Controllers public class LibraryController : ControllerBase { private readonly IAudiobookRepository _repo; - private readonly IImageCacheService _imageCacheService; private readonly ILogger _logger; private readonly IServiceScopeFactory _scopeFactory; - private readonly IHistoryRepository _historyRepository; private readonly IAudiobookFileRepository _audioFileRepository; - private readonly IQualityProfileRepository _qualityProfileRepository; - private readonly IDownloadRepository _downloadRepository; private readonly IMoveQueueService? _moveQueueService; private readonly IFileNamingService _fileNamingService; - private readonly NotificationService? _notificationService; - private readonly ILibraryAddService? _libraryAddService; private readonly IRenameService? _renameService; private readonly ILibraryListService _libraryListService; - private readonly IAudiobookFilesystemDeleteService _audiobookFilesystemDeleteService; + private readonly LibraryAddWorkflow _addWorkflow; private readonly LibraryMetadataRescanWorkflow _metadataRescanWorkflow; private readonly LibraryScanPathResolver _scanPathResolver; private readonly LibraryScanQueueWorkflow _scanQueueWorkflow; - private readonly string _contentRootPath; + private readonly LibraryManualScanWorkflow _manualScanWorkflow; + private readonly LibraryBulkEditWorkflow _bulkEditWorkflow; + private readonly LibraryMoveWorkflow _moveWorkflow; + private readonly LibraryDeleteWorkflow _deleteWorkflow; + private readonly LibraryUpdateWorkflow _updateWorkflow; + private readonly LibraryIdentifierWorkflow _identifierWorkflow; /// Initializes a new instance of . /// Repository for audiobook persistence and queries. - /// Service for caching and moving cover images. /// Logger instance for diagnostic messages. /// Service scope factory used to create scoped services when required. - /// Repository for download history records. /// Repository for audiobook file records. - /// Repository for quality profile configuration. - /// Repository for active download records. /// Service responsible for applying file naming patterns. /// Optional background move queue service for processing move requests. - /// Service for sending webhook notifications. - /// Optional shared add-to-library service used by runtime requests and background syncs. /// Optional organize/rename service used for previewing and executing library file organization. - /// Application path service used to resolve content-root-relative cache files. /// Application service that builds the slim library list payload. - /// Application service responsible for safe audiobook filesystem cleanup. + /// API workflow for add-to-library requests. /// API workflow for on-demand audiobook metadata rescans. /// API workflow for resolving and validating scan roots. /// API workflow for background scan queue operations. + /// API workflow for inline scan execution and reconciliation. + /// API workflow for bulk update and delete operations. + /// API workflow for move queue operations. + /// API workflow for single audiobook delete operations. + /// API workflow for single audiobook update operations. + /// API workflow for audiobook identifier operations. public LibraryController( IAudiobookRepository repo, - IImageCacheService imageCacheService, ILogger logger, IServiceScopeFactory scopeFactory, - IHistoryRepository historyRepository, IAudiobookFileRepository audioFileRepository, - IQualityProfileRepository qualityProfileRepository, - IDownloadRepository downloadRepository, IFileNamingService fileNamingService, - IApplicationPathService applicationPathService, ILibraryListService libraryListService, - IAudiobookFilesystemDeleteService audiobookFilesystemDeleteService, + LibraryAddWorkflow addWorkflow, LibraryMetadataRescanWorkflow metadataRescanWorkflow, LibraryScanPathResolver scanPathResolver, LibraryScanQueueWorkflow scanQueueWorkflow, + LibraryManualScanWorkflow manualScanWorkflow, + LibraryBulkEditWorkflow bulkEditWorkflow, + LibraryMoveWorkflow moveWorkflow, + LibraryDeleteWorkflow deleteWorkflow, + LibraryUpdateWorkflow updateWorkflow, + LibraryIdentifierWorkflow identifierWorkflow, IMoveQueueService? moveQueueService = null, - NotificationService? notificationService = null, - ILibraryAddService? libraryAddService = null, IRenameService? renameService = null) { _repo = repo; - _imageCacheService = imageCacheService; _logger = logger; _scopeFactory = scopeFactory; - _historyRepository = historyRepository; _audioFileRepository = audioFileRepository; - _qualityProfileRepository = qualityProfileRepository; - _downloadRepository = downloadRepository; _fileNamingService = fileNamingService; _moveQueueService = moveQueueService; - _notificationService = notificationService; - _libraryAddService = libraryAddService; _renameService = renameService; _libraryListService = libraryListService; - _audiobookFilesystemDeleteService = audiobookFilesystemDeleteService; + _addWorkflow = addWorkflow; _metadataRescanWorkflow = metadataRescanWorkflow; _scanPathResolver = scanPathResolver; _scanQueueWorkflow = scanQueueWorkflow; - _contentRootPath = applicationPathService.ContentRootPath; - } - - private static string ResolvePathWithOptionalBase(string? basePath, string candidatePath) - { - return FileUtils.CombineWithOptionalBase(basePath, candidatePath); + _manualScanWorkflow = manualScanWorkflow; + _bulkEditWorkflow = bulkEditWorkflow; + _moveWorkflow = moveWorkflow; + _deleteWorkflow = deleteWorkflow; + _updateWorkflow = updateWorkflow; + _identifierWorkflow = identifierWorkflow; } public class ScanRequest @@ -137,381 +121,7 @@ public class ScanRequest [HttpPost("add")] public async Task AddToLibrary([FromBody] AddToLibraryRequest request) { - if (_libraryAddService != null) - { - var result = await _libraryAddService.AddToLibraryAsync(new LibraryAddOperationRequest - { - Metadata = request.Metadata, - Monitored = request.Monitored, - QualityProfileId = request.QualityProfileId, - AutoSearch = request.AutoSearch, - DestinationPath = request.DestinationPath, - SearchResult = request.SearchResult, - HistorySource = "AddNew", - HistoryMessage = $"Audiobook '{request.Metadata.Title}' added to library from Add New page" - }); - - if (result.AlreadyExists) - { - return Conflict(new { message = result.Message, audiobook = result.Audiobook }); - } - - return Ok(new { message = result.Message, audiobook = result.Audiobook }); - } - - var metadata = request.Metadata; - - _logger.LogInformation("AddToLibrary received metadata: Title={Title}, Asin={Asin}, PublishYear={PublishYear}, Authors={Authors}, Series={Series}", - LogRedaction.SanitizeText(metadata.Title), LogRedaction.SanitizeText(metadata.Asin), LogRedaction.SanitizeText(metadata.PublishYear), - LogRedaction.SanitizeText(metadata.Authors != null ? string.Join(", ", metadata.Authors) : "null"), - LogRedaction.SanitizeText(metadata.Series)); - - // If metadata doesn't have PublishYear but we have search result with publishedDate, try to extract year - if (string.IsNullOrWhiteSpace(metadata.PublishYear) && request.SearchResult != null) - { - try - { - if (DateTime.TryParse(request.SearchResult.PublishedDate, out var publishDate)) - { - metadata.PublishYear = publishDate.Year.ToString(); - _logger.LogInformation("Extracted publish year from search result publishedDate: {Year}", metadata.PublishYear); - } - else - { - _logger.LogWarning("Could not parse PublishedDate as DateTime: {PublishedDate}", LogRedaction.SanitizeText(request.SearchResult.PublishedDate)); - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Failed to extract publish year from search result publishedDate"); - } - } - - // Check if audiobook already exists in library - if (!string.IsNullOrEmpty(metadata.Asin)) - { - var existingByAsin = await _repo.GetByAsinAsync(metadata.Asin); - if (existingByAsin != null) - { - return Conflict(new { message = "Audiobook already exists in library", audiobook = existingByAsin }); - } - } - - var firstIsbn = (metadata.Isbn != null && metadata.Isbn.Any()) ? metadata.Isbn.FirstOrDefault(i => !string.IsNullOrWhiteSpace(i)) : null; - if (!string.IsNullOrWhiteSpace(firstIsbn)) - { - var existingByIsbn = await _repo.GetByIsbnAsync(firstIsbn); - if (existingByIsbn != null) - { - return Conflict(new { message = "Audiobook already exists in library", audiobook = existingByIsbn }); - } - } - - // Move image from temp cache to permanent library storage - string? imageUrl = metadata.ImageUrl; - if (!string.IsNullOrEmpty(metadata.Asin)) - { - try - { - var libraryImagePath = await _imageCacheService.MoveToLibraryStorageAsync(metadata.Asin, metadata.ImageUrl); - if (!string.IsNullOrWhiteSpace(libraryImagePath)) - { - imageUrl = $"/{libraryImagePath}"; - _logger.LogInformation("Moved image for ASIN {Asin} to permanent library storage", LogRedaction.SanitizeText(metadata.Asin)); - } - else - { - _logger.LogWarning("Failed to move image for ASIN {Asin}, image may not be in temp cache", LogRedaction.SanitizeText(metadata.Asin)); - } - } - catch (IOException ex) - { - _logger.LogWarning(ex, "Error moving image for ASIN {Asin} to library storage", LogRedaction.SanitizeText(metadata.Asin)); - // Continue with original image URL if move fails - } - catch (UnauthorizedAccessException ex) - { - _logger.LogWarning(ex, "Error moving image for ASIN {Asin} to library storage", LogRedaction.SanitizeText(metadata.Asin)); - // Continue with original image URL if move fails - } - catch (HttpRequestException ex) - { - _logger.LogWarning(ex, "Error moving image for ASIN {Asin} to library storage", LogRedaction.SanitizeText(metadata.Asin)); - // Continue with original image URL if move fails - } - catch (TaskCanceledException ex) - { - _logger.LogWarning(ex, "Error moving image for ASIN {Asin} to library storage", LogRedaction.SanitizeText(metadata.Asin)); - // Continue with original image URL if move fails - } - catch (InvalidOperationException ex) - { - _logger.LogWarning(ex, "Error moving image for ASIN {Asin} to library storage", LogRedaction.SanitizeText(metadata.Asin)); - // Continue with original image URL if move fails - } - catch (UriFormatException ex) - { - _logger.LogWarning(ex, "Error moving image for ASIN {Asin} to library storage", LogRedaction.SanitizeText(metadata.Asin)); - // Continue with original image URL if move fails - } - } - else if (metadata.Isbn != null && metadata.Isbn.Any(i => !string.IsNullOrWhiteSpace(i))) - { - firstIsbn = metadata.Isbn.FirstOrDefault(i => !string.IsNullOrWhiteSpace(i)); - if (!string.IsNullOrWhiteSpace(firstIsbn)) - { - var existingByIsbn = await _repo.GetByIsbnAsync(firstIsbn); - if (existingByIsbn != null) - { - return Conflict(new { message = "Audiobook already exists in library", audiobook = existingByIsbn }); - } - } - - try - { - var derivedKey = "img-" + ComputeShortHash(firstIsbn ?? metadata.ImageUrl ?? string.Empty); - var libraryImagePath = await _imageCacheService.MoveToLibraryStorageAsync(derivedKey, metadata.ImageUrl); - if (!string.IsNullOrWhiteSpace(libraryImagePath)) - { - imageUrl = $"/{libraryImagePath}"; - _logger.LogInformation("Moved image for derived ISBN {Key} to permanent library storage", LogRedaction.SanitizeText(derivedKey)); - } - else - { - _logger.LogWarning("Failed to move image for derived ISBN {Key}, image may not be reachable", LogRedaction.SanitizeText(derivedKey)); - } - } - catch (IOException ex) - { - _logger.LogWarning(ex, "Error moving image for derived ISBN to library storage"); - } - catch (UnauthorizedAccessException ex) - { - _logger.LogWarning(ex, "Error moving image for derived ISBN to library storage"); - } - catch (HttpRequestException ex) - { - _logger.LogWarning(ex, "Error moving image for derived ISBN to library storage"); - } - catch (TaskCanceledException ex) - { - _logger.LogWarning(ex, "Error moving image for derived ISBN to library storage"); - } - catch (InvalidOperationException ex) - { - _logger.LogWarning(ex, "Error moving image for derived ISBN to library storage"); - } - catch (UriFormatException ex) - { - _logger.LogWarning(ex, "Error moving image for derived ISBN to library storage"); - } - } - else if (!string.IsNullOrEmpty(metadata.ImageUrl)) - { - // No ASIN or ISBN available; attempt to move/download the image using a derived key - try - { - var rawKey = request.SearchResult?.Id ?? request.SearchResult?.ResultUrl ?? request.SearchResult?.ProductUrl ?? metadata.ImageUrl; - var derivedKey = "img-" + ComputeShortHash(rawKey); - var libraryImagePath = await _imageCacheService.MoveToLibraryStorageAsync(derivedKey, metadata.ImageUrl); - if (!string.IsNullOrWhiteSpace(libraryImagePath)) - { - imageUrl = $"/{libraryImagePath}"; - _logger.LogInformation("Moved image for derived key {Key} to permanent library storage", LogRedaction.SanitizeText(derivedKey)); - } - else - { - _logger.LogWarning("Failed to move image for derived key {Key}, image may not be reachable", LogRedaction.SanitizeText(derivedKey)); - } - } - catch (IOException ex) - { - _logger.LogWarning(ex, "Error moving image for derived key when ASIN is missing"); - } - catch (UnauthorizedAccessException ex) - { - _logger.LogWarning(ex, "Error moving image for derived key when ASIN is missing"); - } - catch (HttpRequestException ex) - { - _logger.LogWarning(ex, "Error moving image for derived key when ASIN is missing"); - } - catch (TaskCanceledException ex) - { - _logger.LogWarning(ex, "Error moving image for derived key when ASIN is missing"); - } - catch (InvalidOperationException ex) - { - _logger.LogWarning(ex, "Error moving image for derived key when ASIN is missing"); - } - catch (UriFormatException ex) - { - _logger.LogWarning(ex, "Error moving image for derived key when ASIN is missing"); - } - } - - // Convert metadata to Audiobook entity and save to database - var audiobook = metadata.ToAudiobook(); - - audiobook.Monitored = request.Monitored; // Use custom monitored setting - audiobook.ImageUrl = imageUrl; - - AudiobookSeriesMembershipHelper.ApplyToAudiobook( - audiobook, - metadata.SeriesMemberships, - metadata.Series, - AudibleBookMetadata.ToStringOrFirst(metadata.SeriesNumber)); - - AudiobookIdentifierMapper.SyncImportedIdentifiersFromLegacyFields(audiobook); - - _logger.LogInformation("Created Audiobook entity: Title={Title}, Asin={Asin}, PublishYear={PublishYear}", - LogRedaction.SanitizeText(audiobook.Title), LogRedaction.SanitizeText(audiobook.Asin), LogRedaction.SanitizeText(audiobook.PublishYear)); - - // Assign quality profile - use custom if provided, otherwise default - if (request.QualityProfileId.HasValue) - { - audiobook.QualityProfileId = request.QualityProfileId.Value; - _logger.LogInformation("Assigned custom quality profile ID {ProfileId} to new audiobook '{Title}'", - request.QualityProfileId.Value, LogRedaction.SanitizeText(audiobook.Title)); - } - else - { - // Assign default quality profile to new audiobooks - using (var scope = _scopeFactory.CreateScope()) - { - var qualityProfileService = scope.ServiceProvider.GetRequiredService(); - var defaultProfile = await qualityProfileService.GetDefaultAsync(); - if (defaultProfile != null) - { - audiobook.QualityProfileId = defaultProfile.Id; - _logger.LogInformation("Assigned default quality profile '{ProfileName}' (ID: {ProfileId}) to new audiobook '{Title}'", - defaultProfile.Name, defaultProfile.Id, audiobook.Title); - } - else - { - _logger.LogWarning("No default quality profile found. New audiobook '{Title}' will not have a quality profile assigned.", LogRedaction.SanitizeText(audiobook.Title)); - } - } - } - - // Compute or use custom BasePath (but don't create the directory yet - that happens during import) - if (!string.IsNullOrWhiteSpace(request.DestinationPath)) - { - // User provided a custom destination path - store it as BasePath - // ImportService will recognize BasePath as set and use filename-only pattern - audiobook.BasePath = FileUtils.NormalizeStoredPath(request.DestinationPath); - _logger.LogInformation("Using custom destination path for audiobook '{Title}': {BasePath}", - audiobook.Title, audiobook.BasePath); - } - // If no custom path provided, leave BasePath null - // ImportService will use the default naming pattern from settings - - await _repo.AddAsync(audiobook); - - // Resolve author ASINs and cache author images via Audible when possible - try - { - using var scope = _scopeFactory.CreateScope(); - var audible = scope.ServiceProvider.GetRequiredService(); - - if (audiobook.Authors != null && audiobook.Authors.Any()) - { - audiobook.AuthorAsins = audiobook.AuthorAsins ?? new List(); - foreach (var authorName in audiobook.Authors) - { - try - { - var info = await audible.LookupAuthorAsync(authorName); - if (info != null && !string.IsNullOrWhiteSpace(info.Asin)) - { - // Avoid duplicates - if (!audiobook.AuthorAsins.Contains(info.Asin)) - { - audiobook.AuthorAsins.Add(info.Asin); - } - - // Ensure author image is cached in authors folder (will download if necessary) - try - { - var moved = await _imageCacheService.MoveToAuthorLibraryStorageAsync(info.Asin, info.Image); - if (moved != null) - { - _logger.LogInformation("Cached author image for {Author} (ASIN: {Asin})", authorName, info.Asin); - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Failed to cache author image for {Author}", authorName); - } - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Author lookup failed for {Author}", authorName); - } - } - - // Persist any updated author ASINs - try - { - await _repo.UpdateAsync(audiobook); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Failed to persist author ASINs for audiobook '{Title}'", LogRedaction.SanitizeText(audiobook.Title)); - } - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Error resolving author ASINs for audiobook '{Title}'", LogRedaction.SanitizeText(audiobook.Title)); - } - - // Send notification if configured - if (_notificationService != null) - { - using var scope = _scopeFactory.CreateScope(); - var configService = scope.ServiceProvider.GetRequiredService(); - var settings = await configService.GetApplicationSettingsAsync(); - var data = new - { - id = audiobook.Id, - title = audiobook.Title ?? "Unknown Title", - authors = audiobook.Authors, - narrators = audiobook.Narrators, - description = audiobook.Description, - asin = audiobook.Asin, - publisher = audiobook.Publisher, - year = audiobook.PublishYear, - imageUrl = audiobook.ImageUrl - }; - await _notificationService.SendNotificationAsync("book-added", data, settings.WebhookUrl, settings.EnabledNotificationTriggers); - } - - - // Directory creation has been deferred to file import time to avoid creating empty directories - // for audiobooks that may never be downloaded. If a custom destination path was specified when - // adding the audiobook, it will be stored in BasePath and used when ImportService processes - // the downloaded files. If no custom path was specified, ImportService will use the configured - // naming pattern and output path to determine the directory structure. - - // Log history entry for the added audiobook - var historyEntry = new History - { - AudiobookId = audiobook.Id, - AudiobookTitle = audiobook.Title ?? "Unknown Title", - EventType = "Added", - Message = $"Audiobook '{audiobook.Title}' added to library from Add New page", - Source = "AddNew", - Timestamp = DateTime.UtcNow - }; - - await _historyRepository.AddAsync(historyEntry); - - _logger.LogInformation("Added audiobook '{Title}' (ASIN: {Asin}) to library with Monitored={Monitored}, QualityProfileId={QualityProfileId}, AutoSearch={AutoSearch}", - audiobook.Title, audiobook.Asin, request.Monitored, audiobook.QualityProfileId, request.AutoSearch); - - return Ok(new { message = "Audiobook added to library successfully", audiobook }); + return await _addWorkflow.AddAsync(request); } /// @@ -681,22 +291,7 @@ public async Task> GetAudiobook(int id) [HttpGet("{id}/identifiers")] public async Task GetAudiobookIdentifiers(int id) { - var audiobook = await _repo.GetByIdAsync(id); - - if (audiobook == null) - { - return NotFound(new { message = "Audiobook not found" }); - } - - var identifiers = AudiobookIdentifierMapper.GetEffectiveIdentifiers(audiobook) - .Select(AudiobookIdentifierMapper.ToIdentifierResponse) - .ToList(); - - return Ok(new - { - audiobookId = audiobook.Id, - identifiers - }); + return await _identifierWorkflow.GetAsync(id); } /// @@ -707,147 +302,7 @@ public async Task GetAudiobookIdentifiers(int id) [HttpPut("{id}/identifiers")] public async Task ReplaceAudiobookIdentifiers(int id, [FromBody] ReplaceAudiobookIdentifiersRequest? request) { - var audiobook = await _repo.GetByIdAsync(id); - - if (audiobook == null) - { - return NotFound(new { message = "Audiobook not found" }); - } - - var incoming = request?.Identifiers ?? new List(); - if (incoming.Count > 50) - { - return BadRequest(new { message = "Too many identifiers. Maximum is 50." }); - } - - var validationErrors = new List(); - var normalized = new List(); - var seen = new HashSet(StringComparer.OrdinalIgnoreCase); - var primaryCountByType = new Dictionary(); - var now = DateTime.UtcNow; - var existingServerOwnedSourceKeys = new HashSet( - (audiobook.ExternalIdentifiers ?? new List()) - .Where(i => - i.Source != AudiobookExternalIdentifierSource.Manual && - !string.IsNullOrWhiteSpace(i.ValueNormalized)) - .Select(AudiobookIdentifierMapper.FullSourceKey), - StringComparer.OrdinalIgnoreCase); - - for (var index = 0; index < incoming.Count; index++) - { - var item = incoming[index]; - if (!Enum.IsDefined(typeof(AudiobookExternalIdentifierType), item.Type)) - { - validationErrors.Add(new { index, field = "type", error = "Unsupported identifier type." }); - continue; - } - - if (!AudiobookIdentifierNormalizer.TryNormalize(item.Type, item.Value, out var normalizedValue, out var error)) - { - validationErrors.Add(new { index, field = "value", error = error ?? "Invalid identifier value." }); - continue; - } - - var normalizedRegion = item.Type == AudiobookExternalIdentifierType.Asin - ? AudiobookIdentifierNormalizer.NormalizeRegion(item.Region) - : null; - - var key = $"{item.Type}|{normalizedValue}|{normalizedRegion ?? string.Empty}"; - if (!seen.Add(key)) - { - validationErrors.Add(new { index, field = "value", error = "Duplicate identifier." }); - continue; - } - - if (item.IsPrimary) - { - primaryCountByType.TryGetValue(item.Type, out var count); - primaryCountByType[item.Type] = count + 1; - } - - var source = item.Source ?? AudiobookExternalIdentifierSource.Manual; - if (!Enum.IsDefined(typeof(AudiobookExternalIdentifierSource), source)) - { - source = AudiobookExternalIdentifierSource.Manual; - } - else if (source != AudiobookExternalIdentifierSource.Manual) - { - // Client writes cannot create or spoof Provider/Imported provenance. - // Preserve server-owned provenance only for exact existing rows. - var requestedKey = AudiobookIdentifierMapper.FullSourceKey(item.Type, normalizedValue, normalizedRegion, source); - if (!existingServerOwnedSourceKeys.Contains(requestedKey)) - { - source = AudiobookExternalIdentifierSource.Manual; - } - } - - normalized.Add(new AudiobookExternalIdentifier - { - AudiobookId = audiobook.Id, - Type = item.Type, - ValueRaw = AudiobookIdentifierNormalizer.NormalizeRawValueForStorage(item.Value), - ValueNormalized = normalizedValue, - Region = normalizedRegion, - IsPrimary = item.IsPrimary, - Source = source, - CreatedAt = now, - UpdatedAt = now - }); - } - - foreach (var kvp in primaryCountByType.Where(kvp => kvp.Value > 1)) - { - validationErrors.Add(new - { - field = "isPrimary", - type = kvp.Key, - error = $"Only one primary identifier is allowed for type {kvp.Key}." - }); - } - - if (validationErrors.Count > 0) - { - return BadRequest(new { message = "Identifier validation failed.", errors = validationErrors }); - } - - // Ensure a primary ASIN exists when ASINs are present. - var asins = normalized.Where(i => i.Type == AudiobookExternalIdentifierType.Asin).ToList(); - if (asins.Count > 0 && !asins.Any(i => i.IsPrimary)) - { - asins[0].IsPrimary = true; - } - - var olids = normalized.Where(i => i.Type == AudiobookExternalIdentifierType.OpenLibraryId).ToList(); - if (olids.Count == 1) - { - olids[0].IsPrimary = true; - } - - audiobook.ExternalIdentifiers = normalized; - AudiobookIdentifierMapper.SyncLegacyFieldsFromIdentifiers(audiobook); - - await _repo.UpdateWithIdentifierReplaceAsync(audiobook, normalized); - - _logger.LogInformation( - "Replaced identifiers for audiobook {AudiobookId} ({Title}). Count={Count}", - audiobook.Id, - audiobook.Title, - normalized.Count); - - return Ok(new - { - message = "Audiobook identifiers updated successfully", - audiobook = new - { - id = audiobook.Id, - asin = audiobook.Asin, - isbn = audiobook.Isbn, - openLibraryId = audiobook.OpenLibraryId - }, - identifiers = AudiobookIdentifierMapper.OrderIdentifiers(audiobook.ExternalIdentifiers) - .Select(AudiobookIdentifierMapper.ToIdentifierResponse) - .ToList() - }); + return await _identifierWorkflow.ReplaceAsync(id, request); } /// @@ -882,137 +337,7 @@ public async Task GetAudiobookFilesDebug(int id) [HttpPut("{id}")] public async Task UpdateAudiobook(int id, [FromBody] Audiobook updatedAudiobook) { - var existingAudiobook = await _repo.GetByIdAsync(id); - if (existingAudiobook == null) - { - return NotFound(new { message = "Audiobook not found" }); - } - - var legacyIdentifierFieldsTouched = false; - - // Only update non-null properties to support partial updates - if (updatedAudiobook.Title != null) existingAudiobook.Title = updatedAudiobook.Title; - if (updatedAudiobook.Subtitle != null) existingAudiobook.Subtitle = updatedAudiobook.Subtitle; - if (updatedAudiobook.Authors != null) existingAudiobook.Authors = updatedAudiobook.Authors; - if (updatedAudiobook.ImageUrl != null) existingAudiobook.ImageUrl = updatedAudiobook.ImageUrl; - if (updatedAudiobook.PublishYear != null) existingAudiobook.PublishYear = updatedAudiobook.PublishYear; - if (updatedAudiobook.PublishedDate != null) existingAudiobook.PublishedDate = updatedAudiobook.PublishedDate; - if (updatedAudiobook.Description != null) existingAudiobook.Description = updatedAudiobook.Description; - if (updatedAudiobook.Genres != null) existingAudiobook.Genres = updatedAudiobook.Genres; - if (updatedAudiobook.Tags != null) existingAudiobook.Tags = updatedAudiobook.Tags; - if (updatedAudiobook.Narrators != null) existingAudiobook.Narrators = updatedAudiobook.Narrators; - if (updatedAudiobook.Isbn != null) - { - existingAudiobook.Isbn = updatedAudiobook.Isbn; - legacyIdentifierFieldsTouched = true; - } - if (updatedAudiobook.Asin != null) - { - existingAudiobook.Asin = updatedAudiobook.Asin; - legacyIdentifierFieldsTouched = true; - } - if (updatedAudiobook.OpenLibraryId != null) - { - existingAudiobook.OpenLibraryId = updatedAudiobook.OpenLibraryId; - legacyIdentifierFieldsTouched = true; - } - if (updatedAudiobook.Publisher != null) existingAudiobook.Publisher = updatedAudiobook.Publisher; - if (updatedAudiobook.Language != null) existingAudiobook.Language = updatedAudiobook.Language; - if (updatedAudiobook.Runtime != null) existingAudiobook.Runtime = updatedAudiobook.Runtime; - if (updatedAudiobook.Edition != null) existingAudiobook.Edition = updatedAudiobook.Edition; - if (updatedAudiobook.Version != null) existingAudiobook.Version = updatedAudiobook.Version; - - var seriesMembershipsTouched = - updatedAudiobook.SeriesMemberships != null || - updatedAudiobook.Series != null || - updatedAudiobook.SeriesNumber != null; - - if (seriesMembershipsTouched) - { - var mergedSeries = updatedAudiobook.Series ?? existingAudiobook.Series; - var mergedSeriesNumber = updatedAudiobook.SeriesNumber ?? existingAudiobook.SeriesNumber; - var existingPrimaryMembership = AudiobookSeriesMembershipHelper.GetPrimaryMembership(existingAudiobook.SeriesMemberships); - - var normalizedMemberships = AudiobookSeriesMembershipHelper.Normalize( - updatedAudiobook.SeriesMemberships, - mergedSeries, - mergedSeriesNumber, - existingPrimaryMembership?.SeriesAsin); - - if (existingAudiobook.SeriesMemberships == null) - { - existingAudiobook.SeriesMemberships = new List(); - } - else - { - existingAudiobook.SeriesMemberships.Clear(); - } - - foreach (var membership in normalizedMemberships) - { - existingAudiobook.SeriesMemberships.Add(membership); - } - - AudiobookSeriesMembershipHelper.ApplyPrimarySeriesFields(existingAudiobook); - } - - // Always update these fields as they have default values - existingAudiobook.Explicit = updatedAudiobook.Explicit; - existingAudiobook.Abridged = updatedAudiobook.Abridged; - existingAudiobook.Monitored = updatedAudiobook.Monitored; - - if (updatedAudiobook.FilePath != null) existingAudiobook.FilePath = updatedAudiobook.FilePath; - if (updatedAudiobook.FileSize.HasValue) existingAudiobook.FileSize = updatedAudiobook.FileSize; - if (updatedAudiobook.Quality != null) existingAudiobook.Quality = updatedAudiobook.Quality; - - // Handle QualityProfileId - if -1 is sent, use default profile - if (updatedAudiobook.QualityProfileId.HasValue) - { - if (updatedAudiobook.QualityProfileId.Value == -1) - { - // -1 means "use default profile" - using (var scope = _scopeFactory.CreateScope()) - { - var qualityProfileService = scope.ServiceProvider.GetRequiredService(); - var defaultProfile = await qualityProfileService.GetDefaultAsync(); - if (defaultProfile != null) - { - existingAudiobook.QualityProfileId = defaultProfile.Id; - _logger.LogInformation("Assigned default quality profile '{ProfileName}' (ID: {ProfileId}) to audiobook '{Title}'", - defaultProfile.Name, defaultProfile.Id, existingAudiobook.Title); - } - else - { - _logger.LogWarning("No default quality profile found. Audiobook '{Title}' quality profile set to null.", LogRedaction.SanitizeText(existingAudiobook.Title)); - existingAudiobook.QualityProfileId = null; - } - } - } - else - { - existingAudiobook.QualityProfileId = updatedAudiobook.QualityProfileId.Value; - _logger.LogInformation("Updated quality profile for audiobook '{Title}' to ID {ProfileId}", - existingAudiobook.Title, updatedAudiobook.QualityProfileId.Value); - } - } - - // Allow updating BasePath (destination) from the frontend when provided - if (updatedAudiobook.BasePath != null) - { - existingAudiobook.BasePath = FileUtils.NormalizeStoredPath(updatedAudiobook.BasePath); - _logger.LogInformation("Updated BasePath for audiobook '{Title}' to: {BasePath}", LogRedaction.SanitizeText(existingAudiobook.Title), LogRedaction.SanitizeFilePath(updatedAudiobook.BasePath)); - } - - if (legacyIdentifierFieldsTouched) - { - AudiobookIdentifierMapper.SyncImportedIdentifiersFromLegacyFields(existingAudiobook); - } - - await _repo.UpdateAsync(existingAudiobook); - - _logger.LogInformation("Updated audiobook '{Title}' (ID: {Id})", LogRedaction.SanitizeText(existingAudiobook.Title), id); - - return Ok(new { message = "Audiobook updated successfully", audiobook = existingAudiobook }); + return await _updateWorkflow.UpdateAsync(id, updatedAudiobook); } /// @@ -1024,101 +349,7 @@ public async Task UpdateAudiobook(int id, [FromBody] Audiobook up [HttpDelete("{id}")] public async Task DeleteAudiobook(int id, [FromQuery] bool deleteFiles = false, [FromQuery] bool deleteFolder = false) { - var audiobook = await _repo.GetByIdAsync(id); - if (audiobook == null) - { - return NotFound(new { message = "Audiobook not found" }); - } - - deleteFiles = deleteFiles || deleteFolder; - - AudiobookFilesystemDeleteResult? filesystemResult = null; - if (deleteFiles) - { - filesystemResult = await _audiobookFilesystemDeleteService.DeleteAsync(audiobook, deleteFolder); - } - - // Delete associated image from cache if it exists - try - { - // Prefer ASIN-based cleanup when available - if (!string.IsNullOrEmpty(audiobook.Asin)) - { - var imagePath = await _imageCacheService.GetCachedImagePathAsync(audiobook.Asin); - if (imagePath != null) - { - var fullPath = ResolvePathWithOptionalBase(_contentRootPath, imagePath); - if (System.IO.File.Exists(fullPath)) - { - System.IO.File.Delete(fullPath); - _logger.LogInformation("Deleted cached image for ASIN {Asin}", LogRedaction.SanitizeText(audiobook.Asin)); - } - } - } - else if (!string.IsNullOrEmpty(audiobook.ImageUrl)) - { - // If ImageUrl points to our cached library folder, extract the filename and delete it - try - { - // Safely extract identifier from an internal library image URL - const string __marker = "/config/cache/images/library/"; - var __url = audiobook.ImageUrl; - var __idx = __url.IndexOf(__marker, StringComparison.OrdinalIgnoreCase); - if (__idx >= 0) - { - var filename = __url.Substring(__idx + __marker.Length); - // Ensure we only take the file name portion (prevent embedded paths) - filename = System.IO.Path.GetFileName(filename); - var identifier = System.IO.Path.GetFileNameWithoutExtension(filename); - - // Validate identifier to a conservative whitelist (alnum, dash, underscore, dot) - if (!string.IsNullOrEmpty(identifier) && System.Text.RegularExpressions.Regex.IsMatch(identifier, "^[A-Za-z0-9_\\-\\.]{1,128}$")) - { - var imagePath = await _imageCacheService.GetCachedImagePathAsync(identifier); - if (!string.IsNullOrEmpty(imagePath)) - { - var fullPath = ResolvePathWithOptionalBase(_contentRootPath, imagePath); - if (System.IO.File.Exists(fullPath)) - { - System.IO.File.Delete(fullPath); - _logger.LogInformation("Deleted cached image for identifier (from ImageUrl): {Identifier}", LogRedaction.SanitizeText(identifier)); - } - } - } - else - { - _logger.LogWarning("Image identifier from ImageUrl for audiobook id {Id} is invalid: {Identifier}", audiobook.Id, LogRedaction.SanitizeText(identifier)); - } - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Failed to delete cached image based on stored ImageUrl for audiobook id {Id}", audiobook.Id); - } - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Failed to delete cached image for audiobook id {Id}", audiobook.Id); - // Continue with deletion even if image cleanup fails - } - - var deleted = await _repo.DeleteByIdAsync(id); - if (deleted) - { - var message = filesystemResult?.BuildDeleteMessage() ?? "Audiobook deleted successfully."; - return Ok(new - { - message, - id, - deletedFiles = filesystemResult?.DeletedFiles ?? 0, - deletedFolder = filesystemResult?.DeletedFolder, - deletedParentFolder = filesystemResult?.DeletedParentFolder, - warnings = filesystemResult?.Warnings ?? new List() - }); - } - - return StatusCode(500, new { message = "Failed to delete audiobook" }); + return await _deleteWorkflow.DeleteAsync(id, deleteFiles, deleteFolder); } /// @@ -1129,145 +360,7 @@ public async Task DeleteAudiobook(int id, [FromQuery] bool delete [HttpPost("delete-bulk")] public async Task BulkDeleteAudiobooks([FromBody] BulkDeleteRequest request) { - if (request.Ids == null || !request.Ids.Any()) - { - return BadRequest(new { message = "No audiobook IDs provided for bulk deletion" }); - } - - var deletedCount = 0; - var deletedImagesCount = 0; - var errors = new List(); - var deletedIds = new List(); - - foreach (var id in request.Ids.Distinct()) - { - try - { - var audiobook = await _repo.GetByIdAsync(id); - if (audiobook == null) - { - errors.Add($"Audiobook with ID {id} not found"); - continue; - } - - // Delete associated image from cache if it exists - try - { - if (!string.IsNullOrEmpty(audiobook.Asin)) - { - var imagePath = await _imageCacheService.GetCachedImagePathAsync(audiobook.Asin); - if (imagePath != null) - { - var fullPath = ResolvePathWithOptionalBase(_contentRootPath, imagePath); - if (System.IO.File.Exists(fullPath)) - { - System.IO.File.Delete(fullPath); - deletedImagesCount++; - _logger.LogInformation("Deleted cached image for ASIN {Asin}", LogRedaction.SanitizeText(audiobook.Asin)); - } - } - } - else if (!string.IsNullOrEmpty(audiobook.ImageUrl)) - { - try - { - // Safely extract identifier from an internal library image URL - const string __marker = "/config/cache/images/library/"; - var __url = audiobook.ImageUrl; - var __idx = __url.IndexOf(__marker, StringComparison.OrdinalIgnoreCase); - if (__idx >= 0) - { - var filename = __url.Substring(__idx + __marker.Length); - filename = System.IO.Path.GetFileName(filename); - var identifier = System.IO.Path.GetFileNameWithoutExtension(filename); - - if (!string.IsNullOrEmpty(identifier) && System.Text.RegularExpressions.Regex.IsMatch(identifier, "^[A-Za-z0-9_\\-\\.]{1,128}$")) - { - var imagePath = await _imageCacheService.GetCachedImagePathAsync(identifier); - if (!string.IsNullOrEmpty(imagePath)) - { - var fullPath = ResolvePathWithOptionalBase(_contentRootPath, imagePath); - if (System.IO.File.Exists(fullPath)) - { - System.IO.File.Delete(fullPath); - deletedImagesCount++; - _logger.LogInformation("Deleted cached image for identifier (from ImageUrl): {Identifier}", LogRedaction.SanitizeText(identifier)); - } - } - } - else - { - _logger.LogWarning("Image identifier from ImageUrl for audiobook id {Id} is invalid: {Identifier}", audiobook.Id, LogRedaction.SanitizeText(identifier)); - } - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Failed to delete cached image based on stored ImageUrl for audiobook id {Id}", audiobook.Id); - } - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Failed to delete cached image for audiobook id {Id}", audiobook.Id); - // Continue with deletion even if image cleanup fails - } - - // Log history entry for the deleted audiobook - var historyEntry = new History - { - AudiobookId = audiobook.Id, - AudiobookTitle = audiobook.Title ?? "Unknown Title", - EventType = "Deleted", - Message = $"Audiobook '{audiobook.Title}' deleted via bulk operation", - Source = "BulkDelete", - Timestamp = DateTime.UtcNow - }; - - await _historyRepository.AddAsync(historyEntry); - - var deleted = await _repo.DeleteByIdAsync(id); - if (deleted) - { - deletedCount++; - deletedIds.Add(id); - _logger.LogInformation("Deleted audiobook '{Title}' (ID: {Id}) via bulk operation", LogRedaction.SanitizeText(audiobook.Title), id); - } - else - { - errors.Add($"Failed to delete audiobook with ID {id}"); - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogError(ex, "Error during bulk delete for ID {Id}: {Message}", id, ex.Message); - errors.Add($"Error deleting audiobook with ID {id}: {ex.Message}"); - } - } - - if (deletedCount == 0 && errors.Any()) - { - return BadRequest(new { message = "No audiobooks were successfully deleted", errors }); - } - - object result = errors.Any() - ? new - { - message = $"Partially successful: deleted {deletedCount} audiobook{(deletedCount != 1 ? "s" : "")}, {errors.Count} error{(errors.Count != 1 ? "s" : "")} occurred", - deletedCount, - deletedImagesCount, - ids = deletedIds, - errors - } - : new - { - message = $"Successfully deleted {deletedCount} audiobook{(deletedCount != 1 ? "s" : "")}", - deletedCount, - deletedImagesCount, - ids = deletedIds - }; - - return Ok(result); + return await _bulkEditWorkflow.BulkDeleteAsync(request); } /// @@ -1277,179 +370,7 @@ public async Task BulkDeleteAudiobooks([FromBody] BulkDeleteReque [HttpPost("bulk-update")] public async Task BulkUpdateAudiobooks([FromBody] BulkUpdateRequest request) { - if (request?.Ids == null || !request.Ids.Any()) - { - return BadRequest(new { message = "No audiobook IDs provided for bulk update" }); - } - - var results = new List(); - - // Fetch application settings once for naming pattern when processing rootFolder changes - ApplicationSettings? settings = null; - try - { - using var scope = _scopeFactory.CreateScope(); - var configService = scope.ServiceProvider.GetRequiredService(); - settings = await configService.GetApplicationSettingsAsync(); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Failed to load application settings while performing bulk update"); - } - - foreach (var id in request.Ids.Distinct()) - { - var entryErrors = new List(); - var success = false; - - try - { - var audiobook = await _repo.GetByIdAsync(id); - if (audiobook == null) - { - entryErrors.Add($"Audiobook with ID {id} not found"); - results.Add(new { id, success, errors = entryErrors }); - continue; - } - - // Track whether any change was applied - var changed = false; - - // Monitored - if (request.Updates != null && request.Updates.TryGetValue("monitored", out var monitoredObj)) - { - try - { - var monVal = monitoredObj is JsonElement je - ? je.ValueKind == JsonValueKind.True - : Convert.ToBoolean(monitoredObj); - - audiobook.Monitored = monVal; - changed = true; - _logger.LogInformation("Set Monitored={Monitored} for audiobook id={Id}", monVal, id); - - // History entry - await _historyRepository.AddAsync(new History - { - AudiobookId = audiobook.Id, - AudiobookTitle = audiobook.Title ?? "Unknown", - EventType = "Updated", - Message = $"Monitored set to {monVal}", - Source = "BulkUpdate", - Timestamp = DateTime.UtcNow - }); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - entryErrors.Add($"Invalid monitored value: {ex.Message}"); - } - } - - // QualityProfileId - if (request.Updates != null && request.Updates.TryGetValue("qualityProfileId", out var qpObj)) - { - try - { - var qpVal = qpObj is JsonElement jq - ? jq.GetInt32() - : Convert.ToInt32(qpObj); - - audiobook.QualityProfileId = qpVal; - changed = true; - _logger.LogInformation("Set QualityProfileId={Profile} for audiobook id={Id}", qpVal, id); - - await _historyRepository.AddAsync(new History - { - AudiobookId = audiobook.Id, - AudiobookTitle = audiobook.Title ?? "Unknown", - EventType = "Updated", - Message = $"Quality profile set to {qpVal}", - Source = "BulkUpdate", - Timestamp = DateTime.UtcNow - }); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - entryErrors.Add($"Invalid qualityProfileId value: {ex.Message}"); - } - } - - // Root folder change (rootFolder => path string) - if (request.Updates != null && request.Updates.TryGetValue("rootFolder", out var rootObj)) - { - try - { - string? rootPath = null; - if (rootObj is JsonElement jr) - { - if (jr.ValueKind == JsonValueKind.String) - rootPath = jr.GetString(); - } - else if (rootObj != null) - { - rootPath = rootObj.ToString(); - } - - if (!string.IsNullOrWhiteSpace(rootPath)) - { - // Use configured naming pattern to compute full base directory for this audiobook - var fileNamingPattern = !string.IsNullOrWhiteSpace(settings?.FolderNamingPattern) - ? settings!.FolderNamingPattern - : settings?.FileNamingPattern ?? string.Empty; - var newBase = LibraryPathPlanner.ComputeAudiobookBaseDirectoryFromPattern(audiobook, rootPath, fileNamingPattern, _fileNamingService); - - try - { - if (!Directory.Exists(newBase)) - { - Directory.CreateDirectory(newBase); - _logger.LogInformation("Created directory for audiobook id={Id} at {Path}", id, newBase); - } - - audiobook.BasePath = newBase; - changed = true; - - await _historyRepository.AddAsync(new History - { - AudiobookId = audiobook.Id, - AudiobookTitle = audiobook.Title ?? "Unknown", - EventType = "Updated", - Message = $"BasePath set to {newBase} via bulk update", - Source = "BulkUpdate", - Timestamp = DateTime.UtcNow - }); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - entryErrors.Add($"Failed to apply root folder for audiobook {id}: {ex.Message}"); - } - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - entryErrors.Add($"Invalid rootFolder value: {ex.Message}"); - } - } - - if (changed) - { - await _repo.UpdateAsync(audiobook); - success = true; - } - else - { - entryErrors.Add("No valid updates provided for this audiobook"); - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - entryErrors.Add($"Unhandled error: {ex.Message}"); - } - - results.Add(new { id, success, errors = entryErrors }); - } - - return Ok(new { message = "Bulk update completed", results }); + return await _bulkEditWorkflow.BulkUpdateAsync(request); } /// @@ -1459,355 +380,7 @@ await _historyRepository.AddAsync(new History [HttpPost("{id}/scan")] public async Task ScanAudiobookFiles(int id, [FromBody] ScanRequest? request) { - var audiobook = await _repo.GetByIdAsync(id); - if (audiobook == null) return NotFound(new { message = "Audiobook not found" }); - - var queuedResult = await _scanQueueWorkflow.TryEnqueueAsync(audiobook, request?.Path); - if (queuedResult != null) - { - return queuedResult; - } - - var scanPathResolution = await _scanPathResolver.ResolveAsync(audiobook, request?.Path); - if (scanPathResolution.ErrorResult != null) - { - return scanPathResolution.ErrorResult; - } - - var scanRoot = scanPathResolution.ScanRoot; - - if (string.IsNullOrEmpty(scanRoot) || !Directory.Exists(scanRoot)) - { - return BadRequest(new { message = "Scan path not provided or does not exist", path = scanRoot }); - } - - _logger.LogInformation("Scanning for audiobook files for '{Title}' under: {Path}", LogRedaction.SanitizeText(audiobook.Title), LogRedaction.SanitizeFilePath(scanRoot)); - - // Build a simple matching predicate based on title and first author - var titleToken = (audiobook.Title ?? string.Empty).Replace("\"", string.Empty).Trim(); - var authorToken = audiobook.Authors?.FirstOrDefault() ?? string.Empty; - - var foundFiles = new List(); - try - { - // Search recursively but limit to common audio file extensions - var exts = FileUtils.AudioExtensions; - - // Iterative safe directory traversal to avoid unhandled IO/Access exceptions and handle special characters - var dirs = new Stack(); - dirs.Push(scanRoot); - - while (dirs.Count > 0) - { - var dir = dirs.Pop(); - try - { - var normalizedDir = Path.GetFullPath(dir); - - foreach (var file in Directory.EnumerateFiles(normalizedDir)) - { - try - { - var ext = Path.GetExtension(file); - if (!exts.Contains(ext, StringComparer.OrdinalIgnoreCase)) continue; - var fname = Path.GetFileNameWithoutExtension(file); - if (!string.IsNullOrEmpty(titleToken) && fname.IndexOf(titleToken, StringComparison.OrdinalIgnoreCase) >= 0) - { - foundFiles.Add(file); - continue; - } - if (!string.IsNullOrEmpty(authorToken) && file.IndexOf(authorToken, StringComparison.OrdinalIgnoreCase) >= 0) - { - foundFiles.Add(file); - continue; - } - } - catch (Exception innerFileEx) when (innerFileEx is not OperationCanceledException && innerFileEx is not OutOfMemoryException && innerFileEx is not StackOverflowException) - { - _logger.LogDebug(innerFileEx, "Skipped file while scanning {Dir}", normalizedDir); - continue; - } - } - - foreach (var sub in Directory.EnumerateDirectories(normalizedDir)) - { - dirs.Push(sub); - } - } - catch (System.IO.IOException ioEx) - { - _logger.LogWarning(ioEx, "IO error while enumerating directory during scan: {Dir}", dir); - continue; - } - catch (UnauthorizedAccessException uaEx) - { - _logger.LogWarning(uaEx, "Access denied while enumerating directory during scan: {Dir}", dir); - continue; - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Unexpected error while enumerating directory during scan: {Dir}", dir); - continue; - } - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogError(ex, "Error while scanning filesystem for audiobook files"); - return StatusCode(500, new { message = "Error scanning filesystem", error = ex.Message }); - } - - if (!foundFiles.Any()) - { - return Ok(new { message = "No files found during scan", scannedPath = scanRoot, found = 0 }); - } - - // Calculate base path for the audiobook files - var basePath = LibraryPathPlanner.CalculateBasePath(foundFiles, _logger); - _logger.LogInformation("Calculated base path for audiobook '{Title}': {BasePath}", LogRedaction.SanitizeText(audiobook.Title), LogRedaction.SanitizeFilePath(basePath)); - - var created = new List(); - - // Extract metadata and persist - using (var scope = _scopeFactory.CreateScope()) - { - var metadataService = scope.ServiceProvider.GetRequiredService(); - var audioFileRepository = scope.ServiceProvider.GetRequiredService(); - var historyRepository = scope.ServiceProvider.GetRequiredService(); - - var existingFilesList = await audioFileRepository.GetByAudiobookIdAsync(audiobook.Id); - - foreach (var filePath in foundFiles) - { - try - { - // Calculate relative path from base path - var relativePath = Path.GetRelativePath(basePath, filePath); - - var existing = existingFilesList.FirstOrDefault(f => f.Path == relativePath); - if (existing != null) - { - _logger.LogInformation("Skipping existing AudiobookFile for audiobook {AudiobookId}: {Path}", audiobook.Id, relativePath); - continue; - } - - AudioMetadata? meta = null; - try - { - meta = await metadataService.ExtractFileMetadataAsync(filePath); - } - catch (Exception mex) when (mex is not OperationCanceledException && mex is not OutOfMemoryException && mex is not StackOverflowException) - { - _logger.LogWarning(mex, "Failed to extract metadata for file {File}", filePath); - } - - var fi = new FileInfo(filePath); - var fileRecord = new AudiobookFile - { - AudiobookId = audiobook.Id, - Path = relativePath, // Store relative path - Size = fi.Length, - Source = "scan", - CreatedAt = DateTime.UtcNow, - DurationSeconds = meta?.Duration.TotalSeconds, - Format = meta?.Format, - Bitrate = meta?.BitRate, - SampleRate = meta?.SampleRate, - Channels = meta?.Channels - }; - - await audioFileRepository.AddAsync(fileRecord); - created.Add(fileRecord); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Failed to create AudiobookFile for {File}", filePath); - } - } - - // Update audiobook base path only when we have a non-empty value. - if (!string.IsNullOrEmpty(basePath)) - { - audiobook.BasePath = basePath; - await _repo.UpdateAsync(audiobook); - } - - // Add history entries for newly scanned files - foreach (var historyEntry in created.Select(fileRecord => new History - { - AudiobookId = audiobook.Id, - AudiobookTitle = audiobook.Title ?? "Unknown", - EventType = "File Added", - Message = $"File scanned and added: {Path.GetFileName(fileRecord.Path)}", - Source = "Scan", - Data = JsonSerializer.Serialize(new - { - FilePath = fileRecord.Path, - FileSize = fileRecord.Size, - Format = fileRecord.Format, - Source = fileRecord.Source - }), - Timestamp = DateTime.UtcNow - })) - { - await historyRepository.AddAsync(historyEntry); - } - - // Remove AudiobookFile DB rows for files that no longer exist on disk - try - { - var allExistingFiles = await audioFileRepository.GetByAudiobookIdAsync(audiobook.Id); - - var foundSet = new HashSet(foundFiles.Select(f => Path.GetRelativePath(basePath, f)), StringComparer.OrdinalIgnoreCase); - var toRemove = allExistingFiles - .Where(f => f.Path != null && FileUtils.IsAudioFile(f.Path) && !foundSet.Contains(f.Path)) - .ToList(); - - List removedFilesDto = new(); - if (toRemove.Count > 0) - { - foreach (var rem in toRemove) - { - try - { - removedFilesDto.Add(new { id = rem.Id, path = rem.Path }); - await audioFileRepository.DeleteAsync(rem.Id); - _logger.LogInformation("Removing missing AudiobookFile DB row Id={Id} Path={Path}", rem.Id, rem.Path); - - // Add history entry for removed file - var historyEntry = new History - { - AudiobookId = audiobook.Id, - AudiobookTitle = audiobook.Title ?? "Unknown", - EventType = "File Removed", - Message = $"File removed (no longer exists): {Path.GetFileName(rem.Path)}", - Source = "Scan", - Data = JsonSerializer.Serialize(new - { - FilePath = rem.Path, - FileSize = rem.Size, - Format = rem.Format, - Source = rem.Source - }), - Timestamp = DateTime.UtcNow - }; - await historyRepository.AddAsync(historyEntry); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Failed to remove AudiobookFile Id={Id} Path={Path}", rem.Id, rem.Path); - } - } - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Failed to reconcile audiobook files after scan for audiobook {AudiobookId}", audiobook.Id); - } - - // Handle legacy filePath field migration - try - { - var needsUpdate = false; - if (!string.IsNullOrEmpty(audiobook.FilePath)) - { - // Check if the legacy filePath exists - if (System.IO.File.Exists(audiobook.FilePath)) - { - // File exists - check if we already have an AudiobookFile record for it - var existingFileRecord = await audioFileRepository.ExistsAtPathAsync(audiobook.Id, audiobook.FilePath!); - - if (!existingFileRecord) - { - // Create AudiobookFile record for the legacy filePath - try - { - using var afScope = _scopeFactory.CreateScope(); - var audioFileService = afScope.ServiceProvider.GetRequiredService(); - var migrated = await audioFileService.EnsureAudiobookFileAsync(audiobook, audiobook.FilePath, "scan-legacy"); - if (migrated) - { - _logger.LogInformation("Migrated legacy filePath to AudiobookFile record for audiobook {AudiobookId}: {Path}", audiobook.Id, audiobook.FilePath); - created.Add(new AudiobookFile { Path = audiobook.FilePath }); // Add to created list for response - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Failed to migrate legacy filePath for audiobook {AudiobookId}: {Path}", audiobook.Id, audiobook.FilePath); - } - } - } - else - { - // File doesn't exist - clear the legacy filePath and related fields - audiobook.FilePath = null; - audiobook.FileSize = null; - needsUpdate = true; - _logger.LogInformation("Cleared missing legacy filePath for audiobook {AudiobookId}: {Path}", audiobook.Id, audiobook.FilePath); - - // Add history entry for cleared filePath - var historyEntry = new History - { - AudiobookId = audiobook.Id, - AudiobookTitle = audiobook.Title ?? "Unknown", - EventType = "File Removed", - Message = $"Legacy file path cleared (file no longer exists)", - Source = "Scan", - Data = JsonSerializer.Serialize(new - { - FilePath = audiobook.FilePath, - Source = "legacy-migration" - }), - Timestamp = DateTime.UtcNow - }; - await historyRepository.AddAsync(historyEntry); - } - } - - if (needsUpdate) - { - await _repo.UpdateAsync(audiobook); - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Failed to handle legacy filePath migration for audiobook {AudiobookId}", audiobook.Id); - } - - // Reload audiobook with files to return - var updated = await _repo.GetByIdAsync(audiobook.Id); - - // Send "book-available" notification if the audiobook is monitored and files were imported - if (_notificationService != null && audiobook.Monitored && created.Count > 0) - { - try - { - using var notificationScope = _scopeFactory.CreateScope(); - var configService = notificationScope.ServiceProvider.GetRequiredService(); - var settings = await configService.GetApplicationSettingsAsync(); - var availableData = new - { - id = audiobook.Id, - title = audiobook.Title ?? "Unknown Title", - authors = audiobook.Authors, - asin = audiobook.Asin, - imageUrl = audiobook.ImageUrl, - description = audiobook.Description, - monitored = audiobook.Monitored, - qualityProfileId = audiobook.QualityProfileId, - filesImported = created.Count, - totalFiles = updated?.Files?.Count ?? 0 - }; - await _notificationService.SendNotificationAsync("book-available", availableData, settings.WebhookUrl, settings.EnabledNotificationTriggers); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Failed to send book-available notification for audiobook {AudiobookId}", audiobook.Id); - } - } - - return Ok(new { message = "Scan complete", scannedPath = scanRoot, found = foundFiles.Count, created = created.Count, audiobook = updated }); - } + return await _manualScanWorkflow.ScanAsync(id, request); } /// @@ -1828,127 +401,7 @@ public IActionResult GetScanJobStatus(string jobId) [HttpPost("{id}/move")] public async Task EnqueueMove(int id, [FromBody] MoveRequest request) { - if (_moveQueueService == null) return NotFound(new { message = "Move queue not available" }); - var audiobook = await _repo.GetByIdAsync(id); - if (audiobook == null) return NotFound(new { message = "Audiobook not found" }); - if (request == null) return BadRequest(new { message = "Request body is required" }); - - if (string.IsNullOrEmpty(request.DestinationPath)) - { - return BadRequest(new { message = "DestinationPath is required" }); - } - if (FileUtils.IsPathInvalidForCurrentOs(request.DestinationPath)) - { - return BadRequest(new { message = "DestinationPath is not valid for this operating system" }); - } - - try - { - // If the path is not rooted, combine with configured output path - using var scope = _scopeFactory.CreateScope(); - var configService = scope.ServiceProvider.GetRequiredService(); - var settings = await configService.GetApplicationSettingsAsync(); - - var final = FileUtils.CombineWithOptionalBase(settings.OutputPath, request.DestinationPath!); - final = FileUtils.NormalizeStoredPath(final); - - // If caller explicitly asked to change the DB without moving files, update the BasePath and return early. - if (request.MoveFiles == false) - { - try - { - audiobook.BasePath = final; - await _repo.UpdateAsync(audiobook); - _logger.LogInformation("Updated BasePath for audiobook {AudiobookId} without moving files: {BasePath}", id, final); - return Ok(new { message = "Destination updated" }); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogError(ex, "Failed to update BasePath for audiobook {AudiobookId}", id); - return StatusCode(500, new { message = "Failed to update BasePath", error = ex.Message }); - } - } - - // Determine source path snapshot to use for the move. Prefer an explicit source from the request - // (the frontend should send the original source if it updated the audiobook BasePath before requesting a move), - // otherwise fall back to the current audiobook.BasePath as a best-effort. - var sourcePath = !string.IsNullOrEmpty(request.SourcePath) - ? request.SourcePath - : audiobook.BasePath; - - if (string.IsNullOrEmpty(sourcePath)) - { - return BadRequest(new { message = "Source path not provided. Supply current source path in the Move request or ensure audiobook has a valid BasePath." }); - } - if (FileUtils.IsPathInvalidForCurrentOs(sourcePath)) - { - return BadRequest(new { message = "Source path is not valid for this operating system." }); - } - - // Validate source exists now to provide earlier feedback to clients (avoids enqueueing doomed jobs) - if (!Directory.Exists(sourcePath)) - { - return BadRequest(new { message = "Source path does not exist. Ensure the audiobook's current BasePath exists or provide a valid SourcePath in the request." }); - } - - // Validate target parent is valid and writable (try to create if necessary) - var targetParent = Path.GetDirectoryName(final); - if (string.IsNullOrEmpty(targetParent)) - { - return BadRequest(new { message = "Invalid target path" }); - } - try - { - if (!Directory.Exists(targetParent)) Directory.CreateDirectory(targetParent); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Failed to access or create target parent {TargetParent}", targetParent); - return BadRequest(new { message = "Target parent path is not writable or unavailable" }); - } - - // If source and target are identical, nothing to do - try - { - var srcFull = Path.GetFullPath(sourcePath).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - var tgtFull = Path.GetFullPath(final).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - if (string.Equals(srcFull, tgtFull, StringComparison.OrdinalIgnoreCase)) - { - return BadRequest(new { message = "Source and target paths are identical; nothing to move." }); - } - } - catch (Exception normalizeEx) when ( - normalizeEx is ArgumentException - || normalizeEx is NotSupportedException - || normalizeEx is PathTooLongException - || normalizeEx is System.Security.SecurityException) - { - // Ignore errors normalizing paths; background worker will fail if invalid - _logger.LogDebug(normalizeEx, "Unable to normalize move paths for audiobook {AudiobookId}", id); - } - - var jobId = await _moveQueueService.EnqueueMoveAsync(id, final, sourcePath); - - // Broadcast initial job status - try - { - using var hubScope = _scopeFactory.CreateScope(); - var hub = hubScope.ServiceProvider.GetRequiredService(); - var job = new { jobId = jobId.ToString(), audiobookId = id, status = "Queued", enqueuedAt = DateTime.UtcNow }; - await hub.BroadcastAsync(RealtimeHubTarget.Downloads, "MoveJobUpdate", job); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Failed to broadcast MoveJobUpdate for job {JobId}", jobId); - } - - return Accepted(new { message = "Move enqueued", jobId }); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogError(ex, "Failed to enqueue move job for audiobook {AudiobookId}", id); - return StatusCode(500, new { message = "Failed to enqueue move job", error = ex.Message }); - } + return await _moveWorkflow.EnqueueAsync(id, request); } /// @@ -1958,14 +411,7 @@ normalizeEx is ArgumentException [HttpGet("move/{jobId}")] public IActionResult GetMoveJobStatus(string jobId) { - if (_moveQueueService == null) return NotFound(new { message = "Move queue not available" }); - if (!Guid.TryParse(jobId, out var gid)) return BadRequest(new { message = "Invalid jobId" }); - if (_moveQueueService.TryGetJob(gid, out var job)) - { - _logger.LogInformation("Queried move job {JobId} status: {Status}", gid, job!.Status); - return Ok(job); - } - return NotFound(new { message = "Job not found" }); + return _moveWorkflow.GetStatus(jobId); } /// @@ -1976,28 +422,7 @@ public IActionResult GetMoveJobStatus(string jobId) [HttpPost("move/requeue/{jobId}")] public async Task RequeueMoveJob(string jobId) { - if (_moveQueueService == null) return NotFound(new { message = "Move queue not available" }); - if (!Guid.TryParse(jobId, out var gid)) return BadRequest(new { message = "Invalid jobId" }); - - var newJobId = await _moveQueueService.RequeueMoveAsync(gid); - if (newJobId == null) - { - return BadRequest(new { message = "Unable to requeue job (not found or invalid status)" }); - } - - try - { - using var scope = _scopeFactory.CreateScope(); - var hub = scope.ServiceProvider.GetRequiredService(); - var job = new { jobId = newJobId.ToString(), status = "Queued", enqueuedAt = DateTime.UtcNow }; - await hub.BroadcastAsync(RealtimeHubTarget.Downloads, "MoveJobUpdate", job); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Failed to broadcast MoveJobUpdate for requeued job {JobId}", newJobId); - } - - return Accepted(new { message = "Requeued move job", jobId = newJobId }); + return await _moveWorkflow.RequeueAsync(jobId); } /// @@ -2011,91 +436,6 @@ public async Task RequeueScanJob(string jobId) return await _scanQueueWorkflow.RequeueAsync(jobId); } - // Helper to convert incoming update values (possibly JsonElement or boxed types) to the target property type - private static object? ConvertUpdateValue(object? value, Type targetType) - { - if (value == null) - { - if (targetType == typeof(string)) return string.Empty; - if (targetType.IsValueType) return Activator.CreateInstance(targetType); - return null; - } - - // Unwrap JsonElement if present (from System.Text.Json) - if (value is JsonElement je) - { - try - { - if (je.ValueKind == JsonValueKind.Number && (targetType == typeof(int) || targetType == typeof(int?))) - return je.GetInt32(); - if (je.ValueKind == JsonValueKind.Number && targetType == typeof(double)) - return je.GetDouble(); - if (je.ValueKind == JsonValueKind.True || je.ValueKind == JsonValueKind.False) - return je.GetBoolean(); - if (je.ValueKind == JsonValueKind.String) - return je.GetString(); - // Fall back to raw string - return je.GetRawText(); - } - catch (Exception jsonElementConvertEx) when ( - jsonElementConvertEx is InvalidOperationException - || jsonElementConvertEx is FormatException - || jsonElementConvertEx is OverflowException) - { - // continue to other conversion attempts - System.Diagnostics.Debug.WriteLine("Suppressed non-fatal exception in catch block."); - } - } - - // Handle nullable types - var underlying = Nullable.GetUnderlyingType(targetType) ?? targetType; - - // Enums - if (underlying.IsEnum) - { - if (value is string s) - return Enum.Parse(underlying, s, true); - return Enum.ToObject(underlying, Convert.ChangeType(value, Enum.GetUnderlyingType(underlying))); - } - - // If value already matches - if (underlying.IsInstanceOfType(value)) - return value; - - // Try Convert.ChangeType on primitives - try - { - return Convert.ChangeType(value, underlying); - } - catch (Exception changeTypeEx) when ( - changeTypeEx is InvalidCastException - || changeTypeEx is FormatException - || changeTypeEx is OverflowException - || changeTypeEx is ArgumentException) - { - // Final fallback: attempt parse from string - var str = value.ToString(); - if (underlying == typeof(int) && int.TryParse(str, out var i)) return i; - if (underlying == typeof(double) && double.TryParse(str, out var d)) return d; - if (underlying == typeof(bool) && bool.TryParse(str, out var b)) return b; - if (underlying == typeof(string)) return str; - } - - // As a last resort, return the original value - return value; - } - - private static string ComputeShortHash(string? input) - { - if (string.IsNullOrEmpty(input)) - return Guid.NewGuid().ToString("N").Substring(0, 12); - - var bytes = Encoding.UTF8.GetBytes(input); - var hash = SHA1.HashData(bytes); - // Return first 16 hex characters for a compact identifier - return BitConverter.ToString(hash).Replace("-", "").Substring(0, 16).ToLowerInvariant(); - } - public class BulkDeleteRequest { public List Ids { get; set; } = new List(); diff --git a/listenarr.api/Controllers/LibraryDeleteWorkflow.cs b/listenarr.api/Controllers/LibraryDeleteWorkflow.cs new file mode 100644 index 000000000..a9975bb6c --- /dev/null +++ b/listenarr.api/Controllers/LibraryDeleteWorkflow.cs @@ -0,0 +1,157 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +using System.Text.RegularExpressions; +using Listenarr.Application.Audiobooks; +using Listenarr.Application.Interfaces; +using Listenarr.Application.Interfaces.Repositories; +using Listenarr.Application.Security; +using Listenarr.Domain.Common; +using Listenarr.Domain.Models; +using Microsoft.AspNetCore.Mvc; + +namespace Listenarr.Api.Controllers +{ + public sealed class LibraryDeleteWorkflow + { + private readonly IAudiobookRepository _repo; + private readonly IImageCacheService _imageCacheService; + private readonly IAudiobookFilesystemDeleteService _audiobookFilesystemDeleteService; + private readonly string _contentRootPath; + private readonly ILogger _logger; + + public LibraryDeleteWorkflow( + IAudiobookRepository repo, + IImageCacheService imageCacheService, + IAudiobookFilesystemDeleteService audiobookFilesystemDeleteService, + IApplicationPathService applicationPathService, + ILogger logger) + { + _repo = repo; + _imageCacheService = imageCacheService; + _audiobookFilesystemDeleteService = audiobookFilesystemDeleteService; + _contentRootPath = applicationPathService.ContentRootPath; + _logger = logger; + } + + public async Task DeleteAsync(int id, bool deleteFiles, bool deleteFolder) + { + var audiobook = await _repo.GetByIdAsync(id); + if (audiobook == null) + { + return new NotFoundObjectResult(new { message = "Audiobook not found" }); + } + + deleteFiles = deleteFiles || deleteFolder; + + AudiobookFilesystemDeleteResult? filesystemResult = null; + if (deleteFiles) + { + filesystemResult = await _audiobookFilesystemDeleteService.DeleteAsync(audiobook, deleteFolder); + } + + await DeleteCachedImageAsync(audiobook); + + var deleted = await _repo.DeleteByIdAsync(id); + if (deleted) + { + var message = filesystemResult?.BuildDeleteMessage() ?? "Audiobook deleted successfully."; + return new OkObjectResult(new + { + message, + id, + deletedFiles = filesystemResult?.DeletedFiles ?? 0, + deletedFolder = filesystemResult?.DeletedFolder, + deletedParentFolder = filesystemResult?.DeletedParentFolder, + warnings = filesystemResult?.Warnings ?? new List() + }); + } + + return new ObjectResult(new { message = "Failed to delete audiobook" }) + { + StatusCode = StatusCodes.Status500InternalServerError + }; + } + + private async Task DeleteCachedImageAsync(Audiobook audiobook) + { + try + { + if (!string.IsNullOrEmpty(audiobook.Asin)) + { + await DeleteCachedImageByIdentifierAsync(audiobook.Asin, "ASIN"); + } + else if (!string.IsNullOrEmpty(audiobook.ImageUrl)) + { + await DeleteCachedImageFromUrlAsync(audiobook); + } + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning(ex, "Failed to delete cached image for audiobook id {Id}", audiobook.Id); + } + } + + private async Task DeleteCachedImageByIdentifierAsync(string identifier, string source) + { + var imagePath = await _imageCacheService.GetCachedImagePathAsync(identifier); + if (imagePath == null) + { + return; + } + + var fullPath = FileUtils.CombineWithOptionalBase(_contentRootPath, imagePath); + if (File.Exists(fullPath)) + { + File.Delete(fullPath); + _logger.LogInformation("Deleted cached image for {Source} {Identifier}", source, LogRedaction.SanitizeText(identifier)); + } + } + + private async Task DeleteCachedImageFromUrlAsync(Audiobook audiobook) + { + try + { + const string marker = "/config/cache/images/library/"; + var url = audiobook.ImageUrl!; + var idx = url.IndexOf(marker, StringComparison.OrdinalIgnoreCase); + if (idx < 0) + { + return; + } + + var filename = url.Substring(idx + marker.Length); + filename = Path.GetFileName(filename); + var identifier = Path.GetFileNameWithoutExtension(filename); + + if (!string.IsNullOrEmpty(identifier) && Regex.IsMatch(identifier, "^[A-Za-z0-9_\\-\\.]{1,128}$")) + { + await DeleteCachedImageByIdentifierAsync(identifier, "identifier (from ImageUrl)"); + } + else + { + _logger.LogWarning("Image identifier from ImageUrl for audiobook id {Id} is invalid: {Identifier}", audiobook.Id, LogRedaction.SanitizeText(identifier)); + } + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning(ex, "Failed to delete cached image based on stored ImageUrl for audiobook id {Id}", audiobook.Id); + } + } + } +} diff --git a/listenarr.api/Controllers/LibraryIdentifierWorkflow.cs b/listenarr.api/Controllers/LibraryIdentifierWorkflow.cs new file mode 100644 index 000000000..ed9b37fa6 --- /dev/null +++ b/listenarr.api/Controllers/LibraryIdentifierWorkflow.cs @@ -0,0 +1,224 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +using Listenarr.Application.Audiobooks; +using Listenarr.Application.Interfaces.Repositories; +using Listenarr.Domain.Models; +using Microsoft.AspNetCore.Mvc; + +namespace Listenarr.Api.Controllers +{ + public sealed class LibraryIdentifierWorkflow + { + private readonly IAudiobookRepository _repo; + private readonly ILogger _logger; + + public LibraryIdentifierWorkflow( + IAudiobookRepository repo, + ILogger logger) + { + _repo = repo; + _logger = logger; + } + + public async Task GetAsync(int id) + { + var audiobook = await _repo.GetByIdAsync(id); + + if (audiobook == null) + { + return new NotFoundObjectResult(new { message = "Audiobook not found" }); + } + + var identifiers = AudiobookIdentifierMapper.GetEffectiveIdentifiers(audiobook) + .Select(AudiobookIdentifierMapper.ToIdentifierResponse) + .ToList(); + + return new OkObjectResult(new + { + audiobookId = audiobook.Id, + identifiers + }); + } + + public async Task ReplaceAsync(int id, ReplaceAudiobookIdentifiersRequest? request) + { + var audiobook = await _repo.GetByIdAsync(id); + + if (audiobook == null) + { + return new NotFoundObjectResult(new { message = "Audiobook not found" }); + } + + var incoming = request?.Identifiers ?? new List(); + if (incoming.Count > 50) + { + return new BadRequestObjectResult(new { message = "Too many identifiers. Maximum is 50." }); + } + + var normalizedResult = NormalizeIdentifiers(audiobook, incoming); + if (normalizedResult.ValidationErrors.Count > 0) + { + return new BadRequestObjectResult(new { message = "Identifier validation failed.", errors = normalizedResult.ValidationErrors }); + } + + var normalized = normalizedResult.Identifiers; + EnsurePrimaryIdentifiers(normalized); + + audiobook.ExternalIdentifiers = normalized; + AudiobookIdentifierMapper.SyncLegacyFieldsFromIdentifiers(audiobook); + + await _repo.UpdateWithIdentifierReplaceAsync(audiobook, normalized); + + _logger.LogInformation( + "Replaced identifiers for audiobook {AudiobookId} ({Title}). Count={Count}", + audiobook.Id, + audiobook.Title, + normalized.Count); + + return new OkObjectResult(new + { + message = "Audiobook identifiers updated successfully", + audiobook = new + { + id = audiobook.Id, + asin = audiobook.Asin, + isbn = audiobook.Isbn, + openLibraryId = audiobook.OpenLibraryId + }, + identifiers = AudiobookIdentifierMapper.OrderIdentifiers(audiobook.ExternalIdentifiers) + .Select(AudiobookIdentifierMapper.ToIdentifierResponse) + .ToList() + }); + } + + private static (List Identifiers, List ValidationErrors) NormalizeIdentifiers( + Audiobook audiobook, + List incoming) + { + var validationErrors = new List(); + var normalized = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + var primaryCountByType = new Dictionary(); + var now = DateTime.UtcNow; + var existingServerOwnedSourceKeys = new HashSet( + (audiobook.ExternalIdentifiers ?? new List()) + .Where(identifier => + identifier.Source != AudiobookExternalIdentifierSource.Manual && + !string.IsNullOrWhiteSpace(identifier.ValueNormalized)) + .Select(AudiobookIdentifierMapper.FullSourceKey), + StringComparer.OrdinalIgnoreCase); + + for (var index = 0; index < incoming.Count; index++) + { + var item = incoming[index]; + if (!Enum.IsDefined(typeof(AudiobookExternalIdentifierType), item.Type)) + { + validationErrors.Add(new { index, field = "type", error = "Unsupported identifier type." }); + continue; + } + + if (!AudiobookIdentifierNormalizer.TryNormalize(item.Type, item.Value, out var normalizedValue, out var error)) + { + validationErrors.Add(new { index, field = "value", error = error ?? "Invalid identifier value." }); + continue; + } + + var normalizedRegion = item.Type == AudiobookExternalIdentifierType.Asin + ? AudiobookIdentifierNormalizer.NormalizeRegion(item.Region) + : null; + + var key = $"{item.Type}|{normalizedValue}|{normalizedRegion ?? string.Empty}"; + if (!seen.Add(key)) + { + validationErrors.Add(new { index, field = "value", error = "Duplicate identifier." }); + continue; + } + + if (item.IsPrimary) + { + primaryCountByType.TryGetValue(item.Type, out var count); + primaryCountByType[item.Type] = count + 1; + } + + normalized.Add(new AudiobookExternalIdentifier + { + AudiobookId = audiobook.Id, + Type = item.Type, + ValueRaw = AudiobookIdentifierNormalizer.NormalizeRawValueForStorage(item.Value), + ValueNormalized = normalizedValue, + Region = normalizedRegion, + IsPrimary = item.IsPrimary, + Source = ResolveWriteSource(item, normalizedValue, normalizedRegion, existingServerOwnedSourceKeys), + CreatedAt = now, + UpdatedAt = now + }); + } + + foreach (var kvp in primaryCountByType.Where(kvp => kvp.Value > 1)) + { + validationErrors.Add(new + { + field = "isPrimary", + type = kvp.Key, + error = $"Only one primary identifier is allowed for type {kvp.Key}." + }); + } + + return (normalized, validationErrors); + } + + private static AudiobookExternalIdentifierSource ResolveWriteSource( + AudiobookIdentifierWriteItem item, + string normalizedValue, + string? normalizedRegion, + HashSet existingServerOwnedSourceKeys) + { + var source = item.Source ?? AudiobookExternalIdentifierSource.Manual; + if (!Enum.IsDefined(typeof(AudiobookExternalIdentifierSource), source)) + { + return AudiobookExternalIdentifierSource.Manual; + } + + if (source == AudiobookExternalIdentifierSource.Manual) + { + return source; + } + + var requestedKey = AudiobookIdentifierMapper.FullSourceKey(item.Type, normalizedValue, normalizedRegion, source); + return existingServerOwnedSourceKeys.Contains(requestedKey) + ? source + : AudiobookExternalIdentifierSource.Manual; + } + + private static void EnsurePrimaryIdentifiers(List normalized) + { + var asins = normalized.Where(identifier => identifier.Type == AudiobookExternalIdentifierType.Asin).ToList(); + if (asins.Count > 0 && !asins.Any(identifier => identifier.IsPrimary)) + { + asins[0].IsPrimary = true; + } + + var olids = normalized.Where(identifier => identifier.Type == AudiobookExternalIdentifierType.OpenLibraryId).ToList(); + if (olids.Count == 1) + { + olids[0].IsPrimary = true; + } + } + } +} diff --git a/listenarr.api/Controllers/LibraryManualScanWorkflow.cs b/listenarr.api/Controllers/LibraryManualScanWorkflow.cs new file mode 100644 index 000000000..de7f5ef83 --- /dev/null +++ b/listenarr.api/Controllers/LibraryManualScanWorkflow.cs @@ -0,0 +1,416 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +using System.Text.Json; +using Listenarr.Application.Interfaces; +using Listenarr.Application.Interfaces.Repositories; +using Listenarr.Application.Notification; +using Listenarr.Application.Security; +using Listenarr.Domain.Common; +using Listenarr.Domain.Models; +using Microsoft.AspNetCore.Mvc; + +namespace Listenarr.Api.Controllers +{ + public sealed class LibraryManualScanWorkflow + { + private readonly IAudiobookRepository _repo; + private readonly IServiceScopeFactory _scopeFactory; + private readonly NotificationService? _notificationService; + private readonly LibraryScanPathResolver _scanPathResolver; + private readonly LibraryScanQueueWorkflow _scanQueueWorkflow; + private readonly ILogger _logger; + + public LibraryManualScanWorkflow( + IAudiobookRepository repo, + IServiceScopeFactory scopeFactory, + LibraryScanPathResolver scanPathResolver, + LibraryScanQueueWorkflow scanQueueWorkflow, + ILogger logger, + NotificationService? notificationService = null) + { + _repo = repo; + _scopeFactory = scopeFactory; + _scanPathResolver = scanPathResolver; + _scanQueueWorkflow = scanQueueWorkflow; + _logger = logger; + _notificationService = notificationService; + } + + public async Task ScanAsync(int id, LibraryController.ScanRequest? request) + { + var audiobook = await _repo.GetByIdAsync(id); + if (audiobook == null) return new NotFoundObjectResult(new { message = "Audiobook not found" }); + + var queuedResult = await _scanQueueWorkflow.TryEnqueueAsync(audiobook, request?.Path); + if (queuedResult != null) + { + return queuedResult; + } + + var scanPathResolution = await _scanPathResolver.ResolveAsync(audiobook, request?.Path); + if (scanPathResolution.ErrorResult != null) + { + return scanPathResolution.ErrorResult; + } + + var scanRoot = scanPathResolution.ScanRoot; + + if (string.IsNullOrEmpty(scanRoot) || !Directory.Exists(scanRoot)) + { + return new BadRequestObjectResult(new { message = "Scan path not provided or does not exist", path = scanRoot }); + } + + _logger.LogInformation("Scanning for audiobook files for '{Title}' under: {Path}", LogRedaction.SanitizeText(audiobook.Title), LogRedaction.SanitizeFilePath(scanRoot)); + + var foundFilesResult = FindMatchingAudioFiles(audiobook, scanRoot); + if (foundFilesResult.ErrorResult != null) + { + return foundFilesResult.ErrorResult; + } + + var foundFiles = foundFilesResult.FoundFiles; + if (!foundFiles.Any()) + { + return new OkObjectResult(new { message = "No files found during scan", scannedPath = scanRoot, found = 0 }); + } + + var basePath = LibraryPathPlanner.CalculateBasePath(foundFiles, _logger); + _logger.LogInformation("Calculated base path for audiobook '{Title}': {BasePath}", LogRedaction.SanitizeText(audiobook.Title), LogRedaction.SanitizeFilePath(basePath)); + + var created = new List(); + + using var scope = _scopeFactory.CreateScope(); + var metadataService = scope.ServiceProvider.GetRequiredService(); + var audioFileRepository = scope.ServiceProvider.GetRequiredService(); + var historyRepository = scope.ServiceProvider.GetRequiredService(); + + var existingFilesList = await audioFileRepository.GetByAudiobookIdAsync(audiobook.Id); + + foreach (var filePath in foundFiles) + { + try + { + var relativePath = Path.GetRelativePath(basePath, filePath); + + var existing = existingFilesList.FirstOrDefault(f => f.Path == relativePath); + if (existing != null) + { + _logger.LogInformation("Skipping existing AudiobookFile for audiobook {AudiobookId}: {Path}", audiobook.Id, relativePath); + continue; + } + + AudioMetadata? meta = null; + try + { + meta = await metadataService.ExtractFileMetadataAsync(filePath); + } + catch (Exception mex) when (mex is not OperationCanceledException && mex is not OutOfMemoryException && mex is not StackOverflowException) + { + _logger.LogWarning(mex, "Failed to extract metadata for file {File}", filePath); + } + + var fi = new FileInfo(filePath); + var fileRecord = new AudiobookFile + { + AudiobookId = audiobook.Id, + Path = relativePath, + Size = fi.Length, + Source = "scan", + CreatedAt = DateTime.UtcNow, + DurationSeconds = meta?.Duration.TotalSeconds, + Format = meta?.Format, + Bitrate = meta?.BitRate, + SampleRate = meta?.SampleRate, + Channels = meta?.Channels + }; + + await audioFileRepository.AddAsync(fileRecord); + created.Add(fileRecord); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning(ex, "Failed to create AudiobookFile for {File}", filePath); + } + } + + if (!string.IsNullOrEmpty(basePath)) + { + audiobook.BasePath = basePath; + await _repo.UpdateAsync(audiobook); + } + + foreach (var historyEntry in created.Select(fileRecord => new History + { + AudiobookId = audiobook.Id, + AudiobookTitle = audiobook.Title ?? "Unknown", + EventType = "File Added", + Message = $"File scanned and added: {Path.GetFileName(fileRecord.Path)}", + Source = "Scan", + Data = JsonSerializer.Serialize(new + { + FilePath = fileRecord.Path, + FileSize = fileRecord.Size, + Format = fileRecord.Format, + Source = fileRecord.Source + }), + Timestamp = DateTime.UtcNow + })) + { + await historyRepository.AddAsync(historyEntry); + } + + await ReconcileMissingFilesAsync(audiobook, foundFiles, basePath, audioFileRepository, historyRepository); + await MigrateLegacyFilePathAsync(audiobook, created, audioFileRepository, historyRepository); + + var updated = await _repo.GetByIdAsync(audiobook.Id); + + await SendAvailableNotificationAsync(audiobook, created.Count, updated); + + return new OkObjectResult(new { message = "Scan complete", scannedPath = scanRoot, found = foundFiles.Count, created = created.Count, audiobook = updated }); + } + + private (List FoundFiles, IActionResult? ErrorResult) FindMatchingAudioFiles(Audiobook audiobook, string scanRoot) + { + var titleToken = (audiobook.Title ?? string.Empty).Replace("\"", string.Empty).Trim(); + var authorToken = audiobook.Authors?.FirstOrDefault() ?? string.Empty; + var foundFiles = new List(); + + try + { + var exts = FileUtils.AudioExtensions; + var dirs = new Stack(); + dirs.Push(scanRoot); + + while (dirs.Count > 0) + { + var dir = dirs.Pop(); + try + { + var normalizedDir = Path.GetFullPath(dir); + + foreach (var file in Directory.EnumerateFiles(normalizedDir)) + { + try + { + var ext = Path.GetExtension(file); + if (!exts.Contains(ext, StringComparer.OrdinalIgnoreCase)) continue; + var fname = Path.GetFileNameWithoutExtension(file); + if (!string.IsNullOrEmpty(titleToken) && fname.IndexOf(titleToken, StringComparison.OrdinalIgnoreCase) >= 0) + { + foundFiles.Add(file); + continue; + } + if (!string.IsNullOrEmpty(authorToken) && file.IndexOf(authorToken, StringComparison.OrdinalIgnoreCase) >= 0) + { + foundFiles.Add(file); + } + } + catch (Exception innerFileEx) when (innerFileEx is not OperationCanceledException && innerFileEx is not OutOfMemoryException && innerFileEx is not StackOverflowException) + { + _logger.LogDebug(innerFileEx, "Skipped file while scanning {Dir}", normalizedDir); + } + } + + foreach (var sub in Directory.EnumerateDirectories(normalizedDir)) + { + dirs.Push(sub); + } + } + catch (IOException ioEx) + { + _logger.LogWarning(ioEx, "IO error while enumerating directory during scan: {Dir}", dir); + } + catch (UnauthorizedAccessException uaEx) + { + _logger.LogWarning(uaEx, "Access denied while enumerating directory during scan: {Dir}", dir); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning(ex, "Unexpected error while enumerating directory during scan: {Dir}", dir); + } + } + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogError(ex, "Error while scanning filesystem for audiobook files"); + return (foundFiles, new ObjectResult(new { message = "Error scanning filesystem", error = ex.Message }) + { + StatusCode = StatusCodes.Status500InternalServerError + }); + } + + return (foundFiles, null); + } + + private async Task ReconcileMissingFilesAsync( + Audiobook audiobook, + List foundFiles, + string basePath, + IAudiobookFileRepository audioFileRepository, + IHistoryRepository historyRepository) + { + try + { + var allExistingFiles = await audioFileRepository.GetByAudiobookIdAsync(audiobook.Id); + + var foundSet = new HashSet(foundFiles.Select(f => Path.GetRelativePath(basePath, f)), StringComparer.OrdinalIgnoreCase); + var toRemove = allExistingFiles + .Where(f => f.Path != null && FileUtils.IsAudioFile(f.Path) && !foundSet.Contains(f.Path)) + .ToList(); + + foreach (var rem in toRemove) + { + try + { + await audioFileRepository.DeleteAsync(rem.Id); + _logger.LogInformation("Removing missing AudiobookFile DB row Id={Id} Path={Path}", rem.Id, rem.Path); + + await historyRepository.AddAsync(new History + { + AudiobookId = audiobook.Id, + AudiobookTitle = audiobook.Title ?? "Unknown", + EventType = "File Removed", + Message = $"File removed (no longer exists): {Path.GetFileName(rem.Path)}", + Source = "Scan", + Data = JsonSerializer.Serialize(new + { + FilePath = rem.Path, + FileSize = rem.Size, + Format = rem.Format, + Source = rem.Source + }), + Timestamp = DateTime.UtcNow + }); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning(ex, "Failed to remove AudiobookFile Id={Id} Path={Path}", rem.Id, rem.Path); + } + } + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning(ex, "Failed to reconcile audiobook files after scan for audiobook {AudiobookId}", audiobook.Id); + } + } + + private async Task MigrateLegacyFilePathAsync( + Audiobook audiobook, + List created, + IAudiobookFileRepository audioFileRepository, + IHistoryRepository historyRepository) + { + try + { + var needsUpdate = false; + if (!string.IsNullOrEmpty(audiobook.FilePath)) + { + if (File.Exists(audiobook.FilePath)) + { + var existingFileRecord = await audioFileRepository.ExistsAtPathAsync(audiobook.Id, audiobook.FilePath!); + + if (!existingFileRecord) + { + try + { + using var afScope = _scopeFactory.CreateScope(); + var audioFileService = afScope.ServiceProvider.GetRequiredService(); + var migrated = await audioFileService.EnsureAudiobookFileAsync(audiobook, audiobook.FilePath, "scan-legacy"); + if (migrated) + { + _logger.LogInformation("Migrated legacy filePath to AudiobookFile record for audiobook {AudiobookId}: {Path}", audiobook.Id, audiobook.FilePath); + created.Add(new AudiobookFile { Path = audiobook.FilePath }); + } + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning(ex, "Failed to migrate legacy filePath for audiobook {AudiobookId}: {Path}", audiobook.Id, audiobook.FilePath); + } + } + } + else + { + var missingFilePath = audiobook.FilePath; + audiobook.FilePath = null; + audiobook.FileSize = null; + needsUpdate = true; + _logger.LogInformation("Cleared missing legacy filePath for audiobook {AudiobookId}: {Path}", audiobook.Id, missingFilePath); + + await historyRepository.AddAsync(new History + { + AudiobookId = audiobook.Id, + AudiobookTitle = audiobook.Title ?? "Unknown", + EventType = "File Removed", + Message = "Legacy file path cleared (file no longer exists)", + Source = "Scan", + Data = JsonSerializer.Serialize(new + { + FilePath = audiobook.FilePath, + Source = "legacy-migration" + }), + Timestamp = DateTime.UtcNow + }); + } + } + + if (needsUpdate) + { + await _repo.UpdateAsync(audiobook); + } + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning(ex, "Failed to handle legacy filePath migration for audiobook {AudiobookId}", audiobook.Id); + } + } + + private async Task SendAvailableNotificationAsync(Audiobook audiobook, int createdCount, Audiobook? updated) + { + if (_notificationService == null || !audiobook.Monitored || createdCount <= 0) + { + return; + } + + try + { + using var notificationScope = _scopeFactory.CreateScope(); + var configService = notificationScope.ServiceProvider.GetRequiredService(); + var settings = await configService.GetApplicationSettingsAsync(); + var availableData = new + { + id = audiobook.Id, + title = audiobook.Title ?? "Unknown Title", + authors = audiobook.Authors, + asin = audiobook.Asin, + imageUrl = audiobook.ImageUrl, + description = audiobook.Description, + monitored = audiobook.Monitored, + qualityProfileId = audiobook.QualityProfileId, + filesImported = createdCount, + totalFiles = updated?.Files?.Count ?? 0 + }; + await _notificationService.SendNotificationAsync("book-available", availableData, settings.WebhookUrl, settings.EnabledNotificationTriggers); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning(ex, "Failed to send book-available notification for audiobook {AudiobookId}", audiobook.Id); + } + } + } +} diff --git a/listenarr.api/Controllers/LibraryMoveWorkflow.cs b/listenarr.api/Controllers/LibraryMoveWorkflow.cs new file mode 100644 index 000000000..3375664fc --- /dev/null +++ b/listenarr.api/Controllers/LibraryMoveWorkflow.cs @@ -0,0 +1,201 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +using Listenarr.Application.Interfaces; +using Listenarr.Application.Interfaces.Repositories; +using Listenarr.Domain.Common; +using Microsoft.AspNetCore.Mvc; + +namespace Listenarr.Api.Controllers +{ + public sealed class LibraryMoveWorkflow + { + private readonly IAudiobookRepository _repo; + private readonly IServiceScopeFactory _scopeFactory; + private readonly IMoveQueueService? _moveQueueService; + private readonly ILogger _logger; + + public LibraryMoveWorkflow( + IAudiobookRepository repo, + IServiceScopeFactory scopeFactory, + ILogger logger, + IMoveQueueService? moveQueueService = null) + { + _repo = repo; + _scopeFactory = scopeFactory; + _logger = logger; + _moveQueueService = moveQueueService; + } + + public async Task EnqueueAsync(int id, LibraryController.MoveRequest request) + { + if (_moveQueueService == null) return new NotFoundObjectResult(new { message = "Move queue not available" }); + var audiobook = await _repo.GetByIdAsync(id); + if (audiobook == null) return new NotFoundObjectResult(new { message = "Audiobook not found" }); + if (request == null) return new BadRequestObjectResult(new { message = "Request body is required" }); + + if (string.IsNullOrEmpty(request.DestinationPath)) + { + return new BadRequestObjectResult(new { message = "DestinationPath is required" }); + } + + if (FileUtils.IsPathInvalidForCurrentOs(request.DestinationPath)) + { + return new BadRequestObjectResult(new { message = "DestinationPath is not valid for this operating system" }); + } + + try + { + using var scope = _scopeFactory.CreateScope(); + var configService = scope.ServiceProvider.GetRequiredService(); + var settings = await configService.GetApplicationSettingsAsync(); + + var final = FileUtils.CombineWithOptionalBase(settings.OutputPath, request.DestinationPath!); + final = FileUtils.NormalizeStoredPath(final); + + if (request.MoveFiles == false) + { + try + { + audiobook.BasePath = final; + await _repo.UpdateAsync(audiobook); + _logger.LogInformation("Updated BasePath for audiobook {AudiobookId} without moving files: {BasePath}", id, final); + return new OkObjectResult(new { message = "Destination updated" }); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogError(ex, "Failed to update BasePath for audiobook {AudiobookId}", id); + return new ObjectResult(new { message = "Failed to update BasePath", error = ex.Message }) + { + StatusCode = StatusCodes.Status500InternalServerError + }; + } + } + + var sourcePath = !string.IsNullOrEmpty(request.SourcePath) + ? request.SourcePath + : audiobook.BasePath; + + if (string.IsNullOrEmpty(sourcePath)) + { + return new BadRequestObjectResult(new { message = "Source path not provided. Supply current source path in the Move request or ensure audiobook has a valid BasePath." }); + } + + if (FileUtils.IsPathInvalidForCurrentOs(sourcePath)) + { + return new BadRequestObjectResult(new { message = "Source path is not valid for this operating system." }); + } + + if (!Directory.Exists(sourcePath)) + { + return new BadRequestObjectResult(new { message = "Source path does not exist. Ensure the audiobook's current BasePath exists or provide a valid SourcePath in the request." }); + } + + var targetParent = Path.GetDirectoryName(final); + if (string.IsNullOrEmpty(targetParent)) + { + return new BadRequestObjectResult(new { message = "Invalid target path" }); + } + + try + { + if (!Directory.Exists(targetParent)) Directory.CreateDirectory(targetParent); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning(ex, "Failed to access or create target parent {TargetParent}", targetParent); + return new BadRequestObjectResult(new { message = "Target parent path is not writable or unavailable" }); + } + + try + { + var srcFull = Path.GetFullPath(sourcePath).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var tgtFull = Path.GetFullPath(final).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + if (string.Equals(srcFull, tgtFull, StringComparison.OrdinalIgnoreCase)) + { + return new BadRequestObjectResult(new { message = "Source and target paths are identical; nothing to move." }); + } + } + catch (Exception normalizeEx) when ( + normalizeEx is ArgumentException + || normalizeEx is NotSupportedException + || normalizeEx is PathTooLongException + || normalizeEx is System.Security.SecurityException) + { + _logger.LogDebug(normalizeEx, "Unable to normalize move paths for audiobook {AudiobookId}", id); + } + + var jobId = await _moveQueueService.EnqueueMoveAsync(id, final, sourcePath); + await BroadcastQueuedAsync(jobId, id); + + return new AcceptedResult(string.Empty, new { message = "Move enqueued", jobId }); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogError(ex, "Failed to enqueue move job for audiobook {AudiobookId}", id); + return new ObjectResult(new { message = "Failed to enqueue move job", error = ex.Message }) + { + StatusCode = StatusCodes.Status500InternalServerError + }; + } + } + + public IActionResult GetStatus(string jobId) + { + if (_moveQueueService == null) return new NotFoundObjectResult(new { message = "Move queue not available" }); + if (!Guid.TryParse(jobId, out var gid)) return new BadRequestObjectResult(new { message = "Invalid jobId" }); + if (_moveQueueService.TryGetJob(gid, out var job)) + { + _logger.LogInformation("Queried move job {JobId} status: {Status}", gid, job!.Status); + return new OkObjectResult(job); + } + + return new NotFoundObjectResult(new { message = "Job not found" }); + } + + public async Task RequeueAsync(string jobId) + { + if (_moveQueueService == null) return new NotFoundObjectResult(new { message = "Move queue not available" }); + if (!Guid.TryParse(jobId, out var gid)) return new BadRequestObjectResult(new { message = "Invalid jobId" }); + + var newJobId = await _moveQueueService.RequeueMoveAsync(gid); + if (newJobId == null) + { + return new BadRequestObjectResult(new { message = "Unable to requeue job (not found or invalid status)" }); + } + + await BroadcastQueuedAsync(newJobId.Value, audiobookId: null); + return new AcceptedResult(string.Empty, new { message = "Requeued move job", jobId = newJobId }); + } + + private async Task BroadcastQueuedAsync(Guid jobId, int? audiobookId) + { + try + { + using var scope = _scopeFactory.CreateScope(); + var hub = scope.ServiceProvider.GetRequiredService(); + var job = new { jobId = jobId.ToString(), audiobookId, status = "Queued", enqueuedAt = DateTime.UtcNow }; + await hub.BroadcastAsync(RealtimeHubTarget.Downloads, "MoveJobUpdate", job); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning(ex, "Failed to broadcast MoveJobUpdate for job {JobId}", jobId); + } + } + } +} diff --git a/listenarr.api/Controllers/LibraryUpdateWorkflow.cs b/listenarr.api/Controllers/LibraryUpdateWorkflow.cs new file mode 100644 index 000000000..efd54f433 --- /dev/null +++ b/listenarr.api/Controllers/LibraryUpdateWorkflow.cs @@ -0,0 +1,190 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +using Listenarr.Application.Audiobooks; +using Listenarr.Application.Interfaces; +using Listenarr.Application.Interfaces.Repositories; +using Listenarr.Application.Security; +using Listenarr.Domain.Common; +using Listenarr.Domain.Models; +using Microsoft.AspNetCore.Mvc; + +namespace Listenarr.Api.Controllers +{ + public sealed class LibraryUpdateWorkflow + { + private readonly IAudiobookRepository _repo; + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + + public LibraryUpdateWorkflow( + IAudiobookRepository repo, + IServiceScopeFactory scopeFactory, + ILogger logger) + { + _repo = repo; + _scopeFactory = scopeFactory; + _logger = logger; + } + + public async Task UpdateAsync(int id, Audiobook updatedAudiobook) + { + var existingAudiobook = await _repo.GetByIdAsync(id); + if (existingAudiobook == null) + { + return new NotFoundObjectResult(new { message = "Audiobook not found" }); + } + + var legacyIdentifierFieldsTouched = false; + + if (updatedAudiobook.Title != null) existingAudiobook.Title = updatedAudiobook.Title; + if (updatedAudiobook.Subtitle != null) existingAudiobook.Subtitle = updatedAudiobook.Subtitle; + if (updatedAudiobook.Authors != null) existingAudiobook.Authors = updatedAudiobook.Authors; + if (updatedAudiobook.ImageUrl != null) existingAudiobook.ImageUrl = updatedAudiobook.ImageUrl; + if (updatedAudiobook.PublishYear != null) existingAudiobook.PublishYear = updatedAudiobook.PublishYear; + if (updatedAudiobook.PublishedDate != null) existingAudiobook.PublishedDate = updatedAudiobook.PublishedDate; + if (updatedAudiobook.Description != null) existingAudiobook.Description = updatedAudiobook.Description; + if (updatedAudiobook.Genres != null) existingAudiobook.Genres = updatedAudiobook.Genres; + if (updatedAudiobook.Tags != null) existingAudiobook.Tags = updatedAudiobook.Tags; + if (updatedAudiobook.Narrators != null) existingAudiobook.Narrators = updatedAudiobook.Narrators; + if (updatedAudiobook.Isbn != null) + { + existingAudiobook.Isbn = updatedAudiobook.Isbn; + legacyIdentifierFieldsTouched = true; + } + + if (updatedAudiobook.Asin != null) + { + existingAudiobook.Asin = updatedAudiobook.Asin; + legacyIdentifierFieldsTouched = true; + } + + if (updatedAudiobook.OpenLibraryId != null) + { + existingAudiobook.OpenLibraryId = updatedAudiobook.OpenLibraryId; + legacyIdentifierFieldsTouched = true; + } + + if (updatedAudiobook.Publisher != null) existingAudiobook.Publisher = updatedAudiobook.Publisher; + if (updatedAudiobook.Language != null) existingAudiobook.Language = updatedAudiobook.Language; + if (updatedAudiobook.Runtime != null) existingAudiobook.Runtime = updatedAudiobook.Runtime; + if (updatedAudiobook.Edition != null) existingAudiobook.Edition = updatedAudiobook.Edition; + if (updatedAudiobook.Version != null) existingAudiobook.Version = updatedAudiobook.Version; + + ApplySeriesMembershipUpdates(existingAudiobook, updatedAudiobook); + + existingAudiobook.Explicit = updatedAudiobook.Explicit; + existingAudiobook.Abridged = updatedAudiobook.Abridged; + existingAudiobook.Monitored = updatedAudiobook.Monitored; + + if (updatedAudiobook.FilePath != null) existingAudiobook.FilePath = updatedAudiobook.FilePath; + if (updatedAudiobook.FileSize.HasValue) existingAudiobook.FileSize = updatedAudiobook.FileSize; + if (updatedAudiobook.Quality != null) existingAudiobook.Quality = updatedAudiobook.Quality; + + await ApplyQualityProfileAsync(existingAudiobook, updatedAudiobook); + + if (updatedAudiobook.BasePath != null) + { + existingAudiobook.BasePath = FileUtils.NormalizeStoredPath(updatedAudiobook.BasePath); + _logger.LogInformation("Updated BasePath for audiobook '{Title}' to: {BasePath}", LogRedaction.SanitizeText(existingAudiobook.Title), LogRedaction.SanitizeFilePath(updatedAudiobook.BasePath)); + } + + if (legacyIdentifierFieldsTouched) + { + AudiobookIdentifierMapper.SyncImportedIdentifiersFromLegacyFields(existingAudiobook); + } + + await _repo.UpdateAsync(existingAudiobook); + + _logger.LogInformation("Updated audiobook '{Title}' (ID: {Id})", LogRedaction.SanitizeText(existingAudiobook.Title), id); + + return new OkObjectResult(new { message = "Audiobook updated successfully", audiobook = existingAudiobook }); + } + + private static void ApplySeriesMembershipUpdates(Audiobook existingAudiobook, Audiobook updatedAudiobook) + { + var seriesMembershipsTouched = + updatedAudiobook.SeriesMemberships != null || + updatedAudiobook.Series != null || + updatedAudiobook.SeriesNumber != null; + + if (!seriesMembershipsTouched) + { + return; + } + + var mergedSeries = updatedAudiobook.Series ?? existingAudiobook.Series; + var mergedSeriesNumber = updatedAudiobook.SeriesNumber ?? existingAudiobook.SeriesNumber; + var existingPrimaryMembership = AudiobookSeriesMembershipHelper.GetPrimaryMembership(existingAudiobook.SeriesMemberships); + + var normalizedMemberships = AudiobookSeriesMembershipHelper.Normalize( + updatedAudiobook.SeriesMemberships, + mergedSeries, + mergedSeriesNumber, + existingPrimaryMembership?.SeriesAsin); + + if (existingAudiobook.SeriesMemberships == null) + { + existingAudiobook.SeriesMemberships = new List(); + } + else + { + existingAudiobook.SeriesMemberships.Clear(); + } + + foreach (var membership in normalizedMemberships) + { + existingAudiobook.SeriesMemberships.Add(membership); + } + + AudiobookSeriesMembershipHelper.ApplyPrimarySeriesFields(existingAudiobook); + } + + private async Task ApplyQualityProfileAsync(Audiobook existingAudiobook, Audiobook updatedAudiobook) + { + if (!updatedAudiobook.QualityProfileId.HasValue) + { + return; + } + + if (updatedAudiobook.QualityProfileId.Value == -1) + { + using var scope = _scopeFactory.CreateScope(); + var qualityProfileService = scope.ServiceProvider.GetRequiredService(); + var defaultProfile = await qualityProfileService.GetDefaultAsync(); + if (defaultProfile != null) + { + existingAudiobook.QualityProfileId = defaultProfile.Id; + _logger.LogInformation("Assigned default quality profile '{ProfileName}' (ID: {ProfileId}) to audiobook '{Title}'", + defaultProfile.Name, defaultProfile.Id, existingAudiobook.Title); + } + else + { + _logger.LogWarning("No default quality profile found. Audiobook '{Title}' quality profile set to null.", LogRedaction.SanitizeText(existingAudiobook.Title)); + existingAudiobook.QualityProfileId = null; + } + + return; + } + + existingAudiobook.QualityProfileId = updatedAudiobook.QualityProfileId.Value; + _logger.LogInformation("Updated quality profile for audiobook '{Title}' to ID {ProfileId}", + existingAudiobook.Title, updatedAudiobook.QualityProfileId.Value); + } + } +} diff --git a/listenarr.api/Controllers/MetadataController.cs b/listenarr.api/Controllers/MetadataController.cs index a3a8d2bc8..2b2c5ac8b 100644 --- a/listenarr.api/Controllers/MetadataController.cs +++ b/listenarr.api/Controllers/MetadataController.cs @@ -18,7 +18,6 @@ using Listenarr.Application.Interfaces; using Listenarr.Application.Interfaces.Repositories; using Listenarr.Application.Metadata; -using Listenarr.Domain.Models; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Caching.Memory; @@ -39,6 +38,8 @@ public class MetadataController : ControllerBase private readonly IAsinLookupService _asinLookupService; private readonly IAuthorCatalogService _authorCatalogService; private readonly ISeriesCatalogService _seriesCatalogService; + private readonly MetadataImageCacheWorkflow _imageCacheWorkflow; + private readonly MetadataLookupCacheWorkflow _lookupCacheWorkflow; public MetadataController( IAudiobookMetadataService metadataService, @@ -62,6 +63,8 @@ public MetadataController( _authorCatalogService = authorCatalogService; _seriesCatalogService = seriesCatalogService; _logger = logger; + _imageCacheWorkflow = new MetadataImageCacheWorkflow(_audiobookRepository, _imageCacheService, _logger); + _lookupCacheWorkflow = new MetadataLookupCacheWorkflow(_audiobookRepository, _imageCacheService, _imageCacheWorkflow, _logger); } /// @@ -214,7 +217,7 @@ private async Task> LookupAuthorCore( // If previously marked NotFound, try to resolve an ASIN from the DB and check cache by ASIN if (cachedEntry.NotFound) { - var notFoundCacheProbe = await ProbeAuthorImageCacheAsync(normalizedName, region, cachedEntry.Asin); + var notFoundCacheProbe = await _imageCacheWorkflow.ProbeAuthorImageCacheAsync(normalizedName, region, cachedEntry.Asin); if (!string.IsNullOrWhiteSpace(notFoundCacheProbe.CachedPath)) { cachedEntry.Asin = notFoundCacheProbe.Asin ?? cachedEntry.Asin; @@ -232,12 +235,12 @@ private async Task> LookupAuthorCore( string? cachedPath = cachedEntry.CachedPath; if (!string.IsNullOrWhiteSpace(cachedEntry.Asin)) { - cachedPath = await ResolveCachedImagePathAsync(cachedEntry.Asin) ?? cachedPath; + cachedPath = await _imageCacheWorkflow.ResolveCachedImagePathAsync(cachedEntry.Asin) ?? cachedPath; } cachedEntry.CachedPath = cachedPath; - if (HasCompleteAuthorLookupData(cachedEntry.CachedPath, cachedEntry.Description, cachedEntry.SimilarAuthors)) + if (MetadataResponseMapper.HasCompleteAuthorLookupData(cachedEntry.CachedPath, cachedEntry.Description, cachedEntry.SimilarAuthors)) { return Ok(MapAuthorLookupResponse(cachedEntry, normalizedName)); } @@ -252,12 +255,12 @@ private async Task> LookupAuthorCore( .ToList() ?? new List(); } - var persistedEntry = await ResolvePersistedAuthorCacheAsync(normalizedName, region, normalizedAsin); + var persistedEntry = await _lookupCacheWorkflow.ResolvePersistedAuthorCacheAsync(normalizedName, region, normalizedAsin); if (persistedEntry != null) { - var persistedResponse = await MapPersistedAuthorLookupResponseAsync(persistedEntry, normalizedName); + var persistedResponse = await _lookupCacheWorkflow.MapPersistedAuthorLookupResponseAsync(persistedEntry, normalizedName); if (!refresh && - HasCompleteAuthorLookupData(persistedResponse.CachedPath, persistedResponse.Description, persistedResponse.SimilarAuthors)) + MetadataResponseMapper.HasCompleteAuthorLookupData(persistedResponse.CachedPath, persistedResponse.Description, persistedResponse.SimilarAuthors)) { CacheAuthorLookupResponse(cacheKey, persistedResponse); return Ok(persistedResponse); @@ -276,7 +279,7 @@ private async Task> LookupAuthorCore( } } - var cacheHint = await ProbeAuthorImageCacheAsync(normalizedName, region, normalizedAsin); + var cacheHint = await _imageCacheWorkflow.ProbeAuthorImageCacheAsync(normalizedName, region, normalizedAsin); var resolvedAsin = normalizedAsin ?? cacheHint.Asin; var cached = seededCachedPath ?? cacheHint.CachedPath; var needsDescription = refresh || string.IsNullOrWhiteSpace(seededDescription); @@ -430,7 +433,7 @@ private async Task> LookupAuthorCore( string.IsNullOrWhiteSpace(cached) && !string.IsNullOrWhiteSpace(resolvedAsin)) { - cached = await ResolveCachedImagePathAsync(resolvedAsin); + cached = await _imageCacheWorkflow.ResolveCachedImagePathAsync(resolvedAsin); } if ((refresh || string.IsNullOrWhiteSpace(cached)) && @@ -456,7 +459,7 @@ private async Task> LookupAuthorCore( _logger.LogWarning(ex, "Failed to cache author image for {Author}", name); } - var similarAuthors = MapSimilarAuthors( + var similarAuthors = MetadataResponseMapper.MapSimilarAuthors( audnexusAuthor?.Similar ?? audnexusSearchAuthor?.Similar, normalizedName); if (similarAuthors.Count == 0 && seededSimilarAuthors.Count > 0) @@ -474,7 +477,7 @@ private async Task> LookupAuthorCore( SimilarAuthors = similarAuthors }; - await PersistAuthorLookupAsync( + await _lookupCacheWorkflow.PersistAuthorLookupAsync( persistedEntry, normalizedName, region, @@ -556,7 +559,7 @@ private async Task> GetAuthorBooksCore( Name = string.IsNullOrWhiteSpace(catalog.Author.Name) ? normalizedName : catalog.Author.Name, Image = catalog.Author.Image }, - Books = catalog.Books.Select(MapAuthorCatalogBook).ToList(), + Books = catalog.Books.Select(MetadataResponseMapper.MapAuthorCatalogBook).ToList(), TotalBooks = catalog.TotalBooks }); } @@ -624,10 +627,10 @@ private async Task> LookupSeriesCore( return Ok(MapSeriesLookupResponse(cachedEntry, normalizedName)); } - var persistedEntry = await ResolvePersistedSeriesCacheAsync(normalizedName, region, normalizedAsin); + var persistedEntry = await _lookupCacheWorkflow.ResolvePersistedSeriesCacheAsync(normalizedName, region, normalizedAsin); if (!refresh && persistedEntry != null) { - var persistedResponse = await MapPersistedSeriesLookupResponseAsync(persistedEntry, normalizedName); + var persistedResponse = await _lookupCacheWorkflow.MapPersistedSeriesLookupResponseAsync(persistedEntry, normalizedName); CacheSeriesLookupResponse(cacheKey, persistedResponse); return Ok(persistedResponse); } @@ -670,7 +673,7 @@ private async Task> LookupSeriesCore( string? cachedPath = null; if (!string.IsNullOrWhiteSpace(resolvedSeries.Asin)) { - cachedPath = await ResolveCachedImagePathAsync(resolvedSeries.Asin); + cachedPath = await _imageCacheWorkflow.ResolveCachedImagePathAsync(resolvedSeries.Asin); if ((refresh || string.IsNullOrWhiteSpace(cachedPath)) && !string.IsNullOrWhiteSpace(imageUrl)) { @@ -702,7 +705,7 @@ private async Task> LookupSeriesCore( TotalBooks = catalog?.TotalBooks ?? persistedEntry?.CatalogBooks?.Count ?? 0 }; - await PersistSeriesLookupAsync( + await _lookupCacheWorkflow.PersistSeriesLookupAsync( persistedEntry, normalizedName, region, @@ -785,7 +788,7 @@ private async Task> GetSeriesBooksCore( Image = catalog.Series.Image, Description = catalog.Series.Description }, - Books = catalog.Books.Select(MapSeriesCatalogBook).ToList(), + Books = catalog.Books.Select(MetadataResponseMapper.MapSeriesCatalogBook).ToList(), TotalBooks = catalog.TotalBooks }); } @@ -796,267 +799,6 @@ private async Task> GetSeriesBooksCore( } } - private static string BuildAuthorCatalogBookKey(AudibleSearchResult book) - { - if (!string.IsNullOrWhiteSpace(book.Asin)) - { - return $"asin:{MetadataCacheKeys.NormalizeCatalogToken(book.Asin)}"; - } - - var title = MetadataCacheKeys.NormalizeCatalogToken(book.Title); - var authors = string.Join("|", (book.Authors ?? new List()) - .Select(a => MetadataCacheKeys.NormalizeCatalogToken(a.Name)) - .Where(a => !string.IsNullOrWhiteSpace(a))); - - return $"title:{title}:authors:{authors}"; - } - - private static AuthorCatalogBookItem MapAuthorCatalogBook(AudibleSearchResult book) - { - var primarySeries = book.Series?.FirstOrDefault(); - var runtime = book.LengthMinutes ?? book.RuntimeLengthMin ?? book.RuntimeMinutes; - - return new AuthorCatalogBookItem - { - Asin = book.Asin, - Title = book.Title ?? "Unknown Title", - Subtitle = book.Subtitle, - Authors = (book.Authors ?? new List()) - .Select(a => a.Name) - .Where(a => !string.IsNullOrWhiteSpace(a)) - .Cast() - .ToList(), - ImageUrl = book.ImageUrl, - Runtime = runtime, - Language = book.Language, - Publisher = book.Publisher, - Narrators = (book.Narrators ?? new List()) - .Select(n => n.Name) - .Where(n => !string.IsNullOrWhiteSpace(n)) - .Cast() - .ToList(), - Genres = (book.Genres ?? new List()) - .Select(g => g.Name) - .Where(g => !string.IsNullOrWhiteSpace(g)) - .Cast() - .ToList(), - Series = primarySeries?.Name, - SeriesNumber = primarySeries?.Position, - PublishedDate = book.ReleaseDate, - Isbn = book.Isbn, - Link = book.Link, - MetadataSource = "Audible" - }; - } - - private static SeriesCatalogBookItem MapSeriesCatalogBook(AudibleSearchResult book) - { - var primarySeries = book.Series?.FirstOrDefault(); - var runtime = book.LengthMinutes ?? book.RuntimeLengthMin ?? book.RuntimeMinutes; - - return new SeriesCatalogBookItem - { - Asin = book.Asin, - Title = book.Title ?? "Unknown Title", - Subtitle = book.Subtitle, - Authors = (book.Authors ?? new List()) - .Select(a => a.Name) - .Where(a => !string.IsNullOrWhiteSpace(a)) - .Cast() - .ToList(), - ImageUrl = book.ImageUrl, - Runtime = runtime, - Language = book.Language, - Publisher = book.Publisher, - Narrators = (book.Narrators ?? new List()) - .Select(n => n.Name) - .Where(n => !string.IsNullOrWhiteSpace(n)) - .Cast() - .ToList(), - Genres = (book.Genres ?? new List()) - .Select(g => g.Name) - .Where(g => !string.IsNullOrWhiteSpace(g)) - .Cast() - .ToList(), - Series = primarySeries?.Name, - SeriesNumber = primarySeries?.Position, - PublishedDate = book.ReleaseDate, - Isbn = book.Isbn, - Link = book.Link, - MetadataSource = "Audible" - }; - } - - private async Task<(string? Asin, string? CachedPath)> ProbeAuthorImageCacheAsync(string normalizedName, string region, string? hintedAsin) - { - var candidateAsins = new List(); - - if (!string.IsNullOrWhiteSpace(hintedAsin)) - { - candidateAsins.Add(hintedAsin.Trim()); - } - - try - { - var cachedAuthor = await _audiobookRepository.GetCachedAuthorByNameAsync(normalizedName, region); - if (!string.IsNullOrWhiteSpace(cachedAuthor?.AuthorAsin) - && !candidateAsins.Any(existing => string.Equals(existing, cachedAuthor.AuthorAsin, StringComparison.OrdinalIgnoreCase))) - { - candidateAsins.Add(cachedAuthor.AuthorAsin); - } - - var storedAuthorAsin = await _audiobookRepository.GetAuthorAsinByNameAsync(normalizedName); - if (!string.IsNullOrWhiteSpace(storedAuthorAsin) - && !candidateAsins.Any(existing => string.Equals(existing, storedAuthorAsin, StringComparison.OrdinalIgnoreCase))) - { - candidateAsins.Add(storedAuthorAsin); - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Failed to probe DB for cached author ASIN: {Author}", normalizedName); - } - - foreach (var candidateAsin in candidateAsins) - { - var cachedPath = await ResolveCachedImagePathAsync(candidateAsin); - if (!string.IsNullOrWhiteSpace(cachedPath)) - { - return (candidateAsin, cachedPath); - } - } - - return (candidateAsins.FirstOrDefault(), null); - } - - private async Task ResolveCachedImagePathAsync(string? asin) - { - if (string.IsNullOrWhiteSpace(asin)) return null; - - try - { - var diskPath = await _imageCacheService.GetCachedImagePathAsync(asin); - return string.IsNullOrWhiteSpace(diskPath) - ? null - : "/" + diskPath.TrimStart('/'); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Failed to resolve cached author image path for ASIN {Asin}", asin); - return null; - } - } - - private async Task ResolvePersistedAuthorCacheAsync(string normalizedName, string region, string? normalizedAsin) - { - try - { - if (!string.IsNullOrWhiteSpace(normalizedAsin)) - { - var byAsin = await _audiobookRepository.GetCachedAuthorByAsinAsync(normalizedAsin, region); - if (byAsin != null) - { - return byAsin; - } - } - - var byName = await _audiobookRepository.GetCachedAuthorByNameAsync(normalizedName, region); - if (byName != null) - { - return byName; - } - - var storedAuthorAsin = await _audiobookRepository.GetAuthorAsinByNameAsync(normalizedName); - if (!string.IsNullOrWhiteSpace(storedAuthorAsin)) - { - return await _audiobookRepository.GetCachedAuthorByAsinAsync(storedAuthorAsin, region); - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Failed to resolve persisted author cache for {Author}", normalizedName); - } - - return null; - } - - private async Task MapPersistedAuthorLookupResponseAsync(AuthorCacheEntry entry, string fallbackName) - { - var cachedPath = await ResolveCachedImagePathAsync(entry.AuthorAsin); - if (string.IsNullOrWhiteSpace(cachedPath) && - !string.IsNullOrWhiteSpace(entry.AuthorAsin) && - !string.IsNullOrWhiteSpace(entry.ImageUrl)) - { - try - { - cachedPath = await _imageCacheService.MoveToAuthorLibraryStorageAsync(entry.AuthorAsin, entry.ImageUrl); - if (!string.IsNullOrWhiteSpace(cachedPath)) - { - cachedPath = "/" + cachedPath.TrimStart('/'); - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Failed to backfill cached author image for ASIN {Asin}", entry.AuthorAsin); - } - } - - return new AuthorLookupResponse - { - Asin = entry.AuthorAsin, - Name = string.IsNullOrWhiteSpace(entry.AuthorName) ? fallbackName : entry.AuthorName, - Image = entry.ImageUrl, - CachedPath = cachedPath, - Description = entry.Description, - SimilarAuthors = (entry.SimilarAuthors ?? new List()) - .Where(author => !string.IsNullOrWhiteSpace(author.Name)) - .Select(author => new RelatedAuthorItem - { - Asin = author.Asin, - Name = author.Name - }) - .ToList() - }; - } - - private async Task PersistAuthorLookupAsync( - AuthorCacheEntry? existingEntry, - string normalizedName, - string region, - AuthorLookupResponse response) - { - if (string.IsNullOrWhiteSpace(response.Name)) - { - return; - } - - try - { - var entry = existingEntry ?? new AuthorCacheEntry(); - entry.AuthorName = response.Name; - entry.AuthorNameNormalized = MetadataCacheKeys.NormalizeAuthorCacheKey(normalizedName); - entry.AuthorAsin = response.Asin; - entry.Region = AudiobookIdentifierNormalizer.NormalizeRegion(region) ?? "us"; - entry.ImageUrl = response.Image; - entry.Description = response.Description; - entry.SimilarAuthors = response.SimilarAuthors - .Where(author => !string.IsNullOrWhiteSpace(author.Name)) - .Select(author => new CachedRelatedAuthor - { - Asin = author.Asin, - Name = author.Name - }) - .ToList(); - entry.LastFetchedAt = DateTime.UtcNow; - - await _audiobookRepository.UpsertCachedAuthorAsync(entry); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Failed to persist author cache for {Author}", normalizedName); - } - } - private void CacheAuthorLookupResponse(string cacheKey, AuthorLookupResponse response) { _cache.Set(cacheKey, new AuthorLookupCacheEntry @@ -1071,126 +813,6 @@ private void CacheAuthorLookupResponse(string cacheKey, AuthorLookupResponse res }, new MemoryCacheEntryOptions { SlidingExpiration = TimeSpan.FromHours(12) }); } - private async Task ResolvePersistedSeriesCacheAsync(string normalizedName, string region, string? normalizedAsin) - { - try - { - if (!string.IsNullOrWhiteSpace(normalizedAsin)) - { - var byAsin = await _audiobookRepository.GetCachedSeriesByAsinAsync(normalizedAsin, region); - if (byAsin != null) - { - return byAsin; - } - } - - return await _audiobookRepository.GetCachedSeriesByNameAsync(normalizedName, region); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Failed to resolve persisted series cache for {Series}", normalizedName); - } - - return null; - } - - private async Task MapPersistedSeriesLookupResponseAsync(SeriesCacheEntry entry, string fallbackName) - { - var cachedPath = await ResolveCachedImagePathAsync(entry.SeriesAsin); - if (string.IsNullOrWhiteSpace(cachedPath) && - !string.IsNullOrWhiteSpace(entry.SeriesAsin) && - !string.IsNullOrWhiteSpace(entry.ImageUrl)) - { - try - { - cachedPath = await _imageCacheService.MoveToSeriesLibraryStorageAsync(entry.SeriesAsin, entry.ImageUrl); - if (!string.IsNullOrWhiteSpace(cachedPath)) - { - cachedPath = "/" + cachedPath.TrimStart('/'); - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Failed to backfill cached series image for ASIN {Asin}", entry.SeriesAsin); - } - } - - return new SeriesLookupResponse - { - Asin = entry.SeriesAsin, - Name = string.IsNullOrWhiteSpace(entry.SeriesName) ? fallbackName : entry.SeriesName, - Image = entry.ImageUrl, - CachedPath = cachedPath, - Description = entry.Description, - TotalBooks = entry.CatalogBooks?.Count ?? 0 - }; - } - - private async Task PersistSeriesLookupAsync( - SeriesCacheEntry? existingEntry, - string normalizedName, - string region, - SeriesLookupResponse response, - IEnumerable? catalogBooks = null) - { - if (string.IsNullOrWhiteSpace(response.Name)) - { - return; - } - - try - { - var entry = existingEntry ?? new SeriesCacheEntry(); - entry.SeriesName = response.Name; - entry.SeriesNameNormalized = MetadataCacheKeys.NormalizeSeriesCacheKey(normalizedName); - entry.SeriesAsin = response.Asin; - entry.Region = AudiobookIdentifierNormalizer.NormalizeRegion(region) ?? "us"; - entry.ImageUrl = response.Image; - entry.Description = response.Description; - if (catalogBooks != null) - { - entry.CatalogBooks = catalogBooks.Select(book => new CachedSeriesCatalogBook - { - Asin = book.Asin, - Title = book.Title ?? "Unknown Title", - Subtitle = book.Subtitle, - Authors = (book.Authors ?? new List()) - .Select(author => author.Name) - .Where(author => !string.IsNullOrWhiteSpace(author)) - .Cast() - .ToList(), - ImageUrl = book.ImageUrl, - Runtime = book.LengthMinutes ?? book.RuntimeLengthMin ?? book.RuntimeMinutes, - Language = book.Language, - Publisher = book.Publisher, - Narrators = (book.Narrators ?? new List()) - .Select(narrator => narrator.Name) - .Where(narrator => !string.IsNullOrWhiteSpace(narrator)) - .Cast() - .ToList(), - Genres = (book.Genres ?? new List()) - .Select(genre => genre.Name) - .Where(genre => !string.IsNullOrWhiteSpace(genre)) - .Cast() - .ToList(), - Series = book.Series?.FirstOrDefault()?.Name, - SeriesNumber = book.Series?.FirstOrDefault()?.Position, - PublishedDate = book.ReleaseDate, - Isbn = book.Isbn, - Link = book.Link, - MetadataSource = "Audible" - }).ToList(); - } - entry.LastFetchedAt = DateTime.UtcNow; - - await _audiobookRepository.UpsertCachedSeriesAsync(entry); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Failed to persist series cache for {Series}", normalizedName); - } - } - private void CacheSeriesLookupResponse(string cacheKey, SeriesLookupResponse response) { _cache.Set(cacheKey, new SeriesLookupCacheEntry @@ -1230,35 +852,6 @@ private static SeriesLookupResponse MapSeriesLookupResponse(SeriesLookupCacheEnt }; } - private static List MapSimilarAuthors(IEnumerable? authors, string currentAuthorName) - { - if (authors == null) - { - return new List(); - } - - return authors - .Where(author => !string.IsNullOrWhiteSpace(author.Name)) - .Where(author => !string.Equals(author.Name, currentAuthorName, StringComparison.OrdinalIgnoreCase)) - .GroupBy(author => author.Name!, StringComparer.OrdinalIgnoreCase) - .Select(group => new RelatedAuthorItem - { - Asin = group.First().Asin, - Name = group.First().Name ?? string.Empty - }) - .ToList(); - } - - private static bool HasCompleteAuthorLookupData( - string? cachedPath, - string? description, - IEnumerable? similarAuthors) - { - return !string.IsNullOrWhiteSpace(cachedPath) && - !string.IsNullOrWhiteSpace(description) && - (similarAuthors?.Any(author => !string.IsNullOrWhiteSpace(author.Name)) ?? false); - } - private sealed class AuthorLookupCacheEntry { public string? Asin { get; set; } diff --git a/listenarr.api/Controllers/MetadataImageCacheWorkflow.cs b/listenarr.api/Controllers/MetadataImageCacheWorkflow.cs new file mode 100644 index 000000000..4ff5d7d53 --- /dev/null +++ b/listenarr.api/Controllers/MetadataImageCacheWorkflow.cs @@ -0,0 +1,100 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +using Listenarr.Application.Interfaces; +using Listenarr.Application.Interfaces.Repositories; + +namespace Listenarr.Api.Controllers +{ + internal sealed class MetadataImageCacheWorkflow + { + private readonly IAudiobookRepository _audiobookRepository; + private readonly IImageCacheService _imageCacheService; + private readonly ILogger _logger; + + public MetadataImageCacheWorkflow( + IAudiobookRepository audiobookRepository, + IImageCacheService imageCacheService, + ILogger logger) + { + _audiobookRepository = audiobookRepository; + _imageCacheService = imageCacheService; + _logger = logger; + } + + public async Task<(string? Asin, string? CachedPath)> ProbeAuthorImageCacheAsync(string normalizedName, string region, string? hintedAsin) + { + var candidateAsins = new List(); + + if (!string.IsNullOrWhiteSpace(hintedAsin)) + { + candidateAsins.Add(hintedAsin.Trim()); + } + + try + { + var cachedAuthor = await _audiobookRepository.GetCachedAuthorByNameAsync(normalizedName, region); + if (!string.IsNullOrWhiteSpace(cachedAuthor?.AuthorAsin) + && !candidateAsins.Any(existing => string.Equals(existing, cachedAuthor.AuthorAsin, StringComparison.OrdinalIgnoreCase))) + { + candidateAsins.Add(cachedAuthor.AuthorAsin); + } + + var storedAuthorAsin = await _audiobookRepository.GetAuthorAsinByNameAsync(normalizedName); + if (!string.IsNullOrWhiteSpace(storedAuthorAsin) + && !candidateAsins.Any(existing => string.Equals(existing, storedAuthorAsin, StringComparison.OrdinalIgnoreCase))) + { + candidateAsins.Add(storedAuthorAsin); + } + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning(ex, "Failed to probe DB for cached author ASIN: {Author}", normalizedName); + } + + foreach (var candidateAsin in candidateAsins) + { + var cachedPath = await ResolveCachedImagePathAsync(candidateAsin); + if (!string.IsNullOrWhiteSpace(cachedPath)) + { + return (candidateAsin, cachedPath); + } + } + + return (candidateAsins.FirstOrDefault(), null); + } + + public async Task ResolveCachedImagePathAsync(string? asin) + { + if (string.IsNullOrWhiteSpace(asin)) return null; + + try + { + var diskPath = await _imageCacheService.GetCachedImagePathAsync(asin); + return string.IsNullOrWhiteSpace(diskPath) + ? null + : "/" + diskPath.TrimStart('/'); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning(ex, "Failed to resolve cached author image path for ASIN {Asin}", asin); + return null; + } + } + } +} diff --git a/listenarr.api/Controllers/MetadataLookupCacheWorkflow.cs b/listenarr.api/Controllers/MetadataLookupCacheWorkflow.cs new file mode 100644 index 000000000..5e48bd07c --- /dev/null +++ b/listenarr.api/Controllers/MetadataLookupCacheWorkflow.cs @@ -0,0 +1,284 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +using Listenarr.Application.Interfaces; +using Listenarr.Application.Interfaces.Repositories; +using Listenarr.Application.Metadata; +using Listenarr.Domain.Models; + +namespace Listenarr.Api.Controllers +{ + internal sealed class MetadataLookupCacheWorkflow + { + private readonly IAudiobookRepository _audiobookRepository; + private readonly IImageCacheService _imageCacheService; + private readonly MetadataImageCacheWorkflow _imageCacheWorkflow; + private readonly ILogger _logger; + + public MetadataLookupCacheWorkflow( + IAudiobookRepository audiobookRepository, + IImageCacheService imageCacheService, + MetadataImageCacheWorkflow imageCacheWorkflow, + ILogger logger) + { + _audiobookRepository = audiobookRepository; + _imageCacheService = imageCacheService; + _imageCacheWorkflow = imageCacheWorkflow; + _logger = logger; + } + + public async Task ResolvePersistedAuthorCacheAsync(string normalizedName, string region, string? normalizedAsin) + { + try + { + if (!string.IsNullOrWhiteSpace(normalizedAsin)) + { + var byAsin = await _audiobookRepository.GetCachedAuthorByAsinAsync(normalizedAsin, region); + if (byAsin != null) + { + return byAsin; + } + } + + var byName = await _audiobookRepository.GetCachedAuthorByNameAsync(normalizedName, region); + if (byName != null) + { + return byName; + } + + var storedAuthorAsin = await _audiobookRepository.GetAuthorAsinByNameAsync(normalizedName); + if (!string.IsNullOrWhiteSpace(storedAuthorAsin)) + { + return await _audiobookRepository.GetCachedAuthorByAsinAsync(storedAuthorAsin, region); + } + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning(ex, "Failed to resolve persisted author cache for {Author}", normalizedName); + } + + return null; + } + + public async Task MapPersistedAuthorLookupResponseAsync( + AuthorCacheEntry entry, + string fallbackName) + { + var cachedPath = await _imageCacheWorkflow.ResolveCachedImagePathAsync(entry.AuthorAsin); + if (string.IsNullOrWhiteSpace(cachedPath) && + !string.IsNullOrWhiteSpace(entry.AuthorAsin) && + !string.IsNullOrWhiteSpace(entry.ImageUrl)) + { + try + { + cachedPath = await _imageCacheService.MoveToAuthorLibraryStorageAsync(entry.AuthorAsin, entry.ImageUrl); + if (!string.IsNullOrWhiteSpace(cachedPath)) + { + cachedPath = "/" + cachedPath.TrimStart('/'); + } + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning(ex, "Failed to backfill cached author image for ASIN {Asin}", entry.AuthorAsin); + } + } + + return new MetadataController.AuthorLookupResponse + { + Asin = entry.AuthorAsin, + Name = string.IsNullOrWhiteSpace(entry.AuthorName) ? fallbackName : entry.AuthorName, + Image = entry.ImageUrl, + CachedPath = cachedPath, + Description = entry.Description, + SimilarAuthors = (entry.SimilarAuthors ?? new List()) + .Where(author => !string.IsNullOrWhiteSpace(author.Name)) + .Select(author => new MetadataController.RelatedAuthorItem + { + Asin = author.Asin, + Name = author.Name + }) + .ToList() + }; + } + + public async Task PersistAuthorLookupAsync( + AuthorCacheEntry? existingEntry, + string normalizedName, + string region, + MetadataController.AuthorLookupResponse response) + { + if (string.IsNullOrWhiteSpace(response.Name)) + { + return; + } + + try + { + var entry = existingEntry ?? new AuthorCacheEntry(); + entry.AuthorName = response.Name; + entry.AuthorNameNormalized = MetadataCacheKeys.NormalizeAuthorCacheKey(normalizedName); + entry.AuthorAsin = response.Asin; + entry.Region = AudiobookIdentifierNormalizer.NormalizeRegion(region) ?? "us"; + entry.ImageUrl = response.Image; + entry.Description = response.Description; + entry.SimilarAuthors = response.SimilarAuthors + .Where(author => !string.IsNullOrWhiteSpace(author.Name)) + .Select(author => new CachedRelatedAuthor + { + Asin = author.Asin, + Name = author.Name + }) + .ToList(); + entry.LastFetchedAt = DateTime.UtcNow; + + await _audiobookRepository.UpsertCachedAuthorAsync(entry); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning(ex, "Failed to persist author cache for {Author}", normalizedName); + } + } + + public async Task ResolvePersistedSeriesCacheAsync(string normalizedName, string region, string? normalizedAsin) + { + try + { + if (!string.IsNullOrWhiteSpace(normalizedAsin)) + { + var byAsin = await _audiobookRepository.GetCachedSeriesByAsinAsync(normalizedAsin, region); + if (byAsin != null) + { + return byAsin; + } + } + + return await _audiobookRepository.GetCachedSeriesByNameAsync(normalizedName, region); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning(ex, "Failed to resolve persisted series cache for {Series}", normalizedName); + } + + return null; + } + + public async Task MapPersistedSeriesLookupResponseAsync( + SeriesCacheEntry entry, + string fallbackName) + { + var cachedPath = await _imageCacheWorkflow.ResolveCachedImagePathAsync(entry.SeriesAsin); + if (string.IsNullOrWhiteSpace(cachedPath) && + !string.IsNullOrWhiteSpace(entry.SeriesAsin) && + !string.IsNullOrWhiteSpace(entry.ImageUrl)) + { + try + { + cachedPath = await _imageCacheService.MoveToSeriesLibraryStorageAsync(entry.SeriesAsin, entry.ImageUrl); + if (!string.IsNullOrWhiteSpace(cachedPath)) + { + cachedPath = "/" + cachedPath.TrimStart('/'); + } + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning(ex, "Failed to backfill cached series image for ASIN {Asin}", entry.SeriesAsin); + } + } + + return new MetadataController.SeriesLookupResponse + { + Asin = entry.SeriesAsin, + Name = string.IsNullOrWhiteSpace(entry.SeriesName) ? fallbackName : entry.SeriesName, + Image = entry.ImageUrl, + CachedPath = cachedPath, + Description = entry.Description, + TotalBooks = entry.CatalogBooks?.Count ?? 0 + }; + } + + public async Task PersistSeriesLookupAsync( + SeriesCacheEntry? existingEntry, + string normalizedName, + string region, + MetadataController.SeriesLookupResponse response, + IEnumerable? catalogBooks = null) + { + if (string.IsNullOrWhiteSpace(response.Name)) + { + return; + } + + try + { + var entry = existingEntry ?? new SeriesCacheEntry(); + entry.SeriesName = response.Name; + entry.SeriesNameNormalized = MetadataCacheKeys.NormalizeSeriesCacheKey(normalizedName); + entry.SeriesAsin = response.Asin; + entry.Region = AudiobookIdentifierNormalizer.NormalizeRegion(region) ?? "us"; + entry.ImageUrl = response.Image; + entry.Description = response.Description; + if (catalogBooks != null) + { + entry.CatalogBooks = catalogBooks.Select(MapCachedSeriesCatalogBook).ToList(); + } + + entry.LastFetchedAt = DateTime.UtcNow; + + await _audiobookRepository.UpsertCachedSeriesAsync(entry); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning(ex, "Failed to persist series cache for {Series}", normalizedName); + } + } + + private static CachedSeriesCatalogBook MapCachedSeriesCatalogBook(AudibleSearchResult book) + { + var primarySeries = book.Series?.FirstOrDefault(); + + return new CachedSeriesCatalogBook + { + Asin = book.Asin, + Title = book.Title ?? "Unknown Title", + Subtitle = book.Subtitle, + Authors = MapNames(book.Authors, author => author.Name), + ImageUrl = book.ImageUrl, + Runtime = book.LengthMinutes ?? book.RuntimeLengthMin ?? book.RuntimeMinutes, + Language = book.Language, + Publisher = book.Publisher, + Narrators = MapNames(book.Narrators, narrator => narrator.Name), + Genres = MapNames(book.Genres, genre => genre.Name), + Series = primarySeries?.Name, + SeriesNumber = primarySeries?.Position, + PublishedDate = book.ReleaseDate, + Isbn = book.Isbn, + Link = book.Link, + MetadataSource = "Audible" + }; + } + + private static List MapNames(IEnumerable? values, Func selector) + { + return (values ?? Enumerable.Empty()) + .Select(selector) + .Where(name => !string.IsNullOrWhiteSpace(name)) + .Cast() + .ToList(); + } + } +} diff --git a/listenarr.api/Controllers/MetadataResponseMapper.cs b/listenarr.api/Controllers/MetadataResponseMapper.cs new file mode 100644 index 000000000..5d9aaf2b7 --- /dev/null +++ b/listenarr.api/Controllers/MetadataResponseMapper.cs @@ -0,0 +1,117 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +using Listenarr.Application.Metadata; + +namespace Listenarr.Api.Controllers +{ + internal static class MetadataResponseMapper + { + public static MetadataController.AuthorCatalogBookItem MapAuthorCatalogBook(AudibleSearchResult book) + { + var primarySeries = book.Series?.FirstOrDefault(); + var runtime = book.LengthMinutes ?? book.RuntimeLengthMin ?? book.RuntimeMinutes; + + return new MetadataController.AuthorCatalogBookItem + { + Asin = book.Asin, + Title = book.Title ?? "Unknown Title", + Subtitle = book.Subtitle, + Authors = MapNames(book.Authors, author => author.Name), + ImageUrl = book.ImageUrl, + Runtime = runtime, + Language = book.Language, + Publisher = book.Publisher, + Narrators = MapNames(book.Narrators, narrator => narrator.Name), + Genres = MapNames(book.Genres, genre => genre.Name), + Series = primarySeries?.Name, + SeriesNumber = primarySeries?.Position, + PublishedDate = book.ReleaseDate, + Isbn = book.Isbn, + Link = book.Link, + MetadataSource = "Audible" + }; + } + + public static MetadataController.SeriesCatalogBookItem MapSeriesCatalogBook(AudibleSearchResult book) + { + var primarySeries = book.Series?.FirstOrDefault(); + var runtime = book.LengthMinutes ?? book.RuntimeLengthMin ?? book.RuntimeMinutes; + + return new MetadataController.SeriesCatalogBookItem + { + Asin = book.Asin, + Title = book.Title ?? "Unknown Title", + Subtitle = book.Subtitle, + Authors = MapNames(book.Authors, author => author.Name), + ImageUrl = book.ImageUrl, + Runtime = runtime, + Language = book.Language, + Publisher = book.Publisher, + Narrators = MapNames(book.Narrators, narrator => narrator.Name), + Genres = MapNames(book.Genres, genre => genre.Name), + Series = primarySeries?.Name, + SeriesNumber = primarySeries?.Position, + PublishedDate = book.ReleaseDate, + Isbn = book.Isbn, + Link = book.Link, + MetadataSource = "Audible" + }; + } + + public static List MapSimilarAuthors( + IEnumerable? authors, + string currentAuthorName) + { + if (authors == null) + { + return new List(); + } + + return authors + .Where(author => !string.IsNullOrWhiteSpace(author.Name)) + .Where(author => !string.Equals(author.Name, currentAuthorName, StringComparison.OrdinalIgnoreCase)) + .GroupBy(author => author.Name!, StringComparer.OrdinalIgnoreCase) + .Select(group => new MetadataController.RelatedAuthorItem + { + Asin = group.First().Asin, + Name = group.First().Name ?? string.Empty + }) + .ToList(); + } + + public static bool HasCompleteAuthorLookupData( + string? cachedPath, + string? description, + IEnumerable? similarAuthors) + { + return !string.IsNullOrWhiteSpace(cachedPath) && + !string.IsNullOrWhiteSpace(description) && + (similarAuthors?.Any(author => !string.IsNullOrWhiteSpace(author.Name)) ?? false); + } + + private static List MapNames(IEnumerable? values, Func selector) + { + return (values ?? Enumerable.Empty()) + .Select(selector) + .Where(name => !string.IsNullOrWhiteSpace(name)) + .Cast() + .ToList(); + } + } +} diff --git a/listenarr.api/Program.cs b/listenarr.api/Program.cs index 4e2123489..e5572dca6 100644 --- a/listenarr.api/Program.cs +++ b/listenarr.api/Program.cs @@ -317,6 +317,9 @@ ex is IOException builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); // Add search result filters builder.Services.AddScoped(); @@ -347,6 +350,13 @@ ex is IOException builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); // Register named HttpClients for each adapter type so adapter implementations can request the appropriately-configured client. builder.Services.AddListenarrHttpClients(builder.Configuration); diff --git a/listenarr.application/Downloads/DownloadCachedTorrentStore.cs b/listenarr.application/Downloads/DownloadCachedTorrentStore.cs new file mode 100644 index 000000000..e4fac642c --- /dev/null +++ b/listenarr.application/Downloads/DownloadCachedTorrentStore.cs @@ -0,0 +1,115 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +using Listenarr.Application.Common; +using Listenarr.Application.Security; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Application.Downloads +{ + public sealed class DownloadCachedTorrentStore + { + private static readonly TimeSpan CacheSlidingExpiration = TimeSpan.FromMinutes(30); + + private readonly IMemoryCache _cache; + private readonly ILogger _logger; + + public DownloadCachedTorrentStore(IMemoryCache cache, ILogger logger) + { + _cache = cache; + _logger = logger; + } + + public Task<(byte[]? Bytes, string? FileName)> GetCachedTorrentAsync(string downloadId) + { + var cacheKey = BuildCacheKey(downloadId); + var bytes = _cache.Get(cacheKey + ":bytes"); + var name = _cache.Get(cacheKey + ":name"); + return Task.FromResult((bytes, name)); + } + + public Task?> GetCachedAnnouncesAsync(string downloadId) + { + try + { + if (string.IsNullOrEmpty(downloadId)) return Task.FromResult?>(null); + + var cacheKey = BuildCacheKey(downloadId); + var announces = _cache.Get>(cacheKey + ":announces"); + if (announces != null && announces.Count > 0) + { + return Task.FromResult?>(announces); + } + + var bytes = _cache.Get(cacheKey + ":bytes"); + if (bytes != null) + { + var extracted = MyAnonamouseHelper.ExtractAnnounceUrls(bytes); + if (extracted != null && extracted.Count > 0) + { + CacheAnnounces(downloadId, extracted); + return Task.FromResult?>(extracted); + } + } + + return Task.FromResult?>(null); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogDebug(ex, "Failed to retrieve cached announces for download {DownloadId} (non-fatal)", downloadId); + return Task.FromResult?>(null); + } + } + + public void CacheTorrent(string downloadId, byte[] torrentBytes, string fileName) + { + var cacheKey = BuildCacheKey(downloadId); + var options = CreateOptions(); + _cache.Set(cacheKey + ":bytes", torrentBytes, options); + _cache.Set(cacheKey + ":name", fileName, CreateOptions()); + } + + public void CacheAnnounces(string downloadId, List announces) + { + var cacheKey = BuildCacheKey(downloadId); + _cache.Set(cacheKey + ":announces", announces, CreateOptions()); + } + + public void LogCachedAnnounces(string title, IReadOnlyCollection? announces) + { + var count = announces?.Count ?? 0; + var unique = count > 0 ? string.Join(", ", announces?.Take(10) ?? Enumerable.Empty()) : "(none)"; + _logger.LogInformation( + "Cached MyAnonamouse torrent announces for '{Title}' - count={Count}: {Announces}", + title, + count, + LogRedaction.RedactText(unique, LogRedaction.GetSensitiveValuesFromEnvironment())); + } + + private static MemoryCacheEntryOptions CreateOptions() + { + return new MemoryCacheEntryOptions { SlidingExpiration = CacheSlidingExpiration }; + } + + private static string BuildCacheKey(string downloadId) + { + return $"mam:cachedtorrent:{downloadId}"; + } + } +} diff --git a/listenarr.application/Downloads/DownloadService.cs b/listenarr.application/Downloads/DownloadService.cs index 4fe9291d6..bdc5360f3 100644 --- a/listenarr.application/Downloads/DownloadService.cs +++ b/listenarr.application/Downloads/DownloadService.cs @@ -16,10 +16,8 @@ * along with this program. If not, see . */ -using Microsoft.Extensions.Caching.Memory; using System.Text.RegularExpressions; using Listenarr.Domain.Common; -using Listenarr.Application.Common; using Listenarr.Application.Interfaces; using Listenarr.Domain.Models; using Listenarr.Application.Interfaces.Repositories; @@ -38,13 +36,13 @@ public class DownloadService( IQualityProfileService qualityProfileService, ISearchService searchService, IDownloadClientGateway clientGateway, - IMemoryCache cache, IDownloadQueueService downloadQueueService, INotificationService notificationService, IHubBroadcaster hubBroadcaster, IDownloadHistoryService downloadHistoryService, DownloadTypeResolver downloadTypeResolver, - DownloadClientSelector downloadClientSelector) : IDownloadService + DownloadClientSelector downloadClientSelector, + DownloadCachedTorrentStore cachedTorrentStore) : IDownloadService { // Cache expiration constants private const int QueueCacheExpirationSeconds = 10; @@ -67,10 +65,7 @@ public async Task StartDownloadAsync(SearchResult searchResult, string d /// public Task<(byte[]? Bytes, string? FileName)> GetCachedTorrentAsync(string downloadId) { - var cacheKey = $"mam:cachedtorrent:{downloadId}"; - var bytes = cache.Get(cacheKey + ":bytes"); - var name = cache.Get(cacheKey + ":name"); - return Task.FromResult((bytes, name)); + return cachedTorrentStore.GetCachedTorrentAsync(downloadId); } /// @@ -78,36 +73,7 @@ public async Task StartDownloadAsync(SearchResult searchResult, string d /// public Task?> GetCachedAnnouncesAsync(string downloadId) { - try - { - if (string.IsNullOrEmpty(downloadId)) return Task.FromResult?>(null); - var cacheKey = $"mam:cachedtorrent:{downloadId}:announces"; - var announces = cache.Get>(cacheKey); - if (announces != null && announces.Count > 0) - { - return Task.FromResult?>(announces); - } - - // Fallback: if announces not cached, try to extract from cached bytes - var bytes = cache.Get($"mam:cachedtorrent:{downloadId}:bytes"); - if (bytes != null) - { - var extracted = MyAnonamouseHelper.ExtractAnnounceUrls(bytes); - if (extracted != null && extracted.Count > 0) - { - // cache for future retrievals - cache.Set($"mam:cachedtorrent:{downloadId}:announces", extracted, new MemoryCacheEntryOptions { SlidingExpiration = TimeSpan.FromMinutes(30) }); - return Task.FromResult?>(extracted); - } - } - - return Task.FromResult?>(null); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - logger.LogDebug(ex, "Failed to retrieve cached announces for download {DownloadId} (non-fatal)", downloadId); - return Task.FromResult?>(null); - } + return cachedTorrentStore.GetCachedAnnouncesAsync(downloadId); } public async Task<(bool Success, string Message, DownloadClientConfiguration? Client)> TestDownloadClientAsync(DownloadClientConfiguration client) @@ -550,432 +516,12 @@ await downloadHistoryService.RecordGrabbedAsync( private async Task TryPrepareMyAnonamouseTorrentAsync(SearchResult searchResult, string? downloadId = null) { - ArgumentNullException.ThrowIfNull(searchResult); - - logger.LogInformation("TryPrepareMyAnonamouseTorrentAsync called for '{Title}', IndexerId: {IndexerId}, TorrentUrl: '{TorrentUrl}'", - searchResult.Title, searchResult.IndexerId, searchResult.TorrentUrl); - - // Security: Validate all preconditions before performing sensitive operations - // This method downloads content using authenticated HTTP clients, so we must - // ensure the request is legitimate and comes from a trusted, configured source. - - if (searchResult.IndexerId == null) - { - logger.LogWarning("TryPrepareMyAnonamouseTorrentAsync: No IndexerId for '{Title}' - skipping", searchResult.Title); - // Reject: No database-backed indexer ID provided - return; - } - - if (string.IsNullOrEmpty(searchResult.TorrentUrl)) - { - logger.LogDebug("Skipping MyAnonamouse cache: no TorrentUrl for '{Title}'", LogRedaction.SanitizeText(searchResult.Title)); - return; - } - - if (searchResult.TorrentFileContent != null && searchResult.TorrentFileContent.Length > 0) - { - logger.LogDebug("MyAnonamouse torrent already cached for '{Title}'", searchResult.Title); - return; - } - - try - { - // Security: Fetch indexer from database using the validated ID - // Only trusted, administrator-configured indexers can trigger authenticated requests - var indexer = await indexerRepository.GetByIdAsync(searchResult.IndexerId.Value); - - // Security: Indexer must exist in database - reject if not found - if (indexer == null) - { - logger.LogWarning("Unable to cache MyAnonamouse torrent for '{Title}': indexer configuration not found", searchResult.Title); - return; - } - - // Security: Validate against database-stored indexer configuration, not user-provided search result - if (!string.Equals(indexer.Implementation, "MyAnonamouse", StringComparison.OrdinalIgnoreCase)) - { - logger.LogDebug("Skipping MyAnonamouse cache: indexer {IndexerName} is not MyAnonamouse (is {Implementation})", - indexer.Name, indexer.Implementation); - return; - } - - // Parse and validate URLs - if (!Uri.TryCreate(searchResult.TorrentUrl, UriKind.Absolute, out var torrentUri) || - !Uri.TryCreate(indexer.Url, UriKind.Absolute, out var indexerUri)) - { - logger.LogWarning("Unable to cache MyAnonamouse torrent for '{Title}': invalid URL(s). Torrent={Url}, Indexer={IndexerUrl}", searchResult.Title, LogRedaction.SanitizeUrl(searchResult.TorrentUrl), indexer.Url); - return; - } - - if (!string.Equals(torrentUri.Host, indexerUri.Host, StringComparison.OrdinalIgnoreCase)) - { - logger.LogDebug("MyAnonamouse torrent host {TorrentHost} differs from indexer host {IndexerHost}. Proceeding with explicit cookie header.", torrentUri.Host, indexerUri.Host); - } - - var mamId = MyAnonamouseHelper.TryGetMamId(indexer.AdditionalSettings); - if (string.IsNullOrEmpty(mamId)) - { - logger.LogWarning("Unable to cache MyAnonamouse torrent for '{Title}': mam_id missing from indexer {IndexerName}", searchResult.Title, indexer.Name); - return; - } - - // Use factory client for the initial attempt (allows test injection). - // If auto-redirect drops the Cookie header, a fallback retry with - // CreateAuthenticatedHttpClient (AllowAutoRedirect=false) handles it below. - var httpClientToUse = httpClientFactory.CreateClient(); // FIXME: Should use a named client - - logger.LogDebug("Downloading MyAnonamouse torrent for '{Title}' from {Url}", searchResult.Title, LogRedaction.SanitizeUrl(searchResult.TorrentUrl)); - - // Follow redirects manually so we can re-apply cookies and Host header on each hop (mimic Prowlarr) - var currentUri = torrentUri; - HttpResponseMessage? response = null; - for (int redirectAttempt = 0; redirectAttempt < 6; redirectAttempt++) - { - using var req = new HttpRequestMessage(HttpMethod.Get, currentUri); - // Set common headers for MAM to mimic a browser request (some endpoints require this) - req.Headers.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"); - req.Headers.Referrer = new Uri("https://www.myanonamouse.net/"); - req.Headers.Accept.ParseAdd("application/x-bittorrent, application/octet-stream, */*; q=0.01"); - - // Ensure the authenticated session is sent even if the download host differs by adding Cookie header as well - if (!string.IsNullOrEmpty(mamId)) - req.Headers.Add("Cookie", $"mam_id={mamId}"); - - // Always set Host header to the indexer host so tracker sees the expected host - var hostHeader = indexerUri.IsDefaultPort ? indexerUri.Host : $"{indexerUri.Host}:{indexerUri.Port}"; - req.Headers.Host = hostHeader; - - logger.LogDebug("Downloading MyAnonamouse torrent for '{Title}' from {Url} (attempt {Attempt})", searchResult.Title, LogRedaction.SanitizeUrl(currentUri.ToString()), redirectAttempt + 1); - - response = await httpClientToUse.SendAsync(req); - - // Persist mam_id from intermediate responses (Set-Cookie) - try - { - var newMam = MyAnonamouseHelper.TryExtractMamIdFromResponse(response); - if (!string.IsNullOrEmpty(newMam) && !string.Equals(newMam, mamId, StringComparison.Ordinal)) - { - logger.LogInformation("MyAnonamouse: received updated mam_id from download redirect response for indexer {Name}", indexer.Name); - indexer.AdditionalSettings = MyAnonamouseHelper.UpdateMamIdInAdditionalSettings(indexer.AdditionalSettings, newMam); - await indexerRepository.UpdateAsync(indexer); - - // Keep local copy in sync - indexer.AdditionalSettings = MyAnonamouseHelper.UpdateMamIdInAdditionalSettings(indexer.AdditionalSettings, newMam); - mamId = newMam; - } - } - catch (Exception exMam) when (exMam is not OperationCanceledException && exMam is not OutOfMemoryException && exMam is not StackOverflowException) - { - logger.LogDebug(exMam, "Failed to persist updated mam_id from MyAnonamouse redirect response"); - } - - // Handle redirects manually - if (response.StatusCode == System.Net.HttpStatusCode.MovedPermanently || - response.StatusCode == System.Net.HttpStatusCode.Found || - response.StatusCode == System.Net.HttpStatusCode.SeeOther || - response.StatusCode == System.Net.HttpStatusCode.TemporaryRedirect || - response.StatusCode == System.Net.HttpStatusCode.PermanentRedirect) - { - if (response.Headers.Location == null) - { - logger.LogWarning("MyAnonamouse torrent download redirect without Location header for '{Title}'", searchResult.Title); - response.Dispose(); - return; - } - - var next = response.Headers.Location.IsAbsoluteUri ? response.Headers.Location : new Uri(currentUri, response.Headers.Location); - logger.LogDebug("Following MyAnonamouse redirect to {Next}", LogRedaction.SanitizeUrl(next.ToString())); - response.Dispose(); - currentUri = next; - continue; - } - - // Not a redirect - break to process the response - break; - } - - if (response == null) - { - logger.LogWarning("Failed to download MyAnonamouse torrent for '{Title}': no response", searchResult.Title); - return; - } - - if (!response.IsSuccessStatusCode) - { - logger.LogWarning("MyAnonamouse torrent download failed for '{Title}' with status {Status}", searchResult.Title, response.StatusCode); - response.Dispose(); - return; - } - - var torrentBytes = await response.Content.ReadAsByteArrayAsync(); - if (torrentBytes == null || torrentBytes.Length == 0) - { - logger.LogWarning("MyAnonamouse torrent download for '{Title}' returned empty payload", searchResult.Title); - response.Dispose(); - return; - } - - // Quick sanity check: ensure the payload looks like a torrent (bencoded dictionary / contains 'announce'/'info') - var looksLikeTorrent = (torrentBytes.Length > 0 && torrentBytes[0] == (byte)'d') || - System.Text.Encoding.ASCII.GetString(torrentBytes.Take(Math.Min(200, torrentBytes.Length)).ToArray()).IndexOf("announce", StringComparison.OrdinalIgnoreCase) >= 0; - - if (!looksLikeTorrent) - { - // The factory HttpClient may have auto-followed redirects, silently - // dropping the Cookie header (AllowAutoRedirect=true is the default). - // Retry with a dedicated client that disables auto-redirect so the - // manual redirect loop can re-apply cookies on each hop. - logger.LogDebug("Factory client returned non-torrent payload for '{Title}', retrying with authenticated MAM client", searchResult.Title); - response.Dispose(); - response = null; - - try - { - using var authClient = MyAnonamouseHelper.CreateAuthenticatedHttpClient(mamId, indexer.Url); - var retryUri = torrentUri; - for (int retryHop = 0; retryHop < 6; retryHop++) - { - using var retryReq = new HttpRequestMessage(HttpMethod.Get, retryUri); - retryReq.Headers.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"); - retryReq.Headers.Referrer = new Uri("https://www.myanonamouse.net/"); - retryReq.Headers.Accept.ParseAdd("application/x-bittorrent, application/octet-stream, */*; q=0.01"); - if (!string.IsNullOrEmpty(mamId)) - retryReq.Headers.Add("Cookie", $"mam_id={mamId}"); - var retryHost = indexerUri.IsDefaultPort ? indexerUri.Host : $"{indexerUri.Host}:{indexerUri.Port}"; - retryReq.Headers.Host = retryHost; - - response = await authClient.SendAsync(retryReq); - - if ((int)response.StatusCode >= 300 && (int)response.StatusCode < 400 && response.Headers.Location != null) - { - retryUri = response.Headers.Location.IsAbsoluteUri - ? response.Headers.Location - : new Uri(retryUri, response.Headers.Location); - response.Dispose(); - response = null; - continue; - } - break; - } - - if (response != null && response.IsSuccessStatusCode) - { - torrentBytes = await response.Content.ReadAsByteArrayAsync(); - looksLikeTorrent = torrentBytes != null && torrentBytes.Length > 0 && - ((torrentBytes[0] == (byte)'d') || - System.Text.Encoding.ASCII.GetString(torrentBytes.Take(Math.Min(200, torrentBytes.Length)).ToArray()) - .IndexOf("announce", StringComparison.OrdinalIgnoreCase) >= 0); - if (looksLikeTorrent) - logger.LogInformation("Authenticated MAM client successfully downloaded torrent for '{Title}' ({Bytes} bytes)", searchResult.Title, torrentBytes!.Length); - } - } - catch (Exception retryEx) when (retryEx is not OperationCanceledException && retryEx is not OutOfMemoryException && retryEx is not StackOverflowException) - { - logger.LogDebug(retryEx, "Retry with authenticated MAM client also failed (non-fatal)"); - } - } - - if (!looksLikeTorrent) - { - var snippet = System.Text.Encoding.UTF8.GetString((torrentBytes ?? Array.Empty()).Take(Math.Min(512, torrentBytes?.Length ?? 0)).ToArray()); - if (System.Text.RegularExpressions.Regex.IsMatch(snippet, "Unrecognized host|PassKey|Pass Key|Unrecognized", System.Text.RegularExpressions.RegexOptions.IgnoreCase)) - { - logger.LogWarning("MyAnonamouse torrent download for '{Title}' returned an authorization error page from tracker: {Snippet}", searchResult.Title, LogRedaction.RedactText(snippet, LogRedaction.GetSensitiveValuesFromEnvironment())); - } - else - { - logger.LogWarning("MyAnonamouse torrent download for '{Title}' returned unexpected non-torrent payload (first 200 chars): {Snippet}", searchResult.Title, LogRedaction.RedactText(snippet, LogRedaction.GetSensitiveValuesFromEnvironment())); - } - - response?.Dispose(); - return; - } - - // torrentBytes is guaranteed non-null here: looksLikeTorrent check above returns early otherwise - if (torrentBytes == null) return; - - // Additional debug info to help diagnose cases where content looks like a torrent but tracker still rejects it - var contentType = response?.Content.Headers.ContentType?.ToString() ?? "(none)"; - var firstBytesHex = BitConverter.ToString(torrentBytes.Take(Math.Min(16, torrentBytes.Length)).ToArray()).Replace("-", " "); - var containsAnnounce = System.Text.Encoding.ASCII.GetString(torrentBytes.Take(Math.Min(512, torrentBytes.Length)).ToArray()).IndexOf("announce", StringComparison.OrdinalIgnoreCase) >= 0; - logger.LogDebug("MyAnonamouse torrent payload debug: ContentType={ContentType}, FirstBytes={FirstBytesHex}, ContainsAnnounce={ContainsAnnounce}", contentType, firstBytesHex, containsAnnounce); - - // If the torrent references the numeric IP host, rewrite announce/tracker strings to the configured indexer host - try - { - if (!string.IsNullOrEmpty(indexerUri.Host)) - { - var ascii = System.Text.Encoding.ASCII.GetString(torrentBytes); - - // 1) If torrent references the original torrent host (often IP), replace it - if (!string.IsNullOrEmpty(torrentUri.Host) && ascii.IndexOf(torrentUri.Host, StringComparison.OrdinalIgnoreCase) >= 0 && - !string.Equals(torrentUri.Host, indexerUri.Host, StringComparison.OrdinalIgnoreCase)) - { - var replaced = MyAnonamouseHelper.ReplaceHostInTorrent(torrentBytes, torrentUri.Host, indexerUri.Host); - if (replaced != null && replaced.Length > 0) - { - torrentBytes = replaced; - logger.LogInformation("Rewrote torrent tracker host from {OldHost} to {NewHost} for '{Title}'", torrentUri.Host, indexerUri.Host, searchResult.Title); - ascii = System.Text.Encoding.ASCII.GetString(torrentBytes); - } - } - - // 2) Heuristic: replace any bare IPv4 addresses found inside torrent with the indexer host - try - { - var ipMatches = System.Text.RegularExpressions.Regex.Matches(ascii, @"\b\d{1,3}(?:\.\d{1,3}){3}\b"); - var distinctIps = ipMatches.Cast().Select(m => m.Value).Distinct().ToList(); - foreach (var ip in distinctIps.Where(ip => - !ip.StartsWith("127.") - && !ip.StartsWith("10.") - && !ip.StartsWith("192.168.") - && !ip.StartsWith("172.") - && !string.Equals(ip, indexerUri.Host, StringComparison.OrdinalIgnoreCase))) - { - var replaced2 = MyAnonamouseHelper.ReplaceHostInTorrent(torrentBytes, ip, indexerUri.Host); - if (replaced2 != null && replaced2.Length > 0) - { - torrentBytes = replaced2; - logger.LogInformation("Rewrote torrent IP host {Ip} to indexer host {Host} for '{Title}'", ip, indexerUri.Host, searchResult.Title); - } - } - } - catch (Exception rex2) when (rex2 is not OperationCanceledException && rex2 is not OutOfMemoryException && rex2 is not StackOverflowException) - { - logger.LogDebug(rex2, "Failed to rewrite numeric IPs inside torrent (non-fatal)"); - } - - // 3) Log announce URLs for diagnostics — do NOT rewrite legitimate tracker subdomains - // (e.g., t.myanonamouse.net is the actual tracker and must not be changed to www.myanonamouse.net) - try - { - var announces = MyAnonamouseHelper.ExtractAnnounceUrls(torrentBytes); - if (announces != null && announces.Count > 0) - { - logger.LogDebug("Torrent announce URLs for '{Title}': {Announces}", searchResult.Title, string.Join(", ", announces.Distinct())); - } - } - catch (Exception rex3) when (rex3 is not OperationCanceledException && rex3 is not OutOfMemoryException && rex3 is not StackOverflowException) - { - logger.LogDebug(rex3, "Failed to extract announce URLs from torrent (non-fatal)"); - } - } - } - catch (Exception rex) when (rex is not OperationCanceledException && rex is not OutOfMemoryException && rex is not StackOverflowException) - { - logger.LogDebug(rex, "Failed to rewrite torrent tracker hosts (non-fatal)"); - } - - // If we have a mam_id, attempt to append it to any announce URLs inside the torrent so trackers that rely on passkey in query will accept it. - try - { - if (!string.IsNullOrEmpty(mamId)) - { - var normalizedMamId = MyAnonamouseHelper.NormalizeMamId(mamId); - logger.LogInformation("MyAnonamouse: normalizing mam_id from '{Raw}' to '{Normalized}' for '{Title}'", LogRedaction.RedactText(mamId, LogRedaction.GetSensitiveValuesFromEnvironment()), LogRedaction.RedactText(normalizedMamId, LogRedaction.GetSensitiveValuesFromEnvironment()), searchResult.Title); - - var currentAnnounces = MyAnonamouseHelper.ExtractAnnounceUrls(torrentBytes); - var updatedAnnounces = new List(); - var modified = false; - - foreach (var ann in (currentAnnounces ?? new List()) - .Where(ann => !string.IsNullOrWhiteSpace(ann)) - .Distinct()) - { - // Only append mam_id to actual tracker announce URLs, not file/web-seed URLs - if (!ann.Contains("/announce", StringComparison.OrdinalIgnoreCase) && !ann.Contains("/tracker", StringComparison.OrdinalIgnoreCase)) - { - logger.LogDebug("Skipping non-tracker URL for mam_id append: {Url}", ann); - continue; - } - // don't double-append if already present - if (ann.IndexOf("mam_id=", StringComparison.OrdinalIgnoreCase) >= 0) - { - updatedAnnounces.Add(ann); - continue; - } - - try - { - var separator = ann.Contains("?") ? "&" : "?"; - var newAnn = ann + separator + "mam_id=" + normalizedMamId; - - var replaced = MyAnonamouseHelper.ReplaceStringInTorrent(torrentBytes, ann, newAnn); - if (replaced != null && replaced.Length > 0) - { - torrentBytes = replaced; - modified = true; - } - - updatedAnnounces.Add(newAnn); - } - catch (Exception inner) when (inner is not OperationCanceledException && inner is not OutOfMemoryException && inner is not StackOverflowException) - { - logger.LogDebug(inner, "Non-fatal failure while attempting to append mam_id to announce {Ann} for '{Title}'", ann, searchResult.Title); - updatedAnnounces.Add(ann); - } - } - - if (modified) - logger.LogInformation("Appended mam_id to MyAnonamouse announce URLs for '{Title}' - count={Count}", searchResult.Title, updatedAnnounces.Count); - } - } - catch (Exception exAppend) when (exAppend is not OperationCanceledException && exAppend is not OutOfMemoryException && exAppend is not StackOverflowException) - { - logger.LogDebug(exAppend, "Failed to append mam_id to MyAnonamouse announces (non-fatal)"); - } - - searchResult.TorrentFileContent = torrentBytes; - searchResult.TorrentFileName = response != null ? MyAnonamouseHelper.ResolveTorrentFileName(response, searchResult.TorrentUrl) : "myanonamouse.torrent"; - logger.LogInformation("Cached MyAnonamouse torrent for '{Title}' ({Bytes} bytes)", searchResult.Title, torrentBytes.Length); - - // If a downloadId was provided, store the cached torrent (bytes + filename) to the in-memory cache so it can be retrieved for diagnostics. - if (!string.IsNullOrEmpty(downloadId)) - { - try - { - var cacheKey = $"mam:cachedtorrent:{downloadId}"; - cache.Set(cacheKey + ":bytes", torrentBytes, new MemoryCacheEntryOptions { SlidingExpiration = TimeSpan.FromMinutes(30) }); - cache.Set(cacheKey + ":name", searchResult.TorrentFileName ?? "download.torrent", new MemoryCacheEntryOptions { SlidingExpiration = TimeSpan.FromMinutes(30) }); - logger.LogInformation("Cached MyAnonamouse torrent bytes and filename to memory for download {DownloadId}", downloadId); - } - catch (Exception cex) when (cex is not OperationCanceledException && cex is not OutOfMemoryException && cex is not StackOverflowException) - { - logger.LogDebug(cex, "Failed to place cached MyAnonamouse torrent into memory cache (non-fatal)"); - } - } - try - { - var announces = MyAnonamouseHelper.ExtractAnnounceUrls(torrentBytes); - var count = announces?.Count ?? 0; - var unique = count > 0 ? string.Join(", ", announces?.Take(10) ?? Enumerable.Empty()) : "(none)"; - logger.LogInformation("Cached MyAnonamouse torrent announces for '{Title}' - count={Count}: {Announces}", searchResult.Title, count, LogRedaction.RedactText(unique, LogRedaction.GetSensitiveValuesFromEnvironment())); - - // Also cache the extracted announce URLs for quick retrieval by diagnostics endpoints - if (!string.IsNullOrEmpty(downloadId) && announces != null && announces.Count > 0) - { - try - { - var cacheKey = $"mam:cachedtorrent:{downloadId}"; - cache.Set(cacheKey + ":announces", announces, new MemoryCacheEntryOptions { SlidingExpiration = TimeSpan.FromMinutes(30) }); - logger.LogInformation("Cached MyAnonamouse torrent announces to memory for download {DownloadId}", downloadId); - } - catch (Exception cexAnn) when (cexAnn is not OperationCanceledException && cexAnn is not OutOfMemoryException && cexAnn is not StackOverflowException) - { - logger.LogDebug(cexAnn, "Failed to place cached MyAnonamouse announces into memory cache (non-fatal)"); - } - } - } - catch (Exception exAnn) when (exAnn is not OperationCanceledException && exAnn is not OutOfMemoryException && exAnn is not StackOverflowException) - { - logger.LogDebug(exAnn, "Failed to extract announce URLs from cached torrent (non-fatal)"); - } - response?.Dispose(); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - logger.LogWarning(ex, "Failed to cache MyAnonamouse torrent for '{Title}'", searchResult.Title); - } + var preparationService = new MyAnonamouseTorrentPreparationService( + indexerRepository, + httpClientFactory, + cachedTorrentStore, + logger); + await preparationService.PrepareAsync(searchResult, downloadId); } private string BuildSearchQuery(Audiobook audiobook) @@ -992,13 +538,6 @@ private string BuildSearchQuery(Audiobook audiobook) return string.Join(" ", parts); } - // Small container for caching torrent bytes + filename in memory - private class CachedTorrent - { - public byte[]? Bytes { get; set; } - public string? FileName { get; set; } - } - public async Task RemoveFromQueueAsync(string downloadId, string? downloadClientId = null, bool force = false) { try diff --git a/listenarr.application/Downloads/MyAnonamouseTorrentPreparationService.cs b/listenarr.application/Downloads/MyAnonamouseTorrentPreparationService.cs new file mode 100644 index 000000000..7266b1635 --- /dev/null +++ b/listenarr.application/Downloads/MyAnonamouseTorrentPreparationService.cs @@ -0,0 +1,480 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +using System.Text.RegularExpressions; +using Listenarr.Application.Common; +using Listenarr.Application.Interfaces.Repositories; +using Listenarr.Application.Security; +using Listenarr.Domain.Models; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Application.Downloads +{ + public sealed class MyAnonamouseTorrentPreparationService + { + private readonly IIndexerRepository _indexerRepository; + private readonly IHttpClientFactory _httpClientFactory; + private readonly DownloadCachedTorrentStore _cachedTorrentStore; + private readonly ILogger _logger; + + public MyAnonamouseTorrentPreparationService( + IIndexerRepository indexerRepository, + IHttpClientFactory httpClientFactory, + DownloadCachedTorrentStore cachedTorrentStore, + ILogger logger) + { + _indexerRepository = indexerRepository; + _httpClientFactory = httpClientFactory; + _cachedTorrentStore = cachedTorrentStore; + _logger = logger; + } + + public async Task PrepareAsync(SearchResult searchResult, string? downloadId = null) + { + ArgumentNullException.ThrowIfNull(searchResult); + + _logger.LogInformation("TryPrepareMyAnonamouseTorrentAsync called for '{Title}', IndexerId: {IndexerId}, TorrentUrl: '{TorrentUrl}'", + searchResult.Title, searchResult.IndexerId, searchResult.TorrentUrl); + + if (searchResult.IndexerId == null) + { + _logger.LogWarning("TryPrepareMyAnonamouseTorrentAsync: No IndexerId for '{Title}' - skipping", searchResult.Title); + return; + } + + if (string.IsNullOrEmpty(searchResult.TorrentUrl)) + { + _logger.LogDebug("Skipping MyAnonamouse cache: no TorrentUrl for '{Title}'", LogRedaction.SanitizeText(searchResult.Title)); + return; + } + + if (searchResult.TorrentFileContent != null && searchResult.TorrentFileContent.Length > 0) + { + _logger.LogDebug("MyAnonamouse torrent already cached for '{Title}'", searchResult.Title); + return; + } + + try + { + var indexer = await _indexerRepository.GetByIdAsync(searchResult.IndexerId.Value); + if (indexer == null) + { + _logger.LogWarning("Unable to cache MyAnonamouse torrent for '{Title}': indexer configuration not found", searchResult.Title); + return; + } + + if (!string.Equals(indexer.Implementation, "MyAnonamouse", StringComparison.OrdinalIgnoreCase)) + { + _logger.LogDebug("Skipping MyAnonamouse cache: indexer {IndexerName} is not MyAnonamouse (is {Implementation})", + indexer.Name, indexer.Implementation); + return; + } + + if (!Uri.TryCreate(searchResult.TorrentUrl, UriKind.Absolute, out var torrentUri) || + !Uri.TryCreate(indexer.Url, UriKind.Absolute, out var indexerUri)) + { + _logger.LogWarning("Unable to cache MyAnonamouse torrent for '{Title}': invalid URL(s). Torrent={Url}, Indexer={IndexerUrl}", searchResult.Title, LogRedaction.SanitizeUrl(searchResult.TorrentUrl), indexer.Url); + return; + } + + if (!string.Equals(torrentUri.Host, indexerUri.Host, StringComparison.OrdinalIgnoreCase)) + { + _logger.LogDebug("MyAnonamouse torrent host {TorrentHost} differs from indexer host {IndexerHost}. Proceeding with explicit cookie header.", torrentUri.Host, indexerUri.Host); + } + + var mamId = MyAnonamouseHelper.TryGetMamId(indexer.AdditionalSettings); + if (string.IsNullOrEmpty(mamId)) + { + _logger.LogWarning("Unable to cache MyAnonamouse torrent for '{Title}': mam_id missing from indexer {IndexerName}", searchResult.Title, indexer.Name); + return; + } + + var httpClientToUse = _httpClientFactory.CreateClient(); + + _logger.LogDebug("Downloading MyAnonamouse torrent for '{Title}' from {Url}", searchResult.Title, LogRedaction.SanitizeUrl(searchResult.TorrentUrl)); + + var currentUri = torrentUri; + HttpResponseMessage? response = null; + for (int redirectAttempt = 0; redirectAttempt < 6; redirectAttempt++) + { + using var req = BuildTorrentRequest(currentUri, indexerUri, mamId); + + _logger.LogDebug("Downloading MyAnonamouse torrent for '{Title}' from {Url} (attempt {Attempt})", searchResult.Title, LogRedaction.SanitizeUrl(currentUri.ToString()), redirectAttempt + 1); + + response = await httpClientToUse.SendAsync(req); + mamId = await PersistUpdatedMamIdAsync(response, indexer, mamId); + + if (IsRedirect(response)) + { + if (response.Headers.Location == null) + { + _logger.LogWarning("MyAnonamouse torrent download redirect without Location header for '{Title}'", searchResult.Title); + response.Dispose(); + return; + } + + var next = response.Headers.Location.IsAbsoluteUri ? response.Headers.Location : new Uri(currentUri, response.Headers.Location); + _logger.LogDebug("Following MyAnonamouse redirect to {Next}", LogRedaction.SanitizeUrl(next.ToString())); + response.Dispose(); + currentUri = next; + continue; + } + + break; + } + + if (response == null) + { + _logger.LogWarning("Failed to download MyAnonamouse torrent for '{Title}': no response", searchResult.Title); + return; + } + + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning("MyAnonamouse torrent download failed for '{Title}' with status {Status}", searchResult.Title, response.StatusCode); + response.Dispose(); + return; + } + + var torrentBytes = await response.Content.ReadAsByteArrayAsync(); + if (torrentBytes == null || torrentBytes.Length == 0) + { + _logger.LogWarning("MyAnonamouse torrent download for '{Title}' returned empty payload", searchResult.Title); + response.Dispose(); + return; + } + + var looksLikeTorrent = LooksLikeTorrent(torrentBytes); + if (!looksLikeTorrent) + { + _logger.LogDebug("Factory client returned non-torrent payload for '{Title}', retrying with authenticated MAM client", searchResult.Title); + response.Dispose(); + response = null; + + try + { + using var authClient = MyAnonamouseHelper.CreateAuthenticatedHttpClient(mamId, indexer.Url); + var retryUri = torrentUri; + for (int retryHop = 0; retryHop < 6; retryHop++) + { + using var retryReq = BuildTorrentRequest(retryUri, indexerUri, mamId); + response = await authClient.SendAsync(retryReq); + + if (IsRedirect(response) && response.Headers.Location != null) + { + retryUri = response.Headers.Location.IsAbsoluteUri + ? response.Headers.Location + : new Uri(retryUri, response.Headers.Location); + response.Dispose(); + response = null; + continue; + } + + break; + } + + if (response != null && response.IsSuccessStatusCode) + { + torrentBytes = await response.Content.ReadAsByteArrayAsync(); + looksLikeTorrent = torrentBytes != null && torrentBytes.Length > 0 && LooksLikeTorrent(torrentBytes); + if (looksLikeTorrent) + { + _logger.LogInformation("Authenticated MAM client successfully downloaded torrent for '{Title}' ({Bytes} bytes)", searchResult.Title, torrentBytes!.Length); + } + } + } + catch (Exception retryEx) when (retryEx is not OperationCanceledException && retryEx is not OutOfMemoryException && retryEx is not StackOverflowException) + { + _logger.LogDebug(retryEx, "Retry with authenticated MAM client also failed (non-fatal)"); + } + } + + if (!looksLikeTorrent) + { + var snippet = System.Text.Encoding.UTF8.GetString((torrentBytes ?? Array.Empty()).Take(Math.Min(512, torrentBytes?.Length ?? 0)).ToArray()); + if (Regex.IsMatch(snippet, "Unrecognized host|PassKey|Pass Key|Unrecognized", RegexOptions.IgnoreCase)) + { + _logger.LogWarning("MyAnonamouse torrent download for '{Title}' returned an authorization error page from tracker: {Snippet}", searchResult.Title, LogRedaction.RedactText(snippet, LogRedaction.GetSensitiveValuesFromEnvironment())); + } + else + { + _logger.LogWarning("MyAnonamouse torrent download for '{Title}' returned unexpected non-torrent payload (first 200 chars): {Snippet}", searchResult.Title, LogRedaction.RedactText(snippet, LogRedaction.GetSensitiveValuesFromEnvironment())); + } + + response?.Dispose(); + return; + } + + if (torrentBytes == null) + { + return; + } + + LogTorrentPayloadDebug(searchResult.Title, response, torrentBytes); + torrentBytes = RewriteTrackerHosts(searchResult.Title, torrentBytes, torrentUri, indexerUri); + torrentBytes = AppendMamIdToAnnounces(searchResult.Title, torrentBytes, mamId); + + searchResult.TorrentFileContent = torrentBytes; + searchResult.TorrentFileName = response != null ? MyAnonamouseHelper.ResolveTorrentFileName(response, searchResult.TorrentUrl) : "myanonamouse.torrent"; + _logger.LogInformation("Cached MyAnonamouse torrent for '{Title}' ({Bytes} bytes)", searchResult.Title, torrentBytes.Length); + + CachePreparedTorrent(searchResult.Title, torrentBytes, searchResult.TorrentFileName, downloadId); + response?.Dispose(); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning(ex, "Failed to cache MyAnonamouse torrent for '{Title}'", searchResult.Title); + } + } + + private static HttpRequestMessage BuildTorrentRequest(Uri uri, Uri indexerUri, string? mamId) + { + var request = new HttpRequestMessage(HttpMethod.Get, uri); + request.Headers.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"); + request.Headers.Referrer = new Uri("https://www.myanonamouse.net/"); + request.Headers.Accept.ParseAdd("application/x-bittorrent, application/octet-stream, */*; q=0.01"); + if (!string.IsNullOrEmpty(mamId)) + { + request.Headers.Add("Cookie", $"mam_id={mamId}"); + } + + request.Headers.Host = indexerUri.IsDefaultPort ? indexerUri.Host : $"{indexerUri.Host}:{indexerUri.Port}"; + return request; + } + + private async Task PersistUpdatedMamIdAsync(HttpResponseMessage response, Indexer indexer, string mamId) + { + try + { + var newMam = MyAnonamouseHelper.TryExtractMamIdFromResponse(response); + if (!string.IsNullOrEmpty(newMam) && !string.Equals(newMam, mamId, StringComparison.Ordinal)) + { + _logger.LogInformation("MyAnonamouse: received updated mam_id from download redirect response for indexer {Name}", indexer.Name); + indexer.AdditionalSettings = MyAnonamouseHelper.UpdateMamIdInAdditionalSettings(indexer.AdditionalSettings, newMam); + await _indexerRepository.UpdateAsync(indexer); + indexer.AdditionalSettings = MyAnonamouseHelper.UpdateMamIdInAdditionalSettings(indexer.AdditionalSettings, newMam); + return newMam; + } + } + catch (Exception exMam) when (exMam is not OperationCanceledException && exMam is not OutOfMemoryException && exMam is not StackOverflowException) + { + _logger.LogDebug(exMam, "Failed to persist updated mam_id from MyAnonamouse redirect response"); + } + + return mamId; + } + + private static bool IsRedirect(HttpResponseMessage response) + { + return response.StatusCode is + System.Net.HttpStatusCode.MovedPermanently or + System.Net.HttpStatusCode.Found or + System.Net.HttpStatusCode.SeeOther or + System.Net.HttpStatusCode.TemporaryRedirect or + System.Net.HttpStatusCode.PermanentRedirect; + } + + private static bool LooksLikeTorrent(byte[] torrentBytes) + { + return (torrentBytes.Length > 0 && torrentBytes[0] == (byte)'d') || + System.Text.Encoding.ASCII.GetString(torrentBytes.Take(Math.Min(200, torrentBytes.Length)).ToArray()) + .IndexOf("announce", StringComparison.OrdinalIgnoreCase) >= 0; + } + + private void LogTorrentPayloadDebug(string title, HttpResponseMessage? response, byte[] torrentBytes) + { + var contentType = response?.Content.Headers.ContentType?.ToString() ?? "(none)"; + var firstBytesHex = BitConverter.ToString(torrentBytes.Take(Math.Min(16, torrentBytes.Length)).ToArray()).Replace("-", " "); + var containsAnnounce = System.Text.Encoding.ASCII.GetString(torrentBytes.Take(Math.Min(512, torrentBytes.Length)).ToArray()).IndexOf("announce", StringComparison.OrdinalIgnoreCase) >= 0; + _logger.LogDebug("MyAnonamouse torrent payload debug: ContentType={ContentType}, FirstBytes={FirstBytesHex}, ContainsAnnounce={ContainsAnnounce}", contentType, firstBytesHex, containsAnnounce); + } + + private byte[] RewriteTrackerHosts(string title, byte[] torrentBytes, Uri torrentUri, Uri indexerUri) + { + try + { + if (string.IsNullOrEmpty(indexerUri.Host)) + { + return torrentBytes; + } + + var ascii = System.Text.Encoding.ASCII.GetString(torrentBytes); + if (!string.IsNullOrEmpty(torrentUri.Host) && + ascii.IndexOf(torrentUri.Host, StringComparison.OrdinalIgnoreCase) >= 0 && + !string.Equals(torrentUri.Host, indexerUri.Host, StringComparison.OrdinalIgnoreCase)) + { + var replaced = MyAnonamouseHelper.ReplaceHostInTorrent(torrentBytes, torrentUri.Host, indexerUri.Host); + if (replaced != null && replaced.Length > 0) + { + torrentBytes = replaced; + _logger.LogInformation("Rewrote torrent tracker host from {OldHost} to {NewHost} for '{Title}'", torrentUri.Host, indexerUri.Host, title); + ascii = System.Text.Encoding.ASCII.GetString(torrentBytes); + } + } + + try + { + var ipMatches = Regex.Matches(ascii, @"\b\d{1,3}(?:\.\d{1,3}){3}\b"); + var distinctIps = ipMatches.Cast().Select(match => match.Value).Distinct().ToList(); + foreach (var ip in distinctIps.Where(ip => + !ip.StartsWith("127.") + && !ip.StartsWith("10.") + && !ip.StartsWith("192.168.") + && !ip.StartsWith("172.") + && !string.Equals(ip, indexerUri.Host, StringComparison.OrdinalIgnoreCase))) + { + var replaced = MyAnonamouseHelper.ReplaceHostInTorrent(torrentBytes, ip, indexerUri.Host); + if (replaced != null && replaced.Length > 0) + { + torrentBytes = replaced; + _logger.LogInformation("Rewrote torrent IP host {Ip} to indexer host {Host} for '{Title}'", ip, indexerUri.Host, title); + } + } + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogDebug(ex, "Failed to rewrite numeric IPs inside torrent (non-fatal)"); + } + + try + { + var announces = MyAnonamouseHelper.ExtractAnnounceUrls(torrentBytes); + if (announces != null && announces.Count > 0) + { + _logger.LogDebug("Torrent announce URLs for '{Title}': {Announces}", title, string.Join(", ", announces.Distinct())); + } + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogDebug(ex, "Failed to extract announce URLs from torrent (non-fatal)"); + } + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogDebug(ex, "Failed to rewrite torrent tracker hosts (non-fatal)"); + } + + return torrentBytes; + } + + private byte[] AppendMamIdToAnnounces(string title, byte[] torrentBytes, string? mamId) + { + try + { + if (string.IsNullOrEmpty(mamId)) + { + return torrentBytes; + } + + var normalizedMamId = MyAnonamouseHelper.NormalizeMamId(mamId); + _logger.LogInformation("MyAnonamouse: normalizing mam_id from '{Raw}' to '{Normalized}' for '{Title}'", LogRedaction.RedactText(mamId, LogRedaction.GetSensitiveValuesFromEnvironment()), LogRedaction.RedactText(normalizedMamId, LogRedaction.GetSensitiveValuesFromEnvironment()), title); + + var currentAnnounces = MyAnonamouseHelper.ExtractAnnounceUrls(torrentBytes); + var updatedAnnounces = new List(); + var modified = false; + + foreach (var announce in (currentAnnounces ?? new List()) + .Where(announce => !string.IsNullOrWhiteSpace(announce)) + .Distinct()) + { + if (!announce.Contains("/announce", StringComparison.OrdinalIgnoreCase) && !announce.Contains("/tracker", StringComparison.OrdinalIgnoreCase)) + { + _logger.LogDebug("Skipping non-tracker URL for mam_id append: {Url}", announce); + continue; + } + + if (announce.IndexOf("mam_id=", StringComparison.OrdinalIgnoreCase) >= 0) + { + updatedAnnounces.Add(announce); + continue; + } + + try + { + var separator = announce.Contains("?") ? "&" : "?"; + var newAnnounce = announce + separator + "mam_id=" + normalizedMamId; + + var replaced = MyAnonamouseHelper.ReplaceStringInTorrent(torrentBytes, announce, newAnnounce); + if (replaced != null && replaced.Length > 0) + { + torrentBytes = replaced; + modified = true; + } + + updatedAnnounces.Add(newAnnounce); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogDebug(ex, "Non-fatal failure while attempting to append mam_id to announce {Ann} for '{Title}'", announce, title); + updatedAnnounces.Add(announce); + } + } + + if (modified) + { + _logger.LogInformation("Appended mam_id to MyAnonamouse announce URLs for '{Title}' - count={Count}", title, updatedAnnounces.Count); + } + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogDebug(ex, "Failed to append mam_id to MyAnonamouse announces (non-fatal)"); + } + + return torrentBytes; + } + + private void CachePreparedTorrent(string title, byte[] torrentBytes, string? fileName, string? downloadId) + { + if (!string.IsNullOrEmpty(downloadId)) + { + try + { + _cachedTorrentStore.CacheTorrent(downloadId, torrentBytes, fileName ?? "download.torrent"); + _logger.LogInformation("Cached MyAnonamouse torrent bytes and filename to memory for download {DownloadId}", downloadId); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogDebug(ex, "Failed to place cached MyAnonamouse torrent into memory cache (non-fatal)"); + } + } + + try + { + var announces = MyAnonamouseHelper.ExtractAnnounceUrls(torrentBytes); + _cachedTorrentStore.LogCachedAnnounces(title, announces); + + if (!string.IsNullOrEmpty(downloadId) && announces != null && announces.Count > 0) + { + try + { + _cachedTorrentStore.CacheAnnounces(downloadId, announces); + _logger.LogInformation("Cached MyAnonamouse torrent announces to memory for download {DownloadId}", downloadId); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogDebug(ex, "Failed to place cached MyAnonamouse announces into memory cache (non-fatal)"); + } + } + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogDebug(ex, "Failed to extract announce URLs from cached torrent (non-fatal)"); + } + } + } +} diff --git a/listenarr.application/Metadata/AudibleApiClient.cs b/listenarr.application/Metadata/AudibleApiClient.cs new file mode 100644 index 000000000..fa01099bb --- /dev/null +++ b/listenarr.application/Metadata/AudibleApiClient.cs @@ -0,0 +1,134 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Application.Metadata +{ + internal sealed class AudibleApiClient + { + private const string BrowserAcceptHeader = "application/json, text/plain, */*"; + private const string BrowserAcceptLanguageHeader = "en-US,en;q=0.9"; + private const string BrowserUserAgent = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"; + private const string AudibleApiAcceptHeader = "application/json"; + private const string AudibleApiUserAgent = + "Dalvik/2.1.0 (Linux; U; Android 15); com.audible.application"; + private const string AudibleApiVerboseUserAgent = + "Dalvik/2.1.0 (Linux; U; Android 15; good_phone Build/AAAA.240000.005); com.audible.application"; + + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + + public AudibleApiClient(HttpClient httpClient, ILogger logger) + { + _httpClient = httpClient; + _logger = logger; + ConfigureBrowserHeaders(_httpClient); + } + + public Task GetProductDocumentAsync(string asin, string region, string responseGroups) + { + var safeRegion = AudibleRequestHelper.NormalizeRegion(region); + var url = + $"{AudibleRequestHelper.BuildApiBaseUrl(safeRegion)}/1.0/catalog/products/{Uri.EscapeDataString(asin)}?" + + $"{AudibleRequestHelper.BuildQueryString(new Dictionary + { + ["response_groups"] = responseGroups, + ["image_sizes"] = "500,1000,2400,3200" + })}"; + + return GetJsonDocumentAsync(url, safeRegion, includeLocaleHeaders: false, timeoutSeconds: 10); + } + + public async Task GetJsonDocumentAsync( + string url, + string region, + bool includeLocaleHeaders, + int timeoutSeconds) + { + try + { + using var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.TryAddWithoutValidation("User-Agent", includeLocaleHeaders ? AudibleApiVerboseUserAgent : AudibleApiUserAgent); + request.Headers.TryAddWithoutValidation("Accept", AudibleApiAcceptHeader); + request.Headers.TryAddWithoutValidation("Accept-Encoding", "gzip"); + request.Headers.TryAddWithoutValidation("Accept-Charset", "utf-8"); + if (includeLocaleHeaders) + { + var locale = AudibleRequestHelper.GetLocale(region); + request.Headers.TryAddWithoutValidation("ACCEPTED-LANGUAGE", locale); + request.Headers.TryAddWithoutValidation("accept-language", locale); + request.Headers.TryAddWithoutValidation("X-ADP-SW", Random.Shared.Next(10_000_000, 99_999_999).ToString()); + } + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds)); + var response = await _httpClient.SendAsync(request, cts.Token); + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning("Audible API returned status code {StatusCode} for URL {Url}", response.StatusCode, url); + return null; + } + + await using var stream = await response.Content.ReadAsStreamAsync(cts.Token); + return await JsonDocument.ParseAsync(stream, cancellationToken: cts.Token); + } + catch (TaskCanceledException ex) + { + _logger.LogWarning(ex, "Audible API request timed out for URL: {Url}", url); + return null; + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogError(ex, "Error performing Audible API request for URL: {Url}", url); + return null; + } + } + + public async Task GetWithTimeoutAsync(string url, int timeoutSeconds = 5) + { + try + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds)); + var resp = await _httpClient.GetAsync(url, cts.Token); + return resp; + } + catch (TaskCanceledException ex) + { + _logger.LogWarning(ex, "Audible request timed out for URL: {Url}", url); + return null; + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogError(ex, "Error performing Audible HTTP request for URL: {Url}", url); + return null; + } + } + + private static void ConfigureBrowserHeaders(HttpClient httpClient) + { + httpClient.DefaultRequestHeaders.Accept.Clear(); + httpClient.DefaultRequestHeaders.Accept.ParseAdd(BrowserAcceptHeader); + httpClient.DefaultRequestHeaders.AcceptLanguage.Clear(); + httpClient.DefaultRequestHeaders.AcceptLanguage.ParseAdd(BrowserAcceptLanguageHeader); + httpClient.DefaultRequestHeaders.UserAgent.Clear(); + httpClient.DefaultRequestHeaders.UserAgent.ParseAdd(BrowserUserAgent); + } + } +} diff --git a/listenarr.application/Metadata/AudibleAuthorCatalogMatcher.cs b/listenarr.application/Metadata/AudibleAuthorCatalogMatcher.cs new file mode 100644 index 000000000..8790c6efa --- /dev/null +++ b/listenarr.application/Metadata/AudibleAuthorCatalogMatcher.cs @@ -0,0 +1,75 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace Listenarr.Application.Metadata +{ + internal static class AudibleAuthorCatalogMatcher + { + public static bool MatchesTarget(AudibleSearchResult result, string author, string? authorAsin) + { + if (result.Authors == null || result.Authors.Count == 0) + { + return false; + } + + var normalizedTargetName = NormalizeComparableText(author); + if (string.IsNullOrWhiteSpace(normalizedTargetName)) + { + return false; + } + + foreach (var candidate in result.Authors) + { + if (!string.IsNullOrWhiteSpace(authorAsin) && + string.Equals(candidate.Asin, authorAsin, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (string.Equals(NormalizeComparableText(candidate.Name), normalizedTargetName, StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } + + public static string BuildSearchResultKey(AudibleSearchResult result) + { + return string.IsNullOrWhiteSpace(result.Asin) + ? $"{result.Title}|{result.Link}" + : result.Asin; + } + + private static string NormalizeComparableText(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return string.Empty; + } + + var joined = string.Join( + ' ', + value.Trim() + .Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)) + .ToLowerInvariant(); + return AudibleService.RemoveDiacritics(joined); + } + } +} diff --git a/listenarr.application/Metadata/AudibleAuthorCatalogWorkflow.cs b/listenarr.application/Metadata/AudibleAuthorCatalogWorkflow.cs new file mode 100644 index 000000000..0d10f9c13 --- /dev/null +++ b/listenarr.application/Metadata/AudibleAuthorCatalogWorkflow.cs @@ -0,0 +1,455 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +using Listenarr.Application.Interfaces; +using Listenarr.Application.Security; +using Microsoft.Extensions.Logging; +using System.Text.Json; + +namespace Listenarr.Application.Metadata +{ + internal sealed class AudibleAuthorCatalogWorkflow + { + private readonly IAudibleAuthorPageParser? _authorPageParser; + private readonly Func> _searchProductsDirectAsync; + private readonly Func> _getBookMetadataAsync; + private readonly Func> _getWithTimeoutAsync; + private readonly AudibleApiClient _apiClient; + private readonly AudibleProductMetadataWorkflow _metadataWorkflow; + private readonly AudibleAuthorLookupWorkflow _authorLookupWorkflow; + private readonly ILogger _logger; + + public AudibleAuthorCatalogWorkflow( + IAudibleAuthorPageParser? authorPageParser, + Func> searchProductsDirectAsync, + Func> getBookMetadataAsync, + Func> getWithTimeoutAsync, + AudibleApiClient apiClient, + AudibleProductMetadataWorkflow metadataWorkflow, + AudibleAuthorLookupWorkflow authorLookupWorkflow, + ILogger logger) + { + _authorPageParser = authorPageParser; + _searchProductsDirectAsync = searchProductsDirectAsync; + _getBookMetadataAsync = getBookMetadataAsync; + _getWithTimeoutAsync = getWithTimeoutAsync; + _apiClient = apiClient; + _metadataWorkflow = metadataWorkflow; + _authorLookupWorkflow = authorLookupWorkflow; + _logger = logger; + } + + public async Task GetBooksByAuthorAsinAsync(string authorAsin, int page, int limit, string region, string? language) + { + try + { + if (string.IsNullOrWhiteSpace(authorAsin)) + { + return null; + } + + var requestedPage = Math.Max(1, page); + var pageSize = Math.Clamp(limit, 1, 500); + var desiredSkip = (requestedPage - 1) * pageSize; + var desiredTake = pageSize; + var collectedAsins = new List(); + string? continuationToken = null; + var iteration = 0; + + while (iteration < 10 && collectedAsins.Count < desiredSkip + desiredTake) + { + iteration++; + var tokenQuery = string.IsNullOrWhiteSpace(continuationToken) + ? string.Empty + : $"&pageSectionContinuationToken={Uri.EscapeDataString(continuationToken)}"; + var authorPageUrl = + $"{AudibleRequestHelper.BuildApiBaseUrl(region)}/1.0/screens/audible-android-author-detail/{Uri.EscapeDataString(authorAsin)}" + + $"?tabId=titles&author_asin={Uri.EscapeDataString(authorAsin)}&title_source=all" + + $"&session_id={Uri.EscapeDataString(AudibleRequestHelper.GenerateRandomSessionId())}" + + $"&applicationType=Android_App&local_time={Uri.EscapeDataString(DateTime.UtcNow.ToString("O"))}" + + $"&response_groups=always-returned&surface=Android{tokenQuery}"; + + using var authorPageDoc = await _apiClient.GetJsonDocumentAsync( + authorPageUrl, + region, + includeLocaleHeaders: true, + timeoutSeconds: 10); + if (authorPageDoc == null) + { + break; + } + + var root = authorPageDoc.RootElement; + if (!root.TryGetProperty("sections", out var sections) || sections.ValueKind != JsonValueKind.Array) + { + break; + } + + continuationToken = null; + foreach (var section in sections.EnumerateArray()) + { + if (!section.TryGetProperty("model", out var model) || + !model.TryGetProperty("rows", out var rows) || + rows.ValueKind != JsonValueKind.Array) + { + continue; + } + + collectedAsins.AddRange( + rows.EnumerateArray() + .Select(row => GetString(row, "product_metadata", "asin")) + .Where(asin => !string.IsNullOrWhiteSpace(asin))!); + + continuationToken = GetString(section, "pagination"); + if (rows.GetArrayLength() > 0) + { + break; + } + } + + if (string.IsNullOrWhiteSpace(continuationToken)) + { + break; + } + } + + var pagedAsins = collectedAsins + .Distinct(StringComparer.OrdinalIgnoreCase) + .Skip(desiredSkip) + .Take(desiredTake) + .ToList(); + if (pagedAsins.Count == 0) + { + return new AudibleSearchResponse + { + Results = new List(), + TotalResults = collectedAsins.Distinct(StringComparer.OrdinalIgnoreCase).Count() + }; + } + + var books = await _metadataWorkflow.GetBooksMetadataByAsinsAsync(pagedAsins, region); + var mapped = books + .Where(book => book != null) + .Select(AudibleProductMapper.MapBookResponseToSearchResult) + .Where(book => book != null) + .Cast() + .ToList(); + + mapped = AudibleProductMapper.ApplyLanguageFilter(mapped, language); + + return new AudibleSearchResponse + { + Results = mapped, + TotalResults = collectedAsins.Distinct(StringComparer.OrdinalIgnoreCase).Count() + }; + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogError(ex, "Error fetching books for author ASIN {AuthorAsin}", LogRedaction.SanitizeText(authorAsin)); + return null; + } + } + + public async Task SearchByAuthorAsync(string author, int page, int limit, string region, string? language) + { + var authorLookupItems = await _authorLookupWorkflow.LookupAuthorItemsAsync(author, region, language); + var authorAsin = authorLookupItems.FirstOrDefault(a => !string.IsNullOrWhiteSpace(a.Asin))?.Asin; + if (string.IsNullOrWhiteSpace(authorAsin)) + { + _logger.LogWarning("No author ASIN found for author '{Author}'", LogRedaction.SanitizeText(author)); + return null; + } + + return await GetBooksByResolvedAuthorAsync(author, authorAsin, page, limit, region, language); + } + + public async Task GetBooksByAuthorAsync(string author, string authorAsin, int page, int limit, string region, string? language) + { + if (string.IsNullOrWhiteSpace(authorAsin)) return null; + return await GetBooksByResolvedAuthorAsync(author, authorAsin, page, limit, region, language); + } + + public async Task GetAllBooksByAuthorAsync(string author, string authorAsin, int limit, string region, string? language) + { + if (string.IsNullOrWhiteSpace(authorAsin)) + { + return null; + } + + var directResults = await GetDirectAuthorCatalogResultsAsync(author, authorAsin, region, language); + if (directResults.Count > 0) + { + var cappedLimit = Math.Clamp(limit, 1, 500); + return new AudibleSearchResponse + { + Results = directResults.Take(cappedLimit).ToList(), + TotalResults = directResults.Count + }; + } + + var fallbackLimit = Math.Clamp(limit, 1, 500); + var authorScreenResult = await GetBooksByAuthorAsinAsync(authorAsin, 1, fallbackLimit, region, language); + if (authorScreenResult?.Results?.Count > 0) + { + return authorScreenResult; + } + + _logger.LogWarning( + "Direct Audible author catalog lookup returned no results for author {Author} (ASIN {AuthorAsin}); falling back to Audible author page scraping", + LogRedaction.SanitizeText(author), + LogRedaction.SanitizeText(authorAsin)); + + return await ScrapeAudibleAuthorPageAsync(author, authorAsin, 1, fallbackLimit, region, language); + } + + public async Task GetBooksByResolvedAuthorAsync(string author, string authorAsin, int page, int limit, string region, string? language) + { + var fullCatalogResult = await GetAllBooksByAuthorAsync(author, authorAsin, 500, region, language); + if (fullCatalogResult?.Results?.Count > 0) + { + var pageSize = Math.Clamp(limit, 1, 500); + var skip = Math.Max(0, (page - 1) * pageSize); + + return new AudibleSearchResponse + { + Results = fullCatalogResult.Results.Skip(skip).Take(pageSize).ToList(), + TotalResults = fullCatalogResult.TotalResults + }; + } + + return fullCatalogResult; + } + + private async Task> GetDirectAuthorCatalogResultsAsync(string author, string authorAsin, string region, string? language) + { + var normalizedAuthor = author?.Trim(); + if (string.IsNullOrWhiteSpace(normalizedAuthor)) + { + return new List(); + } + + var results = new List(); + var seenKeys = new HashSet(StringComparer.OrdinalIgnoreCase); + var maxPages = 10; + + for (var currentPage = 1; currentPage <= maxPages; currentPage++) + { + var response = await _searchProductsDirectAsync( + null, + null, + normalizedAuthor, + null, + null, + currentPage, + 50, + region, + null, + "BestSellers", + false); + + if (response.Results.Count == 0) + { + break; + } + + if (response.TotalResults > 0) + { + maxPages = Math.Min(10, (int)Math.Ceiling(response.TotalResults / 50d)); + } + + foreach (var result in response.Results) + { + if (!AudibleAuthorCatalogMatcher.MatchesTarget(result, normalizedAuthor, authorAsin)) + { + continue; + } + + var key = AudibleAuthorCatalogMatcher.BuildSearchResultKey(result); + if (!seenKeys.Add(key)) + { + continue; + } + + results.Add(result); + } + + if (response.Results.Count < 50) + { + break; + } + } + + return AudibleProductMapper.ApplyLanguageFilter(results, language); + } + + private async Task ScrapeAudibleAuthorPageAsync(string author, string authorAsin, int page, int limit, string region, string? language) + { + try + { + var authorPageUrl = AudibleRequestHelper.BuildAuthorPageUrl(author, authorAsin, region); + _logger.LogInformation("Scraping Audible author page as fallback: {Url}", authorPageUrl); + + var response = await _getWithTimeoutAsync(authorPageUrl, 10); + if (response == null) + { + _logger.LogWarning("Audible author page request timed out for author {Author}", LogRedaction.SanitizeText(author)); + return null; + } + + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning("Audible author page returned status code {StatusCode} for author {Author}", response.StatusCode, author); + return null; + } + + var html = await response.Content.ReadAsStringAsync(); + if (string.IsNullOrWhiteSpace(html)) + { + _logger.LogWarning("Audible author page returned empty HTML for author {Author}", LogRedaction.SanitizeText(author)); + return null; + } + + if (_authorPageParser == null) + { + _logger.LogWarning("Audible author page parser is unavailable for author {Author}", LogRedaction.SanitizeText(author)); + return null; + } + + var parsedTiles = _authorPageParser.ParseAuthorPage(html, author, authorAsin, region); + if (parsedTiles.Count == 0) + { + _logger.LogWarning("Audible author page tiles could not be parsed for author {Author}", LogRedaction.SanitizeText(author)); + return null; + } + + await EnrichFallbackAuthorResultsAsync(parsedTiles, region); + + var authorMatchedTiles = parsedTiles + .Where(result => result.Authors?.Any(authorItem => string.Equals(authorItem.Name, author, StringComparison.OrdinalIgnoreCase)) == true) + .ToList(); + var filteredTiles = authorMatchedTiles.Count > 0 ? authorMatchedTiles : parsedTiles; + + if (!string.IsNullOrWhiteSpace(language)) + { + filteredTiles = filteredTiles + .Where(result => !string.IsNullOrWhiteSpace(result.Language) && string.Equals(result.Language, language, StringComparison.OrdinalIgnoreCase)) + .ToList(); + } + + var skip = Math.Max(0, (page - 1) * Math.Max(1, limit)); + var pagedTiles = filteredTiles.Skip(skip).Take(Math.Max(1, limit)).ToList(); + + _logger.LogInformation( + "Audible author page fallback returned {PagedCount} of {TotalCount} parsed title(s) for author {Author}", + pagedTiles.Count, + filteredTiles.Count, + LogRedaction.SanitizeText(author)); + + return new AudibleSearchResponse + { + Results = pagedTiles, + TotalResults = filteredTiles.Count + }; + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning(ex, "Failed to scrape Audible author page fallback for author {Author}", LogRedaction.SanitizeText(author)); + return null; + } + } + + private async Task EnrichFallbackAuthorResultsAsync(List books, string region) + { + foreach (var book in books) + { + if (string.IsNullOrWhiteSpace(book.Asin)) + { + continue; + } + + try + { + var metadata = await _getBookMetadataAsync(book.Asin, region, true, null); + if (metadata == null) + { + continue; + } + + book.Title = string.IsNullOrWhiteSpace(metadata.Title) ? book.Title : metadata.Title; + book.Subtitle = string.IsNullOrWhiteSpace(book.Subtitle) ? metadata.Subtitle : book.Subtitle; + if (metadata.Authors?.Any() == true) + { + book.Authors = metadata.Authors; + } + + book.ImageUrl = string.IsNullOrWhiteSpace(book.ImageUrl) ? metadata.ImageUrl : book.ImageUrl; + book.LengthMinutes ??= metadata.LengthMinutes; + book.RuntimeLengthMin ??= metadata.LengthMinutes; + book.Language = string.IsNullOrWhiteSpace(book.Language) ? metadata.Language : book.Language; + book.ContentType = string.IsNullOrWhiteSpace(book.ContentType) ? metadata.ContentType : book.ContentType; + book.ContentDeliveryType = string.IsNullOrWhiteSpace(book.ContentDeliveryType) ? metadata.ContentDeliveryType : book.ContentDeliveryType; + book.BookFormat = string.IsNullOrWhiteSpace(book.BookFormat) ? metadata.BookFormat : book.BookFormat; + if (metadata.Genres?.Any() == true) + { + book.Genres = metadata.Genres; + } + + if (metadata.Series?.Any() == true) + { + book.Series = metadata.Series; + } + + book.Publisher = string.IsNullOrWhiteSpace(book.Publisher) ? metadata.Publisher : book.Publisher; + if (metadata.Narrators?.Any() == true) + { + book.Narrators = metadata.Narrators; + } + + book.ReleaseDate = string.IsNullOrWhiteSpace(book.ReleaseDate) ? metadata.ReleaseDate : book.ReleaseDate; + book.Isbn = string.IsNullOrWhiteSpace(book.Isbn) ? metadata.Isbn : book.Isbn; + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogDebug(ex, "Failed to hydrate fallback author page metadata for ASIN {Asin}", book.Asin); + } + } + } + + private static string? GetString(JsonElement element, params string[] path) + { + var current = element; + foreach (var segment in path) + { + if (current.ValueKind != JsonValueKind.Object || + !current.TryGetProperty(segment, out current)) + { + return null; + } + } + + return current.ValueKind switch + { + JsonValueKind.String => current.GetString(), + JsonValueKind.Number => current.ToString(), + JsonValueKind.True => bool.TrueString.ToLowerInvariant(), + JsonValueKind.False => bool.FalseString.ToLowerInvariant(), + _ => null + }; + } + } +} diff --git a/listenarr.application/Metadata/AudibleAuthorLookupWorkflow.cs b/listenarr.application/Metadata/AudibleAuthorLookupWorkflow.cs new file mode 100644 index 000000000..cc4de4e11 --- /dev/null +++ b/listenarr.application/Metadata/AudibleAuthorLookupWorkflow.cs @@ -0,0 +1,179 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +using System.Globalization; +using System.Text.Json; +using Listenarr.Application.Security; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Application.Metadata +{ + internal sealed class AudibleAuthorLookupWorkflow + { + private readonly AudibleApiClient _apiClient; + private readonly AudibleProductSearchWorkflow _productSearchWorkflow; + private readonly ILogger _logger; + + public AudibleAuthorLookupWorkflow( + AudibleApiClient apiClient, + AudibleProductSearchWorkflow productSearchWorkflow, + ILogger logger) + { + _apiClient = apiClient; + _productSearchWorkflow = productSearchWorkflow; + _logger = logger; + } + + public async Task LookupAuthorAsync(string author, string region) + { + if (string.IsNullOrWhiteSpace(author)) return null; + + try + { + var authorLookupItems = await LookupAuthorItemsAsync(author, region); + var candidate = authorLookupItems.FirstOrDefault(a => !string.IsNullOrWhiteSpace(a.Asin)) ?? authorLookupItems.FirstOrDefault(); + if (candidate == null) + { + return null; + } + + if (!string.IsNullOrWhiteSpace(candidate.Asin) && + (string.IsNullOrWhiteSpace(candidate.Image) || string.IsNullOrWhiteSpace(candidate.Description))) + { + var detailed = await GetAuthorByAsinAsync(candidate.Asin, region); + if (detailed != null) + { + return detailed; + } + } + + return candidate; + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning(ex, "Failed to lookup author {Author}", LogRedaction.SanitizeText(author)); + return null; + } + } + + public async Task GetAuthorByAsinAsync(string authorAsin, string region) + { + if (string.IsNullOrWhiteSpace(authorAsin)) return null; + + try + { + var locale = AudibleRequestHelper.GetLocale(region); + var url = + $"{AudibleRequestHelper.BuildApiBaseUrl(region)}/1.0/catalog/contributors/{Uri.EscapeDataString(authorAsin)}" + + $"?locale={Uri.EscapeDataString(locale)}"; + using var doc = await _apiClient.GetJsonDocumentAsync(url, region, includeLocaleHeaders: true, timeoutSeconds: 10); + if (doc == null || + !doc.RootElement.TryGetProperty("contributor", out var contributor) || + contributor.ValueKind != JsonValueKind.Object) + { + return null; + } + + return new AuthorLookupItem + { + Asin = GetString(contributor, "contributor_id") ?? authorAsin, + Name = GetString(contributor, "name"), + Image = GetString(contributor, "profile_image_url"), + Region = AudibleRequestHelper.NormalizeRegion(region), + Description = GetString(contributor, "bio") + }; + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning(ex, "Failed to lookup Audible author details by ASIN {AuthorAsin}", LogRedaction.SanitizeText(authorAsin)); + return null; + } + } + + public async Task> LookupAuthorItemsAsync(string author, string region, string? language = null) + { + var response = await _productSearchWorkflow.SearchProductsDirectAsync( + query: null, + title: null, + author: author, + narrator: null, + publisher: null, + page: 1, + limit: 10, + region: region, + language: language, + sortBy: "Relevance", + returnRawProducts: true); + + if (response.RawProducts == null || response.RawProducts.Count == 0) + { + return new List(); + } + + var normalizedAuthor = author.Trim(); + var compareInfo = CultureInfo.InvariantCulture.CompareInfo; + const CompareOptions diacriticIgnore = CompareOptions.IgnoreCase | CompareOptions.IgnoreNonSpace; + return response.RawProducts + .SelectMany(product => + GetArray(product, "authors") + .Select(authorItem => new AuthorLookupItem + { + Asin = GetString(authorItem, "asin"), + Name = GetString(authorItem, "name"), + Region = AudibleRequestHelper.NormalizeRegion(region) + })) + .Where(item => !string.IsNullOrWhiteSpace(item.Name)) + .Where(item => + compareInfo.Compare(item.Name, normalizedAuthor, diacriticIgnore) == 0 || + compareInfo.IndexOf(item.Name!, normalizedAuthor, diacriticIgnore) >= 0 || + compareInfo.IndexOf(normalizedAuthor, item.Name!, diacriticIgnore) >= 0) + .GroupBy(item => $"{item.Asin}|{item.Name}", StringComparer.OrdinalIgnoreCase) + .Select(group => group.First()) + .ToList(); + } + + private static IEnumerable GetArray(JsonElement element, string propertyName) + { + return element.TryGetProperty(propertyName, out var value) && value.ValueKind == JsonValueKind.Array + ? value.EnumerateArray() + : Enumerable.Empty(); + } + + private static string? GetString(JsonElement element, params string[] path) + { + var current = element; + foreach (var segment in path) + { + if (current.ValueKind != JsonValueKind.Object || + !current.TryGetProperty(segment, out current)) + { + return null; + } + } + + return current.ValueKind switch + { + JsonValueKind.String => current.GetString(), + JsonValueKind.Number => current.ToString(), + JsonValueKind.True => bool.TrueString.ToLowerInvariant(), + JsonValueKind.False => bool.FalseString.ToLowerInvariant(), + _ => null + }; + } + } +} diff --git a/listenarr.application/Metadata/AudibleLookupJsonParser.cs b/listenarr.application/Metadata/AudibleLookupJsonParser.cs new file mode 100644 index 000000000..c7f6177f7 --- /dev/null +++ b/listenarr.application/Metadata/AudibleLookupJsonParser.cs @@ -0,0 +1,113 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +using System.Text.Json; + +namespace Listenarr.Application.Metadata +{ + internal static class AudibleLookupJsonParser + { + private static readonly JsonSerializerOptions s_options = new() { PropertyNameCaseInsensitive = true }; + + public static AuthorLookupItem? ParseSingleAuthorLookupItem(string lookupJson) + { + var items = ParseAuthorLookupItems(lookupJson); + return items.FirstOrDefault(a => !string.IsNullOrWhiteSpace(a.Asin)) ?? items.FirstOrDefault(); + } + + public static SeriesLookupItem? ParseSeriesLookupItem(string lookupJson) + { + var items = ParseSeriesLookupItems(lookupJson); + return items.FirstOrDefault(item => !string.IsNullOrWhiteSpace(item.Asin)) ?? items.FirstOrDefault(); + } + + public static List ParseAuthorLookupItems(string lookupJson) + { + if (string.IsNullOrWhiteSpace(lookupJson)) return new List(); + + var trimmed = lookupJson.TrimStart(); + if (trimmed.StartsWith("[", StringComparison.Ordinal)) + { + return JsonSerializer.Deserialize>(lookupJson, s_options) ?? new List(); + } + + var single = JsonSerializer.Deserialize(lookupJson, s_options); + if (single != null && (!string.IsNullOrWhiteSpace(single.Asin) || !string.IsNullOrWhiteSpace(single.Name))) + { + return new List { single }; + } + + var doc = JsonSerializer.Deserialize(lookupJson, s_options); + if (doc == null) return new List(); + if (doc.Results?.Any() == true) return doc.Results; + if (!string.IsNullOrWhiteSpace(doc.Asin)) + { + return new List + { + new AuthorLookupItem + { + Asin = doc.Asin, + Name = doc.Name, + Image = doc.Image, + Region = doc.Region, + Description = doc.Description + } + }; + } + + return new List(); + } + + public static List ParseSeriesLookupItems(string lookupJson) + { + if (string.IsNullOrWhiteSpace(lookupJson)) return new List(); + + var trimmed = lookupJson.TrimStart(); + if (trimmed.StartsWith("[", StringComparison.Ordinal)) + { + return JsonSerializer.Deserialize>(lookupJson, s_options) ?? new List(); + } + + var single = JsonSerializer.Deserialize(lookupJson, s_options); + if (single != null && (!string.IsNullOrWhiteSpace(single.Asin) || !string.IsNullOrWhiteSpace(single.Name))) + { + return new List { single }; + } + + var doc = JsonSerializer.Deserialize(lookupJson, s_options); + if (doc == null) return new List(); + if (doc.Results?.Any() == true) return doc.Results; + if (!string.IsNullOrWhiteSpace(doc.Asin)) + { + return new List + { + new SeriesLookupItem + { + Asin = doc.Asin, + Name = doc.Name, + Region = doc.Region, + Description = doc.Description, + Position = doc.Position + } + }; + } + + return new List(); + } + } +} diff --git a/listenarr.application/Metadata/AudibleProductMetadataWorkflow.cs b/listenarr.application/Metadata/AudibleProductMetadataWorkflow.cs new file mode 100644 index 000000000..cbc9e0e60 --- /dev/null +++ b/listenarr.application/Metadata/AudibleProductMetadataWorkflow.cs @@ -0,0 +1,130 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +using Listenarr.Application.Security; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Application.Metadata +{ + internal sealed class AudibleProductMetadataWorkflow + { + private const string DefaultBookResponseGroups = + "media,product_attrs,product_desc,product_details,product_extended_attrs,product_plans,rating,series,relationships,review_attrs,category_ladders,customer_rights"; + + private readonly AudibleApiClient _apiClient; + private readonly ILogger _logger; + + public AudibleProductMetadataWorkflow(AudibleApiClient apiClient, ILogger logger) + { + _apiClient = apiClient; + _logger = logger; + } + + public async Task GetBookMetadataAsync(string asin, string region, string? language) + { + try + { + var result = (await GetBooksMetadataByAsinsAsync(new[] { asin }, region)).FirstOrDefault(); + if (result != null && + !string.IsNullOrWhiteSpace(language) && + !string.Equals(result.Language, language, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + return result; + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogError(ex, "Error fetching metadata from Audible for ASIN {Asin}", LogRedaction.SanitizeText(asin)); + return null; + } + } + + public async Task> GetBooksMetadataByAsinsAsync(IEnumerable asins, string region) + { + var normalizedRegion = AudibleRequestHelper.NormalizeRegion(region); + var orderedAsins = asins + .Where(asin => !string.IsNullOrWhiteSpace(asin)) + .Select(asin => asin.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + var results = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var chunk in Chunk(orderedAsins, 50)) + { + var doc = chunk.Count == 1 + ? await _apiClient.GetProductDocumentAsync(chunk[0], normalizedRegion, DefaultBookResponseGroups) + : await _apiClient.GetJsonDocumentAsync( + $"{AudibleRequestHelper.BuildApiBaseUrl(normalizedRegion)}/1.0/catalog/products/?" + + $"{AudibleRequestHelper.BuildQueryString(new Dictionary + { + ["asins"] = string.Join(",", chunk), + ["response_groups"] = DefaultBookResponseGroups, + ["image_sizes"] = "500,1000,2400,3200" + })}", + normalizedRegion, + includeLocaleHeaders: false, + timeoutSeconds: 15); + + if (doc == null) + { + continue; + } + + using (doc) + { + var root = doc.RootElement; + if (root.TryGetProperty("products", out var products) && products.ValueKind == System.Text.Json.JsonValueKind.Array) + { + foreach (var mapped in products.EnumerateArray() + .Select(product => AudibleProductMapper.MapProductToBookResponse(product, normalizedRegion)) + .Where(mapped => !string.IsNullOrWhiteSpace(mapped?.Asin))) + { + results[mapped!.Asin!] = mapped; + } + } + else if (root.TryGetProperty("product", out var product) && product.ValueKind == System.Text.Json.JsonValueKind.Object) + { + var mapped = AudibleProductMapper.MapProductToBookResponse(product, normalizedRegion); + if (!string.IsNullOrWhiteSpace(mapped?.Asin)) + { + results[mapped.Asin!] = mapped; + } + } + } + } + + return orderedAsins + .Where(results.ContainsKey) + .Select(asin => results[asin]) + .ToList(); + } + + private static List> Chunk(List values, int size) + { + var chunks = new List>(); + for (var i = 0; i < values.Count; i += size) + { + chunks.Add(values.Skip(i).Take(size).ToList()); + } + + return chunks; + } + } +} diff --git a/listenarr.application/Metadata/AudibleProductSearchWorkflow.cs b/listenarr.application/Metadata/AudibleProductSearchWorkflow.cs new file mode 100644 index 000000000..9ca9497a0 --- /dev/null +++ b/listenarr.application/Metadata/AudibleProductSearchWorkflow.cs @@ -0,0 +1,283 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +using System.Text.Json; +using Listenarr.Application.Security; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Application.Metadata +{ + internal sealed class AudibleProductSearchWorkflow + { + private readonly AudibleApiClient _apiClient; + private readonly Func> _getBookMetadataAsync; + private readonly ILogger _logger; + + public AudibleProductSearchWorkflow( + AudibleApiClient apiClient, + Func> getBookMetadataAsync, + ILogger logger) + { + _apiClient = apiClient; + _getBookMetadataAsync = getBookMetadataAsync; + _logger = logger; + } + + public async Task SearchByTitleAsync(string title, int page, int limit, string region, string? language) + { + var normalizedTitle = title?.Trim(); + if (string.IsNullOrWhiteSpace(normalizedTitle)) + { + return new AudibleSearchResponse + { + Results = new List(), + TotalResults = 0 + }; + } + + var response = await SearchProductsDirectAsync( + query: normalizedTitle, + title: null, + author: null, + narrator: null, + publisher: null, + page: page, + limit: limit, + region: region, + language: language, + sortBy: "Relevance"); + + if (response.Results.Count > 0) + { + return ToSearchResponse(response); + } + + _logger.LogInformation( + "Audible keyword title search returned no results for '{Title}' in region {Region}; retrying title-field search", + LogRedaction.SanitizeText(normalizedTitle), + AudibleRequestHelper.NormalizeRegion(region)); + + var titleFieldResponse = await SearchProductsDirectAsync( + query: null, + title: normalizedTitle, + author: null, + narrator: null, + publisher: null, + page: page, + limit: limit, + region: region, + language: language, + sortBy: "Title"); + return ToSearchResponse(titleFieldResponse); + } + + public async Task SearchByIsbnAsync(string isbn, int page, int limit, string region, string? language) + { + var response = await SearchProductsDirectAsync( + query: isbn, + title: null, + author: null, + narrator: null, + publisher: null, + page: page, + limit: limit, + region: region, + language: language, + sortBy: "BestSellers"); + var filtered = response.Results + .Where(result => string.Equals(result.Isbn?.Trim(), isbn.Trim(), StringComparison.OrdinalIgnoreCase)) + .ToList(); + return new AudibleSearchResponse + { + Results = filtered, + TotalResults = filtered.Count + }; + } + + public async Task SearchBooksAsync(string query, int page, int limit, string region, string? language) + { + if (IsAsin(query?.Trim() ?? string.Empty)) + { + var asin = query?.Trim() ?? string.Empty; + _logger.LogInformation("Query appears to be an ASIN; performing direct Audible book lookup for {Asin}", LogRedaction.SanitizeText(asin)); + var meta = await _getBookMetadataAsync(asin, region, true, language); + if (meta == null) return null; + + var single = new AudibleSearchResult + { + Asin = meta.Asin, + Title = meta.Title, + Subtitle = meta.Subtitle, + Authors = meta.Authors, + ImageUrl = meta.ImageUrl, + LengthMinutes = meta.LengthMinutes, + Language = meta.Language, + ContentType = meta.ContentType, + ContentDeliveryType = meta.ContentDeliveryType, + BookFormat = meta.BookFormat, + Genres = meta.Genres, + Series = meta.Series, + Publisher = meta.Publisher, + Narrators = meta.Narrators, + ReleaseDate = meta.ReleaseDate, + Link = $"https://www.amazon.com/dp/{meta.Asin}" + }; + + return new AudibleSearchResponse { Results = new List { single }, TotalResults = 1 }; + } + + var response = await SearchProductsDirectAsync( + query: query, + title: null, + author: null, + narrator: null, + publisher: null, + page: page, + limit: limit, + region: region, + language: language, + sortBy: "Relevance"); + return ToSearchResponse(response); + } + + public async Task SearchProductsDirectAsync( + string? query, + string? title, + string? author, + string? narrator, + string? publisher, + int page, + int limit, + string region, + string? language, + string sortBy, + bool returnRawProducts = false) + { + var safeRegion = AudibleRequestHelper.NormalizeRegion(region); + + var result = await SearchProductsCoreAsync( + query, title, author, narrator, publisher, + page, limit, safeRegion, language, sortBy, returnRawProducts); + + if (result.Results.Count == 0) + { + var hasDiacritics = + HasDiacritics(query) || HasDiacritics(title) || + HasDiacritics(author) || HasDiacritics(narrator) || + HasDiacritics(publisher); + + if (hasDiacritics) + { + _logger.LogInformation("Retrying Audible search with diacritics stripped (region={Region})", safeRegion); + result = await SearchProductsCoreAsync( + AudibleRequestHelper.RemoveDiacritics(query ?? string.Empty), + AudibleRequestHelper.RemoveDiacritics(title ?? string.Empty), + AudibleRequestHelper.RemoveDiacritics(author ?? string.Empty), + AudibleRequestHelper.RemoveDiacritics(narrator ?? string.Empty), + AudibleRequestHelper.RemoveDiacritics(publisher ?? string.Empty), + page, limit, safeRegion, language, sortBy, returnRawProducts); + } + } + + return result; + } + + private async Task SearchProductsCoreAsync( + string? query, string? title, string? author, + string? narrator, string? publisher, + int page, int limit, string safeRegion, + string? language, string sortBy, bool returnRawProducts) + { + var parameters = new Dictionary + { + ["num_results"] = Math.Clamp(limit, 1, 50).ToString(), + ["page"] = Math.Max(0, page - 1).ToString(), + ["products_sort_by"] = string.IsNullOrWhiteSpace(sortBy) ? "Relevance" : sortBy, + ["response_groups"] = "media,contributors,series,product_attrs,product_desc,product_extended_attrs,category_ladders" + }; + + if (!string.IsNullOrWhiteSpace(query)) parameters["keywords"] = query; + if (!string.IsNullOrWhiteSpace(title)) parameters["title"] = title; + if (!string.IsNullOrWhiteSpace(author)) parameters["author"] = author; + if (!string.IsNullOrWhiteSpace(narrator)) parameters["narrator"] = narrator; + if (!string.IsNullOrWhiteSpace(publisher)) parameters["publisher"] = publisher; + + var url = $"{AudibleRequestHelper.BuildApiBaseUrl(safeRegion)}/1.0/catalog/products/?{AudibleRequestHelper.BuildQueryString(parameters)}"; + using var doc = await _apiClient.GetJsonDocumentAsync(url, safeRegion, includeLocaleHeaders: false, timeoutSeconds: 10); + if (doc == null) + { + return new SearchProductsDirectResponse(); + } + + var root = doc.RootElement; + var rawProducts = GetArray(root, "products") + .Where(product => product.ValueKind == JsonValueKind.Object) + .Select(product => product.Clone()) + .ToList(); + var results = rawProducts + .Select(product => AudibleProductMapper.MapProductToBookResponse(product, safeRegion)) + .Where(product => product != null) + .Select(product => AudibleProductMapper.MapBookResponseToSearchResult(product!)) + .Where(product => product != null) + .Cast() + .Where(product => !AudibleSearchResultFilter.IndicatesPodcast(product)) + .ToList(); + + results = AudibleProductMapper.ApplyLanguageFilter(results, language); + + return new SearchProductsDirectResponse + { + Results = results, + TotalResults = root.TryGetProperty("total_results", out var totalResultsElement) && totalResultsElement.TryGetInt32(out var totalResults) + ? totalResults + : results.Count, + RawProducts = returnRawProducts ? rawProducts : null + }; + } + + private static AudibleSearchResponse ToSearchResponse(SearchProductsDirectResponse response) + { + return new AudibleSearchResponse + { + Results = response.Results, + TotalResults = response.TotalResults + }; + } + + private static bool HasDiacritics(string? text) + { + if (string.IsNullOrEmpty(text)) return false; + return text != AudibleRequestHelper.RemoveDiacritics(text); + } + + private static bool IsAsin(string value) + { + if (string.IsNullOrEmpty(value)) return false; + if (value.Length != 10) return false; + if (!(value.StartsWith("B0", StringComparison.OrdinalIgnoreCase) || char.IsDigit(value[0]))) return false; + return value.All(char.IsLetterOrDigit); + } + + private static IEnumerable GetArray(JsonElement element, string propertyName) + { + return element.TryGetProperty(propertyName, out var value) && value.ValueKind == JsonValueKind.Array + ? value.EnumerateArray() + : Enumerable.Empty(); + } + } +} diff --git a/listenarr.application/Metadata/AudibleSearchResultFilter.cs b/listenarr.application/Metadata/AudibleSearchResultFilter.cs new file mode 100644 index 000000000..e45625493 --- /dev/null +++ b/listenarr.application/Metadata/AudibleSearchResultFilter.cs @@ -0,0 +1,62 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace Listenarr.Application.Metadata +{ + internal static class AudibleSearchResultFilter + { + public static bool IndicatesPodcast(AudibleSearchResult? result) + { + if (result == null) return false; + + var contentType = result.ContentType?.Trim(); + var deliveryType = result.ContentDeliveryType?.Trim(); + var contentTypeIsBookOrProduct = !string.IsNullOrWhiteSpace(contentType) && + (string.Equals(contentType, "Book", StringComparison.OrdinalIgnoreCase) || + string.Equals(contentType, "Product", StringComparison.OrdinalIgnoreCase)); + var allowedBookDelivery = new[] { "SinglePartBook", "MultiPartBook", "BookSeries" }; + var deliveryTypeIsBook = !string.IsNullOrWhiteSpace(deliveryType) && + allowedBookDelivery.Any(allowed => string.Equals(allowed, deliveryType, StringComparison.OrdinalIgnoreCase)); + if (contentTypeIsBookOrProduct || deliveryTypeIsBook) return false; + + if (!string.IsNullOrWhiteSpace(result.ContentType) && result.ContentType.IndexOf("podcast", StringComparison.OrdinalIgnoreCase) >= 0) return true; + if (!string.IsNullOrWhiteSpace(result.ContentDeliveryType) && result.ContentDeliveryType.IndexOf("podcast", StringComparison.OrdinalIgnoreCase) >= 0) return true; + if (!string.IsNullOrWhiteSpace(result.EpisodeType)) return true; + if (!string.IsNullOrWhiteSpace(result.Sku) && result.Sku.StartsWith("PC_", StringComparison.OrdinalIgnoreCase)) return true; + if (!string.IsNullOrWhiteSpace(result.BookFormat) && result.BookFormat.IndexOf("podcast", StringComparison.OrdinalIgnoreCase) >= 0) return true; + if (result.Genres?.Any(genre => + (!string.IsNullOrWhiteSpace(genre?.Name) && genre.Name.IndexOf("podcast", StringComparison.OrdinalIgnoreCase) >= 0) || + (!string.IsNullOrWhiteSpace(genre?.Type) && genre.Type.IndexOf("podcast", StringComparison.OrdinalIgnoreCase) >= 0)) == true) return true; + return false; + } + + public static string? GetPodcastFilterReason(AudibleSearchResult? result) + { + if (result == null) return null; + if (!string.IsNullOrWhiteSpace(result.ContentType) && result.ContentType.IndexOf("podcast", StringComparison.OrdinalIgnoreCase) >= 0) return "ContentType contains 'podcast'"; + if (!string.IsNullOrWhiteSpace(result.ContentDeliveryType) && result.ContentDeliveryType.IndexOf("podcast", StringComparison.OrdinalIgnoreCase) >= 0) return "ContentDeliveryType contains 'podcast'"; + if (!string.IsNullOrWhiteSpace(result.EpisodeType)) return "EpisodeType present"; + if (!string.IsNullOrWhiteSpace(result.Sku) && result.Sku.StartsWith("PC_", StringComparison.OrdinalIgnoreCase)) return "SKU starts with PC_"; + if (!string.IsNullOrWhiteSpace(result.BookFormat) && result.BookFormat.IndexOf("podcast", StringComparison.OrdinalIgnoreCase) >= 0) return "BookFormat contains 'podcast'"; + if (result.Genres?.Any(genre => + (!string.IsNullOrWhiteSpace(genre?.Name) && genre.Name.IndexOf("podcast", StringComparison.OrdinalIgnoreCase) >= 0) || + (!string.IsNullOrWhiteSpace(genre?.Type) && genre.Type.IndexOf("podcast", StringComparison.OrdinalIgnoreCase) >= 0)) == true) return "Genre contains 'podcast'"; + return null; + } + } +} diff --git a/listenarr.application/Metadata/AudibleSeriesWorkflow.cs b/listenarr.application/Metadata/AudibleSeriesWorkflow.cs new file mode 100644 index 000000000..76443b1f4 --- /dev/null +++ b/listenarr.application/Metadata/AudibleSeriesWorkflow.cs @@ -0,0 +1,346 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +using System.Globalization; +using System.Text.Json; +using Listenarr.Application.Security; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Application.Metadata +{ + internal sealed class AudibleSeriesWorkflow + { + private const string DefaultSeriesResponseGroups = + "relationships,product_attrs,product_desc,product_extended_attrs"; + + private readonly AudibleApiClient _apiClient; + private readonly AudibleProductMetadataWorkflow _metadataWorkflow; + private readonly AudibleProductSearchWorkflow _productSearchWorkflow; + private readonly ILogger _logger; + + public AudibleSeriesWorkflow( + AudibleApiClient apiClient, + AudibleProductMetadataWorkflow metadataWorkflow, + AudibleProductSearchWorkflow productSearchWorkflow, + ILogger logger) + { + _apiClient = apiClient; + _metadataWorkflow = metadataWorkflow; + _productSearchWorkflow = productSearchWorkflow; + _logger = logger; + } + + public async Task SearchSeriesByNameAsync(string name, string region) + { + try + { + return await LookupSeriesItemsAsync(name, region); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogError(ex, "Error searching Audible series for name {Name}", LogRedaction.SanitizeText(name)); + return null; + } + } + + public async Task LookupSeriesAsync(string seriesName, string region) + { + if (string.IsNullOrWhiteSpace(seriesName)) + { + return null; + } + + try + { + var items = await LookupSeriesItemsAsync(seriesName, region); + return items.FirstOrDefault(item => + !string.IsNullOrWhiteSpace(item.Asin) && + string.Equals(item.Region, region, StringComparison.OrdinalIgnoreCase)) + ?? items.FirstOrDefault(item => !string.IsNullOrWhiteSpace(item.Asin)) + ?? items.FirstOrDefault(); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning(ex, "Failed to lookup series {Series}", LogRedaction.SanitizeText(seriesName)); + return null; + } + } + + public async Task GetSeriesByAsinAsync(string seriesAsin, string region) + { + if (string.IsNullOrWhiteSpace(seriesAsin)) + { + return null; + } + + try + { + using var doc = await _apiClient.GetProductDocumentAsync(seriesAsin, region, DefaultSeriesResponseGroups); + if (doc == null || + !doc.RootElement.TryGetProperty("product", out var product) || + product.ValueKind != JsonValueKind.Object) + { + return null; + } + + return new SeriesLookupItem + { + Asin = GetString(product, "asin") ?? seriesAsin, + Name = GetString(product, "title"), + Region = AudibleRequestHelper.NormalizeRegion(region), + Description = GetString(product, "publisher_summary") ?? GetString(product, "extended_product_description"), + Image = GetHighestResolutionImage(product) + }; + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning(ex, "Failed to lookup Audible series details by ASIN {SeriesAsin}", LogRedaction.SanitizeText(seriesAsin)); + return null; + } + } + + public async Task GetBooksBySeriesAsinAsync(string seriesAsin, string region) + { + try + { + return await GetTypedBooksBySeriesAsinAsync(seriesAsin, region); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogError(ex, "Error fetching Audible series books for ASIN {Asin}", LogRedaction.SanitizeText(seriesAsin)); + return null; + } + } + + public async Task?> GetTypedBooksBySeriesAsinAsync(string seriesAsin, string region) + { + if (string.IsNullOrWhiteSpace(seriesAsin)) + { + return null; + } + + try + { + using var doc = await _apiClient.GetProductDocumentAsync(seriesAsin, region, DefaultSeriesResponseGroups); + if (doc == null || + !doc.RootElement.TryGetProperty("product", out var product) || + product.ValueKind != JsonValueKind.Object) + { + _logger.LogWarning("GetTypedBooksBySeriesAsinAsync: No product document for series ASIN {Asin} (doc={DocNull})", LogRedaction.SanitizeText(seriesAsin), doc == null); + return null; + } + + if (!product.TryGetProperty("relationships", out var relationships) || + relationships.ValueKind != JsonValueKind.Array) + { + _logger.LogWarning("GetTypedBooksBySeriesAsinAsync: No relationships array for series ASIN {Asin}. Product has properties: {Props}", + LogRedaction.SanitizeText(seriesAsin), + string.Join(", ", product.EnumerateObject().Select(p => p.Name).Take(15))); + return new List(); + } + + var relationshipEntries = relationships.EnumerateArray() + .Select(item => new + { + Asin = GetString(item, "asin"), + Position = GetString(item, "sequence") ?? GetString(item, "sort") + }) + .Where(item => !string.IsNullOrWhiteSpace(item.Asin)) + .GroupBy(item => item.Asin!, StringComparer.OrdinalIgnoreCase) + .Select(group => group.First()) + .OrderBy(item => ParseSeriesPosition(item.Position)) + .ToList(); + + _logger.LogInformation("GetTypedBooksBySeriesAsinAsync: Series ASIN {Asin} has {Count} relationship entries", LogRedaction.SanitizeText(seriesAsin), relationshipEntries.Count); + + var books = await _metadataWorkflow.GetBooksMetadataByAsinsAsync( + relationshipEntries.Select(item => item.Asin!), + region); + + _logger.LogInformation("GetTypedBooksBySeriesAsinAsync: Fetched metadata for {FetchedCount}/{TotalCount} books from series {Asin}", + books.Count, relationshipEntries.Count, LogRedaction.SanitizeText(seriesAsin)); + + var booksByAsin = books + .Where(book => !string.IsNullOrWhiteSpace(book.Asin)) + .ToDictionary(book => book.Asin!, StringComparer.OrdinalIgnoreCase); + + var results = new List(); + foreach (var relationship in relationshipEntries) + { + if (!booksByAsin.TryGetValue(relationship.Asin!, out var book)) + { + continue; + } + + var mapped = AudibleProductMapper.MapBookResponseToSearchResult(book); + if (mapped == null) + { + continue; + } + + if (mapped.Series?.Any() == true) + { + foreach (var series in mapped.Series.Where(series => + string.Equals(series.Asin, seriesAsin, StringComparison.OrdinalIgnoreCase) && + string.IsNullOrWhiteSpace(series.Position))) + { + series.Position = relationship.Position; + } + } + + results.Add(mapped); + } + + return results; + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogError(ex, "Error fetching Audible typed series books for ASIN {Asin}", LogRedaction.SanitizeText(seriesAsin)); + return null; + } + } + + public async Task> LookupSeriesItemsAsync(string seriesName, string region) + { + var responses = new List(); + + responses.Add(await _productSearchWorkflow.SearchProductsDirectAsync( + query: null, + title: seriesName, + author: null, + narrator: null, + publisher: null, + page: 1, + limit: 25, + region: region, + language: null, + sortBy: "Title", + returnRawProducts: true)); + + responses.Add(await _productSearchWorkflow.SearchProductsDirectAsync( + query: seriesName, + title: null, + author: null, + narrator: null, + publisher: null, + page: 1, + limit: 25, + region: region, + language: null, + sortBy: "Relevance", + returnRawProducts: true)); + + _logger.LogInformation("LookupSeriesItemsAsync '{SeriesName}' region={Region}: title search returned {TitleCount} raw products, query search returned {QueryCount} raw products", + LogRedaction.SanitizeText(seriesName), LogRedaction.SanitizeText(region), + responses.ElementAtOrDefault(0)?.RawProducts?.Count ?? 0, + responses.ElementAtOrDefault(1)?.RawProducts?.Count ?? 0); + + var normalizedSeries = seriesName.Trim(); + var compareInfo = CultureInfo.InvariantCulture.CompareInfo; + const CompareOptions diacriticIgnore = CompareOptions.IgnoreCase | CompareOptions.IgnoreNonSpace; + + var allSeriesItems = responses + .SelectMany(response => response.RawProducts ?? new List()) + .SelectMany(product => + { + var productImage = GetHighestResolutionImage(product); + return GetArray(product, "series") + .Select(series => new SeriesLookupItem + { + Asin = GetString(series, "asin"), + Name = GetString(series, "title"), + Position = GetString(series, "sequence"), + Region = AudibleRequestHelper.NormalizeRegion(region), + Image = productImage + }); + }) + .Where(item => !string.IsNullOrWhiteSpace(item.Name)) + .ToList(); + + _logger.LogInformation("LookupSeriesItemsAsync '{SeriesName}': extracted {Count} series items from raw products. Unique names: {Names}", + LogRedaction.SanitizeText(seriesName), allSeriesItems.Count, + string.Join(", ", allSeriesItems.Select(i => i.Name).Distinct(StringComparer.OrdinalIgnoreCase).Take(10))); + + var matched = allSeriesItems + .Where(item => + compareInfo.Compare(item.Name, normalizedSeries, diacriticIgnore) == 0 || + compareInfo.IndexOf(item.Name!, normalizedSeries, diacriticIgnore) >= 0 || + compareInfo.IndexOf(normalizedSeries, item.Name!, diacriticIgnore) >= 0) + .GroupBy(item => $"{item.Asin}|{item.Name}", StringComparer.OrdinalIgnoreCase) + .Select(group => group.First()) + .OrderBy(item => compareInfo.Compare(item.Name, normalizedSeries, diacriticIgnore) == 0 ? 0 : 1) + .ToList(); + + _logger.LogInformation("LookupSeriesItemsAsync '{SeriesName}': {MatchCount} series items matched after name filter", + LogRedaction.SanitizeText(seriesName), matched.Count); + + return matched; + } + + private static IEnumerable GetArray(JsonElement element, string propertyName) + { + return element.TryGetProperty(propertyName, out var value) && value.ValueKind == JsonValueKind.Array + ? value.EnumerateArray() + : Enumerable.Empty(); + } + + private static string? GetString(JsonElement element, params string[] path) + { + var current = element; + foreach (var segment in path) + { + if (current.ValueKind != JsonValueKind.Object || + !current.TryGetProperty(segment, out current)) + { + return null; + } + } + + return current.ValueKind switch + { + JsonValueKind.String => current.GetString(), + JsonValueKind.Number => current.ToString(), + JsonValueKind.True => bool.TrueString.ToLowerInvariant(), + JsonValueKind.False => bool.FalseString.ToLowerInvariant(), + _ => null + }; + } + + private static string? GetHighestResolutionImage(JsonElement product) + { + if (product.TryGetProperty("product_images", out var images) && images.ValueKind == JsonValueKind.Object) + { + var bestKey = images.EnumerateObject() + .Select(property => new { property.Name, Numeric = int.TryParse(property.Name, out var size) ? size : 0 }) + .OrderByDescending(property => property.Numeric) + .FirstOrDefault(); + if (bestKey != null && images.TryGetProperty(bestKey.Name, out var imageValue)) + { + return imageValue.GetString(); + } + } + + return GetString(product, "cover_art_url"); + } + + private static decimal ParseSeriesPosition(string? rawPosition) + { + return decimal.TryParse(rawPosition, out var parsed) ? parsed : decimal.MaxValue; + } + } +} diff --git a/listenarr.application/Metadata/AudibleService.cs b/listenarr.application/Metadata/AudibleService.cs index 6680484e9..b5cf7936e 100644 --- a/listenarr.application/Metadata/AudibleService.cs +++ b/listenarr.application/Metadata/AudibleService.cs @@ -26,22 +26,13 @@ namespace Listenarr.Application.Metadata { public class AudibleService { - private const string BrowserAcceptHeader = "application/json, text/plain, */*"; - private const string BrowserAcceptLanguageHeader = "en-US,en;q=0.9"; - private const string BrowserUserAgent = - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"; - private const string AudibleApiAcceptHeader = "application/json"; - private const string AudibleApiUserAgent = - "Dalvik/2.1.0 (Linux; U; Android 15); com.audible.application"; - private const string AudibleApiVerboseUserAgent = - "Dalvik/2.1.0 (Linux; U; Android 15; good_phone Build/AAAA.240000.005); com.audible.application"; - private const string DefaultBookResponseGroups = - "media,product_attrs,product_desc,product_details,product_extended_attrs,product_plans,rating,series,relationships,review_attrs,category_ladders,customer_rights"; - private const string DefaultSeriesResponseGroups = - "relationships,product_attrs,product_desc,product_extended_attrs"; - private readonly HttpClient _httpClient; private readonly ILogger _logger; - private readonly IAudibleAuthorPageParser? _authorPageParser; + private readonly AudibleApiClient _apiClient; + private readonly AudibleProductMetadataWorkflow _metadataWorkflow; + private readonly AudibleProductSearchWorkflow _productSearchWorkflow; + private readonly AudibleAuthorLookupWorkflow _authorLookupWorkflow; + private readonly AudibleAuthorCatalogWorkflow _authorCatalogWorkflow; + private readonly AudibleSeriesWorkflow _seriesWorkflow; public AudibleService(HttpClient httpClient, ILogger logger) : this(httpClient, logger, null) @@ -51,15 +42,21 @@ public AudibleService(HttpClient httpClient, ILogger logger) [ActivatorUtilitiesConstructor] public AudibleService(HttpClient httpClient, ILogger logger, IAudibleAuthorPageParser? authorPageParser) { - _httpClient = httpClient; _logger = logger; - _authorPageParser = authorPageParser; - _httpClient.DefaultRequestHeaders.Accept.Clear(); - _httpClient.DefaultRequestHeaders.Accept.ParseAdd(BrowserAcceptHeader); - _httpClient.DefaultRequestHeaders.AcceptLanguage.Clear(); - _httpClient.DefaultRequestHeaders.AcceptLanguage.ParseAdd(BrowserAcceptLanguageHeader); - _httpClient.DefaultRequestHeaders.UserAgent.Clear(); - _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd(BrowserUserAgent); + _apiClient = new AudibleApiClient(httpClient, _logger); + _metadataWorkflow = new AudibleProductMetadataWorkflow(_apiClient, _logger); + _productSearchWorkflow = new AudibleProductSearchWorkflow(_apiClient, GetBookMetadataAsync, _logger); + _authorLookupWorkflow = new AudibleAuthorLookupWorkflow(_apiClient, _productSearchWorkflow, _logger); + _seriesWorkflow = new AudibleSeriesWorkflow(_apiClient, _metadataWorkflow, _productSearchWorkflow, _logger); + _authorCatalogWorkflow = new AudibleAuthorCatalogWorkflow( + authorPageParser, + SearchProductsDirectAsync, + GetBookMetadataAsync, + GetWithTimeoutAsync, + _apiClient, + _metadataWorkflow, + _authorLookupWorkflow, + _logger); } /// @@ -73,353 +70,43 @@ public AudibleService(HttpClient httpClient, ILogger logger, IAu /// AudibleSearchResponse containing books by the author. public virtual async Task GetBooksByAuthorAsinAsync(string authorAsin, int page = 1, int limit = 50, string region = "us", string? language = null) { - try - { - if (string.IsNullOrWhiteSpace(authorAsin)) - { - return null; - } - - var requestedPage = Math.Max(1, page); - var pageSize = Math.Clamp(limit, 1, 500); - var desiredSkip = (requestedPage - 1) * pageSize; - var desiredTake = pageSize; - var collectedAsins = new List(); - string? continuationToken = null; - var iteration = 0; - - while (iteration < 10 && collectedAsins.Count < desiredSkip + desiredTake) - { - iteration++; - var tokenQuery = string.IsNullOrWhiteSpace(continuationToken) - ? string.Empty - : $"&pageSectionContinuationToken={Uri.EscapeDataString(continuationToken)}"; - var authorPageUrl = - $"{AudibleRequestHelper.BuildApiBaseUrl(region)}/1.0/screens/audible-android-author-detail/{Uri.EscapeDataString(authorAsin)}" + - $"?tabId=titles&author_asin={Uri.EscapeDataString(authorAsin)}&title_source=all" + - $"&session_id={Uri.EscapeDataString(AudibleRequestHelper.GenerateRandomSessionId())}" + - $"&applicationType=Android_App&local_time={Uri.EscapeDataString(DateTime.UtcNow.ToString("O"))}" + - $"&response_groups=always-returned&surface=Android{tokenQuery}"; - - using var authorPageDoc = await GetAudibleJsonDocumentAsync( - authorPageUrl, - region, - includeLocaleHeaders: true, - timeoutSeconds: 10); - if (authorPageDoc == null) - { - break; - } - - var root = authorPageDoc.RootElement; - if (!root.TryGetProperty("sections", out var sections) || sections.ValueKind != JsonValueKind.Array) - { - break; - } - - continuationToken = null; - foreach (var section in sections.EnumerateArray()) - { - if (!section.TryGetProperty("model", out var model) || - !model.TryGetProperty("rows", out var rows) || - rows.ValueKind != JsonValueKind.Array) - { - continue; - } - - collectedAsins.AddRange( - rows.EnumerateArray() - .Select(row => GetString(row, "product_metadata", "asin")) - .Where(asin => !string.IsNullOrWhiteSpace(asin))!); - - continuationToken = GetString(section, "pagination"); - if (rows.GetArrayLength() > 0) - { - break; - } - } - - if (string.IsNullOrWhiteSpace(continuationToken)) - { - break; - } - } - - var pagedAsins = collectedAsins - .Distinct(StringComparer.OrdinalIgnoreCase) - .Skip(desiredSkip) - .Take(desiredTake) - .ToList(); - if (pagedAsins.Count == 0) - { - return new AudibleSearchResponse - { - Results = new List(), - TotalResults = collectedAsins.Distinct(StringComparer.OrdinalIgnoreCase).Count() - }; - } - - var books = await GetBooksMetadataByAsinsAsync(pagedAsins, region); - var mapped = books - .Where(book => book != null) - .Select(AudibleProductMapper.MapBookResponseToSearchResult) - .Where(book => book != null) - .Cast() - .ToList(); - - mapped = AudibleProductMapper.ApplyLanguageFilter(mapped, language); - - return new AudibleSearchResponse - { - Results = mapped, - TotalResults = collectedAsins.Distinct(StringComparer.OrdinalIgnoreCase).Count() - }; - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogError(ex, "Error fetching books for author ASIN {AuthorAsin}", LogRedaction.SanitizeText(authorAsin)); - return null; - } + return await _authorCatalogWorkflow.GetBooksByAuthorAsinAsync(authorAsin, page, limit, region, language); } // Series lookup helpers (proxy audible /series endpoints) public virtual async Task SearchSeriesByNameAsync(string name, string region = "us") { - try - { - return await LookupSeriesItemsAsync(name, region); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogError(ex, "Error searching Audible series for name {Name}", LogRedaction.SanitizeText(name)); - return null; - } + return await _seriesWorkflow.SearchSeriesByNameAsync(name, region); } public virtual async Task LookupSeriesAsync(string seriesName, string region = "us") { - if (string.IsNullOrWhiteSpace(seriesName)) - { - return null; - } - - try - { - var items = await LookupSeriesItemsAsync(seriesName, region); - return items.FirstOrDefault(item => - !string.IsNullOrWhiteSpace(item.Asin) && - string.Equals(item.Region, region, StringComparison.OrdinalIgnoreCase)) - ?? items.FirstOrDefault(item => !string.IsNullOrWhiteSpace(item.Asin)) - ?? items.FirstOrDefault(); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Failed to lookup series {Series}", LogRedaction.SanitizeText(seriesName)); - return null; - } + return await _seriesWorkflow.LookupSeriesAsync(seriesName, region); } public virtual async Task GetSeriesByAsinAsync(string seriesAsin, string region = "us") { - if (string.IsNullOrWhiteSpace(seriesAsin)) - { - return null; - } - - try - { - using var doc = await GetAudibleProductDocumentAsync(seriesAsin, region, DefaultSeriesResponseGroups); - if (doc == null || - !doc.RootElement.TryGetProperty("product", out var product) || - product.ValueKind != JsonValueKind.Object) - { - return null; - } - - return new SeriesLookupItem - { - Asin = GetString(product, "asin") ?? seriesAsin, - Name = GetString(product, "title"), - Region = AudibleRequestHelper.NormalizeRegion(region), - Description = GetString(product, "publisher_summary") ?? GetString(product, "extended_product_description"), - Image = GetHighestResolutionImage(product) - }; - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Failed to lookup Audible series details by ASIN {SeriesAsin}", LogRedaction.SanitizeText(seriesAsin)); - return null; - } + return await _seriesWorkflow.GetSeriesByAsinAsync(seriesAsin, region); } public virtual async Task GetBooksBySeriesAsinAsync(string seriesAsin, string region = "us") { - try - { - return await GetTypedBooksBySeriesAsinAsync(seriesAsin, region); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogError(ex, "Error fetching Audible series books for ASIN {Asin}", LogRedaction.SanitizeText(seriesAsin)); - return null; - } + return await _seriesWorkflow.GetBooksBySeriesAsinAsync(seriesAsin, region); } public virtual async Task?> GetTypedBooksBySeriesAsinAsync(string seriesAsin, string region = "us") { - if (string.IsNullOrWhiteSpace(seriesAsin)) - { - return null; - } - - try - { - using var doc = await GetAudibleProductDocumentAsync(seriesAsin, region, DefaultSeriesResponseGroups); - if (doc == null || - !doc.RootElement.TryGetProperty("product", out var product) || - product.ValueKind != JsonValueKind.Object) - { - _logger.LogWarning("GetTypedBooksBySeriesAsinAsync: No product document for series ASIN {Asin} (doc={DocNull})", LogRedaction.SanitizeText(seriesAsin), doc == null); - return null; - } - - if (!product.TryGetProperty("relationships", out var relationships) || - relationships.ValueKind != JsonValueKind.Array) - { - _logger.LogWarning("GetTypedBooksBySeriesAsinAsync: No relationships array for series ASIN {Asin}. Product has properties: {Props}", - LogRedaction.SanitizeText(seriesAsin), - string.Join(", ", product.EnumerateObject().Select(p => p.Name).Take(15))); - return new List(); - } - - var relationshipEntries = relationships.EnumerateArray() - .Select(item => new - { - Asin = GetString(item, "asin"), - Position = GetString(item, "sequence") ?? GetString(item, "sort") - }) - .Where(item => !string.IsNullOrWhiteSpace(item.Asin)) - .GroupBy(item => item.Asin!, StringComparer.OrdinalIgnoreCase) - .Select(group => group.First()) - .OrderBy(item => ParseSeriesPosition(item.Position)) - .ToList(); - - _logger.LogInformation("GetTypedBooksBySeriesAsinAsync: Series ASIN {Asin} has {Count} relationship entries", LogRedaction.SanitizeText(seriesAsin), relationshipEntries.Count); - - var books = await GetBooksMetadataByAsinsAsync( - relationshipEntries.Select(item => item.Asin!), - region); - - _logger.LogInformation("GetTypedBooksBySeriesAsinAsync: Fetched metadata for {FetchedCount}/{TotalCount} books from series {Asin}", - books.Count, relationshipEntries.Count, LogRedaction.SanitizeText(seriesAsin)); - - var booksByAsin = books - .Where(book => !string.IsNullOrWhiteSpace(book.Asin)) - .ToDictionary(book => book.Asin!, StringComparer.OrdinalIgnoreCase); - - var results = new List(); - foreach (var relationship in relationshipEntries) - { - if (!booksByAsin.TryGetValue(relationship.Asin!, out var book)) - { - continue; - } - - var mapped = AudibleProductMapper.MapBookResponseToSearchResult(book); - if (mapped == null) - { - continue; - } - - if (mapped.Series?.Any() == true) - { - foreach (var series in mapped.Series.Where(series => - string.Equals(series.Asin, seriesAsin, StringComparison.OrdinalIgnoreCase) && - string.IsNullOrWhiteSpace(series.Position))) - { - series.Position = relationship.Position; - } - } - - results.Add(mapped); - } - - return results; - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogError(ex, "Error fetching Audible typed series books for ASIN {Asin}", LogRedaction.SanitizeText(seriesAsin)); - return null; - } + return await _seriesWorkflow.GetTypedBooksBySeriesAsinAsync(seriesAsin, region); } public virtual async Task GetBookMetadataAsync(string asin, string region = "us", bool useCache = true, string? language = null) { - try - { - var result = (await GetBooksMetadataByAsinsAsync(new[] { asin }, region)).FirstOrDefault(); - if (result != null && - !string.IsNullOrWhiteSpace(language) && - !string.Equals(result.Language, language, StringComparison.OrdinalIgnoreCase)) - { - return null; - } - - return result; - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogError(ex, "Error fetching metadata from Audible for ASIN {Asin}", LogRedaction.SanitizeText(asin)); - return null; - } + return await _metadataWorkflow.GetBookMetadataAsync(asin, region, language); } public virtual async Task SearchByTitleAsync(string title, int page = 1, int limit = 50, string region = "us", string? language = null) { - var normalizedTitle = title?.Trim(); - if (string.IsNullOrWhiteSpace(normalizedTitle)) - { - return new AudibleSearchResponse - { - Results = new List(), - TotalResults = 0 - }; - } - - var response = await SearchProductsDirectAsync( - query: normalizedTitle, - title: null, - author: null, - narrator: null, - publisher: null, - page: page, - limit: limit, - region: region, - language: language, - sortBy: "Relevance"); - - if (response.Results.Count > 0) - { - return ToSearchResponse(response); - } - - _logger.LogInformation( - "Audible keyword title search returned no results for '{Title}' in region {Region}; retrying title-field search", - LogRedaction.SanitizeText(normalizedTitle), - AudibleRequestHelper.NormalizeRegion(region)); - - var titleFieldResponse = await SearchProductsDirectAsync( - query: null, - title: normalizedTitle, - author: null, - narrator: null, - publisher: null, - page: page, - limit: limit, - region: region, - language: language, - sortBy: "Title"); - return ToSearchResponse(titleFieldResponse); + return await _productSearchWorkflow.SearchByTitleAsync(title, page, limit, region, language); } public virtual async Task SearchByTitleAndAuthorAsync(string title, string author, int page = 1, int limit = 50, string region = "us", string? language = null) @@ -528,21 +215,12 @@ public AudibleService(HttpClient httpClient, ILogger logger, IAu public virtual async Task SearchByAuthorAsync(string author, int page = 1, int limit = 50, string region = "us", string? language = null) { - var authorLookupItems = await LookupAuthorItemsAsync(author, region, language); - var authorAsin = authorLookupItems.FirstOrDefault(a => !string.IsNullOrWhiteSpace(a.Asin))?.Asin; - if (string.IsNullOrWhiteSpace(authorAsin)) - { - _logger.LogWarning("No author ASIN found for author '{Author}'", LogRedaction.SanitizeText(author)); - return null; - } - - return await GetBooksByResolvedAuthorAsync(author, authorAsin, page, limit, region, language); + return await _authorCatalogWorkflow.SearchByAuthorAsync(author, page, limit, region, language); } public virtual async Task GetBooksByAuthorAsync(string author, string authorAsin, int page = 1, int limit = 50, string region = "us", string? language = null) { - if (string.IsNullOrWhiteSpace(authorAsin)) return null; - return await GetBooksByResolvedAuthorAsync(author, authorAsin, page, limit, region, language); + return await _authorCatalogWorkflow.GetBooksByAuthorAsync(author, authorAsin, page, limit, region, language); } public virtual async Task GetAllBooksByAuthorAsync(string author, string authorAsin, int limit = 250, string region = "us", string? language = null) @@ -552,30 +230,7 @@ public AudibleService(HttpClient httpClient, ILogger logger, IAu return null; } - var directResults = await GetDirectAuthorCatalogResultsAsync(author, authorAsin, region, language); - if (directResults.Count > 0) - { - var cappedLimit = Math.Clamp(limit, 1, 500); - return new AudibleSearchResponse - { - Results = directResults.Take(cappedLimit).ToList(), - TotalResults = directResults.Count - }; - } - - var fallbackLimit = Math.Clamp(limit, 1, 500); - var authorScreenResult = await GetBooksByAuthorAsinAsync(authorAsin, 1, fallbackLimit, region, language); - if (authorScreenResult?.Results?.Count > 0) - { - return authorScreenResult; - } - - _logger.LogWarning( - "Direct Audible author catalog lookup returned no results for author {Author} (ASIN {AuthorAsin}); falling back to Audible author page scraping", - LogRedaction.SanitizeText(author), - LogRedaction.SanitizeText(authorAsin)); - - return await ScrapeAudibleAuthorPageAsync(author, authorAsin, 1, fallbackLimit, region, language); + return await _authorCatalogWorkflow.GetAllBooksByAuthorAsync(author, authorAsin, limit, region, language); } /// @@ -583,34 +238,7 @@ public AudibleService(HttpClient httpClient, ILogger logger, IAu /// public virtual async Task LookupAuthorAsync(string author, string region = "us") { - if (string.IsNullOrWhiteSpace(author)) return null; - - try - { - var authorLookupItems = await LookupAuthorItemsAsync(author, region); - var candidate = authorLookupItems.FirstOrDefault(a => !string.IsNullOrWhiteSpace(a.Asin)) ?? authorLookupItems.FirstOrDefault(); - if (candidate == null) - { - return null; - } - - if (!string.IsNullOrWhiteSpace(candidate.Asin) && - (string.IsNullOrWhiteSpace(candidate.Image) || string.IsNullOrWhiteSpace(candidate.Description))) - { - var detailed = await GetAuthorByAsinAsync(candidate.Asin, region); - if (detailed != null) - { - return detailed; - } - } - - return candidate; - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Failed to lookup author {Author}", LogRedaction.SanitizeText(author)); - return null; - } + return await _authorLookupWorkflow.LookupAuthorAsync(author, region); } /// @@ -618,166 +246,17 @@ public AudibleService(HttpClient httpClient, ILogger logger, IAu /// public virtual async Task GetAuthorByAsinAsync(string authorAsin, string region = "us") { - if (string.IsNullOrWhiteSpace(authorAsin)) return null; - - try - { - var locale = AudibleRequestHelper.GetLocale(region); - var url = - $"{AudibleRequestHelper.BuildApiBaseUrl(region)}/1.0/catalog/contributors/{Uri.EscapeDataString(authorAsin)}" + - $"?locale={Uri.EscapeDataString(locale)}"; - using var doc = await GetAudibleJsonDocumentAsync(url, region, includeLocaleHeaders: true, timeoutSeconds: 10); - if (doc == null || - !doc.RootElement.TryGetProperty("contributor", out var contributor) || - contributor.ValueKind != JsonValueKind.Object) - { - return null; - } - - return new AuthorLookupItem - { - Asin = GetString(contributor, "contributor_id") ?? authorAsin, - Name = GetString(contributor, "name"), - Image = GetString(contributor, "profile_image_url"), - Region = AudibleRequestHelper.NormalizeRegion(region), - Description = GetString(contributor, "bio") - }; - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Failed to lookup Audible author details by ASIN {AuthorAsin}", LogRedaction.SanitizeText(authorAsin)); - return null; - } + return await _authorLookupWorkflow.GetAuthorByAsinAsync(authorAsin, region); } private async Task> LookupAuthorItemsAsync(string author, string region = "us", string? language = null) { - var response = await SearchProductsDirectAsync( - query: null, - title: null, - author: author, - narrator: null, - publisher: null, - page: 1, - limit: 10, - region: region, - language: language, - sortBy: "Relevance", - returnRawProducts: true); - - if (response.RawProducts == null || response.RawProducts.Count == 0) - { - return new List(); - } - - var normalizedAuthor = author.Trim(); - var compareInfo = CultureInfo.InvariantCulture.CompareInfo; - const CompareOptions diacriticIgnore = CompareOptions.IgnoreCase | CompareOptions.IgnoreNonSpace; - return response.RawProducts - .SelectMany(product => - GetArray(product, "authors") - .Select(authorItem => new AuthorLookupItem - { - Asin = GetString(authorItem, "asin"), - Name = GetString(authorItem, "name"), - Region = AudibleRequestHelper.NormalizeRegion(region) - })) - .Where(item => !string.IsNullOrWhiteSpace(item.Name)) - .Where(item => - compareInfo.Compare(item.Name, normalizedAuthor, diacriticIgnore) == 0 || - compareInfo.IndexOf(item.Name!, normalizedAuthor, diacriticIgnore) >= 0 || - compareInfo.IndexOf(normalizedAuthor, item.Name!, diacriticIgnore) >= 0) - .GroupBy(item => $"{item.Asin}|{item.Name}", StringComparer.OrdinalIgnoreCase) - .Select(group => group.First()) - .ToList(); + return await _authorLookupWorkflow.LookupAuthorItemsAsync(author, region, language); } private async Task> LookupSeriesItemsAsync(string seriesName, string region = "us") { - var responses = new List(); - - // First try title search — finds products whose title matches the series name - responses.Add(await SearchProductsDirectAsync( - query: null, - title: seriesName, - author: null, - narrator: null, - publisher: null, - page: 1, - limit: 25, - region: region, - language: null, - sortBy: "Title", - returnRawProducts: true)); - - // Always also run a keyword query search — this finds products that *belong* to - // the series even when no product title contains the series name (e.g. searching - // "Fjällbacka Mysteries" finds "The Hidden Child" which has the series in its metadata) - responses.Add(await SearchProductsDirectAsync( - query: seriesName, - title: null, - author: null, - narrator: null, - publisher: null, - page: 1, - limit: 25, - region: region, - language: null, - sortBy: "Relevance", - returnRawProducts: true)); - - _logger.LogInformation("LookupSeriesItemsAsync '{SeriesName}' region={Region}: title search returned {TitleCount} raw products, query search returned {QueryCount} raw products", - LogRedaction.SanitizeText(seriesName), LogRedaction.SanitizeText(region), - responses.ElementAtOrDefault(0)?.RawProducts?.Count ?? 0, - responses.ElementAtOrDefault(1)?.RawProducts?.Count ?? 0); - - var normalizedSeries = seriesName.Trim(); - var compareInfo = CultureInfo.InvariantCulture.CompareInfo; - const CompareOptions diacriticIgnore = CompareOptions.IgnoreCase | CompareOptions.IgnoreNonSpace; - - var allSeriesItems = responses - .SelectMany(response => response.RawProducts ?? new List()) - .SelectMany(product => - { - var productImage = GetHighestResolutionImage(product); - return GetArray(product, "series") - .Select(series => new SeriesLookupItem - { - Asin = GetString(series, "asin"), - Name = GetString(series, "title"), - Position = GetString(series, "sequence"), - Region = AudibleRequestHelper.NormalizeRegion(region), - Image = productImage - }); - }) - .Where(item => !string.IsNullOrWhiteSpace(item.Name)) - .ToList(); - - _logger.LogInformation("LookupSeriesItemsAsync '{SeriesName}': extracted {Count} series items from raw products. Unique names: {Names}", - LogRedaction.SanitizeText(seriesName), allSeriesItems.Count, - string.Join(", ", allSeriesItems.Select(i => i.Name).Distinct(StringComparer.OrdinalIgnoreCase).Take(10))); - - var matched = allSeriesItems - .Where(item => - compareInfo.Compare(item.Name, normalizedSeries, diacriticIgnore) == 0 || - compareInfo.IndexOf(item.Name!, normalizedSeries, diacriticIgnore) >= 0 || - compareInfo.IndexOf(normalizedSeries, item.Name!, diacriticIgnore) >= 0) - .GroupBy(item => $"{item.Asin}|{item.Name}", StringComparer.OrdinalIgnoreCase) - .Select(group => group.First()) - .OrderBy(item => compareInfo.Compare(item.Name, normalizedSeries, diacriticIgnore) == 0 ? 0 : 1) - .ToList(); - - _logger.LogInformation("LookupSeriesItemsAsync '{SeriesName}': {MatchCount} series items matched after name filter", - LogRedaction.SanitizeText(seriesName), matched.Count); - - return matched; - } - - private sealed class SearchProductsDirectResponse - { - public List Results { get; set; } = new(); - public int TotalResults { get; set; } - public List? RawProducts { get; set; } + return await _seriesWorkflow.LookupSeriesItemsAsync(seriesName, region); } private async Task SearchProductsDirectAsync( @@ -793,808 +272,67 @@ private async Task SearchProductsDirectAsync( string sortBy, bool returnRawProducts = false) { - var safeRegion = AudibleRequestHelper.NormalizeRegion(region); - - // Try with original text first (preserves diacritics for APIs that - // handle them natively, e.g. audible.de for German/Swedish). - var result = await SearchProductsCoreAsync( - query, title, author, narrator, publisher, - page, limit, safeRegion, language, sortBy, returnRawProducts); - - // If no results and any parameter contained diacritics, retry with - // diacritics stripped (helps US/UK APIs that don't match accented text). - if (result.Results.Count == 0) - { - bool hasDiacritics = - HasDiacritics(query) || HasDiacritics(title) || - HasDiacritics(author) || HasDiacritics(narrator) || - HasDiacritics(publisher); - - if (hasDiacritics) - { - _logger.LogInformation("Retrying Audible search with diacritics stripped (region={Region})", safeRegion); - result = await SearchProductsCoreAsync( - RemoveDiacritics(query ?? string.Empty), - RemoveDiacritics(title ?? string.Empty), - RemoveDiacritics(author ?? string.Empty), - RemoveDiacritics(narrator ?? string.Empty), - RemoveDiacritics(publisher ?? string.Empty), - page, limit, safeRegion, language, sortBy, returnRawProducts); - } - } - - return result; + return await _productSearchWorkflow.SearchProductsDirectAsync( + query, + title, + author, + narrator, + publisher, + page, + limit, + region, + language, + sortBy, + returnRawProducts); } - private async Task SearchProductsCoreAsync( - string? query, string? title, string? author, - string? narrator, string? publisher, - int page, int limit, string safeRegion, - string? language, string sortBy, bool returnRawProducts) + private static AudibleSearchResponse ToSearchResponse(SearchProductsDirectResponse response) { - var parameters = new Dictionary - { - ["num_results"] = Math.Clamp(limit, 1, 50).ToString(), - ["page"] = Math.Max(0, page - 1).ToString(), - ["products_sort_by"] = string.IsNullOrWhiteSpace(sortBy) ? "Relevance" : sortBy, - ["response_groups"] = "media,contributors,series,product_attrs,product_desc,product_extended_attrs,category_ladders" - }; - - if (!string.IsNullOrWhiteSpace(query)) parameters["keywords"] = query; - if (!string.IsNullOrWhiteSpace(title)) parameters["title"] = title; - if (!string.IsNullOrWhiteSpace(author)) parameters["author"] = author; - if (!string.IsNullOrWhiteSpace(narrator)) parameters["narrator"] = narrator; - if (!string.IsNullOrWhiteSpace(publisher)) parameters["publisher"] = publisher; - - var url = $"{AudibleRequestHelper.BuildApiBaseUrl(safeRegion)}/1.0/catalog/products/?{AudibleRequestHelper.BuildQueryString(parameters)}"; - using var doc = await GetAudibleJsonDocumentAsync(url, safeRegion, includeLocaleHeaders: false, timeoutSeconds: 10); - if (doc == null) - { - return new SearchProductsDirectResponse(); - } - - var root = doc.RootElement; - var rawProducts = GetArray(root, "products") - .Where(product => product.ValueKind == JsonValueKind.Object) - .Select(product => product.Clone()) - .ToList(); - var results = rawProducts - .Select(product => AudibleProductMapper.MapProductToBookResponse(product, safeRegion)) - .Where(product => product != null) - .Select(product => AudibleProductMapper.MapBookResponseToSearchResult(product!)) - .Where(product => product != null) - .Cast() - .Where(product => !SearchResultIndicatesPodcast(product)) - .ToList(); - - results = AudibleProductMapper.ApplyLanguageFilter(results, language); - - return new SearchProductsDirectResponse + return new AudibleSearchResponse { - Results = results, - TotalResults = root.TryGetProperty("total_results", out var totalResultsElement) && totalResultsElement.TryGetInt32(out var totalResults) - ? totalResults - : results.Count, - RawProducts = returnRawProducts ? rawProducts : null + Results = response.Results, + TotalResults = response.TotalResults }; } /// - /// Returns true if the string contains characters with diacritical marks - /// that would be altered by . + /// Strips diacritical marks (accents) from a string so that characters + /// like Å → A, ä → a, ö → o, etc. The Audible API returns poor or no + /// results when the query contains non-ASCII diacritics, so we normalize + /// before sending the request. Result metadata still contains the + /// correct accented characters from the API response. /// - private static bool HasDiacritics(string? text) + internal static string RemoveDiacritics(string text) { - if (string.IsNullOrEmpty(text)) return false; - return text != RemoveDiacritics(text); + return AudibleRequestHelper.RemoveDiacritics(text); } - private async Task GetAudibleProductDocumentAsync(string asin, string region, string responseGroups) + private async Task GetBooksByResolvedAuthorAsync(string author, string authorAsin, int page = 1, int limit = 50, string region = "us", string? language = null) { - var safeRegion = AudibleRequestHelper.NormalizeRegion(region); - var url = - $"{AudibleRequestHelper.BuildApiBaseUrl(safeRegion)}/1.0/catalog/products/{Uri.EscapeDataString(asin)}?" + - $"{AudibleRequestHelper.BuildQueryString(new Dictionary - { - ["response_groups"] = responseGroups, - ["image_sizes"] = "500,1000,2400,3200" - })}"; + return await _authorCatalogWorkflow.GetBooksByResolvedAuthorAsync(author, authorAsin, page, limit, region, language); + } - return await GetAudibleJsonDocumentAsync(url, safeRegion, includeLocaleHeaders: false, timeoutSeconds: 10); + public virtual async Task SearchByIsbnAsync(string isbn, int page = 1, int limit = 50, string region = "us", string? language = null) + { + return await _productSearchWorkflow.SearchByIsbnAsync(isbn, page, limit, region, language); } - private async Task> GetBooksMetadataByAsinsAsync(IEnumerable asins, string region) + public virtual async Task SearchBooksAsync(string query, int page = 1, int limit = 50, string region = "us", string? language = null) { - var normalizedRegion = AudibleRequestHelper.NormalizeRegion(region); - var orderedAsins = asins - .Where(asin => !string.IsNullOrWhiteSpace(asin)) - .Select(asin => asin.Trim()) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToList(); - var results = new Dictionary(StringComparer.OrdinalIgnoreCase); + return await _productSearchWorkflow.SearchBooksAsync(query, page, limit, region, language); + } - foreach (var chunk in Chunk(orderedAsins, 50)) + private async Task ExecuteSearchAsync(string url, string searchTerm) + { + try { - var doc = chunk.Count == 1 - ? await GetAudibleProductDocumentAsync(chunk[0], normalizedRegion, DefaultBookResponseGroups) - : await GetAudibleJsonDocumentAsync( - $"{AudibleRequestHelper.BuildApiBaseUrl(normalizedRegion)}/1.0/catalog/products/?" + - $"{AudibleRequestHelper.BuildQueryString(new Dictionary - { - ["asins"] = string.Join(",", chunk), - ["response_groups"] = DefaultBookResponseGroups, - ["image_sizes"] = "500,1000,2400,3200" - })}", - normalizedRegion, - includeLocaleHeaders: false, - timeoutSeconds: 15); - - if (doc == null) + var response = await GetWithTimeoutAsync(url); + if (response == null) { - continue; + _logger.LogWarning("Audible search request timed out for: {SearchTerm}", searchTerm); + return null; } - - using (doc) - { - var root = doc.RootElement; - if (root.TryGetProperty("products", out var products) && products.ValueKind == JsonValueKind.Array) - { - foreach (var mapped in products.EnumerateArray() - .Select(product => AudibleProductMapper.MapProductToBookResponse(product, normalizedRegion)) - .Where(mapped => !string.IsNullOrWhiteSpace(mapped?.Asin))) - { - results[mapped!.Asin!] = mapped; - } - } - else if (root.TryGetProperty("product", out var product) && product.ValueKind == JsonValueKind.Object) - { - var mapped = AudibleProductMapper.MapProductToBookResponse(product, normalizedRegion); - if (!string.IsNullOrWhiteSpace(mapped?.Asin)) - { - results[mapped.Asin!] = mapped; - } - } - } - } - - return orderedAsins - .Where(results.ContainsKey) - .Select(asin => results[asin]) - .ToList(); - } - - private static AudibleSearchResponse ToSearchResponse(SearchProductsDirectResponse response) - { - return new AudibleSearchResponse - { - Results = response.Results, - TotalResults = response.TotalResults - }; - } - - private async Task GetAudibleJsonDocumentAsync( - string url, - string region, - bool includeLocaleHeaders, - int timeoutSeconds) - { - try - { - using var request = new HttpRequestMessage(HttpMethod.Get, url); - request.Headers.TryAddWithoutValidation("User-Agent", includeLocaleHeaders ? AudibleApiVerboseUserAgent : AudibleApiUserAgent); - request.Headers.TryAddWithoutValidation("Accept", AudibleApiAcceptHeader); - request.Headers.TryAddWithoutValidation("Accept-Encoding", "gzip"); - request.Headers.TryAddWithoutValidation("Accept-Charset", "utf-8"); - if (includeLocaleHeaders) - { - var locale = AudibleRequestHelper.GetLocale(region); - request.Headers.TryAddWithoutValidation("ACCEPTED-LANGUAGE", locale); - request.Headers.TryAddWithoutValidation("accept-language", locale); - request.Headers.TryAddWithoutValidation("X-ADP-SW", Random.Shared.Next(10_000_000, 99_999_999).ToString()); - } - - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds)); - var response = await _httpClient.SendAsync(request, cts.Token); - if (!response.IsSuccessStatusCode) - { - _logger.LogWarning("Audible API returned status code {StatusCode} for URL {Url}", response.StatusCode, url); - return null; - } - - await using var stream = await response.Content.ReadAsStreamAsync(cts.Token); - return await JsonDocument.ParseAsync(stream, cancellationToken: cts.Token); - } - catch (TaskCanceledException ex) - { - _logger.LogWarning(ex, "Audible API request timed out for URL: {Url}", url); - return null; - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogError(ex, "Error performing Audible API request for URL: {Url}", url); - return null; - } - } - - /// - /// Strips diacritical marks (accents) from a string so that characters - /// like Å → A, ä → a, ö → o, etc. The Audible API returns poor or no - /// results when the query contains non-ASCII diacritics, so we normalize - /// before sending the request. Result metadata still contains the - /// correct accented characters from the API response. - /// - internal static string RemoveDiacritics(string text) - { - return AudibleRequestHelper.RemoveDiacritics(text); - } - - private static IEnumerable GetArray(JsonElement element, string propertyName) - { - return element.TryGetProperty(propertyName, out var value) && value.ValueKind == JsonValueKind.Array - ? value.EnumerateArray() - : Enumerable.Empty(); - } - - private static string? GetString(JsonElement element, params string[] path) - { - var current = element; - foreach (var segment in path) - { - if (current.ValueKind != JsonValueKind.Object || - !current.TryGetProperty(segment, out current)) - { - return null; - } - } - - return current.ValueKind switch - { - JsonValueKind.String => current.GetString(), - JsonValueKind.Number => current.ToString(), - JsonValueKind.True => bool.TrueString.ToLowerInvariant(), - JsonValueKind.False => bool.FalseString.ToLowerInvariant(), - _ => null - }; - } - - private static int? GetInt32(JsonElement element, string propertyName) - { - if (!element.TryGetProperty(propertyName, out var value)) - { - return null; - } - - if (value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var number)) - { - return number; - } - - return int.TryParse(value.ToString(), out var parsed) ? parsed : null; - } - - private static bool? GetBoolean(JsonElement element, string propertyName) - { - if (!element.TryGetProperty(propertyName, out var value)) - { - return null; - } - - return value.ValueKind switch - { - JsonValueKind.True => true, - JsonValueKind.False => false, - JsonValueKind.String when bool.TryParse(value.GetString(), out var parsed) => parsed, - _ => null - }; - } - - private static string? GetHighestResolutionImage(JsonElement product) - { - if (product.TryGetProperty("product_images", out var images) && images.ValueKind == JsonValueKind.Object) - { - var bestKey = images.EnumerateObject() - .Select(property => new { property.Name, Numeric = int.TryParse(property.Name, out var size) ? size : 0 }) - .OrderByDescending(property => property.Numeric) - .FirstOrDefault(); - if (bestKey != null && images.TryGetProperty(bestKey.Name, out var imageValue)) - { - return imageValue.GetString(); - } - } - - return GetString(product, "cover_art_url"); - } - - private static List MapGenres(JsonElement product) - { - var genres = new List(); - foreach (var ladderEntry in GetArray(product, "category_ladders")) - { - if (!ladderEntry.TryGetProperty("ladder", out var ladder) || ladder.ValueKind != JsonValueKind.Array) - { - continue; - } - - var index = 0; - foreach (var genre in ladder.EnumerateArray()) - { - var name = GetString(genre, "name"); - if (string.IsNullOrWhiteSpace(name)) - { - index++; - continue; - } - - genres.Add(new AudibleGenre - { - Asin = GetString(genre, "id"), - Name = name, - Type = index == 0 ? "Genres" : "Tags" - }); - index++; - } - } - - return genres - .GroupBy(genre => $"{genre.Asin}|{genre.Name}|{genre.Type}", StringComparer.OrdinalIgnoreCase) - .Select(group => group.First()) - .ToList(); - } - - private static decimal ParseSeriesPosition(string? rawPosition) - { - return decimal.TryParse(rawPosition, out var parsed) ? parsed : decimal.MaxValue; - } - - private static List> Chunk(List values, int size) - { - var chunks = new List>(); - for (var i = 0; i < values.Count; i += size) - { - chunks.Add(values.Skip(i).Take(size).ToList()); - } - - return chunks; - } - - private static AuthorLookupItem? ParseSingleAuthorLookupItem(string lookupJson) - { - var items = ParseAuthorLookupItems(lookupJson); - return items.FirstOrDefault(a => !string.IsNullOrWhiteSpace(a.Asin)) ?? items.FirstOrDefault(); - } - - private static SeriesLookupItem? ParseSeriesLookupItem(string lookupJson) - { - var items = ParseSeriesLookupItems(lookupJson); - return items.FirstOrDefault(item => !string.IsNullOrWhiteSpace(item.Asin)) ?? items.FirstOrDefault(); - } - - private async Task GetBooksByResolvedAuthorAsync(string author, string authorAsin, int page = 1, int limit = 50, string region = "us", string? language = null) - { - var fullCatalogResult = await GetAllBooksByAuthorAsync(author, authorAsin, 500, region, language); - if (fullCatalogResult?.Results?.Count > 0) - { - var pageSize = Math.Clamp(limit, 1, 500); - var skip = Math.Max(0, (page - 1) * pageSize); - - return new AudibleSearchResponse - { - Results = fullCatalogResult.Results.Skip(skip).Take(pageSize).ToList(), - TotalResults = fullCatalogResult.TotalResults - }; - } - - return fullCatalogResult; - } - - private async Task> GetDirectAuthorCatalogResultsAsync(string author, string authorAsin, string region, string? language) - { - var normalizedAuthor = author?.Trim(); - if (string.IsNullOrWhiteSpace(normalizedAuthor)) - { - return new List(); - } - - var results = new List(); - var seenKeys = new HashSet(StringComparer.OrdinalIgnoreCase); - var maxPages = 10; - - for (var currentPage = 1; currentPage <= maxPages; currentPage++) - { - var response = await SearchProductsDirectAsync( - query: null, - title: null, - author: normalizedAuthor, - narrator: null, - publisher: null, - page: currentPage, - limit: 50, - region: region, - language: null, - sortBy: "BestSellers"); - - if (response.Results.Count == 0) - { - break; - } - - if (response.TotalResults > 0) - { - maxPages = Math.Min(10, (int)Math.Ceiling(response.TotalResults / 50d)); - } - - foreach (var result in response.Results) - { - if (!AuthorSearchResultMatchesTarget(result, normalizedAuthor, authorAsin)) - { - continue; - } - - var key = BuildSearchResultKey(result); - if (!seenKeys.Add(key)) - { - continue; - } - - results.Add(result); - } - - if (response.Results.Count < 50) - { - break; - } - } - - return AudibleProductMapper.ApplyLanguageFilter(results, language); - } - - private static bool AuthorSearchResultMatchesTarget(AudibleSearchResult result, string author, string? authorAsin) - { - if (result.Authors == null || result.Authors.Count == 0) - { - return false; - } - - var normalizedTargetName = NormalizeComparableText(author); - if (string.IsNullOrWhiteSpace(normalizedTargetName)) - { - return false; - } - - foreach (var candidate in result.Authors) - { - if (!string.IsNullOrWhiteSpace(authorAsin) && - string.Equals(candidate.Asin, authorAsin, StringComparison.OrdinalIgnoreCase)) - { - return true; - } - - if (string.Equals(NormalizeComparableText(candidate.Name), normalizedTargetName, StringComparison.Ordinal)) - { - return true; - } - } - - return false; - } - - private static string BuildSearchResultKey(AudibleSearchResult result) - { - return string.IsNullOrWhiteSpace(result.Asin) - ? $"{result.Title}|{result.Link}" - : result.Asin; - } - - private static string NormalizeComparableText(string? value) - { - if (string.IsNullOrWhiteSpace(value)) - { - return string.Empty; - } - - var joined = string.Join( - ' ', - value.Trim() - .Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)) - .ToLowerInvariant(); - return RemoveDiacritics(joined); - } - - private async Task ScrapeAudibleAuthorPageAsync(string author, string authorAsin, int page = 1, int limit = 50, string region = "us", string? language = null) - { - try - { - var authorPageUrl = AudibleRequestHelper.BuildAuthorPageUrl(author, authorAsin, region); - _logger.LogInformation("Scraping Audible author page as fallback: {Url}", authorPageUrl); - - var response = await GetWithTimeoutAsync(authorPageUrl, timeoutSeconds: 10); - if (response == null) - { - _logger.LogWarning("Audible author page request timed out for author {Author}", LogRedaction.SanitizeText(author)); - return null; - } - - if (!response.IsSuccessStatusCode) - { - _logger.LogWarning("Audible author page returned status code {StatusCode} for author {Author}", response.StatusCode, author); - return null; - } - - var html = await response.Content.ReadAsStringAsync(); - if (string.IsNullOrWhiteSpace(html)) - { - _logger.LogWarning("Audible author page returned empty HTML for author {Author}", LogRedaction.SanitizeText(author)); - return null; - } - - if (_authorPageParser == null) - { - _logger.LogWarning("Audible author page parser is unavailable for author {Author}", LogRedaction.SanitizeText(author)); - return null; - } - - var parsedTiles = _authorPageParser.ParseAuthorPage(html, author, authorAsin, region); - if (parsedTiles.Count == 0) - { - _logger.LogWarning("Audible author page tiles could not be parsed for author {Author}", LogRedaction.SanitizeText(author)); - return null; - } - - await EnrichFallbackAuthorResultsAsync(parsedTiles, region); - - var authorMatchedTiles = parsedTiles - .Where(r => r.Authors?.Any(a => string.Equals(a.Name, author, StringComparison.OrdinalIgnoreCase)) == true) - .ToList(); - var filteredTiles = authorMatchedTiles.Count > 0 ? authorMatchedTiles : parsedTiles; - - if (!string.IsNullOrWhiteSpace(language)) - { - filteredTiles = filteredTiles - .Where(r => !string.IsNullOrWhiteSpace(r.Language) && string.Equals(r.Language, language, StringComparison.OrdinalIgnoreCase)) - .ToList(); - } - - var skip = Math.Max(0, (page - 1) * Math.Max(1, limit)); - var pagedTiles = filteredTiles.Skip(skip).Take(Math.Max(1, limit)).ToList(); - - _logger.LogInformation( - "Audible author page fallback returned {PagedCount} of {TotalCount} parsed title(s) for author {Author}", - pagedTiles.Count, - filteredTiles.Count, - LogRedaction.SanitizeText(author)); - - return new AudibleSearchResponse - { - Results = pagedTiles, - TotalResults = filteredTiles.Count - }; - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Failed to scrape Audible author page fallback for author {Author}", LogRedaction.SanitizeText(author)); - return null; - } - } - - private async Task EnrichFallbackAuthorResultsAsync(List books, string region) - { - foreach (var book in books) - { - if (string.IsNullOrWhiteSpace(book.Asin)) - { - continue; - } - - try - { - var metadata = await GetBookMetadataAsync(book.Asin, region, true, language: null); - if (metadata == null) - { - continue; - } - - book.Title = string.IsNullOrWhiteSpace(metadata.Title) ? book.Title : metadata.Title; - book.Subtitle = string.IsNullOrWhiteSpace(book.Subtitle) ? metadata.Subtitle : book.Subtitle; - if (metadata.Authors?.Any() == true) - { - book.Authors = metadata.Authors; - } - book.ImageUrl = string.IsNullOrWhiteSpace(book.ImageUrl) ? metadata.ImageUrl : book.ImageUrl; - book.LengthMinutes ??= metadata.LengthMinutes; - book.RuntimeLengthMin ??= metadata.LengthMinutes; - book.Language = string.IsNullOrWhiteSpace(book.Language) ? metadata.Language : book.Language; - book.ContentType = string.IsNullOrWhiteSpace(book.ContentType) ? metadata.ContentType : book.ContentType; - book.ContentDeliveryType = string.IsNullOrWhiteSpace(book.ContentDeliveryType) ? metadata.ContentDeliveryType : book.ContentDeliveryType; - book.BookFormat = string.IsNullOrWhiteSpace(book.BookFormat) ? metadata.BookFormat : book.BookFormat; - if (metadata.Genres?.Any() == true) - { - book.Genres = metadata.Genres; - } - if (metadata.Series?.Any() == true) - { - book.Series = metadata.Series; - } - book.Publisher = string.IsNullOrWhiteSpace(book.Publisher) ? metadata.Publisher : book.Publisher; - if (metadata.Narrators?.Any() == true) - { - book.Narrators = metadata.Narrators; - } - book.ReleaseDate = string.IsNullOrWhiteSpace(book.ReleaseDate) ? metadata.ReleaseDate : book.ReleaseDate; - book.Isbn = string.IsNullOrWhiteSpace(book.Isbn) ? metadata.Isbn : book.Isbn; - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogDebug(ex, "Failed to hydrate fallback author page metadata for ASIN {Asin}", book.Asin); - } - } - } - - private static List ParseAuthorLookupItems(string lookupJson) - { - var opts = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; - if (string.IsNullOrWhiteSpace(lookupJson)) return new List(); - - var trimmed = lookupJson.TrimStart(); - if (trimmed.StartsWith("[", StringComparison.Ordinal)) - { - return JsonSerializer.Deserialize>(lookupJson, opts) ?? new List(); - } - - var single = JsonSerializer.Deserialize(lookupJson, opts); - if (single != null && (!string.IsNullOrWhiteSpace(single.Asin) || !string.IsNullOrWhiteSpace(single.Name))) - { - return new List { single }; - } - - var doc = JsonSerializer.Deserialize(lookupJson, opts); - if (doc == null) return new List(); - if (doc.Results?.Any() == true) return doc.Results; - if (!string.IsNullOrWhiteSpace(doc.Asin)) - { - return new List - { - new AuthorLookupItem - { - Asin = doc.Asin, - Name = doc.Name, - Image = doc.Image, - Region = doc.Region, - Description = doc.Description - } - }; - } - - return new List(); - } - - private static List ParseSeriesLookupItems(string lookupJson) - { - var opts = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; - if (string.IsNullOrWhiteSpace(lookupJson)) return new List(); - - var trimmed = lookupJson.TrimStart(); - if (trimmed.StartsWith("[", StringComparison.Ordinal)) - { - return JsonSerializer.Deserialize>(lookupJson, opts) ?? new List(); - } - - var single = JsonSerializer.Deserialize(lookupJson, opts); - if (single != null && (!string.IsNullOrWhiteSpace(single.Asin) || !string.IsNullOrWhiteSpace(single.Name))) - { - return new List { single }; - } - - var doc = JsonSerializer.Deserialize(lookupJson, opts); - if (doc == null) return new List(); - if (doc.Results?.Any() == true) return doc.Results; - if (!string.IsNullOrWhiteSpace(doc.Asin)) - { - return new List - { - new SeriesLookupItem - { - Asin = doc.Asin, - Name = doc.Name, - Region = doc.Region, - Description = doc.Description, - Position = doc.Position - } - }; - } - - return new List(); - } - - public virtual async Task SearchByIsbnAsync(string isbn, int page = 1, int limit = 50, string region = "us", string? language = null) - { - var response = await SearchProductsDirectAsync( - query: isbn, - title: null, - author: null, - narrator: null, - publisher: null, - page: page, - limit: limit, - region: region, - language: language, - sortBy: "BestSellers"); - var filtered = response.Results - .Where(result => string.Equals(result.Isbn?.Trim(), isbn.Trim(), StringComparison.OrdinalIgnoreCase)) - .ToList(); - return new AudibleSearchResponse - { - Results = filtered, - TotalResults = filtered.Count - }; - } - - public virtual async Task SearchBooksAsync(string query, int page = 1, int limit = 50, string region = "us", string? language = null) - { - // If query looks like an ASIN, perform a direct metadata lookup which returns a single result - bool IsAsin(string s) - { - if (string.IsNullOrEmpty(s)) return false; - if (s.Length != 10) return false; - if (!(s.StartsWith("B0", StringComparison.OrdinalIgnoreCase) || char.IsDigit(s[0]))) return false; - return s.All(char.IsLetterOrDigit); - } - - if (IsAsin(query?.Trim() ?? string.Empty)) - { - var asin = query?.Trim() ?? string.Empty; - _logger.LogInformation("Query appears to be an ASIN; performing direct Audible book lookup for {Asin}", LogRedaction.SanitizeText(asin)); - var meta = await GetBookMetadataAsync(asin, region, true, language); - if (meta == null) return null; - - // Convert AudibleBookResponse to AudibleSearchResult for compatibility with callers - var single = new AudibleSearchResult - { - Asin = meta.Asin, - Title = meta.Title, - Subtitle = meta.Subtitle, - Authors = meta.Authors, - ImageUrl = meta.ImageUrl, - LengthMinutes = meta.LengthMinutes, - Language = meta.Language, - ContentType = meta.ContentType, - ContentDeliveryType = meta.ContentDeliveryType, - BookFormat = meta.BookFormat, - Genres = meta.Genres, - Series = meta.Series, - Publisher = meta.Publisher, - Narrators = meta.Narrators, - ReleaseDate = meta.ReleaseDate, - Link = $"https://www.amazon.com/dp/{meta.Asin}" - }; - - return new AudibleSearchResponse { Results = new List { single }, TotalResults = 1 }; - } - - var response = await SearchProductsDirectAsync( - query: query, - title: null, - author: null, - narrator: null, - publisher: null, - page: page, - limit: limit, - region: region, - language: language, - sortBy: "Relevance"); - return ToSearchResponse(response); - } - - private async Task ExecuteSearchAsync(string url, string searchTerm) - { - try - { - var response = await GetWithTimeoutAsync(url); - if (response == null) - { - _logger.LogWarning("Audible search request timed out for: {SearchTerm}", searchTerm); - return null; - } - if (!response.IsSuccessStatusCode) + if (!response.IsSuccessStatusCode) { _logger.LogWarning("Audible search returned status code {StatusCode} for: {SearchTerm}", response.StatusCode, searchTerm); return null; @@ -1706,86 +444,17 @@ bool IsAsin(string s) private async Task GetWithTimeoutAsync(string url, int timeoutSeconds = 5) { - try - { - using var cts = new System.Threading.CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds)); - var resp = await _httpClient.GetAsync(url, cts.Token); - return resp; - } - catch (TaskCanceledException ex) - { - _logger.LogWarning(ex, "Audible request timed out for URL: {Url}", url); - return null; - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogError(ex, "Error performing Audible HTTP request for URL: {Url}", url); - return null; - } + return await _apiClient.GetWithTimeoutAsync(url, timeoutSeconds); } private static bool SearchResultIndicatesPodcast(AudibleSearchResult? r) { - if (r == null) return false; - // If result explicitly indicates it's a book/product by content type or delivery type, - // prefer that signal and do not treat it as a podcast even if other fields mention 'podcast'. - var ct = r.ContentType?.Trim(); - var cdt = r.ContentDeliveryType?.Trim(); - var ctIsBookOrProduct = !string.IsNullOrWhiteSpace(ct) && (string.Equals(ct, "Book", StringComparison.OrdinalIgnoreCase) || string.Equals(ct, "Product", StringComparison.OrdinalIgnoreCase)); - var allowedBookDelivery = new[] { "SinglePartBook", "MultiPartBook", "BookSeries" }; - var cdtIsBook = !string.IsNullOrWhiteSpace(cdt) && allowedBookDelivery.Any(a => string.Equals(a, cdt, StringComparison.OrdinalIgnoreCase)); - if (ctIsBookOrProduct || cdtIsBook) return false; - - if (!string.IsNullOrWhiteSpace(r.ContentType) && r.ContentType.IndexOf("podcast", StringComparison.OrdinalIgnoreCase) >= 0) return true; - if (!string.IsNullOrWhiteSpace(r.ContentDeliveryType) && r.ContentDeliveryType.IndexOf("podcast", StringComparison.OrdinalIgnoreCase) >= 0) return true; - if (!string.IsNullOrWhiteSpace(r.EpisodeType)) return true; - if (!string.IsNullOrWhiteSpace(r.Sku) && r.Sku.StartsWith("PC_", StringComparison.OrdinalIgnoreCase)) return true; - if (!string.IsNullOrWhiteSpace(r.BookFormat) && r.BookFormat.IndexOf("podcast", StringComparison.OrdinalIgnoreCase) >= 0) return true; - if (r.Genres?.Any(g => (!string.IsNullOrWhiteSpace(g?.Name) && g.Name.IndexOf("podcast", StringComparison.OrdinalIgnoreCase) >= 0) || (!string.IsNullOrWhiteSpace(g?.Type) && g.Type.IndexOf("podcast", StringComparison.OrdinalIgnoreCase) >= 0)) == true) return true; - return false; + return AudibleSearchResultFilter.IndicatesPodcast(r); } private static string? GetPodcastFilterReason(AudibleSearchResult? r) { - if (r == null) return null; - if (!string.IsNullOrWhiteSpace(r.ContentType) && r.ContentType.IndexOf("podcast", StringComparison.OrdinalIgnoreCase) >= 0) return "ContentType contains 'podcast'"; - if (!string.IsNullOrWhiteSpace(r.ContentDeliveryType) && r.ContentDeliveryType.IndexOf("podcast", StringComparison.OrdinalIgnoreCase) >= 0) return "ContentDeliveryType contains 'podcast'"; - if (!string.IsNullOrWhiteSpace(r.EpisodeType)) return "EpisodeType present"; - if (!string.IsNullOrWhiteSpace(r.Sku) && r.Sku.StartsWith("PC_", StringComparison.OrdinalIgnoreCase)) return "SKU starts with PC_"; - if (!string.IsNullOrWhiteSpace(r.BookFormat) && r.BookFormat.IndexOf("podcast", StringComparison.OrdinalIgnoreCase) >= 0) return "BookFormat contains 'podcast'"; - if (r.Genres?.Any(g => (!string.IsNullOrWhiteSpace(g?.Name) && g.Name.IndexOf("podcast", StringComparison.OrdinalIgnoreCase) >= 0) || (!string.IsNullOrWhiteSpace(g?.Type) && g.Type.IndexOf("podcast", StringComparison.OrdinalIgnoreCase) >= 0)) == true) return "Genre contains 'podcast'"; - return null; - } - - private static bool IsAllowedContentTypeOrDelivery(AudibleSearchResult? r) - { - if (r == null) return false; - // Require BOTH: ContentType must be Book|Product AND ContentDeliveryType must be one of allowed book delivery types. - var ct = r.ContentType?.Trim(); - var cdt = r.ContentDeliveryType?.Trim(); - - var ctOk = !string.IsNullOrWhiteSpace(ct) && (string.Equals(ct, "Book", StringComparison.OrdinalIgnoreCase) || string.Equals(ct, "Product", StringComparison.OrdinalIgnoreCase)); - - var allowed = new[] { "SinglePartBook", "MultiPartBook", "BookSeries" }; - var cdtOk = !string.IsNullOrWhiteSpace(cdt) && allowed.Any(a => string.Equals(a, cdt, StringComparison.OrdinalIgnoreCase)); - - return ctOk && cdtOk; - } - - private static string? GetTypeFilterReason(AudibleSearchResult? r) - { - if (r == null) return null; - var ct = r.ContentType?.Trim(); - var cdt = r.ContentDeliveryType?.Trim(); - - var ctOk = !string.IsNullOrWhiteSpace(ct) && (string.Equals(ct, "Book", StringComparison.OrdinalIgnoreCase) || string.Equals(ct, "Product", StringComparison.OrdinalIgnoreCase)); - var allowed = new[] { "SinglePartBook", "MultiPartBook", "BookSeries" }; - var cdtOk = !string.IsNullOrWhiteSpace(cdt) && allowed.Any(a => string.Equals(a, cdt, StringComparison.OrdinalIgnoreCase)); - - if (ctOk && cdtOk) return $"ContentType='{ct}' AND ContentDeliveryType='{cdt}'"; - if (!ctOk && !cdtOk) return "ContentType not allowed; ContentDeliveryType not allowed"; - if (!ctOk) return $"ContentType='{ct ?? ""}' not allowed"; - return $"ContentDeliveryType='{cdt ?? ""}' not allowed"; + return AudibleSearchResultFilter.GetPodcastFilterReason(r); } } } diff --git a/listenarr.application/Metadata/SearchProductsDirectResponse.cs b/listenarr.application/Metadata/SearchProductsDirectResponse.cs new file mode 100644 index 000000000..f8e617420 --- /dev/null +++ b/listenarr.application/Metadata/SearchProductsDirectResponse.cs @@ -0,0 +1,29 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +using System.Text.Json; + +namespace Listenarr.Application.Metadata +{ + internal sealed class SearchProductsDirectResponse + { + public List Results { get; set; } = new(); + public int TotalResults { get; set; } + public List? RawProducts { get; set; } + } +} diff --git a/listenarr.application/Search/IndexerAdditionalSettingsParser.cs b/listenarr.application/Search/IndexerAdditionalSettingsParser.cs new file mode 100644 index 000000000..562d08bba --- /dev/null +++ b/listenarr.application/Search/IndexerAdditionalSettingsParser.cs @@ -0,0 +1,104 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Application.Search; + +public class IndexerAdditionalSettingsParser +{ + private readonly ILogger _logger; + + public IndexerAdditionalSettingsParser(ILogger logger) + { + _logger = logger; + } + + public MyAnonamouseOptions? ParseMamOptions(string? additional) + { + if (string.IsNullOrWhiteSpace(additional)) return null; + try + { + using var doc = JsonDocument.Parse(additional); + var root = doc.RootElement; + if (root.ValueKind != JsonValueKind.Object) return null; + + var opts = new MyAnonamouseOptions(); + if (root.TryGetProperty("mam_options", out var mo) && mo.ValueKind == JsonValueKind.Object) + { + ApplyProperties(mo, opts); + return opts; + } + + ApplyProperties(root, opts); + + if (opts.SearchInDescription == null + && opts.SearchInSeries == null + && opts.SearchInFilenames == null + && opts.SearchLanguage == null + && opts.Filter == null + && opts.FreeleechWedge == null + && opts.EnrichResults == null + && opts.EnrichTopResults == null) + { + return null; + } + + return opts; + } + catch (JsonException ex) + { + _logger.LogWarning(ex, "Failed to parse AdditionalSettings JSON for MAM options"); + return null; + } + } + + private static void ApplyProperties(JsonElement root, MyAnonamouseOptions opts) + { + if (root.TryGetProperty("searchInDescription", out var sid) && IsBoolean(sid)) + opts.SearchInDescription = sid.GetBoolean(); + if (root.TryGetProperty("searchInSeries", out var sis) && IsBoolean(sis)) + opts.SearchInSeries = sis.GetBoolean(); + if (root.TryGetProperty("searchInFilenames", out var sif) && IsBoolean(sif)) + opts.SearchInFilenames = sif.GetBoolean(); + if (root.TryGetProperty("language", out var lang) && lang.ValueKind == JsonValueKind.String) + opts.SearchLanguage = lang.GetString(); + if (root.TryGetProperty("filter", out var filter) + && filter.ValueKind == JsonValueKind.String + && Enum.TryParse(filter.GetString() ?? string.Empty, true, out var f)) + opts.Filter = f; + if (root.TryGetProperty("freeleechWedge", out var wedge) + && wedge.ValueKind == JsonValueKind.String + && Enum.TryParse(wedge.GetString() ?? string.Empty, true, out var w)) + opts.FreeleechWedge = w; + if (root.TryGetProperty("enrichResults", out var enrich) && IsBoolean(enrich)) + opts.EnrichResults = enrich.GetBoolean(); + if (root.TryGetProperty("enrichTopResults", out var enrichTop) + && (enrichTop.ValueKind == JsonValueKind.Number || enrichTop.ValueKind == JsonValueKind.String)) + { + if (enrichTop.ValueKind == JsonValueKind.Number) opts.EnrichTopResults = enrichTop.GetInt32(); + else if (int.TryParse(enrichTop.GetString(), out var parsed)) opts.EnrichTopResults = parsed; + } + } + + private static bool IsBoolean(JsonElement element) + { + return element.ValueKind == JsonValueKind.True || element.ValueKind == JsonValueKind.False; + } +} diff --git a/listenarr.application/Search/IndexerQuerySanitizer.cs b/listenarr.application/Search/IndexerQuerySanitizer.cs new file mode 100644 index 000000000..102c93863 --- /dev/null +++ b/listenarr.application/Search/IndexerQuerySanitizer.cs @@ -0,0 +1,51 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +using System.Globalization; +using System.Text; +using System.Text.RegularExpressions; + +namespace Listenarr.Application.Search; + +public static class IndexerQuerySanitizer +{ + public static string Sanitize(string query) + { + if (string.IsNullOrWhiteSpace(query)) return string.Empty; + + const string forbidden = "*/\\<>:?|^~`$#%&+={}[]'\"!()"; + + var sb = new StringBuilder(query.Length); + foreach (var ch in query) + { + var category = CharUnicodeInfo.GetUnicodeCategory(ch); + if (char.IsControl(ch) || category == UnicodeCategory.Format) + continue; + + if (forbidden.IndexOf(ch) >= 0) + continue; + + if (ch == '\u2018' || ch == '\u2019' || ch == '\u201C' || ch == '\u201D') + continue; + + sb.Append(ch); + } + + return Regex.Replace(sb.ToString(), "\\s+", " ").Trim(); + } +} diff --git a/listenarr.application/Search/IndexerSearchWorkflow.cs b/listenarr.application/Search/IndexerSearchWorkflow.cs new file mode 100644 index 000000000..002896278 --- /dev/null +++ b/listenarr.application/Search/IndexerSearchWorkflow.cs @@ -0,0 +1,317 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +using Listenarr.Application.Interfaces; +using Listenarr.Application.Interfaces.Repositories; +using Listenarr.Domain.Models; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Application.Search; + +public class IndexerSearchWorkflow +{ + private readonly HttpClient _httpClient; + private readonly IConfigurationService _configurationService; + private readonly IIndexerRepository _indexerRepository; + private readonly IEnumerable _searchProviders; + private readonly IndexerAdditionalSettingsParser _additionalSettingsParser; + private readonly TorznabResponseParser _torznabResponseParser; + private readonly ILogger _logger; + + public IndexerSearchWorkflow( + HttpClient httpClient, + IConfigurationService configurationService, + IIndexerRepository indexerRepository, + IEnumerable searchProviders, + IndexerAdditionalSettingsParser additionalSettingsParser, + ILogger logger, + IHtmlTextExtractor? htmlTextExtractor = null) + { + _httpClient = httpClient; + _configurationService = configurationService; + _indexerRepository = indexerRepository; + _searchProviders = searchProviders; + _additionalSettingsParser = additionalSettingsParser; + _logger = logger; + _torznabResponseParser = new TorznabResponseParser(httpClient, logger, htmlTextExtractor); + } + + public async Task> SearchIndexersAsync( + string query, + string? category = null, + SearchSortBy sortBy = SearchSortBy.Seeders, + SearchSortDirection sortDirection = SearchSortDirection.Descending, + bool isAutomaticSearch = false, + SearchRequest? request = null) + { + var results = new List(); + var indexers = await _indexerRepository.GetEnabledAsync(isAutomaticSearch); + + _logger.LogInformation("Searching {Count} enabled indexers for query: {Query}", indexers.Count, query); + + if (!indexers.Any()) + { + _logger.LogWarning("No indexers configured, returning mock results for query: {Query}", query); + return GenerateMockIndexerResults(query); + } + + var searchTasks = indexers.Select(async indexer => + { + try + { + _logger.LogInformation("Searching indexer {Name} ({Type}) for query: {Query}", indexer.Name, indexer.Type, query); + var perIndexerRequest = ApplyIndexerMamOptions(indexer, request); + + var indexerResults = await SearchIndexerAsync(indexer, query, category, perIndexerRequest); + _logger.LogInformation("Found {Count} results from indexer {Name}", indexerResults.Count, indexer.Name); + return indexerResults; + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogError(ex, "Error searching indexer {Name} for query: {Query}", indexer.Name, query); + return new List(); + } + }).ToList(); + + var indexerResults = await Task.WhenAll(searchTasks); + foreach (var indexerResult in indexerResults) + { + results.AddRange(indexerResult); + } + + _logger.LogInformation("Total {Count} results from all indexers for query: {Query}", results.Count, query); + + return results.OrderByDescending(r => r.Seeders ?? 0).ThenByDescending(r => r.PublishedDate).ToList(); + } + + public async Task> SearchByApiAsync(string apiId, string query, string? category = null) + { + try + { + var indexer = await GetIndexerByApiIdAsync(apiId); + + if (indexer == null) + { + _logger.LogWarning("Indexer not found for apiId: {ApiId}", apiId); + return new List(); + } + + if (!indexer.IsEnabled) + { + _logger.LogWarning("Indexer {IndexerName} (apiId: {ApiId}) is not enabled", indexer.Name, apiId); + return new List(); + } + + var req = new SearchRequest(); + var mamOpts = _additionalSettingsParser.ParseMamOptions(indexer.AdditionalSettings); + if (mamOpts != null) req.MyAnonamouse = mamOpts; + + var idxResults = await SearchIndexerAsync(indexer, query, category, req); + return idxResults.Select(SearchResultConverters.ToSearchResult).ToList(); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogError(ex, "Error searching indexer {ApiId} for query: {Query}", apiId, query); + return new List(); + } + } + + public async Task> SearchIndexerResultsAsync( + string apiId, + string query, + string? category = null, + SearchRequest? request = null) + { + try + { + var indexer = await GetIndexerByApiIdAsync(apiId); + + if (indexer == null || !indexer.IsEnabled) + { + _logger.LogWarning("Indexer not found or disabled for apiId: {ApiId}", apiId); + return new List(); + } + + request = ApplyIndexerMamOptions(indexer, request); + return await SearchIndexerAsync(indexer, query, category, request); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogError(ex, "Error searching indexer {ApiId} for query: {Query}", apiId, query); + return new List(); + } + } + + public async Task TestApiConnectionAsync(string apiId) + { + try + { + var apiConfig = await _configurationService.GetApiConfigurationAsync(apiId); + if (apiConfig == null) return false; + + var response = await _httpClient.GetAsync(apiConfig.BaseUrl); + return response.IsSuccessStatusCode; + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogError(ex, "Error testing API connection for {ApiId}", apiId); + return false; + } + } + + public async Task> ParseTorznabResponseAsync(string xmlContent, Indexer indexer) + { + return await _torznabResponseParser.ParseAsync(xmlContent, indexer); + } + + private async Task GetIndexerByApiIdAsync(string apiId) + { + return int.TryParse(apiId, out var indexerId) + ? await _indexerRepository.GetByIdAsync(indexerId) + : await _indexerRepository.GetByNameAsync(apiId); + } + + private SearchRequest? ApplyIndexerMamOptions(Indexer indexer, SearchRequest? request) + { + if (request?.MyAnonamouse != null) + return request; + + var mam = _additionalSettingsParser.ParseMamOptions(indexer.AdditionalSettings); + if (mam == null) + return request; + + request ??= new SearchRequest(); + request.MyAnonamouse = mam; + return request; + } + + private async Task> SearchIndexerAsync( + Indexer indexer, + string query, + string? category = null, + SearchRequest? request = null) + { + try + { + query = IndexerQuerySanitizer.Sanitize(query); + _logger.LogInformation("Searching indexer {Name} ({Implementation}) for: {Query}", indexer.Name, indexer.Implementation, query); + + var fallbackName = GetFallbackIndexerName(indexer); + var provider = _searchProviders.FirstOrDefault(p => + p.IndexerType.Equals(indexer.Implementation, StringComparison.OrdinalIgnoreCase) || + (p.IndexerType.Equals("Torznab", StringComparison.OrdinalIgnoreCase) + && indexer.Implementation.Equals("Newznab", StringComparison.OrdinalIgnoreCase))); + + if (provider == null) + { + _logger.LogWarning("No provider found for indexer type: {Implementation}", indexer.Implementation); + return new List(); + } + + var providerResults = await provider.SearchAsync(indexer, query, category, request); + foreach (var r in providerResults.Where(r => string.IsNullOrWhiteSpace(r.Source))) + { + r.Source = fallbackName; + } + + return providerResults; + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogError(ex, "Error searching indexer {Name}", indexer.Name); + return new List(); + } + } + + private static string GetFallbackIndexerName(Indexer indexer) + { + if (!string.IsNullOrWhiteSpace(indexer.Name)) + return indexer.Name; + + if (!string.IsNullOrWhiteSpace(indexer.Implementation)) + return indexer.Implementation; + + try + { + var baseUrl = indexer.Url?.TrimEnd('/') ?? string.Empty; + var baseUri = new Uri(baseUrl.StartsWith("http", StringComparison.OrdinalIgnoreCase) ? baseUrl : "https://" + baseUrl); + return baseUri.Host; + } + catch (Exception caughtEx) when (caughtEx is not OperationCanceledException && caughtEx is not OutOfMemoryException && caughtEx is not StackOverflowException) + { + return "Indexer"; + } + } + + private List GenerateMockIndexerResults(string query) + { + return GenerateMockIndexerResults(query, "Mock Indexer", "Torrent"); + } + + private List GenerateMockIndexerResults(string query, string indexerName, string indexerType) + { + var random = new Random(); + var results = new List(); + var isUsenet = indexerType.Equals("Usenet", StringComparison.OrdinalIgnoreCase); + + _logger.LogInformation("Generating {Count} mock {Type} results for indexer {IndexerName}", 5, indexerType, indexerName); + + for (int i = 0; i < 5; i++) + { + var result = new IndexerSearchResult + { + Id = Guid.NewGuid().ToString(), + Title = $"{query} - Quality {i + 1}", + Artist = "Various Authors", + Album = $"{query} Series", + Category = "Audiobook", + Size = random.Next(200_000_000, 1_500_000_000), + Seeders = isUsenet ? 0 : random.Next(5, 100), + Leechers = isUsenet ? 0 : random.Next(0, 20), + Source = indexerName, + PublishedDate = DateTime.UtcNow.AddDays(-random.Next(1, 365)).ToString("o"), + Quality = i switch + { + 0 => "MP3 64kbps", + 1 => "MP3 128kbps", + 2 => "MP3 192kbps", + 3 => "M4B 128kbps", + _ => "FLAC" + }, + Format = i >= 3 ? "M4B" : "MP3", + Language = "English" + }; + + if (isUsenet) + { + result.NzbUrl = $"https://{indexerName.ToLowerInvariant()}.example.com/api/nzb/{Guid.NewGuid():N}"; + result.MagnetLink = string.Empty; + result.TorrentUrl = string.Empty; + } + else + { + result.MagnetLink = $"magnet:?xt=urn:btih:{Guid.NewGuid():N}"; + result.NzbUrl = string.Empty; + } + + results.Add(result); + } + + return results; + } +} diff --git a/listenarr.application/Search/MetadataSourceCatalog.cs b/listenarr.application/Search/MetadataSourceCatalog.cs new file mode 100644 index 000000000..eedb0742f --- /dev/null +++ b/listenarr.application/Search/MetadataSourceCatalog.cs @@ -0,0 +1,70 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +using Listenarr.Application.Interfaces.Repositories; +using Listenarr.Domain.Models.Configurations; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Application.Search; + +public class MetadataSourceCatalog +{ + private readonly IApiConfigurationRepository _apiConfigRepository; + private readonly ILogger _logger; + + public MetadataSourceCatalog( + IApiConfigurationRepository apiConfigRepository, + ILogger logger) + { + _apiConfigRepository = apiConfigRepository; + _logger = logger; + } + + public async Task> GetEnabledMetadataSourcesAsync() + { + try + { + _logger.LogDebug("Querying database for enabled metadata sources..."); + + var allConfigs = await _apiConfigRepository.GetAllAsync(); + var metadataSources = allConfigs + .Where(api => api.IsEnabled && api.Type == "metadata") + .OrderBy(api => api.Priority) + .ToList(); + + if (metadataSources.Count > 0) + { + _logger.LogInformation( + "Retrieved {Count} enabled metadata sources: {Sources}", + metadataSources.Count, + string.Join(", ", metadataSources.Select(s => $"{s.Name} (Priority: {s.Priority}, BaseUrl: {s.BaseUrl})"))); + } + else + { + _logger.LogWarning("No enabled metadata sources found in database"); + } + + return metadataSources; + } + catch (InvalidOperationException ex) + { + _logger.LogError(ex, "Invalid operation error retrieving enabled metadata sources"); + return new List(); + } + } +} diff --git a/listenarr.application/Search/SearchService.cs b/listenarr.application/Search/SearchService.cs index 4213ff0e5..8761f6d32 100644 --- a/listenarr.application/Search/SearchService.cs +++ b/listenarr.application/Search/SearchService.cs @@ -16,13 +16,13 @@ * along with this program. If not, see . */ -using System.Text.Json; using Microsoft.Extensions.Caching.Memory; using Listenarr.Application.Interfaces; using Listenarr.Domain.Models.Configurations; using Listenarr.Domain.Models; using Listenarr.Application.Interfaces.Repositories; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using Listenarr.Application.Notification; using Listenarr.Application.Metadata; using Listenarr.Application.Security; @@ -31,11 +31,8 @@ namespace Listenarr.Application.Search { public class SearchService : ISearchService { - private readonly HttpClient _httpClient; private readonly IConfigurationService _configurationService; private readonly ILogger _logger; - private readonly IIndexerRepository _indexerRepository; - private readonly IApiConfigurationRepository _apiConfigRepository; private readonly AudibleService _audibleService; private readonly MetadataConverters _metadataConverters; private readonly SearchProgressReporter _searchProgressReporter; @@ -45,9 +42,9 @@ public class SearchService : ISearchService private readonly SearchResultSortingService _searchResultSorting; private readonly AsinSearchHandler _asinSearchHandler; private readonly IMemoryCache? _cache; - private readonly IEnumerable _searchProviders; private readonly ICoverImageProbe? _coverImageProbe; - private readonly IHtmlTextExtractor? _htmlTextExtractor; + private readonly IndexerSearchWorkflow _indexerSearchWorkflow; + private readonly MetadataSourceCatalog _metadataSourceCatalog; public SearchService( HttpClient httpClient, @@ -66,25 +63,34 @@ public SearchService( IEnumerable? searchProviders = null, IMemoryCache? cache = null, ICoverImageProbe? coverImageProbe = null, - IHtmlTextExtractor? htmlTextExtractor = null) + IHtmlTextExtractor? htmlTextExtractor = null, + IndexerSearchWorkflow? indexerSearchWorkflow = null, + MetadataSourceCatalog? metadataSourceCatalog = null) { - _httpClient = httpClient; _configurationService = configurationService; _logger = logger; - _indexerRepository = indexerRepository; - _apiConfigRepository = apiConfigRepository; _audibleService = audibleService; _metadataConverters = metadataConverters; _searchProgressReporter = searchProgressReporter; _asinCandidateCollector = asinCandidateCollector; _asinEnricher = asinEnricher; - _searchProviders = searchProviders ?? Enumerable.Empty(); + var resolvedSearchProviders = searchProviders ?? Enumerable.Empty(); _searchResultScorer = searchResultScorer; _searchResultSorting = searchResultSorting; _asinSearchHandler = asinSearchHandler; _cache = cache; _coverImageProbe = coverImageProbe; - _htmlTextExtractor = htmlTextExtractor; + _indexerSearchWorkflow = indexerSearchWorkflow ?? new IndexerSearchWorkflow( + httpClient, + configurationService, + indexerRepository, + resolvedSearchProviders, + new IndexerAdditionalSettingsParser(NullLogger.Instance), + NullLogger.Instance, + htmlTextExtractor); + _metadataSourceCatalog = metadataSourceCatalog ?? new MetadataSourceCatalog( + apiConfigRepository, + NullLogger.Instance); } public async Task> SearchAsync(string query, string? category = null, List? apiIds = null, SearchSortBy sortBy = SearchSortBy.Seeders, SearchSortDirection sortDirection = SearchSortDirection.Descending, bool isAutomaticSearch = false) @@ -142,59 +148,7 @@ internal double CalculateProwlarrStyleScore(SearchResult result, Indexer? indexe public async Task> SearchIndexersAsync(string query, string? category = null, SearchSortBy sortBy = SearchSortBy.Seeders, SearchSortDirection sortDirection = SearchSortDirection.Descending, bool isAutomaticSearch = false, SearchRequest? request = null) { - var results = new List(); - var indexers = await _indexerRepository.GetEnabledAsync(isAutomaticSearch); - - _logger.LogInformation("Searching {Count} enabled indexers for query: {Query}", indexers.Count, query); - - // If no indexers are configured, return mock data for development - if (!indexers.Any()) - { - _logger.LogWarning("No indexers configured, returning mock results for query: {Query}", query); - return GenerateMockIndexerResults(query); - } - - // Search all enabled indexers in parallel - var searchTasks = indexers.Select(async indexer => - { - try - { - _logger.LogInformation("Searching indexer {Name} ({Type}) for query: {Query}", indexer.Name, indexer.Type, query); - // Apply indexer-level MyAnonamouse options if not provided explicitly on the request - var perIndexerRequest = request; - if (perIndexerRequest?.MyAnonamouse == null) - { - var mam = ParseMamOptionsFromAdditionalSettings(indexer.AdditionalSettings); - if (mam != null) - { - perIndexerRequest ??= new SearchRequest(); - perIndexerRequest.MyAnonamouse = mam; - } - } - - var indexerResults = await SearchIndexerAsync(indexer, query, category, perIndexerRequest); - _logger.LogInformation("Found {Count} results from indexer {Name}", indexerResults.Count, indexer.Name); - return indexerResults; - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogError(ex, "Error searching indexer {Name} for query: {Query}", indexer.Name, query); - return new List(); - } - }).ToList(); - - var indexerResults = await Task.WhenAll(searchTasks); - - // Flatten all results - foreach (var indexerResult in indexerResults) - { - results.AddRange(indexerResult); - } - - _logger.LogInformation("Total {Count} results from all indexers for query: {Query}", results.Count, query); - - // Sort by seeders (descending) then by date - treat missing/null seeders as 0 so usenet results sort consistently - return results.OrderByDescending(r => r.Seeders ?? 0).ThenByDescending(r => r.PublishedDate).ToList(); + return await _indexerSearchWorkflow.SearchIndexersAsync(query, category, sortBy, sortDirection, isAutomaticSearch, request); } public async Task> IntelligentSearchAsync(string query, int candidateLimit = 200, int returnLimit = 100, string containmentMode = "Relaxed", bool requireAuthorAndPublisher = false, double fuzzyThreshold = 0.2, string region = "us", string? language = null, CancellationToken ct = default) @@ -1039,763 +993,22 @@ private string ExtractAsin(string magnetLink) public async Task> SearchByApiAsync(string apiId, string query, string? category = null) { - try - { - Indexer? indexer = null; - - // Try parsing apiId as numeric indexer ID first - indexer = int.TryParse(apiId, out var indexerId) - ? await _indexerRepository.GetByIdAsync(indexerId) - : await _indexerRepository.GetByNameAsync(apiId); - - if (indexer == null) - { - _logger.LogWarning("Indexer not found for apiId: {ApiId}", apiId); - return new List(); - } - - if (!indexer.IsEnabled) - { - _logger.LogWarning("Indexer {IndexerName} (apiId: {ApiId}) is not enabled", indexer.Name, apiId); - return new List(); - } - - // By default, reuse existing SearchIndexerAsync for a SearchResult response - var req = new SearchRequest(); - // If this indexer has MyAnonamouse options encoded in AdditionalSettings, apply them - var mamOpts = ParseMamOptionsFromAdditionalSettings(indexer.AdditionalSettings); - if (mamOpts != null) req.MyAnonamouse = mamOpts; - - var idxResults = await SearchIndexerAsync(indexer, query, category, req); - return idxResults.Select(r => SearchResultConverters.ToSearchResult(r)).ToList(); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogError(ex, $"Error searching indexer {apiId} for query: {query}"); - return new List(); - } + return await _indexerSearchWorkflow.SearchByApiAsync(apiId, query, category); } public async Task> SearchIndexerResultsAsync(string apiId, string query, string? category = null, SearchRequest? request = null) { - try - { - Indexer? indexer = null; - - indexer = int.TryParse(apiId, out var indexerId) - ? await _indexerRepository.GetByIdAsync(indexerId) - : await _indexerRepository.GetByNameAsync(apiId); - - if (indexer == null || !indexer.IsEnabled) - { - _logger.LogWarning("Indexer not found or disabled for apiId: {ApiId}", apiId); - return new List(); - } - - // Apply MyAnonamouse options from indexer if not provided explicitly - if (request?.MyAnonamouse == null) - { - var mam = ParseMamOptionsFromAdditionalSettings(indexer.AdditionalSettings); - if (mam != null) - { - request ??= new SearchRequest(); - request.MyAnonamouse = mam; - } - } - - var idxResults = await SearchIndexerAsync(indexer, query, category, request); - return idxResults; - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogError(ex, $"Error searching indexer {apiId} for query: {query}"); - return new List(); - } - } - - private MyAnonamouseOptions? ParseMamOptionsFromAdditionalSettings(string? additional) - { - if (string.IsNullOrWhiteSpace(additional)) return null; - try - { - using var doc = JsonDocument.Parse(additional); - var root = doc.RootElement; - // Expect either { mam_id: '...', mam_options: { ... } } or { mam_id: '...', ...flat options... } - if (root.ValueKind != JsonValueKind.Object) return null; - - var opts = new MyAnonamouseOptions(); - if (root.TryGetProperty("mam_options", out var mo) && mo.ValueKind == JsonValueKind.Object) - { - if (mo.TryGetProperty("searchInDescription", out var sid) && (sid.ValueKind == JsonValueKind.True || sid.ValueKind == JsonValueKind.False)) - opts.SearchInDescription = sid.GetBoolean(); - if (mo.TryGetProperty("searchInSeries", out var sis) && (sis.ValueKind == JsonValueKind.True || sis.ValueKind == JsonValueKind.False)) - opts.SearchInSeries = sis.GetBoolean(); - if (mo.TryGetProperty("searchInFilenames", out var sif) && (sif.ValueKind == JsonValueKind.True || sif.ValueKind == JsonValueKind.False)) - opts.SearchInFilenames = sif.GetBoolean(); - if (mo.TryGetProperty("language", out var lang) && lang.ValueKind == JsonValueKind.String) - opts.SearchLanguage = lang.GetString(); - if (mo.TryGetProperty("filter", out var filter) && - filter.ValueKind == JsonValueKind.String && - Enum.TryParse(filter.GetString() ?? string.Empty, true, out var f)) - opts.Filter = f; - if (mo.TryGetProperty("freeleechWedge", out var wedge) && - wedge.ValueKind == JsonValueKind.String && - Enum.TryParse(wedge.GetString() ?? string.Empty, true, out var w)) - opts.FreeleechWedge = w; - if (mo.TryGetProperty("enrichResults", out var enrich) && (enrich.ValueKind == JsonValueKind.True || enrich.ValueKind == JsonValueKind.False)) - opts.EnrichResults = enrich.GetBoolean(); - if (mo.TryGetProperty("enrichTopResults", out var enrichTop) && (enrichTop.ValueKind == JsonValueKind.Number || enrichTop.ValueKind == JsonValueKind.String)) - { - if (enrichTop.ValueKind == JsonValueKind.Number) opts.EnrichTopResults = enrichTop.GetInt32(); - else if (int.TryParse(enrichTop.GetString(), out var etmp)) opts.EnrichTopResults = etmp; - } - return opts; - } - - // Fallback: check for flat properties directly on root - if (root.TryGetProperty("searchInDescription", out var sid2) && (sid2.ValueKind == JsonValueKind.True || sid2.ValueKind == JsonValueKind.False)) - opts.SearchInDescription = sid2.GetBoolean(); - if (root.TryGetProperty("searchInSeries", out var sis2) && (sis2.ValueKind == JsonValueKind.True || sis2.ValueKind == JsonValueKind.False)) - opts.SearchInSeries = sis2.GetBoolean(); - if (root.TryGetProperty("searchInFilenames", out var sif2) && (sif2.ValueKind == JsonValueKind.True || sif2.ValueKind == JsonValueKind.False)) - opts.SearchInFilenames = sif2.GetBoolean(); - if (root.TryGetProperty("language", out var lang2) && lang2.ValueKind == JsonValueKind.String) - opts.SearchLanguage = lang2.GetString(); - if (root.TryGetProperty("filter", out var filter2) && - filter2.ValueKind == JsonValueKind.String && - Enum.TryParse(filter2.GetString() ?? string.Empty, true, out var f2)) - opts.Filter = f2; - if (root.TryGetProperty("freeleechWedge", out var wedge2) && - wedge2.ValueKind == JsonValueKind.String && - Enum.TryParse(wedge2.GetString() ?? string.Empty, true, out var w2)) - opts.FreeleechWedge = w2; - if (root.TryGetProperty("enrichResults", out var enrich2) && (enrich2.ValueKind == JsonValueKind.True || enrich2.ValueKind == JsonValueKind.False)) - opts.EnrichResults = enrich2.GetBoolean(); - if (root.TryGetProperty("enrichTopResults", out var enrichTop2) && (enrichTop2.ValueKind == JsonValueKind.Number || enrichTop2.ValueKind == JsonValueKind.String)) - { - if (enrichTop2.ValueKind == JsonValueKind.Number) opts.EnrichTopResults = enrichTop2.GetInt32(); - else if (int.TryParse(enrichTop2.GetString(), out var etmp2)) opts.EnrichTopResults = etmp2; - } - - // If no properties were found, return null - if (opts.SearchInDescription == null && opts.SearchInSeries == null && opts.SearchInFilenames == null && opts.SearchLanguage == null && opts.Filter == null && opts.FreeleechWedge == null && opts.EnrichResults == null && opts.EnrichTopResults == null) - return null; - - return opts; - } - catch (JsonException ex) - { - _logger.LogWarning(ex, "Failed to parse AdditionalSettings JSON for MAM options"); - return null; - } + return await _indexerSearchWorkflow.SearchIndexerResultsAsync(apiId, query, category, request); } public async Task TestApiConnectionAsync(string apiId) { - try - { - var apiConfig = await _configurationService.GetApiConfigurationAsync(apiId); - if (apiConfig == null) return false; - - // Test connection to the API - var response = await _httpClient.GetAsync(apiConfig.BaseUrl); - return response.IsSuccessStatusCode; - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogError(ex, $"Error testing API connection for {apiId}"); - return false; - } - } - - private async Task> SearchIndexerAsync(Indexer indexer, string query, string? category = null, SearchRequest? request = null) - { - try - { - // Sanitize the query for indexer searches to remove illegal characters - query = SanitizeIndexerQuery(query); - _logger.LogInformation("Searching indexer {Name} ({Implementation}) for: {Query}", indexer.Name, indexer.Implementation, query); - - // Route to appropriate search method based on implementation - - // Compute a single fallback name to use when indexer.Name is empty - string fallbackName; - if (!string.IsNullOrWhiteSpace(indexer.Name)) - { - fallbackName = indexer.Name; - } - else if (!string.IsNullOrWhiteSpace(indexer.Implementation)) - { - fallbackName = indexer.Implementation; - } - else - { - try - { - var baseUrl = indexer.Url?.TrimEnd('/') ?? string.Empty; - var baseUri = new Uri(baseUrl.StartsWith("http", StringComparison.OrdinalIgnoreCase) ? baseUrl : "https://" + baseUrl); - fallbackName = baseUri.Host; - } - catch (Exception caughtEx_18) when (caughtEx_18 is not OperationCanceledException && caughtEx_18 is not OutOfMemoryException && caughtEx_18 is not StackOverflowException) - { - fallbackName = "Indexer"; - } - } - - // Try to find a matching provider for this indexer type - var provider = _searchProviders.FirstOrDefault(p => - p.IndexerType.Equals(indexer.Implementation, StringComparison.OrdinalIgnoreCase) || - (p.IndexerType.Equals("Torznab", StringComparison.OrdinalIgnoreCase) && indexer.Implementation.Equals("Newznab", StringComparison.OrdinalIgnoreCase))); - - if (provider != null) - { - var providerResults = await provider.SearchAsync(indexer, query, category, request); - // Ensure Source is set for all results - foreach (var r in providerResults.Where(r => string.IsNullOrWhiteSpace(r.Source))) - { - r.Source = fallbackName; - } - return providerResults; - } - else - { - // Default fallback if no provider matches - _logger.LogWarning("No provider found for indexer type: {Implementation}", indexer.Implementation); - return new List(); - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogError(ex, "Error searching indexer {Name}", indexer.Name); - return new List(); - } - } - - /// - /// Remove illegal/unsupported characters from indexer search queries. - /// Strips a curated set of punctuation/symbols, smart quotes, control - /// and formatting Unicode categories, then collapses whitespace. - /// - private string SanitizeIndexerQuery(string query) - { - if (string.IsNullOrWhiteSpace(query)) return string.Empty; - - // Characters explicitly requested to strip - // Added parentheses to remove '(' and ')' from queries - const string forbidden = "*/\\<>:?|^~`$#%&+={}[]'\"!()"; - - var sb = new System.Text.StringBuilder(query.Length); - foreach (var ch in query) - { - // Remove control and format characters - var uc = System.Globalization.CharUnicodeInfo.GetUnicodeCategory(ch); - if (char.IsControl(ch) || uc == System.Globalization.UnicodeCategory.Format) - continue; - - // Remove explicit forbidden ASCII symbols - if (forbidden.IndexOf(ch) >= 0) - continue; - - // Remove common smart quotes and other punctuation variants - // Left/right single quotation mark, left/right double quotation mark - if (ch == '\u2018' || ch == '\u2019' || ch == '\u201C' || ch == '\u201D') - continue; - - sb.Append(ch); - } - - // Collapse runs of whitespace to single space and trim - var cleaned = System.Text.RegularExpressions.Regex.Replace(sb.ToString(), "\\s+", " ").Trim(); - return cleaned; + return await _indexerSearchWorkflow.TestApiConnectionAsync(apiId); } internal async Task> ParseTorznabResponseAsync(string xmlContent, Indexer indexer) { - var results = new List(); - - try - { - // Log first 500 chars of XML for debugging - var preview = xmlContent.Length > 500 ? xmlContent.Substring(0, 500) + "..." : xmlContent; - _logger.LogDebug("Parsing XML from {IndexerName}: {Preview}", indexer.Name, preview); - - // Parse XML with settings that are more lenient - var settings = new System.Xml.XmlReaderSettings - { - DtdProcessing = System.Xml.DtdProcessing.Ignore, - XmlResolver = null, - IgnoreWhitespace = true, - IgnoreComments = true - }; - - System.Xml.Linq.XDocument doc; - using (var reader = System.Xml.XmlReader.Create(new System.IO.StringReader(xmlContent), settings)) - { - doc = System.Xml.Linq.XDocument.Load(reader); - } - - var channel = doc.Root?.Element("channel"); - if (channel == null) - { - _logger.LogWarning("Invalid Torznab response: no channel element"); - return results; - } - - var items = channel.Elements("item"); - var isUsenet = indexer.Type.Equals("Usenet", StringComparison.OrdinalIgnoreCase); - - foreach (var item in items) - { - try - { - var result = new IndexerSearchResult - { - Id = item.Element("guid")?.Value ?? Guid.NewGuid().ToString(), - Title = item.Element("title")?.Value ?? "Unknown", - Source = indexer.Name, - Category = item.Element("category")?.Value ?? "Audiobook" - }; - result.IndexerId = indexer.Id; - result.IndexerImplementation = indexer.Implementation; - - // Parse published date - var pubDateStr = item.Element("pubDate")?.Value; - result.PublishedDate = DateTime.TryParse(pubDateStr, out var pubDate) - ? pubDate.ToString("o") - : string.Empty; - - // Parse Torznab/Newznab attributes (support both torznab and newznab namespaces) - var torznabNs = System.Xml.Linq.XNamespace.Get("http://torznab.com/schemas/2015/feed"); - var newznabNs = System.Xml.Linq.XNamespace.Get("http://www.newznab.com/DTD/2010/feeds/attributes/"); - var attributes = item.Elements(torznabNs + "attr").Concat(item.Elements(newznabNs + "attr")).ToList(); - - foreach (var attr in attributes) - { - var name = attr.Attribute("name")?.Value; - var value = attr.Attribute("value")?.Value; - - if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(value)) - continue; - - switch (name.ToLower()) - { - case "size": - var parsedSize = ParseSizeString(value); - if (parsedSize > 0) - { - result.Size = parsedSize; - _logger.LogDebug("Parsed size for {Title}: {Size} bytes from indexer {Indexer}", result.Title, parsedSize, indexer.Name); - } - else - { - _logger.LogWarning("Failed to parse size value '{Value}' for result '{Title}' from indexer {Indexer}", value, result.Title, indexer.Name); - } - break; - case "seeders": - if (int.TryParse(value, out var seeders)) - result.Seeders = seeders; - break; - case "peers": - if (int.TryParse(value, out var peers)) - result.Leechers = peers; - break; - case "magneturl": - result.MagnetLink = value; - break; - case "filetype": - case "format": - // Prefer explicit filetype/format attributes - var normalizedFmt = value.ToLowerInvariant(); - if (normalizedFmt.Contains("m4b")) result.Format = "M4B"; - else if (normalizedFmt.Contains("flac")) result.Format = "FLAC"; - else if (normalizedFmt.Contains("opus")) result.Format = "OPUS"; - else if (normalizedFmt.Contains("aac")) result.Format = "AAC"; - else if (normalizedFmt.Contains("mp3")) result.Format = "MP3"; - - // Also set Quality from format where possible - if (string.IsNullOrEmpty(result.Quality)) - { - if (normalizedFmt.Contains("320")) result.Quality = "MP3 320kbps"; - else if (normalizedFmt.Contains("256")) result.Quality = "MP3 256kbps"; - else if (normalizedFmt.Contains("192")) result.Quality = "MP3 192kbps"; - else if (normalizedFmt.Contains("128")) result.Quality = "MP3 128kbps"; - else if (normalizedFmt.Contains("m4b")) result.Quality = "M4B"; - } - break; - case "lang_code": - case "language_code": - case "lang": - // Standardized language codes (e.g., ENG, FR) - try - { - var parsedLang = SearchResultAttributeParser.ParseLanguageFromText(value); - if (!string.IsNullOrEmpty(parsedLang)) result.Language = parsedLang; - } - catch (Exception caughtEx_22) when (caughtEx_22 is not OperationCanceledException && caughtEx_22 is not OutOfMemoryException && caughtEx_22 is not StackOverflowException) - { - System.Diagnostics.Debug.WriteLine("Suppressed non-fatal exception in catch block."); - } - break; - case "language": - // Some indexers use numeric language IDs (e.g., 1 -> ENG) - if (int.TryParse(value, out var langNum)) - { - if (langNum == 1) result.Language = "English"; - // Add other mappings if required in the future - } - else - { - try - { - var pl = SearchResultAttributeParser.ParseLanguageFromText(value); - if (!string.IsNullOrEmpty(pl)) result.Language = pl; - } - catch (Exception caughtEx_23) when (caughtEx_23 is not OperationCanceledException && caughtEx_23 is not OutOfMemoryException && caughtEx_23 is not StackOverflowException) - { - System.Diagnostics.Debug.WriteLine("Suppressed non-fatal exception in catch block."); - } - } - break; - case "grabs": - if (int.TryParse(value, out var grabs)) - result.Grabs = grabs; - break; - case "files": - if (int.TryParse(value, out var files)) - result.Files = files; - break; - case "usenetdate": - // Some indexers expose a usenet-specific date attribute; prefer it if parseable - if (long.TryParse(value, out var unixSec)) - { - try - { - var dt = DateTimeOffset.FromUnixTimeSeconds(unixSec).UtcDateTime; - result.PublishedDate = dt.ToString("o"); - } - catch (Exception caughtEx_24) when (caughtEx_24 is not OperationCanceledException && caughtEx_24 is not OutOfMemoryException && caughtEx_24 is not StackOverflowException) - { - System.Diagnostics.Debug.WriteLine("Suppressed non-fatal exception in catch block."); - } - } - else if (DateTime.TryParse(value, out var udt)) - { - result.PublishedDate = udt.ToString("o"); - } - break; - } - } - - // Fallback: some indexers don't expose "grabs" as a standard torznab/newznab attr. - // Attempt a few common alternate attribute names and elements (snatches, comments, etc.) - if (result.Grabs == 0) - { - var altNames = new[] { "snatches", "snatched", "numgrabs", "num_grabs", "grab_count" }; - foreach (var alt in altNames) - { - var altAttr = attributes.FirstOrDefault(a => string.Equals(a.Attribute("name")?.Value, alt, System.StringComparison.OrdinalIgnoreCase)); - if (altAttr != null) - { - var av = altAttr.Attribute("value")?.Value ?? altAttr.Value; - if (!string.IsNullOrEmpty(av) && int.TryParse(av, out var g2)) - { - result.Grabs = g2; - _logger.LogDebug("Set grabs from alternate attr '{Alt}' for {Title}: {Grabs}", alt, result.Title, g2); - break; - } - } - } - - // If still zero, and a comments element points to a details URL (althub-style), attempt to scrape comment count - if (result.Grabs == 0) - { - var commentsVal = item.Element("comments")?.Value; - if (!string.IsNullOrEmpty(commentsVal)) - { - // If comments is a URL, try scraping the page for a numeric comments count (only for known indexers to avoid many extra requests) - if (Uri.TryCreate(commentsVal, UriKind.Absolute, out var commentsUri) && indexer.Url != null && indexer.Url.Contains("althub", StringComparison.OrdinalIgnoreCase)) - { - try - { - var commentsPageUrl = new Uri(commentsUri.GetLeftPart(UriPartial.Path)); - _logger.LogDebug("Fetching comments page to extract grabs for {Title}: {Url}", result.Title, commentsPageUrl); - using var resp = await _httpClient.GetAsync(commentsPageUrl); - if (resp.IsSuccessStatusCode) - { - var html = await resp.Content.ReadAsStringAsync(); - // Look for common comment count patterns in page text - var text = _htmlTextExtractor?.ExtractText(html) ?? html; - var m = System.Text.RegularExpressions.Regex.Match(text, "(\\d{1,6})\\s+comments?", System.Text.RegularExpressions.RegexOptions.IgnoreCase); - if (!m.Success) - { - m = System.Text.RegularExpressions.Regex.Match(text, "Comments\\s*[:\\(]?\\s*(\\d{1,6})", System.Text.RegularExpressions.RegexOptions.IgnoreCase); - } - - if (m.Success && int.TryParse(m.Groups[1].Value, out var scrapedComments)) - { - result.Grabs = scrapedComments; - _logger.LogDebug("Scraped comments count for {Title}: {Grabs}", result.Title, scrapedComments); - } - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogDebug(ex, "Failed to scrape comments page for {Title}", result.Title); - } - } - else - { - // Some feeds put a numeric comments value directly; parse that - if (int.TryParse(commentsVal, out var commVal)) - { - result.Grabs = commVal; - _logger.LogDebug("Set grabs from element for {Title}: {Grabs}", result.Title, commVal); - } - } - } - } - } - - // Get enclosure/link for download URL - var enclosure = item.Element("enclosure"); - if (enclosure != null) - { - var enclosureUrl = enclosure.Attribute("url")?.Value; - if (!string.IsNullOrEmpty(enclosureUrl)) - { - if (isUsenet) - { - result.NzbUrl = enclosureUrl; - } - else - { - result.TorrentUrl = enclosureUrl; - } - } - - // If the indexer provides an enclosure length, use it as a size fallback - var lengthStr = enclosure.Attribute("length")?.Value; - if (!string.IsNullOrEmpty(lengthStr) && result.Size == 0) - { - var parsedLen = ParseSizeString(lengthStr); - if (parsedLen > 0) - { - result.Size = parsedLen; - _logger.LogDebug("Set size from enclosure length for {Title}: {Size} bytes", result.Title, parsedLen); - } - } - } - - // If no magnet link found in attributes, check link element - var linkElem = item.Element("link")?.Value; - if (!string.IsNullOrEmpty(linkElem)) - { - if (linkElem.StartsWith("magnet:") && string.IsNullOrEmpty(result.MagnetLink) && !isUsenet) - { - result.MagnetLink = linkElem; - } - else - { - // Use the link element as the canonical indexer page when possible - if (Uri.IsWellFormedUriString(linkElem, UriKind.Absolute)) - { - result.ResultUrl = linkElem; - } - - // If torrentUrl is empty, prefer the link - if (string.IsNullOrEmpty(result.TorrentUrl) && !linkElem.StartsWith("magnet:") && !isUsenet) - { - result.TorrentUrl = linkElem; - } - else if (string.IsNullOrEmpty(result.NzbUrl) && isUsenet && !linkElem.StartsWith("magnet:")) - { - result.NzbUrl = linkElem; - } - } - } - - // Parse description for additional metadata - var description = item.Element("description")?.Value; - if (!string.IsNullOrEmpty(description)) - { - result.Description = description; - - // Try to extract quality/format from description or title - var titleAndDesc = $"{result.Title} {description}".ToLower(); - - if (titleAndDesc.Contains("flac")) - result.Quality = "FLAC"; - else if (titleAndDesc.Contains("320") || titleAndDesc.Contains("320kbps")) - result.Quality = "MP3 320kbps"; - else if (titleAndDesc.Contains("256") || titleAndDesc.Contains("256kbps")) - result.Quality = "MP3 256kbps"; - else if (titleAndDesc.Contains("192") || titleAndDesc.Contains("192kbps")) - result.Quality = "MP3 192kbps"; - else if (titleAndDesc.Contains("128") || titleAndDesc.Contains("128kbps")) - result.Quality = "MP3 128kbps"; - else if (titleAndDesc.Contains("64") || titleAndDesc.Contains("64kbps")) - result.Quality = "MP3 64kbps"; - else if (titleAndDesc.Contains("m4b")) - result.Quality = "M4B"; - else - result.Quality = "Unknown"; - - // Detect format - if (titleAndDesc.Contains("m4b")) - result.Format = "M4B"; - else if (titleAndDesc.Contains("flac")) - result.Format = "FLAC"; - else if (titleAndDesc.Contains("mp3")) - result.Format = "MP3"; - else if (titleAndDesc.Contains("opus")) - result.Format = "OPUS"; - else if (titleAndDesc.Contains("aac")) - result.Format = "AAC"; - - // Detect language codes present in title or description (e.g. [ENG / M4B]) - try - { - var lang = SearchResultAttributeParser.ParseLanguageFromText(result.Title + " " + description); - if (!string.IsNullOrEmpty(lang)) result.Language = lang; - } - catch (Exception caughtEx_25) when (caughtEx_25 is not OperationCanceledException && caughtEx_25 is not OutOfMemoryException && caughtEx_25 is not StackOverflowException) - { /* Non-critical */ - System.Diagnostics.Debug.WriteLine("Suppressed non-fatal exception in catch block."); - } - } - - // Extract author from title if possible (common format: "Author - Title") - var titleParts = result.Title.Split(new[] { " - ", " – " }, StringSplitOptions.RemoveEmptyEntries); - if (titleParts.Length >= 2) - { - result.Artist = titleParts[0].Trim(); - result.Album = string.Join(" - ", titleParts.Skip(1)).Trim(); - } - else - { - result.Artist = "Unknown Author"; - result.Album = result.Title; - } - - // Only add results that have a valid download link - if (!string.IsNullOrEmpty(result.MagnetLink) || - !string.IsNullOrEmpty(result.TorrentUrl) || - !string.IsNullOrEmpty(result.NzbUrl)) - { - // Set download type based on what's available - if (!string.IsNullOrEmpty(result.NzbUrl)) - { - result.DownloadType = "Usenet"; - } - else if (!string.IsNullOrEmpty(result.MagnetLink) || !string.IsNullOrEmpty(result.TorrentUrl)) - { - result.DownloadType = "Torrent"; - } - - results.Add(result); - } - else - { - _logger.LogWarning("Skipping result '{Title}' - no download link found", result.Title); - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogError(ex, "Error parsing indexer result item"); - } - } - } - catch (System.Xml.XmlException xmlEx) - { - _logger.LogError(xmlEx, "XML parsing error from {IndexerName} at Line {Line}, Position {Position}: {Message}", - indexer.Name, xmlEx.LineNumber, xmlEx.LinePosition, xmlEx.Message); - - // Log the problematic XML content around the error - if (!string.IsNullOrEmpty(xmlContent)) - { - var lines = xmlContent.Split('\n'); - if (xmlEx.LineNumber > 0 && xmlEx.LineNumber <= lines.Length) - { - var startLine = Math.Max(0, xmlEx.LineNumber - 3); - var endLine = Math.Min(lines.Length - 1, xmlEx.LineNumber + 2); - var context = string.Join("\n", lines[startLine..(endLine + 1)]); - _logger.LogError("XML context around error:\n{Context}", context); - } - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogError(ex, "Error parsing Torznab XML response from {IndexerName}", indexer.Name); - } - - return results; - } - - private List GenerateMockIndexerResults(string query) - { - // Generate multiple mock results to simulate real indexer responses - // Default to torrent for backwards compatibility - return GenerateMockIndexerResults(query, "Mock Indexer", "Torrent"); - } - - private List GenerateMockIndexerResults(string query, string indexerName) - { - // Default to torrent for backwards compatibility - return GenerateMockIndexerResults(query, indexerName, "Torrent"); - } - - private List GenerateMockIndexerResults(string query, string indexerName, string indexerType) - { - // Generate multiple mock results to simulate real indexer responses - var random = new Random(); - var results = new List(); - var isUsenet = indexerType.Equals("Usenet", StringComparison.OrdinalIgnoreCase); - - _logger.LogInformation("Generating {Count} mock {Type} results for indexer {IndexerName}", 5, indexerType, indexerName); - - for (int i = 0; i < 5; i++) - { - var result = new IndexerSearchResult - { - Id = Guid.NewGuid().ToString(), - Title = $"{query} - Quality {i + 1}", - Artist = "Various Authors", - Album = $"{query} Series", - Category = "Audiobook", - Size = random.Next(200_000_000, 1_500_000_000), // 200 MB to 1.5 GB - Seeders = isUsenet ? 0 : random.Next(5, 100), // Usenet doesn't have seeders - Leechers = isUsenet ? 0 : random.Next(0, 20), // Usenet doesn't have leechers - Source = indexerName, - PublishedDate = DateTime.UtcNow.AddDays(-random.Next(1, 365)).ToString("o"), - Quality = i switch - { - 0 => "MP3 64kbps", - 1 => "MP3 128kbps", - 2 => "MP3 192kbps", - 3 => "M4B 128kbps", - _ => "FLAC" - }, - Format = i >= 3 ? "M4B" : "MP3", - Language = "English" - }; - - // Set appropriate download link based on indexer type - if (isUsenet) - { - result.NzbUrl = $"https://{indexerName.ToLower()}.example.com/api/nzb/{Guid.NewGuid():N}"; - result.MagnetLink = string.Empty; - result.TorrentUrl = string.Empty; - } - else - { - result.MagnetLink = $"magnet:?xt=urn:btih:{Guid.NewGuid():N}"; - result.NzbUrl = string.Empty; - } - - results.Add(result); - } - - return results; + return await _indexerSearchWorkflow.ParseTorznabResponseAsync(xmlContent, indexer); } private List GenerateMockResults(string query, string source) @@ -1823,76 +1036,9 @@ private List GenerateMockResults(string query, string source) } - private long ParseSizeString(string sizeStr) - { - if (string.IsNullOrEmpty(sizeStr)) - return 0; - - // Remove any commas and extra spaces - sizeStr = sizeStr.Replace(",", "").Trim(); - - // Try to parse as direct bytes first - if (long.TryParse(sizeStr, out var bytes)) - return bytes; - - // Handle formats like "500 MB", "1.2 GB", "1024 KB", "3.7 GiB", "279.0 MiB", etc. - // Support both decimal (KB/MB/GB/TB) and binary (KiB/MiB/GiB/TiB) units - var match = System.Text.RegularExpressions.Regex.Match(sizeStr, @"^([\d\.]+)\s*(KiB|MiB|GiB|TiB|KB|MB|GB|TB|B)$", System.Text.RegularExpressions.RegexOptions.IgnoreCase); - if (match.Success && - double.TryParse(match.Groups[1].Value, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var value)) - { - var unit = match.Groups[2].Value.ToUpper(); - return unit switch - { - "B" => (long)value, - "KB" => (long)(value * 1000), - "MB" => (long)(value * 1000 * 1000), - "GB" => (long)(value * 1000 * 1000 * 1000), - "TB" => (long)(value * 1000 * 1000 * 1000 * 1000), - "KIB" => (long)(value * 1024), - "MIB" => (long)(value * 1024 * 1024), - "GIB" => (long)(value * 1024 * 1024 * 1024), - "TIB" => (long)(value * 1024 * 1024 * 1024 * 1024), - _ => (long)value - }; - } - - _logger.LogWarning("Unable to parse size string: '{SizeStr}'", sizeStr); - return 0; - } - - // (Helper methods for containment and fuzzy scoring are implemented above.) - public async Task> GetEnabledMetadataSourcesAsync() { - try - { - _logger.LogDebug("Querying database for enabled metadata sources..."); - - var allConfigs = await _apiConfigRepository.GetAllAsync(); - var metadataSources = allConfigs - .Where(api => api.IsEnabled && api.Type == "metadata") - .OrderBy(api => api.Priority) - .ToList(); - - if (metadataSources.Count > 0) - { - _logger.LogInformation("Retrieved {Count} enabled metadata sources: {Sources}", - metadataSources.Count, - string.Join(", ", metadataSources.Select(s => $"{s.Name} (Priority: {s.Priority}, BaseUrl: {s.BaseUrl})"))); - } - else - { - _logger.LogWarning("No enabled metadata sources found in database"); - } - - return metadataSources; - } - catch (InvalidOperationException ex) - { - _logger.LogError(ex, "Invalid operation error retrieving enabled metadata sources"); - return new List(); - } + return await _metadataSourceCatalog.GetEnabledMetadataSourcesAsync(); } } } diff --git a/listenarr.application/Search/TorznabResponseParser.cs b/listenarr.application/Search/TorznabResponseParser.cs new file mode 100644 index 000000000..7d22e5367 --- /dev/null +++ b/listenarr.application/Search/TorznabResponseParser.cs @@ -0,0 +1,504 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +using Listenarr.Application.Interfaces; +using Listenarr.Domain.Models; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Application.Search +{ + internal sealed class TorznabResponseParser + { + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + private readonly IHtmlTextExtractor? _htmlTextExtractor; + + public TorznabResponseParser( + HttpClient httpClient, + ILogger logger, + IHtmlTextExtractor? htmlTextExtractor = null) + { + _httpClient = httpClient; + _logger = logger; + _htmlTextExtractor = htmlTextExtractor; + } + public async Task> ParseAsync(string xmlContent, Indexer indexer) + { + var results = new List(); + + try + { + // Log first 500 chars of XML for debugging + var preview = xmlContent.Length > 500 ? xmlContent.Substring(0, 500) + "..." : xmlContent; + _logger.LogDebug("Parsing XML from {IndexerName}: {Preview}", indexer.Name, preview); + + // Parse XML with settings that are more lenient + var settings = new System.Xml.XmlReaderSettings + { + DtdProcessing = System.Xml.DtdProcessing.Ignore, + XmlResolver = null, + IgnoreWhitespace = true, + IgnoreComments = true + }; + + System.Xml.Linq.XDocument doc; + using (var reader = System.Xml.XmlReader.Create(new System.IO.StringReader(xmlContent), settings)) + { + doc = System.Xml.Linq.XDocument.Load(reader); + } + + var channel = doc.Root?.Element("channel"); + if (channel == null) + { + _logger.LogWarning("Invalid Torznab response: no channel element"); + return results; + } + + var items = channel.Elements("item"); + var isUsenet = indexer.Type.Equals("Usenet", StringComparison.OrdinalIgnoreCase); + + foreach (var item in items) + { + try + { + var result = new IndexerSearchResult + { + Id = item.Element("guid")?.Value ?? Guid.NewGuid().ToString(), + Title = item.Element("title")?.Value ?? "Unknown", + Source = indexer.Name, + Category = item.Element("category")?.Value ?? "Audiobook" + }; + result.IndexerId = indexer.Id; + result.IndexerImplementation = indexer.Implementation; + + // Parse published date + var pubDateStr = item.Element("pubDate")?.Value; + result.PublishedDate = DateTime.TryParse(pubDateStr, out var pubDate) + ? pubDate.ToString("o") + : string.Empty; + + // Parse Torznab/Newznab attributes (support both torznab and newznab namespaces) + var torznabNs = System.Xml.Linq.XNamespace.Get("http://torznab.com/schemas/2015/feed"); + var newznabNs = System.Xml.Linq.XNamespace.Get("http://www.newznab.com/DTD/2010/feeds/attributes/"); + var attributes = item.Elements(torznabNs + "attr").Concat(item.Elements(newznabNs + "attr")).ToList(); + + foreach (var attr in attributes) + { + var name = attr.Attribute("name")?.Value; + var value = attr.Attribute("value")?.Value; + + if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(value)) + continue; + + switch (name.ToLower()) + { + case "size": + var parsedSize = ParseSizeString(value); + if (parsedSize > 0) + { + result.Size = parsedSize; + _logger.LogDebug("Parsed size for {Title}: {Size} bytes from indexer {Indexer}", result.Title, parsedSize, indexer.Name); + } + else + { + _logger.LogWarning("Failed to parse size value '{Value}' for result '{Title}' from indexer {Indexer}", value, result.Title, indexer.Name); + } + break; + case "seeders": + if (int.TryParse(value, out var seeders)) + result.Seeders = seeders; + break; + case "peers": + if (int.TryParse(value, out var peers)) + result.Leechers = peers; + break; + case "magneturl": + result.MagnetLink = value; + break; + case "filetype": + case "format": + // Prefer explicit filetype/format attributes + var normalizedFmt = value.ToLowerInvariant(); + if (normalizedFmt.Contains("m4b")) result.Format = "M4B"; + else if (normalizedFmt.Contains("flac")) result.Format = "FLAC"; + else if (normalizedFmt.Contains("opus")) result.Format = "OPUS"; + else if (normalizedFmt.Contains("aac")) result.Format = "AAC"; + else if (normalizedFmt.Contains("mp3")) result.Format = "MP3"; + + // Also set Quality from format where possible + if (string.IsNullOrEmpty(result.Quality)) + { + if (normalizedFmt.Contains("320")) result.Quality = "MP3 320kbps"; + else if (normalizedFmt.Contains("256")) result.Quality = "MP3 256kbps"; + else if (normalizedFmt.Contains("192")) result.Quality = "MP3 192kbps"; + else if (normalizedFmt.Contains("128")) result.Quality = "MP3 128kbps"; + else if (normalizedFmt.Contains("m4b")) result.Quality = "M4B"; + } + break; + case "lang_code": + case "language_code": + case "lang": + // Standardized language codes (e.g., ENG, FR) + try + { + var parsedLang = SearchResultAttributeParser.ParseLanguageFromText(value); + if (!string.IsNullOrEmpty(parsedLang)) result.Language = parsedLang; + } + catch (Exception caughtEx_22) when (caughtEx_22 is not OperationCanceledException && caughtEx_22 is not OutOfMemoryException && caughtEx_22 is not StackOverflowException) + { + System.Diagnostics.Debug.WriteLine("Suppressed non-fatal exception in catch block."); + } + break; + case "language": + // Some indexers use numeric language IDs (e.g., 1 -> ENG) + if (int.TryParse(value, out var langNum)) + { + if (langNum == 1) result.Language = "English"; + // Add other mappings if required in the future + } + else + { + try + { + var pl = SearchResultAttributeParser.ParseLanguageFromText(value); + if (!string.IsNullOrEmpty(pl)) result.Language = pl; + } + catch (Exception caughtEx_23) when (caughtEx_23 is not OperationCanceledException && caughtEx_23 is not OutOfMemoryException && caughtEx_23 is not StackOverflowException) + { + System.Diagnostics.Debug.WriteLine("Suppressed non-fatal exception in catch block."); + } + } + break; + case "grabs": + if (int.TryParse(value, out var grabs)) + result.Grabs = grabs; + break; + case "files": + if (int.TryParse(value, out var files)) + result.Files = files; + break; + case "usenetdate": + // Some indexers expose a usenet-specific date attribute; prefer it if parseable + if (long.TryParse(value, out var unixSec)) + { + try + { + var dt = DateTimeOffset.FromUnixTimeSeconds(unixSec).UtcDateTime; + result.PublishedDate = dt.ToString("o"); + } + catch (Exception caughtEx_24) when (caughtEx_24 is not OperationCanceledException && caughtEx_24 is not OutOfMemoryException && caughtEx_24 is not StackOverflowException) + { + System.Diagnostics.Debug.WriteLine("Suppressed non-fatal exception in catch block."); + } + } + else if (DateTime.TryParse(value, out var udt)) + { + result.PublishedDate = udt.ToString("o"); + } + break; + } + } + + // Fallback: some indexers don't expose "grabs" as a standard torznab/newznab attr. + // Attempt a few common alternate attribute names and elements (snatches, comments, etc.) + if (result.Grabs == 0) + { + var altNames = new[] { "snatches", "snatched", "numgrabs", "num_grabs", "grab_count" }; + foreach (var alt in altNames) + { + var altAttr = attributes.FirstOrDefault(a => string.Equals(a.Attribute("name")?.Value, alt, System.StringComparison.OrdinalIgnoreCase)); + if (altAttr != null) + { + var av = altAttr.Attribute("value")?.Value ?? altAttr.Value; + if (!string.IsNullOrEmpty(av) && int.TryParse(av, out var g2)) + { + result.Grabs = g2; + _logger.LogDebug("Set grabs from alternate attr '{Alt}' for {Title}: {Grabs}", alt, result.Title, g2); + break; + } + } + } + + // If still zero, and a comments element points to a details URL (althub-style), attempt to scrape comment count + if (result.Grabs == 0) + { + var commentsVal = item.Element("comments")?.Value; + if (!string.IsNullOrEmpty(commentsVal)) + { + // If comments is a URL, try scraping the page for a numeric comments count (only for known indexers to avoid many extra requests) + if (Uri.TryCreate(commentsVal, UriKind.Absolute, out var commentsUri) && indexer.Url != null && indexer.Url.Contains("althub", StringComparison.OrdinalIgnoreCase)) + { + try + { + var commentsPageUrl = new Uri(commentsUri.GetLeftPart(UriPartial.Path)); + _logger.LogDebug("Fetching comments page to extract grabs for {Title}: {Url}", result.Title, commentsPageUrl); + using var resp = await _httpClient.GetAsync(commentsPageUrl); + if (resp.IsSuccessStatusCode) + { + var html = await resp.Content.ReadAsStringAsync(); + // Look for common comment count patterns in page text + var text = _htmlTextExtractor?.ExtractText(html) ?? html; + var m = System.Text.RegularExpressions.Regex.Match(text, "(\\d{1,6})\\s+comments?", System.Text.RegularExpressions.RegexOptions.IgnoreCase); + if (!m.Success) + { + m = System.Text.RegularExpressions.Regex.Match(text, "Comments\\s*[:\\(]?\\s*(\\d{1,6})", System.Text.RegularExpressions.RegexOptions.IgnoreCase); + } + + if (m.Success && int.TryParse(m.Groups[1].Value, out var scrapedComments)) + { + result.Grabs = scrapedComments; + _logger.LogDebug("Scraped comments count for {Title}: {Grabs}", result.Title, scrapedComments); + } + } + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogDebug(ex, "Failed to scrape comments page for {Title}", result.Title); + } + } + else + { + // Some feeds put a numeric comments value directly; parse that + if (int.TryParse(commentsVal, out var commVal)) + { + result.Grabs = commVal; + _logger.LogDebug("Set grabs from element for {Title}: {Grabs}", result.Title, commVal); + } + } + } + } + } + + // Get enclosure/link for download URL + var enclosure = item.Element("enclosure"); + if (enclosure != null) + { + var enclosureUrl = enclosure.Attribute("url")?.Value; + if (!string.IsNullOrEmpty(enclosureUrl)) + { + if (isUsenet) + { + result.NzbUrl = enclosureUrl; + } + else + { + result.TorrentUrl = enclosureUrl; + } + } + + // If the indexer provides an enclosure length, use it as a size fallback + var lengthStr = enclosure.Attribute("length")?.Value; + if (!string.IsNullOrEmpty(lengthStr) && result.Size == 0) + { + var parsedLen = ParseSizeString(lengthStr); + if (parsedLen > 0) + { + result.Size = parsedLen; + _logger.LogDebug("Set size from enclosure length for {Title}: {Size} bytes", result.Title, parsedLen); + } + } + } + + // If no magnet link found in attributes, check link element + var linkElem = item.Element("link")?.Value; + if (!string.IsNullOrEmpty(linkElem)) + { + if (linkElem.StartsWith("magnet:") && string.IsNullOrEmpty(result.MagnetLink) && !isUsenet) + { + result.MagnetLink = linkElem; + } + else + { + // Use the link element as the canonical indexer page when possible + if (Uri.IsWellFormedUriString(linkElem, UriKind.Absolute)) + { + result.ResultUrl = linkElem; + } + + // If torrentUrl is empty, prefer the link + if (string.IsNullOrEmpty(result.TorrentUrl) && !linkElem.StartsWith("magnet:") && !isUsenet) + { + result.TorrentUrl = linkElem; + } + else if (string.IsNullOrEmpty(result.NzbUrl) && isUsenet && !linkElem.StartsWith("magnet:")) + { + result.NzbUrl = linkElem; + } + } + } + + // Parse description for additional metadata + var description = item.Element("description")?.Value; + if (!string.IsNullOrEmpty(description)) + { + result.Description = description; + + // Try to extract quality/format from description or title + var titleAndDesc = $"{result.Title} {description}".ToLower(); + + if (titleAndDesc.Contains("flac")) + result.Quality = "FLAC"; + else if (titleAndDesc.Contains("320") || titleAndDesc.Contains("320kbps")) + result.Quality = "MP3 320kbps"; + else if (titleAndDesc.Contains("256") || titleAndDesc.Contains("256kbps")) + result.Quality = "MP3 256kbps"; + else if (titleAndDesc.Contains("192") || titleAndDesc.Contains("192kbps")) + result.Quality = "MP3 192kbps"; + else if (titleAndDesc.Contains("128") || titleAndDesc.Contains("128kbps")) + result.Quality = "MP3 128kbps"; + else if (titleAndDesc.Contains("64") || titleAndDesc.Contains("64kbps")) + result.Quality = "MP3 64kbps"; + else if (titleAndDesc.Contains("m4b")) + result.Quality = "M4B"; + else + result.Quality = "Unknown"; + + // Detect format + if (titleAndDesc.Contains("m4b")) + result.Format = "M4B"; + else if (titleAndDesc.Contains("flac")) + result.Format = "FLAC"; + else if (titleAndDesc.Contains("mp3")) + result.Format = "MP3"; + else if (titleAndDesc.Contains("opus")) + result.Format = "OPUS"; + else if (titleAndDesc.Contains("aac")) + result.Format = "AAC"; + + // Detect language codes present in title or description (e.g. [ENG / M4B]) + try + { + var lang = SearchResultAttributeParser.ParseLanguageFromText(result.Title + " " + description); + if (!string.IsNullOrEmpty(lang)) result.Language = lang; + } + catch (Exception caughtEx_25) when (caughtEx_25 is not OperationCanceledException && caughtEx_25 is not OutOfMemoryException && caughtEx_25 is not StackOverflowException) + { /* Non-critical */ + System.Diagnostics.Debug.WriteLine("Suppressed non-fatal exception in catch block."); + } + } + + // Extract author from title if possible (common format: "Author - Title") + var titleParts = result.Title.Split(new[] { " - ", " – " }, StringSplitOptions.RemoveEmptyEntries); + if (titleParts.Length >= 2) + { + result.Artist = titleParts[0].Trim(); + result.Album = string.Join(" - ", titleParts.Skip(1)).Trim(); + } + else + { + result.Artist = "Unknown Author"; + result.Album = result.Title; + } + + // Only add results that have a valid download link + if (!string.IsNullOrEmpty(result.MagnetLink) || + !string.IsNullOrEmpty(result.TorrentUrl) || + !string.IsNullOrEmpty(result.NzbUrl)) + { + // Set download type based on what's available + if (!string.IsNullOrEmpty(result.NzbUrl)) + { + result.DownloadType = "Usenet"; + } + else if (!string.IsNullOrEmpty(result.MagnetLink) || !string.IsNullOrEmpty(result.TorrentUrl)) + { + result.DownloadType = "Torrent"; + } + + results.Add(result); + } + else + { + _logger.LogWarning("Skipping result '{Title}' - no download link found", result.Title); + } + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogError(ex, "Error parsing indexer result item"); + } + } + } + catch (System.Xml.XmlException xmlEx) + { + _logger.LogError(xmlEx, "XML parsing error from {IndexerName} at Line {Line}, Position {Position}: {Message}", + indexer.Name, xmlEx.LineNumber, xmlEx.LinePosition, xmlEx.Message); + + // Log the problematic XML content around the error + if (!string.IsNullOrEmpty(xmlContent)) + { + var lines = xmlContent.Split('\n'); + if (xmlEx.LineNumber > 0 && xmlEx.LineNumber <= lines.Length) + { + var startLine = Math.Max(0, xmlEx.LineNumber - 3); + var endLine = Math.Min(lines.Length - 1, xmlEx.LineNumber + 2); + var context = string.Join("\n", lines[startLine..(endLine + 1)]); + _logger.LogError("XML context around error:\n{Context}", context); + } + } + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogError(ex, "Error parsing Torznab XML response from {IndexerName}", indexer.Name); + } + + return results; + } + + + private long ParseSizeString(string sizeStr) + { + if (string.IsNullOrEmpty(sizeStr)) + return 0; + + // Remove any commas and extra spaces + sizeStr = sizeStr.Replace(",", "").Trim(); + + // Try to parse as direct bytes first + if (long.TryParse(sizeStr, out var bytes)) + return bytes; + + // Handle formats like "500 MB", "1.2 GB", "1024 KB", "3.7 GiB", "279.0 MiB", etc. + // Support both decimal (KB/MB/GB/TB) and binary (KiB/MiB/GiB/TiB) units + var match = System.Text.RegularExpressions.Regex.Match(sizeStr, @"^([\d\.]+)\s*(KiB|MiB|GiB|TiB|KB|MB|GB|TB|B)$", System.Text.RegularExpressions.RegexOptions.IgnoreCase); + if (match.Success && + double.TryParse(match.Groups[1].Value, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var value)) + { + var unit = match.Groups[2].Value.ToUpper(); + return unit switch + { + "B" => (long)value, + "KB" => (long)(value * 1000), + "MB" => (long)(value * 1000 * 1000), + "GB" => (long)(value * 1000 * 1000 * 1000), + "TB" => (long)(value * 1000 * 1000 * 1000 * 1000), + "KIB" => (long)(value * 1024), + "MIB" => (long)(value * 1024 * 1024), + "GIB" => (long)(value * 1024 * 1024 * 1024), + "TIB" => (long)(value * 1024 * 1024 * 1024 * 1024), + _ => (long)value + }; + } + + _logger.LogWarning("Unable to parse size string: '{SizeStr}'", sizeStr); + return 0; + } + + // (Helper methods for containment and fuzzy scoring are implemented above.) + + } +} diff --git a/listenarr.infrastructure/Adapters/QbittorrentAdapter.cs b/listenarr.infrastructure/Adapters/QbittorrentAdapter.cs index 7e6856e7f..63bee4391 100644 --- a/listenarr.infrastructure/Adapters/QbittorrentAdapter.cs +++ b/listenarr.infrastructure/Adapters/QbittorrentAdapter.cs @@ -626,19 +626,7 @@ public async Task> GetQueueAsync(DownloadClientConfiguration cli foreach (var torrent in torrents) { - var name = torrent.TryGetValue("name", out var nameEl) ? nameEl.GetString() ?? string.Empty : string.Empty; - var progress = torrent.TryGetValue("progress", out var progressEl) ? progressEl.GetDouble() * 100 : 0; - var size = torrent.TryGetValue("size", out var sizeEl) ? sizeEl.GetInt64() : 0; - var downloaded = torrent.TryGetValue("downloaded", out var downloadedEl) ? downloadedEl.GetInt64() : 0; - var dlspeed = torrent.TryGetValue("dlspeed", out var dlspeedEl) ? dlspeedEl.GetDouble() : 0; - var eta = torrent.TryGetValue("eta", out var etaEl) ? (int?)etaEl.GetInt32() : null; - var state = torrent.TryGetValue("state", out var stateEl) ? stateEl.GetString() ?? "unknown" : "unknown"; var hash = torrent.TryGetValue("hash", out var hashEl) ? hashEl.GetString() ?? string.Empty : string.Empty; - var addedOn = torrent.TryGetValue("added_on", out var addedOnEl) ? addedOnEl.GetInt64() : 0; - var numSeeds = torrent.TryGetValue("num_seeds", out var numSeedsEl) ? (int?)numSeedsEl.GetInt32() : null; - var numLeechs = torrent.TryGetValue("num_leechs", out var numLeechsEl) ? (int?)numLeechsEl.GetInt32() : null; - var ratio = torrent.TryGetValue("ratio", out var ratioEl) ? (double?)ratioEl.GetDouble() : null; - var savePath = torrent.TryGetValue("save_path", out var savePathEl) ? savePathEl.GetString() ?? string.Empty : string.Empty; List> files = []; using var filesResp = await httpClient.GetAsync($"{baseUrl}/api/v2/torrents/files?hash={hash}", ct); @@ -648,62 +636,7 @@ public async Task> GetQueueAsync(DownloadClientConfiguration cli files = JsonSerializer.Deserialize>>(filesJson) ?? []; } - var localPath = savePath; - var outputPath = ResolveTorrentContentPath(savePath, files); - - var status = state switch - { - "downloading" => "downloading", - "metaDL" => "downloading", - "forcedDL" => "downloading", - "forcedMetaDL" => "downloading", - "stalledDL" => "downloading", - "checkingDL" => "downloading", - "stoppedDL" => "paused", - "stoppedUP" => "paused", - "queuedDL" => "queued", - "queuedUP" => "queued", - "uploading" => "seeding", - "stalledUP" => "seeding", - "checkingUP" => "seeding", - "forcedUP" => "seeding", - "checkingResumeData" => "downloading", - "moving" => "downloading", - "error" => "failed", - "missingFiles" => "failed", - _ => "unknown" - }; - - if (progress >= 100.0 && (status == "seeding" || state == "uploading" || state == "stalledUP" || state == "checkingUP" || state == "forcedUP" || state == "stoppedUP")) - { - status = "completed"; - } - - items.Add(new QueueItem - { - Id = hash, - Title = name, - Quality = "Unknown", - Status = status, - Progress = progress, - Size = size, - Downloaded = downloaded, - DownloadSpeed = dlspeed, - Eta = eta >= 8640000 ? null : eta, - DownloadClient = client.Name, - DownloadClientId = client.Id, - DownloadClientType = "qbittorrent", - AddedAt = DateTimeOffset.FromUnixTimeSeconds(addedOn).DateTime, - Seeders = numSeeds, - Leechers = numLeechs, - Ratio = ratio, - CanPause = status == "downloading" || status == "queued", - CanRemove = true, - RemotePath = savePath, - LocalPath = localPath, - SourceFiles = BuildTorrentSourceFiles(savePath, files), - ContentPath = outputPath - }); + items.Add(QbittorrentResponseMapper.MapQueueItem(torrent, client, files)); } } catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) @@ -810,98 +743,14 @@ public async Task> GetItemsAsync(DownloadClientConfigur foreach (var torrent in torrents) { - var name = torrent.TryGetValue("name", out var nameEl) ? nameEl.GetString() ?? string.Empty : string.Empty; - var progress = torrent.TryGetValue("progress", out var progressEl) ? progressEl.GetDouble() * 100 : 0; - var size = torrent.TryGetValue("size", out var sizeEl) ? sizeEl.GetInt64() : 0; - var downloaded = torrent.TryGetValue("downloaded", out var downloadedEl) ? downloadedEl.GetInt64() : 0; - var dlspeed = torrent.TryGetValue("dlspeed", out var dlspeedEl) ? dlspeedEl.GetDouble() : 0; - var eta = torrent.TryGetValue("eta", out var etaEl) ? (int?)etaEl.GetInt32() : null; - var state = torrent.TryGetValue("state", out var stateEl) ? stateEl.GetString() ?? "unknown" : "unknown"; - var hash = torrent.TryGetValue("hash", out var hashEl) ? hashEl.GetString() ?? string.Empty : string.Empty; - var numSeeds = torrent.TryGetValue("num_seeds", out var numSeedsEl) ? (int?)numSeedsEl.GetInt32() : null; - var numLeechs = torrent.TryGetValue("num_leechs", out var numLeechsEl) ? (int?)numLeechsEl.GetInt32() : null; - var ratio = torrent.TryGetValue("ratio", out var ratioEl) ? (double?)ratioEl.GetDouble() : null; - // Per-torrent seed limit overrides (-1 = use global, -2 = use global, >=0 = per-torrent limit) - var ratioLimit = torrent.TryGetValue("ratio_limit", out var ratioLimitEl) ? (float)ratioLimitEl.GetDouble() : -2f; - var seedingTimeLimit = torrent.TryGetValue("seeding_time_limit", out var stlEl) ? stlEl.GetInt64() : -2L; - var seedingTime = torrent.TryGetValue("seeding_time", out var seedTimeEl) ? (long?)seedTimeEl.GetInt64() : null; - var savePath = torrent.TryGetValue("save_path", out var savePathEl) ? savePathEl.GetString() ?? string.Empty : string.Empty; - var category = torrent.TryGetValue("category", out var categoryEl) ? categoryEl.GetString() ?? string.Empty : string.Empty; - var contentPath = torrent.TryGetValue("content_path", out var contentPathEl) ? contentPathEl.GetString() ?? string.Empty : string.Empty; - - // ✅ Map qBittorrent status to DownloadItemStatus enum - var status = state switch - { - "downloading" => DownloadItemStatus.Downloading, - "metaDL" => DownloadItemStatus.Downloading, - "forcedDL" => DownloadItemStatus.Downloading, - "forcedMetaDL" => DownloadItemStatus.Downloading, - "stalledDL" => DownloadItemStatus.Downloading, - "checkingDL" => DownloadItemStatus.Downloading, - "stoppedDL" => DownloadItemStatus.Paused, - "stoppedUP" => DownloadItemStatus.Paused, - "queuedDL" => DownloadItemStatus.Queued, - "queuedUP" => DownloadItemStatus.Queued, - "uploading" => DownloadItemStatus.Downloading, // Still seeding after completion - "stalledUP" => DownloadItemStatus.Downloading, - "checkingUP" => DownloadItemStatus.Downloading, - "forcedUP" => DownloadItemStatus.Downloading, - "checkingResumeData" => DownloadItemStatus.Downloading, - "moving" => DownloadItemStatus.Downloading, - "error" => DownloadItemStatus.Failed, - "missingFiles" => DownloadItemStatus.Failed, - _ => DownloadItemStatus.Warning - }; - - // If completed, override status - if (progress >= 100.0 && (status == DownloadItemStatus.Downloading || state == "uploading" || state == "stalledUP" || state == "checkingUP" || state == "forcedUP" || state == "stoppedUP")) - { - status = DownloadItemStatus.Completed; - } - - var localPath = savePath; - - var outputPath = localPath; - - TimeSpan? remainingTime = eta.HasValue && eta.Value < 8640000 ? TimeSpan.FromSeconds(eta.Value) : null; - - // qBittorrent can remove completed torrents while still seeding; file moves - // still require the torrent to be stopped so we don't break the payload. - var isStopped = state is "pausedUP" or "stoppedUP"; - var seedLimitReached = HasReachedSeedLimit( - ratio ?? 0, ratioLimit, seedingTime, seedingTimeLimit, - globalMaxRatioEnabled, globalMaxRatio, - globalMaxSeedingTimeEnabled, globalMaxSeedingTime); - var canBeRemoved = removeCompletedDownloads && seedLimitReached; - var canMoveFiles = canBeRemoved && isStopped; - - items.Add(new DownloadClientItem - { - DownloadId = hash, - Title = name, - Category = category, - Status = status, - TotalSize = size, - RemainingSize = size - downloaded, - RemainingTime = remainingTime, - SeedRatio = ratio, - OutputPath = outputPath, - Message = state, - Progress = progress, - DownloadSpeed = dlspeed, - Seeders = numSeeds ?? 0, - Leechers = numLeechs ?? 0, - CanBeRemoved = canBeRemoved, - CanMoveFiles = canMoveFiles, - DownloadClientInfo = DownloadClientItemClientInfo.FromClient( - clientId: client.Id, - clientName: client.Name, - clientType: "qbittorrent", - protocol: DownloadProtocol.Torrent, - removeCompletedDownloads: removeCompletedDownloads, - hasPostImportCategory: !string.IsNullOrEmpty(client.Settings?.GetValueOrDefault("postImportCategory")?.ToString()) - ) - }); + items.Add(QbittorrentResponseMapper.MapDownloadClientItem( + torrent, + client, + removeCompletedDownloads, + globalMaxRatioEnabled, + globalMaxRatio, + globalMaxSeedingTimeEnabled, + globalMaxSeedingTime)); } } catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) @@ -912,74 +761,6 @@ public async Task> GetItemsAsync(DownloadClientConfigur return items; } - /// - /// Determines whether a qBittorrent torrent has reached its seed limit (ratio or time). - /// Mirrors Sonarr's HasReachedSeedLimit logic for qBittorrent. - /// - /// Current torrent ratio - /// Per-torrent ratio limit (-2 = use global, -1 = no limit, >=0 = per-torrent) - /// Torrent seeding time in seconds (null if unknown) - /// Per-torrent seeding time limit in minutes (-2 = use global, -1 = no limit, >=0 = per-torrent) - /// Whether global max ratio is enabled in qBit preferences - /// Global max ratio from qBit preferences - /// Whether global max seeding time is enabled in qBit preferences - /// Global max seeding time from qBit preferences (in minutes) - private static bool HasReachedSeedLimit( - double ratio, - float ratioLimit, - long? seedingTime, - long seedingTimeLimit, - bool globalMaxRatioEnabled, - float globalMaxRatio, - bool globalMaxSeedingTimeEnabled, - long globalMaxSeedingTime) - { - var hasEffectiveRatioLimit = - ratioLimit >= 0 || - (ratioLimit <= -2 && globalMaxRatioEnabled && globalMaxRatio > 0); - var hasEffectiveSeedingTimeLimit = - seedingTimeLimit >= 0 || - (seedingTimeLimit <= -2 && globalMaxSeedingTimeEnabled && globalMaxSeedingTime > 0); - - if (!hasEffectiveRatioLimit && !hasEffectiveSeedingTimeLimit) - { - return true; - } - - // Check ratio limit (per-torrent override takes precedence) - if (ratioLimit >= 0 && ratioLimit - ratio <= 0.001) - { - // Per-torrent ratio limit set - return true; - } - - if (ratioLimit <= -2 && globalMaxRatioEnabled && globalMaxRatio - ratio <= 0.001) - { - // Use global ratio limit (-2 means inherit global) - return true; - } - - // Check seeding time limit (per-torrent override takes precedence) - if (seedingTimeLimit >= 0 && - seedingTime is long currentSeedingTime && - currentSeedingTime >= seedingTimeLimit * 60) - { - // Per-torrent seeding time limit set (in minutes, convert to seconds for comparison) - return true; - } - - if (seedingTimeLimit <= -2 && - globalMaxSeedingTimeEnabled && - seedingTime is long inheritedSeedingTime && - inheritedSeedingTime >= globalMaxSeedingTime * 60) - { - // Use global seeding time limit (in minutes, convert to seconds) - return true; - } - - return false; - } - /// /// Get import item from DownloadClientItem /// @@ -1417,7 +1198,7 @@ public async Task> FetchDownloadsAsync( // Sonarr parity: compute CanMoveFiles/CanBeRemoved per-torrent var tIsStopped = state is "pausedUP" or "stoppedUP"; - var tSeedLimitReached = QBitHasReachedSeedLimit( + var tSeedLimitReached = QbittorrentSeedLimitEvaluator.HasReachedSeedLimit( tRatio, tRatioLimit, seedingTime, tSeedingTimeLimit, qbtGlobalMaxRatioEnabled, qbtGlobalMaxRatio, qbtGlobalMaxSeedingTimeEnabled, qbtGlobalMaxSeedingTime); @@ -1598,51 +1379,5 @@ public async Task> FetchDownloadsAsync( } } - /// - /// Determines whether a qBittorrent torrent has reached its seed limit. - /// Used by the qBittorrent poller to compute CanMoveFiles/CanBeRemoved per-torrent. - /// Mirrors Sonarr's HasReachedSeedLimit logic. - /// - private static bool QBitHasReachedSeedLimit( - double ratio, - float ratioLimit, - long? seedingTime, - long seedingTimeLimit, - bool globalMaxRatioEnabled, - float globalMaxRatio, - bool globalMaxSeedingTimeEnabled, - long globalMaxSeedingTime) - { - var hasEffectiveRatioLimit = - ratioLimit >= 0 || - (ratioLimit <= -2 && globalMaxRatioEnabled && globalMaxRatio > 0); - var hasEffectiveSeedingTimeLimit = - seedingTimeLimit >= 0 || - (seedingTimeLimit <= -2 && globalMaxSeedingTimeEnabled && globalMaxSeedingTime > 0); - - if (!hasEffectiveRatioLimit && !hasEffectiveSeedingTimeLimit) - return true; - - // Check ratio limit (per-torrent override takes precedence) - if (ratioLimit >= 0 && ratioLimit - ratio <= 0.001) - return true; - - if (ratioLimit <= -2 && globalMaxRatioEnabled && globalMaxRatio - ratio <= 0.001) - return true; - - // Check seeding time limit (per-torrent override takes precedence) - if (seedingTimeLimit >= 0 && - seedingTime is long currentSeedingTime && - currentSeedingTime >= seedingTimeLimit * 60) - return true; - - if (seedingTimeLimit <= -2 && - globalMaxSeedingTimeEnabled && - seedingTime is long inheritedSeedingTime && - inheritedSeedingTime >= globalMaxSeedingTime * 60) - return true; - - return false; - } } } diff --git a/listenarr.infrastructure/Adapters/QbittorrentResponseMapper.cs b/listenarr.infrastructure/Adapters/QbittorrentResponseMapper.cs new file mode 100644 index 000000000..eb9b765a8 --- /dev/null +++ b/listenarr.infrastructure/Adapters/QbittorrentResponseMapper.cs @@ -0,0 +1,234 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +using System.Text.Json; +using Listenarr.Domain.Models; + +namespace Listenarr.Infrastructure.Adapters +{ + internal static class QbittorrentResponseMapper + { + public static QueueItem MapQueueItem( + Dictionary torrent, + DownloadClientConfiguration client, + List> files) + { + var name = GetString(torrent, "name"); + var progress = GetDouble(torrent, "progress") * 100; + var size = GetInt64(torrent, "size"); + var downloaded = GetInt64(torrent, "downloaded"); + var dlspeed = GetDouble(torrent, "dlspeed"); + var eta = GetNullableInt32(torrent, "eta"); + var state = GetString(torrent, "state", "unknown"); + var hash = GetString(torrent, "hash"); + var addedOn = GetInt64(torrent, "added_on"); + var numSeeds = GetNullableInt32(torrent, "num_seeds"); + var numLeechs = GetNullableInt32(torrent, "num_leechs"); + var ratio = GetNullableDouble(torrent, "ratio"); + var savePath = GetString(torrent, "save_path"); + var status = MapQueueStatus(state, progress); + + return new QueueItem + { + Id = hash, + Title = name, + Quality = "Unknown", + Status = status, + Progress = progress, + Size = size, + Downloaded = downloaded, + DownloadSpeed = dlspeed, + Eta = eta >= 8640000 ? null : eta, + DownloadClient = client.Name, + DownloadClientId = client.Id, + DownloadClientType = "qbittorrent", + AddedAt = DateTimeOffset.FromUnixTimeSeconds(addedOn).DateTime, + Seeders = numSeeds, + Leechers = numLeechs, + Ratio = ratio, + CanPause = status == "downloading" || status == "queued", + CanRemove = true, + RemotePath = savePath, + LocalPath = savePath, + SourceFiles = TorrentClientPathMapper.BuildQbittorrentSourceFiles(savePath, files), + ContentPath = TorrentClientPathMapper.ResolveQbittorrentContentPath(savePath, files) + }; + } + + public static DownloadClientItem MapDownloadClientItem( + Dictionary torrent, + DownloadClientConfiguration client, + bool removeCompletedDownloads, + bool globalMaxRatioEnabled, + float globalMaxRatio, + bool globalMaxSeedingTimeEnabled, + long globalMaxSeedingTime) + { + var name = GetString(torrent, "name"); + var progress = GetDouble(torrent, "progress") * 100; + var size = GetInt64(torrent, "size"); + var downloaded = GetInt64(torrent, "downloaded"); + var dlspeed = GetDouble(torrent, "dlspeed"); + var eta = GetNullableInt32(torrent, "eta"); + var state = GetString(torrent, "state", "unknown"); + var hash = GetString(torrent, "hash"); + var numSeeds = GetNullableInt32(torrent, "num_seeds"); + var numLeechs = GetNullableInt32(torrent, "num_leechs"); + var ratio = GetNullableDouble(torrent, "ratio"); + var ratioLimit = (float)GetDouble(torrent, "ratio_limit", -2); + var seedingTimeLimit = GetInt64(torrent, "seeding_time_limit", -2); + var seedingTime = GetNullableInt64(torrent, "seeding_time"); + var savePath = GetString(torrent, "save_path"); + var category = GetString(torrent, "category"); + + var status = MapDownloadItemStatus(state, progress); + TimeSpan? remainingTime = eta.HasValue && eta.Value < 8640000 ? TimeSpan.FromSeconds(eta.Value) : null; + var isStopped = state is "pausedUP" or "stoppedUP"; + var seedLimitReached = QbittorrentSeedLimitEvaluator.HasReachedSeedLimit( + ratio ?? 0, + ratioLimit, + seedingTime, + seedingTimeLimit, + globalMaxRatioEnabled, + globalMaxRatio, + globalMaxSeedingTimeEnabled, + globalMaxSeedingTime); + var canBeRemoved = removeCompletedDownloads && seedLimitReached; + + return new DownloadClientItem + { + DownloadId = hash, + Title = name, + Category = category, + Status = status, + TotalSize = size, + RemainingSize = size - downloaded, + RemainingTime = remainingTime, + SeedRatio = ratio, + OutputPath = savePath, + Message = state, + Progress = progress, + DownloadSpeed = dlspeed, + Seeders = numSeeds ?? 0, + Leechers = numLeechs ?? 0, + CanBeRemoved = canBeRemoved, + CanMoveFiles = canBeRemoved && isStopped, + DownloadClientInfo = DownloadClientItemClientInfo.FromClient( + clientId: client.Id, + clientName: client.Name, + clientType: "qbittorrent", + protocol: DownloadProtocol.Torrent, + removeCompletedDownloads: removeCompletedDownloads, + hasPostImportCategory: !string.IsNullOrEmpty(client.Settings?.GetValueOrDefault("postImportCategory")?.ToString())) + }; + } + + public static DownloadItemStatus MapDownloadItemStatus(string state, double progress) + { + var status = state switch + { + "downloading" => DownloadItemStatus.Downloading, + "metaDL" => DownloadItemStatus.Downloading, + "forcedDL" => DownloadItemStatus.Downloading, + "forcedMetaDL" => DownloadItemStatus.Downloading, + "stalledDL" => DownloadItemStatus.Downloading, + "checkingDL" => DownloadItemStatus.Downloading, + "stoppedDL" => DownloadItemStatus.Paused, + "stoppedUP" => DownloadItemStatus.Paused, + "queuedDL" => DownloadItemStatus.Queued, + "queuedUP" => DownloadItemStatus.Queued, + "uploading" => DownloadItemStatus.Downloading, + "stalledUP" => DownloadItemStatus.Downloading, + "checkingUP" => DownloadItemStatus.Downloading, + "forcedUP" => DownloadItemStatus.Downloading, + "checkingResumeData" => DownloadItemStatus.Downloading, + "moving" => DownloadItemStatus.Downloading, + "error" => DownloadItemStatus.Failed, + "missingFiles" => DownloadItemStatus.Failed, + _ => DownloadItemStatus.Warning + }; + + if (progress >= 100.0 && (status == DownloadItemStatus.Downloading || state is "uploading" or "stalledUP" or "checkingUP" or "forcedUP" or "stoppedUP")) + { + return DownloadItemStatus.Completed; + } + + return status; + } + + public static string MapQueueStatus(string state, double progress) + { + var status = state switch + { + "downloading" => "downloading", + "metaDL" => "downloading", + "forcedDL" => "downloading", + "forcedMetaDL" => "downloading", + "stalledDL" => "downloading", + "checkingDL" => "downloading", + "stoppedDL" => "paused", + "stoppedUP" => "paused", + "queuedDL" => "queued", + "queuedUP" => "queued", + "uploading" => "seeding", + "stalledUP" => "seeding", + "checkingUP" => "seeding", + "forcedUP" => "seeding", + "checkingResumeData" => "downloading", + "moving" => "downloading", + "error" => "failed", + "missingFiles" => "failed", + _ => "unknown" + }; + + return progress >= 100.0 && (status == "seeding" || state is "uploading" or "stalledUP" or "checkingUP" or "forcedUP" or "stoppedUP") + ? "completed" + : status; + } + + private static string GetString(Dictionary values, string key, string defaultValue = "") + { + return values.TryGetValue(key, out var element) ? element.GetString() ?? defaultValue : defaultValue; + } + + private static double GetDouble(Dictionary values, string key, double defaultValue = 0) + { + return values.TryGetValue(key, out var element) ? element.GetDouble() : defaultValue; + } + + private static long GetInt64(Dictionary values, string key, long defaultValue = 0) + { + return values.TryGetValue(key, out var element) ? element.GetInt64() : defaultValue; + } + + private static int? GetNullableInt32(Dictionary values, string key) + { + return values.TryGetValue(key, out var element) ? element.GetInt32() : null; + } + + private static long? GetNullableInt64(Dictionary values, string key) + { + return values.TryGetValue(key, out var element) ? element.GetInt64() : null; + } + + private static double? GetNullableDouble(Dictionary values, string key) + { + return values.TryGetValue(key, out var element) ? element.GetDouble() : null; + } + } +} diff --git a/listenarr.infrastructure/Adapters/QbittorrentSeedLimitEvaluator.cs b/listenarr.infrastructure/Adapters/QbittorrentSeedLimitEvaluator.cs new file mode 100644 index 000000000..36f051eab --- /dev/null +++ b/listenarr.infrastructure/Adapters/QbittorrentSeedLimitEvaluator.cs @@ -0,0 +1,79 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace Listenarr.Infrastructure.Adapters +{ + /// + /// Evaluates qBittorrent's per-torrent and inherited seed limit settings. + /// + public static class QbittorrentSeedLimitEvaluator + { + /// + /// Mirrors Sonarr's qBittorrent seed-limit behavior. + /// + public static bool HasReachedSeedLimit( + double ratio, + float ratioLimit, + long? seedingTime, + long seedingTimeLimit, + bool globalMaxRatioEnabled, + float globalMaxRatio, + bool globalMaxSeedingTimeEnabled, + long globalMaxSeedingTime) + { + var hasEffectiveRatioLimit = + ratioLimit >= 0 || + (ratioLimit <= -2 && globalMaxRatioEnabled && globalMaxRatio > 0); + var hasEffectiveSeedingTimeLimit = + seedingTimeLimit >= 0 || + (seedingTimeLimit <= -2 && globalMaxSeedingTimeEnabled && globalMaxSeedingTime > 0); + + if (!hasEffectiveRatioLimit && !hasEffectiveSeedingTimeLimit) + { + return true; + } + + if (ratioLimit >= 0 && ratioLimit - ratio <= 0.001) + { + return true; + } + + if (ratioLimit <= -2 && globalMaxRatioEnabled && globalMaxRatio - ratio <= 0.001) + { + return true; + } + + if (seedingTimeLimit >= 0 && + seedingTime is long currentSeedingTime && + currentSeedingTime >= seedingTimeLimit * 60) + { + return true; + } + + if (seedingTimeLimit <= -2 && + globalMaxSeedingTimeEnabled && + seedingTime is long inheritedSeedingTime && + inheritedSeedingTime >= globalMaxSeedingTime * 60) + { + return true; + } + + return false; + } + } +} diff --git a/listenarr.infrastructure/Adapters/TransmissionAdapter.cs b/listenarr.infrastructure/Adapters/TransmissionAdapter.cs index 352130345..57601a832 100644 --- a/listenarr.infrastructure/Adapters/TransmissionAdapter.cs +++ b/listenarr.infrastructure/Adapters/TransmissionAdapter.cs @@ -361,13 +361,13 @@ public async Task> GetQueueAsync(DownloadClientConfiguration cli { try { - var labels = ExtractLabels(torrent); + var labels = TransmissionResponseMapper.ExtractLabels(torrent); if (!DownloadClientCategoryFilter.MatchesAny(configuredCategory, labels)) { continue; } - var queueItem = await MapTorrentAsync(client, torrent, ct); + var queueItem = TransmissionResponseMapper.MapQueueItem(client, torrent); items.Add(queueItem); } catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) @@ -451,7 +451,7 @@ public async Task> GetItemsAsync(DownloadClientConfigur { try { - var labels = ExtractLabels(torrent); + var labels = TransmissionResponseMapper.ExtractLabels(torrent); if (!DownloadClientCategoryFilter.MatchesAny(configuredCategory, labels)) { continue; @@ -655,288 +655,14 @@ public async Task GetImportItemAsync( } } - private async Task MapTorrentAsync(DownloadClientConfiguration client, JsonElement torrent, CancellationToken ct) - { - // Try snake_case (JSON-RPC 2.0 / Transmission 4.1+) first, fall back to camelCase for backwards compatibility - var id = torrent.TryGetProperty("hash_string", out var hashProp) || torrent.TryGetProperty("hashString", out hashProp) - ? hashProp.GetString() ?? string.Empty : string.Empty; - if (string.IsNullOrEmpty(id) && torrent.TryGetProperty("id", out var numericId)) - { - id = numericId.GetInt32().ToString(CultureInfo.InvariantCulture); - } - - var name = torrent.TryGetProperty("name", out var nameProp) ? nameProp.GetString() ?? string.Empty : string.Empty; - var percentDone = (torrent.TryGetProperty("percent_done", out var percentProp) || torrent.TryGetProperty("percentDone", out percentProp)) - ? percentProp.GetDouble() * 100 : 0d; - var totalSize = (torrent.TryGetProperty("total_size", out var sizeProp) || torrent.TryGetProperty("totalSize", out sizeProp)) - ? sizeProp.GetInt64() : 0L; - var leftUntilDone = (torrent.TryGetProperty("left_until_done", out var leftProp) || torrent.TryGetProperty("leftUntilDone", out leftProp)) - ? leftProp.GetInt64() : 0L; - var rateDownload = (torrent.TryGetProperty("rate_download", out var rateProp) || torrent.TryGetProperty("rateDownload", out rateProp)) - ? rateProp.GetDouble() : 0d; - var eta = torrent.TryGetProperty("eta", out var etaProp) ? etaProp.GetInt32() : -1; - var downloadDir = (torrent.TryGetProperty("download_dir", out var dirProp) || torrent.TryGetProperty("downloadDir", out dirProp)) - ? dirProp.GetString() ?? string.Empty : string.Empty; - var statusCode = torrent.TryGetProperty("status", out var statusProp) ? statusProp.GetInt32() : 0; - var addedDate = (torrent.TryGetProperty("added_date", out var addedProp) || torrent.TryGetProperty("addedDate", out addedProp)) - ? addedProp.GetInt64() : 0L; - var uploadRatio = (torrent.TryGetProperty("upload_ratio", out var ratioProp) || torrent.TryGetProperty("uploadRatio", out ratioProp)) - ? ratioProp.GetDouble() : 0d; - - var downloaded = Math.Max(0, totalSize - leftUntilDone); - - var status = statusCode switch - { - 0 => "paused", // TR_STATUS_STOPPED - 1 => "queued", // TR_STATUS_CHECK_WAIT - 2 => "downloading", // TR_STATUS_CHECK - 3 => "queued", // TR_STATUS_DOWNLOAD_WAIT - 4 => "downloading", // TR_STATUS_DOWNLOAD - 5 => "queued", // TR_STATUS_SEED_WAIT - 6 => "seeding", // TR_STATUS_SEED - 7 => "failed", // TR_STATUS_ISOLATED - _ => "unknown" - }; - - _logger.LogDebug("Before completion check: hash={Hash}, percentDone={PercentDone}, status={Status}", - id, percentDone, status); - - if (percentDone >= 100.0 && (status == "seeding" || status == "queued" || status == "paused")) - { - status = "completed"; - } - - _logger.LogDebug("After completion check: hash={Hash}, finalStatus={Status}", id, status); - - string? localPath = downloadDir; - var addedAt = addedDate > 0 ? DateTimeOffset.FromUnixTimeSeconds(addedDate).UtcDateTime : DateTime.UtcNow; - - // For Transmission, construct ContentPath from downloadDir + name - var contentPath = !string.IsNullOrEmpty(downloadDir) && !string.IsNullOrEmpty(name) - ? FileUtils.CombineWithOptionalBase(downloadDir, name) - : downloadDir; - var localContentPath = contentPath; - var primaryLabel = ExtractLabels(torrent).FirstOrDefault() ?? string.Empty; - - var queueItem = new QueueItem - { - Id = id, - Title = name, - Quality = string.IsNullOrWhiteSpace(primaryLabel) ? "Unknown" : primaryLabel, - Status = status, - Progress = percentDone, - Size = totalSize, - Downloaded = downloaded, - DownloadSpeed = rateDownload, - Eta = eta >= 0 ? eta : null, - DownloadClient = client.Name ?? client.Id ?? "Transmission", - DownloadClientId = client.Id ?? string.Empty, - DownloadClientType = ClientType, - AddedAt = addedAt, - Ratio = uploadRatio, - CanPause = status is "downloading" or "queued", - CanRemove = true, - RemotePath = downloadDir, - LocalPath = localPath, - ContentPath = localContentPath - }; - - return queueItem; - } - - private async Task MapToDownloadClientItemAsync( + private Task MapToDownloadClientItemAsync( DownloadClientConfiguration client, JsonElement torrent, (bool SeedRatioLimited, double SeedRatioLimit, bool IdleSeedingLimitEnabled, int IdleSeedingLimit) sessionConfig, CancellationToken ct) { - // Try snake_case (JSON-RPC 2.0 / Transmission 4.1+) first, fall back to camelCase for backwards compatibility - var hash = torrent.TryGetProperty("hash_string", out var hashProp) || torrent.TryGetProperty("hashString", out hashProp) - ? hashProp.GetString() ?? string.Empty : string.Empty; - var numericId = torrent.TryGetProperty("id", out var numericIdProp) ? numericIdProp.GetInt32() : 0; - var name = torrent.TryGetProperty("name", out var nameProp) ? nameProp.GetString() ?? string.Empty : string.Empty; - var percentDone = (torrent.TryGetProperty("percent_done", out var percentProp) || torrent.TryGetProperty("percentDone", out percentProp)) - ? percentProp.GetDouble() * 100 : 0d; - var totalSize = (torrent.TryGetProperty("total_size", out var sizeProp) || torrent.TryGetProperty("totalSize", out sizeProp)) - ? sizeProp.GetInt64() : 0L; - var leftUntilDone = (torrent.TryGetProperty("left_until_done", out var leftProp) || torrent.TryGetProperty("leftUntilDone", out leftProp)) - ? leftProp.GetInt64() : 0L; - var rateDownload = (torrent.TryGetProperty("rate_download", out var rateProp) || torrent.TryGetProperty("rateDownload", out rateProp)) - ? rateProp.GetDouble() : 0d; - var eta = torrent.TryGetProperty("eta", out var etaProp) ? etaProp.GetInt32() : -1; - var downloadDir = (torrent.TryGetProperty("download_dir", out var dirProp) || torrent.TryGetProperty("downloadDir", out dirProp)) - ? dirProp.GetString() ?? string.Empty : string.Empty; - var statusCode = torrent.TryGetProperty("status", out var statusProp) ? statusProp.GetInt32() : 0; - var uploadRatio = (torrent.TryGetProperty("upload_ratio", out var ratioProp) || torrent.TryGetProperty("uploadRatio", out ratioProp)) - ? ratioProp.GetDouble() : 0d; - - // Seed limit fields for Sonarr-parity seed limit evaluation - var seedRatioMode = (torrent.TryGetProperty("seed_ratio_mode", out var srmProp) || torrent.TryGetProperty("seedRatioMode", out srmProp)) - ? srmProp.GetInt32() : 0; - var seedRatioLimit = (torrent.TryGetProperty("seed_ratio_limit", out var srlProp) || torrent.TryGetProperty("seedRatioLimit", out srlProp)) - ? srlProp.GetDouble() : 0d; - var seedIdleMode = (torrent.TryGetProperty("seed_idle_mode", out var simProp) || torrent.TryGetProperty("seedIdleMode", out simProp)) - ? simProp.GetInt32() : 0; - var seedIdleLimit = (torrent.TryGetProperty("seed_idle_limit", out var silProp) || torrent.TryGetProperty("seedIdleLimit", out silProp)) - ? silProp.GetInt32() : 0; - var secondsSeeding = (torrent.TryGetProperty("seconds_seeding", out var ssProp) || torrent.TryGetProperty("secondsSeeding", out ssProp)) - ? ssProp.GetInt64() : 0L; - - // Map Transmission status codes to DownloadItemStatus - var status = statusCode switch - { - 0 => DownloadItemStatus.Paused, // Stopped - 1 => DownloadItemStatus.Queued, // Check waiting - 2 => DownloadItemStatus.Downloading, // Checking - 3 => DownloadItemStatus.Queued, // Download waiting - 4 => DownloadItemStatus.Downloading, // Downloading - 5 => DownloadItemStatus.Queued, // Seed waiting - 6 => DownloadItemStatus.Downloading, // Seeding - _ => DownloadItemStatus.Warning - }; - - if (percentDone >= 100.0 && (statusCode is 0 or 3 or 5 or 6)) - { - status = DownloadItemStatus.Completed; - } - - // For Transmission, construct OutputPath from downloadDir + name - var contentPath = !string.IsNullOrEmpty(downloadDir) && !string.IsNullOrEmpty(name) - ? FileUtils.CombineWithOptionalBase(downloadDir, name) - : downloadDir; - var localContentPath = contentPath; - var primaryLabel = ExtractLabels(torrent).FirstOrDefault() ?? string.Empty; - - TimeSpan? remainingTime = eta >= 0 ? TimeSpan.FromSeconds(eta) : null; - - // ✅ Use hash as DownloadId if available, otherwise fall back to numeric ID - var downloadId = !string.IsNullOrEmpty(hash) ? hash.ToUpperInvariant() : numericId.ToString(CultureInfo.InvariantCulture); - - // Sonarr parity: CanBeRemoved = removeCompletedDownloads && HasReachedSeedLimit - // CanMoveFiles = CanBeRemoved && status == Stopped (statusCode 0) - // This prevents removing torrents before seed goals are met and prevents - // moving files from active seeders (which breaks the torrent). - var removeCompletedDownloads = client.Settings?.TryGetValue("removeCompletedDownloads", out var removeVal) is true && - (removeVal is bool boolVal && boolVal); - var isStopped = statusCode == 0; // TR_STATUS_STOPPED - var isSeeding = statusCode == 6; // TR_STATUS_SEED - var seedLimitReached = HasReachedSeedLimit( - isStopped, isSeeding, uploadRatio, - seedRatioMode, seedRatioLimit, - seedIdleMode, seedIdleLimit, secondsSeeding, - sessionConfig); - var canBeRemoved = removeCompletedDownloads && seedLimitReached; - var canMoveFiles = canBeRemoved && isStopped; - - return new DownloadClientItem - { - DownloadId = downloadId, - Title = name, - Category = primaryLabel, - Status = status, - TotalSize = totalSize, - RemainingSize = leftUntilDone, - RemainingTime = remainingTime, - SeedRatio = uploadRatio, - OutputPath = localContentPath, - Message = $"Status code: {statusCode}", - Progress = percentDone, - DownloadSpeed = rateDownload, - CanBeRemoved = canBeRemoved, - CanMoveFiles = canMoveFiles, - DownloadClientInfo = DownloadClientItemClientInfo.FromClient( - clientId: client.Id, - clientName: client.Name, - clientType: "transmission", - protocol: DownloadProtocol.Torrent, - removeCompletedDownloads: removeCompletedDownloads, - hasPostImportCategory: false // Transmission doesn't support post-import categories - ) - }; - } - - /// - /// Determines whether a Transmission torrent has reached its seed limit (ratio or idle time). - /// Mirrors Sonarr's HasReachedSeedLimit logic for Transmission. - /// - private static bool HasReachedSeedLimit( - bool isStopped, - bool isSeeding, - double ratio, - int seedRatioMode, - double seedRatioLimit, - int seedIdleMode, - int seedIdleLimit, - long secondsSeeding, - (bool SeedRatioLimited, double SeedRatioLimit, bool IdleSeedingLimitEnabled, int IdleSeedingLimit) sessionConfig) - { - var hasEffectiveRatioLimit = - (seedRatioMode == 1 && seedRatioLimit > 0) || - (seedRatioMode == 0 && sessionConfig.SeedRatioLimited && sessionConfig.SeedRatioLimit > 0); - var hasEffectiveIdleLimit = - (seedIdleMode == 1 && seedIdleLimit > 0) || - (seedIdleMode == 0 && sessionConfig.IdleSeedingLimitEnabled && sessionConfig.IdleSeedingLimit > 0); - - // With no effective seed constraints configured, honor the cleanup policy - // immediately instead of reporting the torrent as non-removable forever. - if (!hasEffectiveRatioLimit && !hasEffectiveIdleLimit) - { - return true; - } - - // seedRatioMode: 0 = global, 1 = per-torrent, 2 = unlimited - if (seedRatioMode == 1 && isStopped && ratio >= seedRatioLimit) - { - // Per-torrent ratio limit - return true; - } - - if (seedRatioMode == 0 && isStopped && sessionConfig.SeedRatioLimited && ratio >= sessionConfig.SeedRatioLimit) - { - // Use global ratio limit - return true; - } - - // seedIdleMode: 0 = global, 1 = per-torrent, 2 = unlimited - // Transmission uses idle limit as a seeding time limit when set per-torrent - if (seedIdleMode == 1 && (isStopped || isSeeding) && secondsSeeding > seedIdleLimit * 60) - { - // Per-torrent idle/seed time limit (in minutes) - return true; - } - - if (seedIdleMode == 0 && isStopped && sessionConfig.IdleSeedingLimitEnabled) - { - // The global idle limit is a real idle limit, if configured then 'Stopped' is enough - return true; - } - - return false; - } - - private static List ExtractLabels(JsonElement torrent) - { - var labels = new List(); - if (!torrent.TryGetProperty("labels", out var labelsProp) || labelsProp.ValueKind != JsonValueKind.Array) - { - return labels; - } - - foreach (var label in labelsProp.EnumerateArray()) - { - if (label.ValueKind != JsonValueKind.String) - { - continue; - } - - var value = label.GetString(); - if (!string.IsNullOrWhiteSpace(value)) - { - labels.Add(value.Trim()); - } - } - - return labels; + _ = ct; + return Task.FromResult(TransmissionResponseMapper.MapDownloadClientItem(client, torrent, sessionConfig)); } private List CollectLabels(DownloadClientConfiguration client) @@ -1467,7 +1193,7 @@ public async Task> FetchDownloadsAsync( var txIsStopped = statusCode == 0; var txIsSeeding = statusCode == 6; - var txSeedLimitReached = TransmissionHasReachedSeedLimit( + var txSeedLimitReached = TransmissionSeedLimitEvaluator.HasReachedSeedLimit( txIsStopped, txIsSeeding, txUploadRatio, txSeedRatioMode, txSeedRatioLimit, txSeedIdleMode, txSeedIdleLimit, txSecondsSeeding, @@ -1518,55 +1244,5 @@ public async Task> FetchDownloadsAsync( } } - /// - /// Determines whether a Transmission torrent has reached its seed limit. - /// Mirrors Sonarr's HasReachedSeedLimit logic for Transmission. - /// - private static bool TransmissionHasReachedSeedLimit( - bool isStopped, - bool isSeeding, - double ratio, - int seedRatioMode, - double seedRatioLimit, - int seedIdleMode, - int seedIdleLimit, - long secondsSeeding, - bool sessionSeedRatioLimited, - double sessionSeedRatioLimit, - bool sessionIdleSeedingLimitEnabled, - int sessionIdleSeedingLimit) - { - var hasEffectiveRatioLimit = - (seedRatioMode == 1 && seedRatioLimit > 0) || - (seedRatioMode == 0 && sessionSeedRatioLimited && sessionSeedRatioLimit > 0); - var hasEffectiveIdleLimit = - (seedIdleMode == 1 && seedIdleLimit > 0) || - (seedIdleMode == 0 && sessionIdleSeedingLimitEnabled && sessionIdleSeedingLimit > 0); - - // If Transmission has no seed ratio or idle seeding limits configured, - // the user's remove policy should not defer forever. Treat the item as removable. - if (!hasEffectiveRatioLimit && !hasEffectiveIdleLimit) - { - return true; - } - - // seedRatioMode: 0 = global, 1 = per-torrent, 2 = unlimited - if (seedRatioMode == 1 && isStopped && ratio >= seedRatioLimit) - return true; - - bool globalRatioExceeded = seedRatioMode == 0 && isStopped && sessionSeedRatioLimited && ratio >= sessionSeedRatioLimit; - if (globalRatioExceeded) - return true; - - // seedIdleMode: 0 = global, 1 = per-torrent, 2 = unlimited - bool perTorrentIdleExceeded = seedIdleMode == 1 && (isStopped || isSeeding) && secondsSeeding > seedIdleLimit * 60; - if (perTorrentIdleExceeded) - return true; - - if (seedIdleMode == 0 && isStopped && sessionIdleSeedingLimitEnabled) - return true; - - return false; - } } } diff --git a/listenarr.infrastructure/Adapters/TransmissionResponseMapper.cs b/listenarr.infrastructure/Adapters/TransmissionResponseMapper.cs new file mode 100644 index 000000000..3bee07d65 --- /dev/null +++ b/listenarr.infrastructure/Adapters/TransmissionResponseMapper.cs @@ -0,0 +1,259 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +using System.Globalization; +using System.Text.Json; +using Listenarr.Domain.Common; +using Listenarr.Domain.Models; + +namespace Listenarr.Infrastructure.Adapters +{ + internal static class TransmissionResponseMapper + { + public static QueueItem MapQueueItem(DownloadClientConfiguration client, JsonElement torrent) + { + var id = GetString(torrent, "hash_string", "hashString"); + if (string.IsNullOrEmpty(id) && torrent.TryGetProperty("id", out var numericId)) + { + id = numericId.GetInt32().ToString(CultureInfo.InvariantCulture); + } + + var name = GetString(torrent, "name"); + var percentDone = GetDouble(torrent, "percent_done", "percentDone") * 100; + var totalSize = GetInt64(torrent, "total_size", "totalSize"); + var leftUntilDone = GetInt64(torrent, "left_until_done", "leftUntilDone"); + var rateDownload = GetDouble(torrent, "rate_download", "rateDownload"); + var eta = torrent.TryGetProperty("eta", out var etaProp) ? etaProp.GetInt32() : -1; + var downloadDir = GetString(torrent, "download_dir", "downloadDir"); + var statusCode = torrent.TryGetProperty("status", out var statusProp) ? statusProp.GetInt32() : 0; + var addedDate = GetInt64(torrent, "added_date", "addedDate"); + var uploadRatio = GetDouble(torrent, "upload_ratio", "uploadRatio"); + var downloaded = Math.Max(0, totalSize - leftUntilDone); + var status = MapQueueStatus(statusCode, percentDone); + var addedAt = addedDate > 0 ? DateTimeOffset.FromUnixTimeSeconds(addedDate).UtcDateTime : DateTime.UtcNow; + var contentPath = !string.IsNullOrEmpty(downloadDir) && !string.IsNullOrEmpty(name) + ? FileUtils.CombineWithOptionalBase(downloadDir, name) + : downloadDir; + var primaryLabel = ExtractLabels(torrent).FirstOrDefault() ?? string.Empty; + + return new QueueItem + { + Id = id, + Title = name, + Quality = string.IsNullOrWhiteSpace(primaryLabel) ? "Unknown" : primaryLabel, + Status = status, + Progress = percentDone, + Size = totalSize, + Downloaded = downloaded, + DownloadSpeed = rateDownload, + Eta = eta >= 0 ? eta : null, + DownloadClient = client.Name ?? client.Id ?? "Transmission", + DownloadClientId = client.Id ?? string.Empty, + DownloadClientType = "transmission", + AddedAt = addedAt, + Ratio = uploadRatio, + CanPause = status is "downloading" or "queued", + CanRemove = true, + RemotePath = downloadDir, + LocalPath = downloadDir, + ContentPath = contentPath + }; + } + + public static DownloadClientItem MapDownloadClientItem( + DownloadClientConfiguration client, + JsonElement torrent, + (bool SeedRatioLimited, double SeedRatioLimit, bool IdleSeedingLimitEnabled, int IdleSeedingLimit) sessionConfig) + { + var hash = GetString(torrent, "hash_string", "hashString"); + var numericId = torrent.TryGetProperty("id", out var numericIdProp) ? numericIdProp.GetInt32() : 0; + var name = GetString(torrent, "name"); + var percentDone = GetDouble(torrent, "percent_done", "percentDone") * 100; + var totalSize = GetInt64(torrent, "total_size", "totalSize"); + var leftUntilDone = GetInt64(torrent, "left_until_done", "leftUntilDone"); + var rateDownload = GetDouble(torrent, "rate_download", "rateDownload"); + var eta = torrent.TryGetProperty("eta", out var etaProp) ? etaProp.GetInt32() : -1; + var downloadDir = GetString(torrent, "download_dir", "downloadDir"); + var statusCode = torrent.TryGetProperty("status", out var statusProp) ? statusProp.GetInt32() : 0; + var uploadRatio = GetDouble(torrent, "upload_ratio", "uploadRatio"); + var seedRatioMode = GetInt32(torrent, "seed_ratio_mode", "seedRatioMode"); + var seedRatioLimit = GetDouble(torrent, "seed_ratio_limit", "seedRatioLimit"); + var seedIdleMode = GetInt32(torrent, "seed_idle_mode", "seedIdleMode"); + var seedIdleLimit = GetInt32(torrent, "seed_idle_limit", "seedIdleLimit"); + var secondsSeeding = GetInt64(torrent, "seconds_seeding", "secondsSeeding"); + var contentPath = !string.IsNullOrEmpty(downloadDir) && !string.IsNullOrEmpty(name) + ? FileUtils.CombineWithOptionalBase(downloadDir, name) + : downloadDir; + var primaryLabel = ExtractLabels(torrent).FirstOrDefault() ?? string.Empty; + TimeSpan? remainingTime = eta >= 0 ? TimeSpan.FromSeconds(eta) : null; + var downloadId = !string.IsNullOrEmpty(hash) ? hash.ToUpperInvariant() : numericId.ToString(CultureInfo.InvariantCulture); + var removeCompletedDownloads = client.Settings?.TryGetValue("removeCompletedDownloads", out var removeVal) is true && + removeVal is bool boolVal && boolVal; + var isStopped = statusCode == 0; + var isSeeding = statusCode == 6; + var seedLimitReached = TransmissionSeedLimitEvaluator.HasReachedSeedLimit( + isStopped, + isSeeding, + uploadRatio, + seedRatioMode, + seedRatioLimit, + seedIdleMode, + seedIdleLimit, + secondsSeeding, + sessionConfig.SeedRatioLimited, + sessionConfig.SeedRatioLimit, + sessionConfig.IdleSeedingLimitEnabled, + sessionConfig.IdleSeedingLimit); + var canBeRemoved = removeCompletedDownloads && seedLimitReached; + + return new DownloadClientItem + { + DownloadId = downloadId, + Title = name, + Category = primaryLabel, + Status = MapDownloadItemStatus(statusCode, percentDone), + TotalSize = totalSize, + RemainingSize = leftUntilDone, + RemainingTime = remainingTime, + SeedRatio = uploadRatio, + OutputPath = contentPath, + Message = $"Status code: {statusCode}", + Progress = percentDone, + DownloadSpeed = rateDownload, + CanBeRemoved = canBeRemoved, + CanMoveFiles = canBeRemoved && isStopped, + DownloadClientInfo = DownloadClientItemClientInfo.FromClient( + clientId: client.Id, + clientName: client.Name, + clientType: "transmission", + protocol: DownloadProtocol.Torrent, + removeCompletedDownloads: removeCompletedDownloads, + hasPostImportCategory: false) + }; + } + + public static string MapQueueStatus(int statusCode, double percentDone) + { + var status = statusCode switch + { + 0 => "paused", + 1 => "queued", + 2 => "downloading", + 3 => "queued", + 4 => "downloading", + 5 => "queued", + 6 => "seeding", + 7 => "failed", + _ => "unknown" + }; + + return percentDone >= 100.0 && status is "seeding" or "queued" or "paused" + ? "completed" + : status; + } + + public static DownloadItemStatus MapDownloadItemStatus(int statusCode, double percentDone) + { + if (percentDone >= 100.0 && statusCode is 0 or 3 or 5 or 6) + { + return DownloadItemStatus.Completed; + } + + return statusCode switch + { + 0 => DownloadItemStatus.Paused, + 1 => DownloadItemStatus.Queued, + 2 => DownloadItemStatus.Downloading, + 3 => DownloadItemStatus.Queued, + 4 => DownloadItemStatus.Downloading, + 5 => DownloadItemStatus.Queued, + 6 => DownloadItemStatus.Downloading, + _ => DownloadItemStatus.Warning + }; + } + + public static List ExtractLabels(JsonElement torrent) + { + var labels = new List(); + if (!torrent.TryGetProperty("labels", out var labelsProp) || labelsProp.ValueKind != JsonValueKind.Array) + { + return labels; + } + + foreach (var label in labelsProp.EnumerateArray()) + { + if (label.ValueKind != JsonValueKind.String) + { + continue; + } + + var value = label.GetString(); + if (!string.IsNullOrWhiteSpace(value)) + { + labels.Add(value.Trim()); + } + } + + return labels; + } + + private static string GetString(JsonElement value, string snakeCaseName, string? camelCaseName = null) + { + return TryGetProperty(value, snakeCaseName, camelCaseName, out var property) + ? property.GetString() ?? string.Empty + : string.Empty; + } + + private static int GetInt32(JsonElement value, string snakeCaseName, string camelCaseName) + { + return TryGetProperty(value, snakeCaseName, camelCaseName, out var property) + ? property.GetInt32() + : 0; + } + + private static long GetInt64(JsonElement value, string snakeCaseName, string camelCaseName) + { + return TryGetProperty(value, snakeCaseName, camelCaseName, out var property) + ? property.GetInt64() + : 0; + } + + private static double GetDouble(JsonElement value, string snakeCaseName, string? camelCaseName = null) + { + return TryGetProperty(value, snakeCaseName, camelCaseName, out var property) + ? property.GetDouble() + : 0d; + } + + private static bool TryGetProperty(JsonElement value, string snakeCaseName, string? camelCaseName, out JsonElement property) + { + if (value.TryGetProperty(snakeCaseName, out property)) + { + return true; + } + + if (!string.IsNullOrWhiteSpace(camelCaseName) && value.TryGetProperty(camelCaseName, out property)) + { + return true; + } + + property = default; + return false; + } + } +} diff --git a/listenarr.infrastructure/Adapters/TransmissionSeedLimitEvaluator.cs b/listenarr.infrastructure/Adapters/TransmissionSeedLimitEvaluator.cs new file mode 100644 index 000000000..1aca4f1d8 --- /dev/null +++ b/listenarr.infrastructure/Adapters/TransmissionSeedLimitEvaluator.cs @@ -0,0 +1,78 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace Listenarr.Infrastructure.Adapters +{ + /// + /// Evaluates Transmission's per-torrent and inherited seed limit settings. + /// + public static class TransmissionSeedLimitEvaluator + { + /// + /// Mirrors Sonarr's Transmission seed-limit behavior. + /// + public static bool HasReachedSeedLimit( + bool isStopped, + bool isSeeding, + double ratio, + int seedRatioMode, + double seedRatioLimit, + int seedIdleMode, + int seedIdleLimit, + long secondsSeeding, + bool sessionSeedRatioLimited, + double sessionSeedRatioLimit, + bool sessionIdleSeedingLimitEnabled, + int sessionIdleSeedingLimit) + { + var hasEffectiveRatioLimit = + (seedRatioMode == 1 && seedRatioLimit > 0) || + (seedRatioMode == 0 && sessionSeedRatioLimited && sessionSeedRatioLimit > 0); + var hasEffectiveIdleLimit = + (seedIdleMode == 1 && seedIdleLimit > 0) || + (seedIdleMode == 0 && sessionIdleSeedingLimitEnabled && sessionIdleSeedingLimit > 0); + + if (!hasEffectiveRatioLimit && !hasEffectiveIdleLimit) + { + return true; + } + + if (seedRatioMode == 1 && isStopped && ratio >= seedRatioLimit) + { + return true; + } + + if (seedRatioMode == 0 && isStopped && sessionSeedRatioLimited && ratio >= sessionSeedRatioLimit) + { + return true; + } + + if (seedIdleMode == 1 && (isStopped || isSeeding) && secondsSeeding > seedIdleLimit * 60) + { + return true; + } + + if (seedIdleMode == 0 && isStopped && sessionIdleSeedingLimitEnabled) + { + return true; + } + + return false; + } + } +} diff --git a/listenarr.infrastructure/Extensions/AppServiceRegistrationExtensions.cs b/listenarr.infrastructure/Extensions/AppServiceRegistrationExtensions.cs index 8ea1d8db9..068dd8b4a 100644 --- a/listenarr.infrastructure/Extensions/AppServiceRegistrationExtensions.cs +++ b/listenarr.infrastructure/Extensions/AppServiceRegistrationExtensions.cs @@ -55,6 +55,9 @@ public static IServiceCollection AddListenarrAppServices(this IServiceCollection services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -90,6 +93,7 @@ public static IServiceCollection AddListenarrAppServices(this IServiceCollection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); // Queue service extracted from DownloadService to encapsulate queue-building and filtering services.AddScoped(); services.AddScoped(); diff --git a/tests/Builders/ServiceCollectionBuilder.cs b/tests/Builders/ServiceCollectionBuilder.cs index cea4aba73..50689d6ac 100644 --- a/tests/Builders/ServiceCollectionBuilder.cs +++ b/tests/Builders/ServiceCollectionBuilder.cs @@ -177,6 +177,9 @@ private ServiceCollection BuildServices() services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -186,9 +189,17 @@ private ServiceCollection BuildServices() services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/tests/Features/Api/Services/AudibleServiceTests.cs b/tests/Features/Api/Services/AudibleServiceTests.cs index d7ff5dc4b..f94a57a4b 100644 --- a/tests/Features/Api/Services/AudibleServiceTests.cs +++ b/tests/Features/Api/Services/AudibleServiceTests.cs @@ -81,5 +81,99 @@ public void RemoveDiacritics_Null_ReturnsNull() var result = AudibleService.RemoveDiacritics(null!); Assert.Null(result); } + + [Fact] + public void AudibleLookupJsonParser_ParsesAuthorArray() + { + var items = AudibleLookupJsonParser.ParseAuthorLookupItems(""" + [ + { "asin": "A1", "name": "Author One" }, + { "asin": "A2", "name": "Author Two" } + ] + """); + + Assert.Equal(2, items.Count); + Assert.Equal("A1", items[0].Asin); + Assert.Equal("Author Two", items[1].Name); + } + + [Fact] + public void AudibleLookupJsonParser_ParsesSingleAuthorEnvelope() + { + var item = AudibleLookupJsonParser.ParseSingleAuthorLookupItem(""" + { "asin": "A1", "name": "Author One", "image": "https://example.test/a.jpg", "region": "us" } + """); + + Assert.NotNull(item); + Assert.Equal("A1", item.Asin); + Assert.Equal("Author One", item.Name); + Assert.Equal("us", item.Region); + } + + [Fact] + public void AudibleLookupJsonParser_ParsesSeriesResultsEnvelope() + { + var items = AudibleLookupJsonParser.ParseSeriesLookupItems(""" + { + "results": [ + { "asin": "S1", "name": "Series One", "position": "1" } + ] + } + """); + + Assert.Single(items); + Assert.Equal("S1", items[0].Asin); + Assert.Equal("Series One", items[0].Name); + Assert.Equal("1", items[0].Position); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void AudibleLookupJsonParser_EmptyInput_ReturnsNoResults(string lookupJson) + { + Assert.Empty(AudibleLookupJsonParser.ParseAuthorLookupItems(lookupJson)); + Assert.Empty(AudibleLookupJsonParser.ParseSeriesLookupItems(lookupJson)); + } + + [Fact] + public void AudibleAuthorCatalogMatcher_MatchesByAuthorAsin() + { + var result = new AudibleSearchResult + { + Authors = new List + { + new() { Asin = "B001", Name = "Different Name" } + } + }; + + Assert.True(AudibleAuthorCatalogMatcher.MatchesTarget(result, "Target Author", "B001")); + } + + [Fact] + public void AudibleAuthorCatalogMatcher_MatchesByNormalizedName() + { + var result = new AudibleSearchResult + { + Authors = new List + { + new() { Name = "Asa Larsson" } + } + }; + + Assert.True(AudibleAuthorCatalogMatcher.MatchesTarget(result, "Åsa Larsson", null)); + } + + [Fact] + public void AudibleAuthorCatalogMatcher_BuildsStableFallbackKeyWhenAsinMissing() + { + var result = new AudibleSearchResult + { + Title = "Book", + Link = "https://example.test/book" + }; + + Assert.Equal("Book|https://example.test/book", AudibleAuthorCatalogMatcher.BuildSearchResultKey(result)); + } } } diff --git a/tests/Features/Infrastructure/Adapters/QbittorrentAdapterTests.cs b/tests/Features/Infrastructure/Adapters/QbittorrentAdapterTests.cs index 9404dff89..a2a7041fa 100644 --- a/tests/Features/Infrastructure/Adapters/QbittorrentAdapterTests.cs +++ b/tests/Features/Infrastructure/Adapters/QbittorrentAdapterTests.cs @@ -185,6 +185,41 @@ public async Task GetImportItemAsync_MultiFileTorrent_ResolvesTopLevelFolderPath await Task.CompletedTask; } + [Theory] + [InlineData(0.5, -1f, null, -1, false, -1f, false, -1, true)] + [InlineData(1.0, 1.0f, null, -1, false, -1f, false, -1, true)] + [InlineData(0.9995, 1.0f, null, -1, false, -1f, false, -1, true)] + [InlineData(0.5, 1.0f, null, -1, false, -1f, false, -1, false)] + [InlineData(1.5, -2f, null, -1, true, 1.5f, false, -1, true)] + [InlineData(0.5, -2f, null, -1, true, 1.5f, false, -1, false)] + [InlineData(0.5, -1f, 3600, 60, false, -1f, false, -1, true)] + [InlineData(0.5, -1f, 3599, 60, false, -1f, false, -1, false)] + [InlineData(0.5, -1f, 7200, -2, false, -1f, true, 120, true)] + [InlineData(0.5, -1f, 7199, -2, false, -1f, true, 120, false)] + public void HasReachedSeedLimit_EvaluatesQbittorrentRatioAndSeedingTimePolicy( + double ratio, + float ratioLimit, + int? seedingTime, + long seedingTimeLimit, + bool globalMaxRatioEnabled, + float globalMaxRatio, + bool globalMaxSeedingTimeEnabled, + long globalMaxSeedingTime, + bool expected) + { + var result = QbittorrentSeedLimitEvaluator.HasReachedSeedLimit( + ratio, + ratioLimit, + seedingTime, + seedingTimeLimit, + globalMaxRatioEnabled, + globalMaxRatio, + globalMaxSeedingTimeEnabled, + globalMaxSeedingTime); + + Assert.Equal(expected, result); + } + [Fact] [Trait("Area", "QbittorrentImportPathResolution")] [Trait("Scenario", "LocalAutoImportKeepsExistingPath")] diff --git a/tests/Features/Infrastructure/Adapters/TransmissionAdapterTests.cs b/tests/Features/Infrastructure/Adapters/TransmissionAdapterTests.cs index f3c774b5b..78ce7d063 100644 --- a/tests/Features/Infrastructure/Adapters/TransmissionAdapterTests.cs +++ b/tests/Features/Infrastructure/Adapters/TransmissionAdapterTests.cs @@ -19,6 +19,7 @@ using Listenarr.Tests.Builders; using Listenarr.Tests.Common; using Listenarr.Tests.Mocks.Api; +using Listenarr.Infrastructure.Adapters; using Listenarr.Infrastructure.Torrents; namespace Listenarr.Tests.Features.Infrastructure.Adapters @@ -160,6 +161,47 @@ public async Task AddAsync_WhenTorrentUrlUsesInvalidScheme_ThrowsArgumentExcepti Assert.Contains("HTTP or HTTPS", exception.Message, StringComparison.OrdinalIgnoreCase); } + [Theory] + [InlineData(true, false, 0.5, 2, 0, 2, 0, 0, false, 0, false, 0, true)] + [InlineData(true, false, 1.5, 1, 1.5, 2, 0, 0, false, 0, false, 0, true)] + [InlineData(false, true, 1.5, 1, 1.5, 2, 0, 0, false, 0, false, 0, false)] + [InlineData(true, false, 1.5, 0, 0, 2, 0, 0, true, 1.5, false, 0, true)] + [InlineData(true, false, 0.5, 0, 0, 2, 0, 0, true, 1.5, false, 0, false)] + [InlineData(false, true, 0.5, 2, 0, 1, 60, 3601, false, 0, false, 0, true)] + [InlineData(false, true, 0.5, 2, 0, 1, 60, 3600, false, 0, false, 0, false)] + [InlineData(true, false, 0.5, 2, 0, 0, 0, 0, false, 0, true, 60, true)] + public void HasReachedSeedLimit_EvaluatesTransmissionRatioAndIdlePolicy( + bool isStopped, + bool isSeeding, + double ratio, + int seedRatioMode, + double seedRatioLimit, + int seedIdleMode, + int seedIdleLimit, + long secondsSeeding, + bool sessionSeedRatioLimited, + double sessionSeedRatioLimit, + bool sessionIdleSeedingLimitEnabled, + int sessionIdleSeedingLimit, + bool expected) + { + var result = TransmissionSeedLimitEvaluator.HasReachedSeedLimit( + isStopped, + isSeeding, + ratio, + seedRatioMode, + seedRatioLimit, + seedIdleMode, + seedIdleLimit, + secondsSeeding, + sessionSeedRatioLimited, + sessionSeedRatioLimit, + sessionIdleSeedingLimitEnabled, + sessionIdleSeedingLimit); + + Assert.Equal(expected, result); + } + [Fact] [Trait("Method", "AddAsync")] public async Task GetImportItemAsync_WithSpaceInRemoteDirectory() From 7ead41490342053c69325ced4b590577504fd655 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Sat, 13 Jun 2026 07:38:19 -0400 Subject: [PATCH 20/84] Slice backend workflows into focused helpers --- .../Controllers/ImagePlaceholderResolver.cs | 114 ++++ listenarr.api/Controllers/ImagesController.cs | 93 +-- .../Controllers/IndexerUrlNormalizer.cs | 47 ++ .../Controllers/IndexersController.cs | 77 +-- .../Controllers/NewznabErrorParser.cs | 52 ++ .../Controllers/ProwlarrCompatController.cs | 26 +- .../ProwlarrCompatSchemaBuilder.cs | 50 ++ listenarr.api/Controllers/SearchController.cs | 421 +----------- .../Controllers/SearchResponseMapper.cs | 424 ++++++++++++ listenarr.api/Program.cs | 2 + .../Search/MyAnonamouseResponseParser.cs | 2 +- listenarr.application/Search/SearchService.cs | 136 ---- .../Adapters/NzbgetAdapter.cs | 191 +----- .../Adapters/NzbgetResponseMapper.cs | 189 ++++++ .../Adapters/SabnzbdAdapter.cs | 328 +-------- .../Adapters/SabnzbdResponseMapper.cs | 297 ++++++++ .../Providers/MyAnonamouseSearchProvider.cs | 639 +----------------- tests/Builders/ServiceCollectionBuilder.cs | 2 + 18 files changed, 1224 insertions(+), 1866 deletions(-) create mode 100644 listenarr.api/Controllers/ImagePlaceholderResolver.cs create mode 100644 listenarr.api/Controllers/IndexerUrlNormalizer.cs create mode 100644 listenarr.api/Controllers/NewznabErrorParser.cs create mode 100644 listenarr.api/Controllers/ProwlarrCompatSchemaBuilder.cs create mode 100644 listenarr.api/Controllers/SearchResponseMapper.cs create mode 100644 listenarr.infrastructure/Adapters/NzbgetResponseMapper.cs create mode 100644 listenarr.infrastructure/Adapters/SabnzbdResponseMapper.cs diff --git a/listenarr.api/Controllers/ImagePlaceholderResolver.cs b/listenarr.api/Controllers/ImagePlaceholderResolver.cs new file mode 100644 index 000000000..324b074f9 --- /dev/null +++ b/listenarr.api/Controllers/ImagePlaceholderResolver.cs @@ -0,0 +1,114 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +using Listenarr.Domain.Common; + +namespace Listenarr.Api.Controllers; + +public sealed class ImagePlaceholderResolver +{ + private readonly ILogger _logger; + + public ImagePlaceholderResolver(ILogger logger) + { + _logger = logger; + } + + public string? ResolvePlaceholderPath(string effectiveContentRootPath) + { + foreach (var candidate in EnumeratePlaceholderCandidates(effectiveContentRootPath)) + { + if (string.IsNullOrWhiteSpace(candidate)) + { + continue; + } + + try + { + var fullPath = Path.GetFullPath(candidate); + if (File.Exists(fullPath)) + { + return fullPath; + } + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogDebug(ex, "Failed probing placeholder candidate path {Path}", candidate); + } + } + + return null; + } + + private static IEnumerable EnumeratePlaceholderCandidates(string effectiveContentRootPath) + { + var baseDirectories = new[] + { + effectiveContentRootPath, + AppContext.BaseDirectory, + Directory.GetCurrentDirectory() + } + .Where(path => !string.IsNullOrWhiteSpace(path)) + .Select(path => + { + try + { + return Path.GetFullPath(path); + } + catch (Exception ex) when ( + ex is ArgumentException or + ArgumentNullException or + PathTooLongException or + NotSupportedException or + System.Security.SecurityException) + { + return path; + } + }) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + foreach (var baseDirectory in baseDirectories) + { + DirectoryInfo? current = null; + try + { + current = new DirectoryInfo(baseDirectory); + } + catch (Exception ex) when ( + ex is ArgumentException or + ArgumentNullException or + PathTooLongException or + NotSupportedException or + System.Security.SecurityException) + { + current = null; + } + + var depth = 0; + while (current != null && depth++ < 8) + { + yield return FileUtils.CombineRelativePath(current.FullName, "wwwroot", "placeholder.svg"); + yield return FileUtils.CombineRelativePath(current.FullName, "fe", "public", "placeholder.svg"); + yield return FileUtils.CombineRelativePath(current.FullName, "listenarr.api", "wwwroot", "placeholder.svg"); + + current = current.Parent; + } + } + } +} diff --git a/listenarr.api/Controllers/ImagesController.cs b/listenarr.api/Controllers/ImagesController.cs index 82ba0e72f..29cd2f7fc 100644 --- a/listenarr.api/Controllers/ImagesController.cs +++ b/listenarr.api/Controllers/ImagesController.cs @@ -39,6 +39,7 @@ public class ImagesController : ControllerBase private readonly IOpenLibraryService? _openLibraryService; private readonly ILogger _logger; private readonly IApplicationPathService _applicationPathService; + private readonly ImagePlaceholderResolver _placeholderResolver; private readonly string _effectiveContentRootPath; [ActivatorUtilitiesConstructor] @@ -58,7 +59,8 @@ public ImagesController( audiobookRepository, openLibraryService: null, logger, - applicationPathService) + applicationPathService, + placeholderResolver: null) { } @@ -70,7 +72,8 @@ public ImagesController( IAudiobookRepository audiobookRepository, IOpenLibraryService? openLibraryService, ILogger logger, - IApplicationPathService applicationPathService) + IApplicationPathService applicationPathService, + ImagePlaceholderResolver? placeholderResolver = null) { _imageCacheService = imageCacheService; _audiobookMetadataService = audiobookMetadataService; @@ -80,6 +83,7 @@ public ImagesController( _openLibraryService = openLibraryService; _logger = logger; _applicationPathService = applicationPathService; + _placeholderResolver = placeholderResolver ?? new ImagePlaceholderResolver(Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); _effectiveContentRootPath = applicationPathService.ContentRootPath; } @@ -1062,7 +1066,7 @@ private IActionResult CreatePlaceholderResult(string logContext, string? logValu { try { - var placeholderPath = ResolvePlaceholderPath(); + var placeholderPath = _placeholderResolver.ResolvePlaceholderPath(_effectiveContentRootPath); if (!string.IsNullOrWhiteSpace(placeholderPath)) { _logger.LogInformation("Serving placeholder image for {LogContext}: {LogValue}", LogRedaction.SanitizeText(logContext), LogRedaction.SanitizeText(logValue)); @@ -1088,89 +1092,6 @@ private IActionResult CreatePlaceholderResult(string logContext, string? logValu return NotFound(new { message = notFoundMessage }); } - private string? ResolvePlaceholderPath() - { - foreach (var candidate in EnumeratePlaceholderCandidates()) - { - if (string.IsNullOrWhiteSpace(candidate)) - { - continue; - } - - try - { - var fullPath = Path.GetFullPath(candidate); - if (System.IO.File.Exists(fullPath)) - { - return fullPath; - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogDebug(ex, "Failed probing placeholder candidate path {Path}", candidate); - } - } - - return null; - } - - private IEnumerable EnumeratePlaceholderCandidates() - { - var baseDirectories = new[] - { - _effectiveContentRootPath, - AppContext.BaseDirectory, - Directory.GetCurrentDirectory() - } - .Where(path => !string.IsNullOrWhiteSpace(path)) - .Select(path => - { - try - { - return Path.GetFullPath(path); - } - catch (Exception ex) when ( - ex is ArgumentException or - ArgumentNullException or - PathTooLongException or - NotSupportedException or - System.Security.SecurityException) - { - return path; - } - }) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToList(); - - foreach (var baseDirectory in baseDirectories) - { - DirectoryInfo? current = null; - try - { - current = new DirectoryInfo(baseDirectory); - } - catch (Exception ex) when ( - ex is ArgumentException or - ArgumentNullException or - PathTooLongException or - NotSupportedException or - System.Security.SecurityException) - { - current = null; - } - - var depth = 0; - while (current != null && depth++ < 8) - { - yield return FileUtils.CombineRelativePath(current.FullName, "wwwroot", "placeholder.svg"); - yield return FileUtils.CombineRelativePath(current.FullName, "fe", "public", "placeholder.svg"); - yield return FileUtils.CombineRelativePath(current.FullName, "listenarr.api", "wwwroot", "placeholder.svg"); - - current = current.Parent; - } - } - } - /// /// Delete a cached cover image by identifier. /// diff --git a/listenarr.api/Controllers/IndexerUrlNormalizer.cs b/listenarr.api/Controllers/IndexerUrlNormalizer.cs new file mode 100644 index 000000000..7c630afaa --- /dev/null +++ b/listenarr.api/Controllers/IndexerUrlNormalizer.cs @@ -0,0 +1,47 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +using System.Text.RegularExpressions; + +namespace Listenarr.Api.Controllers +{ + internal static class IndexerUrlNormalizer + { + /// + /// Normalize indexer URL by removing duplicate or trailing '/api' segments and ensuring a scheme. + /// + public static string NormalizeIndexerUrl(string? rawUrl) + { + if (string.IsNullOrWhiteSpace(rawUrl)) return rawUrl ?? string.Empty; + + var url = rawUrl.Trim(); + + if (!url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) && + !url.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + { + url = "https://" + url; + } + + while (url.Contains("/api/api", StringComparison.OrdinalIgnoreCase)) + { + url = url.Replace("/api/api", "/api", StringComparison.OrdinalIgnoreCase); + } + + var prowlarrProxyPattern = @"/((api/v\d+(?:\.\d+)?/indexer/\d+)|\d+)/api$"; + if (url.EndsWith("/api", StringComparison.OrdinalIgnoreCase) && + !Regex.IsMatch(url, prowlarrProxyPattern, RegexOptions.IgnoreCase)) + { + url = url.Substring(0, url.Length - 4); + } + + return url.TrimEnd('/'); + } + } +} diff --git a/listenarr.api/Controllers/IndexersController.cs b/listenarr.api/Controllers/IndexersController.cs index 1a6a6d987..2c40b2984 100644 --- a/listenarr.api/Controllers/IndexersController.cs +++ b/listenarr.api/Controllers/IndexersController.cs @@ -25,7 +25,6 @@ using Listenarr.Domain.Models; using Microsoft.AspNetCore.Mvc; using System.Text.Json; -using System.Text.RegularExpressions; namespace Listenarr.Api.Controllers { @@ -121,7 +120,7 @@ private async Task SaveTestResultAsync(Indexer indexer, bool persist, bool succe private async Task ExecuteIndexerTestAsync(Indexer indexer, bool persist) { // Normalize URL first - indexer.Url = NormalizeIndexerUrl(indexer.Url); + indexer.Url = IndexerUrlNormalizer.NormalizeIndexerUrl(indexer.Url); var impl = (indexer.Implementation ?? string.Empty).Trim().ToLowerInvariant(); @@ -254,7 +253,7 @@ private async Task TestGenericIndexer(Indexer indexer, bool persi if (isNewznabStyle) { var xmlContent = await response.Content.ReadAsStringAsync(); - var errorMessage = ParseNewznabError(xmlContent); + var errorMessage = NewznabErrorParser.Parse(xmlContent); if (errorMessage != null) { @@ -300,45 +299,6 @@ private async Task TestGenericIndexer(Indexer indexer, bool persi } } - private string? ParseNewznabError(string xmlContent) - { - try - { - // Parse XML response to check for error element - // Newznab spec: - var settings = new System.Xml.XmlReaderSettings - { - DtdProcessing = System.Xml.DtdProcessing.Ignore, - XmlResolver = null - }; - - using var reader = System.Xml.XmlReader.Create(new System.IO.StringReader(xmlContent), settings); - var doc = System.Xml.Linq.XDocument.Load(reader); - - // Check for error element (can be at root, under rss, or as a descendant) - System.Xml.Linq.XElement? errorElement = null; - - // Case 1: Root element is , Case 2: Error is a child or descendant - errorElement = doc.Root?.Name.LocalName.Equals("error", StringComparison.OrdinalIgnoreCase) == true - ? doc.Root - : doc.Root?.Descendants().FirstOrDefault(e => e.Name.LocalName.Equals("error", StringComparison.OrdinalIgnoreCase)); - - if (errorElement != null) - { - var code = errorElement.Attribute("code")?.Value; - var description = errorElement.Attribute("description")?.Value ?? errorElement.Value; - return string.IsNullOrEmpty(description) ? $"Error code: {code}" : description; - } - - return null; - } - catch (Exception caughtEx_1) when (caughtEx_1 is not OperationCanceledException && caughtEx_1 is not OutOfMemoryException && caughtEx_1 is not StackOverflowException) - { - // If we can't parse the XML, assume no error element - return null; - } - } - /// /// Get all configured indexers. /// @@ -1148,39 +1108,6 @@ public async Task GetEnabled() return Ok(RedactIndexersForCaller(indexers)); } - /// - /// Normalize indexer URL by removing duplicate or trailing '/api' segments and ensuring a scheme - /// - private string NormalizeIndexerUrl(string? rawUrl) - { - if (string.IsNullOrWhiteSpace(rawUrl)) return rawUrl ?? string.Empty; - - var url = rawUrl.Trim(); - - // Add scheme if missing (assume https) - if (!url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) && !url.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) - { - url = "https://" + url; - } - - // Remove repeated '/api/api' or trailing '/api' - // Normalize multiple slashes - while (url.Contains("/api/api", StringComparison.OrdinalIgnoreCase)) - { - url = url.Replace("/api/api", "/api", StringComparison.OrdinalIgnoreCase); - } - - // Preserve /api for Prowlarr proxy URLs (/{id}/api or /api/v{version}/indexer/{id}/api) - var prowlarrProxyPattern = @"/((api/v\d+(?:\.\d+)?/indexer/\d+)|\d+)/api$"; - if (url.EndsWith("/api", StringComparison.OrdinalIgnoreCase) && - !Regex.IsMatch(url, prowlarrProxyPattern, RegexOptions.IgnoreCase)) - { - url = url.Substring(0, url.Length - 4); - } - - return url.TrimEnd('/'); - } - private string BuildProwlarrBaseUrl(string rawUrl, int? port) { var trimmed = rawUrl.Trim(); diff --git a/listenarr.api/Controllers/NewznabErrorParser.cs b/listenarr.api/Controllers/NewznabErrorParser.cs new file mode 100644 index 000000000..1208fcb76 --- /dev/null +++ b/listenarr.api/Controllers/NewznabErrorParser.cs @@ -0,0 +1,52 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +using System.Xml; +using System.Xml.Linq; + +namespace Listenarr.Api.Controllers +{ + internal static class NewznabErrorParser + { + public static string? Parse(string xmlContent) + { + try + { + var settings = new XmlReaderSettings + { + DtdProcessing = DtdProcessing.Ignore, + XmlResolver = null + }; + + using var reader = XmlReader.Create(new StringReader(xmlContent), settings); + var doc = XDocument.Load(reader); + + var errorElement = doc.Root?.Name.LocalName.Equals("error", StringComparison.OrdinalIgnoreCase) == true + ? doc.Root + : doc.Root?.Descendants().FirstOrDefault(e => e.Name.LocalName.Equals("error", StringComparison.OrdinalIgnoreCase)); + + if (errorElement == null) + { + return null; + } + + var code = errorElement.Attribute("code")?.Value; + var description = errorElement.Attribute("description")?.Value ?? errorElement.Value; + return string.IsNullOrEmpty(description) ? $"Error code: {code}" : description; + } + catch (Exception ex) when (ex is not OperationCanceledException && + ex is not OutOfMemoryException && + ex is not StackOverflowException) + { + return null; + } + } + } +} diff --git a/listenarr.api/Controllers/ProwlarrCompatController.cs b/listenarr.api/Controllers/ProwlarrCompatController.cs index 0596e10aa..9dbf93a33 100644 --- a/listenarr.api/Controllers/ProwlarrCompatController.cs +++ b/listenarr.api/Controllers/ProwlarrCompatController.cs @@ -322,12 +322,7 @@ public async Task GetIndexerById(int id) public IActionResult GetIndexersInfo() { Response.ContentType = "application/json"; - var payload = new - { - implementations = new[] { "Newznab", "Torznab" }, - schema = "/api/v1/indexer/schema" - }; - return Ok(payload); + return Ok(ProwlarrCompatSchemaBuilder.BuildInfo()); } /// @@ -1182,24 +1177,7 @@ public async Task PostIndexer([FromBody] System.Text.Json.JsonEle public IActionResult GetIndexerSchema() { Response.ContentType = "application/json"; - - var fields = new[] - { - new IndexerFieldDto { Name = "name", Type = "string", Required = true, Description = "Indexer name" }, - new IndexerFieldDto { Name = "baseUrl", Type = "string", Required = true, Description = "Base URL of indexer" }, - new IndexerFieldDto { Name = "apiPath", Type = "string", Required = true, Description = "API path (e.g. /api or /torznab)" }, - new IndexerFieldDto { Name = "apiKey", Type = "string", Required = false, Description = "API key or token" }, - new IndexerFieldDto { Name = "categories", Type = "array", Required = false, Description = "Optional categories filter (array of integers or strings)" } - }; - - // Return an array of schema entries, one per supported implementation (Prowlarr expects a JSON array here) - var schemaArray = new[] - { - new { fields = fields, implementation = "Newznab" }, - new { fields = fields, implementation = "Torznab" } - }; - - return Ok(schemaArray); + return Ok(ProwlarrCompatSchemaBuilder.BuildSchema()); } /// diff --git a/listenarr.api/Controllers/ProwlarrCompatSchemaBuilder.cs b/listenarr.api/Controllers/ProwlarrCompatSchemaBuilder.cs new file mode 100644 index 000000000..880839068 --- /dev/null +++ b/listenarr.api/Controllers/ProwlarrCompatSchemaBuilder.cs @@ -0,0 +1,50 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +namespace Listenarr.Api.Controllers +{ + internal static class ProwlarrCompatSchemaBuilder + { + public static object BuildInfo() + { + return new + { + implementations = new[] { "Newznab", "Torznab" }, + schema = "/api/v1/indexer/schema" + }; + } + + public static object BuildSchema() + { + var fields = new[] + { + new IndexerFieldDto { Name = "name", Type = "string", Required = true, Description = "Indexer name" }, + new IndexerFieldDto { Name = "baseUrl", Type = "string", Required = true, Description = "Base URL of indexer" }, + new IndexerFieldDto { Name = "apiPath", Type = "string", Required = true, Description = "API path (e.g. /api or /torznab)" }, + new IndexerFieldDto { Name = "apiKey", Type = "string", Required = false, Description = "API key or token" }, + new IndexerFieldDto { Name = "categories", Type = "array", Required = false, Description = "Optional categories filter (array of integers or strings)" } + }; + + return new[] + { + new { fields = fields, implementation = "Newznab" }, + new { fields = fields, implementation = "Torznab" } + }; + } + + private record IndexerFieldDto + { + public string Name { get; init; } = string.Empty; + public string Type { get; init; } = string.Empty; + public bool Required { get; init; } + public string Description { get; init; } = string.Empty; + } + } +} diff --git a/listenarr.api/Controllers/SearchController.cs b/listenarr.api/Controllers/SearchController.cs index 0d26cb23e..dc853fbad 100644 --- a/listenarr.api/Controllers/SearchController.cs +++ b/listenarr.api/Controllers/SearchController.cs @@ -39,6 +39,7 @@ public class SearchController : ControllerBase private readonly IAudiobookMetadataService _metadataService; private readonly IImageCacheService? _imageCacheService; private readonly MetadataConverters _metadataConverters; + private readonly SearchResponseMapper _responseMapper; public SearchController( ISearchService searchService, @@ -46,7 +47,8 @@ public SearchController( AudibleService audibleService, IAudiobookMetadataService metadataService, IImageCacheService? imageCacheService = null, - MetadataConverters? metadataConverters = null) + MetadataConverters? metadataConverters = null, + SearchResponseMapper? responseMapper = null) { _searchService = searchService; _logger = logger; @@ -54,40 +56,14 @@ public SearchController( _metadataService = metadataService; _imageCacheService = imageCacheService; _metadataConverters = metadataConverters ?? new MetadataConverters(imageCacheService, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + _responseMapper = responseMapper ?? new SearchResponseMapper( + metadataService, + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance, + imageCacheService); } private string BuildApiImagePath(string identifier, string? sourceUrl = null) - => HttpApiVersionUtils.BuildImagePath(identifier, HttpContext, sourceUrl: sourceUrl); - - private List SimplifySearchResults(List results) - { - return results?.Select(r => new - { - r.Id, - r.Title, - Artist = r.Artist, - r.Subtitle, - r.Description, - r.Publisher, - r.Language, - r.Runtime, - r.Narrator, - r.ImageUrl, - r.Asin, - Isbn = r.Isbn ?? new List(), - r.Series, - r.SeriesNumber, - r.ProductUrl, - r.PublishedDate, - r.PublishYear, - r.Genres, - r.IsEnriched, - r.MetadataSource, - r.Source, - r.SourceLink, - r.Score - }).Cast().ToList() ?? new List(); - } + => _responseMapper.BuildApiImagePath(identifier, HttpContext, sourceUrl: sourceUrl); /// /// Perform a combined metadata and indexer search using a structured request body. @@ -131,7 +107,7 @@ await SearchResultImageNormalizer.NormalizeMetadataResultsAsync( setApiPathWhenNoExternalImage: true); // Map metadata results into Audible-shaped objects for public API consumers - var mapped = await Task.WhenAll((results ?? new List()).Select(r => MapMetadataResultToAudibleAsync(r, region))).ConfigureAwait(false); + var mapped = await Task.WhenAll((results ?? new List()).Select(r => _responseMapper.MapMetadataResultToAudibleAsync(r, region, HttpContext))).ConfigureAwait(false); _logger.LogDebug("[DBG] Search(simple) returning {Count} metadata results", mapped?.Length ?? 0); return Ok(mapped); } @@ -219,7 +195,7 @@ await SearchResultImageNormalizer.NormalizeMetadataResultsAsync( // Convert audible response to internal metadata then to SearchResult var metadata = _metadataConverters.ConvertAudibleToMetadata(audible, req.Asin, source: "Audible"); var sr = await _metadataConverters.ConvertMetadataToSearchResultAsync(metadata, req.Asin, req.Title, req.Author, fallbackImageUrl: null, fallbackLanguage: language); - SanitizeResultForPublicApi(sr, region); + _responseMapper.SanitizeResultForPublicApi(sr); // Convert to metadata result and normalize images for API response var md = SearchResultConverters.ToMetadata(sr); await SearchResultImageNormalizer.NormalizeMetadataResultAsync( @@ -233,7 +209,7 @@ await SearchResultImageNormalizer.NormalizeMetadataResultAsync( { var result = SearchResultConverters.ToSearchResult(md); var asinResults = new List { result }; - return Ok(useSimplified ? SimplifySearchResults(asinResults) : asinResults); + return Ok(useSimplified ? _responseMapper.SimplifySearchResults(asinResults) : asinResults); } } // If audible didn't return a record, fall through to unified search below @@ -316,7 +292,7 @@ await SearchResultImageNormalizer.NormalizeMetadataResultAsync( { try { - seriesResults.Add(await MapAudibleSearchResultToOutputAsync(book, region)); + seriesResults.Add(await _responseMapper.MapAudibleSearchResultToOutputAsync(book, region, HttpContext)); } catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) { @@ -373,42 +349,7 @@ await SearchResultImageNormalizer.NormalizeMetadataResultAsync( var returnLimit = req.Pagination != null && req.Pagination.Limit > 0 ? Math.Clamp(req.Pagination.Limit, 1, 1000) : 50; var results = await _searchService.IntelligentSearchAsync(query, candidateLimit, returnLimit, region: region, language: language, ct: HttpContext.RequestAborted); - // Ensure images for results are served via our API when possible. - // For results that provide an ASIN, prefer the local /api/v{version}/images/{asin} - // endpoint by checking cached images or attempting to download and cache - // external image URLs. This prevents leaking external Amazon/Audible - // image URLs to the SPA and avoids mixed image sources. - if (_imageCacheService != null && results != null) - { - foreach (var r in results) - { - try - { - if (r == null) continue; - if (string.IsNullOrWhiteSpace(r.Asin)) continue; - - var cached = await _imageCacheService.GetCachedImagePathAsync(r.Asin); - if (!string.IsNullOrWhiteSpace(cached)) - { - r.ImageUrl = BuildApiImagePath(r.Asin); - continue; - } - - if (!string.IsNullOrWhiteSpace(r.ImageUrl) && (r.ImageUrl.StartsWith("http://") || r.ImageUrl.StartsWith("https://"))) - { - var downloaded = await _imageCacheService.DownloadAndCacheImageAsync(r.ImageUrl, r.Asin); - if (!string.IsNullOrWhiteSpace(downloaded)) - { - r.ImageUrl = BuildApiImagePath(r.Asin); - } - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Failed to normalize image for result with ASIN {Asin}", r.Asin); - } - } - } + await _responseMapper.NormalizeMetadataResultImagesAsync(results, HttpContext, "result"); // When a Series filter was provided, apply it to unified search results so only // books actually belonging to the series are returned. This covers both the @@ -435,7 +376,7 @@ await SearchResultImageNormalizer.NormalizeMetadataResultAsync( } // Flatten metadata results into Audible-shaped objects for public POST /api/search response - var flatMapped = await Task.WhenAll((results ?? new List()).Select(r => MapMetadataResultToAudibleAsync(r, region))).ConfigureAwait(false); + var flatMapped = await Task.WhenAll((results ?? new List()).Select(r => _responseMapper.MapMetadataResultToAudibleAsync(r, region, HttpContext))).ConfigureAwait(false); return Ok(flatMapped); } } @@ -446,310 +387,6 @@ await SearchResultImageNormalizer.NormalizeMetadataResultAsync( } } - private void SanitizeResultForPublicApi(SearchResult r, string region) - { - // Minimal sanitization for public API: ensure ProductUrl is an http(s) URL when ASIN is available - try - { - if (r == null) return; - if (string.IsNullOrWhiteSpace(r.ProductUrl) && !string.IsNullOrWhiteSpace(r.Asin)) - { - r.ProductUrl = $"https://www.amazon.com/dp/{r.Asin}"; - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogDebug(ex, "Failed to sanitize public search result for ASIN {Asin}", r.Asin); - } - } - - // Map an AudibleSearchResult (from series/direct endpoints) to the Audible-shaped output object - private async Task MapAudibleSearchResultToOutputAsync(AudibleSearchResult book, string region) - { - string? imageUrl = book.ImageUrl; - if (!string.IsNullOrWhiteSpace(book.Asin) && _imageCacheService != null) - { - try - { - var cached = await _imageCacheService.GetCachedImagePathAsync(book.Asin); - if (!string.IsNullOrWhiteSpace(cached)) - { - imageUrl = BuildApiImagePath(book.Asin); - } - else if (!string.IsNullOrWhiteSpace(imageUrl) && (imageUrl.StartsWith("http://") || imageUrl.StartsWith("https://"))) - { - var downloaded = await _imageCacheService.DownloadAndCacheImageAsync(imageUrl, book.Asin); - if (!string.IsNullOrWhiteSpace(downloaded)) imageUrl = BuildApiImagePath(book.Asin); - } - else - { - imageUrl = BuildApiImagePath(book.Asin); - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogDebug(ex, "Failed to normalize image for series result ASIN {Asin}", book.Asin); - } - } - - var authors = (book.Authors ?? new List()).Where(a => a != null).Select(a => new - { - asin = a!.Asin, - name = a!.Name, - region = a!.Region ?? region, - regions = new[] { a!.Region ?? region }, - updatedAt = DateTime.UtcNow.ToString("o") - }).ToList(); - var narrators = (book.Narrators ?? new List()).Where(n => n != null).Select(n => new { name = n!.Name, updatedAt = DateTime.UtcNow.ToString("o") }).ToList(); - var genres = (book.Genres ?? new List()).Where(g => g != null).Select(g => new - { - asin = g!.Asin, - name = g!.Name, - type = g!.Type, - updatedAt = DateTime.UtcNow.ToString("o") - }).ToList(); - var series = (book.Series ?? new List()).Where(s => s != null).Select(s => new - { - asin = s!.Asin, - name = s!.Name, - region = region, - position = s!.Position, - updatedAt = DateTime.UtcNow.ToString("o") - }).ToList(); - - return new - { - asin = book.Asin, - title = book.Title, - subtitle = book.Subtitle, - region = region, - regions = new[] { region }, - description = (string?)null, - summary = (string?)null, - bookFormat = book.BookFormat, - imageUrl = imageUrl, - lengthMinutes = book.RuntimeLengthMin ?? book.LengthMinutes ?? book.RuntimeMinutes, - whisperSync = false, - publisher = book.Publisher, - isbn = book.Isbn, - language = book.Language, - releaseDate = book.ReleaseDate, - @explicit = false, - hasPdf = false, - link = !string.IsNullOrWhiteSpace(book.Asin) ? $"https://www.audible.com/pd/{book.Asin}" : (string?)null, - sku = book.Sku, - isListenable = !string.IsNullOrWhiteSpace(book.Asin), - isAvailable = true, - isBuyable = true, - contentType = book.ContentType ?? "Product", - contentDeliveryType = book.ContentDeliveryType, - authors, - narrators, - genres, - series, - seriesList = series.Select(s => $"{s.name}{(s.position != null ? $" #{s.position}" : "")}").ToList(), - updatedAt = DateTime.UtcNow.ToString("o") - }; - } - - // Map our internal MetadataSearchResult to a lightweight Audible-shaped object (async) - private async Task MapMetadataResultToAudibleAsync(MetadataSearchResult md, string region) - { - // If we have an ASIN and the metadata was enriched, try to fetch the canonical Audible payload - AudibleBookResponse? aud = null; - try - { - if (!string.IsNullOrWhiteSpace(md?.Asin)) - { - aud = await _metadataService.GetAudibleMetadataAsync(md.Asin, region, true); - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogDebug(ex, "Failed to retrieve Audible metadata for ASIN {Asin}", md?.Asin); - } - - // If Audible provided a rich response, prefer it (but normalize image URLs to local /api/v{version}/images/{asin} when possible) - if (aud != null) - { - string? imageUrl = aud.ImageUrl; - try - { - if (!string.IsNullOrWhiteSpace(aud.Asin) && _imageCacheService != null) - { - var cached = await _imageCacheService.GetCachedImagePathAsync(aud.Asin); - if (!string.IsNullOrWhiteSpace(cached)) - { - imageUrl = BuildApiImagePath(aud.Asin); - } - else if (!string.IsNullOrWhiteSpace(imageUrl) && (imageUrl.StartsWith("http://") || imageUrl.StartsWith("https://"))) - { - var downloaded = await _imageCacheService.DownloadAndCacheImageAsync(imageUrl, aud.Asin); - if (!string.IsNullOrWhiteSpace(downloaded)) imageUrl = BuildApiImagePath(aud.Asin); - } - else - { - // Map to API endpoint even if not cached to keep behaviour consistent - imageUrl = BuildApiImagePath(aud.Asin); - _ = _imageCacheService.DownloadAndCacheImageAsync(aud.ImageUrl ?? imageUrl, aud.Asin); - } - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Failed to normalize Audible image for {Asin}", aud.Asin); - } - - var authors = (aud.Authors ?? new List()).Where(a => a != null).Select(a => new - { - asin = a!.Asin, - name = a!.Name, - region = a!.Region ?? region, - regions = new[] { a!.Region ?? region }, - image = (string?)null, - updatedAt = DateTime.UtcNow.ToString("o") - }).ToList(); - - var narrators = (aud.Narrators ?? new List()).Where(n => n != null).Select(n => new { name = n!.Name, updatedAt = DateTime.UtcNow.ToString("o") }).ToList(); - - var genres = (aud.Genres ?? new List()).Where(g => g != null).Select(g => new - { - asin = g!.Asin, - name = g!.Name, - type = g!.Type, - betterType = (string?)null, - updatedAt = DateTime.UtcNow.ToString("o") - }).ToList(); - - var series = (aud.Series ?? new List()).Where(s => s != null).Select(s => new - { - asin = s!.Asin, - name = s!.Name, - region = region, - position = s!.Position, - updatedAt = DateTime.UtcNow.ToString("o") - }).ToList(); - - return new - { - asin = aud.Asin ?? md?.Asin, - title = aud.Title ?? md?.Title, - subtitle = aud.Subtitle ?? md?.Subtitle, - region = aud.Region ?? region, - regions = new[] { aud.Region ?? region }, - description = aud.Description ?? md?.Description, - summary = aud.Description ?? md?.Description, - copyright = (string?)null, - bookFormat = aud.BookFormat, - imageUrl = imageUrl, - lengthMinutes = aud.LengthMinutes ?? md?.Runtime, - whisperSync = false, - publisher = aud.Publisher ?? md?.Publisher, - isbn = aud.Isbn, - language = aud.Language ?? md?.Language, - rating = (double?)null, - releaseDate = aud.ReleaseDate ?? aud.PublishDate ?? md?.PublishedDate, - @explicit = aud.Explicit ?? false, - hasPdf = false, - link = !string.IsNullOrWhiteSpace(md?.ProductUrl) - ? md.ProductUrl - : !string.IsNullOrWhiteSpace(aud.Asin) ? $"https://www.audible.com/pd/{aud.Asin}" : null, - sku = aud.Sku, - skuGroup = (string?)null, - isListenable = !string.IsNullOrWhiteSpace(aud.Asin ?? md?.Asin), - isAvailable = true, - isBuyable = true, - contentType = aud.ContentType ?? (string?)null, - contentDeliveryType = aud.ContentDeliveryType, - authors = authors, - narrators = narrators, - genres = genres, - series = series, - seriesList = series?.Select(s => $"{s.name}{(s.position != null ? $" #{s.position}" : "")}").ToList(), - updatedAt = DateTime.UtcNow.ToString("o") - }; - } - - // Fallback: build a permissive Audible-like object from available MetadataSearchResult fields - var fallbackAuthors = new List(); - var fallbackNarrators = new List(); - if (!string.IsNullOrWhiteSpace(md?.Narrator)) fallbackNarrators.Add(new { name = md.Narrator, updatedAt = (string?)null }); - if (!string.IsNullOrWhiteSpace(md?.Author)) fallbackAuthors.Add(new { asin = (string?)null, name = md.Author, region = region, regions = new[] { region }, image = (string?)null, updatedAt = (string?)null }); - - var fallbackSeries = new List(); - if (!string.IsNullOrWhiteSpace(md?.Series)) fallbackSeries.Add(new { asin = md.Series, name = md.Series, region = region, position = md.SeriesNumber, updatedAt = (string?)null }); - - return new - { - asin = md?.Asin, - title = md?.Title, - subtitle = md?.Subtitle, - region = region, - regions = new[] { region }, - description = md?.Description, - summary = md?.Description, - copyright = (string?)null, - bookFormat = (string?)null, - imageUrl = md?.ImageUrl, - lengthMinutes = md?.Runtime, - whisperSync = false, - publisher = md?.Publisher, - isbn = md?.Isbn, - language = md?.Language, - rating = (double?)null, - releaseDate = md?.PublishedDate, - @explicit = false, - hasPdf = false, - link = md?.ProductUrl, - sku = (string?)null, - skuGroup = (string?)null, - isListenable = !string.IsNullOrWhiteSpace(md?.Asin), - isAvailable = true, - isBuyable = true, - contentType = "Product", - contentDeliveryType = (string?)null, - authors = fallbackAuthors, - narrators = fallbackNarrators, - genres = new List(), - series = fallbackSeries, - updatedAt = (string?)null - }; - } - - private async Task EnsureCachedImagesForAudibleResultsAsync(List? results) - { - if (results == null || results.Count == 0) return; - if (_imageCacheService == null) return; // nothing to do in tests if not provided - - foreach (var r in results) - { - try - { - if (string.IsNullOrWhiteSpace(r.Asin)) continue; - - var cached = await _imageCacheService.GetCachedImagePathAsync(r.Asin); - if (!string.IsNullOrWhiteSpace(cached)) - { - r.ImageUrl = BuildApiImagePath(r.Asin); - continue; - } - - if (!string.IsNullOrWhiteSpace(r.ImageUrl)) - { - var downloaded = await _imageCacheService.DownloadAndCacheImageAsync(r.ImageUrl, r.Asin); - if (!string.IsNullOrWhiteSpace(downloaded)) - { - r.ImageUrl = BuildApiImagePath(r.Asin); - } - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Failed to ensure cached image for {Asin}", r?.Asin); - } - } - } - /// /// Search configured indexers for audiobook torrents/NZBs using query parameters. /// @@ -907,35 +544,7 @@ public async Task>> IntelligentSearch( var region = Request.Query.TryGetValue("region", out var regionValue) ? regionValue.ToString() ?? "us" : "us"; var language = Request.Query.TryGetValue("language", out var languageValue) ? languageValue.ToString() : null; var results = await _searchService.IntelligentSearchAsync(query, candidateLimit, returnLimit, containmentMode, requireAuthorAndPublisher, fuzzyThreshold, region, language, HttpContext.RequestAborted); - // Normalize images for metadata results so the SPA receives local /api/v{version}/images/{asin} when possible - if (_imageCacheService != null && results != null) - { - foreach (var r in results) - { - try - { - if (r == null) continue; - if (string.IsNullOrWhiteSpace(r.Asin)) continue; - - var cached = await _imageCacheService.GetCachedImagePathAsync(r.Asin); - if (!string.IsNullOrWhiteSpace(cached)) - { - r.ImageUrl = BuildApiImagePath(r.Asin); - continue; - } - - if (!string.IsNullOrWhiteSpace(r.ImageUrl) && (r.ImageUrl.StartsWith("http://") || r.ImageUrl.StartsWith("https://"))) - { - var downloaded = await _imageCacheService.DownloadAndCacheImageAsync(r.ImageUrl, r.Asin); - if (!string.IsNullOrWhiteSpace(downloaded)) r.ImageUrl = BuildApiImagePath(r.Asin); - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Failed to normalize image for metadata result ASIN {Asin}", r.Asin); - } - } - } + await _responseMapper.NormalizeMetadataResultImagesAsync(results, HttpContext, "metadata result"); _logger.LogInformation("IntelligentSearch returning {Count} results for query: {Query}", results?.Count ?? 0, LogRedaction.SanitizeText(query)); return Ok(results ?? new List()); } diff --git a/listenarr.api/Controllers/SearchResponseMapper.cs b/listenarr.api/Controllers/SearchResponseMapper.cs new file mode 100644 index 000000000..344a31b88 --- /dev/null +++ b/listenarr.api/Controllers/SearchResponseMapper.cs @@ -0,0 +1,424 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +using Listenarr.Application.Interfaces; +using Listenarr.Application.Metadata; +using Listenarr.Domain.Models; + +namespace Listenarr.Api.Controllers; + +public sealed class SearchResponseMapper +{ + private readonly IAudiobookMetadataService _metadataService; + private readonly IImageCacheService? _imageCacheService; + private readonly ILogger _logger; + + public SearchResponseMapper( + IAudiobookMetadataService metadataService, + ILogger logger, + IImageCacheService? imageCacheService = null) + { + _metadataService = metadataService; + _logger = logger; + _imageCacheService = imageCacheService; + } + + public string BuildApiImagePath(string identifier, HttpContext httpContext, string? sourceUrl = null) + => HttpApiVersionUtils.BuildImagePath(identifier, httpContext, sourceUrl: sourceUrl); + + public List SimplifySearchResults(List results) + { + return results?.Select(r => new + { + r.Id, + r.Title, + Artist = r.Artist, + r.Subtitle, + r.Description, + r.Publisher, + r.Language, + r.Runtime, + r.Narrator, + r.ImageUrl, + r.Asin, + Isbn = r.Isbn ?? new List(), + r.Series, + r.SeriesNumber, + r.ProductUrl, + r.PublishedDate, + r.PublishYear, + r.Genres, + r.IsEnriched, + r.MetadataSource, + r.Source, + r.SourceLink, + r.Score + }).Cast().ToList() ?? new List(); + } + + public void SanitizeResultForPublicApi(SearchResult r) + { + try + { + if (r == null) return; + if (string.IsNullOrWhiteSpace(r.ProductUrl) && !string.IsNullOrWhiteSpace(r.Asin)) + { + r.ProductUrl = $"https://www.amazon.com/dp/{r.Asin}"; + } + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogDebug(ex, "Failed to sanitize public search result for ASIN {Asin}", r.Asin); + } + } + + public async Task NormalizeMetadataResultImagesAsync( + List? results, + HttpContext httpContext, + string logContext) + { + if (_imageCacheService == null || results == null) + return; + + foreach (var r in results) + { + try + { + if (r == null) continue; + if (string.IsNullOrWhiteSpace(r.Asin)) continue; + + var cached = await _imageCacheService.GetCachedImagePathAsync(r.Asin); + if (!string.IsNullOrWhiteSpace(cached)) + { + r.ImageUrl = BuildApiImagePath(r.Asin, httpContext); + continue; + } + + if (!string.IsNullOrWhiteSpace(r.ImageUrl) && + (r.ImageUrl.StartsWith("http://") || r.ImageUrl.StartsWith("https://"))) + { + var downloaded = await _imageCacheService.DownloadAndCacheImageAsync(r.ImageUrl, r.Asin); + if (!string.IsNullOrWhiteSpace(downloaded)) + { + r.ImageUrl = BuildApiImagePath(r.Asin, httpContext); + } + } + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning(ex, "Failed to normalize image for {Context} ASIN {Asin}", logContext, r.Asin); + } + } + } + + public async Task MapAudibleSearchResultToOutputAsync( + AudibleSearchResult book, + string region, + HttpContext httpContext) + { + string? imageUrl = book.ImageUrl; + if (!string.IsNullOrWhiteSpace(book.Asin) && _imageCacheService != null) + { + try + { + var cached = await _imageCacheService.GetCachedImagePathAsync(book.Asin); + if (!string.IsNullOrWhiteSpace(cached)) + { + imageUrl = BuildApiImagePath(book.Asin, httpContext); + } + else if (!string.IsNullOrWhiteSpace(imageUrl) && (imageUrl.StartsWith("http://") || imageUrl.StartsWith("https://"))) + { + var downloaded = await _imageCacheService.DownloadAndCacheImageAsync(imageUrl, book.Asin); + if (!string.IsNullOrWhiteSpace(downloaded)) imageUrl = BuildApiImagePath(book.Asin, httpContext); + } + else + { + imageUrl = BuildApiImagePath(book.Asin, httpContext); + } + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogDebug(ex, "Failed to normalize image for series result ASIN {Asin}", book.Asin); + } + } + + var authors = (book.Authors ?? new List()).Where(a => a != null).Select(a => new + { + asin = a!.Asin, + name = a!.Name, + region = a!.Region ?? region, + regions = new[] { a!.Region ?? region }, + updatedAt = DateTime.UtcNow.ToString("o") + }).ToList(); + var narrators = (book.Narrators ?? new List()).Where(n => n != null).Select(n => new { name = n!.Name, updatedAt = DateTime.UtcNow.ToString("o") }).ToList(); + var genres = (book.Genres ?? new List()).Where(g => g != null).Select(g => new + { + asin = g!.Asin, + name = g!.Name, + type = g!.Type, + updatedAt = DateTime.UtcNow.ToString("o") + }).ToList(); + var series = (book.Series ?? new List()).Where(s => s != null).Select(s => new + { + asin = s!.Asin, + name = s!.Name, + region = region, + position = s!.Position, + updatedAt = DateTime.UtcNow.ToString("o") + }).ToList(); + + return new + { + asin = book.Asin, + title = book.Title, + subtitle = book.Subtitle, + region = region, + regions = new[] { region }, + description = (string?)null, + summary = (string?)null, + bookFormat = book.BookFormat, + imageUrl = imageUrl, + lengthMinutes = book.RuntimeLengthMin ?? book.LengthMinutes ?? book.RuntimeMinutes, + whisperSync = false, + publisher = book.Publisher, + isbn = book.Isbn, + language = book.Language, + releaseDate = book.ReleaseDate, + @explicit = false, + hasPdf = false, + link = !string.IsNullOrWhiteSpace(book.Asin) ? $"https://www.audible.com/pd/{book.Asin}" : (string?)null, + sku = book.Sku, + isListenable = !string.IsNullOrWhiteSpace(book.Asin), + isAvailable = true, + isBuyable = true, + contentType = book.ContentType ?? "Product", + contentDeliveryType = book.ContentDeliveryType, + authors, + narrators, + genres, + series, + seriesList = series.Select(s => $"{s.name}{(s.position != null ? $" #{s.position}" : "")}").ToList(), + updatedAt = DateTime.UtcNow.ToString("o") + }; + } + + public async Task MapMetadataResultToAudibleAsync( + MetadataSearchResult md, + string region, + HttpContext httpContext) + { + AudibleBookResponse? aud = null; + try + { + if (!string.IsNullOrWhiteSpace(md?.Asin)) + { + aud = await _metadataService.GetAudibleMetadataAsync(md.Asin, region, true); + } + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogDebug(ex, "Failed to retrieve Audible metadata for ASIN {Asin}", md?.Asin); + } + + if (aud != null) + { + return await MapAudibleMetadataToOutputAsync(aud, md, region, httpContext); + } + + var fallbackAuthors = new List(); + var fallbackNarrators = new List(); + if (!string.IsNullOrWhiteSpace(md?.Narrator)) fallbackNarrators.Add(new { name = md.Narrator, updatedAt = (string?)null }); + if (!string.IsNullOrWhiteSpace(md?.Author)) fallbackAuthors.Add(new { asin = (string?)null, name = md.Author, region = region, regions = new[] { region }, image = (string?)null, updatedAt = (string?)null }); + + var fallbackSeries = new List(); + if (!string.IsNullOrWhiteSpace(md?.Series)) fallbackSeries.Add(new { asin = md.Series, name = md.Series, region = region, position = md.SeriesNumber, updatedAt = (string?)null }); + + return new + { + asin = md?.Asin, + title = md?.Title, + subtitle = md?.Subtitle, + region = region, + regions = new[] { region }, + description = md?.Description, + summary = md?.Description, + copyright = (string?)null, + bookFormat = (string?)null, + imageUrl = md?.ImageUrl, + lengthMinutes = md?.Runtime, + whisperSync = false, + publisher = md?.Publisher, + isbn = md?.Isbn, + language = md?.Language, + rating = (double?)null, + releaseDate = md?.PublishedDate, + @explicit = false, + hasPdf = false, + link = md?.ProductUrl, + sku = (string?)null, + skuGroup = (string?)null, + isListenable = !string.IsNullOrWhiteSpace(md?.Asin), + isAvailable = true, + isBuyable = true, + contentType = "Product", + contentDeliveryType = (string?)null, + authors = fallbackAuthors, + narrators = fallbackNarrators, + genres = new List(), + series = fallbackSeries, + updatedAt = (string?)null + }; + } + + public async Task EnsureCachedImagesForAudibleResultsAsync( + List? results, + HttpContext httpContext) + { + if (results == null || results.Count == 0) return; + if (_imageCacheService == null) return; + + foreach (var r in results) + { + try + { + if (string.IsNullOrWhiteSpace(r.Asin)) continue; + + var cached = await _imageCacheService.GetCachedImagePathAsync(r.Asin); + if (!string.IsNullOrWhiteSpace(cached)) + { + r.ImageUrl = BuildApiImagePath(r.Asin, httpContext); + continue; + } + + if (!string.IsNullOrWhiteSpace(r.ImageUrl)) + { + var downloaded = await _imageCacheService.DownloadAndCacheImageAsync(r.ImageUrl, r.Asin); + if (!string.IsNullOrWhiteSpace(downloaded)) + { + r.ImageUrl = BuildApiImagePath(r.Asin, httpContext); + } + } + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning(ex, "Failed to ensure cached image for {Asin}", r?.Asin); + } + } + } + + private async Task MapAudibleMetadataToOutputAsync( + AudibleBookResponse aud, + MetadataSearchResult? md, + string region, + HttpContext httpContext) + { + string? imageUrl = aud.ImageUrl; + try + { + if (!string.IsNullOrWhiteSpace(aud.Asin) && _imageCacheService != null) + { + var cached = await _imageCacheService.GetCachedImagePathAsync(aud.Asin); + if (!string.IsNullOrWhiteSpace(cached)) + { + imageUrl = BuildApiImagePath(aud.Asin, httpContext); + } + else if (!string.IsNullOrWhiteSpace(imageUrl) && (imageUrl.StartsWith("http://") || imageUrl.StartsWith("https://"))) + { + var downloaded = await _imageCacheService.DownloadAndCacheImageAsync(imageUrl, aud.Asin); + if (!string.IsNullOrWhiteSpace(downloaded)) imageUrl = BuildApiImagePath(aud.Asin, httpContext); + } + else + { + imageUrl = BuildApiImagePath(aud.Asin, httpContext); + _ = _imageCacheService.DownloadAndCacheImageAsync(aud.ImageUrl ?? imageUrl, aud.Asin); + } + } + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning(ex, "Failed to normalize Audible image for {Asin}", aud.Asin); + } + + var authors = (aud.Authors ?? new List()).Where(a => a != null).Select(a => new + { + asin = a!.Asin, + name = a!.Name, + region = a!.Region ?? region, + regions = new[] { a!.Region ?? region }, + image = (string?)null, + updatedAt = DateTime.UtcNow.ToString("o") + }).ToList(); + + var narrators = (aud.Narrators ?? new List()).Where(n => n != null).Select(n => new { name = n!.Name, updatedAt = DateTime.UtcNow.ToString("o") }).ToList(); + var genres = (aud.Genres ?? new List()).Where(g => g != null).Select(g => new + { + asin = g!.Asin, + name = g!.Name, + type = g!.Type, + betterType = (string?)null, + updatedAt = DateTime.UtcNow.ToString("o") + }).ToList(); + var series = (aud.Series ?? new List()).Where(s => s != null).Select(s => new + { + asin = s!.Asin, + name = s!.Name, + region = region, + position = s!.Position, + updatedAt = DateTime.UtcNow.ToString("o") + }).ToList(); + + return new + { + asin = aud.Asin ?? md?.Asin, + title = aud.Title ?? md?.Title, + subtitle = aud.Subtitle ?? md?.Subtitle, + region = aud.Region ?? region, + regions = new[] { aud.Region ?? region }, + description = aud.Description ?? md?.Description, + summary = aud.Description ?? md?.Description, + copyright = (string?)null, + bookFormat = aud.BookFormat, + imageUrl = imageUrl, + lengthMinutes = aud.LengthMinutes ?? md?.Runtime, + whisperSync = false, + publisher = aud.Publisher ?? md?.Publisher, + isbn = aud.Isbn, + language = aud.Language ?? md?.Language, + rating = (double?)null, + releaseDate = aud.ReleaseDate ?? aud.PublishDate ?? md?.PublishedDate, + @explicit = aud.Explicit ?? false, + hasPdf = false, + link = !string.IsNullOrWhiteSpace(md?.ProductUrl) + ? md.ProductUrl + : !string.IsNullOrWhiteSpace(aud.Asin) ? $"https://www.audible.com/pd/{aud.Asin}" : null, + sku = aud.Sku, + skuGroup = (string?)null, + isListenable = !string.IsNullOrWhiteSpace(aud.Asin ?? md?.Asin), + isAvailable = true, + isBuyable = true, + contentType = aud.ContentType ?? (string?)null, + contentDeliveryType = aud.ContentDeliveryType, + authors = authors, + narrators = narrators, + genres = genres, + series = series, + seriesList = series.Select(s => $"{s.name}{(s.position != null ? $" #{s.position}" : "")}").ToList(), + updatedAt = DateTime.UtcNow.ToString("o") + }; + } +} diff --git a/listenarr.api/Program.cs b/listenarr.api/Program.cs index e5572dca6..df63d81e4 100644 --- a/listenarr.api/Program.cs +++ b/listenarr.api/Program.cs @@ -357,6 +357,8 @@ ex is IOException builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); // Register named HttpClients for each adapter type so adapter implementations can request the appropriately-configured client. builder.Services.AddListenarrHttpClients(builder.Configuration); diff --git a/listenarr.application/Search/MyAnonamouseResponseParser.cs b/listenarr.application/Search/MyAnonamouseResponseParser.cs index 1b6516e78..7378fa82a 100644 --- a/listenarr.application/Search/MyAnonamouseResponseParser.cs +++ b/listenarr.application/Search/MyAnonamouseResponseParser.cs @@ -25,7 +25,7 @@ namespace Listenarr.Application.Search { - internal static class MyAnonamouseResponseParser + public static class MyAnonamouseResponseParser { public static List Parse(string jsonResponse, Indexer indexer, ILogger logger) { diff --git a/listenarr.application/Search/SearchService.cs b/listenarr.application/Search/SearchService.cs index 8761f6d32..310da74f7 100644 --- a/listenarr.application/Search/SearchService.cs +++ b/listenarr.application/Search/SearchService.cs @@ -41,8 +41,6 @@ public class SearchService : ISearchService private readonly SearchResultScorerService _searchResultScorer; private readonly SearchResultSortingService _searchResultSorting; private readonly AsinSearchHandler _asinSearchHandler; - private readonly IMemoryCache? _cache; - private readonly ICoverImageProbe? _coverImageProbe; private readonly IndexerSearchWorkflow _indexerSearchWorkflow; private readonly MetadataSourceCatalog _metadataSourceCatalog; @@ -78,8 +76,6 @@ public SearchService( _searchResultScorer = searchResultScorer; _searchResultSorting = searchResultSorting; _asinSearchHandler = asinSearchHandler; - _cache = cache; - _coverImageProbe = coverImageProbe; _indexerSearchWorkflow = indexerSearchWorkflow ?? new IndexerSearchWorkflow( httpClient, configurationService, @@ -884,113 +880,6 @@ public async Task> IntelligentSearchAsync(string quer } } - private static bool isOpenLibraryResult(SearchResult r) - { - return string.Equals(r?.MetadataSource, "OpenLibrary", StringComparison.OrdinalIgnoreCase); - } - - // Try to pick the best cover URL from a list of OpenLibrary cover IDs by measuring image aspect ratios. - // Returns a full covers.openlibrary.org URL or null on failure. - private async Task PickBestCoverUrlAsync(List coverIds) - { - if (coverIds == null || !coverIds.Any()) return null; - - double bestDelta = double.MaxValue; - string? bestUrl = null; - - foreach (var cid in coverIds) - { - try - { - var url = $"https://covers.openlibrary.org/b/id/{cid}-L.jpg"; - var dimensions = _coverImageProbe == null ? null : await _coverImageProbe.ProbeAsync(url); - if (dimensions == null || dimensions.Value.Height == 0) continue; - - var ratio = (double)dimensions.Value.Width / dimensions.Value.Height; - var delta = Math.Abs(ratio - 1.0); - if (delta < bestDelta) - { - bestDelta = delta; - bestUrl = url; - } - - if (Math.Abs(delta) < 0.01) - break; - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogDebug(ex, "Failed to fetch cover image for id {Id}", cid); - continue; - } - } - - return bestUrl; - } - - - private int? ParseDuration(string? duration) - { - if (string.IsNullOrEmpty(duration)) return null; - - try - { - // Try to extract hours and minutes from duration string - var hoursMatch = System.Text.RegularExpressions.Regex.Match(duration, @"(\d+)\s*hrs?"); - var minutesMatch = System.Text.RegularExpressions.Regex.Match(duration, @"(\d+)\s*mins?"); - - int totalMinutes = 0; - - if (hoursMatch.Success) - { - totalMinutes += int.Parse(hoursMatch.Groups[1].Value) * 60; - } - - if (minutesMatch.Success) - { - totalMinutes += int.Parse(minutesMatch.Groups[1].Value); - } - - return totalMinutes > 0 ? totalMinutes : null; - } - catch (Exception caughtEx_17) when (caughtEx_17 is not OperationCanceledException && caughtEx_17 is not OutOfMemoryException && caughtEx_17 is not StackOverflowException) - { - return null; - } - } - - private async Task> TraditionalSearchAsync(string query, string? category = null, List? apiIds = null) - { - var results = new List(); - var apis = await _configurationService.GetApiConfigurationsAsync(); - - if (apiIds != null && apiIds.Any()) - { - apis = apis.Where(a => apiIds.Contains(a.Id)).ToList(); - } - - var enabledApis = apis.Where(a => a.IsEnabled).OrderBy(a => a.Priority).ToList(); - - var searchTasks = enabledApis.Select(api => SearchByApiAsync(api.Id, query, category)); - var apiResults = await Task.WhenAll(searchTasks); - - foreach (var apiResult in apiResults) - { - foreach (var result in apiResult) - { - results.Add(result); - } - } - - return results; - } - - private string ExtractAsin(string magnetLink) - { - // TODO: Implement ASIN extraction logic from magnet/torrent/nzb or other property - // For now, return empty string - return string.Empty; - } - public async Task> SearchByApiAsync(string apiId, string query, string? category = null) { return await _indexerSearchWorkflow.SearchByApiAsync(apiId, query, category); @@ -1011,31 +900,6 @@ internal async Task> ParseTorznabResponseAsync(string return await _indexerSearchWorkflow.ParseTorznabResponseAsync(xmlContent, indexer); } - private List GenerateMockResults(string query, string source) - { - // This is mock data for development purposes - return new List - { - new SearchResult - { - Id = Guid.NewGuid().ToString(), - Title = $"Sample Audiobook - {query}", - Artist = "Sample Author", - Album = "Sample Series Book 1", - Category = "Audiobook", - Size = 512_000_000, // 512 MB - Seeders = 25, - Leechers = 3, - MagnetLink = "magnet:?xt=urn:btih:sample", - Source = source, - PublishedDate = DateTime.UtcNow.AddDays(-Random.Shared.Next(1, 365)).ToString("o"), - Quality = "MP3 128kbps", - Format = "MP3" - } - }; - } - - public async Task> GetEnabledMetadataSourcesAsync() { return await _metadataSourceCatalog.GetEnabledMetadataSourcesAsync(); diff --git a/listenarr.infrastructure/Adapters/NzbgetAdapter.cs b/listenarr.infrastructure/Adapters/NzbgetAdapter.cs index b1ddb0090..5b341c45a 100644 --- a/listenarr.infrastructure/Adapters/NzbgetAdapter.cs +++ b/listenarr.infrastructure/Adapters/NzbgetAdapter.cs @@ -429,7 +429,7 @@ public async Task> GetQueueAsync(DownloadClientConfiguration cli continue; } - var queueItem = MapGroup(client, structElement); + var queueItem = NzbgetResponseMapper.MapGroup(client, structElement); items.Add(queueItem); } } @@ -534,7 +534,7 @@ public async Task> GetItemsAsync(DownloadClientConfigur continue; } - var downloadClientItem = await MapGroupToDownloadClientItemAsync(client, structElement); + var downloadClientItem = NzbgetResponseMapper.MapGroupToDownloadClientItem(client, structElement); items.Add(downloadClientItem); } } @@ -621,166 +621,6 @@ public async Task GetImportItemAsync( } } - private async Task MapGroupToDownloadClientItemAsync(DownloadClientConfiguration client, XElement structElement) - { - var members = (IReadOnlyDictionary)structElement.Elements("member").ToDictionary( - m => m.Element("name")?.Value ?? string.Empty, - m => m.Element("value")?.Elements().FirstOrDefault()?.Value ?? string.Empty - ); - - var id = members.GetValueOrDefault("GroupID", null) - ?? members.GetValueOrDefault("LastID", null) - ?? Guid.NewGuid().ToString("N"); - - var title = members.GetValueOrDefault("NZBName", string.Empty); - var statusRaw = members.GetValueOrDefault("Status", string.Empty); - var category = members.GetValueOrDefault("Category", string.Empty); - var sizeMb = double.TryParse(members.GetValueOrDefault("FileSizeMB", "0"), NumberStyles.Any, CultureInfo.InvariantCulture, out var sm) ? sm : 0d; - var remainingMb = double.TryParse(members.GetValueOrDefault("RemainingSizeMB", "0"), NumberStyles.Any, CultureInfo.InvariantCulture, out var rm) ? rm : 0d; - var downloadRate = double.TryParse(members.GetValueOrDefault("DownloadRate", "0"), NumberStyles.Any, CultureInfo.InvariantCulture, out var dr) ? dr : 0d; - var destDir = members.GetValueOrDefault("DestDir", string.Empty); - - var sizeBytes = Convert.ToInt64(Math.Max(0, sizeMb) * 1024 * 1024); - var remainingBytes = Convert.ToInt64(Math.Max(0, remainingMb) * 1024 * 1024); - - TimeSpan? remainingTime = null; - if (downloadRate > 0 && remainingMb > 0) - { - var remainingBytesExact = remainingMb * 1024 * 1024; - var etaSeconds = (int)Math.Max(0, remainingBytesExact / downloadRate); - remainingTime = TimeSpan.FromSeconds(etaSeconds); - } - - // Map NZBGet status to DownloadItemStatus. - // NZBGet can emit suffixed states (e.g. SUCCESS/HEALTH, FAILURE/HEALTH). - var normalizedStatus = (statusRaw ?? "QUEUED").ToUpperInvariant(); - var status = normalizedStatus switch - { - "QUEUED" => DownloadItemStatus.Queued, - "DOWNLOADING" => DownloadItemStatus.Downloading, - "PAUSED" => DownloadItemStatus.Paused, - "FETCHING" => DownloadItemStatus.Downloading, - "SCANNING" => DownloadItemStatus.Downloading, - "PP_QUEUED" => DownloadItemStatus.Downloading, - "PP_PROCESSING" => DownloadItemStatus.Downloading, - _ when normalizedStatus.StartsWith("SUCCESS", StringComparison.Ordinal) => DownloadItemStatus.Completed, - _ when normalizedStatus.StartsWith("FAILURE", StringComparison.Ordinal) || normalizedStatus.StartsWith("FAILED", StringComparison.Ordinal) => DownloadItemStatus.Failed, - _ => DownloadItemStatus.Queued - }; - - // For NZBGet, construct OutputPath from destDir + title - var contentPath = !string.IsNullOrEmpty(destDir) && !string.IsNullOrEmpty(title) - ? CombineWithOptionalBase(destDir, title) - : (destDir ?? string.Empty); - var localContentPath = contentPath ?? string.Empty; - - var progress = sizeMb > 0 ? Math.Clamp((sizeMb - remainingMb) / sizeMb * 100, 0, 100) : 0; - - return new DownloadClientItem - { - DownloadId = id.ToUpperInvariant(), - Title = title ?? string.Empty, - Category = category ?? string.Empty, - Status = status, - TotalSize = sizeBytes, - RemainingSize = remainingBytes, - RemainingTime = remainingTime, - OutputPath = localContentPath ?? string.Empty, - Message = statusRaw ?? "QUEUED", - Progress = progress, - DownloadSpeed = downloadRate, - CanBeRemoved = true, - CanMoveFiles = status == DownloadItemStatus.Completed, - DownloadClientInfo = DownloadClientItemClientInfo.FromClient( - clientId: client.Id, - clientName: client.Name, - clientType: "nzbget", - protocol: DownloadProtocol.Usenet, - removeCompletedDownloads: client.Settings?.TryGetValue("removeCompletedDownloads", out var removeVal) is true && - (removeVal is bool boolVal && boolVal), - hasPostImportCategory: !string.IsNullOrEmpty(client.Settings?.GetValueOrDefault("postImportCategory")?.ToString()) - ) - }; - } - - private QueueItem MapGroup(DownloadClientConfiguration client, XElement structElement) - { - var members = (IReadOnlyDictionary)structElement.Elements("member").ToDictionary( - m => m.Element("name")?.Value ?? string.Empty, - m => m.Element("value")?.Elements().FirstOrDefault()?.Value ?? string.Empty - ); - - var id = members.GetValueOrDefault("GroupID", null) - ?? members.GetValueOrDefault("LastID", null) - ?? Guid.NewGuid().ToString("N"); - - var title = members.GetValueOrDefault("NZBName", string.Empty); - var statusRaw = members.GetValueOrDefault("Status", string.Empty); - var category = members.GetValueOrDefault("Category", string.Empty); - var sizeMb = double.TryParse(members.GetValueOrDefault("FileSizeMB", "0"), NumberStyles.Any, CultureInfo.InvariantCulture, out var sm) ? sm : 0d; - var remainingMb = double.TryParse(members.GetValueOrDefault("RemainingSizeMB", "0"), NumberStyles.Any, CultureInfo.InvariantCulture, out var rm) ? rm : 0d; - var downloadedMb = sizeMb - remainingMb; - var downloadRate = double.TryParse(members.GetValueOrDefault("DownloadRate", "0"), NumberStyles.Any, CultureInfo.InvariantCulture, out var dr) ? dr : 0d; - var destDir = members.GetValueOrDefault("DestDir", string.Empty); - - var sizeBytes = Convert.ToInt64(Math.Max(0, sizeMb) * 1024 * 1024); - var downloadedBytes = Convert.ToInt64(Math.Max(0, downloadedMb) * 1024 * 1024); - - int? etaSeconds = null; - if (downloadRate > 0 && remainingMb > 0) - { - var remainingBytes = remainingMb * 1024 * 1024; - etaSeconds = (int)Math.Max(0, remainingBytes / downloadRate); - } - - var normalizedStatus = (statusRaw ?? "QUEUED").ToUpperInvariant(); - string status = normalizedStatus switch - { - "QUEUED" => "queued", - "DOWNLOADING" => "downloading", - "PAUSED" => "paused", - "FETCHING" => "downloading", - "SCANNING" => "downloading", - "PP_QUEUED" => "downloading", - "PP_PROCESSING" => "downloading", - _ when normalizedStatus.StartsWith("SUCCESS", StringComparison.Ordinal) => "completed", - _ when normalizedStatus.StartsWith("FAILURE", StringComparison.Ordinal) || normalizedStatus.StartsWith("FAILED", StringComparison.Ordinal) => "failed", - _ => "queued" - }; - - string? localPath = destDir; - - // For NZBGet, construct ContentPath from destDir + title - var contentPath = !string.IsNullOrEmpty(destDir) && !string.IsNullOrEmpty(title) - ? CombineWithOptionalBase(destDir, title) - : destDir; - var localContentPath = contentPath; - - var addedAt = DateTime.UtcNow; - - return new QueueItem - { - Id = id, - Title = title ?? string.Empty, - Quality = category ?? string.Empty, - Status = status, - Progress = sizeMb > 0 ? Math.Clamp(downloadedMb / sizeMb * 100, 0, 100) : 0, - Size = sizeBytes, - Downloaded = downloadedBytes, - DownloadSpeed = downloadRate, - Eta = etaSeconds > 0 ? etaSeconds : null, - DownloadClient = client.Name ?? client.Id ?? "NZBGet", - DownloadClientId = client.Id ?? string.Empty, - DownloadClientType = ClientType, - AddedAt = addedAt, - CanPause = status is "downloading" or "queued", - CanRemove = true, - RemotePath = destDir, - LocalPath = localPath, - ContentPath = localContentPath - }; - } - private string ResolveCategory(DownloadClientConfiguration client) { if (client.Settings != null && client.Settings.TryGetValue("category", out var categoryObj)) @@ -1091,32 +931,6 @@ public async Task GetImportItemAsync( } } - private static string CombineWithOptionalBase(string? basePath, string candidatePath) - { - var normalizedPath = candidatePath.Trim(); - - if (string.IsNullOrEmpty(normalizedPath)) - { - return normalizedPath; - } - - if (Path.IsPathRooted(normalizedPath) || string.IsNullOrWhiteSpace(basePath)) - { - return normalizedPath; - } - - var relativePath = normalizedPath.TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - if (Path.IsPathRooted(relativePath)) - { - return relativePath; - } - - var normalizedBasePath = basePath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - return string.IsNullOrEmpty(normalizedBasePath) - ? relativePath - : normalizedBasePath + Path.DirectorySeparatorChar + relativePath; - } - public async Task> FetchDownloadsAsync( DownloadClientConfiguration client, List downloads, @@ -1226,4 +1040,3 @@ public async Task> FetchDownloadsAsync( } } } - diff --git a/listenarr.infrastructure/Adapters/NzbgetResponseMapper.cs b/listenarr.infrastructure/Adapters/NzbgetResponseMapper.cs new file mode 100644 index 000000000..775c56da1 --- /dev/null +++ b/listenarr.infrastructure/Adapters/NzbgetResponseMapper.cs @@ -0,0 +1,189 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +using System.Globalization; +using System.Xml.Linq; +using Listenarr.Domain.Common; +using Listenarr.Domain.Models; + +namespace Listenarr.Infrastructure.Adapters; + +internal static class NzbgetResponseMapper +{ + public static DownloadClientItem MapGroupToDownloadClientItem( + DownloadClientConfiguration client, + XElement structElement) + { + var members = ReadMembers(structElement); + + var id = members.GetValueOrDefault("GroupID", null) + ?? members.GetValueOrDefault("LastID", null) + ?? Guid.NewGuid().ToString("N"); + + var title = members.GetValueOrDefault("NZBName", string.Empty); + var statusRaw = members.GetValueOrDefault("Status", string.Empty); + var category = members.GetValueOrDefault("Category", string.Empty); + var sizeMb = ParseDouble(members.GetValueOrDefault("FileSizeMB", "0")); + var remainingMb = ParseDouble(members.GetValueOrDefault("RemainingSizeMB", "0")); + var downloadRate = ParseDouble(members.GetValueOrDefault("DownloadRate", "0")); + var destDir = members.GetValueOrDefault("DestDir", string.Empty); + + var sizeBytes = Convert.ToInt64(Math.Max(0, sizeMb) * 1024 * 1024); + var remainingBytes = Convert.ToInt64(Math.Max(0, remainingMb) * 1024 * 1024); + + TimeSpan? remainingTime = null; + if (downloadRate > 0 && remainingMb > 0) + { + var remainingBytesExact = remainingMb * 1024 * 1024; + var etaSeconds = (int)Math.Max(0, remainingBytesExact / downloadRate); + remainingTime = TimeSpan.FromSeconds(etaSeconds); + } + + var normalizedStatus = (statusRaw ?? "QUEUED").ToUpperInvariant(); + var status = normalizedStatus switch + { + "QUEUED" => DownloadItemStatus.Queued, + "DOWNLOADING" => DownloadItemStatus.Downloading, + "PAUSED" => DownloadItemStatus.Paused, + "FETCHING" => DownloadItemStatus.Downloading, + "SCANNING" => DownloadItemStatus.Downloading, + "PP_QUEUED" => DownloadItemStatus.Downloading, + "PP_PROCESSING" => DownloadItemStatus.Downloading, + _ when normalizedStatus.StartsWith("SUCCESS", StringComparison.Ordinal) => DownloadItemStatus.Completed, + _ when normalizedStatus.StartsWith("FAILURE", StringComparison.Ordinal) || normalizedStatus.StartsWith("FAILED", StringComparison.Ordinal) => DownloadItemStatus.Failed, + _ => DownloadItemStatus.Queued + }; + + var contentPath = !string.IsNullOrEmpty(destDir) && !string.IsNullOrEmpty(title) + ? FileUtils.CombineWithOptionalBase(destDir, title) + : (destDir ?? string.Empty); + + var progress = sizeMb > 0 ? Math.Clamp((sizeMb - remainingMb) / sizeMb * 100, 0, 100) : 0; + + return new DownloadClientItem + { + DownloadId = id.ToUpperInvariant(), + Title = title ?? string.Empty, + Category = category ?? string.Empty, + Status = status, + TotalSize = sizeBytes, + RemainingSize = remainingBytes, + RemainingTime = remainingTime, + OutputPath = contentPath ?? string.Empty, + Message = statusRaw ?? "QUEUED", + Progress = progress, + DownloadSpeed = downloadRate, + CanBeRemoved = true, + CanMoveFiles = status == DownloadItemStatus.Completed, + DownloadClientInfo = DownloadClientItemClientInfo.FromClient( + clientId: client.Id, + clientName: client.Name, + clientType: "nzbget", + protocol: DownloadProtocol.Usenet, + removeCompletedDownloads: client.Settings?.TryGetValue("removeCompletedDownloads", out var removeVal) is true && + (removeVal is bool boolVal && boolVal), + hasPostImportCategory: !string.IsNullOrEmpty(client.Settings?.GetValueOrDefault("postImportCategory")?.ToString())) + }; + } + + public static QueueItem MapGroup(DownloadClientConfiguration client, XElement structElement) + { + var members = ReadMembers(structElement); + + var id = members.GetValueOrDefault("GroupID", null) + ?? members.GetValueOrDefault("LastID", null) + ?? Guid.NewGuid().ToString("N"); + + var title = members.GetValueOrDefault("NZBName", string.Empty); + var statusRaw = members.GetValueOrDefault("Status", string.Empty); + var category = members.GetValueOrDefault("Category", string.Empty); + var sizeMb = ParseDouble(members.GetValueOrDefault("FileSizeMB", "0")); + var remainingMb = ParseDouble(members.GetValueOrDefault("RemainingSizeMB", "0")); + var downloadedMb = sizeMb - remainingMb; + var downloadRate = ParseDouble(members.GetValueOrDefault("DownloadRate", "0")); + var destDir = members.GetValueOrDefault("DestDir", string.Empty); + + var sizeBytes = Convert.ToInt64(Math.Max(0, sizeMb) * 1024 * 1024); + var downloadedBytes = Convert.ToInt64(Math.Max(0, downloadedMb) * 1024 * 1024); + + int? etaSeconds = null; + if (downloadRate > 0 && remainingMb > 0) + { + var remainingBytes = remainingMb * 1024 * 1024; + etaSeconds = (int)Math.Max(0, remainingBytes / downloadRate); + } + + var normalizedStatus = (statusRaw ?? "QUEUED").ToUpperInvariant(); + string status = normalizedStatus switch + { + "QUEUED" => "queued", + "DOWNLOADING" => "downloading", + "PAUSED" => "paused", + "FETCHING" => "downloading", + "SCANNING" => "downloading", + "PP_QUEUED" => "downloading", + "PP_PROCESSING" => "downloading", + _ when normalizedStatus.StartsWith("SUCCESS", StringComparison.Ordinal) => "completed", + _ when normalizedStatus.StartsWith("FAILURE", StringComparison.Ordinal) || normalizedStatus.StartsWith("FAILED", StringComparison.Ordinal) => "failed", + _ => "queued" + }; + + var contentPath = !string.IsNullOrEmpty(destDir) && !string.IsNullOrEmpty(title) + ? FileUtils.CombineWithOptionalBase(destDir, title) + : destDir; + + return new QueueItem + { + Id = id, + Title = title ?? string.Empty, + Quality = category ?? string.Empty, + Status = status, + Progress = sizeMb > 0 ? Math.Clamp(downloadedMb / sizeMb * 100, 0, 100) : 0, + Size = sizeBytes, + Downloaded = downloadedBytes, + DownloadSpeed = downloadRate, + Eta = etaSeconds > 0 ? etaSeconds : null, + DownloadClient = client.Name ?? client.Id ?? "NZBGet", + DownloadClientId = client.Id ?? string.Empty, + DownloadClientType = "nzbget", + AddedAt = DateTime.UtcNow, + CanPause = status is "downloading" or "queued", + CanRemove = true, + RemotePath = destDir, + LocalPath = destDir, + ContentPath = contentPath + }; + } + + private static IReadOnlyDictionary ReadMembers(XElement structElement) + { + var members = new Dictionary(StringComparer.Ordinal); + foreach (var member in structElement.Elements("member")) + { + members[member.Element("name")?.Value ?? string.Empty] = + member.Element("value")?.Elements().FirstOrDefault()?.Value ?? string.Empty; + } + + return members; + } + + private static double ParseDouble(string? value) + { + return double.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var parsed) ? parsed : 0d; + } +} diff --git a/listenarr.infrastructure/Adapters/SabnzbdAdapter.cs b/listenarr.infrastructure/Adapters/SabnzbdAdapter.cs index be1eaccb8..adf9ea658 100644 --- a/listenarr.infrastructure/Adapters/SabnzbdAdapter.cs +++ b/listenarr.infrastructure/Adapters/SabnzbdAdapter.cs @@ -351,106 +351,27 @@ public async Task> GetQueueAsync(DownloadClientConfiguration cli if (!doc.RootElement.TryGetProperty("queue", out var queue)) return items; if (!queue.TryGetProperty("slots", out var slots) || slots.ValueKind != JsonValueKind.Array) return items; + var speed = 0.0; + if (queue.TryGetProperty("speed", out var speedProp)) + { + speed = SabnzbdResponseMapper.ParseSpeed(speedProp.GetString() ?? "0"); + } + foreach (var slot in slots.EnumerateArray()) { try { - var nzoId = slot.TryGetProperty("nzo_id", out var id) ? id.GetString() ?? "" : ""; - var filename = slot.TryGetProperty("filename", out var fn) ? fn.GetString() ?? "Unknown" : "Unknown"; - var status = slot.TryGetProperty("status", out var st) ? st.GetString() ?? "Unknown" : "Unknown"; - - double ParseNumericValue(JsonElement element) + var queueItem = SabnzbdResponseMapper.MapQueueSlotToQueueItem(client, slot, configuredCategory ?? string.Empty, speed); + if (queueItem != null) { - if (element.ValueKind == JsonValueKind.Number) - return element.GetDouble(); - if (element.ValueKind == JsonValueKind.String) - { - var str = element.GetString() ?? "0"; - if (double.TryParse(str, out var value)) - return value; - } - return 0; - } - - var sizeMB = slot.TryGetProperty("mb", out var mb) ? ParseNumericValue(mb) : 0; - var mbLeft = slot.TryGetProperty("mbleft", out var left) ? ParseNumericValue(left) : 0; - var downloadedMB = sizeMB - mbLeft; - var percentage = slot.TryGetProperty("percentage", out var pct) ? ParseNumericValue(pct) : 0; - - var timeLeft = slot.TryGetProperty("timeleft", out var time) ? time.GetString() ?? "0:00:00" : "0:00:00"; - var category = slot.TryGetProperty("cat", out var cat) ? cat.GetString() ?? "" : ""; - - if (!DownloadClientCategoryFilter.Matches(configuredCategory, category)) - { - continue; + items.Add(queueItem); } - - int etaSeconds = 0; - if (!string.IsNullOrEmpty(timeLeft) && timeLeft != "0:00:00") - { - etaSeconds = ParseSABnzbdTimeLeft(timeLeft); - } - - var sizeBytes = (long)(sizeMB * 1024 * 1024); - var downloadedBytes = (long)(downloadedMB * 1024 * 1024); - - var speed = 0.0; - if (queue.TryGetProperty("speed", out var speedProp)) - { - var speedStr = speedProp.GetString() ?? "0"; - speed = ParseSABnzbdSpeed(speedStr); - } - - var mappedStatus = status.ToLower() switch - { - "downloading" => "downloading", - "queued" => "queued", - "paused" => "paused", - "checking" => "downloading", - "extracting" => "downloading", - "moving" => "downloading", - "completed" => "completed", - "failed" => "failed", - _ => "queued" - }; - - var remotePath = client.DownloadPath ?? ""; - var localPath = remotePath; - - // For SABnzbd, construct ContentPath from download path + filename - var contentPath = !string.IsNullOrEmpty(remotePath) && !string.IsNullOrEmpty(filename) - ? CombineWithOptionalBase(remotePath, filename) - : remotePath; - var localContentPath = contentPath; - - items.Add(new QueueItem - { - Id = nzoId, - Title = filename, - Quality = category, - Status = mappedStatus, - Progress = percentage, - Size = sizeBytes, - Downloaded = downloadedBytes, - DownloadSpeed = speed, - Eta = etaSeconds > 0 ? etaSeconds : null, - DownloadClient = client.Name, - DownloadClientId = client.Id, - DownloadClientType = "sabnzbd", - AddedAt = DateTime.UtcNow, - CanPause = mappedStatus == "downloading" || mappedStatus == "queued", - CanRemove = true, - RemotePath = remotePath, - LocalPath = localPath, - ContentPath = localContentPath - }); } catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) { _logger.LogError(ex, "Error parsing SABnzbd queue item"); } } - _logger.LogInformation("Retrieved {Count} items from SABnzbd active queue", items.Count); // Also fetch completed items from SABnzbd history — SABnzbd moves finished @@ -475,65 +396,17 @@ double ParseNumericValue(JsonElement element) { try { - var nzoId = slot.TryGetProperty("nzo_id", out var hid) ? hid.GetString() ?? "" : ""; - if (string.IsNullOrEmpty(nzoId) || existingNzoIds.Contains(nzoId)) - continue; - - var histStatus = slot.TryGetProperty("status", out var hst) ? hst.GetString() ?? "" : ""; - var histName = slot.TryGetProperty("name", out var hn) ? hn.GetString() ?? "Unknown" : "Unknown"; - var histCategory = slot.TryGetProperty("category", out var hcat) ? hcat.GetString() ?? "" : ""; - var histBytes = slot.TryGetProperty("bytes", out var hb) && hb.TryGetInt64(out var hbl) ? hbl : 0L; - var storagePath = slot.TryGetProperty("storage", out var sp) ? sp.GetString() ?? "" : ""; - - if (!DownloadClientCategoryFilter.Matches(configuredCategory, histCategory)) - continue; - - var mappedStatus = histStatus.ToLower() switch + var historyItem = SabnzbdResponseMapper.MapHistorySlotToQueueItem(client, slot, configuredCategory ?? string.Empty, existingNzoIds); + if (historyItem != null) { - "completed" => "completed", - "failed" => "failed", - _ => "completed" - }; - - var remotePath = !string.IsNullOrEmpty(storagePath) ? storagePath : (client.DownloadPath ?? ""); - var localPath = remotePath; - - // Parse completed timestamp - DateTime? completedAt = null; - if (slot.TryGetProperty("completed", out var compEpoch) && compEpoch.TryGetInt64(out var epoch)) - { - completedAt = DateTimeOffset.FromUnixTimeSeconds(epoch).UtcDateTime; + items.Add(historyItem); } - - items.Add(new QueueItem - { - Id = nzoId, - Title = histName, - Quality = histCategory, - Status = mappedStatus, - Progress = mappedStatus == "completed" ? 100 : 0, - Size = histBytes, - Downloaded = histBytes, - DownloadSpeed = 0, - Eta = null, - DownloadClient = client.Name, - DownloadClientId = client.Id, - DownloadClientType = "sabnzbd", - AddedAt = completedAt ?? DateTime.UtcNow, - CompletionTime = completedAt, - CanPause = false, - CanRemove = true, - RemotePath = remotePath, - LocalPath = localPath, - ContentPath = localPath - }); } catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) { _logger.LogDebug(ex, "Error parsing SABnzbd history item"); } } - _logger.LogInformation("Retrieved {Count} total items from SABnzbd (queue + history)", items.Count); } } @@ -642,105 +515,24 @@ public async Task> GetItemsAsync(DownloadClientConfigur if (queue.TryGetProperty("speed", out var speedProp)) { var speedStr = speedProp.GetString() ?? "0"; - queueSpeed = ParseSABnzbdSpeed(speedStr); + queueSpeed = SabnzbdResponseMapper.ParseSpeed(speedStr); } foreach (var slot in slots.EnumerateArray()) { try { - var nzoId = slot.TryGetProperty("nzo_id", out var id) ? id.GetString() ?? "" : ""; - var filename = slot.TryGetProperty("filename", out var fn) ? fn.GetString() ?? "Unknown" : "Unknown"; - var status = slot.TryGetProperty("status", out var st) ? st.GetString() ?? "Unknown" : "Unknown"; - - double ParseNumericValue(JsonElement element) + var downloadClientItem = SabnzbdResponseMapper.MapQueueSlotToDownloadClientItem(client, slot, configuredCategory ?? string.Empty, queueSpeed); + if (downloadClientItem != null) { - if (element.ValueKind == JsonValueKind.Number) - return element.GetDouble(); - if (element.ValueKind == JsonValueKind.String) - { - var str = element.GetString() ?? "0"; - if (double.TryParse(str, out var value)) - return value; - } - return 0; - } - - var sizeMB = slot.TryGetProperty("mb", out var mb) ? ParseNumericValue(mb) : 0; - var mbLeft = slot.TryGetProperty("mbleft", out var left) ? ParseNumericValue(left) : 0; - var percentage = slot.TryGetProperty("percentage", out var pct) ? ParseNumericValue(pct) : 0; - - var timeLeft = slot.TryGetProperty("timeleft", out var time) ? time.GetString() ?? "0:00:00" : "0:00:00"; - var category = slot.TryGetProperty("cat", out var cat) ? cat.GetString() ?? "" : ""; - - if (!DownloadClientCategoryFilter.Matches(configuredCategory, category)) - { - continue; - } - - int etaSeconds = 0; - if (!string.IsNullOrEmpty(timeLeft) && timeLeft != "0:00:00") - { - etaSeconds = ParseSABnzbdTimeLeft(timeLeft); + items.Add(downloadClientItem); } - - var sizeBytes = (long)(sizeMB * 1024 * 1024); - var remainingBytes = (long)(mbLeft * 1024 * 1024); - - // Map SABnzbd status to DownloadItemStatus - var mappedStatus = status.ToLower() switch - { - "downloading" => DownloadItemStatus.Downloading, - "queued" => DownloadItemStatus.Queued, - "paused" => DownloadItemStatus.Paused, - "checking" => DownloadItemStatus.Downloading, - "extracting" => DownloadItemStatus.Downloading, - "moving" => DownloadItemStatus.Downloading, - "completed" => DownloadItemStatus.Completed, - "failed" => DownloadItemStatus.Failed, - _ => DownloadItemStatus.Queued - }; - - var remotePath = client.DownloadPath ?? ""; - var contentPath = !string.IsNullOrEmpty(remotePath) && !string.IsNullOrEmpty(filename) - ? CombineWithOptionalBase(remotePath, filename) - : remotePath; - var localContentPath = contentPath; - - TimeSpan? remainingTime = etaSeconds > 0 ? TimeSpan.FromSeconds(etaSeconds) : null; - - items.Add(new DownloadClientItem - { - DownloadId = nzoId.ToUpperInvariant(), // SABnzbd uses nzo_id as unique identifier - Title = filename, - Category = category, - Status = mappedStatus, - TotalSize = sizeBytes, - RemainingSize = remainingBytes, - RemainingTime = remainingTime, - OutputPath = localContentPath, - Message = status, - Progress = percentage, - DownloadSpeed = queueSpeed, // SABnzbd provides global speed - CanBeRemoved = true, - CanMoveFiles = mappedStatus == DownloadItemStatus.Completed, - DownloadClientInfo = DownloadClientItemClientInfo.FromClient( - clientId: client.Id, - clientName: client.Name, - clientType: "sabnzbd", - protocol: DownloadProtocol.Usenet, - removeCompletedDownloads: client.Settings?.TryGetValue("removeCompletedDownloads", out var removeVal) is true && - (removeVal is bool boolVal && boolVal), - hasPostImportCategory: !string.IsNullOrEmpty(client.Settings?.GetValueOrDefault("postImportCategory")?.ToString()) - ) - }); } catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) { _logger.LogError(ex, "Error parsing SABnzbd queue item"); } } - _logger.LogInformation("Retrieved {Count} items from SABnzbd queue", items.Count); } catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) @@ -852,68 +644,6 @@ public async Task GetImportItemAsync( } } - private int ParseSABnzbdTimeLeft(string timeLeft) - { - try - { - var totalSeconds = 0; - - if (timeLeft.Contains("day")) - { - var parts = timeLeft.Split(new[] { " day ", " days " }, StringSplitOptions.None); - if (parts.Length == 2 && int.TryParse(parts[0], out var days)) - { - totalSeconds += days * 86400; - timeLeft = parts[1]; - } - } - - var timeParts = timeLeft.Split(':'); - if (timeParts.Length == 3) - { - if (int.TryParse(timeParts[0], out var hours)) - totalSeconds += hours * 3600; - if (int.TryParse(timeParts[1], out var minutes)) - totalSeconds += minutes * 60; - if (int.TryParse(timeParts[2], out var seconds)) - totalSeconds += seconds; - } - - return totalSeconds; - } - catch (Exception caughtEx_1) when (caughtEx_1 is not OperationCanceledException && caughtEx_1 is not OutOfMemoryException && caughtEx_1 is not StackOverflowException) - { - return 0; - } - } - - private double ParseSABnzbdSpeed(string speedStr) - { - try - { - var parts = speedStr.Trim().Split(' '); - if (parts.Length != 2) - return 0; - - if (!double.TryParse(parts[0], out var value)) - return 0; - - var unit = parts[1].ToUpper(); - return unit switch - { - "B" => value, - "K" => value * 1024, - "M" => value * 1024 * 1024, - "G" => value * 1024 * 1024 * 1024, - _ => 0 - }; - } - catch (Exception caughtEx_2) when (caughtEx_2 is not OperationCanceledException && caughtEx_2 is not OutOfMemoryException && caughtEx_2 is not StackOverflowException) - { - return 0; - } - } - /// /// Resolves the actual import item for a completed download. /// Queries SABnzbd history for storage path. @@ -1012,32 +742,6 @@ public async Task GetImportItemAsync( } } - private static string CombineWithOptionalBase(string? basePath, string candidatePath) - { - var normalizedPath = candidatePath.Trim(); - - if (string.IsNullOrEmpty(normalizedPath)) - { - return normalizedPath; - } - - if (Path.IsPathRooted(normalizedPath) || string.IsNullOrWhiteSpace(basePath)) - { - return normalizedPath; - } - - var relativePath = normalizedPath.TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - if (Path.IsPathRooted(relativePath)) - { - return relativePath; - } - - var normalizedBasePath = basePath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - return string.IsNullOrEmpty(normalizedBasePath) - ? relativePath - : normalizedBasePath + Path.DirectorySeparatorChar + relativePath; - } - public async Task> FetchDownloadsAsync( DownloadClientConfiguration client, List downloads, diff --git a/listenarr.infrastructure/Adapters/SabnzbdResponseMapper.cs b/listenarr.infrastructure/Adapters/SabnzbdResponseMapper.cs new file mode 100644 index 000000000..4f214873d --- /dev/null +++ b/listenarr.infrastructure/Adapters/SabnzbdResponseMapper.cs @@ -0,0 +1,297 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +using System.Text.Json; +using Listenarr.Domain.Common; +using Listenarr.Domain.Models; + +namespace Listenarr.Infrastructure.Adapters; + +internal static class SabnzbdResponseMapper +{ + public static QueueItem? MapQueueSlotToQueueItem( + DownloadClientConfiguration client, + JsonElement slot, + string configuredCategory, + double speed) + { + var nzoId = GetString(slot, "nzo_id"); + var filename = GetString(slot, "filename", "Unknown"); + var status = GetString(slot, "status", "Unknown"); + var category = GetString(slot, "cat"); + + if (!DownloadClientCategoryFilter.Matches(configuredCategory, category)) + return null; + + var sizeMb = GetDouble(slot, "mb"); + var mbLeft = GetDouble(slot, "mbleft"); + var downloadedMb = sizeMb - mbLeft; + var percentage = GetDouble(slot, "percentage"); + var timeLeft = GetString(slot, "timeleft", "0:00:00"); + var etaSeconds = !string.IsNullOrEmpty(timeLeft) && timeLeft != "0:00:00" + ? ParseTimeLeft(timeLeft) + : 0; + + var mappedStatus = MapQueueStatus(status); + var remotePath = client.DownloadPath ?? ""; + var contentPath = !string.IsNullOrEmpty(remotePath) && !string.IsNullOrEmpty(filename) + ? FileUtils.CombineWithOptionalBase(remotePath, filename) + : remotePath; + + return new QueueItem + { + Id = nzoId, + Title = filename, + Quality = category, + Status = mappedStatus, + Progress = percentage, + Size = (long)(sizeMb * 1024 * 1024), + Downloaded = (long)(downloadedMb * 1024 * 1024), + DownloadSpeed = speed, + Eta = etaSeconds > 0 ? etaSeconds : null, + DownloadClient = client.Name, + DownloadClientId = client.Id, + DownloadClientType = "sabnzbd", + AddedAt = DateTime.UtcNow, + CanPause = mappedStatus == "downloading" || mappedStatus == "queued", + CanRemove = true, + RemotePath = remotePath, + LocalPath = remotePath, + ContentPath = contentPath + }; + } + + public static QueueItem? MapHistorySlotToQueueItem( + DownloadClientConfiguration client, + JsonElement slot, + string configuredCategory, + ISet existingNzoIds) + { + var nzoId = GetString(slot, "nzo_id"); + if (string.IsNullOrEmpty(nzoId) || existingNzoIds.Contains(nzoId)) + return null; + + var histCategory = GetString(slot, "category"); + if (!DownloadClientCategoryFilter.Matches(configuredCategory, histCategory)) + return null; + + var histStatus = GetString(slot, "status"); + var mappedStatus = histStatus.ToLowerInvariant() switch + { + "completed" => "completed", + "failed" => "failed", + _ => "completed" + }; + + var histBytes = slot.TryGetProperty("bytes", out var hb) && hb.TryGetInt64(out var hbl) ? hbl : 0L; + var storagePath = GetString(slot, "storage"); + var remotePath = !string.IsNullOrEmpty(storagePath) ? storagePath : (client.DownloadPath ?? ""); + DateTime? completedAt = null; + if (slot.TryGetProperty("completed", out var compEpoch) && compEpoch.TryGetInt64(out var epoch)) + { + completedAt = DateTimeOffset.FromUnixTimeSeconds(epoch).UtcDateTime; + } + + return new QueueItem + { + Id = nzoId, + Title = GetString(slot, "name", "Unknown"), + Quality = histCategory, + Status = mappedStatus, + Progress = mappedStatus == "completed" ? 100 : 0, + Size = histBytes, + Downloaded = histBytes, + DownloadSpeed = 0, + Eta = null, + DownloadClient = client.Name, + DownloadClientId = client.Id, + DownloadClientType = "sabnzbd", + AddedAt = completedAt ?? DateTime.UtcNow, + CompletionTime = completedAt, + CanPause = false, + CanRemove = true, + RemotePath = remotePath, + LocalPath = remotePath, + ContentPath = remotePath + }; + } + + public static DownloadClientItem? MapQueueSlotToDownloadClientItem( + DownloadClientConfiguration client, + JsonElement slot, + string configuredCategory, + double queueSpeed) + { + var category = GetString(slot, "cat"); + if (!DownloadClientCategoryFilter.Matches(configuredCategory, category)) + return null; + + var nzoId = GetString(slot, "nzo_id"); + var filename = GetString(slot, "filename", "Unknown"); + var status = GetString(slot, "status", "Unknown"); + var sizeMb = GetDouble(slot, "mb"); + var mbLeft = GetDouble(slot, "mbleft"); + var percentage = GetDouble(slot, "percentage"); + var timeLeft = GetString(slot, "timeleft", "0:00:00"); + var etaSeconds = !string.IsNullOrEmpty(timeLeft) && timeLeft != "0:00:00" + ? ParseTimeLeft(timeLeft) + : 0; + + var mappedStatus = MapDownloadItemStatus(status); + var remotePath = client.DownloadPath ?? ""; + var contentPath = !string.IsNullOrEmpty(remotePath) && !string.IsNullOrEmpty(filename) + ? FileUtils.CombineWithOptionalBase(remotePath, filename) + : remotePath; + + return new DownloadClientItem + { + DownloadId = nzoId.ToUpperInvariant(), + Title = filename, + Category = category, + Status = mappedStatus, + TotalSize = (long)(sizeMb * 1024 * 1024), + RemainingSize = (long)(mbLeft * 1024 * 1024), + RemainingTime = etaSeconds > 0 ? TimeSpan.FromSeconds(etaSeconds) : null, + OutputPath = contentPath, + Message = status, + Progress = percentage, + DownloadSpeed = queueSpeed, + CanBeRemoved = true, + CanMoveFiles = mappedStatus == DownloadItemStatus.Completed, + DownloadClientInfo = DownloadClientItemClientInfo.FromClient( + clientId: client.Id, + clientName: client.Name, + clientType: "sabnzbd", + protocol: DownloadProtocol.Usenet, + removeCompletedDownloads: client.Settings?.TryGetValue("removeCompletedDownloads", out var removeVal) is true && + (removeVal is bool boolVal && boolVal), + hasPostImportCategory: !string.IsNullOrEmpty(client.Settings?.GetValueOrDefault("postImportCategory")?.ToString())) + }; + } + + public static double ParseSpeed(string speedStr) + { + if (string.IsNullOrWhiteSpace(speedStr)) return 0; + + speedStr = speedStr.Trim().ToLowerInvariant(); + var parts = speedStr.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length == 0) return 0; + + if (!double.TryParse(parts[0], out var value)) return 0; + + if (parts.Length > 1) + { + var unit = parts[1]; + if (unit.StartsWith("k")) return value * 1024; + if (unit.StartsWith("m")) return value * 1024 * 1024; + if (unit.StartsWith("g")) return value * 1024 * 1024 * 1024; + } + + return value; + } + + private static int ParseTimeLeft(string timeLeft) + { + if (string.IsNullOrWhiteSpace(timeLeft)) return 0; + + var totalSeconds = 0; + if (timeLeft.Contains("day", StringComparison.OrdinalIgnoreCase)) + { + var partsWithDays = timeLeft.Split(new[] { " day ", " days " }, StringSplitOptions.None); + if (partsWithDays.Length == 2 && int.TryParse(partsWithDays[0], out var days)) + { + totalSeconds += days * 86400; + timeLeft = partsWithDays[1]; + } + } + + var parts = timeLeft.Split(':'); + if (parts.Length == 3) + { + if (int.TryParse(parts[0], out var hours) && + int.TryParse(parts[1], out var minutes) && + int.TryParse(parts[2], out var seconds)) + { + return totalSeconds + hours * 3600 + minutes * 60 + seconds; + } + } + else if (parts.Length == 2) + { + if (int.TryParse(parts[0], out var minutes) && + int.TryParse(parts[1], out var seconds)) + { + return totalSeconds + minutes * 60 + seconds; + } + } + + return totalSeconds; + } + + private static string MapQueueStatus(string status) + { + return status.ToLowerInvariant() switch + { + "downloading" => "downloading", + "queued" => "queued", + "paused" => "paused", + "checking" => "downloading", + "extracting" => "downloading", + "moving" => "downloading", + "completed" => "completed", + "failed" => "failed", + _ => "queued" + }; + } + + private static DownloadItemStatus MapDownloadItemStatus(string status) + { + return status.ToLowerInvariant() switch + { + "downloading" => DownloadItemStatus.Downloading, + "queued" => DownloadItemStatus.Queued, + "paused" => DownloadItemStatus.Paused, + "checking" => DownloadItemStatus.Downloading, + "extracting" => DownloadItemStatus.Downloading, + "moving" => DownloadItemStatus.Downloading, + "completed" => DownloadItemStatus.Completed, + "failed" => DownloadItemStatus.Failed, + _ => DownloadItemStatus.Queued + }; + } + + private static string GetString(JsonElement element, string propertyName, string defaultValue = "") + { + return element.TryGetProperty(propertyName, out var property) + ? property.GetString() ?? defaultValue + : defaultValue; + } + + private static double GetDouble(JsonElement element, string propertyName) + { + if (!element.TryGetProperty(propertyName, out var property)) + return 0; + + if (property.ValueKind == JsonValueKind.Number) + return property.GetDouble(); + + if (property.ValueKind == JsonValueKind.String && double.TryParse(property.GetString() ?? "0", out var value)) + return value; + + return 0; + } +} diff --git a/listenarr.infrastructure/Search/Providers/MyAnonamouseSearchProvider.cs b/listenarr.infrastructure/Search/Providers/MyAnonamouseSearchProvider.cs index e8ecdda24..d60798859 100644 --- a/listenarr.infrastructure/Search/Providers/MyAnonamouseSearchProvider.cs +++ b/listenarr.infrastructure/Search/Providers/MyAnonamouseSearchProvider.cs @@ -219,7 +219,7 @@ public async Task> SearchAsync(Indexer indexer, string var jsonResponse = await response.Content.ReadAsStringAsync(); _logger.LogDebug("MyAnonamouse raw response: {Response}", jsonResponse); - results = ParseMyAnonamouseResponse(jsonResponse, indexer); + results = MyAnonamouseResponseParser.Parse(jsonResponse, indexer, _logger); // Optional per-result enrichment: fetch individual item pages to populate missing fields try @@ -248,528 +248,6 @@ public async Task> SearchAsync(Indexer indexer, string } } - private List ParseMyAnonamouseResponse(string jsonResponse, Indexer indexer) - { - var results = new List(); - - if (indexer == null) - { - _logger.LogError("ParseMyAnonamouseResponse called with null indexer"); - return results; - } - - try - { - _logger.LogDebug("Parsing MyAnonamouse response, length: {Length}", jsonResponse.Length); - - JsonDocument? doc = null; - JsonElement dataArrayElement = default; - - // Try to parse JSON directly. If that fails, try to extract the first JSON array substring. - try - { - doc = JsonDocument.Parse(jsonResponse); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - // Attempt to extract a JSON array from an HTML-wrapped response or stray text - var start = jsonResponse.IndexOf('['); - var end = jsonResponse.LastIndexOf(']'); - if (start >= 0 && end > start) - { - var sub = jsonResponse.Substring(start, end - start + 1); - try - { - doc = JsonDocument.Parse(sub); - } - catch (Exception parseEx) when (parseEx is not OperationCanceledException && parseEx is not OutOfMemoryException && parseEx is not StackOverflowException) - { - _logger.LogWarning(parseEx, "Failed to parse extracted JSON array from MyAnonamouse response"); - return results; - } - } - else - { - _logger.LogWarning("Unable to locate JSON array in MyAnonamouse response"); - return results; - } - } - - var root = doc!.RootElement; - - // Support multiple response shapes: - // 1) Root is an array of items - // 2) Root is an object with property "data" containing array - // 3) Root is an object with property "parsed" or "results" or "items" - if (root.ValueKind == JsonValueKind.Array) - { - dataArrayElement = root; - } - else if (root.ValueKind == JsonValueKind.Object) - { - if (root.TryGetProperty("data", out var tmp) && tmp.ValueKind == JsonValueKind.Array) - { - dataArrayElement = tmp; - } - else if (root.TryGetProperty("parsed", out tmp) && tmp.ValueKind == JsonValueKind.Array) - { - dataArrayElement = tmp; - } - else if (root.TryGetProperty("results", out tmp) && tmp.ValueKind == JsonValueKind.Array) - { - dataArrayElement = tmp; - } - else if (root.TryGetProperty("items", out tmp) && tmp.ValueKind == JsonValueKind.Array) - { - dataArrayElement = tmp; - } - else - { - // As a last resort, try to find the first array value anywhere in the object - foreach (var prop in root.EnumerateObject().Where(prop => prop.Value.ValueKind == JsonValueKind.Array)) - { - dataArrayElement = prop.Value; - break; - } - - if (dataArrayElement.ValueKind == JsonValueKind.Undefined) - { - _logger.LogWarning("MyAnonamouse response did not contain an expected array property"); - return results; - } - } - } - else - { - _logger.LogWarning("Unexpected MyAnonamouse root JSON kind: {Kind}", root.ValueKind); - return results; - } - - _logger.LogDebug("Found {Count} MyAnonamouse results", dataArrayElement.GetArrayLength()); - - int _mamDebugIndex = 0; - foreach (var item in dataArrayElement.EnumerateArray()) - { - try - { - // Log property names for first few items to aid debugging - if (_mamDebugIndex < 3) - { - try - { - var propertyNames = item.EnumerateObject().Select(p => p.Name).ToList(); - _logger.LogInformation("MyAnonamouse result #{Index} has properties: {Properties}", _mamDebugIndex, string.Join(", ", propertyNames)); - } - catch (Exception exNames) when (exNames is not OperationCanceledException && exNames is not OutOfMemoryException && exNames is not StackOverflowException) - { - _logger.LogDebug(exNames, "Failed to enumerate property names for MyAnonamouse result #{Index}", _mamDebugIndex); - } - } - - var id = item.TryGetProperty("id", out var idElem) - ? (idElem.ValueKind == JsonValueKind.String ? idElem.GetString() ?? string.Empty : idElem.ToString()) - : string.Empty; - - // MyAnonamouse uses "title" in responses; fall back to "name" if needed - var title = ""; - if (item.TryGetProperty("title", out var titleElem)) - { - title = titleElem.ValueKind == JsonValueKind.String ? titleElem.GetString() ?? "" : titleElem.ToString(); - } - else if (item.TryGetProperty("name", out titleElem)) - { - title = titleElem.ValueKind == JsonValueKind.String ? titleElem.GetString() ?? "" : titleElem.ToString(); - } - - var sizeStr = ""; - if (item.TryGetProperty("size", out var sizeElem)) - { - if (sizeElem.ValueKind == JsonValueKind.String) - { - sizeStr = sizeElem.GetString() ?? "0"; - } - else if (sizeElem.ValueKind == JsonValueKind.Number) - { - sizeStr = sizeElem.GetInt64().ToString(); - } - else - { - sizeStr = "0"; - } - } - - var seeders = item.TryGetProperty("seeders", out var seedElem) ? seedElem.GetInt32() : 0; - var leechers = item.TryGetProperty("leechers", out var leechElem) ? leechElem.GetInt32() : 0; - - string dlHash = string.Empty; - if (item.TryGetProperty("dl", out var dlElem)) - { - dlHash = dlElem.ValueKind == JsonValueKind.String ? dlElem.GetString() ?? string.Empty : dlElem.ToString(); - } - - // Explicit downloadUrl / infoUrl / fileName fields - string? downloadUrlField = null; - string? infoUrlField = null; - string? fileNameField = null; - foreach (var prop in item.EnumerateObject()) - { - var name = prop.Name; - if (downloadUrlField == null && string.Equals(name, "downloadUrl", StringComparison.OrdinalIgnoreCase) && prop.Value.ValueKind == JsonValueKind.String) - downloadUrlField = prop.Value.GetString(); - if (infoUrlField == null && string.Equals(name, "infoUrl", StringComparison.OrdinalIgnoreCase) && prop.Value.ValueKind == JsonValueKind.String) - infoUrlField = prop.Value.GetString(); - if (fileNameField == null && string.Equals(name, "fileName", StringComparison.OrdinalIgnoreCase) && prop.Value.ValueKind == JsonValueKind.String) - fileNameField = prop.Value.GetString(); - } - - if (string.IsNullOrWhiteSpace(infoUrlField) && - item.TryGetProperty("guid", out var guidElem) && - guidElem.ValueKind == JsonValueKind.String) - { - infoUrlField = guidElem.GetString(); - } - - if (string.IsNullOrWhiteSpace(id) && - !string.IsNullOrWhiteSpace(infoUrlField)) - { - var idMatch = Regex.Match(infoUrlField, @"/t/(\d+)", RegexOptions.IgnoreCase); - if (idMatch.Success) - { - id = idMatch.Groups[1].Value; - } - } - - if (string.IsNullOrWhiteSpace(id)) - { - id = Guid.NewGuid().ToString(); - } - - var torrentId = id; - - // Debug logging for first result - if (_mamDebugIndex == 0) - { - _logger.LogInformation("MyAnonamouse first result - Title: '{Title}', Size: '{Size}', Seeders: {Seeders}, DlHash: '{DlHash}', TorrentId: '{TorrentId}'", - title, sizeStr, seeders, dlHash, torrentId); - } - - string category = string.Empty; - if (item.TryGetProperty("catname", out var catElem)) - { - category = catElem.ValueKind == JsonValueKind.String ? catElem.GetString() ?? string.Empty : catElem.ToString(); - } - - string tags = string.Empty; - if (item.TryGetProperty("tags", out var tagsElem)) - { - tags = tagsElem.ValueKind == JsonValueKind.String ? tagsElem.GetString() ?? string.Empty : tagsElem.ToString(); - } - - string description = string.Empty; - if (item.TryGetProperty("description", out var descElem)) - { - description = descElem.ValueKind == JsonValueKind.String ? descElem.GetString() ?? string.Empty : descElem.ToString(); - } - - // Parse grabs/files with multiple field name variations - int grabs = 0; - var grabKeys = new[] { "grabs", "snatches", "snatched", "snatched_count", "snatches_count", "numgrabs", "num_grabs", "grab_count", "times_completed", "time_completed", "downloaded", "times_downloaded", "completed" }; - foreach (var gEl in grabKeys.Where(key => item.TryGetProperty(key, out _)).Select(key => item.GetProperty(key))) - { - if (gEl.ValueKind == JsonValueKind.Number) - { - grabs = gEl.GetInt32(); - break; - } - else if (gEl.ValueKind == JsonValueKind.String && int.TryParse(gEl.GetString(), out var gtmp)) - { - grabs = gtmp; - break; - } - } - - int files = 0; - if (item.TryGetProperty("files", out var filesElem)) - { - if (filesElem.ValueKind == JsonValueKind.Number) - { - files = filesElem.GetInt32(); - } - else if (filesElem.ValueKind == JsonValueKind.String && int.TryParse(filesElem.GetString(), out var ftmp)) - { - files = ftmp; - } - } - - // Parse PublishDate with multiple field names and formats - DateTime? publishDate = null; - var dateKeys = new[] { "added", "publishDate", "pubDate", "published", "date", "created", "created_at", "upload_date" }; - foreach (var dateElem in dateKeys.Where(key => item.TryGetProperty(key, out _)).Select(key => item.GetProperty(key))) - { - if (dateElem.ValueKind == JsonValueKind.String) - { - var dateStr = dateElem.GetString(); - if (!string.IsNullOrEmpty(dateStr) && DateTime.TryParse(dateStr, out var dt)) - { - publishDate = dt; - break; - } - } - else if (dateElem.ValueKind == JsonValueKind.Number) - { - var timestamp = dateElem.GetInt64(); - publishDate = DateTimeOffset.FromUnixTimeSeconds(timestamp).DateTime; - break; - } - } - - // Parse size - long size = 0; - // Try parsing sizeStr as human-readable format first (e.g., "3.7 GiB") - if (!string.IsNullOrEmpty(sizeStr)) - { - size = ExtractSizeFromDescription(sizeStr); - if (size > 0) - { - _logger.LogDebug("Parsed size for MyAnonamouse result '{Title}': {Size} bytes from size field '{SizeStr}'", title, size, sizeStr); - } - // Fallback: try parsing as plain number (bytes) - else if (long.TryParse(sizeStr, out var parsedSize)) - { - size = parsedSize; - _logger.LogDebug("Parsed size for MyAnonamouse result '{Title}': {Size} bytes (numeric) from size field '{SizeStr}'", title, size, sizeStr); - } - } - - // If still no size, try to extract from description - if (size == 0) - { - size = ExtractSizeFromDescription(description); - if (size > 0) - { - _logger.LogDebug("Parsed size for MyAnonamouse result '{Title}': {Size} bytes from description", title, size); - } - else - { - _logger.LogWarning("MyAnonamouse result '{Title}' has no size information", title); - } - } - - // Extract author from author_info JSON - string? author = null; - if (item.TryGetProperty("author_info", out var authorInfo)) - { - var authorJson = authorInfo.GetString(); - if (!string.IsNullOrEmpty(authorJson)) - { - try - { - var authorDoc = JsonDocument.Parse(authorJson); - var authors = new List(); - foreach (var prop in authorDoc.RootElement.EnumerateObject()) - { - authors.Add(prop.Value.GetString() ?? ""); - } - author = string.Join(", ", authors.Where(a => !string.IsNullOrEmpty(a))); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Failed to parse author JSON for search result"); - } - } - } - - // Extract narrator from narrator_info JSON - string? narrator = null; - if (item.TryGetProperty("narrator_info", out var narratorInfo)) - { - var narratorJson = narratorInfo.GetString(); - if (!string.IsNullOrEmpty(narratorJson)) - { - try - { - var narratorDoc = JsonDocument.Parse(narratorJson); - var narrators = new List(); - foreach (var prop in narratorDoc.RootElement.EnumerateObject()) - { - narrators.Add(prop.Value.GetString() ?? ""); - } - narrator = string.Join(", ", narrators.Where(n => !string.IsNullOrEmpty(n))); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Failed to parse narrator JSON for search result"); - } - } - } - - // Detect quality and format - var rawFormatField = item.EnumerateObject() - .Where(prop => prop.Value.ValueKind == JsonValueKind.String) - .Where(prop => string.Equals(prop.Name, "format", StringComparison.OrdinalIgnoreCase) || string.Equals(prop.Name, "filetype", StringComparison.OrdinalIgnoreCase)) - .Select(prop => prop.Value.GetString() ?? string.Empty) - .FirstOrDefault() ?? string.Empty; - - var formatFromTags = DetectFormatFromTags(tags ?? ""); - var formatFromField = !string.IsNullOrEmpty(rawFormatField) ? DetectFormatFromTags(rawFormatField) : null; - var finalFormat = (formatFromField != null && formatFromField != "MP3") ? formatFromField : formatFromTags; - - var qualityFromTags = DetectQualityFromTags(tags ?? ""); - var qualityFromFormat = !string.IsNullOrEmpty(rawFormatField) ? DetectQualityFromFormat(rawFormatField) : "Unknown"; - - // Prefer bitrate from tags over format-based quality - var finalQuality = qualityFromTags != "Unknown" ? qualityFromTags : qualityFromFormat; - - // Fallback quality detection - if (finalQuality == "Unknown" || finalQuality == "Variable") - { - if (!string.IsNullOrEmpty(description)) - { - var q = DetectQualityFromTags(description); - if (q != "Unknown") finalQuality = q; - } - - if (finalQuality == "Unknown" || finalQuality == "Variable") - { - var q = DetectQualityFromTags(title ?? string.Empty); - if (q != "Unknown") finalQuality = q; - } - } - - // Build download URL - var downloadUrl = ""; - - // First priority: use explicit downloadUrl field if provided - if (!string.IsNullOrEmpty(downloadUrlField)) - { - downloadUrl = downloadUrlField; - _logger.LogDebug("Using explicit downloadUrl field for '{Title}': {Url}", title, downloadUrl); - } - // Second priority: build from dlHash - else if (!string.IsNullOrEmpty(dlHash)) - { - var baseUrl = indexer.Url.TrimEnd('/'); - downloadUrl = $"{baseUrl}/tor/download.php/{dlHash}"; - var mamIdLocal = MyAnonamouseHelper.TryGetMamId(indexer.AdditionalSettings); - if (!string.IsNullOrEmpty(mamIdLocal)) - { - try - { - mamIdLocal = Uri.UnescapeDataString(mamIdLocal); - } - catch (Exception caughtEx_1) when (caughtEx_1 is not OperationCanceledException && caughtEx_1 is not OutOfMemoryException && caughtEx_1 is not StackOverflowException) - { - System.Diagnostics.Debug.WriteLine("Suppressed non-fatal exception in catch block."); - } - downloadUrl += $"?mam_id={Uri.EscapeDataString(mamIdLocal)}"; - } - _logger.LogDebug("Built downloadUrl from dlHash for '{Title}': {Url}", title, downloadUrl); - } - // Third priority: build from torrent ID (MAM Direct API pattern) - else if (!string.IsNullOrEmpty(torrentId)) - { - var baseUrl = indexer.Url.TrimEnd('/'); - var mamIdLocal = MyAnonamouseHelper.TryGetMamId(indexer.AdditionalSettings); - if (!string.IsNullOrEmpty(mamIdLocal)) - { - try - { - mamIdLocal = Uri.UnescapeDataString(mamIdLocal); - } - catch (Exception caughtEx_2) when (caughtEx_2 is not OperationCanceledException && caughtEx_2 is not OutOfMemoryException && caughtEx_2 is not StackOverflowException) - { - System.Diagnostics.Debug.WriteLine("Suppressed non-fatal exception in catch block."); - } - downloadUrl = $"{baseUrl}/tor/download.php?tid={torrentId}&mam_id={Uri.EscapeDataString(mamIdLocal)}"; - } - else - { - downloadUrl = $"{baseUrl}/tor/download.php?tid={torrentId}"; - } - _logger.LogDebug("Built downloadUrl from torrent ID for '{Title}': {Url}", title, downloadUrl); - } - else - { - _logger.LogWarning("No download URL available for MyAnonamouse result '{Title}' - missing downloadUrl field, dlHash, and torrent ID", title); - } - - _mamDebugIndex++; - - // Language parsing - var rawLangCode = item.EnumerateObject() - .Where(prop => prop.Value.ValueKind == JsonValueKind.String) - .Where(prop => prop.Name.Equals("lang_code", StringComparison.OrdinalIgnoreCase) || - prop.Name.Equals("language_code", StringComparison.OrdinalIgnoreCase) || - prop.Name.Equals("lang", StringComparison.OrdinalIgnoreCase) || - prop.Name.Equals("language", StringComparison.OrdinalIgnoreCase)) - .Select(prop => prop.Value.GetString() ?? string.Empty) - .FirstOrDefault() ?? string.Empty; - string? language = null; - - if (!string.IsNullOrEmpty(rawLangCode)) - { - language = ParseLanguageFromCode(rawLangCode); - } - - var result = new IndexerSearchResult - { - Id = id, - Title = title ?? "Unknown", - Artist = author ?? "Unknown Author", - Album = narrator != null ? $"Narrated by {narrator}" : "Unknown", - Category = category ?? "Audiobook", - Size = size, - Seeders = seeders > 0 ? seeders : null, - Leechers = leechers > 0 ? leechers : null, - Source = indexer.Name, - PublishedDate = publishDate?.ToString("o") ?? string.Empty, - Quality = finalQuality, - Format = finalFormat, - TorrentUrl = downloadUrl, - ResultUrl = !string.IsNullOrWhiteSpace(infoUrlField) ? infoUrlField : $"https://myanonamouse.net/t/{Uri.EscapeDataString(id)}", - MagnetLink = "", - NzbUrl = "", - DownloadType = "Torrent", - IndexerId = indexer.Id, - IndexerImplementation = indexer.Implementation ?? string.Empty, - Grabs = grabs, - Files = files, - Language = language ?? string.Empty, - TorrentFileName = fileNameField ?? string.Empty - }; - - // VIP marker - if (item.TryGetProperty("vip", out var vipElem) && - (vipElem.ValueKind == JsonValueKind.True || (vipElem.ValueKind == JsonValueKind.String && string.Equals(vipElem.GetString(), "true", StringComparison.OrdinalIgnoreCase)))) - { - result.Title ??= string.Empty; - if (!result.Title.EndsWith(" [VIP]")) result.Title = result.Title + " [VIP]"; - } - - // Log critical fields for debugging - if (_mamDebugIndex < 3) - { - _logger.LogInformation("MAM Result #{Index}: Title='{Title}', Size={Size} bytes, Seeders={Seeders}, TorrentUrl='{TorrentUrl}', DownloadType='{DownloadType}'", - _mamDebugIndex, result.Title, result.Size, result.Seeders, result.TorrentUrl, result.DownloadType); - } - - results.Add(result); - _mamDebugIndex++; - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Failed to parse MyAnonamouse result item"); - } - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogError(ex, "Failed to parse MyAnonamouse response"); - } - - return results; - } - private async Task EnrichMyAnonamouseResultsAsync(Indexer indexer, List results, int topN, string? mamId, HttpClient httpClient) { if (results == null || results.Count == 0) return; @@ -857,7 +335,7 @@ private async Task EnrichMyAnonamouseResultsAsync(Indexer indexer, List 0) r.Grabs = grabs; if (files > 0) r.Files = files; if (!string.IsNullOrEmpty(format) && string.IsNullOrEmpty(r.Format)) r.Format = format.ToUpper(); - if (!string.IsNullOrEmpty(langCode) && string.IsNullOrEmpty(r.Language)) r.Language = ParseLanguageFromCode(langCode); + if (!string.IsNullOrEmpty(langCode) && string.IsNullOrEmpty(r.Language)) r.Language = SearchResultAttributeParser.ParseLanguageFromCode(langCode); _logger.LogDebug("Enriched MyAnonamouse result {Id}: grabs={Grabs}, files={Files}, format={Format}, language={Language}", r.Id, r.Grabs, r.Files, r.Format, r.Language); } @@ -911,118 +389,5 @@ private static (string? title, string? author) ParseTitleAuthorFromQuery(string return (null, null); } - private long ExtractSizeFromDescription(string description) - { - if (string.IsNullOrEmpty(description)) return 0; - - // Support both binary (GiB, MiB, TiB, KiB) and decimal (GB, MB, TB, KB) units - var match = Regex.Match(description, @"(\d+(?:\.\d+)?)\s*([KMGT]i?B)", RegexOptions.IgnoreCase); - if (match.Success && double.TryParse(match.Groups[1].Value, out var value)) - { - var unit = match.Groups[2].Value.ToUpperInvariant(); - return unit switch - { - "TIB" => (long)(value * 1024 * 1024 * 1024 * 1024), - "TB" => (long)(value * 1024 * 1024 * 1024 * 1024), - "GIB" => (long)(value * 1024 * 1024 * 1024), - "GB" => (long)(value * 1024 * 1024 * 1024), - "MIB" => (long)(value * 1024 * 1024), - "MB" => (long)(value * 1024 * 1024), - "KIB" => (long)(value * 1024), - "KB" => (long)(value * 1024), - _ => 0 - }; - } - return 0; - } - - private string DetectFormatFromTags(string tags) - { - if (string.IsNullOrEmpty(tags)) return "MP3"; - - var upper = tags.ToUpperInvariant(); - if (upper.Contains("FLAC")) return "FLAC"; - if (upper.Contains("M4B")) return "M4B"; - if (upper.Contains("M4A")) return "M4A"; - if (upper.Contains("AAC")) return "AAC"; - if (upper.Contains("OGG")) return "OGG"; - if (upper.Contains("OPUS")) return "OPUS"; - if (upper.Contains("WMA")) return "WMA"; - if (upper.Contains("MP3")) return "MP3"; - - return "MP3"; - } - - private string DetectQualityFromTags(string tags) - { - if (string.IsNullOrEmpty(tags)) return "Unknown"; - - var match = Regex.Match(tags, @"(\d+)\s*kbps", RegexOptions.IgnoreCase); - if (match.Success) - { - return $"{match.Groups[1].Value} kbps"; - } - - return "Unknown"; - } - - private string DetectQualityFromFormat(string format) - { - if (string.IsNullOrEmpty(format)) return "Unknown"; - - var upper = format.ToUpperInvariant(); - - // Try to extract bitrate from format string first (e.g., "M4B 64kbps", "MP3 128kbps") - var bitrateMatch = Regex.Match(format, @"(\d+)\s*kbps", RegexOptions.IgnoreCase); - if (bitrateMatch.Success) - { - return $"{bitrateMatch.Groups[1].Value} kbps"; - } - - // Check for lossless formats - if (upper.Contains("FLAC")) return "Lossless"; - - // For variable bitrate formats, try to indicate the format at least - if (upper.Contains("M4B")) return "M4B"; - if (upper.Contains("M4A")) return "M4A"; - if (upper.Contains("AAC")) return "AAC"; - if (upper.Contains("MP3")) return "MP3"; - - return "Unknown"; - } - - private string? ParseLanguageFromCode(string code) - { - if (string.IsNullOrEmpty(code)) return null; - - var upper = code.ToUpperInvariant(); - return upper switch - { - "ARA" or "AR" => "Arabic", - "CHI" or "ZHO" or "ZH" => "Chinese", - "CZE" or "CES" or "CS" => "Czech", - "DAN" or "DA" => "Danish", - "DUT" or "NLD" or "NL" => "Dutch", - "ENG" or "EN" => "English", - "FIN" or "FI" => "Finnish", - "FRE" or "FRA" or "FR" => "French", - "GER" or "DEU" or "DE" => "German", - "GRE" or "ELL" or "EL" => "Greek", - "HEB" or "HE" or "IW" => "Hebrew", - "HIN" or "HI" => "Hindi", - "HUN" or "HU" => "Hungarian", - "ITA" or "IT" => "Italian", - "JPN" or "JA" => "Japanese", - "KOR" or "KO" => "Korean", - "NOR" or "NOB" or "NNO" or "NO" => "Norwegian", - "POL" or "PL" => "Polish", - "POR" or "PT" => "Portuguese", - "RUS" or "RU" => "Russian", - "SPA" or "ES" => "Spanish", - "SWE" or "SV" => "Swedish", - "TUR" or "TR" => "Turkish", - _ => null - }; - } } } diff --git a/tests/Builders/ServiceCollectionBuilder.cs b/tests/Builders/ServiceCollectionBuilder.cs index 50689d6ac..7aaf9b7ea 100644 --- a/tests/Builders/ServiceCollectionBuilder.cs +++ b/tests/Builders/ServiceCollectionBuilder.cs @@ -200,6 +200,8 @@ private ServiceCollection BuildServices() services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); From ed8df8c25d8283bf8ef9f81b410c7690ab2048b7 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Sat, 13 Jun 2026 07:55:37 -0400 Subject: [PATCH 21/84] Extract remaining backend workflow seams --- .../Controllers/IndexerTestWorkflow.cs | 203 +++++++++++ .../Controllers/IndexersController.cs | 135 ++------ .../Controllers/ProwlarrCompatController.cs | 315 ++---------------- .../ProwlarrIndexerPayloadReader.cs | 225 +++++++++++++ listenarr.api/Program.cs | 2 + .../Search/AudibleAuthorPageCollector.cs | 83 +++++ listenarr.application/Search/SearchService.cs | 88 +---- tests/Builders/ServiceCollectionBuilder.cs | 2 + 8 files changed, 580 insertions(+), 473 deletions(-) create mode 100644 listenarr.api/Controllers/IndexerTestWorkflow.cs create mode 100644 listenarr.api/Controllers/ProwlarrIndexerPayloadReader.cs create mode 100644 listenarr.application/Search/AudibleAuthorPageCollector.cs diff --git a/listenarr.api/Controllers/IndexerTestWorkflow.cs b/listenarr.api/Controllers/IndexerTestWorkflow.cs new file mode 100644 index 000000000..f1a2e49f7 --- /dev/null +++ b/listenarr.api/Controllers/IndexerTestWorkflow.cs @@ -0,0 +1,203 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +using System.Net; +using System.Text.Json; +using Listenarr.Application.Interfaces.Repositories; +using Listenarr.Application.Security; +using Listenarr.Domain.Models; + +namespace Listenarr.Api.Controllers +{ + public sealed class IndexerTestWorkflow + { + private readonly IIndexerRepository _indexerRepository; + private readonly HttpClient _httpClientNoRedirect; + private readonly ILogger _logger; + + public IndexerTestWorkflow( + IIndexerRepository indexerRepository, + HttpClient httpClient, + ILogger logger) + { + _indexerRepository = indexerRepository; + _httpClientNoRedirect = httpClient; + _logger = logger; + } + + public async Task TestGenericIndexerAsync(Indexer indexer, bool persist) + { + try + { + var target = indexer.Url?.TrimEnd('/') ?? string.Empty; + var testUrl = target.EndsWith("/api", StringComparison.OrdinalIgnoreCase) ? target : target + "/api"; + + var implName = (indexer.Implementation ?? string.Empty).Trim().ToLowerInvariant(); + var isNewznabStyle = implName == "newznab" || implName == "torznab"; + + if (isNewznabStyle) + { + if (string.IsNullOrWhiteSpace(indexer.ApiKey)) + { + await SaveTestResultAsync(indexer, persist, false, "API key is required for Newznab/Torznab indexers"); + return IndexerTestWorkflowResult.Failure("API key is required for Newznab/Torznab indexers"); + } + + var separator = testUrl.Contains('?') ? '&' : '?'; + testUrl = testUrl + separator + "t=search&limit=1&offset=0"; + testUrl = testUrl + "&apikey=" + WebUtility.UrlEncode(indexer.ApiKey); + } + + var version = typeof(IndexerTestWorkflow).Assembly.GetName().Version?.ToString() ?? "0.0.0"; + var userAgent = $"Listenarr/{version} (+https://github.com/listenarrs/listenarr)"; + + _logger.LogInformation("[IndexerTest] GET {Url} UA={UserAgent}", LogRedaction.SanitizeUrl(testUrl), LogRedaction.SanitizeText(userAgent)); + + var blockedReason = ValidateOutboundUrl(testUrl); + if (!string.IsNullOrWhiteSpace(blockedReason)) + { + await SaveTestResultAsync(indexer, persist, false, $"Blocked outbound target: {blockedReason}"); + return IndexerTestWorkflowResult.Failure($"Blocked outbound target: {blockedReason}"); + } + + using var response = await SendValidatedAsync(currentUri => + { + var retryRequest = new HttpRequestMessage(HttpMethod.Get, currentUri); + retryRequest.Headers.UserAgent.ParseAdd(userAgent); + if (!string.IsNullOrEmpty(indexer.ApiKey)) + { + retryRequest.Headers.Add("X-Api-Key", indexer.ApiKey); + } + + return retryRequest; + }, testUrl); + + _logger.LogInformation("[IndexerTest] {Name} responded {StatusCode}", LogRedaction.SanitizeText(indexer.Name), (int)response.StatusCode); + + if (response.StatusCode == HttpStatusCode.Unauthorized || + response.StatusCode == HttpStatusCode.Forbidden) + { + await SaveTestResultAsync(indexer, persist, false, $"Authentication failed: HTTP {(int)response.StatusCode}"); + return IndexerTestWorkflowResult.Failure("Authentication failed", (int)response.StatusCode); + } + + if (!response.IsSuccessStatusCode) + { + await SaveTestResultAsync(indexer, persist, false, $"HTTP {(int)response.StatusCode}"); + return IndexerTestWorkflowResult.Failure("Generic indexer test failed", (int)response.StatusCode); + } + + if (isNewznabStyle) + { + var xmlContent = await response.Content.ReadAsStringAsync(); + var errorMessage = NewznabErrorParser.Parse(xmlContent); + + if (errorMessage != null) + { + var isAuthError = errorMessage.Contains("api", StringComparison.OrdinalIgnoreCase) || + errorMessage.Contains("key", StringComparison.OrdinalIgnoreCase) || + errorMessage.Contains("unauthorized", StringComparison.OrdinalIgnoreCase) || + errorMessage.Contains("invalid", StringComparison.OrdinalIgnoreCase) || + errorMessage.Contains("authentication", StringComparison.OrdinalIgnoreCase); + + var failureMessage = isAuthError ? $"Authentication failed: {errorMessage}" : errorMessage; + await SaveTestResultAsync(indexer, persist, false, failureMessage); + return IndexerTestWorkflowResult.Failure(failureMessage); + } + } + + await SaveTestResultAsync(indexer, persist, true, null); + return IndexerTestWorkflowResult.Success("Indexer authentication successful"); + } + catch (HttpRequestException ex) + { + return await BuildGenericFailureAsync(indexer, persist, ex); + } + catch (TaskCanceledException ex) + { + return await BuildGenericFailureAsync(indexer, persist, ex); + } + catch (JsonException ex) + { + return await BuildGenericFailureAsync(indexer, persist, ex); + } + catch (UriFormatException ex) + { + return await BuildGenericFailureAsync(indexer, persist, ex); + } + catch (InvalidOperationException ex) + { + return await BuildGenericFailureAsync(indexer, persist, ex); + } + } + + private async Task BuildGenericFailureAsync(Indexer indexer, bool persist, Exception ex) + { + _logger.LogWarning(ex, "Generic indexer test failed for {Name}", LogRedaction.SanitizeText(indexer.Name)); + await SaveTestResultAsync(indexer, persist, false, ex.Message); + return IndexerTestWorkflowResult.Failure("Indexer test failed", error: ex.Message); + } + + private string? ValidateOutboundUrl(string url) + { + if (!OutboundRequestSecurity.TryValidateExternalHttpUrl(url, out var reason, allowPrivateTargets: true)) + { + return reason; + } + + return null; + } + + private async Task SendValidatedAsync( + Func requestFactory, + string url, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + CancellationToken cancellationToken = default) + { + var uri = new Uri(url); + var (response, _) = await OutboundRequestSecurity.SendWithValidatedRedirectsAsync( + requestFactory, + uri, + _httpClientNoRedirect, + _logger, + allowPrivateTargets: true, + completionOption: completionOption, + cancellationToken: cancellationToken); + return response; + } + + private async Task SaveTestResultAsync(Indexer indexer, bool persist, bool success, string? error) + { + indexer.LastTestedAt = DateTime.UtcNow; + indexer.LastTestSuccessful = success; + indexer.LastTestError = error; + + if (persist && indexer.Id != 0) + { + var existing = await _indexerRepository.GetByIdAsync(indexer.Id); + if (existing != null) + { + existing.LastTestedAt = indexer.LastTestedAt; + existing.LastTestSuccessful = success; + existing.LastTestError = error; + existing.UpdatedAt = DateTime.UtcNow; + await _indexerRepository.UpdateAsync(existing); + } + } + } + } + + public sealed record IndexerTestWorkflowResult(bool Succeeded, string Message, int? Status = null, string? Error = null) + { + public static IndexerTestWorkflowResult Success(string message) => new(true, message); + + public static IndexerTestWorkflowResult Failure(string message, int? status = null, string? error = null) => new(false, message, status, error); + } +} diff --git a/listenarr.api/Controllers/IndexersController.cs b/listenarr.api/Controllers/IndexersController.cs index 2c40b2984..d419e1628 100644 --- a/listenarr.api/Controllers/IndexersController.cs +++ b/listenarr.api/Controllers/IndexersController.cs @@ -38,14 +38,24 @@ public class IndexersController : ControllerBase private readonly HttpClient _httpClient; private readonly HttpClient _httpClientNoRedirect; private readonly IConfigurationService _configurationService; - - public IndexersController(IIndexerRepository indexerRepository, ILogger logger, HttpClient httpClient, IConfigurationService configurationService) + private readonly IndexerTestWorkflow _indexerTestWorkflow; + + public IndexersController( + IIndexerRepository indexerRepository, + ILogger logger, + HttpClient httpClient, + IConfigurationService configurationService, + IndexerTestWorkflow? indexerTestWorkflow = null) { _indexerRepository = indexerRepository; _logger = logger; _httpClient = httpClient; _httpClientNoRedirect = httpClient; _configurationService = configurationService; + _indexerTestWorkflow = indexerTestWorkflow ?? new IndexerTestWorkflow( + indexerRepository, + httpClient, + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); } private bool ShouldRedactIndexerSecretsForCaller() @@ -181,122 +191,23 @@ private async Task BuildIndexerTestBadRequestAsync(Indexer indexe private async Task TestGenericIndexer(Indexer indexer, bool persist) { - // Minimal connectivity check: attempt to hit base URL or indexer 'api' endpoint - try + var result = await _indexerTestWorkflow.TestGenericIndexerAsync(indexer, persist); + if (result.Succeeded) { - var target = indexer.Url?.TrimEnd('/') ?? string.Empty; - // Prefer /api endpoint if present, otherwise base URL - var testUrl = target.EndsWith("/api", StringComparison.OrdinalIgnoreCase) ? target : target + "/api"; - - // If this is a Newznab/Torznab style indexer, append the apikey query parameter and add capabilities query to test auth - var implName = (indexer.Implementation ?? string.Empty).Trim().ToLowerInvariant(); - var isNewznabStyle = implName == "newznab" || implName == "torznab"; - - if (isNewznabStyle) - { - // Newznab/Torznab indexers REQUIRE an API key for authentication - if (string.IsNullOrWhiteSpace(indexer.ApiKey)) - { - await SaveTestResultAsync(indexer, persist, false, "API key is required for Newznab/Torznab indexers"); - return BadRequest(new { success = false, message = "API key is required for Newznab/Torznab indexers", indexer = RedactIndexerForCaller(indexer) }); - } - - // Use search endpoint (t=search) instead of capabilities (t=caps) because - // many indexers expose t=caps publicly without authentication. - // t=search reliably enforces authentication. - var separator = testUrl.Contains('?') ? '&' : '?'; - testUrl = testUrl + separator + "t=search&limit=1&offset=0"; - testUrl = testUrl + "&apikey=" + System.Net.WebUtility.UrlEncode(indexer.ApiKey); - } - - // Ensure User-Agent is present even if the injected HttpClient was created without defaults - var version = typeof(IndexersController).Assembly.GetName().Version?.ToString() ?? "0.0.0"; - var userAgent = $"Listenarr/{version} (+https://github.com/listenarrs/listenarr)"; - - _logger.LogInformation("[IndexerTest] GET {Url} UA={UserAgent}", LogRedaction.SanitizeUrl(testUrl), LogRedaction.SanitizeText(userAgent)); - - var blockedReason = await ValidateOutboundUrlForCallerAsync(testUrl); - if (!string.IsNullOrWhiteSpace(blockedReason)) - { - await SaveTestResultAsync(indexer, persist, false, $"Blocked outbound target: {blockedReason}"); - return BadRequest(new { success = false, message = $"Blocked outbound target: {blockedReason}", indexer = RedactIndexerForCaller(indexer) }); - } - - using var response = await SendValidatedAsync(currentUri => - { - var retryRequest = new HttpRequestMessage(HttpMethod.Get, currentUri); - retryRequest.Headers.UserAgent.ParseAdd(userAgent); - if (!string.IsNullOrEmpty(indexer.ApiKey)) - { - retryRequest.Headers.Add("X-Api-Key", indexer.ApiKey); - } - return retryRequest; - }, testUrl); - - _logger.LogInformation("[IndexerTest] {Name} responded {StatusCode}", LogRedaction.SanitizeText(indexer.Name), (int)response.StatusCode); - - // Check for HTTP-level authentication failures - if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized || - response.StatusCode == System.Net.HttpStatusCode.Forbidden) - { - await SaveTestResultAsync(indexer, persist, false, $"Authentication failed: HTTP {(int)response.StatusCode}"); - return BadRequest(new { success = false, message = "Authentication failed", status = (int)response.StatusCode, indexer = RedactIndexerForCaller(indexer) }); - } - - if (!response.IsSuccessStatusCode) - { - await SaveTestResultAsync(indexer, persist, false, $"HTTP {(int)response.StatusCode}"); - return BadRequest(new { success = false, message = "Generic indexer test failed", status = (int)response.StatusCode, indexer = RedactIndexerForCaller(indexer) }); - } - - // For Newznab/Torznab, parse XML response to check for error elements - if (isNewznabStyle) - { - var xmlContent = await response.Content.ReadAsStringAsync(); - var errorMessage = NewznabErrorParser.Parse(xmlContent); - - if (errorMessage != null) - { - var isAuthError = errorMessage.Contains("api", StringComparison.OrdinalIgnoreCase) || - errorMessage.Contains("key", StringComparison.OrdinalIgnoreCase) || - errorMessage.Contains("unauthorized", StringComparison.OrdinalIgnoreCase) || - errorMessage.Contains("invalid", StringComparison.OrdinalIgnoreCase) || - errorMessage.Contains("authentication", StringComparison.OrdinalIgnoreCase); - - var failureMessage = isAuthError ? $"Authentication failed: {errorMessage}" : errorMessage; - await SaveTestResultAsync(indexer, persist, false, failureMessage); - return BadRequest(new { success = false, message = failureMessage, indexer = RedactIndexerForCaller(indexer) }); - } - } - - await SaveTestResultAsync(indexer, persist, true, null); - return Ok(new { success = true, message = "Indexer authentication successful", indexer = RedactIndexerForCaller(indexer) }); + return Ok(new { success = true, message = result.Message, indexer = RedactIndexerForCaller(indexer) }); } - catch (HttpRequestException ex) - { - _logger.LogWarning(ex, "Generic indexer test failed for {Name}", LogRedaction.SanitizeText(indexer.Name)); - return await BuildIndexerTestBadRequestAsync(indexer, persist, "Indexer test failed", ex); - } - catch (TaskCanceledException ex) - { - _logger.LogWarning(ex, "Generic indexer test failed for {Name}", LogRedaction.SanitizeText(indexer.Name)); - return await BuildIndexerTestBadRequestAsync(indexer, persist, "Indexer test failed", ex); - } - catch (JsonException ex) - { - _logger.LogWarning(ex, "Generic indexer test failed for {Name}", LogRedaction.SanitizeText(indexer.Name)); - return await BuildIndexerTestBadRequestAsync(indexer, persist, "Indexer test failed", ex); - } - catch (UriFormatException ex) + + if (result.Status.HasValue) { - _logger.LogWarning(ex, "Generic indexer test failed for {Name}", LogRedaction.SanitizeText(indexer.Name)); - return await BuildIndexerTestBadRequestAsync(indexer, persist, "Indexer test failed", ex); + return BadRequest(new { success = false, message = result.Message, status = result.Status.Value, indexer = RedactIndexerForCaller(indexer) }); } - catch (InvalidOperationException ex) + + if (!string.IsNullOrEmpty(result.Error)) { - _logger.LogWarning(ex, "Generic indexer test failed for {Name}", LogRedaction.SanitizeText(indexer.Name)); - return await BuildIndexerTestBadRequestAsync(indexer, persist, "Indexer test failed", ex); + return BadRequest(new { success = false, message = result.Message, error = result.Error, indexer = RedactIndexerForCaller(indexer) }); } + + return BadRequest(new { success = false, message = result.Message, indexer = RedactIndexerForCaller(indexer) }); } /// diff --git a/listenarr.api/Controllers/ProwlarrCompatController.cs b/listenarr.api/Controllers/ProwlarrCompatController.cs index 9dbf93a33..179c10fb5 100644 --- a/listenarr.api/Controllers/ProwlarrCompatController.cs +++ b/listenarr.api/Controllers/ProwlarrCompatController.cs @@ -437,75 +437,10 @@ public async Task PutIndexer(int id, [FromBody] System.Text.Json. var created = false; if (indexer == null) { - // Parse payload for upsert/create (tolerant to fields/settings shapes) - var nameFromPayload = GetStringProperty(payload, "name", "title"); - var implementationFromPayload = GetStringProperty(payload, "implementation", "type"); - var baseUrlFromPayload = GetStringProperty(payload, "baseUrl", "url"); - var apiPathFromPayload = GetStringProperty(payload, "apiPath", null); - var apiKeyFromPayload = GetStringProperty(payload, "apiKey", null); - var categoriesFromPayload = ParseCategories(payload); - - // Try settings object - if (string.IsNullOrEmpty(baseUrlFromPayload) && payload.TryGetProperty("settings", out var settingsPayload) && settingsPayload.ValueKind == System.Text.Json.JsonValueKind.Object) - { - baseUrlFromPayload = GetStringProperty(settingsPayload, "baseUrl", "url"); - if (string.IsNullOrEmpty(apiKeyFromPayload)) apiKeyFromPayload = GetStringProperty(settingsPayload, "apiKey", "apikey"); - if (string.IsNullOrEmpty(apiPathFromPayload)) apiPathFromPayload = GetStringProperty(settingsPayload, "apiPath", null); - } - - // Try fields array - if (payload.TryGetProperty("fields", out var fieldsArray) && fieldsArray.ValueKind == System.Text.Json.JsonValueKind.Array) - { - foreach (var f in fieldsArray.EnumerateArray().Where(field => field.ValueKind == System.Text.Json.JsonValueKind.Object)) - { - var fname = GetStringProperty(f, "name", null); - if (string.IsNullOrEmpty(fname)) continue; - - if (fname.Equals("baseUrl", System.StringComparison.InvariantCultureIgnoreCase) && - f.TryGetProperty("value", out var baseUrlValue) && - baseUrlValue.ValueKind == System.Text.Json.JsonValueKind.String) - { - baseUrlFromPayload = baseUrlValue.GetString() ?? baseUrlFromPayload; - } - - if (fname.Equals("apiKey", System.StringComparison.InvariantCultureIgnoreCase) && - f.TryGetProperty("value", out var apiKeyValue) && - apiKeyValue.ValueKind == System.Text.Json.JsonValueKind.String) - { - apiKeyFromPayload = apiKeyValue.GetString() ?? apiKeyFromPayload; - } - - if (fname.Equals("apiPath", System.StringComparison.InvariantCultureIgnoreCase) && - f.TryGetProperty("value", out var apiPathValue) && - apiPathValue.ValueKind == System.Text.Json.JsonValueKind.String) - { - apiPathFromPayload = apiPathValue.GetString() ?? apiPathFromPayload; - } - - if (fname.Equals("categories", System.StringComparison.InvariantCultureIgnoreCase)) - { - if (f.TryGetProperty("value", out var v) && v.ValueKind == System.Text.Json.JsonValueKind.Array) - { - var parts = v.EnumerateArray().Select(x => x.ValueKind == System.Text.Json.JsonValueKind.Number ? x.GetInt32().ToString() : x.GetString() ?? string.Empty).Where(s => !string.IsNullOrEmpty(s)); - categoriesFromPayload = string.Join(',', parts); - } - else if (f.TryGetProperty("value", out var vs) && vs.ValueKind == System.Text.Json.JsonValueKind.String) - { - categoriesFromPayload = vs.GetString() ?? categoriesFromPayload; - } - } - } - } - - var urlFromPayload = (baseUrlFromPayload ?? string.Empty).Trim(); - if (!string.IsNullOrEmpty(apiPathFromPayload) && !string.IsNullOrEmpty(urlFromPayload)) - { - urlFromPayload = urlFromPayload.TrimEnd('/') + "/" + apiPathFromPayload.Trim('/'); - } - - var normalized = NormalizeIndexerUrl(urlFromPayload); + var parsed = ProwlarrIndexerPayloadReader.ParseForPut(payload); + var normalized = NormalizeIndexerUrl(parsed.Url); var allIndexers = await _indexerRepository.GetAllAsync(); - var existing = allIndexers.FirstOrDefault(i => NormalizeIndexerUrl(i.Url) == normalized && (i.ApiKey ?? string.Empty) == (apiKeyFromPayload ?? string.Empty)); + var existing = allIndexers.FirstOrDefault(i => NormalizeIndexerUrl(i.Url) == normalized && (i.ApiKey ?? string.Empty) == (parsed.ApiKey ?? string.Empty)); if (existing != null) { indexer = await _indexerRepository.GetByIdAsync(existing.Id); @@ -515,11 +450,11 @@ public async Task PutIndexer(int id, [FromBody] System.Text.Json. // Not found: create new indexer entry from parsed payload (upsert behavior) indexer = new Indexer { - Name = string.IsNullOrEmpty(nameFromPayload) ? (string.IsNullOrEmpty(baseUrlFromPayload) ? "Prowlarr Indexer" : baseUrlFromPayload) : nameFromPayload, - Implementation = string.IsNullOrEmpty(implementationFromPayload) ? "Custom" : implementationFromPayload, - Url = urlFromPayload, - ApiKey = string.IsNullOrEmpty(apiKeyFromPayload) ? null : apiKeyFromPayload, - Categories = categoriesFromPayload ?? string.Empty, + Name = parsed.Name, + Implementation = parsed.Implementation, + Url = parsed.Url, + ApiKey = string.IsNullOrEmpty(parsed.ApiKey) ? null : parsed.ApiKey, + Categories = parsed.Categories ?? string.Empty, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow, IsEnabled = true, @@ -552,80 +487,12 @@ public async Task PutIndexer(int id, [FromBody] System.Text.Json. return BadRequest(new { message = "Expected JSON object for indexer update" }); } - // Extract tolerant fields from payload - var name = GetStringProperty(payload, "name", "title"); - var implementation = GetStringProperty(payload, "implementation", "type"); - var baseUrl = GetStringProperty(payload, "baseUrl", "url"); - var apiPath = GetStringProperty(payload, "apiPath", null); - var apiKey = GetStringProperty(payload, "apiKey", null); - - // Try settings object - if (string.IsNullOrEmpty(baseUrl) && payload.TryGetProperty("settings", out var settings) && settings.ValueKind == System.Text.Json.JsonValueKind.Object) - { - baseUrl = GetStringProperty(settings, "baseUrl", "url"); - if (string.IsNullOrEmpty(apiKey)) apiKey = GetStringProperty(settings, "apiKey", "apikey"); - if (string.IsNullOrEmpty(apiPath)) apiPath = GetStringProperty(settings, "apiPath", null); - } - - // Try fields array - var categories = ParseCategories(payload); - - if (payload.TryGetProperty("fields", out var fieldsProp) && fieldsProp.ValueKind == System.Text.Json.JsonValueKind.Array) - { - foreach (var f in fieldsProp.EnumerateArray().Where(field => field.ValueKind == System.Text.Json.JsonValueKind.Object)) - { - var fname = GetStringProperty(f, "name", null); - if (string.IsNullOrEmpty(fname)) continue; - - if (fname.Equals("baseUrl", System.StringComparison.InvariantCultureIgnoreCase) && - f.TryGetProperty("value", out var baseUrlValue) && - baseUrlValue.ValueKind == System.Text.Json.JsonValueKind.String) - { - baseUrl = baseUrlValue.GetString() ?? baseUrl; - } - - if (fname.Equals("apiKey", System.StringComparison.InvariantCultureIgnoreCase) && - f.TryGetProperty("value", out var apiKeyValue) && - apiKeyValue.ValueKind == System.Text.Json.JsonValueKind.String) - { - apiKey = apiKeyValue.GetString() ?? apiKey; - } - - if (fname.Equals("apiPath", System.StringComparison.InvariantCultureIgnoreCase) && - f.TryGetProperty("value", out var apiPathValue) && - apiPathValue.ValueKind == System.Text.Json.JsonValueKind.String) - { - apiPath = apiPathValue.GetString() ?? apiPath; - } - - if (fname.Equals("categories", System.StringComparison.InvariantCultureIgnoreCase)) - { - if (f.TryGetProperty("value", out var categoriesValue) && categoriesValue.ValueKind == System.Text.Json.JsonValueKind.Array) - { - var parts = categoriesValue.EnumerateArray().Select(x => x.ValueKind == System.Text.Json.JsonValueKind.Number ? x.GetInt32().ToString() : x.GetString() ?? string.Empty).Where(s => !string.IsNullOrEmpty(s)); - categories = string.Join(',', parts); - } - else if (f.TryGetProperty("value", out var vs) && vs.ValueKind == System.Text.Json.JsonValueKind.String) - { - categories = vs.GetString() ?? categories; - } - } - } - } - - // Apply updates - if (!string.IsNullOrEmpty(name)) indexer.Name = name; - if (!string.IsNullOrEmpty(implementation)) indexer.Implementation = implementation; - - var url = (baseUrl ?? string.Empty).Trim(); - if (!string.IsNullOrEmpty(apiPath) && !string.IsNullOrEmpty(url)) - { - url = url.TrimEnd('/') + "/" + apiPath.Trim('/'); - } - - if (!string.IsNullOrEmpty(url)) indexer.Url = url; - indexer.ApiKey = string.IsNullOrEmpty(apiKey) ? null : apiKey; - indexer.Categories = categories ?? indexer.Categories; + var update = ProwlarrIndexerPayloadReader.ParseForPut(payload); + if (!string.IsNullOrEmpty(update.Name)) indexer.Name = update.Name; + if (!string.IsNullOrEmpty(update.Implementation)) indexer.Implementation = update.Implementation; + if (!string.IsNullOrEmpty(update.Url)) indexer.Url = update.Url; + indexer.ApiKey = string.IsNullOrEmpty(update.ApiKey) ? null : update.ApiKey; + indexer.Categories = update.Categories ?? indexer.Categories; indexer.UpdatedAt = DateTime.UtcNow; await _indexerRepository.UpdateAsync(indexer); @@ -811,128 +678,25 @@ public async Task PostIndexers([FromBody] System.Text.Json.JsonEl foreach (var item in payload.EnumerateArray().Where(item => item.ValueKind == System.Text.Json.JsonValueKind.Object)) { - // Extract common fields with tolerant mapping - string getString(System.Text.Json.JsonElement el, string prop1, string? prop2 = null) - { - if (el.TryGetProperty(prop1, out var p) && p.ValueKind == System.Text.Json.JsonValueKind.String) - return p.GetString() ?? string.Empty; - if (prop2 != null && el.TryGetProperty(prop2, out var p2) && p2.ValueKind == System.Text.Json.JsonValueKind.String) - return p2.GetString() ?? string.Empty; - return string.Empty; - } - - var name = getString(item, "name", "title"); - var implementation = getString(item, "implementation", "type"); - var baseUrl = getString(item, "baseUrl", "url"); - var apiPath = getString(item, "apiPath", null); - var apiKey = getString(item, "apiKey", null); - - // categories can be array or string - string? categories = null; - if (item.TryGetProperty("categories", out var cats)) - { - if (cats.ValueKind == System.Text.Json.JsonValueKind.Array) - { - var parts = cats.EnumerateArray().Select(x => x.ValueKind == System.Text.Json.JsonValueKind.Number ? x.GetInt32().ToString() : x.GetString() ?? string.Empty).Where(s => !string.IsNullOrEmpty(s)); - categories = string.Join(',', parts); - } - else if (cats.ValueKind == System.Text.Json.JsonValueKind.String) - { - categories = cats.GetString(); - } - } - - if (!string.IsNullOrEmpty(apiPath) && !string.IsNullOrEmpty(baseUrl)) - { - baseUrl = baseUrl.TrimEnd('/') + "/" + apiPath.Trim('/'); - } - - // If baseUrl/apiKey/apiPath absent, try to look for a settings object with baseUrl/apiKey - if (string.IsNullOrEmpty(baseUrl) && item.TryGetProperty("settings", out var settings) && settings.ValueKind == System.Text.Json.JsonValueKind.Object) - { - baseUrl = GetStringProperty(settings, "baseUrl", "url"); - if (string.IsNullOrEmpty(apiKey)) apiKey = GetStringProperty(settings, "apiKey", "apikey"); - if (string.IsNullOrEmpty(apiPath)) apiPath = GetStringProperty(settings, "apiPath", null); - } - - // If still missing, try to extract from a "fields" array (Prowlarr sends baseUrl/apiKey/categories within fields) - if ((string.IsNullOrEmpty(baseUrl) || string.IsNullOrEmpty(apiKey) || string.IsNullOrEmpty(apiPath) || string.IsNullOrEmpty(categories)) && - item.TryGetProperty("fields", out var fields) && fields.ValueKind == System.Text.Json.JsonValueKind.Array) - { - foreach (var f in fields.EnumerateArray().Where(field => field.ValueKind == System.Text.Json.JsonValueKind.Object)) - { - var fname = getString(f, "name", null); - if (string.IsNullOrEmpty(fname)) - continue; - - // baseUrl, apiKey, apiPath are strings inside field.value - if (string.IsNullOrEmpty(baseUrl) && - fname.Equals("baseUrl", StringComparison.InvariantCultureIgnoreCase) && - f.TryGetProperty("value", out var v) && - v.ValueKind == System.Text.Json.JsonValueKind.String) - { - baseUrl = v.GetString() ?? string.Empty; - } - - if (string.IsNullOrEmpty(apiKey) && - fname.Equals("apiKey", StringComparison.InvariantCultureIgnoreCase) && - f.TryGetProperty("value", out v) && - v.ValueKind == System.Text.Json.JsonValueKind.String) - { - apiKey = v.GetString() ?? string.Empty; - } - - if (string.IsNullOrEmpty(apiPath) && - fname.Equals("apiPath", StringComparison.InvariantCultureIgnoreCase) && - f.TryGetProperty("value", out v) && - v.ValueKind == System.Text.Json.JsonValueKind.String) - { - apiPath = v.GetString() ?? string.Empty; - } - - if (string.IsNullOrEmpty(categories) && fname.Equals("categories", StringComparison.InvariantCultureIgnoreCase)) - { - if (f.TryGetProperty("value", out var categoriesValue) && categoriesValue.ValueKind == System.Text.Json.JsonValueKind.Array) - { - var parts = categoriesValue.EnumerateArray().Select(x => x.ValueKind == System.Text.Json.JsonValueKind.Number ? x.GetInt32().ToString() : x.GetString() ?? string.Empty).Where(s => !string.IsNullOrEmpty(s)); - categories = string.Join(',', parts); - } - else if (f.TryGetProperty("value", out var vs) && vs.ValueKind == System.Text.Json.JsonValueKind.String) - { - categories = vs.GetString(); - } - } - - if (!string.IsNullOrEmpty(baseUrl) && !string.IsNullOrEmpty(apiKey) && !string.IsNullOrEmpty(apiPath) && !string.IsNullOrEmpty(categories)) - { - break; - } - } - } - - if (string.IsNullOrEmpty(name)) name = baseUrl ?? "Prowlarr Indexer"; - if (string.IsNullOrEmpty(implementation)) implementation = "Custom"; - - // Normalize URLs - var url = (baseUrl ?? string.Empty).Trim(); + var parsed = ProwlarrIndexerPayloadReader.ParseForBulkPost(item); // Deduplicate by normalized URL + ApiKey (normalizes trailing slash and trailing /api) - var normalizedUrl = NormalizeIndexerUrl(url); - var exists = existingIndexers.FirstOrDefault(i => NormalizeIndexerUrl(i.Url) == normalizedUrl && (i.ApiKey ?? string.Empty) == (apiKey ?? string.Empty)); + var normalizedUrl = NormalizeIndexerUrl(parsed.Url); + var exists = existingIndexers.FirstOrDefault(i => NormalizeIndexerUrl(i.Url) == normalizedUrl && (i.ApiKey ?? string.Empty) == (parsed.ApiKey ?? string.Empty)); if (exists != null) { skipped++; - _logger?.LogInformation("Prowlarr: Skipping existing indexer (name={Name}, url={Url}, apiKeyPresent={HasApiKey})", name, exists.Url, !string.IsNullOrEmpty(apiKey)); + _logger?.LogInformation("Prowlarr: Skipping existing indexer (name={Name}, url={Url}, apiKeyPresent={HasApiKey})", parsed.Name, exists.Url, !string.IsNullOrEmpty(parsed.ApiKey)); continue; } var indexer = new Indexer { - Name = name, - Implementation = implementation, - Url = url, - ApiKey = string.IsNullOrEmpty(apiKey) ? null : apiKey, - Categories = categories ?? string.Empty, + Name = parsed.Name, + Implementation = parsed.Implementation, + Url = parsed.Url, + ApiKey = string.IsNullOrEmpty(parsed.ApiKey) ? null : parsed.ApiKey, + Categories = parsed.Categories ?? string.Empty, Tags = string.Empty, AdditionalSettings = string.Empty, CreatedAt = DateTime.UtcNow, @@ -941,7 +705,7 @@ string getString(System.Text.Json.JsonElement el, string prop1, string? prop2 = }; // Guess Type from implementation - var implLower = (implementation ?? string.Empty).ToLowerInvariant(); + var implLower = (parsed.Implementation ?? string.Empty).ToLowerInvariant(); indexer.Type = implLower.Contains("newznab") ? "Usenet" : (implLower.Contains("torznab") ? "Torrent" : "Custom"); indexer = await _indexerRepository.AddAsync(indexer); @@ -1220,37 +984,6 @@ private static string NormalizeIndexerUrl(string url) } } - // Helper to read tolerant string properties from a JSON element - private static string GetStringProperty(System.Text.Json.JsonElement el, string prop1, string? prop2 = null) - { - if (el.ValueKind != System.Text.Json.JsonValueKind.Object) return string.Empty; - - if (el.TryGetProperty(prop1, out var p) && p.ValueKind == System.Text.Json.JsonValueKind.String) - return p.GetString() ?? string.Empty; - if (prop2 != null && el.TryGetProperty(prop2, out var p2) && p2.ValueKind == System.Text.Json.JsonValueKind.String) - return p2.GetString() ?? string.Empty; - return string.Empty; - } - - // Helper to parse categories property (array or string) into a comma-separated string - private static string? ParseCategories(System.Text.Json.JsonElement el) - { - if (el.TryGetProperty("categories", out var cats)) - { - if (cats.ValueKind == System.Text.Json.JsonValueKind.Array) - { - var parts = cats.EnumerateArray().Select(x => x.ValueKind == System.Text.Json.JsonValueKind.Number ? x.GetInt32().ToString() : x.GetString() ?? string.Empty).Where(s => !string.IsNullOrEmpty(s)); - return string.Join(',', parts); - } - else if (cats.ValueKind == System.Text.Json.JsonValueKind.String) - { - return cats.GetString(); - } - } - - return null; - } - // DTOs public record SystemStatusDto { diff --git a/listenarr.api/Controllers/ProwlarrIndexerPayloadReader.cs b/listenarr.api/Controllers/ProwlarrIndexerPayloadReader.cs new file mode 100644 index 000000000..362db9227 --- /dev/null +++ b/listenarr.api/Controllers/ProwlarrIndexerPayloadReader.cs @@ -0,0 +1,225 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +using System.Text.Json; + +namespace Listenarr.Api.Controllers +{ + internal static class ProwlarrIndexerPayloadReader + { + public static ParsedProwlarrIndexerPayload ParseForPut(JsonElement payload) + { + var parsed = ParseCommon(payload); + return parsed with { Url = BuildUrl(parsed.BaseUrl, parsed.ApiPath) }; + } + + public static ParsedProwlarrIndexerPayload ParseForBulkPost(JsonElement payload) + { + var parsed = ParseTopLevel(payload); + + if (!string.IsNullOrEmpty(parsed.ApiPath) && !string.IsNullOrEmpty(parsed.BaseUrl)) + { + parsed = parsed with { BaseUrl = BuildUrl(parsed.BaseUrl, parsed.ApiPath) }; + } + + parsed = ApplySettingsFallback(payload, parsed); + parsed = ApplyFieldsFallback(payload, parsed, onlyMissingValues: true); + + if (string.IsNullOrEmpty(parsed.Name)) + { + parsed = parsed with { Name = parsed.BaseUrl ?? "Prowlarr Indexer" }; + } + + if (string.IsNullOrEmpty(parsed.Implementation)) + { + parsed = parsed with { Implementation = "Custom" }; + } + + return parsed with { Url = (parsed.BaseUrl ?? string.Empty).Trim() }; + } + + private static ParsedProwlarrIndexerPayload ParseCommon(JsonElement payload) + { + var parsed = ParseTopLevel(payload); + parsed = ApplySettingsFallback(payload, parsed); + parsed = ApplyFieldsFallback(payload, parsed, onlyMissingValues: false); + + if (string.IsNullOrEmpty(parsed.Name)) + { + parsed = parsed with { Name = string.IsNullOrEmpty(parsed.BaseUrl) ? "Prowlarr Indexer" : parsed.BaseUrl }; + } + + if (string.IsNullOrEmpty(parsed.Implementation)) + { + parsed = parsed with { Implementation = "Custom" }; + } + + return parsed; + } + + private static ParsedProwlarrIndexerPayload ParseTopLevel(JsonElement payload) + { + return new ParsedProwlarrIndexerPayload( + Name: GetStringProperty(payload, "name", "title"), + Implementation: GetStringProperty(payload, "implementation", "type"), + BaseUrl: GetStringProperty(payload, "baseUrl", "url"), + ApiPath: GetStringProperty(payload, "apiPath", null), + ApiKey: GetStringProperty(payload, "apiKey", null), + Categories: ParseCategories(payload), + Url: string.Empty); + } + + private static ParsedProwlarrIndexerPayload ApplySettingsFallback(JsonElement payload, ParsedProwlarrIndexerPayload parsed) + { + if (!payload.TryGetProperty("settings", out var settings) || settings.ValueKind != JsonValueKind.Object) + { + return parsed; + } + + var baseUrl = parsed.BaseUrl; + var apiKey = parsed.ApiKey; + var apiPath = parsed.ApiPath; + + if (string.IsNullOrEmpty(baseUrl)) baseUrl = GetStringProperty(settings, "baseUrl", "url"); + if (string.IsNullOrEmpty(apiKey)) apiKey = GetStringProperty(settings, "apiKey", "apikey"); + if (string.IsNullOrEmpty(apiPath)) apiPath = GetStringProperty(settings, "apiPath", null); + + return parsed with { BaseUrl = baseUrl, ApiKey = apiKey, ApiPath = apiPath }; + } + + private static ParsedProwlarrIndexerPayload ApplyFieldsFallback(JsonElement payload, ParsedProwlarrIndexerPayload parsed, bool onlyMissingValues) + { + if (!payload.TryGetProperty("fields", out var fields) || fields.ValueKind != JsonValueKind.Array) + { + return parsed; + } + + var baseUrl = parsed.BaseUrl; + var apiKey = parsed.ApiKey; + var apiPath = parsed.ApiPath; + var categories = parsed.Categories; + + foreach (var field in fields.EnumerateArray().Where(field => field.ValueKind == JsonValueKind.Object)) + { + var name = GetStringProperty(field, "name", null); + if (string.IsNullOrEmpty(name)) continue; + + if ((!onlyMissingValues || string.IsNullOrEmpty(baseUrl)) && + name.Equals("baseUrl", StringComparison.InvariantCultureIgnoreCase) && + field.TryGetProperty("value", out var baseUrlValue) && + baseUrlValue.ValueKind == JsonValueKind.String) + { + baseUrl = baseUrlValue.GetString() ?? baseUrl; + } + + if ((!onlyMissingValues || string.IsNullOrEmpty(apiKey)) && + name.Equals("apiKey", StringComparison.InvariantCultureIgnoreCase) && + field.TryGetProperty("value", out var apiKeyValue) && + apiKeyValue.ValueKind == JsonValueKind.String) + { + apiKey = apiKeyValue.GetString() ?? apiKey; + } + + if ((!onlyMissingValues || string.IsNullOrEmpty(apiPath)) && + name.Equals("apiPath", StringComparison.InvariantCultureIgnoreCase) && + field.TryGetProperty("value", out var apiPathValue) && + apiPathValue.ValueKind == JsonValueKind.String) + { + apiPath = apiPathValue.GetString() ?? apiPath; + } + + if ((!onlyMissingValues || string.IsNullOrEmpty(categories)) && + name.Equals("categories", StringComparison.InvariantCultureIgnoreCase)) + { + categories = ParseFieldCategories(field, categories); + } + + if (onlyMissingValues && + !string.IsNullOrEmpty(baseUrl) && + !string.IsNullOrEmpty(apiKey) && + !string.IsNullOrEmpty(apiPath) && + !string.IsNullOrEmpty(categories)) + { + break; + } + } + + return parsed with { BaseUrl = baseUrl, ApiKey = apiKey, ApiPath = apiPath, Categories = categories }; + } + + private static string BuildUrl(string? baseUrl, string? apiPath) + { + var url = (baseUrl ?? string.Empty).Trim(); + if (!string.IsNullOrEmpty(apiPath) && !string.IsNullOrEmpty(url)) + { + url = url.TrimEnd('/') + "/" + apiPath.Trim('/'); + } + + return url; + } + + private static string GetStringProperty(JsonElement el, string prop1, string? prop2 = null) + { + if (el.TryGetProperty(prop1, out var p) && p.ValueKind == JsonValueKind.String) + return p.GetString() ?? string.Empty; + if (prop2 != null && el.TryGetProperty(prop2, out var p2) && p2.ValueKind == JsonValueKind.String) + return p2.GetString() ?? string.Empty; + return string.Empty; + } + + private static string? ParseCategories(JsonElement el) + { + if (el.TryGetProperty("categories", out var cats)) + { + if (cats.ValueKind == JsonValueKind.Array) + { + var parts = cats.EnumerateArray() + .Select(x => x.ValueKind == JsonValueKind.Number ? x.GetInt32().ToString() : x.GetString() ?? string.Empty) + .Where(s => !string.IsNullOrEmpty(s)); + return string.Join(',', parts); + } + + if (cats.ValueKind == JsonValueKind.String) + { + return cats.GetString(); + } + } + + return null; + } + + private static string? ParseFieldCategories(JsonElement field, string? fallback) + { + if (field.TryGetProperty("value", out var value) && value.ValueKind == JsonValueKind.Array) + { + var parts = value.EnumerateArray() + .Select(x => x.ValueKind == JsonValueKind.Number ? x.GetInt32().ToString() : x.GetString() ?? string.Empty) + .Where(s => !string.IsNullOrEmpty(s)); + return string.Join(',', parts); + } + + if (field.TryGetProperty("value", out var stringValue) && stringValue.ValueKind == JsonValueKind.String) + { + return stringValue.GetString() ?? fallback; + } + + return fallback; + } + } + + internal sealed record ParsedProwlarrIndexerPayload( + string Name, + string Implementation, + string BaseUrl, + string ApiPath, + string ApiKey, + string? Categories, + string Url); +} diff --git a/listenarr.api/Program.cs b/listenarr.api/Program.cs index df63d81e4..14e99289c 100644 --- a/listenarr.api/Program.cs +++ b/listenarr.api/Program.cs @@ -359,6 +359,8 @@ ex is IOException builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); // Register named HttpClients for each adapter type so adapter implementations can request the appropriately-configured client. builder.Services.AddListenarrHttpClients(builder.Configuration); diff --git a/listenarr.application/Search/AudibleAuthorPageCollector.cs b/listenarr.application/Search/AudibleAuthorPageCollector.cs new file mode 100644 index 000000000..fd5e320f2 --- /dev/null +++ b/listenarr.application/Search/AudibleAuthorPageCollector.cs @@ -0,0 +1,83 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +using Listenarr.Application.Metadata; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Application.Search +{ + public sealed class AudibleAuthorPageCollector + { + private readonly AudibleService _audibleService; + private readonly ILogger _logger; + + public AudibleAuthorPageCollector(AudibleService audibleService, ILogger logger) + { + _audibleService = audibleService; + _logger = logger; + } + + public async Task> CollectAsync( + string author, + int candidateLimit, + string region, + string? language, + string logContext) + { + var aggregated = new List(); + var pageSize = Math.Min(50, Math.Max(10, candidateLimit)); + + for (var page = 1; page <= int.MaxValue; page++) + { + try + { + var pageRes = await _audibleService.SearchByAuthorAsync(author, page, pageSize, region, language); + var pageCount = pageRes?.Results?.Count ?? 0; + aggregated.AddRange(pageRes?.Results ?? Enumerable.Empty()); + + _logger.LogInformation( + "Audible {Context}: page {Page} returned {PageCount} results (aggregated {AggregatedCount}) for author '{Author}'", + logContext, + page, + pageCount, + aggregated.Count, + author); + + if (pageRes?.Results == null || pageCount == 0) + { + _logger.LogInformation("Audible {Context}: stopping aggregation - page {Page} returned no results", logContext, page); + break; + } + + if (pageCount < pageSize) + { + _logger.LogInformation("Audible {Context}: stopping aggregation - page {Page} count {PageCount} < pageSize {PageSize}", logContext, page, pageCount, pageSize); + break; + } + } + catch (Exception exPage) when (exPage is not OperationCanceledException && exPage is not OutOfMemoryException && exPage is not StackOverflowException) + { + _logger.LogDebug(exPage, "Failed fetching audible author page {Page} for author {Author}", page, author); + break; + } + } + + _logger.LogInformation( + "Audible {Context}: finished aggregating pages for '{Author}': aggregated={AggregatedCount}, candidateLimit={CandidateLimit}, pageSize={PageSize}", + logContext, + author, + aggregated.Count, + candidateLimit, + pageSize); + + return aggregated; + } + } +} diff --git a/listenarr.application/Search/SearchService.cs b/listenarr.application/Search/SearchService.cs index 310da74f7..283005205 100644 --- a/listenarr.application/Search/SearchService.cs +++ b/listenarr.application/Search/SearchService.cs @@ -43,6 +43,7 @@ public class SearchService : ISearchService private readonly AsinSearchHandler _asinSearchHandler; private readonly IndexerSearchWorkflow _indexerSearchWorkflow; private readonly MetadataSourceCatalog _metadataSourceCatalog; + private readonly AudibleAuthorPageCollector _audibleAuthorPageCollector; public SearchService( HttpClient httpClient, @@ -63,7 +64,8 @@ public SearchService( ICoverImageProbe? coverImageProbe = null, IHtmlTextExtractor? htmlTextExtractor = null, IndexerSearchWorkflow? indexerSearchWorkflow = null, - MetadataSourceCatalog? metadataSourceCatalog = null) + MetadataSourceCatalog? metadataSourceCatalog = null, + AudibleAuthorPageCollector? audibleAuthorPageCollector = null) { _configurationService = configurationService; _logger = logger; @@ -87,6 +89,9 @@ public SearchService( _metadataSourceCatalog = metadataSourceCatalog ?? new MetadataSourceCatalog( apiConfigRepository, NullLogger.Instance); + _audibleAuthorPageCollector = audibleAuthorPageCollector ?? new AudibleAuthorPageCollector( + audibleService, + NullLogger.Instance); } public async Task> SearchAsync(string query, string? category = null, List? apiIds = null, SearchSortBy sortBy = SearchSortBy.Seeders, SearchSortDirection sortDirection = SearchSortDirection.Descending, bool isAutomaticSearch = false) @@ -221,42 +226,12 @@ public async Task> IntelligentSearchAsync(string quer // AUTHOR-only if (searchType == "AUTHOR" && !string.IsNullOrEmpty(authorVal)) { - // Aggregate multiple pages from Audible until we reach candidateLimit - var aggregated = new List(); - int page = 1; - int pageSize = Math.Min(50, Math.Max(10, candidateLimit)); - // For Audible author listings, do not artificially cap aggregation - // by the Amazon candidateLimit. Instead, fetch pages until a - // page returns fewer than pageSize results (natural end). - int maxPages = int.MaxValue; - for (; page <= maxPages; page++) - { - try - { - var pageRes = await _audibleService.SearchByAuthorAsync(authorVal, page, pageSize, region, language); - var pageCount = pageRes?.Results?.Count ?? 0; - aggregated.AddRange(pageRes?.Results ?? Enumerable.Empty()); - _logger.LogInformation("Audible author page {Page} returned {PageCount} results (aggregated {AggregatedCount}) for author '{Author}'", page, pageCount, aggregated.Count, authorVal); - if (pageRes?.Results == null || pageCount == 0) - { - _logger.LogInformation("Stopping aggregation: page {Page} returned no results for author '{Author}'", page, authorVal); - break; - } - if (pageCount < pageSize) - { - _logger.LogInformation("Stopping aggregation: page {Page} result count {PageCount} < pageSize {PageSize}", page, pageCount, pageSize); - break; // last page - } - // Do not stop aggregating based on candidateLimit for audible - } - catch (Exception exPage) when (exPage is not OperationCanceledException && exPage is not OutOfMemoryException && exPage is not StackOverflowException) - { - _logger.LogDebug(exPage, "Failed fetching audible author page {Page} for author {Author}", page, authorVal); - break; - } - } - - _logger.LogInformation("Finished aggregating author pages for '{Author}': total aggregated={AggregatedCount}, candidateLimit={CandidateLimit}, pageSize={PageSize}, maxPages={MaxPages}", authorVal, aggregated.Count, candidateLimit, pageSize, maxPages); + var aggregated = await _audibleAuthorPageCollector.CollectAsync( + authorVal, + candidateLimit, + region, + language, + "author"); if (aggregated.Any()) { // Deduplicate results based on ASIN to prevent repeated books across pages @@ -306,39 +281,12 @@ public async Task> IntelligentSearchAsync(string quer { System.Diagnostics.Debug.WriteLine("Suppressed non-fatal exception in catch block."); } - // Aggregate author pages up to candidateLimit to enrich matching - var aggregated = new List(); - int page = 1; - int pageSize = Math.Min(50, Math.Max(10, candidateLimit)); - // For Audible author/title combined flows, allow full aggregation - // across available pages; we will narrow/return a bounded set later. - int maxPages = int.MaxValue; - for (; page <= maxPages; page++) - { - try - { - var pageRes = await _audibleService.SearchByAuthorAsync(authorVal, page, pageSize, region, language); - var pageCount = pageRes?.Results?.Count ?? 0; - aggregated.AddRange(pageRes?.Results ?? Enumerable.Empty()); - _logger.LogInformation("Audible AUTHOR_TITLE: page {Page} returned {PageCount} results (aggregated {AggregatedCount}) for author '{Author}'", page, pageCount, aggregated.Count, authorVal); - if (pageRes?.Results == null || pageCount == 0) - { - _logger.LogInformation("Audible AUTHOR_TITLE: stopping aggregation — page {Page} returned no results", page); - break; - } - if (pageCount < pageSize) - { - _logger.LogInformation("Audible AUTHOR_TITLE: stopping aggregation — page {Page} count {PageCount} < pageSize {PageSize}", page, pageCount, pageSize); - break; - } - } - catch (Exception exPage) when (exPage is not OperationCanceledException && exPage is not OutOfMemoryException && exPage is not StackOverflowException) - { - _logger.LogDebug(exPage, "Failed fetching audible author page {Page} for author {Author}", page, authorVal); - break; - } - } - _logger.LogInformation("Audible AUTHOR_TITLE: finished aggregating pages for '{Author}': aggregated={AggregatedCount}, pageSize={PageSize}, maxPages={MaxPages}", authorVal, aggregated.Count, pageSize, maxPages); + var aggregated = await _audibleAuthorPageCollector.CollectAsync( + authorVal, + candidateLimit, + region, + language, + "AUTHOR_TITLE"); if (aggregated?.Any() == true) { // Deduplicate results based on ASIN to prevent repeated books across pages diff --git a/tests/Builders/ServiceCollectionBuilder.cs b/tests/Builders/ServiceCollectionBuilder.cs index 7aaf9b7ea..0c2c572b3 100644 --- a/tests/Builders/ServiceCollectionBuilder.cs +++ b/tests/Builders/ServiceCollectionBuilder.cs @@ -202,6 +202,8 @@ private ServiceCollection BuildServices() services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); From af82ea23f397a26f8cafe767a4a1d477fe4885b0 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Sat, 13 Jun 2026 08:22:08 -0400 Subject: [PATCH 22/84] Extract deeper backend workflow orchestration --- .../Controllers/IndexerTestWorkflow.cs | 230 +++++++++++++++- .../Controllers/IndexersController.cs | 249 ++---------------- .../Controllers/ProwlarrCompatController.cs | 206 ++------------- .../ProwlarrIndexerPayloadReader.cs | 2 +- .../ProwlarrIndexerUpsertWorkflow.cs | 195 ++++++++++++++ listenarr.api/Program.cs | 2 + .../Search/AudibleSimpleLookupWorkflow.cs | 132 ++++++++++ listenarr.application/Search/SearchService.cs | 80 ++---- tests/Builders/ServiceCollectionBuilder.cs | 2 + 9 files changed, 613 insertions(+), 485 deletions(-) create mode 100644 listenarr.api/Controllers/ProwlarrIndexerUpsertWorkflow.cs create mode 100644 listenarr.application/Search/AudibleSimpleLookupWorkflow.cs diff --git a/listenarr.api/Controllers/IndexerTestWorkflow.cs b/listenarr.api/Controllers/IndexerTestWorkflow.cs index f1a2e49f7..623c129d3 100644 --- a/listenarr.api/Controllers/IndexerTestWorkflow.cs +++ b/listenarr.api/Controllers/IndexerTestWorkflow.cs @@ -138,6 +138,218 @@ public async Task TestGenericIndexerAsync(Indexer ind } } + public async Task TestInternetArchiveAsync(Indexer indexer, bool persist) + { + try + { + var collection = "librivoxaudio"; + if (!string.IsNullOrEmpty(indexer.AdditionalSettings)) + { + try + { + using var doc = JsonDocument.Parse(indexer.AdditionalSettings); + if (doc.RootElement.TryGetProperty("collection", out var collectionProperty)) + { + collection = collectionProperty.GetString() ?? "librivoxaudio"; + } + } + catch (JsonException ex) + { + _logger.LogWarning(ex, "Failed to parse AdditionalSettings for Internet Archive indexer"); + } + } + + var testUrl = $"https://archive.org/advancedsearch.php?q=collection:{collection}&rows=1&output=json"; + + _logger.LogInformation("Testing Internet Archive indexer '{Name}' with collection '{Collection}'", + LogRedaction.SanitizeText(indexer.Name), LogRedaction.SanitizeText(collection)); + + using var response = await SendValidatedAsync( + currentUri => new HttpRequestMessage(HttpMethod.Get, currentUri), + testUrl); + response.EnsureSuccessStatusCode(); + + var content = await response.Content.ReadAsStringAsync(); + using var jsonDoc = JsonDocument.Parse(content); + + if (!jsonDoc.RootElement.TryGetProperty("response", out var responseProperty)) + { + throw new InvalidOperationException("Invalid response format: missing 'response' property"); + } + + if (!responseProperty.TryGetProperty("docs", out _)) + { + throw new InvalidOperationException("Invalid response format: missing 'docs' property"); + } + + await SaveTestResultAsync(indexer, persist, true, null); + + _logger.LogInformation("Internet Archive indexer '{Name}' test succeeded for collection '{Collection}'", + LogRedaction.SanitizeText(indexer.Name), LogRedaction.SanitizeText(collection)); + + return IndexerTestWorkflowResult.Success( + $"Internet Archive connection successful for collection '{collection}'", + collection: collection); + } + catch (HttpRequestException ex) + { + return await BuildInternetArchiveFailureAsync(indexer, persist, ex); + } + catch (TaskCanceledException ex) + { + return await BuildInternetArchiveFailureAsync(indexer, persist, ex); + } + catch (JsonException ex) + { + return await BuildInternetArchiveFailureAsync(indexer, persist, ex); + } + catch (UriFormatException ex) + { + return await BuildInternetArchiveFailureAsync(indexer, persist, ex); + } + catch (InvalidOperationException ex) + { + return await BuildInternetArchiveFailureAsync(indexer, persist, ex); + } + } + + public async Task TestMyAnonamouseAsync(Indexer indexer, bool persist) + { + try + { + var mamId = string.Empty; + + if (!string.IsNullOrEmpty(indexer.AdditionalSettings)) + { + try + { + using var doc = JsonDocument.Parse(indexer.AdditionalSettings); + if (doc.RootElement.TryGetProperty("mam_id", out var mamIdProperty)) + { + mamId = mamIdProperty.GetString() ?? string.Empty; + } + } + catch (JsonException ex) + { + _logger.LogWarning(ex, "Failed to parse AdditionalSettings for MyAnonamouse indexer"); + } + } + + if (string.IsNullOrEmpty(mamId)) + { + throw new InvalidOperationException("MAM ID is required for MyAnonamouse"); + } + + var testUrl = "https://www.myanonamouse.net/tor/js/loadSearchJSONbasic.php"; + + _logger.LogInformation("Testing MyAnonamouse indexer '{Name}' with MAM ID '{MamId}'", + LogRedaction.SanitizeText(indexer.Name), LogRedaction.RedactText(mamId, LogRedaction.GetSensitiveValuesFromEnvironment().Concat(new[] { mamId }))); + + using var request = new HttpRequestMessage(HttpMethod.Post, testUrl); + request.Headers.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"); + request.Headers.Accept.ParseAdd("application/json, text/javascript, */*; q=0.01"); + request.Headers.AcceptLanguage.ParseAdd("en-US,en;q=0.9"); + request.Headers.Referrer = new Uri("https://www.myanonamouse.net/"); + + request.Content = new FormUrlEncodedContent(new Dictionary + { + ["tor[text]"] = "test", + ["tor[srchIn][]"] = "title", + ["tor[searchType]"] = "all", + ["tor[searchIn]"] = "torrents", + ["tor[cat][]"] = "0", + ["tor[browseFlagsHideVsShow]"] = "0", + ["tor[startDate]"] = "", + ["tor[endDate]"] = "", + ["tor[hash]"] = "", + ["tor[sortType]"] = "default", + ["tor[startNumber]"] = "0", + ["perpage"] = "1", + ["thumbnail"] = "false", + ["dlLink"] = "", + ["description"] = "" + }); + + var cookieContainer = new CookieContainer(); + var baseUrl = indexer.Url.TrimEnd('/'); + var baseUri = new Uri(baseUrl.StartsWith("http", StringComparison.OrdinalIgnoreCase) ? baseUrl : "https://" + baseUrl); + cookieContainer.Add(baseUri, new Cookie("mam_id", mamId)); + try + { + var host = baseUri.Host; + if (!host.StartsWith("www.", StringComparison.OrdinalIgnoreCase)) + { + var wwwUri = new Uri($"{baseUri.Scheme}://www.{host}"); + cookieContainer.Add(wwwUri, new Cookie("mam_id", mamId)); + } + } + catch (UriFormatException ex) + { + _logger.LogDebug(ex, "Failed to add www host alias cookie for MyAnonamouse test request to {Host}", baseUri.Host); + } + catch (CookieException ex) + { + _logger.LogDebug(ex, "Failed to add www host alias cookie for MyAnonamouse test request to {Host}", baseUri.Host); + } + + var handler = new HttpClientHandler + { + CookieContainer = cookieContainer, + UseCookies = true + }; + + using var cookieClient = new HttpClient(handler); + cookieClient.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"); + cookieClient.DefaultRequestHeaders.Accept.ParseAdd("application/json, text/javascript, */*; q=0.01"); + cookieClient.DefaultRequestHeaders.AcceptLanguage.ParseAdd("en-US,en;q=0.9"); + cookieClient.DefaultRequestHeaders.Referrer = new Uri("https://www.myanonamouse.net/"); + + using var response = await cookieClient.SendAsync(request); + response.EnsureSuccessStatusCode(); + + var content = await response.Content.ReadAsStringAsync(); + using var jsonDoc = JsonDocument.Parse(content); + + if (!jsonDoc.RootElement.TryGetProperty("data", out _)) + { + throw new InvalidOperationException("Invalid response format: missing 'data' property"); + } + + await SaveTestResultAsync(indexer, persist, true, null); + + _logger.LogInformation("MyAnonamouse indexer '{Name}' test succeeded with MAM ID '{MamId}'", + LogRedaction.SanitizeText(indexer.Name), LogRedaction.RedactText(mamId, LogRedaction.GetSensitiveValuesFromEnvironment().Concat(new[] { mamId }))); + + return IndexerTestWorkflowResult.Success( + $"MyAnonamouse authentication successful with MAM ID '{mamId}'", + mamId: mamId); + } + catch (HttpRequestException ex) + { + return await BuildMamFailureAsync(indexer, persist, ex); + } + catch (TaskCanceledException ex) + { + return await BuildMamFailureAsync(indexer, persist, ex); + } + catch (UriFormatException ex) + { + return await BuildMamFailureAsync(indexer, persist, ex); + } + catch (CookieException ex) + { + return await BuildMamFailureAsync(indexer, persist, ex); + } + catch (JsonException ex) + { + return await BuildMamFailureAsync(indexer, persist, ex); + } + catch (InvalidOperationException ex) + { + return await BuildMamFailureAsync(indexer, persist, ex); + } + } + private async Task BuildGenericFailureAsync(Indexer indexer, bool persist, Exception ex) { _logger.LogWarning(ex, "Generic indexer test failed for {Name}", LogRedaction.SanitizeText(indexer.Name)); @@ -145,6 +357,20 @@ private async Task BuildGenericFailureAsync(Indexer i return IndexerTestWorkflowResult.Failure("Indexer test failed", error: ex.Message); } + private async Task BuildInternetArchiveFailureAsync(Indexer indexer, bool persist, Exception ex) + { + _logger.LogWarning(ex, "Internet Archive indexer '{Name}' test failed", LogRedaction.SanitizeText(indexer.Name)); + await SaveTestResultAsync(indexer, persist, false, ex.Message); + return IndexerTestWorkflowResult.Failure("Internet Archive test failed", error: ex.Message); + } + + private async Task BuildMamFailureAsync(Indexer indexer, bool persist, Exception ex) + { + await SaveTestResultAsync(indexer, persist, false, ex.Message); + _logger.LogWarning(ex, "MyAnonamouse indexer '{Name}' test failed", LogRedaction.SanitizeText(indexer.Name)); + return IndexerTestWorkflowResult.Failure("MyAnonamouse test failed", error: ex.Message); + } + private string? ValidateOutboundUrl(string url) { if (!OutboundRequestSecurity.TryValidateExternalHttpUrl(url, out var reason, allowPrivateTargets: true)) @@ -194,9 +420,9 @@ private async Task SaveTestResultAsync(Indexer indexer, bool persist, bool succe } } - public sealed record IndexerTestWorkflowResult(bool Succeeded, string Message, int? Status = null, string? Error = null) + public sealed record IndexerTestWorkflowResult(bool Succeeded, string Message, int? Status = null, string? Error = null, string? Collection = null, string? MamId = null) { - public static IndexerTestWorkflowResult Success(string message) => new(true, message); + public static IndexerTestWorkflowResult Success(string message, string? collection = null, string? mamId = null) => new(true, message, Collection: collection, MamId: mamId); public static IndexerTestWorkflowResult Failure(string message, int? status = null, string? error = null) => new(false, message, status, error); } diff --git a/listenarr.api/Controllers/IndexersController.cs b/listenarr.api/Controllers/IndexersController.cs index d419e1628..956bed346 100644 --- a/listenarr.api/Controllers/IndexersController.cs +++ b/listenarr.api/Controllers/IndexersController.cs @@ -551,92 +551,25 @@ public async Task TestDraft([FromBody] Indexer indexer) /// private async Task TestInternetArchive(Indexer indexer, bool persist) { - try + var result = await _indexerTestWorkflow.TestInternetArchiveAsync(indexer, persist); + if (result.Succeeded) { - // Parse collection from AdditionalSettings - string collection = "librivoxaudio"; // Default - if (!string.IsNullOrEmpty(indexer.AdditionalSettings)) - { - try - { - using var doc = JsonDocument.Parse(indexer.AdditionalSettings); - if (doc.RootElement.TryGetProperty("collection", out var collectionProperty)) - { - collection = collectionProperty.GetString() ?? "librivoxaudio"; - } - } - catch (JsonException ex) - { - _logger.LogWarning(ex, "Failed to parse AdditionalSettings for Internet Archive indexer"); - } - } - - // Build test URL with minimal query - var testUrl = $"https://archive.org/advancedsearch.php?q=collection:{collection}&rows=1&output=json"; - - _logger.LogInformation("Testing Internet Archive indexer '{Name}' with collection '{Collection}'", - LogRedaction.SanitizeText(indexer.Name), LogRedaction.SanitizeText(collection)); - - // Make HTTP request - using var response = await SendValidatedAsync( - currentUri => new HttpRequestMessage(HttpMethod.Get, currentUri), - testUrl); - response.EnsureSuccessStatusCode(); - - // Parse JSON response - var content = await response.Content.ReadAsStringAsync(); - using var jsonDoc = JsonDocument.Parse(content); - - // Validate response structure - if (!jsonDoc.RootElement.TryGetProperty("response", out var responseProperty)) - { - throw new InvalidOperationException("Invalid response format: missing 'response' property"); - } - - if (!responseProperty.TryGetProperty("docs", out var docsProperty)) - { - throw new InvalidOperationException("Invalid response format: missing 'docs' property"); - } - - // Update indexer with success - await SaveTestResultAsync(indexer, persist, true, null); - - _logger.LogInformation("Internet Archive indexer '{Name}' test succeeded for collection '{Collection}'", - LogRedaction.SanitizeText(indexer.Name), LogRedaction.SanitizeText(collection)); - return Ok(new { success = true, - message = $"Internet Archive connection successful for collection '{collection}'", - collection = collection, + message = result.Message, + collection = result.Collection, indexer = RedactIndexerForCaller(indexer) }); } - catch (HttpRequestException ex) - { - _logger.LogWarning(ex, "Internet Archive indexer '{Name}' test failed", LogRedaction.SanitizeText(indexer.Name)); - return await BuildIndexerTestBadRequestAsync(indexer, persist, "Internet Archive test failed", ex); - } - catch (TaskCanceledException ex) - { - _logger.LogWarning(ex, "Internet Archive indexer '{Name}' test failed", LogRedaction.SanitizeText(indexer.Name)); - return await BuildIndexerTestBadRequestAsync(indexer, persist, "Internet Archive test failed", ex); - } - catch (JsonException ex) - { - _logger.LogWarning(ex, "Internet Archive indexer '{Name}' test failed", LogRedaction.SanitizeText(indexer.Name)); - return await BuildIndexerTestBadRequestAsync(indexer, persist, "Internet Archive test failed", ex); - } - catch (UriFormatException ex) - { - _logger.LogWarning(ex, "Internet Archive indexer '{Name}' test failed", LogRedaction.SanitizeText(indexer.Name)); - return await BuildIndexerTestBadRequestAsync(indexer, persist, "Internet Archive test failed", ex); - } - catch (InvalidOperationException ex) + + return BadRequest(new { - _logger.LogWarning(ex, "Internet Archive indexer '{Name}' test failed", LogRedaction.SanitizeText(indexer.Name)); - return await BuildIndexerTestBadRequestAsync(indexer, persist, "Internet Archive test failed", ex); - } + success = false, + message = result.Message, + error = result.Error, + indexer = RedactIndexerForCaller(indexer) + }); } /// @@ -644,171 +577,23 @@ private async Task TestInternetArchive(Indexer indexer, bool pers /// private async Task TestMyAnonamouse(Indexer indexer, bool persist) { - try + var result = await _indexerTestWorkflow.TestMyAnonamouseAsync(indexer, persist); + if (result.Succeeded) { - // Parse mam_id from AdditionalSettings - string mamId = string.Empty; - - if (!string.IsNullOrEmpty(indexer.AdditionalSettings)) - { - try - { - using var doc = JsonDocument.Parse(indexer.AdditionalSettings); - if (doc.RootElement.TryGetProperty("mam_id", out var mamIdProperty)) - { - mamId = mamIdProperty.GetString() ?? string.Empty; - } - } - catch (JsonException ex) - { - _logger.LogWarning(ex, "Failed to parse AdditionalSettings for MyAnonamouse indexer"); - } - } - - if (string.IsNullOrEmpty(mamId)) - { - throw new InvalidOperationException("MAM ID is required for MyAnonamouse"); - } - - // Build test URL (mam_id is sent as a cookie) - var testUrl = $"https://www.myanonamouse.net/tor/js/loadSearchJSONbasic.php"; - - _logger.LogInformation("Testing MyAnonamouse indexer '{Name}' with MAM ID '{MamId}'", - LogRedaction.SanitizeText(indexer.Name), LogRedaction.RedactText(mamId, LogRedaction.GetSensitiveValuesFromEnvironment().Concat(new[] { mamId }))); - - // Create request with mam_id as cookie - using var request = new HttpRequestMessage(HttpMethod.Post, testUrl); - - // Add browser-like headers to avoid "invalid request" errors - request.Headers.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"); - request.Headers.Accept.ParseAdd("application/json, text/javascript, */*; q=0.01"); - request.Headers.AcceptLanguage.ParseAdd("en-US,en;q=0.9"); - request.Headers.Referrer = new Uri("https://www.myanonamouse.net/"); - - // Create form data (without mam_id since it's now in the cookie) - var formData = new Dictionary - { - ["tor[text]"] = "test", - ["tor[srchIn][]"] = "title", - ["tor[searchType]"] = "all", - ["tor[searchIn]"] = "torrents", - ["tor[cat][]"] = "0", - ["tor[browseFlagsHideVsShow]"] = "0", - ["tor[startDate]"] = "", - ["tor[endDate]"] = "", - ["tor[hash]"] = "", - ["tor[sortType]"] = "default", - ["tor[startNumber]"] = "0", - ["perpage"] = "1", - ["thumbnail"] = "false", - ["dlLink"] = "", - ["description"] = "" - }; - - var formContent = new FormUrlEncodedContent(formData); - request.Content = formContent; - - // Add mam_id as a cookie for authentication (bind cookie to the indexer's base host) - var cookieContainer = new System.Net.CookieContainer(); - var baseUrl = indexer.Url.TrimEnd('/'); - var baseUri = new Uri(baseUrl.StartsWith("http", StringComparison.OrdinalIgnoreCase) ? baseUrl : "https://" + baseUrl); - cookieContainer.Add(baseUri, new System.Net.Cookie("mam_id", mamId)); - try - { - var host = baseUri.Host; - if (!host.StartsWith("www.", StringComparison.OrdinalIgnoreCase)) - { - var wwwUri = new Uri($"{baseUri.Scheme}://www.{host}"); - cookieContainer.Add(wwwUri, new System.Net.Cookie("mam_id", mamId)); - } - } - catch (UriFormatException ex) - { - _logger.LogDebug(ex, "Failed to add www host alias cookie for MyAnonamouse test request to {Host}", baseUri.Host); - } - catch (System.Net.CookieException ex) - { - _logger.LogDebug(ex, "Failed to add www host alias cookie for MyAnonamouse test request to {Host}", baseUri.Host); - } - - // Create HttpClientHandler with cookies - var handler = new HttpClientHandler - { - CookieContainer = cookieContainer, - UseCookies = true - }; - - using var cookieClient = new HttpClient(handler); - cookieClient.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"); - cookieClient.DefaultRequestHeaders.Accept.ParseAdd("application/json, text/javascript, */*; q=0.01"); - cookieClient.DefaultRequestHeaders.AcceptLanguage.ParseAdd("en-US,en;q=0.9"); - cookieClient.DefaultRequestHeaders.Referrer = new Uri("https://www.myanonamouse.net/"); - - // Make HTTP request - using var response = await cookieClient.SendAsync(request); - response.EnsureSuccessStatusCode(); - - // Parse JSON response - var content = await response.Content.ReadAsStringAsync(); - using var jsonDoc = JsonDocument.Parse(content); - - // Validate response (MyAnonamouse returns JSON with data array) - if (!jsonDoc.RootElement.TryGetProperty("data", out _)) - { - throw new InvalidOperationException("Invalid response format: missing 'data' property"); - } - - // Update indexer with success - await SaveTestResultAsync(indexer, persist, true, null); - - _logger.LogInformation("MyAnonamouse indexer '{Name}' test succeeded with MAM ID '{MamId}'", - LogRedaction.SanitizeText(indexer.Name), LogRedaction.RedactText(mamId, LogRedaction.GetSensitiveValuesFromEnvironment().Concat(new[] { mamId }))); - return Ok(new { success = true, - message = $"MyAnonamouse authentication successful with MAM ID '{mamId}'", - mam_id = RedactMamIdForCaller(mamId), + message = result.Message, + mam_id = RedactMamIdForCaller(result.MamId), indexer = RedactIndexerForCaller(indexer) }); } - catch (HttpRequestException ex) - { - return await BuildMamTestFailureResultAsync(indexer, persist, ex); - } - catch (TaskCanceledException ex) - { - return await BuildMamTestFailureResultAsync(indexer, persist, ex); - } - catch (UriFormatException ex) - { - return await BuildMamTestFailureResultAsync(indexer, persist, ex); - } - catch (System.Net.CookieException ex) - { - return await BuildMamTestFailureResultAsync(indexer, persist, ex); - } - catch (JsonException ex) - { - return await BuildMamTestFailureResultAsync(indexer, persist, ex); - } - catch (InvalidOperationException ex) - { - return await BuildMamTestFailureResultAsync(indexer, persist, ex); - } - } - - private async Task BuildMamTestFailureResultAsync(Indexer indexer, bool persist, Exception ex) - { - await SaveTestResultAsync(indexer, persist, false, ex.Message); - - _logger.LogWarning(ex, "MyAnonamouse indexer '{Name}' test failed", LogRedaction.SanitizeText(indexer.Name)); return BadRequest(new { success = false, - message = "MyAnonamouse test failed", - error = ex.Message, + message = result.Message, + error = result.Error, indexer = RedactIndexerForCaller(indexer) }); } diff --git a/listenarr.api/Controllers/ProwlarrCompatController.cs b/listenarr.api/Controllers/ProwlarrCompatController.cs index 179c10fb5..d74c8b000 100644 --- a/listenarr.api/Controllers/ProwlarrCompatController.cs +++ b/listenarr.api/Controllers/ProwlarrCompatController.cs @@ -54,6 +54,7 @@ private StartupConfig GetStartupConfig() private readonly IToastService _toastService; private readonly IStartupConfigService _startupConfigService; private readonly IApplicationVersionService _applicationVersionService; + private readonly ProwlarrIndexerUpsertWorkflow _indexerUpsertWorkflow; // Suppress update toasts for indexers that were created within this window (in seconds) private const int NotificationSuppressionSeconds = 5; @@ -112,7 +113,8 @@ public ProwlarrCompatController( IRealtimeClientRegistry realtimeClientRegistry, IToastService toastService, IStartupConfigService startupConfigService, - IApplicationVersionService applicationVersionService) + IApplicationVersionService applicationVersionService, + ProwlarrIndexerUpsertWorkflow? indexerUpsertWorkflow = null) { _logger = logger; _indexerRepository = indexerRepository; @@ -121,6 +123,9 @@ public ProwlarrCompatController( _toastService = toastService; _startupConfigService = startupConfigService; _applicationVersionService = applicationVersionService; + _indexerUpsertWorkflow = indexerUpsertWorkflow ?? new ProwlarrIndexerUpsertWorkflow( + indexerRepository, + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); } private string GetApplicationVersion() @@ -433,87 +438,21 @@ public async Task PutIndexer(int id, [FromBody] System.Text.Json. System.Diagnostics.Debug.WriteLine($"ProwlarrCompatController payload logging failed (PUT indexer): {ex.Message}"); } - var indexer = await _indexerRepository.GetByIdAsync(id); - var created = false; - if (indexer == null) - { - var parsed = ProwlarrIndexerPayloadReader.ParseForPut(payload); - var normalized = NormalizeIndexerUrl(parsed.Url); - var allIndexers = await _indexerRepository.GetAllAsync(); - var existing = allIndexers.FirstOrDefault(i => NormalizeIndexerUrl(i.Url) == normalized && (i.ApiKey ?? string.Empty) == (parsed.ApiKey ?? string.Empty)); - if (existing != null) - { - indexer = await _indexerRepository.GetByIdAsync(existing.Id); - } - else - { - // Not found: create new indexer entry from parsed payload (upsert behavior) - indexer = new Indexer - { - Name = parsed.Name, - Implementation = parsed.Implementation, - Url = parsed.Url, - ApiKey = string.IsNullOrEmpty(parsed.ApiKey) ? null : parsed.ApiKey, - Categories = parsed.Categories ?? string.Empty, - CreatedAt = DateTime.UtcNow, - UpdatedAt = DateTime.UtcNow, - IsEnabled = true, - Tags = string.Empty, - AdditionalSettings = string.Empty - }; - - var implLower = (indexer.Implementation ?? string.Empty).ToLowerInvariant(); - indexer.Type = implLower.Contains("newznab") ? "Usenet" : (implLower.Contains("torznab") ? "Torrent" : "Custom"); - - indexer = await _indexerRepository.AddAsync(indexer); - created = true; - - _logger?.LogInformation("Prowlarr: Created indexer (upsert from PUT) (name={Name}, url={Url}, apiKeyPresent={HasApiKey})", indexer.Name, indexer.Url, !string.IsNullOrEmpty(indexer.ApiKey)); - - // NOTE: Do not broadcast notifications here. We will broadcast once at the end of the handler - // after dedupe to avoid duplicate notifications when concurrent PUTs create duplicate entries. - } - } - - // Defensive: indexer should be non-null after upsert logic; if it is still null, return an error instead of throwing - if (indexer == null) - { - _logger?.LogError("Prowlarr: Indexer was null after upsert logic for id={Id}", id); - return StatusCode(500, new { error = "Failed to locate or create indexer" }); - } - if (payload.ValueKind != System.Text.Json.JsonValueKind.Object) { return BadRequest(new { message = "Expected JSON object for indexer update" }); } - var update = ProwlarrIndexerPayloadReader.ParseForPut(payload); - if (!string.IsNullOrEmpty(update.Name)) indexer.Name = update.Name; - if (!string.IsNullOrEmpty(update.Implementation)) indexer.Implementation = update.Implementation; - if (!string.IsNullOrEmpty(update.Url)) indexer.Url = update.Url; - indexer.ApiKey = string.IsNullOrEmpty(update.ApiKey) ? null : update.ApiKey; - indexer.Categories = update.Categories ?? indexer.Categories; - indexer.UpdatedAt = DateTime.UtcNow; - - await _indexerRepository.UpdateAsync(indexer); - - // After saving, ensure we dedupe any other entries with same normalized URL + ApiKey (concurrent upsert safety) - try - { - var normalizedUrl = NormalizeIndexerUrl(indexer.Url); - var apiKeyCompare = indexer.ApiKey ?? string.Empty; - await CleanupDuplicateIndexersAsync(normalizedUrl, apiKeyCompare); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger?.LogWarning(ex, "Failed to dedupe indexers after update for {Id}", indexer.Id); - } + var upsertResult = await _indexerUpsertWorkflow.UpsertFromPutAsync( + id, + ProwlarrIndexerPayloadReader.ParseForPut(payload)); + var indexer = upsertResult.Indexer; + var created = upsertResult.Created; // Notify clients (compute whether the created indexer still exists after dedupe to avoid duplicate notifications) try { - var stillExists = (await _indexerRepository.GetByIdAsync(indexer.Id)) != null; - var createdForBroadcast = (created && stillExists) ? 1 : 0; + var createdForBroadcast = (created && upsertResult.StillExists) ? 1 : 0; await _hubBroadcaster.BroadcastAsync(RealtimeHubTarget.Settings, "IndexersUpdated", new { created = createdForBroadcast, skipped = 0, indexers = new[] { new { id = indexer.Id, name = indexer.Name, baseUrl = indexer.Url } } }); // Determine toast message. If the indexer was created very recently (by a prior POST or PUT), @@ -606,34 +545,6 @@ public async Task PutIndexer(int id, [FromBody] System.Text.Json. } } - // Remove duplicate persisted indexers that share the same normalized URL + ApiKey. - // Keeps the earliest created (lowest Id) and removes the rest. - private async Task CleanupDuplicateIndexersAsync(string normalizedUrl, string apiKey) - { - try - { - var all = await _indexerRepository.GetAllAsync(); - var duplicates = all - .Where(i => NormalizeIndexerUrl(i.Url) == normalizedUrl && (i.ApiKey ?? string.Empty) == apiKey) - .OrderBy(i => i.Id) - .ToList(); - - if (duplicates.Count <= 1) return; - - // Keep the first, remove the rest - var remove = duplicates.Skip(1).ToList(); - - _logger?.LogInformation("Dedupe: Removing {Count} duplicate indexer(s) for url={Url}", remove.Count, normalizedUrl); - - foreach (var r in remove) - await _indexerRepository.DeleteAsync(r.Id); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger?.LogWarning(ex, "Failed to cleanup duplicate indexers for {Url}", normalizedUrl); - } - } - /// /// POST /api/v1/indexers /// Accepts an array of indexers from Prowlarr. Expects a JSON array; returns 200 OK if received. @@ -671,68 +582,20 @@ public async Task PostIndexers([FromBody] System.Text.Json.JsonEl return BadRequest(new { message = "Expected JSON array of indexers" }); } - var created = 0; - var skipped = 0; - var createdIndexers = new List(); - var existingIndexers = await _indexerRepository.GetAllAsync(); - - foreach (var item in payload.EnumerateArray().Where(item => item.ValueKind == System.Text.Json.JsonValueKind.Object)) + var importResult = await _indexerUpsertWorkflow.ImportManyAsync( + payload.EnumerateArray() + .Where(item => item.ValueKind == System.Text.Json.JsonValueKind.Object) + .Select(ProwlarrIndexerPayloadReader.ParseForBulkPost)); + var created = importResult.Created; + var skipped = importResult.Skipped; + var createdIndexers = importResult.CreatedIndexers; + foreach (var createdIndexer in createdIndexers) { - var parsed = ProwlarrIndexerPayloadReader.ParseForBulkPost(item); - - // Deduplicate by normalized URL + ApiKey (normalizes trailing slash and trailing /api) - var normalizedUrl = NormalizeIndexerUrl(parsed.Url); - var exists = existingIndexers.FirstOrDefault(i => NormalizeIndexerUrl(i.Url) == normalizedUrl && (i.ApiKey ?? string.Empty) == (parsed.ApiKey ?? string.Empty)); - if (exists != null) - { - skipped++; - _logger?.LogInformation("Prowlarr: Skipping existing indexer (name={Name}, url={Url}, apiKeyPresent={HasApiKey})", parsed.Name, exists.Url, !string.IsNullOrEmpty(parsed.ApiKey)); - continue; - } - - var indexer = new Indexer - { - Name = parsed.Name, - Implementation = parsed.Implementation, - Url = parsed.Url, - ApiKey = string.IsNullOrEmpty(parsed.ApiKey) ? null : parsed.ApiKey, - Categories = parsed.Categories ?? string.Empty, - Tags = string.Empty, - AdditionalSettings = string.Empty, - CreatedAt = DateTime.UtcNow, - UpdatedAt = DateTime.UtcNow, - IsEnabled = true - }; - - // Guess Type from implementation - var implLower = (parsed.Implementation ?? string.Empty).ToLowerInvariant(); - indexer.Type = implLower.Contains("newznab") ? "Usenet" : (implLower.Contains("torznab") ? "Torrent" : "Custom"); - - indexer = await _indexerRepository.AddAsync(indexer); - existingIndexers.Add(indexer); - created++; - createdIndexers.Add(indexer); - _logger?.LogInformation("Prowlarr: Created indexer (name={Name}, url={Url}, apiKeyPresent={HasApiKey})", indexer.Name, indexer.Url, !string.IsNullOrEmpty(indexer.ApiKey)); + _logger?.LogInformation("Prowlarr: Created indexer (name={Name}, url={Url}, apiKeyPresent={HasApiKey})", createdIndexer.Name, createdIndexer.Url, !string.IsNullOrEmpty(createdIndexer.ApiKey)); } if (created > 0) { - - // Cleanup any duplicates caused by concurrent upserts (dedupe by normalized URL + ApiKey) - foreach (var ci in createdIndexers.ToList()) - { - try - { - var normalizedUrl = NormalizeIndexerUrl(ci.Url); - var apiKey = ci.ApiKey ?? string.Empty; - await CleanupDuplicateIndexersAsync(normalizedUrl, apiKey); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger?.LogWarning(ex, "Failed to dedupe indexers for {Name}", ci.Name); - } - } - // Notify connected clients that indexers changed so the UI can refresh try { @@ -957,33 +820,6 @@ public IActionResult GetIndexersSchema() return GetIndexerSchema(); } - private static string NormalizeIndexerUrl(string url) - { - if (string.IsNullOrWhiteSpace(url)) return string.Empty; - - try - { - var uri = new Uri(url); - var path = uri.AbsolutePath ?? string.Empty; - // Trim trailing slash - path = path.TrimEnd('/'); - - // Remove trailing /api if present - if (path.EndsWith("/api", StringComparison.OrdinalIgnoreCase)) - { - path = path.Substring(0, path.Length - 4); - } - - var port = uri.IsDefaultPort ? string.Empty : ":" + uri.Port; - var normalized = $"{uri.Scheme}://{uri.Host}{port}{path}"; - return normalized.TrimEnd('/'); - } - catch (Exception caughtEx_6) when (caughtEx_6 is not OperationCanceledException && caughtEx_6 is not OutOfMemoryException && caughtEx_6 is not StackOverflowException) - { - return url.TrimEnd('/'); - } - } - // DTOs public record SystemStatusDto { diff --git a/listenarr.api/Controllers/ProwlarrIndexerPayloadReader.cs b/listenarr.api/Controllers/ProwlarrIndexerPayloadReader.cs index 362db9227..4925509ab 100644 --- a/listenarr.api/Controllers/ProwlarrIndexerPayloadReader.cs +++ b/listenarr.api/Controllers/ProwlarrIndexerPayloadReader.cs @@ -214,7 +214,7 @@ private static string GetStringProperty(JsonElement el, string prop1, string? pr } } - internal sealed record ParsedProwlarrIndexerPayload( + public sealed record ParsedProwlarrIndexerPayload( string Name, string Implementation, string BaseUrl, diff --git a/listenarr.api/Controllers/ProwlarrIndexerUpsertWorkflow.cs b/listenarr.api/Controllers/ProwlarrIndexerUpsertWorkflow.cs new file mode 100644 index 000000000..f6ce76f3e --- /dev/null +++ b/listenarr.api/Controllers/ProwlarrIndexerUpsertWorkflow.cs @@ -0,0 +1,195 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +using Listenarr.Application.Interfaces.Repositories; +using Listenarr.Domain.Models; + +namespace Listenarr.Api.Controllers +{ + public sealed class ProwlarrIndexerUpsertWorkflow + { + private readonly IIndexerRepository _indexerRepository; + private readonly ILogger _logger; + + public ProwlarrIndexerUpsertWorkflow( + IIndexerRepository indexerRepository, + ILogger logger) + { + _indexerRepository = indexerRepository; + _logger = logger; + } + + public async Task UpsertFromPutAsync(int id, ParsedProwlarrIndexerPayload parsed) + { + var indexer = await _indexerRepository.GetByIdAsync(id); + var created = false; + + if (indexer == null) + { + indexer = await FindExistingAsync(parsed.Url, parsed.ApiKey); + if (indexer == null) + { + indexer = CreateIndexer(parsed); + indexer = await _indexerRepository.AddAsync(indexer); + created = true; + + _logger.LogInformation( + "Prowlarr: Created indexer (upsert from PUT) (name={Name}, url={Url}, apiKeyPresent={HasApiKey})", + indexer.Name, + indexer.Url, + !string.IsNullOrEmpty(indexer.ApiKey)); + } + } + + ApplyParsedValues(indexer, parsed); + await _indexerRepository.UpdateAsync(indexer); + await CleanupDuplicateIndexersAsync(NormalizeIndexerUrl(indexer.Url), indexer.ApiKey ?? string.Empty); + + var stillExists = (await _indexerRepository.GetByIdAsync(indexer.Id)) != null; + return new ProwlarrIndexerUpsertResult(indexer, created, stillExists); + } + + public async Task ImportManyAsync(IEnumerable payloads) + { + var created = 0; + var skipped = 0; + var createdIndexers = new List(); + var existingIndexers = (await _indexerRepository.GetAllAsync()).ToList(); + + foreach (var parsed in payloads) + { + var normalizedUrl = NormalizeIndexerUrl(parsed.Url); + var exists = existingIndexers.FirstOrDefault(i => NormalizeIndexerUrl(i.Url) == normalizedUrl && (i.ApiKey ?? string.Empty) == (parsed.ApiKey ?? string.Empty)); + if (exists != null) + { + skipped++; + _logger.LogInformation("Prowlarr: Skipping existing indexer (name={Name}, url={Url}, apiKeyPresent={HasApiKey})", parsed.Name, exists.Url, !string.IsNullOrEmpty(parsed.ApiKey)); + continue; + } + + var indexer = CreateIndexer(parsed); + indexer = await _indexerRepository.AddAsync(indexer); + existingIndexers.Add(indexer); + created++; + createdIndexers.Add(indexer); + + _logger.LogInformation("Prowlarr: Created indexer (name={Name}, url={Url}, apiKeyPresent={HasApiKey})", indexer.Name, indexer.Url, !string.IsNullOrEmpty(indexer.ApiKey)); + } + + foreach (var createdIndexer in createdIndexers.ToList()) + { + await CleanupDuplicateIndexersAsync(NormalizeIndexerUrl(createdIndexer.Url), createdIndexer.ApiKey ?? string.Empty); + } + + return new ProwlarrIndexerBulkImportResult(created, skipped, createdIndexers); + } + + private async Task FindExistingAsync(string url, string? apiKey) + { + var normalized = NormalizeIndexerUrl(url); + var allIndexers = await _indexerRepository.GetAllAsync(); + var existing = allIndexers.FirstOrDefault(i => NormalizeIndexerUrl(i.Url) == normalized && (i.ApiKey ?? string.Empty) == (apiKey ?? string.Empty)); + return existing == null ? null : await _indexerRepository.GetByIdAsync(existing.Id); + } + + private static Indexer CreateIndexer(ParsedProwlarrIndexerPayload parsed) + { + var indexer = new Indexer + { + Name = parsed.Name, + Implementation = parsed.Implementation, + Url = parsed.Url, + ApiKey = string.IsNullOrEmpty(parsed.ApiKey) ? null : parsed.ApiKey, + Categories = parsed.Categories ?? string.Empty, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + IsEnabled = true, + Tags = string.Empty, + AdditionalSettings = string.Empty + }; + + indexer.Type = ResolveIndexerType(indexer.Implementation); + return indexer; + } + + private static void ApplyParsedValues(Indexer indexer, ParsedProwlarrIndexerPayload parsed) + { + if (!string.IsNullOrEmpty(parsed.Name)) indexer.Name = parsed.Name; + if (!string.IsNullOrEmpty(parsed.Implementation)) + { + indexer.Implementation = parsed.Implementation; + indexer.Type = ResolveIndexerType(parsed.Implementation); + } + + if (!string.IsNullOrEmpty(parsed.Url)) indexer.Url = parsed.Url; + indexer.ApiKey = string.IsNullOrEmpty(parsed.ApiKey) ? null : parsed.ApiKey; + indexer.Categories = parsed.Categories ?? indexer.Categories; + indexer.UpdatedAt = DateTime.UtcNow; + } + + private async Task CleanupDuplicateIndexersAsync(string normalizedUrl, string apiKey) + { + try + { + var all = await _indexerRepository.GetAllAsync(); + var duplicates = all + .Where(i => NormalizeIndexerUrl(i.Url) == normalizedUrl && (i.ApiKey ?? string.Empty) == apiKey) + .OrderBy(i => i.Id) + .ToList(); + + if (duplicates.Count <= 1) return; + + var remove = duplicates.Skip(1).ToList(); + _logger.LogInformation("Dedupe: Removing {Count} duplicate indexer(s) for url={Url}", remove.Count, normalizedUrl); + + foreach (var duplicate in remove) + { + await _indexerRepository.DeleteAsync(duplicate.Id); + } + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning(ex, "Failed to cleanup duplicate indexers for {Url}", normalizedUrl); + } + } + + private static string NormalizeIndexerUrl(string url) + { + if (string.IsNullOrWhiteSpace(url)) return string.Empty; + + try + { + var uri = new Uri(url); + var path = uri.AbsolutePath.TrimEnd('/'); + if (path.EndsWith("/api", StringComparison.OrdinalIgnoreCase)) + { + path = path.Substring(0, path.Length - 4); + } + + var port = uri.IsDefaultPort ? string.Empty : ":" + uri.Port; + return $"{uri.Scheme}://{uri.Host}{port}{path}".TrimEnd('/'); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + return url.TrimEnd('/'); + } + } + + private static string ResolveIndexerType(string? implementation) + { + var implLower = (implementation ?? string.Empty).ToLowerInvariant(); + return implLower.Contains("newznab") ? "Usenet" : (implLower.Contains("torznab") ? "Torrent" : "Custom"); + } + } + + public sealed record ProwlarrIndexerUpsertResult(Indexer Indexer, bool Created, bool StillExists); + + public sealed record ProwlarrIndexerBulkImportResult(int Created, int Skipped, List CreatedIndexers); +} diff --git a/listenarr.api/Program.cs b/listenarr.api/Program.cs index 14e99289c..60bfced17 100644 --- a/listenarr.api/Program.cs +++ b/listenarr.api/Program.cs @@ -360,7 +360,9 @@ ex is IOException builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); // Register named HttpClients for each adapter type so adapter implementations can request the appropriately-configured client. builder.Services.AddListenarrHttpClients(builder.Configuration); diff --git a/listenarr.application/Search/AudibleSimpleLookupWorkflow.cs b/listenarr.application/Search/AudibleSimpleLookupWorkflow.cs new file mode 100644 index 000000000..8093ab1c9 --- /dev/null +++ b/listenarr.application/Search/AudibleSimpleLookupWorkflow.cs @@ -0,0 +1,132 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +using Listenarr.Application.Metadata; +using Listenarr.Domain.Models; + +namespace Listenarr.Application.Search +{ + public sealed class AudibleSimpleLookupWorkflow + { + private readonly AudibleService _audibleService; + private readonly MetadataConverters _metadataConverters; + + public AudibleSimpleLookupWorkflow(AudibleService audibleService, MetadataConverters metadataConverters) + { + _audibleService = audibleService; + _metadataConverters = metadataConverters; + } + + public async Task?> TrySearchAsync( + string? searchType, + string? isbn, + string? title, + string actualQuery, + string region, + string? language) + { + if (searchType == "ISBN" && !string.IsNullOrEmpty(isbn)) + { + return await SearchByIsbnAsync(isbn, region, language); + } + + if (searchType == "TITLE" && !string.IsNullOrEmpty(title)) + { + return await SearchByTitleAsync(title, region, language); + } + + if (string.IsNullOrWhiteSpace(searchType) && !string.IsNullOrWhiteSpace(actualQuery)) + { + return await SearchBooksAsync(actualQuery, region, language); + } + + return null; + } + + private async Task?> SearchByIsbnAsync(string isbn, string region, string? language) + { + var response = await _audibleService.SearchByIsbnAsync(isbn, 1, 50, region, language); + if (response?.Results == null || !response.Results.Any()) + { + return null; + } + + var converted = new List(); + var filtered = response.Results.AsEnumerable(); + if (!string.IsNullOrWhiteSpace(language)) + { + filtered = filtered.Where(b => !string.IsNullOrWhiteSpace(b.Language) && string.Equals(b.Language, language, StringComparison.OrdinalIgnoreCase)); + } + + foreach (var book in filtered.Where(book => !string.IsNullOrWhiteSpace(book.Asin))) + { + var bookResponse = new AudibleBookResponse + { + Asin = book.Asin, + Title = book.Title, + Subtitle = book.Subtitle, + Authors = book.Authors, + ImageUrl = book.ImageUrl, + Language = book.Language, + BookFormat = book.BookFormat, + Genres = book.Genres, + Series = book.Series, + Publisher = book.Publisher, + Narrators = book.Narrators, + ReleaseDate = book.ReleaseDate, + Isbn = book.Isbn + }; + var metadata = _metadataConverters.ConvertAudibleToMetadata(bookResponse, book.Asin!, "Audible"); + var searchResult = await _metadataConverters.ConvertMetadataToSearchResultAsync(metadata, book.Asin!); + searchResult.IsEnriched = true; + searchResult.MetadataSource = "Audible"; + converted.Add(searchResult); + } + + return converted.Any() ? SearchResultConverters.ToMetadataList(converted) : null; + } + + private async Task?> SearchByTitleAsync(string title, string region, string? language) + { + var response = await _audibleService.SearchByTitleAsync(title, 1, 50, region, language); + if (response?.Results == null || !response.Results.Any()) + { + return null; + } + + var filtered = response.Results.AsEnumerable(); + if (!string.IsNullOrWhiteSpace(language)) + { + filtered = filtered.Where(b => string.IsNullOrWhiteSpace(b.Language) || string.Equals(b.Language, language, StringComparison.OrdinalIgnoreCase)); + } + + var converted = await AudibleSearchResultMapper.ConvertToSearchResultsAsync(filtered, _metadataConverters); + return converted.Any() ? SearchResultConverters.ToMetadataList(converted) : null; + } + + private async Task?> SearchBooksAsync(string query, string region, string? language) + { + var response = await _audibleService.SearchBooksAsync(query, 1, 50, region, language); + if (response?.Results == null || !response.Results.Any()) + { + return null; + } + + var filtered = response.Results.AsEnumerable(); + if (!string.IsNullOrWhiteSpace(language)) + { + filtered = filtered.Where(b => string.IsNullOrWhiteSpace(b.Language) || string.Equals(b.Language, language, StringComparison.OrdinalIgnoreCase)); + } + + var converted = await AudibleSearchResultMapper.ConvertToSearchResultsAsync(filtered, _metadataConverters); + return converted.Any() ? SearchResultConverters.ToMetadataList(converted) : null; + } + } +} diff --git a/listenarr.application/Search/SearchService.cs b/listenarr.application/Search/SearchService.cs index 283005205..30a741a2c 100644 --- a/listenarr.application/Search/SearchService.cs +++ b/listenarr.application/Search/SearchService.cs @@ -44,6 +44,7 @@ public class SearchService : ISearchService private readonly IndexerSearchWorkflow _indexerSearchWorkflow; private readonly MetadataSourceCatalog _metadataSourceCatalog; private readonly AudibleAuthorPageCollector _audibleAuthorPageCollector; + private readonly AudibleSimpleLookupWorkflow _audibleSimpleLookupWorkflow; public SearchService( HttpClient httpClient, @@ -65,7 +66,8 @@ public SearchService( IHtmlTextExtractor? htmlTextExtractor = null, IndexerSearchWorkflow? indexerSearchWorkflow = null, MetadataSourceCatalog? metadataSourceCatalog = null, - AudibleAuthorPageCollector? audibleAuthorPageCollector = null) + AudibleAuthorPageCollector? audibleAuthorPageCollector = null, + AudibleSimpleLookupWorkflow? audibleSimpleLookupWorkflow = null) { _configurationService = configurationService; _logger = logger; @@ -92,6 +94,9 @@ public SearchService( _audibleAuthorPageCollector = audibleAuthorPageCollector ?? new AudibleAuthorPageCollector( audibleService, NullLogger.Instance); + _audibleSimpleLookupWorkflow = audibleSimpleLookupWorkflow ?? new AudibleSimpleLookupWorkflow( + audibleService, + metadataConverters); } public async Task> SearchAsync(string query, string? category = null, List? apiIds = null, SearchSortBy sortBy = SearchSortBy.Seeders, SearchSortDirection sortDirection = SearchSortDirection.Descending, bool isAutomaticSearch = false) @@ -186,41 +191,16 @@ public async Task> IntelligentSearchAsync(string quer { // ASIN case is handled separately above via ASIN handler - // ISBN - if (searchType == "ISBN" && !string.IsNullOrEmpty(isbnVal)) + var simpleAudibleResults = await _audibleSimpleLookupWorkflow.TrySearchAsync( + searchType, + isbnVal, + titleVal, + actualQuery, + region, + language); + if (simpleAudibleResults?.Any() == true) { - var amRes = await _audibleService.SearchByIsbnAsync(isbnVal, 1, 50, region, language); - if (amRes?.Results != null && amRes.Results.Any()) - { - var converted = new List(); - var amFiltered = amRes.Results.AsEnumerable(); - if (!string.IsNullOrWhiteSpace(language)) amFiltered = amFiltered.Where(b => !string.IsNullOrWhiteSpace(b.Language) && string.Equals(b.Language, language, StringComparison.OrdinalIgnoreCase)); - foreach (var book in amFiltered.Where(book => !string.IsNullOrWhiteSpace(book.Asin))) - { - var bookResp = new AudibleBookResponse - { - Asin = book.Asin, - Title = book.Title, - Subtitle = book.Subtitle, - Authors = book.Authors, - ImageUrl = book.ImageUrl, - Language = book.Language, - BookFormat = book.BookFormat, - Genres = book.Genres, - Series = book.Series, - Publisher = book.Publisher, - Narrators = book.Narrators, - ReleaseDate = book.ReleaseDate, - Isbn = book.Isbn - }; - var meta = _metadataConverters.ConvertAudibleToMetadata(bookResp, book.Asin!, "Audible"); - var sr = await _metadataConverters.ConvertMetadataToSearchResultAsync(meta, book.Asin!); - sr.IsEnriched = true; - sr.MetadataSource = "Audible"; - converted.Add(sr); - } - if (converted.Any()) return SearchResultConverters.ToMetadataList(converted); - } + return simpleAudibleResults; } // AUTHOR-only @@ -372,36 +352,6 @@ public async Task> IntelligentSearchAsync(string quer } } - // TITLE-only - if (searchType == "TITLE" && !string.IsNullOrEmpty(titleVal)) - { - var titleRes = await _audibleService.SearchByTitleAsync(titleVal, 1, 50, region, language); - if (titleRes?.Results != null && titleRes.Results.Any()) - { - var titleFiltered = titleRes.Results.AsEnumerable(); - if (!string.IsNullOrWhiteSpace(language)) titleFiltered = titleFiltered.Where(b => string.IsNullOrWhiteSpace(b.Language) || string.Equals(b.Language, language, StringComparison.OrdinalIgnoreCase)); - var converted = await AudibleSearchResultMapper.ConvertToSearchResultsAsync( - titleFiltered, - _metadataConverters); - if (converted.Any()) return SearchResultConverters.ToMetadataList(converted); - } - - } - - // General/simple query - try audible search endpoint first - if (string.IsNullOrWhiteSpace(searchType) && !string.IsNullOrWhiteSpace(actualQuery)) - { - var simpleRes = await _audibleService.SearchBooksAsync(actualQuery, 1, 50, region, language); - if (simpleRes?.Results != null && simpleRes.Results.Any()) - { - var simpleFiltered = simpleRes.Results.AsEnumerable(); - if (!string.IsNullOrWhiteSpace(language)) simpleFiltered = simpleFiltered.Where(b => string.IsNullOrWhiteSpace(b.Language) || string.Equals(b.Language, language, StringComparison.OrdinalIgnoreCase)); - var converted = await AudibleSearchResultMapper.ConvertToSearchResultsAsync( - simpleFiltered, - _metadataConverters); - if (converted.Any()) return SearchResultConverters.ToMetadataList(converted); - } - } } catch (Exception exAudibleFirst) when (exAudibleFirst is not OperationCanceledException && exAudibleFirst is not OutOfMemoryException && exAudibleFirst is not StackOverflowException) { diff --git a/tests/Builders/ServiceCollectionBuilder.cs b/tests/Builders/ServiceCollectionBuilder.cs index 0c2c572b3..9f0614e57 100644 --- a/tests/Builders/ServiceCollectionBuilder.cs +++ b/tests/Builders/ServiceCollectionBuilder.cs @@ -203,7 +203,9 @@ private ServiceCollection BuildServices() services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); From 1ec3f59caac23c823c36893947766d18f4ee4dde Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Sat, 13 Jun 2026 12:42:23 -0400 Subject: [PATCH 23/84] Extract backend workflow slices --- .../Controllers/ImagePathValidator.cs | 50 ++ .../Controllers/ImageResponseBuilder.cs | 98 ++++ listenarr.api/Controllers/ImagesController.cs | 72 +-- .../ManualImportCompanionImporter.cs | 159 +++++++ .../Controllers/ManualImportController.cs | 434 +----------------- .../Controllers/ManualImportPathPlanner.cs | 279 +++++++++++ .../Controllers/MetadataController.cs | 102 +--- .../MetadataLookupResponseCache.cs | 107 +++++ listenarr.api/Program.cs | 3 + .../Search/AudibleAuthorSearchWorkflow.cs | 245 ++++++++++ listenarr.application/Search/SearchService.cs | 172 +------ .../Adapters/QbittorrentAdapter.cs | 182 ++------ .../Adapters/QbittorrentAuthSession.cs | 66 +++ .../Adapters/QbittorrentTorrentAddPlanner.cs | 139 ++++++ .../Adapters/TransmissionAdapter.cs | 336 +------------- .../Adapters/TransmissionRpcClient.cs | 135 ++++++ .../Adapters/TransmissionTorrentAddPlanner.cs | 240 ++++++++++ .../Cache/ImageCachePathResolver.cs | 69 +++ .../Cache/ImageCacheService.cs | 38 +- tests/Builders/ServiceCollectionBuilder.cs | 3 + 20 files changed, 1698 insertions(+), 1231 deletions(-) create mode 100644 listenarr.api/Controllers/ImagePathValidator.cs create mode 100644 listenarr.api/Controllers/ImageResponseBuilder.cs create mode 100644 listenarr.api/Controllers/ManualImportCompanionImporter.cs create mode 100644 listenarr.api/Controllers/ManualImportPathPlanner.cs create mode 100644 listenarr.api/Controllers/MetadataLookupResponseCache.cs create mode 100644 listenarr.application/Search/AudibleAuthorSearchWorkflow.cs create mode 100644 listenarr.infrastructure/Adapters/QbittorrentAuthSession.cs create mode 100644 listenarr.infrastructure/Adapters/QbittorrentTorrentAddPlanner.cs create mode 100644 listenarr.infrastructure/Adapters/TransmissionRpcClient.cs create mode 100644 listenarr.infrastructure/Adapters/TransmissionTorrentAddPlanner.cs create mode 100644 listenarr.infrastructure/Cache/ImageCachePathResolver.cs diff --git a/listenarr.api/Controllers/ImagePathValidator.cs b/listenarr.api/Controllers/ImagePathValidator.cs new file mode 100644 index 000000000..8d0bdef74 --- /dev/null +++ b/listenarr.api/Controllers/ImagePathValidator.cs @@ -0,0 +1,50 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +using Listenarr.Domain.Common; + +namespace Listenarr.Api.Controllers +{ + internal sealed class ImagePathValidator + { + private readonly string _contentRootPath; + + public ImagePathValidator(string contentRootPath) + { + _contentRootPath = contentRootPath; + } + + public string ResolvePathWithOptionalBase(string candidatePath) + { + return FileUtils.CombineWithOptionalBase(_contentRootPath, candidatePath.Trim()); + } + + public bool IsInsidePermittedImageRoot(string fullPath) + { + var candidateFull = Path.GetFullPath(fullPath); + return GetPermittedImageRoots().Any(root => IsSamePathOrInside(candidateFull, root)); + } + + private IEnumerable GetPermittedImageRoots() + { + yield return Path.GetFullPath(FileUtils.CombineRelativePath(_contentRootPath, "cache", "images")); + yield return Path.GetFullPath(FileUtils.CombineRelativePath(_contentRootPath, "config", "cache", "images")); + yield return Path.GetFullPath(FileUtils.CombineRelativePath(_contentRootPath, "wwwroot")); + } + + private static bool IsSamePathOrInside(string candidateFullPath, string rootFullPath) + { + var relativePath = Path.GetRelativePath(rootFullPath, candidateFullPath); + return relativePath == "." || + (!relativePath.StartsWith("..", StringComparison.Ordinal) && + !Path.IsPathRooted(relativePath)); + } + } +} diff --git a/listenarr.api/Controllers/ImageResponseBuilder.cs b/listenarr.api/Controllers/ImageResponseBuilder.cs new file mode 100644 index 000000000..0f1083909 --- /dev/null +++ b/listenarr.api/Controllers/ImageResponseBuilder.cs @@ -0,0 +1,98 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +using Listenarr.Application.Security; +using Microsoft.AspNetCore.Mvc; + +namespace Listenarr.Api.Controllers +{ + internal sealed class ImageResponseBuilder + { + private readonly ImagePlaceholderResolver _placeholderResolver; + private readonly ILogger _logger; + private readonly string _contentRootPath; + + public ImageResponseBuilder(ImagePlaceholderResolver placeholderResolver, ILogger logger, string contentRootPath) + { + _placeholderResolver = placeholderResolver; + _logger = logger; + _contentRootPath = contentRootPath; + } + + public IActionResult CreateCachedImageResult( + IHeaderDictionary headers, + string identifier, + string relativePath, + string fullPath) + { + var extension = Path.GetExtension(fullPath).ToLowerInvariant(); + var contentType = extension switch + { + ".jpg" or ".jpeg" => "image/jpeg", + ".png" => "image/png", + ".gif" => "image/gif", + ".webp" => "image/webp", + ".svg" => "image/svg+xml", + _ => "application/octet-stream" + }; + + _logger.LogInformation("Serving cached image for identifier: {Identifier}, path: {Path}", LogRedaction.SanitizeText(identifier), LogRedaction.SanitizeText(relativePath)); + headers["Cache-Control"] = "private, max-age=3600"; + return new PhysicalFileResult(fullPath, contentType) + { + EnableRangeProcessing = true + }; + } + + public IActionResult CreatePlaceholderResult( + IHeaderDictionary headers, + PathString requestPath, + string logContext, + string? logValue, + string notFoundMessage) + { + try + { + var placeholderPath = _placeholderResolver.ResolvePlaceholderPath(_contentRootPath); + if (!string.IsNullOrWhiteSpace(placeholderPath)) + { + _logger.LogInformation("Serving placeholder image for {LogContext}: {LogValue}", LogRedaction.SanitizeText(logContext), LogRedaction.SanitizeText(logValue)); + headers["Cache-Control"] = "public, max-age=300"; + return new PhysicalFileResult(placeholderPath, "image/svg+xml"); + } + } + catch (Exception ex) when (IsRecoverableImageLookupException(ex)) + { + _logger.LogDebug(ex, "Failed to resolve placeholder for {LogContext}: {LogValue}", LogRedaction.SanitizeText(logContext), LogRedaction.SanitizeText(logValue)); + } + + if (!string.Equals(requestPath.Value, "/placeholder.svg", StringComparison.OrdinalIgnoreCase)) + { + headers["Cache-Control"] = "public, max-age=300"; + return new RedirectResult("/placeholder.svg"); + } + + headers["Cache-Control"] = "public, max-age=300"; + return new NotFoundObjectResult(new { message = notFoundMessage }); + } + + private static bool IsRecoverableImageLookupException(Exception ex) + { + return ex is System.IO.IOException + or UnauthorizedAccessException + or InvalidOperationException + or ArgumentException + or FormatException + or UriFormatException + or System.Net.Http.HttpRequestException + or System.Text.Json.JsonException; + } + } +} diff --git a/listenarr.api/Controllers/ImagesController.cs b/listenarr.api/Controllers/ImagesController.cs index 29cd2f7fc..e9af4e4a5 100644 --- a/listenarr.api/Controllers/ImagesController.cs +++ b/listenarr.api/Controllers/ImagesController.cs @@ -40,6 +40,8 @@ public class ImagesController : ControllerBase private readonly ILogger _logger; private readonly IApplicationPathService _applicationPathService; private readonly ImagePlaceholderResolver _placeholderResolver; + private readonly ImageResponseBuilder _imageResponseBuilder; + private readonly ImagePathValidator _imagePathValidator; private readonly string _effectiveContentRootPath; [ActivatorUtilitiesConstructor] @@ -85,6 +87,8 @@ public ImagesController( _applicationPathService = applicationPathService; _placeholderResolver = placeholderResolver ?? new ImagePlaceholderResolver(Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); _effectiveContentRootPath = applicationPathService.ContentRootPath; + _imageResponseBuilder = new ImageResponseBuilder(_placeholderResolver, _logger, _effectiveContentRootPath); + _imagePathValidator = new ImagePathValidator(_effectiveContentRootPath); } /// @@ -933,23 +937,7 @@ void AddCandidateUrl(string? url, string source) notFoundMessage: "Image file not found"); } - // Determine content type based on file extension - var extension = Path.GetExtension(fullPath).ToLowerInvariant(); - var contentType = extension switch - { - ".jpg" or ".jpeg" => "image/jpeg", - ".png" => "image/png", - ".gif" => "image/gif", - ".webp" => "image/webp", - ".svg" => "image/svg+xml", - _ => "application/octet-stream" - }; - - _logger.LogInformation("Serving cached image for identifier: {Identifier}, path: {Path}", LogRedaction.SanitizeText(identifier), LogRedaction.SanitizeText(relativePath)); - - // Return the image with caching headers - Response.Headers["Cache-Control"] = "private, max-age=3600"; - return PhysicalFile(fullPath, contentType, enableRangeProcessing: true); + return _imageResponseBuilder.CreateCachedImageResult(Response.Headers, identifier!, relativePath, fullPath); } catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) { @@ -1043,53 +1031,17 @@ private static string ResolvePathWithOptionalBase(string? basePath, string candi private bool IsInsidePermittedImageRoot(string fullPath) { - var candidateFull = Path.GetFullPath(fullPath); - return GetPermittedImageRoots().Any(root => IsSamePathOrInside(candidateFull, root)); - } - - private IEnumerable GetPermittedImageRoots() - { - yield return Path.GetFullPath(FileUtils.CombineRelativePath(_effectiveContentRootPath, "cache", "images")); - yield return Path.GetFullPath(FileUtils.CombineRelativePath(_effectiveContentRootPath, "config", "cache", "images")); - yield return Path.GetFullPath(FileUtils.CombineRelativePath(_effectiveContentRootPath, "wwwroot")); - } - - private static bool IsSamePathOrInside(string candidateFullPath, string rootFullPath) - { - var relativePath = Path.GetRelativePath(rootFullPath, candidateFullPath); - return relativePath == "." || - (!relativePath.StartsWith("..", StringComparison.Ordinal) && - !Path.IsPathRooted(relativePath)); + return _imagePathValidator.IsInsidePermittedImageRoot(fullPath); } private IActionResult CreatePlaceholderResult(string logContext, string? logValue, string notFoundMessage) { - try - { - var placeholderPath = _placeholderResolver.ResolvePlaceholderPath(_effectiveContentRootPath); - if (!string.IsNullOrWhiteSpace(placeholderPath)) - { - _logger.LogInformation("Serving placeholder image for {LogContext}: {LogValue}", LogRedaction.SanitizeText(logContext), LogRedaction.SanitizeText(logValue)); - Response.Headers["Cache-Control"] = "public, max-age=300"; - return PhysicalFile(placeholderPath, "image/svg+xml"); - } - } - catch (Exception ex) when (IsRecoverableImageLookupException(ex)) - { - _logger.LogDebug(ex, "Failed to resolve placeholder for {LogContext}: {LogValue}", LogRedaction.SanitizeText(logContext), LogRedaction.SanitizeText(logValue)); - } - - // Fall back to the shared placeholder route before returning JSON 404. This - // keeps image consumers rendering an actual placeholder even when the local - // file cannot be resolved from the current content root. - if (!string.Equals(HttpContext?.Request?.Path.Value, "/placeholder.svg", StringComparison.OrdinalIgnoreCase)) - { - Response.Headers["Cache-Control"] = "public, max-age=300"; - return Redirect("/placeholder.svg"); - } - - Response.Headers["Cache-Control"] = "public, max-age=300"; - return NotFound(new { message = notFoundMessage }); + return _imageResponseBuilder.CreatePlaceholderResult( + Response.Headers, + HttpContext?.Request?.Path ?? PathString.Empty, + logContext, + logValue, + notFoundMessage); } /// diff --git a/listenarr.api/Controllers/ManualImportCompanionImporter.cs b/listenarr.api/Controllers/ManualImportCompanionImporter.cs new file mode 100644 index 000000000..03f1e5e37 --- /dev/null +++ b/listenarr.api/Controllers/ManualImportCompanionImporter.cs @@ -0,0 +1,159 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +using Listenarr.Api.Dtos.ManualImport; +using Listenarr.Application.Interfaces; +using Listenarr.Domain.Common; +using Listenarr.Domain.Models; +using Listenarr.Domain.Models.Enumerations; + +namespace Listenarr.Api.Controllers; + +public sealed class ManualImportCompanionImporter +{ + private readonly IMetadataService _metadataService; + private readonly IFileMover _fileMover; + private readonly ILogger _logger; + + public ManualImportCompanionImporter( + IMetadataService metadataService, + IFileMover fileMover, + ILogger logger) + { + _metadataService = metadataService; + _fileMover = fileMover; + _logger = logger; + } + + public async Task> BuildAudioMatchProfilesAsync(IEnumerable filePaths) + { + return (await Task.WhenAll(filePaths + .Where(path => !string.IsNullOrWhiteSpace(path)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Select(BuildAudioMatchProfileAsync))) + .Where(profile => profile != null) + .Cast() + .ToList(); + } + + public async Task ImportAsync( + FileAction action, + IReadOnlyCollection orderedItems, + IReadOnlyCollection results, + string sourceRootPath, + IReadOnlyCollection selectedAudioProfiles, + HashSet unavailableFilenames, + IEnumerable importBlacklist) + { + var audiobookIds = orderedItems + .Select(item => item.MatchedAudiobookId) + .Distinct() + .ToList(); + + if (audiobookIds.Count != 1) + { + _logger.LogDebug("Skipping companion-file import because the batch contains {Count} audiobook targets", audiobookIds.Count); + return 0; + } + + var destinationRoot = ManualImportPathPlanner.DetermineScanPath(results + .Where(r => r.Success && !string.IsNullOrWhiteSpace(r.DestinationPath)) + .Select(r => r.DestinationPath!) + .ToList()); + + if (string.IsNullOrWhiteSpace(destinationRoot)) + { + _logger.LogDebug("Skipping companion-file import because no destination root could be resolved for {SourceRoot}", sourceRootPath); + return 0; + } + + var selectedSourceFiles = new HashSet( + orderedItems + .Where(item => !string.IsNullOrWhiteSpace(item.FullPath)) + .Select(item => Path.GetFullPath(item.FullPath!)), + StringComparer.OrdinalIgnoreCase); + + var selectedDirectories = selectedSourceFiles + .Select(Path.GetDirectoryName) + .Where(d => !string.IsNullOrWhiteSpace(d)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + var companionFiles = selectedDirectories + .Where(Directory.Exists) + .SelectMany(dir => Directory.EnumerateFiles(dir!, "*", SearchOption.TopDirectoryOnly)) + .Where(file => !FileUtils.IsBlacklistedFile(file, importBlacklist)) + .Select(Path.GetFullPath) + .Where(file => !selectedSourceFiles.Contains(file)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + var importedCount = 0; + foreach (var companionFile in companionFiles) + { + try + { + if (FileUtils.IsAudioFile(companionFile)) + { + var profile = await BuildAudioMatchProfileAsync(companionFile); + if (profile == null || !FileUtils.LikelyMatchesAnyReference(profile, selectedAudioProfiles)) + { + _logger.LogInformation( + "Skipping unmatched audio companion file {FilePath} during manual import because it does not match the selected audiobook batch", + companionFile); + continue; + } + } + + var relativePath = Path.GetRelativePath(sourceRootPath, companionFile); + if (relativePath.StartsWith("..", StringComparison.Ordinal)) + { + continue; + } + + var destinationPath = ManualImportPathPlanner.CombineWithOptionalBase(destinationRoot, relativePath); + + var success = await _fileMover.PerformActionOn(action, companionFile, destinationPath); + if (success) + { + unavailableFilenames.Add(destinationPath); + } + + importedCount++; + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning(ex, "Failed to import companion file {FilePath} during manual import", companionFile); + } + } + + return importedCount; + } + + private async Task BuildAudioMatchProfileAsync(string filePath) + { + if (string.IsNullOrWhiteSpace(filePath)) + { + return null; + } + + AudioMetadata? metadata = null; + try + { + metadata = await _metadataService.ExtractFileMetadataAsync(filePath); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogDebug(ex, "Failed to extract metadata while classifying manual-import companion file {FilePath}", filePath); + } + + return FileUtils.CreateAudioMatchProfile(filePath, metadata); + } +} diff --git a/listenarr.api/Controllers/ManualImportController.cs b/listenarr.api/Controllers/ManualImportController.cs index 37dccd6b9..ad28eb440 100644 --- a/listenarr.api/Controllers/ManualImportController.cs +++ b/listenarr.api/Controllers/ManualImportController.cs @@ -39,6 +39,8 @@ public class ManualImportController : ControllerBase private readonly IScanQueueService _scanQueueService; private readonly IRootFolderService _rootFolderService; private readonly IFileMover _fileMover; + private readonly ManualImportPathPlanner _pathPlanner; + private readonly ManualImportCompanionImporter _companionImporter; public ManualImportController( ILogger logger, @@ -48,7 +50,9 @@ public ManualImportController( IConfigurationService configService, IScanQueueService scanQueueService, IRootFolderService rootFolderService, - IFileMover fileMover) + IFileMover fileMover, + ManualImportPathPlanner? pathPlanner = null, + ManualImportCompanionImporter? companionImporter = null) { _logger = logger; _audiobookRepository = audiobookRepository; @@ -58,6 +62,11 @@ public ManualImportController( _scanQueueService = scanQueueService; _rootFolderService = rootFolderService; _fileMover = fileMover; + _pathPlanner = pathPlanner ?? new ManualImportPathPlanner(fileNamingService); + _companionImporter = companionImporter ?? new ManualImportCompanionImporter( + metadataService, + fileMover, + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); } /// @@ -150,9 +159,9 @@ public async Task> Start([FromBody] ManualImportRequestDto var rootFolders = await _rootFolderService.GetAllAsync(); var appSettings = await _configService.GetApplicationSettingsAsync(); var importBlacklist = appSettings.ImportBlacklistExtensions; - var orderedItems = BuildOrderedItems(request.Items); + var orderedItems = ManualImportPathPlanner.BuildOrderedItems(request.Items); var selectedAudioProfiles = request.IncludeCompanionFiles - ? await BuildAudioMatchProfilesAsync( + ? await _companionImporter.BuildAudioMatchProfilesAsync( orderedItems .Where(item => !string.IsNullOrWhiteSpace(item.FullPath)) .Select(item => item.FullPath!) @@ -172,8 +181,8 @@ public async Task> Start([FromBody] ManualImportRequestDto if (request.IncludeCompanionFiles && request.Action != FileAction.None) { - var companionImportCount = await ImportCompanionFilesAsync( - request, + var companionImportCount = await _companionImporter.ImportAsync( + request.Action, orderedItems, results, sourceDirectory, @@ -276,7 +285,7 @@ private async Task ImportFileAsync( var destinationPath = item.FullPath; // Generate destination path using appropriate naming pattern - destinationPath = await GenerateManualImportPathAsync(audiobook, metadata, item, rootFolders, settings, hasMultipleFile); + destinationPath = await _pathPlanner.GeneratePathAsync(audiobook, metadata, item, rootFolders, settings, hasMultipleFile); var success = await _fileMover.PerformActionOn(action, item.FullPath, destinationPath); if (success) @@ -321,7 +330,7 @@ private async Task EnqueueFocusedScansAsync(IEnumerable r foreach (var group in groupedResults) { - var scanPath = DetermineScanPath(group + var scanPath = ManualImportPathPlanner.DetermineScanPath(group .Select(r => r.DestinationPath!) .Where(p => !string.IsNullOrWhiteSpace(p)) .ToList()); @@ -390,417 +399,6 @@ private async Task PersistAudiobookBasePathAsync(Audiobook audiobook, string? ba } } - private static string? DetermineScanPath(IReadOnlyList destinationPaths) - { - return FileUtils.GetCommonDirectory(destinationPaths); - } - - /// - /// Generate the path where the file should be imported - /// - /// Audiobook related to the imported file - /// Metadata related to the imported file - /// File to import into the library - /// Previously fetched list of configured root folders (to save DB hits) - /// Application settings (to save DB hits) - /// Does the original import operation contained multiple files for this audiobook ? - /// Path where we should put the file - private async Task GenerateManualImportPathAsync(Audiobook audiobook, AudioMetadata metadata, ManualImportItemDto item, List rootFolders, ApplicationSettings settings, bool isMultiFile = false) - { - var sourceFilePath = item.FullPath ?? string.Empty; - // Get the configured folder/file naming patterns from settings - var folderPattern = settings.FolderNamingPattern; - var filePattern = isMultiFile ? settings.MultiFileNamingPattern : settings.FileNamingPattern; - - // If a custom BasePath is set (different from configured OutputPath AND not a known - // root folder), store directly under that path using file-only naming. - // If BasePath IS a configured root folder, treat it as a library destination and - // apply the full folder+file naming pattern so files are properly organised. - var basePath = string.IsNullOrWhiteSpace(audiobook.BasePath) - ? string.Empty - : FileUtils.NormalizeStoredPath(audiobook.BasePath); - var configuredOutput = settings.OutputPath ?? string.Empty; - var isCustomBasePath = false; - try - { - if (!string.IsNullOrWhiteSpace(basePath)) - { - var baseFull = FileUtils.NormalizeStoredPath(basePath); - var configuredFull = string.IsNullOrWhiteSpace(configuredOutput) ? string.Empty : Path.GetFullPath(configuredOutput); - isCustomBasePath = !string.Equals(baseFull, configuredFull, StringComparison.OrdinalIgnoreCase); - - // Even if it differs from OutputPath, don't treat it as custom when it - // matches a configured root folder — those are all valid library destinations. - if (isCustomBasePath) - { - var isRootFolder = rootFolders.Any(r => - { - try { return string.Equals(FileUtils.NormalizeStoredPath(r.Path), baseFull, StringComparison.OrdinalIgnoreCase); } - catch (ArgumentException) { return false; } - catch (NotSupportedException) { return false; } - catch (PathTooLongException) { return false; } - }); - if (isRootFolder) isCustomBasePath = false; - } - } - } - catch (Exception caughtEx_1) when (caughtEx_1 is not OperationCanceledException && caughtEx_1 is not OutOfMemoryException && caughtEx_1 is not StackOverflowException) - { - isCustomBasePath = !string.IsNullOrWhiteSpace(basePath) && !string.IsNullOrWhiteSpace(configuredOutput) - && !string.Equals(basePath, configuredOutput, StringComparison.OrdinalIgnoreCase); - } - - // Get the file extension from the source file (preserve original extension) - var extension = Path.GetExtension(sourceFilePath).ToLowerInvariant(); - if (string.IsNullOrEmpty(extension)) - { - extension = ".m4b"; // Fallback if no extension - } - - // Build variables for the pattern - only include non-empty values - var variables = new Dictionary(); - - // Get first author from Authors list - var author = audiobook.Authors?.FirstOrDefault(); - if (!string.IsNullOrWhiteSpace(author)) - variables["Author"] = author; - - var narrator = audiobook.Narrators != null - ? string.Join(", ", audiobook.Narrators.Where(n => !string.IsNullOrWhiteSpace(n))) - : string.Empty; - if (!string.IsNullOrWhiteSpace(narrator)) - variables["Narrator"] = narrator; - - if (!string.IsNullOrWhiteSpace(audiobook.Publisher)) - variables["Publisher"] = audiobook.Publisher; - - if (!string.IsNullOrWhiteSpace(audiobook.Language)) - variables["Language"] = audiobook.Language; - - if (!string.IsNullOrWhiteSpace(audiobook.Asin)) - variables["Asin"] = audiobook.Asin; - - if (!string.IsNullOrWhiteSpace(audiobook.Subtitle)) - variables["Subtitle"] = audiobook.Subtitle; - - if (!string.IsNullOrWhiteSpace(audiobook.Edition)) - variables["Edition"] = audiobook.Edition; - - // Preserve the older title+subtitle uniqueness behavior unless the user explicitly uses {Subtitle}. - // (e.g. "The Land" + "Founding" → "The Land: Founding") - var usesSubtitleToken = (!string.IsNullOrWhiteSpace(folderPattern) && folderPattern.IndexOf("Subtitle", StringComparison.OrdinalIgnoreCase) >= 0) - || (!string.IsNullOrWhiteSpace(filePattern) && filePattern.IndexOf("Subtitle", StringComparison.OrdinalIgnoreCase) >= 0); - - var titleFull = !usesSubtitleToken - && !string.IsNullOrWhiteSpace(audiobook.Subtitle) - && !string.IsNullOrWhiteSpace(audiobook.Title) - && !audiobook.Title.Contains(audiobook.Subtitle, StringComparison.OrdinalIgnoreCase) - ? $"{audiobook.Title}: {audiobook.Subtitle}" - : audiobook.Title; - variables["Title"] = !string.IsNullOrWhiteSpace(titleFull) - ? titleFull - : "Unknown Title"; // Title is required as fallback - - if (!string.IsNullOrWhiteSpace(audiobook.Series)) - variables["Series"] = audiobook.Series; - - if (!string.IsNullOrWhiteSpace(audiobook.PublishYear)) - variables["Year"] = audiobook.PublishYear; - - var effectiveDiskNumber = item.DiskNumberHint - ?? (metadata.DiscNumber.HasValue && metadata.DiscNumber.Value > 0 ? metadata.DiscNumber.Value : null); - var effectiveChapterNumber = item.ChapterNumberHint - ?? (metadata.TrackNumber.HasValue && metadata.TrackNumber.Value > 0 ? metadata.TrackNumber.Value : null); - - if (isMultiFile) - { - effectiveDiskNumber ??= effectiveChapterNumber; - effectiveChapterNumber ??= effectiveDiskNumber; - } - - if (effectiveDiskNumber.HasValue && effectiveDiskNumber.Value > 0) - variables["DiskNumber"] = effectiveDiskNumber.Value; - - if (effectiveChapterNumber.HasValue && effectiveChapterNumber.Value > 0) - variables["ChapterNumber"] = effectiveChapterNumber.Value; - - var stableSuffixNumber = effectiveChapterNumber ?? effectiveDiskNumber ?? item.SequenceNumberHint; - - string relativePath; - var patternHasNumberTokens = !string.IsNullOrWhiteSpace(filePattern) - && (filePattern.IndexOf("DiskNumber", StringComparison.OrdinalIgnoreCase) >= 0 - || filePattern.IndexOf("ChapterNumber", StringComparison.OrdinalIgnoreCase) >= 0); - - if (string.IsNullOrWhiteSpace(folderPattern)) - { - // Legacy behavior: use FileNamingPattern as the full relative path pattern - var legacyPattern = string.IsNullOrWhiteSpace(filePattern) - ? "{Author}/{Title}/{Title}" - : filePattern; - - relativePath = _fileNamingService.ApplyNamingPattern(legacyPattern, variables, treatAsFilename: false); - } - else if (isCustomBasePath) - { - // Custom base path: only apply file naming pattern, not folder pattern - // (the BasePath already represents the folder location) - var effectiveFilePattern = string.IsNullOrWhiteSpace(filePattern) ? "{Title}" : filePattern; - - var patternAllowsSubfolders = effectiveFilePattern.IndexOf("DiskNumber", StringComparison.OrdinalIgnoreCase) >= 0 - || effectiveFilePattern.IndexOf("ChapterNumber", StringComparison.OrdinalIgnoreCase) >= 0 - || effectiveFilePattern.IndexOf('/') >= 0 - || effectiveFilePattern.IndexOf('\\') >= 0; - - relativePath = _fileNamingService.ApplyNamingPattern(effectiveFilePattern, variables, treatAsFilename: !patternAllowsSubfolders); - } - else - { - // New behavior: separate folder and file patterns - var effectiveFilePattern = string.IsNullOrWhiteSpace(filePattern) ? "{Title}" : filePattern; - - var folderRelative = _fileNamingService.ApplyNamingPattern(folderPattern, variables, treatAsFilename: false); - - var patternAllowsSubfolders = effectiveFilePattern.IndexOf("DiskNumber", StringComparison.OrdinalIgnoreCase) >= 0 - || effectiveFilePattern.IndexOf("ChapterNumber", StringComparison.OrdinalIgnoreCase) >= 0 - || effectiveFilePattern.IndexOf('/') >= 0 - || effectiveFilePattern.IndexOf('\\') >= 0; - - var fileRelative = _fileNamingService.ApplyNamingPattern(effectiveFilePattern, variables, treatAsFilename: !patternAllowsSubfolders); - - if (isMultiFile && !patternHasNumberTokens && stableSuffixNumber.HasValue) - fileRelative = FileUtils.AppendSequenceSuffix(fileRelative, stableSuffixNumber.Value); - - relativePath = string.IsNullOrWhiteSpace(folderRelative) - ? fileRelative - : CombineWithOptionalBase(folderRelative, fileRelative); - } - - if ((string.IsNullOrWhiteSpace(folderPattern) || isCustomBasePath) - && isMultiFile - && !patternHasNumberTokens - && stableSuffixNumber.HasValue) - { - relativePath = FileUtils.AppendSequenceSuffix(relativePath, stableSuffixNumber.Value); - } - - // Ensure it has the correct extension - if (!relativePath.EndsWith(extension, StringComparison.OrdinalIgnoreCase)) - { - relativePath += extension; - } - - return string.IsNullOrWhiteSpace(basePath) - ? relativePath - : CombineWithOptionalBase(basePath, relativePath); - } - - private static string CombineWithOptionalBase(string? basePath, string candidatePath) - { - var normalizedPath = candidatePath.Trim(); - - if (string.IsNullOrEmpty(normalizedPath)) - { - return normalizedPath; - } - - if (Path.IsPathRooted(normalizedPath) || string.IsNullOrWhiteSpace(basePath)) - { - return normalizedPath; - } - - var relativePath = normalizedPath.TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - if (Path.IsPathRooted(relativePath)) - { - return relativePath; - } - - var normalizedBasePath = basePath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - return string.IsNullOrEmpty(normalizedBasePath) - ? relativePath - : normalizedBasePath + Path.DirectorySeparatorChar + relativePath; - } - - private static List BuildOrderedItems(IEnumerable items) - { - var ordered = new List(); - - foreach (var validItems in items.GroupBy(i => i.MatchedAudiobookId).Select(g => g.Where(i => !string.IsNullOrWhiteSpace(i.FullPath)).ToList())) - { - if (validItems.Count == 0) - { - continue; - } - - var plans = MultiFileImportPlanner.BuildPlans(validItems.Select(i => (i.FullPath!, string.IsNullOrWhiteSpace(i.RelativePath) ? null : i.RelativePath))); - var itemLookup = validItems.ToDictionary(i => i.FullPath!, StringComparer.OrdinalIgnoreCase); - var diskNumbersForNaming = MultiFileImportPlanner.BuildStableNamingNumbers(plans, p => p.DiskNumberHint); - var chapterNumbersForNaming = MultiFileImportPlanner.BuildStableNamingNumbers(plans, p => p.ChapterNumberHint); - - ordered.AddRange(plans - .Select(plan => - { - if (!itemLookup.TryGetValue(plan.FullPath, out var item)) - { - return null; - } - - item.SequenceNumberHint = plan.SequenceNumber; - item.DiskNumberHint = diskNumbersForNaming.TryGetValue(plan.FullPath, out var diskNumber) ? diskNumber : plan.DiskNumberHint; - item.ChapterNumberHint = chapterNumbersForNaming.TryGetValue(plan.FullPath, out var chapterNumber) ? chapterNumber : plan.ChapterNumberHint; - return item; - }) - .Where(item => item != null)! - .Cast()); - } - - foreach (var invalidItem in items.Where(i => string.IsNullOrWhiteSpace(i.FullPath))) - { - ordered.Add(invalidItem); - } - - return ordered; - } - - /// - /// Allows to copy files that are contained in a directory from which we already imported files - /// - /// - /// - /// - /// - /// - /// Filenames that have already been reserved for operations from this batch - /// - /// - private async Task ImportCompanionFilesAsync( - ManualImportRequestDto request, - IReadOnlyCollection orderedItems, - IReadOnlyCollection results, - string sourceRootPath, - IReadOnlyCollection selectedAudioProfiles, - HashSet unavailableFilenames, - IEnumerable importBlacklist) - { - var audiobookIds = orderedItems - .Select(item => item.MatchedAudiobookId) - .Distinct() - .ToList(); - - if (audiobookIds.Count != 1) - { - _logger.LogDebug("Skipping companion-file import because the batch contains {Count} audiobook targets", audiobookIds.Count); - return 0; - } - - var destinationRoot = DetermineScanPath(results - .Where(r => r.Success && !string.IsNullOrWhiteSpace(r.DestinationPath)) - .Select(r => r.DestinationPath!) - .ToList()); - - if (string.IsNullOrWhiteSpace(destinationRoot)) - { - _logger.LogDebug("Skipping companion-file import because no destination root could be resolved for {SourceRoot}", sourceRootPath); - return 0; - } - - var selectedSourceFiles = new HashSet( - orderedItems - .Where(item => !string.IsNullOrWhiteSpace(item.FullPath)) - .Select(item => Path.GetFullPath(item.FullPath!)), - StringComparer.OrdinalIgnoreCase); - - // Only scan for companion files in directories that actually contain - // the selected import files. Previously, the entire sourceRootPath was - // scanned recursively which could copy unrelated files when the source - // root is a broad directory like a general downloads folder. - var selectedDirectories = selectedSourceFiles - .Select(f => Path.GetDirectoryName(f)) - .Where(d => !string.IsNullOrWhiteSpace(d)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToList(); - - var companionFiles = selectedDirectories - .Where(Directory.Exists) - .SelectMany(dir => Directory.EnumerateFiles(dir!, "*", SearchOption.TopDirectoryOnly)) - .Where(file => !FileUtils.IsBlacklistedFile(file, importBlacklist)) - .Select(Path.GetFullPath) - .Where(file => !selectedSourceFiles.Contains(file)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToList(); - - var importedCount = 0; - foreach (var companionFile in companionFiles) - { - try - { - if (FileUtils.IsAudioFile(companionFile)) - { - var profile = await BuildAudioMatchProfileAsync(companionFile); - if (profile == null || !FileUtils.LikelyMatchesAnyReference(profile, selectedAudioProfiles)) - { - _logger.LogInformation( - "Skipping unmatched audio companion file {FilePath} during manual import because it does not match the selected audiobook batch", - companionFile); - continue; - } - } - - var relativePath = Path.GetRelativePath(sourceRootPath, companionFile); - if (relativePath.StartsWith("..", StringComparison.Ordinal)) - { - continue; - } - - var destinationPath = CombineWithOptionalBase(destinationRoot, relativePath); - - var success = await _fileMover.PerformActionOn(request.Action, companionFile, destinationPath); - if (success) - { - unavailableFilenames.Add(destinationPath); - } - - importedCount++; - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Failed to import companion file {FilePath} during manual import", companionFile); - } - } - - return importedCount; - } - - private async Task> BuildAudioMatchProfilesAsync(IEnumerable filePaths) - { - return (await Task.WhenAll(filePaths - .Where(path => !string.IsNullOrWhiteSpace(path)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .Select(BuildAudioMatchProfileAsync))) - .Where(profile => profile != null) - .Cast() - .ToList(); - } - - private async Task BuildAudioMatchProfileAsync(string filePath) - { - if (string.IsNullOrWhiteSpace(filePath)) - { - return null; - } - - AudioMetadata? metadata = null; - try - { - metadata = await _metadataService.ExtractFileMetadataAsync(filePath); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogDebug(ex, "Failed to extract metadata while classifying manual-import companion file {FilePath}", filePath); - } - - return FileUtils.CreateAudioMatchProfile(filePath, metadata); - } - private static string FormatSize(long bytes) { if (bytes < 1024) return $"{bytes} B"; diff --git a/listenarr.api/Controllers/ManualImportPathPlanner.cs b/listenarr.api/Controllers/ManualImportPathPlanner.cs new file mode 100644 index 000000000..7aa2c60ec --- /dev/null +++ b/listenarr.api/Controllers/ManualImportPathPlanner.cs @@ -0,0 +1,279 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +using Listenarr.Api.Dtos.ManualImport; +using Listenarr.Application.Interfaces; +using Listenarr.Domain.Common; +using Listenarr.Domain.Models; +using Listenarr.Domain.Models.Configurations; + +namespace Listenarr.Api.Controllers; + +public sealed class ManualImportPathPlanner +{ + private readonly IFileNamingService _fileNamingService; + + public ManualImportPathPlanner(IFileNamingService fileNamingService) + { + _fileNamingService = fileNamingService; + } + + public static string? DetermineScanPath(IReadOnlyList destinationPaths) + { + return FileUtils.GetCommonDirectory(destinationPaths); + } + + public async Task GeneratePathAsync( + Audiobook audiobook, + AudioMetadata metadata, + ManualImportItemDto item, + List rootFolders, + ApplicationSettings settings, + bool isMultiFile = false) + { + await Task.CompletedTask; + + var sourceFilePath = item.FullPath ?? string.Empty; + var folderPattern = settings.FolderNamingPattern; + var filePattern = isMultiFile ? settings.MultiFileNamingPattern : settings.FileNamingPattern; + + var basePath = string.IsNullOrWhiteSpace(audiobook.BasePath) + ? string.Empty + : FileUtils.NormalizeStoredPath(audiobook.BasePath); + var configuredOutput = settings.OutputPath ?? string.Empty; + var isCustomBasePath = IsCustomBasePath(basePath, configuredOutput, rootFolders); + + var extension = Path.GetExtension(sourceFilePath).ToLowerInvariant(); + if (string.IsNullOrEmpty(extension)) + { + extension = ".m4b"; + } + + var variables = BuildNamingVariables(audiobook, metadata, item, folderPattern, filePattern, isMultiFile, out var stableSuffixNumber); + + string relativePath; + var patternHasNumberTokens = !string.IsNullOrWhiteSpace(filePattern) + && (filePattern.IndexOf("DiskNumber", StringComparison.OrdinalIgnoreCase) >= 0 + || filePattern.IndexOf("ChapterNumber", StringComparison.OrdinalIgnoreCase) >= 0); + + if (string.IsNullOrWhiteSpace(folderPattern)) + { + var legacyPattern = string.IsNullOrWhiteSpace(filePattern) + ? "{Author}/{Title}/{Title}" + : filePattern; + + relativePath = _fileNamingService.ApplyNamingPattern(legacyPattern, variables, treatAsFilename: false); + } + else if (isCustomBasePath) + { + var effectiveFilePattern = string.IsNullOrWhiteSpace(filePattern) ? "{Title}" : filePattern; + var patternAllowsSubfolders = PatternAllowsSubfolders(effectiveFilePattern); + + relativePath = _fileNamingService.ApplyNamingPattern(effectiveFilePattern, variables, treatAsFilename: !patternAllowsSubfolders); + } + else + { + var effectiveFilePattern = string.IsNullOrWhiteSpace(filePattern) ? "{Title}" : filePattern; + var folderRelative = _fileNamingService.ApplyNamingPattern(folderPattern, variables, treatAsFilename: false); + var patternAllowsSubfolders = PatternAllowsSubfolders(effectiveFilePattern); + var fileRelative = _fileNamingService.ApplyNamingPattern(effectiveFilePattern, variables, treatAsFilename: !patternAllowsSubfolders); + + if (isMultiFile && !patternHasNumberTokens && stableSuffixNumber.HasValue) + fileRelative = FileUtils.AppendSequenceSuffix(fileRelative, stableSuffixNumber.Value); + + relativePath = string.IsNullOrWhiteSpace(folderRelative) + ? fileRelative + : CombineWithOptionalBase(folderRelative, fileRelative); + } + + if ((string.IsNullOrWhiteSpace(folderPattern) || isCustomBasePath) + && isMultiFile + && !patternHasNumberTokens + && stableSuffixNumber.HasValue) + { + relativePath = FileUtils.AppendSequenceSuffix(relativePath, stableSuffixNumber.Value); + } + + if (!relativePath.EndsWith(extension, StringComparison.OrdinalIgnoreCase)) + { + relativePath += extension; + } + + return string.IsNullOrWhiteSpace(basePath) + ? relativePath + : CombineWithOptionalBase(basePath, relativePath); + } + + public static string CombineWithOptionalBase(string? basePath, string candidatePath) + { + var normalizedPath = candidatePath.Trim(); + + if (string.IsNullOrEmpty(normalizedPath)) + { + return normalizedPath; + } + + if (Path.IsPathRooted(normalizedPath) || string.IsNullOrWhiteSpace(basePath)) + { + return normalizedPath; + } + + var relativePath = normalizedPath.TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + if (Path.IsPathRooted(relativePath)) + { + return relativePath; + } + + var normalizedBasePath = basePath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + return string.IsNullOrEmpty(normalizedBasePath) + ? relativePath + : normalizedBasePath + Path.DirectorySeparatorChar + relativePath; + } + + public static List BuildOrderedItems(IEnumerable items) + { + var ordered = new List(); + + foreach (var validItems in items.GroupBy(i => i.MatchedAudiobookId).Select(g => g.Where(i => !string.IsNullOrWhiteSpace(i.FullPath)).ToList())) + { + if (validItems.Count == 0) + { + continue; + } + + var plans = MultiFileImportPlanner.BuildPlans(validItems.Select(i => (i.FullPath!, string.IsNullOrWhiteSpace(i.RelativePath) ? null : i.RelativePath))); + var itemLookup = validItems.ToDictionary(i => i.FullPath!, StringComparer.OrdinalIgnoreCase); + var diskNumbersForNaming = MultiFileImportPlanner.BuildStableNamingNumbers(plans, p => p.DiskNumberHint); + var chapterNumbersForNaming = MultiFileImportPlanner.BuildStableNamingNumbers(plans, p => p.ChapterNumberHint); + + ordered.AddRange(plans + .Select(plan => + { + if (!itemLookup.TryGetValue(plan.FullPath, out var item)) + { + return null; + } + + item.SequenceNumberHint = plan.SequenceNumber; + item.DiskNumberHint = diskNumbersForNaming.TryGetValue(plan.FullPath, out var diskNumber) ? diskNumber : plan.DiskNumberHint; + item.ChapterNumberHint = chapterNumbersForNaming.TryGetValue(plan.FullPath, out var chapterNumber) ? chapterNumber : plan.ChapterNumberHint; + return item; + }) + .Where(item => item != null)! + .Cast()); + } + + foreach (var invalidItem in items.Where(i => string.IsNullOrWhiteSpace(i.FullPath))) + { + ordered.Add(invalidItem); + } + + return ordered; + } + + private static bool IsCustomBasePath(string basePath, string configuredOutput, List rootFolders) + { + try + { + if (string.IsNullOrWhiteSpace(basePath)) + { + return false; + } + + var baseFull = FileUtils.NormalizeStoredPath(basePath); + var configuredFull = string.IsNullOrWhiteSpace(configuredOutput) ? string.Empty : Path.GetFullPath(configuredOutput); + var isCustomBasePath = !string.Equals(baseFull, configuredFull, StringComparison.OrdinalIgnoreCase); + + if (isCustomBasePath) + { + var isRootFolder = rootFolders.Any(r => + { + try { return string.Equals(FileUtils.NormalizeStoredPath(r.Path), baseFull, StringComparison.OrdinalIgnoreCase); } + catch (ArgumentException) { return false; } + catch (NotSupportedException) { return false; } + catch (PathTooLongException) { return false; } + }); + if (isRootFolder) isCustomBasePath = false; + } + + return isCustomBasePath; + } + catch (Exception caughtEx) when (caughtEx is not OperationCanceledException && caughtEx is not OutOfMemoryException && caughtEx is not StackOverflowException) + { + return !string.IsNullOrWhiteSpace(basePath) && !string.IsNullOrWhiteSpace(configuredOutput) + && !string.Equals(basePath, configuredOutput, StringComparison.OrdinalIgnoreCase); + } + } + + private static Dictionary BuildNamingVariables( + Audiobook audiobook, + AudioMetadata metadata, + ManualImportItemDto item, + string? folderPattern, + string? filePattern, + bool isMultiFile, + out int? stableSuffixNumber) + { + var variables = new Dictionary(); + + var author = audiobook.Authors?.FirstOrDefault(); + if (!string.IsNullOrWhiteSpace(author)) variables["Author"] = author; + + var narrator = audiobook.Narrators != null + ? string.Join(", ", audiobook.Narrators.Where(n => !string.IsNullOrWhiteSpace(n))) + : string.Empty; + if (!string.IsNullOrWhiteSpace(narrator)) variables["Narrator"] = narrator; + + if (!string.IsNullOrWhiteSpace(audiobook.Publisher)) variables["Publisher"] = audiobook.Publisher; + if (!string.IsNullOrWhiteSpace(audiobook.Language)) variables["Language"] = audiobook.Language; + if (!string.IsNullOrWhiteSpace(audiobook.Asin)) variables["Asin"] = audiobook.Asin; + if (!string.IsNullOrWhiteSpace(audiobook.Subtitle)) variables["Subtitle"] = audiobook.Subtitle; + if (!string.IsNullOrWhiteSpace(audiobook.Edition)) variables["Edition"] = audiobook.Edition; + + var usesSubtitleToken = (!string.IsNullOrWhiteSpace(folderPattern) && folderPattern.IndexOf("Subtitle", StringComparison.OrdinalIgnoreCase) >= 0) + || (!string.IsNullOrWhiteSpace(filePattern) && filePattern.IndexOf("Subtitle", StringComparison.OrdinalIgnoreCase) >= 0); + + var titleFull = !usesSubtitleToken + && !string.IsNullOrWhiteSpace(audiobook.Subtitle) + && !string.IsNullOrWhiteSpace(audiobook.Title) + && !audiobook.Title.Contains(audiobook.Subtitle, StringComparison.OrdinalIgnoreCase) + ? $"{audiobook.Title}: {audiobook.Subtitle}" + : audiobook.Title; + variables["Title"] = !string.IsNullOrWhiteSpace(titleFull) ? titleFull : "Unknown Title"; + + if (!string.IsNullOrWhiteSpace(audiobook.Series)) variables["Series"] = audiobook.Series; + if (!string.IsNullOrWhiteSpace(audiobook.PublishYear)) variables["Year"] = audiobook.PublishYear; + + var effectiveDiskNumber = item.DiskNumberHint + ?? (metadata.DiscNumber.HasValue && metadata.DiscNumber.Value > 0 ? metadata.DiscNumber.Value : null); + var effectiveChapterNumber = item.ChapterNumberHint + ?? (metadata.TrackNumber.HasValue && metadata.TrackNumber.Value > 0 ? metadata.TrackNumber.Value : null); + + if (isMultiFile) + { + effectiveDiskNumber ??= effectiveChapterNumber; + effectiveChapterNumber ??= effectiveDiskNumber; + } + + if (effectiveDiskNumber.HasValue && effectiveDiskNumber.Value > 0) variables["DiskNumber"] = effectiveDiskNumber.Value; + if (effectiveChapterNumber.HasValue && effectiveChapterNumber.Value > 0) variables["ChapterNumber"] = effectiveChapterNumber.Value; + + stableSuffixNumber = effectiveChapterNumber ?? effectiveDiskNumber ?? item.SequenceNumberHint; + return variables; + } + + private static bool PatternAllowsSubfolders(string effectiveFilePattern) + { + return effectiveFilePattern.IndexOf("DiskNumber", StringComparison.OrdinalIgnoreCase) >= 0 + || effectiveFilePattern.IndexOf("ChapterNumber", StringComparison.OrdinalIgnoreCase) >= 0 + || effectiveFilePattern.IndexOf('/') >= 0 + || effectiveFilePattern.IndexOf('\\') >= 0; + } +} diff --git a/listenarr.api/Controllers/MetadataController.cs b/listenarr.api/Controllers/MetadataController.cs index 2b2c5ac8b..9037c63fa 100644 --- a/listenarr.api/Controllers/MetadataController.cs +++ b/listenarr.api/Controllers/MetadataController.cs @@ -40,6 +40,7 @@ public class MetadataController : ControllerBase private readonly ISeriesCatalogService _seriesCatalogService; private readonly MetadataImageCacheWorkflow _imageCacheWorkflow; private readonly MetadataLookupCacheWorkflow _lookupCacheWorkflow; + private readonly MetadataLookupResponseCache _lookupResponseCache; public MetadataController( IAudiobookMetadataService metadataService, @@ -65,6 +66,7 @@ public MetadataController( _logger = logger; _imageCacheWorkflow = new MetadataImageCacheWorkflow(_audiobookRepository, _imageCacheService, _logger); _lookupCacheWorkflow = new MetadataLookupCacheWorkflow(_audiobookRepository, _imageCacheService, _imageCacheWorkflow, _logger); + _lookupResponseCache = new MetadataLookupResponseCache(_cache); } /// @@ -210,7 +212,7 @@ private async Task> LookupAuthorCore( { _cache.Remove(cacheKey); } - else if (_cache.TryGetValue(cacheKey, out AuthorLookupCacheEntry? cachedEntry) && cachedEntry != null) + else if (_cache.TryGetValue(cacheKey, out MetadataAuthorLookupCacheEntry? cachedEntry) && cachedEntry != null) { cachedEntry.Asin ??= normalizedAsin; @@ -226,7 +228,7 @@ private async Task> LookupAuthorCore( cachedEntry.NotFound = false; _cache.Set(cacheKey, cachedEntry, new MemoryCacheEntryOptions { SlidingExpiration = TimeSpan.FromHours(12) }); - return Ok(MapAuthorLookupResponse(cachedEntry, normalizedName)); + return Ok(_lookupResponseCache.MapAuthorLookupResponse(cachedEntry, normalizedName)); } return NotFound("Author not found"); @@ -242,7 +244,7 @@ private async Task> LookupAuthorCore( if (MetadataResponseMapper.HasCompleteAuthorLookupData(cachedEntry.CachedPath, cachedEntry.Description, cachedEntry.SimilarAuthors)) { - return Ok(MapAuthorLookupResponse(cachedEntry, normalizedName)); + return Ok(_lookupResponseCache.MapAuthorLookupResponse(cachedEntry, normalizedName)); } normalizedAsin ??= cachedEntry.Asin; @@ -262,7 +264,7 @@ private async Task> LookupAuthorCore( if (!refresh && MetadataResponseMapper.HasCompleteAuthorLookupData(persistedResponse.CachedPath, persistedResponse.Description, persistedResponse.SimilarAuthors)) { - CacheAuthorLookupResponse(cacheKey, persistedResponse); + _lookupResponseCache.CacheAuthorLookupResponse(cacheKey, persistedResponse); return Ok(persistedResponse); } @@ -411,11 +413,7 @@ private async Task> LookupAuthorCore( if (!hasResolvedAuthorIdentity) { - _cache.Set(cacheKey, new AuthorLookupCacheEntry - { - NotFound = true, - Name = normalizedName - }, new MemoryCacheEntryOptions { SlidingExpiration = TimeSpan.FromHours(6) }); + _lookupResponseCache.CacheAuthorNotFound(cacheKey, normalizedName); return NotFound("Author not found"); } @@ -483,8 +481,8 @@ await _lookupCacheWorkflow.PersistAuthorLookupAsync( region, result); - CacheAuthorLookupResponse(cacheKey, result); - CacheAuthorLookupResponse(MetadataCacheKeys.BuildAuthorLookupCacheKey(region, normalizedName, result.Asin), result); + _lookupResponseCache.CacheAuthorLookupResponse(cacheKey, result); + _lookupResponseCache.CacheAuthorLookupResponse(MetadataCacheKeys.BuildAuthorLookupCacheKey(region, normalizedName, result.Asin), result); return Ok(result); } @@ -621,17 +619,17 @@ private async Task> LookupSeriesCore( { _cache.Remove(cacheKey); } - else if (_cache.TryGetValue(cacheKey, out SeriesLookupCacheEntry? cachedEntry) && cachedEntry != null) + else if (_cache.TryGetValue(cacheKey, out MetadataSeriesLookupCacheEntry? cachedEntry) && cachedEntry != null) { cachedEntry.Asin ??= normalizedAsin; - return Ok(MapSeriesLookupResponse(cachedEntry, normalizedName)); + return Ok(_lookupResponseCache.MapSeriesLookupResponse(cachedEntry, normalizedName)); } var persistedEntry = await _lookupCacheWorkflow.ResolvePersistedSeriesCacheAsync(normalizedName, region, normalizedAsin); if (!refresh && persistedEntry != null) { var persistedResponse = await _lookupCacheWorkflow.MapPersistedSeriesLookupResponseAsync(persistedEntry, normalizedName); - CacheSeriesLookupResponse(cacheKey, persistedResponse); + _lookupResponseCache.CacheSeriesLookupResponse(cacheKey, persistedResponse); return Ok(persistedResponse); } @@ -712,7 +710,7 @@ await _lookupCacheWorkflow.PersistSeriesLookupAsync( result, catalog?.Books); - CacheSeriesLookupResponse(cacheKey, result); + _lookupResponseCache.CacheSeriesLookupResponse(cacheKey, result); return Ok(result); } @@ -799,80 +797,6 @@ private async Task> GetSeriesBooksCore( } } - private void CacheAuthorLookupResponse(string cacheKey, AuthorLookupResponse response) - { - _cache.Set(cacheKey, new AuthorLookupCacheEntry - { - Asin = response.Asin, - Name = response.Name, - Image = response.Image, - CachedPath = response.CachedPath, - Description = response.Description, - SimilarAuthors = response.SimilarAuthors, - NotFound = false - }, new MemoryCacheEntryOptions { SlidingExpiration = TimeSpan.FromHours(12) }); - } - - private void CacheSeriesLookupResponse(string cacheKey, SeriesLookupResponse response) - { - _cache.Set(cacheKey, new SeriesLookupCacheEntry - { - Asin = response.Asin, - Name = response.Name, - Image = response.Image, - CachedPath = response.CachedPath, - Description = response.Description, - TotalBooks = response.TotalBooks - }, new MemoryCacheEntryOptions { SlidingExpiration = TimeSpan.FromHours(12) }); - } - - private static AuthorLookupResponse MapAuthorLookupResponse(AuthorLookupCacheEntry entry, string fallbackName) - { - return new AuthorLookupResponse - { - Asin = entry.Asin, - Name = entry.Name ?? fallbackName, - Image = entry.Image, - CachedPath = entry.CachedPath, - Description = entry.Description, - SimilarAuthors = entry.SimilarAuthors ?? new List() - }; - } - - private static SeriesLookupResponse MapSeriesLookupResponse(SeriesLookupCacheEntry entry, string fallbackName) - { - return new SeriesLookupResponse - { - Asin = entry.Asin, - Name = entry.Name ?? fallbackName, - Image = entry.Image, - CachedPath = entry.CachedPath, - Description = entry.Description, - TotalBooks = entry.TotalBooks - }; - } - - private sealed class AuthorLookupCacheEntry - { - public string? Asin { get; set; } - public string? Name { get; set; } - public string? Image { get; set; } - public string? CachedPath { get; set; } - public string? Description { get; set; } - public List? SimilarAuthors { get; set; } - public bool NotFound { get; set; } - } - - private sealed class SeriesLookupCacheEntry - { - public string? Asin { get; set; } - public string? Name { get; set; } - public string? Image { get; set; } - public string? CachedPath { get; set; } - public string? Description { get; set; } - public int TotalBooks { get; set; } - } - public sealed class AuthorLookupResponse { public string? Asin { get; set; } diff --git a/listenarr.api/Controllers/MetadataLookupResponseCache.cs b/listenarr.api/Controllers/MetadataLookupResponseCache.cs new file mode 100644 index 000000000..affa8645a --- /dev/null +++ b/listenarr.api/Controllers/MetadataLookupResponseCache.cs @@ -0,0 +1,107 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +using Microsoft.Extensions.Caching.Memory; + +namespace Listenarr.Api.Controllers +{ + internal sealed class MetadataLookupResponseCache + { + private readonly IMemoryCache _cache; + + public MetadataLookupResponseCache(IMemoryCache cache) + { + _cache = cache; + } + + public void CacheAuthorLookupResponse(string cacheKey, MetadataController.AuthorLookupResponse response) + { + _cache.Set(cacheKey, new MetadataAuthorLookupCacheEntry + { + Asin = response.Asin, + Name = response.Name, + Image = response.Image, + CachedPath = response.CachedPath, + Description = response.Description, + SimilarAuthors = response.SimilarAuthors, + NotFound = false + }, new MemoryCacheEntryOptions { SlidingExpiration = TimeSpan.FromHours(12) }); + } + + public void CacheAuthorNotFound(string cacheKey, string normalizedName) + { + _cache.Set(cacheKey, new MetadataAuthorLookupCacheEntry + { + NotFound = true, + Name = normalizedName + }, new MemoryCacheEntryOptions { SlidingExpiration = TimeSpan.FromHours(6) }); + } + + public void CacheSeriesLookupResponse(string cacheKey, MetadataController.SeriesLookupResponse response) + { + _cache.Set(cacheKey, new MetadataSeriesLookupCacheEntry + { + Asin = response.Asin, + Name = response.Name, + Image = response.Image, + CachedPath = response.CachedPath, + Description = response.Description, + TotalBooks = response.TotalBooks + }, new MemoryCacheEntryOptions { SlidingExpiration = TimeSpan.FromHours(12) }); + } + + public MetadataController.AuthorLookupResponse MapAuthorLookupResponse(MetadataAuthorLookupCacheEntry entry, string fallbackName) + { + return new MetadataController.AuthorLookupResponse + { + Asin = entry.Asin, + Name = entry.Name ?? fallbackName, + Image = entry.Image, + CachedPath = entry.CachedPath, + Description = entry.Description, + SimilarAuthors = entry.SimilarAuthors ?? new List() + }; + } + + public MetadataController.SeriesLookupResponse MapSeriesLookupResponse(MetadataSeriesLookupCacheEntry entry, string fallbackName) + { + return new MetadataController.SeriesLookupResponse + { + Asin = entry.Asin, + Name = entry.Name ?? fallbackName, + Image = entry.Image, + CachedPath = entry.CachedPath, + Description = entry.Description, + TotalBooks = entry.TotalBooks + }; + } + } + + internal sealed class MetadataAuthorLookupCacheEntry + { + public string? Asin { get; set; } + public string? Name { get; set; } + public string? Image { get; set; } + public string? CachedPath { get; set; } + public string? Description { get; set; } + public List? SimilarAuthors { get; set; } + public bool NotFound { get; set; } + } + + internal sealed class MetadataSeriesLookupCacheEntry + { + public string? Asin { get; set; } + public string? Name { get; set; } + public string? Image { get; set; } + public string? CachedPath { get; set; } + public string? Description { get; set; } + public int TotalBooks { get; set; } + } +} diff --git a/listenarr.api/Program.cs b/listenarr.api/Program.cs index 60bfced17..18885c15d 100644 --- a/listenarr.api/Program.cs +++ b/listenarr.api/Program.cs @@ -361,8 +361,11 @@ ex is IOException builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); // Register named HttpClients for each adapter type so adapter implementations can request the appropriately-configured client. builder.Services.AddListenarrHttpClients(builder.Configuration); diff --git a/listenarr.application/Search/AudibleAuthorSearchWorkflow.cs b/listenarr.application/Search/AudibleAuthorSearchWorkflow.cs new file mode 100644 index 000000000..b4aabc484 --- /dev/null +++ b/listenarr.application/Search/AudibleAuthorSearchWorkflow.cs @@ -0,0 +1,245 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +using Listenarr.Application.Metadata; +using Listenarr.Domain.Models; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Application.Search +{ + public sealed class AudibleAuthorSearchWorkflow + { + private readonly AudibleService _audibleService; + private readonly AudibleAuthorPageCollector _authorPageCollector; + private readonly MetadataConverters _metadataConverters; + private readonly ILogger _logger; + + public AudibleAuthorSearchWorkflow( + AudibleService audibleService, + AudibleAuthorPageCollector authorPageCollector, + MetadataConverters metadataConverters, + ILogger logger) + { + _audibleService = audibleService; + _authorPageCollector = authorPageCollector; + _metadataConverters = metadataConverters; + _logger = logger; + } + + public async Task?> TrySearchAsync( + string? searchType, + string? author, + string? title, + string? isbn, + int candidateLimit, + string region, + string? language) + { + if (searchType == "AUTHOR" && !string.IsNullOrEmpty(author)) + { + return await SearchByAuthorAsync(author, candidateLimit, region, language); + } + + if (searchType == "AUTHOR_TITLE" && !string.IsNullOrEmpty(author)) + { + return await SearchByAuthorAndTitleAsync(author, title, isbn, candidateLimit, region, language); + } + + return null; + } + + private async Task?> SearchByAuthorAsync( + string author, + int candidateLimit, + string region, + string? language) + { + var aggregated = await _authorPageCollector.CollectAsync( + author, + candidateLimit, + region, + language, + "author"); + + if (!aggregated.Any()) + { + return null; + } + + var deduplicated = DeduplicateByAsin(aggregated); + _logger.LogInformation( + "Deduplicated author results for '{Author}': {OriginalCount} -> {DeduplicatedCount}", + author, + aggregated.Count, + deduplicated.Count); + + var converted = new List(); + var authorFiltered = ApplyStrictLanguageFilter(deduplicated, language); + foreach (var book in authorFiltered.Where(book => !string.IsNullOrWhiteSpace(book.Asin))) + { + var bookResponse = new AudibleBookResponse + { + Asin = book.Asin, + Title = book.Title, + Subtitle = book.Subtitle, + Authors = book.Authors, + ImageUrl = book.ImageUrl, + Language = book.Language, + BookFormat = book.BookFormat, + Genres = book.Genres, + Series = book.Series, + Publisher = book.Publisher, + Narrators = book.Narrators, + ReleaseDate = book.ReleaseDate + }; + var metadata = _metadataConverters.ConvertAudibleToMetadata(bookResponse, book.Asin!, "Audible"); + var searchResult = await _metadataConverters.ConvertMetadataToSearchResultAsync(metadata, book.Asin!); + searchResult.IsEnriched = true; + searchResult.MetadataSource = "Audible"; + converted.Add(searchResult); + } + + return converted.Any() ? SearchResultConverters.ToMetadataList(converted) : null; + } + + private async Task?> SearchByAuthorAndTitleAsync( + string author, + string? title, + string? isbn, + int candidateLimit, + string region, + string? language) + { + try { _logger.LogInformation("Entering AUTHOR_TITLE branch: author='{Author}', title='{Title}', isbn='{Isbn}'", author, title, isbn); } + catch (Exception caughtEx) when (caughtEx is not OperationCanceledException && caughtEx is not OutOfMemoryException && caughtEx is not StackOverflowException) + { + System.Diagnostics.Debug.WriteLine("Suppressed non-fatal exception in catch block."); + } + + var aggregated = await _authorPageCollector.CollectAsync( + author, + candidateLimit, + region, + language, + "AUTHOR_TITLE"); + + if (aggregated?.Any() != true) + { + return null; + } + + var deduplicated = DeduplicateByAsin(aggregated); + _logger.LogInformation( + "Deduplicated AUTHOR_TITLE results for '{Author}': {OriginalCount} -> {DeduplicatedCount}", + author, + aggregated.Count, + deduplicated.Count); + + try { _logger.LogInformation("Audible author lookup returned {Count} aggregated results for author '{Author}'", deduplicated.Count, author); } + catch (Exception caughtEx) when (caughtEx is not OperationCanceledException && caughtEx is not OutOfMemoryException && caughtEx is not StackOverflowException) + { + System.Diagnostics.Debug.WriteLine("Suppressed non-fatal exception in catch block."); + } + + var authorFiltered = ApplyStrictLanguageFilter(deduplicated, language); + + if (!string.IsNullOrEmpty(title)) + { + authorFiltered = authorFiltered.Where(b => + (!string.IsNullOrWhiteSpace(b.Title) && b.Title.IndexOf(title, StringComparison.OrdinalIgnoreCase) >= 0) || + (!string.IsNullOrWhiteSpace(b.Subtitle) && b.Subtitle.IndexOf(title, StringComparison.OrdinalIgnoreCase) >= 0)); + } + + var detailedMetaByAsin = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (!string.IsNullOrEmpty(isbn)) + { + authorFiltered = await FilterByIsbnAsync(aggregated, authorFiltered, isbn, candidateLimit, region, language, detailedMetaByAsin); + } + + try { _logger.LogInformation("[DBG] authorFiltered count after language/title/isbn filtering: {Count}", authorFiltered.Count()); } + catch (Exception caughtEx) when (caughtEx is not OperationCanceledException && caughtEx is not OutOfMemoryException && caughtEx is not StackOverflowException) + { + System.Diagnostics.Debug.WriteLine("Suppressed non-fatal exception in catch block."); + } + + var converted = await AudibleSearchResultMapper.ConvertToSearchResultsAsync( + authorFiltered, + _metadataConverters, + detailedMetaByAsin, + _logger, + continueOnConversionError: true); + + return converted.Any() ? SearchResultConverters.ToMetadataList(converted) : null; + } + + private async Task> FilterByIsbnAsync( + IReadOnlyCollection aggregated, + IEnumerable authorFiltered, + string isbn, + int candidateLimit, + string region, + string? language, + IDictionary detailedMetaByAsin) + { + var isbnScanLimit = Math.Min(200, Math.Max(50, candidateLimit)); + var scanCandidates = aggregated.Where(r => !string.IsNullOrWhiteSpace(r.Asin)).Take(isbnScanLimit).ToList(); + try { _logger.LogInformation("Scanning up to {Limit} author candidates for ISBN {Isbn}", scanCandidates.Count, isbn); } + catch (Exception caughtEx) when (caughtEx is not OperationCanceledException && caughtEx is not OutOfMemoryException && caughtEx is not StackOverflowException) + { + System.Diagnostics.Debug.WriteLine("Suppressed non-fatal exception in catch block."); + } + + foreach (var candidate in scanCandidates.Where(c => !string.IsNullOrWhiteSpace(c.Asin))) + { + try + { + var metadata = await _audibleService.GetBookMetadataAsync(candidate.Asin!, region, true, language); + if (metadata == null) + { + continue; + } + + detailedMetaByAsin[candidate.Asin!] = metadata; + if (!string.IsNullOrWhiteSpace(metadata.Isbn) && string.Equals(metadata.Isbn.Trim(), isbn, StringComparison.OrdinalIgnoreCase)) + { + return authorFiltered.Where(r => !string.IsNullOrWhiteSpace(r.Asin) && string.Equals(r.Asin, candidate.Asin, StringComparison.OrdinalIgnoreCase)); + } + } + catch (Exception exMeta) when (exMeta is not OperationCanceledException && exMeta is not OutOfMemoryException && exMeta is not StackOverflowException) + { + _logger.LogDebug(exMeta, "Failed fetching audible metadata for ASIN {Asin} while scanning for ISBN", candidate.Asin); + } + } + + return authorFiltered; + } + + private static List DeduplicateByAsin(IEnumerable books) + { + return books + .Where(b => !string.IsNullOrWhiteSpace(b.Asin)) + .GroupBy(b => b.Asin, StringComparer.OrdinalIgnoreCase) + .Select(g => g.First()) + .ToList(); + } + + private static IEnumerable ApplyStrictLanguageFilter( + IEnumerable books, + string? language) + { + if (string.IsNullOrWhiteSpace(language)) + { + return books; + } + + return books.Where(b => !string.IsNullOrWhiteSpace(b.Language) && string.Equals(b.Language, language, StringComparison.OrdinalIgnoreCase)); + } + } +} diff --git a/listenarr.application/Search/SearchService.cs b/listenarr.application/Search/SearchService.cs index 30a741a2c..3c9520e24 100644 --- a/listenarr.application/Search/SearchService.cs +++ b/listenarr.application/Search/SearchService.cs @@ -33,8 +33,6 @@ public class SearchService : ISearchService { private readonly IConfigurationService _configurationService; private readonly ILogger _logger; - private readonly AudibleService _audibleService; - private readonly MetadataConverters _metadataConverters; private readonly SearchProgressReporter _searchProgressReporter; private readonly AsinCandidateCollector _asinCandidateCollector; private readonly AsinEnricher _asinEnricher; @@ -43,8 +41,8 @@ public class SearchService : ISearchService private readonly AsinSearchHandler _asinSearchHandler; private readonly IndexerSearchWorkflow _indexerSearchWorkflow; private readonly MetadataSourceCatalog _metadataSourceCatalog; - private readonly AudibleAuthorPageCollector _audibleAuthorPageCollector; private readonly AudibleSimpleLookupWorkflow _audibleSimpleLookupWorkflow; + private readonly AudibleAuthorSearchWorkflow _audibleAuthorSearchWorkflow; public SearchService( HttpClient httpClient, @@ -67,12 +65,11 @@ public SearchService( IndexerSearchWorkflow? indexerSearchWorkflow = null, MetadataSourceCatalog? metadataSourceCatalog = null, AudibleAuthorPageCollector? audibleAuthorPageCollector = null, - AudibleSimpleLookupWorkflow? audibleSimpleLookupWorkflow = null) + AudibleSimpleLookupWorkflow? audibleSimpleLookupWorkflow = null, + AudibleAuthorSearchWorkflow? audibleAuthorSearchWorkflow = null) { _configurationService = configurationService; _logger = logger; - _audibleService = audibleService; - _metadataConverters = metadataConverters; _searchProgressReporter = searchProgressReporter; _asinCandidateCollector = asinCandidateCollector; _asinEnricher = asinEnricher; @@ -91,12 +88,17 @@ public SearchService( _metadataSourceCatalog = metadataSourceCatalog ?? new MetadataSourceCatalog( apiConfigRepository, NullLogger.Instance); - _audibleAuthorPageCollector = audibleAuthorPageCollector ?? new AudibleAuthorPageCollector( + var resolvedAudibleAuthorPageCollector = audibleAuthorPageCollector ?? new AudibleAuthorPageCollector( audibleService, NullLogger.Instance); _audibleSimpleLookupWorkflow = audibleSimpleLookupWorkflow ?? new AudibleSimpleLookupWorkflow( audibleService, metadataConverters); + _audibleAuthorSearchWorkflow = audibleAuthorSearchWorkflow ?? new AudibleAuthorSearchWorkflow( + audibleService, + resolvedAudibleAuthorPageCollector, + metadataConverters, + NullLogger.Instance); } public async Task> SearchAsync(string query, string? category = null, List? apiIds = null, SearchSortBy sortBy = SearchSortBy.Seeders, SearchSortDirection sortDirection = SearchSortDirection.Descending, bool isAutomaticSearch = false) @@ -203,153 +205,17 @@ public async Task> IntelligentSearchAsync(string quer return simpleAudibleResults; } - // AUTHOR-only - if (searchType == "AUTHOR" && !string.IsNullOrEmpty(authorVal)) - { - var aggregated = await _audibleAuthorPageCollector.CollectAsync( - authorVal, - candidateLimit, - region, - language, - "author"); - if (aggregated.Any()) - { - // Deduplicate results based on ASIN to prevent repeated books across pages - var deduplicated = aggregated - .Where(b => !string.IsNullOrWhiteSpace(b.Asin)) - .GroupBy(b => b.Asin, StringComparer.OrdinalIgnoreCase) - .Select(g => g.First()) - .ToList(); - - _logger.LogInformation("Deduplicated author results for '{Author}': {OriginalCount} -> {DeduplicatedCount}", authorVal, aggregated.Count, deduplicated.Count); - - var converted = new List(); - var authorFiltered = deduplicated.AsEnumerable(); - if (!string.IsNullOrWhiteSpace(language)) authorFiltered = authorFiltered.Where(b => !string.IsNullOrWhiteSpace(b.Language) && string.Equals(b.Language, language, StringComparison.OrdinalIgnoreCase)); - foreach (var book in authorFiltered.Where(book => !string.IsNullOrWhiteSpace(book.Asin))) - { - var bookResp = new AudibleBookResponse - { - Asin = book.Asin, - Title = book.Title, - Subtitle = book.Subtitle, - Authors = book.Authors, - ImageUrl = book.ImageUrl, - Language = book.Language, - BookFormat = book.BookFormat, - Genres = book.Genres, - Series = book.Series, - Publisher = book.Publisher, - Narrators = book.Narrators, - ReleaseDate = book.ReleaseDate - }; - var meta = _metadataConverters.ConvertAudibleToMetadata(bookResp, book.Asin!, "Audible"); - var sr = await _metadataConverters.ConvertMetadataToSearchResultAsync(meta, book.Asin!); - sr.IsEnriched = true; - sr.MetadataSource = "Audible"; - converted.Add(sr); - } - if (converted.Any()) return SearchResultConverters.ToMetadataList(converted); - } - } - - // AUTHOR + TITLE: prefer author endpoint then filter by title/isbn to ensure consistent Audible enrichment - if (searchType == "AUTHOR_TITLE" && !string.IsNullOrEmpty(authorVal)) + var authorAudibleResults = await _audibleAuthorSearchWorkflow.TrySearchAsync( + searchType, + authorVal, + titleVal, + isbnVal, + candidateLimit, + region, + language); + if (authorAudibleResults?.Any() == true) { - try { _logger.LogInformation("Entering AUTHOR_TITLE branch: author='{Author}', title='{Title}', isbn='{Isbn}'", authorVal, titleVal, isbnVal); } - catch (Exception caughtEx_3) when (caughtEx_3 is not OperationCanceledException && caughtEx_3 is not OutOfMemoryException && caughtEx_3 is not StackOverflowException) - { - System.Diagnostics.Debug.WriteLine("Suppressed non-fatal exception in catch block."); - } - var aggregated = await _audibleAuthorPageCollector.CollectAsync( - authorVal, - candidateLimit, - region, - language, - "AUTHOR_TITLE"); - if (aggregated?.Any() == true) - { - // Deduplicate results based on ASIN to prevent repeated books across pages - var deduplicated = aggregated - .Where(b => !string.IsNullOrWhiteSpace(b.Asin)) - .GroupBy(b => b.Asin, StringComparer.OrdinalIgnoreCase) - .Select(g => g.First()) - .ToList(); - - _logger.LogInformation("Deduplicated AUTHOR_TITLE results for '{Author}': {OriginalCount} -> {DeduplicatedCount}", authorVal, aggregated.Count, deduplicated.Count); - - try { _logger.LogInformation("Audible author lookup returned {Count} aggregated results for author '{Author}'", deduplicated.Count, authorVal); } - catch (Exception caughtEx_4) when (caughtEx_4 is not OperationCanceledException && caughtEx_4 is not OutOfMemoryException && caughtEx_4 is not StackOverflowException) - { - System.Diagnostics.Debug.WriteLine("Suppressed non-fatal exception in catch block."); - } - - // Use the lightweight author/books results to perform title filtering - // and avoid fetching detailed metadata for every ASIN. Only fetch - // detailed metadata when an ISBN lookup is explicitly required or - // when we need to enrich a small set of final matches. - var authorFiltered = deduplicated.AsEnumerable(); - if (!string.IsNullOrWhiteSpace(language)) authorFiltered = authorFiltered.Where(b => !string.IsNullOrWhiteSpace(b.Language) && string.Equals(b.Language, language, StringComparison.OrdinalIgnoreCase)); - - // Title-based filtering can be done directly against the author results - if (!string.IsNullOrEmpty(titleVal)) - { - authorFiltered = authorFiltered.Where(b => - (!string.IsNullOrWhiteSpace(b.Title) && b.Title.IndexOf(titleVal, StringComparison.OrdinalIgnoreCase) >= 0) || - (!string.IsNullOrWhiteSpace(b.Subtitle) && b.Subtitle.IndexOf(titleVal, StringComparison.OrdinalIgnoreCase) >= 0) - ); - } - - // If an ISBN was provided we must match against detailed metadata; - // instead of fetching metadata for every ASIN, scan a limited set - // of candidates and only fetch metadata until we find ISBN matches. - var detailedMetaByAsin = new Dictionary(StringComparer.OrdinalIgnoreCase); - if (!string.IsNullOrEmpty(isbnVal)) - { - // Limit how many author results to scan for ISBNs to avoid huge loads - var isbnScanLimit = Math.Min(200, Math.Max(50, candidateLimit)); - var scanCandidates = aggregated.Where(r => !string.IsNullOrWhiteSpace(r.Asin)).Take(isbnScanLimit).ToList(); - try { _logger.LogInformation("Scanning up to {Limit} author candidates for ISBN {Isbn}", scanCandidates.Count, isbnVal); } - catch (Exception caughtEx_5) when (caughtEx_5 is not OperationCanceledException && caughtEx_5 is not OutOfMemoryException && caughtEx_5 is not StackOverflowException) - { - System.Diagnostics.Debug.WriteLine("Suppressed non-fatal exception in catch block."); - } - foreach (var c in scanCandidates.Where(c => !string.IsNullOrWhiteSpace(c.Asin))) - { - try - { - var meta = await _audibleService.GetBookMetadataAsync(c.Asin!, region, true, language); - if (meta == null) continue; - detailedMetaByAsin[c.Asin!] = meta; - if (!string.IsNullOrWhiteSpace(meta.Isbn) && string.Equals(meta.Isbn.Trim(), isbnVal, StringComparison.OrdinalIgnoreCase)) - { - // Narrow authorFiltered to only matching ASINs - authorFiltered = authorFiltered.Where(r => !string.IsNullOrWhiteSpace(r.Asin) && string.Equals(r.Asin, c.Asin, StringComparison.OrdinalIgnoreCase)); - break; // stop scanning once we found the ISBN match - } - } - catch (Exception exMeta) when (exMeta is not OperationCanceledException && exMeta is not OutOfMemoryException && exMeta is not StackOverflowException) - { - _logger.LogDebug(exMeta, "Failed fetching audible metadata for ASIN {Asin} while scanning for ISBN", c.Asin); - } - } - } - - try { _logger.LogInformation("[DBG] authorFiltered count after language/title/isbn filtering: {Count}", authorFiltered.Count()); } - catch (Exception caughtEx_6) when (caughtEx_6 is not OperationCanceledException && caughtEx_6 is not OutOfMemoryException && caughtEx_6 is not StackOverflowException) - { - System.Diagnostics.Debug.WriteLine("Suppressed non-fatal exception in catch block."); - } - - var converted = await AudibleSearchResultMapper.ConvertToSearchResultsAsync( - authorFiltered, - _metadataConverters, - detailedMetaByAsin, - _logger, - continueOnConversionError: true); - - if (converted.Any()) return SearchResultConverters.ToMetadataList(converted); - } + return authorAudibleResults; } } diff --git a/listenarr.infrastructure/Adapters/QbittorrentAdapter.cs b/listenarr.infrastructure/Adapters/QbittorrentAdapter.cs index 63bee4391..253efdcf4 100644 --- a/listenarr.infrastructure/Adapters/QbittorrentAdapter.cs +++ b/listenarr.infrastructure/Adapters/QbittorrentAdapter.cs @@ -17,8 +17,6 @@ */ using System.Net; using System.Text.Json; -using BencodeNET.Parsing; -using BencodeNET.Torrents; using Listenarr.Application.Interfaces; using Listenarr.Application.Security; using Listenarr.Domain.Common; @@ -42,12 +40,16 @@ public class QbittorrentAdapter : IDownloadClientAdapter private readonly IHttpClientFactory _httpClientFactory; private readonly ILogger _logger; private readonly ITorrentFileDownloader _torrentFileDownloader; + private readonly QbittorrentTorrentAddPlanner _torrentAddPlanner; + private readonly QbittorrentAuthSession _authSession; public QbittorrentAdapter(IHttpClientFactory httpFactory, ITorrentFileDownloader torrentFileDownloader, ILogger logger) { _httpClientFactory = httpFactory ?? throw new ArgumentNullException(nameof(httpFactory)); _torrentFileDownloader = torrentFileDownloader ?? throw new ArgumentNullException(nameof(torrentFileDownloader)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _torrentAddPlanner = new QbittorrentTorrentAddPlanner(_torrentFileDownloader, _logger); + _authSession = new QbittorrentAuthSession(_logger); } public async Task<(bool Success, string Message)> TestConnectionAsync(DownloadClientConfiguration client, CancellationToken ct = default) @@ -198,66 +200,17 @@ async Task PostLoginWithAgent(string userAgent) } } - /// - /// Perform the login operation with qBittorrent API - /// - /// - /// - /// - /// - /// - private async Task LoginAsync(HttpClient httpClient, DownloadClientConfiguration client, CancellationToken cancellationToken = default) - { - var baseUrl = DownloadClientUriBuilder.BuildAuthority(client); - - using var loginData = new FormUrlEncodedContent( - [ - new KeyValuePair("username", client.Username ?? string.Empty), - new KeyValuePair("password", client.Password ?? string.Empty) - ]); - - using var loginResponse = await httpClient.PostAsync($"{baseUrl}/api/v2/auth/login", loginData, cancellationToken); - if (!loginResponse.IsSuccessStatusCode) - { - var body = await loginResponse.Content.ReadAsStringAsync(cancellationToken); - - if (loginResponse.StatusCode == HttpStatusCode.Forbidden) - { - using var testResp = await httpClient.GetAsync($"{baseUrl}/api/v2/app/version", cancellationToken); - if (!testResp.IsSuccessStatusCode) - { - throw new QbittorrentException($"qBittorrent authentication enabled but credentials are incorrect for {client.Id}"); - } - - _logger.LogDebug($"qBittorrent authentication disabled; proceeding without credentials for client {client.Id}"); - } - else - { - throw new QbittorrentException($"qBittorrent login failed with status {loginResponse.StatusCode}"); - } - } - else - { - _logger.LogDebug("Authenticated to qBittorrent for client {ClientId}", LogRedaction.SanitizeText(client.Id)); - } - - return true; - } - public async Task AddAsync(DownloadClientConfiguration client, SearchResult result, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(client); ArgumentNullException.ThrowIfNull(result); - var magnetLink = DownloadClientUriBuilder.NormalizeMagnetLink(result.MagnetLink); - var httpTorrentUrl = NormalizeTorrentUrl(result.TorrentUrl); - var baseUrl = DownloadClientUriBuilder.BuildAuthority(client); using var httpClient = _httpClientFactory.CreateClient(ClientType); try { - await LoginAsync(httpClient, client, ct); + await _authSession.LoginAsync(httpClient, client, ct); } catch (QbittorrentException exception) { @@ -265,78 +218,25 @@ private async Task LoginAsync(HttpClient httpClient, DownloadClientConfigu return null; } - var savePath = client.DownloadPath ?? string.Empty; - string? category = null; - string? tags = null; - - if (client.Settings != null) - { - if (client.Settings.TryGetValue("category", out var categoryObj)) - category = categoryObj?.ToString(); - if (client.Settings.TryGetValue("tags", out var tagsObj)) - tags = tagsObj?.ToString(); - } - - var hash = string.Empty; - - byte[]? torrentFileData = result.TorrentFileContent; - if (torrentFileData == null && !string.IsNullOrEmpty(httpTorrentUrl)) - { - // Try to get torrent file with the torrent URL - var downloadResult = await _torrentFileDownloader.DownloadAsync(httpTorrentUrl, ct); - if (downloadResult.TorrentBytes != null) - { - torrentFileData = downloadResult.TorrentBytes; - _logger.LogInformation($"Pre-downloaded torrent file ({torrentFileData!.Length} bytes) for '{LogRedaction.SanitizeText(result.Title)}'"); - } - else if (downloadResult.HasMagnet && string.IsNullOrEmpty(magnetLink)) - { - magnetLink = DownloadClientUriBuilder.NormalizeMagnetLink(downloadResult.MagnetUri); - _logger.LogInformation($"Indexer redirected to magnet link for '{LogRedaction.SanitizeText(result.Title)}'"); - } - } - - if (torrentFileData == null && string.IsNullOrEmpty(httpTorrentUrl) && string.IsNullOrEmpty(magnetLink)) - { - _logger.LogError($"No torrent URL, no magnet link and no torrent file given, nothing can be added for search result {result.Title}"); - return null; - } - - // Compute hash from torrent file - if (torrentFileData != null) + var addPlan = await _torrentAddPlanner.CreateAsync(client, result, ct); + if (addPlan == null) { - using (var stream = new MemoryStream(torrentFileData)) - { - var parser = new BencodeParser(); - Torrent torrent = parser.Parse(stream); - hash = torrent.GetInfoHash(); - } - } - // Get hash in magnet link - else if (!string.IsNullOrEmpty(magnetLink)) - { - hash = TryExtractMagnetHash(magnetLink); - } - - if (string.IsNullOrEmpty(hash)) - { - _logger.LogError($"Unable to compute hash for the given torrent: {result.Title} with torrent URL: {result.TorrentUrl} and magnet link: {result.MagnetLink}"); return null; } // Add download using torrent file HttpResponseMessage addResponse; - if (torrentFileData != null) + if (addPlan.TorrentFileData != null) { using var multipart = new MultipartFormDataContent(); - multipart.Add(new StringContent(savePath), "savepath"); - if (!string.IsNullOrEmpty(category)) - multipart.Add(new StringContent(category), "category"); - if (!string.IsNullOrEmpty(tags)) - multipart.Add(new StringContent(tags), "tags"); + multipart.Add(new StringContent(addPlan.SavePath), "savepath"); + if (!string.IsNullOrEmpty(addPlan.Category)) + multipart.Add(new StringContent(addPlan.Category), "category"); + if (!string.IsNullOrEmpty(addPlan.Tags)) + multipart.Add(new StringContent(addPlan.Tags), "tags"); var torrentFileName = string.IsNullOrEmpty(result.TorrentFileName) ? "download.torrent" : result.TorrentFileName; - var torrentContent = new ByteArrayContent(torrentFileData); + var torrentContent = new ByteArrayContent(addPlan.TorrentFileData); torrentContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/x-bittorrent"); multipart.Add(torrentContent, "torrents", torrentFileName); @@ -345,19 +245,19 @@ private async Task LoginAsync(HttpClient httpClient, DownloadClientConfigu // Add using magnet link or torrent url else { - var url = new[] { magnetLink, httpTorrentUrl } + var url = new[] { addPlan.MagnetLink, addPlan.HttpTorrentUrl } .FirstOrDefault(static url => !string.IsNullOrEmpty(url)) ?? string.Empty; var formData = new List> { new("urls", url), - new("savepath", savePath) + new("savepath", addPlan.SavePath) }; - if (!string.IsNullOrEmpty(category)) - formData.Add(new("category", category)); - if (!string.IsNullOrEmpty(tags)) - formData.Add(new("tags", tags)); + if (!string.IsNullOrEmpty(addPlan.Category)) + formData.Add(new("category", addPlan.Category)); + if (!string.IsNullOrEmpty(addPlan.Tags)) + formData.Add(new("tags", addPlan.Tags)); using var addData = new FormUrlEncodedContent(formData); addResponse = await httpClient.PostAsync($"{baseUrl}/api/v2/torrents/add", addData, ct); @@ -378,11 +278,11 @@ private async Task LoginAsync(HttpClient httpClient, DownloadClientConfigu // Inject tracker URLs via addTrackers API as a fallback to ensure the tracker // is registered even if qBittorrent didn't parse it from the torrent file. - if (torrentFileData != null) + if (addPlan.TorrentFileData != null) { try { - var announces = MyAnonamouseHelper.ExtractAnnounceUrls(torrentFileData); + var announces = MyAnonamouseHelper.ExtractAnnounceUrls(addPlan.TorrentFileData); // Filter to only actual tracker announce URLs — exclude file/web-seed URLs var trackerAnnounces = announces?.Where(a => a.Contains("/announce", StringComparison.OrdinalIgnoreCase) || @@ -392,14 +292,14 @@ private async Task LoginAsync(HttpClient httpClient, DownloadClientConfigu var trackerUrls = string.Join("\n", trackerAnnounces.Distinct()); using var addTrackersData = new FormUrlEncodedContent(new[] { - new KeyValuePair("hash", hash), + new KeyValuePair("hash", addPlan.Hash), new KeyValuePair("urls", trackerUrls) }); using var trackersResp = await httpClient.PostAsync($"{baseUrl}/api/v2/torrents/addTrackers", addTrackersData, ct); if (trackersResp.IsSuccessStatusCode) - _logger.LogInformation($"Injected {trackerAnnounces.Count} tracker(s) for torrent {hash} via addTrackers API"); + _logger.LogInformation($"Injected {trackerAnnounces.Count} tracker(s) for torrent {addPlan.Hash} via addTrackers API"); else - _logger.LogDebug($"addTrackers API returned {trackersResp.StatusCode} for torrent {hash} (non-fatal)"); + _logger.LogDebug($"addTrackers API returned {trackersResp.StatusCode} for torrent {addPlan.Hash} (non-fatal)"); } } catch (Exception exception) when (exception is not (OperationCanceledException or OutOfMemoryException or StackOverflowException)) @@ -408,37 +308,7 @@ private async Task LoginAsync(HttpClient httpClient, DownloadClientConfigu } } - return hash; - } - - 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(); + return addPlan.Hash; } /// diff --git a/listenarr.infrastructure/Adapters/QbittorrentAuthSession.cs b/listenarr.infrastructure/Adapters/QbittorrentAuthSession.cs new file mode 100644 index 000000000..4e68c61da --- /dev/null +++ b/listenarr.infrastructure/Adapters/QbittorrentAuthSession.cs @@ -0,0 +1,66 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +using System.Net; +using Listenarr.Application.Security; +using Listenarr.Domain.Models; +using Listenarr.Infrastructure.Adapters.Exceptions; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Infrastructure.Adapters +{ + internal sealed class QbittorrentAuthSession + { + private readonly ILogger _logger; + + public QbittorrentAuthSession(ILogger logger) + { + _logger = logger; + } + + public async Task LoginAsync(HttpClient httpClient, DownloadClientConfiguration client, CancellationToken cancellationToken = default) + { + var baseUrl = DownloadClientUriBuilder.BuildAuthority(client); + + using var loginData = new FormUrlEncodedContent( + [ + new KeyValuePair("username", client.Username ?? string.Empty), + new KeyValuePair("password", client.Password ?? string.Empty) + ]); + + using var loginResponse = await httpClient.PostAsync($"{baseUrl}/api/v2/auth/login", loginData, cancellationToken); + if (!loginResponse.IsSuccessStatusCode) + { + _ = await loginResponse.Content.ReadAsStringAsync(cancellationToken); + + if (loginResponse.StatusCode == HttpStatusCode.Forbidden) + { + using var testResp = await httpClient.GetAsync($"{baseUrl}/api/v2/app/version", cancellationToken); + if (!testResp.IsSuccessStatusCode) + { + throw new QbittorrentException($"qBittorrent authentication enabled but credentials are incorrect for {client.Id}"); + } + + _logger.LogDebug($"qBittorrent authentication disabled; proceeding without credentials for client {client.Id}"); + } + else + { + throw new QbittorrentException($"qBittorrent login failed with status {loginResponse.StatusCode}"); + } + } + else + { + _logger.LogDebug("Authenticated to qBittorrent for client {ClientId}", LogRedaction.SanitizeText(client.Id)); + } + + return true; + } + } +} diff --git a/listenarr.infrastructure/Adapters/QbittorrentTorrentAddPlanner.cs b/listenarr.infrastructure/Adapters/QbittorrentTorrentAddPlanner.cs new file mode 100644 index 000000000..1f8bdd210 --- /dev/null +++ b/listenarr.infrastructure/Adapters/QbittorrentTorrentAddPlanner.cs @@ -0,0 +1,139 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +using BencodeNET.Parsing; +using BencodeNET.Torrents; +using Listenarr.Application.Security; +using Listenarr.Domain.Models; +using Listenarr.Infrastructure.Torrents; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Infrastructure.Adapters +{ + internal sealed class QbittorrentTorrentAddPlanner + { + private readonly ITorrentFileDownloader _torrentFileDownloader; + private readonly ILogger _logger; + + public QbittorrentTorrentAddPlanner(ITorrentFileDownloader torrentFileDownloader, ILogger logger) + { + _torrentFileDownloader = torrentFileDownloader; + _logger = logger; + } + + public async Task CreateAsync( + DownloadClientConfiguration client, + SearchResult result, + CancellationToken ct) + { + var magnetLink = DownloadClientUriBuilder.NormalizeMagnetLink(result.MagnetLink); + var httpTorrentUrl = NormalizeTorrentUrl(result.TorrentUrl); + var torrentFileData = result.TorrentFileContent; + + if (torrentFileData == null && !string.IsNullOrEmpty(httpTorrentUrl)) + { + var downloadResult = await _torrentFileDownloader.DownloadAsync(httpTorrentUrl, ct); + if (downloadResult.TorrentBytes != null) + { + torrentFileData = downloadResult.TorrentBytes; + _logger.LogInformation($"Pre-downloaded torrent file ({torrentFileData!.Length} bytes) for '{LogRedaction.SanitizeText(result.Title)}'"); + } + else if (downloadResult.HasMagnet && string.IsNullOrEmpty(magnetLink)) + { + magnetLink = DownloadClientUriBuilder.NormalizeMagnetLink(downloadResult.MagnetUri); + _logger.LogInformation($"Indexer redirected to magnet link for '{LogRedaction.SanitizeText(result.Title)}'"); + } + } + + if (torrentFileData == null && string.IsNullOrEmpty(httpTorrentUrl) && string.IsNullOrEmpty(magnetLink)) + { + _logger.LogError($"No torrent URL, no magnet link and no torrent file given, nothing can be added for search result {result.Title}"); + return null; + } + + var hash = GetTorrentHash(result, torrentFileData, magnetLink); + if (string.IsNullOrEmpty(hash)) + { + _logger.LogError($"Unable to compute hash for the given torrent: {result.Title} with torrent URL: {result.TorrentUrl} and magnet link: {result.MagnetLink}"); + return null; + } + + var category = client.Settings?.TryGetValue("category", out var categoryObj) is true + ? categoryObj?.ToString() + : null; + var tags = client.Settings?.TryGetValue("tags", out var tagsObj) is true + ? tagsObj?.ToString() + : null; + + return new QbittorrentTorrentAddPlan( + hash, + client.DownloadPath ?? string.Empty, + category, + tags, + torrentFileData, + magnetLink, + httpTorrentUrl); + } + + private static string? GetTorrentHash(SearchResult result, byte[]? torrentFileData, string? magnetLink) + { + if (torrentFileData != null) + { + using var stream = new MemoryStream(torrentFileData); + var parser = new BencodeParser(); + var torrent = parser.Parse(stream); + return torrent.GetInfoHash(); + } + + return !string.IsNullOrEmpty(magnetLink) + ? TryExtractMagnetHash(magnetLink) + : null; + } + + 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(); + } + } + + internal sealed record QbittorrentTorrentAddPlan( + string Hash, + string SavePath, + string? Category, + string? Tags, + byte[]? TorrentFileData, + string? MagnetLink, + string? HttpTorrentUrl); +} diff --git a/listenarr.infrastructure/Adapters/TransmissionAdapter.cs b/listenarr.infrastructure/Adapters/TransmissionAdapter.cs index 57601a832..7fc6037aa 100644 --- a/listenarr.infrastructure/Adapters/TransmissionAdapter.cs +++ b/listenarr.infrastructure/Adapters/TransmissionAdapter.cs @@ -17,8 +17,6 @@ */ using System.Globalization; using System.Net; -using System.Net.Http.Headers; -using System.Text; using System.Text.Encodings.Web; using System.Text.Json; using Listenarr.Application.Interfaces; @@ -40,12 +38,16 @@ public class TransmissionAdapter : IDownloadClientAdapter private readonly IHttpClientFactory _httpClientFactory; private readonly ITorrentFileDownloader _torrentFileDownloader; private readonly ILogger _logger; + private readonly TransmissionTorrentAddPlanner _torrentAddPlanner; + private readonly TransmissionRpcClient _rpcClient; public TransmissionAdapter(IHttpClientFactory httpClientFactory, ITorrentFileDownloader torrentFileDownloader, ILogger logger) { _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); _torrentFileDownloader = torrentFileDownloader ?? throw new ArgumentNullException(nameof(torrentFileDownloader)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _torrentAddPlanner = new TransmissionTorrentAddPlanner(_torrentFileDownloader, _logger); + _rpcClient = new TransmissionRpcClient(_httpClientFactory, ClientType, _logger); } public async Task<(bool Success, string Message)> TestConnectionAsync(DownloadClientConfiguration client, CancellationToken ct = default) @@ -59,7 +61,7 @@ public TransmissionAdapter(IHttpClientFactory httpClientFactory, ITorrentFileDow arguments = new { }, tag = 1 }; - var response = await InvokeRpcAsync(client, payload, ct); + var response = await _rpcClient.InvokeAsync(client, payload, ct); // Validate that the RPC endpoint actually responded with a successful session-get. // Without this check, a non-Transmission service on the same port (or Transmission's @@ -100,143 +102,8 @@ public TransmissionAdapter(IHttpClientFactory httpClientFactory, ITorrentFileDow if (client == null) throw new ArgumentNullException(nameof(client)); if (result == null) throw new ArgumentNullException(nameof(result)); - var arguments = new Dictionary(); - - // Prefer cached torrent file data over URL (required for private trackers with authentication) - byte[]? torrentFileData = result.TorrentFileContent; - 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", - 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 - // weaker. When a separate TorrentUrl (HTTP) is available alongside a magnet link, - // prefer fetching the .torrent file from TorrentUrl. The .torrent file contains - // full tracker lists and piece hashes, giving Transmission everything it needs to - // start immediately without metadata resolution. - if ((torrentFileData == null || torrentFileData.Length == 0) && - 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(httpTorrentUrl)); - try - { - var altResult = await _torrentFileDownloader.DownloadAsync(httpTorrentUrl, ct); - if (altResult.HasBytes) - { - torrentFileData = altResult.TorrentBytes; - _logger.LogInformation("Pre-downloaded .torrent file ({Bytes} bytes) from TorrentUrl for '{Title}' — using instead of magnet link", - torrentFileData!.Length, LogRedaction.SanitizeText(result.Title)); - } - else - { - _logger.LogDebug("TorrentUrl pre-download did not return file data for '{Title}', will use magnet link", LogRedaction.SanitizeText(result.Title)); - } - } - catch (Exception ex) when (ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogDebug(ex, "TorrentUrl pre-download failed for '{Title}', will use magnet link", LogRedaction.SanitizeText(result.Title)); - } - } - - // Pre-download torrent file if not cached and URL is HTTP(S) (not magnet). - // Transmission's built-in HTTP client cannot always follow redirects from indexers - // (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) && - !isMagnetTarget && - !string.IsNullOrEmpty(httpTorrentUrl)) - { - _logger.LogDebug("Attempting pre-download of torrent file from {Url}", LogRedaction.SanitizeUrl(httpTorrentUrl)); - try - { - var downloadResult = await _torrentFileDownloader.DownloadAsync(httpTorrentUrl, ct); - if (downloadResult.HasBytes) - { - torrentFileData = downloadResult.TorrentBytes; - _logger.LogInformation("Pre-downloaded torrent file ({Bytes} bytes) for '{Title}'", - torrentFileData!.Length, LogRedaction.SanitizeText(result.Title)); - } - else if (downloadResult.HasMagnet) - { - // Indexer redirected to a magnet link — use it directly - torrentUrl = DownloadClientUriBuilder.NormalizeMagnetLink(downloadResult.MagnetUri); - _logger.LogInformation("Indexer redirected to magnet link for '{Title}'", LogRedaction.SanitizeText(result.Title)); - } - else - { - _logger.LogWarning("Pre-download returned no data for '{Title}', falling back to URL", LogRedaction.SanitizeText(result.Title)); - } - } - 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(result.Title)); - } - } - else if (torrentFileData == null || torrentFileData.Length == 0) - { - _logger.LogDebug("Skipping pre-download: torrentFileData={HasData}, torrentUrl={Url}, isMagnet={IsMagnet}", - torrentFileData != null && torrentFileData.Length > 0 ? "has data" : "null/empty", - string.IsNullOrEmpty(torrentUrl) ? "(empty)" : LogRedaction.SanitizeUrl(torrentUrl), - torrentUrl?.StartsWith("magnet:", StringComparison.OrdinalIgnoreCase) == true ? "yes" : "no"); - } - - if (torrentFileData != null && torrentFileData.Length > 0) - { - // Use metainfo field for torrent file data (base64 encoded) - arguments["metainfo"] = Convert.ToBase64String(torrentFileData); - _logger.LogDebug("Using cached torrent file data ({Bytes} bytes) for '{Title}'", torrentFileData.Length, LogRedaction.SanitizeText(result.Title)); - } - else - { - // Fall back to filename field for URLs/magnet links - if (string.IsNullOrEmpty(torrentUrl)) - { - throw new ArgumentException("No magnet link, torrent URL, or cached torrent file provided", nameof(result)); - } - - // Transmission does not reliably decode percent-encoded magnet parameter - // values, so decode safe values ahead of time. Leave values encoded when - // decoding would introduce top-level separators like '&' or '#' and corrupt - // the magnet payload. - if (torrentUrl.StartsWith("magnet:", StringComparison.OrdinalIgnoreCase)) - { - var normalizedMagnetUrl = NormalizeMagnetUriForTransmission(torrentUrl); - if (!string.Equals(normalizedMagnetUrl, torrentUrl, StringComparison.Ordinal)) - { - _logger.LogDebug("Normalized percent-encoded magnet link for Transmission compatibility"); - } - torrentUrl = normalizedMagnetUrl; - } - - arguments["filename"] = torrentUrl; - _logger.LogDebug("Using torrent URL for '{Title}': {Url}", LogRedaction.SanitizeText(result.Title), LogRedaction.SanitizeUrl(torrentUrl)); - } - - // Only include download-dir if it's not empty (Transmission requires absolute path or omit) - if (!string.IsNullOrWhiteSpace(client.DownloadPath)) - { - arguments["download-dir"] = client.DownloadPath; - } - - // Explicitly request that the torrent starts immediately. Without this, - // Transmission uses its session setting `start-added-torrents` which - // defaults to true but may be set to false by the user. - arguments["paused"] = false; - var labels = CollectLabels(client); - if (labels.Count > 0) - { - arguments["labels"] = labels.ToArray(); - } + var arguments = await _torrentAddPlanner.BuildArgumentsAsync(client, result, labels, ct); // Use old format for compatibility with Transmission < 4.1.0 var payload = new @@ -248,7 +115,7 @@ public TransmissionAdapter(IHttpClientFactory httpClientFactory, ITorrentFileDow try { - var response = await InvokeRpcAsync(client, payload, ct); + var response = await _rpcClient.InvokeAsync(client, payload, ct); // Log the full response for debugging _logger.LogDebug("Transmission add torrent response: {Response}", response.GetRawText()); @@ -309,7 +176,7 @@ public async Task RemoveAsync(DownloadClientConfiguration client, string i try { - var response = await InvokeRpcAsync(client, payload, ct); + var response = await _rpcClient.InvokeAsync(client, payload, ct); if (response.TryGetProperty("result", out var resultProp) && string.Equals(resultProp.GetString(), "success", StringComparison.OrdinalIgnoreCase)) { _logger.LogInformation("Removed torrent {Id} from Transmission (deleteFiles={DeleteFiles})", LogRedaction.SanitizeText(id), deleteFiles); @@ -351,7 +218,7 @@ public async Task> GetQueueAsync(DownloadClientConfiguration cli try { - var response = await InvokeRpcAsync(client, payload, ct); + var response = await _rpcClient.InvokeAsync(client, payload, ct); if (!response.TryGetProperty("arguments", out var args) || !args.TryGetProperty("torrents", out var torrents) || torrents.ValueKind != JsonValueKind.Array) { return items; @@ -408,7 +275,7 @@ public async Task> GetItemsAsync(DownloadClientConfigur try { var sessionPayload = new { method = "session-get", arguments = new { }, tag = 99 }; - var sessionResp = await InvokeRpcAsync(client, sessionPayload, ct); + var sessionResp = await _rpcClient.InvokeAsync(client, sessionPayload, ct); if (sessionResp.TryGetProperty("arguments", out var sessionArgs)) { sessionSeedRatioLimited = (sessionArgs.TryGetProperty("seedRatioLimited", out var srl) || sessionArgs.TryGetProperty("seed_ratio_limited", out srl)) && srl.GetBoolean(); @@ -441,7 +308,7 @@ public async Task> GetItemsAsync(DownloadClientConfigur try { - var response = await InvokeRpcAsync(client, payload, ct); + var response = await _rpcClient.InvokeAsync(client, payload, ct); if (!response.TryGetProperty("arguments", out var args) || !args.TryGetProperty("torrents", out var torrents) || torrents.ValueKind != JsonValueKind.Array) { return items; @@ -511,7 +378,7 @@ public async Task GetImportItemAsync( try { - var response = await InvokeRpcAsync(client, payload, ct); + var response = await _rpcClient.InvokeAsync(client, payload, ct); if (!response.TryGetProperty("arguments", out var args) || !args.TryGetProperty("torrents", out var torrents) || torrents.ValueKind != JsonValueKind.Array) @@ -599,7 +466,7 @@ public async Task GetImportItemAsync( try { - var response = await InvokeRpcAsync(client, payload, ct); + var response = await _rpcClient.InvokeAsync(client, payload, ct); if (!response.TryGetProperty("arguments", out var args) || !args.TryGetProperty("torrents", out var torrents) || torrents.ValueKind != JsonValueKind.Array) @@ -703,183 +570,6 @@ private object[] ParseTransmissionIds(string id) return new object[] { id }; } - /// - /// JsonSerializerOptions that use UnsafeRelaxedJsonEscaping so that characters like - /// &, +, and = inside magnet-link query strings are NOT escaped to \u00XX sequences. - /// Transmission's built-in JSON parser does not always decode unicode escape sequences - /// correctly, which causes tracker URLs in magnet links (&tr=...) to be silently lost. - /// - private static readonly JsonSerializerOptions s_rpcJsonOptions = new() - { - Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping - }; - - private async Task InvokeRpcAsync(DownloadClientConfiguration client, object payload, CancellationToken ct) - { - var httpClient = _httpClientFactory.CreateClient(ClientType); - var baseUrl = BuildBaseUrl(client); - var serializedPayload = JsonSerializer.Serialize(payload, s_rpcJsonOptions); - string? sessionId = null; - - _logger.LogDebug("Transmission RPC request to {Url}: {Payload}", LogRedaction.SanitizeUrl(baseUrl), LogRedaction.SanitizeText(serializedPayload, 500)); - - for (var attempt = 0; attempt < 2; attempt++) - { - using var request = new HttpRequestMessage(HttpMethod.Post, baseUrl) - { - Content = new StringContent(serializedPayload, Encoding.UTF8, "application/json") - }; - - if (!string.IsNullOrEmpty(sessionId)) - { - request.Headers.Add("X-Transmission-Session-Id", sessionId); - _logger.LogDebug("Using X-Transmission-Session-Id: {SessionId}", LogRedaction.SanitizeText(sessionId)); - } - - var authHeader = BuildAuthHeader(client); - if (authHeader != null) - { - request.Headers.Authorization = authHeader; - } - - var response = await httpClient.SendAsync(request, ct); - var body = await response.Content.ReadAsStringAsync(ct); - - if (response.StatusCode == HttpStatusCode.Conflict && attempt == 0 && response.Headers.TryGetValues("X-Transmission-Session-Id", out var values)) - { - sessionId = values.FirstOrDefault(); - _logger.LogDebug("Received 409 Conflict, retrying with session ID: {SessionId}", LogRedaction.SanitizeText(sessionId)); - continue; - } - - if (!response.IsSuccessStatusCode) - { - var sensitiveValues = LogRedaction.GetSensitiveValuesFromEnvironment().Concat(new[] { client.Password ?? string.Empty }); - var redacted = LogRedaction.RedactText(body, sensitiveValues); - _logger.LogWarning("Transmission returned {StatusCode}: {Body}", response.StatusCode, redacted); - throw new HttpRequestException($"Transmission returned {response.StatusCode}: {redacted}", null, response.StatusCode); - } - - _logger.LogDebug("Transmission RPC response ({StatusCode}): {Body}", response.StatusCode, body); - - if (string.IsNullOrWhiteSpace(body)) - { - _logger.LogWarning("Transmission returned empty response body"); - using var emptyDoc = JsonDocument.Parse("{}"); - return emptyDoc.RootElement.Clone(); - } - - // Validate the response is actually JSON before parsing. A non-Transmission service - // (or the web UI on the wrong port) may return HTML which would fail JSON parsing - // with an unhelpful error message. - var trimmedBody = body.TrimStart(); - if (trimmedBody.Length > 0 && trimmedBody[0] != '{' && trimmedBody[0] != '[') - { - var preview = trimmedBody.Length > 100 ? trimmedBody[..100] + "..." : trimmedBody; - _logger.LogWarning("Transmission RPC returned non-JSON response: {Preview}", LogRedaction.SanitizeText(preview)); - throw new HttpRequestException("Transmission RPC endpoint returned a non-JSON response. Verify the host and port point to the Transmission RPC endpoint (default port 9091)."); - } - - using var doc = JsonDocument.Parse(body); - return doc.RootElement.Clone(); - } - - throw new InvalidOperationException("Transmission did not supply a session identifier after retrying."); - } - - private static string BuildBaseUrl(DownloadClientConfiguration client) - { - var rpcPath = "/transmission/rpc"; - if (client.Settings?.TryGetValue("urlBase", out var urlBaseObj) is true) - { - var custom = urlBaseObj?.ToString()?.Trim(); - if (!string.IsNullOrEmpty(custom)) - { - rpcPath = custom.StartsWith('/') ? custom : "/" + custom; - } - } - return DownloadClientUriBuilder.BuildUri(client, rpcPath).ToString(); - } - - private static string? NormalizeTorrentUrl(string? torrentUrl) - { - var trimmed = (torrentUrl ?? string.Empty).Trim(); - if (trimmed.Length == 0) - { - return null; - } - - if (!DownloadClientUriBuilder.TryParseHttpOrHttpsAbsoluteUri(trimmed, out var torrentUri)) - { - throw new ArgumentException("Torrent URL must be an absolute HTTP or HTTPS URL.", nameof(torrentUrl)); - } - - return torrentUri!.ToString(); - } - - private static string NormalizeMagnetUriForTransmission(string magnetUri) - { - var queryStart = magnetUri.IndexOf('?'); - if (queryStart < 0 || queryStart >= magnetUri.Length - 1) - { - return magnetUri; - } - - var segments = magnetUri[(queryStart + 1)..].Split('&'); - var changed = false; - - for (var i = 0; i < segments.Length; i++) - { - var segment = segments[i]; - if (string.IsNullOrEmpty(segment)) - { - continue; - } - - var equalsIndex = segment.IndexOf('='); - if (equalsIndex <= 0 || equalsIndex >= segment.Length - 1) - { - continue; - } - - var value = segment[(equalsIndex + 1)..]; - if (!value.Contains('%')) - { - continue; - } - - var decodedValue = Uri.UnescapeDataString(value); - if (decodedValue.Contains('&') || decodedValue.Contains('#')) - { - continue; - } - - if (!string.Equals(decodedValue, value, StringComparison.Ordinal)) - { - segments[i] = $"{segment[..(equalsIndex + 1)]}{decodedValue}"; - changed = true; - } - } - - if (!changed) - { - return magnetUri; - } - - return $"{magnetUri[..(queryStart + 1)]}{string.Join("&", segments)}"; - } - - private static AuthenticationHeaderValue? BuildAuthHeader(DownloadClientConfiguration client) - { - if (string.IsNullOrWhiteSpace(client.Username)) - { - return null; - } - - var credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{client.Username}:{client.Password}")); - return new AuthenticationHeaderValue("Basic", credentials); - } - private async Task PreDownloadTorrentFileAsync(string torrentUrl, CancellationToken ct) { using var downloadCts = CancellationTokenSource.CreateLinkedTokenSource(ct); diff --git a/listenarr.infrastructure/Adapters/TransmissionRpcClient.cs b/listenarr.infrastructure/Adapters/TransmissionRpcClient.cs new file mode 100644 index 000000000..847a52c56 --- /dev/null +++ b/listenarr.infrastructure/Adapters/TransmissionRpcClient.cs @@ -0,0 +1,135 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Encodings.Web; +using System.Text.Json; +using Listenarr.Application.Security; +using Listenarr.Domain.Models; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Infrastructure.Adapters +{ + internal sealed class TransmissionRpcClient + { + private static readonly JsonSerializerOptions s_rpcJsonOptions = new() + { + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }; + + private readonly IHttpClientFactory _httpClientFactory; + private readonly string _clientType; + private readonly ILogger _logger; + + public TransmissionRpcClient(IHttpClientFactory httpClientFactory, string clientType, ILogger logger) + { + _httpClientFactory = httpClientFactory; + _clientType = clientType; + _logger = logger; + } + + public async Task InvokeAsync(DownloadClientConfiguration client, object payload, CancellationToken ct) + { + var httpClient = _httpClientFactory.CreateClient(_clientType); + var baseUrl = BuildBaseUrl(client); + var serializedPayload = JsonSerializer.Serialize(payload, s_rpcJsonOptions); + string? sessionId = null; + + _logger.LogDebug("Transmission RPC request to {Url}: {Payload}", LogRedaction.SanitizeUrl(baseUrl), LogRedaction.SanitizeText(serializedPayload, 500)); + + for (var attempt = 0; attempt < 2; attempt++) + { + using var request = new HttpRequestMessage(HttpMethod.Post, baseUrl) + { + Content = new StringContent(serializedPayload, Encoding.UTF8, "application/json") + }; + + if (!string.IsNullOrEmpty(sessionId)) + { + request.Headers.Add("X-Transmission-Session-Id", sessionId); + _logger.LogDebug("Using X-Transmission-Session-Id: {SessionId}", LogRedaction.SanitizeText(sessionId)); + } + + var authHeader = BuildAuthHeader(client); + if (authHeader != null) + { + request.Headers.Authorization = authHeader; + } + + var response = await httpClient.SendAsync(request, ct); + var body = await response.Content.ReadAsStringAsync(ct); + + if (response.StatusCode == HttpStatusCode.Conflict && attempt == 0 && response.Headers.TryGetValues("X-Transmission-Session-Id", out var values)) + { + sessionId = values.FirstOrDefault(); + _logger.LogDebug("Received 409 Conflict, retrying with session ID: {SessionId}", LogRedaction.SanitizeText(sessionId)); + continue; + } + + if (!response.IsSuccessStatusCode) + { + var sensitiveValues = LogRedaction.GetSensitiveValuesFromEnvironment().Concat(new[] { client.Password ?? string.Empty }); + var redacted = LogRedaction.RedactText(body, sensitiveValues); + _logger.LogWarning("Transmission returned {StatusCode}: {Body}", response.StatusCode, redacted); + throw new HttpRequestException($"Transmission returned {response.StatusCode}: {redacted}", null, response.StatusCode); + } + + _logger.LogDebug("Transmission RPC response ({StatusCode}): {Body}", response.StatusCode, body); + + if (string.IsNullOrWhiteSpace(body)) + { + _logger.LogWarning("Transmission returned empty response body"); + using var emptyDoc = JsonDocument.Parse("{}"); + return emptyDoc.RootElement.Clone(); + } + + var trimmedBody = body.TrimStart(); + if (trimmedBody.Length > 0 && trimmedBody[0] != '{' && trimmedBody[0] != '[') + { + var preview = trimmedBody.Length > 100 ? trimmedBody[..100] + "..." : trimmedBody; + _logger.LogWarning("Transmission RPC returned non-JSON response: {Preview}", LogRedaction.SanitizeText(preview)); + throw new HttpRequestException("Transmission RPC endpoint returned a non-JSON response. Verify the host and port point to the Transmission RPC endpoint (default port 9091)."); + } + + using var doc = JsonDocument.Parse(body); + return doc.RootElement.Clone(); + } + + throw new InvalidOperationException("Transmission did not supply a session identifier after retrying."); + } + + private static string BuildBaseUrl(DownloadClientConfiguration client) + { + var rpcPath = "/transmission/rpc"; + if (client.Settings?.TryGetValue("urlBase", out var urlBaseObj) is true) + { + var custom = urlBaseObj?.ToString()?.Trim(); + if (!string.IsNullOrEmpty(custom)) + { + rpcPath = custom.StartsWith('/') ? custom : "/" + custom; + } + } + return DownloadClientUriBuilder.BuildUri(client, rpcPath).ToString(); + } + + private static AuthenticationHeaderValue? BuildAuthHeader(DownloadClientConfiguration client) + { + if (string.IsNullOrWhiteSpace(client.Username)) + { + return null; + } + + var credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{client.Username}:{client.Password}")); + return new AuthenticationHeaderValue("Basic", credentials); + } + } +} diff --git a/listenarr.infrastructure/Adapters/TransmissionTorrentAddPlanner.cs b/listenarr.infrastructure/Adapters/TransmissionTorrentAddPlanner.cs new file mode 100644 index 000000000..4bd79dfcb --- /dev/null +++ b/listenarr.infrastructure/Adapters/TransmissionTorrentAddPlanner.cs @@ -0,0 +1,240 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +using Listenarr.Application.Security; +using Listenarr.Domain.Models; +using Listenarr.Infrastructure.Torrents; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Infrastructure.Adapters +{ + internal sealed class TransmissionTorrentAddPlanner + { + private readonly ITorrentFileDownloader _torrentFileDownloader; + private readonly ILogger _logger; + + public TransmissionTorrentAddPlanner(ITorrentFileDownloader torrentFileDownloader, ILogger logger) + { + _torrentFileDownloader = torrentFileDownloader; + _logger = logger; + } + + public async Task> BuildArgumentsAsync( + DownloadClientConfiguration client, + SearchResult result, + IReadOnlyCollection labels, + CancellationToken ct) + { + var arguments = new Dictionary(); + byte[]? torrentFileData = result.TorrentFileContent; + 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", + isMagnetTarget ? "yes" : "no", + LogRedaction.SanitizeUrl(torrentUrl)); + + torrentFileData = await TryPreferTorrentFileForMagnetAsync(result, torrentFileData, isMagnetTarget, httpTorrentUrl, ct); + (torrentFileData, torrentUrl) = await TryPreDownloadTorrentFileAsync(result, torrentFileData, isMagnetTarget, httpTorrentUrl, torrentUrl, ct); + + if (torrentFileData != null && torrentFileData.Length > 0) + { + arguments["metainfo"] = Convert.ToBase64String(torrentFileData); + _logger.LogDebug("Using cached torrent file data ({Bytes} bytes) for '{Title}'", torrentFileData.Length, LogRedaction.SanitizeText(result.Title)); + } + else + { + if (string.IsNullOrEmpty(torrentUrl)) + { + throw new ArgumentException("No magnet link, torrent URL, or cached torrent file provided", nameof(result)); + } + + if (torrentUrl.StartsWith("magnet:", StringComparison.OrdinalIgnoreCase)) + { + var normalizedMagnetUrl = NormalizeMagnetUriForTransmission(torrentUrl); + if (!string.Equals(normalizedMagnetUrl, torrentUrl, StringComparison.Ordinal)) + { + _logger.LogDebug("Normalized percent-encoded magnet link for Transmission compatibility"); + } + torrentUrl = normalizedMagnetUrl; + } + + arguments["filename"] = torrentUrl; + _logger.LogDebug("Using torrent URL for '{Title}': {Url}", LogRedaction.SanitizeText(result.Title), LogRedaction.SanitizeUrl(torrentUrl)); + } + + if (!string.IsNullOrWhiteSpace(client.DownloadPath)) + { + arguments["download-dir"] = client.DownloadPath; + } + + arguments["paused"] = false; + if (labels.Count > 0) + { + arguments["labels"] = labels.ToArray(); + } + + return arguments; + } + + private async Task TryPreferTorrentFileForMagnetAsync( + SearchResult result, + byte[]? torrentFileData, + bool isMagnetTarget, + string? httpTorrentUrl, + CancellationToken ct) + { + if ((torrentFileData == null || torrentFileData.Length == 0) && + 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(httpTorrentUrl)); + try + { + var altResult = await _torrentFileDownloader.DownloadAsync(httpTorrentUrl, ct); + if (altResult.HasBytes) + { + torrentFileData = altResult.TorrentBytes; + _logger.LogInformation("Pre-downloaded .torrent file ({Bytes} bytes) from TorrentUrl for '{Title}' — using instead of magnet link", + torrentFileData!.Length, LogRedaction.SanitizeText(result.Title)); + } + else + { + _logger.LogDebug("TorrentUrl pre-download did not return file data for '{Title}', will use magnet link", LogRedaction.SanitizeText(result.Title)); + } + } + catch (Exception ex) when (ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogDebug(ex, "TorrentUrl pre-download failed for '{Title}', will use magnet link", LogRedaction.SanitizeText(result.Title)); + } + } + + return torrentFileData; + } + + private async Task<(byte[]? TorrentFileData, string? TorrentUrl)> TryPreDownloadTorrentFileAsync( + SearchResult result, + byte[]? torrentFileData, + bool isMagnetTarget, + string? httpTorrentUrl, + string torrentUrl, + CancellationToken ct) + { + if ((torrentFileData == null || torrentFileData.Length == 0) && + !isMagnetTarget && + !string.IsNullOrEmpty(httpTorrentUrl)) + { + _logger.LogDebug("Attempting pre-download of torrent file from {Url}", LogRedaction.SanitizeUrl(httpTorrentUrl)); + try + { + var downloadResult = await _torrentFileDownloader.DownloadAsync(httpTorrentUrl, ct); + if (downloadResult.HasBytes) + { + torrentFileData = downloadResult.TorrentBytes; + _logger.LogInformation("Pre-downloaded torrent file ({Bytes} bytes) for '{Title}'", + torrentFileData!.Length, LogRedaction.SanitizeText(result.Title)); + } + else if (downloadResult.HasMagnet) + { + torrentUrl = DownloadClientUriBuilder.NormalizeMagnetLink(downloadResult.MagnetUri); + _logger.LogInformation("Indexer redirected to magnet link for '{Title}'", LogRedaction.SanitizeText(result.Title)); + } + else + { + _logger.LogWarning("Pre-download returned no data for '{Title}', falling back to URL", LogRedaction.SanitizeText(result.Title)); + } + } + 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(result.Title)); + } + } + else if (torrentFileData == null || torrentFileData.Length == 0) + { + _logger.LogDebug("Skipping pre-download: torrentFileData={HasData}, torrentUrl={Url}, isMagnet={IsMagnet}", + torrentFileData != null && torrentFileData.Length > 0 ? "has data" : "null/empty", + string.IsNullOrEmpty(torrentUrl) ? "(empty)" : LogRedaction.SanitizeUrl(torrentUrl), + torrentUrl?.StartsWith("magnet:", StringComparison.OrdinalIgnoreCase) == true ? "yes" : "no"); + } + + return (torrentFileData, torrentUrl); + } + + private static string? NormalizeTorrentUrl(string? torrentUrl) + { + var trimmed = (torrentUrl ?? string.Empty).Trim(); + if (trimmed.Length == 0) + { + return null; + } + + if (!DownloadClientUriBuilder.TryParseHttpOrHttpsAbsoluteUri(trimmed, out var torrentUri)) + { + throw new ArgumentException("Torrent URL must be an absolute HTTP or HTTPS URL.", nameof(torrentUrl)); + } + + return torrentUri!.ToString(); + } + + private static string NormalizeMagnetUriForTransmission(string magnetUri) + { + var queryStart = magnetUri.IndexOf('?'); + if (queryStart < 0 || queryStart >= magnetUri.Length - 1) + { + return magnetUri; + } + + var segments = magnetUri[(queryStart + 1)..].Split('&'); + var changed = false; + + for (var i = 0; i < segments.Length; i++) + { + var segment = segments[i]; + if (string.IsNullOrEmpty(segment)) + { + continue; + } + + var equalsIndex = segment.IndexOf('='); + if (equalsIndex <= 0 || equalsIndex >= segment.Length - 1) + { + continue; + } + + var value = segment[(equalsIndex + 1)..]; + if (!value.Contains('%')) + { + continue; + } + + var decodedValue = Uri.UnescapeDataString(value); + if (decodedValue.Contains('&') || decodedValue.Contains('#')) + { + continue; + } + + if (!string.Equals(decodedValue, value, StringComparison.Ordinal)) + { + segments[i] = $"{segment[..(equalsIndex + 1)]}{decodedValue}"; + changed = true; + } + } + + return changed + ? $"{magnetUri[..(queryStart + 1)]}{string.Join("&", segments)}" + : magnetUri; + } + } +} diff --git a/listenarr.infrastructure/Cache/ImageCachePathResolver.cs b/listenarr.infrastructure/Cache/ImageCachePathResolver.cs new file mode 100644 index 000000000..090752972 --- /dev/null +++ b/listenarr.infrastructure/Cache/ImageCachePathResolver.cs @@ -0,0 +1,69 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +using Listenarr.Domain.Common; + +namespace Listenarr.Infrastructure.Cache +{ + internal sealed class ImageCachePathResolver + { + private readonly string _contentRootPath; + + public ImageCachePathResolver(string contentRootPath) + { + _contentRootPath = contentRootPath; + } + + public string GetImagePath(string identifier, string basePath) + { + var sanitized = SanitizeFileName(identifier); + var extensions = new[] { ".jpg", ".jpeg", ".png", ".webp", ".gif", ".svg" }; + + foreach (var ext in extensions) + { + var path = FileUtils.CombineRelativePath(basePath, NormalizeRelativeFileName(sanitized + ext)); + if (File.Exists(path)) + { + return path; + } + } + + return FileUtils.CombineRelativePath(basePath, NormalizeRelativeFileName(sanitized + ".jpg")); + } + + public string GetRelativePath(string fullPath) + { + return Path.GetRelativePath(_contentRootPath, fullPath).Replace('\\', '/'); + } + + public string BuildTempFilePath(string identifier, string extension, string tempCachePath) + { + return BuildFilePath(identifier, extension, tempCachePath); + } + + public string BuildFilePath(string identifier, string extension, string basePath) + { + var fileName = NormalizeRelativeFileName($"{SanitizeFileName(identifier)}{extension}"); + return FileUtils.CombineRelativePath(basePath, fileName); + } + + private static string SanitizeFileName(string fileName) + { + var invalidChars = Path.GetInvalidFileNameChars(); + return string.Join("_", fileName.Split(invalidChars, StringSplitOptions.RemoveEmptyEntries)).Trim(); + } + + private static string NormalizeRelativeFileName(string fileName) + { + var normalized = Path.GetFileName(fileName); + return normalized.TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + } + } +} diff --git a/listenarr.infrastructure/Cache/ImageCacheService.cs b/listenarr.infrastructure/Cache/ImageCacheService.cs index bb9b3fa62..f05ed3651 100644 --- a/listenarr.infrastructure/Cache/ImageCacheService.cs +++ b/listenarr.infrastructure/Cache/ImageCacheService.cs @@ -19,7 +19,6 @@ using AsyncKeyedLock; using Listenarr.Application.Security; using Listenarr.Application.Interfaces; -using Listenarr.Domain.Common; using Microsoft.Extensions.Logging; using SixLabors.ImageSharp; using System.Net; @@ -64,6 +63,7 @@ public class ImageCacheService : IImageCacheService, IDisposable private readonly string _authorImagePath; private readonly string _seriesImagePath; private readonly string _contentRootPath; + private readonly ImageCachePathResolver _pathResolver; private readonly AsyncKeyedLocker _downloadLocks = new(); public ImageCacheService( @@ -78,6 +78,7 @@ public ImageCacheService( _libraryImagePath = applicationPathService.ResolveFromConfig("cache", "images", "library"); _authorImagePath = applicationPathService.ResolveFromConfig("cache", "images", "authors"); _seriesImagePath = applicationPathService.ResolveFromConfig("cache", "images", "series"); + _pathResolver = new ImageCachePathResolver(_contentRootPath); Directory.CreateDirectory(_tempCachePath); Directory.CreateDirectory(_libraryImagePath); @@ -215,8 +216,7 @@ public ImageCacheService( // Determine file extension from content type or URL var extension = GetImageExtension(finalUri.ToString(), mediaType); - var fileName = NormalizeRelativeFileName($"{SanitizeFileName(identifier)}{extension}"); - var filePath = FileUtils.CombineRelativePath(_tempCachePath, fileName); + var filePath = _pathResolver.BuildTempFilePath(identifier, extension, _tempCachePath); // Save to temp cache await File.WriteAllBytesAsync(filePath, imageBytes); @@ -547,12 +547,11 @@ public ImageCacheService( private string? GetBestTempImagePathIfValid(string identifier) { - var sanitized = SanitizeFileName(identifier); var extensions = new[] { ".jpg", ".jpeg", ".png", ".webp", ".gif", ".svg" }; foreach (var ext in extensions) { - var path = FileUtils.CombineRelativePath(_tempCachePath, NormalizeRelativeFileName(sanitized + ext)); + var path = _pathResolver.BuildFilePath(identifier, ext, _tempCachePath); if (!File.Exists(path)) continue; // Remove placeholder images (e.g. 1x1) from temp cache so fallback can continue. @@ -603,37 +602,12 @@ public Task ClearTempCacheAsync() private string GetImagePath(string identifier, string basePath) { - // Try to find existing file with any extension - var sanitized = SanitizeFileName(identifier); - var extensions = new[] { ".jpg", ".jpeg", ".png", ".webp", ".gif", ".svg" }; - - foreach (var ext in extensions) - { - var path = FileUtils.CombineRelativePath(basePath, NormalizeRelativeFileName(sanitized + ext)); - if (File.Exists(path)) - return path; - } - - // Default to .jpg if not found - return FileUtils.CombineRelativePath(basePath, NormalizeRelativeFileName(sanitized + ".jpg")); + return _pathResolver.GetImagePath(identifier, basePath); } private string GetRelativePath(string fullPath) { - var relativePath = Path.GetRelativePath(_contentRootPath, fullPath).Replace("\\", "/"); - return relativePath; - } - - private string SanitizeFileName(string fileName) - { - var invalid = Path.GetInvalidFileNameChars(); - return string.Join("_", fileName.Split(invalid, StringSplitOptions.RemoveEmptyEntries)); - } - - private static string NormalizeRelativeFileName(string fileName) - { - var normalized = Path.GetFileName(fileName); - return normalized.TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + return _pathResolver.GetRelativePath(fullPath); } private static async Task ReadContentWithLimitAsync(HttpContent content, long maxBytes) diff --git a/tests/Builders/ServiceCollectionBuilder.cs b/tests/Builders/ServiceCollectionBuilder.cs index 9f0614e57..606d62133 100644 --- a/tests/Builders/ServiceCollectionBuilder.cs +++ b/tests/Builders/ServiceCollectionBuilder.cs @@ -204,8 +204,11 @@ private ServiceCollection BuildServices() services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); From 69c75000f56e8940eb173e6acb70379782b34d8c Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Sat, 13 Jun 2026 13:27:45 -0400 Subject: [PATCH 24/84] Slice remaining backend workflows --- .../Controllers/IndexerResponseRedactor.cs | 42 ++++ .../Controllers/IndexersController.cs | 14 +- listenarr.api/Controllers/SearchController.cs | 52 +---- .../Controllers/SearchRequestReader.cs | 83 ++++++++ .../DownloadClientIdFallbackResolver.cs | 66 +++++++ .../Downloads/DownloadSearchQueryBuilder.cs | 34 ++++ .../Downloads/DownloadService.cs | 57 +----- .../Search/MyAnonamouseResponseParser.cs | 102 +--------- .../Search/MyAnonamouseSizeParser.cs | 108 +++++++++++ .../Adapters/NzbgetAdapter.cs | 163 ++-------------- .../Adapters/NzbgetNzbDownloader.cs | 81 ++++++++ .../Adapters/NzbgetXmlRpcClient.cs | 115 +++++++++++ .../Adapters/SabnzbdAdapter.cs | 181 +++++++++--------- .../Adapters/SabnzbdRequestBuilder.cs | 56 ++++++ .../Ffmpeg/FfmpegService.cs | 73 +------ .../Ffmpeg/FfprobeTagMetadataMapper.cs | 87 +++++++++ .../Search/AutomaticSearchResultClassifier.cs | 74 +++++++ .../Search/AutomaticSearchService.cs | 64 +------ 18 files changed, 873 insertions(+), 579 deletions(-) create mode 100644 listenarr.api/Controllers/IndexerResponseRedactor.cs create mode 100644 listenarr.api/Controllers/SearchRequestReader.cs create mode 100644 listenarr.application/Downloads/DownloadClientIdFallbackResolver.cs create mode 100644 listenarr.application/Downloads/DownloadSearchQueryBuilder.cs create mode 100644 listenarr.application/Search/MyAnonamouseSizeParser.cs create mode 100644 listenarr.infrastructure/Adapters/NzbgetNzbDownloader.cs create mode 100644 listenarr.infrastructure/Adapters/NzbgetXmlRpcClient.cs create mode 100644 listenarr.infrastructure/Adapters/SabnzbdRequestBuilder.cs create mode 100644 listenarr.infrastructure/Ffmpeg/FfprobeTagMetadataMapper.cs create mode 100644 listenarr.infrastructure/HostedServices/Search/AutomaticSearchResultClassifier.cs diff --git a/listenarr.api/Controllers/IndexerResponseRedactor.cs b/listenarr.api/Controllers/IndexerResponseRedactor.cs new file mode 100644 index 000000000..6fb610183 --- /dev/null +++ b/listenarr.api/Controllers/IndexerResponseRedactor.cs @@ -0,0 +1,42 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +using Listenarr.Application.Security; +using Listenarr.Domain.Models; + +namespace Listenarr.Api.Controllers +{ + internal sealed class IndexerResponseRedactor + { + public bool ShouldRedact(HttpContext httpContext) + { + return HttpSecurityRequestUtils.ShouldRedactSecretsForCaller(httpContext); + } + + public Indexer RedactIndexerForCaller(Indexer indexer, HttpContext httpContext) + { + return ShouldRedact(httpContext) ? ApiResponseRedactor.RedactIndexer(indexer) : indexer; + } + + public List RedactIndexersForCaller(IEnumerable indexers, HttpContext httpContext) + { + return ShouldRedact(httpContext) + ? indexers.Select(ApiResponseRedactor.RedactIndexer).ToList() + : indexers.ToList(); + } + + public string? RedactMamIdForCaller(string? mamId, HttpContext httpContext) + { + return ShouldRedact(httpContext) && !string.IsNullOrWhiteSpace(mamId) + ? ApiResponseRedactor.RedactedValue + : mamId; + } + } +} diff --git a/listenarr.api/Controllers/IndexersController.cs b/listenarr.api/Controllers/IndexersController.cs index 956bed346..463229196 100644 --- a/listenarr.api/Controllers/IndexersController.cs +++ b/listenarr.api/Controllers/IndexersController.cs @@ -39,6 +39,7 @@ public class IndexersController : ControllerBase private readonly HttpClient _httpClientNoRedirect; private readonly IConfigurationService _configurationService; private readonly IndexerTestWorkflow _indexerTestWorkflow; + private readonly IndexerResponseRedactor _responseRedactor; public IndexersController( IIndexerRepository indexerRepository, @@ -56,23 +57,20 @@ public IndexersController( indexerRepository, httpClient, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + _responseRedactor = new IndexerResponseRedactor(); } private bool ShouldRedactIndexerSecretsForCaller() - => HttpSecurityRequestUtils.ShouldRedactSecretsForCaller(HttpContext); + => _responseRedactor.ShouldRedact(HttpContext); private Indexer RedactIndexerForCaller(Indexer indexer) - => ShouldRedactIndexerSecretsForCaller() ? ApiResponseRedactor.RedactIndexer(indexer) : indexer; + => _responseRedactor.RedactIndexerForCaller(indexer, HttpContext); private List RedactIndexersForCaller(IEnumerable indexers) - => ShouldRedactIndexerSecretsForCaller() - ? indexers.Select(ApiResponseRedactor.RedactIndexer).ToList() - : indexers.ToList(); + => _responseRedactor.RedactIndexersForCaller(indexers, HttpContext); private string? RedactMamIdForCaller(string? mamId) - => ShouldRedactIndexerSecretsForCaller() && !string.IsNullOrWhiteSpace(mamId) - ? ApiResponseRedactor.RedactedValue - : mamId; + => _responseRedactor.RedactMamIdForCaller(mamId, HttpContext); private Task ValidateOutboundUrlForCallerAsync(string url) { diff --git a/listenarr.api/Controllers/SearchController.cs b/listenarr.api/Controllers/SearchController.cs index dc853fbad..e7a360f91 100644 --- a/listenarr.api/Controllers/SearchController.cs +++ b/listenarr.api/Controllers/SearchController.cs @@ -16,9 +16,7 @@ * along with this program. If not, see . */ -using System.Text.RegularExpressions; using System.Text.Json; -using System.Text.Json.Serialization; using Microsoft.AspNetCore.Mvc; using Listenarr.Application.Interfaces; using Listenarr.Application.Metadata; @@ -40,6 +38,7 @@ public class SearchController : ControllerBase private readonly IImageCacheService? _imageCacheService; private readonly MetadataConverters _metadataConverters; private readonly SearchResponseMapper _responseMapper; + private readonly SearchRequestReader _requestReader; public SearchController( ISearchService searchService, @@ -60,6 +59,7 @@ public SearchController( metadataService, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance, imageCacheService); + _requestReader = new SearchRequestReader(_logger); } private string BuildApiImagePath(string identifier, string? sourceUrl = null) @@ -81,10 +81,7 @@ public async Task> Search([FromBody] JsonElement reqJson, [ return BadRequest("SearchRequest body is required"); } - var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; - options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase)); - - var req = JsonSerializer.Deserialize(reqJson.GetRawText(), options); + var req = _requestReader.Read(reqJson); if (req == null) return BadRequest("SearchRequest body is required"); _logger.LogDebug("[DBG] Search received mode={Mode}, query='{Query}'", req.Mode, LogRedaction.SanitizeText(req.Query ?? "")); @@ -113,47 +110,10 @@ await SearchResultImageNormalizer.NormalizeMetadataResultsAsync( } else // Advanced { - // Route all advanced search logic through SearchService for normalization, filtering, and orchestration - req.Author = SearchRequestNormalizer.NormalizeStructuredAdvancedField(req.Author, "AUTHOR:"); - req.Title = SearchRequestNormalizer.NormalizeStructuredAdvancedField(req.Title, "TITLE:"); - req.Isbn = SearchRequestNormalizer.NormalizeStructuredAdvancedField(req.Isbn, "ISBN:"); - req.Asin = SearchRequestNormalizer.NormalizeStructuredAdvancedField(req.Asin, "ASIN:"); - - // Validate and normalize ISBN/ASIN inputs for advanced searches. - // If an ISBN-10 is supplied, convert it to ISBN-13 using the 978 prefix. - try - { - if (!string.IsNullOrWhiteSpace(req.Isbn)) - { - var rawIsbn = Regex.Replace(req.Isbn, "[^0-9Xx]", string.Empty); - if (rawIsbn.Length == 10) - { - var converted = SearchRequestNormalizer.ConvertIsbn10ToIsbn13(rawIsbn); - if (converted == null) - { - return BadRequest("Invalid ISBN-10 provided"); - } - req.Isbn = converted; // replace with ISBN-13 - _logger.LogInformation("Converted ISBN-10 to ISBN-13: {Original} -> {Converted}", rawIsbn, converted); - } - else if (rawIsbn.Length == 13) - { - if (!Regex.IsMatch(rawIsbn, "^[0-9]{13}$")) - { - return BadRequest("ISBN must be 13 digits"); - } - req.Isbn = rawIsbn; - } - else - { - return BadRequest("ISBN must be either 10 or 13 characters"); - } - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + var advancedValidationError = _requestReader.NormalizeAdvancedRequest(req); + if (!string.IsNullOrWhiteSpace(advancedValidationError)) { - _logger.LogWarning(ex, "Failed to normalize ISBN in advanced search"); - return BadRequest("Invalid ISBN format"); + return BadRequest(advancedValidationError); } // Compose a query string from advanced parameters for unified handling diff --git a/listenarr.api/Controllers/SearchRequestReader.cs b/listenarr.api/Controllers/SearchRequestReader.cs new file mode 100644 index 000000000..c54a1e785 --- /dev/null +++ b/listenarr.api/Controllers/SearchRequestReader.cs @@ -0,0 +1,83 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; +using Listenarr.Application.Search; + +namespace Listenarr.Api.Controllers +{ + internal sealed class SearchRequestReader + { + private readonly ILogger _logger; + + public SearchRequestReader(ILogger logger) + { + _logger = logger; + } + + public SearchRequest? Read(JsonElement requestJson) + { + var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase)); + return JsonSerializer.Deserialize(requestJson.GetRawText(), options); + } + + public string? NormalizeAdvancedRequest(SearchRequest request) + { + request.Author = SearchRequestNormalizer.NormalizeStructuredAdvancedField(request.Author, "AUTHOR:"); + request.Title = SearchRequestNormalizer.NormalizeStructuredAdvancedField(request.Title, "TITLE:"); + request.Isbn = SearchRequestNormalizer.NormalizeStructuredAdvancedField(request.Isbn, "ISBN:"); + request.Asin = SearchRequestNormalizer.NormalizeStructuredAdvancedField(request.Asin, "ASIN:"); + + try + { + if (string.IsNullOrWhiteSpace(request.Isbn)) + { + return null; + } + + var rawIsbn = Regex.Replace(request.Isbn, "[^0-9Xx]", string.Empty); + if (rawIsbn.Length == 10) + { + var converted = SearchRequestNormalizer.ConvertIsbn10ToIsbn13(rawIsbn); + if (converted == null) + { + return "Invalid ISBN-10 provided"; + } + + request.Isbn = converted; + _logger.LogInformation("Converted ISBN-10 to ISBN-13: {Original} -> {Converted}", rawIsbn, converted); + } + else if (rawIsbn.Length == 13) + { + if (!Regex.IsMatch(rawIsbn, "^[0-9]{13}$")) + { + return "ISBN must be 13 digits"; + } + + request.Isbn = rawIsbn; + } + else + { + return "ISBN must be either 10 or 13 characters"; + } + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning(ex, "Failed to normalize ISBN in advanced search"); + return "Invalid ISBN format"; + } + + return null; + } + } +} diff --git a/listenarr.application/Downloads/DownloadClientIdFallbackResolver.cs b/listenarr.application/Downloads/DownloadClientIdFallbackResolver.cs new file mode 100644 index 000000000..00746c0f1 --- /dev/null +++ b/listenarr.application/Downloads/DownloadClientIdFallbackResolver.cs @@ -0,0 +1,66 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +using System.Text.RegularExpressions; +using Listenarr.Application.Security; +using Listenarr.Domain.Models; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Application.Downloads +{ + internal sealed class DownloadClientIdFallbackResolver + { + private readonly DownloadTypeResolver _downloadTypeResolver; + private readonly ILogger _logger; + + public DownloadClientIdFallbackResolver(DownloadTypeResolver downloadTypeResolver, ILogger logger) + { + _downloadTypeResolver = downloadTypeResolver; + _logger = logger; + } + + public string? TryResolve(DownloadClientConfiguration client, SearchResult searchResult) + { + if (client == null || searchResult == null || !_downloadTypeResolver.IsTorrentResult(searchResult)) + { + return null; + } + + var magnetHash = TryExtractMagnetHash(searchResult.MagnetLink); + if (!string.IsNullOrWhiteSpace(magnetHash)) + { + _logger.LogInformation( + "Using magnet hash fallback for download '{Title}' on client {ClientName}", + LogRedaction.SanitizeText(searchResult.Title), + LogRedaction.SanitizeText(client.Name ?? client.Id)); + return magnetHash; + } + + return null; + } + + private static string? TryExtractMagnetHash(string? magnetLink) + { + if (string.IsNullOrWhiteSpace(magnetLink)) + { + return null; + } + + var match = Regex.Match(magnetLink, @"xt=urn:btih:([^&]+)", RegexOptions.IgnoreCase); + if (!match.Success) + { + return null; + } + + var rawHash = Uri.UnescapeDataString(match.Groups[1].Value).Trim(); + return string.IsNullOrWhiteSpace(rawHash) ? null : rawHash; + } + } +} diff --git a/listenarr.application/Downloads/DownloadSearchQueryBuilder.cs b/listenarr.application/Downloads/DownloadSearchQueryBuilder.cs new file mode 100644 index 000000000..1c20437d6 --- /dev/null +++ b/listenarr.application/Downloads/DownloadSearchQueryBuilder.cs @@ -0,0 +1,34 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +using Listenarr.Domain.Models; + +namespace Listenarr.Application.Downloads +{ + internal static class DownloadSearchQueryBuilder + { + public static string Build(Audiobook audiobook) + { + var parts = new List(); + + if (!string.IsNullOrEmpty(audiobook.Title)) + { + parts.Add(audiobook.Title); + } + + if (audiobook.Authors != null && audiobook.Authors.Any()) + { + parts.Add(audiobook.Authors.First()); + } + + return string.Join(" ", parts); + } + } +} diff --git a/listenarr.application/Downloads/DownloadService.cs b/listenarr.application/Downloads/DownloadService.cs index bdc5360f3..bec8f8518 100644 --- a/listenarr.application/Downloads/DownloadService.cs +++ b/listenarr.application/Downloads/DownloadService.cs @@ -16,7 +16,6 @@ * along with this program. If not, see . */ -using System.Text.RegularExpressions; using Listenarr.Domain.Common; using Listenarr.Application.Interfaces; using Listenarr.Domain.Models; @@ -54,6 +53,7 @@ public class DownloadService( // Track qBittorrent torrent cache for merging incremental updates (clientId -> (torrentHash -> QueueItem)) private readonly Dictionary> _qbittorrentTorrentCache = new(); + private readonly DownloadClientIdFallbackResolver _clientIdFallbackResolver = new(downloadTypeResolver, logger); public async Task StartDownloadAsync(SearchResult searchResult, string downloadClientId, int? audiobookId = null) { @@ -147,7 +147,7 @@ public async Task SearchAndDownloadAsync(int audiobookI } // Build search query from audiobook metadata - var searchQuery = BuildSearchQuery(audiobook); + var searchQuery = DownloadSearchQueryBuilder.Build(audiobook); logger.LogInformation("Searching for audiobook '{Title}' with query: {Query}", LogRedaction.SanitizeText(audiobook.Title), LogRedaction.SanitizeText(searchQuery)); // Search using the working search service. This is an automatic search (triggered @@ -417,7 +417,7 @@ await downloadHistoryService.RecordGrabbedAsync( // Route to appropriate client handler via adapter and capture client-specific IDs when provided string? clientSpecificId = await clientGateway.AddAsync(downloadClient, searchResult); - clientSpecificId ??= TryResolveClientSpecificIdFallback(downloadClient, searchResult); + clientSpecificId ??= _clientIdFallbackResolver.TryResolve(downloadClient, searchResult); // Update download record with client-specific ID if available if (!string.IsNullOrEmpty(clientSpecificId)) @@ -524,20 +524,6 @@ private async Task TryPrepareMyAnonamouseTorrentAsync(SearchResult searchResult, await preparationService.PrepareAsync(searchResult, downloadId); } - private string BuildSearchQuery(Audiobook audiobook) - { - // Build a search query from audiobook metadata - var parts = new List(); - - if (!string.IsNullOrEmpty(audiobook.Title)) - parts.Add(audiobook.Title); - - if (audiobook.Authors != null && audiobook.Authors.Any()) - parts.Add(audiobook.Authors.First()); - - return string.Join(" ", parts); - } - public async Task RemoveFromQueueAsync(string downloadId, string? downloadClientId = null, bool force = false) { try @@ -758,43 +744,6 @@ private async Task LogDownloadHistory(Audiobook audiobook, string source, Search await Task.CompletedTask; } - private string? TryResolveClientSpecificIdFallback(DownloadClientConfiguration client, SearchResult searchResult) - { - if (client == null || searchResult == null || !downloadTypeResolver.IsTorrentResult(searchResult)) - { - return null; - } - - var magnetHash = TryExtractMagnetHash(searchResult.MagnetLink); - if (!string.IsNullOrWhiteSpace(magnetHash)) - { - logger.LogInformation( - "Using magnet hash fallback for download '{Title}' on client {ClientName}", - LogRedaction.SanitizeText(searchResult.Title), - LogRedaction.SanitizeText(client.Name ?? client.Id)); - return magnetHash; - } - - return null; - } - - private static string? TryExtractMagnetHash(string? magnetLink) - { - if (string.IsNullOrWhiteSpace(magnetLink)) - { - return null; - } - - var match = Regex.Match(magnetLink, @"xt=urn:btih:([^&]+)", RegexOptions.IgnoreCase); - if (!match.Success) - { - return null; - } - - var rawHash = Uri.UnescapeDataString(match.Groups[1].Value).Trim(); - return string.IsNullOrWhiteSpace(rawHash) ? null : rawHash; - } - private async Task RemoveFromClientAsync(DownloadClientConfiguration client, string downloadId, Download? downloadRecord = null) { try diff --git a/listenarr.application/Search/MyAnonamouseResponseParser.cs b/listenarr.application/Search/MyAnonamouseResponseParser.cs index 7378fa82a..73e1c003b 100644 --- a/listenarr.application/Search/MyAnonamouseResponseParser.cs +++ b/listenarr.application/Search/MyAnonamouseResponseParser.cs @@ -17,7 +17,6 @@ */ using System.Text.Json; -using System.Text.RegularExpressions; using Listenarr.Application.Common; using Listenarr.Application.Security; using Listenarr.Domain.Models; @@ -380,13 +379,13 @@ public static List Parse(string jsonResponse, Indexer index long size = 0; if (!string.IsNullOrEmpty(sizeStr) && sizeStr != "0") { - size = ParseSizeString(sizeStr, logger); + size = MyAnonamouseSizeParser.ParseSizeString(sizeStr, logger); logger.LogDebug("Parsed size for MyAnonamouse result '{Title}': {Size} bytes from size field '{SizeStr}'", title, size, sizeStr); } else { // Try to extract size from description when size field is 0 - size = ExtractSizeFromMyAnonamouseDescription(description, logger); + size = MyAnonamouseSizeParser.ExtractFromDescription(description, logger); if (size > 0) { logger.LogDebug("Parsed size for MyAnonamouse result '{Title}': {Size} bytes from description", title, size); @@ -823,102 +822,5 @@ public static List Parse(string jsonResponse, Indexer index return null; } - private static long ExtractSizeFromMyAnonamouseDescription(string? description, ILogger logger) - { - if (string.IsNullOrEmpty(description)) - return 0; - - // Look for patterns like "Total Size : 259MB (272 033 986 bytes)" - var match = Regex.Match(description, @"Total Size\s*:\s*([\d\.,]+)\s*(MB|GB|KB|B)\s*\(([\d\s,]+)\s*bytes?\)", RegexOptions.IgnoreCase); - if (match.Success) - { - // Try to parse the bytes value first (most accurate) - var bytesStr = match.Groups[3].Value.Replace(",", "").Replace(" ", ""); - if (long.TryParse(bytesStr, out var bytes)) - { - logger.LogDebug("Extracted size from MyAnonamouse description bytes: {Bytes}", bytes); - return bytes; - } - - // Fallback to parsing the formatted size - var sizeValue = match.Groups[1].Value.Replace(",", ""); - var unit = match.Groups[2].Value.ToUpper(); - if (double.TryParse(sizeValue, out var value)) - { - var result = unit switch - { - "B" => (long)value, - "KB" => (long)(value * 1024), - "MB" => (long)(value * 1024 * 1024), - "GB" => (long)(value * 1024 * 1024 * 1024), - _ => (long)value - }; - logger.LogDebug("Extracted size from MyAnonamouse description formatted: {Value} {Unit} = {Result} bytes", value, unit, result); - return result; - } - } - - // Alternative pattern: just "Total Size : 259MB" without bytes - match = Regex.Match(description, @"Total Size\s*:\s*([\d\.,]+)\s*(MB|GB|KB|B)", RegexOptions.IgnoreCase); - if (match.Success) - { - var sizeValue = match.Groups[1].Value.Replace(",", ""); - var unit = match.Groups[2].Value.ToUpper(); - if (double.TryParse(sizeValue, out var value)) - { - var result = unit switch - { - "B" => (long)value, - "KB" => (long)(value * 1024), - "MB" => (long)(value * 1024 * 1024), - "GB" => (long)(value * 1024 * 1024 * 1024), - _ => (long)value - }; - logger.LogDebug("Extracted size from MyAnonamouse description (no bytes): {Value} {Unit} = {Result} bytes", value, unit, result); - return result; - } - } - - logger.LogDebug("No size found in MyAnonamouse description"); - return 0; - } - - private static long ParseSizeString(string sizeStr, ILogger logger) - { - if (string.IsNullOrEmpty(sizeStr)) - return 0; - - // Remove any commas and extra spaces - sizeStr = sizeStr.Replace(",", "").Trim(); - - // Try to parse as direct bytes first - if (long.TryParse(sizeStr, out var bytes)) - return bytes; - - // Handle formats like "500 MB", "1.2 GB", "1024 KB", "3.7 GiB", "279.0 MiB", etc. - // Support both decimal (KB/MB/GB/TB) and binary (KiB/MiB/GiB/TiB) units - var match = System.Text.RegularExpressions.Regex.Match(sizeStr, @"^([\d\.]+)\s*(KiB|MiB|GiB|TiB|KB|MB|GB|TB|B)$", System.Text.RegularExpressions.RegexOptions.IgnoreCase); - if (match.Success && - double.TryParse(match.Groups[1].Value, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var value)) - { - var unit = match.Groups[2].Value.ToUpper(); - return unit switch - { - "B" => (long)value, - "KB" => (long)(value * 1000), - "MB" => (long)(value * 1000 * 1000), - "GB" => (long)(value * 1000 * 1000 * 1000), - "TB" => (long)(value * 1000 * 1000 * 1000 * 1000), - "KIB" => (long)(value * 1024), - "MIB" => (long)(value * 1024 * 1024), - "GIB" => (long)(value * 1024 * 1024 * 1024), - "TIB" => (long)(value * 1024 * 1024 * 1024 * 1024), - _ => (long)value - }; - } - - logger.LogWarning("Unable to parse size string: '{SizeStr}'", sizeStr); - return 0; - } } } diff --git a/listenarr.application/Search/MyAnonamouseSizeParser.cs b/listenarr.application/Search/MyAnonamouseSizeParser.cs new file mode 100644 index 000000000..0cdd9f8e7 --- /dev/null +++ b/listenarr.application/Search/MyAnonamouseSizeParser.cs @@ -0,0 +1,108 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +using System.Globalization; +using System.Text.RegularExpressions; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Application.Search +{ + internal static class MyAnonamouseSizeParser + { + public static long ExtractFromDescription(string? description, ILogger logger) + { + if (string.IsNullOrEmpty(description)) + return 0; + + var match = Regex.Match(description, @"Total Size\s*:\s*([\d\.,]+)\s*(MB|GB|KB|B)\s*\(([\d\s,]+)\s*bytes?\)", RegexOptions.IgnoreCase); + if (match.Success) + { + var bytesStr = match.Groups[3].Value.Replace(",", "").Replace(" ", ""); + if (long.TryParse(bytesStr, out var bytes)) + { + logger.LogDebug("Extracted size from MyAnonamouse description bytes: {Bytes}", bytes); + return bytes; + } + + var sizeValue = match.Groups[1].Value.Replace(",", ""); + var unit = match.Groups[2].Value.ToUpper(); + if (double.TryParse(sizeValue, out var value)) + { + var result = ParseDecimalUnit(value, unit, binary: true); + logger.LogDebug("Extracted size from MyAnonamouse description formatted: {Value} {Unit} = {Result} bytes", value, unit, result); + return result; + } + } + + match = Regex.Match(description, @"Total Size\s*:\s*([\d\.,]+)\s*(MB|GB|KB|B)", RegexOptions.IgnoreCase); + if (match.Success) + { + var sizeValue = match.Groups[1].Value.Replace(",", ""); + var unit = match.Groups[2].Value.ToUpper(); + if (double.TryParse(sizeValue, out var value)) + { + var result = ParseDecimalUnit(value, unit, binary: true); + logger.LogDebug("Extracted size from MyAnonamouse description (no bytes): {Value} {Unit} = {Result} bytes", value, unit, result); + return result; + } + } + + logger.LogDebug("No size found in MyAnonamouse description"); + return 0; + } + + public static long ParseSizeString(string sizeStr, ILogger logger) + { + if (string.IsNullOrEmpty(sizeStr)) + return 0; + + sizeStr = sizeStr.Replace(",", "").Trim(); + + if (long.TryParse(sizeStr, out var bytes)) + return bytes; + + var match = Regex.Match(sizeStr, @"^([\d\.]+)\s*(KiB|MiB|GiB|TiB|KB|MB|GB|TB|B)$", RegexOptions.IgnoreCase); + if (match.Success && + double.TryParse(match.Groups[1].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var value)) + { + var unit = match.Groups[2].Value.ToUpper(); + return unit switch + { + "B" => (long)value, + "KB" => (long)(value * 1000), + "MB" => (long)(value * 1000 * 1000), + "GB" => (long)(value * 1000 * 1000 * 1000), + "TB" => (long)(value * 1000 * 1000 * 1000 * 1000), + "KIB" => (long)(value * 1024), + "MIB" => (long)(value * 1024 * 1024), + "GIB" => (long)(value * 1024 * 1024 * 1024), + "TIB" => (long)(value * 1024 * 1024 * 1024 * 1024), + _ => (long)value + }; + } + + logger.LogWarning("Unable to parse size string: '{SizeStr}'", sizeStr); + return 0; + } + + private static long ParseDecimalUnit(double value, string unit, bool binary) + { + var multiplier = binary ? 1024L : 1000L; + return unit switch + { + "B" => (long)value, + "KB" => (long)(value * multiplier), + "MB" => (long)(value * multiplier * multiplier), + "GB" => (long)(value * multiplier * multiplier * multiplier), + _ => (long)value + }; + } + } +} diff --git a/listenarr.infrastructure/Adapters/NzbgetAdapter.cs b/listenarr.infrastructure/Adapters/NzbgetAdapter.cs index 5b341c45a..d6f4f8029 100644 --- a/listenarr.infrastructure/Adapters/NzbgetAdapter.cs +++ b/listenarr.infrastructure/Adapters/NzbgetAdapter.cs @@ -20,7 +20,6 @@ using System.Net.Http.Headers; using System.Text; using System.Text.Json; -using System.Xml.Linq; using Listenarr.Application.Interfaces; using Listenarr.Application.Security; using Listenarr.Domain.Common; @@ -41,6 +40,8 @@ public class NzbgetAdapter : IDownloadClientAdapter private readonly IHttpClientFactory _httpClientFactory; private readonly INzbUrlResolver _nzbUrlResolver; private readonly ILogger _logger; + private readonly NzbgetXmlRpcClient _xmlRpcClient; + private readonly NzbgetNzbDownloader _nzbDownloader; public NzbgetAdapter( IHttpClientFactory httpClientFactory, @@ -50,6 +51,8 @@ public NzbgetAdapter( _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); _nzbUrlResolver = nzbUrlResolver ?? throw new ArgumentNullException(nameof(nzbUrlResolver)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _xmlRpcClient = new NzbgetXmlRpcClient(_httpClientFactory, ClientType); + _nzbDownloader = new NzbgetNzbDownloader(_httpClientFactory, ClientType, _logger); } public async Task<(bool Success, string Message)> TestConnectionAsync(DownloadClientConfiguration client, CancellationToken ct = default) @@ -67,7 +70,7 @@ public NzbgetAdapter( try { // Test connection via XML-RPC - var versionResult = await CallXmlRpcAsync(client, "version"); + var versionResult = await _xmlRpcClient.CallAsync(client, "version"); var version = versionResult.Element("string")?.Value ?? "unknown"; if (string.IsNullOrWhiteSpace(version)) @@ -141,7 +144,7 @@ private static bool IsVersion25OrNewer(string version) var droneId = Guid.NewGuid().ToString().Replace("-", string.Empty); // Download NZB content - var nzbBytes = await DownloadNzbAsync(nzbUrl, indexerApiKey, ct); + var nzbBytes = await _nzbDownloader.DownloadAsync(nzbUrl, indexerApiKey, ct); var nzbFileName = BuildNzbFileName(result); var uploadUrl = DownloadClientUriBuilder.BuildUri(client, "/api/v2/nzb"); @@ -214,7 +217,7 @@ private static bool IsVersion25OrNewer(string version) var droneId = Guid.NewGuid().ToString().Replace("-", string.Empty); // Download and base64-encode the NZB content - var nzbBytes = await DownloadNzbAsync(nzbUrl, indexerApiKey, ct); + var nzbBytes = await _nzbDownloader.DownloadAsync(nzbUrl, indexerApiKey, ct); var nzbContentBase64 = Convert.ToBase64String(nzbBytes); var nzbFileName = BuildNzbFileName(result); @@ -232,7 +235,7 @@ private static bool IsVersion25OrNewer(string version) { // Call append via XML-RPC _logger.LogInformation("Calling NZBGet append via XML-RPC for '{Title}'", LogRedaction.SanitizeText(result.Title)); - var appendResult = await CallXmlRpcAsync(client, "append", + var appendResult = await _xmlRpcClient.CallAsync(client, "append", nzbFileName, nzbContentBase64, category ?? string.Empty, @@ -281,7 +284,7 @@ public async Task RemoveAsync(DownloadClientConfiguration client, string i try { // Get history to find the NZBID by matching droneId - var historyResult = await CallXmlRpcAsync(client, "history", false); + var historyResult = await _xmlRpcClient.CallAsync(client, "history", false); var arrayData = historyResult.Element("array")?.Element("data"); var historyCount = arrayData?.Elements("value").Count() ?? 0; @@ -359,7 +362,7 @@ public async Task RemoveAsync(DownloadClientConfiguration client, string i // Try to remove from history first (for completed downloads) try { - var historyDeleteResult = await CallXmlRpcAsync(client, "editqueue", "HistoryDelete", 0, string.Empty, new[] { numericId.Value }); + var historyDeleteResult = await _xmlRpcClient.CallAsync(client, "editqueue", "HistoryDelete", 0, string.Empty, new[] { numericId.Value }); var historySuccess = historyDeleteResult.Element("boolean")?.Value == "1"; if (historySuccess) @@ -377,7 +380,7 @@ public async Task RemoveAsync(DownloadClientConfiguration client, string i try { var command = deleteFiles ? "GroupDeleteFinal" : "GroupDelete"; - var editResult = await CallXmlRpcAsync(client, "editqueue", command, 0, string.Empty, new[] { numericId.Value }); + var editResult = await _xmlRpcClient.CallAsync(client, "editqueue", command, 0, string.Empty, new[] { numericId.Value }); var success = editResult.Element("boolean")?.Value == "1"; if (success) @@ -405,7 +408,7 @@ public async Task> GetQueueAsync(DownloadClientConfiguration cli try { - var listResult = await CallXmlRpcAsync(client, "listgroups"); + var listResult = await _xmlRpcClient.CallAsync(client, "listgroups"); var arrayData = listResult.Element("array")?.Element("data"); if (arrayData == null) @@ -458,7 +461,7 @@ public async Task> GetQueueAsync(DownloadClientConfiguration cli try { - var historyResult = await CallXmlRpcAsync(client, "history", false); + var historyResult = await _xmlRpcClient.CallAsync(client, "history", false); var arrayData = historyResult.Element("array")?.Element("data"); if (arrayData == null) @@ -510,7 +513,7 @@ public async Task> GetItemsAsync(DownloadClientConfigur try { - var listResult = await CallXmlRpcAsync(client, "listgroups"); + var listResult = await _xmlRpcClient.CallAsync(client, "listgroups"); var arrayData = listResult.Element("array")?.Element("data"); if (arrayData == null) @@ -573,7 +576,7 @@ public async Task GetImportItemAsync( try { // Query NZBGet history for the download - var historyResult = await CallXmlRpcAsync(client, "history", false); + var historyResult = await _xmlRpcClient.CallAsync(client, "history", false); var arrayData = historyResult.Element("array")?.Element("data"); if (arrayData == null) @@ -698,140 +701,6 @@ private static string BuildNzbFileName(SearchResult result) return sanitized; } - private async Task CallXmlRpcAsync(DownloadClientConfiguration client, string methodName, params object[] parameters) - { - var baseUrl = DownloadClientUriBuilder.BuildUri(client, "/xmlrpc").ToString(); - var httpClient = _httpClientFactory.CreateClient(ClientType); - - // Build XML-RPC request - var methodCall = new XElement("methodCall", - new XElement("methodName", methodName), - new XElement("params", - parameters.Select(p => new XElement("param", new XElement("value", SerializeValue(p)))) - ) - ); - - var xmlContent = $"\n{methodCall}"; - var content = new StringContent(xmlContent, Encoding.UTF8, "text/xml"); - - using var request = new HttpRequestMessage(HttpMethod.Post, baseUrl) { Content = content }; - var authHeader = BuildAuthHeader(client); - if (authHeader != null) - request.Headers.Authorization = authHeader; - - using var response = await httpClient.SendAsync(request); - var responseBody = await response.Content.ReadAsStringAsync(); - - if (!response.IsSuccessStatusCode) - { - throw new HttpRequestException($"NZBGet XML-RPC error: {response.StatusCode} - {responseBody}", null, response.StatusCode); - } - - var doc = XDocument.Parse(responseBody); - var fault = doc.Root?.Element("fault"); - if (fault != null) - { - var faultStruct = fault.Descendants("member").ToDictionary( - m => m.Element("name")?.Value ?? string.Empty, - m => m.Element("value")?.Value ?? string.Empty - ); - var faultString = faultStruct.GetValueOrDefault("faultString", "Unknown error"); - throw new Exception($"NZBGet XML-RPC fault: {faultString}"); - } - - return doc.Root?.Element("params")?.Element("param")?.Element("value") - ?? throw new Exception("Invalid XML-RPC response"); - } - - private XElement SerializeValue(object value) - { - return value switch - { - string s => new XElement("string", s), - int i => new XElement("i4", i), - bool b => new XElement("boolean", b ? "1" : "0"), - double d => new XElement("double", d.ToString(CultureInfo.InvariantCulture)), - int[] arr => new XElement("array", - new XElement("data", - arr.Select(item => new XElement("value", new XElement("i4", item))) - ) - ), - object[] arr => new XElement("array", - new XElement("data", - arr.Select(item => new XElement("value", SerializeValue(item))) - ) - ), - Dictionary dict => new XElement("struct", - dict.Select(kvp => new XElement("member", - new XElement("name", kvp.Key), - new XElement("value", SerializeValue(kvp.Value)) - )) - ), - _ => new XElement("string", value.ToString() ?? string.Empty) - }; - } - - - private async Task DownloadNzbAsync(string nzbUrl, string? indexerApiKey, CancellationToken ct) - { - // SSRF guard: reject non-HTTP(S) schemes and embedded credentials; allow private/LAN hosts - // because indexers are commonly self-hosted (Prowlarr, Jackett, etc.) on local networks. - if (!OutboundRequestSecurity.TryValidateExternalHttpUrl(nzbUrl, out var ssrfReason, allowPrivateTargets: true)) - { - _logger.LogWarning("Blocked SSRF attempt in NZB download: {Reason}", ssrfReason); - throw new InvalidOperationException($"NZB URL blocked: {ssrfReason}"); - } - - try - { - _logger.LogDebug("Downloading NZB from {Url}", LogRedaction.SanitizeUrl(nzbUrl)); - - var httpClient = _httpClientFactory.CreateClient(ClientType); - using var request = new HttpRequestMessage(HttpMethod.Get, nzbUrl); - - // Note: Newznab/Torznab APIs include the API key in the URL query string (e.g., &apikey=xxx) - // We should NOT add an X-Api-Key header as it may conflict with URL-based authentication - // and cause the API to return error responses instead of the actual NZB file - - // Set User-Agent header - many indexers require this and will reject requests without it - request.Headers.Add("User-Agent", "Listenarr/1.0.0.0"); - - using var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct); - - _logger.LogDebug("NZB download response: StatusCode={StatusCode}, ContentType={ContentType}, ContentLength={ContentLength}", - response.StatusCode, - response.Content.Headers.ContentType?.ToString() ?? "null", - response.Content.Headers.ContentLength?.ToString() ?? "unknown"); - - response.EnsureSuccessStatusCode(); - - var contentBytes = await response.Content.ReadAsByteArrayAsync(ct); - - _logger.LogInformation("Downloaded NZB content: {Size} bytes", contentBytes.Length); - - // If the content is suspiciously small, log it to see if it's an error message - if (contentBytes.Length > 0 && contentBytes.Length < 500) - { - var contentText = Encoding.UTF8.GetString(contentBytes); - _logger.LogWarning("NZB content is suspiciously small ({Size} bytes). Content: {Content}", - contentBytes.Length, contentText); - } - - if (contentBytes.Length == 0) - { - _logger.LogError("Downloaded NZB file is empty (0 bytes) from {Url}", LogRedaction.SanitizeUrl(nzbUrl)); - throw new InvalidOperationException($"Downloaded NZB file is empty from {nzbUrl}"); - } - - return contentBytes; - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogError(ex, "Failed to download NZB content from {Url}", LogRedaction.SanitizeUrl(nzbUrl)); - throw new InvalidOperationException($"Unable to retrieve NZB content from {nzbUrl}"); - } - } - private static AuthenticationHeaderValue? BuildAuthHeader(DownloadClientConfiguration client) { if (string.IsNullOrWhiteSpace(client.Username)) @@ -877,7 +746,7 @@ public async Task GetImportItemAsync( try { // Query NZBGet history for the download - var historyResult = await CallXmlRpcAsync(client, "history", false); + var historyResult = await _xmlRpcClient.CallAsync(client, "history", false); var arrayData = historyResult.Element("array")?.Element("data"); if (arrayData == null) diff --git a/listenarr.infrastructure/Adapters/NzbgetNzbDownloader.cs b/listenarr.infrastructure/Adapters/NzbgetNzbDownloader.cs new file mode 100644 index 000000000..f00dc960b --- /dev/null +++ b/listenarr.infrastructure/Adapters/NzbgetNzbDownloader.cs @@ -0,0 +1,81 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +using System.Text; +using Listenarr.Application.Security; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Infrastructure.Adapters +{ + internal sealed class NzbgetNzbDownloader + { + private readonly IHttpClientFactory _httpClientFactory; + private readonly string _clientType; + private readonly ILogger _logger; + + public NzbgetNzbDownloader(IHttpClientFactory httpClientFactory, string clientType, ILogger logger) + { + _httpClientFactory = httpClientFactory; + _clientType = clientType; + _logger = logger; + } + + public async Task DownloadAsync(string nzbUrl, string? indexerApiKey, CancellationToken ct) + { + if (!OutboundRequestSecurity.TryValidateExternalHttpUrl(nzbUrl, out var ssrfReason, allowPrivateTargets: true)) + { + _logger.LogWarning("Blocked SSRF attempt in NZB download: {Reason}", ssrfReason); + throw new InvalidOperationException($"NZB URL blocked: {ssrfReason}"); + } + + try + { + _logger.LogDebug("Downloading NZB from {Url}", LogRedaction.SanitizeUrl(nzbUrl)); + + var httpClient = _httpClientFactory.CreateClient(_clientType); + using var request = new HttpRequestMessage(HttpMethod.Get, nzbUrl); + request.Headers.Add("User-Agent", "Listenarr/1.0.0.0"); + + using var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct); + + _logger.LogDebug("NZB download response: StatusCode={StatusCode}, ContentType={ContentType}, ContentLength={ContentLength}", + response.StatusCode, + response.Content.Headers.ContentType?.ToString() ?? "null", + response.Content.Headers.ContentLength?.ToString() ?? "unknown"); + + response.EnsureSuccessStatusCode(); + + var contentBytes = await response.Content.ReadAsByteArrayAsync(ct); + + _logger.LogInformation("Downloaded NZB content: {Size} bytes", contentBytes.Length); + + if (contentBytes.Length > 0 && contentBytes.Length < 500) + { + var contentText = Encoding.UTF8.GetString(contentBytes); + _logger.LogWarning("NZB content is suspiciously small ({Size} bytes). Content: {Content}", + contentBytes.Length, contentText); + } + + if (contentBytes.Length == 0) + { + _logger.LogError("Downloaded NZB file is empty (0 bytes) from {Url}", LogRedaction.SanitizeUrl(nzbUrl)); + throw new InvalidOperationException($"Downloaded NZB file is empty from {nzbUrl}"); + } + + return contentBytes; + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogError(ex, "Failed to download NZB content from {Url}", LogRedaction.SanitizeUrl(nzbUrl)); + throw new InvalidOperationException($"Unable to retrieve NZB content from {nzbUrl}"); + } + } + } +} diff --git a/listenarr.infrastructure/Adapters/NzbgetXmlRpcClient.cs b/listenarr.infrastructure/Adapters/NzbgetXmlRpcClient.cs new file mode 100644 index 000000000..6b26baab5 --- /dev/null +++ b/listenarr.infrastructure/Adapters/NzbgetXmlRpcClient.cs @@ -0,0 +1,115 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +using System.Globalization; +using System.Net.Http.Headers; +using System.Text; +using System.Xml.Linq; +using Listenarr.Domain.Models; + +namespace Listenarr.Infrastructure.Adapters +{ + internal sealed class NzbgetXmlRpcClient + { + private readonly IHttpClientFactory _httpClientFactory; + private readonly string _clientType; + + public NzbgetXmlRpcClient(IHttpClientFactory httpClientFactory, string clientType) + { + _httpClientFactory = httpClientFactory; + _clientType = clientType; + } + + public async Task CallAsync(DownloadClientConfiguration client, string methodName, params object[] parameters) + { + var baseUrl = DownloadClientUriBuilder.BuildUri(client, "/xmlrpc").ToString(); + var httpClient = _httpClientFactory.CreateClient(_clientType); + + var methodCall = new XElement("methodCall", + new XElement("methodName", methodName), + new XElement("params", + parameters.Select(p => new XElement("param", new XElement("value", SerializeValue(p)))) + ) + ); + + var xmlContent = $"\n{methodCall}"; + var content = new StringContent(xmlContent, Encoding.UTF8, "text/xml"); + + using var request = new HttpRequestMessage(HttpMethod.Post, baseUrl) { Content = content }; + var authHeader = BuildAuthHeader(client); + if (authHeader != null) + { + request.Headers.Authorization = authHeader; + } + + using var response = await httpClient.SendAsync(request); + var responseBody = await response.Content.ReadAsStringAsync(); + + if (!response.IsSuccessStatusCode) + { + throw new HttpRequestException($"NZBGet XML-RPC error: {response.StatusCode} - {responseBody}", null, response.StatusCode); + } + + var doc = XDocument.Parse(responseBody); + var fault = doc.Root?.Element("fault"); + if (fault != null) + { + var faultStruct = fault.Descendants("member").ToDictionary( + m => m.Element("name")?.Value ?? string.Empty, + m => m.Element("value")?.Value ?? string.Empty + ); + var faultString = faultStruct.GetValueOrDefault("faultString", "Unknown error"); + throw new Exception($"NZBGet XML-RPC fault: {faultString}"); + } + + return doc.Root?.Element("params")?.Element("param")?.Element("value") + ?? throw new Exception("Invalid XML-RPC response"); + } + + private static XElement SerializeValue(object value) + { + return value switch + { + string s => new XElement("string", s), + int i => new XElement("i4", i), + bool b => new XElement("boolean", b ? "1" : "0"), + double d => new XElement("double", d.ToString(CultureInfo.InvariantCulture)), + int[] arr => new XElement("array", + new XElement("data", + arr.Select(item => new XElement("value", new XElement("i4", item))) + ) + ), + object[] arr => new XElement("array", + new XElement("data", + arr.Select(item => new XElement("value", SerializeValue(item))) + ) + ), + Dictionary dict => new XElement("struct", + dict.Select(kvp => new XElement("member", + new XElement("name", kvp.Key), + new XElement("value", SerializeValue(kvp.Value)) + )) + ), + _ => new XElement("string", value.ToString() ?? string.Empty) + }; + } + + private static AuthenticationHeaderValue? BuildAuthHeader(DownloadClientConfiguration client) + { + if (string.IsNullOrWhiteSpace(client.Username)) + { + return null; + } + + var credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{client.Username}:{client.Password}")); + return new AuthenticationHeaderValue("Basic", credentials); + } + } +} diff --git a/listenarr.infrastructure/Adapters/SabnzbdAdapter.cs b/listenarr.infrastructure/Adapters/SabnzbdAdapter.cs index adf9ea658..47b99e075 100644 --- a/listenarr.infrastructure/Adapters/SabnzbdAdapter.cs +++ b/listenarr.infrastructure/Adapters/SabnzbdAdapter.cs @@ -15,6 +15,7 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ +using System.Globalization; using System.Net; using System.Text.Json; using Listenarr.Application.Interfaces; @@ -36,6 +37,7 @@ public class SabnzbdAdapter : IDownloadClientAdapter private readonly INzbUrlResolver _nzbUrlResolver; private readonly ILogger _logger; private readonly IAppMetricsService _appMetricsService; + private readonly SabnzbdRequestBuilder _requestBuilder; public SabnzbdAdapter( IHttpClientFactory httpFactory, @@ -47,6 +49,7 @@ public SabnzbdAdapter( _nzbUrlResolver = nzbUrlResolver ?? throw new ArgumentNullException(nameof(nzbUrlResolver)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _appMetricsService = appMetricsService; + _requestBuilder = new SabnzbdRequestBuilder(); } public async Task<(bool Success, string Message)> TestConnectionAsync(DownloadClientConfiguration client, CancellationToken ct = default) @@ -55,15 +58,15 @@ public SabnzbdAdapter( { if (client == null) throw new ArgumentNullException(nameof(client)); - var baseUrl = DownloadClientUriBuilder.BuildUri(client, "/api").ToString(); - var apiKey = ""; - if (client.Settings != null && client.Settings.TryGetValue("apiKey", out var apiKeyObj)) - apiKey = apiKeyObj?.ToString() ?? ""; - - if (string.IsNullOrEmpty(apiKey)) + var requestContext = _requestBuilder.CreateContext(client); + if (!requestContext.HasApiKey) return (false, "SABnzbd API key not configured in client settings"); - var url = $"{baseUrl}?mode=version&output=json&apikey={Uri.EscapeDataString(apiKey)}"; + var url = _requestBuilder.BuildUrl(requestContext, new Dictionary + { + ["mode"] = "version", + ["output"] = "json" + }); var http = _httpFactory.CreateClient(ClientType); var resp = await http.GetAsync(url, ct); if (!resp.IsSuccessStatusCode) @@ -108,16 +111,8 @@ public SabnzbdAdapter( try { - var baseUrl = DownloadClientUriBuilder.BuildUri(client, "/api").ToString(); - - // Get API key - var apiKey = ""; - if (client.Settings != null && client.Settings.TryGetValue("apiKey", out var apiKeyObj)) - { - apiKey = apiKeyObj?.ToString() ?? ""; - } - - if (string.IsNullOrEmpty(apiKey)) + var requestContext = _requestBuilder.CreateContext(client); + if (!requestContext.HasApiKey) throw new Exception("SABnzbd API key not configured"); var (nzbUrl, indexerApiKey) = await _nzbUrlResolver.ResolveAsync(result, ct); @@ -126,14 +121,12 @@ public SabnzbdAdapter( _logger.LogInformation("Sending NZB to SABnzbd: {Title} from {Source}", LogRedaction.SanitizeText(result.Title), LogRedaction.SanitizeText(result.Source)); - var sensitiveValues = LogRedaction.GetSensitiveValuesFromEnvironment().Concat(new[] { apiKey }).ToList(); - if (!string.IsNullOrEmpty(indexerApiKey)) sensitiveValues.Add(indexerApiKey); + var sensitiveValues = _requestBuilder.BuildSensitiveValues(requestContext, indexerApiKey); var queryParams = new Dictionary { { "mode", "addurl" }, { "name", nzbUrl }, - { "apikey", apiKey }, { "output", "json" }, { "nzbname", result.Title } }; @@ -163,8 +156,7 @@ public SabnzbdAdapter( } queryParams["cat"] = category; - var queryString = string.Join("&", queryParams.Select(kvp => $"{kvp.Key}={Uri.EscapeDataString(kvp.Value)}")); - var requestUrl = $"{baseUrl}?{queryString}"; + var requestUrl = _requestBuilder.BuildUrl(requestContext, queryParams); _logger.LogDebug("SABnzbd request URL: {Url}", LogRedaction.RedactText(requestUrl, sensitiveValues)); @@ -223,15 +215,8 @@ public async Task RemoveAsync(DownloadClientConfiguration client, string i try { - var baseUrl = DownloadClientUriBuilder.BuildUri(client, "/api").ToString(); - - var apiKey = ""; - if (client.Settings != null && client.Settings.TryGetValue("apiKey", out var apiKeyObj)) - { - apiKey = apiKeyObj?.ToString() ?? ""; - } - - if (string.IsNullOrEmpty(apiKey)) + var requestContext = _requestBuilder.CreateContext(client); + if (!requestContext.HasApiKey) { _logger.LogWarning("SABnzbd API key not configured for {ClientName}", client.Name); return false; @@ -242,7 +227,13 @@ public async Task RemoveAsync(DownloadClientConfiguration client, string i bool removedFromHistory = false; // Try to remove from queue first (for active downloads) - var queueRemoveUrl = $"{baseUrl}?mode=queue&name=delete&value={Uri.EscapeDataString(id)}&apikey={Uri.EscapeDataString(apiKey)}&output=json"; + var queueRemoveUrl = _requestBuilder.BuildUrl(requestContext, new Dictionary + { + ["mode"] = "queue", + ["name"] = "delete", + ["value"] = id, + ["output"] = "json" + }); if (deleteFiles) queueRemoveUrl += "&del_files=1"; @@ -265,7 +256,13 @@ public async Task RemoveAsync(DownloadClientConfiguration client, string i } // Try to remove from history (for completed downloads) - var historyRemoveUrl = $"{baseUrl}?mode=history&name=delete&value={Uri.EscapeDataString(id)}&apikey={Uri.EscapeDataString(apiKey)}&output=json"; + var historyRemoveUrl = _requestBuilder.BuildUrl(requestContext, new Dictionary + { + ["mode"] = "history", + ["name"] = "delete", + ["value"] = id, + ["output"] = "json" + }); if (deleteFiles) historyRemoveUrl += "&del_files=1"; @@ -316,21 +313,19 @@ public async Task> GetQueueAsync(DownloadClientConfiguration cli try { - var baseUrl = DownloadClientUriBuilder.BuildUri(client, "/api").ToString(); - var apiKey = ""; - if (client.Settings != null && client.Settings.TryGetValue("apiKey", out var apiKeyObj)) - { - apiKey = apiKeyObj?.ToString() ?? ""; - } - - if (string.IsNullOrEmpty(apiKey)) + var requestContext = _requestBuilder.CreateContext(client); + if (!requestContext.HasApiKey) { _logger.LogWarning("SABnzbd API key not configured for {ClientName}", client.Name); return items; } - var requestUrl = $"{baseUrl}?mode=queue&output=json&apikey={Uri.EscapeDataString(apiKey)}"; - _logger.LogDebug("SABnzbd queue request (redacted): {Url}", LogRedaction.RedactText(requestUrl, LogRedaction.GetSensitiveValuesFromEnvironment().Concat(new[] { apiKey }))); + var requestUrl = _requestBuilder.BuildUrl(requestContext, new Dictionary + { + ["mode"] = "queue", + ["output"] = "json" + }); + _logger.LogDebug("SABnzbd queue request (redacted): {Url}", LogRedaction.RedactText(requestUrl, _requestBuilder.BuildSensitiveValues(requestContext))); var http = _httpFactory.CreateClient(ClientType); var response = await http.GetAsync(requestUrl, ct); @@ -380,7 +375,12 @@ public async Task> GetQueueAsync(DownloadClientConfiguration cli var existingNzoIds = new HashSet(items.Select(i => i.Id), StringComparer.OrdinalIgnoreCase); try { - var historyUrl = $"{baseUrl}?mode=history&output=json&limit=30&apikey={Uri.EscapeDataString(apiKey)}"; + var historyUrl = _requestBuilder.BuildUrl(requestContext, new Dictionary + { + ["mode"] = "history", + ["output"] = "json", + ["limit"] = "30" + }); var historyResp = await http.GetAsync(historyUrl, ct); if (historyResp.IsSuccessStatusCode) { @@ -432,15 +432,15 @@ public async Task> GetQueueAsync(DownloadClientConfiguration cli try { - var baseUrl = DownloadClientUriBuilder.BuildUri(client, "/api").ToString(); - var apiKey = ""; - if (client.Settings != null && client.Settings.TryGetValue("apiKey", out var apiKeyObj)) - { - apiKey = apiKeyObj?.ToString() ?? ""; - } - if (string.IsNullOrEmpty(apiKey)) return result; + var requestContext = _requestBuilder.CreateContext(client); + if (!requestContext.HasApiKey) return result; - var historyUrl = $"{baseUrl}?mode=history&output=json&limit={limit}&apikey={Uri.EscapeDataString(apiKey)}"; + var historyUrl = _requestBuilder.BuildUrl(requestContext, new Dictionary + { + ["mode"] = "history", + ["output"] = "json", + ["limit"] = limit.ToString(CultureInfo.InvariantCulture) + }); var http = _httpFactory.CreateClient(ClientType); var historyResp = await http.GetAsync(historyUrl, ct); if (!historyResp.IsSuccessStatusCode) return result; @@ -479,19 +479,18 @@ public async Task> GetItemsAsync(DownloadClientConfigur try { - var baseUrl = DownloadClientUriBuilder.BuildUri(client, "/api").ToString(); - var apiKey = ""; - if (client.Settings != null && client.Settings.TryGetValue("apiKey", out var apiKeyObj)) - { - apiKey = apiKeyObj?.ToString() ?? ""; - } - if (string.IsNullOrEmpty(apiKey)) + var requestContext = _requestBuilder.CreateContext(client); + if (!requestContext.HasApiKey) { _logger.LogWarning("SABnzbd API key not configured for client {ClientName}", LogRedaction.SanitizeText(client.Name)); return items; } - var requestUrl = $"{baseUrl}?mode=queue&output=json&apikey={Uri.EscapeDataString(apiKey)}"; + var requestUrl = _requestBuilder.BuildUrl(requestContext, new Dictionary + { + ["mode"] = "queue", + ["output"] = "json" + }); var http = _httpFactory.CreateClient(ClientType); var response = await http.GetAsync(requestUrl, ct); if (!response.IsSuccessStatusCode) @@ -569,21 +568,19 @@ public async Task GetImportItemAsync( try { // Query SABnzbd history for the download - var baseUrl = DownloadClientUriBuilder.BuildUri(client, "/api").ToString(); - var apiKey = ""; - if (client.Settings != null && client.Settings.TryGetValue("apiKey", out var apiKeyObj)) - { - apiKey = apiKeyObj?.ToString() ?? ""; - } - - if (string.IsNullOrEmpty(apiKey)) + var requestContext = _requestBuilder.CreateContext(client); + if (!requestContext.HasApiKey) { _logger.LogWarning("SABnzbd API key not configured for client {ClientId}", client.Id); return result; } // Query history with nzo_id filter - var historyUrl = $"{baseUrl}?mode=history&output=json&apikey={Uri.EscapeDataString(apiKey)}"; + var historyUrl = _requestBuilder.BuildUrl(requestContext, new Dictionary + { + ["mode"] = "history", + ["output"] = "json" + }); var http = _httpFactory.CreateClient(ClientType); var historyResp = await http.GetAsync(historyUrl, ct); @@ -673,21 +670,19 @@ public async Task GetImportItemAsync( try { // Query SABnzbd history for the download - var baseUrl = DownloadClientUriBuilder.BuildUri(client, "/api").ToString(); - var apiKey = ""; - if (client.Settings != null && client.Settings.TryGetValue("apiKey", out var apiKeyObj)) - { - apiKey = apiKeyObj?.ToString() ?? ""; - } - - if (string.IsNullOrEmpty(apiKey)) + var requestContext = _requestBuilder.CreateContext(client); + if (!requestContext.HasApiKey) { _logger.LogWarning("SABnzbd API key not configured for client {ClientId}", client.Id); return result; } // Query history with nzo_id filter - var historyUrl = $"{baseUrl}?mode=history&output=json&apikey={Uri.EscapeDataString(apiKey)}"; + var historyUrl = _requestBuilder.BuildUrl(requestContext, new Dictionary + { + ["mode"] = "history", + ["output"] = "json" + }); var http = _httpFactory.CreateClient(ClientType); var historyResp = await http.GetAsync(historyUrl, ct); @@ -750,26 +745,22 @@ public async Task> FetchDownloadsAsync( _logger.LogDebug("Polling SABnzbd client {ClientName}", client.Name); try { - var baseUrl = DownloadClientUriBuilder.BuildUri(client, "/api").ToString(); - using var http = _httpFactory.CreateClient(ClientType); - // Get API key from settings - var apiKey = ""; - if (client.Settings != null && client.Settings.TryGetValue("apiKey", out var apiKeyObj)) - { - apiKey = apiKeyObj?.ToString() ?? ""; - } - - if (string.IsNullOrEmpty(apiKey)) + var requestContext = _requestBuilder.CreateContext(client); + if (!requestContext.HasApiKey) { throw new DownloadClientAdapterPollingException($"SABnzbd API key not configured for client {client.Id}"); } // Poll SABnzbd queue for active downloads progress updates - var queueUrl = $"{baseUrl}?mode=queue&output=json&apikey={Uri.EscapeDataString(apiKey)}"; + var queueUrl = _requestBuilder.BuildUrl(requestContext, new Dictionary + { + ["mode"] = "queue", + ["output"] = "json" + }); // Redacted queue URL for safe diagnostics - _logger.LogDebug("SABnzbd poll queue URL (redacted): {Url}", LogRedaction.RedactText(queueUrl, LogRedaction.GetSensitiveValuesFromEnvironment().Concat([apiKey]))); + _logger.LogDebug("SABnzbd poll queue URL (redacted): {Url}", LogRedaction.RedactText(queueUrl, _requestBuilder.BuildSensitiveValues(requestContext))); using var queueResponse = await http.GetAsync(queueUrl, cancellationToken); if (queueResponse.IsSuccessStatusCode) @@ -850,9 +841,14 @@ double GetDoubleValue(System.Text.Json.JsonElement el) } // Get completed downloads (history) - limit to recent items - var historyUrl = $"{baseUrl}?mode=history&limit=100&output=json&apikey={Uri.EscapeDataString(apiKey)}"; + var historyUrl = _requestBuilder.BuildUrl(requestContext, new Dictionary + { + ["mode"] = "history", + ["limit"] = "100", + ["output"] = "json" + }); // Redacted history URL for safe diagnostics - _logger.LogDebug("SABnzbd history URL (redacted): {Url}", LogRedaction.RedactText(historyUrl, LogRedaction.GetSensitiveValuesFromEnvironment().Concat(new[] { apiKey }))); + _logger.LogDebug("SABnzbd history URL (redacted): {Url}", LogRedaction.RedactText(historyUrl, _requestBuilder.BuildSensitiveValues(requestContext))); using var historyResponse = await http.GetAsync(historyUrl, cancellationToken); if (!historyResponse.IsSuccessStatusCode) @@ -995,4 +991,3 @@ double GetDoubleValue(System.Text.Json.JsonElement el) } } } - diff --git a/listenarr.infrastructure/Adapters/SabnzbdRequestBuilder.cs b/listenarr.infrastructure/Adapters/SabnzbdRequestBuilder.cs new file mode 100644 index 000000000..fde88e5c2 --- /dev/null +++ b/listenarr.infrastructure/Adapters/SabnzbdRequestBuilder.cs @@ -0,0 +1,56 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +using Listenarr.Application.Security; +using Listenarr.Domain.Models; + +namespace Listenarr.Infrastructure.Adapters +{ + internal sealed class SabnzbdRequestBuilder + { + public SabnzbdRequestContext CreateContext(DownloadClientConfiguration client) + { + var baseUrl = DownloadClientUriBuilder.BuildUri(client, "/api").ToString(); + var apiKey = ""; + if (client.Settings != null && client.Settings.TryGetValue("apiKey", out var apiKeyObj)) + { + apiKey = apiKeyObj?.ToString() ?? ""; + } + + return new SabnzbdRequestContext(baseUrl, apiKey); + } + + public string BuildUrl(SabnzbdRequestContext context, IReadOnlyDictionary queryParams) + { + var merged = new Dictionary(queryParams, StringComparer.OrdinalIgnoreCase) + { + ["apikey"] = context.ApiKey + }; + var queryString = string.Join("&", merged.Select(kvp => $"{kvp.Key}={Uri.EscapeDataString(kvp.Value)}")); + return $"{context.BaseUrl}?{queryString}"; + } + + public List BuildSensitiveValues(SabnzbdRequestContext context, string? indexerApiKey = null) + { + var sensitiveValues = LogRedaction.GetSensitiveValuesFromEnvironment().Concat(new[] { context.ApiKey }).ToList(); + if (!string.IsNullOrEmpty(indexerApiKey)) + { + sensitiveValues.Add(indexerApiKey); + } + + return sensitiveValues; + } + } + + internal sealed record SabnzbdRequestContext(string BaseUrl, string ApiKey) + { + public bool HasApiKey => !string.IsNullOrEmpty(ApiKey); + } +} diff --git a/listenarr.infrastructure/Ffmpeg/FfmpegService.cs b/listenarr.infrastructure/Ffmpeg/FfmpegService.cs index ac0a8f262..fde22ad82 100644 --- a/listenarr.infrastructure/Ffmpeg/FfmpegService.cs +++ b/listenarr.infrastructure/Ffmpeg/FfmpegService.cs @@ -753,7 +753,7 @@ public async Task RunFfprobeAsync(string filePath) } if (fmt.TryGetProperty("tags", out var formatTags) && formatTags.ValueKind == JsonValueKind.Object) { - ApplyTagMetadata(metadata, formatTags); + FfprobeTagMetadataMapper.Apply(metadata, formatTags); } } @@ -782,7 +782,7 @@ public async Task RunFfprobeAsync(string filePath) } if (s.TryGetProperty("tags", out var streamTags) && streamTags.ValueKind == JsonValueKind.Object) { - ApplyTagMetadata(metadata, streamTags); + FfprobeTagMetadataMapper.Apply(metadata, streamTags); } break; } @@ -799,75 +799,6 @@ public async Task RunFfprobeAsync(string filePath) return metadata; } - private static void ApplyTagMetadata(AudioMetadata metadata, JsonElement tags) - { - metadata.Title = FirstNonEmpty(metadata.Title, GetTag(tags, "title", "TITLE")); - metadata.Artist = FirstNonEmpty(metadata.Artist, GetTag(tags, "artist", "ARTIST")); - metadata.Album = FirstNonEmpty(metadata.Album, GetTag(tags, "album", "ALBUM")); - metadata.AlbumArtist = FirstNonEmpty(metadata.AlbumArtist, GetTag(tags, "album_artist", "ALBUM_ARTIST", "album artist")); - - metadata.TrackNumber ??= ParseNumericTag(tags, "track", "TRACK", "tracknumber", "TRACKNUMBER"); - metadata.DiscNumber ??= ParseNumericTag(tags, "disc", "DISC", "discnumber", "DISCNUMBER"); - metadata.Year ??= ParseNumericTag(tags, "date", "DATE", "year", "YEAR"); - } - - private static string FirstNonEmpty(params string?[] candidates) - { - foreach (var candidate in candidates.Where(candidate => !string.IsNullOrWhiteSpace(candidate))) - { - return candidate!; - } - - return string.Empty; - } - - private static string? GetTag(JsonElement tags, params string[] names) - { - return names - .Select(name => TryGetTagValue(tags, name, out var value) ? value : null) - .FirstOrDefault(static value => !string.IsNullOrWhiteSpace(value)) - ?.Trim(); - } - - private static int? ParseNumericTag(JsonElement tags, params string[] names) - { - var raw = GetTag(tags, names); - if (string.IsNullOrWhiteSpace(raw)) - { - return null; - } - - var token = raw.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).FirstOrDefault() ?? raw; - var match = System.Text.RegularExpressions.Regex.Match(token, @"\d+"); - return match.Success && int.TryParse(match.Value, out var parsed) ? parsed : null; - } - - private static bool TryGetTagValue(JsonElement tags, string name, out string? value) - { - if (tags.TryGetProperty(name, out var direct) && direct.ValueKind == JsonValueKind.String) - { - value = direct.GetString(); - return true; - } - - foreach (var property in tags.EnumerateObject()) - { - if (!string.Equals(property.Name, name, StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - if (property.Value.ValueKind == JsonValueKind.String) - { - value = property.Value.GetString(); - return true; - } - } - - value = null; - return false; - } - public string FfprobePath { get diff --git a/listenarr.infrastructure/Ffmpeg/FfprobeTagMetadataMapper.cs b/listenarr.infrastructure/Ffmpeg/FfprobeTagMetadataMapper.cs new file mode 100644 index 000000000..bd7a9f07b --- /dev/null +++ b/listenarr.infrastructure/Ffmpeg/FfprobeTagMetadataMapper.cs @@ -0,0 +1,87 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +using System.Text.Json; +using Listenarr.Domain.Models; + +namespace Listenarr.Infrastructure.Ffmpeg +{ + internal static class FfprobeTagMetadataMapper + { + public static void Apply(AudioMetadata metadata, JsonElement tags) + { + metadata.Title = FirstNonEmpty(metadata.Title, GetTag(tags, "title", "TITLE")); + metadata.Artist = FirstNonEmpty(metadata.Artist, GetTag(tags, "artist", "ARTIST")); + metadata.Album = FirstNonEmpty(metadata.Album, GetTag(tags, "album", "ALBUM")); + metadata.AlbumArtist = FirstNonEmpty(metadata.AlbumArtist, GetTag(tags, "album_artist", "ALBUM_ARTIST", "album artist")); + + metadata.TrackNumber ??= ParseNumericTag(tags, "track", "TRACK", "tracknumber", "TRACKNUMBER"); + metadata.DiscNumber ??= ParseNumericTag(tags, "disc", "DISC", "discnumber", "DISCNUMBER"); + metadata.Year ??= ParseNumericTag(tags, "date", "DATE", "year", "YEAR"); + } + + private static string FirstNonEmpty(params string?[] candidates) + { + foreach (var candidate in candidates.Where(candidate => !string.IsNullOrWhiteSpace(candidate))) + { + return candidate!; + } + + return string.Empty; + } + + private static string? GetTag(JsonElement tags, params string[] names) + { + return names + .Select(name => TryGetTagValue(tags, name, out var value) ? value : null) + .FirstOrDefault(static value => !string.IsNullOrWhiteSpace(value)) + ?.Trim(); + } + + private static int? ParseNumericTag(JsonElement tags, params string[] names) + { + var raw = GetTag(tags, names); + if (string.IsNullOrWhiteSpace(raw)) + { + return null; + } + + var token = raw.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).FirstOrDefault() ?? raw; + var match = System.Text.RegularExpressions.Regex.Match(token, @"\d+"); + return match.Success && int.TryParse(match.Value, out var parsed) ? parsed : null; + } + + private static bool TryGetTagValue(JsonElement tags, string name, out string? value) + { + if (tags.TryGetProperty(name, out var direct) && direct.ValueKind == JsonValueKind.String) + { + value = direct.GetString(); + return true; + } + + foreach (var property in tags.EnumerateObject()) + { + if (!string.Equals(property.Name, name, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (property.Value.ValueKind == JsonValueKind.String) + { + value = property.Value.GetString(); + return true; + } + } + + value = null; + return false; + } + } +} diff --git a/listenarr.infrastructure/HostedServices/Search/AutomaticSearchResultClassifier.cs b/listenarr.infrastructure/HostedServices/Search/AutomaticSearchResultClassifier.cs new file mode 100644 index 000000000..e62a798b4 --- /dev/null +++ b/listenarr.infrastructure/HostedServices/Search/AutomaticSearchResultClassifier.cs @@ -0,0 +1,74 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +using Listenarr.Domain.Models; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Infrastructure.HostedServices.Search +{ + internal sealed class AutomaticSearchResultClassifier + { + private readonly ILogger _logger; + + public AutomaticSearchResultClassifier(ILogger logger) + { + _logger = logger; + } + + public string BuildSearchQuery(Audiobook audiobook) + { + var parts = new List(); + + if (!string.IsNullOrEmpty(audiobook.Title)) + parts.Add(audiobook.Title); + + if (audiobook.Authors != null && audiobook.Authors.Any()) + parts.Add(audiobook.Authors.First()); + + if (!string.IsNullOrEmpty(audiobook.Series)) + parts.Add(audiobook.Series); + + return string.Join(" ", parts); + } + + public bool IsTorrentResult(SearchResult result) + { + if (!string.IsNullOrEmpty(result.DownloadType)) + { + if (result.DownloadType == "DDL") + { + return false; + } + else if (result.DownloadType == "Torrent") + { + return true; + } + else if (result.DownloadType == "Usenet") + { + return false; + } + } + + if (!string.IsNullOrEmpty(result.NzbUrl)) + { + return false; + } + + if (!string.IsNullOrEmpty(result.MagnetLink) || !string.IsNullOrEmpty(result.TorrentUrl)) + { + return true; + } + + _logger.LogWarning("Unable to determine result type for '{Title}' from source '{Source}'. No MagnetLink, TorrentUrl, or NzbUrl found. Defaulting to NZB.", + result.Title, result.Source); + return false; + } + } +} diff --git a/listenarr.infrastructure/HostedServices/Search/AutomaticSearchService.cs b/listenarr.infrastructure/HostedServices/Search/AutomaticSearchService.cs index 2226813c3..4b1bdb6f3 100644 --- a/listenarr.infrastructure/HostedServices/Search/AutomaticSearchService.cs +++ b/listenarr.infrastructure/HostedServices/Search/AutomaticSearchService.cs @@ -28,6 +28,7 @@ public class AutomaticSearchService : BackgroundService { private readonly ILogger _logger; private readonly IServiceScopeFactory _serviceScopeFactory; + private readonly AutomaticSearchResultClassifier _resultClassifier; private readonly TimeSpan _searchInterval = TimeSpan.FromHours(6); // Search every 6 hours public AutomaticSearchService( @@ -36,6 +37,7 @@ public AutomaticSearchService( { _logger = logger; _serviceScopeFactory = serviceScopeFactory; + _resultClassifier = new AutomaticSearchResultClassifier(_logger); } protected override async Task ExecuteAsync(CancellationToken stoppingToken) @@ -198,7 +200,7 @@ private async Task ProcessAudiobookAsync( } // Build search query - var searchQuery = BuildSearchQuery(audiobook); + var searchQuery = _resultClassifier.BuildSearchQuery(audiobook); _logger.LogInformation("Searching for audiobook '{Title}' with query: {Query}", audiobook.Title, searchQuery); // Search for results @@ -319,7 +321,7 @@ private async Task ProcessAudiobookAsync( try { // Determine appropriate download client for this result - var isTorrent = IsTorrentResult(topResult.SearchResult); + var isTorrent = _resultClassifier.IsTorrentResult(topResult.SearchResult); var downloadClientId = await GetAppropriateDownloadClientAsync(topResult.SearchResult, isTorrent); if (string.IsNullOrEmpty(downloadClientId)) @@ -491,64 +493,6 @@ private async Task IsQualityCutoffMetAsync( return null; // Unable to determine quality } - private string BuildSearchQuery(Audiobook audiobook) - { - var parts = new List(); - - // Add title - if (!string.IsNullOrEmpty(audiobook.Title)) - parts.Add(audiobook.Title); - - // Add primary author - if (audiobook.Authors != null && audiobook.Authors.Any()) - parts.Add(audiobook.Authors.First()); - - // Add series if available - if (!string.IsNullOrEmpty(audiobook.Series)) - parts.Add(audiobook.Series); - - return string.Join(" ", parts); - } - - private bool IsTorrentResult(SearchResult result) - { - // Check DownloadType first if it's set - if (!string.IsNullOrEmpty(result.DownloadType)) - { - if (result.DownloadType == "DDL") - { - return false; // DDL is not a torrent - } - else if (result.DownloadType == "Torrent") - { - return true; - } - else if (result.DownloadType == "Usenet") - { - return false; - } - } - - // Fallback to legacy detection logic - // Check for NZB first - if it has an NZB URL, it's a Usenet/NZB download - if (!string.IsNullOrEmpty(result.NzbUrl)) - { - return false; - } - - // Check for torrent indicators - magnet link or torrent file - if (!string.IsNullOrEmpty(result.MagnetLink) || !string.IsNullOrEmpty(result.TorrentUrl)) - { - return true; - } - - // If neither is set, we can't reliably determine the type - // Log a warning and default to false (NZB) as a safer choice - _logger.LogWarning("Unable to determine result type for '{Title}' from source '{Source}'. No MagnetLink, TorrentUrl, or NzbUrl found. Defaulting to NZB.", - result.Title, result.Source); - return false; - } - /// /// Determine whether the audiobook already meets the quality cutoff and return the best existing quality string (if any). /// From 0a5c0f46f631fb307c1169838ce8f76004eac721 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Sat, 13 Jun 2026 14:04:39 -0400 Subject: [PATCH 25/84] refactor: slice backend helper workflows - Extract queue metadata matching and Prowlarr toast throttling helpers - Move notification diagnostics, scan path planning, and system formatting into focused helpers --- .../Controllers/ProwlarrCompatController.cs | 62 ++-------- .../Controllers/ProwlarrToastThrottler.cs | 68 +++++++++++ .../Downloads/DownloadQueueMetadataMatcher.cs | 103 ++++++++++++++++ .../Downloads/DownloadQueueService.cs | 85 +------------- .../Notification/NotificationDiagnostics.cs | 69 +++++++++++ .../Notification/NotificationService.cs | 86 +++----------- .../Audiobooks/ScanBackgroundService.cs | 99 +--------------- .../Audiobooks/ScanPathPlanner.cs | 110 ++++++++++++++++++ .../Platform/SystemFormatters.cs | 57 +++++++++ .../Platform/SystemService.cs | 51 ++------ 10 files changed, 447 insertions(+), 343 deletions(-) create mode 100644 listenarr.api/Controllers/ProwlarrToastThrottler.cs create mode 100644 listenarr.application/Downloads/DownloadQueueMetadataMatcher.cs create mode 100644 listenarr.application/Notification/NotificationDiagnostics.cs create mode 100644 listenarr.infrastructure/HostedServices/Audiobooks/ScanPathPlanner.cs create mode 100644 listenarr.infrastructure/Platform/SystemFormatters.cs diff --git a/listenarr.api/Controllers/ProwlarrCompatController.cs b/listenarr.api/Controllers/ProwlarrCompatController.cs index d74c8b000..958bb47a6 100644 --- a/listenarr.api/Controllers/ProwlarrCompatController.cs +++ b/listenarr.api/Controllers/ProwlarrCompatController.cs @@ -56,55 +56,9 @@ private StartupConfig GetStartupConfig() private readonly IApplicationVersionService _applicationVersionService; private readonly ProwlarrIndexerUpsertWorkflow _indexerUpsertWorkflow; - // Suppress update toasts for indexers that were created within this window (in seconds) - private const int NotificationSuppressionSeconds = 5; - - // Track last toast timestamps per indexer id to avoid duplicate toasts when rapid updates/deletes occur - private static readonly System.Collections.Concurrent.ConcurrentDictionary _lastToastTimes = new System.Collections.Concurrent.ConcurrentDictionary(); - - // Track last global toast messages to deduplicate identical messages across indexers (message text -> last sent time) - private static readonly System.Collections.Concurrent.ConcurrentDictionary _lastToastMessages = new System.Collections.Concurrent.ConcurrentDictionary(); - - private static bool ShouldSendToastForIndexer(int indexerId, string message) - { - try - { - var now = DateTime.UtcNow; - if (_lastToastTimes.TryGetValue(indexerId, out var last) && (now - last).TotalSeconds < NotificationSuppressionSeconds) - { - return false; - } - - // Update last time for this indexer - _lastToastTimes[indexerId] = now; - return true; - } - catch (Exception caughtEx_1) when (caughtEx_1 is not OperationCanceledException && caughtEx_1 is not OutOfMemoryException && caughtEx_1 is not StackOverflowException) - { - // Fallback to sending toast if anything goes wrong with suppression logic - return true; - } - } - - private static bool ShouldSendToastForMessage(string message) - { - try - { - var now = DateTime.UtcNow; - var key = message ?? string.Empty; - if (_lastToastMessages.TryGetValue(key, out var last) && (now - last).TotalSeconds < NotificationSuppressionSeconds) - { - return false; - } - - _lastToastMessages[key] = now; - return true; - } - catch (Exception caughtEx_2) when (caughtEx_2 is not OperationCanceledException && caughtEx_2 is not OutOfMemoryException && caughtEx_2 is not StackOverflowException) - { - return true; - } - } + // Preserve the existing private reflection seam used by controller tests to reset toast state. + private static readonly System.Collections.Concurrent.ConcurrentDictionary _lastToastTimes = ProwlarrToastThrottler.LastToastTimes; + private static readonly System.Collections.Concurrent.ConcurrentDictionary _lastToastMessages = ProwlarrToastThrottler.LastToastMessages; public ProwlarrCompatController( ILogger logger, @@ -386,7 +340,7 @@ public async Task DeleteIndexer(int id) { await _hubBroadcaster.BroadcastAsync(RealtimeHubTarget.Settings, "IndexersUpdated", new { created = 0, skipped = 0, indexers = new[] { new { id = i.Id, name = i.Name, baseUrl = i.Url } } }); var deleteMessage = $"Removed indexer: {i.Name}"; - if (ShouldSendToastForIndexer(i.Id, deleteMessage) && ShouldSendToastForMessage(deleteMessage)) + if (ProwlarrToastThrottler.ShouldSendForIndexer(i.Id) && ProwlarrToastThrottler.ShouldSendForMessage(deleteMessage)) { await _toastService.PublishNotificationAsync("Indexers", deleteMessage, icon: null, timeoutMs: 8000); } @@ -461,7 +415,7 @@ public async Task PutIndexer(int id, [FromBody] System.Text.Json. var publishToast = true; try { - if (createdForBroadcast == 0 && indexer.CreatedAt != default && (DateTime.UtcNow - indexer.CreatedAt).TotalSeconds < NotificationSuppressionSeconds) + if (createdForBroadcast == 0 && indexer.CreatedAt != default && (DateTime.UtcNow - indexer.CreatedAt).TotalSeconds < ProwlarrToastThrottler.NotificationSuppressionSeconds) { publishToast = false; _logger?.LogDebug("Suppressing update toast for indexer {Id} since it was created recently", indexer.Id); @@ -479,12 +433,12 @@ public async Task PutIndexer(int id, [FromBody] System.Text.Json. bool sendByMessage = false; try { - sendByIndexer = ShouldSendToastForIndexer(indexer.Id, toastMessage); + sendByIndexer = ProwlarrToastThrottler.ShouldSendForIndexer(indexer.Id); } catch (Exception caughtEx_4) when (caughtEx_4 is not OperationCanceledException && caughtEx_4 is not OutOfMemoryException && caughtEx_4 is not StackOverflowException) { sendByIndexer = true; } try { - sendByMessage = ShouldSendToastForMessage(toastMessage); + sendByMessage = ProwlarrToastThrottler.ShouldSendForMessage(toastMessage); } catch (Exception caughtEx_5) when (caughtEx_5 is not OperationCanceledException && caughtEx_5 is not OutOfMemoryException && caughtEx_5 is not StackOverflowException) { sendByMessage = true; } @@ -612,7 +566,7 @@ public async Task PostIndexers([FromBody] System.Text.Json.JsonEl { var names = createdIndexers.Select(i => i.Name).ToArray(); var message = names.Length > 0 ? $"Imported {created} indexer(s): {string.Join(", ", names)}" : $"Imported {created} indexer(s) successfully"; - if (ShouldSendToastForMessage(message)) + if (ProwlarrToastThrottler.ShouldSendForMessage(message)) { await _toastService.PublishNotificationAsync("Indexers", message, icon: null, timeoutMs: 8000); } diff --git a/listenarr.api/Controllers/ProwlarrToastThrottler.cs b/listenarr.api/Controllers/ProwlarrToastThrottler.cs new file mode 100644 index 000000000..815e473a8 --- /dev/null +++ b/listenarr.api/Controllers/ProwlarrToastThrottler.cs @@ -0,0 +1,68 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +using System.Collections.Concurrent; + +namespace Listenarr.Api.Controllers +{ + internal static class ProwlarrToastThrottler + { + public const int NotificationSuppressionSeconds = 5; + + internal static readonly ConcurrentDictionary LastToastTimes = new(); + internal static readonly ConcurrentDictionary LastToastMessages = new(); + + public static bool ShouldSendForIndexer(int indexerId) + { + try + { + var now = DateTime.UtcNow; + if (LastToastTimes.TryGetValue(indexerId, out var last) && (now - last).TotalSeconds < NotificationSuppressionSeconds) + { + return false; + } + + LastToastTimes[indexerId] = now; + return true; + } + catch (Exception caughtEx) when (caughtEx is not OperationCanceledException && caughtEx is not OutOfMemoryException && caughtEx is not StackOverflowException) + { + return true; + } + } + + public static bool ShouldSendForMessage(string? message) + { + try + { + var now = DateTime.UtcNow; + var key = message ?? string.Empty; + if (LastToastMessages.TryGetValue(key, out var last) && (now - last).TotalSeconds < NotificationSuppressionSeconds) + { + return false; + } + + LastToastMessages[key] = now; + return true; + } + catch (Exception caughtEx) when (caughtEx is not OperationCanceledException && caughtEx is not OutOfMemoryException && caughtEx is not StackOverflowException) + { + return true; + } + } + } +} diff --git a/listenarr.application/Downloads/DownloadQueueMetadataMatcher.cs b/listenarr.application/Downloads/DownloadQueueMetadataMatcher.cs new file mode 100644 index 000000000..06789293e --- /dev/null +++ b/listenarr.application/Downloads/DownloadQueueMetadataMatcher.cs @@ -0,0 +1,103 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +using System.Text.Json; +using Listenarr.Domain.Models; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Application.Downloads +{ + internal static class DownloadQueueMetadataMatcher + { + public static Download? FindBestMatchingDownload( + QueueItem queueItem, + DownloadClientConfiguration client, + IEnumerable candidateDownloads, + ILogger logger) + { + if (queueItem == null || client == null || candidateDownloads == null) + { + return null; + } + + var matches = candidateDownloads + .Where(download => download.DownloadClientId == client.Id) + .Select(download => new + { + Download = download, + Score = queueItem.GetMatchScore(download) + }) + .Where(x => x.Score > 0) + .OrderByDescending(x => x.Score) + .ThenByDescending(x => x.Download.StartedAt) + .ToList(); + + if (matches.Count == 0) + { + return null; + } + + var bestMatch = matches[0]; + if (bestMatch.Score == 1 && matches.Skip(1).Any(x => x.Score == bestMatch.Score)) + { + logger.LogDebug( + "Queue item {QueueId} '{QueueTitle}' had ambiguous title-only matches on client {ClientId}; leaving unmatched", + queueItem.Id, + queueItem.Title, + client.Id); + return null; + } + + return bestMatch.Download; + } + + public static IEnumerable GetKnownClientItemIds(Dictionary? metadata) + { + var clientDownloadId = GetMetadataString(metadata, "ClientDownloadId"); + if (!string.IsNullOrWhiteSpace(clientDownloadId)) + { + yield return clientDownloadId; + } + + var torrentHash = GetMetadataString(metadata, "TorrentHash"); + if (!string.IsNullOrWhiteSpace(torrentHash)) + { + yield return torrentHash; + } + } + + public static string? GetMetadataString(Dictionary? metadata, string key) + { + if (metadata == null || !metadata.TryGetValue(key, out var value) || value == null) + { + return null; + } + + if (value is JsonElement element) + { + return element.ValueKind switch + { + JsonValueKind.Null => null, + JsonValueKind.Undefined => null, + _ => element.ToString() + }; + } + + return value.ToString(); + } + } +} diff --git a/listenarr.application/Downloads/DownloadQueueService.cs b/listenarr.application/Downloads/DownloadQueueService.cs index 759bb4682..7b53844ad 100644 --- a/listenarr.application/Downloads/DownloadQueueService.cs +++ b/listenarr.application/Downloads/DownloadQueueService.cs @@ -16,7 +16,6 @@ * along with this program. If not, see . */ using System.Diagnostics; -using System.Text.Json; using Listenarr.Application.Interfaces; using Listenarr.Application.Interfaces.Repositories; using Listenarr.Domain.Models; @@ -177,7 +176,7 @@ public async Task GetQueueSnapshotAsync() queueItem.CompletionTime = DateTime.UtcNow; } - var matchedDownload = FindBestMatchingDownload(queueItem, client, allDownloadsForMatching); + var matchedDownload = DownloadQueueMetadataMatcher.FindBestMatchingDownload(queueItem, client, allDownloadsForMatching, logger); if (matchedDownload != null) { var originalClientId = queueItem.Id; @@ -339,7 +338,7 @@ public async Task GetQueueSnapshotAsync() return false; } - if (GetKnownClientItemIds(d.Metadata).Any(allClientItemIds.Contains)) + if (DownloadQueueMetadataMatcher.GetKnownClientItemIds(d.Metadata).Any(allClientItemIds.Contains)) { return false; } @@ -734,7 +733,7 @@ private async Task PersistDiscoveredClientIdentifiersAsync( matchedDownload.Metadata ??= new Dictionary(); - var existingClientDownloadId = GetMetadataString(matchedDownload.Metadata, "ClientDownloadId"); + var existingClientDownloadId = DownloadQueueMetadataMatcher.GetMetadataString(matchedDownload.Metadata, "ClientDownloadId"); if (!string.Equals(existingClientDownloadId, originalClientId, StringComparison.OrdinalIgnoreCase)) { matchedDownload.Metadata["ClientDownloadId"] = originalClientId; @@ -746,7 +745,7 @@ private async Task PersistDiscoveredClientIdentifiersAsync( if (string.Equals(client.Type, "qbittorrent", StringComparison.OrdinalIgnoreCase) || string.Equals(client.Type, "transmission", StringComparison.OrdinalIgnoreCase)) { - var existingTorrentHash = GetMetadataString(matchedDownload.Metadata, "TorrentHash"); + var existingTorrentHash = DownloadQueueMetadataMatcher.GetMetadataString(matchedDownload.Metadata, "TorrentHash"); if (!string.Equals(existingTorrentHash, originalClientId, StringComparison.OrdinalIgnoreCase)) { matchedDownload.Metadata["TorrentHash"] = originalClientId; @@ -755,82 +754,6 @@ private async Task PersistDiscoveredClientIdentifiersAsync( } } - private Download? FindBestMatchingDownload( - QueueItem queueItem, - DownloadClientConfiguration client, - IEnumerable candidateDownloads) - { - if (queueItem == null || client == null || candidateDownloads == null) - { - return null; - } - - var matches = candidateDownloads - .Where(download => download.DownloadClientId == client.Id) - .Select(download => new - { - Download = download, - Score = queueItem.GetMatchScore(download) - }) - .Where(x => x.Score > 0) - .OrderByDescending(x => x.Score) - .ThenByDescending(x => x.Download.StartedAt) - .ToList(); - - if (matches.Count == 0) - { - return null; - } - - var bestMatch = matches[0]; - if (bestMatch.Score == 1 && matches.Skip(1).Any(x => x.Score == bestMatch.Score)) - { - logger.LogDebug( - "Queue item {QueueId} '{QueueTitle}' had ambiguous title-only matches on client {ClientId}; leaving unmatched", - queueItem.Id, - queueItem.Title, - client.Id); - return null; - } - - return bestMatch.Download; - } - - private static IEnumerable GetKnownClientItemIds(Dictionary? metadata) - { - var clientDownloadId = GetMetadataString(metadata, "ClientDownloadId"); - if (!string.IsNullOrWhiteSpace(clientDownloadId)) - { - yield return clientDownloadId; - } - - var torrentHash = GetMetadataString(metadata, "TorrentHash"); - if (!string.IsNullOrWhiteSpace(torrentHash)) - { - yield return torrentHash; - } - } - - private static string? GetMetadataString(Dictionary? metadata, string key) - { - if (metadata == null || !metadata.TryGetValue(key, out var value) || value == null) - { - return null; - } - - if (value is JsonElement element) - { - return element.ValueKind switch - { - JsonValueKind.Null => null, - JsonValueKind.Undefined => null, - _ => element.ToString() - }; - } - - return value.ToString(); - } - private sealed class ClientQueueFetchResult { public ClientQueueFetchResult( diff --git a/listenarr.application/Notification/NotificationDiagnostics.cs b/listenarr.application/Notification/NotificationDiagnostics.cs new file mode 100644 index 000000000..3fff9094e --- /dev/null +++ b/listenarr.application/Notification/NotificationDiagnostics.cs @@ -0,0 +1,69 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +using Listenarr.Application.Security; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Application.Notification +{ + internal static class NotificationDiagnostics + { + public static string AggressiveRedact(string input) + { + if (string.IsNullOrEmpty(input)) return string.Empty; + try + { + var secrets = LogRedaction.GetSensitiveValuesFromEnvironment().Where(s => !string.IsNullOrWhiteSpace(s)).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + var result = input; + foreach (var s in secrets) + { + try + { + var esc = System.Text.RegularExpressions.Regex.Escape(s); + result = System.Text.RegularExpressions.Regex.Replace(result, esc, "", System.Text.RegularExpressions.RegexOptions.IgnoreCase); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + System.Diagnostics.Debug.WriteLine($"NotificationService.AggressiveRedact regex replace failed: {ex.Message}"); + } + } + + return result; + } + catch (Exception caughtEx) when (caughtEx is not OperationCanceledException && caughtEx is not OutOfMemoryException && caughtEx is not StackOverflowException) { return input; } + } + + public static async Task TryReadContentAsync(HttpContent? content, ILogger logger) + { + if (content == null) return string.Empty; + try + { + return await content.ReadAsStringAsync(); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + logger.LogDebug(ex, "Could not read HTTP content for diagnostic logging"); + return string.Empty; + } + } + + public static bool TryValidateWebhookTarget(string webhookUrl, out string reason, bool allowPrivateTargets = false) + { + return OutboundRequestSecurity.TryValidateExternalHttpUrl(webhookUrl, out reason, allowPrivateTargets); + } + } +} diff --git a/listenarr.application/Notification/NotificationService.cs b/listenarr.application/Notification/NotificationService.cs index 5da6074e8..63b6e9a63 100644 --- a/listenarr.application/Notification/NotificationService.cs +++ b/listenarr.application/Notification/NotificationService.cs @@ -215,7 +215,7 @@ public async Task SendNotificationAsync(string trigger, object data, string webh if (string.IsNullOrWhiteSpace(webhookUrl) || enabledTriggers == null || !enabledTriggers.Contains(trigger)) return; var allowPrivateWebhookTargets = AllowPrivateWebhookTargetsForCurrentRequest(); - if (!TryValidateWebhookTarget(webhookUrl, out var validationReason, allowPrivateWebhookTargets)) + if (!NotificationDiagnostics.TryValidateWebhookTarget(webhookUrl, out var validationReason, allowPrivateWebhookTargets)) { _logger.LogWarning("Blocked outbound notification target: {Reason}", validationReason); return; @@ -233,7 +233,7 @@ async Task HandleFailedResponseAsync(HttpResponseMessage response) var redactedUrl = LogRedaction.RedactText(webhookUrl, LogRedaction.GetSensitiveValuesFromEnvironment()); var redactedBody = LogRedaction.RedactText(body, LogRedaction.GetSensitiveValuesFromEnvironment()); - redactedBody = AggressiveRedact(redactedBody); + redactedBody = NotificationDiagnostics.AggressiveRedact(redactedBody); if (string.IsNullOrEmpty(redactedBody)) redactedBody = ""; // Structured log so tests and external consumers can inspect the Body property. @@ -345,7 +345,7 @@ async Task HandleFailedResponseAsync(HttpResponseMessage response) var redactedUrl = LogRedaction.RedactText(webhookUrl, LogRedaction.GetSensitiveValuesFromEnvironment()); var headers = string.Join(", ", request.Headers.Select(h => $"{h.Key}={string.Join(';', h.Value)}")); var requestBody = request.Content != null ? await request.Content.ReadAsStringAsync() : string.Empty; - var redactedRequestBody = AggressiveRedact(LogRedaction.RedactText(requestBody, LogRedaction.GetSensitiveValuesFromEnvironment())); + var redactedRequestBody = NotificationDiagnostics.AggressiveRedact(LogRedaction.RedactText(requestBody, LogRedaction.GetSensitiveValuesFromEnvironment())); _logger.LogInformation("Sending NTFY POST to {WebhookUrl} with headers {Headers} and body: {Body}", redactedUrl, headers, redactedRequestBody); @@ -353,8 +353,8 @@ async Task HandleFailedResponseAsync(HttpResponseMessage response) if (!response.IsSuccessStatusCode) { - var respText = await TryReadContentAsync(response.Content); - var redactedResp = AggressiveRedact(LogRedaction.RedactText(respText, LogRedaction.GetSensitiveValuesFromEnvironment())); + var respText = await NotificationDiagnostics.TryReadContentAsync(response.Content, _logger); + var redactedResp = NotificationDiagnostics.AggressiveRedact(LogRedaction.RedactText(respText, LogRedaction.GetSensitiveValuesFromEnvironment())); _logger.LogWarning("NTFY response from {WebhookUrl}: {Status} - {Body}", redactedUrl, response.StatusCode, redactedResp); await HandleFailedResponseAsync(response); } @@ -418,8 +418,8 @@ async Task HandleFailedResponseAsync(HttpResponseMessage response) }; using var content = new FormUrlEncodedContent(values); - var requestBody = await TryReadContentAsync(content); - var redactedRequestBody = AggressiveRedact(LogRedaction.RedactText(requestBody, LogRedaction.GetSensitiveValuesFromEnvironment())); + var requestBody = await NotificationDiagnostics.TryReadContentAsync(content, _logger); + var redactedRequestBody = NotificationDiagnostics.AggressiveRedact(LogRedaction.RedactText(requestBody, LogRedaction.GetSensitiveValuesFromEnvironment())); var redactedUrl = LogRedaction.RedactText(webhookUrl, LogRedaction.GetSensitiveValuesFromEnvironment()); _logger.LogInformation("Sending Pushover POST to {WebhookUrl} with body: {Body}", redactedUrl, redactedRequestBody); @@ -428,8 +428,8 @@ async Task HandleFailedResponseAsync(HttpResponseMessage response) var response = await PostValidatedAsync(postUrl, content); if (!response.IsSuccessStatusCode) { - var respText = await TryReadContentAsync(response.Content); - var redactedResp = AggressiveRedact(LogRedaction.RedactText(respText, LogRedaction.GetSensitiveValuesFromEnvironment())); + var respText = await NotificationDiagnostics.TryReadContentAsync(response.Content, _logger); + var redactedResp = NotificationDiagnostics.AggressiveRedact(LogRedaction.RedactText(respText, LogRedaction.GetSensitiveValuesFromEnvironment())); _logger.LogWarning("Pushover response from {WebhookUrl}: {Status} - {Body}", redactedUrl, response.StatusCode, redactedResp); await HandleFailedResponseAsync(response); } @@ -488,13 +488,13 @@ async Task HandleFailedResponseAsync(HttpResponseMessage response) using var content = new StringContent(json, Encoding.UTF8, "application/json"); var redactedUrl = LogRedaction.RedactText(webhookUrl, LogRedaction.GetSensitiveValuesFromEnvironment()); - _logger.LogInformation("Sending Telegram POST to {WebhookUrl} with body: {Body}", redactedUrl, AggressiveRedact(LogRedaction.RedactText(json, LogRedaction.GetSensitiveValuesFromEnvironment()))); + _logger.LogInformation("Sending Telegram POST to {WebhookUrl} with body: {Body}", redactedUrl, NotificationDiagnostics.AggressiveRedact(LogRedaction.RedactText(json, LogRedaction.GetSensitiveValuesFromEnvironment()))); var response = await PostValidatedAsync(webhookUrl, content); if (!response.IsSuccessStatusCode) { - var respText = await TryReadContentAsync(response.Content); - var redactedResp = AggressiveRedact(LogRedaction.RedactText(respText, LogRedaction.GetSensitiveValuesFromEnvironment())); + var respText = await NotificationDiagnostics.TryReadContentAsync(response.Content, _logger); + var redactedResp = NotificationDiagnostics.AggressiveRedact(LogRedaction.RedactText(respText, LogRedaction.GetSensitiveValuesFromEnvironment())); _logger.LogWarning("Telegram response from {WebhookUrl}: {Status} - {Body}", redactedUrl, response.StatusCode, redactedResp); await HandleFailedResponseAsync(response); } @@ -583,14 +583,14 @@ async Task HandleFailedResponseAsync(HttpResponseMessage response) request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); var redactedUrl = LogRedaction.RedactText(webhookUrl, LogRedaction.GetSensitiveValuesFromEnvironment()); - var redactedBody = AggressiveRedact(LogRedaction.RedactText(json, LogRedaction.GetSensitiveValuesFromEnvironment())); + var redactedBody = NotificationDiagnostics.AggressiveRedact(LogRedaction.RedactText(json, LogRedaction.GetSensitiveValuesFromEnvironment())); _logger.LogInformation("Sending Pushbullet POST to {WebhookUrl} with body: {Body}", redactedUrl, redactedBody); var response = await SendValidatedAsync(request); if (!response.IsSuccessStatusCode) { - var respText = await TryReadContentAsync(response.Content); - var redactedResp = AggressiveRedact(LogRedaction.RedactText(respText, LogRedaction.GetSensitiveValuesFromEnvironment())); + var respText = await NotificationDiagnostics.TryReadContentAsync(response.Content, _logger); + var redactedResp = NotificationDiagnostics.AggressiveRedact(LogRedaction.RedactText(respText, LogRedaction.GetSensitiveValuesFromEnvironment())); _logger.LogWarning("Pushbullet response from {WebhookUrl}: {Status} - {Body}", redactedUrl, response.StatusCode, redactedResp); await HandleFailedResponseAsync(response); } @@ -644,14 +644,14 @@ async Task HandleFailedResponseAsync(HttpResponseMessage response) using var content = new StringContent(json, Encoding.UTF8, "application/json"); var redactedUrl = LogRedaction.RedactText(webhookUrl, LogRedaction.GetSensitiveValuesFromEnvironment()); - var redactedBody = AggressiveRedact(LogRedaction.RedactText(json, LogRedaction.GetSensitiveValuesFromEnvironment())); + var redactedBody = NotificationDiagnostics.AggressiveRedact(LogRedaction.RedactText(json, LogRedaction.GetSensitiveValuesFromEnvironment())); _logger.LogInformation("Sending Slack POST to {WebhookUrl} with body: {Body}", redactedUrl, redactedBody); var response = await PostValidatedAsync(webhookUrl, content); if (!response.IsSuccessStatusCode) { - var respText = await TryReadContentAsync(response.Content); - var redactedResp = AggressiveRedact(LogRedaction.RedactText(respText, LogRedaction.GetSensitiveValuesFromEnvironment())); + var respText = await NotificationDiagnostics.TryReadContentAsync(response.Content, _logger); + var redactedResp = NotificationDiagnostics.AggressiveRedact(LogRedaction.RedactText(respText, LogRedaction.GetSensitiveValuesFromEnvironment())); _logger.LogWarning("Slack response from {WebhookUrl}: {Status} - {Body}", redactedUrl, response.StatusCode, redactedResp); await HandleFailedResponseAsync(response); } @@ -696,7 +696,7 @@ async Task HandleFailedResponseAsync(HttpResponseMessage response) using var defaultContent = new StringContent(defaultJson, Encoding.UTF8, "application/json"); var redactedUrl = LogRedaction.RedactText(webhookUrl, LogRedaction.GetSensitiveValuesFromEnvironment()); - var redactedBody = AggressiveRedact(LogRedaction.RedactText(defaultJson, LogRedaction.GetSensitiveValuesFromEnvironment())); + var redactedBody = NotificationDiagnostics.AggressiveRedact(LogRedaction.RedactText(defaultJson, LogRedaction.GetSensitiveValuesFromEnvironment())); _logger.LogInformation("Sending Generic POST to {WebhookUrl} with body: {Body}", redactedUrl, redactedBody); var response = await PostValidatedAsync(webhookUrl, defaultContent); @@ -723,53 +723,5 @@ async Task HandleFailedResponseAsync(HttpResponseMessage response) #pragma warning restore CA1031 } - // Ensure that any sensitive environment-derived values are redacted even if they were missed - // by the primary redaction routine. Uses regex replace to catch variants. - private static string AggressiveRedact(string input) - { - if (string.IsNullOrEmpty(input)) return string.Empty; - try - { - var secrets = LogRedaction.GetSensitiveValuesFromEnvironment().Where(s => !string.IsNullOrWhiteSpace(s)).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); - var result = input; - foreach (var s in secrets) - { - try - { - var esc = System.Text.RegularExpressions.Regex.Escape(s); - result = System.Text.RegularExpressions.Regex.Replace(result, esc, "", System.Text.RegularExpressions.RegexOptions.IgnoreCase); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - System.Diagnostics.Debug.WriteLine($"NotificationService.AggressiveRedact regex replace failed: {ex.Message}"); - } - } - - return result; - } - catch (Exception caughtEx_2) when (caughtEx_2 is not OperationCanceledException && caughtEx_2 is not OutOfMemoryException && caughtEx_2 is not StackOverflowException) { return input; } - } - - // Safely attempt to read the content of an HttpContent instance. If reading - // fails (disposed stream, IO error, etc.) the exception is logged at Debug - // and an empty string is returned to avoid masking the original failure. - private async Task TryReadContentAsync(HttpContent? content) - { - if (content == null) return string.Empty; - try - { - return await content.ReadAsStringAsync(); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogDebug(ex, "Could not read HTTP content for diagnostic logging"); - return string.Empty; - } - } - - private static bool TryValidateWebhookTarget(string webhookUrl, out string reason, bool allowPrivateTargets = false) - { - return OutboundRequestSecurity.TryValidateExternalHttpUrl(webhookUrl, out reason, allowPrivateTargets); - } } } diff --git a/listenarr.infrastructure/HostedServices/Audiobooks/ScanBackgroundService.cs b/listenarr.infrastructure/HostedServices/Audiobooks/ScanBackgroundService.cs index b81119c04..6d556d1d6 100644 --- a/listenarr.infrastructure/HostedServices/Audiobooks/ScanBackgroundService.cs +++ b/listenarr.infrastructure/HostedServices/Audiobooks/ScanBackgroundService.cs @@ -316,7 +316,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) } // Calculate base path for the audiobook files - var basePath = CalculateBasePath(foundFiles); + var basePath = ScanPathPlanner.CalculateBasePath(foundFiles); if (!string.IsNullOrEmpty(basePath)) { var basePathChanged = !string.Equals(audiobook.BasePath, basePath, StringComparison.OrdinalIgnoreCase); @@ -582,103 +582,6 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) } } - private string CalculateBasePath(List filePaths) - { - if (!filePaths.Any()) - return string.Empty; - - // Convert all paths to directory paths (get parent directory for each file) - var directories = filePaths - .Select(p => FileUtils.NormalizeStoredPath(Path.GetDirectoryName(p) ?? p)) - .Where(p => !string.IsNullOrWhiteSpace(p)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToList(); - - if (directories.Count == 1) - { - // All files are in the same directory - return directories[0]; - } - - // Find the common ancestor directory - var commonPath = GetCommonPath(directories); - - // Walk up the directory tree until we find a directory that has more than 1 subdirectory or file - var currentPath = commonPath; - while (!string.IsNullOrEmpty(currentPath)) - { - try - { - var parent = Directory.GetParent(currentPath)?.FullName; - if (string.IsNullOrEmpty(parent)) - break; - - // Count subdirectories and files in parent - var subDirs = Directory.GetDirectories(parent).Length; - var files = Directory.GetFiles(parent).Length; - - // If parent has more than 1 thing (subdirs + files), we've found our base path - if (subDirs + files > 1) - { - return currentPath; - } - - currentPath = parent; - } - catch (Exception caughtEx_11) when (caughtEx_11 is not OperationCanceledException && caughtEx_11 is not OutOfMemoryException && caughtEx_11 is not StackOverflowException) - { - // If we can't access the directory, stop here - break; - } - } - - return commonPath; - } - - private string GetCommonPath(List paths) - { - if (!paths.Any()) - return string.Empty; - - var firstPath = FileUtils.NormalizeStoredPath(paths[0]); - var commonPath = firstPath; - - foreach (var path in paths.Skip(1).Select(rawPath => FileUtils.NormalizeStoredPath(rawPath))) - { - var minLength = Math.Min(commonPath.Length, path.Length); - var commonLength = 0; - - for (int i = 0; i < minLength; i++) - { - if (commonPath[i] == path[i]) - commonLength++; - else - break; - } - - // Ensure we don't break in the middle of a directory name - if (commonLength < commonPath.Length) - { - var lastSep = commonPath.LastIndexOf(Path.DirectorySeparatorChar, commonLength - 1); - commonLength = lastSep >= 0 ? lastSep + 1 : 0; - } - - commonPath = commonPath.Substring(0, commonLength); - - if (string.IsNullOrEmpty(commonPath)) - break; - } - - // Ensure it's a valid directory path - if (!string.IsNullOrEmpty(commonPath) && !Directory.Exists(commonPath)) - { - var parent = Directory.GetParent(commonPath)?.FullName; - return parent ?? commonPath; - } - - return commonPath; - } } } - diff --git a/listenarr.infrastructure/HostedServices/Audiobooks/ScanPathPlanner.cs b/listenarr.infrastructure/HostedServices/Audiobooks/ScanPathPlanner.cs new file mode 100644 index 000000000..3ff6e07d7 --- /dev/null +++ b/listenarr.infrastructure/HostedServices/Audiobooks/ScanPathPlanner.cs @@ -0,0 +1,110 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +using Listenarr.Domain.Common; + +namespace Listenarr.Infrastructure.HostedServices.Audiobooks +{ + internal static class ScanPathPlanner + { + public static string CalculateBasePath(List filePaths) + { + if (!filePaths.Any()) + return string.Empty; + + var directories = filePaths + .Select(p => FileUtils.NormalizeStoredPath(Path.GetDirectoryName(p) ?? p)) + .Where(p => !string.IsNullOrWhiteSpace(p)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (directories.Count == 1) + { + return directories[0]; + } + + var commonPath = GetCommonPath(directories); + var currentPath = commonPath; + while (!string.IsNullOrEmpty(currentPath)) + { + try + { + var parent = Directory.GetParent(currentPath)?.FullName; + if (string.IsNullOrEmpty(parent)) + break; + + var subDirs = Directory.GetDirectories(parent).Length; + var files = Directory.GetFiles(parent).Length; + if (subDirs + files > 1) + { + return currentPath; + } + + currentPath = parent; + } + catch (Exception caughtEx) when (caughtEx is not OperationCanceledException && caughtEx is not OutOfMemoryException && caughtEx is not StackOverflowException) + { + break; + } + } + + return commonPath; + } + + private static string GetCommonPath(List paths) + { + if (!paths.Any()) + return string.Empty; + + var firstPath = FileUtils.NormalizeStoredPath(paths[0]); + var commonPath = firstPath; + + foreach (var path in paths.Skip(1).Select(rawPath => FileUtils.NormalizeStoredPath(rawPath))) + { + var minLength = Math.Min(commonPath.Length, path.Length); + var commonLength = 0; + + for (int i = 0; i < minLength; i++) + { + if (commonPath[i] == path[i]) + commonLength++; + else + break; + } + + if (commonLength < commonPath.Length) + { + var lastSep = commonPath.LastIndexOf(Path.DirectorySeparatorChar, commonLength - 1); + commonLength = lastSep >= 0 ? lastSep + 1 : 0; + } + + commonPath = commonPath.Substring(0, commonLength); + + if (string.IsNullOrEmpty(commonPath)) + break; + } + + if (!string.IsNullOrEmpty(commonPath) && !Directory.Exists(commonPath)) + { + var parent = Directory.GetParent(commonPath)?.FullName; + return parent ?? commonPath; + } + + return commonPath; + } + } +} diff --git a/listenarr.infrastructure/Platform/SystemFormatters.cs b/listenarr.infrastructure/Platform/SystemFormatters.cs new file mode 100644 index 000000000..9d6b49305 --- /dev/null +++ b/listenarr.infrastructure/Platform/SystemFormatters.cs @@ -0,0 +1,57 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +namespace Listenarr.Infrastructure.Platform +{ + internal static class SystemFormatters + { + public static string FormatBytes(long bytes) + { + string[] sizes = { "B", "KB", "MB", "GB", "TB" }; + double len = bytes; + int order = 0; + + while (len >= 1024 && order < sizes.Length - 1) + { + order++; + len = len / 1024; + } + + return $"{len:0.##} {sizes[order]}"; + } + + public static string FormatUptime(TimeSpan uptime) + { + if (uptime.TotalDays >= 1) + { + return $"{(int)uptime.TotalDays} days, {uptime.Hours} hours"; + } + else if (uptime.TotalHours >= 1) + { + return $"{(int)uptime.TotalHours} hours, {uptime.Minutes} minutes"; + } + else if (uptime.TotalMinutes >= 1) + { + return $"{(int)uptime.TotalMinutes} minutes"; + } + else + { + return $"{(int)uptime.TotalSeconds} seconds"; + } + } + } +} diff --git a/listenarr.infrastructure/Platform/SystemService.cs b/listenarr.infrastructure/Platform/SystemService.cs index db0b60fcc..eb71f2005 100644 --- a/listenarr.infrastructure/Platform/SystemService.cs +++ b/listenarr.infrastructure/Platform/SystemService.cs @@ -73,7 +73,7 @@ public SystemInfo GetSystemInfo() var version = _applicationVersionService.Resolve(); var uptime = DateTime.UtcNow - _startTime; - var uptimeFormatted = FormatUptime(uptime); + var uptimeFormatted = SystemFormatters.FormatUptime(uptime); var memoryInfo = GetMemoryInfo(); var cpuInfo = GetCpuInfo(); @@ -188,9 +188,9 @@ private DiskStorageInfo BuildDiskInfo(string label, string path, long totalBytes TotalBytes = totalBytes, FreeBytes = freeBytes, UsedPercentage = Math.Round(usedPercentage, 2), - UsedFormatted = FormatBytes(usedBytes), - TotalFormatted = FormatBytes(totalBytes), - FreeFormatted = FormatBytes(freeBytes), + UsedFormatted = SystemFormatters.FormatBytes(usedBytes), + TotalFormatted = SystemFormatters.FormatBytes(totalBytes), + FreeFormatted = SystemFormatters.FormatBytes(freeBytes), Status = "available" }; } @@ -201,7 +201,7 @@ public async Task GetServiceHealthAsync() { var version = _applicationVersionService.Resolve(); var uptime = DateTime.UtcNow - _startTime; - var uptimeFormatted = FormatUptime(uptime); + var uptimeFormatted = SystemFormatters.FormatUptime(uptime); // Get download client health var downloadClientHealth = await GetDownloadClientHealthAsync(); @@ -374,9 +374,9 @@ private MemoryInfo GetMemoryInfo() TotalBytes = totalBytes, FreeBytes = freeBytes, UsedPercentage = Math.Round(usedPercentage, 2), - UsedFormatted = FormatBytes(usedBytes), - TotalFormatted = FormatBytes(totalBytes), - FreeFormatted = FormatBytes(freeBytes) + UsedFormatted = SystemFormatters.FormatBytes(usedBytes), + TotalFormatted = SystemFormatters.FormatBytes(totalBytes), + FreeFormatted = SystemFormatters.FormatBytes(freeBytes) }; } catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) @@ -439,41 +439,6 @@ private string GetRuntimeInfo() return framework; } - private string FormatBytes(long bytes) - { - string[] sizes = { "B", "KB", "MB", "GB", "TB" }; - double len = bytes; - int order = 0; - - while (len >= 1024 && order < sizes.Length - 1) - { - order++; - len = len / 1024; - } - - return $"{len:0.##} {sizes[order]}"; - } - - private string FormatUptime(TimeSpan uptime) - { - if (uptime.TotalDays >= 1) - { - return $"{(int)uptime.TotalDays} days, {uptime.Hours} hours"; - } - else if (uptime.TotalHours >= 1) - { - return $"{(int)uptime.TotalHours} hours, {uptime.Minutes} minutes"; - } - else if (uptime.TotalMinutes >= 1) - { - return $"{(int)uptime.TotalMinutes} minutes"; - } - else - { - return $"{(int)uptime.TotalSeconds} seconds"; - } - } - public List GetRecentLogs(int limit = 100) { var logs = new List(); From 33c8157c2d55448cfc6802779c8aec05b8fffb27 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Sat, 13 Jun 2026 14:59:38 -0400 Subject: [PATCH 26/84] refactor: extract queue snapshot helpers - Move queue snapshot result/cache shaping into focused helper types - Extract system log parsing from SystemService --- .../Downloads/ClientQueueFetchResult.cs | 53 +++++++ .../ClientQueueSnapshotCacheEntry.cs | 33 +++++ .../Downloads/DownloadQueueService.cs | 130 +----------------- .../Downloads/DownloadQueueSnapshotMapper.cs | 97 +++++++++++++ .../Platform/SystemLogParser.cs | 82 +++++++++++ .../Platform/SystemService.cs | 67 +-------- 6 files changed, 273 insertions(+), 189 deletions(-) create mode 100644 listenarr.application/Downloads/ClientQueueFetchResult.cs create mode 100644 listenarr.application/Downloads/ClientQueueSnapshotCacheEntry.cs create mode 100644 listenarr.application/Downloads/DownloadQueueSnapshotMapper.cs create mode 100644 listenarr.infrastructure/Platform/SystemLogParser.cs diff --git a/listenarr.application/Downloads/ClientQueueFetchResult.cs b/listenarr.application/Downloads/ClientQueueFetchResult.cs new file mode 100644 index 000000000..9144c683c --- /dev/null +++ b/listenarr.application/Downloads/ClientQueueFetchResult.cs @@ -0,0 +1,53 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +using Listenarr.Domain.Models; + +namespace Listenarr.Application.Downloads +{ + internal sealed class ClientQueueFetchResult + { + public ClientQueueFetchResult( + DownloadClientConfiguration client, + List queueItems, + bool usedCachedSnapshot, + bool isUnavailable, + TimeSpan? snapshotAge, + string? failureReason, + string snapshotState, + DateTimeOffset? snapshotRefreshedAtUtc) + { + Client = client; + QueueItems = queueItems ?? new List(); + UsedCachedSnapshot = usedCachedSnapshot; + IsUnavailable = isUnavailable; + SnapshotAge = snapshotAge; + FailureReason = failureReason; + SnapshotState = snapshotState; + SnapshotRefreshedAtUtc = snapshotRefreshedAtUtc; + } + + public DownloadClientConfiguration Client { get; } + public List QueueItems { get; } + public bool UsedCachedSnapshot { get; } + public bool IsUnavailable { get; } + public TimeSpan? SnapshotAge { get; } + public string? FailureReason { get; } + public string SnapshotState { get; } + public DateTimeOffset? SnapshotRefreshedAtUtc { get; } + } +} diff --git a/listenarr.application/Downloads/ClientQueueSnapshotCacheEntry.cs b/listenarr.application/Downloads/ClientQueueSnapshotCacheEntry.cs new file mode 100644 index 000000000..ce143334b --- /dev/null +++ b/listenarr.application/Downloads/ClientQueueSnapshotCacheEntry.cs @@ -0,0 +1,33 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +using Listenarr.Domain.Models; + +namespace Listenarr.Application.Downloads +{ + internal sealed class ClientQueueSnapshotCacheEntry + { + public ClientQueueSnapshotCacheEntry(List queueItems, DateTimeOffset refreshedAtUtc) + { + QueueItems = queueItems ?? new List(); + RefreshedAtUtc = refreshedAtUtc; + } + + public List QueueItems { get; } + public DateTimeOffset RefreshedAtUtc { get; } + } +} diff --git a/listenarr.application/Downloads/DownloadQueueService.cs b/listenarr.application/Downloads/DownloadQueueService.cs index 7b53844ad..9ddd8cc0e 100644 --- a/listenarr.application/Downloads/DownloadQueueService.cs +++ b/listenarr.application/Downloads/DownloadQueueService.cs @@ -138,13 +138,13 @@ public async Task GetQueueSnapshotAsync() var includeCompletedExternal = appSettings != null && appSettings.ShowCompletedExternalDownloads; var clientQueueResults = await FetchClientQueueResultsAsync(enabledClients); - var clientStatuses = BuildClientStatuses(clientQueueResults); + var clientStatuses = DownloadQueueSnapshotMapper.BuildClientStatuses(clientQueueResults); foreach (var clientQueueResult in clientQueueResults) { var client = clientQueueResult.Client; var clientQueue = clientQueueResult.QueueItems; - ApplySnapshotMetadata(clientQueue, clientQueueResult); + DownloadQueueSnapshotMapper.ApplySnapshotMetadata(clientQueue, clientQueueResult); try { @@ -533,7 +533,7 @@ private async Task FetchClientQueueResultAsync(DownloadC return new ClientQueueFetchResult( client, - CloneQueueItems(clientQueue), + DownloadQueueSnapshotMapper.CloneQueueItems(clientQueue), usedCachedSnapshot: false, isUnavailable: false, snapshotAge: null, @@ -563,7 +563,7 @@ private async Task FetchClientQueueResultAsync(DownloadC private ClientQueueFetchResult BuildFallbackQueueResult(DownloadClientConfiguration client, string failureReason) { - if (cache.TryGetValue(GetClientQueueSnapshotCacheKey(client), out ClientQueueSnapshotCacheEntry? cachedSnapshot) && + if (cache.TryGetValue(DownloadQueueSnapshotMapper.GetClientQueueSnapshotCacheKey(client), out ClientQueueSnapshotCacheEntry? cachedSnapshot) && cachedSnapshot != null) { var snapshotAge = DateTimeOffset.UtcNow - cachedSnapshot.RefreshedAtUtc; @@ -573,7 +573,7 @@ private ClientQueueFetchResult BuildFallbackQueueResult(DownloadClientConfigurat return new ClientQueueFetchResult( client, - CloneQueueItems(cachedSnapshot.QueueItems), + DownloadQueueSnapshotMapper.CloneQueueItems(cachedSnapshot.QueueItems), usedCachedSnapshot: true, isUnavailable: false, snapshotAge: snapshotAge, @@ -599,9 +599,9 @@ private void CacheClientQueueSnapshot( List clientQueue, DateTimeOffset refreshedAtUtc) { - var cacheEntry = new ClientQueueSnapshotCacheEntry(CloneQueueItems(clientQueue), refreshedAtUtc); + var cacheEntry = new ClientQueueSnapshotCacheEntry(DownloadQueueSnapshotMapper.CloneQueueItems(clientQueue), refreshedAtUtc); cache.Set( - GetClientQueueSnapshotCacheKey(client), + DownloadQueueSnapshotMapper.GetClientQueueSnapshotCacheKey(client), cacheEntry, new MemoryCacheEntryOptions { @@ -609,79 +609,6 @@ private void CacheClientQueueSnapshot( }); } - private static string GetClientQueueSnapshotCacheKey(DownloadClientConfiguration client) - { - return $"download-queue:snapshot:{client.Id}"; - } - - private static List CloneQueueItems(IEnumerable? queueItems) - { - if (queueItems == null) - { - return new List(); - } - - return queueItems - .Where(item => item != null) - .Select(item => item.Clone()) - .ToList(); - } - - private static void ApplySnapshotMetadata(List queueItems, ClientQueueFetchResult clientQueueResult) - { - if (queueItems == null || queueItems.Count == 0) - { - return; - } - - var snapshotAgeSeconds = clientQueueResult.SnapshotAge.HasValue - ? (int?)Math.Max(0, Math.Round(clientQueueResult.SnapshotAge.Value.TotalSeconds)) - : null; - var snapshotRefreshedAt = clientQueueResult.SnapshotRefreshedAtUtc?.UtcDateTime; - - foreach (var queueItem in queueItems) - { - queueItem.IsStaleSnapshot = clientQueueResult.UsedCachedSnapshot; - queueItem.SnapshotState = clientQueueResult.SnapshotState; - queueItem.SnapshotFailureReason = clientQueueResult.FailureReason; - queueItem.SnapshotAgeSeconds = snapshotAgeSeconds; - queueItem.SnapshotRefreshedAt = snapshotRefreshedAt; - } - } - - private static List BuildClientStatuses(IEnumerable clientQueueResults) - { - if (clientQueueResults == null) - { - return new List(); - } - - return clientQueueResults - .Where(result => result?.Client != null) - .Select(result => - { - var snapshotAgeSeconds = result.SnapshotAge.HasValue - ? (int?)Math.Max(0, Math.Round(result.SnapshotAge.Value.TotalSeconds)) - : null; - - return new QueueClientStatus - { - ClientId = result.Client.Id ?? string.Empty, - ClientName = result.Client.Name ?? result.Client.Id ?? "Download client", - ClientType = result.Client.Type?.ToLowerInvariant() ?? "unknown", - SnapshotState = result.SnapshotState, - IsStaleSnapshot = result.UsedCachedSnapshot, - IsUnavailable = result.IsUnavailable, - SnapshotFailureReason = result.FailureReason, - SnapshotAgeSeconds = snapshotAgeSeconds, - SnapshotRefreshedAt = result.SnapshotRefreshedAtUtc?.UtcDateTime, - ItemCount = result.QueueItems?.Count ?? 0 - }; - }) - .OrderBy(status => status.ClientName, StringComparer.OrdinalIgnoreCase) - .ToList(); - } - private void ObserveFaultedPollTask(Task> pollTask, DownloadClientConfiguration client) { _ = pollTask.ContinueWith(task => @@ -754,48 +681,5 @@ private async Task PersistDiscoveredClientIdentifiersAsync( } } - private sealed class ClientQueueFetchResult - { - public ClientQueueFetchResult( - DownloadClientConfiguration client, - List queueItems, - bool usedCachedSnapshot, - bool isUnavailable, - TimeSpan? snapshotAge, - string? failureReason, - string snapshotState, - DateTimeOffset? snapshotRefreshedAtUtc) - { - Client = client; - QueueItems = queueItems ?? new List(); - UsedCachedSnapshot = usedCachedSnapshot; - IsUnavailable = isUnavailable; - SnapshotAge = snapshotAge; - FailureReason = failureReason; - SnapshotState = snapshotState; - SnapshotRefreshedAtUtc = snapshotRefreshedAtUtc; - } - - public DownloadClientConfiguration Client { get; } - public List QueueItems { get; } - public bool UsedCachedSnapshot { get; } - public bool IsUnavailable { get; } - public TimeSpan? SnapshotAge { get; } - public string? FailureReason { get; } - public string SnapshotState { get; } - public DateTimeOffset? SnapshotRefreshedAtUtc { get; } - } - - private sealed class ClientQueueSnapshotCacheEntry - { - public ClientQueueSnapshotCacheEntry(List queueItems, DateTimeOffset refreshedAtUtc) - { - QueueItems = queueItems ?? new List(); - RefreshedAtUtc = refreshedAtUtc; - } - - public List QueueItems { get; } - public DateTimeOffset RefreshedAtUtc { get; } - } } } diff --git a/listenarr.application/Downloads/DownloadQueueSnapshotMapper.cs b/listenarr.application/Downloads/DownloadQueueSnapshotMapper.cs new file mode 100644 index 000000000..a46a2421c --- /dev/null +++ b/listenarr.application/Downloads/DownloadQueueSnapshotMapper.cs @@ -0,0 +1,97 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +using Listenarr.Domain.Models; + +namespace Listenarr.Application.Downloads +{ + internal static class DownloadQueueSnapshotMapper + { + public static string GetClientQueueSnapshotCacheKey(DownloadClientConfiguration client) + { + return $"download-queue:snapshot:{client.Id}"; + } + + public static List CloneQueueItems(IEnumerable? queueItems) + { + if (queueItems == null) + { + return new List(); + } + + return queueItems + .Where(item => item != null) + .Select(item => item.Clone()) + .ToList(); + } + + public static void ApplySnapshotMetadata(List queueItems, ClientQueueFetchResult clientQueueResult) + { + if (queueItems == null || queueItems.Count == 0) + { + return; + } + + var snapshotAgeSeconds = clientQueueResult.SnapshotAge.HasValue + ? (int?)Math.Max(0, Math.Round(clientQueueResult.SnapshotAge.Value.TotalSeconds)) + : null; + var snapshotRefreshedAt = clientQueueResult.SnapshotRefreshedAtUtc?.UtcDateTime; + + foreach (var queueItem in queueItems) + { + queueItem.IsStaleSnapshot = clientQueueResult.UsedCachedSnapshot; + queueItem.SnapshotState = clientQueueResult.SnapshotState; + queueItem.SnapshotFailureReason = clientQueueResult.FailureReason; + queueItem.SnapshotAgeSeconds = snapshotAgeSeconds; + queueItem.SnapshotRefreshedAt = snapshotRefreshedAt; + } + } + + public static List BuildClientStatuses(IEnumerable clientQueueResults) + { + if (clientQueueResults == null) + { + return new List(); + } + + return clientQueueResults + .Where(result => result?.Client != null) + .Select(result => + { + var snapshotAgeSeconds = result.SnapshotAge.HasValue + ? (int?)Math.Max(0, Math.Round(result.SnapshotAge.Value.TotalSeconds)) + : null; + + return new QueueClientStatus + { + ClientId = result.Client.Id ?? string.Empty, + ClientName = result.Client.Name ?? result.Client.Id ?? "Download client", + ClientType = result.Client.Type?.ToLowerInvariant() ?? "unknown", + SnapshotState = result.SnapshotState, + IsStaleSnapshot = result.UsedCachedSnapshot, + IsUnavailable = result.IsUnavailable, + SnapshotFailureReason = result.FailureReason, + SnapshotAgeSeconds = snapshotAgeSeconds, + SnapshotRefreshedAt = result.SnapshotRefreshedAtUtc?.UtcDateTime, + ItemCount = result.QueueItems?.Count ?? 0 + }; + }) + .OrderBy(status => status.ClientName, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + } +} diff --git a/listenarr.infrastructure/Platform/SystemLogParser.cs b/listenarr.infrastructure/Platform/SystemLogParser.cs new file mode 100644 index 000000000..006ecf009 --- /dev/null +++ b/listenarr.infrastructure/Platform/SystemLogParser.cs @@ -0,0 +1,82 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +using System.Text.RegularExpressions; +using Listenarr.Domain.Models; + +namespace Listenarr.Infrastructure.Platform +{ + internal static class SystemLogParser + { + public static LogEntry? ParseLogLine(string line) + { + try + { + if (string.IsNullOrWhiteSpace(line)) + return null; + + var match = Regex.Match( + line, + @"^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}\.\d{3}\s+[+-]\d{2}:\d{2})\s+\[(\w{3})\]\s+(.+)$" + ); + + if (match.Success) + { + var timestampStr = match.Groups[1].Value; + var level = match.Groups[2].Value.ToUpperInvariant(); + var message = match.Groups[3].Value; + + if (!DateTime.TryParse(timestampStr, out var timestamp)) + { + timestamp = DateTime.UtcNow; + } + + var mappedLevel = level switch + { + "VRB" => "Debug", + "DBG" => "Debug", + "INF" => "Info", + "WRN" => "Warning", + "ERR" => "Error", + "FTL" => "Error", + _ => "Info" + }; + + return new LogEntry + { + Timestamp = timestamp, + Level = mappedLevel, + Message = message, + Source = "Application" + }; + } + + return new LogEntry + { + Timestamp = DateTime.UtcNow, + Level = "Info", + Message = line, + Source = "Application" + }; + } + catch (Exception caughtEx) when (caughtEx is not OperationCanceledException && caughtEx is not OutOfMemoryException && caughtEx is not StackOverflowException) + { + return null; + } + } + } +} diff --git a/listenarr.infrastructure/Platform/SystemService.cs b/listenarr.infrastructure/Platform/SystemService.cs index eb71f2005..f578e2cdc 100644 --- a/listenarr.infrastructure/Platform/SystemService.cs +++ b/listenarr.infrastructure/Platform/SystemService.cs @@ -18,7 +18,6 @@ using System.Diagnostics; using System.Runtime.InteropServices; -using System.Text.RegularExpressions; using Listenarr.Application.Interfaces; using Listenarr.Domain.Models; using Microsoft.Extensions.Logging; @@ -497,7 +496,7 @@ public List GetRecentLogs(int limit = 100) lines = allLines.TakeLast(limit).ToList(); } - logs.AddRange(lines.Select(ParseLogLine).Where(logEntry => logEntry != null)!); + logs.AddRange(lines.Select(SystemLogParser.ParseLogLine).Where(logEntry => logEntry != null)!); // If no logs were parsed, return sample logs if (logs.Count == 0) @@ -556,69 +555,5 @@ public string GetLogFilePath() return todayLogPath; } - private LogEntry? ParseLogLine(string line) - { - try - { - // Expected Serilog format: 2025-11-05 11:43:58.516 -05:00 [INF] Message here - - if (string.IsNullOrWhiteSpace(line)) - return null; - - // Try to parse Serilog format with regex - // Format: YYYY-MM-DD HH:MM:SS.FFF ZZZ [LEVEL] Message - var match = Regex.Match( - line, - @"^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}\.\d{3}\s+[+-]\d{2}:\d{2})\s+\[(\w{3})\]\s+(.+)$" - ); - - if (match.Success) - { - var timestampStr = match.Groups[1].Value; - var level = match.Groups[2].Value.ToUpperInvariant(); - var message = match.Groups[3].Value; - - // Parse timestamp - DateTime timestamp; - if (!DateTime.TryParse(timestampStr, out timestamp)) - { - timestamp = DateTime.UtcNow; - } - - // Map Serilog log levels - var mappedLevel = level switch - { - "VRB" => "Debug", // Verbose - "DBG" => "Debug", // Debug - "INF" => "Info", // Information - "WRN" => "Warning", // Warning - "ERR" => "Error", // Error - "FTL" => "Error", // Fatal - _ => "Info" - }; - - return new LogEntry - { - Timestamp = timestamp, - Level = mappedLevel, - Message = message, - Source = "Application" - }; - } - - // Fallback: treat the whole line as info message - return new LogEntry - { - Timestamp = DateTime.UtcNow, - Level = "Info", - Message = line, - Source = "Application" - }; - } - catch (Exception caughtEx_1) when (caughtEx_1 is not OperationCanceledException && caughtEx_1 is not OutOfMemoryException && caughtEx_1 is not StackOverflowException) - { - return null; - } - } } } From 0ea1cb4bc3da6a2c81129ab40e8bebd444562fd0 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Sat, 13 Jun 2026 15:07:54 -0400 Subject: [PATCH 27/84] refactor: extract service diagnostics helpers - Move queue metric and late poll diagnostics into a focused helper - Move system disk measurement mapping out of SystemService --- .../Downloads/DownloadQueueDiagnostics.cs | 65 +++++++++++++++++++ .../Downloads/DownloadQueueService.cs | 54 +++------------ .../Platform/SystemService.cs | 42 +----------- .../Platform/SystemStorageMapper.cs | 55 ++++++++++++++++ 4 files changed, 132 insertions(+), 84 deletions(-) create mode 100644 listenarr.application/Downloads/DownloadQueueDiagnostics.cs create mode 100644 listenarr.infrastructure/Platform/SystemStorageMapper.cs diff --git a/listenarr.application/Downloads/DownloadQueueDiagnostics.cs b/listenarr.application/Downloads/DownloadQueueDiagnostics.cs new file mode 100644 index 000000000..b02ef18ce --- /dev/null +++ b/listenarr.application/Downloads/DownloadQueueDiagnostics.cs @@ -0,0 +1,65 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +using Listenarr.Application.Interfaces; +using Listenarr.Domain.Models; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Application.Downloads +{ + internal static class DownloadQueueDiagnostics + { + public static void ObserveFaultedPollTask( + Task> pollTask, + DownloadClientConfiguration client, + ILogger logger) + { + _ = pollTask.ContinueWith(task => + { + if (task.Exception != null) + { + logger.LogDebug(task.Exception, "Observed late poll failure after timeout for client {ClientName}", client.Name ?? client.Id); + _ = task.Exception; + } + }, TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously); + } + + public static void TryIncrementMetric(IAppMetricsService metrics, string metricName, double value = 1) + { + try + { + metrics.Increment(metricName, value); + } + catch (Exception caughtEx) when (caughtEx is not OperationCanceledException && caughtEx is not OutOfMemoryException && caughtEx is not StackOverflowException) + { + System.Diagnostics.Debug.WriteLine("Suppressed non-fatal exception in catch block."); + } + } + + public static void TryTimingMetric(IAppMetricsService metrics, string metricName, TimeSpan duration) + { + try + { + metrics.Timing(metricName, duration); + } + catch (Exception caughtEx) when (caughtEx is not OperationCanceledException && caughtEx is not OutOfMemoryException && caughtEx is not StackOverflowException) + { + System.Diagnostics.Debug.WriteLine("Suppressed non-fatal exception in catch block."); + } + } + } +} diff --git a/listenarr.application/Downloads/DownloadQueueService.cs b/listenarr.application/Downloads/DownloadQueueService.cs index 9ddd8cc0e..ad9d67752 100644 --- a/listenarr.application/Downloads/DownloadQueueService.cs +++ b/listenarr.application/Downloads/DownloadQueueService.cs @@ -516,11 +516,11 @@ private async Task FetchClientQueueResultAsync(DownloadC if (completedTask != pollTask) { timeoutCts.Cancel(); - ObserveFaultedPollTask(pollTask, client); + DownloadQueueDiagnostics.ObserveFaultedPollTask(pollTask, client, logger); stopwatch.Stop(); - TryIncrementMetric("download.queue.client.poll.timeout"); - TryTimingMetric("download.queue.client.poll.duration", stopwatch.Elapsed); + DownloadQueueDiagnostics.TryIncrementMetric(metrics, "download.queue.client.poll.timeout"); + DownloadQueueDiagnostics.TryTimingMetric(metrics, "download.queue.client.poll.duration", stopwatch.Elapsed); return BuildFallbackQueueResult(client, "timeout"); } @@ -529,7 +529,7 @@ private async Task FetchClientQueueResultAsync(DownloadC var refreshedAtUtc = DateTimeOffset.UtcNow; CacheClientQueueSnapshot(client, clientQueue, refreshedAtUtc); - TryTimingMetric("download.queue.client.poll.duration", stopwatch.Elapsed); + DownloadQueueDiagnostics.TryTimingMetric(metrics, "download.queue.client.poll.duration", stopwatch.Elapsed); return new ClientQueueFetchResult( client, @@ -544,8 +544,8 @@ private async Task FetchClientQueueResultAsync(DownloadC catch (OperationCanceledException) when (!timeoutCts.IsCancellationRequested) { stopwatch.Stop(); - TryIncrementMetric("download.queue.client.poll.failure"); - TryTimingMetric("download.queue.client.poll.duration", stopwatch.Elapsed); + DownloadQueueDiagnostics.TryIncrementMetric(metrics, "download.queue.client.poll.failure"); + DownloadQueueDiagnostics.TryTimingMetric(metrics, "download.queue.client.poll.duration", stopwatch.Elapsed); logger.LogWarning("Queue poll for client {ClientName} was canceled before timeout; using fallback behavior", client.Name ?? client.Id); return BuildFallbackQueueResult(client, "canceled"); @@ -553,8 +553,8 @@ private async Task FetchClientQueueResultAsync(DownloadC catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) { stopwatch.Stop(); - TryIncrementMetric("download.queue.client.poll.failure"); - TryTimingMetric("download.queue.client.poll.duration", stopwatch.Elapsed); + DownloadQueueDiagnostics.TryIncrementMetric(metrics, "download.queue.client.poll.failure"); + DownloadQueueDiagnostics.TryTimingMetric(metrics, "download.queue.client.poll.duration", stopwatch.Elapsed); logger.LogWarning(ex, "Error getting queue snapshot from download client {ClientName}", client.Name ?? client.Id); return BuildFallbackQueueResult(client, "error"); @@ -569,7 +569,7 @@ private ClientQueueFetchResult BuildFallbackQueueResult(DownloadClientConfigurat var snapshotAge = DateTimeOffset.UtcNow - cachedSnapshot.RefreshedAtUtc; if (snapshotAge <= _staleSnapshotMaxAge) { - TryIncrementMetric("download.queue.client.snapshot.fallback"); + DownloadQueueDiagnostics.TryIncrementMetric(metrics, "download.queue.client.snapshot.fallback"); return new ClientQueueFetchResult( client, @@ -609,42 +609,6 @@ private void CacheClientQueueSnapshot( }); } - private void ObserveFaultedPollTask(Task> pollTask, DownloadClientConfiguration client) - { - _ = pollTask.ContinueWith(task => - { - if (task.Exception != null) - { - logger.LogDebug(task.Exception, "Observed late poll failure after timeout for client {ClientName}", client.Name ?? client.Id); - _ = task.Exception; - } - }, TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously); - } - - private void TryIncrementMetric(string metricName, double value = 1) - { - try - { - metrics.Increment(metricName, value); - } - catch (Exception caughtEx) when (caughtEx is not OperationCanceledException && caughtEx is not OutOfMemoryException && caughtEx is not StackOverflowException) - { - System.Diagnostics.Debug.WriteLine("Suppressed non-fatal exception in catch block."); - } - } - - private void TryTimingMetric(string metricName, TimeSpan duration) - { - try - { - metrics.Timing(metricName, duration); - } - catch (Exception caughtEx) when (caughtEx is not OperationCanceledException && caughtEx is not OutOfMemoryException && caughtEx is not StackOverflowException) - { - System.Diagnostics.Debug.WriteLine("Suppressed non-fatal exception in catch block."); - } - } - private async Task PersistDiscoveredClientIdentifiersAsync( Download matchedDownload, DownloadClientConfiguration client, diff --git a/listenarr.infrastructure/Platform/SystemService.cs b/listenarr.infrastructure/Platform/SystemService.cs index f578e2cdc..0565ea3e3 100644 --- a/listenarr.infrastructure/Platform/SystemService.cs +++ b/listenarr.infrastructure/Platform/SystemService.cs @@ -107,7 +107,7 @@ public async Task GetStorageInfoAsync() var appDataPath = Directory.Exists(_applicationPathService.ConfigRootPath) ? _applicationPathService.ConfigRootPath : _applicationPathService.ContentRootPath; - var appDisk = MeasureDisk("App Data", appDataPath); + var appDisk = SystemStorageMapper.MeasureDisk(_diskSpaceProbe, "App Data", appDataPath); var storageInfo = new StorageInfo { @@ -127,7 +127,7 @@ public async Task GetStorageInfoAsync() var systemRoot = Path.GetPathRoot(_applicationPathService.ContentRootPath); if (!string.IsNullOrEmpty(systemRoot)) { - storageInfo.Disks.Add(MeasureDisk("System", systemRoot)); + storageInfo.Disks.Add(SystemStorageMapper.MeasureDisk(_diskSpaceProbe, "System", systemRoot)); } storageInfo.Disks.Add(appDisk); @@ -146,7 +146,7 @@ public async Task GetStorageInfoAsync() foreach (var folder in rootFolders) { var label = string.IsNullOrWhiteSpace(folder.Name) ? folder.Path : folder.Name; - storageInfo.Disks.Add(MeasureDisk(label, folder.Path)); + storageInfo.Disks.Add(SystemStorageMapper.MeasureDisk(_diskSpaceProbe, label, folder.Path)); } return storageInfo; @@ -158,42 +158,6 @@ public async Task GetStorageInfoAsync() } } - private DiskStorageInfo MeasureDisk(string label, string path) - { - // The probe owns the platform-specific measurement (Windows native call vs. - // DriveInfo) and returns false for anything it cannot read; here we only map - // its result into the labelled, formatted DiskStorageInfo. - if (_diskSpaceProbe.TryGetDiskSpace(path, out var totalBytes, out var freeBytes)) - { - return BuildDiskInfo(label, path, totalBytes, freeBytes); - } - - return new DiskStorageInfo { Label = label, Path = path, Status = "unavailable" }; - } - - private DiskStorageInfo BuildDiskInfo(string label, string path, long totalBytes, long freeBytes) - { - // Clamp defensively: some filesystems report free space that exceeds the - // total (reserved blocks, over-provisioning, compression/dedup on ZFS/Btrfs, - // or network shares), which would otherwise drive used bytes/percentage negative. - var usedBytes = Math.Clamp(totalBytes - freeBytes, 0, totalBytes); - var usedPercentage = totalBytes > 0 ? Math.Clamp((double)usedBytes / totalBytes * 100, 0, 100) : 0; - - return new DiskStorageInfo - { - Label = label, - Path = path, - UsedBytes = usedBytes, - TotalBytes = totalBytes, - FreeBytes = freeBytes, - UsedPercentage = Math.Round(usedPercentage, 2), - UsedFormatted = SystemFormatters.FormatBytes(usedBytes), - TotalFormatted = SystemFormatters.FormatBytes(totalBytes), - FreeFormatted = SystemFormatters.FormatBytes(freeBytes), - Status = "available" - }; - } - public async Task GetServiceHealthAsync() { try diff --git a/listenarr.infrastructure/Platform/SystemStorageMapper.cs b/listenarr.infrastructure/Platform/SystemStorageMapper.cs new file mode 100644 index 000000000..03e938bf8 --- /dev/null +++ b/listenarr.infrastructure/Platform/SystemStorageMapper.cs @@ -0,0 +1,55 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +using Listenarr.Application.Interfaces; +using Listenarr.Domain.Models; + +namespace Listenarr.Infrastructure.Platform +{ + internal static class SystemStorageMapper + { + public static DiskStorageInfo MeasureDisk(IDiskSpaceProbe diskSpaceProbe, string label, string path) + { + if (diskSpaceProbe.TryGetDiskSpace(path, out var totalBytes, out var freeBytes)) + { + return BuildDiskInfo(label, path, totalBytes, freeBytes); + } + + return new DiskStorageInfo { Label = label, Path = path, Status = "unavailable" }; + } + + private static DiskStorageInfo BuildDiskInfo(string label, string path, long totalBytes, long freeBytes) + { + var usedBytes = Math.Clamp(totalBytes - freeBytes, 0, totalBytes); + var usedPercentage = totalBytes > 0 ? Math.Clamp((double)usedBytes / totalBytes * 100, 0, 100) : 0; + + return new DiskStorageInfo + { + Label = label, + Path = path, + UsedBytes = usedBytes, + TotalBytes = totalBytes, + FreeBytes = freeBytes, + UsedPercentage = Math.Round(usedPercentage, 2), + UsedFormatted = SystemFormatters.FormatBytes(usedBytes), + TotalFormatted = SystemFormatters.FormatBytes(totalBytes), + FreeFormatted = SystemFormatters.FormatBytes(freeBytes), + Status = "available" + }; + } + } +} From ee99ffd641b016ad55d1501d19f836cc74119db4 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Sat, 13 Jun 2026 15:35:09 -0400 Subject: [PATCH 28/84] refactor: extract system health mapper - Move service health status construction out of SystemService - Preserve existing enabled-client and API health semantics --- .../Platform/SystemHealthMapper.cs | 160 ++++++++++++++++++ .../Platform/SystemService.cs | 120 +------------ 2 files changed, 165 insertions(+), 115 deletions(-) create mode 100644 listenarr.infrastructure/Platform/SystemHealthMapper.cs diff --git a/listenarr.infrastructure/Platform/SystemHealthMapper.cs b/listenarr.infrastructure/Platform/SystemHealthMapper.cs new file mode 100644 index 000000000..7b351a76a --- /dev/null +++ b/listenarr.infrastructure/Platform/SystemHealthMapper.cs @@ -0,0 +1,160 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +using Listenarr.Domain.Models; +using Listenarr.Domain.Models.Configurations; + +namespace Listenarr.Infrastructure.Platform +{ + internal static class SystemHealthMapper + { + public static ServiceHealth BuildServiceHealth( + string version, + string uptime, + DownloadClientHealth downloadClientHealth, + ExternalApiHealth externalApiHealth) + { + var overallStatus = "healthy"; + if (downloadClientHealth.Status == "error" || externalApiHealth.Status == "error") + { + overallStatus = "error"; + } + else if (downloadClientHealth.Status == "warning" || externalApiHealth.Status == "warning") + { + overallStatus = "warning"; + } + + return new ServiceHealth + { + Status = overallStatus, + Version = version, + Uptime = uptime, + DownloadClients = downloadClientHealth, + ExternalApis = externalApiHealth + }; + } + + public static DownloadClientHealth BuildDownloadClientHealth(IEnumerable clients) + { + var clientList = clients?.ToList() ?? new List(); + var clientStatuses = new List(); + var connectedCount = 0; + + foreach (var client in clientList) + { + if (!client.IsEnabled) + { + continue; + } + + var status = "connected"; + connectedCount++; + + clientStatuses.Add(new ClientStatus + { + Name = client.Name, + Status = status, + Type = client.Type + }); + } + + var totalEnabled = clientList.Count(c => c.IsEnabled); + var overallStatus = BuildChildStatus(connectedCount, totalEnabled); + + return new DownloadClientHealth + { + Status = overallStatus, + Connected = connectedCount, + Total = totalEnabled, + Clients = clientStatuses + }; + } + + public static ExternalApiHealth BuildExternalApiHealth(IEnumerable apis) + { + var apiList = apis?.ToList() ?? new List(); + var apiStatuses = new List(); + var connectedCount = 0; + + foreach (var api in apiList) + { + if (!api.IsEnabled) + { + continue; + } + + var status = "connected"; + connectedCount++; + + apiStatuses.Add(new ApiStatus + { + Name = api.Name, + Status = status, + Enabled = api.IsEnabled + }); + } + + var totalEnabled = apiList.Count(c => c.IsEnabled); + var overallStatus = BuildChildStatus(connectedCount, totalEnabled); + + return new ExternalApiHealth + { + Status = overallStatus, + Connected = connectedCount, + Total = totalEnabled, + Apis = apiStatuses + }; + } + + public static DownloadClientHealth BuildDownloadClientHealthError() + { + return new DownloadClientHealth + { + Status = "error", + Connected = 0, + Total = 0, + Clients = new List() + }; + } + + public static ExternalApiHealth BuildExternalApiHealthError() + { + return new ExternalApiHealth + { + Status = "error", + Connected = 0, + Total = 0, + Apis = new List() + }; + } + + private static string BuildChildStatus(int connectedCount, int totalEnabled) + { + if (connectedCount == 0 && totalEnabled > 0) + { + return "error"; + } + + if (connectedCount < totalEnabled) + { + return "warning"; + } + + return "healthy"; + } + } +} diff --git a/listenarr.infrastructure/Platform/SystemService.cs b/listenarr.infrastructure/Platform/SystemService.cs index 0565ea3e3..981fdb68a 100644 --- a/listenarr.infrastructure/Platform/SystemService.cs +++ b/listenarr.infrastructure/Platform/SystemService.cs @@ -172,25 +172,7 @@ public async Task GetServiceHealthAsync() // Get external API health var externalApiHealth = await GetExternalApiHealthAsync(); - // Determine overall status - var overallStatus = "healthy"; - if (downloadClientHealth.Status == "error" || externalApiHealth.Status == "error") - { - overallStatus = "error"; - } - else if (downloadClientHealth.Status == "warning" || externalApiHealth.Status == "warning") - { - overallStatus = "warning"; - } - - return new ServiceHealth - { - Status = overallStatus, - Version = version, - Uptime = uptimeFormatted, - DownloadClients = downloadClientHealth, - ExternalApis = externalApiHealth - }; + return SystemHealthMapper.BuildServiceHealth(version, uptimeFormatted, downloadClientHealth, externalApiHealth); } catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) { @@ -204,58 +186,12 @@ private async Task GetDownloadClientHealthAsync() try { var clients = await _configurationService.GetDownloadClientConfigurationsAsync(); - var clientStatuses = new List(); - var connectedCount = 0; - - foreach (var client in clients) - { - if (!client.IsEnabled) - { - continue; - } - - // TODO: Implement actual connection testing for each client type - // For now, assume enabled clients are connected - var status = "connected"; - connectedCount++; - - clientStatuses.Add(new ClientStatus - { - Name = client.Name, - Status = status, - Type = client.Type - }); - } - - var totalEnabled = clients.Count(c => c.IsEnabled); - var overallStatus = "healthy"; - if (connectedCount == 0 && totalEnabled > 0) - { - overallStatus = "error"; - } - else if (connectedCount < totalEnabled) - { - overallStatus = "warning"; - } - - return new DownloadClientHealth - { - Status = overallStatus, - Connected = connectedCount, - Total = totalEnabled, - Clients = clientStatuses - }; + return SystemHealthMapper.BuildDownloadClientHealth(clients); } catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) { _logger.LogError(ex, "Error getting download client health"); - return new DownloadClientHealth - { - Status = "error", - Connected = 0, - Total = 0, - Clients = new List() - }; + return SystemHealthMapper.BuildDownloadClientHealthError(); } } @@ -264,58 +200,12 @@ private async Task GetExternalApiHealthAsync() try { var apis = await _configurationService.GetApiConfigurationsAsync(); - var apiStatuses = new List(); - var connectedCount = 0; - - foreach (var api in apis) - { - if (!api.IsEnabled) - { - continue; - } - - // TODO: Implement actual connection testing for each API - // For now, assume enabled APIs are connected - var status = "connected"; - connectedCount++; - - apiStatuses.Add(new ApiStatus - { - Name = api.Name, - Status = status, - Enabled = api.IsEnabled - }); - } - - var totalEnabled = apis.Count(c => c.IsEnabled); - var overallStatus = "healthy"; - if (connectedCount == 0 && totalEnabled > 0) - { - overallStatus = "error"; - } - else if (connectedCount < totalEnabled) - { - overallStatus = "warning"; - } - - return new ExternalApiHealth - { - Status = overallStatus, - Connected = connectedCount, - Total = totalEnabled, - Apis = apiStatuses - }; + return SystemHealthMapper.BuildExternalApiHealth(apis); } catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) { _logger.LogError(ex, "Error getting external API health"); - return new ExternalApiHealth - { - Status = "error", - Connected = 0, - Total = 0, - Apis = new List() - }; + return SystemHealthMapper.BuildExternalApiHealthError(); } } From e36e09fa28d0cac4f5b741423f68785ddc90d0f5 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Sat, 13 Jun 2026 15:49:13 -0400 Subject: [PATCH 29/84] refactor: extract notification payload context - Centralize startup base URL and API version resolution for notification payloads - Preserve provider-specific delivery and logging behavior --- .../NotificationPayloadContextResolver.cs | 56 +++++++++++ .../Notification/NotificationService.cs | 93 +++---------------- 2 files changed, 71 insertions(+), 78 deletions(-) create mode 100644 listenarr.application/Notification/NotificationPayloadContextResolver.cs diff --git a/listenarr.application/Notification/NotificationPayloadContextResolver.cs b/listenarr.application/Notification/NotificationPayloadContextResolver.cs new file mode 100644 index 000000000..3ce724b5e --- /dev/null +++ b/listenarr.application/Notification/NotificationPayloadContextResolver.cs @@ -0,0 +1,56 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +using Listenarr.Application.Common; +using Listenarr.Application.Interfaces; +using Listenarr.Application.Security; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Application.Notification +{ + internal static class NotificationPayloadContextResolver + { + public static async Task ResolveAsync( + IConfigurationService configurationService, + IRequestContextAccessor? requestContextAccessor, + ILogger logger, + bool validateImageBaseUrl = false) + { + var startup = await configurationService.GetStartupConfigAsync(); + var baseUrl = startup?.UrlBase; + + if (string.IsNullOrWhiteSpace(baseUrl) && requestContextAccessor?.Current != null) + { + var derived = NotificationPayloadBuilder.GetBaseUrlFromRequestContext(requestContextAccessor.Current); + if (!string.IsNullOrWhiteSpace(derived)) baseUrl = derived; + } + + if (validateImageBaseUrl && + !string.IsNullOrWhiteSpace(baseUrl) && + !(baseUrl.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || baseUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase))) + { + logger.LogWarning("Invalid base URL configured: {BaseUrl} - notifications will not include images", LogRedaction.SanitizeUrl(baseUrl)); + baseUrl = null; + } + + var apiVersion = ApiVersionUtils.ResolveApiVersion(requestContextAccessor?.Current?.Path, startup?.ApiVersion); + return new NotificationPayloadContext(baseUrl, apiVersion); + } + } + + internal sealed record NotificationPayloadContext(string? BaseUrl, string ApiVersion); +} diff --git a/listenarr.application/Notification/NotificationService.cs b/listenarr.application/Notification/NotificationService.cs index 63b6e9a63..f364e13d7 100644 --- a/listenarr.application/Notification/NotificationService.cs +++ b/listenarr.application/Notification/NotificationService.cs @@ -19,7 +19,6 @@ using System.Text; using System.Text.Json; using System.Text.Json.Nodes; -using Listenarr.Application.Common; using Listenarr.Application.Interfaces; using Listenarr.Application.Security; using Listenarr.Domain.Models; @@ -247,27 +246,13 @@ async Task HandleFailedResponseAsync(HttpResponseMessage response) { try { - var startup = await _configurationService.GetStartupConfigAsync(); - var baseUrl = startup?.UrlBase; - - if (string.IsNullOrWhiteSpace(baseUrl) && _requestContextAccessor?.Current != null) - { - var derived = NotificationPayloadBuilder.GetBaseUrlFromRequestContext(_requestContextAccessor.Current); - if (!string.IsNullOrWhiteSpace(derived)) baseUrl = derived; - } - - if (!string.IsNullOrWhiteSpace(baseUrl) && - !(baseUrl.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || baseUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase))) - { - _logger.LogWarning("Invalid base URL configured: {BaseUrl} - notifications will not include images", LogRedaction.SanitizeUrl(baseUrl)); - baseUrl = null; - } + var payloadContext = await NotificationPayloadContextResolver.ResolveAsync(_configurationService, _requestContextAccessor, _logger, validateImageBaseUrl: true); var (payloadObj, attachment) = await _payloadBuilder.CreateDiscordPayloadWithAttachmentAsync( - trigger, data, baseUrl, _httpClient, _requestContextAccessor, + trigger, data, payloadContext.BaseUrl, _httpClient, _requestContextAccessor, logInfo: msg => _logger.LogInformation(msg), logDebug: (ex, msg) => _logger.LogDebug(ex, msg), - apiVersion: ApiVersionUtils.ResolveApiVersion(_requestContextAccessor?.Current?.Path, startup?.ApiVersion) + apiVersion: payloadContext.ApiVersion ); _logger.LogDebug("Discord payload attachment present? {HasAttachment}", attachment != null); @@ -319,16 +304,8 @@ async Task HandleFailedResponseAsync(HttpResponseMessage response) try { // Use the payload builder to create a concise message/title - string? baseUrl = null; - var startup = await _configurationService.GetStartupConfigAsync(); - if (startup?.UrlBase != null) baseUrl = startup.UrlBase; - if (string.IsNullOrWhiteSpace(baseUrl) && _requestContextAccessor?.Current != null) - { - var derived = NotificationPayloadBuilder.GetBaseUrlFromRequestContext(_requestContextAccessor.Current); - if (!string.IsNullOrWhiteSpace(derived)) baseUrl = derived; - } - - var discordPayload = NotificationPayloadBuilder.CreateDiscordPayload(trigger, data, baseUrl, ApiVersionUtils.ResolveApiVersion(_requestContextAccessor?.Current?.Path, startup?.ApiVersion)); + var payloadContext = await NotificationPayloadContextResolver.ResolveAsync(_configurationService, _requestContextAccessor, _logger); + var discordPayload = NotificationPayloadBuilder.CreateDiscordPayload(trigger, data, payloadContext.BaseUrl, payloadContext.ApiVersion); var title = discordPayload is JsonObject d && d.TryGetPropertyValue("content", out var c) ? (c?.ToString() ?? string.Empty) : string.Empty; var message = title; @@ -397,16 +374,8 @@ async Task HandleFailedResponseAsync(HttpResponseMessage response) } else { - string? baseUrl = null; - var startup = await _configurationService.GetStartupConfigAsync(); - if (startup?.UrlBase != null) baseUrl = startup.UrlBase; - if (string.IsNullOrWhiteSpace(baseUrl) && _requestContextAccessor?.Current != null) - { - var derived = NotificationPayloadBuilder.GetBaseUrlFromRequestContext(_requestContextAccessor.Current); - if (!string.IsNullOrWhiteSpace(derived)) baseUrl = derived; - } - - var discordPayload = NotificationPayloadBuilder.CreateDiscordPayload(trigger, data, baseUrl, ApiVersionUtils.ResolveApiVersion(_requestContextAccessor?.Current?.Path, startup?.ApiVersion)); + var payloadContext = await NotificationPayloadContextResolver.ResolveAsync(_configurationService, _requestContextAccessor, _logger); + var discordPayload = NotificationPayloadBuilder.CreateDiscordPayload(trigger, data, payloadContext.BaseUrl, payloadContext.ApiVersion); var message = discordPayload is JsonObject d && d.TryGetPropertyValue("content", out var c) ? (c?.ToString() ?? string.Empty) : string.Empty; var values = new List> @@ -471,16 +440,8 @@ async Task HandleFailedResponseAsync(HttpResponseMessage response) } else { - string? baseUrl = null; - var startup = await _configurationService.GetStartupConfigAsync(); - if (startup?.UrlBase != null) baseUrl = startup.UrlBase; - if (string.IsNullOrWhiteSpace(baseUrl) && _requestContextAccessor?.Current != null) - { - var derived = NotificationPayloadBuilder.GetBaseUrlFromRequestContext(_requestContextAccessor.Current); - if (!string.IsNullOrWhiteSpace(derived)) baseUrl = derived; - } - - var discordPayload = NotificationPayloadBuilder.CreateDiscordPayload(trigger, data, baseUrl, ApiVersionUtils.ResolveApiVersion(_requestContextAccessor?.Current?.Path, startup?.ApiVersion)); + var payloadContext = await NotificationPayloadContextResolver.ResolveAsync(_configurationService, _requestContextAccessor, _logger); + var discordPayload = NotificationPayloadBuilder.CreateDiscordPayload(trigger, data, payloadContext.BaseUrl, payloadContext.ApiVersion); var text = discordPayload is JsonObject d && d.TryGetPropertyValue("content", out var c) ? (c?.ToString() ?? string.Empty) : string.Empty; var telegramBody = new { chat_id = chatId, text = text ?? string.Empty, disable_notification = true, parse_mode = "Markdown" }; @@ -552,16 +513,8 @@ async Task HandleFailedResponseAsync(HttpResponseMessage response) } else { - string? baseUrl = null; - var startup = await _configurationService.GetStartupConfigAsync(); - if (startup?.UrlBase != null) baseUrl = startup.UrlBase; - if (string.IsNullOrWhiteSpace(baseUrl) && _requestContextAccessor?.Current != null) - { - var derived = NotificationPayloadBuilder.GetBaseUrlFromRequestContext(_requestContextAccessor.Current); - if (!string.IsNullOrWhiteSpace(derived)) baseUrl = derived; - } - - var discordPayload = NotificationPayloadBuilder.CreateDiscordPayload(trigger, data, baseUrl, ApiVersionUtils.ResolveApiVersion(_requestContextAccessor?.Current?.Path, startup?.ApiVersion)); + var payloadContext = await NotificationPayloadContextResolver.ResolveAsync(_configurationService, _requestContextAccessor, _logger); + var discordPayload = NotificationPayloadBuilder.CreateDiscordPayload(trigger, data, payloadContext.BaseUrl, payloadContext.ApiVersion); var message = discordPayload is JsonObject d && d.TryGetPropertyValue("content", out var c) ? (c?.ToString() ?? string.Empty) : string.Empty; var pushObj = new JsonObject @@ -623,16 +576,8 @@ async Task HandleFailedResponseAsync(HttpResponseMessage response) { try { - string? baseUrl = null; - var startup = await _configurationService.GetStartupConfigAsync(); - if (startup?.UrlBase != null) baseUrl = startup.UrlBase; - if (string.IsNullOrWhiteSpace(baseUrl) && _requestContextAccessor?.Current != null) - { - var derived = NotificationPayloadBuilder.GetBaseUrlFromRequestContext(_requestContextAccessor.Current); - if (!string.IsNullOrWhiteSpace(derived)) baseUrl = derived; - } - - var discordPayload = NotificationPayloadBuilder.CreateDiscordPayload(trigger, data, baseUrl, ApiVersionUtils.ResolveApiVersion(_requestContextAccessor?.Current?.Path, startup?.ApiVersion)); + var payloadContext = await NotificationPayloadContextResolver.ResolveAsync(_configurationService, _requestContextAccessor, _logger); + var discordPayload = NotificationPayloadBuilder.CreateDiscordPayload(trigger, data, payloadContext.BaseUrl, payloadContext.ApiVersion); var message = discordPayload is JsonObject d && d.TryGetPropertyValue("content", out var c) ? (c?.ToString() ?? string.Empty) : string.Empty; var slackObj = new JsonObject @@ -680,17 +625,9 @@ async Task HandleFailedResponseAsync(HttpResponseMessage response) // Generic webhook fallback: send the full JSON payload produced by the payload builder try { - string? baseUrl = null; - var startup = await _configurationService.GetStartupConfigAsync(); - if (startup?.UrlBase != null) baseUrl = startup.UrlBase; - if (string.IsNullOrWhiteSpace(baseUrl) && _requestContextAccessor?.Current != null) - { - var derived = NotificationPayloadBuilder.GetBaseUrlFromRequestContext(_requestContextAccessor.Current); - if (!string.IsNullOrWhiteSpace(derived)) baseUrl = derived; - } - // Prefer rich payload created by the static helper (includes content, embeds, image links, etc.) - var payloadObj = NotificationPayloadBuilder.CreateDiscordPayload(trigger, data, baseUrl, ApiVersionUtils.ResolveApiVersion(_requestContextAccessor?.Current?.Path, startup?.ApiVersion)); + var payloadContext = await NotificationPayloadContextResolver.ResolveAsync(_configurationService, _requestContextAccessor, _logger); + var payloadObj = NotificationPayloadBuilder.CreateDiscordPayload(trigger, data, payloadContext.BaseUrl, payloadContext.ApiVersion); string defaultJson = payloadObj != null ? payloadObj.ToJsonString() : JsonSerializer.Serialize(new { @event = trigger, data = data, timestamp = DateTime.UtcNow }, new JsonSerializerOptions { DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull }); using var defaultContent = new StringContent(defaultJson, Encoding.UTF8, "application/json"); From 3db27c24425c9d89d8da905e1c709225ffb7732d Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Sat, 13 Jun 2026 16:18:41 -0400 Subject: [PATCH 30/84] refactor: extract notification failure logging - Move failed webhook response logging into notification diagnostics - Preserve redaction markers and provider delivery behavior --- .../Notification/NotificationDiagnostics.cs | 18 +++++++++ .../Notification/NotificationService.cs | 37 ++++--------------- 2 files changed, 26 insertions(+), 29 deletions(-) diff --git a/listenarr.application/Notification/NotificationDiagnostics.cs b/listenarr.application/Notification/NotificationDiagnostics.cs index 3fff9094e..e7b932bfc 100644 --- a/listenarr.application/Notification/NotificationDiagnostics.cs +++ b/listenarr.application/Notification/NotificationDiagnostics.cs @@ -61,6 +61,24 @@ public static async Task TryReadContentAsync(HttpContent? content, ILogg } } + public static async Task LogFailedResponseAsync(HttpResponseMessage response, string webhookUrl, ILogger logger) + { + string body = string.Empty; + try { body = await response.Content.ReadAsStringAsync(); } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + logger.LogDebug(ex, "Failed to read notification response body for diagnostic logging"); + } + + var redactedUrl = LogRedaction.RedactText(webhookUrl, LogRedaction.GetSensitiveValuesFromEnvironment()); + var redactedBody = LogRedaction.RedactText(body, LogRedaction.GetSensitiveValuesFromEnvironment()); + redactedBody = AggressiveRedact(redactedBody); + if (string.IsNullOrEmpty(redactedBody)) redactedBody = ""; + + logger.LogWarning("Failed to send notification to {WebhookUrl}: {Status} - {Body}", redactedUrl, response.StatusCode, redactedBody); + logger.LogWarning("BodyRedacted: {Body}", ""); + } + public static bool TryValidateWebhookTarget(string webhookUrl, out string reason, bool allowPrivateTargets = false) { return OutboundRequestSecurity.TryValidateExternalHttpUrl(webhookUrl, out reason, allowPrivateTargets); diff --git a/listenarr.application/Notification/NotificationService.cs b/listenarr.application/Notification/NotificationService.cs index f364e13d7..134091d4d 100644 --- a/listenarr.application/Notification/NotificationService.cs +++ b/listenarr.application/Notification/NotificationService.cs @@ -220,27 +220,6 @@ public async Task SendNotificationAsync(string trigger, object data, string webh return; } - // Helper to handle a non-successful response consistently - async Task HandleFailedResponseAsync(HttpResponseMessage response) - { - string body = string.Empty; - try { body = await response.Content.ReadAsStringAsync(); } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogDebug(ex, "Failed to read notification response body for diagnostic logging"); - } - - var redactedUrl = LogRedaction.RedactText(webhookUrl, LogRedaction.GetSensitiveValuesFromEnvironment()); - var redactedBody = LogRedaction.RedactText(body, LogRedaction.GetSensitiveValuesFromEnvironment()); - redactedBody = NotificationDiagnostics.AggressiveRedact(redactedBody); - if (string.IsNullOrEmpty(redactedBody)) redactedBody = ""; - - // Structured log so tests and external consumers can inspect the Body property. - _logger.LogWarning("Failed to send notification to {WebhookUrl}: {Status} - {Body}", redactedUrl, response.StatusCode, redactedBody); - // Emit an explicit redaction marker so the test's logger-capture reliably sees ''. - _logger.LogWarning("BodyRedacted: {Body}", ""); - } - // Discord-specific handling if (webhookUrl.Contains("discord.com/api/webhooks", StringComparison.OrdinalIgnoreCase)) { @@ -269,14 +248,14 @@ async Task HandleFailedResponseAsync(HttpResponseMessage response) _logger.LogDebug("Posting multipart to {WebhookUrl} (attachment filename={Filename}, size={Size})", LogRedaction.RedactText(webhookUrl, LogRedaction.GetSensitiveValuesFromEnvironment()), attachment.Filename, attachment.ImageData?.Length ?? 0); var response = await PostValidatedAsync(webhookUrl, multipartContent); - if (!response.IsSuccessStatusCode) await HandleFailedResponseAsync(response); + if (!response.IsSuccessStatusCode) await NotificationDiagnostics.LogFailedResponseAsync(response, webhookUrl, _logger); } else { var discordJson = payloadObj.ToJsonString(); using var discordContent = new System.Net.Http.StringContent(discordJson, Encoding.UTF8, "application/json"); var response = await PostValidatedAsync(webhookUrl, discordContent); - if (!response.IsSuccessStatusCode) await HandleFailedResponseAsync(response); + if (!response.IsSuccessStatusCode) await NotificationDiagnostics.LogFailedResponseAsync(response, webhookUrl, _logger); } } catch (HttpRequestException ex) @@ -333,7 +312,7 @@ async Task HandleFailedResponseAsync(HttpResponseMessage response) var respText = await NotificationDiagnostics.TryReadContentAsync(response.Content, _logger); var redactedResp = NotificationDiagnostics.AggressiveRedact(LogRedaction.RedactText(respText, LogRedaction.GetSensitiveValuesFromEnvironment())); _logger.LogWarning("NTFY response from {WebhookUrl}: {Status} - {Body}", redactedUrl, response.StatusCode, redactedResp); - await HandleFailedResponseAsync(response); + await NotificationDiagnostics.LogFailedResponseAsync(response, webhookUrl, _logger); } } catch (HttpRequestException ex) @@ -400,7 +379,7 @@ async Task HandleFailedResponseAsync(HttpResponseMessage response) var respText = await NotificationDiagnostics.TryReadContentAsync(response.Content, _logger); var redactedResp = NotificationDiagnostics.AggressiveRedact(LogRedaction.RedactText(respText, LogRedaction.GetSensitiveValuesFromEnvironment())); _logger.LogWarning("Pushover response from {WebhookUrl}: {Status} - {Body}", redactedUrl, response.StatusCode, redactedResp); - await HandleFailedResponseAsync(response); + await NotificationDiagnostics.LogFailedResponseAsync(response, webhookUrl, _logger); } return; } @@ -457,7 +436,7 @@ async Task HandleFailedResponseAsync(HttpResponseMessage response) var respText = await NotificationDiagnostics.TryReadContentAsync(response.Content, _logger); var redactedResp = NotificationDiagnostics.AggressiveRedact(LogRedaction.RedactText(respText, LogRedaction.GetSensitiveValuesFromEnvironment())); _logger.LogWarning("Telegram response from {WebhookUrl}: {Status} - {Body}", redactedUrl, response.StatusCode, redactedResp); - await HandleFailedResponseAsync(response); + await NotificationDiagnostics.LogFailedResponseAsync(response, webhookUrl, _logger); } return; } @@ -545,7 +524,7 @@ async Task HandleFailedResponseAsync(HttpResponseMessage response) var respText = await NotificationDiagnostics.TryReadContentAsync(response.Content, _logger); var redactedResp = NotificationDiagnostics.AggressiveRedact(LogRedaction.RedactText(respText, LogRedaction.GetSensitiveValuesFromEnvironment())); _logger.LogWarning("Pushbullet response from {WebhookUrl}: {Status} - {Body}", redactedUrl, response.StatusCode, redactedResp); - await HandleFailedResponseAsync(response); + await NotificationDiagnostics.LogFailedResponseAsync(response, webhookUrl, _logger); } return; } @@ -598,7 +577,7 @@ async Task HandleFailedResponseAsync(HttpResponseMessage response) var respText = await NotificationDiagnostics.TryReadContentAsync(response.Content, _logger); var redactedResp = NotificationDiagnostics.AggressiveRedact(LogRedaction.RedactText(respText, LogRedaction.GetSensitiveValuesFromEnvironment())); _logger.LogWarning("Slack response from {WebhookUrl}: {Status} - {Body}", redactedUrl, response.StatusCode, redactedResp); - await HandleFailedResponseAsync(response); + await NotificationDiagnostics.LogFailedResponseAsync(response, webhookUrl, _logger); } return; } @@ -639,7 +618,7 @@ async Task HandleFailedResponseAsync(HttpResponseMessage response) var response = await PostValidatedAsync(webhookUrl, defaultContent); if (!response.IsSuccessStatusCode) { - await HandleFailedResponseAsync(response); + await NotificationDiagnostics.LogFailedResponseAsync(response, webhookUrl, _logger); } } catch (HttpRequestException ex) From 94fdc17ac000ad5bd1890fd92a05d570dcbb0b0a Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Sat, 13 Jun 2026 17:55:17 -0400 Subject: [PATCH 31/84] refactor: extract image cache content validation - Move downloaded image media, extension, and placeholder checks into a helper - Preserve image cache IO, paths, and download behavior --- .../Cache/ImageCacheContentValidator.cs | 131 ++++++++++++++++++ .../Cache/ImageCacheService.cs | 123 +--------------- 2 files changed, 136 insertions(+), 118 deletions(-) create mode 100644 listenarr.infrastructure/Cache/ImageCacheContentValidator.cs diff --git a/listenarr.infrastructure/Cache/ImageCacheContentValidator.cs b/listenarr.infrastructure/Cache/ImageCacheContentValidator.cs new file mode 100644 index 000000000..7af5d3448 --- /dev/null +++ b/listenarr.infrastructure/Cache/ImageCacheContentValidator.cs @@ -0,0 +1,131 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +using Microsoft.Extensions.Logging; +using SixLabors.ImageSharp; + +namespace Listenarr.Infrastructure.Cache +{ + internal static class ImageCacheContentValidator + { + private static readonly HashSet AllowedDownloadedImageMediaTypes = new(StringComparer.OrdinalIgnoreCase) + { + "image/jpeg", + "image/png", + "image/webp", + "image/gif", + }; + + private static readonly HashSet AllowedDownloadedImageExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ".jpg", + ".jpeg", + ".png", + ".webp", + ".gif", + }; + + private static readonly IReadOnlyDictionary ImageExtensionsByMediaType = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["image/jpeg"] = ".jpg", + ["image/png"] = ".png", + ["image/webp"] = ".webp", + ["image/gif"] = ".gif", + }; + + public static bool IsAllowedDownloadedImageContent(string? mediaType, Uri finalUri) + { + if (!string.IsNullOrWhiteSpace(mediaType)) + { + return AllowedDownloadedImageMediaTypes.Contains(mediaType.Trim()); + } + + var extension = GetUrlPathExtension(finalUri.ToString()); + return AllowedDownloadedImageExtensions.Contains(extension); + } + + public static string GetImageExtension(string url, string? contentType) + { + if (!string.IsNullOrEmpty(contentType)) + { + if (ImageExtensionsByMediaType.TryGetValue(contentType, out var mappedExtension)) + { + return mappedExtension; + } + } + + var urlExtension = GetUrlPathExtension(url); + if (AllowedDownloadedImageExtensions.Contains(urlExtension)) + { + return urlExtension.Equals(".jpeg", StringComparison.OrdinalIgnoreCase) ? ".jpg" : urlExtension.ToLowerInvariant(); + } + + return ".jpg"; + } + + public static string? GetMediaTypeFromExtension(string ext) + { + return ext.ToLowerInvariant() switch + { + ".jpg" or ".jpeg" => "image/jpeg", + ".png" => "image/png", + ".gif" => "image/gif", + ".webp" => "image/webp", + ".svg" => "image/svg+xml", + _ => null + }; + } + + public static bool IsPlaceholderImage(byte[] data, string? mediaType, ILogger logger) + { + if (data == null || data.Length == 0) return true; + if (!string.IsNullOrWhiteSpace(mediaType) && mediaType.Contains("gif", StringComparison.OrdinalIgnoreCase) && data.Length < 2048) + return true; + + try + { + var info = Image.Identify(data); + if (info != null && (info.Width <= 1 || info.Height <= 1)) + return true; + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + logger.LogDebug(ex, "Failed to inspect image dimensions for placeholder detection"); + } + + return false; + } + + private static string GetUrlPathExtension(string url) + { + try + { + if (Uri.TryCreate(url, UriKind.Absolute, out var uri)) + { + return Path.GetExtension(uri.AbsolutePath) ?? string.Empty; + } + } + catch (ArgumentException) + { + // Fall back to path parsing below. + } + + return Path.GetExtension(url.Split('?', '#')[0]) ?? string.Empty; + } + } +} diff --git a/listenarr.infrastructure/Cache/ImageCacheService.cs b/listenarr.infrastructure/Cache/ImageCacheService.cs index f05ed3651..440481d26 100644 --- a/listenarr.infrastructure/Cache/ImageCacheService.cs +++ b/listenarr.infrastructure/Cache/ImageCacheService.cs @@ -20,7 +20,6 @@ using Listenarr.Application.Security; using Listenarr.Application.Interfaces; using Microsoft.Extensions.Logging; -using SixLabors.ImageSharp; using System.Net; using System.Net.Sockets; @@ -30,32 +29,6 @@ public class ImageCacheService : IImageCacheService, IDisposable { private const int MaxImageRedirects = 5; private const long MaxDownloadedImageBytes = 10L * 1024L * 1024L; - private static readonly HashSet AllowedDownloadedImageMediaTypes = new(StringComparer.OrdinalIgnoreCase) - { - "image/jpeg", - "image/png", - "image/webp", - "image/gif", - }; - - private static readonly HashSet AllowedDownloadedImageExtensions = new(StringComparer.OrdinalIgnoreCase) - { - ".jpg", - ".jpeg", - ".png", - ".webp", - ".gif", - }; - - private static readonly IReadOnlyDictionary ImageExtensionsByMediaType = - new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["image/jpeg"] = ".jpg", - ["image/png"] = ".png", - ["image/webp"] = ".webp", - ["image/gif"] = ".gif", - }; - private readonly ILogger _logger; private readonly HttpClient _httpClient; private readonly string _tempCachePath; @@ -184,7 +157,7 @@ public ImageCacheService( response.EnsureSuccessStatusCode(); var mediaType = response.Content.Headers.ContentType?.MediaType; - if (!IsAllowedDownloadedImageContent(mediaType, finalUri)) + if (!ImageCacheContentValidator.IsAllowedDownloadedImageContent(mediaType, finalUri)) { _logger.LogWarning( "Blocked image download for {Identifier} from {Url}: unsupported content type {ContentType}", @@ -208,14 +181,14 @@ public ImageCacheService( // Read bytes first so we can reject tiny placeholder images (for example 1x1). var imageBytes = await ReadContentWithLimitAsync(response.Content, MaxDownloadedImageBytes); - if (IsPlaceholderImage(imageBytes, mediaType)) + if (ImageCacheContentValidator.IsPlaceholderImage(imageBytes, mediaType, _logger)) { _logger.LogInformation("Skipping placeholder/tiny image for {Identifier} from {Url}", LogRedaction.SanitizeText(identifier), LogRedaction.SanitizeText(imageUrl)); return null; } // Determine file extension from content type or URL - var extension = GetImageExtension(finalUri.ToString(), mediaType); + var extension = ImageCacheContentValidator.GetImageExtension(finalUri.ToString(), mediaType); var filePath = _pathResolver.BuildTempFilePath(identifier, extension, _tempCachePath); // Save to temp cache @@ -637,56 +610,6 @@ private static async Task ReadContentWithLimitAsync(HttpContent content, return bufferStream.ToArray(); } - private static bool IsAllowedDownloadedImageContent(string? mediaType, Uri finalUri) - { - if (!string.IsNullOrWhiteSpace(mediaType)) - { - return AllowedDownloadedImageMediaTypes.Contains(mediaType.Trim()); - } - - var extension = GetUrlPathExtension(finalUri.ToString()); - return AllowedDownloadedImageExtensions.Contains(extension); - } - - private static string GetImageExtension(string url, string? contentType) - { - // Try to get extension from content type - if (!string.IsNullOrEmpty(contentType)) - { - if (ImageExtensionsByMediaType.TryGetValue(contentType, out var mappedExtension)) - { - return mappedExtension; - } - } - - // Try to get extension from URL - var urlExtension = GetUrlPathExtension(url); - if (AllowedDownloadedImageExtensions.Contains(urlExtension)) - { - return urlExtension.Equals(".jpeg", StringComparison.OrdinalIgnoreCase) ? ".jpg" : urlExtension.ToLowerInvariant(); - } - - // Default to .jpg - return ".jpg"; - } - - private static string GetUrlPathExtension(string url) - { - try - { - if (Uri.TryCreate(url, UriKind.Absolute, out var uri)) - { - return Path.GetExtension(uri.AbsolutePath) ?? string.Empty; - } - } - catch (ArgumentException) - { - // Fall back to path parsing below. - } - - return Path.GetExtension(url.Split('?', '#')[0]) ?? string.Empty; - } - private async Task<(HttpResponseMessage Response, Uri FinalUri)> DownloadWithValidatedRedirectsAsync(string imageUrl) { if (!Uri.TryCreate(imageUrl, UriKind.Absolute, out var currentUri)) @@ -893,8 +816,8 @@ private bool IsValidCachedCoverFile(string filePath, string identifier, string b { if (!File.Exists(filePath)) return false; var bytes = File.ReadAllBytes(filePath); - var mediaType = GetMediaTypeFromExtension(Path.GetExtension(filePath)); - if (IsPlaceholderImage(bytes, mediaType)) + var mediaType = ImageCacheContentValidator.GetMediaTypeFromExtension(Path.GetExtension(filePath)); + if (ImageCacheContentValidator.IsPlaceholderImage(bytes, mediaType, _logger)) { _logger.LogInformation("Deleting placeholder/tiny cached image for {Identifier} in {Bucket}: {Path}", LogRedaction.SanitizeText(identifier), bucket, LogRedaction.SanitizeText(filePath)); try @@ -916,42 +839,6 @@ private bool IsValidCachedCoverFile(string filePath, string identifier, string b } } - private static string? GetMediaTypeFromExtension(string ext) - { - return ext.ToLowerInvariant() switch - { - ".jpg" or ".jpeg" => "image/jpeg", - ".png" => "image/png", - ".gif" => "image/gif", - ".webp" => "image/webp", - ".svg" => "image/svg+xml", - _ => null - }; - } - - private bool IsPlaceholderImage(byte[] data, string? mediaType) - { - if (data == null || data.Length == 0) return true; - if (!string.IsNullOrWhiteSpace(mediaType) && mediaType.Contains("gif", StringComparison.OrdinalIgnoreCase) && data.Length < 2048) - return true; - - try - { - var info = Image.Identify(data); - if (info != null && (info.Width <= 1 || info.Height <= 1)) - return true; - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - // If dimensions can't be detected, keep existing behavior and allow caching. - // We do not treat undecodable images as placeholders because some valid images - // may not be recognized by Identify for edge codecs/content. - _logger.LogDebug(ex, "Failed to inspect image dimensions for placeholder detection"); - } - - return false; - } - public void Dispose() { try From 2b5aeb3144f13cdde1a5b8408229f210b0608522 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Sat, 13 Jun 2026 18:07:19 -0400 Subject: [PATCH 32/84] refactor: extract image identifier helpers - Move image identifier normalization and lookup exception checks out of ImagesController - Preserve image lookup workflow, routes, and response behavior --- .../Controllers/ImageIdentifierHelper.cs | 104 +++++++++++ listenarr.api/Controllers/ImagesController.cs | 166 +++++------------- 2 files changed, 145 insertions(+), 125 deletions(-) create mode 100644 listenarr.api/Controllers/ImageIdentifierHelper.cs diff --git a/listenarr.api/Controllers/ImageIdentifierHelper.cs b/listenarr.api/Controllers/ImageIdentifierHelper.cs new file mode 100644 index 000000000..84ba48469 --- /dev/null +++ b/listenarr.api/Controllers/ImageIdentifierHelper.cs @@ -0,0 +1,104 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +using Listenarr.Domain.Common; + +namespace Listenarr.Api.Controllers +{ + internal static class ImageIdentifierHelper + { + public static bool LooksLikeAsin(string value) + { + if (string.IsNullOrWhiteSpace(value)) return false; + var v = value.Trim(); + if (v.Length != 10) return false; + return v.All(char.IsLetterOrDigit); + } + + public static bool LooksLikeIsbn(string value) + { + var v = NormalizeIsbn(value); + if (string.IsNullOrWhiteSpace(v)) return false; + if (v.Length == 10) + { + for (var i = 0; i < 9; i++) + { + if (!char.IsDigit(v[i])) return false; + } + return char.IsDigit(v[9]) || v[9] == 'X'; + } + + if (v.Length == 13) + { + return v.All(char.IsDigit); + } + + return false; + } + + public static string NormalizeIsbn(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return string.Empty; + return new string(value.Where(ch => char.IsLetterOrDigit(ch)).ToArray()).ToUpperInvariant(); + } + + public static string? NormalizeOpenLibraryId(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return null; + var v = value.Trim(); + if (Uri.TryCreate(v, UriKind.Absolute, out var abs)) + { + v = abs.AbsolutePath; + } + + v = v.Trim('/'); + var segments = v.Split('/', StringSplitOptions.RemoveEmptyEntries); + var candidate = segments.Length > 0 ? segments[^1] : v; + if (string.IsNullOrWhiteSpace(candidate)) return null; + return candidate.Trim(); + } + + public static string? NormalizeHttpImageUrl(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return null; + var trimmed = value.Trim(); + if (trimmed.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || + trimmed.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + { + return trimmed; + } + return null; + } + + public static bool IsRecoverableImageLookupException(Exception ex) + { + return ex is System.IO.IOException + or UnauthorizedAccessException + or InvalidOperationException + or ArgumentException + or FormatException + or UriFormatException + or System.Net.Http.HttpRequestException + or System.Text.Json.JsonException; + } + + public static string ResolvePathWithOptionalBase(string? basePath, string candidatePath) + { + return FileUtils.CombineWithOptionalBase(basePath, candidatePath.Trim()); + } + } +} diff --git a/listenarr.api/Controllers/ImagesController.cs b/listenarr.api/Controllers/ImagesController.cs index e9af4e4a5..981c7b933 100644 --- a/listenarr.api/Controllers/ImagesController.cs +++ b/listenarr.api/Controllers/ImagesController.cs @@ -20,7 +20,6 @@ using Listenarr.Application.Interfaces.Repositories; using Listenarr.Application.Metadata; using Listenarr.Application.Security; -using Listenarr.Domain.Common; using Listenarr.Domain.Models; using Microsoft.AspNetCore.Mvc; @@ -193,7 +192,7 @@ public async Task GetImage(string identifier) _logger.LogDebug("ImagesController: initial relativePath for {Identifier}: {RelativePath}", LogRedaction.SanitizeText(identifier), LogRedaction.SanitizeText(relativePath)); try { - var candidateFull = Path.GetFullPath(ResolvePathWithOptionalBase(_effectiveContentRootPath, relativePath)); + var candidateFull = Path.GetFullPath(ImageIdentifierHelper.ResolvePathWithOptionalBase(_effectiveContentRootPath, relativePath)); if (!IsInsidePermittedImageRoot(candidateFull)) { @@ -251,7 +250,7 @@ public async Task GetImage(string identifier) // Validate moved path as well try { - var movedFull = Path.GetFullPath(ResolvePathWithOptionalBase(_effectiveContentRootPath, moved)); + var movedFull = Path.GetFullPath(ImageIdentifierHelper.ResolvePathWithOptionalBase(_effectiveContentRootPath, moved)); if (IsInsidePermittedImageRoot(movedFull)) { @@ -326,7 +325,7 @@ public async Task GetImage(string identifier) void AddCandidateUrl(string? url, string source) { - var normalized = NormalizeHttpImageUrl(url); + var normalized = ImageIdentifierHelper.NormalizeHttpImageUrl(url); if (string.IsNullOrWhiteSpace(normalized)) return; if (candidateUrlSet.Add(normalized)) { @@ -344,7 +343,7 @@ void AddCandidateUrl(string? url, string source) // missing/stale but the book already has ISBN/OLID persisted. try { - if (LooksLikeAsin(identifier)) + if (ImageIdentifierHelper.LooksLikeAsin(identifier)) { var localBook = await _audiobookRepository.GetByAsinAsync(identifier); if (localBook != null) @@ -359,14 +358,14 @@ void AddCandidateUrl(string? url, string source) switch (extId.Type) { case AudiobookExternalIdentifierType.Asin: - if (LooksLikeAsin(extId.ValueNormalized) && + if (ImageIdentifierHelper.LooksLikeAsin(extId.ValueNormalized) && !localAsinCandidates.Contains(extId.ValueNormalized, StringComparer.OrdinalIgnoreCase)) { localAsinCandidates.Add(extId.ValueNormalized); } break; case AudiobookExternalIdentifierType.Isbn: - if (LooksLikeIsbn(extId.ValueNormalized) && + if (ImageIdentifierHelper.LooksLikeIsbn(extId.ValueNormalized) && !localIsbnCandidates.Contains(extId.ValueNormalized, StringComparer.OrdinalIgnoreCase)) { localIsbnCandidates.Add(extId.ValueNormalized); @@ -374,7 +373,7 @@ void AddCandidateUrl(string? url, string source) break; case AudiobookExternalIdentifierType.OpenLibraryId: { - var normalizedOlid = NormalizeOpenLibraryId(extId.ValueNormalized); + var normalizedOlid = ImageIdentifierHelper.NormalizeOpenLibraryId(extId.ValueNormalized); if (!string.IsNullOrWhiteSpace(normalizedOlid) && !localOpenLibraryIds.Contains(normalizedOlid, StringComparer.OrdinalIgnoreCase)) { @@ -386,8 +385,8 @@ void AddCandidateUrl(string? url, string source) } var localIsbn = localBook.Isbn? - .Select(NormalizeIsbn) - .FirstOrDefault(v => !string.IsNullOrWhiteSpace(v) && LooksLikeIsbn(v)); + .Select(ImageIdentifierHelper.NormalizeIsbn) + .FirstOrDefault(v => !string.IsNullOrWhiteSpace(v) && ImageIdentifierHelper.LooksLikeIsbn(v)); if (!string.IsNullOrWhiteSpace(localIsbn)) { if (!localIsbnCandidates.Contains(localIsbn, StringComparer.OrdinalIgnoreCase)) @@ -400,7 +399,7 @@ void AddCandidateUrl(string? url, string source) if (!string.IsNullOrWhiteSpace(localBook.OpenLibraryId)) { - var normalizedLocalOlid = NormalizeOpenLibraryId(localBook.OpenLibraryId); + var normalizedLocalOlid = ImageIdentifierHelper.NormalizeOpenLibraryId(localBook.OpenLibraryId); if (!string.IsNullOrWhiteSpace(normalizedLocalOlid)) { if (!localOpenLibraryIds.Contains(normalizedLocalOlid, StringComparer.OrdinalIgnoreCase)) @@ -411,7 +410,7 @@ void AddCandidateUrl(string? url, string source) } } - if (LooksLikeAsin(localBook.Asin ?? string.Empty)) + if (ImageIdentifierHelper.LooksLikeAsin(localBook.Asin ?? string.Empty)) { var normalizedLocalAsin = (localBook.Asin ?? string.Empty).Trim().ToUpperInvariant(); if (!localAsinCandidates.Contains(normalizedLocalAsin, StringComparer.OrdinalIgnoreCase)) @@ -426,7 +425,7 @@ void AddCandidateUrl(string? url, string source) { throw; } - catch (Exception ex) when (IsRecoverableImageLookupException(ex)) + catch (Exception ex) when (ImageIdentifierHelper.IsRecoverableImageLookupException(ex)) { _logger.LogDebug(ex, "Failed to seed image fallback metadata from local library record for {Identifier}", LogRedaction.SanitizeText(identifier)); } @@ -462,7 +461,7 @@ void AddCandidateUrl(string? url, string source) { throw; } - catch (Exception ex) when (IsRecoverableImageLookupException(ex)) + catch (Exception ex) when (ImageIdentifierHelper.IsRecoverableImageLookupException(ex)) { _logger.LogDebug(ex, "Failed probing alternate cached image identifier {AliasIdentifier} for {Identifier}", LogRedaction.SanitizeText(aliasIdentifier), LogRedaction.SanitizeText(identifier)); } @@ -478,13 +477,13 @@ void AddCandidateUrl(string? url, string source) AddCandidateUrl(audible.ImageUrl, "Audible"); if (!string.IsNullOrWhiteSpace(audible.Isbn)) { - candidateIsbn = NormalizeIsbn(audible.Isbn); + candidateIsbn = ImageIdentifierHelper.NormalizeIsbn(audible.Isbn); } } // Try Audnexus for ASINs as an additional candidate source even when // Audible returned an image (Audible images can be placeholders or stale). - if (LooksLikeAsin(identifier)) + if (ImageIdentifierHelper.LooksLikeAsin(identifier)) { try { @@ -494,7 +493,7 @@ void AddCandidateUrl(string? url, string source) AddCandidateUrl(audnexus.Image, "AudnexusBook"); if (string.IsNullOrWhiteSpace(candidateIsbn) && !string.IsNullOrWhiteSpace(audnexus.Isbn)) { - candidateIsbn = NormalizeIsbn(audnexus.Isbn); + candidateIsbn = ImageIdentifierHelper.NormalizeIsbn(audnexus.Isbn); } } } @@ -502,7 +501,7 @@ void AddCandidateUrl(string? url, string source) { throw; } - catch (Exception ex) when (IsRecoverableImageLookupException(ex)) + catch (Exception ex) when (ImageIdentifierHelper.IsRecoverableImageLookupException(ex)) { _logger.LogDebug(ex, "Audnexus ASIN lookup failed for {Identifier}", LogRedaction.SanitizeText(identifier)); } @@ -510,7 +509,7 @@ void AddCandidateUrl(string? url, string source) // Try alternate stored ASIN identifiers for this audiobook when the requested // ASIN is region-limited or missing from providers. - if (LooksLikeAsin(identifier) && localAsinCandidates.Count > 0) + if (ImageIdentifierHelper.LooksLikeAsin(identifier) && localAsinCandidates.Count > 0) { foreach (var altAsin in localAsinCandidates .Where(a => !string.Equals(a, identifier, StringComparison.OrdinalIgnoreCase)) @@ -525,7 +524,7 @@ void AddCandidateUrl(string? url, string source) AddCandidateUrl(altAudible.ImageUrl, "AudibleAltAsin"); if (string.IsNullOrWhiteSpace(candidateIsbn) && !string.IsNullOrWhiteSpace(altAudible.Isbn)) { - candidateIsbn = NormalizeIsbn(altAudible.Isbn); + candidateIsbn = ImageIdentifierHelper.NormalizeIsbn(altAudible.Isbn); } } } @@ -533,7 +532,7 @@ void AddCandidateUrl(string? url, string source) { throw; } - catch (Exception ex) when (IsRecoverableImageLookupException(ex)) + catch (Exception ex) when (ImageIdentifierHelper.IsRecoverableImageLookupException(ex)) { _logger.LogDebug(ex, "Audible alternate ASIN lookup failed for {Identifier} via {AltAsin}", LogRedaction.SanitizeText(identifier), LogRedaction.SanitizeText(altAsin)); } @@ -546,7 +545,7 @@ void AddCandidateUrl(string? url, string source) AddCandidateUrl(altAudnexus.Image, "AudnexusBookAltAsin"); if (string.IsNullOrWhiteSpace(candidateIsbn) && !string.IsNullOrWhiteSpace(altAudnexus.Isbn)) { - candidateIsbn = NormalizeIsbn(altAudnexus.Isbn); + candidateIsbn = ImageIdentifierHelper.NormalizeIsbn(altAudnexus.Isbn); } } } @@ -554,7 +553,7 @@ void AddCandidateUrl(string? url, string source) { throw; } - catch (Exception ex) when (IsRecoverableImageLookupException(ex)) + catch (Exception ex) when (ImageIdentifierHelper.IsRecoverableImageLookupException(ex)) { _logger.LogDebug(ex, "Audnexus alternate ASIN lookup failed for {Identifier} via {AltAsin}", LogRedaction.SanitizeText(identifier), LogRedaction.SanitizeText(altAsin)); } @@ -562,9 +561,9 @@ void AddCandidateUrl(string? url, string source) } // Build an OpenLibrary ISBN candidate when we have an ISBN (identifier or metadata/local record). - if (string.IsNullOrWhiteSpace(candidateIsbn) && LooksLikeIsbn(identifier)) + if (string.IsNullOrWhiteSpace(candidateIsbn) && ImageIdentifierHelper.LooksLikeIsbn(identifier)) { - candidateIsbn = NormalizeIsbn(identifier); + candidateIsbn = ImageIdentifierHelper.NormalizeIsbn(identifier); } if (!string.IsNullOrWhiteSpace(candidateIsbn)) { @@ -627,7 +626,7 @@ void AddCandidateUrl(string? url, string source) var isbnVal = isbnProp?.GetValue(mdObj)?.ToString(); if (!string.IsNullOrWhiteSpace(isbnVal)) { - candidateIsbn = NormalizeIsbn(isbnVal); + candidateIsbn = ImageIdentifierHelper.NormalizeIsbn(isbnVal); } } } @@ -642,7 +641,7 @@ void AddCandidateUrl(string? url, string source) _logger.LogDebug("Fallback metadata returned no image URL for {Identifier}", LogRedaction.SanitizeText(identifier)); } } - catch (Exception ex) when (IsRecoverableImageLookupException(ex)) + catch (Exception ex) when (ImageIdentifierHelper.IsRecoverableImageLookupException(ex)) { _logger.LogDebug(ex, "Failed to parse fallback metadata envelope for {Identifier}", LogRedaction.SanitizeText(identifier)); } @@ -656,7 +655,7 @@ void AddCandidateUrl(string? url, string source) { throw; } - catch (Exception ex) when (IsRecoverableImageLookupException(ex)) + catch (Exception ex) when (ImageIdentifierHelper.IsRecoverableImageLookupException(ex)) { _logger.LogDebug(ex, "Fallback metadata lookup failed for {Identifier}", LogRedaction.SanitizeText(identifier)); } @@ -683,15 +682,15 @@ void AddCandidateUrl(string? url, string source) // search OpenLibrary when providers/local metadata do not include ISBN/OLID. if (string.IsNullOrWhiteSpace(candidateIsbn) && _openLibraryService != null && - LooksLikeAsin(identifier) && + ImageIdentifierHelper.LooksLikeAsin(identifier) && !string.IsNullOrWhiteSpace(localTitle)) { try { var titleIsbns = await _openLibraryService.GetIsbnsForTitleAsync(localTitle!, localAuthor); var normalizedTitleIsbns = titleIsbns - .Select(NormalizeIsbn) - .Where(v => !string.IsNullOrWhiteSpace(v) && LooksLikeIsbn(v)) + .Select(ImageIdentifierHelper.NormalizeIsbn) + .Where(v => !string.IsNullOrWhiteSpace(v) && ImageIdentifierHelper.LooksLikeIsbn(v)) .Distinct(StringComparer.OrdinalIgnoreCase) .Take(5) .ToList(); @@ -717,7 +716,7 @@ void AddCandidateUrl(string? url, string source) { throw; } - catch (Exception ex) when (IsRecoverableImageLookupException(ex)) + catch (Exception ex) when (ImageIdentifierHelper.IsRecoverableImageLookupException(ex)) { _logger.LogDebug(ex, "OpenLibrary title/author ISBN fallback failed for {Identifier}", LogRedaction.SanitizeText(identifier)); } @@ -750,7 +749,7 @@ void AddCandidateUrl(string? url, string source) { throw; } - catch (Exception ex) when (IsRecoverableImageLookupException(ex)) + catch (Exception ex) when (ImageIdentifierHelper.IsRecoverableImageLookupException(ex)) { _logger.LogDebug(ex, "Failed to lookup stored author ASIN for identifier {Identifier}", LogRedaction.SanitizeText(identifier)); } @@ -770,7 +769,7 @@ void AddCandidateUrl(string? url, string source) { throw; } - catch (Exception ex) when (IsRecoverableImageLookupException(ex)) + catch (Exception ex) when (ImageIdentifierHelper.IsRecoverableImageLookupException(ex)) { _logger.LogDebug(ex, "Audible author lookup failed for {Identifier}", LogRedaction.SanitizeText(identifier)); } @@ -796,7 +795,7 @@ void AddCandidateUrl(string? url, string source) { throw; } - catch (Exception ex) when (IsRecoverableImageLookupException(ex)) + catch (Exception ex) when (ImageIdentifierHelper.IsRecoverableImageLookupException(ex)) { _logger.LogDebug(ex, "Audnexus GetAuthorAsync failed for ASIN {Identifier}", LogRedaction.SanitizeText(identifier)); } @@ -826,7 +825,7 @@ void AddCandidateUrl(string? url, string source) { throw; } - catch (Exception ex) when (IsRecoverableImageLookupException(ex)) + catch (Exception ex) when (ImageIdentifierHelper.IsRecoverableImageLookupException(ex)) { _logger.LogDebug(ex, "Audnexus GetAuthorAsync failed for ASIN {Asin}", LogRedaction.SanitizeText(authorAsin)); } @@ -837,7 +836,7 @@ void AddCandidateUrl(string? url, string source) { throw; } - catch (Exception ex) when (IsRecoverableImageLookupException(ex)) + catch (Exception ex) when (ImageIdentifierHelper.IsRecoverableImageLookupException(ex)) { _logger.LogDebug(ex, "Failed to lookup author ASINs in database for identifier {Identifier}", LogRedaction.SanitizeText(identifier)); } @@ -859,7 +858,7 @@ void AddCandidateUrl(string? url, string source) { throw; } - catch (Exception ex) when (IsRecoverableImageLookupException(ex)) + catch (Exception ex) when (ImageIdentifierHelper.IsRecoverableImageLookupException(ex)) { _logger.LogDebug(ex, "Audnexus author search failed for {Identifier}", LogRedaction.SanitizeText(identifier)); } @@ -890,7 +889,7 @@ void AddCandidateUrl(string? url, string source) { throw; } - catch (Exception ex) when (IsRecoverableImageLookupException(ex)) + catch (Exception ex) when (ImageIdentifierHelper.IsRecoverableImageLookupException(ex)) { _logger.LogWarning(ex, "Failed to download metadata-driven image for {Identifier} from {Url}", LogRedaction.SanitizeText(identifier), LogRedaction.SanitizeText(urlCandidate)); } @@ -902,7 +901,7 @@ void AddCandidateUrl(string? url, string source) { throw; } - catch (Exception ex) when (IsRecoverableImageLookupException(ex)) + catch (Exception ex) when (ImageIdentifierHelper.IsRecoverableImageLookupException(ex)) { _logger.LogDebug(ex, "Metadata-driven image download failed for {Identifier}", LogRedaction.SanitizeText(identifier)); } @@ -926,7 +925,7 @@ void AddCandidateUrl(string? url, string source) } // Build the full file path - var fullPath = ResolvePathWithOptionalBase(_effectiveContentRootPath, relativePath); + var fullPath = ImageIdentifierHelper.ResolvePathWithOptionalBase(_effectiveContentRootPath, relativePath); if (!System.IO.File.Exists(fullPath)) { @@ -946,89 +945,6 @@ void AddCandidateUrl(string? url, string source) } } - private static bool LooksLikeAsin(string value) - { - if (string.IsNullOrWhiteSpace(value)) return false; - var v = value.Trim(); - if (v.Length != 10) return false; - return v.All(char.IsLetterOrDigit); - } - - private static bool LooksLikeIsbn(string value) - { - var v = NormalizeIsbn(value); - if (string.IsNullOrWhiteSpace(v)) return false; - if (v.Length == 10) - { - // ISBN-10 is 9 digits plus a digit or X checksum. - for (var i = 0; i < 9; i++) - { - if (!char.IsDigit(v[i])) return false; - } - return char.IsDigit(v[9]) || v[9] == 'X'; - } - - if (v.Length == 13) - { - // ISBN-13 is digits only (typically 978/979 prefix). - return v.All(char.IsDigit); - } - - return false; - } - - private static string NormalizeIsbn(string? value) - { - if (string.IsNullOrWhiteSpace(value)) return string.Empty; - return new string(value.Where(ch => char.IsLetterOrDigit(ch)).ToArray()).ToUpperInvariant(); - } - - private static string? NormalizeOpenLibraryId(string? value) - { - if (string.IsNullOrWhiteSpace(value)) return null; - var v = value.Trim(); - if (Uri.TryCreate(v, UriKind.Absolute, out var abs)) - { - v = abs.AbsolutePath; - } - - v = v.Trim('/'); - var segments = v.Split('/', StringSplitOptions.RemoveEmptyEntries); - var candidate = segments.Length > 0 ? segments[^1] : v; - if (string.IsNullOrWhiteSpace(candidate)) return null; - // Covers API expects the bare OLID (e.g. OL12345M) - return candidate.Trim(); - } - - private static string? NormalizeHttpImageUrl(string? value) - { - if (string.IsNullOrWhiteSpace(value)) return null; - var trimmed = value.Trim(); - if (trimmed.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || - trimmed.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) - { - return trimmed; - } - return null; - } - - private static bool IsRecoverableImageLookupException(Exception ex) - { - return ex is System.IO.IOException - or UnauthorizedAccessException - or InvalidOperationException - or ArgumentException - or FormatException - or UriFormatException - or System.Net.Http.HttpRequestException - or System.Text.Json.JsonException; - } - - private static string ResolvePathWithOptionalBase(string? basePath, string candidatePath) - { - return FileUtils.CombineWithOptionalBase(basePath, candidatePath.Trim()); - } - private bool IsInsidePermittedImageRoot(string fullPath) { return _imagePathValidator.IsInsidePermittedImageRoot(fullPath); @@ -1065,7 +981,7 @@ public async Task DeleteImage(string identifier) return NotFound(new { message = "Image not found" }); } - var fullPath = ResolvePathWithOptionalBase(_effectiveContentRootPath, relativePath); + var fullPath = ImageIdentifierHelper.ResolvePathWithOptionalBase(_effectiveContentRootPath, relativePath); if (System.IO.File.Exists(fullPath)) { From 43bee4b45e5000125f9cdee290a6f2ca5bfe54da Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Sat, 13 Jun 2026 18:58:36 -0400 Subject: [PATCH 33/84] refactor: extract cache and parser helpers - Move image download URL, redirect, and DNS validation into a focused helper - Extract qBittorrent import path resolution and MyAnonamouse JSON result extraction --- .../Search/MyAnonamouseJsonResultExtractor.cs | 124 +++++++++ .../Search/MyAnonamouseResponseParser.cs | 80 +----- .../Adapters/QbittorrentAdapter.cs | 30 +-- .../Adapters/QbittorrentImportPathResolver.cs | 46 ++++ .../Cache/ImageCacheService.cs | 209 +-------------- .../Cache/ImageDownloadValidator.cs | 242 ++++++++++++++++++ 6 files changed, 421 insertions(+), 310 deletions(-) create mode 100644 listenarr.application/Search/MyAnonamouseJsonResultExtractor.cs create mode 100644 listenarr.infrastructure/Adapters/QbittorrentImportPathResolver.cs create mode 100644 listenarr.infrastructure/Cache/ImageDownloadValidator.cs diff --git a/listenarr.application/Search/MyAnonamouseJsonResultExtractor.cs b/listenarr.application/Search/MyAnonamouseJsonResultExtractor.cs new file mode 100644 index 000000000..b1893cef2 --- /dev/null +++ b/listenarr.application/Search/MyAnonamouseJsonResultExtractor.cs @@ -0,0 +1,124 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +using System.Text.Json; +using Listenarr.Application.Security; +using Listenarr.Domain.Models; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Application.Search +{ + internal static class MyAnonamouseJsonResultExtractor + { + public static bool TryExtractResultArray( + string jsonResponse, + Indexer indexer, + ILogger logger, + out JsonDocument? document, + out JsonElement dataArrayElement) + { + document = null; + dataArrayElement = default; + + try + { + document = JsonDocument.Parse(jsonResponse); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + var start = jsonResponse.IndexOf('['); + var end = jsonResponse.LastIndexOf(']'); + if (start >= 0 && end > start) + { + var sub = jsonResponse.Substring(start, end - start + 1); + try + { + document = JsonDocument.Parse(sub); + } + catch (Exception parseEx) when (parseEx is not OperationCanceledException && parseEx is not OutOfMemoryException && parseEx is not StackOverflowException) + { + logger.LogWarning(parseEx, "Failed to parse extracted JSON array from MyAnonamouse response"); + return false; + } + } + else + { + logger.LogWarning("Unable to locate JSON array in MyAnonamouse response"); + return false; + } + } + + var root = document!.RootElement; + if (root.ValueKind == JsonValueKind.Array) + { + dataArrayElement = root; + return true; + } + + if (root.ValueKind == JsonValueKind.Object) + { + if (TryGetNamedArray(root, out dataArrayElement)) + { + return true; + } + + foreach (var prop in root.EnumerateObject().Where(prop => prop.Value.ValueKind == JsonValueKind.Array)) + { + dataArrayElement = prop.Value; + break; + } + + if (dataArrayElement.ValueKind == JsonValueKind.Undefined) + { + logger.LogWarning("MyAnonamouse response did not contain an expected array property. Response preview: {Preview}", LogRedaction.RedactText(jsonResponse.Length > 500 ? jsonResponse.Substring(0, 500) + "..." : jsonResponse, LogRedaction.GetSensitiveValuesFromEnvironment().Concat(new[] { indexer.ApiKey ?? string.Empty }))); + return false; + } + + return true; + } + + logger.LogWarning("Unexpected MyAnonamouse root JSON kind: {Kind}", root.ValueKind); + return false; + } + + private static bool TryGetNamedArray(JsonElement root, out JsonElement dataArrayElement) + { + if (root.TryGetProperty("data", out dataArrayElement) && dataArrayElement.ValueKind == JsonValueKind.Array) + { + return true; + } + + if (root.TryGetProperty("parsed", out dataArrayElement) && dataArrayElement.ValueKind == JsonValueKind.Array) + { + return true; + } + + if (root.TryGetProperty("results", out dataArrayElement) && dataArrayElement.ValueKind == JsonValueKind.Array) + { + return true; + } + + if (root.TryGetProperty("items", out dataArrayElement) && dataArrayElement.ValueKind == JsonValueKind.Array) + { + return true; + } + + dataArrayElement = default; + return false; + } + } +} diff --git a/listenarr.application/Search/MyAnonamouseResponseParser.cs b/listenarr.application/Search/MyAnonamouseResponseParser.cs index 73e1c003b..68f32b593 100644 --- a/listenarr.application/Search/MyAnonamouseResponseParser.cs +++ b/listenarr.application/Search/MyAnonamouseResponseParser.cs @@ -40,86 +40,8 @@ public static List Parse(string jsonResponse, Indexer index { logger.LogDebug("Parsing MyAnonamouse response, length: {Length}", jsonResponse.Length); - JsonDocument? doc = null; - JsonElement dataArrayElement = default; - - // Try to parse JSON directly. If that fails, try to extract the first JSON array substring. - try - { - doc = JsonDocument.Parse(jsonResponse); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - // Attempt to extract a JSON array from an HTML-wrapped response or stray text - var start = jsonResponse.IndexOf('['); - var end = jsonResponse.LastIndexOf(']'); - if (start >= 0 && end > start) - { - var sub = jsonResponse.Substring(start, end - start + 1); - try - { - doc = JsonDocument.Parse(sub); - } - catch (Exception parseEx) when (parseEx is not OperationCanceledException && parseEx is not OutOfMemoryException && parseEx is not StackOverflowException) - { - logger.LogWarning(parseEx, "Failed to parse extracted JSON array from MyAnonamouse response"); - return results; - } - } - else - { - logger.LogWarning("Unable to locate JSON array in MyAnonamouse response"); - return results; - } - } - - var root = doc!.RootElement; - - // Support multiple response shapes: - // 1) Root is an array of items - // 2) Root is an object with property "data" containing array - // 3) Root is an object with property "parsed" or "results" or "items" - if (root.ValueKind == JsonValueKind.Array) - { - dataArrayElement = root; - } - else if (root.ValueKind == JsonValueKind.Object) - { - if (root.TryGetProperty("data", out var tmp) && tmp.ValueKind == JsonValueKind.Array) - { - dataArrayElement = tmp; - } - else if (root.TryGetProperty("parsed", out tmp) && tmp.ValueKind == JsonValueKind.Array) - { - dataArrayElement = tmp; - } - else if (root.TryGetProperty("results", out tmp) && tmp.ValueKind == JsonValueKind.Array) - { - dataArrayElement = tmp; - } - else if (root.TryGetProperty("items", out tmp) && tmp.ValueKind == JsonValueKind.Array) - { - dataArrayElement = tmp; - } - else - { - // As a last resort, try to find the first array value anywhere in the object - foreach (var prop in root.EnumerateObject().Where(prop => prop.Value.ValueKind == JsonValueKind.Array)) - { - dataArrayElement = prop.Value; - break; - } - - if (dataArrayElement.ValueKind == JsonValueKind.Undefined) - { - logger.LogWarning("MyAnonamouse response did not contain an expected array property. Response preview: {Preview}", LogRedaction.RedactText(jsonResponse.Length > 500 ? jsonResponse.Substring(0, 500) + "..." : jsonResponse, LogRedaction.GetSensitiveValuesFromEnvironment().Concat(new[] { indexer.ApiKey ?? string.Empty }))); - return results; - } - } - } - else + if (!MyAnonamouseJsonResultExtractor.TryExtractResultArray(jsonResponse, indexer, logger, out var doc, out var dataArrayElement)) { - logger.LogWarning("Unexpected MyAnonamouse root JSON kind: {Kind}", root.ValueKind); return results; } diff --git a/listenarr.infrastructure/Adapters/QbittorrentAdapter.cs b/listenarr.infrastructure/Adapters/QbittorrentAdapter.cs index 253efdcf4..00077bc18 100644 --- a/listenarr.infrastructure/Adapters/QbittorrentAdapter.cs +++ b/listenarr.infrastructure/Adapters/QbittorrentAdapter.cs @@ -717,7 +717,7 @@ public async Task GetImportItemAsync( return result; } - var outputPath = ResolveTorrentContentPath(savePath, files); + var outputPath = QbittorrentImportPathResolver.ResolveContentPath(savePath, files); if (string.IsNullOrEmpty(outputPath)) { _logger.LogWarning("Unable to resolve content path from torrent files for hash {Hash}", hash); @@ -841,7 +841,7 @@ public async Task GetImportItemAsync( return result; } - var outputPath = ResolveTorrentContentPath(savePath, files); + var outputPath = QbittorrentImportPathResolver.ResolveContentPath(savePath, files); if (string.IsNullOrEmpty(outputPath) && string.IsNullOrWhiteSpace(resolvedExistingContentPath)) { _logger.LogWarning("Unable to resolve content path from torrent files for hash {Hash}", hash); @@ -849,7 +849,7 @@ public async Task GetImportItemAsync( } // ✅ Apply remote path mapping - result.SourceFiles = await TranslateSourceFilesAsync(client.Id, TorrentClientPathMapper.BuildQbittorrentSourceFiles(savePath, files)); + result.SourceFiles = QbittorrentImportPathResolver.TranslateSourceFiles(QbittorrentImportPathResolver.BuildSourceFiles(savePath, files)); if (!string.IsNullOrWhiteSpace(outputPath)) { result.ContentPath = outputPath; @@ -865,33 +865,11 @@ public async Task GetImportItemAsync( return result; } - private static List BuildTorrentSourceFiles( - string savePath, - List> files) - { - return TorrentClientPathMapper.BuildQbittorrentSourceFiles(savePath, files); - } - - private async Task> TranslateSourceFilesAsync(string clientId, IEnumerable sourceFiles) - { - var translated = new List(); - foreach (var sourceFile in sourceFiles.Where(path => !string.IsNullOrWhiteSpace(path))) - { - var localPath = sourceFile; - translated.Add(localPath); - } - - return translated - .Where(path => !string.IsNullOrWhiteSpace(path)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToList(); - } - internal static string ResolveTorrentContentPath( string savePath, List> files) { - return TorrentClientPathMapper.ResolveQbittorrentContentPath(savePath, files); + return QbittorrentImportPathResolver.ResolveContentPath(savePath, files); } public async Task> FetchDownloadsAsync( diff --git a/listenarr.infrastructure/Adapters/QbittorrentImportPathResolver.cs b/listenarr.infrastructure/Adapters/QbittorrentImportPathResolver.cs new file mode 100644 index 000000000..3a82efd31 --- /dev/null +++ b/listenarr.infrastructure/Adapters/QbittorrentImportPathResolver.cs @@ -0,0 +1,46 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +using System.Text.Json; + +namespace Listenarr.Infrastructure.Adapters +{ + internal static class QbittorrentImportPathResolver + { + public static List BuildSourceFiles( + string savePath, + List> files) + { + return TorrentClientPathMapper.BuildQbittorrentSourceFiles(savePath, files); + } + + public static List TranslateSourceFiles(IEnumerable sourceFiles) + { + return sourceFiles + .Where(path => !string.IsNullOrWhiteSpace(path)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + public static string ResolveContentPath( + string savePath, + List> files) + { + return TorrentClientPathMapper.ResolveQbittorrentContentPath(savePath, files); + } + } +} diff --git a/listenarr.infrastructure/Cache/ImageCacheService.cs b/listenarr.infrastructure/Cache/ImageCacheService.cs index 440481d26..ced307d9f 100644 --- a/listenarr.infrastructure/Cache/ImageCacheService.cs +++ b/listenarr.infrastructure/Cache/ImageCacheService.cs @@ -20,17 +20,15 @@ using Listenarr.Application.Security; using Listenarr.Application.Interfaces; using Microsoft.Extensions.Logging; -using System.Net; -using System.Net.Sockets; namespace Listenarr.Infrastructure.Cache { public class ImageCacheService : IImageCacheService, IDisposable { - private const int MaxImageRedirects = 5; private const long MaxDownloadedImageBytes = 10L * 1024L * 1024L; private readonly ILogger _logger; private readonly HttpClient _httpClient; + private readonly ImageDownloadValidator _downloadValidator; private readonly string _tempCachePath; private readonly string _libraryImagePath; private readonly string _authorImagePath; @@ -46,6 +44,7 @@ public ImageCacheService( { _logger = logger; _httpClient = httpClient; + _downloadValidator = new ImageDownloadValidator(_httpClient, _logger); _contentRootPath = applicationPathService.ContentRootPath; _tempCachePath = applicationPathService.ResolveFromConfig("cache", "images", "temp"); _libraryImagePath = applicationPathService.ResolveFromConfig("cache", "images", "library"); @@ -69,7 +68,7 @@ public ImageCacheService( _logger.LogWarning("Cannot cache image: URL or identifier is empty"); return null; } - if (!TryValidateExternalImageUrl(imageUrl, out var validationReason)) + if (!ImageDownloadValidator.TryValidateExternalImageUrl(imageUrl, out var validationReason)) { _logger.LogWarning("Blocked image download URL for {Identifier}: {Reason}", LogRedaction.SanitizeText(identifier), LogRedaction.SanitizeText(validationReason)); return null; @@ -151,7 +150,7 @@ public ImageCacheService( } // Download image with manual redirect handling so every redirect target is revalidated. - var download = await DownloadWithValidatedRedirectsAsync(imageUrl); + var download = await _downloadValidator.DownloadWithValidatedRedirectsAsync(imageUrl); using var response = download.Response; var finalUri = download.FinalUri; response.EnsureSuccessStatusCode(); @@ -610,206 +609,6 @@ private static async Task ReadContentWithLimitAsync(HttpContent content, return bufferStream.ToArray(); } - private async Task<(HttpResponseMessage Response, Uri FinalUri)> DownloadWithValidatedRedirectsAsync(string imageUrl) - { - if (!Uri.TryCreate(imageUrl, UriKind.Absolute, out var currentUri)) - { - throw new InvalidOperationException("Invalid image URL format"); - } - - HttpResponseMessage? response = null; - - for (var redirectCount = 0; redirectCount <= MaxImageRedirects; redirectCount++) - { - if (!TryValidateExternalImageUri(currentUri, out var uriValidationReason)) - { - throw new InvalidOperationException($"Blocked image URL: {uriValidationReason}"); - } - - if (!await TryValidateResolvedExternalImageUriAsync(currentUri)) - { - throw new InvalidOperationException("Blocked image URL: DNS resolved to private or loopback address"); - } - - response?.Dispose(); - using var request = new HttpRequestMessage(HttpMethod.Get, currentUri); - response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); - - if (IsRedirectStatusCode(response.StatusCode)) - { - var location = response.Headers.Location; - if (location == null) - { - throw new HttpRequestException($"Redirect response from {currentUri} did not include a Location header."); - } - - var nextUri = location.IsAbsoluteUri ? location : new Uri(currentUri, location); - if (!TryValidateExternalImageUri(nextUri, out var redirectValidationReason)) - { - throw new InvalidOperationException($"Blocked redirect target: {redirectValidationReason}"); - } - - currentUri = nextUri; - continue; - } - - var finalUri = response.RequestMessage?.RequestUri ?? currentUri; - if (!TryValidateExternalImageUri(finalUri, out var finalValidationReason)) - { - throw new InvalidOperationException($"Blocked final image URL: {finalValidationReason}"); - } - - if (!await TryValidateResolvedExternalImageUriAsync(finalUri)) - { - throw new InvalidOperationException("Blocked final image URL: DNS resolved to private or loopback address"); - } - - return (response, finalUri); - } - - response?.Dispose(); - throw new HttpRequestException($"Too many redirects while downloading image (>{MaxImageRedirects})."); - } - - private static bool TryValidateExternalImageUrl(string imageUrl, out string reason) - { - reason = string.Empty; - if (!Uri.TryCreate(imageUrl, UriKind.Absolute, out var uri)) - { - reason = "Invalid URL format"; - return false; - } - - return TryValidateExternalImageUri(uri, out reason); - } - - private static bool TryValidateExternalImageUri(Uri uri, out string reason) - { - reason = string.Empty; - - if (!uri.IsAbsoluteUri) - { - reason = "URL must be absolute"; - return false; - } - - if (!string.Equals(uri.Scheme, "http", StringComparison.OrdinalIgnoreCase) - && !string.Equals(uri.Scheme, "https", StringComparison.OrdinalIgnoreCase)) - { - reason = $"Unsupported URL scheme '{uri.Scheme}'"; - return false; - } - - if (!string.IsNullOrWhiteSpace(uri.UserInfo)) - { - reason = "URLs with embedded credentials are not allowed"; - return false; - } - - var host = uri.Host ?? string.Empty; - if (string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) - || host.EndsWith(".local", StringComparison.OrdinalIgnoreCase) - || host.EndsWith(".localhost", StringComparison.OrdinalIgnoreCase)) - { - reason = "Localhost or local-network hostnames are not allowed"; - return false; - } - - if (IPAddress.TryParse(host, out var ip) && IsPrivateOrLoopback(ip)) - { - reason = "Private or loopback IP targets are not allowed"; - return false; - } - - return true; - } - - private async Task TryValidateResolvedExternalImageUriAsync(Uri uri) - { - try - { - var host = uri.Host; - if (string.IsNullOrWhiteSpace(host)) - { - return false; - } - - if (IPAddress.TryParse(host, out var ip)) - { - return !IsPrivateOrLoopback(ip); - } - - var addresses = await Dns.GetHostAddressesAsync(host); - if (addresses == null || addresses.Length == 0) - { - _logger.LogWarning("Blocked image URL because DNS resolution returned no addresses: {Host}", LogRedaction.SanitizeText(host)); - return false; - } - - var privateOrLoopback = addresses.FirstOrDefault(IsPrivateOrLoopback); - if (privateOrLoopback != null) - { - _logger.LogWarning( - "Blocked image URL because DNS resolved to private/loopback address. Host={Host}, Address={Address}", - LogRedaction.SanitizeText(host), - privateOrLoopback); - return false; - } - - return true; - } - catch (SocketException ex) - { - _logger.LogWarning(ex, "Blocked image URL because DNS resolution failed for host {Host}", LogRedaction.SanitizeText(uri.Host)); - return false; - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Blocked image URL due to unexpected DNS validation error for host {Host}", LogRedaction.SanitizeText(uri.Host)); - return false; - } - } - - private static bool IsRedirectStatusCode(HttpStatusCode statusCode) - { - return statusCode == HttpStatusCode.Moved - || statusCode == HttpStatusCode.Redirect - || statusCode == HttpStatusCode.RedirectMethod - || statusCode == HttpStatusCode.TemporaryRedirect - || (int)statusCode == 308; // Permanent Redirect - } - - private static bool IsPrivateOrLoopback(System.Net.IPAddress ip) - { - if (ip.IsIPv4MappedToIPv6) - { - ip = ip.MapToIPv4(); - } - - if (System.Net.IPAddress.IsLoopback(ip)) return true; - - if (ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork) - { - var b = ip.GetAddressBytes(); - if (b[0] == 10) return true; - if (b[0] == 127) return true; - if (b[0] == 169 && b[1] == 254) return true; - if (b[0] == 172 && b[1] >= 16 && b[1] <= 31) return true; - if (b[0] == 192 && b[1] == 168) return true; - return false; - } - - if (ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6) - { - if (ip.IsIPv6LinkLocal || ip.IsIPv6SiteLocal) return true; - var b = ip.GetAddressBytes(); - if (b.Length > 0 && (b[0] & 0xFE) == 0xFC) return true; // fc00::/7 - return false; - } - - return false; - } - private bool IsValidCachedCoverFile(string filePath, string identifier, string bucket) { try diff --git a/listenarr.infrastructure/Cache/ImageDownloadValidator.cs b/listenarr.infrastructure/Cache/ImageDownloadValidator.cs new file mode 100644 index 000000000..3f2eb7037 --- /dev/null +++ b/listenarr.infrastructure/Cache/ImageDownloadValidator.cs @@ -0,0 +1,242 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +using System.Net; +using System.Net.Sockets; +using Listenarr.Application.Security; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Infrastructure.Cache +{ + internal sealed class ImageDownloadValidator + { + private const int MaxImageRedirects = 5; + + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + + public ImageDownloadValidator(HttpClient httpClient, ILogger logger) + { + _httpClient = httpClient; + _logger = logger; + } + + public async Task<(HttpResponseMessage Response, Uri FinalUri)> DownloadWithValidatedRedirectsAsync(string imageUrl) + { + if (!TryValidateExternalImageUrl(imageUrl, out var validationReason)) + { + throw new InvalidOperationException($"Blocked image URL: {validationReason}"); + } + + var currentUri = new Uri(imageUrl); + HttpResponseMessage? response = null; + + for (var redirectCount = 0; redirectCount <= MaxImageRedirects; redirectCount++) + { + if (!TryValidateExternalImageUri(currentUri, out var uriValidationReason)) + { + response?.Dispose(); + throw new InvalidOperationException($"Blocked image URL: {uriValidationReason}"); + } + + if (!await TryValidateResolvedExternalImageUriAsync(currentUri)) + { + response?.Dispose(); + throw new InvalidOperationException("Blocked image URL: DNS resolved to private or loopback address"); + } + + response = await _httpClient.GetAsync(currentUri, HttpCompletionOption.ResponseHeadersRead); + + if (IsRedirectStatusCode(response.StatusCode)) + { + var location = response.Headers.Location; + response.Dispose(); + if (location == null) + { + throw new InvalidOperationException("Blocked image redirect without a Location header"); + } + + var nextUri = location.IsAbsoluteUri ? location : new Uri(currentUri, location); + if (!TryValidateExternalImageUri(nextUri, out var redirectValidationReason)) + { + throw new InvalidOperationException($"Blocked image redirect: {redirectValidationReason}"); + } + + currentUri = nextUri; + continue; + } + + var finalUri = response.RequestMessage?.RequestUri ?? currentUri; + if (!TryValidateExternalImageUri(finalUri, out var finalValidationReason)) + { + response.Dispose(); + throw new InvalidOperationException($"Blocked final image URL: {finalValidationReason}"); + } + + if (!await TryValidateResolvedExternalImageUriAsync(finalUri)) + { + response.Dispose(); + throw new InvalidOperationException("Blocked final image URL: DNS resolved to private or loopback address"); + } + + return (response, finalUri); + } + + response?.Dispose(); + throw new HttpRequestException($"Too many redirects while downloading image (>{MaxImageRedirects})."); + } + + public static bool TryValidateExternalImageUrl(string imageUrl, out string reason) + { + reason = string.Empty; + if (!Uri.TryCreate(imageUrl, UriKind.Absolute, out var uri)) + { + reason = "Invalid URL format"; + return false; + } + + return TryValidateExternalImageUri(uri, out reason); + } + + private static bool TryValidateExternalImageUri(Uri uri, out string reason) + { + reason = string.Empty; + + if (!uri.IsAbsoluteUri) + { + reason = "URL must be absolute"; + return false; + } + + if (!string.Equals(uri.Scheme, "http", StringComparison.OrdinalIgnoreCase) + && !string.Equals(uri.Scheme, "https", StringComparison.OrdinalIgnoreCase)) + { + reason = $"Unsupported URL scheme '{uri.Scheme}'"; + return false; + } + + if (!string.IsNullOrWhiteSpace(uri.UserInfo)) + { + reason = "URLs with embedded credentials are not allowed"; + return false; + } + + var host = uri.Host ?? string.Empty; + if (string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) + || host.EndsWith(".local", StringComparison.OrdinalIgnoreCase) + || host.EndsWith(".localhost", StringComparison.OrdinalIgnoreCase)) + { + reason = "Localhost or local-network hostnames are not allowed"; + return false; + } + + if (IPAddress.TryParse(host, out var ip) && IsPrivateOrLoopback(ip)) + { + reason = "Private or loopback IP targets are not allowed"; + return false; + } + + return true; + } + + private async Task TryValidateResolvedExternalImageUriAsync(Uri uri) + { + try + { + var host = uri.Host; + if (string.IsNullOrWhiteSpace(host)) + { + return false; + } + + if (IPAddress.TryParse(host, out var ip)) + { + return !IsPrivateOrLoopback(ip); + } + + var addresses = await Dns.GetHostAddressesAsync(host); + if (addresses == null || addresses.Length == 0) + { + _logger.LogWarning("Blocked image URL because DNS resolution returned no addresses: {Host}", LogRedaction.SanitizeText(host)); + return false; + } + + var privateOrLoopback = addresses.FirstOrDefault(IsPrivateOrLoopback); + if (privateOrLoopback != null) + { + _logger.LogWarning( + "Blocked image URL because DNS resolved to private/loopback address. Host={Host}, Address={Address}", + LogRedaction.SanitizeText(host), + privateOrLoopback); + return false; + } + + return true; + } + catch (SocketException ex) + { + _logger.LogWarning(ex, "Blocked image URL because DNS resolution failed for host {Host}", LogRedaction.SanitizeText(uri.Host)); + return false; + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning(ex, "Blocked image URL due to unexpected DNS validation error for host {Host}", LogRedaction.SanitizeText(uri.Host)); + return false; + } + } + + private static bool IsRedirectStatusCode(HttpStatusCode statusCode) + { + return statusCode == HttpStatusCode.Moved + || statusCode == HttpStatusCode.Redirect + || statusCode == HttpStatusCode.RedirectMethod + || statusCode == HttpStatusCode.TemporaryRedirect + || (int)statusCode == 308; + } + + private static bool IsPrivateOrLoopback(IPAddress ip) + { + if (ip.IsIPv4MappedToIPv6) + { + ip = ip.MapToIPv4(); + } + + if (IPAddress.IsLoopback(ip)) return true; + + if (ip.AddressFamily == AddressFamily.InterNetwork) + { + var b = ip.GetAddressBytes(); + if (b[0] == 10) return true; + if (b[0] == 127) return true; + if (b[0] == 169 && b[1] == 254) return true; + if (b[0] == 172 && b[1] >= 16 && b[1] <= 31) return true; + if (b[0] == 192 && b[1] == 168) return true; + return false; + } + + if (ip.AddressFamily == AddressFamily.InterNetworkV6) + { + if (ip.IsIPv6LinkLocal || ip.IsIPv6SiteLocal) return true; + var b = ip.GetAddressBytes(); + if (b.Length > 0 && (b[0] & 0xFE) == 0xFC) return true; + return false; + } + + return false; + } + } +} From d228f7e5bd192cf83cbabf16b27f2f64d8a0f89c Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Sat, 13 Jun 2026 19:08:41 -0400 Subject: [PATCH 34/84] refactor: extract adapter import path helpers - Move SABnzbd import path checks into a focused helper - Move Transmission content path and source-file helpers out of adapter workflows --- .../Adapters/SabnzbdAdapter.cs | 8 ++-- .../Adapters/SabnzbdImportPathResolver.cs | 33 +++++++++++++++ .../Adapters/TransmissionAdapter.cs | 13 +++--- .../TransmissionImportPathResolver.cs | 42 +++++++++++++++++++ 4 files changed, 84 insertions(+), 12 deletions(-) create mode 100644 listenarr.infrastructure/Adapters/SabnzbdImportPathResolver.cs create mode 100644 listenarr.infrastructure/Adapters/TransmissionImportPathResolver.cs diff --git a/listenarr.infrastructure/Adapters/SabnzbdAdapter.cs b/listenarr.infrastructure/Adapters/SabnzbdAdapter.cs index 47b99e075..2cca9db95 100644 --- a/listenarr.infrastructure/Adapters/SabnzbdAdapter.cs +++ b/listenarr.infrastructure/Adapters/SabnzbdAdapter.cs @@ -558,7 +558,7 @@ public async Task GetImportItemAsync( if (!string.IsNullOrEmpty(result.OutputPath)) { var localPath = result.OutputPath; - if (!string.IsNullOrEmpty(localPath) && (File.Exists(localPath) || Directory.Exists(localPath))) + if (SabnzbdImportPathResolver.IsExistingLocalPath(localPath)) { result.OutputPath = localPath; return result; @@ -612,7 +612,7 @@ public async Task GetImportItemAsync( if (!string.Equals(nzoId, item.DownloadId, StringComparison.OrdinalIgnoreCase)) continue; // Extract storage path - var storage = slot.TryGetProperty("storage", out var storageProp) ? storageProp.GetString() : null; + var storage = SabnzbdImportPathResolver.GetStoragePath(slot); if (string.IsNullOrEmpty(storage)) { _logger.LogWarning("No storage path found for SABnzbd download {NzoId}", item.DownloadId); @@ -660,7 +660,7 @@ public async Task GetImportItemAsync( if (!string.IsNullOrEmpty(result.ContentPath)) { var localPath = result.ContentPath; - if (!string.IsNullOrEmpty(localPath) && (File.Exists(localPath) || Directory.Exists(localPath))) + if (SabnzbdImportPathResolver.IsExistingLocalPath(localPath)) { result.ContentPath = localPath; return result; @@ -714,7 +714,7 @@ public async Task GetImportItemAsync( if (nzoId != queueItem.Id) continue; // Extract storage path - var storage = slot.TryGetProperty("storage", out var storageProp) ? storageProp.GetString() : null; + var storage = SabnzbdImportPathResolver.GetStoragePath(slot); if (string.IsNullOrEmpty(storage)) { _logger.LogWarning("No storage path found for SABnzbd download {NzoId}", queueItem.Id); diff --git a/listenarr.infrastructure/Adapters/SabnzbdImportPathResolver.cs b/listenarr.infrastructure/Adapters/SabnzbdImportPathResolver.cs new file mode 100644 index 000000000..a140df9fc --- /dev/null +++ b/listenarr.infrastructure/Adapters/SabnzbdImportPathResolver.cs @@ -0,0 +1,33 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace Listenarr.Infrastructure.Adapters +{ + internal static class SabnzbdImportPathResolver + { + public static bool IsExistingLocalPath(string? path) + { + return !string.IsNullOrEmpty(path) && (File.Exists(path) || Directory.Exists(path)); + } + + public static string? GetStoragePath(System.Text.Json.JsonElement slot) + { + return slot.TryGetProperty("storage", out var storageProp) ? storageProp.GetString() : null; + } + } +} diff --git a/listenarr.infrastructure/Adapters/TransmissionAdapter.cs b/listenarr.infrastructure/Adapters/TransmissionAdapter.cs index 7fc6037aa..3f004bc86 100644 --- a/listenarr.infrastructure/Adapters/TransmissionAdapter.cs +++ b/listenarr.infrastructure/Adapters/TransmissionAdapter.cs @@ -357,7 +357,7 @@ public async Task GetImportItemAsync( if (!string.IsNullOrEmpty(result.OutputPath)) { var localPath = result.OutputPath; - if (!string.IsNullOrEmpty(localPath) && (File.Exists(localPath) || Directory.Exists(localPath))) + if (TransmissionImportPathResolver.IsExistingLocalPath(localPath)) { result.OutputPath = localPath; return result; @@ -404,7 +404,7 @@ public async Task GetImportItemAsync( } // Transmission stores files as: downloadDir/name. - var contentPath = FileUtils.CombineWithOptionalBase(downloadDir, name); + var contentPath = TransmissionImportPathResolver.BuildContentPath(downloadDir, name)!; // Apply path mapping // FIXME: Path mapping should be the responsability of the download processors @@ -445,7 +445,7 @@ public async Task GetImportItemAsync( if (!string.IsNullOrEmpty(result.ContentPath)) { var localPath = result.ContentPath; - if (!string.IsNullOrEmpty(localPath) && (File.Exists(localPath) || Directory.Exists(localPath))) + if (TransmissionImportPathResolver.IsExistingLocalPath(localPath)) { result.ContentPath = localPath; resolvedExistingContentPath = localPath; @@ -492,9 +492,7 @@ public async Task GetImportItemAsync( } // Transmission stores files as: downloadDir/name - var contentPath = !string.IsNullOrWhiteSpace(downloadDir) && !string.IsNullOrWhiteSpace(name) - ? FileUtils.CombineWithOptionalBase(downloadDir, name) - : resolvedExistingContentPath; + var contentPath = TransmissionImportPathResolver.BuildContentPath(downloadDir, name, resolvedExistingContentPath); string? localContentPath = resolvedExistingContentPath; if (!string.IsNullOrWhiteSpace(contentPath)) { @@ -504,8 +502,7 @@ public async Task GetImportItemAsync( if (torrent.TryGetProperty("files", out var filesElement)) { - var sourceFiles = TorrentClientPathMapper.BuildTransmissionSourceFiles(downloadDir, filesElement); - result.SourceFiles = [.. sourceFiles.Where(path => !string.IsNullOrWhiteSpace(path))]; + result.SourceFiles = TransmissionImportPathResolver.BuildSourceFiles(downloadDir, filesElement); } _logger.LogDebug( diff --git a/listenarr.infrastructure/Adapters/TransmissionImportPathResolver.cs b/listenarr.infrastructure/Adapters/TransmissionImportPathResolver.cs new file mode 100644 index 000000000..c57ff67ce --- /dev/null +++ b/listenarr.infrastructure/Adapters/TransmissionImportPathResolver.cs @@ -0,0 +1,42 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +using System.Text.Json; +using Listenarr.Domain.Common; + +namespace Listenarr.Infrastructure.Adapters +{ + internal static class TransmissionImportPathResolver + { + public static bool IsExistingLocalPath(string? path) + { + return !string.IsNullOrEmpty(path) && (File.Exists(path) || Directory.Exists(path)); + } + + public static string? BuildContentPath(string? downloadDir, string? name, string? fallbackPath = null) + { + return !string.IsNullOrWhiteSpace(downloadDir) && !string.IsNullOrWhiteSpace(name) + ? FileUtils.CombineWithOptionalBase(downloadDir, name) + : fallbackPath; + } + + public static List BuildSourceFiles(string? downloadDir, JsonElement filesElement) + { + return [.. TorrentClientPathMapper.BuildTransmissionSourceFiles(downloadDir, filesElement).Where(path => !string.IsNullOrWhiteSpace(path))]; + } + } +} From c0f5067851f362b6eccbd58246070741622f231f Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Sat, 13 Jun 2026 19:31:21 -0400 Subject: [PATCH 35/84] refactor: extract ffprobe metadata mapper - Move ffprobe JSON-to-metadata translation into a focused mapper - Keep process execution and exception handling in FfmpegService --- .../Ffmpeg/FfmpegService.cs | 83 +----------- .../Ffmpeg/FfprobeMetadataMapper.cs | 127 ++++++++++++++++++ 2 files changed, 128 insertions(+), 82 deletions(-) create mode 100644 listenarr.infrastructure/Ffmpeg/FfprobeMetadataMapper.cs diff --git a/listenarr.infrastructure/Ffmpeg/FfmpegService.cs b/listenarr.infrastructure/Ffmpeg/FfmpegService.cs index fde22ad82..d200c72bf 100644 --- a/listenarr.infrastructure/Ffmpeg/FfmpegService.cs +++ b/listenarr.infrastructure/Ffmpeg/FfmpegService.cs @@ -16,7 +16,6 @@ * along with this program. If not, see . */ using System.Security.Cryptography; -using SharpCompress.Archives; using SharpCompress.Common; using SharpCompress.Readers; using System.Runtime.InteropServices; @@ -711,87 +710,7 @@ public async Task RunFfprobeAsync(string filePath) throw new FfmpegException($"Error running ffprobe for {sanitizedFilePath}", ex); } - var metadata = new AudioMetadata(); - - // Try to get format info - if (ffprobeData.TryGetProperty("format", out var fmt)) - { - if (fmt.TryGetProperty("duration", out var durEl) - && durEl.ValueKind == JsonValueKind.String - && double.TryParse(durEl.GetString(), out var dur)) - { - metadata.Duration = TimeSpan.FromSeconds(dur); - } - if (fmt.TryGetProperty("format_name", out var fmtName) && fmtName.ValueKind == JsonValueKind.String) - { - var rawFmt = fmtName.GetString() ?? string.Empty; - var primary = rawFmt.Split(',')[0]; - - var ext = Path.GetExtension(filePath)?.TrimStart('.')?.ToLowerInvariant(); - if (!string.IsNullOrEmpty(ext)) - { - if (ext == "m4b") - { - metadata.Format = ext.ToUpperInvariant(); - metadata.Container = ext.ToUpperInvariant(); - } - else - { - metadata.Format = primary.ToUpperInvariant(); - metadata.Container = primary.ToUpperInvariant(); - } - } - else - { - metadata.Format = primary.ToUpperInvariant(); - metadata.Container = primary.ToUpperInvariant(); - } - } - if (fmt.TryGetProperty("bit_rate", out var br) && br.ValueKind == JsonValueKind.String && int.TryParse(br.GetString(), out var bitRate)) - { - metadata.BitRate = bitRate; - } - if (fmt.TryGetProperty("tags", out var formatTags) && formatTags.ValueKind == JsonValueKind.Object) - { - FfprobeTagMetadataMapper.Apply(metadata, formatTags); - } - } - - // Streams: look for audio stream for sample rate, channels - if (ffprobeData.TryGetProperty("streams", out var streams) && streams.ValueKind == JsonValueKind.Array) - { - foreach (var s in streams - .EnumerateArray() - .Where(s => s.TryGetProperty("codec_type", out var codecType) && codecType.GetString() == "audio")) - { - if (s.TryGetProperty("sample_rate", out var sr) && sr.ValueKind == JsonValueKind.String && int.TryParse(sr.GetString(), out var sampleRate)) - { - metadata.SampleRate = sampleRate; - } - if (s.TryGetProperty("channels", out var ch) && ch.ValueKind == JsonValueKind.Number) - { - metadata.Channels = ch.GetInt32(); - } - if (s.TryGetProperty("bit_rate", out var sbr) && sbr.ValueKind == JsonValueKind.String && int.TryParse(sbr.GetString(), out var sbit)) - { - metadata.BitRate = metadata.BitRate == 0 ? sbit : metadata.BitRate; - } - if (s.TryGetProperty("codec_name", out var codecName) && codecName.ValueKind == JsonValueKind.String) - { - metadata.Codec = codecName.GetString(); - } - if (s.TryGetProperty("tags", out var streamTags) && streamTags.ValueKind == JsonValueKind.Object) - { - FfprobeTagMetadataMapper.Apply(metadata, streamTags); - } - break; - } - } - - var fileName = Path.GetFileNameWithoutExtension(filePath); - if (string.IsNullOrEmpty(metadata.Title)) metadata.Title = fileName; - if (string.IsNullOrEmpty(metadata.Format)) metadata.Format = Path.GetExtension(filePath).TrimStart('.').ToUpper(); - if (string.IsNullOrEmpty(metadata.Container)) metadata.Container = Path.GetExtension(filePath).TrimStart('.').ToUpper(); + var metadata = FfprobeMetadataMapper.Map(ffprobeData, filePath); _logger.LogInformation("Extracted ffprobe metadata from file: {File}", LogRedaction.SanitizeText(filePath)); _logger.LogDebug("Parsed metadata: Duration={Duration} seconds, Format={Format}, Bitrate={Bitrate}, SampleRate={SampleRate}, Channels={Channels}", metadata.Duration.TotalSeconds, metadata.Format, metadata.BitRate, metadata.SampleRate, metadata.Channels); diff --git a/listenarr.infrastructure/Ffmpeg/FfprobeMetadataMapper.cs b/listenarr.infrastructure/Ffmpeg/FfprobeMetadataMapper.cs new file mode 100644 index 000000000..766c985c5 --- /dev/null +++ b/listenarr.infrastructure/Ffmpeg/FfprobeMetadataMapper.cs @@ -0,0 +1,127 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +using System.Text.Json; +using Listenarr.Domain.Models; + +namespace Listenarr.Infrastructure.Ffmpeg +{ + internal static class FfprobeMetadataMapper + { + public static AudioMetadata Map(JsonElement ffprobeData, string filePath) + { + var metadata = new AudioMetadata(); + + if (ffprobeData.TryGetProperty("format", out var fmt)) + { + ApplyFormat(metadata, fmt, filePath); + } + + if (ffprobeData.TryGetProperty("streams", out var streams) && streams.ValueKind == JsonValueKind.Array) + { + ApplyAudioStream(metadata, streams); + } + + var fileName = Path.GetFileNameWithoutExtension(filePath); + if (string.IsNullOrEmpty(metadata.Title)) metadata.Title = fileName; + if (string.IsNullOrEmpty(metadata.Format)) metadata.Format = Path.GetExtension(filePath).TrimStart('.').ToUpper(); + if (string.IsNullOrEmpty(metadata.Container)) metadata.Container = Path.GetExtension(filePath).TrimStart('.').ToUpper(); + + return metadata; + } + + private static void ApplyFormat(AudioMetadata metadata, JsonElement fmt, string filePath) + { + if (fmt.TryGetProperty("duration", out var durEl) + && durEl.ValueKind == JsonValueKind.String + && double.TryParse(durEl.GetString(), out var dur)) + { + metadata.Duration = TimeSpan.FromSeconds(dur); + } + + if (fmt.TryGetProperty("format_name", out var fmtName) && fmtName.ValueKind == JsonValueKind.String) + { + ApplyFormatName(metadata, fmtName.GetString() ?? string.Empty, filePath); + } + + if (fmt.TryGetProperty("bit_rate", out var br) && br.ValueKind == JsonValueKind.String && int.TryParse(br.GetString(), out var bitRate)) + { + metadata.BitRate = bitRate; + } + + if (fmt.TryGetProperty("tags", out var formatTags) && formatTags.ValueKind == JsonValueKind.Object) + { + FfprobeTagMetadataMapper.Apply(metadata, formatTags); + } + } + + private static void ApplyFormatName(AudioMetadata metadata, string rawFormat, string filePath) + { + var primary = rawFormat.Split(',')[0]; + var ext = Path.GetExtension(filePath)?.TrimStart('.')?.ToLowerInvariant(); + + if (!string.IsNullOrEmpty(ext)) + { + if (ext == "m4b") + { + metadata.Format = ext.ToUpperInvariant(); + metadata.Container = ext.ToUpperInvariant(); + } + else + { + metadata.Format = primary.ToUpperInvariant(); + metadata.Container = primary.ToUpperInvariant(); + } + } + else + { + metadata.Format = primary.ToUpperInvariant(); + metadata.Container = primary.ToUpperInvariant(); + } + } + + private static void ApplyAudioStream(AudioMetadata metadata, JsonElement streams) + { + foreach (var s in streams + .EnumerateArray() + .Where(s => s.TryGetProperty("codec_type", out var codecType) && codecType.GetString() == "audio")) + { + if (s.TryGetProperty("sample_rate", out var sr) && sr.ValueKind == JsonValueKind.String && int.TryParse(sr.GetString(), out var sampleRate)) + { + metadata.SampleRate = sampleRate; + } + if (s.TryGetProperty("channels", out var ch) && ch.ValueKind == JsonValueKind.Number) + { + metadata.Channels = ch.GetInt32(); + } + if (s.TryGetProperty("bit_rate", out var sbr) && sbr.ValueKind == JsonValueKind.String && int.TryParse(sbr.GetString(), out var sbit)) + { + metadata.BitRate = metadata.BitRate == 0 ? sbit : metadata.BitRate; + } + if (s.TryGetProperty("codec_name", out var codecName) && codecName.ValueKind == JsonValueKind.String) + { + metadata.Codec = codecName.GetString(); + } + if (s.TryGetProperty("tags", out var streamTags) && streamTags.ValueKind == JsonValueKind.Object) + { + FfprobeTagMetadataMapper.Apply(metadata, streamTags); + } + break; + } + } + } +} From 814c3e37d95314f81340f63b62883273564b0936 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Sat, 13 Jun 2026 19:38:24 -0400 Subject: [PATCH 36/84] refactor: extract download record factory - Move queued download record construction out of DownloadService - Keep duplicate checks and client handoff behavior unchanged --- .../Downloads/DownloadRecordFactory.cs | 58 +++++++++++++++++++ .../Downloads/DownloadService.cs | 31 ++-------- 2 files changed, 64 insertions(+), 25 deletions(-) create mode 100644 listenarr.application/Downloads/DownloadRecordFactory.cs diff --git a/listenarr.application/Downloads/DownloadRecordFactory.cs b/listenarr.application/Downloads/DownloadRecordFactory.cs new file mode 100644 index 000000000..169c7de2f --- /dev/null +++ b/listenarr.application/Downloads/DownloadRecordFactory.cs @@ -0,0 +1,58 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +using Listenarr.Domain.Models; + +namespace Listenarr.Application.Downloads +{ + internal static class DownloadRecordFactory + { + public static Download CreateQueuedDownload( + string downloadId, + SearchResult searchResult, + DownloadClientConfiguration downloadClient, + string downloadClientId, + int? audiobookId) + { + return new Download + { + Id = downloadId, + AudiobookId = audiobookId, + Title = searchResult.Title ?? string.Empty, + Artist = searchResult.Artist ?? string.Empty, + Album = searchResult.Album ?? string.Empty, + Language = searchResult.Language, + OriginalUrl = !string.IsNullOrEmpty(searchResult.MagnetLink) ? searchResult.MagnetLink : (searchResult.TorrentUrl ?? searchResult.NzbUrl ?? string.Empty), + Progress = 0, + TotalSize = searchResult.Size, + DownloadedSize = 0, + DownloadPath = downloadClient.DownloadPath ?? string.Empty, + FinalPath = string.Empty, + StartedAt = DateTime.UtcNow, + DownloadClientId = downloadClientId, + Metadata = new Dictionary + { + ["Source"] = searchResult.Source ?? string.Empty, + ["Seeders"] = searchResult.Seeders ?? 0, + ["Quality"] = searchResult.Quality ?? string.Empty, + ["Language"] = searchResult.Language ?? string.Empty, + ["DownloadType"] = searchResult.DownloadType + } + }; + } + } +} diff --git a/listenarr.application/Downloads/DownloadService.cs b/listenarr.application/Downloads/DownloadService.cs index bec8f8518..a36e003fa 100644 --- a/listenarr.application/Downloads/DownloadService.cs +++ b/listenarr.application/Downloads/DownloadService.cs @@ -359,31 +359,12 @@ public async Task SendToDownloadClientAsync(SearchResult searchResult, s } // Create Download record in database before sending to client - var download = new Download - { - Id = downloadId, - AudiobookId = audiobookId, - Title = searchResult.Title ?? string.Empty, - Artist = searchResult.Artist ?? string.Empty, - Album = searchResult.Album ?? string.Empty, - Language = searchResult.Language, - OriginalUrl = !string.IsNullOrEmpty(searchResult.MagnetLink) ? searchResult.MagnetLink : (searchResult.TorrentUrl ?? searchResult.NzbUrl ?? string.Empty), - Progress = 0, - TotalSize = searchResult.Size, - DownloadedSize = 0, - DownloadPath = downloadClient.DownloadPath ?? string.Empty, - FinalPath = string.Empty, - StartedAt = DateTime.UtcNow, - DownloadClientId = downloadClientIdForModel, - Metadata = new Dictionary - { - ["Source"] = searchResult.Source ?? string.Empty, - ["Seeders"] = searchResult.Seeders ?? 0, - ["Quality"] = searchResult.Quality ?? string.Empty, - ["Language"] = searchResult.Language ?? string.Empty, - ["DownloadType"] = searchResult.DownloadType - } - }; + var download = DownloadRecordFactory.CreateQueuedDownload( + downloadId, + searchResult, + downloadClient, + downloadClientIdForModel, + audiobookId); await downloadRepository.AddAsync(download); logger.LogInformation("Created download record in database: {DownloadId} for '{Title}'", downloadId, searchResult.Title); From cecac18f966716fbbc1aa31712538b0f30cb4972 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Sat, 13 Jun 2026 19:43:04 -0400 Subject: [PATCH 37/84] fix: restore ffmpeg archive import - Restore SharpCompress archive extension import required by WriteToFile - Fix infrastructure build after ffprobe mapper extraction --- listenarr.infrastructure/Ffmpeg/FfmpegService.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/listenarr.infrastructure/Ffmpeg/FfmpegService.cs b/listenarr.infrastructure/Ffmpeg/FfmpegService.cs index d200c72bf..2c1ce8fb4 100644 --- a/listenarr.infrastructure/Ffmpeg/FfmpegService.cs +++ b/listenarr.infrastructure/Ffmpeg/FfmpegService.cs @@ -16,6 +16,7 @@ * along with this program. If not, see . */ using System.Security.Cryptography; +using SharpCompress.Archives; using SharpCompress.Common; using SharpCompress.Readers; using System.Runtime.InteropServices; From 0940a0100e487e21ba3952a0d8a756c65d57931d Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Sat, 13 Jun 2026 19:58:38 -0400 Subject: [PATCH 38/84] refactor: extract download and adapter helpers - Move download duplicate, metadata, and notification helpers out of DownloadService - Extract MyAnonamouse contributor parsing - Split NZBGet, SABnzbd, and qBittorrent planning/connection helpers --- .../DownloadClientMetadataUpdater.cs | 39 ++++ .../Downloads/DownloadDuplicateGuard.cs | 47 ++++ .../DownloadNotificationPayloadBuilder.cs | 77 +++++++ .../Downloads/DownloadService.cs | 85 +------- .../Search/MyAnonamouseContributorParser.cs | 42 ++++ .../Search/MyAnonamouseResponseParser.cs | 44 ++-- .../Adapters/NzbgetAdapter.cs | 104 +-------- .../Adapters/NzbgetRequestPlanner.cs | 111 ++++++++++ .../Adapters/QbittorrentAdapter.cs | 147 +------------ .../Adapters/QbittorrentConnectionTester.cs | 203 ++++++++++++++++++ .../Adapters/SabnzbdAdapter.cs | 34 +-- .../Adapters/SabnzbdAddRequestPlanner.cs | 69 ++++++ 12 files changed, 622 insertions(+), 380 deletions(-) create mode 100644 listenarr.application/Downloads/DownloadClientMetadataUpdater.cs create mode 100644 listenarr.application/Downloads/DownloadDuplicateGuard.cs create mode 100644 listenarr.application/Downloads/DownloadNotificationPayloadBuilder.cs create mode 100644 listenarr.application/Search/MyAnonamouseContributorParser.cs create mode 100644 listenarr.infrastructure/Adapters/NzbgetRequestPlanner.cs create mode 100644 listenarr.infrastructure/Adapters/QbittorrentConnectionTester.cs create mode 100644 listenarr.infrastructure/Adapters/SabnzbdAddRequestPlanner.cs diff --git a/listenarr.application/Downloads/DownloadClientMetadataUpdater.cs b/listenarr.application/Downloads/DownloadClientMetadataUpdater.cs new file mode 100644 index 000000000..4549bd1fa --- /dev/null +++ b/listenarr.application/Downloads/DownloadClientMetadataUpdater.cs @@ -0,0 +1,39 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +using Listenarr.Domain.Models; + +namespace Listenarr.Application.Downloads +{ + internal static class DownloadClientMetadataUpdater + { + public static void ApplyClientSpecificId( + Download download, + DownloadClientConfiguration downloadClient, + string clientSpecificId) + { + download.Metadata ??= new Dictionary(); + download.Metadata["ClientDownloadId"] = clientSpecificId; + + if (downloadClient.Type.Equals("qbittorrent", StringComparison.OrdinalIgnoreCase) || + downloadClient.Type.Equals("transmission", StringComparison.OrdinalIgnoreCase)) + { + download.Metadata["TorrentHash"] = clientSpecificId; + } + } + } +} diff --git a/listenarr.application/Downloads/DownloadDuplicateGuard.cs b/listenarr.application/Downloads/DownloadDuplicateGuard.cs new file mode 100644 index 000000000..14e962d24 --- /dev/null +++ b/listenarr.application/Downloads/DownloadDuplicateGuard.cs @@ -0,0 +1,47 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +using Listenarr.Application.Interfaces; +using Listenarr.Application.Interfaces.Repositories; +using Listenarr.Domain.Models; + +namespace Listenarr.Application.Downloads +{ + internal static class DownloadDuplicateGuard + { + public static async Task HasActiveDownloadAsync( + int audiobookId, + IConfigurationService configurationService, + IDownloadRepository downloadRepository) + { + var downloadClients = await configurationService.GetDownloadClientConfigurationsAsync(); + var enabledClientIds = downloadClients + .Where(c => c.IsEnabled && !string.IsNullOrWhiteSpace(c.Id)) + .Select(c => c.Id) + .ToHashSet(); + + var allDownloads = await downloadRepository.GetAllAsync(); + return allDownloads + .Any(d => d.AudiobookId == audiobookId && + (d.Status == DownloadStatus.Queued || + d.Status == DownloadStatus.Downloading || + d.Status == DownloadStatus.ImportPending) && + (d.DownloadClientId == "DDL" || + (!string.IsNullOrEmpty(d.DownloadClientId) && enabledClientIds.Contains(d.DownloadClientId)))); + } + } +} diff --git a/listenarr.application/Downloads/DownloadNotificationPayloadBuilder.cs b/listenarr.application/Downloads/DownloadNotificationPayloadBuilder.cs new file mode 100644 index 000000000..3ebbd78c7 --- /dev/null +++ b/listenarr.application/Downloads/DownloadNotificationPayloadBuilder.cs @@ -0,0 +1,77 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +using Listenarr.Application.Interfaces.Repositories; +using Listenarr.Domain.Models; + +namespace Listenarr.Application.Downloads +{ + internal static class DownloadNotificationPayloadBuilder + { + public static async Task BuildBookDownloadingPayloadAsync( + IAudiobookRepository audiobookRepository, + int? audiobookId, + string downloadId, + SearchResult searchResult, + DownloadClientConfiguration downloadClient) + { + if (audiobookId.HasValue) + { + var audiobook = await audiobookRepository.GetByIdAsync(audiobookId.Value); + return audiobook != null + ? new + { + title = audiobook.Title, + authors = audiobook.Authors, + asin = audiobook.Asin, + publisher = audiobook.Publisher, + year = audiobook.PublishYear?.ToString(), + publishedDate = audiobook.PublishYear?.ToString(), + imageUrl = audiobook.ImageUrl, + narrators = audiobook.Narrators, + description = audiobook.Description, + downloadId = downloadId, + source = searchResult.Source ?? "Unknown Source", + downloadClient = downloadClient.Name ?? "Unknown Client", + size = searchResult.Size + } + : new + { + downloadId = downloadId, + title = searchResult.Title ?? "Unknown Title", + artist = searchResult.Artist ?? "Unknown Artist", + album = searchResult.Album ?? "Unknown Album", + size = searchResult.Size, + source = searchResult.Source ?? "Unknown Source", + downloadClient = downloadClient.Name ?? "Unknown Client", + audiobookId = audiobookId + }; + } + + return new + { + downloadId = downloadId, + title = searchResult.Title ?? "Unknown Title", + artist = searchResult.Artist ?? "Unknown Artist", + album = searchResult.Album ?? "Unknown Album", + size = searchResult.Size, + source = searchResult.Source ?? "Unknown Source", + downloadClient = downloadClient.Name ?? "Unknown Client" + }; + } + } +} diff --git a/listenarr.application/Downloads/DownloadService.cs b/listenarr.application/Downloads/DownloadService.cs index a36e003fa..c65d36762 100644 --- a/listenarr.application/Downloads/DownloadService.cs +++ b/listenarr.application/Downloads/DownloadService.cs @@ -329,20 +329,10 @@ public async Task SendToDownloadClientAsync(SearchResult searchResult, s { try { - var downloadClients = await configurationService.GetDownloadClientConfigurationsAsync(); - var enabledClientIds = downloadClients - .Where(c => c.IsEnabled && !string.IsNullOrWhiteSpace(c.Id)) - .Select(c => c.Id) - .ToHashSet(); - - var allDownloads = await downloadRepository.GetAllAsync(); - var existingActive = allDownloads - .Any(d => d.AudiobookId == audiobookIdValue && - (d.Status == DownloadStatus.Queued || - d.Status == DownloadStatus.Downloading || - d.Status == DownloadStatus.ImportPending) && - (d.DownloadClientId == "DDL" || - (!string.IsNullOrEmpty(d.DownloadClientId) && enabledClientIds.Contains(d.DownloadClientId)))); + var existingActive = await DownloadDuplicateGuard.HasActiveDownloadAsync( + audiobookIdValue, + configurationService, + downloadRepository); if (existingActive) { @@ -406,72 +396,19 @@ await downloadHistoryService.RecordGrabbedAsync( var downloadToUpdate = await downloadRepository.FindAsync(downloadId); if (downloadToUpdate != null) { - if (downloadToUpdate.Metadata == null) - downloadToUpdate.Metadata = new Dictionary(); - - downloadToUpdate.Metadata["ClientDownloadId"] = clientSpecificId; - - if (downloadClient.Type.Equals("qbittorrent", StringComparison.OrdinalIgnoreCase) || - downloadClient.Type.Equals("transmission", StringComparison.OrdinalIgnoreCase)) - { - downloadToUpdate.Metadata["TorrentHash"] = clientSpecificId; - } - + DownloadClientMetadataUpdater.ApplyClientSpecificId(downloadToUpdate, downloadClient, clientSpecificId); await UpdateAsync(downloadToUpdate); logger.LogInformation("Updated download {DownloadId} with client-specific ID: {ClientId}", downloadId, clientSpecificId); } } var settings = await configurationService.GetApplicationSettingsAsync(); - - // Fetch audiobook data if available for better notification content - object notificationData; - if (audiobookId.HasValue) - { - var audiobook = await audiobookRepository.GetByIdAsync(audiobookId.Value); - notificationData = audiobook != null - ? new - { - title = audiobook.Title, - authors = audiobook.Authors, - asin = audiobook.Asin, - publisher = audiobook.Publisher, - year = audiobook.PublishYear?.ToString(), - publishedDate = audiobook.PublishYear?.ToString(), - imageUrl = audiobook.ImageUrl, - narrators = audiobook.Narrators, - description = audiobook.Description, - downloadId = downloadId, - source = searchResult.Source ?? "Unknown Source", - downloadClient = downloadClient.Name ?? "Unknown Client", - size = searchResult.Size - } - : new - { - downloadId = downloadId, - title = searchResult.Title ?? "Unknown Title", - artist = searchResult.Artist ?? "Unknown Artist", - album = searchResult.Album ?? "Unknown Album", - size = searchResult.Size, - source = searchResult.Source ?? "Unknown Source", - downloadClient = downloadClient.Name ?? "Unknown Client", - audiobookId = audiobookId - }; - } - else - { - // No audiobook ID, use search result data - notificationData = new - { - downloadId = downloadId, - title = searchResult.Title ?? "Unknown Title", - artist = searchResult.Artist ?? "Unknown Artist", - album = searchResult.Album ?? "Unknown Album", - size = searchResult.Size, - source = searchResult.Source ?? "Unknown Source", - downloadClient = downloadClient.Name ?? "Unknown Client" - }; - } + var notificationData = await DownloadNotificationPayloadBuilder.BuildBookDownloadingPayloadAsync( + audiobookRepository, + audiobookId, + downloadId, + searchResult, + downloadClient); await notificationService.SendNotificationAsync("book-downloading", notificationData, settings.WebhookUrl, settings.EnabledNotificationTriggers); diff --git a/listenarr.application/Search/MyAnonamouseContributorParser.cs b/listenarr.application/Search/MyAnonamouseContributorParser.cs new file mode 100644 index 000000000..e5d82fca2 --- /dev/null +++ b/listenarr.application/Search/MyAnonamouseContributorParser.cs @@ -0,0 +1,42 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +using System.Text.Json; + +namespace Listenarr.Application.Search +{ + internal static class MyAnonamouseContributorParser + { + public static string? ParseContributorList(string? contributorJson) + { + if (string.IsNullOrEmpty(contributorJson)) + { + return null; + } + + using var contributorDoc = JsonDocument.Parse(contributorJson); + var contributors = new List(); + foreach (var prop in contributorDoc.RootElement.EnumerateObject()) + { + contributors.Add(prop.Value.GetString() ?? ""); + } + + var joined = string.Join(", ", contributors.Where(a => !string.IsNullOrEmpty(a))); + return string.IsNullOrEmpty(joined) ? null : joined; + } + } +} diff --git a/listenarr.application/Search/MyAnonamouseResponseParser.cs b/listenarr.application/Search/MyAnonamouseResponseParser.cs index 68f32b593..456a889b2 100644 --- a/listenarr.application/Search/MyAnonamouseResponseParser.cs +++ b/listenarr.application/Search/MyAnonamouseResponseParser.cs @@ -322,23 +322,13 @@ public static List Parse(string jsonResponse, Indexer index string? author = null; if (item.TryGetProperty("author_info", out var authorInfo)) { - var authorJson = authorInfo.GetString(); - if (!string.IsNullOrEmpty(authorJson)) + try { - try - { - var authorDoc = JsonDocument.Parse(authorJson); - var authors = new List(); - foreach (var prop in authorDoc.RootElement.EnumerateObject()) - { - authors.Add(prop.Value.GetString() ?? ""); - } - author = string.Join(", ", authors.Where(a => !string.IsNullOrEmpty(a))); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - logger.LogWarning(ex, "Failed to parse author JSON for search result"); - } + author = MyAnonamouseContributorParser.ParseContributorList(authorInfo.GetString()); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + logger.LogWarning(ex, "Failed to parse author JSON for search result"); } } @@ -346,23 +336,13 @@ public static List Parse(string jsonResponse, Indexer index string? narrator = null; if (item.TryGetProperty("narrator_info", out var narratorInfo)) { - var narratorJson = narratorInfo.GetString(); - if (!string.IsNullOrEmpty(narratorJson)) + try { - try - { - var narratorDoc = JsonDocument.Parse(narratorJson); - var narrators = new List(); - foreach (var prop in narratorDoc.RootElement.EnumerateObject()) - { - narrators.Add(prop.Value.GetString() ?? ""); - } - narrator = string.Join(", ", narrators.Where(n => !string.IsNullOrEmpty(n))); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - logger.LogWarning(ex, "Failed to parse narrator JSON for search result"); - } + narrator = MyAnonamouseContributorParser.ParseContributorList(narratorInfo.GetString()); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + logger.LogWarning(ex, "Failed to parse narrator JSON for search result"); } } diff --git a/listenarr.infrastructure/Adapters/NzbgetAdapter.cs b/listenarr.infrastructure/Adapters/NzbgetAdapter.cs index d6f4f8029..c4e79f746 100644 --- a/listenarr.infrastructure/Adapters/NzbgetAdapter.cs +++ b/listenarr.infrastructure/Adapters/NzbgetAdapter.cs @@ -15,7 +15,6 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -using System.Globalization; using System.Net; using System.Net.Http.Headers; using System.Text; @@ -35,8 +34,6 @@ public class NzbgetAdapter : IDownloadClientAdapter public string ClientType => "nzbget"; public DownloadProtocol Protocol => DownloadProtocol.Usenet; - private static readonly HashSet InvalidFileNameChars = new(Path.GetInvalidFileNameChars()); - private readonly IHttpClientFactory _httpClientFactory; private readonly INzbUrlResolver _nzbUrlResolver; private readonly ILogger _logger; @@ -139,13 +136,13 @@ private static bool IsVersion25OrNewer(string version) string? indexerApiKey, CancellationToken ct) { - var category = ResolveCategory(client); - var priority = ResolvePriority(client); + var category = NzbgetRequestPlanner.ResolveCategory(client); + var priority = NzbgetRequestPlanner.ResolvePriority(client); var droneId = Guid.NewGuid().ToString().Replace("-", string.Empty); // Download NZB content var nzbBytes = await _nzbDownloader.DownloadAsync(nzbUrl, indexerApiKey, ct); - var nzbFileName = BuildNzbFileName(result); + var nzbFileName = NzbgetRequestPlanner.BuildNzbFileName(result); var uploadUrl = DownloadClientUriBuilder.BuildUri(client, "/api/v2/nzb"); @@ -212,14 +209,14 @@ private static bool IsVersion25OrNewer(string version) string? indexerApiKey, CancellationToken ct) { - var category = ResolveCategory(client); - var priority = ResolvePriority(client); + var category = NzbgetRequestPlanner.ResolveCategory(client); + var priority = NzbgetRequestPlanner.ResolvePriority(client); var droneId = Guid.NewGuid().ToString().Replace("-", string.Empty); // Download and base64-encode the NZB content var nzbBytes = await _nzbDownloader.DownloadAsync(nzbUrl, indexerApiKey, ct); var nzbContentBase64 = Convert.ToBase64String(nzbBytes); - var nzbFileName = BuildNzbFileName(result); + var nzbFileName = NzbgetRequestPlanner.BuildNzbFileName(result); // PPParameters as array of structs (key-value pairs) var ppParams = new[] @@ -273,7 +270,7 @@ public async Task RemoveAsync(DownloadClientConfiguration client, string i if (string.IsNullOrWhiteSpace(id)) throw new ArgumentNullException(nameof(id)); // First try to parse as numeric NZBID (for queue removal) - var numericId = TryParseId(id); + var numericId = NzbgetRequestPlanner.TryParseId(id); // If it's not a numeric ID, it might be a droneId (GUID from Listenarr) // Try to find it in history first @@ -624,83 +621,6 @@ public async Task GetImportItemAsync( } } - private string ResolveCategory(DownloadClientConfiguration client) - { - if (client.Settings != null && client.Settings.TryGetValue("category", out var categoryObj)) - { - var category = categoryObj?.ToString(); - if (!string.IsNullOrWhiteSpace(category)) - { - return category; - } - } - - return string.Empty; - } - - private int ResolvePriority(DownloadClientConfiguration client) - { - if (client.Settings != null && client.Settings.TryGetValue("recentPriority", out var priorityObj)) - { - var priority = priorityObj?.ToString(); - if (!string.IsNullOrWhiteSpace(priority) && !string.Equals(priority, "default", StringComparison.OrdinalIgnoreCase)) - { - return priority.ToLowerInvariant() switch - { - "force" => 100, - "high" => 50, - "normal" => 0, - "low" => -50, - _ => 0 - }; - } - } - - return 0; - } - - private static string BuildNzbFileName(SearchResult result) - { - if (result == null) - { - return "listenarr-download.nzb"; - } - - var rawName = result.Title; - if (string.IsNullOrWhiteSpace(rawName)) - { - if (!string.IsNullOrWhiteSpace(result.NzbUrl) && Uri.TryCreate(result.NzbUrl, UriKind.Absolute, out var nzbUri)) - { - rawName = Path.GetFileName(nzbUri.AbsolutePath); - } - - if (string.IsNullOrWhiteSpace(rawName)) - { - rawName = result.Id; - } - } - - if (string.IsNullOrWhiteSpace(rawName)) - { - rawName = "listenarr-download"; - } - - // NZBGet v25.4 is very strict about filenames - remove ALL special characters except basic ones - var sanitizedChars = rawName.Where(c => char.IsLetterOrDigit(c) || c == ' ' || c == '-' || c == '_' || c == '.').ToArray(); - var sanitized = new string(sanitizedChars).Trim(); - if (string.IsNullOrWhiteSpace(sanitized)) - { - sanitized = "listenarr-download"; - } - - if (!sanitized.EndsWith(".nzb", StringComparison.OrdinalIgnoreCase)) - { - sanitized = sanitized + ".nzb"; - } - - return sanitized; - } - private static AuthenticationHeaderValue? BuildAuthHeader(DownloadClientConfiguration client) { if (string.IsNullOrWhiteSpace(client.Username)) @@ -712,16 +632,6 @@ private static string BuildNzbFileName(SearchResult result) return new AuthenticationHeaderValue("Basic", credentials); } - private static int? TryParseId(string id) - { - if (int.TryParse(id, NumberStyles.Integer, CultureInfo.InvariantCulture, out var numericId)) - { - return numericId; - } - - return null; - } - /// /// Resolves the actual import item for a completed download. /// Queries NZBGet history for FinalDir or DestDir. diff --git a/listenarr.infrastructure/Adapters/NzbgetRequestPlanner.cs b/listenarr.infrastructure/Adapters/NzbgetRequestPlanner.cs new file mode 100644 index 000000000..08f75aa58 --- /dev/null +++ b/listenarr.infrastructure/Adapters/NzbgetRequestPlanner.cs @@ -0,0 +1,111 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +using System.Globalization; +using Listenarr.Domain.Models; + +namespace Listenarr.Infrastructure.Adapters +{ + internal static class NzbgetRequestPlanner + { + public static string ResolveCategory(DownloadClientConfiguration client) + { + if (client.Settings != null && client.Settings.TryGetValue("category", out var categoryObj)) + { + var category = categoryObj?.ToString(); + if (!string.IsNullOrWhiteSpace(category)) + { + return category; + } + } + + return string.Empty; + } + + public static int ResolvePriority(DownloadClientConfiguration client) + { + if (client.Settings != null && client.Settings.TryGetValue("recentPriority", out var priorityObj)) + { + var priority = priorityObj?.ToString(); + if (!string.IsNullOrWhiteSpace(priority) && !string.Equals(priority, "default", StringComparison.OrdinalIgnoreCase)) + { + return priority.ToLowerInvariant() switch + { + "force" => 100, + "high" => 50, + "normal" => 0, + "low" => -50, + _ => 0 + }; + } + } + + return 0; + } + + public static string BuildNzbFileName(SearchResult result) + { + if (result == null) + { + return "listenarr-download.nzb"; + } + + var rawName = result.Title; + if (string.IsNullOrWhiteSpace(rawName)) + { + if (!string.IsNullOrWhiteSpace(result.NzbUrl) && Uri.TryCreate(result.NzbUrl, UriKind.Absolute, out var nzbUri)) + { + rawName = Path.GetFileName(nzbUri.AbsolutePath); + } + + if (string.IsNullOrWhiteSpace(rawName)) + { + rawName = result.Id; + } + } + + if (string.IsNullOrWhiteSpace(rawName)) + { + rawName = "listenarr-download"; + } + + var sanitizedChars = rawName.Where(c => char.IsLetterOrDigit(c) || c == ' ' || c == '-' || c == '_' || c == '.').ToArray(); + var sanitized = new string(sanitizedChars).Trim(); + if (string.IsNullOrWhiteSpace(sanitized)) + { + sanitized = "listenarr-download"; + } + + if (!sanitized.EndsWith(".nzb", StringComparison.OrdinalIgnoreCase)) + { + sanitized += ".nzb"; + } + + return sanitized; + } + + public static int? TryParseId(string id) + { + if (int.TryParse(id, NumberStyles.Integer, CultureInfo.InvariantCulture, out var numericId)) + { + return numericId; + } + + return null; + } + } +} diff --git a/listenarr.infrastructure/Adapters/QbittorrentAdapter.cs b/listenarr.infrastructure/Adapters/QbittorrentAdapter.cs index 00077bc18..d249d526c 100644 --- a/listenarr.infrastructure/Adapters/QbittorrentAdapter.cs +++ b/listenarr.infrastructure/Adapters/QbittorrentAdapter.cs @@ -42,6 +42,7 @@ public class QbittorrentAdapter : IDownloadClientAdapter private readonly ITorrentFileDownloader _torrentFileDownloader; private readonly QbittorrentTorrentAddPlanner _torrentAddPlanner; private readonly QbittorrentAuthSession _authSession; + private readonly QbittorrentConnectionTester _connectionTester; public QbittorrentAdapter(IHttpClientFactory httpFactory, ITorrentFileDownloader torrentFileDownloader, ILogger logger) { @@ -50,154 +51,12 @@ public QbittorrentAdapter(IHttpClientFactory httpFactory, ITorrentFileDownloader _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _torrentAddPlanner = new QbittorrentTorrentAddPlanner(_torrentFileDownloader, _logger); _authSession = new QbittorrentAuthSession(_logger); + _connectionTester = new QbittorrentConnectionTester(_httpClientFactory, _logger, ClientType); } public async Task<(bool Success, string Message)> TestConnectionAsync(DownloadClientConfiguration client, CancellationToken ct = default) { - try - { - var baseUrl = DownloadClientUriBuilder.BuildAuthority(client); - - using var http = _httpClientFactory.CreateClient(ClientType); - using var resp = await http.GetAsync($"{baseUrl}/api/v2/app/version", ct); - if (resp.IsSuccessStatusCode) - return (true, "Successfully connected to qBittorrent."); - - // If we get Forbidden and credentials are provided, try to authenticate and retry - if (resp.StatusCode == HttpStatusCode.Forbidden && !string.IsNullOrEmpty(client.Username)) - { - try - { - // Helper to POST login with optional User-Agent header - async Task PostLoginWithAgent(string userAgent) - { - var content = new FormUrlEncodedContent(new[] - { - new KeyValuePair("username", client.Username ?? string.Empty), - new KeyValuePair("password", client.Password ?? string.Empty) - }); - - using var req = new HttpRequestMessage(HttpMethod.Post, $"{baseUrl}/api/v2/auth/login") { Content = content }; - if (!string.IsNullOrEmpty(userAgent)) req.Headers.UserAgent.ParseAdd(userAgent); - req.Headers.Referrer = new Uri(baseUrl + "/"); - return await http.SendAsync(req, ct); - } - - // Try a minimal UA first, then a browser-like UA if Forbidden - var loginResp = await PostLoginWithAgent("Listenarr/1.0"); - if (!loginResp.IsSuccessStatusCode && loginResp.StatusCode == HttpStatusCode.Forbidden) - { - _logger.LogDebug("qBittorrent TestConnection: initial login returned Forbidden, retrying with browser UA for client {ClientId}", LogRedaction.SanitizeText(client.Id)); - loginResp.Dispose(); - loginResp = await PostLoginWithAgent("Mozilla/5.0 (compatible; Listenarr)"); - } - using (loginResp) - { - if (loginResp.IsSuccessStatusCode) - { - // Try to detect cookies via Set-Cookie header when using factory clients - try - { - if (loginResp.Headers.TryGetValues("Set-Cookie", out var cookieHeaders)) - { - _logger.LogDebug("qBittorrent TestConnection: login returned Set-Cookie header for client {ClientId}", LogRedaction.SanitizeText(client.Id)); - } - else - { - _logger.LogDebug("qBittorrent TestConnection: login succeeded but no Set-Cookie header present for client {ClientId}", LogRedaction.SanitizeText(client.Id)); - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogDebug(ex, "qBittorrent TestConnection: unable to inspect login response headers for client {ClientId}", LogRedaction.SanitizeText(client.Id)); - } - - // Retry using the same client first (this covers unit tests which - // simulate stateful behavior on the mocked handler). If the retry - // fails and we created a factory client that doesn't handle cookies, - // fall back to a local cookie-enabled client attempt. - using var retry = await http.GetAsync($"{baseUrl}/api/v2/app/version", ct); - if (retry.IsSuccessStatusCode) - return (true, "Successfully connected to qBittorrent."); - - _logger.LogWarning("qBittorrent TestConnection: authenticated but subsequent request returned {Status} for client {ClientId}", retry.StatusCode, LogRedaction.SanitizeText(client.Id)); - - // Try a cookie-enabled HttpClient as a last resort - try - { - var cookieJar2 = new CookieContainer(); - var handler2 = new HttpClientHandler - { - CookieContainer = cookieJar2, - UseCookies = true, - AutomaticDecompression = DecompressionMethods.All - }; - - using var local = new HttpClient(handler2) { Timeout = TimeSpan.FromSeconds(30) }; - using var localLoginContent = new FormUrlEncodedContent( - [ - new KeyValuePair("username", client.Username), - new KeyValuePair("password", client.Password) - ]); - - using var localLogin = await local.PostAsync($"{baseUrl}/api/v2/auth/login", localLoginContent, ct); - if (localLogin.IsSuccessStatusCode) - { - using var final = await local.GetAsync($"{baseUrl}/api/v2/app/version", ct); - if (final.IsSuccessStatusCode) - return (true, "Successfully connected to qBittorrent."); - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogDebug(ex, "qBittorrent TestConnection: fallback local login attempt failed for client {ClientId}", LogRedaction.SanitizeText(client.Id)); - } - } - else - { - var body = string.Empty; - try { body = await loginResp.Content.ReadAsStringAsync(ct); } - catch (Exception caughtEx_1) when (caughtEx_1 is not OperationCanceledException && caughtEx_1 is not OutOfMemoryException && caughtEx_1 is not StackOverflowException) - { - _logger.LogDebug("Suppressed non-fatal exception in catch block."); - } - var redacted = LogRedaction.RedactText(body, LogRedaction.GetSensitiveValuesFromEnvironment().Concat(new[] { client.Password ?? string.Empty })); - _logger.LogWarning("qBittorrent TestConnection: login failed with status {Status} for client {ClientId} - {Body}", loginResp.StatusCode, LogRedaction.SanitizeText(client.Id), redacted); - return (false, "qBittorrent: Connection to download client successful but could not authenticate. Please check username/password."); - } - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogDebug(ex, "qBittorrent TestConnection login attempt failed"); - return (false, "Connection failed: login attempt failed."); - } - } - - // Provide clearer, user-friendly messages for common HTTP statuses - if (resp.StatusCode == HttpStatusCode.Forbidden || resp.StatusCode == HttpStatusCode.Unauthorized) - { - if (string.IsNullOrEmpty(client.Username)) - return (false, "Forbidden: Authentication required."); - - return (false, "Authentication Failed. Check your username and/or password."); - } - - if (resp.StatusCode == HttpStatusCode.NotFound) - { - return (false, "Could not connect to the host and/or port."); - } - - return (false, $"qBittorrent: network error ({resp.StatusCode})"); - } - catch (TaskCanceledException) - { - return (false, "Connection timed out."); - } - catch (Exception exception) when (exception is not (OperationCanceledException or OutOfMemoryException or StackOverflowException)) - { - return (false, "Connection failed."); - } + return await _connectionTester.TestConnectionAsync(client, ct); } public async Task AddAsync(DownloadClientConfiguration client, SearchResult result, CancellationToken ct = default) diff --git a/listenarr.infrastructure/Adapters/QbittorrentConnectionTester.cs b/listenarr.infrastructure/Adapters/QbittorrentConnectionTester.cs new file mode 100644 index 000000000..30407ab4f --- /dev/null +++ b/listenarr.infrastructure/Adapters/QbittorrentConnectionTester.cs @@ -0,0 +1,203 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +using System.Net; +using Listenarr.Application.Security; +using Listenarr.Domain.Models; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Infrastructure.Adapters +{ + internal sealed class QbittorrentConnectionTester + { + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger _logger; + private readonly string _clientType; + + public QbittorrentConnectionTester(IHttpClientFactory httpClientFactory, ILogger logger, string clientType) + { + _httpClientFactory = httpClientFactory; + _logger = logger; + _clientType = clientType; + } + + public async Task<(bool Success, string Message)> TestConnectionAsync(DownloadClientConfiguration client, CancellationToken ct = default) + { + try + { + var baseUrl = DownloadClientUriBuilder.BuildAuthority(client); + + using var http = _httpClientFactory.CreateClient(_clientType); + using var resp = await http.GetAsync($"{baseUrl}/api/v2/app/version", ct); + if (resp.IsSuccessStatusCode) + return (true, "Successfully connected to qBittorrent."); + + if (resp.StatusCode == HttpStatusCode.Forbidden && !string.IsNullOrEmpty(client.Username)) + { + return await TryAuthenticatedConnectionAsync(client, baseUrl, http, ct); + } + + if (resp.StatusCode == HttpStatusCode.Forbidden || resp.StatusCode == HttpStatusCode.Unauthorized) + { + if (string.IsNullOrEmpty(client.Username)) + return (false, "Forbidden: Authentication required."); + + return (false, "Authentication Failed. Check your username and/or password."); + } + + if (resp.StatusCode == HttpStatusCode.NotFound) + { + return (false, "Could not connect to the host and/or port."); + } + + return (false, $"qBittorrent: network error ({resp.StatusCode})"); + } + catch (TaskCanceledException) + { + return (false, "Connection timed out."); + } + catch (Exception exception) when (exception is not (OperationCanceledException or OutOfMemoryException or StackOverflowException)) + { + return (false, "Connection failed."); + } + } + + private async Task<(bool Success, string Message)> TryAuthenticatedConnectionAsync( + DownloadClientConfiguration client, + string baseUrl, + HttpClient http, + CancellationToken ct) + { + try + { + async Task PostLoginWithAgent(string userAgent) + { + var content = new FormUrlEncodedContent(new[] + { + new KeyValuePair("username", client.Username ?? string.Empty), + new KeyValuePair("password", client.Password ?? string.Empty) + }); + + using var req = new HttpRequestMessage(HttpMethod.Post, $"{baseUrl}/api/v2/auth/login") { Content = content }; + if (!string.IsNullOrEmpty(userAgent)) req.Headers.UserAgent.ParseAdd(userAgent); + req.Headers.Referrer = new Uri(baseUrl + "/"); + return await http.SendAsync(req, ct); + } + + var loginResp = await PostLoginWithAgent("Listenarr/1.0"); + if (!loginResp.IsSuccessStatusCode && loginResp.StatusCode == HttpStatusCode.Forbidden) + { + _logger.LogDebug("qBittorrent TestConnection: initial login returned Forbidden, retrying with browser UA for client {ClientId}", LogRedaction.SanitizeText(client.Id)); + loginResp.Dispose(); + loginResp = await PostLoginWithAgent("Mozilla/5.0 (compatible; Listenarr)"); + } + + using (loginResp) + { + if (loginResp.IsSuccessStatusCode) + { + return await VerifyAuthenticatedConnectionAsync(client, baseUrl, http, loginResp, ct); + } + + var body = string.Empty; + try { body = await loginResp.Content.ReadAsStringAsync(ct); } + catch (Exception caughtEx_1) when (caughtEx_1 is not OperationCanceledException && caughtEx_1 is not OutOfMemoryException && caughtEx_1 is not StackOverflowException) + { + _logger.LogDebug("Suppressed non-fatal exception in catch block."); + } + var redacted = LogRedaction.RedactText(body, LogRedaction.GetSensitiveValuesFromEnvironment().Concat(new[] { client.Password ?? string.Empty })); + _logger.LogWarning("qBittorrent TestConnection: login failed with status {Status} for client {ClientId} - {Body}", loginResp.StatusCode, LogRedaction.SanitizeText(client.Id), redacted); + return (false, "qBittorrent: Connection to download client successful but could not authenticate. Please check username/password."); + } + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogDebug(ex, "qBittorrent TestConnection login attempt failed"); + return (false, "Connection failed: login attempt failed."); + } + } + + private async Task<(bool Success, string Message)> VerifyAuthenticatedConnectionAsync( + DownloadClientConfiguration client, + string baseUrl, + HttpClient http, + HttpResponseMessage loginResp, + CancellationToken ct) + { + try + { + if (loginResp.Headers.TryGetValues("Set-Cookie", out _)) + { + _logger.LogDebug("qBittorrent TestConnection: login returned Set-Cookie header for client {ClientId}", LogRedaction.SanitizeText(client.Id)); + } + else + { + _logger.LogDebug("qBittorrent TestConnection: login succeeded but no Set-Cookie header present for client {ClientId}", LogRedaction.SanitizeText(client.Id)); + } + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogDebug(ex, "qBittorrent TestConnection: unable to inspect login response headers for client {ClientId}", LogRedaction.SanitizeText(client.Id)); + } + + using var retry = await http.GetAsync($"{baseUrl}/api/v2/app/version", ct); + if (retry.IsSuccessStatusCode) + return (true, "Successfully connected to qBittorrent."); + + _logger.LogWarning("qBittorrent TestConnection: authenticated but subsequent request returned {Status} for client {ClientId}", retry.StatusCode, LogRedaction.SanitizeText(client.Id)); + return await TryCookieEnabledConnectionAsync(client, baseUrl, ct); + } + + private async Task<(bool Success, string Message)> TryCookieEnabledConnectionAsync( + DownloadClientConfiguration client, + string baseUrl, + CancellationToken ct) + { + try + { + var cookieJar2 = new CookieContainer(); + var handler2 = new HttpClientHandler + { + CookieContainer = cookieJar2, + UseCookies = true, + AutomaticDecompression = DecompressionMethods.All + }; + + using var local = new HttpClient(handler2) { Timeout = TimeSpan.FromSeconds(30) }; + using var localLoginContent = new FormUrlEncodedContent( + [ + new KeyValuePair("username", client.Username), + new KeyValuePair("password", client.Password) + ]); + + using var localLogin = await local.PostAsync($"{baseUrl}/api/v2/auth/login", localLoginContent, ct); + if (localLogin.IsSuccessStatusCode) + { + using var final = await local.GetAsync($"{baseUrl}/api/v2/app/version", ct); + if (final.IsSuccessStatusCode) + return (true, "Successfully connected to qBittorrent."); + } + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogDebug(ex, "qBittorrent TestConnection: fallback local login attempt failed for client {ClientId}", LogRedaction.SanitizeText(client.Id)); + } + + return (false, "Authentication Failed. Check your username and/or password."); + } + } +} diff --git a/listenarr.infrastructure/Adapters/SabnzbdAdapter.cs b/listenarr.infrastructure/Adapters/SabnzbdAdapter.cs index 2cca9db95..4a540f011 100644 --- a/listenarr.infrastructure/Adapters/SabnzbdAdapter.cs +++ b/listenarr.infrastructure/Adapters/SabnzbdAdapter.cs @@ -123,39 +123,7 @@ public SabnzbdAdapter( var sensitiveValues = _requestBuilder.BuildSensitiveValues(requestContext, indexerApiKey); - var queryParams = new Dictionary - { - { "mode", "addurl" }, - { "name", nzbUrl }, - { "output", "json" }, - { "nzbname", result.Title } - }; - - if (client.Settings != null && client.Settings.TryGetValue("recentPriority", out var priorityObj)) - { - var priority = priorityObj?.ToString(); - if (!string.IsNullOrEmpty(priority) && priority != "default") - { - queryParams["priority"] = priority switch - { - "force" => "2", - "high" => "1", - "normal" => "0", - "low" => "-1", - _ => "0" - }; - } - } - - var category = "audiobooks"; - if (client.Settings != null && client.Settings.TryGetValue("category", out var categoryObj)) - { - var configuredCategory = categoryObj?.ToString(); - if (!string.IsNullOrEmpty(configuredCategory)) - category = configuredCategory; - } - queryParams["cat"] = category; - + var queryParams = SabnzbdAddRequestPlanner.BuildQueryParams(client, result, nzbUrl); var requestUrl = _requestBuilder.BuildUrl(requestContext, queryParams); _logger.LogDebug("SABnzbd request URL: {Url}", LogRedaction.RedactText(requestUrl, sensitiveValues)); diff --git a/listenarr.infrastructure/Adapters/SabnzbdAddRequestPlanner.cs b/listenarr.infrastructure/Adapters/SabnzbdAddRequestPlanner.cs new file mode 100644 index 000000000..592e8cf00 --- /dev/null +++ b/listenarr.infrastructure/Adapters/SabnzbdAddRequestPlanner.cs @@ -0,0 +1,69 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +using Listenarr.Domain.Models; + +namespace Listenarr.Infrastructure.Adapters +{ + internal static class SabnzbdAddRequestPlanner + { + public static Dictionary BuildQueryParams(DownloadClientConfiguration client, SearchResult result, string nzbUrl) + { + var queryParams = new Dictionary + { + { "mode", "addurl" }, + { "name", nzbUrl }, + { "output", "json" }, + { "nzbname", result.Title } + }; + + if (client.Settings != null && client.Settings.TryGetValue("recentPriority", out var priorityObj)) + { + var priority = priorityObj?.ToString(); + if (!string.IsNullOrEmpty(priority) && priority != "default") + { + queryParams["priority"] = priority switch + { + "force" => "2", + "high" => "1", + "normal" => "0", + "low" => "-1", + _ => "0" + }; + } + } + + queryParams["cat"] = ResolveCategory(client); + return queryParams; + } + + private static string ResolveCategory(DownloadClientConfiguration client) + { + var category = "audiobooks"; + if (client.Settings != null && client.Settings.TryGetValue("category", out var categoryObj)) + { + var configuredCategory = categoryObj?.ToString(); + if (!string.IsNullOrEmpty(configuredCategory)) + { + category = configuredCategory; + } + } + + return category; + } + } +} From 3c0d7746d6063e9a7b138d51dc07060de2f1180a Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Sat, 13 Jun 2026 20:20:21 -0400 Subject: [PATCH 39/84] refactor: extract image cache helpers - Move cached image path validation out of ImagesController - Extract image cache storage lookup, bounded content reads, and refresh backup workflow - Preserve routes, placeholder behavior, and cache layout --- .../Controllers/ImageCachedPathValidator.cs | 133 +++++++++++ listenarr.api/Controllers/ImagesController.cs | 97 +------- .../Cache/ImageCacheContentReader.cs | 50 ++++ .../Cache/ImageCacheRefreshWorkflow.cs | 72 ++++++ .../Cache/ImageCacheService.cs | 217 ++++-------------- .../Cache/ImageCacheStorageLookup.cs | 128 +++++++++++ 6 files changed, 432 insertions(+), 265 deletions(-) create mode 100644 listenarr.api/Controllers/ImageCachedPathValidator.cs create mode 100644 listenarr.infrastructure/Cache/ImageCacheContentReader.cs create mode 100644 listenarr.infrastructure/Cache/ImageCacheRefreshWorkflow.cs create mode 100644 listenarr.infrastructure/Cache/ImageCacheStorageLookup.cs diff --git a/listenarr.api/Controllers/ImageCachedPathValidator.cs b/listenarr.api/Controllers/ImageCachedPathValidator.cs new file mode 100644 index 000000000..b718c6d93 --- /dev/null +++ b/listenarr.api/Controllers/ImageCachedPathValidator.cs @@ -0,0 +1,133 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +using Listenarr.Application.Security; + +namespace Listenarr.Api.Controllers +{ + internal sealed class ImageCachedPathValidator + { + private readonly ImagePathValidator _pathValidator; + private readonly ILogger _logger; + + public ImageCachedPathValidator(ImagePathValidator pathValidator, ILogger logger) + { + _pathValidator = pathValidator; + _logger = logger; + } + + public string? ValidateReturnedPath(string identifier, string? relativePath) + { + if (string.IsNullOrWhiteSpace(relativePath)) + { + return relativePath; + } + + if (Path.IsPathRooted(relativePath)) + { + _logger.LogWarning("Image service returned rooted path for identifier {Identifier}: {Path}", LogRedaction.SanitizeText(identifier), LogRedaction.SanitizeText(relativePath)); + return null; + } + + _logger.LogDebug("ImagesController: initial relativePath for {Identifier}: {RelativePath}", LogRedaction.SanitizeText(identifier), LogRedaction.SanitizeText(relativePath)); + try + { + var candidateFull = Path.GetFullPath(_pathValidator.ResolvePathWithOptionalBase(relativePath)); + + if (!_pathValidator.IsInsidePermittedImageRoot(candidateFull)) + { + _logger.LogWarning("Resolved image path outside permitted directories for identifier {Identifier}: {Path}", LogRedaction.SanitizeText(identifier), LogRedaction.SanitizeText(candidateFull)); + return null; + } + + if (!TryRejectReparsePoint( + identifier, + candidateFull, + "Rejected reparse-point (symlink) image path for identifier {Identifier}: {Path}", + "Failed to inspect candidate image attributes for identifier {Identifier}")) + { + return null; + } + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning(ex, "Failed to validate image path for identifier {Identifier}", LogRedaction.SanitizeText(identifier)); + return null; + } + + return relativePath; + } + + public bool IsValidMovedPath(string identifier, string movedRelativePath) + { + try + { + var movedFull = Path.GetFullPath(_pathValidator.ResolvePathWithOptionalBase(movedRelativePath)); + + if (!_pathValidator.IsInsidePermittedImageRoot(movedFull)) + { + _logger.LogWarning("Moved image path outside permitted directories for identifier {Identifier}: {Path}", LogRedaction.SanitizeText(identifier), LogRedaction.SanitizeText(movedFull)); + return false; + } + + if (!File.Exists(movedFull)) + { + _logger.LogWarning("Moved image file does not exist for identifier {Identifier}: {Path}", LogRedaction.SanitizeText(identifier), LogRedaction.SanitizeText(movedFull)); + return false; + } + + return TryRejectReparsePoint( + identifier, + movedFull, + "Rejected moved reparse-point (symlink) image path for identifier {Identifier}: {Path}", + "Failed to inspect moved image attributes for identifier {Identifier}"); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning(ex, "Failed to validate moved image path for identifier {Identifier}", LogRedaction.SanitizeText(identifier)); + return false; + } + } + + private bool TryRejectReparsePoint( + string identifier, + string fullPath, + string reparsePointLogMessage, + string inspectFailureLogMessage) + { + try + { + if (File.Exists(fullPath)) + { + var attrs = File.GetAttributes(fullPath); + if ((attrs & FileAttributes.ReparsePoint) != 0) + { + _logger.LogWarning(reparsePointLogMessage, LogRedaction.SanitizeText(identifier), LogRedaction.SanitizeText(fullPath)); + return false; + } + } + + return true; + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning(ex, inspectFailureLogMessage, LogRedaction.SanitizeText(identifier)); + return false; + } + } + } +} diff --git a/listenarr.api/Controllers/ImagesController.cs b/listenarr.api/Controllers/ImagesController.cs index 981c7b933..b7e1301fb 100644 --- a/listenarr.api/Controllers/ImagesController.cs +++ b/listenarr.api/Controllers/ImagesController.cs @@ -41,6 +41,7 @@ public class ImagesController : ControllerBase private readonly ImagePlaceholderResolver _placeholderResolver; private readonly ImageResponseBuilder _imageResponseBuilder; private readonly ImagePathValidator _imagePathValidator; + private readonly ImageCachedPathValidator _cachedPathValidator; private readonly string _effectiveContentRootPath; [ActivatorUtilitiesConstructor] @@ -88,6 +89,7 @@ public ImagesController( _effectiveContentRootPath = applicationPathService.ContentRootPath; _imageResponseBuilder = new ImageResponseBuilder(_placeholderResolver, _logger, _effectiveContentRootPath); _imagePathValidator = new ImagePathValidator(_effectiveContentRootPath); + _cachedPathValidator = new ImageCachedPathValidator(_imagePathValidator, _logger); } /// @@ -179,55 +181,7 @@ public async Task GetImage(string identifier) // Sanitize/validate the returned relative path to ensure it points inside // known image directories. Treat any unexpected location as not-found. - if (!string.IsNullOrWhiteSpace(relativePath)) - { - // Defend against services returning absolute paths unexpectedly - if (Path.IsPathRooted(relativePath)) - { - _logger.LogWarning("Image service returned rooted path for identifier {Identifier}: {Path}", LogRedaction.SanitizeText(identifier), LogRedaction.SanitizeText(relativePath)); - relativePath = null; - } - else - { - _logger.LogDebug("ImagesController: initial relativePath for {Identifier}: {RelativePath}", LogRedaction.SanitizeText(identifier), LogRedaction.SanitizeText(relativePath)); - try - { - var candidateFull = Path.GetFullPath(ImageIdentifierHelper.ResolvePathWithOptionalBase(_effectiveContentRootPath, relativePath)); - - if (!IsInsidePermittedImageRoot(candidateFull)) - { - _logger.LogWarning("Resolved image path outside permitted directories for identifier {Identifier}: {Path}", LogRedaction.SanitizeText(identifier), LogRedaction.SanitizeText(candidateFull)); - relativePath = null; - } - else - { - try - { - // Defend against symlink/reparse-point escapes - if (System.IO.File.Exists(candidateFull)) - { - var attrs = System.IO.File.GetAttributes(candidateFull); - if ((attrs & System.IO.FileAttributes.ReparsePoint) != 0) - { - _logger.LogWarning("Rejected reparse-point (symlink) image path for identifier {Identifier}: {Path}", LogRedaction.SanitizeText(identifier), LogRedaction.SanitizeText(candidateFull)); - relativePath = null; - } - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Failed to inspect candidate image attributes for identifier {Identifier}", LogRedaction.SanitizeText(identifier)); - relativePath = null; - } - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Failed to validate image path for identifier {Identifier}", LogRedaction.SanitizeText(identifier)); - relativePath = null; - } - } - } + relativePath = _cachedPathValidator.ValidateReturnedPath(identifier, relativePath); // If we found a temp cached image but the identifier corresponds to an audiobook in the library, // attempt to move it into permanent library storage so library images don't live in /temp. @@ -248,45 +202,9 @@ public async Task GetImage(string identifier) { // Prefer the moved library path when serving the image // Validate moved path as well - try + if (_cachedPathValidator.IsValidMovedPath(identifier, moved)) { - var movedFull = Path.GetFullPath(ImageIdentifierHelper.ResolvePathWithOptionalBase(_effectiveContentRootPath, moved)); - - if (IsInsidePermittedImageRoot(movedFull)) - { - try - { - if (System.IO.File.Exists(movedFull)) - { - var matt = System.IO.File.GetAttributes(movedFull); - if ((matt & System.IO.FileAttributes.ReparsePoint) != 0) - { - _logger.LogWarning("Rejected moved reparse-point (symlink) image path for identifier {Identifier}: {Path}", LogRedaction.SanitizeText(identifier), LogRedaction.SanitizeText(movedFull)); - } - else - { - relativePath = moved; - } - } - else - { - // If file doesn't yet exist, conservatively reject the moved path - _logger.LogWarning("Moved image file does not exist for identifier {Identifier}: {Path}", LogRedaction.SanitizeText(identifier), LogRedaction.SanitizeText(movedFull)); - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Failed to inspect moved image attributes for identifier {Identifier}", LogRedaction.SanitizeText(identifier)); - } - } - else - { - _logger.LogWarning("Moved image path outside permitted directories for identifier {Identifier}: {Path}", LogRedaction.SanitizeText(identifier), LogRedaction.SanitizeText(movedFull)); - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Failed to validate moved image path for identifier {Identifier}", LogRedaction.SanitizeText(identifier)); + relativePath = moved; } } } @@ -945,11 +863,6 @@ void AddCandidateUrl(string? url, string source) } } - private bool IsInsidePermittedImageRoot(string fullPath) - { - return _imagePathValidator.IsInsidePermittedImageRoot(fullPath); - } - private IActionResult CreatePlaceholderResult(string logContext, string? logValue, string notFoundMessage) { return _imageResponseBuilder.CreatePlaceholderResult( diff --git a/listenarr.infrastructure/Cache/ImageCacheContentReader.cs b/listenarr.infrastructure/Cache/ImageCacheContentReader.cs new file mode 100644 index 000000000..b95fbea5e --- /dev/null +++ b/listenarr.infrastructure/Cache/ImageCacheContentReader.cs @@ -0,0 +1,50 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace Listenarr.Infrastructure.Cache +{ + internal static class ImageCacheContentReader + { + public static async Task ReadWithLimitAsync(HttpContent content, long maxBytes) + { + await using var contentStream = await content.ReadAsStreamAsync(); + using var bufferStream = new MemoryStream(); + var buffer = new byte[81920]; + long totalBytes = 0; + + while (true) + { + var read = await contentStream.ReadAsync(buffer.AsMemory(0, buffer.Length)); + if (read == 0) + { + break; + } + + totalBytes += read; + if (totalBytes > maxBytes) + { + throw new InvalidOperationException($"Downloaded image exceeds the {maxBytes} byte limit."); + } + + bufferStream.Write(buffer, 0, read); + } + + return bufferStream.ToArray(); + } + } +} diff --git a/listenarr.infrastructure/Cache/ImageCacheRefreshWorkflow.cs b/listenarr.infrastructure/Cache/ImageCacheRefreshWorkflow.cs new file mode 100644 index 000000000..7a782acb7 --- /dev/null +++ b/listenarr.infrastructure/Cache/ImageCacheRefreshWorkflow.cs @@ -0,0 +1,72 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace Listenarr.Infrastructure.Cache +{ + internal static class ImageCacheRefreshWorkflow + { + public static async Task RefreshWithBackupAsync( + string destinationPath, + string tempPath, + Func> downloadAsync, + Func getRelativePath) + { + string? backupPath = null; + + try + { + if (File.Exists(destinationPath)) + { + backupPath = destinationPath + ".bak"; + File.Copy(destinationPath, backupPath, overwrite: true); + File.Delete(destinationPath); + } + + if (File.Exists(tempPath)) + { + File.Delete(tempPath); + } + + var refreshed = await downloadAsync(); + if (string.IsNullOrWhiteSpace(refreshed) && !string.IsNullOrWhiteSpace(backupPath)) + { + File.Move(backupPath, destinationPath, overwrite: true); + return getRelativePath(destinationPath); + } + + if (!string.IsNullOrWhiteSpace(backupPath) && File.Exists(backupPath)) + { + File.Delete(backupPath); + } + + return null; + } + catch + { + if (!string.IsNullOrWhiteSpace(backupPath) && + File.Exists(backupPath) && + !File.Exists(destinationPath)) + { + File.Move(backupPath, destinationPath, overwrite: true); + } + + throw; + } + } + } +} diff --git a/listenarr.infrastructure/Cache/ImageCacheService.cs b/listenarr.infrastructure/Cache/ImageCacheService.cs index ced307d9f..775cd722a 100644 --- a/listenarr.infrastructure/Cache/ImageCacheService.cs +++ b/listenarr.infrastructure/Cache/ImageCacheService.cs @@ -35,6 +35,7 @@ public class ImageCacheService : IImageCacheService, IDisposable private readonly string _seriesImagePath; private readonly string _contentRootPath; private readonly ImageCachePathResolver _pathResolver; + private readonly ImageCacheStorageLookup _storageLookup; private readonly AsyncKeyedLocker _downloadLocks = new(); public ImageCacheService( @@ -51,6 +52,13 @@ public ImageCacheService( _authorImagePath = applicationPathService.ResolveFromConfig("cache", "images", "authors"); _seriesImagePath = applicationPathService.ResolveFromConfig("cache", "images", "series"); _pathResolver = new ImageCachePathResolver(_contentRootPath); + _storageLookup = new ImageCacheStorageLookup( + _pathResolver, + _logger, + _libraryImagePath, + _authorImagePath, + _seriesImagePath, + _tempCachePath); Directory.CreateDirectory(_tempCachePath); Directory.CreateDirectory(_libraryImagePath); @@ -77,30 +85,30 @@ public ImageCacheService( try { // Check library storage first - var libraryPath = GetImagePath(identifier, _libraryImagePath); - if (File.Exists(libraryPath) && IsValidCachedCoverFile(libraryPath, identifier, "library")) + var libraryPath = _storageLookup.FindLibraryPath(identifier); + if (!string.IsNullOrEmpty(libraryPath)) { _logger.LogInformation("Image already in library storage: {Identifier}", LogRedaction.SanitizeText(identifier)); return GetRelativePath(libraryPath); } // Also check authors storage (author images may be stored separately) - var authorPath = GetImagePath(identifier, _authorImagePath); - if (File.Exists(authorPath) && IsValidCachedCoverFile(authorPath, identifier, "author")) + var authorPath = _storageLookup.FindAuthorPath(identifier); + if (!string.IsNullOrEmpty(authorPath)) { _logger.LogInformation("Image already in author storage: {Identifier}", LogRedaction.SanitizeText(identifier)); return GetRelativePath(authorPath); } - var seriesPath = GetImagePath(identifier, _seriesImagePath); - if (File.Exists(seriesPath) && IsValidCachedCoverFile(seriesPath, identifier, "series")) + var seriesPath = _storageLookup.FindSeriesPath(identifier); + if (!string.IsNullOrEmpty(seriesPath)) { _logger.LogInformation("Image already in series storage: {Identifier}", LogRedaction.SanitizeText(identifier)); return GetRelativePath(seriesPath); } // Check temp cache for a valid (non-placeholder) image - var tempExisting = GetBestTempImagePathIfValid(identifier); + var tempExisting = _storageLookup.FindTempPath(identifier); if (!string.IsNullOrEmpty(tempExisting)) { _logger.LogInformation("Image already cached: {Identifier}", LogRedaction.SanitizeText(identifier)); @@ -120,29 +128,29 @@ public ImageCacheService( using var _ = await _downloadLocks.LockAsync(identifier); // Re-check after acquiring lock - libraryPath = GetImagePath(identifier, _libraryImagePath); - if (File.Exists(libraryPath) && IsValidCachedCoverFile(libraryPath, identifier, "library")) + libraryPath = _storageLookup.FindLibraryPath(identifier); + if (!string.IsNullOrEmpty(libraryPath)) { _logger.LogInformation("Image already in library storage (after wait): {Identifier}", LogRedaction.SanitizeText(identifier)); return GetRelativePath(libraryPath); } // Also check author storage after lock - authorPath = GetImagePath(identifier, _authorImagePath); - if (File.Exists(authorPath) && IsValidCachedCoverFile(authorPath, identifier, "author")) + authorPath = _storageLookup.FindAuthorPath(identifier); + if (!string.IsNullOrEmpty(authorPath)) { _logger.LogInformation("Image already in author storage (after wait): {Identifier}", LogRedaction.SanitizeText(identifier)); return GetRelativePath(authorPath); } - seriesPath = GetImagePath(identifier, _seriesImagePath); - if (File.Exists(seriesPath) && IsValidCachedCoverFile(seriesPath, identifier, "series")) + seriesPath = _storageLookup.FindSeriesPath(identifier); + if (!string.IsNullOrEmpty(seriesPath)) { _logger.LogInformation("Image already in series storage (after wait): {Identifier}", LogRedaction.SanitizeText(identifier)); return GetRelativePath(seriesPath); } - tempExisting = GetBestTempImagePathIfValid(identifier); + tempExisting = _storageLookup.FindTempPath(identifier); if (!string.IsNullOrEmpty(tempExisting)) { _logger.LogInformation("Image already cached (after wait): {Identifier}", LogRedaction.SanitizeText(identifier)); @@ -179,7 +187,7 @@ public ImageCacheService( } // Read bytes first so we can reject tiny placeholder images (for example 1x1). - var imageBytes = await ReadContentWithLimitAsync(response.Content, MaxDownloadedImageBytes); + var imageBytes = await ImageCacheContentReader.ReadWithLimitAsync(response.Content, MaxDownloadedImageBytes); if (ImageCacheContentValidator.IsPlaceholderImage(imageBytes, mediaType, _logger)) { _logger.LogInformation("Skipping placeholder/tiny image for {Identifier} from {Url}", LogRedaction.SanitizeText(identifier), LogRedaction.SanitizeText(imageUrl)); @@ -286,44 +294,14 @@ public ImageCacheService( if (forceRefresh && !string.IsNullOrWhiteSpace(imageUrl)) { - string? backupAuthorPath = null; - - try + var restored = await ImageCacheRefreshWorkflow.RefreshWithBackupAsync( + authorPath, + tempPath, + () => DownloadAndCacheImageAsync(imageUrl, identifier), + GetRelativePath); + if (!string.IsNullOrWhiteSpace(restored)) { - if (File.Exists(authorPath)) - { - backupAuthorPath = authorPath + ".bak"; - File.Copy(authorPath, backupAuthorPath, overwrite: true); - File.Delete(authorPath); - } - - if (File.Exists(tempPath)) - { - File.Delete(tempPath); - } - - var refreshed = await DownloadAndCacheImageAsync(imageUrl, identifier); - if (string.IsNullOrWhiteSpace(refreshed) && !string.IsNullOrWhiteSpace(backupAuthorPath)) - { - File.Move(backupAuthorPath, authorPath, overwrite: true); - return GetRelativePath(authorPath); - } - - if (!string.IsNullOrWhiteSpace(backupAuthorPath) && File.Exists(backupAuthorPath)) - { - File.Delete(backupAuthorPath); - } - } - catch - { - if (!string.IsNullOrWhiteSpace(backupAuthorPath) && - File.Exists(backupAuthorPath) && - !File.Exists(authorPath)) - { - File.Move(backupAuthorPath, authorPath, overwrite: true); - } - - throw; + return restored; } } @@ -392,44 +370,14 @@ public ImageCacheService( if (forceRefresh && !string.IsNullOrWhiteSpace(imageUrl)) { - string? backupSeriesPath = null; - - try + var restored = await ImageCacheRefreshWorkflow.RefreshWithBackupAsync( + seriesPath, + tempPath, + () => DownloadAndCacheImageAsync(imageUrl, identifier), + GetRelativePath); + if (!string.IsNullOrWhiteSpace(restored)) { - if (File.Exists(seriesPath)) - { - backupSeriesPath = seriesPath + ".bak"; - File.Copy(seriesPath, backupSeriesPath, overwrite: true); - File.Delete(seriesPath); - } - - if (File.Exists(tempPath)) - { - File.Delete(tempPath); - } - - var refreshed = await DownloadAndCacheImageAsync(imageUrl, identifier); - if (string.IsNullOrWhiteSpace(refreshed) && !string.IsNullOrWhiteSpace(backupSeriesPath)) - { - File.Move(backupSeriesPath, seriesPath, overwrite: true); - return GetRelativePath(seriesPath); - } - - if (!string.IsNullOrWhiteSpace(backupSeriesPath) && File.Exists(backupSeriesPath)) - { - File.Delete(backupSeriesPath); - } - } - catch - { - if (!string.IsNullOrWhiteSpace(backupSeriesPath) && - File.Exists(backupSeriesPath) && - !File.Exists(seriesPath)) - { - File.Move(backupSeriesPath, seriesPath, overwrite: true); - } - - throw; + return restored; } } @@ -496,48 +444,27 @@ public ImageCacheService( // Check library storage first - var libraryPath = GetImagePath(identifier, _libraryImagePath); - if (File.Exists(libraryPath) && IsValidCachedCoverFile(libraryPath, identifier, "library")) + var libraryPath = _storageLookup.FindLibraryPath(identifier); + if (!string.IsNullOrEmpty(libraryPath)) return Task.FromResult(GetRelativePath(libraryPath)); // Check authors storage next - var authorPath = GetImagePath(identifier, _authorImagePath); - if (File.Exists(authorPath) && IsValidCachedCoverFile(authorPath, identifier, "author")) + var authorPath = _storageLookup.FindAuthorPath(identifier); + if (!string.IsNullOrEmpty(authorPath)) return Task.FromResult(GetRelativePath(authorPath)); - var seriesPath = GetImagePath(identifier, _seriesImagePath); - if (File.Exists(seriesPath) && IsValidCachedCoverFile(seriesPath, identifier, "series")) + var seriesPath = _storageLookup.FindSeriesPath(identifier); + if (!string.IsNullOrEmpty(seriesPath)) return Task.FromResult(GetRelativePath(seriesPath)); // Check temp cache and prefer non-placeholder images - var tempBest = GetBestTempImagePathIfValid(identifier); + var tempBest = _storageLookup.FindTempPath(identifier); if (!string.IsNullOrEmpty(tempBest)) return Task.FromResult(GetRelativePath(tempBest)); return Task.FromResult(null); } - private string? GetBestTempImagePathIfValid(string identifier) - { - var extensions = new[] { ".jpg", ".jpeg", ".png", ".webp", ".gif", ".svg" }; - - foreach (var ext in extensions) - { - var path = _pathResolver.BuildFilePath(identifier, ext, _tempCachePath); - if (!File.Exists(path)) continue; - - // Remove placeholder images (e.g. 1x1) from temp cache so fallback can continue. - if (!IsValidCachedCoverFile(path, identifier, "temp")) - { - continue; - } - - return path; - } - - return null; - } - /// /// Clears all temporary cached images /// @@ -582,62 +509,6 @@ private string GetRelativePath(string fullPath) return _pathResolver.GetRelativePath(fullPath); } - private static async Task ReadContentWithLimitAsync(HttpContent content, long maxBytes) - { - await using var contentStream = await content.ReadAsStreamAsync(); - using var bufferStream = new MemoryStream(); - var buffer = new byte[81920]; - long totalBytes = 0; - - while (true) - { - var read = await contentStream.ReadAsync(buffer.AsMemory(0, buffer.Length)); - if (read == 0) - { - break; - } - - totalBytes += read; - if (totalBytes > maxBytes) - { - throw new InvalidOperationException($"Downloaded image exceeds the {maxBytes} byte limit."); - } - - bufferStream.Write(buffer, 0, read); - } - - return bufferStream.ToArray(); - } - - private bool IsValidCachedCoverFile(string filePath, string identifier, string bucket) - { - try - { - if (!File.Exists(filePath)) return false; - var bytes = File.ReadAllBytes(filePath); - var mediaType = ImageCacheContentValidator.GetMediaTypeFromExtension(Path.GetExtension(filePath)); - if (ImageCacheContentValidator.IsPlaceholderImage(bytes, mediaType, _logger)) - { - _logger.LogInformation("Deleting placeholder/tiny cached image for {Identifier} in {Bucket}: {Path}", LogRedaction.SanitizeText(identifier), bucket, LogRedaction.SanitizeText(filePath)); - try - { - File.Delete(filePath); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogDebug(ex, "Failed deleting invalid cached image for {Identifier} in {Bucket}: {Path}", LogRedaction.SanitizeText(identifier), bucket, LogRedaction.SanitizeText(filePath)); - } - return false; - } - return true; - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Failed validating cached image file for {Identifier}: {Path}", LogRedaction.SanitizeText(identifier), LogRedaction.SanitizeText(filePath)); - return false; - } - } - public void Dispose() { try diff --git a/listenarr.infrastructure/Cache/ImageCacheStorageLookup.cs b/listenarr.infrastructure/Cache/ImageCacheStorageLookup.cs new file mode 100644 index 000000000..2c288e3be --- /dev/null +++ b/listenarr.infrastructure/Cache/ImageCacheStorageLookup.cs @@ -0,0 +1,128 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +using Listenarr.Application.Security; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Infrastructure.Cache +{ + internal sealed class ImageCacheStorageLookup + { + private static readonly string[] ImageExtensions = [".jpg", ".jpeg", ".png", ".webp", ".gif", ".svg"]; + + private readonly ImageCachePathResolver _pathResolver; + private readonly ILogger _logger; + private readonly string _libraryImagePath; + private readonly string _authorImagePath; + private readonly string _seriesImagePath; + private readonly string _tempCachePath; + + public ImageCacheStorageLookup( + ImageCachePathResolver pathResolver, + ILogger logger, + string libraryImagePath, + string authorImagePath, + string seriesImagePath, + string tempCachePath) + { + _pathResolver = pathResolver; + _logger = logger; + _libraryImagePath = libraryImagePath; + _authorImagePath = authorImagePath; + _seriesImagePath = seriesImagePath; + _tempCachePath = tempCachePath; + } + + public string? FindLibraryPath(string identifier) + { + return GetValidPath(identifier, _libraryImagePath, "library"); + } + + public string? FindAuthorPath(string identifier) + { + return GetValidPath(identifier, _authorImagePath, "author"); + } + + public string? FindSeriesPath(string identifier) + { + return GetValidPath(identifier, _seriesImagePath, "series"); + } + + public string? FindTempPath(string identifier) + { + foreach (var ext in ImageExtensions) + { + var path = _pathResolver.BuildFilePath(identifier, ext, _tempCachePath); + if (!File.Exists(path)) continue; + + if (!IsValidCachedCoverFile(path, identifier, "temp")) + { + continue; + } + + return path; + } + + return null; + } + + public string? FindAnyCachedPath(string identifier) + { + return FindLibraryPath(identifier) + ?? FindAuthorPath(identifier) + ?? FindSeriesPath(identifier) + ?? FindTempPath(identifier); + } + + private string? GetValidPath(string identifier, string basePath, string bucket) + { + var path = _pathResolver.GetImagePath(identifier, basePath); + return File.Exists(path) && IsValidCachedCoverFile(path, identifier, bucket) + ? path + : null; + } + + private bool IsValidCachedCoverFile(string filePath, string identifier, string bucket) + { + try + { + if (!File.Exists(filePath)) return false; + var bytes = File.ReadAllBytes(filePath); + var mediaType = ImageCacheContentValidator.GetMediaTypeFromExtension(Path.GetExtension(filePath)); + if (ImageCacheContentValidator.IsPlaceholderImage(bytes, mediaType, _logger)) + { + _logger.LogInformation("Deleting placeholder/tiny cached image for {Identifier} in {Bucket}: {Path}", LogRedaction.SanitizeText(identifier), bucket, LogRedaction.SanitizeText(filePath)); + try + { + File.Delete(filePath); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogDebug(ex, "Failed deleting invalid cached image for {Identifier} in {Bucket}: {Path}", LogRedaction.SanitizeText(identifier), bucket, LogRedaction.SanitizeText(filePath)); + } + return false; + } + return true; + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning(ex, "Failed validating cached image file for {Identifier}: {Path}", LogRedaction.SanitizeText(identifier), LogRedaction.SanitizeText(filePath)); + return false; + } + } + } +} From dda542f91480db2db6b953f4597cd05af788a397 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Sat, 13 Jun 2026 20:28:51 -0400 Subject: [PATCH 40/84] refactor: extract torrent adapter helpers - Centralize qBittorrent cookie client and login form setup - Move Transmission label, id, and torrent identifier parsing into a request planner --- .../Adapters/QbittorrentAdapter.cs | 88 +++---------------- .../Adapters/QbittorrentConnectionTester.cs | 16 +--- .../Adapters/QbittorrentCookieSession.cs | 47 ++++++++++ .../Adapters/TransmissionAdapter.cs | 76 ++-------------- .../Adapters/TransmissionRequestPlanner.cs | 88 +++++++++++++++++++ 5 files changed, 155 insertions(+), 160 deletions(-) create mode 100644 listenarr.infrastructure/Adapters/QbittorrentCookieSession.cs create mode 100644 listenarr.infrastructure/Adapters/TransmissionRequestPlanner.cs diff --git a/listenarr.infrastructure/Adapters/QbittorrentAdapter.cs b/listenarr.infrastructure/Adapters/QbittorrentAdapter.cs index d249d526c..419897aea 100644 --- a/listenarr.infrastructure/Adapters/QbittorrentAdapter.cs +++ b/listenarr.infrastructure/Adapters/QbittorrentAdapter.cs @@ -190,16 +190,10 @@ public async Task MarkItemAsImportedAsync(DownloadClientConfiguration clie var baseUrl = DownloadClientUriBuilder.BuildAuthority(client); try { - var cookieJar = new CookieContainer(); - var handler = new HttpClientHandler { CookieContainer = cookieJar, UseCookies = true, AutomaticDecompression = DecompressionMethods.All }; - using var httpClient = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(30) }; + using var httpClient = QbittorrentCookieSession.CreateClient(); // Authenticate - using var loginData = new FormUrlEncodedContent(new[] - { - new KeyValuePair("username", client.Username ?? string.Empty), - new KeyValuePair("password", client.Password ?? string.Empty) - }); + using var loginData = QbittorrentCookieSession.CreateLoginContent(client); using (await httpClient.PostAsync($"{baseUrl}/api/v2/auth/login", loginData, ct)) { } // Set category @@ -235,16 +229,8 @@ public async Task RemoveAsync(DownloadClientConfiguration client, string i try { - var cookieJar = new CookieContainer(); - var handler = new HttpClientHandler { CookieContainer = cookieJar, UseCookies = true, AutomaticDecompression = DecompressionMethods.All }; - - using var httpClient = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(30) }; - - using var loginData = new FormUrlEncodedContent(new[] - { - new KeyValuePair("username", client.Username ?? string.Empty), - new KeyValuePair("password", client.Password ?? string.Empty) - }); + using var httpClient = QbittorrentCookieSession.CreateClient(); + using var loginData = QbittorrentCookieSession.CreateLoginContent(client); using var loginResp = await httpClient.PostAsync($"{baseUrl}/api/v2/auth/login", loginData, ct); if (!loginResp.IsSuccessStatusCode) @@ -300,21 +286,8 @@ public async Task> GetQueueAsync(DownloadClientConfiguration cli try { - var cookieJar = new CookieContainer(); - var handler = new HttpClientHandler - { - CookieContainer = cookieJar, - UseCookies = true, - AutomaticDecompression = DecompressionMethods.All - }; - - using var httpClient = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(30) }; - - using var loginData = new FormUrlEncodedContent(new[] - { - new KeyValuePair("username", client.Username ?? string.Empty), - new KeyValuePair("password", client.Password ?? string.Empty) - }); + using var httpClient = QbittorrentCookieSession.CreateClient(); + using var loginData = QbittorrentCookieSession.CreateLoginContent(client); using var loginResp = await httpClient.PostAsync($"{baseUrl}/api/v2/auth/login", loginData, ct); if (loginResp.StatusCode == HttpStatusCode.Forbidden) @@ -394,21 +367,8 @@ public async Task> GetItemsAsync(DownloadClientConfigur try { - var cookieJar = new CookieContainer(); - var handler = new HttpClientHandler - { - CookieContainer = cookieJar, - UseCookies = true, - AutomaticDecompression = DecompressionMethods.All - }; - - using var httpClient = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(30) }; - - using var loginData = new FormUrlEncodedContent(new[] - { - new KeyValuePair("username", client.Username ?? string.Empty), - new KeyValuePair("password", client.Password ?? string.Empty) - }); + using var httpClient = QbittorrentCookieSession.CreateClient(); + using var loginData = QbittorrentCookieSession.CreateLoginContent(client); using var loginResp = await httpClient.PostAsync($"{baseUrl}/api/v2/auth/login", loginData, ct); if (loginResp.StatusCode == HttpStatusCode.Forbidden) @@ -515,22 +475,10 @@ public async Task GetImportItemAsync( try { - var cookieJar = new CookieContainer(); - var handler = new HttpClientHandler - { - CookieContainer = cookieJar, - UseCookies = true, - AutomaticDecompression = DecompressionMethods.All - }; - - using var httpClient = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(30) }; + using var httpClient = QbittorrentCookieSession.CreateClient(); // Login - using var loginData = new FormUrlEncodedContent(new[] - { - new KeyValuePair("username", client.Username ?? string.Empty), - new KeyValuePair("password", client.Password ?? string.Empty) - }); + using var loginData = QbittorrentCookieSession.CreateLoginContent(client); using var loginResp = await httpClient.PostAsync($"{baseUrl}/api/v2/auth/login", loginData, ct); if (!loginResp.IsSuccessStatusCode && loginResp.StatusCode != HttpStatusCode.Forbidden) @@ -639,22 +587,10 @@ public async Task GetImportItemAsync( try { - var cookieJar = new CookieContainer(); - var handler = new HttpClientHandler - { - CookieContainer = cookieJar, - UseCookies = true, - AutomaticDecompression = DecompressionMethods.All - }; - - using var httpClient = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(30) }; + using var httpClient = QbittorrentCookieSession.CreateClient(); // Login - using var loginData = new FormUrlEncodedContent(new[] - { - new KeyValuePair("username", client.Username ?? string.Empty), - new KeyValuePair("password", client.Password ?? string.Empty) - }); + using var loginData = QbittorrentCookieSession.CreateLoginContent(client); using var loginResp = await httpClient.PostAsync($"{baseUrl}/api/v2/auth/login", loginData, ct); if (!loginResp.IsSuccessStatusCode && loginResp.StatusCode != HttpStatusCode.Forbidden) diff --git a/listenarr.infrastructure/Adapters/QbittorrentConnectionTester.cs b/listenarr.infrastructure/Adapters/QbittorrentConnectionTester.cs index 30407ab4f..49175cfcb 100644 --- a/listenarr.infrastructure/Adapters/QbittorrentConnectionTester.cs +++ b/listenarr.infrastructure/Adapters/QbittorrentConnectionTester.cs @@ -169,20 +169,8 @@ async Task PostLoginWithAgent(string userAgent) { try { - var cookieJar2 = new CookieContainer(); - var handler2 = new HttpClientHandler - { - CookieContainer = cookieJar2, - UseCookies = true, - AutomaticDecompression = DecompressionMethods.All - }; - - using var local = new HttpClient(handler2) { Timeout = TimeSpan.FromSeconds(30) }; - using var localLoginContent = new FormUrlEncodedContent( - [ - new KeyValuePair("username", client.Username), - new KeyValuePair("password", client.Password) - ]); + using var local = QbittorrentCookieSession.CreateClient(); + using var localLoginContent = QbittorrentCookieSession.CreateLoginContent(client); using var localLogin = await local.PostAsync($"{baseUrl}/api/v2/auth/login", localLoginContent, ct); if (localLogin.IsSuccessStatusCode) diff --git a/listenarr.infrastructure/Adapters/QbittorrentCookieSession.cs b/listenarr.infrastructure/Adapters/QbittorrentCookieSession.cs new file mode 100644 index 000000000..15f5ffe28 --- /dev/null +++ b/listenarr.infrastructure/Adapters/QbittorrentCookieSession.cs @@ -0,0 +1,47 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +using System.Net; +using Listenarr.Domain.Models; + +namespace Listenarr.Infrastructure.Adapters +{ + internal static class QbittorrentCookieSession + { + public static HttpClient CreateClient() + { + var cookieJar = new CookieContainer(); + var handler = new HttpClientHandler + { + CookieContainer = cookieJar, + UseCookies = true, + AutomaticDecompression = DecompressionMethods.All + }; + + return new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(30) }; + } + + public static FormUrlEncodedContent CreateLoginContent(DownloadClientConfiguration client) + { + return new FormUrlEncodedContent(new[] + { + new KeyValuePair("username", client.Username ?? string.Empty), + new KeyValuePair("password", client.Password ?? string.Empty) + }); + } + } +} diff --git a/listenarr.infrastructure/Adapters/TransmissionAdapter.cs b/listenarr.infrastructure/Adapters/TransmissionAdapter.cs index 3f004bc86..a5c50b602 100644 --- a/listenarr.infrastructure/Adapters/TransmissionAdapter.cs +++ b/listenarr.infrastructure/Adapters/TransmissionAdapter.cs @@ -15,7 +15,6 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -using System.Globalization; using System.Net; using System.Text.Encodings.Web; using System.Text.Json; @@ -102,7 +101,7 @@ public TransmissionAdapter(IHttpClientFactory httpClientFactory, ITorrentFileDow if (client == null) throw new ArgumentNullException(nameof(client)); if (result == null) throw new ArgumentNullException(nameof(result)); - var labels = CollectLabels(client); + var labels = TransmissionRequestPlanner.CollectLabels(client); var arguments = await _torrentAddPlanner.BuildArgumentsAsync(client, result, labels, ct); // Use old format for compatibility with Transmission < 4.1.0 @@ -131,14 +130,14 @@ public TransmissionAdapter(IHttpClientFactory httpClientFactory, ITorrentFileDow { if (args.TryGetProperty("torrent-added", out var added) && added.ValueKind == JsonValueKind.Object) { - var torrentId = ExtractTorrentIdentifier(added); + var torrentId = TransmissionRequestPlanner.ExtractTorrentIdentifier(added); _logger.LogInformation("Transmission successfully added torrent '{Title}' with id/hash: {Id}", LogRedaction.SanitizeText(result.Title), LogRedaction.SanitizeText(torrentId)); return torrentId; } if (args.TryGetProperty("torrent-duplicate", out var duplicate) && duplicate.ValueKind == JsonValueKind.Object) { - var existingId = ExtractTorrentIdentifier(duplicate); + var existingId = TransmissionRequestPlanner.ExtractTorrentIdentifier(duplicate); _logger.LogInformation("Transmission reported duplicate torrent for '{Title}' with id/hash {Id}", LogRedaction.SanitizeText(result.Title), LogRedaction.SanitizeText(existingId)); return existingId; } @@ -159,7 +158,7 @@ public async Task RemoveAsync(DownloadClientConfiguration client, string i if (client == null) throw new ArgumentNullException(nameof(client)); if (string.IsNullOrWhiteSpace(id)) throw new ArgumentNullException(nameof(id)); - var idsPayload = ParseTransmissionIds(id); + var idsPayload = TransmissionRequestPlanner.ParseTransmissionIds(id); var arguments = new Dictionary { ["ids"] = idsPayload, @@ -370,7 +369,7 @@ public async Task GetImportItemAsync( method = "torrent-get", arguments = new { - ids = ParseTransmissionIds(item.DownloadId), + ids = TransmissionRequestPlanner.ParseTransmissionIds(item.DownloadId), fields = new[] { "id", "name", "downloadDir" } }, tag = 5 @@ -458,7 +457,7 @@ public async Task GetImportItemAsync( method = "torrent-get", arguments = new { - ids = ParseTransmissionIds(queueItem.Id), + ids = TransmissionRequestPlanner.ParseTransmissionIds(queueItem.Id), fields = new[] { "id", "name", "downloadDir", "files" } }, tag = 5 @@ -529,44 +528,6 @@ private Task MapToDownloadClientItemAsync( return Task.FromResult(TransmissionResponseMapper.MapDownloadClientItem(client, torrent, sessionConfig)); } - private List CollectLabels(DownloadClientConfiguration client) - { - var labels = new List(); - - if (client.Settings != null && client.Settings.TryGetValue("category", out var categoryObj)) - { - var category = categoryObj?.ToString(); - if (!string.IsNullOrWhiteSpace(category)) - { - labels.Add(category); - } - } - - if (client.Settings != null && client.Settings.TryGetValue("tags", out var tagsObj)) - { - var tags = tagsObj?.ToString(); - if (!string.IsNullOrWhiteSpace(tags)) - { - labels.AddRange(tags - .Split(',', StringSplitOptions.RemoveEmptyEntries) - .Select(t => t.Trim()) - .Where(t => !string.IsNullOrEmpty(t))); - } - } - - return labels; - } - - private object[] ParseTransmissionIds(string id) - { - if (int.TryParse(id, NumberStyles.Integer, CultureInfo.InvariantCulture, out var numericId)) - { - return new object[] { numericId }; - } - - return new object[] { id }; - } - private async Task PreDownloadTorrentFileAsync(string torrentUrl, CancellationToken ct) { using var downloadCts = CancellationTokenSource.CreateLinkedTokenSource(ct); @@ -629,31 +590,6 @@ or HttpStatusCode.TemporaryRedirect or HttpStatusCode.PermanentRedirect return null; } - private static string? ExtractTorrentIdentifier(JsonElement element) - { - if (element.ValueKind != JsonValueKind.Object) - { - return null; - } - - // Try snake_case (JSON-RPC 2.0 / Transmission 4.1+) first, fall back to camelCase - if ((element.TryGetProperty("hash_string", out var hashProp) || element.TryGetProperty("hashString", out hashProp))) - { - var hash = hashProp.GetString(); - if (!string.IsNullOrWhiteSpace(hash)) - { - return hash; - } - } - - if (element.TryGetProperty("id", out var idProp) && idProp.ValueKind == JsonValueKind.Number) - { - return idProp.GetInt32().ToString(CultureInfo.InvariantCulture); - } - - return null; - } - public async Task> FetchDownloadsAsync( DownloadClientConfiguration client, List downloads, diff --git a/listenarr.infrastructure/Adapters/TransmissionRequestPlanner.cs b/listenarr.infrastructure/Adapters/TransmissionRequestPlanner.cs new file mode 100644 index 000000000..599035ed5 --- /dev/null +++ b/listenarr.infrastructure/Adapters/TransmissionRequestPlanner.cs @@ -0,0 +1,88 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +using System.Globalization; +using System.Text.Json; +using Listenarr.Domain.Models; + +namespace Listenarr.Infrastructure.Adapters +{ + internal static class TransmissionRequestPlanner + { + public static List CollectLabels(DownloadClientConfiguration client) + { + var labels = new List(); + + if (client.Settings != null && client.Settings.TryGetValue("category", out var categoryObj)) + { + var category = categoryObj?.ToString(); + if (!string.IsNullOrWhiteSpace(category)) + { + labels.Add(category); + } + } + + if (client.Settings != null && client.Settings.TryGetValue("tags", out var tagsObj)) + { + var tags = tagsObj?.ToString(); + if (!string.IsNullOrWhiteSpace(tags)) + { + labels.AddRange(tags + .Split(',', StringSplitOptions.RemoveEmptyEntries) + .Select(t => t.Trim()) + .Where(t => !string.IsNullOrEmpty(t))); + } + } + + return labels; + } + + public static object[] ParseTransmissionIds(string id) + { + if (int.TryParse(id, NumberStyles.Integer, CultureInfo.InvariantCulture, out var numericId)) + { + return new object[] { numericId }; + } + + return new object[] { id }; + } + + public static string? ExtractTorrentIdentifier(JsonElement element) + { + if (element.ValueKind != JsonValueKind.Object) + { + return null; + } + + if ((element.TryGetProperty("hash_string", out var hashProp) || element.TryGetProperty("hashString", out hashProp))) + { + var hash = hashProp.GetString(); + if (!string.IsNullOrWhiteSpace(hash)) + { + return hash; + } + } + + if (element.TryGetProperty("id", out var idProp) && idProp.ValueKind == JsonValueKind.Number) + { + return idProp.GetInt32().ToString(CultureInfo.InvariantCulture); + } + + return null; + } + } +} From 96a81c6369ce62bf86bd0167cb53503832caa41a Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Sat, 13 Jun 2026 20:31:25 -0400 Subject: [PATCH 41/84] refactor: extract prowlarr import url planner - Move Prowlarr base/proxy URL construction out of IndexersController - Preserve import matching and outbound validation behavior --- .../Controllers/IndexersController.cs | 37 ++----------- .../Controllers/ProwlarrImportUrlPlanner.cs | 52 +++++++++++++++++++ 2 files changed, 56 insertions(+), 33 deletions(-) create mode 100644 listenarr.api/Controllers/ProwlarrImportUrlPlanner.cs diff --git a/listenarr.api/Controllers/IndexersController.cs b/listenarr.api/Controllers/IndexersController.cs index 463229196..8ceecc29c 100644 --- a/listenarr.api/Controllers/IndexersController.cs +++ b/listenarr.api/Controllers/IndexersController.cs @@ -288,7 +288,7 @@ public async Task ImportFromProwlarr([FromBody] ProwlarrImportReq return BadRequest(new { message = "Prowlarr API key is required" }); } - var baseUrl = BuildProwlarrBaseUrl(effectiveUrl, effectivePort); + var baseUrl = ProwlarrImportUrlPlanner.BuildBaseUrl(effectiveUrl, effectivePort); var blockedBaseUrlReason = await ValidateOutboundUrlForCallerAsync(baseUrl); if (!string.IsNullOrWhiteSpace(blockedBaseUrlReason)) { @@ -396,11 +396,11 @@ await _configurationService.SaveProwlarrImportSettingsAsync(new ProwlarrImportCo var implementation = protocol.Equals("usenet", StringComparison.OrdinalIgnoreCase) ? "Newznab" : "Torznab"; - var proxyUrl = BuildProwlarrProxyUrl(baseUrl, indexerId); - var normalizedUrl = NormalizeProwlarrProxyUrl(proxyUrl); + var proxyUrl = ProwlarrImportUrlPlanner.BuildProxyUrl(baseUrl, indexerId); + var normalizedUrl = ProwlarrImportUrlPlanner.NormalizeProxyUrl(proxyUrl); var exists = existingIndexers.FirstOrDefault(i => - NormalizeProwlarrProxyUrl(i.Url) == normalizedUrl && + ProwlarrImportUrlPlanner.NormalizeProxyUrl(i.Url) == normalizedUrl && string.Equals(i.Implementation, implementation, StringComparison.OrdinalIgnoreCase) && string.Equals(i.ApiKey ?? string.Empty, effectiveApiKey ?? string.Empty, StringComparison.Ordinal)); @@ -802,35 +802,6 @@ public async Task GetEnabled() return Ok(RedactIndexersForCaller(indexers)); } - private string BuildProwlarrBaseUrl(string rawUrl, int? port) - { - var trimmed = rawUrl.Trim(); - if (!trimmed.StartsWith("http://", StringComparison.OrdinalIgnoreCase) && !trimmed.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) - { - trimmed = "http://" + trimmed; - } - - var builder = new UriBuilder(trimmed); - if (port.HasValue && port.Value > 0) - { - builder.Port = port.Value; - } - - return builder.Uri.ToString().TrimEnd('/'); - } - - private string BuildProwlarrProxyUrl(string baseUrl, int indexerId) - { - var root = baseUrl.TrimEnd('/'); - return $"{root}/{indexerId}/api"; - } - - private string NormalizeProwlarrProxyUrl(string? rawUrl) - { - if (string.IsNullOrWhiteSpace(rawUrl)) return rawUrl ?? string.Empty; - return rawUrl.Trim().TrimEnd('/'); - } - private async Task<(HttpResponseMessage Response, string Payload)> FetchProwlarrIndexersAsync(string baseUrl, string apiKey) { var encodedKey = System.Net.WebUtility.UrlEncode(apiKey); diff --git a/listenarr.api/Controllers/ProwlarrImportUrlPlanner.cs b/listenarr.api/Controllers/ProwlarrImportUrlPlanner.cs new file mode 100644 index 000000000..405b8939f --- /dev/null +++ b/listenarr.api/Controllers/ProwlarrImportUrlPlanner.cs @@ -0,0 +1,52 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace Listenarr.Api.Controllers +{ + internal static class ProwlarrImportUrlPlanner + { + public static string BuildBaseUrl(string rawUrl, int? port) + { + var trimmed = rawUrl.Trim(); + if (!trimmed.StartsWith("http://", StringComparison.OrdinalIgnoreCase) && !trimmed.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + { + trimmed = "http://" + trimmed; + } + + var builder = new UriBuilder(trimmed); + if (port.HasValue && port.Value > 0) + { + builder.Port = port.Value; + } + + return builder.Uri.ToString().TrimEnd('/'); + } + + public static string BuildProxyUrl(string baseUrl, int indexerId) + { + var root = baseUrl.TrimEnd('/'); + return $"{root}/{indexerId}/api"; + } + + public static string NormalizeProxyUrl(string? rawUrl) + { + if (string.IsNullOrWhiteSpace(rawUrl)) return rawUrl ?? string.Empty; + return rawUrl.Trim().TrimEnd('/'); + } + } +} From 4f61adba134f6f4048d8247a2a676a88ca080483 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Sat, 13 Jun 2026 20:44:34 -0400 Subject: [PATCH 42/84] refactor: extract search mam option parsing - Move MyAnonamouse query parsing out of SearchController - Reuse parsing helper for indexer and API-specific search endpoints --- listenarr.api/Controllers/SearchController.cs | 39 +++------ .../Controllers/SearchMamOptionsReader.cs | 86 +++++++++++++++++++ 2 files changed, 96 insertions(+), 29 deletions(-) create mode 100644 listenarr.api/Controllers/SearchMamOptionsReader.cs diff --git a/listenarr.api/Controllers/SearchController.cs b/listenarr.api/Controllers/SearchController.cs index e7a360f91..efdcf02a3 100644 --- a/listenarr.api/Controllers/SearchController.cs +++ b/listenarr.api/Controllers/SearchController.cs @@ -588,16 +588,7 @@ public async Task>> IndexersSearch( _logger.LogInformation("IndexersSearch called for query: {Query}, isAutomaticSearch={IsAutomatic}", LogRedaction.SanitizeText(query), isAutomaticSearch); // Support MyAnonamouse query string toggles (mamFilter, mamSearchInDescription, mamSearchInSeries, mamSearchInFilenames, mamLanguage, mamFreeleechWedge) - var mamOptions = new MyAnonamouseOptions(); - if (Request.Query.TryGetValue("mamFilter", out var queryMamFilter) && Enum.TryParse(queryMamFilter.ToString() ?? string.Empty, true, out var mamFilter)) - mamOptions.Filter = mamFilter; - if (Request.Query.TryGetValue("mamSearchInDescription", out var queryMamSearchInDescription) && bool.TryParse(queryMamSearchInDescription, out var sd)) mamOptions.SearchInDescription = sd; - if (Request.Query.TryGetValue("mamSearchInSeries", out var queryMamSearchInSeries) && bool.TryParse(queryMamSearchInSeries, out var ss)) mamOptions.SearchInSeries = ss; - if (Request.Query.TryGetValue("mamSearchInFilenames", out var queryMamSearchInFilenames) && bool.TryParse(queryMamSearchInFilenames, out var sf)) mamOptions.SearchInFilenames = sf; - if (Request.Query.TryGetValue("mamLanguage", out var queryMamLanguage)) mamOptions.SearchLanguage = queryMamLanguage.ToString(); - if (Request.Query.TryGetValue("mamFreeleechWedge", out var queryMamFreeleechWedge) && Enum.TryParse(queryMamFreeleechWedge.ToString() ?? string.Empty, true, out var mw)) mamOptions.FreeleechWedge = mw; - - var req = new SearchRequest { MyAnonamouse = mamOptions }; + var req = new SearchRequest { MyAnonamouse = SearchMamOptionsReader.FromQuery(Request.Query) }; var results = await _searchService.SearchIndexersAsync(query, category, sortBy, sortDirection, isAutomaticSearch, req); _logger.LogInformation("IndexersSearch returning {Count} results for query: {Query}", results.Count, LogRedaction.SanitizeText(query)); return Ok(results); @@ -851,25 +842,15 @@ public async Task> SearchByApi( } // If the caller provided explicit MyAnonamouse query params, construct a SearchRequest that will be passed to the service. - SearchRequest? request = null; - if (mamFilter != null || mamSearchInDescription.HasValue || mamSearchInSeries.HasValue || mamSearchInFilenames.HasValue || mamLanguage != null || mamFreeleechWedge != null || mamEnrichResults.HasValue || mamEnrichTopResults.HasValue) - { - request = new SearchRequest(); - request.MyAnonamouse = new MyAnonamouseOptions(); - - if (mamSearchInDescription.HasValue) request.MyAnonamouse.SearchInDescription = mamSearchInDescription.Value; - if (mamSearchInSeries.HasValue) request.MyAnonamouse.SearchInSeries = mamSearchInSeries.Value; - if (mamSearchInFilenames.HasValue) request.MyAnonamouse.SearchInFilenames = mamSearchInFilenames.Value; - if (!string.IsNullOrWhiteSpace(mamLanguage)) request.MyAnonamouse.SearchLanguage = mamLanguage; - - if (!string.IsNullOrWhiteSpace(mamFilter) && Enum.TryParse(mamFilter, true, out var mf)) - request.MyAnonamouse.Filter = mf; - - if (!string.IsNullOrWhiteSpace(mamFreeleechWedge) && Enum.TryParse(mamFreeleechWedge, true, out var fw)) - request.MyAnonamouse.FreeleechWedge = fw; - if (mamEnrichResults.HasValue) request.MyAnonamouse.EnrichResults = mamEnrichResults.Value; - if (mamEnrichTopResults.HasValue) request.MyAnonamouse.EnrichTopResults = mamEnrichTopResults.Value; - } + var request = SearchMamOptionsReader.FromBoundParameters( + mamFilter, + mamSearchInDescription, + mamSearchInSeries, + mamSearchInFilenames, + mamLanguage, + mamFreeleechWedge, + mamEnrichResults, + mamEnrichTopResults); // Use the raw indexer results when the caller expects indexer-specific fields. SearchIndexerResultsAsync will // apply any MyAnonamouse options found in the indexer's AdditionalSettings if no explicit request was supplied. diff --git a/listenarr.api/Controllers/SearchMamOptionsReader.cs b/listenarr.api/Controllers/SearchMamOptionsReader.cs new file mode 100644 index 000000000..0fe4dfa72 --- /dev/null +++ b/listenarr.api/Controllers/SearchMamOptionsReader.cs @@ -0,0 +1,86 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +using Listenarr.Application.Search; + +namespace Listenarr.Api.Controllers +{ + internal static class SearchMamOptionsReader + { + public static MyAnonamouseOptions FromQuery(IQueryCollection query) + { + var mamOptions = new MyAnonamouseOptions(); + if (query.TryGetValue("mamFilter", out var queryMamFilter) && Enum.TryParse(queryMamFilter.ToString() ?? string.Empty, true, out var mamFilter)) + mamOptions.Filter = mamFilter; + if (query.TryGetValue("mamSearchInDescription", out var queryMamSearchInDescription) && bool.TryParse(queryMamSearchInDescription, out var sd)) + mamOptions.SearchInDescription = sd; + if (query.TryGetValue("mamSearchInSeries", out var queryMamSearchInSeries) && bool.TryParse(queryMamSearchInSeries, out var ss)) + mamOptions.SearchInSeries = ss; + if (query.TryGetValue("mamSearchInFilenames", out var queryMamSearchInFilenames) && bool.TryParse(queryMamSearchInFilenames, out var sf)) + mamOptions.SearchInFilenames = sf; + if (query.TryGetValue("mamLanguage", out var queryMamLanguage)) + mamOptions.SearchLanguage = queryMamLanguage.ToString(); + if (query.TryGetValue("mamFreeleechWedge", out var queryMamFreeleechWedge) && Enum.TryParse(queryMamFreeleechWedge.ToString() ?? string.Empty, true, out var mw)) + mamOptions.FreeleechWedge = mw; + + return mamOptions; + } + + public static SearchRequest? FromBoundParameters( + string? mamFilter, + bool? mamSearchInDescription, + bool? mamSearchInSeries, + bool? mamSearchInFilenames, + string? mamLanguage, + string? mamFreeleechWedge, + bool? mamEnrichResults, + int? mamEnrichTopResults) + { + if (mamFilter == null && + !mamSearchInDescription.HasValue && + !mamSearchInSeries.HasValue && + !mamSearchInFilenames.HasValue && + mamLanguage == null && + mamFreeleechWedge == null && + !mamEnrichResults.HasValue && + !mamEnrichTopResults.HasValue) + { + return null; + } + + var request = new SearchRequest + { + MyAnonamouse = new MyAnonamouseOptions() + }; + + if (mamSearchInDescription.HasValue) request.MyAnonamouse.SearchInDescription = mamSearchInDescription.Value; + if (mamSearchInSeries.HasValue) request.MyAnonamouse.SearchInSeries = mamSearchInSeries.Value; + if (mamSearchInFilenames.HasValue) request.MyAnonamouse.SearchInFilenames = mamSearchInFilenames.Value; + if (!string.IsNullOrWhiteSpace(mamLanguage)) request.MyAnonamouse.SearchLanguage = mamLanguage; + + if (!string.IsNullOrWhiteSpace(mamFilter) && Enum.TryParse(mamFilter, true, out var mf)) + request.MyAnonamouse.Filter = mf; + + if (!string.IsNullOrWhiteSpace(mamFreeleechWedge) && Enum.TryParse(mamFreeleechWedge, true, out var fw)) + request.MyAnonamouse.FreeleechWedge = fw; + if (mamEnrichResults.HasValue) request.MyAnonamouse.EnrichResults = mamEnrichResults.Value; + if (mamEnrichTopResults.HasValue) request.MyAnonamouse.EnrichTopResults = mamEnrichTopResults.Value; + + return request; + } + } +} From 1aabaf609621f5e2cf38a05b256f8406ae0dfb1c Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Sat, 13 Jun 2026 20:47:35 -0400 Subject: [PATCH 43/84] refactor: extract ffprobe checksum parser - Move checksum file parsing out of FfmpegService - Keep ffprobe install and checksum validation behavior unchanged --- .../Ffmpeg/FfmpegService.cs | 44 +------------ .../Ffmpeg/FfprobeChecksumParser.cs | 63 +++++++++++++++++++ 2 files changed, 65 insertions(+), 42 deletions(-) create mode 100644 listenarr.infrastructure/Ffmpeg/FfprobeChecksumParser.cs diff --git a/listenarr.infrastructure/Ffmpeg/FfmpegService.cs b/listenarr.infrastructure/Ffmpeg/FfmpegService.cs index 2c1ce8fb4..44069555d 100644 --- a/listenarr.infrastructure/Ffmpeg/FfmpegService.cs +++ b/listenarr.infrastructure/Ffmpeg/FfmpegService.cs @@ -231,7 +231,7 @@ private static bool TryBuildPathUnderRoot(string rootPath, string entryPath, out var expected = GetChecksumForPlatform(); if (string.IsNullOrEmpty(expected) && !string.IsNullOrEmpty(discoveredChecksum)) { - var parsed = ParseChecksumFileForAsset(discoveredChecksum, Path.GetFileName(downloadUrl)); + var parsed = FfprobeChecksumParser.ParseForAsset(discoveredChecksum, Path.GetFileName(downloadUrl)); if (!string.IsNullOrEmpty(parsed)) expected = parsed; } @@ -246,7 +246,7 @@ private static bool TryBuildPathUnderRoot(string rootPath, string entryPath, out try { var content = await File.ReadAllTextAsync(cf); - var parsed = ParseChecksumFileForAsset(content, Path.GetFileName(downloadUrl)); + var parsed = FfprobeChecksumParser.ParseForAsset(content, Path.GetFileName(downloadUrl)); if (!string.IsNullOrEmpty(parsed)) { expected = parsed; break; } } catch (Exception caughtEx_3) when (caughtEx_3 is not OperationCanceledException && caughtEx_3 is not OutOfMemoryException && caughtEx_3 is not StackOverflowException) @@ -616,46 +616,6 @@ private static bool TryBuildPathUnderRoot(string rootPath, string entryPath, out return (null, null); } - private static string? ParseChecksumFileForAsset(string checksumFileContent, string assetFileName) - { - if (string.IsNullOrEmpty(checksumFileContent) || string.IsNullOrEmpty(assetFileName)) return null; - using var sr = new StringReader(checksumFileContent); - string? line; - while ((line = sr.ReadLine()) != null) - { - var trimmed = line.Trim(); - if (string.IsNullOrEmpty(trimmed)) continue; - // Common formats: " " or " *" or " " - var parts = trimmed.Split(' ', StringSplitOptions.RemoveEmptyEntries); - if (parts.Length >= 2) - { - var possibleHash = parts[0].Trim(); - var possibleName = parts[^1].Trim(); - if (possibleName.StartsWith("*")) possibleName = possibleName[1..]; - if (possibleName.Equals(assetFileName, StringComparison.OrdinalIgnoreCase) || possibleName.EndsWith(assetFileName, StringComparison.OrdinalIgnoreCase)) - { - return possibleHash; - } - } - else - { - // Some checksum files list "filename: hash" or JSON; do simple contains - if (trimmed.Contains(assetFileName, StringComparison.OrdinalIgnoreCase)) - { - var tokens = trimmed.Split(':', StringSplitOptions.RemoveEmptyEntries); - if (tokens.Length >= 2) - { - var candidate = tokens[1].Trim(); - var candidateToken = candidate.Split(' ', StringSplitOptions.RemoveEmptyEntries)[0]; - if (!string.IsNullOrEmpty(candidateToken)) return candidateToken; - } - } - } - } - - return null; - } - public async Task RunFfprobeAsync(string filePath) { var sanitizedFilePath = LogRedaction.SanitizeFilePath(filePath); diff --git a/listenarr.infrastructure/Ffmpeg/FfprobeChecksumParser.cs b/listenarr.infrastructure/Ffmpeg/FfprobeChecksumParser.cs new file mode 100644 index 000000000..d32b3df90 --- /dev/null +++ b/listenarr.infrastructure/Ffmpeg/FfprobeChecksumParser.cs @@ -0,0 +1,63 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace Listenarr.Infrastructure.Ffmpeg +{ + internal static class FfprobeChecksumParser + { + public static string? ParseForAsset(string checksumFileContent, string assetFileName) + { + if (string.IsNullOrEmpty(checksumFileContent) || string.IsNullOrEmpty(assetFileName)) return null; + using var sr = new StringReader(checksumFileContent); + string? line; + while ((line = sr.ReadLine()) != null) + { + var trimmed = line.Trim(); + if (string.IsNullOrEmpty(trimmed)) continue; + // Common formats: " " or " *" or " " + var parts = trimmed.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length >= 2) + { + var possibleHash = parts[0].Trim(); + var possibleName = parts[^1].Trim(); + if (possibleName.StartsWith("*")) possibleName = possibleName[1..]; + if (possibleName.Equals(assetFileName, StringComparison.OrdinalIgnoreCase) || possibleName.EndsWith(assetFileName, StringComparison.OrdinalIgnoreCase)) + { + return possibleHash; + } + } + else + { + // Some checksum files list "filename: hash" or JSON; do simple contains + if (trimmed.Contains(assetFileName, StringComparison.OrdinalIgnoreCase)) + { + var tokens = trimmed.Split(':', StringSplitOptions.RemoveEmptyEntries); + if (tokens.Length >= 2) + { + var candidate = tokens[1].Trim(); + var candidateToken = candidate.Split(' ', StringSplitOptions.RemoveEmptyEntries)[0]; + if (!string.IsNullOrEmpty(candidateToken)) return candidateToken; + } + } + } + } + + return null; + } + } +} From 413196b562755dbe3247dc324d2c73a79cda5008 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Sat, 13 Jun 2026 20:51:58 -0400 Subject: [PATCH 44/84] refactor: extract torznab provider helpers - Move Torznab/Newznab request URL construction into a focused helper - Move response size and language parsing out of the provider --- .../Providers/TorznabNewznabRequestBuilder.cs | 72 ++++++++++ .../Providers/TorznabNewznabSearchProvider.cs | 132 +----------------- .../Providers/TorznabNewznabValueParser.cs | 99 +++++++++++++ 3 files changed, 178 insertions(+), 125 deletions(-) create mode 100644 listenarr.infrastructure/Search/Providers/TorznabNewznabRequestBuilder.cs create mode 100644 listenarr.infrastructure/Search/Providers/TorznabNewznabValueParser.cs diff --git a/listenarr.infrastructure/Search/Providers/TorznabNewznabRequestBuilder.cs b/listenarr.infrastructure/Search/Providers/TorznabNewznabRequestBuilder.cs new file mode 100644 index 000000000..3dde1699d --- /dev/null +++ b/listenarr.infrastructure/Search/Providers/TorznabNewznabRequestBuilder.cs @@ -0,0 +1,72 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +using Listenarr.Domain.Models; + +namespace Listenarr.Infrastructure.Search.Providers; + +internal static class TorznabNewznabRequestBuilder +{ + public static string BuildUrl(Indexer indexer, string query, string? category) + { + var url = indexer.Url.TrimEnd('/'); + + // Don't append /api if URL already ends with it (e.g., Prowlarr proxy URLs) + var apiPath = url.EndsWith("/api", StringComparison.OrdinalIgnoreCase) + ? "" + : indexer.Implementation.ToLower() switch + { + "torznab" => "/api", + "newznab" => "/api", + _ => "/api" + }; + + var queryParams = new List + { + $"t=search", + $"q={Uri.EscapeDataString(query)}" + }; + + // Add API key if provided + if (!string.IsNullOrEmpty(indexer.ApiKey)) + { + queryParams.Add($"apikey={Uri.EscapeDataString(indexer.ApiKey)}"); + } + + // Add categories if specified + if (!string.IsNullOrEmpty(category)) + { + queryParams.Add($"cat={Uri.EscapeDataString(category)}"); + } + else if (!string.IsNullOrEmpty(indexer.Categories)) + { + queryParams.Add($"cat={Uri.EscapeDataString(indexer.Categories)}"); + } + + // Add limit + queryParams.Add("limit=100"); + + // Request extended info for Newznab/Torznab indexers to include grabs/snatches and other attributes when available + if (!string.IsNullOrEmpty(indexer.Implementation) && (indexer.Implementation.Equals("newznab", StringComparison.OrdinalIgnoreCase) || indexer.Implementation.Equals("torznab", StringComparison.OrdinalIgnoreCase))) + { + queryParams.Add("extended=1"); + } + + return $"{url}{apiPath}?{string.Join("&", queryParams)}"; + } +} diff --git a/listenarr.infrastructure/Search/Providers/TorznabNewznabSearchProvider.cs b/listenarr.infrastructure/Search/Providers/TorznabNewznabSearchProvider.cs index e3452b81a..49687bfc4 100644 --- a/listenarr.infrastructure/Search/Providers/TorznabNewznabSearchProvider.cs +++ b/listenarr.infrastructure/Search/Providers/TorznabNewznabSearchProvider.cs @@ -53,7 +53,7 @@ public async Task> SearchAsync( try { // Build Torznab/Newznab API URL (redact api keys before logging) - var url = BuildTorznabUrl(indexer, query, category); + var url = TorznabNewznabRequestBuilder.BuildUrl(indexer, query, category); var redactedUrl = LogRedaction.RedactText(url, LogRedaction.GetSensitiveValuesFromEnvironment().Concat(new[] { indexer.ApiKey ?? string.Empty })); _logger.LogDebug("Indexer API URL: {Url}", redactedUrl); @@ -87,50 +87,7 @@ public async Task> SearchAsync( private string BuildTorznabUrl(Indexer indexer, string query, string? category) { - var url = indexer.Url.TrimEnd('/'); - - // Don't append /api if URL already ends with it (e.g., Prowlarr proxy URLs) - var apiPath = url.EndsWith("/api", StringComparison.OrdinalIgnoreCase) - ? "" - : indexer.Implementation.ToLower() switch - { - "torznab" => "/api", - "newznab" => "/api", - _ => "/api" - }; - - var queryParams = new List - { - $"t=search", - $"q={Uri.EscapeDataString(query)}" - }; - - // Add API key if provided - if (!string.IsNullOrEmpty(indexer.ApiKey)) - { - queryParams.Add($"apikey={Uri.EscapeDataString(indexer.ApiKey)}"); - } - - // Add categories if specified - if (!string.IsNullOrEmpty(category)) - { - queryParams.Add($"cat={Uri.EscapeDataString(category)}"); - } - else if (!string.IsNullOrEmpty(indexer.Categories)) - { - queryParams.Add($"cat={Uri.EscapeDataString(indexer.Categories)}"); - } - - // Add limit - queryParams.Add("limit=100"); - - // Request extended info for Newznab/Torznab indexers to include grabs/snatches and other attributes when available - if (!string.IsNullOrEmpty(indexer.Implementation) && (indexer.Implementation.Equals("newznab", StringComparison.OrdinalIgnoreCase) || indexer.Implementation.Equals("torznab", StringComparison.OrdinalIgnoreCase))) - { - queryParams.Add("extended=1"); - } - - return $"{url}{apiPath}?{string.Join("&", queryParams)}"; + return TorznabNewznabRequestBuilder.BuildUrl(indexer, query, category); } private async Task> ParseTorznabResponseAsync(string xmlContent, Indexer indexer) @@ -210,7 +167,7 @@ private async Task> ParseTorznabResponseAsync(string x switch (name.ToLower()) { case "size": - var parsedSize = ParseSizeString(value); + var parsedSize = TorznabNewznabValueParser.ParseSize(value); if (parsedSize > 0) { result.Size = parsedSize; @@ -265,7 +222,7 @@ private async Task> ParseTorznabResponseAsync(string x // Standardized language codes (e.g., ENG, FR) try { - var parsedLang = ParseLanguageFromText(value); + var parsedLang = TorznabNewznabValueParser.ParseLanguageFromText(value); if (!string.IsNullOrEmpty(parsedLang)) result.Language = parsedLang; } catch (Exception caughtEx_1) when (caughtEx_1 is not OperationCanceledException && caughtEx_1 is not OutOfMemoryException && caughtEx_1 is not StackOverflowException) @@ -284,7 +241,7 @@ private async Task> ParseTorznabResponseAsync(string x { try { - var pl = ParseLanguageFromText(value); + var pl = TorznabNewznabValueParser.ParseLanguageFromText(value); if (!string.IsNullOrEmpty(pl)) result.Language = pl; } catch (Exception caughtEx_2) when (caughtEx_2 is not OperationCanceledException && caughtEx_2 is not OutOfMemoryException && caughtEx_2 is not StackOverflowException) @@ -417,7 +374,7 @@ private async Task> ParseTorznabResponseAsync(string x var lengthStr = enclosure.Attribute("length")?.Value; if (!string.IsNullOrEmpty(lengthStr) && result.Size == 0) { - var parsedLen = ParseSizeString(lengthStr); + var parsedLen = TorznabNewznabValueParser.ParseSize(lengthStr); if (parsedLen > 0) { result.Size = parsedLen; @@ -495,7 +452,7 @@ private async Task> ParseTorznabResponseAsync(string x // Detect language codes present in title or description (e.g. [ENG / M4B]) try { - var lang = ParseLanguageFromText(result.Title + " " + description); + var lang = TorznabNewznabValueParser.ParseLanguageFromText(result.Title + " " + description); if (!string.IsNullOrEmpty(lang)) result.Language = lang; } catch (Exception caughtEx_4) when (caughtEx_4 is not OperationCanceledException && caughtEx_4 is not OutOfMemoryException && caughtEx_4 is not StackOverflowException) @@ -579,79 +536,4 @@ private async Task> ParseTorznabResponseAsync(string x return results; } - private long ParseSizeString(string sizeStr) - { - if (string.IsNullOrWhiteSpace(sizeStr)) - return 0; - - // Try parsing as a plain number first (bytes) - if (long.TryParse(sizeStr, out var bytes)) - return bytes; - - // Parse human-readable sizes like "1.5 GB", "3.7 GiB", "500 MB", etc. - // Support both binary (GiB, MiB, TiB, KiB) and decimal (GB, MB, TB, KB) units - var match = Regex.Match(sizeStr, @"([\d\.]+)\s*([KMGT]i?B)", RegexOptions.IgnoreCase); - if (!match.Success) - return 0; - - if (!double.TryParse(match.Groups[1].Value, out var size)) - return 0; - - var unit = match.Groups[2].Value.ToUpper(); - return unit switch - { - "TIB" => (long)(size * 1024 * 1024 * 1024 * 1024), - "TB" => (long)(size * 1024 * 1024 * 1024 * 1024), - "GIB" => (long)(size * 1024 * 1024 * 1024), - "GB" => (long)(size * 1024 * 1024 * 1024), - "MIB" => (long)(size * 1024 * 1024), - "MB" => (long)(size * 1024 * 1024), - "KIB" => (long)(size * 1024), - "KB" => (long)(size * 1024), - "B" => (long)size, - _ => 0 - }; - } - - private string? ParseLanguageFromText(string text) - { - if (string.IsNullOrWhiteSpace(text)) return null; - - // Normalize whitespace - var normalized = Regex.Replace(text, "\\s+", " ", RegexOptions.Compiled | RegexOptions.IgnoreCase).Trim(); - - var codes = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - { "ENG", "English" }, { "EN", "English" }, - { "DUT", "Dutch" }, { "NL", "Dutch" }, - { "GER", "German" }, { "DE", "German" }, - { "FRE", "French" }, { "FR", "French" } - }; - - // Build a joined alternation like ENG|EN|DUT|NL|... - var alternation = string.Join("|", codes.Keys.Select(Regex.Escape)); - - // Bracketed or parenthesis forms: [ ENG / ... ] or (EN) - var bracketedPattern = $@"[\[\(]\s*(?:{alternation})\b"; - - // Standalone word boundary pattern: \b(ENG|EN|DUT|NL|...)\b - var standalonePattern = $@"\b(?:{alternation})\b"; - - // Try bracketed first (higher confidence) - var m = Regex.Match(normalized, bracketedPattern, RegexOptions.IgnoreCase); - if (m.Success) - { - var captured = Regex.Match(m.Value, $@"(?:{alternation})", RegexOptions.IgnoreCase); - if (captured.Success && codes.TryGetValue(captured.Value, out var lang)) - return lang; - } - - // Try standalone word boundary - m = Regex.Match(normalized, standalonePattern, RegexOptions.IgnoreCase); - if (m.Success && codes.TryGetValue(m.Value, out var lang2)) - return lang2; - - return null; - } } - diff --git a/listenarr.infrastructure/Search/Providers/TorznabNewznabValueParser.cs b/listenarr.infrastructure/Search/Providers/TorznabNewznabValueParser.cs new file mode 100644 index 000000000..5739a22da --- /dev/null +++ b/listenarr.infrastructure/Search/Providers/TorznabNewznabValueParser.cs @@ -0,0 +1,99 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +using System.Text.RegularExpressions; + +namespace Listenarr.Infrastructure.Search.Providers; + +internal static class TorznabNewznabValueParser +{ + public static long ParseSize(string sizeStr) + { + if (string.IsNullOrWhiteSpace(sizeStr)) + return 0; + + // Try parsing as a plain number first (bytes) + if (long.TryParse(sizeStr, out var bytes)) + return bytes; + + // Parse human-readable sizes like "1.5 GB", "3.7 GiB", "500 MB", etc. + // Support both binary (GiB, MiB, TiB, KiB) and decimal (GB, MB, TB, KB) units + var match = Regex.Match(sizeStr, @"([\d\.]+)\s*([KMGT]i?B)", RegexOptions.IgnoreCase); + if (!match.Success) + return 0; + + if (!double.TryParse(match.Groups[1].Value, out var size)) + return 0; + + var unit = match.Groups[2].Value.ToUpper(); + return unit switch + { + "TIB" => (long)(size * 1024 * 1024 * 1024 * 1024), + "TB" => (long)(size * 1024 * 1024 * 1024 * 1024), + "GIB" => (long)(size * 1024 * 1024 * 1024), + "GB" => (long)(size * 1024 * 1024 * 1024), + "MIB" => (long)(size * 1024 * 1024), + "MB" => (long)(size * 1024 * 1024), + "KIB" => (long)(size * 1024), + "KB" => (long)(size * 1024), + "B" => (long)size, + _ => 0 + }; + } + + public static string? ParseLanguageFromText(string text) + { + if (string.IsNullOrWhiteSpace(text)) return null; + + // Normalize whitespace + var normalized = Regex.Replace(text, "\\s+", " ", RegexOptions.Compiled | RegexOptions.IgnoreCase).Trim(); + + var codes = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "ENG", "English" }, { "EN", "English" }, + { "DUT", "Dutch" }, { "NL", "Dutch" }, + { "GER", "German" }, { "DE", "German" }, + { "FRE", "French" }, { "FR", "French" } + }; + + // Build a joined alternation like ENG|EN|DUT|NL|... + var alternation = string.Join("|", codes.Keys.Select(Regex.Escape)); + + // Bracketed or parenthesis forms: [ ENG / ... ] or (EN) + var bracketedPattern = $@"[\[\(]\s*(?:{alternation})\b"; + + // Standalone word boundary pattern: \b(ENG|EN|DUT|NL|...)\b + var standalonePattern = $@"\b(?:{alternation})\b"; + + // Try bracketed first (higher confidence) + var m = Regex.Match(normalized, bracketedPattern, RegexOptions.IgnoreCase); + if (m.Success) + { + var captured = Regex.Match(m.Value, $@"(?:{alternation})", RegexOptions.IgnoreCase); + if (captured.Success && codes.TryGetValue(captured.Value, out var lang)) + return lang; + } + + // Try standalone word boundary + m = Regex.Match(normalized, standalonePattern, RegexOptions.IgnoreCase); + if (m.Success && codes.TryGetValue(m.Value, out var lang2)) + return lang2; + + return null; + } +} From 27d472e91cd264779895ff3f0fd16ba980f5bb23 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Sat, 13 Jun 2026 20:54:14 -0400 Subject: [PATCH 45/84] refactor: trim mam response parser - Remove unused recursive MAM id lookup from the response parser - Leave active MyAnonamouseHelper lookup behavior unchanged --- .../Search/MyAnonamouseResponseParser.cs | 39 ------------------- 1 file changed, 39 deletions(-) diff --git a/listenarr.application/Search/MyAnonamouseResponseParser.cs b/listenarr.application/Search/MyAnonamouseResponseParser.cs index 456a889b2..48aa59ebb 100644 --- a/listenarr.application/Search/MyAnonamouseResponseParser.cs +++ b/listenarr.application/Search/MyAnonamouseResponseParser.cs @@ -685,44 +685,5 @@ public static List Parse(string jsonResponse, Indexer index return results; } - // Recursively search a JsonElement for a mam_id-like property (case-insensitive) - private static string? FindMamIdInJson(JsonElement element) - { - // Keys to look for - var keys = new HashSet(StringComparer.OrdinalIgnoreCase) { "mam_id", "mamid", "mamId", "mamID", "mam" }; - - if (element.ValueKind == JsonValueKind.Object) - { - foreach (var prop in element.EnumerateObject()) - { - try - { - if (keys.Contains(prop.Name) && prop.Value.ValueKind == JsonValueKind.String) - return prop.Value.GetString(); - - // Recurse into objects and arrays - if (prop.Value.ValueKind == JsonValueKind.Object || prop.Value.ValueKind == JsonValueKind.Array) - { - var found = FindMamIdInJson(prop.Value); - if (!string.IsNullOrEmpty(found)) return found; - } - } - catch (Exception caughtEx_20) when (caughtEx_20 is not OperationCanceledException && caughtEx_20 is not OutOfMemoryException && caughtEx_20 is not StackOverflowException) - { /* ignore malformed inner values */ - System.Diagnostics.Debug.WriteLine("Suppressed non-fatal exception in catch block."); - } - } - } - else if (element.ValueKind == JsonValueKind.Array) - { - var found = element.EnumerateArray() - .Select(FindMamIdInJson) - .FirstOrDefault(value => !string.IsNullOrEmpty(value)); - if (!string.IsNullOrEmpty(found)) return found; - } - - return null; - } - } } From 9b564f124e16490a29f43e222f99219bac46d0be Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Sat, 13 Jun 2026 20:57:34 -0400 Subject: [PATCH 46/84] refactor: extract nzbget authentication - Centralize NZBGet Basic Auth header construction - Remove unused version parsing helper from the adapter --- .../Adapters/NzbgetAdapter.cs | 35 ++--------------- .../Adapters/NzbgetAuthentication.cs | 38 +++++++++++++++++++ 2 files changed, 42 insertions(+), 31 deletions(-) create mode 100644 listenarr.infrastructure/Adapters/NzbgetAuthentication.cs diff --git a/listenarr.infrastructure/Adapters/NzbgetAdapter.cs b/listenarr.infrastructure/Adapters/NzbgetAdapter.cs index c4e79f746..7db3e878a 100644 --- a/listenarr.infrastructure/Adapters/NzbgetAdapter.cs +++ b/listenarr.infrastructure/Adapters/NzbgetAdapter.cs @@ -16,7 +16,6 @@ * along with this program. If not, see . */ using System.Net; -using System.Net.Http.Headers; using System.Text; using System.Text.Json; using Listenarr.Application.Interfaces; @@ -99,20 +98,6 @@ public NzbgetAdapter( } } - private static bool IsVersion25OrNewer(string version) - { - if (string.IsNullOrWhiteSpace(version)) return false; - - // Version format: "25.4" or "25.4-testing" - var parts = version.Split(new[] { '.', '-' }, StringSplitOptions.RemoveEmptyEntries); - if (parts.Length > 0 && int.TryParse(parts[0], out var major)) - { - return major >= 25; - } - - return false; - } - public async Task AddAsync(DownloadClientConfiguration client, SearchResult result, CancellationToken ct = default) { if (client == null) throw new ArgumentNullException(nameof(client)); @@ -172,7 +157,7 @@ private static bool IsVersion25OrNewer(string version) }; // Add Basic Auth (NZBGet v25 REST API accepts Basic Auth) - var authHeader = BuildAuthHeader(client); + var authHeader = NzbgetAuthentication.BuildAuthHeader(client); if (authHeader != null) { request.Headers.Authorization = authHeader; @@ -621,17 +606,6 @@ public async Task GetImportItemAsync( } } - private static AuthenticationHeaderValue? BuildAuthHeader(DownloadClientConfiguration client) - { - if (string.IsNullOrWhiteSpace(client.Username)) - { - return null; - } - - var credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{client.Username}:{client.Password}")); - return new AuthenticationHeaderValue("Basic", credentials); - } - /// /// Resolves the actual import item for a completed download. /// Queries NZBGet history for FinalDir or DestDir. @@ -723,11 +697,10 @@ public async Task> FetchDownloadsAsync( using var http = _httpClientFactory.CreateClient(ClientType); // Add basic auth if credentials provided - if (!string.IsNullOrEmpty(client.Username)) + var authHeader = NzbgetAuthentication.BuildAuthHeader(client); + if (authHeader != null) { - var authBytes = Encoding.UTF8.GetBytes($"{client.Username}:{client.Password}"); - var authHeader = Convert.ToBase64String(authBytes); - http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", authHeader); + http.DefaultRequestHeaders.Authorization = authHeader; } // Get active downloads from status for progress updates diff --git a/listenarr.infrastructure/Adapters/NzbgetAuthentication.cs b/listenarr.infrastructure/Adapters/NzbgetAuthentication.cs new file mode 100644 index 000000000..405a4af33 --- /dev/null +++ b/listenarr.infrastructure/Adapters/NzbgetAuthentication.cs @@ -0,0 +1,38 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +using System.Net.Http.Headers; +using System.Text; +using Listenarr.Domain.Models; + +namespace Listenarr.Infrastructure.Adapters +{ + internal static class NzbgetAuthentication + { + public static AuthenticationHeaderValue? BuildAuthHeader(DownloadClientConfiguration client) + { + if (string.IsNullOrWhiteSpace(client.Username)) + { + return null; + } + + var credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{client.Username}:{client.Password}")); + return new AuthenticationHeaderValue("Basic", credentials); + } + } +} From b086e89cf676537ee71eb2229a704b6e2a2e2943 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Sat, 13 Jun 2026 20:59:46 -0400 Subject: [PATCH 47/84] refactor: extract sabnzbd json parsing - Move defensive SABnzbd numeric JSON parsing into the response mapper - Keep queue progress mapping behavior unchanged --- .../Adapters/SabnzbdAdapter.cs | 28 ++----------------- .../Adapters/SabnzbdResponseMapper.cs | 23 +++++++++++++++ 2 files changed, 25 insertions(+), 26 deletions(-) diff --git a/listenarr.infrastructure/Adapters/SabnzbdAdapter.cs b/listenarr.infrastructure/Adapters/SabnzbdAdapter.cs index 4a540f011..996a6765e 100644 --- a/listenarr.infrastructure/Adapters/SabnzbdAdapter.cs +++ b/listenarr.infrastructure/Adapters/SabnzbdAdapter.cs @@ -760,32 +760,8 @@ public async Task> FetchDownloadsAsync( continue; } - // SABnzbd sometimes returns numeric values as numbers or strings. - // Be defensive and accept either JSON number or JSON string. - double GetDoubleValue(System.Text.Json.JsonElement el) - { - try - { - if (el.ValueKind == System.Text.Json.JsonValueKind.Number) - return el.GetDouble(); - - if (el.ValueKind == System.Text.Json.JsonValueKind.String) - { - var s = el.GetString(); - if (double.TryParse(s, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out var v)) - return v; - } - } - catch (Exception caughtEx_10) when (caughtEx_10 is not OperationCanceledException && caughtEx_10 is not OutOfMemoryException && caughtEx_10 is not StackOverflowException) - { - System.Diagnostics.Debug.WriteLine("Suppressed non-fatal exception in catch block."); - } - - return 0.0; - } - - var percentage = slot.TryGetProperty("percentage", out var percentageProp) ? GetDoubleValue(percentageProp) : 0.0; - var mbleft = slot.TryGetProperty("mbleft", out var mbleftProp) ? GetDoubleValue(mbleftProp) : 0.0; + var percentage = slot.TryGetProperty("percentage", out var percentageProp) ? SabnzbdResponseMapper.ParseJsonDouble(percentageProp) : 0.0; + var mbleft = slot.TryGetProperty("mbleft", out var mbleftProp) ? SabnzbdResponseMapper.ParseJsonDouble(mbleftProp) : 0.0; var status = slot.TryGetProperty("status", out var statusProp) ? statusProp.GetString() ?? "" : ""; // Calculate progress and update diff --git a/listenarr.infrastructure/Adapters/SabnzbdResponseMapper.cs b/listenarr.infrastructure/Adapters/SabnzbdResponseMapper.cs index 4f214873d..f1cdd8fed 100644 --- a/listenarr.infrastructure/Adapters/SabnzbdResponseMapper.cs +++ b/listenarr.infrastructure/Adapters/SabnzbdResponseMapper.cs @@ -17,6 +17,7 @@ */ using System.Text.Json; +using System.Globalization; using Listenarr.Domain.Common; using Listenarr.Domain.Models; @@ -205,6 +206,28 @@ public static double ParseSpeed(string speedStr) return value; } + public static double ParseJsonDouble(JsonElement element) + { + try + { + if (element.ValueKind == JsonValueKind.Number) + return element.GetDouble(); + + if (element.ValueKind == JsonValueKind.String) + { + var value = element.GetString(); + if (double.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var parsed)) + return parsed; + } + } + catch (Exception caughtEx) when (caughtEx is not OperationCanceledException && caughtEx is not OutOfMemoryException && caughtEx is not StackOverflowException) + { + System.Diagnostics.Debug.WriteLine("Suppressed non-fatal exception in catch block."); + } + + return 0.0; + } + private static int ParseTimeLeft(string timeLeft) { if (string.IsNullOrWhiteSpace(timeLeft)) return 0; From 55c1535dfef8392aa96851d16b45989e009e6562 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Sat, 13 Jun 2026 21:01:47 -0400 Subject: [PATCH 48/84] refactor: extract image identifier validation - Move GetImage identifier normalization and validation into a focused helper - Preserve image route responses and fallback behavior --- .../ImageIdentifierRequestValidator.cs | 61 +++++++++++++++++++ listenarr.api/Controllers/ImagesController.cs | 15 ++--- 2 files changed, 65 insertions(+), 11 deletions(-) create mode 100644 listenarr.api/Controllers/ImageIdentifierRequestValidator.cs diff --git a/listenarr.api/Controllers/ImageIdentifierRequestValidator.cs b/listenarr.api/Controllers/ImageIdentifierRequestValidator.cs new file mode 100644 index 000000000..ff722d4c4 --- /dev/null +++ b/listenarr.api/Controllers/ImageIdentifierRequestValidator.cs @@ -0,0 +1,61 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace Listenarr.Api.Controllers +{ + internal enum ImageIdentifierValidationFailure + { + None, + Missing, + Invalid + } + + internal readonly record struct ImageIdentifierValidationResult( + string Identifier, + ImageIdentifierValidationFailure Failure) + { + public bool IsValid => Failure == ImageIdentifierValidationFailure.None; + } + + internal static class ImageIdentifierRequestValidator + { + public static ImageIdentifierValidationResult ValidateGetImageIdentifier(string identifier) + { + if (string.IsNullOrEmpty(identifier)) + { + return new ImageIdentifierValidationResult(identifier, ImageIdentifierValidationFailure.Missing); + } + + // Strip any query parameters from the identifier (e.g., "B0CQZ5167B?access_token=..." -> "B0CQZ5167B") + var queryIndex = identifier.IndexOf('?'); + if (queryIndex >= 0) + { + identifier = identifier.Substring(0, queryIndex); + } + + // Validate identifier to prevent path traversal or overly long values. + // Identifiers should be simple ASINs, numeric IDs or author names-disallow path separators. + if (identifier.IndexOfAny(new char[] { '\\', '/', '\0' }) >= 0 || identifier.Length > 256) + { + return new ImageIdentifierValidationResult(identifier, ImageIdentifierValidationFailure.Invalid); + } + + return new ImageIdentifierValidationResult(identifier, ImageIdentifierValidationFailure.None); + } + } +} diff --git a/listenarr.api/Controllers/ImagesController.cs b/listenarr.api/Controllers/ImagesController.cs index b7e1301fb..2c17baee5 100644 --- a/listenarr.api/Controllers/ImagesController.cs +++ b/listenarr.api/Controllers/ImagesController.cs @@ -100,21 +100,14 @@ public ImagesController( [HttpGet("{identifier}")] public async Task GetImage(string identifier) { - if (string.IsNullOrEmpty(identifier)) + var identifierValidation = ImageIdentifierRequestValidator.ValidateGetImageIdentifier(identifier); + if (identifierValidation.Failure == ImageIdentifierValidationFailure.Missing) { return BadRequest("Identifier is required"); } - // Strip any query parameters from the identifier (e.g., "B0CQZ5167B?access_token=..." -> "B0CQZ5167B") - var queryIndex = identifier.IndexOf('?'); - if (queryIndex >= 0) - { - identifier = identifier.Substring(0, queryIndex); - } - - // Validate identifier to prevent path traversal or overly long values. - // Identifiers should be simple ASINs, numeric IDs or author names—disallow path separators. - if (identifier.IndexOfAny(new char[] { '\\', '/', '\0' }) >= 0 || identifier.Length > 256) + identifier = identifierValidation.Identifier; + if (identifierValidation.Failure == ImageIdentifierValidationFailure.Invalid) { _logger.LogWarning("Rejected invalid identifier: {Identifier}", LogRedaction.SanitizeText(identifier)); return BadRequest("Invalid identifier"); From 074f9471b43f820d24cf6e7e4025b4acaf8f4c40 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Sat, 13 Jun 2026 21:04:07 -0400 Subject: [PATCH 49/84] refactor: extract qbittorrent add content - Move qBittorrent torrent and URL add request content construction into a helper - Keep adapter authentication, posting, and tracker fallback behavior unchanged --- .../Adapters/QbittorrentAdapter.cs | 40 +----------- .../QbittorrentAddRequestContentBuilder.cs | 61 +++++++++++++++++++ 2 files changed, 63 insertions(+), 38 deletions(-) create mode 100644 listenarr.infrastructure/Adapters/QbittorrentAddRequestContentBuilder.cs diff --git a/listenarr.infrastructure/Adapters/QbittorrentAdapter.cs b/listenarr.infrastructure/Adapters/QbittorrentAdapter.cs index 419897aea..cae350d3e 100644 --- a/listenarr.infrastructure/Adapters/QbittorrentAdapter.cs +++ b/listenarr.infrastructure/Adapters/QbittorrentAdapter.cs @@ -83,44 +83,8 @@ public QbittorrentAdapter(IHttpClientFactory httpFactory, ITorrentFileDownloader return null; } - // Add download using torrent file - HttpResponseMessage addResponse; - if (addPlan.TorrentFileData != null) - { - using var multipart = new MultipartFormDataContent(); - multipart.Add(new StringContent(addPlan.SavePath), "savepath"); - if (!string.IsNullOrEmpty(addPlan.Category)) - multipart.Add(new StringContent(addPlan.Category), "category"); - if (!string.IsNullOrEmpty(addPlan.Tags)) - multipart.Add(new StringContent(addPlan.Tags), "tags"); - - var torrentFileName = string.IsNullOrEmpty(result.TorrentFileName) ? "download.torrent" : result.TorrentFileName; - var torrentContent = new ByteArrayContent(addPlan.TorrentFileData); - torrentContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/x-bittorrent"); - multipart.Add(torrentContent, "torrents", torrentFileName); - - addResponse = await httpClient.PostAsync($"{baseUrl}/api/v2/torrents/add", multipart, ct); - } - // Add using magnet link or torrent url - else - { - var url = new[] { addPlan.MagnetLink, addPlan.HttpTorrentUrl } - .FirstOrDefault(static url => !string.IsNullOrEmpty(url)) ?? string.Empty; - - var formData = new List> - { - new("urls", url), - new("savepath", addPlan.SavePath) - }; - - if (!string.IsNullOrEmpty(addPlan.Category)) - formData.Add(new("category", addPlan.Category)); - if (!string.IsNullOrEmpty(addPlan.Tags)) - formData.Add(new("tags", addPlan.Tags)); - - using var addData = new FormUrlEncodedContent(formData); - addResponse = await httpClient.PostAsync($"{baseUrl}/api/v2/torrents/add", addData, ct); - } + using var addContent = QbittorrentAddRequestContentBuilder.Build(addPlan, result); + var addResponse = await httpClient.PostAsync($"{baseUrl}/api/v2/torrents/add", addContent, ct); if (!addResponse.IsSuccessStatusCode) { diff --git a/listenarr.infrastructure/Adapters/QbittorrentAddRequestContentBuilder.cs b/listenarr.infrastructure/Adapters/QbittorrentAddRequestContentBuilder.cs new file mode 100644 index 000000000..5c528d888 --- /dev/null +++ b/listenarr.infrastructure/Adapters/QbittorrentAddRequestContentBuilder.cs @@ -0,0 +1,61 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +using System.Net.Http.Headers; +using Listenarr.Domain.Models; + +namespace Listenarr.Infrastructure.Adapters +{ + internal static class QbittorrentAddRequestContentBuilder + { + public static HttpContent Build(QbittorrentTorrentAddPlan addPlan, SearchResult result) + { + if (addPlan.TorrentFileData != null) + { + var multipart = new MultipartFormDataContent(); + multipart.Add(new StringContent(addPlan.SavePath), "savepath"); + if (!string.IsNullOrEmpty(addPlan.Category)) + multipart.Add(new StringContent(addPlan.Category), "category"); + if (!string.IsNullOrEmpty(addPlan.Tags)) + multipart.Add(new StringContent(addPlan.Tags), "tags"); + + var torrentFileName = string.IsNullOrEmpty(result.TorrentFileName) ? "download.torrent" : result.TorrentFileName; + var torrentContent = new ByteArrayContent(addPlan.TorrentFileData); + torrentContent.Headers.ContentType = new MediaTypeHeaderValue("application/x-bittorrent"); + multipart.Add(torrentContent, "torrents", torrentFileName); + return multipart; + } + + var url = new[] { addPlan.MagnetLink, addPlan.HttpTorrentUrl } + .FirstOrDefault(static url => !string.IsNullOrEmpty(url)) ?? string.Empty; + + var formData = new List> + { + new("urls", url), + new("savepath", addPlan.SavePath) + }; + + if (!string.IsNullOrEmpty(addPlan.Category)) + formData.Add(new("category", addPlan.Category)); + if (!string.IsNullOrEmpty(addPlan.Tags)) + formData.Add(new("tags", addPlan.Tags)); + + return new FormUrlEncodedContent(formData); + } + } +} From a9eb12816e9cd41c68849a88f6af79bd383e4f3f Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Sat, 13 Jun 2026 21:24:10 -0400 Subject: [PATCH 50/84] refactor: extract lookup workflows - Move metadata author and series lookup orchestration into focused workflows - Extract qBittorrent torrent lookup and SABnzbd history parsing helpers - Move metadata-driven image fallback downloads out of ImagesController --- .../ImageFallbackDownloadWorkflow.cs | 59 +++ listenarr.api/Controllers/ImagesController.cs | 30 +- .../MetadataAuthorLookupWorkflow.cs | 373 +++++++++++++++ .../Controllers/MetadataController.cs | 440 ++---------------- .../MetadataSeriesLookupWorkflow.cs | 194 ++++++++ .../Adapters/QbittorrentAdapter.cs | 37 +- .../QbittorrentTorrentLookupBuilder.cs | 61 +++ .../Adapters/SabnzbdAdapter.cs | 40 +- .../Adapters/SabnzbdHistoryLookupBuilder.cs | 77 +++ 9 files changed, 811 insertions(+), 500 deletions(-) create mode 100644 listenarr.api/Controllers/ImageFallbackDownloadWorkflow.cs create mode 100644 listenarr.api/Controllers/MetadataAuthorLookupWorkflow.cs create mode 100644 listenarr.api/Controllers/MetadataSeriesLookupWorkflow.cs create mode 100644 listenarr.infrastructure/Adapters/QbittorrentTorrentLookupBuilder.cs create mode 100644 listenarr.infrastructure/Adapters/SabnzbdHistoryLookupBuilder.cs diff --git a/listenarr.api/Controllers/ImageFallbackDownloadWorkflow.cs b/listenarr.api/Controllers/ImageFallbackDownloadWorkflow.cs new file mode 100644 index 000000000..df8059f40 --- /dev/null +++ b/listenarr.api/Controllers/ImageFallbackDownloadWorkflow.cs @@ -0,0 +1,59 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +using Listenarr.Application.Interfaces; +using Listenarr.Application.Security; + +namespace Listenarr.Api.Controllers +{ + internal sealed class ImageFallbackDownloadWorkflow + { + private readonly IImageCacheService _imageCacheService; + private readonly ILogger _logger; + + public ImageFallbackDownloadWorkflow(IImageCacheService imageCacheService, ILogger logger) + { + _imageCacheService = imageCacheService; + _logger = logger; + } + + public async Task TryDownloadFirstCachedAsync(string identifier, IEnumerable candidateUrls) + { + foreach (var urlCandidate in candidateUrls) + { + _logger.LogInformation("Attempting metadata-driven image download for identifier {Identifier} from {Url}", LogRedaction.SanitizeText(identifier), LogRedaction.SanitizeText(urlCandidate)); + try + { + _logger.LogDebug("Calling DownloadAndCacheImageAsync for {Identifier} from {Url}", LogRedaction.SanitizeText(identifier), LogRedaction.SanitizeText(urlCandidate)); + var downloaded = await _imageCacheService.DownloadAndCacheImageAsync(urlCandidate, identifier); + if (!string.IsNullOrWhiteSpace(downloaded)) + { + _logger.LogInformation("Downloaded metadata image for identifier: {Identifier}", LogRedaction.SanitizeText(identifier)); + var relativePath = await _imageCacheService.GetCachedImagePathAsync(identifier); + if (!string.IsNullOrWhiteSpace(relativePath)) + { + return relativePath; + } + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) when (ImageIdentifierHelper.IsRecoverableImageLookupException(ex)) + { + _logger.LogWarning(ex, "Failed to download metadata-driven image for {Identifier} from {Url}", LogRedaction.SanitizeText(identifier), LogRedaction.SanitizeText(urlCandidate)); + } + } + + return null; + } + } +} diff --git a/listenarr.api/Controllers/ImagesController.cs b/listenarr.api/Controllers/ImagesController.cs index 2c17baee5..b72b0f9fd 100644 --- a/listenarr.api/Controllers/ImagesController.cs +++ b/listenarr.api/Controllers/ImagesController.cs @@ -42,6 +42,7 @@ public class ImagesController : ControllerBase private readonly ImageResponseBuilder _imageResponseBuilder; private readonly ImagePathValidator _imagePathValidator; private readonly ImageCachedPathValidator _cachedPathValidator; + private readonly ImageFallbackDownloadWorkflow _fallbackDownloadWorkflow; private readonly string _effectiveContentRootPath; [ActivatorUtilitiesConstructor] @@ -90,6 +91,7 @@ public ImagesController( _imageResponseBuilder = new ImageResponseBuilder(_placeholderResolver, _logger, _effectiveContentRootPath); _imagePathValidator = new ImagePathValidator(_effectiveContentRootPath); _cachedPathValidator = new ImageCachedPathValidator(_imagePathValidator, _logger); + _fallbackDownloadWorkflow = new ImageFallbackDownloadWorkflow(_imageCacheService, _logger); } /// @@ -778,33 +780,7 @@ void AddCandidateUrl(string? url, string source) if (candidateUrls.Count > 0) { - foreach (var urlCandidate in candidateUrls) - { - _logger.LogInformation("Attempting metadata-driven image download for identifier {Identifier} from {Url}", LogRedaction.SanitizeText(identifier), LogRedaction.SanitizeText(urlCandidate)); - try - { - _logger.LogDebug("Calling DownloadAndCacheImageAsync for {Identifier} from {Url}", LogRedaction.SanitizeText(identifier), LogRedaction.SanitizeText(urlCandidate)); - var downloaded = await _imageCacheService.DownloadAndCacheImageAsync(urlCandidate, identifier!); - if (!string.IsNullOrWhiteSpace(downloaded)) - { - _logger.LogInformation("Downloaded metadata image for identifier: {Identifier}", LogRedaction.SanitizeText(identifier)); - // Re-check cache - relativePath = await _imageCacheService.GetCachedImagePathAsync(identifier!); - if (!string.IsNullOrWhiteSpace(relativePath)) - { - break; - } - } - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) when (ImageIdentifierHelper.IsRecoverableImageLookupException(ex)) - { - _logger.LogWarning(ex, "Failed to download metadata-driven image for {Identifier} from {Url}", LogRedaction.SanitizeText(identifier), LogRedaction.SanitizeText(urlCandidate)); - } - } + relativePath = await _fallbackDownloadWorkflow.TryDownloadFirstCachedAsync(identifier!, candidateUrls); } } } diff --git a/listenarr.api/Controllers/MetadataAuthorLookupWorkflow.cs b/listenarr.api/Controllers/MetadataAuthorLookupWorkflow.cs new file mode 100644 index 000000000..86f66ff51 --- /dev/null +++ b/listenarr.api/Controllers/MetadataAuthorLookupWorkflow.cs @@ -0,0 +1,373 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +using Listenarr.Application.Interfaces; +using Listenarr.Application.Metadata; +using Microsoft.Extensions.Caching.Memory; + +namespace Listenarr.Api.Controllers +{ + internal enum MetadataAuthorLookupStatus + { + Ok, + BadRequest, + NotFound, + Error + } + + internal sealed record MetadataAuthorLookupResult( + MetadataAuthorLookupStatus Status, + MetadataController.AuthorLookupResponse? Response, + string? Message) + { + public static MetadataAuthorLookupResult Ok(MetadataController.AuthorLookupResponse response) => + new(MetadataAuthorLookupStatus.Ok, response, null); + + public static MetadataAuthorLookupResult BadRequest(string message) => + new(MetadataAuthorLookupStatus.BadRequest, null, message); + + public static MetadataAuthorLookupResult NotFound(string message) => + new(MetadataAuthorLookupStatus.NotFound, null, message); + + public static MetadataAuthorLookupResult Error(string message) => + new(MetadataAuthorLookupStatus.Error, null, message); + } + + internal sealed class MetadataAuthorLookupWorkflow + { + private readonly AudibleService _audibleService; + private readonly IAudnexusService _audnexusService; + private readonly IImageCacheService _imageCacheService; + private readonly IMemoryCache _cache; + private readonly MetadataImageCacheWorkflow _imageCacheWorkflow; + private readonly MetadataLookupCacheWorkflow _lookupCacheWorkflow; + private readonly MetadataLookupResponseCache _lookupResponseCache; + private readonly ILogger _logger; + + public MetadataAuthorLookupWorkflow( + AudibleService audibleService, + IAudnexusService audnexusService, + IImageCacheService imageCacheService, + IMemoryCache cache, + MetadataImageCacheWorkflow imageCacheWorkflow, + MetadataLookupCacheWorkflow lookupCacheWorkflow, + MetadataLookupResponseCache lookupResponseCache, + ILogger logger) + { + _audibleService = audibleService; + _audnexusService = audnexusService; + _imageCacheService = imageCacheService; + _cache = cache; + _imageCacheWorkflow = imageCacheWorkflow; + _lookupCacheWorkflow = lookupCacheWorkflow; + _lookupResponseCache = lookupResponseCache; + _logger = logger; + } + + public async Task LookupAsync( + string name, + string region, + string? asin, + bool refresh) + { + try + { + if (string.IsNullOrWhiteSpace(name)) return MetadataAuthorLookupResult.BadRequest("Author name is required"); + + var normalizedName = name.Trim(); + var normalizedAsin = string.IsNullOrWhiteSpace(asin) ? null : asin.Trim(); + var cacheKey = MetadataCacheKeys.BuildAuthorLookupCacheKey(region, normalizedName, normalizedAsin); + string? seededName = null; + string? seededImage = null; + string? seededDescription = null; + string? seededCachedPath = null; + var seededSimilarAuthors = new List(); + + if (refresh) + { + _cache.Remove(cacheKey); + } + else if (_cache.TryGetValue(cacheKey, out MetadataAuthorLookupCacheEntry? cachedEntry) && cachedEntry != null) + { + cachedEntry.Asin ??= normalizedAsin; + + if (cachedEntry.NotFound) + { + var notFoundCacheProbe = await _imageCacheWorkflow.ProbeAuthorImageCacheAsync(normalizedName, region, cachedEntry.Asin); + if (!string.IsNullOrWhiteSpace(notFoundCacheProbe.CachedPath)) + { + cachedEntry.Asin = notFoundCacheProbe.Asin ?? cachedEntry.Asin; + cachedEntry.CachedPath = notFoundCacheProbe.CachedPath; + cachedEntry.Name ??= normalizedName; + cachedEntry.NotFound = false; + _cache.Set(cacheKey, cachedEntry, new MemoryCacheEntryOptions { SlidingExpiration = TimeSpan.FromHours(12) }); + + return MetadataAuthorLookupResult.Ok(_lookupResponseCache.MapAuthorLookupResponse(cachedEntry, normalizedName)); + } + + return MetadataAuthorLookupResult.NotFound("Author not found"); + } + + string? cachedPath = cachedEntry.CachedPath; + if (!string.IsNullOrWhiteSpace(cachedEntry.Asin)) + { + cachedPath = await _imageCacheWorkflow.ResolveCachedImagePathAsync(cachedEntry.Asin) ?? cachedPath; + } + + cachedEntry.CachedPath = cachedPath; + + if (MetadataResponseMapper.HasCompleteAuthorLookupData(cachedEntry.CachedPath, cachedEntry.Description, cachedEntry.SimilarAuthors)) + { + return MetadataAuthorLookupResult.Ok(_lookupResponseCache.MapAuthorLookupResponse(cachedEntry, normalizedName)); + } + + normalizedAsin ??= cachedEntry.Asin; + seededName = cachedEntry.Name; + seededImage = cachedEntry.Image; + seededDescription = cachedEntry.Description; + seededCachedPath = cachedPath; + seededSimilarAuthors = cachedEntry.SimilarAuthors? + .Where(author => !string.IsNullOrWhiteSpace(author.Name)) + .ToList() ?? new List(); + } + + var persistedEntry = await _lookupCacheWorkflow.ResolvePersistedAuthorCacheAsync(normalizedName, region, normalizedAsin); + if (persistedEntry != null) + { + var persistedResponse = await _lookupCacheWorkflow.MapPersistedAuthorLookupResponseAsync(persistedEntry, normalizedName); + if (!refresh && + MetadataResponseMapper.HasCompleteAuthorLookupData(persistedResponse.CachedPath, persistedResponse.Description, persistedResponse.SimilarAuthors)) + { + _lookupResponseCache.CacheAuthorLookupResponse(cacheKey, persistedResponse); + return MetadataAuthorLookupResult.Ok(persistedResponse); + } + + normalizedAsin ??= persistedResponse.Asin; + seededName ??= persistedResponse.Name; + seededImage ??= persistedResponse.Image; + seededDescription ??= persistedResponse.Description; + seededCachedPath ??= persistedResponse.CachedPath; + if (seededSimilarAuthors.Count == 0 && persistedResponse.SimilarAuthors.Count > 0) + { + seededSimilarAuthors = persistedResponse.SimilarAuthors + .Where(author => !string.IsNullOrWhiteSpace(author.Name)) + .ToList(); + } + } + + var cacheHint = await _imageCacheWorkflow.ProbeAuthorImageCacheAsync(normalizedName, region, normalizedAsin); + var resolvedAsin = normalizedAsin ?? cacheHint.Asin; + var cached = seededCachedPath ?? cacheHint.CachedPath; + var needsDescription = refresh || string.IsNullOrWhiteSpace(seededDescription); + var needsSimilarAuthors = refresh || seededSimilarAuthors.Count == 0; + var needsCachedImage = refresh || string.IsNullOrWhiteSpace(cached); + var needsAuthorDetails = string.IsNullOrWhiteSpace(resolvedAsin) || + string.IsNullOrWhiteSpace(seededName) || + string.IsNullOrWhiteSpace(seededImage) || + needsDescription || + needsCachedImage || + refresh; + + AuthorLookupItem? info = null; + AuthorLookupItem? authorDetails = null; + string? resolvedName = seededName; + string? resolvedImage = seededImage; + string? resolvedDescription = seededDescription; + + if (!string.IsNullOrWhiteSpace(resolvedAsin) && needsAuthorDetails) + { + authorDetails = await _audibleService.GetAuthorByAsinAsync(resolvedAsin, region); + } + + if (authorDetails == null && needsAuthorDetails) + { + info = await _audibleService.LookupAuthorAsync(normalizedName, region); + } + + resolvedAsin ??= authorDetails?.Asin ?? info?.Asin; + + if (authorDetails == null && !string.IsNullOrWhiteSpace(resolvedAsin) && needsAuthorDetails) + { + authorDetails = await _audibleService.GetAuthorByAsinAsync(resolvedAsin, region); + } + + resolvedName ??= authorDetails?.Name ?? info?.Name; + + var audibleImage = authorDetails?.Image ?? info?.Image; + if (!string.IsNullOrWhiteSpace(audibleImage) && + (string.IsNullOrWhiteSpace(resolvedImage) || needsCachedImage)) + { + resolvedImage = audibleImage; + } + + var audibleDescription = authorDetails?.Description ?? info?.Description; + if (!string.IsNullOrWhiteSpace(audibleDescription)) + { + resolvedDescription = audibleDescription; + } + + AudnexusAuthorSearchResult? audnexusSearchAuthor = null; + AudnexusAuthorResponse? audnexusAuthor = null; + var shouldQueryAudnexus = + refresh || + string.IsNullOrWhiteSpace(resolvedAsin) || + string.IsNullOrWhiteSpace(resolvedName) || + string.IsNullOrWhiteSpace(resolvedDescription) || + string.IsNullOrWhiteSpace(resolvedImage) || + needsSimilarAuthors || + (needsCachedImage && string.IsNullOrWhiteSpace(audibleImage)); + + if (!string.IsNullOrWhiteSpace(resolvedAsin) && shouldQueryAudnexus) + { + try + { + audnexusAuthor = await _audnexusService.GetAuthorAsync(resolvedAsin, region, update: false); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning(ex, "Audnexus author details fallback failed for '{Author}'", normalizedName); + } + } + + if (shouldQueryAudnexus && (authorDetails == null || audnexusAuthor == null || string.IsNullOrWhiteSpace(resolvedDescription))) + { + try + { + var audnexResults = await _audnexusService.SearchAuthorsAsync(normalizedName, region); + audnexusSearchAuthor = audnexResults?.FirstOrDefault(a => + !string.IsNullOrWhiteSpace(a.Name) && + a.Name.Equals(normalizedName, StringComparison.OrdinalIgnoreCase)) + ?? audnexResults?.FirstOrDefault(a => + !string.IsNullOrWhiteSpace(a.Asin) && + string.Equals(a.Asin, resolvedAsin, StringComparison.OrdinalIgnoreCase)) + ?? audnexResults?.FirstOrDefault(); + + if (audnexusSearchAuthor != null) + { + resolvedAsin ??= audnexusSearchAuthor.Asin; + resolvedName ??= audnexusSearchAuthor.Name; + resolvedImage ??= audnexusSearchAuthor.Image; + resolvedDescription ??= audnexusSearchAuthor.Description; + + if (audnexusAuthor == null && !string.IsNullOrWhiteSpace(audnexusSearchAuthor.Asin)) + { + audnexusAuthor = await _audnexusService.GetAuthorAsync(audnexusSearchAuthor.Asin, region, update: false); + } + } + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning(ex, "Audnexus author fallback failed for '{Author}'", normalizedName); + } + } + + if (audnexusAuthor != null) + { + resolvedAsin ??= audnexusAuthor.Asin; + resolvedName ??= audnexusAuthor.Name; + resolvedDescription ??= audnexusAuthor.Description; + } + + var audnexusImage = audnexusAuthor?.Image ?? audnexusSearchAuthor?.Image; + if (!string.IsNullOrWhiteSpace(audnexusImage) && + (string.IsNullOrWhiteSpace(resolvedImage) || + (needsCachedImage && string.IsNullOrWhiteSpace(audibleImage)))) + { + resolvedImage = audnexusImage; + } + + var hasResolvedAuthorIdentity = + !string.IsNullOrWhiteSpace(resolvedAsin) || + !string.IsNullOrWhiteSpace(authorDetails?.Name) || + !string.IsNullOrWhiteSpace(info?.Name) || + !string.IsNullOrWhiteSpace(audnexusAuthor?.Name) || + !string.IsNullOrWhiteSpace(audnexusSearchAuthor?.Name); + + if (!hasResolvedAuthorIdentity) + { + _lookupResponseCache.CacheAuthorNotFound(cacheKey, normalizedName); + return MetadataAuthorLookupResult.NotFound("Author not found"); + } + + resolvedName ??= + authorDetails?.Name ?? + info?.Name ?? + audnexusAuthor?.Name ?? + audnexusSearchAuthor?.Name ?? + normalizedName; + + try + { + if (!refresh && + string.IsNullOrWhiteSpace(cached) && + !string.IsNullOrWhiteSpace(resolvedAsin)) + { + cached = await _imageCacheWorkflow.ResolveCachedImagePathAsync(resolvedAsin); + } + + if ((refresh || string.IsNullOrWhiteSpace(cached)) && + !string.IsNullOrWhiteSpace(resolvedAsin)) + { + var preferredImageForCaching = + authorDetails?.Image ?? + info?.Image ?? + audnexusAuthor?.Image ?? + audnexusSearchAuthor?.Image ?? + resolvedImage; + + cached = await _imageCacheService.MoveToAuthorLibraryStorageAsync( + resolvedAsin, + preferredImageForCaching, + forceRefresh: refresh); + if (!string.IsNullOrWhiteSpace(cached)) cached = "/" + cached.TrimStart('/'); + } + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning(ex, "Failed to cache author image for {Author}", name); + } + + var similarAuthors = MetadataResponseMapper.MapSimilarAuthors( + audnexusAuthor?.Similar ?? audnexusSearchAuthor?.Similar, + normalizedName); + if (similarAuthors.Count == 0 && seededSimilarAuthors.Count > 0) + { + similarAuthors = seededSimilarAuthors; + } + + var result = new MetadataController.AuthorLookupResponse + { + Asin = resolvedAsin, + Name = resolvedName, + Image = resolvedImage, + CachedPath = cached, + Description = resolvedDescription, + SimilarAuthors = similarAuthors + }; + + await _lookupCacheWorkflow.PersistAuthorLookupAsync( + persistedEntry, + normalizedName, + region, + result); + + _lookupResponseCache.CacheAuthorLookupResponse(cacheKey, result); + _lookupResponseCache.CacheAuthorLookupResponse(MetadataCacheKeys.BuildAuthorLookupCacheKey(region, normalizedName, result.Asin), result); + + return MetadataAuthorLookupResult.Ok(result); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogError(ex, "Error looking up author: {Name}", name); + return MetadataAuthorLookupResult.Error("Internal server error"); + } + } + } +} diff --git a/listenarr.api/Controllers/MetadataController.cs b/listenarr.api/Controllers/MetadataController.cs index 9037c63fa..c0f2283e9 100644 --- a/listenarr.api/Controllers/MetadataController.cs +++ b/listenarr.api/Controllers/MetadataController.cs @@ -41,6 +41,8 @@ public class MetadataController : ControllerBase private readonly MetadataImageCacheWorkflow _imageCacheWorkflow; private readonly MetadataLookupCacheWorkflow _lookupCacheWorkflow; private readonly MetadataLookupResponseCache _lookupResponseCache; + private readonly MetadataAuthorLookupWorkflow _authorLookupWorkflow; + private readonly MetadataSeriesLookupWorkflow _seriesLookupWorkflow; public MetadataController( IAudiobookMetadataService metadataService, @@ -67,6 +69,24 @@ public MetadataController( _imageCacheWorkflow = new MetadataImageCacheWorkflow(_audiobookRepository, _imageCacheService, _logger); _lookupCacheWorkflow = new MetadataLookupCacheWorkflow(_audiobookRepository, _imageCacheService, _imageCacheWorkflow, _logger); _lookupResponseCache = new MetadataLookupResponseCache(_cache); + _authorLookupWorkflow = new MetadataAuthorLookupWorkflow( + _audibleService, + _audnexusService, + _imageCacheService, + _cache, + _imageCacheWorkflow, + _lookupCacheWorkflow, + _lookupResponseCache, + _logger); + _seriesLookupWorkflow = new MetadataSeriesLookupWorkflow( + _audibleService, + _imageCacheService, + _seriesCatalogService, + _cache, + _imageCacheWorkflow, + _lookupCacheWorkflow, + _lookupResponseCache, + _logger); } /// @@ -195,302 +215,14 @@ private async Task> LookupAuthorCore( string? asin, bool refresh) { - try + var result = await _authorLookupWorkflow.LookupAsync(name, region, asin, refresh); + return result.Status switch { - if (string.IsNullOrWhiteSpace(name)) return BadRequest("Author name is required"); - - var normalizedName = name.Trim(); - var normalizedAsin = string.IsNullOrWhiteSpace(asin) ? null : asin.Trim(); - var cacheKey = MetadataCacheKeys.BuildAuthorLookupCacheKey(region, normalizedName, normalizedAsin); - string? seededName = null; - string? seededImage = null; - string? seededDescription = null; - string? seededCachedPath = null; - var seededSimilarAuthors = new List(); - - if (refresh) - { - _cache.Remove(cacheKey); - } - else if (_cache.TryGetValue(cacheKey, out MetadataAuthorLookupCacheEntry? cachedEntry) && cachedEntry != null) - { - cachedEntry.Asin ??= normalizedAsin; - - // If previously marked NotFound, try to resolve an ASIN from the DB and check cache by ASIN - if (cachedEntry.NotFound) - { - var notFoundCacheProbe = await _imageCacheWorkflow.ProbeAuthorImageCacheAsync(normalizedName, region, cachedEntry.Asin); - if (!string.IsNullOrWhiteSpace(notFoundCacheProbe.CachedPath)) - { - cachedEntry.Asin = notFoundCacheProbe.Asin ?? cachedEntry.Asin; - cachedEntry.CachedPath = notFoundCacheProbe.CachedPath; - cachedEntry.Name ??= normalizedName; - cachedEntry.NotFound = false; - _cache.Set(cacheKey, cachedEntry, new MemoryCacheEntryOptions { SlidingExpiration = TimeSpan.FromHours(12) }); - - return Ok(_lookupResponseCache.MapAuthorLookupResponse(cachedEntry, normalizedName)); - } - - return NotFound("Author not found"); - } - - string? cachedPath = cachedEntry.CachedPath; - if (!string.IsNullOrWhiteSpace(cachedEntry.Asin)) - { - cachedPath = await _imageCacheWorkflow.ResolveCachedImagePathAsync(cachedEntry.Asin) ?? cachedPath; - } - - cachedEntry.CachedPath = cachedPath; - - if (MetadataResponseMapper.HasCompleteAuthorLookupData(cachedEntry.CachedPath, cachedEntry.Description, cachedEntry.SimilarAuthors)) - { - return Ok(_lookupResponseCache.MapAuthorLookupResponse(cachedEntry, normalizedName)); - } - - normalizedAsin ??= cachedEntry.Asin; - seededName = cachedEntry.Name; - seededImage = cachedEntry.Image; - seededDescription = cachedEntry.Description; - seededCachedPath = cachedPath; - seededSimilarAuthors = cachedEntry.SimilarAuthors? - .Where(author => !string.IsNullOrWhiteSpace(author.Name)) - .ToList() ?? new List(); - } - - var persistedEntry = await _lookupCacheWorkflow.ResolvePersistedAuthorCacheAsync(normalizedName, region, normalizedAsin); - if (persistedEntry != null) - { - var persistedResponse = await _lookupCacheWorkflow.MapPersistedAuthorLookupResponseAsync(persistedEntry, normalizedName); - if (!refresh && - MetadataResponseMapper.HasCompleteAuthorLookupData(persistedResponse.CachedPath, persistedResponse.Description, persistedResponse.SimilarAuthors)) - { - _lookupResponseCache.CacheAuthorLookupResponse(cacheKey, persistedResponse); - return Ok(persistedResponse); - } - - normalizedAsin ??= persistedResponse.Asin; - seededName ??= persistedResponse.Name; - seededImage ??= persistedResponse.Image; - seededDescription ??= persistedResponse.Description; - seededCachedPath ??= persistedResponse.CachedPath; - if (seededSimilarAuthors.Count == 0 && persistedResponse.SimilarAuthors.Count > 0) - { - seededSimilarAuthors = persistedResponse.SimilarAuthors - .Where(author => !string.IsNullOrWhiteSpace(author.Name)) - .ToList(); - } - } - - var cacheHint = await _imageCacheWorkflow.ProbeAuthorImageCacheAsync(normalizedName, region, normalizedAsin); - var resolvedAsin = normalizedAsin ?? cacheHint.Asin; - var cached = seededCachedPath ?? cacheHint.CachedPath; - var needsDescription = refresh || string.IsNullOrWhiteSpace(seededDescription); - var needsSimilarAuthors = refresh || seededSimilarAuthors.Count == 0; - var needsCachedImage = refresh || string.IsNullOrWhiteSpace(cached); - var needsAuthorDetails = string.IsNullOrWhiteSpace(resolvedAsin) || - string.IsNullOrWhiteSpace(seededName) || - string.IsNullOrWhiteSpace(seededImage) || - needsDescription || - needsCachedImage || - refresh; - - AuthorLookupItem? info = null; - AuthorLookupItem? authorDetails = null; - string? resolvedName = seededName; - string? resolvedImage = seededImage; - string? resolvedDescription = seededDescription; - - if (!string.IsNullOrWhiteSpace(resolvedAsin) && needsAuthorDetails) - { - authorDetails = await _audibleService.GetAuthorByAsinAsync(resolvedAsin, region); - } - - if (authorDetails == null && needsAuthorDetails) - { - info = await _audibleService.LookupAuthorAsync(normalizedName, region); - } - - resolvedAsin ??= authorDetails?.Asin ?? info?.Asin; - - if (authorDetails == null && !string.IsNullOrWhiteSpace(resolvedAsin) && needsAuthorDetails) - { - authorDetails = await _audibleService.GetAuthorByAsinAsync(resolvedAsin, region); - } - - resolvedName ??= authorDetails?.Name ?? info?.Name; - - var audibleImage = authorDetails?.Image ?? info?.Image; - if (!string.IsNullOrWhiteSpace(audibleImage) && - (string.IsNullOrWhiteSpace(resolvedImage) || needsCachedImage)) - { - resolvedImage = audibleImage; - } - - var audibleDescription = authorDetails?.Description ?? info?.Description; - if (!string.IsNullOrWhiteSpace(audibleDescription)) - { - resolvedDescription = audibleDescription; - } - - AudnexusAuthorSearchResult? audnexusSearchAuthor = null; - AudnexusAuthorResponse? audnexusAuthor = null; - var shouldQueryAudnexus = - refresh || - string.IsNullOrWhiteSpace(resolvedAsin) || - string.IsNullOrWhiteSpace(resolvedName) || - string.IsNullOrWhiteSpace(resolvedDescription) || - string.IsNullOrWhiteSpace(resolvedImage) || - needsSimilarAuthors || - (needsCachedImage && string.IsNullOrWhiteSpace(audibleImage)); - - if (!string.IsNullOrWhiteSpace(resolvedAsin) && shouldQueryAudnexus) - { - try - { - audnexusAuthor = await _audnexusService.GetAuthorAsync(resolvedAsin, region, update: false); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Audnexus author details fallback failed for '{Author}'", normalizedName); - } - } - - if (shouldQueryAudnexus && (authorDetails == null || audnexusAuthor == null || string.IsNullOrWhiteSpace(resolvedDescription))) - { - // Audible returned nothing — try Audnexus as fallback - try - { - var audnexResults = await _audnexusService.SearchAuthorsAsync(normalizedName, region); - audnexusSearchAuthor = audnexResults?.FirstOrDefault(a => - !string.IsNullOrWhiteSpace(a.Name) && - a.Name.Equals(normalizedName, StringComparison.OrdinalIgnoreCase)) - ?? audnexResults?.FirstOrDefault(a => - !string.IsNullOrWhiteSpace(a.Asin) && - string.Equals(a.Asin, resolvedAsin, StringComparison.OrdinalIgnoreCase)) - ?? audnexResults?.FirstOrDefault(); - - if (audnexusSearchAuthor != null) - { - resolvedAsin ??= audnexusSearchAuthor.Asin; - resolvedName ??= audnexusSearchAuthor.Name; - resolvedImage ??= audnexusSearchAuthor.Image; - resolvedDescription ??= audnexusSearchAuthor.Description; - - if (audnexusAuthor == null && !string.IsNullOrWhiteSpace(audnexusSearchAuthor.Asin)) - { - audnexusAuthor = await _audnexusService.GetAuthorAsync(audnexusSearchAuthor.Asin, region, update: false); - } - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Audnexus author fallback failed for '{Author}'", normalizedName); - } - - } - - if (audnexusAuthor != null) - { - resolvedAsin ??= audnexusAuthor.Asin; - resolvedName ??= audnexusAuthor.Name; - resolvedDescription ??= audnexusAuthor.Description; - } - - var audnexusImage = audnexusAuthor?.Image ?? audnexusSearchAuthor?.Image; - if (!string.IsNullOrWhiteSpace(audnexusImage) && - (string.IsNullOrWhiteSpace(resolvedImage) || - (needsCachedImage && string.IsNullOrWhiteSpace(audibleImage)))) - { - resolvedImage = audnexusImage; - } - - var hasResolvedAuthorIdentity = - !string.IsNullOrWhiteSpace(resolvedAsin) || - !string.IsNullOrWhiteSpace(authorDetails?.Name) || - !string.IsNullOrWhiteSpace(info?.Name) || - !string.IsNullOrWhiteSpace(audnexusAuthor?.Name) || - !string.IsNullOrWhiteSpace(audnexusSearchAuthor?.Name); - - if (!hasResolvedAuthorIdentity) - { - _lookupResponseCache.CacheAuthorNotFound(cacheKey, normalizedName); - - return NotFound("Author not found"); - } - - resolvedName ??= - authorDetails?.Name ?? - info?.Name ?? - audnexusAuthor?.Name ?? - audnexusSearchAuthor?.Name ?? - normalizedName; - - try - { - if (!refresh && - string.IsNullOrWhiteSpace(cached) && - !string.IsNullOrWhiteSpace(resolvedAsin)) - { - cached = await _imageCacheWorkflow.ResolveCachedImagePathAsync(resolvedAsin); - } - - if ((refresh || string.IsNullOrWhiteSpace(cached)) && - !string.IsNullOrWhiteSpace(resolvedAsin)) - { - var preferredImageForCaching = - authorDetails?.Image ?? - info?.Image ?? - audnexusAuthor?.Image ?? - audnexusSearchAuthor?.Image ?? - resolvedImage; - - // Attempt to ensure author image is cached under authors storage. - cached = await _imageCacheService.MoveToAuthorLibraryStorageAsync( - resolvedAsin, - preferredImageForCaching, - forceRefresh: refresh); - if (!string.IsNullOrWhiteSpace(cached)) cached = "/" + cached.TrimStart('/'); - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Failed to cache author image for {Author}", name); - } - - var similarAuthors = MetadataResponseMapper.MapSimilarAuthors( - audnexusAuthor?.Similar ?? audnexusSearchAuthor?.Similar, - normalizedName); - if (similarAuthors.Count == 0 && seededSimilarAuthors.Count > 0) - { - similarAuthors = seededSimilarAuthors; - } - - var result = new AuthorLookupResponse - { - Asin = resolvedAsin, - Name = resolvedName, - Image = resolvedImage, - CachedPath = cached, - Description = resolvedDescription, - SimilarAuthors = similarAuthors - }; - - await _lookupCacheWorkflow.PersistAuthorLookupAsync( - persistedEntry, - normalizedName, - region, - result); - - _lookupResponseCache.CacheAuthorLookupResponse(cacheKey, result); - _lookupResponseCache.CacheAuthorLookupResponse(MetadataCacheKeys.BuildAuthorLookupCacheKey(region, normalizedName, result.Asin), result); - - return Ok(result); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogError(ex, "Error looking up author: {Name}", name); - return StatusCode(500, "Internal server error"); - } + MetadataAuthorLookupStatus.Ok => Ok(result.Response!), + MetadataAuthorLookupStatus.BadRequest => BadRequest(result.Message), + MetadataAuthorLookupStatus.NotFound => NotFound(result.Message), + _ => StatusCode(500, result.Message) + }; } /// @@ -607,118 +339,14 @@ private async Task> LookupSeriesCore( string? asin, bool refresh) { - try - { - if (string.IsNullOrWhiteSpace(name)) return BadRequest("Series name is required"); - - var normalizedName = name.Trim(); - var normalizedAsin = string.IsNullOrWhiteSpace(asin) ? null : asin.Trim(); - var cacheKey = $"series-lookup:{region}:{normalizedName.ToLowerInvariant()}"; - - if (refresh) - { - _cache.Remove(cacheKey); - } - else if (_cache.TryGetValue(cacheKey, out MetadataSeriesLookupCacheEntry? cachedEntry) && cachedEntry != null) - { - cachedEntry.Asin ??= normalizedAsin; - return Ok(_lookupResponseCache.MapSeriesLookupResponse(cachedEntry, normalizedName)); - } - - var persistedEntry = await _lookupCacheWorkflow.ResolvePersistedSeriesCacheAsync(normalizedName, region, normalizedAsin); - if (!refresh && persistedEntry != null) - { - var persistedResponse = await _lookupCacheWorkflow.MapPersistedSeriesLookupResponseAsync(persistedEntry, normalizedName); - _lookupResponseCache.CacheSeriesLookupResponse(cacheKey, persistedResponse); - return Ok(persistedResponse); - } - - normalizedAsin ??= persistedEntry?.SeriesAsin; - - var resolvedSeries = !string.IsNullOrWhiteSpace(normalizedAsin) - ? await _audibleService.GetSeriesByAsinAsync(normalizedAsin, region) - : null; - - resolvedSeries ??= await _audibleService.LookupSeriesAsync(normalizedName, region); - normalizedAsin ??= resolvedSeries?.Asin; - - if (resolvedSeries == null && !string.IsNullOrWhiteSpace(normalizedAsin)) - { - resolvedSeries = await _audibleService.GetSeriesByAsinAsync(normalizedAsin, region); - } - - if (resolvedSeries == null) - { - return NotFound("Series not found"); - } - - var resolvedSeriesName = string.IsNullOrWhiteSpace(resolvedSeries.Name) - ? normalizedName - : resolvedSeries.Name; - - var catalog = await _seriesCatalogService.GetCatalogAsync( - resolvedSeriesName, - region, - limit: 250, - language: null, - forceRefresh: refresh); - - var imageUrl = - resolvedSeries.Image ?? - catalog?.Books.FirstOrDefault(book => !string.IsNullOrWhiteSpace(book.ImageUrl))?.ImageUrl ?? - persistedEntry?.ImageUrl; - - string? cachedPath = null; - if (!string.IsNullOrWhiteSpace(resolvedSeries.Asin)) - { - cachedPath = await _imageCacheWorkflow.ResolveCachedImagePathAsync(resolvedSeries.Asin); - - if ((refresh || string.IsNullOrWhiteSpace(cachedPath)) && !string.IsNullOrWhiteSpace(imageUrl)) - { - try - { - cachedPath = await _imageCacheService.MoveToSeriesLibraryStorageAsync( - resolvedSeries.Asin, - imageUrl, - forceRefresh: refresh); - if (!string.IsNullOrWhiteSpace(cachedPath)) - { - cachedPath = "/" + cachedPath.TrimStart('/'); - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Failed to cache series image for {Series}", normalizedName); - } - } - } - - var result = new SeriesLookupResponse - { - Asin = resolvedSeries.Asin, - Name = resolvedSeriesName, - Image = imageUrl, - CachedPath = cachedPath, - Description = resolvedSeries.Description ?? persistedEntry?.Description, - TotalBooks = catalog?.TotalBooks ?? persistedEntry?.CatalogBooks?.Count ?? 0 - }; - - await _lookupCacheWorkflow.PersistSeriesLookupAsync( - persistedEntry, - normalizedName, - region, - result, - catalog?.Books); - - _lookupResponseCache.CacheSeriesLookupResponse(cacheKey, result); - - return Ok(result); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + var result = await _seriesLookupWorkflow.LookupAsync(name, region, asin, refresh); + return result.Status switch { - _logger.LogError(ex, "Error looking up series: {Name}", name); - return StatusCode(500, "Internal server error"); - } + MetadataSeriesLookupStatus.Ok => Ok(result.Response!), + MetadataSeriesLookupStatus.BadRequest => BadRequest(result.Message), + MetadataSeriesLookupStatus.NotFound => NotFound(result.Message), + _ => StatusCode(500, result.Message) + }; } /// diff --git a/listenarr.api/Controllers/MetadataSeriesLookupWorkflow.cs b/listenarr.api/Controllers/MetadataSeriesLookupWorkflow.cs new file mode 100644 index 000000000..06334fb49 --- /dev/null +++ b/listenarr.api/Controllers/MetadataSeriesLookupWorkflow.cs @@ -0,0 +1,194 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +using Listenarr.Application.Interfaces; +using Listenarr.Application.Metadata; +using Microsoft.Extensions.Caching.Memory; + +namespace Listenarr.Api.Controllers +{ + internal enum MetadataSeriesLookupStatus + { + Ok, + BadRequest, + NotFound, + Error + } + + internal sealed record MetadataSeriesLookupResult( + MetadataSeriesLookupStatus Status, + MetadataController.SeriesLookupResponse? Response, + string? Message) + { + public static MetadataSeriesLookupResult Ok(MetadataController.SeriesLookupResponse response) => + new(MetadataSeriesLookupStatus.Ok, response, null); + + public static MetadataSeriesLookupResult BadRequest(string message) => + new(MetadataSeriesLookupStatus.BadRequest, null, message); + + public static MetadataSeriesLookupResult NotFound(string message) => + new(MetadataSeriesLookupStatus.NotFound, null, message); + + public static MetadataSeriesLookupResult Error(string message) => + new(MetadataSeriesLookupStatus.Error, null, message); + } + + internal sealed class MetadataSeriesLookupWorkflow + { + private readonly AudibleService _audibleService; + private readonly IImageCacheService _imageCacheService; + private readonly ISeriesCatalogService _seriesCatalogService; + private readonly IMemoryCache _cache; + private readonly MetadataImageCacheWorkflow _imageCacheWorkflow; + private readonly MetadataLookupCacheWorkflow _lookupCacheWorkflow; + private readonly MetadataLookupResponseCache _lookupResponseCache; + private readonly ILogger _logger; + + public MetadataSeriesLookupWorkflow( + AudibleService audibleService, + IImageCacheService imageCacheService, + ISeriesCatalogService seriesCatalogService, + IMemoryCache cache, + MetadataImageCacheWorkflow imageCacheWorkflow, + MetadataLookupCacheWorkflow lookupCacheWorkflow, + MetadataLookupResponseCache lookupResponseCache, + ILogger logger) + { + _audibleService = audibleService; + _imageCacheService = imageCacheService; + _seriesCatalogService = seriesCatalogService; + _cache = cache; + _imageCacheWorkflow = imageCacheWorkflow; + _lookupCacheWorkflow = lookupCacheWorkflow; + _lookupResponseCache = lookupResponseCache; + _logger = logger; + } + + public async Task LookupAsync( + string name, + string region, + string? asin, + bool refresh) + { + try + { + if (string.IsNullOrWhiteSpace(name)) return MetadataSeriesLookupResult.BadRequest("Series name is required"); + + var normalizedName = name.Trim(); + var normalizedAsin = string.IsNullOrWhiteSpace(asin) ? null : asin.Trim(); + var cacheKey = $"series-lookup:{region}:{normalizedName.ToLowerInvariant()}"; + + if (refresh) + { + _cache.Remove(cacheKey); + } + else if (_cache.TryGetValue(cacheKey, out MetadataSeriesLookupCacheEntry? cachedEntry) && cachedEntry != null) + { + cachedEntry.Asin ??= normalizedAsin; + return MetadataSeriesLookupResult.Ok(_lookupResponseCache.MapSeriesLookupResponse(cachedEntry, normalizedName)); + } + + var persistedEntry = await _lookupCacheWorkflow.ResolvePersistedSeriesCacheAsync(normalizedName, region, normalizedAsin); + if (!refresh && persistedEntry != null) + { + var persistedResponse = await _lookupCacheWorkflow.MapPersistedSeriesLookupResponseAsync(persistedEntry, normalizedName); + _lookupResponseCache.CacheSeriesLookupResponse(cacheKey, persistedResponse); + return MetadataSeriesLookupResult.Ok(persistedResponse); + } + + normalizedAsin ??= persistedEntry?.SeriesAsin; + + var resolvedSeries = !string.IsNullOrWhiteSpace(normalizedAsin) + ? await _audibleService.GetSeriesByAsinAsync(normalizedAsin, region) + : null; + + resolvedSeries ??= await _audibleService.LookupSeriesAsync(normalizedName, region); + normalizedAsin ??= resolvedSeries?.Asin; + + if (resolvedSeries == null && !string.IsNullOrWhiteSpace(normalizedAsin)) + { + resolvedSeries = await _audibleService.GetSeriesByAsinAsync(normalizedAsin, region); + } + + if (resolvedSeries == null) + { + return MetadataSeriesLookupResult.NotFound("Series not found"); + } + + var resolvedSeriesName = string.IsNullOrWhiteSpace(resolvedSeries.Name) + ? normalizedName + : resolvedSeries.Name; + + var catalog = await _seriesCatalogService.GetCatalogAsync( + resolvedSeriesName, + region, + limit: 250, + language: null, + forceRefresh: refresh); + + var imageUrl = + resolvedSeries.Image ?? + catalog?.Books.FirstOrDefault(book => !string.IsNullOrWhiteSpace(book.ImageUrl))?.ImageUrl ?? + persistedEntry?.ImageUrl; + + string? cachedPath = null; + if (!string.IsNullOrWhiteSpace(resolvedSeries.Asin)) + { + cachedPath = await _imageCacheWorkflow.ResolveCachedImagePathAsync(resolvedSeries.Asin); + + if ((refresh || string.IsNullOrWhiteSpace(cachedPath)) && !string.IsNullOrWhiteSpace(imageUrl)) + { + try + { + cachedPath = await _imageCacheService.MoveToSeriesLibraryStorageAsync( + resolvedSeries.Asin, + imageUrl, + forceRefresh: refresh); + if (!string.IsNullOrWhiteSpace(cachedPath)) + { + cachedPath = "/" + cachedPath.TrimStart('/'); + } + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning(ex, "Failed to cache series image for {Series}", normalizedName); + } + } + } + + var result = new MetadataController.SeriesLookupResponse + { + Asin = resolvedSeries.Asin, + Name = resolvedSeriesName, + Image = imageUrl, + CachedPath = cachedPath, + Description = resolvedSeries.Description ?? persistedEntry?.Description, + TotalBooks = catalog?.TotalBooks ?? persistedEntry?.CatalogBooks?.Count ?? 0 + }; + + await _lookupCacheWorkflow.PersistSeriesLookupAsync( + persistedEntry, + normalizedName, + region, + result, + catalog?.Books); + + _lookupResponseCache.CacheSeriesLookupResponse(cacheKey, result); + + return MetadataSeriesLookupResult.Ok(result); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogError(ex, "Error looking up series: {Name}", name); + return MetadataSeriesLookupResult.Error("Internal server error"); + } + } + } +} diff --git a/listenarr.infrastructure/Adapters/QbittorrentAdapter.cs b/listenarr.infrastructure/Adapters/QbittorrentAdapter.cs index cae350d3e..ae443c6d0 100644 --- a/listenarr.infrastructure/Adapters/QbittorrentAdapter.cs +++ b/listenarr.infrastructure/Adapters/QbittorrentAdapter.cs @@ -785,36 +785,13 @@ public async Task> FetchDownloadsAsync( } } - // Build comprehensive lookup with all torrent info we need from single API call - var torrentLookup = new List<(string Hash, string Name, string SavePath, string ContentPath, double Progress, long AmountLeft, string State, long Size, string Category, long? SeedingTime, double Ratio, float RatioLimit, long SeedingTimeLimit, bool CanMoveFiles, bool CanBeRemoved)>(); - foreach (var t in allTorrents) - { - var hash = t.TryGetValue("hash", out var hashElement) ? hashElement.GetString() ?? "" : ""; - var name = t.TryGetValue("name", out var nameElement) ? nameElement.GetString() ?? "" : ""; - var savePath = t.TryGetValue("save_path", out var savePathElement) ? savePathElement.GetString() ?? "" : ""; - var contentPath = t.TryGetValue("content_path", out var contentPathElement) ? contentPathElement.GetString() ?? "" : ""; - var progress = t.TryGetValue("progress", out var progressElement) ? progressElement.GetDouble() : 0.0; - var amountLeft = t.TryGetValue("amount_left", out var amountLeftElement) ? amountLeftElement.GetInt64() : 0L; - var state = t.TryGetValue("state", out var stateElement) ? stateElement.GetString() ?? "" : ""; - var size = t.TryGetValue("size", out var sizeElement) ? sizeElement.GetInt64() : 0L; - var category = t.TryGetValue("category", out var categoryElement) ? categoryElement.GetString() ?? "" : ""; - var seedingTime = t.TryGetValue("seeding_time", out var seedingTimeElement) ? seedingTimeElement.GetInt64() : (long?)null; - var tRatio = t.TryGetValue("ratio", out var ratioElement) ? ratioElement.GetDouble() : 0.0; - var tRatioLimit = t.TryGetValue("ratio_limit", out var ratioLimitElement) ? (float)ratioLimitElement.GetDouble() : -2f; - var tSeedingTimeLimit = t.TryGetValue("seeding_time_limit", out var seedingTimeLimitElement) ? seedingTimeLimitElement.GetInt64() : -2L; - - // Sonarr parity: compute CanMoveFiles/CanBeRemoved per-torrent - var tIsStopped = state is "pausedUP" or "stoppedUP"; - var tSeedLimitReached = QbittorrentSeedLimitEvaluator.HasReachedSeedLimit( - tRatio, tRatioLimit, seedingTime, tSeedingTimeLimit, - qbtGlobalMaxRatioEnabled, qbtGlobalMaxRatio, - qbtGlobalMaxSeedingTimeEnabled, qbtGlobalMaxSeedingTime); - var tCanBeRemoved = qbtRemoveCompletedDownloads && tSeedLimitReached; - var tCanMoveFiles = tCanBeRemoved && tIsStopped; - - torrentLookup.Add((hash, name, savePath, contentPath, progress, amountLeft, state, size, category, seedingTime, tRatio, tRatioLimit, tSeedingTimeLimit, tCanMoveFiles, tCanBeRemoved)); - } - + var torrentLookup = QbittorrentTorrentLookupBuilder.Build( + allTorrents, + qbtRemoveCompletedDownloads, + qbtGlobalMaxRatioEnabled, + qbtGlobalMaxRatio, + qbtGlobalMaxSeedingTimeEnabled, + qbtGlobalMaxSeedingTime); _logger.LogDebug("Found {TorrentCount} torrents in qBittorrent for client {ClientName}", torrentLookup.Count, client.Name); diff --git a/listenarr.infrastructure/Adapters/QbittorrentTorrentLookupBuilder.cs b/listenarr.infrastructure/Adapters/QbittorrentTorrentLookupBuilder.cs new file mode 100644 index 000000000..4129eade2 --- /dev/null +++ b/listenarr.infrastructure/Adapters/QbittorrentTorrentLookupBuilder.cs @@ -0,0 +1,61 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +using System.Text.Json; + +namespace Listenarr.Infrastructure.Adapters +{ + internal static class QbittorrentTorrentLookupBuilder + { + public static List<(string Hash, string Name, string SavePath, string ContentPath, double Progress, long AmountLeft, string State, long Size, string Category, long? SeedingTime, double Ratio, float RatioLimit, long SeedingTimeLimit, bool CanMoveFiles, bool CanBeRemoved)> Build( + IEnumerable> torrents, + bool removeCompletedDownloads, + bool globalMaxRatioEnabled, + float globalMaxRatio, + bool globalMaxSeedingTimeEnabled, + long globalMaxSeedingTime) + { + var torrentLookup = new List<(string Hash, string Name, string SavePath, string ContentPath, double Progress, long AmountLeft, string State, long Size, string Category, long? SeedingTime, double Ratio, float RatioLimit, long SeedingTimeLimit, bool CanMoveFiles, bool CanBeRemoved)>(); + foreach (var torrent in torrents) + { + var hash = torrent.TryGetValue("hash", out var hashElement) ? hashElement.GetString() ?? "" : ""; + var name = torrent.TryGetValue("name", out var nameElement) ? nameElement.GetString() ?? "" : ""; + var savePath = torrent.TryGetValue("save_path", out var savePathElement) ? savePathElement.GetString() ?? "" : ""; + var contentPath = torrent.TryGetValue("content_path", out var contentPathElement) ? contentPathElement.GetString() ?? "" : ""; + var progress = torrent.TryGetValue("progress", out var progressElement) ? progressElement.GetDouble() : 0.0; + var amountLeft = torrent.TryGetValue("amount_left", out var amountLeftElement) ? amountLeftElement.GetInt64() : 0L; + var state = torrent.TryGetValue("state", out var stateElement) ? stateElement.GetString() ?? "" : ""; + var size = torrent.TryGetValue("size", out var sizeElement) ? sizeElement.GetInt64() : 0L; + var category = torrent.TryGetValue("category", out var categoryElement) ? categoryElement.GetString() ?? "" : ""; + var seedingTime = torrent.TryGetValue("seeding_time", out var seedingTimeElement) ? seedingTimeElement.GetInt64() : (long?)null; + var ratio = torrent.TryGetValue("ratio", out var ratioElement) ? ratioElement.GetDouble() : 0.0; + var ratioLimit = torrent.TryGetValue("ratio_limit", out var ratioLimitElement) ? (float)ratioLimitElement.GetDouble() : -2f; + var seedingTimeLimit = torrent.TryGetValue("seeding_time_limit", out var seedingTimeLimitElement) ? seedingTimeLimitElement.GetInt64() : -2L; + + var isStopped = state is "pausedUP" or "stoppedUP"; + var seedLimitReached = QbittorrentSeedLimitEvaluator.HasReachedSeedLimit( + ratio, + ratioLimit, + seedingTime, + seedingTimeLimit, + globalMaxRatioEnabled, + globalMaxRatio, + globalMaxSeedingTimeEnabled, + globalMaxSeedingTime); + var canBeRemoved = removeCompletedDownloads && seedLimitReached; + var canMoveFiles = canBeRemoved && isStopped; + + torrentLookup.Add((hash, name, savePath, contentPath, progress, amountLeft, state, size, category, seedingTime, ratio, ratioLimit, seedingTimeLimit, canMoveFiles, canBeRemoved)); + } + + return torrentLookup; + } + } +} diff --git a/listenarr.infrastructure/Adapters/SabnzbdAdapter.cs b/listenarr.infrastructure/Adapters/SabnzbdAdapter.cs index 996a6765e..c2a5281cf 100644 --- a/listenarr.infrastructure/Adapters/SabnzbdAdapter.cs +++ b/listenarr.infrastructure/Adapters/SabnzbdAdapter.cs @@ -810,43 +810,9 @@ public async Task> FetchDownloadsAsync( throw new DownloadClientAdapterPollingException($"No history data found for SABnzbd client {client.Id}"); } - // Build a lookup of completed items for faster matching - // Include nzo_id when available so we can match downloads by ID as well - var completedItems = new List<(string Name, string Status, string Path, DateTime CompletedTime, string NzoId)>(); - var failedItems = new List<(string Name, string Status, string Path, DateTime CompletedTime, string NzoId, string Error)>(); - - foreach (var slot in slots.EnumerateArray()) - { - var name = slot.TryGetProperty("name", out var nameProp) ? nameProp.GetString() ?? "" : ""; - var status = slot.TryGetProperty("status", out var statusProp) ? statusProp.GetString() ?? "" : ""; - var path = slot.TryGetProperty("storage", out var pathProp) ? pathProp.GetString() ?? "" : ""; - var nzoId = slot.TryGetProperty("nzo_id", out var nzoIdProp) ? nzoIdProp.GetString() ?? "" : ""; - - // Parse completion time - var completedTime = DateTime.MinValue; - if (slot.TryGetProperty("completed", out var completedProp)) - { - var completedTimestamp = completedProp.GetInt64(); - completedTime = DateTimeOffset.FromUnixTimeSeconds(completedTimestamp).DateTime; - } - - if (!string.IsNullOrEmpty(name) && - (status.Equals("Completed", StringComparison.OrdinalIgnoreCase) || - status.Equals("Complete", StringComparison.OrdinalIgnoreCase))) - { - _logger.LogInformation("SABnzbd history slot parsed: nzo_id={NzoId}, name={Name}, status={Status}, path={Path}, completed={Completed}", nzoId, LogRedaction.SanitizeText(name), LogRedaction.SanitizeText(status), LogRedaction.SanitizeFilePath(path), completedTime); - - completedItems.Add((name, status, path, completedTime, nzoId)); - } - else if (!string.IsNullOrEmpty(name) && status.Equals("Failed", StringComparison.OrdinalIgnoreCase)) - { - var failMessage = slot.TryGetProperty("fail_message", out var failProp) - ? failProp.GetString() ?? string.Empty - : status; - - failedItems.Add((name, status, path, completedTime, nzoId, failMessage)); - } - } + var historyLookup = SabnzbdHistoryLookupBuilder.Build(slots, _logger); + var completedItems = historyLookup.CompletedItems; + var failedItems = historyLookup.FailedItems; _logger.LogDebug("Found {CompletedCount} completed items in SABnzbd history for client {ClientName}", completedItems.Count, client.Name); diff --git a/listenarr.infrastructure/Adapters/SabnzbdHistoryLookupBuilder.cs b/listenarr.infrastructure/Adapters/SabnzbdHistoryLookupBuilder.cs new file mode 100644 index 000000000..f8439da2f --- /dev/null +++ b/listenarr.infrastructure/Adapters/SabnzbdHistoryLookupBuilder.cs @@ -0,0 +1,77 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +using System.Text.Json; +using Listenarr.Application.Security; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Infrastructure.Adapters +{ + internal readonly record struct SabnzbdCompletedHistoryItem( + string Name, + string Status, + string Path, + DateTime CompletedTime, + string NzoId); + + internal readonly record struct SabnzbdFailedHistoryItem( + string Name, + string Status, + string Path, + DateTime CompletedTime, + string NzoId, + string Error); + + internal sealed record SabnzbdHistoryLookup( + List CompletedItems, + List FailedItems); + + internal static class SabnzbdHistoryLookupBuilder + { + public static SabnzbdHistoryLookup Build(JsonElement slots, ILogger logger) + { + var completedItems = new List(); + var failedItems = new List(); + + foreach (var slot in slots.EnumerateArray()) + { + var name = slot.TryGetProperty("name", out var nameProp) ? nameProp.GetString() ?? "" : ""; + var status = slot.TryGetProperty("status", out var statusProp) ? statusProp.GetString() ?? "" : ""; + var path = slot.TryGetProperty("storage", out var pathProp) ? pathProp.GetString() ?? "" : ""; + var nzoId = slot.TryGetProperty("nzo_id", out var nzoIdProp) ? nzoIdProp.GetString() ?? "" : ""; + + var completedTime = DateTime.MinValue; + if (slot.TryGetProperty("completed", out var completedProp)) + { + var completedTimestamp = completedProp.GetInt64(); + completedTime = DateTimeOffset.FromUnixTimeSeconds(completedTimestamp).DateTime; + } + + if (!string.IsNullOrEmpty(name) && + (status.Equals("Completed", StringComparison.OrdinalIgnoreCase) || + status.Equals("Complete", StringComparison.OrdinalIgnoreCase))) + { + logger.LogInformation("SABnzbd history slot parsed: nzo_id={NzoId}, name={Name}, status={Status}, path={Path}, completed={Completed}", nzoId, LogRedaction.SanitizeText(name), LogRedaction.SanitizeText(status), LogRedaction.SanitizeFilePath(path), completedTime); + completedItems.Add(new SabnzbdCompletedHistoryItem(name, status, path, completedTime, nzoId)); + } + else if (!string.IsNullOrEmpty(name) && status.Equals("Failed", StringComparison.OrdinalIgnoreCase)) + { + var failMessage = slot.TryGetProperty("fail_message", out var failProp) + ? failProp.GetString() ?? string.Empty + : status; + + failedItems.Add(new SabnzbdFailedHistoryItem(name, status, path, completedTime, nzoId, failMessage)); + } + } + + return new SabnzbdHistoryLookup(completedItems, failedItems); + } + } +} From 83240188930861d3ef760ac5d8c5164d8d3d03d1 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Sat, 13 Jun 2026 21:32:45 -0400 Subject: [PATCH 51/84] refactor: extract sabnzbd polling workflow - Move SABnzbd queue/history download polling into a focused workflow - Keep adapter interface and reconciliation behavior unchanged --- .../Adapters/SabnzbdAdapter.cs | 193 +-------------- .../SabnzbdDownloadPollingWorkflow.cs | 219 ++++++++++++++++++ 2 files changed, 222 insertions(+), 190 deletions(-) create mode 100644 listenarr.infrastructure/Adapters/SabnzbdDownloadPollingWorkflow.cs diff --git a/listenarr.infrastructure/Adapters/SabnzbdAdapter.cs b/listenarr.infrastructure/Adapters/SabnzbdAdapter.cs index c2a5281cf..99d44e3cb 100644 --- a/listenarr.infrastructure/Adapters/SabnzbdAdapter.cs +++ b/listenarr.infrastructure/Adapters/SabnzbdAdapter.cs @@ -20,9 +20,7 @@ using System.Text.Json; using Listenarr.Application.Interfaces; using Listenarr.Application.Security; -using Listenarr.Domain.Common; using Listenarr.Domain.Models; -using Listenarr.Domain.Models.Exceptions; using Microsoft.Extensions.Logging; namespace Listenarr.Infrastructure.Adapters @@ -38,6 +36,7 @@ public class SabnzbdAdapter : IDownloadClientAdapter private readonly ILogger _logger; private readonly IAppMetricsService _appMetricsService; private readonly SabnzbdRequestBuilder _requestBuilder; + private readonly SabnzbdDownloadPollingWorkflow _downloadPollingWorkflow; public SabnzbdAdapter( IHttpClientFactory httpFactory, @@ -50,6 +49,7 @@ public SabnzbdAdapter( _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _appMetricsService = appMetricsService; _requestBuilder = new SabnzbdRequestBuilder(); + _downloadPollingWorkflow = new SabnzbdDownloadPollingWorkflow(_httpFactory, _requestBuilder, _appMetricsService, _logger, ClientType); } public async Task<(bool Success, string Message)> TestConnectionAsync(DownloadClientConfiguration client, CancellationToken ct = default) @@ -710,194 +710,7 @@ public async Task> FetchDownloadsAsync( List downloads, CancellationToken cancellationToken) { - _logger.LogDebug("Polling SABnzbd client {ClientName}", client.Name); - try - { - using var http = _httpFactory.CreateClient(ClientType); - - var requestContext = _requestBuilder.CreateContext(client); - if (!requestContext.HasApiKey) - { - throw new DownloadClientAdapterPollingException($"SABnzbd API key not configured for client {client.Id}"); - } - - // Poll SABnzbd queue for active downloads progress updates - var queueUrl = _requestBuilder.BuildUrl(requestContext, new Dictionary - { - ["mode"] = "queue", - ["output"] = "json" - }); - // Redacted queue URL for safe diagnostics - _logger.LogDebug("SABnzbd poll queue URL (redacted): {Url}", LogRedaction.RedactText(queueUrl, _requestBuilder.BuildSensitiveValues(requestContext))); - using var queueResponse = await http.GetAsync(queueUrl, cancellationToken); - - if (queueResponse.IsSuccessStatusCode) - { - var queueJson = await queueResponse.Content.ReadAsStringAsync(cancellationToken); - var queueDoc = JsonDocument.Parse(queueJson); - - if (queueDoc.RootElement.TryGetProperty("queue", out var queue) && - queue.TryGetProperty("slots", out var queueSlots) && - queueSlots.ValueKind == JsonValueKind.Array) - { - foreach (Download download in downloads) - { - var clientDownloadId = download.GetExternalId(); - - foreach (var slot in queueSlots.EnumerateArray()) - { - try - { - var nzoId = slot.TryGetProperty("nzo_id", out var nzoIdProp) ? nzoIdProp.GetString() ?? "" : ""; - if (!string.IsNullOrEmpty(clientDownloadId) && !string.Equals(nzoId, clientDownloadId, StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - var filename = slot.TryGetProperty("filename", out var filenameProp) ? filenameProp.GetString() ?? "" : ""; - if (!TitleUtils.AreTitlesSimilar(download.Title, filename)) - { - continue; - } - - var percentage = slot.TryGetProperty("percentage", out var percentageProp) ? SabnzbdResponseMapper.ParseJsonDouble(percentageProp) : 0.0; - var mbleft = slot.TryGetProperty("mbleft", out var mbleftProp) ? SabnzbdResponseMapper.ParseJsonDouble(mbleftProp) : 0.0; - var status = slot.TryGetProperty("status", out var statusProp) ? statusProp.GetString() ?? "" : ""; - - // Calculate progress and update - // percentage is provided by SABnzbd as a percent (e.g. 50.0). Our UpdateDownloadProgressAsync - // expects a percentage in the 0..100 range. Use the percentage directly. - var progressPercent = percentage; // 0..100 - - // Convert sizes from MB -> bytes - var amountLeft = (long)(mbleft * 1024 * 1024); - - // Update progress using percent and amountLeft (UpdateDownloadProgressAsync uses percent->downloaded size calculation when TotalSize is set) - AdapterUtils.MapDownloadProgress(download, progressPercent, amountLeft, status); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Error updating SABnzbd queue progress for slot"); - } - } - } - } - } - - // Get completed downloads (history) - limit to recent items - var historyUrl = _requestBuilder.BuildUrl(requestContext, new Dictionary - { - ["mode"] = "history", - ["limit"] = "100", - ["output"] = "json" - }); - // Redacted history URL for safe diagnostics - _logger.LogDebug("SABnzbd history URL (redacted): {Url}", LogRedaction.RedactText(historyUrl, _requestBuilder.BuildSensitiveValues(requestContext))); - using var historyResponse = await http.GetAsync(historyUrl, cancellationToken); - - if (!historyResponse.IsSuccessStatusCode) - { - throw new DownloadClientAdapterPollingException($"Failed to fetch SABnzbd history for {client.Id}: {historyResponse.StatusCode}"); - } - - var historyJson = await historyResponse.Content.ReadAsStringAsync(cancellationToken); - var historyDoc = System.Text.Json.JsonDocument.Parse(historyJson); - - if (!historyDoc.RootElement.TryGetProperty("history", out var history) || - !history.TryGetProperty("slots", out var slots) || - slots.ValueKind != System.Text.Json.JsonValueKind.Array) - { - throw new DownloadClientAdapterPollingException($"No history data found for SABnzbd client {client.Id}"); - } - - var historyLookup = SabnzbdHistoryLookupBuilder.Build(slots, _logger); - var completedItems = historyLookup.CompletedItems; - var failedItems = historyLookup.FailedItems; - - _logger.LogDebug("Found {CompletedCount} completed items in SABnzbd history for client {ClientName}", - completedItems.Count, client.Name); - - // Check each download against completed items - foreach (var dl in downloads) - { - // Skip downloads that are already being processed, awaiting import, - // or fully imported to avoid duplicate finalization/notifications. - if (dl.Status == DownloadStatus.Moved || - dl.Status == DownloadStatus.Processing || - dl.Status == DownloadStatus.ImportPending) - continue; - - try - { - var failedMatch = failedItems.FirstOrDefault(item => - (!string.IsNullOrEmpty(item.NzoId) && !string.IsNullOrEmpty(dl.GetExternalId()) && - string.Equals(item.NzoId, dl.GetExternalId(), StringComparison.OrdinalIgnoreCase)) || - string.Equals(item.Name, dl.Title, StringComparison.OrdinalIgnoreCase) || - (!string.IsNullOrEmpty(dl.Title) && item.Name.Contains(dl.Title, StringComparison.OrdinalIgnoreCase)) - ); - - if (!string.IsNullOrEmpty(failedMatch.Name)) - { - continue; - } - - // Find matching active download by NZO ID - var matchingItem = completedItems.FirstOrDefault(item => - // Match by NZO ID (strongest) or fall back to name/title matching - (!string.IsNullOrEmpty(item.NzoId) && !string.IsNullOrEmpty(dl.GetExternalId()) && - string.Equals(item.NzoId, dl.GetExternalId(), StringComparison.OrdinalIgnoreCase)) || - string.Equals(item.Name, dl.Title, StringComparison.OrdinalIgnoreCase) || - (!string.IsNullOrEmpty(dl.Title) && item.Name.Contains(dl.Title, StringComparison.OrdinalIgnoreCase)) - ); - - if (!string.IsNullOrEmpty(matchingItem.Name)) - { - AdapterUtils.MapDownloadProgress(dl, 100.0, 0, "success"); - - // Populate DownloadPath from SABnzbd's storage field so the import - // processor knows where the completed files are located. - // Without this, DownloadProcessingJobProcessor throws "has no path set" (#631). - if (!string.IsNullOrEmpty(matchingItem.Path)) - { - dl.DownloadPath = matchingItem.Path; - } - - // Record match type metrics - try - { - if (!string.IsNullOrEmpty(matchingItem.NzoId) && !string.IsNullOrEmpty(dl.GetExternalId()) && string.Equals(matchingItem.NzoId, dl.GetExternalId(), StringComparison.OrdinalIgnoreCase)) - { - _appMetricsService.Increment("sabnzbd.history.match.nzo"); - } - else if (!string.IsNullOrEmpty(matchingItem.Name) && string.Equals(matchingItem.Name, dl.Title, StringComparison.OrdinalIgnoreCase)) - { - _appMetricsService.Increment("sabnzbd.history.match.title_exact"); - } - else - { - _appMetricsService.Increment("sabnzbd.history.match.title_contains"); - } - } - catch (Exception caughtEx_11) when (caughtEx_11 is not OperationCanceledException && caughtEx_11 is not OutOfMemoryException && caughtEx_11 is not StackOverflowException) - { - System.Diagnostics.Debug.WriteLine("Suppressed non-fatal exception in catch block."); - } - _logger.LogInformation("Found completed SABnzbd download: {DownloadTitle} -> {CompletedName} at {Path}", - dl.Title, matchingItem.Name, matchingItem.Path); - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Error processing download {DownloadId} while polling SABnzbd", dl.Id); - } - } - - return downloads; - } - catch (Exception exception) when (exception is not (OperationCanceledException or OutOfMemoryException or StackOverflowException)) - { - throw new DownloadClientAdapterPollingException($"Error polling SABnzbd client {client.Id}"); - } + return await _downloadPollingWorkflow.FetchDownloadsAsync(client, downloads, cancellationToken); } } } diff --git a/listenarr.infrastructure/Adapters/SabnzbdDownloadPollingWorkflow.cs b/listenarr.infrastructure/Adapters/SabnzbdDownloadPollingWorkflow.cs new file mode 100644 index 000000000..5c7161b24 --- /dev/null +++ b/listenarr.infrastructure/Adapters/SabnzbdDownloadPollingWorkflow.cs @@ -0,0 +1,219 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +using System.Text.Json; +using Listenarr.Application.Interfaces; +using Listenarr.Application.Security; +using Listenarr.Domain.Common; +using Listenarr.Domain.Models; +using Listenarr.Domain.Models.Exceptions; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Infrastructure.Adapters +{ + internal sealed class SabnzbdDownloadPollingWorkflow + { + private readonly IHttpClientFactory _httpFactory; + private readonly SabnzbdRequestBuilder _requestBuilder; + private readonly IAppMetricsService _appMetricsService; + private readonly ILogger _logger; + private readonly string _clientType; + + public SabnzbdDownloadPollingWorkflow( + IHttpClientFactory httpFactory, + SabnzbdRequestBuilder requestBuilder, + IAppMetricsService appMetricsService, + ILogger logger, + string clientType) + { + _httpFactory = httpFactory; + _requestBuilder = requestBuilder; + _appMetricsService = appMetricsService; + _logger = logger; + _clientType = clientType; + } + + public async Task> FetchDownloadsAsync( + DownloadClientConfiguration client, + List downloads, + CancellationToken cancellationToken) + { + _logger.LogDebug("Polling SABnzbd client {ClientName}", client.Name); + try + { + using var http = _httpFactory.CreateClient(_clientType); + + var requestContext = _requestBuilder.CreateContext(client); + if (!requestContext.HasApiKey) + { + throw new DownloadClientAdapterPollingException($"SABnzbd API key not configured for client {client.Id}"); + } + + var queueUrl = _requestBuilder.BuildUrl(requestContext, new Dictionary + { + ["mode"] = "queue", + ["output"] = "json" + }); + _logger.LogDebug("SABnzbd poll queue URL (redacted): {Url}", LogRedaction.RedactText(queueUrl, _requestBuilder.BuildSensitiveValues(requestContext))); + using var queueResponse = await http.GetAsync(queueUrl, cancellationToken); + + if (queueResponse.IsSuccessStatusCode) + { + var queueJson = await queueResponse.Content.ReadAsStringAsync(cancellationToken); + var queueDoc = JsonDocument.Parse(queueJson); + + if (queueDoc.RootElement.TryGetProperty("queue", out var queue) && + queue.TryGetProperty("slots", out var queueSlots) && + queueSlots.ValueKind == JsonValueKind.Array) + { + foreach (Download download in downloads) + { + var clientDownloadId = download.GetExternalId(); + + foreach (var slot in queueSlots.EnumerateArray()) + { + try + { + var nzoId = slot.TryGetProperty("nzo_id", out var nzoIdProp) ? nzoIdProp.GetString() ?? "" : ""; + if (!string.IsNullOrEmpty(clientDownloadId) && !string.Equals(nzoId, clientDownloadId, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var filename = slot.TryGetProperty("filename", out var filenameProp) ? filenameProp.GetString() ?? "" : ""; + if (!TitleUtils.AreTitlesSimilar(download.Title, filename)) + { + continue; + } + + var percentage = slot.TryGetProperty("percentage", out var percentageProp) ? SabnzbdResponseMapper.ParseJsonDouble(percentageProp) : 0.0; + var mbleft = slot.TryGetProperty("mbleft", out var mbleftProp) ? SabnzbdResponseMapper.ParseJsonDouble(mbleftProp) : 0.0; + var status = slot.TryGetProperty("status", out var statusProp) ? statusProp.GetString() ?? "" : ""; + + var progressPercent = percentage; + var amountLeft = (long)(mbleft * 1024 * 1024); + + AdapterUtils.MapDownloadProgress(download, progressPercent, amountLeft, status); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning(ex, "Error updating SABnzbd queue progress for slot"); + } + } + } + } + } + + var historyUrl = _requestBuilder.BuildUrl(requestContext, new Dictionary + { + ["mode"] = "history", + ["limit"] = "100", + ["output"] = "json" + }); + _logger.LogDebug("SABnzbd history URL (redacted): {Url}", LogRedaction.RedactText(historyUrl, _requestBuilder.BuildSensitiveValues(requestContext))); + using var historyResponse = await http.GetAsync(historyUrl, cancellationToken); + + if (!historyResponse.IsSuccessStatusCode) + { + throw new DownloadClientAdapterPollingException($"Failed to fetch SABnzbd history for {client.Id}: {historyResponse.StatusCode}"); + } + + var historyJson = await historyResponse.Content.ReadAsStringAsync(cancellationToken); + var historyDoc = JsonDocument.Parse(historyJson); + + if (!historyDoc.RootElement.TryGetProperty("history", out var history) || + !history.TryGetProperty("slots", out var slots) || + slots.ValueKind != JsonValueKind.Array) + { + throw new DownloadClientAdapterPollingException($"No history data found for SABnzbd client {client.Id}"); + } + + var historyLookup = SabnzbdHistoryLookupBuilder.Build(slots, _logger); + var completedItems = historyLookup.CompletedItems; + var failedItems = historyLookup.FailedItems; + + _logger.LogDebug("Found {CompletedCount} completed items in SABnzbd history for client {ClientName}", + completedItems.Count, client.Name); + + foreach (var dl in downloads) + { + if (dl.Status == DownloadStatus.Moved || + dl.Status == DownloadStatus.Processing || + dl.Status == DownloadStatus.ImportPending) + continue; + + try + { + var failedMatch = failedItems.FirstOrDefault(item => + (!string.IsNullOrEmpty(item.NzoId) && !string.IsNullOrEmpty(dl.GetExternalId()) && + string.Equals(item.NzoId, dl.GetExternalId(), StringComparison.OrdinalIgnoreCase)) || + string.Equals(item.Name, dl.Title, StringComparison.OrdinalIgnoreCase) || + (!string.IsNullOrEmpty(dl.Title) && item.Name.Contains(dl.Title, StringComparison.OrdinalIgnoreCase)) + ); + + if (!string.IsNullOrEmpty(failedMatch.Name)) + { + continue; + } + + var matchingItem = completedItems.FirstOrDefault(item => + (!string.IsNullOrEmpty(item.NzoId) && !string.IsNullOrEmpty(dl.GetExternalId()) && + string.Equals(item.NzoId, dl.GetExternalId(), StringComparison.OrdinalIgnoreCase)) || + string.Equals(item.Name, dl.Title, StringComparison.OrdinalIgnoreCase) || + (!string.IsNullOrEmpty(dl.Title) && item.Name.Contains(dl.Title, StringComparison.OrdinalIgnoreCase)) + ); + + if (!string.IsNullOrEmpty(matchingItem.Name)) + { + AdapterUtils.MapDownloadProgress(dl, 100.0, 0, "success"); + + if (!string.IsNullOrEmpty(matchingItem.Path)) + { + dl.DownloadPath = matchingItem.Path; + } + + try + { + if (!string.IsNullOrEmpty(matchingItem.NzoId) && !string.IsNullOrEmpty(dl.GetExternalId()) && string.Equals(matchingItem.NzoId, dl.GetExternalId(), StringComparison.OrdinalIgnoreCase)) + { + _appMetricsService.Increment("sabnzbd.history.match.nzo"); + } + else if (!string.IsNullOrEmpty(matchingItem.Name) && string.Equals(matchingItem.Name, dl.Title, StringComparison.OrdinalIgnoreCase)) + { + _appMetricsService.Increment("sabnzbd.history.match.title_exact"); + } + else + { + _appMetricsService.Increment("sabnzbd.history.match.title_contains"); + } + } + catch (Exception caughtEx) when (caughtEx is not OperationCanceledException && caughtEx is not OutOfMemoryException && caughtEx is not StackOverflowException) + { + System.Diagnostics.Debug.WriteLine("Suppressed non-fatal exception in catch block."); + } + _logger.LogInformation("Found completed SABnzbd download: {DownloadTitle} -> {CompletedName} at {Path}", + dl.Title, matchingItem.Name, matchingItem.Path); + } + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning(ex, "Error processing download {DownloadId} while polling SABnzbd", dl.Id); + } + } + + return downloads; + } + catch (Exception exception) when (exception is not (OperationCanceledException or OutOfMemoryException or StackOverflowException)) + { + throw new DownloadClientAdapterPollingException($"Error polling SABnzbd client {client.Id}"); + } + } + } +} From 33eaff7c84c3332aed01b6cfd55eca3e4db5ac67 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Sat, 13 Jun 2026 21:35:38 -0400 Subject: [PATCH 52/84] refactor: extract nzbget polling workflow - Move NZBGet JSON-RPC polling and progress reconciliation into a workflow - Keep adapter interface and matching behavior unchanged --- .../Adapters/NzbgetAdapter.cs | 106 +------------ .../Adapters/NzbgetDownloadPollingWorkflow.cs | 139 ++++++++++++++++++ 2 files changed, 142 insertions(+), 103 deletions(-) create mode 100644 listenarr.infrastructure/Adapters/NzbgetDownloadPollingWorkflow.cs diff --git a/listenarr.infrastructure/Adapters/NzbgetAdapter.cs b/listenarr.infrastructure/Adapters/NzbgetAdapter.cs index 7db3e878a..2be7e3771 100644 --- a/listenarr.infrastructure/Adapters/NzbgetAdapter.cs +++ b/listenarr.infrastructure/Adapters/NzbgetAdapter.cs @@ -16,13 +16,10 @@ * along with this program. If not, see . */ using System.Net; -using System.Text; using System.Text.Json; using Listenarr.Application.Interfaces; using Listenarr.Application.Security; -using Listenarr.Domain.Common; using Listenarr.Domain.Models; -using Listenarr.Domain.Models.Exceptions; using Microsoft.Extensions.Logging; namespace Listenarr.Infrastructure.Adapters @@ -38,6 +35,7 @@ public class NzbgetAdapter : IDownloadClientAdapter private readonly ILogger _logger; private readonly NzbgetXmlRpcClient _xmlRpcClient; private readonly NzbgetNzbDownloader _nzbDownloader; + private readonly NzbgetDownloadPollingWorkflow _downloadPollingWorkflow; public NzbgetAdapter( IHttpClientFactory httpClientFactory, @@ -49,6 +47,7 @@ public NzbgetAdapter( _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _xmlRpcClient = new NzbgetXmlRpcClient(_httpClientFactory, ClientType); _nzbDownloader = new NzbgetNzbDownloader(_httpClientFactory, ClientType, _logger); + _downloadPollingWorkflow = new NzbgetDownloadPollingWorkflow(_httpClientFactory, _logger, ClientType); } public async Task<(bool Success, string Message)> TestConnectionAsync(DownloadClientConfiguration client, CancellationToken ct = default) @@ -689,106 +688,7 @@ public async Task> FetchDownloadsAsync( List downloads, CancellationToken cancellationToken) { - _logger.LogDebug("Polling NZBGet client {ClientName}", client.Name); - try - { - var baseUrl = DownloadClientUriBuilder.BuildUri(client, "/jsonrpc"); - - using var http = _httpClientFactory.CreateClient(ClientType); - - // Add basic auth if credentials provided - var authHeader = NzbgetAuthentication.BuildAuthHeader(client); - if (authHeader != null) - { - http.DefaultRequestHeaders.Authorization = authHeader; - } - - // Get active downloads from status for progress updates - var statusRequest = new - { - method = "status", - id = 2 - }; - - var statusJsonContent = JsonSerializer.Serialize(statusRequest); - using var statusHttpContent = new StringContent(statusJsonContent, Encoding.UTF8, "application/json"); - - using var statusResponse = await http.PostAsync(baseUrl, statusHttpContent, cancellationToken); - - if (statusResponse.IsSuccessStatusCode) - { - var statusJson = await statusResponse.Content.ReadAsStringAsync(cancellationToken); - var statusDoc = JsonDocument.Parse(statusJson); - - if (statusDoc.RootElement.TryGetProperty("result", out var statusResult)) - { - // Get queue for active downloads - var queueRequest = new - { - method = "listgroups", - id = 3 - }; - - var queueJsonContent = JsonSerializer.Serialize(queueRequest); - using var queueHttpContent = new StringContent(queueJsonContent, Encoding.UTF8, "application/json"); - - using var queueResponse = await http.PostAsync(baseUrl, queueHttpContent, cancellationToken); - - if (queueResponse.IsSuccessStatusCode) - { - var queueJson = await queueResponse.Content.ReadAsStringAsync(cancellationToken); - var queueDoc = JsonDocument.Parse(queueJson); - - if (queueDoc.RootElement.TryGetProperty("result", out var queueResult) && queueResult.ValueKind == JsonValueKind.Array) - { - foreach (var group in queueResult.EnumerateArray()) - { - try - { - var nzbId = group.TryGetProperty("NZBID", out var nzbIdProp) ? nzbIdProp.GetInt32() : 0; - var nzbName = group.TryGetProperty("NZBName", out var nameProp) ? nameProp.GetString() ?? "" : ""; - var status = group.TryGetProperty("Status", out var statusProp) ? statusProp.GetString() ?? "" : ""; - var fileSizeMB = group.TryGetProperty("FileSizeMB", out var sizeProp) ? sizeProp.GetString() ?? "" : ""; - var remainingSizeMB = group.TryGetProperty("RemainingSizeMB", out var remainingSizeProp) ? remainingSizeProp.GetString() ?? "" : ""; - // Find matching download by NZB ID - var matchingDownload = downloads.FirstOrDefault(dl => - { - var clientItemId = dl.GetExternalId(); - return !string.IsNullOrEmpty(clientItemId) && - clientItemId.Equals(nzbId.ToString(), StringComparison.OrdinalIgnoreCase); - }); - - if (matchingDownload == null && !string.IsNullOrEmpty(nzbName)) - { - matchingDownload = downloads.FirstOrDefault(dl => TitleUtils.AreTitlesSimilar(dl.Title, nzbName)); - } - - if (matchingDownload != null && - double.TryParse(fileSizeMB, out var totalMB) && - double.TryParse(remainingSizeMB, out var remainingMB)) - { - var progress = totalMB > 0 ? (totalMB - remainingMB) / totalMB : 0.0; - var amountLeft = (long)(remainingMB * 1024 * 1024); // Convert MB to bytes - - AdapterUtils.MapDownloadProgress(matchingDownload, progress, amountLeft, status); - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Error updating NZBGet queue progress for group"); - } - } - } - } - } - } - - return downloads; - } - catch (Exception exception) when (exception is not (OperationCanceledException or OutOfMemoryException or StackOverflowException)) - { - throw new DownloadClientAdapterPollingException($"Error polling NZBGet client {client.Id}", exception); - } + return await _downloadPollingWorkflow.FetchDownloadsAsync(client, downloads, cancellationToken); } } } diff --git a/listenarr.infrastructure/Adapters/NzbgetDownloadPollingWorkflow.cs b/listenarr.infrastructure/Adapters/NzbgetDownloadPollingWorkflow.cs new file mode 100644 index 000000000..8bd906a91 --- /dev/null +++ b/listenarr.infrastructure/Adapters/NzbgetDownloadPollingWorkflow.cs @@ -0,0 +1,139 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +using System.Text; +using System.Text.Json; +using Listenarr.Domain.Common; +using Listenarr.Domain.Models; +using Listenarr.Domain.Models.Exceptions; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Infrastructure.Adapters +{ + internal sealed class NzbgetDownloadPollingWorkflow + { + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger _logger; + private readonly string _clientType; + + public NzbgetDownloadPollingWorkflow( + IHttpClientFactory httpClientFactory, + ILogger logger, + string clientType) + { + _httpClientFactory = httpClientFactory; + _logger = logger; + _clientType = clientType; + } + + public async Task> FetchDownloadsAsync( + DownloadClientConfiguration client, + List downloads, + CancellationToken cancellationToken) + { + _logger.LogDebug("Polling NZBGet client {ClientName}", client.Name); + try + { + var baseUrl = DownloadClientUriBuilder.BuildUri(client, "/jsonrpc"); + + using var http = _httpClientFactory.CreateClient(_clientType); + + var authHeader = NzbgetAuthentication.BuildAuthHeader(client); + if (authHeader != null) + { + http.DefaultRequestHeaders.Authorization = authHeader; + } + + var statusRequest = new + { + method = "status", + id = 2 + }; + + var statusJsonContent = JsonSerializer.Serialize(statusRequest); + using var statusHttpContent = new StringContent(statusJsonContent, Encoding.UTF8, "application/json"); + + using var statusResponse = await http.PostAsync(baseUrl, statusHttpContent, cancellationToken); + + if (statusResponse.IsSuccessStatusCode) + { + var statusJson = await statusResponse.Content.ReadAsStringAsync(cancellationToken); + var statusDoc = JsonDocument.Parse(statusJson); + + if (statusDoc.RootElement.TryGetProperty("result", out _)) + { + var queueRequest = new + { + method = "listgroups", + id = 3 + }; + + var queueJsonContent = JsonSerializer.Serialize(queueRequest); + using var queueHttpContent = new StringContent(queueJsonContent, Encoding.UTF8, "application/json"); + + using var queueResponse = await http.PostAsync(baseUrl, queueHttpContent, cancellationToken); + + if (queueResponse.IsSuccessStatusCode) + { + var queueJson = await queueResponse.Content.ReadAsStringAsync(cancellationToken); + var queueDoc = JsonDocument.Parse(queueJson); + + if (queueDoc.RootElement.TryGetProperty("result", out var queueResult) && queueResult.ValueKind == JsonValueKind.Array) + { + foreach (var group in queueResult.EnumerateArray()) + { + try + { + var nzbId = group.TryGetProperty("NZBID", out var nzbIdProp) ? nzbIdProp.GetInt32() : 0; + var nzbName = group.TryGetProperty("NZBName", out var nameProp) ? nameProp.GetString() ?? "" : ""; + var status = group.TryGetProperty("Status", out var statusProp) ? statusProp.GetString() ?? "" : ""; + var fileSizeMB = group.TryGetProperty("FileSizeMB", out var sizeProp) ? sizeProp.GetString() ?? "" : ""; + var remainingSizeMB = group.TryGetProperty("RemainingSizeMB", out var remainingSizeProp) ? remainingSizeProp.GetString() ?? "" : ""; + var matchingDownload = downloads.FirstOrDefault(dl => + { + var clientItemId = dl.GetExternalId(); + return !string.IsNullOrEmpty(clientItemId) && + clientItemId.Equals(nzbId.ToString(), StringComparison.OrdinalIgnoreCase); + }); + + if (matchingDownload == null && !string.IsNullOrEmpty(nzbName)) + { + matchingDownload = downloads.FirstOrDefault(dl => TitleUtils.AreTitlesSimilar(dl.Title, nzbName)); + } + + if (matchingDownload != null && + double.TryParse(fileSizeMB, out var totalMB) && + double.TryParse(remainingSizeMB, out var remainingMB)) + { + var progress = totalMB > 0 ? (totalMB - remainingMB) / totalMB : 0.0; + var amountLeft = (long)(remainingMB * 1024 * 1024); + + AdapterUtils.MapDownloadProgress(matchingDownload, progress, amountLeft, status); + } + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning(ex, "Error updating NZBGet queue progress for group"); + } + } + } + } + } + } + + return downloads; + } + catch (Exception exception) when (exception is not (OperationCanceledException or OutOfMemoryException or StackOverflowException)) + { + throw new DownloadClientAdapterPollingException($"Error polling NZBGet client {client.Id}", exception); + } + } + } +} From f9c18b166b40729ecd5ee5459fdf08dfef337b1a Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Sat, 13 Jun 2026 21:44:34 -0400 Subject: [PATCH 53/84] refactor: extract transmission polling workflow - Move Transmission RPC polling and torrent reconciliation into a workflow - Preserve session-id retry and seed-limit metadata behavior --- .../Adapters/TransmissionAdapter.cs | 276 +---------------- .../TransmissionDownloadPollingWorkflow.cs | 288 ++++++++++++++++++ 2 files changed, 291 insertions(+), 273 deletions(-) create mode 100644 listenarr.infrastructure/Adapters/TransmissionDownloadPollingWorkflow.cs diff --git a/listenarr.infrastructure/Adapters/TransmissionAdapter.cs b/listenarr.infrastructure/Adapters/TransmissionAdapter.cs index a5c50b602..7ed165b8b 100644 --- a/listenarr.infrastructure/Adapters/TransmissionAdapter.cs +++ b/listenarr.infrastructure/Adapters/TransmissionAdapter.cs @@ -16,13 +16,10 @@ * along with this program. If not, see . */ using System.Net; -using System.Text.Encodings.Web; using System.Text.Json; using Listenarr.Application.Interfaces; using Listenarr.Application.Security; -using Listenarr.Domain.Common; using Listenarr.Domain.Models; -using Listenarr.Domain.Models.Exceptions; using Listenarr.Infrastructure.Torrents; using Microsoft.Extensions.Logging; @@ -39,6 +36,7 @@ public class TransmissionAdapter : IDownloadClientAdapter private readonly ILogger _logger; private readonly TransmissionTorrentAddPlanner _torrentAddPlanner; private readonly TransmissionRpcClient _rpcClient; + private readonly TransmissionDownloadPollingWorkflow _downloadPollingWorkflow; public TransmissionAdapter(IHttpClientFactory httpClientFactory, ITorrentFileDownloader torrentFileDownloader, ILogger logger) { @@ -47,6 +45,7 @@ public TransmissionAdapter(IHttpClientFactory httpClientFactory, ITorrentFileDow _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _torrentAddPlanner = new TransmissionTorrentAddPlanner(_torrentFileDownloader, _logger); _rpcClient = new TransmissionRpcClient(_httpClientFactory, ClientType, _logger); + _downloadPollingWorkflow = new TransmissionDownloadPollingWorkflow(_httpClientFactory, _logger, ClientType); } public async Task<(bool Success, string Message)> TestConnectionAsync(DownloadClientConfiguration client, CancellationToken ct = default) @@ -595,276 +594,7 @@ public async Task> FetchDownloadsAsync( List downloads, CancellationToken cancellationToken) { - _logger.LogInformation("Polling Transmission client {ClientName} for {Count} downloads", client.Name, downloads.Count); - try - { - var rpcPath = "/transmission/rpc"; - if (client.Settings?.TryGetValue("urlBase", out var urlBaseObj) is true) - { - var custom = urlBaseObj?.ToString()?.Trim(); - if (!string.IsNullOrEmpty(custom)) - { - rpcPath = custom.StartsWith('/') ? custom : "/" + custom; - } - } - var baseUrl = DownloadClientUriBuilder.BuildUri(client, rpcPath).ToString(); - using var http = _httpClientFactory.CreateClient(ClientType); - - // Resolve removeCompletedDownloads for CanMoveFiles/CanBeRemoved evaluation - bool txRemoveCompletedDownloads = !string.IsNullOrEmpty(client.RemoveCompletedDownloads) && - client.RemoveCompletedDownloads != "none"; - - // Prepare RPC payload for torrent-get (includes seed limit fields for Sonarr parity) - var rpc = new - { - method = "torrent-get", - arguments = new - { - fields = new[] { "id", "hashString", "name", "percentDone", "leftUntilDone", "isFinished", "status", "downloadDir", - "uploadRatio", "seedRatioMode", "seedRatioLimit", "seedIdleMode", "seedIdleLimit", "secondsSeeding" } - }, - tag = 4 - }; - - var serializedPayload = System.Text.Json.JsonSerializer.Serialize(rpc, new JsonSerializerOptions { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }); - string? sessionId = null; - - _logger.LogDebug("PollTransmission RPC request to {BaseUrl}", baseUrl); - - // Transmission CSRF protection: first request gets 409 with session-id, retry with that session-id - // This mirrors TransmissionAdapter.InvokeRpcAsync pattern - for (var attempt = 0; attempt < 2; attempt++) - { - using var request = new HttpRequestMessage(HttpMethod.Post, baseUrl) - { - Content = new StringContent(serializedPayload, System.Text.Encoding.UTF8, "application/json") - }; - - // Add session-id header if we have one (from previous 409 retry) - if (!string.IsNullOrEmpty(sessionId)) - { - request.Headers.Add("X-Transmission-Session-Id", sessionId); - _logger.LogDebug("PollTransmission using X-Transmission-Session-Id: {SessionId}", sessionId); - } - - // Add Basic auth header if configured - if (!string.IsNullOrWhiteSpace(client.Username)) - { - var credentials = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes($"{client.Username}:{client.Password}")); - request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", credentials); - } - - var resp = await http.SendAsync(request, cancellationToken); - var respText = await resp.Content.ReadAsStringAsync(cancellationToken); - - // Handle 409 Conflict (CSRF session-id flow) - if (resp.StatusCode == System.Net.HttpStatusCode.Conflict && attempt == 0) - { - if (resp.Headers.TryGetValues("X-Transmission-Session-Id", out var values)) - { - sessionId = values.FirstOrDefault(); - _logger.LogDebug("PollTransmission received 409 Conflict, retrying with session-id: {SessionId}", sessionId); - continue; // Retry with session-id - } - } - - // Check for success - _logger.LogInformation("PollTransmission HTTP response: {StatusCode}", resp.StatusCode); - if (!resp.IsSuccessStatusCode) - { - throw new DownloadClientAdapterPollingException($"PollTransmission early-return: non-success HTTP status {resp.StatusCode} from {baseUrl} for client {client.Id}"); - } - - // Process successful response - _logger.LogDebug("PollTransmission response text length: {Length}", respText?.Length ?? 0); - if (string.IsNullOrWhiteSpace(respText)) - { - throw new DownloadClientAdapterPollingException($"PollTransmission early-return: empty response content for client {client.Id}"); - } - - // Parse response and continue with torrent processing - JsonElement doc; - try - { - doc = JsonSerializer.Deserialize(respText)!; - } - catch (Exception exception) when (exception is not (OperationCanceledException or OutOfMemoryException or StackOverflowException)) - { - throw new DownloadClientAdapterPollingException($"PollTransmission failed to parse JSON response for client {client.Id}", exception); - } - - if (!doc.TryGetProperty("arguments", out var args)) - { - throw new DownloadClientAdapterPollingException($"PollTransmission early-return: missing 'arguments' in response for client {client.Id}"); - } - if (!args.TryGetProperty("torrents", out var torrents)) - { - throw new DownloadClientAdapterPollingException($"PollTransmission early-return: missing 'torrents' in 'arguments' for client {client.Id}"); - } - if (torrents.ValueKind != JsonValueKind.Array) - { - throw new DownloadClientAdapterPollingException($"PollTransmission early-return: 'torrents' not an array (Kind={torrents.ValueKind}) for client {client.Id}"); - } - _logger.LogInformation("PollTransmission found {Count} torrents in response", torrents.GetArrayLength()); - - // Fetch session config for seed limit evaluation (Sonarr parity) - bool txSessionSeedRatioLimited = false; - double txSessionSeedRatioLimit = 0; - bool txSessionIdleSeedingLimitEnabled = false; - int txSessionIdleSeedingLimit = 0; - try - { - var sessionPayload = System.Text.Json.JsonSerializer.Serialize(new { method = "session-get", arguments = new { }, tag = 99 }, new JsonSerializerOptions { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }); - using var sessionReq = new HttpRequestMessage(HttpMethod.Post, baseUrl) - { - Content = new StringContent(sessionPayload, System.Text.Encoding.UTF8, "application/json") - }; - if (!string.IsNullOrEmpty(sessionId)) - sessionReq.Headers.Add("X-Transmission-Session-Id", sessionId); - if (!string.IsNullOrWhiteSpace(client.Username)) - { - var creds = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes($"{client.Username}:{client.Password}")); - sessionReq.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", creds); - } - using var sessionResp = await http.SendAsync(sessionReq, cancellationToken); - if (sessionResp.IsSuccessStatusCode) - { - var sessionText = await sessionResp.Content.ReadAsStringAsync(cancellationToken); - var sessionDoc = System.Text.Json.JsonSerializer.Deserialize(sessionText); - if (sessionDoc.TryGetProperty("arguments", out var sessionArgs)) - { - txSessionSeedRatioLimited = (sessionArgs.TryGetProperty("seedRatioLimited", out var srl) || sessionArgs.TryGetProperty("seed_ratio_limited", out srl)) && srl.GetBoolean(); - txSessionSeedRatioLimit = (sessionArgs.TryGetProperty("seedRatioLimit", out var srlv) || sessionArgs.TryGetProperty("seed_ratio_limit", out srlv)) ? srlv.GetDouble() : 0; - txSessionIdleSeedingLimitEnabled = (sessionArgs.TryGetProperty("idle-seeding-limit-enabled", out var isle) || sessionArgs.TryGetProperty("idle_seeding_limit_enabled", out isle)) && isle.GetBoolean(); - txSessionIdleSeedingLimit = (sessionArgs.TryGetProperty("idle-seeding-limit", out var isl) || sessionArgs.TryGetProperty("idle_seeding_limit", out isl)) ? isl.GetInt32() : 0; - } - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogDebug(ex, "Failed to fetch Transmission session config for seed limit evaluation"); - } - - // Process torrents (continue with existing logic below) - foreach (var dl in downloads) - { - try - { - // Attempt to match by hashString (preferred) or name - var matching = torrents.EnumerateArray().FirstOrDefault(t => - { - // First try matching by hash (most reliable) - if (dl.Metadata != null && dl.Metadata.TryGetValue("TorrentHash", out var hashObj)) - { - var downloadHash = hashObj?.ToString() ?? string.Empty; - if (!string.IsNullOrEmpty(downloadHash)) - { - var hash = t.TryGetProperty("hashString", out var h) ? h.GetString() ?? string.Empty : string.Empty; - if (string.Equals(hash, downloadHash, StringComparison.OrdinalIgnoreCase)) - return true; - } - } - - // Fallback to exact name or normalized title match only. - // No fuzzy/path-based matching to avoid cross-contamination. - var name = t.TryGetProperty("name", out var n) ? n.GetString() ?? string.Empty : string.Empty; - if (string.Equals(name, dl.Title, StringComparison.OrdinalIgnoreCase)) - return true; - if (!string.IsNullOrEmpty(name) && !string.IsNullOrEmpty(dl.Title) && - string.Equals(TitleUtils.NormalizeTitle(name), TitleUtils.NormalizeTitle(dl.Title), StringComparison.OrdinalIgnoreCase)) - return true; - return false; - }); - - if (matching.ValueKind == System.Text.Json.JsonValueKind.Undefined) - { - _logger.LogDebug("Could not find matching torrent for download {DownloadId} ({Title}) in Transmission", dl.Id, dl.Title); - continue; - } - - _logger.LogDebug("Matched download {DownloadId} to Transmission torrent", dl.Id); - - var percent = matching.TryGetProperty("percentDone", out var p) ? p.GetDouble() : 0.0; - var left = matching.TryGetProperty("leftUntilDone", out var l) ? l.GetInt64() : 0L; - var statusCode = matching.TryGetProperty("status", out var statusProp) ? statusProp.GetInt32() : 0; - - // Map Transmission status code to status string (same as TransmissionAdapter) - var status = statusCode switch - { - 0 => "paused", // TR_STATUS_STOPPED - 1 => "queued", // TR_STATUS_CHECK_WAIT - 2 => "downloading", // TR_STATUS_CHECK - 3 => "queued", // TR_STATUS_DOWNLOAD_WAIT - 4 => "downloading", // TR_STATUS_DOWNLOAD - 5 => "queued", // TR_STATUS_SEED_WAIT - 6 => "seeding", // TR_STATUS_SEED - 7 => "failed", // TR_STATUS_ISOLATED - _ => "unknown" - }; - - AdapterUtils.MapDownloadProgress(dl, percent * 100, left, status); - - // Compute and persist CanMoveFiles/CanBeRemoved (Sonarr parity) - try - { - var txUploadRatio = (matching.TryGetProperty("uploadRatio", out var txRatP) || matching.TryGetProperty("upload_ratio", out txRatP)) ? txRatP.GetDouble() : 0d; - var txSeedRatioMode = (matching.TryGetProperty("seedRatioMode", out var txSrmP) || matching.TryGetProperty("seed_ratio_mode", out txSrmP)) ? txSrmP.GetInt32() : 0; - var txSeedRatioLimit = (matching.TryGetProperty("seedRatioLimit", out var txSrlP) || matching.TryGetProperty("seed_ratio_limit", out txSrlP)) ? txSrlP.GetDouble() : 0d; - var txSeedIdleMode = (matching.TryGetProperty("seedIdleMode", out var txSimP) || matching.TryGetProperty("seed_idle_mode", out txSimP)) ? txSimP.GetInt32() : 0; - var txSeedIdleLimit = (matching.TryGetProperty("seedIdleLimit", out var txSilP) || matching.TryGetProperty("seed_idle_limit", out txSilP)) ? txSilP.GetInt32() : 0; - var txSecondsSeeding = (matching.TryGetProperty("secondsSeeding", out var txSsP) || matching.TryGetProperty("seconds_seeding", out txSsP)) ? txSsP.GetInt64() : 0L; - - var txIsStopped = statusCode == 0; - var txIsSeeding = statusCode == 6; - var txSeedLimitReached = TransmissionSeedLimitEvaluator.HasReachedSeedLimit( - txIsStopped, txIsSeeding, txUploadRatio, - txSeedRatioMode, txSeedRatioLimit, - txSeedIdleMode, txSeedIdleLimit, txSecondsSeeding, - txSessionSeedRatioLimited, txSessionSeedRatioLimit, - txSessionIdleSeedingLimitEnabled, txSessionIdleSeedingLimit); - var txCanBeRemoved = txRemoveCompletedDownloads && txSeedLimitReached; - var txCanMoveFiles = txCanBeRemoved && txIsStopped; - - if (dl.Metadata == null) dl.Metadata = new Dictionary(); - dl.Metadata["CanMoveFiles"] = txCanMoveFiles; - dl.Metadata["CanBeRemoved"] = txCanBeRemoved; - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogDebug(ex, "Failed to persist CanMoveFiles/CanBeRemoved for Transmission download {DownloadId}", dl.Id); - } - - // Skip finalization/progress logic for downloads that are already - // being processed, awaiting import, or fully imported. - if (dl.Status == DownloadStatus.Moved || - dl.Status == DownloadStatus.Processing || - dl.Status == DownloadStatus.ImportPending) - { - _logger.LogDebug("Skipping finalization for {Status} download {DownloadId}", dl.Status, dl.Id); - continue; - } - - // Check for completion using same logic as TransmissionAdapter - var isComplete = percent >= 1.0 && (status == "seeding" || status == "queued" || status == "paused"); - _logger.LogInformation("PollTransmission download {DownloadId}: percent={Percent}, status={Status}, isComplete={IsComplete}", dl.Id, percent, status, isComplete); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Error processing download {DownloadId} while polling Transmission", dl.Id); - } - } - - return downloads; - } - - // If we reach here, session-id flow failed after retries - throw new DownloadClientAdapterPollingException($"PollTransmission failed to establish session after retries for client {client.Id}"); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Error polling Transmission client {ClientName}", client.Name); - throw new DownloadClientAdapterPollingException($"Error polling Transmission client {client.Id}"); - } + return await _downloadPollingWorkflow.FetchDownloadsAsync(client, downloads, cancellationToken); } } diff --git a/listenarr.infrastructure/Adapters/TransmissionDownloadPollingWorkflow.cs b/listenarr.infrastructure/Adapters/TransmissionDownloadPollingWorkflow.cs new file mode 100644 index 000000000..e05149088 --- /dev/null +++ b/listenarr.infrastructure/Adapters/TransmissionDownloadPollingWorkflow.cs @@ -0,0 +1,288 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +using System.Text.Encodings.Web; +using System.Text.Json; +using Listenarr.Domain.Common; +using Listenarr.Domain.Models; +using Listenarr.Domain.Models.Exceptions; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Infrastructure.Adapters +{ + internal sealed class TransmissionDownloadPollingWorkflow + { + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger _logger; + private readonly string _clientType; + + public TransmissionDownloadPollingWorkflow(IHttpClientFactory httpClientFactory, ILogger logger, string clientType) + { + _httpClientFactory = httpClientFactory; + _logger = logger; + _clientType = clientType; + } + + public async Task> FetchDownloadsAsync( + DownloadClientConfiguration client, + List downloads, + CancellationToken cancellationToken) + { + _logger.LogInformation("Polling Transmission client {ClientName} for {Count} downloads", client.Name, downloads.Count); + try + { + var rpcPath = "/transmission/rpc"; + if (client.Settings?.TryGetValue("urlBase", out var urlBaseObj) is true) + { + var custom = urlBaseObj?.ToString()?.Trim(); + if (!string.IsNullOrEmpty(custom)) + { + rpcPath = custom.StartsWith('/') ? custom : "/" + custom; + } + } + var baseUrl = DownloadClientUriBuilder.BuildUri(client, rpcPath).ToString(); + using var http = _httpClientFactory.CreateClient(_clientType); + + bool txRemoveCompletedDownloads = !string.IsNullOrEmpty(client.RemoveCompletedDownloads) && + client.RemoveCompletedDownloads != "none"; + + var rpc = new + { + method = "torrent-get", + arguments = new + { + fields = new[] { "id", "hashString", "name", "percentDone", "leftUntilDone", "isFinished", "status", "downloadDir", + "uploadRatio", "seedRatioMode", "seedRatioLimit", "seedIdleMode", "seedIdleLimit", "secondsSeeding" } + }, + tag = 4 + }; + + var serializedPayload = JsonSerializer.Serialize(rpc, new JsonSerializerOptions { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }); + string? sessionId = null; + + _logger.LogDebug("PollTransmission RPC request to {BaseUrl}", baseUrl); + + for (var attempt = 0; attempt < 2; attempt++) + { + using var request = new HttpRequestMessage(HttpMethod.Post, baseUrl) + { + Content = new StringContent(serializedPayload, System.Text.Encoding.UTF8, "application/json") + }; + + if (!string.IsNullOrEmpty(sessionId)) + { + request.Headers.Add("X-Transmission-Session-Id", sessionId); + _logger.LogDebug("PollTransmission using X-Transmission-Session-Id: {SessionId}", sessionId); + } + + if (!string.IsNullOrWhiteSpace(client.Username)) + { + var credentials = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes($"{client.Username}:{client.Password}")); + request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", credentials); + } + + var resp = await http.SendAsync(request, cancellationToken); + var respText = await resp.Content.ReadAsStringAsync(cancellationToken); + + if (resp.StatusCode == System.Net.HttpStatusCode.Conflict && attempt == 0) + { + if (resp.Headers.TryGetValues("X-Transmission-Session-Id", out var values)) + { + sessionId = values.FirstOrDefault(); + _logger.LogDebug("PollTransmission received 409 Conflict, retrying with session-id: {SessionId}", sessionId); + continue; + } + } + + _logger.LogInformation("PollTransmission HTTP response: {StatusCode}", resp.StatusCode); + if (!resp.IsSuccessStatusCode) + { + throw new DownloadClientAdapterPollingException($"PollTransmission early-return: non-success HTTP status {resp.StatusCode} from {baseUrl} for client {client.Id}"); + } + + _logger.LogDebug("PollTransmission response text length: {Length}", respText?.Length ?? 0); + if (string.IsNullOrWhiteSpace(respText)) + { + throw new DownloadClientAdapterPollingException($"PollTransmission early-return: empty response content for client {client.Id}"); + } + + JsonElement doc; + try + { + doc = JsonSerializer.Deserialize(respText)!; + } + catch (Exception exception) when (exception is not (OperationCanceledException or OutOfMemoryException or StackOverflowException)) + { + throw new DownloadClientAdapterPollingException($"PollTransmission failed to parse JSON response for client {client.Id}", exception); + } + + if (!doc.TryGetProperty("arguments", out var args)) + { + throw new DownloadClientAdapterPollingException($"PollTransmission early-return: missing 'arguments' in response for client {client.Id}"); + } + if (!args.TryGetProperty("torrents", out var torrents)) + { + throw new DownloadClientAdapterPollingException($"PollTransmission early-return: missing 'torrents' in 'arguments' for client {client.Id}"); + } + if (torrents.ValueKind != JsonValueKind.Array) + { + throw new DownloadClientAdapterPollingException($"PollTransmission early-return: 'torrents' not an array (Kind={torrents.ValueKind}) for client {client.Id}"); + } + _logger.LogInformation("PollTransmission found {Count} torrents in response", torrents.GetArrayLength()); + + bool txSessionSeedRatioLimited = false; + double txSessionSeedRatioLimit = 0; + bool txSessionIdleSeedingLimitEnabled = false; + int txSessionIdleSeedingLimit = 0; + try + { + var sessionPayload = JsonSerializer.Serialize(new { method = "session-get", arguments = new { }, tag = 99 }, new JsonSerializerOptions { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }); + using var sessionReq = new HttpRequestMessage(HttpMethod.Post, baseUrl) + { + Content = new StringContent(sessionPayload, System.Text.Encoding.UTF8, "application/json") + }; + if (!string.IsNullOrEmpty(sessionId)) + sessionReq.Headers.Add("X-Transmission-Session-Id", sessionId); + if (!string.IsNullOrWhiteSpace(client.Username)) + { + var creds = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes($"{client.Username}:{client.Password}")); + sessionReq.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", creds); + } + using var sessionResp = await http.SendAsync(sessionReq, cancellationToken); + if (sessionResp.IsSuccessStatusCode) + { + var sessionText = await sessionResp.Content.ReadAsStringAsync(cancellationToken); + var sessionDoc = JsonSerializer.Deserialize(sessionText); + if (sessionDoc.TryGetProperty("arguments", out var sessionArgs)) + { + txSessionSeedRatioLimited = (sessionArgs.TryGetProperty("seedRatioLimited", out var srl) || sessionArgs.TryGetProperty("seed_ratio_limited", out srl)) && srl.GetBoolean(); + txSessionSeedRatioLimit = (sessionArgs.TryGetProperty("seedRatioLimit", out var srlv) || sessionArgs.TryGetProperty("seed_ratio_limit", out srlv)) ? srlv.GetDouble() : 0; + txSessionIdleSeedingLimitEnabled = (sessionArgs.TryGetProperty("idle-seeding-limit-enabled", out var isle) || sessionArgs.TryGetProperty("idle_seeding_limit_enabled", out isle)) && isle.GetBoolean(); + txSessionIdleSeedingLimit = (sessionArgs.TryGetProperty("idle-seeding-limit", out var isl) || sessionArgs.TryGetProperty("idle_seeding_limit", out isl)) ? isl.GetInt32() : 0; + } + } + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogDebug(ex, "Failed to fetch Transmission session config for seed limit evaluation"); + } + + foreach (var dl in downloads) + { + try + { + var matching = torrents.EnumerateArray().FirstOrDefault(t => + { + if (dl.Metadata != null && dl.Metadata.TryGetValue("TorrentHash", out var hashObj)) + { + var downloadHash = hashObj?.ToString() ?? string.Empty; + if (!string.IsNullOrEmpty(downloadHash)) + { + var hash = t.TryGetProperty("hashString", out var h) ? h.GetString() ?? string.Empty : string.Empty; + if (string.Equals(hash, downloadHash, StringComparison.OrdinalIgnoreCase)) + return true; + } + } + + var name = t.TryGetProperty("name", out var n) ? n.GetString() ?? string.Empty : string.Empty; + if (string.Equals(name, dl.Title, StringComparison.OrdinalIgnoreCase)) + return true; + if (!string.IsNullOrEmpty(name) && !string.IsNullOrEmpty(dl.Title) && + string.Equals(TitleUtils.NormalizeTitle(name), TitleUtils.NormalizeTitle(dl.Title), StringComparison.OrdinalIgnoreCase)) + return true; + return false; + }); + + if (matching.ValueKind == JsonValueKind.Undefined) + { + _logger.LogDebug("Could not find matching torrent for download {DownloadId} ({Title}) in Transmission", dl.Id, dl.Title); + continue; + } + + _logger.LogDebug("Matched download {DownloadId} to Transmission torrent", dl.Id); + + var percent = matching.TryGetProperty("percentDone", out var p) ? p.GetDouble() : 0.0; + var left = matching.TryGetProperty("leftUntilDone", out var l) ? l.GetInt64() : 0L; + var statusCode = matching.TryGetProperty("status", out var statusProp) ? statusProp.GetInt32() : 0; + + var status = statusCode switch + { + 0 => "paused", + 1 => "queued", + 2 => "downloading", + 3 => "queued", + 4 => "downloading", + 5 => "queued", + 6 => "seeding", + 7 => "failed", + _ => "unknown" + }; + + AdapterUtils.MapDownloadProgress(dl, percent * 100, left, status); + + try + { + var txUploadRatio = (matching.TryGetProperty("uploadRatio", out var txRatP) || matching.TryGetProperty("upload_ratio", out txRatP)) ? txRatP.GetDouble() : 0d; + var txSeedRatioMode = (matching.TryGetProperty("seedRatioMode", out var txSrmP) || matching.TryGetProperty("seed_ratio_mode", out txSrmP)) ? txSrmP.GetInt32() : 0; + var txSeedRatioLimit = (matching.TryGetProperty("seedRatioLimit", out var txSrlP) || matching.TryGetProperty("seed_ratio_limit", out txSrlP)) ? txSrlP.GetDouble() : 0d; + var txSeedIdleMode = (matching.TryGetProperty("seedIdleMode", out var txSimP) || matching.TryGetProperty("seed_idle_mode", out txSimP)) ? txSimP.GetInt32() : 0; + var txSeedIdleLimit = (matching.TryGetProperty("seedIdleLimit", out var txSilP) || matching.TryGetProperty("seed_idle_limit", out txSilP)) ? txSilP.GetInt32() : 0; + var txSecondsSeeding = (matching.TryGetProperty("secondsSeeding", out var txSsP) || matching.TryGetProperty("seconds_seeding", out txSsP)) ? txSsP.GetInt64() : 0L; + + var txIsStopped = statusCode == 0; + var txIsSeeding = statusCode == 6; + var txSeedLimitReached = TransmissionSeedLimitEvaluator.HasReachedSeedLimit( + txIsStopped, txIsSeeding, txUploadRatio, + txSeedRatioMode, txSeedRatioLimit, + txSeedIdleMode, txSeedIdleLimit, txSecondsSeeding, + txSessionSeedRatioLimited, txSessionSeedRatioLimit, + txSessionIdleSeedingLimitEnabled, txSessionIdleSeedingLimit); + var txCanBeRemoved = txRemoveCompletedDownloads && txSeedLimitReached; + var txCanMoveFiles = txCanBeRemoved && txIsStopped; + + if (dl.Metadata == null) dl.Metadata = new Dictionary(); + dl.Metadata["CanMoveFiles"] = txCanMoveFiles; + dl.Metadata["CanBeRemoved"] = txCanBeRemoved; + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogDebug(ex, "Failed to persist CanMoveFiles/CanBeRemoved for Transmission download {DownloadId}", dl.Id); + } + + if (dl.Status == DownloadStatus.Moved || + dl.Status == DownloadStatus.Processing || + dl.Status == DownloadStatus.ImportPending) + { + _logger.LogDebug("Skipping finalization for {Status} download {DownloadId}", dl.Status, dl.Id); + continue; + } + + var isComplete = percent >= 1.0 && (status == "seeding" || status == "queued" || status == "paused"); + _logger.LogInformation("PollTransmission download {DownloadId}: percent={Percent}, status={Status}, isComplete={IsComplete}", dl.Id, percent, status, isComplete); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning(ex, "Error processing download {DownloadId} while polling Transmission", dl.Id); + } + } + + return downloads; + } + + throw new DownloadClientAdapterPollingException($"PollTransmission failed to establish session after retries for client {client.Id}"); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning(ex, "Error polling Transmission client {ClientName}", client.Name); + throw new DownloadClientAdapterPollingException($"Error polling Transmission client {client.Id}"); + } + } + } +} From 6f9d220e34b760590dedcaafe0171b117710e5c2 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Sat, 13 Jun 2026 21:55:50 -0400 Subject: [PATCH 54/84] refactor: extract qbittorrent polling workflow - Move qBittorrent polling, torrent lookup, and download reconciliation into a workflow - Preserve login, category/hash querying, and completion behavior --- .../Adapters/QbittorrentAdapter.cs | 330 +---------------- .../QbittorrentDownloadPollingWorkflow.cs | 333 ++++++++++++++++++ 2 files changed, 336 insertions(+), 327 deletions(-) create mode 100644 listenarr.infrastructure/Adapters/QbittorrentDownloadPollingWorkflow.cs diff --git a/listenarr.infrastructure/Adapters/QbittorrentAdapter.cs b/listenarr.infrastructure/Adapters/QbittorrentAdapter.cs index ae443c6d0..926ecfc7c 100644 --- a/listenarr.infrastructure/Adapters/QbittorrentAdapter.cs +++ b/listenarr.infrastructure/Adapters/QbittorrentAdapter.cs @@ -19,9 +19,7 @@ using System.Text.Json; using Listenarr.Application.Interfaces; using Listenarr.Application.Security; -using Listenarr.Domain.Common; using Listenarr.Domain.Models; -using Listenarr.Domain.Models.Exceptions; using Listenarr.Infrastructure.Adapters.Exceptions; using Listenarr.Infrastructure.Torrents; using Microsoft.Extensions.Logging; @@ -43,6 +41,7 @@ public class QbittorrentAdapter : IDownloadClientAdapter private readonly QbittorrentTorrentAddPlanner _torrentAddPlanner; private readonly QbittorrentAuthSession _authSession; private readonly QbittorrentConnectionTester _connectionTester; + private readonly QbittorrentDownloadPollingWorkflow _downloadPollingWorkflow; public QbittorrentAdapter(IHttpClientFactory httpFactory, ITorrentFileDownloader torrentFileDownloader, ILogger logger) { @@ -52,6 +51,7 @@ public QbittorrentAdapter(IHttpClientFactory httpFactory, ITorrentFileDownloader _torrentAddPlanner = new QbittorrentTorrentAddPlanner(_torrentFileDownloader, _logger); _authSession = new QbittorrentAuthSession(_logger); _connectionTester = new QbittorrentConnectionTester(_httpClientFactory, _logger, ClientType); + _downloadPollingWorkflow = new QbittorrentDownloadPollingWorkflow(_logger); } public async Task<(bool Success, string Message)> TestConnectionAsync(DownloadClientConfiguration client, CancellationToken ct = default) @@ -636,331 +636,7 @@ public async Task> FetchDownloadsAsync( List downloads, CancellationToken cancellationToken) { - _logger.LogDebug("Polling qBittorrent client {ClientName}", client.Name); - try - { - 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 - // SID cookie from login is stored and sent with subsequent requests. - // The factory "DownloadClient" has UseCookies=false which breaks qBit auth. - var cookieJar = new System.Net.CookieContainer(); - using var handler = new HttpClientHandler - { - CookieContainer = cookieJar, - UseCookies = true, - AutomaticDecompression = System.Net.DecompressionMethods.All - }; - using var http = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(30) }; - - // Login - using var loginData = new FormUrlEncodedContent(new[] - { - new KeyValuePair("username", client.Username ?? string.Empty), - new KeyValuePair("password", client.Password ?? string.Empty) - }); - using var loginResp = await http.PostAsync($"{baseUrl}/api/v2/auth/login", loginData, cancellationToken); - if (!loginResp.IsSuccessStatusCode) - { - var loginError = await loginResp.Content.ReadAsStringAsync(cancellationToken); - throw new DownloadClientAdapterPollingException($"qBittorrent login failed for client {client.Name} at {baseUrl} - StatusCode={loginResp.StatusCode}, Response={loginError}"); - } - _logger.LogDebug("qBittorrent login successful for client {ClientName}", client.Name); - - // Fetch qBittorrent global preferences for seed limit evaluation (Sonarr parity) - bool qbtGlobalMaxRatioEnabled = false; - float qbtGlobalMaxRatio = -1f; - bool qbtGlobalMaxSeedingTimeEnabled = false; - long qbtGlobalMaxSeedingTime = -1; - bool qbtRemoveCompletedDownloads = !string.IsNullOrEmpty(client.RemoveCompletedDownloads) && - client.RemoveCompletedDownloads != "none"; - try - { - using var prefsResp = await http.GetAsync($"{baseUrl}/api/v2/app/preferences", cancellationToken); - if (prefsResp.IsSuccessStatusCode) - { - var prefsJson = await prefsResp.Content.ReadAsStringAsync(cancellationToken); - if (!string.IsNullOrWhiteSpace(prefsJson)) - { - var prefs = System.Text.Json.JsonSerializer.Deserialize>(prefsJson); - if (prefs != null) - { - qbtGlobalMaxRatioEnabled = prefs.TryGetValue("max_ratio_enabled", out var mre) && mre.GetBoolean(); - qbtGlobalMaxRatio = prefs.TryGetValue("max_ratio", out var mr) ? (float)mr.GetDouble() : -1f; - qbtGlobalMaxSeedingTimeEnabled = prefs.TryGetValue("max_seeding_time_enabled", out var mste) && mste.GetBoolean(); - qbtGlobalMaxSeedingTime = prefs.TryGetValue("max_seeding_time", out var mst) ? mst.GetInt64() : -1; - } - } - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogDebug(ex, "Failed to fetch qBittorrent preferences for seed limit evaluation"); - } - - // Request all necessary fields from torrents/info to avoid additional API calls per torrent - // This single call replaces the need for individual /properties calls per download - var fields = "hash,name,save_path,content_path,progress,amount_left,state,size,category,completion_on,seeding_time,ratio,ratio_limit,seeding_time_limit"; - - // Prefer querying only the hashes we are tracking (if available) to avoid fetching all torrents - var trackedHashes = downloads - .Select(d => d.Metadata != null && d.Metadata.TryGetValue("TorrentHash", out var h) ? h?.ToString() : null) - .Where(h => !string.IsNullOrEmpty(h)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToList(); - - // If we have tracked hashes, chunk them into batches to avoid very large queries and to allow - // slight delays between requests to prevent overwhelming qBittorrent. - List> allTorrents = new(); - - if (trackedHashes.Any()) - { - const int batchSize = 100; // safe default batch size - _logger.LogDebug("Querying qBittorrent for specific hashes (total={Count}), using batches of {BatchSize}", trackedHashes.Count, batchSize); - - var batches = Enumerable.Range(0, (trackedHashes.Count + batchSize - 1) / batchSize) - .Select(i => trackedHashes.Skip(i * batchSize).Take(batchSize).ToList()) - .ToList(); - - foreach (var batch in batches) - { - var hashesParam = Uri.EscapeDataString(string.Join("|", batch)); - var query = $"?hashes={hashesParam}&fields={Uri.EscapeDataString(fields)}"; - - using var torrentsResp = await http.GetAsync($"{baseUrl}/api/v2/torrents/info{query}", cancellationToken); - if (!torrentsResp.IsSuccessStatusCode) - { - var errorContent = await torrentsResp.Content.ReadAsStringAsync(cancellationToken); - throw new DownloadClientAdapterPollingException($"Failed to fetch torrent batch from qBittorrent for {client.Name} (batch size={batch.Count}, URL={baseUrl}/api/v2/torrents/info{query}, StatusCode={torrentsResp.StatusCode}, Response={errorContent})"); - } - - var json = await torrentsResp.Content.ReadAsStringAsync(cancellationToken); - var torrents = System.Text.Json.JsonSerializer.Deserialize>>(json); - if (torrents != null) - { - allTorrents.AddRange(torrents); - } - - // Small delay between batches to avoid hammering the client - await Task.Delay(150, cancellationToken); - } - } - else - { - var configuredCategory = DownloadClientCategoryFilter.GetConfiguredCategory(client); - if (!string.IsNullOrWhiteSpace(configuredCategory)) - { - var cat = Uri.EscapeDataString(configuredCategory); - var query = $"?category={cat}&fields={Uri.EscapeDataString(fields)}"; - _logger.LogDebug("Querying qBittorrent by category: {Category}", configuredCategory); - - using var torrentsResp = await http.GetAsync($"{baseUrl}/api/v2/torrents/info{query}", cancellationToken); - if (!torrentsResp.IsSuccessStatusCode) - { - throw new DownloadClientAdapterPollingException($"Failed to fetch torrents from qBittorrent for {client.Name}"); - } - - var json = await torrentsResp.Content.ReadAsStringAsync(cancellationToken); - var torrents = JsonSerializer.Deserialize>>(json); - if (torrents == null) return []; - - allTorrents.AddRange(torrents); - } - else - { - // Default: fetch a limited set of recent torrents - var query = $"?fields={Uri.EscapeDataString(fields)}"; - using var torrentsResp = await http.GetAsync($"{baseUrl}/api/v2/torrents/info{query}", cancellationToken); - if (!torrentsResp.IsSuccessStatusCode) - { - throw new DownloadClientAdapterPollingException($"Failed to fetch torrents from qBittorrent for {client.Name}"); - } - - var json = await torrentsResp.Content.ReadAsStringAsync(cancellationToken); - var torrents = JsonSerializer.Deserialize>>(json); - if (torrents == null) return []; - - allTorrents.AddRange(torrents); - } - } - - var torrentLookup = QbittorrentTorrentLookupBuilder.Build( - allTorrents, - qbtRemoveCompletedDownloads, - qbtGlobalMaxRatioEnabled, - qbtGlobalMaxRatio, - qbtGlobalMaxSeedingTimeEnabled, - qbtGlobalMaxSeedingTime); - - _logger.LogDebug("Found {TorrentCount} torrents in qBittorrent for client {ClientName}", torrentLookup.Count, client.Name); - - // Log all torrents for diagnostics - foreach (var t in torrentLookup.Take(10)) - { - _logger.LogDebug("qBittorrent torrent: Name={Name}, Hash={Hash}, Progress={Progress:P2}, State={State}, Size={Size}", - t.Name, t.Hash, t.Progress, t.State, t.Size); - } - - // For each DB download associated with this client, try to find matching torrent - _logger.LogInformation("Checking {DownloadCount} downloads against qBittorrent torrents for client {ClientName}", - downloads.Count, client.Name); - - foreach (var dl in downloads) - { - try - { - _logger.LogDebug("Looking for qBittorrent match for download {DownloadId}: {Title}", dl.Id, dl.Title); - - // Try hash-based matching first (most reliable for qBittorrent) - var matched = (Hash: "", Name: "", SavePath: "", ContentPath: "", Progress: 0.0, AmountLeft: 0L, State: "", Size: 0L, Category: "", SeedingTime: (long?)null, Ratio: 0.0, RatioLimit: -2f, SeedingTimeLimit: -2L, CanMoveFiles: false, CanBeRemoved: false); - - // Check if we have a stored torrent hash for this download - if (dl.Metadata != null && dl.Metadata.TryGetValue("TorrentHash", out var hashObj)) - { - var storedHash = hashObj?.ToString(); - if (!string.IsNullOrEmpty(storedHash)) - { - matched = torrentLookup.FirstOrDefault(t => - string.Equals(t.Hash, storedHash, StringComparison.OrdinalIgnoreCase)); - - if (!string.IsNullOrEmpty(matched.Hash)) - { - _logger.LogDebug("Found qBittorrent torrent by hash match: {Hash} for download {DownloadId}", storedHash, dl.Id); - } - } - } - - // Fallback to deterministic matching if hash matching failed. - // Following Sonarr's pattern: only match on exact identifiers - // (name or content path), never on fuzzy title similarity. - // Fuzzy matching caused cross-contamination (e.g. importing - // "Mr. Mercedes" files into "One Hundred Years of Solitude"). - if (string.IsNullOrEmpty(matched.Hash)) - { - _logger.LogInformation("Hash matching failed for download {DownloadId}, trying exact name/path matching", dl.Id); - - // 1. Exact torrent name == download title - matched = torrentLookup.FirstOrDefault(t => - string.Equals(t.Name, dl.Title, StringComparison.OrdinalIgnoreCase)); - - // 2. Exact normalized title match (strip brackets/quality tags only) - if (string.IsNullOrEmpty(matched.Hash)) - { - var dlNorm = TitleUtils.NormalizeTitle(dl.Title); - matched = torrentLookup.FirstOrDefault(t => - string.Equals(TitleUtils.NormalizeTitle(t.Name), dlNorm, StringComparison.OrdinalIgnoreCase)); - - if (!string.IsNullOrEmpty(matched.Hash)) - { - _logger.LogInformation("Normalized title match: '{DbTitle}' <-> '{TorrentTitle}'", dl.Title, matched.Name); - } - } - - // 3. Exact content path match - if (string.IsNullOrEmpty(matched.Hash) && !string.IsNullOrEmpty(dl.DownloadPath)) - { - var dlPathNorm = Path.GetFullPath(dl.DownloadPath).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - matched = torrentLookup.FirstOrDefault(t => - { - if (string.IsNullOrEmpty(t.ContentPath)) return false; - var contentNorm = Path.GetFullPath(t.ContentPath).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - return string.Equals(dlPathNorm, contentNorm, StringComparison.OrdinalIgnoreCase); - }); - } - } - - if (string.IsNullOrEmpty(matched.Hash)) - { - _logger.LogWarning("No matching qBittorrent torrent found for download {DownloadId}: {Title}", dl.Id, dl.Title); - continue; - } - - _logger.LogDebug("Found matching qBittorrent torrent for {DownloadId}: {TorrentName} (Hash: {Hash}, State: {State}, Progress: {Progress:P2}, SavePath: {SavePath}, ContentPath: {ContentPath})", - dl.Id, matched.Name, matched.Hash, matched.State, matched.Progress, matched.SavePath, matched.ContentPath); - - // DIAGNOSTIC: Log detailed completion check values - _logger.LogInformation("Completion diagnostic for {DownloadId}: Progress={Progress:F4} (>= 1.0? {ProgressCheck}), AmountLeft={AmountLeft} (== 0? {AmountCheck}), State={State}", - dl.Id, matched.Progress, matched.Progress >= 1.0, matched.AmountLeft, matched.AmountLeft == 0, matched.State); - - if (!string.IsNullOrEmpty(matched.SavePath) && dl.DownloadPath != matched.SavePath) - { - dl.DownloadPath = matched.SavePath; - } - - if (dl.Metadata == null) dl.Metadata = new Dictionary(); - - if (!string.IsNullOrEmpty(matched.ContentPath)) - { - dl.Metadata["ClientContentPath"] = matched.ContentPath; - } - - if (matched.SeedingTime.HasValue) - { - dl.Metadata["SeedingTimeSeconds"] = matched.SeedingTime.Value; - } - - dl.Metadata["CanMoveFiles"] = matched.CanMoveFiles; - dl.Metadata["CanBeRemoved"] = matched.CanBeRemoved; - - AdapterUtils.MapDownloadProgress(dl, matched.Progress * 100, matched.AmountLeft, matched.State); - - // Skip finalization/progress logic for downloads that are already - // being processed, awaiting import, or fully imported. Re-entering - // finalization for these would cause duplicate notifications and - // potentially import the wrong files a second time. - if (dl.Status == DownloadStatus.Moved || - dl.Status == DownloadStatus.Processing || - dl.Status == DownloadStatus.ImportPending) - { - _logger.LogDebug("Skipping finalization for {Status} download {DownloadId}", dl.Status, dl.Id); - continue; - } - - var normalizedState = (matched.State ?? string.Empty).ToLowerInvariant(); - if (normalizedState == "error" || normalizedState == "missingfiles") - { - dl.Failed($"qBittorrent state: {matched.State}"); - continue; - } - - // Lenient completion detection for qBittorrent - // A torrent is complete when progress >= 100% OR amount left is 0 - // The stability window below ensures we don't immediately import a torrent - // that just hit 100% - we wait for the configured delay period - var isComplete = matched.Progress >= 1.0 || matched.AmountLeft == 0; - - _logger.LogDebug("Completion check for {DownloadId}: IsComplete={IsComplete}, Progress={Progress:P2}, AmountLeft={AmountLeft}, State={State}", - dl.Id, isComplete, matched.Progress, matched.AmountLeft, matched.State); - - if (isComplete) - { - // Determine the best path to use for file discovery - // Priority: content_path (actual file/folder) > save_path + name (torrent root) > save_path (download directory) - var completionPath = !string.IsNullOrEmpty(matched.ContentPath) - ? matched.ContentPath - : (!string.IsNullOrEmpty(matched.SavePath) && !string.IsNullOrEmpty(matched.Name) - ? FileUtils.CombineWithOptionalBase(matched.SavePath, matched.Name) - : matched.SavePath); - - _logger.LogInformation("Download {DownloadId} observed as complete candidate (qBittorrent). Torrent: {TorrentName}, Path: {Path}. Waiting for stability window.", - dl.Id, matched.Name, completionPath); - - dl.Completed(); - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Error processing download {DownloadId} while polling qBittorrent", dl.Id); - } - } - - return downloads; - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - throw new DownloadClientAdapterPollingException($"Error polling qBittorrent client {client.Name}"); - } + return await _downloadPollingWorkflow.FetchDownloadsAsync(client, downloads, cancellationToken); } } diff --git a/listenarr.infrastructure/Adapters/QbittorrentDownloadPollingWorkflow.cs b/listenarr.infrastructure/Adapters/QbittorrentDownloadPollingWorkflow.cs new file mode 100644 index 000000000..de59600b1 --- /dev/null +++ b/listenarr.infrastructure/Adapters/QbittorrentDownloadPollingWorkflow.cs @@ -0,0 +1,333 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +using System.Text.Json; +using Listenarr.Domain.Common; +using Listenarr.Domain.Models; +using Listenarr.Domain.Models.Exceptions; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Infrastructure.Adapters +{ + internal sealed class QbittorrentDownloadPollingWorkflow + { + private readonly ILogger _logger; + + public QbittorrentDownloadPollingWorkflow(ILogger logger) + { + _logger = logger; + } + + public async Task> FetchDownloadsAsync( + DownloadClientConfiguration client, + List downloads, + CancellationToken cancellationToken) + { + _logger.LogDebug("Polling qBittorrent client {ClientName}", client.Name); + try + { + var baseUrl = DownloadClientUriBuilder.BuildAuthority(client); + _logger.LogInformation("Polling qBittorrent client {ClientName} at {BaseUrl}", client.Name, baseUrl); + + using var http = QbittorrentCookieSession.CreateClient(); + + using var loginData = QbittorrentCookieSession.CreateLoginContent(client); + using var loginResp = await http.PostAsync($"{baseUrl}/api/v2/auth/login", loginData, cancellationToken); + if (!loginResp.IsSuccessStatusCode) + { + var loginError = await loginResp.Content.ReadAsStringAsync(cancellationToken); + throw new DownloadClientAdapterPollingException($"qBittorrent login failed for client {client.Name} at {baseUrl} - StatusCode={loginResp.StatusCode}, Response={loginError}"); + } + _logger.LogDebug("qBittorrent login successful for client {ClientName}", client.Name); + + bool qbtGlobalMaxRatioEnabled = false; + float qbtGlobalMaxRatio = -1f; + bool qbtGlobalMaxSeedingTimeEnabled = false; + long qbtGlobalMaxSeedingTime = -1; + bool qbtRemoveCompletedDownloads = !string.IsNullOrEmpty(client.RemoveCompletedDownloads) && + client.RemoveCompletedDownloads != "none"; + try + { + using var prefsResp = await http.GetAsync($"{baseUrl}/api/v2/app/preferences", cancellationToken); + if (prefsResp.IsSuccessStatusCode) + { + var prefsJson = await prefsResp.Content.ReadAsStringAsync(cancellationToken); + if (!string.IsNullOrWhiteSpace(prefsJson)) + { + var prefs = JsonSerializer.Deserialize>(prefsJson); + if (prefs != null) + { + qbtGlobalMaxRatioEnabled = prefs.TryGetValue("max_ratio_enabled", out var mre) && mre.GetBoolean(); + qbtGlobalMaxRatio = prefs.TryGetValue("max_ratio", out var mr) ? (float)mr.GetDouble() : -1f; + qbtGlobalMaxSeedingTimeEnabled = prefs.TryGetValue("max_seeding_time_enabled", out var mste) && mste.GetBoolean(); + qbtGlobalMaxSeedingTime = prefs.TryGetValue("max_seeding_time", out var mst) ? mst.GetInt64() : -1; + } + } + } + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogDebug(ex, "Failed to fetch qBittorrent preferences for seed limit evaluation"); + } + + var fields = "hash,name,save_path,content_path,progress,amount_left,state,size,category,completion_on,seeding_time,ratio,ratio_limit,seeding_time_limit"; + var allTorrents = await FetchTorrentsAsync(http, baseUrl, client, downloads, fields, cancellationToken); + var torrentLookup = QbittorrentTorrentLookupBuilder.Build( + allTorrents, + qbtRemoveCompletedDownloads, + qbtGlobalMaxRatioEnabled, + qbtGlobalMaxRatio, + qbtGlobalMaxSeedingTimeEnabled, + qbtGlobalMaxSeedingTime); + + _logger.LogDebug("Found {TorrentCount} torrents in qBittorrent for client {ClientName}", torrentLookup.Count, client.Name); + + foreach (var torrent in torrentLookup.Take(10)) + { + _logger.LogDebug("qBittorrent torrent: Name={Name}, Hash={Hash}, Progress={Progress:P2}, State={State}, Size={Size}", + torrent.Name, torrent.Hash, torrent.Progress, torrent.State, torrent.Size); + } + + _logger.LogInformation("Checking {DownloadCount} downloads against qBittorrent torrents for client {ClientName}", + downloads.Count, client.Name); + + foreach (var download in downloads) + { + try + { + ReconcileDownload(download, torrentLookup); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning(ex, "Error processing download {DownloadId} while polling qBittorrent", download.Id); + } + } + + return downloads; + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + throw new DownloadClientAdapterPollingException($"Error polling qBittorrent client {client.Name}"); + } + } + + private async Task>> FetchTorrentsAsync( + HttpClient http, + string baseUrl, + DownloadClientConfiguration client, + List downloads, + string fields, + CancellationToken cancellationToken) + { + var trackedHashes = downloads + .Select(download => download.Metadata != null && download.Metadata.TryGetValue("TorrentHash", out var hash) ? hash?.ToString() : null) + .Where(hash => !string.IsNullOrEmpty(hash)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + var allTorrents = new List>(); + + if (trackedHashes.Any()) + { + const int batchSize = 100; + _logger.LogDebug("Querying qBittorrent for specific hashes (total={Count}), using batches of {BatchSize}", trackedHashes.Count, batchSize); + + var batches = Enumerable.Range(0, (trackedHashes.Count + batchSize - 1) / batchSize) + .Select(index => trackedHashes.Skip(index * batchSize).Take(batchSize).ToList()) + .ToList(); + + foreach (var batch in batches) + { + var hashesParam = Uri.EscapeDataString(string.Join("|", batch)); + var query = $"?hashes={hashesParam}&fields={Uri.EscapeDataString(fields)}"; + + using var torrentsResp = await http.GetAsync($"{baseUrl}/api/v2/torrents/info{query}", cancellationToken); + if (!torrentsResp.IsSuccessStatusCode) + { + var errorContent = await torrentsResp.Content.ReadAsStringAsync(cancellationToken); + throw new DownloadClientAdapterPollingException($"Failed to fetch torrent batch from qBittorrent for {client.Name} (batch size={batch.Count}, URL={baseUrl}/api/v2/torrents/info{query}, StatusCode={torrentsResp.StatusCode}, Response={errorContent})"); + } + + var json = await torrentsResp.Content.ReadAsStringAsync(cancellationToken); + var torrents = JsonSerializer.Deserialize>>(json); + if (torrents != null) + { + allTorrents.AddRange(torrents); + } + + await Task.Delay(150, cancellationToken); + } + + return allTorrents; + } + + var configuredCategory = DownloadClientCategoryFilter.GetConfiguredCategory(client); + if (!string.IsNullOrWhiteSpace(configuredCategory)) + { + var cat = Uri.EscapeDataString(configuredCategory); + var query = $"?category={cat}&fields={Uri.EscapeDataString(fields)}"; + _logger.LogDebug("Querying qBittorrent by category: {Category}", configuredCategory); + + using var torrentsResp = await http.GetAsync($"{baseUrl}/api/v2/torrents/info{query}", cancellationToken); + if (!torrentsResp.IsSuccessStatusCode) + { + throw new DownloadClientAdapterPollingException($"Failed to fetch torrents from qBittorrent for {client.Name}"); + } + + var json = await torrentsResp.Content.ReadAsStringAsync(cancellationToken); + var torrents = JsonSerializer.Deserialize>>(json); + return torrents ?? []; + } + + var defaultQuery = $"?fields={Uri.EscapeDataString(fields)}"; + using var defaultResp = await http.GetAsync($"{baseUrl}/api/v2/torrents/info{defaultQuery}", cancellationToken); + if (!defaultResp.IsSuccessStatusCode) + { + throw new DownloadClientAdapterPollingException($"Failed to fetch torrents from qBittorrent for {client.Name}"); + } + + var defaultJson = await defaultResp.Content.ReadAsStringAsync(cancellationToken); + return JsonSerializer.Deserialize>>(defaultJson) ?? []; + } + + private void ReconcileDownload( + Download download, + List<(string Hash, string Name, string SavePath, string ContentPath, double Progress, long AmountLeft, string State, long Size, string Category, long? SeedingTime, double Ratio, float RatioLimit, long SeedingTimeLimit, bool CanMoveFiles, bool CanBeRemoved)> torrentLookup) + { + _logger.LogDebug("Looking for qBittorrent match for download {DownloadId}: {Title}", download.Id, download.Title); + + var matched = FindMatchingTorrent(download, torrentLookup); + if (string.IsNullOrEmpty(matched.Hash)) + { + _logger.LogWarning("No matching qBittorrent torrent found for download {DownloadId}: {Title}", download.Id, download.Title); + return; + } + + _logger.LogDebug("Found matching qBittorrent torrent for {DownloadId}: {TorrentName} (Hash: {Hash}, State: {State}, Progress: {Progress:P2}, SavePath: {SavePath}, ContentPath: {ContentPath})", + download.Id, matched.Name, matched.Hash, matched.State, matched.Progress, matched.SavePath, matched.ContentPath); + + _logger.LogInformation("Completion diagnostic for {DownloadId}: Progress={Progress:F4} (>= 1.0? {ProgressCheck}), AmountLeft={AmountLeft} (== 0? {AmountCheck}), State={State}", + download.Id, matched.Progress, matched.Progress >= 1.0, matched.AmountLeft, matched.AmountLeft == 0, matched.State); + + if (!string.IsNullOrEmpty(matched.SavePath) && download.DownloadPath != matched.SavePath) + { + download.DownloadPath = matched.SavePath; + } + + download.Metadata ??= new Dictionary(); + + if (!string.IsNullOrEmpty(matched.ContentPath)) + { + download.Metadata["ClientContentPath"] = matched.ContentPath; + } + + if (matched.SeedingTime.HasValue) + { + download.Metadata["SeedingTimeSeconds"] = matched.SeedingTime.Value; + } + + download.Metadata["CanMoveFiles"] = matched.CanMoveFiles; + download.Metadata["CanBeRemoved"] = matched.CanBeRemoved; + + AdapterUtils.MapDownloadProgress(download, matched.Progress * 100, matched.AmountLeft, matched.State); + + if (download.Status == DownloadStatus.Moved || + download.Status == DownloadStatus.Processing || + download.Status == DownloadStatus.ImportPending) + { + _logger.LogDebug("Skipping finalization for {Status} download {DownloadId}", download.Status, download.Id); + return; + } + + var normalizedState = (matched.State ?? string.Empty).ToLowerInvariant(); + if (normalizedState == "error" || normalizedState == "missingfiles") + { + download.Failed($"qBittorrent state: {matched.State}"); + return; + } + + var isComplete = matched.Progress >= 1.0 || matched.AmountLeft == 0; + + _logger.LogDebug("Completion check for {DownloadId}: IsComplete={IsComplete}, Progress={Progress:P2}, AmountLeft={AmountLeft}, State={State}", + download.Id, isComplete, matched.Progress, matched.AmountLeft, matched.State); + + if (isComplete) + { + var completionPath = !string.IsNullOrEmpty(matched.ContentPath) + ? matched.ContentPath + : (!string.IsNullOrEmpty(matched.SavePath) && !string.IsNullOrEmpty(matched.Name) + ? FileUtils.CombineWithOptionalBase(matched.SavePath, matched.Name) + : matched.SavePath); + + _logger.LogInformation("Download {DownloadId} observed as complete candidate (qBittorrent). Torrent: {TorrentName}, Path: {Path}. Waiting for stability window.", + download.Id, matched.Name, completionPath); + + download.Completed(); + } + } + + private (string Hash, string Name, string SavePath, string ContentPath, double Progress, long AmountLeft, string State, long Size, string Category, long? SeedingTime, double Ratio, float RatioLimit, long SeedingTimeLimit, bool CanMoveFiles, bool CanBeRemoved) FindMatchingTorrent( + Download download, + List<(string Hash, string Name, string SavePath, string ContentPath, double Progress, long AmountLeft, string State, long Size, string Category, long? SeedingTime, double Ratio, float RatioLimit, long SeedingTimeLimit, bool CanMoveFiles, bool CanBeRemoved)> torrentLookup) + { + var matched = (Hash: "", Name: "", SavePath: "", ContentPath: "", Progress: 0.0, AmountLeft: 0L, State: "", Size: 0L, Category: "", SeedingTime: (long?)null, Ratio: 0.0, RatioLimit: -2f, SeedingTimeLimit: -2L, CanMoveFiles: false, CanBeRemoved: false); + + if (download.Metadata != null && download.Metadata.TryGetValue("TorrentHash", out var hashObj)) + { + var storedHash = hashObj?.ToString(); + if (!string.IsNullOrEmpty(storedHash)) + { + matched = torrentLookup.FirstOrDefault(t => + string.Equals(t.Hash, storedHash, StringComparison.OrdinalIgnoreCase)); + + if (!string.IsNullOrEmpty(matched.Hash)) + { + _logger.LogDebug("Found qBittorrent torrent by hash match: {Hash} for download {DownloadId}", storedHash, download.Id); + } + } + } + + if (!string.IsNullOrEmpty(matched.Hash)) + { + return matched; + } + + _logger.LogInformation("Hash matching failed for download {DownloadId}, trying exact name/path matching", download.Id); + + matched = torrentLookup.FirstOrDefault(t => + string.Equals(t.Name, download.Title, StringComparison.OrdinalIgnoreCase)); + + if (string.IsNullOrEmpty(matched.Hash)) + { + var downloadNormalized = TitleUtils.NormalizeTitle(download.Title); + matched = torrentLookup.FirstOrDefault(t => + string.Equals(TitleUtils.NormalizeTitle(t.Name), downloadNormalized, StringComparison.OrdinalIgnoreCase)); + + if (!string.IsNullOrEmpty(matched.Hash)) + { + _logger.LogInformation("Normalized title match: '{DbTitle}' <-> '{TorrentTitle}'", download.Title, matched.Name); + } + } + + if (string.IsNullOrEmpty(matched.Hash) && !string.IsNullOrEmpty(download.DownloadPath)) + { + var downloadPathNormalized = Path.GetFullPath(download.DownloadPath).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + matched = torrentLookup.FirstOrDefault(t => + { + if (string.IsNullOrEmpty(t.ContentPath)) return false; + var contentNormalized = Path.GetFullPath(t.ContentPath).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + return string.Equals(downloadPathNormalized, contentNormalized, StringComparison.OrdinalIgnoreCase); + }); + } + + return matched; + } + } +} From 9cfbded4568579943beaf6fd9e1143fb9b0bb142 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Sat, 13 Jun 2026 22:02:39 -0400 Subject: [PATCH 55/84] refactor: extract prowlarr import workflow - Move Prowlarr import fetch, tag lookup, filtering, and creation into a focused workflow - Keep IndexersController responsible for HTTP response shaping --- .../Controllers/IndexersController.cs | 342 +-------------- .../ProwlarrIndexerImportWorkflow.cs | 400 ++++++++++++++++++ listenarr.api/Program.cs | 1 + 3 files changed, 420 insertions(+), 323 deletions(-) create mode 100644 listenarr.api/Controllers/ProwlarrIndexerImportWorkflow.cs diff --git a/listenarr.api/Controllers/IndexersController.cs b/listenarr.api/Controllers/IndexersController.cs index 8ceecc29c..fd63e9cb8 100644 --- a/listenarr.api/Controllers/IndexersController.cs +++ b/listenarr.api/Controllers/IndexersController.cs @@ -21,7 +21,6 @@ using Listenarr.Application.Interfaces; using Listenarr.Application.Interfaces.Repositories; using Listenarr.Application.Security; -using Listenarr.Application.Search; using Listenarr.Domain.Models; using Microsoft.AspNetCore.Mvc; using System.Text.Json; @@ -36,9 +35,9 @@ public class IndexersController : ControllerBase private readonly IIndexerRepository _indexerRepository; private readonly ILogger _logger; private readonly HttpClient _httpClient; - private readonly HttpClient _httpClientNoRedirect; private readonly IConfigurationService _configurationService; private readonly IndexerTestWorkflow _indexerTestWorkflow; + private readonly ProwlarrIndexerImportWorkflow _prowlarrImportWorkflow; private readonly IndexerResponseRedactor _responseRedactor; public IndexersController( @@ -46,17 +45,22 @@ public IndexersController( ILogger logger, HttpClient httpClient, IConfigurationService configurationService, - IndexerTestWorkflow? indexerTestWorkflow = null) + IndexerTestWorkflow? indexerTestWorkflow = null, + ProwlarrIndexerImportWorkflow? prowlarrImportWorkflow = null) { _indexerRepository = indexerRepository; _logger = logger; _httpClient = httpClient; - _httpClientNoRedirect = httpClient; _configurationService = configurationService; _indexerTestWorkflow = indexerTestWorkflow ?? new IndexerTestWorkflow( indexerRepository, httpClient, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + _prowlarrImportWorkflow = prowlarrImportWorkflow ?? new ProwlarrIndexerImportWorkflow( + indexerRepository, + configurationService, + httpClient, + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); _responseRedactor = new IndexerResponseRedactor(); } @@ -72,37 +76,6 @@ private List RedactIndexersForCaller(IEnumerable indexers) private string? RedactMamIdForCaller(string? mamId) => _responseRedactor.RedactMamIdForCaller(mamId, HttpContext); - private Task ValidateOutboundUrlForCallerAsync(string url) - { - // *Arr standard behavior: allow private/loopback destinations for indexer connectivity - // tests/imports, but still enforce absolute HTTP(S) URLs and block embedded credentials. - if (!OutboundRequestSecurity.TryValidateExternalHttpUrl(url, out var reason, allowPrivateTargets: true)) - { - return Task.FromResult(reason); - } - - return Task.FromResult(null); - } - - private async Task SendValidatedAsync( - Func requestFactory, - string url, - HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, - CancellationToken cancellationToken = default) - { - var uri = new Uri(url); - var (response, _) = await OutboundRequestSecurity.SendWithValidatedRedirectsAsync( - requestFactory, - uri, - _httpClientNoRedirect, - _logger, - // *Arr standard behavior for indexers: allow private/loopback destinations. - allowPrivateTargets: true, - completionOption: completionOption, - cancellationToken: cancellationToken); - return response; - } - private async Task SaveTestResultAsync(Indexer indexer, bool persist, bool success, string? error) { // Update the passed indexer instance @@ -265,189 +238,28 @@ public async Task Create([FromBody] Indexer indexer) [HttpPost("prowlarr/import")] public async Task ImportFromProwlarr([FromBody] ProwlarrImportRequestDto request) { - if (request == null) - { - return BadRequest(new { message = "Request body is required" }); - } - - var savedConnection = await _configurationService.GetProwlarrImportSettingsAsync(includeSecret: true); - var effectiveUrl = string.IsNullOrWhiteSpace(request.Url) ? savedConnection.Url : request.Url.Trim(); - var effectivePort = request.ClearPort ? null : request.Port ?? savedConnection.Port; - var effectiveApiKey = string.IsNullOrWhiteSpace(request.ApiKey) ? savedConnection.ApiKey : request.ApiKey.Trim(); - var effectiveTagFilter = request.TagFilter == null - ? savedConnection.TagFilter?.Trim() - : request.TagFilter.Trim(); - - if (string.IsNullOrWhiteSpace(effectiveUrl)) + var result = await _prowlarrImportWorkflow.ImportAsync(request); + if (result.Kind == ProwlarrIndexerImportWorkflowResultKind.BadRequest) { - return BadRequest(new { message = "Prowlarr URL is required" }); + return BadRequest(new { message = result.Message }); } - if (string.IsNullOrWhiteSpace(effectiveApiKey)) - { - return BadRequest(new { message = "Prowlarr API key is required" }); - } - - var baseUrl = ProwlarrImportUrlPlanner.BuildBaseUrl(effectiveUrl, effectivePort); - var blockedBaseUrlReason = await ValidateOutboundUrlForCallerAsync(baseUrl); - if (!string.IsNullOrWhiteSpace(blockedBaseUrlReason)) - { - return BadRequest(new { message = $"Blocked Prowlarr target: {blockedBaseUrlReason}" }); - } - - HttpResponseMessage response; - string payload; - try - { - (response, payload) = await FetchProwlarrIndexersAsync(baseUrl, effectiveApiKey.Trim()); - } - catch (HttpRequestException ex) - { - _logger.LogWarning(ex, "Failed to reach Prowlarr at {Url}", LogRedaction.SanitizeUrl(baseUrl)); - return StatusCode(502, new { message = "Failed to reach Prowlarr API" }); - } - catch (TaskCanceledException ex) - { - _logger.LogWarning(ex, "Failed to reach Prowlarr at {Url}", LogRedaction.SanitizeUrl(baseUrl)); - return StatusCode(502, new { message = "Failed to reach Prowlarr API" }); - } - catch (UriFormatException ex) + if (result.Kind == ProwlarrIndexerImportWorkflowResultKind.UpstreamError) { - _logger.LogWarning(ex, "Failed to reach Prowlarr at {Url}", LogRedaction.SanitizeUrl(baseUrl)); - return StatusCode(502, new { message = "Failed to reach Prowlarr API" }); - } - catch (InvalidOperationException ex) - { - _logger.LogWarning(ex, "Failed to reach Prowlarr at {Url}", LogRedaction.SanitizeUrl(baseUrl)); - return StatusCode(502, new { message = "Failed to reach Prowlarr API" }); - } - - using (response) - { - if (!response.IsSuccessStatusCode) - { - _logger.LogWarning("Prowlarr API returned {StatusCode}: {Body}", (int)response.StatusCode, LogRedaction.SanitizeText(payload)); - return StatusCode((int)response.StatusCode, new { message = "Prowlarr API error", status = (int)response.StatusCode }); - } - } - using var doc = JsonDocument.Parse(payload); - if (doc.RootElement.ValueKind != JsonValueKind.Array) - { - return StatusCode(502, new { message = "Unexpected Prowlarr API response" }); - } - - await _configurationService.SaveProwlarrImportSettingsAsync(new ProwlarrImportConnectionSettings - { - Url = effectiveUrl, - Port = effectivePort, - ApiKey = string.IsNullOrWhiteSpace(request.ApiKey) ? null : request.ApiKey.Trim(), - TagFilter = effectiveTagFilter, - }); - - var existingIndexers = await _indexerRepository.GetAllAsync(); - var createdIndexers = new List(); - var skipped = 0; - Dictionary? tagMap = null; - - if (!string.IsNullOrWhiteSpace(effectiveTagFilter)) - { - tagMap = await TryFetchProwlarrTagMapAsync(baseUrl, effectiveApiKey.Trim()); - if ((tagMap == null || tagMap.Count == 0) && ProwlarrIndexerPayloadParser.PayloadRequiresTagMap(doc.RootElement)) - { - _logger.LogWarning( - "Prowlarr tag-filtered import for {Url} requires tag label lookup, but tags could not be loaded", - LogRedaction.SanitizeUrl(baseUrl)); - return StatusCode(502, new { message = "Failed to load Prowlarr tags required for tag-filtered import" }); - } - } - - foreach (var element in doc.RootElement.EnumerateArray()) - { - if (!element.TryGetProperty("id", out var idProp) || idProp.ValueKind != JsonValueKind.Number) - { - skipped++; - continue; - } - - var indexerId = idProp.GetInt32(); - var categoryIds = ProwlarrIndexerPayloadParser.GetCategoryIds(element); - var prowlarrTags = ProwlarrIndexerPayloadParser.GetTagValues(element, tagMap); - var matchesImportFilter = string.IsNullOrWhiteSpace(effectiveTagFilter) - ? categoryIds.Contains(3000) || categoryIds.Contains(3030) - : prowlarrTags.Any(tag => string.Equals(tag, effectiveTagFilter, StringComparison.OrdinalIgnoreCase)); - - if (!matchesImportFilter) - { - skipped++; - continue; - } - - var name = element.TryGetProperty("name", out var nameProp) && nameProp.ValueKind == JsonValueKind.String - ? nameProp.GetString() ?? "Prowlarr Indexer" - : "Prowlarr Indexer"; - if (!name.EndsWith(" (Prowlarr)", StringComparison.OrdinalIgnoreCase)) - { - name = $"{name} (Prowlarr)"; - } - - var protocol = element.TryGetProperty("protocol", out var protocolProp) && protocolProp.ValueKind == JsonValueKind.String - ? protocolProp.GetString() ?? string.Empty - : string.Empty; - - var implementation = protocol.Equals("usenet", StringComparison.OrdinalIgnoreCase) ? "Newznab" : "Torznab"; - - var proxyUrl = ProwlarrImportUrlPlanner.BuildProxyUrl(baseUrl, indexerId); - var normalizedUrl = ProwlarrImportUrlPlanner.NormalizeProxyUrl(proxyUrl); - - var exists = existingIndexers.FirstOrDefault(i => - ProwlarrImportUrlPlanner.NormalizeProxyUrl(i.Url) == normalizedUrl && - string.Equals(i.Implementation, implementation, StringComparison.OrdinalIgnoreCase) && - string.Equals(i.ApiKey ?? string.Empty, effectiveApiKey ?? string.Empty, StringComparison.Ordinal)); - - if (exists != null) - { - skipped++; - continue; - } - - var type = protocol.Equals("usenet", StringComparison.OrdinalIgnoreCase) ? "Usenet" : "Torrent"; - var categories = string.Join(',', categoryIds.Where(c => c == 3000 || c == 3030).OrderBy(c => c)); - - var isEnabled = true; - if (element.TryGetProperty("enable", out var enableProp)) - { - isEnabled = enableProp.ValueKind == JsonValueKind.True; - } - else if (element.TryGetProperty("enabled", out var enabledProp)) + if (result.UpstreamStatus.HasValue) { - isEnabled = enabledProp.ValueKind == JsonValueKind.True; + return StatusCode(result.StatusCode ?? StatusCodes.Status502BadGateway, new { message = result.Message, status = result.UpstreamStatus.Value }); } - var indexer = new Indexer - { - Name = name, - Type = type, - Implementation = implementation, - Url = normalizedUrl, - ApiKey = string.IsNullOrWhiteSpace(effectiveApiKey) ? null : effectiveApiKey.Trim(), - Categories = categories, - EnableRss = true, - EnableAutomaticSearch = true, - EnableInteractiveSearch = true, - IsEnabled = isEnabled, - CreatedAt = DateTime.UtcNow, - UpdatedAt = DateTime.UtcNow - }; - - createdIndexers.Add(await _indexerRepository.AddAsync(indexer)); + return StatusCode(result.StatusCode ?? StatusCodes.Status502BadGateway, new { message = result.Message }); } return Ok(new { - addedCount = createdIndexers.Count, - skippedCount = skipped, - total = createdIndexers.Count + skipped, - indexers = createdIndexers.Select(i => new { id = i.Id, name = i.Name, url = i.Url, implementation = i.Implementation }) + addedCount = result.AddedCount, + skippedCount = result.SkippedCount, + total = result.Total, + indexers = result.CreatedIndexers.Select(i => new { id = i.Id, name = i.Name, url = i.Url, implementation = i.Implementation }) }); } @@ -802,121 +614,5 @@ public async Task GetEnabled() return Ok(RedactIndexersForCaller(indexers)); } - private async Task<(HttpResponseMessage Response, string Payload)> FetchProwlarrIndexersAsync(string baseUrl, string apiKey) - { - var encodedKey = System.Net.WebUtility.UrlEncode(apiKey); - // NOTE: This targets external Prowlarr instances, whose API path is /api/v1. - // It is intentionally independent from Listenarr's own API version segment. - var endpoints = new List - { - $"{baseUrl}/api/v1/indexer", - $"{baseUrl}/api/v1/indexer?apikey={encodedKey}" - }; - - HttpResponseMessage? lastResponse = null; - string lastPayload = string.Empty; - - foreach (var endpoint in endpoints) - { - var response = await SendValidatedAsync(currentUri => - { - var retryRequest = new HttpRequestMessage(HttpMethod.Get, currentUri); - retryRequest.Headers.Add("X-Api-Key", apiKey); - return retryRequest; - }, endpoint); - var body = await response.Content.ReadAsStringAsync(); - - if (response.IsSuccessStatusCode) - { - return (response, body); - } - - lastResponse?.Dispose(); - lastResponse = response; - lastPayload = body; - - if (response.StatusCode != System.Net.HttpStatusCode.MethodNotAllowed && - response.StatusCode != System.Net.HttpStatusCode.Unauthorized && - response.StatusCode != System.Net.HttpStatusCode.Forbidden) - { - break; - } - } - - return (lastResponse ?? new HttpResponseMessage(System.Net.HttpStatusCode.BadGateway), lastPayload); - } - - private async Task?> TryFetchProwlarrTagMapAsync(string baseUrl, string apiKey) - { - try - { - var encodedKey = System.Net.WebUtility.UrlEncode(apiKey); - var endpoints = new List - { - $"{baseUrl}/api/v1/tag", - $"{baseUrl}/api/v1/tag?apikey={encodedKey}" - }; - - foreach (var endpoint in endpoints) - { - using var response = await SendValidatedAsync(currentUri => - { - var retryRequest = new HttpRequestMessage(HttpMethod.Get, currentUri); - retryRequest.Headers.Add("X-Api-Key", apiKey); - return retryRequest; - }, endpoint); - - var body = await response.Content.ReadAsStringAsync(); - if (!response.IsSuccessStatusCode) - { - if (response.StatusCode != System.Net.HttpStatusCode.MethodNotAllowed && - response.StatusCode != System.Net.HttpStatusCode.Unauthorized && - response.StatusCode != System.Net.HttpStatusCode.Forbidden) - { - break; - } - - continue; - } - - using var doc = JsonDocument.Parse(body); - if (doc.RootElement.ValueKind != JsonValueKind.Array) - { - return null; - } - - var result = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var tag in doc.RootElement.EnumerateArray()) - { - if (!tag.TryGetProperty("id", out var idProp) || idProp.ValueKind != JsonValueKind.Number) - { - continue; - } - - var id = idProp.GetInt32().ToString(); - var label = - tag.TryGetProperty("label", out var labelProp) && labelProp.ValueKind == JsonValueKind.String - ? labelProp.GetString() - : tag.TryGetProperty("name", out var nameProp) && nameProp.ValueKind == JsonValueKind.String - ? nameProp.GetString() - : null; - - if (!string.IsNullOrWhiteSpace(label)) - { - result[id] = label.Trim(); - } - } - - return result; - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Failed to load Prowlarr tags from {Url}", LogRedaction.SanitizeUrl(baseUrl)); - } - - return null; - } - } } diff --git a/listenarr.api/Controllers/ProwlarrIndexerImportWorkflow.cs b/listenarr.api/Controllers/ProwlarrIndexerImportWorkflow.cs new file mode 100644 index 000000000..54c383c65 --- /dev/null +++ b/listenarr.api/Controllers/ProwlarrIndexerImportWorkflow.cs @@ -0,0 +1,400 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +using System.Net; +using System.Text.Json; +using Listenarr.Api.Dtos; +using Listenarr.Application.Interfaces; +using Listenarr.Application.Interfaces.Repositories; +using Listenarr.Application.Search; +using Listenarr.Application.Security; +using Listenarr.Domain.Models; + +namespace Listenarr.Api.Controllers +{ + public sealed class ProwlarrIndexerImportWorkflow + { + private readonly IIndexerRepository _indexerRepository; + private readonly IConfigurationService _configurationService; + private readonly HttpClient _httpClientNoRedirect; + private readonly ILogger _logger; + + public ProwlarrIndexerImportWorkflow( + IIndexerRepository indexerRepository, + IConfigurationService configurationService, + HttpClient httpClient, + ILogger logger) + { + _indexerRepository = indexerRepository; + _configurationService = configurationService; + _httpClientNoRedirect = httpClient; + _logger = logger; + } + + public async Task ImportAsync(ProwlarrImportRequestDto? request) + { + if (request == null) + { + return ProwlarrIndexerImportWorkflowResult.BadRequest("Request body is required"); + } + + var savedConnection = await _configurationService.GetProwlarrImportSettingsAsync(includeSecret: true); + var effectiveUrl = string.IsNullOrWhiteSpace(request.Url) ? savedConnection.Url : request.Url.Trim(); + var effectivePort = request.ClearPort ? null : request.Port ?? savedConnection.Port; + var effectiveApiKey = string.IsNullOrWhiteSpace(request.ApiKey) ? savedConnection.ApiKey : request.ApiKey.Trim(); + var effectiveTagFilter = request.TagFilter == null + ? savedConnection.TagFilter?.Trim() + : request.TagFilter.Trim(); + + if (string.IsNullOrWhiteSpace(effectiveUrl)) + { + return ProwlarrIndexerImportWorkflowResult.BadRequest("Prowlarr URL is required"); + } + + if (string.IsNullOrWhiteSpace(effectiveApiKey)) + { + return ProwlarrIndexerImportWorkflowResult.BadRequest("Prowlarr API key is required"); + } + + var baseUrl = ProwlarrImportUrlPlanner.BuildBaseUrl(effectiveUrl, effectivePort); + var blockedBaseUrlReason = ValidateOutboundUrl(baseUrl); + if (!string.IsNullOrWhiteSpace(blockedBaseUrlReason)) + { + return ProwlarrIndexerImportWorkflowResult.BadRequest($"Blocked Prowlarr target: {blockedBaseUrlReason}"); + } + + HttpResponseMessage response; + string payload; + try + { + (response, payload) = await FetchProwlarrIndexersAsync(baseUrl, effectiveApiKey.Trim()); + } + catch (HttpRequestException ex) + { + return BuildProwlarrApiFailure(baseUrl, ex); + } + catch (TaskCanceledException ex) + { + return BuildProwlarrApiFailure(baseUrl, ex); + } + catch (UriFormatException ex) + { + return BuildProwlarrApiFailure(baseUrl, ex); + } + catch (InvalidOperationException ex) + { + return BuildProwlarrApiFailure(baseUrl, ex); + } + + using (response) + { + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning("Prowlarr API returned {StatusCode}: {Body}", (int)response.StatusCode, LogRedaction.SanitizeText(payload)); + return ProwlarrIndexerImportWorkflowResult.UpstreamError("Prowlarr API error", (int)response.StatusCode, (int)response.StatusCode); + } + } + + using var doc = JsonDocument.Parse(payload); + if (doc.RootElement.ValueKind != JsonValueKind.Array) + { + return ProwlarrIndexerImportWorkflowResult.UpstreamError("Unexpected Prowlarr API response", StatusCodes.Status502BadGateway); + } + + await _configurationService.SaveProwlarrImportSettingsAsync(new ProwlarrImportConnectionSettings + { + Url = effectiveUrl, + Port = effectivePort, + ApiKey = string.IsNullOrWhiteSpace(request.ApiKey) ? null : request.ApiKey.Trim(), + TagFilter = effectiveTagFilter, + }); + + var existingIndexers = await _indexerRepository.GetAllAsync(); + var createdIndexers = new List(); + var skipped = 0; + Dictionary? tagMap = null; + + if (!string.IsNullOrWhiteSpace(effectiveTagFilter)) + { + tagMap = await TryFetchProwlarrTagMapAsync(baseUrl, effectiveApiKey.Trim()); + if ((tagMap == null || tagMap.Count == 0) && ProwlarrIndexerPayloadParser.PayloadRequiresTagMap(doc.RootElement)) + { + _logger.LogWarning( + "Prowlarr tag-filtered import for {Url} requires tag label lookup, but tags could not be loaded", + LogRedaction.SanitizeUrl(baseUrl)); + return ProwlarrIndexerImportWorkflowResult.UpstreamError("Failed to load Prowlarr tags required for tag-filtered import", StatusCodes.Status502BadGateway); + } + } + + foreach (var element in doc.RootElement.EnumerateArray()) + { + if (!element.TryGetProperty("id", out var idProp) || idProp.ValueKind != JsonValueKind.Number) + { + skipped++; + continue; + } + + var indexerId = idProp.GetInt32(); + var categoryIds = ProwlarrIndexerPayloadParser.GetCategoryIds(element); + var prowlarrTags = ProwlarrIndexerPayloadParser.GetTagValues(element, tagMap); + var matchesImportFilter = string.IsNullOrWhiteSpace(effectiveTagFilter) + ? categoryIds.Contains(3000) || categoryIds.Contains(3030) + : prowlarrTags.Any(tag => string.Equals(tag, effectiveTagFilter, StringComparison.OrdinalIgnoreCase)); + + if (!matchesImportFilter) + { + skipped++; + continue; + } + + var name = element.TryGetProperty("name", out var nameProp) && nameProp.ValueKind == JsonValueKind.String + ? nameProp.GetString() ?? "Prowlarr Indexer" + : "Prowlarr Indexer"; + if (!name.EndsWith(" (Prowlarr)", StringComparison.OrdinalIgnoreCase)) + { + name = $"{name} (Prowlarr)"; + } + + var protocol = element.TryGetProperty("protocol", out var protocolProp) && protocolProp.ValueKind == JsonValueKind.String + ? protocolProp.GetString() ?? string.Empty + : string.Empty; + + var implementation = protocol.Equals("usenet", StringComparison.OrdinalIgnoreCase) ? "Newznab" : "Torznab"; + var proxyUrl = ProwlarrImportUrlPlanner.BuildProxyUrl(baseUrl, indexerId); + var normalizedUrl = ProwlarrImportUrlPlanner.NormalizeProxyUrl(proxyUrl); + + var exists = existingIndexers.FirstOrDefault(i => + ProwlarrImportUrlPlanner.NormalizeProxyUrl(i.Url) == normalizedUrl && + string.Equals(i.Implementation, implementation, StringComparison.OrdinalIgnoreCase) && + string.Equals(i.ApiKey ?? string.Empty, effectiveApiKey ?? string.Empty, StringComparison.Ordinal)); + + if (exists != null) + { + skipped++; + continue; + } + + var type = protocol.Equals("usenet", StringComparison.OrdinalIgnoreCase) ? "Usenet" : "Torrent"; + var categories = string.Join(',', categoryIds.Where(c => c == 3000 || c == 3030).OrderBy(c => c)); + + var isEnabled = true; + if (element.TryGetProperty("enable", out var enableProp)) + { + isEnabled = enableProp.ValueKind == JsonValueKind.True; + } + else if (element.TryGetProperty("enabled", out var enabledProp)) + { + isEnabled = enabledProp.ValueKind == JsonValueKind.True; + } + + var indexer = new Indexer + { + Name = name, + Type = type, + Implementation = implementation, + Url = normalizedUrl, + ApiKey = string.IsNullOrWhiteSpace(effectiveApiKey) ? null : effectiveApiKey.Trim(), + Categories = categories, + EnableRss = true, + EnableAutomaticSearch = true, + EnableInteractiveSearch = true, + IsEnabled = isEnabled, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + + createdIndexers.Add(await _indexerRepository.AddAsync(indexer)); + } + + return ProwlarrIndexerImportWorkflowResult.Success(createdIndexers, skipped); + } + + private ProwlarrIndexerImportWorkflowResult BuildProwlarrApiFailure(string baseUrl, Exception ex) + { + _logger.LogWarning(ex, "Failed to reach Prowlarr at {Url}", LogRedaction.SanitizeUrl(baseUrl)); + return ProwlarrIndexerImportWorkflowResult.UpstreamError("Failed to reach Prowlarr API", StatusCodes.Status502BadGateway); + } + + private async Task<(HttpResponseMessage Response, string Payload)> FetchProwlarrIndexersAsync(string baseUrl, string apiKey) + { + var encodedKey = WebUtility.UrlEncode(apiKey); + // NOTE: This targets external Prowlarr instances, whose API path is /api/v1. + // It is intentionally independent from Listenarr's own API version segment. + var endpoints = new List + { + $"{baseUrl}/api/v1/indexer", + $"{baseUrl}/api/v1/indexer?apikey={encodedKey}" + }; + + HttpResponseMessage? lastResponse = null; + string lastPayload = string.Empty; + + foreach (var endpoint in endpoints) + { + var response = await SendValidatedAsync(currentUri => + { + var retryRequest = new HttpRequestMessage(HttpMethod.Get, currentUri); + retryRequest.Headers.Add("X-Api-Key", apiKey); + return retryRequest; + }, endpoint); + var body = await response.Content.ReadAsStringAsync(); + + if (response.IsSuccessStatusCode) + { + return (response, body); + } + + lastResponse?.Dispose(); + lastResponse = response; + lastPayload = body; + + if (response.StatusCode != HttpStatusCode.MethodNotAllowed && + response.StatusCode != HttpStatusCode.Unauthorized && + response.StatusCode != HttpStatusCode.Forbidden) + { + break; + } + } + + return (lastResponse ?? new HttpResponseMessage(HttpStatusCode.BadGateway), lastPayload); + } + + private async Task?> TryFetchProwlarrTagMapAsync(string baseUrl, string apiKey) + { + try + { + var encodedKey = WebUtility.UrlEncode(apiKey); + var endpoints = new List + { + $"{baseUrl}/api/v1/tag", + $"{baseUrl}/api/v1/tag?apikey={encodedKey}" + }; + + foreach (var endpoint in endpoints) + { + using var response = await SendValidatedAsync(currentUri => + { + var retryRequest = new HttpRequestMessage(HttpMethod.Get, currentUri); + retryRequest.Headers.Add("X-Api-Key", apiKey); + return retryRequest; + }, endpoint); + + var body = await response.Content.ReadAsStringAsync(); + if (!response.IsSuccessStatusCode) + { + if (response.StatusCode != HttpStatusCode.MethodNotAllowed && + response.StatusCode != HttpStatusCode.Unauthorized && + response.StatusCode != HttpStatusCode.Forbidden) + { + break; + } + + continue; + } + + using var doc = JsonDocument.Parse(body); + if (doc.RootElement.ValueKind != JsonValueKind.Array) + { + return null; + } + + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var tag in doc.RootElement.EnumerateArray()) + { + if (!tag.TryGetProperty("id", out var idProp) || idProp.ValueKind != JsonValueKind.Number) + { + continue; + } + + var id = idProp.GetInt32().ToString(); + var label = + tag.TryGetProperty("label", out var labelProp) && labelProp.ValueKind == JsonValueKind.String + ? labelProp.GetString() + : tag.TryGetProperty("name", out var nameProp) && nameProp.ValueKind == JsonValueKind.String + ? nameProp.GetString() + : null; + + if (!string.IsNullOrWhiteSpace(label)) + { + result[id] = label.Trim(); + } + } + + return result; + } + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning(ex, "Failed to load Prowlarr tags from {Url}", LogRedaction.SanitizeUrl(baseUrl)); + } + + return null; + } + + private string? ValidateOutboundUrl(string url) + { + // *Arr standard behavior: allow private/loopback destinations for indexer connectivity + // tests/imports, but still enforce absolute HTTP(S) URLs and block embedded credentials. + if (!OutboundRequestSecurity.TryValidateExternalHttpUrl(url, out var reason, allowPrivateTargets: true)) + { + return reason; + } + + return null; + } + + private async Task SendValidatedAsync( + Func requestFactory, + string url, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + CancellationToken cancellationToken = default) + { + var uri = new Uri(url); + var (response, _) = await OutboundRequestSecurity.SendWithValidatedRedirectsAsync( + requestFactory, + uri, + _httpClientNoRedirect, + _logger, + allowPrivateTargets: true, + completionOption: completionOption, + cancellationToken: cancellationToken); + return response; + } + } + + public sealed record ProwlarrIndexerImportWorkflowResult( + ProwlarrIndexerImportWorkflowResultKind Kind, + string? Message, + int? StatusCode, + int? UpstreamStatus, + List CreatedIndexers, + int SkippedCount) + { + public int AddedCount => CreatedIndexers.Count; + + public int Total => AddedCount + SkippedCount; + + public static ProwlarrIndexerImportWorkflowResult Success(List createdIndexers, int skippedCount) + => new(ProwlarrIndexerImportWorkflowResultKind.Success, null, null, null, createdIndexers, skippedCount); + + public static ProwlarrIndexerImportWorkflowResult BadRequest(string message) + => new(ProwlarrIndexerImportWorkflowResultKind.BadRequest, message, StatusCodes.Status400BadRequest, null, new List(), 0); + + public static ProwlarrIndexerImportWorkflowResult UpstreamError(string message, int statusCode, int? upstreamStatus = null) + => new(ProwlarrIndexerImportWorkflowResultKind.UpstreamError, message, statusCode, upstreamStatus, new List(), 0); + } + + public enum ProwlarrIndexerImportWorkflowResultKind + { + Success, + BadRequest, + UpstreamError + } +} diff --git a/listenarr.api/Program.cs b/listenarr.api/Program.cs index 18885c15d..5dcfd1d9d 100644 --- a/listenarr.api/Program.cs +++ b/listenarr.api/Program.cs @@ -360,6 +360,7 @@ ex is IOException builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); From 95c0b8aad9dee61e00add8cda3d43efdb8ae8b23 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Sat, 13 Jun 2026 22:07:17 -0400 Subject: [PATCH 56/84] refactor: extract structured search workflow - Move POST search request orchestration into a focused workflow - Keep SearchController as the HTTP adapter for structured search responses --- listenarr.api/Controllers/SearchController.cs | 290 +-------------- .../Controllers/StructuredSearchWorkflow.cs | 343 ++++++++++++++++++ listenarr.api/Program.cs | 1 + 3 files changed, 358 insertions(+), 276 deletions(-) create mode 100644 listenarr.api/Controllers/StructuredSearchWorkflow.cs diff --git a/listenarr.api/Controllers/SearchController.cs b/listenarr.api/Controllers/SearchController.cs index efdcf02a3..b886ac562 100644 --- a/listenarr.api/Controllers/SearchController.cs +++ b/listenarr.api/Controllers/SearchController.cs @@ -36,9 +36,8 @@ public class SearchController : ControllerBase private readonly AudibleService _audibleService; private readonly IAudiobookMetadataService _metadataService; private readonly IImageCacheService? _imageCacheService; - private readonly MetadataConverters _metadataConverters; private readonly SearchResponseMapper _responseMapper; - private readonly SearchRequestReader _requestReader; + private readonly StructuredSearchWorkflow _structuredSearchWorkflow; public SearchController( ISearchService searchService, @@ -47,19 +46,27 @@ public SearchController( IAudiobookMetadataService metadataService, IImageCacheService? imageCacheService = null, MetadataConverters? metadataConverters = null, - SearchResponseMapper? responseMapper = null) + SearchResponseMapper? responseMapper = null, + StructuredSearchWorkflow? structuredSearchWorkflow = null) { _searchService = searchService; _logger = logger; _audibleService = audibleService; _metadataService = metadataService; _imageCacheService = imageCacheService; - _metadataConverters = metadataConverters ?? new MetadataConverters(imageCacheService, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + var metadataConvertersInstance = metadataConverters ?? new MetadataConverters(imageCacheService, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); _responseMapper = responseMapper ?? new SearchResponseMapper( metadataService, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance, imageCacheService); - _requestReader = new SearchRequestReader(_logger); + _structuredSearchWorkflow = structuredSearchWorkflow ?? new StructuredSearchWorkflow( + searchService, + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance, + audibleService, + metadataService, + imageCacheService, + metadataConvertersInstance, + _responseMapper); } private string BuildApiImagePath(string identifier, string? sourceUrl = null) @@ -74,277 +81,8 @@ private string BuildApiImagePath(string identifier, string? sourceUrl = null) [HttpPost] public async Task> Search([FromBody] JsonElement reqJson, [FromQuery] bool? simplified = null) { - try - { - if (reqJson.ValueKind == JsonValueKind.Undefined || reqJson.ValueKind == JsonValueKind.Null) - { - return BadRequest("SearchRequest body is required"); - } - - var req = _requestReader.Read(reqJson); - if (req == null) return BadRequest("SearchRequest body is required"); - _logger.LogDebug("[DBG] Search received mode={Mode}, query='{Query}'", req.Mode, LogRedaction.SanitizeText(req.Query ?? "")); - - // Default to simplified=true for both modes (user only needs metadata for Add New feature) - var useSimplified = simplified ?? true; - - if (req.Mode == SearchMode.Simple) - { - var q = req.Query ?? string.Empty; - var region = string.IsNullOrWhiteSpace(req.Region) ? "us" : req.Region; - var language = string.IsNullOrWhiteSpace(req.Language) ? null : req.Language; - var results = await _searchService.IntelligentSearchAsync(q, region: region, language: language, ct: HttpContext.RequestAborted) ?? new List(); - - await SearchResultImageNormalizer.NormalizeMetadataResultsAsync( - results, - _imageCacheService, - HttpContext, - _logger, - "metadata result", - setApiPathWhenNoExternalImage: true); - - // Map metadata results into Audible-shaped objects for public API consumers - var mapped = await Task.WhenAll((results ?? new List()).Select(r => _responseMapper.MapMetadataResultToAudibleAsync(r, region, HttpContext))).ConfigureAwait(false); - _logger.LogDebug("[DBG] Search(simple) returning {Count} metadata results", mapped?.Length ?? 0); - return Ok(mapped); - } - else // Advanced - { - var advancedValidationError = _requestReader.NormalizeAdvancedRequest(req); - if (!string.IsNullOrWhiteSpace(advancedValidationError)) - { - return BadRequest(advancedValidationError); - } - - // Compose a query string from advanced parameters for unified handling - var region = string.IsNullOrWhiteSpace(req.Region) ? "us" : req.Region; - var language = string.IsNullOrWhiteSpace(req.Language) ? null : req.Language; - - // If no advanced search parameters were provided, signal BadRequest to caller - if (string.IsNullOrWhiteSpace(req.Title) - && string.IsNullOrWhiteSpace(req.Author) - && string.IsNullOrWhiteSpace(req.Query) - && string.IsNullOrWhiteSpace(req.Isbn) - && string.IsNullOrWhiteSpace(req.Asin) - && string.IsNullOrWhiteSpace(req.Series)) - { - return BadRequest("At least one advanced search parameter (title, author, isbn, asin, series, or query) is required"); - } - // Debug: log incoming advanced parameters for diagnostics - try { _logger.LogInformation("[DBG] Advanced search request: Author='{Author}', Title='{Title}', Isbn='{Isbn}', Asin='{Asin}', Query='{Query}', Region='{Region}', Language='{Language}'", LogRedaction.SanitizeText(req.Author), LogRedaction.SanitizeText(req.Title), LogRedaction.SanitizeText(req.Isbn), LogRedaction.SanitizeText(req.Asin), LogRedaction.SanitizeText(req.Query), LogRedaction.SanitizeText(region), LogRedaction.SanitizeText(language)); } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - System.Diagnostics.Debug.WriteLine($"SearchController advanced-search info logging failed: {ex.Message}"); - } - try { _logger.LogDebug("[DBG] Advanced params: Title='{Title}', Author='{Author}', Isbn='{Isbn}'", LogRedaction.SanitizeText(req.Title), LogRedaction.SanitizeText(req.Author), LogRedaction.SanitizeText(req.Isbn)); } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - System.Diagnostics.Debug.WriteLine($"SearchController advanced-search debug logging failed: {ex.Message}"); - } - - // If the advanced request contains an ASIN, prefer a direct Audible metadata - // lookup and return a single enriched SearchResult. ASIN searches should - // be authoritative and ignore other advanced inputs. - if (!string.IsNullOrWhiteSpace(req.Asin)) - { - try - { - var audible = await _audibleService.GetBookMetadataAsync(req.Asin, region, true); - if (audible != null) - { - // Convert audible response to internal metadata then to SearchResult - var metadata = _metadataConverters.ConvertAudibleToMetadata(audible, req.Asin, source: "Audible"); - var sr = await _metadataConverters.ConvertMetadataToSearchResultAsync(metadata, req.Asin, req.Title, req.Author, fallbackImageUrl: null, fallbackLanguage: language); - _responseMapper.SanitizeResultForPublicApi(sr); - // Convert to metadata result and normalize images for API response - var md = SearchResultConverters.ToMetadata(sr); - await SearchResultImageNormalizer.NormalizeMetadataResultAsync( - md, - _imageCacheService, - HttpContext, - _logger, - "ASIN metadata", - setApiPathWhenNoExternalImage: false); - if (md != null) - { - var result = SearchResultConverters.ToSearchResult(md); - var asinResults = new List { result }; - return Ok(useSimplified ? _responseMapper.SimplifySearchResults(asinResults) : asinResults); - } - } - // If audible didn't return a record, fall through to unified search below - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Audible metadata lookup failed for ASIN {Asin} in advanced search; falling back to unified search", req.Asin); - } - } - - - - // If a series name or series ASIN was provided, prefer Audible series endpoints. - // If series is provided and no author is supplied, take the series-specialized path. - // If an author is present, prefer the author flow and later filter by series. - if (!string.IsNullOrWhiteSpace(req.Series) && string.IsNullOrWhiteSpace(req.Author)) - { - try - { - string? seriesAsin = null; - var seriesInput = req.Series.Trim(); - - // Check if the provided value already looks like an ASIN - if (seriesInput.StartsWith("B0", StringComparison.OrdinalIgnoreCase) && seriesInput.Length >= 10) - { - seriesAsin = seriesInput; - } - else - { - // Search by name to resolve the series ASIN - var seriesSearch = await _audibleService.SearchSeriesByNameAsync(seriesInput, region); - _logger.LogInformation("SearchSeriesByNameAsync returned type={Type}, isNull={IsNull}", - seriesSearch?.GetType().Name ?? "null", seriesSearch == null); - if (seriesSearch is IEnumerable seriesList) - { - var seriesListMaterialized = seriesList.ToList(); - _logger.LogInformation("Series lookup for '{SeriesName}' returned {Count} items", LogRedaction.SanitizeText(seriesInput), seriesListMaterialized.Count); - var chosenItem = seriesListMaterialized.FirstOrDefault(s => - !string.IsNullOrWhiteSpace(s.Asin) && - string.Equals(s.Region, region, StringComparison.OrdinalIgnoreCase)) - ?? seriesListMaterialized.FirstOrDefault(s => !string.IsNullOrWhiteSpace(s.Asin)); - if (chosenItem != null) - { - seriesAsin = chosenItem.Asin; - _logger.LogInformation("Resolved series '{SeriesName}' to ASIN {SeriesAsin}", LogRedaction.SanitizeText(req.Series), LogRedaction.SanitizeText(seriesAsin)); - } - } - - if (string.IsNullOrWhiteSpace(seriesAsin)) - { - _logger.LogInformation("No series ASIN found for '{SeriesName}'; falling back to unified search", LogRedaction.SanitizeText(req.Series)); - } - } - - // Fetch all books for the resolved series ASIN - if (!string.IsNullOrWhiteSpace(seriesAsin)) - { - var booksObj = await _audibleService.GetBooksBySeriesAsinAsync(seriesAsin, region); - - // Direct cast — GetBooksBySeriesAsinAsync returns List - var books = booksObj as List; - - if (books != null && books.Any()) - { - // Apply language filter when a preferred language was specified - if (!string.IsNullOrWhiteSpace(language) && !string.Equals(language, "all", StringComparison.OrdinalIgnoreCase)) - { - var langFilter = language.Trim(); - books = books.Where(b => - string.IsNullOrWhiteSpace(b.Language) || - string.Equals(b.Language.Trim(), langFilter, StringComparison.OrdinalIgnoreCase)) - .ToList(); - } - - _logger.LogInformation("Series ASIN {SeriesAsin} returned {Count} books (after language filter)", seriesAsin, books.Count); - - // Return books in the same Audible-shaped format as the unified search path - var seriesResults = new List(); - foreach (var book in books) - { - try - { - seriesResults.Add(await _responseMapper.MapAudibleSearchResultToOutputAsync(book, region, HttpContext)); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Failed converting series book to output for ASIN {Asin}", book.Asin); - } - } - - if (seriesResults.Any()) - { - return Ok(seriesResults); - } - } - else - { - _logger.LogInformation("Series ASIN {SeriesAsin} returned no books", seriesAsin); - } - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Failed to perform series lookup for '{Series}' in advanced search; falling back to unified search", LogRedaction.SanitizeText(req.Series)); - } - } - - // Previously there was a special-case path here that handled author-only - // advanced searches separately. To ensure all advanced searches (author-only, - // author+title, title-only, ISBN, etc.) receive identical metadata - // enrichment and conversion, route advanced requests through the - // unified IntelligentSearch pipeline below. This guarantees Audible - // metadata is fetched and converted consistently. - - // Compose a query string from advanced parameters for unified handling - var queryParts = new List(); - // Prefix author/title/isbn/asin tokens so IntelligentSearch parser - // recognizes them and selects the correct search branch (e.g. AUTHOR_TITLE). - if (!string.IsNullOrWhiteSpace(req.Author)) queryParts.Add($"AUTHOR:{req.Author}"); - if (!string.IsNullOrWhiteSpace(req.Title)) queryParts.Add($"TITLE:{req.Title}"); - if (!string.IsNullOrWhiteSpace(req.Isbn)) queryParts.Add($"ISBN:{req.Isbn}"); - if (!string.IsNullOrWhiteSpace(req.Asin)) queryParts.Add($"ASIN:{req.Asin}"); - // When only a series name was provided and the series-specific lookup above - // didn't resolve, use it as a plain keyword query so the general - // SearchBooksAsync branch handles it (more resilient than TITLE-specific). - // The destructive series filter below ensures only matching results return. - if (queryParts.Count == 0 && !string.IsNullOrWhiteSpace(req.Series)) - queryParts.Add(req.Series); - var query = queryParts.Count > 0 ? string.Join(" ", queryParts) : (req.Query ?? string.Empty); - try { _logger.LogInformation("Advanced search request composed parts={Parts} -> query='{Query}'", string.Join("|", queryParts), LogRedaction.SanitizeText(query)); } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - System.Diagnostics.Debug.WriteLine($"SearchController composed-query logging failed: {ex.Message}"); - } - // Respect optional pagination/candidate caps from the client - var candidateLimit = req.Cap.HasValue ? Math.Clamp(req.Cap.Value, 5, 2000) : 200; - var returnLimit = req.Pagination != null && req.Pagination.Limit > 0 ? Math.Clamp(req.Pagination.Limit, 1, 1000) : 50; - var results = await _searchService.IntelligentSearchAsync(query, candidateLimit, returnLimit, region: region, language: language, ct: HttpContext.RequestAborted); - - await _responseMapper.NormalizeMetadataResultImagesAsync(results, HttpContext, "result"); - - // When a Series filter was provided, apply it to unified search results so only - // books actually belonging to the series are returned. This covers both the - // author+series path and the series-only fallback (when the series ASIN lookup - // above didn't resolve and the series name was injected as TITLE:). - if (!string.IsNullOrWhiteSpace(req.Series) && results != null) - { - try - { - var seriesFilter = req.Series.Trim(); - var ci = System.Globalization.CultureInfo.InvariantCulture.CompareInfo; - const System.Globalization.CompareOptions diOpts = System.Globalization.CompareOptions.IgnoreCase | System.Globalization.CompareOptions.IgnoreNonSpace; - var filtered = System.Text.RegularExpressions.Regex.IsMatch(seriesFilter, @"^B0[A-Z0-9]{8,}$", System.Text.RegularExpressions.RegexOptions.IgnoreCase) - ? results.Where(r => (!string.IsNullOrWhiteSpace(r.Series) && ci.IndexOf(r.Series, seriesFilter, diOpts) >= 0) - || (!string.IsNullOrWhiteSpace(r.Asin) && string.Equals(r.Asin, seriesFilter, StringComparison.OrdinalIgnoreCase))).ToList() - : results.Where(r => !string.IsNullOrWhiteSpace(r.Series) && ci.IndexOf(r.Series, seriesFilter, diOpts) >= 0).ToList(); - - results = filtered; - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogDebug(ex, "Failed to apply series filter '{Series}' to advanced search results", LogRedaction.SanitizeText(req.Series)); - } - } - - // Flatten metadata results into Audible-shaped objects for public POST /api/search response - var flatMapped = await Task.WhenAll((results ?? new List()).Select(r => _responseMapper.MapMetadataResultToAudibleAsync(r, region, HttpContext))).ConfigureAwait(false); - return Ok(flatMapped); - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogError(ex, "Error parsing search request body"); - return BadRequest("Invalid search request"); - } + var result = await _structuredSearchWorkflow.ExecuteAsync(reqJson, simplified, HttpContext); + return result.Succeeded ? Ok(result.Payload) : BadRequest(result.Payload); } /// diff --git a/listenarr.api/Controllers/StructuredSearchWorkflow.cs b/listenarr.api/Controllers/StructuredSearchWorkflow.cs new file mode 100644 index 000000000..72cf96450 --- /dev/null +++ b/listenarr.api/Controllers/StructuredSearchWorkflow.cs @@ -0,0 +1,343 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +using System.Text.Json; +using Listenarr.Application.Interfaces; +using Listenarr.Application.Metadata; +using Listenarr.Application.Search; +using Listenarr.Application.Security; +using Listenarr.Domain.Models; + +namespace Listenarr.Api.Controllers +{ + public sealed class StructuredSearchWorkflow + { + private readonly ISearchService _searchService; + private readonly Microsoft.Extensions.Logging.ILogger _logger; + private readonly AudibleService _audibleService; + private readonly IImageCacheService? _imageCacheService; + private readonly MetadataConverters _metadataConverters; + private readonly SearchResponseMapper _responseMapper; + private readonly SearchRequestReader _requestReader; + + public StructuredSearchWorkflow( + ISearchService searchService, + Microsoft.Extensions.Logging.ILogger logger, + AudibleService audibleService, + IAudiobookMetadataService metadataService, + IImageCacheService? imageCacheService = null, + MetadataConverters? metadataConverters = null, + SearchResponseMapper? responseMapper = null) + { + _searchService = searchService; + _logger = logger; + _audibleService = audibleService; + _imageCacheService = imageCacheService; + _metadataConverters = metadataConverters ?? new MetadataConverters(imageCacheService, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + _responseMapper = responseMapper ?? new SearchResponseMapper( + metadataService, + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance, + imageCacheService); + _requestReader = new SearchRequestReader(_logger); + } + + public async Task ExecuteAsync(JsonElement reqJson, bool? simplified, HttpContext httpContext) + { + try + { + if (reqJson.ValueKind == JsonValueKind.Undefined || reqJson.ValueKind == JsonValueKind.Null) + { + return StructuredSearchWorkflowResult.BadRequest("SearchRequest body is required"); + } + + var req = _requestReader.Read(reqJson); + if (req == null) return StructuredSearchWorkflowResult.BadRequest("SearchRequest body is required"); + _logger.LogDebug("[DBG] Search received mode={Mode}, query='{Query}'", req.Mode, LogRedaction.SanitizeText(req.Query ?? "")); + + var useSimplified = simplified ?? true; + + if (req.Mode == SearchMode.Simple) + { + return await ExecuteSimpleSearchAsync(req, httpContext); + } + + return await ExecuteAdvancedSearchAsync(req, useSimplified, httpContext); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogError(ex, "Error parsing search request body"); + return StructuredSearchWorkflowResult.BadRequest("Invalid search request"); + } + } + + private async Task ExecuteSimpleSearchAsync(SearchRequest req, HttpContext httpContext) + { + var q = req.Query ?? string.Empty; + var region = string.IsNullOrWhiteSpace(req.Region) ? "us" : req.Region; + var language = string.IsNullOrWhiteSpace(req.Language) ? null : req.Language; + var results = await _searchService.IntelligentSearchAsync(q, region: region, language: language, ct: httpContext.RequestAborted) ?? new List(); + + await SearchResultImageNormalizer.NormalizeMetadataResultsAsync( + results, + _imageCacheService, + httpContext, + _logger, + "metadata result", + setApiPathWhenNoExternalImage: true); + + var mapped = await Task.WhenAll((results ?? new List()).Select(r => _responseMapper.MapMetadataResultToAudibleAsync(r, region, httpContext))).ConfigureAwait(false); + _logger.LogDebug("[DBG] Search(simple) returning {Count} metadata results", mapped?.Length ?? 0); + return StructuredSearchWorkflowResult.Ok(mapped); + } + + private async Task ExecuteAdvancedSearchAsync(SearchRequest req, bool useSimplified, HttpContext httpContext) + { + var advancedValidationError = _requestReader.NormalizeAdvancedRequest(req); + if (!string.IsNullOrWhiteSpace(advancedValidationError)) + { + return StructuredSearchWorkflowResult.BadRequest(advancedValidationError); + } + + var region = string.IsNullOrWhiteSpace(req.Region) ? "us" : req.Region; + var language = string.IsNullOrWhiteSpace(req.Language) ? null : req.Language; + + if (string.IsNullOrWhiteSpace(req.Title) + && string.IsNullOrWhiteSpace(req.Author) + && string.IsNullOrWhiteSpace(req.Query) + && string.IsNullOrWhiteSpace(req.Isbn) + && string.IsNullOrWhiteSpace(req.Asin) + && string.IsNullOrWhiteSpace(req.Series)) + { + return StructuredSearchWorkflowResult.BadRequest("At least one advanced search parameter (title, author, isbn, asin, series, or query) is required"); + } + + LogAdvancedRequest(req, region, language); + + var asinResult = await TryExecuteAsinSearchAsync(req, useSimplified, region, language, httpContext); + if (asinResult != null) + { + return asinResult; + } + + var seriesResult = await TryExecuteSeriesSearchAsync(req, region, language, httpContext); + if (seriesResult != null) + { + return seriesResult; + } + + var query = ComposeAdvancedQuery(req); + var candidateLimit = req.Cap.HasValue ? Math.Clamp(req.Cap.Value, 5, 2000) : 200; + var returnLimit = req.Pagination != null && req.Pagination.Limit > 0 ? Math.Clamp(req.Pagination.Limit, 1, 1000) : 50; + var results = await _searchService.IntelligentSearchAsync(query, candidateLimit, returnLimit, region: region, language: language, ct: httpContext.RequestAborted); + + await _responseMapper.NormalizeMetadataResultImagesAsync(results, httpContext, "result"); + results = ApplySeriesFilter(req, results); + + var flatMapped = await Task.WhenAll((results ?? new List()).Select(r => _responseMapper.MapMetadataResultToAudibleAsync(r, region, httpContext))).ConfigureAwait(false); + return StructuredSearchWorkflowResult.Ok(flatMapped); + } + + private void LogAdvancedRequest(SearchRequest req, string region, string? language) + { + try { _logger.LogInformation("[DBG] Advanced search request: Author='{Author}', Title='{Title}', Isbn='{Isbn}', Asin='{Asin}', Query='{Query}', Region='{Region}', Language='{Language}'", LogRedaction.SanitizeText(req.Author), LogRedaction.SanitizeText(req.Title), LogRedaction.SanitizeText(req.Isbn), LogRedaction.SanitizeText(req.Asin), LogRedaction.SanitizeText(req.Query), LogRedaction.SanitizeText(region), LogRedaction.SanitizeText(language)); } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + System.Diagnostics.Debug.WriteLine($"SearchController advanced-search info logging failed: {ex.Message}"); + } + try { _logger.LogDebug("[DBG] Advanced params: Title='{Title}', Author='{Author}', Isbn='{Isbn}'", LogRedaction.SanitizeText(req.Title), LogRedaction.SanitizeText(req.Author), LogRedaction.SanitizeText(req.Isbn)); } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + System.Diagnostics.Debug.WriteLine($"SearchController advanced-search debug logging failed: {ex.Message}"); + } + } + + private async Task TryExecuteAsinSearchAsync(SearchRequest req, bool useSimplified, string region, string? language, HttpContext httpContext) + { + if (string.IsNullOrWhiteSpace(req.Asin)) + { + return null; + } + + try + { + var audible = await _audibleService.GetBookMetadataAsync(req.Asin, region, true); + if (audible != null) + { + var metadata = _metadataConverters.ConvertAudibleToMetadata(audible, req.Asin, source: "Audible"); + var sr = await _metadataConverters.ConvertMetadataToSearchResultAsync(metadata, req.Asin, req.Title, req.Author, fallbackImageUrl: null, fallbackLanguage: language); + _responseMapper.SanitizeResultForPublicApi(sr); + var md = SearchResultConverters.ToMetadata(sr); + await SearchResultImageNormalizer.NormalizeMetadataResultAsync( + md, + _imageCacheService, + httpContext, + _logger, + "ASIN metadata", + setApiPathWhenNoExternalImage: false); + if (md != null) + { + var result = SearchResultConverters.ToSearchResult(md); + var asinResults = new List { result }; + return StructuredSearchWorkflowResult.Ok(useSimplified ? _responseMapper.SimplifySearchResults(asinResults) : asinResults); + } + } + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning(ex, "Audible metadata lookup failed for ASIN {Asin} in advanced search; falling back to unified search", req.Asin); + } + + return null; + } + + private async Task TryExecuteSeriesSearchAsync(SearchRequest req, string region, string? language, HttpContext httpContext) + { + if (string.IsNullOrWhiteSpace(req.Series) || !string.IsNullOrWhiteSpace(req.Author)) + { + return null; + } + + try + { + string? seriesAsin = null; + var seriesInput = req.Series.Trim(); + + if (seriesInput.StartsWith("B0", StringComparison.OrdinalIgnoreCase) && seriesInput.Length >= 10) + { + seriesAsin = seriesInput; + } + else + { + var seriesSearch = await _audibleService.SearchSeriesByNameAsync(seriesInput, region); + _logger.LogInformation("SearchSeriesByNameAsync returned type={Type}, isNull={IsNull}", + seriesSearch?.GetType().Name ?? "null", seriesSearch == null); + if (seriesSearch is IEnumerable seriesList) + { + var seriesListMaterialized = seriesList.ToList(); + _logger.LogInformation("Series lookup for '{SeriesName}' returned {Count} items", LogRedaction.SanitizeText(seriesInput), seriesListMaterialized.Count); + var chosenItem = seriesListMaterialized.FirstOrDefault(s => + !string.IsNullOrWhiteSpace(s.Asin) && + string.Equals(s.Region, region, StringComparison.OrdinalIgnoreCase)) + ?? seriesListMaterialized.FirstOrDefault(s => !string.IsNullOrWhiteSpace(s.Asin)); + if (chosenItem != null) + { + seriesAsin = chosenItem.Asin; + _logger.LogInformation("Resolved series '{SeriesName}' to ASIN {SeriesAsin}", LogRedaction.SanitizeText(req.Series), LogRedaction.SanitizeText(seriesAsin)); + } + } + + if (string.IsNullOrWhiteSpace(seriesAsin)) + { + _logger.LogInformation("No series ASIN found for '{SeriesName}'; falling back to unified search", LogRedaction.SanitizeText(req.Series)); + } + } + + if (!string.IsNullOrWhiteSpace(seriesAsin)) + { + var booksObj = await _audibleService.GetBooksBySeriesAsinAsync(seriesAsin, region); + var books = booksObj as List; + + if (books != null && books.Any()) + { + if (!string.IsNullOrWhiteSpace(language) && !string.Equals(language, "all", StringComparison.OrdinalIgnoreCase)) + { + var langFilter = language.Trim(); + books = books.Where(b => + string.IsNullOrWhiteSpace(b.Language) || + string.Equals(b.Language.Trim(), langFilter, StringComparison.OrdinalIgnoreCase)) + .ToList(); + } + + _logger.LogInformation("Series ASIN {SeriesAsin} returned {Count} books (after language filter)", seriesAsin, books.Count); + + var seriesResults = new List(); + foreach (var book in books) + { + try + { + seriesResults.Add(await _responseMapper.MapAudibleSearchResultToOutputAsync(book, region, httpContext)); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning(ex, "Failed converting series book to output for ASIN {Asin}", book.Asin); + } + } + + if (seriesResults.Any()) + { + return StructuredSearchWorkflowResult.Ok(seriesResults); + } + } + else + { + _logger.LogInformation("Series ASIN {SeriesAsin} returned no books", seriesAsin); + } + } + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning(ex, "Failed to perform series lookup for '{Series}' in advanced search; falling back to unified search", LogRedaction.SanitizeText(req.Series)); + } + + return null; + } + + private string ComposeAdvancedQuery(SearchRequest req) + { + var queryParts = new List(); + if (!string.IsNullOrWhiteSpace(req.Author)) queryParts.Add($"AUTHOR:{req.Author}"); + if (!string.IsNullOrWhiteSpace(req.Title)) queryParts.Add($"TITLE:{req.Title}"); + if (!string.IsNullOrWhiteSpace(req.Isbn)) queryParts.Add($"ISBN:{req.Isbn}"); + if (!string.IsNullOrWhiteSpace(req.Asin)) queryParts.Add($"ASIN:{req.Asin}"); + if (queryParts.Count == 0 && !string.IsNullOrWhiteSpace(req.Series)) + queryParts.Add(req.Series); + + var query = queryParts.Count > 0 ? string.Join(" ", queryParts) : (req.Query ?? string.Empty); + try { _logger.LogInformation("Advanced search request composed parts={Parts} -> query='{Query}'", string.Join("|", queryParts), LogRedaction.SanitizeText(query)); } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + System.Diagnostics.Debug.WriteLine($"SearchController composed-query logging failed: {ex.Message}"); + } + + return query; + } + + private List? ApplySeriesFilter(SearchRequest req, List? results) + { + if (string.IsNullOrWhiteSpace(req.Series) || results == null) + { + return results; + } + + try + { + var seriesFilter = req.Series.Trim(); + var ci = System.Globalization.CultureInfo.InvariantCulture.CompareInfo; + const System.Globalization.CompareOptions diOpts = System.Globalization.CompareOptions.IgnoreCase | System.Globalization.CompareOptions.IgnoreNonSpace; + return System.Text.RegularExpressions.Regex.IsMatch(seriesFilter, @"^B0[A-Z0-9]{8,}$", System.Text.RegularExpressions.RegexOptions.IgnoreCase) + ? results.Where(r => (!string.IsNullOrWhiteSpace(r.Series) && ci.IndexOf(r.Series, seriesFilter, diOpts) >= 0) + || (!string.IsNullOrWhiteSpace(r.Asin) && string.Equals(r.Asin, seriesFilter, StringComparison.OrdinalIgnoreCase))).ToList() + : results.Where(r => !string.IsNullOrWhiteSpace(r.Series) && ci.IndexOf(r.Series, seriesFilter, diOpts) >= 0).ToList(); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogDebug(ex, "Failed to apply series filter '{Series}' to advanced search results", LogRedaction.SanitizeText(req.Series)); + return results; + } + } + } + + public sealed record StructuredSearchWorkflowResult(bool Succeeded, object? Payload) + { + public static StructuredSearchWorkflowResult Ok(object? payload) => new(true, payload); + + public static StructuredSearchWorkflowResult BadRequest(object? payload) => new(false, payload); + } +} diff --git a/listenarr.api/Program.cs b/listenarr.api/Program.cs index 5dcfd1d9d..bb15fbbbc 100644 --- a/listenarr.api/Program.cs +++ b/listenarr.api/Program.cs @@ -362,6 +362,7 @@ ex is IOException builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); From 830be673291e3a85105bb6d559369f56209a86f7 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Sat, 13 Jun 2026 22:11:00 -0400 Subject: [PATCH 57/84] refactor: extract prowlarr notifications - Move indexer realtime broadcasts and toast throttling into a focused workflow - Keep Prowlarr compatibility endpoints focused on payload handling and responses --- .../Controllers/ProwlarrCompatController.cs | 138 ++----------- .../ProwlarrIndexerNotificationWorkflow.cs | 182 ++++++++++++++++++ listenarr.api/Program.cs | 1 + 3 files changed, 195 insertions(+), 126 deletions(-) create mode 100644 listenarr.api/Controllers/ProwlarrIndexerNotificationWorkflow.cs diff --git a/listenarr.api/Controllers/ProwlarrCompatController.cs b/listenarr.api/Controllers/ProwlarrCompatController.cs index 958bb47a6..1992a65c2 100644 --- a/listenarr.api/Controllers/ProwlarrCompatController.cs +++ b/listenarr.api/Controllers/ProwlarrCompatController.cs @@ -55,6 +55,7 @@ private StartupConfig GetStartupConfig() private readonly IStartupConfigService _startupConfigService; private readonly IApplicationVersionService _applicationVersionService; private readonly ProwlarrIndexerUpsertWorkflow _indexerUpsertWorkflow; + private readonly ProwlarrIndexerNotificationWorkflow _indexerNotificationWorkflow; // Preserve the existing private reflection seam used by controller tests to reset toast state. private static readonly System.Collections.Concurrent.ConcurrentDictionary _lastToastTimes = ProwlarrToastThrottler.LastToastTimes; @@ -68,7 +69,8 @@ public ProwlarrCompatController( IToastService toastService, IStartupConfigService startupConfigService, IApplicationVersionService applicationVersionService, - ProwlarrIndexerUpsertWorkflow? indexerUpsertWorkflow = null) + ProwlarrIndexerUpsertWorkflow? indexerUpsertWorkflow = null, + ProwlarrIndexerNotificationWorkflow? indexerNotificationWorkflow = null) { _logger = logger; _indexerRepository = indexerRepository; @@ -80,6 +82,10 @@ public ProwlarrCompatController( _indexerUpsertWorkflow = indexerUpsertWorkflow ?? new ProwlarrIndexerUpsertWorkflow( indexerRepository, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + _indexerNotificationWorkflow = indexerNotificationWorkflow ?? new ProwlarrIndexerNotificationWorkflow( + hubBroadcaster, + toastService, + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); } private string GetApplicationVersion() @@ -336,23 +342,7 @@ public async Task DeleteIndexer(int id) { await _indexerRepository.DeleteAsync(id); _logger?.LogInformation("Prowlarr: Deleted indexer {Id} (name={Name})", i.Id, i.Name); - try - { - await _hubBroadcaster.BroadcastAsync(RealtimeHubTarget.Settings, "IndexersUpdated", new { created = 0, skipped = 0, indexers = new[] { new { id = i.Id, name = i.Name, baseUrl = i.Url } } }); - var deleteMessage = $"Removed indexer: {i.Name}"; - if (ProwlarrToastThrottler.ShouldSendForIndexer(i.Id) && ProwlarrToastThrottler.ShouldSendForMessage(deleteMessage)) - { - await _toastService.PublishNotificationAsync("Indexers", deleteMessage, icon: null, timeoutMs: 8000); - } - else - { - _logger?.LogDebug("Suppressing delete toast for indexer {Id} due to recent toast or duplicate message", i.Id); - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger?.LogWarning(ex, "Failed to broadcast IndexersUpdated after delete"); - } + await _indexerNotificationWorkflow.NotifyDeletedAsync(i); } else { @@ -404,60 +394,8 @@ public async Task PutIndexer(int id, [FromBody] System.Text.Json. var created = upsertResult.Created; // Notify clients (compute whether the created indexer still exists after dedupe to avoid duplicate notifications) - try - { - var createdForBroadcast = (created && upsertResult.StillExists) ? 1 : 0; - await _hubBroadcaster.BroadcastAsync(RealtimeHubTarget.Settings, "IndexersUpdated", new { created = createdForBroadcast, skipped = 0, indexers = new[] { new { id = indexer.Id, name = indexer.Name, baseUrl = indexer.Url } } }); - - // Determine toast message. If the indexer was created very recently (by a prior POST or PUT), - // suppress an additional 'Updated' toast to avoid duplicate notifications for rapid import/update flows. - var toastMessage = createdForBroadcast == 1 ? $"Imported indexer from PUT: {indexer.Name}" : $"Updated indexer: {indexer.Name}"; - var publishToast = true; - try - { - if (createdForBroadcast == 0 && indexer.CreatedAt != default && (DateTime.UtcNow - indexer.CreatedAt).TotalSeconds < ProwlarrToastThrottler.NotificationSuppressionSeconds) - { - publishToast = false; - _logger?.LogDebug("Suppressing update toast for indexer {Id} since it was created recently", indexer.Id); - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger?.LogDebug(ex, "Failed to evaluate recent-create toast suppression for Prowlarr indexer {Id}", indexer.Id); - } - - if (publishToast) - { - // Further suppress toasts if a recent toast for this indexer was already sent OR the same message was recently sent globally - bool sendByIndexer = false; - bool sendByMessage = false; - try - { - sendByIndexer = ProwlarrToastThrottler.ShouldSendForIndexer(indexer.Id); - } - catch (Exception caughtEx_4) when (caughtEx_4 is not OperationCanceledException && caughtEx_4 is not OutOfMemoryException && caughtEx_4 is not StackOverflowException) { sendByIndexer = true; } - try - { - sendByMessage = ProwlarrToastThrottler.ShouldSendForMessage(toastMessage); - } - catch (Exception caughtEx_5) when (caughtEx_5 is not OperationCanceledException && caughtEx_5 is not OutOfMemoryException && caughtEx_5 is not StackOverflowException) { sendByMessage = true; } - - _logger?.LogDebug("Toast suppression check for indexer {Id}: byIndexer={ByIndexer}, byMessage={ByMessage}", indexer.Id, sendByIndexer, sendByMessage); - - if (sendByIndexer && sendByMessage) - { - await _toastService.PublishNotificationAsync("Indexers", toastMessage, icon: null, timeoutMs: 8000); - } - else - { - _logger?.LogDebug("Suppressing toast for indexer {Id} due to recent toast or duplicate message", indexer.Id); - } - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger?.LogWarning(ex, "Failed to broadcast IndexersUpdated after update"); - } + var createdForBroadcast = (created && upsertResult.StillExists) ? 1 : 0; + await _indexerNotificationWorkflow.NotifyPutAsync(indexer, createdForBroadcast); // Return updated DTO (consistent with GetIndexerById shape) var dto = new @@ -548,43 +486,7 @@ public async Task PostIndexers([FromBody] System.Text.Json.JsonEl _logger?.LogInformation("Prowlarr: Created indexer (name={Name}, url={Url}, apiKeyPresent={HasApiKey})", createdIndexer.Name, createdIndexer.Url, !string.IsNullOrEmpty(createdIndexer.ApiKey)); } - if (created > 0) - { - // Notify connected clients that indexers changed so the UI can refresh - try - { - var createdInfo = createdIndexers.Select(i => new { id = i.Id, name = i.Name, baseUrl = i.Url }).ToArray(); - - _logger?.LogInformation("Broadcasting IndexersUpdated to clients: created={Created}, skipped={Skipped}, indexerCount={Count}", created, skipped, createdInfo.Length); - - await _hubBroadcaster.BroadcastAsync(RealtimeHubTarget.Settings, "IndexersUpdated", new { created, skipped, indexers = createdInfo }); - - _logger?.LogInformation("IndexersUpdated broadcast complete"); - - // Publish a toast + dropdown notification so the activity bell receives the update - try - { - var names = createdIndexers.Select(i => i.Name).ToArray(); - var message = names.Length > 0 ? $"Imported {created} indexer(s): {string.Join(", ", names)}" : $"Imported {created} indexer(s) successfully"; - if (ProwlarrToastThrottler.ShouldSendForMessage(message)) - { - await _toastService.PublishNotificationAsync("Indexers", message, icon: null, timeoutMs: 8000); - } - else - { - _logger?.LogDebug("Suppressing batch import toast due to recent identical message"); - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger?.LogWarning(ex, "Failed to publish indexer import notification"); - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger?.LogWarning(ex, "Failed to broadcast IndexersUpdated to realtime clients"); - } - } + await _indexerNotificationWorkflow.NotifyImportedAsync(created, skipped, createdIndexers); // Log a summary for diagnostics _logger?.LogInformation("Prowlarr: Indexers processed - created={Created}, skipped={Skipped}", created, skipped); @@ -663,23 +565,7 @@ public async Task DebugPublishIndexers([FromBody] System.Text.Jso indexers.Add(new { id = 999999, name = "Debug Indexer", baseUrl = "http://debug" }); } - _logger?.LogInformation("DEBUG: Broadcasting IndexersUpdated (manual test): created={Created}", created); - - await _hubBroadcaster.BroadcastAsync(RealtimeHubTarget.Settings, "IndexersUpdated", new { created, skipped = 0, indexers }); - - _logger?.LogInformation("DEBUG: IndexersUpdated broadcast sent"); - - // Also publish a toast/notification to show up in the activity dropdown - try - { - var names = indexers.Select(i => i.GetType().GetProperty("name")?.GetValue(i)?.ToString() ?? string.Empty).Where(s => !string.IsNullOrEmpty(s)).ToArray(); - var message = names.Length > 0 ? $"Imported {created} indexer(s): {string.Join(", ", names)}" : $"Imported {created} indexer(s) successfully"; - await _toastService.PublishNotificationAsync("Indexers", message, icon: null, timeoutMs: 8000); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger?.LogWarning(ex, "Failed to publish debug indexer notification"); - } + await _indexerNotificationWorkflow.NotifyDebugIndexersAsync(created, indexers); return Ok(new { sent = true, created, indexers }); } diff --git a/listenarr.api/Controllers/ProwlarrIndexerNotificationWorkflow.cs b/listenarr.api/Controllers/ProwlarrIndexerNotificationWorkflow.cs new file mode 100644 index 000000000..76aa633ea --- /dev/null +++ b/listenarr.api/Controllers/ProwlarrIndexerNotificationWorkflow.cs @@ -0,0 +1,182 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +using Listenarr.Application.Interfaces; +using Listenarr.Domain.Models; + +namespace Listenarr.Api.Controllers +{ + public sealed class ProwlarrIndexerNotificationWorkflow + { + private readonly IHubBroadcaster _hubBroadcaster; + private readonly IToastService _toastService; + private readonly ILogger _logger; + + public ProwlarrIndexerNotificationWorkflow( + IHubBroadcaster hubBroadcaster, + IToastService toastService, + ILogger logger) + { + _hubBroadcaster = hubBroadcaster; + _toastService = toastService; + _logger = logger; + } + + public async Task NotifyDeletedAsync(Indexer indexer) + { + try + { + var deleteMessage = $"Removed indexer: {indexer.Name}"; + await _hubBroadcaster.BroadcastAsync( + RealtimeHubTarget.Settings, + "IndexersUpdated", + new { created = 0, skipped = 0, indexers = new[] { new { id = indexer.Id, name = indexer.Name, baseUrl = indexer.Url } } }); + + if (ProwlarrToastThrottler.ShouldSendForIndexer(indexer.Id) && ProwlarrToastThrottler.ShouldSendForMessage(deleteMessage)) + { + await _toastService.PublishNotificationAsync("Indexers", deleteMessage, icon: null, timeoutMs: 8000); + } + else + { + _logger.LogDebug("Suppressing delete toast for indexer {Id} due to recent toast or duplicate message", indexer.Id); + } + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning(ex, "Failed to broadcast IndexersUpdated after delete"); + } + } + + public async Task NotifyPutAsync(Indexer indexer, int createdForBroadcast) + { + try + { + await _hubBroadcaster.BroadcastAsync( + RealtimeHubTarget.Settings, + "IndexersUpdated", + new { created = createdForBroadcast, skipped = 0, indexers = new[] { new { id = indexer.Id, name = indexer.Name, baseUrl = indexer.Url } } }); + + var toastMessage = createdForBroadcast == 1 ? $"Imported indexer from PUT: {indexer.Name}" : $"Updated indexer: {indexer.Name}"; + var publishToast = true; + try + { + if (createdForBroadcast == 0 && indexer.CreatedAt != default && (DateTime.UtcNow - indexer.CreatedAt).TotalSeconds < ProwlarrToastThrottler.NotificationSuppressionSeconds) + { + publishToast = false; + _logger.LogDebug("Suppressing update toast for indexer {Id} since it was created recently", indexer.Id); + } + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogDebug(ex, "Failed to evaluate recent-create toast suppression for Prowlarr indexer {Id}", indexer.Id); + } + + if (!publishToast) + { + return; + } + + bool sendByIndexer; + bool sendByMessage; + try + { + sendByIndexer = ProwlarrToastThrottler.ShouldSendForIndexer(indexer.Id); + } + catch (Exception caughtEx) when (caughtEx is not OperationCanceledException && caughtEx is not OutOfMemoryException && caughtEx is not StackOverflowException) + { + sendByIndexer = true; + } + + try + { + sendByMessage = ProwlarrToastThrottler.ShouldSendForMessage(toastMessage); + } + catch (Exception caughtEx) when (caughtEx is not OperationCanceledException && caughtEx is not OutOfMemoryException && caughtEx is not StackOverflowException) + { + sendByMessage = true; + } + + _logger.LogDebug("Toast suppression check for indexer {Id}: byIndexer={ByIndexer}, byMessage={ByMessage}", indexer.Id, sendByIndexer, sendByMessage); + + if (sendByIndexer && sendByMessage) + { + await _toastService.PublishNotificationAsync("Indexers", toastMessage, icon: null, timeoutMs: 8000); + } + else + { + _logger.LogDebug("Suppressing toast for indexer {Id} due to recent toast or duplicate message", indexer.Id); + } + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning(ex, "Failed to broadcast IndexersUpdated after update"); + } + } + + public async Task NotifyImportedAsync(int created, int skipped, IReadOnlyCollection createdIndexers) + { + if (created <= 0) + { + return; + } + + try + { + var createdInfo = createdIndexers.Select(i => new { id = i.Id, name = i.Name, baseUrl = i.Url }).ToArray(); + + _logger.LogInformation("Broadcasting IndexersUpdated to clients: created={Created}, skipped={Skipped}, indexerCount={Count}", created, skipped, createdInfo.Length); + + await _hubBroadcaster.BroadcastAsync(RealtimeHubTarget.Settings, "IndexersUpdated", new { created, skipped, indexers = createdInfo }); + + _logger.LogInformation("IndexersUpdated broadcast complete"); + + try + { + var names = createdIndexers.Select(i => i.Name).ToArray(); + var message = names.Length > 0 ? $"Imported {created} indexer(s): {string.Join(", ", names)}" : $"Imported {created} indexer(s) successfully"; + if (ProwlarrToastThrottler.ShouldSendForMessage(message)) + { + await _toastService.PublishNotificationAsync("Indexers", message, icon: null, timeoutMs: 8000); + } + else + { + _logger.LogDebug("Suppressing batch import toast due to recent identical message"); + } + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning(ex, "Failed to publish indexer import notification"); + } + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning(ex, "Failed to broadcast IndexersUpdated to realtime clients"); + } + } + + public async Task NotifyDebugIndexersAsync(int created, IEnumerable indexers) + { + _logger.LogInformation("DEBUG: Broadcasting IndexersUpdated (manual test): created={Created}", created); + await _hubBroadcaster.BroadcastAsync(RealtimeHubTarget.Settings, "IndexersUpdated", new { created, skipped = 0, indexers }); + _logger.LogInformation("DEBUG: IndexersUpdated broadcast sent"); + + try + { + var names = indexers.Select(i => i.GetType().GetProperty("name")?.GetValue(i)?.ToString() ?? string.Empty).Where(s => !string.IsNullOrWhiteSpace(s)).ToArray(); + var message = names.Length > 0 ? $"Imported {created} indexer(s): {string.Join(", ", names)}" : $"Imported {created} indexer(s) successfully"; + await _toastService.PublishNotificationAsync("Indexers", message, icon: null, timeoutMs: 8000); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning(ex, "Failed to publish debug indexer notification"); + } + } + } +} diff --git a/listenarr.api/Program.cs b/listenarr.api/Program.cs index bb15fbbbc..9b6f22b64 100644 --- a/listenarr.api/Program.cs +++ b/listenarr.api/Program.cs @@ -361,6 +361,7 @@ ex is IOException builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); From 7bb2981dce3b34e89b773c4261834551fb8b035c Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Sat, 13 Jun 2026 22:13:49 -0400 Subject: [PATCH 58/84] refactor: extract direct download workflow - Move DDL tracking record creation out of DownloadService - Register the workflow through existing application service DI --- .../Downloads/DirectDownloadWorkflow.cs | 59 +++++++++++++++++++ .../Downloads/DownloadService.cs | 39 +----------- .../AppServiceRegistrationExtensions.cs | 1 + 3 files changed, 63 insertions(+), 36 deletions(-) create mode 100644 listenarr.application/Downloads/DirectDownloadWorkflow.cs diff --git a/listenarr.application/Downloads/DirectDownloadWorkflow.cs b/listenarr.application/Downloads/DirectDownloadWorkflow.cs new file mode 100644 index 000000000..c32872410 --- /dev/null +++ b/listenarr.application/Downloads/DirectDownloadWorkflow.cs @@ -0,0 +1,59 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +using Listenarr.Application.Interfaces.Repositories; +using Listenarr.Domain.Models; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Application.Downloads +{ + public sealed class DirectDownloadWorkflow( + IDownloadRepository downloadRepository, + ILogger logger) + { + public async Task CreateTrackedDownloadAsync(SearchResult searchResult, int? audiobookId) + { + try + { + var id = Guid.NewGuid().ToString(); + var download = new Download + { + Id = id, + AudiobookId = audiobookId, + Title = searchResult.Title, + Language = searchResult.Language, + OriginalUrl = searchResult.TorrentUrl ?? searchResult.NzbUrl ?? searchResult.MagnetLink ?? string.Empty, + Progress = 0, + TotalSize = searchResult.Size, + DownloadedSize = 0, + DownloadPath = string.Empty, + FinalPath = string.Empty, + StartedAt = DateTime.UtcNow, + DownloadClientId = "DDL", + Metadata = new Dictionary + { + ["Source"] = searchResult.Source ?? string.Empty, + ["Quality"] = searchResult.Quality ?? string.Empty, + ["Language"] = searchResult.Language ?? string.Empty, + ["DownloadType"] = "DDL" + } + }; + + await downloadRepository.AddAsync(download); + return id; + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + logger.LogWarning(ex, "DownloadDirectlyAsync: failed to create DDL download record"); + return Guid.NewGuid().ToString(); + } + } + } +} diff --git a/listenarr.application/Downloads/DownloadService.cs b/listenarr.application/Downloads/DownloadService.cs index c65d36762..200cc8a72 100644 --- a/listenarr.application/Downloads/DownloadService.cs +++ b/listenarr.application/Downloads/DownloadService.cs @@ -41,7 +41,8 @@ public class DownloadService( IDownloadHistoryService downloadHistoryService, DownloadTypeResolver downloadTypeResolver, DownloadClientSelector downloadClientSelector, - DownloadCachedTorrentStore cachedTorrentStore) : IDownloadService + DownloadCachedTorrentStore cachedTorrentStore, + DirectDownloadWorkflow directDownloadWorkflow) : IDownloadService { // Cache expiration constants private const int QueueCacheExpirationSeconds = 10; @@ -611,41 +612,7 @@ public async Task RemoveFromQueueAsync(string downloadId, string? download private async Task DownloadDirectlyAsync(SearchResult searchResult, int? audiobookId) { - // Create a Download record in the database so it's tracked like other downloads. - try - { - var id = Guid.NewGuid().ToString(); - var download = new Download - { - Id = id, - AudiobookId = audiobookId, - Title = searchResult.Title, - Language = searchResult.Language, - OriginalUrl = searchResult.TorrentUrl ?? searchResult.NzbUrl ?? searchResult.MagnetLink ?? string.Empty, - Progress = 0, - TotalSize = searchResult.Size, - DownloadedSize = 0, - DownloadPath = string.Empty, - FinalPath = string.Empty, - StartedAt = DateTime.UtcNow, - DownloadClientId = "DDL", - Metadata = new Dictionary - { - ["Source"] = searchResult.Source ?? string.Empty, - ["Quality"] = searchResult.Quality ?? string.Empty, - ["Language"] = searchResult.Language ?? string.Empty, - ["DownloadType"] = "DDL" - } - }; - - await downloadRepository.AddAsync(download); - return id; - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - logger.LogWarning(ex, "DownloadDirectlyAsync: failed to create DDL download record"); - return Guid.NewGuid().ToString(); - } + return await directDownloadWorkflow.CreateTrackedDownloadAsync(searchResult, audiobookId); } private async Task LogDownloadHistory(Audiobook audiobook, string source, SearchResult result) diff --git a/listenarr.infrastructure/Extensions/AppServiceRegistrationExtensions.cs b/listenarr.infrastructure/Extensions/AppServiceRegistrationExtensions.cs index 068dd8b4a..41f1192e0 100644 --- a/listenarr.infrastructure/Extensions/AppServiceRegistrationExtensions.cs +++ b/listenarr.infrastructure/Extensions/AppServiceRegistrationExtensions.cs @@ -94,6 +94,7 @@ public static IServiceCollection AddListenarrAppServices(this IServiceCollection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); // Queue service extracted from DownloadService to encapsulate queue-building and filtering services.AddScoped(); services.AddScoped(); From 9749315b5ac75d1641477c85d697e19b5c1bd41c Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Sat, 13 Jun 2026 22:18:32 -0400 Subject: [PATCH 59/84] refactor: extract image candidate lookup - Move metadata-driven image candidate discovery into a focused workflow - Keep ImagesController responsible for request validation and image responses --- .../ImageCandidateLookupWorkflow.cs | 628 ++++++++++++++++++ listenarr.api/Controllers/ImagesController.cs | 584 +--------------- 2 files changed, 639 insertions(+), 573 deletions(-) create mode 100644 listenarr.api/Controllers/ImageCandidateLookupWorkflow.cs diff --git a/listenarr.api/Controllers/ImageCandidateLookupWorkflow.cs b/listenarr.api/Controllers/ImageCandidateLookupWorkflow.cs new file mode 100644 index 000000000..48e6fcc04 --- /dev/null +++ b/listenarr.api/Controllers/ImageCandidateLookupWorkflow.cs @@ -0,0 +1,628 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +using Listenarr.Application.Interfaces; +using Listenarr.Application.Interfaces.Repositories; +using Listenarr.Application.Metadata; +using Listenarr.Application.Security; +using Listenarr.Domain.Models; + +namespace Listenarr.Api.Controllers +{ + internal sealed class ImageCandidateLookupWorkflow + { + private readonly IImageCacheService _imageCacheService; + private readonly IAudiobookMetadataService _audiobookMetadataService; + private readonly AudibleService _audibleService; + private readonly IAudnexusService _audnexusService; + private readonly IAudiobookRepository _audiobookRepository; + private readonly IOpenLibraryService? _openLibraryService; + private readonly ImageFallbackDownloadWorkflow _fallbackDownloadWorkflow; + private readonly ILogger _logger; + + public ImageCandidateLookupWorkflow( + IImageCacheService imageCacheService, + IAudiobookMetadataService audiobookMetadataService, + AudibleService audibleService, + IAudnexusService audnexusService, + IAudiobookRepository audiobookRepository, + IOpenLibraryService? openLibraryService, + ImageFallbackDownloadWorkflow fallbackDownloadWorkflow, + ILogger logger) + { + _imageCacheService = imageCacheService; + _audiobookMetadataService = audiobookMetadataService; + _audibleService = audibleService; + _audnexusService = audnexusService; + _audiobookRepository = audiobookRepository; + _openLibraryService = openLibraryService; + _fallbackDownloadWorkflow = fallbackDownloadWorkflow; + _logger = logger; + } + + public async Task TryResolveAsync(string identifier, string? relativePath, string? requestedRegion) + { + try + { + var region = requestedRegion ?? string.Empty; + if (string.IsNullOrWhiteSpace(region)) region = "us"; + + string? candidateUrl = null; + string? candidateIsbn = null; + string? localOpenLibraryId = null; + string? localTitle = null; + string? localAuthor = null; + var localIsbnCandidates = new List(); + var localOpenLibraryIds = new List(); + var localAsinCandidates = new List(); + var candidateUrls = new List(); + var candidateUrlSet = new HashSet(StringComparer.OrdinalIgnoreCase); + + void AddCandidateUrl(string? url, string source) + { + var normalized = ImageIdentifierHelper.NormalizeHttpImageUrl(url); + if (string.IsNullOrWhiteSpace(normalized)) return; + if (candidateUrlSet.Add(normalized)) + { + candidateUrls.Add(normalized); + _logger.LogDebug("Queued image candidate for {Identifier} from {Source}: {Url}", LogRedaction.SanitizeText(identifier), LogRedaction.SanitizeText(source), LogRedaction.SanitizeText(normalized)); + } + if (string.IsNullOrWhiteSpace(candidateUrl)) + { + candidateUrl = normalized; + } + } + + // Seed OpenLibrary fallback inputs from the local library record when + // this identifier is an ASIN. This helps when provider metadata is + // missing/stale but the book already has ISBN/OLID persisted. + try + { + if (ImageIdentifierHelper.LooksLikeAsin(identifier)) + { + var localBook = await _audiobookRepository.GetByAsinAsync(identifier); + if (localBook != null) + { + localTitle = localBook.Title; + localAuthor = localBook.Authors?.FirstOrDefault(a => !string.IsNullOrWhiteSpace(a)); + + // Collect identifiers from the new typed identifier model first. + foreach (var extId in (localBook.ExternalIdentifiers ?? Enumerable.Empty()) + .Where(extId => !string.IsNullOrWhiteSpace(extId.ValueNormalized))) + { + switch (extId.Type) + { + case AudiobookExternalIdentifierType.Asin: + if (ImageIdentifierHelper.LooksLikeAsin(extId.ValueNormalized) && + !localAsinCandidates.Contains(extId.ValueNormalized, StringComparer.OrdinalIgnoreCase)) + { + localAsinCandidates.Add(extId.ValueNormalized); + } + break; + case AudiobookExternalIdentifierType.Isbn: + if (ImageIdentifierHelper.LooksLikeIsbn(extId.ValueNormalized) && + !localIsbnCandidates.Contains(extId.ValueNormalized, StringComparer.OrdinalIgnoreCase)) + { + localIsbnCandidates.Add(extId.ValueNormalized); + } + break; + case AudiobookExternalIdentifierType.OpenLibraryId: + { + var normalizedOlid = ImageIdentifierHelper.NormalizeOpenLibraryId(extId.ValueNormalized); + if (!string.IsNullOrWhiteSpace(normalizedOlid) && + !localOpenLibraryIds.Contains(normalizedOlid, StringComparer.OrdinalIgnoreCase)) + { + localOpenLibraryIds.Add(normalizedOlid); + } + } + break; + } + } + + var localIsbn = localBook.Isbn? + .Select(ImageIdentifierHelper.NormalizeIsbn) + .FirstOrDefault(v => !string.IsNullOrWhiteSpace(v) && ImageIdentifierHelper.LooksLikeIsbn(v)); + if (!string.IsNullOrWhiteSpace(localIsbn)) + { + if (!localIsbnCandidates.Contains(localIsbn, StringComparer.OrdinalIgnoreCase)) + { + localIsbnCandidates.Add(localIsbn); + } + candidateIsbn ??= localIsbn; + _logger.LogDebug("Seeded candidate ISBN {Isbn} from local library record for {Identifier}", LogRedaction.SanitizeText(candidateIsbn), LogRedaction.SanitizeText(identifier)); + } + + if (!string.IsNullOrWhiteSpace(localBook.OpenLibraryId)) + { + var normalizedLocalOlid = ImageIdentifierHelper.NormalizeOpenLibraryId(localBook.OpenLibraryId); + if (!string.IsNullOrWhiteSpace(normalizedLocalOlid)) + { + if (!localOpenLibraryIds.Contains(normalizedLocalOlid, StringComparer.OrdinalIgnoreCase)) + { + localOpenLibraryIds.Add(normalizedLocalOlid); + } + localOpenLibraryId ??= normalizedLocalOlid; + } + } + + if (ImageIdentifierHelper.LooksLikeAsin(localBook.Asin ?? string.Empty)) + { + var normalizedLocalAsin = (localBook.Asin ?? string.Empty).Trim().ToUpperInvariant(); + if (!localAsinCandidates.Contains(normalizedLocalAsin, StringComparer.OrdinalIgnoreCase)) + { + localAsinCandidates.Add(normalizedLocalAsin); + } + } + } + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) when (ImageIdentifierHelper.IsRecoverableImageLookupException(ex)) + { + _logger.LogDebug(ex, "Failed to seed image fallback metadata from local library record for {Identifier}", LogRedaction.SanitizeText(identifier)); + } + + // If the requested identifier key has no cached image, reuse an existing + // cached image from any alternate stored identifier (e.g., old primary ASIN). + if (string.IsNullOrWhiteSpace(relativePath)) + { + var cacheAliasCandidates = localAsinCandidates + .Concat(localIsbnCandidates) + .Concat(localOpenLibraryIds) + .Where(v => !string.IsNullOrWhiteSpace(v) && !string.Equals(v, identifier, StringComparison.OrdinalIgnoreCase)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + foreach (var aliasIdentifier in cacheAliasCandidates) + { + try + { + var aliasPath = await _imageCacheService.GetCachedImagePathAsync(aliasIdentifier); + if (!string.IsNullOrWhiteSpace(aliasPath)) + { + relativePath = aliasPath; + _logger.LogInformation( + "Reused cached image for identifier {Identifier} via alternate identifier {AliasIdentifier}: {Path}", + LogRedaction.SanitizeText(identifier), + LogRedaction.SanitizeText(aliasIdentifier), + LogRedaction.SanitizeText(relativePath)); + break; + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) when (ImageIdentifierHelper.IsRecoverableImageLookupException(ex)) + { + _logger.LogDebug(ex, "Failed probing alternate cached image identifier {AliasIdentifier} for {Identifier}", LogRedaction.SanitizeText(aliasIdentifier), LogRedaction.SanitizeText(identifier)); + } + } + } + + if (string.IsNullOrWhiteSpace(relativePath)) + { + var audible = await _audiobookMetadataService.GetAudibleMetadataAsync(identifier, region, cache: true); + + if (audible != null) + { + AddCandidateUrl(audible.ImageUrl, "Audible"); + if (!string.IsNullOrWhiteSpace(audible.Isbn)) + { + candidateIsbn = ImageIdentifierHelper.NormalizeIsbn(audible.Isbn); + } + } + + // Try Audnexus for ASINs as an additional candidate source even when + // Audible returned an image (Audible images can be placeholders or stale). + if (ImageIdentifierHelper.LooksLikeAsin(identifier)) + { + try + { + var audnexus = await _audnexusService.GetBookMetadataAsync(identifier, region, seedAuthors: true, update: false); + if (audnexus != null) + { + AddCandidateUrl(audnexus.Image, "AudnexusBook"); + if (string.IsNullOrWhiteSpace(candidateIsbn) && !string.IsNullOrWhiteSpace(audnexus.Isbn)) + { + candidateIsbn = ImageIdentifierHelper.NormalizeIsbn(audnexus.Isbn); + } + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) when (ImageIdentifierHelper.IsRecoverableImageLookupException(ex)) + { + _logger.LogDebug(ex, "Audnexus ASIN lookup failed for {Identifier}", LogRedaction.SanitizeText(identifier)); + } + } + + // Try alternate stored ASIN identifiers for this audiobook when the requested + // ASIN is region-limited or missing from providers. + if (ImageIdentifierHelper.LooksLikeAsin(identifier) && localAsinCandidates.Count > 0) + { + foreach (var altAsin in localAsinCandidates + .Where(a => !string.Equals(a, identifier, StringComparison.OrdinalIgnoreCase)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Take(3)) + { + try + { + var altAudible = await _audiobookMetadataService.GetAudibleMetadataAsync(altAsin, region, cache: true); + if (altAudible != null) + { + AddCandidateUrl(altAudible.ImageUrl, "AudibleAltAsin"); + if (string.IsNullOrWhiteSpace(candidateIsbn) && !string.IsNullOrWhiteSpace(altAudible.Isbn)) + { + candidateIsbn = ImageIdentifierHelper.NormalizeIsbn(altAudible.Isbn); + } + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) when (ImageIdentifierHelper.IsRecoverableImageLookupException(ex)) + { + _logger.LogDebug(ex, "Audible alternate ASIN lookup failed for {Identifier} via {AltAsin}", LogRedaction.SanitizeText(identifier), LogRedaction.SanitizeText(altAsin)); + } + + try + { + var altAudnexus = await _audnexusService.GetBookMetadataAsync(altAsin, region, seedAuthors: true, update: false); + if (altAudnexus != null) + { + AddCandidateUrl(altAudnexus.Image, "AudnexusBookAltAsin"); + if (string.IsNullOrWhiteSpace(candidateIsbn) && !string.IsNullOrWhiteSpace(altAudnexus.Isbn)) + { + candidateIsbn = ImageIdentifierHelper.NormalizeIsbn(altAudnexus.Isbn); + } + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) when (ImageIdentifierHelper.IsRecoverableImageLookupException(ex)) + { + _logger.LogDebug(ex, "Audnexus alternate ASIN lookup failed for {Identifier} via {AltAsin}", LogRedaction.SanitizeText(identifier), LogRedaction.SanitizeText(altAsin)); + } + } + } + + // Build an OpenLibrary ISBN candidate when we have an ISBN (identifier or metadata/local record). + if (string.IsNullOrWhiteSpace(candidateIsbn) && ImageIdentifierHelper.LooksLikeIsbn(identifier)) + { + candidateIsbn = ImageIdentifierHelper.NormalizeIsbn(identifier); + } + if (!string.IsNullOrWhiteSpace(candidateIsbn)) + { + var olIsbnCandidate = $"https://covers.openlibrary.org/b/isbn/{Uri.EscapeDataString(candidateIsbn)}-L.jpg"; + AddCandidateUrl(olIsbnCandidate, "OpenLibraryIsbn"); + if (candidateUrls.Count == 1) + { + _logger.LogInformation("Using OpenLibrary ISBN cover candidate for {Identifier}: ISBN={Isbn}", LogRedaction.SanitizeText(identifier), LogRedaction.SanitizeText(candidateIsbn)); + } + } + + foreach (var localIsbnCandidate in localIsbnCandidates) + { + AddCandidateUrl( + $"https://covers.openlibrary.org/b/isbn/{Uri.EscapeDataString(localIsbnCandidate)}-L.jpg", + "OpenLibraryIsbnLocalIdentifier"); + } + + // Legacy fallback path through configured source envelope for compatibility. + if (string.IsNullOrWhiteSpace(candidateUrl) || string.IsNullOrWhiteSpace(candidateIsbn)) + { + _logger.LogDebug("No image found in audible, attempting fallback GetMetadataAsync for {Identifier}", LogRedaction.SanitizeText(identifier)); + try + { + var metadataEnvelope = await _audiobookMetadataService.GetMetadataAsync(identifier, region, cache: true); + if (metadataEnvelope != null) + { + try + { + // If the service returned an AudibleBookResponse directly + if (metadataEnvelope is AudibleBookResponse directMeta) + { + AddCandidateUrl(directMeta.ImageUrl, "MetadataEnvelopeDirect"); + } + else + { + // Try dynamic access + dynamic env = metadataEnvelope; + object? mdObj = env.metadata; + + // If it's already the Audible type, use it + if (mdObj is AudibleBookResponse mdMeta) + { + AddCandidateUrl(mdMeta.ImageUrl, "MetadataEnvelopeAudible"); + } + else if (mdObj != null) + { + // Try reflection for common property names + var t = mdObj.GetType(); + var prop = t.GetProperty("ImageUrl") ?? t.GetProperty("Image") ?? t.GetProperty("image") ?? t.GetProperty("imageUrl"); + if (prop != null) + { + var v = prop.GetValue(mdObj)?.ToString(); + AddCandidateUrl(v, "MetadataEnvelopeReflection"); + } + + if (string.IsNullOrWhiteSpace(candidateIsbn)) + { + var isbnProp = t.GetProperty("Isbn") ?? t.GetProperty("ISBN") ?? t.GetProperty("isbn"); + var isbnVal = isbnProp?.GetValue(mdObj)?.ToString(); + if (!string.IsNullOrWhiteSpace(isbnVal)) + { + candidateIsbn = ImageIdentifierHelper.NormalizeIsbn(isbnVal); + } + } + } + } + + if (!string.IsNullOrWhiteSpace(candidateUrl)) + { + _logger.LogInformation("Found image URL in fallback metadata source for identifier {Identifier}: {Url}", LogRedaction.SanitizeText(identifier), LogRedaction.SanitizeText(candidateUrl)); + } + else + { + _logger.LogDebug("Fallback metadata returned no image URL for {Identifier}", LogRedaction.SanitizeText(identifier)); + } + } + catch (Exception ex) when (ImageIdentifierHelper.IsRecoverableImageLookupException(ex)) + { + _logger.LogDebug(ex, "Failed to parse fallback metadata envelope for {Identifier}", LogRedaction.SanitizeText(identifier)); + } + } + else + { + _logger.LogDebug("GetMetadataAsync returned null for {Identifier}", LogRedaction.SanitizeText(identifier)); + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) when (ImageIdentifierHelper.IsRecoverableImageLookupException(ex)) + { + _logger.LogDebug(ex, "Fallback metadata lookup failed for {Identifier}", LogRedaction.SanitizeText(identifier)); + } + } + + // If metadata envelope yielded ISBN, queue OpenLibrary cover as a fallback candidate. + if (!string.IsNullOrWhiteSpace(candidateIsbn)) + { + AddCandidateUrl($"https://covers.openlibrary.org/b/isbn/{Uri.EscapeDataString(candidateIsbn)}-L.jpg", "OpenLibraryIsbnPostMetadata"); + } + + // Final OpenLibrary fallback via persisted OLID (if available and ISBN path + // wasn't usable). + if (!string.IsNullOrWhiteSpace(localOpenLibraryId)) + { + AddCandidateUrl($"https://covers.openlibrary.org/b/olid/{Uri.EscapeDataString(localOpenLibraryId)}-L.jpg", "OpenLibraryOlid"); + } + foreach (var localOlid in localOpenLibraryIds) + { + AddCandidateUrl($"https://covers.openlibrary.org/b/olid/{Uri.EscapeDataString(localOlid)}-L.jpg", "OpenLibraryOlidLocalIdentifier"); + } + + // Final ISBN discovery fallback for ASIN requests: use local title/author to + // search OpenLibrary when providers/local metadata do not include ISBN/OLID. + if (string.IsNullOrWhiteSpace(candidateIsbn) && + _openLibraryService != null && + ImageIdentifierHelper.LooksLikeAsin(identifier) && + !string.IsNullOrWhiteSpace(localTitle)) + { + try + { + var titleIsbns = await _openLibraryService.GetIsbnsForTitleAsync(localTitle!, localAuthor); + var normalizedTitleIsbns = titleIsbns + .Select(ImageIdentifierHelper.NormalizeIsbn) + .Where(v => !string.IsNullOrWhiteSpace(v) && ImageIdentifierHelper.LooksLikeIsbn(v)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Take(5) + .ToList(); + + if (normalizedTitleIsbns.Count > 0) + { + _logger.LogInformation( + "Derived {Count} OpenLibrary ISBN candidate(s) from local title/author for {Identifier}: Title={Title}, Author={Author}", + normalizedTitleIsbns.Count, + LogRedaction.SanitizeText(identifier), + LogRedaction.SanitizeText(localTitle), + LogRedaction.SanitizeText(localAuthor)); + + foreach (var titleIsbn in normalizedTitleIsbns) + { + AddCandidateUrl( + $"https://covers.openlibrary.org/b/isbn/{Uri.EscapeDataString(titleIsbn)}-L.jpg", + "OpenLibraryTitleAuthorSearch"); + } + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) when (ImageIdentifierHelper.IsRecoverableImageLookupException(ex)) + { + _logger.LogDebug(ex, "OpenLibrary title/author ISBN fallback failed for {Identifier}", LogRedaction.SanitizeText(identifier)); + } + } + + // If no image found from book metadata, attempt author lookups (treating identifier as author name/asin) + if (string.IsNullOrWhiteSpace(candidateUrl)) + { + try + { + // First: try to find a stored author ASIN in the DB and serve its cached image if available + try + { + if (!string.IsNullOrWhiteSpace(identifier)) + { + var authorAsin = await _audiobookRepository.GetAuthorAsinByNameAsync(identifier); + if (!string.IsNullOrWhiteSpace(authorAsin)) + { + var diskPath = await _imageCacheService.GetCachedImagePathAsync(authorAsin); + if (!string.IsNullOrWhiteSpace(diskPath)) + { + // Use cached author image by ASIN (prefer authors storage path) + relativePath = "/" + diskPath.TrimStart('/'); + _logger.LogInformation("Found cached author image for identifier {Identifier} via stored ASIN {Asin}: {Path}", LogRedaction.SanitizeText(identifier), LogRedaction.SanitizeText(authorAsin), LogRedaction.SanitizeText(relativePath)); + } + } + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) when (ImageIdentifierHelper.IsRecoverableImageLookupException(ex)) + { + _logger.LogDebug(ex, "Failed to lookup stored author ASIN for identifier {Identifier}", LogRedaction.SanitizeText(identifier)); + } + + // If we didn't find a cached author image via stored ASIN, fallback to Audible lookup by name + if (string.IsNullOrWhiteSpace(relativePath)) + { + var authorLookup = await _audibleService.LookupAuthorAsync(identifier, region); + if (authorLookup != null && !string.IsNullOrWhiteSpace(authorLookup.Image) && (authorLookup.Image.StartsWith("http://") || authorLookup.Image.StartsWith("https://"))) + { + AddCandidateUrl(authorLookup.Image, "AudibleAuthor"); + _logger.LogInformation("Found author image from Audible for identifier {Identifier}: {Url}", LogRedaction.SanitizeText(identifier), LogRedaction.SanitizeText(candidateUrl)); + } + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) when (ImageIdentifierHelper.IsRecoverableImageLookupException(ex)) + { + _logger.LogDebug(ex, "Audible author lookup failed for {Identifier}", LogRedaction.SanitizeText(identifier)); + } + + // 2) Audnexus author search fallback + if (string.IsNullOrWhiteSpace(candidateUrl)) + { + try + { + // If identifier looks like an ASIN, prefer GetAuthorAsync to fetch the author directly + if (identifier != null && identifier.Length >= 10 && (identifier.StartsWith("B", StringComparison.OrdinalIgnoreCase) || identifier.All(char.IsLetterOrDigit))) + { + try + { + var authorResp = await _audnexusService.GetAuthorAsync(identifier, region, update: false); + if (authorResp != null && !string.IsNullOrWhiteSpace(authorResp.Image) && (authorResp.Image.StartsWith("http://") || authorResp.Image.StartsWith("https://"))) + { + AddCandidateUrl(authorResp.Image, "AudnexusAuthorByAsin"); + _logger.LogInformation("Found author image from Audnexus (by ASIN) for identifier {Identifier}: {Url}", LogRedaction.SanitizeText(identifier), LogRedaction.SanitizeText(candidateUrl)); + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) when (ImageIdentifierHelper.IsRecoverableImageLookupException(ex)) + { + _logger.LogDebug(ex, "Audnexus GetAuthorAsync failed for ASIN {Identifier}", LogRedaction.SanitizeText(identifier)); + } + } + + // If still not found, fallback to searching by name + if (string.IsNullOrWhiteSpace(candidateUrl)) + { + // Try to find stored author ASIN in database (match by author name) and prefer direct GET + try + { + if (!string.IsNullOrWhiteSpace(identifier)) + { + var authorAsin = await _audiobookRepository.GetAuthorAsinByNameAsync(identifier); + if (!string.IsNullOrWhiteSpace(authorAsin)) + { + try + { + var authorResp = await _audnexusService.GetAuthorAsync(authorAsin, region, update: false); + if (authorResp != null && !string.IsNullOrWhiteSpace(authorResp.Image) && (authorResp.Image.StartsWith("http://") || authorResp.Image.StartsWith("https://"))) + { + AddCandidateUrl(authorResp.Image, "AudnexusAuthorByStoredAsin"); + _logger.LogInformation("Found author image from Audnexus by stored ASIN {Asin} for identifier {Identifier}: {Url}", LogRedaction.SanitizeText(authorAsin), LogRedaction.SanitizeText(identifier), LogRedaction.SanitizeText(candidateUrl)); + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) when (ImageIdentifierHelper.IsRecoverableImageLookupException(ex)) + { + _logger.LogDebug(ex, "Audnexus GetAuthorAsync failed for ASIN {Asin}", LogRedaction.SanitizeText(authorAsin)); + } + } + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) when (ImageIdentifierHelper.IsRecoverableImageLookupException(ex)) + { + _logger.LogDebug(ex, "Failed to lookup author ASINs in database for identifier {Identifier}", LogRedaction.SanitizeText(identifier)); + } + + // If still not found, fallback to searching by name + if (string.IsNullOrWhiteSpace(candidateUrl)) + { + var authors = await _audnexusService.SearchAuthorsAsync(identifier!, region); + var first = authors?.FirstOrDefault(a => !string.IsNullOrWhiteSpace(a.Image)); + if (first != null && !string.IsNullOrWhiteSpace(first.Image) && (first.Image.StartsWith("http://") || first.Image.StartsWith("https://"))) + { + AddCandidateUrl(first.Image, "AudnexusAuthorSearch"); + _logger.LogInformation("Found author image from Audnexus (search) for identifier {Identifier}: {Url}", LogRedaction.SanitizeText(identifier), LogRedaction.SanitizeText(candidateUrl)); + } + } + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) when (ImageIdentifierHelper.IsRecoverableImageLookupException(ex)) + { + _logger.LogDebug(ex, "Audnexus author search failed for {Identifier}", LogRedaction.SanitizeText(identifier)); + } + } + } + + if (candidateUrls.Count > 0) + { + relativePath = await _fallbackDownloadWorkflow.TryDownloadFirstCachedAsync(identifier!, candidateUrls); + } + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) when (ImageIdentifierHelper.IsRecoverableImageLookupException(ex)) + { + _logger.LogDebug(ex, "Metadata-driven image download failed for {Identifier}", LogRedaction.SanitizeText(identifier)); + } + + return relativePath; + } + } +} diff --git a/listenarr.api/Controllers/ImagesController.cs b/listenarr.api/Controllers/ImagesController.cs index b72b0f9fd..0909edbb4 100644 --- a/listenarr.api/Controllers/ImagesController.cs +++ b/listenarr.api/Controllers/ImagesController.cs @@ -20,7 +20,6 @@ using Listenarr.Application.Interfaces.Repositories; using Listenarr.Application.Metadata; using Listenarr.Application.Security; -using Listenarr.Domain.Models; using Microsoft.AspNetCore.Mvc; namespace Listenarr.Api.Controllers @@ -43,6 +42,7 @@ public class ImagesController : ControllerBase private readonly ImagePathValidator _imagePathValidator; private readonly ImageCachedPathValidator _cachedPathValidator; private readonly ImageFallbackDownloadWorkflow _fallbackDownloadWorkflow; + private readonly ImageCandidateLookupWorkflow _imageCandidateLookupWorkflow; private readonly string _effectiveContentRootPath; [ActivatorUtilitiesConstructor] @@ -92,6 +92,15 @@ public ImagesController( _imagePathValidator = new ImagePathValidator(_effectiveContentRootPath); _cachedPathValidator = new ImageCachedPathValidator(_imagePathValidator, _logger); _fallbackDownloadWorkflow = new ImageFallbackDownloadWorkflow(_imageCacheService, _logger); + _imageCandidateLookupWorkflow = new ImageCandidateLookupWorkflow( + _imageCacheService, + _audiobookMetadataService, + _audibleService, + _audnexusService, + _audiobookRepository, + _openLibraryService, + _fallbackDownloadWorkflow, + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); } /// @@ -220,578 +229,7 @@ public async Task GetImage(string identifier) // Cache is missing and caller did not provide a URL. Try metadata providers: // ASIN => Audible, then Audnexus; ISBN => OpenLibrary cover URL. - try - { - var region = Request.Query["region"].ToString(); - if (string.IsNullOrWhiteSpace(region)) region = "us"; - - string? candidateUrl = null; - string? candidateIsbn = null; - string? localOpenLibraryId = null; - string? localTitle = null; - string? localAuthor = null; - var localIsbnCandidates = new List(); - var localOpenLibraryIds = new List(); - var localAsinCandidates = new List(); - var candidateUrls = new List(); - var candidateUrlSet = new HashSet(StringComparer.OrdinalIgnoreCase); - - void AddCandidateUrl(string? url, string source) - { - var normalized = ImageIdentifierHelper.NormalizeHttpImageUrl(url); - if (string.IsNullOrWhiteSpace(normalized)) return; - if (candidateUrlSet.Add(normalized)) - { - candidateUrls.Add(normalized); - _logger.LogDebug("Queued image candidate for {Identifier} from {Source}: {Url}", LogRedaction.SanitizeText(identifier), LogRedaction.SanitizeText(source), LogRedaction.SanitizeText(normalized)); - } - if (string.IsNullOrWhiteSpace(candidateUrl)) - { - candidateUrl = normalized; - } - } - - // Seed OpenLibrary fallback inputs from the local library record when - // this identifier is an ASIN. This helps when provider metadata is - // missing/stale but the book already has ISBN/OLID persisted. - try - { - if (ImageIdentifierHelper.LooksLikeAsin(identifier)) - { - var localBook = await _audiobookRepository.GetByAsinAsync(identifier); - if (localBook != null) - { - localTitle = localBook.Title; - localAuthor = localBook.Authors?.FirstOrDefault(a => !string.IsNullOrWhiteSpace(a)); - - // Collect identifiers from the new typed identifier model first. - foreach (var extId in (localBook.ExternalIdentifiers ?? Enumerable.Empty()) - .Where(extId => !string.IsNullOrWhiteSpace(extId.ValueNormalized))) - { - switch (extId.Type) - { - case AudiobookExternalIdentifierType.Asin: - if (ImageIdentifierHelper.LooksLikeAsin(extId.ValueNormalized) && - !localAsinCandidates.Contains(extId.ValueNormalized, StringComparer.OrdinalIgnoreCase)) - { - localAsinCandidates.Add(extId.ValueNormalized); - } - break; - case AudiobookExternalIdentifierType.Isbn: - if (ImageIdentifierHelper.LooksLikeIsbn(extId.ValueNormalized) && - !localIsbnCandidates.Contains(extId.ValueNormalized, StringComparer.OrdinalIgnoreCase)) - { - localIsbnCandidates.Add(extId.ValueNormalized); - } - break; - case AudiobookExternalIdentifierType.OpenLibraryId: - { - var normalizedOlid = ImageIdentifierHelper.NormalizeOpenLibraryId(extId.ValueNormalized); - if (!string.IsNullOrWhiteSpace(normalizedOlid) && - !localOpenLibraryIds.Contains(normalizedOlid, StringComparer.OrdinalIgnoreCase)) - { - localOpenLibraryIds.Add(normalizedOlid); - } - } - break; - } - } - - var localIsbn = localBook.Isbn? - .Select(ImageIdentifierHelper.NormalizeIsbn) - .FirstOrDefault(v => !string.IsNullOrWhiteSpace(v) && ImageIdentifierHelper.LooksLikeIsbn(v)); - if (!string.IsNullOrWhiteSpace(localIsbn)) - { - if (!localIsbnCandidates.Contains(localIsbn, StringComparer.OrdinalIgnoreCase)) - { - localIsbnCandidates.Add(localIsbn); - } - candidateIsbn ??= localIsbn; - _logger.LogDebug("Seeded candidate ISBN {Isbn} from local library record for {Identifier}", LogRedaction.SanitizeText(candidateIsbn), LogRedaction.SanitizeText(identifier)); - } - - if (!string.IsNullOrWhiteSpace(localBook.OpenLibraryId)) - { - var normalizedLocalOlid = ImageIdentifierHelper.NormalizeOpenLibraryId(localBook.OpenLibraryId); - if (!string.IsNullOrWhiteSpace(normalizedLocalOlid)) - { - if (!localOpenLibraryIds.Contains(normalizedLocalOlid, StringComparer.OrdinalIgnoreCase)) - { - localOpenLibraryIds.Add(normalizedLocalOlid); - } - localOpenLibraryId ??= normalizedLocalOlid; - } - } - - if (ImageIdentifierHelper.LooksLikeAsin(localBook.Asin ?? string.Empty)) - { - var normalizedLocalAsin = (localBook.Asin ?? string.Empty).Trim().ToUpperInvariant(); - if (!localAsinCandidates.Contains(normalizedLocalAsin, StringComparer.OrdinalIgnoreCase)) - { - localAsinCandidates.Add(normalizedLocalAsin); - } - } - } - } - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) when (ImageIdentifierHelper.IsRecoverableImageLookupException(ex)) - { - _logger.LogDebug(ex, "Failed to seed image fallback metadata from local library record for {Identifier}", LogRedaction.SanitizeText(identifier)); - } - - // If the requested identifier key has no cached image, reuse an existing - // cached image from any alternate stored identifier (e.g., old primary ASIN). - if (string.IsNullOrWhiteSpace(relativePath)) - { - var cacheAliasCandidates = localAsinCandidates - .Concat(localIsbnCandidates) - .Concat(localOpenLibraryIds) - .Where(v => !string.IsNullOrWhiteSpace(v) && !string.Equals(v, identifier, StringComparison.OrdinalIgnoreCase)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToList(); - - foreach (var aliasIdentifier in cacheAliasCandidates) - { - try - { - var aliasPath = await _imageCacheService.GetCachedImagePathAsync(aliasIdentifier); - if (!string.IsNullOrWhiteSpace(aliasPath)) - { - relativePath = aliasPath; - _logger.LogInformation( - "Reused cached image for identifier {Identifier} via alternate identifier {AliasIdentifier}: {Path}", - LogRedaction.SanitizeText(identifier), - LogRedaction.SanitizeText(aliasIdentifier), - LogRedaction.SanitizeText(relativePath)); - break; - } - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) when (ImageIdentifierHelper.IsRecoverableImageLookupException(ex)) - { - _logger.LogDebug(ex, "Failed probing alternate cached image identifier {AliasIdentifier} for {Identifier}", LogRedaction.SanitizeText(aliasIdentifier), LogRedaction.SanitizeText(identifier)); - } - } - } - - if (string.IsNullOrWhiteSpace(relativePath)) - { - var audible = await _audiobookMetadataService.GetAudibleMetadataAsync(identifier, region, cache: true); - - if (audible != null) - { - AddCandidateUrl(audible.ImageUrl, "Audible"); - if (!string.IsNullOrWhiteSpace(audible.Isbn)) - { - candidateIsbn = ImageIdentifierHelper.NormalizeIsbn(audible.Isbn); - } - } - - // Try Audnexus for ASINs as an additional candidate source even when - // Audible returned an image (Audible images can be placeholders or stale). - if (ImageIdentifierHelper.LooksLikeAsin(identifier)) - { - try - { - var audnexus = await _audnexusService.GetBookMetadataAsync(identifier, region, seedAuthors: true, update: false); - if (audnexus != null) - { - AddCandidateUrl(audnexus.Image, "AudnexusBook"); - if (string.IsNullOrWhiteSpace(candidateIsbn) && !string.IsNullOrWhiteSpace(audnexus.Isbn)) - { - candidateIsbn = ImageIdentifierHelper.NormalizeIsbn(audnexus.Isbn); - } - } - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) when (ImageIdentifierHelper.IsRecoverableImageLookupException(ex)) - { - _logger.LogDebug(ex, "Audnexus ASIN lookup failed for {Identifier}", LogRedaction.SanitizeText(identifier)); - } - } - - // Try alternate stored ASIN identifiers for this audiobook when the requested - // ASIN is region-limited or missing from providers. - if (ImageIdentifierHelper.LooksLikeAsin(identifier) && localAsinCandidates.Count > 0) - { - foreach (var altAsin in localAsinCandidates - .Where(a => !string.Equals(a, identifier, StringComparison.OrdinalIgnoreCase)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .Take(3)) - { - try - { - var altAudible = await _audiobookMetadataService.GetAudibleMetadataAsync(altAsin, region, cache: true); - if (altAudible != null) - { - AddCandidateUrl(altAudible.ImageUrl, "AudibleAltAsin"); - if (string.IsNullOrWhiteSpace(candidateIsbn) && !string.IsNullOrWhiteSpace(altAudible.Isbn)) - { - candidateIsbn = ImageIdentifierHelper.NormalizeIsbn(altAudible.Isbn); - } - } - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) when (ImageIdentifierHelper.IsRecoverableImageLookupException(ex)) - { - _logger.LogDebug(ex, "Audible alternate ASIN lookup failed for {Identifier} via {AltAsin}", LogRedaction.SanitizeText(identifier), LogRedaction.SanitizeText(altAsin)); - } - - try - { - var altAudnexus = await _audnexusService.GetBookMetadataAsync(altAsin, region, seedAuthors: true, update: false); - if (altAudnexus != null) - { - AddCandidateUrl(altAudnexus.Image, "AudnexusBookAltAsin"); - if (string.IsNullOrWhiteSpace(candidateIsbn) && !string.IsNullOrWhiteSpace(altAudnexus.Isbn)) - { - candidateIsbn = ImageIdentifierHelper.NormalizeIsbn(altAudnexus.Isbn); - } - } - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) when (ImageIdentifierHelper.IsRecoverableImageLookupException(ex)) - { - _logger.LogDebug(ex, "Audnexus alternate ASIN lookup failed for {Identifier} via {AltAsin}", LogRedaction.SanitizeText(identifier), LogRedaction.SanitizeText(altAsin)); - } - } - } - - // Build an OpenLibrary ISBN candidate when we have an ISBN (identifier or metadata/local record). - if (string.IsNullOrWhiteSpace(candidateIsbn) && ImageIdentifierHelper.LooksLikeIsbn(identifier)) - { - candidateIsbn = ImageIdentifierHelper.NormalizeIsbn(identifier); - } - if (!string.IsNullOrWhiteSpace(candidateIsbn)) - { - var olIsbnCandidate = $"https://covers.openlibrary.org/b/isbn/{Uri.EscapeDataString(candidateIsbn)}-L.jpg"; - AddCandidateUrl(olIsbnCandidate, "OpenLibraryIsbn"); - if (candidateUrls.Count == 1) - { - _logger.LogInformation("Using OpenLibrary ISBN cover candidate for {Identifier}: ISBN={Isbn}", LogRedaction.SanitizeText(identifier), LogRedaction.SanitizeText(candidateIsbn)); - } - } - - foreach (var localIsbnCandidate in localIsbnCandidates) - { - AddCandidateUrl( - $"https://covers.openlibrary.org/b/isbn/{Uri.EscapeDataString(localIsbnCandidate)}-L.jpg", - "OpenLibraryIsbnLocalIdentifier"); - } - - // Legacy fallback path through configured source envelope for compatibility. - if (string.IsNullOrWhiteSpace(candidateUrl) || string.IsNullOrWhiteSpace(candidateIsbn)) - { - _logger.LogDebug("No image found in audible, attempting fallback GetMetadataAsync for {Identifier}", LogRedaction.SanitizeText(identifier)); - try - { - var metadataEnvelope = await _audiobookMetadataService.GetMetadataAsync(identifier, region, cache: true); - if (metadataEnvelope != null) - { - try - { - // If the service returned an AudibleBookResponse directly - if (metadataEnvelope is AudibleBookResponse directMeta) - { - AddCandidateUrl(directMeta.ImageUrl, "MetadataEnvelopeDirect"); - } - else - { - // Try dynamic access - dynamic env = metadataEnvelope; - object? mdObj = env.metadata; - - // If it's already the Audible type, use it - if (mdObj is AudibleBookResponse mdMeta) - { - AddCandidateUrl(mdMeta.ImageUrl, "MetadataEnvelopeAudible"); - } - else if (mdObj != null) - { - // Try reflection for common property names - var t = mdObj.GetType(); - var prop = t.GetProperty("ImageUrl") ?? t.GetProperty("Image") ?? t.GetProperty("image") ?? t.GetProperty("imageUrl"); - if (prop != null) - { - var v = prop.GetValue(mdObj)?.ToString(); - AddCandidateUrl(v, "MetadataEnvelopeReflection"); - } - - if (string.IsNullOrWhiteSpace(candidateIsbn)) - { - var isbnProp = t.GetProperty("Isbn") ?? t.GetProperty("ISBN") ?? t.GetProperty("isbn"); - var isbnVal = isbnProp?.GetValue(mdObj)?.ToString(); - if (!string.IsNullOrWhiteSpace(isbnVal)) - { - candidateIsbn = ImageIdentifierHelper.NormalizeIsbn(isbnVal); - } - } - } - } - - if (!string.IsNullOrWhiteSpace(candidateUrl)) - { - _logger.LogInformation("Found image URL in fallback metadata source for identifier {Identifier}: {Url}", LogRedaction.SanitizeText(identifier), LogRedaction.SanitizeText(candidateUrl)); - } - else - { - _logger.LogDebug("Fallback metadata returned no image URL for {Identifier}", LogRedaction.SanitizeText(identifier)); - } - } - catch (Exception ex) when (ImageIdentifierHelper.IsRecoverableImageLookupException(ex)) - { - _logger.LogDebug(ex, "Failed to parse fallback metadata envelope for {Identifier}", LogRedaction.SanitizeText(identifier)); - } - } - else - { - _logger.LogDebug("GetMetadataAsync returned null for {Identifier}", LogRedaction.SanitizeText(identifier)); - } - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) when (ImageIdentifierHelper.IsRecoverableImageLookupException(ex)) - { - _logger.LogDebug(ex, "Fallback metadata lookup failed for {Identifier}", LogRedaction.SanitizeText(identifier)); - } - } - - // If metadata envelope yielded ISBN, queue OpenLibrary cover as a fallback candidate. - if (!string.IsNullOrWhiteSpace(candidateIsbn)) - { - AddCandidateUrl($"https://covers.openlibrary.org/b/isbn/{Uri.EscapeDataString(candidateIsbn)}-L.jpg", "OpenLibraryIsbnPostMetadata"); - } - - // Final OpenLibrary fallback via persisted OLID (if available and ISBN path - // wasn't usable). - if (!string.IsNullOrWhiteSpace(localOpenLibraryId)) - { - AddCandidateUrl($"https://covers.openlibrary.org/b/olid/{Uri.EscapeDataString(localOpenLibraryId)}-L.jpg", "OpenLibraryOlid"); - } - foreach (var localOlid in localOpenLibraryIds) - { - AddCandidateUrl($"https://covers.openlibrary.org/b/olid/{Uri.EscapeDataString(localOlid)}-L.jpg", "OpenLibraryOlidLocalIdentifier"); - } - - // Final ISBN discovery fallback for ASIN requests: use local title/author to - // search OpenLibrary when providers/local metadata do not include ISBN/OLID. - if (string.IsNullOrWhiteSpace(candidateIsbn) && - _openLibraryService != null && - ImageIdentifierHelper.LooksLikeAsin(identifier) && - !string.IsNullOrWhiteSpace(localTitle)) - { - try - { - var titleIsbns = await _openLibraryService.GetIsbnsForTitleAsync(localTitle!, localAuthor); - var normalizedTitleIsbns = titleIsbns - .Select(ImageIdentifierHelper.NormalizeIsbn) - .Where(v => !string.IsNullOrWhiteSpace(v) && ImageIdentifierHelper.LooksLikeIsbn(v)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .Take(5) - .ToList(); - - if (normalizedTitleIsbns.Count > 0) - { - _logger.LogInformation( - "Derived {Count} OpenLibrary ISBN candidate(s) from local title/author for {Identifier}: Title={Title}, Author={Author}", - normalizedTitleIsbns.Count, - LogRedaction.SanitizeText(identifier), - LogRedaction.SanitizeText(localTitle), - LogRedaction.SanitizeText(localAuthor)); - - foreach (var titleIsbn in normalizedTitleIsbns) - { - AddCandidateUrl( - $"https://covers.openlibrary.org/b/isbn/{Uri.EscapeDataString(titleIsbn)}-L.jpg", - "OpenLibraryTitleAuthorSearch"); - } - } - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) when (ImageIdentifierHelper.IsRecoverableImageLookupException(ex)) - { - _logger.LogDebug(ex, "OpenLibrary title/author ISBN fallback failed for {Identifier}", LogRedaction.SanitizeText(identifier)); - } - } - - // If no image found from book metadata, attempt author lookups (treating identifier as author name/asin) - if (string.IsNullOrWhiteSpace(candidateUrl)) - { - try - { - // First: try to find a stored author ASIN in the DB and serve its cached image if available - try - { - if (!string.IsNullOrWhiteSpace(identifier)) - { - var authorAsin = await _audiobookRepository.GetAuthorAsinByNameAsync(identifier); - if (!string.IsNullOrWhiteSpace(authorAsin)) - { - var diskPath = await _imageCacheService.GetCachedImagePathAsync(authorAsin); - if (!string.IsNullOrWhiteSpace(diskPath)) - { - // Use cached author image by ASIN (prefer authors storage path) - relativePath = "/" + diskPath.TrimStart('/'); - _logger.LogInformation("Found cached author image for identifier {Identifier} via stored ASIN {Asin}: {Path}", LogRedaction.SanitizeText(identifier), LogRedaction.SanitizeText(authorAsin), LogRedaction.SanitizeText(relativePath)); - } - } - } - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) when (ImageIdentifierHelper.IsRecoverableImageLookupException(ex)) - { - _logger.LogDebug(ex, "Failed to lookup stored author ASIN for identifier {Identifier}", LogRedaction.SanitizeText(identifier)); - } - - // If we didn't find a cached author image via stored ASIN, fallback to Audible lookup by name - if (string.IsNullOrWhiteSpace(relativePath)) - { - var authorLookup = await _audibleService.LookupAuthorAsync(identifier, region); - if (authorLookup != null && !string.IsNullOrWhiteSpace(authorLookup.Image) && (authorLookup.Image.StartsWith("http://") || authorLookup.Image.StartsWith("https://"))) - { - AddCandidateUrl(authorLookup.Image, "AudibleAuthor"); - _logger.LogInformation("Found author image from Audible for identifier {Identifier}: {Url}", LogRedaction.SanitizeText(identifier), LogRedaction.SanitizeText(candidateUrl)); - } - } - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) when (ImageIdentifierHelper.IsRecoverableImageLookupException(ex)) - { - _logger.LogDebug(ex, "Audible author lookup failed for {Identifier}", LogRedaction.SanitizeText(identifier)); - } - - // 2) Audnexus author search fallback - if (string.IsNullOrWhiteSpace(candidateUrl)) - { - try - { - // If identifier looks like an ASIN, prefer GetAuthorAsync to fetch the author directly - if (identifier != null && identifier.Length >= 10 && (identifier.StartsWith("B", StringComparison.OrdinalIgnoreCase) || identifier.All(char.IsLetterOrDigit))) - { - try - { - var authorResp = await _audnexusService.GetAuthorAsync(identifier, region, update: false); - if (authorResp != null && !string.IsNullOrWhiteSpace(authorResp.Image) && (authorResp.Image.StartsWith("http://") || authorResp.Image.StartsWith("https://"))) - { - AddCandidateUrl(authorResp.Image, "AudnexusAuthorByAsin"); - _logger.LogInformation("Found author image from Audnexus (by ASIN) for identifier {Identifier}: {Url}", LogRedaction.SanitizeText(identifier), LogRedaction.SanitizeText(candidateUrl)); - } - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) when (ImageIdentifierHelper.IsRecoverableImageLookupException(ex)) - { - _logger.LogDebug(ex, "Audnexus GetAuthorAsync failed for ASIN {Identifier}", LogRedaction.SanitizeText(identifier)); - } - } - - // If still not found, fallback to searching by name - if (string.IsNullOrWhiteSpace(candidateUrl)) - { - // Try to find stored author ASIN in database (match by author name) and prefer direct GET - try - { - if (!string.IsNullOrWhiteSpace(identifier)) - { - var authorAsin = await _audiobookRepository.GetAuthorAsinByNameAsync(identifier); - if (!string.IsNullOrWhiteSpace(authorAsin)) - { - try - { - var authorResp = await _audnexusService.GetAuthorAsync(authorAsin, region, update: false); - if (authorResp != null && !string.IsNullOrWhiteSpace(authorResp.Image) && (authorResp.Image.StartsWith("http://") || authorResp.Image.StartsWith("https://"))) - { - AddCandidateUrl(authorResp.Image, "AudnexusAuthorByStoredAsin"); - _logger.LogInformation("Found author image from Audnexus by stored ASIN {Asin} for identifier {Identifier}: {Url}", LogRedaction.SanitizeText(authorAsin), LogRedaction.SanitizeText(identifier), LogRedaction.SanitizeText(candidateUrl)); - } - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) when (ImageIdentifierHelper.IsRecoverableImageLookupException(ex)) - { - _logger.LogDebug(ex, "Audnexus GetAuthorAsync failed for ASIN {Asin}", LogRedaction.SanitizeText(authorAsin)); - } - } - } - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) when (ImageIdentifierHelper.IsRecoverableImageLookupException(ex)) - { - _logger.LogDebug(ex, "Failed to lookup author ASINs in database for identifier {Identifier}", LogRedaction.SanitizeText(identifier)); - } - - // If still not found, fallback to searching by name - if (string.IsNullOrWhiteSpace(candidateUrl)) - { - var authors = await _audnexusService.SearchAuthorsAsync(identifier!, region); - var first = authors?.FirstOrDefault(a => !string.IsNullOrWhiteSpace(a.Image)); - if (first != null && !string.IsNullOrWhiteSpace(first.Image) && (first.Image.StartsWith("http://") || first.Image.StartsWith("https://"))) - { - AddCandidateUrl(first.Image, "AudnexusAuthorSearch"); - _logger.LogInformation("Found author image from Audnexus (search) for identifier {Identifier}: {Url}", LogRedaction.SanitizeText(identifier), LogRedaction.SanitizeText(candidateUrl)); - } - } - } - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) when (ImageIdentifierHelper.IsRecoverableImageLookupException(ex)) - { - _logger.LogDebug(ex, "Audnexus author search failed for {Identifier}", LogRedaction.SanitizeText(identifier)); - } - } - } - - if (candidateUrls.Count > 0) - { - relativePath = await _fallbackDownloadWorkflow.TryDownloadFirstCachedAsync(identifier!, candidateUrls); - } - } - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) when (ImageIdentifierHelper.IsRecoverableImageLookupException(ex)) - { - _logger.LogDebug(ex, "Metadata-driven image download failed for {Identifier}", LogRedaction.SanitizeText(identifier)); - } + relativePath = await _imageCandidateLookupWorkflow.TryResolveAsync(identifier!, relativePath, Request.Query["region"].ToString()); if (relativePath == null) { From f0e85ac727c68f0eed093f7dc19dd4d61249717c Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Sat, 13 Jun 2026 22:20:37 -0400 Subject: [PATCH 60/84] refactor: extract mam date parsing - Move MyAnonamouse publish date and age parsing into a focused helper - Keep response parser result mapping behavior unchanged --- .../Search/MyAnonamousePublishDateParser.cs | 110 ++++++++++++++++++ .../Search/MyAnonamouseResponseParser.cs | 86 +------------- 2 files changed, 111 insertions(+), 85 deletions(-) create mode 100644 listenarr.application/Search/MyAnonamousePublishDateParser.cs diff --git a/listenarr.application/Search/MyAnonamousePublishDateParser.cs b/listenarr.application/Search/MyAnonamousePublishDateParser.cs new file mode 100644 index 000000000..927232a71 --- /dev/null +++ b/listenarr.application/Search/MyAnonamousePublishDateParser.cs @@ -0,0 +1,110 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Application.Search +{ + internal static class MyAnonamousePublishDateParser + { + public static DateTime? Parse(JsonElement item, string title, ILogger logger) + { + // Prefer explicit 'added' timestamp when present (MyAnonamouse uses "yyyy-MM-dd HH:mm:ss") + DateTime? publishDate = null; + if (item.TryGetProperty("added", out var addedElem) && addedElem.ValueKind == JsonValueKind.String) + { + var addedStr = addedElem.GetString(); + if (!string.IsNullOrWhiteSpace(addedStr)) + { + try + { + publishDate = DateTime.ParseExact(addedStr, "yyyy-MM-dd HH:mm:ss", System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.AssumeUniversal).ToLocalTime(); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + // ignore and fallback to other fields below + System.Diagnostics.Debug.WriteLine("Suppressed non-fatal exception in catch block."); + } + } + } + + // Parse publish date when present; fallback to 'age' if necessary + if (!publishDate.HasValue) + { + string? publishDateStr = null; + if (item.TryGetProperty("publishDate", out var pdElem) && pdElem.ValueKind == JsonValueKind.String) + publishDateStr = pdElem.GetString(); + else if (item.TryGetProperty("publish_date", out var pd2) && pd2.ValueKind == JsonValueKind.String) + publishDateStr = pd2.GetString(); + else if (item.TryGetProperty("publishdate", out var pd3) && pd3.ValueKind == JsonValueKind.String) + publishDateStr = pd3.GetString(); + + if (!string.IsNullOrWhiteSpace(publishDateStr)) + { + if (System.DateTimeOffset.TryParse(publishDateStr, out var dto)) + { + publishDate = dto.UtcDateTime; + } + else if (DateTime.TryParse(publishDateStr, out var pdv)) + { + publishDate = DateTime.SpecifyKind(pdv, DateTimeKind.Utc); + } + } + else + { + // Support multiple representations of "age": days, hours, minutes, or alternate keys (ageHours, ageMinutes) + int? days = null; + double? hours = null; + double? minutes = null; + + // Prefer explicit ageHours/ageMinutes if present + if (item.TryGetProperty("ageHours", out var ah) && (ah.ValueKind == JsonValueKind.Number || ah.ValueKind == JsonValueKind.String)) + { + if (ah.ValueKind == JsonValueKind.Number) hours = ah.GetDouble(); + else if (double.TryParse(ah.GetString(), out var htmp)) hours = htmp; + } + if (item.TryGetProperty("ageMinutes", out var am) && (am.ValueKind == JsonValueKind.Number || am.ValueKind == JsonValueKind.String)) + { + if (am.ValueKind == JsonValueKind.Number) minutes = am.GetDouble(); + else if (double.TryParse(am.GetString(), out var mtmp)) minutes = mtmp; + } + + // Fallback to 'age' if present. Heuristic: small values (<=48) likely hours; otherwise treat as days. + if ((hours == null && minutes == null) && item.TryGetProperty("age", out var ageElem)) + { + if (ageElem.ValueKind == JsonValueKind.Number) + { + var a = ageElem.GetDouble(); + if (a <= 48) hours = a; + else days = (int)Math.Floor(a); + } + else if (ageElem.ValueKind == JsonValueKind.String && double.TryParse(ageElem.GetString(), out var adtmp)) + { + var a = adtmp; + if (a <= 48) hours = a; + else days = (int)Math.Floor(a); + } + } + + if (minutes.HasValue && minutes.Value > 0) + publishDate = DateTime.UtcNow.AddMinutes(-minutes.Value); + else if (hours.HasValue && hours.Value > 0) + publishDate = DateTime.UtcNow.AddHours(-hours.Value); + else if (days.HasValue && days.Value > 0) + publishDate = DateTime.UtcNow.AddDays(-days.Value); + } + } + + + return publishDate; + } + } +} diff --git a/listenarr.application/Search/MyAnonamouseResponseParser.cs b/listenarr.application/Search/MyAnonamouseResponseParser.cs index 48aa59ebb..27d791b9b 100644 --- a/listenarr.application/Search/MyAnonamouseResponseParser.cs +++ b/listenarr.application/Search/MyAnonamouseResponseParser.cs @@ -206,91 +206,7 @@ public static List Parse(string jsonResponse, Indexer index break; } - // Prefer explicit 'added' timestamp when present (MyAnonamouse uses "yyyy-MM-dd HH:mm:ss") - DateTime? publishDate = null; - if (item.TryGetProperty("added", out var addedElem) && addedElem.ValueKind == JsonValueKind.String) - { - var addedStr = addedElem.GetString(); - if (!string.IsNullOrWhiteSpace(addedStr)) - { - try - { - publishDate = DateTime.ParseExact(addedStr, "yyyy-MM-dd HH:mm:ss", System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.AssumeUniversal).ToLocalTime(); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - // ignore and fallback to other fields below - System.Diagnostics.Debug.WriteLine("Suppressed non-fatal exception in catch block."); - } - } - } - - // Parse publish date when present; fallback to 'age' if necessary - if (!publishDate.HasValue) - { - string? publishDateStr = null; - if (item.TryGetProperty("publishDate", out var pdElem) && pdElem.ValueKind == JsonValueKind.String) - publishDateStr = pdElem.GetString(); - else if (item.TryGetProperty("publish_date", out var pd2) && pd2.ValueKind == JsonValueKind.String) - publishDateStr = pd2.GetString(); - else if (item.TryGetProperty("publishdate", out var pd3) && pd3.ValueKind == JsonValueKind.String) - publishDateStr = pd3.GetString(); - - if (!string.IsNullOrWhiteSpace(publishDateStr)) - { - if (System.DateTimeOffset.TryParse(publishDateStr, out var dto)) - { - publishDate = dto.UtcDateTime; - } - else if (DateTime.TryParse(publishDateStr, out var pdv)) - { - publishDate = DateTime.SpecifyKind(pdv, DateTimeKind.Utc); - } - } - else - { - // Support multiple representations of "age": days, hours, minutes, or alternate keys (ageHours, ageMinutes) - int? days = null; - double? hours = null; - double? minutes = null; - - // Prefer explicit ageHours/ageMinutes if present - if (item.TryGetProperty("ageHours", out var ah) && (ah.ValueKind == JsonValueKind.Number || ah.ValueKind == JsonValueKind.String)) - { - if (ah.ValueKind == JsonValueKind.Number) hours = ah.GetDouble(); - else if (double.TryParse(ah.GetString(), out var htmp)) hours = htmp; - } - if (item.TryGetProperty("ageMinutes", out var am) && (am.ValueKind == JsonValueKind.Number || am.ValueKind == JsonValueKind.String)) - { - if (am.ValueKind == JsonValueKind.Number) minutes = am.GetDouble(); - else if (double.TryParse(am.GetString(), out var mtmp)) minutes = mtmp; - } - - // Fallback to 'age' if present. Heuristic: small values (<=48) likely hours; otherwise treat as days. - if ((hours == null && minutes == null) && item.TryGetProperty("age", out var ageElem)) - { - if (ageElem.ValueKind == JsonValueKind.Number) - { - var a = ageElem.GetDouble(); - if (a <= 48) hours = a; - else days = (int)Math.Floor(a); - } - else if (ageElem.ValueKind == JsonValueKind.String && double.TryParse(ageElem.GetString(), out var adtmp)) - { - var a = adtmp; - if (a <= 48) hours = a; - else days = (int)Math.Floor(a); - } - } - - if (minutes.HasValue && minutes.Value > 0) - publishDate = DateTime.UtcNow.AddMinutes(-minutes.Value); - else if (hours.HasValue && hours.Value > 0) - publishDate = DateTime.UtcNow.AddHours(-hours.Value); - else if (days.HasValue && days.Value > 0) - publishDate = DateTime.UtcNow.AddDays(-days.Value); - } - } + var publishDate = MyAnonamousePublishDateParser.Parse(item, title, logger); if (string.IsNullOrEmpty(title)) continue; From f939d7e6735da11874fbbd510ef16fa947890eae Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Sun, 14 Jun 2026 01:48:04 -0400 Subject: [PATCH 61/84] refactor: extract download removal workflow - Move queue removal lookup, client removal, and cleanup rules into a focused workflow - Keep DownloadService as a thin delegate for removal requests --- .../Downloads/DownloadRemovalWorkflow.cs | 299 ++++++++++++++++++ .../Downloads/DownloadService.cs | 270 +--------------- .../AppServiceRegistrationExtensions.cs | 1 + 3 files changed, 303 insertions(+), 267 deletions(-) create mode 100644 listenarr.application/Downloads/DownloadRemovalWorkflow.cs diff --git a/listenarr.application/Downloads/DownloadRemovalWorkflow.cs b/listenarr.application/Downloads/DownloadRemovalWorkflow.cs new file mode 100644 index 000000000..81d51a722 --- /dev/null +++ b/listenarr.application/Downloads/DownloadRemovalWorkflow.cs @@ -0,0 +1,299 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +using Listenarr.Application.Interfaces; +using Listenarr.Application.Interfaces.Repositories; +using Listenarr.Application.Security; +using Listenarr.Domain.Common; +using Listenarr.Domain.Models; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Application.Downloads +{ + public sealed class DownloadRemovalWorkflow( + IConfigurationService _configurationService, + IDownloadRepository _downloadRepository, + IDownloadClientGateway _clientGateway, + IDownloadQueueService _downloadQueueService, + ILogger _logger) + { + public async Task RemoveAsync(string downloadId, string? downloadClientId = null, bool force = false) + { + try + { + bool removedFromClient = false; + Download? downloadRecord = null; + + // Try to find by direct ID match first + downloadRecord = await _downloadRepository.FindAsync(downloadId); + + // If not found, try to find by client-specific ID (e.g., torrent hash) + if (downloadRecord == null) + { + var allDownloads = await _downloadRepository.GetAllAsync(); + downloadRecord = allDownloads.FirstOrDefault(d => + d.Metadata != null && + ((d.Metadata.TryGetValue("ClientDownloadId", out var clientIdObj) && + string.Equals(clientIdObj?.ToString(), downloadId, StringComparison.OrdinalIgnoreCase)) || + (d.Metadata.TryGetValue("TorrentHash", out var hashObj) && + string.Equals(hashObj?.ToString(), downloadId, StringComparison.OrdinalIgnoreCase)))); + } + + // If still not found, try enhanced title/name matching for legacy downloads + if (downloadRecord == null && downloadClientId != null) + { + var client = await _configurationService.GetDownloadClientConfigurationAsync(downloadClientId); + if (client != null) + { + var queue = await _downloadQueueService.GetQueueAsync(); + var queueItem = queue.FirstOrDefault(q => q.Id == downloadId && q.DownloadClientId == downloadClientId); + + if (queueItem != null) + { + var clientDownloads = await _downloadRepository.GetByClientAsync(downloadClientId); + downloadRecord = clientDownloads.FirstOrDefault(d => TitleUtils.IsMatchingTitle(d.Title, queueItem.Title)); + } + } + } + + // If force=true, skip client removal and just remove from database + if (force) + { + _logger.LogWarning("Force removal requested for {DownloadId}, skipping client removal", downloadId); + removedFromClient = true; + } + else if (downloadClientId == null) + { + // Try all clients to find and remove the item + var downloadClients = await _configurationService.GetDownloadClientConfigurationsAsync(); + var enabledClients = downloadClients.Where(c => c.IsEnabled).ToList(); + + foreach (var client in enabledClients) + { + removedFromClient = await RemoveFromClientAsync(client, downloadId, downloadRecord); + if (removedFromClient) + { + downloadClientId = client.Id; // Track which client it was removed from + break; + } + } + } + else + { + // Check if the downloadClientId is a valid client configuration + var client = await _configurationService.GetDownloadClientConfigurationAsync(downloadClientId); + if (client != null && !client.IsEnabled) + { + _logger.LogInformation("Skipping removal of {DownloadId} from disabled client {ClientName}", downloadId, client.Name); + } + else if (client != null) + { + removedFromClient = await RemoveFromClientAsync(client, downloadId, downloadRecord); + } + else + { + // If client not found by ID, this might be a legacy/invalid client ID + // Try to find the download in the database and check if it's DDL or has a valid client + if (downloadRecord != null) + { + if (downloadRecord.DownloadClientId == "DDL") + { + // DDL downloads don't have an external client to remove from + removedFromClient = true; + _logger.LogInformation("Download {DownloadId} is DDL, skipping external client removal", downloadId); + } + else if (!string.IsNullOrEmpty(downloadRecord.DownloadClientId)) + { + // Try with the download record's client ID + var recordClient = await _configurationService.GetDownloadClientConfigurationAsync(downloadRecord.DownloadClientId); + if (recordClient != null && !recordClient.IsEnabled) + { + _logger.LogInformation("Skipping removal of {DownloadId} from disabled client {ClientName}", downloadId, recordClient.Name); + removedFromClient = true; // Treat as success so DB record is cleaned up + } + else if (recordClient != null) + { + removedFromClient = await RemoveFromClientAsync(recordClient, downloadId, downloadRecord); + downloadClientId = recordClient.Id; + } + else + { + // Client no longer exists, just remove from database + removedFromClient = true; + _logger.LogWarning("Download client {ClientId} not found for download {DownloadId}, removing from database only", + downloadRecord.DownloadClientId, downloadId); + } + } + } + else + { + // Download not in database and invalid client ID provided + // This could be an external queue item with a bad client ID reference + // Try all enabled clients to find and remove it + _logger.LogWarning("Invalid client ID {ClientId} and download {DownloadId} not in database, trying all clients", + downloadClientId, downloadId); + + var downloadClients = await _configurationService.GetDownloadClientConfigurationsAsync(); + var enabledClients = downloadClients.Where(c => c.IsEnabled).ToList(); + + foreach (var tryClient in enabledClients) + { + removedFromClient = await RemoveFromClientAsync(tryClient, downloadId, downloadRecord); + if (removedFromClient) + { + downloadClientId = tryClient.Id; + _logger.LogInformation("Successfully removed {DownloadId} from client {ClientName}", downloadId, tryClient.Name); + break; + } + } + + // If still not removed but not in any queue, consider it success + if (!removedFromClient) + { + _logger.LogInformation("Could not remove {DownloadId} from any client, verifying it's not in any queue", downloadId); + var currentQueue = await _downloadQueueService.GetQueueAsync(); + if (!currentQueue.Any(q => q.Id.Equals(downloadId, StringComparison.OrdinalIgnoreCase))) + { + _logger.LogInformation("Download {DownloadId} not found in any queue, treating as successfully removed", downloadId); + removedFromClient = true; + } + } + } + } + } + + // If successfully removed from client (or force=true), also remove from database + if (removedFromClient && downloadRecord != null) + { + await _downloadRepository.RemoveAsync(downloadRecord.Id); + _logger.LogInformation("Removed download record from database: {DownloadId} (Title: {Title})", + downloadRecord.Id, downloadRecord.Title); + } + + return removedFromClient; + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogError(ex, "Error removing from queue: {DownloadId}", downloadId); + return false; + } + } + + + private async Task RemoveFromClientAsync(DownloadClientConfiguration client, string downloadId, Download? downloadRecord = null) + { + try + { + if (client == null) return false; + + // Resolve the client-specific ID (torrent hash, NZB ID, etc.) from the download record. + // The download record's Metadata dictionary stores the mapping set during AddAsync. + // Without this, Transmission/qBittorrent receive the Listenarr UUID which they don't recognise. + var clientItemId = downloadId; + if (downloadRecord?.Metadata != null) + { + if ((string.Equals(client.Type, "qbittorrent", StringComparison.OrdinalIgnoreCase) || + string.Equals(client.Type, "transmission", StringComparison.OrdinalIgnoreCase)) && + downloadRecord.Metadata.TryGetValue("TorrentHash", out var hashObj)) + { + var hash = hashObj?.ToString(); + if (!string.IsNullOrEmpty(hash)) + { + clientItemId = hash; + _logger.LogDebug("RemoveFromClientAsync: Using torrent hash {Hash} instead of download ID for {ClientType} removal", + hash, client.Type); + } + } + else if (downloadRecord.Metadata.TryGetValue("ClientDownloadId", out var clientIdObj)) + { + var resolvedId = clientIdObj?.ToString(); + if (!string.IsNullOrEmpty(resolvedId)) + { + clientItemId = resolvedId; + _logger.LogDebug("RemoveFromClientAsync: Using client-specific ID {ClientId} for {ClientType} removal", + resolvedId, client.Type); + } + } + } + + if (_clientGateway != null) + { + try + { + var removed = await _clientGateway.RemoveAsync(client, clientItemId, false); + if (removed) + { + _logger.LogInformation("Successfully removed {DownloadId} from client {ClientName}", downloadId, client.Name ?? client.Id); + return true; + } + + // If removal returned false, verify if the item is still in the client's queue + // If it's not in the queue, consider removal successful (item already gone) + _logger.LogWarning("Client reported removal failed for {DownloadId}, checking if item still exists in queue", downloadId); + try + { + var queue = await _clientGateway.GetQueueAsync(client); + var stillExists = queue.Any(q => q.Id.Equals(downloadId, StringComparison.OrdinalIgnoreCase)); + + if (!stillExists) + { + _logger.LogInformation("Item {DownloadId} no longer in {ClientName} queue, treating removal as successful", downloadId, client.Name ?? client.Id); + return true; + } + + _logger.LogWarning("Item {DownloadId} still exists in {ClientName} queue after removal attempt", downloadId, client.Name ?? client.Id); + return false; + } + catch (Exception queueEx) when (queueEx is not OperationCanceledException && queueEx is not OutOfMemoryException && queueEx is not StackOverflowException) + { + _logger.LogWarning(queueEx, "Failed to verify queue status for {DownloadId} on {ClientName}, assuming removal failed", downloadId, client.Name ?? client.Id); + return false; + } + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning(ex, "RemoveFromClientAsync: Exception removing {DownloadId} from {Client}: {Message}", + LogRedaction.SanitizeText(downloadId), LogRedaction.SanitizeText(client.Name ?? client.Id), ex.Message); + + // Check if item still exists in queue - if not, consider removal successful + try + { + var queue = await _clientGateway.GetQueueAsync(client); + var stillExists = queue.Any(q => q.Id.Equals(downloadId, StringComparison.OrdinalIgnoreCase)); + + if (!stillExists) + { + _logger.LogInformation("After exception, item {DownloadId} not found in {ClientName} queue, treating as successfully removed", + downloadId, client.Name ?? client.Id); + return true; + } + } + catch (Exception queueEx) when (queueEx is not OperationCanceledException && queueEx is not OutOfMemoryException && queueEx is not StackOverflowException) + { + _logger.LogDebug(queueEx, "Failed to verify queue after exception for {DownloadId}", downloadId); + } + + return false; + } + } + + // Fallback conservative behavior when no gateway is available + return false; + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning(ex, "RemoveFromClientAsync fallback failed for client {Client}", client.Name ?? client.Id); + return false; + } + } + + + } +} diff --git a/listenarr.application/Downloads/DownloadService.cs b/listenarr.application/Downloads/DownloadService.cs index 200cc8a72..f320dcc83 100644 --- a/listenarr.application/Downloads/DownloadService.cs +++ b/listenarr.application/Downloads/DownloadService.cs @@ -16,7 +16,6 @@ * along with this program. If not, see . */ -using Listenarr.Domain.Common; using Listenarr.Application.Interfaces; using Listenarr.Domain.Models; using Listenarr.Application.Interfaces.Repositories; @@ -42,7 +41,8 @@ public class DownloadService( DownloadTypeResolver downloadTypeResolver, DownloadClientSelector downloadClientSelector, DownloadCachedTorrentStore cachedTorrentStore, - DirectDownloadWorkflow directDownloadWorkflow) : IDownloadService + DirectDownloadWorkflow directDownloadWorkflow, + DownloadRemovalWorkflow downloadRemovalWorkflow) : IDownloadService { // Cache expiration constants private const int QueueCacheExpirationSeconds = 10; @@ -445,164 +445,7 @@ private async Task TryPrepareMyAnonamouseTorrentAsync(SearchResult searchResult, public async Task RemoveFromQueueAsync(string downloadId, string? downloadClientId = null, bool force = false) { - try - { - bool removedFromClient = false; - Download? downloadRecord = null; - - // Try to find by direct ID match first - downloadRecord = await downloadRepository.FindAsync(downloadId); - - // If not found, try to find by client-specific ID (e.g., torrent hash) - if (downloadRecord == null) - { - var allDownloads = await downloadRepository.GetAllAsync(); - downloadRecord = allDownloads.FirstOrDefault(d => - d.Metadata != null && - ((d.Metadata.TryGetValue("ClientDownloadId", out var clientIdObj) && - string.Equals(clientIdObj?.ToString(), downloadId, StringComparison.OrdinalIgnoreCase)) || - (d.Metadata.TryGetValue("TorrentHash", out var hashObj) && - string.Equals(hashObj?.ToString(), downloadId, StringComparison.OrdinalIgnoreCase)))); - } - - // If still not found, try enhanced title/name matching for legacy downloads - if (downloadRecord == null && downloadClientId != null) - { - var client = await configurationService.GetDownloadClientConfigurationAsync(downloadClientId); - if (client != null) - { - var queue = await downloadQueueService.GetQueueAsync(); - var queueItem = queue.FirstOrDefault(q => q.Id == downloadId && q.DownloadClientId == downloadClientId); - - if (queueItem != null) - { - var clientDownloads = await downloadRepository.GetByClientAsync(downloadClientId); - downloadRecord = clientDownloads.FirstOrDefault(d => TitleUtils.IsMatchingTitle(d.Title, queueItem.Title)); - } - } - } - - // If force=true, skip client removal and just remove from database - if (force) - { - logger.LogWarning("Force removal requested for {DownloadId}, skipping client removal", downloadId); - removedFromClient = true; - } - else if (downloadClientId == null) - { - // Try all clients to find and remove the item - var downloadClients = await configurationService.GetDownloadClientConfigurationsAsync(); - var enabledClients = downloadClients.Where(c => c.IsEnabled).ToList(); - - foreach (var client in enabledClients) - { - removedFromClient = await RemoveFromClientAsync(client, downloadId, downloadRecord); - if (removedFromClient) - { - downloadClientId = client.Id; // Track which client it was removed from - break; - } - } - } - else - { - // Check if the downloadClientId is a valid client configuration - var client = await configurationService.GetDownloadClientConfigurationAsync(downloadClientId); - if (client != null && !client.IsEnabled) - { - logger.LogInformation("Skipping removal of {DownloadId} from disabled client {ClientName}", downloadId, client.Name); - } - else if (client != null) - { - removedFromClient = await RemoveFromClientAsync(client, downloadId, downloadRecord); - } - else - { - // If client not found by ID, this might be a legacy/invalid client ID - // Try to find the download in the database and check if it's DDL or has a valid client - if (downloadRecord != null) - { - if (downloadRecord.DownloadClientId == "DDL") - { - // DDL downloads don't have an external client to remove from - removedFromClient = true; - logger.LogInformation("Download {DownloadId} is DDL, skipping external client removal", downloadId); - } - else if (!string.IsNullOrEmpty(downloadRecord.DownloadClientId)) - { - // Try with the download record's client ID - var recordClient = await configurationService.GetDownloadClientConfigurationAsync(downloadRecord.DownloadClientId); - if (recordClient != null && !recordClient.IsEnabled) - { - logger.LogInformation("Skipping removal of {DownloadId} from disabled client {ClientName}", downloadId, recordClient.Name); - removedFromClient = true; // Treat as success so DB record is cleaned up - } - else if (recordClient != null) - { - removedFromClient = await RemoveFromClientAsync(recordClient, downloadId, downloadRecord); - downloadClientId = recordClient.Id; - } - else - { - // Client no longer exists, just remove from database - removedFromClient = true; - logger.LogWarning("Download client {ClientId} not found for download {DownloadId}, removing from database only", - downloadRecord.DownloadClientId, downloadId); - } - } - } - else - { - // Download not in database and invalid client ID provided - // This could be an external queue item with a bad client ID reference - // Try all enabled clients to find and remove it - logger.LogWarning("Invalid client ID {ClientId} and download {DownloadId} not in database, trying all clients", - downloadClientId, downloadId); - - var downloadClients = await configurationService.GetDownloadClientConfigurationsAsync(); - var enabledClients = downloadClients.Where(c => c.IsEnabled).ToList(); - - foreach (var tryClient in enabledClients) - { - removedFromClient = await RemoveFromClientAsync(tryClient, downloadId, downloadRecord); - if (removedFromClient) - { - downloadClientId = tryClient.Id; - logger.LogInformation("Successfully removed {DownloadId} from client {ClientName}", downloadId, tryClient.Name); - break; - } - } - - // If still not removed but not in any queue, consider it success - if (!removedFromClient) - { - logger.LogInformation("Could not remove {DownloadId} from any client, verifying it's not in any queue", downloadId); - var currentQueue = await downloadQueueService.GetQueueAsync(); - if (!currentQueue.Any(q => q.Id.Equals(downloadId, StringComparison.OrdinalIgnoreCase))) - { - logger.LogInformation("Download {DownloadId} not found in any queue, treating as successfully removed", downloadId); - removedFromClient = true; - } - } - } - } - } - - // If successfully removed from client (or force=true), also remove from database - if (removedFromClient && downloadRecord != null) - { - await downloadRepository.RemoveAsync(downloadRecord.Id); - logger.LogInformation("Removed download record from database: {DownloadId} (Title: {Title})", - downloadRecord.Id, downloadRecord.Title); - } - - return removedFromClient; - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - logger.LogError(ex, "Error removing from queue: {DownloadId}", downloadId); - return false; - } + return await downloadRemovalWorkflow.RemoveAsync(downloadId, downloadClientId, force); } // @@ -629,113 +472,6 @@ private async Task LogDownloadHistory(Audiobook audiobook, string source, Search await Task.CompletedTask; } - private async Task RemoveFromClientAsync(DownloadClientConfiguration client, string downloadId, Download? downloadRecord = null) - { - try - { - if (client == null) return false; - - // Resolve the client-specific ID (torrent hash, NZB ID, etc.) from the download record. - // The download record's Metadata dictionary stores the mapping set during AddAsync. - // Without this, Transmission/qBittorrent receive the Listenarr UUID which they don't recognise. - var clientItemId = downloadId; - if (downloadRecord?.Metadata != null) - { - if ((string.Equals(client.Type, "qbittorrent", StringComparison.OrdinalIgnoreCase) || - string.Equals(client.Type, "transmission", StringComparison.OrdinalIgnoreCase)) && - downloadRecord.Metadata.TryGetValue("TorrentHash", out var hashObj)) - { - var hash = hashObj?.ToString(); - if (!string.IsNullOrEmpty(hash)) - { - clientItemId = hash; - logger.LogDebug("RemoveFromClientAsync: Using torrent hash {Hash} instead of download ID for {ClientType} removal", - hash, client.Type); - } - } - else if (downloadRecord.Metadata.TryGetValue("ClientDownloadId", out var clientIdObj)) - { - var resolvedId = clientIdObj?.ToString(); - if (!string.IsNullOrEmpty(resolvedId)) - { - clientItemId = resolvedId; - logger.LogDebug("RemoveFromClientAsync: Using client-specific ID {ClientId} for {ClientType} removal", - resolvedId, client.Type); - } - } - } - - if (clientGateway != null) - { - try - { - var removed = await clientGateway.RemoveAsync(client, clientItemId, false); - if (removed) - { - logger.LogInformation("Successfully removed {DownloadId} from client {ClientName}", downloadId, client.Name ?? client.Id); - return true; - } - - // If removal returned false, verify if the item is still in the client's queue - // If it's not in the queue, consider removal successful (item already gone) - logger.LogWarning("Client reported removal failed for {DownloadId}, checking if item still exists in queue", downloadId); - try - { - var queue = await clientGateway.GetQueueAsync(client); - var stillExists = queue.Any(q => q.Id.Equals(downloadId, StringComparison.OrdinalIgnoreCase)); - - if (!stillExists) - { - logger.LogInformation("Item {DownloadId} no longer in {ClientName} queue, treating removal as successful", downloadId, client.Name ?? client.Id); - return true; - } - - logger.LogWarning("Item {DownloadId} still exists in {ClientName} queue after removal attempt", downloadId, client.Name ?? client.Id); - return false; - } - catch (Exception queueEx) when (queueEx is not OperationCanceledException && queueEx is not OutOfMemoryException && queueEx is not StackOverflowException) - { - logger.LogWarning(queueEx, "Failed to verify queue status for {DownloadId} on {ClientName}, assuming removal failed", downloadId, client.Name ?? client.Id); - return false; - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - logger.LogWarning(ex, "RemoveFromClientAsync: Exception removing {DownloadId} from {Client}: {Message}", - LogRedaction.SanitizeText(downloadId), LogRedaction.SanitizeText(client.Name ?? client.Id), ex.Message); - - // Check if item still exists in queue - if not, consider removal successful - try - { - var queue = await clientGateway.GetQueueAsync(client); - var stillExists = queue.Any(q => q.Id.Equals(downloadId, StringComparison.OrdinalIgnoreCase)); - - if (!stillExists) - { - logger.LogInformation("After exception, item {DownloadId} not found in {ClientName} queue, treating as successfully removed", - downloadId, client.Name ?? client.Id); - return true; - } - } - catch (Exception queueEx) when (queueEx is not OperationCanceledException && queueEx is not OutOfMemoryException && queueEx is not StackOverflowException) - { - logger.LogDebug(queueEx, "Failed to verify queue after exception for {DownloadId}", downloadId); - } - - return false; - } - } - - // Fallback conservative behavior when no gateway is available - return false; - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - logger.LogWarning(ex, "RemoveFromClientAsync fallback failed for client {Client}", client.Name ?? client.Id); - return false; - } - } - public async Task UpdateAsync(Download download) { var previous = await downloadRepository.GetByIdAsync(download.Id); diff --git a/listenarr.infrastructure/Extensions/AppServiceRegistrationExtensions.cs b/listenarr.infrastructure/Extensions/AppServiceRegistrationExtensions.cs index 41f1192e0..4e6665586 100644 --- a/listenarr.infrastructure/Extensions/AppServiceRegistrationExtensions.cs +++ b/listenarr.infrastructure/Extensions/AppServiceRegistrationExtensions.cs @@ -95,6 +95,7 @@ public static IServiceCollection AddListenarrAppServices(this IServiceCollection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); // Queue service extracted from DownloadService to encapsulate queue-building and filtering services.AddScoped(); services.AddScoped(); From 5b1a4e4f1da39ef36eccadb333f3aa68693d3b22 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Sun, 14 Jun 2026 01:51:53 -0400 Subject: [PATCH 62/84] refactor: extract ffprobe asset discovery - Move GitHub release asset lookup out of FfmpegService - Keep ffprobe install and extraction behavior unchanged --- .../Ffmpeg/FfmpegService.cs | 80 ++-------------- .../Ffmpeg/FfprobeGithubAssetDiscoverer.cs | 95 +++++++++++++++++++ 2 files changed, 102 insertions(+), 73 deletions(-) create mode 100644 listenarr.infrastructure/Ffmpeg/FfprobeGithubAssetDiscoverer.cs diff --git a/listenarr.infrastructure/Ffmpeg/FfmpegService.cs b/listenarr.infrastructure/Ffmpeg/FfmpegService.cs index 44069555d..94ae14bee 100644 --- a/listenarr.infrastructure/Ffmpeg/FfmpegService.cs +++ b/listenarr.infrastructure/Ffmpeg/FfmpegService.cs @@ -39,6 +39,7 @@ public class FfmpegService : IFfmpegService private readonly HttpClient _httpClient; private readonly IStartupConfigService _startupConfigService; private readonly IProcessRunner _processRunner; + private readonly FfprobeGithubAssetDiscoverer _githubAssetDiscoverer; // Allow disabling auto-download via environment variable private readonly bool _autoInstall; @@ -61,6 +62,7 @@ public FfmpegService( timeoutSeconds = parsedSeconds; } _httpClient.Timeout = TimeSpan.FromSeconds(timeoutSeconds); + _githubAssetDiscoverer = new FfprobeGithubAssetDiscoverer(_httpClient, _logger); _autoInstall = Environment.GetEnvironmentVariable("LISTENARR_AUTO_INSTALL_FFPROBE")?.ToLower() != "false"; // default true _startupConfigService = startupConfigService; _processRunner = processRunner; @@ -178,13 +180,13 @@ private static bool TryBuildPathUnderRoot(string rootPath, string entryPath, out if (parts.Length == 2) { var repo = parts[1]; - var assetInfo = await TryDiscoverGithubAssetAsync(repo, cfg.Ffmpeg.ReleaseOverride, cfg.Ffmpeg.Arch); - if (!string.IsNullOrEmpty(assetInfo.assetUrl)) + var assetInfo = await _githubAssetDiscoverer.TryDiscoverAsync(repo, cfg.Ffmpeg.ReleaseOverride, cfg.Ffmpeg.Arch); + if (!string.IsNullOrEmpty(assetInfo.AssetUrl)) { - downloadUrl = assetInfo.assetUrl; - if (!string.IsNullOrEmpty(assetInfo.checksumContent)) + downloadUrl = assetInfo.AssetUrl; + if (!string.IsNullOrEmpty(assetInfo.ChecksumContent)) { - discoveredChecksum = assetInfo.checksumContent; + discoveredChecksum = assetInfo.ChecksumContent; } } } @@ -548,74 +550,6 @@ private static bool TryBuildPathUnderRoot(string rootPath, string entryPath, out return null; } - private async Task<(string? assetUrl, string? checksumContent)> TryDiscoverGithubAssetAsync(string repo, string? releaseOverride, string? arch) - { - try - { - // Use GitHub Releases API: https://api.github.com/repos/{owner}/{repo}/releases - var releasesUrl = $"https://api.github.com/repos/{repo}/releases"; - using var req = new HttpRequestMessage(HttpMethod.Get, releasesUrl); - req.Headers.Add("User-Agent", "Listenarr-Installer"); - using var resp = await _httpClient.SendAsync(req); - resp.EnsureSuccessStatusCode(); - var body = await resp.Content.ReadAsStringAsync(); - var docs = System.Text.Json.JsonSerializer.Deserialize(body); - if (docs.ValueKind != System.Text.Json.JsonValueKind.Array) return (null, null); - - foreach (var release in docs.EnumerateArray()) - { - var tag = release.GetProperty("tag_name").GetString() ?? string.Empty; - if (!string.IsNullOrEmpty(releaseOverride) && !tag.Contains(releaseOverride, StringComparison.OrdinalIgnoreCase)) continue; - if (release.TryGetProperty("assets", out var assets) && assets.ValueKind == System.Text.Json.JsonValueKind.Array) - { - string? checksumContent = null; - string? chosenUrl = null; - // First, attempt to find checksum asset(s) - foreach (var asset in assets.EnumerateArray()) - { - var name = asset.GetProperty("name").GetString() ?? string.Empty; - var url = asset.GetProperty("browser_download_url").GetString() ?? string.Empty; - if (string.IsNullOrEmpty(url)) continue; - if (name.Contains("sha256", StringComparison.OrdinalIgnoreCase) || name.Contains("checksum", StringComparison.OrdinalIgnoreCase) || name.Contains("sha256sums", StringComparison.OrdinalIgnoreCase)) - { - try - { - var c = await (await _httpClient.GetAsync(url)).Content.ReadAsStringAsync(); - if (!string.IsNullOrEmpty(c)) checksumContent = c; - } - catch (Exception caughtEx_10) when (caughtEx_10 is not OperationCanceledException && caughtEx_10 is not OutOfMemoryException && caughtEx_10 is not StackOverflowException) - { - System.Diagnostics.Debug.WriteLine("Suppressed non-fatal exception in catch block."); - } - } - } - - // Then find a matching asset for platform/arch - foreach (var asset in assets.EnumerateArray()) - { - var name = asset.GetProperty("name").GetString() ?? string.Empty; - var url = asset.GetProperty("browser_download_url").GetString() ?? string.Empty; - if (string.IsNullOrEmpty(url)) continue; - if (!string.IsNullOrEmpty(arch) && !name.Contains(arch, StringComparison.OrdinalIgnoreCase)) continue; - if (name.EndsWith(".zip", StringComparison.OrdinalIgnoreCase) || name.EndsWith(".tar.xz", StringComparison.OrdinalIgnoreCase) || name.EndsWith(".tgz", StringComparison.OrdinalIgnoreCase) || name.EndsWith(".tar.gz", StringComparison.OrdinalIgnoreCase)) - { - chosenUrl = url; - break; - } - } - - if (!string.IsNullOrEmpty(chosenUrl)) return (chosenUrl, checksumContent); - } - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "GitHub asset discovery failed for repo {Repo}", repo); - } - - return (null, null); - } - public async Task RunFfprobeAsync(string filePath) { var sanitizedFilePath = LogRedaction.SanitizeFilePath(filePath); diff --git a/listenarr.infrastructure/Ffmpeg/FfprobeGithubAssetDiscoverer.cs b/listenarr.infrastructure/Ffmpeg/FfprobeGithubAssetDiscoverer.cs new file mode 100644 index 000000000..ffeba8a2f --- /dev/null +++ b/listenarr.infrastructure/Ffmpeg/FfprobeGithubAssetDiscoverer.cs @@ -0,0 +1,95 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Infrastructure.Ffmpeg +{ + internal sealed class FfprobeGithubAssetDiscoverer + { + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + + public FfprobeGithubAssetDiscoverer(HttpClient httpClient, ILogger logger) + { + _httpClient = httpClient; + _logger = logger; + } + + public async Task<(string? AssetUrl, string? ChecksumContent)> TryDiscoverAsync(string repo, string? releaseOverride, string? arch) + { + try + { + // Use GitHub Releases API: https://api.github.com/repos/{owner}/{repo}/releases + var releasesUrl = $"https://api.github.com/repos/{repo}/releases"; + using var req = new HttpRequestMessage(HttpMethod.Get, releasesUrl); + req.Headers.Add("User-Agent", "Listenarr-Installer"); + using var resp = await _httpClient.SendAsync(req); + resp.EnsureSuccessStatusCode(); + var body = await resp.Content.ReadAsStringAsync(); + var docs = JsonSerializer.Deserialize(body); + if (docs.ValueKind != JsonValueKind.Array) return (null, null); + + foreach (var release in docs.EnumerateArray()) + { + var tag = release.GetProperty("tag_name").GetString() ?? string.Empty; + if (!string.IsNullOrEmpty(releaseOverride) && !tag.Contains(releaseOverride, StringComparison.OrdinalIgnoreCase)) continue; + if (release.TryGetProperty("assets", out var assets) && assets.ValueKind == JsonValueKind.Array) + { + string? checksumContent = null; + string? chosenUrl = null; + // First, attempt to find checksum asset(s) + foreach (var asset in assets.EnumerateArray()) + { + var name = asset.GetProperty("name").GetString() ?? string.Empty; + var url = asset.GetProperty("browser_download_url").GetString() ?? string.Empty; + if (string.IsNullOrEmpty(url)) continue; + if (name.Contains("sha256", StringComparison.OrdinalIgnoreCase) || name.Contains("checksum", StringComparison.OrdinalIgnoreCase) || name.Contains("sha256sums", StringComparison.OrdinalIgnoreCase)) + { + try + { + var c = await (await _httpClient.GetAsync(url)).Content.ReadAsStringAsync(); + if (!string.IsNullOrEmpty(c)) checksumContent = c; + } + catch (Exception caughtEx_10) when (caughtEx_10 is not OperationCanceledException && caughtEx_10 is not OutOfMemoryException && caughtEx_10 is not StackOverflowException) + { + System.Diagnostics.Debug.WriteLine("Suppressed non-fatal exception in catch block."); + } + } + } + + // Then find a matching asset for platform/arch + foreach (var asset in assets.EnumerateArray()) + { + var name = asset.GetProperty("name").GetString() ?? string.Empty; + var url = asset.GetProperty("browser_download_url").GetString() ?? string.Empty; + if (string.IsNullOrEmpty(url)) continue; + if (!string.IsNullOrEmpty(arch) && !name.Contains(arch, StringComparison.OrdinalIgnoreCase)) continue; + if (name.EndsWith(".zip", StringComparison.OrdinalIgnoreCase) || name.EndsWith(".tar.xz", StringComparison.OrdinalIgnoreCase) || name.EndsWith(".tgz", StringComparison.OrdinalIgnoreCase) || name.EndsWith(".tar.gz", StringComparison.OrdinalIgnoreCase)) + { + chosenUrl = url; + break; + } + } + + if (!string.IsNullOrEmpty(chosenUrl)) return (chosenUrl, checksumContent); + } + } + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning(ex, "GitHub asset discovery failed for repo {Repo}", repo); + } + + return (null, null); + } + } +} From aee297d55edd3443b96b759ac71b38f266a3db01 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Sun, 14 Jun 2026 01:55:03 -0400 Subject: [PATCH 63/84] refactor: extract nzbget removal workflow - Move NZBGet history lookup and queue deletion into a focused workflow - Keep adapter removal behavior and logging unchanged --- .../Adapters/NzbgetAdapter.cs | 131 +------------- .../Adapters/NzbgetRemovalWorkflow.cs | 160 ++++++++++++++++++ 2 files changed, 163 insertions(+), 128 deletions(-) create mode 100644 listenarr.infrastructure/Adapters/NzbgetRemovalWorkflow.cs diff --git a/listenarr.infrastructure/Adapters/NzbgetAdapter.cs b/listenarr.infrastructure/Adapters/NzbgetAdapter.cs index 2be7e3771..1289cd966 100644 --- a/listenarr.infrastructure/Adapters/NzbgetAdapter.cs +++ b/listenarr.infrastructure/Adapters/NzbgetAdapter.cs @@ -36,6 +36,7 @@ public class NzbgetAdapter : IDownloadClientAdapter private readonly NzbgetXmlRpcClient _xmlRpcClient; private readonly NzbgetNzbDownloader _nzbDownloader; private readonly NzbgetDownloadPollingWorkflow _downloadPollingWorkflow; + private readonly NzbgetRemovalWorkflow _removalWorkflow; public NzbgetAdapter( IHttpClientFactory httpClientFactory, @@ -48,6 +49,7 @@ public NzbgetAdapter( _xmlRpcClient = new NzbgetXmlRpcClient(_httpClientFactory, ClientType); _nzbDownloader = new NzbgetNzbDownloader(_httpClientFactory, ClientType, _logger); _downloadPollingWorkflow = new NzbgetDownloadPollingWorkflow(_httpClientFactory, _logger, ClientType); + _removalWorkflow = new NzbgetRemovalWorkflow(_xmlRpcClient, _logger); } public async Task<(bool Success, string Message)> TestConnectionAsync(DownloadClientConfiguration client, CancellationToken ct = default) @@ -250,134 +252,7 @@ public NzbgetAdapter( public async Task RemoveAsync(DownloadClientConfiguration client, string id, bool deleteFiles = false, CancellationToken ct = default) { - if (client == null) throw new ArgumentNullException(nameof(client)); - if (string.IsNullOrWhiteSpace(id)) throw new ArgumentNullException(nameof(id)); - - // First try to parse as numeric NZBID (for queue removal) - var numericId = NzbgetRequestPlanner.TryParseId(id); - - // If it's not a numeric ID, it might be a droneId (GUID from Listenarr) - // Try to find it in history first - if (!numericId.HasValue) - { - _logger.LogInformation("ID {Id} is not numeric, searching NZBGet history for matching download", LogRedaction.SanitizeText(id)); - - try - { - // Get history to find the NZBID by matching droneId - var historyResult = await _xmlRpcClient.CallAsync(client, "history", false); - var arrayData = historyResult.Element("array")?.Element("data"); - - var historyCount = arrayData?.Elements("value").Count() ?? 0; - _logger.LogInformation("NZBGet history contains {Count} entries", historyCount); - - if (arrayData != null) - { - foreach (var members in arrayData.Elements("value") - .Select(valueElement => valueElement.Element("struct")) - .Where(s => s != null) - .Select(s => s!.Elements("member").ToDictionary( - m => m.Element("name")?.Value ?? string.Empty, - m => m.Element("value")?.Elements().FirstOrDefault() - ))) - { - - // Log what fields this history entry has - _logger.LogInformation("History entry has fields: {Fields}", string.Join(", ", members.Keys)); - - // Check if this history entry has matching droneId in parameters - if (members.TryGetValue("Parameters", out var paramsElement)) - { - var paramsArray = paramsElement?.Element("array")?.Element("data"); - var paramCount = paramsArray?.Elements("value").Count() ?? 0; - _logger.LogInformation("History entry has {Count} parameters", paramCount); - - if (paramsArray != null) - { - foreach (var paramMembers in paramsArray.Elements("value") - .Select(paramValueElement => paramValueElement.Element("struct")) - .Where(ps => ps != null) - .Select(ps => ps!.Elements("member").ToDictionary( - m => m.Element("name")?.Value ?? string.Empty, - m => m.Element("value")?.Elements().FirstOrDefault()?.Value ?? string.Empty - ))) - { - - // Log all parameters for debugging - foreach (var pm in paramMembers) - { - _logger.LogDebug("NZBGet History Parameter: Name={Name}, Value={Value}", pm.Key, LogRedaction.SanitizeText(pm.Value)); - } - - if (paramMembers.TryGetValue("Name", out var paramName) && - paramMembers.TryGetValue("Value", out var paramValue) && - paramName == "*drone" && paramValue == id && - members.TryGetValue("ID", out var idElement) && - int.TryParse(idElement?.Value, out var foundNumericId)) - { - // Found matching droneId, get the NZBID - _logger.LogDebug("Found NZBID {NzbId} for droneId {DroneId} in history", foundNumericId, LogRedaction.SanitizeText(id)); - numericId = foundNumericId; - break; - } - } - } - } - - if (numericId.HasValue) break; - } - } - } - catch (Exception histEx) when (histEx is not OperationCanceledException && histEx is not OutOfMemoryException && histEx is not StackOverflowException) - { - _logger.LogDebug(histEx, "Failed to search NZBGet history for download {Id}", LogRedaction.SanitizeText(id)); - } - } - - if (!numericId.HasValue) - { - _logger.LogWarning("Cannot remove NZB {Id} - not found in queue or history", LogRedaction.SanitizeText(id)); - return false; - } - - // Try to remove from history first (for completed downloads) - try - { - var historyDeleteResult = await _xmlRpcClient.CallAsync(client, "editqueue", "HistoryDelete", 0, string.Empty, new[] { numericId.Value }); - var historySuccess = historyDeleteResult.Element("boolean")?.Value == "1"; - - if (historySuccess) - { - _logger.LogInformation("Removed NZB {Id} from NZBGet history (deleteFiles={DeleteFiles})", LogRedaction.SanitizeText(id), deleteFiles); - return true; - } - } - catch (Exception histEx) when (histEx is not OperationCanceledException && histEx is not OutOfMemoryException && histEx is not StackOverflowException) - { - _logger.LogDebug(histEx, "Could not remove {Id} from NZBGet history (may not be in history)", LogRedaction.SanitizeText(id)); - } - - // Fall back to queue removal (for active downloads) - try - { - var command = deleteFiles ? "GroupDeleteFinal" : "GroupDelete"; - var editResult = await _xmlRpcClient.CallAsync(client, "editqueue", command, 0, string.Empty, new[] { numericId.Value }); - var success = editResult.Element("boolean")?.Value == "1"; - - if (success) - { - _logger.LogInformation("Removed NZB {Id} from NZBGet queue (deleteFiles={DeleteFiles})", LogRedaction.SanitizeText(id), deleteFiles); - return true; - } - - _logger.LogWarning("NZBGet reported failure when removing {Id} from both history and queue", LogRedaction.SanitizeText(id)); - return false; - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogError(ex, "Error removing NZB {Id} from NZBGet", LogRedaction.SanitizeText(id)); - return false; - } + return await _removalWorkflow.RemoveAsync(client, id, deleteFiles, ct); } public async Task> GetQueueAsync(DownloadClientConfiguration client, CancellationToken ct = default) diff --git a/listenarr.infrastructure/Adapters/NzbgetRemovalWorkflow.cs b/listenarr.infrastructure/Adapters/NzbgetRemovalWorkflow.cs new file mode 100644 index 000000000..7ad24ec4c --- /dev/null +++ b/listenarr.infrastructure/Adapters/NzbgetRemovalWorkflow.cs @@ -0,0 +1,160 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +using Listenarr.Application.Security; +using Listenarr.Domain.Models; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Infrastructure.Adapters +{ + internal sealed class NzbgetRemovalWorkflow + { + private readonly NzbgetXmlRpcClient _xmlRpcClient; + private readonly ILogger _logger; + + public NzbgetRemovalWorkflow(NzbgetXmlRpcClient xmlRpcClient, ILogger logger) + { + _xmlRpcClient = xmlRpcClient; + _logger = logger; + } + + public async Task RemoveAsync(DownloadClientConfiguration client, string id, bool deleteFiles = false, CancellationToken ct = default) + { + if (client == null) throw new ArgumentNullException(nameof(client)); + if (string.IsNullOrWhiteSpace(id)) throw new ArgumentNullException(nameof(id)); + + // First try to parse as numeric NZBID (for queue removal) + var numericId = NzbgetRequestPlanner.TryParseId(id); + + // If it's not a numeric ID, it might be a droneId (GUID from Listenarr) + // Try to find it in history first + if (!numericId.HasValue) + { + _logger.LogInformation("ID {Id} is not numeric, searching NZBGet history for matching download", LogRedaction.SanitizeText(id)); + + try + { + // Get history to find the NZBID by matching droneId + var historyResult = await _xmlRpcClient.CallAsync(client, "history", false); + var arrayData = historyResult.Element("array")?.Element("data"); + + var historyCount = arrayData?.Elements("value").Count() ?? 0; + _logger.LogInformation("NZBGet history contains {Count} entries", historyCount); + + if (arrayData != null) + { + foreach (var members in arrayData.Elements("value") + .Select(valueElement => valueElement.Element("struct")) + .Where(s => s != null) + .Select(s => s!.Elements("member").ToDictionary( + m => m.Element("name")?.Value ?? string.Empty, + m => m.Element("value")?.Elements().FirstOrDefault() + ))) + { + + // Log what fields this history entry has + _logger.LogInformation("History entry has fields: {Fields}", string.Join(", ", members.Keys)); + + // Check if this history entry has matching droneId in parameters + if (members.TryGetValue("Parameters", out var paramsElement)) + { + var paramsArray = paramsElement?.Element("array")?.Element("data"); + var paramCount = paramsArray?.Elements("value").Count() ?? 0; + _logger.LogInformation("History entry has {Count} parameters", paramCount); + + if (paramsArray != null) + { + foreach (var paramMembers in paramsArray.Elements("value") + .Select(paramValueElement => paramValueElement.Element("struct")) + .Where(ps => ps != null) + .Select(ps => ps!.Elements("member").ToDictionary( + m => m.Element("name")?.Value ?? string.Empty, + m => m.Element("value")?.Elements().FirstOrDefault()?.Value ?? string.Empty + ))) + { + + // Log all parameters for debugging + foreach (var pm in paramMembers) + { + _logger.LogDebug("NZBGet History Parameter: Name={Name}, Value={Value}", pm.Key, LogRedaction.SanitizeText(pm.Value)); + } + + if (paramMembers.TryGetValue("Name", out var paramName) && + paramMembers.TryGetValue("Value", out var paramValue) && + paramName == "*drone" && paramValue == id && + members.TryGetValue("ID", out var idElement) && + int.TryParse(idElement?.Value, out var foundNumericId)) + { + // Found matching droneId, get the NZBID + _logger.LogDebug("Found NZBID {NzbId} for droneId {DroneId} in history", foundNumericId, LogRedaction.SanitizeText(id)); + numericId = foundNumericId; + break; + } + } + } + } + + if (numericId.HasValue) break; + } + } + } + catch (Exception histEx) when (histEx is not OperationCanceledException && histEx is not OutOfMemoryException && histEx is not StackOverflowException) + { + _logger.LogDebug(histEx, "Failed to search NZBGet history for download {Id}", LogRedaction.SanitizeText(id)); + } + } + + if (!numericId.HasValue) + { + _logger.LogWarning("Cannot remove NZB {Id} - not found in queue or history", LogRedaction.SanitizeText(id)); + return false; + } + + // Try to remove from history first (for completed downloads) + try + { + var historyDeleteResult = await _xmlRpcClient.CallAsync(client, "editqueue", "HistoryDelete", 0, string.Empty, new[] { numericId.Value }); + var historySuccess = historyDeleteResult.Element("boolean")?.Value == "1"; + + if (historySuccess) + { + _logger.LogInformation("Removed NZB {Id} from NZBGet history (deleteFiles={DeleteFiles})", LogRedaction.SanitizeText(id), deleteFiles); + return true; + } + } + catch (Exception histEx) when (histEx is not OperationCanceledException && histEx is not OutOfMemoryException && histEx is not StackOverflowException) + { + _logger.LogDebug(histEx, "Could not remove {Id} from NZBGet history (may not be in history)", LogRedaction.SanitizeText(id)); + } + + // Fall back to queue removal (for active downloads) + try + { + var command = deleteFiles ? "GroupDeleteFinal" : "GroupDelete"; + var editResult = await _xmlRpcClient.CallAsync(client, "editqueue", command, 0, string.Empty, new[] { numericId.Value }); + var success = editResult.Element("boolean")?.Value == "1"; + + if (success) + { + _logger.LogInformation("Removed NZB {Id} from NZBGet queue (deleteFiles={DeleteFiles})", LogRedaction.SanitizeText(id), deleteFiles); + return true; + } + + _logger.LogWarning("NZBGet reported failure when removing {Id} from both history and queue", LogRedaction.SanitizeText(id)); + return false; + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogError(ex, "Error removing NZB {Id} from NZBGet", LogRedaction.SanitizeText(id)); + return false; + } + } + } +} From 5b3a374221bfd9638e8c5be6527ddd63d7717105 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Sun, 14 Jun 2026 01:57:57 -0400 Subject: [PATCH 64/84] refactor: extract sabnzbd removal workflow - Move SABnzbd queue and history deletion into a focused workflow - Keep adapter removal behavior and logging unchanged --- .../Adapters/SabnzbdAdapter.cs | 95 +------------ .../Adapters/SabnzbdRemovalWorkflow.cs | 133 ++++++++++++++++++ 2 files changed, 136 insertions(+), 92 deletions(-) create mode 100644 listenarr.infrastructure/Adapters/SabnzbdRemovalWorkflow.cs diff --git a/listenarr.infrastructure/Adapters/SabnzbdAdapter.cs b/listenarr.infrastructure/Adapters/SabnzbdAdapter.cs index 99d44e3cb..9473d1b8a 100644 --- a/listenarr.infrastructure/Adapters/SabnzbdAdapter.cs +++ b/listenarr.infrastructure/Adapters/SabnzbdAdapter.cs @@ -37,6 +37,7 @@ public class SabnzbdAdapter : IDownloadClientAdapter private readonly IAppMetricsService _appMetricsService; private readonly SabnzbdRequestBuilder _requestBuilder; private readonly SabnzbdDownloadPollingWorkflow _downloadPollingWorkflow; + private readonly SabnzbdRemovalWorkflow _removalWorkflow; public SabnzbdAdapter( IHttpClientFactory httpFactory, @@ -50,6 +51,7 @@ public SabnzbdAdapter( _appMetricsService = appMetricsService; _requestBuilder = new SabnzbdRequestBuilder(); _downloadPollingWorkflow = new SabnzbdDownloadPollingWorkflow(_httpFactory, _requestBuilder, _appMetricsService, _logger, ClientType); + _removalWorkflow = new SabnzbdRemovalWorkflow(_httpFactory, _requestBuilder, _logger, ClientType); } public async Task<(bool Success, string Message)> TestConnectionAsync(DownloadClientConfiguration client, CancellationToken ct = default) @@ -178,98 +180,7 @@ public SabnzbdAdapter( public async Task RemoveAsync(DownloadClientConfiguration client, string id, bool deleteFiles = false, CancellationToken ct = default) { - if (client == null) throw new ArgumentNullException(nameof(client)); - if (string.IsNullOrEmpty(id)) throw new ArgumentNullException(nameof(id)); - - try - { - var requestContext = _requestBuilder.CreateContext(client); - if (!requestContext.HasApiKey) - { - _logger.LogWarning("SABnzbd API key not configured for {ClientName}", client.Name); - return false; - } - - var http = _httpFactory.CreateClient(ClientType); - bool removedFromQueue = false; - bool removedFromHistory = false; - - // Try to remove from queue first (for active downloads) - var queueRemoveUrl = _requestBuilder.BuildUrl(requestContext, new Dictionary - { - ["mode"] = "queue", - ["name"] = "delete", - ["value"] = id, - ["output"] = "json" - }); - if (deleteFiles) - queueRemoveUrl += "&del_files=1"; - - try - { - var queueResponse = await http.GetAsync(queueRemoveUrl, ct); - if (queueResponse.IsSuccessStatusCode) - { - var queueContent = await queueResponse.Content.ReadAsStringAsync(ct); - var queueDoc = JsonDocument.Parse(queueContent); - if (queueDoc.RootElement.TryGetProperty("status", out var queueStatus)) - { - removedFromQueue = queueStatus.GetBoolean(); - } - } - } - catch (Exception queueEx) when (queueEx is not OperationCanceledException && queueEx is not OutOfMemoryException && queueEx is not StackOverflowException) - { - _logger.LogDebug(queueEx, "Could not remove {DownloadId} from SABnzbd queue (may not be in queue)", id); - } - - // Try to remove from history (for completed downloads) - var historyRemoveUrl = _requestBuilder.BuildUrl(requestContext, new Dictionary - { - ["mode"] = "history", - ["name"] = "delete", - ["value"] = id, - ["output"] = "json" - }); - if (deleteFiles) - historyRemoveUrl += "&del_files=1"; - - try - { - var historyResponse = await http.GetAsync(historyRemoveUrl, ct); - if (historyResponse.IsSuccessStatusCode) - { - var historyContent = await historyResponse.Content.ReadAsStringAsync(ct); - var historyDoc = JsonDocument.Parse(historyContent); - if (historyDoc.RootElement.TryGetProperty("status", out var historyStatus)) - { - removedFromHistory = historyStatus.GetBoolean(); - } - } - } - catch (Exception historyEx) when (historyEx is not OperationCanceledException && historyEx is not OutOfMemoryException && historyEx is not StackOverflowException) - { - _logger.LogDebug(historyEx, "Could not remove {DownloadId} from SABnzbd history (may not be in history)", id); - } - - var success = removedFromQueue || removedFromHistory; - if (success) - { - _logger.LogInformation("Removed {DownloadId} from SABnzbd (queue: {Queue}, history: {History}, deleteFiles: {DeleteFiles})", - LogRedaction.SanitizeText(id), removedFromQueue, removedFromHistory, deleteFiles); - } - else - { - _logger.LogWarning("Failed to remove {DownloadId} from SABnzbd (not found in queue or history)", LogRedaction.SanitizeText(id)); - } - - return success; - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogError(ex, "Error removing from SABnzbd: {DownloadId}", LogRedaction.SanitizeText(id)); - return false; - } + return await _removalWorkflow.RemoveAsync(client, id, deleteFiles, ct); } public async Task> GetQueueAsync(DownloadClientConfiguration client, CancellationToken ct = default) diff --git a/listenarr.infrastructure/Adapters/SabnzbdRemovalWorkflow.cs b/listenarr.infrastructure/Adapters/SabnzbdRemovalWorkflow.cs new file mode 100644 index 000000000..8ebe4a14a --- /dev/null +++ b/listenarr.infrastructure/Adapters/SabnzbdRemovalWorkflow.cs @@ -0,0 +1,133 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +using System.Text.Json; +using Listenarr.Application.Security; +using Listenarr.Domain.Models; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Infrastructure.Adapters +{ + internal sealed class SabnzbdRemovalWorkflow + { + private readonly IHttpClientFactory _httpFactory; + private readonly SabnzbdRequestBuilder _requestBuilder; + private readonly ILogger _logger; + private readonly string _clientType; + + public SabnzbdRemovalWorkflow( + IHttpClientFactory httpFactory, + SabnzbdRequestBuilder requestBuilder, + ILogger logger, + string clientType) + { + _httpFactory = httpFactory; + _requestBuilder = requestBuilder; + _logger = logger; + _clientType = clientType; + } + + public async Task RemoveAsync(DownloadClientConfiguration client, string id, bool deleteFiles = false, CancellationToken ct = default) + { + if (client == null) throw new ArgumentNullException(nameof(client)); + if (string.IsNullOrEmpty(id)) throw new ArgumentNullException(nameof(id)); + + try + { + var requestContext = _requestBuilder.CreateContext(client); + if (!requestContext.HasApiKey) + { + _logger.LogWarning("SABnzbd API key not configured for {ClientName}", client.Name); + return false; + } + + var http = _httpFactory.CreateClient(_clientType); + bool removedFromQueue = false; + bool removedFromHistory = false; + + // Try to remove from queue first (for active downloads) + var queueRemoveUrl = _requestBuilder.BuildUrl(requestContext, new Dictionary + { + ["mode"] = "queue", + ["name"] = "delete", + ["value"] = id, + ["output"] = "json" + }); + if (deleteFiles) + queueRemoveUrl += "&del_files=1"; + + try + { + var queueResponse = await http.GetAsync(queueRemoveUrl, ct); + if (queueResponse.IsSuccessStatusCode) + { + var queueContent = await queueResponse.Content.ReadAsStringAsync(ct); + var queueDoc = JsonDocument.Parse(queueContent); + if (queueDoc.RootElement.TryGetProperty("status", out var queueStatus)) + { + removedFromQueue = queueStatus.GetBoolean(); + } + } + } + catch (Exception queueEx) when (queueEx is not OperationCanceledException && queueEx is not OutOfMemoryException && queueEx is not StackOverflowException) + { + _logger.LogDebug(queueEx, "Could not remove {DownloadId} from SABnzbd queue (may not be in queue)", id); + } + + // Try to remove from history (for completed downloads) + var historyRemoveUrl = _requestBuilder.BuildUrl(requestContext, new Dictionary + { + ["mode"] = "history", + ["name"] = "delete", + ["value"] = id, + ["output"] = "json" + }); + if (deleteFiles) + historyRemoveUrl += "&del_files=1"; + + try + { + var historyResponse = await http.GetAsync(historyRemoveUrl, ct); + if (historyResponse.IsSuccessStatusCode) + { + var historyContent = await historyResponse.Content.ReadAsStringAsync(ct); + var historyDoc = JsonDocument.Parse(historyContent); + if (historyDoc.RootElement.TryGetProperty("status", out var historyStatus)) + { + removedFromHistory = historyStatus.GetBoolean(); + } + } + } + catch (Exception historyEx) when (historyEx is not OperationCanceledException && historyEx is not OutOfMemoryException && historyEx is not StackOverflowException) + { + _logger.LogDebug(historyEx, "Could not remove {DownloadId} from SABnzbd history (may not be in history)", id); + } + + var success = removedFromQueue || removedFromHistory; + if (success) + { + _logger.LogInformation("Removed {DownloadId} from SABnzbd (queue: {Queue}, history: {History}, deleteFiles: {DeleteFiles})", + LogRedaction.SanitizeText(id), removedFromQueue, removedFromHistory, deleteFiles); + } + else + { + _logger.LogWarning("Failed to remove {DownloadId} from SABnzbd (not found in queue or history)", LogRedaction.SanitizeText(id)); + } + + return success; + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogError(ex, "Error removing from SABnzbd: {DownloadId}", LogRedaction.SanitizeText(id)); + return false; + } + } + } +} From b532b968601a2a02ca61fbe79bf3e5ba0122333b Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Sun, 14 Jun 2026 02:00:47 -0400 Subject: [PATCH 65/84] refactor: extract qbittorrent removal workflow - Move qBittorrent auth probing and torrent deletion into a focused workflow - Keep adapter removal semantics and logging unchanged --- .../Adapters/QbittorrentAdapter.cs | 56 +------------ .../Adapters/QbittorrentRemovalWorkflow.cs | 84 +++++++++++++++++++ 2 files changed, 87 insertions(+), 53 deletions(-) create mode 100644 listenarr.infrastructure/Adapters/QbittorrentRemovalWorkflow.cs diff --git a/listenarr.infrastructure/Adapters/QbittorrentAdapter.cs b/listenarr.infrastructure/Adapters/QbittorrentAdapter.cs index 926ecfc7c..ca66b4f22 100644 --- a/listenarr.infrastructure/Adapters/QbittorrentAdapter.cs +++ b/listenarr.infrastructure/Adapters/QbittorrentAdapter.cs @@ -42,6 +42,7 @@ public class QbittorrentAdapter : IDownloadClientAdapter private readonly QbittorrentAuthSession _authSession; private readonly QbittorrentConnectionTester _connectionTester; private readonly QbittorrentDownloadPollingWorkflow _downloadPollingWorkflow; + private readonly QbittorrentRemovalWorkflow _removalWorkflow; public QbittorrentAdapter(IHttpClientFactory httpFactory, ITorrentFileDownloader torrentFileDownloader, ILogger logger) { @@ -52,6 +53,7 @@ public QbittorrentAdapter(IHttpClientFactory httpFactory, ITorrentFileDownloader _authSession = new QbittorrentAuthSession(_logger); _connectionTester = new QbittorrentConnectionTester(_httpClientFactory, _logger, ClientType); _downloadPollingWorkflow = new QbittorrentDownloadPollingWorkflow(_logger); + _removalWorkflow = new QbittorrentRemovalWorkflow(_logger); } public async Task<(bool Success, string Message)> TestConnectionAsync(DownloadClientConfiguration client, CancellationToken ct = default) @@ -186,59 +188,7 @@ public async Task MarkItemAsImportedAsync(DownloadClientConfiguration clie public async Task RemoveAsync(DownloadClientConfiguration client, string id, bool deleteFiles = false, CancellationToken ct = default) { - ArgumentNullException.ThrowIfNull(client); - if (string.IsNullOrEmpty(id)) throw new ArgumentNullException(nameof(id)); - - var baseUrl = DownloadClientUriBuilder.BuildAuthority(client); - - try - { - using var httpClient = QbittorrentCookieSession.CreateClient(); - using var loginData = QbittorrentCookieSession.CreateLoginContent(client); - - using var loginResp = await httpClient.PostAsync($"{baseUrl}/api/v2/auth/login", loginData, ct); - if (!loginResp.IsSuccessStatusCode) - { - if (loginResp.StatusCode == HttpStatusCode.Forbidden) - { - // 403 may mean auth is disabled — probe a version endpoint to confirm - using var testResp = await httpClient.GetAsync($"{baseUrl}/api/v2/app/version", ct); - if (!testResp.IsSuccessStatusCode) - { - _logger.LogWarning("qBittorrent auth appears enabled and credentials are invalid for client {ClientId}", client.Id); - return false; - } - // Auth is disabled; fall through to the delete call - } - else - { - _logger.LogWarning("qBittorrent login failed with status {Status} for client {ClientId}", loginResp.StatusCode, client.Id); - return false; - } - } - - using var deleteData = new FormUrlEncodedContent(new[] - { - new KeyValuePair("hashes", id), - new KeyValuePair("deleteFiles", deleteFiles ? "true" : "false") - }); - - using var deleteResp = await httpClient.PostAsync($"{baseUrl}/api/v2/torrents/delete", deleteData, ct); - if (!deleteResp.IsSuccessStatusCode) - { - var body = await deleteResp.Content.ReadAsStringAsync(ct); - _logger.LogWarning("qBittorrent delete returned {Status}: {Body}", deleteResp.StatusCode, LogRedaction.RedactText(body, LogRedaction.GetSensitiveValuesFromEnvironment())); - return false; - } - - _logger.LogInformation("Removed torrent {Id} from qBittorrent (deleteFiles={DeleteFiles})", LogRedaction.SanitizeText(id), deleteFiles); - return true; - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogError(ex, "Error removing torrent from qBittorrent: {Id}", LogRedaction.SanitizeText(id)); - return false; - } + return await _removalWorkflow.RemoveAsync(client, id, deleteFiles, ct); } public async Task> GetQueueAsync(DownloadClientConfiguration client, CancellationToken ct = default) diff --git a/listenarr.infrastructure/Adapters/QbittorrentRemovalWorkflow.cs b/listenarr.infrastructure/Adapters/QbittorrentRemovalWorkflow.cs new file mode 100644 index 000000000..643ba1e38 --- /dev/null +++ b/listenarr.infrastructure/Adapters/QbittorrentRemovalWorkflow.cs @@ -0,0 +1,84 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +using System.Net; +using Listenarr.Application.Security; +using Listenarr.Domain.Models; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Infrastructure.Adapters +{ + internal sealed class QbittorrentRemovalWorkflow + { + private readonly ILogger _logger; + + public QbittorrentRemovalWorkflow(ILogger logger) + { + _logger = logger; + } + + public async Task RemoveAsync(DownloadClientConfiguration client, string id, bool deleteFiles = false, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(client); + if (string.IsNullOrEmpty(id)) throw new ArgumentNullException(nameof(id)); + + var baseUrl = DownloadClientUriBuilder.BuildAuthority(client); + + try + { + using var httpClient = QbittorrentCookieSession.CreateClient(); + using var loginData = QbittorrentCookieSession.CreateLoginContent(client); + + using var loginResp = await httpClient.PostAsync($"{baseUrl}/api/v2/auth/login", loginData, ct); + if (!loginResp.IsSuccessStatusCode) + { + if (loginResp.StatusCode == HttpStatusCode.Forbidden) + { + // 403 may mean auth is disabled — probe a version endpoint to confirm + using var testResp = await httpClient.GetAsync($"{baseUrl}/api/v2/app/version", ct); + if (!testResp.IsSuccessStatusCode) + { + _logger.LogWarning("qBittorrent auth appears enabled and credentials are invalid for client {ClientId}", client.Id); + return false; + } + // Auth is disabled; fall through to the delete call + } + else + { + _logger.LogWarning("qBittorrent login failed with status {Status} for client {ClientId}", loginResp.StatusCode, client.Id); + return false; + } + } + + using var deleteData = new FormUrlEncodedContent(new[] + { + new KeyValuePair("hashes", id), + new KeyValuePair("deleteFiles", deleteFiles ? "true" : "false") + }); + + using var deleteResp = await httpClient.PostAsync($"{baseUrl}/api/v2/torrents/delete", deleteData, ct); + if (!deleteResp.IsSuccessStatusCode) + { + var body = await deleteResp.Content.ReadAsStringAsync(ct); + _logger.LogWarning("qBittorrent delete returned {Status}: {Body}", deleteResp.StatusCode, LogRedaction.RedactText(body, LogRedaction.GetSensitiveValuesFromEnvironment())); + return false; + } + + _logger.LogInformation("Removed torrent {Id} from qBittorrent (deleteFiles={DeleteFiles})", LogRedaction.SanitizeText(id), deleteFiles); + return true; + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogError(ex, "Error removing torrent from qBittorrent: {Id}", LogRedaction.SanitizeText(id)); + return false; + } + } + } +} From 9f4825a93dff8e06c9fb21d65910ea07cca314e1 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Sun, 14 Jun 2026 02:03:17 -0400 Subject: [PATCH 66/84] refactor: extract transmission removal workflow - Move Transmission torrent-remove RPC payload handling into a focused workflow - Keep adapter removal semantics and logging unchanged --- .../Adapters/TransmissionAdapter.cs | 39 +---------- .../Adapters/TransmissionRemovalWorkflow.cs | 69 +++++++++++++++++++ 2 files changed, 72 insertions(+), 36 deletions(-) create mode 100644 listenarr.infrastructure/Adapters/TransmissionRemovalWorkflow.cs diff --git a/listenarr.infrastructure/Adapters/TransmissionAdapter.cs b/listenarr.infrastructure/Adapters/TransmissionAdapter.cs index 7ed165b8b..07d4e65ce 100644 --- a/listenarr.infrastructure/Adapters/TransmissionAdapter.cs +++ b/listenarr.infrastructure/Adapters/TransmissionAdapter.cs @@ -37,6 +37,7 @@ public class TransmissionAdapter : IDownloadClientAdapter private readonly TransmissionTorrentAddPlanner _torrentAddPlanner; private readonly TransmissionRpcClient _rpcClient; private readonly TransmissionDownloadPollingWorkflow _downloadPollingWorkflow; + private readonly TransmissionRemovalWorkflow _removalWorkflow; public TransmissionAdapter(IHttpClientFactory httpClientFactory, ITorrentFileDownloader torrentFileDownloader, ILogger logger) { @@ -46,6 +47,7 @@ public TransmissionAdapter(IHttpClientFactory httpClientFactory, ITorrentFileDow _torrentAddPlanner = new TransmissionTorrentAddPlanner(_torrentFileDownloader, _logger); _rpcClient = new TransmissionRpcClient(_httpClientFactory, ClientType, _logger); _downloadPollingWorkflow = new TransmissionDownloadPollingWorkflow(_httpClientFactory, _logger, ClientType); + _removalWorkflow = new TransmissionRemovalWorkflow(_rpcClient, _logger); } public async Task<(bool Success, string Message)> TestConnectionAsync(DownloadClientConfiguration client, CancellationToken ct = default) @@ -154,42 +156,7 @@ public TransmissionAdapter(IHttpClientFactory httpClientFactory, ITorrentFileDow public async Task RemoveAsync(DownloadClientConfiguration client, string id, bool deleteFiles = false, CancellationToken ct = default) { - if (client == null) throw new ArgumentNullException(nameof(client)); - if (string.IsNullOrWhiteSpace(id)) throw new ArgumentNullException(nameof(id)); - - var idsPayload = TransmissionRequestPlanner.ParseTransmissionIds(id); - var arguments = new Dictionary - { - ["ids"] = idsPayload, - ["delete-local-data"] = deleteFiles - }; - - // Use old format for compatibility with Transmission < 4.1.0 - var payload = new - { - method = "torrent-remove", - arguments, - tag = 2 - }; - - try - { - var response = await _rpcClient.InvokeAsync(client, payload, ct); - if (response.TryGetProperty("result", out var resultProp) && string.Equals(resultProp.GetString(), "success", StringComparison.OrdinalIgnoreCase)) - { - _logger.LogInformation("Removed torrent {Id} from Transmission (deleteFiles={DeleteFiles})", LogRedaction.SanitizeText(id), deleteFiles); - return true; - } - - var errorMsg = resultProp.ValueKind == JsonValueKind.String ? resultProp.GetString() ?? "Unknown error" : "Unknown error"; - _logger.LogWarning("Transmission failed to remove torrent {Id}: {Message}", LogRedaction.SanitizeText(id), LogRedaction.SanitizeText(errorMsg)); - return false; - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogError(ex, "Error removing torrent {Id} from Transmission", LogRedaction.SanitizeText(id)); - return false; - } + return await _removalWorkflow.RemoveAsync(client, id, deleteFiles, ct); } public async Task> GetQueueAsync(DownloadClientConfiguration client, CancellationToken ct = default) diff --git a/listenarr.infrastructure/Adapters/TransmissionRemovalWorkflow.cs b/listenarr.infrastructure/Adapters/TransmissionRemovalWorkflow.cs new file mode 100644 index 000000000..7ad4064c2 --- /dev/null +++ b/listenarr.infrastructure/Adapters/TransmissionRemovalWorkflow.cs @@ -0,0 +1,69 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +using System.Text.Json; +using Listenarr.Application.Security; +using Listenarr.Domain.Models; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Infrastructure.Adapters +{ + internal sealed class TransmissionRemovalWorkflow + { + private readonly TransmissionRpcClient _rpcClient; + private readonly ILogger _logger; + + public TransmissionRemovalWorkflow(TransmissionRpcClient rpcClient, ILogger logger) + { + _rpcClient = rpcClient; + _logger = logger; + } + + public async Task RemoveAsync(DownloadClientConfiguration client, string id, bool deleteFiles = false, CancellationToken ct = default) + { + if (client == null) throw new ArgumentNullException(nameof(client)); + if (string.IsNullOrWhiteSpace(id)) throw new ArgumentNullException(nameof(id)); + + var idsPayload = TransmissionRequestPlanner.ParseTransmissionIds(id); + var arguments = new Dictionary + { + ["ids"] = idsPayload, + ["delete-local-data"] = deleteFiles + }; + + // Use old format for compatibility with Transmission < 4.1.0 + var payload = new + { + method = "torrent-remove", + arguments, + tag = 2 + }; + + try + { + var response = await _rpcClient.InvokeAsync(client, payload, ct); + if (response.TryGetProperty("result", out var resultProp) && string.Equals(resultProp.GetString(), "success", StringComparison.OrdinalIgnoreCase)) + { + _logger.LogInformation("Removed torrent {Id} from Transmission (deleteFiles={DeleteFiles})", LogRedaction.SanitizeText(id), deleteFiles); + return true; + } + + var errorMsg = resultProp.ValueKind == JsonValueKind.String ? resultProp.GetString() ?? "Unknown error" : "Unknown error"; + _logger.LogWarning("Transmission failed to remove torrent {Id}: {Message}", LogRedaction.SanitizeText(id), LogRedaction.SanitizeText(errorMsg)); + return false; + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogError(ex, "Error removing torrent {Id} from Transmission", LogRedaction.SanitizeText(id)); + return false; + } + } + } +} From 129aeae8c43204c51ee59f001cec619ba94371b6 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Sun, 14 Jun 2026 02:08:19 -0400 Subject: [PATCH 67/84] refactor: extract queue candidate loader - Move queue repository candidate loading into a focused helper - Preserve DDL visibility rules and known client ID matching inputs - Update queue reconciliation test construction for the new helper --- .../Downloads/DownloadQueueCandidateLoader.cs | 103 ++++++++++++++++++ .../Downloads/DownloadQueueService.cs | 70 +----------- .../AppServiceRegistrationExtensions.cs | 1 + ...DownloadQueueServiceReconciliationTests.cs | 6 +- 4 files changed, 114 insertions(+), 66 deletions(-) create mode 100644 listenarr.application/Downloads/DownloadQueueCandidateLoader.cs diff --git a/listenarr.application/Downloads/DownloadQueueCandidateLoader.cs b/listenarr.application/Downloads/DownloadQueueCandidateLoader.cs new file mode 100644 index 000000000..7d376936e --- /dev/null +++ b/listenarr.application/Downloads/DownloadQueueCandidateLoader.cs @@ -0,0 +1,103 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +using Listenarr.Application.Interfaces.Repositories; +using Listenarr.Domain.Models; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Application.Downloads +{ + public sealed class DownloadQueueCandidateLoader( + IDownloadRepository downloadRepository, + IDownloadProcessingJobRepository downloadProcessingJobRepository, + ILogger logger) + { + public async Task LoadAsync() + { + var queueDisplayCandidates = await downloadRepository.GetQueueDisplayCandidatesAsync(); + var queueMatchingCandidates = await downloadRepository.GetQueueMatchingCandidatesAsync(); + var knownClientItemIds = await downloadRepository.GetKnownClientItemIdsAsync(); + + logger.LogInformation( + "Loaded {DisplayCount} queue display candidates, {MatchingCount} queue matching candidates, and {KnownClientIdCount} known client IDs", + queueDisplayCandidates.Count, + queueMatchingCandidates.Count, + knownClientItemIds.Count); + + var ddlDownloads = queueDisplayCandidates.Where(d => d.DownloadClientId == "DDL").ToList(); + var ddlToShow = await BuildVisibleDdlDownloadsAsync(ddlDownloads); + + var externalDownloads = queueDisplayCandidates + .Where(d => d.DownloadClientId != "DDL") + .ToList(); + + var visibleDownloads = ddlToShow.Concat(externalDownloads).ToList(); + var allKnownClientItemIds = new HashSet(knownClientItemIds, StringComparer.OrdinalIgnoreCase); + + logger.LogDebug( + "Final filtering result: {FinalCount} downloads to include in queue filtering ({DdlCount} DDL, {ExternalCount} external), {MatchingCount} in matching pool", + visibleDownloads.Count, + ddlToShow.Count, + externalDownloads.Count, + queueMatchingCandidates.Count); + + return new DownloadQueueCandidateSet( + visibleDownloads, + queueMatchingCandidates, + allKnownClientItemIds); + } + + private async Task> BuildVisibleDdlDownloadsAsync(List ddlDownloads) + { + var ddlToShow = new List(); + if (!ddlDownloads.Any()) + { + return ddlToShow; + } + + var ddlCompleted = ddlDownloads.Where(d => d.Status == DownloadStatus.Completed).ToList(); + if (ddlCompleted.Any()) + { + var completedIds = ddlCompleted.Select(d => d.Id).ToList(); + var pendingJobs = await downloadProcessingJobRepository.GetPendingDownloadIdsAsync(completedIds); + var allJobDownloads = await downloadProcessingJobRepository.GetAllJobDownloadIdsAsync(completedIds); + + var ddlCompletedToShow = ddlCompleted + .Where(d => pendingJobs.Contains(d.Id) || !allJobDownloads.Contains(d.Id)) + .ToList(); + + ddlToShow.AddRange(ddlCompletedToShow); + logger.LogInformation( + "DDL pending jobs count: {PendingJobs}, All job downloads count: {AllJobs}, DDL completed to show: {CompletedToShow}", + pendingJobs.Count, + allJobDownloads.Count, + ddlCompletedToShow.Count); + } + + ddlToShow.AddRange(ddlDownloads.Where(d => + d.Status != DownloadStatus.Completed && + d.Status != DownloadStatus.Moved)); + + return ddlToShow; + } + } + + public sealed record DownloadQueueCandidateSet( + List VisibleDownloads, + List MatchingDownloads, + HashSet KnownClientItemIds); +} diff --git a/listenarr.application/Downloads/DownloadQueueService.cs b/listenarr.application/Downloads/DownloadQueueService.cs index ad9d67752..509b84cec 100644 --- a/listenarr.application/Downloads/DownloadQueueService.cs +++ b/listenarr.application/Downloads/DownloadQueueService.cs @@ -33,7 +33,7 @@ public class DownloadQueueService( IMemoryCache cache, IConfigurationService configurationService, IDownloadRepository downloadRepository, - IDownloadProcessingJobRepository downloadProcessingJobRepository, + DownloadQueueCandidateLoader candidateLoader, IDownloadClientGateway clientGateway, IAppMetricsService metrics, ILogger logger) : IDownloadQueueService @@ -57,70 +57,10 @@ public async Task GetQueueSnapshotAsync() var enabledClients = downloadClients.Where(c => c.IsEnabled).ToList(); - List listenarrDownloads; - List allDownloadsForMatching; - var allKnownClientItemIds = new HashSet(StringComparer.OrdinalIgnoreCase); - - { - var queueDisplayCandidates = await downloadRepository.GetQueueDisplayCandidatesAsync(); - var queueMatchingCandidates = await downloadRepository.GetQueueMatchingCandidatesAsync(); - var knownClientItemIds = await downloadRepository.GetKnownClientItemIdsAsync(); - - logger.LogInformation( - "Loaded {DisplayCount} queue display candidates, {MatchingCount} queue matching candidates, and {KnownClientIdCount} known client IDs", - queueDisplayCandidates.Count, - queueMatchingCandidates.Count, - knownClientItemIds.Count); - - var ddlDownloads = queueDisplayCandidates.Where(d => d.DownloadClientId == "DDL").ToList(); - var ddlToShow = new List(); - - if (ddlDownloads.Any()) - { - var ddlCompleted = ddlDownloads.Where(d => d.Status == DownloadStatus.Completed).ToList(); - if (ddlCompleted.Any()) - { - var completedIds = ddlCompleted.Select(d => d.Id).ToList(); - var pendingJobs = await downloadProcessingJobRepository.GetPendingDownloadIdsAsync(completedIds); - var allJobDownloads = await downloadProcessingJobRepository.GetAllJobDownloadIdsAsync(completedIds); - - var ddlCompletedToShow = ddlCompleted - .Where(d => pendingJobs.Contains(d.Id) || !allJobDownloads.Contains(d.Id)) - .ToList(); - - ddlToShow.AddRange(ddlCompletedToShow); - logger.LogInformation( - "DDL pending jobs count: {PendingJobs}, All job downloads count: {AllJobs}, DDL completed to show: {CompletedToShow}", - pendingJobs.Count, - allJobDownloads.Count, - ddlCompletedToShow.Count); - } - - ddlToShow.AddRange(ddlDownloads.Where(d => - d.Status != DownloadStatus.Completed && - d.Status != DownloadStatus.Moved)); - } - - var externalDownloads = queueDisplayCandidates - .Where(d => d.DownloadClientId != "DDL") - .ToList(); - - listenarrDownloads = ddlToShow.Concat(externalDownloads).ToList(); - - allDownloadsForMatching = queueMatchingCandidates; - - foreach (var clientItemId in knownClientItemIds) - { - allKnownClientItemIds.Add(clientItemId); - } - - logger.LogDebug( - "Final filtering result: {FinalCount} downloads to include in queue filtering ({DdlCount} DDL, {ExternalCount} external), {MatchingCount} in matching pool", - listenarrDownloads.Count, - ddlToShow.Count, - externalDownloads.Count, - allDownloadsForMatching.Count); - } + var candidateSet = await candidateLoader.LoadAsync(); + var listenarrDownloads = candidateSet.VisibleDownloads; + var allDownloadsForMatching = candidateSet.MatchingDownloads; + var allKnownClientItemIds = candidateSet.KnownClientItemIds; ApplicationSettings? appSettings = await cache.GetOrCreateAsync("ApplicationSettings", async entry => { diff --git a/listenarr.infrastructure/Extensions/AppServiceRegistrationExtensions.cs b/listenarr.infrastructure/Extensions/AppServiceRegistrationExtensions.cs index 4e6665586..d16857757 100644 --- a/listenarr.infrastructure/Extensions/AppServiceRegistrationExtensions.cs +++ b/listenarr.infrastructure/Extensions/AppServiceRegistrationExtensions.cs @@ -96,6 +96,7 @@ public static IServiceCollection AddListenarrAppServices(this IServiceCollection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); // Queue service extracted from DownloadService to encapsulate queue-building and filtering services.AddScoped(); services.AddScoped(); diff --git a/tests/Features/Api/Services/DownloadQueueServiceReconciliationTests.cs b/tests/Features/Api/Services/DownloadQueueServiceReconciliationTests.cs index b8d0392a8..48ed5fc1b 100644 --- a/tests/Features/Api/Services/DownloadQueueServiceReconciliationTests.cs +++ b/tests/Features/Api/Services/DownloadQueueServiceReconciliationTests.cs @@ -45,12 +45,16 @@ private DownloadQueueService CreateService( httpFactory.Setup(h => h.CreateClient(It.IsAny())).Returns(httpClient); var scopeProvider = Track(new ServiceCollection().BuildServiceProvider()); var scopeFactory = scopeProvider.GetRequiredService(); + var candidateLoader = new DownloadQueueCandidateLoader( + downloadRepository, + processingJobRepository, + NullLogger.Instance); var service = new DownloadQueueService( resolvedMemoryCache, configurationService, downloadRepository, - processingJobRepository, + candidateLoader, clientGateway, metrics, NullLogger.Instance); From a2737e55f77a3381e4ea9b90ee1cd6fbdf162879 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Sun, 14 Jun 2026 02:13:06 -0400 Subject: [PATCH 68/84] refactor: extract download queue poller - Move client queue polling and stale snapshot fallback into a focused poller - Keep queue enrichment and orphan retention in DownloadQueueService - Preserve focused queue reconciliation test coverage --- .../Downloads/ClientQueueFetchResult.cs | 2 +- .../Downloads/DownloadClientQueuePoller.cs | 181 ++++++++++++++++++ .../Downloads/DownloadQueueService.cs | 145 +------------- .../Downloads/DownloadQueueSnapshotMapper.cs | 2 +- .../AppServiceRegistrationExtensions.cs | 1 + ...DownloadQueueServiceReconciliationTests.cs | 7 +- 6 files changed, 196 insertions(+), 142 deletions(-) create mode 100644 listenarr.application/Downloads/DownloadClientQueuePoller.cs diff --git a/listenarr.application/Downloads/ClientQueueFetchResult.cs b/listenarr.application/Downloads/ClientQueueFetchResult.cs index 9144c683c..26451d556 100644 --- a/listenarr.application/Downloads/ClientQueueFetchResult.cs +++ b/listenarr.application/Downloads/ClientQueueFetchResult.cs @@ -19,7 +19,7 @@ namespace Listenarr.Application.Downloads { - internal sealed class ClientQueueFetchResult + public sealed class ClientQueueFetchResult { public ClientQueueFetchResult( DownloadClientConfiguration client, diff --git a/listenarr.application/Downloads/DownloadClientQueuePoller.cs b/listenarr.application/Downloads/DownloadClientQueuePoller.cs new file mode 100644 index 000000000..cba99a656 --- /dev/null +++ b/listenarr.application/Downloads/DownloadClientQueuePoller.cs @@ -0,0 +1,181 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +using System.Diagnostics; +using Listenarr.Application.Interfaces; +using Listenarr.Domain.Models; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Application.Downloads +{ + public sealed class DownloadClientQueuePoller( + IMemoryCache cache, + IDownloadClientGateway clientGateway, + IAppMetricsService metrics, + ILogger logger) + { + public async Task> FetchAsync( + List enabledClients, + TimeSpan clientQueueTimeout, + TimeSpan staleSnapshotMaxAge, + int maxParallelClientPolls) + { + if (enabledClients == null || enabledClients.Count == 0) + { + return new List(); + } + + using var throttler = new SemaphoreSlim(maxParallelClientPolls); + var tasks = enabledClients + .Select(client => FetchAsync(client, throttler, clientQueueTimeout, staleSnapshotMaxAge)) + .ToArray(); + + var results = await Task.WhenAll(tasks); + return results.ToList(); + } + + private async Task FetchAsync( + DownloadClientConfiguration client, + SemaphoreSlim throttler, + TimeSpan clientQueueTimeout, + TimeSpan staleSnapshotMaxAge) + { + await throttler.WaitAsync(); + try + { + return await FetchAsync(client, clientQueueTimeout, staleSnapshotMaxAge); + } + finally + { + throttler.Release(); + } + } + + private async Task FetchAsync( + DownloadClientConfiguration client, + TimeSpan clientQueueTimeout, + TimeSpan staleSnapshotMaxAge) + { + var stopwatch = Stopwatch.StartNew(); + using var timeoutCts = new CancellationTokenSource(); + + try + { + var pollTask = clientGateway.GetQueueAsync(client, timeoutCts.Token); + var completedTask = await Task.WhenAny(pollTask, Task.Delay(clientQueueTimeout)); + if (completedTask != pollTask) + { + timeoutCts.Cancel(); + DownloadQueueDiagnostics.ObserveFaultedPollTask(pollTask, client, logger); + + stopwatch.Stop(); + DownloadQueueDiagnostics.TryIncrementMetric(metrics, "download.queue.client.poll.timeout"); + DownloadQueueDiagnostics.TryTimingMetric(metrics, "download.queue.client.poll.duration", stopwatch.Elapsed); + return BuildFallbackQueueResult(client, staleSnapshotMaxAge, "timeout"); + } + + var clientQueue = await pollTask; + stopwatch.Stop(); + var refreshedAtUtc = DateTimeOffset.UtcNow; + + CacheClientQueueSnapshot(client, clientQueue, staleSnapshotMaxAge, refreshedAtUtc); + DownloadQueueDiagnostics.TryTimingMetric(metrics, "download.queue.client.poll.duration", stopwatch.Elapsed); + + return new ClientQueueFetchResult( + client, + DownloadQueueSnapshotMapper.CloneQueueItems(clientQueue), + usedCachedSnapshot: false, + isUnavailable: false, + snapshotAge: null, + failureReason: null, + snapshotState: "live", + snapshotRefreshedAtUtc: refreshedAtUtc); + } + catch (OperationCanceledException) when (!timeoutCts.IsCancellationRequested) + { + stopwatch.Stop(); + DownloadQueueDiagnostics.TryIncrementMetric(metrics, "download.queue.client.poll.failure"); + DownloadQueueDiagnostics.TryTimingMetric(metrics, "download.queue.client.poll.duration", stopwatch.Elapsed); + + logger.LogWarning("Queue poll for client {ClientName} was canceled before timeout; using fallback behavior", client.Name ?? client.Id); + return BuildFallbackQueueResult(client, staleSnapshotMaxAge, "canceled"); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + stopwatch.Stop(); + DownloadQueueDiagnostics.TryIncrementMetric(metrics, "download.queue.client.poll.failure"); + DownloadQueueDiagnostics.TryTimingMetric(metrics, "download.queue.client.poll.duration", stopwatch.Elapsed); + + logger.LogWarning(ex, "Error getting queue snapshot from download client {ClientName}", client.Name ?? client.Id); + return BuildFallbackQueueResult(client, staleSnapshotMaxAge, "error"); + } + } + + private ClientQueueFetchResult BuildFallbackQueueResult( + DownloadClientConfiguration client, + TimeSpan staleSnapshotMaxAge, + string failureReason) + { + if (cache.TryGetValue(DownloadQueueSnapshotMapper.GetClientQueueSnapshotCacheKey(client), out ClientQueueSnapshotCacheEntry? cachedSnapshot) && + cachedSnapshot != null) + { + var snapshotAge = DateTimeOffset.UtcNow - cachedSnapshot.RefreshedAtUtc; + if (snapshotAge <= staleSnapshotMaxAge) + { + DownloadQueueDiagnostics.TryIncrementMetric(metrics, "download.queue.client.snapshot.fallback"); + + return new ClientQueueFetchResult( + client, + DownloadQueueSnapshotMapper.CloneQueueItems(cachedSnapshot.QueueItems), + usedCachedSnapshot: true, + isUnavailable: false, + snapshotAge: snapshotAge, + failureReason: failureReason, + snapshotState: "cached", + snapshotRefreshedAtUtc: cachedSnapshot.RefreshedAtUtc); + } + } + + return new ClientQueueFetchResult( + client, + new List(), + usedCachedSnapshot: false, + isUnavailable: true, + snapshotAge: null, + failureReason: failureReason, + snapshotState: "unavailable", + snapshotRefreshedAtUtc: null); + } + + private void CacheClientQueueSnapshot( + DownloadClientConfiguration client, + List clientQueue, + TimeSpan staleSnapshotMaxAge, + DateTimeOffset refreshedAtUtc) + { + var cacheEntry = new ClientQueueSnapshotCacheEntry(DownloadQueueSnapshotMapper.CloneQueueItems(clientQueue), refreshedAtUtc); + cache.Set( + DownloadQueueSnapshotMapper.GetClientQueueSnapshotCacheKey(client), + cacheEntry, + new MemoryCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = staleSnapshotMaxAge + }); + } + } +} diff --git a/listenarr.application/Downloads/DownloadQueueService.cs b/listenarr.application/Downloads/DownloadQueueService.cs index 509b84cec..51ec78e0e 100644 --- a/listenarr.application/Downloads/DownloadQueueService.cs +++ b/listenarr.application/Downloads/DownloadQueueService.cs @@ -15,7 +15,6 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -using System.Diagnostics; using Listenarr.Application.Interfaces; using Listenarr.Application.Interfaces.Repositories; using Listenarr.Domain.Models; @@ -34,7 +33,7 @@ public class DownloadQueueService( IConfigurationService configurationService, IDownloadRepository downloadRepository, DownloadQueueCandidateLoader candidateLoader, - IDownloadClientGateway clientGateway, + DownloadClientQueuePoller clientQueuePoller, IAppMetricsService metrics, ILogger logger) : IDownloadQueueService { @@ -77,7 +76,11 @@ public async Task GetQueueSnapshotAsync() }); var includeCompletedExternal = appSettings != null && appSettings.ShowCompletedExternalDownloads; - var clientQueueResults = await FetchClientQueueResultsAsync(enabledClients); + var clientQueueResults = await clientQueuePoller.FetchAsync( + enabledClients, + _clientQueueTimeout, + _staleSnapshotMaxAge, + _maxParallelClientPolls); var clientStatuses = DownloadQueueSnapshotMapper.BuildClientStatuses(clientQueueResults); foreach (var clientQueueResult in clientQueueResults) @@ -413,142 +416,6 @@ public async Task> GetQueueAsync() return snapshot.Items; } - private async Task> FetchClientQueueResultsAsync(List enabledClients) - { - if (enabledClients == null || enabledClients.Count == 0) - { - return new List(); - } - - using var throttler = new SemaphoreSlim(_maxParallelClientPolls); - var tasks = enabledClients - .Select(client => FetchClientQueueResultAsync(client, throttler)) - .ToArray(); - - var results = await Task.WhenAll(tasks); - return results.ToList(); - } - - private async Task FetchClientQueueResultAsync( - DownloadClientConfiguration client, - SemaphoreSlim throttler) - { - await throttler.WaitAsync(); - try - { - return await FetchClientQueueResultAsync(client); - } - finally - { - throttler.Release(); - } - } - - private async Task FetchClientQueueResultAsync(DownloadClientConfiguration client) - { - var stopwatch = Stopwatch.StartNew(); - using var timeoutCts = new CancellationTokenSource(); - - try - { - var pollTask = clientGateway.GetQueueAsync(client, timeoutCts.Token); - var completedTask = await Task.WhenAny(pollTask, Task.Delay(_clientQueueTimeout)); - if (completedTask != pollTask) - { - timeoutCts.Cancel(); - DownloadQueueDiagnostics.ObserveFaultedPollTask(pollTask, client, logger); - - stopwatch.Stop(); - DownloadQueueDiagnostics.TryIncrementMetric(metrics, "download.queue.client.poll.timeout"); - DownloadQueueDiagnostics.TryTimingMetric(metrics, "download.queue.client.poll.duration", stopwatch.Elapsed); - return BuildFallbackQueueResult(client, "timeout"); - } - - var clientQueue = await pollTask; - stopwatch.Stop(); - var refreshedAtUtc = DateTimeOffset.UtcNow; - - CacheClientQueueSnapshot(client, clientQueue, refreshedAtUtc); - DownloadQueueDiagnostics.TryTimingMetric(metrics, "download.queue.client.poll.duration", stopwatch.Elapsed); - - return new ClientQueueFetchResult( - client, - DownloadQueueSnapshotMapper.CloneQueueItems(clientQueue), - usedCachedSnapshot: false, - isUnavailable: false, - snapshotAge: null, - failureReason: null, - snapshotState: "live", - snapshotRefreshedAtUtc: refreshedAtUtc); - } - catch (OperationCanceledException) when (!timeoutCts.IsCancellationRequested) - { - stopwatch.Stop(); - DownloadQueueDiagnostics.TryIncrementMetric(metrics, "download.queue.client.poll.failure"); - DownloadQueueDiagnostics.TryTimingMetric(metrics, "download.queue.client.poll.duration", stopwatch.Elapsed); - - logger.LogWarning("Queue poll for client {ClientName} was canceled before timeout; using fallback behavior", client.Name ?? client.Id); - return BuildFallbackQueueResult(client, "canceled"); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - stopwatch.Stop(); - DownloadQueueDiagnostics.TryIncrementMetric(metrics, "download.queue.client.poll.failure"); - DownloadQueueDiagnostics.TryTimingMetric(metrics, "download.queue.client.poll.duration", stopwatch.Elapsed); - - logger.LogWarning(ex, "Error getting queue snapshot from download client {ClientName}", client.Name ?? client.Id); - return BuildFallbackQueueResult(client, "error"); - } - } - - private ClientQueueFetchResult BuildFallbackQueueResult(DownloadClientConfiguration client, string failureReason) - { - if (cache.TryGetValue(DownloadQueueSnapshotMapper.GetClientQueueSnapshotCacheKey(client), out ClientQueueSnapshotCacheEntry? cachedSnapshot) && - cachedSnapshot != null) - { - var snapshotAge = DateTimeOffset.UtcNow - cachedSnapshot.RefreshedAtUtc; - if (snapshotAge <= _staleSnapshotMaxAge) - { - DownloadQueueDiagnostics.TryIncrementMetric(metrics, "download.queue.client.snapshot.fallback"); - - return new ClientQueueFetchResult( - client, - DownloadQueueSnapshotMapper.CloneQueueItems(cachedSnapshot.QueueItems), - usedCachedSnapshot: true, - isUnavailable: false, - snapshotAge: snapshotAge, - failureReason: failureReason, - snapshotState: "cached", - snapshotRefreshedAtUtc: cachedSnapshot.RefreshedAtUtc); - } - } - - return new ClientQueueFetchResult( - client, - new List(), - usedCachedSnapshot: false, - isUnavailable: true, - snapshotAge: null, - failureReason: failureReason, - snapshotState: "unavailable", - snapshotRefreshedAtUtc: null); - } - - private void CacheClientQueueSnapshot( - DownloadClientConfiguration client, - List clientQueue, - DateTimeOffset refreshedAtUtc) - { - var cacheEntry = new ClientQueueSnapshotCacheEntry(DownloadQueueSnapshotMapper.CloneQueueItems(clientQueue), refreshedAtUtc); - cache.Set( - DownloadQueueSnapshotMapper.GetClientQueueSnapshotCacheKey(client), - cacheEntry, - new MemoryCacheEntryOptions - { - AbsoluteExpirationRelativeToNow = _staleSnapshotMaxAge - }); - } - private async Task PersistDiscoveredClientIdentifiersAsync( Download matchedDownload, DownloadClientConfiguration client, diff --git a/listenarr.application/Downloads/DownloadQueueSnapshotMapper.cs b/listenarr.application/Downloads/DownloadQueueSnapshotMapper.cs index a46a2421c..db676a405 100644 --- a/listenarr.application/Downloads/DownloadQueueSnapshotMapper.cs +++ b/listenarr.application/Downloads/DownloadQueueSnapshotMapper.cs @@ -19,7 +19,7 @@ namespace Listenarr.Application.Downloads { - internal static class DownloadQueueSnapshotMapper + public static class DownloadQueueSnapshotMapper { public static string GetClientQueueSnapshotCacheKey(DownloadClientConfiguration client) { diff --git a/listenarr.infrastructure/Extensions/AppServiceRegistrationExtensions.cs b/listenarr.infrastructure/Extensions/AppServiceRegistrationExtensions.cs index d16857757..dd8903430 100644 --- a/listenarr.infrastructure/Extensions/AppServiceRegistrationExtensions.cs +++ b/listenarr.infrastructure/Extensions/AppServiceRegistrationExtensions.cs @@ -97,6 +97,7 @@ public static IServiceCollection AddListenarrAppServices(this IServiceCollection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); // Queue service extracted from DownloadService to encapsulate queue-building and filtering services.AddScoped(); services.AddScoped(); diff --git a/tests/Features/Api/Services/DownloadQueueServiceReconciliationTests.cs b/tests/Features/Api/Services/DownloadQueueServiceReconciliationTests.cs index 48ed5fc1b..300fd3f82 100644 --- a/tests/Features/Api/Services/DownloadQueueServiceReconciliationTests.cs +++ b/tests/Features/Api/Services/DownloadQueueServiceReconciliationTests.cs @@ -49,13 +49,18 @@ private DownloadQueueService CreateService( downloadRepository, processingJobRepository, NullLogger.Instance); + var clientQueuePoller = new DownloadClientQueuePoller( + resolvedMemoryCache, + clientGateway, + metrics, + NullLogger.Instance); var service = new DownloadQueueService( resolvedMemoryCache, configurationService, downloadRepository, candidateLoader, - clientGateway, + clientQueuePoller, metrics, NullLogger.Instance); From cc7eef11e9b76a1f7f665c4adba465dc013d3adb Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Sun, 14 Jun 2026 02:18:18 -0400 Subject: [PATCH 69/84] refactor: extract author catalog mapping - Move author catalog keying, matching, language normalization, and cache DTO mapping into a focused helper - Keep author catalog lookup and persistence orchestration in the service - Preserve focused author and metadata test coverage --- .../Audiobooks/AuthorCatalogMapping.cs | 343 ++++++++++++++++++ .../Audiobooks/AuthorCatalogService.cs | 318 +--------------- 2 files changed, 344 insertions(+), 317 deletions(-) create mode 100644 listenarr.application/Audiobooks/AuthorCatalogMapping.cs diff --git a/listenarr.application/Audiobooks/AuthorCatalogMapping.cs b/listenarr.application/Audiobooks/AuthorCatalogMapping.cs new file mode 100644 index 000000000..26f51e297 --- /dev/null +++ b/listenarr.application/Audiobooks/AuthorCatalogMapping.cs @@ -0,0 +1,343 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +using Listenarr.Application.Metadata; +using Listenarr.Domain.Models; + +namespace Listenarr.Application.Audiobooks +{ + internal static class AuthorCatalogMapping + { + private static readonly char[] AuthorCandidateSeparators = [',', ';', '&']; + private static readonly Dictionary LanguageAliases = new(StringComparer.OrdinalIgnoreCase) + { + ["english"] = "english", + ["en"] = "english", + ["eng"] = "english", + ["en-us"] = "english", + ["en-gb"] = "english", + ["spanish"] = "spanish", + ["es"] = "spanish", + ["spa"] = "spanish", + ["es-es"] = "spanish", + ["german"] = "german", + ["de"] = "german", + ["deu"] = "german", + ["ger"] = "german", + ["de-de"] = "german", + ["hungarian"] = "hungarian", + ["hu"] = "hungarian", + ["hun"] = "hungarian", + ["french"] = "french", + ["fr"] = "french", + ["fra"] = "french", + ["fre"] = "french", + ["fr-fr"] = "french", + ["polish"] = "polish", + ["pl"] = "polish", + ["pol"] = "polish", + ["pl-pl"] = "polish", + ["italian"] = "italian", + ["it"] = "italian", + ["ita"] = "italian", + ["it-it"] = "italian", + ["russian"] = "russian", + ["ru"] = "russian", + ["rus"] = "russian", + ["ru-ru"] = "russian", + ["all"] = "all" + }; + + public static string BuildAuthorCatalogBookKey(AudibleSearchResult book) + { + if (!string.IsNullOrWhiteSpace(book.Asin)) + { + return $"asin:{NormalizeCatalogToken(book.Asin)}"; + } + + var title = NormalizeCatalogToken(book.Title); + var authors = string.Join("|", (book.Authors ?? new List()) + .Select(a => NormalizeCatalogToken(a.Name)) + .Where(a => !string.IsNullOrWhiteSpace(a))); + + return $"title:{title}:authors:{authors}"; + } + + public static bool ShouldSupplementWithSearchFallback(int currentCount, int totalLimit) + { + if (currentCount == 0) + { + return true; + } + + return currentCount < Math.Min(3, totalLimit); + } + + public static bool MatchesAuthor(MetadataSearchResult result, string authorName) + { + var target = NormalizeAuthorMatchToken(authorName); + if (string.IsNullOrWhiteSpace(target)) + { + return false; + } + + return ExpandAuthorCandidates(result) + .Any(candidate => NormalizeAuthorMatchToken(candidate) == target); + } + + public static AudibleSearchResult MapFallbackSearchResult(MetadataSearchResult result) + { + var authors = ExpandAuthorCandidates(result) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Select(author => new AudibleAuthor { Name = author }) + .ToList(); + + var narrators = string.IsNullOrWhiteSpace(result.Narrator) + ? new List() + : new List { new() { Name = result.Narrator.Trim() } }; + + var genres = (result.Genres ?? new List()) + .Where(genre => !string.IsNullOrWhiteSpace(genre)) + .Select(genre => new AudibleGenre { Name = genre }) + .ToList(); + + var series = string.IsNullOrWhiteSpace(result.Series) + ? null + : new List + { + new() + { + Name = result.Series, + Position = result.SeriesNumber + } + }; + + return new AudibleSearchResult + { + Asin = result.Asin, + Title = result.Title, + Subtitle = result.Subtitle, + Authors = authors, + ImageUrl = result.ImageUrl, + Language = result.Language, + Publisher = result.Publisher, + Narrators = narrators, + Genres = genres, + Series = series, + ReleaseDate = result.PublishedDate, + Link = result.ProductUrl ?? result.SourceLink, + Isbn = result.Isbn.FirstOrDefault() + }; + } + + public static AuthorLookupItem MapCachedAuthor(AuthorCacheEntry entry, string fallbackName, string region) + { + return new AuthorLookupItem + { + Asin = entry.AuthorAsin, + Name = string.IsNullOrWhiteSpace(entry.AuthorName) ? fallbackName : entry.AuthorName, + Image = entry.ImageUrl, + Description = entry.Description, + Region = region + }; + } + + public static AudibleSearchResult MapCachedCatalogBook(CachedAuthorCatalogBook book) + { + return new AudibleSearchResult + { + Asin = book.Asin, + Title = book.Title, + Subtitle = book.Subtitle, + Authors = (book.Authors ?? new List()) + .Where(author => !string.IsNullOrWhiteSpace(author)) + .Select(author => new AudibleAuthor { Name = author }) + .ToList(), + ImageUrl = book.ImageUrl, + LengthMinutes = book.Runtime, + RuntimeLengthMin = book.Runtime, + Language = book.Language, + Publisher = book.Publisher, + Narrators = (book.Narrators ?? new List()) + .Where(narrator => !string.IsNullOrWhiteSpace(narrator)) + .Select(narrator => new AudibleNarrator { Name = narrator }) + .ToList(), + Genres = (book.Genres ?? new List()) + .Where(genre => !string.IsNullOrWhiteSpace(genre)) + .Select(genre => new AudibleGenre { Name = genre }) + .ToList(), + Series = string.IsNullOrWhiteSpace(book.Series) + ? null + : new List + { + new() + { + Name = book.Series, + Position = book.SeriesNumber + } + }, + ReleaseDate = book.PublishedDate, + Isbn = book.Isbn, + Link = book.Link + }; + } + + public static CachedAuthorCatalogBook MapCachedCatalogBook(AudibleSearchResult book) + { + var primarySeries = book.Series?.FirstOrDefault(); + var runtime = book.LengthMinutes ?? book.RuntimeLengthMin ?? book.RuntimeMinutes; + + return new CachedAuthorCatalogBook + { + Asin = book.Asin, + Title = book.Title ?? string.Empty, + Subtitle = book.Subtitle, + Authors = (book.Authors ?? new List()) + .Select(author => author.Name) + .Where(author => !string.IsNullOrWhiteSpace(author)) + .Cast() + .ToList(), + ImageUrl = book.ImageUrl, + Runtime = runtime, + Language = book.Language, + Publisher = book.Publisher, + Narrators = (book.Narrators ?? new List()) + .Select(narrator => narrator.Name) + .Where(narrator => !string.IsNullOrWhiteSpace(narrator)) + .Cast() + .ToList(), + Genres = (book.Genres ?? new List()) + .Select(genre => genre.Name) + .Where(genre => !string.IsNullOrWhiteSpace(genre)) + .Cast() + .ToList(), + Series = primarySeries?.Name, + SeriesNumber = primarySeries?.Position, + PublishedDate = book.ReleaseDate, + Isbn = book.Isbn, + Link = book.Link, + MetadataSource = "Audible" + }; + } + + public static List FilterCatalogByLanguage( + IEnumerable books, + string? preferredLanguage) + { + var materialized = books.ToList(); + if (string.IsNullOrWhiteSpace(preferredLanguage)) + { + return materialized; + } + + return materialized + .Where(book => string.Equals( + NormalizeLanguage(book.Language), + preferredLanguage, + StringComparison.OrdinalIgnoreCase)) + .ToList(); + } + + public static string NormalizeAuthorCacheKey(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return string.Empty; + } + + var cleaned = new string(value + .Where(character => char.IsLetterOrDigit(character) || char.IsWhiteSpace(character)) + .ToArray()); + var parts = cleaned.Split( + new[] { ' ', '\t', '\n', '\r' }, + StringSplitOptions.RemoveEmptyEntries); + + return string.Join(' ', parts).ToLowerInvariant(); + } + + public static string NormalizeRegion(string? region) + { + return AudiobookIdentifierNormalizer.NormalizeRegion(region) ?? "us"; + } + + public static string? NormalizeLanguage(string? language) + { + if (string.IsNullOrWhiteSpace(language)) + { + return null; + } + + var normalized = language.Trim().ToLowerInvariant(); + if (normalized == "all") + { + return null; + } + + return LanguageAliases.TryGetValue(normalized, out var alias) + ? alias + : normalized; + } + + private static string NormalizeCatalogToken(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return string.Empty; + } + + return new string(value.Trim().ToUpperInvariant().Where(char.IsLetterOrDigit).ToArray()); + } + + private static IEnumerable ExpandAuthorCandidates(MetadataSearchResult result) + { + var values = new[] + { + result.Author, + result.Artist + }; + + foreach (var value in values) + { + if (string.IsNullOrWhiteSpace(value)) + { + continue; + } + + foreach (var trimmed in value.Split( + AuthorCandidateSeparators, + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + yield return trimmed; + } + } + } + + private static string NormalizeAuthorMatchToken(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return string.Empty; + } + + return new string(value + .Trim() + .ToUpperInvariant() + .Where(char.IsLetterOrDigit) + .ToArray()); + } + } +} diff --git a/listenarr.application/Audiobooks/AuthorCatalogService.cs b/listenarr.application/Audiobooks/AuthorCatalogService.cs index 9d586bef3..7cf643390 100644 --- a/listenarr.application/Audiobooks/AuthorCatalogService.cs +++ b/listenarr.application/Audiobooks/AuthorCatalogService.cs @@ -20,51 +20,12 @@ using Listenarr.Application.Metadata; using Listenarr.Domain.Models; using Microsoft.Extensions.Logging; +using static Listenarr.Application.Audiobooks.AuthorCatalogMapping; namespace Listenarr.Application.Audiobooks { public class AuthorCatalogService : IAuthorCatalogService { - private static readonly char[] AuthorCandidateSeparators = [',', ';', '&']; - private static readonly Dictionary LanguageAliases = new(StringComparer.OrdinalIgnoreCase) - { - ["english"] = "english", - ["en"] = "english", - ["eng"] = "english", - ["en-us"] = "english", - ["en-gb"] = "english", - ["spanish"] = "spanish", - ["es"] = "spanish", - ["spa"] = "spanish", - ["es-es"] = "spanish", - ["german"] = "german", - ["de"] = "german", - ["deu"] = "german", - ["ger"] = "german", - ["de-de"] = "german", - ["hungarian"] = "hungarian", - ["hu"] = "hungarian", - ["hun"] = "hungarian", - ["french"] = "french", - ["fr"] = "french", - ["fra"] = "french", - ["fre"] = "french", - ["fr-fr"] = "french", - ["polish"] = "polish", - ["pl"] = "polish", - ["pol"] = "polish", - ["pl-pl"] = "polish", - ["italian"] = "italian", - ["it"] = "italian", - ["ita"] = "italian", - ["it-it"] = "italian", - ["russian"] = "russian", - ["ru"] = "russian", - ["rus"] = "russian", - ["ru-ru"] = "russian", - ["all"] = "all" - }; - private readonly AudibleService _audibleService; private readonly IAudnexusService _audnexusService; private readonly IAudiobookRepository _audiobookRepository; @@ -272,31 +233,6 @@ await PersistCatalogAsync( return null; } - private static string BuildAuthorCatalogBookKey(AudibleSearchResult book) - { - if (!string.IsNullOrWhiteSpace(book.Asin)) - { - return $"asin:{NormalizeCatalogToken(book.Asin)}"; - } - - var title = NormalizeCatalogToken(book.Title); - var authors = string.Join("|", (book.Authors ?? new List()) - .Select(a => NormalizeCatalogToken(a.Name)) - .Where(a => !string.IsNullOrWhiteSpace(a))); - - return $"title:{title}:authors:{authors}"; - } - - private static string NormalizeCatalogToken(string? value) - { - if (string.IsNullOrWhiteSpace(value)) - { - return string.Empty; - } - - return new string(value.Trim().ToUpperInvariant().Where(char.IsLetterOrDigit).ToArray()); - } - private async Task SupplementWithSearchFallbackAsync( string authorName, string region, @@ -356,111 +292,6 @@ private async Task SupplementWithSearchFallbackAsync( } } - private static bool ShouldSupplementWithSearchFallback(int currentCount, int totalLimit) - { - if (currentCount == 0) - { - return true; - } - - return currentCount < Math.Min(3, totalLimit); - } - - private static bool MatchesAuthor(MetadataSearchResult result, string authorName) - { - var target = NormalizeAuthorMatchToken(authorName); - if (string.IsNullOrWhiteSpace(target)) - { - return false; - } - - return ExpandAuthorCandidates(result) - .Any(candidate => NormalizeAuthorMatchToken(candidate) == target); - } - - private static IEnumerable ExpandAuthorCandidates(MetadataSearchResult result) - { - var values = new[] - { - result.Author, - result.Artist - }; - - foreach (var value in values) - { - if (string.IsNullOrWhiteSpace(value)) - { - continue; - } - - foreach (var trimmed in value.Split( - AuthorCandidateSeparators, - StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) - { - yield return trimmed; - } - } - } - - private static string NormalizeAuthorMatchToken(string? value) - { - if (string.IsNullOrWhiteSpace(value)) - { - return string.Empty; - } - - return new string(value - .Trim() - .ToUpperInvariant() - .Where(char.IsLetterOrDigit) - .ToArray()); - } - - private static AudibleSearchResult MapFallbackSearchResult(MetadataSearchResult result) - { - var authors = ExpandAuthorCandidates(result) - .Distinct(StringComparer.OrdinalIgnoreCase) - .Select(author => new AudibleAuthor { Name = author }) - .ToList(); - - var narrators = string.IsNullOrWhiteSpace(result.Narrator) - ? new List() - : new List { new() { Name = result.Narrator.Trim() } }; - - var genres = (result.Genres ?? new List()) - .Where(genre => !string.IsNullOrWhiteSpace(genre)) - .Select(genre => new AudibleGenre { Name = genre }) - .ToList(); - - var series = string.IsNullOrWhiteSpace(result.Series) - ? null - : new List - { - new() - { - Name = result.Series, - Position = result.SeriesNumber - } - }; - - return new AudibleSearchResult - { - Asin = result.Asin, - Title = result.Title, - Subtitle = result.Subtitle, - Authors = authors, - ImageUrl = result.ImageUrl, - Language = result.Language, - Publisher = result.Publisher, - Narrators = narrators, - Genres = genres, - Series = series, - ReleaseDate = result.PublishedDate, - Link = result.ProductUrl ?? result.SourceLink, - Isbn = result.Isbn.FirstOrDefault() - }; - } - private async Task PersistCatalogAsync( AuthorCacheEntry? cachedEntry, string authorName, @@ -491,152 +322,5 @@ private async Task PersistCatalogAsync( } } - private static AuthorLookupItem MapCachedAuthor(AuthorCacheEntry entry, string fallbackName, string region) - { - return new AuthorLookupItem - { - Asin = entry.AuthorAsin, - Name = string.IsNullOrWhiteSpace(entry.AuthorName) ? fallbackName : entry.AuthorName, - Image = entry.ImageUrl, - Description = entry.Description, - Region = region - }; - } - - private static AudibleSearchResult MapCachedCatalogBook(CachedAuthorCatalogBook book) - { - return new AudibleSearchResult - { - Asin = book.Asin, - Title = book.Title, - Subtitle = book.Subtitle, - Authors = (book.Authors ?? new List()) - .Where(author => !string.IsNullOrWhiteSpace(author)) - .Select(author => new AudibleAuthor { Name = author }) - .ToList(), - ImageUrl = book.ImageUrl, - LengthMinutes = book.Runtime, - RuntimeLengthMin = book.Runtime, - Language = book.Language, - Publisher = book.Publisher, - Narrators = (book.Narrators ?? new List()) - .Where(narrator => !string.IsNullOrWhiteSpace(narrator)) - .Select(narrator => new AudibleNarrator { Name = narrator }) - .ToList(), - Genres = (book.Genres ?? new List()) - .Where(genre => !string.IsNullOrWhiteSpace(genre)) - .Select(genre => new AudibleGenre { Name = genre }) - .ToList(), - Series = string.IsNullOrWhiteSpace(book.Series) - ? null - : new List - { - new() - { - Name = book.Series, - Position = book.SeriesNumber - } - }, - ReleaseDate = book.PublishedDate, - Isbn = book.Isbn, - Link = book.Link - }; - } - - private static CachedAuthorCatalogBook MapCachedCatalogBook(AudibleSearchResult book) - { - var primarySeries = book.Series?.FirstOrDefault(); - var runtime = book.LengthMinutes ?? book.RuntimeLengthMin ?? book.RuntimeMinutes; - - return new CachedAuthorCatalogBook - { - Asin = book.Asin, - Title = book.Title ?? string.Empty, - Subtitle = book.Subtitle, - Authors = (book.Authors ?? new List()) - .Select(author => author.Name) - .Where(author => !string.IsNullOrWhiteSpace(author)) - .Cast() - .ToList(), - ImageUrl = book.ImageUrl, - Runtime = runtime, - Language = book.Language, - Publisher = book.Publisher, - Narrators = (book.Narrators ?? new List()) - .Select(narrator => narrator.Name) - .Where(narrator => !string.IsNullOrWhiteSpace(narrator)) - .Cast() - .ToList(), - Genres = (book.Genres ?? new List()) - .Select(genre => genre.Name) - .Where(genre => !string.IsNullOrWhiteSpace(genre)) - .Cast() - .ToList(), - Series = primarySeries?.Name, - SeriesNumber = primarySeries?.Position, - PublishedDate = book.ReleaseDate, - Isbn = book.Isbn, - Link = book.Link, - MetadataSource = "Audible" - }; - } - - private static List FilterCatalogByLanguage( - IEnumerable books, - string? preferredLanguage) - { - var materialized = books.ToList(); - if (string.IsNullOrWhiteSpace(preferredLanguage)) - { - return materialized; - } - - return materialized - .Where(book => string.Equals( - NormalizeLanguage(book.Language), - preferredLanguage, - StringComparison.OrdinalIgnoreCase)) - .ToList(); - } - - private static string NormalizeAuthorCacheKey(string? value) - { - if (string.IsNullOrWhiteSpace(value)) - { - return string.Empty; - } - - var cleaned = new string(value - .Where(character => char.IsLetterOrDigit(character) || char.IsWhiteSpace(character)) - .ToArray()); - var parts = cleaned.Split( - new[] { ' ', '\t', '\n', '\r' }, - StringSplitOptions.RemoveEmptyEntries); - - return string.Join(' ', parts).ToLowerInvariant(); - } - - private static string NormalizeRegion(string? region) - { - return AudiobookIdentifierNormalizer.NormalizeRegion(region) ?? "us"; - } - - private static string? NormalizeLanguage(string? language) - { - if (string.IsNullOrWhiteSpace(language)) - { - return null; - } - - var normalized = language.Trim().ToLowerInvariant(); - if (normalized == "all") - { - return null; - } - - return LanguageAliases.TryGetValue(normalized, out var alias) - ? alias - : normalized; - } } } From 496e0151455f99417a72600f8a6a329babb3b0a0 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Sun, 14 Jun 2026 02:21:55 -0400 Subject: [PATCH 70/84] refactor: extract prowlarr response builder - Move compatibility indexer response shaping into a focused builder - Preserve read versus save API-key response behavior - Remove the stale private field DTO from the controller --- .../Controllers/ProwlarrCompatController.cs | 132 +----------------- .../ProwlarrCompatIndexerResponseBuilder.cs | 118 ++++++++++++++++ 2 files changed, 125 insertions(+), 125 deletions(-) create mode 100644 listenarr.api/Controllers/ProwlarrCompatIndexerResponseBuilder.cs diff --git a/listenarr.api/Controllers/ProwlarrCompatController.cs b/listenarr.api/Controllers/ProwlarrCompatController.cs index 1992a65c2..c8ed33f35 100644 --- a/listenarr.api/Controllers/ProwlarrCompatController.cs +++ b/listenarr.api/Controllers/ProwlarrCompatController.cs @@ -181,30 +181,7 @@ public async Task GetIndexers() var indexers = (await _indexerRepository.GetAllAsync()) .OrderBy(i => i.Priority) .ThenBy(i => i.Name) - .Select(i => new - { - id = i.Id, - name = i.Name, - implementation = i.Implementation, - baseUrl = i.Url, - apiKey = authEnabled ? i.ApiKey : null, - categories = string.IsNullOrEmpty(i.Categories) ? System.Array.Empty() : i.Categories.Split(',').Select(s => s.Trim()).ToArray(), - settings = new - { - baseUrl = i.Url, - apiKey = authEnabled ? i.ApiKey : null, - apiPath = string.Empty, - categories = string.IsNullOrEmpty(i.Categories) ? System.Array.Empty() : i.Categories.Split(',').Select(s => s.Trim()).ToArray() - }, - fields = new[] - { - new FieldDto("baseUrl", i.Url ?? string.Empty), - new FieldDto("apiKey", authEnabled ? i.ApiKey : null), - new FieldDto("apiPath", string.Empty), - new FieldDto("categories", string.IsNullOrEmpty(i.Categories) ? System.Array.Empty() : i.Categories.Split(',').Select(s => s.Trim()).ToArray()) - }, - tags = System.Array.Empty() - }) + .Select(i => ProwlarrCompatIndexerResponseBuilder.BuildReadIndexer(i, authEnabled)) .ToArray(); return Ok(indexers); } @@ -224,56 +201,9 @@ public async Task GetIndexerById(int id) var i = await _indexerRepository.GetByIdAsync(id); if (i == null) { - var fallback = new - { - id = id, - name = "Prowlarr Indexer", - implementation = "Newznab", - baseUrl = string.Empty, - apiKey = (string?)null, - categories = System.Array.Empty(), - settings = new - { - baseUrl = string.Empty, - apiKey = (string?)null, - apiPath = string.Empty, - categories = System.Array.Empty() - }, - fields = new[] - { - new FieldDto("baseUrl", string.Empty), - new FieldDto("apiKey", null), - new FieldDto("apiPath", string.Empty), - new FieldDto("categories", System.Array.Empty()) - }, - tags = System.Array.Empty() - }; - return Ok(fallback); + return Ok(ProwlarrCompatIndexerResponseBuilder.BuildFallbackIndexer(id)); } - var dto = new - { - id = i.Id, - name = i.Name, - implementation = i.Implementation, - baseUrl = i.Url, - apiKey = authEnabled ? i.ApiKey : null, - categories = string.IsNullOrEmpty(i.Categories) ? System.Array.Empty() : i.Categories.Split(',').Select(s => s.Trim()).ToArray(), - settings = new - { - baseUrl = i.Url, - apiKey = authEnabled ? i.ApiKey : null, - apiPath = string.Empty, - categories = string.IsNullOrEmpty(i.Categories) ? System.Array.Empty() : i.Categories.Split(',').Select(s => s.Trim()).ToArray() - }, - fields = new[] - { - new FieldDto("baseUrl", i.Url ?? string.Empty), - new FieldDto("apiKey", authEnabled ? i.ApiKey : null), - new FieldDto("apiPath", string.Empty), - new FieldDto("categories", string.IsNullOrEmpty(i.Categories) ? System.Array.Empty() : i.Categories.Split(',').Select(s => s.Trim()).ToArray()) - }, - tags = System.Array.Empty() - }; + var dto = ProwlarrCompatIndexerResponseBuilder.BuildReadIndexer(i, authEnabled); return Ok(dto); } @@ -398,30 +328,7 @@ public async Task PutIndexer(int id, [FromBody] System.Text.Json. await _indexerNotificationWorkflow.NotifyPutAsync(indexer, createdForBroadcast); // Return updated DTO (consistent with GetIndexerById shape) - var dto = new - { - id = indexer.Id, - name = indexer.Name, - implementation = indexer.Implementation, - baseUrl = indexer.Url, - apiKey = indexer.ApiKey, - categories = string.IsNullOrEmpty(indexer.Categories) ? System.Array.Empty() : indexer.Categories.Split(',').Select(s => s.Trim()).ToArray(), - settings = new - { - baseUrl = indexer.Url, - apiKey = indexer.ApiKey, - apiPath = string.Empty, - categories = string.IsNullOrEmpty(indexer.Categories) ? System.Array.Empty() : indexer.Categories.Split(',').Select(s => s.Trim()).ToArray() - }, - fields = new[] - { - new FieldDto("baseUrl", indexer.Url ?? string.Empty), - new FieldDto("apiKey", indexer.ApiKey ?? string.Empty), - new FieldDto("apiPath", string.Empty), - new FieldDto("categories", string.IsNullOrEmpty(indexer.Categories) ? System.Array.Empty() : indexer.Categories.Split(',').Select(s => s.Trim()).ToArray()) - }, - tags = System.Array.Empty() - }; + var dto = ProwlarrCompatIndexerResponseBuilder.BuildSavedIndexer(indexer); if (created) { @@ -492,31 +399,9 @@ public async Task PostIndexers([FromBody] System.Text.Json.JsonEl _logger?.LogInformation("Prowlarr: Indexers processed - created={Created}, skipped={Skipped}", created, skipped); // Include created indexers in the response (id will be populated after SaveChanges) - var createdDtos = createdIndexers.Select(i => new - { - id = i.Id, - name = i.Name, - implementation = i.Implementation, - baseUrl = i.Url, - apiKey = i.ApiKey, - categories = string.IsNullOrEmpty(i.Categories) ? System.Array.Empty() : i.Categories.Split(',').Select(s => s.Trim()).ToArray(), - settings = new - { - baseUrl = i.Url, - apiKey = i.ApiKey, - apiPath = string.Empty, - categories = string.IsNullOrEmpty(i.Categories) ? System.Array.Empty() : i.Categories.Split(',').Select(s => s.Trim()).ToArray() - }, - fields = new object[] - { - new FieldDto("baseUrl", i.Url ?? string.Empty), - new FieldDto("apiKey", i.ApiKey ?? string.Empty), - new FieldDto("apiPath", string.Empty), - new FieldDto("categories", string.IsNullOrEmpty(i.Categories) ? System.Array.Empty() : i.Categories.Split(',').Select(s => s.Trim()).ToArray()) - } - }).ToArray(); - - + var createdDtos = createdIndexers + .Select(ProwlarrCompatIndexerResponseBuilder.BuildSavedIndexer) + .ToArray(); return Ok(new { accepted = true, created, skipped, indexers = createdDtos }); } @@ -691,8 +576,5 @@ public record IndexerFieldDto public bool Required { get; init; } public string Description { get; init; } = string.Empty; } - - // Simple field DTO to match Listenarr/Prowlarr field shape (Name/Value) - private record FieldDto(string Name, object? Value); } } diff --git a/listenarr.api/Controllers/ProwlarrCompatIndexerResponseBuilder.cs b/listenarr.api/Controllers/ProwlarrCompatIndexerResponseBuilder.cs new file mode 100644 index 000000000..67c999465 --- /dev/null +++ b/listenarr.api/Controllers/ProwlarrCompatIndexerResponseBuilder.cs @@ -0,0 +1,118 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +using Listenarr.Domain.Models; + +namespace Listenarr.Api.Controllers +{ + internal static class ProwlarrCompatIndexerResponseBuilder + { + public static object BuildReadIndexer(Indexer indexer, bool authEnabled) + { + var categories = SplitCategories(indexer.Categories); + var apiKey = authEnabled ? indexer.ApiKey : null; + + return new + { + id = indexer.Id, + name = indexer.Name, + implementation = indexer.Implementation, + baseUrl = indexer.Url, + apiKey, + categories, + settings = new + { + baseUrl = indexer.Url, + apiKey, + apiPath = string.Empty, + categories + }, + fields = BuildFields(indexer.Url ?? string.Empty, apiKey, categories), + tags = Array.Empty() + }; + } + + public static object BuildSavedIndexer(Indexer indexer) + { + var categories = SplitCategories(indexer.Categories); + var apiKey = indexer.ApiKey ?? string.Empty; + + return new + { + id = indexer.Id, + name = indexer.Name, + implementation = indexer.Implementation, + baseUrl = indexer.Url, + apiKey, + categories, + settings = new + { + baseUrl = indexer.Url, + apiKey, + apiPath = string.Empty, + categories + }, + fields = BuildFields(indexer.Url ?? string.Empty, apiKey, categories), + tags = Array.Empty() + }; + } + + public static object BuildFallbackIndexer(int id) + { + var categories = Array.Empty(); + + return new + { + id, + name = "Prowlarr Indexer", + implementation = "Newznab", + baseUrl = string.Empty, + apiKey = (string?)null, + categories, + settings = new + { + baseUrl = string.Empty, + apiKey = (string?)null, + apiPath = string.Empty, + categories + }, + fields = BuildFields(string.Empty, null, categories), + tags = Array.Empty() + }; + } + + private static string[] SplitCategories(string? categories) + { + return string.IsNullOrEmpty(categories) + ? Array.Empty() + : categories.Split(',').Select(s => s.Trim()).ToArray(); + } + + private static ProwlarrCompatFieldDto[] BuildFields(string baseUrl, object? apiKey, string[] categories) + { + return + [ + new ProwlarrCompatFieldDto("baseUrl", baseUrl), + new ProwlarrCompatFieldDto("apiKey", apiKey), + new ProwlarrCompatFieldDto("apiPath", string.Empty), + new ProwlarrCompatFieldDto("categories", categories) + ]; + } + + private record ProwlarrCompatFieldDto(string Name, object? Value); + } +} From c02dcce5d807074d3c1da7de22c496636660ff16 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Sun, 14 Jun 2026 02:25:52 -0400 Subject: [PATCH 71/84] refactor: extract search disposition logger - Move final ASIN disposition auditing out of SearchService - Preserve candidate drop reason mutation and diagnostic logging - Register the helper in API and infrastructure service setup --- listenarr.api/Program.cs | 1 + .../Search/SearchFinalDispositionLogger.cs | 131 +++++++++++++++++ listenarr.application/Search/SearchService.cs | 135 ++---------------- .../AppServiceRegistrationExtensions.cs | 1 + 4 files changed, 147 insertions(+), 121 deletions(-) create mode 100644 listenarr.application/Search/SearchFinalDispositionLogger.cs diff --git a/listenarr.api/Program.cs b/listenarr.api/Program.cs index 9b6f22b64..60ad1c6b0 100644 --- a/listenarr.api/Program.cs +++ b/listenarr.api/Program.cs @@ -344,6 +344,7 @@ ex is IOException // Add search result scorer builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); // Add ASIN search handler builder.Services.AddScoped(); diff --git a/listenarr.application/Search/SearchFinalDispositionLogger.cs b/listenarr.application/Search/SearchFinalDispositionLogger.cs new file mode 100644 index 000000000..0c43e32ff --- /dev/null +++ b/listenarr.application/Search/SearchFinalDispositionLogger.cs @@ -0,0 +1,131 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +using Listenarr.Domain.Models; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Application.Search +{ + public sealed class SearchFinalDispositionLogger(ILogger logger) + { + public void LogFinalAsinDispositions( + IEnumerable asinCandidates, + List results, + List enrichedList, + IDictionary candidateDropReasons, + string query, + bool requireAuthorAndPublisher, + string containmentMode, + double fuzzyThreshold) + { + try + { + var finalAsinEntries = new List(); + + foreach (var asin in asinCandidates.Where(asin => !string.IsNullOrWhiteSpace(asin))) + { + if (results.Any(r => string.Equals(r.Asin, asin, StringComparison.OrdinalIgnoreCase))) + { + TrySetDropReason(candidateDropReasons, asin, "accepted"); + finalAsinEntries.Add($"{asin}:accepted"); + continue; + } + + var enrichedCandidate = enrichedList.FirstOrDefault(e => string.Equals(e.Asin, asin, StringComparison.OrdinalIgnoreCase)); + if (enrichedCandidate != null) + { + if (requireAuthorAndPublisher && (string.IsNullOrWhiteSpace(enrichedCandidate.Artist) || string.IsNullOrWhiteSpace(enrichedCandidate.Publisher))) + { + TrySetDropReason(candidateDropReasons, asin, "author_publisher_missing"); + finalAsinEntries.Add($"{asin}:author_publisher_missing"); + continue; + } + + if (SearchValidation.IsTitleNoise(enrichedCandidate.Title) || !SearchValidation.IsLikelyAudiobook(enrichedCandidate)) + { + TrySetDropReason(candidateDropReasons, asin, "filtered_title_or_not_likely"); + finalAsinEntries.Add($"{asin}:filtered_title_or_not_likely"); + continue; + } + + var containment = 0.0; + var fuzzy = 0.0; + try + { + containment = SearchResultMatchEvaluator.ComputeContainmentScore(enrichedCandidate, query); + fuzzy = SearchResultMatchEvaluator.ComputeFuzzySimilarity(enrichedCandidate.Title + " " + enrichedCandidate.Artist, query); + } + catch (Exception caughtEx) when (caughtEx is not OperationCanceledException && caughtEx is not OutOfMemoryException && caughtEx is not StackOverflowException) + { + System.Diagnostics.Debug.WriteLine("Suppressed non-fatal exception in catch block."); + } + + if (string.Equals(containmentMode, "Strict", StringComparison.OrdinalIgnoreCase)) + { + var hay = string.Join(" ", new[] { enrichedCandidate.Title, enrichedCandidate.Artist, enrichedCandidate.Album, enrichedCandidate.Description, enrichedCandidate.Publisher, enrichedCandidate.Narrator, enrichedCandidate.Language, enrichedCandidate.Series }.Where(s => !string.IsNullOrEmpty(s))).ToLowerInvariant(); + if (string.IsNullOrEmpty(hay) || hay.IndexOf(query, StringComparison.OrdinalIgnoreCase) < 0) + { + TrySetDropReason(candidateDropReasons, asin, "containment_failed_strict"); + finalAsinEntries.Add($"{asin}:containment_failed_strict"); + continue; + } + } + else if (containment < 0.4 && fuzzy < fuzzyThreshold) + { + TrySetDropReason(candidateDropReasons, asin, "containment_failed_relaxed"); + finalAsinEntries.Add($"{asin}:containment_failed_relaxed"); + continue; + } + + TrySetDropReason(candidateDropReasons, asin, "filtered_post_scoring"); + finalAsinEntries.Add($"{asin}:filtered_post_scoring"); + continue; + } + + if (!candidateDropReasons.ContainsKey(asin)) + { + TrySetDropReason(candidateDropReasons, asin, "no_metadata_and_no_scrape"); + } + + candidateDropReasons.TryGetValue(asin, out var dropReason); + finalAsinEntries.Add($"{asin}:{dropReason}"); + } + + if (finalAsinEntries.Any()) + { + logger.LogInformation("Final ASIN dispositions for query '{Query}': {Entries}", query, string.Join(", ", finalAsinEntries)); + } + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + logger.LogWarning(ex, "Failed to compute final ASIN dispositions for query: {Query}", query); + } + } + + private static void TrySetDropReason(IDictionary candidateDropReasons, string asin, string reason) + { + try + { + candidateDropReasons[asin] = reason; + } + catch (Exception caughtEx) when (caughtEx is not OperationCanceledException && caughtEx is not OutOfMemoryException && caughtEx is not StackOverflowException) + { + System.Diagnostics.Debug.WriteLine("Suppressed non-fatal exception in catch block."); + } + } + } +} diff --git a/listenarr.application/Search/SearchService.cs b/listenarr.application/Search/SearchService.cs index 3c9520e24..cf88498ca 100644 --- a/listenarr.application/Search/SearchService.cs +++ b/listenarr.application/Search/SearchService.cs @@ -43,6 +43,7 @@ public class SearchService : ISearchService private readonly MetadataSourceCatalog _metadataSourceCatalog; private readonly AudibleSimpleLookupWorkflow _audibleSimpleLookupWorkflow; private readonly AudibleAuthorSearchWorkflow _audibleAuthorSearchWorkflow; + private readonly SearchFinalDispositionLogger _finalDispositionLogger; public SearchService( HttpClient httpClient, @@ -66,7 +67,8 @@ public SearchService( MetadataSourceCatalog? metadataSourceCatalog = null, AudibleAuthorPageCollector? audibleAuthorPageCollector = null, AudibleSimpleLookupWorkflow? audibleSimpleLookupWorkflow = null, - AudibleAuthorSearchWorkflow? audibleAuthorSearchWorkflow = null) + AudibleAuthorSearchWorkflow? audibleAuthorSearchWorkflow = null, + SearchFinalDispositionLogger? finalDispositionLogger = null) { _configurationService = configurationService; _logger = logger; @@ -99,6 +101,8 @@ public SearchService( resolvedAudibleAuthorPageCollector, metadataConverters, NullLogger.Instance); + _finalDispositionLogger = finalDispositionLogger ?? new SearchFinalDispositionLogger( + NullLogger.Instance); } public async Task> SearchAsync(string query, string? category = null, List? apiIds = null, SearchSortBy sortBy = SearchSortBy.Seeders, SearchSortDirection sortDirection = SearchSortDirection.Descending, bool isAutomaticSearch = false) @@ -496,126 +500,15 @@ public async Task> IntelligentSearchAsync(string quer .ToList(); // Ensure every unified ASIN candidate has a final disposition reason for diagnostics. - try - { - var finalAsinEntries = new List(); - - foreach (var asin in asinCandidates.Where(asin => !string.IsNullOrWhiteSpace(asin))) - { - // If already accepted in the final results, mark as accepted - if (results.Any(r => string.Equals(r.Asin, asin, StringComparison.OrdinalIgnoreCase))) - { - try { candidateDropReasons[asin] = "accepted"; } - catch (Exception caughtEx_9) when (caughtEx_9 is not OperationCanceledException && caughtEx_9 is not OutOfMemoryException && caughtEx_9 is not StackOverflowException) - { - System.Diagnostics.Debug.WriteLine("Suppressed non-fatal exception in catch block."); - } - finalAsinEntries.Add($"{asin}:accepted"); - continue; - } - - // If we have an enriched version but it didn't make the final list, try to compute a specific drop reason - var enrichedCandidate = enrichedList.FirstOrDefault(e => string.Equals(e.Asin, asin, StringComparison.OrdinalIgnoreCase)); - if (enrichedCandidate != null) - { - // Author/publisher requirement - if (requireAuthorAndPublisher && (string.IsNullOrWhiteSpace(enrichedCandidate.Artist) || string.IsNullOrWhiteSpace(enrichedCandidate.Publisher))) - { - try { candidateDropReasons[asin] = "author_publisher_missing"; } - catch (Exception caughtEx_10) when (caughtEx_10 is not OperationCanceledException && caughtEx_10 is not OutOfMemoryException && caughtEx_10 is not StackOverflowException) - { - System.Diagnostics.Debug.WriteLine("Suppressed non-fatal exception in catch block."); - } - finalAsinEntries.Add($"{asin}:author_publisher_missing"); - continue; - } - - // Title noise or unlikely audiobook - if (SearchValidation.IsTitleNoise(enrichedCandidate.Title) || !SearchValidation.IsLikelyAudiobook(enrichedCandidate)) - { - try { candidateDropReasons[asin] = "filtered_title_or_not_likely"; } - catch (Exception caughtEx_11) when (caughtEx_11 is not OperationCanceledException && caughtEx_11 is not OutOfMemoryException && caughtEx_11 is not StackOverflowException) - { - System.Diagnostics.Debug.WriteLine("Suppressed non-fatal exception in catch block."); - } - finalAsinEntries.Add($"{asin}:filtered_title_or_not_likely"); - continue; - } - - // Containment / fuzzy failure - var containment = 0.0; - var fuzzy = 0.0; - try - { - containment = SearchResultMatchEvaluator.ComputeContainmentScore(enrichedCandidate, query); - fuzzy = SearchResultMatchEvaluator.ComputeFuzzySimilarity(enrichedCandidate.Title + " " + enrichedCandidate.Artist, query); - } - catch (Exception caughtEx_12) when (caughtEx_12 is not OperationCanceledException && caughtEx_12 is not OutOfMemoryException && caughtEx_12 is not StackOverflowException) - { - System.Diagnostics.Debug.WriteLine("Suppressed non-fatal exception in catch block."); - } - - if (string.Equals(containmentMode, "Strict", StringComparison.OrdinalIgnoreCase)) - { - // In strict mode we require direct containment - var hay = string.Join(" ", new[] { enrichedCandidate.Title, enrichedCandidate.Artist, enrichedCandidate.Album, enrichedCandidate.Description, enrichedCandidate.Publisher, enrichedCandidate.Narrator, enrichedCandidate.Language, enrichedCandidate.Series }.Where(s => !string.IsNullOrEmpty(s))).ToLowerInvariant(); - if (string.IsNullOrEmpty(hay) || hay.IndexOf(query, StringComparison.OrdinalIgnoreCase) < 0) - { - try { candidateDropReasons[asin] = "containment_failed_strict"; } - catch (Exception caughtEx_13) when (caughtEx_13 is not OperationCanceledException && caughtEx_13 is not OutOfMemoryException && caughtEx_13 is not StackOverflowException) - { - System.Diagnostics.Debug.WriteLine("Suppressed non-fatal exception in catch block."); - } - finalAsinEntries.Add($"{asin}:containment_failed_strict"); - continue; - } - } - else - { - if (containment < 0.4 && fuzzy < fuzzyThreshold) - { - try { candidateDropReasons[asin] = "containment_failed_relaxed"; } - catch (Exception caughtEx_14) when (caughtEx_14 is not OperationCanceledException && caughtEx_14 is not OutOfMemoryException && caughtEx_14 is not StackOverflowException) - { - System.Diagnostics.Debug.WriteLine("Suppressed non-fatal exception in catch block."); - } - finalAsinEntries.Add($"{asin}:containment_failed_relaxed"); - continue; - } - } - - // If none of the above matched, mark as filtered by post-scoring rules - try { candidateDropReasons[asin] = "filtered_post_scoring"; } - catch (Exception caughtEx_15) when (caughtEx_15 is not OperationCanceledException && caughtEx_15 is not OutOfMemoryException && caughtEx_15 is not StackOverflowException) - { - System.Diagnostics.Debug.WriteLine("Suppressed non-fatal exception in catch block."); - } - finalAsinEntries.Add($"{asin}:filtered_post_scoring"); - continue; - } - - // If we reached here, the ASIN never got enriched nor scraped successfully - if (!candidateDropReasons.ContainsKey(asin)) - { - try { candidateDropReasons[asin] = "no_metadata_and_no_scrape"; } - catch (Exception caughtEx_16) when (caughtEx_16 is not OperationCanceledException && caughtEx_16 is not OutOfMemoryException && caughtEx_16 is not StackOverflowException) - { - System.Diagnostics.Debug.WriteLine("Suppressed non-fatal exception in catch block."); - } - } - finalAsinEntries.Add($"{asin}:{candidateDropReasons.GetValueOrDefault(asin)}"); - } - - // Emit a consolidated diagnostic log with per-ASIN dispositions - if (finalAsinEntries.Any()) - { - _logger.LogInformation("Final ASIN dispositions for query '{Query}': {Entries}", query, string.Join(", ", finalAsinEntries)); - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Failed to compute final ASIN dispositions for query: {Query}", query); - } + _finalDispositionLogger.LogFinalAsinDispositions( + asinCandidates, + results, + enrichedList, + candidateDropReasons, + query, + requireAuthorAndPublisher, + containmentMode, + fuzzyThreshold); // Diagnostic: dump final results (title :: metadataSource :: id/asin) to help correlate try diff --git a/listenarr.infrastructure/Extensions/AppServiceRegistrationExtensions.cs b/listenarr.infrastructure/Extensions/AppServiceRegistrationExtensions.cs index dd8903430..130b00fd7 100644 --- a/listenarr.infrastructure/Extensions/AppServiceRegistrationExtensions.cs +++ b/listenarr.infrastructure/Extensions/AppServiceRegistrationExtensions.cs @@ -58,6 +58,7 @@ public static IServiceCollection AddListenarrAppServices(this IServiceCollection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); From 7a6ea8d789385a1105c957348ac2994cadf5a4ea Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Sun, 14 Jun 2026 07:09:51 -0400 Subject: [PATCH 72/84] refactor: extract mam torrent bencode helper - Move torrent bencode rewrite and announce parsing logic into a focused helper - Keep MyAnonamouseHelper public methods as compatibility delegates - Preserve MAM and torrent-focused test coverage --- .../Common/MyAnonamouseHelper.cs | 399 +--------------- .../MyAnonamouseTorrentBencodeHelper.cs | 430 ++++++++++++++++++ 2 files changed, 433 insertions(+), 396 deletions(-) create mode 100644 listenarr.application/Common/MyAnonamouseTorrentBencodeHelper.cs diff --git a/listenarr.application/Common/MyAnonamouseHelper.cs b/listenarr.application/Common/MyAnonamouseHelper.cs index f510b50de..062581cdb 100644 --- a/listenarr.application/Common/MyAnonamouseHelper.cs +++ b/listenarr.application/Common/MyAnonamouseHelper.cs @@ -206,412 +206,19 @@ private static Uri NormalizeBaseUri(string? baseUrl) return null; } - // Replace occurrences of a host inside bencoded torrent content while preserving bencode string lengths. - // This is a minimal, focused implementation that walks bencoded data and rewrites byte strings - // that contain the oldHost by substituting the host name and updating the length prefix. public static byte[] ReplaceHostInTorrent(byte[] torrentBytes, string oldHost, string newHost) { - using var inStream = new System.IO.MemoryStream(torrentBytes); - using var outStream = new System.IO.MemoryStream(); - - string ReadNumber() - { - var sb = new System.Text.StringBuilder(); - while (true) - { - int b = inStream.ReadByte(); - if (b == -1) break; - if (b == (int)':') break; - sb.Append((char)b); - } - return sb.ToString(); - } - - void CopyElement() - { - int c = inStream.ReadByte(); - if (c == -1) return; - char ch = (char)c; - if (ch == 'd' || ch == 'l') - { - // dict or list - outStream.WriteByte((byte)c); - while (true) - { - int peek = inStream.ReadByte(); - if (peek == -1) break; - if ((char)peek == 'e') - { - outStream.WriteByte((byte)peek); - break; - } - inStream.Position -= 1; - // For dicts, keys are strings; for lists, elements can be any - // Recurse - CopyElement(); - } - } - else if (ch == 'i') - { - // integer: read until 'e' - var sb = new System.Text.StringBuilder(); - sb.Append('i'); - while (true) - { - int b = inStream.ReadByte(); - if (b == -1) break; - sb.Append((char)b); - if ((char)b == 'e') break; - } - var s = System.Text.Encoding.ASCII.GetBytes(sb.ToString()); - outStream.Write(s, 0, s.Length); - } - else if (char.IsDigit(ch)) - { - // byte string: read length up to ':' - inStream.Position -= 1; - var lenStr = ReadNumber(); - var len = int.Parse(lenStr); - // read ':' consumed by ReadNumber - // read the data - var data = new byte[len]; - var read = inStream.Read(data, 0, len); - - var dataStr = System.Text.Encoding.UTF8.GetString(data, 0, read); - if (dataStr.Contains(oldHost, StringComparison.OrdinalIgnoreCase)) - { - var replaced = dataStr.Replace(oldHost, newHost, StringComparison.OrdinalIgnoreCase); - var replacedBytes = System.Text.Encoding.UTF8.GetBytes(replaced); - var newLenStr = replacedBytes.Length.ToString(); - var prefix = System.Text.Encoding.ASCII.GetBytes(newLenStr + ":"); - outStream.Write(prefix, 0, prefix.Length); - outStream.Write(replacedBytes, 0, replacedBytes.Length); - } - else - { - var prefix = System.Text.Encoding.ASCII.GetBytes(lenStr + ":"); - outStream.Write(prefix, 0, prefix.Length); - outStream.Write(data, 0, read); - } - } - else - { - // unknown - write the byte and continue - outStream.WriteByte((byte)c); - } - } - - // Walk the top-level element(s) - while (inStream.Position < inStream.Length) - { - CopyElement(); - } - - return outStream.ToArray(); + return MyAnonamouseTorrentBencodeHelper.ReplaceHostInTorrent(torrentBytes, oldHost, newHost); } - // Replace an exact byte-string value inside bencoded torrent content (preserves bencode length prefixes) - // Only replaces when the byte string matches `oldValue` exactly; useful for rewriting announce URLs safely. public static byte[] ReplaceStringInTorrent(byte[] torrentBytes, string oldValue, string newValue) { - using var inStream = new System.IO.MemoryStream(torrentBytes); - using var outStream = new System.IO.MemoryStream(); - - string ReadNumberLocal() - { - var sb = new System.Text.StringBuilder(); - while (true) - { - int b = inStream.ReadByte(); - if (b == -1) break; - if (b == (int)':') break; - sb.Append((char)b); - } - return sb.ToString(); - } - - void CopyElement() - { - int c = inStream.ReadByte(); - if (c == -1) return; - char ch = (char)c; - if (ch == 'd' || ch == 'l') - { - outStream.WriteByte((byte)c); - while (true) - { - int peek = inStream.ReadByte(); - if (peek == -1) break; - if ((char)peek == 'e') - { - outStream.WriteByte((byte)peek); - break; - } - inStream.Position -= 1; - CopyElement(); - } - } - else if (ch == 'i') - { - var sb = new System.Text.StringBuilder(); - sb.Append('i'); - while (true) - { - int b = inStream.ReadByte(); - if (b == -1) break; - sb.Append((char)b); - if ((char)b == 'e') break; - } - var s = System.Text.Encoding.ASCII.GetBytes(sb.ToString()); - outStream.Write(s, 0, s.Length); - } - else if (char.IsDigit(ch)) - { - inStream.Position -= 1; - var lenStr = ReadNumberLocal(); - if (!int.TryParse(lenStr, out var len)) return; - // read ':' consumed by ReadNumberLocal - var data = new byte[len]; - var read = inStream.Read(data, 0, len); - var dataStr = System.Text.Encoding.UTF8.GetString(data, 0, read); - if (string.Equals(dataStr, oldValue, StringComparison.Ordinal)) - { - var replacedBytes = System.Text.Encoding.UTF8.GetBytes(newValue); - var newLenStr = replacedBytes.Length.ToString(); - var prefix = System.Text.Encoding.ASCII.GetBytes(newLenStr + ":"); - outStream.Write(prefix, 0, prefix.Length); - outStream.Write(replacedBytes, 0, replacedBytes.Length); - } - else - { - var prefix = System.Text.Encoding.ASCII.GetBytes(lenStr + ":"); - outStream.Write(prefix, 0, prefix.Length); - outStream.Write(data, 0, read); - } - } - else - { - outStream.WriteByte((byte)c); - } - } - - while (inStream.Position < inStream.Length) - { - CopyElement(); - } - - return outStream.ToArray(); + return MyAnonamouseTorrentBencodeHelper.ReplaceStringInTorrent(torrentBytes, oldValue, newValue); } - // Extract announce/trackers from bencoded torrent content. - // Returns a list of strings including http(s) and udp trackers and any explicit announce-list entries. public static List ExtractAnnounceUrls(byte[] torrentBytes) { - var resultSet = new HashSet(StringComparer.OrdinalIgnoreCase); - try - { - using var inStream = new System.IO.MemoryStream(torrentBytes); - - string ReadNumberLocal() - { - var sb = new System.Text.StringBuilder(); - while (true) - { - int b = inStream.ReadByte(); - if (b == -1) break; - if (b == (int)':') break; - sb.Append((char)b); - } - return sb.ToString(); - } - - string ReadStringLocal(int len) - { - var buf = new byte[len]; - var r = inStream.Read(buf, 0, len); - return System.Text.Encoding.UTF8.GetString(buf, 0, r); - } - - // Skip over a bencoded element without capturing any strings - void ScanElementSkip() - { - int c2 = inStream.ReadByte(); - if (c2 == -1) return; - char ch2 = (char)c2; - if (ch2 == 'd') - { - while (true) - { - int p = inStream.ReadByte(); - if (p == -1 || (char)p == 'e') break; - inStream.Position -= 1; - // skip key (string) - var kl = ReadNumberLocal(); - if (!int.TryParse(kl, out var kLen)) break; - ReadStringLocal(kLen); - ScanElementSkip(); // skip value - } - } - else if (ch2 == 'l') - { - while (true) - { - int p = inStream.ReadByte(); - if (p == -1 || (char)p == 'e') break; - inStream.Position -= 1; - ScanElementSkip(); - } - } - else if (ch2 == 'i') - { - while (true) - { - int b = inStream.ReadByte(); - if (b == -1 || (char)b == 'e') break; - } - } - else if (char.IsDigit(ch2)) - { - inStream.Position -= 1; - var ls = ReadNumberLocal(); - if (int.TryParse(ls, out var len)) ReadStringLocal(len); - } - } - - void ScanElement() - { - int c = inStream.ReadByte(); - if (c == -1) return; - char ch = (char)c; - if (ch == 'd') - { - // dict: read key/value pairs until 'e' - while (true) - { - int peek = inStream.ReadByte(); - if (peek == -1) break; - if ((char)peek == 'e') break; // 'e' is consumed here; no extra read needed - inStream.Position -= 1; - // keys are strings - var keyLenStr = ReadNumberLocal(); - if (!int.TryParse(keyLenStr, out var keyLen)) break; - var key = ReadStringLocal(keyLen); - - // Value can be any bencoded type - if key is announce or announce-list/url-list, capture appropriate strings - if (string.Equals(key, "announce", StringComparison.OrdinalIgnoreCase)) - { - // next is string - var lenStr = ReadNumberLocal(); - if (!int.TryParse(lenStr, out var len)) continue; - var val = ReadStringLocal(len); - if (!string.IsNullOrWhiteSpace(val)) resultSet.Add(val); - } - else if (string.Equals(key, "announce-list", StringComparison.OrdinalIgnoreCase)) - { - // value is a list (possibly nested) of tracker announce URLs - ScanElement(); // will process nested lists/strings and add strings when encountered - } - else if (string.Equals(key, "url-list", StringComparison.OrdinalIgnoreCase)) - { - // url-list is for web seeds / file URLs — NOT tracker announces. - // Skip by scanning without capturing. - ScanElementSkip(); - } - else - { - // For other keys, scan the value recursively - ScanElement(); - } - } - } - else if (ch == 'l') - { - // list: elements until 'e' - while (true) - { - int peek = inStream.ReadByte(); - if (peek == -1) break; - if ((char)peek == 'e') break; // 'e' is consumed here; no extra read needed - inStream.Position -= 1; - // If element is a string, capture it; otherwise recurse - int next = inStream.ReadByte(); - if (next == -1) break; - char nCh = (char)next; - if (char.IsDigit(nCh)) - { - inStream.Position -= 1; - var lenStr = ReadNumberLocal(); - if (!int.TryParse(lenStr, out var len)) break; - var s = ReadStringLocal(len); - if (!string.IsNullOrWhiteSpace(s)) resultSet.Add(s); - } - else - { - inStream.Position -= 1; - ScanElement(); - } - } - } - else if (ch == 'i') - { - // integer: read until 'e' - while (true) - { - int b = inStream.ReadByte(); - if (b == -1) break; - if ((char)b == 'e') break; - } - } - else if (char.IsDigit(ch)) - { - // byte string: read length and string; if the string looks like a URL (http/https/udp) add it - inStream.Position -= 1; - var lenStr = ReadNumberLocal(); - if (!int.TryParse(lenStr, out var len)) return; - // ReadNumberLocal already consumed the ':' separator, so read the string directly - var s = ReadStringLocal(len); - if (!string.IsNullOrWhiteSpace(s) && (s.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || s.StartsWith("https://", StringComparison.OrdinalIgnoreCase) || s.StartsWith("udp://", StringComparison.OrdinalIgnoreCase))) - { - resultSet.Add(s); - } - } - else - { - // unknown - nothing to do - } - } - - // Start scanning from the beginning - inStream.Position = 0; - ScanElement(); - } - catch (Exception caughtEx_4) when (caughtEx_4 is not OperationCanceledException && caughtEx_4 is not OutOfMemoryException && caughtEx_4 is not StackOverflowException) - { - // best-effort, swallow errors - System.Diagnostics.Debug.WriteLine("Suppressed non-fatal exception in catch block."); - } - - // Fallback: regex to find tracker announce URLs if bencode parsing found nothing. - // Only match URLs containing /announce or /tracker to avoid picking up file/web-seed URLs. - if (resultSet.Count == 0) - { - try - { - var asciiAll = System.Text.Encoding.ASCII.GetString(torrentBytes); - var matches = System.Text.RegularExpressions.Regex.Matches(asciiAll, @"(https?|udp)://[^\s\""']+", System.Text.RegularExpressions.RegexOptions.IgnoreCase); - foreach (var v in matches.Select(m => m.Value).Where(v => v.Contains("/announce", StringComparison.OrdinalIgnoreCase) || v.Contains("/tracker", StringComparison.OrdinalIgnoreCase))) - { - resultSet.Add(v); - } - } - catch (Exception caughtEx_5) when (caughtEx_5 is not OperationCanceledException && caughtEx_5 is not OutOfMemoryException && caughtEx_5 is not StackOverflowException) - { - // ignore - System.Diagnostics.Debug.WriteLine("Suppressed non-fatal exception in catch block."); - } - } - - return new List(resultSet); + return MyAnonamouseTorrentBencodeHelper.ExtractAnnounceUrls(torrentBytes); } /// diff --git a/listenarr.application/Common/MyAnonamouseTorrentBencodeHelper.cs b/listenarr.application/Common/MyAnonamouseTorrentBencodeHelper.cs new file mode 100644 index 000000000..1b28b8e57 --- /dev/null +++ b/listenarr.application/Common/MyAnonamouseTorrentBencodeHelper.cs @@ -0,0 +1,430 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +namespace Listenarr.Application.Common +{ + public static class MyAnonamouseTorrentBencodeHelper + { + // Replace occurrences of a host inside bencoded torrent content while preserving bencode string lengths. + // This is a minimal, focused implementation that walks bencoded data and rewrites byte strings + // that contain the oldHost by substituting the host name and updating the length prefix. + public static byte[] ReplaceHostInTorrent(byte[] torrentBytes, string oldHost, string newHost) + { + using var inStream = new System.IO.MemoryStream(torrentBytes); + using var outStream = new System.IO.MemoryStream(); + + string ReadNumber() + { + var sb = new System.Text.StringBuilder(); + while (true) + { + int b = inStream.ReadByte(); + if (b == -1) break; + if (b == (int)':') break; + sb.Append((char)b); + } + return sb.ToString(); + } + + void CopyElement() + { + int c = inStream.ReadByte(); + if (c == -1) return; + char ch = (char)c; + if (ch == 'd' || ch == 'l') + { + // dict or list + outStream.WriteByte((byte)c); + while (true) + { + int peek = inStream.ReadByte(); + if (peek == -1) break; + if ((char)peek == 'e') + { + outStream.WriteByte((byte)peek); + break; + } + inStream.Position -= 1; + // For dicts, keys are strings; for lists, elements can be any + // Recurse + CopyElement(); + } + } + else if (ch == 'i') + { + // integer: read until 'e' + var sb = new System.Text.StringBuilder(); + sb.Append('i'); + while (true) + { + int b = inStream.ReadByte(); + if (b == -1) break; + sb.Append((char)b); + if ((char)b == 'e') break; + } + var s = System.Text.Encoding.ASCII.GetBytes(sb.ToString()); + outStream.Write(s, 0, s.Length); + } + else if (char.IsDigit(ch)) + { + // byte string: read length up to ':' + inStream.Position -= 1; + var lenStr = ReadNumber(); + var len = int.Parse(lenStr); + // read ':' consumed by ReadNumber + // read the data + var data = new byte[len]; + var read = inStream.Read(data, 0, len); + + var dataStr = System.Text.Encoding.UTF8.GetString(data, 0, read); + if (dataStr.Contains(oldHost, StringComparison.OrdinalIgnoreCase)) + { + var replaced = dataStr.Replace(oldHost, newHost, StringComparison.OrdinalIgnoreCase); + var replacedBytes = System.Text.Encoding.UTF8.GetBytes(replaced); + var newLenStr = replacedBytes.Length.ToString(); + var prefix = System.Text.Encoding.ASCII.GetBytes(newLenStr + ":"); + outStream.Write(prefix, 0, prefix.Length); + outStream.Write(replacedBytes, 0, replacedBytes.Length); + } + else + { + var prefix = System.Text.Encoding.ASCII.GetBytes(lenStr + ":"); + outStream.Write(prefix, 0, prefix.Length); + outStream.Write(data, 0, read); + } + } + else + { + // unknown - write the byte and continue + outStream.WriteByte((byte)c); + } + } + + // Walk the top-level element(s) + while (inStream.Position < inStream.Length) + { + CopyElement(); + } + + return outStream.ToArray(); + } + + // Replace an exact byte-string value inside bencoded torrent content (preserves bencode length prefixes) + // Only replaces when the byte string matches `oldValue` exactly; useful for rewriting announce URLs safely. + public static byte[] ReplaceStringInTorrent(byte[] torrentBytes, string oldValue, string newValue) + { + using var inStream = new System.IO.MemoryStream(torrentBytes); + using var outStream = new System.IO.MemoryStream(); + + string ReadNumberLocal() + { + var sb = new System.Text.StringBuilder(); + while (true) + { + int b = inStream.ReadByte(); + if (b == -1) break; + if (b == (int)':') break; + sb.Append((char)b); + } + return sb.ToString(); + } + + void CopyElement() + { + int c = inStream.ReadByte(); + if (c == -1) return; + char ch = (char)c; + if (ch == 'd' || ch == 'l') + { + outStream.WriteByte((byte)c); + while (true) + { + int peek = inStream.ReadByte(); + if (peek == -1) break; + if ((char)peek == 'e') + { + outStream.WriteByte((byte)peek); + break; + } + inStream.Position -= 1; + CopyElement(); + } + } + else if (ch == 'i') + { + var sb = new System.Text.StringBuilder(); + sb.Append('i'); + while (true) + { + int b = inStream.ReadByte(); + if (b == -1) break; + sb.Append((char)b); + if ((char)b == 'e') break; + } + var s = System.Text.Encoding.ASCII.GetBytes(sb.ToString()); + outStream.Write(s, 0, s.Length); + } + else if (char.IsDigit(ch)) + { + inStream.Position -= 1; + var lenStr = ReadNumberLocal(); + if (!int.TryParse(lenStr, out var len)) return; + // read ':' consumed by ReadNumberLocal + var data = new byte[len]; + var read = inStream.Read(data, 0, len); + var dataStr = System.Text.Encoding.UTF8.GetString(data, 0, read); + if (string.Equals(dataStr, oldValue, StringComparison.Ordinal)) + { + var replacedBytes = System.Text.Encoding.UTF8.GetBytes(newValue); + var newLenStr = replacedBytes.Length.ToString(); + var prefix = System.Text.Encoding.ASCII.GetBytes(newLenStr + ":"); + outStream.Write(prefix, 0, prefix.Length); + outStream.Write(replacedBytes, 0, replacedBytes.Length); + } + else + { + var prefix = System.Text.Encoding.ASCII.GetBytes(lenStr + ":"); + outStream.Write(prefix, 0, prefix.Length); + outStream.Write(data, 0, read); + } + } + else + { + outStream.WriteByte((byte)c); + } + } + + while (inStream.Position < inStream.Length) + { + CopyElement(); + } + + return outStream.ToArray(); + } + + // Extract announce/trackers from bencoded torrent content. + // Returns a list of strings including http(s) and udp trackers and any explicit announce-list entries. + public static List ExtractAnnounceUrls(byte[] torrentBytes) + { + var resultSet = new HashSet(StringComparer.OrdinalIgnoreCase); + try + { + using var inStream = new System.IO.MemoryStream(torrentBytes); + + string ReadNumberLocal() + { + var sb = new System.Text.StringBuilder(); + while (true) + { + int b = inStream.ReadByte(); + if (b == -1) break; + if (b == (int)':') break; + sb.Append((char)b); + } + return sb.ToString(); + } + + string ReadStringLocal(int len) + { + var buf = new byte[len]; + var r = inStream.Read(buf, 0, len); + return System.Text.Encoding.UTF8.GetString(buf, 0, r); + } + + // Skip over a bencoded element without capturing any strings + void ScanElementSkip() + { + int c2 = inStream.ReadByte(); + if (c2 == -1) return; + char ch2 = (char)c2; + if (ch2 == 'd') + { + while (true) + { + int p = inStream.ReadByte(); + if (p == -1 || (char)p == 'e') break; + inStream.Position -= 1; + // skip key (string) + var kl = ReadNumberLocal(); + if (!int.TryParse(kl, out var kLen)) break; + ReadStringLocal(kLen); + ScanElementSkip(); // skip value + } + } + else if (ch2 == 'l') + { + while (true) + { + int p = inStream.ReadByte(); + if (p == -1 || (char)p == 'e') break; + inStream.Position -= 1; + ScanElementSkip(); + } + } + else if (ch2 == 'i') + { + while (true) + { + int b = inStream.ReadByte(); + if (b == -1 || (char)b == 'e') break; + } + } + else if (char.IsDigit(ch2)) + { + inStream.Position -= 1; + var ls = ReadNumberLocal(); + if (int.TryParse(ls, out var len)) ReadStringLocal(len); + } + } + + void ScanElement() + { + int c = inStream.ReadByte(); + if (c == -1) return; + char ch = (char)c; + if (ch == 'd') + { + // dict: read key/value pairs until 'e' + while (true) + { + int peek = inStream.ReadByte(); + if (peek == -1) break; + if ((char)peek == 'e') break; // 'e' is consumed here; no extra read needed + inStream.Position -= 1; + // keys are strings + var keyLenStr = ReadNumberLocal(); + if (!int.TryParse(keyLenStr, out var keyLen)) break; + var key = ReadStringLocal(keyLen); + + // Value can be any bencoded type - if key is announce or announce-list/url-list, capture appropriate strings + if (string.Equals(key, "announce", StringComparison.OrdinalIgnoreCase)) + { + // next is string + var lenStr = ReadNumberLocal(); + if (!int.TryParse(lenStr, out var len)) continue; + var val = ReadStringLocal(len); + if (!string.IsNullOrWhiteSpace(val)) resultSet.Add(val); + } + else if (string.Equals(key, "announce-list", StringComparison.OrdinalIgnoreCase)) + { + // value is a list (possibly nested) of tracker announce URLs + ScanElement(); // will process nested lists/strings and add strings when encountered + } + else if (string.Equals(key, "url-list", StringComparison.OrdinalIgnoreCase)) + { + // url-list is for web seeds / file URLs — NOT tracker announces. + // Skip by scanning without capturing. + ScanElementSkip(); + } + else + { + // For other keys, scan the value recursively + ScanElement(); + } + } + } + else if (ch == 'l') + { + // list: elements until 'e' + while (true) + { + int peek = inStream.ReadByte(); + if (peek == -1) break; + if ((char)peek == 'e') break; // 'e' is consumed here; no extra read needed + inStream.Position -= 1; + // If element is a string, capture it; otherwise recurse + int next = inStream.ReadByte(); + if (next == -1) break; + char nCh = (char)next; + if (char.IsDigit(nCh)) + { + inStream.Position -= 1; + var lenStr = ReadNumberLocal(); + if (!int.TryParse(lenStr, out var len)) break; + var s = ReadStringLocal(len); + if (!string.IsNullOrWhiteSpace(s)) resultSet.Add(s); + } + else + { + inStream.Position -= 1; + ScanElement(); + } + } + } + else if (ch == 'i') + { + // integer: read until 'e' + while (true) + { + int b = inStream.ReadByte(); + if (b == -1) break; + if ((char)b == 'e') break; + } + } + else if (char.IsDigit(ch)) + { + // byte string: read length and string; if the string looks like a URL (http/https/udp) add it + inStream.Position -= 1; + var lenStr = ReadNumberLocal(); + if (!int.TryParse(lenStr, out var len)) return; + // ReadNumberLocal already consumed the ':' separator, so read the string directly + var s = ReadStringLocal(len); + if (!string.IsNullOrWhiteSpace(s) && (s.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || s.StartsWith("https://", StringComparison.OrdinalIgnoreCase) || s.StartsWith("udp://", StringComparison.OrdinalIgnoreCase))) + { + resultSet.Add(s); + } + } + else + { + // unknown - nothing to do + } + } + + // Start scanning from the beginning + inStream.Position = 0; + ScanElement(); + } + catch (Exception caughtEx_4) when (caughtEx_4 is not OperationCanceledException && caughtEx_4 is not OutOfMemoryException && caughtEx_4 is not StackOverflowException) + { + // best-effort, swallow errors + System.Diagnostics.Debug.WriteLine("Suppressed non-fatal exception in catch block."); + } + + // Fallback: regex to find tracker announce URLs if bencode parsing found nothing. + // Only match URLs containing /announce or /tracker to avoid picking up file/web-seed URLs. + if (resultSet.Count == 0) + { + try + { + var asciiAll = System.Text.Encoding.ASCII.GetString(torrentBytes); + var matches = System.Text.RegularExpressions.Regex.Matches(asciiAll, @"(https?|udp)://[^\s\""']+", System.Text.RegularExpressions.RegexOptions.IgnoreCase); + foreach (var v in matches.Select(m => m.Value).Where(v => v.Contains("/announce", StringComparison.OrdinalIgnoreCase) || v.Contains("/tracker", StringComparison.OrdinalIgnoreCase))) + { + resultSet.Add(v); + } + } + catch (Exception caughtEx_5) when (caughtEx_5 is not OperationCanceledException && caughtEx_5 is not OutOfMemoryException && caughtEx_5 is not StackOverflowException) + { + // ignore + System.Diagnostics.Debug.WriteLine("Suppressed non-fatal exception in catch block."); + } + } + + return new List(resultSet); + } + } +} From d64b3a77b0e469393fcd900fb8e784d14b077fab Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Sun, 14 Jun 2026 07:11:57 -0400 Subject: [PATCH 73/84] refactor: extract ffprobe platform defaults - Move platform-specific ffprobe download URL and checksum defaults into a focused helper - Keep installation orchestration and provider override behavior unchanged - Preserve ffmpeg-focused test coverage --- .../Ffmpeg/FfmpegService.cs | 39 +------------- .../Ffmpeg/FfprobePlatformDefaults.cs | 54 +++++++++++++++++++ 2 files changed, 56 insertions(+), 37 deletions(-) create mode 100644 listenarr.infrastructure/Ffmpeg/FfprobePlatformDefaults.cs diff --git a/listenarr.infrastructure/Ffmpeg/FfmpegService.cs b/listenarr.infrastructure/Ffmpeg/FfmpegService.cs index 94ae14bee..8085982bb 100644 --- a/listenarr.infrastructure/Ffmpeg/FfmpegService.cs +++ b/listenarr.infrastructure/Ffmpeg/FfmpegService.cs @@ -507,47 +507,12 @@ private static bool TryBuildPathUnderRoot(string rootPath, string entryPath, out private string? GetDownloadUrlForPlatform() { - if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) - { - if (RuntimeInformation.OSArchitecture == Architecture.Arm64) - { - return "https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-arm64-static.tar.xz"; - } - - // johnvansickle static build (x86_64) - return "https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz"; - } - if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) - { - // evermeet/ffmpeg provides static macOS builds (note: keep an eye on licensing) - return "https://evermeet.cx/ffmpeg/ffmpeg-6.0.zip"; - } - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - // gyan.dev builds - return "https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip"; - } - - return null; + return FfprobePlatformDefaults.GetDownloadUrl(); } private string? GetChecksumForPlatform() { - // For production you should pin the checksums for each provider + archive - if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) - { - return null; // placeholder - add SHA256 hex string - } - if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) - { - return null; - } - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - return null; - } - - return null; + return FfprobePlatformDefaults.GetChecksum(); } public async Task RunFfprobeAsync(string filePath) diff --git a/listenarr.infrastructure/Ffmpeg/FfprobePlatformDefaults.cs b/listenarr.infrastructure/Ffmpeg/FfprobePlatformDefaults.cs new file mode 100644 index 000000000..c70f6f775 --- /dev/null +++ b/listenarr.infrastructure/Ffmpeg/FfprobePlatformDefaults.cs @@ -0,0 +1,54 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +using System.Runtime.InteropServices; + +namespace Listenarr.Infrastructure.Ffmpeg +{ + internal static class FfprobePlatformDefaults + { + public static string? GetDownloadUrl() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + if (RuntimeInformation.OSArchitecture == Architecture.Arm64) + { + return "https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-arm64-static.tar.xz"; + } + + return "https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz"; + } + + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + return "https://evermeet.cx/ffmpeg/ffmpeg-6.0.zip"; + } + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return "https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip"; + } + + return null; + } + + public static string? GetChecksum() + { + return null; + } + } +} From 1b7f71e4e0f9d5c4141917f08416927ae2dc542e Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Sun, 14 Jun 2026 07:15:48 -0400 Subject: [PATCH 74/84] refactor: extract search title workflow - Move title search ASIN lookup and Discord response shaping into a focused workflow - Keep route status codes and response payloads unchanged - Register the workflow through API startup --- .../Controllers/SearchByTitleWorkflow.cs | 174 ++++++++++++++++++ listenarr.api/Controllers/SearchController.cs | 134 ++------------ listenarr.api/Program.cs | 1 + 3 files changed, 189 insertions(+), 120 deletions(-) create mode 100644 listenarr.api/Controllers/SearchByTitleWorkflow.cs diff --git a/listenarr.api/Controllers/SearchByTitleWorkflow.cs b/listenarr.api/Controllers/SearchByTitleWorkflow.cs new file mode 100644 index 000000000..eac07d063 --- /dev/null +++ b/listenarr.api/Controllers/SearchByTitleWorkflow.cs @@ -0,0 +1,174 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +using Listenarr.Application.Interfaces; +using Listenarr.Application.Metadata; +using Listenarr.Domain.Models; + +namespace Listenarr.Api.Controllers +{ + public sealed class SearchByTitleWorkflow( + ISearchService searchService, + AudibleService audibleService, + IAudiobookMetadataService metadataService, + Microsoft.Extensions.Logging.ILogger logger) + { + public async Task ExecuteAsync( + string query, + string region, + int limit, + CancellationToken cancellationToken) + { + try + { + if (string.IsNullOrWhiteSpace(query)) + { + return SearchByTitleWorkflowResult.BadRequest("Query parameter is required"); + } + + logger.LogInformation("Searching by title: {Query}", query); + + if (IsAsin(query.Trim())) + { + var directResult = await TryLookupAsinAsync(query.Trim(), region); + if (directResult != null) + { + return SearchByTitleWorkflowResult.Ok(directResult); + } + } + + var searchResults = await searchService.IntelligentSearchAsync( + query, + region: region, + language: null, + ct: cancellationToken); + + if (searchResults == null || !searchResults.Any()) + { + logger.LogWarning("No results found for title search: {Query}", query); + return SearchByTitleWorkflowResult.Ok(new List()); + } + + var results = BuildDiscordTitleResults(searchResults.Take(limit)); + + logger.LogInformation("Successfully fetched {Count} enriched results for title search: {Query}", results.Count, query); + return SearchByTitleWorkflowResult.Ok(results); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + logger.LogError(ex, "Error performing title search for query: {Query}", query); + return SearchByTitleWorkflowResult.ServerError("Internal server error"); + } + } + + private async Task?> TryLookupAsinAsync(string asin, string region) + { + logger.LogInformation("Query appears to be an ASIN; attempting direct metadata lookup for: {Asin}", asin); + + try + { + var audible = await audibleService.GetBookMetadataAsync(asin, region, true); + if (audible != null) + { + var metadataObj = new + { + metadata = audible, + source = "Audible", + sourceUrl = "https://www.audible.com" + }; + return new List { metadataObj }; + } + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + logger.LogWarning(ex, "Audible metadata lookup failed for ASIN {Asin}, trying other configured metadata sources", asin); + } + + try + { + var meta = await metadataService.GetMetadataAsync(asin, region, true); + if (meta != null) + { + return new List { meta }; + } + + logger.LogWarning("Metadata lookup returned null for ASIN {Asin}, falling back to intelligent search", asin); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + logger.LogWarning(ex, "Metadata lookup failed for ASIN {Asin}, falling back to intelligent search", asin); + } + + return null; + } + + private List BuildDiscordTitleResults(IEnumerable searchResults) + { + var results = new List(); + + foreach (var searchResult in searchResults) + { + try + { + var metadata = new + { + Asin = searchResult.Asin, + Title = searchResult.Title, + Subtitle = searchResult.Series != null ? $"{searchResult.Series} #{searchResult.SeriesNumber}" : null, + Authors = !string.IsNullOrEmpty(searchResult.Author) ? new[] { new { Name = searchResult.Author } } : null, + Narrators = !string.IsNullOrEmpty(searchResult.Narrator) ? searchResult.Narrator.Split(new[] { ", " }, StringSplitOptions.RemoveEmptyEntries).Select(n => new { Name = n.Trim() }) : null, + Publisher = searchResult.Publisher, + Description = searchResult.Description, + ImageUrl = searchResult.ImageUrl, + LengthMinutes = searchResult.Runtime, + Language = searchResult.Language, + ReleaseDate = !string.IsNullOrWhiteSpace(searchResult.PublishedDate) ? searchResult.PublishedDate : null, + Series = !string.IsNullOrEmpty(searchResult.Series) ? new[] { new { Name = searchResult.Series, Position = searchResult.SeriesNumber } } : null + }; + + results.Add(new + { + metadata, + source = searchResult.MetadataSource ?? searchResult.Source ?? "Amazon/Audible", + sourceUrl = "https://www.amazon.com" + }); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + logger.LogWarning(ex, "Failed to convert search result for title: {Title}", searchResult.Title); + } + } + + return results; + } + + private static bool IsAsin(string s) + { + if (string.IsNullOrEmpty(s)) return false; + if (s.Length != 10) return false; + if (!(s.StartsWith("B0") || char.IsDigit(s[0]))) return false; + return s.All(char.IsLetterOrDigit); + } + } + + public sealed record SearchByTitleWorkflowResult(int StatusCode, object Payload) + { + public static SearchByTitleWorkflowResult Ok(object payload) => new(StatusCodes.Status200OK, payload); + public static SearchByTitleWorkflowResult BadRequest(object payload) => new(StatusCodes.Status400BadRequest, payload); + public static SearchByTitleWorkflowResult ServerError(object payload) => new(StatusCodes.Status500InternalServerError, payload); + } +} diff --git a/listenarr.api/Controllers/SearchController.cs b/listenarr.api/Controllers/SearchController.cs index b886ac562..de7c67a81 100644 --- a/listenarr.api/Controllers/SearchController.cs +++ b/listenarr.api/Controllers/SearchController.cs @@ -38,6 +38,7 @@ public class SearchController : ControllerBase private readonly IImageCacheService? _imageCacheService; private readonly SearchResponseMapper _responseMapper; private readonly StructuredSearchWorkflow _structuredSearchWorkflow; + private readonly SearchByTitleWorkflow _searchByTitleWorkflow; public SearchController( ISearchService searchService, @@ -47,7 +48,8 @@ public SearchController( IImageCacheService? imageCacheService = null, MetadataConverters? metadataConverters = null, SearchResponseMapper? responseMapper = null, - StructuredSearchWorkflow? structuredSearchWorkflow = null) + StructuredSearchWorkflow? structuredSearchWorkflow = null, + SearchByTitleWorkflow? searchByTitleWorkflow = null) { _searchService = searchService; _logger = logger; @@ -67,6 +69,11 @@ public SearchController( imageCacheService, metadataConvertersInstance, _responseMapper); + _searchByTitleWorkflow = searchByTitleWorkflow ?? new SearchByTitleWorkflow( + searchService, + audibleService, + metadataService, + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); } private string BuildApiImagePath(string identifier, string? sourceUrl = null) @@ -429,126 +436,13 @@ public async Task>> SearchByTitle( [FromQuery] string region = "us", [FromQuery] int limit = 10) { - try - { - if (string.IsNullOrWhiteSpace(query)) - { - return BadRequest("Query parameter is required"); - } - - _logger.LogInformation("Searching by title: {Query}", query); - - // If the query looks like an ASIN, short-circuit to metadata lookup so we don't run - // a full Amazon/Audible text search that can return unrelated items. - bool IsAsin(string s) - { - if (string.IsNullOrEmpty(s)) return false; - if (s.Length != 10) return false; - if (!(s.StartsWith("B0") || char.IsDigit(s[0]))) return false; - return s.All(char.IsLetterOrDigit); - } - - if (IsAsin(query.Trim())) - { - var asin = query.Trim(); - _logger.LogInformation("Query appears to be an ASIN; attempting direct metadata lookup for: {Asin}", asin); - - // Try the Audible-backed provider first, then fall back to other configured metadata sources. - try - { - var audible = await _audibleService.GetBookMetadataAsync(asin, region, true); - if (audible != null) - { - var metadataObj = new - { - metadata = audible, - source = "Audible", - sourceUrl = "https://www.audible.com" - }; - return Ok(new List { metadataObj }); - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Audible metadata lookup failed for ASIN {Asin}, trying other configured metadata sources", asin); - } - - // If audible didn't return anything, try configured metadata sources directly - try - { - var meta = await _metadataService.GetMetadataAsync(asin, region, true); - if (meta != null) - { - return Ok(new List { meta }); - } - _logger.LogWarning("Metadata lookup returned null for ASIN {Asin}, falling back to intelligent search", asin); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Metadata lookup failed for ASIN {Asin}, falling back to intelligent search", asin); - } - - // If no metadata found via configured sources, fall back to the generic intelligent search below - } - - // Use intelligent search (Amazon/Audible + metadata enrichment) for Discord bot - // This excludes indexer results which are not suitable for bot interactions - // The Discord bot now sends proper prefixes (TITLE:, AUTHOR:, AUTHOR_TITLE:) - var searchResults = await _searchService.IntelligentSearchAsync(query, region: region, language: null, ct: HttpContext.RequestAborted); - - if (searchResults == null || !searchResults.Any()) - { - _logger.LogWarning("No results found for title search: {Query}", query); - return Ok(new List()); - } - - // Convert SearchResult objects to the expected format for Discord bot - var results = new List(); - var resultsToReturn = searchResults.Take(limit).ToList(); - - foreach (var searchResult in resultsToReturn) - { - try - { - // Create a metadata-like object from the SearchResult - var metadata = new - { - Asin = searchResult.Asin, - Title = searchResult.Title, - Subtitle = searchResult.Series != null ? $"{searchResult.Series} #{searchResult.SeriesNumber}" : null, - Authors = !string.IsNullOrEmpty(searchResult.Author) ? new[] { new { Name = searchResult.Author } } : null, - Narrators = !string.IsNullOrEmpty(searchResult.Narrator) ? searchResult.Narrator.Split(new[] { ", " }, StringSplitOptions.RemoveEmptyEntries).Select(n => new { Name = n.Trim() }) : null, - Publisher = searchResult.Publisher, - Description = searchResult.Description, - ImageUrl = searchResult.ImageUrl, - LengthMinutes = searchResult.Runtime, - Language = searchResult.Language, - ReleaseDate = !string.IsNullOrWhiteSpace(searchResult.PublishedDate) ? searchResult.PublishedDate : null, - Series = !string.IsNullOrEmpty(searchResult.Series) ? new[] { new { Name = searchResult.Series, Position = searchResult.SeriesNumber } } : null - }; - - results.Add(new - { - metadata = metadata, - source = searchResult.MetadataSource ?? searchResult.Source ?? "Amazon/Audible", - sourceUrl = "https://www.amazon.com" - }); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Failed to convert search result for title: {Title}", searchResult.Title); - continue; - } - } - - _logger.LogInformation("Successfully fetched {Count} enriched results for title search: {Query}", results.Count, query); - return Ok(results); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + var result = await _searchByTitleWorkflow.ExecuteAsync(query, region, limit, HttpContext.RequestAborted); + return result.StatusCode switch { - _logger.LogError(ex, "Error performing title search for query: {Query}", query); - return StatusCode(500, "Internal server error"); - } + StatusCodes.Status400BadRequest => BadRequest(result.Payload), + StatusCodes.Status500InternalServerError => StatusCode(result.StatusCode, result.Payload), + _ => Ok(result.Payload) + }; } // existing code continuation diff --git a/listenarr.api/Program.cs b/listenarr.api/Program.cs index 60ad1c6b0..328579fb6 100644 --- a/listenarr.api/Program.cs +++ b/listenarr.api/Program.cs @@ -365,6 +365,7 @@ ex is IOException builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); From 91d6be056feec4aa1e40da05b08bbb7ba98f2b15 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Sun, 14 Jun 2026 07:18:01 -0400 Subject: [PATCH 75/84] refactor: extract notification http sender - Move validated notification HTTP sending and redirect checks into a focused helper - Preserve request-context private target rules and existing HttpClient behavior - Keep provider-specific notification dispatch unchanged --- .../Notification/NotificationHttpSender.cs | 126 ++++++++++++++++++ .../Notification/NotificationService.cs | 95 +------------ 2 files changed, 130 insertions(+), 91 deletions(-) create mode 100644 listenarr.application/Notification/NotificationHttpSender.cs diff --git a/listenarr.application/Notification/NotificationHttpSender.cs b/listenarr.application/Notification/NotificationHttpSender.cs new file mode 100644 index 000000000..7f3cb4c9e --- /dev/null +++ b/listenarr.application/Notification/NotificationHttpSender.cs @@ -0,0 +1,126 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +using Listenarr.Application.Security; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Application.Notification +{ + internal sealed class NotificationHttpSender( + HttpClient httpClient, + HttpClient httpClientNoRedirect, + ILogger logger, + Func allowPrivateTargetsForCurrentRequest) + { + public async Task PostValidatedAsync(string url, HttpContent content, CancellationToken cancellationToken = default) + { + using var request = new HttpRequestMessage(HttpMethod.Post, url) + { + Content = content + }; + + return await SendValidatedAsync(request, cancellationToken); + } + + public async Task SendValidatedAsync(HttpRequestMessage request, CancellationToken cancellationToken = default) + { + if (request.RequestUri == null) + { + throw new InvalidOperationException("Outbound notification request URI is required."); + } + + var allowPrivateTargets = allowPrivateTargetsForCurrentRequest(); + if (!OutboundRequestSecurity.TryValidateExternalHttpUri(request.RequestUri, out var uriReason, allowPrivateTargets)) + { + throw new InvalidOperationException($"Blocked outbound URL: {uriReason}"); + } + + if (!await OutboundRequestSecurity.TryValidateResolvedExternalHttpUriAsync(request.RequestUri, logger, allowPrivateTargets)) + { + throw new InvalidOperationException("Blocked outbound URL: DNS resolved to private or loopback address"); + } + + if (ReferenceEquals(httpClientNoRedirect, httpClient)) + { + var directResponse = await httpClient.SendAsync(request, cancellationToken); + var finalUri = directResponse.RequestMessage?.RequestUri ?? request.RequestUri; + if (!OutboundRequestSecurity.TryValidateExternalHttpUri(finalUri, out var finalReason, allowPrivateTargets)) + { + directResponse.Dispose(); + throw new InvalidOperationException($"Blocked final outbound URL: {finalReason}"); + } + + if (!await OutboundRequestSecurity.TryValidateResolvedExternalHttpUriAsync(finalUri, logger, allowPrivateTargets)) + { + directResponse.Dispose(); + throw new InvalidOperationException("Blocked final outbound URL: DNS resolved to private or loopback address"); + } + + return directResponse; + } + + var bufferedContent = request.Content != null ? await request.Content.ReadAsByteArrayAsync(cancellationToken) : null; + var contentHeaderSnapshot = request.Content?.Headers + .Select(h => new KeyValuePair>(h.Key, h.Value.ToArray())) + .ToList(); + var requestHeaderSnapshot = request.Headers + .Select(h => new KeyValuePair>(h.Key, h.Value.ToArray())) + .ToList(); + var method = request.Method; + var version = request.Version; + var versionPolicy = request.VersionPolicy; + + var (response, _) = await OutboundRequestSecurity.SendWithValidatedRedirectsAsync( + currentUri => + { + var retryRequest = new HttpRequestMessage(method, currentUri) + { + Version = version, + VersionPolicy = versionPolicy + }; + + foreach (var header in requestHeaderSnapshot) + { + retryRequest.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + + if (bufferedContent != null) + { + var retryContent = new ByteArrayContent(bufferedContent); + if (contentHeaderSnapshot != null) + { + foreach (var header in contentHeaderSnapshot) + { + retryContent.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + } + + retryRequest.Content = retryContent; + } + + return retryRequest; + }, + request.RequestUri, + httpClientNoRedirect, + logger, + allowPrivateTargets: allowPrivateTargets, + cancellationToken: cancellationToken); + + return response; + } + } +} diff --git a/listenarr.application/Notification/NotificationService.cs b/listenarr.application/Notification/NotificationService.cs index 134091d4d..154946c51 100644 --- a/listenarr.application/Notification/NotificationService.cs +++ b/listenarr.application/Notification/NotificationService.cs @@ -34,20 +34,20 @@ namespace Listenarr.Application.Notification public class NotificationService : INotificationService { private readonly HttpClient _httpClient; - private readonly HttpClient _httpClientNoRedirect; private readonly ILogger _logger; private readonly IConfigurationService _configurationService; private readonly IRequestContextAccessor? _requestContextAccessor; private readonly INotificationPayloadBuilder _payloadBuilder; + private readonly NotificationHttpSender _httpSender; public NotificationService(HttpClient httpClient, ILogger logger, IConfigurationService configurationService, INotificationPayloadBuilder payloadBuilder, IRequestContextAccessor? requestContextAccessor = null) { _httpClient = httpClient; - _httpClientNoRedirect = httpClient; _logger = logger; _configurationService = configurationService; _payloadBuilder = payloadBuilder ?? throw new ArgumentNullException(nameof(payloadBuilder)); _requestContextAccessor = requestContextAccessor; + _httpSender = new NotificationHttpSender(httpClient, httpClient, logger, AllowPrivateWebhookTargetsForCurrentRequest); } // INotificationService interface stubs — webhook dispatch goes through SendNotificationAsync; @@ -111,99 +111,12 @@ private bool AllowPrivateWebhookTargetsForCurrentRequest() private async Task PostValidatedAsync(string url, HttpContent content, CancellationToken cancellationToken = default) { - using var request = new HttpRequestMessage(HttpMethod.Post, url) - { - Content = content - }; - - return await SendValidatedAsync(request, cancellationToken); + return await _httpSender.PostValidatedAsync(url, content, cancellationToken); } private async Task SendValidatedAsync(HttpRequestMessage request, CancellationToken cancellationToken = default) { - if (request.RequestUri == null) - { - throw new InvalidOperationException("Outbound notification request URI is required."); - } - - var allowPrivateTargets = AllowPrivateWebhookTargetsForCurrentRequest(); - if (!OutboundRequestSecurity.TryValidateExternalHttpUri(request.RequestUri, out var uriReason, allowPrivateTargets)) - { - throw new InvalidOperationException($"Blocked outbound URL: {uriReason}"); - } - - if (!await OutboundRequestSecurity.TryValidateResolvedExternalHttpUriAsync(request.RequestUri, _logger, allowPrivateTargets)) - { - throw new InvalidOperationException("Blocked outbound URL: DNS resolved to private or loopback address"); - } - - if (ReferenceEquals(_httpClientNoRedirect, _httpClient)) - { - var directResponse = await _httpClient.SendAsync(request, cancellationToken); - var finalUri = directResponse.RequestMessage?.RequestUri ?? request.RequestUri; - if (!OutboundRequestSecurity.TryValidateExternalHttpUri(finalUri, out var finalReason, allowPrivateTargets)) - { - directResponse.Dispose(); - throw new InvalidOperationException($"Blocked final outbound URL: {finalReason}"); - } - - if (!await OutboundRequestSecurity.TryValidateResolvedExternalHttpUriAsync(finalUri, _logger, allowPrivateTargets)) - { - directResponse.Dispose(); - throw new InvalidOperationException("Blocked final outbound URL: DNS resolved to private or loopback address"); - } - - return directResponse; - } - - var bufferedContent = request.Content != null ? await request.Content.ReadAsByteArrayAsync(cancellationToken) : null; - var contentHeaderSnapshot = request.Content?.Headers - .Select(h => new KeyValuePair>(h.Key, h.Value.ToArray())) - .ToList(); - var requestHeaderSnapshot = request.Headers - .Select(h => new KeyValuePair>(h.Key, h.Value.ToArray())) - .ToList(); - var method = request.Method; - var version = request.Version; - var versionPolicy = request.VersionPolicy; - - var (response, _) = await OutboundRequestSecurity.SendWithValidatedRedirectsAsync( - currentUri => - { - var retryRequest = new HttpRequestMessage(method, currentUri) - { - Version = version, - VersionPolicy = versionPolicy - }; - - foreach (var header in requestHeaderSnapshot) - { - retryRequest.Headers.TryAddWithoutValidation(header.Key, header.Value); - } - - if (bufferedContent != null) - { - var retryContent = new ByteArrayContent(bufferedContent); - if (contentHeaderSnapshot != null) - { - foreach (var header in contentHeaderSnapshot) - { - retryContent.Headers.TryAddWithoutValidation(header.Key, header.Value); - } - } - - retryRequest.Content = retryContent; - } - - return retryRequest; - }, - request.RequestUri, - _httpClientNoRedirect, - _logger, - allowPrivateTargets: allowPrivateTargets, - cancellationToken: cancellationToken); - - return response; + return await _httpSender.SendValidatedAsync(request, cancellationToken); } /// From a7da58525b3a38694ec78550073ef3791d04ddad Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Sun, 14 Jun 2026 07:20:23 -0400 Subject: [PATCH 76/84] refactor: reuse search image normalization - Replace inline search result image cache handling with SearchResponseMapper - Remove the now-unused controller image path wrapper - Preserve search-controller focused test coverage --- listenarr.api/Controllers/SearchController.cs | 46 +------------------ 1 file changed, 1 insertion(+), 45 deletions(-) diff --git a/listenarr.api/Controllers/SearchController.cs b/listenarr.api/Controllers/SearchController.cs index de7c67a81..23141a11d 100644 --- a/listenarr.api/Controllers/SearchController.cs +++ b/listenarr.api/Controllers/SearchController.cs @@ -76,9 +76,6 @@ public SearchController( Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); } - private string BuildApiImagePath(string identifier, string? sourceUrl = null) - => _responseMapper.BuildApiImagePath(identifier, HttpContext, sourceUrl: sourceUrl); - /// /// Perform a combined metadata and indexer search using a structured request body. /// Supports simple (metadata-only) and advanced (indexer) search modes. @@ -144,49 +141,8 @@ public async Task>> Search( } } - // Normalize/canonicalize images for returned search results so the - // frontend receives local /api/v{version}/images/{asin} URLs when possible. var mdResults = response.MetadataResults; - var cacheService = _imageCacheService; - - if (cacheService != null && mdResults != null) - { - foreach (var r in mdResults) - { - try - { - if (r == null) continue; - if (string.IsNullOrWhiteSpace(r.Asin)) continue; - - var asin = r.Asin!; - - var cached = await cacheService.GetCachedImagePathAsync(asin); - if (!string.IsNullOrWhiteSpace(cached)) - { - r.ImageUrl = BuildApiImagePath(asin); - continue; - } - - var imageUrl = r.ImageUrl; - if (!string.IsNullOrWhiteSpace(imageUrl)) - { - var url = imageUrl!; - if (url.StartsWith("http://") || url.StartsWith("https://")) - { - var downloaded = await cacheService.DownloadAndCacheImageAsync(url, asin); - if (!string.IsNullOrWhiteSpace(downloaded)) - { - r.ImageUrl = BuildApiImagePath(asin); - } - } - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Failed to ensure cached image for search result ASIN {Asin}", r.Asin); - } - } - } + await _responseMapper.NormalizeMetadataResultImagesAsync(mdResults, HttpContext!, "search result"); if (enrichedOnly && mdResults != null) { From b2e46960f842a8795e2d64e8e92c3d5699ddb874 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Sun, 14 Jun 2026 07:23:00 -0400 Subject: [PATCH 77/84] refactor: extract mam download url builder - Move MyAnonamouse torrent download URL construction into a focused helper - Preserve mam_id decode and encode behavior - Keep parser mapping behavior unchanged --- .../Search/MyAnonamouseDownloadUrlBuilder.cs | 52 +++++++++++++++++++ .../Search/MyAnonamouseResponseParser.cs | 26 +--------- 2 files changed, 53 insertions(+), 25 deletions(-) create mode 100644 listenarr.application/Search/MyAnonamouseDownloadUrlBuilder.cs diff --git a/listenarr.application/Search/MyAnonamouseDownloadUrlBuilder.cs b/listenarr.application/Search/MyAnonamouseDownloadUrlBuilder.cs new file mode 100644 index 000000000..eefb57c41 --- /dev/null +++ b/listenarr.application/Search/MyAnonamouseDownloadUrlBuilder.cs @@ -0,0 +1,52 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +using Listenarr.Application.Common; +using Listenarr.Domain.Models; + +namespace Listenarr.Application.Search +{ + internal static class MyAnonamouseDownloadUrlBuilder + { + public static string Build(string dlHash, Indexer indexer) + { + if (string.IsNullOrEmpty(dlHash)) + { + return string.Empty; + } + + var baseUrl = (indexer.Url ?? "https://www.myanonamouse.net").TrimEnd('/'); + var downloadUrl = $"{baseUrl}/tor/download.php/{dlHash}"; + var mamIdLocal = MyAnonamouseHelper.TryGetMamId(indexer.AdditionalSettings); + if (!string.IsNullOrEmpty(mamIdLocal)) + { + try + { + mamIdLocal = Uri.UnescapeDataString(mamIdLocal); + } + catch (Exception caughtEx) when (caughtEx is not OperationCanceledException && caughtEx is not OutOfMemoryException && caughtEx is not StackOverflowException) + { + System.Diagnostics.Debug.WriteLine("Suppressed non-fatal exception in catch block."); + } + + downloadUrl += $"?mam_id={Uri.EscapeDataString(mamIdLocal)}"; + } + + return downloadUrl; + } + } +} diff --git a/listenarr.application/Search/MyAnonamouseResponseParser.cs b/listenarr.application/Search/MyAnonamouseResponseParser.cs index 27d791b9b..3e2a1b6b1 100644 --- a/listenarr.application/Search/MyAnonamouseResponseParser.cs +++ b/listenarr.application/Search/MyAnonamouseResponseParser.cs @@ -17,7 +17,6 @@ */ using System.Text.Json; -using Listenarr.Application.Common; using Listenarr.Application.Security; using Listenarr.Domain.Models; using Microsoft.Extensions.Logging; @@ -334,30 +333,7 @@ public static List Parse(string jsonResponse, Indexer index } } - // Build download URL (include mam_id if configured) - var downloadUrl = ""; - if (!string.IsNullOrEmpty(dlHash)) - { - var baseUrl = (indexer.Url ?? "https://www.myanonamouse.net").TrimEnd('/'); - downloadUrl = $"{baseUrl}/tor/download.php/{dlHash}"; - var mamIdLocal = MyAnonamouseHelper.TryGetMamId(indexer.AdditionalSettings); - if (!string.IsNullOrEmpty(mamIdLocal)) - { - // Normalize mam_id: if the stored value is already percent-encoded, unescape it first - // to avoid double-encoding sequences like "%252B". Then escape once for safe query use. - try - { - mamIdLocal = Uri.UnescapeDataString(mamIdLocal); - } - catch (Exception caughtEx_19) when (caughtEx_19 is not OperationCanceledException && caughtEx_19 is not OutOfMemoryException && caughtEx_19 is not StackOverflowException) - { - // If unescape fails for any reason, fall back to original value - System.Diagnostics.Debug.WriteLine("Suppressed non-fatal exception in catch block."); - } - - downloadUrl += $"?mam_id={Uri.EscapeDataString(mamIdLocal)}"; - } - } + var downloadUrl = MyAnonamouseDownloadUrlBuilder.Build(dlHash, indexer); // Preserve raw language code for later flagging/flags list string rawLangCode = string.Empty; From 201a816683dce97385070061e4c0fb85e9c0b18d Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Sun, 14 Jun 2026 07:34:03 -0400 Subject: [PATCH 78/84] refactor: extract sabnzbd queue workflows - Move SABnzbd queue/history merge behavior into a focused workflow - Move SABnzbd import item resolution into a dedicated resolver - Keep adapter interface and request/response semantics unchanged --- .../Adapters/SabnzbdAdapter.cs | 294 +----------------- .../Adapters/SabnzbdImportItemResolver.cs | 155 +++++++++ .../Adapters/SabnzbdQueueFetchWorkflow.cs | 160 ++++++++++ 3 files changed, 322 insertions(+), 287 deletions(-) create mode 100644 listenarr.infrastructure/Adapters/SabnzbdImportItemResolver.cs create mode 100644 listenarr.infrastructure/Adapters/SabnzbdQueueFetchWorkflow.cs diff --git a/listenarr.infrastructure/Adapters/SabnzbdAdapter.cs b/listenarr.infrastructure/Adapters/SabnzbdAdapter.cs index 9473d1b8a..4e5c12064 100644 --- a/listenarr.infrastructure/Adapters/SabnzbdAdapter.cs +++ b/listenarr.infrastructure/Adapters/SabnzbdAdapter.cs @@ -38,6 +38,8 @@ public class SabnzbdAdapter : IDownloadClientAdapter private readonly SabnzbdRequestBuilder _requestBuilder; private readonly SabnzbdDownloadPollingWorkflow _downloadPollingWorkflow; private readonly SabnzbdRemovalWorkflow _removalWorkflow; + private readonly SabnzbdQueueFetchWorkflow _queueFetchWorkflow; + private readonly SabnzbdImportItemResolver _importItemResolver; public SabnzbdAdapter( IHttpClientFactory httpFactory, @@ -52,6 +54,8 @@ public SabnzbdAdapter( _requestBuilder = new SabnzbdRequestBuilder(); _downloadPollingWorkflow = new SabnzbdDownloadPollingWorkflow(_httpFactory, _requestBuilder, _appMetricsService, _logger, ClientType); _removalWorkflow = new SabnzbdRemovalWorkflow(_httpFactory, _requestBuilder, _logger, ClientType); + _queueFetchWorkflow = new SabnzbdQueueFetchWorkflow(_httpFactory, _requestBuilder, _logger, ClientType); + _importItemResolver = new SabnzbdImportItemResolver(_httpFactory, _requestBuilder, _logger, ClientType); } public async Task<(bool Success, string Message)> TestConnectionAsync(DownloadClientConfiguration client, CancellationToken ct = default) @@ -185,123 +189,7 @@ public async Task RemoveAsync(DownloadClientConfiguration client, string i public async Task> GetQueueAsync(DownloadClientConfiguration client, CancellationToken ct = default) { - var items = new List(); - if (client == null) return items; - - var configuredCategory = DownloadClientCategoryFilter.GetConfiguredCategory(client); - - try - { - var requestContext = _requestBuilder.CreateContext(client); - if (!requestContext.HasApiKey) - { - _logger.LogWarning("SABnzbd API key not configured for {ClientName}", client.Name); - return items; - } - - var requestUrl = _requestBuilder.BuildUrl(requestContext, new Dictionary - { - ["mode"] = "queue", - ["output"] = "json" - }); - _logger.LogDebug("SABnzbd queue request (redacted): {Url}", LogRedaction.RedactText(requestUrl, _requestBuilder.BuildSensitiveValues(requestContext))); - - var http = _httpFactory.CreateClient(ClientType); - var response = await http.GetAsync(requestUrl, ct); - if (!response.IsSuccessStatusCode) - { - _logger.LogWarning("SABnzbd queue request failed with status {Status}", response.StatusCode); - return items; - } - - var jsonContent = await response.Content.ReadAsStringAsync(ct); - if (string.IsNullOrWhiteSpace(jsonContent)) - { - _logger.LogWarning("SABnzbd returned empty response for client {ClientName}", LogRedaction.SanitizeText(client.Name)); - return items; - } - - var doc = JsonDocument.Parse(jsonContent); - if (!doc.RootElement.TryGetProperty("queue", out var queue)) return items; - if (!queue.TryGetProperty("slots", out var slots) || slots.ValueKind != JsonValueKind.Array) return items; - - var speed = 0.0; - if (queue.TryGetProperty("speed", out var speedProp)) - { - speed = SabnzbdResponseMapper.ParseSpeed(speedProp.GetString() ?? "0"); - } - - foreach (var slot in slots.EnumerateArray()) - { - try - { - var queueItem = SabnzbdResponseMapper.MapQueueSlotToQueueItem(client, slot, configuredCategory ?? string.Empty, speed); - if (queueItem != null) - { - items.Add(queueItem); - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogError(ex, "Error parsing SABnzbd queue item"); - } - } - _logger.LogInformation("Retrieved {Count} items from SABnzbd active queue", items.Count); - - // Also fetch completed items from SABnzbd history — SABnzbd moves finished - // downloads out of the queue into history, so without this the - // CompletedDownloadHandlingService can never find them for import/removal. - var existingNzoIds = new HashSet(items.Select(i => i.Id), StringComparer.OrdinalIgnoreCase); - try - { - var historyUrl = _requestBuilder.BuildUrl(requestContext, new Dictionary - { - ["mode"] = "history", - ["output"] = "json", - ["limit"] = "30" - }); - var historyResp = await http.GetAsync(historyUrl, ct); - if (historyResp.IsSuccessStatusCode) - { - var historyText = await historyResp.Content.ReadAsStringAsync(ct); - if (!string.IsNullOrWhiteSpace(historyText)) - { - var histDoc = JsonDocument.Parse(historyText); - if (histDoc.RootElement.TryGetProperty("history", out var history) && - history.TryGetProperty("slots", out var histSlots) && - histSlots.ValueKind == JsonValueKind.Array) - { - foreach (var slot in histSlots.EnumerateArray()) - { - try - { - var historyItem = SabnzbdResponseMapper.MapHistorySlotToQueueItem(client, slot, configuredCategory ?? string.Empty, existingNzoIds); - if (historyItem != null) - { - items.Add(historyItem); - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogDebug(ex, "Error parsing SABnzbd history item"); - } - } - _logger.LogInformation("Retrieved {Count} total items from SABnzbd (queue + history)", items.Count); - } - } - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogDebug(ex, "Failed to fetch SABnzbd history for queue enrichment (non-fatal)"); - } - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogError(ex, "Error getting SABnzbd queue"); - } - - return items; + return await _queueFetchWorkflow.GetQueueAsync(client, ct); } public async Task> GetRecentHistoryAsync(DownloadClientConfiguration client, int limit = 100, CancellationToken ct = default) @@ -430,94 +318,7 @@ public async Task GetImportItemAsync( DownloadClientItem? previousAttempt = null, CancellationToken ct = default) { - // Clone to avoid mutating the original - var result = item.Clone(); - - // If OutputPath is already set and exists, use it - if (!string.IsNullOrEmpty(result.OutputPath)) - { - var localPath = result.OutputPath; - if (SabnzbdImportPathResolver.IsExistingLocalPath(localPath)) - { - result.OutputPath = localPath; - return result; - } - } - - try - { - // Query SABnzbd history for the download - var requestContext = _requestBuilder.CreateContext(client); - if (!requestContext.HasApiKey) - { - _logger.LogWarning("SABnzbd API key not configured for client {ClientId}", client.Id); - return result; - } - - // Query history with nzo_id filter - var historyUrl = _requestBuilder.BuildUrl(requestContext, new Dictionary - { - ["mode"] = "history", - ["output"] = "json" - }); - var http = _httpFactory.CreateClient(ClientType); - var historyResp = await http.GetAsync(historyUrl, ct); - - if (!historyResp.IsSuccessStatusCode) - { - _logger.LogWarning("Failed to query SABnzbd history for download {NzoId}", item.DownloadId); - return result; - } - - var historyText = await historyResp.Content.ReadAsStringAsync(ct); - if (string.IsNullOrWhiteSpace(historyText)) - { - return result; - } - - var doc = JsonDocument.Parse(historyText); - if (!doc.RootElement.TryGetProperty("history", out var history) || - !history.TryGetProperty("slots", out var slots) || - slots.ValueKind != JsonValueKind.Array) - { - _logger.LogWarning("Invalid SABnzbd history response format"); - return result; - } - - // Find matching history entry (case-insensitive comparison) - foreach (var slot in slots.EnumerateArray()) - { - var nzoId = slot.TryGetProperty("nzo_id", out var nzo) ? nzo.GetString() ?? string.Empty : string.Empty; - if (!string.Equals(nzoId, item.DownloadId, StringComparison.OrdinalIgnoreCase)) continue; - - // Extract storage path - var storage = SabnzbdImportPathResolver.GetStoragePath(slot); - if (string.IsNullOrEmpty(storage)) - { - _logger.LogWarning("No storage path found for SABnzbd download {NzoId}", item.DownloadId); - return result; - } - - // Apply path mapping - var localContentPath = storage; - result.OutputPath = localContentPath; - - _logger.LogDebug( - "Resolved SABnzbd content path for {NzoId}: {ContentPath}", - item.DownloadId, - localContentPath); - - return result; - } - - _logger.LogWarning("Download {NzoId} not found in SABnzbd history", item.DownloadId); - return result; - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Error resolving import item for SABnzbd download {NzoId}", item.DownloadId); - return result; - } + return await _importItemResolver.GetImportItemAsync(client, item, ct); } /// @@ -532,88 +333,7 @@ public async Task GetImportItemAsync( QueueItem? previousAttempt = null, CancellationToken ct = default) { - // Clone to avoid mutating the original - var result = queueItem.Clone(); - - // If ContentPath is already set and exists, use it - if (!string.IsNullOrEmpty(result.ContentPath)) - { - var localPath = result.ContentPath; - if (SabnzbdImportPathResolver.IsExistingLocalPath(localPath)) - { - result.ContentPath = localPath; - return result; - } - } - - try - { - // Query SABnzbd history for the download - var requestContext = _requestBuilder.CreateContext(client); - if (!requestContext.HasApiKey) - { - _logger.LogWarning("SABnzbd API key not configured for client {ClientId}", client.Id); - return result; - } - - // Query history with nzo_id filter - var historyUrl = _requestBuilder.BuildUrl(requestContext, new Dictionary - { - ["mode"] = "history", - ["output"] = "json" - }); - var http = _httpFactory.CreateClient(ClientType); - var historyResp = await http.GetAsync(historyUrl, ct); - - if (!historyResp.IsSuccessStatusCode) - { - _logger.LogWarning("Failed to query SABnzbd history for download {NzoId}", queueItem.Id); - return result; - } - - var historyText = await historyResp.Content.ReadAsStringAsync(ct); - if (string.IsNullOrWhiteSpace(historyText)) - { - return result; - } - - var doc = JsonDocument.Parse(historyText); - if (!doc.RootElement.TryGetProperty("history", out var history) || - !history.TryGetProperty("slots", out var slots) || - slots.ValueKind != JsonValueKind.Array) - { - _logger.LogWarning("Invalid SABnzbd history response format"); - return result; - } - - // Find matching history entry - foreach (var slot in slots.EnumerateArray()) - { - var nzoId = slot.TryGetProperty("nzo_id", out var nzo) ? nzo.GetString() ?? string.Empty : string.Empty; - if (nzoId != queueItem.Id) continue; - - // Extract storage path - var storage = SabnzbdImportPathResolver.GetStoragePath(slot); - if (string.IsNullOrEmpty(storage)) - { - _logger.LogWarning("No storage path found for SABnzbd download {NzoId}", queueItem.Id); - return result; - } - - result.ContentPath = storage; - _logger.LogDebug($"Resolved SABnzbd content path for {queueItem.Id}: {result.ContentPath}"); - - return result; - } - - _logger.LogWarning("Download {NzoId} not found in SABnzbd history", queueItem.Id); - return result; - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Error resolving import item for SABnzbd download {NzoId}", queueItem.Id); - return result; - } + return await _importItemResolver.GetImportItemAsync(client, queueItem, ct); } public async Task> FetchDownloadsAsync( diff --git a/listenarr.infrastructure/Adapters/SabnzbdImportItemResolver.cs b/listenarr.infrastructure/Adapters/SabnzbdImportItemResolver.cs new file mode 100644 index 000000000..0163f446b --- /dev/null +++ b/listenarr.infrastructure/Adapters/SabnzbdImportItemResolver.cs @@ -0,0 +1,155 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +using System.Text.Json; +using Listenarr.Domain.Models; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Infrastructure.Adapters +{ + internal sealed class SabnzbdImportItemResolver( + IHttpClientFactory httpFactory, + SabnzbdRequestBuilder requestBuilder, + ILogger logger, + string clientType) + { + public async Task GetImportItemAsync( + DownloadClientConfiguration client, + DownloadClientItem item, + CancellationToken ct = default) + { + var result = item.Clone(); + + if (!string.IsNullOrEmpty(result.OutputPath)) + { + var localPath = result.OutputPath; + if (SabnzbdImportPathResolver.IsExistingLocalPath(localPath)) + { + result.OutputPath = localPath; + return result; + } + } + + var storage = await ResolveHistoryStorageAsync(client, item.DownloadId, ct); + if (!string.IsNullOrEmpty(storage)) + { + result.OutputPath = storage; + logger.LogDebug( + "Resolved SABnzbd content path for {NzoId}: {ContentPath}", + item.DownloadId, + storage); + } + + return result; + } + + public async Task GetImportItemAsync( + DownloadClientConfiguration client, + QueueItem queueItem, + CancellationToken ct = default) + { + var result = queueItem.Clone(); + + if (!string.IsNullOrEmpty(result.ContentPath)) + { + var localPath = result.ContentPath; + if (SabnzbdImportPathResolver.IsExistingLocalPath(localPath)) + { + result.ContentPath = localPath; + return result; + } + } + + var storage = await ResolveHistoryStorageAsync(client, queueItem.Id, ct); + if (!string.IsNullOrEmpty(storage)) + { + result.ContentPath = storage; + logger.LogDebug($"Resolved SABnzbd content path for {queueItem.Id}: {result.ContentPath}"); + } + + return result; + } + + private async Task ResolveHistoryStorageAsync( + DownloadClientConfiguration client, + string nzoId, + CancellationToken ct) + { + try + { + var requestContext = requestBuilder.CreateContext(client); + if (!requestContext.HasApiKey) + { + logger.LogWarning("SABnzbd API key not configured for client {ClientId}", client.Id); + return null; + } + + var historyUrl = requestBuilder.BuildUrl(requestContext, new Dictionary + { + ["mode"] = "history", + ["output"] = "json" + }); + var http = httpFactory.CreateClient(clientType); + var historyResp = await http.GetAsync(historyUrl, ct); + + if (!historyResp.IsSuccessStatusCode) + { + logger.LogWarning("Failed to query SABnzbd history for download {NzoId}", nzoId); + return null; + } + + var historyText = await historyResp.Content.ReadAsStringAsync(ct); + if (string.IsNullOrWhiteSpace(historyText)) + { + return null; + } + + var doc = JsonDocument.Parse(historyText); + if (!doc.RootElement.TryGetProperty("history", out var history) || + !history.TryGetProperty("slots", out var slots) || + slots.ValueKind != JsonValueKind.Array) + { + logger.LogWarning("Invalid SABnzbd history response format"); + return null; + } + + foreach (var slot in slots.EnumerateArray()) + { + var slotNzoId = slot.TryGetProperty("nzo_id", out var nzo) ? nzo.GetString() ?? string.Empty : string.Empty; + if (!string.Equals(slotNzoId, nzoId, StringComparison.OrdinalIgnoreCase)) continue; + + var storage = SabnzbdImportPathResolver.GetStoragePath(slot); + if (string.IsNullOrEmpty(storage)) + { + logger.LogWarning("No storage path found for SABnzbd download {NzoId}", nzoId); + return null; + } + + return storage; + } + + logger.LogWarning("Download {NzoId} not found in SABnzbd history", nzoId); + return null; + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + logger.LogWarning(ex, "Error resolving import item for SABnzbd download {NzoId}", nzoId); + return null; + } + } + } +} diff --git a/listenarr.infrastructure/Adapters/SabnzbdQueueFetchWorkflow.cs b/listenarr.infrastructure/Adapters/SabnzbdQueueFetchWorkflow.cs new file mode 100644 index 000000000..8dde32aa5 --- /dev/null +++ b/listenarr.infrastructure/Adapters/SabnzbdQueueFetchWorkflow.cs @@ -0,0 +1,160 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +using System.Text.Json; +using Listenarr.Application.Security; +using Listenarr.Domain.Models; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Infrastructure.Adapters +{ + internal sealed class SabnzbdQueueFetchWorkflow( + IHttpClientFactory httpFactory, + SabnzbdRequestBuilder requestBuilder, + ILogger logger, + string clientType) + { + public async Task> GetQueueAsync(DownloadClientConfiguration client, CancellationToken ct = default) + { + var items = new List(); + if (client == null) return items; + + var configuredCategory = DownloadClientCategoryFilter.GetConfiguredCategory(client); + + try + { + var requestContext = requestBuilder.CreateContext(client); + if (!requestContext.HasApiKey) + { + logger.LogWarning("SABnzbd API key not configured for {ClientName}", client.Name); + return items; + } + + var requestUrl = requestBuilder.BuildUrl(requestContext, new Dictionary + { + ["mode"] = "queue", + ["output"] = "json" + }); + logger.LogDebug("SABnzbd queue request (redacted): {Url}", LogRedaction.RedactText(requestUrl, requestBuilder.BuildSensitiveValues(requestContext))); + + var http = httpFactory.CreateClient(clientType); + var response = await http.GetAsync(requestUrl, ct); + if (!response.IsSuccessStatusCode) + { + logger.LogWarning("SABnzbd queue request failed with status {Status}", response.StatusCode); + return items; + } + + var jsonContent = await response.Content.ReadAsStringAsync(ct); + if (string.IsNullOrWhiteSpace(jsonContent)) + { + logger.LogWarning("SABnzbd returned empty response for client {ClientName}", LogRedaction.SanitizeText(client.Name)); + return items; + } + + var doc = JsonDocument.Parse(jsonContent); + if (!doc.RootElement.TryGetProperty("queue", out var queue)) return items; + if (!queue.TryGetProperty("slots", out var slots) || slots.ValueKind != JsonValueKind.Array) return items; + + var speed = 0.0; + if (queue.TryGetProperty("speed", out var speedProp)) + { + speed = SabnzbdResponseMapper.ParseSpeed(speedProp.GetString() ?? "0"); + } + + foreach (var slot in slots.EnumerateArray()) + { + try + { + var queueItem = SabnzbdResponseMapper.MapQueueSlotToQueueItem(client, slot, configuredCategory ?? string.Empty, speed); + if (queueItem != null) + { + items.Add(queueItem); + } + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + logger.LogError(ex, "Error parsing SABnzbd queue item"); + } + } + logger.LogInformation("Retrieved {Count} items from SABnzbd active queue", items.Count); + + await AddHistoryItemsAsync(client, requestContext, configuredCategory, items, http, ct); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + logger.LogError(ex, "Error getting SABnzbd queue"); + } + + return items; + } + + private async Task AddHistoryItemsAsync( + DownloadClientConfiguration client, + SabnzbdRequestContext requestContext, + string? configuredCategory, + List items, + HttpClient http, + CancellationToken ct) + { + var existingNzoIds = new HashSet(items.Select(i => i.Id), StringComparer.OrdinalIgnoreCase); + try + { + var historyUrl = requestBuilder.BuildUrl(requestContext, new Dictionary + { + ["mode"] = "history", + ["output"] = "json", + ["limit"] = "30" + }); + var historyResp = await http.GetAsync(historyUrl, ct); + if (historyResp.IsSuccessStatusCode) + { + var historyText = await historyResp.Content.ReadAsStringAsync(ct); + if (!string.IsNullOrWhiteSpace(historyText)) + { + var histDoc = JsonDocument.Parse(historyText); + if (histDoc.RootElement.TryGetProperty("history", out var history) && + history.TryGetProperty("slots", out var histSlots) && + histSlots.ValueKind == JsonValueKind.Array) + { + foreach (var slot in histSlots.EnumerateArray()) + { + try + { + var historyItem = SabnzbdResponseMapper.MapHistorySlotToQueueItem(client, slot, configuredCategory ?? string.Empty, existingNzoIds); + if (historyItem != null) + { + items.Add(historyItem); + } + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + logger.LogDebug(ex, "Error parsing SABnzbd history item"); + } + } + logger.LogInformation("Retrieved {Count} total items from SABnzbd (queue + history)", items.Count); + } + } + } + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + logger.LogDebug(ex, "Failed to fetch SABnzbd history for queue enrichment (non-fatal)"); + } + } + } +} From f0f186fc1c764173865bdc1d3643a4492d9f05b2 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Sun, 14 Jun 2026 07:39:19 -0400 Subject: [PATCH 79/84] refactor: extract nzbget add workflows - Move NZBGet add orchestration into a focused workflow - Move NZBGet import item history resolution into a dedicated resolver - Keep adapter interface and XML-RPC behavior unchanged --- .../Adapters/NzbgetAdapter.cs | 290 +----------------- .../Adapters/NzbgetAddWorkflow.cs | 104 +++++++ .../Adapters/NzbgetImportItemResolver.cs | 150 +++++++++ 3 files changed, 266 insertions(+), 278 deletions(-) create mode 100644 listenarr.infrastructure/Adapters/NzbgetAddWorkflow.cs create mode 100644 listenarr.infrastructure/Adapters/NzbgetImportItemResolver.cs diff --git a/listenarr.infrastructure/Adapters/NzbgetAdapter.cs b/listenarr.infrastructure/Adapters/NzbgetAdapter.cs index 1289cd966..dbabce53a 100644 --- a/listenarr.infrastructure/Adapters/NzbgetAdapter.cs +++ b/listenarr.infrastructure/Adapters/NzbgetAdapter.cs @@ -16,7 +16,6 @@ * along with this program. If not, see . */ using System.Net; -using System.Text.Json; using Listenarr.Application.Interfaces; using Listenarr.Application.Security; using Listenarr.Domain.Models; @@ -30,26 +29,27 @@ public class NzbgetAdapter : IDownloadClientAdapter public string ClientType => "nzbget"; public DownloadProtocol Protocol => DownloadProtocol.Usenet; - private readonly IHttpClientFactory _httpClientFactory; - private readonly INzbUrlResolver _nzbUrlResolver; private readonly ILogger _logger; private readonly NzbgetXmlRpcClient _xmlRpcClient; - private readonly NzbgetNzbDownloader _nzbDownloader; private readonly NzbgetDownloadPollingWorkflow _downloadPollingWorkflow; private readonly NzbgetRemovalWorkflow _removalWorkflow; + private readonly NzbgetAddWorkflow _addWorkflow; + private readonly NzbgetImportItemResolver _importItemResolver; public NzbgetAdapter( IHttpClientFactory httpClientFactory, INzbUrlResolver nzbUrlResolver, ILogger logger) { - _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); - _nzbUrlResolver = nzbUrlResolver ?? throw new ArgumentNullException(nameof(nzbUrlResolver)); + ArgumentNullException.ThrowIfNull(httpClientFactory); + ArgumentNullException.ThrowIfNull(nzbUrlResolver); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _xmlRpcClient = new NzbgetXmlRpcClient(_httpClientFactory, ClientType); - _nzbDownloader = new NzbgetNzbDownloader(_httpClientFactory, ClientType, _logger); - _downloadPollingWorkflow = new NzbgetDownloadPollingWorkflow(_httpClientFactory, _logger, ClientType); + _xmlRpcClient = new NzbgetXmlRpcClient(httpClientFactory, ClientType); + var nzbDownloader = new NzbgetNzbDownloader(httpClientFactory, ClientType, _logger); + _downloadPollingWorkflow = new NzbgetDownloadPollingWorkflow(httpClientFactory, _logger, ClientType); _removalWorkflow = new NzbgetRemovalWorkflow(_xmlRpcClient, _logger); + _addWorkflow = new NzbgetAddWorkflow(nzbUrlResolver, _xmlRpcClient, nzbDownloader, _logger); + _importItemResolver = new NzbgetImportItemResolver(_xmlRpcClient, _logger); } public async Task<(bool Success, string Message)> TestConnectionAsync(DownloadClientConfiguration client, CancellationToken ct = default) @@ -101,153 +101,7 @@ public NzbgetAdapter( public async Task AddAsync(DownloadClientConfiguration client, SearchResult result, CancellationToken ct = default) { - if (client == null) throw new ArgumentNullException(nameof(client)); - if (result == null) throw new ArgumentNullException(nameof(result)); - - var (nzbUrl, indexerApiKey) = await _nzbUrlResolver.ResolveAsync(result, ct); - if (string.IsNullOrWhiteSpace(nzbUrl)) - { - throw new ArgumentException("No NZB URL available for NZBGet", nameof(result)); - } - - // Use JSON-RPC for all versions (v25+ REST API has authentication issues) - _logger.LogInformation("Using NZBGet JSON-RPC append method"); - return await AddViaJsonRpcAsync(client, result, nzbUrl, indexerApiKey, ct); - } - - private async Task AddViaRestApiAsync( - DownloadClientConfiguration client, - SearchResult result, - string nzbUrl, - string? indexerApiKey, - CancellationToken ct) - { - var category = NzbgetRequestPlanner.ResolveCategory(client); - var priority = NzbgetRequestPlanner.ResolvePriority(client); - var droneId = Guid.NewGuid().ToString().Replace("-", string.Empty); - - // Download NZB content - var nzbBytes = await _nzbDownloader.DownloadAsync(nzbUrl, indexerApiKey, ct); - var nzbFileName = NzbgetRequestPlanner.BuildNzbFileName(result); - - var uploadUrl = DownloadClientUriBuilder.BuildUri(client, "/api/v2/nzb"); - - using var httpClient = _httpClientFactory.CreateClient(ClientType); - using var content = new MultipartFormDataContent(); - - // Add NZB file - content.Add(new ByteArrayContent(nzbBytes), "file", nzbFileName); - - // Add metadata - if (!string.IsNullOrWhiteSpace(category)) - { - content.Add(new StringContent(category), "Category"); - } - - if (priority != 0) - { - content.Add(new StringContent(priority.ToString()), "Priority"); - } - - // Add drone tracking parameter - content.Add(new StringContent($"drone={droneId}"), "PPParameters"); - - using var request = new HttpRequestMessage(HttpMethod.Post, uploadUrl) - { - Content = content - }; - - // Add Basic Auth (NZBGet v25 REST API accepts Basic Auth) - var authHeader = NzbgetAuthentication.BuildAuthHeader(client); - if (authHeader != null) - { - request.Headers.Authorization = authHeader; - } - - _logger.LogDebug("NZBGet REST API POST to {Url} with file {FileName}", LogRedaction.SanitizeUrl(uploadUrl.ToString()), LogRedaction.SanitizeText(nzbFileName)); - - using var response = await httpClient.SendAsync(request, ct); - var responseBody = await response.Content.ReadAsStringAsync(ct); - - if (!response.IsSuccessStatusCode) - { - _logger.LogError("NZBGet REST API upload failed: {StatusCode} - {Body}", response.StatusCode, responseBody); - throw new Exception($"NZBGet REST API upload error: {response.StatusCode} - {responseBody}"); - } - - // Parse response JSON to get queue ID - var jsonResponse = JsonSerializer.Deserialize(responseBody); - if (jsonResponse.TryGetProperty("nzbId", out var nzbIdProp)) - { - var queueId = nzbIdProp.GetInt32(); - _logger.LogInformation("NZBGet REST API added '{Title}' with queue ID {QueueId}", LogRedaction.SanitizeText(result.Title), queueId); - return queueId.ToString(); - } - - _logger.LogWarning("NZBGet REST API response missing nzbId: {Body}", responseBody); - return null; - } - - private async Task AddViaJsonRpcAsync( - DownloadClientConfiguration client, - SearchResult result, - string nzbUrl, - string? indexerApiKey, - CancellationToken ct) - { - var category = NzbgetRequestPlanner.ResolveCategory(client); - var priority = NzbgetRequestPlanner.ResolvePriority(client); - var droneId = Guid.NewGuid().ToString().Replace("-", string.Empty); - - // Download and base64-encode the NZB content - var nzbBytes = await _nzbDownloader.DownloadAsync(nzbUrl, indexerApiKey, ct); - var nzbContentBase64 = Convert.ToBase64String(nzbBytes); - var nzbFileName = NzbgetRequestPlanner.BuildNzbFileName(result); - - // PPParameters as array of structs (key-value pairs) - var ppParams = new[] - { - new Dictionary - { - { "Name", "drone" }, - { "Value", droneId } - } - }; - - try - { - // Call append via XML-RPC - _logger.LogInformation("Calling NZBGet append via XML-RPC for '{Title}'", LogRedaction.SanitizeText(result.Title)); - var appendResult = await _xmlRpcClient.CallAsync(client, "append", - nzbFileName, - nzbContentBase64, - category ?? string.Empty, - priority, - false, // addToTop - false, // addPaused - string.Empty, // dupeKey - 0, // dupeScore - "SCORE", // dupeMode - ppParams - ); - - var queueId = int.Parse(appendResult.Element("i4")?.Value ?? appendResult.Element("int")?.Value ?? "0"); - - if (queueId <= 0) - { - _logger.LogWarning("NZBGet rejected NZB '{Title}', returned ID: {QueueId}", LogRedaction.SanitizeText(result.Title), queueId); - return null; - } - - _logger.LogInformation("NZBGet XML-RPC queued '{Title}' with ID {QueueId}, droneId: {DroneId}", LogRedaction.SanitizeText(result.Title), queueId, LogRedaction.SanitizeText(droneId)); - // Return the NZBID so it can be stored and used for removal later - return queueId.ToString(); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogError(ex, "Failed to add NZB via XML-RPC"); - throw; - } + return await _addWorkflow.AddAsync(client, result, ct); } public async Task RemoveAsync(DownloadClientConfiguration client, string id, bool deleteFiles = false, CancellationToken ct = default) @@ -420,64 +274,7 @@ public async Task GetImportItemAsync( DownloadClientItem? previousAttempt = null, CancellationToken ct = default) { - // Clone to avoid mutating the original - var result = item.Clone(); - - // If OutputPath is already set and exists, use it - if (!string.IsNullOrEmpty(result.OutputPath)) - { - return result; - } - - try - { - // Query NZBGet history for the download - var historyResult = await _xmlRpcClient.CallAsync(client, "history", false); - var arrayData = historyResult.Element("array")?.Element("data"); - - if (arrayData == null) - { - _logger.LogWarning("Invalid NZBGet history response format"); - return result; - } - - // Find matching history entry by ID - foreach (var members in arrayData.Elements("value") - .Select(valueElement => valueElement.Element("struct")) - .Where(structElement => structElement != null) - .Select(structElement => structElement!.Elements("member").ToDictionary( - m => m.Element("name")?.Value ?? string.Empty, - m => m.Element("value")?.Elements().FirstOrDefault()?.Value ?? string.Empty))) - { - var entryId = members.GetValueOrDefault("ID", string.Empty); - if (!string.Equals(entryId, item.DownloadId, StringComparison.OrdinalIgnoreCase)) continue; - - // Extract destination directory - var destDir = members.GetValueOrDefault("DestDir", string.Empty); - if (string.IsNullOrEmpty(destDir)) - { - _logger.LogWarning("No DestDir found for NZBGet download {Id}", item.DownloadId); - return result; - } - - result.OutputPath = destDir; - - _logger.LogDebug( - "Resolved NZBGet content path for {Id}: {ContentPath}", - item.DownloadId, - destDir); - - return result; - } - - _logger.LogWarning("Download {Id} not found in NZBGet history", item.DownloadId); - return result; - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Error resolving import item for NZBGet download {Id}", item.DownloadId); - return result; - } + return await _importItemResolver.GetImportItemAsync(client, item); } /// @@ -492,70 +289,7 @@ public async Task GetImportItemAsync( QueueItem? previousAttempt = null, CancellationToken ct = default) { - // Clone to avoid mutating the original - var result = queueItem.Clone(); - - // If ContentPath is already set and exists, use it - if (!string.IsNullOrEmpty(result.ContentPath)) - { - return result; - } - - try - { - // Query NZBGet history for the download - var historyResult = await _xmlRpcClient.CallAsync(client, "history", false); - var arrayData = historyResult.Element("array")?.Element("data"); - - if (arrayData == null) - { - _logger.LogWarning("Failed to query NZBGet history for download {NzbId}", queueItem.Id); - return result; - } - - // Find the history entry matching our download ID - foreach (var members in arrayData.Elements("value") - .Select(valueElement => valueElement.Element("struct")) - .Where(structElement => structElement != null) - .Select(structElement => structElement!.Elements("member").ToDictionary( - m => m.Element("name")?.Value ?? string.Empty, - m => m.Element("value")?.Elements().FirstOrDefault()?.Value ?? string.Empty))) - { - var entryId = members.GetValueOrDefault("NZBID", string.Empty); - if (entryId != queueItem.Id) continue; - - // Found matching entry - extract path - // FinalDir is preferred (post-processing destination), fallback to DestDir - var finalDir = members.GetValueOrDefault("FinalDir", string.Empty); - var destDir = members.GetValueOrDefault("DestDir", string.Empty); - var contentPath = !string.IsNullOrEmpty(finalDir) ? finalDir : destDir; - - if (string.IsNullOrEmpty(contentPath)) - { - _logger.LogWarning("No FinalDir or DestDir found for NZB {NzbId}", queueItem.Id); - return result; - } - - // Apply path mapping - var localContentPath = contentPath; - result.ContentPath = localContentPath; - - _logger.LogDebug( - "Resolved NZBGet content path for {NzbId}: {ContentPath}", - queueItem.Id, - localContentPath); - - return result; - } - - _logger.LogWarning("Download {NzbId} not found in NZBGet history", queueItem.Id); - return result; - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Error resolving import item for NZBGet download {NzbId}", queueItem.Id); - return result; - } + return await _importItemResolver.GetImportItemAsync(client, queueItem); } public async Task> FetchDownloadsAsync( diff --git a/listenarr.infrastructure/Adapters/NzbgetAddWorkflow.cs b/listenarr.infrastructure/Adapters/NzbgetAddWorkflow.cs new file mode 100644 index 000000000..f56c85c20 --- /dev/null +++ b/listenarr.infrastructure/Adapters/NzbgetAddWorkflow.cs @@ -0,0 +1,104 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +using Listenarr.Application.Security; +using Listenarr.Application.Interfaces; +using Listenarr.Domain.Models; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Infrastructure.Adapters +{ + internal sealed class NzbgetAddWorkflow( + INzbUrlResolver nzbUrlResolver, + NzbgetXmlRpcClient xmlRpcClient, + NzbgetNzbDownloader nzbDownloader, + ILogger logger) + { + public async Task AddAsync(DownloadClientConfiguration client, SearchResult result, CancellationToken ct = default) + { + if (client == null) throw new ArgumentNullException(nameof(client)); + if (result == null) throw new ArgumentNullException(nameof(result)); + + var (nzbUrl, indexerApiKey) = await nzbUrlResolver.ResolveAsync(result, ct); + if (string.IsNullOrWhiteSpace(nzbUrl)) + { + throw new ArgumentException("No NZB URL available for NZBGet", nameof(result)); + } + + logger.LogInformation("Using NZBGet JSON-RPC append method"); + return await AddViaJsonRpcAsync(client, result, nzbUrl, indexerApiKey, ct); + } + + private async Task AddViaJsonRpcAsync( + DownloadClientConfiguration client, + SearchResult result, + string nzbUrl, + string? indexerApiKey, + CancellationToken ct) + { + var category = NzbgetRequestPlanner.ResolveCategory(client); + var priority = NzbgetRequestPlanner.ResolvePriority(client); + var droneId = Guid.NewGuid().ToString().Replace("-", string.Empty); + + var nzbBytes = await nzbDownloader.DownloadAsync(nzbUrl, indexerApiKey, ct); + var nzbContentBase64 = Convert.ToBase64String(nzbBytes); + var nzbFileName = NzbgetRequestPlanner.BuildNzbFileName(result); + + var ppParams = new[] + { + new Dictionary + { + { "Name", "drone" }, + { "Value", droneId } + } + }; + + try + { + logger.LogInformation("Calling NZBGet append via XML-RPC for '{Title}'", LogRedaction.SanitizeText(result.Title)); + var appendResult = await xmlRpcClient.CallAsync(client, "append", + nzbFileName, + nzbContentBase64, + category ?? string.Empty, + priority, + false, + false, + string.Empty, + 0, + "SCORE", + ppParams + ); + + var queueId = int.Parse(appendResult.Element("i4")?.Value ?? appendResult.Element("int")?.Value ?? "0"); + + if (queueId <= 0) + { + logger.LogWarning("NZBGet rejected NZB '{Title}', returned ID: {QueueId}", LogRedaction.SanitizeText(result.Title), queueId); + return null; + } + + logger.LogInformation("NZBGet XML-RPC queued '{Title}' with ID {QueueId}, droneId: {DroneId}", LogRedaction.SanitizeText(result.Title), queueId, LogRedaction.SanitizeText(droneId)); + return queueId.ToString(); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + logger.LogError(ex, "Failed to add NZB via XML-RPC"); + throw; + } + } + } +} diff --git a/listenarr.infrastructure/Adapters/NzbgetImportItemResolver.cs b/listenarr.infrastructure/Adapters/NzbgetImportItemResolver.cs new file mode 100644 index 000000000..378d9d31b --- /dev/null +++ b/listenarr.infrastructure/Adapters/NzbgetImportItemResolver.cs @@ -0,0 +1,150 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +using Listenarr.Domain.Models; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Infrastructure.Adapters +{ + internal sealed class NzbgetImportItemResolver( + NzbgetXmlRpcClient xmlRpcClient, + ILogger logger) + { + public async Task GetImportItemAsync( + DownloadClientConfiguration client, + DownloadClientItem item) + { + var result = item.Clone(); + + if (!string.IsNullOrEmpty(result.OutputPath)) + { + return result; + } + + var members = await FindHistoryEntryAsync( + client, + item.DownloadId, + "ID", + item.DownloadId, + StringComparison.OrdinalIgnoreCase); + if (members == null) + { + return result; + } + + var destDir = members.GetValueOrDefault("DestDir", string.Empty); + if (string.IsNullOrEmpty(destDir)) + { + logger.LogWarning("No DestDir found for NZBGet download {Id}", item.DownloadId); + return result; + } + + result.OutputPath = destDir; + + logger.LogDebug( + "Resolved NZBGet content path for {Id}: {ContentPath}", + item.DownloadId, + destDir); + + return result; + } + + public async Task GetImportItemAsync( + DownloadClientConfiguration client, + QueueItem queueItem) + { + var result = queueItem.Clone(); + + if (!string.IsNullOrEmpty(result.ContentPath)) + { + return result; + } + + var members = await FindHistoryEntryAsync( + client, + queueItem.Id, + "NZBID", + queueItem.Id, + StringComparison.Ordinal); + if (members == null) + { + return result; + } + + var finalDir = members.GetValueOrDefault("FinalDir", string.Empty); + var destDir = members.GetValueOrDefault("DestDir", string.Empty); + var contentPath = !string.IsNullOrEmpty(finalDir) ? finalDir : destDir; + + if (string.IsNullOrEmpty(contentPath)) + { + logger.LogWarning("No FinalDir or DestDir found for NZB {NzbId}", queueItem.Id); + return result; + } + + result.ContentPath = contentPath; + + logger.LogDebug( + "Resolved NZBGet content path for {NzbId}: {ContentPath}", + queueItem.Id, + contentPath); + + return result; + } + + private async Task?> FindHistoryEntryAsync( + DownloadClientConfiguration client, + string id, + string idField, + string logId, + StringComparison comparison) + { + try + { + var historyResult = await xmlRpcClient.CallAsync(client, "history", false); + var arrayData = historyResult.Element("array")?.Element("data"); + + if (arrayData == null) + { + logger.LogWarning("Invalid NZBGet history response format"); + return null; + } + + foreach (var members in arrayData.Elements("value") + .Select(valueElement => valueElement.Element("struct")) + .Where(structElement => structElement != null) + .Select(structElement => structElement!.Elements("member").ToDictionary( + m => m.Element("name")?.Value ?? string.Empty, + m => m.Element("value")?.Elements().FirstOrDefault()?.Value ?? string.Empty))) + { + var entryId = members.GetValueOrDefault(idField, string.Empty); + if (string.Equals(entryId, id, comparison)) + { + return members; + } + } + + logger.LogWarning("Download {Id} not found in NZBGet history", logId); + return null; + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + logger.LogWarning(ex, "Error resolving import item for NZBGet download {Id}", logId); + return null; + } + } + } +} From 9ea409f5a726d29a6b352706e09af2ec2fc05fdd Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Sun, 14 Jun 2026 07:43:16 -0400 Subject: [PATCH 80/84] refactor: extract indexer debug search - Move MyAnonamouse debug-search orchestration into a focused workflow - Keep route, response payloads, and local parsed-search behavior unchanged --- .../Controllers/IndexerDebugSearchWorkflow.cs | 251 ++++++++++++++++++ .../Controllers/IndexersController.cs | 167 +----------- 2 files changed, 259 insertions(+), 159 deletions(-) create mode 100644 listenarr.api/Controllers/IndexerDebugSearchWorkflow.cs diff --git a/listenarr.api/Controllers/IndexerDebugSearchWorkflow.cs b/listenarr.api/Controllers/IndexerDebugSearchWorkflow.cs new file mode 100644 index 000000000..608e05273 --- /dev/null +++ b/listenarr.api/Controllers/IndexerDebugSearchWorkflow.cs @@ -0,0 +1,251 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +using Listenarr.Domain.Models; +using System.Net.Http.Headers; +using System.Text.Json; + +namespace Listenarr.Api.Controllers +{ + public sealed class IndexerDebugSearchWorkflow( + HttpClient httpClient, + ILogger logger) + { + public async Task ExecuteMyAnonamouseAsync( + Indexer indexer, + int id, + JsonElement body, + HttpRequest requestContext, + HttpContext httpContext) + { + try + { + var query = ExtractQuery(body); + var mamId = ExtractMamId(indexer, id); + + if (string.IsNullOrEmpty(mamId)) + { + return IndexerDebugSearchWorkflowResult.BadRequest(new { success = false, message = "MAM ID missing in indexer settings" }); + } + + var testUrl = $"{indexer.Url.TrimEnd('/')}/tor/js/loadSearchJSONbasic.php"; + using var request = BuildMamSearchRequest(testUrl, query); + using var client = BuildMamHttpClient(indexer, mamId); + + using var response = await client.SendAsync(request); + var raw = await response.Content.ReadAsStringAsync(); + var parsed = await TryGetParsedResultsAsync(indexer, id, query, requestContext, httpContext); + + return IndexerDebugSearchWorkflowResult.Ok(new + { + success = true, + status = (int)response.StatusCode, + raw, + parsedCount = parsed.Count, + parsed + }); + } + catch (HttpRequestException ex) + { + return BuildDebugFailure(id, ex); + } + catch (TaskCanceledException ex) + { + return BuildDebugFailure(id, ex); + } + catch (JsonException ex) + { + return BuildDebugFailure(id, ex); + } + catch (UriFormatException ex) + { + return BuildDebugFailure(id, ex); + } + catch (InvalidOperationException ex) + { + return BuildDebugFailure(id, ex); + } + } + + private static string ExtractQuery(JsonElement body) + { + if (body.ValueKind == JsonValueKind.Object && body.TryGetProperty("query", out var q)) + { + return q.GetString() ?? "test"; + } + + return "test"; + } + + private string ExtractMamId(Indexer indexer, int id) + { + var mamId = string.Empty; + if (string.IsNullOrEmpty(indexer.AdditionalSettings)) + { + return mamId; + } + + try + { + using var doc = JsonDocument.Parse(indexer.AdditionalSettings); + if (doc.RootElement.TryGetProperty("mam_id", out var mamIdProperty)) + { + mamId = mamIdProperty.GetString() ?? string.Empty; + } + } + catch (JsonException ex) + { + logger.LogDebug(ex, "Failed parsing AdditionalSettings JSON for indexer {Id} during debug search", id); + } + + return mamId; + } + + private static HttpRequestMessage BuildMamSearchRequest(string testUrl, string query) + { + var formData = new Dictionary + { + ["tor[text]"] = query, + ["tor[srchIn][]"] = "title", + ["tor[searchType]"] = "all", + ["tor[searchIn]"] = "torrents", + ["tor[cat][]"] = "0", + ["tor[browseFlagsHideVsShow]"] = "0", + ["tor[startDate]"] = "", + ["tor[endDate]"] = "", + ["tor[hash]"] = "", + ["tor[sortType]"] = "default", + ["tor[startNumber]"] = "0", + ["perpage"] = "100", + ["thumbnail"] = "false", + ["dlLink"] = "", + ["description"] = "" + }; + + var request = new HttpRequestMessage(HttpMethod.Post, testUrl) + { + Content = new FormUrlEncodedContent(formData) + }; + + ApplyMamHeaders(request.Headers); + request.Headers.Referrer = new Uri("https://www.myanonamouse.net/"); + return request; + } + + private HttpClient BuildMamHttpClient(Indexer indexer, string mamId) + { + var cookieContainer = new System.Net.CookieContainer(); + var baseUrl = indexer.Url.TrimEnd('/'); + var baseUri = new Uri(baseUrl.StartsWith("http", StringComparison.OrdinalIgnoreCase) ? baseUrl : "https://" + baseUrl); + cookieContainer.Add(baseUri, new System.Net.Cookie("mam_id", mamId)); + + try + { + var host = baseUri.Host; + if (!host.StartsWith("www.", StringComparison.OrdinalIgnoreCase)) + { + var wwwUri = new Uri($"{baseUri.Scheme}://www.{host}"); + cookieContainer.Add(wwwUri, new System.Net.Cookie("mam_id", mamId)); + } + } + catch (UriFormatException ex) + { + logger.LogDebug(ex, "Failed to add www host alias cookie for MyAnonamouse debug search request to {Host}", baseUri.Host); + } + catch (System.Net.CookieException ex) + { + logger.LogDebug(ex, "Failed to add www host alias cookie for MyAnonamouse debug search request to {Host}", baseUri.Host); + } + + var handler = new HttpClientHandler { CookieContainer = cookieContainer, UseCookies = true }; + var client = new HttpClient(handler); + ApplyMamHeaders(client.DefaultRequestHeaders); + client.DefaultRequestHeaders.Referrer = new Uri("https://www.myanonamouse.net/"); + return client; + } + + private async Task> TryGetParsedResultsAsync( + Indexer indexer, + int id, + string query, + HttpRequest requestContext, + HttpContext httpContext) + { + try + { + var scheme = requestContext.Scheme; + var hostVal = requestContext.Host.Value; + var localSearchUrl = $"{scheme}://{hostVal}{HttpApiVersionUtils.BuildApiPath($"/search/{id}", httpContext)}?query={Uri.EscapeDataString(query)}"; + using var localResp = await httpClient.GetAsync(localSearchUrl); + if (!localResp.IsSuccessStatusCode) + { + return new List(); + } + + var json = await localResp.Content.ReadAsStringAsync(); + var options = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }; + return JsonSerializer.Deserialize>(json, options) ?? new List(); + } + catch (HttpRequestException ex) + { + logger.LogDebug(ex, "Failed to evaluate local parsed search results for indexer {Id}", indexer.Id); + } + catch (TaskCanceledException ex) + { + logger.LogDebug(ex, "Failed to evaluate local parsed search results for indexer {Id}", indexer.Id); + } + catch (JsonException ex) + { + logger.LogDebug(ex, "Failed to evaluate local parsed search results for indexer {Id}", indexer.Id); + } + catch (UriFormatException ex) + { + logger.LogDebug(ex, "Failed to evaluate local parsed search results for indexer {Id}", indexer.Id); + } + catch (InvalidOperationException ex) + { + logger.LogDebug(ex, "Failed to evaluate local parsed search results for indexer {Id}", indexer.Id); + } + + return new List(); + } + + private IndexerDebugSearchWorkflowResult BuildDebugFailure(int id, Exception ex) + { + logger.LogWarning(ex, "MyAnonamouse debug search failed for indexer {Id}", id); + return IndexerDebugSearchWorkflowResult.BadRequest(new { success = false, error = ex.Message }); + } + + private static void ApplyMamHeaders(HttpRequestHeaders headers) + { + headers.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"); + headers.Accept.ParseAdd("application/json, text/javascript, */*; q=0.01"); + headers.AcceptLanguage.ParseAdd("en-US,en;q=0.9"); + } + } + + public sealed record IndexerDebugSearchWorkflowResult(int StatusCode, object Payload) + { + public static IndexerDebugSearchWorkflowResult Ok(object payload) => new(StatusCodes.Status200OK, payload); + + public static IndexerDebugSearchWorkflowResult BadRequest(object payload) => new(StatusCodes.Status400BadRequest, payload); + } +} diff --git a/listenarr.api/Controllers/IndexersController.cs b/listenarr.api/Controllers/IndexersController.cs index fd63e9cb8..10336eca8 100644 --- a/listenarr.api/Controllers/IndexersController.cs +++ b/listenarr.api/Controllers/IndexersController.cs @@ -34,10 +34,10 @@ public class IndexersController : ControllerBase { private readonly IIndexerRepository _indexerRepository; private readonly ILogger _logger; - private readonly HttpClient _httpClient; private readonly IConfigurationService _configurationService; private readonly IndexerTestWorkflow _indexerTestWorkflow; private readonly ProwlarrIndexerImportWorkflow _prowlarrImportWorkflow; + private readonly IndexerDebugSearchWorkflow _debugSearchWorkflow; private readonly IndexerResponseRedactor _responseRedactor; public IndexersController( @@ -46,11 +46,11 @@ public IndexersController( HttpClient httpClient, IConfigurationService configurationService, IndexerTestWorkflow? indexerTestWorkflow = null, - ProwlarrIndexerImportWorkflow? prowlarrImportWorkflow = null) + ProwlarrIndexerImportWorkflow? prowlarrImportWorkflow = null, + IndexerDebugSearchWorkflow? debugSearchWorkflow = null) { _indexerRepository = indexerRepository; _logger = logger; - _httpClient = httpClient; _configurationService = configurationService; _indexerTestWorkflow = indexerTestWorkflow ?? new IndexerTestWorkflow( indexerRepository, @@ -61,6 +61,9 @@ public IndexersController( configurationService, httpClient, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + _debugSearchWorkflow = debugSearchWorkflow ?? new IndexerDebugSearchWorkflow( + httpClient, + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); _responseRedactor = new IndexerResponseRedactor(); } @@ -418,162 +421,8 @@ public async Task DebugMyAnonamouseSearch(int id, [FromBody] Json var indexer = await _indexerRepository.GetByIdAsync(id); if (indexer == null) return NotFound(new { message = "Indexer not found" }); - try - { - string query = "test"; - if (body.ValueKind == JsonValueKind.Object && body.TryGetProperty("query", out var q)) - { - query = q.GetString() ?? "test"; - } - - // Parse mam_id from AdditionalSettings - string mamId = string.Empty; - if (!string.IsNullOrEmpty(indexer.AdditionalSettings)) - { - try - { - using var doc = JsonDocument.Parse(indexer.AdditionalSettings); - if (doc.RootElement.TryGetProperty("mam_id", out var mamIdProperty)) - mamId = mamIdProperty.GetString() ?? string.Empty; - } - catch (JsonException ex) - { - _logger.LogDebug(ex, "Failed parsing AdditionalSettings JSON for indexer {Id} during debug search", id); - } - } - - if (string.IsNullOrEmpty(mamId)) - return BadRequest(new { success = false, message = "MAM ID missing in indexer settings" }); - - var testUrl = $"{indexer.Url.TrimEnd('/')}/tor/js/loadSearchJSONbasic.php"; - - var formData = new Dictionary - { - ["tor[text]"] = query, - ["tor[srchIn][]"] = "title", - ["tor[searchType]"] = "all", - ["tor[searchIn]"] = "torrents", - ["tor[cat][]"] = "0", - ["tor[browseFlagsHideVsShow]"] = "0", - ["tor[startDate]"] = "", - ["tor[endDate]"] = "", - ["tor[hash]"] = "", - ["tor[sortType]"] = "default", - ["tor[startNumber]"] = "0", - ["perpage"] = "100", - ["thumbnail"] = "false", - ["dlLink"] = "", - ["description"] = "" - }; - - using var request = new HttpRequestMessage(HttpMethod.Post, testUrl) - { - Content = new FormUrlEncodedContent(formData) - }; - - request.Headers.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"); - request.Headers.Accept.ParseAdd("application/json, text/javascript, */*; q=0.01"); - request.Headers.AcceptLanguage.ParseAdd("en-US,en;q=0.9"); - request.Headers.Referrer = new Uri("https://www.myanonamouse.net/"); - - var cookieContainer = new System.Net.CookieContainer(); - var baseUrl = indexer.Url.TrimEnd('/'); - var baseUri = new Uri(baseUrl.StartsWith("http", StringComparison.OrdinalIgnoreCase) ? baseUrl : "https://" + baseUrl); - cookieContainer.Add(baseUri, new System.Net.Cookie("mam_id", mamId)); - try - { - var host = baseUri.Host; - if (!host.StartsWith("www.", StringComparison.OrdinalIgnoreCase)) - { - var wwwUri = new Uri($"{baseUri.Scheme}://www.{host}"); - cookieContainer.Add(wwwUri, new System.Net.Cookie("mam_id", mamId)); - } - } - catch (UriFormatException ex) - { - _logger.LogDebug(ex, "Failed to add www host alias cookie for MyAnonamouse debug search request to {Host}", baseUri.Host); - } - catch (System.Net.CookieException ex) - { - _logger.LogDebug(ex, "Failed to add www host alias cookie for MyAnonamouse debug search request to {Host}", baseUri.Host); - } - - var handler = new HttpClientHandler { CookieContainer = cookieContainer, UseCookies = true }; - using var client = new HttpClient(handler); - client.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"); - client.DefaultRequestHeaders.Accept.ParseAdd("application/json, text/javascript, */*; q=0.01"); - client.DefaultRequestHeaders.AcceptLanguage.ParseAdd("en-US,en;q=0.9"); - client.DefaultRequestHeaders.Referrer = new Uri("https://www.myanonamouse.net/"); - - using var response = await client.SendAsync(request); - var raw = await response.Content.ReadAsStringAsync(); - - // Get parsed results via the Search API on this host - var parsed = new List(); - try - { - var scheme = Request.Scheme; - var hostVal = Request.Host.Value; - var localSearchUrl = $"{scheme}://{hostVal}{HttpApiVersionUtils.BuildApiPath($"/search/{id}", HttpContext)}?query={Uri.EscapeDataString(query)}"; - using var localResp = await _httpClient.GetAsync(localSearchUrl); - if (localResp.IsSuccessStatusCode) - { - var json = await localResp.Content.ReadAsStringAsync(); - var options = new System.Text.Json.JsonSerializerOptions - { - PropertyNameCaseInsensitive = true - }; - parsed = System.Text.Json.JsonSerializer.Deserialize>(json, options) ?? new List(); - } - } - catch (HttpRequestException ex) - { - _logger.LogDebug(ex, "Failed to evaluate local parsed search results for indexer {Id}", indexer.Id); - } - catch (TaskCanceledException ex) - { - _logger.LogDebug(ex, "Failed to evaluate local parsed search results for indexer {Id}", indexer.Id); - } - catch (JsonException ex) - { - _logger.LogDebug(ex, "Failed to evaluate local parsed search results for indexer {Id}", indexer.Id); - } - catch (UriFormatException ex) - { - _logger.LogDebug(ex, "Failed to evaluate local parsed search results for indexer {Id}", indexer.Id); - } - catch (InvalidOperationException ex) - { - _logger.LogDebug(ex, "Failed to evaluate local parsed search results for indexer {Id}", indexer.Id); - } - - return Ok(new { success = true, status = (int)response.StatusCode, raw, parsedCount = parsed.Count, parsed }); - } - catch (HttpRequestException ex) - { - _logger.LogWarning(ex, "MyAnonamouse debug search failed for indexer {Id}", id); - return BadRequest(new { success = false, error = ex.Message }); - } - catch (TaskCanceledException ex) - { - _logger.LogWarning(ex, "MyAnonamouse debug search failed for indexer {Id}", id); - return BadRequest(new { success = false, error = ex.Message }); - } - catch (JsonException ex) - { - _logger.LogWarning(ex, "MyAnonamouse debug search failed for indexer {Id}", id); - return BadRequest(new { success = false, error = ex.Message }); - } - catch (UriFormatException ex) - { - _logger.LogWarning(ex, "MyAnonamouse debug search failed for indexer {Id}", id); - return BadRequest(new { success = false, error = ex.Message }); - } - catch (InvalidOperationException ex) - { - _logger.LogWarning(ex, "MyAnonamouse debug search failed for indexer {Id}", id); - return BadRequest(new { success = false, error = ex.Message }); - } + var result = await _debugSearchWorkflow.ExecuteMyAnonamouseAsync(indexer, id, body, Request, HttpContext); + return StatusCode(result.StatusCode, result.Payload); } /// From abe7afb1f0d01104c00cbfcf8cb53fb4c96b8f43 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Sun, 14 Jun 2026 07:47:54 -0400 Subject: [PATCH 81/84] refactor: extract automatic search helpers - Move existing-quality cutoff evaluation into a focused helper - Move automatic download-client selection into a dedicated selector - Keep search scheduling, scoring, and download queuing behavior unchanged --- .../AutomaticSearchDownloadClientSelector.cs | 83 ++++++ .../Search/AutomaticSearchQualityEvaluator.cs | 207 +++++++++++++ .../Search/AutomaticSearchService.cs | 276 +----------------- 3 files changed, 297 insertions(+), 269 deletions(-) create mode 100644 listenarr.infrastructure/HostedServices/Search/AutomaticSearchDownloadClientSelector.cs create mode 100644 listenarr.infrastructure/HostedServices/Search/AutomaticSearchQualityEvaluator.cs diff --git a/listenarr.infrastructure/HostedServices/Search/AutomaticSearchDownloadClientSelector.cs b/listenarr.infrastructure/HostedServices/Search/AutomaticSearchDownloadClientSelector.cs new file mode 100644 index 000000000..852caa23c --- /dev/null +++ b/listenarr.infrastructure/HostedServices/Search/AutomaticSearchDownloadClientSelector.cs @@ -0,0 +1,83 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +using Listenarr.Application.Interfaces; +using Listenarr.Domain.Models; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Infrastructure.HostedServices.Search +{ + internal sealed class AutomaticSearchDownloadClientSelector( + IServiceScopeFactory serviceScopeFactory, + ILogger logger) + { + public async Task GetAppropriateDownloadClientAsync(SearchResult searchResult, bool isTorrent) + { + using var scope = serviceScopeFactory.CreateScope(); + var configurationService = scope.ServiceProvider.GetRequiredService(); + + if (searchResult.DownloadType?.Equals("DDL", StringComparison.OrdinalIgnoreCase) == true) + { + logger.LogInformation("DDL download detected, using internal DDL client"); + return "DDL"; + } + + var clients = await configurationService.GetDownloadClientConfigurationsAsync(); + var enabledClients = clients.Where(c => c.IsEnabled).ToList(); + + logger.LogInformation("Looking for {ClientType} client. Found {Count} enabled download clients: {Clients}", + isTorrent ? "torrent" : "NZB", + enabledClients.Count, + string.Join(", ", enabledClients.Select(c => $"{c.Name} ({c.Type})"))); + + if (isTorrent) + { + var client = enabledClients.FirstOrDefault(c => c.Type.Equals("qbittorrent", StringComparison.OrdinalIgnoreCase)) + ?? enabledClients.FirstOrDefault(c => c.Type.Equals("transmission", StringComparison.OrdinalIgnoreCase)); + + if (client != null) + { + logger.LogInformation("Selected torrent client: {ClientName} ({ClientType})", client.Name, client.Type); + } + else + { + logger.LogWarning("No torrent client (qBittorrent or Transmission) found among enabled clients"); + } + + return client?.Id ?? string.Empty; + } + else + { + var client = enabledClients.FirstOrDefault(c => c.Type.Equals("sabnzbd", StringComparison.OrdinalIgnoreCase)) + ?? enabledClients.FirstOrDefault(c => c.Type.Equals("nzbget", StringComparison.OrdinalIgnoreCase)); + + if (client != null) + { + logger.LogInformation("Selected NZB client: {ClientName} ({ClientType})", client.Name, client.Type); + } + else + { + logger.LogWarning("No NZB client (SABnzbd or NZBGet) found among enabled clients"); + } + + return client?.Id ?? string.Empty; + } + } + } +} diff --git a/listenarr.infrastructure/HostedServices/Search/AutomaticSearchQualityEvaluator.cs b/listenarr.infrastructure/HostedServices/Search/AutomaticSearchQualityEvaluator.cs new file mode 100644 index 000000000..a0a80f626 --- /dev/null +++ b/listenarr.infrastructure/HostedServices/Search/AutomaticSearchQualityEvaluator.cs @@ -0,0 +1,207 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +using Listenarr.Application.Interfaces.Repositories; +using Listenarr.Domain.Models; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Infrastructure.HostedServices.Search +{ + internal sealed class AutomaticSearchQualityEvaluator(ILogger logger) + { + public async Task<(bool cutoffMet, string? bestExistingQuality)> GetExistingQualityAsync( + Audiobook audiobook, + IDownloadRepository downloadRepository, + IAudiobookFileRepository fileRepository, + CancellationToken ct = default) + { + var cutoffMet = await IsQualityCutoffMetAsync(audiobook, downloadRepository, fileRepository, ct); + string? bestQuality = null; + + var allDownloads = await downloadRepository.GetByAudiobookIdAsync(audiobook.Id, ct); + var existingDownloads = allDownloads.Where(d => d.Status == DownloadStatus.Completed).ToList(); + + foreach (var dl in existingDownloads.Where(dl => dl.Metadata != null)) + { + if (dl.Metadata!.TryGetValue("Quality", out var qobj) && qobj != null) + { + var q = qobj.ToString(); + if (!string.IsNullOrEmpty(q)) + { + if (bestQuality == null) bestQuality = q; + else if (IsQualityBetter(q, bestQuality, audiobook.QualityProfile)) bestQuality = q; + } + } + } + + var existingFiles = await fileRepository.GetByAudiobookIdAsync(audiobook.Id, ct); + + foreach (var fq in existingFiles.Select(DetermineFileQuality).Where(fq => !string.IsNullOrEmpty(fq))) + { + if (bestQuality == null) bestQuality = fq; + else if (IsQualityBetter(fq, bestQuality, audiobook.QualityProfile)) bestQuality = fq; + } + + return (cutoffMet, bestQuality); + } + + public bool IsQualityBetter(string? candidateQuality, string? existingQuality, QualityProfile? profile) + { + if (string.IsNullOrEmpty(candidateQuality)) return false; + if (string.IsNullOrEmpty(existingQuality)) return true; + if (profile == null) return false; + + var cand = profile.Qualities.FirstOrDefault(q => q.Quality == candidateQuality); + var exist = profile.Qualities.FirstOrDefault(q => q.Quality == existingQuality); + + if (cand == null) return false; + if (exist == null) return true; + + return cand.Priority > exist.Priority; + } + + private async Task IsQualityCutoffMetAsync( + Audiobook audiobook, + IDownloadRepository downloadRepository, + IAudiobookFileRepository fileRepository, + CancellationToken ct = default) + { + if (audiobook.QualityProfile == null) + return false; + + var allDownloads = await downloadRepository.GetByAudiobookIdAsync(audiobook.Id, ct); + var existingDownloads = allDownloads.Where(d => + d.Status == DownloadStatus.Completed || + d.Status == DownloadStatus.Downloading || + d.Status == DownloadStatus.ImportPending).ToList(); + + var existingFiles = await fileRepository.GetByAudiobookIdAsync(audiobook.Id, ct); + + if (!existingDownloads.Any() && !existingFiles.Any()) + return false; + + var cutoffQuality = audiobook.QualityProfile.Qualities + .FirstOrDefault(q => q.Quality == audiobook.QualityProfile.CutoffQuality); + + if (cutoffQuality == null) + return false; + + foreach (var download in existingDownloads) + { + if (download.Status == DownloadStatus.Completed && !string.IsNullOrEmpty(download.Metadata?.GetValueOrDefault("Quality")?.ToString())) + { + var downloadQuality = download.Metadata["Quality"].ToString(); + var downloadQualityDefinition = audiobook.QualityProfile.Qualities + .FirstOrDefault(q => q.Quality == downloadQuality); + + if (downloadQualityDefinition != null && downloadQualityDefinition.Priority >= cutoffQuality.Priority) + { + logger.LogDebug("Quality cutoff met for audiobook '{Title}' by completed download (Quality: {Quality})", + audiobook.Title, downloadQuality); + return true; + } + } + else if (download.Status == DownloadStatus.Downloading || download.Status == DownloadStatus.ImportPending) + { + logger.LogDebug("Quality cutoff assumed met for audiobook '{Title}' due to active download", audiobook.Title); + return true; + } + } + + foreach (var file in existingFiles) + { + var fileQuality = DetermineFileQuality(file); + if (!string.IsNullOrEmpty(fileQuality)) + { + var fileQualityDefinition = audiobook.QualityProfile.Qualities + .FirstOrDefault(q => q.Quality == fileQuality); + + if (fileQualityDefinition != null && fileQualityDefinition.Priority >= cutoffQuality.Priority) + { + logger.LogDebug("Quality cutoff met for audiobook '{Title}' by existing file (Quality: {Quality}, File: {FileName})", + audiobook.Title, fileQuality, Path.GetFileName(file.Path)); + return true; + } + } + } + + return false; + } + + private static string? DetermineFileQuality(AudiobookFile file) + { + if (!string.IsNullOrEmpty(file.Container)) + { + var container = file.Container.ToLower(); + if (container.Contains("flac")) return "FLAC"; + if (container.Contains("m4b") || container.Contains("m4a")) return "M4B"; + } + + if (!string.IsNullOrEmpty(file.Format)) + { + var format = file.Format.ToLower(); + if (format.Contains("flac")) return "FLAC"; + if (format.Contains("m4b") || format.Contains("m4a")) return "M4B"; + if (format.Contains("aac")) return "M4B"; + } + + if (file.Bitrate.HasValue) + { + var bitrate = file.Bitrate.Value; + var kbps = bitrate / 1000; + + if (kbps >= 320) return "MP3 320kbps"; + if (kbps >= 256) return "MP3 256kbps"; + if (kbps >= 192) return "MP3 192kbps"; + if (kbps >= 128) return "MP3 128kbps"; + if (kbps >= 64) return "MP3 64kbps"; + + return "MP3 64kbps"; + } + + if (!string.IsNullOrEmpty(file.Codec)) + { + var codec = file.Codec.ToLower(); + if (codec.Contains("flac")) return "FLAC"; + if (codec.Contains("aac")) return "M4B"; + if (codec.Contains("mp3")) return "MP3 128kbps"; + if (codec.Contains("opus")) return "M4B"; + } + + if (!string.IsNullOrEmpty(file.Path)) + { + var extension = Path.GetExtension(file.Path).ToLower(); + switch (extension) + { + case ".flac": + return "FLAC"; + case ".m4b": + case ".m4a": + return "M4B"; + case ".mp3": + return "MP3 128kbps"; + case ".aac": + case ".opus": + return "M4B"; + } + } + + return null; + } + } +} diff --git a/listenarr.infrastructure/HostedServices/Search/AutomaticSearchService.cs b/listenarr.infrastructure/HostedServices/Search/AutomaticSearchService.cs index 4b1bdb6f3..db6bdea66 100644 --- a/listenarr.infrastructure/HostedServices/Search/AutomaticSearchService.cs +++ b/listenarr.infrastructure/HostedServices/Search/AutomaticSearchService.cs @@ -29,6 +29,8 @@ public class AutomaticSearchService : BackgroundService private readonly ILogger _logger; private readonly IServiceScopeFactory _serviceScopeFactory; private readonly AutomaticSearchResultClassifier _resultClassifier; + private readonly AutomaticSearchQualityEvaluator _qualityEvaluator; + private readonly AutomaticSearchDownloadClientSelector _downloadClientSelector; private readonly TimeSpan _searchInterval = TimeSpan.FromHours(6); // Search every 6 hours public AutomaticSearchService( @@ -38,6 +40,8 @@ public AutomaticSearchService( _logger = logger; _serviceScopeFactory = serviceScopeFactory; _resultClassifier = new AutomaticSearchResultClassifier(_logger); + _qualityEvaluator = new AutomaticSearchQualityEvaluator(_logger); + _downloadClientSelector = new AutomaticSearchDownloadClientSelector(_serviceScopeFactory, _logger); } protected override async Task ExecuteAsync(CancellationToken stoppingToken) @@ -188,7 +192,7 @@ private async Task ProcessAudiobookAsync( } // Check existing quality and decide whether to search - var (cutoffMet, bestExistingQuality) = await GetExistingQualityAsync(audiobook, qualityProfileService, downloadRepository, fileRepository, stoppingToken); + var (cutoffMet, bestExistingQuality) = await _qualityEvaluator.GetExistingQualityAsync(audiobook, downloadRepository, fileRepository, stoppingToken); _logger.LogInformation("Audiobook '{Title}': cutoff met={CutoffMet}, best existing quality={BestQuality}", audiobook.Title, cutoffMet, bestExistingQuality ?? "none"); @@ -298,7 +302,7 @@ private async Task ProcessAudiobookAsync( // Check if the found result is better quality than what we already have if (!string.IsNullOrEmpty(bestExistingQuality)) { - var resultIsBetter = IsQualityBetter(topResult.SearchResult.Quality, bestExistingQuality, audiobook.QualityProfile); + var resultIsBetter = _qualityEvaluator.IsQualityBetter(topResult.SearchResult.Quality, bestExistingQuality, audiobook.QualityProfile); if (!resultIsBetter) { _logger.LogInformation("Top result quality '{ResultQuality}' is not better than existing quality '{ExistingQuality}' for audiobook '{Title}', skipping download", @@ -322,7 +326,7 @@ private async Task ProcessAudiobookAsync( { // Determine appropriate download client for this result var isTorrent = _resultClassifier.IsTorrentResult(topResult.SearchResult); - var downloadClientId = await GetAppropriateDownloadClientAsync(topResult.SearchResult, isTorrent); + var downloadClientId = await _downloadClientSelector.GetAppropriateDownloadClientAsync(topResult.SearchResult, isTorrent); if (string.IsNullOrEmpty(downloadClientId)) { @@ -345,271 +349,5 @@ private async Task ProcessAudiobookAsync( return downloadsQueued; } - private async Task IsQualityCutoffMetAsync( - Audiobook audiobook, - IQualityProfileService qualityProfileService, - IDownloadRepository downloadRepository, - IAudiobookFileRepository fileRepository, - CancellationToken ct = default) - { - if (audiobook.QualityProfile == null) - return false; - - // Get existing downloads for this audiobook - var allDownloads = await downloadRepository.GetByAudiobookIdAsync(audiobook.Id, ct); - var existingDownloads = allDownloads.Where(d => - d.Status == DownloadStatus.Completed || - d.Status == DownloadStatus.Downloading || - d.Status == DownloadStatus.ImportPending).ToList(); - - // Get existing files for this audiobook - var existingFiles = await fileRepository.GetByAudiobookIdAsync(audiobook.Id, ct); - - if (!existingDownloads.Any() && !existingFiles.Any()) - return false; - - // Check if any existing download meets or exceeds the cutoff quality - var cutoffQuality = audiobook.QualityProfile.Qualities - .FirstOrDefault(q => q.Quality == audiobook.QualityProfile.CutoffQuality); - - if (cutoffQuality == null) - return false; - - // Check downloads first - foreach (var download in existingDownloads) - { - // For completed downloads, check if the file quality meets cutoff - if (download.Status == DownloadStatus.Completed && !string.IsNullOrEmpty(download.Metadata?.GetValueOrDefault("Quality")?.ToString())) - { - var downloadQuality = download.Metadata["Quality"].ToString(); - var downloadQualityDefinition = audiobook.QualityProfile.Qualities - .FirstOrDefault(q => q.Quality == downloadQuality); - - if (downloadQualityDefinition != null && downloadQualityDefinition.Priority >= cutoffQuality.Priority) - { - _logger.LogDebug("Quality cutoff met for audiobook '{Title}' by completed download (Quality: {Quality})", - audiobook.Title, downloadQuality); - return true; - } - } - // For active downloads, assume they will meet quality requirements - else if (download.Status == DownloadStatus.Downloading || download.Status == DownloadStatus.ImportPending) - { - _logger.LogDebug("Quality cutoff assumed met for audiobook '{Title}' due to active download", audiobook.Title); - return true; - } - } - - // Check existing files - foreach (var file in existingFiles) - { - var fileQuality = DetermineFileQuality(file); - if (!string.IsNullOrEmpty(fileQuality)) - { - var fileQualityDefinition = audiobook.QualityProfile.Qualities - .FirstOrDefault(q => q.Quality == fileQuality); - - if (fileQualityDefinition != null && fileQualityDefinition.Priority >= cutoffQuality.Priority) - { - _logger.LogDebug("Quality cutoff met for audiobook '{Title}' by existing file (Quality: {Quality}, File: {FileName})", - audiobook.Title, fileQuality, Path.GetFileName(file.Path)); - return true; - } - } - } - - return false; - } - - private string? DetermineFileQuality(AudiobookFile file) - { - // Determine quality based on file properties - // This mirrors the logic in QualityProfileService.GetQualityScore but works with file metadata - - // Check format/container first - if (!string.IsNullOrEmpty(file.Container)) - { - var container = file.Container.ToLower(); - if (container.Contains("flac")) return "FLAC"; - if (container.Contains("m4b") || container.Contains("m4a")) return "M4B"; - } - - if (!string.IsNullOrEmpty(file.Format)) - { - var format = file.Format.ToLower(); - if (format.Contains("flac")) return "FLAC"; - if (format.Contains("m4b") || format.Contains("m4a")) return "M4B"; - if (format.Contains("aac")) return "M4B"; // AAC in M4B container - } - - // Check bitrate for MP3 quality determination - if (file.Bitrate.HasValue) - { - var bitrate = file.Bitrate.Value; - - // Convert bits per second to kilobits per second for easier comparison - var kbps = bitrate / 1000; - - if (kbps >= 320) return "MP3 320kbps"; - if (kbps >= 256) return "MP3 256kbps"; - if (kbps >= 192) return "MP3 192kbps"; - if (kbps >= 128) return "MP3 128kbps"; - if (kbps >= 64) return "MP3 64kbps"; - - // For very low bitrates, still classify as MP3 - return "MP3 64kbps"; - } - - // Check codec - if (!string.IsNullOrEmpty(file.Codec)) - { - var codec = file.Codec.ToLower(); - if (codec.Contains("flac")) return "FLAC"; - if (codec.Contains("aac")) return "M4B"; - if (codec.Contains("mp3")) return "MP3 128kbps"; // Default MP3 quality if no bitrate info - if (codec.Contains("opus")) return "M4B"; // Opus is often in M4B containers - } - - // If we can't determine quality from metadata, try to infer from file extension - if (!string.IsNullOrEmpty(file.Path)) - { - var extension = Path.GetExtension(file.Path).ToLower(); - switch (extension) - { - case ".flac": - return "FLAC"; - case ".m4b": - case ".m4a": - return "M4B"; - case ".mp3": - return "MP3 128kbps"; // Conservative default for MP3 - case ".aac": - return "M4B"; - case ".opus": - return "M4B"; - } - } - - return null; // Unable to determine quality - } - - /// - /// Determine whether the audiobook already meets the quality cutoff and return the best existing quality string (if any). - /// - private async Task<(bool cutoffMet, string? bestExistingQuality)> GetExistingQualityAsync( - Audiobook audiobook, - IQualityProfileService qualityProfileService, - IDownloadRepository downloadRepository, - IAudiobookFileRepository fileRepository, - CancellationToken ct = default) - { - // Reuse existing cutoff logic - var cutoffMet = await IsQualityCutoffMetAsync(audiobook, qualityProfileService, downloadRepository, fileRepository, ct); - - // Find the best quality among existing files and completed downloads (if any) - string? bestQuality = null; - - var allDownloads = await downloadRepository.GetByAudiobookIdAsync(audiobook.Id, ct); - var existingDownloads = allDownloads.Where(d => d.Status == DownloadStatus.Completed).ToList(); - - foreach (var dl in existingDownloads.Where(dl => dl.Metadata != null)) - { - if (dl.Metadata!.TryGetValue("Quality", out var qobj) && qobj != null) - { - var q = qobj.ToString(); - if (!string.IsNullOrEmpty(q)) - { - if (bestQuality == null) bestQuality = q; - else if (IsQualityBetter(q, bestQuality, audiobook.QualityProfile)) bestQuality = q; - } - } - } - - var existingFiles = await fileRepository.GetByAudiobookIdAsync(audiobook.Id, ct); - - foreach (var fq in existingFiles.Select(DetermineFileQuality).Where(fq => !string.IsNullOrEmpty(fq))) - { - if (bestQuality == null) bestQuality = fq; - else if (IsQualityBetter(fq, bestQuality, audiobook.QualityProfile)) bestQuality = fq; - } - - return (cutoffMet, bestQuality); - } - - /// - /// Compare two quality strings using the quality profile priorities. - /// Returns true if candidateQuality is better (higher priority) than existingQuality. - /// - private bool IsQualityBetter(string? candidateQuality, string? existingQuality, QualityProfile? profile) - { - if (string.IsNullOrEmpty(candidateQuality)) return false; - if (string.IsNullOrEmpty(existingQuality)) return true; - if (profile == null) return false; - - var cand = profile.Qualities.FirstOrDefault(q => q.Quality == candidateQuality); - var exist = profile.Qualities.FirstOrDefault(q => q.Quality == existingQuality); - - if (cand == null) return false; - if (exist == null) return true; // unknown existing quality -> treat candidate as better - - return cand.Priority > exist.Priority; - } - - private async Task GetAppropriateDownloadClientAsync(SearchResult searchResult, bool isTorrent) - { - using var scope = _serviceScopeFactory.CreateScope(); - var configurationService = scope.ServiceProvider.GetRequiredService(); - - // Special handling for DDL downloads - they don't use external clients - if (searchResult.DownloadType?.Equals("DDL", StringComparison.OrdinalIgnoreCase) == true) - { - _logger.LogInformation("DDL download detected, using internal DDL client"); - return "DDL"; - } - - // Get all configured download clients - var clients = await configurationService.GetDownloadClientConfigurationsAsync(); - var enabledClients = clients.Where(c => c.IsEnabled).ToList(); - - _logger.LogInformation("Looking for {ClientType} client. Found {Count} enabled download clients: {Clients}", - isTorrent ? "torrent" : "NZB", - enabledClients.Count, - string.Join(", ", enabledClients.Select(c => $"{c.Name} ({c.Type})"))); - - if (isTorrent) - { - // Prefer qBittorrent, then Transmission - var client = enabledClients.FirstOrDefault(c => c.Type.Equals("qbittorrent", StringComparison.OrdinalIgnoreCase)) - ?? enabledClients.FirstOrDefault(c => c.Type.Equals("transmission", StringComparison.OrdinalIgnoreCase)); - - if (client != null) - { - _logger.LogInformation("Selected torrent client: {ClientName} ({ClientType})", client.Name, client.Type); - } - else - { - _logger.LogWarning("No torrent client (qBittorrent or Transmission) found among enabled clients"); - } - - return client?.Id ?? string.Empty; - } - else - { - // Prefer SABnzbd, then NZBGet - var client = enabledClients.FirstOrDefault(c => c.Type.Equals("sabnzbd", StringComparison.OrdinalIgnoreCase)) - ?? enabledClients.FirstOrDefault(c => c.Type.Equals("nzbget", StringComparison.OrdinalIgnoreCase)); - - if (client != null) - { - _logger.LogInformation("Selected NZB client: {ClientName} ({ClientType})", client.Name, client.Type); - } - else - { - _logger.LogWarning("No NZB client (SABnzbd or NZBGet) found among enabled clients"); - } - - return client?.Id ?? string.Empty; - } - } } } From b54637072567a7cb5cf13e769c72851f3dc283db Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Sun, 14 Jun 2026 07:51:00 -0400 Subject: [PATCH 82/84] refactor: extract qbittorrent import resolver - Move qBittorrent import path resolution into a focused resolver - Keep queue-item and download-client-item import behavior unchanged --- .../Adapters/QbittorrentAdapter.cs | 190 +----------------- .../Adapters/QbittorrentImportItemResolver.cs | 180 +++++++++++++++++ 2 files changed, 184 insertions(+), 186 deletions(-) create mode 100644 listenarr.infrastructure/Adapters/QbittorrentImportItemResolver.cs diff --git a/listenarr.infrastructure/Adapters/QbittorrentAdapter.cs b/listenarr.infrastructure/Adapters/QbittorrentAdapter.cs index ca66b4f22..e30623824 100644 --- a/listenarr.infrastructure/Adapters/QbittorrentAdapter.cs +++ b/listenarr.infrastructure/Adapters/QbittorrentAdapter.cs @@ -43,6 +43,7 @@ public class QbittorrentAdapter : IDownloadClientAdapter private readonly QbittorrentConnectionTester _connectionTester; private readonly QbittorrentDownloadPollingWorkflow _downloadPollingWorkflow; private readonly QbittorrentRemovalWorkflow _removalWorkflow; + private readonly QbittorrentImportItemResolver _importItemResolver; public QbittorrentAdapter(IHttpClientFactory httpFactory, ITorrentFileDownloader torrentFileDownloader, ILogger logger) { @@ -54,6 +55,7 @@ public QbittorrentAdapter(IHttpClientFactory httpFactory, ITorrentFileDownloader _connectionTester = new QbittorrentConnectionTester(_httpClientFactory, _logger, ClientType); _downloadPollingWorkflow = new QbittorrentDownloadPollingWorkflow(_logger); _removalWorkflow = new QbittorrentRemovalWorkflow(_logger); + _importItemResolver = new QbittorrentImportItemResolver(_logger); } public async Task<(bool Success, string Message)> TestConnectionAsync(DownloadClientConfiguration client, CancellationToken ct = default) @@ -373,89 +375,7 @@ public async Task GetImportItemAsync( DownloadClientItem? previousAttempt = null, CancellationToken ct = default) { - // Clone to avoid modifying original - var result = item.Clone(); - - // If OutputPath is already set, use it directly - if (!string.IsNullOrEmpty(result.OutputPath)) - { - _logger.LogDebug("Using existing OutputPath for import: {Path}", result.OutputPath); - return result; - } - - // Otherwise, resolve path from qBittorrent API - var hash = result.DownloadId.ToLowerInvariant(); - var baseUrl = DownloadClientUriBuilder.BuildAuthority(client); - - try - { - using var httpClient = QbittorrentCookieSession.CreateClient(); - - // Login - using var loginData = QbittorrentCookieSession.CreateLoginContent(client); - - using var loginResp = await httpClient.PostAsync($"{baseUrl}/api/v2/auth/login", loginData, ct); - if (!loginResp.IsSuccessStatusCode && loginResp.StatusCode != HttpStatusCode.Forbidden) - { - _logger.LogWarning("qBittorrent login failed for import resolution"); - return result; - } - - // Query files API to determine base folder - using var filesResp = await httpClient.GetAsync($"{baseUrl}/api/v2/torrents/files?hash={hash}", ct); - if (!filesResp.IsSuccessStatusCode) - { - _logger.LogWarning("Failed to query torrent files for hash {Hash}", hash); - return result; - } - - var filesJson = await filesResp.Content.ReadAsStringAsync(ct); - var files = JsonSerializer.Deserialize>>(filesJson); - - if (files == null || !files.Any()) - { - _logger.LogDebug("No files found for torrent {Hash}", hash); - return result; - } - - // Get torrent properties to find save_path - using var propsResp = await httpClient.GetAsync($"{baseUrl}/api/v2/torrents/properties?hash={hash}", ct); - if (!propsResp.IsSuccessStatusCode) - { - _logger.LogWarning("Failed to query torrent properties for hash {Hash}", hash); - return result; - } - - var propsJson = await propsResp.Content.ReadAsStringAsync(ct); - var props = JsonSerializer.Deserialize>(propsJson); - var savePath = props?.TryGetValue("save_path", out var savePathEl) is true - ? savePathEl.GetString() ?? string.Empty - : string.Empty; - - if (string.IsNullOrEmpty(savePath)) - { - _logger.LogWarning("No save_path found for torrent {Hash}", hash); - return result; - } - - var outputPath = QbittorrentImportPathResolver.ResolveContentPath(savePath, files); - if (string.IsNullOrEmpty(outputPath)) - { - _logger.LogWarning("Unable to resolve content path from torrent files for hash {Hash}", hash); - return result; - } - - // Apply remote path mapping - result.OutputPath = outputPath; - - _logger.LogInformation("Resolved import path for {Hash}: {Path}", hash, result.OutputPath); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogError(ex, "Error resolving import item for torrent {Hash}", hash); - } - - return result; + return await _importItemResolver.GetImportItemAsync(client, item, ct); } /// @@ -469,109 +389,7 @@ public async Task GetImportItemAsync( QueueItem? previousAttempt = null, CancellationToken ct = default) { - // ✅ Clone to avoid modifying original - var result = queueItem.Clone(); - string? resolvedExistingContentPath = null; - - // On API >= 2.6.1, ContentPath/OutputPath is already set correctly from content_path field - if (!string.IsNullOrEmpty(result.ContentPath)) - { - var localPath = result.ContentPath; - if (!string.IsNullOrWhiteSpace(localPath)) - { - result.ContentPath = localPath; - resolvedExistingContentPath = localPath; - } - - _logger.LogDebug("Using existing ContentPath for import: {Path}", result.ContentPath); - } - - var hash = download.Metadata?.GetValueOrDefault("TorrentHash")?.ToString(); - if (string.IsNullOrWhiteSpace(hash)) - { - hash = queueItem.Id; - } - if (string.IsNullOrEmpty(hash)) - { - _logger.LogWarning("No torrent hash found in download metadata for download {DownloadId}", download.Id); - return result; - } - - var baseUrl = DownloadClientUriBuilder.BuildAuthority(client); - - try - { - using var httpClient = QbittorrentCookieSession.CreateClient(); - - // Login - using var loginData = QbittorrentCookieSession.CreateLoginContent(client); - - using var loginResp = await httpClient.PostAsync($"{baseUrl}/api/v2/auth/login", loginData, ct); - if (!loginResp.IsSuccessStatusCode && loginResp.StatusCode != HttpStatusCode.Forbidden) - { - _logger.LogWarning("qBittorrent login failed for import resolution"); - return result; - } - - // ✅ Query files API to determine base folder - using var filesResp = await httpClient.GetAsync($"{baseUrl}/api/v2/torrents/files?hash={hash}", ct); - if (!filesResp.IsSuccessStatusCode) - { - _logger.LogWarning("Failed to query torrent files for hash {Hash}", hash); - return result; - } - - var filesJson = await filesResp.Content.ReadAsStringAsync(ct); - var files = JsonSerializer.Deserialize>>(filesJson); - - if (files == null || !files.Any()) - { - _logger.LogDebug("No files found for torrent {Hash}", hash); - return result; - } - - // Get torrent properties to find save_path - using var propsResp = await httpClient.GetAsync($"{baseUrl}/api/v2/torrents/properties?hash={hash}", ct); - if (!propsResp.IsSuccessStatusCode) - { - _logger.LogWarning("Failed to query torrent properties for hash {Hash}", hash); - return result; - } - - var propsJson = await propsResp.Content.ReadAsStringAsync(ct); - var props = JsonSerializer.Deserialize>(propsJson); - var savePath = props?.TryGetValue("save_path", out var savePathEl) is true - ? savePathEl.GetString() ?? string.Empty - : string.Empty; - - if (string.IsNullOrEmpty(savePath)) - { - _logger.LogWarning("No save_path found for torrent {Hash}", hash); - return result; - } - - var outputPath = QbittorrentImportPathResolver.ResolveContentPath(savePath, files); - if (string.IsNullOrEmpty(outputPath) && string.IsNullOrWhiteSpace(resolvedExistingContentPath)) - { - _logger.LogWarning("Unable to resolve content path from torrent files for hash {Hash}", hash); - return result; - } - - // ✅ Apply remote path mapping - result.SourceFiles = QbittorrentImportPathResolver.TranslateSourceFiles(QbittorrentImportPathResolver.BuildSourceFiles(savePath, files)); - if (!string.IsNullOrWhiteSpace(outputPath)) - { - result.ContentPath = outputPath; - } - - _logger.LogInformation("Resolved import path for {Hash}: {Path}", hash, result.ContentPath); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogError(ex, "Error resolving import item for torrent {Hash}", hash); - } - - return result; + return await _importItemResolver.GetImportItemAsync(client, download, queueItem, ct); } internal static string ResolveTorrentContentPath( diff --git a/listenarr.infrastructure/Adapters/QbittorrentImportItemResolver.cs b/listenarr.infrastructure/Adapters/QbittorrentImportItemResolver.cs new file mode 100644 index 000000000..e80a6f727 --- /dev/null +++ b/listenarr.infrastructure/Adapters/QbittorrentImportItemResolver.cs @@ -0,0 +1,180 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +using System.Net; +using System.Text.Json; +using Listenarr.Domain.Models; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Infrastructure.Adapters +{ + internal sealed class QbittorrentImportItemResolver(ILogger logger) + { + public async Task GetImportItemAsync( + DownloadClientConfiguration client, + DownloadClientItem item, + CancellationToken ct = default) + { + var result = item.Clone(); + + if (!string.IsNullOrEmpty(result.OutputPath)) + { + logger.LogDebug("Using existing OutputPath for import: {Path}", result.OutputPath); + return result; + } + + var hash = result.DownloadId.ToLowerInvariant(); + var resolved = await ResolveTorrentFilesAsync(client, hash, ct); + if (resolved == null) + { + return result; + } + + var outputPath = QbittorrentImportPathResolver.ResolveContentPath(resolved.Value.SavePath, resolved.Value.Files); + if (string.IsNullOrEmpty(outputPath)) + { + logger.LogWarning("Unable to resolve content path from torrent files for hash {Hash}", hash); + return result; + } + + result.OutputPath = outputPath; + logger.LogInformation("Resolved import path for {Hash}: {Path}", hash, result.OutputPath); + return result; + } + + public async Task GetImportItemAsync( + DownloadClientConfiguration client, + Download download, + QueueItem queueItem, + CancellationToken ct = default) + { + var result = queueItem.Clone(); + string? resolvedExistingContentPath = null; + + if (!string.IsNullOrEmpty(result.ContentPath)) + { + var localPath = result.ContentPath; + if (!string.IsNullOrWhiteSpace(localPath)) + { + result.ContentPath = localPath; + resolvedExistingContentPath = localPath; + } + + logger.LogDebug("Using existing ContentPath for import: {Path}", result.ContentPath); + } + + var hash = download.Metadata?.GetValueOrDefault("TorrentHash")?.ToString(); + if (string.IsNullOrWhiteSpace(hash)) + { + hash = queueItem.Id; + } + + if (string.IsNullOrEmpty(hash)) + { + logger.LogWarning("No torrent hash found in download metadata for download {DownloadId}", download.Id); + return result; + } + + var resolved = await ResolveTorrentFilesAsync(client, hash, ct); + if (resolved == null) + { + return result; + } + + var outputPath = QbittorrentImportPathResolver.ResolveContentPath(resolved.Value.SavePath, resolved.Value.Files); + if (string.IsNullOrEmpty(outputPath) && string.IsNullOrWhiteSpace(resolvedExistingContentPath)) + { + logger.LogWarning("Unable to resolve content path from torrent files for hash {Hash}", hash); + return result; + } + + result.SourceFiles = QbittorrentImportPathResolver.TranslateSourceFiles( + QbittorrentImportPathResolver.BuildSourceFiles(resolved.Value.SavePath, resolved.Value.Files)); + if (!string.IsNullOrWhiteSpace(outputPath)) + { + result.ContentPath = outputPath; + } + + logger.LogInformation("Resolved import path for {Hash}: {Path}", hash, result.ContentPath); + return result; + } + + private async Task<(string SavePath, List> Files)?> ResolveTorrentFilesAsync( + DownloadClientConfiguration client, + string hash, + CancellationToken ct) + { + var baseUrl = DownloadClientUriBuilder.BuildAuthority(client); + + try + { + using var httpClient = QbittorrentCookieSession.CreateClient(); + using var loginData = QbittorrentCookieSession.CreateLoginContent(client); + + using var loginResp = await httpClient.PostAsync($"{baseUrl}/api/v2/auth/login", loginData, ct); + if (!loginResp.IsSuccessStatusCode && loginResp.StatusCode != HttpStatusCode.Forbidden) + { + logger.LogWarning("qBittorrent login failed for import resolution"); + return null; + } + + using var filesResp = await httpClient.GetAsync($"{baseUrl}/api/v2/torrents/files?hash={hash}", ct); + if (!filesResp.IsSuccessStatusCode) + { + logger.LogWarning("Failed to query torrent files for hash {Hash}", hash); + return null; + } + + var filesJson = await filesResp.Content.ReadAsStringAsync(ct); + var files = JsonSerializer.Deserialize>>(filesJson); + + if (files == null || !files.Any()) + { + logger.LogDebug("No files found for torrent {Hash}", hash); + return null; + } + + using var propsResp = await httpClient.GetAsync($"{baseUrl}/api/v2/torrents/properties?hash={hash}", ct); + if (!propsResp.IsSuccessStatusCode) + { + logger.LogWarning("Failed to query torrent properties for hash {Hash}", hash); + return null; + } + + var propsJson = await propsResp.Content.ReadAsStringAsync(ct); + var props = JsonSerializer.Deserialize>(propsJson); + var savePath = props?.TryGetValue("save_path", out var savePathEl) is true + ? savePathEl.GetString() ?? string.Empty + : string.Empty; + + if (string.IsNullOrEmpty(savePath)) + { + logger.LogWarning("No save_path found for torrent {Hash}", hash); + return null; + } + + return (savePath, files); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + logger.LogError(ex, "Error resolving import item for torrent {Hash}", hash); + return null; + } + } + } +} From 4604d05a6b3999a5d036bf3168b082df1cd06727 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Sun, 14 Jun 2026 07:53:24 -0400 Subject: [PATCH 83/84] refactor: extract transmission import resolver - Move Transmission import path resolution into a focused resolver - Keep RPC lookup, content path, and source-file behavior unchanged --- .../Adapters/TransmissionAdapter.cs | 157 +--------------- .../TransmissionImportItemResolver.cs | 174 ++++++++++++++++++ 2 files changed, 178 insertions(+), 153 deletions(-) create mode 100644 listenarr.infrastructure/Adapters/TransmissionImportItemResolver.cs diff --git a/listenarr.infrastructure/Adapters/TransmissionAdapter.cs b/listenarr.infrastructure/Adapters/TransmissionAdapter.cs index 07d4e65ce..e50fb5a86 100644 --- a/listenarr.infrastructure/Adapters/TransmissionAdapter.cs +++ b/listenarr.infrastructure/Adapters/TransmissionAdapter.cs @@ -38,6 +38,7 @@ public class TransmissionAdapter : IDownloadClientAdapter private readonly TransmissionRpcClient _rpcClient; private readonly TransmissionDownloadPollingWorkflow _downloadPollingWorkflow; private readonly TransmissionRemovalWorkflow _removalWorkflow; + private readonly TransmissionImportItemResolver _importItemResolver; public TransmissionAdapter(IHttpClientFactory httpClientFactory, ITorrentFileDownloader torrentFileDownloader, ILogger logger) { @@ -48,6 +49,7 @@ public TransmissionAdapter(IHttpClientFactory httpClientFactory, ITorrentFileDow _rpcClient = new TransmissionRpcClient(_httpClientFactory, ClientType, _logger); _downloadPollingWorkflow = new TransmissionDownloadPollingWorkflow(_httpClientFactory, _logger, ClientType); _removalWorkflow = new TransmissionRemovalWorkflow(_rpcClient, _logger); + _importItemResolver = new TransmissionImportItemResolver(_rpcClient, _logger); } public async Task<(bool Success, string Message)> TestConnectionAsync(DownloadClientConfiguration client, CancellationToken ct = default) @@ -315,79 +317,7 @@ public async Task GetImportItemAsync( DownloadClientItem? previousAttempt = null, CancellationToken ct = default) { - // Clone to avoid mutating the original - var result = item.Clone(); - - // If OutputPath is already set and exists, use it - if (!string.IsNullOrEmpty(result.OutputPath)) - { - var localPath = result.OutputPath; - if (TransmissionImportPathResolver.IsExistingLocalPath(localPath)) - { - result.OutputPath = localPath; - return result; - } - } - - // Query Transmission for the torrent details - var payload = new - { - method = "torrent-get", - arguments = new - { - ids = TransmissionRequestPlanner.ParseTransmissionIds(item.DownloadId), - fields = new[] { "id", "name", "downloadDir" } - }, - tag = 5 - }; - - try - { - var response = await _rpcClient.InvokeAsync(client, payload, ct); - if (!response.TryGetProperty("arguments", out var args) || - !args.TryGetProperty("torrents", out var torrents) || - torrents.ValueKind != JsonValueKind.Array) - { - _logger.LogWarning("Failed to query Transmission for torrent {TorrentId}", item.DownloadId); - return result; - } - - var torrent = torrents.EnumerateArray().FirstOrDefault(); - if (torrent.ValueKind == JsonValueKind.Undefined) - { - _logger.LogWarning("Torrent {TorrentId} not found in Transmission", item.DownloadId); - return result; - } - - var downloadDir = torrent.TryGetProperty("downloadDir", out var dirProp) ? dirProp.GetString() : null; - var name = torrent.TryGetProperty("name", out var nameProp) ? nameProp.GetString() : null; - - if (string.IsNullOrEmpty(downloadDir) || string.IsNullOrEmpty(name)) - { - _logger.LogWarning("Missing downloadDir or name for torrent {TorrentId}", item.DownloadId); - return result; - } - - // Transmission stores files as: downloadDir/name. - var contentPath = TransmissionImportPathResolver.BuildContentPath(downloadDir, name)!; - - // Apply path mapping - // FIXME: Path mapping should be the responsability of the download processors - var localContentPath = contentPath; - result.OutputPath = localContentPath; - - _logger.LogDebug( - "Resolved Transmission content path for {TorrentId}: {ContentPath}", - item.DownloadId, - localContentPath); - - return result; - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Error resolving import item for Transmission torrent {TorrentId}", item.DownloadId); - return result; - } + return await _importItemResolver.GetImportItemAsync(client, item, ct); } /// @@ -402,86 +332,7 @@ public async Task GetImportItemAsync( QueueItem? previousAttempt = null, CancellationToken ct = default) { - // Clone to avoid mutating the original - var result = queueItem.Clone(); - string? resolvedExistingContentPath = null; - - // If ContentPath is already set and exists, use it - if (!string.IsNullOrEmpty(result.ContentPath)) - { - var localPath = result.ContentPath; - if (TransmissionImportPathResolver.IsExistingLocalPath(localPath)) - { - result.ContentPath = localPath; - resolvedExistingContentPath = localPath; - } - } - - // Query Transmission for the torrent details - var payload = new - { - method = "torrent-get", - arguments = new - { - ids = TransmissionRequestPlanner.ParseTransmissionIds(queueItem.Id), - fields = new[] { "id", "name", "downloadDir", "files" } - }, - tag = 5 - }; - - try - { - var response = await _rpcClient.InvokeAsync(client, payload, ct); - if (!response.TryGetProperty("arguments", out var args) || - !args.TryGetProperty("torrents", out var torrents) || - torrents.ValueKind != JsonValueKind.Array) - { - _logger.LogWarning("Failed to query Transmission for torrent {TorrentId}", queueItem.Id); - return result; - } - - var torrent = torrents.EnumerateArray().FirstOrDefault(); - if (torrent.ValueKind == JsonValueKind.Undefined) - { - _logger.LogWarning("Torrent {TorrentId} not found in Transmission", queueItem.Id); - return result; - } - - var downloadDir = torrent.TryGetProperty("downloadDir", out var dirProp) ? dirProp.GetString() : null; - var name = torrent.TryGetProperty("name", out var nameProp) ? nameProp.GetString() : null; - - if ((string.IsNullOrEmpty(downloadDir) || string.IsNullOrEmpty(name)) && string.IsNullOrWhiteSpace(resolvedExistingContentPath)) - { - _logger.LogWarning("Missing downloadDir or name for torrent {TorrentId}", queueItem.Id); - return result; - } - - // Transmission stores files as: downloadDir/name - var contentPath = TransmissionImportPathResolver.BuildContentPath(downloadDir, name, resolvedExistingContentPath); - string? localContentPath = resolvedExistingContentPath; - if (!string.IsNullOrWhiteSpace(contentPath)) - { - localContentPath = contentPath; - result.ContentPath = localContentPath; - } - - if (torrent.TryGetProperty("files", out var filesElement)) - { - result.SourceFiles = TransmissionImportPathResolver.BuildSourceFiles(downloadDir, filesElement); - } - - _logger.LogDebug( - "Resolved Transmission content path for {TorrentId}: {ContentPath}", - queueItem.Id, - localContentPath); - - return result; - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Error resolving import item for Transmission torrent {TorrentId}", queueItem.Id); - return result; - } + return await _importItemResolver.GetImportItemAsync(client, queueItem, ct); } private Task MapToDownloadClientItemAsync( diff --git a/listenarr.infrastructure/Adapters/TransmissionImportItemResolver.cs b/listenarr.infrastructure/Adapters/TransmissionImportItemResolver.cs new file mode 100644 index 000000000..f4b92c8bb --- /dev/null +++ b/listenarr.infrastructure/Adapters/TransmissionImportItemResolver.cs @@ -0,0 +1,174 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +using System.Text.Json; +using Listenarr.Domain.Models; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Infrastructure.Adapters +{ + internal sealed class TransmissionImportItemResolver( + TransmissionRpcClient rpcClient, + ILogger logger) + { + public async Task GetImportItemAsync( + DownloadClientConfiguration client, + DownloadClientItem item, + CancellationToken ct = default) + { + var result = item.Clone(); + + if (!string.IsNullOrEmpty(result.OutputPath)) + { + var localPath = result.OutputPath; + if (TransmissionImportPathResolver.IsExistingLocalPath(localPath)) + { + result.OutputPath = localPath; + return result; + } + } + + var torrent = await TryGetTorrentAsync(client, item.DownloadId, includeFiles: false, ct); + if (torrent == null) + { + return result; + } + + var downloadDir = torrent.Value.TryGetProperty("downloadDir", out var dirProp) ? dirProp.GetString() : null; + var name = torrent.Value.TryGetProperty("name", out var nameProp) ? nameProp.GetString() : null; + + if (string.IsNullOrEmpty(downloadDir) || string.IsNullOrEmpty(name)) + { + logger.LogWarning("Missing downloadDir or name for torrent {TorrentId}", item.DownloadId); + return result; + } + + var contentPath = TransmissionImportPathResolver.BuildContentPath(downloadDir, name)!; + var localContentPath = contentPath; + result.OutputPath = localContentPath; + + logger.LogDebug( + "Resolved Transmission content path for {TorrentId}: {ContentPath}", + item.DownloadId, + localContentPath); + + return result; + } + + public async Task GetImportItemAsync( + DownloadClientConfiguration client, + QueueItem queueItem, + CancellationToken ct = default) + { + var result = queueItem.Clone(); + string? resolvedExistingContentPath = null; + + if (!string.IsNullOrEmpty(result.ContentPath)) + { + var localPath = result.ContentPath; + if (TransmissionImportPathResolver.IsExistingLocalPath(localPath)) + { + result.ContentPath = localPath; + resolvedExistingContentPath = localPath; + } + } + + var torrent = await TryGetTorrentAsync(client, queueItem.Id, includeFiles: true, ct); + if (torrent == null) + { + return result; + } + + var downloadDir = torrent.Value.TryGetProperty("downloadDir", out var dirProp) ? dirProp.GetString() : null; + var name = torrent.Value.TryGetProperty("name", out var nameProp) ? nameProp.GetString() : null; + + if ((string.IsNullOrEmpty(downloadDir) || string.IsNullOrEmpty(name)) && string.IsNullOrWhiteSpace(resolvedExistingContentPath)) + { + logger.LogWarning("Missing downloadDir or name for torrent {TorrentId}", queueItem.Id); + return result; + } + + var contentPath = TransmissionImportPathResolver.BuildContentPath(downloadDir, name, resolvedExistingContentPath); + string? localContentPath = resolvedExistingContentPath; + if (!string.IsNullOrWhiteSpace(contentPath)) + { + localContentPath = contentPath; + result.ContentPath = localContentPath; + } + + if (torrent.Value.TryGetProperty("files", out var filesElement)) + { + result.SourceFiles = TransmissionImportPathResolver.BuildSourceFiles(downloadDir, filesElement); + } + + logger.LogDebug( + "Resolved Transmission content path for {TorrentId}: {ContentPath}", + queueItem.Id, + localContentPath); + + return result; + } + + private async Task TryGetTorrentAsync( + DownloadClientConfiguration client, + string torrentId, + bool includeFiles, + CancellationToken ct) + { + var fields = includeFiles + ? new[] { "id", "name", "downloadDir", "files" } + : new[] { "id", "name", "downloadDir" }; + var payload = new + { + method = "torrent-get", + arguments = new + { + ids = TransmissionRequestPlanner.ParseTransmissionIds(torrentId), + fields + }, + tag = 5 + }; + + try + { + var response = await rpcClient.InvokeAsync(client, payload, ct); + if (!response.TryGetProperty("arguments", out var args) || + !args.TryGetProperty("torrents", out var torrents) || + torrents.ValueKind != JsonValueKind.Array) + { + logger.LogWarning("Failed to query Transmission for torrent {TorrentId}", torrentId); + return null; + } + + var torrent = torrents.EnumerateArray().FirstOrDefault(); + if (torrent.ValueKind == JsonValueKind.Undefined) + { + logger.LogWarning("Torrent {TorrentId} not found in Transmission", torrentId); + return null; + } + + return torrent.Clone(); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + logger.LogWarning(ex, "Error resolving import item for Transmission torrent {TorrentId}", torrentId); + return null; + } + } + } +} From 2910ed7dc1c05fff2e86d8011c4a9435c52b32bf Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Sun, 14 Jun 2026 07:56:04 -0400 Subject: [PATCH 84/84] refactor: extract ffprobe archive extraction - Move ffprobe archive expansion and traversal checks into a focused helper - Keep install discovery, checksum validation, and binary promotion behavior unchanged --- .../Ffmpeg/FfmpegService.cs | 101 +---------- .../Ffmpeg/FfprobeArchiveExtractor.cs | 162 ++++++++++++++++++ 2 files changed, 164 insertions(+), 99 deletions(-) create mode 100644 listenarr.infrastructure/Ffmpeg/FfprobeArchiveExtractor.cs diff --git a/listenarr.infrastructure/Ffmpeg/FfmpegService.cs b/listenarr.infrastructure/Ffmpeg/FfmpegService.cs index 8085982bb..31a54bfe3 100644 --- a/listenarr.infrastructure/Ffmpeg/FfmpegService.cs +++ b/listenarr.infrastructure/Ffmpeg/FfmpegService.cs @@ -16,9 +16,6 @@ * along with this program. If not, see . */ using System.Security.Cryptography; -using SharpCompress.Archives; -using SharpCompress.Common; -using SharpCompress.Readers; using System.Runtime.InteropServices; using System.Diagnostics; using System.Text.Json; @@ -90,46 +87,6 @@ private static async Task TryDeleteFileAsync(string path, int retries = 3, int d } } - private static bool TryBuildPathUnderRoot(string rootPath, string entryPath, out string resolvedPath) - { - resolvedPath = string.Empty; - - if (string.IsNullOrWhiteSpace(rootPath) || string.IsNullOrWhiteSpace(entryPath)) - { - return false; - } - - var normalizedRoot = Path.GetFullPath(rootPath) - .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - - var normalizedEntry = entryPath - .Replace('\\', Path.DirectorySeparatorChar) - .Replace('/', Path.DirectorySeparatorChar) - .Trim(); - - if (string.IsNullOrWhiteSpace(normalizedEntry)) - { - return false; - } - - normalizedEntry = normalizedEntry.TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - if (Path.IsPathRooted(normalizedEntry)) - { - return false; - } - - var candidatePath = Path.GetFullPath( - normalizedRoot + Path.DirectorySeparatorChar + normalizedEntry); - if (!candidatePath.StartsWith(normalizedRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) - && !string.Equals(candidatePath, normalizedRoot, StringComparison.OrdinalIgnoreCase)) - { - return false; - } - - resolvedPath = candidatePath; - return true; - } - /// /// Return the ffprobe path if it exists in the configured bundled directory. This method /// does NOT attempt to download or install ffprobe. @@ -270,63 +227,9 @@ private static bool TryBuildPathUnderRoot(string rootPath, string entryPath, out return null; } - if (downloadUrl.EndsWith(".zip", StringComparison.OrdinalIgnoreCase) || downloadUrl.EndsWith(".ffmpeg.zip", StringComparison.OrdinalIgnoreCase)) + if (!await FfprobeArchiveExtractor.ExtractAsync(downloadUrl, tmpFile, _baseDir, _ffprobePath, _logger)) { - using var archive = SharpCompress.Archives.Zip.ZipArchive.OpenArchive(tmpFile, new ReaderOptions()); - var baseRoot = Path.GetFullPath(_baseDir); - foreach (var entry in archive.Entries.Where(e => !e.IsDirectory)) - { - var entryPath = entry.Key ?? string.Empty; - if (!TryBuildPathUnderRoot(baseRoot, entryPath, out var outPath)) - { - _logger.LogWarning("Skipping archive entry outside extraction root: {Entry}", entryPath); - continue; - } - - Directory.CreateDirectory(Path.GetDirectoryName(outPath) ?? _baseDir); - entry.WriteToFile(outPath, new ExtractionOptions() { ExtractFullPath = true, Overwrite = true }); - _logger.LogDebug("Extracted archive entry to {OutPath}", outPath); - } - await TryDeleteFileAsync(tmpFile); - } - else if (downloadUrl.EndsWith(".tar.xz", StringComparison.OrdinalIgnoreCase) || downloadUrl.EndsWith(".tar.gz", StringComparison.OrdinalIgnoreCase) || downloadUrl.EndsWith(".tgz", StringComparison.OrdinalIgnoreCase)) - { - try - { - using var stream = File.OpenRead(tmpFile); - var readerOptions = new ReaderOptions { LeaveStreamOpen = false }; - using var reader = ReaderFactory.OpenReader(stream, readerOptions); - var baseRoot = Path.GetFullPath(_baseDir); - while (reader.MoveToNextEntry()) - { - if (!reader.Entry.IsDirectory) - { - var entryPath = reader.Entry.Key ?? string.Empty; - if (!TryBuildPathUnderRoot(baseRoot, entryPath, out var outPath)) - { - _logger.LogWarning("Skipping archive entry outside extraction root: {Entry}", entryPath); - continue; - } - - Directory.CreateDirectory(Path.GetDirectoryName(outPath) ?? _baseDir); - using var entryStream = reader.OpenEntryStream(); - await using var outFs = File.Create(outPath); - await entryStream.CopyToAsync(outFs); - _logger.LogDebug("Extracted archive entry to {OutPath}", outPath); - } - } - await TryDeleteFileAsync(tmpFile); - } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogWarning(ex, "Managed extraction failed for {Tmp}; skipping system tar fallback to avoid unsafe archive extraction", tmpFile); - await TryDeleteFileAsync(tmpFile); - return null; - } - } - else - { - File.Move(tmpFile, _ffprobePath); + return null; } if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) diff --git a/listenarr.infrastructure/Ffmpeg/FfprobeArchiveExtractor.cs b/listenarr.infrastructure/Ffmpeg/FfprobeArchiveExtractor.cs new file mode 100644 index 000000000..366f42359 --- /dev/null +++ b/listenarr.infrastructure/Ffmpeg/FfprobeArchiveExtractor.cs @@ -0,0 +1,162 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +using Microsoft.Extensions.Logging; +using SharpCompress.Archives; +using SharpCompress.Common; +using SharpCompress.Readers; + +namespace Listenarr.Infrastructure.Ffmpeg +{ + internal static class FfprobeArchiveExtractor + { + public static async Task ExtractAsync( + string downloadUrl, + string tmpFile, + string baseDir, + string ffprobePath, + ILogger logger) + { + if (downloadUrl.EndsWith(".zip", StringComparison.OrdinalIgnoreCase) || downloadUrl.EndsWith(".ffmpeg.zip", StringComparison.OrdinalIgnoreCase)) + { + using var archive = SharpCompress.Archives.Zip.ZipArchive.OpenArchive(tmpFile, new ReaderOptions()); + var baseRoot = Path.GetFullPath(baseDir); + foreach (var entry in archive.Entries.Where(e => !e.IsDirectory)) + { + var entryPath = entry.Key ?? string.Empty; + if (!TryBuildPathUnderRoot(baseRoot, entryPath, out var outPath)) + { + logger.LogWarning("Skipping archive entry outside extraction root: {Entry}", entryPath); + continue; + } + + Directory.CreateDirectory(Path.GetDirectoryName(outPath) ?? baseDir); + entry.WriteToFile(outPath, new ExtractionOptions() { ExtractFullPath = true, Overwrite = true }); + logger.LogDebug("Extracted archive entry to {OutPath}", outPath); + } + + await TryDeleteFileAsync(tmpFile); + return true; + } + + if (downloadUrl.EndsWith(".tar.xz", StringComparison.OrdinalIgnoreCase) || downloadUrl.EndsWith(".tar.gz", StringComparison.OrdinalIgnoreCase) || downloadUrl.EndsWith(".tgz", StringComparison.OrdinalIgnoreCase)) + { + try + { + using var stream = File.OpenRead(tmpFile); + var readerOptions = new ReaderOptions { LeaveStreamOpen = false }; + using var reader = ReaderFactory.OpenReader(stream, readerOptions); + var baseRoot = Path.GetFullPath(baseDir); + while (reader.MoveToNextEntry()) + { + if (reader.Entry.IsDirectory) + { + continue; + } + + var entryPath = reader.Entry.Key ?? string.Empty; + if (!TryBuildPathUnderRoot(baseRoot, entryPath, out var outPath)) + { + logger.LogWarning("Skipping archive entry outside extraction root: {Entry}", entryPath); + continue; + } + + Directory.CreateDirectory(Path.GetDirectoryName(outPath) ?? baseDir); + using var entryStream = reader.OpenEntryStream(); + await using var outFs = File.Create(outPath); + await entryStream.CopyToAsync(outFs); + logger.LogDebug("Extracted archive entry to {OutPath}", outPath); + } + + await TryDeleteFileAsync(tmpFile); + return true; + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + logger.LogWarning(ex, "Managed extraction failed for {Tmp}; skipping system tar fallback to avoid unsafe archive extraction", tmpFile); + await TryDeleteFileAsync(tmpFile); + return false; + } + } + + File.Move(tmpFile, ffprobePath); + return true; + } + + private static async Task TryDeleteFileAsync(string path, int retries = 3, int delayMs = 100, CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(path)) return; + for (var i = 0; i < retries; i++) + { + try + { + if (File.Exists(path)) File.Delete(path); + return; + } + catch (Exception) when (i < retries - 1) + { + try { await Task.Delay(delayMs, cancellationToken); } catch (OperationCanceledException) { return; } + } + catch (Exception) + { + return; + } + } + } + + private static bool TryBuildPathUnderRoot(string rootPath, string entryPath, out string resolvedPath) + { + resolvedPath = string.Empty; + + if (string.IsNullOrWhiteSpace(rootPath) || string.IsNullOrWhiteSpace(entryPath)) + { + return false; + } + + var normalizedRoot = Path.GetFullPath(rootPath) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + + var normalizedEntry = entryPath + .Replace('\\', Path.DirectorySeparatorChar) + .Replace('/', Path.DirectorySeparatorChar) + .Trim(); + + if (string.IsNullOrWhiteSpace(normalizedEntry)) + { + return false; + } + + normalizedEntry = normalizedEntry.TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + if (Path.IsPathRooted(normalizedEntry)) + { + return false; + } + + var candidatePath = Path.GetFullPath( + normalizedRoot + Path.DirectorySeparatorChar + normalizedEntry); + if (!candidatePath.StartsWith(normalizedRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) + && !string.Equals(candidatePath, normalizedRoot, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + resolvedPath = candidatePath; + return true; + } + } +}