use std::sync::Arc;
use mineral_log::{info, warn};
use mineral_model::{Song, SongId, SourceKind};
use rustc_hash::{FxHashMap, FxHashSet};
use sea_orm::sea_query::OnConflict;
use sea_orm::{
ColumnTrait, DatabaseConnection, EntityTrait, JoinType, PaginatorTrait, QueryFilter,
QueryOrder, QuerySelect, Set, TransactionTrait,
};
use crate::persistence::db::rows::SongArtistRow;
use crate::persistence::db::schema::ensure_schema;
use crate::persistence::db::{NamespaceStore, SessionStore};
use crate::persistence::entity::{
playlist_cache, playlist_entries, song_artists, song_favorites, song_meta,
};
use mineral_persist::CacheIndex;
#[derive(Clone)]
pub struct ServerStore {
backend: Arc<Backend>,
}
pub struct PlaylistCacheStats {
pub playlists: u64,
pub tracks: u64,
}
enum Backend {
Sqlite(DatabaseConnection),
Disabled,
}
impl ServerStore {
pub async fn open(db_path: &std::path::Path) -> crate::persistence::Result<Self> {
info!(target: "persist", path = %db_path.display(), "打开 server 数据库");
let pool = mineral_persist::connect(db_path).await?;
ensure_schema(&pool).await?;
Ok(Self {
backend: Arc::new(Backend::Sqlite(pool)),
})
}
pub fn disabled() -> Self {
warn!(target: "persist", "持久化降级为 no-op(disabled)");
Self {
backend: Arc::new(Backend::Disabled),
}
}
pub(crate) fn pool(&self) -> Option<&DatabaseConnection> {
match self.backend.as_ref() {
Backend::Sqlite(p) => Some(p),
Backend::Disabled => None,
}
}
pub fn scope(&self, source: SourceKind) -> NamespaceStore {
NamespaceStore::new(self.clone(), source)
}
pub fn session(&self) -> SessionStore {
SessionStore::new(self.clone())
}
pub async fn audio_cache(
&self,
root: std::path::PathBuf,
capacity: u64,
) -> crate::persistence::Result<CacheIndex> {
match self.pool() {
Some(pool) => {
Ok(CacheIndex::open(pool.clone(), "audio_cache", root, Some(capacity)).await?)
}
None => Ok(CacheIndex::disabled()),
}
}
pub async fn loved_songs(&self) -> crate::persistence::Result<Vec<Song>> {
let Some(db) = self.pool() else {
return Ok(Vec::new());
};
let rows = song_meta::Entity::find()
.join(
JoinType::InnerJoin,
song_meta::Entity::belongs_to(song_favorites::Entity)
.from((song_meta::Column::Namespace, song_meta::Column::SongValue))
.to((
song_favorites::Column::Namespace,
song_favorites::Column::SongValue,
))
.into(),
)
.order_by_desc(song_favorites::Column::EnteredAt)
.order_by_asc(song_favorites::Column::Namespace)
.order_by_asc(song_favorites::Column::SongValue)
.all(db)
.await
.map_err(|source| crate::persistence::Error::Database {
operation: "查跨源 loved 元数据",
source,
})?;
let artist_rows = song_artists::Entity::find()
.join(
JoinType::InnerJoin,
song_artists::Entity::belongs_to(song_favorites::Entity)
.from((
song_artists::Column::Namespace,
song_artists::Column::SongValue,
))
.to((
song_favorites::Column::Namespace,
song_favorites::Column::SongValue,
))
.into(),
)
.order_by_asc(song_artists::Column::Position)
.all(db)
.await
.map_err(|source| crate::persistence::Error::Database {
operation: "查跨源 loved 艺人",
source,
})?;
let mut artists = FxHashMap::<(String, String), Vec<SongArtistRow>>::default();
for row in artist_rows {
artists
.entry((row.namespace, row.song_value))
.or_default()
.push(SongArtistRow {
artist_id: row.artist_id,
artist_name: row.artist_name,
});
}
rows.into_iter()
.map(|row| {
let key = (row.namespace.clone(), row.song_value.clone());
row.into_song(artists.remove(&key).unwrap_or_default())
})
.collect()
}
pub async fn loved_count(&self) -> crate::persistence::Result<u64> {
let Some(db) = self.pool() else {
return Ok(0);
};
song_meta::Entity::find()
.join(
JoinType::InnerJoin,
song_meta::Entity::belongs_to(song_favorites::Entity)
.from((song_meta::Column::Namespace, song_meta::Column::SongValue))
.to((
song_favorites::Column::Namespace,
song_favorites::Column::SongValue,
))
.into(),
)
.count(db)
.await
.map_err(|source| crate::persistence::Error::Database {
operation: "统计跨源 loved 计数",
source,
})
}
pub async fn missing_meta_loved_ids(&self) -> crate::persistence::Result<Vec<SongId>> {
let Some(db) = self.pool() else {
return Ok(Vec::new());
};
let rows = song_favorites::Entity::find()
.join(
JoinType::LeftJoin,
song_favorites::Entity::belongs_to(song_meta::Entity)
.from((
song_favorites::Column::Namespace,
song_favorites::Column::SongValue,
))
.to((song_meta::Column::Namespace, song_meta::Column::SongValue))
.into(),
)
.filter(song_meta::Column::SongValue.is_null())
.all(db)
.await
.map_err(|source| crate::persistence::Error::Database {
operation: "查缺 meta 的 loved 行",
source,
})?;
Ok(rows
.into_iter()
.map(|row| SongId::new(SourceKind::from_name(&row.namespace), row.song_value))
.collect())
}
pub async fn import_favorites(
&self,
ids: &FxHashSet<SongId>,
) -> crate::persistence::Result<Vec<SongId>> {
let Some(pool) = self.pool() else {
return Ok(Vec::new());
};
let mut ordered = ids.iter().cloned().collect::<Vec<SongId>>();
ordered.sort_by(|left, right| {
left.namespace()
.name()
.cmp(right.namespace().name())
.then_with(|| left.value().cmp(right.value()))
});
let tx = pool
.begin()
.await
.map_err(|source| crate::persistence::Error::Database {
operation: "开启 Favorites import 事务",
source,
})?;
let entered_at = next_favorite_entry_time(&tx).await?;
let mut inserted = Vec::<SongId>::new();
for id in ordered {
let result = song_favorites::Entity::insert(song_favorites::ActiveModel {
namespace: Set(id.namespace().name().to_owned()),
song_value: Set(id.value().to_owned()),
entered_at: Set(entered_at),
})
.on_conflict(
OnConflict::columns([
song_favorites::Column::Namespace,
song_favorites::Column::SongValue,
])
.do_nothing()
.to_owned(),
)
.exec_without_returning(&tx)
.await
.map_err(|source| crate::persistence::Error::Record {
operation: "导入 favorite",
record: id.qualified(),
source,
})?;
if result > 0 {
inserted.push(id);
}
}
tx.commit()
.await
.map_err(|source| crate::persistence::Error::Database {
operation: "提交 Favorites import 事务",
source,
})?;
Ok(inserted)
}
pub async fn playlist_cache_stats(&self) -> crate::persistence::Result<PlaylistCacheStats> {
let Some(db) = self.pool() else {
return Ok(PlaylistCacheStats {
playlists: 0,
tracks: 0,
});
};
let playlists = playlist_cache::Entity::find()
.count(db)
.await
.map_err(|source| crate::persistence::Error::Database {
operation: "统计 playlist_cache 行数",
source,
})?;
let tracks = playlist_entries::Entity::find()
.count(db)
.await
.map_err(|source| crate::persistence::Error::Database {
operation: "统计 playlist_entries 行数",
source,
})?;
Ok(PlaylistCacheStats { playlists, tracks })
}
pub async fn clear_playlist_caches(&self) -> crate::persistence::Result<PlaylistCacheStats> {
let Some(db) = self.pool() else {
return Ok(PlaylistCacheStats {
playlists: 0,
tracks: 0,
});
};
info!(target: "persist", "清理歌单缓存");
let tx = db
.begin()
.await
.map_err(|source| crate::persistence::Error::Database {
operation: "开启 clear_playlist_caches 事务",
source,
})?;
let playlists = playlist_cache::Entity::find()
.count(&tx)
.await
.map_err(|source| crate::persistence::Error::Database {
operation: "统计 playlist_cache 行数",
source,
})?;
let tracks = playlist_entries::Entity::find()
.count(&tx)
.await
.map_err(|source| crate::persistence::Error::Database {
operation: "统计 playlist_entries 行数",
source,
})?;
playlist_entries::Entity::delete_many()
.exec(&tx)
.await
.map_err(|source| crate::persistence::Error::Database {
operation: "清空 playlist_entries",
source,
})?;
playlist_cache::Entity::delete_many()
.exec(&tx)
.await
.map_err(|source| crate::persistence::Error::Database {
operation: "清空 playlist_cache",
source,
})?;
tx.commit()
.await
.map_err(|source| crate::persistence::Error::Database {
operation: "提交 clear_playlist_caches 事务",
source,
})?;
Ok(PlaylistCacheStats { playlists, tracks })
}
}
pub(super) async fn next_favorite_entry_time(
tx: &sea_orm::DatabaseTransaction,
) -> crate::persistence::Result<i64> {
let latest = song_favorites::Entity::find()
.select_only()
.expr(song_favorites::Column::EnteredAt.max())
.into_tuple::<Option<i64>>()
.one(tx)
.await
.map_err(|source| crate::persistence::Error::Database {
operation: "读取 Favorites 最新进入时间",
source,
})?
.flatten();
let now = crate::persistence::db::time::now_ms();
Ok(latest
.map(|value| value.saturating_add(1))
.map_or(now, |next| next.max(now)))
}
#[cfg(test)]
mod tests {
use super::ServerStore;
use crate::persistence::entity::song_favorites;
use sea_orm::sea_query::{Expr, ExprTrait};
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QuerySelect};
#[test]
fn disabled_has_no_pool() {
assert!(ServerStore::disabled().pool().is_none());
}
#[tokio::test]
async fn loved_songs_aggregates_across_namespaces() -> color_eyre::Result<()> {
use mineral_model::{SongId, SourceKind};
use mineral_test::{song, with_artist, with_name};
let dir = tempfile::tempdir()?;
let p = ServerStore::open(&dir.path().join("t.db")).await?;
let netease = p.scope(SourceKind::NETEASE);
let bilibili = p.scope(SourceKind::BILIBILI);
let n1 = with_artist(with_name(song("n1"), "Palisade"), "Mineral");
netease.upsert_meta(&n1).await?;
netease.set_loved(&n1.id, true).await?;
let b1 = {
let mut s = with_name(song("b1"), "夜間飛行");
s.id = SongId::new(SourceKind::BILIBILI, "b1");
s
};
bilibili.upsert_meta(&b1).await?;
bilibili.set_loved(&b1.id, true).await?;
netease
.upsert_meta(&with_name(song("n2"), "unloved"))
.await?;
netease
.set_loved(&SongId::new(SourceKind::NETEASE, "ghost"), true)
.await?;
let pool = p
.pool()
.ok_or_else(|| color_eyre::eyre::eyre!("测试库应有 pool"))?;
song_favorites::Entity::update_many()
.col_expr(song_favorites::Column::EnteredAt, Expr::value(100i64))
.filter(song_favorites::Column::SongValue.eq("n1"))
.exec(pool)
.await?;
song_favorites::Entity::update_many()
.col_expr(song_favorites::Column::EnteredAt, Expr::value(200i64))
.filter(song_favorites::Column::SongValue.eq("b1"))
.exec(pool)
.await?;
let songs = p.loved_songs().await?;
let names = songs.iter().map(|s| s.name.as_str()).collect::<Vec<&str>>();
assert_eq!(
names,
vec!["夜間飛行", "Palisade"],
"按 entered_at 降序,缺 meta 的 ghost 跳过,未 loved 的不出现"
);
let first = songs
.first()
.ok_or_else(|| color_eyre::eyre::eyre!("应有两首"))?;
assert_eq!(first.source(), SourceKind::BILIBILI, "namespace 还原为原源");
let second = songs
.get(1)
.ok_or_else(|| color_eyre::eyre::eyre!("应有两首"))?;
assert_eq!(
second.artists.first().map(|a| a.name.as_str()),
Some("Mineral"),
"艺人列表随 meta 还原"
);
Ok(())
}
#[tokio::test]
async fn loved_count_matches_loved_songs() -> color_eyre::Result<()> {
use mineral_model::{SongId, SourceKind};
use mineral_test::{song, with_name};
let dir = tempfile::tempdir()?;
let p = ServerStore::open(&dir.path().join("t.db")).await?;
let netease = p.scope(SourceKind::NETEASE);
let a = with_name(song("a"), "Alpha");
netease.upsert_meta(&a).await?;
netease.set_loved(&a.id, true).await?;
netease
.upsert_meta(&with_name(song("b"), "unloved"))
.await?;
netease
.set_loved(&SongId::new(SourceKind::NETEASE, "ghost"), true)
.await?;
assert_eq!(p.loved_count().await?, 1, "只计有 meta 的收藏");
assert_eq!(
p.loved_count().await?,
u64::try_from(p.loved_songs().await?.len())?,
"count 与 songs 长度同口径"
);
Ok(())
}
#[tokio::test]
async fn loved_count_disabled_is_zero() -> color_eyre::Result<()> {
assert_eq!(ServerStore::disabled().loved_count().await?, 0);
Ok(())
}
#[tokio::test]
async fn loved_songs_stable_order_on_same_millisecond() -> color_eyre::Result<()> {
use mineral_model::SourceKind;
use mineral_test::{song, with_name};
let dir = tempfile::tempdir()?;
let p = ServerStore::open(&dir.path().join("t.db")).await?;
let netease = p.scope(SourceKind::NETEASE);
let b = with_name(song("bbb"), "Beta");
netease.upsert_meta(&b).await?;
netease.set_loved(&b.id, true).await?;
let a = with_name(song("aaa"), "Alpha");
netease.upsert_meta(&a).await?;
netease.set_loved(&a.id, true).await?;
let pool = p
.pool()
.ok_or_else(|| color_eyre::eyre::eyre!("测试库应有 pool"))?;
song_favorites::Entity::update_many()
.col_expr(song_favorites::Column::EnteredAt, Expr::value(500i64))
.exec(pool)
.await?;
let songs = p.loved_songs().await?;
let names = songs.iter().map(|s| s.name.as_str()).collect::<Vec<&str>>();
assert_eq!(
names,
vec!["Alpha", "Beta"],
"同 entered_at 下按 song_value 升序(aaa 在 bbb 前),与插入序无关"
);
Ok(())
}
#[tokio::test]
async fn import_favorites_is_stable_idempotent_and_newest_first() -> color_eyre::Result<()> {
use mineral_model::{SongId, SourceKind};
use mineral_test::{song, with_name};
use rustc_hash::FxHashSet;
let dir = tempfile::tempdir()?;
let p = ServerStore::open(&dir.path().join("t.db")).await?;
let netease = p.scope(SourceKind::NETEASE);
let bilibili = p.scope(SourceKind::BILIBILI);
let n = with_name(song("n"), "N");
netease.upsert_meta(&n).await?;
let mut b = with_name(song("b"), "B");
b.id = SongId::new(SourceKind::BILIBILI, "b");
bilibili.upsert_meta(&b).await?;
let first = FxHashSet::from_iter([n.id.clone(), b.id.clone()]);
assert_eq!(
p.import_favorites(&first).await?,
vec![b.id.clone(), n.id.clone()],
"返回顺序由 namespace/value 决定,不依赖 HashSet iteration"
);
assert!(
p.import_favorites(&first).await?.is_empty(),
"相同 snapshot 第二次应零 insert"
);
let pool = p
.pool()
.ok_or_else(|| color_eyre::eyre::eyre!("测试库应有 pool"))?;
let (distinct_times,): (i64,) = song_favorites::Entity::find()
.select_only()
.expr(Expr::col(song_favorites::Column::EnteredAt).count_distinct())
.into_tuple::<(i64,)>()
.one(pool)
.await?
.ok_or_else(|| color_eyre::eyre::eyre!("missing favorite count"))?;
assert_eq!(distinct_times, 1, "同一 batch 必须共用 entered_at");
let newer = with_name(song("new"), "New");
netease.upsert_meta(&newer).await?;
let second = FxHashSet::from_iter([n.id.clone(), b.id.clone(), newer.id.clone()]);
assert_eq!(p.import_favorites(&second).await?, vec![newer.id]);
assert_eq!(
p.loved_songs()
.await?
.into_iter()
.map(|song| song.name)
.collect::<Vec<String>>(),
vec!["New".to_owned(), "B".to_owned(), "N".to_owned()],
"新 batch 应整体位于旧 batch 前,旧 membership 不重排"
);
Ok(())
}
#[tokio::test]
async fn relove_returns_to_top_without_same_state_reorder() -> color_eyre::Result<()> {
use mineral_model::SourceKind;
use mineral_test::{song, with_name};
let dir = tempfile::tempdir()?;
let p = ServerStore::open(&dir.path().join("t.db")).await?;
let scope = p.scope(SourceKind::NETEASE);
let a = with_name(song("a"), "A");
let b = with_name(song("b"), "B");
scope.upsert_meta(&a).await?;
scope.upsert_meta(&b).await?;
assert!(scope.set_loved(&a.id, true).await?);
let pool = p
.pool()
.ok_or_else(|| color_eyre::eyre::eyre!("测试库应有 pool"))?;
let entered_at = crate::persistence::db::time::now_ms() + 60_000;
song_favorites::Entity::update_many()
.col_expr(song_favorites::Column::EnteredAt, Expr::value(entered_at))
.filter(song_favorites::Column::Namespace.eq(a.id.namespace().name()))
.filter(song_favorites::Column::SongValue.eq(a.id.value()))
.exec(pool)
.await?;
assert!(scope.set_loved(&b.id, true).await?);
assert!(
!scope.set_loved(&a.id, true).await?,
"true -> true 不应移动 A"
);
assert_eq!(
p.loved_songs()
.await?
.into_iter()
.map(|song| song.name)
.collect::<Vec<String>>(),
vec!["B".to_owned(), "A".to_owned()]
);
assert!(scope.set_loved(&a.id, false).await?);
assert!(scope.set_loved(&a.id, true).await?);
assert_eq!(
p.loved_songs()
.await?
.into_iter()
.map(|song| song.name)
.collect::<Vec<String>>(),
vec!["A".to_owned(), "B".to_owned()]
);
Ok(())
}
#[tokio::test]
async fn metadata_backfill_preserves_favorite_order() -> color_eyre::Result<()> {
use mineral_model::{SongId, SourceKind};
use mineral_test::{song, with_name};
let dir = tempfile::tempdir()?;
let persist = ServerStore::open(&dir.path().join("t.db")).await?;
let scope = persist.scope(SourceKind::NETEASE);
let old_id = SongId::new(SourceKind::NETEASE, "old-missing-meta");
assert!(scope.set_loved(&old_id, true).await?);
let newer = with_name(song("new-visible"), "New");
scope.upsert_meta(&newer).await?;
assert!(scope.set_loved(&newer.id, true).await?);
let pool = persist
.pool()
.ok_or_else(|| color_eyre::eyre::eyre!("测试库应有 pool"))?;
let before: (i64,) = song_favorites::Entity::find_by_id((
SourceKind::NETEASE.name().to_owned(),
old_id.value().to_owned(),
))
.select_only()
.column(song_favorites::Column::EnteredAt)
.into_tuple::<(i64,)>()
.one(pool)
.await?
.ok_or_else(|| color_eyre::eyre::eyre!("missing favorite timestamp"))?;
let old_meta = with_name(song("old-missing-meta"), "Old");
scope.upsert_meta(&old_meta).await?;
let after: (i64,) = song_favorites::Entity::find_by_id((
SourceKind::NETEASE.name().to_owned(),
old_id.value().to_owned(),
))
.select_only()
.column(song_favorites::Column::EnteredAt)
.into_tuple::<(i64,)>()
.one(pool)
.await?
.ok_or_else(|| color_eyre::eyre::eyre!("missing favorite timestamp"))?;
assert_eq!(after, before, "backfill 不得改 entered_at");
assert_eq!(
persist
.loved_songs()
.await?
.into_iter()
.map(|song| song.name)
.collect::<Vec<_>>(),
vec!["New".to_owned(), "Old".to_owned()],
"旧 membership 补齐 metadata 后按既有 entered_at 插回原位置"
);
Ok(())
}
#[tokio::test]
async fn missing_meta_loved_ids_lists_only_meta_less_loved() -> color_eyre::Result<()> {
use mineral_model::{SongId, SourceKind};
use mineral_test::{song, with_name};
let dir = tempfile::tempdir()?;
let p = ServerStore::open(&dir.path().join("t.db")).await?;
let netease = p.scope(SourceKind::NETEASE);
let a = with_name(song("a"), "Alpha");
netease.upsert_meta(&a).await?;
netease.set_loved(&a.id, true).await?;
let ghost = SongId::new(SourceKind::NETEASE, "ghost");
netease.set_loved(&ghost, true).await?;
netease
.upsert_meta(&with_name(song("b"), "unloved"))
.await?;
let ids = p.missing_meta_loved_ids().await?;
assert_eq!(ids, vec![ghost], "只列 loved 且缺 meta 的");
Ok(())
}
#[tokio::test]
async fn loved_songs_disabled_is_empty() -> color_eyre::Result<()> {
assert!(ServerStore::disabled().loved_songs().await?.is_empty());
Ok(())
}
#[tokio::test]
async fn clear_playlist_caches_keeps_user_data() -> color_eyre::Result<()> {
use mineral_model::{CollectionIndex, PlaylistId, SongId, SourceKind};
use mineral_channel_core::store::CachedPlaylistEntry;
let dir = tempfile::tempdir()?;
let p = ServerStore::open(&dir.path().join("t.db")).await?;
let s = p.scope(SourceKind::NETEASE);
let pid = PlaylistId::new(SourceKind::NETEASE, "p1");
let song = SongId::new(SourceKind::NETEASE, "s1");
s.put_playlist_cache(
&pid,
Some("歌单"),
Some(1),
&[CachedPlaylistEntry {
index: CollectionIndex::new(0),
song_id: song.clone(),
}],
)
.await?;
s.set_loved(&song, true).await?;
s.set_rating(&song, Some(4)).await?;
let before = p.playlist_cache_stats().await?;
assert_eq!(before.playlists, 1);
assert_eq!(before.tracks, 1);
let removed = p.clear_playlist_caches().await?;
assert_eq!(removed.playlists, 1);
assert_eq!(removed.tracks, 1);
assert!(s.get_playlist_cache(&pid).await?.is_none());
let after = p.playlist_cache_stats().await?;
assert_eq!(after.playlists, 0);
assert_eq!(after.tracks, 0);
assert!(s.is_loved(&song).await?);
assert_eq!(s.query_rating(&song).await?, Some(4));
Ok(())
}
}