@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
/target
|
||||
**/target
|
||||
Cargo.lock.bak
|
||||
*.redb
|
||||
Generated
+1945
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
@@ -1 +1,72 @@
|
||||
# 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(¶ms).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);
|
||||
}
|
||||
+137
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+124
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+161
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
[toolchain]
|
||||
channel = "1.89"
|
||||
components = ["rustfmt", "clippy"]
|
||||
Reference in New Issue
Block a user