Skip to content

Commit 0982085

Browse files
committed
add tv series / episode select support
1 parent f2618fb commit 0982085

5 files changed

Lines changed: 838 additions & 24 deletions

File tree

src/streaming.rs

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -497,6 +497,38 @@ impl StreamingSession {
497497
self.http_addr
498498
}
499499

500+
/// Prioritize downloading a specific file by making a range request
501+
/// This triggers librqbit to prioritize pieces for that file
502+
pub async fn prioritize_file(&self, torrent_id: usize, file_idx: usize) -> Result<(), StreamError> {
503+
let url = format!(
504+
"http://{}/torrents/{}/stream/{}",
505+
self.http_addr, torrent_id, file_idx
506+
);
507+
508+
// Make a small range request to trigger prioritization
509+
let result = self
510+
.http_client
511+
.get(&url)
512+
.header("Range", "bytes=0-1024")
513+
.send()
514+
.await;
515+
516+
match result {
517+
Ok(resp) if resp.status().is_success() || resp.status().as_u16() == 206 => {
518+
info!(torrent_id, file_idx, "file prioritized for pre-download");
519+
Ok(())
520+
}
521+
Ok(resp) => {
522+
debug!(status = %resp.status(), "prioritize request returned non-success");
523+
Ok(()) // Don't fail on this, it's best-effort
524+
}
525+
Err(e) => {
526+
debug!(error = %e, "failed to prioritize file");
527+
Ok(()) // Don't fail on this either
528+
}
529+
}
530+
}
531+
500532
/// Get download stats for a torrent
501533
pub async fn get_stats(&self, torrent_id: usize) -> Option<TorrentStats> {
502534
let url = format!("http://{}/torrents/{}/stats/v1", self.http_addr, torrent_id);
@@ -662,6 +694,41 @@ pub struct VideoFile {
662694
pub stream_url: String,
663695
}
664696

697+
impl VideoFile {
698+
/// Extract season and episode numbers from filename for sorting
699+
pub fn episode_sort_key(&self) -> (u32, u32) {
700+
use regex::Regex;
701+
702+
// S01E02 format
703+
let sxex_re = Regex::new(r"(?i)[Ss](\d{1,2})[Ee](\d{1,3})").unwrap();
704+
if let Some(caps) = sxex_re.captures(&self.name) {
705+
if let (Some(s), Some(e)) = (caps.get(1), caps.get(2)) {
706+
if let (Ok(season), Ok(episode)) = (s.as_str().parse(), e.as_str().parse()) {
707+
return (season, episode);
708+
}
709+
}
710+
}
711+
712+
// 1x02 format
713+
let x_re = Regex::new(r"(?i)(\d{1,2})x(\d{1,3})").unwrap();
714+
if let Some(caps) = x_re.captures(&self.name) {
715+
if let (Some(s), Some(e)) = (caps.get(1), caps.get(2)) {
716+
if let (Ok(season), Ok(episode)) = (s.as_str().parse(), e.as_str().parse()) {
717+
return (season, episode);
718+
}
719+
}
720+
}
721+
722+
// If no episode pattern found, use large values to sort at end
723+
(u32::MAX, u32::MAX)
724+
}
725+
}
726+
727+
/// Sort video files by episode number (for season packs)
728+
pub fn sort_episodes(files: &mut [VideoFile]) {
729+
files.sort_by_key(|f| f.episode_sort_key());
730+
}
731+
665732
#[derive(Debug, Clone)]
666733
pub struct TorrentInfo {
667734
pub id: usize,
@@ -811,4 +878,72 @@ mod tests {
811878
assert!(!is_subtitle_file("movie.txt"));
812879
assert!(!is_subtitle_file("movie.nfo"));
813880
}
881+
882+
#[test]
883+
fn test_episode_sort_key() {
884+
let file1 = VideoFile {
885+
name: "Show.S01E01.720p.mkv".to_string(),
886+
file_idx: 0,
887+
size: 1000,
888+
stream_url: String::new(),
889+
};
890+
let file2 = VideoFile {
891+
name: "Show.S01E02.720p.mkv".to_string(),
892+
file_idx: 1,
893+
size: 1000,
894+
stream_url: String::new(),
895+
};
896+
let file10 = VideoFile {
897+
name: "Show.S01E10.720p.mkv".to_string(),
898+
file_idx: 2,
899+
size: 1000,
900+
stream_url: String::new(),
901+
};
902+
let file_s2 = VideoFile {
903+
name: "Show.S02E01.720p.mkv".to_string(),
904+
file_idx: 3,
905+
size: 1000,
906+
stream_url: String::new(),
907+
};
908+
909+
assert_eq!(file1.episode_sort_key(), (1, 1));
910+
assert_eq!(file2.episode_sort_key(), (1, 2));
911+
assert_eq!(file10.episode_sort_key(), (1, 10));
912+
assert_eq!(file_s2.episode_sort_key(), (2, 1));
913+
914+
// Verify sorting order
915+
assert!(file1.episode_sort_key() < file2.episode_sort_key());
916+
assert!(file2.episode_sort_key() < file10.episode_sort_key());
917+
assert!(file10.episode_sort_key() < file_s2.episode_sort_key());
918+
}
919+
920+
#[test]
921+
fn test_sort_episodes() {
922+
let mut files = vec![
923+
VideoFile {
924+
name: "Show.S01E03.mkv".to_string(),
925+
file_idx: 0,
926+
size: 1000,
927+
stream_url: String::new(),
928+
},
929+
VideoFile {
930+
name: "Show.S01E01.mkv".to_string(),
931+
file_idx: 1,
932+
size: 1000,
933+
stream_url: String::new(),
934+
},
935+
VideoFile {
936+
name: "Show.S01E02.mkv".to_string(),
937+
file_idx: 2,
938+
size: 1000,
939+
stream_url: String::new(),
940+
},
941+
];
942+
943+
sort_episodes(&mut files);
944+
945+
assert!(files[0].name.contains("E01"));
946+
assert!(files[1].name.contains("E02"));
947+
assert!(files[2].name.contains("E03"));
948+
}
814949
}

src/tmdb.rs

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,72 @@ struct SearchResponse {
5858
results: Vec<SearchResult>,
5959
}
6060

61+
/// TV show details including seasons
62+
#[derive(Debug, Clone, Deserialize)]
63+
pub struct TvDetails {
64+
pub id: u64,
65+
pub name: String,
66+
pub overview: Option<String>,
67+
pub first_air_date: Option<String>,
68+
pub poster_path: Option<String>,
69+
pub number_of_seasons: u32,
70+
pub number_of_episodes: u32,
71+
pub seasons: Vec<SeasonSummary>,
72+
}
73+
74+
/// Summary of a season (from TV details)
75+
#[derive(Debug, Clone, Deserialize)]
76+
pub struct SeasonSummary {
77+
pub id: u64,
78+
pub name: String,
79+
pub season_number: u32,
80+
pub episode_count: u32,
81+
pub air_date: Option<String>,
82+
pub poster_path: Option<String>,
83+
pub overview: Option<String>,
84+
}
85+
86+
/// Full season details with episodes
87+
#[derive(Debug, Clone, Deserialize)]
88+
pub struct SeasonDetails {
89+
pub id: u64,
90+
pub name: String,
91+
pub season_number: u32,
92+
pub air_date: Option<String>,
93+
pub overview: Option<String>,
94+
pub poster_path: Option<String>,
95+
pub episodes: Vec<Episode>,
96+
}
97+
98+
/// Episode details
99+
#[derive(Debug, Clone, Deserialize)]
100+
pub struct Episode {
101+
pub id: u64,
102+
pub name: String,
103+
pub episode_number: u32,
104+
pub season_number: u32,
105+
pub air_date: Option<String>,
106+
pub overview: Option<String>,
107+
pub still_path: Option<String>,
108+
pub runtime: Option<u32>,
109+
pub vote_average: Option<f64>,
110+
}
111+
112+
impl Episode {
113+
/// Format as "S01E02 - Episode Name"
114+
pub fn display_title(&self) -> String {
115+
format!(
116+
"S{:02}E{:02} - {}",
117+
self.season_number, self.episode_number, self.name
118+
)
119+
}
120+
121+
/// Format for Prowlarr search query
122+
pub fn search_query(&self, show_name: &str) -> String {
123+
format!("{} S{:02}E{:02}", show_name, self.season_number, self.episode_number)
124+
}
125+
}
126+
61127
pub struct TmdbClient {
62128
client: Client,
63129
api_key: String,
@@ -143,6 +209,38 @@ impl TmdbClient {
143209

144210
Ok(response.results)
145211
}
212+
213+
/// Get TV show details including list of seasons
214+
pub async fn get_tv_details(&self, tv_id: u64) -> Result<TvDetails, TmdbError> {
215+
let url = format!(
216+
"{}/3/tv/{}?api_key={}",
217+
self.base_url, tv_id, self.api_key
218+
);
219+
220+
debug!(tv_id, "fetching TV details");
221+
222+
let response: TvDetails = self.client.get(&url).send().await?.json().await?;
223+
224+
Ok(response)
225+
}
226+
227+
/// Get season details with all episodes
228+
pub async fn get_season_details(
229+
&self,
230+
tv_id: u64,
231+
season_number: u32,
232+
) -> Result<SeasonDetails, TmdbError> {
233+
let url = format!(
234+
"{}/3/tv/{}/season/{}?api_key={}",
235+
self.base_url, tv_id, season_number, self.api_key
236+
);
237+
238+
debug!(tv_id, season_number, "fetching season details");
239+
240+
let response: SeasonDetails = self.client.get(&url).send().await?.json().await?;
241+
242+
Ok(response)
243+
}
146244
}
147245

148246
/// Try to extract a clean title and year from a torrent name

0 commit comments

Comments
 (0)