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
12 changes: 12 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ WHISPARR_API_KEY=
# Lidarr
LIDARR_URL=
LIDARR_API_KEY=
# Bazarr
BAZARR_URL=
BAZARR_API_KEY=

# =============================================================================
# SECURITY & AUTHENTICATION
Expand Down Expand Up @@ -156,6 +159,12 @@ WHISPARR_LOGS_PATH=
# Linux: ~/.config/Lidarr/logs
# Docker: /path/to/lidarr/config/logs
LIDARR_LOGS_PATH=
# Bazarr examples (container path: /bazarr-logs):
# Windows: C:/ProgramData/Bazarr/log
# Linux: ~/.config/bazarr/log
# Docker: /path/to/bazarr/config/log
# NOTE: the directory is `log` (singular), not `logs`, and contains bazarr.log
BAZARR_LOGS_PATH=

# =============================================================================
# MULTIPLE INSTANCES (optional - for multiple servers of the same type)
Expand Down Expand Up @@ -205,3 +214,6 @@ WHISPARR_LOGS_PATH_3=
LIDARR_LOGS_PATH_1=
LIDARR_LOGS_PATH_2=
LIDARR_LOGS_PATH_3=
BAZARR_LOGS_PATH_1=
BAZARR_LOGS_PATH_2=
BAZARR_LOGS_PATH_3=
5 changes: 5 additions & 0 deletions .env.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,10 @@ WHISPARR_API_KEY={{ op://Development/Logarr-Dev/WHISPARR_API_KEY }}
LIDARR_URL={{ op://Development/Logarr-Dev/LIDARR_URL }}
LIDARR_API_KEY={{ op://Development/Logarr-Dev/LIDARR_API_KEY }}

# Bazarr (no 1Password items yet: uncomment and add op:// refs once they exist)
# BAZARR_URL=
# BAZARR_API_KEY=

# =============================================================================
# SECURITY & AUTHENTICATION
# =============================================================================
Expand All @@ -111,3 +115,4 @@ RADARR_LOGS_PATH={{ op://Development/Logarr-Dev/RADARR_LOGS_PATH }}
PROWLARR_LOGS_PATH={{ op://Development/Logarr-Dev/PROWLARR_LOGS_PATH }}
WHISPARR_LOGS_PATH={{ op://Development/Logarr-Dev/WHISPARR_LOGS_PATH }}
LIDARR_LOGS_PATH={{ op://Development/Logarr-Dev/LIDARR_LOGS_PATH }}
# BAZARR_LOGS_PATH= (no 1Password item yet)
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, Lidarr, plus shared `provider-arr` base logic.
- `packages/provider-*`: provider adapters for Plex, Jellyfin, Emby, Sonarr, Radarr, Prowlarr, Whisparr, Lidarr, Bazarr, 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
16 changes: 13 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, Lidarr, and more.
Unified logging, intelligent issue detection, and AI-powered analysis for Plex, Jellyfin, Emby, Sonarr, Radarr, Prowlarr, Whisparr, Lidarr, Bazarr, and more.

[Quick Start](#quick-start) · [Features](#features) · [Screenshots](#screenshots) · [Documentation](#documentation)

Expand Down Expand Up @@ -101,7 +101,7 @@ _Configure multiple AI providers for issue analysis (Anthropic, OpenAI, Google,
| **Sonarr** | ✅ Supported | **Emby** | ✅ Supported |
| **Radarr** | ✅ Supported | **Whisparr** | ✅ Supported |
| **Prowlarr** | ✅ Supported | **Lidarr** | ✅ Supported |
| **Kodi** | 🚧 Planned | | |
| **Kodi** | 🚧 Planned | **Bazarr** | ✅ Supported |

> Logarr uses a provider architecture—adding support for new servers is straightforward. [Contributions welcome!](#contributing)

Expand Down Expand Up @@ -179,13 +179,14 @@ flowchart TB
PR[Prowlarr]
WH[Whisparr]
LI[Lidarr]
BA[Bazarr]
end

FE <--> API
FE <--> WS
API <--> PG
API <--> RD
API <--> PX & JF & EM & SO & RA & PR & WH & LI
API <--> PX & JF & EM & SO & RA & PR & WH & LI & BA
```

### How It Works
Expand Down Expand Up @@ -274,8 +275,13 @@ 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

# Bazarr (note: the directory is `log`, singular, and contains bazarr.log)
BAZARR_LOGS_PATH=/path/to/bazarr/config/log
```

> **Bazarr notes:** Bazarr writes naive local timestamps (no timezone), so run Logarr in the same timezone as Bazarr (e.g. set `TZ` on both containers). Bazarr health issues are recorded once per distinct issue, not on every poll. Bazarr reports history dates in its host's locale format; Logarr infers whether it is day-first or month-first from the data and skips timestamps it cannot read unambiguously.

2. **Restart Logarr** — The docker-compose mounts these paths automatically.

3. **Configure in UI** — Go to **Sources** → Edit your server → Enable file ingestion with the container path:
Expand All @@ -290,6 +296,7 @@ LIDARR_LOGS_PATH=/path/to/lidarr/config/logs
| Prowlarr | `/prowlarr-logs` |
| Whisparr | `/whisp-logs` |
| Lidarr | `/lidarr-logs` |
| Bazarr | `/bazarr-logs` |

#### Multiple Instances (e.g., Movies vs Shorts, 1080p vs 4K)

Expand Down Expand Up @@ -335,6 +342,7 @@ Simply add multiple servers in the UI using the same server type but different U
| Prowlarr | `/config/logs` | `~/.config/Prowlarr/logs` | `C:\ProgramData\Prowlarr\logs` |
| Whisparr | `/config/logs` | `~/.config/Whisparr/logs` | `C:\ProgramData\Whisparr\logs` |
| Lidarr | `/config/logs` | `~/.config/Lidarr/logs` | `C:\ProgramData\Lidarr\logs` |
| Bazarr | `/config/log` | `~/.config/bazarr/log` | `C:\ProgramData\Bazarr\log` |

---

Expand Down Expand Up @@ -377,6 +385,7 @@ logarr/
│ ├── provider-prowlarr/ # Prowlarr integration
│ ├── provider-whisparr/ # Whisparr integration
│ ├── provider-lidarr/ # Lidarr integration
│ ├── provider-bazarr/ # Bazarr integration
│ └── provider-arr/ # Base *arr provider
└── docker-compose.yml
```
Expand Down Expand Up @@ -490,6 +499,7 @@ pnpm test:e2e # Run Playwright E2E tests
| `@logarr/provider-emby` | Emby log parser |
| `@logarr/provider-whisparr` | Whisparr provider |
| `@logarr/provider-lidarr` | Lidarr provider |
| `@logarr/provider-bazarr` | Bazarr 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 @@ -60,6 +60,8 @@ COPY --from=builder --chown=nestjs:nodejs /app/packages/provider-whisparr/packag
COPY --from=builder --chown=nestjs:nodejs /app/packages/provider-whisparr/dist ./packages/provider-whisparr/dist
COPY --from=builder --chown=nestjs:nodejs /app/packages/provider-lidarr/package.json ./packages/provider-lidarr/
COPY --from=builder --chown=nestjs:nodejs /app/packages/provider-lidarr/dist ./packages/provider-lidarr/dist
COPY --from=builder --chown=nestjs:nodejs /app/packages/provider-bazarr/package.json ./packages/provider-bazarr/
COPY --from=builder --chown=nestjs:nodejs /app/packages/provider-bazarr/dist ./packages/provider-bazarr/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 --filter @logarr/provider-lidarr
RUN pnpm build --filter @logarr/core --filter @logarr/provider-arr --filter @logarr/provider-jellyfin --filter @logarr/provider-sonarr --filter @logarr/provider-radarr --filter @logarr/provider-prowlarr --filter @logarr/provider-plex --filter @logarr/provider-emby --filter @logarr/provider-whisparr --filter @logarr/provider-lidarr --filter @logarr/provider-bazarr

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 @@ -25,6 +25,7 @@
"dependencies": {
"@logarr/core": "workspace:*",
"@logarr/provider-arr": "workspace:*",
"@logarr/provider-bazarr": "workspace:*",
"@logarr/provider-emby": "workspace:*",
"@logarr/provider-jellyfin": "workspace:*",
"@logarr/provider-lidarr": "workspace:*",
Expand Down
30 changes: 18 additions & 12 deletions apps/backend/src/modules/ingestion/ingestion.service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { setImmediate } from 'timers';

import { BazarrProvider } from '@logarr/provider-bazarr';
import { EmbyProvider, EmbyClient } from '@logarr/provider-emby';
import { JellyfinProvider, JellyfinClient } from '@logarr/provider-jellyfin';
import { LidarrProvider } from '@logarr/provider-lidarr';
Expand All @@ -19,6 +20,8 @@ import { IssuesService } from '../issues/issues.service';
import { LogsGateway } from '../logs/logs.gateway';
import { SessionsGateway } from '../sessions/sessions.gateway';

import { computeSyncCursor } from './sync-cursor';

import type { MediaServerProvider, NormalizedSession, NormalizedActivity } from '@logarr/core';
import type {
EmbySession,
Expand Down Expand Up @@ -85,6 +88,8 @@ export class IngestionService implements OnModuleInit, OnModuleDestroy {

const lidarrProvider = new LidarrProvider();
this.providers.set(lidarrProvider.id, lidarrProvider);
const bazarrProvider = new BazarrProvider();
this.providers.set(bazarrProvider.id, bazarrProvider);
}

async onModuleInit() {
Expand Down Expand Up @@ -241,18 +246,19 @@ export class IngestionService implements OnModuleInit, OnModuleDestroy {

// Update last sync timestamp in database (persistence!)
if (insertedCount > 0) {
const maxTimestamp = activities.reduce(
(max, a) => (a.timestamp > max ? a.timestamp : max),
activities[0]!.timestamp
);

await this.db
.update(schema.servers)
.set({
lastActivitySync: maxTimestamp,
updatedAt: new Date(),
})
.where(eq(schema.servers.id, server.id));
// Synthetic rows (detection-time stamps) must not move the cursor;
// if nothing qualifies, leave it unchanged.
const nextCursor = computeSyncCursor(activities);

if (nextCursor) {
await this.db
.update(schema.servers)
.set({
lastActivitySync: nextCursor,
updatedAt: new Date(),
})
.where(eq(schema.servers.id, server.id));
}
}
}

Expand Down
36 changes: 36 additions & 0 deletions apps/backend/src/modules/ingestion/sync-cursor.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest';

import { computeSyncCursor } from './sync-cursor';

import type { NormalizedActivity } from '@logarr/core';

const act = (iso: string, advancesSyncCursor?: boolean): NormalizedActivity => ({
id: iso,
type: 't',
name: 'n',
severity: 'info',
timestamp: new Date(iso),
...(advancesSyncCursor === undefined ? {} : { advancesSyncCursor }),
});

describe('computeSyncCursor', () => {
it('uses the max timestamp of event-time rows, ignoring synthetic rows', () => {
const result = computeSyncCursor([
act('2026-01-01T00:00:00Z'),
act('2026-01-03T00:00:00Z', true),
act('2026-01-02T00:00:00Z'),
act('2026-06-01T00:00:00Z', false),
]);
expect(result).toEqual(new Date('2026-01-03T00:00:00Z'));
});

it('returns undefined when all rows are synthetic', () => {
expect(
computeSyncCursor([act('2026-06-01T00:00:00Z', false), act('2026-06-02T00:00:00Z', false)])
).toBeUndefined();
});

it('returns undefined for an empty batch', () => {
expect(computeSyncCursor([])).toBeUndefined();
});
});
20 changes: 20 additions & 0 deletions apps/backend/src/modules/ingestion/sync-cursor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { NormalizedActivity } from '@logarr/core';

/**
* Compute the next `lastActivitySync` value from a batch of activities.
*
* Only activities whose timestamp is an event time count (`advancesSyncCursor`
* !== false). Synthetic rows stamped with the detection time are ignored so they
* can't move the cursor to "now" and hide history not returned in this poll.
*
* @returns the max qualifying timestamp, or undefined if none qualify
* (caller should leave the stored cursor unchanged).
*/
export function computeSyncCursor(activities: readonly NormalizedActivity[]): Date | undefined {
let max: Date | undefined;
for (const activity of activities) {
if (activity.advancesSyncCursor === false) continue;
if (!max || activity.timestamp > max) max = activity.timestamp;
}
return max;
}
1 change: 1 addition & 0 deletions apps/backend/src/modules/proxy/proxy.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export class ProxyService {
lidarr: '/api/v1',
readarr: '/api/v1',
whisparr: '/api/v1',
bazarr: '/api',
qbittorrent: '/api/v2',
sabnzbd: '/api',
};
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 @@ -77,6 +77,13 @@ const ENV_SERVER_CONFIGS: EnvServerConfig[] = [
name: 'Lidarr',
containerLogPath: '/lidarr-logs',
},
{
urlEnvKey: 'BAZARR_URL',
apiKeyEnvKey: 'BAZARR_API_KEY',
providerId: 'bazarr',
name: 'Bazarr',
containerLogPath: '/bazarr-logs',
},
];

@Injectable()
Expand Down
3 changes: 3 additions & 0 deletions apps/backend/src/modules/servers/servers.service.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { BazarrProvider } from '@logarr/provider-bazarr';
import { EmbyProvider } from '@logarr/provider-emby';
import { JellyfinProvider } from '@logarr/provider-jellyfin';
import { LidarrProvider } from '@logarr/provider-lidarr';
Expand Down Expand Up @@ -55,6 +56,8 @@ export class ServersService {

const lidarrProvider = new LidarrProvider();
this.providers.set(lidarrProvider.id, lidarrProvider);
const bazarrProvider = new BazarrProvider();
this.providers.set(bazarrProvider.id, bazarrProvider);
}

async findAll() {
Expand Down
3 changes: 3 additions & 0 deletions apps/frontend/src/app/(dashboard)/logs/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,9 @@ function LogDetailModal({
case 'lidarr':
// *arr apps: system/events shows searchable event log
return `${baseUrl}/system/events`;
case 'bazarr':
// Bazarr: system/logs shows the application log
return `${baseUrl}/system/logs`;
default:
return null;
}
Expand Down
29 changes: 27 additions & 2 deletions apps/frontend/src/components/provider-icon.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,15 @@ describe('ProviderIcon', () => {
});

describe('All *arr providers', () => {
const arrProviders = ['sonarr', 'radarr', 'prowlarr', 'whisparr', 'lidarr', 'readarr'];
const arrProviders = [
'sonarr',
'radarr',
'prowlarr',
'whisparr',
'lidarr',
'readarr',
'bazarr',
];

it.each(arrProviders)('should render %s icon', (providerId) => {
const { container } = render(<ProviderIcon providerId={providerId} />);
Expand All @@ -52,6 +60,15 @@ describe('ProviderIcon', () => {
});

describe('providerMeta', () => {
describe('Bazarr metadata', () => {
it('should have Bazarr entry with brand color', () => {
expect(providerMeta.bazarr).toBeDefined();
expect(providerMeta.bazarr.name).toBe('Bazarr');
expect(providerMeta.bazarr.color).toBe('#7B68EE');
expect(providerMeta.bazarr.bgColor).toBe('bg-purple-500/10');
});
});

describe('Whisparr metadata', () => {
it('should have Whisparr entry', () => {
expect(providerMeta.whisparr).toBeDefined();
Expand Down Expand Up @@ -79,7 +96,15 @@ describe('providerMeta', () => {
});

describe('All *arr provider metadata', () => {
const arrProviders = ['sonarr', 'radarr', 'prowlarr', 'whisparr', 'lidarr', 'readarr'];
const arrProviders = [
'sonarr',
'radarr',
'prowlarr',
'whisparr',
'lidarr',
'readarr',
'bazarr',
];

it.each(arrProviders)('should have metadata for %s', (providerId) => {
expect(providerMeta[providerId]).toBeDefined();
Expand Down
Loading