Skip to content

Commit 08467f9

Browse files
committed
regex lazily
1 parent 8f3424c commit 08467f9

5 files changed

Lines changed: 260 additions & 20 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ which = "8"
3838
discord-rich-presence = "1.0"
3939
futures = "0.3"
4040
itertools = "0.13"
41+
once_cell = "1.20"
4142
unicode-truncate = "2.0"
4243
unicode-width = "0.2"
4344
uuid = { version = "1", features = ["v4"] }

src/extensions/mod.rs

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,16 @@ pub mod trakt;
44
pub use discord::DiscordExtension;
55
pub use trakt::TraktExtension;
66

7+
use once_cell::sync::Lazy;
8+
use regex::Regex;
9+
10+
// Compiled regexes for episode parsing (compiled once at startup)
11+
static SXEX_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?i)[Ss](\d{1,2})[Ee](\d{1,3})").unwrap());
12+
static X_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?i)(\d{1,2})x(\d{1,3})").unwrap());
13+
static FULL_RE: Lazy<Regex> =
14+
Lazy::new(|| Regex::new(r"(?i)season[.\s]*(\d{1,2}).*episode[.\s]*(\d{1,3})").unwrap());
15+
static COMPACT_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"[.\s](\d{1,2})(\d{2})[.\s]").unwrap());
16+
717
/// Information about the currently playing media
818
#[derive(Debug, Clone)]
919
pub struct MediaInfo {
@@ -32,29 +42,24 @@ pub struct MediaInfo {
3242
/// - Season 1 Episode 2
3343
/// - .102. (season 1, episode 02)
3444
pub fn parse_episode_info(filename: &str) -> (Option<u32>, Option<u32>) {
35-
use regex::Regex;
36-
3745
// S01E02, S1E2 format (most common)
38-
let sxex_re = Regex::new(r"(?i)[Ss](\d{1,2})[Ee](\d{1,3})").unwrap();
39-
if let Some(caps) = sxex_re.captures(filename)
46+
if let Some(caps) = SXEX_RE.captures(filename)
4047
&& let (Some(s), Some(e)) = (caps.get(1), caps.get(2))
4148
&& let (Ok(season), Ok(episode)) = (s.as_str().parse(), e.as_str().parse())
4249
{
4350
return (Some(season), Some(episode));
4451
}
4552

4653
// 1x02, 01x02 format
47-
let x_re = Regex::new(r"(?i)(\d{1,2})x(\d{1,3})").unwrap();
48-
if let Some(caps) = x_re.captures(filename)
54+
if let Some(caps) = X_RE.captures(filename)
4955
&& let (Some(s), Some(e)) = (caps.get(1), caps.get(2))
5056
&& let (Ok(season), Ok(episode)) = (s.as_str().parse(), e.as_str().parse())
5157
{
5258
return (Some(season), Some(episode));
5359
}
5460

5561
// Season 1 Episode 2 format (also handles dots instead of spaces)
56-
let full_re = Regex::new(r"(?i)season[.\s]*(\d{1,2}).*episode[.\s]*(\d{1,3})").unwrap();
57-
if let Some(caps) = full_re.captures(filename)
62+
if let Some(caps) = FULL_RE.captures(filename)
5863
&& let (Some(s), Some(e)) = (caps.get(1), caps.get(2))
5964
&& let (Ok(season), Ok(episode)) = (s.as_str().parse(), e.as_str().parse())
6065
{
@@ -63,8 +68,7 @@ pub fn parse_episode_info(filename: &str) -> (Option<u32>, Option<u32>) {
6368

6469
// .102. or .1002. format (season 1, episode 02 or season 10, episode 02)
6570
// Must be surrounded by dots/spaces to avoid matching years
66-
let compact_re = Regex::new(r"[.\s](\d{1,2})(\d{2})[.\s]").unwrap();
67-
if let Some(caps) = compact_re.captures(filename)
71+
if let Some(caps) = COMPACT_RE.captures(filename)
6872
&& let (Some(s), Some(e)) = (caps.get(1), caps.get(2))
6973
&& let (Ok(season), Ok(episode)) = (s.as_str().parse::<u32>(), e.as_str().parse::<u32>())
7074
{

src/streaming.rs

Lines changed: 238 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,20 @@ use std::time::Duration;
77
use librqbit::api::Api;
88
use librqbit::http_api::{HttpApi, HttpApiOptions};
99
use librqbit::{AddTorrent, AddTorrentOptions, AddTorrentResponse, Session, SessionOptions};
10+
use once_cell::sync::Lazy;
11+
use regex::Regex;
1012
use reqwest::Client;
1113
use thiserror::Error;
1214
use tokio::net::TcpListener;
1315
use tokio::process::Command;
1416
use tokio::time::timeout;
1517
use tracing::{debug, info};
1618

19+
// Compiled regexes for episode parsing (compiled once at startup)
20+
static EPISODE_SXEX_RE: Lazy<Regex> =
21+
Lazy::new(|| Regex::new(r"(?i)[Ss](\d{1,2})[Ee](\d{1,3})").unwrap());
22+
static EPISODE_X_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?i)(\d{1,2})x(\d{1,3})").unwrap());
23+
1724
#[derive(Error, Debug)]
1825
pub enum StreamError {
1926
#[error("failed to create streaming session: {0}")]
@@ -860,20 +867,16 @@ pub struct VideoFile {
860867
impl VideoFile {
861868
/// Extract season and episode numbers from filename for sorting
862869
pub fn episode_sort_key(&self) -> (u32, u32) {
863-
use regex::Regex;
864-
865870
// S01E02 format
866-
let sxex_re = Regex::new(r"(?i)[Ss](\d{1,2})[Ee](\d{1,3})").unwrap();
867-
if let Some(caps) = sxex_re.captures(&self.name)
871+
if let Some(caps) = EPISODE_SXEX_RE.captures(&self.name)
868872
&& let (Some(s), Some(e)) = (caps.get(1), caps.get(2))
869873
&& let (Ok(season), Ok(episode)) = (s.as_str().parse(), e.as_str().parse())
870874
{
871875
return (season, episode);
872876
}
873877

874878
// 1x02 format
875-
let x_re = Regex::new(r"(?i)(\d{1,2})x(\d{1,3})").unwrap();
876-
if let Some(caps) = x_re.captures(&self.name)
879+
if let Some(caps) = EPISODE_X_RE.captures(&self.name)
877880
&& let (Some(s), Some(e)) = (caps.get(1), caps.get(2))
878881
&& let (Ok(season), Ok(episode)) = (s.as_str().parse(), e.as_str().parse())
879882
{
@@ -1225,4 +1228,233 @@ mod tests {
12251228
assert!(v.matches("Spider-Man.No.Way.Home.2021.mkv"));
12261229
assert!(v.matches("The.Amazing.Spider-Man.2021.mkv")); // "spider" matches
12271230
}
1231+
1232+
#[test]
1233+
fn test_torrent_validation_empty_keywords() {
1234+
// Empty keywords should match everything (year still applies)
1235+
let v = TorrentValidation::new(vec![], Some(2024));
1236+
assert!(v.matches("Anything.2024.mkv"));
1237+
assert!(!v.matches("Anything.2023.mkv"));
1238+
1239+
// Empty keywords, no year - matches everything
1240+
let v = TorrentValidation::new(vec![], None);
1241+
assert!(v.matches("Anything.mkv"));
1242+
assert!(v.matches("Random.File.2024.mkv"));
1243+
}
1244+
1245+
#[test]
1246+
fn test_torrent_validation_case_insensitive() {
1247+
let v = TorrentValidation::new(vec!["matrix".to_string()], Some(1999));
1248+
assert!(v.matches("The.MATRIX.1999.mkv"));
1249+
assert!(v.matches("matrix.1999.mkv"));
1250+
assert!(v.matches("MATRIX.1999.MKV"));
1251+
}
1252+
1253+
#[test]
1254+
fn test_torrent_validation_partial_match() {
1255+
let v = TorrentValidation::new(vec!["inception".to_string()], Some(2010));
1256+
assert!(v.matches("Inception.2010.1080p.mkv"));
1257+
// Should match if keyword is substring
1258+
assert!(v.matches("The.Inception.Of.Dreams.2010.mkv"));
1259+
}
1260+
1261+
#[test]
1262+
fn test_extract_keywords_filters_short_words() {
1263+
let kw = TorrentValidation::extract_keywords("Go To It Now");
1264+
assert!(!kw.contains(&"go".to_string())); // Too short (2 chars)
1265+
assert!(!kw.contains(&"to".to_string())); // Stop word
1266+
assert!(!kw.contains(&"it".to_string())); // Too short
1267+
assert!(kw.contains(&"now".to_string())); // 3 chars minimum
1268+
}
1269+
1270+
#[test]
1271+
fn test_extract_keywords_handles_special_chars() {
1272+
let kw = TorrentValidation::extract_keywords("Batman: The Dark Knight!");
1273+
assert!(kw.contains(&"batman".to_string()));
1274+
assert!(kw.contains(&"dark".to_string()));
1275+
assert!(kw.contains(&"knight".to_string()));
1276+
}
1277+
1278+
#[test]
1279+
fn test_extract_keywords_filters_years() {
1280+
// 4-digit numbers should be filtered out
1281+
let kw = TorrentValidation::extract_keywords("Movie 2024 2023 1999");
1282+
assert!(!kw.contains(&"2024".to_string()));
1283+
assert!(!kw.contains(&"2023".to_string()));
1284+
assert!(!kw.contains(&"1999".to_string()));
1285+
1286+
// But non-year numbers are OK
1287+
let kw = TorrentValidation::extract_keywords("Apollo 13 Mission");
1288+
assert!(kw.contains(&"apollo".to_string()));
1289+
assert!(kw.contains(&"mission".to_string()));
1290+
// "13" is filtered (too short)
1291+
}
1292+
1293+
#[test]
1294+
fn test_episode_sort_key_1x02_format() {
1295+
let file = VideoFile {
1296+
name: "Show.1x05.720p.mkv".to_string(),
1297+
file_idx: 0,
1298+
size: 1000,
1299+
stream_url: String::new(),
1300+
};
1301+
assert_eq!(file.episode_sort_key(), (1, 5));
1302+
}
1303+
1304+
#[test]
1305+
fn test_episode_sort_key_no_pattern() {
1306+
let file = VideoFile {
1307+
name: "Movie.2024.1080p.mkv".to_string(),
1308+
file_idx: 0,
1309+
size: 1000,
1310+
stream_url: String::new(),
1311+
};
1312+
// Should return max values when no episode pattern found
1313+
assert_eq!(file.episode_sort_key(), (u32::MAX, u32::MAX));
1314+
}
1315+
1316+
#[test]
1317+
fn test_episode_sort_key_double_digit_season() {
1318+
let file = VideoFile {
1319+
name: "Show.S12E34.mkv".to_string(),
1320+
file_idx: 0,
1321+
size: 1000,
1322+
stream_url: String::new(),
1323+
};
1324+
assert_eq!(file.episode_sort_key(), (12, 34));
1325+
}
1326+
1327+
#[test]
1328+
fn test_video_file_extensions() {
1329+
assert!(is_video_file("test.mp4"));
1330+
assert!(is_video_file("test.mkv"));
1331+
assert!(is_video_file("test.avi"));
1332+
assert!(is_video_file("test.mov"));
1333+
assert!(is_video_file("test.wmv"));
1334+
assert!(is_video_file("test.flv"));
1335+
assert!(is_video_file("test.webm"));
1336+
assert!(is_video_file("test.m4v"));
1337+
1338+
// Case insensitive
1339+
assert!(is_video_file("test.MKV"));
1340+
assert!(is_video_file("test.Mp4"));
1341+
}
1342+
1343+
#[test]
1344+
fn test_subtitle_file_extensions() {
1345+
assert!(is_subtitle_file("test.srt"));
1346+
assert!(is_subtitle_file("test.ass"));
1347+
assert!(is_subtitle_file("test.ssa"));
1348+
assert!(is_subtitle_file("test.sub"));
1349+
assert!(is_subtitle_file("test.vtt"));
1350+
1351+
// Case insensitive
1352+
assert!(is_subtitle_file("test.SRT"));
1353+
assert!(is_subtitle_file("test.Ass"));
1354+
}
1355+
1356+
#[test]
1357+
fn test_extract_subtitle_language_dotted_format() {
1358+
assert_eq!(
1359+
extract_subtitle_language("Movie.en.srt"),
1360+
Some("en".to_string())
1361+
);
1362+
assert_eq!(
1363+
extract_subtitle_language("Movie.es.srt"),
1364+
Some("es".to_string())
1365+
);
1366+
assert_eq!(
1367+
extract_subtitle_language("Movie.fr.srt"),
1368+
Some("fr".to_string())
1369+
);
1370+
}
1371+
1372+
#[test]
1373+
fn test_extract_subtitle_language_priority() {
1374+
// Should match first pattern found (english before eng before .en.)
1375+
let result = extract_subtitle_language("Movie.english.eng.en.srt");
1376+
assert_eq!(result, Some("en".to_string()));
1377+
}
1378+
1379+
#[test]
1380+
fn test_extract_subtitle_language_case_insensitive() {
1381+
assert_eq!(
1382+
extract_subtitle_language("Movie.ENGLISH.srt"),
1383+
Some("en".to_string())
1384+
);
1385+
assert_eq!(
1386+
extract_subtitle_language("Movie.ENG.srt"),
1387+
Some("en".to_string())
1388+
);
1389+
}
1390+
1391+
#[test]
1392+
fn test_calculate_progress() {
1393+
assert_eq!(calculate_progress(50.0, 100.0), 50.0);
1394+
assert_eq!(calculate_progress(0.0, 100.0), 0.0);
1395+
assert_eq!(calculate_progress(100.0, 100.0), 100.0);
1396+
assert_eq!(calculate_progress(150.0, 100.0), 100.0); // Clamped to 100
1397+
assert_eq!(calculate_progress(50.0, 0.0), 0.0); // Division by zero guard
1398+
}
1399+
1400+
#[test]
1401+
fn test_sort_episodes_mixed_seasons() {
1402+
let mut files = vec![
1403+
VideoFile {
1404+
name: "Show.S02E01.mkv".to_string(),
1405+
file_idx: 0,
1406+
size: 1000,
1407+
stream_url: String::new(),
1408+
},
1409+
VideoFile {
1410+
name: "Show.S01E03.mkv".to_string(),
1411+
file_idx: 1,
1412+
size: 1000,
1413+
stream_url: String::new(),
1414+
},
1415+
VideoFile {
1416+
name: "Show.S01E01.mkv".to_string(),
1417+
file_idx: 2,
1418+
size: 1000,
1419+
stream_url: String::new(),
1420+
},
1421+
];
1422+
1423+
sort_episodes(&mut files);
1424+
1425+
assert!(files[0].name.contains("S01E01"));
1426+
assert!(files[1].name.contains("S01E03"));
1427+
assert!(files[2].name.contains("S02E01"));
1428+
}
1429+
1430+
#[test]
1431+
fn test_sort_episodes_movies_at_end() {
1432+
let mut files = vec![
1433+
VideoFile {
1434+
name: "Movie.2024.mkv".to_string(),
1435+
file_idx: 0,
1436+
size: 1000,
1437+
stream_url: String::new(),
1438+
},
1439+
VideoFile {
1440+
name: "Show.S01E02.mkv".to_string(),
1441+
file_idx: 1,
1442+
size: 1000,
1443+
stream_url: String::new(),
1444+
},
1445+
VideoFile {
1446+
name: "Show.S01E01.mkv".to_string(),
1447+
file_idx: 2,
1448+
size: 1000,
1449+
stream_url: String::new(),
1450+
},
1451+
];
1452+
1453+
sort_episodes(&mut files);
1454+
1455+
// Episodes should come first, movie last (u32::MAX sort key)
1456+
assert!(files[0].name.contains("S01E01"));
1457+
assert!(files[1].name.contains("S01E02"));
1458+
assert!(files[2].name.contains("Movie"));
1459+
}
12281460
}

src/tmdb.rs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
use itertools::Itertools;
2+
use once_cell::sync::Lazy;
3+
use regex::Regex;
24
use reqwest::Client;
35
use serde::Deserialize;
46
use thiserror::Error;
@@ -9,6 +11,9 @@ use tracing::debug;
911
// At compile time, set TMDB_API_KEY env var to embed it, otherwise users must provide in config
1012
const EMBEDDED_API_KEY: Option<&str> = option_env!("TMDB_API_KEY");
1113

14+
// Compiled regex for year extraction (compiled once at startup)
15+
static YEAR_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"\b(19|20)\d{2}\b").unwrap());
16+
1217
#[derive(Error, Debug)]
1318
pub enum TmdbError {
1419
#[error("request failed: {0}")]
@@ -398,10 +403,7 @@ pub fn parse_torrent_title(torrent_name: &str) -> (String, Option<u16>) {
398403
name = name.replace(['.', '_'], " ");
399404

400405
// Try to find a year (1900-2099)
401-
let year_regex = regex::Regex::new(r"\b(19|20)\d{2}\b").ok();
402-
let year: Option<u16> = year_regex
403-
.and_then(|re| re.find(&name))
404-
.and_then(|m| m.as_str().parse().ok());
406+
let year: Option<u16> = YEAR_RE.find(&name).and_then(|m| m.as_str().parse().ok());
405407

406408
// Remove everything after the year (usually quality info)
407409
if let Some(y) = year

0 commit comments

Comments
 (0)