Initial commit — ferrosonic terminal Subsonic client

Terminal-based Subsonic music client in Rust featuring bit-perfect audio
playback via PipeWire sample rate switching, gapless playback, MPRIS2
desktop integration, cava audio visualizer with theme-matched gradients,
13 built-in color themes with custom TOML theme support, mouse controls,
artist/album browser, playlist support, and play queue management.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-27 21:43:26 +00:00
commit 12cc70e6ec
36 changed files with 11600 additions and 0 deletions

5
src/mpris/mod.rs Normal file
View File

@@ -0,0 +1,5 @@
//! MPRIS2 D-Bus integration module
#![allow(dead_code)]
pub mod server;

372
src/mpris/server.rs Normal file
View File

@@ -0,0 +1,372 @@
//! MPRIS2 D-Bus server implementation
use mpris_server::{
zbus::{fdo, Result},
LoopStatus, Metadata, PlaybackRate, PlaybackStatus, PlayerInterface, Property, RootInterface,
Server, Time, TrackId, Volume,
};
use tokio::sync::mpsc;
use tracing::info;
use url::Url;
use crate::app::actions::AudioAction;
use crate::app::state::{NowPlaying, PlaybackState, SharedState};
use crate::config::Config;
use crate::subsonic::auth::generate_auth_params;
use crate::subsonic::models::Child;
/// API version for Subsonic
const API_VERSION: &str = "1.16.1";
/// Client name for Subsonic
const CLIENT_NAME: &str = "ferrosonic";
/// Build a cover art URL from config and cover art ID
fn build_cover_art_url(config: &Config, cover_art_id: &str) -> Option<String> {
if config.base_url.is_empty() || cover_art_id.is_empty() {
return None;
}
let (salt, token) = generate_auth_params(&config.password);
let mut url = Url::parse(&format!("{}/rest/getCoverArt", config.base_url)).ok()?;
url.query_pairs_mut()
.append_pair("id", cover_art_id)
.append_pair("u", &config.username)
.append_pair("t", &token)
.append_pair("s", &salt)
.append_pair("v", API_VERSION)
.append_pair("c", CLIENT_NAME);
Some(url.to_string())
}
/// MPRIS server instance name
const PLAYER_NAME: &str = "ferrosonic";
/// MPRIS2 player implementation
pub struct MprisPlayer {
state: SharedState,
audio_tx: mpsc::Sender<AudioAction>,
}
impl MprisPlayer {
pub fn new(state: SharedState, audio_tx: mpsc::Sender<AudioAction>) -> Self {
Self { state, audio_tx }
}
async fn get_state(&self) -> (NowPlaying, Option<Child>, Config) {
let state = self.state.read().await;
let now_playing = state.now_playing.clone();
let current_song = state.current_song().cloned();
let config = state.config.clone();
(now_playing, current_song, config)
}
}
impl RootInterface for MprisPlayer {
async fn raise(&self) -> fdo::Result<()> {
// We're a terminal app, can't raise
Ok(())
}
async fn quit(&self) -> fdo::Result<()> {
let mut state = self.state.write().await;
state.should_quit = true;
Ok(())
}
async fn can_quit(&self) -> fdo::Result<bool> {
Ok(true)
}
async fn fullscreen(&self) -> fdo::Result<bool> {
Ok(false)
}
async fn set_fullscreen(&self, _fullscreen: bool) -> Result<()> {
Ok(())
}
async fn can_set_fullscreen(&self) -> fdo::Result<bool> {
Ok(false)
}
async fn can_raise(&self) -> fdo::Result<bool> {
Ok(false)
}
async fn has_track_list(&self) -> fdo::Result<bool> {
Ok(false)
}
async fn identity(&self) -> fdo::Result<String> {
Ok("Termsonic".to_string())
}
async fn desktop_entry(&self) -> fdo::Result<String> {
Ok("ferrosonic".to_string())
}
async fn supported_uri_schemes(&self) -> fdo::Result<Vec<String>> {
Ok(vec!["http".to_string(), "https".to_string()])
}
async fn supported_mime_types(&self) -> fdo::Result<Vec<String>> {
Ok(vec![
"audio/mpeg".to_string(),
"audio/flac".to_string(),
"audio/ogg".to_string(),
"audio/wav".to_string(),
"audio/x-wav".to_string(),
])
}
}
impl PlayerInterface for MprisPlayer {
async fn next(&self) -> fdo::Result<()> {
let _ = self.audio_tx.send(AudioAction::Next).await;
Ok(())
}
async fn previous(&self) -> fdo::Result<()> {
let _ = self.audio_tx.send(AudioAction::Previous).await;
Ok(())
}
async fn pause(&self) -> fdo::Result<()> {
let _ = self.audio_tx.send(AudioAction::Pause).await;
Ok(())
}
async fn play_pause(&self) -> fdo::Result<()> {
let _ = self.audio_tx.send(AudioAction::TogglePause).await;
Ok(())
}
async fn stop(&self) -> fdo::Result<()> {
let _ = self.audio_tx.send(AudioAction::Stop).await;
Ok(())
}
async fn play(&self) -> fdo::Result<()> {
let _ = self.audio_tx.send(AudioAction::Resume).await;
Ok(())
}
async fn seek(&self, offset: Time) -> fdo::Result<()> {
let offset_secs = offset.as_micros() as f64 / 1_000_000.0;
let _ = self
.audio_tx
.send(AudioAction::SeekRelative(offset_secs))
.await;
Ok(())
}
async fn set_position(&self, _track_id: TrackId, position: Time) -> fdo::Result<()> {
let position_secs = position.as_micros() as f64 / 1_000_000.0;
let _ = self.audio_tx.send(AudioAction::Seek(position_secs)).await;
Ok(())
}
async fn open_uri(&self, _uri: String) -> fdo::Result<()> {
// Not supported for now
Ok(())
}
async fn playback_status(&self) -> fdo::Result<PlaybackStatus> {
let (now_playing, _, _) = self.get_state().await;
Ok(match now_playing.state {
PlaybackState::Playing => PlaybackStatus::Playing,
PlaybackState::Paused => PlaybackStatus::Paused,
PlaybackState::Stopped => PlaybackStatus::Stopped,
})
}
async fn loop_status(&self) -> fdo::Result<LoopStatus> {
Ok(LoopStatus::None)
}
async fn set_loop_status(&self, _loop_status: LoopStatus) -> Result<()> {
Ok(())
}
async fn rate(&self) -> fdo::Result<PlaybackRate> {
Ok(1.0)
}
async fn set_rate(&self, _rate: PlaybackRate) -> Result<()> {
Ok(())
}
async fn shuffle(&self) -> fdo::Result<bool> {
Ok(false)
}
async fn set_shuffle(&self, _shuffle: bool) -> Result<()> {
Ok(())
}
async fn metadata(&self) -> fdo::Result<Metadata> {
let (_now_playing, current_song, config) = self.get_state().await;
let mut metadata = Metadata::new();
if let Some(song) = current_song {
metadata.set_trackid(
Some(TrackId::try_from(format!("/org/mpris/MediaPlayer2/Track/{}", song.id)).ok())
.flatten(),
);
metadata.set_title(Some(song.title));
metadata.set_artist(song.artist.map(|a| vec![a]));
metadata.set_album(song.album);
if let Some(duration) = song.duration {
metadata.set_length(Some(Time::from_micros(duration as i64 * 1_000_000)));
}
if let Some(track) = song.track {
metadata.set_track_number(Some(track));
}
if let Some(disc) = song.disc_number {
metadata.set_disc_number(Some(disc));
}
// Add cover art URL
if let Some(ref cover_art_id) = song.cover_art {
if let Some(cover_url) = build_cover_art_url(&config, cover_art_id) {
metadata.set_art_url(Some(cover_url));
}
}
}
Ok(metadata)
}
async fn volume(&self) -> fdo::Result<Volume> {
Ok(1.0)
}
async fn set_volume(&self, volume: Volume) -> Result<()> {
let volume_int = (volume * 100.0) as i32;
let _ = self.audio_tx.send(AudioAction::SetVolume(volume_int)).await;
Ok(())
}
async fn position(&self) -> fdo::Result<Time> {
let (now_playing, _, _) = self.get_state().await;
Ok(Time::from_micros(
(now_playing.position * 1_000_000.0) as i64,
))
}
async fn minimum_rate(&self) -> fdo::Result<PlaybackRate> {
Ok(1.0)
}
async fn maximum_rate(&self) -> fdo::Result<PlaybackRate> {
Ok(1.0)
}
async fn can_go_next(&self) -> fdo::Result<bool> {
let state = self.state.read().await;
Ok(state
.queue_position
.map(|p| p + 1 < state.queue.len())
.unwrap_or(false))
}
async fn can_go_previous(&self) -> fdo::Result<bool> {
let state = self.state.read().await;
Ok(state.queue_position.map(|p| p > 0).unwrap_or(false))
}
async fn can_play(&self) -> fdo::Result<bool> {
let state = self.state.read().await;
Ok(!state.queue.is_empty())
}
async fn can_pause(&self) -> fdo::Result<bool> {
Ok(true)
}
async fn can_seek(&self) -> fdo::Result<bool> {
Ok(true)
}
async fn can_control(&self) -> fdo::Result<bool> {
Ok(true)
}
}
/// Start the MPRIS server
pub async fn start_mpris_server(
state: SharedState,
audio_tx: mpsc::Sender<AudioAction>,
) -> Result<Server<MprisPlayer>> {
info!("Starting MPRIS2 server");
let player = MprisPlayer::new(state, audio_tx);
let server = Server::new(PLAYER_NAME, player).await?;
info!(
"MPRIS2 server started as org.mpris.MediaPlayer2.{}",
PLAYER_NAME
);
Ok(server)
}
/// Update MPRIS properties when state changes
pub async fn update_mpris_properties(
server: &Server<MprisPlayer>,
state: &SharedState,
) -> Result<()> {
let state = state.read().await;
// Emit property changes
server
.properties_changed([
Property::PlaybackStatus(match state.now_playing.state {
PlaybackState::Playing => PlaybackStatus::Playing,
PlaybackState::Paused => PlaybackStatus::Paused,
PlaybackState::Stopped => PlaybackStatus::Stopped,
}),
Property::CanGoNext(
state
.queue_position
.map(|p| p + 1 < state.queue.len())
.unwrap_or(false),
),
Property::CanGoPrevious(state.queue_position.map(|p| p > 0).unwrap_or(false)),
])
.await?;
// Update metadata if we have a current song
if let Some(song) = state.current_song() {
let mut metadata = Metadata::new();
metadata.set_trackid(
Some(TrackId::try_from(format!("/org/mpris/MediaPlayer2/Track/{}", song.id)).ok())
.flatten(),
);
metadata.set_title(Some(song.title.clone()));
metadata.set_artist(song.artist.clone().map(|a| vec![a]));
metadata.set_album(song.album.clone());
if let Some(duration) = song.duration {
metadata.set_length(Some(Time::from_micros(duration as i64 * 1_000_000)));
}
// Add cover art URL
if let Some(ref cover_art_id) = song.cover_art {
if let Some(cover_url) = build_cover_art_url(&state.config, cover_art_id) {
metadata.set_art_url(Some(cover_url));
}
}
server
.properties_changed([Property::Metadata(metadata)])
.await?;
}
Ok(())
}