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

Configure Feed

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

Route logs to a file in TUI mode to avoid corrupting the display

While the TUI owns the terminal, anything written to stderr (GENA
notify failures, UPnP cast-in traffic, replaygain warnings) breaks
the display. Logs now go to <cache-dir>/fin.log when running the
TUI, and the UPnP cast-in log line is demoted from info to debug.

Tsiry Sandratraina (Jul 7, 2026, 4:31 PM +0300) 5ad2f89b 4e88c9af

+38 -9
+2 -2
crates/fin-mediarenderer/src/soap.rs
··· 8 8 9 9 use std::sync::Arc; 10 10 11 - use tracing::{debug, info, warn}; 11 + use tracing::{debug, warn}; 12 12 13 13 use fin_player::{PlaybackStatus, QueueItem, UPNP_CAST_ID_PREFIX}; 14 14 ··· 168 168 let uri = arg(body, "CurrentURI").ok_or(SoapError::INVALID_ARGS)?; 169 169 let meta = arg(body, "CurrentURIMetaData").unwrap_or_default(); 170 170 let item = build_item(inner, &uri, &meta); 171 - info!( 171 + debug!( 172 172 title = %item.title, 173 173 video = item.is_video, 174 174 "UPnP cast-in: SetAVTransportURI"
+36 -7
crates/fin/src/main.rs
··· 21 21 #[tokio::main] 22 22 async fn main() -> Result<()> { 23 23 let cli = Cli::parse(); 24 - init_tracing(cli.verbose); 24 + let tui_mode = matches!(cli.command, None | Some(Command::Tui)); 25 + init_tracing(cli.verbose, tui_mode); 25 26 26 27 // rustls 0.23 requires an explicit CryptoProvider. reqwest picks one via 27 28 // its own feature flags, but `rust_cast` pulls in rustls without a ··· 63 64 } 64 65 } 65 66 66 - fn init_tracing(verbose: u8) { 67 + fn init_tracing(verbose: u8, tui_mode: bool) { 67 68 // mdns-sd logs an ERROR line when its ServiceDaemon shuts down 68 69 // ("failed to send response of shutdown: sending on a closed channel") 69 70 // during clean process exit. Cosmetic — silence it unless the user ··· 73 74 1 => "info,fin=debug,mdns_sd=warn", 74 75 _ => "debug", 75 76 }; 76 - let _ = tracing_subscriber::fmt() 77 - .with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| level.into())) 78 - .with_target(false) 79 - .with_writer(io::stderr) 80 - .try_init(); 77 + let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| level.into()); 78 + let builder = tracing_subscriber::fmt() 79 + .with_env_filter(filter) 80 + .with_target(false); 81 + 82 + // While the TUI owns the terminal, anything written to stderr (GENA 83 + // notify failures, UPnP cast-in traffic, replaygain warnings, ...) 84 + // corrupts the display, so logs go to a file instead. 85 + if tui_mode { 86 + match tui_log_file() { 87 + Ok(file) => { 88 + let _ = builder 89 + .with_ansi(false) 90 + .with_writer(std::sync::Mutex::new(file)) 91 + .try_init(); 92 + } 93 + // No usable log file — drop logs rather than break the TUI. 94 + Err(_) => { 95 + let _ = builder.with_writer(io::sink).try_init(); 96 + } 97 + } 98 + } else { 99 + let _ = builder.with_writer(io::stderr).try_init(); 100 + } 101 + } 102 + 103 + fn tui_log_file() -> Result<std::fs::File> { 104 + let dir = fin_config::cache_dir()?; 105 + std::fs::create_dir_all(&dir)?; 106 + Ok(std::fs::File::options() 107 + .create(true) 108 + .append(true) 109 + .open(dir.join("fin.log"))?) 81 110 } 82 111 83 112 fn load_and_merge(cli: &Cli) -> Result<Config> {