From f32244de286f8dca5128df7d00b84143157346d3 Mon Sep 17 00:00:00 2001 From: Aaron Leopold <36278431+aaronleopold@users.noreply.github.com> Date: Fri, 10 Jan 2025 12:08:26 -0700 Subject: [PATCH] :sparkles: Support basic series keyword search for OPDS v1.2 (#558) Resolves https://github.com/stumpapp/stump/issues/552 --- apps/server/src/routers/opds/v1_2.rs | 139 ++++++++++++++++++++------- core/src/db/query/pagination.rs | 6 ++ core/src/opds/v1_2/feed.rs | 118 +++++++++++++++++++++-- core/src/opds/v1_2/opensearch.rs | 54 +++++------ 4 files changed, 243 insertions(+), 74 deletions(-) diff --git a/apps/server/src/routers/opds/v1_2.rs b/apps/server/src/routers/opds/v1_2.rs index 87d731627..aeec73139 100644 --- a/apps/server/src/routers/opds/v1_2.rs +++ b/apps/server/src/routers/opds/v1_2.rs @@ -4,6 +4,7 @@ use axum::{ routing::get, Extension, Router, }; +use prisma_client_rust::or; use prisma_client_rust::{chrono, Direction}; use serde::{Deserialize, Serialize}; use stump_core::{ @@ -15,10 +16,13 @@ use stump_core::{ }, opds::v1_2::{ entry::{IntoOPDSEntry, OPDSEntryBuilder, OpdsEntry}, - feed::{OPDSFeedBuilder, OpdsFeed}, + feed::{ + OPDSFeedBuilder, OPDSFeedBuilderPageParams, OPDSFeedBuilderParams, OpdsFeed, + }, link::{OpdsLink, OpdsLinkRel, OpdsLinkType}, + opensearch::OpdsOpenSearch, }, - prisma::{active_reading_session, library, media, series, user}, + prisma::{active_reading_session, library, media, series, series_metadata, user}, }; use tracing::{debug, trace}; @@ -41,6 +45,7 @@ use crate::{ pub(crate) fn mount(app_state: AppState) -> Router { let primary_router = Router::new() .route("/catalog", get(catalog)) + .route("/search", get(search_description)) .route("/keep-reading", get(keep_reading)) .nest( "/libraries", @@ -114,6 +119,12 @@ struct OPDSFilenameURLParams { filename: String, } +#[derive(Debug, Default, Serialize, Deserialize)] +struct OPDSSearchQuery { + #[serde(default)] + search: Option, +} + fn pagination_bounds(page: i64, page_size: i64) -> (i64, i64) { let skip = page * page_size; (skip, page_size) @@ -127,6 +138,14 @@ fn catalog_url(req_ctx: &RequestContext, path: &str) -> String { } } +fn service_url(req_ctx: &RequestContext) -> String { + if let Some(api_key) = req_ctx.api_key() { + format!("/opds/{}/v1.2", api_key) + } else { + "/opds/v1.2".to_string() + } +} + async fn catalog(Extension(req): Extension) -> APIResult { let entries = vec![ OpdsEntry::new( @@ -197,6 +216,11 @@ async fn catalog(Extension(req): Extension) -> APIResult { rel: OpdsLinkRel::Start, href: catalog_url(&req, "catalog"), }, + OpdsLink { + link_type: OpdsLinkType::Search, + rel: OpdsLinkRel::Search, + href: catalog_url(&req, "search"), + }, ]; let feed = OpdsFeed::new( @@ -209,6 +233,12 @@ async fn catalog(Extension(req): Extension) -> APIResult { Ok(Xml(feed.build()?)) } +async fn search_description(Extension(req): Extension) -> APIResult { + Ok(OpdsOpenSearch::new(Some(service_url(&req))) + .build() + .map(Xml)?) +} + async fn keep_reading( State(ctx): State, Extension(req): Extension, @@ -272,9 +302,10 @@ async fn keep_reading( Ok(Xml(feed.build()?)) } -// TODO: age restrictions +/// A handler for GET /opds/v1.2/libraries, accepts a `search` URL param async fn get_libraries( State(ctx): State, + Query(OPDSSearchQuery { search }): Query, Extension(req): Extension, ) -> APIResult { let db = &ctx.db; @@ -282,7 +313,10 @@ async fn get_libraries( let user = req.user(); let libraries = db .library() - .find_many(vec![library_not_hidden_from_user_filter(user)]) + .find_many(chain_optional_iter( + [library_not_hidden_from_user_filter(user)], + [search.as_ref().map(|q| library::name::contains(q.clone()))], + )) .order_by(library::name::order(Direction::Asc)) .exec() .await?; @@ -383,14 +417,18 @@ async fn get_library_by_id( }) .collect::>(); - let feed = OPDSFeedBuilder::new(req.api_key()).paginated( - library.id.as_str(), - library.name.as_str(), - entries, - format!("libraries/{}", &library.id).as_str(), - page.into(), - library_series_count, - )?; + let feed = + OPDSFeedBuilder::new(req.api_key()).paginated(OPDSFeedBuilderParams { + id: library.id.clone(), + title: library.name.clone(), + entries, + href_postfix: format!("libraries/{}", &library.id), + page_params: Some(OPDSFeedBuilderPageParams { + page: page.into(), + count: library_series_count, + }), + search: None, + })?; Ok(Xml(feed.build()?)) } else { Err(APIError::NotFound(format!( @@ -399,11 +437,16 @@ async fn get_library_by_id( } } +// FIXME: Based on testing with Panels, it seems like pagination isn't an expected default when +// a search is present? This feels both odd but understandable to support an "at a glance" view, +// but I feel like it should still support pagination... + /// A handler for GET /opds/v1.2/series, accepts a `page` URL param. Note: OPDS /// pagination is zero-indexed. async fn get_series( State(ctx): State, - pagination: Query, + Query(pagination): Query, + Query(OPDSSearchQuery { search }): Query, Extension(req): Extension, ) -> APIResult { let db = &ctx.db; @@ -417,12 +460,26 @@ async fn get_series( .as_ref() .map(|ar| apply_series_age_restriction(ar.age, ar.restrict_on_unset)); + let search_clone = search.clone(); let (series, count) = db ._transaction() .run(|client| async move { let series = client .series() - .find_many(chain_optional_iter([], [age_restrictions.clone()])) + .find_many(chain_optional_iter( + [], + [ + age_restrictions.clone(), + search_clone.map(|q| { + or![ + series::name::contains(q.clone()), + series::metadata::is(vec![ + series_metadata::title::contains(q), + ]) + ] + }), + ], + )) .skip(skip) .take(take) .order_by(series::name::order(Direction::Asc)) @@ -445,14 +502,17 @@ async fn get_series( }) .collect::>(); - let feed = OPDSFeedBuilder::new(req.api_key()).paginated( - "allSeries", - "All Series", + let feed = OPDSFeedBuilder::new(req.api_key()).paginated(OPDSFeedBuilderParams { + id: "allSeries".to_string(), + title: "All Series".to_string(), entries, - "series", - page.into(), - count, - )?; + href_postfix: "series".to_string(), + page_params: Some(OPDSFeedBuilderPageParams { + page: page.into(), + count, + }), + search, + })?; Ok(Xml(feed.build()?)) } @@ -501,14 +561,17 @@ async fn get_latest_series( }) .collect::>(); - let feed = OPDSFeedBuilder::new(req.api_key()).paginated( - "latestSeries", - "Latest Series", + let feed = OPDSFeedBuilder::new(req.api_key()).paginated(OPDSFeedBuilderParams { + id: "latestSeries".to_string(), + title: "Latest Series".to_string(), entries, - "series/latest", - page.into(), - count, - )?; + href_postfix: "series/latest".to_string(), + page_params: Some(OPDSFeedBuilderPageParams { + page: page.into(), + count, + }), + search: None, + })?; Ok(Xml(feed.build()?)) } @@ -575,14 +638,18 @@ async fn get_series_by_id( }) .collect(); - let feed = OPDSFeedBuilder::new(req.api_key()).paginated( - series.id.as_str(), - series.name.as_str(), - entries, - format!("series/{}", &series.id).as_str(), - page.into(), - series_book_count, - )?; + let feed = + OPDSFeedBuilder::new(req.api_key()).paginated(OPDSFeedBuilderParams { + id: series.id.clone(), + title: series.name.clone(), + entries, + href_postfix: format!("series/{}", &series.id), + page_params: Some(OPDSFeedBuilderPageParams { + page: page.into(), + count: series_book_count, + }), + search: None, + })?; Ok(Xml(feed.build()?)) } else { Err(APIError::NotFound(format!("Series {series_id} not found"))) diff --git a/core/src/db/query/pagination.rs b/core/src/db/query/pagination.rs index 508748acd..cd95004a9 100644 --- a/core/src/db/query/pagination.rs +++ b/core/src/db/query/pagination.rs @@ -22,6 +22,12 @@ pub struct PageQuery { pub page_size: Option, } +impl PageQuery { + pub fn is_empty(&self) -> bool { + self.page.is_none() && self.page_size.is_none() + } +} + #[skip_serializing_none] #[derive( Clone, Default, Debug, Deserialize, Serialize, PartialEq, Eq, Type, ToSchema, diff --git a/core/src/opds/v1_2/feed.rs b/core/src/opds/v1_2/feed.rs index 77e6515bc..4beecce3f 100644 --- a/core/src/opds/v1_2/feed.rs +++ b/core/src/opds/v1_2/feed.rs @@ -1,6 +1,8 @@ //! This module defines a struct,[`OpdsFeed`], for representing an OPDS catalogue feed document //! as specified at https://specs.opds.io/opds-1.2#2-opds-catalog-feed-documents +use std::collections::HashMap; + use crate::{ error::CoreError, opds::v1_2::{ @@ -8,6 +10,7 @@ use crate::{ link::OpdsLink, }, prisma::{library, series}, + utils::chain_optional_iter, }; use prisma_client_rust::chrono::{self, DateTime, Utc}; use xml::{writer::XmlEvent, EventWriter}; @@ -90,6 +93,22 @@ pub struct OPDSFeedBuilder { api_key: Option, } +#[derive(Debug, Default)] +pub struct OPDSFeedBuilderPageParams { + pub page: i64, + pub count: i64, +} + +#[derive(Debug, Default)] +pub struct OPDSFeedBuilderParams { + pub id: String, + pub title: String, + pub entries: Vec, + pub href_postfix: String, + pub page_params: Option, + pub search: Option, +} + impl OPDSFeedBuilder { pub fn new(api_key: Option) -> Self { Self { api_key } @@ -103,6 +122,21 @@ impl OPDSFeedBuilder { } } + fn format_params(&self, path: &str, params: HashMap) -> String { + let mut url = self.format_url(path); + if !params.is_empty() { + url.push('?'); + for (idx, (key, value)) in params.iter().enumerate() { + if idx == params.len() - 1 { + url.push_str(&format!("{}={}", key, value)); + } else { + url.push_str(&format!("{}={}&", key, value)); + } + } + } + url + } + pub fn library(&self, library: library::Data) -> Result { let id = library.id.clone(); let title = library.name.clone(); @@ -137,18 +171,33 @@ impl OPDSFeedBuilder { pub fn paginated( self, - id: &str, - title: &str, - entries: Vec, - href_postfix: &str, - page: i64, - count: i64, + OPDSFeedBuilderParams { + id, + title, + entries, + href_postfix, + page_params, + search, + }: OPDSFeedBuilderParams, ) -> Result { + let OPDSFeedBuilderPageParams { page, count } = page_params.unwrap_or_default(); + + let search_params = search + .as_ref() + .map(|s| ("search".to_string(), s.to_string())); + + let this_params = chain_optional_iter( + [("page".to_string(), page.to_string())], + [search_params.clone()], + ) + .into_iter() + .collect::>(); + let mut links = vec![ OpdsLink::new( OpdsLinkType::Navigation, OpdsLinkRel::ItSelf, - self.format_url(&format!("{}?page={}", href_postfix, page)), + self.format_params(&href_postfix, this_params), ), OpdsLink::new( OpdsLinkType::Navigation, @@ -158,20 +207,34 @@ impl OPDSFeedBuilder { ]; if page > 0 { + let next_params = chain_optional_iter( + [("page".to_string(), (page - 1).to_string())], + [search_params.clone()], + ) + .into_iter() + .collect::>(); + links.push(OpdsLink { link_type: OpdsLinkType::Navigation, rel: OpdsLinkRel::Previous, - href: self.format_url(&format!("{}?page={}", href_postfix, page - 1)), + href: self.format_params(&href_postfix, next_params), }); } let total_pages = (count as f32 / 20.0).ceil() as u32; if page < total_pages as i64 && entries.len() == 20 { + let next_params = chain_optional_iter( + [("page".to_string(), (page + 1).to_string())], + [search_params.clone()], + ) + .into_iter() + .collect::>(); + links.push(OpdsLink { link_type: OpdsLinkType::Navigation, rel: OpdsLinkRel::Next, - href: self.format_url(&format!("{}?page={}", href_postfix, page + 1)), + href: self.format_params(&href_postfix, next_params), }); } @@ -182,6 +245,43 @@ impl OPDSFeedBuilder { entries, )) } + + pub fn unpaged( + self, + OPDSFeedBuilderParams { + id, + title, + entries, + href_postfix, + search, + .. + }: OPDSFeedBuilderParams, + ) -> Result { + let search_params = + chain_optional_iter([], [search.map(|s| ("search".to_string(), s.clone()))]) + .into_iter() + .collect::>(); + + let links = vec![ + OpdsLink::new( + OpdsLinkType::Navigation, + OpdsLinkRel::ItSelf, + self.format_params(&href_postfix, search_params), + ), + OpdsLink::new( + OpdsLinkType::Navigation, + OpdsLinkRel::Start, + self.format_url("catalog"), + ), + ]; + + Ok(OpdsFeed::new( + id.to_string(), + title.to_string(), + Some(links), + entries, + )) + } } #[cfg(test)] diff --git a/core/src/opds/v1_2/opensearch.rs b/core/src/opds/v1_2/opensearch.rs index c677318c9..e0a77b0ca 100644 --- a/core/src/opds/v1_2/opensearch.rs +++ b/core/src/opds/v1_2/opensearch.rs @@ -12,51 +12,47 @@ use super::{ /// A struct for building an OpenSearch SearchDescription XML string as /// specified at https://developer.mozilla.org/en-US/docs/Web/OpenSearch -pub struct OpdsOpenSearch {} +pub struct OpdsOpenSearch { + service_url: Option, +} impl OpdsOpenSearch { + pub fn new(service_url: Option) -> Self { + Self { service_url } + } + + fn format_url(&self, url: &str) -> String { + if let Some(service_url) = &self.service_url { + format!("{}/{}", service_url, url) + } else { + url.to_string() + } + } + /// Build an xml string for the OpenSearchDescription - pub fn build() -> CoreResult { + pub fn build(self) -> CoreResult { let raw = Vec::new(); let mut writer = EventWriter::new(raw); - writer.write(XmlEvent::start_element("OpenSearchDescription"))?; + writer.write( + XmlEvent::start_element("OpenSearchDescription") + .attr("xmlns", "http://a9.com/-/spec/opensearch/1.1/"), + )?; write_xml_element("ShortName", "Search", &mut writer)?; write_xml_element("Description", "Search by keyword", &mut writer)?; write_xml_element("InputEncoding", "UTF-8", &mut writer)?; write_xml_element("OutputEncoding", "UTF-8", &mut writer)?; - let series_example = "/opds/v1.2/series?search={searchTerms}"; - let library_example = "/opds/v1.2/libraries?search={searchTerms}"; - let media_example = "/opds/v1.2/books?search={searchTerms}"; - - // series template URL - writer.write( - XmlEvent::start_element("Url") - .attr("type", OpdsLinkType::Acquisition.as_str()) - .attr("template", series_example), - )?; - - writer.write(XmlEvent::end_element())?; // end of URL + let series_example = self.format_url("series?search={searchTerms}"); - // libraries template URL + // start of series template URL writer.write( XmlEvent::start_element("Url") - .attr("type", OpdsLinkType::Acquisition.as_str()) - .attr("template", library_example), + .attr("template", &series_example) + .attr("type", OpdsLinkType::Acquisition.as_str()), )?; - - writer.write(XmlEvent::end_element())?; // end of URL - - // media template URL - writer.write( - XmlEvent::start_element("Url") - .attr("type", OpdsLinkType::Acquisition.as_str()) - .attr("template", media_example), - )?; - - writer.write(XmlEvent::end_element())?; // end of URL + writer.write(XmlEvent::end_element())?; // end series template URL // TODO: more templates? have to look into open search spec to see // if that 'is possible' or if it is only supposed to be one