complete all search functions

This commit is contained in:
askiiart 2024-12-01 12:31:43 -06:00
parent 492579035d
commit 546e4944b4
Signed by untrusted user who does not match committer: askiiart
GPG key ID: EA85979611654C30
6 changed files with 429 additions and 45 deletions

View file

@ -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 | | API call | Explanation | Implemented |
| -------- | ------------------------------------------------------------ | ----------- | | -------- | ------------------------------------------------------------ | ----------- |
| caps | Returns the capabilities of the api. | ✅ | | caps | Returns the capabilities of the api. | ✅ |
| search | Free text search query. | | | search | Free text search query. | |
| tvsearch | Search query with tv specific query params and filtering. | | | tvsearch | Search query with tv specific query params and filtering. | |
| movie | Search query with movie 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. | | | music | Search query with music specific query params and filtering. | |
| book | Search query with book specific query params and filtering. | | | book | Search query with book specific query params and filtering. | |
<!-- for copy-pasting: ❌ ✅ --> <!-- for copy-pasting: ❌ ✅ -->
(copied from [torznab.github.io](https://torznab.github.io/spec-1.3-draft/torznab/Specification-v1.3.html)) (copied from [torznab.github.io](https://torznab.github.io/spec-1.3-draft/torznab/Specification-v1.3.html))

View file

@ -25,7 +25,7 @@ fn main() -> Result {
Queries are returned as an RSS feed something like this: Queries are returned as an RSS feed something like this:
```rusts ```rss
<rss version="1.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:torznab="http://torznab.com/schemas/2015/feed"> <rss version="1.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:torznab="http://torznab.com/schemas/2015/feed">
<channel> <channel>
<atom:link rel="self" type="application/rss+xml" /> <atom:link rel="self" type="application/rss+xml" />
@ -65,6 +65,7 @@ Item attributes:
- URLs: - URLs:
- Main URI can either be a magnet URI or a link to a .torrent file: `<enclosure url="http://localhost/" length="0" type="application/x-bittorrent" />` - Main URI can either be a magnet URI or a link to a .torrent file: `<enclosure url="http://localhost/" length="0" type="application/x-bittorrent" />`
- Length is ambiguous, so it will just be 0 (see below) - 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` - If .torrent URL is provided, use that, if not use the magnet; also put the magnet in `magneturl`
- Rest of available attributes: <https://torznab.github.io/spec-1.3-draft/torznab/Specification-v1.3.html?highlight=server#predefined-attributes> - Rest of available attributes: <https://torznab.github.io/spec-1.3-draft/torznab/Specification-v1.3.html?highlight=server#predefined-attributes>

View file

@ -34,14 +34,12 @@ struct SearchForm {
impl SearchForm { impl SearchForm {
/// Converts it to a SearchParameters object /// Converts it to a SearchParameters object
fn to_parameters(&self, conf: Config) -> InternalSearchParameters { fn to_parameters(&self, conf: Config) -> InternalSearchParameters {
// TODO: Clean up this code - split it into a separate function?
let mut categories: Option<Vec<u32>> = None; let mut categories: Option<Vec<u32>> = None;
if !self.cat.is_none() { if !self.cat.is_none() {
// unholy amalgation of code to make the comma-separated list of strings into a vector of integers // unholy amalgation of code to make the comma-separated list of strings into a vector of integers
categories = Some( categories = Some(
self.cat self.cat
.as_ref() .as_ref()
.ok_or("")
.unwrap() .unwrap()
.split(",") .split(",")
.filter_map(|s| s.parse().ok()) .filter_map(|s| s.parse().ok())
@ -54,7 +52,6 @@ impl SearchForm {
extended_attribute_names = Some( extended_attribute_names = Some(
self.attrs self.attrs
.as_ref() .as_ref()
.ok_or("")
.unwrap() .unwrap()
.split(",") .split(",")
.map(|s| s.to_string()) .map(|s| s.to_string())
@ -109,10 +106,13 @@ pub(crate) fn caps() -> status::Custom<RawXml<String>> {
// So this is needed // So this is needed
let conf; let conf;
unsafe { unsafe {
if CONFIG.is_none() { match CONFIG {
Some(ref config) => {
conf = config.clone();
}
None => {
return (*STATUS_CONFIG_NOT_SPECIFIED).clone(); return (*STATUS_CONFIG_NOT_SPECIFIED).clone();
} else { }
conf = CONFIG.clone().ok_or("").unwrap();
} }
} }
@ -122,14 +122,13 @@ pub(crate) fn caps() -> status::Custom<RawXml<String>> {
writer.write(XmlEvent::start_element("caps")).unwrap(); writer.write(XmlEvent::start_element("caps")).unwrap();
// add the server info // 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"); let mut element = XmlEvent::start_element("server");
match &conf.caps.server_info { match &conf.caps.server_info {
Some(server_info) => { Some(server_info) => {
// needs to be a vec since if i just `.as_str()` them, they don't live long enough // 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(); let server_info_vec: Vec<(&String, &String)> = server_info.iter().collect();
for (key, value) in server_info_vec { for (key, value) in server_info_vec {
element = element.attr(key.as_str(), value.as_str()); element = element.attr(key.as_str(), value);
} }
} }
None => {} None => {}
@ -235,8 +234,7 @@ pub(crate) fn caps() -> status::Custom<RawXml<String>> {
} }
#[get("/api?t=search&<form..>")] #[get("/api?t=search&<form..>")]
/// The search function for the API /// The general search function
// FIXME: VERY incomplete also
pub(crate) fn search(form: SearchForm) -> status::Custom<RawXml<String>> { pub(crate) fn search(form: SearchForm) -> status::Custom<RawXml<String>> {
// The compiler won't let you get a field from a struct in the Option here, since the default is None // 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 // So this is needed
@ -254,7 +252,7 @@ pub(crate) fn search(form: SearchForm) -> status::Custom<RawXml<String>> {
let mut unauthorized = false; let mut unauthorized = false;
match conf.auth { match conf.auth {
Some(auth) => { Some(auth) => {
match parameters.apikey { match parameters.clone().apikey {
Some(apikey) => { Some(apikey) => {
if !auth(apikey).unwrap() { if !auth(apikey).unwrap() {
unauthorized = true; unauthorized = true;
@ -273,8 +271,370 @@ pub(crate) fn search(form: SearchForm) -> status::Custom<RawXml<String>> {
return status::Custom(Status::Unauthorized, RawXml("401 Unauthorized".to_string())); return status::Custom(Status::Unauthorized, RawXml("401 Unauthorized".to_string()));
} }
return status::Custom( let search_parameters: SearchParameters = parameters.to_search_param("search");
Status::NotImplemented,
RawXml("501 Not Implemented: Search function not implemented".to_string()), return search_handler(conf, search_parameters);
); }
#[get("/api?t=tvsearch&<form..>")]
/// The TV search function
pub(crate) fn tv_search(form: SearchForm) -> status::Custom<RawXml<String>> {
// 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&<form..>")]
/// The movie search function
pub(crate) fn movie_search(form: SearchForm) -> status::Custom<RawXml<String>> {
// 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&<form..>")]
/// The music search function
pub(crate) fn music_search(form: SearchForm) -> status::Custom<RawXml<String>> {
// 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&<form..>")]
/// The music search function
pub(crate) fn book_search(form: SearchForm) -> status::Custom<RawXml<String>> {
// 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<RawXml<String>> {
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));
} }

View file

@ -4,8 +4,7 @@ use std::collections::HashMap;
use rocket::FromForm; use rocket::FromForm;
pub(crate) type AuthFunc = fn(String) -> Result<bool, String>; pub(crate) type AuthFunc = fn(String) -> Result<bool, String>;
// TODO: Figure out what the arguments should be for a search function and what it should return pub(crate) type SearchFunc = fn(SearchParameters) -> Result<Vec<Torrent>, String>;
pub(crate) type SearchFunc = fn(String, Vec<String>) -> Result<String, String>;
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
/// The maximum and defaults for the `limit` parameter in queries /// The maximum and defaults for the `limit` parameter in queries
@ -37,7 +36,7 @@ pub struct SearchInfo {
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
/// Contains subcategories, for use in `Category` /// Contains subcategories, for use in [`Category`]
pub struct Subcategory { pub struct Subcategory {
/// The numeric ID of a subcategory /// The numeric ID of a subcategory
/// ///
@ -48,7 +47,7 @@ pub struct Subcategory {
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[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 { pub struct Category {
/// The numeric ID of a category /// The numeric ID of a category
/// ///
@ -61,7 +60,7 @@ pub struct Category {
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[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 { pub struct Genre {
/// The numeric ID of a genre /// The numeric ID of a genre
/// ///
@ -74,7 +73,7 @@ pub struct Genre {
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[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 { pub struct Tag {
/// The name of a tag for a torrent /// The name of a tag for a torrent
pub name: String, 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.</div> /// It's recommended to add any capabilities you want, and set `available` to `false` in the [`Caps`] struct for any currently unsupported search types.</div>
/// ///
/// TODO: Add a way to partially(?) generate automatically from the Config
pub struct Caps { pub struct Caps {
/// The server info, like title - optional /// The server info, like title - optional
/// ///
@ -146,9 +144,12 @@ pub(crate) struct InternalSearchParameters {
} }
impl 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 { return SearchParameters {
search_type: search_type, search_type: search_type.to_string(),
q: self.q.clone(), q: self.q.clone(),
apikey: self.apikey.clone(), apikey: self.apikey.clone(),
categories: self.categories.clone(), categories: self.categories.clone(),
@ -184,17 +185,28 @@ pub struct SearchParameters {
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
/// Holds the info for a torrent /// 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`)
///
/// <div class="warning">One of either `torrent_file_url` or `magnet_uri` are required.</div>
pub struct Torrent { pub struct Torrent {
title: String, /// The title of the torrent
description: Option<String>, pub title: String,
size: u64, /// The description of the torrent - optional
categories: Vec<Category>, pub description: Option<String>,
seeders: Option<u32>, /// The size of the torrent, **in bytes**
leechers: Option<u32>, pub size: u64,
peers: Option<u32>, /// A vector of (sub)category IDs
infohash: Option<String>, pub category_ids: Vec<u32>,
link: Option<String>, /// The URL of the `.torrent` file
torrent_file_url: Option<String>, pub torrent_file_url: Option<String>,
magnet_uri: Option<String>, /// The magnet URI o the torrent
other_attributes: Option<HashMap<String, String>>, pub magnet_uri: Option<String>,
/// Any other attributes
pub other_attributes: Option<HashMap<String, String>>,
} }

View file

@ -20,8 +20,16 @@ macro_rules! hashmap {
}}; }};
} }
fn dummy_search_func(_a: String, _b: Vec<String>) -> Result<String, String> { fn dummy_search_func(_a: SearchParameters) -> Result<Vec<Torrent>, String> {
return Ok("hi".to_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<bool, String> { fn dummy_auth_func(_a: String) -> Result<bool, String> {

View file

@ -5,4 +5,7 @@
//! - Please implement the `season`, `ep`, and `id` attributes for torrents when possible //! - 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. //! - 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 //! - 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` // TODO: Add parsing for `season` and `ep`