use mineral_model::{Album, Playlist, PlaylistId, SourceKind};
use mineral_script::{CurateOutcome, CuratedEntry, PlaylistBrief, QueryId, ResolveValue};
use mineral_task::{ChannelFetchKind, Priority, TaskEvent, TaskKind, TaskOutcome};
use parking_lot::Mutex;
use rustc_hash::{FxHashMap, FxHashSet};
use crate::player::PlayerCore;
pub(crate) struct Library {
state: Mutex<LibraryInner>,
pipeline: tokio::sync::Mutex<()>,
order: Vec<SourceKind>,
}
struct LibraryInner {
saved_albums: FxHashMap<SourceKind, Vec<Album>>,
raw: FxHashMap<SourceKind, Vec<Playlist>>,
curated: FxHashMap<SourceKind, Vec<Playlist>>,
pending: FxHashSet<SourceKind>,
parked: Vec<QueryId>,
snapshot: Vec<Playlist>,
}
impl Library {
pub(crate) fn replace_saved_albums(
&self,
source: SourceKind,
albums: Vec<Album>,
) -> Vec<Album> {
let mut state = self.state.lock();
state.saved_albums.insert(source, albums);
self.order
.iter()
.filter_map(|source| state.saved_albums.get(source))
.flatten()
.cloned()
.collect()
}
pub(crate) fn cached_saved_albums(&self) -> Option<Vec<Album>> {
let state = self.state.lock();
(!state.saved_albums.is_empty()).then(|| {
self.order
.iter()
.filter_map(|source| state.saved_albums.get(source))
.flatten()
.cloned()
.collect()
})
}
pub(crate) fn new(order: Vec<SourceKind>) -> Self {
let pending = order.iter().copied().collect::<FxHashSet<SourceKind>>();
Self {
state: Mutex::new(LibraryInner {
saved_albums: FxHashMap::default(),
raw: FxHashMap::default(),
curated: FxHashMap::default(),
pending,
parked: Vec::new(),
snapshot: Vec::new(),
}),
pipeline: tokio::sync::Mutex::new(()),
order,
}
}
fn conclude(&self, source: SourceKind, raw: Option<Vec<Playlist>>) {
let mut st = self.state.lock();
match raw {
Some(list) => {
st.raw.insert(source, list);
st.curated.remove(&source);
}
None => {
st.raw.entry(source).or_default();
}
}
st.pending.remove(&source);
}
#[cfg(test)]
fn is_complete(&self) -> bool {
self.state.lock().pending.is_empty()
}
#[cfg(test)]
fn raw_of(&self, source: SourceKind) -> Vec<Playlist> {
self.state
.lock()
.raw
.get(&source)
.cloned()
.unwrap_or_default()
}
fn stale_sources(&self) -> Vec<(SourceKind, Vec<Playlist>)> {
let st = self.state.lock();
self.order
.iter()
.filter(|s| st.raw.contains_key(s) && !st.curated.contains_key(s))
.map(|s| (*s, st.raw.get(s).cloned().unwrap_or_default()))
.collect::<Vec<(SourceKind, Vec<Playlist>)>>()
}
fn set_curated(&self, source: SourceKind, list: Vec<Playlist>) {
self.state.lock().curated.insert(source, list);
}
fn merged_input(&self) -> Vec<Playlist> {
let st = self.state.lock();
self.order
.iter()
.filter_map(|s| st.curated.get(s))
.flat_map(|list| list.iter().cloned())
.collect::<Vec<Playlist>>()
}
fn commit_snapshot(&self, snapshot: Vec<Playlist>) -> Vec<QueryId> {
let mut st = self.state.lock();
st.snapshot = snapshot;
if st.pending.is_empty() {
std::mem::take(&mut st.parked)
} else {
Vec::new()
}
}
fn snapshot_or_park(&self, query: QueryId) -> Option<Vec<Playlist>> {
let mut st = self.state.lock();
if st.pending.is_empty() {
Some(st.snapshot.clone())
} else {
st.parked.push(query);
None
}
}
pub(crate) fn cached_snapshot(&self) -> Option<Vec<Playlist>> {
let st = self.state.lock();
if st.raw.is_empty() {
None
} else {
Some(st.snapshot.clone())
}
}
}
fn apply_curated(originals: Vec<Playlist>, entries: Vec<CuratedEntry>) -> Vec<Playlist> {
let mut by_id = originals
.into_iter()
.map(|p| (p.id.clone(), p))
.collect::<FxHashMap<PlaylistId, Playlist>>();
let mut out = Vec::with_capacity(entries.len());
for entry in entries {
let Some(mut playlist) = by_id.remove(&entry.id) else {
mineral_log::warn!(
target: "library",
id = entry.id.qualified(),
"curate 返回未知或重复 id,丢弃该条"
);
continue;
};
if let Some(name) = entry.name {
playlist.name = name;
}
if let Some(description) = entry.description {
playlist.description = description;
}
out.push(playlist);
}
out
}
impl PlayerCore {
pub(crate) fn submit_my_playlists(&self, source: SourceKind) {
let handle = self.inner.scheduler.submit(
TaskKind::ChannelFetch(ChannelFetchKind::MyPlaylists { source }),
Priority::User,
);
let player = self.clone();
tokio::spawn(async move {
if !matches!(handle.done().await, TaskOutcome::Ok) {
player.library_concluded(source, None);
}
});
}
pub(crate) fn library_concluded(&self, source: SourceKind, raw: Option<Vec<Playlist>>) {
let player = self.clone();
tokio::spawn(async move { player.run_library_pipeline(source, raw).await });
}
pub(crate) fn library_snapshot_or_park(&self, query: QueryId) -> Option<Vec<Playlist>> {
self.library().snapshot_or_park(query)
}
pub(crate) fn push_cached_library_snapshot(&self) {
if let Some(albums) = self.library().cached_saved_albums() {
self.notify()
.task_event(TaskEvent::SavedAlbumsSnapshot { albums });
}
if let Some(playlists) = self.library().cached_snapshot() {
self.notify()
.task_event(TaskEvent::LibrarySnapshot { playlists });
}
}
async fn run_library_pipeline(&self, source: SourceKind, raw: Option<Vec<Playlist>>) {
let library = self.library();
let _serial = library.pipeline.lock().await;
library.conclude(source, raw);
for (src, raw_list) in library.stale_sources() {
let curated = self.curate_via_script(Some(src), raw_list).await;
library.set_curated(src, curated);
}
let merged = library.merged_input();
let snapshot = self.curate_via_script( None, merged).await;
let parked = library.commit_snapshot(snapshot.clone());
self.notify().task_event(TaskEvent::LibrarySnapshot {
playlists: snapshot.clone(),
});
if parked.is_empty() {
return;
}
let Some(sender) = self.script_sender() else {
return;
};
let briefs = snapshot
.iter()
.map(PlaylistBrief::from)
.collect::<Vec<PlaylistBrief>>();
for query in parked {
sender.resolve(query, ResolveValue::Playlists(briefs.clone()));
}
}
async fn curate_via_script(
&self,
source: Option<SourceKind>,
list: Vec<Playlist>,
) -> Vec<Playlist> {
let Some(sender) = self
.script_sender()
.filter(mineral_script::ScriptSender::is_attached)
else {
return list;
};
let briefs = list
.iter()
.map(PlaylistBrief::from)
.collect::<Vec<PlaylistBrief>>();
match sender
.curate_playlists(source, briefs, self.hook_timeout())
.await
{
CurateOutcome::Identity => list,
CurateOutcome::Curated(entries) => apply_curated(list, entries),
}
}
}
#[cfg(test)]
mod tests {
use mineral_model::{Playlist, PlaylistId, SourceKind};
use mineral_script::CuratedEntry;
use pretty_assertions::assert_eq;
use super::{Library, apply_curated};
fn pl(id: &str, name: &str) -> Playlist {
Playlist::builder()
.id(PlaylistId::new(SourceKind::NETEASE, id))
.name(name.to_owned())
.build()
}
fn entry(id: &str, name: Option<&str>) -> CuratedEntry {
CuratedEntry {
id: PlaylistId::new(SourceKind::NETEASE, id),
name: name.map(str::to_owned),
description: None,
}
}
#[test]
fn apply_curated_maps_ids_back_in_order() {
let originals = vec![pl("a", "甲"), pl("b", "乙"), pl("c", "丙")];
let entries = vec![entry("c", None), entry("a", Some("甲改"))];
let out = apply_curated(originals, entries);
let got = out
.iter()
.map(|p| (p.id.value(), p.name.as_str()))
.collect::<Vec<(&str, &str)>>();
assert_eq!(
got,
vec![("c", "丙"), ("a", "甲改")],
"b 被省略即隐藏;顺序按返回序;name 覆盖"
);
}
#[test]
fn apply_curated_drops_unknown_and_duplicate_ids() {
let originals = vec![pl("a", "甲"), pl("b", "乙")];
let entries = vec![
entry("a", None),
entry("ghost", None),
entry("a", Some("重复")),
entry("b", None),
];
let out = apply_curated(originals, entries);
let got = out
.iter()
.map(|p| (p.id.value(), p.name.as_str()))
.collect::<Vec<(&str, &str)>>();
assert_eq!(
got,
vec![("a", "甲"), ("b", "乙")],
"未知 id 与重复 id 都被丢弃,首见生效"
);
}
#[test]
fn complete_only_after_all_sources_concluded() {
let lib = Library::new(vec![SourceKind::NETEASE, SourceKind::BILIBILI]);
assert!(!lib.is_complete());
lib.conclude(SourceKind::NETEASE, Some(vec![pl("a", "甲")]));
assert!(!lib.is_complete(), "还有源在途");
lib.conclude(SourceKind::BILIBILI, None);
assert!(lib.is_complete(), "失败/不支持也是结论(空贡献)");
}
#[test]
fn failure_conclusion_keeps_existing_raw() {
let lib = Library::new(vec![SourceKind::NETEASE]);
lib.conclude(SourceKind::NETEASE, Some(vec![pl("a", "甲")]));
lib.conclude(SourceKind::NETEASE, None);
let raws = lib.raw_of(SourceKind::NETEASE);
assert_eq!(raws.len(), 1, "失败结论只是空贡献兜底,不清已有数据");
}
}