a Jellyfin & Subsonic client for the terminal — powered by mpv, Chromecast and UPnP MediaRenderer
mpv
chromecast
mpris
navidrome
jellyfin
upnp
tui
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}