diff --git a/.env.example b/.env.example index 10a722f..87199df 100644 --- a/.env.example +++ b/.env.example @@ -77,6 +77,9 @@ WHISPARR_API_KEY= # Lidarr LIDARR_URL= LIDARR_API_KEY= +# Readarr (retired upstream - best effort) +READARR_URL= +READARR_API_KEY= # ============================================================================= # SECURITY & AUTHENTICATION @@ -156,6 +159,11 @@ WHISPARR_LOGS_PATH= # Linux: ~/.config/Lidarr/logs # Docker: /path/to/lidarr/config/logs LIDARR_LOGS_PATH= +# Readarr examples (container path: /readarr-logs): +# Windows: C:/ProgramData/Readarr/logs +# Linux: ~/.config/Readarr/logs +# Docker: /path/to/readarr/config/logs +READARR_LOGS_PATH= # ============================================================================= # MULTIPLE INSTANCES (optional - for multiple servers of the same type) @@ -205,3 +213,6 @@ WHISPARR_LOGS_PATH_3= LIDARR_LOGS_PATH_1= LIDARR_LOGS_PATH_2= LIDARR_LOGS_PATH_3= +READARR_LOGS_PATH_1= +READARR_LOGS_PATH_2= +READARR_LOGS_PATH_3= diff --git a/.env.tpl b/.env.tpl index 3261f01..b4cc47c 100644 --- a/.env.tpl +++ b/.env.tpl @@ -88,6 +88,10 @@ WHISPARR_API_KEY={{ op://Development/Logarr-Dev/WHISPARR_API_KEY }} LIDARR_URL={{ op://Development/Logarr-Dev/LIDARR_URL }} LIDARR_API_KEY={{ op://Development/Logarr-Dev/LIDARR_API_KEY }} +# Readarr (retired upstream) - add matching items to the 1Password vault, then uncomment +# READARR_URL={{ op://Development/Logarr-Dev/READARR_URL }} +# READARR_API_KEY={{ op://Development/Logarr-Dev/READARR_API_KEY }} + # ============================================================================= # SECURITY & AUTHENTICATION # ============================================================================= @@ -111,3 +115,4 @@ RADARR_LOGS_PATH={{ op://Development/Logarr-Dev/RADARR_LOGS_PATH }} PROWLARR_LOGS_PATH={{ op://Development/Logarr-Dev/PROWLARR_LOGS_PATH }} WHISPARR_LOGS_PATH={{ op://Development/Logarr-Dev/WHISPARR_LOGS_PATH }} LIDARR_LOGS_PATH={{ op://Development/Logarr-Dev/LIDARR_LOGS_PATH }} +# READARR_LOGS_PATH={{ op://Development/Logarr-Dev/READARR_LOGS_PATH }} diff --git a/.github/agents/my-agent.agent.md b/.github/agents/my-agent.agent.md index 159bab5..884e8da 100644 --- a/.github/agents/my-agent.agent.md +++ b/.github/agents/my-agent.agent.md @@ -36,7 +36,7 @@ This is a pnpm + Turborepo TypeScript monorepo. - `apps/backend`: NestJS 11 API with Drizzle/Postgres, Redis, Socket.IO, Swagger, auth/RBAC, audit logging, retention, ingestion, settings, and proxying. - `apps/frontend`: Next.js 16 App Router UI with React 19, Tailwind 4, Radix UI, React Query, and realtime socket hooks. - `packages/core`: shared provider interfaces and normalized cross-service types. -- `packages/provider-*`: provider adapters for Plex, Jellyfin, Emby, Sonarr, Radarr, Prowlarr, Whisparr, Lidarr, plus shared `provider-arr` base logic. +- `packages/provider-*`: provider adapters for Plex, Jellyfin, Emby, Sonarr, Radarr, Prowlarr, Whisparr, Lidarr, Readarr, plus shared `provider-arr` base logic. - `e2e`: Playwright coverage for key UI flows. - `.github/workflows/docker-publish.yml`: CI gates and container publishing. diff --git a/README.md b/README.md index 6c26f02..6575104 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ [![Docker](https://img.shields.io/badge/Docker-Ready-2496ED?logo=docker&logoColor=white)](https://www.docker.com/) [![License](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) -Unified logging, intelligent issue detection, and AI-powered analysis for Plex, Jellyfin, Emby, Sonarr, Radarr, Prowlarr, Whisparr, Lidarr, and more. +Unified logging, intelligent issue detection, and AI-powered analysis for Plex, Jellyfin, Emby, Sonarr, Radarr, Prowlarr, Whisparr, Lidarr, Readarr, and more. [Quick Start](#quick-start) · [Features](#features) · [Screenshots](#screenshots) · [Documentation](#documentation) @@ -95,13 +95,15 @@ _Configure multiple AI providers for issue analysis (Anthropic, OpenAI, Google, ## Supported Servers -| Server | Status | Server | Status | -| ------------ | ------------ | ------------ | ------------ | -| **Jellyfin** | ✅ Supported | **Plex** | ✅ Supported | -| **Sonarr** | ✅ Supported | **Emby** | ✅ Supported | -| **Radarr** | ✅ Supported | **Whisparr** | ✅ Supported | -| **Prowlarr** | ✅ Supported | **Lidarr** | ✅ Supported | -| **Kodi** | 🚧 Planned | | | +| Server | Status | Server | Status | +| ------------ | -------------- | ------------ | ------------ | +| **Jellyfin** | ✅ Supported | **Plex** | ✅ Supported | +| **Sonarr** | ✅ Supported | **Emby** | ✅ Supported | +| **Radarr** | ✅ Supported | **Whisparr** | ✅ Supported | +| **Prowlarr** | ✅ Supported | **Lidarr** | ✅ Supported | +| **Readarr** | ⚠️ Supported\* | **Kodi** | 🚧 Planned | + +> \*Readarr was retired upstream (archived June 2025, last release v0.4.18). Logarr supports it on a best-effort basis; expect permanent metadata (BookInfo) health errors on most installs. > Logarr uses a provider architecture—adding support for new servers is straightforward. [Contributions welcome!](#contributing) @@ -179,13 +181,14 @@ flowchart TB PR[Prowlarr] WH[Whisparr] LI[Lidarr] + RE[Readarr] end FE <--> API FE <--> WS API <--> PG API <--> RD - API <--> PX & JF & EM & SO & RA & PR & WH & LI + API <--> PX & JF & EM & SO & RA & PR & WH & LI & RE ``` ### How It Works @@ -274,6 +277,7 @@ RADARR_LOGS_PATH=/path/to/radarr/config/logs PROWLARR_LOGS_PATH=/path/to/prowlarr/config/logs WHISPARR_LOGS_PATH=/path/to/whisparr/config/logs LIDARR_LOGS_PATH=/path/to/lidarr/config/logs +READARR_LOGS_PATH=/path/to/readarr/config/logs ``` 2. **Restart Logarr** — The docker-compose mounts these paths automatically. @@ -290,6 +294,7 @@ LIDARR_LOGS_PATH=/path/to/lidarr/config/logs | Prowlarr | `/prowlarr-logs` | | Whisparr | `/whisp-logs` | | Lidarr | `/lidarr-logs` | +| Readarr | `/readarr-logs` | #### Multiple Instances (e.g., Movies vs Shorts, 1080p vs 4K) @@ -335,6 +340,7 @@ Simply add multiple servers in the UI using the same server type but different U | Prowlarr | `/config/logs` | `~/.config/Prowlarr/logs` | `C:\ProgramData\Prowlarr\logs` | | Whisparr | `/config/logs` | `~/.config/Whisparr/logs` | `C:\ProgramData\Whisparr\logs` | | Lidarr | `/config/logs` | `~/.config/Lidarr/logs` | `C:\ProgramData\Lidarr\logs` | +| Readarr | `/config/logs` | `~/.config/Readarr/logs` | `C:\ProgramData\Readarr\logs` | --- @@ -377,6 +383,7 @@ logarr/ │ ├── provider-prowlarr/ # Prowlarr integration │ ├── provider-whisparr/ # Whisparr integration │ ├── provider-lidarr/ # Lidarr integration +│ ├── provider-readarr/ # Readarr integration (retired upstream) │ └── provider-arr/ # Base *arr provider └── docker-compose.yml ``` @@ -488,6 +495,7 @@ pnpm test:e2e # Run Playwright E2E tests | `@logarr/core` | Core HTTP utilities | | `@logarr/provider-arr` | \*arr application log parser | | `@logarr/provider-emby` | Emby log parser | +| `@logarr/provider-readarr` | Readarr provider | | `@logarr/provider-whisparr` | Whisparr provider | | `@logarr/provider-lidarr` | Lidarr provider | diff --git a/apps/backend/Dockerfile b/apps/backend/Dockerfile index 4950ae6..a9c0510 100644 --- a/apps/backend/Dockerfile +++ b/apps/backend/Dockerfile @@ -60,6 +60,8 @@ COPY --from=builder --chown=nestjs:nodejs /app/packages/provider-whisparr/packag COPY --from=builder --chown=nestjs:nodejs /app/packages/provider-whisparr/dist ./packages/provider-whisparr/dist COPY --from=builder --chown=nestjs:nodejs /app/packages/provider-lidarr/package.json ./packages/provider-lidarr/ COPY --from=builder --chown=nestjs:nodejs /app/packages/provider-lidarr/dist ./packages/provider-lidarr/dist +COPY --from=builder --chown=nestjs:nodejs /app/packages/provider-readarr/package.json ./packages/provider-readarr/ +COPY --from=builder --chown=nestjs:nodejs /app/packages/provider-readarr/dist ./packages/provider-readarr/dist # Copy backend COPY --from=builder --chown=nestjs:nodejs /app/apps/backend/package.json ./apps/backend/ diff --git a/apps/backend/Dockerfile.dev b/apps/backend/Dockerfile.dev index 66d84b3..cfabcd1 100644 --- a/apps/backend/Dockerfile.dev +++ b/apps/backend/Dockerfile.dev @@ -16,7 +16,7 @@ COPY apps/backend/ ./apps/backend/ RUN pnpm install # Build only the packages (not backend itself - that will be done in watch mode) -RUN pnpm build --filter @logarr/core --filter @logarr/provider-arr --filter @logarr/provider-jellyfin --filter @logarr/provider-sonarr --filter @logarr/provider-radarr --filter @logarr/provider-prowlarr --filter @logarr/provider-plex --filter @logarr/provider-emby --filter @logarr/provider-whisparr --filter @logarr/provider-lidarr +RUN pnpm build --filter @logarr/core --filter @logarr/provider-arr --filter @logarr/provider-jellyfin --filter @logarr/provider-sonarr --filter @logarr/provider-radarr --filter @logarr/provider-prowlarr --filter @logarr/provider-plex --filter @logarr/provider-emby --filter @logarr/provider-whisparr --filter @logarr/provider-lidarr --filter @logarr/provider-readarr EXPOSE 4002 diff --git a/apps/backend/package.json b/apps/backend/package.json index 535f58a..8c1b658 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -31,6 +31,7 @@ "@logarr/provider-plex": "workspace:*", "@logarr/provider-prowlarr": "workspace:*", "@logarr/provider-radarr": "workspace:*", + "@logarr/provider-readarr": "workspace:*", "@logarr/provider-sonarr": "workspace:*", "@logarr/provider-whisparr": "workspace:*", "@nestjs/axios": "^4.0.1", diff --git a/apps/backend/src/modules/ingestion/ingestion.service.ts b/apps/backend/src/modules/ingestion/ingestion.service.ts index 66dd43d..97b6e5a 100644 --- a/apps/backend/src/modules/ingestion/ingestion.service.ts +++ b/apps/backend/src/modules/ingestion/ingestion.service.ts @@ -6,6 +6,7 @@ import { LidarrProvider } from '@logarr/provider-lidarr'; import { PlexProvider, PlexClient } from '@logarr/provider-plex'; import { ProwlarrProvider } from '@logarr/provider-prowlarr'; import { RadarrProvider } from '@logarr/provider-radarr'; +import { ReadarrProvider } from '@logarr/provider-readarr'; import { SonarrProvider } from '@logarr/provider-sonarr'; import { WhisparrProvider } from '@logarr/provider-whisparr'; import { Injectable, Inject, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; @@ -85,6 +86,9 @@ export class IngestionService implements OnModuleInit, OnModuleDestroy { const lidarrProvider = new LidarrProvider(); this.providers.set(lidarrProvider.id, lidarrProvider); + + const readarrProvider = new ReadarrProvider(); + this.providers.set(readarrProvider.id, readarrProvider); } async onModuleInit() { diff --git a/apps/backend/src/modules/servers/server-seed.service.ts b/apps/backend/src/modules/servers/server-seed.service.ts index 1cc32ed..89f3679 100644 --- a/apps/backend/src/modules/servers/server-seed.service.ts +++ b/apps/backend/src/modules/servers/server-seed.service.ts @@ -77,6 +77,13 @@ const ENV_SERVER_CONFIGS: EnvServerConfig[] = [ name: 'Lidarr', containerLogPath: '/lidarr-logs', }, + { + urlEnvKey: 'READARR_URL', + apiKeyEnvKey: 'READARR_API_KEY', + providerId: 'readarr', + name: 'Readarr', + containerLogPath: '/readarr-logs', + }, ]; @Injectable() diff --git a/apps/backend/src/modules/servers/servers.service.ts b/apps/backend/src/modules/servers/servers.service.ts index a3616cd..9b2958e 100644 --- a/apps/backend/src/modules/servers/servers.service.ts +++ b/apps/backend/src/modules/servers/servers.service.ts @@ -4,6 +4,7 @@ import { LidarrProvider } from '@logarr/provider-lidarr'; import { PlexProvider } from '@logarr/provider-plex'; import { ProwlarrProvider } from '@logarr/provider-prowlarr'; import { RadarrProvider } from '@logarr/provider-radarr'; +import { ReadarrProvider } from '@logarr/provider-readarr'; import { SonarrProvider } from '@logarr/provider-sonarr'; import { WhisparrProvider } from '@logarr/provider-whisparr'; import { Injectable, Inject, NotFoundException, forwardRef, Logger } from '@nestjs/common'; @@ -55,6 +56,9 @@ export class ServersService { const lidarrProvider = new LidarrProvider(); this.providers.set(lidarrProvider.id, lidarrProvider); + + const readarrProvider = new ReadarrProvider(); + this.providers.set(readarrProvider.id, readarrProvider); } async findAll() { diff --git a/apps/frontend/src/app/(dashboard)/logs/page.tsx b/apps/frontend/src/app/(dashboard)/logs/page.tsx index db90f9f..e5b3f02 100644 --- a/apps/frontend/src/app/(dashboard)/logs/page.tsx +++ b/apps/frontend/src/app/(dashboard)/logs/page.tsx @@ -158,6 +158,7 @@ function LogDetailModal({ case 'sonarr': case 'prowlarr': case 'lidarr': + case 'readarr': // *arr apps: system/events shows searchable event log return `${baseUrl}/system/events`; default: diff --git a/apps/frontend/src/lib/integrations.test.ts b/apps/frontend/src/lib/integrations.test.ts index 8660d7e..9cc297e 100644 --- a/apps/frontend/src/lib/integrations.test.ts +++ b/apps/frontend/src/lib/integrations.test.ts @@ -144,6 +144,44 @@ describe('Integrations Registry', () => { }); }); + describe('Readarr integration', () => { + it('should be available and not coming soon', () => { + const readarr = getIntegrationById('readarr'); + expect(readarr).toBeDefined(); + expect(readarr?.name).toBe('Readarr'); + expect(readarr?.status).toBe('available'); + expect(readarr?.connectionType).toBe('api'); + expect(readarr?.category).toBe('arr_stack'); + expect(readarr?.defaultPort).toBe(8787); + }); + + it('should not be duplicated in the registry', () => { + expect(integrations.filter((i) => i.id === 'readarr')).toHaveLength(1); + }); + + it('should state that it is retired upstream', () => { + const readarr = getIntegrationById('readarr'); + expect(readarr?.description).toMatch(/retired/i); + expect(readarr?.description).toMatch(/best-effort/i); + }); + + it('should have standard API config fields', () => { + const fieldNames = getIntegrationById('readarr')?.configFields.map((f) => f.name); + expect(fieldNames).toEqual(['name', 'url', 'apiKey', 'logPath']); + }); + + it('should use the dashboard-icons readarr icon', () => { + const readarr = getIntegrationById('readarr'); + expect(readarr?.icon).toBe('readarr'); + expect(readarr?.iconType).toBe('dashboard-icons'); + }); + + it('should be findable by name and by the retired notice', () => { + expect(searchIntegrations('readarr').some((i) => i.id === 'readarr')).toBe(true); + expect(searchIntegrations('retired').some((i) => i.id === 'readarr')).toBe(true); + }); + }); + describe('getIntegrationsByCategory', () => { it('should include Whisparr in arr_stack category', () => { const arrIntegrations = getIntegrationsByCategory('arr_stack'); @@ -180,6 +218,11 @@ describe('Integrations Registry', () => { expect(ids).toContain('whisparr'); expect(ids).toContain('lidarr'); }); + + it('should include Readarr in available integrations', () => { + const ids = getAvailableIntegrations().map((i) => i.id); + expect(ids).toContain('readarr'); + }); }); describe('searchIntegrations', () => { diff --git a/apps/frontend/src/lib/integrations.ts b/apps/frontend/src/lib/integrations.ts index f4d69d5..50c5dd3 100644 --- a/apps/frontend/src/lib/integrations.ts +++ b/apps/frontend/src/lib/integrations.ts @@ -421,9 +421,10 @@ export const integrations: Integration[] = [ { id: 'readarr', name: 'Readarr', - description: 'Book, audiobook, and comic collection manager', + description: + 'Book, audiobook, and comic collection manager (retired upstream - best-effort support)', category: 'arr_stack', - status: 'coming_soon', + status: 'available', connectionType: 'api', icon: 'readarr', iconType: 'dashboard-icons', diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 22b1c9b..8d62b6b 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -73,6 +73,7 @@ services: - ./packages/provider-emby/src:/app/packages/provider-emby/src - ./packages/provider-whisparr/src:/app/packages/provider-whisparr/src - ./packages/provider-lidarr/src:/app/packages/provider-lidarr/src + - ./packages/provider-readarr/src:/app/packages/provider-readarr/src - ./apps/backend/src:/app/apps/backend/src # Mount config files for changes - ./apps/backend/nest-cli.json:/app/apps/backend/nest-cli.json @@ -92,6 +93,7 @@ services: - ${PROWLARR_LOGS_PATH:-./logs/prowlarr}:/prowlarr-logs:ro - ${WHISPARR_LOGS_PATH:-./logs/whisparr}:/whisparr-logs:ro - ${LIDARR_LOGS_PATH:-./logs/lidarr}:/lidarr-logs:ro + - ${READARR_LOGS_PATH:-./logs/readarr}:/readarr-logs:ro # Multiple instance mounts for development - ${PLEX_LOGS_PATH_1:-./logs/plex-1}:/plex-logs-1:ro - ${JELLYFIN_LOGS_PATH_1:-./logs/jellyfin-1}:/jellyfin-logs-1:ro @@ -101,6 +103,7 @@ services: - ${PROWLARR_LOGS_PATH_1:-./logs/prowlarr-1}:/prowlarr-logs-1:ro - ${WHISPARR_LOGS_PATH_1:-./logs/whisparr-1}:/whisparr-logs-1:ro - ${LIDARR_LOGS_PATH_1:-./logs/lidarr-1}:/lidarr-logs-1:ro + - ${READARR_LOGS_PATH_1:-./logs/readarr-1}:/readarr-logs-1:ro - ${PLEX_LOGS_PATH_2:-./logs/plex-2}:/plex-logs-2:ro - ${JELLYFIN_LOGS_PATH_2:-./logs/jellyfin-2}:/jellyfin-logs-2:ro - ${EMBY_LOGS_PATH_2:-./logs/emby-2}:/emby-logs-2:ro @@ -109,6 +112,7 @@ services: - ${PROWLARR_LOGS_PATH_2:-./logs/prowlarr-2}:/prowlarr-logs-2:ro - ${WHISPARR_LOGS_PATH_2:-./logs/whisparr-2}:/whisparr-logs-2:ro - ${LIDARR_LOGS_PATH_2:-./logs/lidarr-2}:/lidarr-logs-2:ro + - ${READARR_LOGS_PATH_2:-./logs/readarr-2}:/readarr-logs-2:ro healthcheck: test: ['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://localhost:4002/api'] interval: 30s diff --git a/docker-compose.yml b/docker-compose.yml index 220bb7d..ac945b6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -77,6 +77,8 @@ services: WHISPARR_API_KEY: ${WHISPARR_API_KEY:-} LIDARR_URL: ${LIDARR_URL:-} LIDARR_API_KEY: ${LIDARR_API_KEY:-} + READARR_URL: ${READARR_URL:-} + READARR_API_KEY: ${READARR_API_KEY:-} # Log paths - set to non-empty to enable file ingestion for that server # The actual path inside container is fixed (e.g., /jellyfin-logs) JELLYFIN_LOGS_PATH: ${JELLYFIN_LOGS_PATH:-} @@ -87,6 +89,7 @@ services: PROWLARR_LOGS_PATH: ${PROWLARR_LOGS_PATH:-} WHISPARR_LOGS_PATH: ${WHISPARR_LOGS_PATH:-} LIDARR_LOGS_PATH: ${LIDARR_LOGS_PATH:-} + READARR_LOGS_PATH: ${READARR_LOGS_PATH:-} # Multiple instance support - add numbered suffixes for additional instances # Example: RADARR_LOGS_PATH_1=/path/to/movies, RADARR_LOGS_PATH_2=/path/to/shorts JELLYFIN_LOGS_PATH_1: ${JELLYFIN_LOGS_PATH_1:-} @@ -113,6 +116,9 @@ services: LIDARR_LOGS_PATH_1: ${LIDARR_LOGS_PATH_1:-} LIDARR_LOGS_PATH_2: ${LIDARR_LOGS_PATH_2:-} LIDARR_LOGS_PATH_3: ${LIDARR_LOGS_PATH_3:-} + READARR_LOGS_PATH_1: ${READARR_LOGS_PATH_1:-} + READARR_LOGS_PATH_2: ${READARR_LOGS_PATH_2:-} + READARR_LOGS_PATH_3: ${READARR_LOGS_PATH_3:-} # Admin password reset - set to 'true' to reset admin account on startup ADMIN_PASSWORD_RESET: ${ADMIN_PASSWORD_RESET:-} volumes: @@ -126,6 +132,7 @@ services: - ${PROWLARR_LOGS_PATH:-/dev/null}:/prowlarr-logs:ro - ${WHISPARR_LOGS_PATH:-/dev/null}:/whisp-logs:ro - ${LIDARR_LOGS_PATH:-/dev/null}:/lidarr-logs:ro + - ${READARR_LOGS_PATH:-/dev/null}:/readarr-logs:ro # Multiple instance mounts - numbered instances map to /-logs- - ${JELLYFIN_LOGS_PATH_1:-/dev/null}:/jellyfin-logs-1:ro - ${JELLYFIN_LOGS_PATH_2:-/dev/null}:/jellyfin-logs-2:ro @@ -151,6 +158,9 @@ services: - ${LIDARR_LOGS_PATH_1:-/dev/null}:/lidarr-logs-1:ro - ${LIDARR_LOGS_PATH_2:-/dev/null}:/lidarr-logs-2:ro - ${LIDARR_LOGS_PATH_3:-/dev/null}:/lidarr-logs-3:ro + - ${READARR_LOGS_PATH_1:-/dev/null}:/readarr-logs-1:ro + - ${READARR_LOGS_PATH_2:-/dev/null}:/readarr-logs-2:ro + - ${READARR_LOGS_PATH_3:-/dev/null}:/readarr-logs-3:ro healthcheck: test: ['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://localhost:4000/api'] interval: 30s diff --git a/packages/provider-arr/src/arr.events.spec.ts b/packages/provider-arr/src/arr.events.spec.ts index 83aae99..e5595c6 100644 --- a/packages/provider-arr/src/arr.events.spec.ts +++ b/packages/provider-arr/src/arr.events.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { describeArrEvent, resolveArrEventName, toArrItemId } from './arr.events.js'; +import { describeArrEvent, resolveArrEventName, stableArrHash, toArrItemId } from './arr.events.js'; import type { ArrEventTable } from './arr.events.js'; @@ -100,3 +100,15 @@ describe('toArrItemId', () => { } ); }); + +describe('stableArrHash', () => { + it('is deterministic and 16 hex chars', () => { + expect(stableArrHash('a', 'b')).toBe(stableArrHash('a', 'b')); + expect(stableArrHash('a', 'b')).toMatch(/^[0-9a-f]{16}$/); + }); + + it('differs for different input and for different part boundaries', () => { + expect(stableArrHash('a', 'b')).not.toBe(stableArrHash('a', 'c')); + expect(stableArrHash('ab', 'c')).not.toBe(stableArrHash('a', 'bc')); + }); +}); diff --git a/packages/provider-arr/src/arr.events.ts b/packages/provider-arr/src/arr.events.ts index 6f65bdc..d6ce9fd 100644 --- a/packages/provider-arr/src/arr.events.ts +++ b/packages/provider-arr/src/arr.events.ts @@ -76,3 +76,19 @@ export function describeArrEvent(name: string, table: ArrEventTable): ArrEventDe return { label: humanize(name), severity: /fail/i.test(name) ? 'error' : 'info' }; } + +/** + * Deterministic short hash (two independent 32-bit FNV-1a passes, 16 hex chars). + * Used to build stable activity ids from free-form text; not cryptographic. + */ +export function stableArrHash(...parts: readonly string[]): string { + const input = parts.join('\u0000'); + let h1 = 0x811c9dc5; + let h2 = 0x01000193 ^ 0xdeadbeef; + for (let i = 0; i < input.length; i++) { + const code = input.charCodeAt(i); + h1 = Math.imul(h1 ^ code, 0x01000193) >>> 0; + h2 = Math.imul(h2 ^ code, 0x85ebca6b) >>> 0; + } + return h1.toString(16).padStart(8, '0') + h2.toString(16).padStart(8, '0'); +} diff --git a/packages/provider-arr/src/arr.provider.activity-ids.spec.ts b/packages/provider-arr/src/arr.provider.activity-ids.spec.ts new file mode 100644 index 0000000..5172dd7 --- /dev/null +++ b/packages/provider-arr/src/arr.provider.activity-ids.spec.ts @@ -0,0 +1,115 @@ +/** + * Tests for the shared ArrBaseProvider activity normalisation + */ + +import { describe, expect, it } from 'vitest'; + +import { ArrBaseProvider } from './arr.provider.js'; + +import type { ArrHealthCheck, ArrHistoryRecordBase, ArrQueueItemBase } from './arr.types.js'; +import type { NormalizedActivity } from '@logarr/core'; + +class TestArrProvider extends ArrBaseProvider { + readonly id = 'testarr'; + readonly name = 'TestArr'; + + protected getHistoryRecords(): Promise { + return Promise.resolve([]); + } + + protected normalizeHistoryRecord(): NormalizedActivity { + throw new Error('not used'); + } + + protected getMediaType(): 'book' { + return 'book'; + } + + health(checks: readonly ArrHealthCheck[]): NormalizedActivity[] { + return this.normalizeHealthChecks(checks); + } + + queue(items: readonly ArrQueueItemBase[]): NormalizedActivity[] { + return this.normalizeQueueItems(items); + } +} + +function check(overrides: Partial = {}): ArrHealthCheck { + return { source: 'MetadataCheck', type: 'error', message: 'BookInfo unreachable', ...overrides }; +} + +function queueItem(overrides: Partial = {}): ArrQueueItemBase { + return { + id: 5, + title: 'Dune', + downloadId: 'ABC123', + trackedDownloadStatus: 'warning', + trackedDownloadState: 'importPending', + statusMessages: [{ title: 't', messages: ['No files found'] }], + ...overrides, + } as ArrQueueItemBase; +} + +describe('ArrBaseProvider.normalizeHealthChecks ids', () => { + const provider = new TestArrProvider(); + + it('gives the same id for the same standing check across polls', async () => { + const first = provider.health([check()]); + await new Promise((resolve) => setTimeout(resolve, 5)); + const second = provider.health([check()]); + + expect(first[0]?.id).toBe(second[0]?.id); + expect(first[0]?.id).toMatch(/^testarr-health-[0-9a-f]{16}$/); + }); + + it('gives different ids for different message, source or severity', () => { + const base = provider.health([check()])[0]?.id; + + expect(provider.health([check({ message: 'other' })])[0]?.id).not.toBe(base); + expect(provider.health([check({ source: 'Other' })])[0]?.id).not.toBe(base); + expect(provider.health([check({ type: 'warning' })])[0]?.id).not.toBe(base); + }); + + it('still skips ok/notice checks and keeps a current timestamp', () => { + const before = Date.now(); + const result = provider.health([check({ type: 'ok' }), check({ type: 'notice' }), check()]); + + expect(result).toHaveLength(1); + expect(result[0]?.timestamp.getTime()).toBeGreaterThanOrEqual(before); + }); +}); + +describe('ArrBaseProvider.normalizeQueueItems ids', () => { + const provider = new TestArrProvider(); + + it('gives the same id for the same queue warning state across polls', async () => { + const first = provider.queue([queueItem()]); + await new Promise((resolve) => setTimeout(resolve, 5)); + const second = provider.queue([queueItem()]); + + expect(first[0]?.id).toBe(second[0]?.id); + expect(first[0]?.id).toMatch(/^testarr-queue-5-warning-importPending-[0-9a-f]{16}$/); + }); + + it('gives different ids for a different queue item, state or message', () => { + const base = provider.queue([queueItem()])[0]?.id; + + expect(provider.queue([queueItem({ id: 6 })])[0]?.id).not.toBe(base); + expect(provider.queue([queueItem({ trackedDownloadState: 'importBlocked' })])[0]?.id).not.toBe( + base + ); + expect( + provider.queue([queueItem({ statusMessages: [{ title: 't', messages: ['Other'] }] })])[0]?.id + ).not.toBe(base); + }); + + it('uses the error message and ignores items without a warning', () => { + const result = provider.queue([ + queueItem({ trackedDownloadStatus: 'ok' }), + queueItem({ id: 9, trackedDownloadStatus: 'ok', errorMessage: 'Disk full' }), + ]); + + expect(result).toHaveLength(1); + expect(result[0]?.overview).toBe('Disk full'); + }); +}); diff --git a/packages/provider-arr/src/arr.provider.ts b/packages/provider-arr/src/arr.provider.ts index 20b3961..c89583f 100644 --- a/packages/provider-arr/src/arr.provider.ts +++ b/packages/provider-arr/src/arr.provider.ts @@ -4,6 +4,7 @@ */ import { ArrClient } from './arr.client.js'; +import { stableArrHash } from './arr.events.js'; import { parseArrLogLine, parseArrLogLineWithContext, @@ -272,7 +273,7 @@ export abstract class ArrBaseProvider implements MediaServerProvider { return checks .filter((check) => check.type === 'warning' || check.type === 'error') .map((check) => ({ - id: `health-${check.source}-${Date.now()}`, + id: `${this.id}-health-${stableArrHash(check.source, check.type, check.message)}`, type: check.type === 'error' ? 'health_error' : 'health_warning', name: `Health ${check.type}: ${check.source}`, overview: check.message, @@ -287,15 +288,20 @@ export abstract class ArrBaseProvider implements MediaServerProvider { protected normalizeQueueItems(items: readonly ArrQueueItemBase[]): NormalizedActivity[] { return items .filter((item) => item.trackedDownloadStatus === 'warning' || item.errorMessage !== undefined) - .map((item) => ({ - id: `queue-${item.downloadId}-${Date.now()}`, - type: 'queue_warning' as const, - name: `Queue issue: ${item.title}`, - overview: - item.errorMessage ?? item.statusMessages.map((m) => m.messages.join(', ')).join('; '), - severity: 'warn' as const, - timestamp: new Date(), - })); + .map((item) => { + const overview = + item.errorMessage ?? item.statusMessages.map((m) => m.messages.join(', ')).join('; '); + return { + // Stable per queue entry + warning state, so a standing warning is not + // re-inserted on every poll (ingestion dedupes on the external id). + id: `${this.id}-queue-${item.id}-${item.trackedDownloadStatus}-${item.trackedDownloadState}-${stableArrHash(overview)}`, + type: 'queue_warning' as const, + name: `Queue issue: ${item.title}`, + overview, + severity: 'warn' as const, + timestamp: new Date(), + }; + }); } /** diff --git a/packages/provider-arr/src/index.ts b/packages/provider-arr/src/index.ts index 5a70b97..d72b561 100644 --- a/packages/provider-arr/src/index.ts +++ b/packages/provider-arr/src/index.ts @@ -3,7 +3,7 @@ export { ArrBaseProvider } from './arr.provider.js'; export { ArrClient } from './arr.client.js'; // Shared event name resolution and display helpers -export { resolveArrEventName, describeArrEvent, toArrItemId } from './arr.events.js'; +export { resolveArrEventName, describeArrEvent, toArrItemId, stableArrHash } from './arr.events.js'; export type { ArrEventSeverity, ArrEventDescriptor, ArrEventTable } from './arr.events.js'; // Parser utilities for file-based log ingestion diff --git a/packages/provider-readarr/package.json b/packages/provider-readarr/package.json new file mode 100644 index 0000000..3c5a432 --- /dev/null +++ b/packages/provider-readarr/package.json @@ -0,0 +1,36 @@ +{ + "name": "@logarr/provider-readarr", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Readarr provider for Logarr", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "lint": "eslint src/", + "lint:fix": "eslint src/ --fix", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "clean": "rm -rf dist .turbo" + }, + "dependencies": { + "@logarr/core": "workspace:*", + "@logarr/provider-arr": "workspace:*" + }, + "devDependencies": { + "tsup": "^8.3.5", + "typescript": "^5.7.2", + "vitest": "^4.0.16" + } +} diff --git a/packages/provider-readarr/src/index.ts b/packages/provider-readarr/src/index.ts new file mode 100644 index 0000000..577246a --- /dev/null +++ b/packages/provider-readarr/src/index.ts @@ -0,0 +1,12 @@ +// Readarr provider for Logarr (Readarr is retired upstream - best-effort support) +export { ReadarrProvider } from './readarr.provider.js'; + +// Types +export type { + ReadarrAuthor, + ReadarrBook, + ReadarrHistoryRecord, + ReadarrQueueItem, +} from './readarr.types.js'; + +export { ReadarrEventTypes, ReadarrEventTypeNames } from './readarr.types.js'; diff --git a/packages/provider-readarr/src/readarr.provider.spec.ts b/packages/provider-readarr/src/readarr.provider.spec.ts new file mode 100644 index 0000000..5c7c23a --- /dev/null +++ b/packages/provider-readarr/src/readarr.provider.spec.ts @@ -0,0 +1,857 @@ +/** + * Tests for Readarr provider + */ + +import { READARR_LOG_FILE_CONFIG } from '@logarr/provider-arr'; +import { describe, it, expect, beforeEach, afterEach, vi, type Mock } from 'vitest'; + +import { ReadarrProvider } from './readarr.provider.js'; +import { ReadarrEventTypes, ReadarrEventTypeNames } from './readarr.types.js'; + +import type { ParsedLogEntry } from '@logarr/core'; + +const BASE_URL = 'http://readarr:8787'; +const API_KEY = 'key'; + +const STATUS = { + appName: 'Readarr', + instanceName: 'Readarr', + version: '0.4.18.2805', + branch: 'develop', +}; + +interface MockResponse { + ok: boolean; + status: number; + statusText: string; + headers: Headers; + json: () => Promise; + text: () => Promise; +} + +type FetchFn = (input: string, init?: { headers: Record }) => Promise; +type FetchMock = Mock; + +/** Minimal Response stand-in for what httpRequest reads */ +function response( + body: unknown, + init: { ok?: boolean; status?: number; statusText?: string } = {} +): MockResponse { + const ok = init.ok ?? true; + return { + ok, + status: init.status ?? (ok ? 200 : 500), + statusText: init.statusText ?? (ok ? 'OK' : 'Error'), + headers: new Headers({ 'content-type': 'application/json' }), + json: () => Promise.resolve(body), + text: () => Promise.resolve(typeof body === 'string' ? body : JSON.stringify(body)), + }; +} + +interface Routes { + status?: unknown; + health?: unknown; + history?: unknown; + historySince?: unknown; + queue?: unknown; +} + +/** URL-based fetch mock (call order must not matter) */ +function routeFetch(mockFetch: FetchMock, routes: Routes = {}): void { + mockFetch.mockImplementation((input: string) => { + const url = new URL(input); + switch (url.pathname) { + case '/api/v1/system/status': + return Promise.resolve(response(routes.status ?? STATUS)); + case '/api/v1/health': + return Promise.resolve(response(routes.health ?? [])); + case '/api/v1/history/since': + return Promise.resolve(response(routes.historySince ?? [])); + case '/api/v1/history': + return Promise.resolve(response(routes.history ?? { records: [] })); + case '/api/v1/queue': + return Promise.resolve(response(routes.queue ?? { records: [] })); + default: + return Promise.resolve(response('not found', { ok: false, status: 404 })); + } + }); +} + +function historyRecord(overrides: Record = {}): Record { + return { + id: 101, + date: '2026-10-10T12:00:00Z', + eventType: 'bookFileImported', + sourceTitle: 'Frank Herbert - Dune (2019) [epub]', + quality: { quality: { id: 1, name: 'EPUB' } }, + authorId: 7, + bookId: 42, + downloadId: 'ABC123', + data: { indexer: 'MyIdx', downloadClient: 'qBit' }, + author: { id: 7, authorName: 'Frank Herbert' }, + book: { id: 42, authorId: 7, title: 'Dune' }, + ...overrides, + }; +} + +describe('ReadarrProvider', () => { + let provider: ReadarrProvider; + let mockFetch: FetchMock; + + beforeEach(() => { + provider = new ReadarrProvider(); + mockFetch = vi.fn(); + vi.stubGlobal('fetch', mockFetch); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + describe('provider identity', () => { + it('should have correct id', () => { + expect(provider.id).toBe('readarr'); + }); + + it('should have correct name', () => { + expect(provider.name).toBe('Readarr'); + }); + }); + + describe('capabilities', () => { + it('should have correct capabilities', () => { + expect(provider.capabilities).toEqual({ + supportsRealTimeLogs: false, + supportsActivityLog: true, + supportsSessions: false, + supportsWebhooks: true, + supportsPlaybackHistory: false, + }); + }); + + it('should not expose sessions or users', async () => { + await expect(provider.getSessions()).resolves.toEqual([]); + await expect(provider.getUsers()).resolves.toEqual([]); + }); + }); + + describe('getLogPaths', () => { + it('should return default log paths when no config', async () => { + const paths = await provider.getLogPaths(); + + expect(paths).toContain('/readarr-logs'); + expect(paths).toContain('/config/logs'); + expect(paths).toContain('~/.config/Readarr/logs'); + expect(paths).toContain('/var/lib/readarr/logs'); + expect(paths).toContain('C:\\ProgramData\\Readarr\\logs'); + }); + + it('should return only the configured log path after connect', async () => { + routeFetch(mockFetch); + await provider.connect({ url: BASE_URL, apiKey: API_KEY, logPath: '/x' }); + + await expect(provider.getLogPaths()).resolves.toEqual(['/x']); + }); + }); + + describe('getLogFileConfig', () => { + it('should return Readarr log file config', () => { + expect(provider.getLogFileConfig()).toBe(READARR_LOG_FILE_CONFIG); + }); + }); + + describe('log parsing', () => { + it('should parse a warning line from the error pipeline', () => { + const entry = provider.parseLogLine( + '2026-10-10 12:03:44.1|Warn|ReadarrErrorPipeline|Invalid request Validation failed: -- Title: Must not be empty' + ); + + expect(entry).not.toBeNull(); + expect(entry?.level).toBe('warn'); + expect(entry?.source).toBe('ReadarrErrorPipeline'); + expect(entry?.message.startsWith('Invalid request')).toBe(true); + }); + + it('should parse a BookInfo metadata error', () => { + const entry = provider.parseLogLine( + "2025-06-20 08:00:01.5|Error|BookInfoProxy|Search for 'x' failed. Invalid response received from BookInfo." + ); + + expect(entry?.level).toBe('error'); + expect(entry?.source).toBe('BookInfoProxy'); + expect(entry?.message).toContain('BookInfo'); + }); + + it('should parse the startup line', () => { + const entry = provider.parseLogLine( + '2026-10-10 12:00:01.2|Info|Bootstrap|Starting Readarr - /app/readarr/bin/Readarr - Version 0.4.18.2805' + ); + + expect(entry?.level).toBe('info'); + expect(entry?.source).toBe('Bootstrap'); + expect(entry?.message).toContain('Version 0.4.18.2805'); + }); + + it('should keep extra pipes in the message', () => { + const entry = provider.parseLogLine('2026-10-10 12:00:01.2|Warn|X|a|b|c'); + + expect(entry?.source).toBe('X'); + expect(entry?.message).toBe('a|b|c'); + }); + + it('should return null for a non-log line', () => { + expect(provider.parseLogLine('this is not a log line')).toBeNull(); + }); + + it('should detect continuation lines', () => { + expect( + provider.isLogContinuation( + "[v0.4.18.2805] System.IO.DirectoryNotFoundException: Could not find a part of the path '/downloads/books/Book'." + ) + ).toBe(true); + expect( + provider.isLogContinuation( + ' at System.IO.Enumeration.FileSystemEnumerator`1.CreateDirectoryHandle(String path, Boolean ignoreNotFound)' + ) + ).toBe(true); + expect(provider.isLogContinuation('2026-10-10 12:01:22.7|Info|Next|Message')).toBe(false); + }); + + it('should return the current entry for header lines and flag continuations', () => { + const context = { + continuationLines: [] as string[], + filePath: '/readarr-logs/readarr.txt', + lineNumber: 1, + }; + + const header = provider.parseLogLineWithContext( + '2026-10-10 12:01:22.7|Error|DownloadedBooksImportService|Import failed, path does not exist or is not accessible by Readarr: /downloads/books/Book', + context + ); + const blank = provider.parseLogLineWithContext('', context); + const exceptionLine = provider.parseLogLineWithContext( + "[v0.4.18.2805] System.IO.DirectoryNotFoundException: Could not find a part of the path '/downloads/books/Book'.", + context + ); + const stackLine = provider.parseLogLineWithContext( + ' at System.IO.Enumeration.FileSystemEnumerator`1.CreateDirectoryHandle(String path, Boolean ignoreNotFound)', + context + ); + + expect(header.isContinuation).toBe(false); + expect(header.entry?.level).toBe('error'); + expect(header.entry?.source).toBe('DownloadedBooksImportService'); + expect(blank).toEqual({ entry: null, isContinuation: false, previousComplete: false }); + expect(exceptionLine.isContinuation).toBe(true); + expect(exceptionLine.entry).toBeNull(); + expect(stackLine.isContinuation).toBe(true); + }); + + it('should populate exception from a version-prefixed continuation line', () => { + const entry = provider.parseLogLine( + '2026-10-10 12:01:22.7|Error|DownloadedBooksImportService|Import failed' + ); + expect(entry).not.toBeNull(); + + const finalized = provider.finalizeLogEntry(entry as ParsedLogEntry, [ + "[v0.4.18.2805] System.IO.DirectoryNotFoundException: Could not find a part of the path '/downloads/books/Book'.", + ' at System.IO.Enumeration.FileSystemEnumerator`1.CreateDirectoryHandle(String path, Boolean ignoreNotFound)', + ]); + + expect(finalized.exception).toBe('System.IO.DirectoryNotFoundException'); + expect(finalized.message).toBe( + "Import failed - Could not find a part of the path '/downloads/books/Book'." + ); + }); + + it('should leave the entry unchanged when continuation lines hold no exception', () => { + const entry = provider.parseLogLine('2026-10-10 12:01:22.7|Error|X|Something failed'); + + const finalized = provider.finalizeLogEntry(entry as ParsedLogEntry, [' at Foo.Bar()']); + + expect(finalized.exception).toBeUndefined(); + expect(finalized.message).toBe('Something failed'); + }); + }); + + describe('connection and HTTP behaviour', () => { + it('should test the connection against the v1 status endpoint with the API key', async () => { + routeFetch(mockFetch); + + await provider.connect({ url: BASE_URL, apiKey: API_KEY }); + const status = await provider.testConnection(); + + expect(status).toEqual({ + connected: true, + serverInfo: { name: 'Readarr', version: '0.4.18.2805', id: 'Readarr-develop' }, + }); + + const [firstUrl, firstInit] = mockFetch.mock.calls[0] as [ + string, + { headers: Record }, + ]; + expect(firstUrl).toBe('http://readarr:8787/api/v1/system/status'); + expect(firstInit.headers['X-Api-Key']).toBe(API_KEY); + expect(mockFetch.mock.calls.every(([url]) => !(url as string).includes('/api/v3'))).toBe( + true + ); + }); + + it('should normalise a trailing slash in the base URL', async () => { + routeFetch(mockFetch); + + await provider.connect({ url: `${BASE_URL}///`, apiKey: API_KEY }); + + expect(mockFetch.mock.calls[0]?.[0]).toBe('http://readarr:8787/api/v1/system/status'); + }); + + it('should fall back to appName when instanceName is empty', async () => { + routeFetch(mockFetch, { status: { ...STATUS, instanceName: '' } }); + + await provider.connect({ url: BASE_URL, apiKey: API_KEY }); + const info = await provider.getServerInfo(); + + expect(info.name).toBe('Readarr'); + }); + + it('should reject connect on 401 without retrying', async () => { + mockFetch.mockResolvedValue( + response('Unauthorized', { ok: false, status: 401, statusText: 'Unauthorized' }) + ); + + await expect(provider.connect({ url: BASE_URL, apiKey: 'bad' })).rejects.toThrow( + /Arr API error/ + ); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('should report not connected when the client never initialised', async () => { + const status = await provider.testConnection(); + + expect(status.connected).toBe(false); + }); + + it('should retry server errors then reject', async () => { + vi.useFakeTimers(); + mockFetch.mockResolvedValue( + response('boom', { ok: false, status: 500, statusText: 'Internal Server Error' }) + ); + + const result = expect(provider.connect({ url: BASE_URL, apiKey: API_KEY })).rejects.toThrow( + /Arr API error/ + ); + await vi.runAllTimersAsync(); + await result; + + expect(mockFetch).toHaveBeenCalledTimes(3); + }); + }); + + describe('getActivity', () => { + beforeEach(() => { + vi.spyOn(console, 'error').mockImplementation(() => undefined); + }); + + it('should request history with author and book included', async () => { + routeFetch(mockFetch); + await provider.connect({ url: BASE_URL, apiKey: API_KEY }); + + await provider.getActivity(); + + const urls = mockFetch.mock.calls.map(([url]) => url as string); + const historyUrl = urls.find((url) => new URL(url).pathname === '/api/v1/history'); + expect(historyUrl).toBeDefined(); + expect(historyUrl).toContain('includeAuthor=true'); + expect(historyUrl).toContain('includeBook=true'); + expect(historyUrl).toContain('sortKey=date'); + expect(historyUrl).toContain('pageSize=50'); + }); + + it('should use history/since when a since date is given', async () => { + routeFetch(mockFetch); + await provider.connect({ url: BASE_URL, apiKey: API_KEY }); + + await provider.getActivity(new Date('2026-10-10T00:00:00.000Z')); + + const urls = mockFetch.mock.calls.map(([url]) => url as string); + expect(urls).toContain( + 'http://readarr:8787/api/v1/history/since?date=2026-10-10T00%3A00%3A00.000Z&includeAuthor=true&includeBook=true' + ); + }); + + it('should normalise an imported book record', async () => { + routeFetch(mockFetch, { history: { records: [historyRecord()] } }); + await provider.connect({ url: BASE_URL, apiKey: API_KEY }); + + const activities = await provider.getActivity(); + + expect(activities).toContainEqual({ + id: 'readarr-history-101', + type: 'bookFileImported', + name: 'Imported: Frank Herbert - Dune', + overview: 'Book file imported: Frank Herbert - Dune (2019) [epub]', + severity: 'info', + itemId: '42', + timestamp: new Date('2026-10-10T12:00:00Z'), + metadata: { + authorId: 7, + bookId: 42, + mediaType: 'book', + quality: 'EPUB', + downloadId: 'ABC123', + indexer: 'MyIdx', + downloadClient: 'qBit', + releaseGroup: undefined, + }, + }); + }); + + it('should normalise records returned by history/since', async () => { + routeFetch(mockFetch, { historySince: [historyRecord({ id: 202 })] }); + await provider.connect({ url: BASE_URL, apiKey: API_KEY }); + + const activities = await provider.getActivity(new Date('2026-10-10T00:00:00.000Z')); + + expect(activities.map((a) => a.id)).toContain('readarr-history-202'); + }); + + it.each([ + // [eventType, expected type, name prefix, severity] + ['grabbed', 'grabbed', 'Grabbed:', 'info'], + [1, 'grabbed', 'Grabbed:', 'info'], + ['bookFileImported', 'bookFileImported', 'Imported:', 'info'], + [3, 'bookFileImported', 'Imported:', 'info'], + ['downloadImported', 'downloadImported', 'Download Imported:', 'info'], + [8, 'downloadImported', 'Download Imported:', 'info'], + ['downloadFailed', 'downloadFailed', 'Download Failed:', 'error'], + [4, 'downloadFailed', 'Download Failed:', 'error'], + ['bookImportIncomplete', 'bookImportIncomplete', 'Import Incomplete:', 'warn'], + [7, 'bookImportIncomplete', 'Import Incomplete:', 'warn'], + ['bookFileDeleted', 'bookFileDeleted', 'Deleted:', 'info'], + [5, 'bookFileDeleted', 'Deleted:', 'info'], + ['bookFileRenamed', 'bookFileRenamed', 'Renamed:', 'info'], + [6, 'bookFileRenamed', 'Renamed:', 'info'], + ['bookFileRetagged', 'bookFileRetagged', 'Retagged:', 'info'], + [9, 'bookFileRetagged', 'Retagged:', 'info'], + ['downloadIgnored', 'downloadIgnored', 'Download Ignored:', 'info'], + [10, 'downloadIgnored', 'Download Ignored:', 'info'], + ['3', 'bookFileImported', 'Imported:', 'info'], + ] as const)( + 'should map eventType %j to type %s (%s, %s)', + async (eventType, type, prefix, severity) => { + routeFetch(mockFetch, { history: { records: [historyRecord({ eventType })] } }); + await provider.connect({ url: BASE_URL, apiKey: API_KEY }); + + const activities = await provider.getActivity(); + const activity = activities.find((a) => a.id === 'readarr-history-101'); + + expect(activity?.type).toBe(type); + expect(activity?.name.startsWith(prefix)).toBe(true); + expect(activity?.severity).toBe(severity); + } + ); + + it('should use the failure message for downloadFailed when present', async () => { + routeFetch(mockFetch, { + history: { + records: [ + historyRecord({ eventType: 'downloadFailed', data: { message: 'Unable to connect' } }), + ], + }, + }); + await provider.connect({ url: BASE_URL, apiKey: API_KEY }); + + const activities = await provider.getActivity(); + const activity = activities.find((a) => a.id === 'readarr-history-101'); + + expect(activity?.severity).toBe('error'); + expect(activity?.overview).toBe('Unable to connect'); + }); + + it('should fall back to the source title for downloadFailed without a message', async () => { + routeFetch(mockFetch, { + history: { records: [historyRecord({ eventType: 'downloadFailed', data: {} })] }, + }); + await provider.connect({ url: BASE_URL, apiKey: API_KEY }); + + const activities = await provider.getActivity(); + const activity = activities.find((a) => a.id === 'readarr-history-101'); + + expect(activity?.overview).toBe('Failed to download: Frank Herbert - Dune (2019) [epub]'); + }); + + it.each([ + [2, 'unknown'], + ['2', 'unknown'], + [0, 'unknown'], + [99, 'unknown'], + ['unknown', 'unknown'], + ] as const)( + 'should map unknown/gap eventType %j to %s with info severity', + async (eventType, type) => { + routeFetch(mockFetch, { history: { records: [historyRecord({ eventType })] } }); + await provider.connect({ url: BASE_URL, apiKey: API_KEY }); + + const activities = await provider.getActivity(); + const activity = activities.find((a) => a.id === 'readarr-history-101'); + + expect(activity?.type).toBe(type); + expect(activity?.severity).toBe('info'); + expect(activity?.name).toBe('Unknown: Frank Herbert - Dune'); + } + ); + + it('should keep unrecognised string event names', async () => { + routeFetch(mockFetch, { + history: { records: [historyRecord({ eventType: 'somethingNew' })] }, + }); + await provider.connect({ url: BASE_URL, apiKey: API_KEY }); + + const activities = await provider.getActivity(); + const activity = activities.find((a) => a.id === 'readarr-history-101'); + + expect(activity?.type).toBe('somethingNew'); + expect(activity?.name).toBe('Something New: Frank Herbert - Dune'); + expect(activity?.severity).toBe('info'); + }); + + it.each([ + ['importFailed', 'error'], + ['SomethingFAILED', 'error'], + ['somethingNew', 'info'], + ] as const)( + 'should set severity for unrecognised event %s to %s', + async (eventType, severity) => { + routeFetch(mockFetch, { history: { records: [historyRecord({ eventType })] } }); + await provider.connect({ url: BASE_URL, apiKey: API_KEY }); + + const activities = await provider.getActivity(); + const activity = activities.find((a) => a.id === 'readarr-history-101'); + + expect(activity?.type).toBe(eventType); + expect(activity?.severity).toBe(severity); + } + ); + + it.each([ + [0, undefined], + [-1, undefined], + [42, '42'], + ] as const)('should set itemId only for positive bookId (%j)', async (bookId, itemId) => { + routeFetch(mockFetch, { history: { records: [historyRecord({ bookId })] } }); + await provider.connect({ url: BASE_URL, apiKey: API_KEY }); + + const activities = await provider.getActivity(); + const activity = activities.find((a) => a.id === 'readarr-history-101'); + + expect(activity?.itemId).toBe(itemId); + }); + + it('should not treat unknown events as grabs', async () => { + routeFetch(mockFetch, { history: { records: [historyRecord({ eventType: 2 })] } }); + await provider.connect({ url: BASE_URL, apiKey: API_KEY }); + + const activities = await provider.getActivity(); + const activity = activities.find((a) => a.id === 'readarr-history-101'); + + expect(activity?.name.startsWith('Grabbed')).toBe(false); + }); + + it('should tolerate records without author and book', async () => { + routeFetch(mockFetch, { + history: { + records: [historyRecord({ eventType: 'grabbed', author: undefined, book: undefined })], + }, + }); + await provider.connect({ url: BASE_URL, apiKey: API_KEY }); + + const activities = await provider.getActivity(); + const activity = activities.find((a) => a.id === 'readarr-history-101'); + + expect(activity?.name).toBe('Grabbed: Unknown Author'); + }); + + it('should normalise health warnings and errors and skip ok checks', async () => { + routeFetch(mockFetch, { + health: [ + { source: 'IndexerStatusCheck', type: 'warning', message: 'Indexers unavailable' }, + { source: 'MetadataCheck', type: 'error', message: 'BookInfo unreachable' }, + { source: 'X', type: 'ok', message: 'fine' }, + ], + }); + await provider.connect({ url: BASE_URL, apiKey: API_KEY }); + + const activities = await provider.getActivity(); + const health = activities.filter((a) => a.type.startsWith('health_')); + + expect(health.map((a) => [a.type, a.severity])).toEqual([ + ['health_warning', 'warn'], + ['health_error', 'error'], + ]); + }); + + it('should normalise queue warnings', async () => { + routeFetch(mockFetch, { + queue: { + records: [ + { + id: 1, + title: 'Dune', + downloadId: 'ABC123', + trackedDownloadStatus: 'warning', + statusMessages: [{ title: 't', messages: ['No files found'] }], + }, + ], + }, + }); + await provider.connect({ url: BASE_URL, apiKey: API_KEY }); + + const activities = await provider.getActivity(); + const queue = activities.find((a) => a.type === 'queue_warning'); + + expect(queue?.name).toBe('Queue issue: Dune'); + expect(queue?.overview).toBe('No files found'); + }); + + it('should keep standing health and queue warnings stable across polls', async () => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date('2026-10-10T12:00:00Z')); + routeFetch(mockFetch, { + health: [{ source: 'MetadataCheck', type: 'error', message: 'BookInfo unreachable' }], + queue: { + records: [ + { + id: 1, + title: 'Dune', + downloadId: 'ABC123', + trackedDownloadStatus: 'warning', + trackedDownloadState: 'importPending', + statusMessages: [{ title: 't', messages: ['No files found'] }], + }, + ], + }, + }); + await provider.connect({ url: BASE_URL, apiKey: API_KEY }); + + const first = await provider.getActivity(); + vi.setSystemTime(new Date('2026-10-10T12:00:30Z')); + const second = await provider.getActivity(); + const standingIds = (list: typeof first): string[] => + list + .filter((a) => a.type === 'health_error' || a.type === 'queue_warning') + .map((a) => a.id); + + expect(standingIds(first)).toHaveLength(2); + expect(standingIds(second)).toEqual(standingIds(first)); + expect(standingIds(first)[0]).toMatch(/^readarr-health-/); + expect(standingIds(first)[1]).toMatch(/^readarr-queue-1-warning-importPending-/); + }); + + it('should still return history when the health request fails', async () => { + mockFetch.mockImplementation((input: string) => { + const pathname = new URL(input).pathname; + if (pathname === '/api/v1/health') { + return Promise.reject(new TypeError('boom')); + } + if (pathname === '/api/v1/history') { + return Promise.resolve(response({ records: [historyRecord()] })); + } + if (pathname === '/api/v1/queue') { + return Promise.resolve(response({ records: [] })); + } + return Promise.resolve(response(STATUS)); + }); + await provider.connect({ url: BASE_URL, apiKey: API_KEY }); + + const activities = await provider.getActivity(); + + expect(activities.map((a) => a.id)).toContain('readarr-history-101'); + expect(console.error).toHaveBeenCalled(); + }); + + it('should produce stable history ids across polls', async () => { + routeFetch(mockFetch, { history: { records: [historyRecord()] } }); + await provider.connect({ url: BASE_URL, apiKey: API_KEY }); + + const first = await provider.getActivity(); + const second = await provider.getActivity(); + const ids = (list: typeof first): string[] => + list.filter((a) => a.id.startsWith('readarr-history-')).map((a) => a.id); + + expect(ids(first)).toEqual(['readarr-history-101']); + expect(ids(second)).toEqual(ids(first)); + }); + + async function activityFor(overrides: Record): Promise { + routeFetch(mockFetch, { history: { records: [historyRecord(overrides)] } }); + await provider.connect({ url: BASE_URL, apiKey: API_KEY }); + const activities = await provider.getActivity(); + return activities.find((a) => a.id === 'readarr-history-101') as object; + } + + it('should describe a grab with the release title', async () => { + const activity = (await activityFor({ eventType: 'grabbed' })) as { overview: string }; + + expect(activity.overview).toBe('Release grabbed: Frank Herbert - Dune (2019) [epub]'); + }); + + it('should use the message for bookImportIncomplete and fall back to the source title', async () => { + const withMsg = (await activityFor({ + eventType: 'bookImportIncomplete', + data: { message: 'Missing tracks' }, + })) as { overview: string }; + const without = (await activityFor({ + eventType: 'bookImportIncomplete', + data: {}, + })) as { overview: string }; + + expect(withMsg.overview).toBe('Missing tracks'); + expect(without.overview).toBe('Book import incomplete: Frank Herbert - Dune (2019) [epub]'); + }); + + it('should describe an ignored download without a message', async () => { + const activity = (await activityFor({ eventType: 'downloadIgnored', data: {} })) as { + overview: string; + }; + + expect(activity.overview).toBe('Download ignored: Frank Herbert - Dune (2019) [epub]'); + }); + + it.each([ + ['bookFileImported', 'Book file imported'], + ['bookFileDeleted', 'Book file deleted'], + ['bookFileRenamed', 'Book file renamed'], + ['bookFileRetagged', 'Book file retagged'], + ['downloadImported', 'Download imported'], + ] as const)( + 'should describe %s as " : "', + async (eventType, text) => { + const activity = (await activityFor({ eventType })) as { overview: string; name: string }; + const withoutBook = (await activityFor({ eventType, book: undefined })) as { + overview: string; + name: string; + }; + + expect(activity.overview).toBe(`${text}: Frank Herbert - Dune (2019) [epub]`); + expect(withoutBook.overview).toBe(activity.overview); + expect(withoutBook.name.endsWith(': Frank Herbert')).toBe(true); + } + ); + + it('should append the deletion reason when present', async () => { + const activity = (await activityFor({ + eventType: 'bookFileDeleted', + data: { reason: 'Manual' }, + })) as { overview: string }; + + expect(activity.overview).toBe( + 'Book file deleted: Frank Herbert - Dune (2019) [epub] (Manual)' + ); + }); + + it('should canonicalise the case of the event type', async () => { + const activity = (await activityFor({ + eventType: 'DownloadFailed', + data: {}, + })) as { type: string; overview: string; severity: string }; + + expect(activity.type).toBe('downloadFailed'); + expect(activity.severity).toBe('error'); + expect(activity.overview).toBe('Failed to download: Frank Herbert - Dune (2019) [epub]'); + }); + + it('should emit mediaType and releaseGroup in metadata', async () => { + const activity = (await activityFor({ + data: { indexer: 'MyIdx', releaseGroup: 'GRP' }, + })) as { metadata: Record }; + + expect(activity.metadata['mediaType']).toBe('book'); + expect(activity.metadata['releaseGroup']).toBe('GRP'); + }); + + it('should treat an empty-string eventType as unknown', async () => { + const activity = (await activityFor({ eventType: '' })) as { type: string }; + + expect(activity.type).toBe('unknown'); + }); + + it('should leave itemId undefined when bookId is missing', async () => { + const activity = (await activityFor({ bookId: undefined })) as { itemId?: string }; + + expect(activity.itemId).toBeUndefined(); + }); + + it('should degrade to the other sources when the history request fails', async () => { + mockFetch.mockImplementation((input: string) => { + const pathname = new URL(input).pathname; + if (pathname === '/api/v1/history') { + return Promise.resolve(response('nope', { ok: false, status: 404 })); + } + return Promise.resolve(response(pathname === '/api/v1/health' ? [] : STATUS)); + }); + await provider.connect({ url: BASE_URL, apiKey: API_KEY }); + + await expect(provider.getActivity()).resolves.toEqual([]); + expect(console.error).toHaveBeenCalled(); + }); + + it('should give distinct ids to distinct history records', async () => { + routeFetch(mockFetch, { + history: { records: [historyRecord({ id: 1 }), historyRecord({ id: 2 })] }, + }); + await provider.connect({ url: BASE_URL, apiKey: API_KEY }); + + const ids = (await provider.getActivity()) + .map((a) => a.id) + .filter((id) => id.startsWith('readarr-history-')); + + expect(ids).toEqual(['readarr-history-1', 'readarr-history-2']); + }); + }); +}); + +describe('ReadarrEventTypes', () => { + it('should have correct event type values', () => { + expect(ReadarrEventTypes.Unknown).toBe(0); + expect(ReadarrEventTypes.Grabbed).toBe(1); + expect(ReadarrEventTypes.BookFileImported).toBe(3); + expect(ReadarrEventTypes.DownloadFailed).toBe(4); + expect(ReadarrEventTypes.BookFileDeleted).toBe(5); + expect(ReadarrEventTypes.BookFileRenamed).toBe(6); + expect(ReadarrEventTypes.BookImportIncomplete).toBe(7); + expect(ReadarrEventTypes.DownloadImported).toBe(8); + expect(ReadarrEventTypes.BookFileRetagged).toBe(9); + expect(ReadarrEventTypes.DownloadIgnored).toBe(10); + }); + + it('should not define value 2 (gap in the upstream enum)', () => { + expect(Object.values(ReadarrEventTypes)).not.toContain(2); + expect(ReadarrEventTypeNames[2]).toBeUndefined(); + }); + + it('should have matching event type names', () => { + expect(ReadarrEventTypeNames[ReadarrEventTypes.Unknown]).toBe('unknown'); + expect(ReadarrEventTypeNames[ReadarrEventTypes.Grabbed]).toBe('grabbed'); + expect(ReadarrEventTypeNames[ReadarrEventTypes.BookFileImported]).toBe('bookFileImported'); + expect(ReadarrEventTypeNames[ReadarrEventTypes.DownloadFailed]).toBe('downloadFailed'); + expect(ReadarrEventTypeNames[ReadarrEventTypes.BookFileDeleted]).toBe('bookFileDeleted'); + expect(ReadarrEventTypeNames[ReadarrEventTypes.BookFileRenamed]).toBe('bookFileRenamed'); + expect(ReadarrEventTypeNames[ReadarrEventTypes.BookImportIncomplete]).toBe( + 'bookImportIncomplete' + ); + expect(ReadarrEventTypeNames[ReadarrEventTypes.DownloadImported]).toBe('downloadImported'); + expect(ReadarrEventTypeNames[ReadarrEventTypes.BookFileRetagged]).toBe('bookFileRetagged'); + expect(ReadarrEventTypeNames[ReadarrEventTypes.DownloadIgnored]).toBe('downloadIgnored'); + }); +}); + +describe('READARR_LOG_FILE_CONFIG', () => { + it('should match real Readarr log file names', () => { + expect(READARR_LOG_FILE_CONFIG.defaultPaths.docker).toContain('/config/logs'); + expect(READARR_LOG_FILE_CONFIG.filePatterns).toContain('readarr.txt'); + expect(READARR_LOG_FILE_CONFIG.filePatterns).toContain('readarr.*.txt'); + expect(READARR_LOG_FILE_CONFIG.encoding).toBe('utf-8'); + }); +}); diff --git a/packages/provider-readarr/src/readarr.provider.ts b/packages/provider-readarr/src/readarr.provider.ts new file mode 100644 index 0000000..580f6d8 --- /dev/null +++ b/packages/provider-readarr/src/readarr.provider.ts @@ -0,0 +1,216 @@ +/** + * Readarr provider implementation + * + * Readarr manages books, audiobooks and comics. It uses the *arr v1 API and the + * same NLog log format as Sonarr. + * + * NOTE: Readarr was retired upstream in June 2025 (last release v0.4.18). It is + * supported on a best-effort basis; expect permanent metadata (BookInfo) health + * errors on most installs. + */ + +import { + ArrBaseProvider, + ArrClient, + READARR_LOG_FILE_CONFIG, + describeArrEvent, + resolveArrEventName, + toArrItemId, + type ArrEventTable, + type ArrHistoryRecordBase, + type ArrPaginatedResponse, +} from '@logarr/provider-arr'; + +import { ReadarrEventTypeNames } from './readarr.types.js'; + +import type { ReadarrHistoryRecord } from './readarr.types.js'; +import type { NormalizedActivity } from '@logarr/core'; + +/** + * Display label and severity per Readarr history event name. + * Must agree with the Lidarr table for equivalent events. + */ +const READARR_EVENTS: ArrEventTable = { + grabbed: { label: 'Grabbed', severity: 'info' }, + bookFileImported: { label: 'Imported', severity: 'info' }, + downloadFailed: { label: 'Download Failed', severity: 'error' }, + bookFileDeleted: { label: 'Deleted', severity: 'info' }, + bookFileRenamed: { label: 'Renamed', severity: 'info' }, + bookImportIncomplete: { label: 'Import Incomplete', severity: 'warn' }, + downloadImported: { label: 'Download Imported', severity: 'info' }, + bookFileRetagged: { label: 'Retagged', severity: 'info' }, + downloadIgnored: { label: 'Download Ignored', severity: 'info' }, +}; + +/** + * Extended client for Readarr-specific endpoints (API v1) + */ +class ReadarrClient extends ArrClient { + constructor(baseUrl: string, apiKey: string) { + super(baseUrl, apiKey, 'v1'); + } + + /** + * Get history with author and book information + */ + async getReadarrHistory(options?: { + page?: number; + pageSize?: number; + }): Promise> { + return this.get>('/history', { + page: options?.page ?? 1, + pageSize: options?.pageSize ?? 50, + sortKey: 'date', + sortDirection: 'descending', + includeAuthor: true, + includeBook: true, + }); + } + + /** + * Get history since a specific date + */ + async getReadarrHistorySince(date: Date): Promise { + return this.get('/history/since', { + date: date.toISOString(), + includeAuthor: true, + includeBook: true, + }); + } +} + +export class ReadarrProvider extends ArrBaseProvider { + readonly id = 'readarr'; + readonly name = 'Readarr'; + + protected override createClient(url: string, apiKey: string): ReadarrClient { + return new ReadarrClient(url, apiKey); + } + + protected override getClient(): ReadarrClient { + return super.getClient() as ReadarrClient; + } + + protected getMediaType(): 'book' { + return 'book'; + } + + /** + * Override log paths with Readarr-specific defaults + */ + override getLogPaths(): Promise { + if (this.config?.logPath !== undefined) { + return Promise.resolve([this.config.logPath]); + } + + // Default Readarr log paths for different platforms + return Promise.resolve([ + '/readarr-logs', // Docker with Logarr (mount via READARR_LOGS_PATH env var) + '/config/logs', // Docker (Readarr container) + '~/.config/Readarr/logs', // Linux + '/var/lib/readarr/logs', // Linux alternative + 'C:\\ProgramData\\Readarr\\logs', // Windows + ]); + } + + /** + * Override log file config with Readarr-specific paths + */ + override getLogFileConfig(): ReturnType { + return READARR_LOG_FILE_CONFIG; + } + + protected override async getHistoryRecords( + since?: Date + ): Promise { + const client = this.getClient(); + + if (since) { + return await client.getReadarrHistorySince(since); + } + + // Get recent history (first page) + const response = await client.getReadarrHistory({ pageSize: 50 }); + return response.records; + } + + protected override normalizeHistoryRecord(record: ArrHistoryRecordBase): NormalizedActivity { + const readarrRecord = record as ReadarrHistoryRecord; + const eventTypeName = resolveArrEventName(readarrRecord.eventType, ReadarrEventTypeNames); + const display = describeArrEvent(eventTypeName, READARR_EVENTS); + + const authorName = readarrRecord.author?.authorName ?? 'Unknown Author'; + const bookTitle = readarrRecord.book?.title ?? ''; + const label = bookTitle !== '' ? `${authorName} - ${bookTitle}` : authorName; + const data = readarrRecord.data as Record | undefined; + const message = this.dataString(data, 'message'); + const sourceTitle = readarrRecord.sourceTitle; + + let description: string; + + switch (eventTypeName) { + case 'grabbed': + description = `Release grabbed: ${sourceTitle}`; + break; + case 'bookFileImported': + description = `Book file imported: ${sourceTitle}`; + break; + case 'downloadFailed': + description = message ?? `Failed to download: ${sourceTitle}`; + break; + case 'bookFileDeleted': { + const reason = this.dataString(data, 'reason'); + description = `Book file deleted: ${sourceTitle}${reason !== undefined ? ` (${reason})` : ''}`; + break; + } + case 'bookFileRenamed': + description = `Book file renamed: ${sourceTitle}`; + break; + case 'bookImportIncomplete': + description = message ?? `Book import incomplete: ${sourceTitle}`; + break; + case 'downloadImported': + description = `Download imported: ${sourceTitle}`; + break; + case 'bookFileRetagged': + description = `Book file retagged: ${sourceTitle}`; + break; + case 'downloadIgnored': + description = `Download ignored: ${sourceTitle}`; + break; + default: + description = sourceTitle; + } + + return { + id: `readarr-history-${readarrRecord.id}`, + type: eventTypeName, + name: `${display.label}: ${label}`, + overview: description, + severity: display.severity, + timestamp: new Date(readarrRecord.date), + itemId: toArrItemId(readarrRecord.bookId), + metadata: { + authorId: readarrRecord.authorId, + bookId: readarrRecord.bookId, + mediaType: this.getMediaType(), + quality: readarrRecord.quality?.quality?.name, + downloadId: readarrRecord.downloadId, + indexer: data?.['indexer'], + downloadClient: data?.['downloadClient'], + releaseGroup: data?.['releaseGroup'], + }, + }; + } + + /** + * Read a non-empty string-ish value from the history `data` bag + */ + private dataString(data: Record | undefined, key: string): string | undefined { + const value = data?.[key]; + if (value === undefined || value === null || value === '') { + return undefined; + } + return String(value); + } +} diff --git a/packages/provider-readarr/src/readarr.types.ts b/packages/provider-readarr/src/readarr.types.ts new file mode 100644 index 0000000..8dec68b --- /dev/null +++ b/packages/provider-readarr/src/readarr.types.ts @@ -0,0 +1,103 @@ +/** + * Readarr-specific API types + * + * Readarr is the *arr application for books, audiobooks and comics. It was + * retired upstream in June 2025 (last release v0.4.18); these types describe + * the v1 API as shipped in that final release. + */ + +import type { ArrHistoryRecordBase, ArrQueueItemBase } from '@logarr/provider-arr'; + +/** + * Readarr author object (the Readarr equivalent of a Sonarr series). + * The API returns partial objects when embedded via includeAuthor. + */ +export interface ReadarrAuthor { + readonly id: number; + readonly authorName: string; + readonly authorNameLastFirst?: string; + readonly sortName?: string; + readonly status?: string; + readonly overview?: string; + readonly monitored?: boolean; + readonly path?: string; + readonly foreignAuthorId?: string; + readonly titleSlug?: string; + readonly genres?: readonly string[]; + readonly tags?: readonly number[]; + readonly added?: string; +} + +/** + * Readarr book object + */ +export interface ReadarrBook { + readonly id: number; + readonly authorId: number; + readonly title: string; + readonly seriesTitle?: string; + readonly releaseDate?: string; + readonly pageCount?: number; + readonly monitored?: boolean; + readonly foreignBookId?: string; + readonly titleSlug?: string; + readonly overview?: string; +} + +/** + * Readarr history record with author/book info. + * `eventType` is a camelCase string in v1 JSON; numeric values may still arrive + * at runtime and are handled by the provider. + */ +export interface ReadarrHistoryRecord extends ArrHistoryRecordBase { + readonly authorId: number; + readonly bookId: number; + readonly author?: ReadarrAuthor; + readonly book?: ReadarrBook; +} + +/** + * Readarr queue item with author/book info + */ +export interface ReadarrQueueItem extends ArrQueueItemBase { + readonly authorId?: number; + readonly bookId?: number; + readonly author?: ReadarrAuthor; + readonly book?: ReadarrBook; +} + +/** + * Readarr history event types (numeric values of EntityHistoryEventType). + * + * Value 2 is intentionally absent: Readarr's enum has no member 2 + * (Lidarr uses it for ArtistFolderImported). + */ +export const ReadarrEventTypes = { + Unknown: 0, + Grabbed: 1, + BookFileImported: 3, + DownloadFailed: 4, + BookFileDeleted: 5, + BookFileRenamed: 6, + BookImportIncomplete: 7, + DownloadImported: 8, + BookFileRetagged: 9, + DownloadIgnored: 10, +} as const; + +/** + * Readarr event type string mapping. Lookups for 2 or any other number + * return undefined. + */ +export const ReadarrEventTypeNames: Record = { + [ReadarrEventTypes.Unknown]: 'unknown', + [ReadarrEventTypes.Grabbed]: 'grabbed', + [ReadarrEventTypes.BookFileImported]: 'bookFileImported', + [ReadarrEventTypes.DownloadFailed]: 'downloadFailed', + [ReadarrEventTypes.BookFileDeleted]: 'bookFileDeleted', + [ReadarrEventTypes.BookFileRenamed]: 'bookFileRenamed', + [ReadarrEventTypes.BookImportIncomplete]: 'bookImportIncomplete', + [ReadarrEventTypes.DownloadImported]: 'downloadImported', + [ReadarrEventTypes.BookFileRetagged]: 'bookFileRetagged', + [ReadarrEventTypes.DownloadIgnored]: 'downloadIgnored', +}; diff --git a/packages/provider-readarr/tsconfig.json b/packages/provider-readarr/tsconfig.json new file mode 100644 index 0000000..7312f9a --- /dev/null +++ b/packages/provider-readarr/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "composite": true, + "lib": ["ES2022", "DOM"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"], + "references": [{ "path": "../core" }, { "path": "../provider-arr" }] +} diff --git a/packages/provider-readarr/tsup.config.ts b/packages/provider-readarr/tsup.config.ts new file mode 100644 index 0000000..27c2517 --- /dev/null +++ b/packages/provider-readarr/tsup.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['cjs', 'esm'], + dts: { + compilerOptions: { + composite: false, + }, + }, + splitting: false, + sourcemap: true, + clean: true, + treeshake: true, + minify: false, + external: ['@logarr/core', '@logarr/provider-arr'], + outExtension({ format }) { + return { + js: format === 'cjs' ? '.cjs' : '.js', + }; + }, +}); diff --git a/packages/provider-readarr/vitest.config.ts b/packages/provider-readarr/vitest.config.ts new file mode 100644 index 0000000..a2f6052 --- /dev/null +++ b/packages/provider-readarr/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.spec.ts'], + exclude: ['dist/**', 'node_modules/**'], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9551804..1c11625 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -80,6 +80,9 @@ importers: '@logarr/provider-radarr': specifier: workspace:* version: link:../../packages/provider-radarr + '@logarr/provider-readarr': + specifier: workspace:* + version: link:../../packages/provider-readarr '@logarr/provider-sonarr': specifier: workspace:* version: link:../../packages/provider-sonarr @@ -552,6 +555,25 @@ importers: specifier: ^5.7.2 version: 5.9.3 + packages/provider-readarr: + dependencies: + '@logarr/core': + specifier: workspace:* + version: link:../core + '@logarr/provider-arr': + specifier: workspace:* + version: link:../provider-arr + devDependencies: + tsup: + specifier: ^8.3.5 + version: 8.5.1(@swc/core@1.15.8)(jiti@2.6.1)(postcss@8.5.14)(typescript@5.9.3) + typescript: + specifier: ^5.7.2 + version: 5.9.3 + vitest: + specifier: ^4.0.16 + version: 4.0.16(@types/node@22.19.3)(@vitest/ui@4.0.16)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(msw@2.12.7(@types/node@22.19.3)(typescript@5.9.3))(terser@5.44.1) + packages/provider-sonarr: dependencies: '@logarr/core': diff --git a/turbo.json b/turbo.json index 248fe4a..f468187 100644 --- a/turbo.json +++ b/turbo.json @@ -30,6 +30,8 @@ "WHISPARR_API_KEY", "LIDARR_URL", "LIDARR_API_KEY", + "READARR_URL", + "READARR_API_KEY", "JELLYFIN_LOGS_PATH", "PLEX_LOGS_PATH", "EMBY_LOGS_PATH", @@ -37,7 +39,8 @@ "RADARR_LOGS_PATH", "PROWLARR_LOGS_PATH", "WHISPARR_LOGS_PATH", - "LIDARR_LOGS_PATH" + "LIDARR_LOGS_PATH", + "READARR_LOGS_PATH" ], "tasks": { "build": {