initial commit

This commit is contained in:
Worph
2026-09-12 21:31:35 +02:00
parent d6086a365b
commit 4cc2a4f415
22 changed files with 4528 additions and 1 deletions
+6
View File
@@ -0,0 +1,6 @@
# FIX CRLF always when developer has not set
# Linux/Mac: git config --global core.autocrlf input
# Windows: git config --global core.autocrlf true
# Auto detect text files and perform LF normalization
* text=auto
* eol=lf
+59
View File
@@ -0,0 +1,59 @@
name: Publish image
# Build and push the feeder image to the GitHub Container Registry (ghcr.io):
# * every push to main -> the moving `main` tag
# * every `v*` tag -> semver tags (e.g. v1.0.0 -> 1.0.0, 1.0, 1)
# The build context is the repo root (the workspace); meta-feeder-sdk is a git
# dependency pinned by tag. The Dockerfile lives under
# feeder-plugin/opensubtitles-feeder/.
on:
push:
branches: [main]
tags: ['v*']
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract image metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=sha,format=short
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: ./feeder-plugin/opensubtitles-feeder/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
+4
View File
@@ -0,0 +1,4 @@
/target
**/target
Cargo.lock.bak
*.redb
Generated
+1945
View File
File diff suppressed because it is too large Load Diff
+9
View File
@@ -0,0 +1,9 @@
[workspace]
resolver = "2"
members = ["feeder-plugin/*"]
[workspace.package]
edition = "2021"
rust-version = "1.85"
license = "MIT"
repository = "https://github.com/worph/meta-feeder-opensubtitles"
+72 -1
View File
@@ -1 +1,72 @@
# meta-feeder-opensubtitles
# meta-feeder-opensubtitles
Gateway feeder that answers **subtitle searches** from
[OpenSubtitles](https://www.opensubtitles.com). It is a *feeder* (it answers
gateway queries), not a meta-sort *plugin*. The enrichment plugin that attaches
subtitles to local files is the separate `metamesh-plugin-opensubtitle`. See
`docs/project-architecture/plugins-vs-feeders.md` in the meta-root.
**Search only.** Nothing is downloaded, so the account's download quota is never
spent. Each result is one subtitle **file** addressed by a `provider-file`
locator (`0x100A`, `docs/cid-formats.md` §8) that embeds
`("opensubtitles", "file:<file_id>")`. Retrieving the bytes is meta-share's job.
## Queries
Only queries that explicitly ask for `fileType:subtitle` are answered.
| Shape | Example | Upstream call |
|---|---|---|
| keyword | `matrix fileType:subtitle` | `query=matrix` |
| movie anchor | `imdbid:tt0133093 fileType:subtitle` | `imdb_id=133093` |
| episode anchor | `tmdbid:1399 season:1 episode:1 fileType:subtitle` | `parent_tmdb_id=1399&season_number=1&episode_number=1` |
| file hash | `moviehash:8e245d9679d31e12 fileType:subtitle` | `moviehash=…` |
- **Language narrowing:** any shape can add `languages:fre` (ISO 639-2/B; `fra` and `fr` are accepted too).
- **Unanswerable queries** never reach OpenSubtitles: a query with nothing to search on, one with a content kind or domain, or one naming only languages OpenSubtitles doesn't have.
## Records
One record per subtitle file:
- **Type and address:** `fileType=subtitle`, and a `cids/<provider-file cid>` member. The gateway only persists records that carry a cid.
- **Identity:** `opensubtitlesid`, `source/gateway:opensubtitles`.
- **Descriptive:** `title`, `fileName`, `extension` (only when the file name has a real subtitle suffix).
- **Language:** `subtitleLanguage` and `languages/<lang3>`, using ISO 639-2/B codes.
- **Work ids:** `imdbid`, `tmdbid`, `season`, `episode`, `movieYear`. Episodes carry the **show's** ids.
- **Ranking signals:** `downloadCount`, `moviehashMatch`, `hearingImpaired`, `machineTranslated`.
There is no `contentKind` and no `domain`, because a subtitle is a sidecar. Results come back hash matches first, then by download count.
## Configuration
| Setting | Config page key | Env seed |
|---|---|---|
| API key (required) | `apiKey` | `OPENSUBTITLES_API_KEY` |
| User-Agent (`App vX.Y`) | `userAgent` | `OPENSUBTITLES_USER_AGENT` |
The config page (`/config`) wins over env. Changes apply on restart. Without a key
the feeder stays up, reports degraded on `/health`, and answers nothing.
Also `META_FEEDER_HTTP_LISTEN` (default `0.0.0.0:8080`), `META_FEEDER_STATE_DIR`
(default `/data/meta-feeder`), and `RUST_LOG`.
Rate limiting: 4 req/s with a burst of 4. OpenSubtitles serves
`x-ratelimit-limit-second: 5`. Searches are cached for 24 h.
## Build and test
No local cargo is needed. Run from the repo root with a named target volume (a
bind-mounted target is too slow on the WSL mount):
```bash
docker run --rm -e RUSTUP_TOOLCHAIN=1.89.0 \
-v "$PWD":/build -v osfeeder-target:/build/target \
-v feeder-cargo-registry:/usr/local/cargo/registry -v osfeeder-cargo-git:/usr/local/cargo/git \
-w /build rust:1.89-slim-bookworm sh -c \
'apt-get update -qq && apt-get install -y -qq --no-install-recommends pkg-config libssl-dev ca-certificates git >/dev/null; cargo test'
docker build -f feeder-plugin/opensubtitles-feeder/Dockerfile -t meta-feeder-opensubtitles:dev .
```
`tests/contract.rs` drives the real SDK router against a wiremock OpenSubtitles,
using fixtures captured from the live API.
@@ -0,0 +1,39 @@
[package]
name = "opensubtitles-feeder"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish = false
# OpenSubtitles subtitle search. Answers `fileType:subtitle` queries — keyword
# and id-anchored — with `provider-file` (0x100A) locator records. Search only:
# no download, no quota spend. Retrieval is meta-share's job.
[lib]
name = "opensubtitles_feeder"
path = "src/lib.rs"
[[bin]]
name = "opensubtitles-feeder"
path = "src/main.rs"
[dependencies]
# Pinned by TAG, never by path. A path dep resolves OUTSIDE this repo, and this
# repo is the Docker build context — see meta-feeder-anilist's manifest.
meta-feeder-sdk = { git = "https://github.com/worph/meta-feeder-sdk", tag = "v1.2.1" }
tokio = { version = "1.40", features = ["full"] }
axum = { version = "0.7", features = ["json"] }
anyhow = "1"
async-trait = "0.1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
[dev-dependencies]
wiremock = "0.6"
tempfile = "3"
tokio = { version = "1.40", features = ["full", "test-util"] }
@@ -0,0 +1,44 @@
# syntax=docker/dockerfile:1
# opensubtitles-feeder sidecar. Multi-stage build; build context is this repo's root.
# meta-feeder-sdk arrives as a git dependency, so no vendored crate is copied
# in — `git` must be present in the builder for cargo to fetch it.
FROM rust:1.89-slim-bookworm AS builder
WORKDIR /build
RUN apt-get update && apt-get install -y --no-install-recommends \
pkg-config libssl-dev ca-certificates git \
&& rm -rf /var/lib/apt/lists/*
COPY Cargo.toml rust-toolchain.toml ./
COPY feeder-plugin/ feeder-plugin/
RUN --mount=type=cache,id=cargo-registry,target=/usr/local/cargo/registry \
--mount=type=cache,id=cargo-git,target=/usr/local/cargo/git \
--mount=type=cache,id=opensubtitles-feeder-target,target=/build/target \
cargo build --release --bin opensubtitles-feeder \
&& cp target/release/opensubtitles-feeder /opensubtitles-feeder
# -----------------------------------------------------------------------------
FROM debian:bookworm-slim AS runtime
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates curl \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /opensubtitles-feeder /usr/local/bin/opensubtitles-feeder
RUN useradd --system --uid 10001 --no-create-home metafeeder \
&& mkdir -p /data/meta-feeder \
&& chown metafeeder:metafeeder /data/meta-feeder
USER metafeeder
WORKDIR /data/meta-feeder
EXPOSE 8080
ENV META_FEEDER_HTTP_LISTEN=0.0.0.0:8080 \
META_FEEDER_STATE_DIR=/data/meta-feeder \
RUST_LOG=info
ENTRYPOINT ["/usr/local/bin/opensubtitles-feeder"]
@@ -0,0 +1,390 @@
//! OpenSubtitles REST v1 search client — `GET /subtitles` only.
//!
//! Search is free: OpenSubtitles meters `/download`, which this feeder never
//! calls. What *is* limited is the request rate (5/s, measured), hence the
//! shared [`RateBudget`].
//!
//! ⚠ **OpenSubtitles 301s any query string that is not in its canonical form**
//! (keys sorted, values lowercased). Redirects are disabled on this client, so
//! a drift in [`SearchParams::to_pairs`] surfaces as a loud `Permanent` error
//! instead of a silent extra round-trip per search.
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use meta_feeder_sdk::budget::{Lease, RateBudget};
use meta_feeder_sdk::cache::{search_key, MidhashCache};
use meta_feeder_sdk::common::{build_http_client, map_status};
use meta_feeder_sdk::types::GatewayError;
use serde::{Deserialize, Deserializer};
use tracing::debug;
use crate::consts::{HTTP_TIMEOUT_SECS, SEARCH_CACHE_TTL_SECS, SEARCH_DEADLINE_SECS};
/// One search, already resolved from a gateway query.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SearchParams {
/// Free text (`query=`).
pub query: Option<String>,
/// IMDb id, numeric (the `tt` prefix stripped).
pub imdb_id: Option<u64>,
pub tmdb_id: Option<u64>,
pub season: Option<u64>,
pub episode: Option<u64>,
pub year: Option<u64>,
/// 16-hex OpenSubtitles moviehash, lowercase.
pub moviehash: Option<String>,
/// OpenSubtitles language codes (`fr`, `pt-br`).
pub languages: Vec<&'static str>,
}
impl SearchParams {
/// A season or episode makes the ids **show** ids, sent as `parent_*`.
/// (A live probe returns identical results for `tmdb_id` and
/// `parent_tmdb_id` on a show, but `parent_*` is the documented form.)
pub fn is_episode(&self) -> bool {
self.season.is_some() || self.episode.is_some()
}
/// Nothing to search on. Never send this: OpenSubtitles would answer with
/// an arbitrary listing.
pub fn is_unbounded(&self) -> bool {
self.query.is_none()
&& self.imdb_id.is_none()
&& self.tmdb_id.is_none()
&& self.moviehash.is_none()
}
/// Query parameters in OpenSubtitles' canonical form: keys sorted, values
/// lowercased, language list sorted and de-duplicated.
pub fn to_pairs(&self) -> Vec<(&'static str, String)> {
let episode = self.is_episode();
let mut pairs: Vec<(&'static str, String)> = Vec::new();
if let Some(e) = self.episode {
pairs.push(("episode_number", e.to_string()));
}
if let Some(id) = self.imdb_id {
pairs.push((if episode { "parent_imdb_id" } else { "imdb_id" }, id.to_string()));
}
if !self.languages.is_empty() {
let mut langs = self.languages.clone();
langs.sort_unstable();
langs.dedup();
pairs.push(("languages", langs.join(",")));
}
if let Some(h) = &self.moviehash {
pairs.push(("moviehash", h.to_ascii_lowercase()));
}
if let Some(id) = self.tmdb_id {
pairs.push((if episode { "parent_tmdb_id" } else { "tmdb_id" }, id.to_string()));
}
if let Some(q) = &self.query {
pairs.push(("query", q.to_lowercase()));
}
if let Some(s) = self.season {
pairs.push(("season_number", s.to_string()));
}
if let Some(y) = self.year {
pairs.push(("year", y.to_string()));
}
pairs.sort_by(|a, b| a.0.cmp(b.0));
pairs
}
}
/// `k=v&…` with values percent-encoded, except `,` (the language list
/// separator) and space as `+`. Built by hand rather than with
/// `RequestBuilder::query` so the exact bytes OpenSubtitles canonicalises
/// against are under test.
pub fn encode_query(pairs: &[(&'static str, String)]) -> String {
pairs
.iter()
.map(|(k, v)| format!("{k}={}", encode_value(v)))
.collect::<Vec<_>>()
.join("&")
}
fn encode_value(v: &str) -> String {
let mut out = String::with_capacity(v.len());
for b in v.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b',' => {
out.push(b as char)
}
b' ' => out.push('+'),
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
// --- response shape ---------------------------------------------------------
//
// Every field is optional and numbers are parsed leniently: one odd value in
// one row must not fail the whole page.
#[derive(Debug, Clone, Default, Deserialize)]
pub struct SearchResponse {
#[serde(default)]
pub data: Vec<OsSubtitle>,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct OsSubtitle {
#[serde(default)]
pub attributes: OsAttributes,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct OsAttributes {
pub language: Option<String>,
pub release: Option<String>,
#[serde(deserialize_with = "lenient_u64")]
pub download_count: Option<u64>,
/// Present only on a `moviehash` search.
pub moviehash_match: Option<bool>,
pub hearing_impaired: Option<bool>,
pub ai_translated: Option<bool>,
pub machine_translated: Option<bool>,
#[serde(deserialize_with = "lenient_u64")]
pub nb_cd: Option<u64>,
pub feature_details: Option<FeatureDetails>,
pub files: Vec<OsFile>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct FeatureDetails {
/// `Movie` | `Episode` | `Tvshow`.
pub feature_type: Option<String>,
#[serde(deserialize_with = "lenient_u64")]
pub year: Option<u64>,
pub title: Option<String>,
/// Display name, e.g. `Game of Thrones - S01E01 Winter Is Coming`.
pub movie_name: Option<String>,
#[serde(deserialize_with = "lenient_u64")]
pub imdb_id: Option<u64>,
#[serde(deserialize_with = "lenient_u64")]
pub tmdb_id: Option<u64>,
#[serde(deserialize_with = "lenient_u64")]
pub season_number: Option<u64>,
#[serde(deserialize_with = "lenient_u64")]
pub episode_number: Option<u64>,
#[serde(deserialize_with = "lenient_u64")]
pub parent_imdb_id: Option<u64>,
#[serde(deserialize_with = "lenient_u64")]
pub parent_tmdb_id: Option<u64>,
pub parent_title: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct OsFile {
#[serde(deserialize_with = "lenient_u64")]
pub file_id: Option<u64>,
#[serde(deserialize_with = "lenient_u64")]
pub cd_number: Option<u64>,
pub file_name: Option<String>,
}
fn lenient_u64<'de, D: Deserializer<'de>>(d: D) -> Result<Option<u64>, D::Error> {
let v = Option::<serde_json::Value>::deserialize(d)?;
Ok(match v {
Some(serde_json::Value::Number(n)) => n.as_u64().or_else(|| {
n.as_f64()
.filter(|f| *f >= 0.0 && f.fract() == 0.0)
.map(|f| f as u64)
}),
Some(serde_json::Value::String(s)) => s.trim().parse().ok(),
_ => None,
})
}
// --- cache ------------------------------------------------------------------
fn cache_key(pairs: &[(&'static str, String)]) -> String {
search_key("os-subtitles", &encode_query(pairs))
}
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn cache_entry(body: &str, fetched_at: u64) -> String {
serde_json::json!({ "fetchedAt": fetched_at, "body": body }).to_string()
}
/// The cached body if the entry is younger than `ttl_secs`.
fn fresh_body(entry: &str, now: u64, ttl_secs: u64) -> Option<String> {
let v: serde_json::Value = serde_json::from_str(entry).ok()?;
let fetched_at = v.get("fetchedAt")?.as_u64()?;
if now.saturating_sub(fetched_at) > ttl_secs {
return None;
}
v.get("body")?.as_str().map(str::to_string)
}
// --- client -----------------------------------------------------------------
pub struct OsClient {
http: reqwest::Client,
api_base: String,
api_key: String,
budget: Arc<RateBudget>,
cache: Option<MidhashCache>,
}
impl OsClient {
pub fn new(
api_base: String,
api_key: String,
user_agent: &str,
budget: Arc<RateBudget>,
cache: Option<MidhashCache>,
) -> Self {
let http = build_http_client(
HTTP_TIMEOUT_SECS,
user_agent,
Some(reqwest::redirect::Policy::none()),
);
Self {
http,
api_base: api_base.trim_end_matches('/').to_string(),
api_key,
budget,
cache,
}
}
pub async fn search(&self, params: &SearchParams) -> Result<Vec<OsSubtitle>, GatewayError> {
let pairs = params.to_pairs();
let key = cache_key(&pairs);
if let Some(cache) = &self.cache {
if let Ok(Some(entry)) = cache.get_misc(&key) {
if let Some(body) = fresh_body(&entry, now_secs(), SEARCH_CACHE_TTL_SECS) {
if let Ok(parsed) = serde_json::from_str::<SearchResponse>(&body) {
debug!(target: "opensubtitles", %key, "search cache hit");
return Ok(parsed.data);
}
}
}
}
if matches!(
self.budget.acquire(Duration::from_secs(SEARCH_DEADLINE_SECS)).await,
Lease::DeadlineExceeded
) {
// Transient, not Permanent: busy is not broken.
return Err(GatewayError::Transient(
"opensubtitles: rate budget deadline exceeded".into(),
));
}
let url = format!("{}/subtitles?{}", self.api_base, encode_query(&pairs));
let resp = self
.http
.get(&url)
.header("Api-Key", &self.api_key)
.header(reqwest::header::ACCEPT, "application/json")
.send()
.await
.map_err(|e| GatewayError::Transient(format!("opensubtitles: {e}")))?;
// 429 carries Retry-After; freeze the whole bucket, not just this caller.
// A 301 (non-canonical query) lands here as Permanent — see the module doc.
if let Err(e) = map_status(&resp) {
if let GatewayError::RateLimited { retry_after_s } = &e {
self.budget
.note_throttled(Duration::from_secs(u64::from(*retry_after_s)));
}
return Err(e);
}
let body = resp
.text()
.await
.map_err(|e| GatewayError::Transient(format!("opensubtitles: {e}")))?;
let parsed: SearchResponse = serde_json::from_str(&body)
.map_err(|e| GatewayError::Transient(format!("opensubtitles: bad json: {e}")))?;
if let Some(cache) = &self.cache {
let _ = cache.put_misc(&key, &cache_entry(&body, now_secs()));
}
Ok(parsed.data)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pairs_are_sorted_and_lowercased() {
let p = SearchParams {
query: Some("The Matrix".into()),
imdb_id: Some(133093),
year: Some(1999),
languages: vec!["pt-br", "fr", "fr"],
moviehash: Some("8E245D9679D31E12".into()),
..Default::default()
};
assert_eq!(
encode_query(&p.to_pairs()),
"imdb_id=133093&languages=fr,pt-br&moviehash=8e245d9679d31e12&query=the+matrix&year=1999"
);
}
#[test]
fn episode_ids_are_sent_as_parent_ids() {
let p = SearchParams {
tmdb_id: Some(1399),
imdb_id: Some(944947),
season: Some(1),
episode: Some(1),
..Default::default()
};
assert_eq!(
encode_query(&p.to_pairs()),
"episode_number=1&parent_imdb_id=944947&parent_tmdb_id=1399&season_number=1"
);
}
#[test]
fn unbounded_means_no_text_no_id_no_hash() {
assert!(SearchParams::default().is_unbounded());
let langs_only = SearchParams { languages: vec!["fr"], ..Default::default() };
assert!(langs_only.is_unbounded());
let text = SearchParams { query: Some("matrix".into()), ..Default::default() };
assert!(!text.is_unbounded());
}
#[test]
fn value_encoding_escapes_everything_but_unreserved_and_comma() {
assert_eq!(encode_value("léon: the professional"), "l%C3%A9on%3A+the+professional");
}
#[test]
fn cache_entries_expire() {
let entry = cache_entry("{\"data\":[]}", 1_000);
assert_eq!(fresh_body(&entry, 1_000 + 10, 60).as_deref(), Some("{\"data\":[]}"));
assert_eq!(fresh_body(&entry, 1_000 + 61, 60), None);
assert_eq!(fresh_body("garbage", 0, 60), None);
}
#[test]
fn lenient_numbers_survive_odd_upstream_values() {
let a: OsAttributes = serde_json::from_str(
r#"{"download_count":"42","nb_cd":null,"files":[{"file_id":7.0,"cd_number":"x"}]}"#,
)
.unwrap();
assert_eq!(a.download_count, Some(42));
assert_eq!(a.nb_cd, None);
assert_eq!(a.files[0].file_id, Some(7));
assert_eq!(a.files[0].cd_number, None);
}
}
@@ -0,0 +1,37 @@
//! Tunables, each with the reason for its value.
/// OpenSubtitles REST API v1 base.
pub(crate) const OS_API_BASE: &str = "https://api.opensubtitles.com/api/v1";
/// `upstream_id` of the one plugin this feeder hosts, and the `source` token
/// inside every `provider-file` locator it mints.
pub const UPSTREAM_ID: &str = "opensubtitles";
/// Provenance label (`source/<label>`, METADATA_KEYS §5).
pub(crate) const SOURCE_LABEL: &str = "gateway:opensubtitles";
/// OpenSubtitles rejects anonymous clients: it wants a named app in `App vX.Y`
/// form. Overridable, because the key's registered app name is what they check.
pub(crate) const DEFAULT_USER_AGENT: &str =
concat!("meta-feeder-opensubtitles v", env!("CARGO_PKG_VERSION"));
/// ⚠ **5 requests per second, measured, not guessed.** A live search returns
/// `x-ratelimit-limit-second: 5` (2026-09-12). 4/s with a burst of 4 stays under
/// it with room for another client sharing the key.
pub(crate) const OS_RATE_PER_SEC: f64 = 4.0;
pub(crate) const OS_BURST: f64 = 4.0;
/// How long a search waits for a rate-budget lease. Someone is watching a
/// spinner, so it may queue, but not past the gateway's own patience.
pub(crate) const SEARCH_DEADLINE_SECS: u64 = 20;
/// HTTP timeout for a single upstream call.
pub(crate) const HTTP_TIMEOUT_SECS: u64 = 20;
/// Search results are cached, but not forever: a listing keeps gaining uploads
/// and download counts, and download count is half of the ranking.
pub(crate) const SEARCH_CACHE_TTL_SECS: u64 = 24 * 60 * 60;
/// Env seeds; the config page's saved values win over them.
pub(crate) const ENV_API_KEY: &str = "OPENSUBTITLES_API_KEY";
pub(crate) const ENV_USER_AGENT: &str = "OPENSUBTITLES_USER_AGENT";
@@ -0,0 +1,145 @@
//! OpenSubtitles language codes ↔ the stack's ISO 639-2/B vocabulary.
//!
//! ⚠ **The stack compares 639-2/B (`fre`, `ger`, `chi`), not 639-3 (`fra`).**
//! `meta_feeder_sdk::lang::normalize_lang_code` normalises onto it, torznab
//! writes `languages/fre`, and meta-search's language prefs store `fre`.
//! `languages_filter_matches` compares strings, so a record stamped `fra` is
//! *dropped* by a `languages:fre` query — a concrete, non-matching language.
//! (METADATA_KEYS.md still says 639-3 for `languages/*`; the code is what bites.)
//!
//! OpenSubtitles speaks its own dialect: mostly 639-1, plus region tags
//! (`pt-br`, `zh-tw`) and a few private codes (`ze` Chinese bilingual, `sp`/`ea`
//! Spanish EU/LA, `pm` Portuguese MZ). Responses use mixed case (`pt-BR`).
use meta_feeder_sdk::lang::normalize_lang_code;
/// Every `language_code` from `GET /api/v1/infos/languages` (105 entries,
/// fetched 2026-09-12) → ISO 639-2/B. Codes with no 639-2 entry use 639-3
/// (`ast`, `ext`, `cnr`, `tok`), which `normalize_lang_code` passes through.
const OS_TO_639_2B: &[(&str, &str)] = &[
("ab", "abk"), ("af", "afr"), ("sq", "alb"), ("am", "amh"), ("ar", "ara"),
("an", "arg"), ("hy", "arm"), ("as", "asm"), ("at", "ast"), ("az-az", "aze"),
("eu", "baq"), ("be", "bel"), ("bn", "ben"), ("bs", "bos"), ("br", "bre"),
("bg", "bul"), ("my", "bur"), ("ca", "cat"), ("ze", "chi"), ("zh-ca", "chi"),
("zh-cn", "chi"), ("zh-tw", "chi"), ("hr", "hrv"), ("cs", "cze"), ("da", "dan"),
("pr", "per"), ("nl", "dut"), ("en", "eng"), ("eo", "epo"), ("et", "est"),
("ex", "ext"), ("fi", "fin"), ("fr", "fre"), ("gd", "gla"), ("gl", "glg"),
("ka", "geo"), ("de", "ger"), ("el", "gre"), ("he", "heb"), ("hi", "hin"),
("hu", "hun"), ("is", "ice"), ("ig", "ibo"), ("id", "ind"), ("ia", "ina"),
("ga", "gle"), ("it", "ita"), ("ja", "jpn"), ("kn", "kan"), ("kk", "kaz"),
("km", "khm"), ("ko", "kor"), ("ku", "kur"), ("lv", "lav"), ("lt", "lit"),
("lb", "ltz"), ("mk", "mac"), ("ms", "may"), ("ml", "mal"), ("ma", "mni"),
("mr", "mar"), ("mn", "mon"), ("me", "cnr"), ("nv", "nav"), ("ne", "nep"),
("se", "sme"), ("no", "nor"), ("oc", "oci"), ("or", "ori"), ("fa", "per"),
("pl", "pol"), ("pt-pt", "por"), ("pt-br", "por"), ("pm", "por"), ("ps", "pus"),
("ro", "rum"), ("ru", "rus"), ("sx", "sat"), ("sr", "srp"), ("sd", "snd"),
("si", "sin"), ("sk", "slo"), ("sl", "slv"), ("so", "som"), ("az-zb", "aze"),
("es", "spa"), ("sp", "spa"), ("ea", "spa"), ("sw", "swa"), ("sv", "swe"),
("sy", "syr"), ("tl", "tgl"), ("ta", "tam"), ("tt", "tat"), ("te", "tel"),
("tm-td", "tet"), ("th", "tha"), ("tp", "tok"), ("tr", "tur"), ("tk", "tuk"),
("uk", "ukr"), ("ur", "urd"), ("uz", "uzb"), ("vi", "vie"), ("cy", "wel"),
];
/// 639-2/T → 639-2/B for the table's codes that `normalize_lang_code` does not
/// already fold (it covers `fra`/`deu`/`zho`/`nld`/`ell`/`ron`/`slk`/`ces`).
const T_TO_B: &[(&str, &str)] = &[
("sqi", "alb"), ("hye", "arm"), ("eus", "baq"), ("mya", "bur"), ("kat", "geo"),
("isl", "ice"), ("mkd", "mac"), ("msa", "may"), ("fas", "per"), ("cym", "wel"),
];
/// Canonical 639-2/B for any spelling a query or response might carry: 639-2/B,
/// 639-2/T, 639-1, or an OpenSubtitles code. `None` when nothing maps.
fn canonical(code: &str) -> Option<String> {
let c = code.trim().to_ascii_lowercase();
if c.is_empty() {
return None;
}
if let Some((_, b)) = T_TO_B.iter().find(|(t, _)| *t == c) {
return Some((*b).to_string());
}
if let Some((_, b)) = OS_TO_639_2B.iter().find(|(os, _)| *os == c) {
return Some((*b).to_string());
}
let n = normalize_lang_code(&c);
if n.len() == 3 && n.bytes().all(|b| b.is_ascii_lowercase()) && n != "und" {
return Some(n);
}
// A region-tagged spelling the table does not list (`en-us`): try the
// primary subtag once.
let primary = c.split(['-', '_']).next().unwrap_or("");
if primary != c && !primary.is_empty() {
return canonical(primary);
}
None
}
/// A subtitle's `language` from a search response → the code stamped on the
/// record (`subtitleLanguage`, `languages/<code>`). `None` for unknown input:
/// METADATA_KEYS says `und` is never written — an unknown language is simply
/// absent, which `languages:` filters treat as "may match".
pub fn from_os_code(code: &str) -> Option<String> {
canonical(code)
}
/// A query's `languages:` value → every OpenSubtitles code that denotes it,
/// sorted (`por` → `pm`, `pt-br`, `pt-pt`). Empty when the language is unknown
/// to OpenSubtitles.
pub fn to_os_codes(lang: &str) -> Vec<&'static str> {
let Some(b) = canonical(lang) else {
return Vec::new();
};
let mut codes: Vec<&'static str> = OS_TO_639_2B
.iter()
.filter(|(_, x)| *x == b)
.map(|(os, _)| *os)
.collect();
codes.sort_unstable();
codes
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn table_is_the_full_opensubtitles_list_with_unique_codes() {
assert_eq!(OS_TO_639_2B.len(), 105);
let mut os: Vec<_> = OS_TO_639_2B.iter().map(|(o, _)| *o).collect();
os.sort_unstable();
os.dedup();
assert_eq!(os.len(), 105);
}
/// Every code the table emits must survive the SDK normaliser unchanged,
/// or records and `languages:` filters would disagree.
#[test]
fn emitted_codes_are_fixed_points_of_the_sdk_normaliser() {
for (_, b) in OS_TO_639_2B {
assert_eq!(normalize_lang_code(b), *b, "{b}");
assert_eq!(canonical(b).as_deref(), Some(*b), "{b}");
}
}
#[test]
fn response_codes_map_to_639_2b() {
assert_eq!(from_os_code("fr").as_deref(), Some("fre"));
assert_eq!(from_os_code("pt-BR").as_deref(), Some("por"));
assert_eq!(from_os_code("zh-TW").as_deref(), Some("chi"));
assert_eq!(from_os_code("fa").as_deref(), Some("per"));
assert_eq!(from_os_code("en-US").as_deref(), Some("eng"));
assert_eq!(from_os_code(""), None);
assert_eq!(from_os_code("xx"), None);
assert_eq!(from_os_code("und"), None);
}
#[test]
fn query_codes_accept_every_spelling() {
assert_eq!(to_os_codes("fre"), vec!["fr"]);
assert_eq!(to_os_codes("fra"), vec!["fr"]);
assert_eq!(to_os_codes("fr"), vec!["fr"]);
assert_eq!(to_os_codes("FAS"), vec!["fa", "pr"]);
assert_eq!(to_os_codes("por"), vec!["pm", "pt-br", "pt-pt"]);
assert_eq!(to_os_codes("spa"), vec!["ea", "es", "sp"]);
assert!(to_os_codes("zzz").is_empty());
}
}
@@ -0,0 +1,23 @@
//! `opensubtitles-feeder` — subtitle search over OpenSubtitles.
//!
//! A **sidecar** tier: it answers "which subtitles exist for X", never "give me
//! the bytes". Each result is one subtitle *file*, addressed by a
//! `provider-file` locator (`0x100A`, `docs/cid-formats.md` §8) that embeds
//! `("opensubtitles", "file:<file_id>")`. meta-share resolves that locator later;
//! nothing here downloads, so searching never spends the account's download quota.
//!
//! ## The contracts that must not drift
//!
//! - The locator bytes ([`locator`]), pinned by the golden vector in
//! `cid-formats.md` §8.2.
//! - Every record carries a `cids/` member. The gateway persists only records
//! with a cid, and its search-coverage skip is only sound for persisted records.
//! - Language codes are ISO 639-2/B (`fre`, not `fra`) — the vocabulary
//! `meta_feeder_sdk::lang::normalize_lang_code` and every `languages:` filter use.
pub mod client;
pub mod consts;
pub mod lang;
pub mod locator;
pub mod plugin;
pub mod record;
@@ -0,0 +1,215 @@
//! The `provider-file` locator (`0x100A`) — `docs/cid-formats.md` §8.
//!
//! One codec for every provider whose bytes are fetched the same way; the
//! provider lives inside the digest:
//!
//! ```text
//! version : 1
//! codec : 0x100A (varint 8a 20)
//! multihash : 0x00 (identity — payload verbatim)
//! digest : varint(len(source)) ‖ source ‖ id (both UTF-8, ≤ 64 bytes)
//! ```
//!
//! The framing is byte-identical to `card` (`0x1007`) — only the codec differs —
//! which is why the tests below re-derive the existing `card-locator` vector
//! with the same encoder.
//!
//! ⚠ **This is a local copy.** The SDK's framing helpers (`kinded_locator_cid`,
//! `write_pb_varint`, `base32_lower_no_padding`) are private, so it could not be
//! reused. Move it into `meta-feeder-sdk` as `encode_source_id_locator(codec,
//! source, id)` (§8 trap 7) and delete this file; the golden vectors are what
//! prove the move changed no bytes.
use crate::consts::UPSTREAM_ID;
/// Custom multicodec for a fetchable `(source, id)` locator.
pub const PROVIDER_FILE_CODEC: u64 = 0x100A;
/// meta-share's `MAX_MULTIHASH_SIZE` — its `CidGeneric<64>` rejects a longer
/// multihash, so a longer digest would mint a CID no peer can parse.
const MAX_DIGEST_BYTES: usize = 64;
/// The locator for one OpenSubtitles **file** (`/download` takes a `file_id`;
/// one subtitle listing can hold several files, one per CD).
pub fn file_cid(file_id: u64) -> Option<String> {
provider_file_cid(UPSTREAM_ID, &format!("file:{file_id}"))
}
/// Encode a `provider-file` CID. `None` when the digest would exceed 64 bytes —
/// the caller drops that row rather than emit an unparseable address.
pub fn provider_file_cid(source: &str, id: &str) -> Option<String> {
source_id_locator(PROVIDER_FILE_CODEC, source, id)
}
/// Inverse of [`provider_file_cid`]: `(source, id)`, or `None` for anything that
/// is not exactly a well-formed `0x100A` CID. Matches the **codec slot** — a
/// `card` CID has the same framing and must not decode here.
pub fn decode_provider_file_cid(cid: &str) -> Option<(String, String)> {
let wire = base32_lower_no_pad_decode(cid.strip_prefix('b')?)?;
let mut pos = 0;
if read_varint(&wire, &mut pos)? != 1 {
return None;
}
if read_varint(&wire, &mut pos)? != PROVIDER_FILE_CODEC {
return None;
}
if read_varint(&wire, &mut pos)? != 0x00 {
return None;
}
let len = read_varint(&wire, &mut pos)? as usize;
let digest = wire.get(pos..)?;
if digest.len() != len {
return None;
}
let mut dpos = 0;
let source_len = read_varint(digest, &mut dpos)? as usize;
let source = digest.get(dpos..dpos.checked_add(source_len)?)?;
let id = digest.get(dpos + source_len..)?;
Some((
String::from_utf8(source.to_vec()).ok()?,
String::from_utf8(id.to_vec()).ok()?,
))
}
fn source_id_locator(codec: u64, source: &str, id: &str) -> Option<String> {
let source = source.as_bytes();
let id = id.as_bytes();
// identity-multihash digest = varint(source_len) ‖ source ‖ id
let mut digest = Vec::with_capacity(2 + source.len() + id.len());
write_varint(source.len() as u64, &mut digest);
digest.extend_from_slice(source);
digest.extend_from_slice(id);
if digest.len() > MAX_DIGEST_BYTES {
return None;
}
// CIDv1: [version=0x01][codec varint][mh code=0x00 identity][len varint][digest]
let mut wire = Vec::with_capacity(6 + digest.len());
wire.push(0x01);
write_varint(codec, &mut wire);
wire.push(0x00);
write_varint(digest.len() as u64, &mut wire);
wire.extend_from_slice(&digest);
Some(format!("b{}", base32_lower_no_pad(&wire)))
}
fn write_varint(mut value: u64, out: &mut Vec<u8>) {
loop {
let byte = (value & 0x7f) as u8;
value >>= 7;
if value == 0 {
out.push(byte);
return;
}
out.push(byte | 0x80);
}
}
fn read_varint(bytes: &[u8], pos: &mut usize) -> Option<u64> {
let mut value = 0u64;
let mut shift = 0u32;
loop {
let byte = *bytes.get(*pos)?;
*pos += 1;
value |= u64::from(byte & 0x7f) << shift;
if byte & 0x80 == 0 {
return Some(value);
}
shift += 7;
if shift >= 64 {
return None;
}
}
}
const BASE32_ALPHABET: &[u8; 32] = b"abcdefghijklmnopqrstuvwxyz234567";
fn base32_lower_no_pad(input: &[u8]) -> String {
let mut out = String::with_capacity((input.len() * 8).div_ceil(5));
let mut acc = 0u64;
let mut bits = 0u32;
for &byte in input {
acc = (acc << 8) | u64::from(byte);
bits += 8;
while bits >= 5 {
bits -= 5;
out.push(BASE32_ALPHABET[((acc >> bits) & 31) as usize] as char);
}
acc &= (1u64 << bits) - 1;
}
if bits > 0 {
out.push(BASE32_ALPHABET[((acc << (5 - bits)) & 31) as usize] as char);
}
out
}
fn base32_lower_no_pad_decode(input: &str) -> Option<Vec<u8>> {
let mut out = Vec::with_capacity(input.len() * 5 / 8);
let mut acc = 0u64;
let mut bits = 0u32;
for c in input.bytes() {
let v = match c {
b'a'..=b'z' => c - b'a',
b'2'..=b'7' => c - b'2' + 26,
_ => return None,
};
acc = (acc << 5) | u64::from(v);
bits += 5;
if bits >= 8 {
bits -= 8;
out.push((acc >> bits) as u8);
acc &= (1u64 << bits) - 1;
}
}
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
/// The same framing under `card`'s codec reproduces the existing
/// `card-locator` golden vector — proof the encoder is the family's framing,
/// not a look-alike.
#[test]
fn framing_reproduces_the_card_locator_vector() {
assert_eq!(
source_id_locator(0x1007, "tmdb", "tv:95479").as_deref(),
Some("bagdsaaanar2g2zdcor3duojvgq3ts")
);
assert_eq!(
source_id_locator(0x1008, "video", "4D7u5KF7SP8").as_deref(),
Some("bagecaaarav3gszdfn42ein3vgvfumn2tka4a")
);
}
/// `docs/cid-formats.md` §8.2 worked example.
#[test]
fn file_cid_matches_the_documented_vector() {
assert_eq!(
file_cid(7061834).as_deref(),
Some("bagfcaaa2bvxxazloon2we5djorwgk43gnfwgkorxga3dcobtgq")
);
}
#[test]
fn decode_round_trips_and_refuses_other_codecs() {
let cid = file_cid(12658754).unwrap();
assert_eq!(
decode_provider_file_cid(&cid),
Some(("opensubtitles".to_string(), "file:12658754".to_string()))
);
// Identical framing, different codec: a card must not decode as a file.
assert_eq!(decode_provider_file_cid("bagdsaaanar2g2zdcor3duojvgq3ts"), None);
assert_eq!(decode_provider_file_cid("not-a-cid"), None);
assert_eq!(decode_provider_file_cid(""), None);
}
#[test]
fn digest_ceiling_is_64_bytes() {
// varint(13) + "opensubtitles" = 14 bytes, so the id may use 50.
assert!(provider_file_cid("opensubtitles", &"x".repeat(50)).is_some());
assert!(provider_file_cid("opensubtitles", &"x".repeat(51)).is_none());
}
}
@@ -0,0 +1,35 @@
//! `opensubtitles-feeder` — the OpenSubtitles subtitle search feeder.
//!
//! Serves `fileType:subtitle`. Needs an OpenSubtitles API key (config page or
//! `OPENSUBTITLES_API_KEY`); without one it stays registered but degraded and
//! answers every query with nothing.
//!
//! Env:
//! - `META_FEEDER_HTTP_LISTEN` — listen addr (default `0.0.0.0:8080`)
//! - `META_FEEDER_STATE_DIR` — per-plugin cache root (default `/data/meta-feeder`)
//! - `OPENSUBTITLES_API_KEY` — seed API key (the config page overrides it)
//! - `OPENSUBTITLES_USER_AGENT` — seed User-Agent (OpenSubtitles wants `App vX.Y`)
//! - `RUST_LOG` — tracing filter (default `info`)
use std::net::SocketAddr;
use meta_feeder_sdk::plugin::FeederPlugin;
use meta_feeder_sdk::serve_feeders;
use opensubtitles_feeder::plugin::OpenSubtitlesPlugin;
use tracing_subscriber::EnvFilter;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
.init();
let listen: SocketAddr = std::env::var("META_FEEDER_HTTP_LISTEN")
.unwrap_or_else(|_| "0.0.0.0:8080".to_string())
.parse()?;
let state_dir =
std::env::var("META_FEEDER_STATE_DIR").unwrap_or_else(|_| "/data/meta-feeder".to_string());
let plugins: Vec<Box<dyn FeederPlugin>> = vec![Box::new(OpenSubtitlesPlugin::from_env())];
serve_feeders(plugins, state_dir, listen).await
}
@@ -0,0 +1,381 @@
//! `OpenSubtitlesPlugin` — the `FeederPlugin` for `fileType:subtitle`.
//!
//! Two query shapes, both answered; which one to send is the client's call:
//!
//! - **keyword** — `matrix fileType:subtitle` → `query=matrix`
//! - **anchored** — any of `imdbid:` `tmdbid:` `season:` `episode:`
//! `movieYear:` `moviehash:`, e.g. `tmdbid:1399 season:1 episode:1 fileType:subtitle`
//!
//! plus an optional `languages:` narrowing on either.
use std::path::Path;
use async_trait::async_trait;
use meta_feeder_sdk::budget::RateBudget;
use meta_feeder_sdk::common::open_midhash_cache;
use meta_feeder_sdk::config::{ConfigField as F, ConfigSchema};
use meta_feeder_sdk::plugin::{ConfigError, FeederPlugin, HashOutcome};
use meta_feeder_sdk::query::GatewayQuery;
use meta_feeder_sdk::query_eval::query_accepts_plugin;
use meta_feeder_sdk::types::{DiscoveryRecord, GatewayError, PluginHealth};
use serde::{Deserialize, Serialize};
use tracing::{debug, warn};
use crate::client::{OsClient, SearchParams};
use crate::consts::{
DEFAULT_USER_AGENT, ENV_API_KEY, ENV_USER_AGENT, OS_API_BASE, OS_BURST, OS_RATE_PER_SEC,
UPSTREAM_ID,
};
use crate::lang::to_os_codes;
use crate::record::project;
/// Keys match [`FeederPlugin::config_schema`], which is what the SDK writes to
/// `config.json` from the dashboard.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct OsConfig {
#[serde(default, rename = "apiKey")]
api_key: String,
#[serde(default, rename = "userAgent")]
user_agent: String,
}
pub struct OpenSubtitlesPlugin {
config: OsConfig,
api_base: String,
/// `None` until `configure` runs, and stays `None` without an API key — the
/// soft-skip path: `/health` says why, queries return nothing.
client: Option<OsClient>,
}
impl OpenSubtitlesPlugin {
/// Production constructor: env seeds, real endpoint.
pub fn from_env() -> Self {
Self {
config: OsConfig {
api_key: std::env::var(ENV_API_KEY).unwrap_or_default(),
user_agent: std::env::var(ENV_USER_AGENT).unwrap_or_default(),
},
api_base: OS_API_BASE.to_string(),
client: None,
}
}
/// Test constructor: a fixed key and an endpoint override (wiremock).
pub fn with_api_base(api_key: &str, api_base: String) -> Self {
Self {
config: OsConfig {
api_key: api_key.to_string(),
user_agent: String::new(),
},
api_base,
client: None,
}
}
/// File wins over env, like every other feeder: `config.json` in the
/// per-plugin cache dir, read at `configure` time. No hot reload.
fn load_config(&mut self, cache_dir: &Path) {
let Ok(bytes) = std::fs::read(cache_dir.join("config.json")) else {
return;
};
let Ok(file_cfg) = serde_json::from_slice::<OsConfig>(&bytes) else {
warn!(target: "opensubtitles", "config.json is not valid; keeping env seeds");
return;
};
if !file_cfg.api_key.trim().is_empty() {
self.config.api_key = file_cfg.api_key;
}
if !file_cfg.user_agent.trim().is_empty() {
self.config.user_agent = file_cfg.user_agent;
}
}
}
/// Axes a sidecar never carries (METADATA_KEYS.md §1).
const NON_SIDECAR_AXES: &[&str] = &["contentKind", "domain", "workForm"];
/// True when the query explicitly asks for subtitles, and for nothing a
/// subtitle record can't carry.
///
/// - `query_accepts_plugin` alone admits a query with no type filter at all, and
/// a query that never mentioned subtitles is not a subtitle question.
/// - ⚠ It also checks only the `fileType` / `contentKind` axes. A `domain:` or
/// `workForm:` filter is a wall or row query that no subtitle record can
/// satisfy (`record_matches` drops every row lacking the key). The gateway
/// rewrites `domain:` into `contentKind:` on its own dispatch path, but a
/// direct `/query` sees the raw filter and would spend an upstream call on a
/// guaranteed-empty answer — the live smoke run returned 10 rows for
/// `domain:screen` before this check existed.
fn requests_subtitles(query: &GatewayQuery) -> bool {
if NON_SIDECAR_AXES.iter().any(|k| query.filters.contains_key(*k)) {
return false;
}
["fileType", "type"].iter().any(|k| {
query
.filters
.get(*k)
.is_some_and(|vs| vs.iter().any(|v| v.eq_ignore_ascii_case("subtitle")))
})
}
fn first_filter<'a>(query: &'a GatewayQuery, key: &str) -> Option<&'a str> {
query
.filters
.get(key)
.and_then(|vs| vs.iter().map(|v| v.trim()).find(|v| !v.is_empty()))
}
fn parse_u64(v: Option<&str>) -> Option<u64> {
v.and_then(|s| s.trim().parse().ok())
}
/// Resolve a gateway query into one OpenSubtitles search. `None` means "no
/// upstream call can answer this": nothing to search on, or a `languages:`
/// filter naming only languages OpenSubtitles does not have.
pub fn search_params(query: &GatewayQuery) -> Option<SearchParams> {
let mut p = SearchParams {
query: Some(query.free_text.trim())
.filter(|s| !s.is_empty())
.map(str::to_string),
imdb_id: first_filter(query, "imdbid").and_then(|v| {
let digits = v
.strip_prefix("tt")
.or_else(|| v.strip_prefix("TT"))
.unwrap_or(v);
digits.parse().ok()
}),
tmdb_id: parse_u64(first_filter(query, "tmdbid")),
season: parse_u64(first_filter(query, "season")),
episode: parse_u64(first_filter(query, "episode")),
year: parse_u64(first_filter(query, "movieYear")),
moviehash: first_filter(query, "moviehash")
.filter(|h| h.len() == 16 && h.bytes().all(|b| b.is_ascii_hexdigit()))
.map(str::to_ascii_lowercase),
languages: Vec::new(),
};
if let Some(values) = query.filters.get("languages") {
p.languages = values
.iter()
.flat_map(|v| v.split(','))
.flat_map(to_os_codes)
.collect();
if p.languages.is_empty() {
// The client asked for languages we cannot express. Searching
// without the filter would return only rows `record_matches` drops.
return None;
}
}
(!p.is_unbounded()).then_some(p)
}
fn rank_key(r: &DiscoveryRecord) -> (bool, u64) {
(
r.fields.get("moviehashMatch").is_some_and(|v| v == "true"),
r.fields
.get("downloadCount")
.and_then(|v| v.parse().ok())
.unwrap_or(0),
)
}
#[async_trait]
impl FeederPlugin for OpenSubtitlesPlugin {
fn upstream_id(&self) -> &'static str {
UPSTREAM_ID
}
/// ⚠ `subtitle` only, and **no content kinds**: a subtitle is a sidecar
/// with no `contentKind`/`domain` (METADATA_KEYS.md §1), so a wall or row
/// query (`domain:screen`, `contentKind:movie`) is correctly not routed here.
fn served_file_types(&self) -> &'static [&'static str] {
&["subtitle"]
}
fn served_content_kinds(&self) -> &'static [&'static str] {
&[]
}
fn configure(&mut self, cache_dir: &Path) -> Result<(), ConfigError> {
self.load_config(cache_dir);
let api_key = self.config.api_key.trim().to_string();
if api_key.is_empty() {
warn!(
target: "opensubtitles",
"no OpenSubtitles API key (config page or {ENV_API_KEY}); subtitle searches return nothing"
);
return Ok(());
}
let cache = open_midhash_cache(cache_dir, UPSTREAM_ID)?;
let user_agent = match self.config.user_agent.trim() {
"" => DEFAULT_USER_AGENT,
ua => ua,
};
self.client = Some(OsClient::new(
self.api_base.clone(),
api_key,
user_agent,
RateBudget::new(OS_RATE_PER_SEC, OS_BURST),
Some(cache),
));
Ok(())
}
fn health(&self) -> PluginHealth {
if self.client.is_some() {
PluginHealth::Ok
} else {
PluginHealth::Degraded {
reason: "no OpenSubtitles API key configured".into(),
}
}
}
async fn handle_query(
&self,
query: &GatewayQuery,
max_results: usize,
) -> Result<Vec<DiscoveryRecord>, GatewayError> {
if !requests_subtitles(query)
|| !query_accepts_plugin(query, self.served_file_types(), self.served_content_kinds())
{
return Ok(Vec::new());
}
let Some(client) = self.client.as_ref() else {
return Ok(Vec::new());
};
let Some(params) = search_params(query) else {
debug!(target: "opensubtitles", filters = ?query.filters.keys().collect::<Vec<_>>(), "nothing searchable in query");
return Ok(Vec::new());
};
let subtitles = client.search(&params).await?;
let mut records: Vec<DiscoveryRecord> =
subtitles.iter().flat_map(|s| project(s, query)).collect();
// Hash matches first (they are the right sync for the file in hand),
// then popularity; record_id keeps the order deterministic.
records.sort_by(|a, b| {
rank_key(b)
.cmp(&rank_key(a))
.then_with(|| a.record_id.cmp(&b.record_id))
});
records.truncate(max_results);
Ok(records)
}
/// Not implemented, on purpose: a `provider-file` locator is resolved by
/// meta-share's fetch tier (`docs/cid-formats.md` §8), and the SDK's
/// `HashKind` has no variant to carry one — adding it is an SDK + gateway
/// change. Searching never needs this path.
async fn compute_outcomes(&self, _record_id: &str) -> Result<Vec<HashOutcome>, GatewayError> {
Err(GatewayError::Permanent(
"opensubtitles: provider-file locators are resolved by meta-share, not /compute \
(docs/cid-formats.md §8)"
.into(),
))
}
fn config_schema(&self) -> ConfigSchema {
ConfigSchema {
fields: vec![
F::secret("apiKey", "OpenSubtitles API key")
.required()
.with_help(
"Consumer API key from opensubtitles.com. Search only — this feeder \
never downloads, so no account login is needed and no download \
quota is spent. Takes effect on the next feeder restart.",
),
F::text("userAgent", "User-Agent").with_help(
"OpenSubtitles expects the registered app name in `App vX.Y` form. \
Blank keeps the built-in default.",
),
],
}
}
fn config_values(&self) -> serde_json::Value {
serde_json::json!({
"apiKey": self.config.api_key,
"userAgent": self.config.user_agent,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
fn q(free: &str, filters: &[(&str, &str)]) -> GatewayQuery {
let mut map: BTreeMap<String, Vec<String>> = BTreeMap::new();
for (k, v) in filters {
map.entry((*k).to_string()).or_default().push((*v).to_string());
}
GatewayQuery {
raw_text: String::new(),
free_text: free.to_string(),
filters: map,
ranges: Vec::new(),
negations: Vec::new(),
}
}
#[test]
fn keyword_only() {
let p = search_params(&q(" matrix ", &[("fileType", "subtitle")])).unwrap();
assert_eq!(p.query.as_deref(), Some("matrix"));
assert!(p.imdb_id.is_none() && p.tmdb_id.is_none());
}
#[test]
fn movie_anchor_strips_tt_and_maps_languages() {
let p = search_params(&q(
"",
&[("fileType", "subtitle"), ("imdbid", "tt0133093"), ("languages", "fre,por")],
))
.unwrap();
assert_eq!(p.imdb_id, Some(133093));
assert_eq!(p.languages, vec!["fr", "pm", "pt-br", "pt-pt"]);
assert!(!p.is_episode());
}
#[test]
fn episode_anchor() {
let p = search_params(&q(
"",
&[("fileType", "subtitle"), ("tmdbid", "1399"), ("season", "1"), ("episode", "2")],
))
.unwrap();
assert_eq!((p.tmdb_id, p.season, p.episode), (Some(1399), Some(1), Some(2)));
assert!(p.is_episode());
}
#[test]
fn moviehash_must_be_16_hex() {
let ok = search_params(&q("", &[("moviehash", "8E245D9679D31E12")])).unwrap();
assert_eq!(ok.moviehash.as_deref(), Some("8e245d9679d31e12"));
assert!(search_params(&q("", &[("moviehash", "not-a-hash")])).is_none());
}
#[test]
fn nothing_searchable_means_no_call() {
assert!(search_params(&q("", &[("fileType", "subtitle")])).is_none());
assert!(search_params(&q("", &[("fileType", "subtitle"), ("languages", "fre")])).is_none());
// Unknown language: searching without it would only return rows the
// gateway then drops.
assert!(search_params(&q("matrix", &[("languages", "zzz")])).is_none());
}
#[test]
fn only_explicit_subtitle_queries_are_answered() {
assert!(requests_subtitles(&q("", &[("fileType", "subtitle")])));
assert!(requests_subtitles(&q("", &[("fileType", "video"), ("fileType", "Subtitle")])));
assert!(!requests_subtitles(&q("matrix", &[])));
assert!(!requests_subtitles(&q("", &[("fileType", "video")])));
// Wall / row axes a sidecar never carries.
for axis in [("domain", "screen"), ("workForm", "standalone"), ("contentKind", "movie")] {
assert!(!requests_subtitles(&q("matrix", &[("fileType", "subtitle"), axis])), "{axis:?}");
}
}
}
@@ -0,0 +1,292 @@
//! Search result → `DiscoveryRecord`: one record per subtitle **file**.
//!
//! A subtitle is a **sidecar** (METADATA_KEYS.md §1): `fileType=subtitle`, and
//! no `contentKind` / `domain` / `workForm` — it is referenced by a work, never
//! listed on a wall.
use std::collections::BTreeMap;
use meta_feeder_sdk::plugin::upstream_id_field;
use meta_feeder_sdk::query::GatewayQuery;
use meta_feeder_sdk::types::DiscoveryRecord;
use crate::client::{FeatureDetails, OsAttributes, OsFile, OsSubtitle};
use crate::consts::{SOURCE_LABEL, UPSTREAM_ID};
use crate::lang::from_os_code;
use crate::locator::file_cid;
/// `fileType=subtitle` extensions (METADATA_KEYS.md `fileType` table). A file
/// name ending in anything else carries no `extension` rather than a guess —
/// OpenSubtitles' `format` field is `null` in practice, and many file names
/// have no extension at all (`….GPRS.fr`).
const SUBTITLE_EXTENSIONS: &[&str] = &["srt", "sub", "sbv", "vtt", "ass", "ssa", "ttml", "dfxp", "smi"];
/// Project one search hit into a record per file. Files with no id, or whose
/// locator would not fit the 64-byte ceiling, are dropped.
pub fn project(sub: &OsSubtitle, query: &GatewayQuery) -> Vec<DiscoveryRecord> {
let a = &sub.attributes;
a.files
.iter()
.filter_map(|file| to_record(a, file, query))
.collect()
}
fn to_record(a: &OsAttributes, file: &OsFile, query: &GatewayQuery) -> Option<DiscoveryRecord> {
let file_id = file.file_id?;
// ⚠ No cid, no record. The gateway persists a search record only when it
// carries a `cids/` member, while the search-coverage gate still marks the
// query covered — an unaddressable record means every repeat search comes
// back empty for the coverage window.
let cid = file_cid(file_id)?;
let mut fields: BTreeMap<String, String> = BTreeMap::new();
fields.insert("fileType".into(), "subtitle".into());
fields.insert(format!("cids/{cid}"), "true".into());
fields.insert(upstream_id_field(UPSTREAM_ID), file_id.to_string());
fields.insert(format!("source/{SOURCE_LABEL}"), "true".into());
let default_fd = FeatureDetails::default();
let fd = a.feature_details.as_ref().unwrap_or(&default_fd);
if let Some(title) = title_for(a, fd, file) {
fields.insert("title".into(), title);
}
if let Some(name) = file.file_name.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
fields.insert("fileName".into(), name.to_string());
if let Some(ext) = extension_of(name) {
fields.insert("extension".into(), ext.to_string());
}
}
if let Some(lang) = a.language.as_deref().and_then(from_os_code) {
fields.insert(format!("languages/{lang}"), "true".into());
fields.insert("subtitleLanguage".into(), lang);
}
// Ids. For an episode, the *show's* ids: meta-watch anchors a series on the
// show's tmdbid, and `record_matches` requires the value to be equal — the
// episode's own `tmdb_id` (63056 for GoT S01E01, not 1399) would drop every
// record from an anchored episode search.
let episode = is_episode(fd);
let (imdb, tmdb) = if episode {
(fd.parent_imdb_id, fd.parent_tmdb_id)
} else {
(fd.imdb_id, fd.tmdb_id)
};
if let Some(id) = imdb {
fields.insert("imdbid".into(), format!("tt{id:07}"));
}
if let Some(id) = tmdb {
fields.insert("tmdbid".into(), id.to_string());
}
if episode {
if let Some(s) = fd.season_number {
fields.insert("season".into(), s.to_string());
}
if let Some(e) = fd.episode_number {
fields.insert("episode".into(), e.to_string());
}
} else if let Some(y) = fd.year {
fields.insert("movieYear".into(), y.to_string());
}
// Ranking signals for the client choosing between versions.
fields.insert(
"downloadCount".into(),
a.download_count.unwrap_or(0).to_string(),
);
fields.insert("moviehashMatch".into(), bool_str(a.moviehash_match));
fields.insert("hearingImpaired".into(), bool_str(a.hearing_impaired));
fields.insert(
"machineTranslated".into(),
bool_str(Some(
a.ai_translated.unwrap_or(false) || a.machine_translated.unwrap_or(false),
)),
);
echo_filters(&mut fields, query);
Some(DiscoveryRecord {
upstream_id: UPSTREAM_ID.to_string(),
record_id: format!("{UPSTREAM_ID}:file:{file_id}"),
fields,
})
}
fn is_episode(fd: &FeatureDetails) -> bool {
fd.feature_type
.as_deref()
.is_some_and(|t| t.eq_ignore_ascii_case("episode"))
|| fd.season_number.is_some()
|| fd.episode_number.is_some()
}
/// The release name users recognise, else the feature's display name. A
/// multi-CD subtitle gets its part number, or its files are indistinguishable.
fn title_for(a: &OsAttributes, fd: &FeatureDetails, file: &OsFile) -> Option<String> {
let non_empty = |s: &Option<String>| {
s.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
};
let base = non_empty(&a.release)
.or_else(|| non_empty(&fd.movie_name))
.or_else(|| non_empty(&fd.title))?;
match (a.nb_cd, file.cd_number) {
(Some(total), Some(n)) if total > 1 => Some(format!("{base} (CD {n}/{total})")),
_ => Some(base),
}
}
fn extension_of(name: &str) -> Option<&'static str> {
let (_, ext) = name.rsplit_once('.')?;
let ext = ext.to_ascii_lowercase();
SUBTITLE_EXTENSIONS.iter().copied().find(|e| *e == ext)
}
fn bool_str(b: Option<bool>) -> String {
if b.unwrap_or(false) { "true" } else { "false" }.to_string()
}
/// Structured filters the record must echo back.
///
/// ⚠ `record_matches` (gateway + meta-search) iterates EVERY filter on the query
/// and fails any record that does not carry the key. Key-set fields (`genres`,
/// `languages`) have dedicated arms and the type axes are set explicitly, so
/// they are skipped; so is any key the record already states — echoing must
/// never overwrite a real value (`imdbid` stays the record's own).
///
/// `moviehash:` is echoed onto every record; `moviehashMatch` is what tells a
/// real hash match apart from an id match returned by the same search.
fn echo_filters(fields: &mut BTreeMap<String, String>, query: &GatewayQuery) {
const SKIP: &[&str] = &["genres", "languages", "fileType", "type", "contentKind", "domain", "workForm"];
for (k, values) in &query.filters {
if SKIP.contains(&k.as_str()) || fields.contains_key(k) {
continue;
}
if let Some(v) = values.first() {
fields.insert(k.clone(), v.clone());
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::client::SearchResponse;
use crate::locator::decode_provider_file_cid;
use meta_feeder_sdk::query_eval::record_matches;
fn q(free: &str, filters: &[(&str, &str)]) -> GatewayQuery {
let mut map: BTreeMap<String, Vec<String>> = BTreeMap::new();
for (k, v) in filters {
map.entry((*k).to_string()).or_default().push((*v).to_string());
}
GatewayQuery {
raw_text: String::new(),
free_text: free.to_string(),
filters: map,
ranges: Vec::new(),
negations: Vec::new(),
}
}
fn records(fixture: &str, query: &GatewayQuery) -> Vec<DiscoveryRecord> {
let resp: SearchResponse = serde_json::from_str(fixture).unwrap();
resp.data.iter().flat_map(|s| project(s, query)).collect()
}
const KEYWORD: &str = include_str!("../tests/fixtures/search_keyword_matrix.json");
const MOVIE_FR: &str = include_str!("../tests/fixtures/search_imdb_133093_fr.json");
const EPISODE: &str = include_str!("../tests/fixtures/search_episode_1399_s01e01.json");
const HASH: &str = include_str!("../tests/fixtures/search_moviehash.json");
#[test]
fn one_addressable_sidecar_record_per_file() {
let query = q("matrix", &[("fileType", "subtitle")]);
let recs = records(KEYWORD, &query);
// 4 subtitles, one of them a 2-CD release.
assert_eq!(recs.len(), 5);
for r in &recs {
assert_eq!(r.fields.get("fileType").map(String::as_str), Some("subtitle"));
for axis in ["contentKind", "domain", "workForm"] {
assert!(!r.fields.contains_key(axis), "{axis} on a sidecar");
}
let cid = r
.fields
.keys()
.find_map(|k| k.strip_prefix("cids/"))
.expect("cids/ member");
let (source, id) = decode_provider_file_cid(cid).expect("0x100A cid");
assert_eq!(source, "opensubtitles");
assert_eq!(id, format!("file:{}", r.fields["opensubtitlesid"]));
assert_eq!(r.record_id, format!("opensubtitles:{id}"));
assert!(record_matches(&r.fields, &query), "{:?}", r.fields);
}
let multi: Vec<_> = recs
.iter()
.filter(|r| r.fields.get("title").is_some_and(|t| t.contains("(CD ")))
.collect();
assert_eq!(multi.len(), 2, "both parts of the multi-CD subtitle are titled");
}
#[test]
fn movie_anchor_with_language_matches_its_query() {
let query = q("", &[("fileType", "subtitle"), ("imdbid", "tt0133093"), ("languages", "fre")]);
let recs = records(MOVIE_FR, &query);
assert!(!recs.is_empty());
for r in &recs {
assert_eq!(r.fields.get("subtitleLanguage").map(String::as_str), Some("fre"));
assert_eq!(r.fields.get("languages/fre").map(String::as_str), Some("true"));
assert_eq!(r.fields.get("imdbid").map(String::as_str), Some("tt0133093"));
assert_eq!(r.fields.get("tmdbid").map(String::as_str), Some("603"));
assert_eq!(r.fields.get("movieYear").map(String::as_str), Some("1999"));
assert!(record_matches(&r.fields, &query), "{:?}", r.fields);
}
}
#[test]
fn episode_records_carry_the_show_ids() {
let query = q(
"",
&[("fileType", "subtitle"), ("tmdbid", "1399"), ("season", "1"), ("episode", "1")],
);
let recs = records(EPISODE, &query);
assert!(!recs.is_empty());
for r in &recs {
assert_eq!(r.fields.get("tmdbid").map(String::as_str), Some("1399"));
assert_eq!(r.fields.get("imdbid").map(String::as_str), Some("tt0944947"));
assert_eq!(r.fields.get("season").map(String::as_str), Some("1"));
assert_eq!(r.fields.get("episode").map(String::as_str), Some("1"));
assert!(!r.fields.contains_key("movieYear"));
assert!(record_matches(&r.fields, &query), "{:?}", r.fields);
}
}
#[test]
fn moviehash_search_flags_matches_and_echoes_the_hash() {
let query = q("", &[("fileType", "subtitle"), ("moviehash", "8e245d9679d31e12")]);
let recs = records(HASH, &query);
assert!(!recs.is_empty());
for r in &recs {
assert_eq!(r.fields.get("moviehashMatch").map(String::as_str), Some("true"));
assert!(record_matches(&r.fields, &query), "{:?}", r.fields);
}
}
#[test]
fn echo_never_overwrites_a_real_value() {
let query = q("", &[("fileType", "subtitle"), ("imdbid", "tt9999999")]);
let recs = records(MOVIE_FR, &query);
assert_eq!(recs[0].fields.get("imdbid").map(String::as_str), Some("tt0133093"));
// …so a record for a different film is correctly rejected downstream.
assert!(!record_matches(&recs[0].fields, &query));
}
#[test]
fn extension_only_from_a_real_subtitle_suffix() {
assert_eq!(extension_of("Movie.en.SRT"), Some("srt"));
assert_eq!(extension_of("The.Matrix.GPRS.fr"), None);
assert_eq!(extension_of("no_extension"), None);
}
}
@@ -0,0 +1,311 @@
//! End-to-end contract test for the feeder's HTTP surface: boots the real SDK
//! router (`configure_plugins` + `router`) on an ephemeral port, pointed at a
//! wiremock OpenSubtitles, and drives `/manifest`, `/health`, `/query` and
//! `/compute` over HTTP. Deterministic — fixtures were captured from the live
//! API on 2026-09-12 and trimmed (uploader names removed).
//!
//! What this pins:
//! - the manifest advertises `fileType=subtitle` — the only reason meta-search
//! routes a `fileType:subtitle` query here;
//! - keyword and anchored queries yield persistable sidecar records (a
//! `provider-file` `cids/` member, no content kind) that pass the gateway's
//! own `record_matches` against the query that produced them;
//! - the upstream sees OpenSubtitles' canonical query form (sorted keys) and the
//! API key header;
//! - a query this feeder cannot answer never reaches the upstream.
use std::collections::BTreeMap;
use meta_feeder_sdk::query_eval::record_matches;
use meta_feeder_sdk::{
configure_plugins, router, ComputeRequest, GatewayQuery, ManifestResponse, QueryRequest,
QueryResponse,
};
use opensubtitles_feeder::locator::decode_provider_file_cid;
use opensubtitles_feeder::plugin::OpenSubtitlesPlugin;
use wiremock::matchers::{header, method, path, query_param};
use wiremock::{Match, Mock, MockServer, Request, ResponseTemplate};
const KEY: &str = "test-key";
const KEYWORD: &str = include_str!("fixtures/search_keyword_matrix.json");
const MOVIE_FR: &str = include_str!("fixtures/search_imdb_133093_fr.json");
const EPISODE: &str = include_str!("fixtures/search_episode_1399_s01e01.json");
async fn spawn_feeder(api_key: &str, upstream: &MockServer) -> (String, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("tempdir");
let plugin = OpenSubtitlesPlugin::with_api_base(api_key, upstream.uri());
let plugins = configure_plugins(vec![Box::new(plugin)], dir.path()).expect("configure plugins");
let app = router(plugins, "opensubtitles-feeder-test".to_string(), dir.path());
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind ephemeral");
let addr = listener.local_addr().expect("local_addr");
tokio::spawn(async move {
axum::serve(listener, app).await.expect("serve");
});
(format!("http://{addr}"), dir)
}
fn query(free: &str, filters: &[(&str, &str)]) -> GatewayQuery {
let mut map: BTreeMap<String, Vec<String>> = BTreeMap::new();
for (k, v) in filters {
map.entry((*k).to_string()).or_default().push((*v).to_string());
}
GatewayQuery {
raw_text: String::new(),
free_text: free.to_string(),
filters: map,
ranges: Vec::new(),
negations: Vec::new(),
}
}
async fn post_query(base: &str, q: &GatewayQuery) -> reqwest::Response {
reqwest::Client::new()
.post(format!("{base}/query"))
.json(&QueryRequest {
upstream_id: "opensubtitles".into(),
query: q.clone(),
max_results: 50,
})
.send()
.await
.expect("POST /query")
}
async fn records(base: &str, q: &GatewayQuery) -> Vec<meta_feeder_sdk::DiscoveryRecord> {
let resp = post_query(base, q).await;
assert_eq!(resp.status(), 200, "{:?}", resp.text().await);
resp.json::<QueryResponse>().await.expect("QueryResponse").records
}
/// OpenSubtitles 301s any query string whose keys are not sorted.
struct SortedQueryKeys;
impl Match for SortedQueryKeys {
fn matches(&self, request: &Request) -> bool {
let keys: Vec<&str> = request
.url
.query()
.unwrap_or("")
.split('&')
.filter(|kv| !kv.is_empty())
.map(|kv| kv.split('=').next().unwrap_or(""))
.collect();
let mut sorted = keys.clone();
sorted.sort_unstable();
keys == sorted
}
}
fn assert_persistable_sidecar(r: &meta_feeder_sdk::DiscoveryRecord, q: &GatewayQuery) {
assert_eq!(r.fields.get("fileType").map(String::as_str), Some("subtitle"));
assert!(!r.fields.contains_key("contentKind") && !r.fields.contains_key("domain"));
let cid = r
.fields
.keys()
.find_map(|k| k.strip_prefix("cids/"))
.expect("a cids/ member, or the gateway never persists the record");
let (source, id) = decode_provider_file_cid(cid).expect("a provider-file (0x100A) cid");
assert_eq!(source, "opensubtitles");
assert_eq!(id, format!("file:{}", r.fields["opensubtitlesid"]));
assert!(record_matches(&r.fields, q), "gateway would drop {:?}", r.fields);
}
#[tokio::test]
async fn manifest_advertises_subtitle_only() {
let upstream = MockServer::start().await;
let (base, _dir) = spawn_feeder(KEY, &upstream).await;
let m: ManifestResponse = reqwest::get(format!("{base}/manifest"))
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(m.plugins.len(), 1);
assert_eq!(m.plugins[0].id, "opensubtitles");
assert_eq!(m.plugins[0].served_file_types, vec!["subtitle".to_string()]);
assert!(m.plugins[0].served_content_kinds.is_empty());
}
#[tokio::test]
async fn keyword_search_yields_persistable_subtitle_records() {
let upstream = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/subtitles"))
.and(query_param("query", "matrix"))
.and(header("Api-Key", KEY))
.and(SortedQueryKeys)
.respond_with(ResponseTemplate::new(200).set_body_string(KEYWORD))
.expect(1)
.mount(&upstream)
.await;
let (base, _dir) = spawn_feeder(KEY, &upstream).await;
let q = query("Matrix", &[("fileType", "subtitle")]);
let recs = records(&base, &q).await;
assert_eq!(recs.len(), 5, "one record per file");
for r in &recs {
assert_persistable_sidecar(r, &q);
}
// Ranked by download count (no hash matches in a keyword search).
let counts: Vec<u64> = recs
.iter()
.map(|r| r.fields["downloadCount"].parse().unwrap())
.collect();
assert!(counts.windows(2).all(|w| w[0] >= w[1]), "{counts:?}");
}
#[tokio::test]
async fn movie_anchor_with_language() {
let upstream = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/subtitles"))
.and(query_param("imdb_id", "133093"))
.and(query_param("languages", "fr"))
.and(SortedQueryKeys)
.respond_with(ResponseTemplate::new(200).set_body_string(MOVIE_FR))
.expect(1)
.mount(&upstream)
.await;
let (base, _dir) = spawn_feeder(KEY, &upstream).await;
let q = query("", &[("fileType", "subtitle"), ("imdbid", "tt0133093"), ("languages", "fre")]);
let recs = records(&base, &q).await;
assert!(!recs.is_empty());
for r in &recs {
assert_persistable_sidecar(r, &q);
assert_eq!(r.fields.get("subtitleLanguage").map(String::as_str), Some("fre"));
assert_eq!(r.fields.get("tmdbid").map(String::as_str), Some("603"));
}
}
#[tokio::test]
async fn episode_anchor_is_sent_as_parent_ids() {
let upstream = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/subtitles"))
.and(query_param("parent_tmdb_id", "1399"))
.and(query_param("season_number", "1"))
.and(query_param("episode_number", "1"))
.and(SortedQueryKeys)
.respond_with(ResponseTemplate::new(200).set_body_string(EPISODE))
.expect(1)
.mount(&upstream)
.await;
let (base, _dir) = spawn_feeder(KEY, &upstream).await;
let q = query(
"",
&[("fileType", "subtitle"), ("tmdbid", "1399"), ("season", "1"), ("episode", "1")],
);
let recs = records(&base, &q).await;
assert!(!recs.is_empty());
for r in &recs {
assert_persistable_sidecar(r, &q);
assert_eq!(r.fields.get("tmdbid").map(String::as_str), Some("1399"));
}
}
#[tokio::test]
async fn unanswerable_queries_never_reach_the_upstream() {
let upstream = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(200).set_body_string(KEYWORD))
.expect(0)
.mount(&upstream)
.await;
let (base, _dir) = spawn_feeder(KEY, &upstream).await;
for q in [
// wall/row queries — a subtitle carries none of these axes. `domain:` is
// the case the live smoke run caught: the SDK gate only checks
// fileType/contentKind, and only the gateway expands domain → kinds.
query("matrix", &[("fileType", "subtitle"), ("contentKind", "movie")]),
query("matrix", &[("fileType", "subtitle"), ("domain", "screen")]),
query("matrix", &[("fileType", "subtitle"), ("workForm", "standalone")]),
// never asked for subtitles
query("matrix", &[]),
query("matrix", &[("fileType", "video")]),
// nothing to search on
query("", &[("fileType", "subtitle")]),
query("", &[("fileType", "subtitle"), ("languages", "fre")]),
// a language OpenSubtitles does not have
query("matrix", &[("fileType", "subtitle"), ("languages", "zzz")]),
] {
assert!(records(&base, &q).await.is_empty(), "{:?}", q.filters);
}
}
#[tokio::test]
async fn upstream_429_surfaces_as_429() {
let upstream = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(429).insert_header("Retry-After", "1"))
.mount(&upstream)
.await;
let (base, _dir) = spawn_feeder(KEY, &upstream).await;
let resp = post_query(&base, &query("matrix", &[("fileType", "subtitle")])).await;
assert_eq!(resp.status(), 429);
}
/// A 301 means the query string drifted from OpenSubtitles' canonical form.
/// It must be loud, not a silently followed extra hop on every search.
#[tokio::test]
async fn upstream_redirect_is_an_error() {
let upstream = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(
ResponseTemplate::new(301).insert_header("Location", "/subtitles?query=matrix"),
)
.mount(&upstream)
.await;
let (base, _dir) = spawn_feeder(KEY, &upstream).await;
let resp = post_query(&base, &query("matrix", &[("fileType", "subtitle")])).await;
assert_eq!(resp.status(), 422);
}
#[tokio::test]
async fn missing_api_key_degrades_and_answers_nothing() {
let upstream = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(200).set_body_string(KEYWORD))
.expect(0)
.mount(&upstream)
.await;
let (base, _dir) = spawn_feeder("", &upstream).await;
let health: serde_json::Value = reqwest::get(format!("{base}/health"))
.await
.unwrap()
.json()
.await
.unwrap();
assert_ne!(health["status"], "ok", "{health}");
assert!(records(&base, &query("matrix", &[("fileType", "subtitle")]))
.await
.is_empty());
}
#[tokio::test]
async fn compute_is_refused() {
let upstream = MockServer::start().await;
let (base, _dir) = spawn_feeder(KEY, &upstream).await;
let resp = reqwest::Client::new()
.post(format!("{base}/compute"))
.json(&ComputeRequest {
upstream_id: "opensubtitles".into(),
record_id: "opensubtitles:file:12658754".into(),
})
.send()
.await
.unwrap();
assert_eq!(resp.status(), 422);
}
@@ -0,0 +1,137 @@
{
"total_pages": 1,
"total_count": 3,
"per_page": 50,
"page": 1,
"data": [
{
"id": "11957644",
"type": "subtitle",
"attributes": {
"subtitle_id": "11957644",
"language": "fa",
"download_count": 5,
"new_download_count": 1,
"hearing_impaired": false,
"hd": true,
"fps": 0.0,
"from_trusted": false,
"foreign_parts_only": false,
"upload_date": "2026-09-05T16:45:23Z",
"ai_translated": false,
"nb_cd": 1,
"machine_translated": false,
"release": "Game.of.Thrones.S01E01.Winter.Is.Coming.REPACK.2160p.MAX.WEB-DL.TrueHD.7.1.Atmos.DV.HDR.H.265-Kitsune",
"legacy_subtitle_id": 14026000,
"feature_details": {
"feature_id": 278305,
"feature_type": "Episode",
"year": 2011,
"title": "Winter Is Coming",
"movie_name": "Game of Thrones - S01E01 Winter Is Coming",
"imdb_id": 1480055,
"tmdb_id": 63056,
"season_number": 1,
"episode_number": 1,
"parent_imdb_id": 944947,
"parent_title": "Game of Thrones",
"parent_tmdb_id": 1399,
"parent_feature_id": 10891
},
"files": [
{
"file_id": 12838679,
"cd_number": 1,
"file_name": "Game.of.Thrones.S01E01.Winter.Is.Coming.REPACK.2160p.MAX.WEB-DL.TrueHD.7.1.Atmos.DV.HDR.H.265-Kitsune"
}
]
}
},
{
"id": "11785541",
"type": "subtitle",
"attributes": {
"subtitle_id": "11785541",
"language": "en",
"download_count": 141,
"new_download_count": 571,
"hearing_impaired": true,
"hd": false,
"fps": 29.97,
"from_trusted": false,
"foreign_parts_only": false,
"upload_date": "2026-08-01T04:06:10Z",
"ai_translated": false,
"nb_cd": 1,
"machine_translated": false,
"release": "Game of Thrones S01E01 Winter Is Coming",
"legacy_subtitle_id": 13943373,
"feature_details": {
"feature_id": 278305,
"feature_type": "Episode",
"year": 2011,
"title": "Winter Is Coming",
"movie_name": "Game of Thrones - S01E01 Winter Is Coming",
"imdb_id": 1480055,
"tmdb_id": 63056,
"season_number": 1,
"episode_number": 1,
"parent_imdb_id": 944947,
"parent_title": "Game of Thrones",
"parent_tmdb_id": 1399,
"parent_feature_id": 10891
},
"files": [
{
"file_id": 12694986,
"cd_number": 1,
"file_name": "Game of Thrones S01E01 Winter Is Coming"
}
]
}
},
{
"id": "11733097",
"type": "subtitle",
"attributes": {
"subtitle_id": "11733097",
"language": "es",
"download_count": 111,
"new_download_count": 173,
"hearing_impaired": false,
"hd": true,
"fps": 0.0,
"from_trusted": false,
"foreign_parts_only": false,
"upload_date": "2026-06-25T15:42:00Z",
"ai_translated": false,
"nb_cd": 1,
"machine_translated": false,
"release": "Game of Thrones S01E01 1080p AMZN playWEB",
"legacy_subtitle_id": 13859500,
"feature_details": {
"feature_id": 278305,
"feature_type": "Episode",
"year": 2011,
"title": "Winter Is Coming",
"movie_name": "Game of Thrones - S01E01 Winter Is Coming",
"imdb_id": 1480055,
"tmdb_id": 63056,
"season_number": 1,
"episode_number": 1,
"parent_imdb_id": 944947,
"parent_title": "Game of Thrones",
"parent_tmdb_id": 1399,
"parent_feature_id": 10891
},
"files": [
{
"file_id": 12642661,
"cd_number": 1,
"file_name": "Game of Thrones S01E01 1080p AMZN playWEB"
}
]
}
}
]
}
@@ -0,0 +1,124 @@
{
"total_pages": 1,
"total_count": 3,
"per_page": 50,
"page": 1,
"data": [
{
"id": "11749486",
"type": "subtitle",
"attributes": {
"subtitle_id": "11749486",
"language": "fr",
"download_count": 57,
"new_download_count": 160,
"hearing_impaired": false,
"hd": true,
"fps": 0.0,
"from_trusted": false,
"foreign_parts_only": false,
"upload_date": "2026-07-10T15:36:25Z",
"ai_translated": false,
"nb_cd": 1,
"machine_translated": false,
"release": "The Matrix (1999) {imdb-tt0133093} - [AMZN][WEBDL-1080p][EAC3 5.1][h264]-GPRS",
"legacy_subtitle_id": 13894449,
"feature_details": {
"feature_id": 646193,
"feature_type": "Movie",
"year": 1999,
"title": "The Matrix",
"movie_name": "1999 - The Matrix",
"imdb_id": 133093,
"tmdb_id": 603
},
"files": [
{
"file_id": 12658754,
"cd_number": 1,
"file_name": "The Matrix (1999) {imdb-tt0133093} - [AMZN][WEBDL-1080p][EAC3 5.1][h264]-GPRS.fr"
}
]
}
},
{
"id": "8520404",
"type": "subtitle",
"attributes": {
"subtitle_id": "8520404",
"language": "fr",
"download_count": 275,
"new_download_count": 348,
"hearing_impaired": false,
"hd": false,
"fps": 0.0,
"from_trusted": false,
"foreign_parts_only": false,
"upload_date": "2024-07-20T18:28:59Z",
"ai_translated": false,
"nb_cd": 2,
"machine_translated": false,
"release": "The.Matrix.Screener-VCD-EUROPE",
"legacy_subtitle_id": 12104394,
"feature_details": {
"feature_id": 646193,
"feature_type": "Movie",
"year": 1999,
"title": "The Matrix",
"movie_name": "1999 - The Matrix",
"imdb_id": 133093,
"tmdb_id": 603
},
"files": [
{
"file_id": 9443700,
"cd_number": 1,
"file_name": "The Matrix Screener part 1.PAL.FR"
},
{
"file_id": 9443701,
"cd_number": 2,
"file_name": "The Matrix Screener part 2.PAL.FR"
}
]
}
},
{
"id": "8234814",
"type": "subtitle",
"attributes": {
"subtitle_id": "8234814",
"language": "fr",
"download_count": 293,
"new_download_count": 221,
"hearing_impaired": false,
"hd": false,
"fps": 0.0,
"from_trusted": false,
"foreign_parts_only": false,
"upload_date": "2024-07-09T04:00:21Z",
"ai_translated": false,
"nb_cd": 1,
"machine_translated": false,
"release": "????1.The.Matrix.1.1999.UC.DVDRip.XViD.AC3.5.1CH.CD3-WAF.FR.PAL",
"legacy_subtitle_id": 11181831,
"feature_details": {
"feature_id": 646193,
"feature_type": "Movie",
"year": 1999,
"title": "The Matrix",
"movie_name": "1999 - The Matrix",
"imdb_id": 133093,
"tmdb_id": 603
},
"files": [
{
"file_id": 9159988,
"cd_number": 1,
"file_name": "1.The.Matrix.1.1999.UC.DVDRip.XViD.AC3.5.1CH.CD3-WAF.FR.PAL"
}
]
}
}
]
}
@@ -0,0 +1,161 @@
{
"total_pages": 1,
"total_count": 4,
"per_page": 50,
"page": 1,
"data": [
{
"id": "10078344",
"type": "subtitle",
"attributes": {
"subtitle_id": "10078344",
"language": "pl",
"download_count": 23,
"new_download_count": 8,
"hearing_impaired": false,
"hd": false,
"fps": 0.0,
"from_trusted": false,
"foreign_parts_only": false,
"upload_date": "2024-06-19T07:54:22Z",
"ai_translated": false,
"nb_cd": 2,
"machine_translated": false,
"release": "Armitage III Dual Matrix CD 1",
"legacy_subtitle_id": 10206883,
"feature_details": {
"feature_id": 517502,
"feature_type": "Movie",
"year": 2002,
"title": "Armitage: Dual Matrix",
"movie_name": "2002 - Armitage: Dual Matrix",
"imdb_id": 303678,
"tmdb_id": 21769
},
"files": [
{
"file_id": 10986218,
"cd_number": 1,
"file_name": "Armitage III Dual Matrix CD 1"
},
{
"file_id": 10986219,
"cd_number": 2,
"file_name": "Armitage III Dual Matrix CD 2"
}
]
}
},
{
"id": "10078343",
"type": "subtitle",
"attributes": {
"subtitle_id": "10078343",
"language": "ar",
"download_count": 24,
"new_download_count": 34,
"hearing_impaired": false,
"hd": false,
"fps": 0.0,
"from_trusted": false,
"foreign_parts_only": false,
"upload_date": "2024-07-18T08:00:49Z",
"ai_translated": false,
"nb_cd": 1,
"machine_translated": false,
"release": "[Kaito] Armitage: Dual Matrix [DVD][Kaito]",
"legacy_subtitle_id": 11930992,
"feature_details": {
"feature_id": 517502,
"feature_type": "Movie",
"year": 2002,
"title": "Armitage: Dual Matrix",
"movie_name": "2002 - Armitage: Dual Matrix",
"imdb_id": 303678,
"tmdb_id": 21769
},
"files": [
{
"file_id": 10986217,
"cd_number": 1,
"file_name": "KaitoFansub_Armitage_III_Dual_Matrix_Movie"
}
]
}
},
{
"id": "5804989",
"type": "subtitle",
"attributes": {
"subtitle_id": "5804989",
"language": "es",
"download_count": 129,
"new_download_count": 143,
"hearing_impaired": false,
"hd": true,
"fps": 29.97,
"from_trusted": true,
"foreign_parts_only": false,
"upload_date": "2021-06-04T17:23:32Z",
"ai_translated": false,
"nb_cd": 1,
"machine_translated": false,
"release": "Armitage III- Dual-Matrix (2002)",
"legacy_subtitle_id": 8696238,
"feature_details": {
"feature_id": 517502,
"feature_type": "Movie",
"year": 2002,
"title": "Armitage: Dual Matrix",
"movie_name": "2002 - Armitage: Dual Matrix",
"imdb_id": 303678,
"tmdb_id": 21769
},
"files": [
{
"file_id": 6820144,
"cd_number": 1,
"file_name": "Armitage III- Dual-Matrix (2002).es"
}
]
}
},
{
"id": "5804815",
"type": "subtitle",
"attributes": {
"subtitle_id": "5804815",
"language": "en",
"download_count": 486,
"new_download_count": 162,
"hearing_impaired": false,
"hd": true,
"fps": 29.97,
"from_trusted": true,
"foreign_parts_only": false,
"upload_date": "2021-06-04T16:57:20Z",
"ai_translated": false,
"nb_cd": 1,
"machine_translated": false,
"release": "Armitage III- Dual-Matrix (2002)",
"legacy_subtitle_id": 8696082,
"feature_details": {
"feature_id": 517502,
"feature_type": "Movie",
"year": 2002,
"title": "Armitage: Dual Matrix",
"movie_name": "2002 - Armitage: Dual Matrix",
"imdb_id": 303678,
"tmdb_id": 21769
},
"files": [
{
"file_id": 6820033,
"cd_number": 1,
"file_name": "Armitage III- Dual-Matrix (2002).eng"
}
]
}
}
]
}
@@ -0,0 +1,96 @@
{
"total_pages": 1,
"total_count": 2,
"per_page": 50,
"page": 1,
"data": [
{
"id": "3097032",
"type": "subtitle",
"attributes": {
"subtitle_id": "3097032",
"language": "en",
"download_count": 321046,
"new_download_count": 3344,
"hearing_impaired": true,
"hd": true,
"fps": 23.976,
"from_trusted": false,
"foreign_parts_only": false,
"upload_date": "2014-02-28T09:55:32Z",
"ai_translated": false,
"nb_cd": 1,
"machine_translated": false,
"release": "Vikings.S02E01.HDTV.x264-EXCELLENCE",
"legacy_subtitle_id": 5562812,
"feature_details": {
"feature_id": 345772,
"feature_type": "Episode",
"year": 2014,
"title": "Brother's War",
"movie_name": "Vikings - S02E01 Brother's War",
"imdb_id": 3358048,
"tmdb_id": 970845,
"season_number": 2,
"episode_number": 1,
"parent_imdb_id": 2306299,
"parent_title": "Vikings",
"parent_tmdb_id": 44217,
"parent_feature_id": 12400
},
"files": [
{
"file_id": 3166793,
"cd_number": 1,
"file_name": "Vikings.S02E01.HDTV.x264-EXCELLENCE"
}
],
"moviehash_match": true
}
},
{
"id": "7508772",
"type": "subtitle",
"attributes": {
"subtitle_id": "7508772",
"language": "en",
"download_count": 18479,
"new_download_count": 764,
"hearing_impaired": false,
"hd": false,
"fps": 23.976,
"from_trusted": false,
"foreign_parts_only": false,
"upload_date": "2009-07-24T13:31:10Z",
"ai_translated": false,
"nb_cd": 1,
"machine_translated": false,
"release": "DVDRip",
"legacy_subtitle_id": 3538948,
"feature_details": {
"feature_id": 1769142,
"feature_type": "Episode",
"year": 2002,
"title": "Jurassic Bark",
"movie_name": "Futurama - S05E02 Jurassic Bark",
"imdb_id": 584444,
"tmdb_id": 1108227,
"season_number": 5,
"episode_number": 2,
"parent_imdb_id": 149460,
"parent_title": "Futurama",
"parent_tmdb_id": 615,
"parent_feature_id": 1768889
},
"files": [
{
"file_id": 8439091,
"cd_number": 1,
"file_name": "Futurama - 5x02 - Jurassic Bark"
}
],
"moviehash_match": true
}
}
]
}
+3
View File
@@ -0,0 +1,3 @@
[toolchain]
channel = "1.89"
components = ["rustfmt", "clippy"]