Skip to content

Commit cd89bd6

Browse files
authored
Fix general bugs (#7)
* validate before saving * isolate temp dirs so cleanup only removes the current session * add required user agent header for OS * validate and store only non-empty values for magneturl and infohash * return None from build_request for TV media when episode information is not available * limit season/episode to 1–99 * replace silentfallback with explicit error handling to avoid saving invalid timestamps * reset the field index when entering Settings * fix cursor misposition with unicode * fmt * regex lazily
1 parent 32d314b commit cd89bd6

12 files changed

Lines changed: 423 additions & 40 deletions

File tree

Cargo.lock

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

Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,10 @@ 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"
43+
unicode-width = "0.2"
44+
uuid = { version = "1", features = ["v4"] }
4245

4346
[dev-dependencies]
4447
wiremock = "0.6"

src/config.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,7 @@ impl Config {
188188
}
189189

190190
pub fn save(&self) -> Result<(), ConfigError> {
191+
self.validate()?;
191192
let path = Self::config_path()?;
192193
// Ensure parent directory exists
193194
if let Some(parent) = path.parent() {

src/extensions/mod.rs

Lines changed: 55 additions & 12 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,13 +68,17 @@ 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)(\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
{
71-
// Only valid if episode isn't too high (avoid matching years like 1999)
72-
if (1..=99).contains(&season) && (1..=99).contains(&episode) {
75+
// Reconstruct the full matched number to check if it's a year
76+
let full_number = season * 100 + episode;
77+
// Only valid if episode isn't too high and the full number isn't a year (1900-2099)
78+
if (1..=99).contains(&season)
79+
&& (1..=99).contains(&episode)
80+
&& !(1900..=2099).contains(&full_number)
81+
{
7382
return (Some(season), Some(episode));
7483
}
7584
}
@@ -214,4 +223,38 @@ mod tests {
214223
assert_eq!(parse_episode_info("show.S01e02.mkv"), (Some(1), Some(2)));
215224
assert_eq!(parse_episode_info("show.s01E02.mkv"), (Some(1), Some(2)));
216225
}
226+
227+
#[test]
228+
fn test_compact_format_season_10_episode_02() {
229+
// The comment in parse_episode_info states this format is supported
230+
let result = parse_episode_info("Show.Name.1002.mkv");
231+
232+
assert_eq!(
233+
result,
234+
(Some(10), Some(2)),
235+
"According to the comment, .1002. should parse as season 10, episode 2"
236+
);
237+
}
238+
239+
#[test]
240+
fn test_compact_format_single_digit_season() {
241+
// Verify single-digit seasons still work
242+
assert_eq!(parse_episode_info("Show.Name.102.mkv"), (Some(1), Some(2)));
243+
assert_eq!(parse_episode_info("Show.Name.315.mkv"), (Some(3), Some(15)));
244+
}
245+
246+
#[test]
247+
fn test_compact_format_rejects_years() {
248+
// Should not match years that look like they could be season/episode
249+
assert_eq!(
250+
parse_episode_info("Movie.1999.BluRay.mkv"),
251+
(None, None),
252+
"1999 should be treated as a year, not season 19 episode 99"
253+
);
254+
assert_eq!(
255+
parse_episode_info("Movie.2020.1080p.mkv"),
256+
(None, None),
257+
"2020 should be treated as a year, not season 20 episode 20"
258+
);
259+
}
217260
}

src/extensions/trakt.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -92,12 +92,12 @@ impl TraktExtension {
9292
})
9393
}
9494
_ => {
95-
tracing::debug!(
95+
tracing::warn!(
9696
title = %media.title,
9797
filename = %media.file_name,
98-
"trakt: no episode info found in filename"
98+
"trakt: cannot scrobble TV show without episode info"
9999
);
100-
None
100+
return None;
101101
}
102102
};
103103

src/history.rs

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -102,10 +102,13 @@ impl WatchHistory {
102102

103103
/// Update watch progress
104104
pub fn update(&mut self, key: String, title: String, progress_percent: f64) {
105-
let now = std::time::SystemTime::now()
106-
.duration_since(std::time::UNIX_EPOCH)
107-
.map(|d| d.as_secs())
108-
.unwrap_or(0);
105+
let now = match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {
106+
Ok(d) => d.as_secs(),
107+
Err(e) => {
108+
error!("failed to get system time, skipping history update: {}", e);
109+
return;
110+
}
111+
};
109112

110113
self.entries.insert(
111114
key,
@@ -143,10 +146,13 @@ impl WatchHistory {
143146

144147
/// Clear entries older than given days
145148
pub fn cleanup_old(&mut self, days: u64) {
146-
let now = std::time::SystemTime::now()
147-
.duration_since(std::time::UNIX_EPOCH)
148-
.map(|d| d.as_secs())
149-
.unwrap_or(0);
149+
let now = match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {
150+
Ok(d) => d.as_secs(),
151+
Err(e) => {
152+
error!("failed to get system time, skipping cleanup: {}", e);
153+
return;
154+
}
155+
};
150156
let cutoff = now.saturating_sub(days * 24 * 60 * 60);
151157

152158
self.entries.retain(|_, e| e.last_watched >= cutoff);

src/opensubtitles.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,15 @@ pub struct SubtitleDownload {
5454

5555
impl OpenSubtitlesClient {
5656
pub fn new(api_key: &str) -> Self {
57+
let user_agent = format!("{}/{}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"));
58+
59+
let client = Client::builder()
60+
.user_agent(user_agent)
61+
.build()
62+
.expect("Failed to build HTTP client");
63+
5764
Self {
58-
client: Client::new(),
65+
client,
5966
api_key: api_key.to_string(),
6067
}
6168
}

0 commit comments

Comments
 (0)