From 24882ef269eff34038c1777045d0faae82b1d0bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1clav=20Brzezina?= Date: Tue, 13 Oct 2026 15:07:23 +0200 Subject: [PATCH 1/7] fix(ingestion): keep synthetic activity timestamps out of the sync cursor --- .../modules/ingestion/ingestion.service.ts | 27 +++++----- .../src/modules/ingestion/sync-cursor.spec.ts | 36 +++++++++++++ .../src/modules/ingestion/sync-cursor.ts | 20 ++++++++ packages/core/src/types.ts | 7 +++ .../provider-arr/src/arr.provider.spec.ts | 50 +++++++++++++++++++ packages/provider-arr/src/arr.provider.ts | 2 + 6 files changed, 130 insertions(+), 12 deletions(-) create mode 100644 apps/backend/src/modules/ingestion/sync-cursor.spec.ts create mode 100644 apps/backend/src/modules/ingestion/sync-cursor.ts create mode 100644 packages/provider-arr/src/arr.provider.spec.ts diff --git a/apps/backend/src/modules/ingestion/ingestion.service.ts b/apps/backend/src/modules/ingestion/ingestion.service.ts index 66dd43d..a2c5960 100644 --- a/apps/backend/src/modules/ingestion/ingestion.service.ts +++ b/apps/backend/src/modules/ingestion/ingestion.service.ts @@ -19,6 +19,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, @@ -241,18 +243,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/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, })); } From b695c35ab294073ba290e6ac37226ac79230f9cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1clav=20Brzezina?= Date: Sat, 10 Oct 2026 02:52:18 +0200 Subject: [PATCH 2/7] feat(providers): add Bazarr integration Add @logarr/provider-bazarr, a standalone provider for Bazarr's /api. - X-API-KEY client with `data` envelope unwrapping; connection test via /api/system/status - Activity from episode and movie subtitle history (action labels from Bazarr's history_log codes), system health and failed jobs, with stable content-hash ids (the history API exposes no row id), since-filtering and bounded paging - File tailing of bazarr.log with a parser for Bazarr's pipe-delimited format, preserving `|` inside messages and unescaping inline tracebacks - Backend registration, env seeding (BAZARR_URL/BAZARR_API_KEY, /bazarr-logs), proxy path, Dockerfiles, compose, turbo env - Frontend: Bazarr integration flipped to available with truthful capability flags, provider icon and logs deep link - README updates, including a note about Bazarr's naive local timestamps --- .env.example | 12 + .env.tpl | 4 + README.md | 16 +- apps/backend/Dockerfile | 2 + apps/backend/Dockerfile.dev | 2 +- apps/backend/package.json | 1 + .../modules/ingestion/ingestion.service.ts | 3 + .../src/modules/proxy/proxy.service.ts | 1 + .../modules/servers/server-seed.service.ts | 7 + .../src/modules/servers/servers.service.ts | 3 + .../src/app/(dashboard)/logs/page.tsx | 3 + .../src/components/provider-icon.test.tsx | 29 +- .../frontend/src/components/provider-icon.tsx | 15 + apps/frontend/src/lib/integrations.test.ts | 43 + apps/frontend/src/lib/integrations.ts | 7 +- docker-compose.dev.yml | 4 + docker-compose.yml | 10 + packages/provider-bazarr/package.json | 35 + .../provider-bazarr/src/bazarr.client.spec.ts | 178 ++++ packages/provider-bazarr/src/bazarr.client.ts | 94 ++ .../provider-bazarr/src/bazarr.parser.spec.ts | 326 +++++++ packages/provider-bazarr/src/bazarr.parser.ts | 146 ++++ .../src/bazarr.provider.spec.ts | 814 ++++++++++++++++++ .../provider-bazarr/src/bazarr.provider.ts | 376 ++++++++ packages/provider-bazarr/src/bazarr.types.ts | 105 +++ .../provider-bazarr/src/bazarr.utils.spec.ts | 106 +++ packages/provider-bazarr/src/bazarr.utils.ts | 104 +++ packages/provider-bazarr/src/index.spec.ts | 19 + packages/provider-bazarr/src/index.ts | 23 + packages/provider-bazarr/tsconfig.json | 14 + packages/provider-bazarr/tsup.config.ts | 22 + packages/provider-bazarr/vitest.config.ts | 8 + pnpm-lock.yaml | 19 + turbo.json | 5 +- 34 files changed, 2546 insertions(+), 10 deletions(-) create mode 100644 packages/provider-bazarr/package.json create mode 100644 packages/provider-bazarr/src/bazarr.client.spec.ts create mode 100644 packages/provider-bazarr/src/bazarr.client.ts create mode 100644 packages/provider-bazarr/src/bazarr.parser.spec.ts create mode 100644 packages/provider-bazarr/src/bazarr.parser.ts create mode 100644 packages/provider-bazarr/src/bazarr.provider.spec.ts create mode 100644 packages/provider-bazarr/src/bazarr.provider.ts create mode 100644 packages/provider-bazarr/src/bazarr.types.ts create mode 100644 packages/provider-bazarr/src/bazarr.utils.spec.ts create mode 100644 packages/provider-bazarr/src/bazarr.utils.ts create mode 100644 packages/provider-bazarr/src/index.spec.ts create mode 100644 packages/provider-bazarr/src/index.ts create mode 100644 packages/provider-bazarr/tsconfig.json create mode 100644 packages/provider-bazarr/tsup.config.ts create mode 100644 packages/provider-bazarr/vitest.config.ts 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..8fbaa33 100644 --- a/.env.tpl +++ b/.env.tpl @@ -87,6 +87,9 @@ WHISPARR_API_KEY={{ op://Development/Logarr-Dev/WHISPARR_API_KEY }} # Lidarr LIDARR_URL={{ op://Development/Logarr-Dev/LIDARR_URL }} LIDARR_API_KEY={{ op://Development/Logarr-Dev/LIDARR_API_KEY }} +# Bazarr +BAZARR_URL={{ op://Development/Logarr-Dev/BAZARR_URL }} +BAZARR_API_KEY={{ op://Development/Logarr-Dev/BAZARR_API_KEY }} # ============================================================================= # SECURITY & AUTHENTICATION @@ -111,3 +114,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={{ op://Development/Logarr-Dev/BAZARR_LOGS_PATH }} diff --git a/README.md b/README.md index 6c26f02..0fb5e44 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ [![Docker](https://img.shields.io/badge/Docker-Ready-2496ED?logo=docker&logoColor=white)](https://www.docker.com/) [![License](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) -Unified logging, intelligent issue detection, and AI-powered analysis for Plex, Jellyfin, Emby, Sonarr, Radarr, Prowlarr, Whisparr, Lidarr, and more. +Unified logging, intelligent issue detection, and AI-powered analysis for Plex, Jellyfin, Emby, Sonarr, Radarr, Prowlarr, Whisparr, Lidarr, 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. + 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 a2c5960..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'; @@ -87,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() { 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 only advertise implemented capabilities', () => { + const bazarr = getIntegrationById('bazarr'); + expect(bazarr?.capabilities).toEqual({ + realTimeLogs: false, + 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..d34b2c7 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, + realTimeLogs: false, 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/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.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..e7d55a6 --- /dev/null +++ b/packages/provider-bazarr/src/bazarr.provider.spec.ts @@ -0,0 +1,814 @@ +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-09-21 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); +}); + +afterEach(() => { + 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('Not connected'); + await expect(provider.getServerInfo()).rejects.toThrow('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, 8, 21, 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'); + 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).toHaveBeenCalled(); + }); + + it('filters history older than since (with a timezone safety margin)', async () => { + const old: BazarrEpisodeHistory = { + ...episode, + id: 12, + parsed_timestamp: '10/01/26 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', () => { + function page(count: number, firstId: number, parsed: string): BazarrEpisodeHistory[] { + return Array.from({ length: count }, (_, i) => ({ + ...episode, + id: firstId + i, + parsed_timestamp: parsed, + })); + } + + function historyCalls(): URL[] { + return mockFetch.mock.calls + .map((c) => new URL(String(c[0]))) + .filter((u) => u.pathname.endsWith('/episodes/history')); + } + + it('fetches only the first page without since', async () => { + route({ episodes: ok({ data: page(50, 1, '10/10/26 12:00:00') }) }); + const provider = await connected(); + await provider.getActivity(); + + expect(historyCalls()).toHaveLength(1); + }); + + it('follows full pages while records are newer than since, up to 3 pages', async () => { + route({ + episodes: (url: URL) => + ok({ data: page(50, Number(url.searchParams.get('start')) + 1, '10/10/26 12:00:00') }), + }); + const provider = await connected(); + const activity = await provider.getActivity(new Date(2026, 9, 10, 11, 0, 0)); + + const starts = historyCalls().map((u) => u.searchParams.get('start')); + expect(starts).toEqual(['0', '50', '100']); + expect(activity.filter((a) => a.type === 'subtitle_download')).toHaveLength(150); + }); + + it('stops on a short page', async () => { + route({ + episodes: (url: URL) => + url.searchParams.get('start') === '0' + ? ok({ data: page(50, 1, '10/10/26 12:00:00') }) + : ok({ data: page(10, 51, '10/10/26 12:00:00') }), + }); + const provider = await connected(); + await provider.getActivity(new Date(2026, 9, 10, 11, 0, 0)); + + expect(historyCalls()).toHaveLength(2); + }); + + it('stops once the oldest record is older than since', async () => { + route({ episodes: ok({ data: page(50, 1, '09/01/26 12:00:00') }) }); + const provider = await connected(); + await provider.getActivity(new Date(2026, 9, 10, 11, 0, 0)); + + expect(historyCalls()).toHaveLength(1); + }); + }); +}); + +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-09-21 12:00:00' }, + { id: 2, parsed_timestamp: '2026-09-21 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-09-21 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'); + }); + + 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-09-21 12:00:00' }] }), + movies: ok({ data: [{ id: 1, parsed_timestamp: '2026-09-21 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('stops after the first page when no row on it has a parseable time', async () => { + const page = Array.from({ length: 50 }, (_, i) => ({ id: i + 1, timestamp: 'recently' })); + const starts: string[] = []; + route({ + episodes: (url: URL) => { + starts.push(url.searchParams.get('start') ?? ''); + return ok({ data: page }); + }, + }); + const provider = await connected(); + await provider.getActivity(new Date()); + + expect(starts).toEqual(['0']); + }); + + it('still bounds paging at 3 pages when parseable rows never reach the cutoff', async () => { + const starts: string[] = []; + route({ + episodes: (url: URL) => { + starts.push(url.searchParams.get('start') ?? ''); + const rows = Array.from({ length: 50 }, (_, i) => ({ + id: i + 1, + parsed_timestamp: '2026-10-10 12:00:00', + })); + return ok({ data: rows }); + }, + }); + const provider = await connected(); + await provider.getActivity(new Date(2026, 9, 10, 11, 0, 0)); + + expect(starts).toEqual(['0', '50', '100']); + }); + + 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..d7f4817 --- /dev/null +++ b/packages/provider-bazarr/src/bazarr.provider.ts @@ -0,0 +1,376 @@ +import { BazarrClient } from './bazarr.client.js'; +import { + BAZARR_CORRELATION_PATTERNS, + BAZARR_LOG_FILE_CONFIG, + isBazarrLogContinuation, + parseBazarrLogLine, + parseBazarrLogLineWithContext, +} from './bazarr.parser.js'; +import { BazarrHistoryActions } from './bazarr.types.js'; +import { parseBazarrTimestamp, stableHash } from './bazarr.utils.js'; + +import type { + BazarrEpisodeHistory, + BazarrHealthIssue, + BazarrHistoryBase, + BazarrJob, + BazarrMovieHistory, +} from './bazarr.types.js'; +import type { + ConnectionStatus, + CorrelationPattern, + LogFileConfig, + LogParseContext, + LogParseResult, + MediaServerProvider, + NormalizedActivity, + NormalizedSession, + NormalizedUser, + ParsedLogEntry, + ProviderCapabilities, + ProviderConfig, + ServerInfo, +} from '@logarr/core'; + +/** History page size requested from Bazarr */ +const HISTORY_PAGE_SIZE = 50; +/** Maximum history pages fetched per endpoint per poll (when a `since` cutoff exists) */ +const HISTORY_MAX_PAGES = 3; +/** + * 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; + +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; + + 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 [episodes, movies, health, jobs] = await Promise.all([ + this.safely('episode history', () => + this.collectHistory( + (length, start) => client.getEpisodeHistory(length, start), + (rec) => this.normalizeEpisodeHistory(rec), + since + ) + ), + this.safely('movie history', () => + this.collectHistory( + (length, start) => client.getMovieHistory(length, start), + (rec) => this.normalizeMovieHistory(rec), + since + ) + ), + this.safely('health', async () => + (await client.getHealth()).map((issue) => this.normalizeHealthIssue(issue)) + ), + this.safely('jobs', async () => + (await client.getJobs('failed')) + .map((job) => this.normalizeFailedJob(job)) + .filter((activity) => since === undefined || activity.timestamp >= since) + ), + ]); + + return [...episodes, ...movies, ...health, ...jobs].sort( + (a, b) => b.timestamp.getTime() - a.timestamp.getTime() + ); + } + + private getClient(): BazarrClient { + if (this.client === null) { + throw new Error('Not connected to Bazarr server'); + } + return this.client; + } + + /** Run one activity section; a failing endpoint must not drop the others */ + private async safely( + label: string, + run: () => Promise + ): Promise { + try { + return await run(); + } catch (error) { + console.error(`Failed to fetch Bazarr ${label}:`, error); + return []; + } + } + + /** + * Page through newest-first history. Without `since` only the latest page is read; + * with `since`, pages are followed (max 3) until a short page or records older than the cutoff. + */ + private async collectHistory( + fetchPage: (length: number, start: number) => Promise, + normalize: (record: T) => NormalizedActivity, + since: Date | undefined + ): Promise { + const cutoff = since !== undefined ? since.getTime() - HISTORY_SINCE_MARGIN_MS : undefined; + const maxPages = cutoff !== undefined ? HISTORY_MAX_PAGES : 1; + const result: NormalizedActivity[] = []; + + for (let page = 0; page < maxPages; page++) { + const records = await fetchPage(HISTORY_PAGE_SIZE, page * HISTORY_PAGE_SIZE); + + let anyParsed = false; + for (const record of records) { + const parsed = parseBazarrTimestamp(record); + anyParsed ||= parsed !== undefined; + // Unparseable timestamps are kept; dedupe by stable id makes that safe + if (cutoff === undefined || parsed === undefined || parsed.getTime() >= cutoff) { + result.push(normalize(record)); + } + } + + const oldest = records[records.length - 1]; + const oldestTime = oldest !== undefined ? parseBazarrTimestamp(oldest) : undefined; + if ( + records.length < HISTORY_PAGE_SIZE || + // Nothing on this page has a usable time, so the cutoff can never be reached + (cutoff !== undefined && !anyParsed) || + (cutoff !== undefined && oldestTime !== undefined && oldestTime.getTime() < cutoff) + ) { + break; + } + } + + return result; + } + + private historyId(kind: 'episode' | 'movie', record: BazarrHistoryBase, ref: unknown): string { + const prefix = `bazarr-${kind}-history-`; + if (typeof record.id === 'number') { + return `${prefix}${record.id}`; + } + + // Bazarr's API model omits the row id: derive a stable one from the record content. + // The humanised `timestamp` changes over time, so only an absolute time is used. + const when = parseBazarrTimestamp(record)?.toISOString() ?? null; + return `${prefix}${stableHash( + JSON.stringify([ + ref, + record.action ?? null, + when, + 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): 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: parseBazarrTimestamp(record) ?? new Date(), + metadata: { + kind: 'episode', + ...this.commonHistoryMetadata(record), + episodeTitle: record.episodeTitle, + sonarrSeriesId: record.sonarrSeriesId, + sonarrEpisodeId: record.sonarrEpisodeId, + }, + }; + } + + private normalizeMovieHistory(record: BazarrMovieHistory): 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: parseBazarrTimestamp(record) ?? 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', + name: `Health issue: ${issue.object}`, + overview: issue.issue, + severity: 'warn', + timestamp: new Date(), + metadata: { object: issue.object, issue: issue.issue }, + }; + } + + private normalizeFailedJob(job: BazarrJob): NormalizedActivity { + return { + id: `bazarr-job-${job.job_id}-${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(), + 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..8456d50 --- /dev/null +++ b/packages/provider-bazarr/src/bazarr.utils.spec.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from 'vitest'; + +import { 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' })).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' })).toEqual( + new Date(2026, 9, 9, 0, 0, 1) + ); + expect(parseBazarrTimestamp({ parsed_timestamp: '10/09/2026 1:30:00 PM' })).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' })).toEqual( + new Date(2026, 9, 9, 0, 30, 0) + ); + expect(parseBazarrTimestamp({ parsed_timestamp: '10/09/2026 12:30:00 PM' })).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')); + }); +}); diff --git a/packages/provider-bazarr/src/bazarr.utils.ts b/packages/provider-bazarr/src/bazarr.utils.ts new file mode 100644 index 0000000..1d035e2 --- /dev/null +++ b/packages/provider-bazarr/src/bazarr.utils.ts @@ -0,0 +1,104 @@ +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}))?/; +const LOCALE_LIKE = + /^(\d{1,2})([/.-])(\d{1,2})[/.-](\d{2,4})[ ,T]+(\d{1,2}):(\d{2})(?::(\d{2}))?\s*(AM|PM)?$/i; + +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; +} + +/** + * Parse a timestamp string as emitted by Bazarr (naive local time). + * Supports ISO-like values and Python's locale `%x %X` output + * (e.g. "10/09/26 12:00:01", "10/09/2026 1:30:00 PM", "09.10.2026 08:00:00"). + */ +function parseDateString(value: string): 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 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; + } + + // Month first (C / en_US) unless the separator is a dot or the first number cannot be a month + const dayFirst = m[2] === '.' || first > 12; + const month = dayFirst ? second : first; + const day = dayFirst ? 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')); +} + +/** + * Best-effort absolute timestamp of a history record. + * Bazarr's `timestamp` is humanised ("2 hours ago"); `parsed_timestamp` is locale + * formatted. Returns undefined when nothing parses so callers can decide on a fallback. + */ +export function parseBazarrTimestamp( + record: Pick +): Date | undefined { + for (const candidate of [record.parsed_timestamp, record.timestamp]) { + if (typeof candidate === 'string' && candidate !== '') { + const date = parseDateString(candidate); + 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": { From 44e4b0284b9e293de6cec50b784d01f86cb7b632 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1clav=20Brzezina?= Date: Sat, 10 Oct 2026 20:18:45 +0200 Subject: [PATCH 3/7] refactor(provider-bazarr): align with shared provider conventions --- .github/agents/my-agent.agent.md | 14 ++++- apps/frontend/src/lib/integrations.test.ts | 4 +- apps/frontend/src/lib/integrations.ts | 2 +- .../src/bazarr.provider.spec.ts | 43 ++++++++++++--- .../provider-bazarr/src/bazarr.provider.ts | 53 +++++++++++++------ packages/provider-bazarr/src/index.spec.ts | 19 ------- 6 files changed, 90 insertions(+), 45 deletions(-) delete mode 100644 packages/provider-bazarr/src/index.spec.ts diff --git a/.github/agents/my-agent.agent.md b/.github/agents/my-agent.agent.md index 159bab5..d76a54c 100644 --- a/.github/agents/my-agent.agent.md +++ b/.github/agents/my-agent.agent.md @@ -2,7 +2,7 @@ name: logarr-maintainer description: Full-stack Logarr maintainer for this pnpm monorepo. Specializes in the NestJS backend, Next.js frontend, provider integrations, auth/RBAC, proxy safety, realtime flows, and Docker-based validation. target: github-copilot -tools: ["*"] +tools: ['*'] --- You are the Logarr maintainer agent for this repository. @@ -14,6 +14,7 @@ Your job is to make production-safe, minimal, well-validated changes in this cod Before changing code, identify the affected slice and read the source-of-truth files for that area. Always start with: + - `README.md` - `package.json` - `turbo.json` @@ -21,6 +22,7 @@ Always start with: - `.env.example` and `apps/backend/src/config/env.ts` when startup, ports, or environment behavior matter If the task touches auth, RBAC, users, sessions, external auth, or proxy behavior, read these in order: + 1. `AUTH_RBAC_EXECUTION_RUNBOOK.md` 2. `BACKEND_AUTH_RBAC_PLAN.md` 3. `AUTH_RBAC_TRACKER.md` @@ -36,20 +38,23 @@ 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. Key backend module areas: + - `auth`, `api-keys`, `audit`, `dashboard`, `issues`, `logs`, `servers`, `sessions`, `settings`, `retention`, `ingestion`, `file-ingestion`, `proxy` Key frontend areas: + - `src/app/(dashboard)/**` for product pages - `src/hooks/use-api.ts` for React Query data access and invalidation patterns - `src/hooks/use-*-socket.ts` and `src/lib/websocket.ts` for realtime behavior - `src/components/ui/**` for shared UI primitives Important repo nuance: + - not every integration has a full provider package; some providers are currently proxy-only and surfaced conservatively from `ServersService` - global backend guards already enforce authentication, permissions, server access, and recent re-auth for sensitive paths - ports differ across README, docker-compose, docker-compose.dev, env config, and Playwright; verify current config before assuming startup URLs @@ -70,6 +75,7 @@ Important repo nuance: Use the repo's existing commands and validate proportionally to the change. Primary commands: + - `pnpm lint` - `pnpm typecheck` - `pnpm test` @@ -77,6 +83,7 @@ Primary commands: - `pnpm test:e2e` Guidance: + - Run targeted Vitest suites first for the touched app or package, then broaden only as needed. - For backend auth, proxy, security, and persistence work, prefer TDD and add failing tests first. - For frontend changes, verify both desktop and mobile behavior and avoid regressing realtime flows. @@ -84,12 +91,14 @@ Guidance: - Do not claim completion if relevant tests are still failing. Coverage expectations: + - backend and frontend Vitest configs enforce 80% thresholds for statements, branches, functions, and lines - some files are intentionally excluded from coverage; do not assume excluded files are unimportant ## Auth / RBAC / Proxy Standard When touching any of these areas: + - follow the runbook's TDD order - update `AUTH_RBAC_TRACKER.md` if you implement or complete tracked work - prefer integration tests for controller, persistence, auth, and policy flows @@ -108,6 +117,7 @@ When touching any of these areas: ## Delivery Standard For each task: + 1. inspect the affected slice first 2. identify the existing pattern already used in this repo 3. implement the minimal correct change diff --git a/apps/frontend/src/lib/integrations.test.ts b/apps/frontend/src/lib/integrations.test.ts index 7a6ed1e..d1d21c9 100644 --- a/apps/frontend/src/lib/integrations.test.ts +++ b/apps/frontend/src/lib/integrations.test.ts @@ -161,10 +161,10 @@ describe('Integrations Registry', () => { expect(bazarr?.defaultPort).toBe(6767); }); - it('should only advertise implemented capabilities', () => { + it('should declare log-file tailing but no webhooks or metrics', () => { const bazarr = getIntegrationById('bazarr'); expect(bazarr?.capabilities).toEqual({ - realTimeLogs: false, + realTimeLogs: true, activityLog: true, sessions: false, webhooks: false, diff --git a/apps/frontend/src/lib/integrations.ts b/apps/frontend/src/lib/integrations.ts index d34b2c7..d9e80d7 100644 --- a/apps/frontend/src/lib/integrations.ts +++ b/apps/frontend/src/lib/integrations.ts @@ -458,7 +458,7 @@ export const integrations: Integration[] = [ defaultPort: 6767, configFields: [...standardApiFields, standardLogPathField], capabilities: { - realTimeLogs: false, + realTimeLogs: true, activityLog: true, sessions: false, webhooks: false, diff --git a/packages/provider-bazarr/src/bazarr.provider.spec.ts b/packages/provider-bazarr/src/bazarr.provider.spec.ts index e7d55a6..9912f88 100644 --- a/packages/provider-bazarr/src/bazarr.provider.spec.ts +++ b/packages/provider-bazarr/src/bazarr.provider.spec.ts @@ -229,8 +229,8 @@ describe('BazarrProvider connection', () => { connected: false, error: 'Not initialized', }); - await expect(provider.getActivity()).rejects.toThrow('Not connected'); - await expect(provider.getServerInfo()).rejects.toThrow('Not connected'); + 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 () => { @@ -294,7 +294,7 @@ describe('BazarrProvider.getActivity', () => { }); expect(mv?.timestamp).toEqual(new Date(2026, 9, 10, 11, 30, 0)); - const health = activity.find((a) => a.type === 'health'); + 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', @@ -302,7 +302,7 @@ describe('BazarrProvider.getActivity', () => { severity: 'warn', }); - expect(byId.get('bazarr-job-42-2026-10-10 11:00:00')).toMatchObject({ + expect(byId.get('bazarr-job-42-2026-10-10-11-00-00')).toMatchObject({ type: 'job_failed', name: 'Job failed: Update Series', severity: 'error', @@ -424,7 +424,38 @@ describe('BazarrProvider.getActivity', () => { 'subtitle_download', 'subtitle_upgrade', ]); - expect(errorSpy).toHaveBeenCalled(); + 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('warns when history paging is capped before reaching the cutoff', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + route({ + episodes: () => + ok({ + data: Array.from({ length: 50 }, (_, i) => ({ + id: i + 1, + parsed_timestamp: '2026-10-10 12:00:00', + })), + }), + }); + const provider = await connected(); + await provider.getActivity(new Date(2026, 9, 10, 11, 0, 0)); + + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('[bazarr] History paging stopped') + ); }); it('filters history older than since (with a timezone safety margin)', async () => { @@ -613,7 +644,7 @@ describe('BazarrProvider.getActivity robustness', () => { ); expect(result).toHaveLength(1); - expect(result[0]?.type).toBe('health'); + expect(result[0]?.type).toBe('health_warning'); }); it('gives different health issues different ids, and the same issue the same id', async () => { diff --git a/packages/provider-bazarr/src/bazarr.provider.ts b/packages/provider-bazarr/src/bazarr.provider.ts index d7f4817..f7a3eed 100644 --- a/packages/provider-bazarr/src/bazarr.provider.ts +++ b/packages/provider-bazarr/src/bazarr.provider.ts @@ -35,7 +35,7 @@ import type { /** History page size requested from Bazarr */ const HISTORY_PAGE_SIZE = 50; /** Maximum history pages fetched per endpoint per poll (when a `since` cutoff exists) */ -const HISTORY_MAX_PAGES = 3; +const HISTORY_MAX_PAGES_WITH_SINCE = 3; /** * 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 @@ -47,6 +47,14 @@ const HISTORY_SINCE_MARGIN_MS = 24 * 60 * 60 * 1000; const ISO_WITH_TIMEZONE = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})$/i; +/** 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; @@ -169,7 +177,7 @@ export class BazarrProvider implements MediaServerProvider { async getActivity(since?: Date): Promise { const client = this.getClient(); - const [episodes, movies, health, jobs] = await Promise.all([ + const sections = await Promise.all([ this.safely('episode history', () => this.collectHistory( (length, start) => client.getEpisodeHistory(length, start), @@ -194,28 +202,35 @@ export class BazarrProvider implements MediaServerProvider { ), ]); - return [...episodes, ...movies, ...health, ...jobs].sort( - (a, b) => b.timestamp.getTime() - a.timestamp.getTime() - ); + // 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 failures = sections.filter((section) => section.error !== undefined); + if (failures.length === sections.length) { + throw failures[0]?.error; + } + + return sections + .flatMap((section) => section.items) + .sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()); } private getClient(): BazarrClient { if (this.client === null) { - throw new Error('Not connected to Bazarr server'); + throw new Error('Not connected'); } return this.client; } - /** Run one activity section; a failing endpoint must not drop the others */ + /** Run one activity source; a failing endpoint must not drop the others */ private async safely( - label: string, + source: string, run: () => Promise - ): Promise { + ): Promise<{ items: readonly NormalizedActivity[]; error?: unknown }> { try { - return await run(); + return { items: await run() }; } catch (error) { - console.error(`Failed to fetch Bazarr ${label}:`, error); - return []; + console.error(`[${this.id}] Failed to get ${source}:`, error); + return { items: [], error }; } } @@ -229,8 +244,9 @@ export class BazarrProvider implements MediaServerProvider { since: Date | undefined ): Promise { const cutoff = since !== undefined ? since.getTime() - HISTORY_SINCE_MARGIN_MS : undefined; - const maxPages = cutoff !== undefined ? HISTORY_MAX_PAGES : 1; + const maxPages = cutoff !== undefined ? HISTORY_MAX_PAGES_WITH_SINCE : 1; const result: NormalizedActivity[] = []; + let reachedEnd = false; for (let page = 0; page < maxPages; page++) { const records = await fetchPage(HISTORY_PAGE_SIZE, page * HISTORY_PAGE_SIZE); @@ -253,10 +269,17 @@ export class BazarrProvider implements MediaServerProvider { (cutoff !== undefined && !anyParsed) || (cutoff !== undefined && oldestTime !== undefined && oldestTime.getTime() < cutoff) ) { + reachedEnd = true; break; } } + if (cutoff !== undefined && !reachedEnd) { + console.warn( + `[${this.id}] History paging stopped after ${maxPages} pages; older events may have been skipped` + ); + } + return result; } @@ -348,7 +371,7 @@ export class BazarrProvider implements MediaServerProvider { private normalizeHealthIssue(issue: BazarrHealthIssue): NormalizedActivity { return { id: `bazarr-health-${stableHash(`${issue.object}|${issue.issue}`)}`, - type: 'health', + type: 'health_warning', name: `Health issue: ${issue.object}`, overview: issue.issue, severity: 'warn', @@ -359,7 +382,7 @@ export class BazarrProvider implements MediaServerProvider { private normalizeFailedJob(job: BazarrJob): NormalizedActivity { return { - id: `bazarr-job-${job.job_id}-${job.last_run_time ?? 'unknown'}`, + 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', diff --git a/packages/provider-bazarr/src/index.spec.ts b/packages/provider-bazarr/src/index.spec.ts deleted file mode 100644 index 5ecea32..0000000 --- a/packages/provider-bazarr/src/index.spec.ts +++ /dev/null @@ -1,19 +0,0 @@ -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'); - }); -}); From 1597a56a4a482eef459f39cbdb61ec86f4871af0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1clav=20Brzezina?= Date: Sat, 10 Oct 2026 20:40:01 +0200 Subject: [PATCH 4/7] chore(provider-bazarr): restore barrel test and keep agent guide edit minimal --- .github/agents/my-agent.agent.md | 12 +----------- packages/provider-bazarr/src/index.spec.ts | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 11 deletions(-) create mode 100644 packages/provider-bazarr/src/index.spec.ts diff --git a/.github/agents/my-agent.agent.md b/.github/agents/my-agent.agent.md index d76a54c..3a6dbab 100644 --- a/.github/agents/my-agent.agent.md +++ b/.github/agents/my-agent.agent.md @@ -2,7 +2,7 @@ name: logarr-maintainer description: Full-stack Logarr maintainer for this pnpm monorepo. Specializes in the NestJS backend, Next.js frontend, provider integrations, auth/RBAC, proxy safety, realtime flows, and Docker-based validation. target: github-copilot -tools: ['*'] +tools: ["*"] --- You are the Logarr maintainer agent for this repository. @@ -14,7 +14,6 @@ Your job is to make production-safe, minimal, well-validated changes in this cod Before changing code, identify the affected slice and read the source-of-truth files for that area. Always start with: - - `README.md` - `package.json` - `turbo.json` @@ -22,7 +21,6 @@ Always start with: - `.env.example` and `apps/backend/src/config/env.ts` when startup, ports, or environment behavior matter If the task touches auth, RBAC, users, sessions, external auth, or proxy behavior, read these in order: - 1. `AUTH_RBAC_EXECUTION_RUNBOOK.md` 2. `BACKEND_AUTH_RBAC_PLAN.md` 3. `AUTH_RBAC_TRACKER.md` @@ -43,18 +41,15 @@ This is a pnpm + Turborepo TypeScript monorepo. - `.github/workflows/docker-publish.yml`: CI gates and container publishing. Key backend module areas: - - `auth`, `api-keys`, `audit`, `dashboard`, `issues`, `logs`, `servers`, `sessions`, `settings`, `retention`, `ingestion`, `file-ingestion`, `proxy` Key frontend areas: - - `src/app/(dashboard)/**` for product pages - `src/hooks/use-api.ts` for React Query data access and invalidation patterns - `src/hooks/use-*-socket.ts` and `src/lib/websocket.ts` for realtime behavior - `src/components/ui/**` for shared UI primitives Important repo nuance: - - not every integration has a full provider package; some providers are currently proxy-only and surfaced conservatively from `ServersService` - global backend guards already enforce authentication, permissions, server access, and recent re-auth for sensitive paths - ports differ across README, docker-compose, docker-compose.dev, env config, and Playwright; verify current config before assuming startup URLs @@ -75,7 +70,6 @@ Important repo nuance: Use the repo's existing commands and validate proportionally to the change. Primary commands: - - `pnpm lint` - `pnpm typecheck` - `pnpm test` @@ -83,7 +77,6 @@ Primary commands: - `pnpm test:e2e` Guidance: - - Run targeted Vitest suites first for the touched app or package, then broaden only as needed. - For backend auth, proxy, security, and persistence work, prefer TDD and add failing tests first. - For frontend changes, verify both desktop and mobile behavior and avoid regressing realtime flows. @@ -91,14 +84,12 @@ Guidance: - Do not claim completion if relevant tests are still failing. Coverage expectations: - - backend and frontend Vitest configs enforce 80% thresholds for statements, branches, functions, and lines - some files are intentionally excluded from coverage; do not assume excluded files are unimportant ## Auth / RBAC / Proxy Standard When touching any of these areas: - - follow the runbook's TDD order - update `AUTH_RBAC_TRACKER.md` if you implement or complete tracked work - prefer integration tests for controller, persistence, auth, and policy flows @@ -117,7 +108,6 @@ When touching any of these areas: ## Delivery Standard For each task: - 1. inspect the affected slice first 2. identify the existing pattern already used in this repo 3. implement the minimal correct change 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'); + }); +}); From 257ba399c78745a1dc10f5174dd995f2c04db7e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1clav=20Brzezina?= Date: Tue, 13 Oct 2026 18:32:13 +0200 Subject: [PATCH 5/7] chore(env): comment out Bazarr entries in .env.tpl until 1Password items exist --- .env.tpl | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.env.tpl b/.env.tpl index 8fbaa33..7998130 100644 --- a/.env.tpl +++ b/.env.tpl @@ -87,9 +87,10 @@ WHISPARR_API_KEY={{ op://Development/Logarr-Dev/WHISPARR_API_KEY }} # Lidarr LIDARR_URL={{ op://Development/Logarr-Dev/LIDARR_URL }} LIDARR_API_KEY={{ op://Development/Logarr-Dev/LIDARR_API_KEY }} -# Bazarr -BAZARR_URL={{ op://Development/Logarr-Dev/BAZARR_URL }} -BAZARR_API_KEY={{ op://Development/Logarr-Dev/BAZARR_API_KEY }} + +# Bazarr (no 1Password items yet: uncomment and add op:// refs once they exist) +# BAZARR_URL= +# BAZARR_API_KEY= # ============================================================================= # SECURITY & AUTHENTICATION @@ -114,4 +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={{ op://Development/Logarr-Dev/BAZARR_LOGS_PATH }} +# BAZARR_LOGS_PATH= (no 1Password item yet) From 204915dd92bd02fd6cfa9ade2ad77c1b09be2e49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1clav=20Brzezina?= Date: Tue, 13 Oct 2026 18:32:13 +0200 Subject: [PATCH 6/7] fix(provider-bazarr): page history to the cursor and read day-first dates History sync now pages until the oldest parseable row is older than the cutoff (40 page safety cap; 7 day window on the first poll). When the cap is hit, only rows at or before the oldest fetched history row may advance the sync cursor. Health, failed-job and unparsed rows never advance it (advancesSyncCursor: false). Slash dates are read per response: a number above 12 proves day-first or month-first, the order is remembered per server, and rows that stay ambiguous are treated as unparsed instead of guessed. --- README.md | 2 +- .../src/bazarr.provider.spec.ts | 268 ++++++++++++++---- .../provider-bazarr/src/bazarr.provider.ts | 192 +++++++++---- .../provider-bazarr/src/bazarr.utils.spec.ts | 98 ++++++- packages/provider-bazarr/src/bazarr.utils.ts | 71 ++++- 5 files changed, 512 insertions(+), 119 deletions(-) diff --git a/README.md b/README.md index 0fb5e44..86c3980 100644 --- a/README.md +++ b/README.md @@ -280,7 +280,7 @@ LIDARR_LOGS_PATH=/path/to/lidarr/config/logs 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 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. diff --git a/packages/provider-bazarr/src/bazarr.provider.spec.ts b/packages/provider-bazarr/src/bazarr.provider.spec.ts index 9912f88..8710b33 100644 --- a/packages/provider-bazarr/src/bazarr.provider.spec.ts +++ b/packages/provider-bazarr/src/bazarr.provider.spec.ts @@ -59,7 +59,7 @@ const episode: BazarrEpisodeHistory = { language: { name: 'English', code2: 'en', forced: false, hi: false }, sonarrSeriesId: 5, sonarrEpisodeId: 7, - parsed_timestamp: '2026-09-21 12:00:00', + parsed_timestamp: '2026-10-09 12:00:00', }; const movie: BazarrMovieHistory = { @@ -82,9 +82,13 @@ async function connected(url = 'http://bazarr:6767'): Promise { 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(); @@ -276,7 +280,7 @@ describe('BazarrProvider.getActivity', () => { severity: 'info', itemId: '7', }); - expect(ep?.timestamp).toEqual(new Date(2026, 8, 21, 12, 0, 0)); + expect(ep?.timestamp).toEqual(new Date(2026, 9, 9, 12, 0, 0)); expect(ep?.metadata).toMatchObject({ kind: 'episode', language: 'English', @@ -462,7 +466,7 @@ describe('BazarrProvider.getActivity', () => { const old: BazarrEpisodeHistory = { ...episode, id: 12, - parsed_timestamp: '10/01/26 10:00:00', + parsed_timestamp: '2026-10-01 10:00:00', }; const fresh: BazarrEpisodeHistory = { ...episode, @@ -487,60 +491,240 @@ describe('BazarrProvider.getActivity', () => { }); describe('history paging', () => { - function page(count: number, firstId: number, parsed: string): BazarrEpisodeHistory[] { - return Array.from({ length: count }, (_, i) => ({ + const HOUR = 60 * 60 * 1000; + const NOW = new Date(2026, 9, 10, 13, 0, 0).getTime(); + + /** Pad a number for `YYYY-MM-DD HH:mm:ss` */ + 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())}`; + } + + /** Newest-first server-side history: row i happened `i * stepMs` before `from` */ + function history(total: number, stepMs: number, from = NOW): BazarrEpisodeHistory[] { + return Array.from({ length: total }, (_, i) => ({ ...episode, - id: firstId + i, - parsed_timestamp: parsed, + id: i + 1, + parsed_timestamp: stamp(from - i * stepMs), })); } + /** Serve `rows` honouring start/length like Bazarr */ + function serve(rows: readonly BazarrEpisodeHistory[]): (url: URL) => unknown { + return (url: URL) => { + const start = Number(url.searchParams.get('start')); + const length = Number(url.searchParams.get('length')); + return ok({ data: rows.slice(start, start + length), total: rows.length }); + }; + } + function historyCalls(): URL[] { return mockFetch.mock.calls .map((c) => new URL(String(c[0]))) .filter((u) => u.pathname.endsWith('/episodes/history')); } - it('fetches only the first page without since', async () => { - route({ episodes: ok({ data: page(50, 1, '10/10/26 12:00:00') }) }); + function downloads(activity: readonly { type: string }[]): number { + return activity.filter((a) => a.type === 'subtitle_download').length; + } + + it('pages back over a 7 day window on the first poll', async () => { + route({ episodes: serve(history(120, HOUR)) }); + const provider = await connected(); + const activity = await provider.getActivity(); + + expect(historyCalls().map((u) => u.searchParams.get('start'))).toEqual(['0', '50', '100']); + expect(downloads(activity)).toBe(120); + }); + + it('stops the first poll at the 7 day window', async () => { + route({ episodes: serve(history(400, HOUR)) }); + const provider = await connected(); + const activity = await provider.getActivity(); + + // 168h = row 168 is exactly at the cutoff (kept); page 4 (rows 150-199) holds it + expect(historyCalls()).toHaveLength(4); + expect(downloads(activity)).toBe(169); + }); + + it('keeps paging with since until a row is older than the cutoff', async () => { + const since = new Date(NOW - 30 * HOUR); + route({ episodes: serve(history(500, 10 * 60 * 1000)) }); + const provider = await connected(); + const activity = await provider.getActivity(since); + + // cutoff = since - 24h = 54h back = 324 rows at 10 minute spacing -> 7 pages + expect(historyCalls()).toHaveLength(7); + expect(downloads(activity)).toBe(325); + }); + + it('stops on a short page', async () => { + route({ episodes: serve(history(60, HOUR)) }); const provider = await connected(); - await provider.getActivity(); + await provider.getActivity(new Date(NOW - 100 * HOUR)); + + expect(historyCalls()).toHaveLength(2); + }); + + it('stops once the oldest record is older than the cutoff', async () => { + route({ episodes: ok({ data: history(50, 24 * HOUR) }) }); + const provider = await connected(); + await provider.getActivity(new Date(NOW - HOUR)); expect(historyCalls()).toHaveLength(1); }); - it('follows full pages while records are newer than since, up to 3 pages', async () => { + it('stops after the first page when no row on it has a parseable time', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); route({ - episodes: (url: URL) => - ok({ data: page(50, Number(url.searchParams.get('start')) + 1, '10/10/26 12:00:00') }), + episodes: ok({ + data: Array.from({ length: 50 }, (_, i) => ({ id: i + 1, timestamp: 'recently' })), + }), }); const provider = await connected(); - const activity = await provider.getActivity(new Date(2026, 9, 10, 11, 0, 0)); + const activity = await provider.getActivity(new Date(NOW - HOUR)); + + expect(historyCalls()).toHaveLength(1); + expect(downloads(activity)).toBe(0); + expect(activity.filter((a) => a.type === 'subtitle_event')).toHaveLength(50); + }); + + describe('when the safety cap is hit', () => { + it('caps at 40 pages, warns and keeps rows newer than the oldest fetched row off the cursor', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const rows = history(3000, 60 * 1000); + route({ + episodes: serve(rows), + health: ok({ data: [{ object: '/tv', issue: 'Path does not exist' }] }), + }); + const provider = await connected(); + const activity = await provider.getActivity(new Date(NOW - 10 * 24 * HOUR)); + + expect(historyCalls()).toHaveLength(40); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('[bazarr] History paging stopped') + ); + + const oldestFetched = NOW - 1999 * 60 * 1000; + const advancing = activity.filter((a) => a.advancesSyncCursor !== false); + expect(advancing).toHaveLength(1); + expect(advancing[0]?.timestamp.getTime()).toBe(oldestFetched); + expect(activity.filter((a) => a.advancesSyncCursor === false)).toHaveLength( + activity.length - 1 + ); + }); - const starts = historyCalls().map((u) => u.searchParams.get('start')); - expect(starts).toEqual(['0', '50', '100']); - expect(activity.filter((a) => a.type === 'subtitle_download')).toHaveLength(150); + it('applies the lowest ceiling of any capped source to rows from the other sources', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + route({ + episodes: serve(history(3000, 60 * 1000)), + movies: ok({ data: [{ ...movie, parsed_timestamp: stamp(NOW - 1000) }] }), + }); + const provider = await connected(); + const activity = await provider.getActivity(new Date(NOW - 10 * 24 * HOUR)); + const film = activity.find((a) => a.id === 'bazarr-movie-history-3'); + + expect(film?.advancesSyncCursor).toBe(false); + }); }); - it('stops on a short page', async () => { + it('lets every parsed history row advance the cursor when paging completes', async () => { + route({ episodes: ok({ data: history(5, HOUR) }) }); + const provider = await connected(); + const activity = await provider.getActivity(); + + expect(activity.every((a) => a.advancesSyncCursor === true)).toBe(true); + }); + + it('never lets health, failed-job or unparsed rows advance the cursor', async () => { route({ - episodes: (url: URL) => - url.searchParams.get('start') === '0' - ? ok({ data: page(50, 1, '10/10/26 12:00:00') }) - : ok({ data: page(10, 51, '10/10/26 12:00:00') }), + episodes: ok({ + data: [ + { ...episode, id: 1, parsed_timestamp: stamp(NOW - HOUR) }, + { id: 2, timestamp: 'recently' }, + ], + }), + health: ok({ data: [{ object: '/tv', issue: 'Path does not exist' }] }), + jobs: ok({ + data: [{ job_id: 1, job_name: 'x', status: 'failed', last_run_time: stamp(NOW - HOUR) }], + }), }); const provider = await connected(); - await provider.getActivity(new Date(2026, 9, 10, 11, 0, 0)); + const activity = await provider.getActivity(); + const flag = (id: string): boolean | undefined => + activity.find((a) => a.id.startsWith(id))?.advancesSyncCursor; - expect(historyCalls()).toHaveLength(2); + expect(flag('bazarr-episode-history-1')).toBe(true); + expect(flag('bazarr-episode-history-2')).toBe(false); + expect(flag('bazarr-health-')).toBe(false); + expect(flag('bazarr-job-1-')).toBe(false); }); + }); + + describe('slash date order', () => { + function rows(...stamps: string[]): BazarrEpisodeHistory[] { + return stamps.map((parsed_timestamp, i) => ({ ...episode, id: i + 1, parsed_timestamp })); + } - it('stops once the oldest record is older than since', async () => { - route({ episodes: ok({ data: page(50, 1, '09/01/26 12:00:00') }) }); + 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(); - await provider.getActivity(new Date(2026, 9, 10, 11, 0, 0)); + const activity = await provider.getActivity(new Date(2026, 8, 1)); + const byId = new Map(activity.map((a) => [a.id, a])); - expect(historyCalls()).toHaveLength(1); + 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('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); }); }); }); @@ -616,8 +800,8 @@ describe('BazarrProvider.getActivity robustness', () => { const result = await activity({ episodes: ok({ data: [ - { id: 1, seriesTitle: 'Show', parsed_timestamp: '2026-09-21 12:00:00' }, - { id: 2, parsed_timestamp: '2026-09-21 12:00:01' }, + { id: 1, seriesTitle: 'Show', parsed_timestamp: '2026-10-09 12:00:00' }, + { id: 2, parsed_timestamp: '2026-10-09 12:00:01' }, ], }), }); @@ -629,7 +813,7 @@ describe('BazarrProvider.getActivity robustness', () => { 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-09-21 12:00:00' }] }), + movies: ok({ data: [{ id: 9, parsed_timestamp: '2026-10-09 12:00:00' }] }), }); expect(entry?.name).toBe('Unknown'); @@ -771,8 +955,8 @@ describe('BazarrProvider.getActivity robustness', () => { 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-09-21 12:00:00' }] }), - movies: ok({ data: [{ id: 1, parsed_timestamp: '2026-09-21 12:00:00' }] }), + 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); @@ -811,24 +995,6 @@ describe('BazarrProvider.getActivity robustness', () => { expect(starts).toEqual(['0']); }); - it('still bounds paging at 3 pages when parseable rows never reach the cutoff', async () => { - const starts: string[] = []; - route({ - episodes: (url: URL) => { - starts.push(url.searchParams.get('start') ?? ''); - const rows = Array.from({ length: 50 }, (_, i) => ({ - id: i + 1, - parsed_timestamp: '2026-10-10 12:00:00', - })); - return ok({ data: rows }); - }, - }); - const provider = await connected(); - await provider.getActivity(new Date(2026, 9, 10, 11, 0, 0)); - - expect(starts).toEqual(['0', '50', '100']); - }); - it('includes the score in the content-hash id', async () => { const row = { action: 1, diff --git a/packages/provider-bazarr/src/bazarr.provider.ts b/packages/provider-bazarr/src/bazarr.provider.ts index f7a3eed..56d5d2d 100644 --- a/packages/provider-bazarr/src/bazarr.provider.ts +++ b/packages/provider-bazarr/src/bazarr.provider.ts @@ -7,7 +7,7 @@ import { parseBazarrLogLineWithContext, } from './bazarr.parser.js'; import { BazarrHistoryActions } from './bazarr.types.js'; -import { parseBazarrTimestamp, stableHash } from './bazarr.utils.js'; +import { inferSlashDateOrder, parseBazarrTimestamp, stableHash } from './bazarr.utils.js'; import type { BazarrEpisodeHistory, @@ -16,6 +16,7 @@ import type { BazarrJob, BazarrMovieHistory, } from './bazarr.types.js'; +import type { SlashDateOrder } from './bazarr.utils.js'; import type { ConnectionStatus, CorrelationPattern, @@ -34,8 +35,15 @@ import type { /** History page size requested from Bazarr */ const HISTORY_PAGE_SIZE = 50; -/** Maximum history pages fetched per endpoint per poll (when a `since` cutoff exists) */ -const HISTORY_MAX_PAGES_WITH_SINCE = 3; +/** + * Safety cap on history pages per endpoint per poll (40 x 50 = 2000 rows). Paging normally + * ends earlier, at a short page or the first row older than the cutoff. + */ +const HISTORY_MAX_PAGES = 40; +/** 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 @@ -47,6 +55,13 @@ const HISTORY_SINCE_MARGIN_MS = 24 * 60 * 60 * 1000; 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; +} + /** Lowercase kebab id fragment (stable, no spaces or punctuation) */ function idPart(value: string | number): string { return String(value) @@ -85,6 +100,8 @@ export class BazarrProvider implements MediaServerProvider { 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; @@ -181,25 +198,28 @@ export class BazarrProvider implements MediaServerProvider { this.safely('episode history', () => this.collectHistory( (length, start) => client.getEpisodeHistory(length, start), - (rec) => this.normalizeEpisodeHistory(rec), + (rec, parsed) => this.normalizeEpisodeHistory(rec, parsed), since ) ), this.safely('movie history', () => this.collectHistory( (length, start) => client.getMovieHistory(length, start), - (rec) => this.normalizeMovieHistory(rec), + (rec, parsed) => this.normalizeMovieHistory(rec, parsed), since ) ), - this.safely('health', async () => - (await client.getHealth()).map((issue) => this.normalizeHealthIssue(issue)) - ), - this.safely('jobs', async () => - (await client.getJobs('failed')) - .map((job) => this.normalizeFailedJob(job)) - .filter((activity) => since === undefined || activity.timestamp >= since) - ), + this.safely('health', async () => ({ + items: (await client.getHealth()).map((issue) => this.normalizeHealthIssue(issue)), + })), + this.safely('jobs', async () => { + const cutoff = since !== undefined ? since.getTime() - HISTORY_SINCE_MARGIN_MS : undefined; + return { + items: (await client.getJobs('failed')) + .map((job) => this.normalizeFailedJob(job)) + .filter((activity) => cutoff === undefined || activity.timestamp.getTime() >= cutoff), + }; + }), ]); // Partial failure is tolerated; if every source failed, surface the first error so the @@ -209,8 +229,22 @@ export class BazarrProvider implements MediaServerProvider { throw failures[0]?.error; } + // If paging was capped, only rows at or before the oldest fetched history row may move + // the sync cursor, so the next poll still reaches the events that were not fetched. + const ceilings = sections.flatMap((section) => + section.cursorCeiling !== undefined ? [section.cursorCeiling.getTime()] : [] + ); + const ceiling = ceilings.length > 0 ? Math.min(...ceilings) : undefined; + return sections .flatMap((section) => section.items) + .map((activity) => + ceiling !== undefined && + activity.advancesSyncCursor !== false && + activity.timestamp.getTime() > ceiling + ? { ...activity, advancesSyncCursor: false } + : activity + ) .sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()); } @@ -224,66 +258,107 @@ export class BazarrProvider implements MediaServerProvider { /** Run one activity source; a failing endpoint must not drop the others */ private async safely( source: string, - run: () => Promise - ): Promise<{ items: readonly NormalizedActivity[]; error?: unknown }> { + run: () => Promise + ): Promise { try { - return { items: await run() }; + return await run(); } catch (error) { console.error(`[${this.id}] Failed to get ${source}:`, error); return { items: [], error }; } } + /** Slash-date order for one response: inferred from its rows, else the one learned earlier */ + private resolveSlashOrder(records: readonly BazarrHistoryBase[]): SlashDateOrder | undefined { + const key = this.config?.url ?? ''; + const inferred = inferSlashDateOrder(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); + } + /** - * Page through newest-first history. Without `since` only the latest page is read; - * with `since`, pages are followed (max 3) until a short page or records older than the cutoff. + * Page through newest-first history until the oldest parseable row on a page is older + * than the cutoff (`since` minus a timezone margin, or 7 days back on the first poll), a + * page is short, or the safety cap is hit. + * + * Rows whose time could not be parsed fall back to "now" and never move the sync cursor or + * the paging cutoff. If the cap is hit first, `cursorCeiling` is the oldest fetched + * history time: rows newer than it must not advance the cursor. */ private async collectHistory( fetchPage: (length: number, start: number) => Promise, - normalize: (record: T) => NormalizedActivity, + normalize: (record: T, parsed: Date | undefined) => NormalizedActivity, since: Date | undefined - ): Promise { - const cutoff = since !== undefined ? since.getTime() - HISTORY_SINCE_MARGIN_MS : undefined; - const maxPages = cutoff !== undefined ? HISTORY_MAX_PAGES_WITH_SINCE : 1; - const result: NormalizedActivity[] = []; + ): Promise { + const cutoff = + since !== undefined + ? since.getTime() - HISTORY_SINCE_MARGIN_MS + : Date.now() - HISTORY_INITIAL_WINDOW_MS; + const items: NormalizedActivity[] = []; + let oldestFetched: number | undefined; let reachedEnd = false; - for (let page = 0; page < maxPages; page++) { + for (let page = 0; page < HISTORY_MAX_PAGES; page++) { const records = await fetchPage(HISTORY_PAGE_SIZE, page * HISTORY_PAGE_SIZE); + const order = this.resolveSlashOrder(records); - let anyParsed = false; + let oldestOnPage: number | undefined; for (const record of records) { - const parsed = parseBazarrTimestamp(record); - anyParsed ||= parsed !== undefined; - // Unparseable timestamps are kept; dedupe by stable id makes that safe - if (cutoff === undefined || parsed === undefined || parsed.getTime() >= cutoff) { - result.push(normalize(record)); + const parsed = parseBazarrTimestamp(record, order); + if (parsed !== undefined) { + const time = parsed.getTime(); + oldestOnPage = oldestOnPage === undefined ? time : Math.min(oldestOnPage, time); + oldestFetched = oldestFetched === undefined ? time : Math.min(oldestFetched, time); + } + // Unparseable rows are kept (stable ids dedupe them) but are not trusted for the cutoff + if (parsed === undefined || parsed.getTime() >= cutoff) { + items.push(normalize(record, parsed)); } } - const oldest = records[records.length - 1]; - const oldestTime = oldest !== undefined ? parseBazarrTimestamp(oldest) : undefined; - if ( - records.length < HISTORY_PAGE_SIZE || - // Nothing on this page has a usable time, so the cutoff can never be reached - (cutoff !== undefined && !anyParsed) || - (cutoff !== undefined && oldestTime !== undefined && oldestTime.getTime() < cutoff) - ) { + if (records.length < HISTORY_PAGE_SIZE) { + reachedEnd = true; + break; + } + if (oldestOnPage === undefined) { + // Nothing on this page has a usable time, so the cutoff can never be proven + console.warn(`[${this.id}] Could not parse any history timestamp; stopped paging`); + reachedEnd = true; + break; + } + if (oldestOnPage < cutoff) { reachedEnd = true; break; } } - if (cutoff !== undefined && !reachedEnd) { - console.warn( - `[${this.id}] History paging stopped after ${maxPages} pages; older events may have been skipped` - ); + if (reachedEnd) { + return { items }; } - return result; + console.warn( + `[${this.id}] History paging stopped after ${HISTORY_MAX_PAGES} pages; older events are picked up on later polls` + ); + return oldestFetched !== undefined + ? { items, cursorCeiling: new Date(oldestFetched) } + : { items }; } - private historyId(kind: 'episode' | 'movie', record: BazarrHistoryBase, ref: unknown): string { + private historyId( + kind: 'episode' | 'movie', + record: BazarrHistoryBase, + parsed: Date | undefined, + ref: unknown + ): string { const prefix = `bazarr-${kind}-history-`; if (typeof record.id === 'number') { return `${prefix}${record.id}`; @@ -291,7 +366,7 @@ export class BazarrProvider implements MediaServerProvider { // Bazarr's API model omits the row id: derive a stable one from the record content. // The humanised `timestamp` changes over time, so only an absolute time is used. - const when = parseBazarrTimestamp(record)?.toISOString() ?? null; + const when = parsed?.toISOString() ?? null; return `${prefix}${stableHash( JSON.stringify([ ref, @@ -326,20 +401,27 @@ export class BazarrProvider implements MediaServerProvider { }; } - private normalizeEpisodeHistory(record: BazarrEpisodeHistory): NormalizedActivity { + 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]), + id: this.historyId('episode', record, parsed, [ + 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: parseBazarrTimestamp(record) ?? new Date(), + timestamp: parsed ?? new Date(), + advancesSyncCursor: parsed !== undefined, metadata: { kind: 'episode', ...this.commonHistoryMetadata(record), @@ -350,15 +432,19 @@ export class BazarrProvider implements MediaServerProvider { }; } - private normalizeMovieHistory(record: BazarrMovieHistory): NormalizedActivity { + private normalizeMovieHistory( + record: BazarrMovieHistory, + parsed: Date | undefined + ): NormalizedActivity { return { - id: this.historyId('movie', record, [record.radarrId]), + id: this.historyId('movie', record, parsed, [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: parseBazarrTimestamp(record) ?? new Date(), + timestamp: parsed ?? new Date(), + advancesSyncCursor: parsed !== undefined, metadata: { kind: 'movie', ...this.commonHistoryMetadata(record), @@ -376,6 +462,8 @@ export class BazarrProvider implements MediaServerProvider { 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 }, }; } @@ -388,6 +476,8 @@ export class BazarrProvider implements MediaServerProvider { 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, diff --git a/packages/provider-bazarr/src/bazarr.utils.spec.ts b/packages/provider-bazarr/src/bazarr.utils.spec.ts index 8456d50..998597b 100644 --- a/packages/provider-bazarr/src/bazarr.utils.spec.ts +++ b/packages/provider-bazarr/src/bazarr.utils.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { parseBazarrTimestamp, stableHash } from './bazarr.utils.js'; +import { inferSlashDateOrder, parseBazarrTimestamp, stableHash } from './bazarr.utils.js'; describe('parseBazarrTimestamp', () => { it('parses ISO-like strings', () => { @@ -13,16 +13,16 @@ describe('parseBazarrTimestamp', () => { }); it('parses the C/en_US %x %X format (month first, 2-digit year)', () => { - expect(parseBazarrTimestamp({ parsed_timestamp: '10/09/26 12:00:01' })).toEqual( + 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' })).toEqual( + 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' })).toEqual( + expect(parseBazarrTimestamp({ parsed_timestamp: '10/09/2026 1:30:00 PM' }, 'mdy')).toEqual( new Date(2026, 9, 9, 13, 30, 0) ); }); @@ -82,10 +82,10 @@ describe('parseBazarrTimestamp edge cases', () => { }); it('handles 12 AM and 12 PM on the 12-hour clock', () => { - expect(parseBazarrTimestamp({ parsed_timestamp: '10/09/2026 12:30:00 AM' })).toEqual( + 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' })).toEqual( + expect(parseBazarrTimestamp({ parsed_timestamp: '10/09/2026 12:30:00 PM' }, 'mdy')).toEqual( new Date(2026, 9, 9, 12, 30, 0) ); }); @@ -104,3 +104,89 @@ describe('stableHash edge cases', () => { 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(); + }); + }); +}); diff --git a/packages/provider-bazarr/src/bazarr.utils.ts b/packages/provider-bazarr/src/bazarr.utils.ts index 1d035e2..cc92f06 100644 --- a/packages/provider-bazarr/src/bazarr.utils.ts +++ b/packages/provider-bazarr/src/bazarr.utils.ts @@ -21,6 +21,12 @@ const ISO_LIKE = /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})(?::(\d{2}))?/; 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, @@ -38,12 +44,24 @@ function build( 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 - * (e.g. "10/09/26 12:00:01", "10/09/2026 1:30:00 PM", "09.10.2026 08:00:00"). + * 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. */ -function parseDateString(value: string): Date | undefined { +function parseDateString(value: string, order: SlashDateOrder | undefined): Date | undefined { const text = value.trim(); const iso = ISO_LIKE.exec(text); @@ -70,10 +88,12 @@ function parseDateString(value: string): Date | undefined { year += 2000; } - // Month first (C / en_US) unless the separator is a dot or the first number cannot be a month - const dayFirst = m[2] === '.' || first > 12; - const month = dayFirst ? second : first; - const day = dayFirst ? first : second; + 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(); @@ -83,17 +103,48 @@ function parseDateString(value: string): Date | undefined { 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. Returns undefined when nothing parses so callers can decide on a fallback. + * formatted and contains the only absolute time. Returns undefined when nothing parses so + * callers can decide on a fallback. */ export function parseBazarrTimestamp( - record: Pick + record: TimestampFields, + order?: SlashDateOrder ): Date | undefined { for (const candidate of [record.parsed_timestamp, record.timestamp]) { if (typeof candidate === 'string' && candidate !== '') { - const date = parseDateString(candidate); + const date = parseDateString(candidate, order); if (date !== undefined) { return date; } From 4f6ab79a29683aa0336cf80a23e2821234ed6972 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1clav=20Brzezina?= Date: Tue, 13 Oct 2026 18:44:49 +0200 Subject: [PATCH 7/7] fix(provider-bazarr): read history backwards from the cutoff so capped polls progress Locate the first row older than the cutoff by probing (exponential then binary search on start), then read pages oldest to newest with an overlap that detects rows inserted at the head mid-read. If the page cap is hit the rows are the oldest ones above the cutoff and the cursor advances only to the newest row read with certainty, so each poll gains ground until the backlog is caught up. History ids hash the raw timestamp text (independent of the inferred date order), the date order is inferred over every row of the poll before normalizing, a failing history source keeps the other source's rows off the cursor, rows never move the cursor backwards past since, and year-first dates (2026/10/05) are read as Y/M/D. --- .../src/bazarr.history.spec.ts | 199 ++++++++++ .../provider-bazarr/src/bazarr.history.ts | 227 +++++++++++ .../src/bazarr.provider.spec.ts | 363 +++++++++++------- .../provider-bazarr/src/bazarr.provider.ts | 274 +++++++------ .../provider-bazarr/src/bazarr.utils.spec.ts | 22 ++ packages/provider-bazarr/src/bazarr.utils.ts | 22 +- 6 files changed, 856 insertions(+), 251 deletions(-) create mode 100644 packages/provider-bazarr/src/bazarr.history.spec.ts create mode 100644 packages/provider-bazarr/src/bazarr.history.ts 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.provider.spec.ts b/packages/provider-bazarr/src/bazarr.provider.spec.ts index 8710b33..ed504e1 100644 --- a/packages/provider-bazarr/src/bazarr.provider.spec.ts +++ b/packages/provider-bazarr/src/bazarr.provider.spec.ts @@ -443,25 +443,6 @@ describe('BazarrProvider.getActivity', () => { } }); - it('warns when history paging is capped before reaching the cutoff', async () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - route({ - episodes: () => - ok({ - data: Array.from({ length: 50 }, (_, i) => ({ - id: i + 1, - parsed_timestamp: '2026-10-10 12:00:00', - })), - }), - }); - const provider = await connected(); - await provider.getActivity(new Date(2026, 9, 10, 11, 0, 0)); - - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining('[bazarr] History paging stopped') - ); - }); - it('filters history older than since (with a timezone safety margin)', async () => { const old: BazarrEpisodeHistory = { ...episode, @@ -491,32 +472,48 @@ describe('BazarrProvider.getActivity', () => { }); describe('history paging', () => { - const HOUR = 60 * 60 * 1000; + const MINUTE = 60 * 1000; + const HOUR = 60 * MINUTE; const NOW = new Date(2026, 9, 10, 13, 0, 0).getTime(); - /** Pad a number for `YYYY-MM-DD HH:mm:ss` */ 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())}`; } - /** Newest-first server-side history: row i happened `i * stepMs` before `from` */ - function history(total: number, stepMs: number, from = NOW): BazarrEpisodeHistory[] { - return Array.from({ length: total }, (_, i) => ({ - ...episode, - id: i + 1, - parsed_timestamp: stamp(from - i * stepMs), - })); + /** 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) }; } - /** Serve `rows` honouring start/length like Bazarr */ - function serve(rows: readonly BazarrEpisodeHistory[]): (url: URL) => unknown { - return (url: URL) => { - const start = Number(url.searchParams.get('start')); - const length = Number(url.searchParams.get('length')); - return ok({ data: rows.slice(start, start + length), total: rows.length }); - }; + 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[] { @@ -529,136 +526,213 @@ describe('BazarrProvider.getActivity', () => { return activity.filter((a) => a.type === 'subtitle_download').length; } - it('pages back over a 7 day window on the first poll', async () => { - route({ episodes: serve(history(120, HOUR)) }); + /** 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(); + const activity = await provider.getActivity(new Date(NOW - HOUR)); - expect(historyCalls().map((u) => u.searchParams.get('start'))).toEqual(['0', '50', '100']); - expect(downloads(activity)).toBe(120); + expect(historyCalls()).toHaveLength(1); + expect(downloads(activity)).toBe(30); }); - it('stops the first poll at the 7 day window', async () => { - route({ episodes: serve(history(400, HOUR)) }); + 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(); - const activity = await provider.getActivity(); + await provider.getActivity(new Date(NOW - HOUR)); - // 168h = row 168 is exactly at the cutoff (kept); page 4 (rows 150-199) holds it - expect(historyCalls()).toHaveLength(4); - expect(downloads(activity)).toBe(169); + expect(historyCalls()).toHaveLength(1); }); - it('keeps paging with since until a row is older than the cutoff', async () => { - const since = new Date(NOW - 30 * HOUR); - route({ episodes: serve(history(500, 10 * 60 * 1000)) }); + it('locates the cutoff by probing and reads exactly the rows after it', async () => { + fakeBazarr(5000); const provider = await connected(); - const activity = await provider.getActivity(since); + // cutoff = since - 24h = NOW - 30h => rows within 30h = 1800 minutes, i.e. 1801 rows + const activity = await provider.getActivity(new Date(NOW - 6 * HOUR)); - // cutoff = since - 24h = 54h back = 324 rows at 10 minute spacing -> 7 pages - expect(historyCalls()).toHaveLength(7); - expect(downloads(activity)).toBe(325); + 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('stops on a short page', async () => { - route({ episodes: serve(history(60, HOUR)) }); + it('pages back over a 7 day window on the first poll', async () => { + fakeBazarr(120); const provider = await connected(); - await provider.getActivity(new Date(NOW - 100 * HOUR)); + const activity = await provider.getActivity(); - expect(historyCalls()).toHaveLength(2); + expect(downloads(activity)).toBe(120); + expect(activity.every((a) => a.advancesSyncCursor === true)).toBe(true); }); - it('stops once the oldest record is older than the cutoff', async () => { - route({ episodes: ok({ data: history(50, 24 * HOUR) }) }); + 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(); - await provider.getActivity(new Date(NOW - HOUR)); + const activity = await provider.getActivity(); - expect(historyCalls()).toHaveLength(1); + 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('stops after the first page when no row on it has a parseable time', async () => { - vi.spyOn(console, 'warn').mockImplementation(() => {}); + 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: ok({ - data: Array.from({ length: 50 }, (_, i) => ({ id: i + 1, timestamp: 'recently' })), - }), + 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()).toHaveLength(1); - expect(downloads(activity)).toBe(0); - expect(activity.filter((a) => a.type === 'subtitle_event')).toHaveLength(50); + 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('when the safety cap is hit', () => { - it('caps at 40 pages, warns and keeps rows newer than the oldest fetched row off the cursor', async () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const rows = history(3000, 60 * 1000); - route({ - episodes: serve(rows), - health: ok({ data: [{ object: '/tv', issue: 'Path does not exist' }] }), - }); + 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 activity = await provider.getActivity(new Date(NOW - 10 * 24 * HOUR)); - expect(historyCalls()).toHaveLength(40); - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining('[bazarr] History paging stopped') - ); + const { ingested, polls } = await runPolls(provider, server, { + maxPolls: 14, + between: (poll) => { + if (poll < 5) prepend(server, 40); + }, + }); - const oldestFetched = NOW - 1999 * 60 * 1000; - const advancing = activity.filter((a) => a.advancesSyncCursor !== false); - expect(advancing).toHaveLength(1); - expect(advancing[0]?.timestamp.getTime()).toBe(oldestFetched); - expect(activity.filter((a) => a.advancesSyncCursor === false)).toHaveLength( - activity.length - 1 - ); + // 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('applies the lowest ceiling of any capped source to rows from the other sources', async () => { + it('misses nothing when rows are inserted at the head while a poll is reading', async () => { vi.spyOn(console, 'warn').mockImplementation(() => {}); - route({ - episodes: serve(history(3000, 60 * 1000)), - movies: ok({ data: [{ ...movie, parsed_timestamp: stamp(NOW - 1000) }] }), - }); + const server = fakeBazarr(5000); + server.onRequest = (count) => { + if (count === 25 || count === 40 || count === 55) prepend(server, 7); + }; const provider = await connected(); - const activity = await provider.getActivity(new Date(NOW - 10 * 24 * HOUR)); - const film = activity.find((a) => a.id === 'bazarr-movie-history-3'); - expect(film?.advancesSyncCursor).toBe(false); + const { ingested } = await runPolls(provider, server, { maxPolls: 14 }); + + const missing = [...allIds(server)].filter((id) => !ingested.has(id)); + expect(missing).toEqual([]); }); }); - it('lets every parsed history row advance the cursor when paging completes', async () => { - route({ episodes: ok({ data: history(5, HOUR) }) }); + 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.every((a) => a.advancesSyncCursor === true)).toBe(true); + expect(activity.map((a) => a.type)).toEqual(['subtitle_download', 'subtitle_download']); + expect(activity.every((a) => a.advancesSyncCursor === false)).toBe(true); }); - it('never lets health, failed-job or unparsed rows advance the cursor', async () => { - route({ - episodes: ok({ - data: [ - { ...episode, id: 1, parsed_timestamp: stamp(NOW - HOUR) }, - { id: 2, timestamp: 'recently' }, - ], - }), - health: ok({ data: [{ object: '/tv', issue: 'Path does not exist' }] }), - jobs: ok({ - data: [{ job_id: 1, job_name: 'x', status: 'failed', last_run_time: stamp(NOW - HOUR) }], - }), - }); + 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(); - const flag = (id: string): boolean | undefined => - activity.find((a) => a.id.startsWith(id))?.advancesSyncCursor; - expect(flag('bazarr-episode-history-1')).toBe(true); - expect(flag('bazarr-episode-history-2')).toBe(false); - expect(flag('bazarr-health-')).toBe(false); - expect(flag('bazarr-job-1-')).toBe(false); + expect(activity.every((a) => a.advancesSyncCursor === true)).toBe(true); }); }); @@ -715,6 +789,42 @@ describe('BazarrProvider.getActivity', () => { 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'); @@ -980,21 +1090,6 @@ describe('BazarrProvider.getActivity robustness', () => { expect(first[0]?.id).toMatch(/^bazarr-episode-history-[0-9a-f]{16}$/); }); - it('stops after the first page when no row on it has a parseable time', async () => { - const page = Array.from({ length: 50 }, (_, i) => ({ id: i + 1, timestamp: 'recently' })); - const starts: string[] = []; - route({ - episodes: (url: URL) => { - starts.push(url.searchParams.get('start') ?? ''); - return ok({ data: page }); - }, - }); - const provider = await connected(); - await provider.getActivity(new Date()); - - expect(starts).toEqual(['0']); - }); - it('includes the score in the content-hash id', async () => { const row = { action: 1, diff --git a/packages/provider-bazarr/src/bazarr.provider.ts b/packages/provider-bazarr/src/bazarr.provider.ts index 56d5d2d..3154051 100644 --- a/packages/provider-bazarr/src/bazarr.provider.ts +++ b/packages/provider-bazarr/src/bazarr.provider.ts @@ -1,4 +1,5 @@ import { BazarrClient } from './bazarr.client.js'; +import { readHistory } from './bazarr.history.js'; import { BAZARR_CORRELATION_PATTERNS, BAZARR_LOG_FILE_CONFIG, @@ -9,6 +10,7 @@ import { 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, @@ -33,13 +35,6 @@ import type { ServerInfo, } from '@logarr/core'; -/** History page size requested from Bazarr */ -const HISTORY_PAGE_SIZE = 50; -/** - * Safety cap on history pages per endpoint per poll (40 x 50 = 2000 rows). Paging normally - * ends earlier, at a short page or the first row older than the cutoff. - */ -const HISTORY_MAX_PAGES = 40; /** 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 */ @@ -60,8 +55,30 @@ 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) @@ -193,55 +210,87 @@ export class BazarrProvider implements MediaServerProvider { 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 sections = await Promise.all([ - this.safely('episode history', () => - this.collectHistory( + const [episodeRead, movieRead, health, jobs] = await Promise.all([ + this.attempt('episode history', () => + this.readSource( + 'episode', (length, start) => client.getEpisodeHistory(length, start), - (rec, parsed) => this.normalizeEpisodeHistory(rec, parsed), + (rec) => [rec.sonarrSeriesId, rec.sonarrEpisodeId], + poll, + cutoffMs, since ) ), - this.safely('movie history', () => - this.collectHistory( + this.attempt('movie history', () => + this.readSource( + 'movie', (length, start) => client.getMovieHistory(length, start), - (rec, parsed) => this.normalizeMovieHistory(rec, parsed), + (rec) => [rec.radarrId], + poll, + cutoffMs, since ) ), this.safely('health', async () => ({ items: (await client.getHealth()).map((issue) => this.normalizeHealthIssue(issue)), })), - this.safely('jobs', async () => { - const cutoff = since !== undefined ? since.getTime() - HISTORY_SINCE_MARGIN_MS : undefined; - return { - items: (await client.getJobs('failed')) - .map((job) => this.normalizeFailedJob(job)) - .filter((activity) => cutoff === undefined || activity.timestamp.getTime() >= cutoff), - }; - }), + 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 failures = sections.filter((section) => section.error !== undefined); - if (failures.length === sections.length) { - throw failures[0]?.error; + const errors = [episodeRead.error, movieRead.error, health.error, jobs.error].filter( + (error) => error !== undefined + ); + if (errors.length === 4) { + throw errors[0]; } - // If paging was capped, only rows at or before the oldest fetched history row may move - // the sync cursor, so the next poll still reaches the events that were not fetched. - const ceilings = sections.flatMap((section) => - section.cursorCeiling !== undefined ? [section.cursorCeiling.getTime()] : [] + 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 sections - .flatMap((section) => section.items) + return [episodes, movies, health, jobs] + .flatMap((section) => section?.items ?? []) .map((activity) => - ceiling !== undefined && activity.advancesSyncCursor !== false && - activity.timestamp.getTime() > ceiling + (historyFailed || (ceiling !== undefined && activity.timestamp.getTime() > ceiling)) ? { ...activity, advancesSyncCursor: false } : activity ) @@ -255,23 +304,31 @@ export class BazarrProvider implements MediaServerProvider { return this.client; } - /** Run one activity source; a failing endpoint must not drop the others */ - private async safely( + /** Run one source; a failing endpoint must not drop the others */ + private async attempt( source: string, - run: () => Promise - ): Promise { + run: () => Promise + ): Promise<{ value?: R; error?: unknown }> { try { - return await run(); + return { value: await run() }; } catch (error) { console.error(`[${this.id}] Failed to get ${source}:`, error); - return { items: [], error }; + return { error }; } } - /** Slash-date order for one response: inferred from its rows, else the one learned earlier */ - private resolveSlashOrder(records: readonly BazarrHistoryBase[]): SlashDateOrder | undefined { + 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(records); + const inferred = inferSlashDateOrder(poll.records); if (inferred !== undefined) { if (!this.slashOrders.has(key) && this.slashOrders.size >= MAX_REMEMBERED_ORDERS) { @@ -285,93 +342,83 @@ export class BazarrProvider implements MediaServerProvider { 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}`), + }); + } + /** - * Page through newest-first history until the oldest parseable row on a page is older - * than the cutoff (`since` minus a timezone margin, or 7 days back on the first poll), a - * page is short, or the safety cap is hit. - * - * Rows whose time could not be parsed fall back to "now" and never move the sync cursor or - * the paging cutoff. If the cap is hit first, `cursorCeiling` is the oldest fetched - * history time: rows newer than it must not advance the cursor. + * 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 async collectHistory( - fetchPage: (length: number, start: number) => Promise, + private finishHistory( + read: HistoryRead, normalize: (record: T, parsed: Date | undefined) => NormalizedActivity, + order: SlashDateOrder | undefined, + cutoffMs: number, since: Date | undefined - ): Promise { - const cutoff = - since !== undefined - ? since.getTime() - HISTORY_SINCE_MARGIN_MS - : Date.now() - HISTORY_INITIAL_WINDOW_MS; + ): 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[] = []; - let oldestFetched: number | undefined; - let reachedEnd = false; - - for (let page = 0; page < HISTORY_MAX_PAGES; page++) { - const records = await fetchPage(HISTORY_PAGE_SIZE, page * HISTORY_PAGE_SIZE); - const order = this.resolveSlashOrder(records); - - let oldestOnPage: number | undefined; - for (const record of records) { - const parsed = parseBazarrTimestamp(record, order); - if (parsed !== undefined) { - const time = parsed.getTime(); - oldestOnPage = oldestOnPage === undefined ? time : Math.min(oldestOnPage, time); - oldestFetched = oldestFetched === undefined ? time : Math.min(oldestFetched, time); - } - // Unparseable rows are kept (stable ids dedupe them) but are not trusted for the cutoff - if (parsed === undefined || parsed.getTime() >= cutoff) { - items.push(normalize(record, parsed)); - } - } - if (records.length < HISTORY_PAGE_SIZE) { - reachedEnd = true; - break; - } - if (oldestOnPage === undefined) { - // Nothing on this page has a usable time, so the cutoff can never be proven - console.warn(`[${this.id}] Could not parse any history timestamp; stopped paging`); - reachedEnd = true; - break; - } - if (oldestOnPage < cutoff) { - reachedEnd = true; - break; - } - } + for (const record of read.rows) { + const parsed = parseBazarrTimestamp(record, order); + if (parsed !== undefined && parsed.getTime() < cutoffMs) continue; - if (reachedEnd) { - return { items }; + const advances = + trusted && + parsed !== undefined && + (since === undefined || parsed.getTime() >= since.getTime()) && + (ceiling === undefined || parsed.getTime() <= ceiling.getTime()); + items.push({ ...normalize(record, parsed), advancesSyncCursor: advances }); } - console.warn( - `[${this.id}] History paging stopped after ${HISTORY_MAX_PAGES} pages; older events are picked up on later polls` - ); - return oldestFetched !== undefined - ? { items, cursorCeiling: new Date(oldestFetched) } - : { items }; + return ceiling !== undefined ? { items, cursorCeiling: ceiling } : { items }; } - private historyId( - kind: 'episode' | 'movie', - record: BazarrHistoryBase, - parsed: Date | undefined, - ref: unknown - ): string { + /** + * 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}`; } - // Bazarr's API model omits the row id: derive a stable one from the record content. - // The humanised `timestamp` changes over time, so only an absolute time is used. - const when = parsed?.toISOString() ?? null; + 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, - when, + stamp, record.description ?? null, record.subtitles_path ?? null, record.subs_id ?? null, @@ -411,17 +458,13 @@ export class BazarrProvider implements MediaServerProvider { : 'Unknown'; return { - id: this.historyId('episode', record, parsed, [ - record.sonarrSeriesId, - record.sonarrEpisodeId, - ]), + 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(), - advancesSyncCursor: parsed !== undefined, metadata: { kind: 'episode', ...this.commonHistoryMetadata(record), @@ -437,14 +480,13 @@ export class BazarrProvider implements MediaServerProvider { parsed: Date | undefined ): NormalizedActivity { return { - id: this.historyId('movie', record, parsed, [record.radarrId]), + 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(), - advancesSyncCursor: parsed !== undefined, metadata: { kind: 'movie', ...this.commonHistoryMetadata(record), diff --git a/packages/provider-bazarr/src/bazarr.utils.spec.ts b/packages/provider-bazarr/src/bazarr.utils.spec.ts index 998597b..b65115f 100644 --- a/packages/provider-bazarr/src/bazarr.utils.spec.ts +++ b/packages/provider-bazarr/src/bazarr.utils.spec.ts @@ -190,3 +190,25 @@ describe('slash date order', () => { }); }); }); + +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 index cc92f06..a1639e8 100644 --- a/packages/provider-bazarr/src/bazarr.utils.ts +++ b/packages/provider-bazarr/src/bazarr.utils.ts @@ -18,6 +18,9 @@ export function stableHash(input: string): string { } 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; @@ -59,7 +62,8 @@ function rowOrder(first: number, second: number): SlashDateOrder | 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. + * 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(); @@ -76,6 +80,22 @@ function parseDateString(value: string, order: SlashDateOrder | undefined): Date ); } + 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;