Skip to content

Commit df4c7d5

Browse files
committed
add opensubtitles support
1 parent 0400ac8 commit df4c7d5

8 files changed

Lines changed: 426 additions & 0 deletions

File tree

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@ apikey = "your-prowlarr-api-key"
2121

2222
[player]
2323
command = "mpv"
24+
25+
# Optional - auto-fetch subtitles
26+
[subtitles]
27+
enabled = true
28+
language = "en"
29+
opensubtitles_api_key = "your-key" # from opensubtitles.com
2430
```
2531

2632
## Usage

TODO.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ Search, select, watch but no permanent storage. Inspired by \*arr stack but for
1313

1414
## laterons
1515

16+
- edit config in TUI
17+
- support discord rich presence
18+
- sync watch history using trakt
1619
- subtitles
1720
- multi-episode handling (select a season, DL in background but be able to start first ep?)
1821
- ig we can support both seq streaming and downloading in bg. should this be temp?

src/config.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ pub struct Config {
2727
pub storage: StorageConfig,
2828
#[serde(default)]
2929
pub extensions: ExtensionsConfig,
30+
#[serde(default)]
31+
pub subtitles: SubtitlesConfig,
3032
}
3133

3234
#[derive(Debug, Clone, Default, Deserialize)]
@@ -63,6 +65,34 @@ pub struct TmdbConfig {
6365
pub apikey: String,
6466
}
6567

68+
#[derive(Debug, Clone, Deserialize)]
69+
pub struct SubtitlesConfig {
70+
#[serde(default = "default_subtitles_enabled")]
71+
pub enabled: bool,
72+
#[serde(default = "default_subtitle_language")]
73+
pub language: String,
74+
/// OpenSubtitles API key for fetching subtitles when not included in torrent
75+
pub opensubtitles_api_key: Option<String>,
76+
}
77+
78+
impl Default for SubtitlesConfig {
79+
fn default() -> Self {
80+
Self {
81+
enabled: default_subtitles_enabled(),
82+
language: default_subtitle_language(),
83+
opensubtitles_api_key: None,
84+
}
85+
}
86+
}
87+
88+
fn default_subtitles_enabled() -> bool {
89+
true
90+
}
91+
92+
fn default_subtitle_language() -> String {
93+
"en".to_string()
94+
}
95+
6696
#[derive(Debug, Clone, Deserialize)]
6797
pub struct PlayerConfig {
6898
#[serde(default = "default_player_command")]

src/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
mod config;
44
mod doctor;
55
mod extensions;
6+
mod opensubtitles;
67
mod prowlarr;
78
mod streaming;
89
mod tmdb;

src/opensubtitles.rs

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
use reqwest::Client;
2+
use serde::Deserialize;
3+
use thiserror::Error;
4+
use tracing::{debug, info};
5+
6+
#[derive(Error, Debug)]
7+
pub enum OpenSubtitlesError {
8+
#[error("request failed: {0}")]
9+
RequestError(#[from] reqwest::Error),
10+
#[error("no subtitles found")]
11+
NotFound,
12+
#[error("API error: {0}")]
13+
ApiError(String),
14+
}
15+
16+
#[derive(Debug, Deserialize)]
17+
struct SearchResponse {
18+
data: Vec<SubtitleResult>,
19+
}
20+
21+
#[derive(Debug, Deserialize)]
22+
struct SubtitleResult {
23+
attributes: SubtitleAttributes,
24+
}
25+
26+
#[derive(Debug, Deserialize)]
27+
struct SubtitleAttributes {
28+
language: String,
29+
files: Vec<SubtitleFileInfo>,
30+
}
31+
32+
#[derive(Debug, Deserialize)]
33+
struct SubtitleFileInfo {
34+
file_id: u64,
35+
file_name: String,
36+
}
37+
38+
#[derive(Debug, Deserialize)]
39+
struct DownloadResponse {
40+
link: String,
41+
}
42+
43+
pub struct OpenSubtitlesClient {
44+
client: Client,
45+
api_key: String,
46+
}
47+
48+
#[derive(Debug, Clone)]
49+
pub struct SubtitleDownload {
50+
pub language: String,
51+
pub file_name: String,
52+
pub download_url: String,
53+
}
54+
55+
impl OpenSubtitlesClient {
56+
pub fn new(api_key: &str) -> Self {
57+
Self {
58+
client: Client::new(),
59+
api_key: api_key.to_string(),
60+
}
61+
}
62+
63+
/// Search for subtitles by IMDB ID
64+
pub async fn search_by_imdb(
65+
&self,
66+
imdb_id: &str,
67+
language: &str,
68+
) -> Result<Vec<SubtitleDownload>, OpenSubtitlesError> {
69+
// Clean IMDB ID (remove 'tt' prefix if present)
70+
let imdb_clean = imdb_id.trim_start_matches("tt");
71+
72+
let url = format!(
73+
"https://api.opensubtitles.com/api/v1/subtitles?imdb_id={}&languages={}",
74+
imdb_clean, language
75+
);
76+
77+
debug!(imdb = imdb_clean, language, "searching OpenSubtitles");
78+
79+
let response = self
80+
.client
81+
.get(&url)
82+
.header("Api-Key", &self.api_key)
83+
.header("Content-Type", "application/json")
84+
.send()
85+
.await?;
86+
87+
if !response.status().is_success() {
88+
let status = response.status();
89+
let body = response.text().await.unwrap_or_default();
90+
return Err(OpenSubtitlesError::ApiError(format!(
91+
"HTTP {}: {}",
92+
status, body
93+
)));
94+
}
95+
96+
let search: SearchResponse = response.json().await?;
97+
98+
if search.data.is_empty() {
99+
return Err(OpenSubtitlesError::NotFound);
100+
}
101+
102+
info!(count = search.data.len(), "found subtitles");
103+
104+
// Get download links for each subtitle
105+
let mut results = Vec::new();
106+
for sub in search.data.into_iter().take(3) {
107+
// Limit to top 3
108+
if let Some(file) = sub.attributes.files.first() {
109+
match self.get_download_link(file.file_id).await {
110+
Ok(link) => {
111+
results.push(SubtitleDownload {
112+
language: sub.attributes.language.clone(),
113+
file_name: file.file_name.clone(),
114+
download_url: link,
115+
});
116+
}
117+
Err(e) => {
118+
debug!(error = %e, "failed to get download link");
119+
}
120+
}
121+
}
122+
}
123+
124+
if results.is_empty() {
125+
return Err(OpenSubtitlesError::NotFound);
126+
}
127+
128+
Ok(results)
129+
}
130+
131+
/// Search for subtitles by TMDB ID
132+
pub async fn search_by_tmdb(
133+
&self,
134+
tmdb_id: u64,
135+
language: &str,
136+
) -> Result<Vec<SubtitleDownload>, OpenSubtitlesError> {
137+
let url = format!(
138+
"https://api.opensubtitles.com/api/v1/subtitles?tmdb_id={}&languages={}",
139+
tmdb_id, language
140+
);
141+
142+
debug!(tmdb_id, language, "searching OpenSubtitles by TMDB");
143+
144+
let response = self
145+
.client
146+
.get(&url)
147+
.header("Api-Key", &self.api_key)
148+
.header("Content-Type", "application/json")
149+
.send()
150+
.await?;
151+
152+
if !response.status().is_success() {
153+
let status = response.status();
154+
let body = response.text().await.unwrap_or_default();
155+
return Err(OpenSubtitlesError::ApiError(format!(
156+
"HTTP {}: {}",
157+
status, body
158+
)));
159+
}
160+
161+
let search: SearchResponse = response.json().await?;
162+
163+
if search.data.is_empty() {
164+
return Err(OpenSubtitlesError::NotFound);
165+
}
166+
167+
info!(count = search.data.len(), "found subtitles");
168+
169+
let mut results = Vec::new();
170+
for sub in search.data.into_iter().take(3) {
171+
if let Some(file) = sub.attributes.files.first() {
172+
match self.get_download_link(file.file_id).await {
173+
Ok(link) => {
174+
results.push(SubtitleDownload {
175+
language: sub.attributes.language.clone(),
176+
file_name: file.file_name.clone(),
177+
download_url: link,
178+
});
179+
}
180+
Err(e) => {
181+
debug!(error = %e, "failed to get download link");
182+
}
183+
}
184+
}
185+
}
186+
187+
if results.is_empty() {
188+
return Err(OpenSubtitlesError::NotFound);
189+
}
190+
191+
Ok(results)
192+
}
193+
194+
async fn get_download_link(&self, file_id: u64) -> Result<String, OpenSubtitlesError> {
195+
let url = "https://api.opensubtitles.com/api/v1/download";
196+
197+
let response = self
198+
.client
199+
.post(url)
200+
.header("Api-Key", &self.api_key)
201+
.header("Content-Type", "application/json")
202+
.json(&serde_json::json!({ "file_id": file_id }))
203+
.send()
204+
.await?;
205+
206+
if !response.status().is_success() {
207+
let status = response.status();
208+
let body = response.text().await.unwrap_or_default();
209+
return Err(OpenSubtitlesError::ApiError(format!(
210+
"HTTP {}: {}",
211+
status, body
212+
)));
213+
}
214+
215+
let download: DownloadResponse = response.json().await?;
216+
Ok(download.link)
217+
}
218+
}

0 commit comments

Comments
 (0)