a Jellyfin & Subsonic client for the terminal — powered by mpv, Chromecast and UPnP MediaRenderer
mpv chromecast mpris navidrome jellyfin upnp tui
0

Configure Feed

Select the types of activity you want to include in your feed.

fin / crates / fin-config / src / lib.rs
7.6 kB 239 lines
1use std::fs; 2use std::path::PathBuf; 3 4use anyhow::{anyhow, Context, Result}; 5use directories::ProjectDirs; 6use serde::{Deserialize, Serialize}; 7 8#[derive(Debug, Clone, Serialize, Deserialize, Default)] 9pub struct Config { 10 /// Name of the active server (must exist in `servers`). 11 #[serde(default)] 12 pub current_server: Option<String>, 13 /// All servers the user has authenticated against. 14 #[serde(default)] 15 pub servers: Vec<ServerConfig>, 16 /// Legacy single-server slot. Kept only for one-shot migration; new 17 /// writes drop it. If both this and `servers` are present, `servers` 18 /// wins and this is ignored. 19 #[serde(default, skip_serializing_if = "Option::is_none")] 20 pub server: Option<LegacyServerConfig>, 21 #[serde(default)] 22 pub renderer: RendererPref, 23 #[serde(default)] 24 pub last_chromecast: Option<String>, 25 #[serde(default)] 26 pub client: ClientInfo, 27} 28 29#[derive(Debug, Clone, Serialize, Deserialize)] 30pub struct ServerConfig { 31 /// Short label the user picks (or auto-derived from the URL host). 32 pub name: String, 33 pub url: String, 34 pub user_id: String, 35 pub user_name: String, 36 pub access_token: String, 37 pub device_id: String, 38} 39 40/// The old, single-server shape we used before multi-server support landed. 41/// Loaded once, then folded into `servers` and never written again. 42#[derive(Debug, Clone, Serialize, Deserialize)] 43pub struct LegacyServerConfig { 44 pub url: String, 45 pub user_id: String, 46 pub user_name: String, 47 pub access_token: String, 48 pub device_id: String, 49} 50 51impl From<LegacyServerConfig> for ServerConfig { 52 fn from(l: LegacyServerConfig) -> Self { 53 let name = derive_server_name(&l.url); 54 Self { 55 name, 56 url: l.url, 57 user_id: l.user_id, 58 user_name: l.user_name, 59 access_token: l.access_token, 60 device_id: l.device_id, 61 } 62 } 63} 64 65/// Derive a short server name from a URL — the host, without the scheme. 66/// 67/// https://media.example.com:8096/path → "media.example.com" 68/// http://192.168.1.42 → "192.168.1.42" 69pub fn derive_server_name(url: &str) -> String { 70 let trimmed = url 71 .trim_start_matches("https://") 72 .trim_start_matches("http://"); 73 let host: &str = trimmed 74 .split(|c: char| c == '/' || c == ':') 75 .next() 76 .unwrap_or(trimmed); 77 if host.is_empty() { 78 "server".to_string() 79 } else { 80 host.to_string() 81 } 82} 83 84#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] 85#[serde(rename_all = "lowercase")] 86pub enum RendererPref { 87 #[default] 88 Mpv, 89 Chromecast, 90} 91 92impl RendererPref { 93 pub fn label(&self) -> &'static str { 94 match self { 95 Self::Mpv => "mpv", 96 Self::Chromecast => "chromecast", 97 } 98 } 99} 100 101#[derive(Debug, Clone, Serialize, Deserialize)] 102pub struct ClientInfo { 103 pub name: String, 104 pub version: String, 105} 106 107impl Default for ClientInfo { 108 fn default() -> Self { 109 Self { 110 name: "fin".into(), 111 version: env!("CARGO_PKG_VERSION").into(), 112 } 113 } 114} 115 116fn project_dirs() -> Result<ProjectDirs> { 117 ProjectDirs::from("app", "rocksky", "fin").context("could not determine project dirs") 118} 119 120pub fn config_path() -> Result<PathBuf> { 121 let dirs = project_dirs()?; 122 Ok(dirs.config_dir().join("config.toml")) 123} 124 125pub fn config_dir() -> Result<PathBuf> { 126 let dirs = project_dirs()?; 127 Ok(dirs.config_dir().to_path_buf()) 128} 129 130pub fn cache_dir() -> Result<PathBuf> { 131 let dirs = project_dirs()?; 132 Ok(dirs.cache_dir().to_path_buf()) 133} 134 135impl Config { 136 pub fn load() -> Result<Self> { 137 let path = config_path()?; 138 if !path.exists() { 139 return Ok(Self::default()); 140 } 141 let text = fs::read_to_string(&path) 142 .with_context(|| format!("reading config at {}", path.display()))?; 143 let mut cfg: Self = toml::from_str(&text).context("parsing config file")?; 144 cfg.migrate_legacy_server(); 145 Ok(cfg) 146 } 147 148 /// Fold a leftover `[server]` block into the `servers` list. Idempotent. 149 fn migrate_legacy_server(&mut self) { 150 if let Some(legacy) = self.server.take() { 151 let migrated: ServerConfig = legacy.into(); 152 let already = self.servers.iter().any(|s| s.url == migrated.url); 153 if !already { 154 let name = migrated.name.clone(); 155 self.servers.push(migrated); 156 if self.current_server.is_none() { 157 self.current_server = Some(name); 158 } 159 } 160 } 161 if self.current_server.is_none() { 162 self.current_server = self.servers.first().map(|s| s.name.clone()); 163 } 164 } 165 166 pub fn save(&self) -> Result<()> { 167 let path = config_path()?; 168 if let Some(parent) = path.parent() { 169 fs::create_dir_all(parent).context("creating config dir")?; 170 } 171 let text = toml::to_string_pretty(self).context("serializing config")?; 172 fs::write(&path, text).with_context(|| format!("writing config to {}", path.display()))?; 173 Ok(()) 174 } 175 176 pub fn current(&self) -> Option<&ServerConfig> { 177 let name = self.current_server.as_ref()?; 178 self.servers.iter().find(|s| &s.name == name) 179 } 180 181 pub fn require_current(&self) -> Result<&ServerConfig> { 182 self.current() 183 .context("no active server — run `fin login <url>` or `fin server switch <name>`") 184 } 185 186 pub fn find_server(&self, name: &str) -> Option<&ServerConfig> { 187 self.servers.iter().find(|s| s.name == name) 188 } 189 190 /// Add or update a server (upsert by name) and mark it as the current one. 191 pub fn add_or_update_server(&mut self, server: ServerConfig) { 192 if let Some(existing) = self.servers.iter_mut().find(|s| s.name == server.name) { 193 *existing = server.clone(); 194 } else { 195 self.servers.push(server.clone()); 196 } 197 self.current_server = Some(server.name); 198 } 199 200 /// Switch the active server by name. 201 pub fn switch_to(&mut self, name: &str) -> Result<()> { 202 if !self.servers.iter().any(|s| s.name == name) { 203 return Err(anyhow!( 204 "no server named `{}` — try `fin server` to list them", 205 name 206 )); 207 } 208 self.current_server = Some(name.to_string()); 209 Ok(()) 210 } 211 212 /// Remove a server by name. Falls back to the first remaining server as 213 /// the new current one, or clears `current_server` if none remain. 214 pub fn remove_server(&mut self, name: &str) -> Result<()> { 215 let before = self.servers.len(); 216 self.servers.retain(|s| s.name != name); 217 if self.servers.len() == before { 218 return Err(anyhow!("no server named `{}`", name)); 219 } 220 if self.current_server.as_deref() == Some(name) { 221 self.current_server = self.servers.first().map(|s| s.name.clone()); 222 } 223 Ok(()) 224 } 225 226 pub fn cycle_next(&mut self) -> Option<&ServerConfig> { 227 if self.servers.is_empty() { 228 return None; 229 } 230 let cur = self 231 .current_server 232 .as_deref() 233 .and_then(|n| self.servers.iter().position(|s| s.name == n)) 234 .unwrap_or(0); 235 let next = (cur + 1) % self.servers.len(); 236 self.current_server = Some(self.servers[next].name.clone()); 237 self.servers.get(next) 238 } 239}