diff --git a/src/cli/readarr/add_command_handler.rs b/src/cli/readarr/add_command_handler.rs index e1306c0a..b66de995 100644 --- a/src/cli/readarr/add_command_handler.rs +++ b/src/cli/readarr/add_command_handler.rs @@ -8,7 +8,9 @@ use super::ReadarrCommand; use crate::{ app::App, cli::{CliCommandHandler, Command}, - models::readarr_models::{AddReadarrRootFolderBody, MonitorType, NewItemMonitorType}, + models::readarr_models::{ + AddAuthorBody, AddAuthorOptions, AddReadarrRootFolderBody, MonitorType, NewItemMonitorType, + }, network::{NetworkTrait, readarr_network::ReadarrEvent}, }; @@ -18,6 +20,63 @@ mod add_command_handler_tests; #[derive(Debug, Clone, PartialEq, Eq, Subcommand)] pub enum ReadarrAddCommand { + #[command(about = "Add a new author to your Readarr library")] + Author { + #[arg( + long, + help = "The Goodreads foreign author ID of the author you wish to add to your library", + required = true + )] + foreign_author_id: String, + #[arg(long, help = "The name of the author", required = true)] + author_name: String, + #[arg( + long, + help = "The root folder path where all author data and metadata should live", + required = true + )] + root_folder_path: String, + #[arg( + long, + help = "The ID of the quality profile to use for this author", + required = true + )] + quality_profile_id: i64, + #[arg( + long, + help = "The ID of the metadata profile to use for this author", + required = true + )] + metadata_profile_id: i64, + #[arg(long, help = "Disable monitoring for this author")] + disable_monitoring: bool, + #[arg( + long, + help = "Tag IDs to tag the author with", + value_parser, + action = ArgAction::Append + )] + tag: Vec, + #[arg( + long, + help = "What Readarr should monitor for this author", + value_enum, + default_value_t = MonitorType::default() + )] + monitor: MonitorType, + #[arg( + long, + help = "How Readarr should monitor new items for this author", + value_enum, + default_value_t = NewItemMonitorType::default() + )] + monitor_new_items: NewItemMonitorType, + #[arg( + long, + help = "Tell Readarr to not start a search for missing books once the author is added to your library" + )] + no_search_for_missing_books: bool, + }, #[command(about = "Add a new root folder")] RootFolder { #[arg(long, help = "The name of the root folder", required = true)] @@ -92,6 +151,39 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrAddCommand> for ReadarrAddCommandH async fn handle(self) -> Result { let result = match self.command { + ReadarrAddCommand::Author { + foreign_author_id, + author_name, + root_folder_path, + quality_profile_id, + metadata_profile_id, + disable_monitoring, + tag: tags, + monitor, + monitor_new_items, + no_search_for_missing_books, + } => { + let body = AddAuthorBody { + foreign_author_id, + author_name, + monitored: !disable_monitoring, + root_folder_path, + quality_profile_id, + metadata_profile_id, + tags, + tag_input_string: None, + add_options: AddAuthorOptions { + monitor, + monitor_new_items, + search_for_missing_books: !no_search_for_missing_books, + }, + }; + let resp = self + .network + .handle_network_event(ReadarrEvent::AddAuthor(body).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } ReadarrAddCommand::RootFolder { name, root_folder_path, diff --git a/src/cli/readarr/add_command_handler_tests.rs b/src/cli/readarr/add_command_handler_tests.rs index ba3ad687..33c4698c 100644 --- a/src/cli/readarr/add_command_handler_tests.rs +++ b/src/cli/readarr/add_command_handler_tests.rs @@ -27,6 +27,321 @@ mod tests { use super::*; use pretty_assertions::assert_eq; + #[test] + fn test_add_author_requires_arguments() { + let result = Cli::command().try_get_matches_from(["managarr", "readarr", "add", "author"]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_add_author_requires_foreign_author_id() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "add", + "author", + "--author-name", + "Test", + "--root-folder-path", + "/nfs/books", + "--quality-profile-id", + "1", + "--metadata-profile-id", + "1", + ]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_add_author_requires_author_name() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "add", + "author", + "--foreign-author-id", + "test-id", + "--root-folder-path", + "/nfs/books", + "--quality-profile-id", + "1", + "--metadata-profile-id", + "1", + ]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_add_author_requires_root_folder_path() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "add", + "author", + "--foreign-author-id", + "test-id", + "--author-name", + "Test", + "--quality-profile-id", + "1", + "--metadata-profile-id", + "1", + ]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_add_author_requires_quality_profile_id() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "add", + "author", + "--foreign-author-id", + "test-id", + "--author-name", + "Test", + "--root-folder-path", + "/nfs/books", + "--metadata-profile-id", + "1", + ]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_add_author_requires_metadata_profile_id() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "add", + "author", + "--foreign-author-id", + "test-id", + "--author-name", + "Test", + "--root-folder-path", + "/nfs/books", + "--quality-profile-id", + "1", + ]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_add_author_success_with_required_args_only() { + let expected_args = ReadarrAddCommand::Author { + foreign_author_id: "test-id".to_owned(), + author_name: "Test Author".to_owned(), + root_folder_path: "/nfs/books".to_owned(), + quality_profile_id: 1, + metadata_profile_id: 1, + disable_monitoring: false, + tag: vec![], + monitor: MonitorType::default(), + monitor_new_items: NewItemMonitorType::default(), + no_search_for_missing_books: false, + }; + + let result = Cli::try_parse_from([ + "managarr", + "readarr", + "add", + "author", + "--foreign-author-id", + "test-id", + "--author-name", + "Test Author", + "--root-folder-path", + "/nfs/books", + "--quality-profile-id", + "1", + "--metadata-profile-id", + "1", + ]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::Add(add_command))) = result.unwrap().command else { + panic!("Unexpected command type") + }; + assert_eq!(add_command, expected_args); + } + + #[test] + fn test_add_author_success_with_all_args() { + let expected_args = ReadarrAddCommand::Author { + foreign_author_id: "test-id".to_owned(), + author_name: "Test Author".to_owned(), + root_folder_path: "/nfs/books".to_owned(), + quality_profile_id: 1, + metadata_profile_id: 2, + disable_monitoring: true, + tag: vec![1, 2], + monitor: MonitorType::Future, + monitor_new_items: NewItemMonitorType::New, + no_search_for_missing_books: true, + }; + + let result = Cli::try_parse_from([ + "managarr", + "readarr", + "add", + "author", + "--foreign-author-id", + "test-id", + "--author-name", + "Test Author", + "--root-folder-path", + "/nfs/books", + "--quality-profile-id", + "1", + "--metadata-profile-id", + "2", + "--disable-monitoring", + "--tag", + "1", + "--tag", + "2", + "--monitor", + "future", + "--monitor-new-items", + "new", + "--no-search-for-missing-books", + ]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::Add(add_command))) = result.unwrap().command else { + panic!("Unexpected command type") + }; + assert_eq!(add_command, expected_args); + } + + #[test] + fn test_add_author_monitor_type_validation() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "add", + "author", + "--foreign-author-id", + "test-id", + "--author-name", + "Test Author", + "--root-folder-path", + "/nfs/books", + "--quality-profile-id", + "1", + "--metadata-profile-id", + "2", + "--monitor", + "test", + ]); + + assert_err!(&result); + assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue); + } + + #[test] + fn test_add_author_new_item_monitor_type_validation() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "add", + "author", + "--foreign-author-id", + "test-id", + "--author-name", + "Test Author", + "--root-folder-path", + "/nfs/books", + "--quality-profile-id", + "1", + "--metadata-profile-id", + "2", + "--monitor-new-items", + "test", + ]); + + assert_err!(&result); + assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue); + } + + #[test] + fn test_add_author_tags_is_repeatable() { + let expected_args = ReadarrAddCommand::Author { + foreign_author_id: "test-id".to_owned(), + author_name: "Test Author".to_owned(), + root_folder_path: "/nfs/books".to_owned(), + quality_profile_id: 1, + metadata_profile_id: 2, + disable_monitoring: false, + tag: vec![1, 2], + monitor: MonitorType::default(), + monitor_new_items: NewItemMonitorType::default(), + no_search_for_missing_books: false, + }; + + let result = Cli::try_parse_from([ + "managarr", + "readarr", + "add", + "author", + "--foreign-author-id", + "test-id", + "--author-name", + "Test Author", + "--root-folder-path", + "/nfs/books", + "--quality-profile-id", + "1", + "--metadata-profile-id", + "2", + "--tag", + "1", + "--tag", + "2", + ]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::Add(add_command))) = result.unwrap().command else { + panic!("Unexpected command type") + }; + assert_eq!(add_command, expected_args); + } + #[test] fn test_add_root_folder_requires_arguments() { let result = @@ -113,7 +428,8 @@ mod tests { use crate::cli::readarr::add_command_handler::{ReadarrAddCommand, ReadarrAddCommandHandler}; use crate::models::Serdeable; use crate::models::readarr_models::{ - AddReadarrRootFolderBody, MonitorType, NewItemMonitorType, ReadarrSerdeable, + AddAuthorBody, AddAuthorOptions, AddReadarrRootFolderBody, MonitorType, NewItemMonitorType, + ReadarrSerdeable, }; use crate::network::readarr_network::ReadarrEvent; use crate::{ @@ -121,6 +437,56 @@ mod tests { network::{MockNetworkTrait, NetworkEvent}, }; + #[tokio::test] + async fn test_handle_add_author_command() { + let expected_body = AddAuthorBody { + foreign_author_id: "test-id".to_owned(), + author_name: "Test Author".to_owned(), + monitored: false, + root_folder_path: "/nfs/books".to_owned(), + quality_profile_id: 1, + metadata_profile_id: 1, + tags: vec![1, 2], + tag_input_string: None, + add_options: AddAuthorOptions { + monitor: MonitorType::All, + monitor_new_items: NewItemMonitorType::All, + search_for_missing_books: false, + }, + }; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::AddAuthor(expected_body).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let add_author_command = ReadarrAddCommand::Author { + foreign_author_id: "test-id".to_owned(), + author_name: "Test Author".to_owned(), + root_folder_path: "/nfs/books".to_owned(), + quality_profile_id: 1, + metadata_profile_id: 1, + disable_monitoring: true, + tag: vec![1, 2], + monitor: MonitorType::All, + monitor_new_items: NewItemMonitorType::All, + no_search_for_missing_books: true, + }; + + let result = ReadarrAddCommandHandler::with(&app_arc, add_author_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + #[tokio::test] async fn test_handle_add_root_folder_command() { let expected_root_folder_path = "/nfs/test".to_owned(); diff --git a/src/models/readarr_models.rs b/src/models/readarr_models.rs index 3e662e80..e54051bb 100644 --- a/src/models/readarr_models.rs +++ b/src/models/readarr_models.rs @@ -220,6 +220,29 @@ pub struct AddAuthorSearchResult { pub ratings: Option, } +#[derive(Default, Clone, Serialize, Debug, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AddAuthorBody { + pub foreign_author_id: String, + pub author_name: String, + pub monitored: bool, + pub root_folder_path: String, + pub quality_profile_id: i64, + pub metadata_profile_id: i64, + pub tags: Vec, + #[serde(skip_serializing, skip_deserializing)] + pub tag_input_string: Option, + pub add_options: AddAuthorOptions, +} + +#[derive(Default, Clone, Serialize, Debug, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AddAuthorOptions { + pub monitor: MonitorType, + pub monitor_new_items: NewItemMonitorType, + pub search_for_missing_books: bool, +} + #[derive(Default, Clone, Serialize, Debug, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct AddReadarrRootFolderBody { diff --git a/src/network/readarr_network/library/authors/mod.rs b/src/network/readarr_network/library/authors/mod.rs index 32643d27..1938aa8d 100644 --- a/src/network/readarr_network/library/authors/mod.rs +++ b/src/network/readarr_network/library/authors/mod.rs @@ -1,8 +1,9 @@ use anyhow::Result; use log::info; +use serde_json::Value; use crate::models::Route; -use crate::models::readarr_models::{AddAuthorSearchResult, Author}; +use crate::models::readarr_models::{AddAuthorBody, AddAuthorSearchResult, Author}; use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; use crate::models::stateful_table::StatefulTable; use crate::network::readarr_network::ReadarrEvent; @@ -14,6 +15,34 @@ use urlencoding::encode; mod readarr_authors_network_tests; impl Network<'_, '_> { + pub(in crate::network::readarr_network) async fn add_author( + &mut self, + mut add_author_body: AddAuthorBody, + ) -> Result { + info!("Adding Readarr author: {}", add_author_body.author_name); + if let Some(tag_input_str) = add_author_body.tag_input_string.as_ref() { + let tag_ids_vec = self + .extract_and_add_readarr_tag_ids_vec(tag_input_str) + .await; + add_author_body.tags = tag_ids_vec; + } + let event = ReadarrEvent::AddAuthor(AddAuthorBody::default()); + + let request_props = self + .request_props_from( + event, + RequestMethod::Post, + Some(add_author_body), + None, + None, + ) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await + } + pub(in crate::network::readarr_network) async fn get_author_details( &mut self, author_id: i64, diff --git a/src/network/readarr_network/library/authors/readarr_authors_network_tests.rs b/src/network/readarr_network/library/authors/readarr_authors_network_tests.rs index e21f0dfe..4a2b5453 100644 --- a/src/network/readarr_network/library/authors/readarr_authors_network_tests.rs +++ b/src/network/readarr_network/library/authors/readarr_authors_network_tests.rs @@ -1,13 +1,17 @@ #[cfg(test)] mod tests { - use crate::models::readarr_models::{AddAuthorSearchResult, Author, ReadarrSerdeable}; + use crate::models::readarr_models::{ + AddAuthorBody, AddAuthorSearchResult, Author, ReadarrSerdeable, + }; use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; use crate::models::stateful_table::StatefulTable; use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; use crate::network::readarr_network::ReadarrEvent; use crate::network::readarr_network::readarr_network_test_utils::test_utils::{ - ADD_AUTHOR_SEARCH_RESULT_JSON, AUTHOR_JSON, stale_add_author_search_result, stale_author, + ADD_AUTHOR_SEARCH_RESULT_JSON, AUTHOR_JSON, add_author_body, stale_add_author_search_result, + stale_author, }; + use bimap::BiMap; use pretty_assertions::assert_eq; use serde_json::{Value, json}; @@ -288,4 +292,99 @@ mod tests { assert_some!(&app.data.readarr_data.add_searched_authors); assert_is_empty!(app.data.readarr_data.add_searched_authors.as_ref().unwrap()); } + + #[tokio::test] + async fn test_handle_add_author_event() { + let (mock, app, _server) = MockServarrApi::post() + .with_request_body(json!({ + "foreignAuthorId": "test-foreign-id", + "authorName": "Test Author", + "monitored": true, + "rootFolderPath": "/nfs/books", + "qualityProfileId": 1, + "metadataProfileId": 1, + "tags": [1, 2], + "addOptions": { + "monitor": "all", + "monitorNewItems": "all", + "searchForMissingBooks": true + } + })) + .returns(json!({"id": 1})) + .build_for(ReadarrEvent::AddAuthor(AddAuthorBody::default())) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.tags_map = + BiMap::from_iter([(1, "usenet".to_owned()), (2, "testing".to_owned())]); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::AddAuthor(add_author_body())) + .await; + + mock.assert_async().await; + assert_ok!(result); + } + + #[tokio::test] + async fn test_handle_add_author_event_does_not_overwrite_tags_vec_when_tag_input_string_is_none() + { + let add_author_body = AddAuthorBody { + tags: vec![1, 2], + tag_input_string: None, + ..add_author_body() + }; + let (mock, app, _server) = MockServarrApi::post() + .with_request_body(json!({ + "foreignAuthorId": "test-foreign-id", + "authorName": "Test Author", + "monitored": true, + "rootFolderPath": "/nfs/books", + "qualityProfileId": 1, + "metadataProfileId": 1, + "tags": [1, 2], + "addOptions": { + "monitor": "all", + "monitorNewItems": "all", + "searchForMissingBooks": true + } + })) + .returns(json!({"id": 1})) + .build_for(ReadarrEvent::AddAuthor(add_author_body.clone())) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::AddAuthor(add_author_body)) + .await; + + mock.assert_async().await; + assert_ok!(result); + } + + #[tokio::test] + async fn test_handle_add_author_event_failure() { + let add_author_body = AddAuthorBody { + tag_input_string: None, + ..add_author_body() + }; + let (mock, app, _server) = MockServarrApi::post() + .returns(json!({"id": 1})) + .status(400) + .build_for(ReadarrEvent::AddAuthor(add_author_body.clone())) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::AddAuthor(add_author_body)) + .await; + + mock.assert_async().await; + assert_err!(result); + } } diff --git a/src/network/readarr_network/mod.rs b/src/network/readarr_network/mod.rs index f665a5bd..7803adee 100644 --- a/src/network/readarr_network/mod.rs +++ b/src/network/readarr_network/mod.rs @@ -3,7 +3,9 @@ use log::info; use serde_json::{Value, json}; use super::{NetworkEvent, NetworkResource}; -use crate::models::readarr_models::{AddReadarrRootFolderBody, ReadarrSerdeable, ReadarrTaskName}; +use crate::models::readarr_models::{ + AddAuthorBody, AddReadarrRootFolderBody, ReadarrSerdeable, ReadarrTaskName, +}; use crate::models::servarr_models::{MetadataProfile, QualityProfile, Tag}; use crate::network::{Network, RequestMethod}; @@ -21,6 +23,7 @@ pub mod readarr_network_test_utils; #[derive(Debug, Eq, PartialEq, Clone)] pub enum ReadarrEvent { + AddAuthor(AddAuthorBody), AddRootFolder(AddReadarrRootFolderBody), AddTag(String), DeleteRootFolder(i64), @@ -47,7 +50,9 @@ pub enum ReadarrEvent { impl NetworkResource for ReadarrEvent { fn resource(&self) -> &'static str { match &self { - ReadarrEvent::GetAuthorDetails(_) | ReadarrEvent::ListAuthors => "/author", + ReadarrEvent::AddAuthor(_) + | ReadarrEvent::GetAuthorDetails(_) + | ReadarrEvent::ListAuthors => "/author", ReadarrEvent::SearchNewAuthor(_) => "/author/lookup", ReadarrEvent::GetQueuedEvents | ReadarrEvent::StartTask(_) => "/command", ReadarrEvent::GetHostConfig | ReadarrEvent::GetSecurityConfig => "/config/host", @@ -79,6 +84,7 @@ impl Network<'_, '_> { readarr_event: ReadarrEvent, ) -> Result { match readarr_event { + ReadarrEvent::AddAuthor(body) => self.add_author(body).await.map(ReadarrSerdeable::from), ReadarrEvent::AddRootFolder(add_root_folder_body) => self .add_readarr_root_folder(add_root_folder_body) .await diff --git a/src/network/readarr_network/readarr_network_test_utils.rs b/src/network/readarr_network/readarr_network_test_utils.rs index 90578f88..3bb4212f 100644 --- a/src/network/readarr_network/readarr_network_test_utils.rs +++ b/src/network/readarr_network/readarr_network_test_utils.rs @@ -2,7 +2,8 @@ #[allow(dead_code)] pub mod test_utils { use crate::models::readarr_models::{ - AddAuthorSearchResult, Author, AuthorStatus, NewItemMonitorType, + AddAuthorBody, AddAuthorOptions, AddAuthorSearchResult, Author, AuthorStatus, MonitorType, + NewItemMonitorType, }; use crate::models::servarr_models::RootFolder; @@ -81,4 +82,22 @@ pub mod test_utils { unmapped_folders: None, } } + + pub fn add_author_body() -> AddAuthorBody { + AddAuthorBody { + foreign_author_id: "test-foreign-id".to_owned(), + author_name: "Test Author".to_owned(), + monitored: true, + root_folder_path: "/nfs/books".to_owned(), + quality_profile_id: 1, + metadata_profile_id: 1, + tags: Vec::default(), + tag_input_string: Some("usenet, testing".to_owned()), + add_options: AddAuthorOptions { + monitor: MonitorType::All, + monitor_new_items: NewItemMonitorType::All, + search_for_missing_books: true, + }, + } + } } diff --git a/src/network/readarr_network/readarr_network_tests.rs b/src/network/readarr_network/readarr_network_tests.rs index a392a3df..adfa3dbc 100644 --- a/src/network/readarr_network/readarr_network_tests.rs +++ b/src/network/readarr_network/readarr_network_tests.rs @@ -1,7 +1,7 @@ #[cfg(test)] mod tests { use crate::app::App; - use crate::models::readarr_models::{AddReadarrRootFolderBody, ReadarrSerdeable}; + use crate::models::readarr_models::{AddAuthorBody, AddReadarrRootFolderBody, ReadarrSerdeable}; use crate::models::servarr_models::{MetadataProfile, QualityProfile, Tag}; use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; use crate::network::{NetworkEvent, NetworkResource, readarr_network::ReadarrEvent}; @@ -13,6 +13,7 @@ mod tests { use tokio::sync::Mutex; #[rstest] + #[case(ReadarrEvent::AddAuthor(AddAuthorBody::default()), "/author")] #[case(ReadarrEvent::GetAuthorDetails(1), "/author")] #[case(ReadarrEvent::ListAuthors, "/author")] #[case(ReadarrEvent::SearchNewAuthor(String::new()), "/author/lookup")]