From 546e4944b4bfe4b00054539982bf828e1ae9e5f5 Mon Sep 17 00:00:00 2001 From: askiiart Date: Sun, 1 Dec 2024 12:31:43 -0600 Subject: [PATCH] complete all search functions --- README.md | 10 +- dev-notes.md | 3 +- src/api.rs | 392 +++++++++++++++++++++++++++++++++++++++++++-- src/data.rs | 54 ++++--- src/dummy.rs | 12 +- src/notes/usage.rs | 3 + 6 files changed, 429 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index 6db37b6..61b96eb 100644 --- a/README.md +++ b/README.md @@ -24,11 +24,11 @@ Note: I wrote the line above when I was tired. Don't ask me what *literal* truck | API call | Explanation | Implemented | | -------- | ------------------------------------------------------------ | ----------- | | caps | Returns the capabilities of the api. | ✅ | -| search | Free text search query. | ❌ | -| tvsearch | Search query with tv specific query params and filtering. | ❌ | -| movie | Search query with movie specific query params and filtering. | ❌ | -| music | Search query with music specific query params and filtering. | ❌ | -| book | Search query with book specific query params and filtering. | ❌ | +| search | Free text search query. | ✅ | +| tvsearch | Search query with tv specific query params and filtering. | ✅ | +| movie | Search query with movie specific query params and filtering. | ✅ | +| music | Search query with music specific query params and filtering. | ✅ | +| book | Search query with book specific query params and filtering. | ✅ | (copied from [torznab.github.io](https://torznab.github.io/spec-1.3-draft/torznab/Specification-v1.3.html)) diff --git a/dev-notes.md b/dev-notes.md index 3678845..99fa6d5 100644 --- a/dev-notes.md +++ b/dev-notes.md @@ -25,7 +25,7 @@ fn main() -> Result { Queries are returned as an RSS feed something like this: -```rusts +```rss @@ -65,6 +65,7 @@ Item attributes: - URLs: - Main URI can either be a magnet URI or a link to a .torrent file: `` - Length is ambiguous, so it will just be 0 (see below) + - for magnet: `application/x-bittorrent;x-scheme-handler/magnet` - If .torrent URL is provided, use that, if not use the magnet; also put the magnet in `magneturl` - Rest of available attributes: diff --git a/src/api.rs b/src/api.rs index 87886cd..2c8011e 100644 --- a/src/api.rs +++ b/src/api.rs @@ -34,14 +34,12 @@ struct SearchForm { impl SearchForm { /// Converts it to a SearchParameters object fn to_parameters(&self, conf: Config) -> InternalSearchParameters { - // TODO: Clean up this code - split it into a separate function? let mut categories: Option> = None; if !self.cat.is_none() { // unholy amalgation of code to make the comma-separated list of strings into a vector of integers categories = Some( self.cat .as_ref() - .ok_or("") .unwrap() .split(",") .filter_map(|s| s.parse().ok()) @@ -54,7 +52,6 @@ impl SearchForm { extended_attribute_names = Some( self.attrs .as_ref() - .ok_or("") .unwrap() .split(",") .map(|s| s.to_string()) @@ -109,10 +106,13 @@ pub(crate) fn caps() -> status::Custom> { // So this is needed let conf; unsafe { - if CONFIG.is_none() { - return (*STATUS_CONFIG_NOT_SPECIFIED).clone(); - } else { - conf = CONFIG.clone().ok_or("").unwrap(); + match CONFIG { + Some(ref config) => { + conf = config.clone(); + } + None => { + return (*STATUS_CONFIG_NOT_SPECIFIED).clone(); + } } } @@ -122,14 +122,13 @@ pub(crate) fn caps() -> status::Custom> { writer.write(XmlEvent::start_element("caps")).unwrap(); // add the server info - // TODO: Clean up the code by making the elements a Vec (to be used as a stack), rather than manually keeping track of them let mut element = XmlEvent::start_element("server"); match &conf.caps.server_info { Some(server_info) => { // needs to be a vec since if i just `.as_str()` them, they don't live long enough let server_info_vec: Vec<(&String, &String)> = server_info.iter().collect(); for (key, value) in server_info_vec { - element = element.attr(key.as_str(), value.as_str()); + element = element.attr(key.as_str(), value); } } None => {} @@ -235,8 +234,7 @@ pub(crate) fn caps() -> status::Custom> { } #[get("/api?t=search&")] -/// The search function for the API -// FIXME: VERY incomplete also +/// The general search function pub(crate) fn search(form: SearchForm) -> status::Custom> { // The compiler won't let you get a field from a struct in the Option here, since the default is None // So this is needed @@ -254,7 +252,7 @@ pub(crate) fn search(form: SearchForm) -> status::Custom> { let mut unauthorized = false; match conf.auth { Some(auth) => { - match parameters.apikey { + match parameters.clone().apikey { Some(apikey) => { if !auth(apikey).unwrap() { unauthorized = true; @@ -273,8 +271,370 @@ pub(crate) fn search(form: SearchForm) -> status::Custom> { return status::Custom(Status::Unauthorized, RawXml("401 Unauthorized".to_string())); } - return status::Custom( - Status::NotImplemented, - RawXml("501 Not Implemented: Search function not implemented".to_string()), - ); + let search_parameters: SearchParameters = parameters.to_search_param("search"); + + return search_handler(conf, search_parameters); +} + +#[get("/api?t=tvsearch&")] +/// The TV search function +pub(crate) fn tv_search(form: SearchForm) -> status::Custom> { + // The compiler won't let you get a field from a struct in the Option here, since the default is None + // So this is needed + let conf; + unsafe { + if CONFIG.is_none() { + return (*STATUS_CONFIG_NOT_SPECIFIED).clone(); + } else { + conf = CONFIG.clone().ok_or("").unwrap(); + } + } + + let parameters = form.to_parameters(conf.clone()); + + let mut unauthorized = false; + match conf.auth { + Some(auth) => { + match parameters.clone().apikey { + Some(apikey) => { + if !auth(apikey).unwrap() { + unauthorized = true; + } + } + None => { + unauthorized = true; + } + } + // that unwrap_or_else is to return "" if the apikey isn't specified + } + None => {} + } + + if unauthorized { + return status::Custom(Status::Unauthorized, RawXml("401 Unauthorized".to_string())); + } + + let search_parameters: SearchParameters = parameters.to_search_param("tv-search"); + + /* + * return status::Custom( + * Status::NotImplemented, + * RawXml("501 Not Implemented: Search function not implemented".to_string()), + * ); + */ + + return search_handler(conf, search_parameters); +} + +#[get("/api?t=movie&")] +/// The movie search function +pub(crate) fn movie_search(form: SearchForm) -> status::Custom> { + // The compiler won't let you get a field from a struct in the Option here, since the default is None + // So this is needed + let conf; + unsafe { + if CONFIG.is_none() { + return (*STATUS_CONFIG_NOT_SPECIFIED).clone(); + } else { + conf = CONFIG.clone().ok_or("").unwrap(); + } + } + + let parameters = form.to_parameters(conf.clone()); + + let mut unauthorized = false; + match conf.auth { + Some(auth) => { + match parameters.clone().apikey { + Some(apikey) => { + if !auth(apikey).unwrap() { + unauthorized = true; + } + } + None => { + unauthorized = true; + } + } + // that unwrap_or_else is to return "" if the apikey isn't specified + } + None => {} + } + + if unauthorized { + return status::Custom(Status::Unauthorized, RawXml("401 Unauthorized".to_string())); + } + + let search_parameters: SearchParameters = parameters.to_search_param("movie-search"); + + /* + * return status::Custom( + * Status::NotImplemented, + * RawXml("501 Not Implemented: Search function not implemented".to_string()), + * ); + */ + + return search_handler(conf, search_parameters); +} + +#[get("/api?t=music&")] +/// The music search function +pub(crate) fn music_search(form: SearchForm) -> status::Custom> { + // The compiler won't let you get a field from a struct in the Option here, since the default is None + // So this is needed + let conf; + unsafe { + if CONFIG.is_none() { + return (*STATUS_CONFIG_NOT_SPECIFIED).clone(); + } else { + conf = CONFIG.clone().ok_or("").unwrap(); + } + } + + let parameters = form.to_parameters(conf.clone()); + + let mut unauthorized = false; + match conf.auth { + Some(auth) => { + match parameters.clone().apikey { + Some(apikey) => { + if !auth(apikey).unwrap() { + unauthorized = true; + } + } + None => { + unauthorized = true; + } + } + // that unwrap_or_else is to return "" if the apikey isn't specified + } + None => {} + } + + if unauthorized { + return status::Custom(Status::Unauthorized, RawXml("401 Unauthorized".to_string())); + } + + let search_parameters: SearchParameters = parameters.to_search_param("audio-search"); + + /* + * return status::Custom( + * Status::NotImplemented, + * RawXml("501 Not Implemented: Search function not implemented".to_string()), + * ); + */ + + return search_handler(conf, search_parameters); +} + +#[get("/api?t=book&")] +/// The music search function +pub(crate) fn book_search(form: SearchForm) -> status::Custom> { + // The compiler won't let you get a field from a struct in the Option here, since the default is None + // So this is needed + let conf; + unsafe { + if CONFIG.is_none() { + return (*STATUS_CONFIG_NOT_SPECIFIED).clone(); + } else { + conf = CONFIG.clone().ok_or("").unwrap(); + } + } + + let parameters = form.to_parameters(conf.clone()); + + let mut unauthorized = false; + match conf.auth { + Some(auth) => { + match parameters.clone().apikey { + Some(apikey) => { + if !auth(apikey).unwrap() { + unauthorized = true; + } + } + None => { + unauthorized = true; + } + } + // that unwrap_or_else is to return "" if the apikey isn't specified + } + None => {} + } + + if unauthorized { + return status::Custom(Status::Unauthorized, RawXml("401 Unauthorized".to_string())); + } + + let search_parameters: SearchParameters = parameters.to_search_param("book-search"); + + /* + * return status::Custom( + * Status::NotImplemented, + * RawXml("501 Not Implemented: Search function not implemented".to_string()), + * ); + */ + + return search_handler(conf, search_parameters); +} + +fn search_handler(conf: Config, parameters: SearchParameters) -> status::Custom> { + let buffer = Vec::new(); + let mut writer = EmitterConfig::new().create_writer(buffer); + writer + .write( + XmlEvent::start_element("rss") + .attr("version", "1.0") + .attr("xmlns:atom", "http://www.w3.org/2005/Atom") + .attr("xmlns:torznab", "http://torznab.com/schemas/2015/feed"), + ) + .unwrap(); + writer.write(XmlEvent::start_element("channel")).unwrap(); + writer + .write( + XmlEvent::start_element("atom:link") + .attr("rel", "self") + .attr("type", "application/rss+xml"), + ) + .unwrap(); + + // add `title` + writer.write(XmlEvent::start_element("title")).unwrap(); + let mut title_provided = false; + match conf.caps.server_info { + Some(server_info) => { + if server_info.contains_key("title") { + match server_info.get("title") { + Some(title) => { + writer.write(XmlEvent::characters(title)).unwrap(); + title_provided = true; + } + None => {} + } + } + } + None => {} + } + if !title_provided { + writer + .write(XmlEvent::characters("Torznab indexer")) + .unwrap(); + } + writer.write(XmlEvent::end_element()).unwrap(); + + for item in (conf.search)(parameters).unwrap() { + let torrent_file_url = item.torrent_file_url.clone().unwrap_or_default(); + + let magnet_uri = item.magnet_uri.clone().unwrap_or_default(); + + if torrent_file_url == "" && magnet_uri == "" { + panic!("Torrent contains neither a .torrent file URL, not a magnet URI") + } + + // start `item` + writer.write(XmlEvent::start_element("item")).unwrap(); + + // add `title` + writer.write(XmlEvent::start_element("title")).unwrap(); + writer.write(XmlEvent::characters(&item.title)).unwrap(); + writer.write(XmlEvent::end_element()).unwrap(); + + // add `description` + writer + .write(XmlEvent::start_element("description")) + .unwrap(); + if !item.description.is_none() { + writer + .write(XmlEvent::characters(&item.description.unwrap_or_default())) + .unwrap(); + } + writer.write(XmlEvent::end_element()).unwrap(); + + // add `size` (torznab attr) + writer + .write( + XmlEvent::start_element("torznab:attr") + .attr("size", item.size.to_string().as_str()), + ) + .unwrap(); + writer.write(XmlEvent::end_element()).unwrap(); + + // add `category`s (torznab attr) + for id in item.category_ids { + writer + .write( + XmlEvent::start_element("torznab:attr") + .attr("name", "category") + .attr("value", id.to_string().as_str()), + ) + .unwrap(); + writer.write(XmlEvent::end_element()).unwrap(); + } + + // add `link` and `enclosure` (for torrent/magnet uri) + // first check if `link` exists in hashmap, and if not, fallback to `torrent_file_url`, then `magnet_uri` + writer.write(XmlEvent::start_element("link")).unwrap(); + let mut link_filled = false; // nesting two layers down of matches, so this is to keep track rather than just doing it in the None + match item.other_attributes { + Some(ref attributes) => match attributes.get("link") { + Some(tmp) => { + writer.write(XmlEvent::characters(tmp)).unwrap(); + link_filled = true; + } + None => {} + }, + None => {} + } + + if !link_filled { + match item.torrent_file_url { + Some(ref url) => { + writer.write(XmlEvent::characters(&url)).unwrap(); + writer.write(XmlEvent::end_element()).unwrap(); + writer + .write( + XmlEvent::start_element("enclosure") + .attr("url", &url) + .attr("length", 0.to_string().as_str()) + .attr("type", "application/x-bittorrent"), + ) + .unwrap(); + writer.write(XmlEvent::end_element()).unwrap(); + } + None => { + writer.write(XmlEvent::characters(&magnet_uri)).unwrap(); + writer.write(XmlEvent::end_element()).unwrap(); + writer + .write( + XmlEvent::start_element("enclosure") + .attr("url", &magnet_uri) + .attr("length", 0.to_string().as_str()) + .attr("type", "application/x-bittorrent;x-scheme-handler/magnet"), + ) + .unwrap(); + writer.write(XmlEvent::end_element()).unwrap(); + } + } + } + + // add the remaining `other_attributes` + match item.other_attributes { + Some(ref other_attributes) => { + for (key, value) in other_attributes { + writer + .write(XmlEvent::start_element("torznab::attr").attr(key.as_str(), value)) + .unwrap(); + } + } + None => {} + } + + writer.write(XmlEvent::end_element()).unwrap(); + } + writer.write(XmlEvent::end_element()).unwrap(); // close `title` + writer.write(XmlEvent::end_element()).unwrap(); // close `channel` + writer.write(XmlEvent::end_element()).unwrap(); // close `rss` + let result = str::from_utf8(writer.into_inner().as_slice()) + .unwrap() + .to_string(); // Convert buffer to a String + + return status::Custom(Status::Ok, RawXml(result)); } diff --git a/src/data.rs b/src/data.rs index 4c869ae..16119db 100644 --- a/src/data.rs +++ b/src/data.rs @@ -4,8 +4,7 @@ use std::collections::HashMap; use rocket::FromForm; pub(crate) type AuthFunc = fn(String) -> Result; -// TODO: Figure out what the arguments should be for a search function and what it should return -pub(crate) type SearchFunc = fn(String, Vec) -> Result; +pub(crate) type SearchFunc = fn(SearchParameters) -> Result, String>; #[derive(Debug, Clone, PartialEq, Eq)] /// The maximum and defaults for the `limit` parameter in queries @@ -37,7 +36,7 @@ pub struct SearchInfo { } #[derive(Debug, Clone, PartialEq, Eq)] -/// Contains subcategories, for use in `Category` +/// Contains subcategories, for use in [`Category`] pub struct Subcategory { /// The numeric ID of a subcategory /// @@ -48,7 +47,7 @@ pub struct Subcategory { } #[derive(Debug, Clone, PartialEq, Eq)] -/// Contains a category, for use in `Caps` and searches as a query parameter +/// Contains a category, for use in [`Caps`] and searches as a query parameter pub struct Category { /// The numeric ID of a category /// @@ -61,7 +60,7 @@ pub struct Category { } #[derive(Debug, Clone, PartialEq, Eq)] -/// Contains a genre, for use in `Caps` and searches as a query parameter +/// Contains a genre, for use in [`Caps`] and searches as a query parameter pub struct Genre { /// The numeric ID of a genre /// @@ -74,7 +73,7 @@ pub struct Genre { } #[derive(Debug, Clone, PartialEq, Eq)] -/// Contains a tag, for use in `Caps` and searches as a query parameter +/// Contains a tag, for use in [`Caps`] and searches as a query parameter pub struct Tag { /// The name of a tag for a torrent pub name: String, @@ -89,7 +88,6 @@ pub struct Tag { /// /// It's recommended to add any capabilities you want, and set `available` to `false` in the [`Caps`] struct for any currently unsupported search types. /// -/// TODO: Add a way to partially(?) generate automatically from the Config pub struct Caps { /// The server info, like title - optional /// @@ -146,9 +144,12 @@ pub(crate) struct InternalSearchParameters { } impl InternalSearchParameters { - pub(crate) fn to_search_param(&self, search_type: String) -> SearchParameters { + /// Converts InternalSearchParameters to SearchParmaters, i.e. add `search_type` + /// + /// Search types: `search`, `tv-search`, `movie-search`, `audio-search`, `book-search` + pub(crate) fn to_search_param(&self, search_type: &str) -> SearchParameters { return SearchParameters { - search_type: search_type, + search_type: search_type.to_string(), q: self.q.clone(), apikey: self.apikey.clone(), categories: self.categories.clone(), @@ -184,17 +185,28 @@ pub struct SearchParameters { #[derive(Debug, Clone, PartialEq, Eq)] /// Holds the info for a torrent +/// +/// Any attributes not listed here are optional, and can be put in `other_attributes`; **however**, the following are recommended: +/// - `seeders` +/// - `leechers` +/// - `peers` +/// - `infohash` +/// - `link` (link to a webpage; if not specified, will fallback to `torrent_file_url`, then `magnet_uri`) +/// +///
One of either `torrent_file_url` or `magnet_uri` are required.
pub struct Torrent { - title: String, - description: Option, - size: u64, - categories: Vec, - seeders: Option, - leechers: Option, - peers: Option, - infohash: Option, - link: Option, - torrent_file_url: Option, - magnet_uri: Option, - other_attributes: Option>, + /// The title of the torrent + pub title: String, + /// The description of the torrent - optional + pub description: Option, + /// The size of the torrent, **in bytes** + pub size: u64, + /// A vector of (sub)category IDs + pub category_ids: Vec, + /// The URL of the `.torrent` file + pub torrent_file_url: Option, + /// The magnet URI o the torrent + pub magnet_uri: Option, + /// Any other attributes + pub other_attributes: Option>, } diff --git a/src/dummy.rs b/src/dummy.rs index 3da7d46..e599035 100644 --- a/src/dummy.rs +++ b/src/dummy.rs @@ -20,8 +20,16 @@ macro_rules! hashmap { }}; } -fn dummy_search_func(_a: String, _b: Vec) -> Result { - return Ok("hi".to_string()); +fn dummy_search_func(_a: SearchParameters) -> Result, String> { + return Ok(vec![Torrent { + title: "totally normal torrent".to_string(), + description: None, + size: 9872349573, + category_ids: vec![1010], + torrent_file_url: Some("http://localhost/totally-normal.torrent".to_string()), + magnet_uri: Some("magnet:?xt=urn:btih:blahblahblahdothechachacha".to_string()), + other_attributes: None, + }]); } fn dummy_auth_func(_a: String) -> Result { diff --git a/src/notes/usage.rs b/src/notes/usage.rs index 239cb90..2f1cc5a 100644 --- a/src/notes/usage.rs +++ b/src/notes/usage.rs @@ -5,4 +5,7 @@ //! - Please implement the `season`, `ep`, and `id` attributes for torrents when possible //! - Implementing `id`, at least, is far out of scope of this library, and providing `season` and `ep` more effective than this library parsing for them. However, parsing for those as an optional fallback may be added later. //! - See [here](https://torznab.github.io/spec-1.3-draft/revisions/1.0-Torznab-Torrent-Support.html) for details +//! +//! TODO: Add better docs for using the library + // TODO: Add parsing for `season` and `ep`