use mineral_model::{MediaUrl, Song};
use rustc_hash::{FxHashMap, FxHashSet};
use sea_orm::sea_query::{self, Expr, Func, Iden, OnConflict};
use sea_orm::{
ColumnTrait, ConnectionTrait, EntityTrait, QueryFilter, QuerySelect, Set, TransactionTrait,
};
use crate::persistence::db::namespace::NamespaceStore;
use crate::persistence::entity::{song_artists, song_meta};
const BATCH_ROWS: usize = 100;
impl NamespaceStore {
pub(crate) async fn insert_missing_meta_batch_on(
&self,
connection: &impl ConnectionTrait,
songs: &[&Song],
) -> crate::persistence::Result<()> {
for song in songs {
self.check_song(song)?;
}
let ns = self.namespace();
for batch in songs.chunks(BATCH_ROWS) {
let existing = song_meta::Entity::find()
.select_only()
.column(song_meta::Column::SongValue)
.filter(song_meta::Column::Namespace.eq(ns))
.filter(
song_meta::Column::SongValue.is_in(batch.iter().map(|song| song.id.value())),
)
.into_tuple::<String>()
.all(connection)
.await
.map_err(|source| crate::persistence::Error::Record {
operation: "查询歌单歌曲已有元数据",
record: ns.to_owned(),
source,
})?
.into_iter()
.collect::<FxHashSet<_>>();
let missing = batch
.iter()
.copied()
.filter(|song| !existing.contains(song.id.value()))
.collect::<Vec<_>>();
if !missing.is_empty() {
write_metadata(connection, ns, &missing).await?;
replace_artists(connection, ns, &missing).await?;
}
}
Ok(())
}
pub async fn replace_meta_batch(&self, songs: &[&Song]) -> crate::persistence::Result<()> {
for song in songs {
self.check_song(song)?;
}
let Some(pool) = self.pool() else {
return Ok(());
};
let namespace = self.namespace();
let transaction =
pool.begin()
.await
.map_err(|source| crate::persistence::Error::Database {
operation: "开启来源元数据替换事务",
source,
})?;
song_artists::Entity::delete_many()
.filter(song_artists::Column::Namespace.eq(namespace))
.exec(&transaction)
.await
.map_err(|source| crate::persistence::Error::Record {
operation: "清空来源歌曲艺人",
record: namespace.to_owned(),
source,
})?;
song_meta::Entity::delete_many()
.filter(song_meta::Column::Namespace.eq(namespace))
.exec(&transaction)
.await
.map_err(|source| crate::persistence::Error::Record {
operation: "清空来源歌曲元数据",
record: namespace.to_owned(),
source,
})?;
for batch in songs.chunks(BATCH_ROWS) {
write_metadata(&transaction, namespace, batch).await?;
replace_artists(&transaction, namespace, batch).await?;
}
transaction
.commit()
.await
.map_err(|source| crate::persistence::Error::Record {
operation: "提交来源元数据替换事务",
record: namespace.to_owned(),
source,
})?;
mineral_log::debug!(target: "persist", source = namespace, songs = songs.len(), "replaced source song metadata");
Ok(())
}
pub async fn upsert_meta(&self, song: &Song) -> crate::persistence::Result<()> {
self.upsert_meta_batch(&[song]).await
}
pub async fn upsert_meta_batch(&self, songs: &[&Song]) -> crate::persistence::Result<()> {
for song in songs {
self.check_song(song)?;
}
let Some(pool) = self.pool().filter(|_| !songs.is_empty()) else {
return Ok(());
};
let started = std::time::Instant::now();
let ns = self.namespace();
let tx = pool
.begin()
.await
.map_err(|source| crate::persistence::Error::Database {
operation: "开启批量元数据事务",
source,
})?;
for batch in songs.chunks(BATCH_ROWS) {
write_metadata(&tx, ns, batch).await?;
replace_artists(&tx, ns, batch).await?;
}
tx.commit()
.await
.map_err(|source| crate::persistence::Error::Record {
operation: "提交批量元数据事务",
record: ns.to_owned(),
source,
})?;
mineral_log::debug!(target: "persist", source = ns, songs = songs.len(),
elapsed_ms = started.elapsed().as_millis(), "stored song metadata batch");
Ok(())
}
}
#[derive(Iden)]
struct Excluded;
async fn write_metadata(
connection: &impl ConnectionTrait,
ns: &str,
songs: &[&Song],
) -> crate::persistence::Result<()> {
let models = songs
.iter()
.map(|song| {
Ok(song_meta::ActiveModel {
namespace: Set(ns.to_owned()),
song_value: Set(song.id.value().to_owned()),
name: Set(song.name.clone()),
alias: Set(song.alias.clone()),
album_id: Set(song.album.as_ref().map(|album| album.id.value().to_owned())),
album_name: Set(song.album.as_ref().map(|album| album.name.clone())),
duration_ms: Set(song.duration_ms.map(i64::try_from).transpose().map_err(
|source| crate::persistence::Error::Duration {
song: song.id.qualified(),
source,
},
)?),
cover_url: Set(song.cover_url.as_ref().map(MediaUrl::to_string)),
})
})
.collect::<crate::persistence::Result<Vec<_>>>()?;
let mut merge =
OnConflict::columns([song_meta::Column::Namespace, song_meta::Column::SongValue]);
merge.update_column(song_meta::Column::Name);
for column in [
song_meta::Column::Alias,
song_meta::Column::AlbumId,
song_meta::Column::AlbumName,
song_meta::Column::DurationMs,
song_meta::Column::CoverUrl,
] {
merge.value(
column,
Func::coalesce([
Expr::col((Excluded, column)),
Expr::col((song_meta::Entity, column)),
]),
);
}
song_meta::Entity::insert_many(models)
.on_conflict(merge)
.exec_without_returning(connection)
.await
.map_err(|source| crate::persistence::Error::Record {
operation: "批量合并 song_meta",
record: ns.to_owned(),
source,
})?;
Ok(())
}
async fn replace_artists(
connection: &impl ConnectionTrait,
ns: &str,
songs: &[&Song],
) -> crate::persistence::Result<()> {
let mut replacements = FxHashMap::default();
for &song in songs {
if !song.artists.is_empty() {
replacements.insert(song.id.value(), song);
}
}
if replacements.is_empty() {
return Ok(());
}
song_artists::Entity::delete_many()
.filter(song_artists::Column::Namespace.eq(ns))
.filter(song_artists::Column::SongValue.is_in(replacements.keys().copied()))
.exec(connection)
.await
.map_err(|source| crate::persistence::Error::Record {
operation: "批量清空 song_artists",
record: ns.to_owned(),
source,
})?;
let mut artists = Vec::new();
for (id, song) in replacements {
for (index, artist) in song.artists.iter().enumerate() {
artists.push(song_artists::ActiveModel {
namespace: Set(ns.to_owned()),
song_value: Set(id.to_owned()),
position: Set(i64::try_from(index)?),
artist_id: Set(artist.id.value().to_owned()),
artist_name: Set(artist.name.clone()),
});
}
}
let batch_count = artists.len().div_ceil(BATCH_ROWS);
let mut artists = artists.into_iter();
for _ in 0..batch_count {
song_artists::Entity::insert_many(artists.by_ref().take(BATCH_ROWS))
.exec_without_returning(connection)
.await
.map_err(|source| crate::persistence::Error::Record {
operation: "批量写入 song_artists",
record: ns.to_owned(),
source,
})?;
}
Ok(())
}