use crate::persistence::Error;
use crate::persistence::entity::{song_kv, song_stats};
use mineral_log::trace;
use mineral_model::SongId;
use mineral_model::StoreValue;
use sea_orm::sea_query::OnConflict;
use sea_orm::{EntityTrait, Set};
use crate::persistence::db::namespace::NamespaceStore;
pub const RESERVED_KEYS: [&str; 3] = ["local_play_count", "rating", "last_played"];
const RATING_MAX: u8 = 5;
impl NamespaceStore {
pub async fn kv_get(&self, id: &SongId, key: &str) -> crate::persistence::Result<StoreValue> {
self.check_source(id.namespace())?;
let Some(db) = self.pool() else {
return Ok(StoreValue::Nil);
};
let Some(row) = song_kv::Entity::find_by_id((
self.namespace().to_owned(),
id.value().to_owned(),
key.to_owned(),
))
.one(db)
.await
.map_err(|source| Error::Key {
operation: "read song_kv",
song: id.value().to_owned(),
key: key.to_owned(),
source,
})?
else {
return Ok(StoreValue::Nil);
};
decode_value(
id.value(),
key,
&row.vtype,
row.int_val,
row.real_val,
row.text_val,
)
}
pub async fn kv_set(
&self,
id: &SongId,
key: &str,
value: &StoreValue,
) -> crate::persistence::Result<()> {
self.check_source(id.namespace())?;
if RESERVED_KEYS.contains(&key) {
return Err(Error::ReservedKey {
key: key.to_owned(),
});
}
let Some(db) = self.pool() else {
return Ok(());
};
trace!(target: "persist", song = id.value(), key, "kv_set");
if matches!(value, StoreValue::Nil) {
song_kv::Entity::delete_by_id((
self.namespace().to_owned(),
id.value().to_owned(),
key.to_owned(),
))
.exec(db)
.await
.map_err(|source| Error::Key {
operation: "delete song_kv",
song: id.value().to_owned(),
key: key.to_owned(),
source,
})?;
return Ok(());
}
let (vtype, int_val, real_val, text_val) = encode_value(value);
song_kv::Entity::insert(song_kv::ActiveModel {
namespace: Set(self.namespace().to_owned()),
song_value: Set(id.value().to_owned()),
key: Set(key.to_owned()),
vtype: Set(vtype.to_owned()),
int_val: Set(int_val),
real_val: Set(real_val),
text_val: Set(text_val),
})
.on_conflict(
OnConflict::columns([
song_kv::Column::Namespace,
song_kv::Column::SongValue,
song_kv::Column::Key,
])
.update_columns([
song_kv::Column::Vtype,
song_kv::Column::IntVal,
song_kv::Column::RealVal,
song_kv::Column::TextVal,
])
.to_owned(),
)
.exec_without_returning(db)
.await
.map_err(|source| Error::Key {
operation: "write song_kv",
song: id.value().to_owned(),
key: key.to_owned(),
source,
})?;
Ok(())
}
pub async fn set_rating(
&self,
id: &SongId,
rating: Option<u8>,
) -> crate::persistence::Result<()> {
self.check_source(id.namespace())?;
if let Some(rating) = rating
&& rating > RATING_MAX
{
return Err(Error::InvalidRating {
rating,
max: RATING_MAX,
});
}
let Some(db) = self.pool() else {
return Ok(());
};
trace!(target: "persist", song = id.value(), ?rating, "set_rating");
song_stats::Entity::insert(song_stats::ActiveModel {
namespace: Set(self.namespace().to_owned()),
song_value: Set(id.value().to_owned()),
rating: Set(rating.map(i64::from)),
..Default::default()
})
.on_conflict(
OnConflict::columns([song_stats::Column::Namespace, song_stats::Column::SongValue])
.update_column(song_stats::Column::Rating)
.to_owned(),
)
.exec_without_returning(db)
.await
.map_err(|source| crate::persistence::Error::Record {
operation: "写 rating",
record: id.value().to_owned(),
source,
})?;
Ok(())
}
pub async fn query_rating(&self, id: &SongId) -> crate::persistence::Result<Option<u8>> {
self.check_source(id.namespace())?;
let Some(db) = self.pool() else {
return Ok(None);
};
let row =
song_stats::Entity::find_by_id((self.namespace().to_owned(), id.value().to_owned()))
.one(db)
.await
.map_err(|source| crate::persistence::Error::Record {
operation: "查 rating",
record: id.value().to_owned(),
source,
})?;
row.and_then(|row| row.rating)
.map(u8::try_from)
.transpose()
.map_err(Into::into)
}
}
fn encode_value(value: &StoreValue) -> (&'static str, Option<i64>, Option<f64>, Option<String>) {
match value {
StoreValue::Int(n) => ("int", Some(*n), None, None),
StoreValue::Real(f) => ("real", None, Some(*f), None),
StoreValue::Text(s) => ("text", None, None, Some(s.clone())),
StoreValue::Bool(b) => ("bool", Some(i64::from(*b)), None, None),
StoreValue::Nil => ("nil", None, None, None),
}
}
fn decode_value(
song: &str,
key: &str,
vtype: &str,
int_val: Option<i64>,
real_val: Option<f64>,
text_val: Option<String>,
) -> crate::persistence::Result<StoreValue> {
match (vtype, int_val, real_val, text_val) {
("int", Some(n), _, _) => Ok(StoreValue::Int(n)),
("real", _, Some(f), _) => Ok(StoreValue::Real(f)),
("text", _, _, Some(s)) => Ok(StoreValue::Text(s)),
("bool", Some(b), _, _) => Ok(StoreValue::Bool(b != 0)),
(vtype, ..) => Err(Error::InvalidValue {
song: song.to_owned(),
key: key.to_owned(),
value_type: vtype.to_owned(),
}),
}
}
#[cfg(test)]
mod tests {
use mineral_model::StoreValue;
use mineral_model::{SongId, SourceKind};
async fn open_scope() -> color_eyre::Result<(
tempfile::TempDir,
crate::persistence::db::namespace::NamespaceStore,
)> {
let dir = tempfile::tempdir()?;
let p = crate::persistence::ServerStore::open(&dir.path().join("t.db")).await?;
let s = p.scope(SourceKind::NETEASE);
Ok((dir, s))
}
#[tokio::test]
async fn song_kv_roundtrips_all_variants() -> color_eyre::Result<()> {
let (_dir, s) = open_scope().await?;
let id = SongId::new(SourceKind::NETEASE, "1");
let cases = [
("plugin.int", StoreValue::Int(-42)),
("plugin.real", StoreValue::Real(2.5)),
("plugin.text", StoreValue::Text("漫游".to_owned())),
("plugin.bool", StoreValue::Bool(true)),
];
for (key, value) in &cases {
s.kv_set(&id, key, value).await?;
assert_eq!(s.kv_get(&id, key).await?, *value, "key={key}");
}
assert_eq!(s.kv_get(&id, "plugin.miss").await?, StoreValue::Nil);
s.kv_set(&id, "plugin.int", &StoreValue::Nil).await?;
assert_eq!(s.kv_get(&id, "plugin.int").await?, StoreValue::Nil);
Ok(())
}
#[tokio::test]
async fn song_kv_isolated_by_source() -> color_eyre::Result<()> {
let dir = tempfile::tempdir()?;
let p = crate::persistence::ServerStore::open(&dir.path().join("t.db")).await?;
let netease = p.scope(SourceKind::NETEASE);
let local = p.scope(SourceKind::LOCAL);
let id_a = SongId::new(SourceKind::NETEASE, "7");
let id_b = SongId::new(SourceKind::LOCAL, "7");
netease
.kv_set(&id_a, "plugin.x", &StoreValue::Int(1))
.await?;
local.kv_set(&id_b, "plugin.x", &StoreValue::Int(2)).await?;
assert_eq!(netease.kv_get(&id_a, "plugin.x").await?, StoreValue::Int(1));
assert_eq!(local.kv_get(&id_b, "plugin.x").await?, StoreValue::Int(2));
Ok(())
}
#[tokio::test]
async fn rating_set_query_clear_and_bounds() -> color_eyre::Result<()> {
let (_dir, s) = open_scope().await?;
let id = SongId::new(SourceKind::NETEASE, "5");
assert_eq!(s.query_rating(&id).await?, None, "未评分为 None");
s.set_rating(&id, Some(4)).await?;
assert_eq!(s.query_rating(&id).await?, Some(4));
s.set_rating(&id, None).await?;
assert_eq!(s.query_rating(&id).await?, None, "None 清空");
assert!(
matches!(
s.set_rating(&id, Some(6)).await,
Err(crate::persistence::Error::InvalidRating { rating: 6, .. })
),
"越界拒绝"
);
Ok(())
}
#[tokio::test]
async fn reserved_keys_rejected_in_open_kv() -> color_eyre::Result<()> {
let (_dir, s) = open_scope().await?;
let id = SongId::new(SourceKind::NETEASE, "3");
for key in super::RESERVED_KEYS {
assert!(
matches!(s.kv_set(&id, key, &StoreValue::Int(1)).await, Err(crate::persistence::Error::ReservedKey { key: rejected }) if rejected == key),
"保留键 {key} 必须拒写"
);
}
Ok(())
}
}