Repository: IgnisDa/ryot (language: rust)

Issue:
More accurate import of books



Discussion / hints:

Fix patch:
diff --git a/crates/providers/src/openlibrary.rs b/crates/providers/src/openlibrary.rs
index 70ca755c28..d671272919 100644
--- a/crates/providers/src/openlibrary.rs
+++ b/crates/providers/src/openlibrary.rs
@@ -568,6 +568,19 @@ impl OpenlibraryService {
         }
         None
     }
+
+    /// Get a book's ID from its ISBN
+    pub async fn id_from_isbn(&self, isbn: &str) -> Option<String> {
+        self.client
+            .get(format!("{}/isbn/{}.json", URL, isbn))
+            .send()
+            .await
+            .ok()?
+            .json::<MetadataDetailsOpenlibraryBook>()
+            .await
+            .ok()
+            .map(|data| get_key(&data.key))
+    }
 }
 
 pub fn get_key(key: &str) -> String {
diff --git a/crates/services/importer/src/audiobookshelf.rs b/crates/services/importer/src/audiobookshelf.rs
index d863aa1725..a673bc754d 100644
--- a/crates/services/importer/src/audiobookshelf.rs
+++ b/crates/services/importer/src/audiobookshelf.rs
@@ -12,7 +12,7 @@ use media_models::{
     CommitMediaInput, DeployUrlAndKeyImportInput, ImportOrExportMediaItem,
     ImportOrExportMediaItemSeen,
 };
-use providers::google_books::GoogleBooksService;
+use providers::{google_books::GoogleBooksService, openlibrary::OpenlibraryService};
 use reqwest::{
     header::{HeaderValue, AUTHORIZATION},
     Client,
@@ -20,11 +20,14 @@ use reqwest::{
 use serde_json::json;
 use specific_models::audiobookshelf as audiobookshelf_models;
 
+use crate::utils;
+
 use super::{ImportFailStep, ImportFailedItem};
 
 pub async fn import<F>(
     input: DeployUrlAndKeyImportInput,
-    isbn_service: &GoogleBooksService,
+    google_books_service: &GoogleBooksService,
+    open_library_service: &OpenlibraryService,
     commit_metadata: impl Fn(CommitMediaInput) -> F,
 ) -> Result<ImportResult>
 where
@@ -65,86 +68,93 @@ where
             let metadata = item.media.clone().unwrap().metadata;
             let title = metadata.title.clone();
             ryot_log!(debug, "Importing item {:?} ({}/{})", title, idx + 1, len);
-            let (identifier, lot, source, episodes) =
-                if Some("epub".to_string()) == item.media.as_ref().unwrap().ebook_format {
-                    match &metadata.isbn {
-                        Some(isbn) => match isbn_service.id_from_isbn(isbn).await {
-                            Some(id) => (id, MediaLot::Book, MediaSource::GoogleBooks, None),
-                            _ => {
-                                failed_items.push(ImportFailedItem {
-                                    error: Some("No Google Books ID found".to_string()),
-                                    identifier: title,
-                                    lot: None,
-                                    step: ImportFailStep::InputTransformation,
-                                });
-                                continue;
-                            }
-                        },
+            let (identifier, lot, source, episodes) = if Some("epub".to_string())
+                == item.media.as_ref().unwrap().ebook_format
+            {
+                match &metadata.isbn {
+                    Some(isbn) => match utils::get_identifier_from_book_isbn(
+                        isbn,
+                        google_books_service,
+                        open_library_service,
+                    )
+                    .await
+                    {
+                        Some((identifier, source)) => (identifier, MediaLot::Book, source, None),
                         _ => {
                             failed_items.push(ImportFailedItem {
-                                error: Some("No ISBN found".to_string()),
+                                error: Some("No Google Books ID found".to_string()),
                                 identifier: title,
                                 lot: None,
                                 step: ImportFailStep::InputTransformation,
                             });
                             continue;
                         }
+                    },
+                    _ => {
+                        failed_items.push(ImportFailedItem {
+                            error: Some("No ISBN found".to_string()),
+                            identifier: title,
+                            lot: None,
+                            step: ImportFailStep::InputTransformation,
+                        });
+                        continue;
                     }
-                } else if let Some(asin) = metadata.asin.clone() {
-                    (asin, MediaLot::AudioBook, MediaSource::Audible, None)
-                } else if let Some(itunes_id) = metadata.itunes_id.clone() {
-                    let item_details = get_item_details(&client, &url, &item.id, None).await?;
-                    match item_details.media.and_then(|m| m.episodes) {
-                        Some(episodes) => {
-                            let lot = MediaLot::Podcast;
-                            let source = MediaSource::Itunes;
-                            let mut to_return = vec![];
-                            for episode in episodes {
-                                ryot_log!(debug, "Importing episode {:?}", episode.title);
-                                let episode_details = get_item_details(
-                                    &client,
-                                    &url,
-                                    &item.id,
-                                    Some(episode.id.unwrap()),
-                                )
+                }
+            } else if let Some(asin) = metadata.asin.clone() {
+                (asin, MediaLot::AudioBook, MediaSource::Audible, None)
+            } else if let Some(itunes_id) = metadata.itunes_id.clone() {
+                let item_details = get_item_details(&client, &url, &item.id, None).await?;
+                match item_details.media.and_then(|m| m.episodes) {
+                    Some(episodes) => {
+                        let lot = MediaLot::Podcast;
+                        let source = MediaSource::Itunes;
+                        let mut to_return = vec![];
+                        for episode in episodes {
+                            ryot_log!(debug, "Importing episode {:?}", episode.title);
+                            let episode_details = get_item_details(
+                                &client,
+                                &url,
+                                &item.id,
+                                Some(episode.id.unwrap()),
+                            )
+                            .await?;
+                            if let Some(true) =
+                                episode_details.user_media_progress.map(|u| u.is_finished)
+                            {
+                                let podcast = commit_metadata(CommitMediaInput {
+                                    identifier: itunes_id.clone(),
+                                    lot,
+                                    source,
+                                    ..Default::default()
+                                })
                                 .await?;
-                                if let Some(true) =
-                                    episode_details.user_media_progress.map(|u| u.is_finished)
-                                {
-                                    let podcast = commit_metadata(CommitMediaInput {
-                                        identifier: itunes_id.clone(),
-                                        lot,
-                                        source,
-                                        ..Default::default()
-                                    })
-                                    .await?;
-                                    if let Some(pe) = podcast.podcast_specifics.and_then(|p| {
-                                        get_podcast_episode_number_by_name(&p, &episode.title)
-                                    }) {
-                                        to_return.push(pe);
-                                    }
+                                if let Some(pe) = podcast.podcast_specifics.and_then(|p| {
+                                    get_podcast_episode_number_by_name(&p, &episode.title)
+                                }) {
+                                    to_return.push(pe);
                                 }
                             }
-                            (itunes_id, lot, source, Some(to_return))
-                        }
-                        _ => {
-                            failed_items.push(ImportFailedItem {
-                                error: Some("No episodes found for podcast".to_string()),
-                                identifier: title,
-                                lot: Some(MediaLot::Podcast),
-                                step: ImportFailStep::ItemDetailsFromSource,
-                            });
-                            continue;
                         }
+                        (itunes_id, lot, source, Some(to_return))
+                    }
+                    _ => {
+                        failed_items.push(ImportFailedItem {
+                            error: Some("No episodes found for podcast".to_string()),
+                            identifier: title,
+                            lot: Some(MediaLot::Podcast),
+                            step: ImportFailStep::ItemDetailsFromSource,
+                        });
+                        continue;
                     }
-                } else {
-                    ryot_log!(
-                        debug,
-                        "No ASIN, ISBN or iTunes ID found for item {:?}",
-                        item
-                    );
-                    continue;
-                };
+                }
+            } else {
+                ryot_log!(
+                    debug,
+                    "No ASIN, ISBN or iTunes ID found for item {:?}",
+                    item
+                );
+                continue;
+            };
             let mut seen_history = vec![];
             if let Some(podcasts) = episodes {
                 for episode in podcasts {
diff --git a/crates/services/importer/src/goodreads.rs b/crates/services/importer/src/goodreads.rs
index 3a13e863d8..233e3343c5 100644
--- a/crates/services/importer/src/goodreads.rs
+++ b/crates/services/importer/src/goodreads.rs
@@ -4,17 +4,19 @@ use common_utils::ryot_log;
 use convert_case::{Case, Casing};
 use csv::Reader;
 use dependent_models::ImportResult;
-use enums::{ImportSource, MediaLot, MediaSource};
+use enums::{ImportSource, MediaLot};
 use itertools::Itertools;
 use media_models::{
     DeployGenericCsvImportInput, ImportOrExportItemRating, ImportOrExportItemReview,
     ImportOrExportMediaItem, ImportOrExportMediaItemSeen,
 };
-use providers::google_books::GoogleBooksService;
+use providers::{google_books::GoogleBooksService, openlibrary::OpenlibraryService};
 use rust_decimal::Decimal;
 use rust_decimal_macros::dec;
 use serde::Deserialize;
 
+use crate::utils;
+
 use super::{ImportFailStep, ImportFailedItem};
 
 #[derive(Debug, Deserialize)]
@@ -37,10 +39,10 @@ struct Book {
 
 pub async fn import(
     input: DeployGenericCsvImportInput,
-    isbn_service: &GoogleBooksService,
+    google_books_service: &GoogleBooksService,
+    open_library_service: &OpenlibraryService,
 ) -> Result<ImportResult> {
     let lot = MediaLot::Book;
-    let source = MediaSource::GoogleBooks;
     let mut media = vec![];
     let mut failed_items = vec![];
     let ratings_reader = Reader::from_path(input.csv_path)
@@ -76,60 +78,10 @@ pub async fn import(
             });
             continue;
         }
-        if let Some(identifier) = isbn_service.id_from_isbn(&isbn).await {
-            let mut seen_history = vec![
-                ImportOrExportMediaItemSeen {
-                    started_on: None,
-                    ended_on: None,
-                    provider_watched_on: Some(ImportSource::Goodreads.to_string()),
-                    ..Default::default()
-                };
-                record.read_count
-            ];
-            if let Some(w) = record.date_read {
-                let w = NaiveDate::parse_from_str(&w, "%Y/%m/%d").unwrap();
-                seen_history.first_mut().unwrap().ended_on = Some(w);
-            }
-            let mut collections = vec![];
-            if !record.bookshelf.is_empty() {
-                collections.push(match record.bookshelf.as_str() {
-                    "to-read" => "Watchlist".to_owned(),
-                    "currently-reading" => "In Progress".to_owned(),
-                    s => s.to_case(Case::Title),
-                });
-            }
-            let mut rating = None;
-            if record.rating > dec!(0) {
-                rating = Some(
-                    record
-                        .rating
-                        // DEV: Rates items out of 10
-                        .saturating_mul(dec!(10)),
-                );
-            }
-            let mut review = None;
-            if !record.review.is_empty() {
-                review = Some(ImportOrExportItemReview {
-                    date: None,
-                    spoiler: Some(false),
-                    text: Some(record.review),
-                    visibility: None,
-                });
-            }
-            media.push(ImportOrExportMediaItem {
-                lot,
-                source,
-                identifier,
-                collections,
-                seen_history,
-                source_id: record.title.clone(),
-                reviews: vec![ImportOrExportItemRating {
-                    review,
-                    rating,
-                    ..Default::default()
-                }],
-            });
-        } else {
+        let Some((identifier, source)) =
+            utils::get_identifier_from_book_isbn(&isbn, google_books_service, open_library_service)
+                .await
+        else {
             failed_items.push(ImportFailedItem {
                 lot: Some(lot),
                 step: ImportFailStep::InputTransformation,
@@ -138,8 +90,61 @@ pub async fn import(
                     "Could not convert ISBN: {} to Google Books ID",
                     isbn,
                 )),
-            })
+            });
+            continue;
+        };
+        let mut seen_history = vec![
+            ImportOrExportMediaItemSeen {
+                started_on: None,
+                ended_on: None,
+                provider_watched_on: Some(ImportSource::Goodreads.to_string()),
+                ..Default::default()
+            };
+            record.read_count
+        ];
+        if let Some(w) = record.date_read {
+            let w = NaiveDate::parse_from_str(&w, "%Y/%m/%d").unwrap();
+            seen_history.first_mut().unwrap().ended_on = Some(w);
+        }
+        let mut collections = vec![];
+        if !record.bookshelf.is_empty() {
+            collections.push(match record.bookshelf.as_str() {
+                "to-read" => "Watchlist".to_owned(),
+                "currently-reading" => "In Progress".to_owned(),
+                s => s.to_case(Case::Title),
+            });
+        }
+        let mut rating = None;
+        if record.rating > dec!(0) {
+            rating = Some(
+                record
+                    .rating
+                    // DEV: Rates items out of 10
+                    .saturating_mul(dec!(10)),
+            );
+        }
+        let mut review = None;
+        if !record.review.is_empty() {
+            review = Some(ImportOrExportItemReview {
+                date: None,
+                spoiler: Some(false),
+                text: Some(record.review),
+                visibility: None,
+            });
         }
+        media.push(ImportOrExportMediaItem {
+            lot,
+            source,
+            identifier,
+            collections,
+            seen_history,
+            source_id: record.title.clone(),
+            reviews: vec![ImportOrExportItemRating {
+                review,
+                rating,
+                ..Default::default()
+            }],
+        });
     }
     Ok(ImportResult {
         metadata: media,
diff --git a/crates/services/importer/src/lib.rs b/crates/services/importer/src/lib.rs
index cb48be0d7b..ee256a5dfe 100644
--- a/crates/services/importer/src/lib.rs
+++ b/crates/services/importer/src/lib.rs
@@ -7,12 +7,13 @@ use common_models::BackgroundJob;
 use common_utils::ryot_log;
 use database_models::{import_report, prelude::ImportReport};
 use dependent_utils::{
-    commit_metadata, deploy_background_job, get_isbn_service, get_tmdb_non_media_service,
-    process_import,
+    commit_metadata, deploy_background_job, get_google_books_service, get_openlibrary_service,
+    get_tmdb_non_media_service, process_import,
 };
-use enums::ImportSource;
+use enums::{ImportSource, MediaSource};
 use importer_models::{ImportFailStep, ImportFailedItem, ImportResultResponse};
 use media_models::{DeployImportJobInput, ImportOrExportMediaItem};
+use providers::{google_books::GoogleBooksService, openlibrary::OpenlibraryService};
 use sea_orm::{ActiveModelTrait, ActiveValue, ColumnTrait, EntityTrait, QueryFilter, QueryOrder};
 use supporting_service::SupportingService;
 use traits::TraceOk;
@@ -73,7 +74,8 @@ impl ImporterService {
             ImportSource::Mal => mal::import(input.mal.unwrap()).await.unwrap(),
             ImportSource::Goodreads => goodreads::import(
                 input.generic_csv.unwrap(),
-                &get_isbn_service(&self.0.config).await.unwrap(),
+                &get_google_books_service(&self.0.config).await.unwrap(),
+                &get_openlibrary_service(&self.0.config).await.unwrap(),
             )
             .await
             .unwrap(),
@@ -81,13 +83,15 @@ impl ImporterService {
             ImportSource::Movary => movary::import(input.movary.unwrap()).await.unwrap(),
             ImportSource::StoryGraph => story_graph::import(
                 input.generic_csv.unwrap(),
-                &get_isbn_service(&self.0.config).await.unwrap(),
+                &get_google_books_service(&self.0.config).await.unwrap(),
+                &get_openlibrary_service(&self.0.config).await.unwrap(),
             )
             .await
             .unwrap(),
             ImportSource::Audiobookshelf => audiobookshelf::import(
                 input.url_and_key.unwrap(),
-                &get_isbn_service(&self.0.config).await.unwrap(),
+                &get_google_books_service(&self.0.config).await.unwrap(),
+                &get_openlibrary_service(&self.0.config).await.unwrap(),
                 |input| commit_metadata(input, &self.0),
             )
             .await
@@ -164,4 +168,20 @@ pub mod utils {
         let offset = Duration::try_seconds(offset.into()).unwrap();
         DateTime::<Utc>::from_naive_utc_and_offset(date_time, Utc) - offset
     }
+
+    pub async fn get_identifier_from_book_isbn(
+        isbn: &str,
+        google_books_service: &GoogleBooksService,
+        open_library_service: &OpenlibraryService,
+    ) -> Option<(String, MediaSource)> {
+        let mut identifier = None;
+        let mut source = MediaSource::GoogleBooks;
+        if let Some(id) = google_books_service.id_from_isbn(isbn).await {
+            identifier = Some(id);
+        } else if let Some(id) = open_library_service.id_from_isbn(isbn).await {
+            identifier = Some(id);
+            source = MediaSource::Openlibrary;
+        }
+        identifier.map(|id| (id, source))
+    }
 }
diff --git a/crates/services/importer/src/story_graph.rs b/crates/services/importer/src/story_graph.rs
index faa279711a..48d8628c18 100644
--- a/crates/services/importer/src/story_graph.rs
+++ b/crates/services/importer/src/story_graph.rs
@@ -4,17 +4,19 @@ use common_utils::ryot_log;
 use convert_case::{Case, Casing};
 use csv::Reader;
 use dependent_models::ImportResult;
-use enums::{ImportSource, MediaLot, MediaSource};
+use enums::{ImportSource, MediaLot};
 use itertools::Itertools;
 use media_models::{
     DeployGenericCsvImportInput, ImportOrExportItemRating, ImportOrExportItemReview,
     ImportOrExportMediaItemSeen,
 };
-use providers::google_books::GoogleBooksService;
+use providers::{google_books::GoogleBooksService, openlibrary::OpenlibraryService};
 use rust_decimal::Decimal;
 use rust_decimal_macros::dec;
 use serde::{Deserialize, Serialize};
 
+use crate::utils;
+
 use super::{ImportFailStep, ImportFailedItem, ImportOrExportMediaItem};
 
 #[derive(Debug, Serialize, Deserialize)]
@@ -49,10 +51,10 @@ struct History {
 
 pub async fn import(
     input: DeployGenericCsvImportInput,
-    isbn_service: &GoogleBooksService,
+    google_books_service: &GoogleBooksService,
+    open_library_service: &OpenlibraryService,
 ) -> Result<ImportResult> {
     let lot = MediaLot::Book;
-    let source = MediaSource::GoogleBooks;
     let mut media = vec![];
     let mut failed_items = vec![];
     let ratings_reader = Reader::from_path(input.csv_path)
@@ -78,70 +80,77 @@ pub async fn import(
             "Getting details for {title:?} ({idx}/{total})",
             title = record.title
         );
-        if let Some(isbn) = record.isbn {
-            if let Some(identifier) = isbn_service.id_from_isbn(&isbn).await {
-                let mut seen_history = vec![
-                    ImportOrExportMediaItemSeen {
-                        started_on: None,
-                        ended_on: None,
-                        provider_watched_on: Some(ImportSource::StoryGraph.to_string()),
-                        ..Default::default()
-                    };
-                    record.read_count
-                ];
-                if let Some(w) = record.last_date_read {
-                    let w = NaiveDate::parse_from_str(&w, "%Y/%m/%d").unwrap();
-                    seen_history.first_mut().unwrap().ended_on = Some(w);
-                }
-                let mut collections = vec![];
-                collections.push(match record.read_status {
-                    ReadStatus::ToRead => "Watchlist".to_owned(),
-                    ReadStatus::CurrentlyReading => "In Progress".to_owned(),
-                    ReadStatus::Other(s) => s.to_case(Case::Title),
-                });
-                if let Some(t) = record.tags {
-                    collections.extend(t.split(", ").map(|d| d.to_case(Case::Title)))
-                }
-                media.push(ImportOrExportMediaItem {
-                    source_id: record.title.clone(),
-                    lot,
-                    source,
-                    identifier,
-                    seen_history,
-                    reviews: vec![ImportOrExportItemRating {
-                        rating: record
-                            .rating
-                            // DEV: Rates items out of 10
-                            .map(|d| d.saturating_mul(dec!(10))),
-                        review: record.review.map(|r| ImportOrExportItemReview {
-                            date: None,
-                            spoiler: Some(false),
-                            text: Some(r),
-                            visibility: None,
-                        }),
-                        ..Default::default()
-                    }],
-                    collections,
-                })
-            } else {
-                failed_items.push(ImportFailedItem {
-                    lot: Some(lot),
-                    step: ImportFailStep::InputTransformation,
-                    identifier: record.title,
-                    error: Some(format!(
-                        "Could not convert ISBN: {} to Openlibrary ID",
-                        isbn
-                    )),
-                })
-            }
-        } else {
+        let Some(isbn) = record.isbn else {
             failed_items.push(ImportFailedItem {
                 lot: Some(lot),
                 step: ImportFailStep::InputTransformation,
                 identifier: record.title,
                 error: Some("No ISBN found".to_owned()),
-            })
+            });
+            continue;
+        };
+        let Some((identifier, source)) =
+            utils::get_identifier_from_book_isbn(&isbn, google_books_service, open_library_service)
+                .await
+        else {
+            failed_items.push(ImportFailedItem {
+                lot: Some(lot),
+                step: ImportFailStep::InputTransformation,
+                identifier: record.title,
+                error: Some(format!(
+                    "Could not convert ISBN: {} to any metadata provider",
+                    isbn
+                )),
+            });
+            continue;
+        };
+        ryot_log!(
+            debug,
+            "Got identifier = {identifier:?} from source = {source:?}"
+        );
+        let mut seen_history = vec![
+            ImportOrExportMediaItemSeen {
+                started_on: None,
+                ended_on: None,
+                provider_watched_on: Some(ImportSource::StoryGraph.to_string()),
+                ..Default::default()
+            };
+            record.read_count
+        ];
+        if let Some(w) = record.last_date_read {
+            let w = NaiveDate::parse_from_str(&w, "%Y/%m/%d").unwrap();
+            seen_history.first_mut().unwrap().ended_on = Some(w);
+        }
+        let mut collections = vec![];
+        collections.push(match record.read_status {
+            ReadStatus::ToRead => "Watchlist".to_owned(),
+            ReadStatus::CurrentlyReading => "In Progress".to_owned(),
+            ReadStatus::Other(s) => s.to_case(Case::Title),
+        });
+        if let Some(t) = record.tags {
+            collections.extend(t.split(", ").map(|d| d.to_case(Case::Title)))
         }
+        media.push(ImportOrExportMediaItem {
+            source_id: record.title.clone(),
+            lot,
+            source,
+            identifier,
+            seen_history,
+            reviews: vec![ImportOrExportItemRating {
+                rating: record
+                    .rating
+                    // DEV: Rates items out of 10
+                    .map(|d| d.saturating_mul(dec!(10))),
+                review: record.review.map(|r| ImportOrExportItemReview {
+                    date: None,
+                    spoiler: Some(false),
+                    text: Some(r),
+                    visibility: None,
+                }),
+                ..Default::default()
+            }],
+            collections,
+        })
     }
     Ok(ImportResult {
         metadata: media,
diff --git a/crates/utils/dependent/src/lib.rs b/crates/utils/dependent/src/lib.rs
index 9e9a01ccb5..6b3ea0ff69 100644
--- a/crates/utils/dependent/src/lib.rs
+++ b/crates/utils/dependent/src/lib.rs
@@ -112,7 +112,7 @@ pub async fn get_openlibrary_service(config: &config::AppConfig) -> Result<Openl
     Ok(OpenlibraryService::new(&config.books.openlibrary).await)
 }
 
-pub async fn get_isbn_service(config: &config::AppConfig) -> Result<GoogleBooksService> {
+pub async fn get_google_books_service(config: &config::AppConfig) -> Result<GoogleBooksService> {
     Ok(GoogleBooksService::new(&config.books.google_books).await)
 }
 
@@ -137,7 +137,7 @@ pub async fn get_metadata_provider(
         MediaSource::Vndb => Box::new(VndbService::new(&ss.config.visual_novels).await),
         MediaSource::Openlibrary => Box::new(get_openlibrary_service(&ss.config).await?),
         MediaSource::Itunes => Box::new(ITunesService::new(&ss.config.podcasts.itunes).await),
-        MediaSource::GoogleBooks => Box::new(get_isbn_service(&ss.config).await?),
+        MediaSource::GoogleBooks => Box::new(get_google_books_service(&ss.config).await?),
         MediaSource::Audible => Box::new(AudibleService::new(&ss.config.audio_books.audible).await),
         MediaSource::Listennotes => Box::new(ListennotesService::new(&ss.config.podcasts).await),
         MediaSource::Tmdb => match lot {
diff --git a/docs/content/configuration.md b/docs/content/configuration.md
index f70b9367ab..782f682674 100644
--- a/docs/content/configuration.md
+++ b/docs/content/configuration.md
@@ -24,6 +24,9 @@ The `/health` endpoint can be used for checking service healthiness. More inform
 
 ## All parameters
 
+Please refer to the `@envvar` annotations to know which environment variable to use for a
+given parameter.
+
 ```yaml
 {% include 'backend-config-schema.yaml' %}
 ```


Test patch: