Skip to content

Commit f2618fb

Browse files
committed
support config editing and create default if no exist
1 parent c7c4e57 commit f2618fb

7 files changed

Lines changed: 648 additions & 207 deletions

File tree

src/config.rs

Lines changed: 55 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use directories::ProjectDirs;
2-
use serde::Deserialize;
2+
use serde::{Deserialize, Serialize};
33
use std::path::PathBuf;
44
use thiserror::Error;
55

@@ -17,7 +17,7 @@ pub enum ConfigError {
1717
ValidationError(String),
1818
}
1919

20-
#[derive(Debug, Clone, Deserialize)]
20+
#[derive(Debug, Clone, Deserialize, Serialize)]
2121
pub struct Config {
2222
pub prowlarr: ProwlarrConfig,
2323
pub tmdb: Option<TmdbConfig>,
@@ -31,47 +31,51 @@ pub struct Config {
3131
pub subtitles: SubtitlesConfig,
3232
}
3333

34-
#[derive(Debug, Clone, Default, Deserialize)]
34+
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
3535
pub struct ExtensionsConfig {
3636
#[serde(default)]
3737
pub discord: DiscordConfig,
3838
#[serde(default)]
3939
pub trakt: TraktConfig,
4040
}
4141

42-
#[derive(Debug, Clone, Default, Deserialize)]
42+
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
4343
pub struct DiscordConfig {
4444
#[serde(default)]
4545
pub enabled: bool,
46+
#[serde(skip_serializing_if = "Option::is_none")]
4647
pub app_id: Option<String>,
4748
}
4849

49-
#[derive(Debug, Clone, Default, Deserialize)]
50+
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
5051
pub struct TraktConfig {
5152
#[serde(default)]
5253
pub enabled: bool,
54+
#[serde(skip_serializing_if = "Option::is_none")]
5355
pub client_id: Option<String>,
56+
#[serde(skip_serializing_if = "Option::is_none")]
5457
pub access_token: Option<String>,
5558
}
5659

57-
#[derive(Debug, Clone, Deserialize)]
60+
#[derive(Debug, Clone, Deserialize, Serialize)]
5861
pub struct ProwlarrConfig {
5962
pub url: String,
6063
pub apikey: String,
6164
}
6265

63-
#[derive(Debug, Clone, Deserialize)]
66+
#[derive(Debug, Clone, Deserialize, Serialize)]
6467
pub struct TmdbConfig {
6568
pub apikey: String,
6669
}
6770

68-
#[derive(Debug, Clone, Deserialize)]
71+
#[derive(Debug, Clone, Deserialize, Serialize)]
6972
pub struct SubtitlesConfig {
7073
#[serde(default = "default_subtitles_enabled")]
7174
pub enabled: bool,
7275
#[serde(default = "default_subtitle_language")]
7376
pub language: String,
7477
/// OpenSubtitles API key for fetching subtitles when not included in torrent
78+
#[serde(skip_serializing_if = "Option::is_none")]
7579
pub opensubtitles_api_key: Option<String>,
7680
}
7781

@@ -93,11 +97,11 @@ fn default_subtitle_language() -> String {
9397
"en".to_string()
9498
}
9599

96-
#[derive(Debug, Clone, Deserialize)]
100+
#[derive(Debug, Clone, Deserialize, Serialize)]
97101
pub struct PlayerConfig {
98102
#[serde(default = "default_player_command")]
99103
pub command: String,
100-
#[serde(default)]
104+
#[serde(default, skip_serializing_if = "Vec::is_empty")]
101105
pub args: Vec<String>,
102106
}
103107

@@ -114,8 +118,9 @@ fn default_player_command() -> String {
114118
"mpv".to_string()
115119
}
116120

117-
#[derive(Default, Debug, Clone, Deserialize)]
121+
#[derive(Default, Debug, Clone, Deserialize, Serialize)]
118122
pub struct StorageConfig {
123+
#[serde(skip_serializing_if = "Option::is_none")]
119124
pub temp_dir: Option<PathBuf>,
120125
}
121126

@@ -133,6 +138,17 @@ impl Config {
133138
Self::load_from(&path)
134139
}
135140

141+
/// Load config, creating a default one if it doesn't exist
142+
pub fn load_or_create() -> Result<Self, ConfigError> {
143+
let path = Self::config_path()?;
144+
if !path.exists() {
145+
let config = Self::default();
146+
config.save()?;
147+
return Ok(config);
148+
}
149+
Self::load_from(&path)
150+
}
151+
136152
pub fn load_from(path: &PathBuf) -> Result<Self, ConfigError> {
137153
if !path.exists() {
138154
return Err(ConfigError::NotFound(path.clone()));
@@ -150,6 +166,18 @@ impl Config {
150166
.ok_or(ConfigError::NoConfigDir)
151167
}
152168

169+
pub fn save(&self) -> Result<(), ConfigError> {
170+
let path = Self::config_path()?;
171+
// Ensure parent directory exists
172+
if let Some(parent) = path.parent() {
173+
std::fs::create_dir_all(parent)?;
174+
}
175+
let contents = toml::to_string_pretty(self)
176+
.map_err(|e| ConfigError::ValidationError(format!("failed to serialize: {}", e)))?;
177+
std::fs::write(&path, contents)?;
178+
Ok(())
179+
}
180+
153181
fn validate(&self) -> Result<(), ConfigError> {
154182
// Validate Prowlarr URL
155183
if self.prowlarr.url.is_empty() {
@@ -175,3 +203,19 @@ impl Config {
175203
Ok(())
176204
}
177205
}
206+
207+
impl Default for Config {
208+
fn default() -> Self {
209+
Self {
210+
prowlarr: ProwlarrConfig {
211+
url: "http://localhost:9696".to_string(),
212+
apikey: String::new(),
213+
},
214+
tmdb: None,
215+
player: PlayerConfig::default(),
216+
storage: StorageConfig::default(),
217+
extensions: ExtensionsConfig::default(),
218+
subtitles: SubtitlesConfig::default(),
219+
}
220+
}
221+
}

src/extensions/mod.rs

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,67 @@ pub struct MediaInfo {
1818
pub media_type: Option<String>,
1919
/// Poster URL from TMDB (for Discord RPC)
2020
pub poster_url: Option<String>,
21+
/// Season number (parsed from filename for TV shows)
22+
pub season: Option<u32>,
23+
/// Episode number (parsed from filename for TV shows)
24+
pub episode: Option<u32>,
25+
}
26+
27+
/// Parse season and episode number from a filename.
28+
///
29+
/// Supports common patterns:
30+
/// - S01E02, s01e02, S1E2
31+
/// - 1x02, 01x02
32+
/// - Season 1 Episode 2
33+
/// - .102. (season 1, episode 02)
34+
pub fn parse_episode_info(filename: &str) -> (Option<u32>, Option<u32>) {
35+
use regex::Regex;
36+
37+
// 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) {
40+
if let (Some(s), Some(e)) = (caps.get(1), caps.get(2)) {
41+
if let (Ok(season), Ok(episode)) = (s.as_str().parse(), e.as_str().parse()) {
42+
return (Some(season), Some(episode));
43+
}
44+
}
45+
}
46+
47+
// 1x02, 01x02 format
48+
let x_re = Regex::new(r"(?i)(\d{1,2})x(\d{1,3})").unwrap();
49+
if let Some(caps) = x_re.captures(filename) {
50+
if let (Some(s), Some(e)) = (caps.get(1), caps.get(2)) {
51+
if let (Ok(season), Ok(episode)) = (s.as_str().parse(), e.as_str().parse()) {
52+
return (Some(season), Some(episode));
53+
}
54+
}
55+
}
56+
57+
// Season 1 Episode 2 format (also handles dots instead of spaces)
58+
let full_re = Regex::new(r"(?i)season[.\s]*(\d{1,2}).*episode[.\s]*(\d{1,3})").unwrap();
59+
if let Some(caps) = full_re.captures(filename) {
60+
if let (Some(s), Some(e)) = (caps.get(1), caps.get(2)) {
61+
if let (Ok(season), Ok(episode)) = (s.as_str().parse(), e.as_str().parse()) {
62+
return (Some(season), Some(episode));
63+
}
64+
}
65+
}
66+
67+
// .102. or .1002. format (season 1, episode 02 or season 10, episode 02)
68+
// Must be surrounded by dots/spaces to avoid matching years
69+
let compact_re = Regex::new(r"[.\s](\d)(\d{2})[.\s]").unwrap();
70+
if let Some(caps) = compact_re.captures(filename) {
71+
if let (Some(s), Some(e)) = (caps.get(1), caps.get(2)) {
72+
if let (Ok(season), Ok(episode)) = (s.as_str().parse::<u32>(), e.as_str().parse::<u32>()) {
73+
// Only valid if episode isn't too high (avoid matching years like 1999)
74+
if (1..=99).contains(&season) && (1..=99).contains(&episode) {
75+
return (Some(season), Some(episode));
76+
}
77+
}
78+
}
79+
}
80+
81+
(None, None)
2182
}
2283

2384
/// Playback event sent to extensions
@@ -101,3 +162,66 @@ impl Default for ExtensionManager {
101162
Self::new()
102163
}
103164
}
165+
166+
#[cfg(test)]
167+
mod tests {
168+
use super::*;
169+
170+
#[test]
171+
fn test_parse_episode_sxex_format() {
172+
assert_eq!(
173+
parse_episode_info("Show.Name.S01E02.720p.HDTV.mkv"),
174+
(Some(1), Some(2))
175+
);
176+
assert_eq!(
177+
parse_episode_info("Show.Name.S10E23.720p.mkv"),
178+
(Some(10), Some(23))
179+
);
180+
assert_eq!(
181+
parse_episode_info("show.s1e5.web.mp4"),
182+
(Some(1), Some(5))
183+
);
184+
}
185+
186+
#[test]
187+
fn test_parse_episode_x_format() {
188+
assert_eq!(
189+
parse_episode_info("Show.Name.1x02.HDTV.mkv"),
190+
(Some(1), Some(2))
191+
);
192+
assert_eq!(
193+
parse_episode_info("Show.Name.10x23.mkv"),
194+
(Some(10), Some(23))
195+
);
196+
}
197+
198+
#[test]
199+
fn test_parse_episode_full_format() {
200+
assert_eq!(
201+
parse_episode_info("Show Name Season 1 Episode 2.mkv"),
202+
(Some(1), Some(2))
203+
);
204+
assert_eq!(
205+
parse_episode_info("Show.Name.Season.3.Episode.15.mkv"),
206+
(Some(3), Some(15))
207+
);
208+
}
209+
210+
#[test]
211+
fn test_parse_episode_no_match() {
212+
assert_eq!(parse_episode_info("Movie.2019.1080p.BluRay.mkv"), (None, None));
213+
assert_eq!(parse_episode_info("Random.File.Name.mkv"), (None, None));
214+
}
215+
216+
#[test]
217+
fn test_parse_episode_case_insensitive() {
218+
assert_eq!(
219+
parse_episode_info("show.S01e02.mkv"),
220+
(Some(1), Some(2))
221+
);
222+
assert_eq!(
223+
parse_episode_info("show.s01E02.mkv"),
224+
(Some(1), Some(2))
225+
);
226+
}
227+
}

src/extensions/trakt.rs

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,8 +77,30 @@ impl TraktExtension {
7777
.is_some_and(|t| t == "tv" || t == "show");
7878

7979
if is_tv {
80-
// For TV shows, we'd need season/episode info which we don't have yet
81-
// Just scrobble as a show for now (Trakt may not accept this)
80+
// Build episode info if we have season/episode from filename
81+
let episode_info = match (media.season, media.episode) {
82+
(Some(s), Some(e)) => {
83+
tracing::debug!(
84+
title = %media.title,
85+
season = s,
86+
episode = e,
87+
"trakt: parsed episode info from filename"
88+
);
89+
Some(ScrobbleEpisode {
90+
season: s,
91+
number: e,
92+
})
93+
}
94+
_ => {
95+
tracing::debug!(
96+
title = %media.title,
97+
filename = %media.file_name,
98+
"trakt: no episode info found in filename"
99+
);
100+
None
101+
}
102+
};
103+
82104
Some(ScrobbleRequest {
83105
movie: None,
84106
show: Some(ScrobbleShow {
@@ -88,7 +110,7 @@ impl TraktExtension {
88110
tmdb: Some(tmdb_id),
89111
},
90112
}),
91-
episode: None, // TODO: Parse season/episode from filename
113+
episode: episode_info,
92114
progress,
93115
})
94116
} else {

src/main.rs

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -42,24 +42,24 @@ async fn main() {
4242
.init();
4343
}
4444

45-
let config = match Config::load() {
46-
Ok(config) => config,
45+
let (config, is_new) = match Config::load() {
46+
Ok(config) => (config, false),
47+
Err(config::ConfigError::NotFound(_)) => {
48+
// Config doesn't exist - create default and open settings
49+
match Config::load_or_create() {
50+
Ok(config) => {
51+
eprintln!("Created default config. Opening settings to configure...");
52+
std::thread::sleep(std::time::Duration::from_secs(1));
53+
(config, true)
54+
}
55+
Err(e) => {
56+
eprintln!("Failed to create config: {}", e);
57+
std::process::exit(1);
58+
}
59+
}
60+
}
4761
Err(e) => {
4862
eprintln!("Failed to load config: {}", e);
49-
if let config::ConfigError::NotFound(path) = &e {
50-
eprintln!("\nCreate a config file at: {}", path.display());
51-
eprintln!("\nExample config.toml:");
52-
eprintln!(
53-
r#"
54-
[prowlarr]
55-
url = "http://localhost:9696"
56-
apikey = "your-api-key"
57-
58-
[player]
59-
command = "mpv"
60-
"#
61-
);
62-
}
6363
std::process::exit(1);
6464
}
6565
};
@@ -80,7 +80,7 @@ command = "mpv"
8080
)));
8181
}
8282

83-
let result = tui::run(config, ext_manager).await;
83+
let result = tui::run(config, ext_manager, is_new).await;
8484

8585
if let Err(e) = result {
8686
eprintln!("Error: {}", e);

0 commit comments

Comments
 (0)