Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@ PROWLARR_API_KEY=
WHISPARR_URL=
WHISPARR_API_KEY=

# Lidarr
LIDARR_URL=
LIDARR_API_KEY=

# =============================================================================
# SECURITY & AUTHENTICATION
# =============================================================================
Expand Down Expand Up @@ -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)
# =============================================================================
Expand Down Expand Up @@ -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=
5 changes: 5 additions & 0 deletions .env.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -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
# =============================================================================
Expand All @@ -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 }}
2 changes: 1 addition & 1 deletion .github/agents/my-agent.agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
13 changes: 10 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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)

Expand Down Expand Up @@ -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` |

---

Expand Down Expand Up @@ -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
```
Expand Down Expand Up @@ -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.

Expand Down
2 changes: 2 additions & 0 deletions apps/backend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
2 changes: 1 addition & 1 deletion apps/backend/Dockerfile.dev
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions apps/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand Down
114 changes: 114 additions & 0 deletions apps/backend/src/modules/file-ingestion/log-file-processor.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
12 changes: 11 additions & 1 deletion apps/backend/src/modules/file-ingestion/log-file-processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,13 @@ function hasContextParsing(provider: MediaServerProvider): provider is ContextAw
return typeof (provider as ContextAwareProvider).parseLogLineWithContext === 'function';
}

type FinalizingProvider = MediaServerProvider &
Required<Pick<MediaServerProvider, 'finalizeLogEntry'>>;

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';
}
Expand Down Expand Up @@ -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;

Expand Down
4 changes: 4 additions & 0 deletions apps/backend/src/modules/ingestion/ingestion.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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() {
Expand Down
7 changes: 7 additions & 0 deletions apps/backend/src/modules/servers/server-seed.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
20 changes: 20 additions & 0 deletions apps/backend/src/modules/servers/servers.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions apps/backend/src/modules/servers/servers.service.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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() {
Expand Down
1 change: 1 addition & 0 deletions apps/frontend/src/app/(dashboard)/logs/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading