diff --git a/src/core/config.rs b/src/core/config.rs new file mode 100644 index 0000000..731fbe4 --- /dev/null +++ b/src/core/config.rs @@ -0,0 +1,103 @@ +use std::{fs, io, path::PathBuf}; + +use super::paths; + +#[derive(Debug, Clone)] +pub struct Config { + pub base_url: String, + pub access_token: Option, + pub user_id: Option, +} + + +#[derive(Debug)] +pub enum ConfigError { + NotFound(PathBuf), + Io(io::Error), + Parse(String), +} + +impl From for ConfigError { + fn from(e: io::Error) -> Self { + Self::Io(e) + } +} + +pub fn config_path() -> Option { + paths::app_config_dir().map(|d| d.join("config.toml")) +} + +// Very small TOML-ish parser for now (no deps). +// Format: +// base_url = "http://..." +// access_token = "..." +pub fn load_config() -> Result { + let path = config_path().ok_or_else(|| ConfigError::Parse("No HOME/XDG dirs".into()))?; + if !path.exists() { + return Err(ConfigError::NotFound(path)); + } + + let s = fs::read_to_string(&path)?; + parse_config(&s).map_err(ConfigError::Parse) +} + +fn parse_config(s: &str) -> Result { + let mut base_url: Option = None; + let mut access_token: Option = None; + let mut user_id: Option = None; + + for (i, line) in s.lines().enumerate() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + + let (k, v) = line + .split_once('=') + .ok_or_else(|| format!("Line {}: expected key = value", i + 1))?; + + let key = k.trim(); + let mut val = v.trim().to_string(); + + // strip quotes if present + if val.starts_with('"') && val.ends_with('"') && val.len() >= 2 { + val = val[1..val.len() - 1].to_string(); + } + + match key { + "base_url" => base_url = Some(val), + "access_token" => access_token = Some(val), + "user_id" => user_id = Some(val), + _ => {} + } + } + + let base_url = base_url.ok_or_else(|| "Missing base_url".to_string())?; + + Ok(Config { + base_url, + access_token, + user_id, + }) +} + +pub fn save_config(cfg: &Config) -> Result<(), ConfigError> { + let dir = paths::app_config_dir() + .ok_or_else(|| ConfigError::Parse("No HOME/XDG dirs".into()))?; + std::fs::create_dir_all(&dir)?; + + let path = dir.join("config.toml"); + + let mut out = String::new(); + out.push_str(&format!("base_url = \"{}\"\n", cfg.base_url)); + + if let Some(t) = &cfg.access_token { + out.push_str(&format!("access_token = \"{}\"\n", t)); + } + if let Some(u) = &cfg.user_id { + out.push_str(&format!("user_id = \"{}\"\n", u)); + } + + std::fs::write(path, out)?; + Ok(()) +} diff --git a/src/core/jellyfin.rs b/src/core/jellyfin.rs new file mode 100644 index 0000000..9c5d5f1 --- /dev/null +++ b/src/core/jellyfin.rs @@ -0,0 +1,33 @@ +use super::config::Config; + +#[derive(Clone)] +pub struct JellyfinClient { + pub cfg: Config, +} + +#[derive(Debug, Clone)] +pub struct JfItem { + pub id: String, + pub name: String, + pub item_type: String, // "Movie", "Series", etc. + pub image_tag: Option, +} + +impl JellyfinClient { + pub fn new(cfg: Config) -> Self { + Self { cfg } + } + + // Placeholder for now — next step will use reqwest and actually call Jellyfin. + pub fn test_connection_stub(&self) -> Result { + Ok(format!("Configured for {}", self.cfg.base_url)) + } + + // Placeholder list so we can wire UI without network yet + pub fn list_movies_stub(&self) -> Vec { + vec![ + JfItem { id: "1".into(), name: "Movie A".into(), item_type: "Movie".into(), image_tag: None }, + JfItem { id: "2".into(), name: "Movie B".into(), item_type: "Movie".into(), image_tag: None }, + ] + } +} diff --git a/src/core/mod.rs b/src/core/mod.rs new file mode 100644 index 0000000..e357c3e --- /dev/null +++ b/src/core/mod.rs @@ -0,0 +1,3 @@ +pub mod config; +pub mod jellyfin; +pub mod paths; \ No newline at end of file diff --git a/src/core/paths.rs b/src/core/paths.rs new file mode 100644 index 0000000..d527cba --- /dev/null +++ b/src/core/paths.rs @@ -0,0 +1,35 @@ +use std::{env, path::PathBuf}; + +pub fn home_dir() -> Option { + env::var_os("HOME").map(PathBuf::from) +} + +pub fn config_dir() -> Option { + // XDG first, fallback to ~/.config + if let Some(p) = env::var_os("XDG_CONFIG_HOME").map(PathBuf::from) { + Some(p) + } else { + home_dir().map(|h| h.join(".config")) + } +} + +pub fn cache_dir() -> Option { + // XDG first, fallback to ~/.cache + if let Some(p) = env::var_os("XDG_CACHE_HOME").map(PathBuf::from) { + Some(p) + } else { + home_dir().map(|h| h.join(".cache")) + } +} + +pub fn app_config_dir() -> Option { + config_dir().map(|d| d.join("cj-jftui")) +} + +pub fn app_cache_dir() -> Option { + cache_dir().map(|d| d.join("cj-jftui")) +} + +pub fn posters_cache_dir() -> Option { + app_cache_dir().map(|d| d.join("posters")) +} \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index b7cc6fa..6dc1f39 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,5 @@ +mod core; + use std::{ io, time::{Duration, Instant}, @@ -26,6 +28,7 @@ enum View { struct App { should_exit: bool, status: String, + config_status: String, view_stack: Vec, @@ -35,11 +38,16 @@ struct App { impl Default for App { fn default() -> Self { + let config_status = match core::config::load_config() { + Ok(cfg) => format!("config: ok ({})", cfg.base_url), + Err(e) => format!("config: missing/invalid ({:?})", e), + }; + Self { should_exit: false, status: "Press q to quit".to_string(), + config_status, view_stack: vec![View::Categories], - items: vec![ "Shows".into(), "Movies".into(), @@ -178,7 +186,8 @@ fn ui(frame: &mut Frame, app: &App) { panes.left_output, ); frame.render_widget( - Block::bordered().title("Info").merge_borders(MergeStrategy::Exact), + Paragraph::new(app.config_status.as_str()) + .block(Block::bordered().title("Info").merge_borders(MergeStrategy::Exact)), panes.right_output, ); }