use std::sync::Arc;
use std::time::Duration;
use mineral_channel_core::{Error as ChannelError, MusicChannel};
use mineral_model::{MediaUrl, Song};
use parking_lot::Mutex;
use rustc_hash::FxHashMap;
use super::write::SongTags;
const COVER_TIMEOUT: Duration = Duration::from_secs(15);
#[derive(Clone, Debug, Default)]
pub(crate) struct AlbumTags {
artists: Vec<String>,
year: Option<u32>,
label: Option<String>,
}
pub(crate) type AlbumCache = Arc<Mutex<FxHashMap<String, Option<AlbumTags>>>>;
pub(crate) async fn collect(
channel: &dyn MusicChannel,
http: Option<&reqwest::Client>,
song: &Song,
album_cache: &AlbumCache,
) -> SongTags {
let (album, lyrics, cover) = tokio::join!(
fetch_album(channel, song, album_cache),
fetch_lyrics(channel, song),
fetch_cover(http, song),
);
let mut tags = SongTags {
title: (!song.name.is_empty()).then(|| song.name.clone()),
artists: song
.artists
.iter()
.map(|a| a.name.clone())
.collect::<Vec<_>>(),
album: song.album.as_ref().map(|a| a.name.clone()),
lyrics_lrc: lyrics,
cover,
..SongTags::default()
};
if let Some(album_tags) = album {
tags.album_artists = album_tags.artists;
tags.year = album_tags.year;
tags.label = album_tags.label;
}
tags
}
async fn fetch_album(
channel: &dyn MusicChannel,
song: &Song,
cache: &AlbumCache,
) -> Option<AlbumTags> {
let album_ref = song.album.as_ref()?;
let key = album_ref.id.qualified();
if let Some(hit) = cache.lock().get(&key) {
return hit.clone();
}
let result = match channel.album_detail(&album_ref.id).await {
Ok(album) => Some(AlbumTags {
artists: album
.artists
.iter()
.map(|a| a.name.clone())
.collect::<Vec<_>>(),
year: publish_year(album.publish_time_ms).and_then(|y| u32::try_from(y).ok()),
label: album.company.clone(),
}),
Err(e) => {
mineral_log::warn!(target: "tagging", song_id = song.id.as_str(), error = mineral_log::chain(&e), "拉专辑详情失败,专辑字段缺省");
None
}
};
cache.lock().insert(key, result.clone());
result
}
async fn fetch_lyrics(channel: &dyn MusicChannel, song: &Song) -> Option<String> {
match channel.lyrics(&song.id).await {
Ok(lyrics) if !lyrics.lines.is_empty() => Some(mineral_model::to_lrc_string(&lyrics.lines)),
Ok(_) | Err(ChannelError::NotSupported) => None,
Err(e) => {
mineral_log::warn!(target: "tagging", song_id = song.id.as_str(), error = mineral_log::chain(&e), "拉歌词失败,歌词字段缺省");
None
}
}
}
async fn fetch_cover(http: Option<&reqwest::Client>, song: &Song) -> Option<Vec<u8>> {
let (Some(http), Some(MediaUrl::Remote(url))) = (http, &song.cover_url) else {
return None;
};
let result = async {
let resp = http
.get(url.clone())
.timeout(COVER_TIMEOUT)
.send()
.await?
.error_for_status()?;
resp.bytes().await
}
.await;
match result {
Ok(bytes) => Some(bytes.to_vec()),
Err(e) => {
mineral_log::warn!(target: "tagging", song_id = song.id.as_str(), error = mineral_log::chain(&e), "拉封面失败,封面字段缺省");
None
}
}
}
fn publish_year(ms: i64) -> Option<i32> {
if ms <= 0 {
return None;
}
let beijing = time::UtcOffset::from_hms(8, 0, 0).ok()?;
let dt = time::OffsetDateTime::from_unix_timestamp(ms / 1000).ok()?;
Some(dt.to_offset(beijing).year())
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use std::sync::atomic::Ordering;
use mineral_model::{
Album, AlbumId, AlbumRef, ArtistId, ArtistRef, LyricLine, Lyrics, SongId, SourceKind,
};
use mineral_test::mock::{CannedChannel, serve_once};
use super::*;
fn song(cover: Option<url::Url>) -> Song {
Song::builder()
.id(SongId::new(SourceKind::NETEASE, "186016"))
.name("晴天".to_owned())
.artists(vec![
ArtistRef {
id: ArtistId::new(SourceKind::NETEASE, "6452"),
name: "周杰伦".to_owned(),
},
ArtistRef {
id: ArtistId::new(SourceKind::NETEASE, "0"),
name: "第二艺人".to_owned(),
},
])
.album(Some(AlbumRef {
id: AlbumId::new(SourceKind::NETEASE, "31655"),
name: "叶惠美".to_owned(),
}))
.cover_url(cover.map(MediaUrl::Remote))
.build()
}
fn album() -> Album {
Album::builder()
.id(AlbumId::new(SourceKind::NETEASE, "31655"))
.name("叶惠美".to_owned())
.artists(vec![
ArtistRef {
id: ArtistId::new(SourceKind::NETEASE, "6452"),
name: "周杰伦".to_owned(),
},
ArtistRef {
id: ArtistId::new(SourceKind::NETEASE, "0"),
name: "专辑合艺人".to_owned(),
},
])
.company(Some("杰威尔".to_owned()))
.publish_time_ms(1_059_379_200_000) .build()
}
fn lyrics() -> Lyrics {
Lyrics {
lines: vec![
LyricLine::timed(1000, "故事的小黄花"),
LyricLine::timed(5000, "从出生那年就飘着"),
],
}
}
fn cache() -> AlbumCache {
Arc::new(Mutex::new(FxHashMap::default()))
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn collect_full() -> color_eyre::Result<()> {
let cover_url = serve_once(b"PNG-BYTES".to_vec()).await?;
let channel = CannedChannel {
album: Some(album()),
lyrics: Some(lyrics()),
..CannedChannel::empty()
};
let http = reqwest::Client::new();
let tags = collect(&channel, Some(&http), &song(Some(cover_url)), &cache()).await;
assert_eq!(tags.title.as_deref(), Some("晴天"));
assert_eq!(tags.artists, vec!["周杰伦", "第二艺人"]);
assert_eq!(tags.album.as_deref(), Some("叶惠美"));
assert_eq!(tags.album_artists, vec!["周杰伦", "专辑合艺人"]);
assert_eq!(tags.year, Some(2003));
assert_eq!(tags.label.as_deref(), Some("杰威尔"));
let lrc = tags.lyrics_lrc.unwrap_or_default();
assert!(
lrc.contains("故事的小黄花") && lrc.contains("[00:01.00]"),
"歌词应序列化为 lrc: {lrc}"
);
assert_eq!(tags.cover.as_deref(), Some(&b"PNG-BYTES"[..]));
Ok(())
}
#[tokio::test]
async fn album_detail_is_cached_per_album() -> color_eyre::Result<()> {
let channel = CannedChannel {
album: Some(album()),
..CannedChannel::empty()
};
let calls = Arc::clone(&channel.album_calls);
let cache = cache();
let s1 = song( None);
let mut s2 = song( None);
s2.id = SongId::new(SourceKind::NETEASE, "186017");
collect(&channel, None, &s1, &cache).await;
collect(&channel, None, &s2, &cache).await;
assert_eq!(
calls.load(Ordering::Acquire),
1,
"同专辑第二首应命中缓存,不再拉取"
);
Ok(())
}
#[tokio::test]
async fn failures_degrade_to_missing_fields() -> color_eyre::Result<()> {
let tags = collect(
&CannedChannel::empty(),
Some(&reqwest::Client::new()),
&song(Some("http://127.0.0.1:9/dead.jpg".parse()?)),
&cache(),
)
.await;
assert_eq!(tags.title.as_deref(), Some("晴天"));
assert_eq!(tags.artists, vec!["周杰伦", "第二艺人"]);
assert_eq!(tags.album.as_deref(), Some("叶惠美"));
assert!(tags.album_artists.is_empty(), "专辑详情失败 → 专辑艺人缺省");
assert_eq!(tags.year, None, "专辑详情失败 → 年份缺省");
assert_eq!(tags.label, None, "专辑详情失败 → 厂牌缺省");
assert_eq!(tags.lyrics_lrc, None, "歌词不支持 → 歌词缺省");
assert_eq!(tags.cover, None, "封面 GET 失败 → 封面缺省");
Ok(())
}
#[test]
fn publish_year_uses_beijing_offset() {
assert_eq!(publish_year(1_059_379_200_000), Some(2003));
assert_eq!(publish_year(1_577_808_000_000), Some(2020));
assert_eq!(publish_year(0), None);
assert_eq!(publish_year(-1), None);
}
}