use anyhow::{Context, Result};
use serde::Deserialize;
use super::{Ask, Category, Found, Indexer, Torrent, encode, hex_hash};
pub struct SubsPlease;
const API: &str = "https://subsplease.org/api/";
const QUALITY: [&str; 3] = ["1080", "720", "480"];
impl Indexer for SubsPlease {
fn name(&self) -> &'static str {
"subsplease"
}
fn categories(&self) -> &'static [Category] {
&[Category::Anime]
}
fn urls(&self, ask: &Ask) -> Vec<String> {
let url = match ask {
Ask::Words(query) => format!("{API}?tz=UTC&f=search&s={}", encode(query)),
Ask::Browse(_) => format!("{API}?tz=UTC&f=latest"),
};
vec![url]
}
fn parse(&self, body: &str, _ask: &Ask) -> Result<Vec<Found>> {
let response: serde_json::Value = serde_json::from_str(body)
.context("subsplease sent something other than its usual JSON")?;
let Some(entries) = response.as_object() else {
return Ok(Vec::new());
};
let mut found = Vec::new();
for value in entries.values() {
let Ok(entry) = serde_json::from_value::<Entry>(value.clone()) else {
continue;
};
let Some(release) = best(&entry.downloads) else {
continue;
};
let Some(info_hash) = hex_hash(&release.magnet) else {
continue;
};
let episode = match entry.episode.is_empty() {
true => String::new(),
false => format!(" - {}", entry.episode),
};
found.push(Found::Ready(Torrent {
title: format!("{}{episode} [{}p]", entry.show, release.res),
size_bytes: exact_length(&release.magnet),
seeders: 0,
leechers: 0,
category: Category::Anime,
source: "subsplease",
info_hash,
magnet: release.magnet.clone(),
}));
}
Ok(found)
}
}
fn best(downloads: &[Release]) -> Option<&Release> {
QUALITY
.iter()
.find_map(|wanted| downloads.iter().find(|release| release.res == *wanted))
.or_else(|| downloads.first())
}
fn exact_length(magnet: &str) -> u64 {
let Some(at) = magnet.find("&xl=") else {
return 0;
};
magnet[at + 4..]
.chars()
.take_while(char::is_ascii_digit)
.collect::<String>()
.parse()
.unwrap_or(0)
}
#[derive(Deserialize)]
struct Entry {
#[serde(default)]
show: String,
#[serde(default)]
episode: String,
#[serde(default)]
downloads: Vec<Release>,
}
#[derive(Deserialize)]
struct Release {
#[serde(default)]
res: String,
#[serde(default)]
magnet: String,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::search::ready;
const FIXTURE: &str = include_str!("fixtures/subsplease.json");
fn parse(body: &str) -> Vec<Torrent> {
ready(
SubsPlease
.parse(body, &Ask::Words("one piece".to_string()))
.unwrap(),
)
}
#[test]
fn one_episode_becomes_one_result_at_the_best_picture() {
let found = parse(FIXTURE);
assert_eq!(found.len(), 3);
assert!(found.iter().all(|t| t.title.ends_with("[1080p]")));
assert!(found.iter().all(|t| t.size_bytes > 0));
let titles: Vec<&str> = found.iter().map(|t| t.title.as_str()).collect();
assert!(titles.contains(&"One Piece - 1173 [1080p]"), "{titles:?}");
assert!(
found
.iter()
.all(|t| t.magnet.starts_with("magnet:?xt=urn:btih:"))
);
}
#[test]
fn a_base32_magnet_still_gives_a_hex_hash_to_match_nyaa_on() {
let found = parse(FIXTURE);
for torrent in &found {
assert_eq!(torrent.info_hash.len(), 40);
assert!(torrent.info_hash.bytes().all(|b| b.is_ascii_hexdigit()));
}
}
#[test]
fn a_search_with_no_hits_answers_with_a_list_and_that_is_fine() {
assert!(parse("[]").is_empty());
}
#[test]
fn a_broken_response_says_so() {
assert!(
SubsPlease
.parse("not json", &Ask::Browse(Category::Anime))
.is_err()
);
}
#[test]
fn the_byte_count_comes_out_of_the_magnet_link() {
assert_eq!(
exact_length("magnet:?xt=urn:btih:abc&xl=376124912&dn=x"),
376_124_912
);
assert_eq!(exact_length("magnet:?xt=urn:btih:abc"), 0);
}
}