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