From 013a312b8af60cd3d5d31cd68c186b20428637f4 Mon Sep 17 00:00:00 2001 From: Sylvain Cau Date: Fri, 5 Jun 2026 20:06:57 -0700 Subject: [PATCH 1/6] feat(plugins): send library context with sync and recommendation entries Sync and recommendation plugins previously received no information about which library a series belongs to. Add libraryId and libraryName to every entry so plugins can branch behaviour per library, which is the groundwork for scoping a plugin (or multiple instances of one) to specific libraries. - Add library_id/library_name to SyncEntry and UserLibraryEntry, serialized as libraryId/libraryName. Both default when absent so pulled entries returned by a plugin still deserialize. - Add a library_names helper that batch-resolves library ids to names. - Populate the fields from each series' library in the recommendation builder and both sync push builders. Adds serde and builder tests. --- crates/codex-services/src/plugin/library.rs | 33 ++++++++++-- crates/codex-services/src/plugin/protocol.rs | 26 +++++++++ .../src/plugin/recommendations.rs | 4 ++ crates/codex-services/src/plugin/sync.rs | 53 +++++++++++++++++++ .../src/handlers/user_plugin_sync/push.rs | 53 ++++++++++++++++++- .../src/handlers/user_plugin_sync/tests.rs | 21 ++++++++ 6 files changed, 186 insertions(+), 4 deletions(-) diff --git a/crates/codex-services/src/plugin/library.rs b/crates/codex-services/src/plugin/library.rs index 6bc4ea0f..2696e66d 100644 --- a/crates/codex-services/src/plugin/library.rs +++ b/crates/codex-services/src/plugin/library.rs @@ -12,11 +12,24 @@ use uuid::Uuid; use crate::plugin::protocol::{UserLibraryEntry, UserLibraryExternalId, UserReadingStatus}; use codex_db::entities::SeriesStatus; use codex_db::repositories::{ - AlternateTitleRepository, BookRepository, GenreRepository, ReadProgressRepository, - SeriesExternalIdRepository, SeriesMetadataRepository, SeriesRepository, TagRepository, - UserSeriesRatingRepository, + AlternateTitleRepository, BookRepository, GenreRepository, LibraryRepository, + ReadProgressRepository, SeriesExternalIdRepository, SeriesMetadataRepository, SeriesRepository, + TagRepository, UserSeriesRatingRepository, }; +/// Resolve a set of library IDs to a `library_id -> library_name` map in a +/// single query. Used to stamp `library_name` onto entries sent to plugins. +/// Returns an empty map (and logs) on failure so callers can degrade gracefully. +pub async fn library_names(db: &DatabaseConnection, library_ids: &[Uuid]) -> HashMap { + match LibraryRepository::get_by_ids(db, library_ids).await { + Ok(libs) => libs.into_iter().map(|(id, lib)| (id, lib.name)).collect(), + Err(e) => { + warn!("Failed to fetch library names for {library_ids:?}: {e}"); + HashMap::new() + } + } +} + /// Build the full user library as `Vec` for recommendation plugins. /// /// Fetches all series, metadata, genres, tags, external IDs, reading progress, @@ -33,6 +46,15 @@ pub async fn build_user_library( let series_ids: Vec = all_series.iter().map(|s| s.id).collect(); + // Resolve library names so each entry can carry its library context. + let library_ids: Vec = { + let mut ids: Vec = all_series.iter().map(|s| s.library_id).collect(); + ids.sort_unstable(); + ids.dedup(); + ids + }; + let lib_names = library_names(db, &library_ids).await; + // 2. Batch-fetch all related data let metadata_map = SeriesMetadataRepository::get_by_series_ids(db, &series_ids).await?; let genres_map = GenreRepository::get_genres_for_series_ids(db, &series_ids).await?; @@ -157,6 +179,11 @@ pub async fn build_user_library( entries.push(UserLibraryEntry { series_id: series.id.to_string(), + library_id: series.library_id.to_string(), + library_name: lib_names + .get(&series.library_id) + .cloned() + .unwrap_or_default(), title, alternate_titles, year: meta.and_then(|m| m.year), diff --git a/crates/codex-services/src/plugin/protocol.rs b/crates/codex-services/src/plugin/protocol.rs index 39d03135..b6a6f798 100644 --- a/crates/codex-services/src/plugin/protocol.rs +++ b/crates/codex-services/src/plugin/protocol.rs @@ -910,6 +910,13 @@ pub enum ExternalLinkType { pub struct UserLibraryEntry { /// Codex series ID pub series_id: String, + /// ID of the library the series belongs to, so recommendation plugins can + /// scope behaviour per library. + #[serde(default)] + pub library_id: String, + /// Human-readable name of the library the series belongs to. + #[serde(default)] + pub library_name: String, /// Primary title pub title: String, /// Alternative titles (native, romaji, etc.) @@ -2138,6 +2145,8 @@ mod tests { fn test_user_library_entry_full_serialization() { let entry = UserLibraryEntry { series_id: "550e8400-e29b-41d4-a716-446655440000".to_string(), + library_id: "11111111-1111-1111-1111-111111111111".to_string(), + library_name: "Manga".to_string(), title: "One Piece".to_string(), alternate_titles: vec!["ワンピース".to_string()], year: Some(1997), @@ -2176,9 +2185,22 @@ mod tests { assert_eq!(json["booksOwned"], 100); assert_eq!(json["userRating"], 95); assert_eq!(json["userNotes"], "Masterpiece"); + assert_eq!(json["libraryId"], "11111111-1111-1111-1111-111111111111"); + assert_eq!(json["libraryName"], "Manga"); assert!(!json.as_object().unwrap().contains_key("completedAt")); } + #[test] + fn test_user_library_entry_library_fields_default_when_absent() { + let json = serde_json::json!({ + "seriesId": "abc", + "title": "Test" + }); + let entry: UserLibraryEntry = serde_json::from_value(json).unwrap(); + assert_eq!(entry.library_id, ""); + assert_eq!(entry.library_name, ""); + } + #[test] fn test_user_library_entry_minimal() { let entry = UserLibraryEntry { @@ -2200,6 +2222,8 @@ mod tests { started_at: None, last_read_at: None, completed_at: None, + library_id: String::new(), + library_name: String::new(), }; let json = serde_json::to_value(&entry).unwrap(); assert_eq!(json["seriesId"], "abc"); @@ -2316,6 +2340,8 @@ mod tests { started_at: None, last_read_at: None, completed_at: None, + library_id: String::new(), + library_name: String::new(), }; let json = serde_json::to_value(&entry).unwrap(); let ids = json["externalIds"].as_array().unwrap(); diff --git a/crates/codex-services/src/plugin/recommendations.rs b/crates/codex-services/src/plugin/recommendations.rs index 07552657..cb6dc809 100644 --- a/crates/codex-services/src/plugin/recommendations.rs +++ b/crates/codex-services/src/plugin/recommendations.rs @@ -271,6 +271,8 @@ mod tests { started_at: None, last_read_at: None, completed_at: None, + library_id: String::new(), + library_name: String::new(), }], limit: Some(10), exclude_ids: vec!["99999".to_string()], @@ -527,6 +529,8 @@ mod tests { started_at: None, last_read_at: None, completed_at: None, + library_id: String::new(), + library_name: String::new(), }], }; let json = serde_json::to_value(&req).unwrap(); diff --git a/crates/codex-services/src/plugin/sync.rs b/crates/codex-services/src/plugin/sync.rs index 17430407..75a5d553 100644 --- a/crates/codex-services/src/plugin/sync.rs +++ b/crates/codex-services/src/plugin/sync.rs @@ -102,6 +102,15 @@ pub struct SyncEntry { /// to search the external service by title when no external ID is present. #[serde(default, skip_serializing_if = "Option::is_none")] pub title: Option, + /// ID of the library the series belongs to. Populated on push so plugins + /// can scope behaviour per library. Empty on pulled entries (the external + /// service does not send it back). + #[serde(default)] + pub library_id: String, + /// Human-readable name of the library the series belongs to. Populated on + /// push; empty on pulled entries. + #[serde(default)] + pub library_name: String, } /// Reading progress details @@ -369,6 +378,8 @@ mod tests { notes: Some("Great series!".to_string()), latest_updated_at: Some("2026-02-01T12:00:00Z".to_string()), title: None, + library_id: String::new(), + library_name: String::new(), }; let json = serde_json::to_value(&entry).unwrap(); assert_eq!(json["externalId"], "12345"); @@ -485,6 +496,8 @@ mod tests { notes: None, latest_updated_at: None, title: None, + library_id: String::new(), + library_name: String::new(), }, SyncEntry { external_id: "2".to_string(), @@ -496,6 +509,8 @@ mod tests { notes: None, latest_updated_at: None, title: None, + library_id: String::new(), + library_name: String::new(), }, ], }; @@ -622,6 +637,8 @@ mod tests { notes: None, latest_updated_at: None, title: None, + library_id: String::new(), + library_name: String::new(), }], next_cursor: Some("page2".to_string()), has_more: true, @@ -715,6 +732,8 @@ mod tests { notes: None, latest_updated_at: None, title: Some("Berserk".to_string()), + library_id: String::new(), + library_name: String::new(), }; let json = serde_json::to_value(&entry).unwrap(); assert_eq!(json["title"], "Berserk"); @@ -733,6 +752,8 @@ mod tests { notes: None, latest_updated_at: None, title: None, + library_id: String::new(), + library_name: String::new(), }; let json = serde_json::to_value(&entry).unwrap(); assert!(!json.as_object().unwrap().contains_key("title")); @@ -760,6 +781,38 @@ mod tests { assert!(entry.title.is_none()); } + #[test] + fn test_sync_entry_library_fields_serialize_as_camel_case() { + let entry = SyncEntry { + external_id: "42".to_string(), + status: SyncReadingStatus::Reading, + progress: None, + score: None, + started_at: None, + completed_at: None, + notes: None, + latest_updated_at: None, + title: None, + library_id: "11111111-1111-1111-1111-111111111111".to_string(), + library_name: "Manga".to_string(), + }; + let json = serde_json::to_value(&entry).unwrap(); + assert_eq!(json["libraryId"], "11111111-1111-1111-1111-111111111111"); + assert_eq!(json["libraryName"], "Manga"); + } + + #[test] + fn test_sync_entry_library_fields_default_when_absent() { + // Pulled entries (from the plugin) omit library context; must not fail. + let json = json!({ + "externalId": "42", + "status": "completed" + }); + let entry: SyncEntry = serde_json::from_value(json).unwrap(); + assert_eq!(entry.library_id, ""); + assert_eq!(entry.library_name, ""); + } + // ========================================================================= // is_sync_method Tests // ========================================================================= diff --git a/crates/codex-tasks/src/handlers/user_plugin_sync/push.rs b/crates/codex-tasks/src/handlers/user_plugin_sync/push.rs index 71d8f7f2..bc1fcd2d 100644 --- a/crates/codex-tasks/src/handlers/user_plugin_sync/push.rs +++ b/crates/codex-tasks/src/handlers/user_plugin_sync/push.rs @@ -8,12 +8,41 @@ use uuid::Uuid; use codex_db::repositories::{ BookRepository, ReadProgressRepository, SeriesExternalIdRepository, SeriesMetadataRepository, - UserSeriesRatingRepository, + SeriesRepository, UserSeriesRatingRepository, }; +use codex_services::plugin::library::library_names; use codex_services::plugin::sync::{SyncEntry, SyncProgress, SyncReadingStatus}; use super::settings::CodexSyncSettings; +/// Resolve `series_id -> (library_id, library_name)` for the given series so +/// push entries can carry their library context. Degrades to an empty map on +/// failure; entries then fall back to empty library fields. +async fn series_library_info( + db: &DatabaseConnection, + series_ids: &[Uuid], +) -> HashMap { + let series = SeriesRepository::get_by_ids(db, series_ids) + .await + .unwrap_or_default(); + + let library_ids: Vec = { + let mut ids: Vec = series.iter().map(|s| s.library_id).collect(); + ids.sort_unstable(); + ids.dedup(); + ids + }; + let names = library_names(db, &library_ids).await; + + series + .into_iter() + .map(|s| { + let name = names.get(&s.library_id).cloned().unwrap_or_default(); + (s.id, (s.library_id.to_string(), name)) + }) + .collect() +} + /// Build push entries from a user's Codex reading progress. /// /// For each series that has an external ID matching the given source, @@ -57,6 +86,9 @@ pub(crate) async fn build_push_entries( // Collect all series IDs for batch queries let series_ids: Vec = external_ids.iter().map(|e| e.series_id).collect(); + // Resolve library context (id + name) for each series so entries carry it. + let lib_info = series_library_info(db, &series_ids).await; + // 2. Batch-fetch all books grouped by series (1 query instead of N) let books_map = match BookRepository::get_by_series_ids(db, &series_ids).await { Ok(map) => map, @@ -270,6 +302,14 @@ pub(crate) async fn build_push_entries( notes, latest_updated_at: latest_updated_at.map(|dt| dt.to_rfc3339()), title: metadata_map.get(&ext_id.series_id).map(|m| m.title.clone()), + library_id: lib_info + .get(&ext_id.series_id) + .map(|(id, _)| id.clone()) + .unwrap_or_default(), + library_name: lib_info + .get(&ext_id.series_id) + .map(|(_, name)| name.clone()) + .unwrap_or_default(), }); } @@ -356,6 +396,9 @@ async fn build_unmatched_entries( let unmatched_ids_vec: Vec = unmatched_series_ids.iter().copied().collect(); + // Resolve library context (id + name) for each unmatched series. + let lib_info = series_library_info(db, &unmatched_ids_vec).await; + // 3. Batch-fetch books, progress, and metadata for unmatched series let books_map = match BookRepository::get_by_series_ids(db, &unmatched_ids_vec).await { Ok(m) => m, @@ -529,6 +572,14 @@ async fn build_unmatched_entries( notes, latest_updated_at: latest_updated_at.map(|dt| dt.to_rfc3339()), title: Some(title), + library_id: lib_info + .get(&series_id) + .map(|(id, _)| id.clone()) + .unwrap_or_default(), + library_name: lib_info + .get(&series_id) + .map(|(_, name)| name.clone()) + .unwrap_or_default(), }); } diff --git a/crates/codex-tasks/src/handlers/user_plugin_sync/tests.rs b/crates/codex-tasks/src/handlers/user_plugin_sync/tests.rs index 340279b5..2905c1b9 100644 --- a/crates/codex-tasks/src/handlers/user_plugin_sync/tests.rs +++ b/crates/codex-tasks/src/handlers/user_plugin_sync/tests.rs @@ -266,6 +266,8 @@ async fn test_match_and_apply_no_source() { notes: None, latest_updated_at: None, title: None, + library_id: String::new(), + library_name: String::new(), }]; let (matched, applied) = pull::match_and_apply_pulled_entries( @@ -323,6 +325,8 @@ async fn test_match_and_apply_with_matches() { notes: None, latest_updated_at: None, title: None, + library_id: String::new(), + library_name: String::new(), }, SyncEntry { external_id: "99999".to_string(), // no match @@ -334,6 +338,8 @@ async fn test_match_and_apply_with_matches() { notes: None, latest_updated_at: None, title: None, + library_id: String::new(), + library_name: String::new(), }, ]; @@ -402,6 +408,8 @@ async fn test_match_and_apply_pulled_entries_applies_progress() { notes: None, latest_updated_at: None, title: None, + library_id: String::new(), + library_name: String::new(), }]; let (matched, applied) = pull::match_and_apply_pulled_entries( @@ -498,6 +506,8 @@ async fn test_match_and_apply_skips_already_read() { notes: None, latest_updated_at: None, title: None, + library_id: String::new(), + library_name: String::new(), }]; let (matched, applied) = pull::match_and_apply_pulled_entries( @@ -586,6 +596,9 @@ async fn test_build_push_entries_with_progress() { // "volumes" mode sends only volumes (not chapters, to avoid misleading activity) assert_eq!(entries[0].progress.as_ref().unwrap().volumes, Some(2)); assert!(entries[0].progress.as_ref().unwrap().chapters.is_none()); + // Each entry carries its library context for the plugin. + assert_eq!(entries[0].library_id, library.id.to_string()); + assert_eq!(entries[0].library_name, "Test Library"); } #[tokio::test] @@ -1071,6 +1084,8 @@ async fn test_apply_pulled_entry_uses_volumes() { notes: None, latest_updated_at: None, title: None, + library_id: String::new(), + library_name: String::new(), }; // Build pre-fetched maps for apply_pulled_entry (via match_and_apply which calls it) @@ -1365,6 +1380,8 @@ async fn test_apply_pulled_rating_no_existing() { notes: Some("Good so far".to_string()), latest_updated_at: None, title: None, + library_id: String::new(), + library_name: String::new(), }]; let (matched, _applied) = pull::match_and_apply_pulled_entries( @@ -1454,6 +1471,8 @@ async fn test_apply_pulled_rating_existing_not_overwritten() { notes: Some("AniList notes".to_string()), latest_updated_at: None, title: None, + library_id: String::new(), + library_name: String::new(), }]; let (_matched, _applied) = pull::match_and_apply_pulled_entries( @@ -1528,6 +1547,8 @@ async fn test_apply_pulled_rating_disabled() { notes: Some("Should not be stored".to_string()), latest_updated_at: None, title: None, + library_id: String::new(), + library_name: String::new(), }]; let (_matched, _applied) = pull::match_and_apply_pulled_entries( From 31f458af084b8177e0133576df927726d9303bbd Mon Sep 17 00:00:00 2001 From: Sylvain Cau Date: Fri, 5 Jun 2026 20:31:00 -0700 Subject: [PATCH 2/6] feat(plugins): scope sync push and pull to a plugin's allowed libraries Sync previously acted on a user's entire collection regardless of which libraries a plugin was configured for. Honor the admin library_ids scope (empty = all libraries) so a plugin only pushes and pulls progress for series in its allowed libraries. This makes it possible to run the same integration against different libraries as separate instances. - Push: build_push_entries and the search-fallback builder drop series whose library is out of scope. - Pull: add find_all_by_external_ids_and_source, which groups all series sharing an external ID instead of collapsing to one. Pull now resolves each match's library, skips out-of-scope matches, and applies progress to every in-scope duplicate (the same title across several allowed libraries all get updated). - The sync handler loads the plugin and threads its allowed library set into both push and pull. When scoped, a series whose library cannot be resolved is skipped (fail-closed); unscoped behavior is unchanged. Adds repository and handler tests. --- .../src/repositories/series_external_id.rs | 88 +++++++ .../src/handlers/user_plugin_sync/mod.rs | 27 ++- .../src/handlers/user_plugin_sync/pull.rs | 141 +++++++---- .../src/handlers/user_plugin_sync/push.rs | 66 ++++-- .../src/handlers/user_plugin_sync/tests.rs | 219 ++++++++++++++++++ 5 files changed, 475 insertions(+), 66 deletions(-) diff --git a/crates/codex-db/src/repositories/series_external_id.rs b/crates/codex-db/src/repositories/series_external_id.rs index 18f1fd85..55d4f7f6 100644 --- a/crates/codex-db/src/repositories/series_external_id.rs +++ b/crates/codex-db/src/repositories/series_external_id.rs @@ -275,6 +275,35 @@ impl SeriesExternalIdRepository { .collect()) } + /// Find ALL series external IDs by multiple external ID values and source, + /// grouped by external_id. + /// + /// Unlike [`find_by_external_ids_and_source`], this preserves every match + /// when the same `external_id` maps to multiple series (e.g. the same title + /// duplicated across libraries). Used during pull sync so progress can be + /// applied to all in-scope duplicates. + pub async fn find_all_by_external_ids_and_source( + db: &DatabaseConnection, + external_ids: &[String], + source: &str, + ) -> Result>> { + if external_ids.is_empty() { + return Ok(HashMap::new()); + } + + let results = SeriesExternalIds::find() + .filter(series_external_ids::Column::ExternalId.is_in(external_ids.to_vec())) + .filter(series_external_ids::Column::Source.eq(source)) + .all(db) + .await?; + + let mut map: HashMap> = HashMap::new(); + for e in results { + map.entry(e.external_id.clone()).or_default().push(e); + } + Ok(map) + } + /// Check if an external ID record belongs to a specific series pub async fn belongs_to_series( db: &DatabaseConnection, @@ -1321,4 +1350,63 @@ mod tests { assert!(result.contains_key("111")); assert!(!result.contains_key("222")); } + + #[tokio::test] + async fn test_find_all_by_external_ids_and_source_groups_cross_library_duplicates() { + let (db, _temp_dir) = create_test_db().await; + + // Same external_id (same title) duplicated across two libraries. + let library_a = LibraryRepository::create( + db.sea_orm_connection(), + "Library A", + "/a", + ScanningStrategy::Default, + ) + .await + .unwrap(); + let library_b = LibraryRepository::create( + db.sea_orm_connection(), + "Library B", + "/b", + ScanningStrategy::Default, + ) + .await + .unwrap(); + + let series_a = SeriesRepository::create(db.sea_orm_connection(), library_a.id, "Dup", None) + .await + .unwrap(); + let series_b = SeriesRepository::create(db.sea_orm_connection(), library_b.id, "Dup", None) + .await + .unwrap(); + + for sid in [series_a.id, series_b.id] { + SeriesExternalIdRepository::create( + db.sea_orm_connection(), + sid, + "api:anilist", + "12345", + None, + None, + ) + .await + .unwrap(); + } + + let result = SeriesExternalIdRepository::find_all_by_external_ids_and_source( + db.sea_orm_connection(), + &["12345".to_string()], + "api:anilist", + ) + .await + .unwrap(); + + // Both series are returned under the single external_id key. + let matches = result.get("12345").unwrap(); + assert_eq!(matches.len(), 2); + let series_ids: std::collections::HashSet = + matches.iter().map(|e| e.series_id).collect(); + assert!(series_ids.contains(&series_a.id)); + assert!(series_ids.contains(&series_b.id)); + } } diff --git a/crates/codex-tasks/src/handlers/user_plugin_sync/mod.rs b/crates/codex-tasks/src/handlers/user_plugin_sync/mod.rs index 0815b97f..12b94e71 100644 --- a/crates/codex-tasks/src/handlers/user_plugin_sync/mod.rs +++ b/crates/codex-tasks/src/handlers/user_plugin_sync/mod.rs @@ -28,7 +28,7 @@ use uuid::Uuid; use crate::handlers::TaskHandler; use crate::types::TaskResult; use codex_db::entities::tasks; -use codex_db::repositories::{UserPluginDataRepository, UserPluginsRepository}; +use codex_db::repositories::{PluginsRepository, UserPluginDataRepository, UserPluginsRepository}; use codex_events::{EventBroadcaster, TaskProgressEvent}; use codex_services::SettingsService; use codex_services::plugin::PluginManager; @@ -184,6 +184,20 @@ impl TaskHandler for UserPluginSyncHandler { let do_push = sync_mode == "both" || sync_mode == "push"; let codex_settings = CodexSyncSettings::from_user_config(&user_config); + // Admin-configured library scope for this plugin (empty = all libraries). + let allowed_library_ids: Vec = + match PluginsRepository::get_by_id(db, plugin_id).await { + Ok(Some(plugin)) => plugin.library_ids_vec(), + _ => Vec::new(), + }; + if !allowed_library_ids.is_empty() { + debug!( + "Task {}: Plugin scoped to {} libraries", + task.id, + allowed_library_ids.len() + ); + } + debug!( "Task {}: syncMode={} (pull={}, push={})", task.id, sync_mode, do_pull, do_push @@ -308,6 +322,7 @@ impl TaskHandler for UserPluginSyncHandler { user_id, task.id, codex_settings.sync_ratings, + &allowed_library_ids, ) .await; @@ -343,7 +358,15 @@ impl TaskHandler for UserPluginSyncHandler { // Step 3: Push progress to external service let (pushed_count, push_failures, push_error) = if do_push { let entries = if let Some(ref source) = external_id_source { - push::build_push_entries(db, user_id, source, task.id, &codex_settings).await + push::build_push_entries( + db, + user_id, + source, + task.id, + &codex_settings, + &allowed_library_ids, + ) + .await } else { warn!( "Task {}: Plugin has no externalIdSource in manifest — cannot build push entries", diff --git a/crates/codex-tasks/src/handlers/user_plugin_sync/pull.rs b/crates/codex-tasks/src/handlers/user_plugin_sync/pull.rs index e3826010..ba0ae37b 100644 --- a/crates/codex-tasks/src/handlers/user_plugin_sync/pull.rs +++ b/crates/codex-tasks/src/handlers/user_plugin_sync/pull.rs @@ -7,7 +7,8 @@ use tracing::{debug, warn}; use uuid::Uuid; use codex_db::repositories::{ - BookRepository, ReadProgressRepository, SeriesExternalIdRepository, UserSeriesRatingRepository, + BookRepository, ReadProgressRepository, SeriesExternalIdRepository, SeriesRepository, + UserSeriesRatingRepository, }; use codex_services::plugin::sync::{SyncEntry, SyncReadingStatus}; @@ -19,6 +20,11 @@ use codex_services::plugin::sync::{SyncEntry, SyncReadingStatus}; /// When a match is found, applies the pulled reading progress to the user's /// Codex books (each book = 1 chapter). /// +/// Only series in a library the plugin is scoped to are updated. +/// `allowed_library_ids` empty means "all libraries". When a pulled entry's +/// external ID matches series in multiple in-scope libraries (a duplicate), the +/// progress is applied to all of them. +/// /// Returns `(matched, applied)` — matched entries count and books updated. pub(crate) async fn match_and_apply_pulled_entries( db: &DatabaseConnection, @@ -27,6 +33,7 @@ pub(crate) async fn match_and_apply_pulled_entries( user_id: Uuid, task_id: Uuid, sync_ratings: bool, + allowed_library_ids: &[Uuid], ) -> (u32, u32) { let Some(source) = external_id_source else { debug!( @@ -40,9 +47,11 @@ pub(crate) async fn match_and_apply_pulled_entries( return (0, 0); } - // 1. Batch-fetch all external ID → series mappings (1 query instead of N) + // 1. Batch-fetch all external ID → series mappings (1 query instead of N). + // Grouped so the same external ID can resolve to several series + // (the same title duplicated across libraries). let entry_external_ids: Vec = entries.iter().map(|e| e.external_id.clone()).collect(); - let ext_id_map = match SeriesExternalIdRepository::find_by_external_ids_and_source( + let ext_id_map = match SeriesExternalIdRepository::find_all_by_external_ids_and_source( db, &entry_external_ids, source, @@ -59,8 +68,27 @@ pub(crate) async fn match_and_apply_pulled_entries( } }; + // Resolve each matched series' library so out-of-scope matches can be + // filtered out. `series_id -> library_id`. + let all_matched_series_ids: Vec = + ext_id_map.values().flatten().map(|e| e.series_id).collect(); + let series_library: HashMap = + SeriesRepository::get_by_ids(db, &all_matched_series_ids) + .await + .unwrap_or_default() + .into_iter() + .map(|s| (s.id, s.library_id)) + .collect(); + let in_scope = |series_id: &Uuid| -> bool { + match series_library.get(series_id) { + Some(lib) => allowed_library_ids.is_empty() || allowed_library_ids.contains(lib), + // Unknown library (lookup failed): apply only when unscoped. + None => allowed_library_ids.is_empty(), + } + }; + // 2. Batch-fetch books for all matched series (1 query instead of N) - let matched_series_ids: Vec = ext_id_map.values().map(|e| e.series_id).collect(); + let matched_series_ids: Vec = all_matched_series_ids.clone(); let books_map = match BookRepository::get_by_series_ids(db, &matched_series_ids).await { Ok(map) => map, Err(e) => { @@ -108,56 +136,69 @@ pub(crate) async fn match_and_apply_pulled_entries( let mut applied: u32 = 0; for entry in entries { - match ext_id_map.get(&entry.external_id) { - Some(ext_id) => { - debug!( - "Task {}: Matched entry {} -> series {} (source: {})", - task_id, entry.external_id, ext_id.series_id, source - ); - matched += 1; - - // Apply reading progress using pre-fetched data - let books_applied = apply_pulled_entry( - db, - user_id, - ext_id.series_id, - entry, - task_id, - &books_map, - &progress_map, - ) - .await; - applied += books_applied; - - // Apply pulled rating/notes if enabled and Codex has no existing rating - if sync_ratings && let Some(pulled_score) = entry.score { - if !existing_ratings.contains_key(&ext_id.series_id) { - let score_i32 = (pulled_score.round() as i32).clamp(1, 100); - if let Err(e) = UserSeriesRatingRepository::upsert( - db, - user_id, - ext_id.series_id, - score_i32, - entry.notes.clone(), - ) - .await - { - warn!( - "Task {}: Failed to apply pulled rating for series {}: {}", - task_id, ext_id.series_id, e - ); - } - } else { - debug!( - "Task {}: Skipping pulled rating for series {} — Codex already has a rating", - task_id, ext_id.series_id + // All in-scope series this external ID maps to (duplicates update all). + let scoped_matches: Vec = ext_id_map + .get(&entry.external_id) + .map(|matches| { + matches + .iter() + .map(|e| e.series_id) + .filter(|sid| in_scope(sid)) + .collect() + }) + .unwrap_or_default(); + + if scoped_matches.is_empty() { + unmatched += 1; + continue; + } + + matched += 1; + + for series_id in scoped_matches { + debug!( + "Task {}: Matched entry {} -> series {} (source: {})", + task_id, entry.external_id, series_id, source + ); + + // Apply reading progress using pre-fetched data + let books_applied = apply_pulled_entry( + db, + user_id, + series_id, + entry, + task_id, + &books_map, + &progress_map, + ) + .await; + applied += books_applied; + + // Apply pulled rating/notes if enabled and Codex has no existing rating + if sync_ratings && let Some(pulled_score) = entry.score { + if !existing_ratings.contains_key(&series_id) { + let score_i32 = (pulled_score.round() as i32).clamp(1, 100); + if let Err(e) = UserSeriesRatingRepository::upsert( + db, + user_id, + series_id, + score_i32, + entry.notes.clone(), + ) + .await + { + warn!( + "Task {}: Failed to apply pulled rating for series {}: {}", + task_id, series_id, e ); } + } else { + debug!( + "Task {}: Skipping pulled rating for series {} — Codex already has a rating", + task_id, series_id + ); } } - None => { - unmatched += 1; - } } } diff --git a/crates/codex-tasks/src/handlers/user_plugin_sync/push.rs b/crates/codex-tasks/src/handlers/user_plugin_sync/push.rs index bc1fcd2d..439a7b40 100644 --- a/crates/codex-tasks/src/handlers/user_plugin_sync/push.rs +++ b/crates/codex-tasks/src/handlers/user_plugin_sync/push.rs @@ -16,12 +16,13 @@ use codex_services::plugin::sync::{SyncEntry, SyncProgress, SyncReadingStatus}; use super::settings::CodexSyncSettings; /// Resolve `series_id -> (library_id, library_name)` for the given series so -/// push entries can carry their library context. Degrades to an empty map on -/// failure; entries then fall back to empty library fields. +/// push entries can carry their library context and be filtered by library +/// scope. Degrades to an empty map on failure; entries then fall back to empty +/// library fields. async fn series_library_info( db: &DatabaseConnection, series_ids: &[Uuid], -) -> HashMap { +) -> HashMap { let series = SeriesRepository::get_by_ids(db, series_ids) .await .unwrap_or_default(); @@ -38,23 +39,33 @@ async fn series_library_info( .into_iter() .map(|s| { let name = names.get(&s.library_id).cloned().unwrap_or_default(); - (s.id, (s.library_id.to_string(), name)) + (s.id, (s.library_id, name)) }) .collect() } +/// Whether a series in `library_id` is in scope for a plugin allowed to act on +/// `allowed_library_ids`. An empty allowed set means "all libraries". +fn library_in_scope(allowed_library_ids: &[Uuid], library_id: Uuid) -> bool { + allowed_library_ids.is_empty() || allowed_library_ids.contains(&library_id) +} + /// Build push entries from a user's Codex reading progress. /// /// For each series that has an external ID matching the given source, /// aggregates book-level reading progress into a single `SyncEntry`. /// Behaviour is controlled by `CodexSyncSettings` (which series to /// include, whether partial-progress books count, ratings). +/// +/// Only series in a library the plugin is scoped to are included. +/// `allowed_library_ids` empty means "all libraries". pub(crate) async fn build_push_entries( db: &DatabaseConnection, user_id: Uuid, external_id_source: &str, task_id: Uuid, settings: &CodexSyncSettings, + allowed_library_ids: &[Uuid], ) -> Vec { // 1. Get all series that have external IDs for this source (1 query) let external_ids = @@ -76,19 +87,29 @@ pub(crate) async fn build_push_entries( external_id_source ); - let external_ids_count = external_ids.len(); - let matched_series_ids: HashSet = external_ids.iter().map(|e| e.series_id).collect(); - if external_ids.is_empty() && !settings.search_fallback { return vec![]; } + // Resolve library context (id + name) for every candidate series, then drop + // series whose library is out of scope for this plugin. + let candidate_series_ids: Vec = external_ids.iter().map(|e| e.series_id).collect(); + let lib_info = series_library_info(db, &candidate_series_ids).await; + let external_ids: Vec<_> = external_ids + .into_iter() + .filter(|e| match lib_info.get(&e.series_id) { + Some((lib, _)) => library_in_scope(allowed_library_ids, *lib), + // Unknown library (lookup failed): keep only when unscoped. + None => allowed_library_ids.is_empty(), + }) + .collect(); + + let external_ids_count = external_ids.len(); + let matched_series_ids: HashSet = external_ids.iter().map(|e| e.series_id).collect(); + // Collect all series IDs for batch queries let series_ids: Vec = external_ids.iter().map(|e| e.series_id).collect(); - // Resolve library context (id + name) for each series so entries carry it. - let lib_info = series_library_info(db, &series_ids).await; - // 2. Batch-fetch all books grouped by series (1 query instead of N) let books_map = match BookRepository::get_by_series_ids(db, &series_ids).await { Ok(map) => map, @@ -304,7 +325,7 @@ pub(crate) async fn build_push_entries( title: metadata_map.get(&ext_id.series_id).map(|m| m.title.clone()), library_id: lib_info .get(&ext_id.series_id) - .map(|(id, _)| id.clone()) + .map(|(id, _)| id.to_string()) .unwrap_or_default(), library_name: lib_info .get(&ext_id.series_id) @@ -323,8 +344,15 @@ pub(crate) async fn build_push_entries( // When search_fallback is enabled, also include series that have reading // progress but no external ID for this source. The plugin will search by title. if settings.search_fallback { - let unmatched = - build_unmatched_entries(db, user_id, task_id, settings, &matched_series_ids).await; + let unmatched = build_unmatched_entries( + db, + user_id, + task_id, + settings, + &matched_series_ids, + allowed_library_ids, + ) + .await; debug!( "Task {}: Built {} unmatched entries for search fallback", @@ -347,6 +375,7 @@ async fn build_unmatched_entries( task_id: Uuid, settings: &CodexSyncSettings, matched_series_ids: &HashSet, + allowed_library_ids: &[Uuid], ) -> Vec { // 1. Get all reading progress for this user let all_progress = match ReadProgressRepository::get_by_user(db, user_id).await { @@ -456,6 +485,15 @@ async fn build_unmatched_entries( let mut entries = Vec::new(); for &series_id in &unmatched_series_ids { + // Skip series outside the plugin's library scope. + let in_scope = match lib_info.get(&series_id) { + Some((lib, _)) => library_in_scope(allowed_library_ids, *lib), + None => allowed_library_ids.is_empty(), + }; + if !in_scope { + continue; + } + let title = match metadata_map.get(&series_id) { Some(m) => m.title.clone(), None => continue, // Skip series without metadata — we need a title for search @@ -574,7 +612,7 @@ async fn build_unmatched_entries( title: Some(title), library_id: lib_info .get(&series_id) - .map(|(id, _)| id.clone()) + .map(|(id, _)| id.to_string()) .unwrap_or_default(), library_name: lib_info .get(&series_id) diff --git a/crates/codex-tasks/src/handlers/user_plugin_sync/tests.rs b/crates/codex-tasks/src/handlers/user_plugin_sync/tests.rs index 2905c1b9..163390ee 100644 --- a/crates/codex-tasks/src/handlers/user_plugin_sync/tests.rs +++ b/crates/codex-tasks/src/handlers/user_plugin_sync/tests.rs @@ -277,6 +277,7 @@ async fn test_match_and_apply_no_source() { user_id, Uuid::new_v4(), false, + &[], ) .await; assert_eq!(matched, 0); @@ -350,6 +351,7 @@ async fn test_match_and_apply_with_matches() { user_id, Uuid::new_v4(), false, + &[], ) .await; assert_eq!(matched, 1); @@ -419,6 +421,7 @@ async fn test_match_and_apply_pulled_entries_applies_progress() { user_id, Uuid::new_v4(), false, + &[], ) .await; assert_eq!(matched, 1); @@ -517,6 +520,7 @@ async fn test_match_and_apply_skips_already_read() { user_id, Uuid::new_v4(), false, + &[], ) .await; assert_eq!(matched, 1); @@ -524,6 +528,199 @@ async fn test_match_and_apply_skips_already_read() { assert_eq!(applied, 2); } +/// A pulled entry for `external_id`, marked completed (used by scope tests). +fn pulled_completed_entry(external_id: &str) -> SyncEntry { + SyncEntry { + external_id: external_id.to_string(), + status: SyncReadingStatus::Completed, + progress: None, + score: None, + started_at: None, + completed_at: None, + notes: None, + latest_updated_at: None, + title: None, + library_id: String::new(), + library_name: String::new(), + } +} + +#[tokio::test] +async fn test_build_push_entries_respects_library_scope() { + let (db, _temp_dir) = create_test_db().await; + let user = create_test_user(db.sea_orm_connection()).await; + let user_id = user.id; + + // Two libraries; identical setup in each. + let mut library_ids = Vec::new(); + for (name, path, ext) in [ + ("Allowed", "/allowed", "100"), + ("Excluded", "/excluded", "200"), + ] { + let library = LibraryRepository::create( + db.sea_orm_connection(), + name, + path, + ScanningStrategy::Default, + ) + .await + .unwrap(); + let series = SeriesRepository::create(db.sea_orm_connection(), library.id, name, None) + .await + .unwrap(); + let book = create_test_book(db.sea_orm_connection(), series.id, library.id, 1, 50).await; + ReadProgressRepository::mark_as_read(db.sea_orm_connection(), user_id, book.id, 50) + .await + .unwrap(); + SeriesExternalIdRepository::create( + db.sea_orm_connection(), + series.id, + "api:anilist", + ext, + None, + None, + ) + .await + .unwrap(); + library_ids.push(library.id); + } + let allowed = library_ids[0]; + + // Scoped to the "Allowed" library only. + let entries = push::build_push_entries( + db.sea_orm_connection(), + user_id, + "api:anilist", + Uuid::new_v4(), + &default_codex_settings(), + &[allowed], + ) + .await; + + assert_eq!(entries.len(), 1, "only the in-scope library should push"); + assert_eq!(entries[0].external_id, "100"); + assert_eq!(entries[0].library_id, allowed.to_string()); + + // Empty scope = all libraries → both push. + let entries_all = push::build_push_entries( + db.sea_orm_connection(), + user_id, + "api:anilist", + Uuid::new_v4(), + &default_codex_settings(), + &[], + ) + .await; + assert_eq!(entries_all.len(), 2); +} + +/// Create a library with one series (external ID `ext`) and one unread book. +/// Returns `(library_id, book_id)`. +async fn library_with_unread_book( + db: &sea_orm::DatabaseConnection, + name: &str, + path: &str, + ext: &str, +) -> (Uuid, Uuid) { + let library = LibraryRepository::create(db, name, path, ScanningStrategy::Default) + .await + .unwrap(); + let series = SeriesRepository::create(db, library.id, name, None) + .await + .unwrap(); + let book = create_test_book(db, series.id, library.id, 1, 50).await; + SeriesExternalIdRepository::create(db, series.id, "api:anilist", ext, None, None) + .await + .unwrap(); + (library.id, book.id) +} + +#[tokio::test] +async fn test_pull_applies_to_cross_library_duplicates() { + let (db, _temp_dir) = create_test_db().await; + let user = create_test_user(db.sea_orm_connection()).await; + let user_id = user.id; + + // Same external ID "555" duplicated across two libraries. + let (_lib_a, book_a) = + library_with_unread_book(db.sea_orm_connection(), "A", "/a", "555").await; + let (_lib_b, book_b) = + library_with_unread_book(db.sea_orm_connection(), "B", "/b", "555").await; + + let entries = vec![pulled_completed_entry("555")]; + + // Unscoped (all libraries): one matched entry, both books updated. + let (matched, applied) = pull::match_and_apply_pulled_entries( + db.sea_orm_connection(), + &entries, + Some("api:anilist"), + user_id, + Uuid::new_v4(), + false, + &[], + ) + .await; + assert_eq!(matched, 1, "one entry matched"); + assert_eq!(applied, 2, "both duplicate series updated"); + + for book_id in [book_a, book_b] { + let progress = + ReadProgressRepository::get_by_user_and_book(db.sea_orm_connection(), user_id, book_id) + .await + .unwrap(); + assert!(progress.unwrap().completed); + } +} + +#[tokio::test] +async fn test_pull_skips_out_of_scope_library() { + let (db, _temp_dir) = create_test_db().await; + let user = create_test_user(db.sea_orm_connection()).await; + let user_id = user.id; + + let (lib_allowed, book_allowed) = + library_with_unread_book(db.sea_orm_connection(), "Allowed", "/allowed", "777").await; + let (_lib_excluded, book_excluded) = + library_with_unread_book(db.sea_orm_connection(), "Excluded", "/excluded", "777").await; + + let entries = vec![pulled_completed_entry("777")]; + + // Scoped to the allowed library only. + let (matched, applied) = pull::match_and_apply_pulled_entries( + db.sea_orm_connection(), + &entries, + Some("api:anilist"), + user_id, + Uuid::new_v4(), + false, + &[lib_allowed], + ) + .await; + assert_eq!(matched, 1); + assert_eq!(applied, 1, "only the in-scope duplicate is updated"); + + let allowed_progress = ReadProgressRepository::get_by_user_and_book( + db.sea_orm_connection(), + user_id, + book_allowed, + ) + .await + .unwrap(); + assert!(allowed_progress.unwrap().completed); + + let excluded_progress = ReadProgressRepository::get_by_user_and_book( + db.sea_orm_connection(), + user_id, + book_excluded, + ) + .await + .unwrap(); + assert!( + excluded_progress.is_none(), + "out-of-scope library must not be touched" + ); +} + /// Default Codex sync settings for tests (matches production defaults) fn default_codex_settings() -> CodexSyncSettings { CodexSyncSettings { @@ -587,6 +784,7 @@ async fn test_build_push_entries_with_progress() { "api:anilist", Uuid::new_v4(), &default_codex_settings(), + &[], ) .await; @@ -652,6 +850,7 @@ async fn test_build_push_entries_all_completed() { "api:anilist", Uuid::new_v4(), &default_codex_settings(), + &[], ) .await; @@ -704,6 +903,7 @@ async fn test_build_push_entries_skips_no_progress() { "api:anilist", Uuid::new_v4(), &default_codex_settings(), + &[], ) .await; @@ -805,6 +1005,7 @@ async fn test_match_and_apply_empty() { Uuid::new_v4(), Uuid::new_v4(), false, + &[], ) .await; assert_eq!(matched, 0); @@ -893,6 +1094,7 @@ async fn test_build_push_entries_skip_completed_series() { "api:anilist", Uuid::new_v4(), &settings, + &[], ) .await; @@ -951,6 +1153,7 @@ async fn test_build_push_entries_skip_in_progress_series() { "api:anilist", Uuid::new_v4(), &settings, + &[], ) .await; @@ -1018,6 +1221,7 @@ async fn test_build_push_entries_count_in_progress_volumes() { "api:anilist", Uuid::new_v4(), &settings, + &[], ) .await; assert_eq!(entries.len(), 1); @@ -1036,6 +1240,7 @@ async fn test_build_push_entries_count_in_progress_volumes() { "api:anilist", Uuid::new_v4(), &settings_with_partial, + &[], ) .await; assert_eq!(entries.len(), 1); @@ -1108,6 +1313,7 @@ async fn test_apply_pulled_entry_uses_volumes() { user.id, Uuid::new_v4(), false, + &[], ) .await; assert_eq!(matched, 1); @@ -1208,6 +1414,7 @@ async fn test_build_push_entries_includes_rating() { "api:anilist", Uuid::new_v4(), &settings, + &[], ) .await; @@ -1269,6 +1476,7 @@ async fn test_build_push_entries_no_rating_when_disabled() { "api:anilist", Uuid::new_v4(), &settings, + &[], ) .await; @@ -1324,6 +1532,7 @@ async fn test_build_push_entries_no_rating_for_unrated() { "api:anilist", Uuid::new_v4(), &settings, + &[], ) .await; @@ -1391,6 +1600,7 @@ async fn test_apply_pulled_rating_no_existing() { user.id, Uuid::new_v4(), true, // sync_ratings=true + &[], ) .await; @@ -1482,6 +1692,7 @@ async fn test_apply_pulled_rating_existing_not_overwritten() { user.id, Uuid::new_v4(), true, + &[], ) .await; @@ -1558,6 +1769,7 @@ async fn test_apply_pulled_rating_disabled() { user.id, Uuid::new_v4(), false, // sync_ratings=false + &[], ) .await; @@ -1619,6 +1831,7 @@ async fn test_build_push_entries_populates_latest_updated_at() { "api:anilist", Uuid::new_v4(), &default_codex_settings(), + &[], ) .await; @@ -1686,6 +1899,7 @@ async fn test_build_push_entries_populates_total_volumes() { "api:anilist", Uuid::new_v4(), &default_codex_settings(), + &[], ) .await; @@ -1747,6 +1961,7 @@ async fn test_build_push_entries_always_sends_volumes() { "api:anilist", Uuid::new_v4(), &default_codex_settings(), + &[], ) .await; @@ -1849,6 +2064,7 @@ async fn test_build_push_entries_includes_unmatched_with_search_fallback() { "api:anilist", Uuid::new_v4(), &settings_no_fallback, + &[], ) .await; assert_eq!( @@ -1869,6 +2085,7 @@ async fn test_build_push_entries_includes_unmatched_with_search_fallback() { "api:anilist", Uuid::new_v4(), &settings_with_fallback, + &[], ) .await; assert_eq!( @@ -1931,6 +2148,7 @@ async fn test_build_push_entries_unmatched_skips_no_metadata() { "api:anilist", Uuid::new_v4(), &settings, + &[], ) .await; @@ -1986,6 +2204,7 @@ async fn test_build_push_entries_populates_title_for_matched() { "api:anilist", Uuid::new_v4(), &default_codex_settings(), + &[], ) .await; From 8d29853b2e4bae5681bd6d93572ee704f2ebbab8 Mon Sep 17 00:00:00 2001 From: Sylvain Cau Date: Fri, 5 Jun 2026 20:40:00 -0700 Subject: [PATCH 3/6] feat(plugins): scope recommendation generation to a plugin's allowed libraries Recommendation generation built the user library from the user's entire collection, ignoring the plugin's configured library scope. Honor the admin library_ids (empty = all libraries) so a recommendation plugin only draws seeds from the libraries it is scoped to. - build_user_library takes an allowed-libraries set and drops out-of-scope series right after fetching, so all downstream batch queries stay scoped. - The recommendations handler loads the plugin once, passes its library scope into the build, and reuses the same plugin for the exclude_ids source (removing a duplicate lookup). Because exclude_ids derives from the scoped library, exclusions stay in scope automatically. Adds the first behavioral test for build_user_library covering scope filtering and library stamping. --- crates/codex-services/src/plugin/library.rs | 56 +++++++++++++++++-- .../handlers/user_plugin_recommendations.rs | 46 +++++++++------ 2 files changed, 82 insertions(+), 20 deletions(-) diff --git a/crates/codex-services/src/plugin/library.rs b/crates/codex-services/src/plugin/library.rs index 2696e66d..feb1d394 100644 --- a/crates/codex-services/src/plugin/library.rs +++ b/crates/codex-services/src/plugin/library.rs @@ -30,16 +30,23 @@ pub async fn library_names(db: &DatabaseConnection, library_ids: &[Uuid]) -> Has } } -/// Build the full user library as `Vec` for recommendation plugins. +/// Build the user library as `Vec` for recommendation plugins. /// -/// Fetches all series, metadata, genres, tags, external IDs, reading progress, +/// Fetches series, metadata, genres, tags, external IDs, reading progress, /// and user ratings in batch, then assembles them into library entries. +/// +/// Only series in a library the plugin is scoped to are included. +/// `allowed_library_ids` empty means "all libraries". pub async fn build_user_library( db: &DatabaseConnection, user_id: Uuid, + allowed_library_ids: &[Uuid], ) -> Result> { - // 1. Get all series - let all_series = SeriesRepository::list_all(db, None).await?; + // 1. Get all series, then drop any outside the plugin's library scope. + let mut all_series = SeriesRepository::list_all(db, None).await?; + if !allowed_library_ids.is_empty() { + all_series.retain(|s| allowed_library_ids.contains(&s.library_id)); + } if all_series.is_empty() { return Ok(vec![]); } @@ -216,3 +223,44 @@ pub async fn build_user_library( Ok(entries) } + +#[cfg(test)] +mod tests { + use super::*; + use codex_db::ScanningStrategy; + use codex_db::repositories::{LibraryRepository, SeriesRepository}; + use codex_db::test_helpers::create_test_db; + + #[tokio::test] + async fn test_build_user_library_respects_library_scope_and_stamps_info() { + let (db, _temp_dir) = create_test_db().await; + let conn = db.sea_orm_connection(); + let user_id = Uuid::new_v4(); + + let lib_a = LibraryRepository::create(conn, "Library A", "/a", ScanningStrategy::Default) + .await + .unwrap(); + let lib_b = LibraryRepository::create(conn, "Library B", "/b", ScanningStrategy::Default) + .await + .unwrap(); + SeriesRepository::create(conn, lib_a.id, "Series A", None) + .await + .unwrap(); + SeriesRepository::create(conn, lib_b.id, "Series B", None) + .await + .unwrap(); + + // Scoped to library A: only its series, stamped with library context. + let entries = build_user_library(conn, user_id, &[lib_a.id]) + .await + .unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].title, "Series A"); + assert_eq!(entries[0].library_id, lib_a.id.to_string()); + assert_eq!(entries[0].library_name, "Library A"); + + // Empty scope = all libraries. + let all = build_user_library(conn, user_id, &[]).await.unwrap(); + assert_eq!(all.len(), 2); + } +} diff --git a/crates/codex-tasks/src/handlers/user_plugin_recommendations.rs b/crates/codex-tasks/src/handlers/user_plugin_recommendations.rs index 5cf20c07..dfcf7ddb 100644 --- a/crates/codex-tasks/src/handlers/user_plugin_recommendations.rs +++ b/crates/codex-tasks/src/handlers/user_plugin_recommendations.rs @@ -300,21 +300,35 @@ impl TaskHandler for UserPluginRecommendationsHandler { emit_phase(event_broadcaster, task, PHASE_BUILD_LIBRARY, None); - // Build user library data - let library = build_user_library(db, user_id).await.unwrap_or_else(|e| { - warn!( - "Task {}: Failed to build user library, using empty: {}", - task.id, e - ); - vec![] - }); - - // Resolve the plugin's external_id_source so we can populate exclude_ids - // with external IDs from series the user has already read (Reading or Completed). - // Unread series are NOT excluded — the user may want recommendations for titles - // they own but haven't started yet. - let exclude_ids = match PluginsRepository::get_by_id(db, plugin_id).await { - Ok(Some(plugin_model)) => { + // Load the plugin once: its admin library scope bounds the library + // we build, and its external_id_source drives exclude_ids below. + let plugin_model = PluginsRepository::get_by_id(db, plugin_id) + .await + .ok() + .flatten(); + let allowed_library_ids: Vec = plugin_model + .as_ref() + .map(|p| p.library_ids_vec()) + .unwrap_or_default(); + + // Build user library data, scoped to the plugin's allowed libraries. + let library = build_user_library(db, user_id, &allowed_library_ids) + .await + .unwrap_or_else(|e| { + warn!( + "Task {}: Failed to build user library, using empty: {}", + task.id, e + ); + vec![] + }); + + // Populate exclude_ids with external IDs from series the user has + // already read (Reading or Completed). Unread series are NOT excluded + // — the user may want recommendations for titles they own but haven't + // started yet. Derived from the scoped `library`, so exclusions stay + // within the plugin's library scope. + let exclude_ids = match plugin_model.as_ref() { + Some(plugin_model) => { let source = plugin_model .manifest .as_ref() @@ -343,7 +357,7 @@ impl TaskHandler for UserPluginRecommendationsHandler { vec![] } } - _ => vec![], + None => vec![], }; debug!( From 17b3c82b673f77bfca785efd7eb9287d030e10d6 Mon Sep 17 00:00:00 2001 From: Sylvain Cau Date: Fri, 5 Jun 2026 21:07:19 -0700 Subject: [PATCH 4/6] feat(recommendations): merge recommendations across all enabled provider instances MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/v1/user/recommendations previously surfaced only the first enabled recommendation provider, so a user running several providers (for example the same plugin scoped to different libraries) silently saw just one. Aggregate across all enabled provider instances instead. API (breaking shape change): - Response is now { recommendations, sources[] }. The single-plugin top-level fields are replaced by a sources array carrying each instance's status and provenance; each recommendation gains sourcePlugin + source. - Recommendations are merged and deduped by external ID — highest score wins, reasons combined, ordered by score — and enriched with local presence per-source before merging. - Refresh enqueues a task per enabled instance and returns taskIds; it only conflicts when every instance is already refreshing. - Dismiss removes the item from every instance cache that contains it and notifies only those plugins. Frontend: - The page consumes the sources array (combined "Powered by", any-cached and any-task-active state) and adds a merged/grouped view toggle plus a source filter; cards show a "via " badge in the merged view. Regenerates the OpenAPI spec and TypeScript types. Adds backend unit and integration tests and frontend component tests. --- crates/codex-api/src/docs.rs | 1 + .../src/routes/v1/dto/recommendations.rs | 96 ++- .../src/routes/v1/handlers/recommendations.rs | 659 ++++++++++++------ docs/api/openapi.json | 117 ++-- tests/api/recommendations.rs | 115 ++- web/openapi.json | 117 ++-- .../RecommendationCard.test.tsx | 17 + .../recommendations/RecommendationCard.tsx | 19 +- .../RecommendationFilters.test.tsx | 31 + .../recommendations/RecommendationFilters.tsx | 56 +- .../RecommendationsWidget.test.tsx | 6 +- .../recommendations/RecommendationsWidget.tsx | 6 +- web/src/mocks/handlers/recommendations.ts | 40 +- web/src/pages/Recommendations.test.tsx | 57 +- web/src/pages/Recommendations.tsx | 135 +++- web/src/types/api.generated.ts | 73 +- 16 files changed, 1113 insertions(+), 432 deletions(-) diff --git a/crates/codex-api/src/docs.rs b/crates/codex-api/src/docs.rs index 600516b1..df26f127 100644 --- a/crates/codex-api/src/docs.rs +++ b/crates/codex-api/src/docs.rs @@ -986,6 +986,7 @@ The following paths are exempt from rate limiting: // Recommendation DTOs v1::dto::recommendations::RecommendationDto, + v1::dto::recommendations::RecommendationSourceDto, v1::dto::recommendations::RecommendationsResponse, v1::dto::recommendations::RecommendationsRefreshResponse, v1::dto::recommendations::DismissRecommendationRequest, diff --git a/crates/codex-api/src/routes/v1/dto/recommendations.rs b/crates/codex-api/src/routes/v1/dto/recommendations.rs index c68463f5..7b18dded 100644 --- a/crates/codex-api/src/routes/v1/dto/recommendations.rs +++ b/crates/codex-api/src/routes/v1/dto/recommendations.rs @@ -81,38 +81,66 @@ pub struct RecommendationDto { /// Popularity ranking/count on the source service #[serde(skip_serializing_if = "Option::is_none")] pub popularity: Option, + /// Display name of the plugin instance that produced this recommendation. + /// When the same item is recommended by several instances, this is the + /// highest-scoring contributor. + #[serde(default)] + pub source_plugin: String, + /// External ID source of the producing plugin (e.g. "anilist"). Used by the + /// UI to filter/group by source. + #[serde(default)] + pub source: String, } -/// Response from GET /api/v1/user/recommendations +/// One recommendation provider instance contributing to the merged response. #[derive(Debug, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] -pub struct RecommendationsResponse { - /// Personalized recommendations - pub recommendations: Vec, - /// Plugin that provided these recommendations +pub struct RecommendationSourceDto { + /// Plugin ID pub plugin_id: Uuid, /// Plugin display name pub plugin_name: String, - /// When these recommendations were generated + /// External ID source of this plugin (e.g. "anilist"). + #[serde(default)] + pub source: String, + /// When this instance's recommendations were generated #[serde(skip_serializing_if = "Option::is_none")] pub generated_at: Option, - /// Whether these are cached results + /// Whether this instance's results came from cache #[serde(default)] pub cached: bool, - /// Status of a running/pending background task ("pending" or "running"), if any + /// Status of a running/pending refresh task for this instance, if any #[serde(skip_serializing_if = "Option::is_none")] pub task_status: Option, - /// ID of the running/pending background task, if any + /// ID of the running/pending refresh task for this instance, if any #[serde(skip_serializing_if = "Option::is_none")] pub task_id: Option, } +/// Response from GET /api/v1/user/recommendations +/// +/// Recommendations from all enabled recommendation-provider instances are +/// merged into a single list (deduped by external ID, highest score wins, +/// reasons combined), each item tagged with its `source`/`sourcePlugin`. +/// `sources` carries per-instance status so the UI can show provenance and +/// per-source refresh/staleness state. +#[derive(Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct RecommendationsResponse { + /// Merged, deduped recommendations across all enabled provider instances + pub recommendations: Vec, + /// The provider instances that contributed (status, provenance) + #[serde(default)] + pub sources: Vec, +} + /// Response from POST /api/v1/user/recommendations/refresh #[derive(Debug, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct RecommendationsRefreshResponse { - /// Task ID for tracking the refresh operation - pub task_id: Uuid, + /// Task IDs enqueued — one per enabled recommendation provider instance + #[serde(default)] + pub task_ids: Vec, /// Human-readable status message pub message: String, } @@ -162,9 +190,13 @@ mod tests { total_chapter_count: None, rating: None, popularity: None, + source_plugin: "AniList Recs".to_string(), + source: "anilist".to_string(), }; let json = serde_json::to_value(&dto).unwrap(); let obj = json.as_object().unwrap(); + assert_eq!(json["sourcePlugin"], "AniList Recs"); + assert_eq!(json["source"], "anilist"); assert!(!obj.contains_key("externalUrl")); assert!(!obj.contains_key("coverUrl")); assert!(!obj.contains_key("summary")); @@ -182,20 +214,26 @@ mod tests { fn test_recommendations_response_serialization() { let resp = RecommendationsResponse { recommendations: vec![], - plugin_id: Uuid::new_v4(), - plugin_name: "AniList Recs".to_string(), - generated_at: Some("2026-02-06T12:00:00Z".to_string()), - cached: true, - task_status: None, - task_id: None, + sources: vec![RecommendationSourceDto { + plugin_id: Uuid::new_v4(), + plugin_name: "AniList Recs".to_string(), + source: "anilist".to_string(), + generated_at: Some("2026-02-06T12:00:00Z".to_string()), + cached: true, + task_status: None, + task_id: None, + }], }; let json = serde_json::to_value(&resp).unwrap(); assert!(json["recommendations"].as_array().unwrap().is_empty()); - assert!(json["cached"].as_bool().unwrap()); - assert_eq!(json["pluginName"], "AniList Recs"); + let sources = json["sources"].as_array().unwrap(); + assert_eq!(sources.len(), 1); + assert_eq!(sources[0]["pluginName"], "AniList Recs"); + assert_eq!(sources[0]["source"], "anilist"); + assert!(sources[0]["cached"].as_bool().unwrap()); // task_status and task_id should be absent when None - assert!(json.get("taskStatus").is_none()); - assert!(json.get("taskId").is_none()); + assert!(sources[0].get("taskStatus").is_none()); + assert!(sources[0].get("taskId").is_none()); } #[test] @@ -213,35 +251,35 @@ mod tests { } #[test] - fn test_recommendations_response_with_task_status() { + fn test_recommendation_source_with_task_status() { let task_id = Uuid::new_v4(); - let resp = RecommendationsResponse { - recommendations: vec![], + let source = RecommendationSourceDto { plugin_id: Uuid::new_v4(), plugin_name: "AniList Recs".to_string(), + source: "anilist".to_string(), generated_at: None, cached: false, task_status: Some("pending".to_string()), task_id: Some(task_id), }; - let json = serde_json::to_value(&resp).unwrap(); + let json = serde_json::to_value(&source).unwrap(); assert_eq!(json["taskStatus"], "pending"); assert_eq!(json["taskId"], task_id.to_string()); } #[test] - fn test_recommendations_response_with_running_status() { + fn test_recommendation_source_with_running_status() { let task_id = Uuid::new_v4(); - let resp = RecommendationsResponse { - recommendations: vec![], + let source = RecommendationSourceDto { plugin_id: Uuid::new_v4(), plugin_name: "Test Plugin".to_string(), + source: "anilist".to_string(), generated_at: Some("2026-02-11T10:00:00Z".to_string()), cached: true, task_status: Some("running".to_string()), task_id: Some(task_id), }; - let json = serde_json::to_value(&resp).unwrap(); + let json = serde_json::to_value(&source).unwrap(); assert_eq!(json["taskStatus"], "running"); assert_eq!(json["taskId"], task_id.to_string()); assert!(json["cached"].as_bool().unwrap()); diff --git a/crates/codex-api/src/routes/v1/handlers/recommendations.rs b/crates/codex-api/src/routes/v1/handlers/recommendations.rs index 3e273365..69bce3f9 100644 --- a/crates/codex-api/src/routes/v1/handlers/recommendations.rs +++ b/crates/codex-api/src/routes/v1/handlers/recommendations.rs @@ -6,7 +6,7 @@ use super::super::dto::recommendations::{ DismissRecommendationRequest, DismissRecommendationResponse, RecommendationDto, - RecommendationsRefreshResponse, RecommendationsResponse, + RecommendationSourceDto, RecommendationsRefreshResponse, RecommendationsResponse, }; use crate::extractors::auth::AuthContext; use crate::{error::ApiError, extractors::AppState}; @@ -26,24 +26,25 @@ use std::sync::Arc; use tracing::{debug, info, warn}; use uuid::Uuid; -/// Find the user's recommendation plugin. +type RecPlugin = ( + codex_db::entities::plugins::Model, + codex_db::entities::user_plugins::Model, +); + +/// Find ALL of the user's enabled recommendation-provider plugin instances. /// -/// Returns the plugin definition and user plugin instance for the first enabled -/// recommendation provider plugin the user has connected. -async fn find_recommendation_plugin( +/// A user can enable the same plugin more than once (as distinct instances, +/// e.g. scoped to different libraries); every enabled recommendation provider +/// contributes to the merged response. +async fn find_recommendation_plugins( db: &sea_orm::DatabaseConnection, user_id: Uuid, -) -> Result< - ( - codex_db::entities::plugins::Model, - codex_db::entities::user_plugins::Model, - ), - ApiError, -> { +) -> Result, ApiError> { let user_instances = UserPluginsRepository::get_enabled_for_user(db, user_id) .await .map_err(|e| ApiError::Internal(format!("Failed to get user plugins: {}", e)))?; + let mut result = Vec::new(); for instance in user_instances { let plugin = PluginsRepository::get_by_id(db, instance.plugin_id) .await @@ -58,15 +59,28 @@ async fn find_recommendation_plugin( .unwrap_or(false); if is_rec_provider { - return Ok((plugin, instance)); + result.push((plugin, instance)); } } } - Err(ApiError::NotFound( - "No recommendation plugin enabled. Enable a recommendation plugin in Settings > Integrations." - .to_string(), - )) + if result.is_empty() { + return Err(ApiError::NotFound( + "No recommendation plugin enabled. Enable a recommendation plugin in Settings > Integrations." + .to_string(), + )); + } + + Ok(result) +} + +/// Resolve a plugin's `external_id_source` (e.g. "anilist"), if declared. +fn plugin_source(plugin: &codex_db::entities::plugins::Model) -> Option { + plugin + .manifest + .as_ref() + .and_then(|m| serde_json::from_value::(m.clone()).ok()) + .and_then(|m| m.capabilities.external_id_source) } /// Default max age for recommendations in hours before considered stale @@ -92,25 +106,47 @@ pub async fn get_recommendations( State(state): State>, auth: AuthContext, ) -> Result, ApiError> { - let (plugin, instance) = find_recommendation_plugin(&state.db, auth.user_id).await?; + let instances = find_recommendation_plugins(&state.db, auth.user_id).await?; - debug!( - user_id = %auth.user_id, - plugin_id = %plugin.id, - "Reading cached recommendations from DB" - ); + let mut all_recommendations: Vec = Vec::new(); + let mut sources: Vec = Vec::new(); + + for (plugin, instance) in &instances { + let (recs, source) = + collect_instance_recommendations(&state, auth.user_id, plugin, instance).await; + all_recommendations.extend(recs); + sources.push(source); + } + + // Merge across instances: dedupe by external ID (highest score wins, + // reasons combined), then order by score desc. + let recommendations = merge_recommendations(all_recommendations); + + Ok(Json(RecommendationsResponse { + recommendations, + sources, + })) +} + +/// Read one instance's cached recommendations, handle staleness/auto-refresh, +/// enrich with Codex presence, stamp provenance, and return its contribution +/// plus a source-status entry. +async fn collect_instance_recommendations( + state: &AppState, + user_id: Uuid, + plugin: &codex_db::entities::plugins::Model, + instance: &codex_db::entities::user_plugins::Model, +) -> (Vec, RecommendationSourceDto) { + let source = plugin_source(plugin).unwrap_or_default(); - // Read cached recommendations from user_plugin_data let cached_entry = UserPluginDataRepository::get(&state.db, instance.id, "recommendations") .await - .map_err(|e| ApiError::Internal(format!("Failed to read cached recommendations: {}", e)))?; + .unwrap_or(None); - // Try to deserialize cached data let cached_response = cached_entry.as_ref().and_then(|entry| { serde_json::from_value::(entry.data.clone()).ok() }); - // Check staleness let max_age_hours = plugin .config .get("recommendations_max_age_hours") @@ -122,112 +158,161 @@ pub async fn get_recommendations( age.num_hours() >= max_age_hours }); - let has_data = cached_response.is_some(); - - // Check for active task + // Per-instance active-task guard so we never double-enqueue. let active_task = TaskRepository::find_pending_or_processing_task( &state.db, "user_plugin_recommendations", plugin.id, - auth.user_id, + user_id, ) .await - .map_err(|e| ApiError::Internal(format!("Failed to check task status: {}", e)))?; - - // Auto-trigger refresh if empty or stale and no task already running - if is_stale && active_task.is_none() { - debug!( - user_id = %auth.user_id, - plugin_id = %plugin.id, - has_data = has_data, - "Recommendations empty or stale, auto-triggering refresh task" - ); + .unwrap_or(None); + // Auto-trigger a refresh for this instance if stale and nothing running. + let (task_status, task_id) = if is_stale && active_task.is_none() { let task_type = TaskType::UserPluginRecommendations { plugin_id: plugin.id, - user_id: auth.user_id, + user_id, }; - match TaskRepository::enqueue(&state.db, task_type, None).await { Ok(task_id) => { info!( - user_id = %auth.user_id, + user_id = %user_id, plugin_id = %plugin.id, task_id = %task_id, "Auto-enqueued recommendations refresh task" ); - // Return response with the newly created task info - let (mut recommendations, generated_at, cached) = match cached_response { - Some(resp) => ( - resp.recommendations - .into_iter() - .map(to_recommendation_dto) - .collect(), - resp.generated_at, - true, - ), - None => (vec![], None, false), - }; - - enrich_and_filter_codex_presence(&state.db, &mut recommendations, &plugin).await; - - return Ok(Json(RecommendationsResponse { - recommendations, - plugin_id: plugin.id, - plugin_name: plugin.display_name.clone(), - generated_at, - cached, - task_status: Some("pending".to_string()), - task_id: Some(task_id), - })); + (Some("pending".to_string()), Some(task_id)) } Err(e) => { warn!( - user_id = %auth.user_id, + user_id = %user_id, plugin_id = %plugin.id, error = %e, "Failed to auto-enqueue refresh task" ); + (None, None) } } - } - - // Map DB status "processing" → API "running" for frontend consistency - let (task_status, task_id) = match active_task { - Some((id, status)) => { - let api_status = match status.as_str() { - "processing" => "running", - other => other, - }; - (Some(api_status.to_string()), Some(id)) + } else { + // Map DB status "processing" → API "running" for frontend consistency. + match active_task { + Some((id, status)) => { + let api_status = match status.as_str() { + "processing" => "running", + other => other, + }; + (Some(api_status.to_string()), Some(id)) + } + None => (None, None), } - None => (None, None), }; - // Build response from cached data let (mut recommendations, generated_at, cached) = match cached_response { Some(resp) => ( resp.recommendations .into_iter() - .map(to_recommendation_dto) + .map(|r| to_recommendation_dto(r, plugin.display_name.clone(), source.clone())) .collect(), resp.generated_at, true, ), - None => (vec![], None, false), + None => (Vec::new(), None, false), }; - enrich_and_filter_codex_presence(&state.db, &mut recommendations, &plugin).await; + enrich_and_filter_codex_presence(&state.db, &mut recommendations, plugin).await; - Ok(Json(RecommendationsResponse { - recommendations, + let source_dto = RecommendationSourceDto { plugin_id: plugin.id, plugin_name: plugin.display_name.clone(), + source, generated_at, cached, task_status, task_id, - })) + }; + + (recommendations, source_dto) +} + +/// Merge recommendations from several instances into one list: dedupe by +/// external ID keeping the highest score, combining the distinct reasons and +/// unioning `based_on`; then order by score descending. Stable for equal +/// scores (insertion order preserved). +fn merge_recommendations(recs: Vec) -> Vec { + use std::collections::HashMap; + + let mut order: Vec = Vec::new(); + let mut by_id: HashMap = HashMap::new(); + + for rec in recs { + match by_id.get_mut(&rec.external_id) { + None => { + order.push(rec.external_id.clone()); + by_id.insert(rec.external_id.clone(), rec); + } + Some(existing) => { + let combined_reason = combine_reasons(&existing.reason, &rec.reason); + let combined_based_on = union_strings(&existing.based_on, &rec.based_on); + let in_codex = existing.in_codex || rec.in_codex; + let in_library = existing.in_library || rec.in_library; + + if rec.score > existing.score { + // Higher-scoring contributor becomes the base. + let mut winner = rec; + winner.reason = combined_reason; + winner.based_on = combined_based_on; + winner.in_codex = in_codex; + winner.in_library = in_library; + *existing = winner; + } else { + existing.reason = combined_reason; + existing.based_on = combined_based_on; + existing.in_codex = in_codex; + existing.in_library = in_library; + } + } + } + } + + let mut merged: Vec = order + .into_iter() + .filter_map(|id| by_id.remove(&id)) + .collect(); + + // Stable sort: equal scores keep insertion order. + merged.sort_by(|a, b| { + b.score + .partial_cmp(&a.score) + .unwrap_or(std::cmp::Ordering::Equal) + }); + + merged +} + +/// Combine two recommendation reasons, keeping distinct text joined with " · ". +fn combine_reasons(a: &str, b: &str) -> String { + if a.is_empty() { + return b.to_string(); + } + if b.is_empty() || a == b || a.contains(b) { + return a.to_string(); + } + if b.contains(a) { + return b.to_string(); + } + format!("{a} · {b}") +} + +/// Union of two string lists preserving order, first-seen wins. +fn union_strings(a: &[String], b: &[String]) -> Vec { + let mut out = a.to_vec(); + for s in b { + if !out.contains(s) { + out.push(s.clone()); + } + } + out } /// Refresh recommendations @@ -249,45 +334,59 @@ pub async fn refresh_recommendations( State(state): State>, auth: AuthContext, ) -> Result, ApiError> { - let (plugin, _instance) = find_recommendation_plugin(&state.db, auth.user_id).await?; + let instances = find_recommendation_plugins(&state.db, auth.user_id).await?; + + // Enqueue a refresh for every instance that doesn't already have one + // running. Conflict only if ALL instances are already refreshing. + let mut task_ids = Vec::new(); + let mut names = Vec::new(); + let mut already_running = 0usize; + + for (plugin, _instance) in &instances { + let has_existing = TaskRepository::has_pending_or_processing( + &state.db, + "user_plugin_recommendations", + plugin.id, + auth.user_id, + ) + .await + .map_err(|e| ApiError::Internal(format!("Failed to check existing tasks: {}", e)))?; - // Check for duplicate pending/processing recommendation task - let has_existing = TaskRepository::has_pending_or_processing( - &state.db, - "user_plugin_recommendations", - plugin.id, - auth.user_id, - ) - .await - .map_err(|e| ApiError::Internal(format!("Failed to check existing tasks: {}", e)))?; + if has_existing { + already_running += 1; + continue; + } + + let task_type = TaskType::UserPluginRecommendations { + plugin_id: plugin.id, + user_id: auth.user_id, + }; - if has_existing { + let task_id = TaskRepository::enqueue(&state.db, task_type, None) + .await + .map_err(|e| { + ApiError::Internal(format!("Failed to enqueue recommendations task: {}", e)) + })?; + + info!( + user_id = %auth.user_id, + plugin_id = %plugin.id, + task_id = %task_id, + "Enqueued recommendations refresh task" + ); + task_ids.push(task_id); + names.push(plugin.display_name.clone()); + } + + if task_ids.is_empty() && already_running == instances.len() { return Err(ApiError::Conflict( "Recommendation refresh already in progress".to_string(), )); } - let task_type = TaskType::UserPluginRecommendations { - plugin_id: plugin.id, - user_id: auth.user_id, - }; - - let task_id = TaskRepository::enqueue(&state.db, task_type, None) - .await - .map_err(|e| { - ApiError::Internal(format!("Failed to enqueue recommendations task: {}", e)) - })?; - - info!( - user_id = %auth.user_id, - plugin_id = %plugin.id, - task_id = %task_id, - "Enqueued recommendations refresh task" - ); - Ok(Json(RecommendationsRefreshResponse { - task_id, - message: format!("Refreshing recommendations from {}", plugin.display_name), + task_ids, + message: format!("Refreshing recommendations from {}", names.join(", ")), })) } @@ -355,6 +454,8 @@ async fn enrich_and_filter_codex_presence( /// into the API response type field-by-field. fn to_recommendation_dto( r: codex_services::plugin::recommendations::Recommendation, + source_plugin: String, + source: String, ) -> RecommendationDto { use super::super::dto::recommendations::RecommendationTagDto; @@ -388,6 +489,8 @@ fn to_recommendation_dto( total_chapter_count: r.total_chapter_count, rating: r.rating, popularity: r.popularity, + source_plugin, + source, } } @@ -415,49 +518,16 @@ pub async fn dismiss_recommendation( Path(external_id): Path, Json(request): Json, ) -> Result, ApiError> { - let (plugin, instance) = find_recommendation_plugin(&state.db, auth.user_id).await?; + let instances = find_recommendation_plugins(&state.db, auth.user_id).await?; debug!( user_id = %auth.user_id, - plugin_id = %plugin.id, external_id = %external_id, - "Dismissing recommendation (non-blocking)" + instances = instances.len(), + "Dismissing recommendation across all instances (non-blocking)" ); - // 1. Read cached recommendations from DB - let cached_entry = UserPluginDataRepository::get(&state.db, instance.id, "recommendations") - .await - .map_err(|e| ApiError::Internal(format!("Failed to read cached recommendations: {}", e)))?; - - // 2. Filter out the dismissed entry and write back - if let Some(entry) = cached_entry - && let Ok(mut cached) = serde_json::from_value::(entry.data.clone()) - { - let before_count = cached.recommendations.len(); - cached - .recommendations - .retain(|r| r.external_id != external_id); - - if cached.recommendations.len() < before_count { - let updated_data = serde_json::to_value(&cached).map_err(|e| { - ApiError::Internal(format!("Failed to serialize recommendations: {}", e)) - })?; - - UserPluginDataRepository::set( - &state.db, - instance.id, - "recommendations", - updated_data, - None, - ) - .await - .map_err(|e| { - ApiError::Internal(format!("Failed to update cached recommendations: {}", e)) - })?; - } - } - - // 3. Parse dismiss reason + // Parse dismiss reason once. let reason = request.reason.and_then(|r| match r.as_str() { "not_interested" => Some("not_interested".to_string()), "already_read" => Some("already_read".to_string()), @@ -465,21 +535,67 @@ pub async fn dismiss_recommendation( _ => None, }); - // 4. Enqueue async task to notify plugin - let task_type = TaskType::UserPluginRecommendationDismiss { - plugin_id: plugin.id, - user_id: auth.user_id, - external_id: external_id.clone(), - reason, - }; + // The same external ID can appear in multiple instances' caches (the same + // title recommended by more than one provider). Remove it from each cache + // that has it and notify only those plugins. + for (plugin, instance) in &instances { + let cached_entry = UserPluginDataRepository::get(&state.db, instance.id, "recommendations") + .await + .map_err(|e| { + ApiError::Internal(format!("Failed to read cached recommendations: {}", e)) + })?; - if let Err(e) = TaskRepository::enqueue(&state.db, task_type, None).await { - warn!( - plugin_id = %plugin.id, - external_id = %external_id, - error = %e, - "Failed to enqueue dismiss task (dismissal from cache still succeeded)" - ); + let removed = if let Some(entry) = cached_entry + && let Ok(mut cached) = + serde_json::from_value::(entry.data.clone()) + { + let before_count = cached.recommendations.len(); + cached + .recommendations + .retain(|r| r.external_id != external_id); + + if cached.recommendations.len() < before_count { + let updated_data = serde_json::to_value(&cached).map_err(|e| { + ApiError::Internal(format!("Failed to serialize recommendations: {}", e)) + })?; + + UserPluginDataRepository::set( + &state.db, + instance.id, + "recommendations", + updated_data, + None, + ) + .await + .map_err(|e| { + ApiError::Internal(format!("Failed to update cached recommendations: {}", e)) + })?; + true + } else { + false + } + } else { + false + }; + + // Only notify plugins whose cache actually contained the item. + if removed { + let task_type = TaskType::UserPluginRecommendationDismiss { + plugin_id: plugin.id, + user_id: auth.user_id, + external_id: external_id.clone(), + reason: reason.clone(), + }; + + if let Err(e) = TaskRepository::enqueue(&state.db, task_type, None).await { + warn!( + plugin_id = %plugin.id, + external_id = %external_id, + error = %e, + "Failed to enqueue dismiss task (dismissal from cache still succeeded)" + ); + } + } } Ok(Json(DismissRecommendationResponse { dismissed: true })) @@ -560,8 +676,10 @@ mod tests { popularity: Some(50000), }; - let dto = to_recommendation_dto(rec); + let dto = to_recommendation_dto(rec, "AniList Recs".to_string(), "anilist".to_string()); + assert_eq!(dto.source_plugin, "AniList Recs"); + assert_eq!(dto.source, "anilist"); assert_eq!(dto.external_id, "12345"); assert_eq!( dto.external_url.as_deref(), @@ -615,7 +733,7 @@ mod tests { popularity: None, }; - let dto = to_recommendation_dto(rec); + let dto = to_recommendation_dto(rec, "AniList Recs".to_string(), "anilist".to_string()); assert_eq!(dto.external_id, "99"); assert!(dto.external_url.is_none()); @@ -641,70 +759,83 @@ mod tests { use codex_db::entities::SeriesStatus; let recs = vec![ - to_recommendation_dto(Recommendation { - external_id: "1".to_string(), - external_url: Some("https://example.com/1".to_string()), - title: "Manga A".to_string(), - cover_url: Some("https://img.example.com/a.jpg".to_string()), - summary: Some("Description A".to_string()), - genres: vec!["Action".to_string()], - tags: None, - score: 0.9, - reason: "Based on your library".to_string(), - based_on: vec!["Source A".to_string()], - codex_series_id: None, - in_library: false, - status: Some(SeriesStatus::Ongoing), - format: Some("MANGA".to_string()), - country_of_origin: Some("JP".to_string()), - start_year: Some(2005), - total_volume_count: Some(30), - total_chapter_count: None, - rating: Some(88), - popularity: Some(75000), - }), - to_recommendation_dto(Recommendation { - external_id: "2".to_string(), - external_url: None, - title: "Manga B".to_string(), - cover_url: None, - summary: None, - genres: vec![], - tags: None, - score: 0.7, - reason: "Popular in your genre".to_string(), - based_on: vec![], - codex_series_id: Some("series-id".to_string()), - in_library: true, - status: None, - format: None, - country_of_origin: None, - start_year: None, - total_volume_count: None, - total_chapter_count: None, - rating: None, - popularity: None, - }), + to_recommendation_dto( + Recommendation { + external_id: "1".to_string(), + external_url: Some("https://example.com/1".to_string()), + title: "Manga A".to_string(), + cover_url: Some("https://img.example.com/a.jpg".to_string()), + summary: Some("Description A".to_string()), + genres: vec!["Action".to_string()], + tags: None, + score: 0.9, + reason: "Based on your library".to_string(), + based_on: vec!["Source A".to_string()], + codex_series_id: None, + in_library: false, + status: Some(SeriesStatus::Ongoing), + format: Some("MANGA".to_string()), + country_of_origin: Some("JP".to_string()), + start_year: Some(2005), + total_volume_count: Some(30), + total_chapter_count: None, + rating: Some(88), + popularity: Some(75000), + }, + "AniList Recs".to_string(), + "anilist".to_string(), + ), + to_recommendation_dto( + Recommendation { + external_id: "2".to_string(), + external_url: None, + title: "Manga B".to_string(), + cover_url: None, + summary: None, + genres: vec![], + tags: None, + score: 0.7, + reason: "Popular in your genre".to_string(), + based_on: vec![], + codex_series_id: Some("series-id".to_string()), + in_library: true, + status: None, + format: None, + country_of_origin: None, + start_year: None, + total_volume_count: None, + total_chapter_count: None, + rating: None, + popularity: None, + }, + "AniList Recs".to_string(), + "anilist".to_string(), + ), ]; let plugin_id = Uuid::new_v4(); let response = RecommendationsResponse { recommendations: recs, - plugin_id, - plugin_name: "AniList Recommendations".to_string(), - generated_at: Some("2026-02-09T12:00:00Z".to_string()), - cached: true, - task_status: None, - task_id: None, + sources: vec![RecommendationSourceDto { + plugin_id, + plugin_name: "AniList Recommendations".to_string(), + source: "anilist".to_string(), + generated_at: Some("2026-02-09T12:00:00Z".to_string()), + cached: true, + task_status: None, + task_id: None, + }], }; let json = serde_json::to_value(&response).unwrap(); - // Top-level fields - assert_eq!(json["pluginId"], plugin_id.to_string()); - assert_eq!(json["pluginName"], "AniList Recommendations"); - assert_eq!(json["generatedAt"], "2026-02-09T12:00:00Z"); - assert!(json["cached"].as_bool().unwrap()); + // Source provenance + let sources = json["sources"].as_array().unwrap(); + assert_eq!(sources.len(), 1); + assert_eq!(sources[0]["pluginId"], plugin_id.to_string()); + assert_eq!(sources[0]["pluginName"], "AniList Recommendations"); + assert_eq!(sources[0]["generatedAt"], "2026-02-09T12:00:00Z"); + assert!(sources[0]["cached"].as_bool().unwrap()); // Recommendations array let recs_arr = json["recommendations"].as_array().unwrap(); @@ -750,6 +881,82 @@ mod tests { assert!(rec1.get("popularity").is_none()); } + /// Build a minimal RecommendationDto for merge tests. + fn merge_test_dto( + external_id: &str, + score: f64, + reason: &str, + source_plugin: &str, + ) -> RecommendationDto { + RecommendationDto { + external_id: external_id.to_string(), + external_url: None, + title: format!("Title {external_id}"), + cover_url: None, + summary: None, + genres: vec![], + tags: None, + score, + reason: reason.to_string(), + based_on: vec![], + codex_series_id: None, + in_library: false, + in_codex: false, + status: None, + format: None, + country_of_origin: None, + start_year: None, + total_volume_count: None, + total_chapter_count: None, + rating: None, + popularity: None, + source_plugin: source_plugin.to_string(), + source: "anilist".to_string(), + } + } + + #[test] + fn test_merge_dedupes_keeps_highest_score_and_combines_reasons() { + // Same external ID "42" from two instances; "1" and "3" unique. + let recs = vec![ + merge_test_dto("1", 0.5, "low one", "Manga"), + merge_test_dto("42", 0.6, "from manga", "Manga"), + merge_test_dto("42", 0.9, "from comics", "Comics"), + merge_test_dto("3", 0.95, "top", "Comics"), + ]; + + let merged = merge_recommendations(recs); + + // "42" deduped to one entry. + assert_eq!(merged.len(), 3); + + // Ordered by score desc: 3 (0.95), 42 (0.9), 1 (0.5). + assert_eq!(merged[0].external_id, "3"); + assert_eq!(merged[1].external_id, "42"); + assert_eq!(merged[2].external_id, "1"); + + // The deduped "42" kept the highest score and the winning source, + // and combined both reasons. + let dup = &merged[1]; + assert!((dup.score - 0.9).abs() < f64::EPSILON); + assert_eq!(dup.source_plugin, "Comics"); + assert!(dup.reason.contains("from comics")); + assert!(dup.reason.contains("from manga")); + } + + #[test] + fn test_merge_identical_reason_not_duplicated() { + let recs = vec![ + merge_test_dto("7", 0.8, "same reason", "Manga"), + merge_test_dto("7", 0.6, "same reason", "Comics"), + ]; + let merged = merge_recommendations(recs); + assert_eq!(merged.len(), 1); + assert_eq!(merged[0].reason, "same reason"); + // Higher score wins its source. + assert_eq!(merged[0].source_plugin, "Manga"); + } + /// Verify that RecommendationResponse round-trips through serde_json::Value. /// This is the exact path used by the task handler (serialize to Value → write to DB) /// and the GET endpoint (read from DB → deserialize from Value). diff --git a/docs/api/openapi.json b/docs/api/openapi.json index 17888223..ba6ad7ce 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -35545,6 +35545,14 @@ "format": "double", "description": "Confidence/relevance score (0.0 to 1.0)" }, + "source": { + "type": "string", + "description": "External ID source of the producing plugin (e.g. \"anilist\"). Used by the\nUI to filter/group by source." + }, + "sourcePlugin": { + "type": "string", + "description": "Display name of the plugin instance that produced this recommendation.\nWhen the same item is recommended by several instances, this is the\nhighest-scoring contributor." + }, "startYear": { "type": [ "integer", @@ -35599,6 +35607,55 @@ } } }, + "RecommendationSourceDto": { + "type": "object", + "description": "One recommendation provider instance contributing to the merged response.", + "required": [ + "pluginId", + "pluginName" + ], + "properties": { + "cached": { + "type": "boolean", + "description": "Whether this instance's results came from cache" + }, + "generatedAt": { + "type": [ + "string", + "null" + ], + "description": "When this instance's recommendations were generated" + }, + "pluginId": { + "type": "string", + "format": "uuid", + "description": "Plugin ID" + }, + "pluginName": { + "type": "string", + "description": "Plugin display name" + }, + "source": { + "type": "string", + "description": "External ID source of this plugin (e.g. \"anilist\")." + }, + "taskId": { + "type": [ + "string", + "null" + ], + "format": "uuid", + "description": "ID of the running/pending refresh task for this instance, if any" + }, + "taskStatus": { + "type": [ + "string", + "null" + ], + "description": "Status of a running/pending refresh task for this instance, if any" + } + } + }, "RecommendationTagDto": { "type": "object", "description": "A tag with relevance rank from the source service", @@ -35627,7 +35684,6 @@ "type": "object", "description": "Response from POST /api/v1/user/recommendations/refresh", "required": [ - "taskId", "message" ], "properties": { @@ -35635,63 +35691,36 @@ "type": "string", "description": "Human-readable status message" }, - "taskId": { - "type": "string", - "format": "uuid", - "description": "Task ID for tracking the refresh operation" + "taskIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "description": "Task IDs enqueued — one per enabled recommendation provider instance" } } }, "RecommendationsResponse": { "type": "object", - "description": "Response from GET /api/v1/user/recommendations", + "description": "Response from GET /api/v1/user/recommendations\n\nRecommendations from all enabled recommendation-provider instances are\nmerged into a single list (deduped by external ID, highest score wins,\nreasons combined), each item tagged with its `source`/`sourcePlugin`.\n`sources` carries per-instance status so the UI can show provenance and\nper-source refresh/staleness state.", "required": [ - "recommendations", - "pluginId", - "pluginName" + "recommendations" ], "properties": { - "cached": { - "type": "boolean", - "description": "Whether these are cached results" - }, - "generatedAt": { - "type": [ - "string", - "null" - ], - "description": "When these recommendations were generated" - }, - "pluginId": { - "type": "string", - "format": "uuid", - "description": "Plugin that provided these recommendations" - }, - "pluginName": { - "type": "string", - "description": "Plugin display name" - }, "recommendations": { "type": "array", "items": { "$ref": "#/components/schemas/RecommendationDto" }, - "description": "Personalized recommendations" + "description": "Merged, deduped recommendations across all enabled provider instances" }, - "taskId": { - "type": [ - "string", - "null" - ], - "format": "uuid", - "description": "ID of the running/pending background task, if any" - }, - "taskStatus": { - "type": [ - "string", - "null" - ], - "description": "Status of a running/pending background task (\"pending\" or \"running\"), if any" + "sources": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RecommendationSourceDto" + }, + "description": "The provider instances that contributed (status, provenance)" } } }, diff --git a/tests/api/recommendations.rs b/tests/api/recommendations.rs index c9b36cd0..5d8386dd 100644 --- a/tests/api/recommendations.rs +++ b/tests/api/recommendations.rs @@ -319,7 +319,7 @@ async fn test_refresh_recommendations_enqueues_task() { assert_eq!(status, StatusCode::OK); let body = response.expect("Expected response body"); - assert!(body.get("taskId").is_some()); + assert_eq!(body["taskIds"].as_array().unwrap().len(), 1); assert!( body["message"] .as_str() @@ -480,10 +480,12 @@ async fn test_get_recommendations_returns_empty_and_triggers_task() { assert_eq!(status, StatusCode::OK); let body = response.expect("Expected response body"); assert!(body["recommendations"].as_array().unwrap().is_empty()); - assert_eq!(body["pluginName"], "AniList Recommendations"); - // Should have auto-triggered a task - assert_eq!(body["taskStatus"], "pending"); - assert!(body.get("taskId").is_some()); + let sources = body["sources"].as_array().unwrap(); + assert_eq!(sources.len(), 1); + assert_eq!(sources[0]["pluginName"], "AniList Recommendations"); + // Should have auto-triggered a task for this source + assert_eq!(sources[0]["taskStatus"], "pending"); + assert!(sources[0].get("taskId").is_some()); } #[tokio::test] @@ -545,7 +547,8 @@ async fn test_get_recommendations_returns_cached_data() { let recs = body["recommendations"].as_array().unwrap(); assert_eq!(recs.len(), 1); assert_eq!(recs[0]["title"], "Cached Manga"); - assert!(body["cached"].as_bool().unwrap()); + let sources = body["sources"].as_array().unwrap(); + assert!(sources[0]["cached"].as_bool().unwrap()); } #[tokio::test] @@ -770,7 +773,7 @@ async fn test_refresh_recommendations_deduplication() { make_json_request(app, request).await; assert_eq!(status, StatusCode::OK); let body = response.expect("Expected response body"); - assert!(body.get("taskId").is_some()); + assert_eq!(body["taskIds"].as_array().unwrap().len(), 1); // Second refresh — should return 409 Conflict let app = create_test_router(state.clone()).await; @@ -1045,3 +1048,101 @@ async fn test_get_recommendations_no_filter_without_external_id_source() { "Without externalIdSource, no filtering should happen" ); } + +// ============================================================================= +// Multi-Instance Merge Tests +// ============================================================================= + +#[tokio::test] +async fn test_get_recommendations_merges_multiple_instances() { + // Two enabled recommendation providers, each with its own cached results. + // GET should merge them into one list (deduping a shared external ID, + // highest score wins) and report both under `sources`. + ensure_test_encryption_key(); + let (db, _temp_dir) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let (user_id, token) = create_user_and_token(&db, &state, "testuser").await; + + // Two distinct plugin rows (the "same plugin twice" pattern uses distinct + // names); both are recommendation providers. + let plugin_a = create_recommendation_plugin(&db, "rec-manga", "Manga Recommendations").await; + let plugin_b = create_recommendation_plugin(&db, "rec-comics", "Comics Recommendations").await; + + for plugin_id in [plugin_a, plugin_b] { + let app = create_test_router(state.clone()).await; + let request = post_request_with_auth( + &format!("/api/v1/user/plugins/{}/enable", plugin_id), + &token, + ); + let (status, _): (StatusCode, Option) = + make_json_request(app, request).await; + assert_eq!(status, StatusCode::OK); + } + + let instance_a = UserPluginsRepository::get_by_user_and_plugin(&db, user_id, plugin_a) + .await + .unwrap() + .unwrap(); + let instance_b = UserPluginsRepository::get_by_user_and_plugin(&db, user_id, plugin_b) + .await + .unwrap() + .unwrap(); + + // Instance A: items "1" (0.6) and shared "99" (0.6). + codex::db::repositories::UserPluginDataRepository::set( + &db, + instance_a.id, + "recommendations", + json!({ + "recommendations": [ + { "externalId": "1", "title": "Manga Only", "score": 0.6, "reason": "from manga" }, + { "externalId": "99", "title": "Shared", "score": 0.6, "reason": "manga says hi" } + ], + "generatedAt": "2026-02-12T10:00:00Z", + "cached": true + }), + None, + ) + .await + .unwrap(); + + // Instance B: items "2" (0.8) and shared "99" (0.9 — higher). + codex::db::repositories::UserPluginDataRepository::set( + &db, + instance_b.id, + "recommendations", + json!({ + "recommendations": [ + { "externalId": "2", "title": "Comics Only", "score": 0.8, "reason": "from comics" }, + { "externalId": "99", "title": "Shared", "score": 0.9, "reason": "comics says hi" } + ], + "generatedAt": "2026-02-12T10:00:00Z", + "cached": true + }), + None, + ) + .await + .unwrap(); + + let app = create_test_router(state.clone()).await; + let request = get_request_with_auth("/api/v1/user/recommendations", &token); + let (status, response): (StatusCode, Option) = + make_json_request(app, request).await; + + assert_eq!(status, StatusCode::OK); + let body = response.expect("Expected response body"); + + // Both instances reported as sources. + let sources = body["sources"].as_array().unwrap(); + assert_eq!(sources.len(), 2); + + // "99" deduped → 3 unique items, ordered by score desc: 99 (0.9), 2 (0.8), 1 (0.6). + let recs = body["recommendations"].as_array().unwrap(); + assert_eq!(recs.len(), 3); + assert_eq!(recs[0]["externalId"], "99"); + assert_eq!(recs[0]["score"], 0.9); + // The winning (higher-score) source produced the deduped item. + assert_eq!(recs[0]["sourcePlugin"], "Comics Recommendations"); + assert_eq!(recs[1]["externalId"], "2"); + assert_eq!(recs[2]["externalId"], "1"); +} diff --git a/web/openapi.json b/web/openapi.json index 17888223..ba6ad7ce 100644 --- a/web/openapi.json +++ b/web/openapi.json @@ -35545,6 +35545,14 @@ "format": "double", "description": "Confidence/relevance score (0.0 to 1.0)" }, + "source": { + "type": "string", + "description": "External ID source of the producing plugin (e.g. \"anilist\"). Used by the\nUI to filter/group by source." + }, + "sourcePlugin": { + "type": "string", + "description": "Display name of the plugin instance that produced this recommendation.\nWhen the same item is recommended by several instances, this is the\nhighest-scoring contributor." + }, "startYear": { "type": [ "integer", @@ -35599,6 +35607,55 @@ } } }, + "RecommendationSourceDto": { + "type": "object", + "description": "One recommendation provider instance contributing to the merged response.", + "required": [ + "pluginId", + "pluginName" + ], + "properties": { + "cached": { + "type": "boolean", + "description": "Whether this instance's results came from cache" + }, + "generatedAt": { + "type": [ + "string", + "null" + ], + "description": "When this instance's recommendations were generated" + }, + "pluginId": { + "type": "string", + "format": "uuid", + "description": "Plugin ID" + }, + "pluginName": { + "type": "string", + "description": "Plugin display name" + }, + "source": { + "type": "string", + "description": "External ID source of this plugin (e.g. \"anilist\")." + }, + "taskId": { + "type": [ + "string", + "null" + ], + "format": "uuid", + "description": "ID of the running/pending refresh task for this instance, if any" + }, + "taskStatus": { + "type": [ + "string", + "null" + ], + "description": "Status of a running/pending refresh task for this instance, if any" + } + } + }, "RecommendationTagDto": { "type": "object", "description": "A tag with relevance rank from the source service", @@ -35627,7 +35684,6 @@ "type": "object", "description": "Response from POST /api/v1/user/recommendations/refresh", "required": [ - "taskId", "message" ], "properties": { @@ -35635,63 +35691,36 @@ "type": "string", "description": "Human-readable status message" }, - "taskId": { - "type": "string", - "format": "uuid", - "description": "Task ID for tracking the refresh operation" + "taskIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "description": "Task IDs enqueued — one per enabled recommendation provider instance" } } }, "RecommendationsResponse": { "type": "object", - "description": "Response from GET /api/v1/user/recommendations", + "description": "Response from GET /api/v1/user/recommendations\n\nRecommendations from all enabled recommendation-provider instances are\nmerged into a single list (deduped by external ID, highest score wins,\nreasons combined), each item tagged with its `source`/`sourcePlugin`.\n`sources` carries per-instance status so the UI can show provenance and\nper-source refresh/staleness state.", "required": [ - "recommendations", - "pluginId", - "pluginName" + "recommendations" ], "properties": { - "cached": { - "type": "boolean", - "description": "Whether these are cached results" - }, - "generatedAt": { - "type": [ - "string", - "null" - ], - "description": "When these recommendations were generated" - }, - "pluginId": { - "type": "string", - "format": "uuid", - "description": "Plugin that provided these recommendations" - }, - "pluginName": { - "type": "string", - "description": "Plugin display name" - }, "recommendations": { "type": "array", "items": { "$ref": "#/components/schemas/RecommendationDto" }, - "description": "Personalized recommendations" + "description": "Merged, deduped recommendations across all enabled provider instances" }, - "taskId": { - "type": [ - "string", - "null" - ], - "format": "uuid", - "description": "ID of the running/pending background task, if any" - }, - "taskStatus": { - "type": [ - "string", - "null" - ], - "description": "Status of a running/pending background task (\"pending\" or \"running\"), if any" + "sources": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RecommendationSourceDto" + }, + "description": "The provider instances that contributed (status, provenance)" } } }, diff --git a/web/src/components/recommendations/RecommendationCard.test.tsx b/web/src/components/recommendations/RecommendationCard.test.tsx index 712b1e7a..3667487e 100644 --- a/web/src/components/recommendations/RecommendationCard.test.tsx +++ b/web/src/components/recommendations/RecommendationCard.test.tsx @@ -59,6 +59,23 @@ describe("RecommendationCard", () => { expect(screen.getByText("95% match")).toBeInTheDocument(); }); + it("shows a 'via ' badge only when showSource is set", () => { + const rec = { ...fullRecommendation, sourcePlugin: "Manga Recs" }; + const { rerender } = renderWithProviders( + , + ); + expect(screen.queryByText("via Manga Recs")).not.toBeInTheDocument(); + + rerender( + , + ); + expect(screen.getByText("via Manga Recs")).toBeInTheDocument(); + }); + it("renders reason text", () => { renderWithProviders(); expect( diff --git a/web/src/components/recommendations/RecommendationCard.tsx b/web/src/components/recommendations/RecommendationCard.tsx index 5aac9a92..44e0cc3f 100644 --- a/web/src/components/recommendations/RecommendationCard.tsx +++ b/web/src/components/recommendations/RecommendationCard.tsx @@ -58,12 +58,15 @@ interface RecommendationCardProps { recommendation: RecommendationDto; onDismiss: (externalId: string, reason: string) => void; dismissing?: boolean; + /** Show a "via " badge identifying the source instance (merged view) */ + showSource?: boolean; } export function RecommendationCard({ recommendation, onDismiss, dismissing, + showSource, }: RecommendationCardProps) { const { externalId, @@ -84,6 +87,7 @@ export function RecommendationCard({ totalChapterCount, rating, popularity, + sourcePlugin, } = recommendation; const borderColor = topBorderColor(inCodex, inLibrary); @@ -152,10 +156,17 @@ export function RecommendationCard({ )} - {/* Match score */} - - {formatScore(score)} match - + {/* Match score + optional source provenance */} + + + {formatScore(score)} match + + {showSource && sourcePlugin && ( + + via {sourcePlugin} + + )} + {/* Meta badges */} diff --git a/web/src/components/recommendations/RecommendationFilters.test.tsx b/web/src/components/recommendations/RecommendationFilters.test.tsx index 7cfa6247..a72e60c3 100644 --- a/web/src/components/recommendations/RecommendationFilters.test.tsx +++ b/web/src/components/recommendations/RecommendationFilters.test.tsx @@ -272,6 +272,37 @@ describe("applyFilters", () => { }); expect(result).toHaveLength(0); }); + + it("includes/excludes by source plugin", () => { + const recs = [ + makeRec({ externalId: "1", sourcePlugin: "Manga Recs" }), + makeRec({ externalId: "2", sourcePlugin: "Comics Recs" }), + makeRec({ externalId: "3", sourcePlugin: "Manga Recs" }), + ]; + + const included = applyFilters(recs, { + ...DEFAULT_FILTERS, + sources: includeGroup("Manga Recs"), + }); + expect(included.map((r) => r.externalId)).toEqual(["1", "3"]); + + const excluded = applyFilters(recs, { + ...DEFAULT_FILTERS, + sources: excludeGroup("Manga Recs"), + }); + expect(excluded.map((r) => r.externalId)).toEqual(["2"]); + }); +}); + +describe("extractFilterOptions sources", () => { + it("collects distinct source plugins", () => { + const opts = extractFilterOptions([ + makeRec({ externalId: "1", sourcePlugin: "Manga Recs" }), + makeRec({ externalId: "2", sourcePlugin: "Comics Recs" }), + makeRec({ externalId: "3", sourcePlugin: "Manga Recs" }), + ]); + expect(opts.sources).toEqual(new Set(["Manga Recs", "Comics Recs"])); + }); }); // ============================================================================= diff --git a/web/src/components/recommendations/RecommendationFilters.tsx b/web/src/components/recommendations/RecommendationFilters.tsx index 7d58c77a..c67e53a6 100644 --- a/web/src/components/recommendations/RecommendationFilters.tsx +++ b/web/src/components/recommendations/RecommendationFilters.tsx @@ -41,6 +41,8 @@ export interface RecommendationFilterState { countries: TriStateGroup; /** Seed (basedOn) filter */ seeds: TriStateGroup; + /** Source plugin filter (which recommendation instance produced the item) */ + sources: TriStateGroup; /** Min score range [0, 100] */ scoreRange: [number, number]; } @@ -56,6 +58,7 @@ export const DEFAULT_FILTERS: RecommendationFilterState = { formats: emptyGroup(), countries: emptyGroup(), seeds: emptyGroup(), + sources: emptyGroup(), scoreRange: [0, 100], }; @@ -95,6 +98,7 @@ export function extractFilterOptions(recommendations: RecommendationDto[]) { const formats = new Set(); const countries = new Set(); const seeds = new Set(); + const sources = new Set(); for (const rec of recommendations) { if (rec.status) statuses.add(rec.status); @@ -103,9 +107,10 @@ export function extractFilterOptions(recommendations: RecommendationDto[]) { if (rec.format) formats.add(rec.format); if (rec.countryOfOrigin) countries.add(rec.countryOfOrigin); for (const s of rec.basedOn ?? []) seeds.add(s); + if (rec.sourcePlugin) sources.add(rec.sourcePlugin); } - return { statuses, genres, tags, formats, countries, seeds }; + return { statuses, genres, tags, formats, countries, seeds, sources }; } /** Get included values from a tri-state group */ @@ -228,6 +233,19 @@ export function applyFilters( } } + // Source filter (which plugin instance produced the recommendation) + if (groupHasActive(filters.sources)) { + const included = getIncluded(filters.sources); + const excluded = getExcluded(filters.sources); + const src = rec.sourcePlugin ?? ""; + if (included.length > 0 && !included.includes(src)) { + return false; + } + if (excluded.length > 0 && excluded.includes(src)) { + return false; + } + } + // Score filter const scorePercent = Math.round(rec.score * 100); if ( @@ -250,6 +268,7 @@ export function activeFilterCount(filters: RecommendationFilterState): number { if (groupHasActive(filters.formats)) count++; if (groupHasActive(filters.countries)) count++; if (groupHasActive(filters.seeds)) count++; + if (groupHasActive(filters.sources)) count++; if (filters.scoreRange[0] > 0 || filters.scoreRange[1] < 100) count++; return count; } @@ -279,7 +298,13 @@ export function RecommendationFilters({ const setTriState = ( key: keyof Pick< RecommendationFilterState, - "statuses" | "genres" | "tags" | "formats" | "countries" | "seeds" + | "statuses" + | "genres" + | "tags" + | "formats" + | "countries" + | "seeds" + | "sources" >, value: string, state: TriState, @@ -296,7 +321,13 @@ export function RecommendationFilters({ const getState = ( key: keyof Pick< RecommendationFilterState, - "statuses" | "genres" | "tags" | "formats" | "countries" | "seeds" + | "statuses" + | "genres" + | "tags" + | "formats" + | "countries" + | "seeds" + | "sources" >, value: string, ): TriState => { @@ -420,6 +451,25 @@ export function RecommendationFilters({ )} + {/* Source filter — only useful with more than one provider */} + {options.sources.size > 1 && ( +
+ + Source + + + {[...options.sources].sort().map((src) => ( + setTriState("sources", src, s)} + /> + ))} + +
+ )} + {/* Seed filter (based on) */} {options.seeds.size > 1 && (
diff --git a/web/src/components/recommendations/RecommendationsWidget.test.tsx b/web/src/components/recommendations/RecommendationsWidget.test.tsx index 13140465..7772bb8a 100644 --- a/web/src/components/recommendations/RecommendationsWidget.test.tsx +++ b/web/src/components/recommendations/RecommendationsWidget.test.tsx @@ -132,9 +132,9 @@ describe("RecommendationsWidget", () => { inLibrary: false, }, ], - pluginId: "plugin-1", - pluginName: "AniList Recs", - cached: false, + sources: [ + { pluginId: "plugin-1", pluginName: "AniList Recs", cached: false }, + ], }); }), ); diff --git a/web/src/components/recommendations/RecommendationsWidget.tsx b/web/src/components/recommendations/RecommendationsWidget.tsx index 3f411bd3..1179bdd8 100644 --- a/web/src/components/recommendations/RecommendationsWidget.tsx +++ b/web/src/components/recommendations/RecommendationsWidget.tsx @@ -30,9 +30,9 @@ export function RecommendationsWidget() { } const limited = recommendations.slice(0, MAX_RECOMMENDATIONS); - const subtitle = data?.pluginName - ? `Powered by ${data.pluginName}` - : undefined; + const sourceNames = (data?.sources ?? []).map((s) => s.pluginName); + const subtitle = + sourceNames.length > 0 ? `Powered by ${sourceNames.join(", ")}` : undefined; return ( diff --git a/web/src/mocks/handlers/recommendations.ts b/web/src/mocks/handlers/recommendations.ts index b4e548a2..cdb69064 100644 --- a/web/src/mocks/handlers/recommendations.ts +++ b/web/src/mocks/handlers/recommendations.ts @@ -290,14 +290,38 @@ export const recommendationsHandlers = [ http.get("/api/v1/user/recommendations", async () => { await delay(200); + // Demonstrate the multi-source merge: split the mock list across two + // provider instances so the merged/grouped toggle and source filter + // have something to work with. + const generatedAt = new Date(Date.now() - 3600000).toISOString(); + const stamped = mockRecommendations.map((rec, i) => + i % 2 === 0 + ? { ...rec, sourcePlugin: "AniList Manga", source: "anilist" } + : { ...rec, sourcePlugin: "AniList Comics", source: "anilist" }, + ); + const response: RecommendationsResponse = { - recommendations: mockRecommendations, - pluginId: "00000000-0000-0000-0000-000000000001", - pluginName: "AniList", - generatedAt: new Date(Date.now() - 3600000).toISOString(), - cached: true, - taskStatus: isRefreshing ? "running" : null, - taskId: isRefreshing ? "00000000-0000-0000-0000-000000000099" : null, + recommendations: stamped, + sources: [ + { + pluginId: "00000000-0000-0000-0000-000000000001", + pluginName: "AniList Manga", + source: "anilist", + generatedAt, + cached: true, + taskStatus: isRefreshing ? "running" : null, + taskId: isRefreshing ? "00000000-0000-0000-0000-000000000099" : null, + }, + { + pluginId: "00000000-0000-0000-0000-000000000002", + pluginName: "AniList Comics", + source: "anilist", + generatedAt, + cached: true, + taskStatus: null, + taskId: null, + }, + ], }; return HttpResponse.json(response); @@ -321,7 +345,7 @@ export const recommendationsHandlers = [ }, 3000); return HttpResponse.json({ - taskId: "00000000-0000-0000-0000-000000000099", + taskIds: ["00000000-0000-0000-0000-000000000099"], message: "Recommendation refresh started", }); }), diff --git a/web/src/pages/Recommendations.test.tsx b/web/src/pages/Recommendations.test.tsx index b4bef03c..a2ffcc8e 100644 --- a/web/src/pages/Recommendations.test.tsx +++ b/web/src/pages/Recommendations.test.tsx @@ -129,9 +129,9 @@ describe("Recommendations", () => { http.get("*/user/recommendations", () => { return HttpResponse.json({ recommendations: [], - pluginId: "plugin-1", - pluginName: "AniList Recs", - cached: false, + sources: [ + { pluginId: "plugin-1", pluginName: "AniList Recs", cached: false }, + ], }); }), ); @@ -147,9 +147,9 @@ describe("Recommendations", () => { http.get("*/user/recommendations", () => { return HttpResponse.json({ recommendations: [], - pluginId: "plugin-1", - pluginName: "AniList Recs", - cached: true, + sources: [ + { pluginId: "plugin-1", pluginName: "AniList Recs", cached: true }, + ], }); }), ); @@ -179,4 +179,49 @@ describe("Recommendations", () => { ).toBeInTheDocument(); }); }); + + it("merges multiple sources and lists them all", async () => { + server.use( + http.get("*/user/recommendations", () => { + return HttpResponse.json({ + recommendations: [ + { + externalId: "1", + title: "Manga Pick", + score: 0.9, + reason: "from manga", + inLibrary: false, + sourcePlugin: "Manga Recs", + source: "anilist", + }, + { + externalId: "2", + title: "Comics Pick", + score: 0.8, + reason: "from comics", + inLibrary: false, + sourcePlugin: "Comics Recs", + source: "anilist", + }, + ], + sources: [ + { pluginId: "p1", pluginName: "Manga Recs", cached: true }, + { pluginId: "p2", pluginName: "Comics Recs", cached: true }, + ], + }); + }), + ); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText("Manga Pick")).toBeInTheDocument(); + expect(screen.getByText("Comics Pick")).toBeInTheDocument(); + }); + // Both sources credited and the view toggle is offered. + expect( + screen.getByText(/Powered by Manga Recs, Comics Recs/), + ).toBeInTheDocument(); + expect(screen.getByTestId("rec-view-toggle")).toBeInTheDocument(); + }); }); diff --git a/web/src/pages/Recommendations.tsx b/web/src/pages/Recommendations.tsx index 2ed24d91..d292d24f 100644 --- a/web/src/pages/Recommendations.tsx +++ b/web/src/pages/Recommendations.tsx @@ -4,6 +4,7 @@ import { Button, Group, Loader, + SegmentedControl, SimpleGrid, Stack, Text, @@ -79,10 +80,14 @@ export function Recommendations() { refetchInterval: taskRunning ? 3000 : false, }); - // Determine if a task is active (from response or SSE) + // Per-source provenance/status (a user can enable several providers). + const sources = useMemo(() => recData?.sources ?? [], [recData]); + + // Determine if a task is active for any source (from response or SSE) const isTaskActive = - recData?.taskStatus === "pending" || - recData?.taskStatus === "running" || + sources.some( + (s) => s.taskStatus === "pending" || s.taskStatus === "running", + ) || (recTask != null && (recTask.status === "running" || recTask.status === "pending")); @@ -143,12 +148,37 @@ export function Recommendations() { const [filters, setFilters] = useState({ ...DEFAULT_FILTERS, }); + // Merged single list vs grouped-by-source view (only meaningful with >1 source) + const [viewMode, setViewMode] = useState<"merged" | "grouped">("merged"); const recommendations = recData?.recommendations ?? []; const filteredRecommendations = useMemo( () => applyFilters(recommendations, filters), [recommendations, filters], ); + const hasMultipleSources = sources.length > 1; + const anyCached = sources.some((s) => s.cached); + const sourceNames = sources.map((s) => s.pluginName); + const oldestGeneratedAt = useMemo(() => { + const times = sources + .map((s) => s.generatedAt) + .filter((t): t is string => !!t) + .sort(); + return times[0]; + }, [sources]); + + // Group filtered recommendations by source plugin, preserving first-seen order. + const groupedRecommendations = useMemo(() => { + const map = new Map(); + for (const rec of filteredRecommendations) { + const key = rec.sourcePlugin || "Unknown source"; + const list = map.get(key) ?? []; + list.push(rec); + map.set(key, list); + } + return [...map.entries()]; + }, [filteredRecommendations]); + // Loading state if (isLoading) { return ( @@ -215,7 +245,7 @@ export function Recommendations() { Recommendations - {recData?.cached && ( + {anyCached && ( (cached) @@ -242,22 +272,36 @@ export function Recommendations() { - {/* Plugin info */} - {recData && ( + {/* Source info */} + {sourceNames.length > 0 && ( - Powered by {recData.pluginName} - {recData.generatedAt && - ` \u00B7 Generated ${new Date(recData.generatedAt).toLocaleDateString()}`} + Powered by {sourceNames.join(", ")} + {oldestGeneratedAt && + ` \u00B7 Generated ${new Date(oldestGeneratedAt).toLocaleDateString()}`} )} - {/* Filters */} + {/* Filters + view toggle */} {recommendations.length > 0 && ( - + + + {hasMultipleSources && ( + setViewMode(v as "merged" | "grouped")} + data={[ + { label: "Merged", value: "merged" }, + { label: "By source", value: "grouped" }, + ]} + data-testid="rec-view-toggle" + /> + )} + )} {/* Filtered count */} @@ -295,21 +339,52 @@ export function Recommendations() { )} {/* Recommendation cards */} - - {filteredRecommendations.map((rec) => ( - - dismissMutation.mutate({ externalId: id, reason }) - } - dismissing={ - dismissMutation.isPending && - dismissMutation.variables?.externalId === rec.externalId - } - /> - ))} - + {hasMultipleSources && viewMode === "grouped" ? ( + + {groupedRecommendations.map(([sourceName, recs]) => ( + + + {sourceName} + + ({recs.length}) + + + + {recs.map((rec) => ( + + dismissMutation.mutate({ externalId: id, reason }) + } + dismissing={ + dismissMutation.isPending && + dismissMutation.variables?.externalId === rec.externalId + } + /> + ))} + + + ))} + + ) : ( + + {filteredRecommendations.map((rec) => ( + + dismissMutation.mutate({ externalId: id, reason }) + } + dismissing={ + dismissMutation.isPending && + dismissMutation.variables?.externalId === rec.externalId + } + /> + ))} + + )} ); diff --git a/web/src/types/api.generated.ts b/web/src/types/api.generated.ts index 1bdbcab4..a310a0f2 100644 --- a/web/src/types/api.generated.ts +++ b/web/src/types/api.generated.ts @@ -15853,6 +15853,17 @@ export interface components { * @description Confidence/relevance score (0.0 to 1.0) */ score: number; + /** + * @description External ID source of the producing plugin (e.g. "anilist"). Used by the + * UI to filter/group by source. + */ + source?: string; + /** + * @description Display name of the plugin instance that produced this recommendation. + * When the same item is recommended by several instances, this is the + * highest-scoring contributor. + */ + sourcePlugin?: string; /** * Format: int32 * @description Year the series started @@ -15877,6 +15888,29 @@ export interface components { */ totalVolumeCount?: number | null; }; + /** @description One recommendation provider instance contributing to the merged response. */ + RecommendationSourceDto: { + /** @description Whether this instance's results came from cache */ + cached?: boolean; + /** @description When this instance's recommendations were generated */ + generatedAt?: string | null; + /** + * Format: uuid + * @description Plugin ID + */ + pluginId: string; + /** @description Plugin display name */ + pluginName: string; + /** @description External ID source of this plugin (e.g. "anilist"). */ + source?: string; + /** + * Format: uuid + * @description ID of the running/pending refresh task for this instance, if any + */ + taskId?: string | null; + /** @description Status of a running/pending refresh task for this instance, if any */ + taskStatus?: string | null; + }; /** @description A tag with relevance rank from the source service */ RecommendationTagDto: { /** @description Tag category (e.g., "Genre", "Theme") */ @@ -15893,34 +15927,23 @@ export interface components { RecommendationsRefreshResponse: { /** @description Human-readable status message */ message: string; - /** - * Format: uuid - * @description Task ID for tracking the refresh operation - */ - taskId: string; + /** @description Task IDs enqueued — one per enabled recommendation provider instance */ + taskIds?: string[]; }; - /** @description Response from GET /api/v1/user/recommendations */ + /** + * @description Response from GET /api/v1/user/recommendations + * + * Recommendations from all enabled recommendation-provider instances are + * merged into a single list (deduped by external ID, highest score wins, + * reasons combined), each item tagged with its `source`/`sourcePlugin`. + * `sources` carries per-instance status so the UI can show provenance and + * per-source refresh/staleness state. + */ RecommendationsResponse: { - /** @description Whether these are cached results */ - cached?: boolean; - /** @description When these recommendations were generated */ - generatedAt?: string | null; - /** - * Format: uuid - * @description Plugin that provided these recommendations - */ - pluginId: string; - /** @description Plugin display name */ - pluginName: string; - /** @description Personalized recommendations */ + /** @description Merged, deduped recommendations across all enabled provider instances */ recommendations: components["schemas"]["RecommendationDto"][]; - /** - * Format: uuid - * @description ID of the running/pending background task, if any - */ - taskId?: string | null; - /** @description Status of a running/pending background task ("pending" or "running"), if any */ - taskStatus?: string | null; + /** @description The provider instances that contributed (status, provenance) */ + sources?: components["schemas"]["RecommendationSourceDto"][]; }; /** @description Refresh-token exchange request body. */ RefreshRequest: { From 0c60aa6c9cafa9b5b799b11cfecd76a66417bb3f Mon Sep 17 00:00:00 2001 From: Sylvain Cau Date: Fri, 5 Jun 2026 21:59:46 -0700 Subject: [PATCH 5/6] feat(plugins): library-scoped seed config and multi-instance admin UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round out per-library plugin scoping with seeding support, an admin-UI affordance for running the same plugin more than once, and docs. - Seed config: SeedPluginConfig gains a `libraries: [name, ...]` field (absent = all libraries). Libraries are now seeded before plugins so the names resolve to library IDs; an unknown name is a hard error. The sample seed config documents the per-library pattern. - Admin UI: installed official plugins now offer "Add another", and the create form auto-suffixes the name and display name so a second instance doesn't collide with the first — making it easy to run one instance per library with its own config. - Docs: document the libraryId/libraryName payload fields and how library scope applies to sync and recommendations, including running a plugin per-library and the cross-instance recommendation merge. Adds seed and UI tests. --- config/seed-config.sample.yaml | 24 ++++ docs/docs/plugins/index.md | 24 ++++ src/commands/seed.rs | 132 +++++++++++++++++- .../pages/settings/PluginsSettings.test.tsx | 42 ++++++ web/src/pages/settings/PluginsSettings.tsx | 17 ++- .../settings/plugins/OfficialPlugins.tsx | 25 ++-- 6 files changed, 247 insertions(+), 17 deletions(-) diff --git a/config/seed-config.sample.yaml b/config/seed-config.sample.yaml index 680b2a9b..94925163 100644 --- a/config/seed-config.sample.yaml +++ b/config/seed-config.sample.yaml @@ -34,6 +34,13 @@ users: # and the plugin will receive the raw text instead of an object. # # NOTE: If credentials are provided, CODEX_ENCRYPTION_KEY must be set. +# +# libraries: optional list of library NAMES this plugin is scoped to (must match +# names in the `libraries:` section below). Empty/absent = all libraries. +# Used by metadata, sync, and recommendation plugins. To run the same plugin +# per-library, add it twice under different `name`s, each scoped to one +# library (e.g. one AniList Sync for Manga, another for Comics). Libraries are +# seeded before plugins, so a referenced name that doesn't exist is an error. plugins: # MangaBaka - Manga metadata provider (requires API key) - name: metadata-mangabaka @@ -112,6 +119,23 @@ plugins: permissions: [] scopes: [] credential_delivery: env + # Scope this instance to specific libraries (by name). Absent = all. + # libraries: [Manga] + + # Per-library example: the same plugin registered a second time under a + # different name, scoped to the Comics library. Each instance has its own + # config/credentials and only sees the libraries listed here. Scope + # same-source instances to DISJOINT libraries to avoid double-syncing a series. + # - name: sync-anilist-comics + # display_name: AniList Sync (Comics) + # description: Sync reading progress for the Comics library + # plugin_type: user + # command: node + # args: ["/opt/codex/plugins/sync-anilist/dist/index.js"] + # permissions: [] + # scopes: [] + # credential_delivery: env + # libraries: [Comics] # MangaUpdates Releases - Translation/scanlation release feed (no credentials needed) # Release-source plugins are gated by manifest capability at reverse-RPC diff --git a/docs/docs/plugins/index.md b/docs/docs/plugins/index.md index 9205c7a5..a857a4e3 100644 --- a/docs/docs/plugins/index.md +++ b/docs/docs/plugins/index.md @@ -59,6 +59,21 @@ Each plugin requests specific permissions for the metadata fields it can write. If you've manually edited a metadata field, you can lock it to prevent plugins from overwriting your changes. Toggle the lock icon next to any field in the metadata editor. +### Library Scope & Running a Plugin Per-Library + +Every plugin can be **scoped to specific libraries**. In the plugin's configuration (**Settings > Plugins**, edit a plugin), the **Library access** control is either "All libraries" or a specific set. This scope is honored by metadata, **sync**, and **recommendation** plugins alike — a scoped plugin only acts on series in its allowed libraries. + +To run the **same plugin against different libraries with different configuration** (for example, one AniList Sync for your Manga library and another for Comics), add it more than once: + +1. From the **Official Plugins** gallery, an already-installed plugin shows an **"Add another"** button that pre-fills the command and a non-colliding name (e.g. `sync-anilist-2`). +2. Give the new instance its own name, credentials/config, and library scope. + +Each instance has isolated storage and its own credentials. **Scope same-source instances to _disjoint_ libraries** — if two instances of the same integration both cover the same library, that series is synced twice (wasteful, and the last run wins). + +For recommendations, results from **all** enabled recommendation instances are merged into one list on the Recommendations page (deduplicated, each item tagged with the source it came from). You can switch between a merged list and a per-source grouped view, and filter by source. + +Seed configs can declare scope too: add a `libraries: [, ...]` list to a plugin entry (names must match the seeded libraries; absent = all). See `config/seed-config.sample.yaml`. + ## Codex Sync Settings When using sync plugins (like AniList Sync), Codex provides a set of **generic sync settings** that apply to all sync plugins. These settings control which entries the server sends to the plugin. @@ -217,3 +232,12 @@ Plugins declare their protocol version via `protocolVersion: "1.0"` in the manif - **Old methods are preserved** within a major version. If a method signature changes in a backward-incompatible way, a new method name is used. This means plugins built for protocol `1.x` will continue to work as long as the server supports major version `1`. New optional fields (like `latestUpdatedAt` on `SyncEntry` or `totalVolumes` on `SyncProgress`) are additive and do not require a version bump. + +### Library Context in Payloads + +Every series/book entry the server sends to **sync** (`SyncEntry`) and **recommendation** (`UserLibraryEntry`) plugins carries the library it belongs to: + +- `libraryId` — the Codex library UUID +- `libraryName` — the library's display name + +These are additive fields; plugins that don't need them can ignore them. They let a plugin branch its behavior per library (for example, applying different mappings for a Manga vs a Comics library). On pulled sync entries (data coming _from_ the external service) these fields are empty, since the external service doesn't know about Codex libraries. diff --git a/src/commands/seed.rs b/src/commands/seed.rs index 5157ce45..dc2d9d4b 100644 --- a/src/commands/seed.rs +++ b/src/commands/seed.rs @@ -72,6 +72,12 @@ pub struct SeedPluginConfig { /// `InitializeParams.adminConfig` on first start. #[serde(default, alias = "admin_config")] pub config: Option, + /// Libraries (by name) this plugin is scoped to. Empty/absent = all + /// libraries (matching `plugins.library_ids`). Names are resolved to + /// library IDs at seed time, so the referenced libraries must be declared + /// in the same seed config (libraries are seeded before plugins). + #[serde(default)] + pub libraries: Vec, #[serde(default = "default_true")] pub enabled: bool, } @@ -168,15 +174,17 @@ pub async fn seed_command(config_path: PathBuf, seed_config_path: Option = LibraryRepository::list_all(db_conn) + .await? + .into_iter() + .map(|lib| (lib.name, lib.id)) + .collect(); + for plugin_cfg in plugins { + // Resolve the plugin's library scope (by name) to IDs. Empty = all. + let library_ids: Vec = plugin_cfg + .libraries + .iter() + .map(|name| { + library_index.get(name).copied().ok_or_else(|| { + anyhow::anyhow!( + "Plugin '{}' references unknown library '{}'. Declare the library in the seed config's `libraries` section.", + plugin_cfg.name, + name + ) + }) + }) + .collect::>>()?; + // Check if plugin already exists by name let existing = PluginsRepository::get_by_name(db_conn, &plugin_cfg.name).await?; @@ -335,7 +366,7 @@ async fn seed_plugins( None, // working_directory plugin_cfg.permissions.clone(), plugin_cfg.scopes.clone(), - vec![], // library_ids (empty = all libraries) + library_ids, // library scope (empty = all libraries) plugin_cfg.credentials.as_ref(), // credentials &plugin_cfg.credential_delivery, // credential_delivery plugin_cfg.config.clone(), // admin config @@ -742,4 +773,93 @@ plugins: let result = SeedConfig::from_file("/nonexistent/path.yaml"); assert!(result.is_err()); } + + #[test] + fn test_seed_plugin_libraries_parsing() { + let yaml = r#" +plugins: + - name: anilist-manga + display_name: AniList Manga + command: node + libraries: [Manga, Comics] + - name: anilist-all + display_name: AniList All + command: node +"#; + let config: SeedConfig = serde_yaml::from_str(yaml).unwrap(); + assert_eq!(config.plugins[0].libraries, vec!["Manga", "Comics"]); + // Absent `libraries` defaults to empty (all libraries). + assert!(config.plugins[1].libraries.is_empty()); + } + + #[tokio::test] + async fn test_seed_plugins_resolves_library_scope_by_name() { + use codex_db::test_helpers::create_test_db; + + let (db, _tmp) = create_test_db().await; + let conn = db.sea_orm_connection(); + + let yaml = r#" +libraries: + - name: Manga + path: /libraries/manga + - name: Comics + path: /libraries/comics +plugins: + - name: anilist-manga + display_name: AniList Manga + command: node + libraries: [Manga] + - name: anilist-all + display_name: AniList All + command: node +"#; + let config: SeedConfig = serde_yaml::from_str(yaml).unwrap(); + + // Seed libraries first, then plugins (the order seed_command uses). + seed_libraries(conn, &config.libraries).await.unwrap(); + seed_plugins(conn, &config.plugins).await.unwrap(); + + let manga = LibraryRepository::get_by_path(conn, "/libraries/manga") + .await + .unwrap() + .unwrap(); + + // Scoped plugin resolved the name to the library's UUID. + let scoped = PluginsRepository::get_by_name(conn, "anilist-manga") + .await + .unwrap() + .unwrap(); + assert_eq!(scoped.library_ids_vec(), vec![manga.id]); + + // Unscoped plugin applies to all libraries (empty). + let all = PluginsRepository::get_by_name(conn, "anilist-all") + .await + .unwrap() + .unwrap(); + assert!(all.library_ids_vec().is_empty()); + } + + #[tokio::test] + async fn test_seed_plugins_errors_on_unknown_library() { + use codex_db::test_helpers::create_test_db; + + let (db, _tmp) = create_test_db().await; + let conn = db.sea_orm_connection(); + + let yaml = r#" +plugins: + - name: broken + display_name: Broken + command: node + libraries: [DoesNotExist] +"#; + let config: SeedConfig = serde_yaml::from_str(yaml).unwrap(); + + let err = seed_plugins(conn, &config.plugins).await.unwrap_err(); + assert!( + err.to_string().contains("unknown library"), + "expected unknown-library error, got: {err}" + ); + } } diff --git a/web/src/pages/settings/PluginsSettings.test.tsx b/web/src/pages/settings/PluginsSettings.test.tsx index 2719f166..b6960bd3 100644 --- a/web/src/pages/settings/PluginsSettings.test.tsx +++ b/web/src/pages/settings/PluginsSettings.test.tsx @@ -1819,6 +1819,48 @@ describe("PluginsSettings - Official Plugins section", () => { }); }); + it("offers 'Add another' on an installed plugin, pre-filling a suffixed name", async () => { + const installed = createMockPlugin({ + id: "ol-1", + name: "metadata-openlibrary", + displayName: "Open Library Metadata", + }); + mockGetAll.mockResolvedValue({ plugins: [installed], total: 1 }); + + const user = userEvent.setup(); + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText("Official Plugins")).toBeInTheDocument(); + }); + await user.click(screen.getByText("Official Plugins")); + + // Find the Open Library flip card and its enabled "Add another" button. + const pkgLink = await screen.findByText( + "@ashdev/codex-plugin-metadata-openlibrary", + ); + const flipCard = pkgLink.closest("[class*='flipCard']") as HTMLElement; + const addAnother = flipCard.querySelector( + "button:not([disabled])", + ) as HTMLElement; + expect(addAnother).toHaveTextContent("Add another"); + await user.click(addAnother); + + await waitFor(() => { + expect( + screen.getByRole("heading", { name: /Add Plugin/i }), + ).toBeInTheDocument(); + }); + + // The second instance gets a distinct, non-colliding name + display name. + expect(screen.getByLabelText(/Display Name/i)).toHaveValue( + "Open Library Metadata (2)", + ); + expect(screen.getByLabelText(/^Name/i)).toHaveValue( + "metadata-openlibrary-2", + ); + }); + it("opens create modal with pre-filled values when Add is clicked", async () => { mockGetAll.mockResolvedValue({ plugins: [], total: 0 }); diff --git a/web/src/pages/settings/PluginsSettings.tsx b/web/src/pages/settings/PluginsSettings.tsx index 43e3519f..251bfded 100644 --- a/web/src/pages/settings/PluginsSettings.tsx +++ b/web/src/pages/settings/PluginsSettings.tsx @@ -340,10 +340,23 @@ export function PluginsSettings() { }); const handleAddOfficialPlugin = (official: OfficialPlugin) => { + // Plugin names are unique, so a second instance of the same official plugin + // needs a distinct name. Suffix `-2`, `-3`, … when the base name is taken + // (e.g. adding an instance scoped to a different library). + const existingNames = new Set(plugins.map((p) => p.name)); + let name = official.name; + let displayName = official.displayName; + if (existingNames.has(name)) { + let suffix = 2; + while (existingNames.has(`${official.name}-${suffix}`)) suffix++; + name = `${official.name}-${suffix}`; + displayName = `${official.displayName} (${suffix})`; + } + createForm.setValues({ ...defaultFormValues, - name: official.name, - displayName: official.displayName, + name, + displayName, description: official.description, command: official.formDefaults.command, args: official.formDefaults.args, diff --git a/web/src/pages/settings/plugins/OfficialPlugins.tsx b/web/src/pages/settings/plugins/OfficialPlugins.tsx index d72d6cd8..123c52b8 100644 --- a/web/src/pages/settings/plugins/OfficialPlugins.tsx +++ b/web/src/pages/settings/plugins/OfficialPlugins.tsx @@ -14,7 +14,6 @@ import { } from "@mantine/core"; import { useDisclosure } from "@mantine/hooks"; import { - IconCheck, IconChevronDown, IconChevronLeft, IconChevronRight, @@ -277,15 +276,23 @@ function PluginFlipCard({ plugin, isInstalled, onAdd }: PluginFlipCardProps) { {isInstalled ? ( - + + ) : (