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.

run cargo fmt

+85 -118
+1 -3
crates/fin-config/src/lib.rs
··· 60 60 /// 16000 Hz); Q is 7.0 across the board; every gain is 0 dB, so the DSP 61 61 /// output is bit-identical to bypass until the user starts tweaking. 62 62 pub fn default_eq_band_settings() -> Vec<EqBand> { 63 - const CUTOFFS_HZ: [i32; 10] = [ 64 - 32, 63, 125, 250, 500, 1000, 2000, 4000, 8000, 16000, 65 - ]; 63 + const CUTOFFS_HZ: [i32; 10] = [32, 63, 125, 250, 500, 1000, 2000, 4000, 8000, 16000]; 66 64 CUTOFFS_HZ 67 65 .iter() 68 66 .map(|&hz| EqBand {
+3 -10
crates/fin-media/src/lib.rs
··· 13 13 use async_trait::async_trait; 14 14 15 15 pub use fin_config::ServerKind; 16 - pub use fin_jellyfin::{AuthResult, BaseItem, ItemKind, StreamFormat}; 17 16 pub use fin_jellyfin::JellyfinClient; 17 + pub use fin_jellyfin::{AuthResult, BaseItem, ItemKind, StreamFormat}; 18 18 pub use fin_subsonic::SubsonicClient; 19 19 20 20 /// Common browse / search / stream surface. Both backends model the same ··· 265 265 } 266 266 // If the winner failed, run the other to completion so we can report 267 267 // its result too. 268 - let (jf, ss) = tokio::join!( 269 - probe_jellyfin(&http, base), 270 - probe_subsonic(&http, base), 271 - ); 268 + let (jf, ss) = tokio::join!(probe_jellyfin(&http, base), probe_subsonic(&http, base),); 272 269 if jf.is_ok() { 273 270 return Ok(ServerKind::Jellyfin); 274 271 } ··· 323 320 324 321 /// One-shot auth wrapper: probe the server, log in with the right client, 325 322 /// and return everything wired up as `Arc<dyn MediaClient>`. 326 - pub async fn login_any( 327 - url: &str, 328 - username: &str, 329 - password: &str, 330 - ) -> Result<LoggedIn> { 323 + pub async fn login_any(url: &str, username: &str, password: &str) -> Result<LoggedIn> { 331 324 let kind = probe_server(url).await?; 332 325 match kind { 333 326 ServerKind::Jellyfin => {
+4 -2
crates/fin-player/src/persist.rs
··· 121 121 // Unique-per-call so parallel tests don't step on each other. 122 122 static COUNTER: AtomicU64 = AtomicU64::new(0); 123 123 let n = COUNTER.fetch_add(1, Ordering::Relaxed); 124 - std::env::temp_dir() 125 - .join(format!("fin-persist-test-{name}-{}-{n}.json", std::process::id())) 124 + std::env::temp_dir().join(format!( 125 + "fin-persist-test-{name}-{}-{n}.json", 126 + std::process::id() 127 + )) 126 128 } 127 129 128 130 // ------------------------------------------------------------------
+9 -7
crates/fin-player/src/symphonia_player.rs
··· 1167 1167 // only the biquad delay lines differ. Skip both configs 1168 1168 // when no stage is doing anything to avoid the f32↔i16 1169 1169 // conversion hops. 1170 - let dsp_active = 1171 - eq_enabled || tone_bass_db != 0 || tone_treble_db != 0; 1170 + let dsp_active = eq_enabled || tone_bass_db != 0 || tone_treble_db != 0; 1172 1171 let dsp_arg: Option<&mut dyn DspProcess> = if dsp_active { 1173 1172 dsp.as_mut().map(|d| d as &mut dyn DspProcess) 1174 1173 } else { ··· 1198 1197 if let Some(ref mut nt) = next { 1199 1198 let free = nt.ring_free_slots(); 1200 1199 if free >= 8192 && !nt.ended { 1201 - let dsp_active = 1202 - eq_enabled || tone_bass_db != 0 || tone_treble_db != 0; 1200 + let dsp_active = eq_enabled || tone_bass_db != 0 || tone_treble_db != 0; 1203 1201 let dsp_arg: Option<&mut dyn DspProcess> = if dsp_active { 1204 1202 voice_dsp.as_mut().map(|d| d as &mut dyn DspProcess) 1205 1203 } else { ··· 1296 1294 _ => false, 1297 1295 }; 1298 1296 if should_promote { 1299 - debug!(advance = promote_advances_queue, "crossfade complete, promoting next → current"); 1297 + debug!( 1298 + advance = promote_advances_queue, 1299 + "crossfade complete, promoting next → current" 1300 + ); 1300 1301 stop_current(&mut track); 1301 1302 if let Some(mut nt) = next.take() { 1302 1303 nt.overlap_incoming = None; ··· 1430 1431 // f32 → i16, saturating. 1431 1432 let mut input = Vec::with_capacity(samples.len()); 1432 1433 for &s in samples { 1433 - let v = (s * 32767.0).round().clamp(i16::MIN as f32, i16::MAX as f32) as i16; 1434 + let v = (s * 32767.0) 1435 + .round() 1436 + .clamp(i16::MIN as f32, i16::MAX as f32) as i16; 1434 1437 input.push(v); 1435 1438 } 1436 1439 let mut out_i16: Vec<i16> = Vec::with_capacity(input.len()); ··· 1470 1473 } 1471 1474 dsp.eq_enable(enabled); 1472 1475 } 1473 - 1474 1476 1475 1477 fn push_samples_with_volume(t: &Track, samples: &[f32]) { 1476 1478 // Effective scale = user volume × ReplayGain linear multiplier ×
+14 -13
crates/fin-subsonic/src/lib.rs
··· 154 154 Ok(album.song.into_iter().map(song_to_base_item).collect()) 155 155 } 156 156 157 - pub async fn search( 158 - &self, 159 - query: &str, 160 - _kinds: &[&str], 161 - limit: u32, 162 - ) -> Result<Vec<BaseItem>> { 157 + pub async fn search(&self, query: &str, _kinds: &[&str], limit: u32) -> Result<Vec<BaseItem>> { 163 158 let per = limit.min(50); 164 159 let resp: SearchResp = self 165 160 .get_json( ··· 204 199 let p = resp 205 200 .playlist() 206 201 .ok_or_else(|| anyhow!("playlist {} not found", id))?; 207 - Ok(p.entry.unwrap_or_default().into_iter().map(song_to_base_item).collect()) 202 + Ok(p.entry 203 + .unwrap_or_default() 204 + .into_iter() 205 + .map(song_to_base_item) 206 + .collect()) 208 207 } 209 208 210 209 /// Everything the user has starred — albums first, then songs, matching ··· 228 227 /// Star (`star`) or unstar (`unstar`) a song / album / artist by id. 229 228 pub async fn set_star(&self, id: &str, star: bool) -> Result<()> { 230 229 let endpoint = if star { "star" } else { "unstar" }; 231 - let resp: PingResp = self 232 - .get_json(endpoint, &[("id", id.to_string())]) 233 - .await?; 230 + let resp: PingResp = self.get_json(endpoint, &[("id", id.to_string())]).await?; 234 231 resp.check() 235 232 } 236 233 ··· 342 339 .error_for_status()? 343 340 .text() 344 341 .await?; 345 - serde_json::from_str(&text) 346 - .with_context(|| format!("parsing {} response", endpoint)) 342 + serde_json::from_str(&text).with_context(|| format!("parsing {} response", endpoint)) 347 343 } 348 344 } 349 345 ··· 404 400 return Ok(()); 405 401 } 406 402 let (code, msg) = error 407 - .map(|e| (e.code.unwrap_or(-1), e.message.as_deref().unwrap_or("unknown"))) 403 + .map(|e| { 404 + ( 405 + e.code.unwrap_or(-1), 406 + e.message.as_deref().unwrap_or("unknown"), 407 + ) 408 + }) 408 409 .unwrap_or((-1, "unknown")); 409 410 Err(anyhow!("subsonic error {}: {}", code, msg)) 410 411 }
+34 -50
crates/fin-tui/src/app.rs
··· 520 520 self.set_status("No EQ bands — add [[eq_band_settings]] to config.toml"); 521 521 return; 522 522 } 523 - let idx = self 524 - .eq_selected_band 525 - .min(cfg.eq_band_settings.len() - 1); 523 + let idx = self.eq_selected_band.min(cfg.eq_band_settings.len() - 1); 526 524 let band = &mut cfg.eq_band_settings[idx]; 527 525 band.gain = (band.gain + delta_tenths).clamp(-240, 240); 528 526 let hz = band.cutoff; 529 527 let g = band.gain as f32 / 10.0; 530 528 drop(cfg); 531 - self.set_status(format!( 532 - "band {}: {} Hz → {:+.1} dB", 533 - idx + 1, 534 - hz, 535 - g 536 - )); 529 + self.set_status(format!("band {}: {} Hz → {:+.1} dB", idx + 1, hz, g)); 537 530 let cfg = self.config.lock(); 538 531 let _ = cfg.save(); 539 532 (cfg.eq_enabled, cfg.eq_band_settings.clone()) ··· 629 622 } 630 623 } 631 624 Err(e) => { 632 - *status.lock() = 633 - Some((format!("favorite failed: {}", e), Instant::now())) 625 + *status.lock() = Some((format!("favorite failed: {}", e), Instant::now())) 634 626 } 635 627 } 636 628 }); ··· 1135 1127 BaseItem { 1136 1128 id: q.id.clone(), 1137 1129 name: q.title.clone(), 1138 - type_: if q.is_video { "Video".into() } else { "Audio".into() }, 1130 + type_: if q.is_video { 1131 + "Video".into() 1132 + } else { 1133 + "Audio".into() 1134 + }, 1139 1135 album: None, 1140 1136 album_id: None, 1141 1137 album_artist: None, ··· 1147 1143 series_name: None, 1148 1144 production_year: None, 1149 1145 run_time_ticks: q.duration_secs.map(|s| (s * 10_000_000) as i64), 1150 - media_type: Some(if q.is_video { "Video".into() } else { "Audio".into() }), 1146 + media_type: Some(if q.is_video { 1147 + "Video".into() 1148 + } else { 1149 + "Audio".into() 1150 + }), 1151 1151 container: None, 1152 1152 index_number: None, 1153 1153 parent_index_number: None, ··· 1521 1521 (KeyCode::Char('['), _) if app.screen == Screen::Settings => { 1522 1522 let n = app.config.lock().eq_band_settings.len(); 1523 1523 if n > 0 { 1524 - app.eq_selected_band = 1525 - (app.eq_selected_band + n - 1) % n; 1524 + app.eq_selected_band = (app.eq_selected_band + n - 1) % n; 1526 1525 } 1527 1526 } 1528 1527 (KeyCode::Char(']'), _) if app.screen == Screen::Settings => { ··· 1861 1860 if tracks.is_empty() { 1862 1861 f.render_widget(block, area); 1863 1862 f.render_widget( 1864 - Paragraph::new(Line::from(Span::styled( 1865 - " Loading tracks…", 1866 - muted_style(), 1867 - ))) 1868 - .alignment(Alignment::Center), 1863 + Paragraph::new(Line::from(Span::styled(" Loading tracks…", muted_style()))) 1864 + .alignment(Alignment::Center), 1869 1865 inner.inner(Margin::new(2, 1)), 1870 1866 ); 1871 1867 return; ··· 1906 1902 let disc = track.parent_index_number.unwrap_or(1); 1907 1903 if show_disc_headers && current_disc != Some(disc) { 1908 1904 header_visual_indices.push(items.len()); 1909 - items.push(ListItem::new(Line::from(vec![ 1910 - Span::styled( 1911 - format!(" ▤ Disc {}", disc), 1912 - Style::default() 1913 - .fg(Palette::ACCENT) 1914 - .add_modifier(Modifier::BOLD), 1915 - ), 1916 - ]))); 1905 + items.push(ListItem::new(Line::from(vec![Span::styled( 1906 + format!(" ▤ Disc {}", disc), 1907 + Style::default() 1908 + .fg(Palette::ACCENT) 1909 + .add_modifier(Modifier::BOLD), 1910 + )]))); 1917 1911 current_disc = Some(disc); 1918 1912 } 1919 1913 track_to_visual.push(items.len()); ··· 1988 1982 } else { 1989 1983 String::new() 1990 1984 }; 1991 - let time = track 1992 - .duration_secs() 1993 - .map(fmt_dur_local) 1994 - .unwrap_or_default(); 1985 + let time = track.duration_secs().map(fmt_dur_local).unwrap_or_default(); 1995 1986 let time_pad = layout 1996 1987 .time_col 1997 1988 .saturating_sub(UnicodeWidthStr::width(time.as_str())); ··· 2249 2240 ]), 2250 2241 Line::from(vec![ 2251 2242 Span::styled(" Tone ", title_style()), 2252 - Span::styled( 2253 - format!("bass {:+} dB", cfg_snapshot.bass), 2254 - accent_style(), 2255 - ), 2243 + Span::styled(format!("bass {:+} dB", cfg_snapshot.bass), accent_style()), 2256 2244 Span::styled(" ", muted_style()), 2257 2245 Span::styled( 2258 2246 format!("treble {:+} dB", cfg_snapshot.treble), 2259 2247 accent_style(), 2260 2248 ), 2261 - Span::styled( 2262 - " (b/B: bass, y/Y: treble; 1 dB steps)", 2263 - muted_style(), 2264 - ), 2249 + Span::styled(" (b/B: bass, y/Y: treble; 1 dB steps)", muted_style()), 2265 2250 ]), 2266 2251 Line::from(vec![ 2267 2252 Span::styled(" Config File ", title_style()), ··· 2284 2269 let sel_band = if cfg_snapshot.eq_band_settings.is_empty() { 2285 2270 None 2286 2271 } else { 2287 - Some(app.eq_selected_band.min(cfg_snapshot.eq_band_settings.len() - 1)) 2272 + Some( 2273 + app.eq_selected_band 2274 + .min(cfg_snapshot.eq_band_settings.len() - 1), 2275 + ) 2288 2276 }; 2289 2277 let eq_title = if cfg_snapshot.eq_enabled { 2290 2278 format!( ··· 2326 2314 b.gain as f32 / 10.0, 2327 2315 ) 2328 2316 }) 2329 - .unwrap_or_else(|| " no bands configured — add [[eq_band_settings]] to config.toml ".to_string()); 2330 - let hint = format!( 2331 - "{}E: on/off [ / ]: band Shift+↑/↓: ±1 dB", 2332 - sel_hint 2333 - ); 2317 + .unwrap_or_else(|| { 2318 + " no bands configured — add [[eq_band_settings]] to config.toml ".to_string() 2319 + }); 2320 + let hint = format!("{}E: on/off [ / ]: band Shift+↑/↓: ±1 dB", sel_hint); 2334 2321 f.render_widget( 2335 2322 Paragraph::new(Span::styled(hint, muted_style())), 2336 2323 eq_rows[1], ··· 2511 2498 track("d2-t2", Some(2), Some(2)), 2512 2499 ]; 2513 2500 sort_album_tracks(&mut v); 2514 - assert_eq!( 2515 - names(&v), 2516 - vec!["d1-t1", "d1-t2", "d2-t1", "d2-t2"] 2517 - ); 2501 + assert_eq!(names(&v), vec!["d1-t1", "d1-t2", "d2-t1", "d2-t2"]); 2518 2502 } 2519 2503 2520 2504 #[test]
+3 -6
crates/fin-tui/src/widgets/eq.rs
··· 58 58 let ratio = clamped / self.range_db as f32; // −1..=+1 59 59 let half_h = bar_h / 2; 60 60 let offset = ((ratio * half_h as f32).round()) as i32; 61 - let bar_end_row = (center_row as i32 - offset).clamp(bar_top as i32, bar_bot as i32) as u16; 61 + let bar_end_row = 62 + (center_row as i32 - offset).clamp(bar_top as i32, bar_bot as i32) as u16; 62 63 63 64 // Bar style — muted when EQ off, highlighted on the selected band. 64 65 let is_sel = self.selected == Some(i); ··· 95 96 for r in from..=to { 96 97 if let Some(cell) = buf.cell_mut((bar_x, r)) { 97 98 cell.set_char('█'); 98 - cell.set_style( 99 - Style::default() 100 - .fg(bar_color) 101 - .add_modifier(Modifier::BOLD), 102 - ); 99 + cell.set_style(Style::default().fg(bar_color).add_modifier(Modifier::BOLD)); 103 100 } 104 101 } 105 102
+14 -15
crates/fin-tui/src/widgets/help.rs
··· 134 134 // Split into as many horizontal rows as the widget occupies, one 135 135 // Line per row. We rely on Paragraph's wrapping for width overflow. 136 136 let lines = build_lines(inner.width as usize); 137 - let footer = Line::from(vec![Span::styled( 138 - " ? or Esc to close", 139 - muted_style(), 140 - )]); 137 + let footer = Line::from(vec![Span::styled(" ? or Esc to close", muted_style())]); 141 138 142 139 let rows = Layout::default() 143 140 .direction(Direction::Vertical) ··· 176 173 if idx > 0 { 177 174 lines.push(Line::from("")); 178 175 } 179 - lines.push(Line::from(vec![ 180 - Span::styled( 181 - format!(" {}", section.title), 182 - Style::default() 183 - .fg(Palette::ACCENT) 184 - .add_modifier(Modifier::BOLD), 185 - ), 186 - ])); 176 + lines.push(Line::from(vec![Span::styled( 177 + format!(" {}", section.title), 178 + Style::default() 179 + .fg(Palette::ACCENT) 180 + .add_modifier(Modifier::BOLD), 181 + )])); 187 182 for entry in section.entries { 188 - let key_pad = key_col_max 189 - .saturating_sub(unicode_width::UnicodeWidthStr::width(entry.key)); 183 + let key_pad = 184 + key_col_max.saturating_sub(unicode_width::UnicodeWidthStr::width(entry.key)); 190 185 lines.push(Line::from(vec![ 191 186 Span::raw(" "), 192 187 Span::styled( ··· 223 218 fn every_entry_has_non_empty_key_and_description() { 224 219 for s in HELP_SECTIONS { 225 220 for e in s.entries { 226 - assert!(!e.key.trim().is_empty(), "empty key in section '{}'", s.title); 221 + assert!( 222 + !e.key.trim().is_empty(), 223 + "empty key in section '{}'", 224 + s.title 225 + ); 227 226 assert!( 228 227 !e.description.trim().is_empty(), 229 228 "empty description for '{}'",
+1 -4
crates/fin-tui/src/widgets/player_bar.rs
··· 165 165 }; 166 166 let tone_span = if self.state.bass_db != 0 || self.state.treble_db != 0 { 167 167 Span::styled( 168 - format!( 169 - "B{:+}/T{:+} ", 170 - self.state.bass_db, self.state.treble_db 171 - ), 168 + format!("B{:+}/T{:+} ", self.state.bass_db, self.state.treble_db), 172 169 Style::default() 173 170 .fg(Palette::HIGHLIGHT) 174 171 .add_modifier(Modifier::BOLD),
+2 -8
crates/fin/src/main.rs
··· 127 127 .clone() 128 128 .or_else(|| existing.as_ref().map(|s| s.device_id.clone())) 129 129 .unwrap_or_default(), 130 - server_kind: existing 131 - .as_ref() 132 - .map(|s| s.server_kind) 133 - .unwrap_or_default(), 130 + server_kind: existing.as_ref().map(|s| s.server_kind).unwrap_or_default(), 134 131 }; 135 132 cfg.add_or_update_server(merged); 136 133 } else { ··· 203 200 if let Err(e) = r.set_crossfade(cfg.crossfade).await { 204 201 tracing::warn!(?e, "crossfade apply failed"); 205 202 } 206 - if let Err(e) = r 207 - .set_eq(cfg.eq_enabled, cfg.eq_band_settings.clone()) 208 - .await 209 - { 203 + if let Err(e) = r.set_eq(cfg.eq_enabled, cfg.eq_band_settings.clone()).await { 210 204 tracing::warn!(?e, "eq apply failed"); 211 205 } 212 206 if let Err(e) = r