use std::path::PathBuf;
use crate::persistence::ServerStore;
use mineral_model::{AudioFormat, BitRate, Song, SongId};
use mineral_persist::CacheIndex;
use crate::playback_instance::PlaybackInstanceId;
const SEGMENT_MAX_BYTES: usize = 200;
pub(crate) struct MediaCache {
index: CacheIndex,
root: Option<PathBuf>,
}
impl MediaCache {
pub(crate) async fn open(
persist: &ServerStore,
dir: PathBuf,
capacity: u64,
) -> Result<Self, crate::persistence::Error> {
let tmp = dir.join("tmp");
if tmp.is_dir() {
drop(std::fs::remove_dir_all(&tmp));
}
let index = persist.audio_cache(dir.clone(), capacity).await?;
Ok(Self {
index,
root: Some(dir),
})
}
pub(crate) fn disabled() -> Self {
Self {
index: CacheIndex::disabled(),
root: None,
}
}
pub(crate) fn capture_path(
&self,
song_id: &SongId,
quality: BitRate,
instance_id: PlaybackInstanceId,
) -> Option<PathBuf> {
let root = self.root.as_ref()?;
let safe = sanitize_segment(&cache_key(song_id, quality), "capture");
Some(root.join("tmp").join(format!("{safe}-{instance_id}.part")))
}
pub(crate) fn get(&self, song_id: &SongId, quality: BitRate) -> Option<PathBuf> {
self.index.get(&cache_key(song_id, quality))
}
pub(crate) async fn put_played(
&self,
song: &Song,
quality: BitRate,
format: Option<&AudioFormat>,
src: &std::path::Path,
) -> Result<Vec<mineral_persist::Evicted>, mineral_persist::Error> {
let key = cache_key(&song.id, quality);
let (subdir, file_name) = library_relpath(song, quality, format);
self.index.record_file(&key, src, &subdir, &file_name).await
}
}
pub(crate) fn library_relpath(
song: &Song,
quality: BitRate,
format: Option<&AudioFormat>,
) -> (String, String) {
let (subdir, stem) = library_dir_and_stem(song, quality);
let ext_seg = sanitize_segment(&ext_for(format, quality), "bin");
(subdir, format!("{stem}.{ext_seg}"))
}
pub(crate) fn library_dir_and_stem(song: &Song, quality: BitRate) -> (String, String) {
let album = song.album.as_ref().map(|a| a.name.as_str());
let title = if song.name.is_empty() {
song.id.as_str()
} else {
song.name.as_str()
};
(
library_subdir(song.source().name(), album, quality),
sanitize_segment(title, "_untitled"),
)
}
pub(crate) fn cache_key(song_id: &SongId, quality: BitRate) -> String {
format!("{}:{}", song_id.qualified(), quality.as_str())
}
fn library_subdir(source: &str, album: Option<&str>, quality: BitRate) -> String {
let source_seg = sanitize_segment(source, "_unknown");
let album_seg = sanitize_segment(album.unwrap_or(""), "_unknown");
format!("{source_seg}/{}/{album_seg}", quality.as_str())
}
fn ext_for(format: Option<&AudioFormat>, quality: BitRate) -> String {
if let Some(f) = format {
return f.as_str().to_owned();
}
match quality {
BitRate::Lossless | BitRate::Hires => "flac",
BitRate::Standard | BitRate::Higher | BitRate::Exhigh => "mp3",
}
.to_owned()
}
fn sanitize_segment(raw: &str, fallback: &str) -> String {
let mut out = String::with_capacity(raw.len());
for ch in raw.chars() {
if matches!(ch, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|') || ch.is_control() {
out.push('_');
} else {
out.push(ch);
}
}
let trimmed = out.trim().trim_end_matches('.').trim();
let truncated = truncate_bytes(trimmed, SEGMENT_MAX_BYTES);
if truncated.is_empty() {
fallback.to_owned()
} else {
truncated.to_owned()
}
}
fn truncate_bytes(s: &str, max: usize) -> &str {
if s.len() <= max {
return s;
}
let mut end = max;
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
s.get(..end).unwrap_or(s)
}
#[cfg(test)]
mod tests {
use mineral_model::{AlbumId, AlbumRef, AudioFormat, BitRate, Song, SongId, SourceKind};
use crate::persistence::ServerStore;
use crate::playback_instance::PlaybackInstanceId;
use super::{
MediaCache, cache_key, ext_for, library_relpath, sanitize_segment, truncate_bytes,
};
async fn open_cache(
db: &std::path::Path,
dir: std::path::PathBuf,
) -> color_eyre::Result<MediaCache> {
let persist = ServerStore::open(db).await?;
let capacity = *crate::config::DaemonConfig::defaults()?
.cache()
.audio_capacity();
Ok(MediaCache::open(&persist, dir, capacity).await?)
}
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()
}
#[test]
fn key_includes_qualified_and_quality() {
let s = song("186016", "晴天", Some("叶惠美"));
assert_eq!(
cache_key(&s.id, BitRate::Lossless),
"netease:186016:lossless"
);
assert_eq!(cache_key(&s.id, BitRate::Exhigh), "netease:186016:exhigh");
assert_ne!(
cache_key(&s.id, BitRate::Lossless),
cache_key(&s.id, BitRate::Exhigh),
"不同音质应是不同键"
);
}
#[test]
fn relpath_is_readable_library_layout() {
let s = song("186016", "晴天", Some("叶惠美"));
let (subdir, file) = library_relpath(&s, BitRate::Lossless, Some(&AudioFormat::Flac));
assert_eq!(subdir, "netease/lossless/叶惠美");
assert_eq!(file, "晴天.flac");
}
#[test]
fn relpath_falls_back_on_empty_album_and_title() {
let s = song("186016", " ", None);
let (subdir, file) = library_relpath(&s, BitRate::Higher, Some(&AudioFormat::Mp3));
assert_eq!(subdir, "netease/higher/_unknown");
assert_eq!(file, "_untitled.mp3");
}
#[tokio::test]
async fn put_played_then_get_roundtrips() -> color_eyre::Result<()> {
let d = tempfile::tempdir()?;
let cache = open_cache(&d.path().join("t.db"), d.path().join("cache")).await?;
let s = song("186016", "晴天", Some("叶惠美"));
let src = d.path().join("capture.part");
tokio::fs::write(&src, b"FLACdata").await?;
cache
.put_played(&s, BitRate::Lossless, Some(&AudioFormat::Flac), &src)
.await?;
let Some(path) = cache.get(&s.id, BitRate::Lossless) else {
return Err(color_eyre::eyre::eyre!("入库后应命中"));
};
assert!(
path.ends_with("netease/lossless/叶惠美/晴天.flac"),
"应落到可读库路径: {}",
path.display()
);
assert_eq!(tokio::fs::read(&path).await?, b"FLACdata");
assert!(!src.exists(), "源 capture 文件应被移走");
assert!(cache.get(&s.id, BitRate::Exhigh).is_none());
Ok(())
}
#[tokio::test]
async fn survives_daemon_restart_no_redownload_dup() -> color_eyre::Result<()> {
let d = tempfile::tempdir()?;
let db = d.path().join("mineral.db");
let dir = d.path().join("cache");
let s = song("186016", "昨天的去向", Some("夜晚做决定"));
let first_path = {
let cache = open_cache(&db, dir.clone()).await?;
let src = d.path().join("cap.part");
tokio::fs::write(&src, b"AUDIO").await?;
cache
.put_played(&s, BitRate::Exhigh, Some(&AudioFormat::Mp3), &src)
.await?;
let Some(p) = cache.get(&s.id, BitRate::Exhigh) else {
return Err(color_eyre::eyre::eyre!("入库后应命中"));
};
p
};
let reopened = open_cache(&db, dir.clone()).await?;
let Some(p2) = reopened.get(&s.id, BitRate::Exhigh) else {
return Err(color_eyre::eyre::eyre!("重启后应仍命中,不该回退重下"));
};
assert_eq!(p2, first_path, "重启后应命中同一文件");
let src2 = d.path().join("cap2.part");
tokio::fs::write(&src2, b"AUDIO2").await?;
reopened
.put_played(&s, BitRate::Exhigh, Some(&AudioFormat::Mp3), &src2)
.await?;
let album_dir = dir.join("netease/exhigh/夜晚做决定");
let mut names = Vec::<String>::new();
for entry in std::fs::read_dir(&album_dir)? {
if let Some(name) = entry?.file_name().to_str() {
names.push(name.to_owned());
}
}
assert_eq!(
names,
vec!["昨天的去向.mp3".to_owned()],
"同 key 应原地覆盖,绝不产生 ` (2)` 副本"
);
Ok(())
}
#[tokio::test]
async fn capture_path_under_tmp() -> color_eyre::Result<()> {
let d = tempfile::tempdir()?;
let dir = d.path().join("cache");
let cache = open_cache(&d.path().join("t.db"), dir.clone()).await?;
let s = song("186016", "晴天", None);
let instance_id = PlaybackInstanceId::new();
let Some(p) = cache.capture_path(&s.id, BitRate::Lossless, instance_id) else {
return Err(color_eyre::eyre::eyre!("启用态应给 capture 路径"));
};
assert!(
p.starts_with(dir.join("tmp")),
"应在 tmp/ 下: {}",
p.display()
);
assert_eq!(
p.file_name().and_then(|name| name.to_str()),
Some(format!("netease_186016_lossless-{instance_id}.part").as_str())
);
Ok(())
}
#[tokio::test]
async fn disabled_is_null_object() -> color_eyre::Result<()> {
let cache = MediaCache::disabled();
let s = song("1", "x", None);
assert!(cache.get(&s.id, BitRate::Lossless).is_none());
assert!(
cache
.capture_path(&s.id, BitRate::Lossless, PlaybackInstanceId::new())
.is_none()
);
cache
.put_played(
&s,
BitRate::Lossless,
Some(&AudioFormat::Flac),
std::path::Path::new("/nope"),
)
.await?;
Ok(())
}
#[test]
fn sanitize_replaces_illegal_chars() {
assert_eq!(sanitize_segment("AC/DC: Back?", "_x"), "AC_DC_ Back_");
assert_eq!(sanitize_segment(" ", "_unknown"), "_unknown");
assert_eq!(sanitize_segment("a/b", "_x"), "a_b");
}
#[test]
fn sanitize_truncates_on_char_boundary() {
let long = "晴".repeat(100); let out = sanitize_segment(&long, "_x");
assert!(out.len() <= 200, "应截到 <=200 字节, got {}", out.len());
assert!(out.chars().all(|c| c == '晴'), "不应在字符中间截断出乱码");
}
#[test]
fn truncate_keeps_char_boundary() {
assert_eq!(truncate_bytes("abcdef", 3), "abc");
assert_eq!(truncate_bytes("晴天", 4), "晴"); assert_eq!(truncate_bytes("abc", 10), "abc");
}
#[test]
fn ext_prefers_format_then_falls_back_by_quality() {
assert_eq!(ext_for(Some(&AudioFormat::Flac), BitRate::Lossless), "flac");
assert_eq!(ext_for(Some(&AudioFormat::Mp3), BitRate::Higher), "mp3");
assert_eq!(ext_for(None, BitRate::Lossless), "flac");
assert_eq!(ext_for(None, BitRate::Higher), "mp3");
}
}