refactor(subs): remove podnapisi
This commit is contained in:
Generated
-67
@@ -2,12 +2,6 @@
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "adler2"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.5"
|
||||
@@ -228,7 +222,6 @@ dependencies = [
|
||||
"tokio",
|
||||
"tracing",
|
||||
"wiremock",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -466,15 +459,6 @@ version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-queue"
|
||||
version = "0.3.13"
|
||||
@@ -640,16 +624,6 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890"
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
version = "1.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
|
||||
dependencies = [
|
||||
"miniz_oxide",
|
||||
"zlib-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "flume"
|
||||
version = "0.12.0"
|
||||
@@ -1299,16 +1273,6 @@ dependencies = [
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
|
||||
dependencies = [
|
||||
"adler2",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.2.2"
|
||||
@@ -1827,12 +1791,6 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
@@ -2392,12 +2350,6 @@ version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "typed-path"
|
||||
version = "0.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.1"
|
||||
@@ -2907,25 +2859,6 @@ dependencies = [
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "8.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
"indexmap",
|
||||
"memchr",
|
||||
"typed-path",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zlib-rs"
|
||||
version = "0.6.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
|
||||
@@ -57,7 +57,6 @@ tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
url = "2"
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
zip = { version = "8", default-features = false, features = ["deflate-flate2-zlib-rs"] }
|
||||
|
||||
# Test-only
|
||||
tempfile = "3"
|
||||
|
||||
@@ -931,7 +931,7 @@ they satisfy viewing but can never feed a translator, and arr does not OCR
|
||||
them. `arr-probe` already reports subtitle tracks with resolved languages; the
|
||||
format is the new fact it must carry.
|
||||
|
||||
**Providers.** OpenSubtitles.com and Podnapisi, behind one trait.
|
||||
**Providers.** OpenSubtitles.com, behind one trait.
|
||||
|
||||
**Ranking.** A `moviehash` match wins outright. Then an exact release-name
|
||||
match, then same release group or same source, then uploader rating and
|
||||
|
||||
@@ -275,8 +275,6 @@ async fn probe_subtitles(state: &AppState) -> SubtitleHealth {
|
||||
async fn probe_provider(state: &AppState, id: &str) -> ProviderCheck {
|
||||
let check = match state.subtitle_provider(id) {
|
||||
Some(provider) => check_from(provider.probe().await),
|
||||
// Only reachable for a provider that needs credentials: Podnapisi is
|
||||
// anonymous (#215) and is always attached, so it never lands here.
|
||||
None => Check::unconfigured(format!(
|
||||
"{id} is enabled but has no credentials — they are bootstrap config, not a setting"
|
||||
)),
|
||||
@@ -481,15 +479,13 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn an_enabled_provider_without_credentials_is_unconfigured() {
|
||||
let (_dir, state) = application().await;
|
||||
// The seed row enables opensubtitles and podnapisi; none are attached.
|
||||
// The seed row enables OpenSubtitles; none is attached.
|
||||
let body = report(state).await;
|
||||
assert_eq!(
|
||||
body["subtitles"]["providers"],
|
||||
serde_json::json!([
|
||||
{ "id": "opensubtitles", "status": "unconfigured",
|
||||
"detail": "opensubtitles is enabled but has no credentials — they are bootstrap config, not a setting" },
|
||||
{ "id": "podnapisi", "status": "unconfigured",
|
||||
"detail": "podnapisi is enabled but has no credentials — they are bootstrap config, not a setting" },
|
||||
])
|
||||
);
|
||||
assert_eq!(body["status"], "degraded");
|
||||
|
||||
@@ -400,7 +400,7 @@ mod tests {
|
||||
fn valid_input() -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"wanted_languages": ["pt-PT", "en"],
|
||||
"providers_enabled": ["opensubtitles", "podnapisi"],
|
||||
"providers_enabled": ["opensubtitles"],
|
||||
"translation_engine": null,
|
||||
"provider_daily_budgets": { "opensubtitles": 100 },
|
||||
"translator_daily_budgets": {},
|
||||
@@ -423,7 +423,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
settings["providers_enabled"],
|
||||
serde_json::json!(["opensubtitles", "podnapisi"])
|
||||
serde_json::json!(["opensubtitles"])
|
||||
);
|
||||
assert!(settings["translation_engine"].is_null());
|
||||
// `available_engines` is a fact about the build, not about the seed
|
||||
|
||||
@@ -2609,7 +2609,7 @@ mod tests {
|
||||
async fn a_grab_naming_an_unconfigured_provider_is_a_422() {
|
||||
let fixture = stub_application().await;
|
||||
let mut body = pt();
|
||||
body["provider"] = serde_json::json!("podnapisi");
|
||||
body["provider"] = serde_json::json!("unconfigured");
|
||||
let (status, _) = grab(&fixture, body).await;
|
||||
assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
@@ -330,8 +330,7 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
db.migrate().await.unwrap();
|
||||
// The seed row already enables opensubtitles and podnapisi; none are
|
||||
// attached to this action.
|
||||
// The seed row enables OpenSubtitles, which is not attached to this action.
|
||||
let prowlarr = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/ping"))
|
||||
@@ -354,7 +353,7 @@ mod tests {
|
||||
let first = action.tick(&db).await;
|
||||
let second = action.tick(&db).await;
|
||||
|
||||
assert_eq!(first.len(), 2, "one lamp per missing provider");
|
||||
assert_eq!(first.len(), 1, "one lamp per missing provider");
|
||||
assert_eq!(second.len(), 0, "does not repeat while still broken");
|
||||
|
||||
// Attaching nothing but disabling them silences the lamps.
|
||||
|
||||
@@ -36,8 +36,6 @@ pub const ENV_NTFY_OPERATOR_TOPIC: &str = "ARR_NTFY_OPERATOR_TOPIC";
|
||||
pub const ENV_OPENSUBTITLES_API_KEY: &str = "ARR_OPENSUBTITLES_API_KEY";
|
||||
pub const ENV_OPENSUBTITLES_USERNAME: &str = "ARR_OPENSUBTITLES_USERNAME";
|
||||
pub const ENV_OPENSUBTITLES_PASSWORD: &str = "ARR_OPENSUBTITLES_PASSWORD";
|
||||
// Podnapisi takes no credentials (#215): its search and download are
|
||||
// unauthenticated, so there is nothing here for an operator to set.
|
||||
// The OpenAI-compatible backend's base URL and model are `subtitle_settings`
|
||||
// rows, not bootstrap keys (#220): that backend is anything speaking the
|
||||
// shape, and which endpoint is in use is something the operator changes from
|
||||
@@ -51,11 +49,6 @@ pub const ENV_TRANSLATE_GOOGLE_BASE_URL: &str = "ARR_TRANSLATE_GOOGLE_BASE_URL";
|
||||
pub const ENV_TRANSLATE_COMMAND_TEMPLATE: &str = "ARR_TRANSLATE_COMMAND_TEMPLATE";
|
||||
pub const ENV_ALASS_PATH: &str = "ARR_ALASS_PATH";
|
||||
pub const ENV_FFMPEG_PATH: &str = "ARR_FFMPEG_PATH";
|
||||
/// E2E seam only, env-only, same shape as [`ENV_TMDB_URL`]: `None` means
|
||||
/// Podnapisi's real address. Kept out of the config file for the same
|
||||
/// reason `tmdb_url` is — DESIGN.md §10 has no business exposing a seam that
|
||||
/// only a test harness uses.
|
||||
pub const ENV_PODNAPISI_URL: &str = "ARR_PODNAPISI_URL";
|
||||
|
||||
pub const DEFAULT_BIND_ADDR: &str = "0.0.0.0:7878";
|
||||
pub const DEFAULT_DATABASE_PATH: &str = "arr.db";
|
||||
@@ -166,7 +159,6 @@ pub struct EnvOverrides {
|
||||
pub tmdb_url: Option<String>,
|
||||
pub jellyfin_url: Option<String>,
|
||||
pub jellyfin_api_key: Option<String>,
|
||||
pub podnapisi_url: Option<String>,
|
||||
pub ntfy_url: Option<String>,
|
||||
pub ntfy_operator_topic: Option<String>,
|
||||
pub opensubtitles_api_key: Option<String>,
|
||||
@@ -199,7 +191,6 @@ impl EnvOverrides {
|
||||
tmdb_url: std::env::var(ENV_TMDB_URL).ok(),
|
||||
jellyfin_url: std::env::var(ENV_JELLYFIN_URL).ok(),
|
||||
jellyfin_api_key: std::env::var(ENV_JELLYFIN_API_KEY).ok(),
|
||||
podnapisi_url: std::env::var(ENV_PODNAPISI_URL).ok(),
|
||||
ntfy_url: std::env::var(ENV_NTFY_URL).ok(),
|
||||
ntfy_operator_topic: std::env::var(ENV_NTFY_OPERATOR_TOPIC).ok(),
|
||||
opensubtitles_api_key: std::env::var(ENV_OPENSUBTITLES_API_KEY).ok(),
|
||||
@@ -238,9 +229,6 @@ pub struct Config {
|
||||
pub tmdb_url: Option<String>,
|
||||
pub jellyfin_url: String,
|
||||
pub jellyfin_api_key: Option<String>,
|
||||
/// E2E seam only, env-only. `None` means Podnapisi's built-in default
|
||||
/// address; see [`ENV_PODNAPISI_URL`].
|
||||
pub podnapisi_url: Option<String>,
|
||||
pub ntfy_url: String,
|
||||
/// The operator's ntfy topic (DESIGN.md §9.5) for *needs a decision* and
|
||||
/// *broken*. `None` means those two notifications are skipped — there is
|
||||
@@ -401,7 +389,6 @@ impl Config {
|
||||
.or(file.jellyfin_url)
|
||||
.unwrap_or_else(|| DEFAULT_JELLYFIN_URL.to_string()),
|
||||
jellyfin_api_key: env.jellyfin_api_key,
|
||||
podnapisi_url: env.podnapisi_url,
|
||||
ntfy_url: env
|
||||
.ntfy_url
|
||||
.or(file.ntfy_url)
|
||||
@@ -457,7 +444,6 @@ mod tests {
|
||||
DEFAULT_SEED_IDLE_LIMIT_MINUTES
|
||||
);
|
||||
assert_eq!(config.jellyfin_url, DEFAULT_JELLYFIN_URL);
|
||||
assert_eq!(config.podnapisi_url, None);
|
||||
assert!(config.tracker_seeding.is_empty());
|
||||
assert_eq!(config.ntfy_url, DEFAULT_NTFY_URL);
|
||||
assert_eq!(config.ntfy_operator_topic, None);
|
||||
@@ -536,9 +522,8 @@ prowlarr_url = "http://prowlarr.internal:9696"
|
||||
assert_eq!(config.jellyfin_api_key.as_deref(), Some("secret-3"));
|
||||
}
|
||||
|
||||
/// §15: `OpenSubtitles` and every translator's credentials are secrets,
|
||||
/// so this mirrors [`secrets_come_only_from_env`] for them. Podnapisi has
|
||||
/// no entry: it takes no credentials at all (#215).
|
||||
/// §15: OpenSubtitles and every translator's credentials are secrets, so
|
||||
/// this mirrors [`secrets_come_only_from_env`] for them.
|
||||
#[test]
|
||||
fn subtitle_secrets_come_only_from_env() {
|
||||
let env = EnvOverrides {
|
||||
@@ -656,37 +641,6 @@ alass_path = "/usr/local/bin/alass"
|
||||
));
|
||||
}
|
||||
|
||||
/// Same env-only seam as `tmdb_url`, for the same reason: `arr-e2e` needs
|
||||
/// to point Podnapisi at a `wiremock` fake without a live-tracker risk
|
||||
/// creeping into the config file (DESIGN.md §15, §10).
|
||||
#[test]
|
||||
fn podnapisi_url_is_an_env_only_seam() {
|
||||
let config = Config::resolve(EnvOverrides::default()).unwrap();
|
||||
assert_eq!(config.podnapisi_url, None);
|
||||
|
||||
let env = EnvOverrides {
|
||||
podnapisi_url: Some("http://127.0.0.1:9/subtitles".into()),
|
||||
..EnvOverrides::default()
|
||||
};
|
||||
let config = Config::resolve(env).unwrap();
|
||||
assert_eq!(
|
||||
config.podnapisi_url.as_deref(),
|
||||
Some("http://127.0.0.1:9/subtitles")
|
||||
);
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("arr.toml");
|
||||
std::fs::write(&path, "podnapisi_url = \"http://127.0.0.1:9/subtitles\"\n").unwrap();
|
||||
let env = EnvOverrides {
|
||||
config_file: Some(path.to_string_lossy().into_owned()),
|
||||
..EnvOverrides::default()
|
||||
};
|
||||
assert!(matches!(
|
||||
Config::resolve(env),
|
||||
Err(ConfigError::TomlDecode(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_secret_in_the_config_file_is_a_parse_error() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -135,7 +135,6 @@ fn api_state(
|
||||
config.opensubtitles_api_key.clone(),
|
||||
config.opensubtitles_username.clone(),
|
||||
config.opensubtitles_password.clone(),
|
||||
config.podnapisi_url.clone(),
|
||||
))
|
||||
.with_translation_backends(translators.backends.clone())
|
||||
.with_jellyfin(jellyfin)
|
||||
@@ -460,7 +459,6 @@ fn register_broken(
|
||||
config.opensubtitles_api_key.clone(),
|
||||
config.opensubtitles_username.clone(),
|
||||
config.opensubtitles_password.clone(),
|
||||
config.podnapisi_url.clone(),
|
||||
),
|
||||
backends: translators.backends.clone(),
|
||||
alass_path: config.alass_path.clone(),
|
||||
@@ -607,7 +605,6 @@ fn subtitle_action(
|
||||
config.opensubtitles_api_key.clone(),
|
||||
config.opensubtitles_username.clone(),
|
||||
config.opensubtitles_password.clone(),
|
||||
config.podnapisi_url.clone(),
|
||||
),
|
||||
translators.backends.clone(),
|
||||
arr_subs::Syncer::new().with_binary(config.alass_path.clone()),
|
||||
@@ -626,26 +623,14 @@ fn subtitle_action(
|
||||
/// which providers *exist* is decided here, once, at startup; which of them a
|
||||
/// search *runs* is the `providers_enabled` setting the API reads per
|
||||
/// request. OpenSubtitles.com needs a registered API key to be called at all,
|
||||
/// so without one it is not offered; Podnapisi is anonymous and always is.
|
||||
/// so without one it is not offered.
|
||||
fn subtitle_providers(
|
||||
opensubtitles_api_key: Option<String>,
|
||||
username: Option<String>,
|
||||
password: Option<String>,
|
||||
podnapisi_url: Option<String>,
|
||||
) -> Vec<std::sync::Arc<dyn arr_subs::Provider>> {
|
||||
let mut providers: Vec<std::sync::Arc<dyn arr_subs::Provider>> = Vec::new();
|
||||
|
||||
// `podnapisi_url` is the e2e-only seam (`ARR_PODNAPISI_URL`); absent, this
|
||||
// is the real Podnapisi.net.
|
||||
let mut builder = arr_subs::Podnapisi::builder();
|
||||
if let Some(url) = podnapisi_url {
|
||||
builder = builder.base_url(url);
|
||||
}
|
||||
match builder.build() {
|
||||
Ok(podnapisi) => providers.push(std::sync::Arc::new(podnapisi)),
|
||||
Err(error) => tracing::warn!(%error, "Podnapisi not available"),
|
||||
}
|
||||
|
||||
let Some(api_key) = opensubtitles_api_key else {
|
||||
tracing::info!("no OpenSubtitles.com API key configured; that provider is off");
|
||||
return providers;
|
||||
|
||||
@@ -1688,18 +1688,24 @@ mod tests {
|
||||
async fn a_cheaper_candidate_is_downloaded_when_the_ranked_winner_is_over_budget() {
|
||||
let fixture = Fixture::new(&no_tracks()).await;
|
||||
fixture.configure(r#"["pt-PT"]"#, None).await;
|
||||
sqlx::query(
|
||||
"UPDATE subtitle_settings SET providers_enabled = '[\"opensubtitles\",\"fallback\"]'",
|
||||
)
|
||||
.execute(fixture.database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
fixture.configure_provider_budget("opensubtitles", 0).await;
|
||||
let winner = Candidate {
|
||||
hash_match: true,
|
||||
..candidate("opensubtitles", "hash", Language::PortuguesePortugal)
|
||||
};
|
||||
let opensubtitles = StubProvider::new("opensubtitles", vec![winner]);
|
||||
let podnapisi = StubProvider::new(
|
||||
"podnapisi",
|
||||
vec![candidate("podnapisi", "7", Language::PortuguesePortugal)],
|
||||
let fallback = StubProvider::new(
|
||||
"fallback",
|
||||
vec![candidate("fallback", "7", Language::PortuguesePortugal)],
|
||||
);
|
||||
let action = action(
|
||||
vec![Arc::new(opensubtitles), Arc::new(podnapisi)],
|
||||
vec![Arc::new(opensubtitles), Arc::new(fallback)],
|
||||
Vec::new(),
|
||||
);
|
||||
|
||||
@@ -1707,7 +1713,7 @@ mod tests {
|
||||
|
||||
let files = fixture.files().await;
|
||||
assert_eq!(files.len(), 1);
|
||||
assert_eq!(files[0].provider.as_deref(), Some("podnapisi"));
|
||||
assert_eq!(files[0].provider.as_deref(), Some("fallback"));
|
||||
let attempt = fixture.attempt("pt-PT").await.unwrap();
|
||||
assert_eq!(attempt.state, SubtitleState::Satisfied);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ CREATE TABLE subtitle_settings (
|
||||
-- Provider ids in search order (DESIGN.md §15's ranking runs per
|
||||
-- provider before it runs per candidate). Absent from the array means
|
||||
-- disabled.
|
||||
providers_enabled TEXT NOT NULL DEFAULT '["opensubtitles","podnapisi"]'
|
||||
providers_enabled TEXT NOT NULL DEFAULT '["opensubtitles"]'
|
||||
CHECK (json_valid(providers_enabled)),
|
||||
-- The compiled-in backend in use, or NULL when translation is off. Not
|
||||
-- checked against the running binary's features here — the API does
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
-- Podnapisi.net no longer has a reachable public service. Existing settings
|
||||
-- must not keep selecting it or preserve a budget for a provider arr no
|
||||
-- longer ships.
|
||||
UPDATE subtitle_settings
|
||||
SET providers_enabled = COALESCE(
|
||||
(SELECT json_group_array(value)
|
||||
FROM json_each(providers_enabled)
|
||||
WHERE value != 'podnapisi'),
|
||||
'[]'
|
||||
),
|
||||
provider_daily_budgets = json_remove(provider_daily_budgets, '$.podnapisi'),
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now');
|
||||
@@ -213,7 +213,7 @@ mod tests {
|
||||
);
|
||||
// Same kind, other name: untouched.
|
||||
assert_eq!(
|
||||
spent_today(db.pool(), BudgetKind::Provider, "podnapisi")
|
||||
spent_today(db.pool(), BudgetKind::Provider, "another-provider")
|
||||
.await
|
||||
.unwrap(),
|
||||
0
|
||||
|
||||
@@ -970,7 +970,7 @@ mod tests {
|
||||
let file = media_file(db.pool(), "/m/d.mkv", "2026-01-01T00:00:00.000Z").await;
|
||||
let id = record_file(
|
||||
db.pool(),
|
||||
&NewSubtitleFile::fetched(file, "en", "podnapisi", "7", "/m/d.en.srt"),
|
||||
&NewSubtitleFile::fetched(file, "en", "opensubtitles", "7", "/m/d.en.srt"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -38,15 +38,6 @@ pub mod fixtures {
|
||||
pub const PROWLARR_MOVIE_SEARCH: &str = include_str!("../fixtures/prowlarr-movie-search.xml");
|
||||
pub const TMDB_CONFIGURATION: &str = include_str!("../fixtures/tmdb-configuration.json");
|
||||
pub const TMDB_MOVIE_DUNE: &str = include_str!("../fixtures/tmdb-movie-dune.json");
|
||||
/// A zipped SRT with one cue at 00:00:01 — plausibly close to a fetched
|
||||
/// subtitle's real timing for [`crate::probe_movie_fixture`]'s
|
||||
/// three-second clip.
|
||||
pub const PODNAPISI_PLAUSIBLE_ZIP: &[u8] =
|
||||
include_bytes!("../fixtures/podnapisi-plausible.zip");
|
||||
/// A zipped SRT timed five minutes in — implausible against the same
|
||||
/// three-second clip, so `alass` must reject it (DESIGN.md §15).
|
||||
pub const PODNAPISI_FARFETCHED_ZIP: &[u8] =
|
||||
include_bytes!("../fixtures/podnapisi-farfetched.zip");
|
||||
}
|
||||
|
||||
/// The clip the subtitle scenarios probe and sync against: three seconds,
|
||||
@@ -211,8 +202,8 @@ impl Daemon {
|
||||
}
|
||||
|
||||
/// Same as [`Self::spawn`], plus extra environment variables for the
|
||||
/// child — the subtitle seams (`ARR_PODNAPISI_URL`,
|
||||
/// `ARR_TRANSLATE_COMMAND_TEMPLATE`, ...) that only a test harness sets.
|
||||
/// child — configuration such as `ARR_TRANSLATE_COMMAND_TEMPLATE` that
|
||||
/// only a test harness sets.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
|
||||
@@ -1,430 +0,0 @@
|
||||
//! The subtitle path end to end (DESIGN.md §15, issue #205), cross-process
|
||||
//! like the rest of this crate: the real `arr` binary, real `ffmpeg` and real
|
||||
//! `alass`. Only the HTTP boundary — Podnapisi — and the remote-command
|
||||
//! translation backend are stubbed, the same rule §12 already applies to
|
||||
//! Prowlarr and TMDB: a live tracker or a live translation API never runs in
|
||||
//! a test.
|
||||
//!
|
||||
//! There is no HTTP route that adopts an already-imported file (that
|
||||
//! pipeline is its own seam — grab, download, import), so every scenario
|
||||
//! here seeds a `media_files` row directly against the daemon's own SQLite
|
||||
//! file, the same fixture shape the daemon's in-process subtitle tests use.
|
||||
|
||||
// Same per-target quirk as `e2e.rs`: dev-dependencies used only by this
|
||||
// integration target still link into the crate's own test build.
|
||||
use {arr_db as _, arr_dl as _, arr_indexer as _, arr_meta as _, chrono as _};
|
||||
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
use arr_e2e::{fixtures, probe_movie_fixture, Daemon};
|
||||
use wiremock::matchers::{method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
/// Neither Prowlarr, TMDB nor Transmission matter to a subtitle scenario;
|
||||
/// every test points them at an address nothing answers on rather than
|
||||
/// standing up fakes it never calls.
|
||||
const UNUSED_UPSTREAM: &str = "http://127.0.0.1:1";
|
||||
|
||||
/// Insert a movie, an imported grab and a media file directly into the
|
||||
/// daemon's database, then copy `clip` into the daemon's media root next to
|
||||
/// it. Returns the media file id and the video's path on disk.
|
||||
async fn seed_movie_file(
|
||||
database: &arr_db::Db,
|
||||
media_root: &Path,
|
||||
tmdb_id: i64,
|
||||
release_name: &str,
|
||||
probed: Option<&serde_json::Value>,
|
||||
) -> (i64, PathBuf) {
|
||||
let pool = database.pool();
|
||||
|
||||
let root_id: i64 =
|
||||
sqlx::query_scalar("SELECT id FROM roots WHERE kind = 'movie' AND audience = 'main'")
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("migrations seed the main movie root");
|
||||
|
||||
let movie_id: i64 = sqlx::query_scalar(
|
||||
"INSERT INTO movies (tmdb_id, title, root_id) VALUES (?, ?, ?) RETURNING id",
|
||||
)
|
||||
.bind(tmdb_id)
|
||||
.bind(format!("Movie {tmdb_id}"))
|
||||
.bind(root_id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("insert movie");
|
||||
|
||||
let folder = media_root.join(format!("Movie {tmdb_id} (2024) - [1080p]"));
|
||||
std::fs::create_dir_all(&folder).expect("create title folder");
|
||||
let video = folder.join(format!("Movie {tmdb_id} (2024) - [1080p].mkv"));
|
||||
std::fs::copy(probe_movie_fixture(), &video).expect("copy the real probe clip");
|
||||
let size = i64::try_from(std::fs::metadata(&video).expect("clip metadata").len())
|
||||
.expect("clip size fits in i64");
|
||||
|
||||
let media_file_id: i64 = sqlx::query_scalar(
|
||||
"INSERT INTO media_files (owner_kind, owner_id, path, size, probed)
|
||||
VALUES ('movie', ?, ?, ?, ?) RETURNING id",
|
||||
)
|
||||
.bind(movie_id)
|
||||
.bind(video.to_string_lossy().into_owned())
|
||||
.bind(size)
|
||||
.bind(probed.map(ToString::to_string))
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("insert media file");
|
||||
|
||||
// A release and an imported grab, so `Target::release_name` resolves to
|
||||
// `release_name` — Podnapisi searches nothing without one.
|
||||
let release_id: i64 = sqlx::query_scalar(
|
||||
"INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict)
|
||||
VALUES (1, ?, ?, 1000000, 'http://example.invalid/download', '{}', 'eligible')
|
||||
RETURNING id",
|
||||
)
|
||||
.bind(uuid::Uuid::new_v4().to_string())
|
||||
.bind(release_name)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("insert release");
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO grabs (release_id, target_kind, target_id, infohash, state, imported_at)
|
||||
VALUES (?, 'movie', ?, ?, 'imported', strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))",
|
||||
)
|
||||
.bind(release_id)
|
||||
.bind(movie_id)
|
||||
.bind(uuid::Uuid::new_v4().to_string())
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("insert grab");
|
||||
|
||||
(media_file_id, video)
|
||||
}
|
||||
|
||||
/// `PUT /api/settings/subtitles`, the operator surface the reconcile loop
|
||||
/// reads every tick (DESIGN.md §15, #198).
|
||||
async fn configure_subtitles(
|
||||
base: &str,
|
||||
wanted_languages: &[&str],
|
||||
providers_enabled: &[&str],
|
||||
translation_engine: Option<&str>,
|
||||
) {
|
||||
let response = reqwest::Client::new()
|
||||
.put(format!("{base}/api/settings/subtitles"))
|
||||
.json(&serde_json::json!({
|
||||
"wanted_languages": wanted_languages,
|
||||
"providers_enabled": providers_enabled,
|
||||
"translation_engine": translation_engine,
|
||||
"provider_daily_budgets": {},
|
||||
"translator_daily_budgets": {},
|
||||
"remote_command_timeout_seconds": 30,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("put subtitle settings");
|
||||
assert_eq!(
|
||||
response.status(),
|
||||
reqwest::StatusCode::OK,
|
||||
"subtitle settings accepted: {}",
|
||||
response.text().await.unwrap_or_default()
|
||||
);
|
||||
}
|
||||
|
||||
/// Poll `/api/media-files/{id}/subtitles` until a row of the given `origin`
|
||||
/// shows up — the reconcile lane closes a gap from a detached task, so its
|
||||
/// effects land some time after the tick that started it (§8, §15).
|
||||
async fn wait_for_subtitle(base: &str, media_file_id: i64, origin: &str) -> serde_json::Value {
|
||||
for _ in 0..200 {
|
||||
let subtitles: Vec<serde_json::Value> =
|
||||
reqwest::get(format!("{base}/api/media-files/{media_file_id}/subtitles"))
|
||||
.await
|
||||
.expect("list subtitles")
|
||||
.json()
|
||||
.await
|
||||
.expect("subtitles json");
|
||||
if let Some(found) = subtitles
|
||||
.iter()
|
||||
.find(|subtitle| subtitle["origin"] == origin)
|
||||
{
|
||||
return found.clone();
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
}
|
||||
panic!("no {origin} subtitle appeared for media file {media_file_id} in time");
|
||||
}
|
||||
|
||||
/// A remote-command translation backend stub (DESIGN.md §15, issue #193):
|
||||
/// discards whatever batch it is fed and always answers with the same
|
||||
/// translated cue. Every scenario here feeds it exactly one embedded English
|
||||
/// cue ("hello", numbered 1, from the committed probe fixture), so a fixed
|
||||
/// reply exercises the real IPC without needing a JSON parser in `sh`.
|
||||
fn write_stub_translator(dir: &Path) -> PathBuf {
|
||||
let script = dir.join("stub-translate.sh");
|
||||
std::fs::write(
|
||||
&script,
|
||||
"#!/bin/sh\ncat >/dev/null\nprintf '[{\"number\":1,\"text\":\"Ola\"}]'\n",
|
||||
)
|
||||
.expect("write stub translator");
|
||||
let mut permissions = std::fs::metadata(&script)
|
||||
.expect("stub translator metadata")
|
||||
.permissions();
|
||||
permissions.set_mode(0o755);
|
||||
std::fs::set_permissions(&script, permissions).expect("make stub translator executable");
|
||||
script
|
||||
}
|
||||
|
||||
async fn connect(daemon: &Daemon) -> arr_db::Db {
|
||||
arr_db::Db::connect(daemon.database_path())
|
||||
.await
|
||||
.expect("connect to the daemon's own database")
|
||||
}
|
||||
|
||||
/// Where DESIGN.md §15 puts a sidecar for `video`, e.g. `<stem>.pt-PT.srt`.
|
||||
fn sidecar_path(video: &Path, suffix: &str) -> PathBuf {
|
||||
let stem = video
|
||||
.file_stem()
|
||||
.and_then(|stem| stem.to_str())
|
||||
.expect("the seeded video path has a UTF-8 stem");
|
||||
video.with_file_name(format!("{stem}.{suffix}"))
|
||||
}
|
||||
|
||||
/// A media file with no subtitles gets one fetched from a provider, synced
|
||||
/// by real `alass`, and written under the §15 sidecar name — and once
|
||||
/// satisfied, the loop never asks the provider again.
|
||||
#[tokio::test]
|
||||
async fn a_missing_subtitle_is_fetched_synced_and_named() {
|
||||
let podnapisi = MockServer::start().await;
|
||||
let pid = "movie-one";
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/subtitles/search/advanced"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200).set_body_string(
|
||||
serde_json::json!({
|
||||
"data": [{
|
||||
"id": pid,
|
||||
"language": "pt-PT",
|
||||
"flags": [],
|
||||
"releases": ["Movie.One.2024.1080p.WEB-DL-GROUP"],
|
||||
"custom_releases": []
|
||||
}],
|
||||
"page": 1,
|
||||
"all_pages": 1
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
)
|
||||
.mount(&podnapisi)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path(format!("/subtitles/{pid}/download")))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_raw(fixtures::PODNAPISI_PLAUSIBLE_ZIP, "application/zip"),
|
||||
)
|
||||
.mount(&podnapisi)
|
||||
.await;
|
||||
let podnapisi_url = format!("{}/subtitles", podnapisi.uri());
|
||||
|
||||
let daemon = Daemon::spawn_with_env(
|
||||
UNUSED_UPSTREAM,
|
||||
UNUSED_UPSTREAM,
|
||||
UNUSED_UPSTREAM,
|
||||
&[("ARR_PODNAPISI_URL", &podnapisi_url)],
|
||||
)
|
||||
.await;
|
||||
let database = connect(&daemon).await;
|
||||
let (media_file_id, video) = seed_movie_file(
|
||||
&database,
|
||||
daemon.media_root(),
|
||||
900_001,
|
||||
"Movie.One.2024.1080p.WEB-DL-GROUP",
|
||||
Some(&serde_json::json!({ "sub_tracks": [] })),
|
||||
)
|
||||
.await;
|
||||
configure_subtitles(daemon.base_url(), &["pt-PT"], &["podnapisi"], None).await;
|
||||
|
||||
let subtitle = wait_for_subtitle(daemon.base_url(), media_file_id, "provider").await;
|
||||
assert_eq!(subtitle["language"], "pt-PT");
|
||||
assert_eq!(subtitle["provider"], "podnapisi");
|
||||
assert_eq!(
|
||||
subtitle["sync"], "synced",
|
||||
"real alass produced a plausible shift"
|
||||
);
|
||||
|
||||
let expected_path = sidecar_path(&video, "pt-PT.srt");
|
||||
assert_eq!(
|
||||
subtitle["path"].as_str(),
|
||||
Some(expected_path.to_string_lossy()).as_deref()
|
||||
);
|
||||
assert!(
|
||||
expected_path.exists(),
|
||||
"sidecar written at the §15 name: {}",
|
||||
expected_path.display()
|
||||
);
|
||||
|
||||
// §15: once satisfied, the loop never searches this language again.
|
||||
// Wait past one more 30-second reconcile tick and confirm nothing moved.
|
||||
tokio::time::sleep(Duration::from_secs(35)).await;
|
||||
let requests = podnapisi
|
||||
.received_requests()
|
||||
.await
|
||||
.expect("request recording is on by default");
|
||||
assert_eq!(
|
||||
requests.len(),
|
||||
3,
|
||||
"a pt-PT want searches both pt-PT and pt-BR (§15's substitution), plus one \
|
||||
download, and never again once satisfied: {requests:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A file whose only English text lives in an embedded track gets that track
|
||||
/// extracted by real `ffmpeg` and translated into a `.mt` sidecar — the
|
||||
/// improvement over Bazarr §15 calls out explicitly.
|
||||
#[tokio::test]
|
||||
async fn an_embedded_track_is_extracted_and_translated() {
|
||||
let scripts = tempfile::tempdir().expect("tempdir for the stub translator");
|
||||
let template = write_stub_translator(scripts.path());
|
||||
|
||||
let daemon = Daemon::spawn_with_env(
|
||||
UNUSED_UPSTREAM,
|
||||
UNUSED_UPSTREAM,
|
||||
UNUSED_UPSTREAM,
|
||||
&[(
|
||||
"ARR_TRANSLATE_COMMAND_TEMPLATE",
|
||||
template.to_str().expect("utf8 path"),
|
||||
)],
|
||||
)
|
||||
.await;
|
||||
let database = connect(&daemon).await;
|
||||
let (media_file_id, video) = seed_movie_file(
|
||||
&database,
|
||||
daemon.media_root(),
|
||||
900_002,
|
||||
"Movie.Two.2024.1080p.WEB-DL-GROUP",
|
||||
Some(&serde_json::json!({ "sub_tracks": [
|
||||
{ "language": "en", "codec": "subrip", "forced": false, "sdh": false }
|
||||
] })),
|
||||
)
|
||||
.await;
|
||||
// No provider enabled: §15 translates immediately once nothing else has
|
||||
// the language, no waiting window.
|
||||
configure_subtitles(daemon.base_url(), &["pt-PT"], &[], Some("command")).await;
|
||||
|
||||
let translated = wait_for_subtitle(daemon.base_url(), media_file_id, "translated").await;
|
||||
assert_eq!(translated["language"], "pt-PT");
|
||||
assert_eq!(translated["engine"], "command");
|
||||
|
||||
let subtitles: Vec<serde_json::Value> = reqwest::get(format!(
|
||||
"{}/api/media-files/{media_file_id}/subtitles",
|
||||
daemon.base_url()
|
||||
))
|
||||
.await
|
||||
.expect("list subtitles")
|
||||
.json()
|
||||
.await
|
||||
.expect("subtitles json");
|
||||
let extracted = subtitles
|
||||
.iter()
|
||||
.find(|subtitle| subtitle["origin"] == "extracted")
|
||||
.expect("the embedded English track was extracted as the translation source");
|
||||
assert_eq!(extracted["language"], "en");
|
||||
|
||||
let sidecar = sidecar_path(&video, "pt-PT.mt.srt");
|
||||
let content = tokio::fs::read_to_string(&sidecar)
|
||||
.await
|
||||
.expect("read the translated sidecar");
|
||||
assert!(
|
||||
content.contains("Ola"),
|
||||
"the stub's translation reached the sidecar: {content}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A subtitle whose sync is implausible — real `alass` correlating a
|
||||
/// five-minutes-off cue against a three-second clip — keeps the unsynced
|
||||
/// original and is flagged, rather than silently writing a bad shift.
|
||||
#[tokio::test]
|
||||
async fn an_implausible_sync_keeps_the_original_and_is_flagged() {
|
||||
let podnapisi = MockServer::start().await;
|
||||
let pid = "movie-three";
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/subtitles/search/advanced"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200).set_body_string(
|
||||
serde_json::json!({
|
||||
"data": [{
|
||||
"id": pid,
|
||||
"language": "pt-PT",
|
||||
"flags": [],
|
||||
"releases": ["Movie.Three.2024.1080p.WEB-DL-GROUP"],
|
||||
"custom_releases": []
|
||||
}],
|
||||
"page": 1,
|
||||
"all_pages": 1
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
)
|
||||
.mount(&podnapisi)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path(format!("/subtitles/{pid}/download")))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_raw(fixtures::PODNAPISI_FARFETCHED_ZIP, "application/zip"),
|
||||
)
|
||||
.mount(&podnapisi)
|
||||
.await;
|
||||
let podnapisi_url = format!("{}/subtitles", podnapisi.uri());
|
||||
|
||||
let daemon = Daemon::spawn_with_env(
|
||||
UNUSED_UPSTREAM,
|
||||
UNUSED_UPSTREAM,
|
||||
UNUSED_UPSTREAM,
|
||||
&[("ARR_PODNAPISI_URL", &podnapisi_url)],
|
||||
)
|
||||
.await;
|
||||
let database = connect(&daemon).await;
|
||||
let (media_file_id, video) = seed_movie_file(
|
||||
&database,
|
||||
daemon.media_root(),
|
||||
900_003,
|
||||
"Movie.Three.2024.1080p.WEB-DL-GROUP",
|
||||
Some(&serde_json::json!({ "sub_tracks": [] })),
|
||||
)
|
||||
.await;
|
||||
configure_subtitles(daemon.base_url(), &["pt-PT"], &["podnapisi"], None).await;
|
||||
|
||||
let subtitle = wait_for_subtitle(daemon.base_url(), media_file_id, "provider").await;
|
||||
assert_eq!(subtitle["sync"], "rejected");
|
||||
|
||||
let sidecar = sidecar_path(&video, "pt-PT.srt");
|
||||
let content = tokio::fs::read_to_string(&sidecar)
|
||||
.await
|
||||
.expect("read the unsynced sidecar");
|
||||
assert!(
|
||||
content.contains("completely unrelated dialogue") && content.contains("00:05:00"),
|
||||
"the unsynced original is kept verbatim, timings untouched: {content}"
|
||||
);
|
||||
|
||||
let queue: serde_json::Value =
|
||||
reqwest::get(format!("{}/api/queues/subtitles", daemon.base_url()))
|
||||
.await
|
||||
.expect("get subtitle queue")
|
||||
.json()
|
||||
.await
|
||||
.expect("queue json");
|
||||
let movie_gaps = queue["movies"]
|
||||
.as_array()
|
||||
.expect("movies array")
|
||||
.iter()
|
||||
.find(|movie| movie["media_file_id"] == media_file_id)
|
||||
.expect("the rejected sync surfaces in the missing-subtitles queue (#202)");
|
||||
assert!(
|
||||
movie_gaps["gaps"]
|
||||
.as_array()
|
||||
.expect("gaps array")
|
||||
.iter()
|
||||
.any(|gap| gap["reason"] == "sync_rejected"),
|
||||
"{movie_gaps}"
|
||||
);
|
||||
}
|
||||
@@ -29,7 +29,6 @@ tempfile.workspace = true
|
||||
thiserror.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
zip.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
|
||||
|
||||
@@ -38,7 +38,6 @@ pub mod openai;
|
||||
// `subtitle_settings` columns exist either way (#220).
|
||||
pub mod openai_endpoint;
|
||||
pub mod opensubtitles;
|
||||
pub mod podnapisi;
|
||||
pub mod srt;
|
||||
pub mod sync;
|
||||
pub mod translate;
|
||||
@@ -63,7 +62,6 @@ pub use openai_endpoint::{
|
||||
DEFAULT_MODEL as OPENAI_DEFAULT_MODEL,
|
||||
};
|
||||
pub use opensubtitles::{moviehash, OpenSubtitles, OpenSubtitlesConfig};
|
||||
pub use podnapisi::{Podnapisi, PodnapisiBuilder, DEFAULT_BASE_URL as PODNAPISI_DEFAULT_BASE_URL};
|
||||
pub use srt::Cue;
|
||||
pub use sync::{
|
||||
binary_present, Outcome, Rejection, Settled, SyncState, Syncer,
|
||||
@@ -113,7 +111,7 @@ pub fn compiled_engines() -> Vec<&'static str> {
|
||||
engines
|
||||
}
|
||||
|
||||
/// One subtitle source: OpenSubtitles.com, Podnapisi (DESIGN.md §15).
|
||||
/// One subtitle source: OpenSubtitles.com (DESIGN.md §15).
|
||||
///
|
||||
/// Boxed futures rather than `async fn`, so that the trait stays object safe
|
||||
/// and the reconcile loop can hold the enabled providers as
|
||||
|
||||
@@ -1,496 +0,0 @@
|
||||
//! 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 Podnapisi {
|
||||
http: reqwest::Client,
|
||||
base_url: Url,
|
||||
}
|
||||
|
||||
impl Podnapisi {
|
||||
/// 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() -> PodnapisiBuilder {
|
||||
PodnapisiBuilder {
|
||||
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 Podnapisi {
|
||||
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 })
|
||||
}
|
||||
|
||||
/// Anonymous, so the lamp is reachability alone (#200): the site root
|
||||
/// answers, no quota spent.
|
||||
fn probe(&self) -> crate::ProbeFuture<'_> {
|
||||
Box::pin(async move { self.send(self.base_url.clone(), None).await.map(|_| ()) })
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration for a [`Podnapisi`].
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PodnapisiBuilder {
|
||||
base_url: String,
|
||||
timeout: Duration,
|
||||
}
|
||||
|
||||
impl PodnapisiBuilder {
|
||||
/// 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<Podnapisi> {
|
||||
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(Podnapisi { 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))
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
// the library's dependencies without using them all directly.
|
||||
use {
|
||||
arr_parse as _, chardetng as _, encoding_rs as _, reqwest as _, serde as _, serde_json as _,
|
||||
thiserror as _, tracing as _, wiremock as _, zip as _,
|
||||
thiserror as _, tracing as _, wiremock as _,
|
||||
};
|
||||
|
||||
use std::{
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
// the library's dependencies without using them all directly.
|
||||
use {
|
||||
arr_parse as _, chardetng as _, encoding_rs as _, reqwest as _, serde as _, serde_json as _,
|
||||
tempfile as _, thiserror as _, tracing as _, zip as _,
|
||||
tempfile as _, thiserror as _, tracing as _,
|
||||
};
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
// dependencies without using them directly.
|
||||
use {
|
||||
arr_parse as _, chardetng as _, encoding_rs as _, reqwest as _, serde as _, thiserror as _,
|
||||
tracing as _, zip as _,
|
||||
tracing as _,
|
||||
};
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -1,307 +0,0 @@
|
||||
//! 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 _, chardetng as _, encoding_rs as _, reqwest as _, serde as _, serde_json as _,
|
||||
tempfile as _, thiserror as _, tracing as _, zip as _,
|
||||
};
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use arr_core::Language;
|
||||
use arr_subs::{
|
||||
Candidate, Error, Fetched, MediaFile, MediaRef, Podnapisi, 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) -> Podnapisi {
|
||||
Podnapisi::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:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_probe_is_reachability_alone() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/subtitles/"))
|
||||
.respond_with(ResponseTemplate::new(200))
|
||||
.mount(&server)
|
||||
.await;
|
||||
provider(&server)
|
||||
.probe()
|
||||
.await
|
||||
.expect("the anonymous provider is up when the site answers");
|
||||
|
||||
server.reset().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/subtitles/"))
|
||||
.respond_with(ResponseTemplate::new(500))
|
||||
.mount(&server)
|
||||
.await;
|
||||
let error = provider(&server).probe().await.expect_err("site down");
|
||||
assert!(matches!(error, Error::Malformed { .. }), "got {error:?}");
|
||||
}
|
||||
+2
-2
@@ -37,8 +37,8 @@ export interface RootInput {
|
||||
|
||||
const RESOLUTIONS = ["2160p", "1080p", "720p"] as const;
|
||||
const SOURCES = ["Remux", "BluRay", "WEB-DL", "WEBRip", "HDTV"] as const;
|
||||
/** DESIGN.md §15: OpenSubtitles and Podnapisi, no cargo feature gate. */
|
||||
const PROVIDERS = ["opensubtitles", "podnapisi"] as const;
|
||||
/** DESIGN.md §15: OpenSubtitles is the available subtitle provider. */
|
||||
const PROVIDERS = ["opensubtitles"] as const;
|
||||
|
||||
/** The runtime-editable half of subtitle config (§15) — issue #198's shape. */
|
||||
export interface SubtitleSettings {
|
||||
|
||||
Reference in New Issue
Block a user