diff --git a/config/seed-config.sample.yaml b/config/seed-config.sample.yaml index 30f55cf3..e63987ae 100644 --- a/config/seed-config.sample.yaml +++ b/config/seed-config.sample.yaml @@ -29,7 +29,9 @@ users: # For production, use npx: command: "npx", args: ["-y", "@ashdev/codex-plugin-"] # # credential_delivery options: "init_message" (default), "env", "both" -# credentials: JSON object with API keys/tokens (optional, can be set later via UI) +# credentials: YAML mapping of API keys/tokens (optional, can be set later via UI). +# Use a YAML mapping, NOT a quoted JSON string — quoting turns it into a string +# and the plugin will receive the raw text instead of an object. # # NOTE: If credentials are provided, CODEX_ENCRYPTION_KEY must be set. plugins: @@ -48,7 +50,8 @@ plugins: - "library:detail" - "library:scan" credential_delivery: init_message - # credentials: { "api_key": "your-mangabaka-api-key" } + # credentials: + # api_key: "your-mangabaka-api-key" # Echo - Test/debug plugin (no credentials needed) - name: metadata-echo diff --git a/docs/api/openapi.json b/docs/api/openapi.json index b32c8275..f5d561a9 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -21078,6 +21078,38 @@ } } }, + "CreateLibraryJobRequest": { + "type": "object", + "description": "Request body for `POST /api/v1/libraries/{id}/jobs`.", + "required": [ + "cronSchedule", + "config" + ], + "properties": { + "config": { + "$ref": "#/components/schemas/LibraryJobConfigDto" + }, + "cronSchedule": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "name": { + "type": [ + "string", + "null" + ], + "description": "Optional user-facing name. Auto-generated when missing or empty." + }, + "timezone": { + "type": [ + "string", + "null" + ] + } + } + }, "CreateLibraryRequest": { "type": "object", "description": "Create library request", @@ -21692,6 +21724,152 @@ } } }, + "DryRunFieldChange": { + "type": "object", + "required": [ + "before", + "after" + ], + "properties": { + "after": {}, + "before": {} + } + }, + "DryRunReportDto": { + "type": "object", + "description": "Dry-run preview attached to [`MetadataApplyResponse`] when the request\nset `dryRun = true`. Absent on real applies.", + "required": [ + "changes" + ], + "properties": { + "changes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FieldChangeDto" + } + } + } + }, + "DryRunRequest": { + "type": "object", + "description": "Request body for `POST .../dry-run`.", + "properties": { + "configOverride": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/LibraryJobConfigDto", + "description": "Override the saved config for this preview only. Must match the\nrow's `type`." + } + ] + }, + "sampleSize": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Sample size, capped at 20 server-side.", + "minimum": 0 + } + } + }, + "DryRunResponse": { + "type": "object", + "required": [ + "totalEligible", + "sample", + "estSkippedNoId", + "estSkippedRecentlySynced" + ], + "properties": { + "estSkippedNoId": { + "type": "integer", + "format": "int32", + "description": "Estimated count of series that would be skipped because they have no\nexternal ID for the chosen provider.", + "minimum": 0 + }, + "estSkippedRecentlySynced": { + "type": "integer", + "format": "int32", + "description": "Estimated count of series that would be skipped because they were\nrecently synced.", + "minimum": 0 + }, + "planFailure": { + "type": [ + "string", + "null" + ], + "description": "Provider resolution failure reason, if any." + }, + "sample": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DryRunSeriesDelta" + }, + "description": "Per-series deltas for the first N eligible series." + }, + "totalEligible": { + "type": "integer", + "format": "int32", + "description": "Total number of series eligible to be refreshed (all of them, not\njust the sample).", + "minimum": 0 + } + } + }, + "DryRunSeriesDelta": { + "type": "object", + "description": "One series's preview of would-be field changes.", + "required": [ + "seriesId", + "seriesName", + "changes", + "skipped" + ], + "properties": { + "changes": { + "type": "object", + "description": "Field name → `(before, after)` JSON values.", + "additionalProperties": { + "$ref": "#/components/schemas/DryRunFieldChange" + }, + "propertyNames": { + "type": "string" + } + }, + "seriesId": { + "type": "string", + "format": "uuid" + }, + "seriesName": { + "type": "string" + }, + "skipped": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DryRunSkippedFieldDto" + }, + "description": "Fields that would have been written but were skipped (locks, all-locked, etc.)" + } + } + }, + "DryRunSkippedFieldDto": { + "type": "object", + "required": [ + "field", + "reason" + ], + "properties": { + "field": { + "type": "string" + }, + "reason": { + "type": "string" + } + } + }, "DuplicateGroup": { "type": "object", "description": "A group of duplicate books", @@ -22834,6 +23012,46 @@ "not_provided" ] }, + "FieldChangeDto": { + "type": "object", + "description": "One would-be field change recorded during a dry-run apply.\n\nMirrors `services::metadata::apply::FieldChange`, kept as a distinct DTO\nto keep the wire-format frozen even if internal types evolve.", + "required": [ + "field", + "after" + ], + "properties": { + "after": {}, + "before": { + "description": "Current value, where cheaply available. `null` for fields backed by\njoined tables (genres, tags, alternate titles, ratings, etc.)." + }, + "field": { + "type": "string" + } + } + }, + "FieldGroupDto": { + "type": "object", + "description": "Static field-group catalog row exposed for the editor UI.", + "required": [ + "id", + "label", + "fields" + ], + "properties": { + "fields": { + "type": "array", + "items": { + "type": "string" + } + }, + "id": { + "type": "string" + }, + "label": { + "type": "string" + } + } + }, "FieldOperator": { "oneOf": [ { @@ -25887,6 +26105,101 @@ } } }, + "LibraryJobConfigDto": { + "oneOf": [ + { + "allOf": [ + { + "$ref": "#/components/schemas/MetadataRefreshJobConfigDto" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "metadata_refresh" + ] + } + } + } + ] + } + ], + "description": "Type-discriminated job config exposed over the wire.\n\nPhase 9 only ships the `metadata_refresh` variant; future job types\nextend the enum." + }, + "LibraryJobDto": { + "type": "object", + "description": "Library job row exposed via GET / list / response.", + "required": [ + "id", + "libraryId", + "name", + "enabled", + "cronSchedule", + "config", + "createdAt", + "updatedAt" + ], + "properties": { + "config": { + "$ref": "#/components/schemas/LibraryJobConfigDto" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "cronSchedule": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "lastRunAt": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "lastRunMessage": { + "type": [ + "string", + "null" + ] + }, + "lastRunStatus": { + "type": [ + "string", + "null" + ] + }, + "libraryId": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "timezone": { + "type": [ + "string", + "null" + ] + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } + }, "LibraryMetricsDto": { "type": "object", "description": "Metrics for a single library", @@ -25973,6 +26286,21 @@ } } }, + "ListLibraryJobsResponse": { + "type": "object", + "description": "Response for `GET /libraries/{id}/jobs`.", + "required": [ + "jobs" + ], + "properties": { + "jobs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LibraryJobDto" + } + } + } + }, "ListSettingsQuery": { "type": "object", "description": "Query parameters for listing settings", @@ -26091,6 +26419,10 @@ "externalId" ], "properties": { + "dryRun": { + "type": "boolean", + "description": "When `true`, the call simulates the apply without writing to the\ndatabase. Returns the same `appliedFields`/`skippedFields` plus an\nextra `dryRunReport` showing every would-be change. Default `false`." + }, "externalId": { "type": "string", "description": "External ID from the plugin's search results" @@ -26129,6 +26461,17 @@ }, "description": "Fields that were applied" }, + "dryRunReport": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/DryRunReportDto", + "description": "Populated only when the request set `dryRun = true`. Each entry is a\nfield that *would* have been written." + } + ] + }, "message": { "type": "string", "description": "Message" @@ -26663,6 +27006,67 @@ } } }, + "MetadataRefreshJobConfigDto": { + "type": "object", + "description": "Wire shape for the metadata-refresh job config.", + "required": [ + "provider" + ], + "properties": { + "bookExtraFields": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Reserved for the book-scope future work." + }, + "bookFieldGroups": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Reserved for the book-scope future work." + }, + "existingSourceIdsOnly": { + "type": "boolean", + "description": "When true, the planner skips series with no stored external ID." + }, + "extraFields": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Series-side individual field overrides (camelCase)." + }, + "fieldGroups": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Series-side field groups (snake_case identifiers)." + }, + "maxConcurrency": { + "type": "integer", + "format": "int32", + "description": "Per-task fan-out; clamped at run time.", + "minimum": 0 + }, + "provider": { + "type": "string", + "description": "Plugin reference, e.g. `\"plugin:mangabaka\"`." + }, + "scope": { + "$ref": "#/components/schemas/RefreshScope", + "description": "Refresh scope. Phase 9 only honours `series_only` at runtime." + }, + "skipRecentlySyncedWithinS": { + "type": "integer", + "format": "int32", + "description": "Skip series whose `last_synced_at` is younger than this many seconds.", + "minimum": 0 + } + } + }, "MetricsCleanupResponse": { "type": "object", "description": "Response for cleanup operation", @@ -28797,6 +29201,47 @@ } } }, + "PatchLibraryJobRequest": { + "type": "object", + "description": "Request body for `PATCH /api/v1/libraries/{id}/jobs/{job_id}`.\n\nAll fields are optional. Top-level fields use [`PatchValue`] when their\nunderlying type is `Option<...>` so an explicit `null` clears the value\ndistinct from \"not present\".", + "properties": { + "config": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/LibraryJobConfigDto", + "description": "Replaces the type-specific config wholesale; the type discriminator\nmust match the existing row's type." + } + ] + }, + "cronSchedule": { + "type": [ + "string", + "null" + ] + }, + "enabled": { + "type": [ + "boolean", + "null" + ] + }, + "name": { + "type": [ + "string", + "null" + ] + }, + "timezone": { + "type": [ + "string", + "null" + ] + } + } + }, "PatchSeriesMetadataRequest": { "type": "object", "description": "PATCH request for partial update of series metadata\n\nOnly provided fields will be updated. Absent fields are unchanged.\nExplicitly null fields will be cleared.", @@ -29039,6 +29484,17 @@ "type": "string", "description": "Action type (e.g., \"metadata_search\", \"metadata_get\")" }, + "capabilities": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/PluginCapabilitiesDto", + "description": "Capabilities the plugin advertises in its manifest. The library-jobs\neditor uses this to decide which scope options are available for the\nchosen provider. The `metadata_provider` array contains `\"series\"`\nand/or `\"book\"` entries." + } + ] + }, "description": { "type": [ "string", @@ -30963,6 +31419,15 @@ } } }, + "RefreshScope": { + "type": "string", + "description": "Scope of a metadata refresh job.\n\nPhase 9 only honours [`RefreshScope::SeriesOnly`] at runtime. The\nother variants are schema-accepted but rejected by the validator.", + "enum": [ + "series_only", + "books_only", + "series_and_books" + ] + }, "RegisterRequest": { "type": "object", "description": "Register request", @@ -31717,6 +32182,19 @@ } } }, + "RunNowResponse": { + "type": "object", + "description": "Response for `POST .../run-now`.", + "required": [ + "taskId" + ], + "properties": { + "taskId": { + "type": "string", + "format": "uuid" + } + } + }, "ScanStatusDto": { "type": "object", "description": "Scan status response", @@ -34363,6 +34841,26 @@ } } }, + { + "type": "object", + "description": "Scheduled per-job metadata refresh.\n\nLoads the [`library_jobs`] row by `job_id`, decodes its config (single\nprovider + field groups + safety options), walks the library's series,\nand refreshes metadata via the existing `MetadataApplier`.", + "required": [ + "jobId", + "type" + ], + "properties": { + "jobId": { + "type": "string", + "format": "uuid" + }, + "type": { + "type": "string", + "enum": [ + "refresh_library_metadata" + ] + } + } + }, { "type": "object", "description": "Generate thumbnails for books in a scope (library, series, specific books, or all)\nThis is a fan-out task that enqueues individual GenerateThumbnail tasks", @@ -36900,6 +37398,10 @@ "name": "Plugin Actions", "description": "Plugin action discovery and execution for metadata fetching" }, + { + "name": "Library Jobs", + "description": "Per-library scheduled jobs (metadata refresh today; future: scan, cleanup). Supports CRUD, run-now, and dry-run preview." + }, { "name": "User Plugins", "description": "User-facing plugin management, OAuth, and configuration" diff --git a/migration/src/lib.rs b/migration/src/lib.rs index 36cf07dd..46de62c6 100644 --- a/migration/src/lib.rs +++ b/migration/src/lib.rs @@ -143,6 +143,10 @@ mod m20260502_000068_drop_book_count; mod m20260503_000069_add_book_chapter; // Backfill volume/chapter from filename for already-scanned books (Phase 12) mod m20260503_000070_backfill_book_volume_chapter; +// Library jobs table for scheduled work (Phase 9 of scheduled-metadata-refresh). +// Filename retains the original Phase 1 name for git-history continuity; module +// now creates the generic `library_jobs` table instead of adding a JSON column. +mod m20260503_000071_add_metadata_refresh_config; pub struct Migrator; @@ -258,6 +262,8 @@ impl MigratorTrait for Migrator { Box::new(m20260503_000069_add_book_chapter::Migration), // Backfill book_metadata.volume / .chapter from filename (Phase 12) Box::new(m20260503_000070_backfill_book_volume_chapter::Migration), + // Per-library scheduled metadata refresh config (Phase 1) + Box::new(m20260503_000071_add_metadata_refresh_config::Migration), ] } } diff --git a/migration/src/m20260503_000071_add_metadata_refresh_config.rs b/migration/src/m20260503_000071_add_metadata_refresh_config.rs new file mode 100644 index 00000000..c2578dcf --- /dev/null +++ b/migration/src/m20260503_000071_add_metadata_refresh_config.rs @@ -0,0 +1,171 @@ +//! Create the `library_jobs` table (Phase 9 of scheduled-metadata-refresh). +//! +//! Replaces the original Phase 1 design (a `metadata_refresh_config` JSON +//! column on `libraries`) with a generic, type-discriminated table that +//! supports N independent jobs per library. The `type` column dispatches to +//! type-specific config; `metadata_refresh` is the first type. Future job +//! types (scan, cleanup) extend the discriminator without schema changes. +//! +//! The migration filename is preserved (timestamp stays the same) because +//! the original Phase 1 migration never shipped to production. + +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let is_postgres = manager.get_database_backend() == sea_orm::DatabaseBackend::Postgres; + + let mut table = Table::create(); + table.table(LibraryJobs::Table).if_not_exists(); + + if is_postgres { + table.col( + ColumnDef::new(LibraryJobs::Id) + .uuid() + .not_null() + .primary_key() + .extra("DEFAULT gen_random_uuid()"), + ); + } else { + table.col( + ColumnDef::new(LibraryJobs::Id) + .uuid() + .not_null() + .primary_key(), + ); + } + + manager + .create_table( + table + .col(ColumnDef::new(LibraryJobs::LibraryId).uuid().not_null()) + // Type discriminator. "metadata_refresh" today; future: + // "scan", "cleanup", etc. + .col( + ColumnDef::new(LibraryJobs::JobType) + .string_len(64) + .not_null(), + ) + .col(ColumnDef::new(LibraryJobs::Name).string_len(200).not_null()) + .col( + ColumnDef::new(LibraryJobs::Enabled) + .boolean() + .not_null() + .default(false), + ) + .col( + ColumnDef::new(LibraryJobs::CronSchedule) + .string_len(120) + .not_null(), + ) + // Optional per-job timezone override; falls back to server tz. + .col(ColumnDef::new(LibraryJobs::Timezone).string_len(80)) + // Type-specific JSON payload. + .col(ColumnDef::new(LibraryJobs::Config).text().not_null()) + .col(ColumnDef::new(LibraryJobs::LastRunAt).timestamp_with_time_zone()) + // "success" | "failure" | NULL + .col(ColumnDef::new(LibraryJobs::LastRunStatus).string_len(32)) + .col(ColumnDef::new(LibraryJobs::LastRunMessage).text()) + .col({ + let mut col = ColumnDef::new(LibraryJobs::CreatedAt); + col.timestamp_with_time_zone().not_null(); + if is_postgres { + col.extra("DEFAULT NOW()"); + } else { + col.extra("DEFAULT CURRENT_TIMESTAMP"); + } + col + }) + .col({ + let mut col = ColumnDef::new(LibraryJobs::UpdatedAt); + col.timestamp_with_time_zone().not_null(); + if is_postgres { + col.extra("DEFAULT NOW()"); + } else { + col.extra("DEFAULT CURRENT_TIMESTAMP"); + } + col + }) + .foreign_key( + ForeignKey::create() + .name("fk_library_jobs_library_id") + .from(LibraryJobs::Table, LibraryJobs::LibraryId) + .to(Libraries::Table, Libraries::Id) + .on_delete(ForeignKeyAction::Cascade) + .on_update(ForeignKeyAction::NoAction), + ) + .to_owned(), + ) + .await?; + + // Lookup by library (list jobs for a given library). + manager + .create_index( + Index::create() + .name("idx_library_jobs_library_id") + .table(LibraryJobs::Table) + .col(LibraryJobs::LibraryId) + .to_owned(), + ) + .await?; + + // Filter to only enabled jobs at scheduler boot. + manager + .create_index( + Index::create() + .name("idx_library_jobs_enabled") + .table(LibraryJobs::Table) + .col(LibraryJobs::Enabled) + .to_owned(), + ) + .await?; + + // Filter by type when listing (future-proofing for multi-type queries). + manager + .create_index( + Index::create() + .name("idx_library_jobs_type") + .table(LibraryJobs::Table) + .col(LibraryJobs::JobType) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(LibraryJobs::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum LibraryJobs { + Table, + Id, + LibraryId, + #[sea_orm(iden = "type")] + JobType, + Name, + Enabled, + CronSchedule, + Timezone, + Config, + LastRunAt, + LastRunStatus, + LastRunMessage, + CreatedAt, + UpdatedAt, +} + +#[derive(DeriveIden)] +enum Libraries { + Table, + Id, +} diff --git a/src/api/docs.rs b/src/api/docs.rs index f5c8fe48..1ca5d0ba 100644 --- a/src/api/docs.rs +++ b/src/api/docs.rs @@ -878,6 +878,8 @@ The following paths are exempt from rate limiting: v1::dto::PreviewSummary, v1::dto::MetadataApplyRequest, v1::dto::MetadataApplyResponse, + v1::dto::FieldChangeDto, + v1::dto::DryRunReportDto, v1::dto::SkippedField, v1::dto::MetadataAutoMatchRequest, v1::dto::MetadataAutoMatchResponse, @@ -887,6 +889,21 @@ The following paths are exempt from rate limiting: v1::dto::EnqueueBulkAutoMatchRequest, v1::dto::EnqueueLibraryAutoMatchRequest, + // Library Jobs DTOs (Phase 9) + v1::dto::LibraryJobDto, + v1::dto::LibraryJobConfigDto, + v1::dto::MetadataRefreshJobConfigDto, + v1::dto::CreateLibraryJobRequest, + v1::dto::PatchLibraryJobRequest, + v1::dto::ListLibraryJobsResponse, + v1::dto::RunNowResponse, + v1::dto::DryRunRequest, + v1::dto::DryRunResponse, + v1::dto::DryRunSeriesDelta, + v1::dto::DryRunSkippedFieldDto, + v1::dto::FieldGroupDto, + v1::dto::PluginCapabilitiesDto, + // Task Queue DTOs v1::handlers::task_queue::CreateTaskRequest, v1::handlers::task_queue::CreateTaskResponse, @@ -1008,6 +1025,7 @@ The following paths are exempt from rate limiting: (name = "Settings", description = "Runtime configuration settings (admin only)"), (name = "Plugins", description = "Admin-managed external plugin processes"), (name = "Plugin Actions", description = "Plugin action discovery and execution for metadata fetching"), + (name = "Library Jobs", description = "Per-library scheduled jobs (metadata refresh today; future: scan, cleanup). Supports CRUD, run-now, and dry-run preview."), (name = "User Plugins", description = "User-facing plugin management, OAuth, and configuration"), (name = "Recommendations", description = "Personalized recommendation endpoints"), (name = "Metrics", description = "Application metrics and statistics"), diff --git a/src/api/routes/v1/dto/library_jobs.rs b/src/api/routes/v1/dto/library_jobs.rs new file mode 100644 index 00000000..4ab74054 --- /dev/null +++ b/src/api/routes/v1/dto/library_jobs.rs @@ -0,0 +1,251 @@ +//! DTOs for `/api/v1/libraries/{id}/jobs` (Phase 9). + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use utoipa::ToSchema; +use uuid::Uuid; + +use crate::api::routes::v1::dto::patch::PatchValue; +use crate::services::library_jobs::{LibraryJobConfig, MetadataRefreshJobConfig, RefreshScope}; + +/// Type-discriminated job config exposed over the wire. +/// +/// Phase 9 only ships the `metadata_refresh` variant; future job types +/// extend the enum. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, PartialEq)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum LibraryJobConfigDto { + MetadataRefresh(MetadataRefreshJobConfigDto), +} + +/// Wire shape for the metadata-refresh job config. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct MetadataRefreshJobConfigDto { + /// Plugin reference, e.g. `"plugin:mangabaka"`. + pub provider: String, + /// Refresh scope. Phase 9 only honours `series_only` at runtime. + #[serde(default)] + pub scope: RefreshScope, + /// Series-side field groups (snake_case identifiers). + #[serde(default)] + pub field_groups: Vec, + /// Series-side individual field overrides (camelCase). + #[serde(default)] + pub extra_fields: Vec, + /// Reserved for the book-scope future work. + #[serde(default)] + pub book_field_groups: Vec, + /// Reserved for the book-scope future work. + #[serde(default)] + pub book_extra_fields: Vec, + /// When true, the planner skips series with no stored external ID. + #[serde(default = "default_existing_source_ids_only")] + pub existing_source_ids_only: bool, + /// Skip series whose `last_synced_at` is younger than this many seconds. + #[serde(default = "default_skip_recently_synced")] + pub skip_recently_synced_within_s: u32, + /// Per-task fan-out; clamped at run time. + #[serde(default = "default_max_concurrency")] + pub max_concurrency: u8, +} + +fn default_existing_source_ids_only() -> bool { + true +} +fn default_skip_recently_synced() -> u32 { + 3600 +} +fn default_max_concurrency() -> u8 { + 4 +} + +impl From for MetadataRefreshJobConfigDto { + fn from(c: MetadataRefreshJobConfig) -> Self { + Self { + provider: c.provider, + scope: c.scope, + field_groups: c.field_groups, + extra_fields: c.extra_fields, + book_field_groups: c.book_field_groups, + book_extra_fields: c.book_extra_fields, + existing_source_ids_only: c.existing_source_ids_only, + skip_recently_synced_within_s: c.skip_recently_synced_within_s, + max_concurrency: c.max_concurrency, + } + } +} + +impl From for MetadataRefreshJobConfig { + fn from(c: MetadataRefreshJobConfigDto) -> Self { + Self { + provider: c.provider, + scope: c.scope, + field_groups: c.field_groups, + extra_fields: c.extra_fields, + book_field_groups: c.book_field_groups, + book_extra_fields: c.book_extra_fields, + existing_source_ids_only: c.existing_source_ids_only, + skip_recently_synced_within_s: c.skip_recently_synced_within_s, + max_concurrency: c.max_concurrency, + } + } +} + +impl From for LibraryJobConfigDto { + fn from(c: LibraryJobConfig) -> Self { + match c { + LibraryJobConfig::MetadataRefresh(c) => LibraryJobConfigDto::MetadataRefresh(c.into()), + } + } +} + +impl From for LibraryJobConfig { + fn from(c: LibraryJobConfigDto) -> Self { + match c { + LibraryJobConfigDto::MetadataRefresh(c) => LibraryJobConfig::MetadataRefresh(c.into()), + } + } +} + +/// Library job row exposed via GET / list / response. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct LibraryJobDto { + pub id: Uuid, + pub library_id: Uuid, + pub name: String, + pub enabled: bool, + pub cron_schedule: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub timezone: Option, + pub config: LibraryJobConfigDto, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_run_at: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_run_status: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_run_message: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// Request body for `POST /api/v1/libraries/{id}/jobs`. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct CreateLibraryJobRequest { + /// Optional user-facing name. Auto-generated when missing or empty. + #[serde(default)] + pub name: Option, + #[serde(default)] + pub enabled: bool, + pub cron_schedule: String, + #[serde(default)] + pub timezone: Option, + pub config: LibraryJobConfigDto, +} + +/// Request body for `PATCH /api/v1/libraries/{id}/jobs/{job_id}`. +/// +/// All fields are optional. Top-level fields use [`PatchValue`] when their +/// underlying type is `Option<...>` so an explicit `null` clears the value +/// distinct from "not present". +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, Default)] +#[serde(rename_all = "camelCase")] +pub struct PatchLibraryJobRequest { + #[serde(default)] + pub name: Option, + #[serde(default)] + pub enabled: Option, + #[serde(default)] + pub cron_schedule: Option, + #[serde(default, skip_serializing_if = "PatchValue::is_absent")] + #[schema(value_type = Option, nullable = true)] + pub timezone: PatchValue, + /// Replaces the type-specific config wholesale; the type discriminator + /// must match the existing row's type. + #[serde(default)] + pub config: Option, +} + +/// Response for `GET /libraries/{id}/jobs`. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ListLibraryJobsResponse { + pub jobs: Vec, +} + +/// Response for `POST .../run-now`. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct RunNowResponse { + pub task_id: Uuid, +} + +/// Request body for `POST .../dry-run`. +#[derive(Debug, Clone, Default, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct DryRunRequest { + /// Override the saved config for this preview only. Must match the + /// row's `type`. + #[serde(default)] + pub config_override: Option, + /// Sample size, capped at 20 server-side. + #[serde(default)] + pub sample_size: Option, +} + +/// One series's preview of would-be field changes. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct DryRunSeriesDelta { + pub series_id: Uuid, + pub series_name: String, + /// Field name → `(before, after)` JSON values. + pub changes: HashMap, + /// Fields that would have been written but were skipped (locks, all-locked, etc.) + pub skipped: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct DryRunFieldChange { + pub before: serde_json::Value, + pub after: serde_json::Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct DryRunSkippedFieldDto { + pub field: String, + pub reason: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct DryRunResponse { + /// Total number of series eligible to be refreshed (all of them, not + /// just the sample). + pub total_eligible: u32, + /// Per-series deltas for the first N eligible series. + pub sample: Vec, + /// Estimated count of series that would be skipped because they have no + /// external ID for the chosen provider. + pub est_skipped_no_id: u32, + /// Estimated count of series that would be skipped because they were + /// recently synced. + pub est_skipped_recently_synced: u32, + /// Provider resolution failure reason, if any. + #[serde(skip_serializing_if = "Option::is_none")] + pub plan_failure: Option, +} + +/// Static field-group catalog row exposed for the editor UI. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct FieldGroupDto { + pub id: String, + pub label: String, + pub fields: Vec, +} diff --git a/src/api/routes/v1/dto/mod.rs b/src/api/routes/v1/dto/mod.rs index f65f1bed..43df7639 100644 --- a/src/api/routes/v1/dto/mod.rs +++ b/src/api/routes/v1/dto/mod.rs @@ -12,6 +12,7 @@ pub mod duplicates; pub mod filter; pub mod info; pub mod library; +pub mod library_jobs; pub mod metrics; pub mod oidc; pub mod page; @@ -43,6 +44,7 @@ pub use duplicates::*; pub use filter::*; pub use info::*; pub use library::*; +pub use library_jobs::*; pub use metrics::*; pub use oidc::*; pub use page::*; diff --git a/src/api/routes/v1/dto/plugins.rs b/src/api/routes/v1/dto/plugins.rs index 367b5f6c..80de669c 100644 --- a/src/api/routes/v1/dto/plugins.rs +++ b/src/api/routes/v1/dto/plugins.rs @@ -1018,6 +1018,13 @@ pub struct PluginActionDto { /// URI template for searching on the plugin's website (from manifest) #[serde(skip_serializing_if = "Option::is_none")] pub search_uri_template: Option, + + /// Capabilities the plugin advertises in its manifest. The library-jobs + /// editor uses this to decide which scope options are available for the + /// chosen provider. The `metadata_provider` array contains `"series"` + /// and/or `"book"` entries. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub capabilities: Option, } /// Response containing available plugin actions for a scope @@ -1290,6 +1297,39 @@ pub struct MetadataApplyRequest { /// Optional list of fields to apply (default: all applicable fields) #[serde(skip_serializing_if = "Option::is_none")] pub fields: Option>, + + /// When `true`, the call simulates the apply without writing to the + /// database. Returns the same `appliedFields`/`skippedFields` plus an + /// extra `dryRunReport` showing every would-be change. Default `false`. + #[serde(default, skip_serializing_if = "is_false")] + pub dry_run: bool, +} + +/// One would-be field change recorded during a dry-run apply. +/// +/// Mirrors `services::metadata::apply::FieldChange`, kept as a distinct DTO +/// to keep the wire-format frozen even if internal types evolve. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct FieldChangeDto { + pub field: String, + /// Current value, where cheaply available. `null` for fields backed by + /// joined tables (genres, tags, alternate titles, ratings, etc.). + #[serde(skip_serializing_if = "Option::is_none")] + pub before: Option, + pub after: serde_json::Value, +} + +/// Dry-run preview attached to [`MetadataApplyResponse`] when the request +/// set `dryRun = true`. Absent on real applies. +#[derive(Debug, Clone, Default, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct DryRunReportDto { + pub changes: Vec, +} + +fn is_false(b: &bool) -> bool { + !*b } /// Response after applying metadata @@ -1307,6 +1347,11 @@ pub struct MetadataApplyResponse { /// Message pub message: String, + + /// Populated only when the request set `dryRun = true`. Each entry is a + /// field that *would* have been written. + #[serde(skip_serializing_if = "Option::is_none")] + pub dry_run_report: Option, } /// A field that was skipped during apply diff --git a/src/api/routes/v1/handlers/library_jobs.rs b/src/api/routes/v1/handlers/library_jobs.rs new file mode 100644 index 00000000..cc8c56b6 --- /dev/null +++ b/src/api/routes/v1/handlers/library_jobs.rs @@ -0,0 +1,544 @@ +//! Library jobs CRUD + run-now + dry-run handlers (Phase 9). + +use axum::{ + Json, + extract::{Path, State}, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use std::sync::Arc; +use uuid::Uuid; + +use crate::api::{ + error::ApiError, + extractors::{AppState, AuthContext}, + permissions::Permission, +}; +use crate::db::entities::library_jobs; +use crate::db::repositories::{ + CreateLibraryJobParams, LibraryJobRepository, LibraryRepository, SeriesRepository, +}; +use crate::require_permission; +use crate::services::library_jobs::{ + LibraryJobConfig, MetadataRefreshJobConfig, parse_job_config, validation, +}; +use crate::services::metadata::{FieldGroup, RefreshPlanner, fields_for_group}; +use crate::tasks::types::TaskType; + +use super::super::dto::patch::PatchValue; +use super::super::dto::{ + CreateLibraryJobRequest, DryRunFieldChange, DryRunRequest, DryRunResponse, DryRunSeriesDelta, + FieldGroupDto, LibraryJobConfigDto, LibraryJobDto, ListLibraryJobsResponse, + PatchLibraryJobRequest, RunNowResponse, +}; + +// ============================================================================= +// Helpers +// ============================================================================= + +fn validation_to_api_error(e: validation::ValidationError) -> ApiError { + ApiError::BadRequest(e.to_string()) +} + +fn anyhow_to_api_error(e: anyhow::Error, ctx: &str) -> ApiError { + ApiError::Internal(format!("{ctx}: {e}")) +} + +async fn ensure_library_exists(state: &AppState, library_id: Uuid) -> Result<(), ApiError> { + LibraryRepository::get_by_id(&state.db, library_id) + .await + .map_err(|e| anyhow_to_api_error(e, "Failed to look up library"))? + .ok_or_else(|| ApiError::NotFound("Library not found".to_string()))?; + Ok(()) +} + +fn row_to_dto(row: library_jobs::Model) -> Result { + let cfg = parse_job_config(&row.r#type, &row.config) + .map_err(|e| anyhow_to_api_error(e, "Failed to decode job config"))?; + Ok(LibraryJobDto { + id: row.id, + library_id: row.library_id, + name: row.name, + enabled: row.enabled, + cron_schedule: row.cron_schedule, + timezone: row.timezone, + config: cfg.into(), + last_run_at: row.last_run_at, + last_run_status: row.last_run_status, + last_run_message: row.last_run_message, + created_at: row.created_at, + updated_at: row.updated_at, + }) +} + +/// Auto-suggest a name when the create body omits one. +fn auto_name(cfg: &MetadataRefreshJobConfig) -> String { + let provider = cfg + .provider + .strip_prefix("plugin:") + .unwrap_or(&cfg.provider); + let groups: Vec<&str> = cfg.field_groups.iter().map(String::as_str).collect(); + if groups.is_empty() { + provider.to_string() + } else { + format!("{provider} — {}", groups.join(", ")) + } +} + +async fn validate_and_normalise_create( + state: &AppState, + name: &str, + cron: &str, + tz: Option<&str>, + cfg: &LibraryJobConfigDto, +) -> Result<(String, String, Option), ApiError> { + let domain_cfg: LibraryJobConfig = cfg.clone().into(); + let validated = match &domain_cfg { + LibraryJobConfig::MetadataRefresh(c) => { + validation::validate_metadata_refresh_config(&state.db, name, cron, tz, c) + .await + .map_err(validation_to_api_error)? + } + }; + let normalised_name = name.to_string(); + Ok((normalised_name, validated.cron_schedule, validated.timezone)) +} + +// ============================================================================= +// CRUD +// ============================================================================= + +pub async fn list_jobs( + State(state): State>, + auth: AuthContext, + Path(library_id): Path, +) -> Result, ApiError> { + require_permission!(auth, Permission::LibrariesRead)?; + ensure_library_exists(&state, library_id).await?; + let rows = LibraryJobRepository::list_for_library(&state.db, library_id) + .await + .map_err(|e| anyhow_to_api_error(e, "Failed to list library jobs"))?; + let jobs = rows + .into_iter() + .map(row_to_dto) + .collect::, _>>()?; + Ok(Json(ListLibraryJobsResponse { jobs })) +} + +pub async fn get_job( + State(state): State>, + auth: AuthContext, + Path((library_id, job_id)): Path<(Uuid, Uuid)>, +) -> Result, ApiError> { + require_permission!(auth, Permission::LibrariesRead)?; + let row = LibraryJobRepository::get_by_id(&state.db, job_id) + .await + .map_err(|e| anyhow_to_api_error(e, "Failed to load job"))? + .ok_or_else(|| ApiError::NotFound("Job not found".to_string()))?; + if row.library_id != library_id { + return Err(ApiError::NotFound("Job not found".to_string())); + } + Ok(Json(row_to_dto(row)?)) +} + +pub async fn create_job( + State(state): State>, + auth: AuthContext, + Path(library_id): Path, + Json(body): Json, +) -> Result<(StatusCode, Json), ApiError> { + require_permission!(auth, Permission::LibrariesWrite)?; + ensure_library_exists(&state, library_id).await?; + + // Determine name (auto-generate when blank). + let name = match body.name.as_deref().map(str::trim) { + Some(s) if !s.is_empty() => s.to_string(), + _ => match &body.config { + LibraryJobConfigDto::MetadataRefresh(c) => { + let mr: MetadataRefreshJobConfig = c.clone().into(); + auto_name(&mr) + } + }, + }; + + let (name, cron, tz) = validate_and_normalise_create( + &state, + &name, + &body.cron_schedule, + body.timezone.as_deref(), + &body.config, + ) + .await?; + + // Determine the type discriminator from the config variant. + let domain_cfg: LibraryJobConfig = body.config.into(); + let job_type = domain_cfg.job_type().as_str().to_string(); + let config_json = serde_json::to_string(&domain_cfg) + .map_err(|e| anyhow_to_api_error(e.into(), "Failed to serialize config"))?; + + let row = LibraryJobRepository::create( + &state.db, + CreateLibraryJobParams { + library_id, + job_type, + name, + enabled: body.enabled, + cron_schedule: cron, + timezone: tz, + config: config_json, + }, + ) + .await + .map_err(|e| anyhow_to_api_error(e, "Failed to create job"))?; + + if let Some(scheduler) = state.scheduler.as_ref() { + let mut s = scheduler.lock().await; + if let Err(e) = s.reload_schedules().await { + tracing::warn!("Scheduler reload after job create failed: {e:#}"); + } + } + + Ok((StatusCode::CREATED, Json(row_to_dto(row)?))) +} + +pub async fn patch_job( + State(state): State>, + auth: AuthContext, + Path((library_id, job_id)): Path<(Uuid, Uuid)>, + Json(patch): Json, +) -> Result, ApiError> { + require_permission!(auth, Permission::LibrariesWrite)?; + let mut row = LibraryJobRepository::get_by_id(&state.db, job_id) + .await + .map_err(|e| anyhow_to_api_error(e, "Failed to load job"))? + .ok_or_else(|| ApiError::NotFound("Job not found".to_string()))?; + if row.library_id != library_id { + return Err(ApiError::NotFound("Job not found".to_string())); + } + + if let Some(n) = patch.name.as_ref() { + if n.trim().is_empty() { + return Err(ApiError::BadRequest("Name cannot be empty".to_string())); + } + row.name = n.clone(); + } + if let Some(e) = patch.enabled { + row.enabled = e; + } + if let Some(cron) = patch.cron_schedule.as_ref() { + row.cron_schedule = cron.clone(); + } + match patch.timezone { + PatchValue::Absent => {} + PatchValue::Null => row.timezone = None, + PatchValue::Value(tz) => row.timezone = Some(tz), + } + if let Some(new_cfg_dto) = patch.config { + let new_cfg: LibraryJobConfig = new_cfg_dto.into(); + if new_cfg.job_type().as_str() != row.r#type { + return Err(ApiError::BadRequest( + "Config 'type' must match the existing job's type".to_string(), + )); + } + row.config = serde_json::to_string(&new_cfg) + .map_err(|e| anyhow_to_api_error(e.into(), "Failed to serialize config"))?; + } + + // Re-validate the whole row's worth of typed fields. + let cfg = parse_job_config(&row.r#type, &row.config) + .map_err(|e| anyhow_to_api_error(e, "Failed to decode job config"))?; + match &cfg { + LibraryJobConfig::MetadataRefresh(c) => { + validation::validate_metadata_refresh_config( + &state.db, + &row.name, + &row.cron_schedule, + row.timezone.as_deref(), + c, + ) + .await + .map_err(validation_to_api_error)?; + } + } + + LibraryJobRepository::update(&state.db, &row) + .await + .map_err(|e| anyhow_to_api_error(e, "Failed to update job"))?; + + if let Some(scheduler) = state.scheduler.as_ref() { + let mut s = scheduler.lock().await; + if let Err(e) = s.reload_schedules().await { + tracing::warn!("Scheduler reload after job patch failed: {e:#}"); + } + } + + let updated = LibraryJobRepository::get_by_id(&state.db, job_id) + .await + .map_err(|e| anyhow_to_api_error(e, "Failed to reload job"))? + .ok_or_else(|| ApiError::NotFound("Job vanished after update".to_string()))?; + Ok(Json(row_to_dto(updated)?)) +} + +pub async fn delete_job( + State(state): State>, + auth: AuthContext, + Path((library_id, job_id)): Path<(Uuid, Uuid)>, +) -> Result { + require_permission!(auth, Permission::LibrariesWrite)?; + let row = LibraryJobRepository::get_by_id(&state.db, job_id) + .await + .map_err(|e| anyhow_to_api_error(e, "Failed to load job"))? + .ok_or_else(|| ApiError::NotFound("Job not found".to_string()))?; + if row.library_id != library_id { + return Err(ApiError::NotFound("Job not found".to_string())); + } + LibraryJobRepository::delete(&state.db, job_id) + .await + .map_err(|e| anyhow_to_api_error(e, "Failed to delete job"))?; + + if let Some(scheduler) = state.scheduler.as_ref() { + let mut s = scheduler.lock().await; + if let Err(e) = s.reload_schedules().await { + tracing::warn!("Scheduler reload after job delete failed: {e:#}"); + } + } + Ok(StatusCode::NO_CONTENT.into_response()) +} + +// ============================================================================= +// Run-now +// ============================================================================= + +pub async fn run_job_now( + State(state): State>, + auth: AuthContext, + Path((library_id, job_id)): Path<(Uuid, Uuid)>, +) -> Result, ApiError> { + require_permission!(auth, Permission::LibrariesWrite)?; + let row = LibraryJobRepository::get_by_id(&state.db, job_id) + .await + .map_err(|e| anyhow_to_api_error(e, "Failed to load job"))? + .ok_or_else(|| ApiError::NotFound("Job not found".to_string()))?; + if row.library_id != library_id { + return Err(ApiError::NotFound("Job not found".to_string())); + } + + if crate::scheduler::has_active_refresh_for_job(&state.db, job_id) + .await + .map_err(|e| anyhow_to_api_error(e, "Failed to check in-flight task"))? + { + return Err(ApiError::Conflict( + "A refresh task for this job is already running".to_string(), + )); + } + + let task_id = crate::db::repositories::TaskRepository::enqueue( + &state.db, + TaskType::RefreshLibraryMetadata { job_id }, + None, + ) + .await + .map_err(|e| anyhow_to_api_error(e, "Failed to enqueue task"))?; + + Ok(Json(RunNowResponse { task_id })) +} + +// ============================================================================= +// Dry-run +// ============================================================================= + +const DRY_RUN_DEFAULT_SAMPLE: u32 = 5; +const DRY_RUN_MAX_SAMPLE: u32 = 20; + +pub async fn dry_run_job( + State(state): State>, + auth: AuthContext, + Path((library_id, job_id)): Path<(Uuid, Uuid)>, + Json(body): Json, +) -> Result, ApiError> { + require_permission!(auth, Permission::LibrariesWrite)?; + let row = LibraryJobRepository::get_by_id(&state.db, job_id) + .await + .map_err(|e| anyhow_to_api_error(e, "Failed to load job"))? + .ok_or_else(|| ApiError::NotFound("Job not found".to_string()))?; + if row.library_id != library_id { + return Err(ApiError::NotFound("Job not found".to_string())); + } + + // Resolve the config to plan against. + let cfg = if let Some(override_dto) = body.config_override { + let domain: LibraryJobConfig = override_dto.into(); + if domain.job_type().as_str() != row.r#type { + return Err(ApiError::BadRequest( + "Override config 'type' must match the job's type".to_string(), + )); + } + match domain { + LibraryJobConfig::MetadataRefresh(c) => { + validation::validate_metadata_refresh_config( + &state.db, + &row.name, + &row.cron_schedule, + row.timezone.as_deref(), + &c, + ) + .await + .map_err(validation_to_api_error)?; + c + } + } + } else { + let parsed = parse_job_config(&row.r#type, &row.config) + .map_err(|e| anyhow_to_api_error(e, "Failed to decode job config"))?; + let LibraryJobConfig::MetadataRefresh(c) = parsed; + c + }; + + let plan = RefreshPlanner::plan(&state.db, library_id, &cfg) + .await + .map_err(|e| anyhow_to_api_error(e, "Failed to build refresh plan"))?; + + // Surface plan-level failures cleanly. + if let Some(failure) = plan.failure.as_ref() { + return Ok(Json(DryRunResponse { + total_eligible: 0, + sample: vec![], + est_skipped_no_id: 0, + est_skipped_recently_synced: 0, + plan_failure: Some(failure.as_str().to_string()), + })); + } + + let total_eligible = plan.planned.len() as u32; + let mut est_skipped_no_id = 0u32; + let mut est_skipped_recently = 0u32; + for s in &plan.skipped { + match s.reason { + crate::services::metadata::SkipReason::NoExternalId => est_skipped_no_id += 1, + crate::services::metadata::SkipReason::RecentlySynced { .. } => { + est_skipped_recently += 1 + } + } + } + + let sample_size = body + .sample_size + .unwrap_or(DRY_RUN_DEFAULT_SAMPLE) + .min(DRY_RUN_MAX_SAMPLE) as usize; + + // For Phase 9 we return a planner-only sample (no plugin call). Phase 6 + // executed plugin calls per pair; the downsides (slow, brittle) outweigh + // the marginal benefit when the user is previewing a single provider. + let mut sample = Vec::new(); + for planned in plan.planned.iter().take(sample_size) { + let series = SeriesRepository::get_by_id(&state.db, planned.series_id) + .await + .map_err(|e| anyhow_to_api_error(e, "Failed to load series"))?; + let series_name = series + .map(|s| s.name) + .unwrap_or_else(|| "(unknown)".to_string()); + sample.push(DryRunSeriesDelta { + series_id: planned.series_id, + series_name, + // Actual `before/after` requires a plugin call; we surface a + // single placeholder row so the UI can render "this series is + // a candidate" without lying about specifics. + changes: std::collections::HashMap::from([( + "_preview".to_string(), + DryRunFieldChange { + before: serde_json::Value::Null, + after: serde_json::Value::String(format!( + "would refresh via {}", + planned.plugin.name + )), + }, + )]), + skipped: vec![], + }); + } + + Ok(Json(DryRunResponse { + total_eligible, + sample, + est_skipped_no_id, + est_skipped_recently_synced: est_skipped_recently, + plan_failure: None, + })) +} + +// ============================================================================= +// Field-groups catalog +// ============================================================================= + +pub async fn list_field_groups(auth: AuthContext) -> Result>, ApiError> { + require_permission!(auth, Permission::LibrariesRead)?; + let mut out = Vec::with_capacity(FieldGroup::all().len()); + for g in FieldGroup::all() { + out.push(FieldGroupDto { + id: g.as_str().to_string(), + label: human_label(*g).to_string(), + fields: fields_for_group(*g) + .iter() + .map(|s| (*s).to_string()) + .collect(), + }); + } + Ok(Json(out)) +} + +fn human_label(g: FieldGroup) -> &'static str { + match g { + FieldGroup::Identifiers => "Identifiers", + FieldGroup::Descriptive => "Descriptive", + FieldGroup::Status => "Status", + FieldGroup::Counts => "Counts", + FieldGroup::Ratings => "Ratings", + FieldGroup::Cover => "Cover", + FieldGroup::Tags => "Tags", + FieldGroup::Genres => "Genres", + FieldGroup::AgeRating => "Age rating", + FieldGroup::Classification => "Classification", + FieldGroup::Publisher => "Publisher", + FieldGroup::ExternalRefs => "External refs", + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::services::library_jobs::RefreshScope; + + #[test] + fn auto_name_uses_provider_and_groups() { + let cfg = MetadataRefreshJobConfig { + provider: "plugin:mb".to_string(), + scope: RefreshScope::SeriesOnly, + field_groups: vec!["ratings".to_string(), "status".to_string()], + extra_fields: vec![], + book_field_groups: vec![], + book_extra_fields: vec![], + existing_source_ids_only: true, + skip_recently_synced_within_s: 0, + max_concurrency: 4, + }; + assert_eq!(auto_name(&cfg), "mb — ratings, status"); + } + + #[test] + fn auto_name_handles_no_groups() { + let cfg = MetadataRefreshJobConfig { + provider: "plugin:foo".to_string(), + field_groups: vec![], + ..MetadataRefreshJobConfig::default() + }; + assert_eq!(auto_name(&cfg), "foo"); + } + + #[test] + fn human_labels_cover_every_group() { + for g in FieldGroup::all() { + let lbl = human_label(*g); + assert!(!lbl.is_empty()); + } + } +} diff --git a/src/api/routes/v1/handlers/mod.rs b/src/api/routes/v1/handlers/mod.rs index ff556d70..76b77dc1 100644 --- a/src/api/routes/v1/handlers/mod.rs +++ b/src/api/routes/v1/handlers/mod.rs @@ -51,6 +51,7 @@ pub mod filesystem; pub mod health; pub mod info; pub mod libraries; +pub mod library_jobs; pub mod metrics; pub mod oidc; pub mod pages; diff --git a/src/api/routes/v1/handlers/plugin_actions.rs b/src/api/routes/v1/handlers/plugin_actions.rs index ffb7f324..1f85cbef 100644 --- a/src/api/routes/v1/handlers/plugin_actions.rs +++ b/src/api/routes/v1/handlers/plugin_actions.rs @@ -11,13 +11,13 @@ //! - POST /api/v1/books/{id}/metadata/apply - Apply metadata for a book use super::super::dto::{ - EnqueueAutoMatchRequest, EnqueueAutoMatchResponse, EnqueueBulkAutoMatchRequest, - EnqueueLibraryAutoMatchRequest, ExecutePluginRequest, ExecutePluginResponse, FieldApplyStatus, - MetadataAction, MetadataApplyRequest, MetadataApplyResponse, MetadataAutoMatchRequest, - MetadataAutoMatchResponse, MetadataFieldPreview, MetadataPreviewRequest, - MetadataPreviewResponse, PluginActionDto, PluginActionRequest, PluginActionsResponse, - PluginSearchResponse, PluginSearchResultDto, PreviewSummary, SearchTitleResponse, SkippedField, - parse_scope, + DryRunReportDto, EnqueueAutoMatchRequest, EnqueueAutoMatchResponse, + EnqueueBulkAutoMatchRequest, EnqueueLibraryAutoMatchRequest, ExecutePluginRequest, + ExecutePluginResponse, FieldApplyStatus, FieldChangeDto, MetadataAction, MetadataApplyRequest, + MetadataApplyResponse, MetadataAutoMatchRequest, MetadataAutoMatchResponse, + MetadataFieldPreview, MetadataPreviewRequest, MetadataPreviewResponse, PluginActionDto, + PluginActionRequest, PluginActionsResponse, PluginSearchResponse, PluginSearchResultDto, + PreviewSummary, SearchTitleResponse, SkippedField, parse_scope, }; use crate::api::{AppState, error::ApiError, extractors::AuthContext, permissions::Permission}; use crate::db::entities::plugins::PluginPermission; @@ -224,6 +224,9 @@ pub async fn get_plugin_actions( icon: Some("search".to_string()), library_ids: plugin.library_ids_vec(), search_uri_template: manifest.search_uri_template.clone(), + capabilities: Some(super::super::dto::PluginCapabilitiesDto::from( + manifest.capabilities.clone(), + )), }); } } @@ -1431,11 +1434,12 @@ pub async fn apply_series_metadata( .await .map_err(|e| ApiError::Internal(format!("Failed to get current metadata: {}", e)))?; - // Build apply options + let dry_run = request.dry_run; let options = ApplyOptions { fields_filter: request.fields.map(|f| f.into_iter().collect()), thumbnail_service: Some(state.thumbnail_service.clone()), event_broadcaster: Some(state.event_broadcaster.clone()), + dry_run, }; // Apply metadata using the shared service @@ -1451,16 +1455,18 @@ pub async fn apply_series_metadata( .await .map_err(|e| ApiError::Internal(format!("Failed to apply metadata: {}", e)))?; - // Store/update external ID for future lookups (matches auto-match behavior) - if let Err(e) = SeriesExternalIdRepository::upsert_for_plugin( - &state.db, - series_id, - &plugin.name, - &plugin_metadata.external_id, - Some(&plugin_metadata.external_url), - None, // metadata_hash - could be computed if needed - ) - .await + // Store/update external ID for future lookups (matches auto-match + // behavior). Skipped on dry-run so a preview never mutates DB state. + if !dry_run + && let Err(e) = SeriesExternalIdRepository::upsert_for_plugin( + &state.db, + series_id, + &plugin.name, + &plugin_metadata.external_id, + Some(&plugin_metadata.external_url), + None, // metadata_hash - could be computed if needed + ) + .await { tracing::warn!( "Failed to store external ID for series {}: {}", @@ -1480,8 +1486,25 @@ pub async fn apply_series_metadata( }) .collect(); + let dry_run_report = result.dry_run_report.map(|r| DryRunReportDto { + changes: r + .changes + .into_iter() + .map(|c| FieldChangeDto { + field: c.field, + before: c.before, + after: c.after, + }) + .collect(), + }); + let message = if result.applied_fields.is_empty() { "No fields were applied".to_string() + } else if dry_run { + format!( + "Dry run: {} field(s) would be applied", + result.applied_fields.len() + ) } else { format!("Applied {} field(s)", result.applied_fields.len()) }; @@ -1491,6 +1514,7 @@ pub async fn apply_series_metadata( applied_fields: result.applied_fields, skipped_fields, message, + dry_run_report, })) } @@ -1632,11 +1656,11 @@ pub async fn auto_match_series_metadata( .await .map_err(|e| ApiError::Internal(format!("Failed to get current metadata: {}", e)))?; - // Build apply options (no field filtering for auto-match) let options = ApplyOptions { fields_filter: None, // Apply all fields thumbnail_service: Some(state.thumbnail_service.clone()), event_broadcaster: Some(state.event_broadcaster.clone()), + dry_run: false, }; // Apply metadata using the shared service @@ -2279,6 +2303,14 @@ pub async fn apply_book_metadata( // Check permission to edit book metadata auth.require_permission(&Permission::BooksWrite)?; + // Dry-run is currently only supported on the series apply path. Reject + // it explicitly here so the user doesn't get a silent real-apply. + if request.dry_run { + return Err(ApiError::BadRequest( + "dryRun is not supported on book metadata apply".to_string(), + )); + } + // Get the book (verify it exists) let book = BookRepository::get_by_id(&state.db, book_id) .await @@ -2374,6 +2406,8 @@ pub async fn apply_book_metadata( applied_fields: result.applied_fields, skipped_fields, message, + // Book apply doesn't support dry-run yet; series-only for now. + dry_run_report: None, })) } diff --git a/src/api/routes/v1/routes/libraries.rs b/src/api/routes/v1/routes/libraries.rs index 7e8157fa..c20c93d6 100644 --- a/src/api/routes/v1/routes/libraries.rs +++ b/src/api/routes/v1/routes/libraries.rs @@ -101,4 +101,23 @@ pub fn routes(_state: Arc) -> Router> { "/libraries/{library_id}/series/titles/reprocess", post(handlers::task_queue::reprocess_library_series_titles), ) + // Library jobs (Phase 9): generic CRUD for per-library scheduled work. + .route( + "/libraries/{library_id}/jobs", + get(handlers::library_jobs::list_jobs).post(handlers::library_jobs::create_job), + ) + .route( + "/libraries/{library_id}/jobs/{job_id}", + get(handlers::library_jobs::get_job) + .patch(handlers::library_jobs::patch_job) + .delete(handlers::library_jobs::delete_job), + ) + .route( + "/libraries/{library_id}/jobs/{job_id}/run-now", + post(handlers::library_jobs::run_job_now), + ) + .route( + "/libraries/{library_id}/jobs/{job_id}/dry-run", + post(handlers::library_jobs::dry_run_job), + ) } diff --git a/src/api/routes/v1/routes/misc.rs b/src/api/routes/v1/routes/misc.rs index db6ce6b6..a8a87787 100644 --- a/src/api/routes/v1/routes/misc.rs +++ b/src/api/routes/v1/routes/misc.rs @@ -80,4 +80,9 @@ pub fn routes(_state: Arc) -> Router> { "/settings/public", get(handlers::settings::get_public_settings), ) + // Static field-group catalog for the metadata-refresh job editor. + .route( + "/library-jobs/metadata-refresh/field-groups", + get(handlers::library_jobs::list_field_groups), + ) } diff --git a/src/db/entities/library_jobs.rs b/src/db/entities/library_jobs.rs new file mode 100644 index 00000000..63cd4424 --- /dev/null +++ b/src/db/entities/library_jobs.rs @@ -0,0 +1,54 @@ +//! `library_jobs` table: per-library scheduled jobs. +//! +//! Generic across job types via the `r#type` discriminator. The `config` +//! column carries a JSON payload whose shape depends on `r#type`. +//! Phase 9 introduces the `metadata_refresh` type; future work can add +//! `scan`, `cleanup`, etc. without schema changes. + +use chrono::{DateTime, Utc}; +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] +#[sea_orm(table_name = "library_jobs")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub library_id: Uuid, + /// Discriminator for `config`. e.g. `"metadata_refresh"`. + #[sea_orm(column_name = "type")] + pub r#type: String, + pub name: String, + pub enabled: bool, + pub cron_schedule: String, + pub timezone: Option, + /// Type-specific payload as JSON-encoded text. + #[sea_orm(column_type = "Text")] + pub config: String, + pub last_run_at: Option>, + pub last_run_status: Option, + pub last_run_message: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::libraries::Entity", + from = "Column::LibraryId", + to = "super::libraries::Column::Id", + on_update = "NoAction", + on_delete = "Cascade" + )] + Library, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Library.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src/db/entities/mod.rs b/src/db/entities/mod.rs index 24563a34..62265e51 100644 --- a/src/db/entities/mod.rs +++ b/src/db/entities/mod.rs @@ -20,6 +20,7 @@ pub mod book_tags; pub mod books; pub mod email_verification_tokens; pub mod libraries; +pub mod library_jobs; pub mod metadata_sources; pub mod pages; pub mod plugin_failures; diff --git a/src/db/entities/prelude.rs b/src/db/entities/prelude.rs index 4f530f63..9984e3c3 100644 --- a/src/db/entities/prelude.rs +++ b/src/db/entities/prelude.rs @@ -14,6 +14,7 @@ pub use super::book_metadata::Entity as BookMetadata; pub use super::book_tags::Entity as BookTags; pub use super::books::Entity as Books; pub use super::libraries::Entity as Libraries; +pub use super::library_jobs::Entity as LibraryJobs; pub use super::pages::Entity as Pages; pub use super::series::Entity as Series; pub use super::task_metrics::Entity as TaskMetrics; diff --git a/src/db/repositories/library_jobs.rs b/src/db/repositories/library_jobs.rs new file mode 100644 index 00000000..ad633f7a --- /dev/null +++ b/src/db/repositories/library_jobs.rs @@ -0,0 +1,393 @@ +//! Repository for `library_jobs` rows. +//! +//! Generic CRUD across job types. The `r#type` discriminator + `config` JSON +//! shape are validated at the service layer (`services::library_jobs`), not +//! here — this module only persists strings. + +use anyhow::{Context, Result}; +use chrono::Utc; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder, Set, +}; +use uuid::Uuid; + +use crate::db::entities::{library_jobs, prelude::*}; + +/// Parameters for creating a new library job row. +#[derive(Debug, Clone)] +pub struct CreateLibraryJobParams { + pub library_id: Uuid, + /// Discriminator (e.g. `"metadata_refresh"`). + pub job_type: String, + pub name: String, + pub enabled: bool, + pub cron_schedule: String, + pub timezone: Option, + /// Type-specific JSON config (already validated + serialized). + pub config: String, +} + +/// Outcome of a job run, used by [`LibraryJobRepository::record_run`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RecordRunStatus { + Success, + Failure, +} + +impl RecordRunStatus { + fn as_str(self) -> &'static str { + match self { + RecordRunStatus::Success => "success", + RecordRunStatus::Failure => "failure", + } + } +} + +/// Repository for [`library_jobs::Model`]. +pub struct LibraryJobRepository; + +impl LibraryJobRepository { + /// Insert a new job row. + pub async fn create( + db: &DatabaseConnection, + params: CreateLibraryJobParams, + ) -> Result { + let now = Utc::now(); + let row = library_jobs::ActiveModel { + id: Set(Uuid::new_v4()), + library_id: Set(params.library_id), + r#type: Set(params.job_type), + name: Set(params.name), + enabled: Set(params.enabled), + cron_schedule: Set(params.cron_schedule), + timezone: Set(params.timezone), + config: Set(params.config), + last_run_at: Set(None), + last_run_status: Set(None), + last_run_message: Set(None), + created_at: Set(now), + updated_at: Set(now), + }; + + row.insert(db).await.context("Failed to create library job") + } + + /// Look up a single job by primary key. + pub async fn get_by_id( + db: &DatabaseConnection, + id: Uuid, + ) -> Result> { + LibraryJobs::find_by_id(id) + .one(db) + .await + .context("Failed to load library job by id") + } + + /// List all jobs for a library, ordered by `created_at` ascending so the + /// UI shows them in insertion order. + pub async fn list_for_library( + db: &DatabaseConnection, + library_id: Uuid, + ) -> Result> { + LibraryJobs::find() + .filter(library_jobs::Column::LibraryId.eq(library_id)) + .order_by_asc(library_jobs::Column::CreatedAt) + .all(db) + .await + .context("Failed to list library jobs") + } + + /// List all enabled jobs across every library, optionally filtered by type. + /// Used by the scheduler at boot to register cron entries. + pub async fn list_enabled( + db: &DatabaseConnection, + type_filter: Option<&str>, + ) -> Result> { + let mut query = LibraryJobs::find().filter(library_jobs::Column::Enabled.eq(true)); + if let Some(t) = type_filter { + query = query.filter(library_jobs::Column::Type.eq(t)); + } + query + .order_by_asc(library_jobs::Column::CreatedAt) + .all(db) + .await + .context("Failed to list enabled library jobs") + } + + /// Update mutable fields on a job. The caller mutates the model first and + /// then passes it back; we set `updated_at` here. + pub async fn update(db: &DatabaseConnection, model: &library_jobs::Model) -> Result<()> { + let active = library_jobs::ActiveModel { + id: Set(model.id), + library_id: Set(model.library_id), + r#type: Set(model.r#type.clone()), + name: Set(model.name.clone()), + enabled: Set(model.enabled), + cron_schedule: Set(model.cron_schedule.clone()), + timezone: Set(model.timezone.clone()), + config: Set(model.config.clone()), + last_run_at: Set(model.last_run_at), + last_run_status: Set(model.last_run_status.clone()), + last_run_message: Set(model.last_run_message.clone()), + created_at: Set(model.created_at), + updated_at: Set(Utc::now()), + }; + active + .update(db) + .await + .context("Failed to update library job")?; + Ok(()) + } + + /// Delete a job by id. No-op if the row doesn't exist. + pub async fn delete(db: &DatabaseConnection, id: Uuid) -> Result { + let res = LibraryJobs::delete_by_id(id) + .exec(db) + .await + .context("Failed to delete library job")?; + Ok(res.rows_affected) + } + + /// Record the outcome of a run. + pub async fn record_run( + db: &DatabaseConnection, + id: Uuid, + status: RecordRunStatus, + message: Option, + ) -> Result<()> { + let model = Self::get_by_id(db, id) + .await? + .ok_or_else(|| anyhow::anyhow!("Library job not found: {}", id))?; + let mut active: library_jobs::ActiveModel = model.into(); + active.last_run_at = Set(Some(Utc::now())); + active.last_run_status = Set(Some(status.as_str().to_string())); + active.last_run_message = Set(message); + active.updated_at = Set(Utc::now()); + active + .update(db) + .await + .context("Failed to record library job run")?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::ScanningStrategy; + use crate::db::repositories::LibraryRepository; + use crate::db::test_helpers::create_test_db; + + async fn seed_library(db: &DatabaseConnection, name: &str, path: &str) -> Uuid { + LibraryRepository::create(db, name, path, ScanningStrategy::Default) + .await + .unwrap() + .id + } + + fn sample_params(library_id: Uuid, name: &str) -> CreateLibraryJobParams { + CreateLibraryJobParams { + library_id, + job_type: "metadata_refresh".to_string(), + name: name.to_string(), + enabled: false, + cron_schedule: "0 0 4 * * *".to_string(), + timezone: None, + config: r#"{"provider":"plugin:mangabaka"}"#.to_string(), + } + } + + #[tokio::test] + async fn create_round_trips() { + let (db, _tmp) = create_test_db().await; + let lib = seed_library(db.sea_orm_connection(), "L", "/p").await; + + let row = LibraryJobRepository::create(db.sea_orm_connection(), sample_params(lib, "Test")) + .await + .unwrap(); + + assert_eq!(row.library_id, lib); + assert_eq!(row.r#type, "metadata_refresh"); + assert_eq!(row.name, "Test"); + assert!(!row.enabled); + assert!(row.last_run_at.is_none()); + + let loaded = LibraryJobRepository::get_by_id(db.sea_orm_connection(), row.id) + .await + .unwrap() + .unwrap(); + assert_eq!(loaded.id, row.id); + assert_eq!(loaded.config, row.config); + } + + #[tokio::test] + async fn list_for_library_returns_only_that_library_in_order() { + let (db, _tmp) = create_test_db().await; + let lib_a = seed_library(db.sea_orm_connection(), "A", "/a").await; + let lib_b = seed_library(db.sea_orm_connection(), "B", "/b").await; + + let _ja1 = + LibraryJobRepository::create(db.sea_orm_connection(), sample_params(lib_a, "A1")) + .await + .unwrap(); + // Sleep micro-tick to keep created_at ordering deterministic on fast clocks. + tokio::time::sleep(std::time::Duration::from_millis(2)).await; + let _ja2 = + LibraryJobRepository::create(db.sea_orm_connection(), sample_params(lib_a, "A2")) + .await + .unwrap(); + let _jb = LibraryJobRepository::create(db.sea_orm_connection(), sample_params(lib_b, "B")) + .await + .unwrap(); + + let rows = LibraryJobRepository::list_for_library(db.sea_orm_connection(), lib_a) + .await + .unwrap(); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0].name, "A1"); + assert_eq!(rows[1].name, "A2"); + } + + #[tokio::test] + async fn list_enabled_filters_by_enabled_and_type() { + let (db, _tmp) = create_test_db().await; + let lib = seed_library(db.sea_orm_connection(), "L", "/p").await; + + let mut p = sample_params(lib, "Disabled"); + p.enabled = false; + let _ = LibraryJobRepository::create(db.sea_orm_connection(), p) + .await + .unwrap(); + + let mut p2 = sample_params(lib, "Enabled"); + p2.enabled = true; + let enabled = LibraryJobRepository::create(db.sea_orm_connection(), p2) + .await + .unwrap(); + + let mut p3 = sample_params(lib, "OtherType"); + p3.enabled = true; + p3.job_type = "scan".to_string(); + let _ = LibraryJobRepository::create(db.sea_orm_connection(), p3) + .await + .unwrap(); + + let all = LibraryJobRepository::list_enabled(db.sea_orm_connection(), None) + .await + .unwrap(); + assert_eq!(all.len(), 2); + + let only_refresh = + LibraryJobRepository::list_enabled(db.sea_orm_connection(), Some("metadata_refresh")) + .await + .unwrap(); + assert_eq!(only_refresh.len(), 1); + assert_eq!(only_refresh[0].id, enabled.id); + } + + #[tokio::test] + async fn update_persists_changes() { + let (db, _tmp) = create_test_db().await; + let lib = seed_library(db.sea_orm_connection(), "L", "/p").await; + + let mut row = + LibraryJobRepository::create(db.sea_orm_connection(), sample_params(lib, "Original")) + .await + .unwrap(); + row.name = "Updated".to_string(); + row.enabled = true; + row.cron_schedule = "0 0 6 * * *".to_string(); + LibraryJobRepository::update(db.sea_orm_connection(), &row) + .await + .unwrap(); + + let loaded = LibraryJobRepository::get_by_id(db.sea_orm_connection(), row.id) + .await + .unwrap() + .unwrap(); + assert_eq!(loaded.name, "Updated"); + assert!(loaded.enabled); + assert_eq!(loaded.cron_schedule, "0 0 6 * * *"); + } + + #[tokio::test] + async fn delete_removes_row() { + let (db, _tmp) = create_test_db().await; + let lib = seed_library(db.sea_orm_connection(), "L", "/p").await; + let row = LibraryJobRepository::create(db.sea_orm_connection(), sample_params(lib, "X")) + .await + .unwrap(); + let n = LibraryJobRepository::delete(db.sea_orm_connection(), row.id) + .await + .unwrap(); + assert_eq!(n, 1); + assert!( + LibraryJobRepository::get_by_id(db.sea_orm_connection(), row.id) + .await + .unwrap() + .is_none() + ); + } + + #[tokio::test] + async fn record_run_updates_last_run_fields() { + let (db, _tmp) = create_test_db().await; + let lib = seed_library(db.sea_orm_connection(), "L", "/p").await; + let row = LibraryJobRepository::create(db.sea_orm_connection(), sample_params(lib, "X")) + .await + .unwrap(); + + LibraryJobRepository::record_run( + db.sea_orm_connection(), + row.id, + RecordRunStatus::Success, + Some("done".to_string()), + ) + .await + .unwrap(); + let loaded = LibraryJobRepository::get_by_id(db.sea_orm_connection(), row.id) + .await + .unwrap() + .unwrap(); + assert!(loaded.last_run_at.is_some()); + assert_eq!(loaded.last_run_status.as_deref(), Some("success")); + assert_eq!(loaded.last_run_message.as_deref(), Some("done")); + + LibraryJobRepository::record_run( + db.sea_orm_connection(), + row.id, + RecordRunStatus::Failure, + None, + ) + .await + .unwrap(); + let loaded = LibraryJobRepository::get_by_id(db.sea_orm_connection(), row.id) + .await + .unwrap() + .unwrap(); + assert_eq!(loaded.last_run_status.as_deref(), Some("failure")); + assert!(loaded.last_run_message.is_none()); + } + + #[tokio::test] + async fn cascade_delete_removes_jobs_when_library_deleted() { + use crate::db::entities::libraries::Entity as Libs; + + let (db, _tmp) = create_test_db().await; + let lib = seed_library(db.sea_orm_connection(), "L", "/p").await; + let row = LibraryJobRepository::create(db.sea_orm_connection(), sample_params(lib, "X")) + .await + .unwrap(); + + // Drop the library row directly to exercise the FK. + Libs::delete_by_id(lib) + .exec(db.sea_orm_connection()) + .await + .unwrap(); + + let loaded = LibraryJobRepository::get_by_id(db.sea_orm_connection(), row.id) + .await + .unwrap(); + assert!(loaded.is_none(), "cascade should have removed the job"); + } +} diff --git a/src/db/repositories/mod.rs b/src/db/repositories/mod.rs index e5be8045..81aaa89a 100644 --- a/src/db/repositories/mod.rs +++ b/src/db/repositories/mod.rs @@ -10,6 +10,7 @@ pub mod external_link; pub mod external_rating; pub mod genre; pub mod library; +pub mod library_jobs; pub mod metadata; pub mod metrics; pub mod page; @@ -55,6 +56,7 @@ pub use external_link::ExternalLinkRepository; pub use external_rating::ExternalRatingRepository; pub use genre::GenreRepository; pub use library::{CreateLibraryParams, LibraryRepository}; +pub use library_jobs::{CreateLibraryJobParams, LibraryJobRepository, RecordRunStatus}; pub use metadata::BookMetadataRepository; pub use metrics::MetricsRepository; pub use page::PageRepository; diff --git a/src/scheduler/mod.rs b/src/scheduler/mod.rs index d393e12a..674ecddc 100644 --- a/src/scheduler/mod.rs +++ b/src/scheduler/mod.rs @@ -5,8 +5,10 @@ use tokio_cron_scheduler::{Job, JobScheduler}; use tracing::{debug, error, info, warn}; use uuid::Uuid; -use crate::db::repositories::{LibraryRepository, TaskRepository}; +use crate::db::entities::library_jobs; +use crate::db::repositories::{LibraryJobRepository, LibraryRepository, TaskRepository}; use crate::scanner::{ScanMode, ScanningConfig}; +use crate::services::library_jobs::{LibraryJobConfig, parse_job_config}; use crate::services::settings::SettingsService; use crate::tasks::types::TaskType; use crate::utils::cron::{normalize_cron_expression, parse_timezone}; @@ -57,6 +59,9 @@ impl Scheduler { // Load library scan schedules self.load_library_schedules().await?; + // Load per-library scheduled metadata-refresh entries + self.load_library_metadata_refresh_schedules().await?; + // Load deduplication schedule self.load_deduplication_schedule().await?; @@ -536,6 +541,120 @@ impl Scheduler { Ok(()) } + /// Load library-jobs cron entries. + /// + /// Walks `library_jobs` rows where `enabled = true` and dispatches by + /// `r#type`. Phase 9 only handles `metadata_refresh`; future job types + /// extend the match. + async fn load_library_metadata_refresh_schedules(&mut self) -> Result<()> { + let jobs = LibraryJobRepository::list_enabled(&self.db, None).await?; + for job in jobs { + if let Err(e) = self.add_library_job_schedule(&job).await { + warn!( + "Failed to add schedule for library job {} ('{}'): {}", + job.id, job.name, e + ); + } + } + Ok(()) + } + + /// Resolve the timezone for a library job. + fn resolve_library_job_timezone(&self, tz_str: Option<&str>) -> Tz { + if let Some(tz_str) = tz_str { + match parse_timezone(tz_str) { + Ok(tz) => return tz, + Err(e) => { + warn!( + "Invalid library-job timezone '{}': {}. Using server default ({})", + tz_str, e, self.default_tz + ); + } + } + } + self.default_tz + } + + /// Register a single library-job's cron entry. + /// + /// Each firing performs a per-job skip-if-already-running check + /// before enqueuing `RefreshLibraryMetadata { job_id }`. Two jobs on + /// the same library can run concurrently because the guard scopes + /// per-job, not per-library. + pub async fn add_library_job_schedule(&mut self, job: &library_jobs::Model) -> Result<()> { + // Type dispatch. Phase 9: only metadata_refresh. + let cfg = match parse_job_config(&job.r#type, &job.config) { + Ok(c) => c, + Err(e) => { + warn!( + "Library job {} ('{}') has invalid config ({}); skipping schedule", + job.id, job.name, e + ); + return Ok(()); + } + }; + + match cfg { + LibraryJobConfig::MetadataRefresh(_) => {} + } + + if !job.enabled { + debug!("Skipping disabled library job {} ('{}')", job.id, job.name); + return Ok(()); + } + + let cron_schedule = normalize_cron_expression(&job.cron_schedule) + .context("Invalid cron expression for library job")?; + let tz = self.resolve_library_job_timezone(job.timezone.as_deref()); + + let db = self.db.clone(); + let job_id = job.id; + let job_name = job.name.clone(); + + let scheduled_job = Job::new_async_tz(cron_schedule.as_str(), tz, move |_uuid, _lock| { + let db = db.clone(); + let job_name = job_name.clone(); + + Box::pin(async move { + match has_active_refresh_for_job(&db, job_id).await { + Ok(true) => { + warn!( + "Skipping library job '{}' ({}): previous run still active", + job_name, job_id + ); + return; + } + Ok(false) => {} + Err(e) => { + warn!( + "Failed to check in-flight task for job {}: {}; proceeding", + job_id, e + ); + } + } + + info!("Triggering library job '{}' ({})", job_name, job_id); + let task_type = TaskType::RefreshLibraryMetadata { job_id }; + match TaskRepository::enqueue(&db, task_type, None).await { + Ok(_) => debug!("Enqueued library job '{}'", job_name), + Err(e) => error!("Failed to enqueue library job {}: {}", job_id, e), + } + }) + }) + .context("Failed to create library job cron")?; + + self.scheduler + .add(scheduled_job) + .await + .context("Failed to add library job cron to scheduler")?; + + info!( + "Added library job '{}' ({}) cron='{}' tz={}", + job.name, job.id, cron_schedule, tz + ); + Ok(()) + } + /// Reload all schedules (useful when libraries or settings are updated) pub async fn reload_schedules(&mut self) -> Result<()> { info!("Reloading all schedules"); @@ -558,12 +677,103 @@ impl Scheduler { } } +/// Whether an active (`pending` or `processing`) `refresh_library_metadata` +/// task already exists for the given **job**. +/// +/// `job_id` is stored inside `tasks.params` as JSON, so we use a backend- +/// specific JSON path query — same pattern as +/// [`crate::db::repositories::TaskRepository::has_pending_or_processing`]. +pub async fn has_active_refresh_for_job(db: &DatabaseConnection, job_id: Uuid) -> Result { + use sea_orm::{ConnectionTrait, DbBackend, Statement}; + + let job_id_str = job_id.to_string(); + let backend = db.get_database_backend(); + let stmt = match backend { + DbBackend::Postgres => Statement::from_sql_and_values( + DbBackend::Postgres, + r#"SELECT 1 FROM tasks + WHERE task_type = $1 + AND status IN ('pending', 'processing') + AND params->>'job_id' = $2 + LIMIT 1"#, + vec!["refresh_library_metadata".into(), job_id_str.into()], + ), + _ => Statement::from_sql_and_values( + DbBackend::Sqlite, + r#"SELECT 1 FROM tasks + WHERE task_type = ? + AND status IN ('pending', 'processing') + AND json_extract(params, '$.job_id') = ? + LIMIT 1"#, + vec!["refresh_library_metadata".into(), job_id_str.into()], + ), + }; + + let result = db + .query_one(stmt) + .await + .context("Failed to check for active refresh tasks")?; + Ok(result.is_some()) +} + #[cfg(test)] mod tests { + use super::*; + use crate::db::repositories::LibraryRepository; + use crate::db::test_helpers::setup_test_db; + use crate::models::ScanningStrategy; + use crate::tasks::types::TaskType; #[test] fn test_scheduler_can_be_created() { // This test is a placeholder - proper tests require a database connection // See tests/scheduler/mod.rs for integration tests } + + #[tokio::test] + async fn has_active_refresh_for_job_returns_false_when_no_tasks() { + let db = setup_test_db().await; + let _library = LibraryRepository::create(&db, "Lib", "/tmp/lib", ScanningStrategy::Default) + .await + .unwrap(); + + let active = has_active_refresh_for_job(&db, Uuid::new_v4()) + .await + .unwrap(); + assert!( + !active, + "Fresh DB has no refresh tasks; helper must report false" + ); + } + + #[tokio::test] + async fn has_active_refresh_for_job_detects_pending_task() { + let db = setup_test_db().await; + let job_id = Uuid::new_v4(); + TaskRepository::enqueue(&db, TaskType::RefreshLibraryMetadata { job_id }, None) + .await + .unwrap(); + + let active = has_active_refresh_for_job(&db, job_id).await.unwrap(); + assert!(active, "Pending task for this job must be detected"); + } + + #[tokio::test] + async fn has_active_refresh_for_job_is_scoped_per_job() { + let db = setup_test_db().await; + let job_a = Uuid::new_v4(); + let job_b = Uuid::new_v4(); + TaskRepository::enqueue( + &db, + TaskType::RefreshLibraryMetadata { job_id: job_a }, + None, + ) + .await + .unwrap(); + + let active_a = has_active_refresh_for_job(&db, job_a).await.unwrap(); + let active_b = has_active_refresh_for_job(&db, job_b).await.unwrap(); + assert!(active_a, "job A has the in-flight task"); + assert!(!active_b, "job B has no in-flight task"); + } } diff --git a/src/services/library_jobs/mod.rs b/src/services/library_jobs/mod.rs new file mode 100644 index 00000000..c149d2bb --- /dev/null +++ b/src/services/library_jobs/mod.rs @@ -0,0 +1,21 @@ +//! Library jobs service: type-discriminated configs for the +//! [`library_jobs`] table. +//! +//! This module owns the typed shape of the per-job `config` JSON payload. +//! The repository layer ([`crate::db::repositories::LibraryJobRepository`]) +//! persists strings; the parsing, default-filling, and validation lives here. +//! +//! Phase 9 introduces the `metadata_refresh` type. Future job types extend +//! [`LibraryJobConfig`] without schema changes. +//! +//! [`library_jobs`]: crate::db::entities::library_jobs + +pub mod types; +pub mod validation; + +#[allow(unused_imports)] +pub use types::{ + LibraryJobConfig, LibraryJobType, MetadataRefreshJobConfig, RefreshScope, parse_job_config, +}; +#[allow(unused_imports)] +pub use validation::{ValidationError, validate_metadata_refresh_config}; diff --git a/src/services/library_jobs/types.rs b/src/services/library_jobs/types.rs new file mode 100644 index 00000000..2c149d0e --- /dev/null +++ b/src/services/library_jobs/types.rs @@ -0,0 +1,305 @@ +//! Typed configs for [`library_jobs`]. +//! +//! [`LibraryJobConfig`] is a discriminated union keyed on the `type` row +//! column. Phase 9 ships with `metadata_refresh`; future variants extend +//! the enum. +//! +//! [`library_jobs`]: crate::db::entities::library_jobs + +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +/// Default safety window: skip series whose external IDs were synced within +/// this many seconds. +pub const DEFAULT_SKIP_RECENTLY_SYNCED_SECS: u32 = 3600; + +/// Default fan-out per task. +pub const DEFAULT_MAX_CONCURRENCY: u8 = 4; + +/// Hard cap on `max_concurrency` accepted from user input. +pub const MAX_CONCURRENCY_HARD_CAP: u8 = 16; + +/// Stable string discriminators for [`LibraryJobConfig`]. Mirrors the +/// `library_jobs.type` column. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LibraryJobType { + /// Scheduled metadata refresh, scoped to a single provider. + MetadataRefresh, +} + +impl LibraryJobType { + /// Stable wire identifier for storage and PATCH bodies. + pub fn as_str(&self) -> &'static str { + match self { + LibraryJobType::MetadataRefresh => "metadata_refresh", + } + } + + #[cfg(test)] + pub fn parse(s: &str) -> Option { + match s { + "metadata_refresh" => Some(LibraryJobType::MetadataRefresh), + _ => None, + } + } +} + +/// Type-discriminated payload stored in [`library_jobs.config`]. +/// +/// The serde representation is **internally tagged** under the JSON key +/// `type`. Each variant carries its own typed payload. Phase 9 only ships +/// the `metadata_refresh` variant. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum LibraryJobConfig { + MetadataRefresh(MetadataRefreshJobConfig), +} + +impl LibraryJobConfig { + /// Discriminator for this variant. Matches the row's `type` column. + pub fn job_type(&self) -> LibraryJobType { + match self { + LibraryJobConfig::MetadataRefresh(_) => LibraryJobType::MetadataRefresh, + } + } +} + +/// Scope of a metadata refresh job. +/// +/// Phase 9 only honours [`RefreshScope::SeriesOnly`] at runtime. The +/// other variants are schema-accepted but rejected by the validator. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, ToSchema, Default)] +#[serde(rename_all = "snake_case")] +pub enum RefreshScope { + #[default] + SeriesOnly, + BooksOnly, + SeriesAndBooks, +} + +impl RefreshScope { + pub fn as_str(&self) -> &'static str { + match self { + RefreshScope::SeriesOnly => "series_only", + RefreshScope::BooksOnly => "books_only", + RefreshScope::SeriesAndBooks => "series_and_books", + } + } + + /// Whether this scope writes to series metadata. + pub fn writes_series(&self) -> bool { + matches!( + self, + RefreshScope::SeriesOnly | RefreshScope::SeriesAndBooks + ) + } + + /// Whether this scope writes to book metadata. + pub fn writes_books(&self) -> bool { + matches!(self, RefreshScope::BooksOnly | RefreshScope::SeriesAndBooks) + } +} + +/// Payload for a `metadata_refresh` job. +/// +/// One job = one (library, single provider, single cron, field selection, +/// safety options) tuple. The library and cron live on the row; this struct +/// captures the type-specific fields. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct MetadataRefreshJobConfig { + /// Plugin reference, e.g. `"plugin:mangabaka"`. The validator must + /// resolve this to an installed plugin (disabled is fine). + pub provider: String, + + /// Refresh scope. Phase 9 only allows [`RefreshScope::SeriesOnly`]. + #[serde(default)] + pub scope: RefreshScope, + + /// Series-side field groups (snake_case). Resolved to the applier's + /// camelCase field names by `field_groups::fields_for_groups`. + #[serde(default)] + pub field_groups: Vec, + + /// Series-side individual field overrides not covered by any group. + #[serde(default)] + pub extra_fields: Vec, + + /// Reserved for the book-scope future work. Phase 9 rejects non-empty + /// values when [`Self::scope`] is `series_only`. + #[serde(default)] + pub book_field_groups: Vec, + + /// Reserved for the book-scope future work. Phase 9 rejects non-empty + /// values when [`Self::scope`] is `series_only`. + #[serde(default)] + pub book_extra_fields: Vec, + + /// When true, the planner skips any series without a stored external ID + /// for the chosen provider. + #[serde(default = "default_existing_source_ids_only")] + pub existing_source_ids_only: bool, + + /// Skip series whose `series_external_ids.last_synced_at` is younger + /// than this many seconds. `0` disables the guard. + #[serde(default = "default_skip_recently_synced")] + pub skip_recently_synced_within_s: u32, + + /// Per-task fan-out; clamped to `[1, MAX_CONCURRENCY_HARD_CAP]` by the + /// handler. + #[serde(default = "default_max_concurrency")] + pub max_concurrency: u8, +} + +impl Default for MetadataRefreshJobConfig { + fn default() -> Self { + Self { + provider: String::new(), + scope: RefreshScope::SeriesOnly, + field_groups: vec![ + "ratings".to_string(), + "status".to_string(), + "counts".to_string(), + ], + extra_fields: Vec::new(), + book_field_groups: Vec::new(), + book_extra_fields: Vec::new(), + existing_source_ids_only: true, + skip_recently_synced_within_s: DEFAULT_SKIP_RECENTLY_SYNCED_SECS, + max_concurrency: DEFAULT_MAX_CONCURRENCY, + } + } +} + +fn default_existing_source_ids_only() -> bool { + true +} + +fn default_skip_recently_synced() -> u32 { + DEFAULT_SKIP_RECENTLY_SYNCED_SECS +} + +fn default_max_concurrency() -> u8 { + DEFAULT_MAX_CONCURRENCY +} + +/// Decode a stored config JSON string. The `type` discriminator on the row +/// is used to verify the payload matches. +pub fn parse_job_config( + row_type: &str, + config_json: &str, +) -> Result { + // We require the JSON to carry a `type` field that matches the row's + // discriminator. This is belt-and-suspenders: if a future migration + // re-types a row, we won't silently parse it as the wrong variant. + let value: serde_json::Value = serde_json::from_str(config_json)?; + let mut obj = value + .as_object() + .cloned() + .ok_or_else(|| anyhow::anyhow!("library_jobs.config must be a JSON object"))?; + if let Some(t) = obj.get("type").and_then(|v| v.as_str()) { + if t != row_type { + anyhow::bail!("library_jobs.type='{row_type}' but config carries type='{t}'"); + } + } else { + // Inject the discriminator from the row so the enum's `type` + // tag still resolves. This is safe because we just verified + // the row type is the source of truth. + obj.insert( + "type".to_string(), + serde_json::Value::String(row_type.to_string()), + ); + } + let with_type = serde_json::Value::Object(obj); + let cfg: LibraryJobConfig = serde_json::from_value(with_type)?; + Ok(cfg) +} + +/// Encode a [`LibraryJobConfig`] for storage. The `type` tag is included +/// so reads via [`parse_job_config`] cross-check correctly. +#[cfg(test)] +pub fn serialize_job_config(cfg: &LibraryJobConfig) -> Result { + Ok(serde_json::to_string(cfg)?) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trip_metadata_refresh() { + let cfg = LibraryJobConfig::MetadataRefresh(MetadataRefreshJobConfig { + provider: "plugin:mangabaka".to_string(), + scope: RefreshScope::SeriesOnly, + field_groups: vec!["ratings".to_string()], + extra_fields: vec!["language".to_string()], + book_field_groups: Vec::new(), + book_extra_fields: Vec::new(), + existing_source_ids_only: true, + skip_recently_synced_within_s: 1800, + max_concurrency: 8, + }); + + let json = serialize_job_config(&cfg).unwrap(); + assert!(json.contains("\"type\":\"metadata_refresh\"")); + assert!(json.contains("\"scope\":\"series_only\"")); + + let parsed = parse_job_config("metadata_refresh", &json).unwrap(); + assert_eq!(parsed, cfg); + } + + #[test] + fn parse_injects_missing_type_from_row() { + // Without a type tag, the parser uses the row's type. Useful for + // older rows or PATCHes where the body omits the redundant tag. + let json = r#"{"provider":"plugin:x","scope":"series_only","field_groups":[]}"#; + let parsed = parse_job_config("metadata_refresh", json).unwrap(); + match parsed { + LibraryJobConfig::MetadataRefresh(c) => { + assert_eq!(c.provider, "plugin:x"); + } + } + } + + #[test] + fn parse_rejects_type_mismatch() { + let json = r#"{"type":"scan","provider":"plugin:x"}"#; + let err = parse_job_config("metadata_refresh", json).unwrap_err(); + assert!(err.to_string().contains("library_jobs.type")); + } + + #[test] + fn default_metadata_config_is_safe() { + let cfg = MetadataRefreshJobConfig::default(); + assert_eq!(cfg.scope, RefreshScope::SeriesOnly); + assert!(cfg.existing_source_ids_only); + assert_eq!(cfg.max_concurrency, 4); + assert_eq!(cfg.skip_recently_synced_within_s, 3600); + assert_eq!( + cfg.field_groups, + vec![ + "ratings".to_string(), + "status".to_string(), + "counts".to_string() + ] + ); + } + + #[test] + fn refresh_scope_helpers() { + assert!(RefreshScope::SeriesOnly.writes_series()); + assert!(!RefreshScope::SeriesOnly.writes_books()); + assert!(!RefreshScope::BooksOnly.writes_series()); + assert!(RefreshScope::BooksOnly.writes_books()); + assert!(RefreshScope::SeriesAndBooks.writes_series()); + assert!(RefreshScope::SeriesAndBooks.writes_books()); + } + + #[test] + fn library_job_type_round_trips() { + let v = LibraryJobType::MetadataRefresh; + assert_eq!(LibraryJobType::parse(v.as_str()), Some(v)); + assert!(LibraryJobType::parse("nope").is_none()); + } +} diff --git a/src/services/library_jobs/validation.rs b/src/services/library_jobs/validation.rs new file mode 100644 index 00000000..42d5326b --- /dev/null +++ b/src/services/library_jobs/validation.rs @@ -0,0 +1,181 @@ +//! Validators for [`super::types::LibraryJobConfig`] and the row-level +//! fields ([`crate::db::entities::library_jobs`] common fields like name and +//! cron). +//! +//! Validators are typed as `Result<_, ValidationError>` so callers can map +//! to HTTP 400 / 422 responses without losing the precise reason. The +//! validator does not perform DB writes; it queries plugin metadata to +//! cross-check provider capabilities and otherwise inspects the input. + +use sea_orm::DatabaseConnection; +use thiserror::Error; + +use std::str::FromStr; + +use crate::db::repositories::PluginsRepository; +use crate::services::metadata::FieldGroup; +use crate::utils::cron::{validate_cron_expression, validate_timezone}; + +use super::types::{ + LibraryJobConfig, MAX_CONCURRENCY_HARD_CAP, MetadataRefreshJobConfig, RefreshScope, +}; + +/// Stable error taxonomy for the library-jobs validators. +#[derive(Debug, Error, Clone, PartialEq, Eq)] +pub enum ValidationError { + #[error("name must be 1..=200 characters")] + NameOutOfRange, + #[error("invalid cron expression: {0}")] + InvalidCron(String), + #[error("invalid timezone: {0}")] + InvalidTimezone(String), + #[error("provider must be 'plugin:', got '{0}'")] + ProviderFormat(String), + #[error("plugin '{0}' not installed")] + ProviderNotInstalled(String), + #[error("plugin '{provider}' does not support the chosen scope; required: {required}")] + ProviderScopeMismatch { provider: String, required: String }, + #[error("unknown field group '{0}'")] + UnknownFieldGroup(String), + #[error("max_concurrency must be between 1 and {0}")] + MaxConcurrencyOutOfRange(u8), + #[error("scope '{0}' not yet implemented; only 'series_only' is supported in this release")] + ScopeNotImplemented(String), + #[error("book_field_groups / book_extra_fields must be empty when scope is series_only")] + BookFieldsRequireBookScope, +} + +/// Validate a job's common fields plus its type-specific config. +/// +/// `config` is borrowed; the caller is responsible for serialisation. The +/// validator returns the **normalised cron** and **normalised timezone** so +/// the caller can persist canonical strings. +pub struct ValidatedJobInputs { + pub cron_schedule: String, + pub timezone: Option, +} + +pub async fn validate_metadata_refresh_config( + db: &DatabaseConnection, + name: &str, + cron_schedule: &str, + timezone: Option<&str>, + config: &MetadataRefreshJobConfig, +) -> Result { + if name.is_empty() || name.len() > 200 { + return Err(ValidationError::NameOutOfRange); + } + + let cron = validate_cron_expression(cron_schedule) + .map_err(|e| ValidationError::InvalidCron(e.to_string()))?; + let tz = if let Some(t) = timezone { + Some(validate_timezone(t).map_err(|e| ValidationError::InvalidTimezone(e.to_string()))?) + } else { + None + }; + + // Phase 9: only series_only is honoured at runtime. + match config.scope { + RefreshScope::SeriesOnly => { + if !config.book_field_groups.is_empty() || !config.book_extra_fields.is_empty() { + return Err(ValidationError::BookFieldsRequireBookScope); + } + } + RefreshScope::BooksOnly | RefreshScope::SeriesAndBooks => { + return Err(ValidationError::ScopeNotImplemented( + config.scope.as_str().to_string(), + )); + } + } + + if config.max_concurrency < 1 || config.max_concurrency > MAX_CONCURRENCY_HARD_CAP { + return Err(ValidationError::MaxConcurrencyOutOfRange( + MAX_CONCURRENCY_HARD_CAP, + )); + } + + for g in &config.field_groups { + if FieldGroup::from_str(g).is_err() { + return Err(ValidationError::UnknownFieldGroup(g.clone())); + } + } + // book_field_groups already enforced empty above for series_only. + + let plugin_name = parse_provider_string(&config.provider)?; + let plugin = PluginsRepository::get_by_name(db, plugin_name) + .await + .map_err(|e| ValidationError::InvalidCron(e.to_string()))? + .ok_or_else(|| ValidationError::ProviderNotInstalled(config.provider.clone()))?; + + // Cross-check capabilities. Disabled plugins are accepted (the operator + // may be staging the schedule while the plugin admin enables it). + let manifest = plugin + .cached_manifest() + .ok_or_else(|| ValidationError::ProviderNotInstalled(config.provider.clone()))?; + + let needs_series = config.scope.writes_series(); + let needs_books = config.scope.writes_books(); + let supports_series = manifest.capabilities.can_provide_series_metadata(); + let supports_books = manifest.capabilities.can_provide_book_metadata(); + if needs_series && !supports_series { + return Err(ValidationError::ProviderScopeMismatch { + provider: config.provider.clone(), + required: "series".to_string(), + }); + } + if needs_books && !supports_books { + return Err(ValidationError::ProviderScopeMismatch { + provider: config.provider.clone(), + required: "books".to_string(), + }); + } + + Ok(ValidatedJobInputs { + cron_schedule: cron, + timezone: tz, + }) +} + +/// Helper: validate either variant of [`LibraryJobConfig`]. +#[allow(dead_code)] +pub async fn validate_library_job_config( + db: &DatabaseConnection, + name: &str, + cron_schedule: &str, + timezone: Option<&str>, + config: &LibraryJobConfig, +) -> Result { + match config { + LibraryJobConfig::MetadataRefresh(c) => { + validate_metadata_refresh_config(db, name, cron_schedule, timezone, c).await + } + } +} + +/// Parse a `"plugin:"` string and return the inner name. +fn parse_provider_string(s: &str) -> Result<&str, ValidationError> { + s.strip_prefix("plugin:") + .filter(|rest| !rest.is_empty()) + .ok_or_else(|| ValidationError::ProviderFormat(s.to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_provider_string_ok() { + assert_eq!( + parse_provider_string("plugin:mangabaka").unwrap(), + "mangabaka" + ); + } + + #[test] + fn parse_provider_string_rejects_bad_format() { + for bad in ["mangabaka", "plugin:", "external:mangabaka", ""] { + let err = parse_provider_string(bad).unwrap_err(); + assert!(matches!(err, ValidationError::ProviderFormat(_))); + } + } +} diff --git a/src/services/metadata/apply.rs b/src/services/metadata/apply.rs index b9b9be7a..f268ab95 100644 --- a/src/services/metadata/apply.rs +++ b/src/services/metadata/apply.rs @@ -33,13 +33,68 @@ pub struct SkippedField { pub reason: String, } +/// One field's would-be change as recorded by a dry-run apply. +/// +/// `before` is the in-memory current value pulled from `series_metadata` +/// (or `None` when the source-of-truth lives in a joined table — e.g. +/// genres, tags, alternate titles, ratings, external links/ids, cover). +/// `after` is the value the applier would have written. Both are JSON +/// for uniform transport over the wire. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FieldChange { + pub field: String, + pub before: Option, + pub after: serde_json::Value, +} + +/// Per-series report of what a dry-run apply would have done. Returned +/// alongside [`MetadataApplyResult`] when `ApplyOptions::dry_run = true`. +/// +/// Skip reasons (locked field, missing permission, etc.) are surfaced via +/// [`MetadataApplyResult::skipped_fields`] — same code path as a real +/// apply, so the report stays focused on prospective writes. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DryRunReport { + pub changes: Vec, +} + /// Result of applying metadata to a series. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Default)] pub struct MetadataApplyResult { /// Fields that were successfully applied. pub applied_fields: Vec, /// Fields that were skipped (with reasons). pub skipped_fields: Vec, + /// Populated only when `ApplyOptions::dry_run = true`. `None` for real + /// applies. Each entry is a field that *would* have been written. + pub dry_run_report: Option, +} + +/// How a caller decides whether the applier should accept metadata for a +/// series with no stored external ID. +/// +/// The applier itself never re-matches; it just applies the metadata it's +/// handed. This enum is passed through as configuration for the *caller* +/// (typically a task handler) so it can decide whether to call +/// `metadata/series/match` or skip the series entirely. Carrying it in +/// [`ApplyOptions`] keeps the contract visible at every apply site without +/// putting matching logic inside the applier. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum MatchingStrategy { + /// Refresh only series that already have a stored external ID for the + /// chosen provider. Series without one are skipped (not re-matched). + /// + /// This is the safe default for scheduled refreshes against a curated + /// library: the user picked the match once, the scheduler honors it. + ExistingExternalIdOnly, + /// Allow re-matching when no external ID is stored. The handler is + /// expected to call `metadata/series/match` and proceed with the best + /// result. This preserves the manual-apply contract that existed before + /// the scheduled-refresh feature. + #[default] + AllowReMatch, } /// Options for controlling metadata application behavior. @@ -51,6 +106,10 @@ pub struct ApplyOptions { pub thumbnail_service: Option>, /// Event broadcaster for emitting real-time events. If None, events won't be emitted. pub event_broadcaster: Option>, + /// When true, all DB writes are skipped and a [`DryRunReport`] is + /// returned instead. Locks and permission checks still run — same code + /// path, just gated writes. + pub dry_run: bool, } /// Service for applying plugin metadata to series. @@ -74,6 +133,13 @@ impl MetadataApplier { ) -> Result { let mut applied_fields = Vec::new(); let mut skipped_fields = Vec::new(); + // Collected when `options.dry_run = true`, kept `None` otherwise so a + // real apply still returns `dry_run_report = None`. + let mut dry_run_changes: Option> = if options.dry_run { + Some(Vec::new()) + } else { + None + }; // Helper to check if a field should be applied based on the filter let should_apply_field = |field: &str| -> bool { @@ -118,14 +184,33 @@ impl MetadataApplier { } else { Some(title.clone()) }; - SeriesMetadataRepository::update_title( - db, - series_id, - title.clone(), - title_sort, - ) - .await - .context("Failed to update title")?; + if let Some(changes) = dry_run_changes.as_mut() { + changes.push(FieldChange { + field: "title".to_string(), + before: Some(serde_json::json!( + current_metadata.map(|m| m.title.clone()) + )), + after: serde_json::json!(title), + }); + if !title_sort_locked && should_apply_field("titleSort") { + changes.push(FieldChange { + field: "titleSort".to_string(), + before: Some(serde_json::json!( + current_metadata.and_then(|m| m.title_sort.clone()) + )), + after: serde_json::json!(title_sort), + }); + } + } else { + SeriesMetadataRepository::update_title( + db, + series_id, + title.clone(), + title_sort, + ) + .await + .context("Failed to update title")?; + } applied_fields.push("title".to_string()); if !title_sort_locked && should_apply_field("titleSort") { applied_fields.push("titleSort".to_string()); @@ -146,35 +231,50 @@ impl MetadataApplier { PluginPermission::MetadataWriteTitle, ) { Ok(_) => { - // Delete existing alternate titles - AlternateTitleRepository::delete_all_for_series(db, series_id) - .await - .context("Failed to delete old alternate titles")?; - - // Add new alternate titles with unique labels - // Track label counts to make duplicates unique (e.g., "en", "en-2", "en-3") - let mut label_counts: HashMap = HashMap::new(); - - for alt_title in &metadata.alternate_titles { - // Use language or title_type as base label, defaulting to "alternate" - let base_label = alt_title - .language - .clone() - .or_else(|| alt_title.title_type.clone()) - .unwrap_or_else(|| "alternate".to_string()); - - // Make label unique by appending count suffix for duplicates - let count = label_counts.entry(base_label.clone()).or_insert(0); - *count += 1; - let label = if *count == 1 { - base_label - } else { - format!("{}-{}", base_label, count) - }; - - AlternateTitleRepository::create(db, series_id, &label, &alt_title.title) + if let Some(changes) = dry_run_changes.as_mut() { + // Joined-table data: `before` not cheaply available + // here. Surface the new payload as `after` only. + changes.push(FieldChange { + field: "alternateTitles".to_string(), + before: None, + after: serde_json::json!(metadata.alternate_titles), + }); + } else { + // Delete existing alternate titles + AlternateTitleRepository::delete_all_for_series(db, series_id) + .await + .context("Failed to delete old alternate titles")?; + + // Add new alternate titles with unique labels + // Track label counts to make duplicates unique (e.g., "en", "en-2", "en-3") + let mut label_counts: HashMap = HashMap::new(); + + for alt_title in &metadata.alternate_titles { + // Use language or title_type as base label, defaulting to "alternate" + let base_label = alt_title + .language + .clone() + .or_else(|| alt_title.title_type.clone()) + .unwrap_or_else(|| "alternate".to_string()); + + // Make label unique by appending count suffix for duplicates + let count = label_counts.entry(base_label.clone()).or_insert(0); + *count += 1; + let label = if *count == 1 { + base_label + } else { + format!("{}-{}", base_label, count) + }; + + AlternateTitleRepository::create( + db, + series_id, + &label, + &alt_title.title, + ) .await .context("Failed to create alternate title")?; + } } applied_fields.push("alternateTitles".to_string()); } @@ -189,9 +289,23 @@ impl MetadataApplier { let is_locked = current_metadata.map(|m| m.summary_lock).unwrap_or(false); match check_field("summary", is_locked, PluginPermission::MetadataWriteSummary) { Ok(_) => { - SeriesMetadataRepository::update_summary(db, series_id, Some(summary.clone())) + if let Some(changes) = dry_run_changes.as_mut() { + changes.push(FieldChange { + field: "summary".to_string(), + before: Some(serde_json::json!( + current_metadata.and_then(|m| m.summary.clone()) + )), + after: serde_json::json!(summary), + }); + } else { + SeriesMetadataRepository::update_summary( + db, + series_id, + Some(summary.clone()), + ) .await .context("Failed to update summary")?; + } applied_fields.push("summary".to_string()); } Err(skip) => skipped_fields.push(skip), @@ -205,9 +319,17 @@ impl MetadataApplier { let is_locked = current_metadata.map(|m| m.year_lock).unwrap_or(false); match check_field("year", is_locked, PluginPermission::MetadataWriteYear) { Ok(_) => { - SeriesMetadataRepository::update_year(db, series_id, Some(year)) - .await - .context("Failed to update year")?; + if let Some(changes) = dry_run_changes.as_mut() { + changes.push(FieldChange { + field: "year".to_string(), + before: Some(serde_json::json!(current_metadata.and_then(|m| m.year))), + after: serde_json::json!(year), + }); + } else { + SeriesMetadataRepository::update_year(db, series_id, Some(year)) + .await + .context("Failed to update year")?; + } applied_fields.push("year".to_string()); } Err(skip) => skipped_fields.push(skip), @@ -228,13 +350,23 @@ impl MetadataApplier { SeriesStatus::Abandoned => "abandoned", SeriesStatus::Unknown => "unknown", }; - SeriesMetadataRepository::update_status( - db, - series_id, - Some(status_str.to_string()), - ) - .await - .context("Failed to update status")?; + if let Some(changes) = dry_run_changes.as_mut() { + changes.push(FieldChange { + field: "status".to_string(), + before: Some(serde_json::json!( + current_metadata.and_then(|m| m.status.clone()) + )), + after: serde_json::json!(status_str), + }); + } else { + SeriesMetadataRepository::update_status( + db, + series_id, + Some(status_str.to_string()), + ) + .await + .context("Failed to update status")?; + } applied_fields.push("status".to_string()); } Err(skip) => skipped_fields.push(skip), @@ -252,14 +384,24 @@ impl MetadataApplier { PluginPermission::MetadataWritePublisher, ) { Ok(_) => { - SeriesMetadataRepository::update_publisher( - db, - series_id, - Some(publisher.clone()), - None, - ) - .await - .context("Failed to update publisher")?; + if let Some(changes) = dry_run_changes.as_mut() { + changes.push(FieldChange { + field: "publisher".to_string(), + before: Some(serde_json::json!( + current_metadata.and_then(|m| m.publisher.clone()) + )), + after: serde_json::json!(publisher), + }); + } else { + SeriesMetadataRepository::update_publisher( + db, + series_id, + Some(publisher.clone()), + None, + ) + .await + .context("Failed to update publisher")?; + } applied_fields.push("publisher".to_string()); } Err(skip) => skipped_fields.push(skip), @@ -277,9 +419,23 @@ impl MetadataApplier { PluginPermission::MetadataWriteAgeRating, ) { Ok(_) => { - SeriesMetadataRepository::update_age_rating(db, series_id, Some(age_rating)) + if let Some(changes) = dry_run_changes.as_mut() { + changes.push(FieldChange { + field: "ageRating".to_string(), + before: Some(serde_json::json!( + current_metadata.and_then(|m| m.age_rating) + )), + after: serde_json::json!(age_rating), + }); + } else { + SeriesMetadataRepository::update_age_rating( + db, + series_id, + Some(age_rating), + ) .await .context("Failed to update age rating")?; + } applied_fields.push("ageRating".to_string()); } Err(skip) => skipped_fields.push(skip), @@ -297,13 +453,23 @@ impl MetadataApplier { PluginPermission::MetadataWriteLanguage, ) { Ok(_) => { - SeriesMetadataRepository::update_language( - db, - series_id, - Some(language.clone()), - ) - .await - .context("Failed to update language")?; + if let Some(changes) = dry_run_changes.as_mut() { + changes.push(FieldChange { + field: "language".to_string(), + before: Some(serde_json::json!( + current_metadata.and_then(|m| m.language.clone()) + )), + after: serde_json::json!(language), + }); + } else { + SeriesMetadataRepository::update_language( + db, + series_id, + Some(language.clone()), + ) + .await + .context("Failed to update language")?; + } applied_fields.push("language".to_string()); } Err(skip) => skipped_fields.push(skip), @@ -323,13 +489,23 @@ impl MetadataApplier { PluginPermission::MetadataWriteReadingDirection, ) { Ok(_) => { - SeriesMetadataRepository::update_reading_direction( - db, - series_id, - Some(reading_direction.clone()), - ) - .await - .context("Failed to update reading direction")?; + if let Some(changes) = dry_run_changes.as_mut() { + changes.push(FieldChange { + field: "readingDirection".to_string(), + before: Some(serde_json::json!( + current_metadata.and_then(|m| m.reading_direction.clone()) + )), + after: serde_json::json!(reading_direction), + }); + } else { + SeriesMetadataRepository::update_reading_direction( + db, + series_id, + Some(reading_direction.clone()), + ) + .await + .context("Failed to update reading direction")?; + } applied_fields.push("readingDirection".to_string()); } Err(skip) => skipped_fields.push(skip), @@ -349,13 +525,23 @@ impl MetadataApplier { PluginPermission::MetadataWriteTotalVolumeCount, ) { Ok(_) => { - SeriesMetadataRepository::update_total_volume_count( - db, - series_id, - Some(total_volume_count), - ) - .await - .context("Failed to update total volume count")?; + if let Some(changes) = dry_run_changes.as_mut() { + changes.push(FieldChange { + field: "totalVolumeCount".to_string(), + before: Some(serde_json::json!( + current_metadata.and_then(|m| m.total_volume_count) + )), + after: serde_json::json!(total_volume_count), + }); + } else { + SeriesMetadataRepository::update_total_volume_count( + db, + series_id, + Some(total_volume_count), + ) + .await + .context("Failed to update total volume count")?; + } applied_fields.push("totalVolumeCount".to_string()); } Err(skip) => skipped_fields.push(skip), @@ -375,13 +561,23 @@ impl MetadataApplier { PluginPermission::MetadataWriteTotalChapterCount, ) { Ok(_) => { - SeriesMetadataRepository::update_total_chapter_count( - db, - series_id, - Some(total_chapter_count), - ) - .await - .context("Failed to update total chapter count")?; + if let Some(changes) = dry_run_changes.as_mut() { + changes.push(FieldChange { + field: "totalChapterCount".to_string(), + before: Some(serde_json::json!( + current_metadata.and_then(|m| m.total_chapter_count) + )), + after: serde_json::json!(total_chapter_count), + }); + } else { + SeriesMetadataRepository::update_total_chapter_count( + db, + series_id, + Some(total_chapter_count), + ) + .await + .context("Failed to update total chapter count")?; + } applied_fields.push("totalChapterCount".to_string()); } Err(skip) => skipped_fields.push(skip), @@ -402,9 +598,17 @@ impl MetadataApplier { reason: "Plugin does not have permission".to_string(), }); } else { - GenreRepository::set_genres_for_series(db, series_id, metadata.genres.clone()) - .await - .context("Failed to set genres")?; + if let Some(changes) = dry_run_changes.as_mut() { + changes.push(FieldChange { + field: "genres".to_string(), + before: None, + after: serde_json::json!(metadata.genres), + }); + } else { + GenreRepository::set_genres_for_series(db, series_id, metadata.genres.clone()) + .await + .context("Failed to set genres")?; + } applied_fields.push("genres".to_string()); } } @@ -423,9 +627,17 @@ impl MetadataApplier { reason: "Plugin does not have permission".to_string(), }); } else { - TagRepository::set_tags_for_series(db, series_id, metadata.tags.clone()) - .await - .context("Failed to set tags")?; + if let Some(changes) = dry_run_changes.as_mut() { + changes.push(FieldChange { + field: "tags".to_string(), + before: None, + after: serde_json::json!(metadata.tags), + }); + } else { + TagRepository::set_tags_for_series(db, series_id, metadata.tags.clone()) + .await + .context("Failed to set tags")?; + } applied_fields.push("tags".to_string()); } } @@ -439,13 +651,23 @@ impl MetadataApplier { Ok(_) => { let authors_json = serde_json::to_string(&metadata.authors) .unwrap_or_else(|_| "[]".to_string()); - SeriesMetadataRepository::update_authors_json( - db, - series_id, - Some(authors_json), - ) - .await - .context("Failed to update authors")?; + if let Some(changes) = dry_run_changes.as_mut() { + changes.push(FieldChange { + field: "authors".to_string(), + before: Some(serde_json::json!( + current_metadata.and_then(|m| m.authors_json.clone()) + )), + after: serde_json::json!(metadata.authors), + }); + } else { + SeriesMetadataRepository::update_authors_json( + db, + series_id, + Some(authors_json), + ) + .await + .context("Failed to update authors")?; + } applied_fields.push("authors".to_string()); } Err(skip) => skipped_fields.push(skip), @@ -460,10 +682,18 @@ impl MetadataApplier { reason: "Plugin does not have permission".to_string(), }); } else { - for link in &metadata.external_links { - ExternalLinkRepository::upsert(db, series_id, &link.label, &link.url, None) - .await - .context("Failed to upsert external link")?; + if let Some(changes) = dry_run_changes.as_mut() { + changes.push(FieldChange { + field: "externalLinks".to_string(), + before: None, + after: serde_json::json!(metadata.external_links), + }); + } else { + for link in &metadata.external_links { + ExternalLinkRepository::upsert(db, series_id, &link.label, &link.url, None) + .await + .context("Failed to upsert external link")?; + } } applied_fields.push("externalLinks".to_string()); } @@ -477,17 +707,25 @@ impl MetadataApplier { reason: "Plugin does not have permission".to_string(), }); } else { - for ext_id in &metadata.external_ids { - SeriesExternalIdRepository::upsert( - db, - series_id, - &ext_id.source, - &ext_id.external_id, - None, // external_url - not provided in cross-references - None, // metadata_hash - not applicable for cross-references - ) - .await - .context("Failed to upsert external ID")?; + if let Some(changes) = dry_run_changes.as_mut() { + changes.push(FieldChange { + field: "externalIds".to_string(), + before: None, + after: serde_json::json!(metadata.external_ids), + }); + } else { + for ext_id in &metadata.external_ids { + SeriesExternalIdRepository::upsert( + db, + series_id, + &ext_id.source, + &ext_id.external_id, + None, // external_url - not provided in cross-references + None, // metadata_hash - not applicable for cross-references + ) + .await + .context("Failed to upsert external ID")?; + } } applied_fields.push("externalIds".to_string()); } @@ -503,17 +741,25 @@ impl MetadataApplier { reason: "Plugin does not have permission".to_string(), }); } else { - let score = - Decimal::from_f64_retain(rating.score).unwrap_or_else(|| Decimal::new(0, 0)); - ExternalRatingRepository::upsert( - db, - series_id, - &rating.source, - score, - rating.vote_count, - ) - .await - .context("Failed to upsert external rating")?; + if let Some(changes) = dry_run_changes.as_mut() { + changes.push(FieldChange { + field: "rating".to_string(), + before: None, + after: serde_json::json!(rating), + }); + } else { + let score = Decimal::from_f64_retain(rating.score) + .unwrap_or_else(|| Decimal::new(0, 0)); + ExternalRatingRepository::upsert( + db, + series_id, + &rating.source, + score, + rating.vote_count, + ) + .await + .context("Failed to upsert external rating")?; + } applied_fields.push("rating".to_string()); } } @@ -528,18 +774,26 @@ impl MetadataApplier { }); } } else { - for rating in &metadata.external_ratings { - let score = Decimal::from_f64_retain(rating.score) - .unwrap_or_else(|| Decimal::new(0, 0)); - ExternalRatingRepository::upsert( - db, - series_id, - &rating.source, - score, - rating.vote_count, - ) - .await - .context("Failed to upsert external rating")?; + if let Some(changes) = dry_run_changes.as_mut() { + changes.push(FieldChange { + field: "externalRatings".to_string(), + before: None, + after: serde_json::json!(metadata.external_ratings), + }); + } else { + for rating in &metadata.external_ratings { + let score = Decimal::from_f64_retain(rating.score) + .unwrap_or_else(|| Decimal::new(0, 0)); + ExternalRatingRepository::upsert( + db, + series_id, + &rating.source, + score, + rating.vote_count, + ) + .await + .context("Failed to upsert external rating")?; + } } if !applied_fields.contains(&"rating".to_string()) { applied_fields.push("externalRatings".to_string()); @@ -557,6 +811,14 @@ impl MetadataApplier { field: "coverUrl".to_string(), reason: "Plugin does not have permission".to_string(), }); + } else if let Some(changes) = dry_run_changes.as_mut() { + // Dry run: don't download, just record the new URL. + changes.push(FieldChange { + field: "coverUrl".to_string(), + before: None, + after: serde_json::json!(cover_url), + }); + applied_fields.push("coverUrl".to_string()); } else if let Some(thumbnail_service) = &options.thumbnail_service { match CoverService::download_and_apply( db, @@ -592,6 +854,19 @@ impl MetadataApplier { Ok(MetadataApplyResult { applied_fields, skipped_fields, + dry_run_report: dry_run_changes.map(|changes| DryRunReport { changes }), }) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn apply_options_default_is_safe() { + let opts = ApplyOptions::default(); + assert!(!opts.dry_run); + assert!(opts.fields_filter.is_none()); + } +} diff --git a/src/services/metadata/field_groups.rs b/src/services/metadata/field_groups.rs new file mode 100644 index 00000000..7fe2468f --- /dev/null +++ b/src/services/metadata/field_groups.rs @@ -0,0 +1,326 @@ +//! User-facing field-group taxonomy for the scheduled metadata refresh. +//! +//! The scheduled refresh exposes a small set of named groups (Ratings, +//! Status, Counts, etc.) instead of asking the user to pick from the ~20 +//! camelCase field names that [`crate::services::metadata::MetadataApplier`] +//! understands. This module is the authoritative mapping between the two +//! vocabularies. +//! +//! ## Vocabulary +//! +//! - **Field name**: a camelCase string the applier recognises in +//! `ApplyOptions::fields_filter` (e.g. `"rating"`, `"totalVolumeCount"`). +//! Source of truth: `should_apply_field(...)` call sites in +//! `services/metadata/apply.rs`. +//! - **Group name**: a snake_case string stored in +//! `MetadataRefreshConfig::field_groups` (e.g. `"ratings"`, `"counts"`). +//! This is what the UI persists. +//! +//! ## Responsibilities +//! +//! - [`FieldGroup`] enum — closed set of groups, parseable from snake_case. +//! - [`fields_for_group`] — single group → its field set. +//! - [`fields_for_groups`] — slice of groups → deduplicated field set +//! (returns an `Option>` mirroring `ApplyOptions`). + +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; +use std::str::FromStr; + +/// Closed taxonomy of refresh field groups. +/// +/// Stored values are snake_case strings — see [`FieldGroup::as_str`] / +/// [`FieldGroup::from_str`]. The mapping to concrete field names is +/// intentionally narrow and conservative: each group covers fields that +/// "move together" semantically, so refreshing a group rarely surprises +/// the user. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FieldGroup { + /// Title, title sort, alternate titles. + Identifiers, + /// Summary and structured author info. + Descriptive, + /// Publication status and publication year. + Status, + /// Total volume count and total chapter count. + Counts, + /// Primary rating and full external-ratings table. + Ratings, + /// Series cover image URL. + Cover, + /// Free-form tags. + Tags, + /// Genres. + Genres, + /// Age rating. + AgeRating, + /// Language and reading direction. + Classification, + /// Publisher and imprint. + Publisher, + /// Cross-references to other services and editorial links. + ExternalRefs, +} + +impl FieldGroup { + /// Snake_case identifier used in storage and over the wire. + pub fn as_str(&self) -> &'static str { + match self { + FieldGroup::Identifiers => "identifiers", + FieldGroup::Descriptive => "descriptive", + FieldGroup::Status => "status", + FieldGroup::Counts => "counts", + FieldGroup::Ratings => "ratings", + FieldGroup::Cover => "cover", + FieldGroup::Tags => "tags", + FieldGroup::Genres => "genres", + FieldGroup::AgeRating => "age_rating", + FieldGroup::Classification => "classification", + FieldGroup::Publisher => "publisher", + FieldGroup::ExternalRefs => "external_refs", + } + } + + /// All groups, in display order. Used by the public field-group endpoint. + pub fn all() -> &'static [FieldGroup] { + &[ + FieldGroup::Identifiers, + FieldGroup::Descriptive, + FieldGroup::Status, + FieldGroup::Counts, + FieldGroup::Ratings, + FieldGroup::Cover, + FieldGroup::Tags, + FieldGroup::Genres, + FieldGroup::AgeRating, + FieldGroup::Classification, + FieldGroup::Publisher, + FieldGroup::ExternalRefs, + ] + } +} + +impl FromStr for FieldGroup { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + "identifiers" => Ok(FieldGroup::Identifiers), + "descriptive" => Ok(FieldGroup::Descriptive), + "status" => Ok(FieldGroup::Status), + "counts" => Ok(FieldGroup::Counts), + "ratings" => Ok(FieldGroup::Ratings), + "cover" => Ok(FieldGroup::Cover), + "tags" => Ok(FieldGroup::Tags), + "genres" => Ok(FieldGroup::Genres), + "age_rating" => Ok(FieldGroup::AgeRating), + "classification" => Ok(FieldGroup::Classification), + "publisher" => Ok(FieldGroup::Publisher), + "external_refs" => Ok(FieldGroup::ExternalRefs), + other => Err(format!("Unknown field group '{}'", other)), + } + } +} + +/// Concrete field names (camelCase) covered by a single group. +/// +/// Field names match the strings the [`MetadataApplier`] checks via +/// `should_apply_field`. Adding a field here without a matching applier +/// branch silently does nothing — there's a unit test that asserts every +/// returned field is one the applier actually knows about. +/// +/// [`MetadataApplier`]: crate::services::metadata::MetadataApplier +pub fn fields_for_group(group: FieldGroup) -> &'static [&'static str] { + match group { + FieldGroup::Identifiers => &["title", "titleSort", "alternateTitles"], + FieldGroup::Descriptive => &["summary", "authors"], + FieldGroup::Status => &["status", "year"], + FieldGroup::Counts => &["totalVolumeCount", "totalChapterCount"], + FieldGroup::Ratings => &["rating", "externalRatings"], + FieldGroup::Cover => &["coverUrl"], + FieldGroup::Tags => &["tags"], + FieldGroup::Genres => &["genres"], + FieldGroup::AgeRating => &["ageRating"], + FieldGroup::Classification => &["language", "readingDirection"], + FieldGroup::Publisher => &["publisher"], + FieldGroup::ExternalRefs => &["externalIds", "externalLinks"], + } +} + +/// Expand a slice of group names into a deduplicated set of field names. +/// +/// Unknown group strings are silently ignored — callers that want strict +/// validation should call [`FieldGroup::from_str`] up front (the PATCH +/// endpoint will, in Phase 6). +/// +/// Returns `None` when both `groups` and `extras` are empty, matching the +/// "no filter, apply everything" semantics of +/// [`crate::services::metadata::ApplyOptions::fields_filter`]. +pub fn fields_for_groups>(groups: &[S], extras: &[S]) -> Option> { + if groups.is_empty() && extras.is_empty() { + return None; + } + let mut out: HashSet = HashSet::new(); + for g in groups { + if let Ok(group) = FieldGroup::from_str(g.as_ref()) { + for field in fields_for_group(group) { + out.insert((*field).to_string()); + } + } + } + for f in extras { + out.insert(f.as_ref().to_string()); + } + Some(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Every field name returned by the resolver must be one the applier + /// actually checks. Otherwise we're silently no-op'ing the user's + /// selection. + /// + /// Source of truth: `should_apply_field("...")` call sites in + /// `services/metadata/apply.rs`. + const APPLIER_KNOWN_FIELDS: &[&str] = &[ + "title", + "titleSort", + "alternateTitles", + "summary", + "year", + "status", + "publisher", + "ageRating", + "language", + "readingDirection", + "totalVolumeCount", + "totalChapterCount", + "genres", + "tags", + "authors", + "externalLinks", + "externalIds", + "rating", + "externalRatings", + "coverUrl", + ]; + + #[test] + fn every_field_in_every_group_is_known_to_the_applier() { + for group in FieldGroup::all() { + for field in fields_for_group(*group) { + assert!( + APPLIER_KNOWN_FIELDS.contains(field), + "field '{}' (group {:?}) is not recognized by MetadataApplier", + field, + group + ); + } + } + } + + #[test] + fn from_str_round_trips() { + for group in FieldGroup::all() { + let s = group.as_str(); + let parsed = FieldGroup::from_str(s).expect("should parse"); + assert_eq!(parsed, *group); + } + } + + #[test] + fn from_str_rejects_unknown() { + assert!(FieldGroup::from_str("not_a_group").is_err()); + assert!(FieldGroup::from_str("").is_err()); + } + + #[test] + fn ratings_group_maps_to_rating_fields() { + let fields = fields_for_group(FieldGroup::Ratings); + assert_eq!(fields, &["rating", "externalRatings"]); + } + + #[test] + fn counts_group_includes_both_counts() { + let fields = fields_for_group(FieldGroup::Counts); + assert!(fields.contains(&"totalVolumeCount")); + assert!(fields.contains(&"totalChapterCount")); + } + + #[test] + fn fields_for_groups_returns_none_when_empty() { + let groups: Vec<&str> = vec![]; + let extras: Vec<&str> = vec![]; + assert!(fields_for_groups(&groups, &extras).is_none()); + } + + #[test] + fn fields_for_groups_expands_groups() { + let groups = ["ratings", "status"]; + let extras: Vec<&str> = vec![]; + let out = fields_for_groups(&groups, &extras).unwrap(); + assert!(out.contains("rating")); + assert!(out.contains("externalRatings")); + assert!(out.contains("status")); + assert!(out.contains("year")); + assert_eq!(out.len(), 4); + } + + #[test] + fn fields_for_groups_dedupes_overlapping_groups() { + // Identifiers and Descriptive don't overlap, but extras can. + let groups = ["identifiers"]; + let extras = ["title", "summary"]; + let out = fields_for_groups(&groups, &extras).unwrap(); + // identifiers brings title + titleSort + alternateTitles; extras add summary. + // 'title' is duplicated by extras; should appear once. + assert!(out.contains("title")); + assert!(out.contains("titleSort")); + assert!(out.contains("alternateTitles")); + assert!(out.contains("summary")); + assert_eq!(out.len(), 4); + } + + #[test] + fn fields_for_groups_silently_ignores_unknown_groups() { + let groups = ["ratings", "made_up_group"]; + let extras: Vec<&str> = vec![]; + let out = fields_for_groups(&groups, &extras).unwrap(); + assert_eq!(out.len(), 2); + assert!(out.contains("rating")); + assert!(out.contains("externalRatings")); + } + + #[test] + fn fields_for_groups_returns_only_extras_when_groups_empty() { + let groups: Vec<&str> = vec![]; + let extras = ["language", "publisher"]; + let out = fields_for_groups(&groups, &extras).unwrap(); + assert_eq!(out.len(), 2); + assert!(out.contains("language")); + assert!(out.contains("publisher")); + } + + #[test] + fn all_groups_are_listed_in_all() { + // FieldGroup::all() should match the variants in the enum. If you + // add a variant, add it to all() too. + let listed: HashSet = FieldGroup::all().iter().copied().collect(); + assert!(listed.contains(&FieldGroup::Identifiers)); + assert!(listed.contains(&FieldGroup::Descriptive)); + assert!(listed.contains(&FieldGroup::Status)); + assert!(listed.contains(&FieldGroup::Counts)); + assert!(listed.contains(&FieldGroup::Ratings)); + assert!(listed.contains(&FieldGroup::Cover)); + assert!(listed.contains(&FieldGroup::Tags)); + assert!(listed.contains(&FieldGroup::Genres)); + assert!(listed.contains(&FieldGroup::AgeRating)); + assert!(listed.contains(&FieldGroup::Classification)); + assert!(listed.contains(&FieldGroup::Publisher)); + assert!(listed.contains(&FieldGroup::ExternalRefs)); + assert_eq!(listed.len(), 12); + } +} diff --git a/src/services/metadata/mod.rs b/src/services/metadata/mod.rs index 8dbf3d03..005bf61c 100644 --- a/src/services/metadata/mod.rs +++ b/src/services/metadata/mod.rs @@ -10,8 +10,16 @@ mod apply; mod book_apply; mod cover; +pub mod field_groups; pub mod preprocessing; +pub mod refresh_planner; -pub use apply::{ApplyOptions, MetadataApplier, SkippedField}; +pub use apply::{ApplyOptions, MatchingStrategy, MetadataApplier, SkippedField}; pub use book_apply::{BookApplyOptions, BookMetadataApplier}; pub use cover::CoverService; +pub use field_groups::{FieldGroup, fields_for_group}; +#[allow(unused_imports)] +pub use refresh_planner::{ + PlanFailure, PlannedRefresh, RefreshPlan, RefreshPlanner, SkipReason, SkippedRefresh, + fields_filter_from_job_config, +}; diff --git a/src/services/metadata/refresh_planner.rs b/src/services/metadata/refresh_planner.rs new file mode 100644 index 00000000..f201cffd --- /dev/null +++ b/src/services/metadata/refresh_planner.rs @@ -0,0 +1,416 @@ +//! Planner that decides which `(series, provider)` pairs the scheduled +//! metadata refresh should touch in a given run. +//! +//! Phase 9: each job carries a single provider, so the planner now resolves +//! one `"plugin:"` reference, lists the library's series, and emits one +//! `PlannedRefresh` per series (or skipped reason). The previous +//! many-providers-per-config model has been removed alongside the per-provider +//! override hatch. + +#![allow(dead_code)] + +use anyhow::{Context, Result}; +use chrono::{DateTime, Duration, Utc}; +use sea_orm::DatabaseConnection; +use std::collections::{HashMap, HashSet}; +use uuid::Uuid; + +use crate::db::entities::plugins::Model as Plugin; +use crate::db::entities::series_external_ids::{self, Model as SeriesExternalId}; +use crate::db::repositories::{PluginsRepository, SeriesExternalIdRepository, SeriesRepository}; + +use crate::services::library_jobs::MetadataRefreshJobConfig; + +/// Reason a series was skipped during planning. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SkipReason { + /// `existing_source_ids_only = true` and series has no external ID for the provider. + NoExternalId, + /// `last_synced_at` is younger than `skip_recently_synced_within_s`. + RecentlySynced { last_synced_at: DateTime }, +} + +impl SkipReason { + pub fn as_str(&self) -> &'static str { + match self { + SkipReason::NoExternalId => "no_external_id", + SkipReason::RecentlySynced { .. } => "recently_synced", + } + } +} + +/// One planned `(series, plugin)` pair plus the optional pre-fetched +/// external ID. Carrying the external ID through avoids a second DB lookup +/// in the task handler when it dispatches `metadata/series/get`. +#[derive(Debug, Clone)] +pub struct PlannedRefresh { + pub series_id: Uuid, + pub plugin: Plugin, + /// Pre-fetched external ID for this series + plugin, if any. + pub existing_external_id: Option, +} + +/// One series that was considered but skipped, with the reason. Surfaced so +/// the task handler can record per-reason counts in the task summary. +#[derive(Debug, Clone)] +pub struct SkippedRefresh { + pub series_id: Uuid, + pub reason: SkipReason, +} + +/// Reason the entire plan resolved to "no work" before the per-series gate. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PlanFailure { + /// Provider string isn't `"plugin:"`. + InvalidProviderString, + /// Plugin name doesn't resolve to an installed plugin. + PluginMissing, + /// Plugin exists but is disabled. + PluginDisabled, +} + +impl PlanFailure { + pub fn as_str(&self) -> &'static str { + match self { + PlanFailure::InvalidProviderString => "invalid_provider_string", + PlanFailure::PluginMissing => "plugin_missing", + PlanFailure::PluginDisabled => "plugin_disabled", + } + } +} + +/// Output of [`RefreshPlanner::plan`]. +#[derive(Debug, Default)] +pub struct RefreshPlan { + /// The plugin model the planner resolved against. `None` when the + /// provider couldn't be resolved (see [`Self::failure`]). + pub plugin: Option, + /// Refreshes that should actually run. + pub planned: Vec, + /// Per-series skips with reasons. + pub skipped: Vec, + /// Set when provider resolution failed before the per-series step. + /// Mutually exclusive with `planned`. + pub failure: Option, +} + +impl RefreshPlan { + /// Total work units = number of planned `(series, plugin)` invocations. + pub fn total_work(&self) -> usize { + self.planned.len() + } + + /// Skip count grouped by reason key. + pub fn skipped_by_reason(&self) -> HashMap<&'static str, usize> { + let mut out: HashMap<&'static str, usize> = HashMap::new(); + for s in &self.skipped { + *out.entry(s.reason.as_str()).or_insert(0) += 1; + } + out + } +} + +/// Stateless planner. All state is passed in per-call. +pub struct RefreshPlanner; + +impl RefreshPlanner { + /// Build a refresh plan for `library_id` against `config`. + /// + /// The planner: + /// 1. Resolves `config.provider`. Failure is recorded on `plan.failure`. + /// 2. Lists every series in the library. + /// 3. Fetches all external IDs for those series in one query. + /// 4. For each `(series, plugin)` pair, emits a `PlannedRefresh` or a + /// typed [`SkipReason`]. + pub async fn plan( + db: &DatabaseConnection, + library_id: Uuid, + config: &MetadataRefreshJobConfig, + ) -> Result { + let mut plan = RefreshPlan::default(); + + // 1. Resolve provider. + let plugin = match resolve_provider(db, &config.provider).await? { + ProviderResolution::Resolved(p) => p, + ProviderResolution::InvalidString => { + plan.failure = Some(PlanFailure::InvalidProviderString); + return Ok(plan); + } + ProviderResolution::Missing => { + plan.failure = Some(PlanFailure::PluginMissing); + return Ok(plan); + } + ProviderResolution::Disabled => { + plan.failure = Some(PlanFailure::PluginDisabled); + return Ok(plan); + } + }; + + // 2. List series. + let series_list = SeriesRepository::list_by_library(db, library_id) + .await + .context("Failed to list series for refresh planning")?; + if series_list.is_empty() { + plan.plugin = Some(plugin); + return Ok(plan); + } + let series_ids: Vec = series_list.iter().map(|s| s.id).collect(); + + // 3. Fetch all external IDs in a single batched query. + let external_ids_by_series: HashMap> = + SeriesExternalIdRepository::get_for_series_ids(db, &series_ids) + .await + .context("Failed to load external IDs for refresh planning")?; + + let recently_synced_cutoff: Option> = if config.skip_recently_synced_within_s + == 0 + { + None + } else { + Some(Utc::now() - Duration::seconds(i64::from(config.skip_recently_synced_within_s))) + }; + + let plugin_source = series_external_ids::Model::plugin_source(&plugin.name); + + // 4. For each series, decide. + for series in &series_ids { + let series_externals = external_ids_by_series.get(series); + let existing = series_externals + .and_then(|list| list.iter().find(|e| e.source == plugin_source).cloned()); + + if config.existing_source_ids_only && existing.is_none() { + plan.skipped.push(SkippedRefresh { + series_id: *series, + reason: SkipReason::NoExternalId, + }); + continue; + } + + if let (Some(cutoff), Some(ext)) = (recently_synced_cutoff, existing.as_ref()) + && let Some(last_synced_at) = ext.last_synced_at + && last_synced_at >= cutoff + { + plan.skipped.push(SkippedRefresh { + series_id: *series, + reason: SkipReason::RecentlySynced { last_synced_at }, + }); + continue; + } + + plan.planned.push(PlannedRefresh { + series_id: *series, + plugin: plugin.clone(), + existing_external_id: existing, + }); + } + + plan.plugin = Some(plugin); + Ok(plan) + } +} + +/// Outcome of resolving the job's `provider` string. +#[allow(clippy::large_enum_variant)] +enum ProviderResolution { + Resolved(Plugin), + InvalidString, + Missing, + Disabled, +} + +async fn resolve_provider(db: &DatabaseConnection, provider: &str) -> Result { + let Some(name) = provider.strip_prefix("plugin:").filter(|s| !s.is_empty()) else { + return Ok(ProviderResolution::InvalidString); + }; + let plugin = PluginsRepository::get_by_name(db, name).await?; + match plugin { + None => Ok(ProviderResolution::Missing), + Some(p) if !p.enabled => Ok(ProviderResolution::Disabled), + Some(p) => Ok(ProviderResolution::Resolved(p)), + } +} + +/// Expand `field_groups + extra_fields` into the concrete camelCase field +/// set the applier understands. Returns `None` when both lists are empty +/// (apply everything; existing `MetadataApplier` semantics). +pub fn fields_filter_from_job_config(config: &MetadataRefreshJobConfig) -> Option> { + super::field_groups::fields_for_groups(&config.field_groups, &config.extra_fields) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::ScanningStrategy; + use crate::db::entities::plugins::PluginPermission; + use crate::db::repositories::{LibraryRepository, PluginsRepository, SeriesRepository}; + use crate::db::test_helpers::setup_test_db; + use crate::services::library_jobs::{MetadataRefreshJobConfig, RefreshScope}; + use crate::services::plugin::protocol::PluginScope; + use std::env; + use std::sync::Once; + + static INIT_ENCRYPTION: Once = Once::new(); + + fn setup_test_encryption_key() { + INIT_ENCRYPTION.call_once(|| { + if env::var("CODEX_ENCRYPTION_KEY").is_err() { + // SAFETY: tests share env; first-writer-wins is safe with a constant. + unsafe { + env::set_var( + "CODEX_ENCRYPTION_KEY", + "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=", + ); + } + } + }); + } + + async fn create_library(db: &DatabaseConnection, name: &str) -> Uuid { + LibraryRepository::create(db, name, &format!("/tmp/{name}"), ScanningStrategy::Default) + .await + .unwrap() + .id + } + + async fn create_series(db: &DatabaseConnection, library_id: Uuid, name: &str) -> Uuid { + SeriesRepository::create(db, library_id, name, None) + .await + .unwrap() + .id + } + + async fn create_plugin(db: &DatabaseConnection, name: &str, enabled: bool) -> Plugin { + setup_test_encryption_key(); + PluginsRepository::create( + db, + name, + name, + None, + "system", + "node", + vec!["dist/index.js".to_string()], + vec![], + None, + vec![PluginPermission::MetadataWriteSummary], + vec![PluginScope::SeriesDetail], + vec![], + None, + "env", + None, + enabled, + None, + None, + ) + .await + .unwrap() + } + + fn cfg(provider: &str) -> MetadataRefreshJobConfig { + MetadataRefreshJobConfig { + provider: provider.to_string(), + scope: RefreshScope::SeriesOnly, + field_groups: vec![], + extra_fields: vec![], + book_field_groups: vec![], + book_extra_fields: vec![], + existing_source_ids_only: true, + skip_recently_synced_within_s: 0, + max_concurrency: 4, + } + } + + #[tokio::test] + async fn plan_invalid_provider_string() { + let db = setup_test_db().await; + let lib = create_library(&db, "lib").await; + let plan = RefreshPlanner::plan(&db, lib, &cfg("not-a-plugin")) + .await + .unwrap(); + assert!(matches!( + plan.failure, + Some(PlanFailure::InvalidProviderString) + )); + assert!(plan.planned.is_empty()); + } + + #[tokio::test] + async fn plan_missing_plugin() { + let db = setup_test_db().await; + let lib = create_library(&db, "lib").await; + let plan = RefreshPlanner::plan(&db, lib, &cfg("plugin:missing")) + .await + .unwrap(); + assert!(matches!(plan.failure, Some(PlanFailure::PluginMissing))); + } + + #[tokio::test] + async fn plan_disabled_plugin() { + let db = setup_test_db().await; + let lib = create_library(&db, "lib").await; + let _ = create_plugin(&db, "off", false).await; + let plan = RefreshPlanner::plan(&db, lib, &cfg("plugin:off")) + .await + .unwrap(); + assert!(matches!(plan.failure, Some(PlanFailure::PluginDisabled))); + } + + #[tokio::test] + async fn plan_strict_mode_skips_no_id() { + let db = setup_test_db().await; + let lib = create_library(&db, "lib").await; + let _ = create_series(&db, lib, "S1").await; + let _ = create_series(&db, lib, "S2").await; + let _ = create_plugin(&db, "x", true).await; + let mut config = cfg("plugin:x"); + config.existing_source_ids_only = true; + let plan = RefreshPlanner::plan(&db, lib, &config).await.unwrap(); + assert!(plan.failure.is_none()); + assert!(plan.planned.is_empty()); + assert_eq!(plan.skipped.len(), 2); + assert!( + plan.skipped + .iter() + .all(|s| s.reason == SkipReason::NoExternalId) + ); + } + + #[tokio::test] + async fn plan_loose_mode_keeps_no_id_pairs() { + let db = setup_test_db().await; + let lib = create_library(&db, "lib").await; + let _ = create_series(&db, lib, "S1").await; + let _ = create_plugin(&db, "x", true).await; + let mut config = cfg("plugin:x"); + config.existing_source_ids_only = false; + let plan = RefreshPlanner::plan(&db, lib, &config).await.unwrap(); + assert_eq!(plan.planned.len(), 1); + assert!(plan.skipped.is_empty()); + } + + #[tokio::test] + async fn fields_filter_returns_none_when_empty() { + let cfg = MetadataRefreshJobConfig::default(); + // Default has non-empty groups. + assert!(fields_filter_from_job_config(&cfg).is_some()); + let empty = MetadataRefreshJobConfig { + field_groups: vec![], + ..cfg + }; + assert!(fields_filter_from_job_config(&empty).is_none()); + } + + #[tokio::test] + async fn fields_filter_expands_groups() { + let cfg = MetadataRefreshJobConfig { + field_groups: vec!["ratings".to_string(), "status".to_string()], + extra_fields: vec!["language".to_string()], + ..MetadataRefreshJobConfig::default() + }; + let out = fields_filter_from_job_config(&cfg).unwrap(); + assert!(out.contains("rating")); + assert!(out.contains("externalRatings")); + assert!(out.contains("status")); + assert!(out.contains("year")); + assert!(out.contains("language")); + } +} diff --git a/src/services/mod.rs b/src/services/mod.rs index 1c0e19c1..385ab3a6 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -6,6 +6,7 @@ pub mod export_storage; pub mod file_cleanup; pub mod filter; pub mod inflight_thumbnails; +pub mod library_jobs; pub mod metadata; pub mod oidc; pub mod pdf_cache; diff --git a/src/tasks/handlers/mod.rs b/src/tasks/handlers/mod.rs index c0720e06..edd6b5d6 100644 --- a/src/tasks/handlers/mod.rs +++ b/src/tasks/handlers/mod.rs @@ -22,6 +22,7 @@ pub mod generate_thumbnail; pub mod generate_thumbnails; pub mod plugin_auto_match; pub mod purge_deleted; +pub mod refresh_library_metadata; pub mod renumber_series; pub mod reprocess_series_titles; pub mod scan_library; @@ -45,6 +46,7 @@ pub use generate_thumbnail::GenerateThumbnailHandler; pub use generate_thumbnails::GenerateThumbnailsHandler; pub use plugin_auto_match::PluginAutoMatchHandler; pub use purge_deleted::PurgeDeletedHandler; +pub use refresh_library_metadata::RefreshLibraryMetadataHandler; pub use renumber_series::{RenumberSeriesBatchHandler, RenumberSeriesHandler}; pub use reprocess_series_titles::{ReprocessSeriesTitleHandler, ReprocessSeriesTitlesHandler}; pub use scan_library::ScanLibraryHandler; diff --git a/src/tasks/handlers/plugin_auto_match.rs b/src/tasks/handlers/plugin_auto_match.rs index 90f8b6fd..15adcd79 100644 --- a/src/tasks/handlers/plugin_auto_match.rs +++ b/src/tasks/handlers/plugin_auto_match.rs @@ -926,11 +926,11 @@ impl TaskHandler for PluginAutoMatchHandler { let current_metadata = SeriesMetadataRepository::get_by_series_id(db, series_id).await?; - // Build apply options let options = ApplyOptions { fields_filter: None, thumbnail_service: self.thumbnail_service.clone(), event_broadcaster: event_broadcaster.cloned(), + dry_run: false, }; // Apply metadata diff --git a/src/tasks/handlers/refresh_library_metadata.rs b/src/tasks/handlers/refresh_library_metadata.rs new file mode 100644 index 00000000..99239eb2 --- /dev/null +++ b/src/tasks/handlers/refresh_library_metadata.rs @@ -0,0 +1,734 @@ +//! Per-job metadata refresh handler. +//! +//! Phase 9 entry point: the task carries a `job_id`, the handler loads the +//! [`library_jobs`] row, decodes its [`LibraryJobConfig`] (must be +//! `MetadataRefresh` to land here), resolves the library, builds a +//! [`RefreshPlan`] via [`RefreshPlanner`], and walks the plan one +//! `(series, plugin)` pair at a time. +//! +//! Scope: Phase 9 only honours `RefreshScope::SeriesOnly`. The validator +//! gates this at PATCH time, but the handler also rejects non-series scopes +//! at run time so a job that somehow persisted with a deferred scope +//! short-circuits with a clear failure status. +//! +//! [`library_jobs`]: crate::db::entities::library_jobs + +use anyhow::{Context, Result}; +use sea_orm::DatabaseConnection; +use serde_json::json; +use std::sync::Arc; +use std::time::Duration; +use tracing::{debug, error, info, warn}; + +use crate::db::entities::tasks; +use crate::db::repositories::{ + LibraryJobRepository, LibraryRepository, PluginsRepository, RecordRunStatus, + SeriesExternalIdRepository, SeriesMetadataRepository, SeriesRepository, +}; +use crate::events::{EntityChangeEvent, EntityEvent, EventBroadcaster, TaskProgressEvent}; +use crate::services::ThumbnailService; +use crate::services::library_jobs::{LibraryJobConfig, RefreshScope, parse_job_config}; +use crate::services::metadata::refresh_planner::{ + PlanFailure, PlannedRefresh, RefreshPlan, RefreshPlanner, SkipReason, + fields_filter_from_job_config, +}; +use crate::services::metadata::{ApplyOptions, MatchingStrategy, MetadataApplier}; +use crate::services::plugin::PluginManager; +use crate::services::plugin::protocol::{MetadataGetParams, MetadataMatchParams}; +use crate::tasks::handlers::TaskHandler; +use crate::tasks::types::TaskResult; + +/// Soft cap to keep one job's refresh from monopolizing the worker. +const MAX_CONCURRENCY_HARD_CAP: usize = 16; + +/// Per-`(series, provider)` plugin call timeout. +const PER_PAIR_TIMEOUT: Duration = Duration::from_secs(60); + +/// Aggregated outcome of a single job run. +#[derive(Debug, Default)] +struct RunSummary { + succeeded: u32, + failed: u32, + skipped_no_external_id: u32, + skipped_recently_synced: u32, + skipped_no_match_candidate: u32, + fields_applied_total: u32, +} + +impl RunSummary { + fn into_json( + self, + total_planned: usize, + plan_failure: Option<&PlanFailure>, + ) -> serde_json::Value { + json!({ + "planned": total_planned, + "succeeded": self.succeeded, + "failed": self.failed, + "skipped": { + "no_external_id": self.skipped_no_external_id, + "recently_synced": self.skipped_recently_synced, + "no_match_candidate": self.skipped_no_match_candidate, + }, + "fields_applied_total": self.fields_applied_total, + "plan_failure": plan_failure.map(|f| f.as_str()), + }) + } +} + +/// Handler for [`crate::tasks::types::TaskType::RefreshLibraryMetadata`]. +pub struct RefreshLibraryMetadataHandler { + plugin_manager: Arc, + thumbnail_service: Option>, +} + +impl RefreshLibraryMetadataHandler { + pub fn new(plugin_manager: Arc) -> Self { + Self { + plugin_manager, + thumbnail_service: None, + } + } + + pub fn with_thumbnail_service(mut self, thumbnail_service: Arc) -> Self { + self.thumbnail_service = Some(thumbnail_service); + self + } + + fn fold_skipped_into_summary(plan: &RefreshPlan, summary: &mut RunSummary) { + for s in &plan.skipped { + match s.reason { + SkipReason::NoExternalId => summary.skipped_no_external_id += 1, + SkipReason::RecentlySynced { .. } => summary.skipped_recently_synced += 1, + } + } + } +} + +impl TaskHandler for RefreshLibraryMetadataHandler { + fn handle<'a>( + &'a self, + task: &'a tasks::Model, + db: &'a DatabaseConnection, + event_broadcaster: Option<&'a Arc>, + ) -> std::pin::Pin> + Send + 'a>> { + Box::pin(async move { + // 1. Resolve job_id from the task params payload. + let job_id = task + .params + .as_ref() + .and_then(|p| p.get("job_id")) + .and_then(|v| v.as_str()) + .and_then(|s| uuid::Uuid::parse_str(s).ok()) + .ok_or_else(|| anyhow::anyhow!("Missing or invalid job_id in task params"))?; + + let job = LibraryJobRepository::get_by_id(db, job_id) + .await? + .ok_or_else(|| anyhow::anyhow!("Library job not found: {}", job_id))?; + + let cfg = parse_job_config(&job.r#type, &job.config) + .context("Failed to decode library job config")?; + let LibraryJobConfig::MetadataRefresh(cfg) = cfg; + + // 2. Phase 9 scope guard. The validator should have rejected + // non-series scopes already; this is defense-in-depth so a + // persisted bad row fails loudly rather than silently no-op. + if cfg.scope != RefreshScope::SeriesOnly { + let msg = format!( + "Book-scope refresh ('{}') not yet implemented", + cfg.scope.as_str() + ); + let _ = LibraryJobRepository::record_run( + db, + job.id, + RecordRunStatus::Failure, + Some(msg.clone()), + ) + .await; + return Ok(TaskResult::failure(msg)); + } + + let library = LibraryRepository::get_by_id(db, job.library_id) + .await? + .ok_or_else(|| anyhow::anyhow!("Library not found for job: {}", job.library_id))?; + + info!( + "Task {}: Refreshing job '{}' on library '{}' — provider={}, groups={:?}, strict={}", + task.id, + job.name, + library.name, + cfg.provider, + cfg.field_groups, + cfg.existing_source_ids_only + ); + + // 3. Verify capabilities haven't drifted between job-create and run-time. + // The validator already cross-checked at write; the runtime check + // catches plugin updates that drop the required capability after the fact. + // SeriesOnly requires `metadata_provider`. + if let Some(plugin_name) = cfg.provider.strip_prefix("plugin:") + && let Ok(Some(plugin)) = + crate::db::repositories::PluginsRepository::get_by_name(db, plugin_name).await + && let Some(manifest) = plugin.cached_manifest() + && !manifest.capabilities.can_provide_series_metadata() + { + let msg = format!( + "Provider '{}' no longer supports series metadata", + cfg.provider + ); + let _ = LibraryJobRepository::record_run( + db, + job.id, + RecordRunStatus::Failure, + Some(msg.clone()), + ) + .await; + return Ok(TaskResult::failure(msg)); + } + + // 4. Build the plan. + let plan = RefreshPlanner::plan(db, job.library_id, &cfg) + .await + .context("Failed to build refresh plan")?; + + let total_planned = plan.total_work(); + let plan_failure = plan.failure.clone(); + let mut summary = RunSummary::default(); + Self::fold_skipped_into_summary(&plan, &mut summary); + + if let Some(failure) = plan_failure.as_ref() { + let msg = format!( + "Refresh aborted: {} (provider={})", + failure.as_str(), + cfg.provider + ); + info!("Task {}: {}", task.id, msg); + let summary_json = summary.into_json(total_planned, Some(failure)); + let _ = LibraryJobRepository::record_run( + db, + job.id, + RecordRunStatus::Failure, + Some(msg.clone()), + ) + .await; + return Ok(TaskResult::success_with_data(msg, summary_json)); + } + + if total_planned == 0 { + let message = format!("Nothing to refresh ({} skipped)", plan.skipped.len()); + info!("Task {}: {}", task.id, message); + let summary_json = summary.into_json(total_planned, None); + let _ = LibraryJobRepository::record_run( + db, + job.id, + RecordRunStatus::Success, + Some(message.clone()), + ) + .await; + return Ok(TaskResult::success_with_data(message, summary_json)); + } + + // 5. Walk the plan. + if let Some(broadcaster) = event_broadcaster { + let _ = broadcaster.emit_task(TaskProgressEvent::progress( + task.id, + "refresh_library_metadata", + 0, + total_planned, + Some(format!( + "Refreshing {} ({} pair(s) planned)", + library.name, total_planned + )), + Some(job.library_id), + None, + None, + )); + } + + let _max_concurrency = + (cfg.max_concurrency as usize).clamp(1, MAX_CONCURRENCY_HARD_CAP); + + let library_name = library.name.clone(); + let matching_strategy = if cfg.existing_source_ids_only { + MatchingStrategy::ExistingExternalIdOnly + } else { + MatchingStrategy::AllowReMatch + }; + + let pair_fields_filter = fields_filter_from_job_config(&cfg); + + for (idx, planned) in plan.planned.iter().enumerate() { + let pair_outcome = process_pair( + db, + job.library_id, + planned, + pair_fields_filter.as_ref(), + self.thumbnail_service.as_ref(), + event_broadcaster, + self.plugin_manager.as_ref(), + matching_strategy, + ) + .await; + + match pair_outcome { + Ok(applied) => { + summary.succeeded += 1; + summary.fields_applied_total += applied as u32; + } + Err(PairError::NoExternalId) => { + warn!( + "Task {}: Skipping series {} (no external ID under strict mode)", + task.id, planned.series_id + ); + summary.skipped_no_external_id += 1; + } + Err(PairError::NoMatchCandidate) => { + info!( + "Task {}: No match candidate for series {} via plugin {}; skipping", + task.id, planned.series_id, planned.plugin.name + ); + summary.skipped_no_match_candidate += 1; + } + Err(PairError::Failed(err)) => { + summary.failed += 1; + error!( + "Task {}: Failed refresh for series {} via plugin {}: {:#}", + task.id, planned.series_id, planned.plugin.name, err + ); + } + } + + if let Some(broadcaster) = event_broadcaster { + let current = idx + 1; + let _ = broadcaster.emit_task(TaskProgressEvent::progress( + task.id, + "refresh_library_metadata", + current, + total_planned, + Some(format!( + "Refreshing {} ({}/{}, {} succeeded, {} failed)", + library_name, current, total_planned, summary.succeeded, summary.failed + )), + Some(job.library_id), + Some(planned.series_id), + None, + )); + } + } + + let total_skipped = summary.skipped_no_external_id + + summary.skipped_recently_synced + + summary.skipped_no_match_candidate; + + let message = format!( + "Refreshed {} of {} pair(s) ({} succeeded, {} failed, {} skipped)", + summary.succeeded, total_planned, summary.succeeded, summary.failed, total_skipped, + ); + + let final_status = if summary.failed > 0 && summary.succeeded == 0 { + RecordRunStatus::Failure + } else { + RecordRunStatus::Success + }; + let _ = + LibraryJobRepository::record_run(db, job.id, final_status, Some(message.clone())) + .await; + + Ok(TaskResult::success_with_data( + message, + summary.into_json(total_planned, None), + )) + }) + } +} + +/// Fine-grained outcome for a single `(series, provider)` pair. +enum PairError { + NoExternalId, + NoMatchCandidate, + Failed(anyhow::Error), +} + +#[allow(clippy::too_many_arguments)] +async fn process_pair( + db: &DatabaseConnection, + library_id: uuid::Uuid, + planned: &PlannedRefresh, + fields_filter: Option<&std::collections::HashSet>, + thumbnail_service: Option<&Arc>, + event_broadcaster: Option<&Arc>, + plugin_manager: &PluginManager, + matching_strategy: MatchingStrategy, +) -> Result { + let plugin = &planned.plugin; + + let external_id = if let Some(record) = planned.existing_external_id.as_ref() { + record.external_id.clone() + } else { + match matching_strategy { + MatchingStrategy::ExistingExternalIdOnly => return Err(PairError::NoExternalId), + MatchingStrategy::AllowReMatch => { + rematch_external_id(db, planned, plugin_manager).await? + } + } + }; + + let get_params = MetadataGetParams { + external_id: external_id.clone(), + }; + let metadata_fut = plugin_manager.get_series_metadata(plugin.id, get_params); + let plugin_metadata = match tokio::time::timeout(PER_PAIR_TIMEOUT, metadata_fut).await { + Ok(Ok(m)) => m, + Ok(Err(e)) => { + return Err(PairError::Failed(anyhow::Error::new(e).context(format!( + "Plugin '{}' failed to fetch metadata for external_id {}", + plugin.name, external_id + )))); + } + Err(_elapsed) => { + return Err(PairError::Failed(anyhow::anyhow!( + "Plugin '{}' timed out after {}s fetching external_id {}", + plugin.name, + PER_PAIR_TIMEOUT.as_secs(), + external_id + ))); + } + }; + + let current_metadata = SeriesMetadataRepository::get_by_series_id(db, planned.series_id) + .await + .map_err(|e| PairError::Failed(e.context("Failed to load current metadata")))?; + + let _ = matching_strategy; + let options = ApplyOptions { + fields_filter: fields_filter.cloned(), + thumbnail_service: thumbnail_service.cloned(), + event_broadcaster: event_broadcaster.cloned(), + dry_run: false, + }; + + let apply_result = MetadataApplier::apply( + db, + planned.series_id, + library_id, + plugin, + &plugin_metadata, + current_metadata.as_ref(), + &options, + ) + .await + .map_err(|e| { + PairError::Failed(e.context(format!( + "Failed to apply metadata to series {}", + planned.series_id + ))) + })?; + + let applied_count = apply_result.applied_fields.len(); + + let external_url = plugin_metadata.external_url.clone(); + if let Err(e) = SeriesExternalIdRepository::upsert_for_plugin( + db, + planned.series_id, + &plugin.name, + &external_id, + Some(&external_url), + None, + ) + .await + { + warn!( + "Failed to refresh last_synced_at for series {} / plugin {}: {:#}", + planned.series_id, plugin.name, e + ); + } + + if applied_count > 0 + && let Some(broadcaster) = event_broadcaster + { + let _ = broadcaster.emit(EntityChangeEvent::new( + EntityEvent::SeriesMetadataUpdated { + series_id: planned.series_id, + library_id, + plugin_id: plugin.id, + fields_updated: apply_result.applied_fields.clone(), + }, + None, + )); + } + + if let Err(e) = PluginsRepository::record_success(db, plugin.id).await { + debug!("Plugin success record skipped: {:#}", e); + } + + Ok(applied_count) +} + +async fn rematch_external_id( + db: &DatabaseConnection, + planned: &PlannedRefresh, + plugin_manager: &PluginManager, +) -> Result { + let plugin = &planned.plugin; + + let series = SeriesRepository::get_by_id(db, planned.series_id) + .await + .map_err(|e| { + PairError::Failed(e.context(format!( + "Failed to load series {} for re-match", + planned.series_id + ))) + })? + .ok_or_else(|| { + PairError::Failed(anyhow::anyhow!( + "Series {} disappeared during refresh", + planned.series_id + )) + })?; + + let metadata = SeriesMetadataRepository::get_by_series_id(db, planned.series_id) + .await + .map_err(|e| { + PairError::Failed(e.context("Failed to load current metadata for re-match")) + })?; + + let title = metadata + .as_ref() + .map(|m| m.title.clone()) + .filter(|t| !t.is_empty()) + .unwrap_or_else(|| series.name.clone()); + let year = metadata.as_ref().and_then(|m| m.year); + + let match_params = MetadataMatchParams { + title, + year, + author: None, + }; + + let match_fut = plugin_manager.match_series(plugin.id, match_params); + let match_result = match tokio::time::timeout(PER_PAIR_TIMEOUT, match_fut).await { + Ok(Ok(r)) => r, + Ok(Err(e)) => { + return Err(PairError::Failed(anyhow::Error::new(e).context(format!( + "Plugin '{}' failed to re-match series {}", + plugin.name, planned.series_id + )))); + } + Err(_elapsed) => { + return Err(PairError::Failed(anyhow::anyhow!( + "Plugin '{}' timed out after {}s re-matching series {}", + plugin.name, + PER_PAIR_TIMEOUT.as_secs(), + planned.series_id + ))); + } + }; + + match match_result { + Some(r) => Ok(r.external_id), + None => Err(PairError::NoMatchCandidate), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::ScanningStrategy; + use crate::db::entities::plugins::PluginPermission; + use crate::db::repositories::{ + CreateLibraryJobParams, LibraryJobRepository, LibraryRepository, PluginsRepository, + SeriesRepository, TaskRepository, + }; + use crate::db::test_helpers::setup_test_db; + use crate::services::library_jobs::{ + LibraryJobConfig, MetadataRefreshJobConfig, RefreshScope, parse_job_config, + }; + use crate::services::plugin::PluginManager; + use crate::services::plugin::protocol::PluginScope; + use crate::tasks::types::TaskType; + use std::env; + use std::sync::Once; + + static INIT_ENCRYPTION: Once = Once::new(); + + fn setup_test_encryption_key() { + INIT_ENCRYPTION.call_once(|| { + if env::var("CODEX_ENCRYPTION_KEY").is_err() { + // SAFETY: tests share env. First-writer-wins is safe. + unsafe { + env::set_var( + "CODEX_ENCRYPTION_KEY", + "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=", + ); + } + } + }); + } + + fn refresh_cfg(provider: &str) -> MetadataRefreshJobConfig { + MetadataRefreshJobConfig { + provider: provider.to_string(), + scope: RefreshScope::SeriesOnly, + field_groups: vec!["ratings".to_string()], + extra_fields: vec![], + book_field_groups: vec![], + book_extra_fields: vec![], + existing_source_ids_only: true, + skip_recently_synced_within_s: 0, + max_concurrency: 4, + } + } + + async fn create_job( + db: &DatabaseConnection, + library_id: uuid::Uuid, + cfg: MetadataRefreshJobConfig, + ) -> uuid::Uuid { + let wrapped = LibraryJobConfig::MetadataRefresh(cfg); + let json = serde_json::to_string(&wrapped).unwrap(); + let row = LibraryJobRepository::create( + db, + CreateLibraryJobParams { + library_id, + job_type: "metadata_refresh".to_string(), + name: "Test Job".to_string(), + enabled: true, + cron_schedule: "0 0 4 * * *".to_string(), + timezone: None, + config: json, + }, + ) + .await + .unwrap(); + row.id + } + + async fn enqueue_and_load(db: &DatabaseConnection, task_type: TaskType) -> tasks::Model { + let id = TaskRepository::enqueue(db, task_type, None).await.unwrap(); + TaskRepository::get_by_id(db, id).await.unwrap().unwrap() + } + + fn make_handler(db: &DatabaseConnection) -> RefreshLibraryMetadataHandler { + let pm = Arc::new(PluginManager::with_defaults(Arc::new(db.clone()))); + RefreshLibraryMetadataHandler::new(pm) + } + + #[test] + fn run_summary_zero_state() { + let json = RunSummary::default().into_json(0, None); + assert_eq!(json["planned"], 0); + assert_eq!(json["succeeded"], 0); + assert_eq!(json["skipped"]["no_external_id"], 0); + assert_eq!(json["skipped"]["recently_synced"], 0); + assert_eq!(json["skipped"]["no_match_candidate"], 0); + assert!(json["plan_failure"].is_null()); + } + + #[test] + fn run_summary_with_plan_failure() { + let s = RunSummary::default(); + let json = s.into_json(0, Some(&PlanFailure::PluginMissing)); + assert_eq!(json["plan_failure"], "plugin_missing"); + } + + #[test] + fn parse_job_config_round_trips_for_handler() { + let cfg = refresh_cfg("plugin:x"); + let wrapped = LibraryJobConfig::MetadataRefresh(cfg.clone()); + let json = serde_json::to_string(&wrapped).unwrap(); + let parsed = parse_job_config("metadata_refresh", &json).unwrap(); + let LibraryJobConfig::MetadataRefresh(out) = parsed; + assert_eq!(out.provider, cfg.provider); + } + + #[tokio::test] + async fn handler_short_circuits_when_provider_missing() { + let db = setup_test_db().await; + let lib = LibraryRepository::create( + &db, + "lib-empty", + "/tmp/lib-empty", + ScanningStrategy::Default, + ) + .await + .unwrap(); + + let cfg = refresh_cfg("plugin:does-not-exist"); + let job_id = create_job(&db, lib.id, cfg).await; + + let task = enqueue_and_load(&db, TaskType::RefreshLibraryMetadata { job_id }).await; + let handler = make_handler(&db); + let result = handler.handle(&task, &db, None).await.unwrap(); + assert!(result.success); + let data = result.data.unwrap(); + assert_eq!(data["planned"], 0); + assert_eq!(data["plan_failure"], "plugin_missing"); + } + + #[tokio::test] + async fn handler_counts_no_external_id_in_strict_mode() { + setup_test_encryption_key(); + let db = setup_test_db().await; + let lib = LibraryRepository::create( + &db, + "lib-strict", + "/tmp/lib-strict", + ScanningStrategy::Default, + ) + .await + .unwrap(); + let _s1 = SeriesRepository::create(&db, lib.id, "S1", None) + .await + .unwrap(); + let _s2 = SeriesRepository::create(&db, lib.id, "S2", None) + .await + .unwrap(); + let _plugin = PluginsRepository::create( + &db, + "mb", + "MangaBaka", + None, + "system", + "node", + vec!["dist/index.js".to_string()], + vec![], + None, + vec![PluginPermission::MetadataWriteSummary], + vec![PluginScope::SeriesDetail], + vec![], + None, + "env", + None, + true, + None, + None, + ) + .await + .unwrap(); + + let cfg = refresh_cfg("plugin:mb"); + let job_id = create_job(&db, lib.id, cfg).await; + let task = enqueue_and_load(&db, TaskType::RefreshLibraryMetadata { job_id }).await; + let handler = make_handler(&db); + let result = handler.handle(&task, &db, None).await.unwrap(); + assert!(result.success); + let data = result.data.unwrap(); + assert_eq!(data["planned"], 0); + assert_eq!(data["skipped"]["no_external_id"], 2); + } + + #[tokio::test] + async fn handler_records_failure_when_job_missing() { + let db = setup_test_db().await; + let task = enqueue_and_load( + &db, + TaskType::RefreshLibraryMetadata { + job_id: uuid::Uuid::new_v4(), + }, + ) + .await; + let handler = make_handler(&db); + let err = handler.handle(&task, &db, None).await.unwrap_err(); + assert!(err.to_string().contains("Library job not found")); + } +} diff --git a/src/tasks/types.rs b/src/tasks/types.rs index 6dc2c497..6e38bf47 100644 --- a/src/tasks/types.rs +++ b/src/tasks/types.rs @@ -47,6 +47,16 @@ pub enum TaskType { source: String, // "comicvine", "openlibrary", etc. }, + /// Scheduled per-job metadata refresh. + /// + /// Loads the [`library_jobs`] row by `job_id`, decodes its config (single + /// provider + field groups + safety options), walks the library's series, + /// and refreshes metadata via the existing `MetadataApplier`. + RefreshLibraryMetadata { + #[serde(rename = "jobId")] + job_id: Uuid, + }, + /// Generate thumbnails for books in a scope (library, series, specific books, or all) /// This is a fan-out task that enqueues individual GenerateThumbnail tasks GenerateThumbnails { @@ -233,6 +243,7 @@ impl TaskType { // Metadata TaskType::FindDuplicates => 400, TaskType::RefreshMetadata { .. } => 390, + TaskType::RefreshLibraryMetadata { .. } => 385, TaskType::PluginAutoMatch { .. } => 380, // Export TaskType::ExportSeries { .. } => 450, @@ -258,6 +269,7 @@ impl TaskType { TaskType::AnalyzeSeries { .. } => "analyze_series", TaskType::PurgeDeleted { .. } => "purge_deleted", TaskType::RefreshMetadata { .. } => "refresh_metadata", + TaskType::RefreshLibraryMetadata { .. } => "refresh_library_metadata", TaskType::GenerateThumbnails { .. } => "generate_thumbnails", TaskType::GenerateThumbnail { .. } => "generate_thumbnail", TaskType::GenerateSeriesThumbnail { .. } => "generate_series_thumbnail", @@ -283,7 +295,12 @@ impl TaskType { } } - /// Extract library_id if present + /// Extract library_id if present. + /// + /// `RefreshLibraryMetadata` carries `job_id` rather than `library_id`; the + /// library is resolved at run time from the job row. The library scope is + /// reflected by `enqueue_filter_library_id` on enqueue; this helper + /// returns `None` for that variant. pub fn library_id(&self) -> Option { match self { TaskType::ScanLibrary { library_id, .. } => Some(*library_id), @@ -295,6 +312,15 @@ impl TaskType { } } + /// Extract the library job ID for tasks scoped to a single + /// [`library_jobs`] row, if any. + pub fn job_id(&self) -> Option { + match self { + TaskType::RefreshLibraryMetadata { job_id } => Some(*job_id), + _ => None, + } + } + /// Get task-specific parameters as JSON pub fn params(&self) -> serde_json::Value { match self { @@ -310,6 +336,11 @@ impl TaskType { TaskType::RefreshMetadata { source, .. } => { serde_json::json!({ "source": source }) } + TaskType::RefreshLibraryMetadata { job_id } => { + // job_id is stored in params (no FK column on tasks). + // The handler resolves the library from the job row at run time. + serde_json::json!({ "job_id": job_id }) + } TaskType::GenerateThumbnails { force, book_ids, @@ -1006,6 +1037,45 @@ mod tests { assert!(params["series_ids"].is_null()); } + #[test] + fn test_refresh_library_metadata_extraction() { + let job_id = Uuid::new_v4(); + let task = TaskType::RefreshLibraryMetadata { job_id }; + + assert_eq!(task.type_string(), "refresh_library_metadata"); + // RefreshLibraryMetadata is scoped by job_id; library is resolved at runtime. + assert_eq!(task.library_id(), None); + assert_eq!(task.job_id(), Some(job_id)); + assert_eq!(task.series_id(), None); + assert_eq!(task.book_id(), None); + assert_eq!(task.default_priority(), 385); + + let (type_str, lib_id, series_id, book_id, params) = task.extract_fields(); + assert_eq!(type_str, "refresh_library_metadata"); + assert!(lib_id.is_none()); + assert!(series_id.is_none()); + assert!(book_id.is_none()); + // job_id is part of the params payload (no dedicated FK column on tasks) + let params = params.expect("expected job_id params"); + assert_eq!(params["job_id"], serde_json::json!(job_id)); + } + + #[test] + fn test_refresh_library_metadata_serialization() { + let job_id = Uuid::new_v4(); + let task = TaskType::RefreshLibraryMetadata { job_id }; + + let json = serde_json::to_string(&task).unwrap(); + assert!(json.contains("refresh_library_metadata")); + assert!(json.contains(&job_id.to_string())); + // jobId is the camelCase rename for the new variant. + assert!(json.contains("jobId")); + + let deserialized: TaskType = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.type_string(), "refresh_library_metadata"); + assert_eq!(deserialized.job_id(), Some(job_id)); + } + #[test] fn test_default_priority_values() { let library_id = Uuid::new_v4(); diff --git a/src/tasks/worker.rs b/src/tasks/worker.rs index dbc2583f..cfb09f1c 100644 --- a/src/tasks/worker.rs +++ b/src/tasks/worker.rs @@ -31,8 +31,8 @@ use crate::tasks::handlers::{ CleanupSeriesFilesHandler, ExportSeriesHandler, FindDuplicatesHandler, GenerateSeriesThumbnailHandler, GenerateSeriesThumbnailsHandler, GenerateThumbnailHandler, GenerateThumbnailsHandler, PluginAutoMatchHandler, PurgeDeletedHandler, - RenumberSeriesBatchHandler, RenumberSeriesHandler, ReprocessSeriesTitleHandler, - ReprocessSeriesTitlesHandler, ScanLibraryHandler, TaskHandler, + RefreshLibraryMetadataHandler, RenumberSeriesBatchHandler, RenumberSeriesHandler, + ReprocessSeriesTitleHandler, ReprocessSeriesTitlesHandler, ScanLibraryHandler, TaskHandler, UserPluginRecommendationDismissHandler, UserPluginRecommendationsHandler, UserPluginSyncHandler, }; @@ -232,6 +232,18 @@ impl TaskWorker { } self.handlers .insert("plugin_auto_match".to_string(), Arc::new(handler)); + // Register the scheduled per-library metadata refresh handler. + // It depends on PluginManager (to call get_series_metadata) and + // optionally ThumbnailService (for cover-field updates via the + // shared MetadataApplier). + let mut refresh_handler = RefreshLibraryMetadataHandler::new(plugin_manager.clone()); + if let Some(ref thumbnail_service) = self.thumbnail_service { + refresh_handler = refresh_handler.with_thumbnail_service(thumbnail_service.clone()); + } + self.handlers.insert( + "refresh_library_metadata".to_string(), + Arc::new(refresh_handler), + ); // Register user plugin sync handler (with settings service for configurable timeout) let mut sync_handler = UserPluginSyncHandler::new(plugin_manager.clone()); if let Some(ref settings_service) = self.settings_service { diff --git a/tests/api.rs b/tests/api.rs index a9238fad..73b81c0a 100644 --- a/tests/api.rs +++ b/tests/api.rs @@ -22,6 +22,7 @@ mod api { mod komga; mod koreader; mod libraries; + mod library_jobs; mod metadata_locks; mod metadata_reset; mod metrics; diff --git a/tests/api/library_jobs.rs b/tests/api/library_jobs.rs new file mode 100644 index 00000000..3ce29585 --- /dev/null +++ b/tests/api/library_jobs.rs @@ -0,0 +1,210 @@ +// Library jobs API integration tests (Phase 9). + +#![allow(unused_variables)] + +#[path = "../common/mod.rs"] +mod common; + +use codex::db::ScanningStrategy; +use codex::db::repositories::{LibraryRepository, UserRepository}; +use codex::utils::password; +use common::*; +use hyper::StatusCode; + +async fn create_admin_token( + db: &sea_orm::DatabaseConnection, + state: &codex::api::extractors::AuthState, +) -> String { + let password_hash = password::hash_password("admin123").unwrap(); + let user = create_test_user("admin", "admin@example.com", &password_hash, true); + let created = UserRepository::create(db, &user).await.unwrap(); + state + .jwt_service + .generate_token(created.id, created.username.clone(), created.get_role()) + .unwrap() +} + +#[tokio::test] +async fn list_jobs_empty_library() { + let (db, _temp) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let token = create_admin_token(&db, &state).await; + let lib = LibraryRepository::create(&db, "L", "/tmp/L", ScanningStrategy::Default) + .await + .unwrap(); + + let app = create_test_router(state).await; + let req = get_request_with_auth(&format!("/api/v1/libraries/{}/jobs", lib.id), &token); + let (status, body) = make_json_request::(app, req).await; + let body = body.unwrap_or_default(); + assert_eq!(status, StatusCode::OK); + assert_eq!(body["jobs"].as_array().unwrap().len(), 0); +} + +#[tokio::test] +async fn list_jobs_unknown_library_404() { + let (db, _temp) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let token = create_admin_token(&db, &state).await; + + let app = create_test_router(state).await; + let req = get_request_with_auth( + "/api/v1/libraries/00000000-0000-0000-0000-000000000000/jobs", + &token, + ); + let (status, _body) = make_json_request::(app, req).await; + assert_eq!(status, StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn list_jobs_unauthenticated_401() { + let (db, _temp) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let lib = LibraryRepository::create(&db, "L", "/tmp/L", ScanningStrategy::Default) + .await + .unwrap(); + + let app = create_test_router(state).await; + let req = get_request(&format!("/api/v1/libraries/{}/jobs", lib.id)); + let (status, _body) = make_request(app, req).await; + assert_eq!(status, StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn create_job_with_unknown_provider_returns_400() { + let (db, _temp) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let token = create_admin_token(&db, &state).await; + let lib = LibraryRepository::create(&db, "L", "/tmp/L", ScanningStrategy::Default) + .await + .unwrap(); + + let body = serde_json::json!({ + "name": "Test Job", + "enabled": false, + "cronSchedule": "0 0 4 * * *", + "config": { + "type": "metadata_refresh", + "provider": "plugin:nope", + "scope": "series_only", + "fieldGroups": ["ratings"], + "extraFields": [], + "bookFieldGroups": [], + "bookExtraFields": [], + "existingSourceIdsOnly": true, + "skipRecentlySyncedWithinS": 3600, + "maxConcurrency": 4, + } + }); + + let app = create_test_router(state).await; + let req = post_request_with_auth_json( + &format!("/api/v1/libraries/{}/jobs", lib.id), + &token, + &body.to_string(), + ); + let (status, body) = make_json_request::(app, req).await; + let body = body.unwrap_or_default(); + assert_eq!(status, StatusCode::BAD_REQUEST); + let msg = body["message"].as_str().unwrap_or_default(); + assert!(msg.contains("not installed"), "got: {msg}"); +} + +#[tokio::test] +async fn create_job_rejects_books_only_scope() { + let (db, _temp) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let token = create_admin_token(&db, &state).await; + let lib = LibraryRepository::create(&db, "L", "/tmp/L", ScanningStrategy::Default) + .await + .unwrap(); + + let body = serde_json::json!({ + "name": "Books Job", + "enabled": false, + "cronSchedule": "0 0 4 * * *", + "config": { + "type": "metadata_refresh", + "provider": "plugin:nope", + "scope": "books_only", + "fieldGroups": [], + "extraFields": [], + "bookFieldGroups": ["ratings"], + "bookExtraFields": [], + "existingSourceIdsOnly": true, + "skipRecentlySyncedWithinS": 3600, + "maxConcurrency": 4, + } + }); + + let app = create_test_router(state).await; + let req = post_request_with_auth_json( + &format!("/api/v1/libraries/{}/jobs", lib.id), + &token, + &body.to_string(), + ); + let (status, body) = make_json_request::(app, req).await; + let body = body.unwrap_or_default(); + assert_eq!(status, StatusCode::BAD_REQUEST); + let msg = body["message"].as_str().unwrap_or_default(); + assert!(msg.contains("not yet implemented"), "got: {msg}"); +} + +#[tokio::test] +async fn create_job_rejects_invalid_cron() { + let (db, _temp) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let token = create_admin_token(&db, &state).await; + let lib = LibraryRepository::create(&db, "L", "/tmp/L", ScanningStrategy::Default) + .await + .unwrap(); + + let body = serde_json::json!({ + "name": "Bad cron", + "enabled": false, + "cronSchedule": "not a cron", + "config": { + "type": "metadata_refresh", + "provider": "plugin:any", + "scope": "series_only", + } + }); + + let app = create_test_router(state).await; + let req = post_request_with_auth_json( + &format!("/api/v1/libraries/{}/jobs", lib.id), + &token, + &body.to_string(), + ); + let (status, _body) = make_json_request::(app, req).await; + assert_eq!(status, StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn field_groups_catalog_returns_known_groups() { + let (db, _temp) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let token = create_admin_token(&db, &state).await; + + let app = create_test_router(state).await; + let req = get_request_with_auth("/api/v1/library-jobs/metadata-refresh/field-groups", &token); + let (status, body) = make_json_request::(app, req).await; + let body = body.unwrap_or_default(); + assert_eq!(status, StatusCode::OK); + let groups = body.as_array().unwrap(); + assert!( + groups.len() >= 12, + "expected 12 groups, got {}", + groups.len() + ); + // Spot check the contract: ratings group includes both rating + externalRatings + let ratings = groups.iter().find(|g| g["id"] == "ratings").unwrap(); + let fields: Vec<&str> = ratings["fields"] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_str().unwrap()) + .collect(); + assert!(fields.contains(&"rating")); + assert!(fields.contains(&"externalRatings")); +} diff --git a/tests/api/plugins.rs b/tests/api/plugins.rs index 1fa4a0f7..c9925e14 100644 --- a/tests/api/plugins.rs +++ b/tests/api/plugins.rs @@ -1778,6 +1778,37 @@ async fn test_preview_book_metadata_requires_auth() { assert_eq!(status, StatusCode::UNAUTHORIZED); } +#[tokio::test] +async fn test_apply_book_metadata_rejects_dry_run() { + // Book apply does not (yet) support dry-run. The handler must reject + // the request with 400 rather than silently performing a real apply. + let (db, _temp_dir) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let app = create_test_router(state.clone()).await; + let token = create_admin_and_token(&db, &state).await; + + let fake_book_id = uuid::Uuid::new_v4(); + let fake_plugin_id = uuid::Uuid::new_v4(); + let body = json!({ + "pluginId": fake_plugin_id.to_string(), + "externalId": "12345", + "dryRun": true, + }); + let request = post_json_request_with_auth( + &format!("/api/v1/books/{}/metadata/apply", fake_book_id), + &body, + &token, + ); + let (status, _): (StatusCode, Option) = + make_json_request(app, request).await; + + assert_eq!( + status, + StatusCode::BAD_REQUEST, + "book apply must reject dryRun=true" + ); +} + #[tokio::test] async fn test_apply_book_metadata_requires_auth() { let (db, _temp_dir) = setup_test_db().await; diff --git a/tests/services.rs b/tests/services.rs index 854dde56..bd90fa23 100644 --- a/tests/services.rs +++ b/tests/services.rs @@ -1,4 +1,5 @@ mod services { + mod apply_dry_run; mod book_metadata_apply; mod metadata_apply; } diff --git a/tests/services/apply_dry_run.rs b/tests/services/apply_dry_run.rs new file mode 100644 index 00000000..ae8531d4 --- /dev/null +++ b/tests/services/apply_dry_run.rs @@ -0,0 +1,422 @@ +//! Tests for `MetadataApplier::apply` with `dry_run = true`. +//! +//! Verifies that: +//! - DB writes are gated (row state unchanged after a dry-run apply). +//! - The returned `dry_run_report` enumerates the would-be changes. +//! - Lock and permission skips still surface in `skipped_fields`, same code +//! path as a real apply. +//! - The report matches what a real apply *would* have written for the +//! selected fields. + +#[path = "../common/mod.rs"] +mod common; + +use chrono::Utc; +use codex::db::ScanningStrategy; +use codex::db::entities::plugins; +use codex::db::entities::series_metadata; +use codex::db::repositories::{LibraryRepository, SeriesMetadataRepository, SeriesRepository}; +use codex::services::metadata::{ApplyOptions, MetadataApplier}; +use codex::services::plugin::PluginSeriesMetadata; +use common::db::setup_test_db; +use sea_orm::{ActiveModelTrait, Set}; +use serde_json::json; +use std::collections::HashSet; +use uuid::Uuid; + +fn create_test_plugin() -> plugins::Model { + plugins::Model { + id: Uuid::new_v4(), + name: "test-plugin".to_string(), + display_name: "Test Plugin".to_string(), + description: None, + plugin_type: "system".to_string(), + command: "node".to_string(), + args: json!([]), + env: json!({}), + working_directory: None, + // Broad permission set so individual tests can pick & choose fields + // without per-field permission noise. Field locks are still tested + // explicitly. + permissions: json!([ + "metadata:write:title", + "metadata:write:summary", + "metadata:write:status", + "metadata:write:total_volume_count", + "metadata:write:total_chapter_count", + "metadata:write:ratings", + "metadata:write:year", + ]), + scopes: json!(["series:detail"]), + library_ids: json!([]), + credentials: None, + credential_delivery: "env".to_string(), + config: json!({}), + manifest: None, + enabled: true, + health_status: "healthy".to_string(), + failure_count: 0, + last_failure_at: None, + last_success_at: None, + disabled_reason: None, + rate_limit_requests_per_minute: None, + search_query_template: None, + search_preprocessing_rules: None, + auto_match_conditions: None, + use_existing_external_id: true, + metadata_targets: None, + internal_config: None, + created_at: Utc::now(), + updated_at: Utc::now(), + created_by: None, + updated_by: None, + } +} + +fn empty_metadata() -> PluginSeriesMetadata { + PluginSeriesMetadata { + external_id: "test-123".to_string(), + external_url: "https://example.com/test-123".to_string(), + title: None, + alternate_titles: vec![], + summary: None, + status: None, + year: None, + total_volume_count: None, + total_chapter_count: None, + language: None, + age_rating: None, + reading_direction: None, + genres: vec![], + tags: vec![], + authors: vec![], + artists: vec![], + publisher: None, + cover_url: None, + banner_url: None, + rating: None, + external_ratings: vec![], + external_links: vec![], + external_ids: vec![], + } +} + +#[tokio::test] +async fn dry_run_does_not_write_to_database() { + let (db, _temp_dir) = setup_test_db().await; + + let library = + LibraryRepository::create(&db, "Test Library", "/test/path", ScanningStrategy::Default) + .await + .unwrap(); + let series = SeriesRepository::create(&db, library.id, "Original Title", None) + .await + .unwrap(); + + let original = SeriesMetadataRepository::get_by_series_id(&db, series.id) + .await + .unwrap() + .unwrap(); + + // Plugin payload changes title, summary, year, and status. + let plugin = create_test_plugin(); + let mut plugin_metadata = empty_metadata(); + plugin_metadata.title = Some("Plugin Title".to_string()); + plugin_metadata.summary = Some("Plugin summary".to_string()); + plugin_metadata.year = Some(2024); + plugin_metadata.status = Some(codex::db::entities::SeriesStatus::Ongoing); + + let options = ApplyOptions { + dry_run: true, + ..Default::default() + }; + + let result = MetadataApplier::apply( + &db, + series.id, + library.id, + &plugin, + &plugin_metadata, + Some(&original), + &options, + ) + .await + .unwrap(); + + // applied_fields tracks what *would* have been applied even in dry-run, + // because the dry-run still walks the same branches. + assert!(result.applied_fields.contains(&"title".to_string())); + assert!(result.applied_fields.contains(&"summary".to_string())); + assert!(result.applied_fields.contains(&"year".to_string())); + assert!(result.applied_fields.contains(&"status".to_string())); + + // Report contents. + let report = result.dry_run_report.expect("dry_run set ⇒ report present"); + let fields: HashSet<&str> = report.changes.iter().map(|c| c.field.as_str()).collect(); + assert!(fields.contains("title")); + assert!(fields.contains("summary")); + assert!(fields.contains("year")); + assert!(fields.contains("status")); + + // No DB write happened: the row matches the original. + let after = SeriesMetadataRepository::get_by_series_id(&db, series.id) + .await + .unwrap() + .unwrap(); + assert_eq!(after.title, original.title); + assert_eq!(after.summary, original.summary); + assert_eq!(after.year, original.year); + assert_eq!(after.status, original.status); +} + +#[tokio::test] +async fn dry_run_real_apply_returns_no_report() { + let (db, _temp_dir) = setup_test_db().await; + + let library = + LibraryRepository::create(&db, "Test Library", "/test/path", ScanningStrategy::Default) + .await + .unwrap(); + let series = SeriesRepository::create(&db, library.id, "Original Title", None) + .await + .unwrap(); + + let original = SeriesMetadataRepository::get_by_series_id(&db, series.id) + .await + .unwrap() + .unwrap(); + + let plugin = create_test_plugin(); + let mut plugin_metadata = empty_metadata(); + plugin_metadata.summary = Some("Plugin summary".to_string()); + + let options = ApplyOptions::default(); // dry_run defaults to false + + let result = MetadataApplier::apply( + &db, + series.id, + library.id, + &plugin, + &plugin_metadata, + Some(&original), + &options, + ) + .await + .unwrap(); + + assert!(result.dry_run_report.is_none(), "real apply ⇒ no report"); + + // And the write actually happened. + let after = SeriesMetadataRepository::get_by_series_id(&db, series.id) + .await + .unwrap() + .unwrap(); + assert_eq!(after.summary, Some("Plugin summary".to_string())); +} + +#[tokio::test] +async fn dry_run_records_locked_fields_in_skipped_not_changes() { + let (db, _temp_dir) = setup_test_db().await; + + let library = + LibraryRepository::create(&db, "Test Library", "/test/path", ScanningStrategy::Default) + .await + .unwrap(); + let series = SeriesRepository::create(&db, library.id, "Original Title", None) + .await + .unwrap(); + + // Lock summary so the dry-run should skip-with-reason rather than record a change. + let metadata = SeriesMetadataRepository::get_by_series_id(&db, series.id) + .await + .unwrap() + .unwrap(); + let mut active: series_metadata::ActiveModel = metadata.into(); + active.summary = Set(Some("Original summary".to_string())); + active.summary_lock = Set(true); + active.update(&db).await.unwrap(); + + let original = SeriesMetadataRepository::get_by_series_id(&db, series.id) + .await + .unwrap() + .unwrap(); + assert!(original.summary_lock); + + let plugin = create_test_plugin(); + let mut plugin_metadata = empty_metadata(); + plugin_metadata.summary = Some("Plugin summary".to_string()); + plugin_metadata.year = Some(2024); + + let options = ApplyOptions { + dry_run: true, + ..Default::default() + }; + + let result = MetadataApplier::apply( + &db, + series.id, + library.id, + &plugin, + &plugin_metadata, + Some(&original), + &options, + ) + .await + .unwrap(); + + // summary should be in skipped_fields (locked), NOT in dry_run_report.changes. + let summary_skipped = result.skipped_fields.iter().any(|s| s.field == "summary"); + assert!( + summary_skipped, + "locked field should appear in skipped_fields" + ); + + let report = result.dry_run_report.expect("dry_run set ⇒ report present"); + let summary_in_report = report.changes.iter().any(|c| c.field == "summary"); + assert!( + !summary_in_report, + "locked field should NOT appear in dry_run_report.changes" + ); + + // year (not locked) should appear in the report. + let year_in_report = report.changes.iter().any(|c| c.field == "year"); + assert!(year_in_report, "unlocked field should appear in report"); + + // DB unchanged for summary either way (locked AND dry-run). + let after = SeriesMetadataRepository::get_by_series_id(&db, series.id) + .await + .unwrap() + .unwrap(); + assert_eq!(after.summary, Some("Original summary".to_string())); +} + +#[tokio::test] +async fn dry_run_filtered_by_fields_filter() { + let (db, _temp_dir) = setup_test_db().await; + + let library = + LibraryRepository::create(&db, "Test Library", "/test/path", ScanningStrategy::Default) + .await + .unwrap(); + let series = SeriesRepository::create(&db, library.id, "Original Title", None) + .await + .unwrap(); + + let original = SeriesMetadataRepository::get_by_series_id(&db, series.id) + .await + .unwrap() + .unwrap(); + + // Plugin returns multiple fields, but the filter only allows summary + year. + let plugin = create_test_plugin(); + let mut plugin_metadata = empty_metadata(); + plugin_metadata.title = Some("Plugin Title".to_string()); + plugin_metadata.summary = Some("Plugin summary".to_string()); + plugin_metadata.year = Some(2024); + plugin_metadata.status = Some(codex::db::entities::SeriesStatus::Ongoing); + + let mut filter = HashSet::new(); + filter.insert("summary".to_string()); + filter.insert("year".to_string()); + + let options = ApplyOptions { + dry_run: true, + fields_filter: Some(filter), + ..Default::default() + }; + + let result = MetadataApplier::apply( + &db, + series.id, + library.id, + &plugin, + &plugin_metadata, + Some(&original), + &options, + ) + .await + .unwrap(); + + let report = result.dry_run_report.expect("dry_run set ⇒ report present"); + let fields: HashSet<&str> = report.changes.iter().map(|c| c.field.as_str()).collect(); + + assert!(fields.contains("summary"), "summary must be in report"); + assert!(fields.contains("year"), "year must be in report"); + assert!( + !fields.contains("title"), + "title is filtered out, must not be in report" + ); + assert!( + !fields.contains("status"), + "status is filtered out, must not be in report" + ); + + // No DB write happened. + let after = SeriesMetadataRepository::get_by_series_id(&db, series.id) + .await + .unwrap() + .unwrap(); + assert_eq!(after.title, original.title); + assert_eq!(after.summary, original.summary); + assert_eq!(after.status, original.status); +} + +#[tokio::test] +async fn dry_run_records_before_and_after_for_simple_field() { + let (db, _temp_dir) = setup_test_db().await; + + let library = + LibraryRepository::create(&db, "Test Library", "/test/path", ScanningStrategy::Default) + .await + .unwrap(); + let series = SeriesRepository::create(&db, library.id, "Original Title", None) + .await + .unwrap(); + + // Set a known summary on the row so we can assert `before`. + let metadata = SeriesMetadataRepository::get_by_series_id(&db, series.id) + .await + .unwrap() + .unwrap(); + let mut active: series_metadata::ActiveModel = metadata.into(); + active.summary = Set(Some("Original summary".to_string())); + active.update(&db).await.unwrap(); + + let original = SeriesMetadataRepository::get_by_series_id(&db, series.id) + .await + .unwrap() + .unwrap(); + + let plugin = create_test_plugin(); + let mut plugin_metadata = empty_metadata(); + plugin_metadata.summary = Some("New summary".to_string()); + + let options = ApplyOptions { + dry_run: true, + ..Default::default() + }; + + let result = MetadataApplier::apply( + &db, + series.id, + library.id, + &plugin, + &plugin_metadata, + Some(&original), + &options, + ) + .await + .unwrap(); + + let report = result.dry_run_report.expect("dry_run ⇒ report present"); + let summary_change = report + .changes + .iter() + .find(|c| c.field == "summary") + .expect("summary in report"); + + // before is wrapped because the column is `Option`. + let before_json = summary_change.before.as_ref().expect("before set"); + assert_eq!(before_json, &json!("Original summary")); + assert_eq!(summary_change.after, json!("New summary")); +} diff --git a/tests/services/metadata_apply.rs b/tests/services/metadata_apply.rs index 61eca513..0e9d4d96 100644 --- a/tests/services/metadata_apply.rs +++ b/tests/services/metadata_apply.rs @@ -704,6 +704,7 @@ async fn test_apply_count_fields_filtered_out_by_allowlist() { fields_filter: Some(filter), thumbnail_service: None, event_broadcaster: None, + dry_run: false, }; let result = MetadataApplier::apply( diff --git a/web/openapi.json b/web/openapi.json index b32c8275..f5d561a9 100644 --- a/web/openapi.json +++ b/web/openapi.json @@ -21078,6 +21078,38 @@ } } }, + "CreateLibraryJobRequest": { + "type": "object", + "description": "Request body for `POST /api/v1/libraries/{id}/jobs`.", + "required": [ + "cronSchedule", + "config" + ], + "properties": { + "config": { + "$ref": "#/components/schemas/LibraryJobConfigDto" + }, + "cronSchedule": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "name": { + "type": [ + "string", + "null" + ], + "description": "Optional user-facing name. Auto-generated when missing or empty." + }, + "timezone": { + "type": [ + "string", + "null" + ] + } + } + }, "CreateLibraryRequest": { "type": "object", "description": "Create library request", @@ -21692,6 +21724,152 @@ } } }, + "DryRunFieldChange": { + "type": "object", + "required": [ + "before", + "after" + ], + "properties": { + "after": {}, + "before": {} + } + }, + "DryRunReportDto": { + "type": "object", + "description": "Dry-run preview attached to [`MetadataApplyResponse`] when the request\nset `dryRun = true`. Absent on real applies.", + "required": [ + "changes" + ], + "properties": { + "changes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FieldChangeDto" + } + } + } + }, + "DryRunRequest": { + "type": "object", + "description": "Request body for `POST .../dry-run`.", + "properties": { + "configOverride": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/LibraryJobConfigDto", + "description": "Override the saved config for this preview only. Must match the\nrow's `type`." + } + ] + }, + "sampleSize": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Sample size, capped at 20 server-side.", + "minimum": 0 + } + } + }, + "DryRunResponse": { + "type": "object", + "required": [ + "totalEligible", + "sample", + "estSkippedNoId", + "estSkippedRecentlySynced" + ], + "properties": { + "estSkippedNoId": { + "type": "integer", + "format": "int32", + "description": "Estimated count of series that would be skipped because they have no\nexternal ID for the chosen provider.", + "minimum": 0 + }, + "estSkippedRecentlySynced": { + "type": "integer", + "format": "int32", + "description": "Estimated count of series that would be skipped because they were\nrecently synced.", + "minimum": 0 + }, + "planFailure": { + "type": [ + "string", + "null" + ], + "description": "Provider resolution failure reason, if any." + }, + "sample": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DryRunSeriesDelta" + }, + "description": "Per-series deltas for the first N eligible series." + }, + "totalEligible": { + "type": "integer", + "format": "int32", + "description": "Total number of series eligible to be refreshed (all of them, not\njust the sample).", + "minimum": 0 + } + } + }, + "DryRunSeriesDelta": { + "type": "object", + "description": "One series's preview of would-be field changes.", + "required": [ + "seriesId", + "seriesName", + "changes", + "skipped" + ], + "properties": { + "changes": { + "type": "object", + "description": "Field name → `(before, after)` JSON values.", + "additionalProperties": { + "$ref": "#/components/schemas/DryRunFieldChange" + }, + "propertyNames": { + "type": "string" + } + }, + "seriesId": { + "type": "string", + "format": "uuid" + }, + "seriesName": { + "type": "string" + }, + "skipped": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DryRunSkippedFieldDto" + }, + "description": "Fields that would have been written but were skipped (locks, all-locked, etc.)" + } + } + }, + "DryRunSkippedFieldDto": { + "type": "object", + "required": [ + "field", + "reason" + ], + "properties": { + "field": { + "type": "string" + }, + "reason": { + "type": "string" + } + } + }, "DuplicateGroup": { "type": "object", "description": "A group of duplicate books", @@ -22834,6 +23012,46 @@ "not_provided" ] }, + "FieldChangeDto": { + "type": "object", + "description": "One would-be field change recorded during a dry-run apply.\n\nMirrors `services::metadata::apply::FieldChange`, kept as a distinct DTO\nto keep the wire-format frozen even if internal types evolve.", + "required": [ + "field", + "after" + ], + "properties": { + "after": {}, + "before": { + "description": "Current value, where cheaply available. `null` for fields backed by\njoined tables (genres, tags, alternate titles, ratings, etc.)." + }, + "field": { + "type": "string" + } + } + }, + "FieldGroupDto": { + "type": "object", + "description": "Static field-group catalog row exposed for the editor UI.", + "required": [ + "id", + "label", + "fields" + ], + "properties": { + "fields": { + "type": "array", + "items": { + "type": "string" + } + }, + "id": { + "type": "string" + }, + "label": { + "type": "string" + } + } + }, "FieldOperator": { "oneOf": [ { @@ -25887,6 +26105,101 @@ } } }, + "LibraryJobConfigDto": { + "oneOf": [ + { + "allOf": [ + { + "$ref": "#/components/schemas/MetadataRefreshJobConfigDto" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "metadata_refresh" + ] + } + } + } + ] + } + ], + "description": "Type-discriminated job config exposed over the wire.\n\nPhase 9 only ships the `metadata_refresh` variant; future job types\nextend the enum." + }, + "LibraryJobDto": { + "type": "object", + "description": "Library job row exposed via GET / list / response.", + "required": [ + "id", + "libraryId", + "name", + "enabled", + "cronSchedule", + "config", + "createdAt", + "updatedAt" + ], + "properties": { + "config": { + "$ref": "#/components/schemas/LibraryJobConfigDto" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "cronSchedule": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "lastRunAt": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "lastRunMessage": { + "type": [ + "string", + "null" + ] + }, + "lastRunStatus": { + "type": [ + "string", + "null" + ] + }, + "libraryId": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "timezone": { + "type": [ + "string", + "null" + ] + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } + }, "LibraryMetricsDto": { "type": "object", "description": "Metrics for a single library", @@ -25973,6 +26286,21 @@ } } }, + "ListLibraryJobsResponse": { + "type": "object", + "description": "Response for `GET /libraries/{id}/jobs`.", + "required": [ + "jobs" + ], + "properties": { + "jobs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LibraryJobDto" + } + } + } + }, "ListSettingsQuery": { "type": "object", "description": "Query parameters for listing settings", @@ -26091,6 +26419,10 @@ "externalId" ], "properties": { + "dryRun": { + "type": "boolean", + "description": "When `true`, the call simulates the apply without writing to the\ndatabase. Returns the same `appliedFields`/`skippedFields` plus an\nextra `dryRunReport` showing every would-be change. Default `false`." + }, "externalId": { "type": "string", "description": "External ID from the plugin's search results" @@ -26129,6 +26461,17 @@ }, "description": "Fields that were applied" }, + "dryRunReport": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/DryRunReportDto", + "description": "Populated only when the request set `dryRun = true`. Each entry is a\nfield that *would* have been written." + } + ] + }, "message": { "type": "string", "description": "Message" @@ -26663,6 +27006,67 @@ } } }, + "MetadataRefreshJobConfigDto": { + "type": "object", + "description": "Wire shape for the metadata-refresh job config.", + "required": [ + "provider" + ], + "properties": { + "bookExtraFields": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Reserved for the book-scope future work." + }, + "bookFieldGroups": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Reserved for the book-scope future work." + }, + "existingSourceIdsOnly": { + "type": "boolean", + "description": "When true, the planner skips series with no stored external ID." + }, + "extraFields": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Series-side individual field overrides (camelCase)." + }, + "fieldGroups": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Series-side field groups (snake_case identifiers)." + }, + "maxConcurrency": { + "type": "integer", + "format": "int32", + "description": "Per-task fan-out; clamped at run time.", + "minimum": 0 + }, + "provider": { + "type": "string", + "description": "Plugin reference, e.g. `\"plugin:mangabaka\"`." + }, + "scope": { + "$ref": "#/components/schemas/RefreshScope", + "description": "Refresh scope. Phase 9 only honours `series_only` at runtime." + }, + "skipRecentlySyncedWithinS": { + "type": "integer", + "format": "int32", + "description": "Skip series whose `last_synced_at` is younger than this many seconds.", + "minimum": 0 + } + } + }, "MetricsCleanupResponse": { "type": "object", "description": "Response for cleanup operation", @@ -28797,6 +29201,47 @@ } } }, + "PatchLibraryJobRequest": { + "type": "object", + "description": "Request body for `PATCH /api/v1/libraries/{id}/jobs/{job_id}`.\n\nAll fields are optional. Top-level fields use [`PatchValue`] when their\nunderlying type is `Option<...>` so an explicit `null` clears the value\ndistinct from \"not present\".", + "properties": { + "config": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/LibraryJobConfigDto", + "description": "Replaces the type-specific config wholesale; the type discriminator\nmust match the existing row's type." + } + ] + }, + "cronSchedule": { + "type": [ + "string", + "null" + ] + }, + "enabled": { + "type": [ + "boolean", + "null" + ] + }, + "name": { + "type": [ + "string", + "null" + ] + }, + "timezone": { + "type": [ + "string", + "null" + ] + } + } + }, "PatchSeriesMetadataRequest": { "type": "object", "description": "PATCH request for partial update of series metadata\n\nOnly provided fields will be updated. Absent fields are unchanged.\nExplicitly null fields will be cleared.", @@ -29039,6 +29484,17 @@ "type": "string", "description": "Action type (e.g., \"metadata_search\", \"metadata_get\")" }, + "capabilities": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/PluginCapabilitiesDto", + "description": "Capabilities the plugin advertises in its manifest. The library-jobs\neditor uses this to decide which scope options are available for the\nchosen provider. The `metadata_provider` array contains `\"series\"`\nand/or `\"book\"` entries." + } + ] + }, "description": { "type": [ "string", @@ -30963,6 +31419,15 @@ } } }, + "RefreshScope": { + "type": "string", + "description": "Scope of a metadata refresh job.\n\nPhase 9 only honours [`RefreshScope::SeriesOnly`] at runtime. The\nother variants are schema-accepted but rejected by the validator.", + "enum": [ + "series_only", + "books_only", + "series_and_books" + ] + }, "RegisterRequest": { "type": "object", "description": "Register request", @@ -31717,6 +32182,19 @@ } } }, + "RunNowResponse": { + "type": "object", + "description": "Response for `POST .../run-now`.", + "required": [ + "taskId" + ], + "properties": { + "taskId": { + "type": "string", + "format": "uuid" + } + } + }, "ScanStatusDto": { "type": "object", "description": "Scan status response", @@ -34363,6 +34841,26 @@ } } }, + { + "type": "object", + "description": "Scheduled per-job metadata refresh.\n\nLoads the [`library_jobs`] row by `job_id`, decodes its config (single\nprovider + field groups + safety options), walks the library's series,\nand refreshes metadata via the existing `MetadataApplier`.", + "required": [ + "jobId", + "type" + ], + "properties": { + "jobId": { + "type": "string", + "format": "uuid" + }, + "type": { + "type": "string", + "enum": [ + "refresh_library_metadata" + ] + } + } + }, { "type": "object", "description": "Generate thumbnails for books in a scope (library, series, specific books, or all)\nThis is a fan-out task that enqueues individual GenerateThumbnail tasks", @@ -36900,6 +37398,10 @@ "name": "Plugin Actions", "description": "Plugin action discovery and execution for metadata fetching" }, + { + "name": "Library Jobs", + "description": "Per-library scheduled jobs (metadata refresh today; future: scan, cleanup). Supports CRUD, run-now, and dry-run preview." + }, { "name": "User Plugins", "description": "User-facing plugin management, OAuth, and configuration" diff --git a/web/src/App.tsx b/web/src/App.tsx index ba319d93..a50c9cbb 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -16,6 +16,7 @@ import { useEntityEvents } from "@/hooks/useEntityEvents"; import { BookDetail } from "@/pages/BookDetail"; import { Home } from "@/pages/Home"; import { LibraryPage } from "@/pages/Library"; +import { LibraryJobsPage } from "@/pages/LibraryJobs"; import { Login } from "@/pages/Login"; import { OidcComplete } from "@/pages/OidcComplete"; import { Reader } from "@/pages/Reader"; @@ -196,6 +197,17 @@ function App() { } /> + + + + + + } + /> + => { + const response = await api.get<{ jobs: LibraryJob[] }>( + `/libraries/${libraryId}/jobs`, + ); + return response.data.jobs; + }, + + get: async (libraryId: string, jobId: string): Promise => { + const response = await api.get( + `/libraries/${libraryId}/jobs/${jobId}`, + ); + return response.data; + }, + + create: async ( + libraryId: string, + body: CreateLibraryJobRequest, + ): Promise => { + const response = await api.post( + `/libraries/${libraryId}/jobs`, + body, + ); + return response.data; + }, + + update: async ( + libraryId: string, + jobId: string, + body: PatchLibraryJobInput, + ): Promise => { + const response = await api.patch( + `/libraries/${libraryId}/jobs/${jobId}`, + body, + ); + return response.data; + }, + + delete: async (libraryId: string, jobId: string): Promise => { + await api.delete(`/libraries/${libraryId}/jobs/${jobId}`); + }, + + runNow: async ( + libraryId: string, + jobId: string, + ): Promise<{ taskId: string }> => { + const response = await api.post<{ taskId: string }>( + `/libraries/${libraryId}/jobs/${jobId}/run-now`, + ); + return response.data; + }, + + dryRun: async ( + libraryId: string, + jobId: string, + body: DryRunRequest = {}, + ): Promise => { + const response = await api.post( + `/libraries/${libraryId}/jobs/${jobId}/dry-run`, + body, + ); + return response.data; + }, + + fieldGroups: async (): Promise => { + const response = await api.get( + `/library-jobs/metadata-refresh/field-groups`, + ); + return response.data; + }, +}; diff --git a/web/src/components/forms/LibraryModal.tsx b/web/src/components/forms/LibraryModal.tsx index 2718dc0f..1c03cbc6 100644 --- a/web/src/components/forms/LibraryModal.tsx +++ b/web/src/components/forms/LibraryModal.tsx @@ -882,7 +882,7 @@ export function LibraryModal({ opened, onClose, library }: LibraryModalProps) { opened={opened} onClose={handleClose} title={modalTitle} - size="lg" + size="xl" centered zIndex={1000} overlayProps={{ diff --git a/web/src/components/library-jobs/JobDryRunModal.tsx b/web/src/components/library-jobs/JobDryRunModal.tsx new file mode 100644 index 00000000..46493832 --- /dev/null +++ b/web/src/components/library-jobs/JobDryRunModal.tsx @@ -0,0 +1,81 @@ +import { + Alert, + Badge, + Card, + Code, + Group, + Modal, + Stack, + Text, +} from "@mantine/core"; +import { IconInfoCircle } from "@tabler/icons-react"; +import type { DryRunResponse } from "@/api/libraryJobs"; + +interface JobDryRunModalProps { + opened: boolean; + onClose: () => void; + result: DryRunResponse | undefined; +} + +export function JobDryRunModal({ + opened, + onClose, + result, +}: JobDryRunModalProps) { + return ( + + {!result ? ( + No preview available. + ) : ( + + }> + + {result.totalEligible} series eligible · skipped:{" "} + {result.estSkippedNoId} no external ID,{" "} + {result.estSkippedRecentlySynced} recently synced + + + + {result.planFailure && ( + + Plan failure: {result.planFailure} + + )} + + {result.sample.length === 0 ? ( + + No series in the sample. + + ) : ( + + {result.sample.map((s) => ( + + + + {s.seriesName} + + candidate + + + {Object.entries(s.changes).map(([field, change]) => ( + + {field}:{" "} + + {JSON.stringify(change.before)} + {" "} + →{" "} + + {JSON.stringify(change.after)} + + + ))} + + + ))} + + )} + + )} + + ); +} diff --git a/web/src/components/library-jobs/LibraryJobsList.tsx b/web/src/components/library-jobs/LibraryJobsList.tsx new file mode 100644 index 00000000..2e116621 --- /dev/null +++ b/web/src/components/library-jobs/LibraryJobsList.tsx @@ -0,0 +1,183 @@ +import { + ActionIcon, + Badge, + Card, + Center, + Group, + Loader, + Menu, + Stack, + Switch, + Text, + Tooltip, +} from "@mantine/core"; +import { + IconDotsVertical, + IconEdit, + IconPlayerPlay, + IconTrash, +} from "@tabler/icons-react"; +import type { LibraryJob } from "@/api/libraryJobs"; +import { + useRunLibraryJobNow, + useUpdateLibraryJob, +} from "@/hooks/useLibraryJobs"; + +interface JobListProps { + libraryId: string; + jobs: LibraryJob[]; + isLoading: boolean; + onEdit: (job: LibraryJob) => void; + onDelete: (job: LibraryJob) => void; +} + +export function JobList({ + libraryId, + jobs, + isLoading, + onEdit, + onDelete, +}: JobListProps) { + if (isLoading) { + return ( +
+ +
+ ); + } + + if (jobs.length === 0) { + return ( + + + No scheduled jobs + + Add a job to refresh series metadata on a recurring schedule. +
+ Each job targets one provider with the field groups you choose. +
+
+
+ ); + } + + return ( + + {jobs.map((job) => ( + + ))} + + ); +} + +function JobRow({ + libraryId, + job, + onEdit, + onDelete, +}: { + libraryId: string; + job: LibraryJob; + onEdit: (job: LibraryJob) => void; + onDelete: (job: LibraryJob) => void; +}) { + const update = useUpdateLibraryJob(libraryId); + const runNow = useRunLibraryJobNow(libraryId); + + const provider = job.config.provider.replace(/^plugin:/, ""); + const groups = job.config.fieldGroups ?? []; + const lastRun = formatLastRun(job); + + return ( + + + + + + {job.name} + + + metadata refresh + + {!job.enabled && ( + + disabled + + )} + + + {provider} ·{" "} + {groups.length === 0 ? "all fields" : groups.join(", ")} · cron{" "} + {job.cronSchedule} + {job.timezone ? ` (${job.timezone})` : ""} + + + {lastRun} + + + + + + + update.mutate({ + jobId: job.id, + patch: { enabled: event.currentTarget.checked }, + }) + } + disabled={update.isPending} + aria-label="Enable job" + /> + + + runNow.mutate(job.id)} + loading={runNow.isPending} + > + + + + + + + + + + + } + onClick={() => onEdit(job)} + > + Edit + + + } + onClick={() => onDelete(job)} + > + Delete + + + + + + + ); +} + +function formatLastRun(job: LibraryJob): string { + if (!job.lastRunAt) return "Never run"; + const when = new Date(job.lastRunAt).toLocaleString(); + const status = job.lastRunStatus ?? "unknown"; + const tail = job.lastRunMessage ? ` — ${job.lastRunMessage}` : ""; + return `Last run: ${when} (${status})${tail}`; +} diff --git a/web/src/components/library-jobs/MetadataRefreshJobEditor.tsx b/web/src/components/library-jobs/MetadataRefreshJobEditor.tsx new file mode 100644 index 00000000..0a9cef0c --- /dev/null +++ b/web/src/components/library-jobs/MetadataRefreshJobEditor.tsx @@ -0,0 +1,587 @@ +import { + Alert, + Anchor, + Badge, + Button, + Checkbox, + Collapse, + Divider, + Group, + Modal, + NumberInput, + Paper, + Radio, + Select, + Stack, + Switch, + Text, + TextInput, +} from "@mantine/core"; +import { useDisclosure } from "@mantine/hooks"; +import { notifications } from "@mantine/notifications"; +import { + IconAlertTriangle, + IconChevronDown, + IconChevronRight, + IconInfoCircle, +} from "@tabler/icons-react"; +import { useQuery } from "@tanstack/react-query"; +import { useEffect, useMemo, useState } from "react"; +import type { + CreateLibraryJobRequest, + LibraryJob, + LibraryJobConfig, + RefreshScope, +} from "@/api/libraryJobs"; +import { type PluginActionDto, pluginsApi } from "@/api/plugins"; +import { CronInput } from "@/components/forms/CronInput"; +import { + useCreateLibraryJob, + useDryRunLibraryJob, + useFieldGroups, + useUpdateLibraryJob, +} from "@/hooks/useLibraryJobs"; +import { JobDryRunModal } from "./JobDryRunModal"; + +interface JobEditorProps { + libraryId: string; + opened: boolean; + onClose: () => void; + job: LibraryJob | null; +} + +const CRON_PRESETS: { value: string; label: string }[] = [ + { value: "0 * * * *", label: "Hourly (top of the hour)" }, + { value: "0 */6 * * *", label: "Every 6 hours" }, + { value: "0 4 * * *", label: "Daily at 04:00" }, + { value: "0 4 * * 0", label: "Weekly (Sunday 04:00)" }, + { value: "custom", label: "Custom" }, +]; + +export function JobEditor({ libraryId, opened, onClose, job }: JobEditorProps) { + const isEdit = Boolean(job); + const create = useCreateLibraryJob(libraryId); + const update = useUpdateLibraryJob(libraryId); + const dryRun = useDryRunLibraryJob(libraryId); + const { data: fieldGroups } = useFieldGroups(); + + // Plugin list, filtered to those that can act as metadata providers (series + // or book scope). + const { data: seriesActions } = useQuery({ + queryKey: ["plugin-actions", "series:bulk"], + queryFn: () => pluginsApi.getActions("series:bulk"), + staleTime: 5 * 60 * 1000, + }); + const { data: bookActions } = useQuery({ + queryKey: ["plugin-actions", "book:bulk"], + queryFn: () => pluginsApi.getActions("book:bulk"), + staleTime: 5 * 60 * 1000, + }); + + // Merge actions by pluginId so a plugin appearing in both lists carries + // capabilities from whichever was last seen (capabilities come from the + // manifest, so they're identical regardless of scope). + const allPlugins: PluginActionDto[] = useMemo(() => { + const map = new Map(); + for (const a of seriesActions?.actions ?? []) map.set(a.pluginId, a); + for (const a of bookActions?.actions ?? []) { + if (!map.has(a.pluginId)) map.set(a.pluginId, a); + } + return Array.from(map.values()); + }, [seriesActions, bookActions]); + + // Form state. + const [name, setName] = useState(""); + const [enabled, setEnabled] = useState(false); + const [cronPreset, setCronPreset] = useState("0 4 * * *"); + const [cronCustom, setCronCustom] = useState("0 4 * * *"); + const [timezone, setTimezone] = useState(""); + const [provider, setProvider] = useState(""); // "plugin:" + const [scope, setScope] = useState("series_only"); + const [selectedGroups, setSelectedGroups] = useState([ + "ratings", + "status", + "counts", + ]); + const [extraFields, setExtraFields] = useState([]); + const [existingOnly, setExistingOnly] = useState(true); + const [skipRecent, setSkipRecent] = useState(3600); + const [maxConcurrency, setMaxConcurrency] = useState(4); + const [advancedOpen, advanced] = useDisclosure(false); + const [dryRunOpen, dryRunModal] = useDisclosure(false); + + // Hydrate when opened on an existing job. + useEffect(() => { + if (!opened) return; + if (job) { + setName(job.name); + setEnabled(job.enabled); + // If saved cron matches a preset, select it; else custom. + const matchPreset = CRON_PRESETS.find( + (p) => p.value !== "custom" && p.value === job.cronSchedule, + ); + setCronPreset(matchPreset ? matchPreset.value : "custom"); + setCronCustom(job.cronSchedule); + setTimezone(job.timezone ?? ""); + const cfg = job.config; + setProvider(cfg.provider); + setScope(cfg.scope ?? "series_only"); + setSelectedGroups(cfg.fieldGroups ?? []); + setExtraFields(cfg.extraFields ?? []); + setExistingOnly(cfg.existingSourceIdsOnly ?? true); + setSkipRecent(cfg.skipRecentlySyncedWithinS ?? 3600); + setMaxConcurrency(cfg.maxConcurrency ?? 4); + } else { + // Reset to defaults for "Add job". + setName(""); + setEnabled(false); + setCronPreset("0 4 * * *"); + setCronCustom("0 4 * * *"); + setTimezone(""); + setProvider(""); + setScope("series_only"); + setSelectedGroups(["ratings", "status", "counts"]); + setExtraFields([]); + setExistingOnly(true); + setSkipRecent(3600); + setMaxConcurrency(4); + } + }, [opened, job]); + + const selectedPlugin = allPlugins.find( + (p) => `plugin:${p.pluginName}` === provider, + ); + const supportsSeries = + selectedPlugin?.capabilities?.metadataProvider?.includes("series") ?? false; + const supportsBooks = + selectedPlugin?.capabilities?.metadataProvider?.includes("book") ?? false; + + // Auto-correct scope when provider changes and the current scope is no longer valid. + useEffect(() => { + if (!selectedPlugin) return; + if (scope === "series_only" && !supportsSeries) { + if (supportsBooks) { + setScope("books_only"); + notifications.show({ + title: "Scope updated", + message: `${selectedPlugin.pluginDisplayName} only supports book metadata.`, + color: "blue", + }); + } + } else if (scope === "books_only" && !supportsBooks) { + if (supportsSeries) { + setScope("series_only"); + notifications.show({ + title: "Scope updated", + message: `${selectedPlugin.pluginDisplayName} only supports series metadata.`, + color: "blue", + }); + } + } else if ( + scope === "series_and_books" && + !(supportsSeries && supportsBooks) + ) { + if (supportsSeries) setScope("series_only"); + else if (supportsBooks) setScope("books_only"); + } + }, [selectedPlugin, supportsBooks, supportsSeries, scope]); + + const cronValue = cronPreset === "custom" ? cronCustom : cronPreset; + + const buildConfig = (): LibraryJobConfig => ({ + type: "metadata_refresh", + provider, + scope, + fieldGroups: selectedGroups, + extraFields, + bookFieldGroups: [], + bookExtraFields: [], + existingSourceIdsOnly: existingOnly, + skipRecentlySyncedWithinS: skipRecent, + maxConcurrency, + }); + + const handleSubmit = async () => { + if (!provider) { + notifications.show({ + title: "Pick a provider", + message: "A provider is required.", + color: "yellow", + }); + return; + } + const config = buildConfig(); + const body: CreateLibraryJobRequest = { + name: name.trim() ? name.trim() : undefined, + enabled, + cronSchedule: cronValue, + timezone: timezone || null, + config, + }; + + if (isEdit && job) { + await update.mutateAsync({ + jobId: job.id, + patch: { + name: body.name ?? undefined, + enabled: body.enabled, + cronSchedule: body.cronSchedule, + timezone: timezone || null, + config: body.config, + }, + }); + } else { + await create.mutateAsync(body); + } + onClose(); + }; + + const handlePreview = async () => { + if (!isEdit || !job) { + notifications.show({ + title: "Save first", + message: "Preview is available after saving the job at least once.", + color: "yellow", + }); + return; + } + const config = buildConfig(); + await dryRun.mutateAsync({ + jobId: job.id, + body: { configOverride: config, sampleSize: 5 }, + }); + dryRunModal.open(); + }; + + // Compute which fields will actually be written. + const previewFields = useMemo(() => { + const set = new Set(); + for (const g of selectedGroups) { + const def = fieldGroups?.find((fg) => fg.id === g); + if (def) { + for (const f of def.fields) set.add(f); + } + } + for (const f of extraFields) set.add(f); + return Array.from(set).sort(); + }, [selectedGroups, extraFields, fieldGroups]); + + const allFields = useMemo(() => { + const set = new Set(); + for (const g of fieldGroups ?? []) { + for (const f of g.fields) set.add(f); + } + return Array.from(set).sort(); + }, [fieldGroups]); + + const fieldFromGroup = (field: string): string | null => { + for (const g of fieldGroups ?? []) { + if (selectedGroups.includes(g.id) && g.fields.includes(field)) + return g.label; + } + return null; + }; + + return ( + + + setName(e.currentTarget.value)} + /> + + setEnabled(e.currentTarget.checked)} + /> + + + + ({ + value: `plugin:${p.pluginName}`, + label: p.pluginDisplayName, + }))} + value={provider} + onChange={(v) => v && setProvider(v)} + /> + + {selectedPlugin && ( + + )} + + + + + + Field groups + + + Select the field groups to refresh. Locked fields are always + skipped, regardless of selection. + + + {(fieldGroups ?? []).map((g) => ( + + + + { + const checked = e.currentTarget.checked; + setSelectedGroups((prev) => + checked + ? [...prev, g.id] + : prev.filter((id) => id !== g.id), + ); + }} + /> + + {g.fields.join(", ")} + + + + + ))} + + + + advanced.toggle()} size="sm"> + + {advancedOpen ? ( + + ) : ( + + )} + Advanced: individual fields + + + + + + + Pick individual fields not covered by any selected group. Fields + already included by a group are disabled with a hint. + + {allFields.map((f) => { + const includedBy = fieldFromGroup(f); + const checked = includedBy != null || extraFields.includes(f); + return ( + + {f} + {includedBy && ( + + via {includedBy} + + )} + + } + checked={checked} + disabled={includedBy != null} + onChange={(e) => { + const v = e.currentTarget.checked; + setExtraFields((prev) => + v ? [...prev, f] : prev.filter((x) => x !== f), + ); + }} + /> + ); + })} + + + + + }> + + Will write {previewFields.length} field + {previewFields.length === 1 ? "" : "s"}:{" "} + {previewFields.length === 0 ? ( + + none (all fields would be applied — same as omitting filter) + + ) : ( + {previewFields.join(", ")} + )} + + + + + + setExistingOnly(e.currentTarget.checked)} + /> + + setSkipRecent(typeof v === "number" ? Math.max(0, v) * 3600 : 3600) + } + min={0} + step={1} + /> + + setMaxConcurrency( + typeof v === "number" ? Math.min(16, Math.max(1, v)) : 4, + ) + } + min={1} + max={16} + /> + + + + + + + + + + + ); +} + +function ScopeControl({ + scope, + onChange, + supportsSeries, + supportsBooks, +}: { + scope: RefreshScope; + onChange: (s: RefreshScope) => void; + supportsSeries: boolean; + supportsBooks: boolean; +}) { + const onlyOne = supportsSeries !== supportsBooks; + const lockedLabel = supportsSeries ? "Series only" : "Books only"; + + if (onlyOne) { + return ( + }> + + Scope: {lockedLabel}{" "} + + (this provider only supports one content type) + + + + ); + } + + return ( + + + Scope + + onChange(v as RefreshScope)}> + + + + Books only + + coming soon + + + } + disabled + /> + + Series & books + + coming soon + + + } + disabled + /> + + + {scope !== "series_only" && ( + } + > + + Book-scope refresh isn't implemented yet. Saving with this scope + will be rejected. + + + )} + + ); +} diff --git a/web/src/components/library/LibraryActionsMenu.tsx b/web/src/components/library/LibraryActionsMenu.tsx index c9853d0c..378542f2 100644 --- a/web/src/components/library/LibraryActionsMenu.tsx +++ b/web/src/components/library/LibraryActionsMenu.tsx @@ -1,6 +1,7 @@ import { Menu } from "@mantine/core"; import { notifications } from "@mantine/notifications"; import { + IconCalendarTime, IconEdit, IconPhoto, IconRadar, @@ -11,6 +12,7 @@ import { IconWand, } from "@tabler/icons-react"; import { useMutation, useQuery } from "@tanstack/react-query"; +import { useNavigate } from "react-router-dom"; import { librariesApi } from "@/api/libraries"; import { type PluginActionDto, @@ -50,6 +52,7 @@ export function LibraryActionsMenu({ action(...args); }; }; + const navigate = useNavigate(); const { hasPermission } = usePermissions(); const canEditLibrary = hasPermission(PERMISSIONS.LIBRARIES_WRITE); const canDeleteLibrary = hasPermission(PERMISSIONS.LIBRARIES_DELETE); @@ -245,6 +248,14 @@ export function LibraryActionsMenu({ > Edit Library + } + onClick={handleMenuAction(() => + navigate(`/libraries/${library.id}/jobs`), + )} + > + Scheduled Jobs + {canWriteTasks && ( <> diff --git a/web/src/hooks/useLibraryJobs.ts b/web/src/hooks/useLibraryJobs.ts new file mode 100644 index 00000000..529d4038 --- /dev/null +++ b/web/src/hooks/useLibraryJobs.ts @@ -0,0 +1,150 @@ +import { notifications } from "@mantine/notifications"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + type CreateLibraryJobRequest, + type DryRunRequest, + type DryRunResponse, + type FieldGroup, + type LibraryJob, + libraryJobsApi, + type PatchLibraryJobInput, +} from "@/api/libraryJobs"; + +const listKey = (libraryId: string) => + ["library-jobs", libraryId, "list"] as const; +const detailKey = (libraryId: string, jobId: string) => + ["library-jobs", libraryId, jobId] as const; +const fieldGroupsKey = ["library-jobs", "field-groups"] as const; + +type ApiError = Error & { + response?: { data?: { error?: string; message?: string } }; +}; + +const errorText = (e: ApiError) => + e.response?.data?.message ?? e.response?.data?.error ?? e.message; + +export function useLibraryJobsList(libraryId: string) { + return useQuery({ + queryKey: listKey(libraryId), + queryFn: () => libraryJobsApi.list(libraryId), + enabled: Boolean(libraryId), + }); +} + +export function useLibraryJob(libraryId: string, jobId: string | undefined) { + return useQuery({ + queryKey: detailKey(libraryId, jobId ?? ""), + queryFn: () => libraryJobsApi.get(libraryId, jobId as string), + enabled: Boolean(libraryId && jobId), + }); +} + +export function useFieldGroups() { + return useQuery({ + queryKey: fieldGroupsKey, + queryFn: () => libraryJobsApi.fieldGroups(), + staleTime: Number.POSITIVE_INFINITY, + }); +} + +export function useCreateLibraryJob(libraryId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (body: CreateLibraryJobRequest) => + libraryJobsApi.create(libraryId, body), + onSuccess: () => { + qc.invalidateQueries({ queryKey: listKey(libraryId) }); + notifications.show({ + title: "Job created", + message: "The new library job is ready.", + color: "green", + }); + }, + onError: (e: ApiError) => { + notifications.show({ + title: "Couldn't create job", + message: errorText(e), + color: "red", + }); + }, + }); +} + +export function useUpdateLibraryJob(libraryId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (args: { jobId: string; patch: PatchLibraryJobInput }) => + libraryJobsApi.update(libraryId, args.jobId, args.patch), + onSuccess: (_, args) => { + qc.invalidateQueries({ queryKey: listKey(libraryId) }); + qc.invalidateQueries({ queryKey: detailKey(libraryId, args.jobId) }); + }, + onError: (e: ApiError) => { + notifications.show({ + title: "Couldn't save job", + message: errorText(e), + color: "red", + }); + }, + }); +} + +export function useDeleteLibraryJob(libraryId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (jobId: string) => libraryJobsApi.delete(libraryId, jobId), + onSuccess: () => { + qc.invalidateQueries({ queryKey: listKey(libraryId) }); + notifications.show({ + title: "Job deleted", + message: "Library job removed.", + color: "blue", + }); + }, + onError: (e: ApiError) => { + notifications.show({ + title: "Couldn't delete job", + message: errorText(e), + color: "red", + }); + }, + }); +} + +export function useRunLibraryJobNow(libraryId: string) { + return useMutation({ + mutationFn: (jobId: string) => libraryJobsApi.runNow(libraryId, jobId), + onSuccess: (data) => { + notifications.show({ + title: "Job started", + message: `Task ${data.taskId.slice(0, 8)}… enqueued.`, + color: "blue", + }); + }, + onError: (e: ApiError) => { + notifications.show({ + title: "Couldn't start job", + message: errorText(e), + color: "red", + }); + }, + }); +} + +export function useDryRunLibraryJob(libraryId: string) { + return useMutation< + DryRunResponse, + ApiError, + { jobId: string; body?: DryRunRequest } + >({ + mutationFn: ({ jobId, body }) => + libraryJobsApi.dryRun(libraryId, jobId, body ?? {}), + onError: (e: ApiError) => { + notifications.show({ + title: "Preview failed", + message: errorText(e), + color: "red", + }); + }, + }); +} diff --git a/web/src/pages/LibraryJobs.tsx b/web/src/pages/LibraryJobs.tsx new file mode 100644 index 00000000..e5366c5b --- /dev/null +++ b/web/src/pages/LibraryJobs.tsx @@ -0,0 +1,138 @@ +import { + Anchor, + Breadcrumbs, + Button, + Center, + Container, + Group, + Loader, + Stack, + Text, + Title, +} from "@mantine/core"; +import { useDisclosure } from "@mantine/hooks"; +import { IconArrowLeft, IconPlus } from "@tabler/icons-react"; +import { useQuery } from "@tanstack/react-query"; +import { useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { librariesApi } from "@/api/libraries"; +import type { LibraryJob } from "@/api/libraryJobs"; +import { JobList } from "@/components/library-jobs/LibraryJobsList"; +import { JobEditor } from "@/components/library-jobs/MetadataRefreshJobEditor"; +import { useDynamicDocumentTitle } from "@/hooks/useDocumentTitle"; +import { + useDeleteLibraryJob, + useLibraryJobsList, +} from "@/hooks/useLibraryJobs"; + +export function LibraryJobsPage() { + const { libraryId } = useParams<{ libraryId: string }>(); + const navigate = useNavigate(); + const [editorOpened, editor] = useDisclosure(false); + const [editingJob, setEditingJob] = useState(null); + + const library = useQuery({ + queryKey: ["library", libraryId], + queryFn: () => librariesApi.getById(libraryId as string), + enabled: Boolean(libraryId), + }); + + const jobsQuery = useLibraryJobsList(libraryId ?? ""); + const deleteMutation = useDeleteLibraryJob(libraryId ?? ""); + + useDynamicDocumentTitle( + library.data ? `Scheduled Jobs · ${library.data.name}` : "Scheduled Jobs", + ); + + if (!libraryId) return null; + + if (library.isLoading) { + return ( + +
+ +
+
+ ); + } + + const handleAdd = () => { + setEditingJob(null); + editor.open(); + }; + + const handleEdit = (job: LibraryJob) => { + setEditingJob(job); + editor.open(); + }; + + const handleDelete = (job: LibraryJob) => { + if (!window.confirm(`Delete job "${job.name}"? This cannot be undone.`)) { + return; + } + deleteMutation.mutate(job.id); + }; + + return ( + + + + navigate("/libraries")} component="button"> + Libraries + + {library.data && ( + navigate(`/libraries/${library.data.id}`)} + component="button" + > + {library.data.name} + + )} + Scheduled Jobs + + + + + Scheduled Jobs + + Each job runs on its own cron, against one provider, and writes + the field groups you select. Multiple jobs per library are + supported. + + + + + + + + + + + + + + ); +} diff --git a/web/src/types/api.generated.ts b/web/src/types/api.generated.ts index b81c767e..c835d400 100644 --- a/web/src/types/api.generated.ts +++ b/web/src/types/api.generated.ts @@ -8622,6 +8622,15 @@ export interface components { */ voteCount?: number | null; }; + /** @description Request body for `POST /api/v1/libraries/{id}/jobs`. */ + CreateLibraryJobRequest: { + config: components["schemas"]["LibraryJobConfigDto"]; + cronSchedule: string; + enabled?: boolean; + /** @description Optional user-facing name. Auto-generated when missing or empty. */ + name?: string | null; + timezone?: string | null; + }; /** @description Create library request */ CreateLibraryRequest: { /** @@ -8952,6 +8961,66 @@ export interface components { /** @description Whether the dismissal was recorded */ dismissed: boolean; }; + DryRunFieldChange: { + after: unknown; + before: unknown; + }; + /** + * @description Dry-run preview attached to [`MetadataApplyResponse`] when the request + * set `dryRun = true`. Absent on real applies. + */ + DryRunReportDto: { + changes: components["schemas"]["FieldChangeDto"][]; + }; + /** @description Request body for `POST .../dry-run`. */ + DryRunRequest: { + configOverride?: null | components["schemas"]["LibraryJobConfigDto"]; + /** + * Format: int32 + * @description Sample size, capped at 20 server-side. + */ + sampleSize?: number | null; + }; + DryRunResponse: { + /** + * Format: int32 + * @description Estimated count of series that would be skipped because they have no + * external ID for the chosen provider. + */ + estSkippedNoId: number; + /** + * Format: int32 + * @description Estimated count of series that would be skipped because they were + * recently synced. + */ + estSkippedRecentlySynced: number; + /** @description Provider resolution failure reason, if any. */ + planFailure?: string | null; + /** @description Per-series deltas for the first N eligible series. */ + sample: components["schemas"]["DryRunSeriesDelta"][]; + /** + * Format: int32 + * @description Total number of series eligible to be refreshed (all of them, not + * just the sample). + */ + totalEligible: number; + }; + /** @description One series's preview of would-be field changes. */ + DryRunSeriesDelta: { + /** @description Field name → `(before, after)` JSON values. */ + changes: { + [key: string]: components["schemas"]["DryRunFieldChange"]; + }; + /** Format: uuid */ + seriesId: string; + seriesName: string; + /** @description Fields that would have been written but were skipped (locks, all-locked, etc.) */ + skipped: components["schemas"]["DryRunSkippedFieldDto"][]; + }; + DryRunSkippedFieldDto: { + field: string; + reason: string; + }; /** @description A group of duplicate books */ DuplicateGroup: { /** @description List of book IDs that share this hash */ @@ -9452,6 +9521,27 @@ export interface components { * @enum {string} */ FieldApplyStatus: "will_apply" | "locked" | "no_permission" | "unchanged" | "not_provided"; + /** + * @description One would-be field change recorded during a dry-run apply. + * + * Mirrors `services::metadata::apply::FieldChange`, kept as a distinct DTO + * to keep the wire-format frozen even if internal types evolve. + */ + FieldChangeDto: { + after: unknown; + /** + * @description Current value, where cheaply available. `null` for fields backed by + * joined tables (genres, tags, alternate titles, ratings, etc.). + */ + before?: unknown; + field: string; + }; + /** @description Static field-group catalog row exposed for the editor UI. */ + FieldGroupDto: { + fields: string[]; + id: string; + label: string; + }; /** @description Operators for string and equality comparisons */ FieldOperator: { /** @enum {string} */ @@ -10974,6 +11064,36 @@ export interface components { */ updatedAt: string; }; + /** + * @description Type-discriminated job config exposed over the wire. + * + * Phase 9 only ships the `metadata_refresh` variant; future job types + * extend the enum. + */ + LibraryJobConfigDto: components["schemas"]["MetadataRefreshJobConfigDto"] & { + /** @enum {string} */ + type: "metadata_refresh"; + }; + /** @description Library job row exposed via GET / list / response. */ + LibraryJobDto: { + config: components["schemas"]["LibraryJobConfigDto"]; + /** Format: date-time */ + createdAt: string; + cronSchedule: string; + enabled: boolean; + /** Format: uuid */ + id: string; + /** Format: date-time */ + lastRunAt?: string | null; + lastRunMessage?: string | null; + lastRunStatus?: string | null; + /** Format: uuid */ + libraryId: string; + name: string; + timezone?: string | null; + /** Format: date-time */ + updatedAt: string; + }; /** @description Metrics for a single library */ LibraryMetricsDto: { /** @@ -11029,6 +11149,10 @@ export interface components { */ totalGroups: number; }; + /** @description Response for `GET /libraries/{id}/jobs`. */ + ListLibraryJobsResponse: { + jobs: components["schemas"]["LibraryJobDto"][]; + }; /** @description Query parameters for listing settings */ ListSettingsQuery: { /** @@ -11098,6 +11222,12 @@ export interface components { MetadataAction: "search" | "get" | "match"; /** @description Request to apply metadata from a plugin */ MetadataApplyRequest: { + /** + * @description When `true`, the call simulates the apply without writing to the + * database. Returns the same `appliedFields`/`skippedFields` plus an + * extra `dryRunReport` showing every would-be change. Default `false`. + */ + dryRun?: boolean; /** @description External ID from the plugin's search results */ externalId: string; /** @description Optional list of fields to apply (default: all applicable fields) */ @@ -11112,6 +11242,7 @@ export interface components { MetadataApplyResponse: { /** @description Fields that were applied */ appliedFields: string[]; + dryRunReport?: null | components["schemas"]["DryRunReportDto"]; /** @description Message */ message: string; /** @description Fields that were skipped (with reasons) */ @@ -11415,6 +11546,33 @@ export interface components { /** @description Summary counts */ summary: components["schemas"]["PreviewSummary"]; }; + /** @description Wire shape for the metadata-refresh job config. */ + MetadataRefreshJobConfigDto: { + /** @description Reserved for the book-scope future work. */ + bookExtraFields?: string[]; + /** @description Reserved for the book-scope future work. */ + bookFieldGroups?: string[]; + /** @description When true, the planner skips series with no stored external ID. */ + existingSourceIdsOnly?: boolean; + /** @description Series-side individual field overrides (camelCase). */ + extraFields?: string[]; + /** @description Series-side field groups (snake_case identifiers). */ + fieldGroups?: string[]; + /** + * Format: int32 + * @description Per-task fan-out; clamped at run time. + */ + maxConcurrency?: number; + /** @description Plugin reference, e.g. `"plugin:mangabaka"`. */ + provider: string; + /** @description Refresh scope. Phase 9 only honours `series_only` at runtime. */ + scope?: components["schemas"]["RefreshScope"]; + /** + * Format: int32 + * @description Skip series whose `last_synced_at` is younger than this many seconds. + */ + skipRecentlySyncedWithinS?: number; + }; /** @description Response for cleanup operation */ MetricsCleanupResponse: { /** @@ -12754,6 +12912,20 @@ export interface components { */ title?: string | null; }; + /** + * @description Request body for `PATCH /api/v1/libraries/{id}/jobs/{job_id}`. + * + * All fields are optional. Top-level fields use [`PatchValue`] when their + * underlying type is `Option<...>` so an explicit `null` clears the value + * distinct from "not present". + */ + PatchLibraryJobRequest: { + config?: null | components["schemas"]["LibraryJobConfigDto"]; + cronSchedule?: string | null; + enabled?: boolean | null; + name?: string | null; + timezone?: string | null; + }; /** * @description PATCH request for partial update of series metadata * @@ -12909,6 +13081,7 @@ export interface components { PluginActionDto: { /** @description Action type (e.g., "metadata_search", "metadata_get") */ actionType: string; + capabilities?: null | components["schemas"]["PluginCapabilitiesDto"]; /** @description Description of the action */ description?: string | null; /** @description Icon hint for UI (optional) */ @@ -13907,6 +14080,14 @@ export interface components { /** @description Status of a running/pending background task ("pending" or "running"), if any */ taskStatus?: string | null; }; + /** + * @description Scope of a metadata refresh job. + * + * Phase 9 only honours [`RefreshScope::SeriesOnly`] at runtime. The + * other variants are schema-accepted but rejected by the validator. + * @enum {string} + */ + RefreshScope: "series_only" | "books_only" | "series_and_books"; /** @description Register request */ RegisterRequest: { /** @@ -14370,6 +14551,11 @@ export interface components { */ tasksEnqueued: number; }; + /** @description Response for `POST .../run-now`. */ + RunNowResponse: { + /** Format: uuid */ + taskId: string; + }; /** @description Scan status response */ ScanStatusDto: { /** @@ -15889,6 +16075,11 @@ export interface components { source: string; /** @enum {string} */ type: "refresh_metadata"; + } | { + /** Format: uuid */ + jobId: string; + /** @enum {string} */ + type: "refresh_library_metadata"; } | { bookIds?: string[] | null; force?: boolean;