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/.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/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 e66d165b7..b10870801 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -2,6 +2,8 @@ true true + true + true 10.0.8 @@ -20,7 +22,12 @@ + + + + + diff --git a/fe/package-lock.json b/fe/package-lock.json index 91b91b5e9..0d0df8ddd 100644 --- a/fe/package-lock.json +++ b/fe/package-lock.json @@ -32,31 +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" - } - }, - "..": { - "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": { @@ -2020,9 +1923,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": { @@ -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", @@ -3113,9 +2956,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", @@ -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,10 +4230,43 @@ "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" + }, + "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": { @@ -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" }, @@ -5003,13 +4911,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": { @@ -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" @@ -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,31 +5910,18 @@ "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" } }, - "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": "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 +5938,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": { @@ -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,17 +6178,50 @@ "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/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" @@ -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": { @@ -6436,9 +6472,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": { @@ -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": { @@ -6592,17 +6628,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": { @@ -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", @@ -7210,9 +7191,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": { @@ -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": { @@ -8195,14 +8252,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" @@ -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/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/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..0d00f0633 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 @@ -35,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; } /// @@ -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)); } @@ -72,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")] @@ -88,10 +86,13 @@ 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 (SecurityRequestUtils.ShouldRedactSecretsForCaller(HttpContext)) + _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/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/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/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/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/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/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/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/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/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 82ba0e72f..0909edbb4 100644 --- a/listenarr.api/Controllers/ImagesController.cs +++ b/listenarr.api/Controllers/ImagesController.cs @@ -20,8 +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; namespace Listenarr.Api.Controllers @@ -39,6 +37,12 @@ public class ImagesController : ControllerBase private readonly IOpenLibraryService? _openLibraryService; private readonly ILogger _logger; private readonly IApplicationPathService _applicationPathService; + private readonly ImagePlaceholderResolver _placeholderResolver; + private readonly ImageResponseBuilder _imageResponseBuilder; + private readonly ImagePathValidator _imagePathValidator; + private readonly ImageCachedPathValidator _cachedPathValidator; + private readonly ImageFallbackDownloadWorkflow _fallbackDownloadWorkflow; + private readonly ImageCandidateLookupWorkflow _imageCandidateLookupWorkflow; private readonly string _effectiveContentRootPath; [ActivatorUtilitiesConstructor] @@ -58,7 +62,8 @@ public ImagesController( audiobookRepository, openLibraryService: null, logger, - applicationPathService) + applicationPathService, + placeholderResolver: null) { } @@ -70,7 +75,8 @@ public ImagesController( IAudiobookRepository audiobookRepository, IOpenLibraryService? openLibraryService, ILogger logger, - IApplicationPathService applicationPathService) + IApplicationPathService applicationPathService, + ImagePlaceholderResolver? placeholderResolver = null) { _imageCacheService = imageCacheService; _audiobookMetadataService = audiobookMetadataService; @@ -80,7 +86,21 @@ public ImagesController( _openLibraryService = openLibraryService; _logger = logger; _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); + _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); } /// @@ -91,21 +111,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"); @@ -172,55 +185,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(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. @@ -241,45 +206,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(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; } } } @@ -300,604 +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 = 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 (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 (LooksLikeAsin(extId.ValueNormalized) && - !localAsinCandidates.Contains(extId.ValueNormalized, StringComparer.OrdinalIgnoreCase)) - { - localAsinCandidates.Add(extId.ValueNormalized); - } - break; - case AudiobookExternalIdentifierType.Isbn: - if (LooksLikeIsbn(extId.ValueNormalized) && - !localIsbnCandidates.Contains(extId.ValueNormalized, StringComparer.OrdinalIgnoreCase)) - { - localIsbnCandidates.Add(extId.ValueNormalized); - } - break; - case AudiobookExternalIdentifierType.OpenLibraryId: - { - var normalizedOlid = NormalizeOpenLibraryId(extId.ValueNormalized); - if (!string.IsNullOrWhiteSpace(normalizedOlid) && - !localOpenLibraryIds.Contains(normalizedOlid, StringComparer.OrdinalIgnoreCase)) - { - localOpenLibraryIds.Add(normalizedOlid); - } - } - break; - } - } - - var localIsbn = localBook.Isbn? - .Select(NormalizeIsbn) - .FirstOrDefault(v => !string.IsNullOrWhiteSpace(v) && 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 = NormalizeOpenLibraryId(localBook.OpenLibraryId); - if (!string.IsNullOrWhiteSpace(normalizedLocalOlid)) - { - if (!localOpenLibraryIds.Contains(normalizedLocalOlid, StringComparer.OrdinalIgnoreCase)) - { - localOpenLibraryIds.Add(normalizedLocalOlid); - } - localOpenLibraryId ??= normalizedLocalOlid; - } - } - - if (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 (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 (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 = 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)) - { - 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 = NormalizeIsbn(audnexus.Isbn); - } - } - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) when (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 (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 = NormalizeIsbn(altAudible.Isbn); - } - } - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) when (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 = NormalizeIsbn(altAudnexus.Isbn); - } - } - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) when (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) && LooksLikeIsbn(identifier)) - { - candidateIsbn = 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 = 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 (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 (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 && - 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)) - .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 (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 (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 (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 (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 (IsRecoverableImageLookupException(ex)) - { - _logger.LogDebug(ex, "Audnexus GetAuthorAsync failed for ASIN {Asin}", LogRedaction.SanitizeText(authorAsin)); - } - } - } - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) when (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 (IsRecoverableImageLookupException(ex)) - { - _logger.LogDebug(ex, "Audnexus author search failed for {Identifier}", LogRedaction.SanitizeText(identifier)); - } - } - } - - 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 (IsRecoverableImageLookupException(ex)) - { - _logger.LogWarning(ex, "Failed to download metadata-driven image for {Identifier} from {Url}", LogRedaction.SanitizeText(identifier), LogRedaction.SanitizeText(urlCandidate)); - } - } - } - } - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) when (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) { @@ -918,7 +250,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)) { @@ -929,23 +261,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) { @@ -954,221 +270,14 @@ 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) - { - 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)); - } - private IActionResult CreatePlaceholderResult(string logContext, string? logValue, string notFoundMessage) { - try - { - var placeholderPath = ResolvePlaceholderPath(); - 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 }); - } - - 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; - } - } + return _imageResponseBuilder.CreatePlaceholderResult( + Response.Headers, + HttpContext?.Request?.Path ?? PathString.Empty, + logContext, + logValue, + notFoundMessage); } /// @@ -1192,7 +301,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)) { 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/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/IndexerTestWorkflow.cs b/listenarr.api/Controllers/IndexerTestWorkflow.cs new file mode 100644 index 000000000..623c129d3 --- /dev/null +++ b/listenarr.api/Controllers/IndexerTestWorkflow.cs @@ -0,0 +1,429 @@ +/* + * 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); + } + } + + 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)); + await SaveTestResultAsync(indexer, persist, false, ex.Message); + 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)) + { + 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, string? Collection = null, string? MamId = null) + { + 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/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 1d5059a71..10336eca8 100644 --- a/listenarr.api/Controllers/IndexersController.cs +++ b/listenarr.api/Controllers/IndexersController.cs @@ -18,14 +18,12 @@ 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; using Listenarr.Domain.Models; using Microsoft.AspNetCore.Mvc; using System.Text.Json; -using System.Text.RegularExpressions; namespace Listenarr.Api.Controllers { @@ -36,65 +34,50 @@ public class IndexersController : ControllerBase { private readonly IIndexerRepository _indexerRepository; private readonly ILogger _logger; - 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; + private readonly ProwlarrIndexerImportWorkflow _prowlarrImportWorkflow; + private readonly IndexerDebugSearchWorkflow _debugSearchWorkflow; + private readonly IndexerResponseRedactor _responseRedactor; + + public IndexersController( + IIndexerRepository indexerRepository, + ILogger logger, + HttpClient httpClient, + IConfigurationService configurationService, + IndexerTestWorkflow? indexerTestWorkflow = null, + ProwlarrIndexerImportWorkflow? prowlarrImportWorkflow = null, + IndexerDebugSearchWorkflow? debugSearchWorkflow = 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); + _debugSearchWorkflow = debugSearchWorkflow ?? new IndexerDebugSearchWorkflow( + httpClient, + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + _responseRedactor = new IndexerResponseRedactor(); } private bool ShouldRedactIndexerSecretsForCaller() - => SecurityRequestUtils.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; - - 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; - } + => _responseRedactor.RedactMamIdForCaller(mamId, HttpContext); private async Task SaveTestResultAsync(Indexer indexer, bool persist, bool success, string? error) { @@ -121,7 +104,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(); @@ -182,161 +165,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 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 = ParseNewznabError(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) }); - } - 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) + var result = await _indexerTestWorkflow.TestGenericIndexerAsync(indexer, persist); + if (result.Succeeded) { - _logger.LogWarning(ex, "Generic indexer test failed for {Name}", LogRedaction.SanitizeText(indexer.Name)); - return await BuildIndexerTestBadRequestAsync(indexer, persist, "Indexer test failed", ex); + return Ok(new { success = true, message = result.Message, indexer = RedactIndexerForCaller(indexer) }); } - catch (InvalidOperationException ex) - { - _logger.LogWarning(ex, "Generic indexer test failed for {Name}", LogRedaction.SanitizeText(indexer.Name)); - return await BuildIndexerTestBadRequestAsync(indexer, persist, "Indexer test failed", ex); - } - } - private string? ParseNewznabError(string xmlContent) - { - try + if (result.Status.HasValue) { - // 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; + return BadRequest(new { success = false, message = result.Message, status = result.Status.Value, indexer = RedactIndexerForCaller(indexer) }); } - catch (Exception caughtEx_1) when (caughtEx_1 is not OperationCanceledException && caughtEx_1 is not OutOfMemoryException && caughtEx_1 is not StackOverflowException) + + if (!string.IsNullOrEmpty(result.Error)) { - // If we can't parse the XML, assume no error element - return null; + 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) }); } /// @@ -396,189 +241,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)) - { - return BadRequest(new { message = "Prowlarr URL is required" }); - } - - if (string.IsNullOrWhiteSpace(effectiveApiKey)) - { - return BadRequest(new { message = "Prowlarr API key is required" }); - } - - var baseUrl = BuildProwlarrBaseUrl(effectiveUrl, effectivePort); - var blockedBaseUrlReason = await ValidateOutboundUrlForCallerAsync(baseUrl); - if (!string.IsNullOrWhiteSpace(blockedBaseUrlReason)) + var result = await _prowlarrImportWorkflow.ImportAsync(request); + if (result.Kind == ProwlarrIndexerImportWorkflowResultKind.BadRequest) { - return BadRequest(new { message = $"Blocked Prowlarr target: {blockedBaseUrlReason}" }); + return BadRequest(new { message = result.Message }); } - 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) && PayloadRequiresProwlarrTagMap(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 = GetCategoryIdsFromProwlarrIndexer(element); - var prowlarrTags = GetProwlarrTagValues(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)) + if (result.UpstreamStatus.HasValue) { - name = $"{name} (Prowlarr)"; + return StatusCode(result.StatusCode ?? StatusCodes.Status502BadGateway, new { message = result.Message, status = result.UpstreamStatus.Value }); } - 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 = BuildProwlarrProxyUrl(baseUrl, indexerId); - var normalizedUrl = NormalizeProwlarrProxyUrl(proxyUrl); - - var exists = existingIndexers.FirstOrDefault(i => - NormalizeProwlarrProxyUrl(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 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 }) }); } @@ -680,92 +364,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) + }); } /// @@ -773,171 +390,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) }); } @@ -952,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}{ApiVersionUtils.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); } /// @@ -1148,497 +463,5 @@ 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(); - 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); - // 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; - } - - 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.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 ed1d665b1..9e5391e16 100644 --- a/listenarr.api/Controllers/LibraryController.cs +++ b/listenarr.api/Controllers/LibraryController.cs @@ -17,22 +17,10 @@ */ using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Caching.Memory; 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; 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.Search; -using Microsoft.AspNetCore.SignalR; using Listenarr.Application.Audiobooks; using Listenarr.Api.Attributes; @@ -43,91 +31,81 @@ 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; private readonly IServiceScopeFactory _scopeFactory; - private readonly IHistoryRepository _historyRepository; 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; - private readonly NotificationService? _notificationService; - private readonly IRootFolderService? _rootFolderService; - private readonly ILibraryAddService? _libraryAddService; private readonly IRenameService? _renameService; private readonly ILibraryListService _libraryListService; - private readonly string _contentRootPath; + private readonly LibraryAddWorkflow _addWorkflow; + private readonly LibraryMetadataRescanWorkflow _metadataRescanWorkflow; + private readonly LibraryScanPathResolver _scanPathResolver; + private readonly LibraryScanQueueWorkflow _scanQueueWorkflow; + 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. - /// 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. - /// 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. + /// 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, - IRootFolderRepository rootFolderRepository, IFileNamingService fileNamingService, - IApplicationPathService applicationPathService, ILibraryListService libraryListService, - IScanQueueService? scanQueueService = null, + 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, - IRootFolderService? rootFolderService = null, - ILibraryAddService? libraryAddService = null, IRenameService? renameService = null) { _repo = repo; - _imageCacheService = imageCacheService; _logger = logger; _scopeFactory = scopeFactory; - _historyRepository = historyRepository; _audioFileRepository = audioFileRepository; - _qualityProfileRepository = qualityProfileRepository; - _downloadRepository = downloadRepository; - _rootFolderRepository = rootFolderRepository; _fileNamingService = fileNamingService; - _scanQueueService = scanQueueService; _moveQueueService = moveQueueService; - _notificationService = notificationService; - _rootFolderService = rootFolderService; - _libraryAddService = libraryAddService; _renameService = renameService; _libraryListService = libraryListService; - _contentRootPath = applicationPathService.ContentRootPath; - } - - private static string ResolvePathWithOptionalBase(string? basePath, string candidatePath) - { - return FileUtils.CombineWithOptionalBase(basePath, candidatePath); + _addWorkflow = addWorkflow; + _metadataRescanWorkflow = metadataRescanWorkflow; + _scanPathResolver = scanPathResolver; + _scanQueueWorkflow = scanQueueWorkflow; + _manualScanWorkflow = manualScanWorkflow; + _bulkEditWorkflow = bulkEditWorkflow; + _moveWorkflow = moveWorkflow; + _deleteWorkflow = deleteWorkflow; + _updateWorkflow = updateWorkflow; + _identifierWorkflow = identifierWorkflow; } public class ScanRequest @@ -143,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)); - - 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); } /// @@ -548,7 +152,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)) @@ -623,7 +227,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, @@ -685,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 = GetEffectiveIdentifiers(audiobook) - .Select(ToIdentifierResponse) - .ToList(); - - return Ok(new - { - audiobookId = audiobook.Id, - identifiers - }); + return await _identifierWorkflow.GetAsync(id); } /// @@ -711,145 +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(IdentifierFullSourceKey), - 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 = IdentifierFullSourceKey(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; - 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 = OrderIdentifiers(audiobook.ExternalIdentifiers).Select(ToIdentifierResponse).ToList() - }); + return await _identifierWorkflow.ReplaceAsync(id, request); } /// @@ -859,3346 +312,128 @@ 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 await _metadataRescanWorkflow.RescanAsync(id, HttpContext); + } - return StatusCode(StatusCodes.Status429TooManyRequests, new - { - message = rateLimitMessage, - retryAfterSeconds - }); - } + // NOTE: Do not perform ad-hoc schema changes at runtime. Use EF Core migrations to modify the database schema. - var effectiveIdentifiers = GetEffectiveIdentifiers(audiobook); - var asinIdentifiers = effectiveIdentifiers - .Where(i => i.Type == AudiobookExternalIdentifierType.Asin) - .OrderByDescending(i => i.IsPrimary) - .ThenBy(i => i.Source) - .ThenBy(i => i.ValueNormalized) - .ToList(); + /// + /// [Debug] Return raw AudiobookFile database rows for an audiobook. Restricted to local/admin callers. + /// + /// Audiobook ID. + [HttpGet("{id}/files-debug")] + [LocalOrAdmin] + public async Task GetAudiobookFilesDebug(int id) + { + var files = await _audioFileRepository.GetByAudiobookIdAsync(id); + return Ok(files); + } - var isbnIdentifiers = effectiveIdentifiers - .Where(i => i.Type == AudiobookExternalIdentifierType.Isbn) - .OrderByDescending(i => i.IsPrimary) - .ThenBy(i => i.Source) - .ThenBy(i => i.ValueNormalized) - .ToList(); + /// + /// Update an existing audiobook's metadata and settings. Supports partial updates — only non-null fields are applied. + /// + /// Audiobook ID. + /// Fields to update (null fields are left unchanged). + [HttpPut("{id}")] + public async Task UpdateAudiobook(int id, [FromBody] Audiobook updatedAudiobook) + { + return await _updateWorkflow.UpdateAsync(id, updatedAudiobook); + } - if (!asinIdentifiers.Any() && !isbnIdentifiers.Any()) - { - return BadRequest(new { message = "No ASIN or ISBN identifiers are available for metadata rescan." }); - } + /// + /// Delete an audiobook from the library, including its cached cover image. + /// + /// Audiobook ID. + /// When true, delete all files within the audiobook folder when it can be done safely; otherwise fall back to tracked audiobook files before removing the library record. + /// When true, also delete the audiobook folder when it can be done safely. + [HttpDelete("{id}")] + public async Task DeleteAudiobook(int id, [FromQuery] bool deleteFiles = false, [FromQuery] bool deleteFolder = false) + { + return await _deleteWorkflow.DeleteAsync(id, deleteFiles, deleteFolder); + } - 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) - { - 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 - }); - } - - // NOTE: Do not perform ad-hoc schema changes at runtime. Use EF Core migrations to modify the database schema. + /// + /// Delete multiple audiobooks in a single transaction. + /// + /// List of audiobook IDs to delete. + /// Summary with deleted count, image cleanup count, and any per-item errors. + [HttpPost("delete-bulk")] + public async Task BulkDeleteAudiobooks([FromBody] BulkDeleteRequest request) + { + return await _bulkEditWorkflow.BulkDeleteAsync(request); + } /// - /// [Debug] Return raw AudiobookFile database rows for an audiobook. Restricted to local/admin callers. + /// Bulk-update fields (monitored status, quality profile, root folder) for multiple audiobooks at once. /// - /// Audiobook ID. - [HttpGet("{id}/files-debug")] - [LocalOrAdmin] - public async Task GetAudiobookFilesDebug(int id) + /// Audiobook IDs and the fields to update. + [HttpPost("bulk-update")] + public async Task BulkUpdateAudiobooks([FromBody] BulkUpdateRequest request) { - var files = await _audioFileRepository.GetByAudiobookIdAsync(id); - return Ok(files); + return await _bulkEditWorkflow.BulkUpdateAsync(request); } /// - /// Update an existing audiobook's metadata and settings. Supports partial updates — only non-null fields are applied. + /// Scan the filesystem for files belonging to this audiobook, extract metadata (ffprobe) and persist AudiobookFile records. + /// Optional body: { path: "C:\\some\\folder" } to scan a specific folder instead of the configured output path. /// - /// Audiobook ID. - /// Fields to update (null fields are left unchanged). - [HttpPut("{id}")] - public async Task UpdateAudiobook(int id, [FromBody] Audiobook updatedAudiobook) + [HttpPost("{id}/scan")] + public async Task ScanAudiobookFiles(int id, [FromBody] ScanRequest? request) { - 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) - { - 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 _manualScanWorkflow.ScanAsync(id, request); } /// - /// Delete an audiobook from the library, including its cached cover image. + /// Get in-memory scan job status by jobId (debugging/admin helper). /// - /// Audiobook ID. - /// When true, delete all files within the audiobook folder when it can be done safely; otherwise fall back to tracked audiobook files before removing the library record. - /// When true, also delete the audiobook folder when it can be done safely. - [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; - - DeleteFilesystemResult? filesystemResult = null; - if (deleteFiles) - { - filesystemResult = await DeleteAudiobookFilesystemAsync(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 = BuildDeleteMessage(filesystemResult); - 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" }); - } - - 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) + [HttpGet("scan/{jobId}")] + public IActionResult GetScanJobStatus(string jobId) { - 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; + return _scanQueueWorkflow.GetStatus(jobId); } - private static IReadOnlyList CollectTrackedFilePaths(Audiobook audiobook) + /// + /// Enqueue a background job to move an audiobook's files to a new destination path. + /// + /// Audiobook ID. + /// Move request with destination path and optional source override. + /// Accepted with a job ID that can be polled for progress. + [HttpPost("{id}/move")] + public async Task EnqueueMove(int id, [FromBody] MoveRequest request) { - 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(); + return await _moveWorkflow.EnqueueAsync(id, request); } - private void TryDeleteFile(string path, DeleteFilesystemResult result) + /// + /// Get the current status of a file-move background job. + /// + /// The GUID returned when the move was enqueued. + [HttpGet("move/{jobId}")] + public IActionResult GetMoveJobStatus(string jobId) { - 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)); - } + return _moveWorkflow.GetStatus(jobId); } - private void TryDeleteFolderContents(string folderPath, DeleteFilesystemResult result) + /// + /// Re-enqueue a previously failed or completed move job for retry. + /// + /// Original move job GUID. + /// Accepted with the new job ID. + [HttpPost("move/requeue/{jobId}")] + public async Task RequeueMoveJob(string jobId) { - 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. - /// - /// List of audiobook IDs to delete. - /// Summary with deleted count, image cleanup count, and any per-item errors. - [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 _moveWorkflow.RequeueAsync(jobId); } /// - /// Bulk-update fields (monitored status, quality profile, root folder) for multiple audiobooks at once. + /// Re-enqueue a previously failed or completed scan job for retry. /// - /// Audiobook IDs and the fields to update. - [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 = ComputeAudiobookBaseDirectoryFromPattern(audiobook, rootPath, fileNamingPattern); - - 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 }); - } - - /// - /// Scan the filesystem for files belonging to this audiobook, extract metadata (ffprobe) and persist AudiobookFile records. - /// Optional body: { path: "C:\\some\\folder" } to scan a specific folder instead of the configured output path. - /// - [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" }); - - // If a background scan queue is available, enqueue the job and return Accepted - if (_scanQueueService != 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 via SignalR so 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.Clients.All.SendAsync("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 }); - } - } - - // Determine scan root: request.Path, audiobook.BasePath, or application settings output path - string? scanRoot = null; - try - { - 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" }); - } - } - - 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 = CalculateBasePath(foundFiles); - _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 }); - } - } - - /// - /// Get in-memory scan job status by jobId (debugging/admin helper). - /// - [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" }); - } - - /// - /// Enqueue a background job to move an audiobook's files to a new destination path. - /// - /// Audiobook ID. - /// Move request with destination path and optional source override. - /// Accepted with a job ID that can be polled for progress. - [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.Clients.All.SendAsync("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 }); - } - } - - /// - /// Get the current status of a file-move background job. - /// - /// The GUID returned when the move was enqueued. - [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" }); - } - - /// - /// Re-enqueue a previously failed or completed move job for retry. - /// - /// Original move job GUID. - /// Accepted with the new job ID. - [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.Clients.All.SendAsync("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 }); - } - - /// - /// Re-enqueue a previously failed or completed scan job for retry. - /// - /// Original scan job GUID. + /// Original scan job GUID. /// Accepted with the new job ID. [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.Clients.All.SendAsync("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 }); - } - - 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.Clients.All.SendCoreAsync("SearchProgress", new object[] { 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) - { - 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 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)) - 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 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); - - 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; } + return await _scanQueueWorkflow.RequeueAsync(jobId); } public class BulkDeleteRequest 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/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/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/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/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/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/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/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/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..c0f2283e9 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,11 @@ public class MetadataController : ControllerBase private readonly IAsinLookupService _asinLookupService; private readonly IAuthorCatalogService _authorCatalogService; private readonly ISeriesCatalogService _seriesCatalogService; + private readonly MetadataImageCacheWorkflow _imageCacheWorkflow; + private readonly MetadataLookupCacheWorkflow _lookupCacheWorkflow; + private readonly MetadataLookupResponseCache _lookupResponseCache; + private readonly MetadataAuthorLookupWorkflow _authorLookupWorkflow; + private readonly MetadataSeriesLookupWorkflow _seriesLookupWorkflow; public MetadataController( IAudiobookMetadataService metadataService, @@ -62,6 +66,27 @@ public MetadataController( _authorCatalogService = authorCatalogService; _seriesCatalogService = seriesCatalogService; _logger = logger; + _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); } /// @@ -190,306 +215,14 @@ private async Task> LookupAuthorCore( string? asin, bool refresh) { - try - { - if (string.IsNullOrWhiteSpace(name)) return BadRequest("Author name is required"); - - var normalizedName = name.Trim(); - var normalizedAsin = string.IsNullOrWhiteSpace(asin) ? null : asin.Trim(); - var cacheKey = 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 AuthorLookupCacheEntry? 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 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(MapAuthorLookupResponse(cachedEntry, normalizedName)); - } - - return NotFound("Author not found"); - } - - string? cachedPath = cachedEntry.CachedPath; - if (!string.IsNullOrWhiteSpace(cachedEntry.Asin)) - { - cachedPath = await ResolveCachedImagePathAsync(cachedEntry.Asin) ?? cachedPath; - } - - cachedEntry.CachedPath = cachedPath; - - if (HasCompleteAuthorLookupData(cachedEntry.CachedPath, cachedEntry.Description, cachedEntry.SimilarAuthors)) - { - return Ok(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 ResolvePersistedAuthorCacheAsync(normalizedName, region, normalizedAsin); - if (persistedEntry != null) - { - var persistedResponse = await MapPersistedAuthorLookupResponseAsync(persistedEntry, normalizedName); - if (!refresh && - HasCompleteAuthorLookupData(persistedResponse.CachedPath, persistedResponse.Description, persistedResponse.SimilarAuthors)) - { - 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 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) - { - _cache.Set(cacheKey, new AuthorLookupCacheEntry - { - NotFound = true, - Name = normalizedName - }, new MemoryCacheEntryOptions { SlidingExpiration = TimeSpan.FromHours(6) }); - - 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 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 = 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 PersistAuthorLookupAsync( - persistedEntry, - normalizedName, - region, - result); - - CacheAuthorLookupResponse(cacheKey, result); - CacheAuthorLookupResponse(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) + var result = await _authorLookupWorkflow.LookupAsync(name, region, asin, refresh); + return result.Status switch { - _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) + }; } /// @@ -556,7 +289,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 }); } @@ -606,118 +339,14 @@ private async Task> LookupSeriesCore( string? asin, bool refresh) { - try + var result = await _seriesLookupWorkflow.LookupAsync(name, region, asin, refresh); + return result.Status switch { - 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 SeriesLookupCacheEntry? cachedEntry) && cachedEntry != null) - { - cachedEntry.Asin ??= normalizedAsin; - return Ok(MapSeriesLookupResponse(cachedEntry, normalizedName)); - } - - var persistedEntry = await ResolvePersistedSeriesCacheAsync(normalizedName, region, normalizedAsin); - if (!refresh && persistedEntry != null) - { - var persistedResponse = await MapPersistedSeriesLookupResponseAsync(persistedEntry, normalizedName); - 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 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 PersistSeriesLookupAsync( - persistedEntry, - normalizedName, - region, - result, - catalog?.Books); - - CacheSeriesLookupResponse(cacheKey, 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 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) + }; } /// @@ -785,7 +414,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,541 +425,6 @@ private async Task> GetSeriesBooksCore( } } - 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 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 = 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 - { - 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 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 - { - 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 = 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 - { - 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 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 - { - 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 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; } - 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/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/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/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/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.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 43d333856..c8ed33f35 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; @@ -51,75 +49,43 @@ 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; + private readonly ProwlarrIndexerUpsertWorkflow _indexerUpsertWorkflow; + private readonly ProwlarrIndexerNotificationWorkflow _indexerNotificationWorkflow; - // 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, IIndexerRepository indexerRepository, - IHubContext settingsHub, + IHubBroadcaster hubBroadcaster, + IRealtimeClientRegistry realtimeClientRegistry, IToastService toastService, IStartupConfigService startupConfigService, - IApplicationVersionService applicationVersionService) + IApplicationVersionService applicationVersionService, + ProwlarrIndexerUpsertWorkflow? indexerUpsertWorkflow = null, + ProwlarrIndexerNotificationWorkflow? indexerNotificationWorkflow = null) { _logger = logger; _indexerRepository = indexerRepository; - _settingsHub = settingsHub; + _hubBroadcaster = hubBroadcaster; + _realtimeClientRegistry = realtimeClientRegistry; _toastService = toastService; _startupConfigService = startupConfigService; _applicationVersionService = applicationVersionService; + _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() @@ -215,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); } @@ -258,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); } @@ -321,12 +217,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()); } /// @@ -347,7 +238,7 @@ public async Task GetIndexersList() .ThenBy(i => i.Name) .ToList(); - if (SecurityRequestUtils.ShouldRedactSecretsForCaller(HttpContext)) + if (HttpSecurityRequestUtils.ShouldRedactSecretsForCaller(HttpContext)) { indexers = indexers.Select(ApiResponseRedactor.RedactIndexer).ToList(); } @@ -381,23 +272,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 _settingsHub.Clients.All.SendAsync("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)) - { - 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 { @@ -437,297 +312,23 @@ 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) - { - // 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 allIndexers = await _indexerRepository.GetAllAsync(); - var existing = allIndexers.FirstOrDefault(i => NormalizeIndexerUrl(i.Url) == normalized && (i.ApiKey ?? string.Empty) == (apiKeyFromPayload ?? 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 = 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, - 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" }); } - // 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; - 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; - await _settingsHub.Clients.All.SendAsync("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 < 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 = ShouldSendToastForIndexer(indexer.Id, toastMessage); - } - 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); - } - 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 - { - 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) { @@ -743,34 +344,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. @@ -808,243 +381,34 @@ 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) { - // 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(); - - // 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)); - if (exists != null) - { - skipped++; - _logger?.LogInformation("Prowlarr: Skipping existing indexer (name={Name}, url={Url}, apiKeyPresent={HasApiKey})", name, exists.Url, !string.IsNullOrEmpty(apiKey)); - continue; - } - - var indexer = new Indexer - { - Name = name, - Implementation = implementation, - Url = url, - ApiKey = string.IsNullOrEmpty(apiKey) ? null : apiKey, - Categories = categories ?? string.Empty, - Tags = string.Empty, - AdditionalSettings = string.Empty, - CreatedAt = DateTime.UtcNow, - UpdatedAt = DateTime.UtcNow, - IsEnabled = true - }; - - // Guess Type from implementation - var implLower = (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 - { - 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 _settingsHub.Clients.All.SendAsync("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 (ShouldSendToastForMessage(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 via SignalR"); - } - } + await _indexerNotificationWorkflow.NotifyImportedAsync(created, skipped, createdIndexers); // Log a summary for diagnostics _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 }); } /// /// 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] @@ -1086,45 +450,29 @@ 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 _settingsHub.Clients.All.SendAsync("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 }); } /// /// 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 { - 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) { - _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" }); } } @@ -1132,7 +480,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] @@ -1181,24 +529,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()); } /// @@ -1214,64 +545,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('/'); - } - } - - // 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 { @@ -1303,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); + } +} 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/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('/'); + } + } +} 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/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/Controllers/ProwlarrIndexerPayloadReader.cs b/listenarr.api/Controllers/ProwlarrIndexerPayloadReader.cs new file mode 100644 index 000000000..4925509ab --- /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; + } + } + + public sealed record ParsedProwlarrIndexerPayload( + string Name, + string Implementation, + string BaseUrl, + string ApiPath, + string ApiKey, + string? Categories, + string Url); +} 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/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.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/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/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 fefe6e9d7..23141a11d 100644 --- a/listenarr.api/Controllers/SearchController.cs +++ b/listenarr.api/Controllers/SearchController.cs @@ -16,11 +16,8 @@ * 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.Common; using Listenarr.Application.Interfaces; using Listenarr.Application.Metadata; using Listenarr.Application.Search; @@ -39,7 +36,9 @@ 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 StructuredSearchWorkflow _structuredSearchWorkflow; + private readonly SearchByTitleWorkflow _searchByTitleWorkflow; public SearchController( ISearchService searchService, @@ -47,106 +46,34 @@ public SearchController( AudibleService audibleService, IAudiobookMetadataService metadataService, IImageCacheService? imageCacheService = null, - MetadataConverters? metadataConverters = null) + MetadataConverters? metadataConverters = null, + SearchResponseMapper? responseMapper = null, + StructuredSearchWorkflow? structuredSearchWorkflow = null, + SearchByTitleWorkflow? searchByTitleWorkflow = null) { _searchService = searchService; _logger = logger; _audibleService = audibleService; _metadataService = metadataService; _imageCacheService = imageCacheService; - _metadataConverters = metadataConverters ?? new MetadataConverters(imageCacheService, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); - } - - private string BuildApiImagePath(string identifier, string? sourceUrl = null) - => ApiVersionUtils.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; - - 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 - { - 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(); + var metadataConvertersInstance = metadataConverters ?? new MetadataConverters(imageCacheService, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + _responseMapper = responseMapper ?? new SearchResponseMapper( + metadataService, + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance, + imageCacheService); + _structuredSearchWorkflow = structuredSearchWorkflow ?? new StructuredSearchWorkflow( + searchService, + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance, + audibleService, + metadataService, + imageCacheService, + metadataConvertersInstance, + _responseMapper); + _searchByTitleWorkflow = searchByTitleWorkflow ?? new SearchByTitleWorkflow( + searchService, + audibleService, + metadataService, + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); } /// @@ -158,718 +85,8 @@ private List SimplifySearchResults(List results) [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 options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; - options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase)); - - var req = JsonSerializer.Deserialize(reqJson.GetRawText(), options); - 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(); - - // 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); - } - } - } - - // 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); - _logger.LogDebug("[DBG] Search(simple) returning {Count} metadata results", mapped?.Length ?? 0); - return Ok(mapped); - } - 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:"); - - // 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 = 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) - { - _logger.LogWarning(ex, "Failed to normalize ISBN in advanced search"); - return BadRequest("Invalid ISBN format"); - } - - // 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); - 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); - } - } - if (md != null) - { - var result = SearchResultConverters.ToSearchResult(md); - var asinResults = new List { result }; - return Ok(useSimplified ? 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 MapAudibleSearchResultToOutputAsync(book, region)); - } - 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); - - // 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); - } - } - } - - // 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 => MapMetadataResultToAudibleAsync(r, region))).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"); - } - } - - 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 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; - 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); - } - } + var result = await _structuredSearchWorkflow.ExecuteAsync(reqJson, simplified, HttpContext); + return result.Succeeded ? Ok(result.Payload) : BadRequest(result.Payload); } /// @@ -924,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) { @@ -1029,35 +205,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()); } @@ -1141,16 +289,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); @@ -1253,126 +392,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 @@ -1404,25 +430,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. @@ -1448,4 +464,3 @@ public async Task> SearchByApi( } } } - 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; + } + } +} 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.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.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/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/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/GlobalUsings.cs b/listenarr.api/GlobalUsings.cs new file mode 100644 index 000000000..66c625839 --- /dev/null +++ b/listenarr.api/GlobalUsings.cs @@ -0,0 +1,3 @@ +global using Microsoft.Extensions.Hosting; +global using Listenarr.Api.Security; +global using Listenarr.Api.Common; 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/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/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.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..328579fb6 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; @@ -110,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"); @@ -319,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(); @@ -342,9 +343,34 @@ ex is IOException // Add fallback scraper // Add search result scorer builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); // Add ASIN search handler 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(); +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(); +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); @@ -417,9 +443,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) @@ -653,8 +676,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()) @@ -775,23 +798,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.api/Security/SecurityRequestHttpContextExtensions.cs b/listenarr.api/Security/SecurityRequestHttpContextExtensions.cs new file mode 100644 index 000000000..571ec81fe --- /dev/null +++ b/listenarr.api/Security/SecurityRequestHttpContextExtensions.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. + */ +namespace Listenarr.Api.Security; + +public static class HttpSecurityRequestUtils +{ + public static bool IsLoopbackRequest(HttpContext? context) + { + var ip = context?.Connection?.RemoteIpAddress; + if (ip == null) + { + return true; + } + + return Listenarr.Application.Security.SecurityRequestUtils.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.api/packages.lock.json b/listenarr.api/packages.lock.json new file mode 100644 index 000000000..3d8569c1e --- /dev/null +++ b/listenarr.api/packages.lock.json @@ -0,0 +1,552 @@ +{ + "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, )", + "Listenarr.Domain": "[1.0.0, )" + } + }, + "listenarr.domain": { + "type": "Project" + }, + "listenarr.infrastructure": { + "type": "Project", + "dependencies": { + "AsyncKeyedLock": "[8.0.2, )", + "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, )", + "SixLabors.ImageSharp": "[3.1.12, )", + "TagLibSharp": "[2.3.0, )" + } + }, + "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/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/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/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/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/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; - } } } 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/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}"; - } } } diff --git a/listenarr.application/Audiobooks/MoveQueueService.cs b/listenarr.application/Audiobooks/MoveQueueService.cs index c7c9092ee..3bc1c73ee 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; @@ -129,10 +127,10 @@ 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>(); + 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) { @@ -221,5 +219,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/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); + } + } +} 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/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/Downloads/ClientQueueFetchResult.cs b/listenarr.application/Downloads/ClientQueueFetchResult.cs new file mode 100644 index 000000000..26451d556 --- /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 +{ + public 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/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/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/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/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/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/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/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/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/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/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..51ec78e0e 100644 --- a/listenarr.application/Downloads/DownloadQueueService.cs +++ b/listenarr.application/Downloads/DownloadQueueService.cs @@ -15,8 +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 System.Text.Json; using Listenarr.Application.Interfaces; using Listenarr.Application.Interfaces.Repositories; using Listenarr.Domain.Models; @@ -34,8 +32,8 @@ public class DownloadQueueService( IMemoryCache cache, IConfigurationService configurationService, IDownloadRepository downloadRepository, - IDownloadProcessingJobRepository downloadProcessingJobRepository, - IDownloadClientGateway clientGateway, + DownloadQueueCandidateLoader candidateLoader, + DownloadClientQueuePoller clientQueuePoller, IAppMetricsService metrics, ILogger logger) : IDownloadQueueService { @@ -58,70 +56,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 => { @@ -138,14 +76,18 @@ public async Task GetQueueSnapshotAsync() }); var includeCompletedExternal = appSettings != null && appSettings.ShowCompletedExternalDownloads; - var clientQueueResults = await FetchClientQueueResultsAsync(enabledClients); - var clientStatuses = BuildClientStatuses(clientQueueResults); + var clientQueueResults = await clientQueuePoller.FetchAsync( + enabledClients, + _clientQueueTimeout, + _staleSnapshotMaxAge, + _maxParallelClientPolls); + 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 { @@ -177,7 +119,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 +281,7 @@ public async Task GetQueueSnapshotAsync() return false; } - if (GetKnownClientItemIds(d.Metadata).Any(allClientItemIds.Contains)) + if (DownloadQueueMetadataMatcher.GetKnownClientItemIds(d.Metadata).Any(allClientItemIds.Contains)) { return false; } @@ -474,251 +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(); - ObserveFaultedPollTask(pollTask, client); - - stopwatch.Stop(); - TryIncrementMetric("download.queue.client.poll.timeout"); - TryTimingMetric("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); - TryTimingMetric("download.queue.client.poll.duration", stopwatch.Elapsed); - - return new ClientQueueFetchResult( - client, - CloneQueueItems(clientQueue), - usedCachedSnapshot: false, - isUnavailable: false, - snapshotAge: null, - failureReason: null, - snapshotState: "live", - snapshotRefreshedAtUtc: refreshedAtUtc); - } - catch (OperationCanceledException) when (!timeoutCts.IsCancellationRequested) - { - stopwatch.Stop(); - TryIncrementMetric("download.queue.client.poll.failure"); - TryTimingMetric("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(); - TryIncrementMetric("download.queue.client.poll.failure"); - TryTimingMetric("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(GetClientQueueSnapshotCacheKey(client), out ClientQueueSnapshotCacheEntry? cachedSnapshot) && - cachedSnapshot != null) - { - var snapshotAge = DateTimeOffset.UtcNow - cachedSnapshot.RefreshedAtUtc; - if (snapshotAge <= _staleSnapshotMaxAge) - { - TryIncrementMetric("download.queue.client.snapshot.fallback"); - - return new ClientQueueFetchResult( - client, - 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(CloneQueueItems(clientQueue), refreshedAtUtc); - cache.Set( - GetClientQueueSnapshotCacheKey(client), - cacheEntry, - new MemoryCacheEntryOptions - { - AbsoluteExpirationRelativeToNow = _staleSnapshotMaxAge - }); - } - - 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 => - { - 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, @@ -734,7 +431,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 +443,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,124 +452,5 @@ 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( - 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..db676a405 --- /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 +{ + public 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.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/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/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 d80d9d78b..f320dcc83 100644 --- a/listenarr.application/Downloads/DownloadService.cs +++ b/listenarr.application/Downloads/DownloadService.cs @@ -16,10 +16,6 @@ * 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,30 +34,27 @@ public class DownloadService( IQualityProfileService qualityProfileService, ISearchService searchService, IDownloadClientGateway clientGateway, - IMemoryCache cache, IDownloadQueueService downloadQueueService, INotificationService notificationService, IHubBroadcaster hubBroadcaster, - IDownloadHistoryService downloadHistoryService) : IDownloadService + IDownloadHistoryService downloadHistoryService, + DownloadTypeResolver downloadTypeResolver, + DownloadClientSelector downloadClientSelector, + DownloadCachedTorrentStore cachedTorrentStore, + DirectDownloadWorkflow directDownloadWorkflow, + DownloadRemovalWorkflow downloadRemovalWorkflow) : 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(); // 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) { @@ -73,10 +66,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); } /// @@ -84,36 +74,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) @@ -187,7 +148,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 @@ -240,8 +201,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 +235,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 +272,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 +296,7 @@ public async Task SendToDownloadClientAsync(SearchResult searchResult, s if (downloadClientId == null) { - downloadClientId = await GetAppropriateDownloadClient(isTorrent); + downloadClientId = await downloadClientSelector.GetAppropriateDownloadClientAsync(isTorrent); if (downloadClientId == null) { @@ -369,20 +330,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) { @@ -399,31 +350,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); @@ -457,7 +389,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)) @@ -465,76 +397,23 @@ 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); - // 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 { @@ -556,815 +435,17 @@ 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); - } - } - - 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); - } - - 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 - { - public byte[]? Bytes { get; set; } - 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; - } + var preparationService = new MyAnonamouseTorrentPreparationService( + indexerRepository, + httpClientFactory, + cachedTorrentStore, + logger); + await preparationService.PrepareAsync(searchResult, downloadId); } 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); } // @@ -1374,41 +455,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) @@ -1425,150 +472,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 || !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 - { - 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.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.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/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..75dddddd2 100644 --- a/listenarr.application/Interfaces/IHubBroadcaster.cs +++ b/listenarr.application/Interfaces/IHubBroadcaster.cs @@ -19,8 +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.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/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/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/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/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/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 68e9be914..b5cf7936e 100644 --- a/listenarr.application/Metadata/AudibleService.cs +++ b/listenarr.application/Metadata/AudibleService.cs @@ -16,72 +16,47 @@ * along with this program. If not, see . */ 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 { 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 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 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) + { + } + + [ActivatorUtilitiesConstructor] + public AudibleService(HttpClient httpClient, ILogger logger, IAudibleAuthorPageParser? authorPageParser) { - _httpClient = httpClient; _logger = logger; - _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); } /// @@ -95,353 +70,43 @@ public AudibleService(HttpClient httpClient, ILogger logger) /// 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 = - $"{BuildAudibleApiBaseUrl(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())}" + - $"&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(MapBookResponseToSearchResult) - .Where(book => book != null) - .Cast() - .ToList(); - - mapped = 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 = 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 = 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), - 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) @@ -550,21 +215,12 @@ public AudibleService(HttpClient httpClient, ILogger logger) 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) @@ -574,30 +230,7 @@ public AudibleService(HttpClient httpClient, ILogger logger) 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); } /// @@ -605,34 +238,7 @@ public AudibleService(HttpClient httpClient, ILogger logger) /// 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); } /// @@ -640,166 +246,17 @@ public AudibleService(HttpClient httpClient, ILogger logger) /// public virtual async Task GetAuthorByAsinAsync(string authorAsin, string region = "us") { - if (string.IsNullOrWhiteSpace(authorAsin)) return null; - - try - { - var locale = GetAudibleLocale(region); - var url = - $"{BuildAudibleApiBaseUrl(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 = 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 = 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 = 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( @@ -815,1159 +272,67 @@ private async Task SearchProductsDirectAsync( string sortBy, bool returnRawProducts = false) { - var safeRegion = 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 = $"{BuildAudibleApiBaseUrl(safeRegion)}/1.0/catalog/products/?{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 => MapProductToBookResponse(product, safeRegion)) - .Where(product => product != null) - .Select(product => MapBookResponseToSearchResult(product!)) - .Where(product => product != null) - .Cast() - .Where(product => !SearchResultIndicatesPodcast(product)) - .ToList(); - - results = 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 = NormalizeRegion(region); - var url = - $"{BuildAudibleApiBaseUrl(safeRegion)}/1.0/catalog/products/{Uri.EscapeDataString(asin)}?" + - $"{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 = 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( - $"{BuildAudibleApiBaseUrl(normalizedRegion)}/1.0/catalog/products/?" + - $"{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 => 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 = MapProductToBookResponse(product, normalizedRegion); - if (!string.IsNullOrWhiteSpace(mapped?.Asin)) - { - results[mapped.Asin!] = mapped; - } - } - } - } - - return orderedAsins - .Where(results.ContainsKey) - .Select(asin => results[asin]) - .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 = 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 = 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 : $"{GetAudibleBaseUrl(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 - { - 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 = GetAudibleLocale(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; - } - } - - 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 - /// 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) - { - 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); - } - - 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 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); - 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 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 = BuildAudibleAuthorPageUrl(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; - } - - 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)) - { - _logger.LogWarning("Audible author page contained no recognizable product tiles 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); - } - } - } - - 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(); - } - - 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) - ? 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( - 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; @@ -2079,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/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/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/Notification/DiscordBotService.cs b/listenarr.application/Notification/DiscordBotService.cs index 6743fd39e..c77674c8d 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; } @@ -107,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 @@ -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/NotificationDiagnostics.cs b/listenarr.application/Notification/NotificationDiagnostics.cs new file mode 100644 index 000000000..e7b932bfc --- /dev/null +++ b/listenarr.application/Notification/NotificationDiagnostics.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. + * + * 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 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/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/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/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 5a258dc77..154946c51 100644 --- a/listenarr.application/Notification/NotificationService.cs +++ b/listenarr.application/Notification/NotificationService.cs @@ -19,11 +19,9 @@ 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; -using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; namespace Listenarr.Application.Notification @@ -36,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 IHttpContextAccessor? _httpContextAccessor; + private readonly IRequestContextAccessor? _requestContextAccessor; private readonly INotificationPayloadBuilder _payloadBuilder; + private readonly NotificationHttpSender _httpSender; - 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; + _httpSender = new NotificationHttpSender(httpClient, httpClient, logger, AllowPrivateWebhookTargetsForCurrentRequest); } // INotificationService interface stubs — webhook dispatch goes through SendNotificationAsync; @@ -100,111 +98,25 @@ 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 + || SecurityRequestUtils.IsLoopback(context.RemoteIpAddress) + || context.IsAuthenticatedAdminOrApiKey; } 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); } /// @@ -215,59 +127,24 @@ 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; } - // 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 = 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)) { try { - var startup = await _configurationService.GetStartupConfigAsync(); - var baseUrl = startup?.UrlBase; - - if (string.IsNullOrWhiteSpace(baseUrl) && _httpContextAccessor?.HttpContext != null) - { - var derived = NotificationPayloadBuilder.GetBaseUrlFromHttpContext(_httpContextAccessor.HttpContext); - 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, _httpContextAccessor, + trigger, data, payloadContext.BaseUrl, _httpClient, _requestContextAccessor, logInfo: msg => _logger.LogInformation(msg), logDebug: (ex, msg) => _logger.LogDebug(ex, msg), - apiVersion: ApiVersionUtils.ResolveApiVersion(_httpContextAccessor?.HttpContext, startup?.ApiVersion) + apiVersion: payloadContext.ApiVersion ); _logger.LogDebug("Discord payload attachment present? {HasAttachment}", attachment != null); @@ -284,14 +161,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) @@ -319,16 +196,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) && _httpContextAccessor?.HttpContext != null) - { - var derived = NotificationPayloadBuilder.GetBaseUrlFromHttpContext(_httpContextAccessor.HttpContext); - if (!string.IsNullOrWhiteSpace(derived)) baseUrl = derived; - } - - var discordPayload = NotificationPayloadBuilder.CreateDiscordPayload(trigger, data, baseUrl, ApiVersionUtils.ResolveApiVersion(_httpContextAccessor?.HttpContext, 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; @@ -345,7 +214,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,10 +222,10 @@ 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); + await NotificationDiagnostics.LogFailedResponseAsync(response, webhookUrl, _logger); } } catch (HttpRequestException ex) @@ -397,16 +266,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) && _httpContextAccessor?.HttpContext != null) - { - var derived = NotificationPayloadBuilder.GetBaseUrlFromHttpContext(_httpContextAccessor.HttpContext); - if (!string.IsNullOrWhiteSpace(derived)) baseUrl = derived; - } - - var discordPayload = NotificationPayloadBuilder.CreateDiscordPayload(trigger, data, baseUrl, ApiVersionUtils.ResolveApiVersion(_httpContextAccessor?.HttpContext, 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> @@ -418,8 +279,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,10 +289,10 @@ 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); + await NotificationDiagnostics.LogFailedResponseAsync(response, webhookUrl, _logger); } return; } @@ -471,16 +332,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) && _httpContextAccessor?.HttpContext != null) - { - var derived = NotificationPayloadBuilder.GetBaseUrlFromHttpContext(_httpContextAccessor.HttpContext); - if (!string.IsNullOrWhiteSpace(derived)) baseUrl = derived; - } - - var discordPayload = NotificationPayloadBuilder.CreateDiscordPayload(trigger, data, baseUrl, ApiVersionUtils.ResolveApiVersion(_httpContextAccessor?.HttpContext, 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" }; @@ -488,15 +341,15 @@ 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); + await NotificationDiagnostics.LogFailedResponseAsync(response, webhookUrl, _logger); } return; } @@ -552,16 +405,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) && _httpContextAccessor?.HttpContext != null) - { - var derived = NotificationPayloadBuilder.GetBaseUrlFromHttpContext(_httpContextAccessor.HttpContext); - if (!string.IsNullOrWhiteSpace(derived)) baseUrl = derived; - } - - var discordPayload = NotificationPayloadBuilder.CreateDiscordPayload(trigger, data, baseUrl, ApiVersionUtils.ResolveApiVersion(_httpContextAccessor?.HttpContext, 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 @@ -583,16 +428,16 @@ 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); + await NotificationDiagnostics.LogFailedResponseAsync(response, webhookUrl, _logger); } return; } @@ -623,16 +468,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) && _httpContextAccessor?.HttpContext != null) - { - var derived = NotificationPayloadBuilder.GetBaseUrlFromHttpContext(_httpContextAccessor.HttpContext); - if (!string.IsNullOrWhiteSpace(derived)) baseUrl = derived; - } - - var discordPayload = NotificationPayloadBuilder.CreateDiscordPayload(trigger, data, baseUrl, ApiVersionUtils.ResolveApiVersion(_httpContextAccessor?.HttpContext, 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 @@ -644,16 +481,16 @@ 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); + await NotificationDiagnostics.LogFailedResponseAsync(response, webhookUrl, _logger); } return; } @@ -680,29 +517,21 @@ 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) && _httpContextAccessor?.HttpContext != null) - { - var derived = NotificationPayloadBuilder.GetBaseUrlFromHttpContext(_httpContextAccessor.HttpContext); - 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 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"); 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); if (!response.IsSuccessStatusCode) { - await HandleFailedResponseAsync(response); + await NotificationDiagnostics.LogFailedResponseAsync(response, webhookUrl, _logger); } } catch (HttpRequestException ex) @@ -723,57 +552,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.application/Notification/SearchProgressReporter.cs b/listenarr.application/Notification/SearchProgressReporter.cs index b34953f8a..826ddd29e 100644 --- a/listenarr.application/Notification/SearchProgressReporter.cs +++ b/listenarr.application/Notification/SearchProgressReporter.cs @@ -15,27 +15,27 @@ * 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 { /// - /// Handles broadcasting search progress updates to connected clients via SignalR. + /// Handles broadcasting search progress updates to connected realtime clients. /// 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; } /// - /// 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 @@ -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/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/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/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/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/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/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/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/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/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/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/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 new file mode 100644 index 000000000..3e2a1b6b1 --- /dev/null +++ b/listenarr.application/Search/MyAnonamouseResponseParser.cs @@ -0,0 +1,581 @@ +/* + * 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 +{ + public 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); + + if (!MyAnonamouseJsonResultExtractor.TryExtractResultArray(jsonResponse, indexer, logger, out var doc, out var dataArrayElement)) + { + 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; + } + + var publishDate = MyAnonamousePublishDateParser.Parse(item, title, logger); + + 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 = 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 = MyAnonamouseSizeParser.ExtractFromDescription(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)) + { + try + { + 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"); + } + } + + // Extract narrator from narrator_info JSON + string? narrator = null; + if (item.TryGetProperty("narrator_info", out var narratorInfo)) + { + try + { + 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"); + } + } + + // 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; + } + } + + var downloadUrl = MyAnonamouseDownloadUrlBuilder.Build(dlHash, indexer); + + // 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; + } + + } +} 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.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/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/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/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/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/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..cf88498ca 100644 --- a/listenarr.application/Search/SearchService.cs +++ b/listenarr.application/Search/SearchService.cs @@ -16,40 +16,34 @@ * along with this program. If not, see . */ -using System.Text.Json; -using System.Text.RegularExpressions; 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; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using Listenarr.Application.Notification; using Listenarr.Application.Metadata; -using SixLabors.ImageSharp; using Listenarr.Application.Security; 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; 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; + private readonly IndexerSearchWorkflow _indexerSearchWorkflow; + private readonly MetadataSourceCatalog _metadataSourceCatalog; + private readonly AudibleSimpleLookupWorkflow _audibleSimpleLookupWorkflow; + private readonly AudibleAuthorSearchWorkflow _audibleAuthorSearchWorkflow; + private readonly SearchFinalDispositionLogger _finalDispositionLogger; public SearchService( HttpClient httpClient, @@ -63,24 +57,52 @@ public SearchService( AsinCandidateCollector asinCandidateCollector, AsinEnricher asinEnricher, SearchResultScorerService searchResultScorer, + SearchResultSortingService searchResultSorting, AsinSearchHandler asinSearchHandler, IEnumerable? searchProviders = null, - IMemoryCache? cache = null) + IMemoryCache? cache = null, + ICoverImageProbe? coverImageProbe = null, + IHtmlTextExtractor? htmlTextExtractor = null, + IndexerSearchWorkflow? indexerSearchWorkflow = null, + MetadataSourceCatalog? metadataSourceCatalog = null, + AudibleAuthorPageCollector? audibleAuthorPageCollector = null, + AudibleSimpleLookupWorkflow? audibleSimpleLookupWorkflow = null, + AudibleAuthorSearchWorkflow? audibleAuthorSearchWorkflow = null, + SearchFinalDispositionLogger? finalDispositionLogger = 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; + _indexerSearchWorkflow = indexerSearchWorkflow ?? new IndexerSearchWorkflow( + httpClient, + configurationService, + indexerRepository, + resolvedSearchProviders, + new IndexerAdditionalSettingsParser(NullLogger.Instance), + NullLogger.Instance, + htmlTextExtractor); + _metadataSourceCatalog = metadataSourceCatalog ?? new MetadataSourceCatalog( + apiConfigRepository, + NullLogger.Instance); + var resolvedAudibleAuthorPageCollector = audibleAuthorPageCollector ?? new AudibleAuthorPageCollector( + audibleService, + NullLogger.Instance); + _audibleSimpleLookupWorkflow = audibleSimpleLookupWorkflow ?? new AudibleSimpleLookupWorkflow( + audibleService, + metadataConverters); + _audibleAuthorSearchWorkflow = audibleAuthorSearchWorkflow ?? new AudibleAuthorSearchWorkflow( + audibleService, + 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) @@ -103,7 +125,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 +148,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,132 +158,9 @@ 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(); - 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) @@ -435,50 +171,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) @@ -486,392 +185,43 @@ 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 { // ASIN case is handled separately above via ASIN handler - // ISBN - if (searchType == "ISBN" && !string.IsNullOrEmpty(isbnVal)) - { - 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); - } - } - - // 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); - 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 simpleAudibleResults = await _audibleSimpleLookupWorkflow.TrySearchAsync( + searchType, + isbnVal, + titleVal, + actualQuery, + region, + language); + if (simpleAudibleResults?.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."); - } - // 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); - 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); - - 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) - { - 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."); - } - - // 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); - } - } - - if (converted.Any()) return SearchResultConverters.ToMetadataList(converted); - } + return simpleAudibleResults; } - // TITLE-only - if (searchType == "TITLE" && !string.IsNullOrEmpty(titleVal)) + var authorAudibleResults = await _audibleAuthorSearchWorkflow.TrySearchAsync( + searchType, + authorVal, + titleVal, + isbnVal, + candidateLimit, + region, + language); + if (authorAudibleResults?.Any() == true) { - 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); - } - if (converted.Any()) return SearchResultConverters.ToMetadataList(converted); - } - + return authorAudibleResults; } - // 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 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); - } - if (converted.Any()) return SearchResultConverters.ToMetadataList(converted); - } - } } catch (Exception exAudibleFirst) when (exAudibleFirst is not OperationCanceledException && exAudibleFirst is not OutOfMemoryException && exAudibleFirst is not StackOverflowException) { @@ -1030,8 +380,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) { @@ -1150,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 = ComputeContainmentScore(enrichedCandidate, query); - fuzzy = 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 @@ -1298,2906 +537,29 @@ 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) + public async Task> SearchByApiAsync(string apiId, string query, string? category = null) { - 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; + return await _indexerSearchWorkflow.SearchByApiAsync(apiId, query, category); } - // 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) + public async Task> SearchIndexerResultsAsync(string apiId, string query, string? category = null, SearchRequest? request = null) { - 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); - } - - // 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"; - using var resp = await _httpClient.GetAsync(url); - if (!resp.IsSuccessStatusCode) continue; - using var ms = new System.IO.MemoryStream(await resp.Content.ReadAsByteArrayAsync()); - try - { - // 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; - } - } - 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) - { - 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(); - } - } - - 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(); - } - } - - 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 = ParseMyAnonamouseResponse(jsonResponse, indexer); - - // 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(); - } - } - - 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 = DetectFormatFromTags(tags ?? ""); - var formatFromField = !string.IsNullOrEmpty(rawFormatField) ? 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 = DetectQualityFromTags(tags ?? ""); - var finalQuality = qualityFromTags != "Unknown" ? qualityFromTags : (!string.IsNullOrEmpty(rawFormatField) ? 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); - if (q != "Unknown") finalQuality = q; - else - { - var q2 = DetectQualityFromFormat(description); - if (q2 != "Unknown") finalQuality = q2; - } - } - - if (finalQuality == "Unknown") - { - var probeText = title; - var q = DetectQualityFromTags(probeText); - if (q != "Unknown") finalQuality = q; - else - { - var q2 = 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 = DetectFormatFromTags(description); - if (!string.IsNullOrEmpty(f) && f != "MP3") finalFormat = f; - } - - if (finalFormat == "MP3") - { - var probeText = title; - var f = 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 = ParseLanguageFromCode(rawLangCode) ?? 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 = ParseLanguageFromCode(explicitLang) ?? 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 = 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) - { - 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 = 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 - /// 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; - } - - 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 = 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 = 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; - } + 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 = 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 = 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(); - var htmlDoc = new HtmlAgilityPack.HtmlDocument(); - htmlDoc.LoadHtml(html); - - // Look for common comment count patterns in page text - var text = htmlDoc.DocumentNode.InnerText; - 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 = 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; - } - - 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" - } - }; - } - - 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)) - 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) - { - 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; + return await _indexerSearchWorkflow.ParseTorznabResponseAsync(xmlContent, indexer); } - // (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.application/Security/SecurityRequestUtils.cs b/listenarr.application/Security/SecurityRequestUtils.cs index 9b68c6b31..0bba11f24 100644 --- a/listenarr.application/Security/SecurityRequestUtils.cs +++ b/listenarr.application/Security/SecurityRequestUtils.cs @@ -18,21 +18,13 @@ 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) + public static bool IsLoopback(IPAddress ip) { - 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(); @@ -41,66 +33,6 @@ public static bool IsLoopbackRequest(HttpContext? context) 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)) @@ -123,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/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.application/packages.lock.json b/listenarr.application/packages.lock.json new file mode 100644 index 000000000..ed41468eb --- /dev/null +++ b/listenarr.application/packages.lock.json @@ -0,0 +1,144 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "AsyncKeyedLock": { + "type": "Direct", + "requested": "[8.0.2, )", + "resolved": "8.0.2", + "contentHash": "QGys5cnIerNryv7V14PDkvGnlLz69kJtTfdnr+Lndcu+lRre397RNyU4FIeAJWgI9u73lTzXL52Qca9B/ncLXw==" + }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "Direct", + "requested": "[10.0.8, )", + "resolved": "10.0.8", + "contentHash": "EoK2TwVR1daxmfXUPnvIYZSk5XQjHe45sGekox4kvMt88KQZQhDVzYW5Na5+oNwTuRpE48hipyGJg12F1Tm70w==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.8" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Direct", + "requested": "[10.0.8, )", + "resolved": "10.0.8", + "contentHash": "21nbDV60SRPWGIivsyl6lqBeEJNG1sginhhfWgRrr3Ais7aQ12To25OAHQxgoiJkjqy1aQ6RxpZBGYuTi7Ge6A==" + }, + "Microsoft.Extensions.Http": { + "type": "Direct", + "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.Logging.Abstractions": { + "type": "Direct", + "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": "Direct", + "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" + } + }, + "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.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.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.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==" + }, + "listenarr.domain": { + "type": "Project" + } + } + } +} \ 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/Adapters/NzbgetAdapter.cs b/listenarr.infrastructure/Adapters/NzbgetAdapter.cs index 65c7f253d..dbabce53a 100644 --- a/listenarr.infrastructure/Adapters/NzbgetAdapter.cs +++ b/listenarr.infrastructure/Adapters/NzbgetAdapter.cs @@ -15,18 +15,10 @@ * 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; -using System.Text.Json; -using System.Xml.Linq; -using Listenarr.Application.Downloads; 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 @@ -37,20 +29,27 @@ 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; + private readonly NzbgetXmlRpcClient _xmlRpcClient; + 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); + 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) @@ -68,7 +67,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)) @@ -100,301 +99,14 @@ 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)); - 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 = ResolveCategory(client); - var priority = ResolvePriority(client); - var droneId = Guid.NewGuid().ToString().Replace("-", string.Empty); - - // Download NZB content - var nzbBytes = await DownloadNzbAsync(nzbUrl, indexerApiKey, ct); - var nzbFileName = 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 = 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 = ResolveCategory(client); - var priority = ResolvePriority(client); - var droneId = Guid.NewGuid().ToString().Replace("-", string.Empty); - - // Download and base64-encode the NZB content - var nzbBytes = await DownloadNzbAsync(nzbUrl, indexerApiKey, ct); - var nzbContentBase64 = Convert.ToBase64String(nzbBytes); - var nzbFileName = 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 CallXmlRpcAsync(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) { - 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 = 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 CallXmlRpcAsync(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 CallXmlRpcAsync(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 CallXmlRpcAsync(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) @@ -406,7 +118,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) @@ -430,7 +142,7 @@ public async Task> GetQueueAsync(DownloadClientConfiguration cli continue; } - var queueItem = MapGroup(client, structElement); + var queueItem = NzbgetResponseMapper.MapGroup(client, structElement); items.Add(queueItem); } } @@ -459,7 +171,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) @@ -511,7 +223,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) @@ -535,7 +247,7 @@ public async Task> GetItemsAsync(DownloadClientConfigur continue; } - var downloadClientItem = await MapGroupToDownloadClientItemAsync(client, structElement); + var downloadClientItem = NzbgetResponseMapper.MapGroupToDownloadClientItem(client, structElement); items.Add(downloadClientItem); } } @@ -562,456 +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 CallXmlRpcAsync(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; - } - } - - 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)) - { - 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 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)) - { - return null; - } - - var credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{client.Username}:{client.Password}")); - 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; + return await _importItemResolver.GetImportItemAsync(client, item); } /// @@ -1026,96 +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 CallXmlRpcAsync(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; - } - } - - 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; + return await _importItemResolver.GetImportItemAsync(client, queueItem); } public async Task> FetchDownloadsAsync( @@ -1123,108 +297,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 - if (!string.IsNullOrEmpty(client.Username)) - { - var authBytes = Encoding.UTF8.GetBytes($"{client.Username}:{client.Password}"); - var authHeader = Convert.ToBase64String(authBytes); - http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", 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/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/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); + } + } +} 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); + } + } + } +} 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; + } + } + } +} 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/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; + } + } + } +} 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/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/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/QbittorrentAdapter.cs b/listenarr.infrastructure/Adapters/QbittorrentAdapter.cs index 0fc19052f..e30623824 100644 --- a/listenarr.infrastructure/Adapters/QbittorrentAdapter.cs +++ b/listenarr.infrastructure/Adapters/QbittorrentAdapter.cs @@ -17,15 +17,9 @@ */ using System.Net; 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; using Listenarr.Domain.Models; -using Listenarr.Domain.Models.Exceptions; using Listenarr.Infrastructure.Adapters.Exceptions; using Listenarr.Infrastructure.Torrents; using Microsoft.Extensions.Logging; @@ -44,206 +38,29 @@ public class QbittorrentAdapter : IDownloadClientAdapter private readonly IHttpClientFactory _httpClientFactory; private readonly ILogger _logger; private readonly ITorrentFileDownloader _torrentFileDownloader; + private readonly QbittorrentTorrentAddPlanner _torrentAddPlanner; + private readonly QbittorrentAuthSession _authSession; + private readonly QbittorrentConnectionTester _connectionTester; + private readonly QbittorrentDownloadPollingWorkflow _downloadPollingWorkflow; + private readonly QbittorrentRemovalWorkflow _removalWorkflow; + private readonly QbittorrentImportItemResolver _importItemResolver; 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); + _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) { - 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."); - } - } - - /// - /// 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; + return await _connectionTester.TestConnectionAsync(client, ct); } public async Task AddAsync(DownloadClientConfiguration client, SearchResult result, CancellationToken ct = default) @@ -251,15 +68,12 @@ private async Task LoginAsync(HttpClient httpClient, DownloadClientConfigu 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) { @@ -267,103 +81,14 @@ 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) - { - 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"); - - var torrentFileName = string.IsNullOrEmpty(result.TorrentFileName) ? "download.torrent" : result.TorrentFileName; - var torrentContent = new ByteArrayContent(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[] { magnetLink, httpTorrentUrl } - .FirstOrDefault(static url => !string.IsNullOrEmpty(url)) ?? string.Empty; - - var formData = new List> - { - new("urls", url), - new("savepath", savePath) - }; - - if (!string.IsNullOrEmpty(category)) - formData.Add(new("category", category)); - if (!string.IsNullOrEmpty(tags)) - formData.Add(new("tags", 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) { @@ -380,11 +105,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) || @@ -394,14 +119,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)) @@ -410,37 +135,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; } /// @@ -463,16 +158,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 @@ -501,67 +190,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 - { - 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 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) @@ -573,21 +202,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) @@ -628,19 +244,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); @@ -650,62 +254,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) @@ -734,21 +283,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) @@ -812,98 +348,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) @@ -914,74 +366,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 /// @@ -991,101 +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 - { - 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) }; - - // 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 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 = ResolveTorrentContentPath(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); } /// @@ -1099,226 +389,14 @@ 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 - { - 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) }; - - // 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 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 = ResolveTorrentContentPath(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 = await TranslateSourceFilesAsync(client.Id, BuildTorrentSourceFiles(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; - } - - 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(); - } - - 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(); + return await _importItemResolver.GetImportItemAsync(client, download, queueItem, ct); } 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 QbittorrentImportPathResolver.ResolveContentPath(savePath, files); } public async Task> FetchDownloadsAsync( @@ -1326,402 +404,8 @@ 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); - } - } - - // 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 = QBitHasReachedSeedLimit( - 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)); - } - - - _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) - ? 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); } - /// - /// 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/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); + } + } +} 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/QbittorrentConnectionTester.cs b/listenarr.infrastructure/Adapters/QbittorrentConnectionTester.cs new file mode 100644 index 000000000..49175cfcb --- /dev/null +++ b/listenarr.infrastructure/Adapters/QbittorrentConnectionTester.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 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 + { + 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) + { + 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/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/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; + } + } +} 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; + } + } + } +} 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/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; + } + } + } +} 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/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/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 a77787c9c..4e5c12064 100644 --- a/listenarr.infrastructure/Adapters/SabnzbdAdapter.cs +++ b/listenarr.infrastructure/Adapters/SabnzbdAdapter.cs @@ -15,14 +15,12 @@ * 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.Downloads; 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 @@ -37,6 +35,11 @@ public class SabnzbdAdapter : IDownloadClientAdapter private readonly INzbUrlResolver _nzbUrlResolver; private readonly ILogger _logger; private readonly IAppMetricsService _appMetricsService; + private readonly SabnzbdRequestBuilder _requestBuilder; + private readonly SabnzbdDownloadPollingWorkflow _downloadPollingWorkflow; + private readonly SabnzbdRemovalWorkflow _removalWorkflow; + private readonly SabnzbdQueueFetchWorkflow _queueFetchWorkflow; + private readonly SabnzbdImportItemResolver _importItemResolver; public SabnzbdAdapter( IHttpClientFactory httpFactory, @@ -48,6 +51,11 @@ public SabnzbdAdapter( _nzbUrlResolver = nzbUrlResolver ?? throw new ArgumentNullException(nameof(nzbUrlResolver)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _appMetricsService = appMetricsService; + _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) @@ -56,15 +64,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) @@ -109,16 +117,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); @@ -127,45 +127,10 @@ 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 queryParams = new Dictionary - { - { "mode", "addurl" }, - { "name", nzbUrl }, - { "apikey", apiKey }, - { "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 sensitiveValues = _requestBuilder.BuildSensitiveValues(requestContext, indexerApiKey); - var queryString = string.Join("&", queryParams.Select(kvp => $"{kvp.Key}={Uri.EscapeDataString(kvp.Value)}")); - var requestUrl = $"{baseUrl}?{queryString}"; + var queryParams = SabnzbdAddRequestPlanner.BuildQueryParams(client, result, nzbUrl); + var requestUrl = _requestBuilder.BuildUrl(requestContext, queryParams); _logger.LogDebug("SABnzbd request URL: {Url}", LogRedaction.RedactText(requestUrl, sensitiveValues)); @@ -219,338 +184,12 @@ 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 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)) - { - _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 = $"{baseUrl}?mode=queue&name=delete&value={Uri.EscapeDataString(id)}&apikey={Uri.EscapeDataString(apiKey)}&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 = $"{baseUrl}?mode=history&name=delete&value={Uri.EscapeDataString(id)}&apikey={Uri.EscapeDataString(apiKey)}&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) { - var items = new List(); - if (client == null) return items; - - var configuredCategory = DownloadClientCategoryFilter.GetConfiguredCategory(client); - - 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)) - { - _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 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; - - 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) - { - 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; - } - - 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 - // 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 = $"{baseUrl}?mode=history&output=json&limit=30&apikey={Uri.EscapeDataString(apiKey)}"; - 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 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 - { - "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(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); - } - } - } - } - 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) @@ -560,15 +199,15 @@ double ParseNumericValue(JsonElement element) 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; @@ -607,19 +246,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) @@ -643,105 +281,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) - { - 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") + var downloadClientItem = SabnzbdResponseMapper.MapQueueSlotToDownloadClientItem(client, slot, configuredCategory ?? string.Empty, queueSpeed); + if (downloadClientItem != null) { - 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) @@ -761,158 +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 (!string.IsNullOrEmpty(localPath) && (File.Exists(localPath) || Directory.Exists(localPath))) - { - result.OutputPath = localPath; - return result; - } - } - - 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)) - { - _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 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 = slot.TryGetProperty("storage", out var storageProp) ? storageProp.GetString() : null; - 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; - } - } - - 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; - } + return await _importItemResolver.GetImportItemAsync(client, item, ct); } /// @@ -927,116 +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 (!string.IsNullOrEmpty(localPath) && (File.Exists(localPath) || Directory.Exists(localPath))) - { - result.ContentPath = localPath; - return result; - } - } - - 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)) - { - _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 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 = slot.TryGetProperty("storage", out var storageProp) ? storageProp.GetString() : null; - 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; - } - } - - 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; + return await _importItemResolver.GetImportItemAsync(client, queueItem, ct); } public async Task> FetchDownloadsAsync( @@ -1044,252 +341,7 @@ public async Task> FetchDownloadsAsync( List downloads, CancellationToken cancellationToken) { - _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)) - { - 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)}"; - // Redacted queue URL for safe diagnostics - _logger.LogDebug("SABnzbd poll queue URL (redacted): {Url}", LogRedaction.RedactText(queueUrl, LogRedaction.GetSensitiveValuesFromEnvironment().Concat([apiKey]))); - 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; - } - - // 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 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 = $"{baseUrl}?mode=history&limit=100&output=json&apikey={Uri.EscapeDataString(apiKey)}"; - // Redacted history URL for safe diagnostics - _logger.LogDebug("SABnzbd history URL (redacted): {Url}", LogRedaction.RedactText(historyUrl, LogRedaction.GetSensitiveValuesFromEnvironment().Concat(new[] { apiKey }))); - 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}"); - } - - // 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)); - } - } - - _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/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; + } + } +} 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}"); + } + } + } +} 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); + } + } +} 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/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/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)"); + } + } + } +} 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; + } + } + } +} 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/Adapters/SabnzbdResponseMapper.cs b/listenarr.infrastructure/Adapters/SabnzbdResponseMapper.cs new file mode 100644 index 000000000..f1cdd8fed --- /dev/null +++ b/listenarr.infrastructure/Adapters/SabnzbdResponseMapper.cs @@ -0,0 +1,320 @@ +/* + * 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.Globalization; +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; + } + + 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; + + 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/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 c1f6a38bb..e50fb5a86 100644 --- a/listenarr.infrastructure/Adapters/TransmissionAdapter.cs +++ b/listenarr.infrastructure/Adapters/TransmissionAdapter.cs @@ -15,18 +15,11 @@ * 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; -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; using Listenarr.Domain.Models; -using Listenarr.Domain.Models.Exceptions; using Listenarr.Infrastructure.Torrents; using Microsoft.Extensions.Logging; @@ -41,12 +34,22 @@ public class TransmissionAdapter : IDownloadClientAdapter private readonly IHttpClientFactory _httpClientFactory; private readonly ITorrentFileDownloader _torrentFileDownloader; private readonly ILogger _logger; + private readonly TransmissionTorrentAddPlanner _torrentAddPlanner; + private readonly TransmissionRpcClient _rpcClient; + private readonly TransmissionDownloadPollingWorkflow _downloadPollingWorkflow; + private readonly TransmissionRemovalWorkflow _removalWorkflow; + private readonly TransmissionImportItemResolver _importItemResolver; 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); + _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) @@ -60,7 +63,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 @@ -101,143 +104,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 labels = TransmissionRequestPlanner.CollectLabels(client); + var arguments = await _torrentAddPlanner.BuildArgumentsAsync(client, result, labels, ct); // Use old format for compatibility with Transmission < 4.1.0 var payload = new @@ -249,7 +117,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()); @@ -265,14 +133,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; } @@ -290,42 +158,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 = 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 InvokeRpcAsync(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) @@ -352,7 +185,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; @@ -362,13 +195,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) @@ -409,7 +242,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(); @@ -442,7 +275,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; @@ -452,7 +285,7 @@ public async Task> GetItemsAsync(DownloadClientConfigur { try { - var labels = ExtractLabels(torrent); + var labels = TransmissionResponseMapper.ExtractLabels(torrent); if (!DownloadClientCategoryFilter.MatchesAny(configuredCategory, labels)) { continue; @@ -484,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 (!string.IsNullOrEmpty(localPath) && (File.Exists(localPath) || Directory.Exists(localPath))) - { - result.OutputPath = localPath; - return result; - } - } - - // Query Transmission for the torrent details - var payload = new - { - method = "torrent-get", - arguments = new - { - ids = ParseTransmissionIds(item.DownloadId), - fields = new[] { "id", "name", "downloadDir" } - }, - tag = 5 - }; - - try - { - var response = await InvokeRpcAsync(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 = FileUtils.CombineWithOptionalBase(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); } /// @@ -571,615 +332,17 @@ 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 (!string.IsNullOrEmpty(localPath) && (File.Exists(localPath) || Directory.Exists(localPath))) - { - result.ContentPath = localPath; - resolvedExistingContentPath = localPath; - } - } - - // Query Transmission for the torrent details - var payload = new - { - method = "torrent-get", - arguments = new - { - ids = ParseTransmissionIds(queueItem.Id), - fields = new[] { "id", "name", "downloadDir", "files" } - }, - tag = 5 - }; - - try - { - var response = await InvokeRpcAsync(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 = !string.IsNullOrWhiteSpace(downloadDir) && !string.IsNullOrWhiteSpace(name) - ? FileUtils.CombineWithOptionalBase(downloadDir, name) - : resolvedExistingContentPath; - string? localContentPath = resolvedExistingContentPath; - if (!string.IsNullOrWhiteSpace(contentPath)) - { - localContentPath = contentPath; - result.ContentPath = localContentPath; - } - - if (torrent.TryGetProperty("files", out var filesElement)) - { - var sourceFiles = BuildTransmissionSourceFiles(downloadDir, filesElement); - result.SourceFiles = [.. sourceFiles.Where(path => !string.IsNullOrWhiteSpace(path))]; - } - - _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; - } - } - - 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; + return await _importItemResolver.GetImportItemAsync(client, queueItem, ct); } - 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; - } - - 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 }; - } - - /// - /// 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); + _ = ct; + return Task.FromResult(TransmissionResponseMapper.MapDownloadClientItem(client, torrent, sessionConfig)); } private async Task PreDownloadTorrentFileAsync(string torrentUrl, CancellationToken ct) @@ -1244,358 +407,13 @@ 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, 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 = TransmissionHasReachedSeedLimit( - 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); } - /// - /// 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/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}"); + } + } + } +} 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; + } + } + } +} 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))]; + } + } +} 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; + } + } + } +} 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; + } + } +} 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/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/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/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/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/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/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/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 bb9b3fa62..775cd722a 100644 --- a/listenarr.infrastructure/Cache/ImageCacheService.cs +++ b/listenarr.infrastructure/Cache/ImageCacheService.cs @@ -19,51 +19,23 @@ using AsyncKeyedLock; using Listenarr.Application.Security; using Listenarr.Application.Interfaces; -using Listenarr.Domain.Common; using Microsoft.Extensions.Logging; -using SixLabors.ImageSharp; -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 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 ImageDownloadValidator _downloadValidator; private readonly string _tempCachePath; private readonly string _libraryImagePath; private readonly string _authorImagePath; private readonly string _seriesImagePath; private readonly string _contentRootPath; + private readonly ImageCachePathResolver _pathResolver; + private readonly ImageCacheStorageLookup _storageLookup; private readonly AsyncKeyedLocker _downloadLocks = new(); public ImageCacheService( @@ -73,11 +45,20 @@ 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"); _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); @@ -95,7 +76,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; @@ -104,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)); @@ -147,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)); @@ -177,13 +158,13 @@ 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(); 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}", @@ -206,17 +187,16 @@ 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)) + 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)); return null; } // 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 extension = ImageCacheContentValidator.GetImageExtension(finalUri.ToString(), mediaType); + var filePath = _pathResolver.BuildTempFilePath(identifier, extension, _tempCachePath); // Save to temp cache await File.WriteAllBytesAsync(filePath, imageBytes); @@ -314,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; } } @@ -420,44 +370,14 @@ public ImageCacheService( if (forceRefresh && !string.IsNullOrWhiteSpace(imageUrl)) { - string? backupSeriesPath = null; - - try - { - 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 + var restored = await ImageCacheRefreshWorkflow.RefreshWithBackupAsync( + seriesPath, + tempPath, + () => DownloadAndCacheImageAsync(imageUrl, identifier), + GetRelativePath); + if (!string.IsNullOrWhiteSpace(restored)) { - if (!string.IsNullOrWhiteSpace(backupSeriesPath) && - File.Exists(backupSeriesPath) && - !File.Exists(seriesPath)) - { - File.Move(backupSeriesPath, seriesPath, overwrite: true); - } - - throw; + return restored; } } @@ -524,49 +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 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)); - 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 /// @@ -603,379 +501,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); - } - - 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 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)) - { - 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 - { - if (!File.Exists(filePath)) return false; - var bytes = File.ReadAllBytes(filePath); - var mediaType = GetMediaTypeFromExtension(Path.GetExtension(filePath)); - if (IsPlaceholderImage(bytes, mediaType)) - { - _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; - } - } - - 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; + return _pathResolver.GetRelativePath(fullPath); } public void Dispose() 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; + } + } + } +} 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; + } + } +} diff --git a/listenarr.infrastructure/Extensions/AppServiceRegistrationExtensions.cs b/listenarr.infrastructure/Extensions/AppServiceRegistrationExtensions.cs index 55975459c..130b00fd7 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,9 @@ public static IServiceCollection AddListenarrAppServices(this IServiceCollection { // Core services and application logic services.AddScoped(); + services.AddDataProtection(); + services.AddSingleton(); + services.AddSingleton(); // Startup config: read config.json (optional) and expose via IStartupConfigService services.AddSingleton(); @@ -57,12 +55,17 @@ public static IServiceCollection AddListenarrAppServices(this IServiceCollection services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -89,6 +92,13 @@ public static IServiceCollection AddListenarrAppServices(this IServiceCollection services.AddSingleton(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + 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/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/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/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>()); + } + } +} diff --git a/listenarr.infrastructure/Extensions/ServiceRegistrationExtensions.cs b/listenarr.infrastructure/Extensions/ServiceRegistrationExtensions.cs index 758ebade1..e17a4a073 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; @@ -222,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/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/Ffmpeg/FfmpegService.cs b/listenarr.infrastructure/Ffmpeg/FfmpegService.cs index ac0a8f262..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; @@ -39,6 +36,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 +59,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; @@ -88,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. @@ -178,13 +137,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; } } } @@ -231,7 +190,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 +205,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) @@ -268,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)) @@ -505,155 +410,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; - } - - 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); - } - - 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; + return FfprobePlatformDefaults.GetChecksum(); } public async Task RunFfprobeAsync(string filePath) @@ -711,87 +473,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) - { - ApplyTagMetadata(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) - { - ApplyTagMetadata(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); @@ -799,75 +481,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/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; + } + } +} 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; + } + } +} 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); + } + } +} 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; + } + } + } +} 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; + } + } +} 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/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/AudiobookFilesystemDeleteService.cs b/listenarr.infrastructure/FileSystem/AudiobookFilesystemDeleteService.cs new file mode 100644 index 000000000..df2f51750 --- /dev/null +++ b/listenarr.infrastructure/FileSystem/AudiobookFilesystemDeleteService.cs @@ -0,0 +1,566 @@ +/* + * 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.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/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/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 91% rename from listenarr.application/Audiobooks/ScanBackgroundService.cs rename to listenarr.infrastructure/HostedServices/Audiobooks/ScanBackgroundService.cs index 7ffa3e857..6d556d1d6 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 { @@ -318,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); @@ -584,104 +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.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.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/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.application/Search/AutomaticSearchService.cs b/listenarr.infrastructure/HostedServices/Search/AutomaticSearchService.cs similarity index 53% rename from listenarr.application/Search/AutomaticSearchService.cs rename to listenarr.infrastructure/HostedServices/Search/AutomaticSearchService.cs index 2e48bea4e..db6bdea66 100644 --- a/listenarr.application/Search/AutomaticSearchService.cs +++ b/listenarr.infrastructure/HostedServices/Search/AutomaticSearchService.cs @@ -18,19 +18,19 @@ 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 { 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( @@ -39,6 +39,9 @@ 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) @@ -189,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"); @@ -201,7 +204,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 @@ -299,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,8 +325,8 @@ private async Task ProcessAudiobookAsync( try { // Determine appropriate download client for this result - var isTorrent = IsTorrentResult(topResult.SearchResult); - var downloadClientId = await GetAppropriateDownloadClientAsync(topResult.SearchResult, isTorrent); + var isTorrent = _resultClassifier.IsTorrentResult(topResult.SearchResult); + var downloadClientId = await _downloadClientSelector.GetAppropriateDownloadClientAsync(topResult.SearchResult, isTorrent); if (string.IsNullOrEmpty(downloadClientId)) { @@ -346,330 +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 - } - - 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). - /// - 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; - } - } } } - 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/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/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/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/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/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/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 db0b60fcc..981fdb68a 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; @@ -73,7 +72,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(); @@ -108,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 { @@ -128,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); @@ -147,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; @@ -159,49 +158,13 @@ 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 = FormatBytes(usedBytes), - TotalFormatted = FormatBytes(totalBytes), - FreeFormatted = FormatBytes(freeBytes), - Status = "available" - }; - } - public async Task GetServiceHealthAsync() { try { 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(); @@ -209,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) { @@ -241,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(); } } @@ -301,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(); } } @@ -374,9 +227,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 +292,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(); @@ -532,7 +350,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) @@ -591,69 +409,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; - } - } } } 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" + }; + } + } +} 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..d60798859 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 @@ -221,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 @@ -250,507 +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()) - : 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 == 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(); - } - - // 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; - 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(); - } - - 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.IsNullOrEmpty(id) ? $"https://myanonamouse.net/t/{Uri.EscapeDataString(id)}" : indexer.Url, - 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; @@ -838,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); } @@ -892,119 +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/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 14bcfd832..49687bfc4 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; @@ -54,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); @@ -88,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) @@ -211,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; @@ -266,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) @@ -285,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) @@ -418,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; @@ -496,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) @@ -580,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; + } +} 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.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.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/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 eb23d226b..d9c8c9538 100644 --- a/listenarr.infrastructure/SignalR/SignalRHubBroadcaster.cs +++ b/listenarr.infrastructure/SignalR/SignalRHubBroadcaster.cs @@ -16,20 +16,23 @@ * 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 { 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)); } @@ -38,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,6 +59,35 @@ 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) + { + await BroadcastAsync(RealtimeHubTarget.Downloads, eventName, payload, cancellationToken); + } + + public async Task BroadcastAsync(RealtimeHubTarget target, string eventName, object payload, CancellationToken cancellationToken = default) + { + try + { + 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} to {HubTarget}", eventName, target); + } + } } } - 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/listenarr.infrastructure/Web/AspNetRequestContextAccessor.cs b/listenarr.infrastructure/Web/AspNetRequestContextAccessor.cs new file mode 100644 index 000000000..a5ae17297 --- /dev/null +++ b/listenarr.infrastructure/Web/AspNetRequestContextAccessor.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 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)); + + return new RequestContextSnapshot( + context.Request.Path.Value, + context.Request.Scheme, + context.Request.Host.Value, + context.Connection.RemoteIpAddress, + isAuthenticatedAdminOrApiKey); + } + } + } +} diff --git a/listenarr.infrastructure/packages.lock.json b/listenarr.infrastructure/packages.lock.json new file mode 100644 index 000000000..63b7c93d4 --- /dev/null +++ b/listenarr.infrastructure/packages.lock.json @@ -0,0 +1,358 @@ +{ + "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, )", + "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.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.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==" + }, + "SixLabors.ImageSharp": { + "type": "Direct", + "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", + "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.DependencyModel": { + "type": "Transitive", + "resolved": "10.0.8", + "contentHash": "vLyZVpxmduO2jx+76ggqnsA3m81kwMY3NkWciNTj5E+Nvqb0VihqCvQP89QsGONWp0AJwMZG+u9GzaCjDdFGNw==" + }, + "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" + } + }, + "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, )", + "Listenarr.Domain": "[1.0.0, )" + } + }, + "listenarr.domain": { + "type": "Project" + }, + "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" + } + }, + "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/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/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..606d62133 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 { @@ -176,18 +170,45 @@ private ServiceCollection BuildServices() services.AddSingleton(new Mock().Object); 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(); + 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(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); 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..0c01bdd4a 100644 --- a/tests/Features/Api/Controllers/ConfigurationControllerSettingsTests.cs +++ b/tests/Features/Api/Controllers/ConfigurationControllerSettingsTests.cs @@ -16,14 +16,8 @@ * 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 { @@ -47,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/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..6e50cff72 100644 --- a/tests/Features/Api/Controllers/LibraryController_BasePathTests.cs +++ b/tests/Features/Api/Controllers/LibraryController_BasePathTests.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 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 { @@ -32,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")] @@ -48,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); @@ -71,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); 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..e33eb3638 100644 --- a/tests/Features/Api/Controllers/LibraryController_QualityCutoffTests.cs +++ b/tests/Features/Api/Controllers/LibraryController_QualityCutoffTests.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 System.Reflection; -using Listenarr.Api.Controllers; -using Listenarr.Application.Interfaces; -using Listenarr.Domain.Models; +using Listenarr.Application.Audiobooks; using Listenarr.Tests.Builders; using Listenarr.Tests.Common; -using Microsoft.Extensions.DependencyInjection; -using Xunit; namespace Listenarr.Tests.Features.Api.Controllers { @@ -56,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/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..b50e2a4f9 100644 --- a/tests/Features/Api/Controllers/ProwlarrCompatControllerTests.cs +++ b/tests/Features/Api/Controllers/ProwlarrCompatControllerTests.cs @@ -15,19 +15,12 @@ * 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 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 { @@ -46,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() + public async Task PostIndexers_BroadcastsRealtimeUpdate_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 }); @@ -77,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 @@ -100,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 @@ -218,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()), @@ -230,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" }; @@ -273,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 }; @@ -330,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/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..3afe4d770 100644 --- a/tests/Features/Api/ForwardedHeadersTrustModelTests.cs +++ b/tests/Features/Api/ForwardedHeadersTrustModelTests.cs @@ -16,12 +16,12 @@ * 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.DependencyInjection; using Microsoft.Extensions.Options; -using Xunit; namespace Listenarr.Tests.Features.Api { @@ -51,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; 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..f94a57a4b 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 { @@ -82,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/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/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/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..300fd3f82 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 @@ -51,13 +45,22 @@ 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 clientQueuePoller = new DownloadClientQueuePoller( + resolvedMemoryCache, + clientGateway, + metrics, + NullLogger.Instance); var service = new DownloadQueueService( resolvedMemoryCache, configurationService, downloadRepository, - processingJobRepository, - clientGateway, + candidateLoader, + clientQueuePoller, metrics, NullLogger.Instance); 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/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..104f2d74b 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(); @@ -395,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 @@ -492,7 +531,7 @@ public async Task SendNotificationAsync_AttachesImageAndReferencesAttachmentInPa Mock.Of>(), mockConfigService.Object, payloadBuilder, - Mock.Of() + Mock.Of() ); // Act diff --git a/tests/Features/Api/Services/ParseLanguageTests.cs b/tests/Features/Api/Services/ParseLanguageTests.cs index 8605db2e6..45e555839 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 { @@ -35,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/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..163393d57 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; @@ -51,6 +46,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 +61,7 @@ private static SearchService CreateSearchService(HttpClient? httpClient = null) collector, enricher, scorer, + sorting, handler, Enumerable.Empty()); } @@ -403,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]; @@ -435,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]; @@ -459,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); } @@ -490,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]; @@ -518,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]; @@ -543,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]; @@ -565,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]; @@ -591,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]; @@ -620,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]; @@ -649,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]; @@ -697,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/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..d1dfa4f83 100644 --- a/tests/Features/Api/Services/SearchServiceFixesTests.cs +++ b/tests/Features/Api/Services/SearchServiceFixesTests.cs @@ -15,64 +15,19 @@ * 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; 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 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, - 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]; @@ -84,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]; diff --git a/tests/Features/Api/Services/SearchServiceScoringTests.cs b/tests/Features/Api/Services/SearchServiceScoringTests.cs index dbae53ef8..6bec4e488 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; @@ -46,6 +41,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 +56,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..845557168 100644 --- a/tests/Features/Api/Services/SearchServiceSortingTests.cs +++ b/tests/Features/Api/Services/SearchServiceSortingTests.cs @@ -15,10 +15,9 @@ * 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 Xunit; +using Microsoft.Extensions.Logging.Abstractions; namespace Listenarr.Tests.Features.Api.Services { @@ -27,7 +26,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 +38,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 +50,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 +62,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); 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); + } + } +} 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/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/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/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/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/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/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); + } + } +} 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..a2a7041fa 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; @@ -191,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/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..78ce7d063 100644 --- a/tests/Features/Infrastructure/Adapters/TransmissionAdapterTests.cs +++ b/tests/Features/Infrastructure/Adapters/TransmissionAdapterTests.cs @@ -16,14 +16,10 @@ * 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.Adapters; using Listenarr.Infrastructure.Torrents; namespace Listenarr.Tests.Features.Infrastructure.Adapters @@ -165,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() 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/GlobalUsings.cs b/tests/GlobalUsings.cs new file mode 100644 index 000000000..9338de628 --- /dev/null +++ b/tests/GlobalUsings.cs @@ -0,0 +1,18 @@ +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; +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; 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/NoopHubBroadcaster.cs b/tests/Mocks/NoopHubBroadcaster.cs index f9ea1b170..95df31ec0 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,17 @@ 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; + } + + public Task BroadcastAsync(RealtimeHubTarget target, string eventName, object payload, CancellationToken cancellationToken = default) + { + // Intentionally do nothing in tests or lightweight hosts + return Task.CompletedTask; + } } } 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 { /// diff --git a/tests/packages.lock.json b/tests/packages.lock.json new file mode 100644 index 000000000..1f87661d1 --- /dev/null +++ b/tests/packages.lock.json @@ -0,0 +1,877 @@ +{ + "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.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.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.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.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.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, )", + "Listenarr.Domain": "[1.0.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": { + "type": "Project" + }, + "listenarr.infrastructure": { + "type": "Project", + "dependencies": { + "AsyncKeyedLock": "[8.0.2, )", + "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, )", + "SixLabors.ImageSharp": "[3.1.12, )", + "TagLibSharp": "[2.3.0, )" + } + }, + "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.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, )", + "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" + } + }, + "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, )", + "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