Files
pmomusic/pmoqobuz/src/disk_cache.rs

199 lines
6.0 KiB
Rust
Raw Normal View History

2025-12-12 21:42:04 +00:00
#![cfg(feature = "disk-cache")]
2025-12-08 22:32:42 +01:00
2025-12-12 21:42:04 +00:00
use anyhow::anyhow;
2025-12-08 22:32:42 +01:00
use serde::{de::DeserializeOwned, Serialize};
2025-12-12 21:42:04 +00:00
use std::{
path::PathBuf,
time::{Duration, SystemTime, UNIX_EPOCH},
};
use rusqlite::{params, Connection};
use tokio::task;
#[derive(Debug, Clone)]
pub struct CacheEntry<T> {
pub value: T,
pub age: Duration,
pub fresh: bool,
2025-12-08 22:32:42 +01:00
}
2025-12-12 21:42:04 +00:00
#[async_trait::async_trait]
pub trait CacheStore: Send + Sync {
async fn get_json<T: DeserializeOwned + Send>(
&self,
user_id: &str,
namespace: &str,
key: &str,
) -> anyhow::Result<Option<CacheEntry<T>>>;
2025-12-08 22:32:42 +01:00
2025-12-12 21:42:04 +00:00
async fn put_json<T: Serialize + Send + Sync>(
2025-12-08 22:32:42 +01:00
&self,
2025-12-12 21:42:04 +00:00
user_id: &str,
namespace: &str,
2025-12-08 22:32:42 +01:00
key: &str,
ttl: Duration,
2025-12-12 21:42:04 +00:00
value: &T,
) -> anyhow::Result<()>;
2025-12-08 22:32:42 +01:00
2025-12-13 13:36:37 +01:00
async fn invalidate(&self, user_id: &str, namespace: &str, key: &str) -> anyhow::Result<()>;
2025-12-08 22:32:42 +01:00
2025-12-12 21:42:04 +00:00
async fn purge_expired(&self) -> anyhow::Result<usize>;
}
2025-12-08 22:32:42 +01:00
2025-12-12 21:42:04 +00:00
pub struct SqliteCacheStore {
db_path: PathBuf,
}
2025-12-08 22:32:42 +01:00
2025-12-12 21:42:04 +00:00
impl SqliteCacheStore {
pub fn new(db_path: PathBuf) -> anyhow::Result<Self> {
let store = Self { db_path };
store.init_blocking()?;
Ok(store)
2025-12-08 22:32:42 +01:00
}
2025-12-12 21:42:04 +00:00
fn init_blocking(&self) -> anyhow::Result<()> {
let conn = Connection::open(&self.db_path)?;
conn.execute_batch(
r#"
CREATE TABLE IF NOT EXISTS qobuz_cache (
user_id TEXT NOT NULL,
namespace TEXT NOT NULL,
key TEXT NOT NULL,
fetched_at INTEGER NOT NULL,
ttl_seconds INTEGER NOT NULL,
json BLOB NOT NULL,
PRIMARY KEY (user_id, namespace, key)
);
CREATE INDEX IF NOT EXISTS qobuz_cache_expiry
ON qobuz_cache (fetched_at, ttl_seconds);
"#,
)?;
2025-12-08 22:32:42 +01:00
Ok(())
}
2025-12-12 21:42:04 +00:00
fn now_seconds() -> i64 {
match SystemTime::now().duration_since(UNIX_EPOCH) {
Ok(duration) => i64::try_from(duration.as_secs()).unwrap_or(i64::MAX),
Err(_) => 0,
2025-12-08 22:32:42 +01:00
}
}
}
2025-12-12 21:42:04 +00:00
#[async_trait::async_trait]
impl CacheStore for SqliteCacheStore {
async fn get_json<T: DeserializeOwned + Send>(
&self,
user_id: &str,
namespace: &str,
key: &str,
) -> anyhow::Result<Option<CacheEntry<T>>> {
let user_id = user_id.to_owned();
let namespace = namespace.to_owned();
let key = key.to_owned();
let db_path = self.db_path.clone();
task::spawn_blocking(move || {
let conn = Connection::open(db_path)?;
let mut stmt = conn.prepare(
"SELECT fetched_at, ttl_seconds, json
FROM qobuz_cache
WHERE user_id = ?1 AND namespace = ?2 AND key = ?3",
)?;
2025-12-13 13:36:37 +01:00
let result = stmt.query_row(params![user_id, namespace, key], |row| {
let fetched_at: i64 = row.get(0)?;
let ttl_seconds: i64 = row.get(1)?;
let data: Vec<u8> = row.get(2)?;
let now = Self::now_seconds();
let fresh = now <= fetched_at + ttl_seconds;
let age_secs = if now >= fetched_at {
(now - fetched_at) as u64
} else {
0
};
let age = Duration::from_secs(age_secs);
let value = serde_json::from_slice(&data)?;
Ok(CacheEntry { value, age, fresh })
});
2025-12-08 22:32:42 +01:00
2025-12-12 21:42:04 +00:00
match result {
Ok(entry) => Ok(Some(entry)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(err) => Err(err.into()),
}
})
.await
.map_err(|err| anyhow!(err))?
2025-12-08 22:32:42 +01:00
}
2025-12-12 21:42:04 +00:00
async fn put_json<T: Serialize + Send + Sync>(
&self,
user_id: &str,
namespace: &str,
key: &str,
ttl: Duration,
value: &T,
) -> anyhow::Result<()> {
let user_id = user_id.to_owned();
let namespace = namespace.to_owned();
let key = key.to_owned();
let db_path = self.db_path.clone();
let ttl_seconds = i64::try_from(ttl.as_secs()).unwrap_or(i64::MAX);
let now = Self::now_seconds();
let json = serde_json::to_vec(value)?;
task::spawn_blocking(move || {
let conn = Connection::open(db_path)?;
let tx = conn.transaction()?;
tx.execute(
"INSERT OR REPLACE INTO qobuz_cache
(user_id, namespace, key, fetched_at, ttl_seconds, json)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![user_id, namespace, key, now, ttl_seconds, json],
)?;
tx.commit()?;
Ok(())
})
.await
.map_err(|err| anyhow!(err))?
2025-12-08 22:32:42 +01:00
}
2025-12-13 13:36:37 +01:00
async fn invalidate(&self, user_id: &str, namespace: &str, key: &str) -> anyhow::Result<()> {
2025-12-12 21:42:04 +00:00
let user_id = user_id.to_owned();
let namespace = namespace.to_owned();
let key = key.to_owned();
let db_path = self.db_path.clone();
task::spawn_blocking(move || {
let conn = Connection::open(db_path)?;
conn.execute(
"DELETE FROM qobuz_cache
WHERE user_id = ?1 AND namespace = ?2 AND key = ?3",
params![user_id, namespace, key],
)?;
Ok(())
})
.await
.map_err(|err| anyhow!(err))?
2025-12-08 22:32:42 +01:00
}
2025-12-12 21:42:04 +00:00
async fn purge_expired(&self) -> anyhow::Result<usize> {
let db_path = self.db_path.clone();
let now = Self::now_seconds();
task::spawn_blocking(move || {
let conn = Connection::open(db_path)?;
let changes = conn.execute(
"DELETE FROM qobuz_cache
WHERE (fetched_at + ttl_seconds) <= ?1",
params![now],
)?;
Ok(changes)
})
.await
.map_err(|err| anyhow!(err))?
2025-12-08 22:32:42 +01:00
}
}
2025-12-12 21:42:04 +00:00
pub use {CacheEntry, CacheStore, SqliteCacheStore};