use std::{collections::HashSet, sync::Arc, time::Duration};
use cpal::traits::{DeviceTrait, HostTrait};
use md5::{Digest, Md5};
use rodio::Source;
use url::Url;
use crate::{
config::Config,
decrypt::{Decrypt, Key},
error::{Error, ErrorKind, Result},
events::Event,
http,
protocol::{
connect::{
contents::{AudioQuality, RepeatMode},
Percentage,
},
gateway::{self, MediaUrl},
},
track::{Track, TrackId},
};
type SampleFormat = <rodio::decoder::Decoder<std::fs::File> as Iterator>::Item;
pub struct Player {
audio_quality: AudioQuality,
license_token: String,
bf_secret: Key,
queue: Vec<Track>,
skip_tracks: HashSet<TrackId>,
position: usize,
deferred_seek: Option<Duration>,
client: http::Client,
repeat_mode: RepeatMode,
normalization: bool,
gain_target_db: i8,
volume: Percentage,
event_tx: Option<tokio::sync::mpsc::UnboundedSender<Event>>,
device: String,
sink: Option<rodio::Sink>,
stream: Option<rodio::OutputStream>,
sources: Option<Arc<rodio::queue::SourcesQueueInput<SampleFormat>>>,
playing_since: Duration,
current_rx: Option<std::sync::mpsc::Receiver<()>>,
preload_rx: Option<std::sync::mpsc::Receiver<()>>,
media_url: Url,
}
impl Player {
const LOG_VOLUME_SCALE_FACTOR: f32 = 1000.0;
const LOG_VOLUME_GROWTH_RATE: f32 = 6.907_755_4;
pub async fn new(config: &Config, device: &str) -> Result<Self> {
let client = http::Client::without_cookies(config)?;
let bf_secret = if let Some(secret) = config.bf_secret {
secret
} else {
debug!("no bf_secret specified, fetching one from the web player");
Config::try_key(&client).await?
};
if format!("{:x}", Md5::digest(*bf_secret)) != Config::BF_SECRET_MD5 {
return Err(Error::permission_denied("the bf_secret is not valid"));
}
#[expect(clippy::cast_possible_truncation)]
let gain_target_db = gateway::user_data::Gain::default().target as i8;
Ok(Self {
queue: Vec::new(),
skip_tracks: HashSet::new(),
position: 0,
audio_quality: AudioQuality::default(),
client,
license_token: String::new(),
media_url: MediaUrl::default().into(),
bf_secret,
repeat_mode: RepeatMode::default(),
normalization: config.normalization,
gain_target_db,
volume: Percentage::from_ratio_f32(1.0),
event_tx: None,
playing_since: Duration::ZERO,
deferred_seek: None,
current_rx: None,
preload_rx: None,
device: device.to_owned(),
sink: None,
stream: None,
sources: None,
})
}
fn get_device(device: &str) -> Result<(rodio::Device, rodio::SupportedStreamConfig)> {
let mut components = device.split('|');
let host = match components.next() {
Some("") | None => cpal::default_host(),
Some(name) => {
let host_ids = cpal::available_hosts();
host_ids
.into_iter()
.find_map(|host_id| {
let host = cpal::host_from_id(host_id).ok()?;
if host.id().name().eq_ignore_ascii_case(name) {
Some(host)
} else {
None
}
})
.ok_or_else(|| Error::not_found(format!("audio host {name} not found")))?
}
};
let device = match components.next() {
Some("") | None => host.default_output_device().ok_or_else(|| {
Error::not_found(format!(
"default audio output device not found on {}",
host.id().name()
))
})?,
Some(name) => {
let mut devices = host.output_devices()?;
devices
.find(|device| device.name().is_ok_and(|n| n.eq_ignore_ascii_case(name)))
.ok_or_else(|| {
Error::not_found(format!(
"audio output device {name} not found on {}",
host.id().name()
))
})?
}
};
let config = match components.next() {
Some("") | None => device.default_output_config().map_err(|e| {
Error::unavailable(format!("default output configuration unavailable: {e}"))
})?,
Some(rate) => {
let rate = rate
.parse()
.map_err(|_| Error::invalid_argument(format!("invalid sample rate {rate}")))?;
let rate = cpal::SampleRate(rate);
let format = match components.next() {
Some("") | None => None,
other => other,
};
device
.supported_output_configs()?
.find_map(|config| {
if format.is_none_or(|format| {
config
.sample_format()
.to_string()
.eq_ignore_ascii_case(format)
}) {
config.try_with_sample_rate(rate)
} else {
None
}
})
.ok_or_else(|| {
Error::unavailable(format!(
"audio output device {} does not support sample rate {} with {} sample format",
device.name().as_deref().unwrap_or("UNKNOWN"),
rate.0,
format.unwrap_or("default")
))
})?
}
};
info!(
"audio output device: {} on {}",
device.name().as_deref().unwrap_or("UNKNOWN"),
host.id().name()
);
#[expect(clippy::cast_precision_loss)]
let sample_rate = config.sample_rate().0 as f32 / 1000.0;
info!(
"audio output configuration: {sample_rate:.1} kHz in {}",
config.sample_format()
);
trace!("audio buffer size: {:#?}", config.buffer_size());
Ok((device, config))
}
pub fn start(&mut self) -> Result<()> {
debug!("opening output device");
let (device, device_config) = Self::get_device(&self.device)?;
let (stream, handle) = rodio::OutputStream::try_from_device_config(&device, device_config)?;
let sink = rodio::Sink::try_new(&handle)?;
sink.set_volume(self.volume.as_ratio_f32());
let (sources, output) = rodio::queue::queue(true);
sink.append(output);
sink.pause();
self.sink = Some(sink);
self.sources = Some(sources);
self.stream = Some(stream);
Ok(())
}
pub fn stop(&mut self) {
if let Ok(sink) = self.sink_mut() {
debug!("closing output device");
sink.stop();
}
self.sources = None;
self.stream = None;
self.sink = None;
}
const SAMPLE_RATES: [u32; 2] = [44_100, 48_000];
const SAMPLE_FORMATS: [cpal::SampleFormat; 3] = [
cpal::SampleFormat::I16,
cpal::SampleFormat::I32,
cpal::SampleFormat::F32,
];
#[must_use]
pub fn enumerate_devices() -> Vec<String> {
let hosts = cpal::available_hosts();
let mut result = Vec::new();
for host in hosts
.into_iter()
.filter_map(|id| cpal::host_from_id(id).ok())
{
if let Ok(devices) = host.output_devices() {
for device in devices {
if let Ok(device_name) = device.name() {
if let Ok(configs) = device.supported_output_configs() {
for config in configs {
if config.channels() == 2
&& Self::SAMPLE_FORMATS.contains(&config.sample_format())
{
for sample_rate in &Self::SAMPLE_RATES {
if let Some(config) = config
.try_with_sample_rate(cpal::SampleRate(*sample_rate))
{
let line = format!(
"{}|{}|{}|{}",
host.id().name(),
device_name,
config.sample_rate().0,
config.sample_format(),
);
result.push(line);
}
}
}
}
}
}
}
}
}
result
}
fn go_next(&mut self) {
let old_position = self.position;
let repeat_mode = self.repeat_mode();
if repeat_mode != RepeatMode::One {
let next = self.position.saturating_add(1);
if next < self.queue.len() {
self.position = next;
} else {
if repeat_mode != RepeatMode::All {
let _ = self.pause();
};
self.position = 0;
}
}
if self.position() != old_position {
self.notify(Event::TrackChanged);
}
if self.is_playing() {
self.notify(Event::Play);
}
}
const AGC_ATTACK_TIME: Duration = Duration::from_millis(5);
const AGC_RELEASE_TIME: Duration = Duration::from_millis(100);
async fn load_track(
&mut self,
position: usize,
) -> Result<Option<std::sync::mpsc::Receiver<()>>> {
let track = self
.queue
.get_mut(position)
.ok_or_else(|| Error::not_found(format!("track at position {position} not found")))?;
let sources = self
.sources
.as_mut()
.ok_or(Error::unavailable("audio sources not available"))?;
if track.handle().is_none() {
let download = tokio::time::timeout(Duration::from_secs(3), async {
let medium = track
.get_medium(
&self.client,
&self.media_url,
self.audio_quality,
self.license_token.clone(),
)
.await?;
track.start_download(&self.client, &medium).await
})
.await??;
let decryptor = Decrypt::new(track, download, &self.bf_secret)?;
let mut decoder = match track.quality() {
AudioQuality::Lossless => rodio::Decoder::new_flac(decryptor),
_ => rodio::Decoder::new_mp3(decryptor),
}?;
if let Some(progress) = self.deferred_seek.take() {
if !progress.is_zero() {
if let Err(e) = decoder.try_seek(progress) {
error!("failed to seek to deferred position: {}", e);
}
}
}
let mut difference = 0.0;
let mut ratio = 1.0;
if self.normalization {
match track.gain() {
Some(gain) => {
difference = f32::from(self.gain_target_db) - gain;
if difference > 0.0 && !track.is_lossless() {
difference -= 1.0;
}
ratio = f32::powf(10.0, difference / 20.0);
}
None => {
warn!("track {track} has no gain information, skipping normalization");
}
}
}
let rx = if ratio < 1.0 {
debug!(
"attenuating track {track} by {difference:.1} dB ({})",
Percentage::from_ratio_f32(ratio)
);
let attenuated = decoder.amplify(ratio);
sources.append_with_signal(attenuated)
} else if ratio > 1.0 {
debug!(
"amplifying track {track} by {difference:.1} dB ({}) (with limiter)",
Percentage::from_ratio_f32(ratio)
);
let amplified = decoder.automatic_gain_control(
ratio,
Self::AGC_ATTACK_TIME.as_secs_f32(),
Self::AGC_RELEASE_TIME.as_secs_f32(),
difference,
);
sources.append_with_signal(amplified)
} else {
sources.append_with_signal(decoder)
};
return Ok(Some(rx));
}
Ok(None)
}
#[must_use]
fn get_pos(&self) -> Duration {
self.sink
.as_ref()
.map_or(Duration::ZERO, rodio::Sink::get_pos)
}
pub async fn run(&mut self) -> Result<()> {
loop {
match self.current_rx.as_mut() {
Some(current_rx) => {
if current_rx.try_recv().is_ok() {
self.playing_since = self.get_pos();
self.current_rx = self.preload_rx.take();
self.go_next();
}
if self.preload_rx.is_none()
&& self.repeat_mode() != RepeatMode::One
&& self.track().is_some_and(Track::is_complete)
{
let next_position = self.position.saturating_add(1);
if let Some(next_track) = self.queue.get(next_position) {
let next_track_id = next_track.id();
if !self.skip_tracks.contains(&next_track_id) {
match self.load_track(next_position).await {
Ok(rx) => {
self.preload_rx = rx;
}
Err(e) => {
error!("failed to preload next track: {e}");
self.mark_unavailable(next_track_id);
}
}
}
}
}
}
None => {
if let Some(track) = self.track() {
let track_id = track.id();
if self.skip_tracks.contains(&track_id) {
self.go_next();
} else {
match self.load_track(self.position).await {
Ok(rx) => {
if let Some(rx) = rx {
self.current_rx = Some(rx);
self.notify(Event::TrackChanged);
if self.is_playing() {
self.notify(Event::Play);
}
}
}
Err(e) => {
error!("failed to load track: {e}");
self.mark_unavailable(track_id);
self.go_next();
}
}
}
}
}
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
}
fn mark_unavailable(&mut self, track_id: TrackId) {
if self.skip_tracks.insert(track_id) {
warn!("marking track {track_id} as unavailable");
}
}
fn notify(&self, event: Event) {
if let Some(event_tx) = &self.event_tx {
if let Err(e) = event_tx.send(event) {
error!("failed to send event: {e}");
}
}
}
pub fn register(&mut self, event_tx: tokio::sync::mpsc::UnboundedSender<Event>) {
self.event_tx = Some(event_tx);
}
fn sink_mut(&mut self) -> Result<&mut rodio::Sink> {
self.sink
.as_mut()
.ok_or(Error::unavailable("audio sink not available"))
}
pub fn play(&mut self) -> Result<()> {
if !self.is_playing() {
debug!("starting playback");
self.sink_mut()?.play();
self.notify(Event::Play);
}
Ok(())
}
pub fn pause(&mut self) -> Result<()> {
if self.is_playing() {
debug!("pausing playback");
let _ = self.sink_mut().map(|sink| sink.pause());
self.notify(Event::Pause);
}
Ok(())
}
#[must_use]
pub fn is_playing(&self) -> bool {
self.current_rx.is_some() && self.sink.as_ref().is_some_and(|sink| !sink.is_paused())
}
pub fn set_playing(&mut self, should_play: bool) -> Result<()> {
if should_play {
self.play()
} else {
self.pause()
}
}
#[must_use]
pub fn track(&self) -> Option<&Track> {
self.queue.get(self.position)
}
#[must_use]
pub fn track_mut(&mut self) -> Option<&mut Track> {
self.queue.get_mut(self.position)
}
pub fn set_queue(&mut self, tracks: Vec<Track>) {
self.clear();
self.position = 0;
self.queue = tracks;
self.skip_tracks.clear();
self.skip_tracks.shrink_to_fit();
}
#[must_use]
pub fn next_track(&self) -> Option<&Track> {
let next = self.position.saturating_add(1);
self.queue.get(next)
}
#[must_use]
pub fn next_track_mut(&mut self) -> Option<&mut Track> {
let next = self.position.saturating_add(1);
self.queue.get_mut(next)
}
pub fn reorder_queue(&mut self, track_ids: &[TrackId]) {
let current_track_id = self.track().map(Track::id);
let next_track_id = self.next_track().map(Track::id);
let mut new_queue = Vec::with_capacity(track_ids.len());
for new_track_id in track_ids {
if let Some(position) = self
.queue
.iter()
.position(|track| &track.id() == new_track_id)
{
let mut new_track = self.queue.remove(position);
if ![current_track_id, next_track_id].contains(&Some(new_track.id())) {
new_track.reset_download();
}
new_queue.push(new_track);
}
}
self.position = new_queue
.iter()
.position(|track| Some(track.id()) == current_track_id)
.unwrap_or_default();
self.queue = new_queue;
self.preload_rx = None;
self.sources.as_mut().map(|sources| sources.clear());
}
pub fn extend_queue(&mut self, tracks: Vec<Track>) {
self.queue.extend(tracks);
}
pub fn set_position(&mut self, position: usize) {
if self.position == position {
return;
}
info!("setting playlist position to {position}");
self.clear();
self.position = position;
}
pub fn clear(&mut self) {
if let Ok(sink) = self.sink_mut() {
let (sources, output) = rodio::queue::queue(true);
sink.append(output);
sink.skip_one();
self.sources = Some(sources);
}
if let Some(current) = self.track_mut() {
current.reset_download();
}
if let Some(next) = self.next_track_mut() {
next.reset_download();
}
self.playing_since = Duration::ZERO;
self.current_rx = None;
self.preload_rx = None;
}
#[must_use]
pub fn repeat_mode(&self) -> RepeatMode {
self.repeat_mode
}
pub fn set_repeat_mode(&mut self, repeat_mode: RepeatMode) {
info!("setting repeat mode to {repeat_mode}");
self.repeat_mode = repeat_mode;
if repeat_mode == RepeatMode::One {
self.sources.as_mut().map(|sources| sources.clear());
self.preload_rx = None;
}
}
#[must_use]
pub fn volume(&self) -> Percentage {
self.volume
}
pub fn set_volume(&mut self, volume: Percentage) -> Result<()> {
if volume == self.volume() {
return Ok(());
}
info!("setting volume to {volume}");
self.volume = volume;
let volume = volume.as_ratio_f32().clamp(0.0, 1.0);
let mut amplitude = volume;
if amplitude > 0.0 && amplitude < 1.0 {
amplitude =
f32::exp(Self::LOG_VOLUME_GROWTH_RATE * volume) / Self::LOG_VOLUME_SCALE_FACTOR;
if volume < 0.1 {
amplitude *= volume * 10.0;
}
debug!(
"volume scaled logarithmically to {}",
Percentage::from_ratio_f32(amplitude)
);
}
self.sink_mut().map(|sink| sink.set_volume(amplitude))
}
#[must_use]
pub fn progress(&self) -> Option<Percentage> {
let progress = self.get_pos().saturating_sub(self.playing_since);
self.track().map(|track| {
let ratio = progress.div_duration_f32(track.duration());
Percentage::from_ratio_f32(ratio)
})
}
pub fn set_progress(&mut self, progress: Percentage) -> Result<()> {
if let Some(track) = self.track() {
info!("setting track progress to {progress}");
let progress = progress.as_ratio_f32();
if progress < 1.0 {
let progress = track.duration().mul_f32(progress);
match self
.sink_mut()
.and_then(|sink| sink.try_seek(progress).map_err(Into::into))
{
Ok(()) => {
self.playing_since = Duration::ZERO;
self.deferred_seek = None;
}
Err(e) => {
if matches!(e.kind, ErrorKind::Unavailable | ErrorKind::Unimplemented) {
self.deferred_seek = Some(progress);
} else {
return Err(e);
}
}
}
} else {
self.clear();
self.go_next();
}
}
Ok(())
}
#[must_use]
pub fn position(&self) -> usize {
self.position
}
pub fn set_license_token(&mut self, license_token: impl Into<String>) {
self.license_token = license_token.into();
}
pub fn set_normalization(&mut self, normalization: bool) {
self.normalization = normalization;
}
pub fn set_gain_target_db(&mut self, gain_target_db: i8) {
if self.normalization {
info!("normalizing volume to {gain_target_db} dB");
}
self.gain_target_db = gain_target_db;
}
pub fn set_audio_quality(&mut self, quality: AudioQuality) {
self.audio_quality = quality;
}
#[must_use]
pub fn normalization(&self) -> bool {
self.normalization
}
#[must_use]
pub fn license_token(&self) -> &str {
&self.license_token
}
#[must_use]
pub fn audio_quality(&self) -> AudioQuality {
self.audio_quality
}
#[must_use]
pub fn gain_target_db(&self) -> i8 {
self.gain_target_db
}
pub fn set_media_url(&mut self, url: Url) {
self.media_url = url;
}
#[must_use]
pub fn is_started(&self) -> bool {
self.sink.is_some()
}
}
impl Drop for Player {
fn drop(&mut self) {
self.stop();
}
}