diff --git a/.env.example b/.env.example
index 10a722f..89be0e6 100644
--- a/.env.example
+++ b/.env.example
@@ -77,6 +77,9 @@ WHISPARR_API_KEY=
# Lidarr
LIDARR_URL=
LIDARR_API_KEY=
+# Bazarr
+BAZARR_URL=
+BAZARR_API_KEY=
# =============================================================================
# SECURITY & AUTHENTICATION
@@ -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)
@@ -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=
diff --git a/.env.tpl b/.env.tpl
index 3261f01..7998130 100644
--- a/.env.tpl
+++ b/.env.tpl
@@ -88,6 +88,10 @@ WHISPARR_API_KEY={{ op://Development/Logarr-Dev/WHISPARR_API_KEY }}
LIDARR_URL={{ op://Development/Logarr-Dev/LIDARR_URL }}
LIDARR_API_KEY={{ op://Development/Logarr-Dev/LIDARR_API_KEY }}
+# Bazarr (no 1Password items yet: uncomment and add op:// refs once they exist)
+# BAZARR_URL=
+# BAZARR_API_KEY=
+
# =============================================================================
# SECURITY & AUTHENTICATION
# =============================================================================
@@ -111,3 +115,4 @@ RADARR_LOGS_PATH={{ op://Development/Logarr-Dev/RADARR_LOGS_PATH }}
PROWLARR_LOGS_PATH={{ op://Development/Logarr-Dev/PROWLARR_LOGS_PATH }}
WHISPARR_LOGS_PATH={{ op://Development/Logarr-Dev/WHISPARR_LOGS_PATH }}
LIDARR_LOGS_PATH={{ op://Development/Logarr-Dev/LIDARR_LOGS_PATH }}
+# BAZARR_LOGS_PATH= (no 1Password item yet)
diff --git a/.github/agents/my-agent.agent.md b/.github/agents/my-agent.agent.md
index 159bab5..3a6dbab 100644
--- a/.github/agents/my-agent.agent.md
+++ b/.github/agents/my-agent.agent.md
@@ -36,7 +36,7 @@ This is a pnpm + Turborepo TypeScript monorepo.
- `apps/backend`: NestJS 11 API with Drizzle/Postgres, Redis, Socket.IO, Swagger, auth/RBAC, audit logging, retention, ingestion, settings, and proxying.
- `apps/frontend`: Next.js 16 App Router UI with React 19, Tailwind 4, Radix UI, React Query, and realtime socket hooks.
- `packages/core`: shared provider interfaces and normalized cross-service types.
-- `packages/provider-*`: provider adapters for Plex, Jellyfin, Emby, Sonarr, Radarr, Prowlarr, Whisparr, Lidarr, plus shared `provider-arr` base logic.
+- `packages/provider-*`: provider adapters for Plex, Jellyfin, Emby, Sonarr, Radarr, Prowlarr, Whisparr, Lidarr, Bazarr, plus shared `provider-arr` base logic.
- `e2e`: Playwright coverage for key UI flows.
- `.github/workflows/docker-publish.yml`: CI gates and container publishing.
diff --git a/README.md b/README.md
index 6c26f02..86c3980 100644
--- a/README.md
+++ b/README.md
@@ -11,7 +11,7 @@
[](https://www.docker.com/)
[](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)
@@ -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)
@@ -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
@@ -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:
@@ -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)
@@ -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` |
---
@@ -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
```
@@ -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.
diff --git a/apps/backend/Dockerfile b/apps/backend/Dockerfile
index 4950ae6..06f9775 100644
--- a/apps/backend/Dockerfile
+++ b/apps/backend/Dockerfile
@@ -60,6 +60,8 @@ COPY --from=builder --chown=nestjs:nodejs /app/packages/provider-whisparr/packag
COPY --from=builder --chown=nestjs:nodejs /app/packages/provider-whisparr/dist ./packages/provider-whisparr/dist
COPY --from=builder --chown=nestjs:nodejs /app/packages/provider-lidarr/package.json ./packages/provider-lidarr/
COPY --from=builder --chown=nestjs:nodejs /app/packages/provider-lidarr/dist ./packages/provider-lidarr/dist
+COPY --from=builder --chown=nestjs:nodejs /app/packages/provider-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/
diff --git a/apps/backend/Dockerfile.dev b/apps/backend/Dockerfile.dev
index 66d84b3..22cc2fa 100644
--- a/apps/backend/Dockerfile.dev
+++ b/apps/backend/Dockerfile.dev
@@ -16,7 +16,7 @@ COPY apps/backend/ ./apps/backend/
RUN pnpm install
# Build only the packages (not backend itself - that will be done in watch mode)
-RUN pnpm build --filter @logarr/core --filter @logarr/provider-arr --filter @logarr/provider-jellyfin --filter @logarr/provider-sonarr --filter @logarr/provider-radarr --filter @logarr/provider-prowlarr --filter @logarr/provider-plex --filter @logarr/provider-emby --filter @logarr/provider-whisparr --filter @logarr/provider-lidarr
+RUN pnpm build --filter @logarr/core --filter @logarr/provider-arr --filter @logarr/provider-jellyfin --filter @logarr/provider-sonarr --filter @logarr/provider-radarr --filter @logarr/provider-prowlarr --filter @logarr/provider-plex --filter @logarr/provider-emby --filter @logarr/provider-whisparr --filter @logarr/provider-lidarr --filter @logarr/provider-bazarr
EXPOSE 4002
diff --git a/apps/backend/package.json b/apps/backend/package.json
index 535f58a..be0d3f5 100644
--- a/apps/backend/package.json
+++ b/apps/backend/package.json
@@ -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:*",
diff --git a/apps/backend/src/modules/ingestion/ingestion.service.ts b/apps/backend/src/modules/ingestion/ingestion.service.ts
index 66dd43d..691f591 100644
--- a/apps/backend/src/modules/ingestion/ingestion.service.ts
+++ b/apps/backend/src/modules/ingestion/ingestion.service.ts
@@ -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';
@@ -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,
@@ -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() {
@@ -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));
+ }
}
}
diff --git a/apps/backend/src/modules/ingestion/sync-cursor.spec.ts b/apps/backend/src/modules/ingestion/sync-cursor.spec.ts
new file mode 100644
index 0000000..2191cb1
--- /dev/null
+++ b/apps/backend/src/modules/ingestion/sync-cursor.spec.ts
@@ -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();
+ });
+});
diff --git a/apps/backend/src/modules/ingestion/sync-cursor.ts b/apps/backend/src/modules/ingestion/sync-cursor.ts
new file mode 100644
index 0000000..0352d87
--- /dev/null
+++ b/apps/backend/src/modules/ingestion/sync-cursor.ts
@@ -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;
+}
diff --git a/apps/backend/src/modules/proxy/proxy.service.ts b/apps/backend/src/modules/proxy/proxy.service.ts
index 64cf4eb..2e1ed7e 100644
--- a/apps/backend/src/modules/proxy/proxy.service.ts
+++ b/apps/backend/src/modules/proxy/proxy.service.ts
@@ -31,6 +31,7 @@ export class ProxyService {
lidarr: '/api/v1',
readarr: '/api/v1',
whisparr: '/api/v1',
+ bazarr: '/api',
qbittorrent: '/api/v2',
sabnzbd: '/api',
};
diff --git a/apps/backend/src/modules/servers/server-seed.service.ts b/apps/backend/src/modules/servers/server-seed.service.ts
index 1cc32ed..c0ddee2 100644
--- a/apps/backend/src/modules/servers/server-seed.service.ts
+++ b/apps/backend/src/modules/servers/server-seed.service.ts
@@ -77,6 +77,13 @@ const ENV_SERVER_CONFIGS: EnvServerConfig[] = [
name: 'Lidarr',
containerLogPath: '/lidarr-logs',
},
+ {
+ urlEnvKey: 'BAZARR_URL',
+ apiKeyEnvKey: 'BAZARR_API_KEY',
+ providerId: 'bazarr',
+ name: 'Bazarr',
+ containerLogPath: '/bazarr-logs',
+ },
];
@Injectable()
diff --git a/apps/backend/src/modules/servers/servers.service.ts b/apps/backend/src/modules/servers/servers.service.ts
index a3616cd..e690f65 100644
--- a/apps/backend/src/modules/servers/servers.service.ts
+++ b/apps/backend/src/modules/servers/servers.service.ts
@@ -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';
@@ -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() {
diff --git a/apps/frontend/src/app/(dashboard)/logs/page.tsx b/apps/frontend/src/app/(dashboard)/logs/page.tsx
index db90f9f..0018708 100644
--- a/apps/frontend/src/app/(dashboard)/logs/page.tsx
+++ b/apps/frontend/src/app/(dashboard)/logs/page.tsx
@@ -160,6 +160,9 @@ function LogDetailModal({
case 'lidarr':
// *arr apps: system/events shows searchable event log
return `${baseUrl}/system/events`;
+ case 'bazarr':
+ // Bazarr: system/logs shows the application log
+ return `${baseUrl}/system/logs`;
default:
return null;
}
diff --git a/apps/frontend/src/components/provider-icon.test.tsx b/apps/frontend/src/components/provider-icon.test.tsx
index 8089195..fa0a17c 100644
--- a/apps/frontend/src/components/provider-icon.test.tsx
+++ b/apps/frontend/src/components/provider-icon.test.tsx
@@ -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();
@@ -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();
@@ -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();
diff --git a/apps/frontend/src/components/provider-icon.tsx b/apps/frontend/src/components/provider-icon.tsx
index aec0f90..7a6ac08 100644
--- a/apps/frontend/src/components/provider-icon.tsx
+++ b/apps/frontend/src/components/provider-icon.tsx
@@ -130,6 +130,19 @@ function WhisparrIcon({ className }: { className?: string }) {
);
}
+// Bazarr icon (purple rounded square with subtitle bars)
+function BazarrIcon({ className }: { className?: string }) {
+ return (
+
+ );
+}
+
// Official Prowlarr logo (owl/lion face)
function ProwlarrIcon({ className }: { className?: string }) {
return (
@@ -618,6 +631,7 @@ const providerIcons: Record>
whisparr: WhisparrIcon,
lidarr: LidarrIcon,
readarr: ReadarrIcon,
+ bazarr: BazarrIcon,
sqlite: SQLiteIcon,
openai: OpenAIIcon,
anthropic: AnthropicIcon,
@@ -645,6 +659,7 @@ export const providerMeta: Record {
});
});
+ describe('Bazarr integration', () => {
+ it('should be available in the arr_stack category', () => {
+ const bazarr = getIntegrationById('bazarr');
+ expect(bazarr).toBeDefined();
+ expect(bazarr?.name).toBe('Bazarr');
+ expect(bazarr?.category).toBe('arr_stack');
+ expect(bazarr?.status).toBe('available');
+ expect(bazarr?.connectionType).toBe('api');
+ });
+
+ it('should use the bazarr icon, brand color and default port', () => {
+ const bazarr = getIntegrationById('bazarr');
+ expect(bazarr?.icon).toBe('bazarr');
+ expect(bazarr?.color).toBe('#7B68EE');
+ expect(bazarr?.defaultPort).toBe(6767);
+ });
+
+ it('should declare log-file tailing but no webhooks or metrics', () => {
+ const bazarr = getIntegrationById('bazarr');
+ expect(bazarr?.capabilities).toEqual({
+ realTimeLogs: true,
+ activityLog: true,
+ sessions: false,
+ webhooks: false,
+ metrics: false,
+ });
+ });
+
+ it('should have API key and log path config fields', () => {
+ const fieldNames = getIntegrationById('bazarr')?.configFields.map((f) => f.name);
+ expect(fieldNames).toContain('name');
+ expect(fieldNames).toContain('url');
+ expect(fieldNames).toContain('apiKey');
+ expect(fieldNames).toContain('logPath');
+ });
+
+ it('should have documentation URLs', () => {
+ const bazarr = getIntegrationById('bazarr');
+ expect(bazarr?.website).toBe('https://bazarr.media');
+ expect(bazarr?.docsUrl).toBe('https://wiki.bazarr.media');
+ });
+ });
+
describe('getIntegrationsByCategory', () => {
it('should include Whisparr in arr_stack category', () => {
const arrIntegrations = getIntegrationsByCategory('arr_stack');
diff --git a/apps/frontend/src/lib/integrations.ts b/apps/frontend/src/lib/integrations.ts
index f4d69d5..d9e80d7 100644
--- a/apps/frontend/src/lib/integrations.ts
+++ b/apps/frontend/src/lib/integrations.ts
@@ -447,20 +447,21 @@ export const integrations: Integration[] = [
name: 'Bazarr',
description: 'Companion to Sonarr and Radarr for subtitles',
category: 'arr_stack',
- status: 'coming_soon',
+ status: 'available',
connectionType: 'api',
icon: 'bazarr',
iconType: 'dashboard-icons',
color: '#7B68EE',
bgColor: 'bg-purple-500/10',
website: 'https://bazarr.media',
+ docsUrl: 'https://wiki.bazarr.media',
defaultPort: 6767,
configFields: [...standardApiFields, standardLogPathField],
capabilities: {
realTimeLogs: true,
activityLog: true,
sessions: false,
- webhooks: true,
+ webhooks: false,
metrics: false,
},
tags: ['arr', 'subtitles', 'srt', 'automation'],
diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml
index 22b1c9b..227b3d4 100644
--- a/docker-compose.dev.yml
+++ b/docker-compose.dev.yml
@@ -73,6 +73,7 @@ services:
- ./packages/provider-emby/src:/app/packages/provider-emby/src
- ./packages/provider-whisparr/src:/app/packages/provider-whisparr/src
- ./packages/provider-lidarr/src:/app/packages/provider-lidarr/src
+ - ./packages/provider-bazarr/src:/app/packages/provider-bazarr/src
- ./apps/backend/src:/app/apps/backend/src
# Mount config files for changes
- ./apps/backend/nest-cli.json:/app/apps/backend/nest-cli.json
@@ -92,6 +93,7 @@ services:
- ${PROWLARR_LOGS_PATH:-./logs/prowlarr}:/prowlarr-logs:ro
- ${WHISPARR_LOGS_PATH:-./logs/whisparr}:/whisparr-logs:ro
- ${LIDARR_LOGS_PATH:-./logs/lidarr}:/lidarr-logs:ro
+ - ${BAZARR_LOGS_PATH:-./logs/bazarr}:/bazarr-logs:ro
# Multiple instance mounts for development
- ${PLEX_LOGS_PATH_1:-./logs/plex-1}:/plex-logs-1:ro
- ${JELLYFIN_LOGS_PATH_1:-./logs/jellyfin-1}:/jellyfin-logs-1:ro
@@ -101,6 +103,7 @@ services:
- ${PROWLARR_LOGS_PATH_1:-./logs/prowlarr-1}:/prowlarr-logs-1:ro
- ${WHISPARR_LOGS_PATH_1:-./logs/whisparr-1}:/whisparr-logs-1:ro
- ${LIDARR_LOGS_PATH_1:-./logs/lidarr-1}:/lidarr-logs-1:ro
+ - ${BAZARR_LOGS_PATH_1:-./logs/bazarr-1}:/bazarr-logs-1:ro
- ${PLEX_LOGS_PATH_2:-./logs/plex-2}:/plex-logs-2:ro
- ${JELLYFIN_LOGS_PATH_2:-./logs/jellyfin-2}:/jellyfin-logs-2:ro
- ${EMBY_LOGS_PATH_2:-./logs/emby-2}:/emby-logs-2:ro
@@ -109,6 +112,7 @@ services:
- ${PROWLARR_LOGS_PATH_2:-./logs/prowlarr-2}:/prowlarr-logs-2:ro
- ${WHISPARR_LOGS_PATH_2:-./logs/whisparr-2}:/whisparr-logs-2:ro
- ${LIDARR_LOGS_PATH_2:-./logs/lidarr-2}:/lidarr-logs-2:ro
+ - ${BAZARR_LOGS_PATH_2:-./logs/bazarr-2}:/bazarr-logs-2:ro
healthcheck:
test: ['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://localhost:4002/api']
interval: 30s
diff --git a/docker-compose.yml b/docker-compose.yml
index 220bb7d..d08f09d 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -77,6 +77,8 @@ services:
WHISPARR_API_KEY: ${WHISPARR_API_KEY:-}
LIDARR_URL: ${LIDARR_URL:-}
LIDARR_API_KEY: ${LIDARR_API_KEY:-}
+ BAZARR_URL: ${BAZARR_URL:-}
+ BAZARR_API_KEY: ${BAZARR_API_KEY:-}
# Log paths - set to non-empty to enable file ingestion for that server
# The actual path inside container is fixed (e.g., /jellyfin-logs)
JELLYFIN_LOGS_PATH: ${JELLYFIN_LOGS_PATH:-}
@@ -87,6 +89,7 @@ services:
PROWLARR_LOGS_PATH: ${PROWLARR_LOGS_PATH:-}
WHISPARR_LOGS_PATH: ${WHISPARR_LOGS_PATH:-}
LIDARR_LOGS_PATH: ${LIDARR_LOGS_PATH:-}
+ BAZARR_LOGS_PATH: ${BAZARR_LOGS_PATH:-}
# Multiple instance support - add numbered suffixes for additional instances
# Example: RADARR_LOGS_PATH_1=/path/to/movies, RADARR_LOGS_PATH_2=/path/to/shorts
JELLYFIN_LOGS_PATH_1: ${JELLYFIN_LOGS_PATH_1:-}
@@ -113,6 +116,9 @@ services:
LIDARR_LOGS_PATH_1: ${LIDARR_LOGS_PATH_1:-}
LIDARR_LOGS_PATH_2: ${LIDARR_LOGS_PATH_2:-}
LIDARR_LOGS_PATH_3: ${LIDARR_LOGS_PATH_3:-}
+ BAZARR_LOGS_PATH_1: ${BAZARR_LOGS_PATH_1:-}
+ BAZARR_LOGS_PATH_2: ${BAZARR_LOGS_PATH_2:-}
+ BAZARR_LOGS_PATH_3: ${BAZARR_LOGS_PATH_3:-}
# Admin password reset - set to 'true' to reset admin account on startup
ADMIN_PASSWORD_RESET: ${ADMIN_PASSWORD_RESET:-}
volumes:
@@ -126,6 +132,7 @@ services:
- ${PROWLARR_LOGS_PATH:-/dev/null}:/prowlarr-logs:ro
- ${WHISPARR_LOGS_PATH:-/dev/null}:/whisp-logs:ro
- ${LIDARR_LOGS_PATH:-/dev/null}:/lidarr-logs:ro
+ - ${BAZARR_LOGS_PATH:-/dev/null}:/bazarr-logs:ro
# Multiple instance mounts - numbered instances map to /-logs-
- ${JELLYFIN_LOGS_PATH_1:-/dev/null}:/jellyfin-logs-1:ro
- ${JELLYFIN_LOGS_PATH_2:-/dev/null}:/jellyfin-logs-2:ro
@@ -151,6 +158,9 @@ services:
- ${LIDARR_LOGS_PATH_1:-/dev/null}:/lidarr-logs-1:ro
- ${LIDARR_LOGS_PATH_2:-/dev/null}:/lidarr-logs-2:ro
- ${LIDARR_LOGS_PATH_3:-/dev/null}:/lidarr-logs-3:ro
+ - ${BAZARR_LOGS_PATH_1:-/dev/null}:/bazarr-logs-1:ro
+ - ${BAZARR_LOGS_PATH_2:-/dev/null}:/bazarr-logs-2:ro
+ - ${BAZARR_LOGS_PATH_3:-/dev/null}:/bazarr-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 a55b7e3..1e1dd04 100644
--- a/packages/core/src/types.ts
+++ b/packages/core/src/types.ts
@@ -239,6 +239,13 @@ export interface NormalizedActivity {
readonly itemId?: string | undefined;
readonly timestamp: Date;
readonly metadata?: Record | undefined;
+ /**
+ * Whether this activity's timestamp may move the provider's `since` sync cursor.
+ * Defaults to true. Set to false for synthetic rows whose timestamp is the
+ * detection time rather than the event time (health checks, connection status,
+ * queue warnings), so they don't skip history that wasn't returned this poll.
+ */
+ readonly advancesSyncCursor?: boolean | undefined;
}
// =============================================================================
diff --git a/packages/provider-arr/src/arr.provider.spec.ts b/packages/provider-arr/src/arr.provider.spec.ts
new file mode 100644
index 0000000..09a4aba
--- /dev/null
+++ b/packages/provider-arr/src/arr.provider.spec.ts
@@ -0,0 +1,50 @@
+import { describe, it, expect } from 'vitest';
+
+import { ArrBaseProvider } from './arr.provider.js';
+
+import type { ArrHealthCheck, ArrHistoryRecordBase, ArrQueueItemBase } from './arr.types.js';
+import type { NormalizedActivity } from '@logarr/core';
+
+class TestArrProvider extends ArrBaseProvider {
+ readonly id = 'test';
+ readonly name = 'Test';
+ protected getHistoryRecords(): Promise {
+ return Promise.resolve([]);
+ }
+ protected normalizeHistoryRecord(): NormalizedActivity {
+ throw new Error('not used');
+ }
+ protected getMediaType(): 'series' {
+ return 'series';
+ }
+ health(checks: readonly ArrHealthCheck[]): NormalizedActivity[] {
+ return this.normalizeHealthChecks(checks);
+ }
+ queue(items: readonly ArrQueueItemBase[]): NormalizedActivity[] {
+ return this.normalizeQueueItems(items);
+ }
+}
+
+describe('ArrBaseProvider synthetic activities', () => {
+ it('flags health activities as not advancing the sync cursor', () => {
+ const result = new TestArrProvider().health([
+ { source: 'IndexerCheck', type: 'warning', message: 'm', wikiUrl: '' },
+ ] as unknown as ArrHealthCheck[]);
+ expect(result).toHaveLength(1);
+ expect(result[0]!.advancesSyncCursor).toBe(false);
+ });
+
+ it('flags queue activities as not advancing the sync cursor', () => {
+ const result = new TestArrProvider().queue([
+ {
+ title: 'x',
+ downloadId: 'd1',
+ trackedDownloadStatus: 'warning',
+ errorMessage: 'bad',
+ statusMessages: [],
+ },
+ ] as unknown as ArrQueueItemBase[]);
+ expect(result).toHaveLength(1);
+ expect(result[0]!.advancesSyncCursor).toBe(false);
+ });
+});
diff --git a/packages/provider-arr/src/arr.provider.ts b/packages/provider-arr/src/arr.provider.ts
index 20b3961..8d405c1 100644
--- a/packages/provider-arr/src/arr.provider.ts
+++ b/packages/provider-arr/src/arr.provider.ts
@@ -278,6 +278,7 @@ export abstract class ArrBaseProvider implements MediaServerProvider {
overview: check.message,
severity: check.type === 'error' ? 'error' : ('warn' as const),
timestamp: new Date(),
+ advancesSyncCursor: false,
}));
}
@@ -295,6 +296,7 @@ export abstract class ArrBaseProvider implements MediaServerProvider {
item.errorMessage ?? item.statusMessages.map((m) => m.messages.join(', ')).join('; '),
severity: 'warn' as const,
timestamp: new Date(),
+ advancesSyncCursor: false,
}));
}
diff --git a/packages/provider-bazarr/package.json b/packages/provider-bazarr/package.json
new file mode 100644
index 0000000..cba4cb6
--- /dev/null
+++ b/packages/provider-bazarr/package.json
@@ -0,0 +1,35 @@
+{
+ "name": "@logarr/provider-bazarr",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "description": "Bazarr provider for Logarr",
+ "main": "./dist/index.cjs",
+ "module": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js",
+ "require": "./dist/index.cjs"
+ }
+ },
+ "scripts": {
+ "build": "tsup",
+ "dev": "tsup --watch",
+ "lint": "eslint src/",
+ "lint:fix": "eslint src/ --fix",
+ "typecheck": "tsc --noEmit",
+ "test": "vitest run",
+ "test:watch": "vitest",
+ "clean": "rm -rf dist .turbo"
+ },
+ "dependencies": {
+ "@logarr/core": "workspace:*"
+ },
+ "devDependencies": {
+ "tsup": "^8.3.5",
+ "typescript": "^5.7.2",
+ "vitest": "^4.0.16"
+ }
+}
diff --git a/packages/provider-bazarr/src/bazarr.client.spec.ts b/packages/provider-bazarr/src/bazarr.client.spec.ts
new file mode 100644
index 0000000..4f2b56a
--- /dev/null
+++ b/packages/provider-bazarr/src/bazarr.client.spec.ts
@@ -0,0 +1,178 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { BazarrClient } from './bazarr.client.js';
+
+const mockFetch = vi.fn();
+
+function ok(body: unknown): unknown {
+ return { ok: true, status: 200, json: () => Promise.resolve(body) };
+}
+
+function fail(status: number, statusText: string): unknown {
+ return { ok: false, status, statusText, text: () => Promise.resolve('x') };
+}
+
+function requestedUrl(call = 0): URL {
+ return new URL(String(mockFetch.mock.calls[call]?.[0]));
+}
+
+beforeEach(() => {
+ vi.stubGlobal('fetch', mockFetch);
+});
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+ mockFetch.mockReset();
+});
+
+describe('BazarrClient', () => {
+ it('sends the API key in X-API-KEY and unwraps the data envelope', async () => {
+ mockFetch.mockResolvedValue(ok({ data: { bazarr_version: '1.6.2' } }));
+ const client = new BazarrClient('http://bazarr:6767', 'secret');
+
+ await expect(client.getSystemStatus()).resolves.toEqual({ bazarr_version: '1.6.2' });
+
+ const init = mockFetch.mock.calls[0]?.[1] as { headers: Record };
+ expect(init.headers['X-API-KEY']).toBe('secret');
+ expect(requestedUrl().pathname).toBe('/api/system/status');
+ });
+
+ it('strips multiple trailing slashes from the base URL', async () => {
+ mockFetch.mockResolvedValue(ok({ data: [] }));
+
+ await new BazarrClient('http://bazarr:6767///', 'k').getHealth();
+
+ expect(requestedUrl().href).toBe('http://bazarr:6767/api/system/health');
+ });
+
+ it('passes start and length for history endpoints', async () => {
+ mockFetch.mockResolvedValue(ok({ data: [], total: 0 }));
+ const client = new BazarrClient('http://bazarr:6767', 'k');
+
+ await client.getEpisodeHistory(25, 50);
+ await client.getMovieHistory();
+
+ expect(requestedUrl(0).pathname).toBe('/api/episodes/history');
+ expect(requestedUrl(0).searchParams.get('start')).toBe('50');
+ expect(requestedUrl(0).searchParams.get('length')).toBe('25');
+ expect(requestedUrl(1).pathname).toBe('/api/movies/history');
+ expect(requestedUrl(1).searchParams.get('start')).toBe('0');
+ expect(requestedUrl(1).searchParams.get('length')).toBe('50');
+ });
+
+ it('adds the status filter only when requested for jobs', async () => {
+ mockFetch.mockResolvedValue(ok({ data: [] }));
+ const client = new BazarrClient('http://bazarr:6767', 'k');
+
+ await client.getJobs();
+ await client.getJobs('failed');
+
+ expect(requestedUrl(0).search).toBe('');
+ expect(requestedUrl(1).searchParams.get('status')).toBe('failed');
+ });
+
+ it('testConnection resolves true on success', async () => {
+ mockFetch.mockResolvedValue(ok({ data: { bazarr_version: '1.6.2' } }));
+
+ await expect(new BazarrClient('http://bazarr:6767', 'k').testConnection()).resolves.toBe(true);
+ });
+
+ it('wraps HTTP errors with the status and a suggestion, without leaking the API key', async () => {
+ mockFetch.mockResolvedValue(fail(401, 'Unauthorized'));
+ const client = new BazarrClient('http://bazarr:6767', 'super-secret-key');
+
+ const error = await client.getSystemStatus().catch((e: unknown) => e as Error);
+
+ expect(error).toBeInstanceOf(Error);
+ expect(error.message).toMatch(/^Bazarr API error: /);
+ expect(error.message).toMatch(/401/);
+ expect(error.message).toMatch(/Authentication failed/);
+ expect(error.message).not.toContain('super-secret-key');
+ });
+
+ it('does not retry on 401', async () => {
+ mockFetch.mockResolvedValue(fail(401, 'Unauthorized'));
+
+ await new BazarrClient('http://bazarr:6767', 'k').getHealth().catch(() => undefined);
+
+ expect(mockFetch).toHaveBeenCalledTimes(1);
+ });
+
+ it('retries transient 5xx responses and recovers', async () => {
+ vi.useFakeTimers();
+ try {
+ mockFetch
+ .mockResolvedValueOnce(fail(503, 'Service Unavailable'))
+ .mockResolvedValueOnce(ok({ data: [{ object: 'a', issue: 'b' }] }));
+
+ const pending = new BazarrClient('http://bazarr:6767', 'k').getHealth();
+ await vi.runAllTimersAsync();
+
+ await expect(pending).resolves.toEqual([{ object: 'a', issue: 'b' }]);
+ expect(mockFetch).toHaveBeenCalledTimes(2);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it('gives up after the retry budget on persistent 5xx', async () => {
+ vi.useFakeTimers();
+ try {
+ mockFetch.mockResolvedValue(fail(500, 'Internal Server Error'));
+
+ const pending = new BazarrClient('http://bazarr:6767', 'k').getHealth().then(
+ () => new Error('expected rejection'),
+ (e: unknown) => e as Error
+ );
+ await vi.runAllTimersAsync();
+
+ const error = await pending;
+ expect(error.message).toMatch(/^Bazarr API error: /);
+ expect(error.message).toMatch(/500/);
+ expect(mockFetch).toHaveBeenCalledTimes(3);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it('surfaces network failures as a Bazarr API error', async () => {
+ vi.useFakeTimers();
+ try {
+ mockFetch.mockRejectedValue(new TypeError('fetch failed'));
+
+ const pending = new BazarrClient('http://bazarr:6767', 'k')
+ .getSystemStatus()
+ .catch((e: unknown) => e as Error);
+ await vi.runAllTimersAsync();
+
+ const error = await pending;
+ expect(error.message).toMatch(/Bazarr API error: .*bazarr:6767/);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it('rejects non-object and null bodies', async () => {
+ const client = new BazarrClient('http://bazarr:6767', 'k');
+
+ mockFetch.mockResolvedValueOnce(ok(null));
+ await expect(client.getHealth()).rejects.toThrow('Unexpected Bazarr response');
+
+ mockFetch.mockResolvedValueOnce(ok('login'));
+ await expect(client.getHealth()).rejects.toThrow('Unexpected Bazarr response');
+ });
+
+ it('rejects an envelope-less object such as a bare error payload', async () => {
+ mockFetch.mockResolvedValue(ok({ message: 'nope' }));
+
+ await expect(new BazarrClient('http://bazarr:6767', 'k').getJobs()).rejects.toThrow(
+ 'Unexpected Bazarr response'
+ );
+ });
+
+ it('returns data as-is when the envelope carries a null payload', async () => {
+ mockFetch.mockResolvedValue(ok({ data: null }));
+
+ await expect(new BazarrClient('http://bazarr:6767', 'k').getHealth()).resolves.toBeNull();
+ });
+});
diff --git a/packages/provider-bazarr/src/bazarr.client.ts b/packages/provider-bazarr/src/bazarr.client.ts
new file mode 100644
index 0000000..0aa5a14
--- /dev/null
+++ b/packages/provider-bazarr/src/bazarr.client.ts
@@ -0,0 +1,94 @@
+import { HttpError, httpRequest } from '@logarr/core';
+
+import type {
+ BazarrEnvelope,
+ BazarrEpisodeHistory,
+ BazarrHealthIssue,
+ BazarrJob,
+ BazarrMovieHistory,
+ BazarrSystemStatus,
+} from './bazarr.types.js';
+
+/**
+ * Bazarr API client.
+ *
+ * Bazarr is not an *arr v3 API: routes live under `/api`, authentication uses the
+ * `X-API-KEY` header and every response is wrapped in a `data` envelope.
+ */
+export class BazarrClient {
+ private readonly baseUrl: string;
+ private readonly headers: Record;
+
+ constructor(baseUrl: string, apiKey: string) {
+ this.baseUrl = baseUrl.replace(/\/+$/, '');
+ this.headers = {
+ 'X-API-KEY': apiKey,
+ Accept: 'application/json',
+ };
+ }
+
+ private async get(path: string, params?: Record): Promise {
+ const url = new URL(`${this.baseUrl}/api${path}`);
+
+ if (params) {
+ for (const [key, value] of Object.entries(params)) {
+ url.searchParams.set(key, String(value));
+ }
+ }
+
+ let response: BazarrEnvelope;
+ try {
+ response = await httpRequest>(url.toString(), {
+ method: 'GET',
+ headers: this.headers,
+ timeout: 10000,
+ retries: 2,
+ });
+ } catch (error) {
+ if (error instanceof HttpError) {
+ throw new Error(`Bazarr API error: ${error.message}\n${error.suggestion}`);
+ }
+ throw error;
+ }
+
+ if (typeof response !== 'object' || response === null || !('data' in response)) {
+ throw new Error('Unexpected Bazarr response');
+ }
+
+ return response.data;
+ }
+
+ /** GET /api/system/status (authenticated, unlike /system/ping) */
+ async getSystemStatus(): Promise {
+ return this.get('/system/status');
+ }
+
+ /** GET /api/system/health */
+ async getHealth(): Promise {
+ return this.get('/system/health');
+ }
+
+ /** GET /api/episodes/history (newest first) */
+ async getEpisodeHistory(length = 50, start = 0): Promise {
+ return this.get('/episodes/history', { start, length });
+ }
+
+ /** GET /api/movies/history (newest first) */
+ async getMovieHistory(length = 50, start = 0): Promise {
+ return this.get('/movies/history', { start, length });
+ }
+
+ /** GET /api/system/jobs, optionally filtered by status (e.g. 'failed') */
+ async getJobs(status?: string): Promise {
+ return this.get(
+ '/system/jobs',
+ status !== undefined ? { status } : undefined
+ );
+ }
+
+ /** Verify connectivity and credentials; throws on failure */
+ async testConnection(): Promise {
+ await this.getSystemStatus();
+ return true;
+ }
+}
diff --git a/packages/provider-bazarr/src/bazarr.history.spec.ts b/packages/provider-bazarr/src/bazarr.history.spec.ts
new file mode 100644
index 0000000..935c3a7
--- /dev/null
+++ b/packages/provider-bazarr/src/bazarr.history.spec.ts
@@ -0,0 +1,199 @@
+import { describe, expect, it, vi } from 'vitest';
+
+import {
+ HISTORY_MAX_PAGES,
+ HISTORY_OVERLAP,
+ HISTORY_PAGE_SIZE,
+ readHistory,
+} from './bazarr.history.js';
+
+import type { HistoryRead } from './bazarr.history.js';
+
+interface Row {
+ key: string;
+ time: number | undefined;
+}
+
+/** Newest-first rows: row `n` (0 = newest) happened at `total - n` minutes */
+function makeRows(total: number): Row[] {
+ return Array.from({ length: total }, (_, n) => ({ key: `r${total - n}`, time: total - n }));
+}
+
+class Fake {
+ requests: { length: number; start: number }[] = [];
+ onRequest?: (count: number) => void;
+ constructor(public rows: Row[]) {}
+
+ fetchPage = (length: number, start: number): Promise => {
+ this.requests.push({ length, start });
+ this.onRequest?.(this.requests.length);
+ return Promise.resolve(this.rows.slice(start, start + length));
+ };
+
+ prepend(count: number): void {
+ const newest = Number(this.rows[0]?.key.slice(1) ?? 0);
+ for (let i = 1; i <= count; i++) this.rows.unshift({ key: `r${newest + i}`, time: newest + i });
+ }
+}
+
+function read(
+ fake: Fake,
+ options: { cutoff: number; since?: number; maxProbes?: number; warn?: (m: string) => void }
+): Promise> {
+ return readHistory({
+ fetchPage: fake.fetchPage,
+ keyOf: (r) => r.key,
+ timeOf: (r) => (r.time === undefined ? undefined : new Date(r.time)),
+ cutoffMs: options.cutoff,
+ sinceMs: options.since,
+ warn: options.warn ?? (() => {}),
+ ...(options.maxProbes !== undefined ? { maxProbes: options.maxProbes } : {}),
+ });
+}
+
+describe('readHistory', () => {
+ it('returns a short history in one request', async () => {
+ const fake = new Fake(makeRows(20));
+ const result = await read(fake, { cutoff: 0 });
+
+ expect(result.mode).toBe('complete');
+ expect(result.rows).toHaveLength(20);
+ expect(fake.requests).toHaveLength(1);
+ });
+
+ it('reads a deep history completely, oldest page first, without duplicates', async () => {
+ const fake = new Fake(makeRows(430));
+ const result = await read(fake, { cutoff: 0 });
+
+ expect(result.mode).toBe('complete');
+ expect(new Set(result.rows.map((r) => r.key)).size).toBe(430);
+ // after the head page and the probes, pages are read from the old end towards the head
+ const pages = fake.requests.filter((r) => (r.length > 1 && r.start !== 0) || r.length > 50);
+ const starts = pages.map((r) => r.start);
+ expect([...starts].sort((a, b) => b - a)).toEqual(starts);
+ });
+
+ it('reads only rows at or after the cutoff', async () => {
+ const fake = new Fake(makeRows(430));
+ const result = await read(fake, { cutoff: 201 }); // keeps times 201..430
+
+ const kept = result.rows.filter((r) => (r.time ?? 0) >= 201);
+ expect(kept).toHaveLength(230);
+ expect(result.mode).toBe('complete');
+ });
+
+ it('when capped, returns the OLDEST rows above the cutoff and an edge to advance the cursor to', async () => {
+ const warn = vi.fn();
+ const total = HISTORY_MAX_PAGES * HISTORY_PAGE_SIZE + 700;
+ const fake = new Fake(makeRows(total));
+ const result = await read(fake, { cutoff: 0, warn });
+
+ expect(result.mode).toBe('capped');
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('History paging stopped'));
+ // 700 newest rows are left for the following polls
+ const times = result.rows.map((r) => r.time ?? 0);
+ expect(Math.min(...times)).toBe(1);
+ expect(Math.max(...times)).toBe(2000);
+ expect(result.edge?.time).toBe(2000);
+ });
+
+ it('successive capped reads covering a moving cursor reach the head', async () => {
+ const total = 5000;
+ const fake = new Fake(makeRows(total));
+ let since: number | undefined;
+ const seen = new Set();
+
+ for (let poll = 0; poll < 10; poll++) {
+ const result = await read(fake, {
+ cutoff: since === undefined ? 0 : since - 1440,
+ ...(since !== undefined ? { since } : {}),
+ warn: () => {},
+ });
+ for (const row of result.rows) seen.add(row.key);
+ if (result.mode === 'complete') {
+ since = Math.max(...result.rows.map((r) => r.time ?? 0));
+ break;
+ }
+ since = result.edge?.time;
+ }
+
+ expect(since).toBe(total);
+ expect(seen.size).toBe(total);
+ });
+
+ it('absorbs rows inserted at the head while it reads (overlap)', async () => {
+ const fake = new Fake(makeRows(500));
+ fake.onRequest = (count) => {
+ if (count === 6 || count === 8) fake.prepend(4);
+ };
+ const result = await read(fake, { cutoff: 0 });
+ const keys = new Set(result.rows.map((r) => r.key));
+
+ expect(result.mode).toBe('complete');
+ for (let n = 1; n <= 500; n++) expect(keys.has(`r${n}`)).toBe(true);
+ });
+
+ it('widens the overlap when more rows than the overlap were inserted', async () => {
+ const fake = new Fake(makeRows(500));
+ fake.onRequest = (count) => {
+ if (count === 7) fake.prepend(HISTORY_OVERLAP + 5);
+ };
+ const result = await read(fake, { cutoff: 0 });
+ const keys = new Set(result.rows.map((r) => r.key));
+
+ expect(result.mode).toBe('complete');
+ for (let n = 1; n <= 500; n++) expect(keys.has(`r${n}`)).toBe(true);
+ });
+
+ it('stops at the last verified page when the history shifts beyond what it can verify', async () => {
+ const warn = vi.fn();
+ const fake = new Fake(makeRows(800));
+ fake.onRequest = (count) => {
+ if (count > 8 && count % 2 === 0) fake.prepend(500);
+ };
+ const result = await read(fake, { cutoff: 0, warn });
+
+ expect(['capped', 'unsafe']).toContain(result.mode);
+ if (result.mode === 'capped') {
+ expect(result.edge).toBeDefined();
+ expect(warn).toHaveBeenCalled();
+ }
+ });
+
+ it('falls back head-first, off the cursor, when the probe budget is exceeded', async () => {
+ const warn = vi.fn();
+ const fake = new Fake(makeRows(5000));
+ const result = await read(fake, { cutoff: 0, maxProbes: 3, warn });
+
+ expect(result.mode).toBe('unsafe');
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('Could not locate'));
+ expect(fake.requests.filter((r) => r.length === 1)).toHaveLength(3);
+ });
+
+ it('falls back when a probed row has no readable time', async () => {
+ const warn = vi.fn();
+ const rows = makeRows(500).map((r, n) => (n === 100 ? { ...r, time: undefined } : r));
+ const result = await read(new Fake(rows), { cutoff: 0, warn });
+
+ expect(result.mode).toBe('unsafe');
+ });
+
+ it('uses a bounded number of probe requests on a very deep history', async () => {
+ const fake = new Fake(makeRows(200000));
+ // generate lazily would be better, but 200k tiny objects are fine
+ const result = await read(fake, { cutoff: 100000, since: 190000 });
+
+ expect(result.mode).toBe('capped');
+ const probes = fake.requests.filter((r) => r.length === 1);
+ expect(probes.length).toBeLessThanOrEqual(64);
+ });
+
+ it('reads at least half a poll of rows at or after the cursor when the margin is huge', async () => {
+ const fake = new Fake(makeRows(20000));
+ // cursor in the middle of a 10000-row margin region
+ const result = await read(fake, { cutoff: 10001, since: 15001, warn: () => {} });
+
+ expect(result.mode).toBe('capped');
+ expect(result.edge?.time ?? 0).toBeGreaterThan(15001);
+ });
+});
diff --git a/packages/provider-bazarr/src/bazarr.history.ts b/packages/provider-bazarr/src/bazarr.history.ts
new file mode 100644
index 0000000..c1ad49e
--- /dev/null
+++ b/packages/provider-bazarr/src/bazarr.history.ts
@@ -0,0 +1,227 @@
+/**
+ * Reads Bazarr's newest-first history (`start`/`length` paging) so that a sync cursor can
+ * safely advance, without any state kept between polls.
+ *
+ * Reading head-first is only correct while the whole backlog fits into one poll: when it does
+ * not, the head is re-read forever and the older tail is never reached. Instead the reader
+ * locates the cutoff (first row older than it) by probing, then reads backwards from there,
+ * oldest to newest. If the page cap is hit, the rows fetched are the OLDEST ones above the
+ * cutoff and the cursor may advance up to the newest of them, so every poll makes progress
+ * until the backlog is caught up.
+ */
+
+/** Rows per history request */
+export const HISTORY_PAGE_SIZE = 50;
+/** Safety cap on pages read per source per poll (40 x 50 = 2000 rows) */
+export const HISTORY_MAX_PAGES = 40;
+/** Rows re-read beyond the boundary of each page, to absorb rows inserted at the head mid-read */
+export const HISTORY_OVERLAP = 10;
+/** How many times the overlap is doubled when the previous page's edge row cannot be found */
+export const HISTORY_MAX_OVERLAP_RETRIES = 3;
+/** Budget for single-row probes used to locate boundaries, per source per poll */
+export const HISTORY_MAX_PROBES = 64;
+/** Pages read head-first when timestamps cannot be trusted */
+export const HISTORY_FALLBACK_PAGES = 3;
+
+/**
+ * - `complete`: everything newer than the cutoff was read; the cursor may advance to the newest row.
+ * - `capped`: the page cap was hit; rows are the oldest ones above the cutoff and the cursor may
+ * only advance to `edge` (the newest row read with certainty).
+ * - `unsafe`: timestamps were unreadable or the read could not be verified; rows are returned
+ * (they are deduplicated by id) but nothing may move the cursor.
+ */
+export type HistoryReadMode = 'complete' | 'capped' | 'unsafe';
+
+export interface HistoryRead {
+ readonly rows: readonly T[];
+ readonly mode: HistoryReadMode;
+ /** Newest row read with certainty when `mode` is `capped` */
+ readonly edge?: T;
+}
+
+export interface HistoryReaderOptions {
+ /** GET history with `length`/`start` (newest first) */
+ fetchPage: (length: number, start: number) => Promise;
+ /** Stable identity of a row (equal rows may collapse) */
+ keyOf: (record: T) => string;
+ /** Provisional absolute time of a row, undefined when it cannot be read unambiguously */
+ timeOf: (record: T) => Date | undefined;
+ /** Rows older than this (epoch ms) are not needed */
+ cutoffMs: number;
+ /** Persisted cursor (epoch ms): rows at or after it are the ones that must not be missed */
+ sinceMs?: number | undefined;
+ warn: (message: string) => void;
+ /** Override the probe budget (tests) */
+ maxProbes?: number;
+}
+
+type Probe = 'past' | 'below' | 'above' | 'unknown';
+
+export async function readHistory(options: HistoryReaderOptions): Promise> {
+ const { fetchPage, keyOf, timeOf, cutoffMs, sinceMs, warn } = options;
+ const maxProbes = options.maxProbes ?? HISTORY_MAX_PROBES;
+ let probes = 0;
+
+ const times = (records: readonly T[]): number[] =>
+ records.flatMap((record) => {
+ const time = timeOf(record);
+ return time !== undefined ? [time.getTime()] : [];
+ });
+
+ /** Head-first read for when timestamps cannot be trusted: nothing may move the cursor */
+ const fallback = async (first: readonly T[], reason: string): Promise> => {
+ warn(`${reason}; reading the latest ${HISTORY_FALLBACK_PAGES} pages without moving the cursor`);
+ const rows = [...first];
+ for (
+ let page = 1;
+ page < HISTORY_FALLBACK_PAGES && rows.length === page * HISTORY_PAGE_SIZE;
+ page++
+ ) {
+ const next = await fetchPage(HISTORY_PAGE_SIZE, page * HISTORY_PAGE_SIZE);
+ rows.push(...next);
+ if (next.length < HISTORY_PAGE_SIZE) break;
+ }
+ return { rows, mode: 'unsafe' };
+ };
+
+ /** Compare the row at `index` with `limit` (epoch ms) */
+ const probe = async (index: number, limit: number): Promise => {
+ probes++;
+ const [row] = await fetchPage(1, index);
+ if (row === undefined) return 'past';
+ const time = timeOf(row);
+ if (time === undefined) return 'unknown';
+ return time.getTime() < limit ? 'below' : 'above';
+ };
+
+ /**
+ * First index whose row does not exist or is older than `limit`.
+ * `lo` is an index known to hold a row at/after `limit` (-1 = virtual), `hi` one known not to.
+ */
+ const findBoundary = async (
+ limit: number,
+ lo: number,
+ hi?: number
+ ): Promise => {
+ let low = lo;
+ let high = hi;
+
+ while (high === undefined) {
+ const index = 2 * (low + 1);
+ if (probes >= maxProbes) return undefined;
+ const result = await probe(index, limit);
+ if (result === 'unknown') return undefined;
+ if (result === 'above') low = index;
+ else high = index;
+ }
+
+ while (high - low > 1) {
+ if (probes >= maxProbes) return undefined;
+ const mid = Math.floor((low + high) / 2);
+ const result = await probe(mid, limit);
+ if (result === 'unknown') return undefined;
+ if (result === 'above') low = mid;
+ else high = mid;
+ }
+
+ return high;
+ };
+
+ // 1. Head page: usually all that is needed.
+ const head = await fetchPage(HISTORY_PAGE_SIZE, 0);
+ if (head.length < HISTORY_PAGE_SIZE) {
+ return { rows: head, mode: 'complete' };
+ }
+ const headTimes = times(head);
+ if (headTimes.length === 0) {
+ return fallback(head, 'Could not read any history timestamp');
+ }
+ if (Math.min(...headTimes) < cutoffMs) {
+ return { rows: head, mode: 'complete' };
+ }
+
+ // 2. The backlog is deeper than one page: locate the cutoff.
+ const cutoffIndex = await findBoundary(cutoffMs, HISTORY_PAGE_SIZE - 1);
+ if (cutoffIndex === undefined) {
+ return fallback(head, 'Could not locate the history cutoff');
+ }
+
+ const capacity = HISTORY_MAX_PAGES * HISTORY_PAGE_SIZE;
+ let readFrom = cutoffIndex; // exclusive upper bound of the region to read
+ if (sinceMs !== undefined && cutoffIndex > capacity) {
+ // The margin region alone may exceed one poll's capacity. Make sure the window still
+ // reaches the rows at/after the cursor, otherwise the cursor could never advance.
+ const sinceIndex = await findBoundary(sinceMs, -1, cutoffIndex);
+ if (sinceIndex === undefined) {
+ return fallback(head, 'Could not locate the history cursor');
+ }
+ // Keep half a poll of margin before the cursor, so each capped poll gains >= capacity / 2 rows.
+ readFrom = Math.min(cutoffIndex, sinceIndex + capacity / 2);
+ }
+
+ // 3. Read backwards (oldest -> newest), each page overlapping the previous one.
+ const rows = new Map();
+ let end = readFrom;
+ let edge: T | undefined;
+ let edgeKey: string | undefined;
+ let pages = 0;
+ let verified = true;
+
+ while (end > 0 && pages < HISTORY_MAX_PAGES) {
+ const start = Math.max(0, end - HISTORY_PAGE_SIZE);
+ let list: readonly T[] = [];
+ let found = false;
+
+ for (let attempt = 0; attempt <= HISTORY_MAX_OVERLAP_RETRIES && !found; attempt++) {
+ const overlap = HISTORY_OVERLAP * 2 ** attempt;
+ const length = end + overlap - start;
+ list = await fetchPage(length, start);
+
+ if (edgeKey === undefined) {
+ // Oldest page: confirm it really passes the cutoff (rows may have shifted meanwhile)
+ const last = list[list.length - 1];
+ const lastTime = last !== undefined ? timeOf(last) : undefined;
+ found =
+ list.length < length ||
+ (readFrom < cutoffIndex && attempt === 0) ||
+ (lastTime !== undefined && lastTime.getTime() < cutoffMs);
+ } else {
+ found = list.some((row) => keyOf(row) === edgeKey);
+ }
+ }
+
+ if (!found) {
+ verified = false;
+ break;
+ }
+
+ for (const row of list) rows.set(keyOf(row), row);
+ const newest = list[0];
+ if (newest === undefined) {
+ verified = false;
+ break;
+ }
+ edge = newest;
+ edgeKey = keyOf(newest);
+ end = start;
+ pages++;
+ }
+
+ if (!verified || edge === undefined) {
+ if (edge === undefined) {
+ // Nothing could be verified at all
+ return { rows: [...rows.values()], mode: 'unsafe' };
+ }
+ warn('History changed while it was being read; stopped at the last verified page');
+ return { rows: [...rows.values()], mode: 'capped', edge };
+ }
+
+ if (end > 0) {
+ warn(
+ `History paging stopped after ${HISTORY_MAX_PAGES} pages; newer events are picked up on the next polls`
+ );
+ return { rows: [...rows.values()], mode: 'capped', edge };
+ }
+
+ return { rows: [...rows.values()], mode: 'complete' };
+}
diff --git a/packages/provider-bazarr/src/bazarr.parser.spec.ts b/packages/provider-bazarr/src/bazarr.parser.spec.ts
new file mode 100644
index 0000000..55e4a6a
--- /dev/null
+++ b/packages/provider-bazarr/src/bazarr.parser.spec.ts
@@ -0,0 +1,326 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ BAZARR_CORRELATION_PATTERNS,
+ BAZARR_LOG_FILE_CONFIG,
+ isBazarrLogContinuation,
+ mapBazarrLevel,
+ parseBazarrLogLine,
+ parseBazarrLogLineWithContext,
+} from './bazarr.parser.js';
+
+import type { ParsedLogEntry } from '@logarr/core';
+
+const LINE_INFO =
+ '2026-10-10 12:00:01|INFO |root |BAZARR is started and waiting for requests on: http://0.0.0.0:6767|';
+const LINE_WARN =
+ '2026-10-10 12:06:00|WARNING |root |BAZARR cannot get providers: 1 provider throttled|';
+const LINE_EXCEPTION = String.raw`2026-10-10 12:05:11|ERROR |sonarr.sync.episodes |BAZARR Error trying to get episodes from Sonarr.|'Traceback (most recent call last):\n File "/app/bin/bazarr/sonarr/sync/episodes.py", line 40, in update_episodes\nrequests.exceptions.ConnectionError: HTTPConnectionPool(host=\'sonarr\', port=8989): Max retries exceeded'|`;
+const LINE_PIPES =
+ '2026-10-10 13:00:00|INFO |subliminal_patch.providers.opensubtitlescom|Result: a|b|c status=ok|';
+const LINE_PIPES_EXCEPTION = String.raw`2026-10-10 13:01:00|ERROR |root |Failed: x|y|'Traceback (most recent call last):\n File "a.py"\nValueError: bad|value'|`;
+const LINE_NO_TRAILING_PIPE =
+ '2026-10-10 13:02:00|INFO |root |first line of message';
+
+describe('parseBazarrLogLine', () => {
+ it('parses a plain INFO record', () => {
+ const entry = parseBazarrLogLine(LINE_INFO);
+
+ expect(entry).not.toBeNull();
+ expect(entry?.level).toBe('info');
+ expect(entry?.source).toBe('root');
+ expect(entry?.message).toBe(
+ 'BAZARR is started and waiting for requests on: http://0.0.0.0:6767'
+ );
+ expect(entry?.timestamp).toEqual(new Date('2026-10-10T12:00:01'));
+ expect(entry?.raw).toBe(LINE_INFO);
+ expect(entry?.exception).toBeUndefined();
+ expect(entry?.stackTrace).toBeUndefined();
+ });
+
+ it('maps WARNING to warn', () => {
+ const entry = parseBazarrLogLine(LINE_WARN);
+
+ expect(entry?.level).toBe('warn');
+ expect(entry?.message).toBe('BAZARR cannot get providers: 1 provider throttled');
+ });
+
+ it('extracts an inline python traceback as exception and stackTrace', () => {
+ const entry = parseBazarrLogLine(LINE_EXCEPTION);
+
+ expect(entry?.level).toBe('error');
+ expect(entry?.source).toBe('sonarr.sync.episodes');
+ expect(entry?.message).toBe('BAZARR Error trying to get episodes from Sonarr.');
+ expect(
+ entry?.stackTrace?.startsWith('Traceback (most recent call last):\n File "/app/bin/bazarr/')
+ ).toBe(true);
+ expect(entry?.stackTrace).toContain("host='sonarr'");
+ expect(entry?.stackTrace).not.toContain('\\n');
+ expect(entry?.exception).toBe(
+ "requests.exceptions.ConnectionError: HTTPConnectionPool(host='sonarr', port=8989): Max retries exceeded"
+ );
+ });
+
+ it('preserves pipes inside the message', () => {
+ const entry = parseBazarrLogLine(LINE_PIPES);
+
+ expect(entry?.message).toBe('Result: a|b|c status=ok');
+ expect(entry?.source).toBe('subliminal_patch.providers.opensubtitlescom');
+ expect(entry?.exception).toBeUndefined();
+ });
+
+ it('preserves pipes in both message and exception', () => {
+ const entry = parseBazarrLogLine(LINE_PIPES_EXCEPTION);
+
+ expect(entry?.message).toBe('Failed: x|y');
+ expect(entry?.exception).toBe('ValueError: bad|value');
+ });
+
+ it('tolerates a missing trailing pipe', () => {
+ const entry = parseBazarrLogLine(LINE_NO_TRAILING_PIPE);
+
+ expect(entry?.message).toBe('first line of message');
+ });
+
+ it('tolerates a trailing carriage return', () => {
+ const entry = parseBazarrLogLine(`${LINE_INFO}\r`);
+
+ expect(entry?.message).toBe(
+ 'BAZARR is started and waiting for requests on: http://0.0.0.0:6767'
+ );
+ });
+
+ it('leaves source undefined when the logger name is empty', () => {
+ const entry = parseBazarrLogLine(
+ '2026-10-10 13:02:00|INFO | |hello|'
+ );
+
+ expect(entry?.source).toBeUndefined();
+ expect(entry?.message).toBe('hello');
+ });
+
+ it.each(['', 'garbage', ' File "x.py", line 1', '2026-10-10 12:00:01 INFO something'])(
+ 'returns null for non-matching line %j',
+ (line) => {
+ expect(parseBazarrLogLine(line)).toBeNull();
+ }
+ );
+
+ it('returns null for an impossible date', () => {
+ expect(parseBazarrLogLine('2026-13-45 99:99:99|INFO |root|x|')).toBeNull();
+ });
+});
+
+describe('mapBazarrLevel', () => {
+ it.each([
+ ['DEBUG', 'debug'],
+ ['INFO', 'info'],
+ ['WARNING', 'warn'],
+ ['WARN', 'warn'],
+ ['ERROR', 'error'],
+ ['CRITICAL', 'fatal'],
+ ['FATAL', 'fatal'],
+ ['TRACE', 'trace'],
+ ['warning', 'warn'],
+ [' error ', 'error'],
+ ['NOTICE', 'info'],
+ ])('maps %j to %s', (raw, expected) => {
+ expect(mapBazarrLevel(raw)).toBe(expected);
+ });
+});
+
+describe('isBazarrLogContinuation', () => {
+ it('treats an exception on a following line as a continuation', () => {
+ expect(isBazarrLogContinuation(`'Traceback (most recent call last):\\n...'|`)).toBe(true);
+ });
+
+ it('treats the next line of a multi-line message as a continuation', () => {
+ expect(isBazarrLogContinuation('second line of a multi-line message|')).toBe(true);
+ });
+
+ it('does not treat a record start as a continuation', () => {
+ expect(isBazarrLogContinuation(LINE_INFO)).toBe(false);
+ });
+
+ it('does not treat blank lines as a continuation', () => {
+ expect(isBazarrLogContinuation('')).toBe(false);
+ expect(isBazarrLogContinuation(' ')).toBe(false);
+ });
+});
+
+describe('parseBazarrLogLineWithContext', () => {
+ const baseContext = { continuationLines: [], filePath: 'bazarr.log', lineNumber: 1 };
+
+ it('flags continuation lines', () => {
+ expect(parseBazarrLogLineWithContext('second line|', { ...baseContext })).toEqual({
+ entry: null,
+ isContinuation: true,
+ previousComplete: false,
+ });
+ });
+
+ it('marks the previous entry complete when one exists', () => {
+ const previousEntry = parseBazarrLogLine(LINE_INFO) as ParsedLogEntry;
+ const result = parseBazarrLogLineWithContext(LINE_WARN, { ...baseContext, previousEntry });
+
+ expect(result.entry?.level).toBe('warn');
+ expect(result.isContinuation).toBe(false);
+ expect(result.previousComplete).toBe(true);
+ });
+
+ it('does not mark previous complete without one', () => {
+ const result = parseBazarrLogLineWithContext(LINE_WARN, { ...baseContext });
+
+ expect(result.entry).not.toBeNull();
+ expect(result.previousComplete).toBe(false);
+ });
+
+ it('returns an empty result for garbage', () => {
+ expect(parseBazarrLogLineWithContext('', { ...baseContext })).toEqual({
+ entry: null,
+ isContinuation: false,
+ previousComplete: false,
+ });
+ });
+
+ it('groups continuation lines the way the file processor does', () => {
+ const groups: { head: string; continuations: string[] }[] = [];
+ for (const line of [LINE_INFO, 'continued text|', LINE_WARN]) {
+ const result = parseBazarrLogLineWithContext(line, { ...baseContext });
+ if (result.isContinuation) {
+ groups[groups.length - 1]?.continuations.push(line);
+ } else if (result.entry !== null) {
+ groups.push({ head: result.entry.message, continuations: [] });
+ }
+ }
+
+ expect(groups).toHaveLength(2);
+ expect(groups[0]?.continuations).toEqual(['continued text|']);
+ expect(groups[1]?.continuations).toEqual([]);
+ });
+});
+
+describe('BAZARR_LOG_FILE_CONFIG', () => {
+ it('defines docker path, patterns and encoding', () => {
+ expect(BAZARR_LOG_FILE_CONFIG.defaultPaths.docker).toContain('/config/log');
+ expect(BAZARR_LOG_FILE_CONFIG.filePatterns).toContain('bazarr.log');
+ expect(BAZARR_LOG_FILE_CONFIG.filePatterns).toContain('bazarr.log.*');
+ expect(BAZARR_LOG_FILE_CONFIG.encoding).toBe('utf-8');
+ expect(BAZARR_LOG_FILE_CONFIG.rotatesDaily).toBe(true);
+ });
+
+ it('extracts the date from rotated files only', () => {
+ const pattern = BAZARR_LOG_FILE_CONFIG.datePattern as RegExp;
+
+ expect(pattern.exec('bazarr.log.2026-10-09')?.[1]).toBe('2026-10-09');
+ expect(pattern.test('bazarr.log')).toBe(false);
+ });
+});
+
+describe('BAZARR_CORRELATION_PATTERNS', () => {
+ it('is an array', () => {
+ expect(Array.isArray(BAZARR_CORRELATION_PATTERNS)).toBe(true);
+ });
+});
+
+describe('parseBazarrLogLine edge cases', () => {
+ it('unwraps a double-quoted traceback repr (Python uses " when the text contains \')', () => {
+ const line = String.raw`2026-10-10 12:05:11|ERROR |root |boom|"Traceback (most recent call last):\n File \"a.py\", line 1\nKeyError: 'x'"|`;
+ const entry = parseBazarrLogLine(line);
+
+ // Python would not escape the inner quotes in this form; the parser must still find the end
+ expect(entry?.message).toBe('boom');
+ expect(entry?.stackTrace).toContain('Traceback (most recent call last):\n');
+ expect(entry?.exception).toBe("KeyError: 'x'");
+ });
+
+ it('unescapes tabs, backslashes and escaped quotes in the traceback', () => {
+ const line = String.raw`2026-10-10 12:05:11|ERROR |root |boom|'Traceback (most recent call last):\n\tFile \'C:\\bazarr\\a.py\'\nOSError: nope'|`;
+ const entry = parseBazarrLogLine(line);
+
+ expect(entry?.stackTrace).toBe(
+ "Traceback (most recent call last):\n\tFile 'C:\\bazarr\\a.py'\nOSError: nope"
+ );
+ expect(entry?.exception).toBe('OSError: nope');
+ });
+
+ it('does not treat a literal "|Traceback" without quotes as an exception', () => {
+ const line =
+ '2026-10-10 12:05:11|ERROR |root |see|Traceback (most recent call last): x|';
+ const entry = parseBazarrLogLine(line);
+
+ expect(entry?.message).toBe('see|Traceback (most recent call last): x');
+ expect(entry?.stackTrace).toBeUndefined();
+ });
+
+ it('does not match a traceback whose closing quote differs from the opening quote', () => {
+ const line = String.raw`2026-10-10 12:05:11|ERROR |root |m|'Traceback (most recent call last):\nValueError: x"|`;
+ const entry = parseBazarrLogLine(line);
+
+ expect(entry?.stackTrace).toBeUndefined();
+ expect(entry?.message).toContain('Traceback');
+ });
+
+ it('accepts a lowercase level and tight padding', () => {
+ const entry = parseBazarrLogLine('2026-10-10 12:00:00|debug|root|hello|');
+
+ expect(entry?.level).toBe('debug');
+ expect(entry?.source).toBe('root');
+ expect(entry?.message).toBe('hello');
+ });
+
+ it('keeps an empty message empty', () => {
+ const entry = parseBazarrLogLine(
+ '2026-10-10 12:00:00|INFO |root ||'
+ );
+
+ expect(entry?.message).toBe('');
+ });
+
+ it('strips only one trailing pipe', () => {
+ const entry = parseBazarrLogLine('2026-10-10 12:00:00|INFO |root|a||');
+
+ expect(entry?.message).toBe('a|');
+ });
+
+ it('parses the timestamp as naive local time', () => {
+ const entry = parseBazarrLogLine(LINE_INFO);
+
+ expect(entry?.timestamp.getFullYear()).toBe(2026);
+ expect(entry?.timestamp.getHours()).toBe(12);
+ expect(entry?.timestamp.getSeconds()).toBe(1);
+ });
+
+ it('keeps the original line (including CR) as raw', () => {
+ const entry = parseBazarrLogLine(`${LINE_INFO}\r`);
+
+ expect(entry?.raw).toBe(`${LINE_INFO}\r`);
+ });
+});
+
+describe('isBazarrLogContinuation edge cases', () => {
+ it('treats whitespace-only lines as not a continuation', () => {
+ expect(isBazarrLogContinuation(' \t')).toBe(false);
+ });
+
+ it('treats a CRLF record start as a record, not a continuation', () => {
+ expect(isBazarrLogContinuation(`${LINE_INFO}\r`)).toBe(false);
+ });
+
+ it('treats a record with a malformed timestamp prefix as a continuation', () => {
+ expect(isBazarrLogContinuation('2026-10-10T12:00:01|INFO |root|x|')).toBe(true);
+ });
+});
+
+describe('parseBazarrLogLineWithContext edge cases', () => {
+ it('returns an empty result for a record-shaped line with an impossible date', () => {
+ const result = parseBazarrLogLineWithContext('2026-13-45 99:99:99|INFO |root|x|', {
+ continuationLines: [],
+ filePath: 'bazarr.log',
+ lineNumber: 1,
+ });
+
+ expect(result.entry).toBeNull();
+ expect(result.previousComplete).toBe(false);
+ });
+});
diff --git a/packages/provider-bazarr/src/bazarr.parser.ts b/packages/provider-bazarr/src/bazarr.parser.ts
new file mode 100644
index 0000000..246f74e
--- /dev/null
+++ b/packages/provider-bazarr/src/bazarr.parser.ts
@@ -0,0 +1,146 @@
+import type {
+ CorrelationPattern,
+ LogFileConfig,
+ LogLevel,
+ LogParseContext,
+ LogParseResult,
+ ParsedLogEntry,
+} from '@logarr/core';
+
+/**
+ * Bazarr log file configuration.
+ * Bazarr writes `/log/bazarr.log` (singular `log`) and rotates it
+ * daily to `bazarr.log.YYYY-MM-DD`.
+ */
+export const BAZARR_LOG_FILE_CONFIG: LogFileConfig = {
+ defaultPaths: {
+ docker: ['/config/log'],
+ linux: ['~/.config/bazarr/log', '/opt/bazarr/data/log', '/var/lib/bazarr/log'],
+ windows: ['C:\\ProgramData\\Bazarr\\log', '%PROGRAMDATA%\\Bazarr\\log'],
+ macos: ['~/Library/Application Support/Bazarr/log'],
+ },
+ filePatterns: ['bazarr.log', 'bazarr.log.*'],
+ encoding: 'utf-8',
+ rotatesDaily: true,
+ datePattern: /bazarr\.log\.(\d{4}-\d{2}-\d{2})$/,
+};
+
+/** Bazarr does not emit stable correlation identifiers in its log messages */
+export const BAZARR_CORRELATION_PATTERNS: readonly CorrelationPattern[] = [];
+
+/**
+ * Record start: `%(asctime)s|%(levelname)-8s|%(name)-32s|` (message follows)
+ */
+const RECORD_START = /^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\|([A-Za-z]+)\s*\|([^|]*)\|/;
+
+/**
+ * Inline exception: `message|'Traceback (most recent call last):\n ...'`
+ * Python's repr() of the traceback is appended after the message.
+ */
+const INLINE_EXCEPTION = /^([\s\S]*?)\|(['"])(Traceback \(most recent call last\):[\s\S]*)\2$/;
+
+const LEVEL_MAP: Record = {
+ TRACE: 'trace',
+ DEBUG: 'debug',
+ INFO: 'info',
+ WARNING: 'warn',
+ WARN: 'warn',
+ ERROR: 'error',
+ CRITICAL: 'fatal',
+ FATAL: 'fatal',
+};
+
+/** Map a Python logging level name to a Logarr level */
+export function mapBazarrLevel(raw: string): LogLevel {
+ return LEVEL_MAP[raw.trim().toUpperCase()] ?? 'info';
+}
+
+/** Undo Python repr() escaping of the traceback string */
+function unescapePythonRepr(value: string): string {
+ return value.replace(/\\(n|t|'|"|\\)/g, (_match, ch: string) => {
+ switch (ch) {
+ case 'n':
+ return '\n';
+ case 't':
+ return '\t';
+ default:
+ return ch;
+ }
+ });
+}
+
+/**
+ * A continuation is any non-empty line that does not start a new record. This covers
+ * multi-line messages and exceptions emitted on a following physical line.
+ */
+export function isBazarrLogContinuation(line: string): boolean {
+ return line.trim() !== '' && !RECORD_START.test(line);
+}
+
+/**
+ * Parse a single Bazarr log record.
+ * Timestamps are naive local time of the Bazarr host and are interpreted in the
+ * local timezone of the Logarr backend.
+ */
+export function parseBazarrLogLine(line: string): ParsedLogEntry | null {
+ const match = RECORD_START.exec(line);
+ if (match === null) {
+ return null;
+ }
+
+ const [full, stamp, rawLevel, rawSource] = match;
+ const timestamp = new Date((stamp ?? '').replace(' ', 'T'));
+ if (Number.isNaN(timestamp.getTime())) {
+ return null;
+ }
+
+ const source = (rawSource ?? '').trim();
+
+ let rest = line.slice((full ?? '').length).trimEnd();
+ if (rest.endsWith('|')) {
+ rest = rest.slice(0, -1);
+ }
+
+ let message = rest;
+ let exception: string | undefined;
+ let stackTrace: string | undefined;
+
+ const withException = INLINE_EXCEPTION.exec(rest);
+ if (withException !== null) {
+ message = withException[1] ?? '';
+ stackTrace = unescapePythonRepr(withException[3] ?? '');
+ const lines = stackTrace.split('\n').filter((l) => l.trim() !== '');
+ exception = lines[lines.length - 1]?.trim();
+ }
+
+ return {
+ timestamp,
+ level: mapBazarrLevel(rawLevel ?? ''),
+ message,
+ source: source === '' ? undefined : source,
+ raw: line,
+ exception,
+ stackTrace,
+ };
+}
+
+/** Parse a line with context so the file processor can attach continuation lines */
+export function parseBazarrLogLineWithContext(
+ line: string,
+ context: LogParseContext
+): LogParseResult {
+ if (isBazarrLogContinuation(line)) {
+ return { entry: null, isContinuation: true, previousComplete: false };
+ }
+
+ const entry = parseBazarrLogLine(line);
+ if (entry !== null) {
+ return {
+ entry,
+ isContinuation: false,
+ previousComplete: context.previousEntry !== undefined,
+ };
+ }
+
+ return { entry: null, isContinuation: false, previousComplete: false };
+}
diff --git a/packages/provider-bazarr/src/bazarr.provider.spec.ts b/packages/provider-bazarr/src/bazarr.provider.spec.ts
new file mode 100644
index 0000000..ed504e1
--- /dev/null
+++ b/packages/provider-bazarr/src/bazarr.provider.spec.ts
@@ -0,0 +1,1106 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { BAZARR_LOG_FILE_CONFIG } from './bazarr.parser.js';
+import { BazarrProvider } from './bazarr.provider.js';
+
+import type { BazarrEpisodeHistory, BazarrMovieHistory } from './bazarr.types.js';
+import type { NormalizedActivity } from '@logarr/core';
+
+const mockFetch = vi.fn();
+
+function ok(body: unknown): { ok: true; status: number; json: () => Promise } {
+ return { ok: true, status: 200, json: () => Promise.resolve(body) };
+}
+
+function fail(status: number, statusText: string): unknown {
+ return { ok: false, status, statusText, text: () => Promise.resolve('x') };
+}
+
+type RouteValue = unknown;
+
+interface Routes {
+ status?: RouteValue;
+ episodes?: RouteValue;
+ movies?: RouteValue;
+ health?: RouteValue;
+ jobs?: RouteValue;
+}
+
+/** Route fetch by path, since the four activity sections run concurrently */
+function route(routes: Routes): void {
+ mockFetch.mockImplementation((input: unknown) => {
+ const url = new URL(String(input));
+ const path = url.pathname;
+ const pick = (value: unknown): unknown => (typeof value === 'function' ? value(url) : value);
+
+ if (path.endsWith('/system/status'))
+ return Promise.resolve(pick(routes.status ?? ok({ data: { bazarr_version: '1.6.2' } })));
+ if (path.endsWith('/episodes/history'))
+ return Promise.resolve(pick(routes.episodes ?? ok({ data: [], total: 0 })));
+ if (path.endsWith('/movies/history'))
+ return Promise.resolve(pick(routes.movies ?? ok({ data: [], total: 0 })));
+ if (path.endsWith('/system/health'))
+ return Promise.resolve(pick(routes.health ?? ok({ data: [] })));
+ if (path.endsWith('/system/jobs'))
+ return Promise.resolve(pick(routes.jobs ?? ok({ data: [] })));
+ return Promise.reject(new Error(`unexpected ${path}`));
+ });
+}
+
+const episode: BazarrEpisodeHistory = {
+ id: 11,
+ seriesTitle: 'Show',
+ episode_number: '1x2',
+ episodeTitle: 'Pilot',
+ description: 'English subtitles downloaded from opensubtitlescom with a score of 95%.',
+ action: 1,
+ provider: 'opensubtitlescom',
+ score: '95%',
+ language: { name: 'English', code2: 'en', forced: false, hi: false },
+ sonarrSeriesId: 5,
+ sonarrEpisodeId: 7,
+ parsed_timestamp: '2026-10-09 12:00:00',
+};
+
+const movie: BazarrMovieHistory = {
+ id: 3,
+ title: 'Film',
+ radarrId: 9,
+ description: 'Czech subtitles upgraded from podnapisi with a score of 99%.',
+ action: 3,
+ provider: 'podnapisi',
+ score: '99%',
+ language: { name: 'Czech', code2: 'cs' },
+ parsed_timestamp: '10/10/26 11:30:00',
+};
+
+async function connected(url = 'http://bazarr:6767'): Promise {
+ const provider = new BazarrProvider();
+ await provider.connect({ url, apiKey: 'key' });
+ return provider;
+}
+
+beforeEach(() => {
+ vi.stubGlobal('fetch', mockFetch);
+ // Pin "now" (Date only) so the first-poll window (7 days) is deterministic
+ vi.useFakeTimers({ toFake: ['Date'] });
+ vi.setSystemTime(new Date(2026, 9, 10, 13, 0, 0));
+});
+
+afterEach(() => {
+ vi.useRealTimers();
+ vi.unstubAllGlobals();
+ vi.restoreAllMocks();
+ mockFetch.mockReset();
+});
+
+describe('BazarrProvider identity', () => {
+ const provider = new BazarrProvider();
+
+ it('has id and name', () => {
+ expect(provider.id).toBe('bazarr');
+ expect(provider.name).toBe('Bazarr');
+ });
+
+ it('declares only implemented capabilities', () => {
+ expect(provider.capabilities).toEqual({
+ supportsRealTimeLogs: false,
+ supportsActivityLog: true,
+ supportsSessions: false,
+ supportsWebhooks: false,
+ supportsPlaybackHistory: false,
+ });
+ });
+
+ it('returns no sessions or users', async () => {
+ await expect(provider.getSessions()).resolves.toEqual([]);
+ await expect(provider.getUsers()).resolves.toEqual([]);
+ });
+
+ it('exposes log file config and correlation patterns', () => {
+ expect(provider.getLogFileConfig()).toBe(BAZARR_LOG_FILE_CONFIG);
+ expect(Array.isArray(provider.getCorrelationPatterns())).toBe(true);
+ });
+
+ it('delegates log line parsing', () => {
+ const line = '2026-10-10 12:00:01|INFO |root |hello|';
+
+ expect(provider.parseLogLine(line)?.message).toBe('hello');
+ expect(provider.isLogContinuation('more|')).toBe(true);
+ expect(provider.isLogContinuation(line)).toBe(false);
+ expect(
+ provider.parseLogLineWithContext(line, {
+ continuationLines: [],
+ filePath: 'f',
+ lineNumber: 1,
+ }).entry?.message
+ ).toBe('hello');
+ });
+
+ it('returns default log paths', async () => {
+ const paths = await provider.getLogPaths();
+
+ expect(paths).toContain('/bazarr-logs');
+ expect(paths).toContain('/config/log');
+ });
+
+ it('honours a configured log path', async () => {
+ const custom = new BazarrProvider();
+ mockFetch.mockResolvedValue(ok({ data: { bazarr_version: '1.6.2' } }));
+ await custom.connect({ url: 'http://bazarr:6767', apiKey: 'key', logPath: '/my/logs' });
+
+ await expect(custom.getLogPaths()).resolves.toEqual(['/my/logs']);
+ });
+});
+
+describe('BazarrProvider connection', () => {
+ it('tests connection against the authenticated status endpoint', async () => {
+ route({
+ status: ok({ data: { bazarr_version: '1.6.2', bazarr_config_directory: '/config' } }),
+ });
+ const provider = await connected();
+ const result = await provider.testConnection();
+
+ expect(result).toEqual({
+ connected: true,
+ serverInfo: { name: 'Bazarr', version: '1.6.2', id: '/config' },
+ });
+ const [url, init] = mockFetch.mock.calls[0] as [string, { headers: Record }];
+ expect(url).toBe('http://bazarr:6767/api/system/status');
+ expect(init.headers['X-API-KEY']).toBe('key');
+ });
+
+ it('does not double the slash for a trailing-slash URL', async () => {
+ route({});
+ await connected('http://bazarr:6767/');
+
+ expect(mockFetch.mock.calls[0]?.[0]).toBe('http://bazarr:6767/api/system/status');
+ });
+
+ it('supports a base_url sub-path', async () => {
+ route({});
+ await connected('http://h/bazarr');
+
+ expect(mockFetch.mock.calls[0]?.[0]).toBe('http://h/bazarr/api/system/status');
+ });
+
+ it('falls back to a generic id when no config directory is reported', async () => {
+ route({});
+ const provider = await connected();
+
+ await expect(provider.getServerInfo()).resolves.toEqual({
+ name: 'Bazarr',
+ version: '1.6.2',
+ id: 'bazarr',
+ });
+ });
+
+ it('reports an authentication failure on 401', async () => {
+ mockFetch.mockResolvedValue(fail(401, 'Unauthorized'));
+ const provider = new BazarrProvider();
+
+ await expect(provider.connect({ url: 'http://bazarr:6767', apiKey: 'bad' })).rejects.toThrow(
+ /401/
+ );
+ await expect(provider.connect({ url: 'http://bazarr:6767', apiKey: 'bad' })).rejects.toThrow(
+ /Authentication failed/
+ );
+ });
+
+ it('returns connected:false when the status call fails after connect', async () => {
+ route({});
+ const provider = await connected();
+ route({ status: fail(401, 'Unauthorized') });
+ const result = await provider.testConnection();
+
+ expect(result.connected).toBe(false);
+ expect(result.error).toMatch(/401/);
+ });
+
+ it('rejects a response without a data envelope', async () => {
+ mockFetch.mockResolvedValue(ok({ bazarr_version: '1.6.2' }));
+ const provider = new BazarrProvider();
+
+ await expect(provider.connect({ url: 'http://bazarr:6767', apiKey: 'key' })).rejects.toThrow(
+ /Unexpected Bazarr response/
+ );
+ });
+
+ it('is not initialised before connect', async () => {
+ const provider = new BazarrProvider();
+
+ await expect(provider.testConnection()).resolves.toEqual({
+ connected: false,
+ error: 'Not initialized',
+ });
+ await expect(provider.getActivity()).rejects.toThrow(new Error('Not connected'));
+ await expect(provider.getServerInfo()).rejects.toThrow(new Error('Not connected'));
+ });
+
+ it('forgets the client on disconnect', async () => {
+ route({});
+ const provider = await connected();
+ await provider.disconnect();
+
+ await expect(provider.testConnection()).resolves.toEqual({
+ connected: false,
+ error: 'Not initialized',
+ });
+ });
+});
+
+describe('BazarrProvider.getActivity', () => {
+ const allRoutes: Routes = {
+ episodes: ok({ data: [episode], total: 1 }),
+ movies: ok({ data: [movie], total: 1 }),
+ health: ok({ data: [{ object: '/tv', issue: 'Path does not exist' }] }),
+ jobs: ok({
+ data: [
+ {
+ job_id: 42,
+ job_name: 'Update Series',
+ status: 'failed',
+ last_run_time: '2026-10-10 11:00:00',
+ },
+ ],
+ }),
+ };
+
+ it('normalizes history, health and failed jobs', async () => {
+ route(allRoutes);
+ const provider = await connected();
+ const activity = await provider.getActivity();
+ const byId = new Map(activity.map((a) => [a.id, a]));
+
+ const ep = byId.get('bazarr-episode-history-11');
+ expect(ep).toMatchObject({
+ type: 'subtitle_download',
+ name: 'Show 1x2',
+ overview: episode.description,
+ severity: 'info',
+ itemId: '7',
+ });
+ expect(ep?.timestamp).toEqual(new Date(2026, 9, 9, 12, 0, 0));
+ expect(ep?.metadata).toMatchObject({
+ kind: 'episode',
+ language: 'English',
+ provider: 'opensubtitlescom',
+ sonarrSeriesId: 5,
+ sonarrEpisodeId: 7,
+ });
+
+ const mv = byId.get('bazarr-movie-history-3');
+ expect(mv).toMatchObject({
+ type: 'subtitle_upgrade',
+ name: 'Film',
+ severity: 'info',
+ itemId: '9',
+ });
+ expect(mv?.timestamp).toEqual(new Date(2026, 9, 10, 11, 30, 0));
+
+ const health = activity.find((a) => a.type === 'health_warning');
+ expect(health?.id).toMatch(/^bazarr-health-[0-9a-f]+$/);
+ expect(health).toMatchObject({
+ name: 'Health issue: /tv',
+ overview: 'Path does not exist',
+ severity: 'warn',
+ });
+
+ expect(byId.get('bazarr-job-42-2026-10-10-11-00-00')).toMatchObject({
+ type: 'job_failed',
+ name: 'Job failed: Update Series',
+ severity: 'error',
+ });
+ expect(activity).toHaveLength(4);
+ });
+
+ it('sorts newest first', async () => {
+ route(allRoutes);
+ const provider = await connected();
+ const activity = await provider.getActivity();
+ const times = activity.map((a) => a.timestamp.getTime());
+
+ expect(times).toEqual([...times].sort((a, b) => b - a));
+ });
+
+ it('produces identical ids across polls (dedupe contract)', async () => {
+ route(allRoutes);
+ const provider = await connected();
+ const first = (await provider.getActivity()).map((a) => a.id).sort();
+ const second = (await provider.getActivity()).map((a) => a.id).sort();
+
+ expect(second).toEqual(first);
+ });
+
+ it('requests failed jobs and 50-row history pages', async () => {
+ route(allRoutes);
+ const provider = await connected();
+ await provider.getActivity();
+ const urls = mockFetch.mock.calls.map((c) => new URL(String(c[0])));
+
+ const jobs = urls.find((u) => u.pathname.endsWith('/system/jobs'));
+ expect(jobs?.searchParams.get('status')).toBe('failed');
+ const eps = urls.find((u) => u.pathname.endsWith('/episodes/history'));
+ expect(eps?.searchParams.get('start')).toBe('0');
+ expect(eps?.searchParams.get('length')).toBe('50');
+ });
+
+ it('flags blacklisted history as warn and labels unknown actions', async () => {
+ route({
+ episodes: ok({ data: [{ ...episode, blacklisted: true, action: 99 }] }),
+ });
+ const provider = await connected();
+ const [item] = await provider.getActivity();
+
+ expect(item?.severity).toBe('warn');
+ expect(item?.type).toBe('subtitle_event');
+ });
+
+ it('labels the verified action codes', async () => {
+ const records = [0, 1, 2, 3, 4, 5, 6].map((action) => ({
+ ...episode,
+ id: 100 + action,
+ action,
+ }));
+ route({ episodes: ok({ data: records }) });
+ const provider = await connected();
+ const types = (await provider.getActivity()).map((a) => a.type).sort();
+
+ expect(types).toEqual([
+ 'subtitle_delete',
+ 'subtitle_download',
+ 'subtitle_manual',
+ 'subtitle_sync',
+ 'subtitle_translated',
+ 'subtitle_upgrade',
+ 'subtitle_upload',
+ ]);
+ });
+
+ it('derives a stable id when Bazarr does not expose the row id', async () => {
+ const { id: _id, parsed_timestamp: _parsed, ...noId } = episode;
+ const rec = { ...noId, timestamp: '2 hours ago', parsed_timestamp: '10/10/26 10:00:00' };
+ route({ episodes: ok({ data: [rec] }) });
+ const provider = await connected();
+ const first = await provider.getActivity();
+ route({ episodes: ok({ data: [{ ...rec, timestamp: '3 hours ago' }] }) });
+ const second = await provider.getActivity();
+
+ expect(first[0]?.id).toMatch(/^bazarr-episode-history-[0-9a-f]{16}$/);
+ expect(second[0]?.id).toBe(first[0]?.id);
+ expect(first[0]?.timestamp).toEqual(new Date(2026, 9, 10, 10, 0, 0));
+ });
+
+ it('derives different ids for different events', async () => {
+ const { id: _id, parsed_timestamp: _parsed, ...noId } = episode;
+ route({
+ episodes: ok({
+ data: [
+ { ...noId, parsed_timestamp: '10/10/26 10:00:00' },
+ { ...noId, parsed_timestamp: '10/10/26 10:05:00' },
+ ],
+ }),
+ });
+ const provider = await connected();
+ const ids = (await provider.getActivity()).map((a) => a.id);
+
+ expect(new Set(ids).size).toBe(2);
+ });
+
+ it('falls back to now for unparseable timestamps', async () => {
+ const { parsed_timestamp: _parsed, ...rest } = episode;
+ route({ episodes: ok({ data: [{ ...rest, timestamp: 'garbage' }] }) });
+ const provider = await connected();
+ const before = Date.now();
+ const [item] = await provider.getActivity();
+
+ expect(item?.timestamp.getTime()).toBeGreaterThanOrEqual(before);
+ });
+
+ it('keeps other sections when one endpoint fails', async () => {
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ route({ ...allRoutes, health: fail(404, 'Not Found') });
+ const provider = await connected();
+ const activity = await provider.getActivity();
+
+ expect(activity.map((a) => a.type).sort()).toEqual([
+ 'job_failed',
+ 'subtitle_download',
+ 'subtitle_upgrade',
+ ]);
+ expect(errorSpy).toHaveBeenCalledWith('[bazarr] Failed to get health:', expect.any(Error));
+ });
+
+ it('rethrows the first error when every source fails', async () => {
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const down = fail(404, 'Not Found');
+ route({ episodes: down, movies: down, health: down, jobs: down });
+ const provider = await connected();
+
+ await expect(provider.getActivity()).rejects.toThrow(/Bazarr API error/);
+ for (const source of ['episode history', 'movie history', 'health', 'jobs']) {
+ expect(errorSpy).toHaveBeenCalledWith(`[bazarr] Failed to get ${source}:`, expect.any(Error));
+ }
+ });
+
+ it('filters history older than since (with a timezone safety margin)', async () => {
+ const old: BazarrEpisodeHistory = {
+ ...episode,
+ id: 12,
+ parsed_timestamp: '2026-10-01 10:00:00',
+ };
+ const fresh: BazarrEpisodeHistory = {
+ ...episode,
+ id: 13,
+ parsed_timestamp: '10/10/26 12:30:00',
+ };
+ route({ episodes: ok({ data: [fresh, old] }) });
+ const provider = await connected();
+ const activity = await provider.getActivity(new Date(2026, 9, 10, 12, 0, 0));
+ const ids = activity.map((a) => a.id);
+
+ expect(ids).toContain('bazarr-episode-history-13');
+ expect(ids).not.toContain('bazarr-episode-history-12');
+ });
+
+ it('filters failed jobs older than since', async () => {
+ route(allRoutes);
+ const provider = await connected();
+ const activity = await provider.getActivity(new Date(2026, 9, 20));
+
+ expect(activity.some((a) => a.type === 'job_failed')).toBe(false);
+ });
+
+ describe('history paging', () => {
+ const MINUTE = 60 * 1000;
+ const HOUR = 60 * MINUTE;
+ const NOW = new Date(2026, 9, 10, 13, 0, 0).getTime();
+
+ function stamp(ms: number): string {
+ const d = new Date(ms);
+ const p = (n: number): string => String(n).padStart(2, '0');
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
+ }
+
+ /** Event `seq` happened at BASE + seq minutes; rows are listed newest first */
+ const BASE = NOW - 5000 * MINUTE;
+ function row(seq: number): BazarrEpisodeHistory {
+ return { ...episode, id: seq, parsed_timestamp: stamp(BASE + seq * MINUTE) };
+ }
+
+ interface FakeBazarr {
+ rows: BazarrEpisodeHistory[];
+ requests: number;
+ onRequest?: (count: number) => void;
+ }
+
+ /** A fake Bazarr history endpoint serving `rows` (mutable) with start/length paging */
+ function fakeBazarr(newestSeq: number, oldestSeq = 1): FakeBazarr {
+ const rows: BazarrEpisodeHistory[] = [];
+ for (let seq = newestSeq; seq >= oldestSeq; seq--) rows.push(row(seq));
+ const server: FakeBazarr = { rows, requests: 0 };
+ route({
+ episodes: (url: URL) => {
+ server.requests++;
+ server.onRequest?.(server.requests);
+ const start = Number(url.searchParams.get('start'));
+ const length = Number(url.searchParams.get('length'));
+ return ok({ data: server.rows.slice(start, start + length), total: server.rows.length });
+ },
+ });
+ return server;
+ }
+
+ function prepend(server: FakeBazarr, count: number): void {
+ const newest = server.rows[0]?.id ?? 0;
+ for (let i = 1; i <= count; i++) server.rows.unshift(row(newest + i));
+ }
+
+ function historyCalls(): URL[] {
+ return mockFetch.mock.calls
+ .map((c) => new URL(String(c[0])))
+ .filter((u) => u.pathname.endsWith('/episodes/history'));
+ }
+
+ function downloads(activity: readonly { type: string }[]): number {
+ return activity.filter((a) => a.type === 'subtitle_download').length;
+ }
+
+ /** What IngestionService does: dedupe by id, cursor = max time of rows that may advance it */
+ async function runPolls(
+ provider: BazarrProvider,
+ server: FakeBazarr,
+ options: { maxPolls: number; between?: (poll: number) => void }
+ ): Promise<{
+ ingested: Set;
+ polls: { starts: string[]; newIds: number; cursor: Date | undefined }[];
+ }> {
+ const ingested = new Set();
+ const polls: { starts: string[]; newIds: number; cursor: Date | undefined }[] = [];
+ let cursor: Date | undefined;
+
+ for (let poll = 0; poll < options.maxPolls; poll++) {
+ const callsBefore = historyCalls().length;
+ const activity = await provider.getActivity(cursor);
+ const starts = historyCalls()
+ .slice(callsBefore)
+ .map((u) => `${u.searchParams.get('start')}+${u.searchParams.get('length')}`);
+
+ const sizeBefore = ingested.size;
+ for (const a of activity) ingested.add(a.id);
+ const advancing = activity.filter((a) => a.advancesSyncCursor !== false);
+ if (advancing.length > 0) {
+ const next = new Date(Math.max(...advancing.map((a) => a.timestamp.getTime())));
+ if (cursor !== undefined) expect(next.getTime()).toBeGreaterThanOrEqual(cursor.getTime());
+ cursor = next;
+ }
+ polls.push({ starts, newIds: ingested.size - sizeBefore, cursor });
+
+ const newest = server.rows[0];
+ const caughtUp =
+ newest !== undefined &&
+ cursor?.getTime() === BASE + (newest.id ?? 0) * MINUTE &&
+ ingested.has(`bazarr-episode-history-${newest.id}`) &&
+ polls[polls.length - 1]?.newIds === 0;
+ if (caughtUp) break;
+ options.between?.(poll);
+ }
+
+ return { ingested, polls };
+ }
+
+ function allIds(server: FakeBazarr): Set {
+ return new Set(server.rows.map((r) => `bazarr-episode-history-${r.id}`));
+ }
+
+ it('reads a small history with a single request', async () => {
+ fakeBazarr(5000, 4971);
+ const provider = await connected();
+ const activity = await provider.getActivity(new Date(NOW - HOUR));
+
+ expect(historyCalls()).toHaveLength(1);
+ expect(downloads(activity)).toBe(30);
+ });
+
+ it('stops at the head page when its oldest row is already older than the cutoff', async () => {
+ const server = fakeBazarr(5000);
+ server.rows = Array.from({ length: 80 }, (_, i) => ({
+ ...episode,
+ id: 80 - i,
+ parsed_timestamp: stamp(NOW - i * HOUR),
+ }));
+ const provider = await connected();
+ await provider.getActivity(new Date(NOW - HOUR));
+
+ expect(historyCalls()).toHaveLength(1);
+ });
+
+ it('locates the cutoff by probing and reads exactly the rows after it', async () => {
+ fakeBazarr(5000);
+ const provider = await connected();
+ // cutoff = since - 24h = NOW - 30h => rows within 30h = 1800 minutes, i.e. 1801 rows
+ const activity = await provider.getActivity(new Date(NOW - 6 * HOUR));
+
+ expect(downloads(activity)).toBe(1801);
+ expect(activity.every((a) => a.advancesSyncCursor === true)).toBe(
+ activity.every((a) => a.timestamp.getTime() >= NOW - 6 * HOUR)
+ );
+ // probes use single-row requests; the bulk is read as pages
+ const probes = historyCalls().filter((u) => u.searchParams.get('length') === '1');
+ expect(probes.length).toBeGreaterThan(0);
+ expect(probes.length).toBeLessThan(40);
+ });
+
+ it('pages back over a 7 day window on the first poll', async () => {
+ fakeBazarr(120);
+ const provider = await connected();
+ const activity = await provider.getActivity();
+
+ expect(downloads(activity)).toBe(120);
+ expect(activity.every((a) => a.advancesSyncCursor === true)).toBe(true);
+ });
+
+ it('does not read rows older than the first-poll window', async () => {
+ const server = fakeBazarr(1000);
+ // 1000 rows at 1 hour spacing => 41 days of history
+ server.rows = Array.from({ length: 1000 }, (_, i) => ({
+ ...episode,
+ id: 1000 - i,
+ parsed_timestamp: stamp(NOW - i * HOUR),
+ }));
+ const provider = await connected();
+ const activity = await provider.getActivity();
+
+ expect(downloads(activity)).toBe(169);
+ });
+
+ it('never lets rows older than the cursor move it backwards', async () => {
+ fakeBazarr(5000, 4991);
+ const provider = await connected();
+ const activity = await provider.getActivity(new Date(NOW + HOUR));
+
+ expect(activity.length).toBeGreaterThan(0);
+ expect(activity.every((a) => a.advancesSyncCursor === false)).toBe(true);
+ });
+
+ it('falls back to the latest pages when no timestamp can be read, off the cursor', async () => {
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
+ route({
+ episodes: (url: URL) => {
+ const start = Number(url.searchParams.get('start'));
+ return ok({
+ data: Array.from({ length: Number(url.searchParams.get('length')) }, (_, i) => ({
+ id: start + i + 1,
+ timestamp: 'recently',
+ })),
+ });
+ },
+ });
+ const provider = await connected();
+ const activity = await provider.getActivity(new Date(NOW - HOUR));
+
+ expect(historyCalls().map((u) => u.searchParams.get('start'))).toEqual(['0', '50', '100']);
+ expect(activity).toHaveLength(150);
+ expect(activity.every((a) => a.advancesSyncCursor === false)).toBe(true);
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('[bazarr] Could not read any'));
+ });
+
+ describe('catching up on a backlog larger than one poll', () => {
+ it('advances through the backlog poll by poll and ingests every row exactly once', async () => {
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
+ const server = fakeBazarr(5000);
+ const provider = await connected();
+
+ const { ingested, polls } = await runPolls(provider, server, {
+ maxPolls: 14,
+ between: (poll) => {
+ if (poll < 5) prepend(server, 40);
+ },
+ });
+
+ // every event that ever existed was ingested, none skipped
+ const missing = [...allIds(server)].filter((id) => !ingested.has(id));
+ expect(missing).toEqual([]);
+ expect(ingested.size).toBe(5200);
+
+ // the first polls were capped and each read a different, progressing range
+ const first = polls[0]?.starts.join(',');
+ const second = polls[1]?.starts.join(',');
+ expect(first).not.toBe(second);
+ expect(polls[0]?.newIds).toBeGreaterThan(0);
+ expect(polls[1]?.newIds).toBeGreaterThan(0);
+ expect(polls.length).toBeLessThanOrEqual(12);
+
+ // cursor strictly progresses while the backlog is being worked off
+ const cursors = polls.map((p) => p.cursor?.getTime() ?? 0);
+ for (let i = 1; i < 4; i++) expect(cursors[i]!).toBeGreaterThan(cursors[i - 1]!);
+
+ // request budget per poll stays sane
+ for (const poll of polls) expect(poll.starts.length).toBeLessThan(130);
+ });
+
+ it('misses nothing when rows are inserted at the head while a poll is reading', async () => {
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
+ const server = fakeBazarr(5000);
+ server.onRequest = (count) => {
+ if (count === 25 || count === 40 || count === 55) prepend(server, 7);
+ };
+ const provider = await connected();
+
+ const { ingested } = await runPolls(provider, server, { maxPolls: 14 });
+
+ const missing = [...allIds(server)].filter((id) => !ingested.has(id));
+ expect(missing).toEqual([]);
+ });
+ });
+
+ it('keeps the cursor off the rows of a history source when the other one failed', async () => {
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+ route({
+ episodes: ok({ data: [row(10), row(9)] }),
+ movies: fail(404, 'Not Found'),
+ });
+ const provider = await connected();
+ const activity = await provider.getActivity();
+
+ expect(activity.map((a) => a.type)).toEqual(['subtitle_download', 'subtitle_download']);
+ expect(activity.every((a) => a.advancesSyncCursor === false)).toBe(true);
+ });
+
+ it('still advances the cursor when both history sources work', async () => {
+ route({ episodes: ok({ data: [row(10), row(9)] }), movies: ok({ data: [] }) });
+ const provider = await connected();
+ const activity = await provider.getActivity();
+
+ expect(activity.every((a) => a.advancesSyncCursor === true)).toBe(true);
+ });
+ });
+
+ describe('slash date order', () => {
+ function rows(...stamps: string[]): BazarrEpisodeHistory[] {
+ return stamps.map((parsed_timestamp, i) => ({ ...episode, id: i + 1, parsed_timestamp }));
+ }
+
+ it('reads 05/10/26 as 5 October when the response is day-first', async () => {
+ route({ episodes: ok({ data: rows('05/10/26 08:00:00', '30/09/26 10:00:00') }) });
+ const provider = await connected();
+ const activity = await provider.getActivity(new Date(2026, 8, 1));
+ const byId = new Map(activity.map((a) => [a.id, a]));
+
+ expect(byId.get('bazarr-episode-history-1')?.timestamp).toEqual(
+ new Date(2026, 9, 5, 8, 0, 0)
+ );
+ expect(byId.get('bazarr-episode-history-2')?.timestamp).toEqual(
+ new Date(2026, 8, 30, 10, 0, 0)
+ );
+ expect(byId.get('bazarr-episode-history-1')?.advancesSyncCursor).toBe(true);
+ });
+
+ it('reads 05/10/26 as 10 May when the response is month-first', async () => {
+ route({ episodes: ok({ data: rows('05/10/26 08:00:00', '05/13/26 10:00:00') }) });
+ const provider = await connected();
+ const activity = await provider.getActivity(new Date(2026, 3, 1));
+ const first = activity.find((a) => a.id === 'bazarr-episode-history-1');
+
+ expect(first?.timestamp).toEqual(new Date(2026, 4, 10, 8, 0, 0));
+ });
+
+ it('treats an all-ambiguous response as unparsed: fallback time, off the cursor and cutoff', async () => {
+ route({ episodes: ok({ data: rows('05/10/26 08:00:00', '04/10/26 08:00:00') }) });
+ const provider = await connected();
+ const activity = await provider.getActivity(new Date(2026, 9, 9));
+
+ expect(activity).toHaveLength(2);
+ for (const a of activity) {
+ expect(a.timestamp).toEqual(new Date(new Date(2026, 9, 10, 13, 0, 0).getTime()));
+ expect(a.advancesSyncCursor).toBe(false);
+ }
+ });
+
+ it('reuses the order learned from an earlier response of the same server', async () => {
+ route({ episodes: ok({ data: rows('05/10/26 08:00:00', '30/09/26 10:00:00') }) });
+ const provider = await connected();
+ await provider.getActivity(new Date(2026, 8, 1));
+
+ route({ episodes: ok({ data: rows('06/10/26 09:00:00') }) });
+ const [later] = await provider.getActivity(new Date(2026, 9, 1));
+
+ expect(later?.timestamp).toEqual(new Date(2026, 9, 6, 9, 0, 0));
+ expect(later?.advancesSyncCursor).toBe(true);
+ });
+
+ it('gives a row the same id whatever order was inferred or learned (restart safe)', async () => {
+ const ambiguous = [{ ...episode, parsed_timestamp: '05/10/26 08:00:00' }].map(
+ ({ id: _id, ...rest }) => rest
+ );
+ const disambiguating = { ...ambiguous[0], parsed_timestamp: '30/09/26 10:00:00' };
+
+ // "restart": a fresh provider that knows nothing sees only the ambiguous row
+ route({ episodes: ok({ data: ambiguous }) });
+ const fresh = await connected();
+ const [unparsed] = await fresh.getActivity(new Date(2026, 8, 1));
+ expect(unparsed?.advancesSyncCursor).toBe(false);
+
+ // a provider that learned day-first from another response parses the same row...
+ route({ episodes: ok({ data: [disambiguating, ...ambiguous] }) });
+ const learned = await connected();
+ await learned.getActivity(new Date(2026, 8, 1));
+ route({ episodes: ok({ data: ambiguous }) });
+ const [parsed] = await learned.getActivity(new Date(2026, 8, 1));
+
+ // ...but the id never depended on how the timestamp was interpreted
+ expect(parsed?.advancesSyncCursor).toBe(true);
+ expect(parsed?.id).toBe(unparsed?.id);
+ });
+
+ it('infers the order from rows of both sources before normalizing', async () => {
+ const ambiguousEpisode = { ...episode, id: 1, parsed_timestamp: '05/10/26 08:00:00' };
+ const provingMovie = { ...movie, id: 2, parsed_timestamp: '30/09/26 10:00:00' };
+ route({ episodes: ok({ data: [ambiguousEpisode] }), movies: ok({ data: [provingMovie] }) });
+ const provider = await connected();
+ const activity = await provider.getActivity(new Date(2026, 8, 1));
+ const ep = activity.find((a) => a.id === 'bazarr-episode-history-1');
+
+ expect(ep?.timestamp).toEqual(new Date(2026, 9, 5, 8, 0, 0));
+ expect(ep?.advancesSyncCursor).toBe(true);
+ });
+
+ it('does not apply an order learned from another server', async () => {
+ route({ episodes: ok({ data: rows('05/10/26 08:00:00', '30/09/26 10:00:00') }) });
+ const first = await connected('http://one:6767');
+ await first.getActivity(new Date(2026, 8, 1));
+
+ route({ episodes: ok({ data: rows('06/10/26 09:00:00') }) });
+ const second = await connected('http://two:6767');
+ const [row] = await second.getActivity(new Date(2026, 9, 1));
+
+ expect(row?.advancesSyncCursor).toBe(false);
+ });
+ });
+});
+
+describe('BazarrProvider.getServerInfo', () => {
+ it('returns name, version and config directory as id', async () => {
+ route({
+ status: ok({ data: { bazarr_version: '1.6.2', bazarr_config_directory: '/config' } }),
+ });
+ const provider = await connected();
+
+ await expect(provider.getServerInfo()).resolves.toEqual({
+ name: 'Bazarr',
+ version: '1.6.2',
+ id: '/config',
+ });
+ });
+
+ it('falls back to a generic id', async () => {
+ route({});
+ const provider = await connected();
+
+ await expect(provider.getServerInfo()).resolves.toMatchObject({ id: 'bazarr' });
+ });
+});
+
+describe('BazarrProvider.getActivity robustness', () => {
+ beforeEach(() => {
+ vi.spyOn(console, 'error').mockImplementation(() => undefined);
+ });
+
+ async function activity(routes: Routes, since?: Date): Promise {
+ route(routes);
+ const provider = await connected();
+ return provider.getActivity(since);
+ }
+
+ it('returns an empty list when Bazarr has nothing to report', async () => {
+ await expect(activity({})).resolves.toEqual([]);
+ });
+
+ it('drops a section whose payload is not an array but keeps the others', async () => {
+ const result = await activity({
+ episodes: ok({ data: { unexpected: true } }),
+ movies: ok({ data: [movie] }),
+ health: ok({ data: null }),
+ });
+
+ expect(result.map((a) => a.id)).toEqual(['bazarr-movie-history-3']);
+ });
+
+ it('keeps history when every other endpoint fails', async () => {
+ vi.useFakeTimers();
+ try {
+ route({
+ episodes: ok({ data: [episode] }),
+ movies: fail(401, 'Unauthorized'),
+ health: fail(401, 'Unauthorized'),
+ jobs: fail(401, 'Unauthorized'),
+ });
+ const provider = await connected();
+ const pending = provider.getActivity();
+ await vi.runAllTimersAsync();
+
+ const result = await pending;
+ expect(result.map((a) => a.id)).toEqual(['bazarr-episode-history-11']);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it('names episodes without a title or number sensibly', async () => {
+ const result = await activity({
+ episodes: ok({
+ data: [
+ { id: 1, seriesTitle: 'Show', parsed_timestamp: '2026-10-09 12:00:00' },
+ { id: 2, parsed_timestamp: '2026-10-09 12:00:01' },
+ ],
+ }),
+ });
+
+ expect(result.find((a) => a.id.endsWith('-1'))?.name).toBe('Show');
+ expect(result.find((a) => a.id.endsWith('-2'))?.name).toBe('Unknown');
+ expect(result.find((a) => a.id.endsWith('-2'))?.itemId).toBeUndefined();
+ });
+
+ it('names movies without a title Unknown and omits itemId without radarrId', async () => {
+ const [entry] = await activity({
+ movies: ok({ data: [{ id: 9, parsed_timestamp: '2026-10-09 12:00:00' }] }),
+ });
+
+ expect(entry?.name).toBe('Unknown');
+ expect(entry?.itemId).toBeUndefined();
+ expect(entry?.type).toBe('subtitle_event');
+ });
+
+ it('does not filter health issues by since (they are state)', async () => {
+ const result = await activity(
+ { health: ok({ data: [{ object: '/tv', issue: 'Path does not exist' }] }) },
+ new Date(Date.now() + 60_000)
+ );
+
+ expect(result).toHaveLength(1);
+ expect(result[0]?.type).toBe('health_warning');
+ });
+
+ it('gives different health issues different ids, and the same issue the same id', async () => {
+ const result = await activity({
+ health: ok({
+ data: [
+ { object: '/tv', issue: 'Path does not exist' },
+ { object: '/movies', issue: 'Path does not exist' },
+ { object: '/tv', issue: 'Path does not exist' },
+ ],
+ }),
+ });
+
+ expect(new Set(result.map((a) => a.id)).size).toBe(2);
+ });
+
+ describe('failed jobs', () => {
+ const job = (extra: Record): Record => ({
+ job_id: 1,
+ job_name: 'Sync',
+ status: 'failed',
+ ...extra,
+ });
+
+ it('parses an ISO job time with a UTC designator natively', async () => {
+ const [entry] = await activity({
+ jobs: ok({ data: [job({ last_run_time: '2026-10-10T11:00:00Z' })] }),
+ });
+
+ expect(entry?.timestamp).toEqual(new Date('2026-10-10T11:00:00Z'));
+ });
+
+ it('honours a numeric offset such as +02:00', async () => {
+ const [entry] = await activity({
+ jobs: ok({ data: [job({ last_run_time: '2026-10-10T11:00:00+02:00' })] }),
+ });
+
+ expect(entry?.timestamp).toEqual(new Date('2026-10-10T09:00:00Z'));
+ });
+
+ it('treats a zone-less job time as naive local time', async () => {
+ const [entry] = await activity({
+ jobs: ok({ data: [job({ last_run_time: '2026-10-10 11:00:00' })] }),
+ });
+
+ expect(entry?.timestamp).toEqual(new Date(2026, 9, 10, 11, 0, 0));
+ });
+
+ // parseJobTime() only trusts strict ISO-8601 values with an offset; garbage that merely
+ // ends in an offset-looking suffix must fall back to "now" instead of a bogus V8 date.
+ it('falls back to now for an unparseable job time with an offset-looking suffix', async () => {
+ const before = Date.now();
+ const [entry] = await activity({
+ jobs: ok({ data: [job({ last_run_time: 'nonsense+0200' })] }),
+ });
+
+ expect(entry?.timestamp.getTime()).toBeGreaterThanOrEqual(before);
+ });
+
+ it('uses the progress message as overview and a default otherwise', async () => {
+ const result = await activity({
+ jobs: ok({
+ data: [
+ job({ job_id: 1, progress_message: 'disk full' }),
+ job({ job_id: 2, progress_message: null }),
+ ],
+ }),
+ });
+
+ expect(result.find((a) => a.id.startsWith('bazarr-job-1'))?.overview).toBe('disk full');
+ expect(result.find((a) => a.id.startsWith('bazarr-job-2'))?.overview).toBe(
+ 'Background job failed'
+ );
+ });
+
+ it('builds a stable id with "unknown" when there is no run time, and supports string ids', async () => {
+ const result = await activity({
+ jobs: ok({ data: [job({ job_id: 'abc', last_run_time: null }), job({ job_id: 'abc' })] }),
+ });
+
+ expect(result.map((a) => a.id)).toEqual(['bazarr-job-abc-unknown', 'bazarr-job-abc-unknown']);
+ });
+
+ it('gives a re-run of the same job a new id when last_run_time changes', async () => {
+ const result = await activity({
+ jobs: ok({
+ data: [
+ job({ job_id: 5, last_run_time: '2026-10-10 11:00:00' }),
+ job({ job_id: 5, last_run_time: '2026-10-10 12:00:00' }),
+ ],
+ }),
+ });
+
+ expect(new Set(result.map((a) => a.id)).size).toBe(2);
+ });
+ });
+
+ describe('history timestamps and ids', () => {
+ it('does not drop records with unparseable timestamps when since is set', async () => {
+ const result = await activity(
+ { episodes: ok({ data: [{ id: 77, timestamp: '3 hours ago' }] }) },
+ new Date()
+ );
+
+ expect(result.map((a) => a.id)).toEqual(['bazarr-episode-history-77']);
+ });
+
+ it('keeps records exactly at the cutoff margin and drops one second older', async () => {
+ const since = new Date(2026, 9, 10, 12, 0, 0);
+ const result = await activity(
+ {
+ episodes: ok({
+ data: [
+ { id: 1, parsed_timestamp: '2026-10-09 12:00:00' },
+ { id: 2, parsed_timestamp: '2026-10-09 11:59:59' },
+ ],
+ }),
+ },
+ since
+ );
+
+ expect(result.map((a) => a.id)).toEqual(['bazarr-episode-history-1']);
+ });
+
+ it('does not collide episode and movie history rows that share a numeric id', async () => {
+ const result = await activity({
+ episodes: ok({ data: [{ id: 1, parsed_timestamp: '2026-10-09 12:00:00' }] }),
+ movies: ok({ data: [{ id: 1, parsed_timestamp: '2026-10-09 12:00:00' }] }),
+ });
+
+ expect(new Set(result.map((a) => a.id)).size).toBe(2);
+ });
+
+ it('derives content ids that ignore the changing humanised timestamp', async () => {
+ const row = {
+ action: 1,
+ description: 'd',
+ parsed_timestamp: '10/10/26 11:30:00',
+ sonarrEpisodeId: 4,
+ };
+ const first = await activity({
+ episodes: ok({ data: [{ ...row, timestamp: '1 hour ago' }] }),
+ });
+ const second = await activity({
+ episodes: ok({ data: [{ ...row, timestamp: '2 hours ago' }] }),
+ });
+
+ expect(first[0]?.id).toBe(second[0]?.id);
+ expect(first[0]?.id).toMatch(/^bazarr-episode-history-[0-9a-f]{16}$/);
+ });
+
+ it('includes the score in the content-hash id', async () => {
+ const row = {
+ action: 1,
+ description: 'd',
+ parsed_timestamp: '2026-10-10 11:30:00',
+ sonarrEpisodeId: 4,
+ };
+ const low = await activity({ episodes: ok({ data: [{ ...row, score: '80%' }] }) });
+ const high = await activity({ episodes: ok({ data: [{ ...row, score: '95%' }] }) });
+
+ expect(low[0]?.id).not.toBe(high[0]?.id);
+ });
+ });
+});
diff --git a/packages/provider-bazarr/src/bazarr.provider.ts b/packages/provider-bazarr/src/bazarr.provider.ts
new file mode 100644
index 0000000..3154051
--- /dev/null
+++ b/packages/provider-bazarr/src/bazarr.provider.ts
@@ -0,0 +1,531 @@
+import { BazarrClient } from './bazarr.client.js';
+import { readHistory } from './bazarr.history.js';
+import {
+ BAZARR_CORRELATION_PATTERNS,
+ BAZARR_LOG_FILE_CONFIG,
+ isBazarrLogContinuation,
+ parseBazarrLogLine,
+ parseBazarrLogLineWithContext,
+} from './bazarr.parser.js';
+import { BazarrHistoryActions } from './bazarr.types.js';
+import { inferSlashDateOrder, parseBazarrTimestamp, stableHash } from './bazarr.utils.js';
+
+import type { HistoryRead } from './bazarr.history.js';
+import type {
+ BazarrEpisodeHistory,
+ BazarrHealthIssue,
+ BazarrHistoryBase,
+ BazarrJob,
+ BazarrMovieHistory,
+} from './bazarr.types.js';
+import type { SlashDateOrder } from './bazarr.utils.js';
+import type {
+ ConnectionStatus,
+ CorrelationPattern,
+ LogFileConfig,
+ LogParseContext,
+ LogParseResult,
+ MediaServerProvider,
+ NormalizedActivity,
+ NormalizedSession,
+ NormalizedUser,
+ ParsedLogEntry,
+ ProviderCapabilities,
+ ProviderConfig,
+ ServerInfo,
+} from '@logarr/core';
+
+/** How far back the first poll (no `since`) reads history, matching file-ingestion's default backfill */
+const HISTORY_INITIAL_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
+/** Upper bound for remembered per-server slash-date orders */
+const MAX_REMEMBERED_ORDERS = 32;
+/**
+ * History timestamps are naive local time of the Bazarr host, so the `since` cutoff is
+ * widened by this margin to absorb timezone differences. Re-read rows are deduplicated
+ * by their stable activity id.
+ */
+const HISTORY_SINCE_MARGIN_MS = 24 * 60 * 60 * 1000;
+
+/** Anything with an explicit UTC offset can be parsed natively */
+const ISO_WITH_TIMEZONE =
+ /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})$/i;
+
+/** Result of fetching one activity source */
+interface SourceResult {
+ items: readonly NormalizedActivity[];
+ /** Set when history paging was capped: rows newer than this must not advance the cursor */
+ cursorCeiling?: Date;
+ error?: unknown;
+}
+
+/** Raw history rows seen during one poll, shared by both history sources */
+class HistoryPoll {
+ readonly records: BazarrHistoryBase[] = [];
+ private cachedLength = -1;
+ private cachedOrder: SlashDateOrder | undefined;
+
+ constructor(private readonly learned: SlashDateOrder | undefined) {}
+
+ /** Slash-date order inferred from every row seen so far, else the one learned earlier */
+ order(): SlashDateOrder | undefined {
+ if (this.cachedLength !== this.records.length) {
+ this.cachedOrder = inferSlashDateOrder(this.records);
+ this.cachedLength = this.records.length;
+ }
+ return this.cachedOrder ?? this.learned;
+ }
+}
+
+/** A timestamp string that carries a calendar date (not "2 hours ago") */
+const ABSOLUTE_TIMESTAMP = /\d{4}[/.-]\d{1,2}[/.-]\d{1,2}|\d{1,2}[/.-]\d{1,2}[/.-]\d{2,4}/;
+
+/** Lowercase kebab id fragment (stable, no spaces or punctuation) */
+function idPart(value: string | number): string {
+ return String(value)
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-+|-+$/g, '');
+}
+
+function parseJobTime(value: string | null | undefined): Date | undefined {
+ if (value === undefined || value === null || value === '') {
+ return undefined;
+ }
+ if (ISO_WITH_TIMEZONE.test(value)) {
+ const date = new Date(value.replace(' ', 'T'));
+ if (!Number.isNaN(date.getTime())) {
+ return date;
+ }
+ }
+ return parseBazarrTimestamp({ timestamp: value });
+}
+
+/**
+ * Bazarr subtitle manager provider
+ */
+export class BazarrProvider implements MediaServerProvider {
+ readonly id = 'bazarr';
+ readonly name = 'Bazarr';
+
+ readonly capabilities: ProviderCapabilities = {
+ supportsRealTimeLogs: false,
+ supportsActivityLog: true,
+ supportsSessions: false,
+ supportsWebhooks: false,
+ supportsPlaybackHistory: false,
+ };
+
+ private client: BazarrClient | null = null;
+ private config: ProviderConfig | null = null;
+ /** Slash-date order learned per Bazarr URL, used when a response is all-ambiguous */
+ private readonly slashOrders = new Map();
+
+ async connect(config: ProviderConfig): Promise {
+ this.config = config;
+ this.client = new BazarrClient(config.url, config.apiKey);
+ await this.client.testConnection();
+ }
+
+ disconnect(): Promise {
+ this.client = null;
+ this.config = null;
+ return Promise.resolve();
+ }
+
+ async testConnection(): Promise {
+ if (this.client === null) {
+ return { connected: false, error: 'Not initialized' };
+ }
+
+ try {
+ const status = await this.client.getSystemStatus();
+
+ return {
+ connected: true,
+ serverInfo: {
+ name: 'Bazarr',
+ version: status.bazarr_version,
+ id: status.bazarr_config_directory ?? 'bazarr',
+ },
+ };
+ } catch (error) {
+ return {
+ connected: false,
+ error: error instanceof Error ? error.message : 'Unknown error',
+ };
+ }
+ }
+
+ getLogPaths(): Promise {
+ if (this.config?.logPath !== undefined) {
+ return Promise.resolve([this.config.logPath]);
+ }
+
+ return Promise.resolve([
+ '/bazarr-logs', // Docker with Logarr (mount via BAZARR_LOGS_PATH env var)
+ '/config/log', // Docker (Bazarr container)
+ '/opt/bazarr/data/log', // Linux
+ '/var/lib/bazarr/log', // Linux
+ 'C:\\ProgramData\\Bazarr\\log', // Windows
+ ]);
+ }
+
+ parseLogLine(line: string): ParsedLogEntry | null {
+ return parseBazarrLogLine(line);
+ }
+
+ getCorrelationPatterns(): readonly CorrelationPattern[] {
+ return BAZARR_CORRELATION_PATTERNS;
+ }
+
+ getLogFileConfig(): LogFileConfig {
+ return BAZARR_LOG_FILE_CONFIG;
+ }
+
+ parseLogLineWithContext(line: string, context: LogParseContext): LogParseResult {
+ return parseBazarrLogLineWithContext(line, context);
+ }
+
+ isLogContinuation(line: string): boolean {
+ return isBazarrLogContinuation(line);
+ }
+
+ getSessions(): Promise {
+ return Promise.resolve([]);
+ }
+
+ getUsers(): Promise {
+ return Promise.resolve([]);
+ }
+
+ async getServerInfo(): Promise {
+ const status = await this.getClient().getSystemStatus();
+
+ return {
+ name: 'Bazarr',
+ version: status.bazarr_version,
+ id: status.bazarr_config_directory ?? 'bazarr',
+ };
+ }
+
+ async getActivity(since?: Date): Promise {
+ const client = this.getClient();
+ const poll = new HistoryPoll(this.slashOrders.get(this.config?.url ?? ''));
+ const cutoffMs =
+ since !== undefined
+ ? since.getTime() - HISTORY_SINCE_MARGIN_MS
+ : Date.now() - HISTORY_INITIAL_WINDOW_MS;
+
+ const [episodeRead, movieRead, health, jobs] = await Promise.all([
+ this.attempt('episode history', () =>
+ this.readSource(
+ 'episode',
+ (length, start) => client.getEpisodeHistory(length, start),
+ (rec) => [rec.sonarrSeriesId, rec.sonarrEpisodeId],
+ poll,
+ cutoffMs,
+ since
+ )
+ ),
+ this.attempt('movie history', () =>
+ this.readSource(
+ 'movie',
+ (length, start) => client.getMovieHistory(length, start),
+ (rec) => [rec.radarrId],
+ poll,
+ cutoffMs,
+ since
+ )
+ ),
+ this.safely('health', async () => ({
+ items: (await client.getHealth()).map((issue) => this.normalizeHealthIssue(issue)),
+ })),
+ this.safely('jobs', async () => ({
+ items: (await client.getJobs('failed'))
+ .map((job) => this.normalizeFailedJob(job))
+ .filter((activity) => activity.timestamp.getTime() >= cutoffMs || since === undefined),
+ })),
+ ]);
+
+ // Partial failure is tolerated; if every source failed, surface the first error so the
+ // poll marks the server unhealthy instead of reporting "connected, no activity".
+ const errors = [episodeRead.error, movieRead.error, health.error, jobs.error].filter(
+ (error) => error !== undefined
+ );
+ if (errors.length === 4) {
+ throw errors[0];
+ }
+
+ const order = this.rememberSlashOrder(poll);
+ const episodes =
+ episodeRead.value !== undefined
+ ? this.finishHistory(
+ episodeRead.value,
+ (rec, parsed) => this.normalizeEpisodeHistory(rec, parsed),
+ order,
+ cutoffMs,
+ since
+ )
+ : undefined;
+ const movies =
+ movieRead.value !== undefined
+ ? this.finishHistory(
+ movieRead.value,
+ (rec, parsed) => this.normalizeMovieHistory(rec, parsed),
+ order,
+ cutoffMs,
+ since
+ )
+ : undefined;
+
+ // If either history source failed, its rows are missing: the other source must not move the
+ // cursor past that gap. If paging was capped, only rows at or before the oldest ceiling may.
+ const historyFailed = episodeRead.error !== undefined || movieRead.error !== undefined;
+ const ceilings = [episodes?.cursorCeiling, movies?.cursorCeiling].flatMap((c) =>
+ c !== undefined ? [c.getTime()] : []
+ );
+ const ceiling = ceilings.length > 0 ? Math.min(...ceilings) : undefined;
+
+ return [episodes, movies, health, jobs]
+ .flatMap((section) => section?.items ?? [])
+ .map((activity) =>
+ activity.advancesSyncCursor !== false &&
+ (historyFailed || (ceiling !== undefined && activity.timestamp.getTime() > ceiling))
+ ? { ...activity, advancesSyncCursor: false }
+ : activity
+ )
+ .sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
+ }
+
+ private getClient(): BazarrClient {
+ if (this.client === null) {
+ throw new Error('Not connected');
+ }
+ return this.client;
+ }
+
+ /** Run one source; a failing endpoint must not drop the others */
+ private async attempt(
+ source: string,
+ run: () => Promise
+ ): Promise<{ value?: R; error?: unknown }> {
+ try {
+ return { value: await run() };
+ } catch (error) {
+ console.error(`[${this.id}] Failed to get ${source}:`, error);
+ return { error };
+ }
+ }
+
+ private async safely(
+ source: string,
+ run: () => Promise<{ items: readonly NormalizedActivity[] }>
+ ): Promise {
+ const result = await this.attempt(source, run);
+ return result.value ?? { items: [], error: result.error };
+ }
+
+ /** Slash-date order of the whole poll (every row of both sources); learned per server */
+ private rememberSlashOrder(poll: HistoryPoll): SlashDateOrder | undefined {
+ const key = this.config?.url ?? '';
+ const inferred = inferSlashDateOrder(poll.records);
+
+ if (inferred !== undefined) {
+ if (!this.slashOrders.has(key) && this.slashOrders.size >= MAX_REMEMBERED_ORDERS) {
+ const oldest = this.slashOrders.keys().next();
+ if (oldest.done !== true) this.slashOrders.delete(oldest.value);
+ }
+ this.slashOrders.set(key, inferred);
+ return inferred;
+ }
+
+ return this.slashOrders.get(key);
+ }
+
+ private readSource(
+ kind: 'episode' | 'movie',
+ fetchPage: (length: number, start: number) => Promise,
+ refOf: (record: T) => unknown,
+ poll: HistoryPoll,
+ cutoffMs: number,
+ since: Date | undefined
+ ): Promise> {
+ return readHistory({
+ fetchPage: async (length, start) => {
+ const rows = await fetchPage(length, start);
+ poll.records.push(...rows);
+ return rows;
+ },
+ keyOf: (record) => this.historyId(kind, record, refOf(record)),
+ timeOf: (record) => parseBazarrTimestamp(record, poll.order()),
+ cutoffMs,
+ sinceMs: since?.getTime(),
+ warn: (message) => console.warn(`[${this.id}] ${message}`),
+ });
+ }
+
+ /**
+ * Turn raw rows into activities using the final slash-date order of the poll.
+ * Only rows with a readable time may advance the cursor, never backwards past `since`, and in
+ * a capped read never past its newest certain row. Unsafe reads never move the cursor.
+ */
+ private finishHistory(
+ read: HistoryRead,
+ normalize: (record: T, parsed: Date | undefined) => NormalizedActivity,
+ order: SlashDateOrder | undefined,
+ cutoffMs: number,
+ since: Date | undefined
+ ): SourceResult {
+ const ceiling =
+ read.mode === 'capped' && read.edge !== undefined
+ ? parseBazarrTimestamp(read.edge, order)
+ : undefined;
+ const trusted = read.mode === 'complete' || (read.mode === 'capped' && ceiling !== undefined);
+ const items: NormalizedActivity[] = [];
+
+ for (const record of read.rows) {
+ const parsed = parseBazarrTimestamp(record, order);
+ if (parsed !== undefined && parsed.getTime() < cutoffMs) continue;
+
+ const advances =
+ trusted &&
+ parsed !== undefined &&
+ (since === undefined || parsed.getTime() >= since.getTime()) &&
+ (ceiling === undefined || parsed.getTime() <= ceiling.getTime());
+ items.push({ ...normalize(record, parsed), advancesSyncCursor: advances });
+ }
+
+ return ceiling !== undefined ? { items, cursorCeiling: ceiling } : { items };
+ }
+
+ /**
+ * Stable id of a history row. It hashes the RAW timestamp text, never an interpreted Date, so
+ * the same row always gets the same id whatever date order was inferred or learned.
+ * The humanised `timestamp` ("2 hours ago") changes over time and is only used if it looks absolute.
+ */
+ private historyId(kind: 'episode' | 'movie', record: BazarrHistoryBase, ref: unknown): string {
+ const prefix = `bazarr-${kind}-history-`;
+ if (typeof record.id === 'number') {
+ return `${prefix}${record.id}`;
+ }
+
+ const stamp =
+ record.parsed_timestamp ??
+ (record.timestamp !== undefined && ABSOLUTE_TIMESTAMP.test(record.timestamp)
+ ? record.timestamp
+ : null);
+ return `${prefix}${stableHash(
+ JSON.stringify([
+ ref,
+ record.action ?? null,
+ stamp,
+ record.description ?? null,
+ record.subtitles_path ?? null,
+ record.subs_id ?? null,
+ record.provider ?? null,
+ record.score ?? null,
+ record.language?.code2 ?? record.language?.name ?? null,
+ ])
+ )}`;
+ }
+
+ private historyType(action: number | undefined): string {
+ const label = action !== undefined ? BazarrHistoryActions[action] : undefined;
+ return label !== undefined ? `subtitle_${label}` : 'subtitle_event';
+ }
+
+ private commonHistoryMetadata(record: BazarrHistoryBase): Record {
+ return {
+ action: record.action,
+ language: record.language?.name ?? record.language?.code2,
+ forced: record.language?.forced,
+ hi: record.language?.hi,
+ provider: record.provider,
+ score: record.score,
+ subtitlesPath: record.subtitles_path,
+ upgradable: record.upgradable,
+ blacklisted: record.blacklisted,
+ };
+ }
+
+ private normalizeEpisodeHistory(
+ record: BazarrEpisodeHistory,
+ parsed: Date | undefined
+ ): NormalizedActivity {
+ const name =
+ record.seriesTitle !== undefined
+ ? `${record.seriesTitle} ${record.episode_number ?? ''}`.trim()
+ : 'Unknown';
+
+ return {
+ id: this.historyId('episode', record, [record.sonarrSeriesId, record.sonarrEpisodeId]),
+ type: this.historyType(record.action),
+ name,
+ overview: record.description,
+ severity: record.blacklisted === true ? 'warn' : 'info',
+ itemId: record.sonarrEpisodeId !== undefined ? String(record.sonarrEpisodeId) : undefined,
+ timestamp: parsed ?? new Date(),
+ metadata: {
+ kind: 'episode',
+ ...this.commonHistoryMetadata(record),
+ episodeTitle: record.episodeTitle,
+ sonarrSeriesId: record.sonarrSeriesId,
+ sonarrEpisodeId: record.sonarrEpisodeId,
+ },
+ };
+ }
+
+ private normalizeMovieHistory(
+ record: BazarrMovieHistory,
+ parsed: Date | undefined
+ ): NormalizedActivity {
+ return {
+ id: this.historyId('movie', record, [record.radarrId]),
+ type: this.historyType(record.action),
+ name: record.title ?? 'Unknown',
+ overview: record.description,
+ severity: record.blacklisted === true ? 'warn' : 'info',
+ itemId: record.radarrId !== undefined ? String(record.radarrId) : undefined,
+ timestamp: parsed ?? new Date(),
+ metadata: {
+ kind: 'movie',
+ ...this.commonHistoryMetadata(record),
+ radarrId: record.radarrId,
+ },
+ };
+ }
+
+ /** Health issues are state, not events: the id is stable so a persistent issue is logged once */
+ private normalizeHealthIssue(issue: BazarrHealthIssue): NormalizedActivity {
+ return {
+ id: `bazarr-health-${stableHash(`${issue.object}|${issue.issue}`)}`,
+ type: 'health_warning',
+ name: `Health issue: ${issue.object}`,
+ overview: issue.issue,
+ severity: 'warn',
+ timestamp: new Date(),
+ // Stamped with the detection time, so it must not move the sync cursor
+ advancesSyncCursor: false,
+ metadata: { object: issue.object, issue: issue.issue },
+ };
+ }
+
+ private normalizeFailedJob(job: BazarrJob): NormalizedActivity {
+ return {
+ id: `bazarr-job-${idPart(job.job_id)}-${idPart(job.last_run_time ?? 'unknown')}`,
+ type: 'job_failed',
+ name: `Job failed: ${job.job_name}`,
+ overview: job.progress_message ?? 'Background job failed',
+ severity: 'error',
+ timestamp: parseJobTime(job.last_run_time) ?? new Date(),
+ // Failed jobs are state (the queue keeps them), not a history stream: never move the cursor
+ advancesSyncCursor: false,
+ metadata: {
+ jobId: job.job_id,
+ jobName: job.job_name,
+ status: job.status,
+ lastRunTime: job.last_run_time,
+ },
+ };
+ }
+}
diff --git a/packages/provider-bazarr/src/bazarr.types.ts b/packages/provider-bazarr/src/bazarr.types.ts
new file mode 100644
index 0000000..d65b8e1
--- /dev/null
+++ b/packages/provider-bazarr/src/bazarr.types.ts
@@ -0,0 +1,105 @@
+/**
+ * Bazarr API types.
+ *
+ * Shapes are derived from Bazarr's flask-restx models (morpheus65535/bazarr).
+ * Every field except identifiers is optional because the API is not strictly
+ * versioned and the history endpoints in particular vary between releases.
+ */
+
+/** Bazarr wraps every JSON response in a `data` envelope */
+export interface BazarrEnvelope {
+ readonly data: T;
+ readonly total?: number;
+}
+
+/** GET /api/system/status */
+export interface BazarrSystemStatus {
+ bazarr_version: string;
+ sonarr_version?: string;
+ radarr_version?: string;
+ operating_system?: string;
+ python_version?: string;
+ bazarr_config_directory?: string;
+ timezone?: string;
+ start_time?: number;
+ [key: string]: unknown;
+}
+
+/** Subtitle language as returned in history records */
+export interface BazarrLanguage {
+ name?: string;
+ code2?: string;
+ code3?: string;
+ forced?: boolean;
+ hi?: boolean;
+}
+
+/** Fields shared by episode and movie history records */
+export interface BazarrHistoryBase {
+ /**
+ * Row id. NOTE: Bazarr's marshalling model does not currently expose it, so it is
+ * normally absent and a stable hash of the record is used instead.
+ */
+ id?: number;
+ /** Humanised timestamp ("2 hours ago") - not parseable */
+ timestamp?: string;
+ /** Server-locale `%x %X` formatted timestamp, e.g. "10/10/26 12:00:01" */
+ parsed_timestamp?: string;
+ description?: string;
+ action?: number;
+ provider?: string | null;
+ score?: string | number | null;
+ language?: BazarrLanguage | null;
+ subs_id?: string | null;
+ subtitles_path?: string | null;
+ blacklisted?: boolean;
+ upgradable?: boolean;
+}
+
+/** GET /api/episodes/history record */
+export interface BazarrEpisodeHistory extends BazarrHistoryBase {
+ seriesTitle?: string;
+ episode_number?: string;
+ episodeTitle?: string;
+ sonarrSeriesId?: number;
+ sonarrEpisodeId?: number;
+}
+
+/** GET /api/movies/history record */
+export interface BazarrMovieHistory extends BazarrHistoryBase {
+ title?: string;
+ radarrId?: number;
+}
+
+/** GET /api/system/health item */
+export interface BazarrHealthIssue {
+ object: string;
+ issue: string;
+}
+
+/** GET /api/system/jobs item */
+export interface BazarrJob {
+ job_id: number | string;
+ job_name: string;
+ status: string;
+ last_run_time?: string | null;
+ is_progress?: boolean;
+ progress_value?: number;
+ progress_max?: number;
+ progress_message?: string | null;
+}
+
+/**
+ * History `action` codes, verified against the `history_log(...)` call sites in
+ * Bazarr's source (subtitles/wanted, manual, upgrade, upload, tools/subsyncer,
+ * tools/delete, tools/translate).
+ */
+export const BazarrHistoryActions: Readonly> = {
+ 0: 'delete',
+ 1: 'download',
+ 2: 'manual',
+ 3: 'upgrade',
+ 4: 'upload',
+ 5: 'sync',
+ 6: 'translated',
+};
diff --git a/packages/provider-bazarr/src/bazarr.utils.spec.ts b/packages/provider-bazarr/src/bazarr.utils.spec.ts
new file mode 100644
index 0000000..b65115f
--- /dev/null
+++ b/packages/provider-bazarr/src/bazarr.utils.spec.ts
@@ -0,0 +1,214 @@
+import { describe, expect, it } from 'vitest';
+
+import { inferSlashDateOrder, parseBazarrTimestamp, stableHash } from './bazarr.utils.js';
+
+describe('parseBazarrTimestamp', () => {
+ it('parses ISO-like strings', () => {
+ expect(parseBazarrTimestamp({ timestamp: '2026-10-10 10:00:00' })).toEqual(
+ new Date('2026-10-10T10:00:00')
+ );
+ expect(parseBazarrTimestamp({ parsed_timestamp: '2026-10-10T10:00:00' })).toEqual(
+ new Date('2026-10-10T10:00:00')
+ );
+ });
+
+ it('parses the C/en_US %x %X format (month first, 2-digit year)', () => {
+ expect(parseBazarrTimestamp({ parsed_timestamp: '10/09/26 12:00:01' }, 'mdy')).toEqual(
+ new Date(2026, 9, 9, 12, 0, 1)
+ );
+ });
+
+ it('parses 4-digit year and 12-hour clock', () => {
+ expect(parseBazarrTimestamp({ parsed_timestamp: '10/09/2026 12:00:01 AM' }, 'mdy')).toEqual(
+ new Date(2026, 9, 9, 0, 0, 1)
+ );
+ expect(parseBazarrTimestamp({ parsed_timestamp: '10/09/2026 1:30:00 PM' }, 'mdy')).toEqual(
+ new Date(2026, 9, 9, 13, 30, 0)
+ );
+ });
+
+ it('treats a first number above 12 as the day', () => {
+ expect(parseBazarrTimestamp({ parsed_timestamp: '25/09/2026 08:00:00' })).toEqual(
+ new Date(2026, 8, 25, 8, 0, 0)
+ );
+ });
+
+ it('treats dotted dates as day first', () => {
+ expect(parseBazarrTimestamp({ parsed_timestamp: '09.10.2026 08:00:00' })).toEqual(
+ new Date(2026, 9, 9, 8, 0, 0)
+ );
+ });
+
+ it('returns undefined for humanised or garbage values', () => {
+ expect(parseBazarrTimestamp({ timestamp: '2 hours ago' })).toBeUndefined();
+ expect(parseBazarrTimestamp({ timestamp: 'garbage' })).toBeUndefined();
+ expect(parseBazarrTimestamp({ parsed_timestamp: '13/45/26 99:99:99' })).toBeUndefined();
+ expect(parseBazarrTimestamp({})).toBeUndefined();
+ });
+});
+
+describe('stableHash', () => {
+ it('is deterministic and 16 hex chars', () => {
+ expect(stableHash('abc')).toBe(stableHash('abc'));
+ expect(stableHash('abc')).toMatch(/^[0-9a-f]{16}$/);
+ });
+
+ it('differs for different input', () => {
+ expect(stableHash('abc')).not.toBe(stableHash('abd'));
+ });
+});
+
+describe('parseBazarrTimestamp edge cases', () => {
+ it('falls through to timestamp when parsed_timestamp is unparseable', () => {
+ expect(
+ parseBazarrTimestamp({ parsed_timestamp: 'an hour ago', timestamp: '2026-10-10 10:00:00' })
+ ).toEqual(new Date(2026, 9, 10, 10, 0, 0));
+ });
+
+ it('ignores empty strings and missing fields', () => {
+ expect(parseBazarrTimestamp({ parsed_timestamp: '', timestamp: '' })).toBeUndefined();
+ expect(parseBazarrTimestamp({})).toBeUndefined();
+ });
+
+ it('rejects calendar-invalid dates instead of rolling them over', () => {
+ expect(parseBazarrTimestamp({ timestamp: '2026-02-30 10:00:00' })).toBeUndefined();
+ expect(parseBazarrTimestamp({ timestamp: '13/45/26 10:00:00' })).toBeUndefined();
+ });
+
+ it('accepts ISO-like values without seconds and with a T separator', () => {
+ expect(parseBazarrTimestamp({ timestamp: '2026-10-10T10:05' })).toEqual(
+ new Date(2026, 9, 10, 10, 5, 0)
+ );
+ });
+
+ it('handles 12 AM and 12 PM on the 12-hour clock', () => {
+ expect(parseBazarrTimestamp({ parsed_timestamp: '10/09/2026 12:30:00 AM' }, 'mdy')).toEqual(
+ new Date(2026, 9, 9, 0, 30, 0)
+ );
+ expect(parseBazarrTimestamp({ parsed_timestamp: '10/09/2026 12:30:00 PM' }, 'mdy')).toEqual(
+ new Date(2026, 9, 9, 12, 30, 0)
+ );
+ });
+
+ it('does not accept a date-only string', () => {
+ expect(parseBazarrTimestamp({ timestamp: '2026-10-10' })).toBeUndefined();
+ });
+});
+
+describe('stableHash edge cases', () => {
+ it('hashes the empty string to the FNV-1a offset basis', () => {
+ expect(stableHash('')).toBe('cbf29ce484222325');
+ });
+
+ it('is sensitive to the field separator', () => {
+ expect(stableHash('a|b')).not.toBe(stableHash('ab'));
+ });
+});
+
+describe('slash date order', () => {
+ it('reads an ambiguous date as 5 October when the response is day-first', () => {
+ expect(parseBazarrTimestamp({ parsed_timestamp: '05/10/26 08:00:00' }, 'dmy')).toEqual(
+ new Date(2026, 9, 5, 8, 0, 0)
+ );
+ });
+
+ it('reads the same ambiguous date as 10 May when the response is month-first', () => {
+ expect(parseBazarrTimestamp({ parsed_timestamp: '05/10/26 08:00:00' }, 'mdy')).toEqual(
+ new Date(2026, 4, 10, 8, 0, 0)
+ );
+ });
+
+ it('refuses to guess an ambiguous date without a known order', () => {
+ expect(parseBazarrTimestamp({ parsed_timestamp: '05/10/26 08:00:00' })).toBeUndefined();
+ });
+
+ it('parses dates that are the same in both orders without a known order', () => {
+ expect(parseBazarrTimestamp({ parsed_timestamp: '10/10/26 08:00:00' })).toEqual(
+ new Date(2026, 9, 10, 8, 0, 0)
+ );
+ });
+
+ it('lets an unambiguous row override the supplied order', () => {
+ expect(parseBazarrTimestamp({ parsed_timestamp: '25/10/26 08:00:00' }, 'mdy')).toEqual(
+ new Date(2026, 9, 25, 8, 0, 0)
+ );
+ });
+
+ it('keeps dotted dates day-first regardless of order', () => {
+ expect(parseBazarrTimestamp({ parsed_timestamp: '05.10.2026 08:00:00' }, 'mdy')).toEqual(
+ new Date(2026, 9, 5, 8, 0, 0)
+ );
+ });
+
+ describe('inferSlashDateOrder', () => {
+ it('infers day-first from a first number above 12', () => {
+ expect(
+ inferSlashDateOrder([
+ { parsed_timestamp: '05/10/26 08:00:00' },
+ { parsed_timestamp: '25/09/26 08:00:00' },
+ ])
+ ).toBe('dmy');
+ });
+
+ it('infers month-first from a second number above 12', () => {
+ expect(
+ inferSlashDateOrder([
+ { parsed_timestamp: '05/10/26 08:00:00' },
+ { parsed_timestamp: '09/25/26 08:00:00' },
+ ])
+ ).toBe('mdy');
+ });
+
+ it('returns undefined when every row is ambiguous', () => {
+ expect(
+ inferSlashDateOrder([
+ { parsed_timestamp: '05/10/26 08:00:00' },
+ { parsed_timestamp: '04/10/26 08:00:00' },
+ { parsed_timestamp: '10/10/26 08:00:00' },
+ ])
+ ).toBeUndefined();
+ });
+
+ it('returns undefined on conflicting evidence', () => {
+ expect(
+ inferSlashDateOrder([
+ { parsed_timestamp: '25/09/26 08:00:00' },
+ { parsed_timestamp: '09/25/26 08:00:00' },
+ ])
+ ).toBeUndefined();
+ });
+
+ it('ignores ISO, dotted, humanised and missing values', () => {
+ expect(
+ inferSlashDateOrder([
+ { parsed_timestamp: '2026-10-05 08:00:00' },
+ { parsed_timestamp: '25.09.2026 08:00:00' },
+ { timestamp: '2 hours ago' },
+ {},
+ ])
+ ).toBeUndefined();
+ });
+ });
+});
+
+describe('year-first dates', () => {
+ it('reads a four-digit year first as Y/M/D', () => {
+ expect(parseBazarrTimestamp({ parsed_timestamp: '2026/10/05 08:00:00' })).toEqual(
+ new Date(2026, 9, 5, 8, 0, 0)
+ );
+ expect(parseBazarrTimestamp({ parsed_timestamp: '2026.10.05 08:00:00' })).toEqual(
+ new Date(2026, 9, 5, 8, 0, 0)
+ );
+ });
+
+ it('is not evidence for the day/month order of other rows', () => {
+ expect(inferSlashDateOrder([{ parsed_timestamp: '2026/10/25 08:00:00' }])).toBeUndefined();
+ });
+
+ it('leaves a two-digit year first ambiguous by the usual rules', () => {
+ // 26/10/05: first number > 12, so it is read day-first (26 October 2005), not Y/M/D
+ expect(parseBazarrTimestamp({ parsed_timestamp: '26/10/05 08:00:00' })).toEqual(
+ new Date(2005, 9, 26, 8, 0, 0)
+ );
+ });
+});
diff --git a/packages/provider-bazarr/src/bazarr.utils.ts b/packages/provider-bazarr/src/bazarr.utils.ts
new file mode 100644
index 0000000..a1639e8
--- /dev/null
+++ b/packages/provider-bazarr/src/bazarr.utils.ts
@@ -0,0 +1,175 @@
+import type { BazarrHistoryBase } from './bazarr.types.js';
+
+/**
+ * Stable 64-bit FNV-1a hash rendered as 16 hex chars.
+ * Used to derive deterministic activity ids (no Date.now(), no node:crypto).
+ */
+export function stableHash(input: string): string {
+ const prime = 0x100000001b3n;
+ const mask = 0xffffffffffffffffn;
+ let hash = 0xcbf29ce484222325n;
+
+ for (let i = 0; i < input.length; i++) {
+ hash ^= BigInt(input.charCodeAt(i));
+ hash = (hash * prime) & mask;
+ }
+
+ return hash.toString(16).padStart(16, '0');
+}
+
+const ISO_LIKE = /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})(?::(\d{2}))?/;
+/** Year-first locale dates such as `2026/10/05 08:00:00` (ja/zh/ko `%x`): always Y/M/D */
+const YMD_LIKE =
+ /^(\d{4})[/.-](\d{1,2})[/.-](\d{1,2})[ ,T]+(\d{1,2}):(\d{2})(?::(\d{2}))?\s*(AM|PM)?$/i;
+const LOCALE_LIKE =
+ /^(\d{1,2})([/.-])(\d{1,2})[/.-](\d{2,4})[ ,T]+(\d{1,2}):(\d{2})(?::(\d{2}))?\s*(AM|PM)?$/i;
+
+/**
+ * Field order of a numeric slash date. Bazarr's `parsed_timestamp` is Python's locale
+ * `%x %X`, so `05/10/26` is 10 May (`mdy`) or 5 October (`dmy`) depending on the host locale.
+ */
+export type SlashDateOrder = 'mdy' | 'dmy';
+
+function build(
+ year: number,
+ month: number,
+ day: number,
+ hour: number,
+ minute: number,
+ second: number
+): Date | undefined {
+ const date = new Date(year, month - 1, day, hour, minute, second);
+ const valid =
+ !Number.isNaN(date.getTime()) &&
+ date.getFullYear() === year &&
+ date.getMonth() === month - 1 &&
+ date.getDate() === day;
+ return valid ? date : undefined;
+}
+
+/**
+ * Resolve the order of one slash date: `undefined` when it cannot be known from the
+ * numbers alone (both <= 12 and different).
+ */
+function rowOrder(first: number, second: number): SlashDateOrder | undefined {
+ if (first === second) return 'mdy'; // identical either way
+ if (first > 12) return 'dmy';
+ if (second > 12) return 'mdy';
+ return undefined;
+}
+
+/**
+ * Parse a timestamp string as emitted by Bazarr (naive local time).
+ * Supports ISO-like values and Python's locale `%x %X` output. Ambiguous slash dates
+ * (both numbers <= 12) are only parsed when `order` says how to read them; dotted dates
+ * are always day-first; a four-digit year first (`2026/10/05`) is always Y/M/D. A two-digit
+ * year first (`26/10/05`) is deliberately not special-cased: it follows the ambiguity rules above.
+ */
+function parseDateString(value: string, order: SlashDateOrder | undefined): Date | undefined {
+ const text = value.trim();
+
+ const iso = ISO_LIKE.exec(text);
+ if (iso !== null) {
+ return build(
+ Number(iso[1]),
+ Number(iso[2]),
+ Number(iso[3]),
+ Number(iso[4]),
+ Number(iso[5]),
+ Number(iso[6] ?? '0')
+ );
+ }
+
+ const ymd = YMD_LIKE.exec(text);
+ if (ymd !== null) {
+ let hour = Number(ymd[4]);
+ const meridiem = ymd[7]?.toUpperCase();
+ if (meridiem === 'PM' && hour < 12) hour += 12;
+ if (meridiem === 'AM' && hour === 12) hour = 0;
+ return build(
+ Number(ymd[1]),
+ Number(ymd[2]),
+ Number(ymd[3]),
+ hour,
+ Number(ymd[5]),
+ Number(ymd[6] ?? '0')
+ );
+ }
+
+ const m = LOCALE_LIKE.exec(text);
+ if (m === null) {
+ return undefined;
+ }
+
+ const first = Number(m[1]);
+ const second = Number(m[3]);
+ let year = Number(m[4]);
+ if ((m[4] ?? '').length === 2) {
+ year += 2000;
+ }
+
+ const resolved = m[2] === '.' ? 'dmy' : (rowOrder(first, second) ?? order);
+ if (resolved === undefined) {
+ return undefined;
+ }
+ const month = resolved === 'dmy' ? second : first;
+ const day = resolved === 'dmy' ? first : second;
+
+ let hour = Number(m[5]);
+ const meridiem = m[8]?.toUpperCase();
+ if (meridiem === 'PM' && hour < 12) hour += 12;
+ if (meridiem === 'AM' && hour === 12) hour = 0;
+
+ return build(year, month, day, hour, Number(m[6]), Number(m[7] ?? '0'));
+}
+
+type TimestampFields = Pick;
+
+/**
+ * Infer the slash-date order of a whole response: any date whose first number is > 12
+ * proves day-first, any whose second number is > 12 proves month-first. Returns undefined
+ * when there is no evidence or the evidence conflicts.
+ */
+export function inferSlashDateOrder(
+ records: readonly TimestampFields[]
+): SlashDateOrder | undefined {
+ let dmy = false;
+ let mdy = false;
+
+ for (const record of records) {
+ for (const candidate of [record.parsed_timestamp, record.timestamp]) {
+ if (typeof candidate !== 'string') continue;
+ const m = LOCALE_LIKE.exec(candidate.trim());
+ if (m === null || m[2] === '.') continue;
+ if (Number(m[1]) === Number(m[3])) continue;
+ const found = rowOrder(Number(m[1]), Number(m[3]));
+ if (found === 'dmy') dmy = true;
+ if (found === 'mdy') mdy = true;
+ }
+ }
+
+ if (dmy === mdy) return undefined;
+ return dmy ? 'dmy' : 'mdy';
+}
+
+/**
+ * Best-effort absolute timestamp of a history record.
+ * Bazarr's `timestamp` is humanised ("2 hours ago"); `parsed_timestamp` is locale
+ * formatted and contains the only absolute time. Returns undefined when nothing parses so
+ * callers can decide on a fallback.
+ */
+export function parseBazarrTimestamp(
+ record: TimestampFields,
+ order?: SlashDateOrder
+): Date | undefined {
+ for (const candidate of [record.parsed_timestamp, record.timestamp]) {
+ if (typeof candidate === 'string' && candidate !== '') {
+ const date = parseDateString(candidate, order);
+ if (date !== undefined) {
+ return date;
+ }
+ }
+ }
+
+ return undefined;
+}
diff --git a/packages/provider-bazarr/src/index.spec.ts b/packages/provider-bazarr/src/index.spec.ts
new file mode 100644
index 0000000..5ecea32
--- /dev/null
+++ b/packages/provider-bazarr/src/index.spec.ts
@@ -0,0 +1,19 @@
+import { describe, expect, it } from 'vitest';
+
+import * as bazarr from './index.js';
+
+describe('package exports', () => {
+ it('exposes the provider, client and parser helpers', () => {
+ expect(new bazarr.BazarrProvider().id).toBe('bazarr');
+ expect(typeof bazarr.BazarrClient).toBe('function');
+ expect(typeof bazarr.parseBazarrLogLine).toBe('function');
+ expect(typeof bazarr.parseBazarrLogLineWithContext).toBe('function');
+ expect(typeof bazarr.isBazarrLogContinuation).toBe('function');
+ expect(typeof bazarr.mapBazarrLevel).toBe('function');
+ expect(typeof bazarr.parseBazarrTimestamp).toBe('function');
+ expect(typeof bazarr.stableHash).toBe('function');
+ expect(bazarr.BAZARR_LOG_FILE_CONFIG.filePatterns).toContain('bazarr.log');
+ expect(bazarr.BAZARR_CORRELATION_PATTERNS).toEqual([]);
+ expect(bazarr.BazarrHistoryActions[1]).toBe('download');
+ });
+});
diff --git a/packages/provider-bazarr/src/index.ts b/packages/provider-bazarr/src/index.ts
new file mode 100644
index 0000000..68dc25b
--- /dev/null
+++ b/packages/provider-bazarr/src/index.ts
@@ -0,0 +1,23 @@
+export { BazarrProvider } from './bazarr.provider.js';
+export { BazarrClient } from './bazarr.client.js';
+export {
+ parseBazarrLogLine,
+ parseBazarrLogLineWithContext,
+ isBazarrLogContinuation,
+ mapBazarrLevel,
+ BAZARR_LOG_FILE_CONFIG,
+ BAZARR_CORRELATION_PATTERNS,
+} from './bazarr.parser.js';
+export { parseBazarrTimestamp, stableHash } from './bazarr.utils.js';
+export { BazarrHistoryActions } from './bazarr.types.js';
+
+export type {
+ BazarrEnvelope,
+ BazarrEpisodeHistory,
+ BazarrHealthIssue,
+ BazarrHistoryBase,
+ BazarrJob,
+ BazarrLanguage,
+ BazarrMovieHistory,
+ BazarrSystemStatus,
+} from './bazarr.types.js';
diff --git a/packages/provider-bazarr/tsconfig.json b/packages/provider-bazarr/tsconfig.json
new file mode 100644
index 0000000..f103ea7
--- /dev/null
+++ b/packages/provider-bazarr/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "outDir": "./dist",
+ "rootDir": "./src",
+ "declaration": true,
+ "declarationMap": true,
+ "composite": true,
+ "lib": ["ES2022", "DOM"]
+ },
+ "include": ["src/**/*"],
+ "exclude": ["node_modules", "dist"],
+ "references": [{ "path": "../core" }]
+}
diff --git a/packages/provider-bazarr/tsup.config.ts b/packages/provider-bazarr/tsup.config.ts
new file mode 100644
index 0000000..4a0ea44
--- /dev/null
+++ b/packages/provider-bazarr/tsup.config.ts
@@ -0,0 +1,22 @@
+import { defineConfig } from 'tsup';
+
+export default defineConfig({
+ entry: ['src/index.ts'],
+ format: ['cjs', 'esm'],
+ dts: {
+ compilerOptions: {
+ composite: false,
+ },
+ },
+ splitting: false,
+ sourcemap: true,
+ clean: true,
+ treeshake: true,
+ minify: false,
+ external: ['@logarr/core'],
+ outExtension({ format }) {
+ return {
+ js: format === 'cjs' ? '.cjs' : '.js',
+ };
+ },
+});
diff --git a/packages/provider-bazarr/vitest.config.ts b/packages/provider-bazarr/vitest.config.ts
new file mode 100644
index 0000000..a2f6052
--- /dev/null
+++ b/packages/provider-bazarr/vitest.config.ts
@@ -0,0 +1,8 @@
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ test: {
+ include: ['src/**/*.spec.ts'],
+ exclude: ['dist/**', 'node_modules/**'],
+ },
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 9551804..097ee76 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -62,6 +62,9 @@ importers:
'@logarr/provider-arr':
specifier: workspace:*
version: link:../../packages/provider-arr
+ '@logarr/provider-bazarr':
+ specifier: workspace:*
+ version: link:../../packages/provider-bazarr
'@logarr/provider-emby':
specifier: workspace:*
version: link:../../packages/provider-emby
@@ -450,6 +453,22 @@ importers:
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-bazarr:
+ dependencies:
+ '@logarr/core':
+ specifier: workspace:*
+ version: link:../core
+ 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-emby:
dependencies:
'@logarr/core':
diff --git a/turbo.json b/turbo.json
index 248fe4a..2b0daef 100644
--- a/turbo.json
+++ b/turbo.json
@@ -30,6 +30,8 @@
"WHISPARR_API_KEY",
"LIDARR_URL",
"LIDARR_API_KEY",
+ "BAZARR_URL",
+ "BAZARR_API_KEY",
"JELLYFIN_LOGS_PATH",
"PLEX_LOGS_PATH",
"EMBY_LOGS_PATH",
@@ -37,7 +39,8 @@
"RADARR_LOGS_PATH",
"PROWLARR_LOGS_PATH",
"WHISPARR_LOGS_PATH",
- "LIDARR_LOGS_PATH"
+ "LIDARR_LOGS_PATH",
+ "BAZARR_LOGS_PATH"
],
"tasks": {
"build": {