diff --git a/.env.example b/.env.example index 10a722f..654363f 100644 --- a/.env.example +++ b/.env.example @@ -77,6 +77,12 @@ WHISPARR_API_KEY= # Lidarr LIDARR_URL= LIDARR_API_KEY= +# qBittorrent (WebUI) +# QBITTORRENT_PASSWORD is the WebUI password, or an API key if QBITTORRENT_USERNAME is empty (qBittorrent 5.2+). +# Leave USERNAME and PASSWORD empty if qBittorrent bypasses authentication for Logarr's IP. +QBITTORRENT_URL= +QBITTORRENT_USERNAME= +QBITTORRENT_PASSWORD= # ============================================================================= # SECURITY & AUTHENTICATION @@ -156,6 +162,11 @@ WHISPARR_LOGS_PATH= # Linux: ~/.config/Lidarr/logs # Docker: /path/to/lidarr/config/logs LIDARR_LOGS_PATH= +# qBittorrent examples (container path: /qbittorrent-logs): +# Docker (linuxserver, unverified): /path/to/qbittorrent/config/qBittorrent/logs +# Linux: ~/.local/share/qBittorrent/logs +# Windows: %LOCALAPPDATA%/qBittorrent/logs +QBITTORRENT_LOGS_PATH= # ============================================================================= # MULTIPLE INSTANCES (optional - for multiple servers of the same type) @@ -205,3 +216,6 @@ WHISPARR_LOGS_PATH_3= LIDARR_LOGS_PATH_1= LIDARR_LOGS_PATH_2= LIDARR_LOGS_PATH_3= +QBITTORRENT_LOGS_PATH_1= +QBITTORRENT_LOGS_PATH_2= +QBITTORRENT_LOGS_PATH_3= diff --git a/.env.tpl b/.env.tpl index 3261f01..5b562f3 100644 --- a/.env.tpl +++ b/.env.tpl @@ -87,6 +87,10 @@ WHISPARR_API_KEY={{ op://Development/Logarr-Dev/WHISPARR_API_KEY }} # Lidarr LIDARR_URL={{ op://Development/Logarr-Dev/LIDARR_URL }} LIDARR_API_KEY={{ op://Development/Logarr-Dev/LIDARR_API_KEY }} +# qBittorrent (not in the 1Password vault yet: add the items, then uncomment) +# QBITTORRENT_URL={{ op://Development/Logarr-Dev/QBITTORRENT_URL }} +# QBITTORRENT_USERNAME={{ op://Development/Logarr-Dev/QBITTORRENT_USERNAME }} +# QBITTORRENT_PASSWORD={{ op://Development/Logarr-Dev/QBITTORRENT_PASSWORD }} # ============================================================================= # 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 }} +# QBITTORRENT_LOGS_PATH={{ op://Development/Logarr-Dev/QBITTORRENT_LOGS_PATH }} diff --git a/.github/agents/my-agent.agent.md b/.github/agents/my-agent.agent.md index 159bab5..b00206d 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, qBittorrent, 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..60e4204 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, qBittorrent, and more. [Quick Start](#quick-start) · [Features](#features) · [Screenshots](#screenshots) · [Documentation](#documentation) @@ -95,13 +95,13 @@ _Configure multiple AI providers for issue analysis (Anthropic, OpenAI, Google, ## Supported Servers -| Server | Status | Server | Status | -| ------------ | ------------ | ------------ | ------------ | -| **Jellyfin** | ✅ Supported | **Plex** | ✅ Supported | -| **Sonarr** | ✅ Supported | **Emby** | ✅ Supported | -| **Radarr** | ✅ Supported | **Whisparr** | ✅ Supported | -| **Prowlarr** | ✅ Supported | **Lidarr** | ✅ Supported | -| **Kodi** | 🚧 Planned | | | +| Server | Status | Server | Status | +| ------------ | ------------ | --------------- | ------------ | +| **Jellyfin** | ✅ Supported | **Plex** | ✅ Supported | +| **Sonarr** | ✅ Supported | **Emby** | ✅ Supported | +| **Radarr** | ✅ Supported | **Whisparr** | ✅ Supported | +| **Prowlarr** | ✅ Supported | **Lidarr** | ✅ Supported | +| **Kodi** | 🚧 Planned | **qBittorrent** | ✅ Supported | > Logarr uses a provider architecture—adding support for new servers is straightforward. [Contributions welcome!](#contributing) @@ -179,6 +179,7 @@ flowchart TB PR[Prowlarr] WH[Whisparr] LI[Lidarr] + QB[qBittorrent] end FE <--> API @@ -186,6 +187,7 @@ flowchart TB API <--> PG API <--> RD API <--> PX & JF & EM & SO & RA & PR & WH & LI + API <--> PX & JF & EM & SO & RA & PR & WH & QB ``` ### How It Works @@ -274,22 +276,44 @@ 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 + +# Download clients +QBITTORRENT_LOGS_PATH=/path/to/qbittorrent/config/qBittorrent/logs ``` 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: -| Server | Container Path | -| -------- | ---------------- | -| Plex | `/plex-logs` | -| Jellyfin | `/jellyfin-logs` | -| Emby | `/emby-logs` | -| Sonarr | `/sonarr-logs` | -| Radarr | `/radarr-logs` | -| Prowlarr | `/prowlarr-logs` | -| Whisparr | `/whisp-logs` | -| Lidarr | `/lidarr-logs` | +| Server | Container Path | +| ----------- | ------------------- | +| Plex | `/plex-logs` | +| Jellyfin | `/jellyfin-logs` | +| Emby | `/emby-logs` | +| Sonarr | `/sonarr-logs` | +| Radarr | `/radarr-logs` | +| Prowlarr | `/prowlarr-logs` | +| Whisparr | `/whisp-logs` | +| Lidarr | `/lidarr-logs` | +| qBittorrent | `/qbittorrent-logs` | + +#### qBittorrent authentication + +qBittorrent is a standalone provider (WebUI API v2) and is detected from the stored credentials: + +| Setup | Username | Password / API key | Notes | +| ---------------------------- | -------- | ------------------ | -------------------------------------------------------- | +| Username + password | set | WebUI password | Cookie session, re-login on expiry | +| API key (qBittorrent >= 5.2) | empty | API key | Sent as `Authorization: Bearer` | +| No authentication | empty | empty | "Bypass authentication" for Logarr's IP / subnet enabled | + +Environment variables for auto-setup: `QBITTORRENT_URL`, `QBITTORRENT_USERNAME`, `QBITTORRENT_PASSWORD`. + +- Stored passwords and API keys are never returned by the API. When editing a source, leave the password / API key field empty to keep the stored value. The request proxy sends API keys to qBittorrent as `Authorization: Bearer` and does not support username/password sources. +- qBittorrent bans an IP for 1 hour after 5 failed logins. After a failed login Logarr backs off (5, 15, then 60 minutes) instead of retrying every poll; fix the credentials and edit the source to retry immediately. +- Behind a reverse proxy or with Host header validation enabled, qBittorrent may answer 401; allow Logarr's host name in "Server domains". +- The API log feed only reports warnings and critical messages, and is skipped when a log path is configured (the `qbittorrent.log` file is tailed instead). Log file timestamps have no timezone: set the backend `TZ` to qBittorrent's timezone. +- Default log locations are best guesses: `/config/qBittorrent/logs` (linuxserver) and `/config/logs` (hotio) are unverified, so set the log path explicitly. #### Multiple Instances (e.g., Movies vs Shorts, 1080p vs 4K) @@ -325,16 +349,17 @@ Simply add multiple servers in the UI using the same server type but different U #### Common Log Locations -| Server | Docker | Linux | Windows | -| -------- | -------------------------- | ----------------------------------- | --------------------------------------- | -| Plex | `/config/Library/.../Logs` | `/var/lib/plexmediaserver/.../Logs` | `%LOCALAPPDATA%\Plex Media Server\Logs` | -| Jellyfin | `/config/log` | `/var/lib/jellyfin/log` | `C:\ProgramData\Jellyfin\Server\log` | -| Emby | `/config/logs` | `/var/lib/emby/logs` | `C:\ProgramData\Emby-Server\logs` | -| Sonarr | `/config/logs` | `~/.config/Sonarr/logs` | `C:\ProgramData\Sonarr\logs` | -| Radarr | `/config/logs` | `~/.config/Radarr/logs` | `C:\ProgramData\Radarr\logs` | -| Prowlarr | `/config/logs` | `~/.config/Prowlarr/logs` | `C:\ProgramData\Prowlarr\logs` | -| Whisparr | `/config/logs` | `~/.config/Whisparr/logs` | `C:\ProgramData\Whisparr\logs` | -| Lidarr | `/config/logs` | `~/.config/Lidarr/logs` | `C:\ProgramData\Lidarr\logs` | +| Server | Docker | Linux | Windows | +| ----------- | ---------------------------------------------------- | ----------------------------------- | --------------------------------------- | +| Plex | `/config/Library/.../Logs` | `/var/lib/plexmediaserver/.../Logs` | `%LOCALAPPDATA%\Plex Media Server\Logs` | +| Jellyfin | `/config/log` | `/var/lib/jellyfin/log` | `C:\ProgramData\Jellyfin\Server\log` | +| Emby | `/config/logs` | `/var/lib/emby/logs` | `C:\ProgramData\Emby-Server\logs` | +| Sonarr | `/config/logs` | `~/.config/Sonarr/logs` | `C:\ProgramData\Sonarr\logs` | +| Radarr | `/config/logs` | `~/.config/Radarr/logs` | `C:\ProgramData\Radarr\logs` | +| Prowlarr | `/config/logs` | `~/.config/Prowlarr/logs` | `C:\ProgramData\Prowlarr\logs` | +| Whisparr | `/config/logs` | `~/.config/Whisparr/logs` | `C:\ProgramData\Whisparr\logs` | +| Lidarr | `/config/logs` | `~/.config/Lidarr/logs` | `C:\ProgramData\Lidarr\logs` | +| qBittorrent | `/config/qBittorrent/logs` (linuxserver, unverified) | `~/.local/share/qBittorrent/logs` | `%LOCALAPPDATA%\qBittorrent\logs` | --- @@ -377,6 +402,7 @@ logarr/ │ ├── provider-prowlarr/ # Prowlarr integration │ ├── provider-whisparr/ # Whisparr integration │ ├── provider-lidarr/ # Lidarr integration +│ ├── provider-qbittorrent/ # qBittorrent integration │ └── provider-arr/ # Base *arr provider └── docker-compose.yml ``` @@ -481,15 +507,24 @@ pnpm test:e2e # Run Playwright E2E tests ### Test Packages -| Package | Description | -| --------------------------- | -------------------------------------- | -| `@logarr/backend` | Backend services and controllers | -| `frontend` | React components, hooks, and utilities | -| `@logarr/core` | Core HTTP utilities | -| `@logarr/provider-arr` | \*arr application log parser | -| `@logarr/provider-emby` | Emby log parser | -| `@logarr/provider-whisparr` | Whisparr provider | -| `@logarr/provider-lidarr` | Lidarr provider | +| Package | Description | +| ------------------------------ | -------------------------------------- | +| `@logarr/backend` | Backend services and controllers | +| `frontend` | React components, hooks, and utilities | +| `@logarr/core` | Core HTTP utilities | +| `@logarr/provider-arr` | \*arr application log parser | +| `@logarr/provider-emby` | Emby log parser | +| `@logarr/provider-whisparr` | Whisparr provider | +| `@logarr/provider-lidarr` | Lidarr provider | +| Package | Description | +| ------------------------------ | -------------------------------------- | +| `@logarr/backend` | Backend services and controllers | +| `frontend` | React components, hooks, and utilities | +| `@logarr/core` | Core HTTP utilities | +| `@logarr/provider-arr` | \*arr application log parser | +| `@logarr/provider-emby` | Emby log parser | +| `@logarr/provider-whisparr` | Whisparr provider | +| `@logarr/provider-qbittorrent` | qBittorrent 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..a6f1301 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-qbittorrent/package.json ./packages/provider-qbittorrent/ +COPY --from=builder --chown=nestjs:nodejs /app/packages/provider-qbittorrent/dist ./packages/provider-qbittorrent/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..b690de9 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-qbittorrent EXPOSE 4002 diff --git a/apps/backend/drizzle/0010_add-server-username.sql b/apps/backend/drizzle/0010_add-server-username.sql new file mode 100644 index 0000000..fe4f0ab --- /dev/null +++ b/apps/backend/drizzle/0010_add-server-username.sql @@ -0,0 +1 @@ +ALTER TABLE "servers" ADD COLUMN "username" text; \ No newline at end of file diff --git a/apps/backend/drizzle/meta/0010_snapshot.json b/apps/backend/drizzle/meta/0010_snapshot.json new file mode 100644 index 0000000..84fab12 --- /dev/null +++ b/apps/backend/drizzle/meta/0010_snapshot.json @@ -0,0 +1,3281 @@ +{ + "id": "808acff7-519d-4a92-85ef-35c4b3ded8f7", + "prevId": "118f4cac-0a95-450d-ae09-ce6c9b721fd7", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.ai_analyses": { + "name": "ai_analyses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "server_id": { + "name": "server_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "log_entry_id": { + "name": "log_entry_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "response": { + "name": "response", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tokens_used": { + "name": "tokens_used", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ai_analyses_server_id_idx": { + "name": "ai_analyses_server_id_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ai_analyses_log_entry_id_idx": { + "name": "ai_analyses_log_entry_id_idx", + "columns": [ + { + "expression": "log_entry_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ai_analyses_session_id_idx": { + "name": "ai_analyses_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ai_analyses_server_id_servers_id_fk": { + "name": "ai_analyses_server_id_servers_id_fk", + "tableFrom": "ai_analyses", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ai_analyses_log_entry_id_log_entries_id_fk": { + "name": "ai_analyses_log_entry_id_log_entries_id_fk", + "tableFrom": "ai_analyses", + "tableTo": "log_entries", + "columnsFrom": [ + "log_entry_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ai_analyses_session_id_sessions_id_fk": { + "name": "ai_analyses_session_id_sessions_id_fk", + "tableFrom": "ai_analyses", + "tableTo": "sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ai_provider_settings": { + "name": "ai_provider_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "max_tokens": { + "name": "max_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1000 + }, + "temperature": { + "name": "temperature", + "type": "real", + "primaryKey": false, + "notNull": false, + "default": 0.7 + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_tested_at": { + "name": "last_tested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_test_result": { + "name": "last_test_result", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ai_provider_settings_provider_idx": { + "name": "ai_provider_settings_provider_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ai_provider_settings_is_default_idx": { + "name": "ai_provider_settings_is_default_idx", + "columns": [ + { + "expression": "is_default", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.analysis_conversations": { + "name": "analysis_conversations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "messages": { + "name": "messages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "context_snapshot": { + "name": "context_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_tokens": { + "name": "total_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "analysis_conversations_issue_id_idx": { + "name": "analysis_conversations_issue_id_idx", + "columns": [ + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "analysis_conversations_created_at_idx": { + "name": "analysis_conversations_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "analysis_conversations_issue_id_issues_id_fk": { + "name": "analysis_conversations_issue_id_issues_id_fk", + "tableFrom": "analysis_conversations", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_key_usage_log": { + "name": "api_key_usage_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "key_id": { + "name": "key_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "response_time": { + "name": "response_time", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "success": { + "name": "success", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "api_key_usage_log_key_id_idx": { + "name": "api_key_usage_log_key_id_idx", + "columns": [ + { + "expression": "key_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_usage_log_timestamp_idx": { + "name": "api_key_usage_log_timestamp_idx", + "columns": [ + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_usage_log_success_idx": { + "name": "api_key_usage_log_success_idx", + "columns": [ + { + "expression": "success", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_key_usage_log_key_id_api_keys_id_fk": { + "name": "api_key_usage_log_key_id_api_keys_id_fk", + "tableFrom": "api_key_usage_log", + "tableTo": "api_keys", + "columnsFrom": [ + "key_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_info": { + "name": "device_info", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "rate_limit": { + "name": "rate_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "rate_limit_ttl": { + "name": "rate_limit_ttl", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_used_ip": { + "name": "last_used_ip", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "api_keys_key_hash_idx": { + "name": "api_keys_key_hash_idx", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_keys_is_enabled_idx": { + "name": "api_keys_is_enabled_idx", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_keys_last_used_at_idx": { + "name": "api_keys_last_used_at_idx", + "columns": [ + { + "expression": "last_used_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_keys_key_hash_unique": { + "name": "api_keys_key_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "key_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_settings": { + "name": "app_settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "audit_log_action", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "audit_log_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "response_time": { + "name": "response_time", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "success": { + "name": "success", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "api_key_id": { + "name": "api_key_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_user_id_idx": { + "name": "audit_log_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_session_id_idx": { + "name": "audit_log_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_category_idx": { + "name": "audit_log_category_idx", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_entity_type_idx": { + "name": "audit_log_entity_type_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_entity_id_idx": { + "name": "audit_log_entity_id_idx", + "columns": [ + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_timestamp_idx": { + "name": "audit_log_timestamp_idx", + "columns": [ + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_success_idx": { + "name": "audit_log_success_idx", + "columns": [ + { + "expression": "success", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_api_key_id_idx": { + "name": "audit_log_api_key_id_idx", + "columns": [ + { + "expression": "api_key_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_log_timestamp_category": { + "name": "idx_audit_log_timestamp_category", + "columns": [ + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_log_user_action": { + "name": "idx_audit_log_user_action", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_api_key_id_api_keys_id_fk": { + "name": "audit_log_api_key_id_api_keys_id_fk", + "tableFrom": "audit_log", + "tableTo": "api_keys", + "columnsFrom": [ + "api_key_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_occurrences": { + "name": "issue_occurrences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "log_entry_id": { + "name": "log_entry_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_occurrences_issue_id_idx": { + "name": "issue_occurrences_issue_id_idx", + "columns": [ + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_occurrences_log_entry_id_idx": { + "name": "issue_occurrences_log_entry_id_idx", + "columns": [ + { + "expression": "log_entry_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_occurrences_timestamp_idx": { + "name": "issue_occurrences_timestamp_idx", + "columns": [ + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_occurrences_issue_log_idx": { + "name": "issue_occurrences_issue_log_idx", + "columns": [ + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "log_entry_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_issue_occurrences_issue_timestamp": { + "name": "idx_issue_occurrences_issue_timestamp", + "columns": [ + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_occurrences_issue_id_issues_id_fk": { + "name": "issue_occurrences_issue_id_issues_id_fk", + "tableFrom": "issue_occurrences", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_occurrences_log_entry_id_log_entries_id_fk": { + "name": "issue_occurrences_log_entry_id_log_entries_id_fk", + "tableFrom": "issue_occurrences", + "tableTo": "log_entries", + "columnsFrom": [ + "log_entry_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_occurrences_server_id_servers_id_fk": { + "name": "issue_occurrences_server_id_servers_id_fk", + "tableFrom": "issue_occurrences", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_patterns": { + "name": "issue_patterns", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sources": { + "name": "sources", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "pattern": { + "name": "pattern", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_regex": { + "name": "is_regex", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "suggested_severity": { + "name": "suggested_severity", + "type": "issue_severity", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'medium'" + }, + "suggested_category": { + "name": "suggested_category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_system": { + "name": "is_system", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "match_count": { + "name": "match_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_patterns_is_enabled_idx": { + "name": "issue_patterns_is_enabled_idx", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_patterns_is_system_idx": { + "name": "issue_patterns_is_system_idx", + "columns": [ + { + "expression": "is_system", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issues": { + "name": "issues", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "issue_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "severity": { + "name": "severity", + "type": "issue_severity", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'medium'" + }, + "status": { + "name": "status", + "type": "issue_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "server_id": { + "name": "server_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_pattern": { + "name": "error_pattern", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sample_message": { + "name": "sample_message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "exception_type": { + "name": "exception_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "first_seen": { + "name": "first_seen", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_seen": { + "name": "last_seen", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "occurrence_count": { + "name": "occurrence_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "affected_users_count": { + "name": "affected_users_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "affected_sessions_count": { + "name": "affected_sessions_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "impact_score": { + "name": "impact_score", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "ai_analysis": { + "name": "ai_analysis", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ai_analysis_at": { + "name": "ai_analysis_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "ai_suggested_fix": { + "name": "ai_suggested_fix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "related_links": { + "name": "related_links", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resolved_by": { + "name": "resolved_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issues_fingerprint_idx": { + "name": "issues_fingerprint_idx", + "columns": [ + { + "expression": "fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_server_id_idx": { + "name": "issues_server_id_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_source_idx": { + "name": "issues_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_severity_idx": { + "name": "issues_severity_idx", + "columns": [ + { + "expression": "severity", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_status_idx": { + "name": "issues_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_category_idx": { + "name": "issues_category_idx", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_last_seen_idx": { + "name": "issues_last_seen_idx", + "columns": [ + { + "expression": "last_seen", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_impact_score_idx": { + "name": "issues_impact_score_idx", + "columns": [ + { + "expression": "impact_score", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_occurrence_count_idx": { + "name": "issues_occurrence_count_idx", + "columns": [ + { + "expression": "occurrence_count", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_issues_status_severity": { + "name": "idx_issues_status_severity", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "severity", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_issues_server_status": { + "name": "idx_issues_server_status", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issues_server_id_servers_id_fk": { + "name": "issues_server_id_servers_id_fk", + "tableFrom": "issues", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "issues_fingerprint_unique": { + "name": "issues_fingerprint_unique", + "nullsNotDistinct": false, + "columns": [ + "fingerprint" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.log_entries": { + "name": "log_entries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "server_id": { + "name": "server_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "external_activity_id": { + "name": "external_activity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "raw": { + "name": "raw", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "play_session_id": { + "name": "play_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "exception": { + "name": "exception", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stack_trace": { + "name": "stack_trace", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "search_vector": { + "name": "search_vector", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "log_source": { + "name": "log_source", + "type": "log_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'api'" + }, + "log_file_path": { + "name": "log_file_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "log_file_line": { + "name": "log_file_line", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deduplication_key": { + "name": "deduplication_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "log_entries_server_id_idx": { + "name": "log_entries_server_id_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "log_entries_timestamp_idx": { + "name": "log_entries_timestamp_idx", + "columns": [ + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "log_entries_level_idx": { + "name": "log_entries_level_idx", + "columns": [ + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "log_entries_source_idx": { + "name": "log_entries_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "log_entries_session_id_idx": { + "name": "log_entries_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "log_entries_user_id_idx": { + "name": "log_entries_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "log_entries_play_session_id_idx": { + "name": "log_entries_play_session_id_idx", + "columns": [ + { + "expression": "play_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "log_entries_log_source_idx": { + "name": "log_entries_log_source_idx", + "columns": [ + { + "expression": "log_source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "log_entries_server_external_id_idx": { + "name": "log_entries_server_external_id_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_activity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "log_entries_server_dedup_key_idx": { + "name": "log_entries_server_dedup_key_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deduplication_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_log_entries_server_timestamp": { + "name": "idx_log_entries_server_timestamp", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_log_entries_server_level": { + "name": "idx_log_entries_server_level", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_log_entries_level_timestamp": { + "name": "idx_log_entries_level_timestamp", + "columns": [ + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "log_entries_server_id_servers_id_fk": { + "name": "log_entries_server_id_servers_id_fk", + "tableFrom": "log_entries", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.log_file_state": { + "name": "log_file_state", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "server_id": { + "name": "server_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "absolute_path": { + "name": "absolute_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_size": { + "name": "file_size", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "0" + }, + "byte_offset": { + "name": "byte_offset", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "0" + }, + "line_number": { + "name": "line_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "file_inode": { + "name": "file_inode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_modified_at": { + "name": "file_modified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_read_at": { + "name": "last_read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "log_file_state_server_file_idx": { + "name": "log_file_state_server_file_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "file_path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "log_file_state_server_id_idx": { + "name": "log_file_state_server_id_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "log_file_state_is_active_idx": { + "name": "log_file_state_is_active_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "log_file_state_server_id_servers_id_fk": { + "name": "log_file_state_server_id_servers_id_fk", + "tableFrom": "log_file_state", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.playback_events": { + "name": "playback_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "item_name": { + "name": "item_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "item_type": { + "name": "item_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "position_ticks": { + "name": "position_ticks", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "duration_ticks": { + "name": "duration_ticks", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "is_paused": { + "name": "is_paused", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_muted": { + "name": "is_muted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_transcoding": { + "name": "is_transcoding", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "transcode_reasons": { + "name": "transcode_reasons", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "video_codec": { + "name": "video_codec", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "audio_codec": { + "name": "audio_codec", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "container": { + "name": "container", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thumbnail_url": { + "name": "thumbnail_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "series_name": { + "name": "series_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "season_name": { + "name": "season_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "playback_events_session_id_idx": { + "name": "playback_events_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "playback_events_timestamp_idx": { + "name": "playback_events_timestamp_idx", + "columns": [ + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "playback_events_event_type_idx": { + "name": "playback_events_event_type_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_playback_events_session_timestamp": { + "name": "idx_playback_events_session_timestamp", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "playback_events_session_id_sessions_id_fk": { + "name": "playback_events_session_id_sessions_id_fk", + "tableFrom": "playback_events", + "tableTo": "sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.retention_history": { + "name": "retention_history", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "info_deleted": { + "name": "info_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "debug_deleted": { + "name": "debug_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "warn_deleted": { + "name": "warn_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_deleted": { + "name": "error_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "orphaned_occurrences_deleted": { + "name": "orphaned_occurrences_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "retention_history_started_at_idx": { + "name": "retention_history_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "retention_history_status_idx": { + "name": "retention_history_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.servers": { + "name": "servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "log_path": { + "name": "log_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_connected": { + "name": "is_connected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "last_seen": { + "name": "last_seen", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "server_name": { + "name": "server_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_activity_sync": { + "name": "last_activity_sync", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "file_ingestion_enabled": { + "name": "file_ingestion_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "file_ingestion_connected": { + "name": "file_ingestion_connected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "file_ingestion_error": { + "name": "file_ingestion_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "log_paths": { + "name": "log_paths", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "log_file_patterns": { + "name": "log_file_patterns", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "last_file_sync": { + "name": "last_file_sync", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "sync_status": { + "name": "sync_status", + "type": "sync_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "sync_progress": { + "name": "sync_progress", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sync_total_files": { + "name": "sync_total_files", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sync_processed_files": { + "name": "sync_processed_files", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sync_started_at": { + "name": "sync_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "sync_completed_at": { + "name": "sync_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "initial_sync_completed": { + "name": "initial_sync_completed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "servers_provider_id_idx": { + "name": "servers_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "servers_is_enabled_idx": { + "name": "servers_is_enabled_idx", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "servers_sync_status_idx": { + "name": "servers_sync_status_idx", + "columns": [ + { + "expression": "sync_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "server_id": { + "name": "server_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "play_session_id": { + "name": "play_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_name": { + "name": "user_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_name": { + "name": "device_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_name": { + "name": "client_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_version": { + "name": "client_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "now_playing_item_id": { + "name": "now_playing_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "now_playing_item_name": { + "name": "now_playing_item_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "now_playing_item_type": { + "name": "now_playing_item_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_activity": { + "name": "last_activity", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sessions_server_id_idx": { + "name": "sessions_server_id_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_user_id_idx": { + "name": "sessions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_device_id_idx": { + "name": "sessions_device_id_idx", + "columns": [ + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_is_active_idx": { + "name": "sessions_is_active_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_server_external_id_idx": { + "name": "sessions_server_external_id_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_sessions_server_active": { + "name": "idx_sessions_server_active", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_server_id_servers_id_fk": { + "name": "sessions_server_id_servers_id_fk", + "tableFrom": "sessions", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.audit_log_action": { + "name": "audit_log_action", + "schema": "public", + "values": [ + "create", + "update", + "delete", + "read", + "login", + "logout", + "error", + "export", + "import", + "sync", + "test", + "other" + ] + }, + "public.audit_log_category": { + "name": "audit_log_category", + "schema": "public", + "values": [ + "auth", + "server", + "log_entry", + "session", + "playback", + "issue", + "ai_analysis", + "api_key", + "settings", + "retention", + "proxy", + "other" + ] + }, + "public.issue_severity": { + "name": "issue_severity", + "schema": "public", + "values": [ + "critical", + "high", + "medium", + "low", + "info" + ] + }, + "public.issue_source": { + "name": "issue_source", + "schema": "public", + "values": [ + "jellyfin", + "sonarr", + "radarr", + "prowlarr", + "docker", + "system" + ] + }, + "public.issue_status": { + "name": "issue_status", + "schema": "public", + "values": [ + "open", + "acknowledged", + "in_progress", + "resolved", + "ignored" + ] + }, + "public.log_source": { + "name": "log_source", + "schema": "public", + "values": [ + "api", + "file" + ] + }, + "public.sync_status": { + "name": "sync_status", + "schema": "public", + "values": [ + "idle", + "pending", + "discovering", + "syncing", + "error" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/backend/drizzle/meta/_journal.json b/apps/backend/drizzle/meta/_journal.json index 96a9cc3..970b75d 100644 --- a/apps/backend/drizzle/meta/_journal.json +++ b/apps/backend/drizzle/meta/_journal.json @@ -71,6 +71,13 @@ "when": 1768151860740, "tag": "0009_high_venus", "breakpoints": true + }, + { + "idx": 10, + "version": "7", + "when": 1791589999831, + "tag": "0010_add-server-username", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/backend/package.json b/apps/backend/package.json index 535f58a..bc06661 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -30,6 +30,7 @@ "@logarr/provider-lidarr": "workspace:*", "@logarr/provider-plex": "workspace:*", "@logarr/provider-prowlarr": "workspace:*", + "@logarr/provider-qbittorrent": "workspace:*", "@logarr/provider-radarr": "workspace:*", "@logarr/provider-sonarr": "workspace:*", "@logarr/provider-whisparr": "workspace:*", diff --git a/apps/backend/src/database/schema.ts b/apps/backend/src/database/schema.ts index 2378b4c..af8b81d 100644 --- a/apps/backend/src/database/schema.ts +++ b/apps/backend/src/database/schema.ts @@ -60,7 +60,10 @@ export const servers = pgTable( name: text('name').notNull(), providerId: text('provider_id').notNull(), url: text('url').notNull(), + // API key / token / password. May be '' for providers without auth (e.g. qBittorrent bypass) apiKey: text('api_key').notNull(), + // Optional login username (e.g. qBittorrent). The password lives in api_key. + username: text('username'), logPath: text('log_path'), isEnabled: boolean('is_enabled').notNull().default(true), isConnected: boolean('is_connected').notNull().default(false), diff --git a/apps/backend/src/modules/file-ingestion/log-file-processor.qbittorrent.spec.ts b/apps/backend/src/modules/file-ingestion/log-file-processor.qbittorrent.spec.ts new file mode 100644 index 0000000..fec7f83 --- /dev/null +++ b/apps/backend/src/modules/file-ingestion/log-file-processor.qbittorrent.spec.ts @@ -0,0 +1,39 @@ +import { QBittorrentProvider } from '@logarr/provider-qbittorrent'; +import { describe, expect, it } from 'vitest'; + +import { LogFileProcessor } from './log-file-processor'; + +import type { ParsedLogEntry } from '@logarr/core'; + +const SAMPLE = [ + '(N) 2026-10-10T12:00:01 - qBittorrent v5.2.4 started', + '(I) 2026-10-10T12:00:02 - Trying to listen on the following list of IP addresses: "0.0.0.0:6881,[::]:6881"', + '(W) 2026-10-10T12:01:15 - File error alert. Torrent: "Some.Torrent.Name". File: "/downloads/x.mkv". Reason: "Permission denied"', + 'some unparseable garbage line', + '(C) 2026-10-10T12:02:30 - I/O error. Torrent: "Foo". Reason: "No space left on device"', + '(W) 2026-10-10T12:03:00 - WebUI login failure. IP: 172.18.0.5, username: admin', +]; + +describe('LogFileProcessor with the qBittorrent provider', () => { + it('emits every parseable qbittorrent.log line exactly once, with correct levels', () => { + const processor = new LogFileProcessor(new QBittorrentProvider()); + const entries: ParsedLogEntry[] = []; + + SAMPLE.forEach((line, index) => { + const entry = processor.processLine(line, index + 1); + if (entry) entries.push(entry); + }); + const last = processor.flush(); + if (last) entries.push(last); + + expect(entries.map((e) => e.level)).toEqual(['info', 'info', 'warn', 'error', 'warn']); + expect(entries.map((e) => e.message)).toEqual([ + 'qBittorrent v5.2.4 started', + 'Trying to listen on the following list of IP addresses: "0.0.0.0:6881,[::]:6881"', + 'File error alert. Torrent: "Some.Torrent.Name". File: "/downloads/x.mkv". Reason: "Permission denied"', + 'I/O error. Torrent: "Foo". Reason: "No space left on device"', + 'WebUI login failure. IP: 172.18.0.5, username: admin', + ]); + expect(entries.every((e) => e.stackTrace === undefined)).toBe(true); + }); +}); diff --git a/apps/backend/src/modules/ingestion/ingestion.service.ts b/apps/backend/src/modules/ingestion/ingestion.service.ts index 66dd43d..863f93c 100644 --- a/apps/backend/src/modules/ingestion/ingestion.service.ts +++ b/apps/backend/src/modules/ingestion/ingestion.service.ts @@ -5,6 +5,7 @@ import { JellyfinProvider, JellyfinClient } from '@logarr/provider-jellyfin'; import { LidarrProvider } from '@logarr/provider-lidarr'; import { PlexProvider, PlexClient } from '@logarr/provider-plex'; import { ProwlarrProvider } from '@logarr/provider-prowlarr'; +import { QBittorrentProvider } from '@logarr/provider-qbittorrent'; import { RadarrProvider } from '@logarr/provider-radarr'; import { SonarrProvider } from '@logarr/provider-sonarr'; import { WhisparrProvider } from '@logarr/provider-whisparr'; @@ -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 qbittorrentProvider = new QBittorrentProvider(); + this.providers.set(qbittorrentProvider.id, qbittorrentProvider); } async onModuleInit() { @@ -229,6 +234,7 @@ export class IngestionService implements OnModuleInit, OnModuleDestroy { await provider.connect({ url: server.url, apiKey: server.apiKey, + username: server.username ?? undefined, logPath: server.logPath ?? undefined, }); @@ -241,18 +247,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)); + } } } @@ -291,6 +298,7 @@ export class IngestionService implements OnModuleInit, OnModuleDestroy { await provider.connect({ url: server.url, apiKey: server.apiKey, + username: server.username ?? undefined, logPath: server.logPath ?? undefined, }); 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.spec.ts b/apps/backend/src/modules/proxy/proxy.service.spec.ts index 3f2e9bc..de91727 100644 --- a/apps/backend/src/modules/proxy/proxy.service.spec.ts +++ b/apps/backend/src/modules/proxy/proxy.service.spec.ts @@ -1,5 +1,5 @@ import { HttpService } from '@nestjs/axios'; -import { NotFoundException, ForbiddenException } from '@nestjs/common'; +import { NotFoundException, ForbiddenException, BadRequestException } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import { of, throwError } from 'rxjs'; import { describe, it, expect, vi, beforeEach } from 'vitest'; @@ -421,6 +421,68 @@ describe('ProxyService', () => { ); }); + describe('qBittorrent authentication modes', () => { + const qbitServer = { + id: 'server-qbit', + name: 'My qBittorrent', + providerId: 'qbittorrent', + url: 'http://localhost:8080', + isEnabled: true, + }; + + it('should reject servers that use username/password login', async () => { + configureMockDb(mockDb, { + select: [{ ...qbitServer, apiKey: 'password', username: 'admin' }], + }); + + await expect( + service.proxyRequest('server-qbit', { endpoint: 'torrents/info', method: 'GET' }) + ).rejects.toThrow(BadRequestException); + expect(mockHttpService.request).not.toHaveBeenCalled(); + }); + + it('should reject any server with a stored username regardless of provider', async () => { + configureMockDb(mockDb, { + select: [{ ...qbitServer, providerId: 'other', apiKey: 'pw', username: 'bob' }], + }); + + await expect( + service.proxyRequest('server-qbit', { endpoint: 'x', method: 'GET' }) + ).rejects.toThrow(/cookie session/); + }); + + it('should send Authorization: Bearer (and X-Api-Key) for API key setups without username', async () => { + configureMockDb(mockDb, { + select: [{ ...qbitServer, apiKey: 'qbt-key', username: null }], + }); + vi.mocked(mockHttpService.request)!.mockReturnValue(of({ data: {}, status: 200 } as any)); + + await service.proxyRequest('server-qbit', { endpoint: 'torrents/info', method: 'GET' }); + + expect(mockHttpService.request).toHaveBeenCalledWith( + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer qbt-key', + 'X-Api-Key': 'qbt-key', + }), + }) + ); + }); + + it('should omit X-Api-Key when the key is empty (auth bypass)', async () => { + configureMockDb(mockDb, { select: [{ ...qbitServer, apiKey: '', username: null }] }); + vi.mocked(mockHttpService.request)!.mockReturnValue(of({ data: {}, status: 200 } as any)); + + await service.proxyRequest('server-qbit', { endpoint: 'torrents/info', method: 'GET' }); + + const call = vi.mocked(mockHttpService.request)!.mock.calls[0]![0] as { + headers: Record; + }; + expect(call.headers).not.toHaveProperty('X-Api-Key'); + expect(call.headers).not.toHaveProperty('Authorization'); + }); + }); + it('should replace path parameters in URL', async () => { const mockServer = { id: 'server-sonarr', diff --git a/apps/backend/src/modules/proxy/proxy.service.ts b/apps/backend/src/modules/proxy/proxy.service.ts index 64cf4eb..ffc9adf 100644 --- a/apps/backend/src/modules/proxy/proxy.service.ts +++ b/apps/backend/src/modules/proxy/proxy.service.ts @@ -1,5 +1,12 @@ import { HttpService } from '@nestjs/axios'; -import { Injectable, Inject, NotFoundException, ForbiddenException, Logger } from '@nestjs/common'; +import { + Injectable, + Inject, + NotFoundException, + ForbiddenException, + BadRequestException, + Logger, +} from '@nestjs/common'; import { eq } from 'drizzle-orm'; import { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; import { firstValueFrom } from 'rxjs'; @@ -15,6 +22,7 @@ interface ServerWithConfig { providerId: string; url: string; apiKey: string; + username?: string | null; status?: string; lastChecked?: Date; } @@ -35,6 +43,12 @@ export class ProxyService { sabnzbd: '/api', }; + // Per-provider API key auth headers (default: X-Api-Key). Released qBittorrent (>= 5.2) only + // accepts `Authorization: Bearer `; X-API-Key exists on qBittorrent master only. + private readonly authHeaderMap: Record Record> = { + qbittorrent: (key) => ({ Authorization: `Bearer ${key}`, 'X-Api-Key': key }), + }; + constructor( @Inject(DATABASE_CONNECTION) private readonly db: PostgresJsDatabase, @@ -77,6 +91,15 @@ export class ProxyService { return server; } + private buildAuthHeaders(server: ServerWithConfig): Record { + if (server.apiKey === '') { + return {}; + } + return (this.authHeaderMap[server.providerId] ?? ((key) => ({ 'X-Api-Key': key })))( + server.apiKey + ); + } + /** * Proxy request to target service with enhanced features */ @@ -88,6 +111,15 @@ export class ProxyService { // 1. Get server configuration const server = await this.findUserServer(serverId); + // Servers with a stored username authenticate with a cookie session (username/password + // login), which the proxy cannot do. API-key servers work (qBittorrent >= 5.2 takes the key as + // a Bearer token, see authHeaderMap); auth-bypass servers need no header. + if (server.username) { + throw new BadRequestException( + 'The request proxy does not support servers that use username/password login (cookie session). Use an API key (qBittorrent 5.2+) or authentication bypass instead.' + ); + } + // 2. Build target URL with path parameters const targetUrl = this.buildTargetUrl(server, dto.endpoint, dto.params); @@ -95,7 +127,8 @@ export class ProxyService { // 3. Merge headers const headers: Record = { - 'X-Api-Key': server.apiKey, + // Providers without credentials (e.g. qBittorrent auth bypass) store an empty key + ...this.buildAuthHeaders(server), 'Content-Type': 'application/json', ...dto.headers, }; diff --git a/apps/backend/src/modules/servers/server-response.mapper.ts b/apps/backend/src/modules/servers/server-response.mapper.ts new file mode 100644 index 0000000..0718086 --- /dev/null +++ b/apps/backend/src/modules/servers/server-response.mapper.ts @@ -0,0 +1,18 @@ +import type * as schema from '../../database/schema'; + +export type ServerRow = typeof schema.servers.$inferSelect; + +/** A server as returned by the API: the stored secret is never included */ +export type ServerResponse = Omit & { + /** Whether a secret (API key / token / password) is stored. The value itself is never returned. */ + hasApiKey: boolean; +}; + +/** + * Map a database row to the API shape. Every server returned over HTTP must go through this + * function so the `api_key` column (API key, token or qBittorrent WebUI password) cannot leak. + */ +export function toServerResponse(row: ServerRow): ServerResponse { + const { apiKey, ...rest } = row; + return { ...rest, hasApiKey: apiKey !== '' }; +} diff --git a/apps/backend/src/modules/servers/server-seed.service.spec.ts b/apps/backend/src/modules/servers/server-seed.service.spec.ts new file mode 100644 index 0000000..9a458cf --- /dev/null +++ b/apps/backend/src/modules/servers/server-seed.service.spec.ts @@ -0,0 +1,196 @@ +import { Logger } from '@nestjs/common'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createMockDb, configureMockDb, type MockDb } from '../../test/mock-db'; + +import { ServerSeedService, describeSeedError } from './server-seed.service'; + +const SEEDED_ENV_KEYS = [ + 'JELLYFIN_URL', + 'JELLYFIN_API_KEY', + 'QBITTORRENT_URL', + 'QBITTORRENT_USERNAME', + 'QBITTORRENT_PASSWORD', + 'QBITTORRENT_LOGS_PATH', +] as const; + +describe('ServerSeedService - qBittorrent', () => { + let mockDb: MockDb; + let service: ServerSeedService; + const originalEnv: Record = {}; + let providerList: Array<{ id: string; capabilities: Record }>; + + const insertedRows = (): Array> => + mockDb.insert.mock.results.map( + (r) => + (r.value as { values: ReturnType }).values.mock.calls[0]![0] as Record< + string, + unknown + > + ); + + beforeEach(() => { + providerList = [ + { id: 'qbittorrent', capabilities: { apiKeyOptional: true, supportsUsername: true } }, + { id: 'jellyfin', capabilities: {} }, + ]; + for (const key of SEEDED_ENV_KEYS) { + originalEnv[key] = process.env[key]; + delete process.env[key]; + } + // Unrelated providers must not be seeded by the test environment + for (const key of Object.keys(process.env)) { + if (/_(URL|API_KEY)$/.test(key) && !key.startsWith('QBITTORRENT')) { + originalEnv[key] = process.env[key]; + delete process.env[key]; + } + } + mockDb = createMockDb(); + configureMockDb(mockDb, { select: [] }); + service = new ServerSeedService( + mockDb as never, + { + getAvailableProviders: () => providerList, + } as never + ); + }); + + afterEach(() => { + for (const [key, value] of Object.entries(originalEnv)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + }); + + it('seeds qBittorrent with only a URL (auth bypass)', async () => { + process.env['QBITTORRENT_URL'] = 'http://qbit:8080'; + + await service.onModuleInit(); + + expect(insertedRows()).toHaveLength(1); + expect(insertedRows()[0]).toMatchObject({ + providerId: 'qbittorrent', + url: 'http://qbit:8080', + apiKey: '', + username: null, + }); + }); + + it('seeds username and password', async () => { + process.env['QBITTORRENT_URL'] = 'http://qbit:8080'; + process.env['QBITTORRENT_USERNAME'] = ' admin '; + process.env['QBITTORRENT_PASSWORD'] = ' secret '; + + await service.onModuleInit(); + + // the password is stored verbatim (not trimmed), the username is trimmed + expect(insertedRows()[0]).toMatchObject({ username: 'admin', apiKey: ' secret ' }); + }); + + it('seeds an API key when only the password variable is set', async () => { + process.env['QBITTORRENT_URL'] = 'http://qbit:8080'; + process.env['QBITTORRENT_PASSWORD'] = 'qbt_key'; + + await service.onModuleInit(); + + expect(insertedRows()[0]).toMatchObject({ username: null, apiKey: 'qbt_key' }); + }); + + it('enables file ingestion with the container log path when QBITTORRENT_LOGS_PATH is set', async () => { + process.env['QBITTORRENT_URL'] = 'http://qbit:8080'; + process.env['QBITTORRENT_LOGS_PATH'] = '/host/logs'; + + await service.onModuleInit(); + + expect(insertedRows()[0]).toMatchObject({ + fileIngestionEnabled: true, + logPath: '/qbittorrent-logs', + logPaths: ['/qbittorrent-logs'], + }); + }); + + it('does not seed without a URL', async () => { + process.env['QBITTORRENT_PASSWORD'] = 'secret'; + + await service.onModuleInit(); + + expect(mockDb.insert).not.toHaveBeenCalled(); + }); + + it('does not seed when a qBittorrent server already exists', async () => { + process.env['QBITTORRENT_URL'] = 'http://qbit:8080'; + configureMockDb(mockDb, { select: [{ id: 'existing' }] }); + + await service.onModuleInit(); + + expect(mockDb.insert).not.toHaveBeenCalled(); + }); + + it('requires an API key when the provider does not declare apiKeyOptional', async () => { + providerList = [{ id: 'qbittorrent', capabilities: {} }]; + process.env['QBITTORRENT_URL'] = 'http://qbit:8080'; + + await service.onModuleInit(); + + expect(mockDb.insert).not.toHaveBeenCalled(); + }); + + it('still requires an API key for other providers', async () => { + process.env['JELLYFIN_URL'] = 'http://jf:8096'; + + await service.onModuleInit(); + + expect(mockDb.insert).not.toHaveBeenCalled(); + }); + + describe('failure logging', () => { + const failingInsert = (error: Error): void => { + mockDb.insert = vi.fn().mockImplementation(() => ({ + values: () => Promise.reject(error), + })); + }; + + it('does not log the database error message (it contains bound parameters)', async () => { + process.env['QBITTORRENT_URL'] = 'http://qbit:8080'; + process.env['QBITTORRENT_USERNAME'] = 'admin'; + process.env['QBITTORRENT_PASSWORD'] = 'super-secret-pw'; + failingInsert( + Object.assign( + new Error('Failed query: insert into "servers" ... params: admin,super-secret-pw'), + { name: 'DrizzleQueryError', cause: { code: '23505' } } + ) + ); + const warn = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); + + await service.onModuleInit(); + + const logged = JSON.stringify(warn.mock.calls); + expect(logged).not.toContain('super-secret-pw'); + expect(logged).toContain('DrizzleQueryError (code 23505)'); + warn.mockRestore(); + }); + + it('rethrows a sanitized error when seeding itself fails', async () => { + process.env['JELLYFIN_URL'] = 'http://jf'; + process.env['JELLYFIN_API_KEY'] = 'k'; + mockDb.select = vi.fn().mockImplementation(() => { + throw new Error('select ... params: super-secret-pw'); + }); + const error = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); + + const result = service.onModuleInit(); + + await expect(result).rejects.toThrow('ServerSeedService failed: Error'); + await expect(result).rejects.not.toThrow(/super-secret-pw/); + expect(JSON.stringify(error.mock.calls)).not.toContain('super-secret-pw'); + error.mockRestore(); + }); + + it('describes non-errors and coded errors safely', () => { + expect(describeSeedError('boom super-secret-pw')).toBe('Unknown error'); + expect(describeSeedError(Object.assign(new Error('x'), { code: 'ECONNREFUSED' }))).toBe( + 'Error (code ECONNREFUSED)' + ); + }); + }); +}); diff --git a/apps/backend/src/modules/servers/server-seed.service.ts b/apps/backend/src/modules/servers/server-seed.service.ts index 1cc32ed..6f66770 100644 --- a/apps/backend/src/modules/servers/server-seed.service.ts +++ b/apps/backend/src/modules/servers/server-seed.service.ts @@ -4,6 +4,8 @@ import { eq } from 'drizzle-orm'; import { DATABASE_CONNECTION } from '../../database'; import * as schema from '../../database/schema'; +import { ServersService } from './servers.service'; + import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; /** @@ -13,7 +15,10 @@ import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; */ interface EnvServerConfig { urlEnvKey: string; + /** Env var holding the API key (or the password when usernameEnvKey is used) */ apiKeyEnvKey: string; + /** Optional env var holding a login username */ + usernameEnvKey?: string; providerId: string; name: string; /** Container path for log files (used in Docker) */ @@ -77,15 +82,39 @@ const ENV_SERVER_CONFIGS: EnvServerConfig[] = [ name: 'Lidarr', containerLogPath: '/lidarr-logs', }, + { + urlEnvKey: 'QBITTORRENT_URL', + usernameEnvKey: 'QBITTORRENT_USERNAME', + apiKeyEnvKey: 'QBITTORRENT_PASSWORD', + providerId: 'qbittorrent', + name: 'qBittorrent', + containerLogPath: '/qbittorrent-logs', + }, ]; +/** + * Describe a seeding error without its message: Drizzle query errors include the bound + * parameters, which would put credentials (e.g. QBITTORRENT_PASSWORD) into the logs. + */ +export function describeSeedError(error: unknown): string { + if (!(error instanceof Error)) { + return 'Unknown error'; + } + const withCode = error as Error & { code?: unknown; cause?: { code?: unknown } }; + const code = withCode.code ?? withCode.cause?.code; + return typeof code === 'string' || typeof code === 'number' + ? `${error.name} (code ${code})` + : error.name; +} + @Injectable() export class ServerSeedService implements OnModuleInit { private readonly logger = new Logger(ServerSeedService.name); constructor( @Inject(DATABASE_CONNECTION) - private readonly db: PostgresJsDatabase + private readonly db: PostgresJsDatabase, + private readonly serversService: ServersService ) {} async onModuleInit() { @@ -93,8 +122,11 @@ export class ServerSeedService implements OnModuleInit { try { await this.seedServersFromEnv(); } catch (error) { - this.logger.error(`ServerSeedService failed: ${error}`); - throw error; + const description = describeSeedError(error); + this.logger.error(`ServerSeedService failed: ${description}`); + // Do not rethrow the original error: Drizzle errors embed the bound query parameters + // (including the stored credentials) in their message + throw new Error(`ServerSeedService failed: ${description}`); } } @@ -107,13 +139,25 @@ export class ServerSeedService implements OnModuleInit { let seededCount = 0; for (const config of ENV_SERVER_CONFIGS) { + // Whether only the URL is required (credentials may be empty, e.g. auth bypass) comes from + // the provider's capabilities + const apiKeyOptional = + this.serversService.getAvailableProviders().find((p) => p.id === config.providerId) + ?.capabilities.apiKeyOptional === true; const url = process.env[config.urlEnvKey]; - const apiKey = process.env[config.apiKeyEnvKey]; + // Not trimmed for providers with a password: a leading/trailing space may be intentional, + // and the UI stores the value verbatim too. Only the emptiness check ignores whitespace. + const apiKey = config.usernameEnvKey + ? (process.env[config.apiKeyEnvKey] ?? '') + : (process.env[config.apiKeyEnvKey]?.trim() ?? ''); + const username = config.usernameEnvKey + ? process.env[config.usernameEnvKey]?.trim() || null + : null; this.logger.log(`Checking ${config.providerId}: URL=${!!url}, API key=${!!apiKey}`); - // Skip if URL or API key not set - if (!url || url.trim() === '' || !apiKey || apiKey.trim() === '') { + // Skip if URL (or, unless credentials are optional, the API key) is not set + if (!url || url.trim() === '' || (!apiKeyOptional && apiKey.trim() === '')) { this.logger.log(`Skipping ${config.providerId}: missing URL or API key`); continue; } @@ -145,7 +189,8 @@ export class ServerSeedService implements OnModuleInit { name: config.name, providerId: config.providerId, url: url.trim(), - apiKey: apiKey.trim(), + apiKey, + username, // Enable file ingestion if log path is configured fileIngestionEnabled: hasLogPath, logPaths: effectiveLogPath ? [effectiveLogPath] : null, @@ -159,7 +204,9 @@ export class ServerSeedService implements OnModuleInit { `Auto-configured server from env: ${config.name} (${config.providerId})${logStatus}` ); } catch (error) { - this.logger.warn(`Failed to auto-configure server ${config.name}: ${error}`); + this.logger.warn( + `Failed to auto-configure server ${config.name}: ${describeSeedError(error)}` + ); } } diff --git a/apps/backend/src/modules/servers/servers.dto.ts b/apps/backend/src/modules/servers/servers.dto.ts index aadef9c..bcabf22 100644 --- a/apps/backend/src/modules/servers/servers.dto.ts +++ b/apps/backend/src/modules/servers/servers.dto.ts @@ -10,9 +10,17 @@ export class CreateServerDto { @ApiProperty({ description: 'Server URL' }) url: string; - @ApiProperty({ description: 'API key for authentication' }) + @ApiProperty({ + description: + 'API key / token / password for authentication (may be empty for qBittorrent with authentication bypass)', + }) apiKey: string; + @ApiPropertyOptional({ + description: 'Login username (qBittorrent). Leave empty for API-key or no-auth setups.', + }) + username?: string; + @ApiPropertyOptional({ description: 'Path to log files' }) logPath?: string; @@ -36,8 +44,18 @@ export class UpdateServerDto { @ApiPropertyOptional({ description: 'Server URL' }) url?: string; - @ApiPropertyOptional({ description: 'API key for authentication' }) - apiKey?: string; + @ApiPropertyOptional({ + description: + 'API key / token / password for authentication. Omit or send an empty string to keep the stored value. Send null to clear it (only for providers whose credentials are optional, e.g. qBittorrent with authentication bypass).', + type: String, + nullable: true, + }) + apiKey?: string | null; + + @ApiPropertyOptional({ + description: 'Login username (qBittorrent). An empty string clears it.', + }) + username?: string; @ApiPropertyOptional({ description: 'Path to log files' }) logPath?: string; @@ -71,6 +89,15 @@ export class ServerResponseDto { @ApiProperty() url: string; + @ApiPropertyOptional({ description: 'Login username (qBittorrent)' }) + username?: string | null; + + @ApiProperty({ + description: + 'Whether a secret (API key, token or password) is stored. The secret is never returned.', + }) + hasApiKey: boolean; + @ApiPropertyOptional() logPath?: string | null; diff --git a/apps/backend/src/modules/servers/servers.service.spec.ts b/apps/backend/src/modules/servers/servers.service.spec.ts index b68d720..9baa5e3 100644 --- a/apps/backend/src/modules/servers/servers.service.spec.ts +++ b/apps/backend/src/modules/servers/servers.service.spec.ts @@ -1,4 +1,4 @@ -import { NotFoundException } from '@nestjs/common'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import { describe, it, expect, vi, beforeEach } from 'vitest'; @@ -11,6 +11,12 @@ import { ServersService } from './servers.service'; import type { TestingModule } from '@nestjs/testing'; +/** Expected API shape: the stored secret is replaced by hasApiKey */ +const withoutSecret = (row: T) => { + const { apiKey, ...rest } = row; + return { ...rest, hasApiKey: apiKey !== '' }; +}; + describe('ServersService', () => { let service: ServersService; let mockDb: MockDb; @@ -66,7 +72,7 @@ describe('ServersService', () => { const result = await service.findAll(); - expect(result).toEqual(servers); + expect(result).toEqual(servers.map(withoutSecret)); }); it('should return empty array when no servers', async () => { @@ -84,7 +90,7 @@ describe('ServersService', () => { const result = await service.findOne('server-1'); - expect(result).toEqual(mockServer); + expect(result).toEqual(withoutSecret(mockServer)); }); it('should throw NotFoundException when server not found', async () => { @@ -107,7 +113,7 @@ describe('ServersService', () => { const result = await service.create(dto); - expect(result).toEqual(mockServer); + expect(result).toEqual(withoutSecret(mockServer)); expect(mockDb.insert).toHaveBeenCalled(); }); @@ -139,7 +145,7 @@ describe('ServersService', () => { apiKey: 'test-key', }); - expect(result).toEqual(lidarrServer); + expect(result).toEqual(withoutSecret(lidarrServer)); }); it('should start file ingestion if enabled', async () => { @@ -194,7 +200,7 @@ describe('ServersService', () => { const result = await service.create(dto); // Should successfully create the second Radarr instance - expect(result).toEqual(newRadarr); + expect(result).toEqual(withoutSecret(newRadarr)); expect(mockDb.insert).toHaveBeenCalled(); }); @@ -1226,4 +1232,369 @@ describe('ServersService', () => { expect(result.message).toBe('Restart failed'); }); }); + + describe('qBittorrent credentials', () => { + const qbitServer = { + ...mockServer, + id: 'server-qbit', + providerId: 'qbittorrent', + url: 'http://localhost:8080', + apiKey: 'pw', + username: 'admin', + }; + const baseDto = { + name: 'qBittorrent', + providerId: 'qbittorrent', + url: 'http://localhost:8080', + }; + + const insertedValues = (): Record => { + const query = mockDb.insert.mock.results[0]!.value as { + values: ReturnType; + }; + return query.values.mock.calls[0]![0] as Record; + }; + + const updatedValues = (): Record => { + const query = mockDb.update.mock.results[0]!.value as { set: ReturnType }; + return query.set.mock.calls[0]![0] as Record; + }; + + it('does not store a username for providers without supportsUsername (create)', async () => { + configureMockDb(mockDb, { insert: [mockServer] }); + + await service.create({ + name: 'Sonarr', + providerId: 'sonarr', + url: 'http://localhost:8989', + apiKey: 'key', + username: 'admin', + }); + + expect(insertedValues()['username']).toBeNull(); + }); + + it('ignores a username update for providers without supportsUsername', async () => { + configureMockDb(mockDb, { + select: [{ ...mockServer, providerId: 'sonarr' }], + update: [mockServer], + }); + + await service.update('server-1', { username: 'admin', name: 'Renamed' }); + + expect(updatedValues()).not.toHaveProperty('username'); + expect(updatedValues()['name']).toBe('Renamed'); + }); + + it('trims whitespace and newlines from the API key on create', async () => { + configureMockDb(mockDb, { insert: [qbitServer] }); + + await service.create({ ...baseDto, apiKey: ' secret-key\n' }); + + expect(insertedValues()['apiKey']).toBe('secret-key'); + }); + + it('rejects a whitespace-only API key on create when the provider requires one', async () => { + await expect( + service.create({ + name: 'Sonarr', + providerId: 'sonarr', + url: 'http://localhost:8989', + apiKey: ' \n', + }) + ).rejects.toThrow(BadRequestException); + }); + + it('stores an empty key for whitespace-only input when the API key is optional', async () => { + configureMockDb(mockDb, { insert: [qbitServer] }); + + await service.create({ ...baseDto, apiKey: ' \n' }); + + expect(insertedValues()['apiKey']).toBe(''); + }); + + it('trims the API key on update', async () => { + configureMockDb(mockDb, { select: [qbitServer], update: [qbitServer] }); + + await service.update('server-qbit', { apiKey: 'new-secret\r\n' }); + + expect(updatedValues()['apiKey']).toBe('new-secret'); + }); + + it('keeps the stored API key when the update value is whitespace-only', async () => { + configureMockDb(mockDb, { select: [qbitServer], update: [qbitServer] }); + + await service.update('server-qbit', { apiKey: ' \n' }); + + expect(updatedValues()).not.toHaveProperty('apiKey'); + }); + + it('registers the qbittorrent provider without session support', () => { + const qbit = service.getAvailableProviders().find((p) => p.id === 'qbittorrent'); + expect(qbit?.capabilities.supportsUsername).toBe(true); + expect(qbit?.capabilities.apiKeyOptional).toBe(true); + + expect(qbit).toBeDefined(); + expect(qbit?.capabilities.supportsSessions).toBe(false); + }); + + it('stores a trimmed username on create', async () => { + configureMockDb(mockDb, { insert: [qbitServer] }); + + await service.create({ ...baseDto, apiKey: 'pw', username: ' admin ' }); + + expect(insertedValues()).toMatchObject({ username: 'admin', apiKey: 'pw' }); + }); + + it.each([undefined, '', ' '])('stores NULL for username %j on create', async (username) => { + configureMockDb(mockDb, { insert: [qbitServer] }); + + await service.create({ + ...baseDto, + apiKey: 'key', + ...(username !== undefined && { username }), + }); + + expect(insertedValues()['username']).toBeNull(); + }); + + it('stores NULL when the username is sent as null on create', async () => { + configureMockDb(mockDb, { insert: [qbitServer] }); + + await service.create({ ...baseDto, apiKey: 'key', username: null } as any); + + expect(insertedValues()['username']).toBeNull(); + }); + + it('allows an omitted apiKey (auth bypass) and stores an empty string', async () => { + configureMockDb(mockDb, { insert: [qbitServer] }); + + await service.create({ ...baseDto } as any); + + expect(insertedValues()['apiKey']).toBe(''); + }); + + it('stores NULL for null or non-string usernames on create', async () => { + configureMockDb(mockDb, { insert: [qbitServer] }); + await service.create({ ...baseDto, apiKey: 'k', username: null } as any); + expect(insertedValues()['username']).toBeNull(); + }); + + it('derives the optional apiKey rule from the provider capability, not the provider id', async () => { + configureMockDb(mockDb, { insert: [mockServer] }); + service['providers'].set('custom', { + id: 'custom', + name: 'Custom', + capabilities: { apiKeyOptional: true }, + } as any); + + await service.create({ name: 'c', providerId: 'custom', url: 'http://x' } as any); + + expect(insertedValues()['apiKey']).toBe(''); + }); + + it.each([undefined, ''])( + 'rejects a missing or empty apiKey (%j) for other providers', + async (apiKey) => { + configureMockDb(mockDb, { insert: [mockServer] }); + + await expect( + service.create({ + name: 'Sonarr', + providerId: 'sonarr', + url: 'http://localhost:8989', + ...(apiKey !== undefined && { apiKey }), + } as any) + ).rejects.toThrow(BadRequestException); + expect(mockDb.insert).not.toHaveBeenCalled(); + } + ); + + it('allows an empty apiKey on create', async () => { + configureMockDb(mockDb, { insert: [qbitServer] }); + + await service.create({ ...baseDto, apiKey: '' }); + + expect(insertedValues()['apiKey']).toBe(''); + }); + + describe('update', () => { + beforeEach(() => { + configureMockDb(mockDb, { select: [qbitServer], update: [qbitServer] }); + }); + + it('clears the username when an empty string is sent', async () => { + await service.update('server-qbit', { username: '' }); + + expect(updatedValues()['username']).toBeNull(); + }); + + it('trims and stores a new username', async () => { + await service.update('server-qbit', { username: ' bob ' }); + + expect(updatedValues()['username']).toBe('bob'); + }); + + it('does not touch the username when it is not part of the update', async () => { + await service.update('server-qbit', { name: 'Renamed' }); + + expect(updatedValues()).not.toHaveProperty('username'); + expect(updatedValues()['name']).toBe('Renamed'); + }); + + it('clears the username when only whitespace is sent', async () => { + await service.update('server-qbit', { username: ' ' }); + + expect(updatedValues()['username']).toBeNull(); + }); + + it('clears the username when null is sent explicitly', async () => { + await service.update('server-qbit', { username: null } as any); + + expect(updatedValues()['username']).toBeNull(); + }); + + it('ignores non-string usernames instead of crashing', async () => { + await service.update('server-qbit', { username: 42 } as any); + + expect(updatedValues()).not.toHaveProperty('username'); + }); + + it('keeps the stored secret when apiKey is empty or omitted', async () => { + await service.update('server-qbit', { apiKey: '' }); + + expect(updatedValues()).not.toHaveProperty('apiKey'); + }); + + it('clears the stored secret when apiKey is null (credentials optional)', async () => { + await service.update('server-qbit', { apiKey: null }); + + expect(updatedValues()['apiKey']).toBe(''); + }); + + it('rejects clearing the secret for providers that require one', async () => { + configureMockDb(mockDb, { + select: [{ ...mockServer, providerId: 'sonarr', apiKey: 'k' }], + update: [mockServer], + }); + + await expect(service.update('server-1', { apiKey: null })).rejects.toThrow( + BadRequestException + ); + expect(mockDb.update).not.toHaveBeenCalled(); + }); + + it('replaces the stored secret when a new apiKey is sent', async () => { + await service.update('server-qbit', { apiKey: 'new-secret' }); + + expect(updatedValues()['apiKey']).toBe('new-secret'); + }); + }); + + describe('testConnection', () => { + const mockProvider = { + id: 'qbittorrent', + name: 'qBittorrent', + connect: vi.fn().mockResolvedValue(undefined), + disconnect: vi.fn().mockResolvedValue(undefined), + testConnection: vi.fn().mockResolvedValue({ connected: true }), + }; + + beforeEach(() => { + mockProvider.connect.mockClear(); + service['providers'].set('qbittorrent', mockProvider as any); + }); + + it('passes the username to the provider', async () => { + configureMockDb(mockDb, { select: [qbitServer], update: [qbitServer] }); + + await service.testConnection('server-qbit'); + + expect(mockProvider.connect).toHaveBeenCalledWith({ + url: 'http://localhost:8080', + apiKey: 'pw', + username: 'admin', + logPath: '/var/log/jellyfin', + }); + }); + + it('passes undefined when the stored username is NULL', async () => { + configureMockDb(mockDb, { + select: [{ ...qbitServer, username: null }], + update: [qbitServer], + }); + + await service.testConnection('server-qbit'); + + expect(mockProvider.connect).toHaveBeenCalledWith( + expect.objectContaining({ username: undefined }) + ); + }); + }); + }); + + describe('secret is never returned', () => { + const secretServer = { ...mockServer, apiKey: 'super-secret-token' }; + + it('findAll / findOne strip apiKey and expose hasApiKey', async () => { + configureMockDb(mockDb, { select: [secretServer] }); + + const [listed] = await service.findAll(); + const single = await service.findOne('server-1'); + + for (const server of [listed, single]) { + expect(server).not.toHaveProperty('apiKey'); + expect(server?.hasApiKey).toBe(true); + expect(JSON.stringify(server)).not.toContain('super-secret-token'); + } + }); + + it('hasApiKey is false for an empty stored secret', async () => { + configureMockDb(mockDb, { select: [{ ...secretServer, apiKey: '' }] }); + + expect((await service.findOne('server-1')).hasApiKey).toBe(false); + }); + + it('create strips apiKey from the returned server', async () => { + configureMockDb(mockDb, { insert: [secretServer] }); + + const created = await service.create({ + name: 'n', + providerId: 'jellyfin', + url: 'http://x', + apiKey: 'super-secret-token', + }); + + expect(created).not.toHaveProperty('apiKey'); + expect(created?.hasApiKey).toBe(true); + }); + + it('update strips apiKey from the returned server', async () => { + configureMockDb(mockDb, { select: [secretServer], update: [secretServer] }); + + const updated = await service.update('server-1', { name: 'Renamed' }); + + expect(updated).not.toHaveProperty('apiKey'); + expect(JSON.stringify(updated)).not.toContain('super-secret-token'); + }); + + it('testConnection reads the secret from the database, not from the caller', async () => { + const connect = vi.fn().mockResolvedValue(undefined); + service['providers'].set('jellyfin', { + id: 'jellyfin', + name: 'Jellyfin', + connect, + disconnect: vi.fn().mockResolvedValue(undefined), + testConnection: vi.fn().mockResolvedValue({ connected: true }), + capabilities: {}, + } as any); + configureMockDb(mockDb, { select: [secretServer], update: [secretServer] }); + + await service.testConnection('server-1'); + + expect(connect).toHaveBeenCalledWith( + expect.objectContaining({ apiKey: 'super-secret-token' }) + ); + }); + }); }); diff --git a/apps/backend/src/modules/servers/servers.service.ts b/apps/backend/src/modules/servers/servers.service.ts index a3616cd..acdc730 100644 --- a/apps/backend/src/modules/servers/servers.service.ts +++ b/apps/backend/src/modules/servers/servers.service.ts @@ -3,10 +3,18 @@ import { JellyfinProvider } from '@logarr/provider-jellyfin'; import { LidarrProvider } from '@logarr/provider-lidarr'; import { PlexProvider } from '@logarr/provider-plex'; import { ProwlarrProvider } from '@logarr/provider-prowlarr'; +import { QBittorrentProvider } from '@logarr/provider-qbittorrent'; import { RadarrProvider } from '@logarr/provider-radarr'; import { SonarrProvider } from '@logarr/provider-sonarr'; import { WhisparrProvider } from '@logarr/provider-whisparr'; -import { Injectable, Inject, NotFoundException, forwardRef, Logger } from '@nestjs/common'; +import { + Injectable, + Inject, + NotFoundException, + BadRequestException, + forwardRef, + Logger, +} from '@nestjs/common'; import { eq } from 'drizzle-orm'; import { DATABASE_CONNECTION } from '../../database'; @@ -14,10 +22,24 @@ import * as schema from '../../database/schema'; import { FileIngestionService } from '../file-ingestion/file-ingestion.service'; import { IngestionService } from '../ingestion/ingestion.service'; +import { toServerResponse } from './server-response.mapper'; + +import type { ServerResponse, ServerRow } from './server-response.mapper'; import type { CreateServerDto, UpdateServerDto } from './servers.dto'; import type { MediaServerProvider } from '@logarr/core'; import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; +/** + * Normalise a username from an API client: strings are trimmed (empty -> null), an explicit + * null clears it, anything else (missing or a non-string value) is left untouched (undefined). + */ +function normalizeUsername(username: unknown): string | null | undefined { + if (typeof username === 'string') { + return username.trim() || null; + } + return username === null ? null : undefined; +} + @Injectable() export class ServersService { private readonly logger = new Logger(ServersService.name); @@ -55,13 +77,23 @@ export class ServersService { const lidarrProvider = new LidarrProvider(); this.providers.set(lidarrProvider.id, lidarrProvider); + const qbittorrentProvider = new QBittorrentProvider(); + this.providers.set(qbittorrentProvider.id, qbittorrentProvider); } - async findAll() { - return this.db.select().from(schema.servers).orderBy(schema.servers.createdAt); + /** All servers as returned by the API (without the stored secret) */ + async findAll(): Promise { + const rows = await this.db.select().from(schema.servers).orderBy(schema.servers.createdAt); + return rows.map(toServerResponse); } - async findOne(id: string) { + /** One server as returned by the API (without the stored secret) */ + async findOne(id: string): Promise { + return toServerResponse(await this.getServerRow(id)); + } + + /** Database row including the secret: for internal use only, never return it from the API */ + private async getServerRow(id: string): Promise { const result = await this.db .select() .from(schema.servers) @@ -82,13 +114,27 @@ export class ServersService { throw new Error(`Unknown provider: ${dto.providerId}`); } + // A pasted trailing newline/space would make the Authorization header invalid + const apiKey = dto.apiKey?.trim() ?? ''; + + if (provider.capabilities.apiKeyOptional !== true && apiKey === '') { + throw new BadRequestException('API key is required'); + } + const result = await this.db .insert(schema.servers) .values({ name: dto.name, providerId: dto.providerId, url: dto.url, - apiKey: dto.apiKey, + // apiKey may be empty only for providers with apiKeyOptional (e.g. qBittorrent auth bypass); + // the column is NOT NULL + apiKey, + // Only providers that authenticate with a username may store one + username: + provider.capabilities.supportsUsername === true + ? (normalizeUsername(dto.username) ?? null) + : null, logPath: dto.logPath, fileIngestionEnabled: dto.fileIngestionEnabled ?? false, logPaths: dto.logPaths, @@ -96,7 +142,8 @@ export class ServersService { }) .returning(); - const server = result[0]; + const created = result[0]; + const server = created ? toServerResponse(created) : undefined; // If file ingestion is enabled, start it immediately if (server && dto.fileIngestionEnabled && dto.logPaths?.length) { @@ -110,12 +157,30 @@ export class ServersService { async update(id: string, dto: UpdateServerDto) { // Get the current server state to detect changes - const currentServer = await this.findOne(id); + const currentServer = await this.getServerRow(id); + + // An empty username clears it (stored as NULL) + const { username, apiKey: rawApiKey, ...rest } = dto; + const apiKey = typeof rawApiKey === 'string' ? rawApiKey.trim() : rawApiKey; + const provider = this.providers.get(currentServer.providerId); + + // An explicit null clears the stored secret, but only where credentials are optional + if (apiKey === null && provider?.capabilities.apiKeyOptional !== true) { + throw new BadRequestException('API key is required and cannot be cleared'); + } + // Ignored for providers that do not authenticate with a username + const normalizedUsername = + provider?.capabilities.supportsUsername === true ? normalizeUsername(username) : undefined; const result = await this.db .update(schema.servers) .set({ - ...dto, + ...rest, + // An omitted or empty secret keeps the stored value (the API never returns it); + // null clears it + ...(typeof apiKey === 'string' && apiKey !== '' && { apiKey }), + ...(apiKey === null && { apiKey: '' }), + ...(normalizedUsername !== undefined && { username: normalizedUsername }), updatedAt: new Date(), }) .where(eq(schema.servers.id, id)) @@ -208,7 +273,7 @@ export class ServersService { } async testConnection(id: string) { - const server = await this.findOne(id); + const server = await this.getServerRow(id); const provider = this.providers.get(server.providerId); if (!provider) { @@ -228,6 +293,7 @@ export class ServersService { await provider.connect({ url: server.url, apiKey: server.apiKey, + username: server.username ?? undefined, logPath: server.logPath ?? undefined, }); @@ -356,7 +422,7 @@ export class ServersService { * Start file ingestion for a specific server */ private async startFileIngestionForServer(serverId: string) { - const server = await this.findOne(serverId); + const server = await this.getServerRow(serverId); if (!server.fileIngestionEnabled || !server.logPaths?.length) { return; @@ -408,7 +474,7 @@ export class ServersService { * This clears all file states and restarts file ingestion */ async resetFileIngestionState(id: string) { - const server = await this.findOne(id); + const server = await this.getServerRow(id); if (!server.fileIngestionEnabled) { return { diff --git a/apps/frontend/src/components/add-source-modal.tsx b/apps/frontend/src/components/add-source-modal.tsx index 4904a63..63ad299 100644 --- a/apps/frontend/src/components/add-source-modal.tsx +++ b/apps/frontend/src/components/add-source-modal.tsx @@ -40,6 +40,7 @@ import { } from '@/components/ui/select'; import { useCreateServer } from '@/hooks/use-api'; import { type Integration, type ConfigField, getIntegrationById } from '@/lib/integrations'; +import { buildCreateServerPayload } from '@/lib/server-payload'; import { cn } from '@/lib/utils'; interface AddSourceModalProps { @@ -211,13 +212,7 @@ function ConfigurationStep({ async function onSubmit(data: Record) { try { - await createServer.mutateAsync({ - name: data.name as string, - providerId: integration.id, - url: data.url as string, - apiKey: (data.apiKey || data.token || data.password || '') as string, - logPath: data.logPath as string | undefined, - }); + await createServer.mutateAsync(buildCreateServerPayload(integration.id, data)); onSuccess(); } catch (error) { toast.error(error instanceof Error ? error.message : 'Failed to add source'); diff --git a/apps/frontend/src/components/edit-server-dialog.test.tsx b/apps/frontend/src/components/edit-server-dialog.test.tsx new file mode 100644 index 0000000..43f61c8 --- /dev/null +++ b/apps/frontend/src/components/edit-server-dialog.test.tsx @@ -0,0 +1,294 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { EditServerDialog } from './edit-server-dialog'; +import type { Server } from '@/lib/api'; + +const mutateAsync = vi.fn(); + +vi.mock('@/hooks/use-api', () => ({ + useUpdateServer: () => ({ mutateAsync, isPending: false }), + useTestConnection: () => ({ mutateAsync: vi.fn(), isPending: false }), +})); + +vi.mock('sonner', () => ({ + toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }, +})); + +function makeServer(overrides: Partial): Server { + return { + id: 'server-1', + name: 'My Server', + providerId: 'sonarr', + url: 'http://localhost:8989', + hasApiKey: true, + username: null, + logPath: null, + isConnected: true, + lastSeen: null, + lastError: null, + version: null, + serverName: null, + fileIngestionEnabled: false, + fileIngestionConnected: false, + fileIngestionError: null, + logPaths: null, + logFilePatterns: null, + lastFileSync: null, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + ...overrides, + }; +} + +function renderDialog(server: Server) { + return render(); +} + +/** The "API key" / "Password / API key" input is the only password-type field */ +function secretInput(): HTMLInputElement { + const input = document.querySelector('input[type="password"]'); + if (!input) throw new Error('secret input not found'); + return input; +} + +describe('EditServerDialog credential validation', () => { + beforeEach(() => { + mutateAsync.mockReset(); + mutateAsync.mockResolvedValue({}); + }); + + describe('qbittorrent', () => { + const qbit = makeServer({ + providerId: 'qbittorrent', + url: 'http://localhost:8080', + hasApiKey: true, + username: 'admin', + }); + + it('renders a username field prefilled from the server', () => { + renderDialog(qbit); + + expect(screen.getByPlaceholderText('admin')).toHaveValue('admin'); + expect(screen.getByText('Password / API key')).toBeInTheDocument(); + }); + + it('never prefills the stored secret and shows that one is stored', () => { + renderDialog(qbit); + + expect(secretInput()).toHaveValue(''); + expect(secretInput().placeholder).toBe('Stored - leave empty to keep'); + }); + + it('keeps the stored password when the field is left empty (apiKey is not sent)', async () => { + const user = userEvent.setup(); + renderDialog(qbit); + + await user.click(screen.getByRole('button', { name: /save changes/i })); + + await waitFor(() => expect(mutateAsync).toHaveBeenCalledTimes(1)); + expect(screen.queryByText('API key is required')).not.toBeInTheDocument(); + const payload = mutateAsync.mock.calls[0]![0]; + expect(payload).toMatchObject({ id: 'server-1', data: { username: 'admin' } }); + expect(payload.data).not.toHaveProperty('apiKey'); + }); + + it('sends a newly typed password', async () => { + const user = userEvent.setup(); + renderDialog(qbit); + + await user.type(secretInput(), 'new-password'); + await user.click(screen.getByRole('button', { name: /save changes/i })); + + await waitFor(() => expect(mutateAsync).toHaveBeenCalledTimes(1)); + expect(mutateAsync.mock.calls[0]![0].data.apiKey).toBe('new-password'); + }); + + it('offers to clear the stored password and sends apiKey: null when chosen', async () => { + const user = userEvent.setup(); + renderDialog(qbit); + + await user.click(screen.getByRole('button', { name: /clear stored password/i })); + expect(screen.getByText(/will be removed when you save/i)).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: /save changes/i })); + + await waitFor(() => expect(mutateAsync).toHaveBeenCalledTimes(1)); + expect(mutateAsync.mock.calls[0]![0].data.apiKey).toBeNull(); + }); + + it('lets the user undo the clear action', async () => { + const user = userEvent.setup(); + renderDialog(qbit); + + await user.click(screen.getByRole('button', { name: /clear stored password/i })); + await user.click(screen.getByRole('button', { name: /keep stored password/i })); + await user.click(screen.getByRole('button', { name: /save changes/i })); + + await waitFor(() => expect(mutateAsync).toHaveBeenCalledTimes(1)); + expect(mutateAsync.mock.calls[0]![0].data).not.toHaveProperty('apiKey'); + }); + + it('does not offer clearing when no secret is stored', () => { + renderDialog(makeServer({ providerId: 'qbittorrent', hasApiKey: false, username: null })); + + expect(screen.queryByRole('button', { name: /clear stored password/i })).toBeNull(); + }); + + it('requires a choice when the username is removed while a secret is stored', async () => { + const user = userEvent.setup(); + renderDialog(qbit); + + await user.clear(screen.getByPlaceholderText('admin')); + expect(screen.getByText(/would be used as an API key/i)).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: /save changes/i })); + + expect(mutateAsync).not.toHaveBeenCalled(); + }); + + it('keeps the secret as an API key when that choice is made', async () => { + const user = userEvent.setup(); + renderDialog(qbit); + + await user.clear(screen.getByPlaceholderText('admin')); + await user.click(screen.getByRole('button', { name: /keep it as an api key/i })); + await user.click(screen.getByRole('button', { name: /save changes/i })); + + await waitFor(() => expect(mutateAsync).toHaveBeenCalledTimes(1)); + const data = mutateAsync.mock.calls[0]![0].data; + expect(data.username).toBe(''); + expect(data).not.toHaveProperty('apiKey'); + }); + + it('clears the secret when that choice is made', async () => { + const user = userEvent.setup(); + renderDialog(qbit); + + await user.clear(screen.getByPlaceholderText('admin')); + await user.click(screen.getByRole('button', { name: /clear it/i })); + await user.click(screen.getByRole('button', { name: /save changes/i })); + + await waitFor(() => expect(mutateAsync).toHaveBeenCalledTimes(1)); + expect(mutateAsync.mock.calls[0]![0].data).toMatchObject({ username: '', apiKey: null }); + }); + + it('does not ask when a new password is typed along with removing the username', async () => { + const user = userEvent.setup(); + renderDialog(qbit); + + await user.clear(screen.getByPlaceholderText('admin')); + await user.type(secretInput(), 'api-key-123'); + + expect(screen.queryByText(/would be used as an API key/i)).toBeNull(); + }); + + it('sends an empty username to clear it when the field is emptied', async () => { + const user = userEvent.setup(); + renderDialog(qbit); + + await user.clear(screen.getByPlaceholderText('admin')); + await user.click(screen.getByRole('button', { name: /keep it as an api key/i })); + await user.click(screen.getByRole('button', { name: /save changes/i })); + + await waitFor(() => expect(mutateAsync).toHaveBeenCalledTimes(1)); + expect(mutateAsync.mock.calls[0]![0].data.username).toBe(''); + }); + + it('trims the username before sending it', async () => { + const user = userEvent.setup(); + renderDialog(qbit); + + await user.clear(screen.getByPlaceholderText('admin')); + await user.type(screen.getByPlaceholderText('admin'), ' bob '); + await user.click(screen.getByRole('button', { name: /save changes/i })); + + await waitFor(() => expect(mutateAsync).toHaveBeenCalledTimes(1)); + expect(mutateAsync.mock.calls[0]![0].data.username).toBe('bob'); + }); + + it('still validates the URL', async () => { + const user = userEvent.setup(); + renderDialog(qbit); + + const url = screen.getByDisplayValue('http://localhost:8080'); + await user.clear(url); + await user.type(url, 'not a url'); + await user.click(screen.getByRole('button', { name: /save changes/i })); + + expect(await screen.findByText('Must be a valid URL')).toBeInTheDocument(); + expect(mutateAsync).not.toHaveBeenCalled(); + }); + + it('prefills an empty username as an empty string for auth-bypass servers', () => { + renderDialog(makeServer({ providerId: 'qbittorrent', hasApiKey: false, username: null })); + + expect(screen.getByPlaceholderText('admin')).toHaveValue(''); + expect(secretInput()).toHaveValue(''); + }); + }); + + describe.each(['sonarr', 'radarr', 'prowlarr', 'whisparr', 'jellyfin', 'emby', 'plex'])( + '%s', + (providerId) => { + const server = makeServer({ providerId, hasApiKey: false }); + + it('has no username field', () => { + renderDialog(server); + + expect(screen.queryByPlaceholderText('admin')).not.toBeInTheDocument(); + }); + + it('rejects an empty API key', async () => { + const user = userEvent.setup(); + renderDialog(server); + + await user.clear(secretInput()); + await user.click(screen.getByRole('button', { name: /save changes/i })); + + expect(await screen.findByText('API key is required')).toBeInTheDocument(); + expect(mutateAsync).not.toHaveBeenCalled(); + }); + + it('rejects a whitespace-only API key', async () => { + const user = userEvent.setup(); + renderDialog(server); + + await user.clear(secretInput()); + await user.type(secretInput(), ' '); + await user.click(screen.getByRole('button', { name: /save changes/i })); + + expect(await screen.findByText('API key is required')).toBeInTheDocument(); + expect(mutateAsync).not.toHaveBeenCalled(); + }); + } + ); + + it('does not offer clearing the stored key for providers that require one', () => { + renderDialog(makeServer({ providerId: 'sonarr' })); + + expect(screen.queryByRole('button', { name: /clear stored/i })).toBeNull(); + }); + + it('keeps the stored sonarr key when the field is empty and sends no username', async () => { + const user = userEvent.setup(); + renderDialog(makeServer({ providerId: 'sonarr' })); + + expect(secretInput()).toHaveValue(''); + await user.click(screen.getByRole('button', { name: /save changes/i })); + + await waitFor(() => expect(mutateAsync).toHaveBeenCalledTimes(1)); + const data = mutateAsync.mock.calls[0]![0].data; + expect(data).not.toHaveProperty('apiKey'); + expect(data).not.toHaveProperty('username'); + }); + + it('sends a replacement sonarr key when one is typed', async () => { + const user = userEvent.setup(); + renderDialog(makeServer({ providerId: 'sonarr' })); + + await user.type(secretInput(), 'new-key'); + await user.click(screen.getByRole('button', { name: /save changes/i })); + + await waitFor(() => expect(mutateAsync).toHaveBeenCalledTimes(1)); + expect(mutateAsync.mock.calls[0]![0].data.apiKey).toBe('new-key'); + }); +}); diff --git a/apps/frontend/src/components/edit-server-dialog.tsx b/apps/frontend/src/components/edit-server-dialog.tsx index c2922ba..3bab942 100644 --- a/apps/frontend/src/components/edit-server-dialog.tsx +++ b/apps/frontend/src/components/edit-server-dialog.tsx @@ -14,7 +14,7 @@ import { FolderOpen, FileSearch, } from 'lucide-react'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useForm } from 'react-hook-form'; import { toast } from 'sonner'; import { z } from 'zod/v3'; @@ -50,19 +50,34 @@ import { Input } from '@/components/ui/input'; import { Switch } from '@/components/ui/switch'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { useUpdateServer, useTestConnection } from '@/hooks/use-api'; +import { + areCredentialsOptional, + buildUpdateCredentials, + supportsUsername, +} from '@/lib/server-payload'; import { cn } from '@/lib/utils'; -const formSchema = z.object({ +// The stored secret is never sent to the browser: an empty field keeps the stored value, so it is +// only required when nothing is stored yet (providers with optional credentials never need one). +const buildFormSchema = (providerId: string, hasStoredSecret: boolean) => + baseFormSchema.superRefine((data, ctx) => { + if (!areCredentialsOptional(providerId) && !hasStoredSecret && data.apiKey.trim() === '') { + ctx.addIssue({ code: 'custom', path: ['apiKey'], message: 'API key is required' }); + } + }); + +const baseFormSchema = z.object({ name: z.string().min(1, 'Name is required').max(100), url: z.string().url('Must be a valid URL'), - apiKey: z.string().min(1, 'API key is required'), + apiKey: z.string(), + username: z.string().optional(), logPath: z.string().optional(), fileIngestionEnabled: z.boolean(), logPaths: z.string().optional(), logFilePatterns: z.string().optional(), }); -type FormData = z.infer; +type FormData = z.infer; interface EditServerDialogProps { server: ServerType; @@ -75,12 +90,24 @@ export function EditServerDialog({ server, open, onOpenChange }: EditServerDialo const testConnection = useTestConnection(); const [isTesting, setIsTesting] = useState(false); + const formSchema = useMemo( + () => buildFormSchema(server.providerId, server.hasApiKey), + [server.providerId, server.hasApiKey] + ); + const hasUsername = supportsUsername(server.providerId); + // Deliberate removal of the stored secret (only offered where credentials are optional) + const canClearSecret = server.hasApiKey && areCredentialsOptional(server.providerId); + const [clearSecret, setClearSecret] = useState(false); + // What to do with the stored secret when the username is removed: no silent mode change + const [usernameChoice, setUsernameChoice] = useState<'keep' | 'clear' | null>(null); + const form = useForm({ resolver: zodResolver(formSchema), defaultValues: { name: server.name, url: server.url, - apiKey: server.apiKey, + apiKey: '', + username: server.username ?? '', logPath: server.logPath || '', fileIngestionEnabled: server.fileIngestionEnabled || false, logPaths: server.logPaths?.join(', ') || '', @@ -89,13 +116,25 @@ export function EditServerDialog({ server, open, onOpenChange }: EditServerDialo }); const fileIngestionEnabled = form.watch('fileIngestionEnabled'); + const watchedUsername = form.watch('username') ?? ''; + const watchedSecret = form.watch('apiKey'); + const usernameRemoved = + hasUsername && + (server.username ?? '') !== '' && + watchedUsername.trim() === '' && + server.hasApiKey && + watchedSecret.trim() === '' && + !clearSecret; useEffect(() => { if (open) { + setClearSecret(false); + setUsernameChoice(null); form.reset({ name: server.name, url: server.url, - apiKey: server.apiKey, + apiKey: '', + username: server.username ?? '', logPath: server.logPath || '', fileIngestionEnabled: server.fileIngestionEnabled || false, logPaths: server.logPaths?.join(', ') || '', @@ -132,6 +171,10 @@ export function EditServerDialog({ server, open, onOpenChange }: EditServerDialo } async function onSubmit(data: FormData) { + if (usernameRemoved && usernameChoice === null) { + toast.error('Choose what happens to the stored password / API key before saving'); + return; + } try { const logPaths = data.logPaths ? data.logPaths @@ -156,7 +199,11 @@ export function EditServerDialog({ server, open, onOpenChange }: EditServerDialo data: { name: data.name, url: data.url, - apiKey: data.apiKey, + ...buildUpdateCredentials( + server.providerId, + data, + clearSecret || (usernameRemoved && usernameChoice === 'clear') + ), logPath: data.logPath || undefined, fileIngestionEnabled: data.fileIngestionEnabled, logPaths: logPaths?.length ? logPaths : undefined, @@ -288,24 +335,133 @@ export function EditServerDialog({ server, open, onOpenChange }: EditServerDialo /> - {/* API Key full width */} - ( - - - - API Key - - - - - Found in Dashboard → API Keys - - - )} - /> + {hasUsername ? ( +
+ ( + + + + Username + + + + + Leave empty for API-key or no-auth setups + + + )} + /> + ( + + + + Password / API key + + + + + + With a username: password. Without: API key. Leave empty to keep the + stored value + + + + )} + /> +
+ ) : ( + ( + + + + API Key + + + + + + Found in Dashboard → API Keys. Leave empty to keep the stored key. + + + + )} + /> + )} + + {canClearSecret && ( +
+ + {clearSecret && ( + + The stored value will be removed when you save. + + )} +
+ )} + + {usernameRemoved && ( + + + +

+ Without a username, the stored password would be used as an API key. Choose + what to do with it: +

+
+ + +
+
+
+ )} {/* File Ingestion Section */} diff --git a/apps/frontend/src/components/provider-icon.test.tsx b/apps/frontend/src/components/provider-icon.test.tsx index 8089195..6155b69 100644 --- a/apps/frontend/src/components/provider-icon.test.tsx +++ b/apps/frontend/src/components/provider-icon.test.tsx @@ -42,6 +42,23 @@ describe('ProviderIcon', () => { }); }); + describe('qBittorrent provider', () => { + it('should render the dashboard-icons image instead of the default icon', () => { + const { container } = render(); + const img = container.querySelector('img'); + expect(img).not.toBeNull(); + expect(img?.getAttribute('src')).toBe( + 'https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/qbittorrent.svg' + ); + expect(img?.classList.contains('h-8')).toBe(true); + expect(container.querySelector('svg')).toBeNull(); + }); + + it('should have provider metadata', () => { + expect(providerMeta.qbittorrent?.name).toBe('qBittorrent'); + }); + }); + describe('Default icon fallback', () => { it('should render default icon for unknown provider', () => { const { container } = render(); diff --git a/apps/frontend/src/components/provider-icon.tsx b/apps/frontend/src/components/provider-icon.tsx index aec0f90..d4bd768 100644 --- a/apps/frontend/src/components/provider-icon.tsx +++ b/apps/frontend/src/components/provider-icon.tsx @@ -557,6 +557,17 @@ function PlexIcon({ className }: { className?: string }) { ); } +// qBittorrent logo - Uses external CDN icon (dashboard-icons) +function QBittorrentIcon({ className }: { className?: string }) { + return ( + qBittorrent + ); +} + // Emby logo - Uses external CDN icon function EmbyIcon({ className }: { className?: string }) { return ( @@ -618,6 +629,7 @@ const providerIcons: Record> whisparr: WhisparrIcon, lidarr: LidarrIcon, readarr: ReadarrIcon, + qbittorrent: QBittorrentIcon, sqlite: SQLiteIcon, openai: OpenAIIcon, anthropic: AnthropicIcon, @@ -645,6 +657,7 @@ export const providerMeta: Record { name: 'Plex Server', url: 'http://192.168.1.100:32400', providerId: 'plex', - apiKey: 'test-api-key', + hasApiKey: true, + username: null, logPath: null, isConnected: true, lastSeen: '2024-01-01T00:00:00Z', diff --git a/apps/frontend/src/lib/api.ts b/apps/frontend/src/lib/api.ts index ad7e0f9..def90f0 100644 --- a/apps/frontend/src/lib/api.ts +++ b/apps/frontend/src/lib/api.ts @@ -8,7 +8,9 @@ export interface Server { name: string; providerId: string; url: string; - apiKey: string; + /** Whether a secret (API key / token / password) is stored. The value is never returned. */ + hasApiKey: boolean; + username: string | null; logPath: string | null; isConnected: boolean; lastSeen: string | null; @@ -31,6 +33,7 @@ export interface CreateServerDto { providerId: string; url: string; apiKey: string; + username?: string; logPath?: string; fileIngestionEnabled?: boolean; logPaths?: string[]; @@ -40,7 +43,9 @@ export interface CreateServerDto { export interface UpdateServerDto { name?: string; url?: string; - apiKey?: string; + /** Omit or send '' to keep the stored secret; null clears it (providers with optional credentials) */ + apiKey?: string | null; + username?: string; logPath?: string; fileIngestionEnabled?: boolean; logPaths?: string[]; diff --git a/apps/frontend/src/lib/integrations.test.ts b/apps/frontend/src/lib/integrations.test.ts index 8660d7e..d5404e7 100644 --- a/apps/frontend/src/lib/integrations.test.ts +++ b/apps/frontend/src/lib/integrations.test.ts @@ -10,6 +10,42 @@ import { } from './integrations'; describe('Integrations Registry', () => { + describe('qBittorrent integration', () => { + it('should be available in the download clients category', () => { + const qbit = getIntegrationById('qbittorrent'); + expect(qbit?.name).toBe('qBittorrent'); + expect(qbit?.status).toBe('available'); + expect(qbit?.category).toBe('download_clients'); + expect(qbit?.connectionType).toBe('api_basic'); + expect(getAvailableIntegrations().some((i) => i.id === 'qbittorrent')).toBe(true); + }); + + it('should have username and password optional and name/url required', () => { + const fields = getIntegrationById('qbittorrent')?.configFields ?? []; + expect(fields.map((f) => f.name)).toEqual(['name', 'url', 'username', 'password', 'logPath']); + const byName = Object.fromEntries(fields.map((f) => [f.name, f])); + expect(byName['name']?.required).toBe(true); + expect(byName['url']?.required).toBe(true); + expect(byName['username']?.required).toBe(false); + expect(byName['password']?.required).toBe(false); + expect(byName['password']?.type).toBe('password'); + }); + + it('should declare credential capabilities and no sessions', () => { + const caps = getIntegrationById('qbittorrent')?.capabilities; + expect(caps?.sessions).toBe(false); + // log-file tailing, like the *arr entries + expect(caps?.realTimeLogs).toBe(true); + expect(caps?.supportsUsername).toBe(true); + expect(caps?.apiKeyOptional).toBe(true); + }); + + it('should be found by search', () => { + expect(searchIntegrations('qbittorrent').some((i) => i.id === 'qbittorrent')).toBe(true); + expect(searchIntegrations('torrent').some((i) => i.id === 'qbittorrent')).toBe(true); + }); + }); + describe('Whisparr integration', () => { it('should be included in integrations array', () => { const whisparr = integrations.find((i) => i.id === 'whisparr'); diff --git a/apps/frontend/src/lib/integrations.ts b/apps/frontend/src/lib/integrations.ts index f4d69d5..757d498 100644 --- a/apps/frontend/src/lib/integrations.ts +++ b/apps/frontend/src/lib/integrations.ts @@ -69,6 +69,10 @@ export interface Integration { sessions: boolean; webhooks: boolean; metrics: boolean; + /** Authenticates with a username plus password (stored as username + apiKey) */ + supportsUsername?: boolean; + /** The API key / password may be empty (e.g. authentication bypass) */ + apiKeyOptional?: boolean; }; tags: string[]; // For search } @@ -472,9 +476,10 @@ export const integrations: Integration[] = [ { id: 'qbittorrent', name: 'qBittorrent', - description: 'Free and reliable P2P BitTorrent client', + description: + 'Free and reliable P2P BitTorrent client (WebUI API v2). Supports username/password, authentication bypass and API keys (5.2+)', category: 'download_clients', - status: 'coming_soon', + status: 'available', connectionType: 'api_basic', icon: 'qbittorrent', iconType: 'dashboard-icons', @@ -490,22 +495,32 @@ export const integrations: Integration[] = [ label: 'Username', type: 'text', placeholder: 'admin', - required: true, + description: 'WebUI username. Leave empty for API-key or no-auth (bypass) setups', + required: false, }, { name: 'password', - label: 'Password', + label: 'Password / API key', type: 'password', - placeholder: 'Enter password', - required: true, + placeholder: 'WebUI password, or API key (qBittorrent 5.2+)', + description: + "With a username: the WebUI password. Without a username: an API key. Leave both empty if authentication is bypassed for Logarr's IP", + required: false, + }, + { + ...standardLogPathField, + placeholder: '/config/qBittorrent/logs', }, ], + docsUrl: 'https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-5.0)', capabilities: { realTimeLogs: true, activityLog: true, sessions: false, webhooks: false, - metrics: true, + metrics: false, + supportsUsername: true, + apiKeyOptional: true, }, tags: ['torrent', 'download', 'p2p', 'bittorrent', 'open-source'], }, diff --git a/apps/frontend/src/lib/server-payload.test.ts b/apps/frontend/src/lib/server-payload.test.ts new file mode 100644 index 0000000..b6e7653 --- /dev/null +++ b/apps/frontend/src/lib/server-payload.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect } from 'vitest'; + +import { + areCredentialsOptional, + supportsUsername, + buildCreateServerPayload, + buildUpdateCredentials, +} from './server-payload'; + +describe('buildCreateServerPayload', () => { + it('maps the password field to apiKey and trims the username', () => { + expect( + buildCreateServerPayload('qbittorrent', { + name: 'qBit', + url: 'http://qbit:8080', + username: ' admin ', + password: 'secret', + logPath: '/qbittorrent-logs', + }) + ).toEqual({ + name: 'qBit', + providerId: 'qbittorrent', + url: 'http://qbit:8080', + apiKey: 'secret', + username: 'admin', + logPath: '/qbittorrent-logs', + }); + }); + + it('omits a blank username and allows an empty apiKey', () => { + const payload = buildCreateServerPayload('qbittorrent', { + name: 'qBit', + url: 'http://qbit:8080', + username: ' ', + password: '', + }); + expect(payload.apiKey).toBe(''); + expect(payload).not.toHaveProperty('username'); + }); + + it('keeps apiKey / token / password precedence for other providers', () => { + expect(buildCreateServerPayload('sonarr', { name: 'n', url: 'u', apiKey: 'k' }).apiKey).toBe( + 'k' + ); + expect(buildCreateServerPayload('x', { name: 'n', url: 'u', token: 't' }).apiKey).toBe('t'); + expect( + buildCreateServerPayload('sonarr', { name: 'n', url: 'u', apiKey: 'k' }) + ).not.toHaveProperty('username'); + }); +}); + +describe('buildUpdateCredentials', () => { + it('sends the trimmed username for qbittorrent, empty string to clear', () => { + expect(buildUpdateCredentials('qbittorrent', { apiKey: 'pw', username: ' bob ' })).toEqual({ + apiKey: 'pw', + username: 'bob', + }); + // an empty secret means "keep the stored value" and is not sent + expect(buildUpdateCredentials('qbittorrent', { apiKey: '', username: undefined })).toEqual({ + username: '', + }); + expect(buildUpdateCredentials('sonarr', { apiKey: ' ' })).toEqual({}); + }); + + it('sends apiKey: null to clear the stored secret and ignores a typed value', () => { + expect(buildUpdateCredentials('qbittorrent', { apiKey: 'typed' }, true)).toEqual({ + apiKey: null, + username: '', + }); + }); + + it('never sends a username for other providers', () => { + expect(buildUpdateCredentials('sonarr', { apiKey: 'k', username: 'x' })).toEqual({ + apiKey: 'k', + }); + }); +}); + +describe('credential capability flags', () => { + it('are read from the integration entry', () => { + expect(areCredentialsOptional('qbittorrent')).toBe(true); + expect(supportsUsername('qbittorrent')).toBe(true); + expect(areCredentialsOptional('sonarr')).toBe(false); + expect(supportsUsername('sonarr')).toBe(false); + expect(areCredentialsOptional('unknown-provider')).toBe(false); + expect(supportsUsername('unknown-provider')).toBe(false); + }); +}); diff --git a/apps/frontend/src/lib/server-payload.ts b/apps/frontend/src/lib/server-payload.ts new file mode 100644 index 0000000..595c0bc --- /dev/null +++ b/apps/frontend/src/lib/server-payload.ts @@ -0,0 +1,51 @@ +import { getIntegrationById } from '@/lib/integrations'; + +import type { CreateServerDto } from '@/lib/api'; + +/** The API key / password may be left empty (capability flag from the integration entry) */ +export function areCredentialsOptional(providerId: string): boolean { + return getIntegrationById(providerId)?.capabilities.apiKeyOptional === true; +} + +/** The provider authenticates with a username plus password (capability flag) */ +export function supportsUsername(providerId: string): boolean { + return getIntegrationById(providerId)?.capabilities.supportsUsername === true; +} + +/** + * Build the create-server payload from the dynamic "add source" form values. + * The password field is stored in apiKey (it may be empty for providers with the apiKeyOptional capability). + */ +export function buildCreateServerPayload( + providerId: string, + data: Record +): CreateServerDto { + const username = typeof data.username === 'string' ? data.username.trim() : ''; + + return { + name: data.name as string, + providerId, + url: data.url as string, + apiKey: (data.apiKey || data.token || data.password || '') as string, + ...(username !== '' && { username }), + logPath: data.logPath as string | undefined, + }; +} + +/** + * Credential fields for the update payload. The stored secret is never sent back to the client, + * so an empty apiKey field means "keep the stored value" and is omitted. For providers using + * usernames the username is always sent ('' clears it on the backend). `clearApiKey` sends + * apiKey: null to deliberately remove the stored secret. + */ +export function buildUpdateCredentials( + providerId: string, + data: { apiKey: string; username?: string | undefined }, + clearApiKey = false +): { apiKey?: string | null; username?: string } { + return { + // null explicitly clears the stored secret + ...(clearApiKey ? { apiKey: null } : data.apiKey.trim() !== '' && { apiKey: data.apiKey }), + ...(supportsUsername(providerId) && { username: data.username?.trim() ?? '' }), + }; +} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 22b1c9b..80b6d26 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-qbittorrent/src:/app/packages/provider-qbittorrent/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 + - ${QBITTORRENT_LOGS_PATH:-./logs/qbittorrent}:/qbittorrent-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 + - ${QBITTORRENT_LOGS_PATH_1:-./logs/qbittorrent-1}:/qbittorrent-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 + - ${QBITTORRENT_LOGS_PATH_2:-./logs/qbittorrent-2}:/qbittorrent-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..d97b6ad 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -77,6 +77,10 @@ services: WHISPARR_API_KEY: ${WHISPARR_API_KEY:-} LIDARR_URL: ${LIDARR_URL:-} LIDARR_API_KEY: ${LIDARR_API_KEY:-} + # qBittorrent: username + password, or only a password/API key (5.2+), or neither (auth bypass) + QBITTORRENT_URL: ${QBITTORRENT_URL:-} + QBITTORRENT_USERNAME: ${QBITTORRENT_USERNAME:-} + QBITTORRENT_PASSWORD: ${QBITTORRENT_PASSWORD:-} # 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 +91,7 @@ services: PROWLARR_LOGS_PATH: ${PROWLARR_LOGS_PATH:-} WHISPARR_LOGS_PATH: ${WHISPARR_LOGS_PATH:-} LIDARR_LOGS_PATH: ${LIDARR_LOGS_PATH:-} + QBITTORRENT_LOGS_PATH: ${QBITTORRENT_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 +118,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:-} + QBITTORRENT_LOGS_PATH_1: ${QBITTORRENT_LOGS_PATH_1:-} + QBITTORRENT_LOGS_PATH_2: ${QBITTORRENT_LOGS_PATH_2:-} + QBITTORRENT_LOGS_PATH_3: ${QBITTORRENT_LOGS_PATH_3:-} # Admin password reset - set to 'true' to reset admin account on startup ADMIN_PASSWORD_RESET: ${ADMIN_PASSWORD_RESET:-} volumes: @@ -126,6 +134,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 + - ${QBITTORRENT_LOGS_PATH:-/dev/null}:/qbittorrent-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 +160,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 + - ${QBITTORRENT_LOGS_PATH_1:-/dev/null}:/qbittorrent-logs-1:ro + - ${QBITTORRENT_LOGS_PATH_2:-/dev/null}:/qbittorrent-logs-2:ro + - ${QBITTORRENT_LOGS_PATH_3:-/dev/null}:/qbittorrent-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..9589609 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -26,6 +26,10 @@ export interface ProviderCapabilities { readonly supportsSessions: boolean; readonly supportsWebhooks: boolean; readonly supportsPlaybackHistory: boolean; + /** Authenticates with a username (stored in the server's username field) plus a password */ + readonly supportsUsername?: boolean; + /** The API key / password may be empty (e.g. WebUI authentication bypass) */ + readonly apiKeyOptional?: boolean; } /** @@ -33,7 +37,10 @@ export interface ProviderCapabilities { */ export interface ProviderConfig { readonly url: string; + /** API key, token, or password (may be empty for providers without authentication) */ readonly apiKey: string; + /** Optional login username (providers using username/password authentication) */ + readonly username?: string | undefined; readonly logPath?: string | undefined; } @@ -239,6 +246,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; } // ============================================================================= @@ -386,6 +400,7 @@ export interface Server { readonly providerId: string; readonly url: string; readonly apiKey: string; + readonly username: string | null; readonly logPath: string | null; readonly isEnabled: boolean; readonly isConnected: boolean; @@ -411,6 +426,7 @@ export interface CreateServerInput { readonly providerId: string; readonly url: string; readonly apiKey: string; + readonly username?: string; readonly logPath?: string; } @@ -421,6 +437,7 @@ export interface UpdateServerInput { readonly name?: string; readonly url?: string; readonly apiKey?: string; + readonly username?: string; readonly logPath?: string; readonly isEnabled?: boolean; // File-based log ingestion fields 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-qbittorrent/package.json b/packages/provider-qbittorrent/package.json new file mode 100644 index 0000000..2dbca93 --- /dev/null +++ b/packages/provider-qbittorrent/package.json @@ -0,0 +1,36 @@ +{ + "name": "@logarr/provider-qbittorrent", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "qBittorrent 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": { + "@types/node": "^22.10.2", + "tsup": "^8.3.5", + "typescript": "^5.7.2", + "vitest": "^4.0.16" + } +} diff --git a/packages/provider-qbittorrent/src/index.ts b/packages/provider-qbittorrent/src/index.ts new file mode 100644 index 0000000..81231aa --- /dev/null +++ b/packages/provider-qbittorrent/src/index.ts @@ -0,0 +1,21 @@ +// qBittorrent provider for Logarr +export { QBittorrentProvider } from './qbittorrent.provider.js'; +export { QBittorrentClient } from './qbittorrent.client.js'; +export { + QBITTORRENT_CORRELATION_PATTERNS, + QBITTORRENT_LOG_FILE_CONFIG, + isQBittorrentLogContinuation, + parseQBittorrentLogLine, + parseQBittorrentLogLineWithContext, +} from './qbittorrent.parser.js'; +export { resetQBittorrentState } from './qbittorrent.state.js'; + +// Types +export type { + QBittorrentAuthMode, + QBittorrentLogEntry, + QBittorrentTorrent, + QBittorrentTransferInfo, +} from './qbittorrent.types.js'; + +export { QBITTORRENT_LOG_TYPE } from './qbittorrent.types.js'; diff --git a/packages/provider-qbittorrent/src/qbittorrent.activity.spec.ts b/packages/provider-qbittorrent/src/qbittorrent.activity.spec.ts new file mode 100644 index 0000000..d96c39a --- /dev/null +++ b/packages/provider-qbittorrent/src/qbittorrent.activity.spec.ts @@ -0,0 +1,499 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { QBittorrentProvider } from './qbittorrent.provider.js'; + +const mockFetch = vi.fn(); + +let counter = 0; +function uniqueUrl(): string { + counter += 1; + return `http://qba-${counter}.local:8080`; +} + +type Route = (url: URL) => Response; + +function routeFetch(routes: Record): void { + mockFetch.mockImplementation((input: unknown) => { + const url = new URL(String(input)); + const route = routes[url.pathname.replace('/api/v2', '')]; + return Promise.resolve(route ? route(url) : new Response('not found', { status: 404 })); + }); +} + +const text = + (body: string): Route => + () => + new Response(body); +const json = + (body: unknown, status = 200): Route => + () => + new Response(JSON.stringify(body), { status }); + +function logRequests(): string[] { + return mockFetch.mock.calls + .map(([url]) => String(url)) + .filter((url) => url.includes('/log/main')); +} + +const lastKnownIds = (): string[] => + logRequests().map((url) => new URL(url).searchParams.get('last_known_id') ?? ''); + +const SERVER = { + '/app/version': text('v5.2.4'), + '/app/webapiVersion': text('2.15.1'), + '/torrents/info': json([]), + '/transfer/info': json({ connection_status: 'connected' }), +}; + +const entry = ( + id: number, + timestamp: number, + type = 4, + message = `m${id}` +): { id: number; message: string; timestamp: number; type: number } => ({ + id, + message, + timestamp, + type, +}); + +describe('QBittorrentProvider activity edge cases', () => { + let provider: QBittorrentProvider; + let baseUrl: string; + + beforeEach(() => { + vi.stubGlobal('fetch', mockFetch); + mockFetch.mockReset(); + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-10-10T12:00:00Z')); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + provider = new QBittorrentProvider(); + baseUrl = uniqueUrl(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + const connect = (logPath?: string): Promise => + provider.connect({ url: baseUrl, apiKey: '', logPath }); + + describe('log cursor after a qBittorrent restart (ids go backwards)', () => { + it('recovers when the restarted instance reuses lower ids', async () => { + // fake qBittorrent: honours last_known_id against its in-memory buffer + let buffer = [entry(998, 1_790_000_000), entry(999, 1_790_000_001, 8)]; + routeFetch({ + ...SERVER, + '/log/main': (url) => { + const last = Number(url.searchParams.get('last_known_id')); + return json(buffer.filter((e) => e.id > last))(url); + }, + }); + await connect(); + + const first = await provider.getActivity(); + expect(first.map((a) => a.id)).toEqual([ + 'qbittorrent-log-1790000000-998', + 'qbittorrent-log-1790000001-999', + ]); + + // qBittorrent restarts: empty buffer, ids start from 0 again + buffer = []; + vi.advanceTimersByTime(60_000); + expect(await provider.getActivity()).toEqual([]); + expect(lastKnownIds()).toEqual(['-1', '999']); + + // new warnings arrive after the restart but with ids far below the cursor + buffer = [entry(0, 1_790_001_000), entry(1, 1_790_001_001, 8)]; + vi.advanceTimersByTime(60_000); + expect(await provider.getActivity()).toEqual([]); + expect(lastKnownIds()).toEqual(['-1', '999', '999']); + + // 10+ minutes since the last full sync: empty answer triggers the resync + vi.advanceTimersByTime(10 * 60_000); + const afterRestart = await provider.getActivity(); + expect(lastKnownIds()).toEqual(['-1', '999', '999', '999', '-1']); + expect(afterRestart.map((a) => a.id)).toEqual([ + 'qbittorrent-log-1790001000-0', + 'qbittorrent-log-1790001001-1', + ]); + + // cursor now follows the new (lower) id space + await provider.getActivity(); + expect(lastKnownIds().at(-1)).toBe('1'); + }); + + it('does not re-ingest old buffer entries that were already seen before the restart', async () => { + const queue: unknown[][] = [ + [entry(5, 1_790_000_100)], + [], + // resync returns the same entry (no restart happened, id space unchanged) + [entry(5, 1_790_000_100)], + ]; + routeFetch({ ...SERVER, '/log/main': () => json(queue.shift() ?? [])(new URL(baseUrl)) }); + await connect(); + await provider.getActivity(); + + vi.advanceTimersByTime(11 * 60_000); + const again = await provider.getActivity(); + + expect(again).toEqual([]); + expect(lastKnownIds()).toEqual(['-1', '5', '-1']); + }); + + it('never resyncs while the first response was empty (cursor id -1)', async () => { + routeFetch({ ...SERVER, '/log/main': json([]) }); + await connect(); + + await provider.getActivity(); + vi.advanceTimersByTime(60 * 60_000); + await provider.getActivity(); + + expect(lastKnownIds()).toEqual(['-1', '-1']); + }); + }); + + describe('full-resync timer', () => { + const setup = async (): Promise => { + const queue: unknown[][] = [[entry(1, 1_790_000_000)], [], []]; + routeFetch({ ...SERVER, '/log/main': () => json(queue.shift() ?? [])(new URL(baseUrl)) }); + await connect(); + await provider.getActivity(); + }; + + it('does not resync at exactly 10 minutes', async () => { + await setup(); + vi.advanceTimersByTime(10 * 60_000); + await provider.getActivity(); + expect(lastKnownIds()).toEqual(['-1', '1']); + }); + + it('resyncs one millisecond after 10 minutes', async () => { + await setup(); + vi.advanceTimersByTime(10 * 60_000 + 1); + await provider.getActivity(); + expect(lastKnownIds()).toEqual(['-1', '1', '-1']); + }); + + it('is not postponed by regular non-empty polls', async () => { + const queue: unknown[][] = [[entry(1, 1_790_000_000)], [entry(2, 1_790_000_100)], [], []]; + routeFetch({ ...SERVER, '/log/main': () => json(queue.shift() ?? [])(new URL(baseUrl)) }); + await connect(); + await provider.getActivity(); + vi.advanceTimersByTime(9 * 60_000); + await provider.getActivity(); // non-empty poll at t=9min + vi.advanceTimersByTime(2 * 60_000); + await provider.getActivity(); // empty at t=11min since the FIRST full sync + + expect(lastKnownIds()).toEqual(['-1', '1', '2', '-1']); + }); + + it('keeps the previous cursor when the resync request itself fails', async () => { + const queue: Array = [ + json([entry(1, 1_790_000_000)]), + json([]), + () => new Response('boom', { status: 500 }), + json([]), + ]; + routeFetch({ + ...SERVER, + '/log/main': (url) => (queue.shift() ?? json([]))(url), + }); + await connect(); + await provider.getActivity(); + vi.advanceTimersByTime(11 * 60_000); + + await expect(provider.getActivity()).resolves.toEqual([]); + expect(console.error).toHaveBeenCalledWith( + '[qbittorrent] Failed to get log messages:', + expect.objectContaining({ message: expect.stringContaining('HTTP 500') }) + ); + + // next poll tries the plain cursor again and the resync again + await provider.getActivity(); + expect(lastKnownIds().slice(-2)).toEqual(['1', '-1']); + }); + }); + + describe('cursor isolation', () => { + it('keeps independent cursors per base URL', async () => { + const other = uniqueUrl(); + const second = new QBittorrentProvider(); + routeFetch({ + ...SERVER, + '/log/main': (url) => + json(url.origin === new URL(baseUrl).origin ? [entry(10, 1_790_000_000)] : [])(url), + }); + await connect(); + await second.connect({ url: other, apiKey: '' }); + + await provider.getActivity(); + await second.getActivity(); + await provider.getActivity(); + + const byHost = mockFetch.mock.calls + .map(([url]) => String(url)) + .filter((url) => url.includes('/log/main')) + .map((url) => [new URL(url).host, new URL(url).searchParams.get('last_known_id')]); + expect(byHost).toEqual([ + [new URL(baseUrl).host, '-1'], + [new URL(other).host, '-1'], + [new URL(baseUrl).host, '10'], + ]); + }); + + it('survives disconnect and reconnect (cursor is module-level)', async () => { + routeFetch({ ...SERVER, '/log/main': json([entry(7, 1_790_000_000)]) }); + await connect(); + await provider.getActivity(); + await provider.disconnect(); + await connect(); + await provider.getActivity(); + + expect(lastKnownIds()).toEqual(['-1', '7']); + }); + }); + + describe('log filtering', () => { + it('ignores normal and info entries even if the server returns them', async () => { + routeFetch({ + ...SERVER, + '/log/main': json([ + entry(1, 1_790_000_000, 1), + entry(2, 1_790_000_001, 2), + entry(3, 1_790_000_002, 4), + ]), + }); + await connect(); + + const activities = await provider.getActivity(); + + expect(activities.map((a) => a.metadata?.['qbId'])).toEqual([3]); + }); + + it('still advances the cursor past ignored entries', async () => { + routeFetch({ ...SERVER, '/log/main': json([entry(41, 1_790_000_000, 1)]) }); + await connect(); + await provider.getActivity(); + await provider.getActivity(); + + expect(lastKnownIds()).toEqual(['-1', '41']); + }); + + it('truncates long messages in the name but keeps the full overview', async () => { + const message = 'x'.repeat(100_000); + routeFetch({ ...SERVER, '/log/main': json([entry(1, 1_790_000_000, 8, message)]) }); + await connect(); + + const [activity] = await provider.getActivity(); + + expect(activity?.name).toHaveLength(120); + expect(activity?.name.endsWith('…')).toBe(true); + expect(activity?.overview).toBe(message); + }); + + it('applies the since filter to millisecond timestamps (qBittorrent < 4.5)', async () => { + routeFetch({ + ...SERVER, + '/log/main': json([entry(1, 1_790_000_000_000), entry(2, 1_790_000_100_000)]), + }); + await connect(); + + const activities = await provider.getActivity(new Date(1_790_000_050_000)); + + expect(activities.map((a) => a.metadata?.['qbId'])).toEqual([2]); + }); + + it('drops entries at exactly the since timestamp (strictly newer only)', async () => { + routeFetch({ + ...SERVER, + '/log/main': json([entry(1, 1_790_000_000), entry(2, 1_790_000_001)]), + }); + await connect(); + + const activities = await provider.getActivity(new Date(1_790_000_000_000)); + + expect(activities.map((a) => a.metadata?.['qbId'])).toEqual([2]); + }); + + it('keeps the newest 500 warnings ordered oldest to newest when the buffer is unsorted', async () => { + const entries = Array.from({ length: 700 }, (_, i) => entry(i, 1_790_000_000 + i)); + routeFetch({ ...SERVER, '/log/main': json([...entries].reverse()) }); + await connect(); + + const activities = await provider.getActivity(); + + expect(activities).toHaveLength(500); + expect(activities[0]?.metadata?.['qbId']).toBe(200); + expect(activities.at(-1)?.metadata?.['qbId']).toBe(699); + }); + + it('does not cap subsequent polls', async () => { + const first = [entry(0, 1_790_000_000)]; + const next = Array.from({ length: 800 }, (_, i) => entry(i + 1, 1_790_000_001 + i)); + const queue: unknown[][] = [first, next]; + routeFetch({ ...SERVER, '/log/main': () => json(queue.shift() ?? [])(new URL(baseUrl)) }); + await connect(); + await provider.getActivity(); + + expect(await provider.getActivity()).toHaveLength(800); + }); + + it('produces unique ids for same-second entries', async () => { + routeFetch({ + ...SERVER, + '/log/main': json([entry(1, 1_790_000_000), entry(2, 1_790_000_000)]), + }); + await connect(); + + const ids = (await provider.getActivity()).map((a) => a.id); + + expect(new Set(ids).size).toBe(2); + }); + }); + + describe('malformed payloads', () => { + it.each([ + ['an object instead of an array', { error: 'nope' }], + ['null', null], + ['a string', 'Ok.'], + ])('survives %s from /log/main and keeps the other sources', async (_label, payload) => { + routeFetch({ + ...SERVER, + '/log/main': json(payload), + '/torrents/info': json([{ hash: 'h', name: 'N', state: 'error' }]), + }); + await connect(); + + const activities = await provider.getActivity(); + + expect(activities.map((a) => a.type)).toEqual(['torrent_error']); + expect(console.error).toHaveBeenCalledWith( + '[qbittorrent] Failed to get log messages:', + expect.any(Error) + ); + }); + + it('survives a non-JSON /transfer/info body', async () => { + routeFetch({ + ...SERVER, + '/log/main': json([entry(1, 1_790_000_000)]), + '/transfer/info': text('proxy error'), + }); + await connect(''); + + const activities = await provider.getActivity(); + + expect(activities.map((a) => a.type)).toEqual(['log_warning']); + expect(console.error).toHaveBeenCalledWith( + '[qbittorrent] Failed to get connection status:', + expect.objectContaining({ message: 'Unexpected response from qBittorrent' }) + ); + }); + + it.each([{}, { connection_status: 'weird' }, { connection_status: null }])( + 'ignores unknown or missing connection_status %j', + async (info) => { + routeFetch({ ...SERVER, '/log/main': json([]), '/transfer/info': json(info) }); + await connect(); + + expect(await provider.getActivity()).toEqual([]); + } + ); + + it('tolerates a torrent without optional fields', async () => { + routeFetch({ + ...SERVER, + '/log/main': json([]), + '/torrents/info': json([{ hash: 'h', name: 'N', state: 'error', category: '' }]), + }); + await connect(); + + const [activity] = await provider.getActivity(); + + expect(activity?.overview).toBe('N is in state "error"'); + expect(activity?.metadata?.['savePath']).toBeUndefined(); + }); + }); + + describe('errored torrent id stability', () => { + const torrents = (state: string): Route => + json([{ hash: 'abc', name: 'Show.S01', state, category: 'tv' }]); + + it('keeps the id within a day but re-fires with a new id the next day', async () => { + routeFetch({ ...SERVER, '/log/main': json([]), '/torrents/info': torrents('error') }); + await connect(); + + const [first] = await provider.getActivity(); + vi.advanceTimersByTime(60 * 60_000); + const [sameDay] = await provider.getActivity(); + vi.advanceTimersByTime(24 * 60 * 60_000); + const [nextDay] = await provider.getActivity(); + + expect(sameDay?.id).toBe(first?.id); + expect(nextDay?.id).not.toBe(first?.id); + expect(nextDay?.timestamp.getTime()).toBeGreaterThan(first?.timestamp.getTime() ?? 0); + }); + + it('issues a new id when the same torrent changes error state', async () => { + routeFetch({ ...SERVER, '/log/main': json([]), '/torrents/info': torrents('error') }); + await connect(); + const [error] = await provider.getActivity(); + + routeFetch({ ...SERVER, '/log/main': json([]), '/torrents/info': torrents('missingFiles') }); + const [missing] = await provider.getActivity(); + + expect(error?.id).toBe('qbittorrent-torrent-error-abc-error-2026-10-10'); + expect(missing?.id).toBe('qbittorrent-torrent-error-abc-missing-files-2026-10-10'); + }); + + it('emits nothing once the torrent is no longer errored', async () => { + routeFetch({ ...SERVER, '/log/main': json([]), '/torrents/info': torrents('error') }); + await connect(); + await provider.getActivity(); + + routeFetch({ ...SERVER, '/log/main': json([]), '/torrents/info': json([]) }); + + expect(await provider.getActivity()).toEqual([]); + }); + }); + + describe('connection status buckets', () => { + const status = (connection_status: string): Route => json({ connection_status, dht_nodes: 0 }); + + it('rolls over exactly at the day boundary', async () => { + vi.setSystemTime(new Date('2026-10-10T23:59:59.999Z')); + routeFetch({ ...SERVER, '/log/main': json([]), '/transfer/info': status('firewalled') }); + await connect(); + const [before] = await provider.getActivity(); + + vi.setSystemTime(new Date('2026-10-11T00:00:00.000Z')); + const [after] = await provider.getActivity(); + + expect(before?.id).toBe('qbittorrent-connection-firewalled-2026-10-10'); + expect(after?.id).toBe('qbittorrent-connection-firewalled-2026-10-11'); + }); + + it('does not collide across days with the same hour', async () => { + routeFetch({ ...SERVER, '/log/main': json([]), '/transfer/info': status('disconnected') }); + await connect(); + const [today] = await provider.getActivity(); + vi.advanceTimersByTime(24 * 60 * 60_000); + const [tomorrow] = await provider.getActivity(); + + expect(tomorrow?.id).not.toBe(today?.id); + }); + + it('uses different ids for firewalled and disconnected in the same hour', async () => { + routeFetch({ ...SERVER, '/log/main': json([]), '/transfer/info': status('firewalled') }); + await connect(); + const [firewalled] = await provider.getActivity(); + routeFetch({ ...SERVER, '/log/main': json([]), '/transfer/info': status('disconnected') }); + const [disconnected] = await provider.getActivity(); + + expect(firewalled?.id).not.toBe(disconnected?.id); + }); + }); +}); diff --git a/packages/provider-qbittorrent/src/qbittorrent.client.spec.ts b/packages/provider-qbittorrent/src/qbittorrent.client.spec.ts new file mode 100644 index 0000000..6bdaf7b --- /dev/null +++ b/packages/provider-qbittorrent/src/qbittorrent.client.spec.ts @@ -0,0 +1,597 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { QBittorrentClient } from './qbittorrent.client.js'; + +const mockFetch = vi.fn(); + +let counter = 0; +/** Unique base URL per test so module-level session/cooldown maps never leak between tests */ +function uniqueUrl(): string { + counter += 1; + return `http://qb-${counter}.local:8080`; +} + +function ok(body = 'Ok.', init: { status?: number; cookies?: string[] } = {}): Response { + const headers = new Headers(); + for (const cookie of init.cookies ?? []) { + headers.append('set-cookie', cookie); + } + return new Response(body, { status: init.status ?? 200, headers }); +} + +interface RecordedCall { + url: string; + method: string; + headers: Record; + body: string | undefined; +} + +function calls(): RecordedCall[] { + return mockFetch.mock.calls.map(([url, init]) => ({ + url: String(url), + method: (init as RequestInit | undefined)?.method ?? 'GET', + headers: ((init as RequestInit | undefined)?.headers ?? {}) as Record, + body: (init as RequestInit | undefined)?.body as string | undefined, + })); +} + +describe('QBittorrentClient', () => { + beforeEach(() => { + vi.stubGlobal('fetch', mockFetch); + mockFetch.mockReset(); + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-10-10T12:00:00Z')); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + describe('auth mode detection', () => { + it('uses login mode when a username is given', () => { + expect(new QBittorrentClient(uniqueUrl(), { username: 'admin', secret: 'pw' }).authMode).toBe( + 'login' + ); + }); + + it('uses apiKey mode without username but with a secret', () => { + expect(new QBittorrentClient(uniqueUrl(), { secret: 'key' }).authMode).toBe('apiKey'); + }); + + it('uses none mode without username and secret', () => { + expect(new QBittorrentClient(uniqueUrl(), { secret: '' }).authMode).toBe('none'); + expect(new QBittorrentClient(uniqueUrl(), { username: ' ', secret: '' }).authMode).toBe( + 'none' + ); + }); + }); + + describe('login mode', () => { + it('logs in with form encoded body, Referer and Origin, then sends the session cookie', async () => { + const base = `${uniqueUrl()}/`; + mockFetch + .mockResolvedValueOnce(ok('Ok.', { cookies: ['QBT_SID_8080=abc123; HttpOnly; path=/'] })) + .mockResolvedValueOnce(ok('v5.2.4')); + const client = new QBittorrentClient(base, { username: 'admin', secret: 'p@ss&w=rd' }); + + const version = await client.getVersion(); + + expect(version).toBe('v5.2.4'); + const [login, get] = calls(); + const origin = new URL(base).origin; + expect(login?.url).toBe(`${origin}/api/v2/auth/login`); + expect(login?.method).toBe('POST'); + expect(login?.headers['Content-Type']).toBe('application/x-www-form-urlencoded'); + expect(login?.headers['Referer']).toBe(origin); + expect(login?.headers['Origin']).toBe(origin); + expect(login?.body).toBe('username=admin&password=p%40ss%26w%3Drd'); + expect(get?.url).toBe(`${origin}/api/v2/app/version`); + expect(get?.headers['Cookie']).toBe('QBT_SID_8080=abc123'); + expect(get?.headers['Referer']).toBe(origin); + }); + + it('merges multiple Set-Cookie headers and honours Max-Age=0 deletions', async () => { + const base = uniqueUrl(); + mockFetch + .mockResolvedValueOnce(ok('Ok.', { cookies: ['a=1; path=/', 'b=2; path=/'] })) + .mockResolvedValueOnce(ok('v5.2.4', { cookies: ['a=; Max-Age=0; path=/', 'c=3'] })) + .mockResolvedValueOnce(ok('2.15.1')); + const client = new QBittorrentClient(base, { username: 'admin', secret: 'pw' }); + + await client.getVersion(); + await client.getWebApiVersion(); + + expect(calls()[2]?.headers['Cookie']).toBe('b=2; c=3'); + }); + + it('does not log in again while the session is valid', async () => { + mockFetch + .mockResolvedValueOnce(ok('Ok.', { cookies: ['SID=x'] })) + .mockResolvedValueOnce(ok('v5.2.4')) + .mockResolvedValueOnce(ok('2.15.1')); + const client = new QBittorrentClient(uniqueUrl(), { username: 'admin', secret: 'pw' }); + + await client.getVersion(); + await client.getWebApiVersion(); + + expect(calls().filter((c) => c.url.endsWith('/auth/login'))).toHaveLength(1); + }); + + it('shares the session between client instances with identical credentials only', async () => { + const base = uniqueUrl(); + mockFetch + .mockResolvedValueOnce(ok('Ok.', { cookies: ['SID=first'] })) + .mockResolvedValueOnce(ok('v5.2.4')) + .mockResolvedValueOnce(ok('v5.2.4')) + .mockResolvedValueOnce(ok('Ok.', { cookies: ['SID=other'] })) + .mockResolvedValueOnce(ok('v5.2.4')); + + await new QBittorrentClient(base, { username: 'admin', secret: 'pw' }).getVersion(); + await new QBittorrentClient(base, { username: 'admin', secret: 'pw' }).getVersion(); + // different credentials against the same URL must not reuse the cookie + await new QBittorrentClient(base, { username: 'bob', secret: 'pw2' }).getVersion(); + + const recorded = calls(); + expect(recorded).toHaveLength(5); + expect(recorded[2]?.headers['Cookie']).toBe('SID=first'); + expect(recorded[3]?.url).toContain('/auth/login'); + expect(recorded[4]?.headers['Cookie']).toBe('SID=other'); + }); + + it('does not share sessions between different servers', async () => { + mockFetch + .mockResolvedValueOnce(ok('Ok.', { cookies: ['SID=one'] })) + .mockResolvedValueOnce(ok('v5.2.4')) + .mockResolvedValueOnce(ok('Ok.', { cookies: ['SID=two'] })) + .mockResolvedValueOnce(ok('v5.2.4')); + + await new QBittorrentClient(uniqueUrl(), { username: 'admin', secret: 'pw' }).getVersion(); + await new QBittorrentClient(uniqueUrl(), { username: 'admin', secret: 'pw' }).getVersion(); + + expect(calls()[3]?.headers['Cookie']).toBe('SID=two'); + }); + + it('treats an Ok. login without cookie as auth bypass and does not re-login on every call', async () => { + mockFetch + .mockResolvedValueOnce(ok('Ok.')) + .mockResolvedValueOnce(ok('v5.2.4')) + .mockResolvedValueOnce(ok('2.15.1')); + const client = new QBittorrentClient(uniqueUrl(), { username: 'admin', secret: 'pw' }); + + await client.getVersion(); + await client.getWebApiVersion(); + + expect(calls().filter((c) => c.url.endsWith('/auth/login'))).toHaveLength(1); + expect(calls()[1]?.headers['Cookie']).toBeUndefined(); + }); + + it('fails with a clear message on wrong credentials and stores no cookie', async () => { + mockFetch.mockResolvedValueOnce(ok('Fails.', { cookies: ['SID=bad'] })); + const client = new QBittorrentClient(uniqueUrl(), { username: 'admin', secret: 'wrong' }); + + await expect(client.getVersion()).rejects.toThrow('Invalid qBittorrent username or password'); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('reports an IP ban when login returns 403', async () => { + mockFetch.mockResolvedValueOnce(ok('Forbidden', { status: 403 })); + const client = new QBittorrentClient(uniqueUrl(), { username: 'admin', secret: 'pw' }); + + await expect(client.getVersion()).rejects.toThrow(/banned/); + }); + + it('reports a reverse proxy / CSRF hint when login returns 401', async () => { + mockFetch.mockResolvedValueOnce(ok('Unauthorized', { status: 401 })); + const client = new QBittorrentClient(uniqueUrl(), { username: 'admin', secret: 'pw' }); + + await expect(client.getVersion()).rejects.toThrow(/Host header validation/); + }); + + it('reports a non-qBittorrent endpoint when login returns 404', async () => { + mockFetch.mockResolvedValueOnce(ok('nope', { status: 404 })); + const base = uniqueUrl(); + const client = new QBittorrentClient(base, { username: 'admin', secret: 'pw' }); + + await expect(client.getVersion()).rejects.toThrow(`Not a qBittorrent WebUI at ${base}`); + }); + + it('does not count a 401 login response towards the cooldown', async () => { + const base = uniqueUrl(); + mockFetch + .mockResolvedValueOnce(ok('Unauthorized', { status: 401 })) + .mockResolvedValueOnce(ok('Ok.', { cookies: ['SID=x'] })) + .mockResolvedValueOnce(ok('v5.2.4')); + const creds = { username: 'admin', secret: 'pw' }; + + await expect(new QBittorrentClient(base, creds).getVersion()).rejects.toThrow(); + await expect(new QBittorrentClient(base, creds).getVersion()).resolves.toBe('v5.2.4'); + }); + }); + + describe('re-login', () => { + it('re-logs in once and retries when a request returns 403', async () => { + mockFetch + .mockResolvedValueOnce(ok('Ok.', { cookies: ['SID=old'] })) + .mockResolvedValueOnce(ok('Forbidden', { status: 403 })) + .mockResolvedValueOnce(ok('Ok.', { cookies: ['SID=new'] })) + .mockResolvedValueOnce(ok('v5.2.4')); + const client = new QBittorrentClient(uniqueUrl(), { username: 'admin', secret: 'pw' }); + + await expect(client.getVersion()).resolves.toBe('v5.2.4'); + + const recorded = calls(); + expect(recorded.map((c) => c.method)).toEqual(['POST', 'GET', 'POST', 'GET']); + expect(recorded[3]?.headers['Cookie']).toBe('SID=new'); + }); + + it('re-logs in on 401 as well', async () => { + mockFetch + .mockResolvedValueOnce(ok('Ok.', { cookies: ['SID=old'] })) + .mockResolvedValueOnce(ok('Unauthorized', { status: 401 })) + .mockResolvedValueOnce(ok('Ok.', { cookies: ['SID=new'] })) + .mockResolvedValueOnce(ok('v5.2.4')); + const client = new QBittorrentClient(uniqueUrl(), { username: 'admin', secret: 'pw' }); + + await expect(client.getVersion()).resolves.toBe('v5.2.4'); + }); + + it('gives up after one re-login when the request is still rejected', async () => { + mockFetch + .mockResolvedValueOnce(ok('Ok.', { cookies: ['SID=a'] })) + .mockResolvedValueOnce(ok('Forbidden', { status: 403 })) + .mockResolvedValueOnce(ok('Ok.', { cookies: ['SID=b'] })) + .mockResolvedValueOnce(ok('Forbidden', { status: 403 })); + const client = new QBittorrentClient(uniqueUrl(), { username: 'admin', secret: 'pw' }); + + await expect(client.getVersion()).rejects.toThrow( + 'qBittorrent authentication failed after re-login' + ); + expect(mockFetch).toHaveBeenCalledTimes(4); + }); + }); + + describe('failure cooldown', () => { + const failLogin = (): void => { + mockFetch.mockResolvedValueOnce(ok('Fails.')); + }; + + it('does not contact the server again during the cooldown, then retries after 5 minutes', async () => { + const base = uniqueUrl(); + const creds = { username: 'admin', secret: 'wrong' }; + failLogin(); + await expect(new QBittorrentClient(base, creds).getVersion()).rejects.toThrow( + 'Invalid qBittorrent username or password' + ); + expect(mockFetch).toHaveBeenCalledTimes(1); + + await expect(new QBittorrentClient(base, creds).getVersion()).rejects.toThrow( + /Invalid qBittorrent username or password.*retrying in \d+s/ + ); + expect(mockFetch).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(5 * 60 * 1000 - 1000); + await expect(new QBittorrentClient(base, creds).getVersion()).rejects.toThrow(/retrying in/); + expect(mockFetch).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(1000); + failLogin(); + await expect(new QBittorrentClient(base, creds).getVersion()).rejects.toThrow( + 'Invalid qBittorrent username or password' + ); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it('escalates to 15 minutes and then 60 minutes', async () => { + const base = uniqueUrl(); + const creds = { username: 'admin', secret: 'wrong' }; + failLogin(); + await expect(new QBittorrentClient(base, creds).getVersion()).rejects.toThrow(); + + vi.advanceTimersByTime(5 * 60 * 1000); + failLogin(); + await expect(new QBittorrentClient(base, creds).getVersion()).rejects.toThrow(); + expect(mockFetch).toHaveBeenCalledTimes(2); + + // second failure: 15 minute cooldown + vi.advanceTimersByTime(14 * 60 * 1000); + await expect(new QBittorrentClient(base, creds).getVersion()).rejects.toThrow(/retrying in/); + expect(mockFetch).toHaveBeenCalledTimes(2); + vi.advanceTimersByTime(60 * 1000); + failLogin(); + await expect(new QBittorrentClient(base, creds).getVersion()).rejects.toThrow(); + expect(mockFetch).toHaveBeenCalledTimes(3); + + // third failure: 60 minute cooldown + vi.advanceTimersByTime(59 * 60 * 1000); + await expect(new QBittorrentClient(base, creds).getVersion()).rejects.toThrow(/retrying in/); + expect(mockFetch).toHaveBeenCalledTimes(3); + vi.advanceTimersByTime(60 * 1000); + failLogin(); + await expect(new QBittorrentClient(base, creds).getVersion()).rejects.toThrow(); + expect(mockFetch).toHaveBeenCalledTimes(4); + + // fourth failure stays at 60 minutes + vi.advanceTimersByTime(59 * 60 * 1000); + await expect(new QBittorrentClient(base, creds).getVersion()).rejects.toThrow(/retrying in/); + expect(mockFetch).toHaveBeenCalledTimes(4); + }); + + it('applies the cooldown after an IP ban response too', async () => { + const base = uniqueUrl(); + const creds = { username: 'admin', secret: 'pw' }; + mockFetch.mockResolvedValueOnce(ok('Forbidden', { status: 403 })); + await expect(new QBittorrentClient(base, creds).getVersion()).rejects.toThrow(/banned/); + await expect(new QBittorrentClient(base, creds).getVersion()).rejects.toThrow(/banned/); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('is bypassed immediately when the credentials change', async () => { + const base = uniqueUrl(); + failLogin(); + await expect( + new QBittorrentClient(base, { username: 'admin', secret: 'wrong' }).getVersion() + ).rejects.toThrow(); + + mockFetch + .mockResolvedValueOnce(ok('Ok.', { cookies: ['SID=x'] })) + .mockResolvedValueOnce(ok('v5.2.4')); + await expect( + new QBittorrentClient(base, { username: 'admin', secret: 'right' }).getVersion() + ).resolves.toBe('v5.2.4'); + }); + + it('is cleared after a successful login', async () => { + const base = uniqueUrl(); + const creds = { username: 'admin', secret: 'pw' }; + failLogin(); + await expect(new QBittorrentClient(base, creds).getVersion()).rejects.toThrow(); + + vi.advanceTimersByTime(5 * 60 * 1000); + mockFetch + .mockResolvedValueOnce(ok('Ok.', { cookies: ['SID=x'] })) + .mockResolvedValueOnce(ok('v5.2.4')); + await expect(new QBittorrentClient(base, creds).getVersion()).resolves.toBe('v5.2.4'); + + // a new failure sequence starts again at the first (5 minute) step, not at 15 minutes + mockFetch + .mockResolvedValueOnce(ok('Forbidden', { status: 403 })) + .mockResolvedValueOnce(ok('Fails.')); + await expect(new QBittorrentClient(base, creds).getWebApiVersion()).rejects.toThrow(); + mockFetch.mockClear(); + vi.advanceTimersByTime(5 * 60 * 1000); + mockFetch.mockResolvedValueOnce(ok('Fails.')); + await expect(new QBittorrentClient(base, creds).getVersion()).rejects.toThrow(); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + }); + + describe('auth bypass (none mode)', () => { + it('never logs in and sends no credentials', async () => { + mockFetch.mockResolvedValueOnce(ok('v5.2.4')); + const client = new QBittorrentClient(uniqueUrl(), { secret: '' }); + + await client.getVersion(); + + const [call] = calls(); + expect(call?.url).toContain('/api/v2/app/version'); + expect(call?.headers['Cookie']).toBeUndefined(); + expect(call?.headers['Authorization']).toBeUndefined(); + }); + + it('explains that authentication is required on 403', async () => { + mockFetch.mockResolvedValueOnce(ok('Forbidden', { status: 403 })); + const client = new QBittorrentClient(uniqueUrl(), { secret: '' }); + + await expect(client.getVersion()).rejects.toThrow( + 'qBittorrent requires authentication; configure username/password (or an API key for >= 5.2)' + ); + }); + }); + + describe('API key mode', () => { + it('sends a bearer token on every request and never logs in', async () => { + mockFetch.mockResolvedValueOnce(ok('v5.3.0')).mockResolvedValueOnce(ok('2.16.2')); + const client = new QBittorrentClient(uniqueUrl(), { secret: 'qbt_key' }); + + await client.getVersion(); + await client.getWebApiVersion(); + + for (const call of calls()) { + expect(call.headers['Authorization']).toBe('Bearer qbt_key'); + expect(call.url).not.toContain('/auth/login'); + } + }); + + it('reports a rejected key on 401 without echoing the key', async () => { + mockFetch.mockResolvedValueOnce(ok('Unauthorized', { status: 401 })); + const client = new QBittorrentClient(uniqueUrl(), { secret: 'qbt_supersecret' }); + + const error = await client.getVersion().catch((e: unknown) => e as Error); + expect((error as Error).message).toBe( + 'API key rejected (API keys require qBittorrent >= 5.2)' + ); + expect((error as Error).message).not.toContain('qbt_supersecret'); + }); + }); + + describe('network errors', () => { + it('maps AbortError to a timeout message', async () => { + mockFetch.mockRejectedValueOnce(Object.assign(new Error('aborted'), { name: 'AbortError' })); + const client = new QBittorrentClient(uniqueUrl(), { secret: '' }); + + await expect(client.getVersion()).rejects.toThrow('qBittorrent request timed out after 10s'); + }); + + it('aborts requests that never complete after 10 seconds', async () => { + mockFetch.mockImplementationOnce( + (_url: unknown, init: RequestInit) => + new Promise((_resolve, reject) => { + init.signal?.addEventListener('abort', () => + reject(Object.assign(new Error('aborted'), { name: 'AbortError' })) + ); + }) + ); + const client = new QBittorrentClient(uniqueUrl(), { secret: '' }); + + const pending = client.getVersion().catch((e: unknown) => e as Error); + await vi.advanceTimersByTimeAsync(10_000); + + expect(((await pending) as Error).message).toBe('qBittorrent request timed out after 10s'); + }); + + it('maps connection refused to a reachability message', async () => { + const base = uniqueUrl(); + mockFetch.mockRejectedValueOnce( + Object.assign(new TypeError('fetch failed'), { cause: { code: 'ECONNREFUSED' } }) + ); + const client = new QBittorrentClient(base, { secret: '' }); + + await expect(client.getVersion()).rejects.toThrow(`Cannot reach qBittorrent at ${base}`); + }); + + it('maps unknown errors to a generic message', async () => { + mockFetch.mockRejectedValueOnce(new Error('boom')); + const client = new QBittorrentClient(uniqueUrl(), { secret: '' }); + + await expect(client.getVersion()).rejects.toThrow('qBittorrent request failed: boom'); + }); + }); + + describe('API calls', () => { + it('requests the log with a cursor and severity filters', async () => { + mockFetch.mockResolvedValueOnce(ok('[]')); + const client = new QBittorrentClient(uniqueUrl(), { secret: '' }); + + await client.getLog(-1); + + const url = calls()[0]?.url ?? ''; + expect(url).toContain('/api/v2/log/main?'); + expect(url).toContain('last_known_id=-1'); + expect(url).toContain('warning=true'); + expect(url).toContain('critical=true'); + expect(url).toContain('normal=false'); + expect(url).toContain('info=false'); + }); + + it('parses errored torrents and transfer info', async () => { + mockFetch + .mockResolvedValueOnce(ok('[{"hash":"h","name":"n","state":"error"}]')) + .mockResolvedValueOnce(ok('{"connection_status":"firewalled","dht_nodes":3}')); + const client = new QBittorrentClient(uniqueUrl(), { secret: '' }); + + await expect(client.getErroredTorrents()).resolves.toEqual([ + { hash: 'h', name: 'n', state: 'error' }, + ]); + await expect(client.getTransferInfo()).resolves.toEqual({ + connection_status: 'firewalled', + dht_nodes: 3, + }); + expect(calls()[0]?.url).toContain('/api/v2/torrents/info?filter=errored'); + expect(calls()[1]?.url).toContain('/api/v2/transfer/info'); + }); + + it('wraps JSON parse errors', async () => { + mockFetch.mockResolvedValueOnce(ok('')); + const client = new QBittorrentClient(uniqueUrl(), { secret: '' }); + + await expect(client.getTransferInfo()).rejects.toThrow( + 'Unexpected response from qBittorrent' + ); + }); + + it('reports a non-qBittorrent endpoint on 404', async () => { + const base = uniqueUrl(); + mockFetch.mockResolvedValueOnce(ok('nope', { status: 404 })); + const client = new QBittorrentClient(base, { secret: '' }); + + await expect(client.getVersion()).rejects.toThrow(`Not a qBittorrent WebUI at ${base}`); + }); + + it('reports other HTTP errors with status and path', async () => { + mockFetch.mockResolvedValueOnce(ok('err', { status: 500 })); + const client = new QBittorrentClient(uniqueUrl(), { secret: '' }); + + await expect(client.getVersion()).rejects.toThrow( + 'qBittorrent API error: HTTP 500 /app/version' + ); + }); + }); + + describe('secret hygiene', () => { + it('never leaks passwords or cookies in error messages', async () => { + const password = 'hunter2-very-secret'; + const messages: string[] = []; + + mockFetch.mockResolvedValueOnce(ok('Fails.', { cookies: ['SID=cookievalue123'] })); + messages.push( + ( + (await new QBittorrentClient(uniqueUrl(), { username: 'admin', secret: password }) + .getVersion() + .catch((e: unknown) => e)) as Error + ).message + ); + + mockFetch + .mockResolvedValueOnce(ok('Ok.', { cookies: ['SID=cookievalue123'] })) + .mockResolvedValueOnce(ok('Forbidden', { status: 403 })) + .mockResolvedValueOnce(ok('Ok.', { cookies: ['SID=cookievalue456'] })) + .mockResolvedValueOnce(ok('Forbidden', { status: 403 })); + messages.push( + ( + (await new QBittorrentClient(uniqueUrl(), { username: 'admin', secret: password }) + .getVersion() + .catch((e: unknown) => e)) as Error + ).message + ); + + mockFetch.mockRejectedValueOnce( + Object.assign(new TypeError('fetch failed'), { cause: { code: 'ECONNREFUSED' } }) + ); + messages.push( + ( + (await new QBittorrentClient(uniqueUrl(), { username: 'admin', secret: password }) + .getVersion() + .catch((e: unknown) => e)) as Error + ).message + ); + + for (const message of messages) { + expect(message).not.toContain(password); + expect(message).not.toContain('cookievalue'); + } + }); + }); + describe('network error redaction', () => { + it('never leaks the secret when fetch rejects an invalid header value', async () => { + const secret = 'super-secret-key\n'; + mockFetch.mockRejectedValue( + new TypeError(`Headers.append: "Bearer ${secret}" is an invalid header value.`) + ); + const client = new QBittorrentClient(uniqueUrl(), { secret }); + + const error = await client.getVersion().catch((e: Error) => e); + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).not.toContain('super-secret-key'); + expect((error as Error).message).toBe( + 'qBittorrent request failed: invalid credentials format' + ); + }); + + it('hides ByteString errors and any message containing the secret', async () => { + mockFetch.mockRejectedValueOnce( + new TypeError('Cannot convert argument to a ByteString because of character 10') + ); + const client = new QBittorrentClient(uniqueUrl(), { secret: 'abc123' }); + const first = await client.getVersion().catch((e: Error) => e); + expect((first as Error).message).toBe( + 'qBittorrent request failed: invalid credentials format' + ); + + mockFetch.mockRejectedValueOnce(new Error('weird failure for Authorization: Bearer abc123')); + const second = await client.getVersion().catch((e: Error) => e); + expect((second as Error).message).not.toContain('abc123'); + }); + }); +}); diff --git a/packages/provider-qbittorrent/src/qbittorrent.client.ts b/packages/provider-qbittorrent/src/qbittorrent.client.ts new file mode 100644 index 0000000..9608a48 --- /dev/null +++ b/packages/provider-qbittorrent/src/qbittorrent.client.ts @@ -0,0 +1,354 @@ +import { createHash } from 'node:crypto'; + +import { redactSecrets } from './qbittorrent.redact.js'; +import { cooldowns, sessionCache } from './qbittorrent.state.js'; + +import type { + QBittorrentAuthMode, + QBittorrentLogEntry, + QBittorrentTorrent, + QBittorrentTransferInfo, +} from './qbittorrent.types.js'; + +const REQUEST_TIMEOUT_MS = 10_000; +const API_PREFIX = '/api/v2'; + +/** Escalating cooldown after authentication failures (qBittorrent bans an IP for 1h after 5) */ +const COOLDOWN_STEPS_MS = [5 * 60_000, 15 * 60_000, 60 * 60_000] as const; + +type CookieJar = Map; + +interface RawResponse { + status: number; + text: string; +} + +interface QBittorrentCredentials { + readonly username?: string | undefined; + /** Password (with username) or API key (without username). May be empty. */ + readonly secret: string; +} + +/** + * Cache keys are a SHA-256 of base URL, username and secret, so different servers or credentials + * never share cookies and no plaintext secret is used as a map key. The caches themselves are + * bounded process-wide state, see qbittorrent.state.ts. + */ +function hashKey(baseUrl: string, username: string, secret: string): string { + return createHash('sha256').update(`${baseUrl}\u0000${username}\u0000${secret}`).digest('hex'); +} + +/** True when a Set-Cookie attribute list marks the cookie as expired/deleted */ +function isExpired(attributes: string[]): boolean { + for (const attribute of attributes) { + const separator = attribute.indexOf('='); + if (separator < 0) { + continue; + } + const name = attribute.slice(0, separator).trim().toLowerCase(); + const value = attribute.slice(separator + 1).trim(); + if (name === 'max-age') { + const seconds = Number(value); + if (Number.isFinite(seconds) && seconds <= 0) { + return true; + } + } else if (name === 'expires') { + const time = Date.parse(value); + if (!Number.isNaN(time) && time <= Date.now()) { + return true; + } + } + } + return false; +} + +function splitSetCookie(header: string | null): string[] { + if (header === null || header === '') { + return []; + } + return header.split(/,(?=\s*[^;,=\s]+=)/); +} + +/** + * Minimal qBittorrent WebUI API v2 client (cookie session, API key or no authentication). + * Uses fetch directly because core httpRequest cannot send forms or manage cookies. + */ +export class QBittorrentClient { + readonly baseUrl: string; + readonly authMode: QBittorrentAuthMode; + + private readonly origin: string; + private readonly username: string; + private readonly secret: string; + private readonly key: string; + private jar: CookieJar; + private sessionReady: boolean; + + constructor(baseUrl: string, credentials: QBittorrentCredentials) { + this.baseUrl = baseUrl.trim().replace(/\/+$/, ''); + this.origin = new URL(this.baseUrl).origin; + this.username = credentials.username?.trim() ?? ''; + this.secret = credentials.secret; + + if (this.username !== '') { + this.authMode = 'login'; + } else if (this.secret !== '') { + this.authMode = 'apiKey'; + } else { + this.authMode = 'none'; + } + + this.key = hashKey(this.baseUrl, this.username, this.secret); + const cached = sessionCache.get(this.key); + this.sessionReady = cached !== undefined; + this.jar = new Map(cached ?? []); + } + + async getVersion(): Promise { + return this.getText('/app/version'); + } + + async getWebApiVersion(): Promise { + return this.getText('/app/webapiVersion'); + } + + async getLog(lastKnownId: number): Promise { + return this.getJson('/log/main', { + normal: 'false', + info: 'false', + warning: 'true', + critical: 'true', + last_known_id: String(lastKnownId), + }); + } + + async getErroredTorrents(): Promise { + return this.getJson('/torrents/info', { filter: 'errored' }); + } + + async getTransferInfo(): Promise { + return this.getJson('/transfer/info'); + } + + // --------------------------------------------------------------------------- + // Internals + // --------------------------------------------------------------------------- + + private async getJson(path: string, query?: Record): Promise { + const text = await this.getText(path, query); + try { + return JSON.parse(text) as T; + } catch { + throw new Error('Unexpected response from qBittorrent'); + } + } + + private async getText(path: string, query?: Record): Promise { + if (this.authMode === 'login' && !this.sessionReady) { + await this.login(); + } + + let response = await this.send('GET', path, { query }); + + if (response.status === 401 || response.status === 403) { + if (this.authMode === 'login') { + this.jar = new Map(); + this.sessionReady = false; + sessionCache.delete(this.key); + await this.login(); + response = await this.send('GET', path, { query }); + if (response.status === 401 || response.status === 403) { + throw new Error('qBittorrent authentication failed after re-login'); + } + } else if (this.authMode === 'apiKey') { + throw new Error('API key rejected (API keys require qBittorrent >= 5.2)'); + } else { + throw new Error( + 'qBittorrent requires authentication; configure username/password (or an API key for >= 5.2)' + ); + } + } + + if (response.status === 404) { + throw this.notQBittorrentError(); + } + if (response.status < 200 || response.status >= 300) { + throw new Error(`qBittorrent API error: HTTP ${response.status} ${path}`); + } + + return response.text; + } + + private notQBittorrentError(): Error { + return new Error(`Not a qBittorrent WebUI at ${this.baseUrl} (/api/v2 not found)`); + } + + private async login(): Promise { + const cooldown = cooldowns.get(this.key); + if (cooldown !== undefined && cooldown.until > Date.now()) { + const seconds = Math.ceil((cooldown.until - Date.now()) / 1000); + throw new Error(`${cooldown.message} (retrying in ${seconds}s)`); + } + + // Success requires a cookie set by THIS response, so drop any stale session first + this.jar = new Map(); + this.sessionReady = false; + + const response = await this.send('POST', '/auth/login', { + form: { username: this.username, password: this.secret }, + }); + + const success = response.status >= 200 && response.status < 300; + const body = response.text.trim(); + + // qBittorrent < 5.2 answers 200 "Ok."; newer versions answer 204 No Content (empty body) with + // the session cookie. "Ok." without a cookie is auth bypass for our IP. + if (success && (body === 'Ok.' || (body === '' && this.jar.size > 0))) { + cooldowns.delete(this.key); + this.sessionReady = true; + sessionCache.set(this.key, new Map(this.jar)); + return; + } + + if (response.status === 200 && body === 'Fails.') { + throw this.recordFailure('Invalid qBittorrent username or password'); + } + if (success) { + // Not counted as a credential failure: probably not qBittorrent (e.g. an auth proxy page) + throw new Error( + 'Unexpected login response — is this a qBittorrent WebUI (or behind an auth proxy)?' + ); + } + if (response.status === 403) { + throw this.recordFailure( + 'qBittorrent refused the login (HTTP 403): this IP is banned after too many failed attempts. Wait (default 1 hour) or check the credentials' + ); + } + if (response.status === 401) { + throw new Error( + 'qBittorrent rejected the request (HTTP 401): check that the URL is correct and, behind a reverse proxy, that Host header validation / CSRF settings allow Logarr' + ); + } + if (response.status === 404) { + throw this.notQBittorrentError(); + } + throw new Error(`qBittorrent login failed: HTTP ${response.status}`); + } + + private recordFailure(message: string): Error { + this.jar = new Map(); + const previous = cooldowns.get(this.key); + const failures = (previous?.failures ?? 0) + 1; + const step = COOLDOWN_STEPS_MS[Math.min(failures, COOLDOWN_STEPS_MS.length) - 1] as number; + const now = Date.now(); + cooldowns.set(this.key, { until: now + step, message, failures }); + return new Error(message); + } + + private async send( + method: 'GET' | 'POST', + path: string, + options: { query?: Record | undefined; form?: Record } + ): Promise { + const query = options.query ? `?${new URLSearchParams(options.query).toString()}` : ''; + const url = `${this.baseUrl}${API_PREFIX}${path}${query}`; + + const headers: Record = { + Accept: '*/*', + Referer: this.origin, + Origin: this.origin, + }; + if (options.form) { + headers['Content-Type'] = 'application/x-www-form-urlencoded'; + } + if (this.authMode === 'apiKey') { + headers['Authorization'] = `Bearer ${this.secret}`; + } + if (this.authMode === 'login' && this.jar.size > 0) { + headers['Cookie'] = [...this.jar].map(([name, value]) => `${name}=${value}`).join('; '); + } + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + + try { + const response = await fetch(url, { + method, + headers, + signal: controller.signal, + redirect: 'manual', + ...(options.form && { body: new URLSearchParams(options.form).toString() }), + }); + if (response.status >= 300 && response.status < 400) { + throw new Error(this.redirectMessage(response, url)); + } + this.storeCookies(response.headers); + return { status: response.status, text: await response.text() }; + } catch (error) { + throw this.mapNetworkError(error); + } finally { + clearTimeout(timer); + } + } + + private redirectMessage(response: Response, requestUrl: string): string { + let target = 'another location'; + try { + const location = response.headers.get('location'); + if (location !== null) { + target = new URL(location, requestUrl).origin; + } + } catch { + // keep the generic wording + } + return `URL redirects to ${target} — configure the final URL`; + } + + private mapNetworkError(error: unknown): Error { + if (error instanceof Error && error.message.startsWith('URL redirects to')) { + return error; + } + const err = error as { name?: string; message?: string; cause?: { code?: string } }; + if (err.name === 'AbortError' || err.name === 'TimeoutError') { + return new Error(`qBittorrent request timed out after ${REQUEST_TIMEOUT_MS / 1000}s`); + } + const code = err.cause?.code; + if (code === 'ECONNREFUSED' || code === 'ENOTFOUND' || code === 'EHOSTUNREACH') { + return new Error(`Cannot reach qBittorrent at ${this.baseUrl}`); + } + const message = err.message ?? 'unknown error'; + // fetch embeds the offending header value (the Bearer secret) in invalid-header errors + if ( + /invalid header value|ByteString/i.test(message) || + (this.secret !== '' && message.includes(this.secret)) + ) { + return new Error('qBittorrent request failed: invalid credentials format'); + } + return new Error(`qBittorrent request failed: ${redactSecrets(message)}`); + } + + private storeCookies(headers: Headers): void { + if (this.authMode !== 'login') { + return; + } + const raw = + typeof headers.getSetCookie === 'function' + ? headers.getSetCookie() + : splitSetCookie(headers.get('set-cookie')); + + for (const cookie of raw) { + const [pair = '', ...attributes] = cookie.split(';'); + const separator = pair.indexOf('='); + if (separator <= 0) { + continue; + } + const name = pair.slice(0, separator).trim(); + const value = pair.slice(separator + 1).trim(); + if (value === '' || isExpired(attributes)) { + this.jar.delete(name); + } else { + this.jar.set(name, value); + } + } + } +} diff --git a/packages/provider-qbittorrent/src/qbittorrent.cookies.spec.ts b/packages/provider-qbittorrent/src/qbittorrent.cookies.spec.ts new file mode 100644 index 0000000..d97a75d --- /dev/null +++ b/packages/provider-qbittorrent/src/qbittorrent.cookies.spec.ts @@ -0,0 +1,206 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { QBittorrentClient } from './qbittorrent.client.js'; + +const mockFetch = vi.fn(); + +let counter = 0; +function uniqueUrl(): string { + counter += 1; + return `http://qb-cookie-${counter}.local:8080`; +} + +/** + * Response stand-in for runtimes without Headers.getSetCookie(): all Set-Cookie values are + * folded into one comma-joined header, exactly like Headers.get('set-cookie') does. + */ +function legacyResponse(body: string, setCookie: string | null, status = 200): Response { + const headers = { + get: (name: string) => (name.toLowerCase() === 'set-cookie' ? setCookie : null), + }; + return { + status, + headers, + text: () => Promise.resolve(body), + } as unknown as Response; +} + +function modernResponse(body: string, cookies: string[], status = 200): Response { + const headers = new Headers(); + for (const cookie of cookies) { + headers.append('set-cookie', cookie); + } + return new Response(body, { status, headers }); +} + +function cookieHeaderOfCall(index: number): string | undefined { + const init = mockFetch.mock.calls[index]?.[1] as RequestInit | undefined; + return (init?.headers as Record | undefined)?.['Cookie']; +} + +async function loginAndGetCookie( + loginResponse: Response, + baseUrl = uniqueUrl() +): Promise { + mockFetch.mockResolvedValueOnce(loginResponse).mockResolvedValueOnce(new Response('v5.2.4')); + const client = new QBittorrentClient(baseUrl, { username: 'admin', secret: 'pw' }); + await client.getVersion(); + return cookieHeaderOfCall(1); +} + +describe('QBittorrentClient cookie handling', () => { + beforeEach(() => { + vi.stubGlobal('fetch', mockFetch); + mockFetch.mockReset(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + describe('Headers.getSetCookie fallback (splitSetCookie)', () => { + it('parses a single comma-free cookie', async () => { + const cookie = await loginAndGetCookie( + legacyResponse('Ok.', 'QBT_SID_8080=abc123; HttpOnly; SameSite=Strict; path=/') + ); + expect(cookie).toBe('QBT_SID_8080=abc123'); + }); + + it('splits several cookies folded into one comma-joined header', async () => { + const cookie = await loginAndGetCookie( + legacyResponse('Ok.', 'a=1; path=/, b=2; HttpOnly; path=/, c=3') + ); + expect(cookie).toBe('a=1; b=2; c=3'); + }); + + it('does not split on the comma inside an Expires date', async () => { + const cookie = await loginAndGetCookie( + legacyResponse( + 'Ok.', + 'a=1; Expires=Wed, 21 Oct 2099 07:28:00 GMT; path=/, b=2; Expires=Thu, 22 Oct 2099 07:28:00 GMT' + ) + ); + expect(cookie).toBe('a=1; b=2'); + }); + + it('does not split on the comma inside a dashed (Netscape) Expires date', async () => { + const cookie = await loginAndGetCookie( + legacyResponse('Ok.', 'a=1; Expires=Wed, 21-Oct-2099 07:28:00 GMT, b=2') + ); + expect(cookie).toBe('a=1; b=2'); + }); + + it('treats a missing Set-Cookie header (null) as no cookies', async () => { + mockFetch + .mockResolvedValueOnce(legacyResponse('Ok.', null)) + .mockResolvedValueOnce(legacyResponse('v5.2.4', null)) + .mockResolvedValueOnce(legacyResponse('2.15.1', null)); + const client = new QBittorrentClient(uniqueUrl(), { username: 'admin', secret: 'pw' }); + + await expect(client.getVersion()).resolves.toBe('v5.2.4'); + // bypass-style login: no cookie, no re-login on the next call + await expect(client.getWebApiVersion()).resolves.toBe('2.15.1'); + expect(mockFetch).toHaveBeenCalledTimes(3); + expect(cookieHeaderOfCall(1)).toBeUndefined(); + }); + + it('treats an empty Set-Cookie header as no cookies', async () => { + const cookie = await loginAndGetCookie(legacyResponse('Ok.', '')); + expect(cookie).toBeUndefined(); + }); + + it('honours Max-Age=0 deletions delivered through the fallback', async () => { + mockFetch + .mockResolvedValueOnce(legacyResponse('Ok.', 'a=1; path=/, b=2; path=/')) + .mockResolvedValueOnce(legacyResponse('v5.2.4', 'a=; Max-Age=0; path=/')) + .mockResolvedValueOnce(legacyResponse('2.15.1', null)); + const client = new QBittorrentClient(uniqueUrl(), { username: 'admin', secret: 'pw' }); + + await client.getVersion(); + await client.getWebApiVersion(); + + expect(cookieHeaderOfCall(2)).toBe('b=2'); + }); + + it('produces the same jar as the getSetCookie() path', async () => { + const raw = ['x=1; path=/', 'y=2; HttpOnly', 'z=3; Max-Age=3600']; + const modern = await loginAndGetCookie(modernResponse('Ok.', raw)); + mockFetch.mockReset(); + const legacy = await loginAndGetCookie(legacyResponse('Ok.', raw.join(', '))); + expect(legacy).toBe(modern); + }); + + it('does not parse cookies at all outside login mode', async () => { + mockFetch.mockResolvedValueOnce(legacyResponse('v5.2.4', 'SID=leak')); + const client = new QBittorrentClient(uniqueUrl(), { secret: 'key' }); + await client.getVersion(); + mockFetch.mockResolvedValueOnce(legacyResponse('2.15.1', null)); + await client.getWebApiVersion(); + expect(cookieHeaderOfCall(1)).toBeUndefined(); + }); + }); + + describe('cookie parsing edge cases', () => { + it('keeps "=" characters inside the cookie value (base64 padding)', async () => { + const cookie = await loginAndGetCookie(modernResponse('Ok.', ['SID=YWJj==; path=/'])); + expect(cookie).toBe('SID=YWJj=='); + }); + + it('ignores cookies without a name or without "="', async () => { + const cookie = await loginAndGetCookie( + modernResponse('Ok.', ['=novalue', 'garbage', 'ok=1; path=/']) + ); + expect(cookie).toBe('ok=1'); + }); + + it('trims whitespace around names and values', async () => { + const cookie = await loginAndGetCookie(modernResponse('Ok.', [' a = 1 ; path=/'])); + expect(cookie).toBe('a=1'); + }); + + it('deletes a cookie that is set with an empty value', async () => { + mockFetch + .mockResolvedValueOnce(modernResponse('Ok.', ['a=1', 'b=2'])) + .mockResolvedValueOnce(modernResponse('v5.2.4', ['a='])) + .mockResolvedValueOnce(new Response('2.15.1')); + const client = new QBittorrentClient(uniqueUrl(), { username: 'admin', secret: 'pw' }); + await client.getVersion(); + await client.getWebApiVersion(); + expect(cookieHeaderOfCall(2)).toBe('b=2'); + }); + + it('treats Max-Age=0 case-insensitively and with padding zeros', async () => { + mockFetch + .mockResolvedValueOnce(modernResponse('Ok.', ['a=1', 'b=2', 'c=3'])) + .mockResolvedValueOnce(modernResponse('v5.2.4', ['a=1; MAX-AGE=0', 'b=2; max-age = 00'])) + .mockResolvedValueOnce(new Response('2.15.1')); + const client = new QBittorrentClient(uniqueUrl(), { username: 'admin', secret: 'pw' }); + await client.getVersion(); + await client.getWebApiVersion(); + expect(cookieHeaderOfCall(2)).toBe('c=3'); + }); + + it('overwrites an existing cookie of the same name', async () => { + mockFetch + .mockResolvedValueOnce(modernResponse('Ok.', ['SID=old'])) + .mockResolvedValueOnce(modernResponse('v5.2.4', ['SID=new'])) + .mockResolvedValueOnce(new Response('2.15.1')); + const client = new QBittorrentClient(uniqueUrl(), { username: 'admin', secret: 'pw' }); + await client.getVersion(); + await client.getWebApiVersion(); + expect(cookieHeaderOfCall(2)).toBe('SID=new'); + }); + + it('deletes a cookie with a negative Max-Age', async () => { + mockFetch + .mockResolvedValueOnce(modernResponse('Ok.', ['a=1', 'b=2'])) + .mockResolvedValueOnce(modernResponse('v5.2.4', ['a=1; Max-Age=-1'])) + .mockResolvedValueOnce(new Response('2.15.1')); + const client = new QBittorrentClient(uniqueUrl(), { username: 'admin', secret: 'pw' }); + await client.getVersion(); + await client.getWebApiVersion(); + expect(cookieHeaderOfCall(2)).toBe('b=2'); + }); + }); +}); diff --git a/packages/provider-qbittorrent/src/qbittorrent.hardening.spec.ts b/packages/provider-qbittorrent/src/qbittorrent.hardening.spec.ts new file mode 100644 index 0000000..71b3781 --- /dev/null +++ b/packages/provider-qbittorrent/src/qbittorrent.hardening.spec.ts @@ -0,0 +1,346 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { QBittorrentClient } from './qbittorrent.client.js'; +import { parseQBittorrentLogLine } from './qbittorrent.parser.js'; +import { QBittorrentProvider } from './qbittorrent.provider.js'; +import { redactSecrets, trackerHost } from './qbittorrent.redact.js'; + +const mockFetch = vi.fn(); + +let counter = 0; +function uniqueUrl(): string { + counter += 1; + return `http://hard-${counter}.local:8080`; +} + +function withCookies(body: string, cookies: string[], status = 200): Response { + const headers = new Headers(); + for (const cookie of cookies) headers.append('set-cookie', cookie); + return new Response(body, { status, headers }); +} + +function cookieOfCall(index: number): string | undefined { + const init = mockFetch.mock.calls[index]?.[1] as RequestInit | undefined; + return (init?.headers as Record | undefined)?.['Cookie']; +} + +describe('redactSecrets / trackerHost', () => { + it.each([ + ['https://t.example/announce?passkey=abc123&x=1', 'https://t.example/announce?passkey=***&x=1'], + ['http://t/a?authkey=ZZ&torrent_pass=YY', 'http://t/a?authkey=***&torrent_pass=***'], + ['http://t/a?pk=secret', 'http://t/a?pk=***'], + [ + 'https://t.example/0123456789abcdef0123456789abcdef/announce', + 'https://t.example/***/announce', + ], + ['nothing secret here', 'nothing secret here'], + ])('masks %s', (input, expected) => { + expect(redactSecrets(input)).toBe(expected); + }); + + it('reduces tracker URLs to the host and tolerates invalid input', () => { + expect(trackerHost('https://tracker.example:2710/abc/announce?passkey=1')).toBe( + 'tracker.example:2710' + ); + expect(trackerHost('not a url')).toBeUndefined(); + expect(trackerHost('')).toBeUndefined(); + expect(trackerHost(undefined)).toBeUndefined(); + }); +}); + +describe('QBittorrentClient hardening', () => { + beforeEach(() => { + vi.stubGlobal('fetch', mockFetch); + mockFetch.mockReset(); + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-10-10T12:00:00Z')); + }); + afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + it('does not treat an unexpected 200 login body as bad credentials and does not escalate', async () => { + const base = uniqueUrl(); + const creds = { username: 'admin', secret: 'pw' }; + mockFetch.mockResolvedValueOnce(new Response('proxy login')); + await expect(new QBittorrentClient(base, creds).getVersion()).rejects.toThrow( + 'Unexpected login response — is this a qBittorrent WebUI (or behind an auth proxy)?' + ); + + // no cooldown: the next attempt reaches the server again + mockFetch + .mockResolvedValueOnce(withCookies('Ok.', ['SID=x'])) + .mockResolvedValueOnce(new Response('v5.2.4')); + await expect(new QBittorrentClient(base, creds).getVersion()).resolves.toBe('v5.2.4'); + }); + + it('only counts a trimmed "Fails." body as a credential failure', async () => { + mockFetch.mockResolvedValueOnce(new Response('Fails.\n')); + await expect( + new QBittorrentClient(uniqueUrl(), { username: 'a', secret: 'b' }).getVersion() + ).rejects.toThrow('Invalid qBittorrent username or password'); + }); + + it('uses manual redirect handling and reports the redirect target origin', async () => { + mockFetch.mockResolvedValueOnce( + new Response(null, { status: 302, headers: { location: 'https://qb.example/login?next=/' } }) + ); + const client = new QBittorrentClient(uniqueUrl(), { secret: '' }); + + await expect(client.getVersion()).rejects.toThrow( + 'URL redirects to https://qb.example — configure the final URL' + ); + expect((mockFetch.mock.calls[0]?.[1] as RequestInit).redirect).toBe('manual'); + }); + + it('reports a redirect on login too', async () => { + mockFetch.mockResolvedValueOnce( + new Response(null, { status: 301, headers: { location: 'https://secure.example/' } }) + ); + await expect( + new QBittorrentClient(uniqueUrl(), { username: 'a', secret: 'b' }).getVersion() + ).rejects.toThrow('URL redirects to https://secure.example'); + }); + + it('deletes cookies with a past Expires but keeps future ones', async () => { + mockFetch + .mockResolvedValueOnce(withCookies('Ok.', ['a=1', 'b=2', 'c=3'])) + .mockResolvedValueOnce( + withCookies('v5.2.4', [ + 'a=9; Expires=Thu, 01 Jan 1970 00:00:00 GMT', + 'b=9; Expires=Wed, 11 Oct 2028 12:00:00 GMT', + 'c=9; Max-Age=-5', + ]) + ) + .mockResolvedValueOnce(new Response('2.15.1')); + const client = new QBittorrentClient(uniqueUrl(), { username: 'admin', secret: 'pw' }); + await client.getVersion(); + await client.getWebApiVersion(); + + expect(cookieOfCall(2)).toBe('b=9'); + }); + + it('treats Max-Age with spaces or a large positive value correctly', async () => { + mockFetch + .mockResolvedValueOnce(withCookies('Ok.', ['a=1; Max-Age = 3600'])) + .mockResolvedValueOnce(new Response('v5.2.4')); + await new QBittorrentClient(uniqueUrl(), { username: 'u', secret: 'p' }).getVersion(); + expect(cookieOfCall(1)).toBe('a=1'); + }); + + it('parses a pathological Max-Age attribute quickly', async () => { + const evil = `a=1; Max-Age=${'0 '.repeat(20000)}x`; + mockFetch + .mockResolvedValueOnce(withCookies('Ok.', [evil])) + .mockResolvedValueOnce(new Response('v5.2.4')); + const started = Date.now(); + await new QBittorrentClient(uniqueUrl(), { username: 'u', secret: 'p' }).getVersion(); + expect(Date.now() - started).toBeLessThan(500); + }); +}); + +describe('QBittorrentProvider hardening', () => { + let provider: QBittorrentProvider; + let baseUrl: string; + + beforeEach(() => { + vi.stubGlobal('fetch', mockFetch); + mockFetch.mockReset(); + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-10-10T12:00:00Z')); + provider = new QBittorrentProvider(); + baseUrl = uniqueUrl(); + }); + afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + const respondMixed = (json: Record): void => { + mockFetch.mockImplementation((url: unknown) => { + const path = new URL(String(url)).pathname.replace('/api/v2', ''); + if (path === '/app/version') return Promise.resolve(new Response('v5.2.4')); + if (path === '/app/webapiVersion') return Promise.resolve(new Response('2.15.1')); + const value = json[path]; + return Promise.resolve( + value === undefined + ? new Response('nf', { status: 404 }) + : new Response(JSON.stringify(value)) + ); + }); + }; + + it('keeps only the tracker host and drops the save path from torrent metadata', async () => { + respondMixed({ + '/torrents/info': [ + { + hash: 'h', + name: 'N', + state: 'error', + save_path: '/secret/path', + tracker: 'https://priv.example/ann/0123456789abcdef0123456789abcdef?passkey=abc', + }, + ], + '/transfer/info': { connection_status: 'connected' }, + }); + await provider.connect({ url: baseUrl, apiKey: '', logPath: '/logs' }); + + const [activity] = await provider.getActivity(); + + expect(activity?.metadata?.['tracker']).toBe('priv.example'); + expect(activity?.metadata).not.toHaveProperty('savePath'); + expect(JSON.stringify(activity)).not.toContain('abc'); + expect(JSON.stringify(activity)).not.toContain('secret/path'); + }); + + it('redacts secrets in API log messages', async () => { + respondMixed({ + '/log/main': [ + { + id: 1, + message: 'Tracker error "https://t/announce?passkey=SECRETKEY&a=b"', + timestamp: 1_790_000_000, + type: 4, + }, + ], + '/torrents/info': [], + '/transfer/info': { connection_status: 'connected' }, + }); + await provider.connect({ url: baseUrl, apiKey: '' }); + + const [activity] = await provider.getActivity(); + + expect(activity?.overview).toContain('passkey=***'); + expect(JSON.stringify(activity)).not.toContain('SECRETKEY'); + }); + + it('emits connection activity on state change and once per day only', async () => { + let status = 'connected'; + mockFetch.mockImplementation((url: unknown) => { + const path = new URL(String(url)).pathname.replace('/api/v2', ''); + if (path === '/app/version') return Promise.resolve(new Response('v5.2.4')); + if (path === '/app/webapiVersion') return Promise.resolve(new Response('2.15.1')); + if (path === '/transfer/info') + return Promise.resolve(new Response(JSON.stringify({ connection_status: status }))); + return Promise.resolve(new Response('[]')); + }); + await provider.connect({ url: baseUrl, apiKey: '', logPath: '/logs' }); + + expect(await provider.getActivity()).toEqual([]); + status = 'firewalled'; + expect(await provider.getActivity()).toHaveLength(1); + expect(await provider.getActivity()).toHaveLength(0); + status = 'disconnected'; + expect(await provider.getActivity()).toHaveLength(1); + status = 'connected'; + expect(await provider.getActivity()).toHaveLength(0); + status = 'disconnected'; + // recovered and failed again the same day: same id, deduped downstream, but a state change + const again = await provider.getActivity(); + expect(again).toHaveLength(1); + expect(await provider.getActivity()).toHaveLength(0); + vi.advanceTimersByTime(25 * 60 * 60_000); + expect(await provider.getActivity()).toHaveLength(1); + }); + + it('treats ids at or below the cursor as an id regression and filters by time', async () => { + const responses: unknown[][] = [ + [{ id: 500, message: 'old', timestamp: 1_790_000_000, type: 4 }], + [ + { id: 3, message: 'stale buffer entry', timestamp: 1_789_999_000, type: 4 }, + { id: 4, message: 'fresh after restart', timestamp: 1_790_000_100, type: 8 }, + ], + [], + ]; + mockFetch.mockImplementation((url: unknown) => { + const path = new URL(String(url)).pathname.replace('/api/v2', ''); + if (path === '/app/version') return Promise.resolve(new Response('v5.2.4')); + if (path === '/app/webapiVersion') return Promise.resolve(new Response('2.15.1')); + if (path === '/log/main') + return Promise.resolve(new Response(JSON.stringify(responses.shift() ?? []))); + if (path === '/transfer/info') + return Promise.resolve(new Response('{"connection_status":"connected"}')); + return Promise.resolve(new Response('[]')); + }); + await provider.connect({ url: baseUrl, apiKey: '' }); + + await provider.getActivity(); + const activities = await provider.getActivity(); + expect(activities.map((a) => a.overview)).toEqual(['fresh after restart']); + + await provider.getActivity(); + const lastCall = mockFetch.mock.calls + .map(([url]) => String(url)) + .filter((url) => url.includes('/log/main')) + .at(-1); + expect(lastCall).toContain('last_known_id=4'); + }); +}); + +describe('redactSecrets - encoded keys and opaque tokens', () => { + it.each([ + ['https://t/a?passkey%3Dsecret123&x=1', 'https://t/a?passkey%3D***&x=1'], + ['https://t/a%3Fpasskey%3Dsecret123%26x%3D1', 'https://t/a%3Fpasskey%3D***%26x%3D1'], + [ + 'url=https%3A%2F%2Ft%2Fa%3Fauthkey%3DSECRET%26z', + 'url=https%3A%2F%2Ft%2Fa%3Fauthkey%3D***%26z', + ], + ['https://t/a?PassKey=Secret&b=2', 'https://t/a?PassKey=***&b=2'], + ['https://t/announce/ABCDEFGHIJKLMNOPQRSTUV', 'https://t/announce/***'], + ['https://t/ABCDEFGHIJKLMNOPQRSTUV/announce', 'https://t/***/announce'], + ['https://t/a1b2c3d4e5f6g7h8i9j0k1l2/scrape', 'https://t/***/scrape'], + ['https%3A%2F%2Ft%2Fannounce%2Fabcdefghij0123456789xyz', 'https%3A%2F%2Ft%2Fannounce/***'], + ['https://t/announce', 'https://t/announce'], + ['https://t/announce/short', 'https://t/announce/short'], + ['kpk=notakey and npk=x', 'kpk=notakey and npk=x'], + ])('masks %s', (input, expected) => { + expect(redactSecrets(input)).toBe(expected); + }); + + it('is applied to API log messages and file-parsed lines', () => { + const entry = parseQBittorrentLogLine( + '(W) 2026-10-10T12:00:00 - Tracker "https://t/announce/ABCDEFGHIJKLMNOPQRSTUV?passkey%3Dzzz" failed' + ); + expect(entry?.message).not.toContain('ABCDEFGHIJ'); + expect(entry?.message).not.toContain('zzz'); + expect(entry?.raw).not.toContain('zzz'); + }); +}); + +describe('QBittorrentProvider sync cursor flags', () => { + beforeEach(() => { + vi.stubGlobal('fetch', mockFetch); + mockFetch.mockReset(); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('keeps synthetic rows out of the sync cursor but lets real log events advance it', async () => { + mockFetch.mockImplementation((url: unknown) => { + const path = new URL(String(url)).pathname.replace('/api/v2', ''); + const bodies: Record = { + '/app/version': 'v5.2.4', + '/app/webapiVersion': '2.15.1', + '/log/main': JSON.stringify([ + { id: 1, message: 'real event', timestamp: 1_790_000_000, type: 4 }, + ]), + '/torrents/info': JSON.stringify([{ hash: 'h', name: 'N', state: 'error' }]), + '/transfer/info': JSON.stringify({ connection_status: 'firewalled' }), + }; + return Promise.resolve( + new Response(bodies[path] ?? 'nf', { status: bodies[path] ? 200 : 404 }) + ); + }); + const provider = new QBittorrentProvider(); + await provider.connect({ url: uniqueUrl(), apiKey: '' }); + + const activities = await provider.getActivity(); + + const byType = Object.fromEntries(activities.map((a) => [a.type, a.advancesSyncCursor])); + expect(byType['torrent_error']).toBe(false); + expect(byType['connection_status']).toBe(false); + expect(byType['log_warning']).toBeUndefined(); + }); +}); diff --git a/packages/provider-qbittorrent/src/qbittorrent.login-response.spec.ts b/packages/provider-qbittorrent/src/qbittorrent.login-response.spec.ts new file mode 100644 index 0000000..304ff26 --- /dev/null +++ b/packages/provider-qbittorrent/src/qbittorrent.login-response.spec.ts @@ -0,0 +1,162 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { QBittorrentClient } from './qbittorrent.client.js'; +import { QBittorrentProvider } from './qbittorrent.provider.js'; +import { resetQBittorrentState } from './qbittorrent.state.js'; + +const mockFetch = vi.fn(); + +let counter = 0; +function uniqueUrl(): string { + counter += 1; + return `http://login-resp-${counter}.local:8080`; +} + +function cookieOfCall(index: number): string | undefined { + const init = mockFetch.mock.calls[index]?.[1] as RequestInit | undefined; + return (init?.headers as Record | undefined)?.['Cookie']; +} + +/** qBittorrent 5.2.x: 204 No Content, empty body, session cookie */ +function login204(): Response { + return new Response(null, { + status: 204, + headers: { 'set-cookie': 'QBT_SID_8080=abc123; HttpOnly; SameSite=Strict; path=/' }, + }); +} + +describe('login response variants', () => { + beforeEach(() => { + vi.stubGlobal('fetch', mockFetch); + mockFetch.mockReset(); + resetQBittorrentState(); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('accepts 204 No Content with a session cookie (qBittorrent 5.2.3) and sends the cookie', async () => { + mockFetch.mockResolvedValueOnce(login204()).mockResolvedValueOnce(new Response('v5.2.3')); + const client = new QBittorrentClient(uniqueUrl(), { username: 'admin', secret: 'pw' }); + + await expect(client.getVersion()).resolves.toBe('v5.2.3'); + + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(cookieOfCall(1)).toBe('QBT_SID_8080=abc123'); + }); + + it('reuses the 204 session without logging in again', async () => { + mockFetch + .mockResolvedValueOnce(login204()) + .mockResolvedValueOnce(new Response('v5.2.3')) + .mockResolvedValueOnce(new Response('2.15.1')); + const client = new QBittorrentClient(uniqueUrl(), { username: 'admin', secret: 'pw' }); + + await client.getVersion(); + await client.getWebApiVersion(); + + expect( + mockFetch.mock.calls.filter(([url]) => String(url).endsWith('/auth/login')) + ).toHaveLength(1); + expect(cookieOfCall(2)).toBe('QBT_SID_8080=abc123'); + }); + + it('accepts 200 with an empty body and a session cookie', async () => { + mockFetch + .mockResolvedValueOnce(new Response('', { status: 200, headers: { 'set-cookie': 'SID=x' } })) + .mockResolvedValueOnce(new Response('v5.2.4')); + const client = new QBittorrentClient(uniqueUrl(), { username: 'admin', secret: 'pw' }); + + await expect(client.getVersion()).resolves.toBe('v5.2.4'); + expect(cookieOfCall(1)).toBe('SID=x'); + }); + + it('still accepts 200 "Ok." with a cookie', async () => { + mockFetch + .mockResolvedValueOnce(new Response('Ok.', { headers: { 'set-cookie': 'SID=y' } })) + .mockResolvedValueOnce(new Response('v4.6.7')); + const client = new QBittorrentClient(uniqueUrl(), { username: 'admin', secret: 'pw' }); + + await expect(client.getVersion()).resolves.toBe('v4.6.7'); + }); + + it('rejects an empty 2xx login response without a cookie as unexpected (no escalation)', async () => { + const base = uniqueUrl(); + const creds = { username: 'admin', secret: 'pw' }; + mockFetch.mockResolvedValueOnce(new Response(null, { status: 204 })); + await expect(new QBittorrentClient(base, creds).getVersion()).rejects.toThrow( + 'Unexpected login response' + ); + + // no cooldown: the next attempt reaches the server + mockFetch.mockResolvedValueOnce(login204()).mockResolvedValueOnce(new Response('v5.2.3')); + await expect(new QBittorrentClient(base, creds).getVersion()).resolves.toBe('v5.2.3'); + }); + + it('requires a fresh cookie: a stale jar entry plus an empty 204 is not a successful login', async () => { + const base = uniqueUrl(); + const creds = { username: 'admin', secret: 'pw' }; + const client = new QBittorrentClient(base, creds); + (client as unknown as { jar: Map }).jar.set('QBT_SID_8080', 'stale'); + mockFetch.mockResolvedValueOnce(new Response(null, { status: 204 })); + + await expect((client as unknown as { login: () => Promise }).login()).rejects.toThrow( + 'Unexpected login response' + ); + + // the stale cookie was not sent with the login request and no cooldown was recorded + expect(cookieOfCall(0)).toBeUndefined(); + mockFetch.mockResolvedValueOnce(login204()).mockResolvedValueOnce(new Response('v5.2.3')); + await expect(new QBittorrentClient(base, creds).getVersion()).resolves.toBe('v5.2.3'); + }); + + it('still treats 200 "Fails." as bad credentials with cooldown escalation', async () => { + const base = uniqueUrl(); + const creds = { username: 'admin', secret: 'wrong' }; + mockFetch.mockResolvedValueOnce(new Response('Fails.')); + await expect(new QBittorrentClient(base, creds).getVersion()).rejects.toThrow( + 'Invalid qBittorrent username or password' + ); + await expect(new QBittorrentClient(base, creds).getVersion()).rejects.toThrow(/retrying in/); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('still rejects a non-empty unexpected 2xx body without escalation', async () => { + mockFetch.mockResolvedValueOnce(new Response('proxy', { status: 200 })); + await expect( + new QBittorrentClient(uniqueUrl(), { username: 'a', secret: 'b' }).getVersion() + ).rejects.toThrow('Unexpected login response'); + }); + + it('does not treat a 204 login with a body-less non-2xx as success', async () => { + mockFetch.mockResolvedValueOnce(new Response(null, { status: 304 })); + await expect( + new QBittorrentClient(uniqueUrl(), { username: 'a', secret: 'b' }).getVersion() + ).rejects.toThrow(); + }); + + it('API key and bypass paths accept any 2xx without requiring a body', async () => { + mockFetch.mockResolvedValueOnce(new Response(null, { status: 204 })); + await expect(new QBittorrentClient(uniqueUrl(), { secret: 'key' }).getVersion()).resolves.toBe( + '' + ); + mockFetch.mockResolvedValueOnce(new Response(null, { status: 204 })); + await expect(new QBittorrentClient(uniqueUrl(), { secret: '' }).getVersion()).resolves.toBe(''); + }); + + it('connects and tests the connection through the provider with a 204 login', async () => { + mockFetch.mockImplementation((url: unknown) => { + const path = new URL(String(url)).pathname; + if (path.endsWith('/auth/login')) return Promise.resolve(login204()); + return Promise.resolve(new Response(path.endsWith('version') ? 'v5.2.3' : '2.15.1')); + }); + const provider = new QBittorrentProvider(); + + await provider.connect({ url: uniqueUrl(), apiKey: 'pw', username: 'admin' }); + + await expect(provider.testConnection()).resolves.toEqual({ + connected: true, + serverInfo: { name: 'qBittorrent', version: '5.2.3', id: 'qbittorrent-webapi-2.15.1' }, + }); + }); +}); diff --git a/packages/provider-qbittorrent/src/qbittorrent.parser.spec.ts b/packages/provider-qbittorrent/src/qbittorrent.parser.spec.ts new file mode 100644 index 0000000..34c44f6 --- /dev/null +++ b/packages/provider-qbittorrent/src/qbittorrent.parser.spec.ts @@ -0,0 +1,177 @@ +import { performance } from 'node:perf_hooks'; + +import { describe, it, expect } from 'vitest'; + +import { + QBITTORRENT_LOG_FILE_CONFIG, + isQBittorrentLogContinuation, + parseQBittorrentLogLine, + parseQBittorrentLogLineWithContext, +} from './qbittorrent.parser.js'; + +describe('parseQBittorrentLogLine', () => { + it('parses a normal line as info', () => { + const entry = parseQBittorrentLogLine('(N) 2026-10-10T12:00:01 - qBittorrent v5.2.4 started'); + expect(entry).not.toBeNull(); + expect(entry?.level).toBe('info'); + expect(entry?.message).toBe('qBittorrent v5.2.4 started'); + expect(entry?.timestamp).toEqual(new Date('2026-10-10T12:00:01')); + expect(entry?.source).toBe('qBittorrent'); + expect(entry?.metadata?.['qbLevel']).toBe('N'); + }); + + it('parses an info line as info', () => { + const entry = parseQBittorrentLogLine( + '(I) 2026-10-10T12:00:02 - Trying to listen on the following list of IP addresses: "0.0.0.0:6881,[::]:6881"' + ); + expect(entry?.level).toBe('info'); + expect(entry?.message).toContain('0.0.0.0:6881'); + }); + + it('parses a warning line as warn and extracts the torrent name', () => { + const entry = parseQBittorrentLogLine( + '(W) 2026-10-10T12:01:15 - File error alert. Torrent: "Some.Torrent.Name". File: "/downloads/x.mkv". Reason: "x.mkv file_open (/downloads/x.mkv) error: Permission denied"' + ); + expect(entry?.level).toBe('warn'); + expect(entry?.metadata?.['torrent']).toBe('Some.Torrent.Name'); + }); + + it('parses a critical line as error', () => { + const entry = parseQBittorrentLogLine( + '(C) 2026-10-10T12:02:30 - I/O error. Torrent: "Foo". Reason: "No space left on device"' + ); + expect(entry?.level).toBe('error'); + expect(entry?.metadata?.['torrent']).toBe('Foo'); + }); + + it('parses WebUI login failures', () => { + const entry = parseQBittorrentLogLine( + '(W) 2026-10-10T12:03:00 - WebUI login failure. IP: 172.18.0.5, username: admin' + ); + expect(entry?.level).toBe('warn'); + expect(entry?.message).toBe('WebUI login failure. IP: 172.18.0.5, username: admin'); + expect(entry?.metadata).not.toHaveProperty('torrent'); + }); + + it('keeps separators inside the message', () => { + const entry = parseQBittorrentLogLine('(I) 2026-10-10T12:00:02 - a - b - c'); + expect(entry?.message).toBe('a - b - c'); + }); + + it('tolerates a trailing carriage return', () => { + const entry = parseQBittorrentLogLine('(I) 2026-10-10T12:00:02 - hello\r'); + expect(entry?.message).toBe('hello'); + expect(entry?.raw).toBe('(I) 2026-10-10T12:00:02 - hello'); + }); + + it.each(['', ' ', 'random text', '(X) 2026-10-10T12:00:02 - bad level', '(I) 2026-10-10 - x'])( + 'returns null for %j', + (line) => { + expect(parseQBittorrentLogLine(line)).toBeNull(); + } + ); +}); + +describe('parseQBittorrentLogLineWithContext', () => { + const context = { continuationLines: [], filePath: '/x/qbittorrent.log', lineNumber: 1 }; + + it('returns the entry without continuation flags', () => { + const result = parseQBittorrentLogLineWithContext('(C) 2026-10-10T12:02:30 - boom', context); + expect(result.entry?.level).toBe('error'); + expect(result.isContinuation).toBe(false); + expect(result.previousComplete).toBe(false); + }); + + it('returns a null entry for unparseable lines', () => { + const result = parseQBittorrentLogLineWithContext('garbage', context); + expect(result).toEqual({ entry: null, isContinuation: false, previousComplete: false }); + }); + + it('never reports continuation lines', () => { + expect(isQBittorrentLogContinuation(' at something')).toBe(false); + }); +}); + +describe('QBITTORRENT_LOG_FILE_CONFIG', () => { + it('has docker paths including /qbittorrent-logs', () => { + expect(QBITTORRENT_LOG_FILE_CONFIG.defaultPaths.docker).toContain('/qbittorrent-logs'); + }); + + it('matches only the live log file', () => { + expect(QBITTORRENT_LOG_FILE_CONFIG.filePatterns).toEqual(['qbittorrent.log']); + expect(QBITTORRENT_LOG_FILE_CONFIG.rotatesDaily).toBe(false); + expect(QBITTORRENT_LOG_FILE_CONFIG.encoding).toBe('utf-8'); + }); +}); + +describe('parseQBittorrentLogLine - adversarial input', () => { + const header = '(W) 2026-10-10T12:00:02 - '; + + it('handles a 100k character message quickly', () => { + const line = header + 'x'.repeat(100_000); + const start = performance.now(); + const entry = parseQBittorrentLogLine(line); + expect(performance.now() - start).toBeLessThan(50); + expect(entry?.message).toHaveLength(100_000); + }); + + it('rejects a 100k character garbage line quickly', () => { + const start = performance.now(); + expect(parseQBittorrentLogLine('(' + 'N'.repeat(100_000))).toBeNull(); + expect(parseQBittorrentLogLine(' '.repeat(100_000))).toBeNull(); + expect(parseQBittorrentLogLine('(W) 2026-10-10T12:00:02-' + ' - '.repeat(30_000))).toBeNull(); + expect(performance.now() - start).toBeLessThan(50); + }); + + it('does not backtrack catastrophically on an unterminated torrent name', () => { + const line = header + 'Torrent: "' + 'a'.repeat(100_000); + const start = performance.now(); + const entry = parseQBittorrentLogLine(line); + expect(performance.now() - start).toBeLessThan(50); + expect(entry?.metadata).not.toHaveProperty('torrent'); + }); + + it('handles many torrent markers without a closing quote quickly', () => { + const line = header + 'Torrent: "x '.repeat(8_000); + const start = performance.now(); + parseQBittorrentLogLine(line); + expect(performance.now() - start).toBeLessThan(50); + }); + + it('only extracts the first quoted torrent name', () => { + const entry = parseQBittorrentLogLine(header + 'Torrent: "A", Torrent: "B"'); + expect(entry?.metadata?.['torrent']).toBe('A'); + }); + + it('accepts an empty message', () => { + expect(parseQBittorrentLogLine('(I) 2026-10-10T12:00:02 - ')?.message).toBe(''); + }); + + it('rejects lines with embedded newlines in the middle (no multi-line matching)', () => { + expect(parseQBittorrentLogLine(header + 'first\n(I) 2026-10-10T12:00:03 - second')).toBeNull(); + }); + + it('treats a line with a leading BOM or whitespace as unparseable', () => { + expect(parseQBittorrentLogLine('' + header + 'x')).toBeNull(); + expect(parseQBittorrentLogLine(' ' + header + 'x')).toBeNull(); + }); + + it('keeps html/script and control characters verbatim in the message', () => { + const entry = parseQBittorrentLogLine(header + '\u0000\u001b[31m'); + expect(entry?.message).toBe('\u0000\u001b[31m'); + }); + + it('returns null (not an Invalid Date entry) for an impossible timestamp', () => { + expect(parseQBittorrentLogLine('(I) 2026-13-45T99:99:99 - x')).toBeNull(); + }); + + it('masks passkeys and authkeys in messages and the raw line', () => { + const entry = parseQBittorrentLogLine( + '(W) 2026-10-10T12:00:00 - Tracker error. URL: "https://t.example/announce?passkey=abc123&x=1" authkey=zzz' + ); + expect(entry?.message).not.toContain('abc123'); + expect(entry?.message).not.toContain('zzz'); + expect(entry?.message).toContain('passkey=***'); + expect(entry?.raw).not.toContain('abc123'); + }); +}); diff --git a/packages/provider-qbittorrent/src/qbittorrent.parser.ts b/packages/provider-qbittorrent/src/qbittorrent.parser.ts new file mode 100644 index 0000000..8ed2918 --- /dev/null +++ b/packages/provider-qbittorrent/src/qbittorrent.parser.ts @@ -0,0 +1,95 @@ +import { redactSecrets } from './qbittorrent.redact.js'; + +import type { + CorrelationPattern, + LogFileConfig, + LogLevel, + LogParseContext, + LogParseResult, + ParsedLogEntry, +} from '@logarr/core'; + +/** + * qBittorrent file log format (qbittorrent.log): + * (N) 2026-10-10T12:00:01 - message + * N = normal, I = info, W = warning, C = critical. Timestamps are local time without timezone. + */ +const LOG_LINE_REGEX = /^\((N|I|W|C)\) (\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}) - (.*)$/; + +const LEVEL_MAP: Record = { + N: 'info', + I: 'info', + W: 'warn', + C: 'error', +}; + +const TORRENT_REGEX = /Torrent: "(.+?)"/; + +export const QBITTORRENT_LOG_FILE_CONFIG: LogFileConfig = { + defaultPaths: { + docker: ['/qbittorrent-logs', '/config/qBittorrent/logs'], + linux: [ + '~/.local/share/qBittorrent/logs', + '/var/lib/qbittorrent/.local/share/qBittorrent/logs', + ], + windows: ['%LOCALAPPDATA%\\qBittorrent\\logs'], + macos: ['~/Library/Application Support/qBittorrent/logs'], + }, + // Rotated backups (qbittorrent.log.bak*) are intentionally not matched to avoid duplicates + filePatterns: ['qbittorrent.log'], + encoding: 'utf-8', + rotatesDaily: false, +}; + +export const QBITTORRENT_CORRELATION_PATTERNS: readonly CorrelationPattern[] = [ + { name: 'ip', pattern: /IP:\s*([0-9a-f:.]+)/i }, +]; + +/** + * Parse a single qbittorrent.log line. Returns null for non-matching lines. + */ +export function parseQBittorrentLogLine(line: string): ParsedLogEntry | null { + const trimmed = line.replace(/\r$/, ''); + const match = LOG_LINE_REGEX.exec(trimmed); + if (!match) { + return null; + } + + const [, letter = '', time = '', rawMessage = ''] = match; + const timestamp = new Date(time); + if (Number.isNaN(timestamp.getTime())) { + return null; + } + const message = redactSecrets(rawMessage); + const torrent = TORRENT_REGEX.exec(message)?.[1]; + + return { + timestamp, + level: LEVEL_MAP[letter] ?? 'info', + message, + source: 'qBittorrent', + raw: redactSecrets(trimmed), + metadata: { + qbLevel: letter, + ...(torrent !== undefined && { torrent }), + }, + }; +} + +/** + * qBittorrent log entries are single-line, so there is never a continuation. + */ +export function parseQBittorrentLogLineWithContext( + line: string, + _context: LogParseContext +): LogParseResult { + return { + entry: parseQBittorrentLogLine(line), + isContinuation: false, + previousComplete: false, + }; +} + +export function isQBittorrentLogContinuation(_line: string): boolean { + return false; +} diff --git a/packages/provider-qbittorrent/src/qbittorrent.provider.spec.ts b/packages/provider-qbittorrent/src/qbittorrent.provider.spec.ts new file mode 100644 index 0000000..eae26ee --- /dev/null +++ b/packages/provider-qbittorrent/src/qbittorrent.provider.spec.ts @@ -0,0 +1,448 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { QBITTORRENT_LOG_FILE_CONFIG } from './qbittorrent.parser.js'; +import { QBittorrentProvider } from './qbittorrent.provider.js'; + +const mockFetch = vi.fn(); + +let counter = 0; +function uniqueUrl(): string { + counter += 1; + return `http://qbp-${counter}.local:8080`; +} + +/** Routes unauthenticated (bypass mode) requests by path */ +function routeFetch(routes: Record Response>): void { + mockFetch.mockImplementation((url: unknown) => { + const path = new URL(String(url)).pathname.replace('/api/v2', ''); + const route = routes[path]; + return Promise.resolve(route ? route() : new Response('not found', { status: 404 })); + }); +} + +const text = + (body: string, status = 200) => + () => + new Response(body, { status }); +const json = + (body: unknown, status = 200) => + () => + new Response(JSON.stringify(body), { status }); + +function logCalls(): string[] { + return mockFetch.mock.calls + .map(([url]) => String(url)) + .filter((url) => url.includes('/log/main')); +} + +describe('QBittorrentProvider', () => { + let provider: QBittorrentProvider; + let baseUrl: string; + + beforeEach(() => { + vi.stubGlobal('fetch', mockFetch); + mockFetch.mockReset(); + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-10-10T12:00:00Z')); + provider = new QBittorrentProvider(); + baseUrl = uniqueUrl(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + describe('identity', () => { + it('has correct id and name', () => { + expect(provider.id).toBe('qbittorrent'); + expect(provider.name).toBe('qBittorrent'); + }); + + it('has the expected capabilities', () => { + expect(provider.capabilities).toEqual({ + supportsRealTimeLogs: false, + supportsActivityLog: true, + supportsSessions: false, + supportsWebhooks: false, + supportsPlaybackHistory: false, + supportsUsername: true, + apiKeyOptional: true, + }); + }); + + it('returns empty sessions and users', async () => { + await expect(provider.getSessions()).resolves.toEqual([]); + await expect(provider.getUsers()).resolves.toEqual([]); + }); + + it('returns the log file config and parses lines', () => { + expect(provider.getLogFileConfig()).toBe(QBITTORRENT_LOG_FILE_CONFIG); + expect(provider.parseLogLine('(C) 2026-10-10T12:02:30 - boom')?.level).toBe('error'); + expect(provider.isLogContinuation('x')).toBe(false); + expect( + provider.parseLogLineWithContext('(W) 2026-10-10T12:02:30 - hmm', { + continuationLines: [], + filePath: 'f', + lineNumber: 1, + }).entry?.level + ).toBe('warn'); + expect(provider.getCorrelationPatterns().length).toBeGreaterThan(0); + }); + }); + + describe('getLogPaths', () => { + it('returns defaults including /qbittorrent-logs', async () => { + const paths = await provider.getLogPaths(); + expect(paths).toContain('/qbittorrent-logs'); + expect(paths).toContain('/config/qBittorrent/logs'); + }); + + it('honours a configured logPath', async () => { + routeFetch({ '/app/version': text('v5.2.4'), '/app/webapiVersion': text('2.15.1') }); + await provider.connect({ url: baseUrl, apiKey: '', logPath: '/custom/logs' }); + await expect(provider.getLogPaths()).resolves.toEqual(['/custom/logs']); + }); + }); + + describe('connect / testConnection', () => { + it('connects and reports server info (5.x)', async () => { + routeFetch({ '/app/version': text('v5.2.4'), '/app/webapiVersion': text('2.15.1\n') }); + await provider.connect({ url: baseUrl, apiKey: '' }); + + await expect(provider.testConnection()).resolves.toEqual({ + connected: true, + serverInfo: { name: 'qBittorrent', version: '5.2.4', id: 'qbittorrent-webapi-2.15.1' }, + }); + }); + + it('supports 4.x version strings', async () => { + routeFetch({ '/app/version': text('v4.6.7'), '/app/webapiVersion': text('2.9.3') }); + await provider.connect({ url: baseUrl, apiKey: '' }); + const info = await provider.getServerInfo(); + expect(info.version).toBe('4.6.7'); + }); + + it('rejects connect with the failure message', async () => { + routeFetch({ '/app/version': text('Forbidden', 403) }); + await expect(provider.connect({ url: baseUrl, apiKey: '' })).rejects.toThrow( + 'qBittorrent requires authentication' + ); + }); + + it('passes the username through to login', async () => { + mockFetch.mockImplementation((url: unknown) => { + const path = new URL(String(url)).pathname; + if (path.endsWith('/auth/login')) { + return Promise.resolve( + new Response('Ok.', { headers: { 'set-cookie': 'QBT_SID_8080=zzz; path=/' } }) + ); + } + return Promise.resolve(new Response(path.endsWith('version') ? 'v5.2.4' : '2.15.1')); + }); + await provider.connect({ url: baseUrl, apiKey: 'pw', username: 'admin' }); + const login = mockFetch.mock.calls[0]; + expect((login?.[1] as RequestInit).body).toBe('username=admin&password=pw'); + }); + + it('throws when used before connect', async () => { + await expect(provider.getServerInfo()).rejects.toThrow('Not connected'); + await provider.disconnect(); + }); + }); + + describe('getActivity - log messages', () => { + const connect = async (logPath?: string): Promise => { + await provider.connect({ url: baseUrl, apiKey: '', logPath }); + }; + const base = { + '/app/version': text('v5.2.4'), + '/app/webapiVersion': text('2.15.1'), + '/torrents/info': json([]), + '/transfer/info': json({ connection_status: 'connected' }), + }; + + it('ingests only warning and critical entries with stable ids and severities', async () => { + routeFetch({ + ...base, + '/log/main': json([ + { id: 1, message: 'normal', timestamp: 1_790_000_000, type: 1 }, + { id: 2, message: 'info', timestamp: 1_790_000_001, type: 2 }, + { id: 3, message: 'File error alert', timestamp: 1_790_000_002, type: 4 }, + { id: 4, message: 'I/O error', timestamp: 1_790_000_003, type: 8 }, + ]), + }); + await connect(); + + const activities = await provider.getActivity(); + + expect(activities.map((a) => a.id)).toEqual([ + 'qbittorrent-log-1790000002-3', + 'qbittorrent-log-1790000003-4', + ]); + expect(activities.map((a) => a.severity)).toEqual(['warn', 'error']); + expect(activities[0]?.timestamp).toEqual(new Date(1_790_000_002_000)); + expect(activities.map((a) => a.type)).toEqual(['log_warning', 'log_critical']); + expect(activities[1]?.overview).toBe('I/O error'); + }); + + it('normalises millisecond timestamps from qBittorrent < 4.5', async () => { + routeFetch({ + ...base, + '/log/main': json([{ id: 7, message: 'old', timestamp: 1_790_000_002_000, type: 4 }]), + }); + await connect(); + + const [activity] = await provider.getActivity(); + + expect(activity?.timestamp).toEqual(new Date(1_790_000_002_000)); + expect(activity?.id).toBe('qbittorrent-log-1790000002-7'); + }); + + it('requests only warnings and criticals and then continues from the last id', async () => { + let call = 0; + routeFetch({ + ...base, + '/log/main': () => + json( + call++ === 0 + ? [ + { id: 5, message: 'a', timestamp: 1_790_000_000, type: 4 }, + { id: 9, message: 'b', timestamp: 1_790_000_001, type: 8 }, + ] + : [] + )(), + }); + await connect(); + + await provider.getActivity(); + await provider.getActivity(); + + const [first, second] = logCalls(); + expect(first).toContain('last_known_id=-1'); + expect(first).toContain('warning=true'); + expect(first).toContain('critical=true'); + expect(first).toContain('normal=false'); + expect(second).toContain('last_known_id=9'); + }); + + it('resyncs after 10 minutes of silence and detects an id reset after a restart', async () => { + const responses: unknown[][] = [ + [{ id: 500, message: 'before', timestamp: 1_790_000_000, type: 4 }], + [], + // empty answer to last_known_id=500 (ids restarted), then the full resync + [], + // full resync after restart: ids restarted at 0, newer timestamps + [ + { id: 0, message: 'old buffer entry', timestamp: 1_789_999_000, type: 4 }, + { id: 1, message: 'after restart', timestamp: 1_790_000_900, type: 8 }, + ], + // next regular poll uses the reset cursor + [], + ]; + routeFetch({ ...base, '/log/main': () => json(responses.shift() ?? [])() }); + await connect(); + + await provider.getActivity(); + vi.advanceTimersByTime(60_000); + expect(await provider.getActivity()).toEqual([]); + expect(logCalls()).toHaveLength(2); + + vi.advanceTimersByTime(11 * 60_000); + const activities = await provider.getActivity(); + expect(activities.map((a) => a.id)).toEqual(['qbittorrent-log-1790000900-1']); + expect(logCalls()[2]).toContain('last_known_id=500'); + expect(logCalls()[3]).toContain('last_known_id=-1'); + + await provider.getActivity(); + expect(logCalls()[4]).toContain('last_known_id=1'); + }); + + it('does not resync again within 10 minutes of the last full sync', async () => { + routeFetch({ + ...base, + '/log/main': json([{ id: 5, message: 'a', timestamp: 1_790_000_000, type: 4 }]), + }); + await connect(); + await provider.getActivity(); + mockFetch.mockClear(); + routeFetch({ ...base, '/log/main': json([]) }); + vi.advanceTimersByTime(5 * 60_000); + + await provider.getActivity(); + + expect(logCalls()).toHaveLength(1); + expect(logCalls()[0]).toContain('last_known_id=5'); + }); + + it('applies the since filter on a fresh start', async () => { + routeFetch({ + ...base, + '/log/main': json([ + { id: 1, message: 'old', timestamp: 1_790_000_000, type: 4 }, + { id: 2, message: 'new', timestamp: 1_790_000_100, type: 4 }, + ]), + }); + await connect(); + + const activities = await provider.getActivity(new Date(1_790_000_050_000)); + + expect(activities.map((a) => a.overview)).toEqual(['new']); + }); + + it('caps a fresh start without since to the newest 500 entries', async () => { + const entries = Array.from({ length: 700 }, (_, i) => ({ + id: i, + message: `m${i}`, + timestamp: 1_790_000_000 + i, + type: 4, + })); + routeFetch({ ...base, '/log/main': json(entries) }); + await connect(); + + const activities = await provider.getActivity(); + + expect(activities).toHaveLength(500); + expect(activities[0]?.overview).toBe('m200'); + expect(activities.at(-1)?.overview).toBe('m699'); + }); + + it('skips the API log feed entirely when a logPath is configured', async () => { + routeFetch({ + ...base, + '/log/main': json([{ id: 1, message: 'x', timestamp: 1_790_000_000, type: 8 }]), + }); + await connect('/qbittorrent-logs'); + + const activities = await provider.getActivity(); + + expect(activities).toEqual([]); + expect(logCalls()).toHaveLength(0); + }); + + it('keeps other sources when the log endpoint fails', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + routeFetch({ + ...base, + '/log/main': text('boom', 500), + '/torrents/info': json([{ hash: 'h1', name: 'T', state: 'error' }]), + }); + await connect(); + + const activities = await provider.getActivity(); + + expect(activities.map((a) => a.type)).toEqual(['torrent_error']); + expect(errorSpy).toHaveBeenCalled(); + }); + }); + + describe('getActivity - torrents and connection', () => { + const connect = async (): Promise => { + await provider.connect({ url: baseUrl, apiKey: '', logPath: '/logs' }); + }; + const base = { + '/app/version': text('v5.2.4'), + '/app/webapiVersion': text('2.15.1'), + '/log/main': json([]), + }; + + it('maps errored torrents to stable error activities', async () => { + routeFetch({ + ...base, + '/torrents/info': json([ + { + hash: 'abc', + name: 'Some.Torrent', + state: 'missingFiles', + category: 'tv', + save_path: '/dl', + tracker: 'http://t/announce?passkey=abcdef', + progress: 0.5, + }, + { hash: 'def', name: 'Other', state: 'error' }, + ]), + '/transfer/info': json({ connection_status: 'connected' }), + }); + await connect(); + + const first = await provider.getActivity(); + const second = await provider.getActivity(); + + expect(first.map((a) => a.id)).toEqual([ + 'qbittorrent-torrent-error-abc-missing-files-2026-10-10', + 'qbittorrent-torrent-error-def-error-2026-10-10', + ]); + expect(second.map((a) => a.id)).toEqual(first.map((a) => a.id)); + expect(first[0]).toMatchObject({ + type: 'torrent_error', + severity: 'error', + itemId: 'abc', + name: 'Torrent error: Some.Torrent', + overview: 'Some.Torrent is in state "missingFiles" (category: tv)', + metadata: { hash: 'abc', state: 'missingFiles', tracker: 't' }, + }); + expect(first[1]?.overview).toBe('Other is in state "error"'); + }); + + it.each([ + ['firewalled', 'warn'], + ['disconnected', 'error'], + ])('reports %s connection status as %s', async (status, severity) => { + routeFetch({ + ...base, + '/torrents/info': json([]), + '/transfer/info': json({ connection_status: status, dht_nodes: 12 }), + }); + await connect(); + + const [activity] = await provider.getActivity(); + + expect(activity).toMatchObject({ + id: `qbittorrent-connection-${status}-2026-10-10`, + type: 'connection_status', + severity, + metadata: { connectionStatus: status, dhtNodes: 12 }, + }); + }); + + it('does not report a healthy connection and reports an unhealthy one once per day', async () => { + routeFetch({ + ...base, + '/torrents/info': json([]), + '/transfer/info': json({ connection_status: 'connected' }), + }); + await connect(); + expect(await provider.getActivity()).toEqual([]); + + routeFetch({ + ...base, + '/torrents/info': json([]), + '/transfer/info': json({ connection_status: 'firewalled' }), + }); + const [first] = await provider.getActivity(); + vi.advanceTimersByTime(30 * 60_000); + const sameDay = await provider.getActivity(); + vi.advanceTimersByTime(24 * 60 * 60_000); + const [nextDay] = await provider.getActivity(); + + expect(first).toBeDefined(); + expect(sameDay).toEqual([]); + expect(nextDay?.id).not.toBe(first?.id); + }); + + it('sorts the combined result by timestamp', async () => { + routeFetch({ + ...base, + '/torrents/info': json([{ hash: 'h', name: 'N', state: 'error' }]), + '/transfer/info': json({ connection_status: 'disconnected' }), + }); + await connect(); + + const activities = await provider.getActivity(); + + const times = activities.map((a) => a.timestamp.getTime()); + expect(times).toEqual([...times].sort((a, b) => a - b)); + }); + }); +}); diff --git a/packages/provider-qbittorrent/src/qbittorrent.provider.ts b/packages/provider-qbittorrent/src/qbittorrent.provider.ts new file mode 100644 index 0000000..754f30b --- /dev/null +++ b/packages/provider-qbittorrent/src/qbittorrent.provider.ts @@ -0,0 +1,347 @@ +import { QBittorrentClient } from './qbittorrent.client.js'; +import { + QBITTORRENT_CORRELATION_PATTERNS, + QBITTORRENT_LOG_FILE_CONFIG, + isQBittorrentLogContinuation, + parseQBittorrentLogLine, + parseQBittorrentLogLineWithContext, +} from './qbittorrent.parser.js'; +import { redactSecrets, trackerHost } from './qbittorrent.redact.js'; +import { connectionStates, logCursors } from './qbittorrent.state.js'; +import { QBITTORRENT_LOG_TYPE } from './qbittorrent.types.js'; + +import type { QBittorrentLogEntry } from './qbittorrent.types.js'; +import type { + ConnectionStatus, + CorrelationPattern, + LogFileConfig, + LogLevel, + LogParseContext, + LogParseResult, + MediaServerProvider, + NormalizedActivity, + NormalizedSession, + NormalizedUser, + ParsedLogEntry, + ProviderCapabilities, + ProviderConfig, + ServerInfo, +} from '@logarr/core'; + +/** Newest log messages ingested on the very first poll when no "since" is known */ +const INITIAL_LOG_LIMIT = 500; +/** How often an empty log response triggers a full resync (detects qBittorrent restarts) */ +const LOG_RESYNC_INTERVAL_MS = 10 * 60_000; + +/** missingFiles -> missing-files */ +function kebab(value: string): string { + return value.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase(); +} + +function truncate(value: string, max: number): string { + return value.length > max ? `${value.slice(0, max - 1)}…` : value; +} + +/** qBittorrent < 4.5.0 reports milliseconds, newer versions seconds */ +function toMillis(timestamp: number): number { + return timestamp > 1e11 ? timestamp : timestamp * 1000; +} + +function severityOf(type: number): LogLevel { + return type === QBITTORRENT_LOG_TYPE.CRITICAL ? 'error' : 'warn'; +} + +/** + * qBittorrent download client provider. + * + * Supports username/password (cookie session), API keys (qBittorrent >= 5.2) and + * authentication bypass. Credentials: `apiKey` holds the password (or the API key when no + * username is configured). + */ +export class QBittorrentProvider implements MediaServerProvider { + readonly id = 'qbittorrent'; + readonly name = 'qBittorrent'; + + readonly capabilities: ProviderCapabilities = { + supportsRealTimeLogs: false, + supportsActivityLog: true, + supportsSessions: false, + supportsWebhooks: false, + supportsPlaybackHistory: false, + supportsUsername: true, + apiKeyOptional: true, + }; + + private config: ProviderConfig | null = null; + private client: QBittorrentClient | null = null; + + async connect(config: ProviderConfig): Promise { + this.config = config; + this.client = new QBittorrentClient(config.url, { + username: config.username, + secret: config.apiKey, + }); + + const status = await this.testConnection(); + if (!status.connected) { + throw new Error(status.error ?? 'Connection failed'); + } + } + + disconnect(): Promise { + // Sessions and cursors live in module-level maps on purpose + this.client = null; + this.config = null; + return Promise.resolve(); + } + + async testConnection(): Promise { + try { + return { connected: true, serverInfo: await this.getServerInfo() }; + } catch (error) { + return { + connected: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + } + + async getServerInfo(): Promise { + const client = this.getClient(); + const version = (await client.getVersion()).trim(); + const webApi = (await client.getWebApiVersion()).trim(); + return { + name: 'qBittorrent', + version: version.replace(/^v/i, ''), + id: `qbittorrent-webapi-${webApi}`, + }; + } + + getLogPaths(): Promise { + const logPath = this.config?.logPath?.trim(); + if (logPath !== undefined && logPath !== '') { + return Promise.resolve([logPath]); + } + return Promise.resolve([ + '/qbittorrent-logs', + // linuxserver/qbittorrent (profile under /config) - best guess, verify in your container + '/config/qBittorrent/logs', + // hotio/qbittorrent - best guess, verify in your container + '/config/logs', + '~/.local/share/qBittorrent/logs', + 'C:\\Users\\\\AppData\\Local\\qBittorrent\\logs', + ]); + } + + parseLogLine(line: string): ParsedLogEntry | null { + return parseQBittorrentLogLine(line); + } + + getCorrelationPatterns(): readonly CorrelationPattern[] { + return QBITTORRENT_CORRELATION_PATTERNS; + } + + getSessions(): Promise { + return Promise.resolve([]); + } + + getUsers(): Promise { + return Promise.resolve([]); + } + + getLogFileConfig(): LogFileConfig { + return QBITTORRENT_LOG_FILE_CONFIG; + } + + parseLogLineWithContext(line: string, context: LogParseContext): LogParseResult { + return parseQBittorrentLogLineWithContext(line, context); + } + + isLogContinuation(line: string): boolean { + return isQBittorrentLogContinuation(line); + } + + async getActivity(since?: Date): Promise { + const client = this.getClient(); + const activities: NormalizedActivity[] = []; + const errors: unknown[] = []; + let attempted = 0; + + const collect = async ( + source: string, + load: () => Promise + ): Promise => { + attempted++; + try { + activities.push(...(await load())); + } catch (error) { + errors.push(error); + console.error(`[${this.id}] Failed to get ${source}:`, error); + } + }; + + // Warning/critical log messages. Skipped when the log file is tailed instead. + if ((this.config?.logPath?.trim() ?? '') === '') { + await collect('log messages', () => this.fetchLogActivities(client, since)); + } + await collect('errored torrents', () => this.fetchTorrentErrors(client)); + await collect('connection status', () => this.fetchConnectionStatus(client)); + + // Partial failures are tolerated; if every source failed, surface it so the server is + // marked unhealthy by the poll + if (errors.length === attempted) { + throw errors[0]; + } + + return activities.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()); + } + + // --------------------------------------------------------------------------- + // Activity sources + // --------------------------------------------------------------------------- + + private async fetchLogActivities( + client: QBittorrentClient, + since: Date | undefined + ): Promise { + const now = Date.now(); + const cursor = logCursors.get(client.baseUrl); + let entries = await client.getLog(cursor?.lastId ?? -1); + let keepAfter: number | undefined; + let capNewest = false; + let resynced = false; + + if (cursor === undefined) { + keepAfter = since?.getTime() ?? 0; + capNewest = since === undefined; + } else if (cursor.lastId >= 0 && entries.some((entry) => entry.id <= cursor.lastId)) { + // ids went backwards (qBittorrent restarted): everything returned is new, filter by time + keepAfter = cursor.lastTs; + resynced = true; + } else if ( + entries.length === 0 && + cursor.lastId >= 0 && + now - cursor.fullSyncAt > LOG_RESYNC_INTERVAL_MS + ) { + // ids restart at 0 after a qBittorrent restart; re-read the whole buffer + entries = await client.getLog(-1); + keepAfter = cursor.lastTs; + resynced = true; + } + + const relevant = entries.filter( + (entry) => + (entry.type === QBITTORRENT_LOG_TYPE.WARNING || + entry.type === QBITTORRENT_LOG_TYPE.CRITICAL) && + (keepAfter === undefined || toMillis(entry.timestamp) > keepAfter) + ); + + const maxId = entries.reduce((max, entry) => Math.max(max, entry.id), -1); + const maxTs = entries.reduce((max, entry) => Math.max(max, toMillis(entry.timestamp)), 0); + const previousId = cursor?.lastId ?? -1; + let lastId = Math.max(maxId, previousId); + if (resynced && entries.length > 0) { + // ids restarted: trust the new (possibly lower) maximum + lastId = maxId; + } + logCursors.set(client.baseUrl, { + lastId, + lastTs: Math.max(maxTs, cursor?.lastTs ?? 0), + fullSyncAt: cursor === undefined || resynced ? now : cursor.fullSyncAt, + }); + + const selected = capNewest + ? [...relevant].sort((a, b) => a.timestamp - b.timestamp).slice(-INITIAL_LOG_LIMIT) + : relevant; + + return selected.map((entry) => this.toLogActivity(entry)); + } + + private toLogActivity(entry: QBittorrentLogEntry): NormalizedActivity { + const timestampMs = toMillis(entry.timestamp); + const message = redactSecrets(entry.message); + return { + id: `qbittorrent-log-${Math.floor(timestampMs / 1000)}-${entry.id}`, + type: entry.type === QBITTORRENT_LOG_TYPE.CRITICAL ? 'log_critical' : 'log_warning', + name: truncate(message, 120), + overview: message, + severity: severityOf(entry.type), + timestamp: new Date(timestampMs), + metadata: { qbId: entry.id, qbType: entry.type }, + }; + } + + private async fetchTorrentErrors(client: QBittorrentClient): Promise { + const torrents = await client.getErroredTorrents(); + // Synthetic detection time: these rows are flagged advancesSyncCursor: false so they never + // move lastActivitySync, which therefore only follows real log event times. + const timestamp = new Date(); + const day = timestamp.toISOString().slice(0, 10); + return torrents.map((torrent) => ({ + // Day bucket: a persisting/recurring error is re-announced at most once per day + id: `qbittorrent-torrent-error-${torrent.hash}-${kebab(torrent.state)}-${day}`, + type: 'torrent_error', + name: `Torrent error: ${torrent.name}`, + overview: + `${torrent.name} is in state "${torrent.state}"` + + (torrent.category !== undefined && torrent.category !== '' + ? ` (category: ${torrent.category})` + : ''), + severity: 'error' as const, + itemId: torrent.hash, + timestamp, + // Detection time, not an event time: must not move the lastActivitySync cursor + advancesSyncCursor: false, + metadata: { + hash: torrent.hash, + state: torrent.state, + category: torrent.category, + // Host only: private tracker URLs embed the account passkey + tracker: trackerHost(torrent.tracker), + progress: torrent.progress, + }, + })); + } + + private async fetchConnectionStatus(client: QBittorrentClient): Promise { + const info = await client.getTransferInfo(); + const status = info.connection_status; + const previous = connectionStates.get(client.baseUrl); + + if (status !== 'firewalled' && status !== 'disconnected') { + connectionStates.set(client.baseUrl, { status, reportedDay: null }); + return []; + } + + // Synthetic detection time (see fetchTorrentErrors), also advancesSyncCursor: false + const timestamp = new Date(); + const day = timestamp.toISOString().slice(0, 10); + if (previous?.status === status && previous.reportedDay === day) { + return []; + } + connectionStates.set(client.baseUrl, { status, reportedDay: day }); + + return [ + { + id: `qbittorrent-connection-${status}-${day}`, + type: 'connection_status', + name: `qBittorrent connection status: ${status}`, + overview: `qBittorrent reports it is ${status} (check port forwarding / network binding)`, + severity: status === 'disconnected' ? 'error' : 'warn', + timestamp, + advancesSyncCursor: false, + metadata: { + connectionStatus: status, + dhtNodes: info.dht_nodes, + }, + }, + ]; + } + + private getClient(): QBittorrentClient { + if (!this.client) { + throw new Error('Not connected'); + } + return this.client; + } +} diff --git a/packages/provider-qbittorrent/src/qbittorrent.redact.ts b/packages/provider-qbittorrent/src/qbittorrent.redact.ts new file mode 100644 index 0000000..1c6a0ce --- /dev/null +++ b/packages/provider-qbittorrent/src/qbittorrent.redact.ts @@ -0,0 +1,35 @@ +/** + * Private trackers embed account secrets (passkey / authkey) in announce URLs, and qBittorrent + * log messages and torrent objects can contain those URLs. Mask them before anything is stored. + */ +// "=" may be percent-encoded (%3D) and the key may follow an encoded "?" / "&" (%3F / %26). The value +// ends at a literal "&", an encoded "&" (%26), whitespace or a quote. +const SECRET_QUERY_PARAM = + /(?<=^|[^a-z0-9]|%3f|%26)(passkey|authkey|torrent_pass|pk)(=|%3d)(?:(?!%26)[^&\s"'<>])+/gi; +const HEX_PATH_SEGMENT = /\/[0-9a-f]{32,}(?=[/?#&\s"'<>]|$)/gi; +// Opaque tokens (base32 / base64url / alphanumeric, >= 20 chars) next to announce/scrape in a path: +// /announce/ or //announce ("/" may be percent-encoded as %2F) +const SLASH = '(?:/|%2f)'; +const TOKEN = '[a-z0-9_-]{20,}'; +const TOKEN_AFTER_ANNOUNCE = new RegExp(`(${SLASH}(?:announce|scrape))${SLASH}${TOKEN}`, 'gi'); +const TOKEN_BEFORE_ANNOUNCE = new RegExp(`${SLASH}${TOKEN}(?=${SLASH}(?:announce|scrape))`, 'gi'); + +export function redactSecrets(text: string): string { + return text + .replace(SECRET_QUERY_PARAM, '$1$2***') + .replace(HEX_PATH_SEGMENT, '/***') + .replace(TOKEN_AFTER_ANNOUNCE, '$1/***') + .replace(TOKEN_BEFORE_ANNOUNCE, '/***'); +} + +/** Reduce a tracker URL to its host (no path or query); undefined if it is not a valid URL */ +export function trackerHost(tracker: string | undefined): string | undefined { + if (tracker === undefined || tracker === '') { + return undefined; + } + try { + return new URL(tracker).host; + } catch { + return undefined; + } +} diff --git a/packages/provider-qbittorrent/src/qbittorrent.state.spec.ts b/packages/provider-qbittorrent/src/qbittorrent.state.spec.ts new file mode 100644 index 0000000..a9e13c6 --- /dev/null +++ b/packages/provider-qbittorrent/src/qbittorrent.state.spec.ts @@ -0,0 +1,191 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { QBittorrentClient } from './qbittorrent.client.js'; +import { QBittorrentProvider } from './qbittorrent.provider.js'; +import { + BoundedCache, + connectionStates, + logCursors, + resetQBittorrentState, + sessionCache, +} from './qbittorrent.state.js'; + +const mockFetch = vi.fn(); + +describe('BoundedCache', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-10-10T12:00:00Z')); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('stores and returns values', () => { + const cache = new BoundedCache(3, 1000); + cache.set('a', 1); + expect(cache.get('a')).toBe(1); + expect(cache.has('a')).toBe(true); + expect(cache.get('missing')).toBeUndefined(); + }); + + it('evicts the least recently used entry beyond the size cap', () => { + const cache = new BoundedCache(2, 60_000); + cache.set('a', 1); + cache.set('b', 2); + cache.get('a'); // a is now more recent than b + cache.set('c', 3); + + expect(cache.size).toBe(2); + expect(cache.get('b')).toBeUndefined(); + expect(cache.get('a')).toBe(1); + expect(cache.get('c')).toBe(3); + }); + + it('expires idle entries after the TTL and refreshes the TTL on access', () => { + const cache = new BoundedCache(10, 1000); + cache.set('a', 1); + cache.set('b', 2); + + vi.advanceTimersByTime(800); + expect(cache.get('a')).toBe(1); // refreshed + vi.advanceTimersByTime(800); + + expect(cache.get('a')).toBe(1); + expect(cache.get('b')).toBeUndefined(); + vi.advanceTimersByTime(1001); + expect(cache.size).toBe(0); + }); + + it('supports delete and clear', () => { + const cache = new BoundedCache(10, 1000); + cache.set('a', 1); + cache.set('b', 2); + cache.delete('a'); + expect(cache.has('a')).toBe(false); + cache.clear(); + expect(cache.size).toBe(0); + }); +}); + +describe('qBittorrent process-wide state', () => { + beforeEach(() => { + vi.stubGlobal('fetch', mockFetch); + mockFetch.mockReset(); + resetQBittorrentState(); + }); + afterEach(() => { + vi.unstubAllGlobals(); + resetQBittorrentState(); + }); + + it('bounds the session cache when many servers log in', async () => { + mockFetch.mockImplementation((url: unknown) => + Promise.resolve( + String(url).endsWith('/auth/login') + ? new Response('Ok.', { headers: { 'set-cookie': 'SID=x' } }) + : new Response('v5.2.4') + ) + ); + + for (let i = 0; i < 130; i++) { + await new QBittorrentClient(`http://bounded-${i}.local:8080`, { + username: 'u', + secret: 'p', + }).getVersion(); + } + + expect(sessionCache.size).toBe(100); + }); + + it('bounds log cursors and connection states', async () => { + mockFetch.mockImplementation((url: unknown) => { + const path = new URL(String(url)).pathname; + if (path.endsWith('/log/main')) return Promise.resolve(new Response('[]')); + if (path.endsWith('/transfer/info')) + return Promise.resolve(new Response('{"connection_status":"connected"}')); + if (path.endsWith('/torrents/info')) return Promise.resolve(new Response('[]')); + return Promise.resolve(new Response(path.endsWith('version') ? 'v5.2.4' : '2.15.1')); + }); + + for (let i = 0; i < 120; i++) { + const provider = new QBittorrentProvider(); + await provider.connect({ url: `http://cursor-${i}.local:8080`, apiKey: '' }); + await provider.getActivity(); + } + + expect(logCursors.size).toBe(100); + expect(connectionStates.size).toBe(100); + }); + + it('resetQBittorrentState clears everything', () => { + sessionCache.set('a', new Map()); + logCursors.set('a', { lastId: 1, lastTs: 1, fullSyncAt: 1 }); + resetQBittorrentState(); + expect(sessionCache.size).toBe(0); + expect(logCursors.size).toBe(0); + }); +}); + +describe('QBittorrentProvider.getActivity error policy', () => { + beforeEach(() => { + vi.stubGlobal('fetch', mockFetch); + mockFetch.mockReset(); + resetQBittorrentState(); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + }); + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('throws "Not connected" before connect', async () => { + await expect(new QBittorrentProvider().getActivity()).rejects.toThrow('Not connected'); + }); + + it('rethrows the first error when every source fails', async () => { + const provider = new QBittorrentProvider(); + let healthy = true; + mockFetch.mockImplementation((url: unknown) => { + const path = new URL(String(url)).pathname; + if (healthy) { + return Promise.resolve(new Response(path.endsWith('version') ? 'v5.2.4' : '2.15.1')); + } + return Promise.resolve(new Response('boom', { status: 500 })); + }); + await provider.connect({ url: 'http://allfail.local:8080', apiKey: '' }); + healthy = false; + + await expect(provider.getActivity()).rejects.toThrow('qBittorrent API error: HTTP 500'); + expect(console.error).toHaveBeenCalledWith( + '[qbittorrent] Failed to get log messages:', + expect.any(Error) + ); + expect(console.error).toHaveBeenCalledWith( + '[qbittorrent] Failed to get errored torrents:', + expect.any(Error) + ); + expect(console.error).toHaveBeenCalledWith( + '[qbittorrent] Failed to get connection status:', + expect.any(Error) + ); + }); + + it('counts only attempted sources: with a logPath, two failures are a total failure', async () => { + const provider = new QBittorrentProvider(); + let healthy = true; + mockFetch.mockImplementation((url: unknown) => { + const path = new URL(String(url)).pathname; + return Promise.resolve( + healthy + ? new Response(path.endsWith('version') ? 'v5.2.4' : '2.15.1') + : new Response('boom', { status: 500 }) + ); + }); + await provider.connect({ url: 'http://allfail2.local:8080', apiKey: '', logPath: '/logs' }); + healthy = false; + + await expect(provider.getActivity()).rejects.toThrow('HTTP 500'); + expect(console.error).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/provider-qbittorrent/src/qbittorrent.state.ts b/packages/provider-qbittorrent/src/qbittorrent.state.ts new file mode 100644 index 0000000..ef1bae9 --- /dev/null +++ b/packages/provider-qbittorrent/src/qbittorrent.state.ts @@ -0,0 +1,119 @@ +/** + * Process-wide state of the qBittorrent provider. + * + * Providers are singletons that connect/disconnect on every poll, so sessions, login cooldowns, + * log cursors and connection states must outlive client instances. All of it is kept in bounded + * caches (size cap with oldest-first eviction plus an idle TTL) so it cannot grow without limit + * when servers are added, changed or deleted. + */ + +/** Maximum number of entries per cache (far above any realistic number of qBittorrent servers) */ +const MAX_ENTRIES = 100; +const HOUR_MS = 60 * 60_000; + +interface Entry { + value: V; + expires: number; +} + +/** + * Small Map-like cache with a size cap (least recently used entry is evicted first) and a TTL + * that is refreshed on access. Uses Date.now() so it works with fake timers. + */ +export class BoundedCache { + private readonly entries = new Map>(); + + constructor( + private readonly maxSize: number, + private readonly ttlMs: number + ) {} + + get(key: string): V | undefined { + const entry = this.entries.get(key); + if (entry === undefined) { + return undefined; + } + if (entry.expires <= Date.now()) { + this.entries.delete(key); + return undefined; + } + // Refresh recency and TTL + this.entries.delete(key); + this.entries.set(key, { value: entry.value, expires: Date.now() + this.ttlMs }); + return entry.value; + } + + has(key: string): boolean { + return this.get(key) !== undefined; + } + + set(key: string, value: V): void { + this.entries.delete(key); + this.entries.set(key, { value, expires: Date.now() + this.ttlMs }); + this.prune(); + } + + delete(key: string): void { + this.entries.delete(key); + } + + clear(): void { + this.entries.clear(); + } + + /** Number of stored entries (expired ones are removed first) */ + get size(): number { + this.prune(); + return this.entries.size; + } + + private prune(): void { + const now = Date.now(); + for (const [key, entry] of this.entries) { + if (entry.expires <= now) { + this.entries.delete(key); + } + } + while (this.entries.size > this.maxSize) { + const oldest = this.entries.keys().next(); + if (oldest.done === true) { + break; + } + this.entries.delete(oldest.value); + } + } +} + +export interface Cooldown { + until: number; + message: string; + failures: number; +} + +export interface LogCursor { + lastId: number; + lastTs: number; + fullSyncAt: number; +} + +export interface ConnectionState { + status: string; + reportedDay: string | null; +} + +/** Session cookies per (url, username, secret) hash */ +export const sessionCache = new BoundedCache>(MAX_ENTRIES, 12 * HOUR_MS); +/** Login failure cooldowns per (url, username, secret) hash; they also carry the failure count */ +export const cooldowns = new BoundedCache(MAX_ENTRIES, 24 * HOUR_MS); +/** Log cursors per qBittorrent base URL */ +export const logCursors = new BoundedCache(MAX_ENTRIES, 7 * 24 * HOUR_MS); +/** Last reported connection status per qBittorrent base URL */ +export const connectionStates = new BoundedCache(MAX_ENTRIES, 7 * 24 * HOUR_MS); + +/** Clear all cached state (used by tests) */ +export function resetQBittorrentState(): void { + sessionCache.clear(); + cooldowns.clear(); + logCursors.clear(); + connectionStates.clear(); +} diff --git a/packages/provider-qbittorrent/src/qbittorrent.types.ts b/packages/provider-qbittorrent/src/qbittorrent.types.ts new file mode 100644 index 0000000..44d1666 --- /dev/null +++ b/packages/provider-qbittorrent/src/qbittorrent.types.ts @@ -0,0 +1,45 @@ +/** + * qBittorrent WebUI API v2 types + */ + +/** Entry returned by GET /api/v2/log/main */ +export interface QBittorrentLogEntry { + readonly id: number; + readonly message: string; + /** Seconds since epoch (milliseconds before qBittorrent 4.5.0) */ + readonly timestamp: number; + readonly type: number; +} + +/** Subset of the torrent object returned by GET /api/v2/torrents/info */ +export interface QBittorrentTorrent { + readonly hash: string; + readonly name: string; + readonly state: string; + readonly category?: string; + readonly save_path?: string; + readonly tracker?: string; + readonly progress?: number; +} + +/** Subset of GET /api/v2/transfer/info */ +export interface QBittorrentTransferInfo { + readonly connection_status: 'connected' | 'firewalled' | 'disconnected'; + readonly dht_nodes?: number; +} + +/** Log message type bit flags */ +export const QBITTORRENT_LOG_TYPE = { + NORMAL: 1, + INFO: 2, + WARNING: 4, + CRITICAL: 8, +} as const; + +/** + * How the client authenticates, derived from the stored credentials: + * - login: username + password -> POST /auth/login and session cookie + * - apiKey: no username, non-empty secret -> Authorization: Bearer (qBittorrent >= 5.2) + * - none: no credentials (WebUI authentication bypass) + */ +export type QBittorrentAuthMode = 'login' | 'apiKey' | 'none'; diff --git a/packages/provider-qbittorrent/tsconfig.json b/packages/provider-qbittorrent/tsconfig.json new file mode 100644 index 0000000..f103ea7 --- /dev/null +++ b/packages/provider-qbittorrent/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-qbittorrent/tsup.config.ts b/packages/provider-qbittorrent/tsup.config.ts new file mode 100644 index 0000000..4a0ea44 --- /dev/null +++ b/packages/provider-qbittorrent/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-qbittorrent/vitest.config.ts b/packages/provider-qbittorrent/vitest.config.ts new file mode 100644 index 0000000..a2f6052 --- /dev/null +++ b/packages/provider-qbittorrent/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..aa3562e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -77,6 +77,9 @@ importers: '@logarr/provider-prowlarr': specifier: workspace:* version: link:../../packages/provider-prowlarr + '@logarr/provider-qbittorrent': + specifier: workspace:* + version: link:../../packages/provider-qbittorrent '@logarr/provider-radarr': specifier: workspace:* version: link:../../packages/provider-radarr @@ -536,6 +539,25 @@ importers: specifier: ^5.7.2 version: 5.9.3 + packages/provider-qbittorrent: + dependencies: + '@logarr/core': + specifier: workspace:* + version: link:../core + devDependencies: + '@types/node': + specifier: ^22.10.2 + version: 22.19.3 + 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-radarr: dependencies: '@logarr/core': diff --git a/turbo.json b/turbo.json index 248fe4a..79c3e77 100644 --- a/turbo.json +++ b/turbo.json @@ -30,6 +30,9 @@ "WHISPARR_API_KEY", "LIDARR_URL", "LIDARR_API_KEY", + "QBITTORRENT_URL", + "QBITTORRENT_USERNAME", + "QBITTORRENT_PASSWORD", "JELLYFIN_LOGS_PATH", "PLEX_LOGS_PATH", "EMBY_LOGS_PATH", @@ -37,7 +40,8 @@ "RADARR_LOGS_PATH", "PROWLARR_LOGS_PATH", "WHISPARR_LOGS_PATH", - "LIDARR_LOGS_PATH" + "LIDARR_LOGS_PATH", + "QBITTORRENT_LOGS_PATH" ], "tasks": { "build": {