Merge #188: Podnapisi provider
Closes #188 # Conflicts: # Cargo.lock # crates/arr-subs/Cargo.toml # crates/arr-subs/src/error.rs # crates/arr-subs/src/lib.rs
This commit is contained in:
@@ -27,6 +27,7 @@ serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
tracing.workspace = true
|
||||
zip.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
|
||||
@@ -87,6 +87,17 @@ pub enum Error {
|
||||
/// What was wrong, short enough to log.
|
||||
detail: String,
|
||||
},
|
||||
|
||||
/// The provider could not be constructed: a bad base URL, or its HTTP
|
||||
/// client failed to build. Always a caller bug — a hardcoded URL or a
|
||||
/// test override — never a fact about the live service.
|
||||
#[error("{provider} could not be configured: {detail}")]
|
||||
Config {
|
||||
/// The provider being constructed.
|
||||
provider: ProviderId,
|
||||
/// What went wrong, short enough to log.
|
||||
detail: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl Error {
|
||||
|
||||
@@ -21,9 +21,16 @@
|
||||
use std::{fmt, future::Future, pin::Pin};
|
||||
|
||||
pub mod decode;
|
||||
// `unused_crate_dependencies` is a per-target lint; `wiremock` is only used
|
||||
// from the `tests/podnapisi.rs` integration target, not from this library
|
||||
// target's own `#[cfg(test)]` module.
|
||||
#[cfg(test)]
|
||||
use wiremock as _;
|
||||
|
||||
pub mod error;
|
||||
pub mod model;
|
||||
pub mod opensubtitles;
|
||||
pub mod podnapisi;
|
||||
pub mod srt;
|
||||
pub mod translate;
|
||||
|
||||
@@ -32,6 +39,9 @@ pub use model::{
|
||||
Candidate, CandidateId, Fetched, MediaFile, MediaRef, ProviderId, SearchRequest, SubtitleFormat,
|
||||
};
|
||||
pub use opensubtitles::{moviehash, OpenSubtitles, OpenSubtitlesConfig};
|
||||
pub use podnapisi::{
|
||||
PodnapisiProvider, PodnapisiProviderBuilder, DEFAULT_BASE_URL as PODNAPISI_DEFAULT_BASE_URL,
|
||||
};
|
||||
pub use srt::Cue;
|
||||
pub use translate::{Backend, BackendId, Batch, BatchCue, TranslateFuture, TranslatedCue};
|
||||
|
||||
|
||||
@@ -0,0 +1,490 @@
|
||||
//! The Podnapisi.net provider (`DESIGN.md` §15).
|
||||
//!
|
||||
//! Podnapisi has no `moviehash` lane: it matches on a text search over the
|
||||
//! release name it was imported under, nothing else. Every candidate this
|
||||
//! provider returns therefore carries `hash_match: false`, which is exactly
|
||||
//! what ranking (#185) already expresses without a special case — a plain
|
||||
//! release-name match and below.
|
||||
//!
|
||||
//! The search API is unauthenticated JSON, and download hands back a zip
|
||||
//! containing exactly one subtitle file. Both are undocumented officially;
|
||||
//! this follows the shape `subliminal`'s maintained provider uses against the
|
||||
//! live service.
|
||||
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
io::{Cursor, Read as _},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use arr_core::{
|
||||
lang::{resolve_portuguese, PortugueseEvidence},
|
||||
Language,
|
||||
};
|
||||
use reqwest::{StatusCode, Url};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::{
|
||||
error::{Error, Result},
|
||||
model::{
|
||||
Candidate, CandidateId, Fetched, MediaFile, MediaRef, ProviderId, SearchRequest,
|
||||
SubtitleFormat,
|
||||
},
|
||||
DownloadFuture, Provider, SearchFuture,
|
||||
};
|
||||
|
||||
/// Podnapisi's public search and download root.
|
||||
pub const DEFAULT_BASE_URL: &str = "https://www.podnapisi.net/subtitles/";
|
||||
|
||||
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
/// A results page claiming more pages than this is treated as malformed
|
||||
/// rather than looped over forever.
|
||||
const MAX_PAGES: u32 = 50;
|
||||
|
||||
fn provider_id() -> ProviderId {
|
||||
ProviderId::new("podnapisi")
|
||||
}
|
||||
|
||||
/// The Podnapisi.net subtitle provider.
|
||||
#[derive(Debug)]
|
||||
pub struct PodnapisiProvider {
|
||||
http: reqwest::Client,
|
||||
base_url: Url,
|
||||
}
|
||||
|
||||
impl PodnapisiProvider {
|
||||
/// A provider against the real Podnapisi.net, with default timeouts.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// [`Error::Config`] if the HTTP client cannot be built.
|
||||
pub fn new() -> Result<Self> {
|
||||
Self::builder().build()
|
||||
}
|
||||
|
||||
/// Start configuring a provider.
|
||||
#[must_use]
|
||||
pub fn builder() -> PodnapisiProviderBuilder {
|
||||
PodnapisiProviderBuilder {
|
||||
base_url: DEFAULT_BASE_URL.to_owned(),
|
||||
timeout: DEFAULT_TIMEOUT,
|
||||
}
|
||||
}
|
||||
|
||||
async fn search_language(
|
||||
&self,
|
||||
file: &MediaFile,
|
||||
language: &Language,
|
||||
) -> Result<Vec<Candidate>> {
|
||||
let Some(keywords) = file.release_name.as_deref() else {
|
||||
// No release name, nothing to search Podnapisi's keyword index
|
||||
// with. Not an error: the provider simply has nothing to offer.
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
|
||||
let mut candidates = Vec::new();
|
||||
let mut seen_pids = HashSet::new();
|
||||
let mut page = 1;
|
||||
|
||||
loop {
|
||||
let response = self
|
||||
.fetch_page(keywords, language, &file.media, page)
|
||||
.await?;
|
||||
let all_pages = response.all_pages.max(1);
|
||||
|
||||
for item in response.data {
|
||||
if !seen_pids.insert(item.id.clone()) {
|
||||
continue;
|
||||
}
|
||||
candidates.push(item.into_candidate(language));
|
||||
}
|
||||
|
||||
if response.page >= all_pages || page >= MAX_PAGES {
|
||||
break;
|
||||
}
|
||||
page += 1;
|
||||
}
|
||||
|
||||
Ok(candidates)
|
||||
}
|
||||
|
||||
async fn fetch_page(
|
||||
&self,
|
||||
keywords: &str,
|
||||
language: &Language,
|
||||
media: &MediaRef,
|
||||
page: u32,
|
||||
) -> Result<SearchResponse> {
|
||||
let mut url = self
|
||||
.base_url
|
||||
.join("search/advanced")
|
||||
.map_err(|err| config_error(format!("invalid search URL: {err}")))?;
|
||||
|
||||
{
|
||||
let mut params = url.query_pairs_mut();
|
||||
params.append_pair("keywords", keywords);
|
||||
params.append_pair("language", &language_param(language));
|
||||
match media {
|
||||
MediaRef::Movie { .. } => {
|
||||
params.append_pair("movie_type", "movie");
|
||||
}
|
||||
MediaRef::Episode {
|
||||
season, episode, ..
|
||||
} => {
|
||||
params.append_pair("movie_type", "tv-series");
|
||||
params.append_pair("movie_type", "mini-series");
|
||||
params.append_pair("seasons", &season.to_string());
|
||||
params.append_pair("episodes", &episode.to_string());
|
||||
}
|
||||
}
|
||||
if page > 1 {
|
||||
params.append_pair("page", &page.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let body = self.get(url).await?;
|
||||
serde_json::from_str(&body).map_err(|err| Error::Malformed {
|
||||
provider: provider_id(),
|
||||
detail: format!("search response did not parse: {err}"),
|
||||
})
|
||||
}
|
||||
|
||||
/// Download one candidate this provider offered.
|
||||
async fn download_candidate(&self, id: &CandidateId) -> Result<Fetched> {
|
||||
let (pid, language) = split_candidate_id(id)?;
|
||||
|
||||
let mut url = self
|
||||
.base_url
|
||||
.join(&format!("{pid}/download"))
|
||||
.map_err(|err| config_error(format!("invalid download URL: {err}")))?;
|
||||
url.query_pairs_mut().append_pair("container", "zip");
|
||||
|
||||
let bytes = self.get_bytes(url, id).await?;
|
||||
let (content, format) = extract_single_file(&bytes)?;
|
||||
|
||||
Ok(Fetched {
|
||||
id: id.clone(),
|
||||
language,
|
||||
format,
|
||||
content,
|
||||
})
|
||||
}
|
||||
|
||||
async fn get(&self, url: Url) -> Result<String> {
|
||||
let response = self.send(url, None).await?;
|
||||
response.text().await.map_err(|err| Error::Transport {
|
||||
provider: provider_id(),
|
||||
source: Box::new(err),
|
||||
})
|
||||
}
|
||||
|
||||
async fn get_bytes(&self, url: Url, candidate: &CandidateId) -> Result<Vec<u8>> {
|
||||
let response = self.send(url, Some(candidate)).await?;
|
||||
response
|
||||
.bytes()
|
||||
.await
|
||||
.map(|bytes| bytes.to_vec())
|
||||
.map_err(|err| Error::Transport {
|
||||
provider: provider_id(),
|
||||
source: Box::new(err),
|
||||
})
|
||||
}
|
||||
|
||||
/// `candidate` is only known when the request is a download — it names
|
||||
/// the [`Error::NotFound`] a 404 becomes.
|
||||
async fn send(&self, url: Url, candidate: Option<&CandidateId>) -> Result<reqwest::Response> {
|
||||
tracing::debug!(url = %url, "Podnapisi request");
|
||||
let response = self
|
||||
.http
|
||||
.get(url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| Error::Transport {
|
||||
provider: provider_id(),
|
||||
source: Box::new(err),
|
||||
})?;
|
||||
|
||||
let status = response.status();
|
||||
if status.is_success() {
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
Err(match (status, candidate) {
|
||||
(StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN, _) => Error::Unauthorized {
|
||||
provider: provider_id(),
|
||||
},
|
||||
(StatusCode::TOO_MANY_REQUESTS, _) => Error::RateLimited {
|
||||
provider: provider_id(),
|
||||
retry_after: retry_after(&response),
|
||||
},
|
||||
(StatusCode::NOT_FOUND, Some(candidate)) => Error::NotFound {
|
||||
provider: provider_id(),
|
||||
candidate: candidate.clone(),
|
||||
},
|
||||
(other, _) => Error::Malformed {
|
||||
provider: provider_id(),
|
||||
detail: format!("HTTP {other}"),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn config_error(detail: String) -> Error {
|
||||
Error::Config {
|
||||
provider: provider_id(),
|
||||
detail,
|
||||
}
|
||||
}
|
||||
|
||||
impl Provider for PodnapisiProvider {
|
||||
fn id(&self) -> ProviderId {
|
||||
provider_id()
|
||||
}
|
||||
|
||||
fn search<'a>(&'a self, request: &'a SearchRequest) -> SearchFuture<'a> {
|
||||
Box::pin(async move {
|
||||
let mut candidates = Vec::new();
|
||||
for language in &request.languages {
|
||||
candidates.extend(self.search_language(&request.file, language).await?);
|
||||
}
|
||||
Ok(candidates)
|
||||
})
|
||||
}
|
||||
|
||||
fn download<'a>(&'a self, id: &'a CandidateId) -> DownloadFuture<'a> {
|
||||
Box::pin(async move { self.download_candidate(id).await })
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration for a [`PodnapisiProvider`].
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PodnapisiProviderBuilder {
|
||||
base_url: String,
|
||||
timeout: Duration,
|
||||
}
|
||||
|
||||
impl PodnapisiProviderBuilder {
|
||||
/// Point the provider somewhere other than Podnapisi.net. Tests use
|
||||
/// this; nothing else should.
|
||||
#[must_use]
|
||||
pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
|
||||
self.base_url = base_url.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Per-request timeout.
|
||||
#[must_use]
|
||||
pub fn timeout(mut self, timeout: Duration) -> Self {
|
||||
self.timeout = timeout;
|
||||
self
|
||||
}
|
||||
|
||||
/// Build the provider.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// [`Error::Config`] if the base URL will not parse or the HTTP client
|
||||
/// cannot be built.
|
||||
pub fn build(self) -> Result<PodnapisiProvider> {
|
||||
let mut base_url = self.base_url;
|
||||
if !base_url.ends_with('/') {
|
||||
base_url.push('/');
|
||||
}
|
||||
let base_url = Url::parse(&base_url).map_err(|err| Error::Config {
|
||||
provider: provider_id(),
|
||||
detail: format!("invalid base URL: {err}"),
|
||||
})?;
|
||||
|
||||
let http = reqwest::Client::builder()
|
||||
.timeout(self.timeout)
|
||||
.user_agent(concat!("arr/", env!("CARGO_PKG_VERSION")))
|
||||
.build()
|
||||
.map_err(|err| Error::Config {
|
||||
provider: provider_id(),
|
||||
detail: err.to_string(),
|
||||
})?;
|
||||
|
||||
Ok(PodnapisiProvider { http, base_url })
|
||||
}
|
||||
}
|
||||
|
||||
/// One page of Podnapisi's `search/advanced` response.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SearchResponse {
|
||||
data: Vec<RawCandidate>,
|
||||
page: u32,
|
||||
all_pages: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RawCandidate {
|
||||
id: String,
|
||||
language: String,
|
||||
#[serde(default)]
|
||||
flags: Vec<String>,
|
||||
#[serde(default)]
|
||||
releases: Vec<String>,
|
||||
#[serde(default)]
|
||||
custom_releases: Vec<String>,
|
||||
}
|
||||
|
||||
impl RawCandidate {
|
||||
fn into_candidate(self, requested: &Language) -> Candidate {
|
||||
let release_name = self.releases.into_iter().chain(self.custom_releases).next();
|
||||
let claims = release_name.as_deref().map(arr_parse::parse);
|
||||
|
||||
let language = resolve_language(
|
||||
&self.language,
|
||||
claims.as_ref().map_or(&[], |claims| &claims.languages[..]),
|
||||
requested,
|
||||
);
|
||||
|
||||
Candidate {
|
||||
provider: provider_id(),
|
||||
id: CandidateId::new(format!("{}|{language}", self.id)),
|
||||
language,
|
||||
hash_match: false,
|
||||
group: claims.as_ref().and_then(|claims| claims.group.clone()),
|
||||
source: claims.and_then(|claims| claims.source).map(Into::into),
|
||||
release_name,
|
||||
rating: None,
|
||||
download_count: None,
|
||||
forced: false,
|
||||
sdh: self.flags.iter().any(|flag| flag == "hearing_impaired"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Turn a wanted [`Language`] into the string Podnapisi's `language` query
|
||||
/// parameter expects — the same BCP-47-shaped spelling [`Language::Display`]
|
||||
/// already produces for the pt-PT/pt-BR split, lower-cased where this crate
|
||||
/// has no opinion.
|
||||
fn language_param(language: &Language) -> String {
|
||||
match language {
|
||||
Language::PortuguesePortugal => "pt-PT".to_owned(),
|
||||
Language::PortugueseBrazil => "pt-BR".to_owned(),
|
||||
Language::PortugueseUnverified => "pt".to_owned(),
|
||||
Language::Other(tag) => tag.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve Podnapisi's own `language` tag on a result, using the release
|
||||
/// name's claimed language markers as the first signal — the same signal
|
||||
/// order `arr-core::lang` documents, with the container tag Podnapisi sends
|
||||
/// standing in for a BCP-47 tag.
|
||||
fn resolve_language(
|
||||
raw_tag: &str,
|
||||
name_markers: &[arr_parse::LanguageMarker],
|
||||
requested: &Language,
|
||||
) -> Language {
|
||||
let primary = raw_tag
|
||||
.split(['-', '_'])
|
||||
.next()
|
||||
.unwrap_or(raw_tag)
|
||||
.to_ascii_lowercase();
|
||||
|
||||
if primary == "pt" || primary == "por" {
|
||||
return resolve_portuguese(PortugueseEvidence {
|
||||
name_markers,
|
||||
stream_title: None,
|
||||
handler_name: None,
|
||||
container_tag: Some(raw_tag),
|
||||
});
|
||||
}
|
||||
|
||||
// Podnapisi echoes back whatever it matched the search on; when the tag
|
||||
// carries no useful primary subtag, the language we asked for is a
|
||||
// better answer than a guess.
|
||||
if primary.is_empty() {
|
||||
return requested.clone();
|
||||
}
|
||||
|
||||
Language::Other(primary)
|
||||
}
|
||||
|
||||
/// The `CandidateId` this provider issues is `{pid}|{language}`, the
|
||||
/// language spelled exactly as [`Language::Display`] renders it — so
|
||||
/// `download` recovers it without a second round trip to disambiguate
|
||||
/// pt-PT from pt-BR.
|
||||
fn split_candidate_id(id: &CandidateId) -> Result<(&str, Language)> {
|
||||
id.as_str()
|
||||
.rsplit_once('|')
|
||||
.map(|(pid, language)| (pid, parse_language_tag(language)))
|
||||
.ok_or_else(|| Error::NotFound {
|
||||
provider: provider_id(),
|
||||
candidate: id.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// The inverse of [`Language::Display`], for the tag this module itself
|
||||
/// wrote into a [`CandidateId`]. Not a general parser: it only has to
|
||||
/// round-trip the four spellings `Display` can produce.
|
||||
fn parse_language_tag(tag: &str) -> Language {
|
||||
match tag {
|
||||
"pt-PT" => Language::PortuguesePortugal,
|
||||
"pt-BR" => Language::PortugueseBrazil,
|
||||
"por-unverified" => Language::PortugueseUnverified,
|
||||
other => Language::Other(other.to_owned()),
|
||||
}
|
||||
}
|
||||
|
||||
fn retry_after(response: &reqwest::Response) -> Option<Duration> {
|
||||
response
|
||||
.headers()
|
||||
.get(reqwest::header::RETRY_AFTER)?
|
||||
.to_str()
|
||||
.ok()?
|
||||
.trim()
|
||||
.parse::<u64>()
|
||||
.ok()
|
||||
.map(Duration::from_secs)
|
||||
}
|
||||
|
||||
/// Unzip a Podnapisi download, which is always a single subtitle file.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// [`Error::Malformed`] if the body is not a zip, or does not contain
|
||||
/// exactly one file.
|
||||
fn extract_single_file(bytes: &[u8]) -> Result<(Vec<u8>, SubtitleFormat)> {
|
||||
let mut archive = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|err| Error::Malformed {
|
||||
provider: provider_id(),
|
||||
detail: format!("download was not a zip: {err}"),
|
||||
})?;
|
||||
|
||||
if archive.len() != 1 {
|
||||
return Err(Error::Malformed {
|
||||
provider: provider_id(),
|
||||
detail: format!("zip contained {} files, expected 1", archive.len()),
|
||||
});
|
||||
}
|
||||
|
||||
let mut entry = archive.by_index(0).map_err(|err| Error::Malformed {
|
||||
provider: provider_id(),
|
||||
detail: format!("could not read the zipped subtitle: {err}"),
|
||||
})?;
|
||||
|
||||
let format =
|
||||
entry
|
||||
.name()
|
||||
.rsplit_once('.')
|
||||
.map_or(SubtitleFormat::Other(String::new()), |(_, ext)| {
|
||||
match ext.to_ascii_lowercase().as_str() {
|
||||
"srt" => SubtitleFormat::Srt,
|
||||
"ass" | "ssa" => SubtitleFormat::Ass,
|
||||
"vtt" => SubtitleFormat::Vtt,
|
||||
other => SubtitleFormat::Other(other.to_owned()),
|
||||
}
|
||||
});
|
||||
|
||||
let mut content = Vec::new();
|
||||
entry
|
||||
.read_to_end(&mut content)
|
||||
.map_err(|err| Error::Malformed {
|
||||
provider: provider_id(),
|
||||
detail: format!("could not read the zipped subtitle: {err}"),
|
||||
})?;
|
||||
|
||||
Ok((content, format))
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# A global gitignore excludes *.zip, which silently dropped these fixtures on
|
||||
# their way into #188. Test fixtures are source, whatever their extension.
|
||||
!*.zip
|
||||
BIN
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": "eee555",
|
||||
"language": "en",
|
||||
"flags": [],
|
||||
"releases": ["Show.S01E02.1080p.WEB-DL-TEAM"],
|
||||
"custom_releases": []
|
||||
}
|
||||
],
|
||||
"page": 1,
|
||||
"all_pages": 1
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"oops": true}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": "aaa111",
|
||||
"language": "pt-BR",
|
||||
"flags": ["hearing_impaired"],
|
||||
"releases": ["Movie.2024.1080p.WEB-DL-GROUP"],
|
||||
"custom_releases": []
|
||||
},
|
||||
{
|
||||
"id": "bbb222",
|
||||
"language": "en",
|
||||
"flags": [],
|
||||
"releases": ["Movie.2024.1080p.WEB-DL-GROUP"],
|
||||
"custom_releases": []
|
||||
}
|
||||
],
|
||||
"page": 1,
|
||||
"all_pages": 2
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": "aaa111",
|
||||
"language": "pt-BR",
|
||||
"flags": ["hearing_impaired"],
|
||||
"releases": ["Movie.2024.1080p.WEB-DL-GROUP"],
|
||||
"custom_releases": []
|
||||
},
|
||||
{
|
||||
"id": "ccc333",
|
||||
"language": "pt-PT",
|
||||
"flags": [],
|
||||
"releases": ["Movie.2024.720p.BluRay-OTHERGRP"],
|
||||
"custom_releases": []
|
||||
}
|
||||
],
|
||||
"page": 2,
|
||||
"all_pages": 2
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
//! Podnapisi provider tests. Everything runs against `wiremock` — the live
|
||||
//! service is never touched (DESIGN.md §15).
|
||||
|
||||
// Same per-target quirk as the crate's own tests: an integration test links
|
||||
// the library's dependencies without using them all directly.
|
||||
use {
|
||||
arr_parse as _, reqwest as _, serde as _, serde_json as _, thiserror as _, tracing as _,
|
||||
zip as _,
|
||||
};
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use arr_core::Language;
|
||||
use arr_subs::{
|
||||
Candidate, Error, Fetched, MediaFile, MediaRef, PodnapisiProvider, Provider, SearchRequest,
|
||||
SubtitleFormat,
|
||||
};
|
||||
use wiremock::matchers::{method, path, query_param, query_param_is_missing};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
const SEARCH_MOVIE_PAGE1: &str = include_str!("fixtures/search_movie_page1.json");
|
||||
const SEARCH_MOVIE_PAGE2: &str = include_str!("fixtures/search_movie_page2.json");
|
||||
const SEARCH_EPISODE: &str = include_str!("fixtures/search_episode.json");
|
||||
const SEARCH_MALFORMED: &str = include_str!("fixtures/search_malformed.json");
|
||||
const DOWNLOAD_ZIP: &[u8] = include_bytes!("fixtures/download.zip");
|
||||
const DOWNLOAD_MULTI_ZIP: &[u8] = include_bytes!("fixtures/download_multi.zip");
|
||||
|
||||
fn provider(server: &MockServer) -> PodnapisiProvider {
|
||||
PodnapisiProvider::builder()
|
||||
.base_url(format!("{}/subtitles", server.uri()))
|
||||
.build()
|
||||
.expect("provider builds")
|
||||
}
|
||||
|
||||
fn movie_file(release_name: Option<&str>) -> MediaFile {
|
||||
MediaFile {
|
||||
path: "/mnt/media/film.mkv".into(),
|
||||
size: 1_234,
|
||||
release_name: release_name.map(str::to_owned),
|
||||
media: MediaRef::Movie { tmdb_id: 42 },
|
||||
}
|
||||
}
|
||||
|
||||
fn request(file: MediaFile, languages: Vec<Language>) -> SearchRequest {
|
||||
SearchRequest { file, languages }
|
||||
}
|
||||
|
||||
async fn mount_search(server: &MockServer, page: Option<&str>, body: &str) {
|
||||
let given = Mock::given(method("GET")).and(path("/subtitles/search/advanced"));
|
||||
let given = match page {
|
||||
Some(page) => given.and(query_param("page", page)),
|
||||
None => given.and(query_param_is_missing("page")),
|
||||
};
|
||||
given
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(body))
|
||||
.mount(server)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_provider_with_no_release_name_searches_nothing() {
|
||||
// No mock is mounted: a request would fail the test if one were sent.
|
||||
let server = MockServer::start().await;
|
||||
let request = request(movie_file(None), vec![Language::Other("en".to_owned())]);
|
||||
|
||||
let candidates = provider(&server)
|
||||
.search(&request)
|
||||
.await
|
||||
.expect("nothing to search on is not an error");
|
||||
|
||||
assert!(candidates.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn search_paginates_and_dedupes_across_pages() {
|
||||
let server = MockServer::start().await;
|
||||
mount_search(&server, None, SEARCH_MOVIE_PAGE1).await;
|
||||
mount_search(&server, Some("2"), SEARCH_MOVIE_PAGE2).await;
|
||||
|
||||
let request = request(
|
||||
movie_file(Some("Movie.2024.1080p.WEB-DL-GROUP")),
|
||||
vec![Language::PortugueseBrazil],
|
||||
);
|
||||
|
||||
let candidates = provider(&server)
|
||||
.search(&request)
|
||||
.await
|
||||
.expect("search succeeds");
|
||||
|
||||
// Three distinct pids across the two pages; "aaa111" repeats on both and
|
||||
// must survive only once (DESIGN.md's provider forum note on duplicates).
|
||||
assert_eq!(candidates.len(), 3);
|
||||
assert!(candidates.iter().all(|candidate| !candidate.hash_match));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_candidate_carries_the_release_name_group_and_source() {
|
||||
let server = MockServer::start().await;
|
||||
mount_search(&server, None, SEARCH_MOVIE_PAGE1).await;
|
||||
mount_search(&server, Some("2"), SEARCH_MOVIE_PAGE2).await;
|
||||
|
||||
let request = request(
|
||||
movie_file(Some("Movie.2024.1080p.WEB-DL-GROUP")),
|
||||
vec![Language::PortugueseBrazil],
|
||||
);
|
||||
|
||||
let candidates = provider(&server)
|
||||
.search(&request)
|
||||
.await
|
||||
.expect("search succeeds");
|
||||
|
||||
let hearing_impaired = candidates
|
||||
.iter()
|
||||
.find(|candidate| candidate.sdh)
|
||||
.expect("one candidate is flagged hearing_impaired");
|
||||
assert_eq!(
|
||||
hearing_impaired.release_name.as_deref(),
|
||||
Some("Movie.2024.1080p.WEB-DL-GROUP")
|
||||
);
|
||||
assert_eq!(hearing_impaired.group.as_deref(), Some("GROUP"));
|
||||
assert_eq!(hearing_impaired.language, Language::PortugueseBrazil);
|
||||
assert!(!hearing_impaired.forced);
|
||||
|
||||
let plain = candidates
|
||||
.iter()
|
||||
.find(|candidate: &&Candidate| {
|
||||
!candidate.sdh && candidate.language == Language::PortuguesePortugal
|
||||
})
|
||||
.expect("the pt-PT candidate from page two");
|
||||
assert_eq!(plain.group.as_deref(), Some("OTHERGRP"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn episode_media_sends_season_and_episode_params() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/subtitles/search/advanced"))
|
||||
.and(query_param("seasons", "1"))
|
||||
.and(query_param("episodes", "2"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(SEARCH_EPISODE))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let file = MediaFile {
|
||||
path: "/mnt/media/show/s01e02.mkv".into(),
|
||||
size: 999,
|
||||
release_name: Some("Show.S01E02.1080p.WEB-DL-TEAM".to_owned()),
|
||||
media: MediaRef::Episode {
|
||||
tmdb_id: 7,
|
||||
season: 1,
|
||||
episode: 2,
|
||||
},
|
||||
};
|
||||
let request = request(file, vec![Language::Other("en".to_owned())]);
|
||||
|
||||
let candidates = provider(&server)
|
||||
.search(&request)
|
||||
.await
|
||||
.expect("search succeeds");
|
||||
|
||||
assert_eq!(candidates.len(), 1);
|
||||
assert_eq!(candidates[0].language, Language::Other("en".to_owned()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_401_is_unauthorized() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/subtitles/search/advanced"))
|
||||
.respond_with(ResponseTemplate::new(401))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let request = request(
|
||||
movie_file(Some("Movie.2024")),
|
||||
vec![Language::Other("en".to_owned())],
|
||||
);
|
||||
|
||||
let error = provider(&server)
|
||||
.search(&request)
|
||||
.await
|
||||
.expect_err("401 is an error");
|
||||
|
||||
assert!(matches!(error, Error::Unauthorized { .. }), "got {error:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_429_surfaces_retry_after() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/subtitles/search/advanced"))
|
||||
.respond_with(ResponseTemplate::new(429).insert_header("retry-after", "13"))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let request = request(
|
||||
movie_file(Some("Movie.2024")),
|
||||
vec![Language::Other("en".to_owned())],
|
||||
);
|
||||
|
||||
let error = provider(&server)
|
||||
.search(&request)
|
||||
.await
|
||||
.expect_err("429 is an error");
|
||||
|
||||
match error {
|
||||
Error::RateLimited { retry_after, .. } => {
|
||||
assert_eq!(retry_after, Some(Duration::from_secs(13)));
|
||||
}
|
||||
other => panic!("expected RateLimited, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_response_that_does_not_parse_is_malformed() {
|
||||
let server = MockServer::start().await;
|
||||
mount_search(&server, None, SEARCH_MALFORMED).await;
|
||||
|
||||
let request = request(
|
||||
movie_file(Some("Movie.2024")),
|
||||
vec![Language::Other("en".to_owned())],
|
||||
);
|
||||
|
||||
let error = provider(&server)
|
||||
.search(&request)
|
||||
.await
|
||||
.expect_err("an unparseable body is an error");
|
||||
|
||||
assert!(matches!(error, Error::Malformed { .. }), "got {error:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn download_extracts_the_single_zipped_file() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/subtitles/aaa111/download"))
|
||||
.and(query_param("container", "zip"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_bytes(DOWNLOAD_ZIP))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let id = arr_subs::CandidateId::new("aaa111|pt-BR");
|
||||
let fetched: Fetched = provider(&server)
|
||||
.download(&id)
|
||||
.await
|
||||
.expect("download succeeds");
|
||||
|
||||
assert_eq!(fetched.id, id);
|
||||
assert_eq!(fetched.language, Language::PortugueseBrazil);
|
||||
assert_eq!(fetched.format, SubtitleFormat::Srt);
|
||||
assert!(fetched.content.starts_with(b"1\n00:00:01"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_zip_with_more_than_one_file_is_malformed() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/subtitles/bbb222/download"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_bytes(DOWNLOAD_MULTI_ZIP))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let id = arr_subs::CandidateId::new("bbb222|en");
|
||||
let error = provider(&server)
|
||||
.download(&id)
|
||||
.await
|
||||
.expect_err("more than one file in the zip is an error");
|
||||
|
||||
assert!(matches!(error, Error::Malformed { .. }), "got {error:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_id_this_provider_never_issued_is_not_found() {
|
||||
let server = MockServer::start().await;
|
||||
// No mock mounted: a malformed id must fail before any request is sent.
|
||||
let id = arr_subs::CandidateId::new("no-language-suffix");
|
||||
|
||||
let error = provider(&server)
|
||||
.download(&id)
|
||||
.await
|
||||
.expect_err("an id without a language suffix is not this provider's");
|
||||
|
||||
assert!(matches!(error, Error::NotFound { .. }), "got {error:?}");
|
||||
}
|
||||
Reference in New Issue
Block a user