use std::path::{Path, PathBuf};
use lofty::file::AudioFile;
use lofty::probe::Probe;
use mineral_model::{AudioFormat, BitRate, PlaybackMediaInfo, Song, SourceKind};
use mineral_playback::DirectMedia;
use mineral_protocol::PlaybackOrigin;
use crate::media_cache::{MediaCache, library_dir_and_stem};
pub(crate) struct LocalMediaHit {
pub(crate) path: PathBuf,
pub(crate) origin: PlaybackOrigin,
}
pub(crate) fn resolve_local(
media_cache: &MediaCache,
download_root: Option<&Path>,
song: &Song,
want: BitRate,
) -> Option<LocalMediaHit> {
if song.source() == SourceKind::LOCAL
|| song
.source_url
.as_ref()
.is_some_and(mineral_model::MediaUrl::is_local)
{
return None;
}
for &q in BitRate::ALL.iter().rev() {
if q < want {
break;
}
if let Some(path) = media_cache.get(&song.id, q) {
return Some(LocalMediaHit {
path,
origin: PlaybackOrigin::Cache,
});
}
if let Some(root) = download_root
&& let Some(path) = probe_export(root, song, q)
{
return Some(LocalMediaHit {
path,
origin: PlaybackOrigin::Download,
});
}
}
None
}
pub(crate) fn probe_export(root: &Path, song: &Song, quality: BitRate) -> Option<PathBuf> {
let (subdir, stem) = library_dir_and_stem(song, quality);
let dir = root.join(subdir);
for entry in std::fs::read_dir(&dir).ok()?.flatten() {
let path = entry.path();
let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
continue;
};
let Some((file_stem, ext)) = name.rsplit_once('.') else {
continue;
};
if file_stem == stem && is_audio_ext(ext) && path.is_file() {
return Some(path);
}
}
None
}
pub(crate) fn local_media(song: &Song, path: &Path) -> DirectMedia {
let size = std::fs::metadata(path).map(|m| m.len()).ok();
let (format, probed_kbps, bit_depth) = probe_format_props(path);
let bitrate_bps = probed_kbps
.and_then(|kbps| kbps.checked_mul(1000))
.or_else(|| est_bitrate_bps(size, song.duration_ms));
let info = PlaybackMediaInfo {
song_id: song.id.clone(),
bitrate_bps,
size,
format,
bit_depth,
substituted: false,
};
DirectMedia::local(info, path.to_path_buf())
}
fn probe_format_props(path: &Path) -> (Option<AudioFormat>, Option<u32>, Option<u8>) {
let Some(ft) = detect_file_type(path) else {
return (None, None, None);
};
let (kbps, bit_depth) = read_audio_props(path, ft);
(file_type_to_format(ft), kbps, bit_depth)
}
fn detect_file_type(path: &Path) -> Option<lofty::file::FileType> {
let file = std::fs::File::open(path).ok()?;
Probe::new(std::io::BufReader::new(file))
.guess_file_type()
.ok()?
.file_type()
}
fn read_audio_props(path: &Path, ft: lofty::file::FileType) -> (Option<u32>, Option<u8>) {
let Ok(file) = std::fs::File::open(path) else {
return (None, None);
};
let Ok(tagged) = Probe::new(std::io::BufReader::new(file))
.set_file_type(ft)
.read()
else {
return (None, None);
};
let props = tagged.properties();
(props.audio_bitrate(), props.bit_depth())
}
fn est_bitrate_bps(size: Option<u64>, duration_ms: Option<u64>) -> Option<u32> {
size?
.saturating_mul(8000)
.checked_div(duration_ms?)
.and_then(|bps| u32::try_from(bps).ok())
}
fn file_type_to_format(ft: lofty::file::FileType) -> Option<AudioFormat> {
use lofty::file::FileType;
match ft {
FileType::Mpeg => Some(AudioFormat::Mp3),
FileType::Flac => Some(AudioFormat::Flac),
FileType::Mp4 => Some(AudioFormat::Mp4),
FileType::Vorbis => Some(AudioFormat::Ogg),
FileType::Wav => Some(AudioFormat::Wav),
FileType::Ape => Some(AudioFormat::Ape),
FileType::Aac => Some(AudioFormat::Aac),
FileType::Opus => Some(AudioFormat::Other("opus".to_owned())),
_ => None,
}
}
fn is_audio_ext(ext: &str) -> bool {
matches!(
ext.to_ascii_lowercase().as_str(),
"mp3" | "flac" | "aac" | "mp4" | "m4a" | "ogg" | "opus" | "wav" | "ape" | "alac"
)
}
#[cfg(test)]
mod tests {
use std::path::{Path, PathBuf};
use crate::persistence::ServerStore;
use mineral_model::{AlbumId, AlbumRef, AudioFormat, BitRate, Song, SongId, SourceKind};
use mineral_playback::DirectLocator;
use mineral_protocol::PlaybackOrigin;
use super::{local_media, probe_export, resolve_local};
use crate::media_cache::{MediaCache, library_relpath};
fn song(id: &str, name: &str, album: Option<&str>) -> Song {
Song::builder()
.id(SongId::new(SourceKind::NETEASE, id))
.name(name.to_owned())
.album(album.map(|a| AlbumRef {
id: AlbumId::new(SourceKind::NETEASE, "0"),
name: a.to_owned(),
}))
.build()
}
async fn open_cache(persist: &ServerStore, dir: PathBuf) -> color_eyre::Result<MediaCache> {
Ok(MediaCache::open(persist, dir, 1_000_000).await?)
}
#[tokio::test]
async fn original_local_resource_skips_download_lookup() -> color_eyre::Result<()> {
let temp = tempfile::tempdir()?;
let store = ServerStore::open(&temp.path().join("mineral.db")).await?;
let cache = open_cache(&store, temp.path().join("cache")).await?;
let downloads = temp.path().join("downloads");
let original = temp.path().join("original.wav");
mineral_test::write_wav(&original, &[100; 4_800], 1, 48_000)?;
for source in [SourceKind::NETEASE, SourceKind::LOCAL] {
let mut song = song("original", "Original", None);
song.id = SongId::new(source, "original");
song.source_url = Some(mineral_model::MediaUrl::local(&original));
put_download(
&downloads,
&song,
BitRate::Lossless,
&AudioFormat::Wav,
b"copy",
)?;
assert!(resolve_local(&cache, Some(&downloads), &song, BitRate::Standard).is_none());
if source == SourceKind::LOCAL {
let scope = store.scope(source);
scope.upsert_meta(&song).await?;
scope.set_loved(&song.id, true).await?;
let favorite = store
.loved_songs()
.await?
.into_iter()
.find(|favorite| favorite.id == song.id)
.ok_or_else(|| color_eyre::eyre::eyre!("missing local favorite"))?;
assert!(favorite.source_url.is_none());
assert!(
resolve_local(&cache, Some(&downloads), &favorite, BitRate::Standard).is_none()
);
}
}
Ok(())
}
fn put_download(
root: &Path,
s: &Song,
quality: BitRate,
format: &AudioFormat,
bytes: &[u8],
) -> color_eyre::Result<PathBuf> {
let (subdir, file_name) = library_relpath(s, quality, Some(format));
let abs = root.join(&subdir).join(&file_name);
if let Some(parent) = abs.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&abs, bytes)?;
Ok(abs)
}
async fn put_cache(
cache: &MediaCache,
tmp_dir: &Path,
s: &Song,
quality: BitRate,
format: &AudioFormat,
bytes: &[u8],
) -> color_eyre::Result<()> {
let src = tmp_dir.join(format!("cap-{}-{}.part", s.id.value(), quality.as_str()));
tokio::fs::write(&src, bytes).await?;
cache.put_played(s, quality, Some(format), &src).await?;
Ok(())
}
#[tokio::test]
async fn cache_only_hit() -> color_eyre::Result<()> {
let d = tempfile::tempdir()?;
let persist = ServerStore::open(&d.path().join("t.db")).await?;
let cache = open_cache(&persist, d.path().join("cache")).await?;
let root = d.path().join("music");
let s = song("1", "晴天", Some("叶惠美"));
put_cache(
&cache,
d.path(),
&s,
BitRate::Exhigh,
&AudioFormat::Mp3,
b"AUDIO",
)
.await?;
let Some(hit) = resolve_local(&cache, Some(&root), &s, BitRate::Exhigh) else {
return Err(color_eyre::eyre::eyre!("应命中 cache"));
};
assert_eq!(hit.origin, PlaybackOrigin::Cache);
assert_eq!(tokio::fs::read(&hit.path).await?, b"AUDIO");
Ok(())
}
#[tokio::test]
async fn download_only_hit() -> color_eyre::Result<()> {
let d = tempfile::tempdir()?;
let cache = MediaCache::disabled();
let root = d.path().join("music");
let s = song("1", "晴天", Some("叶惠美"));
put_download(&root, &s, BitRate::Lossless, &AudioFormat::Flac, b"FLAC")?;
let Some(hit) = resolve_local(&cache, Some(&root), &s, BitRate::Exhigh) else {
return Err(color_eyre::eyre::eyre!("应命中下载导出文件"));
};
assert_eq!(hit.origin, PlaybackOrigin::Download);
assert!(hit.path.ends_with("netease/lossless/叶惠美/晴天.flac"));
assert_eq!(tokio::fs::read(&hit.path).await?, b"FLAC");
Ok(())
}
#[tokio::test]
async fn same_quality_prefers_cache() -> color_eyre::Result<()> {
let d = tempfile::tempdir()?;
let persist = ServerStore::open(&d.path().join("t.db")).await?;
let cache = open_cache(&persist, d.path().join("cache")).await?;
let root = d.path().join("music");
let s = song("1", "晴天", Some("叶惠美"));
put_cache(
&cache,
d.path(),
&s,
BitRate::Exhigh,
&AudioFormat::Mp3,
b"FROM_CACHE",
)
.await?;
put_download(&root, &s, BitRate::Exhigh, &AudioFormat::Mp3, b"FROM_DL")?;
let Some(hit) = resolve_local(&cache, Some(&root), &s, BitRate::Exhigh) else {
return Err(color_eyre::eyre::eyre!("应命中"));
};
assert_eq!(hit.origin, PlaybackOrigin::Cache, "同音质应取 cache");
assert_eq!(
tokio::fs::read(&hit.path).await?,
b"FROM_CACHE",
"同音质应取 cache"
);
Ok(())
}
#[tokio::test]
async fn higher_download_beats_cache() -> color_eyre::Result<()> {
let d = tempfile::tempdir()?;
let persist = ServerStore::open(&d.path().join("t.db")).await?;
let cache = open_cache(&persist, d.path().join("cache")).await?;
let root = d.path().join("music");
let s = song("1", "捕风", Some("野泳 (Wild Swim)"));
put_cache(
&cache,
d.path(),
&s,
BitRate::Exhigh,
&AudioFormat::Mp3,
b"FROM_CACHE",
)
.await?;
put_download(&root, &s, BitRate::Lossless, &AudioFormat::Flac, b"FROM_DL")?;
let Some(hit) = resolve_local(&cache, Some(&root), &s, BitRate::Exhigh) else {
return Err(color_eyre::eyre::eyre!("应命中"));
};
assert_eq!(hit.origin, PlaybackOrigin::Download, "更高音质应取下载导出");
assert_eq!(
tokio::fs::read(&hit.path).await?,
b"FROM_DL",
"更高音质应取下载导出"
);
Ok(())
}
#[tokio::test]
async fn below_threshold_misses() -> color_eyre::Result<()> {
let d = tempfile::tempdir()?;
let cache = MediaCache::disabled();
let root = d.path().join("music");
let s = song("1", "晴天", Some("叶惠美"));
put_download(&root, &s, BitRate::Standard, &AudioFormat::Mp3, b"LOW")?;
assert!(
resolve_local(&cache, Some(&root), &s, BitRate::Exhigh).is_none(),
"低于 want 的本地副本不应命中"
);
Ok(())
}
#[tokio::test]
async fn missing_file_misses() -> color_eyre::Result<()> {
let d = tempfile::tempdir()?;
let cache = MediaCache::disabled();
let root = d.path().join("music");
let s = song("1", "晴天", Some("叶惠美"));
let abs = put_download(&root, &s, BitRate::Lossless, &AudioFormat::Flac, b"FLAC")?;
std::fs::remove_file(&abs)?;
assert!(
resolve_local(&cache, Some(&root), &s, BitRate::Exhigh).is_none(),
"文件没了应当 miss"
);
Ok(())
}
#[tokio::test]
async fn ignores_part_dl_leftover() -> color_eyre::Result<()> {
let d = tempfile::tempdir()?;
let cache = MediaCache::disabled();
let root = d.path().join("music");
let s = song("1", "晴天", Some("叶惠美"));
let dir = root.join("netease/lossless/叶惠美");
std::fs::create_dir_all(&dir)?;
tokio::fs::write(dir.join("晴天.part-dl"), b"HALF").await?;
assert!(
resolve_local(&cache, Some(&root), &s, BitRate::Exhigh).is_none(),
".part-dl 残件不应命中"
);
Ok(())
}
#[tokio::test]
async fn no_download_root_uses_cache_only() -> color_eyre::Result<()> {
let cache = MediaCache::disabled();
let s = song("1", "晴天", Some("叶惠美"));
assert!(resolve_local(&cache, None, &s, BitRate::Exhigh).is_none());
Ok(())
}
fn wav_bytes(data_len: usize) -> Vec<u8> {
let data = u32::try_from(data_len).unwrap_or(0);
let mut v = Vec::new();
v.extend_from_slice(b"RIFF");
v.extend_from_slice(&36u32.saturating_add(data).to_le_bytes()); v.extend_from_slice(b"WAVE");
v.extend_from_slice(b"fmt ");
v.extend_from_slice(&16u32.to_le_bytes()); v.extend_from_slice(&1u16.to_le_bytes()); v.extend_from_slice(&1u16.to_le_bytes()); v.extend_from_slice(&8000u32.to_le_bytes()); v.extend_from_slice(&8000u32.to_le_bytes()); v.extend_from_slice(&1u16.to_le_bytes()); v.extend_from_slice(&8u16.to_le_bytes()); v.extend_from_slice(b"data");
v.extend_from_slice(&data.to_le_bytes()); v.resize(v.len() + data_len, 0u8);
v
}
fn id3_prefixed_mp3() -> Vec<u8> {
let mut v = Vec::new();
v.extend_from_slice(b"ID3");
v.extend_from_slice(&[0x04, 0x00, 0x00]);
v.extend_from_slice(&[0x00, 0x00, 0x00, 0x23]); v.resize(v.len() + 35, 0u8); v.extend_from_slice(&[0xFF, 0xFB, 0x90, 0x00]);
v.resize(v.len() + 417 - 4, 0u8);
v
}
#[tokio::test]
async fn local_media_detects_id3_prefixed_mp3() -> color_eyre::Result<()> {
let d = tempfile::tempdir()?;
let root = d.path().join("music");
let s = song("1", "环岛", Some("夜晚做决定"));
let bytes = id3_prefixed_mp3();
assert!(
lofty::file::FileType::from_buffer(&bytes).is_none(),
"from_buffer 对 ID3 前缀应返回 None"
);
let abs = put_download(&root, &s, BitRate::Exhigh, &AudioFormat::Mp3, &bytes)?;
let media = local_media(&s, &abs);
assert_eq!(
media.info().format,
Some(AudioFormat::Mp3),
"ID3 前缀的 mp3 应判 Mp3"
);
Ok(())
}
#[tokio::test]
async fn local_media_format_and_bitrate_from_content() -> color_eyre::Result<()> {
let d = tempfile::tempdir()?;
let root = d.path().join("music");
let mut s = song("1", "晴天", Some("叶惠美"));
s.duration_ms = Some(1000); let abs = put_download(
&root,
&s,
BitRate::Lossless,
&AudioFormat::Flac,
&wav_bytes(8000),
)?;
assert!(
abs.ends_with("netease/lossless/叶惠美/晴天.flac"),
"盘上是 .flac 名"
);
let media = local_media(&s, &abs);
assert_eq!(
media.info().format,
Some(AudioFormat::Wav),
"应按内容判 WAV,而非文件名的 .flac"
);
assert_eq!(media.info().size, Some(8044));
assert_eq!(
media.info().bitrate_bps,
Some(64_000),
"lofty 解析 64kbps(非 size/时长 估算)"
);
assert!(matches!(media.locator(), DirectLocator::Local(_)));
Ok(())
}
#[tokio::test]
async fn local_media_unknown_content_estimates_bitrate() -> color_eyre::Result<()> {
let d = tempfile::tempdir()?;
let root = d.path().join("music");
let mut s = song("1", "晴天", Some("叶惠美"));
s.duration_ms = Some(1000);
let abs = put_download(
&root,
&s,
BitRate::Lossless,
&AudioFormat::Flac,
&[0u8; 16000],
)?;
let media = local_media(&s, &abs);
assert!(media.info().format.is_none(), "乱字节识别不出格式 → None");
assert_eq!(
media.info().bitrate_bps,
Some(128_000),
"回退估算:16000B/1s → 128kbps"
);
Ok(())
}
#[tokio::test]
async fn local_media_unknown_content_without_duration() -> color_eyre::Result<()> {
let d = tempfile::tempdir()?;
let root = d.path().join("music");
let s = song("1", "晴天", Some("叶惠美")); let abs = put_download(
&root,
&s,
BitRate::Lossless,
&AudioFormat::Flac,
&[0u8; 8000],
)?;
let media = local_media(&s, &abs);
assert!(media.info().format.is_none());
assert_eq!(media.info().bitrate_bps, None, "时长未知且无解析 → None");
Ok(())
}
#[test]
fn file_type_to_format_maps_known() {
use lofty::file::FileType;
assert_eq!(
super::file_type_to_format(FileType::Mpeg),
Some(AudioFormat::Mp3)
);
assert_eq!(
super::file_type_to_format(FileType::Flac),
Some(AudioFormat::Flac)
);
assert_eq!(
super::file_type_to_format(FileType::Wav),
Some(AudioFormat::Wav)
);
assert_eq!(
super::file_type_to_format(FileType::Vorbis),
Some(AudioFormat::Ogg)
);
assert_eq!(
super::file_type_to_format(FileType::Mp4),
Some(AudioFormat::Mp4)
);
}
#[tokio::test]
async fn mp4_export_is_available_for_download_and_playback() -> color_eyre::Result<()> {
let dir = tempfile::tempdir()?;
let song = song("mp4", "Audio", None);
let format = AudioFormat::from("m4a".to_owned());
let exported = put_download(
dir.path(),
&song,
BitRate::Lossless,
&format,
b"encoded audio",
)?;
assert_eq!(
exported.extension().and_then(|ext| ext.to_str()),
Some("mp4")
);
assert_eq!(
probe_export(dir.path(), &song, BitRate::Lossless),
Some(exported.clone())
);
let hit = resolve_local(
&MediaCache::disabled(),
Some(dir.path()),
&song,
BitRate::Exhigh,
)
.ok_or_else(|| color_eyre::eyre::eyre!("missing MP4 download"))?;
assert_eq!(hit.path, exported);
assert_eq!(hit.origin, PlaybackOrigin::Download);
Ok(())
}
#[tokio::test]
async fn probe_export_finds_readable_path() -> color_eyre::Result<()> {
let d = tempfile::tempdir()?;
let root = d.path().join("music");
let s = song("1", "晴天", Some("叶惠美"));
put_download(&root, &s, BitRate::Lossless, &AudioFormat::Flac, b"FLAC")?;
let Some(path) = probe_export(&root, &s, BitRate::Lossless) else {
return Err(color_eyre::eyre::eyre!("应反查到文件"));
};
assert!(path.ends_with("netease/lossless/叶惠美/晴天.flac"));
assert!(probe_export(&root, &s, BitRate::Exhigh).is_none());
Ok(())
}
}