use std::collections::HashSet;
use std::future::Future;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use bloop_protocol::message::AchievementRecord;
use thiserror::Error;
use tokio::fs;
use tracing::warn;
use uuid::Uuid;
use crate::client::BloopClient;
use crate::connection::Session;
use crate::request::RequestError;
pub trait AudioProvider {
fn retrieve_audio(
&mut self,
achievement_id: Uuid,
) -> impl Future<Output = Result<Vec<u8>, RequestError>> + Send;
}
impl AudioProvider for &BloopClient {
async fn retrieve_audio(&mut self, achievement_id: Uuid) -> Result<Vec<u8>, RequestError> {
BloopClient::retrieve_audio(self, achievement_id).await
}
}
impl AudioProvider for &mut Session<'_> {
async fn retrieve_audio(&mut self, achievement_id: Uuid) -> Result<Vec<u8>, RequestError> {
Session::retrieve_audio(self, achievement_id).await
}
}
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum AudioCacheError {
#[error(transparent)]
Io(#[from] std::io::Error),
#[error(transparent)]
Request(#[from] RequestError),
}
#[derive(Clone, Debug)]
pub struct AudioCache {
base_path: PathBuf,
}
impl AudioCache {
pub fn new(base_path: impl Into<PathBuf>) -> Self {
Self {
base_path: base_path.into(),
}
}
pub fn path_for(&self, record: &AchievementRecord) -> Option<PathBuf> {
record
.audio_hash
.as_ref()
.map(|hash| self.base_path.join(format!("{}_{}.mp3", record.id, hash)))
}
pub async fn ensure(
&self,
mut provider: impl AudioProvider,
record: &AchievementRecord,
) -> Result<Option<PathBuf>, AudioCacheError> {
self.ensure_inner(&mut provider, record).await
}
async fn ensure_inner<P: AudioProvider>(
&self,
provider: &mut P,
record: &AchievementRecord,
) -> Result<Option<PathBuf>, AudioCacheError> {
let Some(path) = self.path_for(record) else {
return Ok(None);
};
if fs::try_exists(&path).await? {
return Ok(Some(path));
}
fs::create_dir_all(&self.base_path).await?;
let data = provider.retrieve_audio(record.id).await?;
let temp_path = self.base_path.join(format!(
"{}.{}.download",
record.id,
TEMP_COUNTER.fetch_add(1, Ordering::Relaxed)
));
fs::write(&temp_path, data).await?;
fs::rename(&temp_path, &path).await?;
Ok(Some(path))
}
pub async fn sync(
&self,
mut provider: impl AudioProvider,
records: &[AchievementRecord],
) -> Result<Vec<uuid::Uuid>, AudioCacheError> {
fs::create_dir_all(&self.base_path).await?;
let expected: HashSet<PathBuf> = records
.iter()
.filter_map(|record| self.path_for(record))
.collect();
let mut entries = fs::read_dir(&self.base_path).await?;
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
let is_stale_audio = path.extension().is_some_and(|extension| extension == "mp3")
&& !expected.contains(&path);
let is_leftover_download = path
.extension()
.is_some_and(|extension| extension == "download");
if is_stale_audio || is_leftover_download {
let _ = fs::remove_file(&path).await;
}
}
let mut skipped = Vec::new();
for record in records {
match self.ensure_inner(&mut provider, record).await {
Ok(_) => {}
Err(AudioCacheError::Request(RequestError::Error(error))) if !error.is_fatal() => {
warn!(
"skipping audio for achievement {}: server answered {:?}",
record.id, error
);
skipped.push(record.id);
}
Err(error) => return Err(error),
}
}
Ok(skipped)
}
}