diff --git a/.dockerignore b/.dockerignore index 19a006d5..22442596 100644 --- a/.dockerignore +++ b/.dockerignore @@ -45,6 +45,7 @@ webui/coverage/ *.db-shm # Config files (use example) +.config/ config.toml # Test files diff --git a/README.md b/README.md index 9334b5f0..97d22eac 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,7 @@ Access the WebUI at `http://:6969/ui` after startup. ## Key Features - **Manage** – Configure and run all ten recovery, organization, and storage tools in one workspace. Compact cards keep advanced settings out of the client configuration dialog. [Maintenance guide](docs/features/torrent-maintenance.md) and [worked examples](docs/features/maintenance-examples.md). +- **Radarr v3 through v7 support** – All versions use `/api/v3`; no configuration changes. [Radarr configuration](docs/configuration/arr/radarr.md). - **Sonarr v3, v4, and v5 support** – Automatic API version detection with the same configuration format. [Sonarr configuration](docs/configuration/arr/sonarr.md). - **Multi-qBittorrent Support** – Manage torrents across multiple qBittorrent instances for load balancing, redundancy, and VPN isolation - **Torrent Health Monitoring** – Detect stalled/failed downloads, auto-blacklist, trigger re-searches diff --git a/config.example.toml b/config.example.toml index 47ba9ee5..3c7c6cba 100644 --- a/config.example.toml +++ b/config.example.toml @@ -82,8 +82,8 @@ OIDCEnabled = false # Enable live updates for Arr views LiveArr = true -# WebUI theme (Light or Dark) -Theme = "Dark" +# WebUI theme (System, Light, Dark, or Midnight) +Theme = "System" # View density (Comfortable or Compact) ViewDensity = "Comfortable" @@ -225,6 +225,8 @@ ExcludedCategories = [] ExcludedTags = [] # Explicit mappings describe the same folder in the client and Torrentarr. +# Recycling requires mappings even when both paths are identical. In Docker, +# LocalPath is inside Torrentarr; the directory must already be mounted/readable. # [[qBit.Maintenance.PathMappings]] # ClientPath = '/downloads' # LocalPath = '/data/downloads' diff --git a/docs/assets/favicon-16x16.png b/docs/assets/favicon-16x16.png index 00c828e7..8cbdfb54 100644 Binary files a/docs/assets/favicon-16x16.png and b/docs/assets/favicon-16x16.png differ diff --git a/docs/assets/favicon-32x32.png b/docs/assets/favicon-32x32.png index c3c3707d..9e75b434 100644 Binary files a/docs/assets/favicon-32x32.png and b/docs/assets/favicon-32x32.png differ diff --git a/docs/assets/favicon-48x48.png b/docs/assets/favicon-48x48.png index 377430e3..4d5e4fe1 100644 Binary files a/docs/assets/favicon-48x48.png and b/docs/assets/favicon-48x48.png differ diff --git a/docs/assets/logov2-clean.svg b/docs/assets/logov2-clean.svg index 70315292..e0f80932 100644 --- a/docs/assets/logov2-clean.svg +++ b/docs/assets/logov2-clean.svg @@ -1,48 +1,19 @@ - - - - - - - - - - - + + Torrentarr + + + + + + + + + + + + + + + + diff --git a/docs/assets/openapi.json b/docs/assets/openapi.json index d5e7f96e..0bc11032 100644 --- a/docs/assets/openapi.json +++ b/docs/assets/openapi.json @@ -67,6 +67,28 @@ ], "type": "string" }, + "restartRequirements": { + "items": { + "properties": { + "category": { + "type": "string" + }, + "component": { + "type": "string" + }, + "kind": { + "type": "string" + } + }, + "required": [ + "component", + "category", + "kind" + ], + "type": "object" + }, + "type": "array" + }, "status": { "example": "ok", "type": "string" @@ -5902,6 +5924,7 @@ }, "/web/qbit/overview": { "get": { + "description": "Includes optional summaries per available client: instance, torrentCount, totalSize, downloading, seeding, stopped, other. These monitored-category totals are computed before the display cap and deduplicated by client and torrent hash. Clients without an inventory response are omitted.", "operationId": "web_qbit_overview", "parameters": [ { @@ -6426,6 +6449,289 @@ ] } }, + "/web/statistics/arr-activity": { + "get": { + "description": "Follows WebUI authentication. Worker-level series retain seven days; no backfill. Queue depth averages fresh heartbeats, rounded to whole items. Completions count only tracked Torrentarr submissions: searches require successful command completion; imports require positive correlated download-import history after submission. Counts are timestamped when confirmed, not when requested. Missing/partial coverage is explicit. Buckets are 1, 5, or 30 minutes. Disabled/deleted workers can remain in retained history.", + "parameters": [ + { + "in": "query", + "name": "range", + "schema": { + "default": "24h", + "enum": [ + "1h", + "24h", + "7d" + ], + "type": "string" + } + }, + { + "description": "Optional configured Arr section name.", + "in": "query", + "name": "worker", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "bucketMinutes": { + "type": "integer" + }, + "generatedAt": { + "format": "date-time", + "type": "string" + }, + "range": { + "type": "string" + }, + "series": { + "items": { + "properties": { + "instance": { + "type": "string" + }, + "points": { + "items": { + "properties": { + "completionSamples": { + "type": "integer" + }, + "expectedSamples": { + "type": "integer" + }, + "importsConfirmed": { + "nullable": true, + "type": "integer" + }, + "queueDepth": { + "nullable": true, + "type": "integer" + }, + "queueSamples": { + "type": "integer" + }, + "searchesCompleted": { + "nullable": true, + "type": "integer" + }, + "timestamp": { + "format": "date-time", + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + }, + "type": { + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + } + }, + "type": "object" + } + } + }, + "description": "OK" + }, + "400": { + "description": "Invalid range or worker" + }, + "401": { + "description": "Authentication required" + } + }, + "summary": "Persistent Arr queue and confirmed activity history", + "tags": [ + "Torrentarr Statistics" + ] + } + }, + "/web/statistics/catalog": { + "get": { + "description": "Follows configured WebUI authentication. Transfer rates are bytes/second, collected once per minute and retained seven days. Completed buckets use 1, 5, or 30 minutes. Null rates indicate no observations; samples below expectedSamples indicate partial coverage. Catalog availability is monitored media with files; missing is monitored minus available. Catalog counts ignore page and text filters.", + "parameters": [ + { + "in": "query", + "name": "kind", + "required": true, + "schema": { + "enum": [ + "radarr", + "sonarr", + "lidarr", + "readarr" + ], + "type": "string" + } + }, + { + "description": "Arr section name; omitted or aggregate selects all instances of this kind.", + "in": "query", + "name": "instance", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "available": { + "type": "integer" + }, + "missing": { + "type": "integer" + }, + "monitored": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "unit": { + "type": "string" + } + }, + "type": "object" + } + } + }, + "description": "OK" + }, + "400": { + "description": "Invalid range, kind, or instance" + }, + "401": { + "description": "Authentication required" + } + }, + "summary": "Complete catalog availability rollup", + "tags": [ + "Torrentarr Statistics" + ] + } + }, + "/web/statistics/transfers": { + "get": { + "description": "Follows configured WebUI authentication. Transfer rates are bytes/second, collected once per minute and retained seven days. Completed buckets use 1, 5, or 30 minutes. Null rates indicate no observations; samples below expectedSamples indicate partial coverage. Catalog availability is monitored media with files; missing is monitored minus available. Catalog counts ignore page and text filters.", + "parameters": [ + { + "description": "Enabled client name; omitted or all selects all enabled clients.", + "in": "query", + "name": "instance", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "range", + "schema": { + "default": "24h", + "enum": [ + "1h", + "24h", + "7d" + ], + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "bucketMinutes": { + "type": "integer" + }, + "generatedAt": { + "format": "date-time", + "type": "string" + }, + "range": { + "enum": [ + "1h", + "24h", + "7d" + ], + "type": "string" + }, + "series": { + "items": { + "properties": { + "instance": { + "type": "string" + }, + "points": { + "items": { + "properties": { + "downloadRate": { + "format": "double", + "nullable": true, + "type": "number" + }, + "expectedSamples": { + "type": "integer" + }, + "samples": { + "type": "integer" + }, + "timestamp": { + "format": "date-time", + "type": "string" + }, + "uploadRate": { + "format": "double", + "nullable": true, + "type": "number" + } + }, + "type": "object" + }, + "type": "array" + } + }, + "type": "object" + }, + "type": "array" + } + }, + "type": "object" + } + } + }, + "description": "OK" + }, + "400": { + "description": "Invalid range, kind, or instance" + }, + "401": { + "description": "Authentication required" + } + }, + "summary": "Persistent whole-client transfer history", + "tags": [ + "Torrentarr Statistics" + ] + } + }, "/web/status": { "get": { "description": "All clients are equal. Read qbitInstances by instance name; the legacy qbit field has been removed.", diff --git a/docs/configuration/arr/radarr.md b/docs/configuration/arr/radarr.md index 54414ebc..3d29d941 100644 --- a/docs/configuration/arr/radarr.md +++ b/docs/configuration/arr/radarr.md @@ -1,6 +1,6 @@ # Radarr Configuration -This guide covers how to configure Radarr instances in Torrentarr for movie management, automated searching, and quality upgrades. +This guide covers how to configure Radarr instances in Torrentarr for movie management, automated searching, and quality upgrades. Torrentarr supports Radarr v3.x through v7.x; all versions use the `/api/v3` API. --- diff --git a/docs/configuration/config-file.md b/docs/configuration/config-file.md index 3523f390..3bcc79b4 100644 --- a/docs/configuration/config-file.md +++ b/docs/configuration/config-file.md @@ -200,6 +200,16 @@ LocalPath = "/data/downloads" The same fields work in legacy `[qBit]` and `[qBit-name]` sections. `DownloadPath` is the default root **as seen by the client**, used when creating missing categories. Existing category paths are preserved. Monitor `Path` values are absolute paths **as seen by Torrentarr**; mounts must already exist. Path mappings translate between these views without moving files. +Recycling requires explicit mappings for every configured client's file ownership inventory, including when client and Torrentarr paths are identical. A missing mapping keeps affected cleanup pending; it does not indicate a worker crash. For a shared `/downloads` mount visible at the same path in both containers: + +```toml +[[TorrentClient.local.Maintenance.PathMappings]] +ClientPath = '/downloads' +LocalPath = '/downloads' +``` + +Use this example instead of the mapping above when both paths match. For Docker, `LocalPath` is the path **inside Torrentarr's container**, not the host path. Verify the mounted directory and permissions before saving; a mapping does not create a mount. Config reload applies saved mappings on the next worker cycle, which can then perform previously blocked recycling after fresh protection and ownership checks. + New clients have no monitors. New monitor rows start disabled with a `10G` limit. Limits accept nonnegative bytes or K/M/G/T suffixes (powers of 1024); use `Enabled = false` to disable a row. Duplicate paths are rejected, while temporarily unavailable paths may still be saved. `TorrentScanIntervalSeconds` accepts only 5, 15, 30, 60, 90, or 120 seconds; new clients default to 30. Arr and qBit-only torrent processing, tracker sorting, failed/recheck handling, and routine protection inspections follow this per-client interval. Free space checks use the shortest interval among clients sharing each filesystem. Independent filesystems keep separate deadlines. Scans run immediately at startup; configuration changes and client reconnections can wake processing early. Each loop runs sequentially and skips missed ticks; slow operations and error backoff can delay scans. Fresh safety checks and manual actions may run between scans. @@ -220,7 +230,7 @@ Maintenance remains the canonical per-client configuration and all ten tools rem The old global `CompletedDownloadFolder`, `FreeSpace`, `FreeSpaceFolder`, `AutoPauseResume`, and `FreeSpaceThresholdGB` fields are migrated automatically, backed up to `config.toml.bak`, and removed from the saved file. Existing per-client values take precedence, including an explicitly empty monitor list. -For one client, a missing download root inherits the old completed folder (reverse-mapped only when unambiguous). The monitor uses the old free-space folder, completed folder, then mapped client download path. The old limit and disabled state are preserved. With multiple clients, explicit client paths are used; ambiguous paths remain disabled and produce warnings for review. Migration does not create path mappings. +For one client, a missing download root inherits the old completed folder (reverse-mapped only when unambiguous). The monitor uses the old free-space folder, completed folder, then mapped client download path. The old limit and disabled state are preserved. With multiple clients, explicit client paths are used; ambiguous paths remain disabled and produce warnings for review. Migration does not create path mappings. Unresolved migration warnings are saved under each client’s `Migration.Notices` and appear as issues linked to Config. Review and correct the indicated settings, then clear the reviewed notice entries in `config.toml`. Missing mappings required for recycling and invalid/inaccessible configured mapping roots also create issues before cleanup is attempted; those issues resolve after correction. Legacy `TORRENTARR_SETTINGS_*` environment overrides and their `QBITRR_SETTINGS_*` aliases still apply at runtime through equivalent conversion, without being written to disk. Explicitly empty primary variables suppress their aliases. @@ -854,14 +864,14 @@ Sonarr and Lidarr catalogs always browse as series and artist rows (qBitrr 5.14) ### Theme ```toml -Theme = "Dark" +Theme = "System" ``` **Type:** String -**Default:** `"Dark"` -**Options:** `Dark`, `Light` +**Default:** `"System"` +**Options:** `System`, `Light`, `Dark`, `Midnight` -WebUI color theme. +WebUI color theme. System follows the operating system preference; Midnight uses a black background. - `Dark` - Dark mode (easier on eyes) - `Light` - Light mode (better in bright environments) diff --git a/docs/configuration/maintenance-controls.md b/docs/configuration/maintenance-controls.md index b500b5f1..63fdee0f 100644 --- a/docs/configuration/maintenance-controls.md +++ b/docs/configuration/maintenance-controls.md @@ -40,70 +40,70 @@ Generated by `Torrentarr.ConfigSchemaGenerator`. All maintenance controls are av | `Maintenance.RecycleBin.SplitByCategory` | checkbox | `false` | | | `Maintenance.RecycleBin.ResumeDataPath` | text | `""` | Optional resume-data backup directory, used when the client supports export. | | `Maintenance.Operations.OrphanScan.Enabled` | checkbox | `false` | Explicit operation override. Existing configs without an override retain their legacy enablement. | -| `Maintenance.Operations.OrphanScan.ExecutionMode` | select | `"manual"` | Manual applies a reviewed preview once. Automatic runs on the saved schedule and remains enabled after restart; no restart is required. | +| `Maintenance.Operations.OrphanScan.ExecutionMode` | select | `"manual"` | Manual applies a reviewed preview once. Automatic runs on the saved schedule and remains enabled after restart. | | `Maintenance.Operations.OrphanScan.Schedule` | text | `"15 3 * * *"` | Five-field cron expression in UTC. Blank inherits the client schedule. | | `Maintenance.Operations.OrphanScan.IncludedCategories` | tags | `[]` | Blank inherits client-wide scope. Entries narrow that scope. | | `Maintenance.Operations.OrphanScan.ExcludedCategories` | tags | `[]` | | | `Maintenance.Operations.OrphanScan.ExcludedTags` | tags | `[]` | | | `Maintenance.Operations.OrphanScan.MaxActionsPerRun` | number | `null` | Blank preserves operation limits. Zero permits no mutations. | | `Maintenance.Operations.HardlinkAudit.Enabled` | checkbox | `false` | Explicit operation override. Existing configs without an override retain their legacy enablement. | -| `Maintenance.Operations.HardlinkAudit.ExecutionMode` | select | `"manual"` | Manual applies a reviewed preview once. Automatic runs on the saved schedule and remains enabled after restart; no restart is required. | +| `Maintenance.Operations.HardlinkAudit.ExecutionMode` | select | `"manual"` | Manual applies a reviewed preview once. Automatic runs on the saved schedule and remains enabled after restart. | | `Maintenance.Operations.HardlinkAudit.Schedule` | text | `"15 4 * * *"` | Five-field cron expression in UTC. Blank inherits the client schedule. | | `Maintenance.Operations.HardlinkAudit.IncludedCategories` | tags | `[]` | Blank inherits client-wide scope. Entries narrow that scope. | | `Maintenance.Operations.HardlinkAudit.ExcludedCategories` | tags | `[]` | | | `Maintenance.Operations.HardlinkAudit.ExcludedTags` | tags | `[]` | | | `Maintenance.Operations.HardlinkAudit.MaxActionsPerRun` | number | `null` | Blank preserves operation limits. Zero permits no mutations. | | `Maintenance.Operations.UnregisteredCleanup.Enabled` | checkbox | `false` | Explicit operation override. Existing configs without an override retain their legacy enablement. | -| `Maintenance.Operations.UnregisteredCleanup.ExecutionMode` | select | `"manual"` | Manual applies a reviewed preview once. Automatic runs on the saved schedule and remains enabled after restart; no restart is required. | +| `Maintenance.Operations.UnregisteredCleanup.ExecutionMode` | select | `"manual"` | Manual applies a reviewed preview once. Automatic runs on the saved schedule and remains enabled after restart. | | `Maintenance.Operations.UnregisteredCleanup.Schedule` | text | `"*/30 * * * *"` | Five-field cron expression in UTC. Blank inherits the client schedule. | | `Maintenance.Operations.UnregisteredCleanup.IncludedCategories` | tags | `[]` | Blank inherits client-wide scope. Entries narrow that scope. | | `Maintenance.Operations.UnregisteredCleanup.ExcludedCategories` | tags | `[]` | | | `Maintenance.Operations.UnregisteredCleanup.ExcludedTags` | tags | `[]` | | | `Maintenance.Operations.UnregisteredCleanup.MaxActionsPerRun` | number | `null` | Blank preserves operation limits. Zero permits no mutations. | | `Maintenance.Operations.CategoryReconcile.Enabled` | checkbox | `false` | Explicit operation override. Existing configs without an override retain their legacy enablement. | -| `Maintenance.Operations.CategoryReconcile.ExecutionMode` | select | `"manual"` | Manual applies a reviewed preview once. Automatic runs on the saved schedule and remains enabled after restart; no restart is required. | +| `Maintenance.Operations.CategoryReconcile.ExecutionMode` | select | `"manual"` | Manual applies a reviewed preview once. Automatic runs on the saved schedule and remains enabled after restart. | | `Maintenance.Operations.CategoryReconcile.Schedule` | text | `"*/15 * * * *"` | Five-field cron expression in UTC. Blank inherits the client schedule. | | `Maintenance.Operations.CategoryReconcile.IncludedCategories` | tags | `[]` | Blank inherits client-wide scope. Entries narrow that scope. | | `Maintenance.Operations.CategoryReconcile.ExcludedCategories` | tags | `[]` | | | `Maintenance.Operations.CategoryReconcile.ExcludedTags` | tags | `[]` | | | `Maintenance.Operations.CategoryReconcile.MaxActionsPerRun` | number | `null` | Blank preserves operation limits. Zero permits no mutations. | | `Maintenance.Operations.AutomaticManagement.Enabled` | checkbox | `false` | Explicit operation override. Existing configs without an override retain their legacy enablement. | -| `Maintenance.Operations.AutomaticManagement.ExecutionMode` | select | `"manual"` | Manual applies a reviewed preview once. Automatic runs on the saved schedule and remains enabled after restart; no restart is required. | +| `Maintenance.Operations.AutomaticManagement.ExecutionMode` | select | `"manual"` | Manual applies a reviewed preview once. Automatic runs on the saved schedule and remains enabled after restart. | | `Maintenance.Operations.AutomaticManagement.Schedule` | text | `"*/30 * * * *"` | Five-field cron expression in UTC. Blank inherits the client schedule. | | `Maintenance.Operations.AutomaticManagement.IncludedCategories` | tags | `[]` | Blank inherits client-wide scope. Entries narrow that scope. | | `Maintenance.Operations.AutomaticManagement.ExcludedCategories` | tags | `[]` | | | `Maintenance.Operations.AutomaticManagement.ExcludedTags` | tags | `[]` | | | `Maintenance.Operations.AutomaticManagement.MaxActionsPerRun` | number | `null` | Blank preserves operation limits. Zero permits no mutations. | | `Maintenance.Operations.PrivateTagging.Enabled` | checkbox | `false` | Explicit operation override. Existing configs without an override retain their legacy enablement. | -| `Maintenance.Operations.PrivateTagging.ExecutionMode` | select | `"manual"` | Manual applies a reviewed preview once. Automatic runs on the saved schedule and remains enabled after restart; no restart is required. | +| `Maintenance.Operations.PrivateTagging.ExecutionMode` | select | `"manual"` | Manual applies a reviewed preview once. Automatic runs on the saved schedule and remains enabled after restart. | | `Maintenance.Operations.PrivateTagging.Schedule` | text | `"*/30 * * * *"` | Five-field cron expression in UTC. Blank inherits the client schedule. | | `Maintenance.Operations.PrivateTagging.IncludedCategories` | tags | `[]` | Blank inherits client-wide scope. Entries narrow that scope. | | `Maintenance.Operations.PrivateTagging.ExcludedCategories` | tags | `[]` | | | `Maintenance.Operations.PrivateTagging.ExcludedTags` | tags | `[]` | | | `Maintenance.Operations.PrivateTagging.MaxActionsPerRun` | number | `null` | Blank preserves operation limits. Zero permits no mutations. | | `Maintenance.Operations.TrackerErrorTagging.Enabled` | checkbox | `false` | Explicit operation override. Existing configs without an override retain their legacy enablement. | -| `Maintenance.Operations.TrackerErrorTagging.ExecutionMode` | select | `"manual"` | Manual applies a reviewed preview once. Automatic runs on the saved schedule and remains enabled after restart; no restart is required. | +| `Maintenance.Operations.TrackerErrorTagging.ExecutionMode` | select | `"manual"` | Manual applies a reviewed preview once. Automatic runs on the saved schedule and remains enabled after restart. | | `Maintenance.Operations.TrackerErrorTagging.Schedule` | text | `"*/30 * * * *"` | Five-field cron expression in UTC. Blank inherits the client schedule. | | `Maintenance.Operations.TrackerErrorTagging.IncludedCategories` | tags | `[]` | Blank inherits client-wide scope. Entries narrow that scope. | | `Maintenance.Operations.TrackerErrorTagging.ExcludedCategories` | tags | `[]` | | | `Maintenance.Operations.TrackerErrorTagging.ExcludedTags` | tags | `[]` | | | `Maintenance.Operations.TrackerErrorTagging.MaxActionsPerRun` | number | `null` | Blank preserves operation limits. Zero permits no mutations. | | `Maintenance.Operations.RepairPaused.Enabled` | checkbox | `false` | Explicit operation override. Existing configs without an override retain their legacy enablement. | -| `Maintenance.Operations.RepairPaused.ExecutionMode` | select | `"manual"` | Manual applies a reviewed preview once. Automatic runs on the saved schedule and remains enabled after restart; no restart is required. | +| `Maintenance.Operations.RepairPaused.ExecutionMode` | select | `"manual"` | Manual applies a reviewed preview once. Automatic runs on the saved schedule and remains enabled after restart. | | `Maintenance.Operations.RepairPaused.Schedule` | text | `"*/30 * * * *"` | Five-field cron expression in UTC. Blank inherits the client schedule. | | `Maintenance.Operations.RepairPaused.IncludedCategories` | tags | `[]` | Blank inherits client-wide scope. Entries narrow that scope. | | `Maintenance.Operations.RepairPaused.ExcludedCategories` | tags | `[]` | | | `Maintenance.Operations.RepairPaused.ExcludedTags` | tags | `[]` | | | `Maintenance.Operations.RepairPaused.MaxActionsPerRun` | number | `null` | Blank preserves operation limits. Zero permits no mutations. | | `Maintenance.Operations.RecycleRetention.Enabled` | checkbox | `false` | Explicit operation override. Existing configs without an override retain their legacy enablement. | -| `Maintenance.Operations.RecycleRetention.ExecutionMode` | select | `"manual"` | Manual applies a reviewed preview once. Automatic runs on the saved schedule and remains enabled after restart; no restart is required. | +| `Maintenance.Operations.RecycleRetention.ExecutionMode` | select | `"manual"` | Manual applies a reviewed preview once. Automatic runs on the saved schedule and remains enabled after restart. | | `Maintenance.Operations.RecycleRetention.Schedule` | text | `"45 4 * * *"` | Five-field cron expression in UTC. Blank inherits the client schedule. | | `Maintenance.Operations.RecycleRetention.IncludedCategories` | tags | `[]` | Blank inherits client-wide scope. Entries narrow that scope. | | `Maintenance.Operations.RecycleRetention.ExcludedCategories` | tags | `[]` | | | `Maintenance.Operations.RecycleRetention.ExcludedTags` | tags | `[]` | | | `Maintenance.Operations.RecycleRetention.MaxActionsPerRun` | number | `null` | Blank preserves operation limits. Zero permits no mutations. | | `Maintenance.Operations.ErrorRecovery.Enabled` | checkbox | `false` | Explicit operation override. Existing configs without an override retain their legacy enablement. | -| `Maintenance.Operations.ErrorRecovery.ExecutionMode` | select | `"manual"` | Manual applies a reviewed preview once. Automatic runs on the saved schedule and remains enabled after restart; no restart is required. | +| `Maintenance.Operations.ErrorRecovery.ExecutionMode` | select | `"manual"` | Manual applies a reviewed preview once. Automatic runs on the saved schedule and remains enabled after restart. | | `Maintenance.Operations.ErrorRecovery.Schedule` | text | `"*/5 * * * *"` | Five-field cron expression in UTC. Blank inherits the client schedule. | | `Maintenance.Operations.ErrorRecovery.IncludedCategories` | tags | `[]` | Blank inherits client-wide scope. Entries narrow that scope. | | `Maintenance.Operations.ErrorRecovery.ExcludedCategories` | tags | `[]` | | diff --git a/docs/configuration/seeding.md b/docs/configuration/seeding.md index 46e5e7d7..b2bcbf61 100644 --- a/docs/configuration/seeding.md +++ b/docs/configuration/seeding.md @@ -68,6 +68,9 @@ TorrentLeech rules are documented in its [HnR rules](https://wiki.torrentleech.o and [user classes](https://wiki.torrentleech.org/doku.php/user_classes). Freeleech remains protected. OnlyEncodes rules above are supplied by the account owner; its 0.4 overall account-ratio requirement is separate from per-torrent seed-time clearance. +A tracker that is configured with `HitAndRunMode = 'disabled'` and no `Protection` +has no clearance obligation and is treated as eligible. Only a private tracker with +no matching configuration stays `unknown` and is preserved. ### Protection controls @@ -122,14 +125,14 @@ A missed deadline preserves protection and triggers reporting/recovery. Protection checks normally run at least every five minutes when enabled, with targeted one-minute inspection for deadline or native stop-limit risk. Monitoring runs independently of long maintenance jobs. Outages may delay these checks; failures back off and preserve protection. These checks cannot guarantee service -availability or tracker-side acceptance. Authorized Torrentarr-owned pauses may be -recovered; deliberate user pauses remain blockers unless explicit paused-repair -resume permission was selected. +availability or tracker-side acceptance. H&R only restricts torrent/content deletion and recycling. Imports, pause/resume, tracker/category changes, and repair follow their configured policies and ownership checks independently. Per-torrent native ratio, seed-time and inactive seed-time stop limits can interrupt -protected seeding. Explicit protection temporarily overrides conflicting limits and -records their original values, restoring them after clearance if the user has not -changed them. Global client preferences are unchanged. See the [official qBittorrent +protected seeding. Protection temporarily overrides conflicting limits and records +their original values, restoring them after clearance if the user has not changed +them. Private torrents are reconciled even when no tracker policy is configured, so +qBittorrent cannot natively remove an unmatched private torrent before review. +Global client preferences are unchanged. See the [official qBittorrent share-limit contract](https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-%28qBittorrent-5.0%29#set-torrent-share-limit). ## Global seeding settings @@ -172,7 +175,10 @@ Ordinary tracker handling preserves and reports failures. Manage recovery may re ### RemoveTrackerWithMessage Tracker-message matching for ordinary tracker handling. Removing a configured -tracker remains subject to fresh protection checks. +tracker remains subject to fresh protection checks. Private torrents keep the +configured tracker URL and their last remaining announce URL even when a message +matches; removing either would destroy the only way to re-announce. Explicit +`RemoveIfExists` actions are unchanged. ## Per-tracker configuration @@ -227,7 +233,10 @@ New policies expose AccountingGraceMinutes and AdditionalSeedHours independently When protection is explicitly configured, conflicting managed per-torrent stop limits are recorded and temporarily disabled to preserve seeding. After clearance, previous values are restored only if the unlimited values still belong to Torrentarr; -later user changes are retained. Global qBittorrent preferences are never changed. +later user changes are retained. Private torrents without a matching tracker policy +are also treated as unresolved and keep their native stop limits disabled until +configuration or manual review resolves them. Global qBittorrent preferences are +never changed. A stopped torrent with unknown pause ownership is reported rather than assumed safe to resume. Review client global stop/queue settings separately. diff --git a/docs/configuration/torrents.md b/docs/configuration/torrents.md index 332b6e3b..fa5caa16 100644 --- a/docs/configuration/torrents.md +++ b/docs/configuration/torrents.md @@ -448,10 +448,15 @@ UploadRateLimitPerTorrent = -1 # KB/s upload limit (-1 = unlimited) MaxUploadRatio = 2.0 # Remove after 2:1 ratio MaxSeedingTime = 43200 # Remove after 12 hours (seconds) RemoveTorrent = -1 # Additional removal delay (seconds) -RemoveDeadTrackers = false # Remove torrents with dead trackers -RemoveTrackerWithMessage = [] # Remove if tracker message matches +RemoveDeadTrackers = false # Report dead trackers; never delete torrents +RemoveTrackerWithMessage = [] # Remove matching tracker URLs; private configured/last URLs preserved ``` +While protection is unmet or a private tracker is unmatched, Torrentarr records and +temporarily clears conflicting per-torrent native stop limits so qBittorrent cannot +remove the torrent first. Restored only when Torrentarr still owns the unlimited +values and the torrent identity matches. + **See also:** [Seeding Configuration Guide](seeding.md) --- @@ -925,3 +930,13 @@ Adjust patterns if too many valid files are excluded. - **Stalled handling** - Configure `StalledDelay` and `DoNotRemoveSlow` - **Tracker overrides** - Custom settings per tracker for optimal performance - **Start conservative** and enable automation after testing + +## Import rejection and deferred removal + +An import allowlist warning means that the configured rules excluded content needed for import. Executables remain excluded. Rejected `.rar` and split `.r00`/`.r01` archive files are identified in the diagnostic; Torrentarr does not extract archives through this workflow. Use an appropriate unpacking workflow outside Torrentarr or select a release containing allowed media files. Adding archive extensions to the allowlist does not extract them. + +Import rejection and cleanup are separate outcomes. H&R can preserve a rejected release until its configured obligations are met. A healthy protection wait is informational; missing mappings, unknown tracker evidence, and tracker deadline or native-stop risks remain actionable warnings. Legacy diagnostics use client-reported seed time, including the configured tracker update buffer; remaining time describes only the time requirement and does not clear unmet ratio obligations. + +Unchanged allowlist and removal diagnostics move to Debug after the first message. Changed blockers or configuration produce a fresh message; retries continue normally and operational issues remain current. Suppression is in memory and resets after a restart or cache eviction. + +Configured file inclusion/exclusion rules determine import eligibility. If allowed content remains and ordinary readiness checks pass, excluded files remaining on disk do not block import. `AutoDelete` cleanup is independent and retries safely; H&R can prevent that cleanup or torrent removal without suppressing the import. This applies to all excluded file types, with no screenshot or metadata exception list. All-files-excluded releases remain rejected, and unsafe paths, missing storage, failed filtering requests, and Arr failures remain actionable. diff --git a/docs/configuration/webui.md b/docs/configuration/webui.md index ddfff45c..69a194b6 100644 --- a/docs/configuration/webui.md +++ b/docs/configuration/webui.md @@ -274,17 +274,19 @@ UI density setting for tables and lists. ## Theme ```toml -Theme = "Dark" +Theme = "System" ``` **Type:** String -**Default:** `"Dark"` -**Options:** `"Dark"`, `"Light"` +**Default:** `"System"` +**Options:** `"System"`, `"Light"`, `"Dark"`, `"Midnight"` Default color theme for the WebUI. +- `"System"` - Follow the operating system's light or dark preference - `"Dark"` - Dark mode (easier on eyes, lower power consumption) - `"Light"` - Light mode (better in bright environments) +- `"Midnight"` - Black background with near-black surfaces for OLED displays **Note:** Users can toggle theme in the WebUI itself. This sets the initial default. diff --git a/docs/development/contributing.md b/docs/development/contributing.md index 8d9cdfbd..3827f604 100644 --- a/docs/development/contributing.md +++ b/docs/development/contributing.md @@ -121,7 +121,7 @@ Why is this change needed? What problem does it solve? ## Testing How was this tested? - [ ] Tested with qBittorrent v4.6.0 -- [ ] Tested with Radarr v5.0.0 +- [ ] Tested with Radarr v7.0.2 - [ ] Tested in Docker - [ ] Tested native install diff --git a/docs/faq.md b/docs/faq.md index 6bc858b9..f310e857 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -21,7 +21,7 @@ Torrentarr solves several common problems: Torrentarr works with: - **qBittorrent**: v4.x and v5.x (including 5.2) -- **Radarr**: v3.x, v4.x, v5.x, and v6.x +- **Radarr**: v3.x, v4.x, v5.x, v6.x, and v7.x - **Sonarr**: v3.x, v4.x, and v5.x - **Lidarr**: v1.x and v2.x - **Runtime**: Self-contained **binary** (no separate .NET install) or **Docker**; **from source** needs a .NET SDK diff --git a/docs/features/index.md b/docs/features/index.md index 50fb5359..7aad49c9 100644 --- a/docs/features/index.md +++ b/docs/features/index.md @@ -413,7 +413,7 @@ Port = 6969 Token = "your-secret-token" # Theme -Theme = "Dark" # Dark or Light +Theme = "Dark" # System, Light, Dark, or Midnight ``` **Security:** diff --git a/docs/features/torrent-maintenance.md b/docs/features/torrent-maintenance.md index 3a15a55b..b6163f9d 100644 --- a/docs/features/torrent-maintenance.md +++ b/docs/features/torrent-maintenance.md @@ -23,7 +23,7 @@ Unsaved changes must be saved or discarded before switching clients or leaving. ## Scheduling and protection -Every torrent removal and deletion of referenced torrent content requires current HnR clearance. Manual execution, custom-format rejection, failed categories, import errors, and file exclusions cannot bypass it. Unmet or unknown obligations preserve the torrent and content. Arr queue removal and blocklisting for failed downloads follow confirmed protected removal; Arr is not asked to remove the torrent independently. Recycle preparation rechecks clearance and restores moved content if clearance changes before client removal. +Every torrent removal and deletion of referenced torrent content requires current HnR clearance. Manual execution, custom-format rejection, failed categories, import errors, and file exclusions cannot bypass it. Unmet or unknown obligations prevent those deletion actions. They do not block imports, configured pauses/resumes, tracker/category changes, or repair; those actions retain their own scope, capability, path, and ownership checks. Arr queue removal and blocklisting for failed downloads follow confirmed protected removal; Arr is not asked to remove the torrent independently. Recycle preparation rechecks clearance and restores moved content if clearance changes before client removal. Manual mode applies a reviewed preview once. Automatic mode runs on the saved schedule without requiring a restart and retains scheduling across restarts. Existing session settings migrate to Manual with their schedules preserved. Cron expressions use UTC. Missed runs are not replayed; persisted claims prevent the same scheduled minute from running twice after restart. diff --git a/docs/getting-started/installation/index.md b/docs/getting-started/installation/index.md index f2113e3a..93260bad 100644 --- a/docs/getting-started/installation/index.md +++ b/docs/getting-started/installation/index.md @@ -131,7 +131,7 @@ Regardless of installation method, you'll need: - Authentication configured 2. **Arr Instance** - At least one of: - - Radarr (v3.x, v4.x, v5.x, v6.x) + - Radarr (v3.x, v4.x, v5.x, v6.x, v7.x) - Sonarr (v3.x, v4.x, v5.x) - Lidarr (v1.x, v2.x) diff --git a/docs/index.md b/docs/index.md index 0450fbb7..e6e17315 100644 --- a/docs/index.md +++ b/docs/index.md @@ -215,7 +215,7 @@ Total time to library: 20.5 minutes (26% faster) - **qBittorrent**: Version 4.1.0+ or 5.x (including 5.3) with WebUI enabled - **Arr Instance**: At least one of: - - Radarr: v3.x, v4.x, v5.x, v6.x + - Radarr: v3.x, v4.x, v5.x, v6.x, v7.x - Sonarr: v3.x, v4.x, v5.x - Lidarr: v1.x, v2.x - Readarr: v0.4+ @@ -248,6 +248,7 @@ Versions are detected separately for each configured client. Mixed 4.x and 5.x i | Version | Status | Notes | |---------|--------|-------| +| 7.x | ✅ Fully Supported | Tested with 7.0.2; still uses `/api/v3` | | 6.x | ✅ Fully Supported | Still uses `/api/v3` | | 5.x | ✅ Fully Supported | Stable | | 4.x | ✅ Fully Supported | Stable | diff --git a/docs/webui/arr-views.md b/docs/webui/arr-views.md index 041b1703..32bd12a8 100644 --- a/docs/webui/arr-views.md +++ b/docs/webui/arr-views.md @@ -2,6 +2,14 @@ The **Arr Views** provide a unified interface to browse and monitor your media library across all managed Radarr, Sonarr, and Lidarr instances. View movies, TV series, episodes, albums, and tracks directly from Torrentarr's WebUI without switching between multiple Arr interfaces. +Expand a library type in the main sidebar to choose an instance or **All**. Torrent +Clients uses the same navigation; there is no secondary instance sidebar. On mobile, +open **Menu** to reach these entries. + +Local database statistics refresh through the statistics stream, independently of +the direct Arr Live toggle. Hover, focus, or tap an availability-bar segment for its +exact count and percentage. + --- ## Overview @@ -9,7 +17,7 @@ The **Arr Views** provide a unified interface to browse and monitor your media l Torrentarr's Arr views offer: - **Unified Library Browser**: View all media from multiple Arr instances in one place -- **Real-time Sync**: Data refreshes from Arr APIs with configurable intervals +- **Live statistics**: Local database commits refresh visible statistics; direct Arr polling remains separately controlled - **Advanced Filtering**: Filter by monitored status, file availability, quality, requests - **Search**: Full-text search across titles, years, artists - **Quality Profile Display**: See which quality profile is assigned to each entry diff --git a/docs/webui/config-editor.md b/docs/webui/config-editor.md index e207cf7c..055271de 100644 --- a/docs/webui/config-editor.md +++ b/docs/webui/config-editor.md @@ -1,15 +1,39 @@ -# Configuration editor +# Configuration editor -The Config view edits the running Host's TOML through its configuration API. Open -Configure on a torrent client, Arr instance, Global Settings, Web Settings or -Authentication. Identity and connection sections start open; secondary sections -collapse and open when they contain validation errors. Searchable controls retain -password redaction, tooltips, connection testing and specialized collection editors. -Unsaved edits remain local until saved; cancelling does not publish them. +The Config view edits the running Host's TOML through its configuration API. +Use the Settings, Web Settings, Authentication, Torrent Clients, and Integrations +tabs. Integrations has separate Radarr, Sonarr, Lidarr, and Readarr sections, +including empty types. Torrent Clients groups legacy and canonical entries by +configured type, with qBittorrent first. Each torrent-client section shows its +instance count and an **Add Instance** button that uses that type's defaults. +Integrations instead has one **Add Instance** button above its sections; it +opens an integration-type picker and creates the chosen type's next instance. + +Instance rows show address, category scope, enabled state, and unsaved changes; +client rows also show scan intervals, while integration rows show search/processing +mode. Both instance tabs start with their overview; select a row or add an instance +to open its inline editor. Returning to the tab hides the editor while retaining +its draft. Field search appears only while an editor is open. Editors and Save +controls use the page scroll, without a nested modal scroll area. + +Use **Find a setting in this section** to search field names and descriptions. +Matching groups open automatically. Dirty section markers and the Unsaved changes +badge identify local drafts. Switching sections preserves those drafts; saving +publishes only the selected section. Leaving Config retains its existing +unsaved-change confirmation. + +The editors preserve password redaction, validation, connection testing, tooltips, +and specialized collection editors. Destructive changes still require confirmation. + +Ordinary successful saves report **Configuration saved**. Pending application, +errors, and warnings remain visible. Only explicit restart requirements returned +by the save API open a **Restart now / Later** confirmation listing affected +components. Deferral or restart failure keeps a pending notice with retry; a failed +save never triggers a restart. ## Section organization -| Dialog | Ordered sections | +| Editor | Ordered sections | | --- | --- | | Torrent client | General; Connection; Torrent scope; Download location; Free-space monitors; Seeding and Hit & Run; Trackers; Manage shortcut | | Arr instance | General; Connection; Categories and torrent clients; Entry search; Quality profiles; Request integrations; Torrent handling; Seeding; Trackers | @@ -20,7 +44,7 @@ Unsaved edits remain local until saved; cancelling does not publish them. Host, Port, Username, Password and Skip TLS Verify are consecutive in a client Connection section. Arr URI, API key, TLS and connection testing stay together; request integration credentials and their TLS settings are grouped. Each exposed -field appears once in its dialog. Unrecognized saved fields survive ordinary +field appears once in its editor. Unrecognized saved fields survive ordinary partial updates. Blank redacted password placeholders retain the stored secret; use the password control to explicitly replace a credential. @@ -59,7 +83,7 @@ monitors after ambiguous multi-client migration. See [storage migration](../conf ## Manage tools -Maintenance forms are available only in **Manage**. The torrent-client dialog shows a compact summary and an **Open Manage** shortcut selecting that saved client. Save a new or renamed client before using it. The shortcut preserves other unsaved client edits. +Maintenance forms are available only in **Manage**. The torrent-client editor shows a compact summary and an **Open Manage** shortcut selecting that saved client. Save a new or renamed client before using it. The shortcut preserves other unsaved client edits. Manage groups all ten tools under Recovery, Organization, and Storage. Expand one card for its settings and actions, with Advanced initially collapsed. Shared scope, mappings, recycling, and safeguards appear once in Shared settings. Save and Discard affect only the selected client's maintenance settings. diff --git a/docs/webui/console-statistics.md b/docs/webui/console-statistics.md new file mode 100644 index 00000000..1e6225dc --- /dev/null +++ b/docs/webui/console-statistics.md @@ -0,0 +1,29 @@ +# Console and statistics + +Torrentarr uses grouped navigation for monitoring, libraries and clients, and administration. Display controls retain the saved theme and density preferences. Small screens use the Menu drawer. + +Transfer activity appears inside Torrent Clients. It records whole-client download/upload rates, including traffic outside monitored categories. Collection runs on the server at each client’s configured `TorrentScanIntervalSeconds`, even with the browser closed; history is stored in minute buckets. Samples survive restarts, are kept for seven days in `torrentarr.db`, and are pruned hourly. No history is backfilled. + +Choose 1 hour, 24 hours (default), or 7 days. Graphs use completed 1-, 5-, or 30-minute intervals. Each client has its own series; absent or incomplete intervals break the line. Coverage and the latest complete interval are labeled. Disabled clients are not sampled. Disconnected clients and unsupported metrics produce gaps rather than zero traffic. Database notifications refresh visible statistics independently of Live Arr polling; the existing Refresh button also refreshes statistics. Changing range or client fetches that selection once. + +Monitored-category activity uses full inventories before the 500-row display cap and deduplicates overlapping categories. It is separate from whole-client transfer history and text search. Unavailable client inventories are excluded and partial coverage is labeled. + +Library bars report available and missing **monitored** movies, episodes, albums, or books from the full selected catalog, independently of page and text filters. Monitoring is a separate count. Manage summarizes operation outcomes in the latest 20 displayed runs. + +The `/web/statistics/transfers` and `/web/statistics/catalog` endpoints follow the configured WebUI authentication policy; their parameters and payloads are documented in OpenAPI. The history table is a Torrentarr extension and does not change qBitrr-compatible tables or TOML configuration. + +The logo is true vector artwork with transparent negative space, traced and shaded from the PNG reference. WebUI and documentation use the SVG; platform icons are raster exports. + +## Arr activity + +Processes uses aligned instance rows. Each Arr Torrent activity target opens its category in Torrent Clients, selecting a unique matching client or all clients when the category is shared. Old torrent searches are cleared. Search activity and restart actions remain separate. Wiki and GitHub links are available in both navigation footers. + +The two inline Arr graphs show queue depth and confirmed completed activity. Choose 1h, 24h (default), or 7d and group by Arr type (default) or individual worker. Radarr is orange, Sonarr teal, Lidarr green, and Readarr violet; worker colors are stable and series are distinguished by color only. Legends toggle series. Hover or focus a plotted point, touch it, or use the keyboard-accessible time slider to inspect exact timestamps and values. Dedicated sampled-data tables are omitted. + +Queue observations come from fresh worker heartbeats every minute. Queue depth is the Arr download queue total reported by the worker heartbeat; stale, stopped, or unavailable workers leave gaps. Graphs use per-minute buckets for 1h and hourly buckets for 24h and 7d. Queue buckets are averages rounded to whole items; type totals sum worker averages and disclose incomplete coverage. Completion buckets sum confirmed observations, separately for searches and torrent-import batches. Partial coverage breaks the line; it never becomes a fabricated zero. + +Only Torrentarr-submitted commands are tracked. Search requests count after Arr reports successful completion. Import requests count only with a correlated positive download-import history event dated after submission. Accepted commands, completed scans with no import, queue disappearance, tags, and failed/cancelled work do not count. Multiple files in one torrent batch and retries sharing confirmation evidence do not inflate the count. Counts use the time confirmation was recorded. Pending records survive worker restarts; unconfirmed work expires after seven days. There is no historical backfill. + +The server reconciles only instances with pending work, at most at each client’s configured `TorrentScanIntervalSeconds`, using the existing API-version and TLS settings. Telemetry failures do not change search/import/protection behavior. Additional Torrentarr-specific tables retain samples and operation evidence for seven days; pruning runs hourly. Standalone WebUI cannot supply worker queue observations when no supervisor is present. + +`GET /web/statistics/arr-activity?range=24h&worker=Radarr-example` follows WebUI authentication and returns worker identity/type, timestamped values, queue/completion coverage, and expected sample counts. Omit worker for all configured workers and retained history. Existing transfer-history endpoints are unchanged. diff --git a/docs/webui/logs.md b/docs/webui/logs.md index 5280abfa..0568da77 100644 --- a/docs/webui/logs.md +++ b/docs/webui/logs.md @@ -32,9 +32,9 @@ attempts/retries and decision reasons; TRACE adds snapshots, effective settings and rule inputs. Runtime level selection is available from Processes. Persistent changes belong in the configuration editor. See [Debug logging](../troubleshooting/debug-logging.md). -Files append on restart and rotate daily with seven files retained per stream. -There is **no size-based rotation or file size cap**. Large TRACE files remain -accessible through bounded tail reads and streaming downloads. +Use **INFO** for normal operation and enable TRACE temporarily for investigations. Repeated per-item skips and database details require DEBUG/TRACE; cycle summaries and actionable failures remain available at INFO. + +Files append on restart and rotate daily or when they reach **128 MiB**. Each stream retains at most **eight files**, with a **seven-day age limit**; whichever limit is reached first applies. Size rotation uses numbered suffixes, and individual events remain intact. Existing oversized files age out through normal retention. Aggregate and per-service streams are retained, and sanitized file-sink diagnostics are written to stderr. ## API diff --git a/docs/webui/processes.md b/docs/webui/processes.md index d0624083..f14b0a85 100644 --- a/docs/webui/processes.md +++ b/docs/webui/processes.md @@ -1,423 +1,57 @@ -# Processes View - -The **Processes View** provides real-time monitoring and control of Torrentarr's multiprocessing architecture. Each Arr instance (Radarr/Sonarr/Lidarr) runs two independent processes—**search** and **torrent**—that can be monitored and restarted individually. - ---- - -## Overview - -Torrentarr orchestrates torrent management across multiple Arr instances using a multiprocessing model. The Processes tab gives you visibility into: - -- **Process Status**: Whether each process is running or stopped -- **Search Activity**: Latest search operations and their timestamps -- **Queue & Category Metrics**: Torrent counts in Arr queues and qBittorrent categories -- **Manual Restart**: Restart individual processes or rebuild all Arr connections - ---- - -## Process Architecture - -### Process Types - -Each managed Arr instance spawns **two separate processes**: - -1. **Search Process** (`search`) - - Polls Radarr/Sonarr/Lidarr for missing/wanted media - - Triggers searches based on Search configuration - - Handles Overseerr/Ombi request integration - - Logs the most recent search operation (e.g., "Movie Title (2023)") - -2. **Torrent Process** (`torrent`) - - Monitors qBittorrent for active torrents in the Arr's category - - Performs health checks (stalled, failed, slow transfers) - - Triggers instant imports when torrents complete - - Cleans up completed/seeded torrents based on SeedingMode settings - -### Special Processes - -- **Free Space Manager** (shown under **Other** when qBit is configured) - - Monitors disk space and pauses torrents when threshold is reached - - Displays total count of torrents paused due to low disk space across all qBit instances - -- **Recheck** and **Failed** (shown under **Other** when qBit is configured) - - Host-managed categories: Recheck shows torrent count in `Settings.RecheckCategory`, Failed shows count in `Settings.FailedCategory`, each summed across all qBit instances - -- **qBittorrent** (if configured as a category-only tracker) - - Displays total torrent count in the category - ---- - -## UI Layout - -### Section Groups - -Processes are grouped by application type: - -- **Radarr**: All Radarr instances (e.g., `Radarr-Movies`, `Radarr-4K`) -- **Sonarr**: All Sonarr instances (e.g., `Sonarr-TV`, `Sonarr-Anime`) -- **qBittorrent**: Category-only processes and synthetic instance cards -- **Other**: Shown **only when at least one qBittorrent instance is configured**. Contains three Host-managed cards: - - **Recheck** — Total torrent count in the recheck category (`Settings.RecheckCategory`) across all qBit instances - - **Failed** — Total torrent count in the failed category (`Settings.FailedCategory`) across all qBit instances - - **Free Space Manager** — Total count of torrents currently paused due to low disk space (across all instances) - - These entries are display-only; restart does not apply (they are managed by the Host orchestrator, not per-Arr workers). - -### Process Cards - -Each instance displays: - -```plaintext -┌────────────────────────────────────┐ -│ Instance Name [Status] │ -│ 2 processes │ -│ │ -│ Search 🟢 │ -│ Last search: Movie Title (2023) │ -│ [Restart] │ -│ │ -│ Torrent 🟢 │ -│ Queue: 3 / Category: 12 │ -│ [Restart] │ -│ │ -│ [Restart All] │ -└────────────────────────────────────┘ -``` - -**Field Descriptions**: - -| Field | Description | -|-------|-------------| -| **Instance Name** | Display name from config (e.g., `Radarr-Movies`) | -| **Status Indicator** | 🟢 All running, 🔴 All stopped, 🟠 Partial | -| **Search Summary** | Latest search description or "No searches recorded" | -| **Search Timestamp** | ISO 8601 timestamp of last search operation | -| **Queue Count** | Number of items in Arr's download queue (`/api/v3/queue`) | -| **Category Count** | Number of torrents in qBittorrent with matching category | -| **Metric Type** | Special metric for Free Space (`free-space`) or qBit (`category`) | - ---- - -## Actions - -### Restart Individual Process - -**Location**: Process chip → **Restart** button -**Endpoint**: `POST /api/processes///restart` - -Terminates and recreates the specified process (search or torrent). Useful when: - -- A search loop appears hung -- Torrent monitoring stops updating -- Process crashes and needs manual recovery - -**Example**: -```bash -POST /api/processes/Radarr-Movies/search/restart -``` - -**Response**: -```json -{ - "status": "ok", - "restarted": ["search"] -} -``` - -### Restart All Processes (Group) - -**Location**: Process card → **Restart All** button - -Restarts both search and torrent processes for the selected instance. Equivalent to: -```bash -POST /api/processes/Radarr-Movies/all/restart -``` - -### Restart All Processes (Global) - -**Location**: Top toolbar → **Restart All** button -**Endpoint**: `POST /api/processes/restart_all` - -Kills and recreates **all** search and torrent processes across **all** managed Arr instances. Use when: - -- Configuration changes require full reload -- Systemwide performance issues -- Debugging connectivity problems - -**Confirmation Required**: Modal dialog prevents accidental execution. - -### Rebuild Arrs - -**Location**: Top toolbar → **Rebuild Arrs** button -**Endpoint**: `POST /api/arr/rebuild` - -Recreates the entire Arr manager subsystem: - -1. Terminates all existing processes -2. Reloads configuration from `config.toml` -3. Re-establishes Arr API connections -4. Recreates qBittorrent client -5. Spawns new search and torrent processes - -**Use Cases**: - -- After adding/removing Arr instances in config -- After changing Arr URIs or API keys -- When Arr connections appear corrupted - -**⚠️ Warning**: This operation interrupts all torrent management for 5-15 seconds. - ---- - -## Refresh Behavior - -The Processes view **auto-refreshes** at adaptive intervals: - -| Condition | Refresh Interval | -|-----------|------------------| -| Active search process running | 5 seconds | -| Queue activity (count > 0) | 10 seconds | -| Idle (no activity) | 20 seconds | -| Tab inactive | Paused (resumes on tab focus) | - -**Manual Refresh**: Click the **Refresh** button to force an immediate update. - ---- - -## Process States - -### Running (🟢) - -- Process has a valid PID -- `is_alive()` returns `True` -- Process is actively polling Arr APIs or qBittorrent - -### Stopped (🔴) - -- Process terminated (expected shutdown) -- Process crashed and was not auto-restarted -- `AutoRestartProcesses` disabled in config - -### Rebuilding (🔄) - -- Arr rebuild in progress -- All processes show as inactive during rebuild -- Processes respawn within 10-30 seconds - ---- - -## Metrics Explained - -### Search Process - -**Field**: `searchSummary` -**Source:** Backend services (e.g. `ArrWorkerManager`, `SearchActivity` or equivalent in Torrentarr.Infrastructure / Torrentarr.Host) - -Displays the most recent search operation formatted as: - -- **Radarr**: `Movie Title (Year)` -- **Sonarr**: `Series Name | S01E02` -- **Generic**: Raw search description - -**Sanitization**: - -- Removes release tokens (720p, WEB-DL, x264) -- Removes excessive year/date duplicates -- Trims to first sentence if multi-line - -**Example**: -``` -Before: "Inception 2010 1080p BluRay x264-GROUP" -After: "Inception (2010)" -``` - -### Torrent Process - -**Field**: `queueCount` (Arr queue) + `categoryCount` (qBit category) - -- **Queue Count**: Active downloads in Arr's `/api/v3/queue` -- **Category Count**: Torrents in qBittorrent matching `Settings.CategoryX` - -**Metric Types**: - -| Type | Description | Queue | Category | -|------|-------------|-------|----------| -| `arr` (default) | Standard Radarr/Sonarr | ✓ | ✓ | -| `category` | qBit category tracker | ❌ | ✓ | -| `free-space` | Free Space Manager | ✓ | ✓ (paused count) | - ---- - -## API Reference - -### GET /api/processes - -**Description**: Fetch process states and metrics for all managed instances. - -**Headers**: -```http -Authorization: Bearer -``` - -**Response**: -```json -{ - "processes": [ - { - "category": "Radarr-Movies", - "name": "Radarr-Movies", - "kind": "search", - "pid": 12345, - "alive": true, - "rebuilding": false, - "searchSummary": "Inception (2010)", - "searchTimestamp": "2025-11-27T10:30:15Z" - }, - { - "category": "Radarr-Movies", - "name": "Radarr-Movies", - "kind": "torrent", - "pid": 12346, - "alive": true, - "queueCount": 3, - "categoryCount": 12 - } - ] -} -``` - -**Fields**: - -| Field | Type | Description | -|-------|------|-------------| -| `category` | `string` | qBittorrent category identifier | -| `name` | `string` | Display name from config | -| `kind` | `string` | `search`, `torrent`, or other | -| `pid` | `int` \| `null` | Process ID (null if not started) | -| `alive` | `boolean` | Whether process is running | -| `rebuilding` | `boolean` | Arr rebuild in progress | -| `searchSummary` | `string` | Latest search description (search only) | -| `searchTimestamp` | `string` | ISO 8601 timestamp (search only) | -| `queueCount` | `int` | Arr queue count (torrent only) | -| `categoryCount` | `int` | qBit category count (torrent only) | -| `metricType` | `string` | `category`, `free-space`, or absent | - -### POST /api/processes/:category/:kind/restart - -**Description**: Restart a specific process. - -**Path Parameters**: - -- `category`: Instance category (e.g., `Radarr-Movies`) -- `kind`: Process type (`search`, `torrent`, or `all`) - -**Response**: -```json -{ - "status": "ok", - "restarted": ["search"] -} -``` - -### POST /api/processes/restart_all - -**Description**: Restart all search and torrent processes globally. - -**Response**: -```json -{ - "status": "ok" -} -``` - -### POST /api/arr/rebuild - -**Description**: Rebuild the entire Arr manager subsystem. - -**Response**: -```json -{ - "status": "ok" -} -``` - ---- - -## Configuration - -### AutoRestartProcesses - -**Path**: `Settings.AutoRestartProcesses` -**Type**: `bool` -**Default**: `true` - -Automatically restart processes that crash or exit unexpectedly. - -**Example** (`config.toml`): -```toml -[Settings] -AutoRestartProcesses = true -MaxProcessRestarts = 3 -ProcessRestartWindow = 300 # 5 minutes -ProcessRestartDelay = 10 # 10 seconds -``` - -See [Process Management](../features/process-management.md) for restart policies. - ---- - -## Troubleshooting - -### Process shows as stopped but was not manually terminated - -**Possible Causes**: - -1. Process crashed due to unhandled exception -2. API connection to Arr timed out repeatedly -3. Database lock contention (SQLite) -4. Out of memory (OOM killer on Linux) - -**Solutions**: - -- Check `Main.log` or `.log` for traceback -- Increase `Settings.LoopSleepTimer` to reduce API call frequency -- Verify Arr URI and API key are correct -- Restart the process manually - -### Search summary shows "No searches recorded" - -**Possible Causes**: - -1. Search process just started and hasn't run a cycle yet -2. `Search.SearchMissing` is disabled -3. No missing media to search -4. Search database (`search_activity.db`) was cleared - -**Solutions**: - -- Wait 1-2 minutes for first search cycle -- Verify `SearchMissing = true` in config -- Check Arr's "Wanted" page for missing items - -### Queue count doesn't match qBittorrent UI - -**Possible Causes**: - -1. Torrents in qBit don't have Arr tags (if `Settings.Tagless = false`) -2. Torrent category mismatch -3. Caching delay (up to 20 seconds) - -**Solutions**: - -- Verify torrents have tags matching `Settings.CategoryX` -- Check torrent category in qBit matches `Settings.CategoryX` -- Force refresh to clear cache - ---- - -## See Also - -- [Process Management](../features/process-management.md) – Auto-restart policies and multiprocessing architecture -- [WebUI Configuration](../configuration/webui.md) – WebUI settings and authentication -- [Logs View](logs.md) – Real-time log streaming for debugging processes -- [API Documentation](api.md) – Full API endpoint reference +# Processes view + +Processes shows what Torrentarr is doing, grouped by library type, torrent client, +and host task. Each Arr instance has one isolated worker with independent Search +and Torrent loops. + +## Activity cards + +- **Searches / items searched:** numeric results from the last completed search + pass, with its completion timestamp. Running or sleeping activity appears below. +- **Managed torrents / downloading / seeding:** counts from complete client + inventories, including owned subcategories when configured. **Arr queue** is the + latest local queue count. +- **Torrent clients:** managed torrent, downloading, and seeding totals across + owned categories. These totals use full snapshots, even when a displayed list + is truncated. +- **Free Space Manager:** currently marked pauses and paused/resumed actions from + the last allocation pass. Each storage monitor shows free space, reserve, + committed downloads, remaining allocation, and any reason resumes are blocked. +- **Failed / Recheck:** successfully handled torrents from the last completed + pass, current category inventory, and monitored category count. + +A successful empty observation is **0**. Before the first observation, a dash and +pending message appear. Failed observations retain the last successful metrics +with an error or stale timestamp; they do not become zeroes. + +Small status dots and **Issues** expose operational problems. Worker counts are +not headline metrics. Use an instance's **Actions** menu to restart its worker; +restarting affects both loops. Host tasks do not have individual restart actions. +The toolbar also provides global restart and Arr rebuild controls. + +## Refresh behavior + +Database commits and runtime activity changes notify visible views through the +authenticated statistics stream. Database change detection checks SQLite once a +second, including commits from separate worker processes. Reconnection fetches +current statistics; hidden views catch up when activated. + +Client observations are cached and deduplicated per instance using that client's +`TorrentScanIntervalSeconds`. Aggregate views combine each client's latest +snapshot. Changing the interval applies without restarting Torrentarr. The direct +Arr **Live** setting does not disable database statistics or client snapshots. + +Processes also refreshes its visible dashboard every two seconds as a fallback; +this reuses cached client snapshots rather than scanning clients every two seconds. + +## Free-space allocation + +Torrentarr reserves each active download's remaining bytes above the configured +free-space reserve and allocates the oldest eligible torrents first. Space above +the reserve alone does not guarantee that another download fits. Shared disks +have one budget across clients, and unmanaged active downloads consume that budget. + +Only Torrentarr-owned pauses are resumed. Missing client inventories, unmatched +active download paths, unavailable storage, and failed protective actions can +withhold resumes. The card reports these reasons instead of implying that every +paused torrent can safely resume. diff --git a/mkdocs.yml b/mkdocs.yml index 32df1ec4..cba43e0a 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -257,7 +257,9 @@ nav: - Web UI: - webui/index.md + - Console and Statistics: webui/console-statistics.md - Processes View: webui/processes.md + - Console Statistics: webui/console-statistics.md - Issues View: webui/issues.md - Logs View: webui/logs.md - Arr Views: webui/arr-views.md diff --git a/scripts/check-openapi-drift.py b/scripts/check-openapi-drift.py index 851c1a04..062a8048 100644 --- a/scripts/check-openapi-drift.py +++ b/scripts/check-openapi-drift.py @@ -7,12 +7,13 @@ import sys from copy import deepcopy from openapi_status import STATUS_SCHEMA +from openapi_statistics import EXTENSION_PATHS as STATISTICS_PATHS, apply as apply_statistics from openapi_maintenance import EXTENSION_PATHS as MAINTENANCE_PATHS, apply as apply_maintenance from pathlib import Path from typing import Any HTTP_METHODS = {"get", "put", "post", "delete", "patch", "head", "options", "trace"} -TORRENTARR_EXTENSIONS = MAINTENANCE_PATHS | {f"{prefix}/issues{suffix}" for prefix in ("/web", "/api") for suffix in ("", "/summary", "/{id}")} | { +TORRENTARR_EXTENSIONS = STATISTICS_PATHS | MAINTENANCE_PATHS | {f"{prefix}/issues{suffix}" for prefix in ("/web", "/api") for suffix in ("", "/summary", "/{id}")} | { "/api/arr/{category}/open/{kind}/{entryId}", "/api/lidarr/{category}/tracks", "/api/qbit/categories", @@ -168,6 +169,14 @@ def compare_content_schemas( if key != "description"} == {"type": "string", "nullable": True} and "warning" not in ta_schema.get("required", [])): ta_schema["properties"].pop("warning") # Optional activation warning preserves the upstream payload. + if (context in {"response 200 on POST /api/config", "response 200 on POST /web/config"} + and media == "application/json" and "restartRequirements" not in qb_schema.get("properties", {}) + and ta_schema.get("properties", {}).get("restartRequirements") == { + "type": "array", "items": {"type": "object", "properties": { + key: {"type": "string"} for key in ("component", "category", "kind") + }, "required": ["category", "component", "kind"]}} + and "restartRequirements" not in ta_schema.get("required", [])): + ta_schema["properties"].pop("restartRequirements") if qb_schema != ta_schema: errors.append(f"{context} schema differs for {media}") @@ -365,13 +374,21 @@ def maintenance_drift(document: dict) -> list[str]: return errors +def statistics_drift(document: dict) -> list[str]: + expected = {"paths": {}} + apply_statistics(expected) + return [f"generated statistics operation differs: {path}" + for path, operations in expected['paths'].items() + if document.get('paths', {}).get(path) != operations] + + def main(argv: list[str]) -> int: if len(argv) != 3: print(f"usage: {argv[0]} TORRENTARR_SPEC QBITRR_SPEC", file=sys.stderr) return 2 torrentarr = load(argv[1]) qbitrr = load(argv[2]) - errors = compare(torrentarr, qbitrr) + maintenance_drift(torrentarr) + errors = compare(torrentarr, qbitrr) + maintenance_drift(torrentarr) + statistics_drift(torrentarr) if errors: print(f"OpenAPI parity failed with {len(errors)} difference(s):") for error in errors: diff --git a/scripts/generate-openapi-from-qbitrr.py b/scripts/generate-openapi-from-qbitrr.py index 4c875413..6996ab09 100644 --- a/scripts/generate-openapi-from-qbitrr.py +++ b/scripts/generate-openapi-from-qbitrr.py @@ -3,11 +3,13 @@ from __future__ import annotations import json +from pathlib import Path import os import sys import urllib.request from copy import deepcopy from openapi_status import STATUS_SCHEMA +from openapi_statistics import EXTENSION_PATHS as STATISTICS_PATHS, apply as apply_statistics from openapi_maintenance import apply as apply_maintenance QBITRR_REF = os.environ.get("QBITRR_OPENAPI_REF", "v5.14.6-1") @@ -143,6 +145,12 @@ def main() -> int: with urllib.request.urlopen(url) as resp: spec = json.load(resp) + current_path = Path(OUT_PATH) + if current_path.exists(): + current = json.loads(current_path.read_text()) + spec["paths"].update({path: value for path, value in current["paths"].items() if "/issues" in path}) + spec["components"]["schemas"].update({name: value for name, value in current["components"]["schemas"].items() if "Issue" in name}) + spec = deepcopy(spec) spec["info"] = { "title": "Torrentarr API", @@ -155,6 +163,11 @@ def main() -> int: ), } spec["paths"].update(EXTENSION_PATHS) + spec["components"]["schemas"]["ConfigUpdateResponse"]["properties"]["restartRequirements"] = { + "type": "array", "items": {"type": "object", "properties": { + key: {"type": "string"} for key in ("component", "category", "kind") + }, "required": ["component", "category", "kind"]} + } spec["components"]["schemas"]["ConfigUpdateResponse"]["properties"]["warning"] = { "type": "string", "nullable": True, "description": "Configuration was saved, but client or worker activation failed." @@ -169,10 +182,11 @@ def main() -> int: operation["responses"]["200"]["content"]["application/json"]["schema"] = deepcopy(STATUS_SCHEMA) apply_maintenance(spec) + apply_statistics(spec) out = sys.argv[1] if len(sys.argv) > 1 else OUT_PATH with open(out, "w", encoding="utf-8") as f: - json.dump(spec, f, indent=2) + json.dump(spec, f, indent=2, sort_keys=True) f.write("\n") print(f"Wrote {len(spec['paths'])} paths to {out}") diff --git a/scripts/openapi_statistics.py b/scripts/openapi_statistics.py new file mode 100644 index 00000000..463156a3 --- /dev/null +++ b/scripts/openapi_statistics.py @@ -0,0 +1,53 @@ +"""Torrentarr console statistics API extensions.""" +from copy import deepcopy + +EXTENSION_PATHS = {"/web/statistics/transfers", "/web/statistics/catalog", "/web/statistics/arr-activity"} + + +def apply(spec): + number = {"type": "number", "format": "double", "nullable": True} + bucket = {"type": "object", "properties": { + "timestamp": {"type": "string", "format": "date-time"}, + "downloadRate": number, "uploadRate": number, + "samples": {"type": "integer"}, "expectedSamples": {"type": "integer"}}} + series = {"type": "object", "properties": {"instance": {"type": "string"}, + "points": {"type": "array", "items": bucket}}} + history = {"type": "object", "properties": { + "range": {"type": "string", "enum": ["1h", "24h", "7d"]}, + "bucketMinutes": {"type": "integer"}, "generatedAt": {"type": "string", "format": "date-time"}, + "series": {"type": "array", "items": series}}} + catalog = {"type": "object", "properties": {"unit": {"type": "string"}, + **{name: {"type": "integer"} for name in ("total", "available", "monitored", "missing")}}} + for path, summary, parameters, schema in [ + ("/web/statistics/transfers", "Persistent whole-client transfer history", [ + {"name": "instance", "in": "query", "schema": {"type": "string"}, "description": "Enabled client name; omitted or all selects all enabled clients."}, + {"name": "range", "in": "query", "schema": {"type": "string", "enum": ["1h", "24h", "7d"], "default": "24h"}}], history), + ("/web/statistics/catalog", "Complete catalog availability rollup", [ + {"name": "kind", "in": "query", "required": True, "schema": {"type": "string", "enum": ["radarr", "sonarr", "lidarr", "readarr"]}}, + {"name": "instance", "in": "query", "schema": {"type": "string"}, "description": "Arr section name; omitted or aggregate selects all instances of this kind."}], catalog), + ]: + spec["paths"][path] = {"get": {"tags": ["Torrentarr Statistics"], "summary": summary, + "description": "Follows configured WebUI authentication. Transfer rates are bytes/second, collected once per minute and retained seven days. Completed buckets use 1, 5, or 30 minutes. Null rates indicate no observations; samples below expectedSamples indicate partial coverage. Catalog availability is monitored media with files; missing is monitored minus available. Catalog counts ignore page and text filters.", + "parameters": parameters, + "responses": {"200": {"description": "OK", "content": {"application/json": {"schema": deepcopy(schema)}}}, + "400": {"description": "Invalid range, kind, or instance"}, "401": {"description": "Authentication required"}}}} + point = {"type": "object", "properties": { + "timestamp": {"type": "string", "format": "date-time"}, + "queueDepth": {"type": "integer", "nullable": True}, + **{name: {"type": "integer", "nullable": True} for name in ("searchesCompleted", "importsConfirmed")}, + **{name: {"type": "integer"} for name in ("queueSamples", "completionSamples", "expectedSamples")}}} + spec["paths"]["/web/statistics/arr-activity"] = {"get": { + "tags": ["Torrentarr Statistics"], "summary": "Persistent Arr queue and confirmed activity history", + "description": "Follows WebUI authentication. Worker-level series retain seven days; no backfill. Queue depth averages fresh heartbeats, rounded to whole items. Completions count only tracked Torrentarr submissions: searches require successful command completion; imports require positive correlated download-import history after submission. Counts are timestamped when confirmed, not when requested. Missing/partial coverage is explicit. Buckets are 1, 5, or 30 minutes. Disabled/deleted workers can remain in retained history.", + "parameters": [ + {"name": "range", "in": "query", "schema": {"type": "string", "enum": ["1h", "24h", "7d"], "default": "24h"}}, + {"name": "worker", "in": "query", "schema": {"type": "string"}, "description": "Optional configured Arr section name."}], + "responses": {"200": {"description": "OK", "content": {"application/json": {"schema": { + "type": "object", "properties": {"range": {"type": "string"}, "bucketMinutes": {"type": "integer"}, + "generatedAt": {"type": "string", "format": "date-time"}, "series": {"type": "array", "items": { + "type": "object", "properties": {"instance": {"type": "string"}, "type": {"type": "string"}, + "points": {"type": "array", "items": point}}}}}}}}}, + "400": {"description": "Invalid range or worker"}, "401": {"description": "Authentication required"}}}} + overview = spec.get("paths", {}).get("/web/qbit/overview", {}).get("get") + if overview: + overview["description"] = "Includes optional summaries per available client: instance, torrentCount, totalSize, downloading, seeding, stopped, other. These monitored-category totals are computed before the display cap and deduplicated by client and torrent hash. Clients without an inventory response are omitted." diff --git a/scripts/smoke-standalone.ps1 b/scripts/smoke-standalone.ps1 index d2b0750d..66173a63 100644 --- a/scripts/smoke-standalone.ps1 +++ b/scripts/smoke-standalone.ps1 @@ -97,20 +97,21 @@ SearchMissing = false for ($attempt = 0; $attempt -lt 60; $attempt++) { if (Test-Path $statusPath) { $status = Get-Content $statusPath -Raw | ConvertFrom-Json - if ($status.torrent.lastSuccess -and $status.search.lastSuccess) { break } + # No torrent client is configured, so the torrent loop stays idle and lastSuccess remains unset by design. + if ($status.torrent -and $status.search.lastSuccess) { break } } Start-Sleep -Milliseconds 500 } - if (-not $status.torrent.lastSuccess -or -not $status.search.lastSuccess) { throw 'Worker did not complete both processing loops' } + if (-not $status.torrent -or -not $status.search.lastSuccess) { throw 'Worker did not complete both processing loops' } if ($status.instance -ne 'Radarr-Smoke' -or $status.pid -eq $process.Id -or -not $status.torrent -or -not $status.search) { throw 'Invalid worker heartbeat' } $workerPid = $status.pid Invoke-WebRequest -Method Post -Uri "$baseUrl/web/processes/Radarr-Smoke/worker/restart" -UseBasicParsing | Out-Null for ($attempt = 0; $attempt -lt 60; $attempt++) { $status = Get-Content $statusPath -Raw | ConvertFrom-Json - if ($status.pid -ne $workerPid -and $status.torrent.lastSuccess -and $status.search.lastSuccess) { break } + if ($status.pid -ne $workerPid -and $status.torrent -and $status.search.lastSuccess) { break } Start-Sleep -Milliseconds 500 } - if ($status.pid -eq $workerPid -or -not $status.torrent.lastSuccess -or -not $status.search.lastSuccess -or (Get-Process -Id $workerPid -ErrorAction SilentlyContinue)) { throw 'Worker restart failed' } + if ($status.pid -eq $workerPid -or -not $status.torrent -or -not $status.search.lastSuccess -or (Get-Process -Id $workerPid -ErrorAction SilentlyContinue)) { throw 'Worker restart failed' } $workerPid = $status.pid Stop-Process -Id $process.Id -Force $process.WaitForExit() diff --git a/scripts/smoke-standalone.sh b/scripts/smoke-standalone.sh index 2b2c0d52..c82415c3 100644 --- a/scripts/smoke-standalone.sh +++ b/scripts/smoke-standalone.sh @@ -120,7 +120,8 @@ curl --fail --silent -H 'Authorization: Bearer standalone-smoke-token' \ status_file="$work_dir/workers/Radarr-Smoke.json" wait_for_worker() { for _ in $(seq 1 60); do - if node -e 'const s=JSON.parse(require("fs").readFileSync(process.argv[1])); if(!s.torrent?.lastSuccess || !s.search?.lastSuccess) process.exit(1)' "$status_file" 2>/dev/null; then return 0; fi + # No torrent client is configured, so the torrent loop stays idle and lastSuccess remains unset by design. + if node -e 'const s=JSON.parse(require("fs").readFileSync(process.argv[1])); if(!s.torrent || !s.search?.lastSuccess) process.exit(1)' "$status_file" 2>/dev/null; then return 0; fi sleep 0.5 done echo "Worker did not complete both processing loops" >&2 diff --git a/scripts/webui-parity-preview.py b/scripts/webui-parity-preview.py index 32286fbf..6195deb9 100644 --- a/scripts/webui-parity-preview.py +++ b/scripts/webui-parity-preview.py @@ -9,6 +9,8 @@ import argparse import json import threading +import math +from datetime import datetime, timezone parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--scenario', choices=['full', 'empty', 'no-readarr', 'offline', 'large', 'missing-images', 'login', 'setup', 'update'], default='full') scenario = parser.parse_args().scenario @@ -89,6 +91,38 @@ def do_GET(self): return self.reply(config) if p == '/web/meta': return self.reply(dict(current_version='6.15.2', ready=True, auth_required=scenario in ('login', 'setup'), local_auth_enabled=True, setup_required=scenario == 'setup', url_base='/prefix' if u.path.startswith('/prefix/') else '', repository_url='https://github.com/Feramance/Torrentarr', installation_type='docker', auto_update_supported=False, update_available=scenario == 'update', latest_version='6.15.3', changelog='Fixture release notes')) + if p == '/web/statistics/arr-activity': + selected_range = q.get('range', ['24h'])[0] + minutes, bucket = {'1h': (60, 1), '24h': (1440, 5), '7d': (10080, 30)}[selected_range] + end = int(datetime.now(timezone.utc).timestamp() / 60) // bucket * bucket + activity = [] + for worker_index, arr in enumerate(arrs): + points = [dict(timestamp=datetime.fromtimestamp((end-minutes+i*bucket)*60, timezone.utc).isoformat(), + queueDepth=max(0, round(12+worker_index*4+8*math.sin(i/17+worker_index))) if i%45 else None, + searchesCompleted=(i+worker_index)%4 if i%45 else None, + importsConfirmed=(i+worker_index)%3 if i%45 else None, + queueSamples=bucket if i%45 else 0, completionSamples=bucket if i%45 else 0, expectedSamples=bucket) + for i in range(minutes//bucket)] + activity.append(dict(instance=arr['name'], type=arr['type'], points=points)) + return self.reply(dict(range=selected_range,bucketMinutes=bucket,generatedAt=datetime.now(timezone.utc).isoformat(),series=activity)) + if p == '/web/statistics/transfers': + ranges = {'1h': (60, 1), '24h': (1440, 5), '7d': (10080, 30)} + selected_range = q.get('range', ['24h'])[0] + minutes, bucket = ranges[selected_range] + end = int(datetime.now(timezone.utc).timestamp() / 60) // bucket * bucket + points = [dict(timestamp=datetime.fromtimestamp((end - minutes + i * bucket) * 60, timezone.utc).isoformat(), + downloadRate=(8 + 6 * math.sin(i / 14)) * 1024 ** 2 if i % 50 else None, + uploadRate=(2 + math.cos(i / 19)) * 1024 ** 2 if i % 50 else None, + samples=bucket if i % 50 else 0, expectedSamples=bucket) for i in range(minutes // bucket)] + return self.reply(dict(range=selected_range, bucketMinutes=bucket, generatedAt=datetime.now(timezone.utc).isoformat(), + series=[] if scenario == 'empty' else [dict(instance='qBit-main', points=points)])) + if p == '/web/statistics/catalog': + unit = {'radarr': 'movies', 'sonarr': 'episodes', 'lidarr': 'albums', 'readarr': 'books'}[q.get('kind', ['radarr'])[0]] + return self.reply(dict(unit=unit, total=240, available=180, monitored=210, missing=30)) + if p == '/web/issues/summary': + return self.reply(dict(available=True, activeGroups=0)) + if p == '/web/issues': + return self.reply(dict(available=True, groups=[], total=0, page=0, pageSize=50)) if p == '/web/status': return self.reply(dict(ready=True, arrs=arrs, qbitInstances={'qBit-main': dict(alive=scenario != 'offline', host='qbittorrent', port=8080, version='5.1')} if scenario != 'empty' else {})) if p == '/web/arr': @@ -105,7 +139,8 @@ def do_GET(self): if p == '/web/qbit/categories': return self.reply(dict(ready=True, categories=[] if scenario == 'empty' else [category])) if p == '/web/qbit/overview': - return self.reply(dict(ready=True, instances=[] if scenario == 'empty' else ['qBit-main'], categories=[] if scenario == 'empty' else [category])) + return self.reply(dict(ready=True, instances=[] if scenario == 'empty' else ['qBit-main'], categories=[] if scenario == 'empty' else [category], + summaries=[] if scenario == 'empty' else [dict(instance='qBit-main', torrentCount=42, totalSize=204800, downloading=8, seeding=28, stopped=4, other=2)])) if p == '/web/logs': return self.reply(dict(files=['torrentarr.log'] if self.server.reference else [dict(name='torrentarr.log', size=2048, modified='2026-10-18T12:00:00Z')])) if p == '/web/logs/search': @@ -140,6 +175,8 @@ def do_GET(self): if '/author/' in p: return self.reply(dict(**extra, author=author, books=[book])) return self.reply(dict(**extra, book_total=2, authors=[dict(author=author)])) + if p == '/web/torrent-clients': + return self.reply([]) if p == '/api/torrent-clients': return self.reply([]) if 'maintenance' in p: diff --git a/src/Torrentarr.Core/Configuration/ConfigSchemaBuilder.cs b/src/Torrentarr.Core/Configuration/ConfigSchemaBuilder.cs index 5331664a..7ab53341 100644 --- a/src/Torrentarr.Core/Configuration/ConfigSchemaBuilder.cs +++ b/src/Torrentarr.Core/Configuration/ConfigSchemaBuilder.cs @@ -17,13 +17,19 @@ public static class ConfigSchemaBuilder new Dictionary> { ["Settings"] = Qbitrr5146ConfigSchema.Settings.Where(f => f.key is not ("CompletedDownloadFolder" or "FreeSpace" or "FreeSpaceFolder" or "AutoPauseResume" or "FreeSpaceThresholdGB")).Select(f => f.key == "ConfigVersion" ? f with { @default = ConfigurationLoader.ExpectedConfigVersion } - : f.key == "LoopSleepTimer" ? f with { label = "Database refresh interval", comments = "Interval in seconds between Arr catalog refreshes into SQLite. Torrent scans use each client’s scan interval." } : f).ToList(), - ["WebUI"] = [.. Qbitrr5146ConfigSchema.WebUI, + : f.key == "LoopSleepTimer" ? f with { label = "Database refresh interval", comments = "Interval in seconds between Arr catalog refreshes into SQLite. Torrent scans use each client’s scan interval." } : f).Select(RuntimeField).ToList(), + ["WebUI"] = [.. Qbitrr5146ConfigSchema.WebUI.Select(RuntimeField).Select(f => f.key == "Theme" ? f with + { + @default = "System", + options = ["System", "Light", "Dark", "Midnight"], + comments = "WebUI theme (System, Light, Dark, or Midnight)", + description = "System follows the operating system. Midnight uses a black background." + } : f), Field("CorsAllowedOrigins", "tags", "Allowed browser origins", Array.Empty()) with { section = "WebUI", applyLive = true, requiresRestart = false, comments = "Allowed browser origins. Empty allows any origin without credentials; entries must be exact HTTP(S) origins.", - description = "HTTP(S) origins without trailing slashes. Empty allows any origin without credentials; a list permits credentials only for those origins. Changes apply live." + description = "HTTP(S) origins without trailing slashes. Empty allows any origin without credentials; a list permits credentials only for those origins." }], ["qBit"] = TorrentClientFields().Where(f => f.key != "Type").Select(f => f with { section = "qBit" }).ToList(), ["TorrentClient"] = TorrentClientFields(), @@ -31,6 +37,9 @@ public static class ConfigSchemaBuilder ["TrackerProtection"] = ProtectionFields().Select(f => f with { section = "TrackerProtection" }).ToList() }; + // Torrentarr reconciles these settings at runtime; qBitrr's startup flags do not apply. + private static ConfigSchemaField RuntimeField(ConfigSchemaField field) => field with { applyLive = true, requiresRestart = false }; + private static ConfigSchemaField ClarifySearchScope(ConfigSchemaField field) { if (field.key == "EntrySearch.AlsoSearchSpecials") @@ -153,7 +162,7 @@ private static IEnumerable OperationPolicyFields() { var prefix = "Maintenance.Operations." + operation + "."; yield return Field(prefix + "Enabled", "checkbox", "Enable operation", false) with { description = "Explicit operation override. Existing configs without an override retain their legacy enablement." }; - yield return Field(prefix + "ExecutionMode", "select", "Execution mode", "manual", options: ["manual", "automatic"]) with { description = "Manual applies a reviewed preview once. Automatic runs on the saved schedule and remains enabled after restart; no restart is required." }; + yield return Field(prefix + "ExecutionMode", "select", "Execution mode", "manual", options: ["manual", "automatic"]) with { description = "Manual applies a reviewed preview once. Automatic runs on the saved schedule and remains enabled after restart." }; yield return Field(prefix + "Schedule", "text", "Operation schedule (UTC)", MaintenancePolicies.SuggestedSchedule(operation)) with { description = "Five-field cron expression in UTC. Blank inherits the client schedule." }; yield return Field(prefix + "IncludedCategories", "tags", "Included categories", Array.Empty()) with { description = "Blank inherits client-wide scope. Entries narrow that scope." }; yield return Field(prefix + "ExcludedCategories", "tags", "Excluded categories", Array.Empty()); diff --git a/src/Torrentarr.Core/Configuration/ConfigurationLoader.cs b/src/Torrentarr.Core/Configuration/ConfigurationLoader.cs index 7eefd267..7655eeca 100644 --- a/src/Torrentarr.Core/Configuration/ConfigurationLoader.cs +++ b/src/Torrentarr.Core/Configuration/ConfigurationLoader.cs @@ -982,7 +982,7 @@ private static bool ValidateAndFillConfig(TomlTable root) ("Username", ""), ("PasswordHash", ""), ("LiveArr", true), - ("Theme", "Dark"), + ("Theme", "System"), ("ViewDensity", "Comfortable"), }; foreach (var (key, defaultVal) in webuiDefaults) @@ -1011,8 +1011,14 @@ private static bool ValidateAndFillConfig(TomlTable root) // Normalize Theme casing if (webui.TryGetValue("Theme", out var themeVal)) { - var themeStr = themeVal?.ToString()?.Trim().ToLowerInvariant() ?? "dark"; - var normalized = themeStr == "light" ? "Light" : "Dark"; + var themeStr = themeVal?.ToString()?.Trim().ToLowerInvariant() ?? "system"; + var normalized = themeStr switch + { + "light" => "Light", + "dark" => "Dark", + "midnight" => "Midnight", + _ => "System", + }; if (themeVal?.ToString() != normalized) { webui["Theme"] = normalized; @@ -1597,7 +1603,7 @@ private WebUIConfig ParseWebUI(TomlTable table) webui.AllowInsecureTokenQuery = Convert.ToBoolean(allowInsecureTokenQuery); if (table.TryGetValue("Theme", out var theme)) - webui.Theme = theme?.ToString() ?? "Dark"; + webui.Theme = theme?.ToString() ?? "System"; if (table.TryGetValue("ViewDensity", out var viewDensity)) webui.ViewDensity = viewDensity?.ToString() ?? "Comfortable"; @@ -2032,7 +2038,7 @@ public static TorrentarrConfig GenerateDefaultConfig() Username = "", PasswordHash = "", LiveArr = true, - Theme = "Dark", + Theme = "System", ViewDensity = "Comfortable" }, ArrInstances = GenerateDefaultArrInstances() diff --git a/src/Torrentarr.Core/Configuration/StorageConfigMigration.cs b/src/Torrentarr.Core/Configuration/StorageConfigMigration.cs index 34afb336..8abb6c8c 100644 --- a/src/Torrentarr.Core/Configuration/StorageConfigMigration.cs +++ b/src/Torrentarr.Core/Configuration/StorageConfigMigration.cs @@ -94,7 +94,7 @@ private static void ConvertClients(TomlTable root, TomlTable legacy) { var download = Map(completed, client.Maintenance.PathMappings, reverse: true); if (download != null) { table["DownloadPath"] = download; client.DownloadPath = download; } - else Warn(id, "could not translate the old completed folder to a client download path"); + else Warn(id, table, "could not translate the old completed folder to a client download path"); } if (table.ContainsKey("FreeSpace")) continue; var path = LocalDownloadPath(client); @@ -113,8 +113,8 @@ private static void ConvertClients(TomlTable root, TomlTable legacy) table["FreeSpace"] = validPath ? new TomlTableArray { new TomlTable { ["Path"] = path!, ["Enabled"] = enabled, ["Limit"] = limit } } : new TomlTableArray(); - if (ambiguous && validPath) Warn(id, "legacy monitor ownership is ambiguous; the inherited path is disabled for review"); - if (!validPath) Warn(id, "no unambiguous local monitor path; free-space protection remains disabled until configured"); + if (ambiguous && validPath) Warn(id, table, "legacy monitor ownership is ambiguous; the inherited path is disabled for review"); + if (!validPath) Warn(id, table, "no unambiguous local monitor path; free-space protection remains disabled until configured"); } } @@ -129,7 +129,7 @@ public static void ApplyEnvironment(TomlTable root, TomlTable legacy, IEnumerabl { var rootPath = Map(legacy["CompletedDownloadFolder"].ToString(), client.Maintenance.PathMappings, reverse: true); if (rootPath != null) { table["DownloadPath"] = rootPath; client.DownloadPath = rootPath; } - else Warn(id, "legacy environment download folder cannot be reverse-mapped"); + else Warn(id, table, "legacy environment download folder cannot be reverse-mapped"); } if (client.FreeSpace.Count == 0) table.Remove("FreeSpace"); else @@ -168,5 +168,13 @@ public static void ApplyEnvironment(TomlTable root, TomlTable legacy, IEnumerabl return null; } - private static void Warn(string id, string message) => Console.Error.WriteLine($"Storage migration [{id}]: {message}."); + private static void Warn(string id, TomlTable client, string message) + { + if (!client.TryGetValue("Migration", out var value)) client["Migration"] = value = new TomlTable(); + var migration = value as TomlTable ?? throw new InvalidDataException("Migration must be a table."); + if (!migration.TryGetValue("Notices", out var noticesValue)) migration["Notices"] = noticesValue = new TomlArray(); + var notices = noticesValue as TomlArray ?? throw new InvalidDataException("Migration.Notices must be a list."); + if (!notices.Contains(message)) notices.Add(message); + Console.Error.WriteLine($"Storage migration [{id}]: {message}."); + } } diff --git a/src/Torrentarr.Core/Configuration/TorrentarrConfig.cs b/src/Torrentarr.Core/Configuration/TorrentarrConfig.cs index c77299d9..bd37c151 100644 --- a/src/Torrentarr.Core/Configuration/TorrentarrConfig.cs +++ b/src/Torrentarr.Core/Configuration/TorrentarrConfig.cs @@ -353,7 +353,7 @@ public class WebUIConfig public string Username { get; set; } = ""; public string PasswordHash { get; set; } = ""; public bool LiveArr { get; set; } = true; - public string Theme { get; set; } = "Dark"; + public string Theme { get; set; } = "System"; public string ViewDensity { get; set; } = "Comfortable"; public List CorsAllowedOrigins { get; set; } = new(); public OIDCConfig? OIDC { get; set; } diff --git a/src/Torrentarr.Core/Interfaces/IProcessOrchestrator.cs b/src/Torrentarr.Core/Interfaces/IProcessOrchestrator.cs index 2c6b2352..871a445d 100644 --- a/src/Torrentarr.Core/Interfaces/IProcessOrchestrator.cs +++ b/src/Torrentarr.Core/Interfaces/IProcessOrchestrator.cs @@ -43,6 +43,15 @@ public class ProcessStatus public string? LastFailure { get; set; } public bool RestartRequested { get; set; } public long ConfigRevision { get; set; } + public int? SearchesTriggered { get; set; } + public int? ItemsSearched { get; set; } + public int? DownloadingCount { get; set; } + public int? SeedingCount { get; set; } + public DateTimeOffset? ObservedAt { get; set; } + public string? ObservationError { get; set; } + public int? PausedLastPass { get; set; } + public int? ResumedLastPass { get; set; } + public int? HandledLastPass { get; set; } public string? SearchSummary { get; set; } public string? SearchTimestamp { get; set; } public int? QueueCount { get; set; } diff --git a/src/Torrentarr.Core/Models/ProtectionDecision.cs b/src/Torrentarr.Core/Models/ProtectionDecision.cs index 3977f1e0..a216cd62 100644 --- a/src/Torrentarr.Core/Models/ProtectionDecision.cs +++ b/src/Torrentarr.Core/Models/ProtectionDecision.cs @@ -20,6 +20,8 @@ public sealed class ProtectionDecision public string DeadlineRisk { get; set; } = "none"; public bool LocalEstimate { get; set; } = true; public bool NativeStopLimitRisk { get; set; } + + public string SeedAccountingDescription() => $"{(LocalEstimate ? "Locally observed" : "Client-reported")} seed={ObservedSeedSeconds:F0}s; remaining time requirement={RemainingSeedSeconds:F0}s (ratio/accounting requirements may still apply)"; } public sealed class ProtectionObservation diff --git a/src/Torrentarr.Core/Models/WorkerHeartbeat.cs b/src/Torrentarr.Core/Models/WorkerHeartbeat.cs index 5e8cdae5..86f4e645 100644 --- a/src/Torrentarr.Core/Models/WorkerHeartbeat.cs +++ b/src/Torrentarr.Core/Models/WorkerHeartbeat.cs @@ -19,6 +19,15 @@ public sealed record WorkerHeartbeat public DateTimeOffset Heartbeat { get; init; } public bool RestartRequested { get; init; } public long ConfigRevision { get; init; } + public int? SearchesTriggered { get; init; } + public int? ItemsSearched { get; init; } + public int? DownloadingCount { get; init; } + public int? SeedingCount { get; init; } + public DateTimeOffset? ObservedAt { get; init; } + public string? ObservationError { get; init; } + public int? PausedLastPass { get; init; } + public int? ResumedLastPass { get; init; } + public int? HandledLastPass { get; init; } public string? SearchSummary { get; init; } public string? SearchTimestamp { get; init; } public int? QueueCount { get; init; } diff --git a/src/Torrentarr.Core/Services/IConfigReloader.cs b/src/Torrentarr.Core/Services/IConfigReloader.cs index 06bf7e72..c30f6f1b 100644 --- a/src/Torrentarr.Core/Services/IConfigReloader.cs +++ b/src/Torrentarr.Core/Services/IConfigReloader.cs @@ -1,5 +1,8 @@ namespace Torrentarr.Core.Services; +// Requirements identify existing worker restart targets, independently of legacy reloadType labels. +public sealed record ConfigurationRestartRequirement(string Component, string Category, string Kind); + public sealed record ConfigurationComponentStatus(long AppliedRevision, string? Error = null); public sealed record ConfigurationApplicationStatus(long AcceptedRevision, string State, diff --git a/src/Torrentarr.Core/Services/IFreeSpaceService.cs b/src/Torrentarr.Core/Services/IFreeSpaceService.cs index c955d0a8..529aaa4c 100644 --- a/src/Torrentarr.Core/Services/IFreeSpaceService.cs +++ b/src/Torrentarr.Core/Services/IFreeSpaceService.cs @@ -18,4 +18,8 @@ public class FreeSpaceStats public long FreeBytes { get; set; } public long ThresholdBytes { get; set; } public bool BelowThreshold { get; set; } + public long? CommittedBytes { get; set; } + public long? RemainingAllocationBytes { get; set; } + public DateTimeOffset? LastCompletedPass { get; set; } + public string? BlockedReason { get; set; } } diff --git a/src/Torrentarr.Core/Services/ISeedingService.cs b/src/Torrentarr.Core/Services/ISeedingService.cs index d434ba2f..b0977661 100644 --- a/src/Torrentarr.Core/Services/ISeedingService.cs +++ b/src/Torrentarr.Core/Services/ISeedingService.cs @@ -11,7 +11,8 @@ public enum TorrentRemovalIntent { SeedingLimit, StalledDownload, - RejectedByAllowlist + RejectedByAllowlist, + RejectedByCustomFormat } public interface ISeedingService @@ -63,6 +64,9 @@ public interface ISeedingService /// Task IsHnRSafeToRemoveAsync(TorrentInfo torrent, TrackerConfig config, CancellationToken cancellationToken = default); + /// Evaluate ordinary seeding limits without H&R deletion authorization. + Task SeedingLimitsMetAsync(TorrentInfo torrent, CancellationToken cancellationToken = default) => Task.FromResult(false); + /// /// Check if torrent meets removal conditions based on RemoveMode. /// Matches qBitrr's _should_remove_torrent() exactly. diff --git a/src/Torrentarr.Core/Services/ITorrentCacheService.cs b/src/Torrentarr.Core/Services/ITorrentCacheService.cs index fd9f67e3..fdd2f78a 100644 --- a/src/Torrentarr.Core/Services/ITorrentCacheService.cs +++ b/src/Torrentarr.Core/Services/ITorrentCacheService.cs @@ -72,17 +72,17 @@ public interface ITorrentCacheService void MarkFileFiltered(string qBitInstanceName, string hash); /// - /// Record an allowlist warning and return whether it should be logged after throttling. + /// Check whether a deferred allowlist cleanup is still inside its retry cooldown. /// - bool ShouldLogAllowlistWarning( - string qBitInstanceName, - string hash, - DateTimeOffset timestamp, - TimeSpan throttlePeriod); - - /// Throttle repeated identical removal blocks, reporting a changed reason immediately. - bool ShouldLogRemovalBlock(string instanceId, string hash, string reason, - DateTimeOffset timestamp, TimeSpan throttlePeriod) => true; + bool IsAllowlistRetryCoolingDown(string qBitInstanceName, string hash, TimeSpan cooldown); + + /// + /// Record a deferred allowlist cleanup so the next retry waits for the cooldown. + /// + void MarkAllowlistRetry(string qBitInstanceName, string hash); + + /// Report a changed diagnostic; null evidence clears a resolved stream. State is scoped to client and torrent. + bool DiagnosticChanged(string instanceId, string hash, string stream, string? evidence); } public class TorrentCacheStats diff --git a/src/Torrentarr.Core/Services/ITorrentProcessor.cs b/src/Torrentarr.Core/Services/ITorrentProcessor.cs index 0aebd013..c3c41651 100644 --- a/src/Torrentarr.Core/Services/ITorrentProcessor.cs +++ b/src/Torrentarr.Core/Services/ITorrentProcessor.cs @@ -74,3 +74,6 @@ public enum TorrentState CheckingResumeData, MetadataDownloading } + +/// A requested torrent inventory could not be inspected; this is not a successful empty scan. +public sealed class TorrentProcessingUnavailableException(string message) : IOException(message); diff --git a/src/Torrentarr.Core/Services/MaintenanceRequirements.cs b/src/Torrentarr.Core/Services/MaintenanceRequirements.cs index 4d3cea7f..f65b9205 100644 --- a/src/Torrentarr.Core/Services/MaintenanceRequirements.cs +++ b/src/Torrentarr.Core/Services/MaintenanceRequirements.cs @@ -24,7 +24,7 @@ public static class MaintenanceRequirements MaintenanceOperation.OrphanScan => InventoryRequirements.Metadata | InventoryRequirements.Files | InventoryRequirements.LocalFiles | InventoryRequirements.GlobalOwnership, MaintenanceOperation.HardlinkAudit => InventoryRequirements.Metadata | InventoryRequirements.Files | InventoryRequirements.LocalFiles, MaintenanceOperation.UnregisteredCleanup or MaintenanceOperation.ErrorRecovery => InventoryRequirements.Metadata | InventoryRequirements.Trackers | InventoryRequirements.Properties, - MaintenanceOperation.CategoryReconcile or MaintenanceOperation.AutomaticManagement or MaintenanceOperation.RepairPaused => InventoryRequirements.Metadata | InventoryRequirements.Trackers | InventoryRequirements.Properties, + MaintenanceOperation.CategoryReconcile or MaintenanceOperation.AutomaticManagement or MaintenanceOperation.RepairPaused => InventoryRequirements.Metadata, MaintenanceOperation.TrackerErrorTagging => InventoryRequirements.Metadata | InventoryRequirements.Trackers, MaintenanceOperation.PrivateTagging => InventoryRequirements.Metadata | InventoryRequirements.Properties, MaintenanceOperation.RecycleRetention => InventoryRequirements.None, diff --git a/src/Torrentarr.Core/Services/RemovalDecision.cs b/src/Torrentarr.Core/Services/RemovalDecision.cs index ef4d225b..459e2152 100644 --- a/src/Torrentarr.Core/Services/RemovalDecision.cs +++ b/src/Torrentarr.Core/Services/RemovalDecision.cs @@ -4,6 +4,9 @@ public sealed record RemovalBlocker(string Code, string Reason, string Remediati public sealed record RemovalDecision(IReadOnlyList Blockers) { + // Native limits are cleared automatically; only deadline risk is actionable. + public bool IsExpectedProtectionWait() => Blockers.Count > 0 && Blockers.All(b => + b.Code is "hnr.protected" or "hnr.protected.native-stop-risk"); public bool Allowed => Blockers.Count == 0; public string Reason => string.Join("; ", Blockers.Select(b => $"{b.Code}: {b.Reason} Next: {b.Remediation}")); } diff --git a/src/Torrentarr.Host/Program.cs b/src/Torrentarr.Host/Program.cs index 05fadb50..68f66578 100644 --- a/src/Torrentarr.Host/Program.cs +++ b/src/Torrentarr.Host/Program.cs @@ -187,6 +187,8 @@ builder.Services.AddSingleton(); builder.Services.AddHostedService(sp => sp.GetRequiredService()); builder.Services.AddSingleton(sp => sp.GetRequiredService()); + builder.Services.AddHostedService(); + builder.Services.AddHostedService(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -209,6 +211,10 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddSingleton(); + builder.Services.AddHostedService(sp => sp.GetRequiredService()); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddScoped(); @@ -619,6 +625,9 @@ static bool MetaForceAllowed(HttpContext ctx, TorrentarrConfig cfg) app.MapControllers(); app.MapOperationalIssueEndpoints("/web"); app.MapOperationalIssueEndpoints("/api"); + app.MapStatisticsEndpoints(); + app.MapTransferHistory(); + app.MapArrActivity(); app.MapArrCatalogEndpoints(); app.MapRadarrMoviesEndpoints(); @@ -733,10 +742,10 @@ static bool MetaForceAllowed(HttpContext ctx, TorrentarrConfig cfg) // • each Arr instance's Category (Arr-managed) // The "instance" field is always the qBit instance name (never the Arr instance name) // so that ProcessesView can match categories to the correct qBit process card. - app.MapGet("/web/qbit/categories", async (ITorrentClientRegistry registry, TorrentarrConfig cfg, CancellationToken ct) => + app.MapGet("/web/qbit/categories", async (ITorrentClientRegistry registry, TorrentarrConfig cfg, TorrentStatisticsSnapshots snapshots, CancellationToken ct) => { - var categories = await TorrentClientApiBuilder.CategoriesAsync(cfg, registry, ct: ct); - return Results.Ok(new { categories, ready = true }); + var categories = await TorrentClientApiBuilder.CategoriesAsync(cfg, registry, ct: ct, snapshots: snapshots); + return Results.Ok(new { categories, ready = true, observations = snapshots.Describe(cfg, registry) }); }); // Web Processes — host tasks and real worker heartbeats; clients use /web/status. @@ -750,6 +759,16 @@ static bool MetaForceAllowed(HttpContext ctx, TorrentarrConfig cfg) pid = s.Pid, alive = s.Alive, rebuilding = s.Rebuilding, + searchesTriggered = s.SearchesTriggered, + itemsSearched = s.ItemsSearched, + downloadingCount = s.DownloadingCount, + seedingCount = s.SeedingCount, + observedAt = s.ObservedAt, + observationError = s.ObservationError, + pausedLastPass = s.PausedLastPass, + resumedLastPass = s.ResumedLastPass, + handledLastPass = s.HandledLastPass, + monitoredCategoryCount = s.MonitoredCategoryCount, searchSummary = s.SearchSummary, searchTimestamp = s.SearchTimestamp, queueCount = s.QueueCount, @@ -761,7 +780,7 @@ static bool MetaForceAllowed(HttpContext ctx, TorrentarrConfig cfg) }).ToList(); foreach (var status in (await workers.GetProcessStatusAsync()).Values) - processes.Add(new { category = cfg.ArrInstances.GetValueOrDefault(status.Name)?.Category ?? status.Name, name = status.Name, kind = status.Kind, pid = status.ProcessId, alive = status.IsAlive, rebuilding = false, searchSummary = status.SearchSummary, searchTimestamp = status.SearchTimestamp, queueCount = status.QueueCount, categoryCount = status.CategoryCount, metricType = status.MetricType, status = status.Status ?? (status.IsAlive ? "Running" : "Stopped"), restartKey = status.Name, restartable = true, torrent = status.Torrent, search = status.Search }); + processes.Add(new { category = cfg.ArrInstances.GetValueOrDefault(status.Name)?.Category ?? status.Name, name = status.Name, kind = status.Kind, pid = status.ProcessId, alive = status.IsAlive, rebuilding = false, searchesTriggered = status.SearchesTriggered, itemsSearched = status.ItemsSearched, downloadingCount = status.DownloadingCount, seedingCount = status.SeedingCount, observedAt = status.ObservedAt, observationError = status.ObservationError, pausedLastPass = status.PausedLastPass, resumedLastPass = status.ResumedLastPass, handledLastPass = status.HandledLastPass, searchSummary = status.SearchSummary, searchTimestamp = status.SearchTimestamp, queueCount = status.QueueCount, categoryCount = status.CategoryCount, metricType = status.MetricType, status = status.Status ?? (status.IsAlive ? "Running" : "Stopped"), restartKey = status.Name, restartable = true, torrent = status.Torrent, search = status.Search }); return Results.Ok(new { processes }); }); @@ -869,10 +888,10 @@ static bool MetaForceAllowed(HttpContext ctx, TorrentarrConfig cfg) app.MapGet("/web/config/schema", () => Results.Ok(ConfigSchemaBuilder.Build())); - app.MapGet("/web/qbit/overview", async (HttpRequest request, TorrentarrConfig cfg, ITorrentClientRegistry qbitManager) => + app.MapGet("/web/qbit/overview", async (HttpRequest request, TorrentarrConfig cfg, ITorrentClientRegistry qbitManager, TorrentStatisticsSnapshots snapshots, CancellationToken ct) => { var instance = request.Query["instance"].FirstOrDefault(); - return Results.Ok(await QbitOverviewBuilder.BuildAsync(cfg, qbitManager, instance)); + return Results.Ok(await QbitOverviewBuilder.BuildAsync(cfg, qbitManager, instance, ct, snapshots)); }); // Web Arr List @@ -1481,10 +1500,10 @@ orderby t.TrackNumber }); // Web Qbit Categories (api mirror — same logic as /web/qbit/categories, token-protected) - app.MapGet("/api/qbit/categories", async (ITorrentClientRegistry registry, TorrentarrConfig cfg, CancellationToken ct) => + app.MapGet("/api/qbit/categories", async (ITorrentClientRegistry registry, TorrentarrConfig cfg, TorrentStatisticsSnapshots snapshots, CancellationToken ct) => { - var categories = await TorrentClientApiBuilder.CategoriesAsync(cfg, registry, ct: ct); - return Results.Ok(new { categories, ready = true }); + var categories = await TorrentClientApiBuilder.CategoriesAsync(cfg, registry, ct: ct, snapshots: snapshots); + return Results.Ok(new { categories, ready = true, observations = snapshots.Describe(cfg, registry) }); }); // ==================== /api/* endpoints (Bearer token protected via middleware) ==================== @@ -1525,6 +1544,16 @@ orderby t.TrackNumber pid = s.Pid, alive = s.Alive, rebuilding = s.Rebuilding, + searchesTriggered = s.SearchesTriggered, + itemsSearched = s.ItemsSearched, + downloadingCount = s.DownloadingCount, + seedingCount = s.SeedingCount, + observedAt = s.ObservedAt, + observationError = s.ObservationError, + pausedLastPass = s.PausedLastPass, + resumedLastPass = s.ResumedLastPass, + handledLastPass = s.HandledLastPass, + monitoredCategoryCount = s.MonitoredCategoryCount, searchSummary = s.SearchSummary, searchTimestamp = s.SearchTimestamp, queueCount = s.QueueCount, @@ -1535,7 +1564,7 @@ orderby t.TrackNumber status = s.Status }).ToList(); foreach (var status in (await workers.GetProcessStatusAsync()).Values) - processes.Add(new { category = cfg.ArrInstances.GetValueOrDefault(status.Name)?.Category ?? status.Name, name = status.Name, kind = status.Kind, pid = status.ProcessId, alive = status.IsAlive, rebuilding = false, searchSummary = status.SearchSummary, searchTimestamp = status.SearchTimestamp, queueCount = status.QueueCount, categoryCount = status.CategoryCount, metricType = status.MetricType, status = status.Status ?? (status.IsAlive ? "Running" : "Stopped"), restartKey = status.Name, restartable = true, torrent = status.Torrent, search = status.Search }); + processes.Add(new { category = cfg.ArrInstances.GetValueOrDefault(status.Name)?.Category ?? status.Name, name = status.Name, kind = status.Kind, pid = status.ProcessId, alive = status.IsAlive, rebuilding = false, searchesTriggered = status.SearchesTriggered, itemsSearched = status.ItemsSearched, downloadingCount = status.DownloadingCount, seedingCount = status.SeedingCount, observedAt = status.ObservedAt, observationError = status.ObservationError, pausedLastPass = status.PausedLastPass, resumedLastPass = status.ResumedLastPass, handledLastPass = status.HandledLastPass, searchSummary = status.SearchSummary, searchTimestamp = status.SearchTimestamp, queueCount = status.QueueCount, categoryCount = status.CategoryCount, metricType = status.MetricType, status = status.Status ?? (status.IsAlive ? "Running" : "Stopped"), restartKey = status.Name, restartable = true, torrent = status.Torrent, search = status.Search }); return Results.Ok(new { processes }); }); @@ -2413,6 +2442,7 @@ void Publish() { status = "ok", configReloaded = application?.State == "applied", + restartRequirements = Array.Empty(), application, reloadType, affectedInstances = affectedInstancesList, @@ -2717,6 +2747,9 @@ private async Task ProcessSpecialCategoriesAsync(CancellationToken cancellationT { using var processing = ConfigMutationGuard.EnterProcessing(); var configured = _config.GetAllTorrentClients(); + var failedHandled = 0; + var recheckHandled = 0; + var complete = true; foreach (var id in _specialCategoryCounts.Keys.Where(id => !configured.TryGetValue(id, out var cfg) || cfg.Disabled).ToArray()) _specialCategoryCounts.Remove(id); foreach (var (instanceName, client) in _qbitManager.GetAllClients().Where(pair => clientIds.Contains(pair.Key))) @@ -2747,6 +2780,7 @@ private async Task ProcessSpecialCategoriesAsync(CancellationToken cancellationT } _logger.LogWarning("[{Instance}] Deleting failed torrent: {Name}", instanceName, torrent.Name); var deletion = await _safeDeletion.RemoveTorrentAsync(instanceName, torrent.Hash, removeContent: true, ct: cancellationToken); + if (deletion.Success) failedHandled++; if (!deletion.Success) _logger.LogWarning("[{Instance}] Safe deletion skipped for {Name}: {Reason}", instanceName, torrent.Name, deletion.Error); } @@ -2768,16 +2802,34 @@ private async Task ProcessSpecialCategoriesAsync(CancellationToken cancellationT } torrent.ClientInstanceId = instanceName; if (await MaintenanceRecovery.RequestExplicitAsync(recoveryDb, client, torrent, cancellationToken)) + { + recheckHandled++; _logger.LogInformation("[{Instance}] Requested recheck for torrent: {Name}", instanceName, torrent.Name); + } } } - catch (Exception ex) + catch (Exception ex) when (ex is not OperationCanceledException) { + complete = false; _logger.LogError(ex, "[{Instance}] Error processing special categories", instanceName); } } - _stateManager.Update("Failed", s => { s.CategoryCount = _specialCategoryCounts.Values.Sum(counts => counts.Failed); s.Alive = true; }); - _stateManager.Update("Recheck", s => { s.CategoryCount = _specialCategoryCounts.Values.Sum(counts => counts.Recheck); s.Alive = true; }); + _stateManager.Update("Failed", s => + { + s.CategoryCount = _specialCategoryCounts.Values.Sum(counts => counts.Failed); + s.MonitoredCategoryCount = string.IsNullOrWhiteSpace(_config.Settings.FailedCategory) ? 0 : 1; + s.Alive = true; + s.ObservationError = complete ? null : "Some clients could not be inspected; retaining the last completed pass."; + if (complete) { s.HandledLastPass = failedHandled; s.ObservedAt = DateTimeOffset.UtcNow; } + }); + _stateManager.Update("Recheck", s => + { + s.CategoryCount = _specialCategoryCounts.Values.Sum(counts => counts.Recheck); + s.MonitoredCategoryCount = string.IsNullOrWhiteSpace(_config.Settings.RecheckCategory) ? 0 : 1; + s.Alive = true; + s.ObservationError = complete ? null : "Some clients could not be inspected; retaining the last completed pass."; + if (complete) { s.HandledLastPass = recheckHandled; s.ObservedAt = DateTimeOffset.UtcNow; } + }); } private async Task ProcessTorrentPolicyAsync(HashSet managedCategories, IReadOnlySet clientIds, CancellationToken cancellationToken) @@ -2831,6 +2883,9 @@ private async Task ProcessFreeSpaceManagerAsync(HashSet managedCategorie _stateManager.Update("FreeSpaceManager", state => { state.CategoryCount = result.MarkedCount; + state.PausedLastPass = result.Paused; + state.ResumedLastPass = result.Resumed; + state.ObservedAt = DateTimeOffset.UtcNow; state.MetricType = "free-space"; state.Alive = result.Available; }); @@ -2854,7 +2909,7 @@ private async Task SortManagedTorrentsByTrackerPriorityAsync( { torrentList = await client.GetTorrentsAsync(category: null, "priority", cancellationToken); } - catch (Exception ex) + catch (Exception ex) when (ex is not OperationCanceledException) { _logger.LogDebug(ex, "[{Instance}] SortTorrents: falling back to added_on sort", instanceName); torrentList = await client.GetTorrentsAsync(category: null, "added_on", cancellationToken); @@ -2923,6 +2978,7 @@ private async Task SortManagedTorrentsByTrackerPriorityAsync( await client.SetTopPriorityAsync(torrent.Hash, cancellationToken); } } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } catch (Exception ex) { _logger.LogError(ex, "[{Instance}] SortTorrents policy step failed", instanceName); diff --git a/src/Torrentarr.Infrastructure/ApiClients/Arr/ArrActivityClient.cs b/src/Torrentarr.Infrastructure/ApiClients/Arr/ArrActivityClient.cs new file mode 100644 index 00000000..13588e74 --- /dev/null +++ b/src/Torrentarr.Infrastructure/ApiClients/Arr/ArrActivityClient.cs @@ -0,0 +1,62 @@ +using Newtonsoft.Json.Linq; +using RestSharp; +namespace Torrentarr.Infrastructure.ApiClients.Arr; + +internal interface IArrActivityClient : IDisposable +{ + Task ReadActivityAsync(string resource, CancellationToken ct); +} + +internal static class ArrActivityResponse +{ + internal static async Task ReadAsync(RestClient client, RestRequest request, CancellationToken ct) + { + var response = await ArrClientResponse.ExecuteAsync(client, request, ct); + ArrClientResponse.EnsureSuccess(response, "GET activity evidence"); + return JArray.Parse(response.Content ?? throw new IOException("Missing activity evidence")); + } +} + +public partial class RadarrClient +{ + void IDisposable.Dispose() => _client.Dispose(); + async Task IArrActivityClient.ReadActivityAsync(string resource, CancellationToken ct) + { + var request = new RestRequest("/api/v3/" + resource, Method.Get); + AddApiKeyHeader(request); + return await ArrActivityResponse.ReadAsync(_client, request, ct); + } +} + +public partial class SonarrClient +{ + void IDisposable.Dispose() => _client.Dispose(); + async Task IArrActivityClient.ReadActivityAsync(string resource, CancellationToken ct) + { + var request = await CreateRequestAsync(resource, Method.Get, ct); + AddApiKeyHeader(request); + return await ArrActivityResponse.ReadAsync(_client, request, ct); + } +} + +public partial class LidarrClient +{ + void IDisposable.Dispose() => _client.Dispose(); + async Task IArrActivityClient.ReadActivityAsync(string resource, CancellationToken ct) + { + var request = new RestRequest("/api/v1/" + resource, Method.Get); + AddApiKeyHeader(request); + return await ArrActivityResponse.ReadAsync(_client, request, ct); + } +} + +public partial class ReadarrClient +{ + void IDisposable.Dispose() => _client.Dispose(); + async Task IArrActivityClient.ReadActivityAsync(string resource, CancellationToken ct) + { + var request = new RestRequest("/api/v1/" + resource, Method.Get); + AddApiKeyHeader(request); + return await ArrActivityResponse.ReadAsync(_client, request, ct); + } +} diff --git a/src/Torrentarr.Infrastructure/ApiClients/Arr/ArrClientResponse.cs b/src/Torrentarr.Infrastructure/ApiClients/Arr/ArrClientResponse.cs index 668399db..a4f10515 100644 --- a/src/Torrentarr.Infrastructure/ApiClients/Arr/ArrClientResponse.cs +++ b/src/Torrentarr.Infrastructure/ApiClients/Arr/ArrClientResponse.cs @@ -7,8 +7,12 @@ namespace Torrentarr.Infrastructure.ApiClients.Arr; internal static class ArrClientResponse { - internal static Task ExecuteAsync(RestClient client, RestRequest request, CancellationToken ct) => - HttpRetryHelper.ExecuteArrAsync(client, request, ct); + internal static async Task ExecuteAsync(RestClient client, RestRequest request, CancellationToken ct) + { + var response = await HttpRetryHelper.ExecuteArrAsync(client, request, ct); + await Torrentarr.Infrastructure.Services.ArrActivityTracking.RecordAcceptedAsync(request, response, ct); + return response; + } internal static void EnsureSuccess(RestResponse response, string operation) { diff --git a/src/Torrentarr.Infrastructure/ApiClients/Arr/LidarrClient.cs b/src/Torrentarr.Infrastructure/ApiClients/Arr/LidarrClient.cs index f9c440be..f1db8ef4 100644 --- a/src/Torrentarr.Infrastructure/ApiClients/Arr/LidarrClient.cs +++ b/src/Torrentarr.Infrastructure/ApiClients/Arr/LidarrClient.cs @@ -6,7 +6,7 @@ namespace Torrentarr.Infrastructure.ApiClients.Arr; /// /// Lidarr API client using RestSharp /// -public class LidarrClient +public partial class LidarrClient : IArrActivityClient { private readonly RestClient _client; private readonly string _apiKey; diff --git a/src/Torrentarr.Infrastructure/ApiClients/Arr/RadarrClient.cs b/src/Torrentarr.Infrastructure/ApiClients/Arr/RadarrClient.cs index 002a77fd..82a55fef 100644 --- a/src/Torrentarr.Infrastructure/ApiClients/Arr/RadarrClient.cs +++ b/src/Torrentarr.Infrastructure/ApiClients/Arr/RadarrClient.cs @@ -6,7 +6,7 @@ namespace Torrentarr.Infrastructure.ApiClients.Arr; /// /// Radarr API client using RestSharp /// -public class RadarrClient +public partial class RadarrClient : IArrActivityClient { private readonly RestClient _client; private readonly string _apiKey; @@ -486,7 +486,8 @@ public class QualityProfile [JsonProperty("name")] public string Name { get; set; } = ""; - [JsonProperty("minCustomFormatScore")] + // Arr APIs expose this as minFormatScore; DB column is MinCustomFormatScore. + [JsonProperty("minFormatScore")] public int? MinCustomFormatScore { get; set; } [JsonProperty("cutoff")] diff --git a/src/Torrentarr.Infrastructure/ApiClients/Arr/ReadarrClient.cs b/src/Torrentarr.Infrastructure/ApiClients/Arr/ReadarrClient.cs index 25a4aaf8..df932094 100644 --- a/src/Torrentarr.Infrastructure/ApiClients/Arr/ReadarrClient.cs +++ b/src/Torrentarr.Infrastructure/ApiClients/Arr/ReadarrClient.cs @@ -7,7 +7,7 @@ namespace Torrentarr.Infrastructure.ApiClients.Arr; /// /// Readarr API client using RestSharp. Author → book (no track layer). /// -public class ReadarrClient +public partial class ReadarrClient : IArrActivityClient { private readonly RestClient _client; private readonly string _apiKey; diff --git a/src/Torrentarr.Infrastructure/ApiClients/Arr/SonarrClient.cs b/src/Torrentarr.Infrastructure/ApiClients/Arr/SonarrClient.cs index 58d8c187..6341f5be 100644 --- a/src/Torrentarr.Infrastructure/ApiClients/Arr/SonarrClient.cs +++ b/src/Torrentarr.Infrastructure/ApiClients/Arr/SonarrClient.cs @@ -6,7 +6,7 @@ namespace Torrentarr.Infrastructure.ApiClients.Arr; /// /// Sonarr API client using RestSharp /// -public class SonarrClient +public partial class SonarrClient : IArrActivityClient { private readonly RestClient _client; private readonly string _apiKey; diff --git a/src/Torrentarr.Infrastructure/Database/DatabaseRetryExtensions.cs b/src/Torrentarr.Infrastructure/Database/DatabaseRetryExtensions.cs index 663274f1..febe87ef 100644 --- a/src/Torrentarr.Infrastructure/Database/DatabaseRetryExtensions.cs +++ b/src/Torrentarr.Infrastructure/Database/DatabaseRetryExtensions.cs @@ -1,3 +1,4 @@ +using Microsoft.EntityFrameworkCore.Query; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; @@ -10,6 +11,23 @@ namespace Torrentarr.Infrastructure.Database; /// public static class DatabaseRetryExtensions { + /// Bulk updates bypass SaveChanges, so retry transient SQLite writer contention here. + public static async Task ExecuteUpdateWithRetryAsync(this IQueryable query, + Action> setters, + ILogger? logger = null, CancellationToken cancellationToken = default) + { + for (var attempt = 0; ; attempt++) + { + cancellationToken.ThrowIfCancellationRequested(); + try { return await query.ExecuteUpdateAsync(setters, cancellationToken); } + catch (SqliteException ex) when (ex.SqliteErrorCode is 5 or 6 && attempt < 2) + { + logger?.LogWarning("SQLite writer busy; retrying bulk update (attempt {Attempt}/3)", attempt + 2); + await Task.Delay(TimeSpan.FromMilliseconds(250 * (1 << attempt)), cancellationToken); + } + } + } + public static async Task SaveChangesWithRetryAsync( this DbContext context, ILogger? logger = null, @@ -18,7 +36,7 @@ public static async Task SaveChangesWithRetryAsync( CancellationToken cancellationToken = default) { // Capture only operational identifiers and state, never arbitrary entity values or payloads. - var changes = logger?.IsEnabled(LogLevel.Information) == true + var changes = logger?.IsEnabled(LogLevel.Debug) == true ? context.ChangeTracker.Entries() .Where(e => e.State is EntityState.Added or EntityState.Modified or EntityState.Deleted) .Select(e => (Entry: e, State: e.State)).ToArray() @@ -35,9 +53,9 @@ async Task SaveAsync() "Searched" or "Upgrade" or "IsRequest" or "Imported" or "QualityMet" or "CustomFormatMet" or "SeasonNumber" or "EpisodeNumber" or "ArrSeriesId" or "AirDateUtc" or "ReleaseDate") .Select(p => $"{p.Metadata.Name}={p.CurrentValue}")); - logger?.LogInformation("Database saved {Operation}: {Entity} | {Identity}", state, entry.Metadata.ClrType.Name, identity); + logger?.LogDebug("Database saved {Operation}: {Entity} | {Identity}", state, entry.Metadata.ClrType.Name, identity); } - logger?.LogInformation("Database save completed: {Count} entries saved (added={Added}, updated={Updated}, deleted={Deleted})", + logger?.LogDebug("Database save completed: {Count} entries saved (added={Added}, updated={Updated}, deleted={Deleted})", count, changes.Count(c => c.State == EntityState.Added), changes.Count(c => c.State == EntityState.Modified), changes.Count(c => c.State == EntityState.Deleted)); return count; @@ -103,11 +121,16 @@ public static bool IsSqliteCorruption(Exception ex) return false; } - private static bool IsRetriable(Exception ex) => - ex is DbUpdateException or SqliteException - && (ex.Message.Contains("database is locked", StringComparison.OrdinalIgnoreCase) - || ex.Message.Contains("disk I/O", StringComparison.OrdinalIgnoreCase) - || ex.Message.Contains("malformed", StringComparison.OrdinalIgnoreCase)); + private static bool IsRetriable(Exception error) + { + for (Exception? current = error; current != null; current = current.InnerException) + if (current is SqliteException sqlite && sqlite.SqliteErrorCode is 5 or 6 + || current is DbUpdateException or SqliteException && + (current.Message.Contains("database is locked", StringComparison.OrdinalIgnoreCase) + || current.Message.Contains("disk I/O", StringComparison.OrdinalIgnoreCase) + || current.Message.Contains("malformed", StringComparison.OrdinalIgnoreCase))) return true; + return false; + } private static bool IsCorruption(Exception ex) => IsSqliteCorruption(ex); } diff --git a/src/Torrentarr.Infrastructure/Database/ManualSqliteMigrations.cs b/src/Torrentarr.Infrastructure/Database/ManualSqliteMigrations.cs index 66cbf626..d065aa15 100644 --- a/src/Torrentarr.Infrastructure/Database/ManualSqliteMigrations.cs +++ b/src/Torrentarr.Infrastructure/Database/ManualSqliteMigrations.cs @@ -40,6 +40,16 @@ CREATE TABLE IF NOT EXISTS torrentarr_operational_issues ( AddColumnIfMissing(db, "albumfilesmodel", "PhysicalRelease", "TEXT"); AddColumnIfMissing(db, "albumfilesmodel", "MinimumAvailability", "TEXT"); + CreateTableIfMissing(db, "torrentarr_arr_samples", "CREATE TABLE IF NOT EXISTS torrentarr_arr_samples (Instance TEXT NOT NULL, Minute INTEGER NOT NULL, Type TEXT NOT NULL, QueueDepth INTEGER, CompletionObserved INTEGER NOT NULL, PRIMARY KEY (Instance, Minute));"); + db.Database.ExecuteSqlRaw("CREATE INDEX IF NOT EXISTS IX_torrentarr_arr_samples_Minute ON torrentarr_arr_samples (Minute)"); + CreateTableIfMissing(db, "torrentarr_arr_operations", "CREATE TABLE IF NOT EXISTS torrentarr_arr_operations (Instance TEXT NOT NULL, CommandId INTEGER NOT NULL, Type TEXT NOT NULL, Kind TEXT NOT NULL, Server TEXT NOT NULL, ClientInstance TEXT NOT NULL, DownloadId TEXT NOT NULL, SubmittedAt INTEGER NOT NULL, ConfirmedAt INTEGER, Outcome TEXT NOT NULL DEFAULT 'pending', EvidenceId TEXT, PRIMARY KEY (Instance, CommandId));"); + db.Database.ExecuteSqlRaw("CREATE UNIQUE INDEX IF NOT EXISTS IX_torrentarr_arr_operations_Instance_EvidenceId ON torrentarr_arr_operations (Instance, EvidenceId)"); + db.Database.ExecuteSqlRaw("CREATE INDEX IF NOT EXISTS IX_torrentarr_arr_operations_SubmittedAt ON torrentarr_arr_operations (SubmittedAt)"); + db.Database.ExecuteSqlRaw("CREATE INDEX IF NOT EXISTS IX_torrentarr_arr_operations_ConfirmedAt ON torrentarr_arr_operations (ConfirmedAt)"); + db.Database.ExecuteSqlRaw("CREATE INDEX IF NOT EXISTS IX_torrentarr_arr_operations_Instance_DownloadId ON torrentarr_arr_operations (Instance, DownloadId)"); + db.Database.ExecuteSqlRaw("CREATE INDEX IF NOT EXISTS IX_torrentarr_arr_operations_Instance_Outcome ON torrentarr_arr_operations (Instance, Outcome)"); + CreateTableIfMissing(db, "torrentarr_transfer_samples", "CREATE TABLE IF NOT EXISTS torrentarr_transfer_samples (ClientInstance TEXT NOT NULL, Minute INTEGER NOT NULL, DownloadRate INTEGER NOT NULL, UploadRate INTEGER NOT NULL, PRIMARY KEY (ClientInstance, Minute));"); + db.Database.ExecuteSqlRaw("CREATE INDEX IF NOT EXISTS IX_torrentarr_transfer_samples_Minute ON torrentarr_transfer_samples (Minute)"); CreateTableIfMissing(db, "torrentarr_maintenance_state", "CREATE TABLE IF NOT EXISTS torrentarr_maintenance_state (Key TEXT NOT NULL PRIMARY KEY, Data TEXT NOT NULL DEFAULT '{}');"); CreateTableIfMissing(db, "searchactivity", "CREATE TABLE IF NOT EXISTS searchactivity ( category TEXT NOT NULL PRIMARY KEY, summary TEXT, timestamp TEXT );"); @@ -154,6 +164,14 @@ torrentdownloadpath TEXT MarkManualMigrationApplied(db, emptyArrInstanceCleanup); } + // QueueDepth was sampled from queue tables that were never populated; clear the false zeros once. + const string resetArrQueueDepth = "arr_activity_queue_depth_reset_v1"; + if (!IsManualMigrationApplied(db, resetArrQueueDepth)) + { + db.Database.ExecuteSqlRaw("UPDATE torrentarr_arr_samples SET QueueDepth = NULL"); + MarkManualMigrationApplied(db, resetArrQueueDepth); + } + CreateIndexIfMissing(db, "idx_arrinstance_movies", "moviesfilesmodel", "arrinstance"); CreateIndexIfMissing(db, "idx_arrinstance_episodes", "episodefilesmodel", "arrinstance"); CreateIndexIfMissing(db, "idx_arrinstance_series", "seriesfilesmodel", "arrinstance"); diff --git a/src/Torrentarr.Infrastructure/Database/Models/ArrActivity.cs b/src/Torrentarr.Infrastructure/Database/Models/ArrActivity.cs new file mode 100644 index 00000000..19106b01 --- /dev/null +++ b/src/Torrentarr.Infrastructure/Database/Models/ArrActivity.cs @@ -0,0 +1,28 @@ +using System.ComponentModel.DataAnnotations.Schema; +namespace Torrentarr.Infrastructure.Database.Models; + +[Table("torrentarr_arr_samples")] +public sealed class ArrActivitySample +{ + public string Instance { get; set; } = ""; + public long Minute { get; set; } + public string Type { get; set; } = ""; + public int? QueueDepth { get; set; } + public bool CompletionObserved { get; set; } +} + +[Table("torrentarr_arr_operations")] +public sealed class ArrActivityOperation +{ + public string Instance { get; set; } = ""; + public int CommandId { get; set; } + public string Type { get; set; } = ""; + public string Kind { get; set; } = ""; + public string Server { get; set; } = ""; + public string ClientInstance { get; set; } = ""; + public string DownloadId { get; set; } = ""; + public long SubmittedAt { get; set; } + public long? ConfirmedAt { get; set; } + public string Outcome { get; set; } = "pending"; + public string? EvidenceId { get; set; } +} diff --git a/src/Torrentarr.Infrastructure/Database/Models/TransferSample.cs b/src/Torrentarr.Infrastructure/Database/Models/TransferSample.cs new file mode 100644 index 00000000..448fd32b --- /dev/null +++ b/src/Torrentarr.Infrastructure/Database/Models/TransferSample.cs @@ -0,0 +1,12 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace Torrentarr.Infrastructure.Database.Models; + +[Table("torrentarr_transfer_samples")] +public sealed class TransferSample +{ + public string ClientInstance { get; set; } = ""; + public long Minute { get; set; } + public long DownloadRate { get; set; } + public long UploadRate { get; set; } +} diff --git a/src/Torrentarr.Infrastructure/Database/TorrentarrDbContext.cs b/src/Torrentarr.Infrastructure/Database/TorrentarrDbContext.cs index 345a35ec..61c47fc0 100644 --- a/src/Torrentarr.Infrastructure/Database/TorrentarrDbContext.cs +++ b/src/Torrentarr.Infrastructure/Database/TorrentarrDbContext.cs @@ -32,11 +32,26 @@ public TorrentarrDbContext(DbContextOptions options) public DbSet SonarrSearchRetries { get; set; } public DbSet MaintenanceState { get; set; } + public DbSet ArrActivitySamples { get; set; } + public DbSet ArrActivityOperations { get; set; } + + public DbSet TransferSamples { get; set; } + public DbSet OperationalIssues { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); + modelBuilder.Entity().HasKey(s => new { s.Instance, s.Minute }); + modelBuilder.Entity().HasIndex(s => s.Minute); + modelBuilder.Entity().HasKey(s => new { s.Instance, s.CommandId }); + modelBuilder.Entity().HasIndex(s => new { s.Instance, s.EvidenceId }).IsUnique(); + modelBuilder.Entity().HasIndex(s => s.SubmittedAt); + modelBuilder.Entity().HasIndex(s => s.ConfirmedAt); + modelBuilder.Entity().HasIndex(s => new { s.Instance, s.DownloadId }); + modelBuilder.Entity().HasIndex(s => new { s.Instance, s.Outcome }); + modelBuilder.Entity().HasKey(s => new { s.ClientInstance, s.Minute }); + modelBuilder.Entity().HasIndex(s => s.Minute); modelBuilder.Entity().Property(i => i.ArrInstance).UseCollation("NOCASE"); modelBuilder.Entity().Property(i => i.ClientInstance).UseCollation("NOCASE"); modelBuilder.Entity().HasIndex(i => new { i.Code, i.ArrInstance, i.ClientInstance, i.SubjectType, i.SubjectId }).IsUnique(); diff --git a/src/Torrentarr.Infrastructure/Endpoints/LogFileApi.cs b/src/Torrentarr.Infrastructure/Endpoints/LogFileApi.cs index 4d43cacf..cf8a4d23 100644 --- a/src/Torrentarr.Infrastructure/Endpoints/LogFileApi.cs +++ b/src/Torrentarr.Infrastructure/Endpoints/LogFileApi.cs @@ -387,10 +387,11 @@ private static List SiblingLogFiles(string logsRoot, string name, bool i if (!includeRotated || !Directory.Exists(logsRoot)) return files; - var prefix = Path.GetFileName(name); + var prefix = Regex.Replace(Path.GetFileName(name), @"(?:_\d{3,})?\.log$", ""); + var sibling = new Regex("^" + Regex.Escape(prefix) + @"(?:_\d{3,})?\.log(?:\..*)?$", RegexOptions.CultureInvariant | RegexOptions.NonBacktracking); foreach (var candidate in Directory.GetFiles(logsRoot, prefix + "*")) { - if (files.Contains(candidate, StringComparer.OrdinalIgnoreCase)) + if (!sibling.IsMatch(Path.GetFileName(candidate)) || files.Contains(candidate, StringComparer.OrdinalIgnoreCase)) continue; files.Add(candidate); } diff --git a/src/Torrentarr.Infrastructure/Endpoints/StatisticsEndpoints.cs b/src/Torrentarr.Infrastructure/Endpoints/StatisticsEndpoints.cs new file mode 100644 index 00000000..5ff4c7e4 --- /dev/null +++ b/src/Torrentarr.Infrastructure/Endpoints/StatisticsEndpoints.cs @@ -0,0 +1,44 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Torrentarr.Core.Services; +using Torrentarr.Infrastructure.Services; + +namespace Torrentarr.Infrastructure.Endpoints; + +public static class StatisticsEndpoints +{ + public static void MapStatisticsEndpoints(this IEndpointRouteBuilder app) + { + app.MapGet("/web/free-space/stats", async (IFreeSpaceService storage, CancellationToken ct) => + Results.Ok(await storage.GetFreeSpaceStatsAsync(ct))); + app.MapGet("/web/stats/events", async (HttpContext context, StatisticsEvents events) => + { + context.Response.ContentType = "text/event-stream"; + context.Response.Headers.CacheControl = "no-cache, no-store"; + context.Response.Headers["X-Accel-Buffering"] = "no"; + var (id, reader) = events.Subscribe(); + try + { + while (!context.RequestAborted.IsCancellationRequested) + { + using var heartbeat = CancellationTokenSource.CreateLinkedTokenSource(context.RequestAborted); + heartbeat.CancelAfter(TimeSpan.FromSeconds(15)); + try + { + if (!await reader.WaitToReadAsync(heartbeat.Token)) break; + while (reader.TryRead(out var revision)) + await context.Response.WriteAsync($"data: {{\"revision\":{revision}}}\n\n", context.RequestAborted); + } + catch (OperationCanceledException) when (!context.RequestAborted.IsCancellationRequested) + { + await context.Response.WriteAsync(": keepalive\n\n", context.RequestAborted); + } + await context.Response.Body.FlushAsync(context.RequestAborted); + } + } + catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested) { } + finally { events.Unsubscribe(id); } + }); + } +} diff --git a/src/Torrentarr.Infrastructure/Logging/ServiceLogSink.cs b/src/Torrentarr.Infrastructure/Logging/ServiceLogSink.cs index 8f530641..21ca5ed4 100644 --- a/src/Torrentarr.Infrastructure/Logging/ServiceLogSink.cs +++ b/src/Torrentarr.Infrastructure/Logging/ServiceLogSink.cs @@ -13,6 +13,15 @@ namespace Torrentarr.Infrastructure.Logging; /// Shared daily files for every process, with an aggregate and one service destination. public class ServiceLogSink(string logsPath, string processType) : ILogEventSink, IDisposable { + internal const long FileSizeLimit = 128L * 1024 * 1024; + internal const int RetainedFileCount = 8; + + static ServiceLogSink() => Serilog.Debugging.SelfLog.Enable(message => Console.Error.WriteLine(DiagnosticText.Safe(message))); + + private readonly long _fileSizeLimit = FileSizeLimit; + internal ServiceLogSink(string logsPath, string processType, long fileSizeLimit) : this(logsPath, processType) + => _fileSizeLimit = fileSizeLimit; + private readonly Dictionary _writers = new(StringComparer.Ordinal); private readonly object _gate = new(); @@ -52,7 +61,8 @@ private Logger Writer(string service) Directory.CreateDirectory(logsPath); writer = new LoggerConfiguration().MinimumLevel.Verbose() .WriteTo.File(new ServiceLogFormatter(), Path.Combine(logsPath, service + "-.log"), - rollingInterval: RollingInterval.Day, shared: true, retainedFileCountLimit: 7, fileSizeLimitBytes: null) + rollingInterval: RollingInterval.Day, shared: true, retainedFileCountLimit: RetainedFileCount, + fileSizeLimitBytes: _fileSizeLimit, rollOnFileSizeLimit: true, retainedFileTimeLimit: TimeSpan.FromDays(7)) .CreateLogger(); _writers.Add(service, writer); return writer; diff --git a/src/Torrentarr.Infrastructure/Services/ArrActivityService.cs b/src/Torrentarr.Infrastructure/Services/ArrActivityService.cs new file mode 100644 index 00000000..3faf2606 --- /dev/null +++ b/src/Torrentarr.Infrastructure/Services/ArrActivityService.cs @@ -0,0 +1,235 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json.Linq; +using Torrentarr.Core.Configuration; +using Torrentarr.Core.Interfaces; +using Torrentarr.Infrastructure.ApiClients.Arr; +using Torrentarr.Infrastructure.Database; +using Torrentarr.Infrastructure.Database.Models; + +namespace Torrentarr.Infrastructure.Services; + +public sealed class ArrActivityCollector(IServiceScopeFactory scopes, IServiceProvider services, + TorrentarrConfig config, ILogger logger) : BackgroundService +{ + private long _lastPrune; + protected override async Task ExecuteAsync(CancellationToken ct) + { + using var timer = new PeriodicTimer(TimeSpan.FromMinutes(1)); + do + { + try + { + var supervisor = services.GetService(); + var statuses = supervisor == null ? new Dictionary() : await supervisor.GetProcessStatusAsync(); + await CollectAsync(DateTimeOffset.UtcNow, statuses, ReadAsync, ct); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; } + catch (Exception ex) { logger.LogWarning(ex, "Arr activity collection unavailable"); } + } while (await timer.WaitForNextTickAsync(ct)); + } + + private static async Task ReadAsync(ArrInstanceConfig settings, string resource, CancellationToken ct) + { + using IArrActivityClient client = settings.Type.ToLowerInvariant() switch + { + "radarr" => new RadarrClient(settings.URI, settings.APIKey, settings.SkipTLSVerify), + "sonarr" => new SonarrClient(settings.URI, settings.APIKey, settings.SkipTLSVerify), + "lidarr" => new LidarrClient(settings.URI, settings.APIKey, settings.SkipTLSVerify), + "readarr" => new ReadarrClient(settings.URI, settings.APIKey, settings.SkipTLSVerify), + _ => throw new InvalidOperationException("Unsupported Arr activity type") + }; + return await client.ReadActivityAsync(resource, ct); + } + + internal async Task CollectAsync(DateTimeOffset now, IReadOnlyDictionary statuses, + Func> read, CancellationToken ct = default) + { + using var scope = scopes.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var minute = now.ToUnixTimeSeconds() / 60; + foreach (var (name, settings) in config.ArrInstances.ToArray()) + { + if (!settings.Managed || settings.Type is not ("radarr" or "sonarr" or "lidarr" or "readarr")) continue; + var status = statuses.GetValueOrDefault(name); + var fresh = status?.IsAlive == true && status.ConfigRevision >= config.Revision && status.LastHeartbeat is { } heartbeat + && now.UtcDateTime - heartbeat <= TimeSpan.FromSeconds(15) && heartbeat <= now.UtcDateTime; + var observed = fresh; + try + { + // Repeated callers in one minute must not poll Arr again or rewrite coverage. + if (await db.ArrActivitySamples.AnyAsync(s => s.Instance == name && s.Minute == minute, ct)) continue; + var pending = await db.ArrActivityOperations.Where(o => o.Instance == name && o.Outcome == "pending").ToListAsync(ct); + var eligible = new List(); + foreach (var operation in pending) + { + if (operation.Server != ArrActivityTracking.ServerKey(settings) || operation.SubmittedAt < now.ToUnixTimeSeconds() - 604800) + operation.Outcome = "expired"; + else eligible.Add(operation); + } + if (eligible.Count > 0) + { + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeout.CancelAfter(TimeSpan.FromSeconds(20)); + var commands = await read(settings, "command", timeout.Token); + JArray history = []; + if (eligible.Any(o => o.Kind == "import")) + { + var since = DateTimeOffset.FromUnixTimeSeconds(eligible.Where(o => o.Kind == "import").Min(o => o.SubmittedAt)); + var eventType = settings.Type is "lidarr" or "readarr" ? 8 : 3; + history = await read(settings, $"history/since?date={Uri.EscapeDataString(since.ToString("O"))}&eventType={eventType}", timeout.Token); + } + await ReconcileAsync(db, eligible, commands, history, now, ct); + } + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } + catch (Exception ex) { observed = false; logger.LogDebug(ex, "Arr completion evidence unavailable for {Instance}", name); } + db.ArrActivitySamples.Add(new ArrActivitySample + { + Instance = name, + Type = settings.Type, + Minute = minute, + QueueDepth = fresh && status!.QueueCount is >= 0 ? status.QueueCount : null, + CompletionObserved = observed + }); + await db.SaveChangesAsync(ct); + } + if (minute - _lastPrune >= 60) + { + await db.ArrActivitySamples.Where(s => s.Minute < minute - 10080).ExecuteDeleteAsync(ct); + await db.ArrActivityOperations.Where(o => (o.ConfirmedAt ?? o.SubmittedAt) < now.ToUnixTimeSeconds() - 604800).ExecuteDeleteAsync(ct); + _lastPrune = minute; + } + } + + internal static async Task ReconcileAsync(TorrentarrDbContext db, List pending, + JArray commands, JArray history, DateTimeOffset now, CancellationToken ct = default) + { + var importInstances = pending.Where(o => o.Kind == "import").Select(o => o.Instance).Distinct().ToList(); + var importHashes = pending.Where(o => o.Kind == "import").Select(o => o.DownloadId).Distinct().ToList(); + var previousImports = importInstances.Count == 0 ? new List() : await db.ArrActivityOperations + .Where(o => o.Kind == "import" && importInstances.Contains(o.Instance) && importHashes.Contains(o.DownloadId)).ToListAsync(ct); + var claimed = previousImports.Where(o => o.EvidenceId != null).Select(o => (o.Instance, o.EvidenceId)).ToHashSet(); + var confirmedImports = previousImports.Where(o => o.Outcome == "completed").ToList(); + foreach (var operation in pending.OrderBy(o => o.SubmittedAt).ThenBy(o => o.CommandId)) + { + if (operation.Outcome != "pending") continue; + var command = commands.FirstOrDefault(c => c["id"]?.Value() == operation.CommandId); + var state = command?["status"]?.Value()?.ToLowerInvariant(); + if (state is "failed" or "aborted" or "cancelled" or "canceled") { operation.Outcome = "failed"; continue; } + var result = command?["result"]?.Value()?.ToLowerInvariant(); + if (result is "failed" or "unsuccessful" or "2") { operation.Outcome = "failed"; continue; } + if (result is "unknown" or "0") continue; + if (state != "completed" && !(operation.Kind == "import" && command == null)) continue; + if (operation.Kind == "search" && result is not ("successful" or "1")) continue; + if (operation.Kind == "import") + { + if (confirmedImports.Any(o => o.Instance == operation.Instance && o.DownloadId == operation.DownloadId + && o.ConfirmedAt >= operation.SubmittedAt)) { operation.Outcome = "duplicate"; continue; } + var evidence = history.FirstOrDefault(h => PositiveImport(h, operation) + && !claimed.Contains((operation.Instance, "history:" + h["id"]!.Value()))); + if (evidence == null) continue; + operation.EvidenceId = "history:" + evidence["id"]!.Value(); + claimed.Add((operation.Instance, operation.EvidenceId)); + } + operation.Outcome = "completed"; + operation.ConfirmedAt = now.ToUnixTimeSeconds(); + if (operation.Kind == "import") confirmedImports.Add(operation); + } + } + + internal static bool PositiveImport(JToken history, ArrActivityOperation operation) + { + var type = history["eventType"]?.Value(); + var expected = operation.Type is "lidarr" or "readarr" ? "downloadImported" : "downloadFolderImported"; + var numeric = operation.Type is "lidarr" or "readarr" ? "8" : "3"; + return history["id"] != null && !string.IsNullOrEmpty(operation.DownloadId) + && string.Equals(history["downloadId"]?.Value(), operation.DownloadId, StringComparison.OrdinalIgnoreCase) + && (string.Equals(type, expected, StringComparison.OrdinalIgnoreCase) || type == numeric) + && DateTimeOffset.TryParse(history["date"]?.Value(), out var date) + && date.ToUnixTimeSeconds() >= operation.SubmittedAt; + } +} + +public sealed record ArrActivityPoint(DateTimeOffset Timestamp, int? QueueDepth, int? SearchesCompleted, + int? ImportsConfirmed, int QueueSamples, int CompletionSamples, int ExpectedSamples); +public sealed record ArrActivitySeries(string Instance, string Type, List Points); +public sealed record ArrActivityHistory(string Range, int BucketMinutes, DateTimeOffset GeneratedAt, List Series); + +public static class ArrActivityApi +{ + public static void MapArrActivity(this IEndpointRouteBuilder app) => app.MapGet("/web/statistics/arr-activity", + async (string? range, string? worker, TorrentarrConfig config, TorrentarrDbContext db, CancellationToken ct) => + { + range ??= "24h"; + if (range is not ("1h" or "24h" or "7d")) return Results.BadRequest(new { error = "range must be 1h, 24h, or 7d" }); + if (!string.IsNullOrEmpty(worker) && !config.ArrInstances.ContainsKey(worker)) + return Results.BadRequest(new { error = "Unknown Arr worker" }); + var canonicalWorker = string.IsNullOrEmpty(worker) ? null : config.ArrInstances.Keys.First(n => n.Equals(worker, StringComparison.OrdinalIgnoreCase)); + return Results.Ok(await QueryAsync(db, config, range, canonicalWorker, DateTimeOffset.UtcNow, ct)); + }); + + public static async Task QueryAsync(TorrentarrDbContext db, TorrentarrConfig config, + string range, string? worker, DateTimeOffset now, CancellationToken ct = default) + { + var (duration, bucket) = range switch + { + "1h" => (60, 1), + "24h" => (1440, 60), + "7d" => (10080, 60), + _ => throw new ArgumentOutOfRangeException(nameof(range)) + }; + var end = now.ToUnixTimeSeconds() / 60 / bucket * bucket; + var start = end - duration; + var samples = await db.ArrActivitySamples.AsNoTracking().Where(s => s.Minute >= start && s.Minute < end + && (worker == null || worker == "" || s.Instance == worker)) + .GroupBy(s => new { s.Instance, s.Type, Bucket = s.Minute / bucket * bucket }) + .Select(g => new + { + g.Key.Instance, + g.Key.Type, + g.Key.Bucket, + QueueDepth = g.Average(s => (double?)s.QueueDepth), + QueueSamples = g.Count(s => s.QueueDepth != null), + CompletionSamples = g.Count(s => s.CompletionObserved) + }).ToListAsync(ct); + var events = await db.ArrActivityOperations.AsNoTracking().Where(o => o.Outcome == "completed" && o.ConfirmedAt >= start * 60 && o.ConfirmedAt < end * 60 + && (worker == null || worker == "" || o.Instance == worker)) + .GroupBy(o => new { o.Instance, o.Type, Bucket = o.ConfirmedAt!.Value / 60 / bucket * bucket }) + .Select(g => new + { + g.Key.Instance, + g.Key.Type, + g.Key.Bucket, + Searches = g.Count(o => o.Kind == "search"), + Imports = g.Count(o => o.Kind == "import") + }).ToListAsync(ct); + var names = config.ArrInstances.Where(a => a.Value.Managed && (string.IsNullOrEmpty(worker) || a.Key == worker)) + .Select(a => a.Key).Union(samples.Select(s => s.Instance)).Union(events.Select(s => s.Instance)).Order().ToList(); + var sampleLookup = samples.ToLookup(s => (s.Instance, s.Bucket)); + var eventLookup = events.ToLookup(s => (s.Instance, s.Bucket)); + var series = names.Select(name => new ArrActivitySeries(name, + samples.OrderBy(s => s.Bucket).LastOrDefault(s => s.Instance == name)?.Type ?? events.FirstOrDefault(e => e.Instance == name)?.Type ?? config.ArrInstances[name].Type, + Enumerable.Range(0, duration / bucket).Select(i => + { + var minute = start + i * bucket; + var rows = sampleLookup[(name, minute)].ToList(); + var queueSamples = rows.Sum(s => s.QueueSamples); + var coverage = rows.Sum(s => s.CompletionSamples); + var completed = eventLookup[(name, minute)].ToList(); + var searches = completed.Sum(e => e.Searches); + var imports = completed.Sum(e => e.Imports); + return new ArrActivityPoint(DateTimeOffset.FromUnixTimeSeconds(minute * 60), + queueSamples == 0 ? null : (int)Math.Round(rows.Sum(s => (s.QueueDepth ?? 0) * s.QueueSamples) / queueSamples, + MidpointRounding.AwayFromZero), + coverage == 0 && searches == 0 ? null : searches, + coverage == 0 && imports == 0 ? null : imports, queueSamples, coverage, bucket); + }).ToList())).ToList(); + return new(range, bucket, now, series); + } +} diff --git a/src/Torrentarr.Infrastructure/Services/ArrActivityTracking.cs b/src/Torrentarr.Infrastructure/Services/ArrActivityTracking.cs new file mode 100644 index 00000000..64ebeaee --- /dev/null +++ b/src/Torrentarr.Infrastructure/Services/ArrActivityTracking.cs @@ -0,0 +1,52 @@ +using System.Security.Cryptography; +using System.Text; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json.Linq; +using RestSharp; +using Torrentarr.Core.Configuration; +using Torrentarr.Infrastructure.Database; + +namespace Torrentarr.Infrastructure.Services; + +/// Narrow execution-context scopes attribute real command responses to their submitter. +internal sealed class ArrActivityTracking : IDisposable +{ + private sealed record Context(TorrentarrDbContext? Db, ILogger? Logger, string Instance, + ArrInstanceConfig? Config, string Kind, string Hash, string Client); + private static readonly AsyncLocal Current = new(); + private readonly Context? _previous; + private ArrActivityTracking(Context value) { _previous = Current.Value; Current.Value = value; } + internal static IDisposable ForClient(string? client) => new ArrActivityTracking( + new(null, null, "", null, "", "", client ?? "")); + internal static IDisposable Begin(TorrentarrDbContext db, ILogger logger, string instance, + ArrInstanceConfig config, string kind, string hash = "") => new ArrActivityTracking( + new(db, logger, instance, config, kind, hash, Current.Value?.Client ?? "")); + public void Dispose() => Current.Value = _previous; + internal static string ServerKey(ArrInstanceConfig config) => Convert.ToHexString( + SHA256.HashData(Encoding.UTF8.GetBytes(config.URI.TrimEnd('/')))); + + internal static async Task RecordAcceptedAsync(RestRequest request, RestResponse response, CancellationToken ct) + { + var context = Current.Value; + if (context?.Db == null || context.Config == null || !context.Db.Database.IsSqlite() + || request.Method != Method.Post || !request.Resource.TrimEnd('/').EndsWith("/command") || !response.IsSuccessful) return; + try + { + var id = JObject.Parse(response.Content ?? "{}")["id"]?.Value() ?? 0; + if (id <= 0) return; + var server = ServerKey(context.Config); + var submitted = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var hash = context.Hash.ToUpperInvariant(); + var previousTimeout = context.Db.Database.GetCommandTimeout(); + try + { + // Optional statistics must not hold up normal worker writes on a busy SQLite database. + context.Db.Database.SetCommandTimeout(1); + await context.Db.Database.ExecuteSqlInterpolatedAsync($"INSERT OR IGNORE INTO torrentarr_arr_operations (Instance,CommandId,Type,Kind,Server,ClientInstance,DownloadId,SubmittedAt,Outcome) VALUES ({context.Instance},{id},{context.Config.Type},{context.Kind},{server},{context.Client},{hash},{submitted},'pending')", ct); + } + finally { context.Db.Database.SetCommandTimeout(previousTimeout); } + } + catch (Exception ex) { context.Logger?.LogDebug(ex, "Activity submission tracking unavailable for {Instance}", context.Instance); } + } +} diff --git a/src/Torrentarr.Infrastructure/Services/ArrImportService.cs b/src/Torrentarr.Infrastructure/Services/ArrImportService.cs index f8a19093..20195b02 100644 --- a/src/Torrentarr.Infrastructure/Services/ArrImportService.cs +++ b/src/Torrentarr.Infrastructure/Services/ArrImportService.cs @@ -61,8 +61,9 @@ public async Task TriggerImportAsync( // Find the Arr instance configuration for this category _logger.LogTrace("Looking up Arr instance for category {Category}", category); - var arrInstance = _config.ArrInstances.Values.FirstOrDefault(i => - i.Category.Equals(category, StringComparison.OrdinalIgnoreCase)); + var target = _config.ArrInstances.FirstOrDefault(p => + p.Value.Category.Equals(category, StringComparison.OrdinalIgnoreCase)); + var arrInstance = target.Value; if (arrInstance == null) { @@ -77,12 +78,13 @@ public async Task TriggerImportAsync( _logger.LogInformation("Found Arr instance: Type={Type}, URI={URI}, Category={Category}", arrInstance.Type, DiagnosticText.Safe(arrInstance.URI), arrInstance.Category); - using var instanceScope = _logger.BeginScope(new Dictionary { ["ProcessInstance"] = _config.ArrInstances.FirstOrDefault(p => ReferenceEquals(p.Value, arrInstance)).Key ?? category }); + using var instanceScope = _logger.BeginScope(new Dictionary { ["ProcessInstance"] = target.Key }); _logger.LogTrace("Import request: path={Path}; configuredMode={ConfiguredMode}; globalMode={GlobalMode}; type={Type}", contentPath, arrInstance.ImportMode, _config.Settings.ImportMode, arrInstance.Type); try { _logger.LogInformation("Routing import to {Type} handler", arrInstance.Type); + using var activity = ArrActivityTracking.Begin(_dbContext, _logger, target.Key, arrInstance, "import", hash); var result = arrInstance.Type.ToLower() switch { "radarr" => await TriggerRadarrImportAsync(arrInstance, hash, contentPath, cancellationToken), @@ -370,8 +372,9 @@ public async Task IsCustomFormatUnmetAsync(string hash, string category, C ["TorrentHash"] = hash, ["TorrentCategory"] = category }); - var arrInstance = _config.ArrInstances.Values.FirstOrDefault(i => - i.Category.Equals(category, StringComparison.OrdinalIgnoreCase)); + var target = _config.ArrInstances.FirstOrDefault(p => + p.Value.Category.Equals(category, StringComparison.OrdinalIgnoreCase)); + var arrInstance = target.Value; if (arrInstance == null || !arrInstance.Search.CustomFormatUnmetSearch) { @@ -537,8 +540,9 @@ public async Task BlocklistAndReSearchAsync(string hash, string category, ["TorrentHash"] = hash, ["TorrentCategory"] = category }); - var arrInstance = _config.ArrInstances.Values.FirstOrDefault(i => - i.Category.Equals(category, StringComparison.OrdinalIgnoreCase)); + var target = _config.ArrInstances.FirstOrDefault(p => + p.Value.Category.Equals(category, StringComparison.OrdinalIgnoreCase)); + var arrInstance = target.Value; if (arrInstance == null) { diff --git a/src/Torrentarr.Infrastructure/Services/ArrMediaService.cs b/src/Torrentarr.Infrastructure/Services/ArrMediaService.cs index 98488180..3f82e0d2 100644 --- a/src/Torrentarr.Infrastructure/Services/ArrMediaService.cs +++ b/src/Torrentarr.Infrastructure/Services/ArrMediaService.cs @@ -337,7 +337,7 @@ private async Task> GetSearchCandidatesAsync( { if ((!searchConfig.Unmonitored && movie.Monitored != true) || movie.Searched) { - _logger.LogInformation("Search skipped for {Title} [id={ArrId}, instance={Instance}]: monitored={Monitored}, searched={Searched}", movie.Title, movie.ArrId, instanceName, movie.Monitored, movie.Searched); + _logger.LogDebug("Search skipped for {Title} [id={ArrId}, instance={Instance}]: monitored={Monitored}, searched={Searched}", movie.Title, movie.ArrId, instanceName, movie.Monitored, movie.Searched); continue; } var priority = GetReasonPriority(movie.Reason, searchConfig); @@ -372,7 +372,7 @@ private async Task> GetSearchCandidatesAsync( { if ((!searchConfig.Unmonitored && album.Monitored != true) || album.Searched) { - _logger.LogInformation("Search skipped for {Title} [id={ArrId}, instance={Instance}]: monitored={Monitored}, searched={Searched}", album.Title, album.ArrId, instanceName, album.Monitored, album.Searched); + _logger.LogDebug("Search skipped for {Title} [id={ArrId}, instance={Instance}]: monitored={Monitored}, searched={Searched}", album.Title, album.ArrId, instanceName, album.Monitored, album.Searched); continue; } var priority = GetReasonPriority(album.Reason, searchConfig); @@ -401,7 +401,7 @@ private async Task> GetSearchCandidatesAsync( { if ((!searchConfig.Unmonitored && book.Monitored != true) || book.Searched) { - _logger.LogInformation("Search skipped for {Title} [id={ArrId}, instance={Instance}]: monitored={Monitored}, searched={Searched}", book.Title, book.ArrId, instanceName, book.Monitored, book.Searched); + _logger.LogDebug("Search skipped for {Title} [id={ArrId}, instance={Instance}]: monitored={Monitored}, searched={Searched}", book.Title, book.ArrId, instanceName, book.Monitored, book.Searched); continue; } var priority = GetReasonPriority(book.Reason, searchConfig); @@ -452,7 +452,7 @@ private async Task> GetUpgradeCandidatesAsync( { if (movie.Monitored != true || !movie.HasFile || (searchConfig.DoUpgradeSearch ? movie.Upgrade : movie.Searched)) { - _logger.LogInformation("Upgrade search skipped for {Title} [id={ArrId}, instance={Instance}]: monitored={Monitored}, hasFile={HasFile}, upgrade={Upgrade}, searched={Searched}", movie.Title, movie.ArrId, instanceName, movie.Monitored, movie.HasFile, movie.Upgrade, movie.Searched); + _logger.LogDebug("Upgrade search skipped for {Title} [id={ArrId}, instance={Instance}]: monitored={Monitored}, hasFile={HasFile}, upgrade={Upgrade}, searched={Searched}", movie.Title, movie.ArrId, instanceName, movie.Monitored, movie.HasFile, movie.Upgrade, movie.Searched); continue; } var priority = GetUpgradePriority(movie.QualityMet, movie.CustomFormatMet, searchConfig); @@ -487,7 +487,7 @@ private async Task> GetUpgradeCandidatesAsync( { if (album.Monitored != true || !album.HasFile || (searchConfig.DoUpgradeSearch ? album.Upgrade : album.Searched)) { - _logger.LogInformation("Upgrade search skipped for {Title} [id={ArrId}, instance={Instance}]: monitored={Monitored}, hasFile={HasFile}, upgrade={Upgrade}, searched={Searched}", album.Title, album.ArrId, instanceName, album.Monitored, album.HasFile, album.Upgrade, album.Searched); + _logger.LogDebug("Upgrade search skipped for {Title} [id={ArrId}, instance={Instance}]: monitored={Monitored}, hasFile={HasFile}, upgrade={Upgrade}, searched={Searched}", album.Title, album.ArrId, instanceName, album.Monitored, album.HasFile, album.Upgrade, album.Searched); continue; } var priority = GetUpgradePriority(album.QualityMet, album.CustomFormatMet, searchConfig); @@ -518,7 +518,7 @@ private async Task> GetUpgradeCandidatesAsync( { if (book.Monitored != true || !book.HasFile || (searchConfig.DoUpgradeSearch ? book.Upgrade : book.Searched)) { - _logger.LogInformation("Upgrade search skipped for {Title} [id={ArrId}, instance={Instance}]: monitored={Monitored}, hasFile={HasFile}, upgrade={Upgrade}, searched={Searched}", book.Title, book.ArrId, instanceName, book.Monitored, book.HasFile, book.Upgrade, book.Searched); + _logger.LogDebug("Upgrade search skipped for {Title} [id={ArrId}, instance={Instance}]: monitored={Monitored}, hasFile={HasFile}, upgrade={Upgrade}, searched={Searched}", book.Title, book.ArrId, instanceName, book.Monitored, book.HasFile, book.Upgrade, book.Searched); continue; } var priority = GetUpgradePriority(book.QualityMet, book.CustomFormatMet, searchConfig); diff --git a/src/Torrentarr.Infrastructure/Services/ArrSyncService.cs b/src/Torrentarr.Infrastructure/Services/ArrSyncService.cs index f69d0796..14785806 100644 --- a/src/Torrentarr.Infrastructure/Services/ArrSyncService.cs +++ b/src/Torrentarr.Infrastructure/Services/ArrSyncService.cs @@ -1344,7 +1344,7 @@ private static bool MinimumAvailabilityCheck(string? minimumAvailability, DateTi // Case 1: Year > now.year or Year == 0 → Skip if (year > now.Year || year == 0) { - logger?.LogInformation("Unavailable for search (availability rule 1): {Title} - Minimum Availability: {MinAvail}, Dates Cinema:{Cinemas}, Digital:{Digital}, Physical:{Physical}", + logger?.LogDebug("Unavailable for search (availability rule 1): {Title} - Minimum Availability: {MinAvail}, Dates Cinema:{Cinemas}, Digital:{Digital}, Physical:{Physical}", title, minimumAvailability, inCinemas, digitalRelease, physicalRelease); return false; } @@ -1352,7 +1352,7 @@ private static bool MinimumAvailabilityCheck(string? minimumAvailability, DateTi // Case 2: Year < now.year - 1 → Grab (old movie over 1 year old) if (year < now.Year - 1) { - logger?.LogInformation("Available for search (availability rule 2): {Title} - Minimum Availability: {MinAvail}, Dates Cinema:{Cinemas}, Digital:{Digital}, Physical:{Physical}", + logger?.LogDebug("Available for search (availability rule 2): {Title} - Minimum Availability: {MinAvail}, Dates Cinema:{Cinemas}, Digital:{Digital}, Physical:{Physical}", title, minimumAvailability, inCinemas, digitalRelease, physicalRelease); return true; } @@ -1360,7 +1360,7 @@ private static bool MinimumAvailabilityCheck(string? minimumAvailability, DateTi // Case 3: No dates + "released" → Grab if (inCinemas == null && digitalRelease == null && physicalRelease == null && minimumAvailability == "released") { - logger?.LogInformation("Available for search (availability rule 3): {Title} - Minimum Availability: {MinAvail}, Dates Cinema:{Cinemas}, Digital:{Digital}, Physical:{Physical}", + logger?.LogDebug("Available for search (availability rule 3): {Title} - Minimum Availability: {MinAvail}, Dates Cinema:{Cinemas}, Digital:{Digital}, Physical:{Physical}", title, minimumAvailability, inCinemas, digitalRelease, physicalRelease); return true; } @@ -1370,13 +1370,13 @@ private static bool MinimumAvailabilityCheck(string? minimumAvailability, DateTi { if (digitalRelease <= now || physicalRelease <= now) { - logger?.LogInformation("Available for search (availability rule 4): {Title} - Minimum Availability: {MinAvail}, Dates Cinema:{Cinemas}, Digital:{Digital}, Physical:{Physical}", + logger?.LogDebug("Available for search (availability rule 4): {Title} - Minimum Availability: {MinAvail}, Dates Cinema:{Cinemas}, Digital:{Digital}, Physical:{Physical}", title, minimumAvailability, inCinemas, digitalRelease, physicalRelease); return true; } else { - logger?.LogInformation("Unavailable for search (availability rule 5): {Title} - Minimum Availability: {MinAvail}, Dates Cinema:{Cinemas}, Digital:{Digital}, Physical:{Physical}", + logger?.LogDebug("Unavailable for search (availability rule 5): {Title} - Minimum Availability: {MinAvail}, Dates Cinema:{Cinemas}, Digital:{Digital}, Physical:{Physical}", title, minimumAvailability, inCinemas, digitalRelease, physicalRelease); return false; } @@ -1389,13 +1389,13 @@ private static bool MinimumAvailabilityCheck(string? minimumAvailability, DateTi { if (digitalRelease <= now) { - logger?.LogInformation("Available for search (availability rule 6): {Title} - Minimum Availability: {MinAvail}, Dates Cinema:{Cinemas}, Digital:{Digital}, Physical:{Physical}", + logger?.LogDebug("Available for search (availability rule 6): {Title} - Minimum Availability: {MinAvail}, Dates Cinema:{Cinemas}, Digital:{Digital}, Physical:{Physical}", title, minimumAvailability, inCinemas, digitalRelease, physicalRelease); return true; } else { - logger?.LogInformation("Unavailable for search (availability rule 7): {Title} - Minimum Availability: {MinAvail}, Dates Cinema:{Cinemas}, Digital:{Digital}, Physical:{Physical}", + logger?.LogDebug("Unavailable for search (availability rule 7): {Title} - Minimum Availability: {MinAvail}, Dates Cinema:{Cinemas}, Digital:{Digital}, Physical:{Physical}", title, minimumAvailability, inCinemas, digitalRelease, physicalRelease); return false; } @@ -1404,13 +1404,13 @@ private static bool MinimumAvailabilityCheck(string? minimumAvailability, DateTi { if (physicalRelease <= now) { - logger?.LogInformation("Available for search (availability rule 8): {Title} - Minimum Availability: {MinAvail}, Dates Cinema:{Cinemas}, Digital:{Digital}, Physical:{Physical}", + logger?.LogDebug("Available for search (availability rule 8): {Title} - Minimum Availability: {MinAvail}, Dates Cinema:{Cinemas}, Digital:{Digital}, Physical:{Physical}", title, minimumAvailability, inCinemas, digitalRelease, physicalRelease); return true; } else { - logger?.LogInformation("Unavailable for search (availability rule 9): {Title} - Minimum Availability: {MinAvail}, Dates Cinema:{Cinemas}, Digital:{Digital}, Physical:{Physical}", + logger?.LogDebug("Unavailable for search (availability rule 9): {Title} - Minimum Availability: {MinAvail}, Dates Cinema:{Cinemas}, Digital:{Digital}, Physical:{Physical}", title, minimumAvailability, inCinemas, digitalRelease, physicalRelease); return false; } @@ -1420,7 +1420,7 @@ private static bool MinimumAvailabilityCheck(string? minimumAvailability, DateTi // Case 10: No dates + "inCinemas" → Grab if (inCinemas == null && digitalRelease == null && physicalRelease == null && minimumAvailability == "inCinemas") { - logger?.LogInformation("Available for search (availability rule 10): {Title} - Minimum Availability: {MinAvail}, Dates Cinema:{Cinemas}, Digital:{Digital}, Physical:{Physical}", + logger?.LogDebug("Available for search (availability rule 10): {Title} - Minimum Availability: {MinAvail}, Dates Cinema:{Cinemas}, Digital:{Digital}, Physical:{Physical}", title, minimumAvailability, inCinemas, digitalRelease, physicalRelease); return true; } @@ -1430,13 +1430,13 @@ private static bool MinimumAvailabilityCheck(string? minimumAvailability, DateTi { if (inCinemas <= now) { - logger?.LogInformation("Available for search (availability rule 11): {Title} - Minimum Availability: {MinAvail}, Dates Cinema:{Cinemas}, Digital:{Digital}, Physical:{Physical}", + logger?.LogDebug("Available for search (availability rule 11): {Title} - Minimum Availability: {MinAvail}, Dates Cinema:{Cinemas}, Digital:{Digital}, Physical:{Physical}", title, minimumAvailability, inCinemas, digitalRelease, physicalRelease); return true; } else { - logger?.LogInformation("Unavailable for search (availability rule 12): {Title} - Minimum Availability: {MinAvail}, Dates Cinema:{Cinemas}, Digital:{Digital}, Physical:{Physical}", + logger?.LogDebug("Unavailable for search (availability rule 12): {Title} - Minimum Availability: {MinAvail}, Dates Cinema:{Cinemas}, Digital:{Digital}, Physical:{Physical}", title, minimumAvailability, inCinemas, digitalRelease, physicalRelease); return false; } @@ -1449,13 +1449,13 @@ private static bool MinimumAvailabilityCheck(string? minimumAvailability, DateTi { if (digitalRelease <= now) { - logger?.LogInformation("Available for search (availability rule 13): {Title} - Minimum Availability: {MinAvail}, Dates Cinema:{Cinemas}, Digital:{Digital}, Physical:{Physical}", + logger?.LogDebug("Available for search (availability rule 13): {Title} - Minimum Availability: {MinAvail}, Dates Cinema:{Cinemas}, Digital:{Digital}, Physical:{Physical}", title, minimumAvailability, inCinemas, digitalRelease, physicalRelease); return true; } else { - logger?.LogInformation("Unavailable for search (availability rule 14): {Title} - Minimum Availability: {MinAvail}, Dates Cinema:{Cinemas}, Digital:{Digital}, Physical:{Physical}", + logger?.LogDebug("Unavailable for search (availability rule 14): {Title} - Minimum Availability: {MinAvail}, Dates Cinema:{Cinemas}, Digital:{Digital}, Physical:{Physical}", title, minimumAvailability, inCinemas, digitalRelease, physicalRelease); return false; } @@ -1464,13 +1464,13 @@ private static bool MinimumAvailabilityCheck(string? minimumAvailability, DateTi { if (physicalRelease <= now) { - logger?.LogInformation("Available for search (availability rule 15): {Title} - Minimum Availability: {MinAvail}, Dates Cinema:{Cinemas}, Digital:{Digital}, Physical:{Physical}", + logger?.LogDebug("Available for search (availability rule 15): {Title} - Minimum Availability: {MinAvail}, Dates Cinema:{Cinemas}, Digital:{Digital}, Physical:{Physical}", title, minimumAvailability, inCinemas, digitalRelease, physicalRelease); return true; } else { - logger?.LogInformation("Unavailable for search (availability rule 16): {Title} - Minimum Availability: {MinAvail}, Dates Cinema:{Cinemas}, Digital:{Digital}, Physical:{Physical}", + logger?.LogDebug("Unavailable for search (availability rule 16): {Title} - Minimum Availability: {MinAvail}, Dates Cinema:{Cinemas}, Digital:{Digital}, Physical:{Physical}", title, minimumAvailability, inCinemas, digitalRelease, physicalRelease); return false; } @@ -1478,7 +1478,7 @@ private static bool MinimumAvailabilityCheck(string? minimumAvailability, DateTi else { // Case 17: No inCinemas + no dates + "inCinemas" → Skip - logger?.LogInformation("Unavailable for search (availability rule 17): {Title} - Minimum Availability: {MinAvail}, Dates Cinema:{Cinemas}, Digital:{Digital}, Physical:{Physical}", + logger?.LogDebug("Unavailable for search (availability rule 17): {Title} - Minimum Availability: {MinAvail}, Dates Cinema:{Cinemas}, Digital:{Digital}, Physical:{Physical}", title, minimumAvailability, inCinemas, digitalRelease, physicalRelease); return false; } @@ -1487,13 +1487,13 @@ private static bool MinimumAvailabilityCheck(string? minimumAvailability, DateTi // Case 18: "announced" → Grab if (minimumAvailability == "announced") { - logger?.LogInformation("Available for search (availability rule 18): {Title} - Minimum Availability: {MinAvail}, Dates Cinema:{Cinemas}, Digital:{Digital}, Physical:{Physical}", + logger?.LogDebug("Available for search (availability rule 18): {Title} - Minimum Availability: {MinAvail}, Dates Cinema:{Cinemas}, Digital:{Digital}, Physical:{Physical}", title, minimumAvailability, inCinemas, digitalRelease, physicalRelease); return true; } // Case 19: Default → Skip - logger?.LogInformation("Unavailable for search (availability rule 19): {Title} - Minimum Availability: {MinAvail}, Dates Cinema:{Cinemas}, Digital:{Digital}, Physical:{Physical}", + logger?.LogDebug("Unavailable for search (availability rule 19): {Title} - Minimum Availability: {MinAvail}, Dates Cinema:{Cinemas}, Digital:{Digital}, Physical:{Physical}", title, minimumAvailability, inCinemas, digitalRelease, physicalRelease); return false; } @@ -1504,17 +1504,17 @@ private bool CheckEpisodeAvailability(DateTime? airDateUtc, string episodeTitle, if (airDateUtc == null) { - logger?.LogInformation("Episode {Title} - No air date, waiting before automatic search", episodeTitle); + logger?.LogDebug("Episode {Title} - No air date, waiting before automatic search", episodeTitle); return false; } if (airDateUtc > now) { - logger?.LogInformation("Episode {Title} - Not aired yet (AirDate: {AirDate}), skipping", episodeTitle, airDateUtc); + logger?.LogDebug("Episode {Title} - Not aired yet (AirDate: {AirDate}), skipping", episodeTitle, airDateUtc); return false; } - logger?.LogInformation("Episode {Title} - Available (aired on {AirDate})", episodeTitle, airDateUtc); + logger?.LogDebug("Episode {Title} - Available (aired on {AirDate})", episodeTitle, airDateUtc); return true; } @@ -1524,17 +1524,17 @@ private bool CheckAlbumAvailability(DateTime? releaseDate, string albumTitle, IL if (releaseDate == null) { - logger?.LogInformation("Album {Title} - No release date, available for search", albumTitle); + logger?.LogDebug("Album {Title} - No release date, available for search", albumTitle); return true; } if (releaseDate > now) { - logger?.LogInformation("Album {Title} - Not released yet (ReleaseDate: {ReleaseDate}), skipping", albumTitle, releaseDate); + logger?.LogDebug("Album {Title} - Not released yet (ReleaseDate: {ReleaseDate}), skipping", albumTitle, releaseDate); return false; } - logger?.LogInformation("Album {Title} - Available (released on {ReleaseDate})", albumTitle, releaseDate); + logger?.LogDebug("Album {Title} - Available (released on {ReleaseDate})", albumTitle, releaseDate); return true; } diff --git a/src/Torrentarr.Infrastructure/Services/ArrWorkerManager.cs b/src/Torrentarr.Infrastructure/Services/ArrWorkerManager.cs index 33707b3c..a7997daa 100644 --- a/src/Torrentarr.Infrastructure/Services/ArrWorkerManager.cs +++ b/src/Torrentarr.Infrastructure/Services/ArrWorkerManager.cs @@ -455,6 +455,8 @@ await SonarrRetryQueue.ExecuteAsync(retryScope.ServiceProvider.GetRequiredServic { _stateManager.Update(searchStateName, s => { + s.SearchesTriggered = result.SearchesTriggered; + s.ItemsSearched = result.ItemsSearched; s.SearchSummary = $"{result.SearchesTriggered} searches triggered ({result.ItemsSearched} items)"; s.SearchTimestamp = DateTime.UtcNow.ToString("o"); s.MetricType = "search"; diff --git a/src/Torrentarr.Infrastructure/Services/ConfigMutationMiddleware.cs b/src/Torrentarr.Infrastructure/Services/ConfigMutationMiddleware.cs index fab7b390..6a10af6c 100644 --- a/src/Torrentarr.Infrastructure/Services/ConfigMutationMiddleware.cs +++ b/src/Torrentarr.Infrastructure/Services/ConfigMutationMiddleware.cs @@ -16,7 +16,7 @@ public static void UseConfigMutationGuard(this WebApplication app, string path, try { var endpoint = context.Request.Path.Value?.TrimEnd('/').ToLowerInvariant(); - if (endpoint == "/web/config/events") { await next(context); return; } + if (endpoint is "/web/config/events" or "/web/stats/events") { await next(context); return; } // Log streams do not use instance state and can stay open indefinitely. if (endpoint != null && (endpoint.StartsWith("/web/logs/") || endpoint.StartsWith("/api/logs/")) && endpoint.EndsWith("/stream", StringComparison.Ordinal)) diff --git a/src/Torrentarr.Infrastructure/Services/FreeSpaceObservations.cs b/src/Torrentarr.Infrastructure/Services/FreeSpaceObservations.cs new file mode 100644 index 00000000..23d03bcf --- /dev/null +++ b/src/Torrentarr.Infrastructure/Services/FreeSpaceObservations.cs @@ -0,0 +1,13 @@ +using System.Collections.Concurrent; + +namespace Torrentarr.Infrastructure.Services; + +/// Last completed allocations, shared between the scoped allocator and WebUI. +public sealed class FreeSpaceObservations +{ + public sealed record Allocation(long CommittedBytes, long RemainingBytes, DateTimeOffset CompletedAt, string? BlockedReason); + private readonly ConcurrentDictionary _allocations = new(StringComparer.OrdinalIgnoreCase); + private static string Key(string client, string path) => $"{client}\n{path}"; + public Allocation? Get(string client, string path) => _allocations.GetValueOrDefault(Key(client, path)); + public void Set(string client, string path, Allocation allocation) => _allocations[Key(client, path)] = allocation; +} diff --git a/src/Torrentarr.Infrastructure/Services/FreeSpaceService.cs b/src/Torrentarr.Infrastructure/Services/FreeSpaceService.cs index b56b979e..c8b81127 100644 --- a/src/Torrentarr.Infrastructure/Services/FreeSpaceService.cs +++ b/src/Torrentarr.Infrastructure/Services/FreeSpaceService.cs @@ -10,7 +10,7 @@ namespace Torrentarr.Infrastructure.Services; /// One coordinated allocation pass across every client's shared storage. public class FreeSpaceService( ILogger logger, TorrentarrConfig config, - ITorrentClientRegistry registry, TorrentarrDbContext db) : IFreeSpaceService + ITorrentClientRegistry registry, TorrentarrDbContext db, FreeSpaceObservations? observations = null) : IFreeSpaceService { private const string PausedTag = "qBitrr-free_space_paused"; private readonly PathMappingService _paths = new(); @@ -115,11 +115,16 @@ public Task> GetFreeSpaceStatsAsync(CancellationTo { var reserve = m.Space == null ? FreeSpaceThreshold.Parse(m.Config.Limit) : monitors.Where(other => other.Space?.Identity == m.Space.Identity).Max(other => FreeSpaceThreshold.Parse(other.Config.Limit)); + var allocation = observations?.Get(m.Client, m.Config.Path); return new FreeSpaceStats { ClientInstanceId = m.Client, Path = m.Config.Path, Error = m.Error, + CommittedBytes = allocation?.CommittedBytes, + RemainingAllocationBytes = allocation?.RemainingBytes, + LastCompletedPass = allocation?.CompletedAt, + BlockedReason = allocation?.BlockedReason, TotalBytes = m.Space?.TotalBytes ?? 0, FreeBytes = m.Space?.FreeBytes ?? 0, ThresholdBytes = reserve, @@ -202,12 +207,19 @@ private async Task ProcessCoreAsync(IReadOnlySet ma // Saturate subtraction so very large torrent inventories cannot overflow into free space. long remaining = available >= reserve ? available - reserve : -1; void Reserve(long bytes) => remaining = bytes > remaining ? -1 : remaining - Math.Max(0, bytes); - bool Managed(FreeSpaceTorrent item) => managedCategories.Contains(item.Torrent.Category) - && rows.Any(m => m.Client.Equals(item.InstanceName, StringComparison.OrdinalIgnoreCase)); + bool Managed(FreeSpaceTorrent item) + { + var owner = CategoryOwnershipHelper.ResolveOwningCategory(config, item.Torrent.Category, item.InstanceName); + return rows.Any(m => m.Client.Equals(item.InstanceName, StringComparison.OrdinalIgnoreCase)) + && (owner != null && managedCategories.Contains(owner) + || string.IsNullOrEmpty(item.Torrent.Category) && managedCategories.Contains("")); + } foreach (var item in items.Where(i => !Managed(i) && TorrentPolicyHelper.IsFreeSpaceDownloadState(i.Torrent.State) && !TorrentPolicyHelper.IsPausedDownloadStateForFreeSpace(i.Torrent.State))) Reserve(Math.Max(0, item.Torrent.AmountLeft)); var safe = rows.All(m => m.Space != null) && allocationComplete; + var capacityBlocked = false; + var actionFailed = false; var resumes = new List<(FreeSpaceTorrent Item, Func Run)>(); var ordered = items.Where(Managed) .OrderBy(i => i.Torrent.AddedOn); @@ -238,6 +250,7 @@ bool Managed(FreeSpaceTorrent item) => managedCategories.Contains(item.Torrent.C if (!stopped && marked) await SetMarker(item, false, cancellationToken); var needed = Math.Max(0, torrent.AmountLeft); var fits = rows[0].Space != null && remaining >= needed; + if (!fits) capacityBlocked = true; if (!fits && !stopped) { operation = "mark and pause torrent"; @@ -282,10 +295,20 @@ await FreeSpaceInventory.ResumeAndClearMarkerAsync(item.Client, torrent.Hash, catch (Exception ex) when (ex is not OperationCanceledException) { resumesWithheld = true; + actionFailed = true; logger.LogWarning(ex, "FreeSpace: [{Client}] Resume failed for torrent {Hash}; remaining shared-storage resumes withheld", resume.Item.InstanceName, resume.Item.Torrent.Hash); break; } } + var reason = rows.Any(m => m.Space == null) ? "Storage is unavailable." + : !inventory.Complete ? "A torrent client's inventory is unavailable; resumes withheld." + : !allocationComplete ? "An active download's storage path could not be matched; resumes withheld." + : !safe || actionFailed ? "A client action or marker update failed; resumes withheld." + : capacityBlocked || remaining < 0 ? "Remaining downloads exceed the space available above the reserve." + : null; + foreach (var row in rows) + observations?.Set(row.Client, row.Config.Path, new(Math.Max(0, available - reserve - Math.Max(0, remaining)), + Math.Max(0, remaining), DateTimeOffset.UtcNow, reason)); } if (due != null) schedule!.Complete(due, intervals); var markedCount = 0; diff --git a/src/Torrentarr.Infrastructure/Services/MaintenanceCoordinator.cs b/src/Torrentarr.Infrastructure/Services/MaintenanceCoordinator.cs index b42131dd..6f6e2615 100644 --- a/src/Torrentarr.Infrastructure/Services/MaintenanceCoordinator.cs +++ b/src/Torrentarr.Infrastructure/Services/MaintenanceCoordinator.cs @@ -292,13 +292,6 @@ public async Task PreviewAsync(MaintenancePreviewRequest reques else if (!await seeding.CanDeleteTorrentAsync(candidate.Torrent, "maintenance preview", ct)) action.BlockedReason ??= (await seeding.EvaluateRemovalAsync(candidate.Torrent, TorrentRemovalIntent.SeedingLimit, ct))?.Reason ?? "Protection service rejected removal without diagnostic evidence."; } - if (action.Kind is MaintenanceActionKind.SetCategory or MaintenanceActionKind.SetAutomaticManagement) - { - var candidate = inspectable.FirstOrDefault(i => i.Torrent.Hash == action.TorrentHash); - if (candidate == null || !seeding.HnrAllowsDelete(candidate.Torrent, candidate.Trackers)) - action.BlockedReason ??= candidate == null ? "Torrent metadata is missing; refresh the preview." : ProtectionEvidence(seeding, candidate); - } - if (MaintenanceRequirements.MovesContent(action.Kind)) { var item = inventory.Torrents.FirstOrDefault(i => i.Torrent.ClientInstanceId.Equals(id, StringComparison.OrdinalIgnoreCase) && i.Torrent.Hash == action.TorrentHash); @@ -865,14 +858,11 @@ private async Task ApplyActionAsync(MaintenanceAction action if (action.Kind is MaintenanceActionKind.RecycleContent or MaintenanceActionKind.DeleteContent && !await seeding.CanDeleteTorrentAsync(item.Torrent, "maintenance apply", ct)) return (await seeding.EvaluateRemovalAsync(item.Torrent, TorrentRemovalIntent.SeedingLimit, ct))?.Reason ?? "Protection service rejected removal without diagnostic evidence."; - if (action.Kind is MaintenanceActionKind.RecycleContent or MaintenanceActionKind.DeleteContent or MaintenanceActionKind.SetAutomaticManagement or MaintenanceActionKind.SetCategory) + if (action.Kind is MaintenanceActionKind.RecycleContent or MaintenanceActionKind.DeleteContent) { if (!item.TrackersComplete || !seeding.HnrAllowsDelete(item.Torrent, item.Trackers)) return ProtectionEvidence(seeding, item); } - if (action.Kind == MaintenanceActionKind.Recheck && item.Torrent.IsSeeding && !MaintenanceRecovery.IsError(item.Torrent, cfg.Maintenance) - && (!item.TrackersComplete || !seeding.HnrAllowsDelete(item.Torrent, item.Trackers))) - return "Protected seeding must not be interrupted for a tracker-only issue."; var required = MaintenanceRequirements.For(action.Operation, cfg.Maintenance); if (required.HasFlag(InventoryRequirements.Trackers) && !item.TrackersComplete) return "Tracker inspection is incomplete."; if (required.HasFlag(InventoryRequirements.Files) && !item.FilesComplete) return "Torrent file enumeration is incomplete."; diff --git a/src/Torrentarr.Infrastructure/Services/MaintenancePersistence.cs b/src/Torrentarr.Infrastructure/Services/MaintenancePersistence.cs index d0150d15..d0c72cb0 100644 --- a/src/Torrentarr.Infrastructure/Services/MaintenancePersistence.cs +++ b/src/Torrentarr.Infrastructure/Services/MaintenancePersistence.cs @@ -24,6 +24,17 @@ public static void Write(TorrentarrDbContext db, string key, T state) } db.Database.ExecuteSqlInterpolated($"INSERT INTO torrentarr_maintenance_state (Key, Data) VALUES ({key}, {json}) ON CONFLICT(Key) DO UPDATE SET Data=excluded.Data"); } + public static async Task DeleteAsync(TorrentarrDbContext db, string key, CancellationToken ct) + { + if (!db.Database.IsRelational()) + { + var record = await db.MaintenanceState.FindAsync([key], ct); + if (record != null) { db.MaintenanceState.Remove(record); await db.SaveChangesAsync(ct); } + return; + } + // Raw delete stays idempotent when another worker clears the same row concurrently. + await db.Database.ExecuteSqlInterpolatedAsync($"DELETE FROM torrentarr_maintenance_state WHERE Key = {key}", ct); + } } internal sealed class ScanCursor { public string Position { get; set; } = ""; } diff --git a/src/Torrentarr.Infrastructure/Services/OperationalIssueMonitor.cs b/src/Torrentarr.Infrastructure/Services/OperationalIssueMonitor.cs index 162ddb39..876d5459 100644 --- a/src/Torrentarr.Infrastructure/Services/OperationalIssueMonitor.cs +++ b/src/Torrentarr.Infrastructure/Services/OperationalIssueMonitor.cs @@ -16,6 +16,8 @@ public sealed class OperationalIssueMonitor(IServiceScopeFactory scopes, IOperat // Completion cursor per scope; equal timestamps retain IDs to avoid losing simultaneous runs. private readonly Dictionary RunIds)> _maintenanceRuns = new(); + private List _configurationIssues = []; + protected override async Task ExecuteAsync(CancellationToken ct) { while (!ct.IsCancellationRequested) @@ -35,8 +37,9 @@ internal async Task InspectAsync(CancellationToken ct) if (services.GetService() is { } reloader) { var state = reloader.GetStatus(); - if (state.State is "error" or "applied") await Observe(new("config.apply", "system", "Configuration could not be applied", $"Accepted revision={state.AcceptedRevision}; state={state.State}; error={DiagnosticText.Safe(state.Error)}; components: {string.Join("; ", state.Components.Select(c => $"{c.Key}: applied={c.Value.AppliedRevision}, error={DiagnosticText.Safe(c.Value.Error)}"))}.", "Check configuration validation and component status.", Target: "config"), state.State == "error", ct); + if (state.State is "error" or "applied") await Observe(new("config.apply", "system", "Configuration could not be applied", $"File={DiagnosticText.Safe(reloader.ConfigPath)}; accepted revision={state.AcceptedRevision}; state={state.State}; error={DiagnosticText.Safe(state.Error)}; components: {string.Join("; ", state.Components.Select(c => $"{c.Key}: applied={c.Value.AppliedRevision}, error={DiagnosticText.Safe(c.Value.Error)}"))}.", "Open Config and correct the indicated settings in the saved configuration file. Save and verify that all components acknowledge the accepted revision.", Target: "config"), state.State == "error", ct); } + await InspectConfigurationAsync(services, ct); if (services.GetService() is { LastChecked: not null } network) await Observe(new("network.internet", "system", "Internet connectivity is unavailable", $"Last connectivity check={network.LastChecked:O}; connected={network.IsConnected}. {network.FailureReason ?? "Client reachability and configured HTTP/TCP probes did not establish connectivity; no further probe detail was supplied."}", "Check the host network, DNS and configured connectivity hosts."), !network.IsConnected, ct); if (services.GetService() is { } clients) @@ -78,7 +81,7 @@ internal async Task InspectAsync(CancellationToken ct) foreach (var recovery in state.Recovery.Where(r => r.Stage is not ("new" or "checked"))) current.Add(new("automation.recovery", "automation", "Torrent recovery is pending", $"Torrent={recovery.TorrentName}; hash={recovery.TorrentHash}; operation={recovery.Operation}; stage={recovery.Stage}; attempts={recovery.Attempts}; failure since={recovery.FailureSince:O}; {recovery.Reason}", "Inspect the recovery status and storage access; do not remove protection evidence.", ClientInstance: recovery.ClientInstanceId, SubjectType: "torrent", SubjectId: recovery.TorrentHash, Severity: "warning", Target: "maintenance")); foreach (var group in state.Protection.Where(p => p.NativeStopLimitRisk || p.DeadlineRisk is "warning" or "critical" or "unachievable").GroupBy(p => new { p.ClientInstanceId, p.TorrentHash })) - current.Add(new("automation.protection", "automation", "Tracker obligation may be at risk", $"Hash={group.Key.TorrentHash}; {string.Join("; ", group.Select(p => $"{p.TorrentName}: tracker={p.Tracker}, state={p.State}, observed={p.ObservedSeedSeconds:F0}s, remaining={p.RemainingSeedSeconds:F0}s, deadline={p.Deadline:O}, recovery deadline={p.LatestRecoveryStart:O}, risk={p.DeadlineRisk}, native stop risk={p.NativeStopLimitRisk}. {p.Reason}"))}", "Review tracker protection and seeding limits in Manage; preserve the torrent while obligations remain.", ClientInstance: group.Key.ClientInstanceId, SubjectType: "torrent", SubjectId: group.Key.TorrentHash, + current.Add(new("automation.protection", "automation", "Tracker obligation may be at risk", $"Hash={group.Key.TorrentHash}; {string.Join("; ", group.Select(p => $"{p.TorrentName}: tracker={p.Tracker}, state={p.State}, {p.SeedAccountingDescription()}, deadline={p.Deadline:O}, recovery deadline={p.LatestRecoveryStart:O}, risk={p.DeadlineRisk}, native stop risk={p.NativeStopLimitRisk}. {p.Reason}"))}", "Review tracker protection and seeding limits in Manage; preserve the torrent while obligations remain.", ClientInstance: group.Key.ClientInstanceId, SubjectType: "torrent", SubjectId: group.Key.TorrentHash, Severity: group.Any(p => p.DeadlineRisk is "critical" or "unachievable") ? "error" : "warning", Target: "maintenance")); foreach (var inspection in state.Inspections) if (inspection.ConsecutiveFailures > 0 || inspection.LastSucceededAt.HasValue) @@ -142,6 +145,56 @@ internal async Task InspectAsync(CancellationToken ct) } } + private async Task InspectConfigurationAsync(IServiceProvider services, CancellationToken ct) + { + var current = new List(); + var validation = ConfigValidationHelper.ValidateAll(config); + if (!validation.Ok) + current.Add(new("config.validation", "system", "Configuration needs correction", + DiagnosticText.Safe(validation.Error), "Open Config and correct the indicated setting, then save and verify configuration activation.", Target: "config")); + + var clients = config.GetAllTorrentClients(); + var recycling = clients.Values.Any(c => !c.Disabled && c.Maintenance?.RecycleBin?.Enabled == true); + var paths = services.GetService(); + foreach (var (id, client) in clients) + { + if (!client.Disabled && (string.IsNullOrWhiteSpace(client.Type) || string.IsNullOrWhiteSpace(client.Host) + || client.Host == "CHANGE_ME" || client.Port is < 1 or > 65535)) + current.Add(new("config.client", "system", "Torrent client configuration is incomplete", + "An enabled torrent client requires a Type, Host and port between 1 and 65535.", + "Open Config and complete this client's connection settings, or disable/remove an unused client.", ClientInstance: id, Target: "config")); + if (client.Migration?.Notices is { Count: > 0 } notices) + current.Add(new("config.migration", "system", "Migrated configuration needs review", + DiagnosticText.Safe(string.Join("; ", notices)), + "Open Config and review this client's migrated DownloadPath, FreeSpace and Maintenance settings. Verify ownership and local mounts before enabling protection. After reviewing and correcting the settings, clear the corresponding Migration.Notices entries in config.toml.", + ClientInstance: id, Severity: "warning", Target: "config")); + if (client.Maintenance?.PathMappings is not { } mappings) continue; + var errors = new List(); + if (recycling && mappings.Count == 0 && !string.IsNullOrWhiteSpace(client.Host) && client.Host != "CHANGE_ME") + errors.Add("Maintenance.PathMappings is missing. Recycling requires a complete file ownership inventory across all configured clients, even when client and local paths are identical."); + if (paths != null) errors.AddRange(paths.Validate(mappings)); + if (errors.Count > 0) + current.Add(new("config.storage", "system", "Client storage configuration needs correction", + DiagnosticText.Safe(string.Join("; ", errors)), + "Open Config and configure this client's Maintenance.PathMappings: ClientPath is visible to the torrent client; LocalPath is the same folder inside Torrentarr. Verify mounts and permissions, then save.", + ClientInstance: id, Severity: "warning", Target: "config")); + } + + var previous = _configurationIssues.AsEnumerable(); + if (services.GetService() is { } db) + { + var persisted = await db.OperationalIssues.AsNoTracking().Where(i => i.ResolvedAt == null + && (i.Code == "config.validation" || i.Code == "config.migration" || i.Code == "config.storage" || i.Code == "config.client")).ToListAsync(ct); + previous = previous.Concat(persisted.Select(i => new OperationalIssueReport(i.Code, i.Category, i.Title, + i.Impact, i.Remediation, i.ArrInstance, i.ClientInstance, i.SubjectType, i.SubjectId, i.Severity, i.Target))); + } + foreach (var old in previous.DistinctBy(i => (i.Code, i.ClientInstance))) + if (!current.Any(i => i.Code == old.Code && i.ClientInstance == old.ClientInstance)) + await issues.ResolveAsync(old, ct); + foreach (var issue in current) await issues.ReportAsync(issue, ct); + _configurationIssues = current; + } + private Task Observe(OperationalIssueReport issue, bool failed, CancellationToken ct) => failed ? issues.ReportAsync(issue, ct) : issues.ResolveAsync(issue, ct); diff --git a/src/Torrentarr.Infrastructure/Services/ProcessStateManager.cs b/src/Torrentarr.Infrastructure/Services/ProcessStateManager.cs index e460b234..2c5e32d9 100644 --- a/src/Torrentarr.Infrastructure/Services/ProcessStateManager.cs +++ b/src/Torrentarr.Infrastructure/Services/ProcessStateManager.cs @@ -13,6 +13,16 @@ public class ArrProcessState public bool Alive { get; set; } public bool Rebuilding { get; set; } public int? Pid { get; set; } + public int? SearchesTriggered { get; set; } + public int? ItemsSearched { get; set; } + public int? DownloadingCount { get; set; } + public int? SeedingCount { get; set; } + public DateTimeOffset? ObservedAt { get; set; } + public string? ObservationError { get; set; } + public int? PausedLastPass { get; set; } + public int? ResumedLastPass { get; set; } + public int? MonitoredCategoryCount { get; set; } + public int? HandledLastPass { get; set; } public string? SearchSummary { get; set; } public string? SearchTimestamp { get; set; } public int? QueueCount { get; set; } diff --git a/src/Torrentarr.Infrastructure/Services/QBitCategoryWorkerManager.cs b/src/Torrentarr.Infrastructure/Services/QBitCategoryWorkerManager.cs index d23afd8f..2938d5fc 100644 --- a/src/Torrentarr.Infrastructure/Services/QBitCategoryWorkerManager.cs +++ b/src/Torrentarr.Infrastructure/Services/QBitCategoryWorkerManager.cs @@ -261,6 +261,11 @@ private async Task RunCategoryLoopAsync(string category, string stateName, Cance break; } catch (ConfigurationBusyException) { } + catch (TorrentProcessingUnavailableException ex) + { + _stateManager.Update(stateName, s => s.Status = "Waiting for torrent client..."); + _logger.LogInformation("Torrent processing deferred for {Category}: {Reason}", category, ex.Message); + } catch (Exception ex) { _logger.LogError(ex, "Error in qBit category worker for {Category}", category); diff --git a/src/Torrentarr.Infrastructure/Services/QbitOverviewBuilder.cs b/src/Torrentarr.Infrastructure/Services/QbitOverviewBuilder.cs index 7e64e967..5f0dab97 100644 --- a/src/Torrentarr.Infrastructure/Services/QbitOverviewBuilder.cs +++ b/src/Torrentarr.Infrastructure/Services/QbitOverviewBuilder.cs @@ -14,7 +14,7 @@ public static async Task BuildAsync( TorrentarrConfig cfg, ITorrentClientRegistry qbitManager, string? instanceFilter, - CancellationToken ct = default) + CancellationToken ct = default, TorrentStatisticsSnapshots? snapshots = null) { var filter = (instanceFilter ?? "").Trim(); var includeAll = string.IsNullOrEmpty(filter) || filter.Equals("all", StringComparison.OrdinalIgnoreCase); @@ -29,6 +29,8 @@ public static async Task BuildAsync( .ToHashSet(StringComparer.OrdinalIgnoreCase); var categories = new List(); + var summaries = new List(); + var observations = new Dictionary(); foreach (var instanceName in instanceNames) { var qbit = cfg.GetTorrentClient(instanceName); @@ -37,27 +39,47 @@ public static async Task BuildAsync( if (qbit.Disabled || qbit.Host == "CHANGE_ME" || string.IsNullOrEmpty(qbit.Host)) continue; - var client = qbitManager.GetClient(instanceName); - if (client == null) continue; - List torrents; - try + var client = qbitManager.GetClient(instanceName); + if (client == null) { - torrents = await client.GetTorrentsAsync(cancellationToken: ct); + if (snapshots != null) + { + var unavailable = snapshots.Unavailable(instanceName, cfg); + observations[instanceName] = new { unavailable.ObservedAt, unavailable.Error, unavailable.ScanIntervalSeconds }; + if (unavailable.ObservedAt == null) continue; + torrents = unavailable.Torrents.ToList(); + } + else continue; } - catch + else { - continue; + try + { + if (snapshots != null) + { + var snapshot = await snapshots.GetAsync(instanceName, cfg, client, ct); + torrents = snapshot.Torrents.ToList(); + observations[instanceName] = new { snapshot.ObservedAt, snapshot.Error, snapshot.ScanIntervalSeconds }; + if (snapshot.ObservedAt == null) continue; + } + else torrents = await client.GetTorrentsAsync(cancellationToken: ct); + } + catch (Exception) when (!ct.IsCancellationRequested) + { + continue; + } } var monitored = new HashSet(qbit.ManagedCategories, StringComparer.OrdinalIgnoreCase); monitored.UnionWith(arrCategories); + var matched = new Dictionary(StringComparer.OrdinalIgnoreCase); + var byOwner = torrents.ToLookup(t => CategoryOwnershipHelper.ResolveOwningCategory(cfg, t.Category, instanceName) ?? "", StringComparer.OrdinalIgnoreCase); foreach (var catName in monitored.OrderBy(c => c, StringComparer.OrdinalIgnoreCase)) { - var inCat = torrents.Where(t => - CategoryPathHelper.MatchesConfigured(t.Category, new[] { catName }, prefix: CategoryOwnershipHelper.PrefixMatchAllowedForOwner(cfg, catName, instanceName)) - == CategoryPathHelper.NormalizeCategory(catName)).ToList(); + var inCat = byOwner[CategoryPathHelper.NormalizeCategory(catName)].ToList(); + foreach (var torrent in inCat) matched.TryAdd(torrent.Hash, torrent); var truncated = inCat.Count > MaxTorrentsPerCategory; var slice = inCat.Take(MaxTorrentsPerCategory).Select(SerializeTorrent).ToList(); var seeding = inCat.Count(t => t.IsSeeding); @@ -80,16 +102,37 @@ public static async Task BuildAsync( torrents = slice }); } + var unique = matched.Values.ToList(); + summaries.Add(new + { + instance = instanceName, + torrentCount = unique.Count, + totalSize = unique.Sum(t => t.Size), + downloading = unique.Count(t => Activity(t) == "downloading"), + seeding = unique.Count(t => Activity(t) == "seeding"), + stopped = unique.Count(t => Activity(t) == "stopped"), + other = unique.Count(t => Activity(t) == "other") + }); } return new { instances = instanceNames, categories, + summaries, + observations, ready = true }; } + private static string Activity(TorrentInfo torrent) + { + var state = torrent.State.ToLowerInvariant(); + if (torrent.IsStopped || state.StartsWith("paused")) return "stopped"; + if (torrent.IsDownloading || state is "stalleddl" or "forceddl" or "queueddl" or "metadl" or "forcedmetadl") return "downloading"; + return torrent.IsSeeding ? "seeding" : "other"; + } + private static object SerializeTorrent(TorrentInfo t) => new { hash = t.Hash, diff --git a/src/Torrentarr.Infrastructure/Services/SafeDeletionService.cs b/src/Torrentarr.Infrastructure/Services/SafeDeletionService.cs index f6e09db0..62ee623c 100644 --- a/src/Torrentarr.Infrastructure/Services/SafeDeletionService.cs +++ b/src/Torrentarr.Infrastructure/Services/SafeDeletionService.cs @@ -80,7 +80,11 @@ private async Task RemoveAsync(string clientInstanceId, stri var inventory = validatedInventory ?? await _inventory.BuildAsync(ct: ct); if (!inventory.IsComplete) - return new(false, true, 0, $"Content deletion blocked because inventory is incomplete: {string.Join("; ", inventory.Warnings)}"); + { + var mappings = inventory.Diagnostics.Where(d => d.Code is "missing-mapping" or "unmatched-prefix" or "invalid-mapping" or "inaccessible-local-root") + .Select(d => $"Client '{d.ClientInstanceId}', client path '{d.Examples.FirstOrDefault()?.ClientPath ?? "unavailable"}': configure this client's Maintenance.PathMappings (ClientPath -> LocalPath) and ensure the local root is mounted and accessible inside Torrentarr. Identical client/local paths also require an explicit mapping."); + return new(false, true, 0, DiagnosticText.Safe($"Recycling blocked because global file ownership inventory is incomplete: {string.Join("; ", inventory.Warnings)} {string.Join(" ", mappings)}")); + } var item = inventory.Torrents.FirstOrDefault(t => t.Torrent.ClientInstanceId.Equals(clientInstanceId, StringComparison.OrdinalIgnoreCase) && t.Torrent.Hash.Equals(hash, StringComparison.OrdinalIgnoreCase)); diff --git a/src/Torrentarr.Infrastructure/Services/SearchExecutor.cs b/src/Torrentarr.Infrastructure/Services/SearchExecutor.cs index a40ae864..5b01b0a8 100644 --- a/src/Torrentarr.Infrastructure/Services/SearchExecutor.cs +++ b/src/Torrentarr.Infrastructure/Services/SearchExecutor.cs @@ -252,6 +252,7 @@ await _db.Series.AsNoTracking().Where(e => e.ArrInstance == instanceName).ToList bool triggered; try { + using var activity = ArrActivityTracking.Begin(_db, _logger, instanceName, arrConfig, "search"); triggered = isSonarr ? await TriggerSonarrSearchAsync(instanceName, arrConfig, plan, cancellationToken) : await TriggerSearchForCandidateAsync(instanceName, arrConfig, candidate, false, cancellationToken); diff --git a/src/Torrentarr.Infrastructure/Services/SeedingService.cs b/src/Torrentarr.Infrastructure/Services/SeedingService.cs index 49d68988..edaece14 100644 --- a/src/Torrentarr.Infrastructure/Services/SeedingService.cs +++ b/src/Torrentarr.Infrastructure/Services/SeedingService.cs @@ -372,13 +372,14 @@ public List GetProtectionDecisions(TorrentInfo torrent, IRea var effective = GetEffectiveSeedingConfig(torrent, cfg); if (cfg.Protection == null && !IsHnREnabled(effective.HitAndRunMode)) { - if (torrent.IsPrivate != false) decisions.Add(new() + decisions.Add(new() { ClientInstanceId = torrent.ClientInstanceId, TorrentHash = torrent.Hash, TorrentName = torrent.Name, Tracker = TrackerLogHost(cfg.Uri), - Reason = "Private tracker has no configured clearance requirements." + State = "eligible", + Reason = "Configured tracker has no active clearance requirements." }); continue; } @@ -410,20 +411,7 @@ public List GetProtectionDecisions(TorrentInfo torrent, IRea } else { - var safe = IsHnRSafeToRemove(torrent, new TrackerConfig - { - HitAndRunMode = effective.HitAndRunMode, - MinSeedRatio = effective.MinSeedRatio, - MinSeedingTimeDays = effective.MinSeedingTimeDays, - HitAndRunMinimumDownloadPercent = effective.HitAndRunMinimumDownloadPercent, - HitAndRunPartialSeedRatio = effective.HitAndRunPartialSeedRatio, - TrackerUpdateBuffer = effective.TrackerUpdateBuffer - }); - decision = new() - { - State = safe && healthy ? "eligible" : healthy ? "protected" : "unknown", - Reason = $"Legacy mode={effective.HitAndRunMode}; ratio={torrent.Ratio:F3}/{effective.MinSeedRatio}; seed={torrent.SeedingTime}s/{effective.MinSeedingTimeDays * 86400}s; minimum download={effective.HitAndRunMinimumDownloadPercent}%; partial ratio={effective.HitAndRunPartialSeedRatio}; tracker healthy={healthy}; obligations met={safe}." - }; + decision = LegacyProtectionDecision(torrent, effective, healthy); } decision.ClientInstanceId = torrent.ClientInstanceId; decision.TorrentHash = torrent.Hash; decision.TorrentName = torrent.Name; decision.Tracker = TrackerLogHost(cfg.Uri); decisions.Add(decision); @@ -435,25 +423,16 @@ public List GetProtectionDecisions(TorrentInfo torrent, IRea var knownPublic = torrent.IsPrivate == false; var legacy = IsHnREnabled(category.HitAndRunMode); var categoryConfigured = matched.Count == 0 && legacy; - var categorySafe = categoryConfigured && IsHnRSafeToRemove(torrent, new TrackerConfig - { - HitAndRunMode = category.HitAndRunMode, - MinSeedRatio = category.MinSeedRatio, - MinSeedingTimeDays = category.MinSeedingTimeDays, - HitAndRunMinimumDownloadPercent = category.HitAndRunMinimumDownloadPercent, - HitAndRunPartialSeedRatio = category.HitAndRunPartialSeedRatio, - TrackerUpdateBuffer = category.TrackerUpdateBuffer - }); var healthy = torrentTrackers.Any(t => t.Status == 2 && string.IsNullOrWhiteSpace(t.Msg)); - decisions.Add(new() + var decision = categoryConfigured ? LegacyProtectionDecision(torrent, category, healthy) : new ProtectionDecision { - ClientInstanceId = torrent.ClientInstanceId, - TorrentHash = torrent.Hash, - TorrentName = torrent.Name, - State = categoryConfigured && healthy ? categorySafe ? "eligible" : "protected" : knownPublic && !legacy ? "eligible" : "unknown", - Reason = categoryConfigured ? $"Category mode={category.HitAndRunMode}; ratio={torrent.Ratio:F3}/{category.MinSeedRatio}; seed={torrent.SeedingTime}s/{category.MinSeedingTimeDays * 86400}s; minimum download={category.HitAndRunMinimumDownloadPercent}%; partial ratio={category.HitAndRunPartialSeedRatio}; tracker healthy={healthy}; obligations met={categorySafe}." - : knownPublic && !legacy ? "Public torrent without configured HnR obligations." : "Unmatched private/unknown tracker: preserve and report." - }); + State = knownPublic && !legacy ? "eligible" : "unknown", + Reason = knownPublic && !legacy ? "Public torrent without configured HnR obligations." : "Unmatched private/unknown tracker: preserve and report." + }; + decision.ClientInstanceId = torrent.ClientInstanceId; + decision.TorrentHash = torrent.Hash; + decision.TorrentName = torrent.Name; + decisions.Add(decision); } // Configured category protection applies even when no tracker override supplies it. if (decisions.Count == 0) @@ -477,13 +456,14 @@ public List GetProtectionDecisions(TorrentInfo torrent, IRea return decisions; } - private async Task FreshAllowsMutationAsync(ITorrentClient client, TorrentInfo torrent, string reason, CancellationToken ct) + private async Task FreshAllowsMutationAsync(ITorrentClient client, TorrentInfo torrent, string reason, CancellationToken ct, bool requireHnrClearance = true) { var current = (await client.GetTorrentsAsync(cancellationToken: ct)) .FirstOrDefault(t => t.Hash.Equals(torrent.Hash, StringComparison.OrdinalIgnoreCase)); if (current == null) return false; current.ClientInstanceId = torrent.ClientInstanceId; - return await HnrAllowsDeleteAsync(current, reason, ct); + if (current.AddedOn != torrent.AddedOn || _config.Revision != _config.LatestRevision) return false; + return !requireHnrClearance || await HnrAllowsDeleteAsync(current, reason, ct); } /// @@ -536,6 +516,30 @@ public async Task IsTrackerDeadAsync(TorrentInfo torrent, TrackerConfig co public Task IsHnRSafeToRemoveAsync(TorrentInfo torrent, TrackerConfig config, CancellationToken cancellationToken = default) => Task.FromResult(IsHnRSafeToRemove(torrent, config)); + private static ProtectionDecision LegacyProtectionDecision(TorrentInfo torrent, CategorySeedingConfig config, bool healthy) + { + var safe = IsHnRSafeToRemove(torrent, new TrackerConfig + { + HitAndRunMode = config.HitAndRunMode, + MinSeedRatio = config.MinSeedRatio, + MinSeedingTimeDays = config.MinSeedingTimeDays, + HitAndRunMinimumDownloadPercent = config.HitAndRunMinimumDownloadPercent, + HitAndRunPartialSeedRatio = config.HitAndRunPartialSeedRatio, + TrackerUpdateBuffer = config.TrackerUpdateBuffer + }); + var requiredSeconds = (long)config.MinSeedingTimeDays * 86400; + var timeApplies = IsHnREnabled(config.HitAndRunMode) && torrent.Progress >= 1 + && torrent.Progress >= config.HitAndRunMinimumDownloadPercent / 100.0 && requiredSeconds > 0; + return new() + { + State = safe && healthy ? "eligible" : healthy ? "protected" : "unknown", + LocalEstimate = false, + ObservedSeedSeconds = torrent.SeedingTime, + RemainingSeedSeconds = timeApplies ? Math.Max(0, requiredSeconds + (double)config.TrackerUpdateBuffer - torrent.SeedingTime) : 0, + Reason = $"Legacy mode={config.HitAndRunMode} ({(config.HitAndRunMode.Trim().Equals("and", StringComparison.OrdinalIgnoreCase) ? "all enabled ratio and time requirements" : "either enabled ratio or time requirement")}); ratio={torrent.Ratio:F3}/{config.MinSeedRatio}; client-reported seed={torrent.SeedingTime}s/{requiredSeconds}s; tracker update buffer={config.TrackerUpdateBuffer}s; minimum download={config.HitAndRunMinimumDownloadPercent}%; partial ratio={config.HitAndRunPartialSeedRatio} (partial downloads use ratio only); tracker healthy={healthy}; obligations met={safe}." + }; + } + private static bool IsHnRSafeToRemove(TorrentInfo torrent, TrackerConfig config) { if (!double.IsFinite(torrent.Ratio) || !double.IsFinite(torrent.Progress)) return false; @@ -599,16 +603,16 @@ public async Task HnrAllowsDeleteAsync(TorrentInfo torrent, string reason, if (client == null || torrent.AddedOn <= 0 || torrent.AddedOn > DateTimeOffset.UtcNow.ToUnixTimeSeconds() || DateTimeOffset.UtcNow.ToUnixTimeSeconds() - torrent.AddedOn < _config.Settings.IgnoreTorrentsYoungerThan) { - _logger.LogInformation("HnR check blocked {Reason} of {Hash} on {Client}: client unavailable or torrent age is unverified/too young", reason, torrent.Hash, torrent.ClientInstanceId); + _logger.LogDebug("HnR check blocked {Reason} of {Hash} on {Client}: client unavailable or torrent age is unverified/too young", reason, torrent.Hash, torrent.ClientInstanceId); return false; } if (!torrent.IsPrivate.HasValue) torrent.IsPrivate = (await client.GetTorrentPropertiesAsync(torrent.Hash, cancellationToken))?.IsPrivate; var trackerEvidence = await client.GetTorrentTrackersAsync(torrent.Hash, cancellationToken); var allowed = HnrAllowsDelete(torrent, trackerEvidence); - if (!allowed) _logger.LogInformation("HnR protection blocked {Reason} of {Hash}: {Evidence}", reason, torrent.Hash, + if (!allowed) _logger.LogDebug("HnR protection blocked {Reason} of {Hash}: {Evidence}", reason, torrent.Hash, DiagnosticText.Safe(string.Join("; ", GetProtectionDecisions(torrent, trackerEvidence).Where(d => d.State != "eligible").Select(d => $"{d.Tracker}: {d.State}; {d.Reason}")))); - if (allowed) _logger.LogInformation("HnR obligations cleared for {Reason} of {Hash} on {Client}", reason, torrent.Hash, torrent.ClientInstanceId); + if (allowed) _logger.LogDebug("HnR obligations cleared for {Reason} of {Hash} on {Client}", reason, torrent.Hash, torrent.ClientInstanceId); return allowed; } @@ -625,7 +629,7 @@ public async Task EvaluateRemovalAsync(TorrentInfo torrent, Tor void Block(string code, string evidence, string next) { blockers.Add(new(code, DiagnosticText.Safe(evidence), next)); - _logger.LogInformation("Removal blocked for [{Name}] ({Hash}) on {Client}, category={Category}, intent={Intent}: {Code}; {Evidence}", + _logger.LogDebug("Removal blocked for [{Name}] ({Hash}) on {Client}, category={Category}, intent={Intent}: {Code}; {Evidence}", torrent.Name, torrent.Hash, torrent.ClientInstanceId, torrent.Category, intent, code, DiagnosticText.Safe(evidence)); } var cfg = _config.GetTorrentClient(torrent.ClientInstanceId); @@ -644,7 +648,13 @@ void Block(string code, string evidence, string next) if (!torrent.IsPrivate.HasValue) torrent.IsPrivate = (await client.GetTorrentPropertiesAsync(torrent.Hash, ct))?.IsPrivate; var trackers = await client.GetTorrentTrackersAsync(torrent.Hash, ct); foreach (var decision in GetProtectionDecisions(torrent, trackers).Where(d => d.State != "eligible")) - Block("hnr." + decision.State, $"Tracker={decision.Tracker}; state={decision.State}; {decision.Reason} Observed seed={decision.ObservedSeedSeconds:F0}s; remaining={decision.RemainingSeedSeconds:F0}s; ratio={torrent.Ratio:F3}; progress={torrent.Progress:P1}; native ratioLimit={torrent.RatioLimit}, timeLimit={torrent.SeedingTimeLimit}", "Meet this tracker/category's configured obligations and verify healthy tracker accounting."); + { + var code = "hnr." + decision.State; + if (decision.NativeStopLimitRisk) code += ".native-stop-risk"; + if (decision.DeadlineRisk is "warning" or "critical" or "unachievable") code += ".deadline-" + decision.DeadlineRisk; + Block(code, $"Tracker={decision.Tracker}; state={decision.State}; {decision.Reason} {decision.SeedAccountingDescription()}; ratio={torrent.Ratio:F3}; progress={torrent.Progress:P1}; native ratioLimit={torrent.RatioLimit}, timeLimit={torrent.SeedingTimeLimit}; native stop risk={decision.NativeStopLimitRisk}; deadline risk={decision.DeadlineRisk}", + "Meet this tracker/category's configured obligations and verify healthy tracker accounting."); + } } var tags = torrent.Tags.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); foreach (var tag in tags.Where(t => t == IgnoredTag || t == FreeSpacePausedTag)) @@ -657,7 +667,7 @@ void Block(string code, string evidence, string next) var requiredAge = Math.Max(age, _config.Settings.IgnoreTorrentsYoungerThan); if (age < 0 || now - torrent.AddedOn < requiredAge) Block("torrent.minimum-age", $"Age={now - torrent.AddedOn}s; effective IgnoreTorrentsYoungerThan={requiredAge}s; category/Arr value={age}s", "Wait until the minimum age is met or correct the effective age policy."); - if (intent == TorrentRemovalIntent.RejectedByAllowlist) return new(blockers); + if (intent is TorrentRemovalIntent.RejectedByAllowlist or TorrentRemovalIntent.RejectedByCustomFormat) return new(blockers); if (intent is not (TorrentRemovalIntent.SeedingLimit or TorrentRemovalIntent.StalledDownload)) { Block("removal.unsupported-intent", $"Unknown removal intent '{intent}'", "Use a supported removal intent."); return new(blockers); } if (intent == TorrentRemovalIntent.SeedingLimit) @@ -716,6 +726,14 @@ internal static bool SameRemovalCandidate(TorrentInfo expected, TorrentInfo curr /// When HnR is enabled, returns false until real seeding_time / ratio obligations are met. /// Callers should trust this result and not repeat . /// + public async Task SeedingLimitsMetAsync(TorrentInfo torrent, CancellationToken cancellationToken = default) + { + if (!double.IsFinite(torrent.Ratio) || torrent.Ratio < 0 || !double.IsFinite(torrent.Progress) + || torrent.Progress is < 0 or > 1 || torrent.SeedingTime < 0 || !IsUploadingState(torrent.State)) return false; + _stalledUploads.Touch(torrent.ClientInstanceId, torrent.Hash, IsStalledUploadState(torrent.State)); + return RemovalLimitsMet(torrent, GetEffectiveSeedingConfig(torrent, await GetTrackerConfigAsync(torrent, cancellationToken))); + } + public async Task ShouldRemoveTorrentAsync(TorrentInfo torrent, CancellationToken cancellationToken = default) { if (MaintenanceRecovery.BlocksAutomaticRemoval(_dbContext, torrent, _config.GetTorrentClient(torrent.ClientInstanceId)?.Maintenance.ErrorRecovery ?? new ErrorRecoveryConfig())) @@ -1061,7 +1079,8 @@ public async Task UpdateSeedingTagsAsync(string category, IReadOnlySet? } private bool HasConfiguredProtection(TorrentInfo torrent) => - (_config.GetTorrentClient(torrent.ClientInstanceId)?.Trackers ?? []).Concat(_config.ArrInstances.Values + torrent.IsPrivate != false + || (_config.GetTorrentClient(torrent.ClientInstanceId)?.Trackers ?? []).Concat(_config.ArrInstances.Values .Where(a => CategoryOwnershipHelper.ArrOwnsTorrent(_config, a, torrent)).SelectMany(a => a.Torrent.Trackers)) .Any(t => t.Protection != null || IsHnREnabled(t.HitAndRunMode)) || IsHnREnabled(GetSeedingConfig(torrent).HitAndRunMode); @@ -1088,6 +1107,12 @@ async Task Set(double ratio, long time, long? inactive) => inactive.HasVal MaintenancePersistence.Write(_dbContext, key, state); if (!await Set(-1, -1, torrent.InactiveSeedingTimeLimit.HasValue ? -1 : null)) _logger.LogWarning("Unable to clear conflicting native stop limits for {Hash}; protection remains active", torrent.Hash); + else + { + torrent.RatioLimit = -1; + torrent.SeedingTimeLimit = -1; + if (torrent.InactiveSeedingTimeLimit.HasValue) torrent.InactiveSeedingTimeLimit = -1; + } } else if (protection.All(d => d.State == "eligible") && state.PreviousRatioLimit.HasValue) { @@ -1106,10 +1131,22 @@ public async Task ApplySeedingLimitsAsync(TorrentInfo torrent, CancellationToken var client = GetClient(torrent); if (client == null || !client.Capabilities.PerTorrentLimits) return; - var trackerConfig = await GetTrackerConfigAsync(torrent, cancellationToken); + var trackerConfigs = GetTrackerList(torrent); + IReadOnlyList trackerEvidence = []; + if (trackerConfigs.Count > 0) + { + try + { + trackerEvidence = await client.GetTorrentTrackersAsync(torrent.Hash, cancellationToken); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogWarning(ex, "Error getting tracker evidence for torrent {Hash}", torrent.Hash); + } + } + var trackerConfig = SelectTrackerConfig(trackerConfigs, trackerEvidence); var config = GetEffectiveSeedingConfig(torrent, trackerConfig); - if (HasConfiguredProtection(torrent)) - await ReconcileProtectionLimitsAsync(torrent, await client.GetTorrentTrackersAsync(torrent.Hash, cancellationToken), cancellationToken); + await ReconcileProtectionLimitsAsync(torrent, trackerEvidence, cancellationToken); var dlLimit = config.DownloadRateLimitPerTorrent; @@ -1395,6 +1432,7 @@ private async Task ApplyTrackerActionsAsync( .Select(t => t.Trim()) .ToHashSet(StringComparer.OrdinalIgnoreCase); var tagsToAdd = new HashSet(StringComparer.OrdinalIgnoreCase); + bool? superSeedMode = null; foreach (var cfg in actionTrackers) { @@ -1441,13 +1479,13 @@ private async Task ApplyTrackerActionsAsync( tagsToAdd.Add(tag); } - // §3.5: SuperSeedMode — set or clear super-seed mode when active tracker matches - if (client.Capabilities.SuperSeeding && cfg.SuperSeedMode.HasValue && isPresent) - { - _logger.LogInformation("SuperSeedMode={Mode}: applying to [{Name}]", cfg.SuperSeedMode.Value, torrent.Name); - if (!await client.SetSuperSeedingAsync(torrent.Hash, cfg.SuperSeedMode.Value, ct)) throw new IOException("Torrent client rejected super-seeding update."); - _logger.LogInformation("Client accepted super-seeding update for {Hash} on {Client}", torrent.Hash, torrent.ClientInstanceId); - } + if (cfg.SuperSeedMode.HasValue && isPresent) superSeedMode = cfg.SuperSeedMode.Value; + } + if (client.Capabilities.SuperSeeding && superSeedMode.HasValue && torrent.SuperSeeding != superSeedMode.Value) + { + if (!await client.SetSuperSeedingAsync(torrent.Hash, superSeedMode.Value, ct)) throw new IOException("Torrent client rejected super-seeding update."); + torrent.SuperSeeding = superSeedMode.Value; + _logger.LogInformation("Client accepted super-seeding update for {Hash} on {Client}: enabled={Enabled}", torrent.Hash, torrent.ClientInstanceId, superSeedMode.Value); } // Apply new tags (only add, never remove — qBitrr pattern) @@ -1469,23 +1507,36 @@ internal async Task ObserveTrackerIssueAsync(TorrentInfo torrent, IReadOnlyList< { var issueKey = MaintenancePersistence.Key(torrent.ClientInstanceId, torrent.Hash, "tracker-issue"); var rejected = trackers.Any(t => TrackerMessageIndicatesDead(t.Msg)); - if (rejected) + var healthy = MaintenanceRecovery.HealthyTracker(trackers); + if (rejected && !healthy) MaintenancePersistence.Write(_dbContext, issueKey, new TorrentAutomationIssue(torrent.ClientInstanceId, torrent.Hash, torrent.Name, "Tracker rejected this torrent. A file recheck cannot restore registration; keep and investigate.", torrent.AddedOn)); - else if (MaintenanceRecovery.HealthyTracker(trackers)) - { - var issue = await _dbContext.MaintenanceState.FindAsync([issueKey], ct); - if (issue != null) { _dbContext.MaintenanceState.Remove(issue); await _dbContext.SaveChangesAsync(ct); } - } + else if (healthy) await MaintenancePersistence.DeleteAsync(_dbContext, issueKey, ct); } - private async Task ProcessTrackerMessagesAsync( + private bool PreservesPrivateAnnounceUrl( + TorrentInfo torrent, + TorrentTracker tracker, + IReadOnlyCollection remainingAnnounceUrls) + { + if (torrent.IsPrivate == false) return false; + var host = ExtractTrackerHost(tracker.Url); + if (GetTrackerList(torrent).Any(c => TrackerHostMatches(host, ExtractTrackerHost(c.Uri ?? "")))) + return true; + return remainingAnnounceUrls.Count <= 1; + } + + internal async Task ProcessTrackerMessagesAsync( ITorrentClient client, TorrentInfo torrent, CancellationToken ct) { var trackers = await client.GetTorrentTrackersAsync(torrent.Hash, ct); await ObserveTrackerIssueAsync(torrent, trackers, ct); + var remainingAnnounceUrls = trackers + .Where(t => t.Tier >= 0 && !string.IsNullOrWhiteSpace(t.Url)) + .Select(t => t.Url) + .ToHashSet(StringComparer.OrdinalIgnoreCase); // Determine effective RemoveTrackerWithMessage and RemoveDeadTrackers from Arr config var arrCfg = _config.ArrInstances.Values .FirstOrDefault(a => CategoryOwnershipHelper.ArrOwnsTorrent(_config, a, torrent)); @@ -1525,13 +1576,23 @@ private async Task ProcessTrackerMessagesAsync( _logger.LogWarning("Tracker failure for {Hash}: preserving torrent; tracker rejection never authorizes automatic removal", torrent.Hash); continue; } - if (!await FreshAllowsMutationAsync(client, torrent, "tracker message removal", ct)) continue; + if (PreservesPrivateAnnounceUrl(torrent, tracker, remainingAnnounceUrls)) + { + _logger.LogInformation( + "RemoveTrackerWithMessage: preserving private announce URL {Host} on [{Name}] — message: \"{Msg}\"", + TrackerLogHost(tracker.Url), torrent.Name, DiagnosticText.Safe(msg)); + continue; + } + if (!await FreshAllowsMutationAsync(client, torrent, "tracker message removal", ct, requireHnrClearance: false)) continue; { _logger.LogInformation( "RemoveTrackerWithMessage: removing tracker host {Host} from [{Name}] — message: \"{Msg}\"", TrackerLogHost(tracker.Url), torrent.Name, DiagnosticText.Safe(msg)); if (client.Capabilities.TrackerMutation) + { if (!await client.RemoveTrackersAsync(torrent.Hash, new List { tracker.Url }, ct)) throw new IOException("Torrent client rejected tracker message cleanup."); + remainingAnnounceUrls.Remove(tracker.Url); + } } } } diff --git a/src/Torrentarr.Infrastructure/Services/SonarrSearchScope.cs b/src/Torrentarr.Infrastructure/Services/SonarrSearchScope.cs index 6a51f561..1b3f520b 100644 --- a/src/Torrentarr.Infrastructure/Services/SonarrSearchScope.cs +++ b/src/Torrentarr.Infrastructure/Services/SonarrSearchScope.cs @@ -21,7 +21,7 @@ internal static List Candidates(IEnumerable if ((!config.Unmonitored && episode.Monitored != true) || (episode.SeasonNumber == 0 && !specialsAllowed.Contains(episode.ArrSeriesId))) { - logger?.LogInformation("Search skipped for {Title} S{Season}E{Episode} [id={ArrId}, instance={Instance}]: monitored={Monitored}, specialsAllowed={Specials}", + logger?.LogDebug("Search skipped for {Title} S{Season}E{Episode} [id={ArrId}, instance={Instance}]: monitored={Monitored}, specialsAllowed={Specials}", episode.SeriesTitle, episode.SeasonNumber, episode.EpisodeNumber, episode.ArrId, episode.ArrInstance, episode.Monitored, specialsAllowed.Contains(episode.ArrSeriesId)); continue; diff --git a/src/Torrentarr.Infrastructure/Services/StatisticsEvents.cs b/src/Torrentarr.Infrastructure/Services/StatisticsEvents.cs new file mode 100644 index 00000000..644662c0 --- /dev/null +++ b/src/Torrentarr.Infrastructure/Services/StatisticsEvents.cs @@ -0,0 +1,89 @@ +using System.Collections.Concurrent; +using System.Text.Json; +using System.Threading.Channels; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Torrentarr.Infrastructure.Database; +using Torrentarr.Core.Interfaces; + +namespace Torrentarr.Infrastructure.Services; + +/// A single cross-process SQLite observer fans out coalesced invalidations. +public sealed class StatisticsEvents(IServiceScopeFactory scopes, ILogger logger) : BackgroundService +{ + private readonly ConcurrentDictionary> _subscribers = new(); + private long _revision; + public long Revision => Interlocked.Read(ref _revision); + public (Guid Id, ChannelReader Reader) Subscribe() + { + var channel = Channel.CreateBounded(new BoundedChannelOptions(1) { FullMode = BoundedChannelFullMode.DropOldest }); + var id = Guid.NewGuid(); + _subscribers[id] = channel; + channel.Writer.TryWrite(Revision); + return (id, channel.Reader); + } + public void Unsubscribe(Guid id) + { + if (_subscribers.TryRemove(id, out var channel)) channel.Writer.TryComplete(); + } + public void Publish() + { + var revision = Interlocked.Increment(ref _revision); + foreach (var channel in _subscribers.Values) channel.Writer.TryWrite(revision); + } + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + try + { + using var scope = scopes.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await using var connection = new SqliteConnection(db.Database.GetConnectionString()); + await connection.OpenAsync(stoppingToken); + await using var command = connection.CreateCommand(); + command.CommandText = "PRAGMA data_version"; + var previousVersion = Convert.ToInt64(await command.ExecuteScalarAsync(stoppingToken)); + string? previousRuntime = null; + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1)); + while (await timer.WaitForNextTickAsync(stoppingToken)) + { + var version = Convert.ToInt64(await command.ExecuteScalarAsync(stoppingToken)); + var workers = scope.ServiceProvider.GetService(); + var statuses = workers == null ? Enumerable.Empty() : (await workers.GetProcessStatusAsync()).Values; + var runtime = JsonSerializer.Serialize(new + { + host = scope.ServiceProvider.GetService()?.GetAll().OrderBy(s => s.Name), + workers = statuses.OrderBy(s => s.Name).Select(s => new + { + s.Name, + s.IsAlive, + s.SearchesTriggered, + s.ItemsSearched, + s.SearchTimestamp, + s.CategoryCount, + s.QueueCount, + s.ObservedAt, + s.ObservationError, + s.Torrent, + s.Search + }) + }); + if (version != previousVersion || runtime != previousRuntime) Publish(); + previousVersion = version; + previousRuntime = runtime; + } + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { break; } + catch (Exception ex) + { + logger.LogWarning(ex, "Statistics observation failed; retrying"); + try { await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken); } + catch (OperationCanceledException) { break; } + } + } + } +} diff --git a/src/Torrentarr.Infrastructure/Services/TorrentAutomationService.cs b/src/Torrentarr.Infrastructure/Services/TorrentAutomationService.cs index ada36b5d..707734a5 100644 --- a/src/Torrentarr.Infrastructure/Services/TorrentAutomationService.cs +++ b/src/Torrentarr.Infrastructure/Services/TorrentAutomationService.cs @@ -116,13 +116,10 @@ internal async Task InspectProtectionAsync(CancellationToken ct) var hashes = allHashes; foreach (var entry in _protection.Where(p => p.Value.Any(d => d.ClientInstanceId == id && !hashes.Contains(d.TorrentHash))).ToList()) _protection.TryRemove(entry.Key, out _); var prefix = id.ToUpperInvariant() + "|"; - var oldTrackerIssues = await db.MaintenanceState.Where(s => s.Key.StartsWith(prefix) && s.Key.EndsWith("|tracker-issue")).ToListAsync(inspectionCt); - var removed = oldTrackerIssues.Where(s => !allHashes.Contains(s.Key.Split('|')[1])).ToList(); - if (removed.Count > 0) - { - db.MaintenanceState.RemoveRange(removed); - await db.SaveChangesAsync(inspectionCt); - } + var oldTrackerIssues = await db.MaintenanceState.AsNoTracking().Where(s => s.Key.StartsWith(prefix) && s.Key.EndsWith("|tracker-issue")) + .Select(s => s.Key).ToListAsync(inspectionCt); + foreach (var stale in oldTrackerIssues.Where(k => !allHashes.Contains(k.Split('|')[1]))) + await MaintenancePersistence.DeleteAsync(db, stale, inspectionCt); using var budget = new SemaphoreSlim(2); using var observations = new SemaphoreSlim(1); await Task.WhenAll(torrents.Select(async torrent => @@ -181,16 +178,6 @@ await Task.WhenAll(torrents.Select(async torrent => } _protection[key] = decisions; } - if ((cfg.Trackers.Any(t => t.Protection != null) || _config.ArrInstances.Values.Any(a => CategoryOwnershipHelper.ArrOwnsTorrent(_config, a, torrent) && a.Torrent.Trackers.Any(t => t.Protection != null))) && torrent.Progress >= 1 && (torrent.IsStopped || torrent.State.Contains("paused", StringComparison.OrdinalIgnoreCase)) - && !SplitTags(torrent.Tags).Contains("qBitrr-free_space_paused") && !SplitTags(torrent.Tags).Contains("qBitrr-ignored")) - { - var pauseKey = MaintenancePersistence.Key(id, torrent.Hash, "routine-pause"); - var pause = MaintenancePersistence.Read(db, pauseKey); - if (pause.Owned && pause.AddedOn == torrent.AddedOn - && _protection.Values.SelectMany(decisions => decisions).Any(d => d.ClientInstanceId == id && d.TorrentHash == torrent.Hash && d.State != "eligible") - && await client.ResumeTorrentsAsync([torrent.Hash], inspectionCt)) - MaintenancePersistence.Write(db, pauseKey, new PauseOwnership()); - } _trackerInspections[key] = (fetchedAt, trackers, torrent.IsPrivate); } finally { observations.Release(); } diff --git a/src/Torrentarr.Infrastructure/Services/TorrentCacheService.cs b/src/Torrentarr.Infrastructure/Services/TorrentCacheService.cs index f8ab1955..14ec0fff 100644 --- a/src/Torrentarr.Infrastructure/Services/TorrentCacheService.cs +++ b/src/Torrentarr.Infrastructure/Services/TorrentCacheService.cs @@ -15,8 +15,8 @@ public class TorrentCacheService : ITorrentCacheService private readonly Dictionary _ignoreCache = new(StringComparer.OrdinalIgnoreCase); private readonly HashSet _filePrioritiesAppliedHashes = new(StringComparer.Ordinal); private readonly HashSet _fileFilteredHashes = new(StringComparer.Ordinal); - private readonly Dictionary _allowlistWarningCache = new(StringComparer.Ordinal); - private readonly Dictionary _removalBlocks = new(StringComparer.Ordinal); + private readonly Dictionary _allowlistRetryAt = new(StringComparer.Ordinal); + private readonly Dictionary> _diagnostics = new(StringComparer.Ordinal); private readonly object _lock = new(); private DateTime _lastFullClearUtc = DateTime.MinValue; private static readonly TimeSpan FullClearInterval = TimeSpan.FromHours(24); @@ -126,36 +126,41 @@ public void MarkFileFiltered(string qBitInstanceName, string hash) } } - public bool ShouldLogAllowlistWarning( - string qBitInstanceName, - string hash, - DateTimeOffset timestamp, - TimeSpan throttlePeriod) + public bool IsAllowlistRetryCoolingDown(string qBitInstanceName, string hash, TimeSpan cooldown) { lock (_lock) { - var key = FileStateKey(qBitInstanceName, hash); - if (_allowlistWarningCache.TryGetValue(key, out var last) - && timestamp - last < throttlePeriod) - { - return false; - } + return _allowlistRetryAt.TryGetValue(FileStateKey(qBitInstanceName, hash), out var at) + && DateTime.UtcNow - at < cooldown; + } + } - _allowlistWarningCache[key] = timestamp; - return true; + public void MarkAllowlistRetry(string qBitInstanceName, string hash) + { + lock (_lock) + { + _allowlistRetryAt[FileStateKey(qBitInstanceName, hash)] = DateTime.UtcNow; } } - public bool ShouldLogRemovalBlock(string instanceId, string hash, string reason, - DateTimeOffset timestamp, TimeSpan throttlePeriod) + public bool DiagnosticChanged(string instanceId, string hash, string stream, string? evidence) { lock (_lock) { var key = FileStateKey(instanceId, hash); - if (_removalBlocks.TryGetValue(key, out var previous) - && previous.Reason == reason && timestamp - previous.At < throttlePeriod) - return false; - _removalBlocks[key] = (reason, timestamp); + if (!_diagnostics.TryGetValue(key, out var streams)) + { + if (evidence == null) return false; + _diagnostics[key] = streams = new(StringComparer.Ordinal); + } + if (evidence == null) + { + var removed = streams.Remove(stream); + if (streams.Count == 0) _diagnostics.Remove(key); + return removed; + } + if (streams.TryGetValue(stream, out var previous) && previous == evidence) return false; + streams[stream] = evidence; return true; } } @@ -166,7 +171,7 @@ public void InvalidateTorrent(string instanceId, string hash) { var key = FileStateKey(instanceId, hash); _categoryCache.Remove(key); _nameCache.Remove(key); _ignoreCache.Remove(key); - _filePrioritiesAppliedHashes.Remove(key); _fileFilteredHashes.Remove(key); _allowlistWarningCache.Remove(key); _removalBlocks.Remove(key); + _filePrioritiesAppliedHashes.Remove(key); _fileFilteredHashes.Remove(key); _diagnostics.Remove(key); _allowlistRetryAt.Remove(key); } } @@ -179,8 +184,8 @@ public void Clear() _ignoreCache.Clear(); _filePrioritiesAppliedHashes.Clear(); _fileFilteredHashes.Clear(); - _allowlistWarningCache.Clear(); - _removalBlocks.Clear(); + _diagnostics.Clear(); + _allowlistRetryAt.Clear(); _lastFullClearUtc = DateTime.UtcNow; _logger.LogTrace("All caches cleared"); } @@ -198,8 +203,8 @@ public void CleanExpired() _ignoreCache.Clear(); _filePrioritiesAppliedHashes.Clear(); _fileFilteredHashes.Clear(); - _allowlistWarningCache.Clear(); - _removalBlocks.Clear(); + _diagnostics.Clear(); + _allowlistRetryAt.Clear(); _lastFullClearUtc = now; _logger.LogInformation("Periodic full cache clear (every {Hours}h) to bound memory growth", (int)FullClearInterval.TotalHours); } diff --git a/src/Torrentarr.Infrastructure/Services/TorrentClientApiBuilder.cs b/src/Torrentarr.Infrastructure/Services/TorrentClientApiBuilder.cs index d78935b5..2ea6edcc 100644 --- a/src/Torrentarr.Infrastructure/Services/TorrentClientApiBuilder.cs +++ b/src/Torrentarr.Infrastructure/Services/TorrentClientApiBuilder.cs @@ -25,12 +25,12 @@ public static async Task> StatusAsync(TorrentarrConfi } catch (Exception) when (!ct.IsCancellationRequested) { alive = false; } } - instances[id] = new { alive, host = cfg.Host, port = cfg.Port, version }; + instances[id] = new { alive, host = cfg.Host, port = cfg.Port, version, scanIntervalSeconds = cfg.TorrentScanIntervalSeconds }; } return instances; } - public static async Task> CategoriesAsync(TorrentarrConfig config, ITorrentClientRegistry registry, bool extendedPolicy = false, CancellationToken ct = default) + public static async Task> CategoriesAsync(TorrentarrConfig config, ITorrentClientRegistry registry, bool extendedPolicy = false, CancellationToken ct = default, TorrentStatisticsSnapshots? snapshots = null) { var categories = new List(); Exception? failure = null; @@ -41,22 +41,30 @@ public static async Task> CategoriesAsync(TorrentarrConfig config, { if (cfg.Disabled || string.IsNullOrWhiteSpace(cfg.Host) || cfg.Host == "CHANGE_ME") continue; var client = registry.GetClient(id); - if (client == null) continue; + if (client == null && snapshots == null) continue; List torrents; + TorrentStatisticsSnapshots.Snapshot? snapshot = null; try { using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); timeout.CancelAfter(TimeSpan.FromSeconds(5)); - torrents = await client.GetTorrentsAsync(cancellationToken: timeout.Token); + if (snapshots != null) + { + snapshot = client == null ? snapshots.Unavailable(id, config) : await snapshots.GetAsync(id, config, client, ct); + if (snapshot.ObservedAt == null) { failure = new IOException(snapshot.Error); continue; } + torrents = snapshot.Torrents.ToList(); + } + else torrents = await client!.GetTorrentsAsync(cancellationToken: timeout.Token); inspected++; } catch (OperationCanceledException) when (!ct.IsCancellationRequested) { failure = new TimeoutException("Torrent-client category inspection timed out."); continue; } catch (Exception ex) when (ex is not OperationCanceledException) { failure = ex; continue; } + var byOwner = torrents.ToLookup(t => CategoryOwnershipHelper.ResolveOwningCategory(config, t.Category, id) ?? "", StringComparer.OrdinalIgnoreCase); foreach (var category in cfg.ManagedCategories.Union(arrCategories, StringComparer.OrdinalIgnoreCase)) { - var inCategory = CategorySeedingApiResolver.SelectAggregateTorrents(torrents, category, cfg.MatchSubcategories); + var inCategory = byOwner[CategoryPathHelper.NormalizeCategory(category)].ToList(); var seeding = inCategory.Where(t => t.IsSeeding).ToList(); var row = new Dictionary { @@ -64,6 +72,10 @@ public static async Task> CategoriesAsync(TorrentarrConfig config, ["instance"] = id, ["managedBy"] = arrCategories.Contains(category) ? "arr" : "qbit", ["torrentCount"] = inCategory.Count, + ["downloadingCount"] = inCategory.Count(t => TorrentPolicyHelper.IsActiveDownloadingStateForFreeSpace(t.State)), + ["observedAt"] = snapshot?.ObservedAt, + ["observationError"] = snapshot?.Error, + ["scanIntervalSeconds"] = cfg.TorrentScanIntervalSeconds, ["seedingCount"] = seeding.Count, ["totalSize"] = inCategory.Sum(t => t.Size), ["avgRatio"] = inCategory.Count > 0 ? inCategory.Average(t => t.Ratio) : 0.0, @@ -76,7 +88,7 @@ public static async Task> CategoriesAsync(TorrentarrConfig config, categories.Add(row); } } - if (inspected == 0 && failure != null) throw failure; + if (inspected == 0 && failure != null && snapshots == null) throw failure; return categories; } } diff --git a/src/Torrentarr.Infrastructure/Services/TorrentProcessor.cs b/src/Torrentarr.Infrastructure/Services/TorrentProcessor.cs index 43d3e90e..b017ae64 100644 --- a/src/Torrentarr.Infrastructure/Services/TorrentProcessor.cs +++ b/src/Torrentarr.Infrastructure/Services/TorrentProcessor.cs @@ -21,6 +21,7 @@ public class TorrentProcessor : ITorrentProcessor private const string AllowedStalledTag = "qBitrr-allowed_stalled"; private const string FreeSpacePausedTag = "qBitrr-free_space_paused"; private const string HnrActiveTag = "qBitrr-hnr_active"; + private static readonly TimeSpan AllowlistRetryCooldown = TimeSpan.FromMinutes(15); private readonly ILogger _logger; private readonly ITorrentClientRegistry _qbitManager; @@ -35,8 +36,9 @@ public class TorrentProcessor : ITorrentProcessor private readonly StalledUploadTracker? _stalledUploads; private readonly ISafeDeletionService? _safeDeletion; private readonly IOperationalIssueReporter? _issues; - private static readonly TimeSpan AllowlistWarningLifetime = TimeSpan.FromMinutes(5); - private static readonly TimeSpan RemovalFailureWarningLifetime = TimeSpan.FromMinutes(5); + private readonly HashSet _activeRemovalBlocks = []; + // Persisted warnings are reconciled once per torrent observation; cache changes handle later transitions. + private readonly HashSet<(string Client, string Hash, long AddedOn)> _reconciledRemovalIssues = []; private readonly HashSet _specialCategories; private readonly Dictionary _recoveryRequests = new(StringComparer.OrdinalIgnoreCase); @@ -78,11 +80,6 @@ public TorrentProcessor( private async Task MayPauseAsync(ITorrentClient client, TorrentInfo torrent, CancellationToken ct) { - if (_seedingService == null) - { - _logger.LogInformation("Pause withheld for {Hash}: seeding protection service is unavailable", torrent.Hash); - return false; - } var current = (await client.GetTorrentsAsync(cancellationToken: ct)).FirstOrDefault(t => t.Hash.Equals(torrent.Hash, StringComparison.OrdinalIgnoreCase)); if (current == null) { @@ -90,7 +87,9 @@ private async Task MayPauseAsync(ITorrentClient client, TorrentInfo torren return false; } current.ClientInstanceId = torrent.ClientInstanceId; - return await _seedingService.HnrAllowsDeleteAsync(current, "routine pause", ct); + if (!SeedingService.SameRemovalCandidate(torrent, current) || current.SuperSeeding + || current.State.Equals("forcedUP", StringComparison.OrdinalIgnoreCase)) return false; + return _seedingService == null || await _seedingService.HnrAllowsDeleteAsync(current, "routine pause", ct); } private bool CanResumeAutomatically(TorrentInfo torrent) @@ -131,15 +130,8 @@ public async Task ProcessTorrentsAsync(string category, IReadOnlySet? cl { _logger.LogInformation("Starting torrent processing for category {Category}", category); + cancellationToken.ThrowIfCancellationRequested(); _recoveryRequests.Clear(); - var allClients = _qbitManager.GetAllClients().Where(pair => clientIds == null || clientIds.Contains(pair.Key)) - .ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.OrdinalIgnoreCase); - if (allClients.Count == 0) - { - _logger.LogWarning("No qBittorrent client available"); - return; - } - // Skip special categories - they are handled globally by the Host orchestrator if (_specialCategories.Contains(category)) { @@ -147,6 +139,18 @@ public async Task ProcessTorrentsAsync(string category, IReadOnlySet? cl return; } + var allClients = _qbitManager.GetAllClients().Where(pair => clientIds == null || clientIds.Contains(pair.Key)) + .ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.OrdinalIgnoreCase); + if (allClients.Count == 0) + { + throw new TorrentProcessingUnavailableException("No connected torrent client is available for the requested scan."); + } + + var missingClients = _config.GetAllTorrentClients().Where(p => !p.Value.Disabled + && !string.IsNullOrWhiteSpace(p.Value.Host) && p.Value.Host != "CHANGE_ME" + && (clientIds == null || clientIds.Contains(p.Key)) + && CategoryOwnershipHelper.ResolveOwningCategory(_config, category, p.Key) == category + && !allClients.ContainsKey(p.Key)).Select(p => p.Key).ToList(); try { // Ensure tags exist on all clients (skip in Tagless mode — §1.6) @@ -206,12 +210,18 @@ public async Task ProcessTorrentsAsync(string category, IReadOnlySet? cl catch (Exception ex) when (ex is not OperationCanceledException) { failures.Add(ex); } } if (failures.Count > 0) throw new AggregateException("Some torrent work could not be completed.", failures); + if (missingClients.Count > 0) throw new TorrentProcessingUnavailableException($"Torrent clients could not be inspected: {string.Join(", ", missingClients)}"); if (_issues != null) await _issues.ResolveAsync(ProcessingIssue(category), cancellationToken); _logger.LogInformation( "Processed {Total} torrents in {Category}: {Downloading} downloading, {Completed} completed, {Seeding} seeding, {Failed} failed, {Ignored} ignored", stats.TotalTorrents, category, stats.Downloading, stats.Completed, stats.Seeding, stats.Failed, stats.Ignored); } + catch (TorrentProcessingUnavailableException ex) + { + if (_issues != null) await _issues.ReportAsync(ProcessingIssue(category).WithEvidence(ex.Message) with { Severity = "warning" }, cancellationToken); + throw; + } catch (Exception ex) when (ex is not OperationCanceledException) { _logger.LogError(ex, "Error processing torrents for category {Category}", category); @@ -369,6 +379,7 @@ public async Task ImportTorrentAsync(string hash, string? qbitInstance = null, C } } + using var activityClient = ArrActivityTracking.ForClient(torrent.QBitInstanceName); var result = await _importService.TriggerImportAsync( hash, torrent.ContentPath, owner, cancellationToken); @@ -470,8 +481,25 @@ private async Task ProcessObservedTorrentAsync(TorrentInfo torrent, string categ try { if (_issues != null) await _issues.ResolveAsync(OwnershipIssue(torrent), ct); + _activeRemovalBlocks.Clear(); await ProcessSingleTorrentAsync(torrent, category, stats, ct); - if (_issues != null) await _issues.ResolveAsync(issue, ct); + foreach (var intent in Enum.GetValues().Where(i => !_activeRemovalBlocks.Contains(i))) + await ClearRemovalBlockAsync(torrent, intent); + if (_issues != null) + { + // A restart may have cleared the cache entries behind persisted warnings; reconcile once. + if (_reconciledRemovalIssues.Add((torrent.ClientInstanceId, torrent.Hash, torrent.AddedOn))) + { + var outstanding = await _dbContext.OperationalIssues.AsNoTracking().Where(i => i.ResolvedAt == null + && i.Code.StartsWith("torrent.removal.") && i.ClientInstance == torrent.ClientInstanceId + && i.SubjectType == "torrent" && i.SubjectId == torrent.Hash).ToListAsync(ct); + foreach (var previous in outstanding.Where(i => !_activeRemovalBlocks.Any(intent => i.Code == "torrent.removal." + intent))) + await _issues.ResolveAsync(new(previous.Code, previous.Category, previous.Title, previous.Impact, + previous.Remediation, previous.ArrInstance, previous.ClientInstance, previous.SubjectType, + previous.SubjectId, previous.Severity, previous.Target), ct); + } + await _issues.ResolveAsync(issue, ct); + } } catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound) { @@ -604,6 +632,27 @@ await ignClient.RemoveTagsAsync(new List { torrent.Hash }, return; } + if (_pathTracker?.IsHashAlreadyScanned(torrent.ClientInstanceId, torrent.Hash) == true) + { + await TryFinalizeImportedTorrentAsync(torrent, ct); + return; + } + + var completedForImport = arrCfg != null && torrent.AddedOn > 0 + && torrent.CompletionOn > 0 + && torrent.AmountLeft == 0 + && IsCompleteState(state) + && !string.IsNullOrEmpty(torrent.ContentPath) + && torrent.CompletionOn < timeNow - 60; + if (completedForImport && !_cache.IsFileFiltered(torrent.ClientInstanceId, torrent.Hash) + && !_cache.IsAllowlistRetryCoolingDown(torrent.ClientInstanceId, torrent.Hash, AllowlistRetryCooldown)) + { + var filterResult = await ApplyFileFilterAsync(torrent, arrCfg!, client, completed: true, ct); + if (filterResult.Deleted || !filterResult.Ready) return; + if (filterResult.RetryCleanup) _cache.MarkAllowlistRetry(torrent.ClientInstanceId, torrent.Hash); + else _cache.MarkFileFiltered(torrent.ClientInstanceId, torrent.Hash); + } + // ================================================================================ // STATE MACHINE — if/elif chain matching qBitrr's _process_single_torrent ordering // ================================================================================ @@ -621,10 +670,23 @@ await ignClient.RemoveTagsAsync(new List { torrent.Hash }, _logger.LogInformation("Removal candidate (custom format unmet): [{Name}] | Hash[{Hash}]", torrent.Name, torrent.Hash); if (await DeleteTorrentFromClientAsync(client, torrent, deleteFiles: true, ct, - stillApplies: current => _importService.IsCustomFormatUnmetAsync(current.Hash, ResolveOwner(current) ?? "", ct))) + stillApplies: current => _importService.IsCustomFormatUnmetAsync(current.Hash, ResolveOwner(current) ?? "", ct), + intent: TorrentRemovalIntent.RejectedByCustomFormat)) stats.Failed++; } } + // Import allowed completed content before routine seeding cleanup; H&R only authorizes deletion. + else if (completedForImport + && (await FindTorrentLibraryEntryAsync(torrent.Hash, torrent.ClientInstanceId, ct))?.Imported != true) + { + stats.Completed++; + if (await IsReadyForImportAsync(torrent.Hash, torrent.QBitInstanceName, ct)) + { + _logger.LogInformation("Completed torrent ready for import: [{Name}] | Progress[{Progress:P1}] | Hash[{Hash}]", + torrent.Name, torrent.Progress, torrent.Hash); + await ImportTorrentAsync(torrent.Hash, torrent.QBitInstanceName, ct); + } + } // Branch 2: Ratio/seed limit met AND not leave_alone AND fully downloaded → delete // (qBitrr line 6106-6109: remove_torrent and not leave_alone and amount_left==0) else if (!recoveryBlocksRemoval && removeTorrent && !leaveAlone && torrent.AmountLeft == 0) @@ -785,35 +847,6 @@ await ProcessStalledTorrentAsync( _logger.LogInformation("Re-checking errored torrent: [{Name}] | Hash[{Hash}]", torrent.Name, torrent.Hash); _logger.LogInformation("Keeping errored torrent {Hash}; recovery never authorizes removal", torrent.Hash); } - // Branch 16: Fully completed (60s grace period) → trigger import (qBitrr line 6196-6205) - // Python: if leave_alone → allow seeding; elif not tagged qBitrr-imported → dispatch to import_torrents - else if (torrent.AddedOn > 0 - && torrent.CompletionOn > 0 - && torrent.AmountLeft == 0 - && IsCompleteState(state) - && !string.IsNullOrEmpty(torrent.ContentPath) - && torrent.CompletionOn < timeNow - 60) - { - stats.Completed++; - if (arrCfg != null && !_cache.IsFileFiltered(torrent.QBitInstanceName, torrent.Hash)) - { - var filterResult = await ApplyFileFilterAsync(torrent, arrCfg, client, completed: true, ct); - if (filterResult.Deleted || !filterResult.Ready) - return; - - _cache.MarkFileFiltered(torrent.QBitInstanceName, torrent.Hash); - } - if (leaveAlone && !recoveryBlocksRemoval || state == TorrentState.ForcedUploading) - { - _logger.LogInformation("Completed torrent — allowing seeding: [{Name}]", torrent.Name); - } - else if (await IsReadyForImportAsync(torrent.Hash, torrent.QBitInstanceName, ct)) - { - _logger.LogInformation("Completed torrent ready for import: [{Name}] | Progress[{Progress:P1}] | Hash[{Hash}]", - torrent.Name, torrent.Progress, torrent.Hash); - await ImportTorrentAsync(torrent.Hash, torrent.QBitInstanceName, ct); - } - } // Branch 17: Uploading + seeding limits configured → pause if !leave_alone (qBitrr line 6207-6215) else if (IsUploadingState(state) && torrent.SeedingTime > 1 @@ -821,7 +854,7 @@ await ProcessStalledTorrentAsync( && torrent.AddedOn > 0 && !string.IsNullOrEmpty(torrent.ContentPath) && GetRemoveMode(torrent, arrCfg) != -1 - && _cache.IsFileFiltered(torrent.QBitInstanceName, torrent.Hash)) + && (completedForImport || _cache.IsFileFiltered(torrent.QBitInstanceName, torrent.Hash))) { if (leaveAlone || state == TorrentState.ForcedUploading) { @@ -948,7 +981,7 @@ await ProcessStalledTorrentAsync( _logger.LogTrace("ETA policy for {Hash}: ArrMaximumETA={ArrEta}s; trackerMaximumETA={TrackerEta}s; effectiveMaximumETA={EffectiveEta}s; actualETA={ActualEta}s", torrent.Hash, defaultMaxEta, trackerCfg?.MaxETA, maxEta, torrent.Eta); - // Check if torrent should be removed (ratio/time limits met, including stalledUP idle) + // Check if torrent should be removed (ratio/time limits met, including stalledUP idle, plus H&R clearance) var shouldRemove = await _seedingService.ShouldRemoveTorrentAsync(torrent, ct); // leave_alone = NOT (isUploading AND shouldRemove) — qBitrr line 5862-5864 @@ -987,18 +1020,34 @@ await ProcessStalledTorrentAsync( ArrInstance: ResolveOwner(torrent) ?? "", ClientInstance: torrent.ClientInstanceId, SubjectType: "torrent", SubjectId: torrent.Hash, Severity: "warning", Target: "maintenance"); - private async Task LogRemovalBlocked(TorrentInfo torrent, TorrentRemovalIntent intent, string reason, string? cause = null) + private async Task ClearRemovalBlockAsync(TorrentInfo torrent, TorrentRemovalIntent intent) { - if (!_cache.ShouldLogRemovalBlock(torrent.ClientInstanceId, torrent.Hash, $"{intent}:{_config.Revision}: {cause ?? System.Text.RegularExpressions.Regex.Replace(reason, @"\b\d+(?:[.,]\d+)*\b", "#")}", - DateTimeOffset.UtcNow, RemovalFailureWarningLifetime)) return; - _logger.LogWarning("Torrent removal blocked: [{Name}] | Hash[{Hash}] | Client[{Client}] | Arr[{Arr}] | Category[{Category}] | Intent[{Intent}] | Mode[{Mode}] | Reason[{Reason}]", - torrent.Name, torrent.Hash, torrent.ClientInstanceId, ResolveOwner(torrent), torrent.Category, intent, - _config.GetTorrentClient(torrent.ClientInstanceId)?.Maintenance.RecycleBin.Enabled == true ? "recycle" : "native client deletion", - DiagnosticText.Safe(reason)); - if (_issues != null) await _issues.ReportAsync(RemovalIssue(torrent, intent) with - { - Impact = DiagnosticText.Safe($"Torrent={torrent.Name}; hash={torrent.Hash}; client={torrent.ClientInstanceId}; category={torrent.Category}; intent={intent}; mode={(_config.GetTorrentClient(torrent.ClientInstanceId)?.Maintenance.RecycleBin.Enabled == true ? "recycle" : "native deletion")}. {reason}") - }); + var cleared = _cache.DiagnosticChanged(torrent.ClientInstanceId, torrent.Hash, "removal:" + intent, null); + if (cleared && _issues != null) await _issues.ResolveAsync(RemovalIssue(torrent, intent)); + } + + private async Task LogRemovalBlocked(TorrentInfo torrent, TorrentRemovalIntent intent, string reason, + string? cause = null, RemovalDecision? decision = null) + { + _activeRemovalBlocks.Add(intent); + var expectedWait = decision?.IsExpectedProtectionWait() == true; + var identity = decision == null ? cause ?? Regex.Replace(reason, @"\b\d+(?:[.,]\d+)*\b", "#") + : string.Join(",", decision.Blockers.Select(b => b.Code).Distinct().Order()); + var changed = _cache.DiagnosticChanged(torrent.ClientInstanceId, torrent.Hash, "removal:" + intent, + $"{_config.Revision}:{torrent.Category}:{identity}"); + _logger.Log(changed ? expectedWait ? LogLevel.Information : LogLevel.Warning : LogLevel.Debug, + "Torrent removal deferred: [{Name}] | Hash[{Hash}] | Client[{Client}] | Arr[{Arr}] | Category[{Category}] | Intent[{Intent}] | Mode[{Mode}] | Reason[{Reason}]", + torrent.Name, torrent.Hash, torrent.ClientInstanceId, ResolveOwner(torrent), torrent.Category, intent, + _config.GetTorrentClient(torrent.ClientInstanceId)?.Maintenance.RecycleBin.Enabled == true ? "recycle" : "native client deletion", + DiagnosticText.Safe(reason)); + if (_issues != null) + { + if (expectedWait) await _issues.ResolveAsync(RemovalIssue(torrent, intent)); + else await _issues.ReportAsync(RemovalIssue(torrent, intent) with + { + Impact = DiagnosticText.Safe($"Torrent={torrent.Name}; hash={torrent.Hash}; client={torrent.ClientInstanceId}; category={torrent.Category}; intent={intent}. {reason}") + }); + } } private async Task DeleteTorrentFromClientAsync( @@ -1006,8 +1055,8 @@ private async Task DeleteTorrentFromClientAsync( Func>? stillApplies = null, TorrentRemovalIntent intent = TorrentRemovalIntent.SeedingLimit, string? triggerEvidence = null) { - Task Block(string reason, string? cause = null) => LogRemovalBlocked(torrent, intent, - triggerEvidence == null ? reason : $"{triggerEvidence} Blocker: {reason}", cause); + Task Block(string reason, string? cause = null, RemovalDecision? decision = null) => LogRemovalBlocked(torrent, intent, + triggerEvidence == null ? reason : $"{triggerEvidence} Cleanup deferred: {reason}", cause, decision); if (MaintenanceRecovery.BlocksAutomaticRemoval(_dbContext, torrent, _config.GetTorrentClient(torrent.ClientInstanceId)?.Maintenance.ErrorRecovery ?? new ErrorRecoveryConfig())) { @@ -1040,7 +1089,7 @@ Task Block(string reason, string? cause = null) => LogRemovalBlocked(torrent, in if (!cleared) { await Block(decision?.Reason ?? "Protection service denied removal without supplying a diagnostic decision; inspect the protection implementation.", - decision == null ? "protection.unknown" : string.Join(",", decision.Blockers.Select(b => b.Code))); + decision == null ? "protection.unknown" : string.Join(",", decision.Blockers.Select(b => b.Code)), decision); return false; } if (stillApplies != null && !await stillApplies(current)) @@ -1627,9 +1676,9 @@ var s when s.Contains("checking") => TorrentState.CheckingUploading, /// /// §2.1: Apply file filtering to a downloading torrent. /// Sets excluded files to priority 0; deletes torrent if all files are excluded. - /// For completed torrents, disallowed files must be absent before import. When AutoDelete - /// is enabled only torrent-owned paths are removed. The result is not ready when qBittorrent - /// rejects a priority update or disallowed files remain, so the next processing cycle retries. + /// Completed torrents with allowed files can import while optional excluded-file cleanup retries. + /// Only owned paths may be removed, with H&R clearance; invalid paths and rejected priority updates + /// remain pending. Cleanup never gates an otherwise eligible import. /// private async Task ApplyFileFilterAsync( TorrentInfo torrent, @@ -1639,6 +1688,11 @@ private async Task ApplyFileFilterAsync( CancellationToken ct) { var cfg = arrCfg.Torrent; + FileFilterResult Ready(bool retryCleanup = false) + { + _cache.DiagnosticChanged(torrent.ClientInstanceId, torrent.Hash, "allowlist", null); + return FileFilterResult.ReadyResult with { RetryCleanup = retryCleanup }; + } // No filtering configured — fast exit if (cfg.FolderExclusionRegex.Count == 0 && @@ -1646,7 +1700,7 @@ private async Task ApplyFileFilterAsync( cfg.FileExtensionAllowlist.Count == 0) { _logger.LogInformation("File filtering skipped for [{Name}]: no exclusion or allowlist rules configured", torrent.Name); - return FileFilterResult.ReadyResult; + return Ready(); } var files = await client.GetTorrentFilesAsync(torrent.Hash, ct); @@ -1669,7 +1723,6 @@ private async Task ApplyFileFilterAsync( file.Name, torrent.Name, excluded, reason ?? "allowed by configured rules", file.Priority, file.Progress, file.Size, string.Join("; ", cfg.FolderExclusionRegex), string.Join("; ", cfg.FileNameExclusionRegex), string.Join("; ", cfg.FileExtensionAllowlist), cfg.CaseSensitiveMatches); (excluded ? excludedFiles : allowedFiles).Add(file); } - var excludedIds = excludedFiles.Select(f => f.Index).ToArray(); var prioritiesToApply = excludedFiles .Where(f => f.Priority != 0) .Select(f => f.Index) @@ -1702,13 +1755,13 @@ private async Task ApplyFileFilterAsync( if (allowedFiles.Count == 0) { - WarnAllowlistBlocked(torrent, "no allowed files remain", excludedFiles); + WarnAllowlistBlocked(torrent, "no allowed files remain", excludedFiles, cfg); var hnrAllows = _seedingService != null && await _seedingService.HnrAllowsDeleteAsync(torrent, "all files excluded by import allowlist", ct); if (!hnrAllows) { var decision = _seedingService == null ? null : await _seedingService.EvaluateRemovalAsync(torrent, TorrentRemovalIntent.RejectedByAllowlist, ct); - await LogRemovalBlocked(torrent, TorrentRemovalIntent.RejectedByAllowlist, decision?.Reason ?? "HnR inspection could not authorize removal.", "allowlist.hnr"); + await LogRemovalBlocked(torrent, TorrentRemovalIntent.RejectedByAllowlist, decision?.Reason ?? "HnR inspection could not authorize removal.", "allowlist.hnr", decision); return FileFilterResult.PendingResult; } @@ -1717,19 +1770,29 @@ private async Task ApplyFileFilterAsync( meaningfulFiles.Count, torrent.Name, torrent.Hash); return await DeleteTorrentFromClientAsync(client, torrent, deleteFiles: true, ct, intent: TorrentRemovalIntent.RejectedByAllowlist, - triggerEvidence: $"All {meaningfulFiles.Count} meaningful files were excluded. Files={string.Join("; ", excludedFiles.Select(f => f.Name))}; effective extension allowlist={string.Join("; ", cfg.FileExtensionAllowlist)}; filename exclusions={string.Join("; ", cfg.FileNameExclusionRegex)}; folder exclusions={string.Join("; ", cfg.FolderExclusionRegex)}.") + triggerEvidence: $"Import rejected: all {meaningfulFiles.Count} meaningful files were excluded. {AllowlistEvidence(excludedFiles, cfg)}") ? FileFilterResult.DeletedResult : FileFilterResult.PendingResult; } - if (excludedIds.Length == 0) - return FileFilterResult.ReadyResult; + if (excludedFiles.Count == 0) + return Ready(); if (!completed) - return FileFilterResult.ReadyResult; + return Ready(); + + try + { + foreach (var file in meaningfulFiles) _ = ResolveTorrentOwnedPath(torrent, file.Name); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + WarnAllowlistBlocked(torrent, "unsafe or invalid content path", excludedFiles, cfg); + return FileFilterResult.PendingResult; + } if (!TorrentStorageAvailable(torrent, allowedFiles)) { - WarnAllowlistBlocked(torrent, "content storage is unavailable", excludedFiles); + WarnAllowlistBlocked(torrent, "content storage is unavailable", excludedFiles, cfg); return FileFilterResult.PendingResult; } @@ -1738,80 +1801,36 @@ private async Task ApplyFileFilterAsync( { var hnrAllows = _seedingService != null && await _seedingService.HnrAllowsDeleteAsync(torrent, "excluded file cleanup by import allowlist", ct); - if (!hnrAllows) - { - // H&R must prevent deleting owned files, but does not need to block - // importing valid media when only harmless metadata sidecars remain. - if (CanImportWithPreservedSidecars(torrent, remaining)) - { - _logger.LogInformation("Import permitted for {Hash}; preserving {Count} excluded .nfo/.txt sidecar files due to H&R obligations", - torrent.Hash, remaining.Count); - return FileFilterResult.ReadyResult; - } - WarnAllowlistBlocked(torrent, "H&R obligations prevent excluded file cleanup", remaining); - return FileFilterResult.PendingResult; - } - - foreach (var file in remaining) - await TryDeleteTorrentOwnedFileAsync(torrent, file.Name, ct); - - remaining = excludedFiles.Where(f => TorrentOwnedFileExists(torrent, f.Name)).ToList(); - } - - if (remaining.Count == 0) - return FileFilterResult.ReadyResult; - - if (CanImportWithPreservedSidecars(torrent, remaining)) - { - _logger.LogInformation("Import permitted for {Hash}; preserving {Count} excluded .nfo/.txt metadata sidecars", - torrent.Hash, remaining.Count); - return FileFilterResult.ReadyResult; + if (hnrAllows) + foreach (var file in remaining) await TryDeleteTorrentOwnedFileAsync(torrent, file.Name, ct); + else + _logger.LogDebug("Excluded-file cleanup deferred for {Hash}; H&R does not block import", torrent.Hash); } - - WarnAllowlistBlocked(torrent, $"{remaining.Count} disallowed file(s) remain", remaining); - return FileFilterResult.PendingResult; + return Ready(retryCleanup: cfg.AutoDelete && remaining.Any(f => TorrentOwnedFileExists(torrent, f.Name))); } - private bool CanImportWithPreservedSidecars(TorrentInfo torrent, IReadOnlyCollection remaining) + private static string AllowlistEvidence(IReadOnlyList files, TorrentConfig cfg) { - if (remaining.Count == 0 || MaintenanceRecovery.BlocksAutomaticRemoval(_dbContext, torrent, - _config.GetTorrentClient(torrent.ClientInstanceId)?.Maintenance.ErrorRecovery ?? new ErrorRecoveryConfig())) - return false; - - foreach (var file in remaining) - { - var extension = Path.GetExtension(file.Name); - if (!extension.Equals(".nfo", StringComparison.OrdinalIgnoreCase) - && !extension.Equals(".txt", StringComparison.OrdinalIgnoreCase)) - return false; - try - { - // Do not relax the import barrier for symlink, traversal, or invalid paths. - _ = ResolveTorrentOwnedPath(torrent, file.Name); - } - catch - { - return false; - } - } - return true; + var options = cfg.CaseSensitiveMatches ? RegexOptions.None : RegexOptions.IgnoreCase; + var examples = files.Take(5).Select(f => + $"{(f.Name.Length > 200 ? f.Name[..200] + "…" : f.Name)} ({FileExclusionReason(f.Name, cfg, options)})"); + var archives = files.Any(f => Regex.IsMatch(f.Name, @"\.(?:rar|r\d{2,3})$", RegexOptions.IgnoreCase)); + return $"Excluded files ({files.Count} total, {Math.Max(0, files.Count - 5)} more): {string.Join("; ", examples)}. " + + (archives ? "Rejected content includes RAR archive files; archive contents cannot be imported through this allowlist workflow. " : "") + + $"Effective extension allowlist={string.Join("; ", cfg.FileExtensionAllowlist.Distinct())}; filename exclusions={string.Join("; ", cfg.FileNameExclusionRegex.Distinct())}; folder exclusions={string.Join("; ", cfg.FolderExclusionRegex.Distinct())}."; } - private void WarnAllowlistBlocked(TorrentInfo torrent, string reason, IReadOnlyList excludedFiles) + private void WarnAllowlistBlocked(TorrentInfo torrent, string reason, IReadOnlyList excludedFiles, TorrentConfig cfg) { - var now = DateTimeOffset.UtcNow; - if (!_cache.ShouldLogAllowlistWarning( - torrent.QBitInstanceName, - torrent.Hash, - now, - AllowlistWarningLifetime)) - { - return; - } - - _logger.LogWarning("Import blocked by file allowlist for [{Name}] ({Hash}): {Reason}; excluded files ({Count} total, {Remaining} more): {Files}", - torrent.Name, torrent.Hash, reason, excludedFiles.Count, Math.Max(0, excludedFiles.Count - 5), - string.Join(", ", excludedFiles.Take(5).Select(f => f.Name.Length > 200 ? f.Name[..200] + "…" : f.Name))); + // File progress and seeding counters do not change the rejection itself. + var fingerprint = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData( + System.Text.Encoding.UTF8.GetBytes(System.Text.Json.JsonSerializer.Serialize( + excludedFiles.Select(f => new { f.Name, f.Size }).OrderBy(f => f.Name))))); + var changed = _cache.DiagnosticChanged(torrent.ClientInstanceId, torrent.Hash, "allowlist", + $"{_config.Revision}:{reason}:{fingerprint}"); + _logger.Log(changed ? LogLevel.Warning : LogLevel.Debug, + "Import blocked by file allowlist for [{Name}] ({Hash}): {Reason}; {Evidence}", + torrent.Name, torrent.Hash, reason, DiagnosticText.Safe(AllowlistEvidence(excludedFiles, cfg))); } internal static (int StalledDelay, int IgnoreTorrentsYoungerThan) ResolveStalledSettings( @@ -1848,7 +1867,7 @@ private async Task TryDeleteTorrentOwnedFileAsync(TorrentInfo torrent, string re { var path = ResolveTorrentOwnedPath(torrent, relativePath); if (File.Exists(path) && (_safeDeletion == null || !await _safeDeletion.DeleteOwnedFileAsync(torrent.ClientInstanceId, torrent.Hash, path, ct))) - _logger.LogWarning("Excluded-file cleanup was not authorized for {Hash}: {Path}; import remains pending", torrent.Hash, path); + _logger.LogWarning("Excluded-file cleanup was not authorized for {Hash}: {Path}; import is permitted after configured filtering", torrent.Hash, path); } catch (Exception ex) when (ex is not OperationCanceledException) { @@ -1942,7 +1961,7 @@ private static bool IsIgnoredAuxiliaryFile(string path) || name.EndsWith(".parts", StringComparison.OrdinalIgnoreCase); } - private readonly record struct FileFilterResult(bool Ready, bool Deleted) + private readonly record struct FileFilterResult(bool Ready, bool Deleted, bool RetryCleanup = false) { public static FileFilterResult ReadyResult => new(true, false); public static FileFilterResult PendingResult => new(false, false); diff --git a/src/Torrentarr.Infrastructure/Services/TorrentStatisticsSnapshots.cs b/src/Torrentarr.Infrastructure/Services/TorrentStatisticsSnapshots.cs new file mode 100644 index 00000000..940c3e8d --- /dev/null +++ b/src/Torrentarr.Infrastructure/Services/TorrentStatisticsSnapshots.cs @@ -0,0 +1,67 @@ +using System.Collections.Concurrent; +using Torrentarr.Core.Configuration; +using Torrentarr.Core.Interfaces; +using Torrentarr.Core.Models; + +namespace Torrentarr.Infrastructure.Services; + +/// Complete client observations, refreshed at each client's configured scan cadence. +public sealed class TorrentStatisticsSnapshots +{ + public sealed record Snapshot(IReadOnlyList Torrents, DateTimeOffset? ObservedAt, string? Error, int ScanIntervalSeconds); + private sealed class Entry + { + public readonly SemaphoreSlim Gate = new(1); + public Snapshot? Value; + public DateTimeOffset NextAttempt; + public long Revision = -1; + public ITorrentClient? Client; + } + private readonly ConcurrentDictionary _entries = new(StringComparer.OrdinalIgnoreCase); + + public sealed record Observation(DateTimeOffset? ObservedAt, string? Error, int ScanIntervalSeconds); + public Snapshot Unavailable(string id, TorrentarrConfig config) + { + var previous = _entries.GetValueOrDefault(id)?.Value; + return new(previous?.Torrents ?? [], previous?.ObservedAt, "Client is disconnected.", config.GetTorrentClient(id)!.TorrentScanIntervalSeconds); + } + public Dictionary Describe(TorrentarrConfig config, ITorrentClientRegistry registry) + => config.GetAllTorrentClients().Where(pair => !pair.Value.Disabled && !string.IsNullOrWhiteSpace(pair.Value.Host) && pair.Value.Host != "CHANGE_ME") + .ToDictionary(pair => pair.Key, pair => + { + var snapshot = _entries.GetValueOrDefault(pair.Key)?.Value; + return new Observation(snapshot?.ObservedAt, registry.GetClient(pair.Key) == null ? "Client is disconnected." : snapshot?.Error, + pair.Value.TorrentScanIntervalSeconds); + }); + + public async Task GetAsync(string id, TorrentarrConfig config, ITorrentClient client, CancellationToken ct) + { + var entry = _entries.GetOrAdd(id, _ => new()); + await entry.Gate.WaitAsync(ct); + try + { + var interval = config.GetTorrentClient(id)!.TorrentScanIntervalSeconds; + if (entry.Value != null && entry.NextAttempt > DateTimeOffset.UtcNow && entry.Revision == config.Revision + && entry.Value.ScanIntervalSeconds == interval && ReferenceEquals(entry.Client, client)) return entry.Value; + if (!ReferenceEquals(entry.Client, client)) entry.Value = null; + entry.Client = client; + try + { + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeout.CancelAfter(TimeSpan.FromSeconds(10)); + var torrents = await client.GetTorrentsAsync(cancellationToken: timeout.Token); + foreach (var torrent in torrents) torrent.QBitInstanceName = id; + entry.Value = new(torrents, DateTimeOffset.UtcNow, null, interval); + } + catch (Exception) when (!ct.IsCancellationRequested) + { + entry.Value = new(entry.Value?.Torrents ?? [], entry.Value?.ObservedAt, + "Client statistics could not be refreshed.", interval); + } + entry.Revision = config.Revision; + entry.NextAttempt = DateTimeOffset.UtcNow.AddSeconds(interval); + return entry.Value; + } + finally { entry.Gate.Release(); } + } +} diff --git a/src/Torrentarr.Infrastructure/Services/TransferHistoryService.cs b/src/Torrentarr.Infrastructure/Services/TransferHistoryService.cs new file mode 100644 index 00000000..63bd8aa3 --- /dev/null +++ b/src/Torrentarr.Infrastructure/Services/TransferHistoryService.cs @@ -0,0 +1,150 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Torrentarr.Core.Configuration; +using Torrentarr.Core.Interfaces; +using Torrentarr.Infrastructure.Database; +using Torrentarr.Infrastructure.Database.Models; + +namespace Torrentarr.Infrastructure.Services; + +public sealed record TransferBucket(DateTimeOffset Timestamp, double? DownloadRate, double? UploadRate, int Samples, int ExpectedSamples); +public sealed record TransferSeries(string Instance, List Points); +public sealed record TransferHistoryResponse(string Range, int BucketMinutes, DateTimeOffset GeneratedAt, List Series); + +/// Transfer observations follow each client scan interval; persisted history uses minute buckets. +public sealed class TransferHistoryCollector( + IServiceScopeFactory scopes, TorrentarrConfig config, ITorrentClientRegistry registry, + ILogger logger) : BackgroundService +{ + private readonly Dictionary _lastAttempt = new(StringComparer.OrdinalIgnoreCase); + private long _lastPruneMinute = long.MinValue; + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1)); + do + { + try { await CollectAsync(DateTimeOffset.UtcNow, stoppingToken); } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { break; } + catch (Exception ex) { logger.LogWarning(ex, "Transfer history collection failed"); } + } while (await timer.WaitForNextTickAsync(stoppingToken)); + } + + internal async Task CollectAsync(DateTimeOffset now, CancellationToken ct) + { + var minute = now.ToUnixTimeSeconds() / 60; + using var scope = scopes.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + foreach (var (name, settings) in config.GetAllTorrentClients().ToArray()) + { + if (settings.Disabled || registry.GetClient(name) is not { } client) continue; + if (_lastAttempt.TryGetValue(name, out var last) && now - last < TimeSpan.FromSeconds(Math.Max(1, settings.TorrentScanIntervalSeconds))) continue; + _lastAttempt[name] = now; + try + { + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeout.CancelAfter(TimeSpan.FromSeconds(10)); + var transfer = await client.GetTransferInfoAsync(timeout.Token); + if (transfer == null || transfer.DownloadSpeed < 0 || transfer.UploadSpeed < 0) continue; + if (await db.TransferSamples.AnyAsync(s => s.ClientInstance == name && s.Minute == minute, ct)) continue; + db.TransferSamples.Add(new TransferSample + { + ClientInstance = name, + Minute = minute, + DownloadRate = transfer.DownloadSpeed, + UploadRate = transfer.UploadSpeed + }); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } + catch (Exception ex) { logger.LogDebug(ex, "Transfer history unavailable for {Client}", name); } + } + await db.SaveChangesAsync(ct); + if (_lastPruneMinute == long.MinValue || minute - _lastPruneMinute >= 60) + { + await db.TransferSamples.Where(s => s.Minute < minute - 7 * 24 * 60).ExecuteDeleteAsync(ct); + _lastPruneMinute = minute; + } + } +} + +public static class TransferHistoryApi +{ + public static void MapTransferHistory(this IEndpointRouteBuilder app) + { + app.MapGet("/web/statistics/catalog", async (string kind, string? instance, + TorrentarrConfig config, CatalogRollupService rollups, TorrentarrDbContext db, CancellationToken ct) => + { + if (kind is not ("radarr" or "sonarr" or "lidarr" or "readarr")) + return Results.BadRequest(new { error = "Unknown catalog kind" }); + var keys = config.ArrInstances.Where(a => a.Value.Type == kind && + (string.IsNullOrEmpty(instance) || instance == "aggregate" || a.Key.Equals(instance, StringComparison.OrdinalIgnoreCase))) + .SelectMany(a => ArrCatalogIdentity.QueryKeys(a)).Distinct().ToList(); + if (!string.IsNullOrEmpty(instance) && instance != "aggregate" && keys.Count == 0) + return Results.BadRequest(new { error = "Unknown catalog instance" }); + CatalogRollupService.Counts counts; + int total; + string unit; + switch (kind) + { + case "radarr": + (counts, total) = await rollups.GetRadarrRollupsAsync(keys, ct); unit = "movies"; break; + case "sonarr": + (counts, _) = await rollups.GetSonarrRollupsAsync(keys, ct); + total = await db.Episodes.CountAsync(e => keys.Contains(e.ArrInstance), ct); unit = "episodes"; break; + case "lidarr": + (counts, total, _) = await rollups.GetLidarrRollupsAsync(keys, ct); unit = "albums"; break; + default: + (counts, total) = await rollups.GetReadarrRollupsAsync(keys, ct); unit = "books"; break; + } + return Results.Ok(new { unit, total, counts.Available, counts.Monitored, counts.Missing }); + }); + app.MapGet("/web/statistics/transfers", async (string? instance, string? range, + TorrentarrConfig config, TorrentarrDbContext db, CancellationToken ct) => + { + range ??= "24h"; + if (range is not ("1h" or "24h" or "7d")) + return Results.BadRequest(new { error = "range must be 1h, 24h, or 7d" }); + var names = config.GetAllTorrentClients().Where(c => !c.Value.Disabled).Select(c => c.Key).ToList(); + if (!string.IsNullOrEmpty(instance) && instance != "all") + { + var match = names.Find(n => n.Equals(instance, StringComparison.OrdinalIgnoreCase)); + if (match == null) return Results.BadRequest(new { error = "Unknown client instance" }); + names = [match]; + } + return Results.Ok(await QueryAsync(db, names, range, DateTimeOffset.UtcNow, ct)); + }); + } + + public static async Task QueryAsync(TorrentarrDbContext db, + List names, string range, DateTimeOffset now, CancellationToken ct = default) + { + var (minutes, bucket) = range switch + { + "1h" => (60, 1), + "24h" => (1440, 5), + "7d" => (10080, 30), + _ => throw new ArgumentOutOfRangeException(nameof(range)) + }; + // Use completed wall-clock buckets so the expected coverage is unambiguous. + var end = now.ToUnixTimeSeconds() / 60 / bucket * bucket; + var start = end - minutes; + var rows = await db.TransferSamples.AsNoTracking().Where(s => names.Contains(s.ClientInstance) + && s.Minute >= start && s.Minute < end).ToListAsync(ct); + var lookup = rows.ToLookup(s => (s.ClientInstance, Bucket: s.Minute / bucket * bucket)); + var series = names.Select(name => new TransferSeries(name, + Enumerable.Range(0, minutes / bucket).Select(i => + { + var minute = start + i * bucket; + var samples = lookup[(name, minute)].ToList(); + return new TransferBucket(DateTimeOffset.FromUnixTimeSeconds(minute * 60), + samples.Count == 0 ? null : samples.Average(s => (double)s.DownloadRate), + samples.Count == 0 ? null : samples.Average(s => (double)s.UploadRate), samples.Count, bucket); + }).ToList())).ToList(); + return new(range, bucket, now, series); + } +} diff --git a/src/Torrentarr.Infrastructure/Services/WorkerProcessSupervisor.cs b/src/Torrentarr.Infrastructure/Services/WorkerProcessSupervisor.cs index 48d7f58f..059dba6a 100644 --- a/src/Torrentarr.Infrastructure/Services/WorkerProcessSupervisor.cs +++ b/src/Torrentarr.Infrastructure/Services/WorkerProcessSupervisor.cs @@ -28,6 +28,7 @@ public sealed class WorkerProcessSupervisor : BackgroundService, IProcessOrchest private readonly object _gate = new(); private readonly SemaphoreSlim _lifecycle = new(1, 1); private readonly HashSet _reportedWorkers = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _lastObservations = new(StringComparer.OrdinalIgnoreCase); private long _lastReconciledRevision = -1; private readonly Dictionary _lastFailures = new(StringComparer.OrdinalIgnoreCase); // A failed launch or exhausted retry allowance must not be bypassed by another config reconciliation. @@ -55,7 +56,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) { while (!stoppingToken.IsCancellationRequested) { - MonitorWorkers(); + await MonitorWorkersAsync(stoppingToken); await StopRemovedWorkersAsync(stoppingToken); await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken); } @@ -108,18 +109,19 @@ private async Task StopWorkersAsync(CancellationToken cancellationToken) finally { _lifecycle.Release(); } } - private async Task StopWorkerAsync(string instanceName, CancellationToken cancellationToken) + private async Task StopWorkerAsync(string instanceName, CancellationToken cancellationToken, Process? expected = null) { Process? process; lock (_gate) { + if (expected != null && (!_processes.TryGetValue(instanceName, out var current) || !ReferenceEquals(current, expected))) return false; CancelRetry(instanceName); _processes.Remove(instanceName, out process); if (process != null) _processRevisions.Remove(process); _statusFiles.Remove(instanceName); _reportedWorkers.Remove(instanceName); } - if (process == null) return; + if (process == null) return false; try { if (!process.HasExited) @@ -132,12 +134,14 @@ private async Task StopWorkerAsync(string instanceName, CancellationToken cancel { if (!process.HasExited) { + _logger.LogWarning("Worker {Instance} did not stop gracefully; terminating it", instanceName); try { process.Kill(true); } catch (InvalidOperationException) { } await process.WaitForExitAsync(CancellationToken.None); } } finally { process.Dispose(); } + return true; } private void CancelRetry(string name) @@ -319,13 +323,7 @@ public Task> GetProcessStatusAsync() result[name] = ReadProcessStatus(name, process); foreach (var (name, config) in _config.ArrInstances) if (IsEligible(config) && !result.ContainsKey(name)) - result[name] = new ProcessStatus - { - Name = name, - Kind = "worker", - IsAlive = false, - Status = _pendingRetries.ContainsKey(name) ? "Backoff" : "Stopped" - }; + result[name] = StoppedStatus(name, _pendingRetries.ContainsKey(name) ? "Backoff" : "Stopped"); } lock (_gate) foreach (var (name, state) in result) @@ -334,11 +332,34 @@ public Task> GetProcessStatusAsync() return Task.FromResult(result); } + private ProcessStatus StoppedStatus(string name, string status) + { + var observation = _lastObservations.GetValueOrDefault(name); + return new ProcessStatus + { + Name = name, + Kind = "worker", + IsAlive = false, + Status = status, + SearchesTriggered = observation?.SearchesTriggered, + ItemsSearched = observation?.ItemsSearched, + SearchSummary = observation?.SearchSummary, + SearchTimestamp = observation?.SearchTimestamp, + CategoryCount = observation?.CategoryCount, + DownloadingCount = observation?.DownloadingCount, + SeedingCount = observation?.SeedingCount, + QueueCount = observation?.QueueCount, + ObservedAt = observation?.ObservedAt, + MetricType = observation?.MetricType, + ObservationError = observation == null ? "Awaiting a worker observation." : "Worker is stopped; showing its last successful observation." + }; + } + private ProcessStatus ReadProcessStatus(string name, Process process) { try { - if (process.HasExited) return new ProcessStatus { Name = name, Kind = "worker", IsAlive = false, Status = "Stopped" }; + if (process.HasExited) return StoppedStatus(name, "Stopped"); var started = process.StartTime.ToUniversalTime(); var now = DateTimeOffset.UtcNow; WorkerHeartbeat? heartbeat = null; @@ -351,12 +372,14 @@ private ProcessStatus ReadProcessStatus(string name, Process process) catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException) { } if (heartbeat != null) { + _lastObservations[name] = heartbeat; _reportedWorkers.Add(name); _reloader?.Acknowledge("worker:" + name, heartbeat.ConfigRevision); } var alive = !process.HasExited && (heartbeat != null ? now - heartbeat.Heartbeat < TimeSpan.FromSeconds(15) && !heartbeat.IsStalled(now) : !_reportedWorkers.Contains(name) && now - started < WorkerLoopProgress.CycleTimeout); + var observed = heartbeat ?? (_lastObservations.TryGetValue(name, out var previous) && previous.Matches(name, process.Id, started) ? previous : null); return new ProcessStatus { Name = name, @@ -366,11 +389,17 @@ private ProcessStatus ReadProcessStatus(string name, Process process) LastHeartbeat = heartbeat?.Heartbeat.UtcDateTime, RestartRequested = heartbeat?.RestartRequested ?? false, ConfigRevision = heartbeat?.ConfigRevision ?? 0, - SearchSummary = heartbeat?.SearchSummary, - SearchTimestamp = heartbeat?.SearchTimestamp, - QueueCount = heartbeat?.QueueCount, - CategoryCount = heartbeat?.CategoryCount, - MetricType = heartbeat?.MetricType, + SearchesTriggered = observed?.SearchesTriggered, + ItemsSearched = observed?.ItemsSearched, + DownloadingCount = observed?.DownloadingCount, + SeedingCount = observed?.SeedingCount, + ObservedAt = observed?.ObservedAt, + ObservationError = heartbeat == null ? "Worker observation is unavailable; retaining the last successful metrics." : heartbeat.ObservationError, + SearchSummary = observed?.SearchSummary, + SearchTimestamp = observed?.SearchTimestamp, + QueueCount = observed?.QueueCount, + CategoryCount = observed?.CategoryCount, + MetricType = observed?.MetricType, Torrent = heartbeat?.Torrent, Search = heartbeat?.Search, Status = !alive ? "Timed out" : heartbeat?.DescribeProgress() ?? "Initializing" @@ -519,22 +548,28 @@ private void ScheduleRetry(string name) _ = task.ContinueWith(_ => { lock (_gate) _retryTasks.Remove(task); }, TaskScheduler.Default); } - internal void MonitorWorkers() + internal async Task MonitorWorkersAsync(CancellationToken ct = default) { - lock (_gate) + await _lifecycle.WaitAsync(ct); + try { - foreach (var (name, process) in _processes.ToArray()) + KeyValuePair[] unhealthy; + lock (_gate) + unhealthy = _processes.Where(p => !p.Value.HasExited) + .Where(p => { var status = ReadProcessStatus(p.Key, p.Value); return !status.IsAlive || status.RestartRequested; }) + .ToArray(); + foreach (var (name, process) in unhealthy) { - if (process.HasExited) continue; - var status = ReadProcessStatus(name, process); - if (!status.IsAlive || status.RestartRequested) + _logger.LogWarning("Worker {Instance} timed out or requested a restart; requesting graceful shutdown", name); + if (!await StopWorkerAsync(name, ct, process)) continue; + lock (_gate) { - _logger.LogWarning("Worker {Instance} timed out or requested a restart; terminating it", name); - try { process.Kill(true); } - catch (InvalidOperationException) { } + _lastFailures[name] = (null, "Worker timed out or requested a restart."); + ScheduleRetry(name); } } } + finally { _lifecycle.Release(); } } internal static ProcessStartInfo ResolveWorkerStartInfo(string instanceName, string? baseDirectory = null) diff --git a/src/Torrentarr.WebUI/Program.cs b/src/Torrentarr.WebUI/Program.cs index 8ced8cee..c8ee7d70 100644 --- a/src/Torrentarr.WebUI/Program.cs +++ b/src/Torrentarr.WebUI/Program.cs @@ -166,6 +166,8 @@ [new OpenApiSecuritySchemeReference("Bearer", document)] = [] builder.Services.AddSingleton(); builder.Services.AddHostedService(sp => sp.GetRequiredService()); builder.Services.AddSingleton(sp => sp.GetRequiredService()); +builder.Services.AddHostedService(); +builder.Services.AddHostedService(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -181,6 +183,11 @@ [new OpenApiSecuritySchemeReference("Bearer", document)] = [] ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator }); builder.Services.AddScoped(); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(sp => sp.GetRequiredService()); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -847,6 +854,7 @@ await InstanceRenamePersistence.SaveAsync(config, updatedConfig, renames, { status = "ok", configReloaded = reloaded, + restartRequirements = Array.Empty(), application = reloader.GetStatus(), reloadType, affectedInstances @@ -1233,11 +1241,11 @@ static bool IsValidLogFileName(string name) => app.MapTorrentMaintenanceEndpoints("/web", standalone: true); app.MapTorrentMaintenanceEndpoints("/api", standalone: true); -app.MapGet("/web/qbit/overview", async (HttpRequest request, TorrentarrConfig cfg, ITorrentClientRegistry clientRegistry, CancellationToken ct) => +app.MapGet("/web/qbit/overview", async (HttpRequest request, TorrentarrConfig cfg, ITorrentClientRegistry clientRegistry, TorrentStatisticsSnapshots snapshots, CancellationToken ct) => { var instance = request.Query["instance"].FirstOrDefault(); await clientRegistry.EnsureAllConnectedAsync(cfg.GetAllTorrentClients(), ct); - return Results.Ok(await QbitOverviewBuilder.BuildAsync(cfg, clientRegistry, instance, ct)); + return Results.Ok(await QbitOverviewBuilder.BuildAsync(cfg, clientRegistry, instance, ct, snapshots)); }); // Radarr movies for specific category @@ -1630,11 +1638,11 @@ static bool IsValidLogFileName(string name) => }); // §6.5: qBit categories — seeding config + live torrent stats per category -app.MapGet("/web/qbit/categories", async (TorrentarrConfig config, ITorrentClientRegistry registry, CancellationToken ct) => +app.MapGet("/web/qbit/categories", async (TorrentarrConfig config, ITorrentClientRegistry registry, TorrentStatisticsSnapshots snapshots, CancellationToken ct) => { await registry.EnsureAllConnectedAsync(config.GetAllTorrentClients(), ct); - var categories = await TorrentClientApiBuilder.CategoriesAsync(config, registry, extendedPolicy: true, ct: ct); - return Results.Ok(new { categories, ready = true }); + var categories = await TorrentClientApiBuilder.CategoriesAsync(config, registry, extendedPolicy: true, ct: ct, snapshots: snapshots); + return Results.Ok(new { categories, ready = true, observations = snapshots.Describe(config, registry) }); }); app.MapGet("/web/config/full", (TorrentarrConfig config, IConfigReloader reloader) => @@ -1708,6 +1716,9 @@ static bool IsValidLogFileName(string name) => return Results.Ok(new { path = reloader.ConfigPath }); }); +app.MapStatisticsEndpoints(); +app.MapTransferHistory(); +app.MapArrActivity(); app.MapArrCatalogEndpoints(); // Meta info — matches frontend MetaResponse interface (includes auth flags) diff --git a/src/Torrentarr.Workers/Program.cs b/src/Torrentarr.Workers/Program.cs index 80e52156..9ff95be9 100644 --- a/src/Torrentarr.Workers/Program.cs +++ b/src/Torrentarr.Workers/Program.cs @@ -324,7 +324,6 @@ class ArrWorkerService : BackgroundService private bool _searchLoopCompleted; private volatile bool _initialized; private bool _qbitSetupComplete; - private readonly Dictionary _categoryCounts = new(StringComparer.OrdinalIgnoreCase); private int _consecutiveErrors = 0; private DateTime _lastErrorTime = DateTime.MinValue; @@ -457,11 +456,22 @@ private async Task RunTorrentLoopAsync(CancellationToken cancellationToken) if (due.Count > 0) await ProcessTorrentsAsync(cycle.Token, due); else await RunPeriodicCommandsAsync(cycle.Token); _consecutiveErrors = 0; - _runtimeMetrics.Torrent = _runtimeMetrics.Torrent with { LastSuccess = DateTimeOffset.UtcNow }; - await ObserveLoopAsync("torrent", true, cycle.Token); - outcome = "success"; + if (due.Count > 0) + { + _runtimeMetrics.Torrent = _runtimeMetrics.Torrent with { LastSuccess = DateTimeOffset.UtcNow }; + await ObserveLoopAsync("torrent", true, cycle.Token); + } + outcome = due.Count > 0 ? "success" : "idle"; } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { break; } + catch (TorrentProcessingUnavailableException ex) + { + outcome = "waiting for torrent client"; + _logger.LogInformation("Torrent processing deferred for {Instance}: {Reason}", _context.InstanceName, ex.Message); + await ObserveLoopAsync("torrent", false, cancellationToken); + await SleepLoopAsync("Torrent", "Waiting for client", TimeSpan.FromSeconds(5), cancellationToken, connectionRevision); + continue; + } catch (ConfigurationBusyException) { outcome = "configuration pending"; @@ -664,6 +674,8 @@ private Task ObserveLoopAsync(string loop, bool healthy, CancellationToken ct) return healthy ? reporter.ResolveAsync(issue, ct) : reporter.ReportAsync(issue, ct); } + private readonly Dictionary _torrentCounts = new(StringComparer.OrdinalIgnoreCase); + internal async Task ProcessTorrentsAsync(CancellationToken cancellationToken, IReadOnlySet? clientIds = null) { _logger.LogTrace("Processing torrents for {Instance}", _context.InstanceName); @@ -700,21 +712,42 @@ internal async Task ProcessTorrentsAsync(CancellationToken cancellationToken, IR try { + var configured = _config.GetAllTorrentClients().Where(pair => !pair.Value.Disabled && !string.IsNullOrWhiteSpace(pair.Value.Host) && pair.Value.Host != "CHANGE_ME").Select(pair => pair.Key).ToHashSet(StringComparer.OrdinalIgnoreCase); + foreach (var id in _torrentCounts.Keys.Where(id => !configured.Contains(id)).ToList()) _torrentCounts.Remove(id); var clients = _qbitManager.GetAllClients(); - foreach (var id in _categoryCounts.Keys.Where(id => !clients.ContainsKey(id)).ToList()) _categoryCounts.Remove(id); - foreach (var (id, client) in clients.Where(pair => clientIds == null || clientIds.Contains(pair.Key))) - try { _categoryCounts[id] = (await client.GetTorrentsAsync(_instanceConfig.Category, cancellationToken: cancellationToken)).Count; } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } - catch { } - _runtimeMetrics.CategoryCount = _categoryCounts.Values.Sum(); + foreach (var id in configured.Where(id => clientIds == null || clientIds.Contains(id))) + { + if (!clients.TryGetValue(id, out var client)) throw new IOException($"Client {id} is unavailable."); + var torrents = await client.GetTorrentsAsync(cancellationToken: cancellationToken); + foreach (var torrent in torrents) torrent.QBitInstanceName = id; + var owned = torrents.Where(torrent => CategoryOwnershipHelper.ArrOwnsTorrent(_config, _instanceConfig, torrent)).ToList(); + _torrentCounts[id] = (owned.Count, owned.Count(t => TorrentPolicyHelper.IsActiveDownloadingStateForFreeSpace(t.State)), owned.Count(t => t.IsSeeding), DateTimeOffset.UtcNow); + } + if (configured.Any(id => !_torrentCounts.ContainsKey(id))) throw new IOException("Awaiting a complete client inventory."); + _runtimeMetrics.CategoryCount = _torrentCounts.Values.Sum(value => value.Total); + _runtimeMetrics.DownloadingCount = _torrentCounts.Values.Sum(value => value.Downloading); + _runtimeMetrics.SeedingCount = _torrentCounts.Values.Sum(value => value.Seeding); + _runtimeMetrics.ObservedAt = _torrentCounts.Count > 0 ? _torrentCounts.Values.Min(value => value.ObservedAt) : DateTimeOffset.UtcNow; + _runtimeMetrics.ObservationError = null; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + _runtimeMetrics.ObservationError = "Unable to refresh torrent activity; showing the last successful observation."; + _logger.LogDebug(ex, "Unable to update process metrics for {Instance}", _context.InstanceName); + } - var db = scope.ServiceProvider.GetRequiredService(); + try + { _runtimeMetrics.QueueCount = _instanceConfig.Type.ToLowerInvariant() switch { - "radarr" => await db.MovieQueue.CountAsync(q => q.ArrInstance == _context.InstanceName, cancellationToken), - "sonarr" => await db.EpisodeQueue.CountAsync(q => q.ArrInstance == _context.InstanceName, cancellationToken), - "lidarr" => await db.AlbumQueue.CountAsync(q => q.ArrInstance == _context.InstanceName, cancellationToken), - "readarr" => await db.BookQueue.CountAsync(q => q.ArrInstance == _context.InstanceName, cancellationToken), + "radarr" => (await new Torrentarr.Infrastructure.ApiClients.Arr.RadarrClient(_instanceConfig.URI, _instanceConfig.APIKey, _instanceConfig.SkipTLSVerify).GetQueueAsync(ct: cancellationToken)).TotalRecords, + "sonarr" => (await new Torrentarr.Infrastructure.ApiClients.Arr.SonarrClient(_instanceConfig.URI, _instanceConfig.APIKey, _instanceConfig.SkipTLSVerify).GetQueueAsync(ct: cancellationToken)).TotalRecords, + "lidarr" => (await new Torrentarr.Infrastructure.ApiClients.Arr.LidarrClient(_instanceConfig.URI, _instanceConfig.APIKey, _instanceConfig.SkipTLSVerify).GetQueueAsync(ct: cancellationToken)).TotalRecords, + "readarr" => (await new Torrentarr.Infrastructure.ApiClients.Arr.ReadarrClient(_instanceConfig.URI, _instanceConfig.APIKey, _instanceConfig.SkipTLSVerify).GetQueueAsync(ct: cancellationToken)).TotalRecords, _ => null }; } @@ -724,7 +757,8 @@ internal async Task ProcessTorrentsAsync(CancellationToken cancellationToken, IR } catch (Exception ex) { - _logger.LogDebug(ex, "Unable to update process metrics for {Instance}", _context.InstanceName); + _runtimeMetrics.QueueCount = null; + _logger.LogDebug(ex, "Unable to read Arr queue depth for {Instance}", _context.InstanceName); } if (!_instanceConfig.SearchOnly) @@ -876,21 +910,21 @@ private async Task ResetSearchedFlagsAsync(TorrentarrDbContext db, CancellationT { case "radarr": await db.Movies.Where(x => x.ArrInstance == _context.InstanceName && x.Searched) - .ExecuteUpdateAsync(s => s.SetProperty(x => x.Searched, false).SetProperty(x => x.Upgrade, false), ct); + .ExecuteUpdateWithRetryAsync(s => s.SetProperty(x => x.Searched, false).SetProperty(x => x.Upgrade, false), _logger, ct); break; case "sonarr": await db.Episodes.Where(x => x.ArrInstance == _context.InstanceName && x.Searched) - .ExecuteUpdateAsync(s => s.SetProperty(x => x.Searched, false).SetProperty(x => x.Upgrade, false), ct); + .ExecuteUpdateWithRetryAsync(s => s.SetProperty(x => x.Searched, false).SetProperty(x => x.Upgrade, false), _logger, ct); await db.Series.Where(x => x.ArrInstance == _context.InstanceName && x.Searched) - .ExecuteUpdateAsync(s => s.SetProperty(x => x.Searched, false).SetProperty(x => x.Upgrade, false), ct); + .ExecuteUpdateWithRetryAsync(s => s.SetProperty(x => x.Searched, false).SetProperty(x => x.Upgrade, false), _logger, ct); break; case "lidarr": await db.Albums.Where(x => x.ArrInstance == _context.InstanceName && x.Searched) - .ExecuteUpdateAsync(s => s.SetProperty(x => x.Searched, false).SetProperty(x => x.Upgrade, false), ct); + .ExecuteUpdateWithRetryAsync(s => s.SetProperty(x => x.Searched, false).SetProperty(x => x.Upgrade, false), _logger, ct); break; case "readarr": await db.Books.Where(x => x.ArrInstance == _context.InstanceName && x.Searched) - .ExecuteUpdateAsync(s => s.SetProperty(x => x.Searched, false).SetProperty(x => x.Upgrade, false), ct); + .ExecuteUpdateWithRetryAsync(s => s.SetProperty(x => x.Searched, false).SetProperty(x => x.Upgrade, false), _logger, ct); break; } } @@ -1054,6 +1088,12 @@ sealed class WorkerRuntimeMetrics Heartbeat = DateTimeOffset.UtcNow, RestartRequested = restartRequested, ConfigRevision = Math.Min(Interlocked.Read(ref TorrentRevision), Interlocked.Read(ref SearchRevision)), + SearchesTriggered = SearchesTriggered, + ItemsSearched = ItemsSearched, + DownloadingCount = DownloadingCount, + SeedingCount = SeedingCount, + ObservedAt = ObservedAt, + ObservationError = ObservationError, SearchSummary = SearchSummary, SearchTimestamp = SearchTimestamp, QueueCount = QueueCount, @@ -1066,11 +1106,19 @@ sealed class WorkerRuntimeMetrics public void RecordSearch(SearchResult? result) { if (result == null) return; + SearchesTriggered = result.SearchesTriggered; + ItemsSearched = result.ItemsSearched; SearchSummary = $"{result.SearchesTriggered} searches triggered ({result.ItemsSearched} items)"; SearchTimestamp = DateTimeOffset.UtcNow.ToString("O"); MetricType = "search"; } + public int? SearchesTriggered { get; set; } + public int? ItemsSearched { get; set; } + public int? DownloadingCount { get; set; } + public int? SeedingCount { get; set; } + public DateTimeOffset? ObservedAt { get; set; } + public string? ObservationError { get; set; } public string? SearchSummary { get; set; } public string? SearchTimestamp { get; set; } public int? QueueCount { get; set; } diff --git a/tests/Torrentarr.Core.Tests/Configuration/ConfigSchemaBuilderTests.cs b/tests/Torrentarr.Core.Tests/Configuration/ConfigSchemaBuilderTests.cs index 2c0f5e98..0f364605 100644 --- a/tests/Torrentarr.Core.Tests/Configuration/ConfigSchemaBuilderTests.cs +++ b/tests/Torrentarr.Core.Tests/Configuration/ConfigSchemaBuilderTests.cs @@ -16,6 +16,14 @@ public void SonarrSearchMode_ExposesFourNamedOptions() mode.@default.Should().Be("smart"); } + [Fact] + public void Theme_DefaultsToSystemWithAllOptions() + { + var field = ConfigSchemaBuilder.Sections["WebUI"].Single(f => f.key == "Theme"); + field.@default.Should().Be("System"); + field.options.Should().Equal("System", "Light", "Dark", "Midnight"); + } + [Fact] public void MaintenanceRegistry_CoversEveryPersistentPropertyAndCollectionItem() { @@ -113,4 +121,11 @@ public void Registry_HasNoImpossibleNumericBounds() .Where(field => field.minimum.HasValue && field.maximum.HasValue) .Should().OnlyContain(field => field.minimum <= field.maximum); } + [Fact] + public void RuntimeFields_DoNotInheritQbitrrRestartRequirements() + { + ConfigSchemaBuilder.Sections.SelectMany(section => section.Value) + .Should().NotContain(field => field.requiresRestart == true); + } + } diff --git a/tests/Torrentarr.Core.Tests/Configuration/ConfigurationLoaderTests.cs b/tests/Torrentarr.Core.Tests/Configuration/ConfigurationLoaderTests.cs index 7b0a1dfb..284584d4 100644 --- a/tests/Torrentarr.Core.Tests/Configuration/ConfigurationLoaderTests.cs +++ b/tests/Torrentarr.Core.Tests/Configuration/ConfigurationLoaderTests.cs @@ -301,7 +301,7 @@ public void Load_ReturnsSectionDefaults_WhenSectionMissing() // WebUI defaults when [WebUI] omitted config.WebUI.Port.Should().Be(6969); - config.WebUI.Theme.Should().Be("Dark"); + config.WebUI.Theme.Should().Be("System"); config.WebUI.LiveArr.Should().BeTrue(); } @@ -567,7 +567,7 @@ public void Load_ValidateAndFill_FillsMissingWebUIDefaults() config.WebUI.Host.Should().Be("0.0.0.0"); config.WebUI.Port.Should().Be(6969); - config.WebUI.Theme.Should().Be("Dark"); + config.WebUI.Theme.Should().Be("System"); config.WebUI.ViewDensity.Should().Be("Comfortable"); config.WebUI.LiveArr.Should().BeTrue(); // Filled WebUI auth defaults must match GenerateDefaultConfig / qBitrr: auth on, local login opt-in. @@ -606,6 +606,24 @@ public void Load_ValidateAndFill_NormalizesLightTheme() config.WebUI.ViewDensity.Should().Be("Comfortable"); } + [Fact] + public void Load_ValidateAndFill_NormalizesSystemAndMidnightThemes() + { + WriteToml(""" + [WebUI] + Theme = "system" + """); + + new ConfigurationLoader(_tempFilePath).Load().WebUI.Theme.Should().Be("System"); + + WriteToml(""" + [WebUI] + Theme = "MIDNIGHT" + """); + + new ConfigurationLoader(_tempFilePath).Load().WebUI.Theme.Should().Be("Midnight"); + } + [Fact] public void Load_ValidateAndFill_FillsQBitDefaults() { diff --git a/tests/Torrentarr.Core.Tests/Configuration/StorageMigrationTests.cs b/tests/Torrentarr.Core.Tests/Configuration/StorageMigrationTests.cs index f7b417ad..516dfea9 100644 --- a/tests/Torrentarr.Core.Tests/Configuration/StorageMigrationTests.cs +++ b/tests/Torrentarr.Core.Tests/Configuration/StorageMigrationTests.cs @@ -174,6 +174,32 @@ public void MultipleClientsUseTheirOwnPaths_AndWarnForAmbiguousPaths() config.QBitInstances["qBit-one"].FreeSpace.Single().Enabled.Should().BeTrue(); config.TorrentClients["two"].DownloadPath.Should().BeNull(); config.TorrentClients["two"].FreeSpace.Should().OnlyContain(m => !m.Enabled); + config.QBitInstances["qBit-one"].Migration.Notices.Should().BeEmpty(); + config.TorrentClients["two"].Migration.Notices.Should().ContainSingle().Which.Should().Contain("ambiguous"); + var persisted = File.ReadAllText(_file); + var loader = new ConfigurationLoader(_file); + loader.Load().TorrentClients["two"].Migration.Notices.Should().Equal(config.TorrentClients["two"].Migration.Notices); + File.ReadAllText(_file).Should().Be(persisted); + config.TorrentClients["two"].Migration.Notices.Clear(); + loader.SaveConfig(config); + loader.Load().TorrentClients["two"].Migration.Notices.Should().BeEmpty(); + } + + [Theory] + [InlineData("qBit")] + [InlineData("TorrentClient.named")] + public void MissingLegacyStoragePaths_PersistAnActionableMigrationNotice(string section) + { + var config = Load($""" + [Settings] + FreeSpace = "10G" + [{section}] + Host = "localhost" + """); + var client = config.GetAllTorrentClients().Values.Single(); + client.FreeSpace.Should().BeEmpty(); + client.Migration.Notices.Should().ContainSingle().Which.Should().Contain("free-space protection remains disabled until configured"); + new ConfigurationLoader(_file).Load().GetAllTorrentClients().Values.Single().Migration.Notices.Should().Equal(client.Migration.Notices); } [Theory] diff --git a/tests/Torrentarr.Core.Tests/Configuration/TorrentClientMaintenanceTests.cs b/tests/Torrentarr.Core.Tests/Configuration/TorrentClientMaintenanceTests.cs index 8510714f..a17466ba 100644 --- a/tests/Torrentarr.Core.Tests/Configuration/TorrentClientMaintenanceTests.cs +++ b/tests/Torrentarr.Core.Tests/Configuration/TorrentClientMaintenanceTests.cs @@ -7,6 +7,13 @@ namespace Torrentarr.Core.Tests.Configuration; public sealed class TorrentClientMaintenanceTests { + [Theory] + [InlineData(Torrentarr.Core.Models.MaintenanceOperation.CategoryReconcile)] + [InlineData(Torrentarr.Core.Models.MaintenanceOperation.AutomaticManagement)] + [InlineData(Torrentarr.Core.Models.MaintenanceOperation.RepairPaused)] + public void NonDeletionMaintenance_DoesNotRequireHnrEvidence(Torrentarr.Core.Models.MaintenanceOperation operation) + => MaintenanceRequirements.For(operation, new()).Should().Be(Torrentarr.Core.Models.InventoryRequirements.Metadata); + [Fact] public void CanonicalClient_RoundTripsWithoutChangingLegacySections() { diff --git a/tests/Torrentarr.Core.Tests/Configuration/TorrentarrConfigDefaultsTests.cs b/tests/Torrentarr.Core.Tests/Configuration/TorrentarrConfigDefaultsTests.cs index 7299c567..63072aab 100644 --- a/tests/Torrentarr.Core.Tests/Configuration/TorrentarrConfigDefaultsTests.cs +++ b/tests/Torrentarr.Core.Tests/Configuration/TorrentarrConfigDefaultsTests.cs @@ -47,7 +47,7 @@ public void WebUI_HasExpectedDefaults() var config = new TorrentarrConfig(); config.WebUI.Port.Should().Be(6969); - config.WebUI.Theme.Should().Be("Dark"); + config.WebUI.Theme.Should().Be("System"); config.WebUI.LiveArr.Should().BeTrue(); config.WebUI.LocalAuthEnabled.Should().BeFalse(); config.WebUI.AllowInsecureTokenQuery.Should().BeNull(); diff --git a/tests/Torrentarr.Host.Tests/Api/ConfigEndpointTests.cs b/tests/Torrentarr.Host.Tests/Api/ConfigEndpointTests.cs index bffa4c71..fb48139e 100644 --- a/tests/Torrentarr.Host.Tests/Api/ConfigEndpointTests.cs +++ b/tests/Torrentarr.Host.Tests/Api/ConfigEndpointTests.cs @@ -256,6 +256,7 @@ public async Task PostConfig_ReturnsReloadType_None_ForWebuiKeys() response.StatusCode.Should().Be(HttpStatusCode.OK); var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement; json.GetProperty("reloadType").GetString().Should().Be("live"); + json.GetProperty("restartRequirements").GetArrayLength().Should().Be(0); } [Fact] @@ -276,6 +277,7 @@ public async Task PostConfig_ReturnsReloadType_Webui_ForSettingsKeys() response.StatusCode.Should().Be(HttpStatusCode.OK); var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement; json.GetProperty("reloadType").GetString().Should().Be("live"); + json.GetProperty("restartRequirements").GetArrayLength().Should().Be(0); } [Fact] @@ -296,6 +298,7 @@ public async Task PostConfig_ReturnsReloadType_Full_ForQbitKeys() response.StatusCode.Should().Be(HttpStatusCode.OK); var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement; json.GetProperty("reloadType").GetString().Should().Be("live"); + json.GetProperty("restartRequirements").GetArrayLength().Should().Be(0); } [Fact] diff --git a/tests/Torrentarr.Host.Tests/Api/ConfigurationEventEndpointTests.cs b/tests/Torrentarr.Host.Tests/Api/ConfigurationEventEndpointTests.cs index bcabc3e6..1e8d4940 100644 --- a/tests/Torrentarr.Host.Tests/Api/ConfigurationEventEndpointTests.cs +++ b/tests/Torrentarr.Host.Tests/Api/ConfigurationEventEndpointTests.cs @@ -36,13 +36,15 @@ public async Task Events_SendInitialSnapshotAndAcknowledgments_WithoutHoldingCon [Collection("HostWebAuth")] public class ConfigurationEventAuthenticationTests(AuthEnabledWebApplicationFactory factory) { - [Fact] - public async Task Events_RequireAuthentication() + [Theory] + [InlineData("/web/config/events")] + [InlineData("/web/stats/events")] + public async Task Events_RequireAuthentication(string endpoint) { using var client = factory.CreateClient(new Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); client.DefaultRequestHeaders.Accept.ParseAdd("application/json"); using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); - using var response = await client.GetAsync("/web/config/events", HttpCompletionOption.ResponseHeadersRead, timeout.Token); + using var response = await client.GetAsync(endpoint, HttpCompletionOption.ResponseHeadersRead, timeout.Token); response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); } } diff --git a/tests/Torrentarr.Host.Tests/Api/ProcessesEndpointTests.cs b/tests/Torrentarr.Host.Tests/Api/ProcessesEndpointTests.cs index ed9807ee..71c2d37b 100644 --- a/tests/Torrentarr.Host.Tests/Api/ProcessesEndpointTests.cs +++ b/tests/Torrentarr.Host.Tests/Api/ProcessesEndpointTests.cs @@ -17,6 +17,38 @@ public ProcessesEndpointTests(TorrentarrWebApplicationFactory factory) _factory = factory; } + [Theory] + [InlineData("/web/processes")] + [InlineData("/api/processes")] + public async Task ActivityMetrics_AreNumeric_AndZeroIsDifferentFromMissing(string endpoint) + { + var states = _factory.Services.GetRequiredService(); + states.Initialize("metric-test", new ArrProcessState { Name = "metric-test", Kind = "search", SearchesTriggered = 0, ItemsSearched = 12 }); + using var client = _factory.CreateClientWithApiToken(); + using var response = await client.GetAsync(endpoint); + using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + var item = json.RootElement.GetProperty("processes").EnumerateArray().Single(p => p.GetProperty("name").GetString() == "metric-test"); + item.GetProperty("searchesTriggered").GetInt32().Should().Be(0); + item.GetProperty("itemsSearched").GetInt32().Should().Be(12); + item.GetProperty("downloadingCount").ValueKind.Should().Be(JsonValueKind.Null); + } + + [Fact] + public async Task StatisticsStream_SendsInitialRevision_AndSubsequentInvalidation() + { + using var client = _factory.CreateClientWithApiToken(); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + using var response = await client.GetAsync("/web/stats/events", HttpCompletionOption.ResponseHeadersRead, timeout.Token); + response.EnsureSuccessStatusCode(); + response.Content.Headers.ContentType!.MediaType.Should().Be("text/event-stream"); + using var reader = new StreamReader(await response.Content.ReadAsStreamAsync(timeout.Token)); + (await reader.ReadLineAsync(timeout.Token)).Should().StartWith("data: "); + await reader.ReadLineAsync(timeout.Token); + using (Torrentarr.Core.Configuration.ConfigMutationGuard.EnterMutation(_factory.TempConfigPath)) + _factory.Services.GetRequiredService().Publish(); + (await reader.ReadLineAsync(timeout.Token)).Should().StartWith("data: "); + } + [Theory] [InlineData("/web/processes")] [InlineData("/api/processes")] diff --git a/tests/Torrentarr.Host.Tests/Api/StatisticsEndpointTests.cs b/tests/Torrentarr.Host.Tests/Api/StatisticsEndpointTests.cs new file mode 100644 index 00000000..89f506c2 --- /dev/null +++ b/tests/Torrentarr.Host.Tests/Api/StatisticsEndpointTests.cs @@ -0,0 +1,96 @@ +using FluentAssertions; +using System.Net; +using Microsoft.Extensions.DependencyInjection; +using Torrentarr.Infrastructure.Database; +using System.Text.Json; +using Xunit; + +namespace Torrentarr.Host.Tests.Api; + +[Collection("HostWeb")] +public class StatisticsEndpointTests(TorrentarrWebApplicationFactory factory) +{ + [Theory] + [InlineData("/web/statistics/transfers?range=invalid")] + [InlineData("/web/statistics/arr-activity?range=invalid")] + [InlineData("/web/statistics/arr-activity?worker=does-not-exist")] + [InlineData("/web/statistics/transfers?instance=does-not-exist")] + [InlineData("/web/statistics/catalog?kind=invalid")] + [InlineData("/web/statistics/catalog?kind=radarr&instance=does-not-exist")] + public async Task RejectsInvalidSelections(string path) + { + factory.SetConfigEnv(); + using var client = factory.CreateClientWithApiToken(); + (await client.GetAsync(path)).StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task Transfers_DefaultsTo24HoursAndEmptySeriesWithoutClients() + { + factory.SetConfigEnv(); + using var client = factory.CreateClientWithApiToken(); + var response = await client.GetAsync("/web/statistics/transfers"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + json.RootElement.GetProperty("range").GetString().Should().Be("24h"); + json.RootElement.GetProperty("bucketMinutes").GetInt32().Should().Be(5); + } + [Fact] + public async Task ArrActivity_ReturnsBoundedTypedHistory() + { + factory.SetConfigEnv(); using var client = factory.CreateClientWithApiToken(); + var response = await client.GetAsync("/web/statistics/arr-activity?range=1h"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + json.RootElement.GetProperty("bucketMinutes").GetInt32().Should().Be(1); + json.RootElement.GetProperty("series").ValueKind.Should().Be(JsonValueKind.Array); + var daily = await client.GetAsync("/web/statistics/arr-activity?range=24h"); + using var dailyJson = JsonDocument.Parse(await daily.Content.ReadAsStringAsync()); + dailyJson.RootElement.GetProperty("bucketMinutes").GetInt32().Should().Be(60); + } + +} + +[Collection("HostWebAuth")] +public class StatisticsAuthTests(AuthEnabledWebApplicationFactory factory) +{ + [Theory] + [InlineData("/web/statistics/transfers")] + [InlineData("/web/statistics/arr-activity")] + [InlineData("/web/statistics/catalog?kind=radarr")] + public async Task RequiresConfiguredAuthentication(string path) + { + factory.SetConfigEnv(); + using var client = factory.CreateClientWithApiTokenNoRedirect(); + client.DefaultRequestHeaders.Authorization = null; + var response = await client.GetAsync(path); + response.StatusCode.Should().Be(HttpStatusCode.Redirect); + response.Headers.Location!.OriginalString.Should().Contain("/login"); + } +} + +[Collection("HostWebCatalog")] +public class CatalogStatisticsEndpointTests(ArrCatalogWebApplicationFactory factory) +{ + [Theory] + [InlineData("radarr", "Radarr", "movies")] + [InlineData("sonarr", "Sonarr", "episodes")] + [InlineData("lidarr", "Lidarr", "albums")] + [InlineData("readarr", "Readarr-Books", "books")] + public async Task ReturnsCompleteSelectedLibraryCounts(string kind, string instance, string unit) + { + factory.SetConfigEnv(); + using var client = factory.CreateClientWithApiToken(); + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await CatalogTestDataSeeder.SeedAllCatalogDataAsync(db); + await CatalogTestDataSeeder.SeedReadarrAuthorsAsync(db, clearFirst: false); + var response = await client.GetAsync($"/web/statistics/catalog?kind={kind}&instance={instance}"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + json.RootElement.GetProperty("unit").GetString().Should().Be(unit); + json.RootElement.GetProperty("available").GetInt32().Should().Be(1); + json.RootElement.GetProperty("monitored").GetInt32().Should().Be(2); + json.RootElement.GetProperty("missing").GetInt32().Should().Be(1); + } +} diff --git a/tests/Torrentarr.Host.Tests/ProcessOrchestratorServiceTests.cs b/tests/Torrentarr.Host.Tests/ProcessOrchestratorServiceTests.cs index b2a3228b..fa9bb181 100644 --- a/tests/Torrentarr.Host.Tests/ProcessOrchestratorServiceTests.cs +++ b/tests/Torrentarr.Host.Tests/ProcessOrchestratorServiceTests.cs @@ -15,6 +15,32 @@ namespace Torrentarr.Host.Tests; public class ProcessOrchestratorServiceTests { + [Fact] + public async Task TrackerSort_ShutdownCancellationPropagatesWithoutErrorOrFallback() + { + using var cancellation = new CancellationTokenSource(); cancellation.Cancel(); + var client = new Mock(); + client.Setup(c => c.GetTorrentsAsync(null, "priority", It.IsAny())) + .ThrowsAsync(new OperationCanceledException(cancellation.Token)); + var registry = new Mock(); + registry.Setup(r => r.GetAllClients()).Returns(new Dictionary { ["one"] = client.Object }); + var entries = new Mock(); + var factory = new Mock(); + factory.Setup(f => f.CreateLogger(It.IsAny())).Returns(entries.Object); + var logger = new Microsoft.Extensions.Logging.Logger(factory.Object); + using var provider = new ServiceCollection().BuildServiceProvider(); + using var service = new ProcessOrchestratorService(logger, new(), registry.Object, + provider.GetRequiredService(), new ProcessStateManager(), Mock.Of()); + var method = typeof(ProcessOrchestratorService).GetMethod("SortManagedTorrentsByTrackerPriorityAsync", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!; + await FluentActions.Awaiting(() => (Task)method.Invoke(service, + [Mock.Of(), new HashSet { "movies" }, new HashSet { "one" }, cancellation.Token])!) + .Should().ThrowAsync(); + client.Verify(c => c.GetTorrentsAsync(null, "added_on", It.IsAny()), Times.Never()); + entries.Invocations.Where(i => i.Method.Name == "Log").Should().NotContain(i => + (Microsoft.Extensions.Logging.LogLevel)i.Arguments[0]! == Microsoft.Extensions.Logging.LogLevel.Error); + } + [Fact] public async Task FreeSpace_ConfigSaveWakesSingleManagerAndRefreshesClientMonitors() { diff --git a/tests/Torrentarr.Host.Tests/WorkerLogEventSinkTests.cs b/tests/Torrentarr.Host.Tests/WorkerLogEventSinkTests.cs index a7132a8e..19024e8d 100644 --- a/tests/Torrentarr.Host.Tests/WorkerLogEventSinkTests.cs +++ b/tests/Torrentarr.Host.Tests/WorkerLogEventSinkTests.cs @@ -86,13 +86,15 @@ public void Exceptions_AreWrittenWithoutChangingRoutingOrPrefix(string message, finally { directory.Delete(true); } } [Fact] - public void RestartAppends_AndKeepsExistingHistory() + public void RestartAppends_KeepsRecentHistory_AndExpiresOldFiles() { var directory = Directory.CreateTempSubdirectory("torrentarr-log-restart-"); try { - var older = Path.Combine(directory.FullName, "torrentarr-20200101.log"); + var older = Path.Combine(directory.FullName, $"torrentarr-{DateTime.Now.AddDays(-1):yyyyMMdd}.log"); + var expired = Path.Combine(directory.FullName, "torrentarr-20200101.log"); File.WriteAllText(older, "old history\n"); + File.WriteAllText(expired, "expired history\n"); for (var i = 0; i < 2; i++) { using var sink = new WorkerLogEventSink(directory.FullName); @@ -104,6 +106,7 @@ public void RestartAppends_AndKeepsExistingHistory() var service = Directory.GetFiles(directory.FullName, "torrentarr-*.log").Single(p => p != older); File.ReadAllText(service).Should().Be(File.ReadAllText(aggregate)); File.ReadAllText(older).Should().Be("old history\n"); + File.Exists(expired).Should().BeFalse(); Directory.GetFiles(directory.FullName, "*.old").Should().BeEmpty(); } finally { directory.Delete(true); } @@ -232,8 +235,11 @@ await Task.WhenAll(processes.Select(async process => lines.Count(line => line.StartsWith("System.IO.IOException: concurrent-")).Should().Be(8); lines.Count(line => line.Contains("InvalidOperationException: inner cause")).Should().Be(8); Directory.GetFiles(directory.FullName, "worker-test-*.log").Should().HaveCount(2); + var serviceRecords = Directory.GetFiles(directory.FullName, "worker-test-*.log") + .SelectMany(File.ReadAllLines).Where(line => line.Contains("]entry-")).ToArray(); + serviceRecords.Should().BeEquivalentTo(records); var rolled = Directory.GetFiles(Path.Combine(directory.FullName, "rollover"), "all-*.log"); - rolled.Should().HaveCount(7); + rolled.Should().HaveCount(7, "the seven-day age limit applies before the eight-file maximum"); rolled.Select(Path.GetFileName).Should().BeEquivalentTo(Enumerable.Range(4, 7).Select(day => $"all-202601{day:00}.log")); foreach (var file in rolled) File.ReadAllLines(file).Should().ContainSingle(); } diff --git a/tests/Torrentarr.Infrastructure.Tests/ApiClients/ArrClientResponseTests.cs b/tests/Torrentarr.Infrastructure.Tests/ApiClients/ArrClientResponseTests.cs index 0f9baff4..fac3f680 100644 --- a/tests/Torrentarr.Infrastructure.Tests/ApiClients/ArrClientResponseTests.cs +++ b/tests/Torrentarr.Infrastructure.Tests/ApiClients/ArrClientResponseTests.cs @@ -25,14 +25,17 @@ public void EnsureSuccess_ThrowsArrApiException_OnHttpFailure() .Which.StatusCode.Should().Be(HttpStatusCode.ServiceUnavailable); } - [Fact] - public void EnsureSuccess_DoesNotThrow_OnSuccess() + [Theory] + [InlineData(HttpStatusCode.OK)] + [InlineData(HttpStatusCode.Created)] + [InlineData(HttpStatusCode.Accepted)] + public void EnsureSuccess_DoesNotThrow_On2xx(HttpStatusCode statusCode) { var response = new RestResponse { ResponseStatus = ResponseStatus.Completed, - StatusCode = HttpStatusCode.OK, - StatusDescription = "OK", + StatusCode = statusCode, + StatusDescription = statusCode.ToString(), Content = "[]" }; diff --git a/tests/Torrentarr.Infrastructure.Tests/ApiClients/ArrQualityProfileJsonTests.cs b/tests/Torrentarr.Infrastructure.Tests/ApiClients/ArrQualityProfileJsonTests.cs new file mode 100644 index 00000000..390562be --- /dev/null +++ b/tests/Torrentarr.Infrastructure.Tests/ApiClients/ArrQualityProfileJsonTests.cs @@ -0,0 +1,22 @@ +using FluentAssertions; +using Newtonsoft.Json; +using Torrentarr.Infrastructure.ApiClients.Arr; +using Xunit; + +namespace Torrentarr.Infrastructure.Tests.ApiClients; + +public sealed class ArrQualityProfileJsonTests +{ + [Fact] + public void QualityProfile_ReadsMinFormatScore() + { + const string json = """ + {"id":1,"name":"HD-1080p","minFormatScore":100,"cutoffFormatScore":50} + """; + + var profile = JsonConvert.DeserializeObject(json); + + profile.Should().NotBeNull(); + profile!.MinCustomFormatScore.Should().Be(100); + } +} diff --git a/tests/Torrentarr.Infrastructure.Tests/ApiClients/SonarrApiDiscoveryTests.cs b/tests/Torrentarr.Infrastructure.Tests/ApiClients/SonarrApiDiscoveryTests.cs index 7ace5315..156a1cc8 100644 --- a/tests/Torrentarr.Infrastructure.Tests/ApiClients/SonarrApiDiscoveryTests.cs +++ b/tests/Torrentarr.Infrastructure.Tests/ApiClients/SonarrApiDiscoveryTests.cs @@ -39,6 +39,8 @@ public sealed class SonarrApiDiscoveryTests : IDisposable private string? _queueJson; private int _seriesProfile = 1; private string _commandsJson = "[]"; + private string _historyJson = "[]"; + private string? _commandResponseJson; private bool _limitAfterFirstSearch; private readonly string _key = Guid.NewGuid().ToString(); @@ -60,6 +62,45 @@ public SonarrApiDiscoveryTests() _loop = Task.Run(ListenAsync); } + + [Theory] + [InlineData("radarr", "v3")] + [InlineData("sonarr", "v3")] + [InlineData("sonarr", "v5")] + [InlineData("lidarr", "v1")] + [InlineData("readarr", "v1")] + public async Task ActivityEvidence_UsesClientApiAndPreservesBasePath(string type, string version) + { + _version = version; + _commandsJson = "[{\"id\":42,\"status\":\"completed\"}]"; + _historyJson = "[{\"id\":99,\"downloadId\":\"HASH\",\"eventType\":\"downloadFolderImported\"}]"; + IArrActivityClient client = type switch + { + "radarr" => new RadarrClient(_url, _key), + "sonarr" => new SonarrClient(_url, _key), + "lidarr" => new LidarrClient(_url, _key), + _ => new ReadarrClient(_url, _key) + }; + (await client.ReadActivityAsync("command", default)).Single()["id"]!.Value().Should().Be(42); + (await client.ReadActivityAsync("history/since?date=2026-11-04T12%3A00%3A00Z&eventType=3", default)).Single()["id"]!.Value().Should().Be(99); + _requests.Should().Contain(r => r.Path.StartsWith($"/sonarr/api/{version}/history/since?") && r.Path.Contains("eventType=3")); + } + + [Theory] + [InlineData("v3")] + [InlineData("v5")] + public async Task SearchTracking_CapturesRealAcceptedCommandWithoutChangingBooleanApi(string version) + { + _version = version; _commandResponseJson = "{\"id\":42}"; + using var db = new TorrentarrDbContext(new DbContextOptionsBuilder().UseSqlite("Data Source=:memory:").Options); + await db.Database.OpenConnectionAsync(); await db.Database.EnsureCreatedAsync(); + var config = new ArrInstanceConfig { Type = "sonarr", URI = _url, APIKey = _key }; + using (ArrActivityTracking.Begin(db, NullLogger.Instance, "Sonarr-test", config, "search")) + (await new SonarrClient(_url, _key).SearchEpisodeAsync([1, 2])).Should().BeTrue(); + var operation = await db.ArrActivityOperations.SingleAsync(); + operation.CommandId.Should().Be(42); operation.Instance.Should().Be("Sonarr-test"); operation.ConfirmedAt.Should().BeNull(); + } + [Theory] [InlineData("v3")] [InlineData("v5")] @@ -869,6 +910,8 @@ private async Task ListenAsync() var json = relative == "" ? _discoveryJson ?? $"{{\"current\":\"{_version}\"}}" : relative.EndsWith("/system/status") ? _statusJson ?? $"{{\"version\":\"{(_version == "v3" ? 4 : 5)}.0.0\"}}" : relative.Contains("/queue?") ? _queueJson ?? "{\"records\":[{\"id\":1,\"episodeIds\":[1,2],\"seasonNumbers\":[1],\"sizeLeft\":100}]}" + : relative.Contains("/history/since?") ? _historyJson + : relative.EndsWith("/command") && ctx.Request.HttpMethod == "POST" && _commandResponseJson != null ? _commandResponseJson : relative.Contains("/wanted/") ? "{\"records\":[]}" : relative.Contains("/episode?") && ctx.Request.QueryString["seriesId"] == "7" ? "[{\"id\":1,\"seriesId\":7,\"seasonNumber\":1,\"monitored\":true,\"airDateUtc\":\"2020-01-01T00:00:00Z\"},{\"id\":2,\"seriesId\":7,\"seasonNumber\":1,\"monitored\":true,\"airDateUtc\":\"2020-01-01T00:00:00Z\"}]" : relative.Contains("/episode?") ? "[{\"id\":1,\"episodeFile\":{\"id\":8}}]" diff --git a/tests/Torrentarr.Infrastructure.Tests/Database/DatabaseRetryExtensionsTests.cs b/tests/Torrentarr.Infrastructure.Tests/Database/DatabaseRetryExtensionsTests.cs new file mode 100644 index 00000000..cc7e8ab2 --- /dev/null +++ b/tests/Torrentarr.Infrastructure.Tests/Database/DatabaseRetryExtensionsTests.cs @@ -0,0 +1,125 @@ +using System.Data.Common; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Torrentarr.Infrastructure.Database.Models; +using FluentAssertions; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Torrentarr.Infrastructure.Database; +using Xunit; + +namespace Torrentarr.Infrastructure.Tests.Database; + +public sealed class DatabaseRetryExtensionsTests +{ + [Theory] + [InlineData(5)] + [InlineData(6)] + public async Task WrappedBusyErrors_RetryLocalWriteWithoutRepeatingSubmission(int code) + { + var error = new DbUpdateException("Save failed", new SqliteException("Busy database", code)); + using var db = new FailingContext(error, 2); + (await db.SaveChangesWithRetryAsync()).Should().Be(1); + db.Attempts.Should().Be(3); + } + + [Fact] + public async Task PermanentErrors_AreNotRetried() + { + using var db = new FailingContext(new DbUpdateException("Invalid data", new SqliteException("Constraint", 19)), 5); + await FluentActions.Awaiting(() => db.SaveChangesWithRetryAsync()).Should().ThrowAsync(); + db.Attempts.Should().Be(1); + } + + [Fact] + public async Task Cancellation_StopsBackoffWithoutAnotherWrite() + { + using var cancellation = new CancellationTokenSource(); + using var db = new FailingContext(new DbUpdateException("Save failed", new SqliteException("Busy", 5)), 5); + var save = db.SaveChangesWithRetryAsync(cancellationToken: cancellation.Token); + cancellation.Cancel(); + await FluentActions.Awaiting(() => save).Should().ThrowAsync(); + db.Attempts.Should().Be(1); + } + + [Theory] + [InlineData(5)] + [InlineData(6)] + public async Task BulkReset_RetriesBusyErrorsAndResetsBothSearchFlags(int code) + { + using var connection = new SqliteConnection("Data Source=:memory:"); + connection.Open(); + var writes = new FailingBulkWrite(code, 2); + using var db = BulkContext(connection, writes); + await SeedAsync(db); + (await db.Movies.Where(m => m.ArrInstance == "Radarr" && m.Searched) + .ExecuteUpdateWithRetryAsync(s => s.SetProperty(m => m.Searched, false).SetProperty(m => m.Upgrade, false))).Should().Be(1); + writes.Attempts.Should().Be(3); + db.ChangeTracker.Clear(); + var movie = await db.Movies.SingleAsync(); + movie.Searched.Should().BeFalse(); + movie.Upgrade.Should().BeFalse(); + } + + [Theory] + [InlineData(19, 1)] + [InlineData(5, 3)] + public async Task BulkReset_DoesNotRetryPermanentErrorsOrRetryIndefinitely(int code, int expected) + { + using var connection = new SqliteConnection("Data Source=:memory:"); + connection.Open(); + var writes = new FailingBulkWrite(code, 99); + using var db = BulkContext(connection, writes); + await SeedAsync(db); + await FluentActions.Awaiting(() => db.Movies.ExecuteUpdateWithRetryAsync(s => s.SetProperty(m => m.Searched, false))) + .Should().ThrowAsync(); + writes.Attempts.Should().Be(expected); + db.ChangeTracker.Clear(); + (await db.Movies.SingleAsync()).Searched.Should().BeTrue(); + } + + [Fact] + public async Task BulkReset_CancellationStopsBackoffBeforeAnotherWrite() + { + using var connection = new SqliteConnection("Data Source=:memory:"); + connection.Open(); + using var cancellation = new CancellationTokenSource(); + var writes = new FailingBulkWrite(5, 99, cancellation); + using var db = BulkContext(connection, writes); + await SeedAsync(db); + await FluentActions.Awaiting(() => db.Movies.ExecuteUpdateWithRetryAsync(s => s.SetProperty(m => m.Searched, false), cancellationToken: cancellation.Token)) + .Should().ThrowAsync(); + writes.Attempts.Should().Be(1); + } + + private static TorrentarrDbContext BulkContext(SqliteConnection connection, FailingBulkWrite writes) + => new(new DbContextOptionsBuilder().UseSqlite(connection).AddInterceptors(writes).Options); + + private static async Task SeedAsync(TorrentarrDbContext db) + { + db.Database.EnsureCreated(); + db.Movies.Add(new MoviesFilesModel { ArrInstance = "Radarr", TmdbId = 1, Searched = true, Upgrade = true }); + await db.SaveChangesAsync(); + } + + private sealed class FailingBulkWrite(int code, int failures, CancellationTokenSource? cancel = null) : DbCommandInterceptor + { + public int Attempts { get; private set; } + public override ValueTask> NonQueryExecutingAsync(DbCommand command, + CommandEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default) + { + if (command.CommandText.TrimStart().StartsWith("UPDATE", StringComparison.OrdinalIgnoreCase) && ++Attempts <= failures) + { + cancel?.Cancel(); + throw new SqliteException("Injected database write failure", code); + } + return ValueTask.FromResult(result); + } + } + + private sealed class FailingContext(Exception failure, int failures) : DbContext + { + public int Attempts { get; private set; } + public override Task SaveChangesAsync(CancellationToken cancellationToken = default) + => ++Attempts <= failures ? Task.FromException(failure) : Task.FromResult(1); + } +} diff --git a/tests/Torrentarr.Infrastructure.Tests/Database/SchemaParityTests.cs b/tests/Torrentarr.Infrastructure.Tests/Database/SchemaParityTests.cs index cafb8ae2..a3c2b90c 100644 --- a/tests/Torrentarr.Infrastructure.Tests/Database/SchemaParityTests.cs +++ b/tests/Torrentarr.Infrastructure.Tests/Database/SchemaParityTests.cs @@ -43,7 +43,7 @@ ORDER BY name { "albumfilesmodel", "albumqueuemodel", "artistfilesmodel", "authorfilesmodel", "bookfilesmodel", "bookqueuemodel", "episodefilesmodel", "episodequeuemodel", - "filesqueued", "moviequeuemodel", "moviesfilesmodel", "searchactivity", "seriesfilesmodel", "torrentarr_maintenance_state", "torrentarr_operational_issues", "torrentarr_sonarr_search_retries", "torrentlibrary", + "filesqueued", "moviequeuemodel", "moviesfilesmodel", "searchactivity", "seriesfilesmodel", "torrentarr_arr_operations", "torrentarr_arr_samples", "torrentarr_maintenance_state", "torrentarr_operational_issues", "torrentarr_sonarr_search_retries", "torrentarr_transfer_samples", "torrentlibrary", "trackfilesmodel" }; tableNames.Should().Equal(expected); diff --git a/tests/Torrentarr.Infrastructure.Tests/Services/ActiveWorkerSearchTests.cs b/tests/Torrentarr.Infrastructure.Tests/Services/ActiveWorkerSearchTests.cs index b068abb4..e60c7ad3 100644 --- a/tests/Torrentarr.Infrastructure.Tests/Services/ActiveWorkerSearchTests.cs +++ b/tests/Torrentarr.Infrastructure.Tests/Services/ActiveWorkerSearchTests.cs @@ -14,6 +14,44 @@ namespace Torrentarr.Infrastructure.Tests.Services; public class ActiveWorkerSearchTests { + [Fact] + public async Task UnavailableTorrentCycle_WaitsWithoutAdvancingLastSuccess() + { + var instance = new ArrInstanceConfig { Managed = true, URI = "http://fixture", Category = "movies", RssSyncTimer = 0, RefreshDownloadsTimer = 0 }; + var reloader = new Mock(); reloader.Setup(r => r.ReloadConfig()).Returns(true); + using var harness = new Harness(instance, skipSync: false, reloader: reloader.Object); + var config = harness.Provider.GetRequiredService(); + config.TorrentClients["missing"] = new() { Type = "fake", Host = "fixture" }; + Mock.Get(harness.Provider.GetRequiredService()) + .Setup(p => p.ProcessTorrentsAsync("movies", It.IsAny>(), It.IsAny())) + .ThrowsAsync(new TorrentProcessingUnavailableException("Client unavailable")); + var client = new Mock(); + client.Setup(c => c.LoginAsync(It.IsAny())).ReturnsAsync(false); + var factory = new Mock(); + factory.SetupGet(f => f.Type).Returns("fake"); + factory.Setup(f => f.Create(It.IsAny(), It.IsAny())).Returns(client.Object); + using var manager = new QBittorrentConnectionManager(NullLogger.Instance, [factory.Object]); + var connectivity = new Mock(); connectivity.Setup(c => c.IsConnectedAsync(It.IsAny())).ReturnsAsync(true); + using var worker = new ArrWorkerService(NullLogger.Instance, config, instance, + new WorkerContext { InstanceName = "Radarr" }, harness.Provider, manager, connectivity.Object, new SearchYearCursor(), harness.Metrics); + foreach (var field in new[] { "_initialized", "_qbitSetupComplete" }) + typeof(ArrWorkerService).GetField(field, System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!.SetValue(worker, true); + var previous = DateTimeOffset.UtcNow.AddHours(-1); + harness.Metrics.Torrent = new("Sleeping", previous, LastSuccess: previous); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + reloader.Setup(r => r.WaitForChangeAsync(0, TimeSpan.FromSeconds(5), It.IsAny())) + .Returns((long _, TimeSpan _, CancellationToken ct) => + { + harness.Metrics.Torrent.Phase.Should().Be("Waiting for client"); + harness.Metrics.Torrent.LastSuccess.Should().Be(previous); + timeout.Cancel(); + return Task.FromCanceled(ct); + }); + var method = typeof(ArrWorkerService).GetMethod("RunTorrentLoopAsync", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!; + await FluentActions.Awaiting(() => (Task)method.Invoke(worker, [timeout.Token])!).Should().ThrowAsync(); + reloader.Verify(r => r.WaitForChangeAsync(0, TimeSpan.FromSeconds(5), It.IsAny()), Times.Once()); + } + [Fact] public async Task TorrentSleep_WakesOnRecoveryWithoutConfigurationChange() { @@ -351,7 +389,8 @@ public async Task BusyLoops_AcknowledgeCompletedRevisionWithoutSleeping() }); await (Task)typeof(ArrWorkerService).GetMethod($"Run{loop}LoopAsync", flags)!.Invoke(worker, [stop.Token])!; calls.Should().Be(2); - (loop == "Torrent" ? harness.Metrics.Torrent : harness.Metrics.Search).LastSuccess.Should().NotBeNull(); + if (loop == "Torrent") harness.Metrics.Torrent.LastSuccess.Should().BeNull("no client inventory was due for inspection"); + else harness.Metrics.Search.LastSuccess.Should().NotBeNull(); (loop == "Torrent" ? harness.Metrics.TorrentRevision : harness.Metrics.SearchRevision).Should().Be(42); } harness.Metrics.Snapshot("Radarr", false).ConfigRevision.Should().Be(42); @@ -430,9 +469,9 @@ public async Task TorrentPass_FiltersClientReadsAndPreservesOtherClientsMetrics( client.Setup(c => c.GetVersionAsync(It.IsAny())).ReturnsAsync("5.3"); } fast.Setup(c => c.GetTorrentsAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(Enumerable.Range(0, 4).Select(_ => new Torrentarr.Core.Models.TorrentInfo()).ToList()); + .ReturnsAsync(Enumerable.Range(0, 4).Select(_ => new Torrentarr.Core.Models.TorrentInfo { Category = "movies" }).ToList()); slow.Setup(c => c.GetTorrentsAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(Enumerable.Range(0, 7).Select(_ => new Torrentarr.Core.Models.TorrentInfo()).ToList()); + .ReturnsAsync(Enumerable.Range(0, 7).Select(_ => new Torrentarr.Core.Models.TorrentInfo { Category = "movies" }).ToList()); var factory = new Mock(); factory.SetupGet(f => f.Type).Returns("fake"); factory.Setup(f => f.Create(It.IsAny(), It.IsAny())) @@ -448,7 +487,7 @@ public async Task TorrentPass_FiltersClientReadsAndPreservesOtherClientsMetrics( await worker.ProcessTorrentsAsync(default, new HashSet { "fast", "slow" }); harness.Metrics.CategoryCount.Should().Be(11); fast.Setup(c => c.GetTorrentsAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(Enumerable.Range(0, 2).Select(_ => new Torrentarr.Core.Models.TorrentInfo()).ToList()); + .ReturnsAsync(Enumerable.Range(0, 2).Select(_ => new Torrentarr.Core.Models.TorrentInfo { Category = "movies" }).ToList()); await worker.ProcessTorrentsAsync(default, new HashSet { "fast" }); harness.Metrics.CategoryCount.Should().Be(9); slow.Verify(c => c.GetTorrentsAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); diff --git a/tests/Torrentarr.Infrastructure.Tests/Services/ArrActivityTests.cs b/tests/Torrentarr.Infrastructure.Tests/Services/ArrActivityTests.cs new file mode 100644 index 00000000..4b967c61 --- /dev/null +++ b/tests/Torrentarr.Infrastructure.Tests/Services/ArrActivityTests.cs @@ -0,0 +1,206 @@ +using System.Net; +using FluentAssertions; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Newtonsoft.Json.Linq; +using RestSharp; +using Torrentarr.Core.Configuration; +using Torrentarr.Core.Interfaces; +using Torrentarr.Infrastructure.Database; +using Torrentarr.Infrastructure.Database.Models; +using Torrentarr.Infrastructure.Services; +using Xunit; + +namespace Torrentarr.Infrastructure.Tests.Services; + +public sealed class ArrActivityTests : IDisposable +{ + private readonly SqliteConnection _connection = new("Data Source=:memory:"); + private readonly ServiceProvider _services; + private static readonly DateTimeOffset Now = DateTimeOffset.Parse("2026-11-04T12:00:00Z"); + private readonly ArrInstanceConfig _settings = new() { Type = "sonarr", URI = "http://fixture", Managed = true }; + private readonly TorrentarrConfig _config = new(); + public ArrActivityTests() + { + _connection.Open(); + var services = new ServiceCollection(); + services.AddDbContext(o => o.UseSqlite(_connection)); + _services = services.BuildServiceProvider(); + using var scope = _services.CreateScope(); + scope.ServiceProvider.GetRequiredService().Database.EnsureCreated(); + _config.ArrInstances["Sonarr-one"] = _settings; + } + private TorrentarrDbContext Db() => new(new DbContextOptionsBuilder().UseSqlite(_connection).Options); + private ArrActivityOperation Operation(int id, string kind = "search", string hash = "") => new() + { + Instance = "Sonarr-one", + CommandId = id, + Type = "sonarr", + Kind = kind, + DownloadId = hash, + Server = ArrActivityTracking.ServerKey(_settings), + SubmittedAt = Now.AddMinutes(-5).ToUnixTimeSeconds() + }; + public void Dispose() { _services.Dispose(); _connection.Dispose(); } + + [Fact] + public async Task Tracking_AttributesNestedScopesAndPersistsWithoutChangingMainResponse() + { + await using var db = Db(); + var request = new RestRequest("/api/v5/command", Method.Post); + var response = new RestResponse { StatusCode = HttpStatusCode.Accepted, IsSuccessStatusCode = true, ResponseStatus = ResponseStatus.Completed, Content = "{\"id\":42}" }; + using (ArrActivityTracking.ForClient("client-one")) + using (ArrActivityTracking.Begin(db, NullLogger.Instance, "Sonarr-one", _settings, "import", "hash")) + { + await ArrActivityTracking.RecordAcceptedAsync(request, response, default); + await ArrActivityTracking.RecordAcceptedAsync(request, response, default); + } + response.Content = "{\"id\":43}"; + await ArrActivityTracking.RecordAcceptedAsync(request, response, default); + var operation = await db.ArrActivityOperations.SingleAsync(); + operation.CommandId.Should().Be(42); operation.ClientInstance.Should().Be("client-one"); + operation.DownloadId.Should().Be("HASH"); operation.Kind.Should().Be("import"); + db.Database.ExecuteSqlRaw("DROP TABLE torrentarr_arr_operations"); + using (ArrActivityTracking.Begin(db, NullLogger.Instance, "Sonarr-one", _settings, "search")) + await ArrActivityTracking.RecordAcceptedAsync(request, response, default); + response.IsSuccessful.Should().BeTrue(); + } + + [Theory] + [InlineData("started", null, false)] + [InlineData("completed", "successful", true)] + [InlineData("completed", "unsuccessful", false)] + [InlineData("failed", null, false)] + [InlineData("cancelled", null, false)] + [InlineData("completed", "unknown", false)] + [InlineData("completed", null, false)] + [InlineData("completed", "1", true)] + public async Task Search_RequiresSuccessfulCommandCompletion(string status, string? result, bool completed) + { + await using var db = Db(); var operation = Operation(1); db.Add(operation); await db.SaveChangesAsync(); + var commands = new JArray(new JObject { ["id"] = 1, ["status"] = status, ["result"] = result }); + await ArrActivityCollector.ReconcileAsync(db, [operation], commands, [], Now); + await db.SaveChangesAsync(); + operation.ConfirmedAt.HasValue.Should().Be(completed); + await ArrActivityCollector.ReconcileAsync(db, [operation], commands, [], Now.AddMinutes(1)); + if (completed) operation.ConfirmedAt.Should().Be(Now.ToUnixTimeSeconds()); + } + + [Fact] + public async Task Imports_RequireNewCorrelatedPositiveEvidenceAndDeduplicateBatchRetries() + { + await using var db = Db(); var first = Operation(1, "import", "HASH"); var retry = Operation(2, "import", "HASH"); + db.AddRange(first, retry); await db.SaveChangesAsync(); + var commands = JArray.Parse("[{\"id\":1,\"status\":\"completed\"},{\"id\":2,\"status\":\"completed\"}]"); + await ArrActivityCollector.ReconcileAsync(db, [first, retry], commands, [], Now); + first.ConfirmedAt.Should().BeNull(); + var history = new JArray( + new JObject { ["id"] = 5, ["downloadId"] = "OTHER", ["eventType"] = "downloadFolderImported", ["date"] = Now.ToString("O") }, + new JObject { ["id"] = 6, ["downloadId"] = "HASH", ["eventType"] = "grabbed", ["date"] = Now.ToString("O") }, + new JObject { ["id"] = 7, ["downloadId"] = "HASH", ["eventType"] = "downloadFolderImported", ["date"] = Now.AddDays(-1).ToString("O") }); + await ArrActivityCollector.ReconcileAsync(db, [first, retry], commands, history, Now); + first.ConfirmedAt.Should().BeNull(); + history.Add(new JObject { ["id"] = 8, ["downloadId"] = "hash", ["eventType"] = "downloadFolderImported", ["date"] = Now.ToString("O") }); + history.Add(new JObject { ["id"] = 9, ["downloadId"] = "HASH", ["eventType"] = "downloadFolderImported", ["date"] = Now.ToString("O") }); + await ArrActivityCollector.ReconcileAsync(db, [first, retry], commands, history, Now); + await db.SaveChangesAsync(); first.ConfirmedAt.Should().Be(Now.ToUnixTimeSeconds()); + retry.Outcome.Should().Be("duplicate"); retry.ConfirmedAt.Should().BeNull(); + } + + [Theory] + [InlineData("radarr", "downloadFolderImported", true)] + [InlineData("sonarr", "downloadFolderImported", true)] + [InlineData("lidarr", "downloadImported", true)] + [InlineData("readarr", "downloadImported", true)] + [InlineData("lidarr", "trackFileImported", false)] + [InlineData("readarr", "bookImportIncomplete", false)] + public void Imports_UseTypeSpecificBatchEvidence(string type, string eventType, bool expected) + { + var operation = Operation(1, "import", "HASH"); operation.Type = type; + var evidence = new JObject { ["id"] = 2, ["downloadId"] = "HASH", ["eventType"] = eventType, ["date"] = Now.ToString("O") }; + ArrActivityCollector.PositiveImport(evidence, operation).Should().Be(expected); + } + + [Fact] + public async Task Collector_RestartsWithPendingWorkAndSamplesFreshHeartbeatsOncePerMinute() + { + await using (var db = Db()) { db.Add(Operation(1)); await db.SaveChangesAsync(); } + var statuses = new Dictionary { ["Sonarr-one"] = new() { IsAlive = true, LastHeartbeat = Now.UtcDateTime, QueueCount = 7 } }; + var calls = 0; + Task Read(ArrInstanceConfig _, string resource, CancellationToken ct) { calls++; return Task.FromResult(JArray.Parse("[{\"id\":1,\"status\":\"completed\",\"result\":\"successful\"}]")); } + var collector = new ArrActivityCollector(_services.GetRequiredService(), _services, _config, NullLogger.Instance); + await collector.CollectAsync(Now, statuses, Read); + await collector.CollectAsync(Now.AddSeconds(10), statuses, Read); + calls.Should().Be(1); + await collector.CollectAsync(Now.AddMinutes(1), statuses, Read); + await using var check = Db(); + (await check.ArrActivityOperations.SingleAsync()).Outcome.Should().Be("completed"); + var rows = await check.ArrActivitySamples.OrderBy(s => s.Minute).ToListAsync(); + rows[0].QueueDepth.Should().Be(7); rows[0].CompletionObserved.Should().BeTrue(); + rows[1].QueueDepth.Should().BeNull(); rows[1].CompletionObserved.Should().BeFalse(); + } + + [Fact] + public async Task Query_RoundsQueueAveragesSumsConfirmedEventsAndPreservesUnknownAndZero() + { + await using var db = Db(); var minute = Now.ToUnixTimeSeconds() / 60; + db.AddRange(new ArrActivitySample { Instance = "Sonarr-one", Type = "sonarr", Minute = minute - 5, QueueDepth = 4, CompletionObserved = true }, + new ArrActivitySample { Instance = "Sonarr-one", Type = "sonarr", Minute = minute - 4, QueueDepth = 8, CompletionObserved = true }, + new ArrActivitySample { Instance = "Sonarr-one", Type = "sonarr", Minute = minute - 3, QueueDepth = 8, CompletionObserved = true }); + var operation = Operation(1); operation.Outcome = "completed"; operation.ConfirmedAt = Now.AddMinutes(-2).ToUnixTimeSeconds(); db.Add(operation); + await db.SaveChangesAsync(); + var history = await ArrActivityApi.QueryAsync(db, _config, "24h", null, Now); + var point = history.Series.Single().Points.Last(); + point.QueueDepth.Should().Be(7); point.QueueSamples.Should().Be(3); point.ExpectedSamples.Should().Be(60); + point.SearchesCompleted.Should().Be(1); point.ImportsConfirmed.Should().Be(0); + history.Series.Single().Points.First().SearchesCompleted.Should().BeNull(); + foreach (var range in new[] { "1h", "24h", "7d" }) + (await ArrActivityApi.QueryAsync(db, _config, range, null, Now)).Series.Single().Points.Count.Should().Be(range switch { "1h" => 60, "24h" => 24, _ => 168 }); + } + + [Fact] + public async Task Collector_PrunesExpiredHistoryAndMigrationIsRepeatable() + { + await using var db = Db(); + db.Add(new ArrActivitySample { Instance = "old", Type = "sonarr", Minute = Now.AddDays(-8).ToUnixTimeSeconds() / 60 }); + var old = Operation(10); old.SubmittedAt = Now.AddDays(-8).ToUnixTimeSeconds(); db.Add(old); await db.SaveChangesAsync(); + ManualSqliteMigrations.Apply(db); ManualSqliteMigrations.Apply(db); + var collector = new ArrActivityCollector(_services.GetRequiredService(), _services, _config, NullLogger.Instance); + await collector.CollectAsync(Now, new Dictionary(), (_, _, _) => throw new IOException("must not poll expired operations")); + db.ChangeTracker.Clear(); (await db.ArrActivitySamples.CountAsync(s => s.Instance == "old")).Should().Be(0); + (await db.ArrActivityOperations.CountAsync()).Should().Be(0); + } + + [Fact] + public async Task Collector_FailedEvidenceLeavesGapAndDoesNotBlockOtherWorkers() + { + await using var db = Db(); db.Add(Operation(1)); await db.SaveChangesAsync(); + _config.ArrInstances["Sonarr-two"] = new() { Type = "sonarr", URI = "http://fixture-two" }; + var statuses = new Dictionary + { + ["Sonarr-one"] = new() { IsAlive = true, LastHeartbeat = Now.UtcDateTime, QueueCount = 7 }, + ["Sonarr-two"] = new() { IsAlive = true, LastHeartbeat = Now.UtcDateTime, QueueCount = 3 } + }; + var collector = new ArrActivityCollector(_services.GetRequiredService(), _services, _config, NullLogger.Instance); + await collector.CollectAsync(Now, statuses, (_, _, _) => throw new IOException("unavailable")); + var rows = await db.ArrActivitySamples.OrderBy(s => s.Instance).ToListAsync(); + rows[0].QueueDepth.Should().Be(7); rows[0].CompletionObserved.Should().BeFalse(); + rows[1].QueueDepth.Should().Be(3); rows[1].CompletionObserved.Should().BeTrue(); + (await db.ArrActivityOperations.SingleAsync()).Outcome.Should().Be("pending"); + } + + [Fact] + public async Task Migration_AddsActivityTablesToExistingDatabaseWithoutLosingData() + { + await using var db = Db(); + db.Database.ExecuteSqlRaw("DROP TABLE torrentarr_arr_samples"); + db.Database.ExecuteSqlRaw("DROP TABLE torrentarr_arr_operations"); + ManualSqliteMigrations.Apply(db); ManualSqliteMigrations.Apply(db); + db.Add(Operation(1)); db.Add(new ArrActivitySample { Instance = "Sonarr-one", Type = "sonarr", Minute = 123 }); + await db.SaveChangesAsync(); ManualSqliteMigrations.Apply(db); + (await db.ArrActivityOperations.SingleAsync()).CommandId.Should().Be(1); + (await db.ArrActivitySamples.SingleAsync()).Minute.Should().Be(123); + } +} diff --git a/tests/Torrentarr.Infrastructure.Tests/Services/AutomationLoggingTests.cs b/tests/Torrentarr.Infrastructure.Tests/Services/AutomationLoggingTests.cs index 8f6a307d..1db6c8f6 100644 --- a/tests/Torrentarr.Infrastructure.Tests/Services/AutomationLoggingTests.cs +++ b/tests/Torrentarr.Infrastructure.Tests/Services/AutomationLoggingTests.cs @@ -67,10 +67,10 @@ public async Task TrackerActions_LogOnlyHost_AndReportAcceptance(string tracker) } [Fact] - public async Task RemovalProtection_ExplainsBlockersAtInformation() + public async Task RemovalProtection_ExplainsEvaluationAtDebug() { await using var db = new TorrentarrDbContext(new DbContextOptionsBuilder().UseInMemoryDatabase(Guid.NewGuid().ToString()).Options); - var logger = new ActivityLogger(); + var logger = new ActivityLogger(LogLevel.Debug); var registry = new Mock(); var service = new SeedingService(logger, db, new TorrentarrConfig(), registry.Object); var decision = await service.EvaluateRemovalAsync(new() { Hash = "protected", QBitInstanceName = "seedbox", Name = "Protected item" }, TorrentRemovalIntent.SeedingLimit); @@ -85,7 +85,7 @@ public async Task DatabaseChanges_AreLoggedAfterSuccessfulSave_AndNeverAfterFail await connection.OpenAsync(); await using var db = new TorrentarrDbContext(new DbContextOptionsBuilder().UseSqlite(connection).Options); await db.Database.EnsureCreatedAsync(); - var logger = new ActivityLogger(); + var logger = new ActivityLogger(LogLevel.Debug); var movie = new MoviesFilesModel { ArrInstance = "Radarr", ArrId = 17, TmdbId = 17, Title = "SaveChanges movie", Reason = "Missing" }; db.Movies.Add(movie); await db.SaveChangesWithRetryAsync(logger); @@ -127,7 +127,7 @@ public async Task SearchEligibility_LogsExcludedAndSelectedItems_WithoutChanging var executor = new Mock(); executor.Setup(e => e.ExecuteSearchesAsync("Arr-test", It.IsAny>(), It.IsAny())) .ReturnsAsync(new SearchResult()); - var logger = new ActivityLogger(); + var logger = new ActivityLogger(LogLevel.Debug); var service = new ArrMediaService(logger, db, config, executor.Object, new ArrSyncService(NullLogger.Instance, config, db, new())); await service.SearchMissingMediaAsync("media"); @@ -139,7 +139,7 @@ public async Task SearchEligibility_LogsExcludedAndSelectedItems_WithoutChanging [Fact] public void SonarrEligibility_LogsSpecialsAndUnairedDecisions() { - var logger = new ActivityLogger(); + var logger = new ActivityLogger(LogLevel.Debug); var candidates = SonarrSearchScope.Candidates([ new() { ArrInstance = "Sonarr", ArrId = 1, SeriesTitle = "Special", SeasonNumber = 0, Monitored = true }, new() { ArrInstance = "Sonarr", ArrId = 2, SeriesTitle = "Future", SeasonNumber = 1, Monitored = true, AirDateUtc = DateTime.UtcNow.AddDays(1) } diff --git a/tests/Torrentarr.Infrastructure.Tests/Services/ClientScanSchedulingTests.cs b/tests/Torrentarr.Infrastructure.Tests/Services/ClientScanSchedulingTests.cs index b2486e32..2f9e16d5 100644 --- a/tests/Torrentarr.Infrastructure.Tests/Services/ClientScanSchedulingTests.cs +++ b/tests/Torrentarr.Infrastructure.Tests/Services/ClientScanSchedulingTests.cs @@ -1,6 +1,7 @@ using FluentAssertions; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Moq; using Torrentarr.Core.Configuration; @@ -92,9 +93,11 @@ public async Task InProcessMetrics_RefreshOnlyDueClientsAndRetainOtherCounts() } [Theory] - [InlineData(false)] - [InlineData(true)] - public async Task CategoryLoops_ProcessOnlyDueClientsAndStopCleanly(bool qbitOnly) + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task CategoryLoops_ProcessOnlyDueClientsAndStopCleanly(bool qbitOnly, bool failFirst) { var clock = new ScanClock(); var config = Configuration(); @@ -105,6 +108,7 @@ public async Task CategoryLoops_ProcessOnlyDueClientsAndStopCleanly(bool qbitOnl .Returns((string _, IReadOnlySet? ids, CancellationToken ct) => { seen.Add(ids!.ToHashSet()); + if (failFirst && seen.Count == 1) throw new TorrentProcessingUnavailableException("client offline"); if (clock.Now >= DateTimeOffset.UnixEpoch.AddSeconds(120)) stop.Cancel(); return Task.CompletedTask; }); @@ -125,11 +129,12 @@ public async Task CategoryLoops_ProcessOnlyDueClientsAndStopCleanly(bool qbitOnl .AddSingleton(new QBitCategoryEnsureService(NullLogger.Instance, config, registry.Object)).BuildServiceProvider(); var scopes = provider.GetRequiredService(); var states = new ProcessStateManager(); + var logger = new Mock>(); Task running; const System.Reflection.BindingFlags flags = System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic; if (qbitOnly) { - using var manager = new QBitCategoryWorkerManager(NullLogger.Instance, + using var manager = new QBitCategoryWorkerManager(logger.Object, config, scopes, states, connectivity.Object, reloader.Object) { Clock = clock }; running = (Task)typeof(QBitCategoryWorkerManager).GetMethod("RunCategoryLoopAsync", flags)! @@ -150,6 +155,12 @@ public async Task CategoryLoops_ProcessOnlyDueClientsAndStopCleanly(bool qbitOnl seen.Should().HaveCount(25); seen.Count(ids => ids.Contains("slow")).Should().Be(2); seen.Should().OnlyContain(ids => ids.Contains("fast")); + if (failFirst && !qbitOnly) + seen[1].Should().Equal(new[] { "fast" }, "a client outage must not force a full rescan"); + if (failFirst && qbitOnly) + logger.Invocations.Where(i => i.Method.Name == "Log") + .Should().Contain(i => (LogLevel)i.Arguments[0]! == LogLevel.Information) + .And.NotContain(i => (LogLevel)i.Arguments[0]! == LogLevel.Error); } private static Mock Client() diff --git a/tests/Torrentarr.Infrastructure.Tests/Services/FreeSpaceReviewTests.cs b/tests/Torrentarr.Infrastructure.Tests/Services/FreeSpaceReviewTests.cs index a96da5d3..dd1ae880 100644 --- a/tests/Torrentarr.Infrastructure.Tests/Services/FreeSpaceReviewTests.cs +++ b/tests/Torrentarr.Infrastructure.Tests/Services/FreeSpaceReviewTests.cs @@ -29,7 +29,7 @@ public FreeSpaceReviewTests() private Mock Client(string id, bool tags, params TorrentInfo[] torrents) { - _config.TorrentClients[id] = new() { Host = "fixture", DownloadPath = Path.GetTempPath(), FreeSpace = [new() { Path = Path.GetTempPath(), Enabled = true, Limit = "1" }] }; + _config.TorrentClients[id] = new() { Host = "fixture", ManagedCategories = ["movies"], DownloadPath = Path.GetTempPath(), FreeSpace = [new() { Path = Path.GetTempPath(), Enabled = true, Limit = "1" }] }; foreach (var torrent in torrents) torrent.SavePath = Path.GetTempPath(); var client = new Mock(); client.SetupGet(c => c.Capabilities).Returns(TorrentClientCapabilities.QBittorrent with { Tags = tags }); @@ -401,5 +401,32 @@ public async Task SharedDisk_AllocatesOldestFirstAcrossClientLocalQueuePositions second.Verify(c => c.ResumeTorrentAsync("old", It.IsAny()), state == "pausedDL" ? Times.Once() : Times.Never()); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task OwnedSubcategory_ResumesAfterSpaceRecovers(bool tags) + { + var torrent = new TorrentInfo { Hash = "child", Category = "movies/4k", State = "pausedDL", Tags = "qBitrr-free_space_paused", AmountLeft = 90 }; + var client = Client("one", tags, torrent); + _config.TorrentClients["one"].ManagedCategories = ["movies"]; + _config.TorrentClients["one"].MatchSubcategories = true; + if (!tags) { _db.TorrentLibrary.Add(new() { Hash = "child", QbitInstance = "one", FreeSpacePaused = true }); await _db.SaveChangesAsync(); } + SetLimit("10"); + var service = new SharedDiskService(_config, _registry.Object, _db, "other"); + (await service.ProcessAsync(new HashSet { "movies" })).Resumed.Should().Be(1); + client.Verify(c => c.ResumeTorrentAsync("child", It.IsAny()), Times.Once); + } + + [Fact] + public async Task CategoryOwnedByAnotherClient_IsNotAutomaticallyResumed() + { + Client("owner", true); + var other = Client("other", true, new TorrentInfo { Hash = "foreign", Category = "movies", State = "pausedDL", Tags = "qBitrr-free_space_paused", AmountLeft = 1 }); + _config.TorrentClients["other"].ManagedCategories = []; + var result = await Service().ProcessAsync(new HashSet { "movies" }); + result.Resumed.Should().Be(0); + other.Verify(c => c.ResumeTorrentAsync(It.IsAny(), It.IsAny()), Times.Never); + } + public void Dispose() => _db.Dispose(); } diff --git a/tests/Torrentarr.Infrastructure.Tests/Services/LogRetentionTests.cs b/tests/Torrentarr.Infrastructure.Tests/Services/LogRetentionTests.cs new file mode 100644 index 00000000..7d5d2bd3 --- /dev/null +++ b/tests/Torrentarr.Infrastructure.Tests/Services/LogRetentionTests.cs @@ -0,0 +1,51 @@ +using FluentAssertions; +using Serilog.Events; +using Serilog.Parsing; +using Torrentarr.Infrastructure.Logging; +using Xunit; + +namespace Torrentarr.Infrastructure.Tests.Services; + +public sealed class LogRetentionTests +{ + [Theory] + [InlineData("all-20261104.log", true, 3)] + [InlineData("all-20261104_001.log", true, 3)] + [InlineData("all-20261104_001.log", false, 1)] + public void Search_IncludesNumberedSizeRotationsWithoutOtherStreams(string selected, bool includeRotated, int expected) + { + var directory = Directory.CreateTempSubdirectory("log-search-rotation-"); + try + { + foreach (var name in new[] { "all-20261104.log", "all-20261104_001.log", "all-20261104.log.1", "all-20261103.log", "worker-one-20261104.log" }) + File.WriteAllText(Path.Combine(directory.FullName, name), "marker\n"); + var result = Torrentarr.Infrastructure.Endpoints.LogFileApi.Search(directory.FullName, selected, + "marker", null, null, includeRotated ? "true" : "false", 10, 0); + var value = ((Microsoft.AspNetCore.Http.IValueHttpResult)result).Value; + var json = System.Text.Json.JsonSerializer.SerializeToElement(value); + json.GetProperty("matches").GetArrayLength().Should().Be(expected); + } + finally { directory.Delete(recursive: true); } + } + + [Fact] + public void SizeRotation_BoundsHistoryAndKeepsAggregateAndServiceRecordsConsistent() + { + var directory = Directory.CreateTempSubdirectory("log-retention-"); + try + { + using (var sink = new ServiceLogSink(directory.FullName, "Host", 256)) + for (var index = 0; index < 40; index++) + sink.Emit(new LogEvent(DateTimeOffset.Now, LogEventLevel.Information, null, + new MessageTemplateParser().Parse($"event-{index:D2}-{new string('x', 120)}"), [])); + var aggregate = Directory.GetFiles(directory.FullName, "all-*.log"); + var service = Directory.GetFiles(directory.FullName, "torrentarr-*.log"); + aggregate.Should().HaveCount(8); + service.Should().HaveCount(8); + aggregate.Concat(service).Should().OnlyContain(path => new FileInfo(path).Length <= 512, + "one complete event may cross the size rotation threshold"); + aggregate.SelectMany(File.ReadAllLines).Should().BeEquivalentTo(service.SelectMany(File.ReadAllLines)); + } + finally { directory.Delete(recursive: true); } + } +} diff --git a/tests/Torrentarr.Infrastructure.Tests/Services/MaintenanceSafetyTests.cs b/tests/Torrentarr.Infrastructure.Tests/Services/MaintenanceSafetyTests.cs index eab37a92..aa652a58 100644 --- a/tests/Torrentarr.Infrastructure.Tests/Services/MaintenanceSafetyTests.cs +++ b/tests/Torrentarr.Infrastructure.Tests/Services/MaintenanceSafetyTests.cs @@ -207,8 +207,15 @@ public async Task ProtectionWarning_PersistsUntilSafeLimitsAreObserved(bool acce _config, NullLogger.Instance); await Inspect(automation, default); await monitor.InspectAsync(default); - var issue = await _db.OperationalIssues.SingleAsync(i => i.Code == "automation.protection"); - issue.ResolvedAt.Should().BeNull(); + var issue = await _db.OperationalIssues.SingleOrDefaultAsync(i => i.Code == "automation.protection"); + if (accepted) + { + issue.Should().BeNull(); + automation.GetStatus().Protection.Should().ContainSingle().Which.NativeStopLimitRisk.Should().BeFalse(); + return; + } + issue.Should().NotBeNull(); + issue!.ResolvedAt.Should().BeNull(); torrent.RatioLimit = -1; torrent.SeedingTimeLimit = -1; torrent.InactiveSeedingTimeLimit = -1; AutomationField>(automation, "_nextInspection").Clear(); await Inspect(automation, default); @@ -958,6 +965,22 @@ public async Task Rollback_DoesNotOverwriteRecreatedSource() Directory.EnumerateFiles(Path.Combine(_root, ".recycle"), "*", SearchOption.AllDirectories).Should().ContainSingle(); } + [Fact] + public async Task RecyclingWithoutMappings_ExplainsClientPathAndDoesNotDeleteContent() + { + AddTorrent(); + ClientConfig.Maintenance.PathMappings.Clear(); + var result = await _deletion.RemoveTorrentAsync("one", "abc", true); + result.Success.Should().BeFalse(); + result.Error.Should().Contain("Recycling blocked").And.Contain("Client 'one'") + .And.Contain("/downloads/movie.mkv").And.Contain("Maintenance.PathMappings") + .And.Contain("mounted and accessible").And.Contain("Identical client/local paths"); + _client.Verify(c => c.DeleteTorrentsAsync(It.IsAny>(), It.IsAny(), It.IsAny()), Times.Never()); + File.Exists(Path.Combine(_root, "movie.mkv")).Should().BeTrue(); + ClientConfig.Maintenance.PathMappings.Add(new() { ClientPath = "/downloads", LocalPath = _root }); + (await _deletion.RemoveTorrentAsync("one", "abc", true)).Success.Should().BeTrue(); + } + [Fact] public async Task DisabledRecycle_MaintenanceUsesNativeDeletion() { @@ -1466,6 +1489,19 @@ public async Task TrackerIssues_ForRemovedTorrentsClearAfterACompleteClientInven automation.GetStatus().TrackerIssues.Should().BeEmpty(); } + [Fact] + public async Task TrackerIssue_IsNotReportedWhileAnotherTrackerIsHealthy() + { + var torrent = AddTorrent(); + TorrentTracker healthy = new() { Url = "https://tracker.example/announce", Status = 2 }; + TorrentTracker dead = new() { Url = "https://backup.example/announce", Status = 4, Msg = "unregistered torrent" }; + await _seeding.ObserveTrackerIssueAsync(torrent, [dead], default); + using var automation = new TorrentAutomationService(_config, _registry.Object, _services.GetRequiredService(), NullLogger.Instance); + automation.GetStatus().TrackerIssues.Should().ContainSingle(); + await _seeding.ObserveTrackerIssueAsync(torrent, [healthy, dead], default); + automation.GetStatus().TrackerIssues.Should().BeEmpty(); + } + [Fact] public async Task TrackerCache_ReusesOriginalTimestampAndPrivacyThenExpires() { diff --git a/tests/Torrentarr.Infrastructure.Tests/Services/OperationalIssueMonitorTests.cs b/tests/Torrentarr.Infrastructure.Tests/Services/OperationalIssueMonitorTests.cs index 92b2a51f..1ca64d19 100644 --- a/tests/Torrentarr.Infrastructure.Tests/Services/OperationalIssueMonitorTests.cs +++ b/tests/Torrentarr.Infrastructure.Tests/Services/OperationalIssueMonitorTests.cs @@ -12,6 +12,82 @@ namespace Torrentarr.Infrastructure.Tests.Services; public sealed class OperationalIssueMonitorTests { + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task MigrationAndMissingMappings_CreateIssuesBeforeProcessing_AndResolveAfterRestart(bool removeClient) + { + using var root = new TemporaryDirectory(); + using var db = new Torrentarr.Infrastructure.Database.TorrentarrDbContext(new DbContextOptionsBuilder() + .UseSqlite("Data Source=:memory:").Options); + await db.Database.OpenConnectionAsync(); + await db.Database.EnsureCreatedAsync(); + var config = new TorrentarrConfig(); + config.TorrentClients["recycling"] = new() + { + Host = "localhost", + Migration = new() { Notices = ["Legacy monitor ownership is ambiguous; review FreeSpace."] }, + Maintenance = new() + { + RecycleBin = new() { Enabled = true }, + PathMappings = [new() { ClientPath = "/downloads", LocalPath = root.Path }] + } + }; + config.QBitInstances["qBit-other"] = new() { Host = "localhost" }; + await using var services = new ServiceCollection().AddSingleton(db).AddSingleton(config) + .AddSingleton().BuildServiceProvider(); + using var reporter = new OperationalIssueReporter(services.GetRequiredService(), NullLogger.Instance, config); + using var monitor = new OperationalIssueMonitor(services.GetRequiredService(), reporter, config, NullLogger.Instance); + await monitor.InspectAsync(default); + await monitor.InspectAsync(default); + var active = await db.OperationalIssues.AsNoTracking().Where(i => i.ResolvedAt == null).ToListAsync(); + active.Should().HaveCount(2).And.OnlyContain(i => i.Target == "config" && i.Occurrences == 2); + active.Single(i => i.Code == "config.storage").ClientInstance.Should().Be("qBit-other"); + active.Single(i => i.Code == "config.storage").Remediation.Should().Contain("Maintenance.PathMappings"); + active.Single(i => i.Code == "config.migration").Remediation.Should().Contain("Migration.Notices"); + config.TorrentClients["recycling"].Migration.Notices.Clear(); + if (removeClient) config.QBitInstances.Clear(); + else config.QBitInstances["qBit-other"].Maintenance.PathMappings.Add(new() { ClientPath = root.Path, LocalPath = root.Path }); + using var restarted = new OperationalIssueMonitor(services.GetRequiredService(), reporter, config, NullLogger.Instance); + await restarted.InspectAsync(default); + var history = await db.OperationalIssues.AsNoTracking().ToListAsync(); + history.Should().HaveCount(2).And.OnlyContain(i => i.ResolvedAt != null); + } + + [Fact] + public async Task InvalidOrMissingConfiguration_DirectsUserToConfigAndResolvesAfterCorrection() + { + using var root = new TemporaryDirectory(); + var config = new TorrentarrConfig(); + config.TorrentClients["client"] = new() + { + TorrentScanIntervalSeconds = 7, + Maintenance = new() + { + RecycleBin = new() { Enabled = true }, + PathMappings = [new() { ClientPath = "/downloads", LocalPath = System.IO.Path.Combine(root.Path, "missing-mount") }] + } + }; + var reports = new List(); + var resolved = new List(); + var reporter = new Mock(); + reporter.Setup(r => r.ReportAsync(It.IsAny(), It.IsAny())) + .Callback((r, _) => reports.Add(r)).Returns(Task.CompletedTask); + reporter.Setup(r => r.ResolveAsync(It.IsAny(), It.IsAny())) + .Callback((r, _) => resolved.Add(r)).Returns(Task.CompletedTask); + await using var services = new ServiceCollection().AddSingleton().BuildServiceProvider(); + using var monitor = new OperationalIssueMonitor(services.GetRequiredService(), reporter.Object, config, NullLogger.Instance); + await monitor.InspectAsync(default); + reports.Select(r => r.Code).Should().BeEquivalentTo(["config.validation", "config.client", "config.storage"]); + reports.Should().OnlyContain(r => r.Target == "config" && r.Remediation.Contains("Open Config")); + reports.Single(r => r.Code == "config.validation").Impact.Should().Contain("TorrentScanIntervalSeconds"); + config.TorrentClients["client"].Host = "localhost"; + config.TorrentClients["client"].TorrentScanIntervalSeconds = 30; + config.TorrentClients["client"].Maintenance.PathMappings[0].LocalPath = root.Path; + await monitor.InspectAsync(default); + resolved.Select(r => r.Code).Should().BeEquivalentTo(reports.Select(r => r.Code)); + } + [Theory] [InlineData(false, false)] [InlineData(true, false)] @@ -251,4 +327,10 @@ public async Task PendingConfigurationNeverClearsAnError_AndRecoveryRequiresSucc state = state with { State = "applied", Error = null }; online = true; low = false; await monitor.InspectAsync(default); resolved.Select(r => r.Code).Should().Contain(["config.apply", "network.internet", "storage.reserve"]); } + private sealed class TemporaryDirectory : IDisposable + { + public string Path { get; } = Directory.CreateDirectory(System.IO.Path.Combine(TestPaths.TempPath, "migration-issues-" + Guid.NewGuid().ToString("N"))).FullName; + public void Dispose() => Directory.Delete(Path, recursive: true); + } + } diff --git a/tests/Torrentarr.Infrastructure.Tests/Services/ProtectionLimitTests.cs b/tests/Torrentarr.Infrastructure.Tests/Services/ProtectionLimitTests.cs index 2f84aa2e..32ba66a1 100644 --- a/tests/Torrentarr.Infrastructure.Tests/Services/ProtectionLimitTests.cs +++ b/tests/Torrentarr.Infrastructure.Tests/Services/ProtectionLimitTests.cs @@ -13,6 +13,22 @@ namespace Torrentarr.Infrastructure.Tests.Services; public sealed class ProtectionLimitTests { + private static (SeedingService Service, Mock Client, TorrentarrDbContext Db) Create( + TorrentarrConfig? config = null) + { + var db = new TorrentarrDbContext(new DbContextOptionsBuilder() + .UseSqlite("Data Source=:memory:").Options); + db.Database.OpenConnection(); + db.Database.EnsureCreated(); + var client = new Mock(); + client.SetupGet(c => c.Capabilities).Returns(TorrentClientCapabilities.QBittorrent); + client.Setup(c => c.SetShareLimitsAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(true); + client.Setup(c => c.SetShareLimitsAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(true); + var registry = new Mock(); + registry.Setup(r => r.GetClient("client")).Returns(client.Object); + return (new SeedingService(NullLogger.Instance, db, config ?? new TorrentarrConfig(), registry.Object), client, db); + } + [Theory] [InlineData(false, false, false, false)] [InlineData(true, false, false, false)] @@ -21,8 +37,6 @@ public sealed class ProtectionLimitTests [InlineData(false, false, false, true)] public async Task ProtectedLimits_ClearAllThree_AndPreserveSubsequentUserChanges(bool userChanged, bool revisedWhileProtected, bool clearRejected, bool restoreRejected) { - using var db = new TorrentarrDbContext(new DbContextOptionsBuilder().UseSqlite("Data Source=:memory:").Options); - db.Database.OpenConnection(); db.Database.EnsureCreated(); var config = new TorrentarrConfig(); config.TorrentClients["client"] = new() { @@ -30,12 +44,7 @@ public async Task ProtectedLimits_ClearAllThree_AndPreserveSubsequentUserChanges MinimumSeedHours = 48, AllowClientRatioClearance = true, AccountingGraceMinutes = 0, AdditionalSeedHours = 0 } }] }; - var client = new Mock(); - client.SetupGet(c => c.Capabilities).Returns(TorrentClientCapabilities.QBittorrent); - client.Setup(c => c.SetShareLimitsAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(true); - var registry = new Mock(); - registry.Setup(r => r.GetClient("client")).Returns(client.Object); - var service = new SeedingService(NullLogger.Instance, db, config, registry.Object); + var (service, client, db) = Create(config); var torrent = new TorrentInfo { ClientInstanceId = "client", @@ -88,4 +97,124 @@ public async Task ProtectedLimits_ClearAllThree_AndPreserveSubsequentUserChanges await service.ReconcileProtectionLimitsAsync(torrent, trackers, default); client.Verify(c => c.SetShareLimitsAsync("hash", ratio, seedTime, inactiveTime, It.IsAny()), userChanged ? Times.Never() : Times.Exactly(restoreRejected ? 2 : 1)); } + + [Fact] + public async Task UnmatchedPrivateTorrent_ClearsNativeLimitsWithoutConfiguredProtection() + { + var (service, client, db) = Create(); + var torrent = new TorrentInfo + { + ClientInstanceId = "client", + Hash = "hash", + AddedOn = 1, + Progress = 1, + IsPrivate = true, + RatioLimit = 1, + SeedingTimeLimit = 60, + InactiveSeedingTimeLimit = 30 + }; + List trackers = [new() { Url = "https://unknown.example/announce", Status = 2 }]; + + await service.ReconcileProtectionLimitsAsync(torrent, trackers, default); + + client.Verify(c => c.SetShareLimitsAsync("hash", -1, -1, -1, It.IsAny()), Times.Once); + var state = MaintenancePersistence.Read( + db, MaintenancePersistence.Key("client", "hash", "native-limits")); + state.PreviousRatioLimit.Should().Be(1); + state.PreviousTimeLimit.Should().Be(60); + state.PreviousInactiveTimeLimit.Should().Be(30); + } + + [Fact] + public async Task PublicTorrentWithoutProtection_DoesNotTouchNativeLimits() + { + var (service, client, _) = Create(); + var torrent = new TorrentInfo + { + ClientInstanceId = "client", + Hash = "hash", + AddedOn = 1, + Progress = 1, + IsPrivate = false, + RatioLimit = 1, + SeedingTimeLimit = 60, + InactiveSeedingTimeLimit = 30 + }; + + await service.ReconcileProtectionLimitsAsync(torrent, [], default); + + client.Verify(c => c.SetShareLimitsAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + client.Verify(c => c.SetShareLimitsAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task ReAddedTorrent_ReplacesSavedStateAndClearsFreshNativeLimits() + { + var config = new TorrentarrConfig(); + config.TorrentClients["client"] = new() + { + Trackers = [new() { Uri = "tracker.example", Protection = new() { MinimumSeedHours = 48 } }] + }; + var (service, client, db) = Create(config); + var key = MaintenancePersistence.Key("client", "hash", "native-limits"); + MaintenancePersistence.Write(db, key, new ProtectionObservation + { + AddedOn = 1, + PreviousRatioLimit = 9, + PreviousTimeLimit = 900, + PreviousInactiveTimeLimit = 90 + }); + var torrent = new TorrentInfo + { + ClientInstanceId = "client", + Hash = "hash", + AddedOn = 2, + Progress = 1, + IsPrivate = true, + Ratio = 0, + RatioLimit = 1, + SeedingTimeLimit = 60, + InactiveSeedingTimeLimit = 30 + }; + List trackers = [new() { Url = "https://tracker.example/announce", Status = 2 }]; + + await service.ReconcileProtectionLimitsAsync(torrent, trackers, default); + + client.Verify(c => c.SetShareLimitsAsync("hash", -1, -1, -1, It.IsAny()), Times.Once); + var state = MaintenancePersistence.Read(db, key); + state.AddedOn.Should().Be(2); + state.PreviousRatioLimit.Should().Be(1); + state.PreviousTimeLimit.Should().Be(60); + state.PreviousInactiveTimeLimit.Should().Be(30); + } + + [Fact] + public async Task ApplySeedingLimits_UsesFreshTrackerEvidenceToClearNativeLimits() + { + var config = new TorrentarrConfig(); + config.TorrentClients["client"] = new() + { + Trackers = [new() { Uri = "tracker.example", Protection = new() { MinimumSeedHours = 48 } }] + }; + var (service, client, _) = Create(config); + var torrent = new TorrentInfo + { + ClientInstanceId = "client", + Hash = "hash", + AddedOn = 1, + Progress = 1, + IsPrivate = true, + Ratio = 0, + RatioLimit = 1, + SeedingTimeLimit = 60, + InactiveSeedingTimeLimit = 30 + }; + client.Setup(c => c.GetTorrentTrackersAsync("hash", It.IsAny())) + .ReturnsAsync([new() { Url = "https://tracker.example/announce", Status = 2 }]); + + await service.ApplySeedingLimitsAsync(torrent); + + client.Verify(c => c.GetTorrentTrackersAsync("hash", It.IsAny()), Times.Once); + client.Verify(c => c.SetShareLimitsAsync("hash", -1, -1, -1, It.IsAny()), Times.Once); + } } diff --git a/tests/Torrentarr.Infrastructure.Tests/Services/QbitOverviewBuilderTests.cs b/tests/Torrentarr.Infrastructure.Tests/Services/QbitOverviewBuilderTests.cs index 469bccb0..6705f1d1 100644 --- a/tests/Torrentarr.Infrastructure.Tests/Services/QbitOverviewBuilderTests.cs +++ b/tests/Torrentarr.Infrastructure.Tests/Services/QbitOverviewBuilderTests.cs @@ -30,4 +30,43 @@ public async Task Overview_UsesEffectiveOwnerSubcategorySetting(bool? arrOverrid category["torrentCount"]!.Value().Should().Be(expected); category["totalSize"]!.Value().Should().Be(expected == 2 ? 30 : 10); } + + [Fact] + public async Task Overview_SummarizesBeforeCapAndDeduplicatesOverlappingCategories() + { + var config = new TorrentarrConfig(); + config.TorrentClients["one"] = new() { Host = "fixture", MatchSubcategories = true, ManagedCategories = ["movies", "movies/4k"] }; + var torrents = Enumerable.Range(0, 650).Select(i => new TorrentInfo + { Hash = $"hash{i}", Category = "movies/4k", Size = 10, State = i < 600 ? "stalledDL" : "pausedUP" }).ToList(); + var client = new Mock(); + client.Setup(c => c.GetTorrentsAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(torrents); + var registry = new Mock(); + registry.Setup(r => r.GetClient("one")).Returns(client.Object); + var result = JObject.FromObject(await QbitOverviewBuilder.BuildAsync(config, registry.Object, null)); + result["categories"]!.Single(c => c["category"]!.Value() == "movies/4k")["torrents"]!.Count().Should().Be(500); + result["categories"]!.Single(c => c["category"]!.Value() == "movies/4k")["torrentsTruncated"]!.Value().Should().BeTrue(); + var summary = result["summaries"]!.Single(); + summary["torrentCount"]!.Value().Should().Be(650); + summary["totalSize"]!.Value().Should().Be(6500); + summary["downloading"]!.Value().Should().Be(600); + summary["stopped"]!.Value().Should().Be(50); + } + [Fact] + public async Task Overview_DisconnectedClientRetainsLastInventoryWithError() + { + var config = new TorrentarrConfig(); + config.TorrentClients["one"] = new() { Host = "fixture", ManagedCategories = ["movies"] }; + var client = new Mock(); + client.Setup(c => c.GetTorrentsAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync([new() { Hash = "hash", Category = "movies", Size = 10, State = "seeding" }]); + var registry = new Mock(); + registry.Setup(r => r.GetClient("one")).Returns(client.Object); + var snapshots = new TorrentStatisticsSnapshots(); + await QbitOverviewBuilder.BuildAsync(config, registry.Object, null, snapshots: snapshots); + registry.Setup(r => r.GetClient("one")).Returns((ITorrentClient?)null); + var result = JObject.FromObject(await QbitOverviewBuilder.BuildAsync(config, registry.Object, null, snapshots: snapshots)); + result["summaries"]!.Single()["torrentCount"]!.Value().Should().Be(1); + result["observations"]!["one"]!["Error"]!.Value().Should().Be("Client is disconnected."); + } + } diff --git a/tests/Torrentarr.Infrastructure.Tests/Services/SeedingServiceTests.cs b/tests/Torrentarr.Infrastructure.Tests/Services/SeedingServiceTests.cs index 30bb4c45..4a978be8 100644 --- a/tests/Torrentarr.Infrastructure.Tests/Services/SeedingServiceTests.cs +++ b/tests/Torrentarr.Infrastructure.Tests/Services/SeedingServiceTests.cs @@ -19,7 +19,7 @@ namespace Torrentarr.Infrastructure.Tests.Services; /// public class SeedingServiceTests { - private static SeedingService CreateService(TorrentarrConfig? config = null, StalledUploadTracker? stalled = null) + private static SeedingService CreateService(TorrentarrConfig? config = null, StalledUploadTracker? stalled = null, TorrentInfo? observed = null) { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()) @@ -27,6 +27,8 @@ private static SeedingService CreateService(TorrentarrConfig? config = null, Sta var dbContext = new TorrentarrDbContext(options); var client = new Mock(); client.SetupGet(c => c.Capabilities).Returns(TorrentClientCapabilities.QBittorrent); + client.Setup(c => c.GetTorrentsAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(observed == null ? [] : [observed]); client.Setup(c => c.GetTorrentTrackersAsync(It.IsAny(), It.IsAny())) .ReturnsAsync([new() { Url = "https://tracker.example/announce", Status = 2 }]); var registry = new Mock(); @@ -35,6 +37,231 @@ private static SeedingService CreateService(TorrentarrConfig? config = null, Sta } + [Fact] + public async Task ProtectedTorrent_DoesNotApplyConfiguredTrackerRemoval() + { + var config = new TorrentarrConfig(); + config.TorrentClients["client"] = new() + { + Trackers = [new() { Uri = "tracker.example", RemoveIfExists = true, + HitAndRunMode = "and", MinSeedRatio = 99 }] + }; + using var db = new TorrentarrDbContext(new DbContextOptionsBuilder().UseInMemoryDatabase(Guid.NewGuid().ToString()).Options); + var torrent = new TorrentInfo { ClientInstanceId = "client", Hash = "hash", AddedOn = 1, Progress = 1, IsPrivate = true }; + var client = new Mock(); + client.SetupGet(c => c.Capabilities).Returns(TorrentClientCapabilities.QBittorrent); + client.Setup(c => c.GetTorrentsAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync([torrent]); + client.Setup(c => c.GetTorrentTrackersAsync("hash", It.IsAny())).ReturnsAsync([new() { Url = "https://tracker.example/announce", Status = 2 }]); + client.Setup(c => c.RemoveTrackersAsync("hash", It.IsAny>(), It.IsAny())).ReturnsAsync(true); + var registry = new Mock(); registry.Setup(r => r.GetClient("client")).Returns(client.Object); + var service = new SeedingService(NullLogger.Instance, db, config, registry.Object); + await service.ApplyTrackerActionsForTorrentAsync(torrent); + client.Verify(c => c.RemoveTrackersAsync("hash", It.IsAny>(), It.IsAny()), Times.Never()); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task SuperSeeding_UsesFinalMatchingRuleAndSkipsUnchangedState(bool observed, bool desired) + { + var config = new TorrentarrConfig(); + config.TorrentClients["client"] = new() + { + Trackers = [new() { Uri = "tracker.example", SuperSeedMode = !desired }, + new() { Uri = "tracker.example", SuperSeedMode = desired }] + }; + using var db = new TorrentarrDbContext(new DbContextOptionsBuilder().UseInMemoryDatabase(Guid.NewGuid().ToString()).Options); + var client = new Mock(); + client.SetupGet(c => c.Capabilities).Returns(TorrentClientCapabilities.QBittorrent); + client.Setup(c => c.GetTorrentTrackersAsync("hash", It.IsAny())).ReturnsAsync([new() { Url = "https://tracker.example/announce", Status = 2 }]); + client.Setup(c => c.SetSuperSeedingAsync("hash", desired, It.IsAny())).ReturnsAsync(true); + var registry = new Mock(); registry.Setup(r => r.GetClient("client")).Returns(client.Object); + var service = new SeedingService(NullLogger.Instance, db, config, registry.Object); + var torrent = new TorrentInfo { Hash = "hash", ClientInstanceId = "client", SuperSeeding = observed }; + await service.ApplyTrackerActionsForTorrentAsync(torrent); + await service.ApplyTrackerActionsForTorrentAsync(torrent); + client.Verify(c => c.SetSuperSeedingAsync("hash", desired, It.IsAny()), observed == desired ? Times.Never() : Times.Once()); + client.Verify(c => c.SetSuperSeedingAsync("hash", !desired, It.IsAny()), Times.Never()); + } + + [Fact] + public async Task OrdinarySeedingLimits_AreIndependentOfHnrDeletionClearance() + { + var config = new TorrentarrConfig(); + config.TorrentClients["client"] = new() + { + CategorySeeding = new() + { + RemoveTorrent = 1, + MaxUploadRatio = 0.1, + HitAndRunMode = "and", + MinSeedRatio = 2, + MinSeedingTimeDays = 8 + } + }; + var service = CreateService(config); + var torrent = new TorrentInfo + { + ClientInstanceId = "client", + Hash = "hash", + State = "uploading", + AddedOn = 1, + Progress = 1, + Ratio = 0.5, + SeedingTime = 100, + IsPrivate = true + }; + (await service.SeedingLimitsMetAsync(torrent)).Should().BeTrue(); + (await service.ShouldRemoveTorrentAsync(torrent)).Should().BeFalse(); + } + + [Fact] + public async Task CustomFormatRejection_DoesNotRequireOrdinarySeedingLimits() + { + var config = new TorrentarrConfig(); + config.TorrentClients["client"] = new() { CategorySeeding = new() { RemoveTorrent = 2, MaxSeedingTime = 100000 } }; + var torrent = new TorrentInfo + { + ClientInstanceId = "client", + Hash = "hash", + State = "uploading", + AddedOn = 1, + Progress = 1, + IsPrivate = false, + RatioLimit = -1, + SeedingTimeLimit = -1 + }; + var service = CreateService(config, observed: torrent); + (await service.EvaluateRemovalAsync(torrent, TorrentRemovalIntent.RejectedByCustomFormat)).Allowed.Should().BeTrue(); + (await service.EvaluateRemovalAsync(torrent, TorrentRemovalIntent.SeedingLimit)).Blockers.Should().Contain(b => b.Code == "seeding.limit"); + config.TorrentClients["client"].CategorySeeding.HitAndRunMode = "and"; + config.TorrentClients["client"].CategorySeeding.MinSeedRatio = 2; + (await service.EvaluateRemovalAsync(torrent, TorrentRemovalIntent.RejectedByCustomFormat)).Allowed.Should().BeFalse(); + } + + [Theory] + [InlineData("and", 1, 1, 0.1, 80000, 1.0, 6460, false)] + [InlineData("and", 1, 1, 0.1, 90000, 1.0, 0, false)] + [InlineData("and", 1, 1, 1.0, 86460, 1.0, 0, true)] + [InlineData("or", 1, 1, 1.0, 100, 1.0, 86360, true)] + [InlineData("or", 1, 1, 0.1, 86460, 1.0, 0, true)] + [InlineData("and", 0, 1, 0.0, 86459, 1.0, 1, false)] + [InlineData("and", 1, 0, 0.1, 100, 1.0, 0, false)] + [InlineData("and", 0, 0, 0.0, 100, 1.0, 0, true)] + [InlineData("and", 1, 1, 0.1, 90000, 0.5, 0, false)] + [InlineData("and", 1, 1, 1.0, 100, 0.5, 0, true)] + [InlineData("and", 1, 1, 0.0, 0, 0.05, 0, true)] + public void LegacyDiagnostics_UseClientTimeAndPreserveClearance(string mode, double minimumRatio, + int minimumDays, double ratio, long seedSeconds, double progress, double remaining, bool eligible) + { + foreach (var trackerOverride in new[] { false, true }) + { + var config = new TorrentarrConfig(); + config.TorrentClients["client"] = new() + { + CategorySeeding = new() + { + HitAndRunMode = mode, + MinSeedRatio = minimumRatio, + MinSeedingTimeDays = minimumDays, + TrackerUpdateBuffer = 60 + }, + Trackers = trackerOverride ? [new() { Uri = "tracker.example" }] : [] + }; + var service = CreateService(config); + var torrent = new TorrentInfo + { + ClientInstanceId = "client", + IsPrivate = true, + Ratio = ratio, + SeedingTime = seedSeconds, + Progress = progress, + RatioLimit = -1, + SeedingTimeLimit = -1 + }; + var decision = service.GetProtectionDecisions(torrent, + [new() { Url = "https://tracker.example/announce", Status = 2 }]).Should().ContainSingle().Subject; + decision.State.Should().Be(eligible ? "eligible" : "protected"); + decision.ObservedSeedSeconds.Should().Be(seedSeconds); + decision.RemainingSeedSeconds.Should().Be(remaining); + decision.LocalEstimate.Should().BeFalse(); + decision.SeedAccountingDescription().Should().Contain("Client-reported").And.Contain("remaining time requirement"); + decision.Reason.Should().Contain("tracker update buffer=60s").And.Contain("mode=" + mode); + } + } + + [Theory] + [InlineData(true, false, "hnr.protected")] + [InlineData(false, false, "hnr.unknown")] + [InlineData(true, true, "hnr.protected.native-stop-risk")] + public async Task RemovalDiagnostic_OnlyHealthyProtectionWaitsAreExpected(bool healthy, bool nativeRisk, string code) + { + var config = new TorrentarrConfig(); + config.TorrentClients["client"] = new() { CategorySeeding = new() { HitAndRunMode = "and", MinSeedRatio = 1, MinSeedingTimeDays = 8 } }; + using var db = new TorrentarrDbContext(new DbContextOptionsBuilder().UseInMemoryDatabase(Guid.NewGuid().ToString()).Options); + var client = new Mock(); + client.Setup(c => c.GetTorrentTrackersAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync([new() { Url = "https://tracker.example/announce", Status = healthy ? 2 : 4 }]); + var registry = new Mock(); + registry.Setup(r => r.GetClient("client")).Returns(client.Object); + var service = new SeedingService(NullLogger.Instance, db, config, registry.Object); + var decision = await service.EvaluateRemovalAsync(new() + { + ClientInstanceId = "client", + Hash = "hash", + AddedOn = 1, + IsPrivate = true, + Progress = 1, + SeedingTime = 480000, + Ratio = 0.048, + RatioLimit = nativeRisk ? 1 : -1, + SeedingTimeLimit = -1 + }, TorrentRemovalIntent.RejectedByAllowlist); + decision.Blockers.Should().ContainSingle().Which.Code.Should().Be(code); + decision.IsExpectedProtectionWait().Should().Be(code is "hnr.protected" or "hnr.protected.native-stop-risk"); + decision.Reason.Should().Contain("Client-reported seed=480000s").And.Contain("remaining time requirement=211200s"); + } + + [Fact] + public async Task ModernDeadlineRisk_RemainsActionableAndUsesLocalObservations() + { + var config = new TorrentarrConfig(); + config.TorrentClients["client"] = new() + { + Trackers = [new() { Uri = "tracker.example", + Protection = new() { MinimumSeedHours = 48, FulfillmentHours = 1 } }] + }; + using var db = new TorrentarrDbContext(new DbContextOptionsBuilder().UseInMemoryDatabase(Guid.NewGuid().ToString()).Options); + var client = new Mock(); + List trackers = [new() { Url = "https://tracker.example/announce", Status = 2 }]; + client.Setup(c => c.GetTorrentTrackersAsync("hash", It.IsAny())).ReturnsAsync(trackers); + var registry = new Mock(); + registry.Setup(r => r.GetClient("client")).Returns(client.Object); + var service = new SeedingService(NullLogger.Instance, db, config, registry.Object); + var torrent = new TorrentInfo + { + Hash = "hash", + ClientInstanceId = "client", + AddedOn = DateTimeOffset.UtcNow.AddDays(-1).ToUnixTimeSeconds(), + IsPrivate = true, + Progress = 1, + SeedingTime = 90000, + RatioLimit = -1, + SeedingTimeLimit = -1 + }; + var protection = service.GetProtectionDecisions(torrent, trackers).Should().ContainSingle().Subject; + protection.LocalEstimate.Should().BeTrue(); + protection.ObservedSeedSeconds.Should().Be(0); + protection.RemainingSeedSeconds.Should().Be(48 * 3600); + protection.DeadlineRisk.Should().Be("unachievable"); + var decision = await service.EvaluateRemovalAsync(torrent, TorrentRemovalIntent.RejectedByAllowlist); + decision.IsExpectedProtectionWait().Should().BeFalse(); + decision.Blockers.Should().ContainSingle().Which.Code.Should().Be("hnr.protected.deadline-unachievable"); + decision.Reason.Should().Contain("Locally observed seed=0s").And.Contain("deadline risk=unachievable"); + } + [Fact] public void CategoryOverride_ReplacesOnlyExplicitValues() { @@ -704,4 +931,51 @@ public async Task GetTorrentQueueSortPriorityAsync_TagPriAtOrBelowNegative100_Fa p.Should().Be(-100); } + + [Fact] + public void ConfiguredTrackerWithDisabledHnr_HasNoProtectionObligation() + { + var config = new TorrentarrConfig(); + config.TorrentClients["client"] = new() + { + Trackers = [new() { Uri = "tracker.example", HitAndRunMode = "disabled" }] + }; + var svc = CreateService(config); + var torrent = new TorrentInfo + { + ClientInstanceId = "client", + Hash = "hash", + IsPrivate = true, + Progress = 1, + Ratio = 0, + SeedingTime = 0 + }; + + var decision = svc.GetProtectionDecisions(torrent, + [new() { Url = "https://tracker.example/announce", Status = 2 }]).Should().ContainSingle().Subject; + + decision.State.Should().Be("eligible"); + decision.Reason.Should().Contain("no active clearance requirements"); + } + + [Fact] + public void UnmatchedPrivateTracker_RemainsUnknown() + { + var svc = CreateService(); + var torrent = new TorrentInfo + { + ClientInstanceId = "client", + Hash = "hash", + IsPrivate = true, + Progress = 1, + Ratio = 0, + SeedingTime = 0 + }; + + var decision = svc.GetProtectionDecisions(torrent, + [new() { Url = "https://unknown.example/announce", Status = 2 }]).Should().ContainSingle().Subject; + + decision.State.Should().Be("unknown"); + decision.Reason.Should().Contain("Unmatched private/unknown tracker"); + } } diff --git a/tests/Torrentarr.Infrastructure.Tests/Services/SharedStorageTests.cs b/tests/Torrentarr.Infrastructure.Tests/Services/SharedStorageTests.cs index 03bf834d..9011ef21 100644 --- a/tests/Torrentarr.Infrastructure.Tests/Services/SharedStorageTests.cs +++ b/tests/Torrentarr.Infrastructure.Tests/Services/SharedStorageTests.cs @@ -30,7 +30,7 @@ public SharedStorageTests() private Mock Client(string id, string disk, long remaining, string category = "movies", string state = "downloading", long reserve = 10) { var root = Root(id); - _config.TorrentClients[id] = new() { Host = "fixture", FreeSpace = [new() { Path = root, Enabled = true, Limit = reserve.ToString() }] }; + _config.TorrentClients[id] = new() { Host = "fixture", ManagedCategories = ["movies"], FreeSpace = [new() { Path = root, Enabled = true, Limit = reserve.ToString() }] }; _disks[root] = new(disk, 1000, 100); var client = new Mock(); client.SetupGet(c => c.Capabilities).Returns(TorrentClientCapabilities.QBittorrent); diff --git a/tests/Torrentarr.Infrastructure.Tests/Services/StatisticsEventsTests.cs b/tests/Torrentarr.Infrastructure.Tests/Services/StatisticsEventsTests.cs new file mode 100644 index 00000000..6e01a6a7 --- /dev/null +++ b/tests/Torrentarr.Infrastructure.Tests/Services/StatisticsEventsTests.cs @@ -0,0 +1,52 @@ +using FluentAssertions; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Torrentarr.Core.Configuration; +using Torrentarr.Core.Services; +using Torrentarr.Infrastructure.Database; +using Torrentarr.Infrastructure.Services; +using Xunit; + +namespace Torrentarr.Infrastructure.Tests.Services; + +public class StatisticsEventsTests +{ + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Observer_DetectsCommitsFromAnotherConnection_AndCoalescesNotifications(bool withSupervisor) + { + var path = Path.Combine(Path.GetTempPath(), $"torrentarr-statistics-{Guid.NewGuid():N}.db"); + var connectionString = $"Data Source={path};Pooling=False"; + try + { + var services = new ServiceCollection(); + services.AddDbContext(options => options.UseSqlite(connectionString)); + services.AddSingleton(); + if (withSupervisor) services.AddSingleton(new WorkerProcessSupervisor(new TorrentarrConfig(), NullLogger.Instance, Mock.Of())); + using var provider = services.BuildServiceProvider(); + await using var writer = new SqliteConnection(connectionString); + await writer.OpenAsync(); + await using var command = writer.CreateCommand(); + command.CommandText = "CREATE TABLE observation (value INTEGER)"; + await command.ExecuteNonQueryAsync(); + using var events = new StatisticsEvents(provider.GetRequiredService(), NullLogger.Instance); + var (id, reader) = events.Subscribe(); + (await reader.ReadAsync()).Should().Be(0); + await events.StartAsync(default); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var initial = await reader.ReadAsync(timeout.Token); // first runtime snapshot + command.CommandText = "INSERT INTO observation VALUES (1)"; + await command.ExecuteNonQueryAsync(); + (await reader.ReadAsync(timeout.Token)).Should().BeGreaterThan(initial); + events.Publish(); events.Publish(); events.Publish(); + (await reader.ReadAsync(timeout.Token)).Should().Be(events.Revision); + events.Unsubscribe(id); + await events.StopAsync(timeout.Token); + } + finally { File.Delete(path); } + } +} diff --git a/tests/Torrentarr.Infrastructure.Tests/Services/TorrentCacheServiceTests.cs b/tests/Torrentarr.Infrastructure.Tests/Services/TorrentCacheServiceTests.cs index f273df73..d89999aa 100644 --- a/tests/Torrentarr.Infrastructure.Tests/Services/TorrentCacheServiceTests.cs +++ b/tests/Torrentarr.Infrastructure.Tests/Services/TorrentCacheServiceTests.cs @@ -104,6 +104,7 @@ public void Clear_WipesAllCaches() svc.AddToIgnoreCache("qBit", "h1", TimeSpan.FromHours(1)); svc.MarkFilePrioritiesApplied("qBit", "h1"); svc.MarkFileFiltered("qBit", "h1"); + svc.MarkAllowlistRetry("qBit", "h1"); svc.Clear(); @@ -112,6 +113,20 @@ public void Clear_WipesAllCaches() svc.IsInIgnoreCache("qBit", "h1").Should().BeFalse(); svc.AreFilePrioritiesApplied("qBit", "h1").Should().BeFalse(); svc.IsFileFiltered("qBit", "h1").Should().BeFalse(); + svc.IsAllowlistRetryCoolingDown("qBit", "h1", TimeSpan.FromHours(1)).Should().BeFalse(); + } + + [Fact] + public void AllowlistRetryCooldown_DefersRetriesUntilCooldownElapses() + { + var svc = CreateService(); + + svc.IsAllowlistRetryCoolingDown("qBit", "h1", TimeSpan.FromHours(1)).Should().BeFalse(); + svc.MarkAllowlistRetry("qBit", "h1"); + + svc.IsAllowlistRetryCoolingDown("qBit", "h1", TimeSpan.FromHours(1)).Should().BeTrue(); + svc.IsAllowlistRetryCoolingDown("qBit-b", "h1", TimeSpan.FromHours(1)).Should().BeFalse(); + svc.IsAllowlistRetryCoolingDown("qBit", "h1", TimeSpan.Zero).Should().BeFalse(); } [Fact] @@ -157,32 +172,23 @@ public void FileFilterStates_IgnoreInstanceAndHashCase() } [Fact] - public void AllowlistWarnings_AreThrottledPerInstanceAndHash() + public void Diagnostics_ReportChangesPerClientTorrentAndStreamUntilReset() { var svc = CreateService(); - var now = DateTimeOffset.UtcNow; - var throttle = TimeSpan.FromMinutes(5); - - svc.ShouldLogAllowlistWarning("qBit-a", "shared-hash", now, throttle).Should().BeTrue(); - svc.ShouldLogAllowlistWarning("qBit-a", "shared-hash", now.AddMinutes(1), throttle).Should().BeFalse(); - svc.ShouldLogAllowlistWarning("qBit-b", "shared-hash", now.AddMinutes(1), throttle).Should().BeTrue(); - svc.ShouldLogAllowlistWarning("qBit-a", "shared-hash", now.AddMinutes(6), throttle).Should().BeTrue(); - } - - [Fact] - public void RemovalWarnings_ThrottleSameReasonButReportChangedReasons() - { - var svc = CreateService(); - var now = DateTimeOffset.UtcNow; - var window = TimeSpan.FromMinutes(5); - - svc.ShouldLogRemovalBlock("client-a", "hash", "HnR clearance", now, window).Should().BeTrue(); - svc.ShouldLogRemovalBlock("client-a", "hash", "HnR clearance", now.AddSeconds(5), window).Should().BeFalse(); - svc.ShouldLogRemovalBlock("client-a", "hash", "Missing path mapping", now.AddSeconds(6), window).Should().BeTrue(); - svc.ShouldLogRemovalBlock("client-b", "hash", "Missing path mapping", now.AddSeconds(7), window).Should().BeTrue(); - svc.ShouldLogRemovalBlock("client-a", "hash", "Missing path mapping", now.AddMinutes(6), window).Should().BeTrue(); + svc.DiagnosticChanged("client-a", "hash", "allowlist", "no files").Should().BeTrue(); + svc.DiagnosticChanged("CLIENT-A", "HASH", "allowlist", "no files").Should().BeFalse(); + svc.DiagnosticChanged("client-b", "hash", "allowlist", "no files").Should().BeTrue(); + svc.DiagnosticChanged("client-a", "other", "allowlist", "no files").Should().BeTrue(); + svc.DiagnosticChanged("client-a", "hash", "removal", "hnr.protected").Should().BeTrue(); + svc.DiagnosticChanged("client-a", "hash", "removal", "missing-mapping").Should().BeTrue(); + svc.DiagnosticChanged("client-a", "hash", "removal", "missing-mapping").Should().BeFalse(); + svc.DiagnosticChanged("client-a", "hash", "allowlist", null).Should().BeTrue(); + svc.DiagnosticChanged("client-a", "hash", "allowlist", "no files").Should().BeTrue(); + svc.DiagnosticChanged("client-a", "hash", "removal", "missing-mapping").Should().BeFalse(); svc.InvalidateTorrent("client-a", "hash"); - svc.ShouldLogRemovalBlock("client-a", "hash", "Missing path mapping", now.AddMinutes(6), window).Should().BeTrue(); + svc.DiagnosticChanged("client-a", "hash", "removal", "missing-mapping").Should().BeTrue(); + svc.Clear(); + svc.DiagnosticChanged("client-a", "hash", "removal", "missing-mapping").Should().BeTrue(); } [Fact] diff --git a/tests/Torrentarr.Infrastructure.Tests/Services/TorrentProcessorImportAllowlistTests.cs b/tests/Torrentarr.Infrastructure.Tests/Services/TorrentProcessorImportAllowlistTests.cs index 08b1ba49..032dfecb 100644 --- a/tests/Torrentarr.Infrastructure.Tests/Services/TorrentProcessorImportAllowlistTests.cs +++ b/tests/Torrentarr.Infrastructure.Tests/Services/TorrentProcessorImportAllowlistTests.cs @@ -89,7 +89,7 @@ public async Task CompletedTorrent_AutoDelete_RemovesOnlyOwnedDisallowedFiles(bo new RecoveryIncident { AddedOn = torrent.AddedOn, Stage = "checked", Attempts = 1 }); var result = await ApplyAsync(CreateProcessor(seeding.Object, deletion: deletion.Object), torrent, Config(autoDelete: true), client, completed: true); - Ready(result).Should().Be(!repaired); + Ready(result).Should().BeTrue("cleanup restrictions must not block allowed imports"); File.Exists(allowed).Should().BeTrue(); File.Exists(disallowed).Should().Be(repaired); File.Exists(sibling).Should().BeTrue(); @@ -174,7 +174,7 @@ public async Task CompletedTorrent_MetadataSidecars_DoNotBlockImportWithoutAutoD } [Fact] - public async Task CompletedTorrent_DisallowedNonMetadataExtras_StillBlockImport() + public async Task CompletedTorrent_DisallowedExtras_DoNotBlockConfiguredMedia() { var root = Directory.CreateTempSubdirectory("torrentarr-disallowed-import-"); try @@ -186,7 +186,7 @@ public async Task CompletedTorrent_DisallowedNonMetadataExtras_StillBlockImport( TorrentFile(0, "Movie/movie.mkv"), TorrentFile(1, "Movie/extra.dat")]); Ready(await ApplyAsync(CreateProcessor(), Torrent(content.FullName), Config(autoDelete: false), client, completed: true)) - .Should().BeFalse(); + .Should().BeTrue(); } finally { @@ -288,25 +288,32 @@ public async Task AllFilesExcluded_WhenHnrBlocksDeletion_RemainsPending() seeding.VerifyAll(); } - [Fact] - public async Task PartialExclusion_WhenHnrBlocksCleanup_DoesNotDeleteExcludedFile() + [Theory] + [InlineData(".txt")] + [InlineData(".png")] + [InlineData(".exe")] + [InlineData(".rar")] + [InlineData(".mkv")] + public async Task PartialExclusion_WhenHnrBlocksCleanup_DoesNotBlockImport(string extension) { var root = Directory.CreateTempSubdirectory("torrentarr-hnr-cleanup-"); try { var content = Directory.CreateDirectory(Path.Combine(root.FullName, "Movie")); await File.WriteAllTextAsync(Path.Combine(content.FullName, "movie.mkv"), "video"); - var excludedPath = Path.Combine(content.FullName, "sample.txt"); + var excludedPath = Path.Combine(content.FullName, "sample" + extension); await File.WriteAllTextAsync(excludedPath, "sample"); var torrent = Torrent(content.FullName); var seeding = new Mock(); seeding.Setup(s => s.HnrAllowsDeleteAsync(torrent, "excluded file cleanup by import allowlist", It.IsAny())) .ReturnsAsync(false); - var client = new FileClient([TorrentFile(0, "Movie/movie.mkv"), TorrentFile(1, "Movie/sample.txt")]); + var client = new FileClient([TorrentFile(0, "Movie/movie.mkv"), TorrentFile(1, "Movie/sample" + extension)]); - var result = await ApplyAsync(CreateProcessor(seeding.Object), torrent, Config(autoDelete: true), client, completed: true); + var cfg = Config(autoDelete: true); + cfg.Torrent.FileNameExclusionRegex = ["sample"]; + var result = await ApplyAsync(CreateProcessor(seeding.Object), torrent, cfg, client, completed: true); - Ready(result).Should().BeTrue("valid media can be imported without deleting H&R-protected metadata sidecars"); + Ready(result).Should().BeTrue("valid media can be imported without deleting H&R-protected excluded files"); File.Exists(excludedPath).Should().BeTrue(); seeding.VerifyAll(); } @@ -357,25 +364,40 @@ public async Task CompletedTorrent_WhenContentRootIsMissingButSavePathExists_Rem } [Fact] - public async Task AllowlistWarning_IsDeduplicatedForFiveMinutesThenExpires() + public async Task AllowlistWarning_ReportsChangesAndRecurrenceAfterResolution() { + var logger = new Mock>(); + var processor = CreateProcessor(logger: logger.Object); var torrent = Torrent("/downloads/Movie"); - var seeding = new Mock(); - seeding.Setup(s => s.HnrAllowsDeleteAsync(torrent, It.IsAny(), It.IsAny())) - .ReturnsAsync(false); - var cache = new TorrentCacheService(NullLogger.Instance); - var firstProcessor = CreateProcessor(seeding.Object, cache); - var secondProcessor = CreateProcessor(seeding.Object, cache); - var client = new FileClient([TorrentFile(0, "Movie/sample.txt")]); - var started = DateTimeOffset.UtcNow; - - await ApplyAsync(firstProcessor, torrent, Config(autoDelete: false), client, completed: true); - cache.ShouldLogAllowlistWarning("qBit", torrent.Hash, started.AddMinutes(1), TimeSpan.FromMinutes(5)) - .Should().BeFalse(); + var client = new FileClient([TorrentFile(0, "Movie/bad.exe")]); + await ApplyAsync(processor, torrent, Config(false), client, true); + await ApplyAsync(processor, torrent, Config(false), client, true); + await ApplyAsync(processor, torrent, Config(false), new FileClient([TorrentFile(0, "Movie/other.exe")]), true); + await ApplyAsync(processor, torrent, Config(false), new FileClient([TorrentFile(0, "Movie/movie.mkv")]), true); + await ApplyAsync(processor, torrent, Config(false), client, true); + var logs = logger.Invocations.Where(i => i.Method.Name == "Log" + && i.Arguments[2]?.ToString()?.StartsWith("Import blocked by file allowlist") == true).ToList(); + logs.Count(i => (LogLevel)i.Arguments[0]! == LogLevel.Warning).Should().Be(3); + logs.Count(i => (LogLevel)i.Arguments[0]! == LogLevel.Debug).Should().Be(1); + client.PriorityCalls.Should().Be(3); + } - await ApplyAsync(secondProcessor, torrent, Config(autoDelete: false), client, completed: true); - cache.ShouldLogAllowlistWarning("qBit", torrent.Hash, started.AddMinutes(6), TimeSpan.FromMinutes(5)) - .Should().BeTrue(); + [Theory] + [InlineData("Movie/release.rar", true)] + [InlineData("Movie/release.r00", true)] + [InlineData("Movie/release.R123", true)] + [InlineData("Movie/release.exe", false)] + public async Task RejectedContent_ExplainsArchivesWithoutAllowingImport(string name, bool archive) + { + var logger = new Mock>(); + var client = new FileClient([TorrentFile(0, name), TorrentFile(1, "Movie/sample.mkv")]); + Ready(await ApplyAsync(CreateProcessor(logger: logger.Object), Torrent("/downloads/Movie"), Config(false), client, true)).Should().BeFalse(); + var message = logger.Invocations.Single(i => i.Method.Name == "Log" + && i.Arguments[2]?.ToString()?.StartsWith("Import blocked by file allowlist") == true).Arguments[2]!.ToString()!; + message.Should().Contain(name).And.Contain("extension"); + message.Contains("archive contents cannot be imported").Should().Be(archive); + message.Should().NotContain("archive-only"); + client.PriorityCalls.Should().Be(1); } [Fact] @@ -486,7 +508,7 @@ public async Task AutoDelete_ContentRootSymlink_RemainsBlockedAndDoesNotDeleteTa } [Fact] - public async Task AllowlistWarning_ShowsBoundedExamplesAndKeepsThrottle() + public async Task AllowlistWarning_ShowsBoundedExamplesAndSuppressesRetries() { var logger = new Mock>(); var seeding = new Mock(); @@ -503,16 +525,52 @@ public async Task AllowlistWarning_ShowsBoundedExamplesAndKeepsThrottle() warning.Should().Contain("8 total, 3 more").And.Contain("sample0.txt").And.Contain("sample4.txt").And.NotContain("sample5.txt"); } + [Theory] + [InlineData("hnr.protected", LogLevel.Information)] + [InlineData("hnr.unknown", LogLevel.Warning)] + [InlineData("hnr.protected.native-stop-risk", LogLevel.Information)] + [InlineData("storage.mapping", LogLevel.Warning)] + public async Task RemovalDiagnostics_ReportIssuesIndependentlyOfLogSuppression(string code, LogLevel firstLevel) + { + var logger = new Mock>(); + var issues = new Mock(); + var config = new TorrentarrConfig(); + var processor = CreateProcessor(logger: logger.Object, issues: issues.Object, config: config); + var torrent = Torrent("/downloads/Movie"); + var method = typeof(TorrentProcessor).GetMethod("LogRemovalBlocked", BindingFlags.NonPublic | BindingFlags.Instance)!; + async Task Block(string reason, string blocker = "") + { + var decision = new RemovalDecision([new(blocker.Length == 0 ? code : blocker, reason, "Review settings")]); + await (Task)method.Invoke(processor, [torrent, TorrentRemovalIntent.RejectedByAllowlist, decision.Reason, null, decision])!; + } + await Block("ratio=0.048; seed=480000s"); + await Block("ratio=0.049; seed=480300s"); + config.Apply(new TorrentarrConfig(), 1); + await Block("ratio=0.050; seed=480600s"); + var logs = logger.Invocations.Where(i => i.Method.Name == "Log").ToList(); + logs.Select(i => (LogLevel)i.Arguments[0]!).Should().Equal(firstLevel, LogLevel.Debug, firstLevel); + var expectedWait = code is "hnr.protected" or "hnr.protected.native-stop-risk"; + issues.Verify(i => i.ReportAsync(It.IsAny(), It.IsAny()), expectedWait ? Times.Never() : Times.Exactly(3)); + issues.Verify(i => i.ResolveAsync(It.IsAny(), It.IsAny()), expectedWait ? Times.Exactly(3) : Times.Never()); + await Block("new blocker", "client.disconnected"); + ((LogLevel)logger.Invocations.Last(i => i.Method.Name == "Log").Arguments[0]!).Should().Be(LogLevel.Warning); + var clear = typeof(TorrentProcessor).GetMethod("ClearRemovalBlockAsync", BindingFlags.NonPublic | BindingFlags.Instance)!; + await (Task)clear.Invoke(processor, [torrent, TorrentRemovalIntent.RejectedByAllowlist])!; + await Block("new blocker", "client.disconnected"); + ((LogLevel)logger.Invocations.Last(i => i.Method.Name == "Log").Arguments[0]!).Should().Be(LogLevel.Warning); + } + private TorrentProcessor CreateProcessor( ISeedingService? seeding = null, - ITorrentCacheService? cache = null, ISafeDeletionService? deletion = null, ILogger? logger = null) => new( + ITorrentCacheService? cache = null, ISafeDeletionService? deletion = null, ILogger? logger = null, + IOperationalIssueReporter? issues = null, TorrentarrConfig? config = null) => new( logger ?? NullLogger.Instance, new QBittorrentConnectionManager(NullLogger.Instance), _db, - new TorrentarrConfig(), + config ?? new TorrentarrConfig(), cache ?? new TorrentCacheService(NullLogger.Instance), new DatabaseRestartCoordinator(), - seedingService: seeding, safeDeletion: deletion); + seedingService: seeding, safeDeletion: deletion, issues: issues); private static ArrInstanceConfig Config(bool autoDelete) => new() { diff --git a/tests/Torrentarr.Infrastructure.Tests/Services/TorrentProcessorTests.cs b/tests/Torrentarr.Infrastructure.Tests/Services/TorrentProcessorTests.cs index 83d1980f..b663f19f 100644 --- a/tests/Torrentarr.Infrastructure.Tests/Services/TorrentProcessorTests.cs +++ b/tests/Torrentarr.Infrastructure.Tests/Services/TorrentProcessorTests.cs @@ -62,6 +62,17 @@ private TorrentProcessor CreateProcessor(TorrentarrConfig? config = null, Micros // ── Constructor ──────────────────────────────────────────────────────────── + [Fact] + public async Task RoutinePause_DoesNotDependOnHnrService() + { + var torrent = new TorrentInfo { ClientInstanceId = "qBit", Hash = "hash", AddedOn = 1, Progress = 1, State = "uploading", IsPrivate = true }; + var client = new Mock(); + client.Setup(c => c.GetTorrentsAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync([torrent]); + var processor = CreateProcessor(); + var method = typeof(TorrentProcessor).GetMethod("MayPauseAsync", BindingFlags.NonPublic | BindingFlags.Instance)!; + (await (Task)method.Invoke(processor, [client.Object, torrent, CancellationToken.None])!).Should().BeTrue(); + } + [Fact] public async Task ImportDecision_IsVisibleAtInformation_WhenTorrentIsMissing() { @@ -95,13 +106,29 @@ public void Constructor_SpecialCategoriesComputedFromConfig() // ── ProcessTorrentsAsync – no clients ───────────────────────────────────── [Fact] - public async Task ProcessTorrentsAsync_NoQBitClients_DoesNotThrow() + public async Task ProcessTorrentsAsync_NoQBitClients_ReportsUnavailable() { var svc = CreateProcessor(); var act = async () => await svc.ProcessTorrentsAsync("radarr-hd"); - await act.Should().NotThrowAsync(); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task PartialClientOutage_ProcessesHealthyInventoryWithoutReportingOverallSuccess() + { + var config = new TorrentarrConfig(); config.Settings.Tagless = true; + config.QBitInstances["qBit-good"] = new() { Host = "fixture", ManagedCategories = ["autobrr"] }; + config.QBitInstances["qBit-missing"] = new() { Host = "fixture", ManagedCategories = ["autobrr"] }; + var torrent = new TorrentInfo { Hash = "healthy", ClientInstanceId = "qBit-good", Category = "autobrr", Tags = "qBitrr-ignored" }; + var manager = new QBittorrentConnectionManager(NullLogger.Instance); + RegisterTestClient(manager, "qBit-good", new StubQBittorrentClient(torrent)); + var processor = new TorrentProcessor(NullLogger.Instance, manager, _db, config, + new TorrentCacheService(NullLogger.Instance), new()); + await FluentActions.Awaiting(() => processor.ProcessTorrentsAsync("autobrr")) + .Should().ThrowAsync().WithMessage("*qBit-missing*"); + (await _db.TorrentLibrary.SingleAsync()).Hash.Should().Be("healthy"); } [Fact] @@ -112,8 +139,7 @@ public async Task ProcessTorrentsAsync_SpecialCategoryName_NoQBitClients_DoesNot var svc = CreateProcessor(config); - // No client registered → returns at the client-null guard before the - // special-category skip, so this must not throw. + // Special categories are handled by the host, regardless of client availability. var act = async () => await svc.ProcessTorrentsAsync("failed"); await act.Should().NotThrowAsync(); @@ -158,7 +184,7 @@ public async Task ProcessSingleTorrentAsync_RecheckOnMissingOwningClient_DoesNot } [Fact] - public async Task ProcessTorrentsAsync_PreCancelledToken_DoesNotThrow() + public async Task ProcessTorrentsAsync_PreCancelledToken_PreservesCancellation() { var svc = CreateProcessor(); using var cts = new CancellationTokenSource(); @@ -167,7 +193,7 @@ public async Task ProcessTorrentsAsync_PreCancelledToken_DoesNotThrow() // No client → exits before any awaited work involving the token. var act = async () => await svc.ProcessTorrentsAsync("radarr-hd", cts.Token); - await act.Should().NotThrowAsync(); + await act.Should().ThrowAsync(); } // ── ProcessSpecialCategoriesAsync ───────────────────────────────────────── @@ -263,6 +289,50 @@ public async Task ImportEligibility_RequiresArrOwner(string category, bool hasIm issues.Verify(i => i.ResolveAsync(It.Is(r => r.Code == "import.ownership" && r.SubjectId == torrent.Hash), It.IsAny()), Times.AtLeastOnce()); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task RemovalWarningsAfterRestart_ResolveOnlyAfterSuccessfulProcessing(bool processingFails) + { + var config = new TorrentarrConfig(); + config.Settings.Tagless = true; + config.QBitInstances["qBit"] = new() { ManagedCategories = ["autobrr"] }; + var torrent = new TorrentInfo + { + Hash = "ignored", + ClientInstanceId = "qBit", + Category = "autobrr", + Tags = "qBitrr-ignored", + AddedOn = 1 + }; + _db.OperationalIssues.Add(new() + { + Code = "torrent.removal.RejectedByAllowlist", + ClientInstance = "qBit", + ArrInstance = "PreviousOwner", + SubjectType = "torrent", + SubjectId = torrent.Hash, + Severity = "warning" + }); + await _db.SaveChangesAsync(); + var manager = new QBittorrentConnectionManager(NullLogger.Instance); + RegisterTestClient(manager, "qBit", new StubQBittorrentClient(torrent)); + var issues = new Mock(); + var seeding = new Mock(); + if (processingFails) + seeding.Setup(s => s.ApplySeedingLimitsAsync(torrent, It.IsAny())).ThrowsAsync(new IOException("inspection failed")); + var processor = new TorrentProcessor(NullLogger.Instance, manager, _db, config, + new TorrentCacheService(NullLogger.Instance), new(), seedingService: processingFails ? seeding.Object : null, + issues: issues.Object); + if (processingFails) + await FluentActions.Awaiting(() => processor.ProcessTorrentAsync(torrent.Hash)).Should().ThrowAsync(); + else + await processor.ProcessTorrentAsync(torrent.Hash); + issues.Verify(i => i.ResolveAsync(It.Is(r => r.Code == "torrent.removal.RejectedByAllowlist" + && r.ArrInstance == "PreviousOwner" && r.SubjectId == torrent.Hash), It.IsAny()), + processingFails ? Times.Never() : Times.Once()); + } + [Fact] public async Task ObservingIgnoredUnownedTorrent_ResolvesLegacyOwnershipIssue() { @@ -693,11 +763,14 @@ public async Task ProcessSingleTorrentAsync_CompleteOnFirstObservation_IsInspect } [Theory] - [InlineData(false)] - [InlineData(true)] - public async Task HealthyRepairedTorrent_ImportsWithoutWaitingForDestructiveClearance(bool removalLimitMet) + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(true, true)] + public async Task CompletedTorrent_ImportsWithoutWaitingForDestructiveClearance(bool removalLimitMet, bool repaired) { - var path = Path.GetTempFileName(); + var path = Path.Combine(Path.GetTempPath(), "import-" + Guid.NewGuid().ToString("N") + ".mkv"); + await File.WriteAllTextAsync(path, "video"); try { var torrent = new TorrentInfo @@ -717,17 +790,19 @@ public async Task HealthyRepairedTorrent_ImportsWithoutWaitingForDestructiveClea var manager = new QBittorrentConnectionManager(NullLogger.Instance); RegisterTestClient(manager, "qBit", new StubQBittorrentClient(torrent)); var imports = new Mock(); - imports.Setup(i => i.IsCustomFormatUnmetAsync(torrent.Hash, torrent.Category, It.IsAny())).ReturnsAsync(true); + imports.Setup(i => i.IsCustomFormatUnmetAsync(torrent.Hash, torrent.Category, It.IsAny())).ReturnsAsync(repaired); imports.Setup(i => i.TriggerImportAsync(torrent.Hash, path, torrent.Category, It.IsAny())).ReturnsAsync(new ImportResult { Success = true }); var seeding = new Mock(); seeding.Setup(s => s.ShouldRemoveTorrentAsync(torrent, It.IsAny())).ReturnsAsync(removalLimitMet); var deletion = new Mock(MockBehavior.Strict); var cache = new TorrentCacheService(NullLogger.Instance); cache.MarkFileFiltered("qBit", torrent.Hash); - MaintenancePersistence.Write(_db, MaintenancePersistence.Key("qBit", torrent.Hash, "recovery:ErrorRecovery"), + if (repaired) MaintenancePersistence.Write(_db, MaintenancePersistence.Key("qBit", torrent.Hash, "recovery:ErrorRecovery"), new RecoveryIncident { AddedOn = torrent.AddedOn, Attempts = 1, Stage = "checked", CompletedAt = DateTimeOffset.UtcNow, LastObserved = DateTimeOffset.UtcNow }); + var validation = new Mock(); + validation.Setup(v => v.ValidateFileAsync(path, It.IsAny())).ReturnsAsync(new MediaValidationResult { IsValid = true }); var processor = new TorrentProcessor(NullLogger.Instance, manager, _db, config, cache, new DatabaseRestartCoordinator(), - imports.Object, seeding.Object, mediaValidation: Mock.Of(), safeDeletion: deletion.Object); + imports.Object, seeding.Object, mediaValidation: validation.Object, safeDeletion: deletion.Object); await processor.ProcessTorrentAsync(torrent.Hash); imports.Verify(i => i.TriggerImportAsync(torrent.Hash, path, torrent.Category, It.IsAny()), Times.Once); imports.Verify(i => i.BlocklistAndReSearchAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); diff --git a/tests/Torrentarr.Infrastructure.Tests/Services/TorrentStatisticsSnapshotsTests.cs b/tests/Torrentarr.Infrastructure.Tests/Services/TorrentStatisticsSnapshotsTests.cs new file mode 100644 index 00000000..325be729 --- /dev/null +++ b/tests/Torrentarr.Infrastructure.Tests/Services/TorrentStatisticsSnapshotsTests.cs @@ -0,0 +1,78 @@ +using FluentAssertions; +using Moq; +using Torrentarr.Core.Configuration; +using Torrentarr.Core.Interfaces; +using Torrentarr.Core.Models; +using Torrentarr.Infrastructure.Services; +using Xunit; + +namespace Torrentarr.Infrastructure.Tests.Services; + +public class TorrentStatisticsSnapshotsTests +{ + [Fact] + public async Task CompleteSnapshots_AreSharedAcrossConcurrentRequests_AndIntervalsApplyLive() + { + var config = new TorrentarrConfig(); + config.TorrentClients["one"] = new() { TorrentScanIntervalSeconds = 30 }; + config.TorrentClients["two"] = new() { TorrentScanIntervalSeconds = 120 }; + var first = new Mock(); + first.Setup(c => c.GetTorrentsAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync([new TorrentInfo { Hash = "same" }]); + var second = new Mock(); + second.Setup(c => c.GetTorrentsAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync([]); + var snapshots = new TorrentStatisticsSnapshots(); + await Task.WhenAll(Enumerable.Range(0, 10).Select(_ => snapshots.GetAsync("one", config, first.Object, default))); + first.Verify(c => c.GetTorrentsAsync(null, null, It.IsAny()), Times.Once); + var empty = await snapshots.GetAsync("two", config, second.Object, default); + empty.ObservedAt.Should().NotBeNull(); + empty.Torrents.Should().BeEmpty(); + empty.ScanIntervalSeconds.Should().Be(120); + config.TorrentClients["one"].TorrentScanIntervalSeconds = 5; + var updated = await snapshots.GetAsync("one", config, first.Object, default); + updated.ScanIntervalSeconds.Should().Be(5); + updated.Torrents.Single().QBitInstanceName.Should().Be("one"); + first.Verify(c => c.GetTorrentsAsync(null, null, It.IsAny()), Times.Exactly(2)); + } + + [Fact] + public async Task FailedRefresh_RetainsLastCompleteObservation_AndDoesNotTurnFailureIntoZero() + { + var config = new TorrentarrConfig(); + config.TorrentClients["one"] = new() { TorrentScanIntervalSeconds = 30 }; + var client = new Mock(); + client.SetupSequence(c => c.GetTorrentsAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync([new TorrentInfo { Hash = "abc" }]).ThrowsAsync(new IOException("offline")); + var snapshots = new TorrentStatisticsSnapshots(); + var good = await snapshots.GetAsync("one", config, client.Object, default); + config.TorrentClients["one"].TorrentScanIntervalSeconds = 5; + var stale = await snapshots.GetAsync("one", config, client.Object, default); + stale.ObservedAt.Should().Be(good.ObservedAt); + stale.Torrents.Single().Hash.Should().Be("abc"); + stale.Error.Should().NotBeNull(); + var absent = new Mock(); + absent.Setup(c => c.GetTorrentsAsync(It.IsAny(), It.IsAny(), It.IsAny())).ThrowsAsync(new IOException()); + var unavailable = await snapshots.GetAsync("one", config, absent.Object, default); + unavailable.ObservedAt.Should().BeNull(); + unavailable.Error.Should().NotBeNull(); + } + [Fact] + public async Task ParentAndChildCategories_DoNotDoubleCount_OrUseTruncatedListTotals() + { + var config = new TorrentarrConfig(); + config.TorrentClients["one"] = new() { Host = "fixture", ManagedCategories = ["movies", "movies/4k"], MatchSubcategories = true }; + var client = new Mock(); + client.Setup(c => c.GetTorrentsAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(Enumerable.Range(0, 600).Select(i => new TorrentInfo { Hash = i.ToString(), Category = "movies/4k" }).ToList()); + var registry = new Mock(); + registry.Setup(r => r.GetClient("one")).Returns(client.Object); + var data = await QbitOverviewBuilder.BuildAsync(config, registry.Object, "one", snapshots: new TorrentStatisticsSnapshots()); + using var json = System.Text.Json.JsonDocument.Parse(System.Text.Json.JsonSerializer.Serialize(data)); + var categories = json.RootElement.GetProperty("categories").EnumerateArray().ToList(); + categories.Single(c => c.GetProperty("category").GetString() == "movies").GetProperty("torrentCount").GetInt32().Should().Be(0); + var child = categories.Single(c => c.GetProperty("category").GetString() == "movies/4k"); + child.GetProperty("torrentCount").GetInt32().Should().Be(600); + child.GetProperty("torrents").GetArrayLength().Should().Be(QbitOverviewBuilder.MaxTorrentsPerCategory); + } + +} diff --git a/tests/Torrentarr.Infrastructure.Tests/Services/TrackerMessagePreservationTests.cs b/tests/Torrentarr.Infrastructure.Tests/Services/TrackerMessagePreservationTests.cs new file mode 100644 index 00000000..149a6e95 --- /dev/null +++ b/tests/Torrentarr.Infrastructure.Tests/Services/TrackerMessagePreservationTests.cs @@ -0,0 +1,144 @@ +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Torrentarr.Core.Configuration; +using Torrentarr.Core.Interfaces; +using Torrentarr.Core.Models; +using Torrentarr.Infrastructure.Database; +using Torrentarr.Infrastructure.Services; +using Xunit; + +namespace Torrentarr.Infrastructure.Tests.Services; + +public sealed class TrackerMessagePreservationTests +{ + private const string Message = "skipping tracker announce (unreachable)"; + private const string ConfiguredUrl = "https://tracker.example/announce"; + + private static (SeedingService Service, Mock Client) Create( + bool configureTracker, + bool removeDeadTrackers) + { + var config = new TorrentarrConfig(); + config.TorrentClients["client"] = new() + { + Trackers = configureTracker ? [new() { Uri = "tracker.example" }] : [] + }; + config.ArrInstances["Radarr-Movies"] = new() + { + Type = "radarr", + Category = "movies", + Torrent = new() + { + SeedingMode = new() + { + RemoveDeadTrackers = removeDeadTrackers, + RemoveTrackerWithMessage = ["unreachable"] + } + } + }; + var db = new TorrentarrDbContext(new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()).Options); + var client = new Mock(); + client.SetupGet(c => c.Capabilities).Returns(TorrentClientCapabilities.QBittorrent); + client.Setup(c => c.RemoveTrackersAsync(It.IsAny(), It.IsAny>(), It.IsAny())) + .ReturnsAsync(true); + var registry = new Mock(); + registry.Setup(r => r.GetClient("client")).Returns(client.Object); + return (new SeedingService(NullLogger.Instance, db, config, registry.Object), client); + } + + private static TorrentInfo Torrent(bool isPrivate) => new() + { + ClientInstanceId = "client", + Hash = "hash", + Name = "torrent", + Category = "movies", + AddedOn = 1, + Progress = 1, + IsPrivate = isPrivate + }; + + private static async Task Run( + SeedingService service, + Mock client, + TorrentInfo torrent, + params TorrentTracker[] trackers) + { + client.Setup(c => c.GetTorrentsAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync([torrent]); + client.Setup(c => c.GetTorrentTrackersAsync("hash", It.IsAny())) + .ReturnsAsync(trackers.ToList()); + await service.ProcessTrackerMessagesAsync(client.Object, torrent, default); + } + + [Fact] + public async Task PrivateConfiguredTracker_IsPreserved() + { + var (service, client) = Create(configureTracker: true, removeDeadTrackers: false); + var torrent = Torrent(isPrivate: true); + + await Run(service, client, torrent, + new TorrentTracker { Url = ConfiguredUrl, Status = 4, Tier = 0, Msg = Message }); + + client.Verify(c => c.RemoveTrackersAsync(It.IsAny(), It.IsAny>(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task PrivateLastAnnounceUrl_IsPreserved() + { + var (service, client) = Create(configureTracker: false, removeDeadTrackers: false); + var torrent = Torrent(isPrivate: true); + + await Run(service, client, torrent, + new TorrentTracker { Url = "https://only.example/announce", Status = 4, Tier = 0, Msg = Message }); + + client.Verify(c => c.RemoveTrackersAsync(It.IsAny(), It.IsAny>(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task PrivateTrailingAnnounceUrl_IsPreservedAfterExtraRemoved() + { + var (service, client) = Create(configureTracker: false, removeDeadTrackers: false); + var torrent = Torrent(isPrivate: true); + var removed = new List(); + + client.Setup(c => c.RemoveTrackersAsync("hash", It.IsAny>(), It.IsAny())) + .Callback, CancellationToken>((_, urls, _) => removed.AddRange(urls)) + .ReturnsAsync(true); + await Run(service, client, torrent, + new TorrentTracker { Url = "https://extra.example/announce", Status = 4, Tier = 0, Msg = Message }, + new TorrentTracker { Url = "https://last.example/announce", Status = 4, Tier = 0, Msg = Message }); + + removed.Should().Equal("https://extra.example/announce"); + } + + [Fact] + public async Task PublicTracker_IsRemoved() + { + var (service, client) = Create(configureTracker: false, removeDeadTrackers: false); + var torrent = Torrent(isPrivate: false); + var removed = new List(); + + client.Setup(c => c.RemoveTrackersAsync("hash", It.IsAny>(), It.IsAny())) + .Callback, CancellationToken>((_, urls, _) => removed.AddRange(urls)) + .ReturnsAsync(true); + await Run(service, client, torrent, + new TorrentTracker { Url = "https://public.example/announce", Status = 4, Tier = 0, Msg = Message }); + + removed.Should().Equal("https://public.example/announce"); + } + + [Fact] + public async Task RemoveDeadTrackers_StillPreservesEveryTracker() + { + var (service, client) = Create(configureTracker: false, removeDeadTrackers: true); + var torrent = Torrent(isPrivate: false); + + await Run(service, client, torrent, + new TorrentTracker { Url = "https://public.example/announce", Status = 4, Tier = 0, Msg = Message }); + + client.Verify(c => c.RemoveTrackersAsync(It.IsAny(), It.IsAny>(), It.IsAny()), Times.Never); + } +} diff --git a/tests/Torrentarr.Infrastructure.Tests/Services/TransferHistoryTests.cs b/tests/Torrentarr.Infrastructure.Tests/Services/TransferHistoryTests.cs new file mode 100644 index 00000000..c14f4151 --- /dev/null +++ b/tests/Torrentarr.Infrastructure.Tests/Services/TransferHistoryTests.cs @@ -0,0 +1,147 @@ +using FluentAssertions; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Torrentarr.Core.Configuration; +using Torrentarr.Core.Interfaces; +using Torrentarr.Core.Models; +using Torrentarr.Infrastructure.Database; +using Torrentarr.Infrastructure.Database.Models; +using Torrentarr.Infrastructure.Services; +using Xunit; + +namespace Torrentarr.Infrastructure.Tests.Services; + +public sealed class TransferHistoryTests +{ + private static readonly DateTimeOffset Now = DateTimeOffset.Parse("2026-11-04T12:00:00Z"); + + [Fact] + public async Task Query_BucketsRatesAndPreservesPartialAndMissingCoverageAcrossReopen() + { + using var connection = new SqliteConnection("Data Source=:memory:"); + connection.Open(); + var options = new DbContextOptionsBuilder().UseSqlite(connection).Options; + var minute = Now.ToUnixTimeSeconds() / 60; + await using (var db = new TorrentarrDbContext(options)) + { + db.Database.EnsureCreated(); + db.TransferSamples.AddRange( + new TransferSample { ClientInstance = "one", Minute = minute - 5, DownloadRate = 100, UploadRate = 10 }, + new TransferSample { ClientInstance = "one", Minute = minute - 4, DownloadRate = 300, UploadRate = 30 }, + new TransferSample { ClientInstance = "one", Minute = minute, DownloadRate = 900 }); + await db.SaveChangesAsync(); + } + await using var reopened = new TorrentarrDbContext(options); + var result = await TransferHistoryApi.QueryAsync(reopened, ["one", "offline"], "24h", Now); + result.Series[0].Points.Should().HaveCount(288); + var last = result.Series[0].Points.Last(); + last.DownloadRate.Should().Be(200); + last.UploadRate.Should().Be(20); + last.Samples.Should().Be(2); + last.ExpectedSamples.Should().Be(5); + result.Series[0].Points.First().DownloadRate.Should().BeNull(); + result.Series[1].Points.Should().OnlyContain(p => p.DownloadRate == null && p.Samples == 0); + } + + [Theory] + [InlineData("1h", 60, 1)] + [InlineData("24h", 288, 5)] + [InlineData("7d", 336, 30)] + public async Task Query_ReturnsBoundedAlignedRanges(string range, int count, int bucket) + { + await using var db = new TorrentarrDbContext(new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()).Options); + var result = await TransferHistoryApi.QueryAsync(db, ["one"], range, Now); + result.BucketMinutes.Should().Be(bucket); + result.Series.Single().Points.Should().HaveCount(count); + result.Series.Single().Points.Last().Timestamp.Should().BeBefore(Now); + } + + [Fact] + public async Task Collector_SkipsUnavailableAndDisabledClientsAndPrunesWithoutDuplicateMinutes() + { + using var connection = new SqliteConnection("Data Source=:memory:"); + connection.Open(); + var services = new ServiceCollection(); + services.AddDbContext(o => o.UseSqlite(connection)); + using var provider = services.BuildServiceProvider(); + using (var scope = provider.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.EnsureCreated(); + db.TransferSamples.Add(new() { ClientInstance = "one", Minute = Now.ToUnixTimeSeconds() / 60 - 10081 }); + await db.SaveChangesAsync(); + } + var config = new TorrentarrConfig(); + foreach (var name in new[] { "one", "offline", "unsupported", "disabled" }) + config.TorrentClients[name] = new() { Host = "fixture", Disabled = name == "disabled" }; + var registry = new Mock(); + var one = new Mock(); + one.Setup(c => c.GetTransferInfoAsync(It.IsAny())) + .ReturnsAsync(new TorrentTransferRecord { DownloadSpeed = 123, UploadSpeed = 45 }); + var offline = new Mock(); + offline.Setup(c => c.GetTransferInfoAsync(It.IsAny())).ThrowsAsync(new IOException("fixture offline")); + var unsupported = new Mock(); + registry.Setup(r => r.GetClient("one")).Returns(one.Object); + registry.Setup(r => r.GetClient("offline")).Returns(offline.Object); + registry.Setup(r => r.GetClient("unsupported")).Returns(unsupported.Object); + var collector = new TransferHistoryCollector(provider.GetRequiredService(), config, registry.Object, + NullLogger.Instance); + await collector.CollectAsync(Now, default); + await collector.CollectAsync(Now.AddSeconds(20), default); + using var check = provider.CreateScope(); + var saved = await check.ServiceProvider.GetRequiredService().TransferSamples.ToListAsync(); + saved.Should().ContainSingle().Which.DownloadRate.Should().Be(123); + registry.Verify(r => r.GetClient("disabled"), Times.Never); + } + + [Fact] + public async Task Collector_UsesIndependentClientIntervalsAndAppliesChangesLive() + { + using var connection = new SqliteConnection("Data Source=:memory:"); + connection.Open(); + var services = new ServiceCollection(); + services.AddDbContext(o => o.UseSqlite(connection)); + using var provider = services.BuildServiceProvider(); + using (var scope = provider.CreateScope()) + scope.ServiceProvider.GetRequiredService().Database.EnsureCreated(); + var config = new TorrentarrConfig(); + config.TorrentClients["fast"] = new() { Host = "fixture", TorrentScanIntervalSeconds = 10 }; + config.TorrentClients["slow"] = new() { Host = "fixture", TorrentScanIntervalSeconds = 60 }; + var fast = new Mock(); + var slow = new Mock(); + foreach (var client in new[] { fast, slow }) + client.Setup(c => c.GetTransferInfoAsync(It.IsAny())) + .ReturnsAsync(new TorrentTransferRecord { DownloadSpeed = 0, UploadSpeed = 0 }); + var registry = new Mock(); + registry.Setup(r => r.GetClient("fast")).Returns(fast.Object); + registry.Setup(r => r.GetClient("slow")).Returns(slow.Object); + var collector = new TransferHistoryCollector(provider.GetRequiredService(), config, registry.Object, + NullLogger.Instance); + await collector.CollectAsync(Now, default); + await collector.CollectAsync(Now.AddSeconds(10), default); + fast.Verify(c => c.GetTransferInfoAsync(It.IsAny()), Times.Exactly(2)); + slow.Verify(c => c.GetTransferInfoAsync(It.IsAny()), Times.Once); + config.TorrentClients["slow"].TorrentScanIntervalSeconds = 10; + await collector.CollectAsync(Now.AddSeconds(11), default); + slow.Verify(c => c.GetTransferInfoAsync(It.IsAny()), Times.Exactly(2)); + } + + [Fact] + public void Migration_CreatesHistoryTableOnExistingDatabaseAndIsRepeatable() + { + using var db = new TorrentarrDbContext(new DbContextOptionsBuilder() + .UseSqlite("Data Source=:memory:").Options); + db.Database.OpenConnection(); + db.Database.EnsureCreated(); + db.Database.ExecuteSqlRaw("DROP TABLE torrentarr_transfer_samples"); + ManualSqliteMigrations.Apply(db); + db.TransferSamples.Add(new() { ClientInstance = "one", Minute = 123 }); + db.SaveChanges(); + ManualSqliteMigrations.Apply(db); + db.TransferSamples.Single().Minute.Should().Be(123); + } +} diff --git a/tests/Torrentarr.Infrastructure.Tests/Services/WorkerLifecycleTests.cs b/tests/Torrentarr.Infrastructure.Tests/Services/WorkerLifecycleTests.cs index dfe64879..33af8e34 100644 --- a/tests/Torrentarr.Infrastructure.Tests/Services/WorkerLifecycleTests.cs +++ b/tests/Torrentarr.Infrastructure.Tests/Services/WorkerLifecycleTests.cs @@ -13,6 +13,24 @@ namespace Torrentarr.Infrastructure.Tests.Services; public class WorkerLifecycleTests { + [Fact] + public async Task StoppedWorker_RetainsStructuredLastObservation() + { + var config = Config(); + using var supervisor = new WorkerProcessSupervisor(config, NullLogger.Instance, Mock.Of()); + var observations = (Dictionary)typeof(WorkerProcessSupervisor) + .GetField("_lastObservations", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!.GetValue(supervisor)!; + var heartbeat = new WorkerHeartbeat { Instance = "Radarr", SearchesTriggered = 0, ItemsSearched = 12, CategoryCount = 40, DownloadingCount = 3, ObservedAt = DateTimeOffset.UtcNow }; + observations["Radarr"] = System.Text.Json.JsonSerializer.Deserialize(System.Text.Json.JsonSerializer.Serialize(heartbeat, WorkerHeartbeat.JsonOptions), WorkerHeartbeat.JsonOptions)!; + var status = (await supervisor.GetProcessStatusAsync())["Radarr"]; + status.IsAlive.Should().BeFalse(); + status.SearchesTriggered.Should().Be(0); + status.ItemsSearched.Should().Be(12); + status.CategoryCount.Should().Be(40); + status.DownloadingCount.Should().Be(3); + status.ObservationError.Should().Contain("last successful"); + } + [Fact] public void Heartbeat_RejectsPriorProcessAndDetectsStuckLoopDespiteFreshPublisher() { @@ -208,7 +226,7 @@ public async Task ImmediateChildExit_CannotDisposeProcessDuringStatusRead() { await supervisor.RestartProcessAsync("Radarr"); await supervisor.GetProcessStatusAsync(); - supervisor.MonitorWorkers(); + await supervisor.MonitorWorkersAsync(); } } finally { await orchestrator.StopAsync(); } @@ -291,6 +309,36 @@ await File.WriteAllTextAsync(status, System.Text.Json.JsonSerializer.Serialize(n finally { await orchestrator.StopAsync(); File.Delete(status); } } + [Fact] + public async Task RequestedRestart_UsesGracefulShutdownBeforeTerminatingWorker() + { + if (OperatingSystem.IsWindows()) return; + var config = Config(); config.Settings.AutoRestartProcesses = false; + var evidence = Path.GetTempFileName(); var status = Path.GetTempFileName(); + using var supervisor = new WorkerProcessSupervisor(config, NullLogger.Instance, + Mock.Of(), _ => new ProcessStartInfo("/bin/sh") + { + ArgumentList = { "-c", "read command; printf '%s' \"$command\" > \"$1\"", "worker", evidence }, + UseShellExecute = false, + RedirectStandardInput = true + }); + var orchestrator = (IProcessOrchestrator)supervisor; + try + { + await orchestrator.StartAsync(); + var pid = (await supervisor.GetProcessStatusAsync())["Radarr"].ProcessId!.Value; + var files = (Dictionary)typeof(WorkerProcessSupervisor) + .GetField("_statusFiles", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!.GetValue(supervisor)!; + files["Radarr"] = status; + await File.WriteAllTextAsync(status, System.Text.Json.JsonSerializer.Serialize(new WorkerHeartbeat + { Instance = "Radarr", Pid = pid, Heartbeat = DateTimeOffset.UtcNow, RestartRequested = true }, WorkerHeartbeat.JsonOptions)); + await supervisor.MonitorWorkersAsync(); + (await File.ReadAllTextAsync(evidence)).Should().Be("shutdown"); + (await supervisor.GetProcessStatusAsync())["Radarr"].IsAlive.Should().BeFalse(); + } + finally { await orchestrator.StopAsync(); File.Delete(evidence); File.Delete(status); } + } + private static TorrentarrConfig Config() { var config = new TorrentarrConfig(); diff --git a/tests/scripts/test_check_openapi_drift.py b/tests/scripts/test_check_openapi_drift.py index 1c345e62..6071deb7 100644 --- a/tests/scripts/test_check_openapi_drift.py +++ b/tests/scripts/test_check_openapi_drift.py @@ -17,6 +17,16 @@ def document(paths, components=None): class OpenApiDriftTests(unittest.TestCase): + def test_statistics_generation_documents_coverage_and_detects_drift(self): + contract = json.loads((SCRIPT.parents[1] / "docs/assets/openapi.json").read_text()) + self.assertEqual(module.statistics_drift(contract), []) + schema = contract['paths']['/web/statistics/transfers']['get']['responses']['200']['content']['application/json']['schema'] + bucket = schema['properties']['series']['items']['properties']['points']['items'] + self.assertTrue(bucket['properties']['downloadRate']['nullable']) + self.assertIn('expectedSamples', bucket['properties']) + del contract['paths']['/web/statistics/transfers'] + self.assertTrue(module.statistics_drift(contract)) + def test_checked_in_contract_documents_native_cleanup_and_inspection_failure(self): contract = json.loads((SCRIPT.parents[1] / "docs/assets/openapi.json").read_text()) self.assertEqual(module.maintenance_drift(contract), []) @@ -52,6 +62,23 @@ def test_config_warning_is_optional_and_cannot_hide_other_drift(self): schema["properties"]["warning"] = "malformed" self.assertTrue(module.compare(local, upstream, set())) + def test_restart_requirements_are_optional_and_cannot_hide_other_drift(self): + for route in ("/web/config", "/api/config"): + upstream = document({route: {"post": {"responses": {"200": {"content": {"application/json": {"schema": { + "type": "object", "properties": {"status": {"type": "string"}}, "required": ["status"] + }}}}}}}}) + local = deepcopy(upstream) + schema = local["paths"][route]["post"]["responses"]["200"]["content"]["application/json"]["schema"] + schema["properties"]["restartRequirements"] = {"type": "array", "items": { + "type": "object", "properties": {key: {"type": "string"} for key in ("component", "category", "kind")}, + "required": ["component", "category", "kind"]}} + self.assertEqual(module.compare(local, upstream, set()), []) + schema["required"].append("restartRequirements") + self.assertTrue(module.compare(local, upstream, set())) + schema["required"].remove("restartRequirements") + schema["properties"]["status"] = {"type": "integer"} + self.assertTrue(module.compare(local, upstream, set())) + def test_accepts_only_the_declared_named_client_status_contract(self): for route in ("/web/status", "/api/status"): def status(schema): diff --git a/webui/index.html b/webui/index.html index 2cc13661..c5a0a42a 100644 --- a/webui/index.html +++ b/webui/index.html @@ -2,6 +2,7 @@ + @@ -12,7 +13,7 @@ name="description" content="A Radarr and Sonarr companion app for managing torrents" /> - + Torrentarr diff --git a/webui/public/favicon-16x16.png b/webui/public/favicon-16x16.png index 00c828e7..8cbdfb54 100644 Binary files a/webui/public/favicon-16x16.png and b/webui/public/favicon-16x16.png differ diff --git a/webui/public/favicon-32x32.png b/webui/public/favicon-32x32.png index c3c3707d..9e75b434 100644 Binary files a/webui/public/favicon-32x32.png and b/webui/public/favicon-32x32.png differ diff --git a/webui/public/favicon-48x48.png b/webui/public/favicon-48x48.png index 377430e3..4d5e4fe1 100644 Binary files a/webui/public/favicon-48x48.png and b/webui/public/favicon-48x48.png differ diff --git a/webui/public/favicon.ico b/webui/public/favicon.ico index 9ac3276d..3010cad2 100644 Binary files a/webui/public/favicon.ico and b/webui/public/favicon.ico differ diff --git a/webui/public/icon-192.png b/webui/public/icon-192.png index c1a3b9f0..1d74adc4 100644 Binary files a/webui/public/icon-192.png and b/webui/public/icon-192.png differ diff --git a/webui/public/icon-512.png b/webui/public/icon-512.png index b3251392..6ca43f5d 100644 Binary files a/webui/public/icon-512.png and b/webui/public/icon-512.png differ diff --git a/webui/public/logov2-clean.svg b/webui/public/logov2-clean.svg index 70315292..e0f80932 100644 --- a/webui/public/logov2-clean.svg +++ b/webui/public/logov2-clean.svg @@ -1,48 +1,19 @@ - - - - - - - - - - - + + Torrentarr + + + + + + + + + + + + + + + + diff --git a/webui/public/manifest.json b/webui/public/manifest.json index d4111f08..cb7ab338 100644 --- a/webui/public/manifest.json +++ b/webui/public/manifest.json @@ -1,5 +1,5 @@ { - "background_color": "#101114", + "background_color": "#151719", "categories": ["utilities", "productivity"], "description": "A Radarr and Sonarr companion app for managing torrents", "display": "standalone", @@ -34,5 +34,5 @@ "screenshots": [], "short_name": "Torrentarr", "start_url": "/", - "theme_color": "#7aa2f7" + "theme_color": "#151719" } diff --git a/webui/src/AppShell.tsx b/webui/src/AppShell.tsx index 4fd5096d..7c8b6207 100644 --- a/webui/src/AppShell.tsx +++ b/webui/src/AppShell.tsx @@ -1,3 +1,5 @@ +import { STATISTICS_UPDATED } from "./hooks/useStatisticsUpdates"; +import { useStatisticsStream } from "./hooks/useStatisticsStream"; import React, { useCallback, useEffect, @@ -23,8 +25,10 @@ import { } from "./api/client"; import { webPath } from "./api/urlBase"; import type { ArrInfo, MetaResponse } from "./api/types"; +import { arrTypeLabel } from "./config/arrSections"; +import { THEME_OPTIONS } from "./utils/theme"; import { IconImage } from "./components/IconImage"; -import ExternalIcon from "./icons/github.svg"; +import GitHubIcon from "./icons/github.svg"; import RefreshIcon from "./icons/refresh-arrow.svg"; import UpdateIcon from "./icons/up-arrow.svg"; import ProcessesIcon from "./icons/process.svg"; @@ -37,6 +41,7 @@ import ReadarrIcon from "./icons/readarr.svg"; import QbitIcon from "./icons/qbittorrent.svg"; import ConfigIcon from "./icons/gear.svg"; import logoUrl from "./icons/logov2-clean.svg"; +import { ShellDialog } from "./components/ShellDialog"; import { safeClick } from "./utils/safeClick"; import { NAVIGATE_TAB_EVENT, @@ -130,6 +135,22 @@ function AppShell({ onSignOut: () => void; initialMeta?: MetaResponse | null; }): JSX.Element { + useStatisticsStream(); + const [navInstances, setNavInstances] = useState< + Partial> + >({}); + const [instanceSelections, setInstanceSelections] = useState< + Partial> + >({}); + const [navigationOpen, setNavigationOpen] = useState(false); + const [navCollapsed, setNavCollapsed] = useState(false); + const [railSelectionPending, setRailSelectionPending] = useState(false); + const [expandedNav, setExpandedNav] = useState>>( + {}, + ); + const selectInstance = useCallback((tab: Tab, instance: string) => { + setInstanceSelections((previous) => ({ ...previous, [tab]: instance })); + }, []); const [issueSelection, setIssueSelection] = useState<{ instance: string; category: "automation" | "system"; @@ -157,10 +178,12 @@ function AppShell({ if (!document.hidden) void refresh(); }; document.addEventListener("visibilitychange", visible); + window.addEventListener(STATISTICS_UPDATED, visible); return () => { mounted = false; window.clearInterval(interval); document.removeEventListener("visibilitychange", visible); + window.removeEventListener(STATISTICS_UPDATED, visible); }; }, []); const [activeTab, setActiveTab] = useState("processes"); @@ -235,6 +258,12 @@ function AppShell({ setConfigKey((prev) => prev + 1); setConfigDirty(false); } + if (railSelectionPending) { + setRailSelectionPending(false); + setNavCollapsed(true); + } + setExpandedNav((current) => (current[tabId] ? current : {})); + setNavigationOpen(false); tabScroll.current[activeTab] = window.scrollY; startTransition(() => { setActiveTab(tabId); @@ -242,14 +271,21 @@ function AppShell({ }); setSearchValue(""); }, - [activeTab, configDirty, manageDirty, markTabVisited, setSearchValue], + [ + activeTab, + configDirty, + manageDirty, + markTabVisited, + railSelectionPending, + setSearchValue, + ], ); const { viewDensity, setViewDensity, theme, setTheme, liveArr, setLiveArr } = useWebUI(); const isOnline = useNetworkStatus(); const [meta, setMeta] = useState(initialMeta); const [metaLoading, setMetaLoading] = useState(false); - const [showHelpMenu, setShowHelpMenu] = useState(false); + const [displayOpen, setDisplayOpen] = useState(false); const [showChangelog, setShowChangelog] = useState(false); const [showAlreadyUpToDateModal, setShowAlreadyUpToDateModal] = useState(false); @@ -396,9 +432,8 @@ function AppShell({ return; } - // ESC - Clear search / close help menu + // ESC - Clear search if (event.key === "Escape") { - setShowHelpMenu(false); setSearchValue(""); return; } @@ -451,6 +486,8 @@ function AppShell({ }); if (detail.manageClientId) setManageClientId(detail.manageClientId); switchTab(tab as Tab, detail.preserveConfigDraft); + if (tab === "qbittorrent" && (detail.qbitCategory || detail.qbitInstance)) + selectInstance("qbittorrent", detail.qbitInstance ?? "aggregate"); if (detail.qbitCategory) { try { sessionStorage.setItem( @@ -462,6 +499,7 @@ function AppShell({ "torrentarr:focusQbitInstance", detail.qbitInstance, ); + else sessionStorage.removeItem("torrentarr:focusQbitInstance"); } catch { /* Navigation still works without a persisted focus hint. */ } @@ -469,7 +507,7 @@ function AppShell({ }; window.addEventListener(NAVIGATE_TAB_EVENT, onNavigate); return () => window.removeEventListener(NAVIGATE_TAB_EVENT, onNavigate); - }, [configuredTabs, switchTab]); + }, [configuredTabs, switchTab, selectInstance]); useEffect(() => { const id = window.setInterval( @@ -486,6 +524,33 @@ function AppShell({ const status = await getStatus(); const arrs: ArrInfo[] = Array.isArray(status.arrs) ? status.arrs : []; const qbitInstances = status.qbitInstances ?? {}; + const nextInstances: Partial> = { + qbittorrent: Object.keys(qbitInstances).sort(), + }; + for (const kind of ["radarr", "sonarr", "lidarr", "readarr"] as const) + nextInstances[kind] = arrs + .filter((arr) => arr.type === kind) + .map((arr) => arr.name) + .sort(); + setNavInstances((previous) => + JSON.stringify(previous) === JSON.stringify(nextInstances) + ? previous + : nextInstances, + ); + setInstanceSelections((previous) => { + const next = { ...previous }; + for (const [tab, names] of Object.entries(nextInstances)) { + const key = tab as Tab; + if ( + !next[key] || + (next[key] !== "aggregate" && !names.includes(next[key]!)) + ) + next[key] = names.length === 1 ? names[0] : "aggregate"; + } + return JSON.stringify(next) === JSON.stringify(previous) + ? previous + : next; + }); const nextTabs = { radarr: arrs.some((arr) => arr.type === "radarr"), sonarr: arrs.some((arr) => arr.type === "sonarr"), @@ -674,16 +739,32 @@ function AppShell({ { id: "logs", label: "Logs", icon: LogsIcon }, ]; if (configuredTabs.radarr) { - nextTabs.push({ id: "radarr", label: "Radarr", icon: RadarrIcon }); + nextTabs.push({ + id: "radarr", + label: arrTypeLabel("radarr"), + icon: RadarrIcon, + }); } if (configuredTabs.sonarr) { - nextTabs.push({ id: "sonarr", label: "Sonarr", icon: SonarrIcon }); + nextTabs.push({ + id: "sonarr", + label: arrTypeLabel("sonarr"), + icon: SonarrIcon, + }); } if (configuredTabs.lidarr) { - nextTabs.push({ id: "lidarr", label: "Lidarr", icon: LidarrIcon }); + nextTabs.push({ + id: "lidarr", + label: arrTypeLabel("lidarr"), + icon: LidarrIcon, + }); } if (configuredTabs.readarr) { - nextTabs.push({ id: "readarr", label: "Readarr", icon: ReadarrIcon }); + nextTabs.push({ + id: "readarr", + label: arrTypeLabel("readarr"), + icon: ReadarrIcon, + }); } if (configuredTabs.qbittorrent) { nextTabs.push({ @@ -797,17 +878,337 @@ function AppShell({ } }, [push, refreshMeta]); + const wikiIcon = ( + + ); + + const resourceLinks = ( +
+ + {wikiIcon} + + + + + + + +
+ ); + const burgerIcon = ( + + ); + const themeIcon = ( + + ); + const navFooter = (scope: string) => ( +
+ + {resourceLinks} +
+ ); + const navigation = (scope: string) => ( + + ); + + const selectedTabInstance = instanceSelections[activeTab]; + const activeTabLabel = tabs.find((tab) => tab.id === activeTab)?.label; + const pageTitle = + selectedTabInstance && selectedTabInstance !== "aggregate" + ? selectedTabInstance + : activeTabLabel && (navInstances[activeTab]?.length ?? 0) > 1 + ? `All ${activeTabLabel}` + : activeTabLabel; return ( -
+
+ + {navigationOpen && ( + setNavigationOpen(false)} + > + {navigation("drawer")} + {navFooter("drawer")} + + )} + {displayOpen && ( + setDisplayOpen(false)}> +
+ Theme +
+ {THEME_OPTIONS.map((option) => ( + + ))} +
+
+
+ View density +
+ + +
+
+
+ )}
- -

Torrentarr

+ + +

{pageTitle}

{displayVersion} @@ -823,17 +1224,18 @@ function AppShell({ className="btn small primary appbar__update" onClick={handleOpenChangelog} disabled={updateBusy || Boolean(updateState?.in_progress)} + aria-label="Update available" >
{!isOnline && ( )} -
- - -
-
- - -
Live @@ -887,8 +1255,8 @@ function AppShell({ role="switch" aria-checked={liveArr} aria-labelledby="live-switch-label" - title="Live updates for Arr and qBittorrent views" - aria-label="Live updates for Arr and qBittorrent views" + title="Live queries to Arr services; local statistics refresh automatically" + aria-label="Live queries to Arr services; local statistics refresh automatically" onClick={() => setLiveArr(!liveArr)} >
-
- - {showHelpMenu ? ( - - ) : null} -
{authRequired && (
- Loading...
}>
{visitedTabs.has("issues") && ( @@ -1016,17 +1309,38 @@ function AppShell({ ) : null} {visitedTabs.has("radarr") && visibleTabIds.has("radarr") ? ( ) : null} {visitedTabs.has("sonarr") && visibleTabIds.has("sonarr") ? ( ) : null} {visitedTabs.has("lidarr") && visibleTabIds.has("lidarr") ? ( ) : null} {visitedTabs.has("readarr") && visibleTabIds.has("readarr") ? ( @@ -1034,13 +1348,23 @@ function AppShell({ + selectInstance("readarr", instance) + } />
) : null} {visitedTabs.has("qbittorrent") && visibleTabIds.has("qbittorrent") ? ( ) : null} {visitedTabs.has("maintenance") ? ( diff --git a/webui/src/AuthGate.tsx b/webui/src/AuthGate.tsx index bb04d278..231452c7 100644 --- a/webui/src/AuthGate.tsx +++ b/webui/src/AuthGate.tsx @@ -19,19 +19,19 @@ class ErrorBoundary extends React.Component< render() { if (this.state.hasError) { return ( -
-

Something went wrong

-

{this.state.error?.message}

- +
+
+

Something went wrong

+

+ {this.state.error?.message} +

+ +
); } @@ -206,14 +206,7 @@ function AuthGate({ if (authState === "loading") { return ( -
+
); diff --git a/webui/src/__tests__/WebUIContext.events.test.tsx b/webui/src/__tests__/WebUIContext.events.test.tsx index e730b610..a94361a2 100644 --- a/webui/src/__tests__/WebUIContext.events.test.tsx +++ b/webui/src/__tests__/WebUIContext.events.test.tsx @@ -44,6 +44,7 @@ beforeEach(() => { }); afterEach(() => { vi.unstubAllGlobals(); + delete document.documentElement.dataset.palette; resetUrlBaseCacheForTests(); }); async function mount() { @@ -104,6 +105,16 @@ it("applies theme changes without navigating", async () => { expect(replace).not.toHaveBeenCalled(); }); +it("applies the midnight palette without navigating", async () => { + await mount(); + act(() => + receive({ ...initial, webui: { ...initial.webui, theme: "Midnight" } }), + ); + expect(document.documentElement).toHaveAttribute("data-theme", "dark"); + expect(document.documentElement.dataset.palette).toBe("midnight"); + expect(replace).not.toHaveBeenCalled(); +}); + it("keeps the current document until the replacement listener responds", async () => { await mount(); vi.mocked(fetch).mockRejectedValueOnce(new TypeError("connection refused")); diff --git a/webui/src/__tests__/components/ArrActivityCharts.test.tsx b/webui/src/__tests__/components/ArrActivityCharts.test.tsx new file mode 100644 index 00000000..f0d23047 --- /dev/null +++ b/webui/src/__tests__/components/ArrActivityCharts.test.tsx @@ -0,0 +1,245 @@ +import { act, fireEvent, render, screen, within } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ArrActivityHistory, ArrActivityPoint } from "../../api/types"; +import { + groupActivity, + activityPath, + activityColor, + activityValue, +} from "../../utils/arrActivityChart"; +import { ArrActivityCharts } from "../../components/ArrActivityCharts"; +import { getArrActivity } from "../../api/client"; +const settings = vi.hoisted(() => ({ liveArr: true })); +vi.mock("../../context/WebUIContext", () => ({ useWebUI: () => settings })); +vi.mock("../../api/client", async (original) => ({ + ...(await original()), + getArrActivity: vi.fn(), +})); +const point = ( + queueDepth: number | null, + queueSamples = 60, +): ArrActivityPoint => ({ + timestamp: "2026-11-04T11:55:00Z", + queueDepth, + queueSamples, + completionSamples: 60, + expectedSamples: 60, + searchesCompleted: 0, + importsConfirmed: 1, +}); +const history: ArrActivityHistory = { + range: "24h", + bucketMinutes: 60, + generatedAt: "2026-11-04T12:00:00Z", + series: [ + { instance: "Sonarr-one", type: "sonarr", points: [point(3)] }, + { instance: "Sonarr-two", type: "sonarr", points: [point(4)] }, + { instance: "Radarr-one", type: "radarr", points: [point(2)] }, + ], +}; +beforeEach(() => { + vi.mocked(getArrActivity).mockReset(); + settings.liveArr = true; +}); +afterEach(() => vi.useRealTimers()); +const settle = () => + act(async () => { + await Promise.resolve(); + }); +describe("Arr activity", () => { + it("aggregates workers by type while retaining missing coverage and observed zero", () => { + const rows = groupActivity(history.series, "type"); + const sonarr = rows.find((s) => s.type === "sonarr")!; + expect(sonarr.points[0]!.queueDepth).toBe(7); + expect(sonarr.points[0]!.searchesCompleted).toBe(0); + expect(sonarr.points[0]!.expectedSamples).toBe(120); + const partial = groupActivity( + [{ ...history.series[0]!, points: [point(null, 0)] }, history.series[1]!], + "type", + )[0]!; + expect(activityValue(partial.points[0], "queueDepth")).toBe("4"); + expect(activityValue(point(4.6), "queueDepth")).toBe("5"); + expect(activityPath(partial.points, "queueDepth", 10)).toBe(""); + expect( + activityPath( + [point(0), point(null, 0), point(3)], + "queueDepth", + 10, + ).match(/M/g), + ).toHaveLength(2); + }); + it("keeps worker colors deterministic and uses distinct type colors", () => { + expect(activityColor(history.series[0]!, "worker", 0)).toBe( + activityColor(history.series[0]!, "worker", 0), + ); + expect(activityColor(history.series[0]!, "worker", 0)).not.toBe( + activityColor(history.series[1]!, "worker", 1), + ); + expect(activityColor(history.series[0]!, "type")).toBe("var(--arr-sonarr)"); + expect(activityColor(history.series[2]!, "type")).toBe("var(--arr-radarr)"); + }); + it("supports grouping, separate throughput metrics, legend toggles, and accessible data", async () => { + vi.mocked(getArrActivity).mockResolvedValue(history); + render(); + await settle(); + const section = screen.getByRole("region", { + name: "Arr activity history", + }); + expect(getArrActivity).toHaveBeenCalledWith("24h"); + expect( + within(section).getByRole("button", { name: "Sonarr", exact: true }), + ).toHaveAttribute("aria-pressed", "true"); + fireEvent.change(screen.getByLabelText("Group activity by"), { + target: { value: "worker" }, + }); + const toggle = within(section).getByRole("button", { + name: "Sonarr-one", + exact: true, + }); + fireEvent.click(toggle); + expect(toggle).toHaveAttribute("aria-pressed", "false"); + fireEvent.change(screen.getByLabelText("Completed activity metric"), { + target: { value: "importsConfirmed" }, + }); + expect( + screen.getByText("Torrent imports confirmed · per hour"), + ).toBeInTheDocument(); + expect( + screen.getByRole("slider", { name: "Inspect activity time" }), + ).toBeInTheDocument(); + expect(screen.queryByText("View activity data")).not.toBeInTheDocument(); + expect(screen.queryByRole("table")).not.toBeInTheDocument(); + }); + it("refreshes active graphs from database notifications independently of Live", async () => { + vi.useFakeTimers(); + vi.mocked(getArrActivity).mockResolvedValue(history); + const result = render(); + await settle(); + await act(async () => { + window.dispatchEvent(new Event("torrentarr:statistics-updated")); + }); + expect(getArrActivity).toHaveBeenCalledTimes(2); + settings.liveArr = false; + result.rerender(); + await settle(); + const count = vi.mocked(getArrActivity).mock.calls.length; + await act(async () => { + await vi.advanceTimersByTimeAsync(120000); + }); + expect(getArrActivity).toHaveBeenCalledTimes(count); + window.dispatchEvent(new Event("torrentarr:statistics-refresh")); + await settle(); + expect(getArrActivity).toHaveBeenCalledTimes(count + 1); + result.rerender(); + window.dispatchEvent(new Event("torrentarr:statistics-refresh")); + await settle(); + expect(getArrActivity).toHaveBeenCalledTimes(count + 1); + }); + it("shows an error without fabricating a zero timeline", async () => { + vi.mocked(getArrActivity).mockRejectedValue(new Error("offline")); + render(); + await settle(); + expect(screen.getByRole("status")).toHaveTextContent( + "Arr activity unavailable", + ); + expect(screen.queryByRole("img")).not.toBeInTheDocument(); + }); + it("restores the latest interval when hover ends, including after manual inspection", async () => { + const twoIntervals = { + ...history, + series: history.series.map((series) => ({ + ...series, + points: [ + { ...point(1), timestamp: "2026-11-04T11:50:00Z" }, + ...series.points, + ], + })), + }; + vi.mocked(getArrActivity).mockResolvedValue(twoIntervals); + render(); + await settle(); + const slider = screen.getByRole("slider", { + name: "Inspect activity time", + }); + const plot = screen.getByRole("img", { name: /^Queue depth/ }); + vi.spyOn(plot, "getBoundingClientRect").mockReturnValue({ + left: 0, + width: 800, + } as DOMRect); + expect(slider).toHaveValue("1"); + fireEvent.change(slider, { target: { value: "0" } }); + expect(slider).toHaveValue("0"); + fireEvent.pointerMove(plot, { pointerType: "mouse", clientX: 776 }); + expect(slider).toHaveValue("1"); + fireEvent.pointerMove(plot, { pointerType: "mouse", clientX: 24 }); + expect(slider).toHaveValue("0"); + fireEvent.pointerLeave(plot, { pointerType: "mouse" }); + expect(slider).toHaveValue("1"); + fireEvent.pointerMove(plot, { pointerType: "mouse", clientX: 24 }); + fireEvent.pointerCancel(plot, { pointerType: "mouse" }); + expect(slider).toHaveValue("1"); + vi.mocked(getArrActivity).mockResolvedValue({ + ...twoIntervals, + series: twoIntervals.series.map((series) => ({ + ...series, + points: [ + ...series.points, + { ...point(9), timestamp: "2026-11-04T12:00:00Z" }, + ], + })), + }); + window.dispatchEvent(new Event("torrentarr:statistics-updated")); + await settle(); + expect(slider).toHaveValue("2"); + }); + + it("clears keyboard, mouse, touch, and obsolete activity inspections", async () => { + vi.mocked(getArrActivity).mockResolvedValue({ + ...history, + series: history.series.map((series) => ({ + ...series, + points: [ + { ...point(1), timestamp: "2026-11-04T11:50:00Z" }, + ...series.points, + ], + })), + }); + const view = render(); + await settle(); + const slider = screen.getByRole("slider", { + name: "Inspect activity time", + }); + const plot = () => screen.getByRole("img", { name: /^Queue depth/ }); + const marker = () => plot().querySelector("circle")!; + fireEvent.focus(marker()); + expect(slider).toHaveValue("0"); + fireEvent.blur(marker()); + expect(slider).toHaveValue("1"); + fireEvent.pointerDown(marker(), { pointerType: "mouse" }); + fireEvent.focus(marker()); + fireEvent.click(marker()); + fireEvent.pointerLeave(plot(), { pointerType: "mouse" }); + expect(slider).toHaveValue("1"); + fireEvent.pointerDown(marker(), { pointerType: "touch" }); + fireEvent.pointerLeave(plot(), { pointerType: "touch" }); + expect(slider).toHaveValue("0"); + fireEvent.pointerDown(document.body, { pointerType: "touch" }); + expect(slider).toHaveValue("1"); + fireEvent.focus(marker()); + fireEvent.keyDown(marker(), { key: "Escape" }); + expect(slider).toHaveValue("1"); + fireEvent.focus(marker()); + fireEvent.change(screen.getByLabelText("Group activity by"), { + target: { value: "worker" }, + }); + expect(slider).toHaveValue("1"); + fireEvent.focus(marker()); + fireEvent.change(screen.getByLabelText("Completed activity metric"), { + target: { value: "importsConfirmed" }, + }); + expect(slider).toHaveValue("1"); + fireEvent.focus(marker()); + view.rerender(); + expect(slider).toHaveValue("1"); + }); +}); diff --git a/webui/src/__tests__/components/ArrMiniProgress.test.tsx b/webui/src/__tests__/components/ArrMiniProgress.test.tsx new file mode 100644 index 00000000..0ed3a235 --- /dev/null +++ b/webui/src/__tests__/components/ArrMiniProgress.test.tsx @@ -0,0 +1,34 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { expect, it } from "vitest"; +import { ArrMiniProgress } from "../../components/arr/ArrMiniProgress"; + +it("shows exact segment values on hover, keyboard focus, and mouse click without pinning", async () => { + const user = userEvent.setup(); + render(); + const available = screen.getByRole("button", { + name: "Available: 75 (75.0%)", + }); + await user.hover(available); + expect(screen.getByRole("tooltip")).toHaveTextContent( + "Episodes · Available: 75 items (75.0%) · 100 monitored", + ); + await user.unhover(available); + expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); + await user.tab(); + expect(screen.getByRole("tooltip")).toHaveTextContent("Available: 75"); + await user.tab(); + expect(screen.getByRole("tooltip")).toHaveTextContent("Missing: 25"); + await user.keyboard("{Escape}"); + expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); + await user.click(available); + expect(screen.getByRole("tooltip")).toHaveTextContent("Available: 75"); + await user.unhover(available); + expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); +}); + +it("represents a successful empty observation without NaN percentages", () => { + render(); + expect(screen.getByRole("meter")).toHaveAttribute("aria-valuenow", "0"); + expect(screen.queryByText(/NaN/)).not.toBeInTheDocument(); +}); diff --git a/webui/src/__tests__/components/LiveArtistDetails.test.tsx b/webui/src/__tests__/components/LiveArtistDetails.test.tsx new file mode 100644 index 00000000..ad9ee742 --- /dev/null +++ b/webui/src/__tests__/components/LiveArtistDetails.test.tsx @@ -0,0 +1,35 @@ +import { act, render, screen } from "@testing-library/react"; +import { expect, it, vi } from "vitest"; +import { LidarrArtistDetailBody } from "../../components/arr/LidarrArtistDetailBody"; +import { STATISTICS_UPDATED } from "../../hooks/useStatisticsUpdates"; +import * as api from "../../api/client"; + +vi.mock("../../api/client", async (original) => ({ + ...(await original()), + getLidarrArtistDetail: vi.fn(), +})); + +it("refreshes open artist statistics on database changes and retains them on failure", async () => { + vi.mocked(api.getLidarrArtistDetail) + .mockResolvedValueOnce({ + category: "music", + counts: { available: 0, monitored: 0 }, + artist: { id: 1, name: "Artist", albumCount: 7 }, + albums: [], + }) + .mockResolvedValueOnce({ + category: "music", + counts: { available: 0, monitored: 0 }, + artist: { id: 1, name: "Artist", albumCount: 9 }, + albums: [], + }) + .mockRejectedValueOnce(new Error("Temporarily offline")); + render(); + await screen.findByText("7"); + act(() => window.dispatchEvent(new Event(STATISTICS_UPDATED))); + await screen.findByText("9"); + act(() => window.dispatchEvent(new Event(STATISTICS_UPDATED))); + await screen.findByText(/Temporarily offline/); + expect(screen.getByText("9")).toBeInTheDocument(); + expect(screen.getByText("Artist", { selector: "dd" })).toBeInTheDocument(); +}); diff --git a/webui/src/__tests__/components/Statistics.test.tsx b/webui/src/__tests__/components/Statistics.test.tsx new file mode 100644 index 00000000..8699d8ca --- /dev/null +++ b/webui/src/__tests__/components/Statistics.test.tsx @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { BreakdownBar } from "../../components/Statistics"; +import { transferPath } from "../../utils/transferChart"; +import type { TransferBucket } from "../../api/types"; + +const point = (downloadRate: number | null, samples = 5): TransferBucket => ({ + timestamp: "2026-11-04T12:00:00Z", + downloadRate, + uploadRate: 0, + samples, + expectedSamples: 5, +}); +describe("statistics", () => { + it("breaks lines for missing and incomplete buckets while preserving actual zero", () => { + const path = transferPath( + [ + point(0), + point(100), + point(null, 0), + point(30), + point(40, 2), + point(50), + ], + "downloadRate", + 100, + ); + expect(path.match(/M/g)).toHaveLength(3); + expect(path.match(/L/g)).toHaveLength(1); + expect(path).toContain("M70.00,130.00"); + }); + it("shows text equivalents for breakdowns including empty results", () => { + render( + , + ); + expect(screen.getByRole("group", { name: "Outcomes" })).toHaveTextContent( + "Failed 0", + ); + expect(screen.getByText("No data yet")).toBeInTheDocument(); + }); + it("shows exact values, percentages and units on focus and touch", () => { + render( + , + ); + fireEvent.focus( + screen.getByRole("button", { name: /Available: 3 movies/ }), + ); + expect(screen.getByRole("tooltip")).toHaveTextContent( + "Available: 3 movies (75.0%)", + ); + const missing = screen.getByRole("button", { name: /Missing: 1 movies/ }); + fireEvent.pointerDown(missing, { pointerType: "touch" }); + fireEvent.pointerLeave(missing, { pointerType: "touch" }); + expect(screen.getByRole("tooltip")).toHaveTextContent( + "Missing: 1 movies (25.0%)", + ); + fireEvent.pointerDown(document.body, { pointerType: "touch" }); + expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); + fireEvent.pointerEnter(missing, { pointerType: "mouse" }); + expect(screen.getByRole("tooltip")).toHaveTextContent("Missing: 1 movies"); + fireEvent.pointerLeave(missing, { pointerType: "mouse" }); + expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); + }); +}); diff --git a/webui/src/__tests__/components/TransferHistory.test.tsx b/webui/src/__tests__/components/TransferHistory.test.tsx new file mode 100644 index 00000000..62cbe871 --- /dev/null +++ b/webui/src/__tests__/components/TransferHistory.test.tsx @@ -0,0 +1,193 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { TransferHistory } from "../../components/TransferHistory"; +import { getTransferHistory } from "../../api/client"; +import type { TransferHistoryResponse } from "../../api/types"; + +const settings = vi.hoisted(() => ({ liveArr: true })); +vi.mock("../../context/WebUIContext", () => ({ useWebUI: () => settings })); +vi.mock("../../api/client", async (original) => ({ + ...(await original()), + getTransferHistory: vi.fn(), +})); +const history = (instance: string): TransferHistoryResponse => ({ + range: "24h", + bucketMinutes: 5, + generatedAt: "2026-11-04T12:00:00Z", + series: [ + { + instance, + points: [ + { + timestamp: "2026-11-04T11:50:00Z", + downloadRate: 1024, + uploadRate: 2048, + samples: 5, + expectedSamples: 5, + }, + { + timestamp: "2026-11-04T11:55:00Z", + downloadRate: null, + uploadRate: null, + samples: 0, + expectedSamples: 5, + }, + ], + }, + ], +}); +beforeEach(() => { + vi.useFakeTimers(); + vi.mocked(getTransferHistory).mockReset(); + settings.liveArr = true; +}); +afterEach(() => vi.useRealTimers()); +const settle = () => + act(async () => { + await Promise.resolve(); + }); + +describe("transfer history", () => { + it("polls at minute intervals only while active and live", async () => { + vi.mocked(getTransferHistory).mockResolvedValue(history("one")); + const view = render(); + await settle(); + expect(getTransferHistory).toHaveBeenCalledWith("one", "24h"); + await act(async () => { + await vi.advanceTimersByTimeAsync(59999); + }); + expect(getTransferHistory).toHaveBeenCalledTimes(1); + await act(async () => { + window.dispatchEvent(new Event("torrentarr:statistics-updated")); + }); + expect(getTransferHistory).toHaveBeenCalledTimes(2); + view.rerender(); + await act(async () => { + await vi.advanceTimersByTimeAsync(120000); + }); + expect(getTransferHistory).toHaveBeenCalledTimes(2); + settings.liveArr = false; + view.rerender(); + await settle(); + await act(async () => { + await vi.advanceTimersByTimeAsync(120000); + }); + expect(getTransferHistory).toHaveBeenCalledTimes(3); + window.dispatchEvent(new Event("torrentarr:statistics-refresh")); + await settle(); + expect(getTransferHistory).toHaveBeenCalledTimes(4); + }); + + it("changes range without mixing clients and rejects stale responses", async () => { + let finish!: (value: TransferHistoryResponse) => void; + vi.mocked(getTransferHistory) + .mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ) + .mockResolvedValue(history("two")); + const view = render(); + view.rerender(); + await settle(); + finish(history("one")); + await settle(); + expect(screen.queryByText("one")).not.toBeInTheDocument(); + expect(screen.getByText("two")).toBeInTheDocument(); + fireEvent.focus( + screen.getByRole("img", { name: /two.*Download: 1.0 KiB/ }), + ); + expect(screen.getByRole("status")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "7d", exact: true })); + await settle(); + expect(getTransferHistory).toHaveBeenLastCalledWith("two", "7d"); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + }); + + it("exposes coverage, readable sampled data and rate labels", async () => { + vi.mocked(getTransferHistory).mockResolvedValue(history("one")); + render(); + await settle(); + expect(screen.getByText(/5 \/ 10 minutes sampled/)).toBeInTheDocument(); + expect(screen.queryByText("View sampled data")).not.toBeInTheDocument(); + expect(screen.queryByRole("table")).not.toBeInTheDocument(); + expect(screen.getByText("2.0 KiB/s")).toBeInTheDocument(); + expect(screen.getByText("1.0 KiB/s")).toBeInTheDocument(); + expect(screen.getByText("0 B/s")).toBeInTheDocument(); + const point = screen.getByRole("img", { name: /one.*Download: 1.0 KiB/ }); + fireEvent.focus(point); + expect(screen.getByRole("status")).toHaveTextContent("Inspecting"); + expect(screen.getByRole("status")).toHaveAttribute( + "aria-label", + expect.stringContaining("Download 1.0 KiB/s"), + ); + }); + it("keeps the inspected point stable across gaps until leaving the plot", async () => { + vi.mocked(getTransferHistory).mockResolvedValue(history("one")); + const view = render(); + await settle(); + const marker = screen.getByRole("img", { name: /one.*Download: 1.0 KiB/ }); + const plot = screen.getByRole("img", { name: /^one download and upload/ }); + vi.spyOn(plot, "getBoundingClientRect").mockReturnValue({ + x: 0, + y: 0, + width: 800, + height: 155, + top: 0, + left: 0, + right: 800, + bottom: 155, + toJSON: () => ({}), + } as DOMRect); + fireEvent.pointerMove(plot, { pointerType: "mouse", clientX: 70 }); + expect(screen.getByRole("status")).toHaveAttribute( + "aria-label", + expect.stringContaining("Download 1.0 KiB/s"), + ); + expect(screen.getByRole("status").tagName).toBe("SPAN"); + expect( + screen.queryByText("Latest complete interval", { exact: false }), + ).not.toBeInTheDocument(); + fireEvent.pointerMove(plot, { pointerType: "mouse", clientX: 780 }); + expect(screen.getByRole("status")).toHaveAttribute( + "aria-label", + expect.stringContaining("No complete sample"), + ); + fireEvent.pointerMove(plot, { pointerType: "mouse", clientX: 70 }); + expect(screen.getByRole("status")).toHaveAttribute( + "aria-label", + expect.stringContaining("Download 1.0 KiB/s"), + ); + fireEvent.pointerLeave(plot, { pointerType: "mouse" }); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + expect( + screen.getByText("Latest complete interval", { exact: false }), + ).toBeInTheDocument(); + fireEvent.pointerDown(marker, { pointerType: "mouse" }); + fireEvent.focus(marker); + fireEvent.click(marker); + fireEvent.pointerLeave(plot, { pointerType: "mouse" }); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + fireEvent.focus(marker); + fireEvent.blur(marker); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + fireEvent.pointerDown(marker, { pointerType: "touch" }); + fireEvent.pointerLeave(marker, { pointerType: "touch" }); + expect(screen.getByRole("status")).toBeInTheDocument(); + fireEvent.pointerDown(document.body, { pointerType: "touch" }); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + fireEvent.pointerMove(plot, { pointerType: "mouse", clientX: 70 }); + fireEvent.pointerCancel(plot, { pointerType: "mouse" }); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + fireEvent.focus(marker); + fireEvent.keyDown(marker, { key: "Escape" }); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + fireEvent.focus(marker); + view.rerender(); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + expect( + screen.getByText("Latest complete interval", { exact: false }), + ).toBeInTheDocument(); + }); +}); diff --git a/webui/src/__tests__/pages/AppShell.test.tsx b/webui/src/__tests__/pages/AppShell.test.tsx index 659b67b9..a35309fa 100644 --- a/webui/src/__tests__/pages/AppShell.test.tsx +++ b/webui/src/__tests__/pages/AppShell.test.tsx @@ -1,6 +1,12 @@ import React from "react"; import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { + render, + screen, + fireEvent, + waitFor, + within, +} from "@testing-library/react"; import { AppShell } from "../../AppShell"; import { ToastProvider } from "../../context/ToastContext"; import { SearchProvider } from "../../context/SearchContext"; @@ -80,7 +86,13 @@ describe("configured navigation", () => { vi.mocked(api.getStatus).mockResolvedValue(offline); view(); fireEvent.click( - await screen.findByRole("button", { name: "Readarr", exact: true }), + await screen.findByRole("button", { name: "Expand Readarr" }), + ); + fireEvent.click( + screen.getByRole("button", { + name: "Readarr-books", + exact: true, + }), ); const filter = await screen.findByLabelText("catalog filter"); fireEvent.change(filter, { target: { value: "Dune" } }); @@ -92,27 +104,31 @@ describe("configured navigation", () => { fireEvent.click( screen.getByRole("button", { name: "Processes", exact: true }), ); + fireEvent.click(screen.getByRole("button", { name: "Expand Readarr" })); fireEvent.click( - screen.getByRole("button", { name: "Readarr", exact: true }), + screen.getByRole("button", { name: "Readarr-books", exact: true }), ); expect(screen.getByLabelText("catalog filter")).toHaveValue("Dune"); expect(scrollTo).toHaveBeenLastCalledWith(0, 420); Object.defineProperty(window, "scrollY", { value: 0, configurable: true }); scrollTo.mockRestore(); + fireEvent.click( + screen.getByRole("button", { name: "Expand Torrent Clients" }), + ); expect( - screen.getByRole("button", { name: "Torrent Clients", exact: true }), + screen.getByRole("button", { name: "transmission", exact: true }), ).toBeInTheDocument(); vi.mocked(api.getStatus).mockRejectedValueOnce(new Error("offline")); saved(); await waitFor(() => expect(api.getStatus).toHaveBeenCalledTimes(3)); expect( - screen.getByRole("button", { name: "Readarr", exact: true }), + screen.getByRole("button", { name: "Expand Readarr" }), ).toBeInTheDocument(); vi.mocked(api.getStatus).mockResolvedValue(empty); saved(); await waitFor(() => expect( - screen.queryByRole("button", { name: "Readarr", exact: true }), + screen.queryByRole("button", { name: "Expand Readarr" }), ).not.toBeInTheDocument(), ); await waitFor(() => @@ -133,3 +149,293 @@ it("renders the authenticated shell when browser storage is blocked", async () = vi.restoreAllMocks(); } }); + +it("opens Display with persisted controls and returns focus on close", async () => { + view(); + const trigger = screen.getByRole("button", { name: "Display", exact: true }); + trigger.focus(); + fireEvent.click(trigger); + const dialog = screen.getByRole("dialog", { name: "Display" }); + fireEvent.click( + within(dialog).getByRole("button", { name: "Light", exact: true }), + ); + expect(document.documentElement).toHaveAttribute("data-theme", "light"); + fireEvent.click( + within(dialog).getByRole("button", { name: "Compact", exact: true }), + ); + expect(document.querySelector("[data-density]")).toHaveAttribute( + "data-density", + "compact", + ); + fireEvent.click( + within(dialog).getByRole("button", { name: "Close Display" }), + ); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect(trigger).toHaveFocus(); +}); + +it("closes the navigation drawer after switching pages and on Escape", async () => { + view(); + fireEvent.click(screen.getByRole("button", { name: "Menu", exact: true })); + const dialog = screen.getByRole("dialog", { name: "Navigation" }); + fireEvent.click( + within(dialog).getByRole("button", { name: "Config", exact: true }), + ); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Menu", exact: true })); + fireEvent( + screen.getByRole("dialog", { name: "Navigation" }), + new Event("cancel", { bubbles: false, cancelable: true }), + ); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); +}); + +it("expands Arr choices in the drawer before navigating", async () => { + vi.mocked(api.getStatus).mockResolvedValue({ + ...empty, + arrs: [ + { name: "Radarr-HD", category: "movies", type: "radarr" }, + { name: "Radarr-4K", category: "movies4k", type: "radarr" }, + ], + }); + view(); + fireEvent.click(screen.getByRole("button", { name: "Menu", exact: true })); + const drawer = screen.getByRole("dialog", { name: "Navigation" }); + fireEvent.click( + await within(drawer).findByRole("button", { + name: "Radarr", + exact: true, + }), + ); + expect( + within(drawer).getByRole("button", { name: "All Radarr" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("dialog", { name: "Navigation" }), + ).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Processes" })).toBeVisible(); + fireEvent.click(within(drawer).getByRole("button", { name: "Radarr-4K" })); + expect( + screen.queryByRole("dialog", { name: "Navigation" }), + ).not.toBeInTheDocument(); + expect( + await screen.findByRole("heading", { name: "Radarr-4K" }), + ).toBeVisible(); +}); + +it("no longer shows a Help menu in the app bar", () => { + view(); + expect( + screen.queryByRole("button", { name: "Help", exact: true }), + ).not.toBeInTheDocument(); +}); + +it("collapses the desktop navigation to an icon rail", () => { + view(); + const shell = document.querySelector(".console-shell"); + const trigger = screen.getByRole("button", { + name: "Collapse navigation", + }); + expect(trigger).toHaveAttribute("aria-controls", "app-navigation"); + expect(trigger).toHaveAttribute("aria-expanded", "true"); + fireEvent.click(trigger); + expect(shell).toHaveAttribute("data-nav", "collapsed"); + expect( + screen.getByRole("button", { name: "Expand navigation" }), + ).toHaveAttribute("aria-expanded", "false"); + fireEvent.click(screen.getByRole("button", { name: "Expand navigation" })); + expect(shell).toHaveAttribute("data-nav", "expanded"); + expect(trigger).toHaveAttribute("aria-expanded", "true"); +}); + +it("expands the rail for instance selection and collapses after choosing", async () => { + vi.mocked(api.getStatus).mockResolvedValue({ + ...empty, + arrs: [ + { name: "Radarr-HD", category: "movies", type: "radarr" }, + { name: "Radarr-4K", category: "movies4k", type: "radarr" }, + ], + }); + view(); + const shell = document.querySelector(".console-shell"); + await screen.findByRole("button", { name: "Expand Radarr" }); + fireEvent.click(screen.getByRole("button", { name: "Collapse navigation" })); + fireEvent.click(screen.getByRole("button", { name: "Radarr", exact: true })); + expect(shell).toHaveAttribute("data-nav", "expanded"); + expect(screen.getByRole("button", { name: "Radarr-4K" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Processes" })).toBeVisible(); + fireEvent.click(screen.getByRole("button", { name: "Radarr-4K" })); + expect(shell).toHaveAttribute("data-nav", "collapsed"); + expect( + await screen.findByRole("heading", { name: "Radarr-4K" }), + ).toBeVisible(); +}); + +it("collapses an expanded Arr section when leaving that view", async () => { + vi.mocked(api.getStatus).mockResolvedValue({ + ...empty, + arrs: [ + { name: "Radarr-HD", category: "movies", type: "radarr" }, + { name: "Radarr-4K", category: "movies4k", type: "radarr" }, + ], + }); + view(); + const disclosure = await screen.findByRole("button", { + name: "Expand Radarr", + }); + fireEvent.click(disclosure); + expect(screen.getByRole("button", { name: "Radarr-4K" })).toBeInTheDocument(); + fireEvent.click( + screen.getByRole("button", { name: "Processes", exact: true }), + ); + expect(disclosure).toHaveAttribute("aria-expanded", "false"); + expect( + screen.queryByRole("button", { name: "Radarr-4K" }), + ).not.toBeInTheDocument(); +}); + +it("provides Wiki, GitHub and OpenAPI links on desktop and in the drawer", () => { + view(); + const resources = screen.getByLabelText("Project resources"); + const wiki = within(resources).getByRole("link", { name: /Wiki/ }); + expect(wiki).toHaveAttribute( + "href", + "https://feramance.github.io/Torrentarr/", + ); + expect(wiki).toHaveAttribute("target", "_blank"); + expect( + within(resources).getByRole("link", { name: /GitHub/ }), + ).toHaveAttribute("href", "https://github.com/Feramance/Torrentarr"); + expect( + within(resources).getByRole("link", { name: /OpenAPI/ }), + ).toHaveAttribute("href", "/web/docs"); + fireEvent.click(screen.getByRole("button", { name: "Menu", exact: true })); + const drawer = screen.getByRole("dialog", { name: "Navigation" }); + expect( + within(drawer).getByRole("link", { name: /Wiki/ }), + ).toBeInTheDocument(); + expect(within(drawer).getByRole("link", { name: /GitHub/ })).toHaveAttribute( + "rel", + "noopener noreferrer", + ); + expect(within(drawer).getByRole("link", { name: /OpenAPI/ })).toHaveAttribute( + "rel", + "noopener noreferrer", + ); +}); + +it("clears a stale client focus when Arr navigation requests all clients", async () => { + vi.mocked(api.getStatus).mockResolvedValue(offline); + view(); + fireEvent.click( + await screen.findByRole("button", { name: "Expand Torrent Clients" }), + ); + await screen.findByRole("button", { name: "transmission", exact: true }); + sessionStorage.setItem("torrentarr:focusQbitInstance", "stale"); + window.dispatchEvent( + new CustomEvent("qbitrr:navigate-tab", { + detail: { tab: "qbittorrent", qbitCategory: "shared" }, + }), + ); + expect(sessionStorage.getItem("torrentarr:focusQbitInstance")).toBeNull(); + expect(sessionStorage.getItem("qbitrr:focusQbitCategory")).toBe("shared"); + sessionStorage.clear(); +}); + +it("expands configured instances in the main navigation without replacing the active view", async () => { + vi.mocked(api.getStatus).mockResolvedValue({ + ...empty, + arrs: [ + { name: "Radarr-HD", category: "movies", type: "radarr" }, + { name: "Radarr-4K", category: "movies4k", type: "radarr" }, + ], + }); + view(); + const disclosure = await screen.findByRole("button", { + name: "Expand Radarr", + }); + expect(disclosure).toHaveAttribute("aria-expanded", "false"); + fireEvent.click(disclosure); + expect(disclosure).toHaveAttribute("aria-expanded", "true"); + expect(screen.getByText("Worker dashboard")).toBeVisible(); + fireEvent.click(screen.getByRole("button", { name: "Radarr-4K" })); + expect(screen.getByRole("button", { name: "Radarr-4K" })).toHaveAttribute( + "aria-current", + "page", + ); + expect( + screen.getByRole("button", { name: "All Radarr" }), + ).toBeInTheDocument(); +}); + +it("keeps only one navigation section expanded at a time", async () => { + vi.mocked(api.getStatus).mockResolvedValue({ + ...empty, + arrs: [ + { name: "Radarr-HD", category: "movies", type: "radarr" }, + { name: "Radarr-4K", category: "movies4k", type: "radarr" }, + ], + qbitInstances: { + "qBit-home": { alive: true, host: "fixture", port: 1, version: null }, + "qBit-seedbox": { + alive: true, + host: "fixture", + port: 2, + version: null, + }, + }, + }); + view(); + const radarr = await screen.findByRole("button", { name: "Expand Radarr" }); + fireEvent.click(radarr); + expect(screen.getByRole("button", { name: "Radarr-4K" })).toBeInTheDocument(); + + const clients = screen.getByRole("button", { + name: "Expand Torrent Clients", + }); + fireEvent.click(clients); + expect(radarr).toHaveAttribute("aria-expanded", "false"); + expect( + screen.queryByRole("button", { name: "Radarr-4K" }), + ).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "qBit-home" })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Radarr", exact: true })); + expect(clients).toHaveAttribute("aria-expanded", "false"); + expect(screen.getByRole("button", { name: "Radarr-4K" })).toBeInTheDocument(); +}); + +it("shows a collapsed section for a single configured instance", async () => { + vi.mocked(api.getStatus).mockResolvedValue({ + ...empty, + arrs: [{ name: "Radarr-HD", category: "movies", type: "radarr" }], + qbitInstances: { + "qBit-home": { alive: true, host: "fixture", port: 1, version: null }, + }, + }); + view(); + const radarr = await screen.findByRole("button", { name: "Expand Radarr" }); + const clients = screen.getByRole("button", { + name: "Expand Torrent Clients", + }); + expect(radarr).toHaveAttribute("aria-expanded", "false"); + expect(clients).toHaveAttribute("aria-expanded", "false"); + expect( + screen.queryByRole("button", { name: "Radarr-HD", exact: true }), + ).not.toBeInTheDocument(); + + fireEvent.click(radarr); + expect(radarr).toHaveAttribute("aria-expanded", "true"); + expect( + screen.getByRole("button", { name: "Radarr-HD", exact: true }), + ).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "All Radarr" }), + ).not.toBeInTheDocument(); + + fireEvent.click( + screen.getByRole("button", { name: "Radarr-HD", exact: true }), + ); + expect(await screen.findByLabelText("catalog filter")).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Radarr-HD" })).toBeVisible(); +}); diff --git a/webui/src/__tests__/pages/ArrView.test.tsx b/webui/src/__tests__/pages/ArrView.test.tsx index 865ac02d..74ade8ed 100644 --- a/webui/src/__tests__/pages/ArrView.test.tsx +++ b/webui/src/__tests__/pages/ArrView.test.tsx @@ -51,7 +51,7 @@ describe("ArrView – routing", () => { render(, { wrapper: Wrapper }); - await screen.findByText("Radarr"); + await screen.findByRole("region", { name: "Radarr" }); }); it("renders Sonarr catalog (shows 'Sonarr' header) when type=sonarr", async () => { @@ -63,7 +63,7 @@ describe("ArrView – routing", () => { render(, { wrapper: Wrapper }); - await screen.findByText("Sonarr"); + await screen.findByRole("region", { name: "Sonarr" }); }); it("renders Lidarr catalog (shows 'Lidarr' header) when type=lidarr", async () => { @@ -75,7 +75,7 @@ describe("ArrView – routing", () => { render(, { wrapper: Wrapper }); - await screen.findByText("Lidarr"); + await screen.findByRole("region", { name: "Lidarr" }); }); it("renders ReadarrView (shows 'Readarr' header) when type=readarr", async () => { @@ -87,7 +87,7 @@ describe("ArrView – routing", () => { render(, { wrapper: Wrapper }); - await screen.findByText("Readarr"); + await screen.findByRole("region", { name: "Readarr" }); }); }); @@ -102,7 +102,7 @@ describe("ArrView – inactive", () => { render(, { wrapper: Wrapper }); - await screen.findByText("Radarr"); + await screen.findByRole("region", { name: "Radarr" }); }); it("renders Sonarr catalog card frame when type=sonarr active=false", async () => { @@ -113,7 +113,7 @@ describe("ArrView – inactive", () => { render(, { wrapper: Wrapper }); - await screen.findByText("Sonarr"); + await screen.findByRole("region", { name: "Sonarr" }); }); it("renders Lidarr catalog card frame when type=lidarr active=false", async () => { @@ -124,7 +124,7 @@ describe("ArrView – inactive", () => { render(, { wrapper: Wrapper }); - await screen.findByText("Lidarr"); + await screen.findByRole("region", { name: "Lidarr" }); }); it("renders ReadarrView card frame when type=readarr active=false", async () => { @@ -135,6 +135,6 @@ describe("ArrView – inactive", () => { render(, { wrapper: Wrapper }); - await screen.findByText("Readarr"); + await screen.findByRole("region", { name: "Readarr" }); }); }); diff --git a/webui/src/__tests__/pages/ConfigView.test.tsx b/webui/src/__tests__/pages/ConfigView.test.tsx index cb2d25eb..58d340d8 100644 --- a/webui/src/__tests__/pages/ConfigView.test.tsx +++ b/webui/src/__tests__/pages/ConfigView.test.tsx @@ -89,6 +89,37 @@ function renderConfig() { return render(, { wrapper: Wrapper }); } +async function openSection(name: string): Promise { + await screen.findByRole("navigation", { name: "Configuration sections" }); + if ( + ["Settings", "Web Settings", "Authentication", "Integrations"].includes( + name, + ) + ) { + await userEvent.click( + screen.getByRole("button", { name: new RegExp(`^${name}(?: •)?$`) }), + ); + return; + } + await userEvent.click( + screen.getByRole("button", { name: "Torrent Clients", exact: true }), + ); + let button = screen.queryByRole("button", { + name: `Configure ${name}`, + exact: true, + }); + if (!button) { + await userEvent.click( + screen.getByRole("button", { name: "Integrations", exact: true }), + ); + button = screen.getByRole("button", { + name: `Configure ${name}`, + exact: true, + }); + } + await userEvent.click(button); +} + it("requires confirmation before changing the draft to disable authentication", async () => { server.use( http.get("/web/config", () => @@ -100,10 +131,7 @@ it("requires confirmation before changing the draft to disable authentication", ); const user = userEvent.setup(); renderConfig(); - const card = (await screen.findByText("Authentication")).closest(".card")!; - await user.click( - within(card as HTMLElement).getByRole("button", { name: "Configure" }), - ); + await openSection("Authentication"); const accessControls = screen .getByText("Access controls") .closest("details")!; @@ -148,50 +176,30 @@ it("lists every client together by name with the same controls and shared settin }), ), ); - const user = userEvent.setup(); renderConfig(); - const section = (await screen.findByText("Torrent Clients")).closest( - "section", - )!; - expect(screen.queryByText("qBittorrent Instances")).not.toBeInTheDocument(); - expect( - screen.queryByText("Additional Torrent Clients"), - ).not.toBeInTheDocument(); - expect(section.querySelector(".config-arr-group__count")).toHaveTextContent( - "3", - ); + await openSection("a-transmission"); expect( - Array.from( - section.querySelectorAll(".card-header"), - (header) => header.textContent, - ), - ).toEqual(["a-transmission", "qBit", "z-qbit"]); + screen.getByRole("navigation", { name: "Configuration sections" }), + ).toBeInTheDocument(); expect( - within(section).getAllByRole("button", { name: "Add Instance" }), - ).toHaveLength(1); - for (const card of section.querySelectorAll( - ".config-arr-card", - )) { - expect(within(card).getAllByRole("button")).toHaveLength(1); - expect(within(card).getByText("Host")).toBeInTheDocument(); - expect(within(card).getByText("Status")).toBeInTheDocument(); - await user.click(within(card).getByRole("button", { name: "Configure" })); - const dialog = screen.getByRole("dialog"); - expect( - within(dialog).getByText("Managed Categories", { exact: true }), - ).toBeInTheDocument(); + within( + screen.getByRole("region", { name: "qBittorrent Instances" }), + ).getByRole("button", { name: "Add Instance" }), + ).toBeInTheDocument(); + for (const name of ["a-transmission", "qBit", "z-qbit"]) { + await openSection(name); + const editor = screen.getByRole("region", { + name: /^(Configure |Settings|Web Settings|Authentication)/, + }); expect( - within(dialog).getByText("Hit and Run Mode", { exact: true }), + within(editor).getByText("Managed Categories", { exact: true }), ).toBeInTheDocument(); expect( - within(dialog).getByRole("button", { name: "Open Manage" }), + within(editor).getByRole("button", { name: "Open Manage" }), ).toBeInTheDocument(); expect( - within(dialog).getByRole("button", { name: "Delete", exact: true }), + within(editor).getByRole("button", { name: "Delete", exact: true }), ).toBeInTheDocument(); - await user.click( - within(dialog).getByRole("button", { name: "Close", exact: true }), - ); } }); @@ -207,15 +215,13 @@ describe("ConfigView – delete button rendering", () => { renderConfig(); // Wait for config to load by checking for a known card header - await screen.findByText("qBit"); - // Find the card that contains "qBit" and confirm its delete button - const qbitHeader = screen.getByText("qBit"); - const card = qbitHeader.closest(".card")!; - await userEvent.click( - within(card).getByRole("button", { name: /configure/i }), - ); + await openSection("qBit"); expect( - within(screen.getByRole("dialog")).getByRole("button", { + within( + screen.getByRole("region", { + name: /^(Configure |Settings|Web Settings|Authentication)/, + }), + ).getByRole("button", { name: /delete/i, }), ).toBeInTheDocument(); @@ -229,14 +235,13 @@ describe("ConfigView – delete button rendering", () => { renderConfig(); - await screen.findByText("qBit-1"); - const qbit1Header = screen.getByText("qBit-1"); - const card = qbit1Header.closest(".card")!; - await userEvent.click( - within(card).getByRole("button", { name: /configure/i }), - ); + await openSection("qBit-1"); expect( - within(screen.getByRole("dialog")).getByRole("button", { + within( + screen.getByRole("region", { + name: /^(Configure |Settings|Web Settings|Authentication)/, + }), + ).getByRole("button", { name: /delete/i, }), ).toBeInTheDocument(); @@ -250,14 +255,13 @@ describe("ConfigView – delete button rendering", () => { renderConfig(); - await screen.findByText("Radarr-1080"); - const arrHeader = screen.getByText("Radarr-1080"); - const card = arrHeader.closest(".card")!; - await userEvent.click( - within(card).getByRole("button", { name: /configure/i }), - ); + await openSection("Radarr-1080"); expect( - within(screen.getByRole("dialog")).getByRole("button", { + within( + screen.getByRole("region", { + name: /^(Configure |Settings|Web Settings|Authentication)/, + }), + ).getByRole("button", { name: /delete/i, }), ).toBeInTheDocument(); @@ -284,10 +288,13 @@ describe("ConfigView – section deletion", () => { ); const user = userEvent.setup(); renderConfig(); - const card = (await screen.findByText("qBit")).closest(".card")!; - await user.click(within(card).getByRole("button", { name: /configure/i })); + await openSection("qBit"); await user.click( - within(screen.getByRole("dialog")).getByRole("button", { + within( + screen.getByRole("region", { + name: /^(Configure |Settings|Web Settings|Authentication)/, + }), + ).getByRole("button", { name: /delete/i, }), ); @@ -309,10 +316,13 @@ describe("ConfigView – section deletion", () => { ); const user = userEvent.setup(); renderConfig(); - const card = (await screen.findByText("qBit-1")).closest(".card")!; - await user.click(within(card).getByRole("button", { name: /configure/i })); + await openSection("qBit-1"); await user.click( - within(screen.getByRole("dialog")).getByRole("button", { + within( + screen.getByRole("region", { + name: /^(Configure |Settings|Web Settings|Authentication)/, + }), + ).getByRole("button", { name: /delete/i, }), ); @@ -393,28 +403,34 @@ describe("ConfigView – equal client names", () => { ); const user = userEvent.setup(); renderConfig(); - const header = await screen.findByText("qBit"); + await openSection("qBit"); expect(screen.queryByText("(Default)")).not.toBeInTheDocument(); - await user.click( - within(header.closest(".card")!).getByRole("button", { - name: /configure/i, - }), - ); - const dialog = screen.getByRole("dialog"); + + const dialog = screen.getByRole("region", { + name: /^(Configure |Settings|Web Settings|Authentication)/, + }); for (const newName of names) { const name = within(dialog).getByPlaceholderText("qBit-seedbox"); await user.clear(name); await user.type(name, `${newName}{Enter}`); } await user.click( - within(screen.getByRole("dialog")).getByRole("button", { + within( + screen.getByRole("region", { + name: /^(Configure |Settings|Web Settings|Authentication)/, + }), + ).getByRole("button", { name: /^save$/i, }), ); if (outcome === "retry") { await screen.findByText(/configuration is busy/i); await user.click( - within(screen.getByRole("dialog")).getByRole("button", { + within( + screen.getByRole("region", { + name: /^(Configure |Settings|Web Settings|Authentication)/, + }), + ).getByRole("button", { name: /^save$/i, }), ); @@ -458,13 +474,14 @@ describe("ConfigView – addQbitInstance behaviour", () => { renderConfig(); - await screen.findByText("qBit"); + await openSection("qBit"); expect(screen.queryByText("client-1")).not.toBeInTheDocument(); - // Scope the shared add button to the torrent client section. - // Arr groups (Radarr/Sonarr/Lidarr) each have their own "Add Instance" button, - // so we must scope the query to avoid ambiguity. - const qbitSection = screen.getByText("Torrent Clients").closest("section")!; + // Scope the shared add button to the torrent client section; the + // Integrations tab has its own top-level Add Instance button. + const qbitSection = screen.getByRole("region", { + name: "qBittorrent Instances", + }); const addBtn = within(qbitSection).getByRole("button", { name: /add instance/i, }); @@ -484,14 +501,13 @@ describe("ConfigView – addQbitInstance behaviour", () => { renderConfig(); - await screen.findByText("qBit"); + await openSection("qBit"); // Click once → creates client-1 and opens its configure modal const getAddBtn = () => - within(screen.getByText("Torrent Clients").closest("section")!).getByRole( - "button", - { name: /add instance/i }, - ); + within( + screen.getByRole("region", { name: "qBittorrent Instances" }), + ).getByRole("button", { name: /add instance/i }); await user.click(getAddBtn()); // Card header + modal title both show "client-1" @@ -557,15 +573,9 @@ describe("ConfigView – UrlBase field", () => { ), ); - const user = userEvent.setup(); renderConfig(); - const webSettingsCard = (await screen.findByText("Web Settings")).closest( - ".card", - )!; - await user.click( - within(webSettingsCard).getByRole("button", { name: /configure/i }), - ); + await openSection("Web Settings"); expect( await screen.findByLabelText("Allow Insecure Exposure"), @@ -579,14 +589,9 @@ describe("ConfigView – UrlBase field", () => { http.post("/web/config", () => HttpResponse.json(okUpdateResponse)), ); - const user = userEvent.setup(); renderConfig(); - await screen.findByText("Web Settings"); - const webSettingsCard = screen.getByText("Web Settings").closest(".card")!; - await user.click( - within(webSettingsCard).getByRole("button", { name: /configure/i }), - ); + await openSection("Web Settings"); expect(await screen.findByText("Url Base")).toBeInTheDocument(); }); @@ -604,8 +609,7 @@ describe("ConfigView – UrlBase field", () => { const user = userEvent.setup(); renderConfig(); - const card = (await screen.findByText("Web Settings")).closest(".card")!; - await user.click(within(card).getByRole("button", { name: /configure/i })); + await openSection("Web Settings"); await user.click(screen.getByRole("button", { name: "Save", exact: true })); expect( @@ -622,11 +626,7 @@ describe("ConfigView – UrlBase field", () => { const user = userEvent.setup(); renderConfig(); - await screen.findByText("Web Settings"); - const webSettingsCard = screen.getByText("Web Settings").closest(".card")!; - await user.click( - within(webSettingsCard).getByRole("button", { name: /configure/i }), - ); + await openSection("Web Settings"); await screen.findByText("Url Base"); const urlBaseField = screen.getByText("Url Base").closest(".field")!; const urlBaseInput = within(urlBaseField).getByRole("textbox"); @@ -656,13 +656,9 @@ describe("ConfigView – Arr Import Mode and Entry Search", () => { http.post("/web/config", () => HttpResponse.json(okUpdateResponse)), ); - const user = userEvent.setup(); renderConfig(); - await screen.findByText("Radarr-1080"); - const arrHeader = screen.getByText("Radarr-1080"); - const card = arrHeader.closest(".card")!; - await user.click(within(card).getByRole("button", { name: /configure/i })); + await openSection("Radarr-1080"); expect(await screen.findByText("Import Mode")).toBeInTheDocument(); const importModeField = screen.getByText("Import Mode").closest(".field")!; @@ -697,13 +693,9 @@ describe("ConfigView – Arr Entry Search and Import Mode", () => { ), ); - const user = userEvent.setup(); renderConfig(); - await screen.findByText("Radarr-1080"); - const arrHeader = screen.getByText("Radarr-1080"); - const card = arrHeader.closest(".card")!; - await user.click(within(card).getByRole("button", { name: /configure/i })); + await openSection("Radarr-1080"); await screen.findByText("Import Mode"); const importModeField = screen.getByText("Import Mode").closest(".field")!; @@ -748,34 +740,46 @@ describe("ConfigView – section persistence", () => { ); const user = userEvent.setup(); renderConfig(); - const web = (await screen.findByText("Web Settings")).closest(".card")!; - await user.click(within(web).getByRole("button", { name: /configure/i })); + await openSection("Web Settings"); await user.type( - within(screen.getByRole("dialog")).getByRole("textbox", { + within( + screen.getByRole("region", { + name: /^(Configure |Settings|Web Settings|Authentication)/, + }), + ).getByRole("textbox", { name: "Url Base", }), "/draft", ); - await user.click( - within(screen.getByRole("dialog")).getByRole("button", { name: "Close" }), - ); - const qbit = screen.getByText("qBit").closest(".card")!; - await user.click(within(qbit).getByRole("button", { name: /configure/i })); - const host = within(screen.getByRole("dialog")).getByRole("textbox", { + + await openSection("qBit"); + const host = within( + screen.getByRole("region", { + name: /^(Configure |Settings|Web Settings|Authentication)/, + }), + ).getByRole("textbox", { name: "Host", }); await user.clear(host); await user.type(host, "seedbox.local"); await user.click( - within(screen.getByRole("dialog")).getByRole("button", { + within( + screen.getByRole("region", { + name: /^(Configure |Settings|Web Settings|Authentication)/, + }), + ).getByRole("button", { name: /^Save$/, }), ); await screen.findByText("Configuration saved"); expect(saved!.changes).toEqual({ "qBit.Host": "seedbox.local" }); - await user.click(within(web).getByRole("button", { name: /configure/i })); + await openSection("Web Settings"); expect( - within(screen.getByRole("dialog")).getByRole("textbox", { + within( + screen.getByRole("region", { + name: /^(Configure |Settings|Web Settings|Authentication)/, + }), + ).getByRole("textbox", { name: "Url Base", }), ).toHaveValue("/draft"); @@ -803,21 +807,32 @@ describe("ConfigView – section persistence", () => { ); const user = userEvent.setup(); renderConfig(); - const card = (await screen.findByText("qBit")).closest(".card")!; - await user.click(within(card).getByRole("button", { name: /configure/i })); - const host = within(screen.getByRole("dialog")).getByRole("textbox", { + await openSection("qBit"); + const host = within( + screen.getByRole("region", { + name: /^(Configure |Settings|Web Settings|Authentication)/, + }), + ).getByRole("textbox", { name: "Host", }); await user.clear(host); await user.type(host, "keep-this.local"); await user.click( - within(screen.getByRole("dialog")).getByRole("button", { + within( + screen.getByRole("region", { + name: /^(Configure |Settings|Web Settings|Authentication)/, + }), + ).getByRole("button", { name: /^Save$/, }), ); await screen.findByText("disk unavailable"); expect(host).toHaveValue("keep-this.local"); - expect(screen.getByRole("dialog")).toBeInTheDocument(); + expect( + screen.getByRole("region", { + name: /^(Configure |Settings|Web Settings|Authentication)/, + }), + ).toBeInTheDocument(); expect(screen.queryByText(/Configuration saved/)).not.toBeInTheDocument(); }); }); @@ -828,20 +843,34 @@ it.each(["Web Settings", "Radarr-1080"])( server.use( http.get("/web/config", () => HttpResponse.json(configWithInstances)), ); - const user = userEvent.setup(); const { rerender } = renderConfig(); - const card = (await screen.findByText(section)).closest(".card")!; - await user.click(within(card).getByRole("button", { name: /configure/i })); - expect(screen.getByRole("dialog")).toBeInTheDocument(); - expect(document.body.style.overflow).toBe("hidden"); + await openSection(section); + expect( + screen.getByRole("region", { + name: /^(Configure |Settings|Web Settings|Authentication)/, + }), + ).toBeInTheDocument(); + expect(document.body.style.overflow).toBe(""); rerender(); - expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect( + screen.queryByRole("region", { + name: /^(Configure |Settings|Web Settings|Authentication)/, + }), + ).not.toBeInTheDocument(); expect(document.body.style.overflow).toBe(""); rerender(); - expect(screen.getByRole("dialog")).toBeInTheDocument(); - expect(document.body.style.overflow).toBe("hidden"); + expect( + screen.getByRole("region", { + name: /^(Configure |Settings|Web Settings|Authentication)/, + }), + ).toBeInTheDocument(); + expect(document.body.style.overflow).toBe(""); fireEvent.keyDown(window, { key: "Escape" }); - expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect( + screen.getByRole("region", { + name: /^(Configure |Settings|Web Settings|Authentication)/, + }), + ).toBeInTheDocument(); expect(document.body.style.overflow).toBe(""); }, ); @@ -875,8 +904,7 @@ it("uses the shared client dialog and saves only the selected torrent client", a ); const user = userEvent.setup(); renderConfig(); - const card = (await screen.findByText("seedbox")).closest(".card")!; - await user.click(within(card).getByRole("button", { name: "Configure" })); + await openSection("seedbox"); expect( screen.getByRole("textbox", { name: "Client Type", exact: true }), ).toHaveValue("transmission"); @@ -1047,7 +1075,16 @@ it.each([false, true])( detail: { ...application, state: "applied" }, }), ); - await screen.findByText("Configuration applied live"); + await waitFor(() => + expect( + screen.queryByText( + "Configuration saved; waiting for active operations to finish", + ), + ).not.toBeInTheDocument(), + ); + expect( + screen.queryByText(/Configuration applied live/i), + ).not.toBeInTheDocument(); window.dispatchEvent( new CustomEvent("torrentarr:configuration", { detail: { ...application, acceptedRevision: 19 }, @@ -1129,10 +1166,7 @@ it("opens Manage for the saved client without losing its pending connection edit try { const user = userEvent.setup(); renderConfig(); - const card = (await screen.findByText("qBit", { exact: true })).closest( - ".card", - )!; - await user.click(within(card).getByRole("button", { name: "Configure" })); + await openSection("qBit"); fireEvent.change( screen.getByRole("textbox", { name: "Host", exact: true }), { target: { value: "pending.local" } }, @@ -1150,3 +1184,504 @@ it("opens Manage for the saved client without losing its pending connection edit window.removeEventListener(NAVIGATE_TAB_EVENT, navigate); } }); + +it("filters inline setting fields without locking page scrolling or losing drafts", async () => { + server.use(http.get("/web/config", () => HttpResponse.json(configQbitOnly))); + const user = userEvent.setup(); + renderConfig(); + await user.click(await screen.findByRole("button", { name: "Web Settings" })); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect(document.body.style.overflow).toBe(""); + const search = screen.getByRole("searchbox", { + name: "Find a setting in this section", + }); + await user.type(search, "Url Base"); + const url = screen.getByRole("textbox", { name: "Url Base" }); + await user.type(url, "/draft"); + await openSection("qBit"); + await user.click(screen.getByRole("button", { name: /Web Settings/ })); + expect(screen.getByRole("textbox", { name: "Url Base" })).toHaveValue( + "/draft", + ); +}); + +it("shows grouped Arr application summaries and opens the selected inline editor", async () => { + server.use( + http.get("/web/config", () => + HttpResponse.json({ + ...configWithInstances, + "Radarr-1080": { + ...configWithInstances["Radarr-1080"], + URI: "http://user:secret@radarr:7878/base?apikey=hidden", + }, + "Sonarr-TV": { + Managed: false, + URI: "http://sonarr:8989", + Category: "tv", + SearchOnly: true, + }, + }), + ), + ); + renderConfig(); + await screen.findByRole("navigation", { name: "Configuration sections" }); + await userEvent.click( + screen.getByRole("button", { name: "Integrations", exact: true }), + ); + expect(screen.queryByLabelText("Library instance")).not.toBeInTheDocument(); + const radarr = screen.getByRole("button", { + name: "Configure Radarr-1080", + exact: true, + }); + expect(radarr).toHaveTextContent("radarr:7878/base"); + expect(radarr).toHaveTextContent("Search + torrents"); + expect(radarr).not.toHaveTextContent("secret"); + expect(radarr).not.toHaveTextContent("hidden"); + expect(radarr).toHaveAttribute("aria-pressed", "false"); + expect( + screen.queryByRole("region", { name: /^Configure / }), + ).not.toBeInTheDocument(); + const sonarr = screen.getByRole("button", { + name: "Configure Sonarr-TV", + exact: true, + }); + expect(sonarr).toHaveTextContent("tv"); + expect(sonarr).toHaveTextContent("Search only"); + expect(sonarr).toHaveTextContent("Disabled"); + await userEvent.click(sonarr); + expect(sonarr).toHaveAttribute("aria-pressed", "true"); + expect( + screen.getByRole("heading", { name: /Sonarr-TV/ }), + ).toBeInTheDocument(); +}); + +it("shows empty type sections and creates integrations from the top-level picker while retaining drafts", async () => { + server.use( + http.get("/web/config", () => + HttpResponse.json({ Settings: {}, WebUI: {} }), + ), + ); + renderConfig(); + await openSection("Integrations"); + expect(screen.queryByLabelText("Integration type")).not.toBeInTheDocument(); + for (const type of ["Radarr", "Sonarr", "Lidarr", "Readarr"]) { + const section = screen.getByRole("region", { name: `${type} Instances` }); + expect(within(section).getByText("0 instances")).toBeInTheDocument(); + expect( + within(section).queryByRole("button", { name: "Add Instance" }), + ).not.toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Add Instance" })); + const picker = screen.getByRole("dialog", { name: "Add integration" }); + await userEvent.click( + within(picker).getByRole("button", { name: type, exact: true }), + ); + expect( + screen.getByRole("region", { name: `Configure ${type}-1` }), + ).toBeInTheDocument(); + } + await userEvent.click(screen.getByRole("button", { name: "Add Instance" })); + await userEvent.click( + within(screen.getByRole("dialog", { name: "Add integration" })).getByRole( + "button", + { name: "Radarr", exact: true }, + ), + ); + expect( + screen.getByRole("button", { name: "Configure Radarr-1" }), + ).toHaveTextContent("Unsaved"); + expect( + screen.getByRole("region", { name: "Configure Radarr-2" }), + ).toBeInTheDocument(); + await userEvent.click( + screen.getByRole("button", { name: "Torrent Clients", exact: true }), + ); + const group = screen.getByRole("region", { name: "qBittorrent Instances" }); + expect(within(group).getByText("0 instances")).toBeInTheDocument(); + expect(screen.queryByLabelText("Torrent client")).not.toBeInTheDocument(); +}); + +it("groups mixed client storage by normalized type without conflating identities", async () => { + server.use( + http.get("/web/config", () => + HttpResponse.json({ + ...configQbitOnly, + TorrentClient: { + qBit: { + Type: "QBITTORRENT", + Host: "canonical", + Port: 8080, + ManagedCategories: ["movies"], + TorrentScanIntervalSeconds: 60, + }, + custom: { Type: "Custom", Host: "custom", Port: 1234 }, + transmission: { Type: "transmission", Host: "other", Port: 9091 }, + }, + }), + ), + ); + renderConfig(); + await userEvent.click( + await screen.findByRole("button", { name: "Torrent Clients", exact: true }), + ); + const group = screen.getByRole("region", { name: "qBittorrent Instances" }); + expect(within(group).getByText("2 instances")).toBeInTheDocument(); + expect(within(group).getByText("movies")).toBeInTheDocument(); + expect(within(group).getByText("60 s")).toBeInTheDocument(); + await userEvent.click( + within(group).getByRole("button", { name: "Configure qBit (legacy)" }), + ); + expect( + screen.getByRole("textbox", { name: "Host", exact: true }), + ).toHaveValue("localhost"); + await userEvent.click( + within(group).getByRole("button", { + name: "Configure qBit (TorrentClient)", + }), + ); + expect( + screen.getByRole("textbox", { name: "Host", exact: true }), + ).toHaveValue("canonical"); + await userEvent.click( + within(screen.getByRole("region", { name: "custom Instances" })).getByRole( + "button", + { name: "Add Instance" }, + ), + ); + expect(screen.getByRole("textbox", { name: "Client Type" })).toHaveValue( + "custom", + ); + fireEvent.change(screen.getByRole("textbox", { name: "Client Type" }), { + target: { value: "transmission" }, + }); + expect( + within( + screen.getByRole("region", { name: "transmission Instances" }), + ).getByText("2 instances"), + ).toBeInTheDocument(); +}); + +it.each([ + "ordinary", + "accept", + "defer", + "retry", + "empty target", + "failed save", +])( + "handles %s save requirements independently of legacy reload labels", + async (scenario) => { + const requirements = [ + { component: "Radarr search", category: "radarr", kind: "search" }, + { component: "Sonarr torrents", category: "sonarr", kind: "torrent" }, + ]; + const current = { + ...structuredClone(configQbitOnly), + WebUI: { ...configQbitOnly.WebUI, Host: "127.0.0.1", UrlBase: "" }, + }; + const restarts: string[] = []; + let retry = scenario === "retry" || scenario === "empty target"; + let saves = 0; + server.use( + http.get("/web/config", () => HttpResponse.json(current)), + http.post("/web/config", async ({ request }) => { + saves++; + const { changes } = (await request.json()) as { + changes: Record; + }; + if (scenario === "failed save") + return HttpResponse.json({ error: "save failed" }, { status: 500 }); + current.WebUI.UrlBase = String(changes["WebUI.UrlBase"]); + return HttpResponse.json({ + ...okUpdateResponse, + reloadType: "full", + configReloaded: true, + restartRequirements: scenario === "ordinary" ? [] : requirements, + }); + }), + http.post("/web/processes/:category/:kind/restart", ({ params }) => { + expect(saves).toBe(1); + restarts.push(`${params.category}/${params.kind}`); + if ( + retry && + params.category === "radarr" && + scenario === "empty target" + ) + return HttpResponse.json({ status: "restarted", restarted: [] }); + if (retry && params.category === "radarr") + return HttpResponse.json( + { error: "restart failed" }, + { status: 500 }, + ); + return HttpResponse.json({ status: "restarted" }); + }), + ); + renderConfig(); + await openSection("Web Settings"); + expect( + screen.queryByText(/Applies live|No restart required/), + ).not.toBeInTheDocument(); + const save = screen.getByRole("button", { name: "Save", exact: true }); + expect( + screen.queryByRole("dialog", { name: "Restart required" }), + ).not.toBeInTheDocument(); + if (scenario === "ordinary") { + await userEvent.click(save); + await screen.findByText("No changes detected for WebUI"); + expect(saves).toBe(0); + } + fireEvent.change(screen.getByRole("textbox", { name: "Url Base" }), { + target: { value: "/new" }, + }); + await userEvent.click(save); + if (scenario === "failed save") { + await screen.findByText("save failed"); + expect( + screen.queryByRole("dialog", { name: "Restart required" }), + ).not.toBeInTheDocument(); + } else if (scenario === "ordinary") { + await screen.findByText("Configuration saved"); + expect( + screen.queryByRole("dialog", { name: "Restart required" }), + ).not.toBeInTheDocument(); + expect( + screen.queryByText(/reloaded|Applied live|without restart/i), + ).not.toBeInTheDocument(); + } else { + const dialog = await screen.findByRole("dialog", { + name: "Restart required", + }); + expect(dialog).toHaveTextContent("Radarr search, Sonarr torrents"); + expect( + within(dialog).getByRole("button", { name: "Close" }), + ).toHaveFocus(); + await userEvent.tab({ shift: true }); + expect( + within(dialog).getByRole("button", { name: "Restart now" }), + ).toHaveFocus(); + if (scenario === "defer") { + await userEvent.click( + within(dialog).getByRole("button", { name: "Later" }), + ); + expect(screen.getByText(/Pending restart:/)).toHaveTextContent( + "Radarr search, Sonarr torrents", + ); + expect(current.WebUI.UrlBase).toBe("/new"); + } else { + await userEvent.click( + within(dialog).getByRole("button", { name: "Restart now" }), + ); + await waitFor(() => expect(restarts).toHaveLength(2)); + if (scenario === "retry" || scenario === "empty target") { + await screen.findByText( + scenario === "retry" + ? "restart failed" + : "Radarr search did not restart", + ); + expect(screen.getByText(/Pending restart:/)).toHaveTextContent( + "Radarr search", + ); + expect(screen.getByText(/Pending restart:/)).not.toHaveTextContent( + "Sonarr torrents", + ); + retry = false; + await userEvent.click( + await screen.findByRole("button", { name: "Retry restart" }), + ); + await userEvent.click( + screen.getByRole("button", { name: "Restart now" }), + ); + await waitFor(() => expect(restarts).toHaveLength(3)); + } + await waitFor(() => + expect( + screen.queryByText(/Pending restart:/), + ).not.toBeInTheDocument(), + ); + } + } + if (["ordinary", "defer", "failed save"].includes(scenario)) + expect(restarts).toEqual([]); + }, +); + +it.each([ + { + tab: "Integrations", + name: "Radarr-1080", + field: "URI", + draft: "http://draft:7878", + }, + { tab: "Torrent Clients", name: "qBit", field: "Host", draft: "draft.local" }, + { + tab: "Torrent Clients", + name: "seedbox", + field: "Host", + draft: "draft.local", + }, +])( + "opens $name only on selection and retains its draft when returning to the overview", + async ({ tab, name, field, draft }) => { + server.use( + http.get("/web/config", () => + HttpResponse.json({ + ...configWithInstances, + TorrentClient: { + seedbox: { + Type: "qbittorrent", + Host: "canonical.local", + Port: 8080, + }, + }, + }), + ), + ); + renderConfig(); + const tabs = await screen.findByRole("navigation", { + name: "Configuration sections", + }); + const openTab = () => + userEvent.click( + within(tabs).getByRole("button", { name: tab, exact: true }), + ); + await openTab(); + expect( + screen.queryByRole("region", { name: /^Configure / }), + ).not.toBeInTheDocument(); + expect(screen.queryByRole("searchbox")).not.toBeInTheDocument(); + const row = () => + screen.getByRole("button", { name: `Configure ${name}`, exact: true }); + await userEvent.click(row()); + expect( + screen.getByRole("region", { name: `Configure ${name}`, exact: true }), + ).toBeInTheDocument(); + expect( + screen + .getByRole("region", { name: `Configure ${name}`, exact: true }) + .closest(".config-inline-backdrop"), + ).toBeInTheDocument(); + expect(document.querySelector(".modal-backdrop")).not.toBeInTheDocument(); + const actions = document.querySelector(".config-inline-actions")!; + expect(actions).toContainElement( + screen.getByRole("button", { name: "Save", exact: true }), + ); + const note = document.querySelector(".modal-footer-note"); + if (note) expect(actions).toContainElement(note); + const input = screen.getByRole("textbox", { name: field, exact: true }); + fireEvent.change(input, { target: { value: draft } }); + expect(screen.getByRole("searchbox")).toBeInTheDocument(); + await userEvent.click( + within(tabs).getByRole("button", { name: "Settings", exact: true }), + ); + await openTab(); + expect( + screen.queryByRole("region", { name: /^Configure / }), + ).not.toBeInTheDocument(); + expect(screen.queryByRole("searchbox")).not.toBeInTheDocument(); + expect(row()).toHaveTextContent("Unsaved"); + await userEvent.click(row()); + expect( + screen.getByRole("textbox", { name: field, exact: true }), + ).toHaveValue(draft); + }, +); + +it("focuses and scrolls after the requested editor mounts, without jumping on ordinary updates", async () => { + server.use( + http.get("/web/config", () => + HttpResponse.json({ + ...configWithInstances, + TorrentClient: { + seedbox: { Type: "qbittorrent", Host: "canonical.local", Port: 8080 }, + }, + }), + ), + ); + const previous = HTMLElement.prototype.scrollIntoView; + const header = document.createElement("header"); + header.className = "appbar"; + vi.spyOn(header, "getBoundingClientRect").mockReturnValue({ + height: 72, + } as DOMRect); + document.body.append(header); + const titles: string[] = []; + const scroll = vi.fn(function (this: HTMLElement) { + titles.push(this.querySelector("h2")?.textContent ?? ""); + expect(this).toHaveFocus(); + expect(this.style.scrollMarginTop).toBe("84px"); + }); + HTMLElement.prototype.scrollIntoView = scroll; + try { + renderConfig(); + const tabs = await screen.findByRole("navigation", { + name: "Configuration sections", + }); + expect(scroll).not.toHaveBeenCalled(); + for (const name of ["qBit", "seedbox", "Radarr-1080"]) { + await openSection(name); + expect(titles.at(-1)).toBe(`Configure ${name}`); + const calls = scroll.mock.calls.length; + const field = screen.getByRole("textbox", { + name: name.startsWith("Radarr") ? "URI" : "Host", + exact: true, + }); + fireEvent.change(field, { target: { value: "" } }); + expect(scroll).toHaveBeenCalledTimes(calls); + await userEvent.type(screen.getByRole("searchbox"), "Connection"); + expect(scroll).toHaveBeenCalledTimes(calls); + fireEvent.change(screen.getByRole("searchbox"), { + target: { value: "" }, + }); + const summary = screen.getByText( + name.startsWith("Radarr") ? "Processing" : "Torrent scope", + { selector: "summary" }, + ); + await userEvent.click(summary); + fireEvent( + window, + new CustomEvent("torrentarr:configuration", { + detail: { acceptedRevision: 42, state: "applied", components: {} }, + }), + ); + expect(scroll).toHaveBeenCalledTimes(calls); + // Selecting the same row is still an explicit navigation request. + await userEvent.click( + screen.getByRole("button", { name: `Configure ${name}`, exact: true }), + ); + expect(scroll).toHaveBeenCalledTimes(calls + 1); + } + await userEvent.click( + within(tabs).getByRole("button", { + name: "Torrent Clients", + exact: true, + }), + ); + const calls = scroll.mock.calls.length; + expect(screen.queryByRole("searchbox")).not.toBeInTheDocument(); + await userEvent.click( + within( + screen.getByRole("region", { name: "qBittorrent Instances" }), + ).getByRole("button", { name: "Add Instance" }), + ); + expect(titles.at(-1)).toBe("Configure client-1"); + expect(scroll).toHaveBeenCalledTimes(calls + 1); + await userEvent.click( + within(tabs).getByRole("button", { name: "Integrations", exact: true }), + ); + await userEvent.click(screen.getByRole("button", { name: "Add Instance" })); + await userEvent.click( + within(screen.getByRole("dialog", { name: "Add integration" })).getByRole( + "button", + { name: "Readarr", exact: true }, + ), + ); + expect(titles.at(-1)).toBe("Configure Readarr-1"); + expect(scroll).toHaveBeenCalledTimes(calls + 2); + expect(scroll).toHaveBeenLastCalledWith({ + block: "start", + behavior: "instant", + }); + } finally { + HTMLElement.prototype.scrollIntoView = previous; + header.remove(); + } +}, 15_000); diff --git a/webui/src/__tests__/pages/LidarrView.test.tsx b/webui/src/__tests__/pages/LidarrView.test.tsx index 2c7e6bdd..9fb71de2 100644 --- a/webui/src/__tests__/pages/LidarrView.test.tsx +++ b/webui/src/__tests__/pages/LidarrView.test.tsx @@ -64,7 +64,7 @@ function renderView(active = true) { // ── Card header ─────────────────────────────────────────────────────────────── describe("Lidarr catalog – card header", () => { - it("renders the Lidarr card header", async () => { + it("renders the Lidarr catalog region", async () => { server.use( http.get("/web/config", () => HttpResponse.json(minimalConfig)), http.post("/web/config", () => HttpResponse.json({})), @@ -73,7 +73,7 @@ describe("Lidarr catalog – card header", () => { renderView(); - await screen.findByText("Lidarr", {}, { timeout: 10000 }); + await screen.findByRole("region", { name: "Lidarr" }); }); }); @@ -115,8 +115,8 @@ describe("Lidarr catalog – empty state", () => { // ── Single instance ─────────────────────────────────────────────────────────── -describe("Lidarr catalog – instance sidebar", () => { - it("shows instance button in sidebar when one lidarr instance is configured", async () => { +describe("Lidarr catalog – centralized instance selection", () => { + it("does not duplicate navigation when one lidarr instance is configured", async () => { server.use( http.get("/web/config", () => HttpResponse.json(minimalConfig)), http.post("/web/config", () => HttpResponse.json({})), @@ -129,12 +129,10 @@ describe("Lidarr catalog – instance sidebar", () => { renderView(); // Use findByRole to get the sidebar button specifically (not the mobile select option) - const btn = await screen.findByRole( - "button", - { name: /lidarr hd/i }, - { timeout: 5000 }, - ); - expect(btn).toBeInTheDocument(); + await screen.findByRole("region", { name: "Lidarr HD" }); + expect( + screen.queryByRole("button", { name: /lidarr hd/i }), + ).not.toBeInTheDocument(); }); it("shows artist rows when artists are returned", async () => { @@ -172,12 +170,10 @@ describe("Lidarr catalog – instance sidebar", () => { // The sidebar button appears when instances load (before albums fetch completes). // Use waitFor to handle the race between instance load and album fetch. - const btn = await screen.findByRole( - "button", - { name: /lidarr hd/i }, - { timeout: 5000 }, - ); - expect(btn).toBeInTheDocument(); + await screen.findByRole("region", { name: "Lidarr HD" }); + expect( + screen.queryByRole("button", { name: /lidarr hd/i }), + ).not.toBeInTheDocument(); await screen.findByText("Radiohead"); }); }); @@ -193,6 +189,6 @@ describe("Lidarr catalog – inactive", () => { renderView(false); - await screen.findByText("Lidarr", {}, { timeout: 10000 }); + await screen.findByRole("region", { name: "Lidarr" }); }); }); diff --git a/webui/src/__tests__/pages/MaintenanceView.test.tsx b/webui/src/__tests__/pages/MaintenanceView.test.tsx index 800ce1b6..517482a9 100644 --- a/webui/src/__tests__/pages/MaintenanceView.test.tsx +++ b/webui/src/__tests__/pages/MaintenanceView.test.tsx @@ -175,7 +175,7 @@ beforeEach(() => { it("opens without scans or mutations and keeps every tool collapsed", async () => { show(); await loaded(); - expect(screen.getByRole("heading", { name: "Manage" })).toBeInTheDocument(); + expect(screen.getByRole("region", { name: "Manage" })).toBeInTheDocument(); for (const tool of maintenanceOperations) expect( screen.getByText(tool.title, { selector: "strong" }).closest("details"), diff --git a/webui/src/__tests__/pages/ProcessesView.test.tsx b/webui/src/__tests__/pages/ProcessesView.test.tsx index 4d5986d1..4a90d914 100644 --- a/webui/src/__tests__/pages/ProcessesView.test.tsx +++ b/webui/src/__tests__/pages/ProcessesView.test.tsx @@ -1,14 +1,36 @@ +import { NAVIGATE_TAB_EVENT } from "../../utils/navigateTab"; import userEvent from "@testing-library/user-event"; import React from "react"; -import { describe, it, expect, beforeAll, afterAll, afterEach } from "vitest"; -import { render, screen, waitFor } from "@testing-library/react"; +import { + describe, + it, + expect, + beforeAll, + afterAll, + afterEach, + vi, +} from "vitest"; +import { render, screen, waitFor, fireEvent } from "@testing-library/react"; import { http, HttpResponse } from "msw"; import { setupServer } from "msw/node"; import type { ReactNode } from "react"; import { ProcessesView } from "../../pages/ProcessesView"; +import { WebUIProvider } from "../../context/WebUIContext"; import { ToastProvider } from "../../context/ToastContext"; -const server = setupServer(); +const server = setupServer( + http.get("/web/config", () => + HttpResponse.json({ WebUI: { LiveArr: false } }), + ), + http.get("/web/statistics/arr-activity", () => + HttpResponse.json({ + range: "24h", + bucketMinutes: 5, + generatedAt: "2026-11-04T12:00:00Z", + series: [], + }), + ), +); beforeAll(() => server.listen({ onUnhandledRequest: "bypass" })); afterEach(() => server.resetHandlers()); @@ -23,7 +45,11 @@ const emptyStatus = { const emptyCategories = { categories: [], ready: true }; function Wrapper({ children }: { children: ReactNode }) { - return {children}; + return ( + + {children} + + ); } function renderView(active = true) { @@ -88,6 +114,13 @@ describe("ProcessesView – process grouping", () => { await screen.findByText("Radarr"); expect(screen.getByText("Radarr-1080")).toBeInTheDocument(); + expect( + screen.getByText("Awaiting first completed pass"), + ).toBeInTheDocument(); + expect( + screen.getByText("Awaiting first successful scan"), + ).toBeInTheDocument(); + expect(screen.getByText("Searches").parentElement).toHaveTextContent("—"); }); it("groups Sonarr and Radarr processes into separate sections", async () => { @@ -364,9 +397,9 @@ describe("ProcessesView – Other section", () => { screen.getAllByText("Free Space Manager").length, ).toBeGreaterThanOrEqual(1); expect( - screen.getAllByText("Managing 0 categories").length, + screen.getAllByText("Torrents in category").length, ).toBeGreaterThanOrEqual(2); - expect(screen.getByText("0 paused for free space")).toBeInTheDocument(); + expect(screen.getByText("Paused for free space")).toBeInTheDocument(); }); }); @@ -404,11 +437,13 @@ it("refreshes loop status without showing a worker badge", async () => { expect( await screen.findByText("Running", {}, { timeout: 3000 }), ).toBeInTheDocument(); - expect(screen.queryByText(/^Worker(?: ·|$)/)).not.toBeInTheDocument(); - expect(screen.getByText(/1 in progress/)).toBeInTheDocument(); + expect( + document.querySelector(".process-card__badge"), + ).not.toBeInTheDocument(); + expect(screen.getByText("Arr queue").parentElement).toHaveTextContent("1"); }); -it("shows two loop activities with one physical worker count and restart", async () => { +it("keeps instance controls separate from activity with a secondary restart action", async () => { let restarts = 0; server.use( http.get("/web/status", () => @@ -447,9 +482,14 @@ it("shows two loop activities with one physical worker count and restart", async }), ); renderView(); - expect(await screen.findByText("1/1")).toBeInTheDocument(); + expect(await screen.findByText("Radarr-A")).toBeInTheDocument(); + expect(screen.queryByText("1/1")).not.toBeInTheDocument(); + await userEvent.click(screen.getByText("Actions")); expect(screen.getByText(/No active search/)).toBeInTheDocument(); - expect(screen.getByText(/2 in progress/)).toBeInTheDocument(); + expect(screen.getByText("Arr queue").parentElement).toHaveTextContent("2"); + const card = screen.getByText("Radarr-A").closest(".process-card")!; + expect(card.querySelectorAll(".process-activity")).toHaveLength(2); + expect(card.querySelector(".process-metric")).not.toBeInTheDocument(); expect( screen.getAllByRole("button", { name: "Restart worker", exact: true }), ).toHaveLength(1); @@ -485,7 +525,8 @@ it("marks stopped worker activities as stopped despite a stale heartbeat", async ), ); renderView(); - expect(await screen.findByText("0/1")).toBeInTheDocument(); + expect(await screen.findByText("Radarr-A")).toBeInTheDocument(); + expect(screen.queryByText("0/1")).not.toBeInTheDocument(); expect( screen .getAllByLabelText("Stopped") @@ -493,3 +534,118 @@ it("marks stopped worker activities as stopped despite a stale heartbeat", async ).toHaveLength(2); expect(screen.queryByText(/resumes/)).not.toBeInTheDocument(); }); + +it("keeps successful zero counts and stale observations visible", async () => { + server.use( + http.get("/web/status", () => + HttpResponse.json({ + ...emptyStatus, + arrs: [{ name: "Radarr-A", category: "shared", type: "radarr" }], + }), + ), + http.get("/web/processes", () => + HttpResponse.json({ + processes: [ + { + name: "Radarr-A", + category: "shared", + kind: "worker", + alive: true, + searchesTriggered: 0, + itemsSearched: 0, + searchTimestamp: "2026-10-18T12:00:00Z", + categoryCount: 12, + downloadingCount: 0, + seedingCount: 12, + observedAt: "2026-10-18T12:00:00Z", + observationError: "Client offline; showing last successful scan", + }, + ], + }), + ), + http.get("/web/qbit/categories", () => HttpResponse.json(emptyCategories)), + ); + renderView(); + await screen.findByText("Radarr-A"); + expect(screen.getByText("Searches").parentElement).toHaveTextContent( + "Searches 0", + ); + expect(screen.getByText("Downloading").parentElement).toHaveTextContent( + "Downloading 0", + ); + expect(screen.getByText("Managed").parentElement).toHaveTextContent( + "Managed 12", + ); + expect(screen.getByText("Arr queue").parentElement).toHaveTextContent("—"); + expect(screen.getByText(/Client offline/)).toBeInTheDocument(); + expect(screen.getByText(/Scanned/)).toBeInTheDocument(); + expect( + screen.queryByText(/Awaiting first completed pass/), + ).not.toBeInTheDocument(); +}); + +it.each(["worker", "torrent"])( + "makes the Arr %s torrent activity target navigate without nesting restart buttons", + async (kind) => { + const navigation = vi.fn(); + window.addEventListener(NAVIGATE_TAB_EVENT, navigation); + server.use( + http.get("/web/processes", () => + HttpResponse.json({ + processes: [ + { + name: "Sonarr-one", + category: "fallback", + kind, + alive: true, + pid: 10, + restartable: true, + }, + ], + }), + ), + http.get("/web/status", () => + HttpResponse.json({ + ready: true, + arrs: [{ name: "Sonarr-one", type: "sonarr", category: "tv" }], + qbitInstances: { one: { alive: false } }, + }), + ), + http.get("/web/qbit/categories", () => + HttpResponse.json({ + ready: true, + categories: [ + { + instance: "one", + category: "tv", + managedBy: "arr", + torrentCount: 2, + seedingCount: 0, + totalSize: 0, + }, + ], + }), + ), + ); + try { + renderView(); + const button = await screen.findByRole("button", { + name: "View torrents for Sonarr-one", + }); + expect(button.querySelector("button")).toBeNull(); + fireEvent.click(button); + expect(navigation).toHaveBeenCalledTimes(1); + expect((navigation.mock.calls[0]![0] as CustomEvent).detail).toEqual({ + tab: "qbittorrent", + qbitCategory: "tv", + qbitInstance: "one", + }); + if (kind === "worker") + expect( + screen.queryByRole("button", { name: /Search/ }), + ).not.toBeInTheDocument(); + } finally { + window.removeEventListener(NAVIGATE_TAB_EVENT, navigation); + } + }, +); diff --git a/webui/src/__tests__/pages/QbitCategoriesView.test.tsx b/webui/src/__tests__/pages/QbitCategoriesView.test.tsx index 4cf84801..de1b417b 100644 --- a/webui/src/__tests__/pages/QbitCategoriesView.test.tsx +++ b/webui/src/__tests__/pages/QbitCategoriesView.test.tsx @@ -20,6 +20,7 @@ vi.mock("../../api/client", () => ({ updateConfig: vi.fn(), getStatus: vi.fn(), getQbitOverview: vi.fn(), + getTransferHistory: vi.fn(async () => ({ series: [] })), })); const torrent = ( name = "Example", @@ -86,6 +87,7 @@ function view(active = true) { } beforeEach(() => { vi.resetAllMocks(); + sessionStorage.clear(); vi.mocked(api.getConfig).mockResolvedValue({ WebUI: { LiveArr: false } }); vi.mocked(api.getStatus).mockResolvedValue({ qbitInstances: { @@ -100,6 +102,13 @@ beforeEach(() => { }); }); describe("torrent overview parity", () => { + it("loads the only configured instance instead of the aggregate view", async () => { + view(); + await waitFor(() => + expect(api.getQbitOverview).toHaveBeenCalledWith("qBit-home"), + ); + expect(screen.queryByText(/All qBittorrent/)).not.toBeInTheDocument(); + }); it("expands a category into torrent metrics and retains aggregate statistics", async () => { view(); fireEvent.click(await screen.findByRole("button", { name: /movies.*Arr/ })); @@ -192,11 +201,15 @@ it("fetches the newly selected instance while an old overview is pending", async old = resolve; }), ); - view(); + const { rerender } = view(); await waitFor(() => expect(api.getQbitOverview).toHaveBeenCalled()); - fireEvent.change(screen.getByLabelText("Instance"), { - target: { value: "qBit-remote" }, - }); + rerender( + + + + + , + ); await screen.findByRole("button", { name: /remote-only/ }); await act(async () => old({ ready: true, instances: [], categories: [category()] }), @@ -205,3 +218,49 @@ it("fetches the newly selected instance while an old overview is pending", async screen.queryByRole("button", { name: /movies.*Arr/ }), ).not.toBeInTheDocument(); }); + +it("clears an old search and opens shared categories across all clients", async () => { + vi.mocked(api.getStatus).mockResolvedValue({ + ready: true, + arrs: [], + qbitInstances: { + "qBit-home": { alive: true, host: "fixture", port: 1, version: null }, + "qBit-remote": { alive: false, host: "fixture", port: 2, version: null }, + }, + }); + vi.mocked(api.getQbitOverview).mockImplementation(async (instance) => ({ + ready: true, + instances: ["qBit-home", "qBit-remote"], + categories: [ + { ...category(), qbitInstance: "qBit-home" }, + { ...category(), qbitInstance: "qBit-remote" }, + ].filter((cat) => !instance || cat.qbitInstance === instance), + })); + const node = (active: boolean, selectedInstance = "qBit-home") => ( + + + + + + ); + const result = render(node(true)); + await screen.findAllByRole("button", { name: /movies.*Arr/ }); + fireEvent.change(screen.getByLabelText("Search"), { + target: { value: "not-a-match" }, + }); + result.rerender(node(false)); + sessionStorage.setItem("qbitrr:focusQbitCategory", "movies"); + sessionStorage.removeItem("torrentarr:focusQbitInstance"); + result.rerender(node(true, "aggregate")); + await waitFor(() => expect(screen.getByLabelText("Search")).toHaveValue("")); + expect(screen.queryByLabelText("Instance")).not.toBeInTheDocument(); + await waitFor(() => { + const buttons = screen.getAllByRole("button", { name: /movies.*Arr/ }); + expect(buttons).toHaveLength(2); + for (const button of buttons) + expect(button).toHaveAttribute("aria-expanded", "true"); + }); +}); diff --git a/webui/src/__tests__/pages/RadarrView.test.tsx b/webui/src/__tests__/pages/RadarrView.test.tsx index 7bc58a3a..fa101a09 100644 --- a/webui/src/__tests__/pages/RadarrView.test.tsx +++ b/webui/src/__tests__/pages/RadarrView.test.tsx @@ -109,7 +109,7 @@ function renderView(active = true) { // ── Card header ─────────────────────────────────────────────────────────────── describe("Radarr catalog – card header", () => { - it("renders the Radarr card header", async () => { + it("renders the Radarr catalog region", async () => { server.use( http.get("/web/config", () => HttpResponse.json(minimalConfig)), http.post("/web/config", () => HttpResponse.json({})), @@ -118,7 +118,7 @@ describe("Radarr catalog – card header", () => { renderView(); - await screen.findByText("Radarr"); + await screen.findByRole("region", { name: "Radarr" }); }); }); @@ -157,10 +157,10 @@ describe("Radarr catalog – empty state", () => { }); }); -// ── Single instance sidebar ─────────────────────────────────────────────────── +// ── Single centralized instance selection ─────────────────────────────────────────────────── -describe("Radarr catalog – instance sidebar", () => { - it("shows instance button in sidebar when one radarr instance is configured", async () => { +describe("Radarr catalog – centralized instance selection", () => { + it("does not duplicate navigation when one radarr instance is configured", async () => { server.use( http.get("/web/config", () => HttpResponse.json(minimalConfig)), http.post("/web/config", () => HttpResponse.json({})), @@ -177,12 +177,10 @@ describe("Radarr catalog – instance sidebar", () => { // content matching (not ARIA traversal) and is much faster. The sidebar // appears as soon as /web/arr responds (before // movies are fetched), so we don't need to wait for movie data here. - const btn = await screen.findByText( - /radarr hd/i, - { selector: "button" }, - { timeout: 4500 }, - ); - expect(btn).toBeInTheDocument(); + await screen.findByText("Total:", { exact: true }); + expect( + screen.queryByRole("button", { name: /radarr hd/i }), + ).not.toBeInTheDocument(); }); it("shows movies table columns when movies are returned", async () => { @@ -306,6 +304,9 @@ it("paginates, searches, filters missing movies, and opens details in the active fireEvent.click(screen.getByText("Wanted").closest("tr")!); const dialog = await screen.findByRole("dialog"); expect(within(dialog).getByText("Wanted")).toBeInTheDocument(); + expect( + within(dialog).getByRole("link", { name: "Open in Radarr HD" }), + ).toBeInTheDocument(); fireEvent.click(within(dialog).getByRole("button", { name: "Close" })); expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); fireEvent.change(screen.getByLabelText("Search"), { @@ -329,6 +330,6 @@ describe("Radarr catalog – inactive", () => { renderView(false); // Card header still renders even when inactive - await screen.findByText("Radarr"); + await screen.findByRole("region", { name: "Radarr" }); }); }); diff --git a/webui/src/__tests__/pages/SonarrView.test.tsx b/webui/src/__tests__/pages/SonarrView.test.tsx index 2aad3a06..f320ef42 100644 --- a/webui/src/__tests__/pages/SonarrView.test.tsx +++ b/webui/src/__tests__/pages/SonarrView.test.tsx @@ -62,7 +62,7 @@ function renderView(active = true) { // ── Card header ─────────────────────────────────────────────────────────────── describe("Sonarr catalog – card header", () => { - it("renders the Sonarr card header", async () => { + it("renders the Sonarr catalog region", async () => { server.use( http.get("/web/config", () => HttpResponse.json(minimalConfig)), http.post("/web/config", () => HttpResponse.json({})), @@ -71,7 +71,7 @@ describe("Sonarr catalog – card header", () => { renderView(); - await screen.findByText("Sonarr"); + await screen.findByRole("region", { name: "Sonarr" }); }); }); @@ -113,8 +113,8 @@ describe("Sonarr catalog – empty state", () => { // ── Single instance ─────────────────────────────────────────────────────────── -describe("Sonarr catalog – instance sidebar", () => { - it("shows instance button in sidebar when one sonarr instance is configured", async () => { +describe("Sonarr catalog – centralized instance selection", () => { + it("does not duplicate navigation when one sonarr instance is configured", async () => { server.use( http.get("/web/config", () => HttpResponse.json(minimalConfig)), http.post("/web/config", () => HttpResponse.json({})), @@ -127,12 +127,10 @@ describe("Sonarr catalog – instance sidebar", () => { renderView(); // Use findByRole to get the sidebar button specifically (not the mobile select option) - const btn = await screen.findByRole( - "button", - { name: /sonarr hd/i }, - { timeout: 5000 }, - ); - expect(btn).toBeInTheDocument(); + await screen.findByRole("region", { name: "Sonarr HD" }); + expect( + screen.queryByRole("button", { name: /sonarr hd/i }), + ).not.toBeInTheDocument(); }); it("shows series table columns when series are returned", async () => { @@ -195,6 +193,6 @@ describe("Sonarr catalog – inactive", () => { renderView(false); - await screen.findByText("Sonarr"); + await screen.findByRole("region", { name: "Sonarr" }); }); }); diff --git a/webui/src/__tests__/setup.ts b/webui/src/__tests__/setup.ts index e9351c65..3731c22b 100644 --- a/webui/src/__tests__/setup.ts +++ b/webui/src/__tests__/setup.ts @@ -28,3 +28,21 @@ if (!globalThis.IntersectionObserver) unobserve() {} disconnect() {} } as unknown as typeof IntersectionObserver; + +if (!HTMLDialogElement.prototype.showModal) { + HTMLDialogElement.prototype.showModal = function () { + this.setAttribute("open", ""); + }; + HTMLDialogElement.prototype.close = function () { + this.removeAttribute("open"); + }; +} + +if (!HTMLElement.prototype.showPopover) { + HTMLElement.prototype.showPopover = function () { + this.setAttribute("open", ""); + }; + HTMLElement.prototype.hidePopover = function () { + this.removeAttribute("open"); + }; +} diff --git a/webui/src/__tests__/theme.test.ts b/webui/src/__tests__/theme.test.ts new file mode 100644 index 00000000..9f8cbbfc --- /dev/null +++ b/webui/src/__tests__/theme.test.ts @@ -0,0 +1,49 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + applyTheme, + isDarkTheme, + resolveTheme, + themeFromValue, +} from "../utils/theme"; + +afterEach(() => { + vi.unstubAllGlobals(); + delete document.documentElement.dataset.palette; +}); + +describe("theme model", () => { + it("parses persisted values and defaults unknown values to system", () => { + expect(themeFromValue("SYSTEM")).toBe("system"); + expect(themeFromValue("Light")).toBe("light"); + expect(themeFromValue("dark")).toBe("dark"); + expect(themeFromValue("Midnight")).toBe("midnight"); + expect(themeFromValue(null)).toBe("system"); + expect(themeFromValue("sepia")).toBe("system"); + }); + + it("resolves the system theme from the media query", () => { + vi.stubGlobal( + "matchMedia", + vi.fn(() => ({ + matches: false, + media: "(prefers-color-scheme: dark)", + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })), + ); + + expect(resolveTheme("system")).toBe("light"); + expect(isDarkTheme("system")).toBe(false); + expect(isDarkTheme("midnight")).toBe(true); + }); + + it("applies midnight as dark with the midnight palette", () => { + applyTheme("midnight"); + expect(document.documentElement).toHaveAttribute("data-theme", "dark"); + expect(document.documentElement.dataset.palette).toBe("midnight"); + + applyTheme("light"); + expect(document.documentElement).toHaveAttribute("data-theme", "light"); + expect(document.documentElement.dataset.palette).toBeUndefined(); + }); +}); diff --git a/webui/src/__tests__/useStatisticsStream.test.tsx b/webui/src/__tests__/useStatisticsStream.test.tsx new file mode 100644 index 00000000..89e00cd1 --- /dev/null +++ b/webui/src/__tests__/useStatisticsStream.test.tsx @@ -0,0 +1,68 @@ +import { act, renderHook } from "@testing-library/react"; +import { afterEach, expect, it, vi } from "vitest"; +import { invalidateGetCache, watchStatistics } from "../api/client"; +import { useStatisticsStream } from "../hooks/useStatisticsStream"; +import { STATISTICS_UPDATED } from "../hooks/useStatisticsUpdates"; + +vi.mock("../api/client", () => ({ + watchStatistics: vi.fn(), + invalidateGetCache: vi.fn(), +})); +afterEach(() => { + vi.useRealTimers(); + vi.resetAllMocks(); +}); + +it("shares one subscription per shell and coalesces updates before invalidating data", async () => { + vi.useFakeTimers(); + let receive!: () => void; + let signal!: AbortSignal; + vi.mocked(watchStatistics).mockImplementation((next, abort) => { + receive = next; + signal = abort; + return new Promise((resolve) => + abort.addEventListener("abort", () => resolve()), + ); + }); + const changed = vi.fn(); + window.addEventListener(STATISTICS_UPDATED, changed); + const { rerender, unmount } = renderHook(() => useStatisticsStream()); + rerender(); + expect(watchStatistics).toHaveBeenCalledTimes(1); + receive(); + receive(); + receive(); + await act(async () => { + await vi.advanceTimersByTimeAsync(150); + }); + expect(changed).toHaveBeenCalledTimes(1); + expect(invalidateGetCache).toHaveBeenCalledWith([ + "/web/processes", + "/web/arr", + "/web/status", + "/web/statistics", + ]); + unmount(); + expect(signal.aborted).toBe(true); + window.removeEventListener(STATISTICS_UPDATED, changed); +}); + +it("reconnects after a failed stream and cancels reconnect work on unmount", async () => { + vi.useFakeTimers(); + vi.mocked(watchStatistics) + .mockRejectedValueOnce(new Error("offline")) + .mockImplementation( + (_next, abort) => + new Promise((resolve) => + abort.addEventListener("abort", () => resolve()), + ), + ); + const { unmount } = renderHook(() => useStatisticsStream()); + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + }); + expect(watchStatistics).toHaveBeenCalledTimes(2); + unmount(); + await vi.advanceTimersByTimeAsync(60000); + expect(watchStatistics).toHaveBeenCalledTimes(2); +}); diff --git a/webui/src/__tests__/useStatisticsUpdates.test.tsx b/webui/src/__tests__/useStatisticsUpdates.test.tsx new file mode 100644 index 00000000..cc7f3f98 --- /dev/null +++ b/webui/src/__tests__/useStatisticsUpdates.test.tsx @@ -0,0 +1,55 @@ +import { act, renderHook } from "@testing-library/react"; +import { expect, it, vi } from "vitest"; +import { + STATISTICS_UPDATED, + useStatisticsUpdates, +} from "../hooks/useStatisticsUpdates"; + +it("refreshes visible database statistics independently of live Arr polling and cleans up", async () => { + const refresh = vi.fn(); + const { rerender, unmount } = renderHook( + ({ active }) => useStatisticsUpdates(active, refresh), + { initialProps: { active: true } }, + ); + await act(async () => { + window.dispatchEvent(new Event(STATISTICS_UPDATED)); + }); + expect(refresh).toHaveBeenCalledTimes(1); + rerender({ active: false }); + await act(async () => { + window.dispatchEvent(new Event(STATISTICS_UPDATED)); + }); + expect(refresh).toHaveBeenCalledTimes(1); + rerender({ active: true }); + await act(async () => { + document.dispatchEvent(new Event("visibilitychange")); + }); + expect(refresh).toHaveBeenCalledTimes(2); + unmount(); + window.dispatchEvent(new Event(STATISTICS_UPDATED)); + expect(refresh).toHaveBeenCalledTimes(2); +}); + +it("fetches again when a database change arrives during an in-flight refresh", async () => { + let complete!: () => void; + const refresh = vi + .fn<() => Promise>() + .mockImplementationOnce( + () => + new Promise((resolve) => { + complete = resolve; + }), + ) + .mockResolvedValue(undefined); + renderHook(() => useStatisticsUpdates(true, refresh)); + act(() => { + window.dispatchEvent(new Event(STATISTICS_UPDATED)); + window.dispatchEvent(new Event(STATISTICS_UPDATED)); + window.dispatchEvent(new Event(STATISTICS_UPDATED)); + }); + expect(refresh).toHaveBeenCalledTimes(1); + await act(async () => { + complete(); + }); + expect(refresh).toHaveBeenCalledTimes(2); +}); diff --git a/webui/src/api/client.ts b/webui/src/api/client.ts index 6c2642ab..3c7697dd 100644 --- a/webui/src/api/client.ts +++ b/webui/src/api/client.ts @@ -1,5 +1,7 @@ import { clearUrlBaseCache, setUrlBaseFromMeta, webPath } from "./urlBase"; import type { + TransferHistoryResponse, + TransferRange, ArrListResponse, ConfigDocument, ConfigResponseWithWarning, @@ -951,38 +953,35 @@ export function getQbitOverviewPath(instance?: string): string { export async function watchConfiguration( receive: (event: import("./types").LiveConfigurationEvent) => void, signal: AbortSignal, + path = "/web/config/events", ): Promise { - await fetchWithAuthRetry( - "/web/config/events", - { signal }, - async (response) => { - if ( - !response.ok || - !response.body || - !response.headers.get("content-type")?.includes("text/event-stream") - ) - throw new Error("Configuration stream unavailable"); - const reader = response.body - .pipeThrough(new TextDecoderStream()) - .getReader(); - let pending = ""; - try { - while (!signal.aborted) { - const { value, done } = await reader.read(); - if (done) break; - pending += value; - let end: number; - while ((end = pending.indexOf("\n\n")) >= 0) { - const event = pending.slice(0, end); - pending = pending.slice(end + 2); - if (event.startsWith("data: ")) receive(JSON.parse(event.slice(6))); - } + await fetchWithAuthRetry(path, { signal }, async (response) => { + if ( + !response.ok || + !response.body || + !response.headers.get("content-type")?.includes("text/event-stream") + ) + throw new Error("Configuration stream unavailable"); + const reader = response.body + .pipeThrough(new TextDecoderStream()) + .getReader(); + let pending = ""; + try { + while (!signal.aborted) { + const { value, done } = await reader.read(); + if (done) break; + pending += value; + let end: number; + while ((end = pending.indexOf("\n\n")) >= 0) { + const event = pending.slice(0, end); + pending = pending.slice(end + 2); + if (event.startsWith("data: ")) receive(JSON.parse(event.slice(6))); } - } finally { - reader.releaseLock(); } - }, - ); + } finally { + reader.releaseLock(); + } + }); } export const getTorrentAutomationStatus = (): Promise< @@ -1014,3 +1013,49 @@ export async function getIssueDetails( }> { return fetchJson(`/web/issues/${id}?page=${page}`); } + +export function watchStatistics( + receive: () => void, + signal: AbortSignal, +): Promise { + return watchConfiguration(() => receive(), signal, "/web/stats/events"); +} + +export const getFreeSpaceStatistics = (): Promise< + import("./types").FreeSpaceStatistics[] +> => fetchJson("/web/free-space/stats"); + +export function getTransferHistory( + instance: string | undefined, + range: TransferRange, +) { + const params = new URLSearchParams({ range }); + if (instance) params.set("instance", instance); + return fetchJson( + `/web/statistics/transfers?${params}`, + ); +} + +export interface CatalogStatistics { + unit: string; + total: number; + available: number; + monitored: number; + missing: number; +} +export function getCatalogStatistics(kind: string, instance?: string) { + const params = new URLSearchParams({ kind }); + if (instance) params.set("instance", instance); + return fetchJson(`/web/statistics/catalog?${params}`); +} + +export function getArrActivity( + range: import("./types").TransferRange, + worker?: string, +) { + const params = new URLSearchParams({ range }); + if (worker) params.set("worker", worker); + return fetchJson( + `/web/statistics/arr-activity?${params}`, + ); +} diff --git a/webui/src/api/types.ts b/webui/src/api/types.ts index ec5a3101..5bf0411d 100644 --- a/webui/src/api/types.ts +++ b/webui/src/api/types.ts @@ -8,6 +8,16 @@ export interface WorkerLoopProgress { } export interface ProcessInfo { + searchesTriggered?: number | null; + itemsSearched?: number | null; + downloadingCount?: number | null; + seedingCount?: number | null; + observedAt?: string | null; + observationError?: string | null; + pausedLastPass?: number | null; + resumedLastPass?: number | null; + handledLastPass?: number | null; + monitoredCategoryCount?: number | null; torrent?: WorkerLoopProgress | null; search?: WorkerLoopProgress | null; freeSpacePaused?: number; @@ -53,6 +63,7 @@ export interface ArrListResponse { } export interface QbitInstance { + scanIntervalSeconds?: number; alive: boolean; host: string; port: number; @@ -76,6 +87,10 @@ export interface QbitCategorySeedingConfig { } export interface QbitCategory { + downloadingCount?: number; + observedAt?: string | null; + observationError?: string | null; + scanIntervalSeconds?: number; category: string; instance: string; managedBy: "qbit" | "arr"; @@ -87,7 +102,14 @@ export interface QbitCategory { seedingConfig: QbitCategorySeedingConfig | null; } +export interface ClientObservation { + observedAt: string | null; + error: string | null; + scanIntervalSeconds: number; +} + export interface QbitCategoriesResponse { + observations?: Record; categories: QbitCategory[]; ready: boolean; } @@ -157,7 +179,41 @@ export interface QbitOverviewCategory { torrentsTruncated?: boolean; } +export interface QbitActivitySummary { + instance: string; + torrentCount: number; + totalSize: number; + downloading: number; + seeding: number; + stopped: number; + other: number; +} + +export type TransferRange = "1h" | "24h" | "7d"; +export interface TransferBucket { + timestamp: string; + downloadRate: number | null; + uploadRate: number | null; + samples: number; + expectedSamples: number; +} +export interface TransferHistoryResponse { + range: TransferRange; + bucketMinutes: number; + generatedAt: string; + series: { instance: string; points: TransferBucket[] }[]; +} + export interface QbitOverviewResponse { + summaries?: QbitActivitySummary[]; + observations?: Record< + string, + { + observedAt: string | null; + error: string | null; + scanIntervalSeconds: number; + } + >; instances: string[]; categories: QbitOverviewCategory[]; ready: boolean; @@ -514,7 +570,14 @@ export interface MetaResponse { url_base?: string; } +export interface ConfigRestartRequirement { + component: string; + category: string; + kind: string; +} + export interface ConfigUpdateResponse { + restartRequirements?: ConfigRestartRequirement[]; application?: ConfigurationApplicationStatus; warning?: string | null; status: string; @@ -801,3 +864,36 @@ export interface IssuesSummary { }[]; pending: PendingOperationalIssue[]; } + +export interface FreeSpaceStatistics { + clientInstanceId: string; + path: string; + error: string | null; + freeBytes: number; + thresholdBytes: number; + committedBytes: number | null; + remainingAllocationBytes: number | null; + lastCompletedPass: string | null; + blockedReason: string | null; +} + +export interface ArrActivityPoint { + timestamp: string; + queueDepth: number | null; + searchesCompleted: number | null; + importsConfirmed: number | null; + queueSamples: number; + completionSamples: number; + expectedSamples: number; +} +export interface ArrActivitySeries { + instance: string; + type: string; + points: ArrActivityPoint[]; +} +export interface ArrActivityHistory { + range: TransferRange; + bucketMinutes: number; + generatedAt: string; + series: ArrActivitySeries[]; +} diff --git a/webui/src/components/ArrActivityCharts.tsx b/webui/src/components/ArrActivityCharts.tsx new file mode 100644 index 00000000..8bf1445a --- /dev/null +++ b/webui/src/components/ArrActivityCharts.tsx @@ -0,0 +1,352 @@ +import { useChartInspection } from "../hooks/useChartInspection"; +import { useEffect, useState } from "react"; +import { getArrActivity } from "../api/client"; +import type { + ArrActivityHistory, + ArrActivitySeries, + TransferRange, +} from "../api/types"; +import { STATISTICS_UPDATED } from "../hooks/useStatisticsUpdates"; +import { + activityColor, + activityCoverage, + activityPath, + activityValue, + groupActivity, + type ActivityMetric, +} from "../utils/arrActivityChart"; + +function ActivityPlot({ + series, + metric, + title, + grouping, + index, + inspection, + colorOf, +}: { + series: ArrActivitySeries[]; + metric: ActivityMetric; + title: string; + grouping: "type" | "worker"; + index: number; + inspection: ReturnType>; + colorOf: (series: ArrActivitySeries) => string; +}) { + const maximum = Math.max( + 1, + ...series.map((s) => + s.points.reduce((max, p) => Math.max(max, p[metric] ?? 0), 0), + ), + ); + const count = series[0]?.points.length ?? 0; + const available = series.some((s) => s.points.some((p) => p[metric] != null)); + return ( +
+
{title}
+ {!available ? ( +

+ {series.length + ? "Collecting activity history. Unavailable observations appear as gaps." + : "No series selected."} +

+ ) : ( + <> + { + if (event.pointerType === "touch") return; + const rect = event.currentTarget.getBoundingClientRect(); + if (!rect.width) return; + inspection.inspect( + Math.round( + Math.max( + 0, + Math.min( + 1, + (((event.clientX - rect.left) / rect.width) * 800 - 24) / + 752, + ), + ) * + (count - 1), + ), + ); + }} + > + {[30, 86, 142].map((y) => ( + + ))} + + {maximum.toLocaleString(undefined, { maximumFractionDigits: 0 })} + + + {series[0]?.points[0] + ? new Date(series[0].points[0].timestamp).toLocaleString() + : ""} + + + {series[0]?.points.at(-1) + ? new Date(series[0].points.at(-1)!.timestamp).toLocaleString() + : ""} + + + {series.map((s) => ( + + + {s.points.map((p, i) => + p[metric] != null && + activityCoverage(p, metric) === p.expectedSamples ? ( + + {`${s.instance} · ${new Date(p.timestamp).toLocaleString()} · ${title}: ${activityValue(p, metric)}`} + + ) : null, + )} + + ))} + +
+ {series.map((s) => ( +
+
+ + {s.instance} +
+
{activityValue(s.points[index], metric)}
+
+ ))} +
+ + )} +
+ ); +} + +export function ArrActivityCharts({ active }: { active: boolean }) { + const [range, setRange] = useState("24h"); + const [grouping, setGrouping] = useState<"type" | "worker">("type"); + const [metric, setMetric] = useState< + "searchesCompleted" | "importsConfirmed" + >("searchesCompleted"); + const [hidden, setHidden] = useState>(() => new Set()); + const inspection = useChartInspection( + `${range}:${grouping}:${metric}:${[...hidden].join(":")}`, + active, + ); + const cursor = inspection.value; + const [result, setResult] = useState<{ + range: TransferRange; + data: ArrActivityHistory; + } | null>(null); + const [failed, setFailed] = useState(null); + useEffect(() => { + if (!active) return; + let cancelled = false, + busy = false; + const load = async () => { + if (busy || document.hidden) return; + busy = true; + try { + const data = await getArrActivity(range); + if (!cancelled) { + setResult({ range, data }); + setFailed(null); + } + } catch { + if (!cancelled) setFailed(range); + } finally { + busy = false; + } + }; + void load(); + window.addEventListener("torrentarr:statistics-refresh", load); + window.addEventListener(STATISTICS_UPDATED, load); + document.addEventListener("visibilitychange", load); + return () => { + cancelled = true; + window.removeEventListener(STATISTICS_UPDATED, load); + document.removeEventListener("visibilitychange", load); + window.removeEventListener("torrentarr:statistics-refresh", load); + }; + }, [range, active]); + const data = result?.range === range ? result.data : null; + const series = groupActivity(data?.series ?? [], grouping); + const shown = series.filter((s) => !hidden.has(`${grouping}:${s.instance}`)); + const workerIndex = new Map(series.map((s, i) => [s.instance, i])); + const colorOf = (s: ArrActivitySeries) => + activityColor(s, grouping, workerIndex.get(s.instance) ?? 0); + const count = series[0]?.points.length ?? 0; + const index = Math.min(cursor ?? count - 1, Math.max(0, count - 1)); + const time = series[0]?.points[index]?.timestamp; + const throughput = + metric === "searchesCompleted" + ? "Searches completed" + : "Torrent imports confirmed"; + return ( +
+
+

Arr activity

+
+ +
+ {(["1h", "24h", "7d"] as const).map((value) => ( + + ))} +
+ +
+
+ {failed === range && ( +

+ Arr activity unavailable. + {data ? " Showing the last received history." : ""} +

+ )} + {!data && failed !== range && ( +

Loading Arr activity…

+ )} + {data && !series.length && ( +

+ Collecting activity history. No managed Arr workers yet. +

+ )} + {series.length > 0 && ( + <> +
+ {series.map((s) => { + const key = `${grouping}:${s.instance}`; + return ( + + ); + })} +
+
+ + +
+ +

+ 7 days retained. Queue values are interval averages rounded to whole + items; completions are counted when confirmed. Gaps in worker + coverage break the line. +

+ + )} +
+ ); +} diff --git a/webui/src/components/CatalogStatistics.tsx b/webui/src/components/CatalogStatistics.tsx new file mode 100644 index 00000000..2da7df05 --- /dev/null +++ b/webui/src/components/CatalogStatistics.tsx @@ -0,0 +1,91 @@ +import { useEffect, useState } from "react"; +import { + getCatalogStatistics, + type CatalogStatistics as Statistics, +} from "../api/client"; +import { STATISTICS_UPDATED } from "../hooks/useStatisticsUpdates"; +import { BreakdownBar } from "./Statistics"; + +export function CatalogStatistics({ + kind, + instance, + active, +}: { + kind: string; + instance?: string; + active: boolean; +}) { + const key = `${kind}:${instance ?? "aggregate"}`; + const [result, setResult] = useState<{ + key: string; + data: Statistics; + } | null>(null); + const [failedKey, setFailedKey] = useState(""); + useEffect(() => { + if (!active) return; + let cancelled = false; + let busy = false; + const load = async () => { + if (busy || document.hidden) return; + busy = true; + try { + const data = await getCatalogStatistics(kind, instance); + if (!cancelled) { + setResult({ key, data }); + setFailedKey(""); + } + } catch { + if (!cancelled) setFailedKey(key); + } finally { + busy = false; + } + }; + void load(); + window.addEventListener("torrentarr:statistics-refresh", load); + window.addEventListener(STATISTICS_UPDATED, load); + document.addEventListener("visibilitychange", load); + return () => { + cancelled = true; + window.removeEventListener("torrentarr:statistics-refresh", load); + window.removeEventListener(STATISTICS_UPDATED, load); + document.removeEventListener("visibilitychange", load); + }; + }, [kind, instance, active, key]); + const data = result?.key === key ? result.data : null; + if (!data) + return ( +

+ {failedKey === key + ? "Catalog statistics unavailable." + : "Loading catalog statistics…"} +

+ ); + return ( +
+
+ + {data.available.toLocaleString()} / {data.monitored.toLocaleString()}{" "} + monitored {data.unit} available + + + {data.total.toLocaleString()} {data.unit} in catalog · full selected + library + +
+ + {failedKey === key && ( +

+ Refresh unavailable; showing the last received statistics. +

+ )} +
+ ); +} diff --git a/webui/src/components/ConfirmDialog.tsx b/webui/src/components/ConfirmDialog.tsx index fd8c0257..185cf429 100644 --- a/webui/src/components/ConfirmDialog.tsx +++ b/webui/src/components/ConfirmDialog.tsx @@ -1,4 +1,4 @@ -import type { JSX } from "react"; +import { useEffect, useRef, type JSX } from "react"; import { IconImage } from "./IconImage"; import CloseIcon from "../icons/close.svg"; @@ -21,11 +21,38 @@ export function ConfirmDialog({ onCancel, danger = false, }: ConfirmDialogProps): JSX.Element { + const dialog = useRef(null); + useEffect(() => { + const previous = document.activeElement as HTMLElement | null; + dialog.current?.querySelector("button")?.focus(); + return () => previous?.focus(); + }, []); return (
{ + if (event.key === "Escape") { + event.preventDefault(); + onCancel(); + } + if (event.key !== "Tab") return; + const buttons = dialog.current?.querySelectorAll( + "button:not(:disabled)", + ); + if (!buttons?.length) return; + const first = buttons[0], + last = buttons[buttons.length - 1]; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + }} aria-modal="true" aria-labelledby="confirm-dialog-title" style={{ maxWidth: "500px" }} @@ -33,7 +60,7 @@ export function ConfirmDialog({ >

{title}

-
diff --git a/webui/src/components/ShellDialog.tsx b/webui/src/components/ShellDialog.tsx new file mode 100644 index 00000000..6ecd2125 --- /dev/null +++ b/webui/src/components/ShellDialog.tsx @@ -0,0 +1,61 @@ +import { useLayoutEffect, useRef, type ReactNode } from "react"; + +/** Native modal drawers contain focus; display popovers allow light dismissal. */ +export function ShellDialog({ + title, + onClose, + children, + className = "", + popover = false, +}: { + title: string; + onClose: () => void; + children: ReactNode; + className?: string; + popover?: boolean; +}) { + const ref = useRef(null); + useLayoutEffect(() => { + const trigger = document.activeElement as HTMLElement | null; + const dialog = ref.current!; + if (popover) dialog.showPopover(); + else dialog.showModal(); + return () => { + if (popover) dialog.hidePopover(); + else dialog.close(); + trigger?.focus(); + }; + }, [popover]); + return ( + { + if (popover && (event.nativeEvent as ToggleEvent).newState === "closed") + onClose(); + }} + onCancel={(event) => { + event.preventDefault(); + onClose(); + }} + onClick={(event) => { + if (event.target === event.currentTarget) onClose(); + }} + > +
+ {title} + +
+ {children} +
+ ); +} diff --git a/webui/src/components/Statistics.tsx b/webui/src/components/Statistics.tsx new file mode 100644 index 00000000..5a117873 --- /dev/null +++ b/webui/src/components/Statistics.tsx @@ -0,0 +1,78 @@ +import { useChartInspection } from "../hooks/useChartInspection"; +import { type ReactNode } from "react"; + +export function MetricStrip({ + items, + label, +}: { + label: string; + items: { label: string; value: ReactNode }[]; +}) { + return ( +
+ {items.map((item) => ( +
+
{item.label}
+
{item.value}
+
+ ))} +
+ ); +} + +export function BreakdownBar({ + label, + values, + unit = "torrents", + active = true, +}: { + label: string; + values: { label: string; value: number; tone: string }[]; + unit?: string; + active?: boolean; +}) { + const inspection = useChartInspection(label, active); + const selected = inspection.value; + const total = values.reduce((sum, value) => sum + value.value, 0); + return ( +
+
+ {values.map((item) => ( + 0 ? 0 : undefined} + role="button" + aria-label={`${label} · ${item.label}: ${item.value.toLocaleString()} ${unit} (${total ? ((item.value / total) * 100).toFixed(1) : 0}%)`} + {...inspection.bind(item.label)} + /> + ))} +
+ {selected && ( +
+ {values + .filter((item) => item.label === selected) + .map( + (item) => + `${label} · ${item.label}: ${item.value.toLocaleString()} ${unit} (${total ? ((item.value / total) * 100).toFixed(1) : 0}%)`, + )} +
+ )} +
+ {values.map((item) => ( + +