diff --git a/.env.example b/.env.example index 995cbfd..10a722f 100644 --- a/.env.example +++ b/.env.example @@ -74,6 +74,10 @@ PROWLARR_API_KEY= WHISPARR_URL= WHISPARR_API_KEY= +# Lidarr +LIDARR_URL= +LIDARR_API_KEY= + # ============================================================================= # SECURITY & AUTHENTICATION # ============================================================================= @@ -147,6 +151,12 @@ PROWLARR_LOGS_PATH= # Docker: /path/to/whisparr/config/logs WHISPARR_LOGS_PATH= +# Lidarr examples (container path: /lidarr-logs): +# Windows: C:/ProgramData/Lidarr/logs +# Linux: ~/.config/Lidarr/logs +# Docker: /path/to/lidarr/config/logs +LIDARR_LOGS_PATH= + # ============================================================================= # MULTIPLE INSTANCES (optional - for multiple servers of the same type) # ============================================================================= @@ -191,3 +201,7 @@ PROWLARR_LOGS_PATH_3= WHISPARR_LOGS_PATH_1= WHISPARR_LOGS_PATH_2= WHISPARR_LOGS_PATH_3= + +LIDARR_LOGS_PATH_1= +LIDARR_LOGS_PATH_2= +LIDARR_LOGS_PATH_3= diff --git a/.env.tpl b/.env.tpl index f23c363..3261f01 100644 --- a/.env.tpl +++ b/.env.tpl @@ -84,6 +84,10 @@ PROWLARR_API_KEY={{ op://Development/Logarr-Dev/PROWLARR_API_KEY }} WHISPARR_URL={{ op://Development/Logarr-Dev/WHISPARR_URL }} WHISPARR_API_KEY={{ op://Development/Logarr-Dev/WHISPARR_API_KEY }} +# Lidarr +LIDARR_URL={{ op://Development/Logarr-Dev/LIDARR_URL }} +LIDARR_API_KEY={{ op://Development/Logarr-Dev/LIDARR_API_KEY }} + # ============================================================================= # SECURITY & AUTHENTICATION # ============================================================================= @@ -106,3 +110,4 @@ SONARR_LOGS_PATH={{ op://Development/Logarr-Dev/SONARR_LOGS_PATH }} 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 }} diff --git a/.github/agents/my-agent.agent.md b/.github/agents/my-agent.agent.md index dc77d5b..159bab5 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, plus shared `provider-arr` base logic. +- `packages/provider-*`: provider adapters for Plex, Jellyfin, Emby, Sonarr, Radarr, Prowlarr, Whisparr, Lidarr, plus shared `provider-arr` base logic. - `e2e`: Playwright coverage for key UI flows. - `.github/workflows/docker-publish.yml`: CI gates and container publishing. diff --git a/README.md b/README.md index eee7a6d..6c26f02 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, and more. +Unified logging, intelligent issue detection, and AI-powered analysis for Plex, Jellyfin, Emby, Sonarr, Radarr, Prowlarr, Whisparr, Lidarr, and more. [Quick Start](#quick-start) · [Features](#features) · [Screenshots](#screenshots) · [Documentation](#documentation) @@ -100,7 +100,8 @@ _Configure multiple AI providers for issue analysis (Anthropic, OpenAI, Google, | **Jellyfin** | ✅ Supported | **Plex** | ✅ Supported | | **Sonarr** | ✅ Supported | **Emby** | ✅ Supported | | **Radarr** | ✅ Supported | **Whisparr** | ✅ Supported | -| **Prowlarr** | ✅ Supported | **Kodi** | 🚧 Planned | +| **Prowlarr** | ✅ Supported | **Lidarr** | ✅ Supported | +| **Kodi** | 🚧 Planned | | | > Logarr uses a provider architecture—adding support for new servers is straightforward. [Contributions welcome!](#contributing) @@ -177,13 +178,14 @@ flowchart TB RA[Radarr] PR[Prowlarr] WH[Whisparr] + LI[Lidarr] end FE <--> API FE <--> WS API <--> PG API <--> RD - API <--> PX & JF & EM & SO & RA & PR & WH + API <--> PX & JF & EM & SO & RA & PR & WH & LI ``` ### How It Works @@ -271,6 +273,7 @@ SONARR_LOGS_PATH=/path/to/sonarr/config/logs 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 ``` 2. **Restart Logarr** — The docker-compose mounts these paths automatically. @@ -286,6 +289,7 @@ WHISPARR_LOGS_PATH=/path/to/whisparr/config/logs | Radarr | `/radarr-logs` | | Prowlarr | `/prowlarr-logs` | | Whisparr | `/whisp-logs` | +| Lidarr | `/lidarr-logs` | #### Multiple Instances (e.g., Movies vs Shorts, 1080p vs 4K) @@ -330,6 +334,7 @@ Simply add multiple servers in the UI using the same server type but different U | Radarr | `/config/logs` | `~/.config/Radarr/logs` | `C:\ProgramData\Radarr\logs` | | 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` | --- @@ -371,6 +376,7 @@ logarr/ │ ├── provider-radarr/ # Radarr integration │ ├── provider-prowlarr/ # Prowlarr integration │ ├── provider-whisparr/ # Whisparr integration +│ ├── provider-lidarr/ # Lidarr integration │ └── provider-arr/ # Base *arr provider └── docker-compose.yml ``` @@ -483,6 +489,7 @@ pnpm test:e2e # Run Playwright E2E tests | `@logarr/provider-arr` | \*arr application log parser | | `@logarr/provider-emby` | Emby log parser | | `@logarr/provider-whisparr` | Whisparr provider | +| `@logarr/provider-lidarr` | Lidarr provider | > Coverage thresholds are enforced at 80% for statements, branches, functions, and lines. diff --git a/apps/backend/Dockerfile b/apps/backend/Dockerfile index f37df39..4950ae6 100644 --- a/apps/backend/Dockerfile +++ b/apps/backend/Dockerfile @@ -58,6 +58,8 @@ COPY --from=builder --chown=nestjs:nodejs /app/packages/provider-emby/package.js COPY --from=builder --chown=nestjs:nodejs /app/packages/provider-emby/dist ./packages/provider-emby/dist COPY --from=builder --chown=nestjs:nodejs /app/packages/provider-whisparr/package.json ./packages/provider-whisparr/ 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 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 bff1f78..66d84b3 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 +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 EXPOSE 4002 diff --git a/apps/backend/package.json b/apps/backend/package.json index 7df915a..535f58a 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -27,6 +27,7 @@ "@logarr/provider-arr": "workspace:*", "@logarr/provider-emby": "workspace:*", "@logarr/provider-jellyfin": "workspace:*", + "@logarr/provider-lidarr": "workspace:*", "@logarr/provider-plex": "workspace:*", "@logarr/provider-prowlarr": "workspace:*", "@logarr/provider-radarr": "workspace:*", diff --git a/apps/backend/src/modules/file-ingestion/log-file-processor.spec.ts b/apps/backend/src/modules/file-ingestion/log-file-processor.spec.ts new file mode 100644 index 0000000..89ece45 --- /dev/null +++ b/apps/backend/src/modules/file-ingestion/log-file-processor.spec.ts @@ -0,0 +1,114 @@ +import { EmbyProvider } from '@logarr/provider-emby'; +import { SonarrProvider } from '@logarr/provider-sonarr'; +import { describe, expect, it } from 'vitest'; + +import { LogFileProcessor } from './log-file-processor'; + +import type { MediaServerProvider, ParsedLogEntry } from '@logarr/core'; + +function feed( + lines: string[], + provider: MediaServerProvider = new SonarrProvider() +): ParsedLogEntry[] { + const processor = new LogFileProcessor(provider); + const out: ParsedLogEntry[] = []; + lines.forEach((line, i) => { + const entry = processor.processLine(line, i + 1); + if (entry) out.push(entry); + }); + const last = processor.flush(); + if (last) out.push(last); + return out; +} + +describe('LogFileProcessor with *arr multi-line errors', () => { + it('assembles two entries and a blank-line separated exception with stack trace', () => { + const entries = feed([ + '2026-10-10 12:00:05.4|Error|DownloadClientProvider|Error connecting to qBittorrent', + '', + '[v4.0.10.2544] System.Net.Http.HttpRequestException: Connection refused', + ' at System.Net.Http.HttpConnectionPool.ConnectToTcpHostAsync()', + '', + '2026-10-10 12:00:06.1|Info|RssSyncService|RSS Sync Completed', + ]); + + expect(entries).toHaveLength(2); + const [error, info] = entries; + expect(error?.level).toBe('error'); + expect(error?.source).toBe('DownloadClientProvider'); + expect(error?.message).toBe('Error connecting to qBittorrent - Connection refused'); + expect(error?.exception).toBe('System.Net.Http.HttpRequestException'); + expect(error?.stackTrace).toContain('HttpRequestException: Connection refused'); + expect(error?.stackTrace).toContain('ConnectToTcpHostAsync'); + expect(error?.stackTrace).not.toContain('RSS Sync'); + expect(info?.message).toBe('RSS Sync Completed'); + expect(info?.stackTrace).toBeUndefined(); + }); + + it('emits plain single-line entries', () => { + const entries = feed([ + '2026-10-10 12:00:05.4|Info|A|one', + '', + '2026-10-10 12:00:06.1|Info|B|two', + ]); + + expect(entries.map((e) => e.message)).toEqual(['one', 'two']); + }); + + it('flushes a trailing error entry at EOF with its exception', () => { + const processor = new LogFileProcessor(new SonarrProvider()); + const lines = [ + '2026-10-10 12:00:05.4|Error|Src|Boom', + '', + '[v4.0.10.2544] System.IO.IOException: disk full', + ' at Foo.Bar()', + '', + ]; + lines.forEach((l, i) => expect(processor.processLine(l, i + 1)).toBeNull()); + + const entry = processor.flush(); + expect(entry?.message).toBe('Boom - disk full'); + expect(entry?.exception).toBe('System.IO.IOException'); + expect(entry?.stackTrace).toContain('at Foo.Bar()'); + expect(processor.flush()).toBeNull(); + }); + + it('drops a stray continuation line at the start of the file', () => { + const entries = feed([' at Orphan.Frame()', '2026-10-10 12:00:05.4|Info|A|one']); + + expect(entries).toHaveLength(1); + expect(entries[0]?.message).toBe('one'); + expect(entries[0]?.stackTrace).toBeUndefined(); + }); + + it('does not attach or emit an unparseable non-continuation line between entries', () => { + // 8-digit fraction does not match the NLog format (known limitation) so it is a continuation + // of the pending entry; the surrounding entries must still be emitted intact. + const entries = feed([ + '2026-10-10 12:00:05.4|Info|A|one', + '2026-10-10 12:00:05.12345678|Info|B|weird', + '2026-10-10 12:00:06.1|Info|C|three', + ]); + + expect(entries.map((e) => e.message)).toEqual(['one', 'three']); + expect(entries[0]?.stackTrace).toContain('weird'); + }); + + it('still works for a provider without finalizeLogEntry (Emby)', () => { + const emby = new EmbyProvider(); + expect((emby as unknown as { finalizeLogEntry?: unknown }).finalizeLogEntry).toBeUndefined(); + + const entries = feed( + [ + '2024-01-15 10:30:45.123 Error App: Something failed', + ' at Foo.Bar()', + '2024-01-15 10:30:46.123 Info App: Next', + ], + emby + ); + + expect(entries).toHaveLength(2); + expect(entries[0]?.stackTrace).toContain('at Foo.Bar()'); + expect(entries[1]?.stackTrace).toBeUndefined(); + }); +}); diff --git a/apps/backend/src/modules/file-ingestion/log-file-processor.ts b/apps/backend/src/modules/file-ingestion/log-file-processor.ts index a370bdc..5ee7a09 100644 --- a/apps/backend/src/modules/file-ingestion/log-file-processor.ts +++ b/apps/backend/src/modules/file-ingestion/log-file-processor.ts @@ -24,6 +24,13 @@ function hasContextParsing(provider: MediaServerProvider): provider is ContextAw return typeof (provider as ContextAwareProvider).parseLogLineWithContext === 'function'; } +type FinalizingProvider = MediaServerProvider & + Required>; + +function hasFinalizeLogEntry(provider: MediaServerProvider): provider is FinalizingProvider { + return typeof provider.finalizeLogEntry === 'function'; +} + function hasLogContinuation(provider: MediaServerProvider): provider is ContextAwareProvider { return typeof (provider as ContextAwareProvider).isLogContinuation === 'function'; } @@ -175,7 +182,10 @@ export class LogFileProcessor { return null; } - const entry = this.pendingEntry; + let entry = this.pendingEntry; + if (hasFinalizeLogEntry(this.provider) && this.continuationLines.length > 0) { + entry = this.provider.finalizeLogEntry(entry, this.continuationLines); + } // Clear pending entry after finalizing this.pendingEntry = null; diff --git a/apps/backend/src/modules/ingestion/ingestion.service.ts b/apps/backend/src/modules/ingestion/ingestion.service.ts index 5d8dcd7..66dd43d 100644 --- a/apps/backend/src/modules/ingestion/ingestion.service.ts +++ b/apps/backend/src/modules/ingestion/ingestion.service.ts @@ -2,6 +2,7 @@ import { setImmediate } from 'timers'; import { EmbyProvider, EmbyClient } from '@logarr/provider-emby'; import { JellyfinProvider, JellyfinClient } from '@logarr/provider-jellyfin'; +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'; @@ -81,6 +82,9 @@ export class IngestionService implements OnModuleInit, OnModuleDestroy { const whisparrProvider = new WhisparrProvider(); this.providers.set(whisparrProvider.id, whisparrProvider); + + const lidarrProvider = new LidarrProvider(); + this.providers.set(lidarrProvider.id, lidarrProvider); } async onModuleInit() { diff --git a/apps/backend/src/modules/servers/server-seed.service.ts b/apps/backend/src/modules/servers/server-seed.service.ts index a94d2c2..1cc32ed 100644 --- a/apps/backend/src/modules/servers/server-seed.service.ts +++ b/apps/backend/src/modules/servers/server-seed.service.ts @@ -70,6 +70,13 @@ const ENV_SERVER_CONFIGS: EnvServerConfig[] = [ name: 'Whisparr', containerLogPath: '/whisparr-logs', }, + { + urlEnvKey: 'LIDARR_URL', + apiKeyEnvKey: 'LIDARR_API_KEY', + providerId: 'lidarr', + name: 'Lidarr', + containerLogPath: '/lidarr-logs', + }, ]; @Injectable() diff --git a/apps/backend/src/modules/servers/servers.service.spec.ts b/apps/backend/src/modules/servers/servers.service.spec.ts index e3ed962..b68d720 100644 --- a/apps/backend/src/modules/servers/servers.service.spec.ts +++ b/apps/backend/src/modules/servers/servers.service.spec.ts @@ -122,6 +122,26 @@ describe('ServersService', () => { await expect(service.create(dto)).rejects.toThrow('Unknown provider'); }); + it('should accept lidarr as a registered provider', async () => { + const lidarrServer = { + ...mockServer, + id: 'lidarr-1', + name: 'Lidarr', + providerId: 'lidarr', + url: 'http://lidarr:8686', + }; + configureMockDb(mockDb, { insert: [lidarrServer] }); + + const result = await service.create({ + name: 'Lidarr', + providerId: 'lidarr', + url: 'http://lidarr:8686', + apiKey: 'test-key', + }); + + expect(result).toEqual(lidarrServer); + }); + it('should start file ingestion if enabled', async () => { const serverWithIngestion = { ...mockServer, diff --git a/apps/backend/src/modules/servers/servers.service.ts b/apps/backend/src/modules/servers/servers.service.ts index 83b64fb..a3616cd 100644 --- a/apps/backend/src/modules/servers/servers.service.ts +++ b/apps/backend/src/modules/servers/servers.service.ts @@ -1,5 +1,6 @@ import { EmbyProvider } from '@logarr/provider-emby'; import { JellyfinProvider } from '@logarr/provider-jellyfin'; +import { LidarrProvider } from '@logarr/provider-lidarr'; import { PlexProvider } from '@logarr/provider-plex'; import { ProwlarrProvider } from '@logarr/provider-prowlarr'; import { RadarrProvider } from '@logarr/provider-radarr'; @@ -51,6 +52,9 @@ export class ServersService { const whisparrProvider = new WhisparrProvider(); this.providers.set(whisparrProvider.id, whisparrProvider); + + const lidarrProvider = new LidarrProvider(); + this.providers.set(lidarrProvider.id, lidarrProvider); } async findAll() { diff --git a/apps/frontend/src/app/(dashboard)/logs/page.tsx b/apps/frontend/src/app/(dashboard)/logs/page.tsx index 9897e6f..db90f9f 100644 --- a/apps/frontend/src/app/(dashboard)/logs/page.tsx +++ b/apps/frontend/src/app/(dashboard)/logs/page.tsx @@ -157,6 +157,7 @@ function LogDetailModal({ case 'radarr': case 'sonarr': case 'prowlarr': + case 'lidarr': // *arr apps: system/events shows searchable event log return `${baseUrl}/system/events`; default: diff --git a/apps/frontend/src/components/provider-icon.test.tsx b/apps/frontend/src/components/provider-icon.test.tsx index a8b022a..8089195 100644 --- a/apps/frontend/src/components/provider-icon.test.tsx +++ b/apps/frontend/src/components/provider-icon.test.tsx @@ -70,6 +70,14 @@ describe('providerMeta', () => { }); }); + describe('Lidarr metadata', () => { + it('should have Lidarr name and green branding', () => { + expect(providerMeta.lidarr.name).toBe('Lidarr'); + expect(providerMeta.lidarr.color).toBe('#00C853'); + expect(providerMeta.lidarr.bgColor).toBe('bg-green-500/10'); + }); + }); + describe('All *arr provider metadata', () => { const arrProviders = ['sonarr', 'radarr', 'prowlarr', 'whisparr', 'lidarr', 'readarr']; @@ -89,6 +97,12 @@ describe('getProviderMeta', () => { expect(meta.color).toBe('#FF69B4'); }); + it('should return Lidarr metadata', () => { + const meta = getProviderMeta('lidarr'); + expect(meta.name).toBe('Lidarr'); + expect(meta.color).toBe('#00C853'); + }); + it('should be case insensitive', () => { const meta = getProviderMeta('WHISPARR'); expect(meta.name).toBe('Whisparr'); diff --git a/apps/frontend/src/lib/integrations.test.ts b/apps/frontend/src/lib/integrations.test.ts index 501a588..8660d7e 100644 --- a/apps/frontend/src/lib/integrations.test.ts +++ b/apps/frontend/src/lib/integrations.test.ts @@ -89,6 +89,61 @@ describe('Integrations Registry', () => { }); }); + describe('Lidarr integration', () => { + it('should be defined as an *arr stack integration', () => { + const lidarr = getIntegrationById('lidarr'); + expect(lidarr).toBeDefined(); + expect(lidarr?.name).toBe('Lidarr'); + expect(lidarr?.category).toBe('arr_stack'); + }); + + it('should be marked as available', () => { + expect(getIntegrationById('lidarr')?.status).toBe('available'); + }); + + it('should use the API connection type', () => { + expect(getIntegrationById('lidarr')?.connectionType).toBe('api'); + }); + + it('should use the dashboard-icons icon', () => { + const lidarr = getIntegrationById('lidarr'); + expect(lidarr?.iconType).toBe('dashboard-icons'); + expect(lidarr?.icon).toBe('lidarr'); + }); + + it('should have correct branding', () => { + const lidarr = getIntegrationById('lidarr'); + expect(lidarr?.color).toBe('#00C853'); + expect(lidarr?.bgColor).toBe('bg-green-500/10'); + }); + + it('should have correct default port', () => { + expect(getIntegrationById('lidarr')?.defaultPort).toBe(8686); + }); + + it('should support the activity log', () => { + expect(getIntegrationById('lidarr')?.capabilities.activityLog).toBe(true); + }); + + it('should have required password API key and log path config fields', () => { + const lidarr = getIntegrationById('lidarr'); + const fieldNames = lidarr?.configFields.map((f) => f.name); + expect(fieldNames).toContain('name'); + expect(fieldNames).toContain('url'); + expect(fieldNames).toContain('apiKey'); + expect(fieldNames).toContain('logPath'); + + const apiKey = lidarr?.configFields.find((f) => f.name === 'apiKey'); + expect(apiKey?.required).toBe(true); + expect(apiKey?.type).toBe('password'); + }); + + it('should be found by search', () => { + const results = searchIntegrations('lidarr'); + expect(results.some((i) => i.id === 'lidarr')).toBe(true); + }); + }); + describe('getIntegrationsByCategory', () => { it('should include Whisparr in arr_stack category', () => { const arrIntegrations = getIntegrationsByCategory('arr_stack'); @@ -118,11 +173,12 @@ describe('Integrations Registry', () => { it('should include other available *arr integrations', () => { const available = getAvailableIntegrations(); const ids = available.map((i) => i.id); - // Sonarr, Radarr, Prowlarr, and Whisparr should be available + // Sonarr, Radarr, Prowlarr, Whisparr, and Lidarr should be available expect(ids).toContain('sonarr'); expect(ids).toContain('radarr'); expect(ids).toContain('prowlarr'); expect(ids).toContain('whisparr'); + expect(ids).toContain('lidarr'); }); }); diff --git a/apps/frontend/src/lib/integrations.ts b/apps/frontend/src/lib/integrations.ts index 37641c8..f4d69d5 100644 --- a/apps/frontend/src/lib/integrations.ts +++ b/apps/frontend/src/lib/integrations.ts @@ -399,7 +399,7 @@ export const integrations: Integration[] = [ name: 'Lidarr', description: 'Music collection manager for Usenet and BitTorrent', category: 'arr_stack', - status: 'coming_soon', + status: 'available', connectionType: 'api', icon: 'lidarr', iconType: 'dashboard-icons', diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index edbb744..22b1c9b 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -72,6 +72,7 @@ services: - ./packages/provider-plex/src:/app/packages/provider-plex/src - ./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 - ./apps/backend/src:/app/apps/backend/src # Mount config files for changes - ./apps/backend/nest-cli.json:/app/apps/backend/nest-cli.json @@ -90,6 +91,7 @@ services: - ${RADARR_LOGS_PATH:-./logs/radarr}:/radarr-logs:ro - ${PROWLARR_LOGS_PATH:-./logs/prowlarr}:/prowlarr-logs:ro - ${WHISPARR_LOGS_PATH:-./logs/whisparr}:/whisparr-logs:ro + - ${LIDARR_LOGS_PATH:-./logs/lidarr}:/lidarr-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 @@ -98,6 +100,7 @@ services: - ${RADARR_LOGS_PATH_1:-./logs/radarr-1}:/radarr-logs-1:ro - ${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 - ${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 @@ -105,6 +108,7 @@ services: - ${RADARR_LOGS_PATH_2:-./logs/radarr-2}:/radarr-logs-2:ro - ${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 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 d813880..220bb7d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -75,6 +75,8 @@ services: PROWLARR_API_KEY: ${PROWLARR_API_KEY:-} WHISPARR_URL: ${WHISPARR_URL:-} WHISPARR_API_KEY: ${WHISPARR_API_KEY:-} + LIDARR_URL: ${LIDARR_URL:-} + LIDARR_API_KEY: ${LIDARR_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:-} @@ -84,6 +86,7 @@ services: RADARR_LOGS_PATH: ${RADARR_LOGS_PATH:-} PROWLARR_LOGS_PATH: ${PROWLARR_LOGS_PATH:-} WHISPARR_LOGS_PATH: ${WHISPARR_LOGS_PATH:-} + LIDARR_LOGS_PATH: ${LIDARR_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:-} @@ -107,6 +110,9 @@ services: WHISPARR_LOGS_PATH_1: ${WHISPARR_LOGS_PATH_1:-} WHISPARR_LOGS_PATH_2: ${WHISPARR_LOGS_PATH_2:-} WHISPARR_LOGS_PATH_3: ${WHISPARR_LOGS_PATH_3:-} + LIDARR_LOGS_PATH_1: ${LIDARR_LOGS_PATH_1:-} + LIDARR_LOGS_PATH_2: ${LIDARR_LOGS_PATH_2:-} + LIDARR_LOGS_PATH_3: ${LIDARR_LOGS_PATH_3:-} # Admin password reset - set to 'true' to reset admin account on startup ADMIN_PASSWORD_RESET: ${ADMIN_PASSWORD_RESET:-} volumes: @@ -119,6 +125,7 @@ services: - ${RADARR_LOGS_PATH:-/dev/null}:/radarr-logs:ro - ${PROWLARR_LOGS_PATH:-/dev/null}:/prowlarr-logs:ro - ${WHISPARR_LOGS_PATH:-/dev/null}:/whisp-logs:ro + - ${LIDARR_LOGS_PATH:-/dev/null}:/lidarr-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 @@ -141,6 +148,9 @@ services: - ${WHISPARR_LOGS_PATH_1:-/dev/null}:/whisp-logs-1:ro - ${WHISPARR_LOGS_PATH_2:-/dev/null}:/whisp-logs-2:ro - ${WHISPARR_LOGS_PATH_3:-/dev/null}:/whisp-logs-3:ro + - ${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 healthcheck: test: ['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://localhost:4000/api'] interval: 30s diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index fbc0555..06c3344 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -337,6 +337,13 @@ export interface MediaServerProvider { * (e.g., stack trace lines, wrapped messages) */ isLogContinuation?(line: string): boolean; + + /** + * Enrich an assembled multi-line entry (e.g. extract exception details from + * its continuation lines). The caller attaches the stack trace/raw text itself. + * Only called when the entry has at least one continuation line. + */ + finalizeLogEntry?(entry: ParsedLogEntry, continuationLines: readonly string[]): ParsedLogEntry; } /** diff --git a/packages/provider-arr/src/arr.events.spec.ts b/packages/provider-arr/src/arr.events.spec.ts new file mode 100644 index 0000000..83aae99 --- /dev/null +++ b/packages/provider-arr/src/arr.events.spec.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest'; + +import { describeArrEvent, resolveArrEventName, toArrItemId } from './arr.events.js'; + +import type { ArrEventTable } from './arr.events.js'; + +const NAMES = { 1: 'grabbed', 4: 'downloadFolderImported', 7: 'downloadFailed' } as const; + +describe('resolveArrEventName', () => { + it.each([ + [1, 'grabbed'], + [7, 'downloadFailed'], + ['4', 'downloadFolderImported'], + [' 7 ', 'downloadFailed'], + ])('maps %j to %s', (input, expected) => { + expect(resolveArrEventName(input, NAMES)).toBe(expected); + }); + + it.each([[99], ['99'], [-1], [1.5], [NaN], [Infinity]])('returns unknown for %j', (input) => { + expect(resolveArrEventName(input, NAMES)).toBe('unknown'); + }); + + it.each([[''], [' '], [null], [undefined], [{}], [[]], [true]])( + 'returns unknown for %j', + (input) => { + expect(resolveArrEventName(input, NAMES)).toBe('unknown'); + } + ); + + it('returns unmatched strings unchanged', () => { + expect(resolveArrEventName('trackFileImported', NAMES)).toBe('trackFileImported'); + }); + + it('matches known names case-insensitively', () => { + expect(resolveArrEventName('Grabbed', NAMES)).toBe('grabbed'); + expect(resolveArrEventName('DownloadFailed', NAMES)).toBe('downloadFailed'); + expect(resolveArrEventName(' DOWNLOADFAILED ', NAMES)).toBe('downloadFailed'); + }); + + it('does not resolve inherited object keys', () => { + expect(resolveArrEventName('constructor', {})).toBe('constructor'); + expect(resolveArrEventName(0, {})).toBe('unknown'); + }); +}); + +describe('describeArrEvent', () => { + const table: ArrEventTable = { + downloadFailed: { label: 'Download Failed', severity: 'error' }, + albumImportIncomplete: { label: 'Import Incomplete', severity: 'warn' }, + grabbed: { label: 'Grabbed', severity: 'info' }, + }; + + it('returns the table entry for a known name', () => { + expect(describeArrEvent('albumImportIncomplete', table)).toEqual({ + label: 'Import Incomplete', + severity: 'warn', + }); + }); + + it('matches names case-insensitively', () => { + expect(describeArrEvent('DOWNLOADFAILED', table)).toBe(table['downloadFailed']); + }); + + it('defaults unknown names containing "fail" to error', () => { + expect(describeArrEvent('bookImportFailed', table)).toEqual({ + label: 'Book Import Failed', + severity: 'error', + }); + }); + + it('defaults other unknown names to info with a humanized label', () => { + expect(describeArrEvent('bookFileRetagged', table)).toEqual({ + label: 'Book File Retagged', + severity: 'info', + }); + }); + + it('handles the unknown sentinel and empty table', () => { + expect(describeArrEvent('unknown', {})).toEqual({ label: 'Unknown', severity: 'info' }); + expect(describeArrEvent('', {})).toEqual({ label: 'Unknown', severity: 'info' }); + }); + + it('does not resolve inherited object keys', () => { + expect(describeArrEvent('constructor', {}).severity).toBe('info'); + }); +}); + +describe('toArrItemId', () => { + it.each([ + [1, '1'], + [42, '42'], + ])('stringifies %j', (input, expected) => { + expect(toArrItemId(input)).toBe(expected); + }); + + it.each([[0], [-1], [1.5], [NaN], [Infinity], ['5'], [null], [undefined], [{}]])( + 'returns undefined for %j', + (input) => { + expect(toArrItemId(input)).toBeUndefined(); + } + ); +}); diff --git a/packages/provider-arr/src/arr.events.ts b/packages/provider-arr/src/arr.events.ts new file mode 100644 index 0000000..6f65bdc --- /dev/null +++ b/packages/provider-arr/src/arr.events.ts @@ -0,0 +1,78 @@ +/** + * Shared helpers for resolving and describing *arr history event types. + * + * *arr APIs report `eventType` either as an enum name ("downloadFailed"), as a + * numeric enum value (4) or as a numeric string ("4"), depending on the app/version. + * Per-app providers supply their own enum table and display table. + */ + +export type ArrEventSeverity = 'info' | 'warn' | 'error'; + +/** Display label and severity for a single event */ +export interface ArrEventDescriptor { + readonly label: string; + readonly severity: ArrEventSeverity; +} + +/** Event name (camelCase, as reported by the API) to descriptor */ +export type ArrEventTable = Readonly>; + +/** + * Resolve a raw `eventType` to an event name. + * + * - integer number or numeric string: looked up in `names`; absent -> 'unknown' + * - any other non-blank string: matched case-insensitively against the known names in + * `names` (so 'DownloadFailed' -> 'downloadFailed'); if none match, returned unchanged + * - blank string, non-string/non-number, NaN/Infinity, fractional: 'unknown' + */ +export function resolveArrEventName( + eventType: unknown, + names: Readonly> +): string { + if (typeof eventType === 'number') { + return Number.isInteger(eventType) ? (names[eventType] ?? 'unknown') : 'unknown'; + } + + if (typeof eventType === 'string') { + const trimmed = eventType.trim(); + if (trimmed === '') return 'unknown'; + if (/^\d+$/.test(trimmed)) return names[Number(trimmed)] ?? 'unknown'; + const lower = trimmed.toLowerCase(); + const known = Object.values(names).find((name) => name.toLowerCase() === lower); + return known ?? eventType; + } + + return 'unknown'; +} + +/** + * Convert a raw record id to an item id string. + * Returns the stringified id only for positive finite integers (numbers only; strings + * are not accepted), otherwise undefined (e.g. 0, -1, NaN, null). + */ +export function toArrItemId(id: unknown): string | undefined { + return typeof id === 'number' && Number.isInteger(id) && id > 0 ? String(id) : undefined; +} + +function humanize(name: string): string { + const spaced = name.replace(/([a-z0-9])([A-Z])/g, '$1 $2').trim(); + return spaced === '' ? 'Unknown' : spaced.charAt(0).toUpperCase() + spaced.slice(1); +} + +/** + * Describe an event by name using a per-app table. + * + * Lookup is exact first, then case-insensitive. Names missing from the table get + * a humanized label and a default severity: 'error' when the name contains "fail", + * otherwise 'info'. + */ +export function describeArrEvent(name: string, table: ArrEventTable): ArrEventDescriptor { + if (Object.hasOwn(table, name)) return table[name] as ArrEventDescriptor; + + const lower = name.toLowerCase(); + for (const [key, descriptor] of Object.entries(table)) { + if (key.toLowerCase() === lower) return descriptor; + } + + return { label: humanize(name), severity: /fail/i.test(name) ? 'error' : 'info' }; +} diff --git a/packages/provider-arr/src/arr.parser.spec.ts b/packages/provider-arr/src/arr.parser.spec.ts index 3480f38..d96f8e3 100644 --- a/packages/provider-arr/src/arr.parser.spec.ts +++ b/packages/provider-arr/src/arr.parser.spec.ts @@ -8,6 +8,7 @@ import { parseArrLogLine, isArrLogContinuation, parseArrLogLineWithContext, + finalizeArrLogEntry, extractArrMetadata, getArrSeverityBoost, ARR_LOG_FILE_CONFIG, @@ -101,16 +102,74 @@ describe('Arr Parser', () => { expect(result?.raw).toBe(line); }); - it('should handle timestamp without milliseconds', () => { - const line = '2024-01-15 10:30:45|Info|Test|No milliseconds'; - // This should not match the NLog format since .123 is expected + it('should handle timestamp without fractional seconds', () => { + const line = '2024-01-15 10:30:45|Info|Test|No fractional seconds'; const result = parseArrLogLine(line); - // Actually checking the regex - it uses (?:\.\d{3})? which makes ms optional + expect(result).not.toBeNull(); + expect(result?.timestamp.getMilliseconds()).toBe(0); + expect(result?.message).toBe('No fractional seconds'); + }); + + describe('fractional second precision', () => { + it.each([ + ['.2', 200], + ['.25', 250], + ['.123', 123], + ['.1234', 123], + ['.1234567', 123], + ['', 0], + ])('should parse fractional seconds %j as %i ms', (fraction, expectedMs) => { + const line = `2026-10-10 12:00:01${fraction}|Info|RssSyncService|RSS Sync Completed. Reports found: 50`; + const result = parseArrLogLine(line); + + expect(result).not.toBeNull(); + expect(result?.level).toBe('info'); + expect(result?.source).toBe('RssSyncService'); + expect(result?.message).toBe('RSS Sync Completed. Reports found: 50'); + expect(result?.timestamp.getTime()).toBe( + new Date(2026, 9, 10, 12, 0, 1, expectedMs).getTime() + ); + }); + + it('documents known limitation: an 8-digit fraction is not parsed as an entry and is treated as a continuation', () => { + const line = '2026-10-10 12:00:01.12345678|Info|RssSyncService|Too many digits'; + + // Regex allows at most 7 fraction digits, so the line does not match the NLog format + expect(parseArrLogLine(line)).toBeNull(); + expect(isArrLogContinuation(line)).toBe(true); + }); }); }); describe('isArrLogContinuation', () => { + it('should not treat a one-digit fractional second line as a continuation', () => { + expect( + isArrLogContinuation('2026-10-10 12:00:01.2|Info|RssSyncService|RSS Sync Completed.') + ).toBe(false); + }); + + it('should parse a realistic multi-line exception sample', () => { + const lines = [ + '2026-10-10 12:00:05.4|Error|DownloadClientProvider|Error connecting to qBittorrent', + '', + '[v4.0.10.2544] System.Net.Http.HttpRequestException: Connection refused (localhost:8080)', + ' at System.Net.Http.HttpConnectionPool.ConnectToTcpHostAsync(String host, Int32 port, HttpRequestMessage initialRequest, Boolean async, CancellationToken cancellationToken)', + ]; + + const first = parseArrLogLine(lines[0] as string); + expect(first).not.toBeNull(); + expect(first?.level).toBe('error'); + expect(first?.source).toBe('DownloadClientProvider'); + expect(first?.timestamp.getTime()).toBe(new Date(2026, 9, 10, 12, 0, 5, 400).getTime()); + expect(isArrLogContinuation(lines[0] as string)).toBe(false); + + // Exception detail lines are not NLog entries and must be treated as continuations + expect(parseArrLogLine(lines[2] as string)).toBeNull(); + expect(isArrLogContinuation(lines[2] as string)).toBe(true); + expect(isArrLogContinuation(lines[3] as string)).toBe(true); + }); + it('should detect stack trace lines', () => { expect(isArrLogContinuation(' at System.Threading.Task.Run()')).toBe(true); expect(isArrLogContinuation(' at MyClass.MyMethod()')).toBe(true); @@ -146,45 +205,84 @@ describe('Arr Parser', () => { }); describe('parseArrLogLineWithContext', () => { - const baseContext = { - continuationLines: [] as string[], - filePath: '/test/arr.log', - lineNumber: 1, - }; - - it('should parse a standalone entry', () => { - const line = '2024-01-15 10:30:45.123|Info|Test|Message'; - const context = { ...baseContext }; - const result = parseArrLogLineWithContext(line, context); - - // First call stores in previousEntry, returns null for entry - expect(result.entry).toBeNull(); + const context = { continuationLines: [] as string[], filePath: '/test/arr.log', lineNumber: 1 }; + + it('returns the entry parsed from the current line', () => { + const result = parseArrLogLineWithContext( + '2024-01-15 10:30:45.123|Info|Test|Message', + context + ); + + expect(result.entry?.message).toBe('Message'); expect(result.isContinuation).toBe(false); + expect(result.previousComplete).toBe(false); }); - it('should identify continuation lines', () => { - const line = ' at System.Threading.Task.Run()'; - const context = { ...baseContext }; - const result = parseArrLogLineWithContext(line, context); + it('parses one-digit fraction timestamps', () => { + const result = parseArrLogLineWithContext('2026-10-10 12:00:05.4|Error|Src|Boom', context); - expect(result.entry).toBeNull(); - expect(result.isContinuation).toBe(true); + expect(result.entry?.timestamp.getTime()).toBe( + new Date(2026, 9, 10, 12, 0, 5, 400).getTime() + ); + }); + + it('flags previousComplete when a previous entry is pending', () => { + const previousEntry = parseArrLogLine('2024-01-15 10:30:45.123|Info|Test|First')!; + const result = parseArrLogLineWithContext('2024-01-15 10:30:46.123|Info|Test|Second', { + ...context, + previousEntry, + }); + + expect(result.entry?.message).toBe('Second'); + expect(result.previousComplete).toBe(true); }); - it('should complete previous entry when new entry starts', () => { - // First line - sets previousEntry - const context = { ...baseContext, continuationLines: [] as string[] }; - parseArrLogLineWithContext('2024-01-15 10:30:45.123|Info|Test|First message', context); + it('identifies continuation lines', () => { + const result = parseArrLogLineWithContext(' at System.Threading.Task.Run()', context); + + expect(result).toEqual({ entry: null, isContinuation: true, previousComplete: false }); + }); - // Second line - should complete previous + it('treats the [vX] exception line as a continuation', () => { const result = parseArrLogLineWithContext( - '2024-01-15 10:30:46.123|Info|Test|Second message', + '[v4.0.10.2544] System.InvalidOperationException: bad state', context ); - expect(result.previousComplete).toBe(true); - expect(result.entry).not.toBeNull(); - expect(result.entry?.message).toBe('First message'); + expect(result.isContinuation).toBe(true); + }); + + it('ignores blank lines without flushing the pending entry', () => { + const previousEntry = parseArrLogLine('2026-10-10 12:00:05.4|Error|Src|Boom')!; + const result = parseArrLogLineWithContext('', { ...context, previousEntry }); + + expect(result).toEqual({ entry: null, isContinuation: false, previousComplete: false }); + }); + }); + + describe('finalizeArrLogEntry', () => { + const entry = parseArrLogLine('2026-10-10 12:00:05.4|Error|Src|Boom')!; + + it('populates exception and appends its message for a [vX] prefixed line', () => { + const result = finalizeArrLogEntry(entry, [ + '[v4.0.10.2544] System.InvalidOperationException: bad state', + ' at Foo.Bar()', + ]); + + expect(result.exception).toBe('System.InvalidOperationException'); + expect(result.message).toBe('Boom - bad state'); + }); + + it('skips leading blank lines and does not duplicate a message already present', () => { + const withMsg = parseArrLogLine('2026-10-10 12:00:05.4|Error|Src|bad state happened')!; + const result = finalizeArrLogEntry(withMsg, ['', 'System.IO.IOException: bad state']); + + expect(result.exception).toBe('System.IO.IOException'); + expect(result.message).toBe('bad state happened'); + }); + + it('returns the entry unchanged when there is no exception line', () => { + expect(finalizeArrLogEntry(entry, [' at Foo.Bar()'])).toBe(entry); }); }); diff --git a/packages/provider-arr/src/arr.parser.ts b/packages/provider-arr/src/arr.parser.ts index 5a860bb..24644b0 100644 --- a/packages/provider-arr/src/arr.parser.ts +++ b/packages/provider-arr/src/arr.parser.ts @@ -173,10 +173,12 @@ export const ARR_LOG_FILE_CONFIG: LogFileConfig = { /** * NLog timestamp + metadata regex - * Matches: 2024-01-15 10:30:45.123|Level|Component|Message + * Matches: 2024-01-15 10:30:45.1|Level|Component|Message + * The fractional second is optional and may have 1-7 digits: *arr apps log with + * the NLog layout `yyyy-MM-dd HH:mm:ss.f` (one digit); custom layouts may log up to 7 digits. */ const NLOG_LINE_REGEX = - /^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d{3})?)\|(\w+)\|([^|]+)\|(.*)$/; + /^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d{1,7})?)\|(\w+)\|([^|]+)\|(.*)$/; /** * Log level mapping from NLog format @@ -261,93 +263,65 @@ export function isArrLogContinuation(line: string): boolean { } /** - * Parse a log line with context for multi-line entry support + * Parse a log line with context for multi-line entry support. * - * Handles: - * - Regular single-line entries - * - Multi-line entries with stack traces - * - Exception details spanning multiple lines + * Follows the `MediaServerProvider.parseLogLineWithContext` contract: `entry` is the + * entry parsed from THIS line (not the previous one), continuation lines are flagged with + * `isContinuation` so the caller can buffer them, and `previousComplete` tells the caller + * the previously pending entry is finished. Attaching continuation lines is the caller's + * job; use `finalizeArrLogEntry` to enrich the assembled entry with exception details. */ export function parseArrLogLineWithContext(line: string, context: LogParseContext): LogParseResult { - const trimmed = line.trim(); - - // Check if this is a continuation line - if (isArrLogContinuation(line)) { - // Add to continuation buffer - context.continuationLines.push(line); - - return { - entry: null, - isContinuation: true, - previousComplete: false, - }; + // Blank lines carry no information. NLog emits one between the header and the + // exception (`${newline}${exception}`), so they must neither flush the pending + // entry nor be added to its stack trace. + if (line.trim() === '') { + return { entry: null, isContinuation: false, previousComplete: false }; } - // This is a new log entry - first, check if we need to complete the previous entry - let completedEntry: ParsedLogEntry | null = null; - - if (context.previousEntry && context.continuationLines.length > 0) { - // Check if it's an exception - const exceptionMatch = context.continuationLines[0]?.match( - /^((?:System|Microsoft|NLog|NzbDrone)\.\w+(?:\.\w+)*Exception):\s*(.*)$/i - ); - - let message = context.previousEntry.message; - let exception: string | undefined; - - if (exceptionMatch) { - exception = exceptionMatch[1]; - // Append exception message to the main message if it's different - const exceptionMessage = exceptionMatch[2]; - if ( - exceptionMessage !== undefined && - exceptionMessage !== '' && - !message.includes(exceptionMessage) - ) { - message = `${message} - ${exceptionMatch[2]}`; - } - } - - // Build the complete entry with continuation lines - completedEntry = { - ...context.previousEntry, - message, - exception, - stackTrace: context.continuationLines.join('\n'), - raw: context.previousEntry.raw + '\n' + context.continuationLines.join('\n'), - }; - } else if (context.previousEntry) { - // Previous entry is complete with no continuation - completedEntry = context.previousEntry; - } - - // Clear continuation buffer - context.continuationLines = []; - - // Parse the new line - const parsed = parseArrLogLine(trimmed); - - if (parsed) { - // Store as new previous entry - context.previousEntry = parsed; - - return { - entry: completedEntry, - isContinuation: false, - previousComplete: completedEntry !== null, - }; + if (isArrLogContinuation(line)) { + return { entry: null, isContinuation: true, previousComplete: false }; } - // Line didn't match expected format - treat as standalone - delete context.previousEntry; + const entry = parseArrLogLine(line); return { - entry: completedEntry, + entry, isContinuation: false, - previousComplete: completedEntry !== null, + previousComplete: entry !== null && context.previousEntry !== undefined, }; } +/** + * Enrich an assembled entry with exception details taken from its continuation lines. + * + * *arr prefixes the exception with its version, e.g. "[v4.0.10.2544] System.Foo: msg". + * Sets `exception` and appends the exception message to `message` when not already present. + */ +export function finalizeArrLogEntry( + entry: ParsedLogEntry, + continuationLines: readonly string[] +): ParsedLogEntry { + const first = continuationLines.find((l) => l.trim() !== ''); + const match = first + ?.trim() + .replace(/^\[v[\d.]+\]\s*/, '') + .match(/^((?:System|Microsoft|NLog|NzbDrone)\.\w+(?:\.\w+)*Exception):\s*(.*)$/i); + if (!match) return entry; + + const [, exception, exceptionMessage] = match; + if (exception === undefined || exception === '') return entry; + + const message = + exceptionMessage !== undefined && + exceptionMessage !== '' && + !entry.message.includes(exceptionMessage) + ? `${entry.message} - ${exceptionMessage}` + : entry.message; + + return { ...entry, message, exception }; +} + /** * Extract metadata from arr log messages * diff --git a/packages/provider-arr/src/arr.provider.ts b/packages/provider-arr/src/arr.provider.ts index ba476cb..9cfc771 100644 --- a/packages/provider-arr/src/arr.provider.ts +++ b/packages/provider-arr/src/arr.provider.ts @@ -7,6 +7,7 @@ import { ArrClient } from './arr.client.js'; import { parseArrLogLine, parseArrLogLineWithContext, + finalizeArrLogEntry, isArrLogContinuation, ARR_LOG_FILE_CONFIG, } from './arr.parser.js'; @@ -147,6 +148,14 @@ export abstract class ArrBaseProvider implements MediaServerProvider { return parseArrLogLineWithContext(line, context); } + /** + * Enrich an assembled multi-line entry with exception details. + * Optional hook: only called by the file processor when the entry has continuation lines. + */ + finalizeLogEntry(entry: ParsedLogEntry, continuationLines: readonly string[]): ParsedLogEntry { + return finalizeArrLogEntry(entry, continuationLines); + } + /** * Check if a line is a continuation of the previous entry */ diff --git a/packages/provider-arr/src/index.ts b/packages/provider-arr/src/index.ts index 5fe9a2f..0fcde3c 100644 --- a/packages/provider-arr/src/index.ts +++ b/packages/provider-arr/src/index.ts @@ -2,10 +2,15 @@ export { ArrBaseProvider } from './arr.provider.js'; export { ArrClient } from './arr.client.js'; +// Shared event name resolution and display helpers +export { resolveArrEventName, describeArrEvent, toArrItemId } from './arr.events.js'; +export type { ArrEventSeverity, ArrEventDescriptor, ArrEventTable } from './arr.events.js'; + // Parser utilities for file-based log ingestion export { parseArrLogLine, parseArrLogLineWithContext, + finalizeArrLogEntry, isArrLogContinuation, extractArrMetadata, getArrSeverityBoost, diff --git a/packages/provider-lidarr/package.json b/packages/provider-lidarr/package.json new file mode 100644 index 0000000..3b85974 --- /dev/null +++ b/packages/provider-lidarr/package.json @@ -0,0 +1,36 @@ +{ + "name": "@logarr/provider-lidarr", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Lidarr provider for Logarr", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "lint": "eslint src/", + "lint:fix": "eslint src/ --fix", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "clean": "rm -rf dist .turbo" + }, + "dependencies": { + "@logarr/core": "workspace:*", + "@logarr/provider-arr": "workspace:*" + }, + "devDependencies": { + "tsup": "^8.3.5", + "typescript": "^5.7.2", + "vitest": "^4.0.16" + } +} diff --git a/packages/provider-lidarr/src/index.ts b/packages/provider-lidarr/src/index.ts new file mode 100644 index 0000000..6956299 --- /dev/null +++ b/packages/provider-lidarr/src/index.ts @@ -0,0 +1,12 @@ +// Lidarr provider for Logarr +export { LidarrProvider } from './lidarr.provider.js'; + +// Types +export type { + LidarrArtist, + LidarrAlbum, + LidarrHistoryRecord, + LidarrQueueItem, +} from './lidarr.types.js'; + +export { LidarrEventTypes, LidarrEventTypeNames } from './lidarr.types.js'; diff --git a/packages/provider-lidarr/src/lidarr.provider.spec.ts b/packages/provider-lidarr/src/lidarr.provider.spec.ts new file mode 100644 index 0000000..6a06b69 --- /dev/null +++ b/packages/provider-lidarr/src/lidarr.provider.spec.ts @@ -0,0 +1,846 @@ +/** + * Tests for Lidarr provider + */ + +import { LIDARR_LOG_FILE_CONFIG } from '@logarr/provider-arr'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +import { LidarrProvider } from './lidarr.provider.js'; +import { LidarrEventTypes, LidarrEventTypeNames } from './lidarr.types.js'; + +import type { LidarrHistoryRecord } from './lidarr.types.js'; +import type { NormalizedActivity } from '@logarr/core'; + +const mockFetch = vi.fn(); + +const BASE_URL = 'http://lidarr:8686'; + +const STATUS = { + appName: 'Lidarr', + instanceName: 'Lidarr', + version: '3.1.0.4875', + branch: 'master', +}; + +const ok = (data: unknown): Record => ({ + ok: true, + status: 200, + json: () => Promise.resolve(data), +}); + +const fail = (status: number, statusText: string): Record => ({ + ok: false, + status, + statusText, + text: () => Promise.resolve('bad'), +}); + +interface RouteTable { + status?: unknown; + health?: unknown; + history?: unknown; + historySince?: unknown; + queue?: unknown; +} + +/** Route mocked fetch responses by request path */ +function routeFetch(routes: RouteTable): void { + mockFetch.mockImplementation((input: string) => { + const path = new URL(input).pathname; + const pick = (value: unknown): Record => value as Record; + switch (path) { + case '/api/v1/system/status': + return Promise.resolve(pick(routes.status ?? ok(STATUS))); + case '/api/v1/health': + return Promise.resolve(pick(routes.health ?? ok([]))); + case '/api/v1/history': + return Promise.resolve( + pick( + routes.history ?? + ok({ + page: 1, + pageSize: 50, + sortKey: 'date', + sortDirection: 'descending', + totalRecords: 0, + records: [], + }) + ) + ); + case '/api/v1/history/since': + return Promise.resolve(pick(routes.historySince ?? ok([]))); + case '/api/v1/queue': + return Promise.resolve(pick(routes.queue ?? ok({ records: [] }))); + default: + return Promise.resolve(fail(404, 'Not Found')); + } + }); +} + +function paged(records: unknown[]): Record { + return ok({ + page: 1, + pageSize: 50, + sortKey: 'date', + sortDirection: 'descending', + totalRecords: records.length, + records, + }); +} + +const baseRecord = { + id: 42, + albumId: 7, + artistId: 3, + trackId: 99, + sourceTitle: 'Artist - Album [FLAC]', + date: '2026-10-10T11:22:33Z', + downloadId: 'DL123', + quality: { quality: { id: 6, name: 'FLAC' } }, + data: { indexer: 'NZBgeek', downloadClient: 'SABnzbd', releaseGroup: 'GRP' }, + artist: { id: 3, artistName: 'Daft Punk' }, + album: { id: 7, artistId: 3, title: 'Discovery' }, +}; + +function record(overrides: Record = {}): LidarrHistoryRecord { + return { ...baseRecord, eventType: 'grabbed', ...overrides } as unknown as LidarrHistoryRecord; +} + +describe('LidarrProvider', () => { + let provider: LidarrProvider; + + beforeEach(() => { + vi.clearAllMocks(); + mockFetch.mockReset(); + vi.stubGlobal('fetch', mockFetch); + provider = new LidarrProvider(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + /** Connect with mocked routes and return the provider */ + async function connected(routes: RouteTable = {}): Promise { + routeFetch(routes); + await provider.connect({ url: BASE_URL, apiKey: 'key' }); + mockFetch.mockClear(); + return provider; + } + + /** Normalize a single history record by driving getActivity() */ + async function normalize(rec: LidarrHistoryRecord): Promise { + await connected({ history: paged([rec]) }); + const activities = await provider.getActivity(); + const found = activities.find((a) => a.id.startsWith('lidarr-history-')); + if (!found) throw new Error('no history activity returned'); + return found; + } + + describe('provider identity', () => { + it('should have correct id', () => { + expect(provider.id).toBe('lidarr'); + }); + + it('should have correct name', () => { + expect(provider.name).toBe('Lidarr'); + }); + + it('should use the artist media type', () => { + expect((provider as unknown as { getMediaType(): string }).getMediaType()).toBe('artist'); + }); + }); + + describe('capabilities', () => { + it('should have correct capabilities', () => { + expect(provider.capabilities).toEqual({ + supportsRealTimeLogs: false, + supportsActivityLog: true, + supportsSessions: false, + supportsWebhooks: true, + supportsPlaybackHistory: false, + }); + }); + + it('should return no sessions or users', async () => { + await expect(provider.getSessions()).resolves.toEqual([]); + await expect(provider.getUsers()).resolves.toEqual([]); + }); + }); + + describe('getLogPaths', () => { + it('should return default log paths when no config', async () => { + const paths = await provider.getLogPaths(); + + expect(paths).toContain('/lidarr-logs'); + expect(paths).toContain('/config/logs'); + expect(paths).toContain('~/.config/Lidarr/logs'); + expect(paths).toContain('/var/lib/lidarr/logs'); + expect(paths).toContain('C:\\ProgramData\\Lidarr\\logs'); + }); + + it('should return only the configured log path when set', async () => { + await connected(); + // reconnect with a custom log path + await provider.connect({ url: BASE_URL, apiKey: 'key', logPath: '/custom' }); + + await expect(provider.getLogPaths()).resolves.toEqual(['/custom']); + }); + }); + + describe('getLogFileConfig', () => { + it('should return Lidarr log file config', () => { + expect(provider.getLogFileConfig()).toBe(LIDARR_LOG_FILE_CONFIG); + }); + + it('should describe real Lidarr log files', () => { + const config = provider.getLogFileConfig(); + + expect(config.defaultPaths.docker).toContain('/config/logs'); + expect(config.filePatterns).toContain('lidarr.txt'); + expect(config.filePatterns).toContain('lidarr.*.txt'); + expect(config.filePatterns).toContain('*.log'); + expect(config.encoding).toBe('utf-8'); + }); + }); + + describe('log parsing', () => { + const START = + '2026-10-10 12:00:01.2|Info|Bootstrap|Starting Lidarr - /app/lidarr/bin/Lidarr - Version 3.1.0.4875'; + const WARN = + "2026-10-10 12:00:09.4|Warn|ImportApprovedTracks|Couldn't import track /downloads/music/Artist/01 Track.flac"; + const ERROR = + '2026-10-10 12:01:22.7|Error|DownloadedTracksImportService|Import failed, path does not exist or is not accessible by Lidarr: /downloads/music/Album'; + const NEXT = '2026-10-10 12:01:30.0|Info|Foo|next'; + + it('should parse an info line with a one-digit fractional second', () => { + const entry = provider.parseLogLine(START); + + expect(entry).toMatchObject({ + level: 'info', + source: 'Bootstrap', + message: 'Starting Lidarr - /app/lidarr/bin/Lidarr - Version 3.1.0.4875', + }); + expect(entry?.timestamp).toBeInstanceOf(Date); + }); + + it('should parse warn and error levels', () => { + expect(provider.parseLogLine(WARN)).toMatchObject({ + level: 'warn', + source: 'ImportApprovedTracks', + }); + expect(provider.parseLogLine(ERROR)).toMatchObject({ + level: 'error', + source: 'DownloadedTracksImportService', + }); + }); + + it('should preserve pipes inside the message', () => { + const entry = provider.parseLogLine('2026-10-10 12:00:02.0|Info|Foo|a|b|c'); + + expect(entry?.source).toBe('Foo'); + expect(entry?.message).toBe('a|b|c'); + }); + + it('should return null for non-log lines', () => { + expect(provider.parseLogLine(' at System.IO.Foo()')).toBeNull(); + expect(provider.parseLogLine('')).toBeNull(); + }); + + it('should detect continuation lines', () => { + expect( + provider.isLogContinuation( + ' at NzbDrone.Core.MediaFiles.DiskScanService.GetAudioFiles(String path)' + ) + ).toBe(true); + expect( + provider.isLogContinuation( + "[v3.1.0.4875] System.IO.DirectoryNotFoundException: Could not find a part of the path '/x'." + ) + ).toBe(true); + expect(provider.isLogContinuation(START)).toBe(false); + expect(provider.isLogContinuation('')).toBe(false); + }); + + it('should return header entries, flag continuations and ignore blank lines', () => { + const context = { continuationLines: [] as string[], filePath: 'lidarr.txt', lineNumber: 0 }; + const exceptionLine = + "[v3.1.0.4875] System.IO.DirectoryNotFoundException: Could not find a part of the path '/downloads/music/Album'."; + const lines = [ + ERROR, + '', + exceptionLine, + ' at System.IO.Enumeration.FileSystemEnumerator`1.CreateDirectoryHandle(String path, Boolean ignoreNotFound)', + ' at NzbDrone.Core.MediaFiles.DiskScanService.GetAudioFiles(String path, Boolean allDirectories)', + NEXT, + ]; + + const results = lines.map((line, index) => { + context.lineNumber = index + 1; + return provider.parseLogLineWithContext(line, context); + }); + + // header -> current entry; blank -> ignored; exception/stack lines -> continuation + expect(results.map((r) => r.isContinuation)).toEqual([false, false, true, true, true, false]); + expect(results[0]?.entry).toMatchObject({ + level: 'error', + source: 'DownloadedTracksImportService', + }); + expect(results[1]?.entry).toBeNull(); + expect(results[2]?.entry).toBeNull(); + expect(results[5]?.entry).toMatchObject({ level: 'info', source: 'Foo', message: 'next' }); + }); + + it('should populate exception from the [vX] prefixed continuation via finalizeLogEntry', () => { + const header = provider.parseLogLine(ERROR); + if (!header) throw new Error('header did not parse'); + const continuation = [ + "[v3.1.0.4875] System.IO.DirectoryNotFoundException: Could not find a part of the path '/downloads/music/Album'.", + ' at NzbDrone.Core.MediaFiles.DiskScanService.GetAudioFiles(String path, Boolean allDirectories)', + ]; + + const finalized = provider.finalizeLogEntry(header, continuation); + + expect(finalized.exception).toBe('System.IO.DirectoryNotFoundException'); + expect(finalized.level).toBe('error'); + expect(finalized.message).toContain('Import failed, path does not exist'); + expect(finalized.message).toContain('Could not find a part of the path'); + }); + + it('should provide correlation patterns', () => { + const names = provider.getCorrelationPatterns().map((p) => p.name); + + expect(names).toContain('downloadId'); + expect(names).toContain('indexer'); + }); + }); + + describe('HTTP behaviour', () => { + it('should call API v1 system status with X-Api-Key and strip trailing slash', async () => { + routeFetch({}); + + await provider.connect({ url: `${BASE_URL}/`, apiKey: 'key' }); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(url).toBe('http://lidarr:8686/api/v1/system/status'); + expect((init.headers as Record)['X-Api-Key']).toBe('key'); + }); + + it('should report a successful connection test', async () => { + await connected(); + + await expect(provider.testConnection()).resolves.toEqual({ + connected: true, + serverInfo: { name: 'Lidarr', version: '3.1.0.4875', id: 'Lidarr-master' }, + }); + }); + + it('should prefer a custom instance name and fall back to appName when empty', async () => { + await connected({ status: ok({ ...STATUS, instanceName: 'Music' }) }); + const named = await provider.testConnection(); + expect(named.serverInfo?.name).toBe('Music'); + + await connected({ status: ok({ ...STATUS, instanceName: '' }) }); + const fallback = await provider.testConnection(); + expect(fallback.serverInfo?.name).toBe('Lidarr'); + }); + + it('should return server info', async () => { + await connected(); + + await expect(provider.getServerInfo()).resolves.toEqual({ + name: 'Lidarr', + version: '3.1.0.4875', + id: 'Lidarr-master', + }); + }); + + it('should reject connect() on 401 with an Arr API error', async () => { + routeFetch({ status: fail(401, 'Unauthorized') }); + + await expect(provider.connect({ url: BASE_URL, apiKey: 'wrong' })).rejects.toThrow( + /Arr API error.*HTTP 401/s + ); + }); + + it('should report not connected from testConnection after a failed connect', async () => { + routeFetch({ status: fail(401, 'Unauthorized') }); + await provider.connect({ url: BASE_URL, apiKey: 'wrong' }).catch(() => undefined); + + const result = await provider.testConnection(); + + expect(result.connected).toBe(false); + }); + + it('should report not connected when the status call fails later', async () => { + await connected(); + routeFetch({ status: fail(401, 'Unauthorized') }); + + const result = await provider.testConnection(); + + expect(result.connected).toBe(false); + expect(result.error).toContain('HTTP 401'); + }); + + it('should throw when used before connecting', async () => { + await expect(provider.getActivity()).rejects.toThrow('Not connected'); + }); + + describe('network failures', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should retry twice and then reject', async () => { + mockFetch.mockRejectedValue(new TypeError('fetch failed')); + + const promise = provider.connect({ url: BASE_URL, apiKey: 'key' }); + const assertion = expect(promise).rejects.toThrow(/Network error/); + await vi.runAllTimersAsync(); + await assertion; + + expect(mockFetch).toHaveBeenCalledTimes(3); + }); + }); + }); + + describe('getActivity requests', () => { + it('should request recent history with artist and album included', async () => { + await connected({ history: paged([]) }); + + await provider.getActivity(); + + const historyCall = mockFetch.mock.calls + .map(([url]) => new URL(url as string)) + .find((u) => u.pathname === '/api/v1/history'); + expect(historyCall).toBeDefined(); + const params = historyCall?.searchParams; + expect(params?.get('page')).toBe('1'); + expect(params?.get('pageSize')).toBe('50'); + expect(params?.get('sortKey')).toBe('date'); + expect(params?.get('sortDirection')).toBe('descending'); + expect(params?.get('includeArtist')).toBe('true'); + expect(params?.get('includeAlbum')).toBe('true'); + }); + + it('should use history/since when a since date is given', async () => { + await connected({ historySince: ok([record()]) }); + + const activities = await provider.getActivity(new Date('2026-10-10T10:00:00Z')); + + const urls = mockFetch.mock.calls.map(([url]) => new URL(url as string)); + const sinceCall = urls.find((u) => u.pathname === '/api/v1/history/since'); + expect(sinceCall).toBeDefined(); + expect(sinceCall?.searchParams.get('date')).toBe('2026-10-10T10:00:00.000Z'); + expect(sinceCall?.searchParams.get('includeArtist')).toBe('true'); + expect(sinceCall?.searchParams.get('includeAlbum')).toBe('true'); + expect(urls.some((u) => u.pathname === '/api/v1/history')).toBe(false); + expect(activities.filter((a) => a.id === 'lidarr-history-42')).toHaveLength(1); + }); + + it('should never call API v3', async () => { + await connected(); + + await provider.getActivity(); + + for (const [url] of mockFetch.mock.calls) { + expect(url as string).toContain('/api/v1/'); + expect(url as string).not.toContain('/api/v3/'); + } + }); + }); + + describe('health and queue activities', () => { + it('should convert warning and error health checks, ignoring ok', async () => { + await connected({ + health: ok([ + { + source: 'IndexerStatusCheck', + type: 'warning', + message: 'Indexers unavailable due to failures: X', + wikiUrl: '', + }, + { source: 'RootFolderCheck', type: 'error', message: 'Missing root folder: /music' }, + { source: 'UpdateCheck', type: 'ok', message: 'fine' }, + ]), + }); + + const activities = await provider.getActivity(); + + expect(activities).toHaveLength(2); + expect(activities[0]).toMatchObject({ type: 'health_warning', severity: 'warn' }); + expect(activities[1]).toMatchObject({ type: 'health_error', severity: 'error' }); + }); + + it('should convert queue warnings and exclude healthy items', async () => { + await connected({ + queue: ok({ + records: [ + { + downloadId: 'ABC', + title: 'Artist - Album', + trackedDownloadStatus: 'warning', + statusMessages: [ + { title: 't', messages: ['No files found are eligible for import'] }, + ], + }, + { downloadId: 'DEF', title: 'Fine', trackedDownloadStatus: 'ok', statusMessages: [] }, + ], + }), + }); + + const activities = await provider.getActivity(); + + expect(activities).toHaveLength(1); + expect(activities[0]).toMatchObject({ + type: 'queue_warning', + severity: 'warn', + name: 'Queue issue: Artist - Album', + overview: 'No files found are eligible for import', + }); + }); + + it('should still return history and queue when health fails', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + await connected({ + health: fail(401, 'Unauthorized'), + history: paged([record()]), + queue: ok({ + records: [ + { downloadId: 'ABC', title: 'T', trackedDownloadStatus: 'warning', statusMessages: [] }, + ], + }), + }); + + const activities = await provider.getActivity(); + + expect(activities.map((a) => a.type)).toEqual(['grabbed', 'queue_warning']); + errorSpy.mockRestore(); + }); + + it('should still return health and queue when history fails', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + await connected({ + health: ok([{ source: 'S', type: 'error', message: 'm' }]), + history: fail(404, 'Not Found'), + queue: ok({ + records: [ + { downloadId: 'ABC', title: 'T', trackedDownloadStatus: 'warning', statusMessages: [] }, + ], + }), + }); + + const activities = await provider.getActivity(); + + expect(activities.map((a) => a.type)).toEqual(['health_error', 'queue_warning']); + errorSpy.mockRestore(); + }); + }); + + describe('history normalization', () => { + it('should normalize a grabbed event completely', async () => { + const activity = await normalize(record({ eventType: 'grabbed' })); + + expect(activity).toEqual({ + id: 'lidarr-history-42', + type: 'grabbed', + name: 'Grabbed: Daft Punk - Discovery', + overview: 'Release grabbed: Artist - Album [FLAC]', + severity: 'info', + timestamp: new Date('2026-10-10T11:22:33Z'), + itemId: '7', + metadata: { + artistId: 3, + albumId: 7, + trackId: 99, + mediaType: 'artist', + quality: 'FLAC', + downloadId: 'DL123', + indexer: 'NZBgeek', + downloadClient: 'SABnzbd', + releaseGroup: 'GRP', + }, + }); + }); + + const table: readonly [number, string, 'info' | 'warn' | 'error', string][] = [ + [1, 'grabbed', 'info', 'Grabbed:'], + [2, 'artistFolderImported', 'info', 'Artist Folder Imported:'], + [3, 'trackFileImported', 'info', 'Imported:'], + [4, 'downloadFailed', 'error', 'Download Failed:'], + [5, 'trackFileDeleted', 'info', 'Deleted:'], + [6, 'trackFileRenamed', 'info', 'Renamed:'], + [7, 'albumImportIncomplete', 'warn', 'Import Incomplete:'], + [8, 'downloadImported', 'info', 'Download Imported:'], + [9, 'trackFileRetagged', 'info', 'Retagged:'], + [10, 'downloadIgnored', 'info', 'Download Ignored:'], + ]; + + it.each(table)('should map string eventType %i (%s)', async (_n, name, severity, prefix) => { + const activity = await normalize(record({ eventType: name })); + + expect(activity.type).toBe(name); + expect(activity.severity).toBe(severity); + expect(activity.name.startsWith(prefix)).toBe(true); + }); + + it.each(table)('should map numeric eventType %i (%s)', async (n, name, severity, prefix) => { + const activity = await normalize(record({ eventType: n })); + + expect(activity.type).toBe(name); + expect(activity.severity).toBe(severity); + expect(activity.name.startsWith(prefix)).toBe(true); + }); + + it.each(table)('should map numeric-string eventType "%i" (%s)', async (n, name, severity) => { + const activity = await normalize(record({ eventType: String(n) })); + + expect(activity.type).toBe(name); + expect(activity.severity).toBe(severity); + }); + + it('should not default imported tracks to grab', async () => { + const activity = await normalize(record({ eventType: 'trackFileImported' })); + + expect(activity.type).toBe('trackFileImported'); + expect(activity.name).toBe('Imported: Daft Punk - Discovery'); + expect(activity.overview).toBe('Track imported: Artist - Album [FLAC]'); + }); + + it('should resolve event types case-insensitively', async () => { + const activity = await normalize(record({ eventType: 'DownloadFailed' })); + + expect(activity.type).toBe('downloadFailed'); + expect(activity.severity).toBe('error'); + }); + + it('should use data.message for failed downloads when present', async () => { + const withMessage = await normalize( + record({ + eventType: 'downloadFailed', + data: { message: 'Unable to connect to download client' }, + }) + ); + expect(withMessage.overview).toBe('Unable to connect to download client'); + + const without = await normalize(record({ eventType: 'downloadFailed' })); + expect(without.overview).toBe('Failed to download: Artist - Album [FLAC]'); + }); + + it('should use data.message for incomplete imports as a warning', async () => { + const activity = await normalize( + record({ eventType: 'albumImportIncomplete', data: { message: 'Missing 2 tracks' } }) + ); + + expect(activity.severity).toBe('warn'); + expect(activity.overview).toBe('Missing 2 tracks'); + }); + + it('should append the reason on deleted track files', async () => { + const activity = await normalize( + record({ eventType: 'trackFileDeleted', data: { reason: 'Upgrade' } }) + ); + + expect(activity.overview).toContain('Track file deleted: Artist - Album [FLAC]'); + expect(activity.overview).toContain('Upgrade'); + }); + + it('should fall back for missing artist and album', async () => { + const noBoth = await normalize(record({ artist: undefined, album: undefined })); + expect(noBoth.name).toBe('Grabbed: Unknown Artist'); + + const noAlbum = await normalize(record({ album: undefined })); + expect(noAlbum.name).toBe('Grabbed: Daft Punk'); + }); + + it('should keep unrecognised string event types as info', async () => { + const activity = await normalize(record({ eventType: 'somethingNew' })); + + expect(activity.type).toBe('somethingNew'); + expect(activity.severity).toBe('info'); + expect(activity.name).toBe('Something New: Daft Punk - Discovery'); + expect(activity.overview).toBe('Artist - Album [FLAC]'); + }); + + it.each([0, 'unknown', 99, 'somethingElse'])( + 'should treat unknown-ish eventType %j as info and never as grab', + async (eventType) => { + const activity = await normalize(record({ eventType })); + + expect(activity.severity).toBe('info'); + expect(activity.type).not.toBe('grabbed'); + expect(activity.type).not.toBe('grab'); + } + ); + + it.each([ + [0, undefined], + [-1, undefined], + [42, '42'], + ])('should map albumId %i to itemId %s', async (albumId, expected) => { + const activity = await normalize(record({ albumId, album: undefined })); + + expect(activity.itemId).toBe(expected); + }); + + it.each(['importFailed', 'SomethingFAILED'])( + 'should treat unknown event type %s containing "fail" as error', + async (eventType) => { + const activity = await normalize(record({ eventType })); + + expect(activity.type).toBe(eventType); + expect(activity.severity).toBe('error'); + } + ); + + it('should keep other unknown event types as info', async () => { + const activity = await normalize(record({ eventType: 'somethingNew' })); + + expect(activity.severity).toBe('info'); + }); + + it('should map eventType 0 to unknown', async () => { + const activity = await normalize(record({ eventType: 0 })); + + expect(activity.type).toBe('unknown'); + }); + + it('should produce stable, unique ids', async () => { + await connected({ + history: paged([record({ id: 1 }), record({ id: 2 })]), + }); + + const first = await provider.getActivity(); + const second = await provider.getActivity(); + const ids = (list: readonly NormalizedActivity[]): string[] => list.map((a) => a.id); + + expect(ids(first)).toEqual(['lidarr-history-1', 'lidarr-history-2']); + expect(ids(second)).toEqual(ids(first)); + }); + + it.each([ + [1, 'grabbed', 'info'], + [2, 'artistFolderImported', 'info'], + [3, 'trackFileImported', 'info'], + [4, 'downloadFailed', 'error'], + [5, 'trackFileDeleted', 'info'], + [6, 'trackFileRenamed', 'info'], + [7, 'albumImportIncomplete', 'warn'], + [8, 'downloadImported', 'info'], + [9, 'trackFileRetagged', 'info'], + [10, 'downloadIgnored', 'info'], + ])('should resolve numeric eventType %i to %s with %s severity', async (id, type, severity) => { + const fromNumber = await normalize(record({ eventType: id })); + const fromNumericString = await normalize(record({ eventType: String(id) })); + + expect(fromNumber.type).toBe(type); + expect(fromNumber.severity).toBe(severity); + expect(fromNumericString.type).toBe(type); + expect(fromNumericString.severity).toBe(severity); + }); + + it.each([undefined, '', ' '])( + 'should treat blank eventType %j as unknown', + async (eventType) => { + const activity = await normalize(record({ eventType })); + + expect(activity.type).toBe('unknown'); + expect(activity.severity).toBe('info'); + } + ); + + it('should fall back to default text when data.message is empty or null', async () => { + const emptyMessage = await normalize( + record({ eventType: 'downloadFailed', data: { message: '' } }) + ); + const nullMessage = await normalize( + record({ eventType: 'albumImportIncomplete', data: { message: null } }) + ); + + expect(emptyMessage.overview).toBe('Failed to download: Artist - Album [FLAC]'); + expect(nullMessage.overview).toBe('Album import incomplete: Artist - Album [FLAC]'); + }); + + it('should omit an empty album title and leave itemId unset without albumId', async () => { + const activity = await normalize( + record({ album: { id: 7, artistId: 3, title: '' }, albumId: undefined }) + ); + + expect(activity.name).toBe('Grabbed: Daft Punk'); + expect(activity.itemId).toBeUndefined(); + }); + + it('should expose the album id as itemId and a Date timestamp', async () => { + const activity = await normalize(record()); + + expect(activity.itemId).toBe('7'); + expect(activity.timestamp).toBeInstanceOf(Date); + expect(activity.timestamp.toISOString()).toBe('2026-10-10T11:22:33.000Z'); + }); + + it('should tolerate missing optional data', async () => { + const activity = await normalize( + record({ data: undefined, quality: undefined, downloadId: undefined, trackId: undefined }) + ); + + expect(activity.id).toBe('lidarr-history-42'); + expect(activity.metadata).toMatchObject({ + artistId: 3, + albumId: 7, + mediaType: 'artist', + quality: undefined, + indexer: undefined, + downloadClient: undefined, + releaseGroup: undefined, + }); + }); + }); +}); + +describe('LidarrEventTypes', () => { + it('should have correct event type values', () => { + expect(LidarrEventTypes.Unknown).toBe(0); + expect(LidarrEventTypes.Grabbed).toBe(1); + expect(LidarrEventTypes.ArtistFolderImported).toBe(2); + expect(LidarrEventTypes.TrackFileImported).toBe(3); + expect(LidarrEventTypes.DownloadFailed).toBe(4); + expect(LidarrEventTypes.TrackFileDeleted).toBe(5); + expect(LidarrEventTypes.TrackFileRenamed).toBe(6); + expect(LidarrEventTypes.AlbumImportIncomplete).toBe(7); + expect(LidarrEventTypes.DownloadImported).toBe(8); + expect(LidarrEventTypes.TrackFileRetagged).toBe(9); + expect(LidarrEventTypes.DownloadIgnored).toBe(10); + }); + + it('should have matching event type names', () => { + expect(LidarrEventTypeNames[LidarrEventTypes.Unknown]).toBe('unknown'); + expect(LidarrEventTypeNames[LidarrEventTypes.Grabbed]).toBe('grabbed'); + expect(LidarrEventTypeNames[LidarrEventTypes.ArtistFolderImported]).toBe( + 'artistFolderImported' + ); + expect(LidarrEventTypeNames[LidarrEventTypes.TrackFileImported]).toBe('trackFileImported'); + expect(LidarrEventTypeNames[LidarrEventTypes.DownloadFailed]).toBe('downloadFailed'); + expect(LidarrEventTypeNames[LidarrEventTypes.TrackFileDeleted]).toBe('trackFileDeleted'); + expect(LidarrEventTypeNames[LidarrEventTypes.TrackFileRenamed]).toBe('trackFileRenamed'); + expect(LidarrEventTypeNames[LidarrEventTypes.AlbumImportIncomplete]).toBe( + 'albumImportIncomplete' + ); + expect(LidarrEventTypeNames[LidarrEventTypes.DownloadImported]).toBe('downloadImported'); + expect(LidarrEventTypeNames[LidarrEventTypes.TrackFileRetagged]).toBe('trackFileRetagged'); + expect(LidarrEventTypeNames[LidarrEventTypes.DownloadIgnored]).toBe('downloadIgnored'); + }); +}); + +describe('LIDARR_LOG_FILE_CONFIG', () => { + it('should have valid default paths', () => { + expect(LIDARR_LOG_FILE_CONFIG.defaultPaths.docker).toContain('/config/logs'); + expect(LIDARR_LOG_FILE_CONFIG.defaultPaths.linux.length).toBeGreaterThan(0); + expect(LIDARR_LOG_FILE_CONFIG.defaultPaths.windows.length).toBeGreaterThan(0); + expect(LIDARR_LOG_FILE_CONFIG.defaultPaths.macos.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/provider-lidarr/src/lidarr.provider.ts b/packages/provider-lidarr/src/lidarr.provider.ts new file mode 100644 index 0000000..9442faf --- /dev/null +++ b/packages/provider-lidarr/src/lidarr.provider.ts @@ -0,0 +1,227 @@ +/** + * Lidarr provider implementation + * + * Lidarr is the *arr application for music management. + * Uses API v1 and models media as artists, albums and tracks. + */ + +import { + ArrBaseProvider, + ArrClient, + LIDARR_LOG_FILE_CONFIG, + describeArrEvent, + resolveArrEventName, + toArrItemId, + type ArrEventTable, + type ArrHistoryRecordBase, + type ArrPaginatedResponse, +} from '@logarr/provider-arr'; + +import { LidarrEventTypeNames } from './lidarr.types.js'; + +import type { LidarrHistoryRecord } from './lidarr.types.js'; +import type { NormalizedActivity } from '@logarr/core'; + +/** + * Extended client for Lidarr-specific endpoints + * Lidarr uses API v1 instead of v3 + */ +class LidarrClient extends ArrClient { + constructor(baseUrl: string, apiKey: string) { + super(baseUrl, apiKey, 'v1'); + } + + /** + * Get history with artist and album information + */ + async getLidarrHistory(options?: { + page?: number; + pageSize?: number; + }): Promise> { + return this.get>('/history', { + page: options?.page ?? 1, + pageSize: options?.pageSize ?? 50, + sortKey: 'date', + sortDirection: 'descending', + includeArtist: true, + includeAlbum: true, + }); + } + + /** + * Get history since a specific date + */ + async getLidarrHistorySince(date: Date): Promise { + return this.get('/history/since', { + date: date.toISOString(), + includeArtist: true, + includeAlbum: true, + }); + } +} + +/** Display label and severity per event (camelCase name as reported by the API) */ +const LIDARR_EVENTS: ArrEventTable = { + grabbed: { label: 'Grabbed', severity: 'info' }, + artistFolderImported: { label: 'Artist Folder Imported', severity: 'info' }, + trackFileImported: { label: 'Imported', severity: 'info' }, + downloadFailed: { label: 'Download Failed', severity: 'error' }, + trackFileDeleted: { label: 'Deleted', severity: 'info' }, + trackFileRenamed: { label: 'Renamed', severity: 'info' }, + albumImportIncomplete: { label: 'Import Incomplete', severity: 'warn' }, + downloadImported: { label: 'Download Imported', severity: 'info' }, + trackFileRetagged: { label: 'Retagged', severity: 'info' }, + downloadIgnored: { label: 'Download Ignored', severity: 'info' }, +}; + +export class LidarrProvider extends ArrBaseProvider { + readonly id = 'lidarr'; + readonly name = 'Lidarr'; + + protected override createClient(url: string, apiKey: string): LidarrClient { + return new LidarrClient(url, apiKey); + } + + protected override getClient(): LidarrClient { + return super.getClient() as LidarrClient; + } + + protected getMediaType(): 'artist' { + return 'artist'; + } + + /** + * Override log paths with Lidarr-specific defaults + */ + override getLogPaths(): Promise { + if (this.config?.logPath !== undefined) { + return Promise.resolve([this.config.logPath]); + } + + // Default Lidarr log paths for different platforms + return Promise.resolve([ + '/lidarr-logs', // Docker with Logarr (mount via LIDARR_LOGS_PATH env var) + '/config/logs', // Docker (Lidarr container) + '~/.config/Lidarr/logs', // Linux / macOS + '/var/lib/lidarr/logs', // Linux alternative + 'C:\\ProgramData\\Lidarr\\logs', // Windows + ]); + } + + /** + * Override log file config with Lidarr-specific paths + */ + override getLogFileConfig(): ReturnType { + return LIDARR_LOG_FILE_CONFIG; + } + + protected override async getHistoryRecords( + since?: Date + ): Promise { + const client = this.getClient(); + + if (since) { + return client.getLidarrHistorySince(since); + } + + // Get recent history (first page) + const response = await client.getLidarrHistory({ pageSize: 50 }); + return response.records; + } + + protected override normalizeHistoryRecord(record: ArrHistoryRecordBase): NormalizedActivity { + const lidarrRecord = record as LidarrHistoryRecord; + const eventName = resolveArrEventName(lidarrRecord.eventType, LidarrEventTypeNames); + const { label, severity } = describeArrEvent(eventName, LIDARR_EVENTS); + + const artist = lidarrRecord.artist?.artistName ?? 'Unknown Artist'; + const album = lidarrRecord.album?.title; + const subject = album !== undefined && album !== '' ? `${artist} - ${album}` : artist; + const sourceTitle = lidarrRecord.sourceTitle; + const data = lidarrRecord.data as Record | undefined; + const message = this.dataString(data, 'message'); + + let name: string; + let overview: string; + + switch (eventName) { + case 'grabbed': + name = `${label}: ${subject}`; + overview = `Release grabbed: ${sourceTitle}`; + break; + case 'artistFolderImported': + name = `${label}: ${artist}`; + overview = `Artist folder imported: ${sourceTitle}`; + break; + case 'trackFileImported': + name = `${label}: ${subject}`; + overview = `Track imported: ${sourceTitle}`; + break; + case 'downloadFailed': + name = `${label}: ${subject}`; + overview = message ?? `Failed to download: ${sourceTitle}`; + break; + case 'trackFileDeleted': { + const reason = this.dataString(data, 'reason'); + name = `${label}: ${subject}`; + overview = `Track file deleted: ${sourceTitle}${reason !== undefined ? ` (${reason})` : ''}`; + break; + } + case 'trackFileRenamed': + name = `${label}: ${subject}`; + overview = `Track file renamed: ${sourceTitle}`; + break; + case 'albumImportIncomplete': + name = `${label}: ${subject}`; + overview = message ?? `Album import incomplete: ${sourceTitle}`; + break; + case 'downloadImported': + name = `${label}: ${subject}`; + overview = `Download imported: ${sourceTitle}`; + break; + case 'trackFileRetagged': + name = `${label}: ${subject}`; + overview = `Track file retagged: ${sourceTitle}`; + break; + case 'downloadIgnored': + name = `${label}: ${subject}`; + overview = `Download ignored: ${sourceTitle}`; + break; + default: + name = `${label}: ${subject}`; + overview = sourceTitle; + } + + return { + id: `lidarr-history-${lidarrRecord.id}`, + type: eventName, + name, + overview, + severity, + timestamp: new Date(lidarrRecord.date), + itemId: toArrItemId(lidarrRecord.albumId), + metadata: { + artistId: lidarrRecord.artistId, + albumId: lidarrRecord.albumId, + trackId: lidarrRecord.trackId, + mediaType: this.getMediaType(), + quality: lidarrRecord.quality?.quality?.name, + downloadId: lidarrRecord.downloadId, + indexer: data?.['indexer'], + downloadClient: data?.['downloadClient'], + releaseGroup: data?.['releaseGroup'], + }, + }; + } + + /** + * Read a non-empty string-ish value from the history `data` bag + */ + private dataString(data: Record | undefined, key: string): string | undefined { + const value = data?.[key]; + if (value === undefined || value === null || value === '') { + return undefined; + } + return String(value); + } +} diff --git a/packages/provider-lidarr/src/lidarr.types.ts b/packages/provider-lidarr/src/lidarr.types.ts new file mode 100644 index 0000000..68e2ef8 --- /dev/null +++ b/packages/provider-lidarr/src/lidarr.types.ts @@ -0,0 +1,94 @@ +/** + * Lidarr-specific API types + * + * Lidarr is the *arr application for music. It uses API v1 and models + * media as artists, albums and tracks. + */ + +import type { ArrHistoryRecordBase, ArrQueueItemBase } from '@logarr/provider-arr'; + +/** + * Lidarr artist object + */ +export interface LidarrArtist { + readonly id: number; + readonly artistName: string; + readonly foreignArtistId?: string; + readonly status?: string; + readonly path?: string; + readonly monitored?: boolean; + readonly overview?: string; + readonly genres?: readonly string[]; +} + +/** + * Lidarr album object + */ +export interface LidarrAlbum { + readonly id: number; + readonly artistId: number; + readonly title: string; + readonly albumType?: string; + readonly releaseDate?: string; + readonly monitored?: boolean; + readonly foreignAlbumId?: string; +} + +/** + * Lidarr history record with artist/album info + * + * NOTE: `eventType` is declared as string by the base type. Lidarr may return + * either a camelCase string ("trackFileImported") or a number; the provider + * accepts both. + */ +export interface LidarrHistoryRecord extends ArrHistoryRecordBase { + readonly artistId: number; + readonly albumId: number; + readonly trackId?: number; + readonly artist?: LidarrArtist; + readonly album?: LidarrAlbum; +} + +/** + * Lidarr queue item with artist/album info + */ +export interface LidarrQueueItem extends ArrQueueItemBase { + readonly artistId: number; + readonly albumId: number; + readonly artist?: LidarrArtist; + readonly album?: LidarrAlbum; +} + +/** + * Lidarr history event types (numeric values used by the API) + */ +export const LidarrEventTypes = { + Unknown: 0, + Grabbed: 1, + ArtistFolderImported: 2, + TrackFileImported: 3, + DownloadFailed: 4, + TrackFileDeleted: 5, + TrackFileRenamed: 6, + AlbumImportIncomplete: 7, + DownloadImported: 8, + TrackFileRetagged: 9, + DownloadIgnored: 10, +} as const; + +/** + * Lidarr event type string mapping (camelCase names used by the API) + */ +export const LidarrEventTypeNames: Record = { + [LidarrEventTypes.Unknown]: 'unknown', + [LidarrEventTypes.Grabbed]: 'grabbed', + [LidarrEventTypes.ArtistFolderImported]: 'artistFolderImported', + [LidarrEventTypes.TrackFileImported]: 'trackFileImported', + [LidarrEventTypes.DownloadFailed]: 'downloadFailed', + [LidarrEventTypes.TrackFileDeleted]: 'trackFileDeleted', + [LidarrEventTypes.TrackFileRenamed]: 'trackFileRenamed', + [LidarrEventTypes.AlbumImportIncomplete]: 'albumImportIncomplete', + [LidarrEventTypes.DownloadImported]: 'downloadImported', + [LidarrEventTypes.TrackFileRetagged]: 'trackFileRetagged', + [LidarrEventTypes.DownloadIgnored]: 'downloadIgnored', +}; diff --git a/packages/provider-lidarr/tsconfig.json b/packages/provider-lidarr/tsconfig.json new file mode 100644 index 0000000..7312f9a --- /dev/null +++ b/packages/provider-lidarr/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "composite": true, + "lib": ["ES2022", "DOM"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"], + "references": [{ "path": "../core" }, { "path": "../provider-arr" }] +} diff --git a/packages/provider-lidarr/tsup.config.ts b/packages/provider-lidarr/tsup.config.ts new file mode 100644 index 0000000..27c2517 --- /dev/null +++ b/packages/provider-lidarr/tsup.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['cjs', 'esm'], + dts: { + compilerOptions: { + composite: false, + }, + }, + splitting: false, + sourcemap: true, + clean: true, + treeshake: true, + minify: false, + external: ['@logarr/core', '@logarr/provider-arr'], + outExtension({ format }) { + return { + js: format === 'cjs' ? '.cjs' : '.js', + }; + }, +}); diff --git a/packages/provider-lidarr/vitest.config.ts b/packages/provider-lidarr/vitest.config.ts new file mode 100644 index 0000000..a2f6052 --- /dev/null +++ b/packages/provider-lidarr/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 c9972fb..9551804 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -68,6 +68,9 @@ importers: '@logarr/provider-jellyfin': specifier: workspace:* version: link:../../packages/provider-jellyfin + '@logarr/provider-lidarr': + specifier: workspace:* + version: link:../../packages/provider-lidarr '@logarr/provider-plex': specifier: workspace:* version: link:../../packages/provider-plex @@ -482,6 +485,25 @@ importers: specifier: ^5.7.2 version: 5.9.3 + packages/provider-lidarr: + dependencies: + '@logarr/core': + specifier: workspace:* + version: link:../core + '@logarr/provider-arr': + specifier: workspace:* + version: link:../provider-arr + devDependencies: + tsup: + specifier: ^8.3.5 + version: 8.5.1(@swc/core@1.15.8)(jiti@2.6.1)(postcss@8.5.14)(typescript@5.9.3) + typescript: + specifier: ^5.7.2 + version: 5.9.3 + vitest: + specifier: ^4.0.16 + version: 4.0.16(@types/node@22.19.3)(@vitest/ui@4.0.16)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(msw@2.12.7(@types/node@22.19.3)(typescript@5.9.3))(terser@5.44.1) + packages/provider-plex: dependencies: '@logarr/core': diff --git a/turbo.json b/turbo.json index b7b976a..248fe4a 100644 --- a/turbo.json +++ b/turbo.json @@ -28,13 +28,16 @@ "PROWLARR_API_KEY", "WHISPARR_URL", "WHISPARR_API_KEY", + "LIDARR_URL", + "LIDARR_API_KEY", "JELLYFIN_LOGS_PATH", "PLEX_LOGS_PATH", "EMBY_LOGS_PATH", "SONARR_LOGS_PATH", "RADARR_LOGS_PATH", "PROWLARR_LOGS_PATH", - "WHISPARR_LOGS_PATH" + "WHISPARR_LOGS_PATH", + "LIDARR_LOGS_PATH" ], "tasks": { "build": {