From 090a3c5bfad421cf82246380385c5a039e31a53a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1clav=20Brzezina?= Date: Sat, 10 Oct 2026 02:51:12 +0200 Subject: [PATCH 1/4] feat(providers): add Seerr integration (Overseerr/Jellyseerr compatible) Add @logarr/provider-seerr for Seerr, the merged successor of Overseerr and Jellyseerr (same /api/v1, so all three are supported by one provider). - X-Api-Key client; connection test and version via /settings/about - Activity from media requests and issues, one row per status transition (seerr-request--status-), with since-filtering and bounded paging - File tailing of seerr-*.log / overseerr-*.log / jellyseerr-*.log with a winston line parser that handles labels, JSON metadata and stack traces - Backend registration, env seeding (SEERR_URL/SEERR_API_KEY, /seerr-logs), proxy path, Dockerfiles, compose, turbo env - Frontend: single available "Seerr" integration replaces the Overseerr and Jellyseerr coming-soon entries; provider icon and logs deep link - README updates --- .env.example | 11 + .env.tpl | 6 + README.md | 16 +- apps/backend/Dockerfile | 2 + apps/backend/Dockerfile.dev | 2 +- apps/backend/package.json | 1 + .../modules/ingestion/ingestion.service.ts | 3 + .../src/modules/proxy/proxy.service.ts | 1 + .../modules/servers/server-seed.service.ts | 7 + .../src/modules/servers/servers.service.ts | 3 + .../src/app/(dashboard)/logs/page.tsx | 3 + .../src/components/provider-icon.test.tsx | 25 + .../frontend/src/components/provider-icon.tsx | 13 + apps/frontend/src/lib/integrations.test.ts | 51 ++ apps/frontend/src/lib/integrations.ts | 62 +- docker-compose.dev.yml | 4 + docker-compose.yml | 10 + packages/provider-seerr/package.json | 36 + packages/provider-seerr/src/index.ts | 35 + .../provider-seerr/src/seerr.client.spec.ts | 138 ++++ packages/provider-seerr/src/seerr.client.ts | 101 +++ .../provider-seerr/src/seerr.parser.spec.ts | 290 ++++++++ packages/provider-seerr/src/seerr.parser.ts | 176 +++++ .../provider-seerr/src/seerr.provider.spec.ts | 681 ++++++++++++++++++ packages/provider-seerr/src/seerr.provider.ts | 371 ++++++++++ packages/provider-seerr/src/seerr.types.ts | 112 +++ packages/provider-seerr/tsconfig.json | 14 + packages/provider-seerr/tsup.config.ts | 22 + packages/provider-seerr/vitest.config.ts | 8 + pnpm-lock.yaml | 22 + turbo.json | 5 +- 31 files changed, 2191 insertions(+), 40 deletions(-) create mode 100644 packages/provider-seerr/package.json create mode 100644 packages/provider-seerr/src/index.ts create mode 100644 packages/provider-seerr/src/seerr.client.spec.ts create mode 100644 packages/provider-seerr/src/seerr.client.ts create mode 100644 packages/provider-seerr/src/seerr.parser.spec.ts create mode 100644 packages/provider-seerr/src/seerr.parser.ts create mode 100644 packages/provider-seerr/src/seerr.provider.spec.ts create mode 100644 packages/provider-seerr/src/seerr.provider.ts create mode 100644 packages/provider-seerr/src/seerr.types.ts create mode 100644 packages/provider-seerr/tsconfig.json create mode 100644 packages/provider-seerr/tsup.config.ts create mode 100644 packages/provider-seerr/vitest.config.ts diff --git a/.env.example b/.env.example index 10a722f..bacd4f2 100644 --- a/.env.example +++ b/.env.example @@ -77,6 +77,9 @@ WHISPARR_API_KEY= # Lidarr LIDARR_URL= LIDARR_API_KEY= +# Seerr (also Overseerr / Jellyseerr) - use the ADMIN API key (Settings > General) +SEERR_URL= +SEERR_API_KEY= # ============================================================================= # SECURITY & AUTHENTICATION @@ -156,6 +159,11 @@ WHISPARR_LOGS_PATH= # Linux: ~/.config/Lidarr/logs # Docker: /path/to/lidarr/config/logs LIDARR_LOGS_PATH= +# Seerr / Overseerr / Jellyseerr examples (container path: /seerr-logs): +# Host path to the /logs directory (files seerr-YYYY-MM-DD.log; +# legacy overseerr-/jellyseerr- prefixes are supported) +# Docker: /path/to/seerr/config/logs +SEERR_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= +SEERR_LOGS_PATH_1= +SEERR_LOGS_PATH_2= +SEERR_LOGS_PATH_3= diff --git a/.env.tpl b/.env.tpl index 3261f01..3dbf130 100644 --- a/.env.tpl +++ b/.env.tpl @@ -88,6 +88,11 @@ 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 }} +# Seerr (also Overseerr / Jellyseerr) - not in the vault yet; add the items, then +# replace these with op:// references +# SEERR_URL= +# SEERR_API_KEY= + # ============================================================================= # SECURITY & AUTHENTICATION # ============================================================================= @@ -111,3 +116,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 }} +# SEERR_LOGS_PATH= diff --git a/README.md b/README.md index 6c26f02..d5ec8c2 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, Seerr, and more. [Quick Start](#quick-start) · [Features](#features) · [Screenshots](#screenshots) · [Documentation](#documentation) @@ -101,7 +101,9 @@ _Configure multiple AI providers for issue analysis (Anthropic, OpenAI, Google, | **Sonarr** | ✅ Supported | **Emby** | ✅ Supported | | **Radarr** | ✅ Supported | **Whisparr** | ✅ Supported | | **Prowlarr** | ✅ Supported | **Lidarr** | ✅ Supported | -| **Kodi** | 🚧 Planned | | | +| **Seerr** | ✅ Supported | **Kodi** | 🚧 Planned | + +> **Seerr** also covers Overseerr and Jellyseerr (same API). Use the **admin** API key (Settings → General). Activity comes from media requests and user-reported issues; application logs come from file ingestion of `seerr-YYYY-MM-DD.log` (legacy `overseerr-`/`jellyseerr-` files are supported). > 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] + SE[Seerr] 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 & SE ``` ### How It Works @@ -274,6 +277,9 @@ 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 + +# Request Managers +SEERR_LOGS_PATH=/path/to/seerr/config/logs ``` 2. **Restart Logarr** — The docker-compose mounts these paths automatically. @@ -290,6 +296,7 @@ LIDARR_LOGS_PATH=/path/to/lidarr/config/logs | Prowlarr | `/prowlarr-logs` | | Whisparr | `/whisp-logs` | | Lidarr | `/lidarr-logs` | +| Seerr | `/seerr-logs` | #### Multiple Instances (e.g., Movies vs Shorts, 1080p vs 4K) @@ -335,6 +342,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` | +| Seerr | `/app/config/logs` | `~/.config/seerr/logs` | `C:\ProgramData\seerr\logs` | --- @@ -377,6 +385,7 @@ logarr/ │ ├── provider-prowlarr/ # Prowlarr integration │ ├── provider-whisparr/ # Whisparr integration │ ├── provider-lidarr/ # Lidarr integration +│ ├── provider-seerr/ # Seerr integration (Overseerr/Jellyseerr compatible) │ └── provider-arr/ # Base *arr provider └── docker-compose.yml ``` @@ -490,6 +499,7 @@ pnpm test:e2e # Run Playwright E2E tests | `@logarr/provider-emby` | Emby log parser | | `@logarr/provider-whisparr` | Whisparr provider | | `@logarr/provider-lidarr` | Lidarr provider | +| `@logarr/provider-seerr` | Seerr provider and log parser | > Coverage thresholds are enforced at 80% for statements, branches, functions, and lines. diff --git a/apps/backend/Dockerfile b/apps/backend/Dockerfile index 4950ae6..e8f4b22 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-seerr/package.json ./packages/provider-seerr/ +COPY --from=builder --chown=nestjs:nodejs /app/packages/provider-seerr/dist ./packages/provider-seerr/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..7f4570c 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-seerr EXPOSE 4002 diff --git a/apps/backend/package.json b/apps/backend/package.json index 535f58a..457ef95 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-seerr": "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..39fc058 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 { SeerrProvider } from '@logarr/provider-seerr'; import { SonarrProvider } from '@logarr/provider-sonarr'; import { WhisparrProvider } from '@logarr/provider-whisparr'; import { Injectable, Inject, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; @@ -85,6 +86,8 @@ export class IngestionService implements OnModuleInit, OnModuleDestroy { const lidarrProvider = new LidarrProvider(); this.providers.set(lidarrProvider.id, lidarrProvider); + const seerrProvider = new SeerrProvider(); + this.providers.set(seerrProvider.id, seerrProvider); } async onModuleInit() { diff --git a/apps/backend/src/modules/proxy/proxy.service.ts b/apps/backend/src/modules/proxy/proxy.service.ts index 64cf4eb..f9d7418 100644 --- a/apps/backend/src/modules/proxy/proxy.service.ts +++ b/apps/backend/src/modules/proxy/proxy.service.ts @@ -31,6 +31,7 @@ export class ProxyService { lidarr: '/api/v1', readarr: '/api/v1', whisparr: '/api/v1', + seerr: '/api/v1', qbittorrent: '/api/v2', sabnzbd: '/api', }; diff --git a/apps/backend/src/modules/servers/server-seed.service.ts b/apps/backend/src/modules/servers/server-seed.service.ts index 1cc32ed..13425e9 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: 'SEERR_URL', + apiKeyEnvKey: 'SEERR_API_KEY', + providerId: 'seerr', + name: 'Seerr', + containerLogPath: '/seerr-logs', + }, ]; @Injectable() diff --git a/apps/backend/src/modules/servers/servers.service.ts b/apps/backend/src/modules/servers/servers.service.ts index a3616cd..bcd8447 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 { SeerrProvider } from '@logarr/provider-seerr'; import { SonarrProvider } from '@logarr/provider-sonarr'; import { WhisparrProvider } from '@logarr/provider-whisparr'; import { Injectable, Inject, NotFoundException, forwardRef, Logger } from '@nestjs/common'; @@ -55,6 +56,8 @@ export class ServersService { const lidarrProvider = new LidarrProvider(); this.providers.set(lidarrProvider.id, lidarrProvider); + const seerrProvider = new SeerrProvider(); + this.providers.set(seerrProvider.id, seerrProvider); } async findAll() { diff --git a/apps/frontend/src/app/(dashboard)/logs/page.tsx b/apps/frontend/src/app/(dashboard)/logs/page.tsx index db90f9f..6dd7f05 100644 --- a/apps/frontend/src/app/(dashboard)/logs/page.tsx +++ b/apps/frontend/src/app/(dashboard)/logs/page.tsx @@ -160,6 +160,9 @@ function LogDetailModal({ case 'lidarr': // *arr apps: system/events shows searchable event log return `${baseUrl}/system/events`; + case 'seerr': + // Seerr/Overseerr/Jellyseerr: Settings > Logs + return `${baseUrl}/settings/logs`; default: return null; } diff --git a/apps/frontend/src/components/provider-icon.test.tsx b/apps/frontend/src/components/provider-icon.test.tsx index 8089195..4fd78c0 100644 --- a/apps/frontend/src/components/provider-icon.test.tsx +++ b/apps/frontend/src/components/provider-icon.test.tsx @@ -90,6 +90,31 @@ describe('providerMeta', () => { }); }); +describe('Seerr icon', () => { + it('renders the dashboard-icons image instead of the default icon', () => { + const { container } = render(); + const img = container.querySelector('img'); + expect(img).not.toBeNull(); + expect(img?.getAttribute('src')).toBe( + 'https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/seerr.svg' + ); + expect(img?.classList.contains('custom-class')).toBe(true); + expect(container.querySelector('svg')).toBeNull(); + }); +}); + +describe('Seerr metadata', () => { + it('should have Seerr entry', () => { + expect(providerMeta.seerr.name).toBe('Seerr'); + expect(providerMeta.seerr.color).toBe('#8B5CF6'); + expect(providerMeta.seerr.bgColor).toBe('bg-violet-500/10'); + }); + + it('should resolve case-insensitively via getProviderMeta', () => { + expect(getProviderMeta('SEERR').name).toBe('Seerr'); + }); +}); + describe('getProviderMeta', () => { it('should return Whisparr metadata', () => { const meta = getProviderMeta('whisparr'); diff --git a/apps/frontend/src/components/provider-icon.tsx b/apps/frontend/src/components/provider-icon.tsx index aec0f90..cae4a9f 100644 --- a/apps/frontend/src/components/provider-icon.tsx +++ b/apps/frontend/src/components/provider-icon.tsx @@ -568,6 +568,17 @@ function EmbyIcon({ className }: { className?: string }) { ); } +// Seerr logo - Uses external CDN icon +function SeerrIcon({ className }: { className?: string }) { + return ( + Seerr + ); +} + // Official Docker logo from dashboard-icons function DockerIcon({ className }: { className?: string }) { return ( @@ -618,6 +629,7 @@ const providerIcons: Record> whisparr: WhisparrIcon, lidarr: LidarrIcon, readarr: ReadarrIcon, + seerr: SeerrIcon, sqlite: SQLiteIcon, openai: OpenAIIcon, anthropic: AnthropicIcon, @@ -645,6 +657,7 @@ export const providerMeta: Record { }); }); + describe('Seerr integration', () => { + it('should be registered with correct metadata', () => { + const seerr = getIntegrationById('seerr'); + expect(seerr).toBeDefined(); + expect(seerr?.name).toBe('Seerr'); + expect(seerr?.description).toContain('Overseerr'); + expect(seerr?.description).toContain('Jellyseerr'); + expect(seerr?.category).toBe('media_requests'); + expect(seerr?.status).toBe('available'); + expect(seerr?.connectionType).toBe('api'); + expect(seerr?.defaultPort).toBe(5055); + }); + + it('should use a dashboard-icons slug', () => { + const seerr = getIntegrationById('seerr'); + expect(seerr?.iconType).toBe('dashboard-icons'); + expect(seerr?.icon).toBe('seerr'); + }); + + it('should have name, url, admin apiKey and logPath fields', () => { + const seerr = getIntegrationById('seerr'); + expect(seerr?.configFields.map((f) => f.name)).toEqual(['name', 'url', 'apiKey', 'logPath']); + expect(seerr?.configFields.find((f) => f.name === 'apiKey')?.label).toBe('Admin API Key'); + }); + + it('should have correct capabilities', () => { + expect(getIntegrationById('seerr')?.capabilities).toEqual({ + realTimeLogs: true, + activityLog: true, + sessions: false, + webhooks: false, + metrics: false, + }); + }); + + it('should be available and replace the separate Overseerr/Jellyseerr entries', () => { + expect(getAvailableIntegrations().map((i) => i.id)).toContain('seerr'); + expect(getIntegrationById('overseerr')).toBeUndefined(); + expect(getIntegrationById('jellyseerr')).toBeUndefined(); + const available = getIntegrationsByCategory('media_requests').filter( + (i) => i.status !== 'coming_soon' + ); + expect(available.map((i) => i.id)).toEqual(['seerr']); + }); + + it('should be searchable by Overseerr and Jellyseerr', () => { + expect(searchIntegrations('overseerr').map((i) => i.id)).toContain('seerr'); + expect(searchIntegrations('jellyseerr').map((i) => i.id)).toContain('seerr'); + }); + }); + describe('getIntegrationsByCategory', () => { it('should include Whisparr in arr_stack category', () => { const arrIntegrations = getIntegrationsByCategory('arr_stack'); diff --git a/apps/frontend/src/lib/integrations.ts b/apps/frontend/src/lib/integrations.ts index f4d69d5..d3907df 100644 --- a/apps/frontend/src/lib/integrations.ts +++ b/apps/frontend/src/lib/integrations.ts @@ -617,50 +617,42 @@ export const integrations: Integration[] = [ // MEDIA REQUESTS // ============================================ { - id: 'overseerr', - name: 'Overseerr', - description: 'Request management and media discovery for Plex', + id: 'seerr', + name: 'Seerr', + description: + 'Media request management for Plex, Jellyfin and Emby (also supports Overseerr and Jellyseerr)', category: 'media_requests', - status: 'coming_soon', - connectionType: 'api', - icon: 'overseerr', - iconType: 'dashboard-icons', - color: '#7B68EE', - bgColor: 'bg-purple-500/10', - website: 'https://overseerr.dev', - defaultPort: 5055, - configFields: [standardApiFields[0], standardApiFields[1], standardApiFields[2]], - capabilities: { - realTimeLogs: true, - activityLog: true, - sessions: false, - webhooks: true, - metrics: true, - }, - tags: ['requests', 'media', 'plex', 'discovery'], - }, - { - id: 'jellyseerr', - name: 'Jellyseerr', - description: 'Request management for Jellyfin/Emby', - category: 'media_requests', - status: 'coming_soon', + status: 'available', connectionType: 'api', - icon: 'jellyseerr', + icon: 'seerr', iconType: 'dashboard-icons', - color: '#805AD5', - bgColor: 'bg-purple-600/10', - website: 'https://github.com/Fallenbagel/jellyseerr', + color: '#8B5CF6', + bgColor: 'bg-violet-500/10', + website: 'https://github.com/seerr-team/seerr', + docsUrl: 'https://docs.seerr.dev', defaultPort: 5055, - configFields: [standardApiFields[0], standardApiFields[1], standardApiFields[2]], + configFields: [ + standardApiFields[0], + standardApiFields[1], + { + ...standardApiFields[2], + label: 'Admin API Key', + description: 'Admin API key from Settings > General in Seerr, Overseerr or Jellyseerr', + }, + { + ...standardLogPathField, + placeholder: '/app/config/logs', + description: 'Path to the Seerr logs directory (leave empty for default)', + }, + ], capabilities: { realTimeLogs: true, activityLog: true, sessions: false, - webhooks: true, - metrics: true, + webhooks: false, + metrics: false, }, - tags: ['requests', 'media', 'jellyfin', 'emby', 'discovery'], + tags: ['requests', 'media', 'plex', 'jellyfin', 'emby', 'overseerr', 'jellyseerr', 'discovery'], }, { id: 'ombi', diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 22b1c9b..000a540 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-seerr/src:/app/packages/provider-seerr/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 + - ${SEERR_LOGS_PATH:-./logs/seerr}:/seerr-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 + - ${SEERR_LOGS_PATH_1:-./logs/seerr-1}:/seerr-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 + - ${SEERR_LOGS_PATH_2:-./logs/seerr-2}:/seerr-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..2fc794b 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:-} + SEERR_URL: ${SEERR_URL:-} + SEERR_API_KEY: ${SEERR_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:-} + SEERR_LOGS_PATH: ${SEERR_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:-} + SEERR_LOGS_PATH_1: ${SEERR_LOGS_PATH_1:-} + SEERR_LOGS_PATH_2: ${SEERR_LOGS_PATH_2:-} + SEERR_LOGS_PATH_3: ${SEERR_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 + - ${SEERR_LOGS_PATH:-/dev/null}:/seerr-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 + - ${SEERR_LOGS_PATH_1:-/dev/null}:/seerr-logs-1:ro + - ${SEERR_LOGS_PATH_2:-/dev/null}:/seerr-logs-2:ro + - ${SEERR_LOGS_PATH_3:-/dev/null}:/seerr-logs-3:ro healthcheck: test: ['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://localhost:4000/api'] interval: 30s diff --git a/packages/provider-seerr/package.json b/packages/provider-seerr/package.json new file mode 100644 index 0000000..1bbf3e4 --- /dev/null +++ b/packages/provider-seerr/package.json @@ -0,0 +1,36 @@ +{ + "name": "@logarr/provider-seerr", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Seerr (Overseerr / Jellyseerr compatible) 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:*" + }, + "devDependencies": { + "@types/node": "^22.10.2", + "tsup": "^8.3.5", + "typescript": "^5.7.2", + "vitest": "^4.0.16" + } +} diff --git a/packages/provider-seerr/src/index.ts b/packages/provider-seerr/src/index.ts new file mode 100644 index 0000000..d085d60 --- /dev/null +++ b/packages/provider-seerr/src/index.ts @@ -0,0 +1,35 @@ +/** + * @logarr/provider-seerr + * Seerr (Overseerr / Jellyseerr compatible) provider for Logarr + */ + +// Provider +export { SeerrProvider } from './seerr.provider.js'; + +// Client +export { SeerrClient } from './seerr.client.js'; +export type { SeerrPageOptions } from './seerr.client.js'; + +// Parser functions and config +export { + parseSeerrLogLine, + parseSeerrLogLineWithContext, + isSeerrLogContinuation, + SEERR_LOG_FILE_CONFIG, + SEERR_CORRELATION_PATTERNS, +} from './seerr.parser.js'; + +// Types +export type { + SeerrPageInfo, + SeerrPaged, + SeerrAbout, + SeerrUser, + SeerrMediaInfo, + SeerrRequestSeason, + SeerrRequest, + SeerrIssueComment, + SeerrIssue, +} from './seerr.types.js'; + +export { SeerrRequestStatus, SeerrIssueType, SeerrIssueStatus } from './seerr.types.js'; diff --git a/packages/provider-seerr/src/seerr.client.spec.ts b/packages/provider-seerr/src/seerr.client.spec.ts new file mode 100644 index 0000000..ed5994d --- /dev/null +++ b/packages/provider-seerr/src/seerr.client.spec.ts @@ -0,0 +1,138 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { SeerrClient } from './seerr.client.js'; + +const mockFetch = vi.fn(); + +const ok = (json: unknown) => ({ ok: true, status: 200, json: async () => json }); +const fail = (status: number, text = 'err') => ({ + ok: false, + status, + statusText: 'x', + text: async () => text, +}); + +function calledUrl(index = 0): URL { + return new URL(String(mockFetch.mock.calls[index]?.[0])); +} + +describe('SeerrClient', () => { + beforeEach(() => { + mockFetch.mockReset(); + vi.stubGlobal('fetch', mockFetch); + }); + afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + it('calls /api/v1 with the X-Api-Key header', async () => { + mockFetch.mockResolvedValueOnce(ok({ version: '3.5.0' })); + await new SeerrClient('http://h:5055/', 'K').getAbout(); + expect(String(mockFetch.mock.calls[0]?.[0])).toBe('http://h:5055/api/v1/settings/about'); + expect(mockFetch.mock.calls[0]?.[1]?.headers).toMatchObject({ 'X-Api-Key': 'K' }); + }); + + it('keeps reverse proxy sub-paths and strips trailing slashes', async () => { + mockFetch.mockResolvedValueOnce(ok({ version: '3.5.0' })); + await new SeerrClient('http://h/seerr//', 'K').getAbout(); + expect(String(mockFetch.mock.calls[0]?.[0])).toBe('http://h/seerr/api/v1/settings/about'); + }); + + it('getRequests builds the paging query', async () => { + mockFetch.mockResolvedValueOnce(ok({ pageInfo: {}, results: [] })); + await new SeerrClient('http://h', 'K').getRequests({ take: 50, skip: 0 }); + expect(calledUrl().pathname).toBe('/api/v1/request'); + expect(calledUrl().search).toBe('?take=50&skip=0&sort=modified&sortDirection=desc&filter=all'); + }); + + it('getIssues builds the paging query', async () => { + mockFetch.mockResolvedValueOnce(ok({ results: [] })); + await new SeerrClient('http://h', 'K').getIssues({ take: 50, skip: 50 }); + expect(calledUrl().pathname).toBe('/api/v1/issue'); + expect(calledUrl().search).toBe('?take=50&skip=50&sort=modified&filter=all'); + }); + + describe('envelope variants', () => { + const item = { id: 1, status: 2, updatedAt: '2026-10-10T12:00:00.000Z' }; + + it.each([ + [ + 'paged envelope', + { pageInfo: { pages: 1, pageSize: 50, results: 1, page: 1 }, results: [item] }, + ], + ['envelope without pageInfo', { results: [item] }], + ['bare array', [item]], + ])('getRequests accepts %s', async (_name, body) => { + mockFetch.mockResolvedValueOnce(ok(body)); + await expect( + new SeerrClient('http://h', 'K').getRequests({ take: 50, skip: 0 }) + ).resolves.toEqual([item]); + }); + + it.each([ + ['paged envelope', { pageInfo: {}, results: [{ id: 5, issueType: 1 }] }], + ['bare array', [{ id: 5, issueType: 1 }]], + ])('getIssues accepts %s', async (_name, body) => { + mockFetch.mockResolvedValueOnce(ok(body)); + await expect( + new SeerrClient('http://h', 'K').getIssues({ take: 50, skip: 0 }) + ).resolves.toEqual([{ id: 5, issueType: 1 }]); + }); + + it.each([[null], [{}], [{ results: 'nope' }], ['str']])( + 'returns [] for unusable body %j', + async (body) => { + mockFetch.mockResolvedValueOnce(ok(body)); + await expect( + new SeerrClient('http://h', 'K').getRequests({ take: 50, skip: 0 }) + ).resolves.toEqual([]); + } + ); + }); + + describe('errors', () => { + it.each([401, 403])('maps %s to an admin API key hint without retrying', async (status) => { + mockFetch.mockResolvedValue(fail(status)); + await expect(new SeerrClient('http://h', 'K').getAbout()).rejects.toThrow(/ADMIN API key/); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('wraps other HTTP errors', async () => { + mockFetch.mockResolvedValue(fail(404, 'nf')); + await expect(new SeerrClient('http://h', 'K').getAbout()).rejects.toThrow(/Seerr API error/); + }); + + it('does not leak the API key in thrown error messages', async () => { + vi.useFakeTimers(); + mockFetch.mockResolvedValue(fail(500, 'boom')); + const promise = new SeerrClient('http://h', 'SECRET-KEY').getAbout().catch((e) => e); + await vi.advanceTimersByTimeAsync(10000); + const error = await promise; + expect(error).toBeInstanceOf(Error); + expect(String(error.message)).not.toContain('SECRET-KEY'); + }); + }); + + describe('list normalisation', () => { + it.each([[null], [{}], [{ results: 'nope' }], ['text'], [42]])( + 'returns an empty list for malformed body %j', + async (body) => { + mockFetch.mockResolvedValue(ok(body)); + await expect( + new SeerrClient('http://h', 'K').getIssues({ take: 1, skip: 0 }) + ).resolves.toEqual([]); + } + ); + + it('requests newest-modified requests first', async () => { + mockFetch.mockResolvedValue(ok({ results: [] })); + await new SeerrClient('http://h', 'K').getRequests({ take: 50, skip: 100 }); + const params = calledUrl().searchParams; + expect(params.get('sort')).toBe('modified'); + expect(params.get('sortDirection')).toBe('desc'); + expect(params.get('take')).toBe('50'); + expect(params.get('skip')).toBe('100'); + }); + }); +}); diff --git a/packages/provider-seerr/src/seerr.client.ts b/packages/provider-seerr/src/seerr.client.ts new file mode 100644 index 0000000..f07a28e --- /dev/null +++ b/packages/provider-seerr/src/seerr.client.ts @@ -0,0 +1,101 @@ +/** + * Seerr API client (Seerr, Overseerr and Jellyseerr share the same /api/v1) + */ + +import { HttpError, httpRequest } from '@logarr/core'; + +import type { SeerrAbout, SeerrIssue, SeerrPaged, SeerrRequest } from './seerr.types.js'; + +/** Paging options for list endpoints */ +export interface SeerrPageOptions { + readonly take: number; + readonly skip: number; +} + +/** + * Normalize a list response: Seerr returns `{ pageInfo, results }`, but a bare + * array is tolerated as well. Anything else yields an empty list. + */ +function normalizeList(body: SeerrPaged | T[] | null | undefined): T[] { + if (Array.isArray(body)) { + return body; + } + if (typeof body === 'object' && body !== null && Array.isArray(body.results)) { + return body.results; + } + return []; +} + +/** + * HTTP client for the Seerr API + */ +export class SeerrClient { + private readonly baseUrl: string; + + constructor( + baseUrl: string, + private readonly apiKey: string + ) { + this.baseUrl = `${baseUrl.replace(/\/+$/, '')}/api/v1`; + } + + private async get( + path: string, + params?: Record + ): Promise { + const search = new URLSearchParams(); + for (const [key, value] of Object.entries(params ?? {})) { + if (value !== undefined) { + search.set(key, String(value)); + } + } + const query = search.toString(); + const url = `${this.baseUrl}${path}${query === '' ? '' : `?${query}`}`; + + try { + return await httpRequest(url, { + headers: { 'X-Api-Key': this.apiKey }, + timeout: 10000, + retries: 2, + }); + } catch (error) { + if (error instanceof HttpError) { + if (error.statusCode === 401 || error.statusCode === 403) { + throw new Error( + `Seerr API error: authentication failed - use the ADMIN API key from Settings > General (${error.statusCode})` + ); + } + throw new Error(`Seerr API error: ${error.message}`); + } + throw error; + } + } + + /** GET /settings/about (admin only, validates the API key) */ + getAbout(): Promise { + return this.get('/settings/about'); + } + + /** GET /request, most recently modified first */ + async getRequests(options: SeerrPageOptions): Promise { + const body = await this.get | SeerrRequest[]>('/request', { + take: options.take, + skip: options.skip, + sort: 'modified', + sortDirection: 'desc', + filter: 'all', + }); + return normalizeList(body); + } + + /** GET /issue, most recently modified first */ + async getIssues(options: SeerrPageOptions): Promise { + const body = await this.get | SeerrIssue[]>('/issue', { + take: options.take, + skip: options.skip, + sort: 'modified', + filter: 'all', + }); + return normalizeList(body); + } +} diff --git a/packages/provider-seerr/src/seerr.parser.spec.ts b/packages/provider-seerr/src/seerr.parser.spec.ts new file mode 100644 index 0000000..8faabb0 --- /dev/null +++ b/packages/provider-seerr/src/seerr.parser.spec.ts @@ -0,0 +1,290 @@ +import { describe, it, expect } from 'vitest'; + +import { + SEERR_CORRELATION_PATTERNS, + SEERR_LOG_FILE_CONFIG, + isSeerrLogContinuation, + parseSeerrLogLine, + parseSeerrLogLineWithContext, +} from './seerr.parser.js'; + +import type { LogParseContext } from '@logarr/core'; + +const ctx = (overrides: Partial = {}): LogParseContext => ({ + continuationLines: [], + filePath: '/seerr-logs/seerr-2026-10-10.log', + lineNumber: 1, + ...overrides, +}); + +/** Mirrors file-discovery glob conversion: `*` -> `.*`, anchored, case-insensitive */ +function globToRegex(glob: string): RegExp { + return new RegExp(`^${glob.replace(/\./g, '\\.').replace(/\*/g, '.*')}$`, 'i'); +} + +function matchesAnyPattern(name: string): boolean { + return SEERR_LOG_FILE_CONFIG.filePatterns.some((p) => globToRegex(p).test(name)); +} + +describe('parseSeerrLogLine', () => { + it('parses a label-less line with trailing space', () => { + const line = '2026-10-10T12:00:01.123Z [info]: Server ready on port 5055 '; + const entry = parseSeerrLogLine(line); + expect(entry).not.toBeNull(); + expect(entry?.timestamp.toISOString()).toBe('2026-10-10T12:00:01.123Z'); + expect(entry?.level).toBe('info'); + expect(entry?.message).toBe('Server ready on port 5055'); + expect(entry?.source).toBeUndefined(); + expect(entry?.metadata).toBeUndefined(); + expect(entry?.raw).toBe(line); + }); + + it('parses label and JSON metadata', () => { + const entry = parseSeerrLogLine( + '2026-10-10T12:00:05.456Z [debug][Jobs]: Starting scheduled job {"job":"Plex Recently Added Scan"}' + ); + expect(entry?.level).toBe('debug'); + expect(entry?.source).toBe('Jobs'); + expect(entry?.message).toBe('Starting scheduled job'); + expect(entry?.metadata).toEqual({ job: 'Plex Recently Added Scan' }); + }); + + it('supports labels containing spaces and error metadata', () => { + const entry = parseSeerrLogLine( + '2026-10-10T12:05:10.789Z [error][Radarr API]: Something went wrong {"errorMessage":"connect ECONNREFUSED 192.168.1.20:7878","radarrUrl":"http://192.168.1.20:7878/api/v3"}' + ); + expect(entry?.level).toBe('error'); + expect(entry?.source).toBe('Radarr API'); + expect(entry?.message).toBe('Something went wrong'); + expect(entry?.metadata?.['errorMessage']).toContain('ECONNREFUSED'); + }); + + it('keeps braces that are not valid JSON in the message', () => { + const entry = parseSeerrLogLine( + '2026-10-10T12:06:00.000Z [warn][Settings]: Value {not json} here ' + ); + expect(entry?.level).toBe('warn'); + expect(entry?.message).toBe('Value {not json} here'); + expect(entry?.metadata).toBeUndefined(); + }); + + it('does not treat a trailing brace group in the message as metadata', () => { + const entry = parseSeerrLogLine('2026-10-10T12:06:00.000Z [warn][Settings]: Value {not json}'); + expect(entry?.message).toBe('Value {not json}'); + expect(entry?.metadata).toBeUndefined(); + }); + + it('parses nested JSON metadata', () => { + const entry = parseSeerrLogLine( + '2026-10-10T12:06:01.000Z [info][API]: Nested {"a":{"b":[1,2]},"c":"x y {z}"}' + ); + expect(entry?.message).toBe('Nested'); + expect(entry?.metadata).toEqual({ a: { b: [1, 2] }, c: 'x y {z}' }); + }); + + it('ignores JSON arrays as metadata', () => { + const entry = parseSeerrLogLine('2026-10-10T12:06:01.000Z [info][API]: List [1,2] {"a":1}'); + expect(entry?.message).toBe('List [1,2]'); + expect(entry?.metadata).toEqual({ a: 1 }); + }); + + it('treats an empty label as no source', () => { + const entry = parseSeerrLogLine('2026-10-10T12:06:02.000Z [info][]: empty label'); + expect(entry?.source).toBeUndefined(); + expect(entry?.message).toBe('empty label'); + }); + + it('accepts timestamps without milliseconds and upper-case levels', () => { + const entry = parseSeerrLogLine('2026-10-10T12:06:03Z [INFO]: no millis upper'); + expect(entry?.level).toBe('info'); + expect(entry?.message).toBe('no millis upper'); + }); + + it('strips ANSI colour codes before parsing', () => { + const entry = parseSeerrLogLine( + '\u001b[32m2026-10-10T12:00:00.000Z [info]\u001b[39m: coloured' + ); + expect(entry?.level).toBe('info'); + expect(entry?.message).toBe('coloured'); + }); + + it('keeps the original line (with ANSI codes) as raw and ignores a trailing CR', () => { + const line = '2026-10-10T12:00:00.000Z [warn][Jobs]: windows eol {"a":1}\r'; + const entry = parseSeerrLogLine(line); + expect(entry?.raw).toBe(line); + expect(entry?.message).toBe('windows eol'); + expect(entry?.metadata).toEqual({ a: 1 }); + }); + + it('parses a record with an empty message', () => { + const entry = parseSeerrLogLine('2026-10-10T12:00:00.000Z [info]: '); + expect(entry?.message).toBe(''); + expect(entry?.metadata).toBeUndefined(); + }); + + it('splits at the right brace group when the message itself contains JSON', () => { + const entry = parseSeerrLogLine( + '2026-10-10T12:00:00.000Z [error][Plex API]: got {"x":1} from upstream {"status":502}' + ); + expect(entry?.message).toBe('got {"x":1} from upstream'); + expect(entry?.metadata).toEqual({ status: 502 }); + }); + + it('parses the realistic sample lines from the upstream logger', () => { + const lines = [ + '2026-10-10T12:00:01.123Z [info]: Server ready on port 5055 ', + '2026-10-10T12:00:05.456Z [debug][Jobs]: Starting scheduled job {"job":"Plex Recently Added Scan"}', + '2026-10-10T12:05:10.789Z [error][Radarr API]: Something went wrong {"errorMessage":"connect ECONNREFUSED 192.168.1.20:7878","radarrUrl":"http://192.168.1.20:7878/api/v3"}', + ]; + const parsed = lines.map((l) => parseSeerrLogLine(l)); + expect(parsed.map((e) => e?.level)).toEqual(['info', 'debug', 'error']); + expect(parsed.map((e) => e?.source)).toEqual([undefined, 'Jobs', 'Radarr API']); + expect(parsed[2]?.metadata?.['errorMessage']).toBe('connect ECONNREFUSED 192.168.1.20:7878'); + expect(parsed[1]?.timestamp.toISOString()).toBe('2026-10-10T12:00:05.456Z'); + }); + + it('returns null for a syntactically matching but invalid timestamp', () => { + expect(parseSeerrLogLine('2026-13-45T25:61:61Z [info]: bad date')).toBeNull(); + }); + + it.each([ + [''], + [' '], + ['not a log line'], + ['2026-10-10 12:00:00 [info]: wrong ts format'], + ['2026-10-10T12:00:00.000Z [verbose]: unknown level'], + ])('returns null for %j', (line) => { + expect(parseSeerrLogLine(line)).toBeNull(); + }); +}); + +describe('isSeerrLogContinuation', () => { + it.each([ + [' at Object. (/app/dist/x.js:1:1)', true], + ['Error: boom', true], + ['}', true], + ['', false], + [' ', false], + ['2026-10-10T12:00:00.000Z [info]: x', false], + ['2026-10-10T12:00:00.000Z [verbose]: x', false], + ['2026-10-10T12:00:00Z [info]: no millis', false], + ['\u001b[31m at coloured stack frame\u001b[39m', true], + ['\tat tab indented frame', true], + ])('%j -> %s', (line, expected) => { + expect(isSeerrLogContinuation(line)).toBe(expected); + }); +}); + +describe('parseSeerrLogLineWithContext', () => { + const sample = [ + '2026-10-10T12:10:00.000Z [error][Jellyfin API]: Request failed {"status":500}', + 'Error: Request failed with status code 500', + ' at settle (/app/node_modules/axios/lib/core/settle.js:19:12)', + ' at IncomingMessage.handleStreamEnd (/app/node_modules/axios/lib/adapters/http.js:572:11)', + '2026-10-10T12:10:01.000Z [info]: Next record ', + ]; + + it('classifies a multi-line sample correctly', () => { + const results = sample.map((l) => parseSeerrLogLineWithContext(l, ctx())); + expect(results.map((r) => r.isContinuation)).toEqual([false, true, true, true, false]); + expect(results[0]?.entry?.source).toBe('Jellyfin API'); + expect(results[4]?.entry?.message).toBe('Next record'); + }); + + it('returns continuation results without an entry', () => { + expect(parseSeerrLogLineWithContext(' at x', ctx())).toEqual({ + entry: null, + isContinuation: true, + previousComplete: false, + }); + }); + + it('marks previous entry complete only when one exists', () => { + const line = '2026-10-10T12:10:01.000Z [info]: Next'; + const previousEntry = parseSeerrLogLine(sample[0] as string) ?? undefined; + const withPrev = parseSeerrLogLineWithContext(line, ctx({ previousEntry })); + expect(withPrev.entry?.message).toBe('Next'); + expect(withPrev.previousComplete).toBe(true); + expect(parseSeerrLogLineWithContext(line, ctx()).previousComplete).toBe(false); + }); + + it('returns a null entry for unparseable ISO-start lines', () => { + expect(parseSeerrLogLineWithContext('2026-10-10T12:00:00.000Z [verbose]: x', ctx())).toEqual({ + entry: null, + isContinuation: false, + previousComplete: false, + }); + }); +}); + +describe('SEERR_LOG_FILE_CONFIG', () => { + it('has docker default paths', () => { + expect(SEERR_LOG_FILE_CONFIG.defaultPaths.docker).toContain('/seerr-logs'); + expect(SEERR_LOG_FILE_CONFIG.defaultPaths.docker).toContain('/app/config/logs'); + }); + + it('declares the three dated patterns, utf-8 and daily rotation', () => { + expect(SEERR_LOG_FILE_CONFIG.filePatterns).toEqual([ + 'seerr-*.log', + 'overseerr-*.log', + 'jellyseerr-*.log', + ]); + expect(SEERR_LOG_FILE_CONFIG.encoding).toBe('utf-8'); + expect(SEERR_LOG_FILE_CONFIG.rotatesDaily).toBe(true); + }); + + it('datePattern matches dated files only', () => { + const re = SEERR_LOG_FILE_CONFIG.datePattern as RegExp; + expect(re.test('seerr-2026-10-10.log')).toBe(true); + expect(re.test('overseerr-2026-10-10.log')).toBe(true); + expect(re.test('jellyseerr-2026-10-10.log')).toBe(true); + expect(re.test('seerr-2026-10-10.log.gz')).toBe(false); + expect(re.test('seerr.log')).toBe(false); + }); + + it.each(['seerr-2026-10-10.log', 'overseerr-2026-10-09.log', 'jellyseerr-2026-10-08.log'])( + 'file discovery globs match %s', + (name) => { + expect(matchesAnyPattern(name)).toBe(true); + } + ); + + it.each(['seerr.log', '.machinelogs-2026-10-10.json', 'seerr-2026-10-10.log.gz'])( + 'file discovery globs do not match %s', + (name) => { + expect(matchesAnyPattern(name)).toBe(false); + } + ); +}); + +describe('SEERR_CORRELATION_PATTERNS', () => { + it('names requestId, tmdbId and userId', () => { + expect(SEERR_CORRELATION_PATTERNS.map((p) => p.name)).toEqual([ + 'requestId', + 'tmdbId', + 'userId', + ]); + }); + + it('extracts ids', () => { + const find = (name: string, text: string) => + text.match(SEERR_CORRELATION_PATTERNS.find((p) => p.name === name)?.pattern as RegExp)?.[1]; + expect(find('requestId', 'Request #12 approved')).toBe('12'); + expect(find('tmdbId', 'lookup tmdbId=603 failed')).toBe('603'); + expect(find('userId', 'userId: 7')).toBe('7'); + }); + + it('extracts ids from JSON-style metadata', () => { + const find = (name: string, text: string) => + text.match(SEERR_CORRELATION_PATTERNS.find((p) => p.name === name)?.pattern as RegExp)?.[1]; + expect(find('tmdbId', '{"tmdbId":1396,"mediaType":"tv"}')).toBe('1396'); + expect(find('userId', '{"userId":42}')).toBe('42'); + expect(find('requestId', 'request=15 failed')).toBe('15'); + }); + + it('does not match unrelated text', () => { + for (const { pattern } of SEERR_CORRELATION_PATTERNS) { + expect('Server ready on port 5055'.match(pattern)).toBeNull(); + } + }); +}); diff --git a/packages/provider-seerr/src/seerr.parser.ts b/packages/provider-seerr/src/seerr.parser.ts new file mode 100644 index 0000000..8fef021 --- /dev/null +++ b/packages/provider-seerr/src/seerr.parser.ts @@ -0,0 +1,176 @@ +/** + * Seerr (and Overseerr / Jellyseerr) log file parser + * + * The apps write winston text logs to `/logs/seerr-YYYY-MM-DD.log`: + * `${timestamp} [${level}]${label ? `[${label}]` : ''}: ${message} ${JSON(metadata)}` + * with an ISO 8601 UTC timestamp and a trailing space even when there is no metadata. + */ + +import type { + CorrelationPattern, + LogFileConfig, + LogLevel, + LogParseContext, + LogParseResult, + ParsedLogEntry, +} from '@logarr/core'; + +// ============================================================================= +// Log File Configuration +// ============================================================================= + +/** + * Configuration for Seerr log file ingestion. + * + * Only dated files are matched: `seerr.log` is a symlink to the current dated + * file (would be ingested twice), `.machinelogs*.json` is a different format and + * rotated `.gz` archives are not tailable. + */ +export const SEERR_LOG_FILE_CONFIG: LogFileConfig = { + defaultPaths: { + docker: ['/seerr-logs', '/app/config/logs'], + linux: ['/opt/seerr/config/logs', '~/.config/seerr/logs', '/var/lib/seerr/config/logs'], + windows: ['%APPDATA%\\seerr\\logs', 'C:\\ProgramData\\seerr\\logs'], + macos: ['~/Library/Application Support/seerr/logs'], + }, + filePatterns: ['seerr-*.log', 'overseerr-*.log', 'jellyseerr-*.log'], + encoding: 'utf-8', + rotatesDaily: true, + datePattern: /(?:over|jelly)?seerr-(\d{4}-\d{2}-\d{2})\.log$/, +}; + +// ============================================================================= +// Patterns +// ============================================================================= + +/** + * Main log line pattern + * + * Capture groups: 1 timestamp, 2 level, 3 label (optional), 4 message + metadata + */ +const SEERR_LOG_PATTERN = + /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z) \[(debug|info|warn|error)\](?:\[([^\]]*)\])?: ?([\s\S]*)$/i; + +/** ANSI colour escape sequences (console style output) */ +// eslint-disable-next-line no-control-regex +const ANSI_PATTERN = new RegExp('\\u001b\\[[0-9;]*m', 'g'); + +/** Marks the start of a new record */ +const RECORD_START_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/; + +/** + * Patterns to extract correlation IDs from Seerr log messages + */ +export const SEERR_CORRELATION_PATTERNS: readonly CorrelationPattern[] = [ + { name: 'requestId', pattern: /\brequest(?:\s+#|[=:\s]+)(\d+)/i }, + { name: 'tmdbId', pattern: /\btmdb(?:Id)?["=:\s]+(\d+)/i }, + { name: 'userId', pattern: /\buser(?:Id)?["=:\s]+(\d+)/i }, +]; + +// ============================================================================= +// Helpers +// ============================================================================= + +function stripAnsi(text: string): string { + return text.replace(ANSI_PATTERN, ''); +} + +function parseJsonObject(text: string): Record | null { + try { + const parsed: unknown = JSON.parse(text); + if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) { + return parsed as Record; + } + } catch { + // not JSON + } + return null; +} + +/** + * Split `message {json}` into message and metadata. Candidate split points are + * every ` {` from the left, so braces inside the message are tolerated. + */ +function splitMetadata(rest: string): { + message: string; + metadata: Record | undefined; +} { + if (rest.endsWith('}')) { + for (let i = rest.indexOf(' {'); i !== -1; i = rest.indexOf(' {', i + 1)) { + const metadata = parseJsonObject(rest.slice(i + 1)); + if (metadata !== null) { + return { message: rest.slice(0, i).trimEnd(), metadata }; + } + } + } + return { message: rest, metadata: undefined }; +} + +// ============================================================================= +// Main Parsing Functions +// ============================================================================= + +/** + * Parse a single Seerr log line. + * Returns null for blank, unparseable or continuation lines. + */ +export function parseSeerrLogLine(line: string): ParsedLogEntry | null { + const stripped = stripAnsi(line).trimEnd(); + if (stripped.trim() === '') { + return null; + } + + const match = stripped.match(SEERR_LOG_PATTERN); + if (match === null) { + return null; + } + + const timestamp = new Date(match[1] ?? ''); + if (isNaN(timestamp.getTime())) { + return null; + } + + const level = (match[2] ?? 'info').toLowerCase() as LogLevel; + const label = match[3]; + const { message, metadata } = splitMetadata((match[4] ?? '').trimEnd()); + + return { + timestamp, + level, + message, + source: label !== undefined && label !== '' ? label : undefined, + raw: line, + metadata, + }; +} + +/** + * Check whether a line continues the previous record (stack traces, multi-line + * messages). Indented lines count as continuations; a record never starts with + * whitespace. + */ +export function isSeerrLogContinuation(line: string): boolean { + const stripped = stripAnsi(line); + return stripped.trim() !== '' && !RECORD_START_PATTERN.test(stripped); +} + +/** + * Parse a log line with context for multi-line handling. + * Returns the newly parsed entry (not the previous one), matching the contract + * of the backend LogFileProcessor. + */ +export function parseSeerrLogLineWithContext( + line: string, + context: LogParseContext +): LogParseResult { + if (isSeerrLogContinuation(line)) { + return { entry: null, isContinuation: true, previousComplete: false }; + } + + const entry = parseSeerrLogLine(line); + return { + entry, + isContinuation: false, + previousComplete: entry !== null && context.previousEntry !== undefined, + }; +} diff --git a/packages/provider-seerr/src/seerr.provider.spec.ts b/packages/provider-seerr/src/seerr.provider.spec.ts new file mode 100644 index 0000000..bdd8feb --- /dev/null +++ b/packages/provider-seerr/src/seerr.provider.spec.ts @@ -0,0 +1,681 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { SEERR_LOG_FILE_CONFIG } from './seerr.parser.js'; +import { SeerrProvider } from './seerr.provider.js'; + +const mockFetch = vi.fn(); + +const ok = (json: unknown) => ({ ok: true, status: 200, json: async () => json }); +const fail = (status: number, text = 'err') => ({ + ok: false, + status, + statusText: 'x', + text: async () => text, +}); + +type Routes = Record unknown>; + +/** Route-based fetch mock keyed on pathname (after /api/v1) */ +function route(routes: Routes): void { + mockFetch.mockImplementation(async (url: string) => { + const path = new URL(url).pathname.replace('/api/v1', ''); + const handler = routes[path]; + return handler === undefined ? fail(404, 'not found') : handler(); + }); +} + +const T = (s: string): number => Date.parse(s); + +const requests = [ + { + id: 12, + status: 2, + type: 'movie', + createdAt: '2026-10-10T11:00:00.000Z', + updatedAt: '2026-10-10T12:00:00.000Z', + is4k: false, + media: { tmdbId: 603, tvdbId: null, mediaType: 'movie', status: 3 }, + requestedBy: { id: 7, displayName: 'alice' }, + }, + { + id: 13, + status: 4, + type: 'tv', + updatedAt: '2026-10-10T12:01:00.000Z', + is4k: true, + media: { tmdbId: 1396, mediaType: 'tv' }, + seasons: [{ seasonNumber: 1 }, { seasonNumber: 2 }], + requestedBy: { id: 8, username: 'bob' }, + modifiedBy: { id: 1, displayName: 'admin' }, + }, + { + id: 14, + status: 3, + updatedAt: '2026-10-10T12:02:00.000Z', + requestedBy: { id: 9, email: 'c@x' }, + }, +]; + +const issues = [ + { + id: 5, + issueType: 3, + status: 1, + updatedAt: '2026-10-10T12:03:00.000Z', + media: { tmdbId: 603, mediaType: 'movie' }, + createdBy: { id: 7, displayName: 'alice' }, + comments: [{ id: 1, message: 'No English subs' }], + }, + { id: 6, issueType: 1, status: 2, createdAt: '2026-10-10T12:04:00.000Z' }, +]; + +const paged = (results: unknown[]) => ({ + pageInfo: { pages: 1, pageSize: 50, results: results.length, page: 1 }, + results, +}); + +async function connected(): Promise { + route({ '/settings/about': () => ok({ version: '3.5.0' }) }); + const p = new SeerrProvider(); + await p.connect({ url: 'http://seerr:5055', apiKey: 'K' }); + return p; +} + +describe('SeerrProvider', () => { + beforeEach(() => { + mockFetch.mockReset(); + vi.stubGlobal('fetch', mockFetch); + }); + afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + describe('identity', () => { + const p = new SeerrProvider(); + + it('has id, name and capabilities', () => { + expect(p.id).toBe('seerr'); + expect(p.name).toBe('Seerr'); + expect(p.capabilities).toEqual({ + supportsRealTimeLogs: false, + supportsActivityLog: true, + supportsSessions: false, + supportsWebhooks: false, + supportsPlaybackHistory: false, + }); + }); + + it('returns empty sessions and users', async () => { + await expect(p.getSessions()).resolves.toEqual([]); + await expect(p.getUsers()).resolves.toEqual([]); + }); + + it('exposes file ingestion hooks', () => { + expect(p.getLogFileConfig()).toBe(SEERR_LOG_FILE_CONFIG); + const line = '2026-10-10T12:00:01.123Z [info][API]: hi '; + expect(p.parseLogLine(line)?.source).toBe('API'); + expect(p.isLogContinuation(' at x')).toBe(true); + const ctx = { continuationLines: [], filePath: 'f', lineNumber: 1 }; + expect(p.parseLogLineWithContext(line, ctx).entry?.message).toBe('hi'); + }); + + it('has correlation patterns', () => { + const names = p.getCorrelationPatterns().map((c) => c.name); + expect(names).toEqual(['requestId', 'tmdbId', 'userId']); + const re = p.getCorrelationPatterns()[0]?.pattern as RegExp; + expect('Request #12 approved'.match(re)?.[1]).toBe('12'); + }); + + it('returns default log paths and a custom one after connect', async () => { + const paths = await p.getLogPaths(); + expect(paths).toContain('/seerr-logs'); + expect(paths).toContain('/app/config/logs'); + route({ '/settings/about': () => ok({ version: '3.5.0' }) }); + const q = new SeerrProvider(); + await q.connect({ url: 'http://h', apiKey: 'K', logPath: '/custom' }); + await expect(q.getLogPaths()).resolves.toEqual(['/custom']); + }); + }); + + describe('connection', () => { + it('reports version and name when connected', async () => { + const p = await connected(); + route({ + '/settings/about': () => ok({ version: '3.5.0' }), + }); + await expect(p.testConnection()).resolves.toEqual({ + connected: true, + serverInfo: { name: 'Seerr', version: '3.5.0', id: 'seerr' }, + }); + await expect(p.getServerInfo()).resolves.toEqual({ + name: 'Seerr', + version: '3.5.0', + id: 'seerr', + }); + }); + + it('takes the version from /settings/about and never calls /status', async () => { + const p = await connected(); + route({ '/settings/about': () => ok({ version: '1.2.3' }) }); + mockFetch.mockClear(); + const status = await p.testConnection(); + expect(status.connected).toBe(true); + expect(status.serverInfo?.version).toBe('1.2.3'); + expect(mockFetch.mock.calls.map((c) => new URL(String(c[0])).pathname)).toEqual([ + '/api/v1/settings/about', + ]); + }); + + it('rejects connect with a bad key', async () => { + route({ '/settings/about': () => fail(401) }); + await expect(new SeerrProvider().connect({ url: 'http://h', apiKey: 'bad' })).rejects.toThrow( + /ADMIN API key/ + ); + }); + + it('is not connected before connect and after disconnect', async () => { + const p = new SeerrProvider(); + await expect(p.testConnection()).resolves.toEqual({ + connected: false, + error: 'Not initialized', + }); + await expect(p.getActivity()).rejects.toThrow('Not connected to Seerr server'); + await expect(p.getServerInfo()).rejects.toThrow('Not connected to Seerr server'); + const c = await connected(); + await c.disconnect(); + await expect(c.getActivity()).rejects.toThrow('Not connected to Seerr server'); + }); + + it('returns connected:false when the key stops working', async () => { + const p = await connected(); + route({ '/settings/about': () => fail(403) }); + const status = await p.testConnection(); + expect(status.connected).toBe(false); + expect(status.error).toMatch(/ADMIN API key/); + }); + + it('returns connected:false on network errors', async () => { + vi.useFakeTimers(); + const p = await connected(); + mockFetch.mockRejectedValue(new TypeError('fetch failed')); + const promise = p.testConnection(); + await vi.advanceTimersByTimeAsync(10000); + const status = await promise; + expect(status.connected).toBe(false); + expect(status.error).toBeDefined(); + }); + }); + + describe('getActivity', () => { + it('normalizes requests without title lookups', async () => { + const p = await connected(); + route({ '/request': () => ok(paged(requests)), '/issue': () => ok(paged([])) }); + const acts = await p.getActivity(); + + expect(mockFetch.mock.calls.map((c) => new URL(String(c[0])).pathname)).not.toContain( + '/api/v1/movie/603' + ); + expect(acts.map((a) => a.id)).toEqual([ + 'seerr-request-12-status-2', + 'seerr-request-13-status-4', + 'seerr-request-14-status-3', + ]); + expect(acts.map((a) => a.type)).toEqual([ + 'request_approved', + 'request_failed', + 'request_declined', + ]); + expect(acts.map((a) => a.severity)).toEqual(['info', 'error', 'warn']); + + const [a, b, c] = acts; + expect(a?.name).toBe('Movie request approved'); + expect(a?.overview).toContain('tmdb:603'); + expect(a?.overview).toContain('alice'); + expect(a?.itemId).toBe('tmdb:603'); + expect(a?.userId).toBe('7'); + expect(a?.timestamp.getTime()).toBe(T('2026-10-10T12:00:00.000Z')); + expect(a?.metadata).toMatchObject({ + requestId: 12, + status: 2, + mediaType: 'movie', + tmdbId: 603, + }); + + expect(b?.name).toBe('TV request failed'); + expect(b?.overview).toContain('tmdb:1396'); + expect(b?.overview).toContain('bob'); + expect(b?.overview).toContain('4K'); + expect(b?.overview).toContain('S1, S2'); + expect(b?.overview).toContain('admin'); + + expect(c?.overview).toContain('user 9'); + expect(c?.overview).not.toContain('c@x'); + expect(c?.itemId).toBeUndefined(); + }); + + it('is dedupe-stable and creates a new id only on status change', async () => { + const p = await connected(); + route({ '/request': () => ok(paged(requests)), '/issue': () => ok(paged([])) }); + const first = await p.getActivity(); + const second = await p.getActivity(); + expect(second.map((a) => a.id)).toEqual(first.map((a) => a.id)); + + const moved = [{ ...requests[0], updatedAt: '2026-10-10T13:00:00.000Z' }]; + route({ '/request': () => ok(paged(moved)), '/issue': () => ok(paged([])) }); + expect((await p.getActivity())[0]?.id).toBe(first[0]?.id); + + const approvedToCompleted = [{ ...requests[0], status: 5 }]; + route({ '/request': () => ok(paged(approvedToCompleted)), '/issue': () => ok(paged([])) }); + const done = await p.getActivity(); + expect(done[0]?.id).toBe('seerr-request-12-status-5'); + expect(done[0]?.type).toBe('request_completed'); + }); + + it('handles unknown request statuses', async () => { + const p = await connected(); + route({ + '/request': () => ok(paged([{ id: 1, status: 99, updatedAt: '2026-10-10T12:00:00.000Z' }])), + '/issue': () => ok(paged([])), + }); + const [a] = await p.getActivity(); + expect(a?.type).toBe('request_unknown'); + expect(a?.name).toContain('status 99'); + expect(a?.severity).toBe('info'); + }); + + it('normalizes issues', async () => { + const p = await connected(); + route({ '/request': () => ok(paged([])), '/issue': () => ok(paged(issues)) }); + const acts = await p.getActivity(); + expect(acts.map((a) => a.id)).toEqual(['seerr-issue-5-status-1', 'seerr-issue-6-status-2']); + const [open, resolved] = acts; + expect(open?.type).toBe('issue_open'); + expect(open?.severity).toBe('warn'); + expect(open?.name).toContain('Subtitles'); + expect(open?.overview).toContain('1 comment'); + expect(open?.overview).not.toContain('No English subs'); + expect(open?.overview).toContain('alice'); + expect(open?.itemId).toBe('tmdb:603'); + expect(resolved?.type).toBe('issue_resolved'); + expect(resolved?.severity).toBe('info'); + expect(resolved?.timestamp.getTime()).toBe(T('2026-10-10T12:04:00.000Z')); + }); + + it('treats a missing issue status as open and unknown types safely', async () => { + const p = await connected(); + route({ + '/request': () => ok(paged([])), + '/issue': () => + ok( + paged([ + { + id: 9, + issueType: 42, + problemSeason: 2, + problemEpisode: 3, + updatedAt: '2026-10-10T12:00:00.000Z', + }, + ]) + ), + }); + const [a] = await p.getActivity(); + expect(a?.id).toBe('seerr-issue-9-status-1'); + expect(a?.type).toBe('issue_open'); + expect(a?.name).toContain('Type 42'); + expect(a?.name).toContain('S2E3'); + }); + + it('accepts bare array envelopes', async () => { + const p = await connected(); + route({ '/request': () => ok(requests), '/issue': () => ok(issues) }); + expect(await p.getActivity()).toHaveLength(5); + }); + + it('filters by since and stops paging at older items', async () => { + const p = await connected(); + route({ + '/request': () => ok(paged([...requests].reverse())), + '/issue': () => ok(paged([])), + }); + const acts = await p.getActivity(new Date('2026-10-10T12:00:30Z')); + expect(acts.map((a) => a.metadata?.['requestId']).sort()).toEqual([13, 14]); + const requestCalls = mockFetch.mock.calls.filter((c) => + String(c[0]).includes('/api/v1/request') + ); + expect(requestCalls).toHaveLength(1); + }); + + it('pages while items are newer than since and takes only one page without since', async () => { + const p = await connected(); + const page = (start: number, iso: string) => + Array.from({ length: 50 }, (_, i) => ({ + id: start + i, + status: 2, + updatedAt: iso, + })); + route({ + '/request': () => { + const skip = Number( + new URL(String(mockFetch.mock.calls.at(-1)?.[0])).searchParams.get('skip') + ); + return ok( + paged( + skip === 0 + ? page(1, '2026-10-10T12:10:00.000Z') + : [ + ...page(100, '2026-10-10T12:05:00.000Z').slice(0, 1), + { id: 999, status: 2, updatedAt: '2026-10-09T00:00:00.000Z' }, + ] + ) + ); + }, + '/issue': () => ok(paged([])), + }); + const since = new Date('2026-10-10T12:00:00Z'); + const acts = await p.getActivity(since); + expect(acts).toHaveLength(51); + const skips = mockFetch.mock.calls + .map((c) => new URL(String(c[0]))) + .filter((u) => u.pathname === '/api/v1/request') + .map((u) => u.searchParams.get('skip')); + expect(skips).toEqual(['0', '50']); + + mockFetch.mockClear(); + await p.getActivity(); + const noSince = mockFetch.mock.calls.filter((c) => String(c[0]).includes('/api/v1/request')); + expect(noSince).toHaveLength(1); + }); + + it('skips invalid dates', async () => { + const p = await connected(); + route({ + '/request': () => + ok( + paged([ + { id: 1, status: 2, updatedAt: 'garbage' }, + { id: 2, status: 2 }, + { id: 3, status: 2, updatedAt: '2026-10-10T12:00:00.000Z' }, + ]) + ), + '/issue': () => ok(paged([{ id: 1, issueType: 1, updatedAt: 'nope', createdAt: 'nope' }])), + }); + const acts = await p.getActivity(); + expect(acts.map((a) => a.id)).toEqual(['seerr-request-3-status-2']); + }); + + it('sorts ascending by timestamp', async () => { + const p = await connected(); + route({ + '/request': () => ok(paged([...requests].reverse())), + '/issue': () => ok(paged(issues)), + }); + const times = (await p.getActivity()).map((a) => a.timestamp.getTime()); + expect(times).toEqual([...times].sort((x, y) => x - y)); + }); + + it('returns partial results when one source fails', async () => { + vi.useFakeTimers(); + const p = await connected(); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + route({ '/request': () => ok(paged(requests)), '/issue': () => fail(500) }); + const promise = p.getActivity(); + await vi.advanceTimersByTimeAsync(10000); + const acts = await promise; + expect(acts).toHaveLength(3); + expect(errorSpy).toHaveBeenCalled(); + }); + + it('rejects when every source fails', async () => { + const p = await connected(); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + route({ '/request': () => fail(401), '/issue': () => fail(401) }); + await expect(p.getActivity()).rejects.toThrow(/ADMIN API key/); + }); + }); + + describe('edge cases', () => { + const one = async (request: unknown) => { + const p = await connected(); + route({ '/request': () => ok(paged([request])), '/issue': () => ok(paged([])) }); + const [act] = await p.getActivity(); + return act; + }; + const oneIssue = async (issue: unknown) => { + const p = await connected(); + route({ '/request': () => ok(paged([])), '/issue': () => ok(paged([issue])) }); + const [act] = await p.getActivity(); + return act; + }; + + it('throws when used before connect', async () => { + const p = new SeerrProvider(); + await expect(p.getActivity()).rejects.toThrow('Not connected to Seerr server'); + await expect(p.getServerInfo()).rejects.toThrow('Not connected to Seerr server'); + }); + + it('includes boundary items (timestamp === since)', async () => { + const act = await (async () => { + const p = await connected(); + route({ + '/request': () => + ok(paged([{ id: 1, status: 2, updatedAt: '2026-10-10T12:00:00.000Z' }])), + '/issue': () => ok(paged([])), + }); + return p.getActivity(new Date('2026-10-10T12:00:00.000Z')); + })(); + expect(act).toHaveLength(1); + }); + + it('caps paging at three pages when everything is newer than since', async () => { + const p = await connected(); + const full = Array.from({ length: 50 }, (_, i) => ({ + id: i, + status: 2, + updatedAt: '2026-10-10T12:00:00.000Z', + })); + route({ '/request': () => ok(paged(full)), '/issue': () => ok(paged([])) }); + await p.getActivity(new Date('2026-01-01T00:00:00Z')); + const skips = mockFetch.mock.calls + .map((c) => new URL(String(c[0]))) + .filter((u) => u.pathname === '/api/v1/request') + .map((u) => u.searchParams.get('skip')); + expect(skips).toEqual(['0', '50', '100']); + }); + + it('tolerates a request without requestedBy or media', async () => { + const act = await one({ id: 9, status: 99, updatedAt: '2026-10-10T12:00:00.000Z' }); + expect(act?.type).toBe('request_unknown'); + expect(act?.userId).toBeUndefined(); + expect(act?.itemId).toBeUndefined(); + expect(act?.overview).toContain('request #9'); + expect(act?.overview).toContain('unknown user'); + }); + + it('ignores a string or null modifiedBy', async () => { + for (const modifiedBy of ['system', null]) { + const act = await one({ + id: 9, + status: 2, + updatedAt: '2026-10-10T12:00:00.000Z', + requestedBy: { id: 1, displayName: 'a' }, + modifiedBy, + }); + expect(act?.overview).not.toContain('updated by'); + } + }); + + it('falls back to the user id when no name field is set', async () => { + const act = await one({ + id: 9, + status: 1, + updatedAt: '2026-10-10T12:00:00.000Z', + requestedBy: { id: 33 }, + }); + expect(act?.overview).toContain('requested by user 33'); + }); + + it('prefers a media type from the media object when type is absent', async () => { + const act = await one({ + id: 9, + status: 5, + updatedAt: '2026-10-10T12:00:00.000Z', + media: { tmdbId: 1, mediaType: 'tv' }, + }); + expect(act?.name).toBe('TV request completed'); + expect(act?.severity).toBe('info'); + }); + + it('uses a stable id and maps severity per status', async () => { + const sev = new Map(); + for (const status of [1, 2, 3, 4, 5]) { + const act = await one({ id: 1, status, updatedAt: '2026-10-10T12:00:00.000Z' }); + sev.set(status, `${act?.severity}:${act?.id}`); + } + expect([...sev.values()]).toEqual([ + 'info:seerr-request-1-status-1', + 'info:seerr-request-1-status-2', + 'warn:seerr-request-1-status-3', + 'error:seerr-request-1-status-4', + 'info:seerr-request-1-status-5', + ]); + }); + + it('formats season and episode of an issue', async () => { + const base = { id: 1, issueType: 1, updatedAt: '2026-10-10T12:00:00.000Z' }; + expect((await oneIssue({ ...base, problemSeason: 2, problemEpisode: 5 }))?.name).toBe( + 'Video issue open S2E5' + ); + expect((await oneIssue({ ...base, problemSeason: 2, problemEpisode: 0 }))?.name).toBe( + 'Video issue open S2' + ); + expect((await oneIssue({ ...base, problemSeason: 0, problemEpisode: 3 }))?.name).toBe( + 'Video issue open' + ); + }); + + it('skips requests and issues with a non-numeric status', async () => { + const p = await connected(); + route({ + '/request': () => + ok( + paged([ + { id: 1, updatedAt: '2026-10-10T12:00:00.000Z' }, + { id: 2, status: '2', updatedAt: '2026-10-10T12:00:00.000Z' }, + { id: 3, status: 2, updatedAt: '2026-10-10T12:00:00.000Z' }, + ]) + ), + '/issue': () => + ok( + paged([ + { id: 1, issueType: 1, status: 'open', updatedAt: '2026-10-10T12:00:00.000Z' }, + { id: 2, issueType: 1, updatedAt: '2026-10-10T12:00:00.000Z' }, + ]) + ), + }); + const ids = (await p.getActivity()).map((a) => a.id); + expect(ids).toEqual(['seerr-request-3-status-2', 'seerr-issue-2-status-1']); + expect(ids.some((id) => id.includes('undefined'))).toBe(false); + }); + + it('never puts email addresses into overviews', async () => { + const p = await connected(); + route({ + '/request': () => + ok( + paged([ + { + id: 1, + status: 2, + updatedAt: '2026-10-10T12:00:00.000Z', + requestedBy: { id: 4, email: 'a@b.c' }, + modifiedBy: { id: 5, email: 'm@b.c' }, + }, + ]) + ), + '/issue': () => ok(paged([])), + }); + const [a] = await p.getActivity(); + expect(a?.overview).toContain('user 4'); + expect(a?.overview).not.toContain('@'); + }); + + it('warns when the page cap is hit with items still newer than since', async () => { + const p = await connected(); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const full = Array.from({ length: 50 }, (_, i) => ({ + id: i + 1, + status: 2, + updatedAt: '2026-10-10T12:00:00.000Z', + })); + route({ '/request': () => ok(paged(full)), '/issue': () => ok(paged([])) }); + const acts = await p.getActivity(new Date('2026-10-10T00:00:00Z')); + expect(acts).toHaveLength(150); + expect(warn).toHaveBeenCalledWith( + '[seerr] activity page cap reached; some changes may be skipped' + ); + }); + + it('does not warn when an older item ends paging', async () => { + const p = await connected(); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + route({ '/request': () => ok(paged(requests)), '/issue': () => ok(paged([])) }); + await p.getActivity(new Date('2026-10-10T12:00:30Z')); + expect(warn).not.toHaveBeenCalled(); + }); + + it('shows only the comment count, never the comment text', async () => { + const act = await oneIssue({ + id: 1, + issueType: 2, + updatedAt: '2026-10-10T12:00:00.000Z', + comments: [ + { id: 1, message: 'first' }, + { id: 2, message: 'x'.repeat(2000) }, + ], + }); + expect(act?.overview).toContain('2 comments'); + expect(act?.overview).not.toContain('first'); + expect(act?.overview).not.toContain('xxx'); + expect(act?.metadata?.['commentCount']).toBe(2); + }); + + it('falls back to createdAt and reports zero comments', async () => { + const act = await oneIssue({ id: 4, issueType: 4, createdAt: '2026-10-10T09:00:00.000Z' }); + expect(act?.timestamp.toISOString()).toBe('2026-10-10T09:00:00.000Z'); + expect(act?.overview).toContain('0 comments'); + expect(act?.overview).toContain('issue #4'); + }); + + it('reports a non-Error rejection as an Error when all sources fail', async () => { + const p = await connected(); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + mockFetch.mockImplementation(() => Promise.reject('string failure')); + vi.useFakeTimers(); + const promise = p.getActivity().catch((e) => e); + await vi.advanceTimersByTimeAsync(10000); + const error = await promise; + expect(error).toBeInstanceOf(Error); + }); + + it('tags the failing source in the error log', async () => { + vi.useFakeTimers(); + const p = await connected(); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + route({ '/request': () => fail(500), '/issue': () => ok(paged(issues)) }); + const promise = p.getActivity(); + await vi.advanceTimersByTimeAsync(10000); + expect(await promise).toHaveLength(2); + expect(errorSpy.mock.calls[0]?.[0]).toContain('request'); + }); + + it('reports testConnection failure as connected:false with the hint', async () => { + const p = await connected(); + route({ '/settings/about': () => fail(403) }); + const status = await p.testConnection(); + expect(status.connected).toBe(false); + expect(status.error).toMatch(/ADMIN API key/); + }); + }); +}); diff --git a/packages/provider-seerr/src/seerr.provider.ts b/packages/provider-seerr/src/seerr.provider.ts new file mode 100644 index 0000000..1e671c3 --- /dev/null +++ b/packages/provider-seerr/src/seerr.provider.ts @@ -0,0 +1,371 @@ +/** + * Seerr provider for Logarr (also covers Overseerr and Jellyseerr) + * Implements the MediaServerProvider interface + * + * Activity comes from media requests and user reported issues. Application logs + * are ingested from log files only (see getLogFileConfig). + */ + +import { SeerrClient } from './seerr.client.js'; +import { + SEERR_CORRELATION_PATTERNS, + SEERR_LOG_FILE_CONFIG, + isSeerrLogContinuation, + parseSeerrLogLine, + parseSeerrLogLineWithContext, +} from './seerr.parser.js'; + +import type { SeerrIssue, SeerrMediaInfo, SeerrRequest, SeerrUser } from './seerr.types.js'; +import type { + ConnectionStatus, + CorrelationPattern, + LogFileConfig, + LogLevel, + LogParseContext, + LogParseResult, + MediaServerProvider, + NormalizedActivity, + NormalizedSession, + NormalizedUser, + ParsedLogEntry, + ProviderCapabilities, + ProviderConfig, + ServerInfo, +} from '@logarr/core'; + +const PAGE_SIZE = 50; +const MAX_PAGES_WITH_SINCE = 3; + +const REQUEST_STATUS: Record = { + 1: { type: 'request_pending', label: 'pending approval', severity: 'info' }, + 2: { type: 'request_approved', label: 'approved', severity: 'info' }, + 3: { type: 'request_declined', label: 'declined', severity: 'warn' }, + 4: { type: 'request_failed', label: 'failed', severity: 'error' }, + 5: { type: 'request_completed', label: 'completed', severity: 'info' }, +}; + +const ISSUE_TYPE_LABELS: Record = { + 1: 'Video', + 2: 'Audio', + 3: 'Subtitles', + 4: 'Other', +}; + +function userName(user: SeerrUser | undefined): string | undefined { + if (user === undefined) { + return undefined; + } + return ( + user.displayName ?? + user.username ?? + user.plexUsername ?? + user.jellyfinUsername ?? + `user ${user.id}` + ); +} + +function mediaLabel(media: SeerrMediaInfo | undefined, fallback: string): string { + return media?.tmdbId !== undefined ? `tmdb:${media.tmdbId}` : fallback; +} + +function parseDate(value: string | undefined): Date | null { + if (value === undefined) { + return null; + } + const date = new Date(value); + return isNaN(date.getTime()) ? null : date; +} + +/** + * Seerr provider + */ +export class SeerrProvider implements MediaServerProvider { + readonly id = 'seerr'; + readonly name = 'Seerr'; + + readonly capabilities: ProviderCapabilities = { + supportsRealTimeLogs: false, + supportsActivityLog: true, + supportsSessions: false, + supportsWebhooks: false, + supportsPlaybackHistory: false, + }; + + private client: SeerrClient | null = null; + private config: ProviderConfig | null = null; + + // =========================================================================== + // Connection Lifecycle + // =========================================================================== + + async connect(config: ProviderConfig): Promise { + this.config = config; + this.client = new SeerrClient(config.url, config.apiKey); + // /settings/about is admin-only, so this validates the API key as well + await this.client.getAbout(); + } + + disconnect(): Promise { + this.client = null; + this.config = null; + return Promise.resolve(); + } + + async testConnection(): Promise { + if (this.client === null) { + return { connected: false, error: 'Not initialized' }; + } + + try { + return { connected: true, serverInfo: await this.fetchServerInfo(this.client) }; + } catch (error) { + return { + connected: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + } + + async getServerInfo(): Promise { + return this.fetchServerInfo(this.getClient()); + } + + // =========================================================================== + // Log Files + // =========================================================================== + + getLogPaths(): Promise { + if (this.config?.logPath !== undefined) { + return Promise.resolve([this.config.logPath]); + } + return Promise.resolve(['/seerr-logs', '/app/config/logs']); + } + + getLogFileConfig(): LogFileConfig { + return SEERR_LOG_FILE_CONFIG; + } + + parseLogLine(line: string): ParsedLogEntry | null { + return parseSeerrLogLine(line); + } + + parseLogLineWithContext(line: string, context: LogParseContext): LogParseResult { + return parseSeerrLogLineWithContext(line, context); + } + + isLogContinuation(line: string): boolean { + return isSeerrLogContinuation(line); + } + + getCorrelationPatterns(): readonly CorrelationPattern[] { + return SEERR_CORRELATION_PATTERNS; + } + + // =========================================================================== + // Sessions / Users (not applicable) + // =========================================================================== + + getSessions(): Promise { + return Promise.resolve([]); + } + + getUsers(): Promise { + return Promise.resolve([]); + } + + // =========================================================================== + // Activity + // =========================================================================== + + /** + * Requests and issues. One source failing does not hide the other; if both + * fail the first error is rethrown so the poll marks the server as down. + */ + async getActivity(since?: Date): Promise { + const client = this.getClient(); + + const results = await Promise.allSettled([ + this.collect(client, 'request', since), + this.collect(client, 'issue', since), + ]); + + const activities: NormalizedActivity[] = []; + const errors: unknown[] = []; + for (const [index, result] of results.entries()) { + if (result.status === 'fulfilled') { + activities.push(...result.value); + } else { + errors.push(result.reason); + console.error( + `[${this.id}] Failed to fetch ${index === 0 ? 'request' : 'issue'} activity:`, + result.reason + ); + } + } + + if (errors.length === results.length) { + throw errors[0] instanceof Error ? errors[0] : new Error(String(errors[0])); + } + + return activities.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()); + } + + // =========================================================================== + // Private Helpers + // =========================================================================== + + private getClient(): SeerrClient { + if (this.client === null) { + throw new Error('Not connected to Seerr server'); + } + return this.client; + } + + private async fetchServerInfo(client: SeerrClient): Promise { + const about = await client.getAbout(); + return { name: 'Seerr', version: about.version, id: 'seerr' }; + } + + /** + * Page through a list (newest modification first) until an item older than + * `since` is seen, a short page is returned or the page cap is reached. + * + * Trade-off: the cap (3 pages = 150 items per poll) bounds the number of API + * calls (Seerr rate limits to 50 req/60 s). If more than 150 items changed since + * the last poll, the oldest ones are skipped; this is logged as a warning. + */ + private async collect( + client: SeerrClient, + kind: 'request' | 'issue', + since?: Date + ): Promise { + const maxPages = since === undefined ? 1 : MAX_PAGES_WITH_SINCE; + const out: NormalizedActivity[] = []; + + for (let page = 0; page < maxPages; page++) { + const options = { take: PAGE_SIZE, skip: page * PAGE_SIZE }; + const items: readonly (SeerrRequest | SeerrIssue)[] = + kind === 'request' ? await client.getRequests(options) : await client.getIssues(options); + + let reachedOlder = false; + for (const item of items) { + const activity = + kind === 'request' + ? this.normalizeRequest(item as SeerrRequest) + : this.normalizeIssue(item as SeerrIssue); + if (activity === null) { + continue; + } + if (since !== undefined && activity.timestamp < since) { + reachedOlder = true; + continue; + } + out.push(activity); + } + + if (reachedOlder || items.length < PAGE_SIZE) { + break; + } + + if (since !== undefined && page === maxPages - 1) { + console.warn(`[${this.id}] activity page cap reached; some changes may be skipped`); + } + } + + return out; + } + + private normalizeRequest(req: SeerrRequest): NormalizedActivity | null { + const timestamp = parseDate(req.updatedAt); + if (timestamp === null || typeof req.status !== 'number') { + return null; + } + + const mediaType = req.type ?? req.media?.mediaType; + const typeLabel = mediaType === 'movie' ? 'Movie' : mediaType === 'tv' ? 'TV' : 'Media'; + const mapped = REQUEST_STATUS[req.status] ?? { + type: 'request_unknown', + label: `status ${req.status}`, + severity: 'info' as LogLevel, + }; + + const seasons = (req.seasons ?? []).map((s) => `S${s.seasonNumber}`); + const modifier = + typeof req.modifiedBy === 'object' ? userName(req.modifiedBy ?? undefined) : undefined; + const details = [ + req.is4k === true ? '4K' : undefined, + seasons.length > 0 ? seasons.join(', ') : undefined, + `requested by ${userName(req.requestedBy) ?? 'unknown user'}`, + modifier !== undefined ? `updated by ${modifier}` : undefined, + ].filter((d): d is string => d !== undefined); + + return { + id: `seerr-request-${req.id}-status-${req.status}`, + type: mapped.type, + name: `${typeLabel} request ${mapped.label}`, + overview: `${mediaLabel(req.media, `request #${req.id}`)} (${details.join(', ')})`, + severity: mapped.severity, + userId: req.requestedBy !== undefined ? String(req.requestedBy.id) : undefined, + itemId: req.media?.tmdbId !== undefined ? `tmdb:${req.media.tmdbId}` : undefined, + timestamp, + metadata: { + requestId: req.id, + status: req.status, + mediaType, + tmdbId: req.media?.tmdbId, + tvdbId: req.media?.tvdbId, + is4k: req.is4k ?? false, + mediaStatus: req.media?.status, + }, + }; + } + + private normalizeIssue(issue: SeerrIssue): NormalizedActivity | null { + const timestamp = parseDate(issue.updatedAt) ?? parseDate(issue.createdAt); + if (timestamp === null) { + return null; + } + + if (issue.status !== undefined && typeof issue.status !== 'number') { + return null; + } + const status = issue.status ?? 1; + const resolved = status === 2; + const typeLabel = ISSUE_TYPE_LABELS[issue.issueType] ?? `Type ${issue.issueType}`; + + const where = + issue.problemSeason !== undefined && issue.problemSeason > 0 + ? ` S${issue.problemSeason}${ + issue.problemEpisode !== undefined && issue.problemEpisode > 0 + ? `E${issue.problemEpisode}` + : '' + }` + : ''; + + const comments = issue.comments ?? []; + const reporter = userName(issue.createdBy) ?? 'unknown user'; + const subject = mediaLabel(issue.media, `issue #${issue.id}`); + + return { + id: `seerr-issue-${issue.id}-status-${status}`, + type: resolved ? 'issue_resolved' : 'issue_open', + name: `${typeLabel} issue ${resolved ? 'resolved' : 'open'}${where}`, + // Comment text is user-controlled and flows into AI analysis, so only the count is shown + overview: `${subject}: ${comments.length} ${ + comments.length === 1 ? 'comment' : 'comments' + } (reported by ${reporter})`, + severity: resolved ? 'info' : 'warn', + userId: issue.createdBy !== undefined ? String(issue.createdBy.id) : undefined, + itemId: issue.media?.tmdbId !== undefined ? `tmdb:${issue.media.tmdbId}` : undefined, + timestamp, + metadata: { + issueId: issue.id, + issueType: issue.issueType, + status, + commentCount: comments.length, + tmdbId: issue.media?.tmdbId, + mediaType: issue.media?.mediaType, + }, + }; + } +} diff --git a/packages/provider-seerr/src/seerr.types.ts b/packages/provider-seerr/src/seerr.types.ts new file mode 100644 index 0000000..2948e2f --- /dev/null +++ b/packages/provider-seerr/src/seerr.types.ts @@ -0,0 +1,112 @@ +/** + * Type definitions for the Seerr API (also covers Overseerr and Jellyseerr) + */ + +/** Paging information returned by list endpoints */ +export interface SeerrPageInfo { + pages: number; + pageSize: number; + results: number; + page: number; +} + +/** Paged list envelope returned by /request and /issue */ +export interface SeerrPaged { + pageInfo?: SeerrPageInfo; + results: T[]; +} + +/** GET /settings/about (admin only) */ +export interface SeerrAbout { + version: string; + totalRequests?: number; + totalMediaItems?: number; + tz?: string | null; + appDataPath?: string; +} + +/** Seerr user */ +export interface SeerrUser { + id: number; + displayName?: string; + username?: string; + plexUsername?: string; + jellyfinUsername?: string; + email?: string; +} + +/** Media info attached to requests and issues */ +export interface SeerrMediaInfo { + id?: number; + tmdbId?: number; + tvdbId?: number | null; + mediaType?: 'movie' | 'tv'; + status?: number; +} + +/** Season selection of a TV request */ +export interface SeerrRequestSeason { + id?: number; + seasonNumber: number; + status?: number; +} + +/** Media request */ +export interface SeerrRequest { + id: number; + status: number; + type?: 'movie' | 'tv'; + is4k?: boolean; + createdAt?: string; + updatedAt?: string; + media?: SeerrMediaInfo; + seasons?: SeerrRequestSeason[]; + requestedBy?: SeerrUser; + modifiedBy?: SeerrUser | string | null; +} + +/** Comment on an issue */ +export interface SeerrIssueComment { + id: number; + message: string; + user?: SeerrUser; + createdAt?: string; +} + +/** User reported issue */ +export interface SeerrIssue { + id: number; + issueType: number; + status?: number; + problemSeason?: number; + problemEpisode?: number; + createdAt?: string; + updatedAt?: string; + media?: SeerrMediaInfo; + createdBy?: SeerrUser; + modifiedBy?: SeerrUser; + comments?: SeerrIssueComment[]; +} + +/** Request status codes (4 and 5 exist only in newer versions) */ +export const SeerrRequestStatus = { + PENDING: 1, + APPROVED: 2, + DECLINED: 3, + FAILED: 4, + COMPLETED: 5, +} as const; + +/** Issue type codes */ +export const SeerrIssueType = { + VIDEO: 1, + AUDIO: 2, + SUBTITLES: 3, + OTHER: 4, +} as const; + +/** Issue status codes */ +export const SeerrIssueStatus = { + OPEN: 1, + RESOLVED: 2, +} as const; diff --git a/packages/provider-seerr/tsconfig.json b/packages/provider-seerr/tsconfig.json new file mode 100644 index 0000000..f103ea7 --- /dev/null +++ b/packages/provider-seerr/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" }] +} diff --git a/packages/provider-seerr/tsup.config.ts b/packages/provider-seerr/tsup.config.ts new file mode 100644 index 0000000..4a0ea44 --- /dev/null +++ b/packages/provider-seerr/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'], + outExtension({ format }) { + return { + js: format === 'cjs' ? '.cjs' : '.js', + }; + }, +}); diff --git a/packages/provider-seerr/vitest.config.ts b/packages/provider-seerr/vitest.config.ts new file mode 100644 index 0000000..a2f6052 --- /dev/null +++ b/packages/provider-seerr/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..5f543a8 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-seerr': + specifier: workspace:* + version: link:../../packages/provider-seerr '@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-seerr: + dependencies: + '@logarr/core': + specifier: workspace:* + version: link:../core + devDependencies: + '@types/node': + specifier: ^22.10.2 + version: 22.19.3 + 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..7233f84 100644 --- a/turbo.json +++ b/turbo.json @@ -30,6 +30,8 @@ "WHISPARR_API_KEY", "LIDARR_URL", "LIDARR_API_KEY", + "SEERR_URL", + "SEERR_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", + "SEERR_LOGS_PATH" ], "tasks": { "build": { From e33efbf1ed88be4b9deea9fd609fed20be1df5a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1clav=20Brzezina?= Date: Sat, 10 Oct 2026 02:54:15 +0200 Subject: [PATCH 2/4] fix(provider-seerr): bound log metadata parsing to avoid superlinear time --- .../provider-seerr/src/seerr.parser.spec.ts | 50 +++++++++++++++++++ packages/provider-seerr/src/seerr.parser.ts | 22 +++++++- 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/packages/provider-seerr/src/seerr.parser.spec.ts b/packages/provider-seerr/src/seerr.parser.spec.ts index 8faabb0..95a0a65 100644 --- a/packages/provider-seerr/src/seerr.parser.spec.ts +++ b/packages/provider-seerr/src/seerr.parser.spec.ts @@ -1,3 +1,5 @@ +import { performance } from 'node:perf_hooks'; + import { describe, it, expect } from 'vitest'; import { @@ -288,3 +290,51 @@ describe('SEERR_CORRELATION_PATTERNS', () => { } }); }); + +describe('adversarial input (algorithmic complexity)', () => { + const HEAD = '2026-10-10T12:00:00.000Z [info][API]: '; + const N = 100_000; + + const cases: Record = { + 'nested valid-prefix JSON openers': HEAD + 'x' + ' {"a":['.repeat(Math.ceil(N / 7)) + '}', + 'many " {" candidates': HEAD + ' {'.repeat(N / 2) + '}', + 'long run of spaces': HEAD + ' '.repeat(N) + '}', + 'unclosed brackets in label': '2026-10-10T12:00:00.000Z [info][' + '['.repeat(N), + 'unclosed long label': '2026-10-10T12:00:00.000Z [info][' + 'a'.repeat(N), + 'repeated ANSI introducers': '\u001b['.repeat(N / 2) + HEAD, + 'long run of closing braces': HEAD + '}'.repeat(N), + 'long unterminated JSON string': HEAD + 'x {"a":"' + 'a'.repeat(N) + '}', + }; + + it.each(Object.entries(cases))('parses %s quickly', (_name, line) => { + const start = performance.now(); + parseSeerrLogLine(line); + isSeerrLogContinuation(line); + parseSeerrLogLineWithContext(line, ctx()); + expect(performance.now() - start).toBeLessThan(50); + }); + + it('scales linearly for nested openers (4x input is far below 16x time)', () => { + const build = (n: number): string => HEAD + 'x' + ' {"a":['.repeat(n) + '}'; + const time = (line: string): number => { + const s = performance.now(); + parseSeerrLogLine(line); + return performance.now() - s; + }; + time(build(1000)); // warm up + expect(time(build(40_000))).toBeLessThan(50); + }); + + it('still extracts metadata after a few brace-containing words in the message', () => { + const entry = parseSeerrLogLine(`${HEAD}a {b} c {d} e {"k":"v"}`); + expect(entry?.message).toBe('a {b} c {d} e'); + expect(entry?.metadata).toEqual({ k: 'v' }); + }); + + it('does not attempt to parse oversized metadata tails', () => { + const big = `{"k":"${'v'.repeat(70_000)}"}`; + const entry = parseSeerrLogLine(`${HEAD}msg ${big}`); + expect(entry?.metadata).toBeUndefined(); + expect(entry?.message.startsWith('msg {')).toBe(true); + }); +}); diff --git a/packages/provider-seerr/src/seerr.parser.ts b/packages/provider-seerr/src/seerr.parser.ts index 8fef021..4c6fd61 100644 --- a/packages/provider-seerr/src/seerr.parser.ts +++ b/packages/provider-seerr/src/seerr.parser.ts @@ -87,16 +87,34 @@ function parseJsonObject(text: string): Record | null { return null; } +/** + * Upper bounds for metadata detection. Every attempt copies and parses the tail of + * the line, so unbounded attempts on a line full of ` {` would be quadratic. + */ +const MAX_METADATA_ATTEMPTS = 16; +const MAX_METADATA_LENGTH = 64 * 1024; + /** * Split `message {json}` into message and metadata. Candidate split points are - * every ` {` from the left, so braces inside the message are tolerated. + * the first few ` {` from the left (so braces inside the message are tolerated); + * the number of attempts and the size of the JSON tail are bounded to keep + * parsing linear for adversarial lines. */ function splitMetadata(rest: string): { message: string; metadata: Record | undefined; } { if (rest.endsWith('}')) { - for (let i = rest.indexOf(' {'); i !== -1; i = rest.indexOf(' {', i + 1)) { + let attempts = 0; + for ( + let i = rest.indexOf(' {'); + i !== -1 && attempts < MAX_METADATA_ATTEMPTS; + i = rest.indexOf(' {', i + 1) + ) { + if (rest.length - i - 1 > MAX_METADATA_LENGTH) { + continue; + } + attempts++; const metadata = parseJsonObject(rest.slice(i + 1)); if (metadata !== null) { return { message: rest.slice(0, i).trimEnd(), metadata }; From a66095a71eee43b9a5b43e768b03fa035ef4db7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1clav=20Brzezina?= Date: Sat, 10 Oct 2026 20:18:12 +0200 Subject: [PATCH 3/4] refactor(provider-seerr): align with shared provider conventions --- .github/agents/my-agent.agent.md | 2 +- packages/provider-seerr/src/seerr.provider.spec.ts | 14 +++++++------- packages/provider-seerr/src/seerr.provider.ts | 4 ++-- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/agents/my-agent.agent.md b/.github/agents/my-agent.agent.md index 159bab5..56faf47 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, Seerr, 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/packages/provider-seerr/src/seerr.provider.spec.ts b/packages/provider-seerr/src/seerr.provider.spec.ts index bdd8feb..56375ef 100644 --- a/packages/provider-seerr/src/seerr.provider.spec.ts +++ b/packages/provider-seerr/src/seerr.provider.spec.ts @@ -181,11 +181,11 @@ describe('SeerrProvider', () => { connected: false, error: 'Not initialized', }); - await expect(p.getActivity()).rejects.toThrow('Not connected to Seerr server'); - await expect(p.getServerInfo()).rejects.toThrow('Not connected to Seerr server'); + await expect(p.getActivity()).rejects.toThrow(/^Not connected$/); + await expect(p.getServerInfo()).rejects.toThrow(/^Not connected$/); const c = await connected(); await c.disconnect(); - await expect(c.getActivity()).rejects.toThrow('Not connected to Seerr server'); + await expect(c.getActivity()).rejects.toThrow(/^Not connected$/); }); it('returns connected:false when the key stops working', async () => { @@ -424,7 +424,7 @@ describe('SeerrProvider', () => { await vi.advanceTimersByTimeAsync(10000); const acts = await promise; expect(acts).toHaveLength(3); - expect(errorSpy).toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith('[seerr] Failed to get issues:', expect.any(Error)); }); it('rejects when every source fails', async () => { @@ -451,8 +451,8 @@ describe('SeerrProvider', () => { it('throws when used before connect', async () => { const p = new SeerrProvider(); - await expect(p.getActivity()).rejects.toThrow('Not connected to Seerr server'); - await expect(p.getServerInfo()).rejects.toThrow('Not connected to Seerr server'); + await expect(p.getActivity()).rejects.toThrow(/^Not connected$/); + await expect(p.getServerInfo()).rejects.toThrow(/^Not connected$/); }); it('includes boundary items (timestamp === since)', async () => { @@ -667,7 +667,7 @@ describe('SeerrProvider', () => { const promise = p.getActivity(); await vi.advanceTimersByTimeAsync(10000); expect(await promise).toHaveLength(2); - expect(errorSpy.mock.calls[0]?.[0]).toContain('request'); + expect(errorSpy.mock.calls[0]?.[0]).toBe('[seerr] Failed to get requests:'); }); it('reports testConnection failure as connected:false with the hint', async () => { diff --git a/packages/provider-seerr/src/seerr.provider.ts b/packages/provider-seerr/src/seerr.provider.ts index 1e671c3..b59664c 100644 --- a/packages/provider-seerr/src/seerr.provider.ts +++ b/packages/provider-seerr/src/seerr.provider.ts @@ -197,7 +197,7 @@ export class SeerrProvider implements MediaServerProvider { } else { errors.push(result.reason); console.error( - `[${this.id}] Failed to fetch ${index === 0 ? 'request' : 'issue'} activity:`, + `[${this.id}] Failed to get ${index === 0 ? 'requests' : 'issues'}:`, result.reason ); } @@ -216,7 +216,7 @@ export class SeerrProvider implements MediaServerProvider { private getClient(): SeerrClient { if (this.client === null) { - throw new Error('Not connected to Seerr server'); + throw new Error('Not connected'); } return this.client; } From 99b7cabfb8588492638782417a11c1d50f60a9ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1clav=20Brzezina?= Date: Tue, 13 Oct 2026 15:06:29 +0200 Subject: [PATCH 4/4] refactor(provider-seerr): address review nits (userName, cap comment, banners) --- packages/provider-seerr/src/seerr.parser.ts | 16 ------- .../provider-seerr/src/seerr.provider.spec.ts | 40 +++++++++++++++++ packages/provider-seerr/src/seerr.provider.ts | 43 ++++++------------- 3 files changed, 54 insertions(+), 45 deletions(-) diff --git a/packages/provider-seerr/src/seerr.parser.ts b/packages/provider-seerr/src/seerr.parser.ts index 4c6fd61..466d24c 100644 --- a/packages/provider-seerr/src/seerr.parser.ts +++ b/packages/provider-seerr/src/seerr.parser.ts @@ -15,10 +15,6 @@ import type { ParsedLogEntry, } from '@logarr/core'; -// ============================================================================= -// Log File Configuration -// ============================================================================= - /** * Configuration for Seerr log file ingestion. * @@ -39,10 +35,6 @@ export const SEERR_LOG_FILE_CONFIG: LogFileConfig = { datePattern: /(?:over|jelly)?seerr-(\d{4}-\d{2}-\d{2})\.log$/, }; -// ============================================================================= -// Patterns -// ============================================================================= - /** * Main log line pattern * @@ -67,10 +59,6 @@ export const SEERR_CORRELATION_PATTERNS: readonly CorrelationPattern[] = [ { name: 'userId', pattern: /\buser(?:Id)?["=:\s]+(\d+)/i }, ]; -// ============================================================================= -// Helpers -// ============================================================================= - function stripAnsi(text: string): string { return text.replace(ANSI_PATTERN, ''); } @@ -124,10 +112,6 @@ function splitMetadata(rest: string): { return { message: rest, metadata: undefined }; } -// ============================================================================= -// Main Parsing Functions -// ============================================================================= - /** * Parse a single Seerr log line. * Returns null for blank, unparseable or continuation lines. diff --git a/packages/provider-seerr/src/seerr.provider.spec.ts b/packages/provider-seerr/src/seerr.provider.spec.ts index 56375ef..90f4050 100644 --- a/packages/provider-seerr/src/seerr.provider.spec.ts +++ b/packages/provider-seerr/src/seerr.provider.spec.ts @@ -579,6 +579,46 @@ describe('SeerrProvider', () => { expect(ids.some((id) => id.includes('undefined'))).toBe(false); }); + it.each([ + [ + 'prefers username over displayName', + { id: 3, username: 'carol', displayName: 'Carol C' }, + 'carol', + ], + [ + 'prefers plexUsername over displayName', + { id: 3, plexUsername: 'plexcarol', displayName: 'Carol C' }, + 'plexcarol', + ], + [ + 'prefers jellyfinUsername over displayName', + { id: 3, jellyfinUsername: 'jfcarol', displayName: 'Carol C' }, + 'jfcarol', + ], + ['uses displayName when no username exists', { id: 3, displayName: 'Carol C' }, 'Carol C'], + [ + 'skips an email displayName (Seerr default)', + { id: 3, displayName: 'carol@example.com' }, + 'user 3', + ], + [ + 'skips an email username but uses the next candidate', + { id: 3, username: 'c@example.com', jellyfinUsername: 'jfcarol' }, + 'jfcarol', + ], + ['skips blank names', { id: 3, username: ' ', displayName: '' }, 'user 3'], + ])('requester name: %s', async (_name, requestedBy, expected) => { + const p = await connected(); + route({ + '/request': () => + ok(paged([{ id: 1, status: 2, updatedAt: '2026-10-10T12:00:00.000Z', requestedBy }])), + '/issue': () => ok(paged([])), + }); + const [a] = await p.getActivity(); + expect(a?.overview).toContain(`requested by ${expected})`); + expect(a?.overview).not.toContain('@'); + }); + it('never puts email addresses into overviews', async () => { const p = await connected(); route({ diff --git a/packages/provider-seerr/src/seerr.provider.ts b/packages/provider-seerr/src/seerr.provider.ts index b59664c..8e8a4f0 100644 --- a/packages/provider-seerr/src/seerr.provider.ts +++ b/packages/provider-seerr/src/seerr.provider.ts @@ -51,17 +51,21 @@ const ISSUE_TYPE_LABELS: Record = { 4: 'Other', }; +/** + * Display name for a Seerr user. Seerr fills `displayName` with the email address + * when an account has no username, so username fields are preferred and anything + * that looks like an email is never surfaced. + */ function userName(user: SeerrUser | undefined): string | undefined { if (user === undefined) { return undefined; } - return ( - user.displayName ?? - user.username ?? - user.plexUsername ?? - user.jellyfinUsername ?? - `user ${user.id}` + const candidates = [user.username, user.plexUsername, user.jellyfinUsername, user.displayName]; + const name = candidates.find( + (candidate): candidate is string => + candidate !== undefined && candidate.trim() !== '' && !candidate.includes('@') ); + return name ?? `user ${user.id}`; } function mediaLabel(media: SeerrMediaInfo | undefined, fallback: string): string { @@ -94,10 +98,6 @@ export class SeerrProvider implements MediaServerProvider { private client: SeerrClient | null = null; private config: ProviderConfig | null = null; - // =========================================================================== - // Connection Lifecycle - // =========================================================================== - async connect(config: ProviderConfig): Promise { this.config = config; this.client = new SeerrClient(config.url, config.apiKey); @@ -130,10 +130,6 @@ export class SeerrProvider implements MediaServerProvider { return this.fetchServerInfo(this.getClient()); } - // =========================================================================== - // Log Files - // =========================================================================== - getLogPaths(): Promise { if (this.config?.logPath !== undefined) { return Promise.resolve([this.config.logPath]); @@ -161,10 +157,6 @@ export class SeerrProvider implements MediaServerProvider { return SEERR_CORRELATION_PATTERNS; } - // =========================================================================== - // Sessions / Users (not applicable) - // =========================================================================== - getSessions(): Promise { return Promise.resolve([]); } @@ -173,10 +165,6 @@ export class SeerrProvider implements MediaServerProvider { return Promise.resolve([]); } - // =========================================================================== - // Activity - // =========================================================================== - /** * Requests and issues. One source failing does not hide the other; if both * fail the first error is rethrown so the poll marks the server as down. @@ -210,10 +198,6 @@ export class SeerrProvider implements MediaServerProvider { return activities.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()); } - // =========================================================================== - // Private Helpers - // =========================================================================== - private getClient(): SeerrClient { if (this.client === null) { throw new Error('Not connected'); @@ -230,9 +214,10 @@ export class SeerrProvider implements MediaServerProvider { * Page through a list (newest modification first) until an item older than * `since` is seen, a short page is returned or the page cap is reached. * - * Trade-off: the cap (3 pages = 150 items per poll) bounds the number of API - * calls (Seerr rate limits to 50 req/60 s). If more than 150 items changed since - * the last poll, the oldest ones are skipped; this is logged as a warning. + * Trade-off: the cap (3 pages = 150 items per source and poll) bounds the number + * of API calls and the amount of data handled in a single poll, e.g. after the + * server was unreachable for a long time. If more than 150 items changed since + * the last poll, the oldest ones are skipped and a warning is logged. */ private async collect( client: SeerrClient,