Move the files around
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
[package]
|
||||
name = "musicfs-cache"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
musicfs-core = { path = "../musicfs-core" }
|
||||
musicfs-cas = { path = "../musicfs-cas" }
|
||||
musicfs-metadata = { path = "../musicfs-metadata" }
|
||||
rusqlite = { workspace = true, features = ["bundled"] }
|
||||
sled.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
thiserror.workspace = true
|
||||
serde.workspace = true
|
||||
rmp-serde.workspace = true
|
||||
image.workspace = true
|
||||
parking_lot.workspace = true
|
||||
chrono.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
@@ -0,0 +1,213 @@
|
||||
use image::ImageFormat;
|
||||
use musicfs_cas::CasStore;
|
||||
use musicfs_core::ChunkHash;
|
||||
use musicfs_metadata::artwork::{ArtSize, ArtType, Artwork};
|
||||
use std::io::Cursor;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use tracing::{debug, info, trace, warn};
|
||||
|
||||
const MAX_ARTWORK_INPUT_SIZE: usize = 10 * 1024 * 1024;
|
||||
|
||||
pub struct ArtworkCache {
|
||||
store: Arc<CasStore>,
|
||||
db_path: std::path::PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct CachedArtwork {
|
||||
pub file_id: i64,
|
||||
pub art_type: String,
|
||||
pub chunk_hash: ChunkHash,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
}
|
||||
|
||||
impl ArtworkCache {
|
||||
pub fn new(store: Arc<CasStore>, db_path: &Path) -> Result<Self, ArtworkError> {
|
||||
let db = rusqlite::Connection::open(db_path)?;
|
||||
|
||||
db.execute(
|
||||
"CREATE TABLE IF NOT EXISTS artwork (
|
||||
id INTEGER PRIMARY KEY,
|
||||
file_id INTEGER NOT NULL,
|
||||
art_type TEXT NOT NULL,
|
||||
chunk_hash TEXT NOT NULL,
|
||||
width INTEGER NOT NULL,
|
||||
height INTEGER NOT NULL,
|
||||
UNIQUE(file_id, art_type)
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
info!(path = ?db_path, "Artwork cache opened");
|
||||
Ok(Self {
|
||||
store,
|
||||
db_path: db_path.to_path_buf(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn store(&self, file_id: i64, artwork: &Artwork) -> Result<ChunkHash, ArtworkError> {
|
||||
trace!(
|
||||
file_id = file_id,
|
||||
size_bytes = artwork.data.len(),
|
||||
"Storing artwork"
|
||||
);
|
||||
if artwork.data.len() > MAX_ARTWORK_INPUT_SIZE {
|
||||
warn!(
|
||||
file_id = file_id,
|
||||
size = artwork.data.len(),
|
||||
max = MAX_ARTWORK_INPUT_SIZE,
|
||||
"Artwork too large"
|
||||
);
|
||||
return Err(ArtworkError::ImageTooLarge(artwork.data.len()));
|
||||
}
|
||||
|
||||
let hash = self.store.put(&artwork.data).await?;
|
||||
|
||||
let art_type_str = match artwork.art_type {
|
||||
ArtType::Front => "front",
|
||||
ArtType::Back => "back",
|
||||
ArtType::Other => "other",
|
||||
};
|
||||
|
||||
let db_path = self.db_path.clone();
|
||||
let art_type_clone = art_type_str.to_string();
|
||||
let hash_hex = hash.to_hex();
|
||||
let width = artwork.width;
|
||||
let height = artwork.height;
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let db = rusqlite::Connection::open(&db_path)?;
|
||||
db.execute(
|
||||
"INSERT OR REPLACE INTO artwork
|
||||
(file_id, art_type, chunk_hash, width, height)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
rusqlite::params![file_id, art_type_clone, hash_hex, width, height],
|
||||
)?;
|
||||
Ok::<_, ArtworkError>(())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| ArtworkError::SpawnBlocking(e.to_string()))??;
|
||||
|
||||
debug!("Cached artwork for file {}", file_id);
|
||||
Ok(hash)
|
||||
}
|
||||
|
||||
pub async fn get(
|
||||
&self,
|
||||
file_id: i64,
|
||||
art_type: &str,
|
||||
size: ArtSize,
|
||||
) -> Result<Option<Vec<u8>>, ArtworkError> {
|
||||
trace!(file_id = file_id, art_type = %art_type, "Getting artwork");
|
||||
let db_path = self.db_path.clone();
|
||||
let art_type_clone = art_type.to_string();
|
||||
|
||||
let hash_hex: Option<String> = tokio::task::spawn_blocking(move || {
|
||||
let db = rusqlite::Connection::open(&db_path)?;
|
||||
db.query_row(
|
||||
"SELECT chunk_hash FROM artwork WHERE file_id = ?1 AND art_type = ?2",
|
||||
rusqlite::params![file_id, art_type_clone],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.ok()
|
||||
.ok_or(ArtworkError::NotFound)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| ArtworkError::SpawnBlocking(e.to_string()))?
|
||||
.ok();
|
||||
|
||||
match hash_hex {
|
||||
Some(hex) => {
|
||||
trace!(file_id = file_id, "Artwork cache hit");
|
||||
let hash = ChunkHash::from_hex(&hex).ok_or(ArtworkError::InvalidHash)?;
|
||||
let data = self.store.get(&hash).await?;
|
||||
|
||||
match size {
|
||||
ArtSize::Full => Ok(Some(data.to_vec())),
|
||||
ArtSize::Thumbnail | ArtSize::Medium => {
|
||||
let resized = self.resize_on_demand(&data, size)?;
|
||||
Ok(Some(resized))
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
trace!(file_id = file_id, "Artwork cache miss");
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn has(&self, file_id: i64, art_type: &str) -> Result<bool, ArtworkError> {
|
||||
let db_path = self.db_path.clone();
|
||||
let art_type_clone = art_type.to_string();
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let db = rusqlite::Connection::open(&db_path)?;
|
||||
let count: i64 = db.query_row(
|
||||
"SELECT COUNT(*) FROM artwork WHERE file_id = ?1 AND art_type = ?2",
|
||||
rusqlite::params![file_id, art_type_clone],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
Ok(count > 0)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| ArtworkError::SpawnBlocking(e.to_string()))?
|
||||
}
|
||||
|
||||
fn resize_on_demand(&self, data: &[u8], size: ArtSize) -> Result<Vec<u8>, ArtworkError> {
|
||||
let max_dim = size.max_dimension().unwrap_or(300);
|
||||
let img = image::load_from_memory(data).map_err(|_| ArtworkError::InvalidImage)?;
|
||||
|
||||
if img.width() <= max_dim && img.height() <= max_dim {
|
||||
return Ok(data.to_vec());
|
||||
}
|
||||
|
||||
let resized = img.thumbnail(max_dim, max_dim);
|
||||
let mut output = Vec::new();
|
||||
let mut cursor = Cursor::new(&mut output);
|
||||
resized
|
||||
.write_to(&mut cursor, ImageFormat::Jpeg)
|
||||
.map_err(|_| ArtworkError::ResizeFailed)?;
|
||||
|
||||
Ok(output)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ArtworkError {
|
||||
#[error("database error: {0}")]
|
||||
Database(#[from] rusqlite::Error),
|
||||
|
||||
#[error("CAS error: {0}")]
|
||||
Cas(#[from] musicfs_cas::CasError),
|
||||
|
||||
#[error("invalid hash")]
|
||||
InvalidHash,
|
||||
|
||||
#[error("artwork not found")]
|
||||
NotFound,
|
||||
|
||||
#[error("image too large: {0} bytes (max 10MB)")]
|
||||
ImageTooLarge(usize),
|
||||
|
||||
#[error("invalid image data")]
|
||||
InvalidImage,
|
||||
|
||||
#[error("resize failed")]
|
||||
ResizeFailed,
|
||||
|
||||
#[error("spawn_blocking error: {0}")]
|
||||
SpawnBlocking(String),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_max_artwork_size() {
|
||||
assert_eq!(MAX_ARTWORK_INPUT_SIZE, 10 * 1024 * 1024);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,504 @@
|
||||
use musicfs_core::{
|
||||
AudioFormat, AudioMeta, ContentHash, Error, FileId, FileMeta, OriginId, RealPath, Result,
|
||||
VirtualPath,
|
||||
};
|
||||
use rusqlite::{params, Connection, OptionalExtension};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
const SCHEMA: &str = include_str!("schema.sql");
|
||||
|
||||
pub struct Database {
|
||||
conn: Arc<Mutex<Connection>>,
|
||||
}
|
||||
|
||||
impl Database {
|
||||
pub fn open(path: &Path) -> Result<Self> {
|
||||
debug!(?path, "Opening database");
|
||||
|
||||
let conn =
|
||||
Connection::open(path).map_err(|e| Error::Database(format!("open failed: {}", e)))?;
|
||||
|
||||
conn.execute_batch(SCHEMA)
|
||||
.map_err(|e| Error::Database(format!("schema init failed: {}", e)))?;
|
||||
|
||||
let db = Self {
|
||||
conn: Arc::new(Mutex::new(conn)),
|
||||
};
|
||||
let count = db.file_count().unwrap_or(0);
|
||||
info!(path = ?path, file_count = count, "Database opened");
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
pub fn open_with_integrity_check(path: &Path) -> Result<Self> {
|
||||
debug!(?path, "Opening database with integrity check");
|
||||
|
||||
let conn =
|
||||
Connection::open(path).map_err(|e| Error::Database(format!("open failed: {}", e)))?;
|
||||
|
||||
let integrity: String = conn
|
||||
.query_row("PRAGMA integrity_check(1)", [], |row| row.get(0))
|
||||
.map_err(|e| Error::Database(format!("integrity check failed: {}", e)))?;
|
||||
|
||||
if integrity != "ok" {
|
||||
warn!(path = ?path, result = %integrity, "Database integrity check failed");
|
||||
return Err(Error::DatabaseCorrupted(format!(
|
||||
"integrity check failed: {}",
|
||||
integrity
|
||||
)));
|
||||
}
|
||||
|
||||
conn.execute_batch(SCHEMA)
|
||||
.map_err(|e| Error::Database(format!("schema init failed: {}", e)))?;
|
||||
|
||||
let db = Self {
|
||||
conn: Arc::new(Mutex::new(conn)),
|
||||
};
|
||||
let count = db.file_count().unwrap_or(0);
|
||||
info!(path = ?path, file_count = count, "Database opened (integrity verified)");
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
pub fn open_memory() -> Result<Self> {
|
||||
let conn = Connection::open_in_memory()
|
||||
.map_err(|e| Error::Database(format!("open_in_memory failed: {}", e)))?;
|
||||
|
||||
conn.execute_batch(SCHEMA)
|
||||
.map_err(|e| Error::Database(format!("schema init failed: {}", e)))?;
|
||||
|
||||
Ok(Self {
|
||||
conn: Arc::new(Mutex::new(conn)),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn upsert_file(
|
||||
&self,
|
||||
origin_id: &OriginId,
|
||||
real_path: &Path,
|
||||
virtual_path: &VirtualPath,
|
||||
audio_meta: &AudioMeta,
|
||||
origin_mtime: SystemTime,
|
||||
origin_size: u64,
|
||||
) -> Result<FileId> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
|
||||
let mtime_secs = origin_mtime
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs() as i64;
|
||||
|
||||
conn.execute(
|
||||
r#"
|
||||
INSERT INTO files (
|
||||
origin_id, real_path, virtual_path,
|
||||
title, artist, album, album_artist, genre,
|
||||
year, track, disc,
|
||||
duration_ms, bitrate, sample_rate, format,
|
||||
origin_mtime, origin_size
|
||||
) VALUES (
|
||||
?1, ?2, ?3,
|
||||
?4, ?5, ?6, ?7, ?8,
|
||||
?9, ?10, ?11,
|
||||
?12, ?13, ?14, ?15,
|
||||
?16, ?17
|
||||
)
|
||||
ON CONFLICT(origin_id, real_path) DO UPDATE SET
|
||||
virtual_path = excluded.virtual_path,
|
||||
title = excluded.title,
|
||||
artist = excluded.artist,
|
||||
album = excluded.album,
|
||||
album_artist = excluded.album_artist,
|
||||
genre = excluded.genre,
|
||||
year = excluded.year,
|
||||
track = excluded.track,
|
||||
disc = excluded.disc,
|
||||
duration_ms = excluded.duration_ms,
|
||||
bitrate = excluded.bitrate,
|
||||
sample_rate = excluded.sample_rate,
|
||||
format = excluded.format,
|
||||
origin_mtime = excluded.origin_mtime,
|
||||
origin_size = excluded.origin_size,
|
||||
last_sync = strftime('%s', 'now')
|
||||
"#,
|
||||
params![
|
||||
&origin_id.0,
|
||||
real_path.to_string_lossy(),
|
||||
virtual_path.as_str(),
|
||||
&audio_meta.title,
|
||||
&audio_meta.artist,
|
||||
&audio_meta.album,
|
||||
&audio_meta.album_artist,
|
||||
&audio_meta.genre,
|
||||
&audio_meta.year,
|
||||
&audio_meta.track,
|
||||
&audio_meta.disc,
|
||||
&audio_meta.duration_ms.map(|d| d as i64),
|
||||
&audio_meta.bitrate,
|
||||
&audio_meta.sample_rate,
|
||||
format!("{:?}", audio_meta.format),
|
||||
mtime_secs,
|
||||
origin_size as i64,
|
||||
],
|
||||
)
|
||||
.map_err(|e| Error::Database(format!("upsert failed: {}", e)))?;
|
||||
|
||||
let id = conn.last_insert_rowid();
|
||||
debug!(id, vpath = virtual_path.as_str(), "Upserted file");
|
||||
|
||||
Ok(FileId(id))
|
||||
}
|
||||
|
||||
pub fn get_file_by_virtual_path(&self, path: &VirtualPath) -> Result<Option<FileMeta>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
|
||||
conn.query_row(
|
||||
r#"
|
||||
SELECT id, origin_id, real_path, virtual_path,
|
||||
title, artist, album, album_artist, genre,
|
||||
year, track, disc,
|
||||
duration_ms, bitrate, sample_rate, format,
|
||||
origin_mtime, origin_size, content_hash
|
||||
FROM files
|
||||
WHERE virtual_path = ?1
|
||||
"#,
|
||||
params![path.as_str()],
|
||||
|row| {
|
||||
let format_str: Option<String> = row.get(15)?;
|
||||
let format = format_str
|
||||
.as_deref()
|
||||
.map(parse_audio_format)
|
||||
.unwrap_or(AudioFormat::Unknown);
|
||||
|
||||
let content_hash: Option<String> = row.get(18)?;
|
||||
|
||||
Ok(FileMeta {
|
||||
id: FileId(row.get(0)?),
|
||||
real_path: RealPath {
|
||||
origin_id: OriginId(row.get(1)?),
|
||||
path: PathBuf::from(row.get::<_, String>(2)?),
|
||||
},
|
||||
virtual_path: VirtualPath::new(row.get::<_, String>(3)?),
|
||||
audio: Some(AudioMeta {
|
||||
title: row.get(4)?,
|
||||
artist: row.get(5)?,
|
||||
album: row.get(6)?,
|
||||
album_artist: row.get(7)?,
|
||||
genre: row.get(8)?,
|
||||
year: row.get(9)?,
|
||||
track: row.get(10)?,
|
||||
disc: row.get(11)?,
|
||||
duration_ms: row.get::<_, Option<i64>>(12)?.map(|d| d as u64),
|
||||
bitrate: row.get(13)?,
|
||||
sample_rate: row.get(14)?,
|
||||
format,
|
||||
}),
|
||||
size: row.get::<_, i64>(17)? as u64,
|
||||
mtime: UNIX_EPOCH + Duration::from_secs(row.get::<_, i64>(16)? as u64),
|
||||
content_hash: content_hash.and_then(|s| parse_content_hash(&s)),
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
.map_err(|e| Error::Database(format!("query failed: {}", e)))
|
||||
}
|
||||
|
||||
pub fn get_file_by_id(&self, id: FileId) -> Result<Option<FileMeta>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
|
||||
let vpath: Option<String> = conn
|
||||
.query_row(
|
||||
"SELECT virtual_path FROM files WHERE id = ?1",
|
||||
params![id.0],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()
|
||||
.map_err(|e| Error::Database(format!("query failed: {}", e)))?;
|
||||
|
||||
drop(conn);
|
||||
|
||||
match vpath {
|
||||
Some(p) => self.get_file_by_virtual_path(&VirtualPath::new(p)),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn list_files_by_origin(&self, origin_id: &OriginId) -> Result<Vec<VirtualPath>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT virtual_path FROM files WHERE origin_id = ?1")
|
||||
.map_err(|e| Error::Database(format!("prepare failed: {}", e)))?;
|
||||
|
||||
let paths: Vec<VirtualPath> = stmt
|
||||
.query_map(params![&origin_id.0], |row| {
|
||||
Ok(VirtualPath::new(row.get::<_, String>(0)?))
|
||||
})
|
||||
.map_err(|e| Error::Database(format!("query failed: {}", e)))?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
Ok(paths)
|
||||
}
|
||||
|
||||
pub fn delete_file(&self, id: FileId) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute("DELETE FROM files WHERE id = ?1", params![id.0])
|
||||
.map_err(|e| Error::Database(format!("delete failed: {}", e)))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn file_count(&self) -> Result<u64> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.query_row("SELECT COUNT(*) FROM files", [], |row| row.get::<_, i64>(0))
|
||||
.map(|c| c as u64)
|
||||
.map_err(|e| Error::Database(format!("count failed: {}", e)))
|
||||
}
|
||||
|
||||
pub fn update_content_hash(&self, id: FileId, hash: &ContentHash) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute(
|
||||
"UPDATE files SET content_hash = ?1 WHERE id = ?2",
|
||||
params![hash.to_hex(), id.0],
|
||||
)
|
||||
.map_err(|e| Error::Database(format!("update hash failed: {}", e)))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_mtime_by_real_path(
|
||||
&self,
|
||||
origin_id: &OriginId,
|
||||
real_path: &Path,
|
||||
) -> Result<Option<SystemTime>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
|
||||
conn.query_row(
|
||||
"SELECT origin_mtime FROM files WHERE origin_id = ?1 AND real_path = ?2",
|
||||
params![&origin_id.0, real_path.to_string_lossy()],
|
||||
|row| {
|
||||
let mtime_secs: i64 = row.get(0)?;
|
||||
Ok(UNIX_EPOCH + Duration::from_secs(mtime_secs as u64))
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
.map_err(|e| Error::Database(format!("query mtime failed: {}", e)))
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_audio_format(s: &str) -> AudioFormat {
|
||||
match s {
|
||||
"Flac" => AudioFormat::Flac,
|
||||
"Mp3" => AudioFormat::Mp3,
|
||||
"Aac" => AudioFormat::Aac,
|
||||
"Opus" => AudioFormat::Opus,
|
||||
"Vorbis" => AudioFormat::Vorbis,
|
||||
"Wav" => AudioFormat::Wav,
|
||||
"Alac" => AudioFormat::Alac,
|
||||
_ => AudioFormat::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_content_hash(hex: &str) -> Option<ContentHash> {
|
||||
if hex.len() != 16 {
|
||||
return None;
|
||||
}
|
||||
let mut bytes = [0u8; 8];
|
||||
for (i, chunk) in hex.as_bytes().chunks(2).enumerate() {
|
||||
if i >= 8 {
|
||||
break;
|
||||
}
|
||||
let s = std::str::from_utf8(chunk).ok()?;
|
||||
bytes[i] = u8::from_str_radix(s, 16).ok()?;
|
||||
}
|
||||
Some(ContentHash(bytes))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_database_creation() {
|
||||
let db = Database::open_memory().unwrap();
|
||||
assert_eq!(db.file_count().unwrap(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_upsert_and_retrieve() {
|
||||
let db = Database::open_memory().unwrap();
|
||||
|
||||
let origin_id = OriginId::from("local");
|
||||
let real_path = Path::new("/music/test.flac");
|
||||
let virtual_path = VirtualPath::new("/Artist/Album/01 - Track.flac");
|
||||
let audio_meta = AudioMeta {
|
||||
title: Some("Track".to_string()),
|
||||
artist: Some("Artist".to_string()),
|
||||
album: Some("Album".to_string()),
|
||||
track: Some(1),
|
||||
format: AudioFormat::Flac,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let id = db
|
||||
.upsert_file(
|
||||
&origin_id,
|
||||
real_path,
|
||||
&virtual_path,
|
||||
&audio_meta,
|
||||
UNIX_EPOCH,
|
||||
1000,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let retrieved = db.get_file_by_virtual_path(&virtual_path).unwrap().unwrap();
|
||||
assert_eq!(retrieved.id, id);
|
||||
assert_eq!(
|
||||
retrieved.audio.as_ref().unwrap().title,
|
||||
Some("Track".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_upsert_updates_existing() {
|
||||
let db = Database::open_memory().unwrap();
|
||||
|
||||
let origin_id = OriginId::from("local");
|
||||
let real_path = Path::new("/music/test.flac");
|
||||
let virtual_path = VirtualPath::new("/Artist/Album/01 - Track.flac");
|
||||
|
||||
let meta1 = AudioMeta {
|
||||
title: Some("Original".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
db.upsert_file(
|
||||
&origin_id,
|
||||
real_path,
|
||||
&virtual_path,
|
||||
&meta1,
|
||||
UNIX_EPOCH,
|
||||
1000,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let meta2 = AudioMeta {
|
||||
title: Some("Updated".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
db.upsert_file(
|
||||
&origin_id,
|
||||
real_path,
|
||||
&virtual_path,
|
||||
&meta2,
|
||||
UNIX_EPOCH,
|
||||
1000,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(db.file_count().unwrap(), 1);
|
||||
|
||||
let retrieved = db.get_file_by_virtual_path(&virtual_path).unwrap().unwrap();
|
||||
assert_eq!(
|
||||
retrieved.audio.as_ref().unwrap().title,
|
||||
Some("Updated".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_metadata_persistence() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let db_path = dir.path().join("test.db");
|
||||
|
||||
{
|
||||
let db = Database::open(&db_path).unwrap();
|
||||
db.upsert_file(
|
||||
&OriginId::from("local"),
|
||||
Path::new("/test.flac"),
|
||||
&VirtualPath::new("/Test.flac"),
|
||||
&AudioMeta::default(),
|
||||
UNIX_EPOCH,
|
||||
100,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
{
|
||||
let db = Database::open(&db_path).unwrap();
|
||||
assert_eq!(db.file_count().unwrap(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_file() {
|
||||
let db = Database::open_memory().unwrap();
|
||||
|
||||
let id = db
|
||||
.upsert_file(
|
||||
&OriginId::from("local"),
|
||||
Path::new("/test.flac"),
|
||||
&VirtualPath::new("/Test.flac"),
|
||||
&AudioMeta::default(),
|
||||
UNIX_EPOCH,
|
||||
100,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(db.file_count().unwrap(), 1);
|
||||
db.delete_file(id).unwrap();
|
||||
assert_eq!(db.file_count().unwrap(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_files_by_origin() {
|
||||
let db = Database::open_memory().unwrap();
|
||||
let origin = OriginId::from("local");
|
||||
|
||||
db.upsert_file(
|
||||
&origin,
|
||||
Path::new("/a.flac"),
|
||||
&VirtualPath::new("/A.flac"),
|
||||
&AudioMeta::default(),
|
||||
UNIX_EPOCH,
|
||||
100,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
db.upsert_file(
|
||||
&origin,
|
||||
Path::new("/b.flac"),
|
||||
&VirtualPath::new("/B.flac"),
|
||||
&AudioMeta::default(),
|
||||
UNIX_EPOCH,
|
||||
100,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let paths = db.list_files_by_origin(&origin).unwrap();
|
||||
assert_eq!(paths.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_content_hash_update() {
|
||||
let db = Database::open_memory().unwrap();
|
||||
|
||||
let id = db
|
||||
.upsert_file(
|
||||
&OriginId::from("local"),
|
||||
Path::new("/test.flac"),
|
||||
&VirtualPath::new("/Test.flac"),
|
||||
&AudioMeta::default(),
|
||||
UNIX_EPOCH,
|
||||
100,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let hash = ContentHash::from_bytes(b"test data");
|
||||
db.update_content_hash(id, &hash).unwrap();
|
||||
|
||||
let retrieved = db
|
||||
.get_file_by_virtual_path(&VirtualPath::new("/Test.flac"))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(retrieved.content_hash.is_some());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
use musicfs_cas::CasStore;
|
||||
use musicfs_core::ChunkHash;
|
||||
use parking_lot::RwLock;
|
||||
use std::collections::BTreeMap;
|
||||
use std::time::Instant;
|
||||
use tracing::info;
|
||||
|
||||
pub trait EvictionPolicy: Send + Sync {
|
||||
fn record_access(&self, hash: ChunkHash);
|
||||
fn select_victims(&self, count: usize) -> Vec<ChunkHash>;
|
||||
fn remove(&self, hash: &ChunkHash);
|
||||
}
|
||||
|
||||
pub struct LruEviction {
|
||||
access_times: RwLock<BTreeMap<Instant, ChunkHash>>,
|
||||
hash_to_time: RwLock<std::collections::HashMap<ChunkHash, Instant>>,
|
||||
}
|
||||
|
||||
impl LruEviction {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
access_times: RwLock::new(BTreeMap::new()),
|
||||
hash_to_time: RwLock::new(std::collections::HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn evict_to_target(
|
||||
&self,
|
||||
store: &CasStore,
|
||||
target_size: u64,
|
||||
) -> Result<u64, EvictionError> {
|
||||
let mut bytes_freed = 0u64;
|
||||
|
||||
while store.current_size() > target_size {
|
||||
let victims = self.select_victims(10);
|
||||
|
||||
if victims.is_empty() {
|
||||
break;
|
||||
}
|
||||
|
||||
for hash in victims {
|
||||
if let Ok(data) = store.get(&hash).await {
|
||||
bytes_freed += data.len() as u64;
|
||||
store.delete(&hash).await?;
|
||||
self.remove(&hash);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if bytes_freed > 0 {
|
||||
info!("Evicted {} bytes from cache", bytes_freed);
|
||||
}
|
||||
|
||||
Ok(bytes_freed)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for LruEviction {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl EvictionPolicy for LruEviction {
|
||||
fn record_access(&self, hash: ChunkHash) {
|
||||
let now = Instant::now();
|
||||
let mut times = self.access_times.write();
|
||||
let mut h2t = self.hash_to_time.write();
|
||||
|
||||
if let Some(old_time) = h2t.remove(&hash) {
|
||||
times.remove(&old_time);
|
||||
}
|
||||
|
||||
times.insert(now, hash);
|
||||
h2t.insert(hash, now);
|
||||
}
|
||||
|
||||
fn select_victims(&self, count: usize) -> Vec<ChunkHash> {
|
||||
let times = self.access_times.read();
|
||||
times.values().take(count).copied().collect()
|
||||
}
|
||||
|
||||
fn remove(&self, hash: &ChunkHash) {
|
||||
let mut times = self.access_times.write();
|
||||
let mut h2t = self.hash_to_time.write();
|
||||
|
||||
if let Some(time) = h2t.remove(hash) {
|
||||
times.remove(&time);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum EvictionError {
|
||||
#[error("CAS error: {0}")]
|
||||
Cas(#[from] musicfs_cas::CasError),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_lru_access_order() {
|
||||
let lru = LruEviction::new();
|
||||
|
||||
let h1 = ChunkHash::from_bytes(b"chunk1");
|
||||
let h2 = ChunkHash::from_bytes(b"chunk2");
|
||||
let h3 = ChunkHash::from_bytes(b"chunk3");
|
||||
|
||||
lru.record_access(h1);
|
||||
std::thread::sleep(std::time::Duration::from_millis(1));
|
||||
lru.record_access(h2);
|
||||
std::thread::sleep(std::time::Duration::from_millis(1));
|
||||
lru.record_access(h3);
|
||||
|
||||
let victims = lru.select_victims(2);
|
||||
assert_eq!(victims.len(), 2);
|
||||
assert_eq!(victims[0], h1);
|
||||
assert_eq!(victims[1], h2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lru_reaccess_updates_order() {
|
||||
let lru = LruEviction::new();
|
||||
|
||||
let h1 = ChunkHash::from_bytes(b"chunk1");
|
||||
let h2 = ChunkHash::from_bytes(b"chunk2");
|
||||
|
||||
lru.record_access(h1);
|
||||
std::thread::sleep(std::time::Duration::from_millis(1));
|
||||
lru.record_access(h2);
|
||||
std::thread::sleep(std::time::Duration::from_millis(1));
|
||||
lru.record_access(h1);
|
||||
|
||||
let victims = lru.select_victims(1);
|
||||
assert_eq!(victims[0], h2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lru_remove() {
|
||||
let lru = LruEviction::new();
|
||||
|
||||
let h1 = ChunkHash::from_bytes(b"chunk1");
|
||||
let h2 = ChunkHash::from_bytes(b"chunk2");
|
||||
|
||||
lru.record_access(h1);
|
||||
lru.record_access(h2);
|
||||
lru.remove(&h1);
|
||||
|
||||
let victims = lru.select_victims(10);
|
||||
assert_eq!(victims.len(), 1);
|
||||
assert_eq!(victims[0], h2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
mod artwork;
|
||||
mod db;
|
||||
mod eviction;
|
||||
mod metadata;
|
||||
mod patterns;
|
||||
mod prefetch;
|
||||
mod tree;
|
||||
|
||||
pub use artwork::{ArtworkCache, ArtworkError, CachedArtwork};
|
||||
pub use db::Database;
|
||||
pub use eviction::{EvictionError, EvictionPolicy, LruEviction};
|
||||
pub use metadata::MetadataCache;
|
||||
pub use patterns::{AccessContext, AccessPattern, PatternError, PatternStore};
|
||||
pub use prefetch::{PrefetchConfig, PrefetchEngine, PrefetchHandle};
|
||||
pub use tree::{
|
||||
DirNode, FileNode, Inode, RefreshPolicy, TreeBuilder, VirtualNode, VirtualTree, ROOT_INODE,
|
||||
};
|
||||
@@ -0,0 +1,138 @@
|
||||
use crate::db::Database;
|
||||
use musicfs_core::{AudioMeta, FileMeta, OriginId, Result, VirtualPath};
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
use tracing::trace;
|
||||
|
||||
pub struct MetadataCache {
|
||||
db: Arc<Database>,
|
||||
}
|
||||
|
||||
impl MetadataCache {
|
||||
pub fn new(db: Arc<Database>) -> Self {
|
||||
Self { db }
|
||||
}
|
||||
|
||||
pub fn store(
|
||||
&self,
|
||||
origin_id: &OriginId,
|
||||
real_path: &Path,
|
||||
virtual_path: &VirtualPath,
|
||||
audio_meta: &AudioMeta,
|
||||
origin_mtime: SystemTime,
|
||||
origin_size: u64,
|
||||
) -> Result<()> {
|
||||
self.db.upsert_file(
|
||||
origin_id,
|
||||
real_path,
|
||||
virtual_path,
|
||||
audio_meta,
|
||||
origin_mtime,
|
||||
origin_size,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn lookup(&self, path: &VirtualPath) -> Result<Option<FileMeta>> {
|
||||
let result = self.db.get_file_by_virtual_path(path)?;
|
||||
let hit = result.is_some();
|
||||
trace!(path = path.as_str(), hit, "metadata cache lookup");
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn is_fresh(
|
||||
&self,
|
||||
origin_id: &OriginId,
|
||||
real_path: &Path,
|
||||
current_mtime: SystemTime,
|
||||
) -> Result<bool> {
|
||||
if let Some(cached_mtime) = self.db.get_mtime_by_real_path(origin_id, real_path)? {
|
||||
let current_secs = current_mtime
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or(Duration::ZERO)
|
||||
.as_secs();
|
||||
let cached_secs = cached_mtime
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or(Duration::ZERO)
|
||||
.as_secs();
|
||||
let hit = current_secs == cached_secs;
|
||||
trace!(path = ?real_path, hit, "metadata freshness check");
|
||||
Ok(hit)
|
||||
} else {
|
||||
trace!(path = ?real_path, hit = false, "metadata freshness check");
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn invalidate(&self, path: &VirtualPath) -> Result<()> {
|
||||
if let Some(meta) = self.db.get_file_by_virtual_path(path)? {
|
||||
self.db.delete_file(meta.id)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use musicfs_core::AudioFormat;
|
||||
|
||||
#[test]
|
||||
fn test_metadata_cache_store_and_lookup() {
|
||||
let db = Arc::new(Database::open_memory().unwrap());
|
||||
let cache = MetadataCache::new(db);
|
||||
|
||||
let origin_id = OriginId::from("local");
|
||||
let real_path = Path::new("/music/song.flac");
|
||||
let virtual_path = VirtualPath::new("/Artist/Album/Song.flac");
|
||||
let meta = AudioMeta {
|
||||
title: Some("Song".to_string()),
|
||||
artist: Some("Artist".to_string()),
|
||||
format: AudioFormat::Flac,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
cache
|
||||
.store(
|
||||
&origin_id,
|
||||
real_path,
|
||||
&virtual_path,
|
||||
&meta,
|
||||
UNIX_EPOCH,
|
||||
5000,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let retrieved = cache.lookup(&virtual_path).unwrap().unwrap();
|
||||
assert_eq!(
|
||||
retrieved.audio.as_ref().unwrap().title,
|
||||
Some("Song".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_metadata_cache_invalidate() {
|
||||
let db = Arc::new(Database::open_memory().unwrap());
|
||||
let cache = MetadataCache::new(db);
|
||||
|
||||
let virtual_path = VirtualPath::new("/Test.flac");
|
||||
|
||||
cache
|
||||
.store(
|
||||
&OriginId::from("local"),
|
||||
Path::new("/test.flac"),
|
||||
&virtual_path,
|
||||
&AudioMeta::default(),
|
||||
UNIX_EPOCH,
|
||||
100,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(cache.lookup(&virtual_path).unwrap().is_some());
|
||||
|
||||
cache.invalidate(&virtual_path).unwrap();
|
||||
|
||||
assert!(cache.lookup(&virtual_path).unwrap().is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
use musicfs_core::FileId;
|
||||
use parking_lot::{Mutex, RwLock};
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use tracing::{debug, info, trace};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AccessPattern {
|
||||
pub file_id: FileId,
|
||||
pub timestamp: SystemTime,
|
||||
pub context: AccessContext,
|
||||
pub hour_of_day: u8,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct AccessContext {
|
||||
pub album_id: Option<i64>,
|
||||
pub track_number: Option<u32>,
|
||||
pub artist: Option<String>,
|
||||
}
|
||||
|
||||
pub struct PatternStore {
|
||||
db: Mutex<rusqlite::Connection>,
|
||||
sequence_counts: RwLock<HashMap<(FileId, FileId), u32>>,
|
||||
time_patterns: RwLock<HashMap<u8, Vec<FileId>>>,
|
||||
max_history: usize,
|
||||
}
|
||||
|
||||
impl PatternStore {
|
||||
pub fn new(db_path: &Path, max_history: usize) -> Result<Self, PatternError> {
|
||||
let db = rusqlite::Connection::open(db_path)?;
|
||||
|
||||
db.execute(
|
||||
"CREATE TABLE IF NOT EXISTS access_log (
|
||||
id INTEGER PRIMARY KEY,
|
||||
file_id INTEGER NOT NULL,
|
||||
access_time INTEGER NOT NULL,
|
||||
hour_of_day INTEGER NOT NULL
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
db.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_access_log_file ON access_log(file_id)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
db.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_access_log_time ON access_log(access_time)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
db.execute(
|
||||
"CREATE TABLE IF NOT EXISTS sequence_counts (
|
||||
from_file_id INTEGER NOT NULL,
|
||||
to_file_id INTEGER NOT NULL,
|
||||
count INTEGER NOT NULL DEFAULT 1,
|
||||
PRIMARY KEY (from_file_id, to_file_id)
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
let sequence_counts = {
|
||||
let mut map = HashMap::new();
|
||||
let mut stmt =
|
||||
db.prepare("SELECT from_file_id, to_file_id, count FROM sequence_counts")?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
Ok((
|
||||
(FileId(row.get::<_, i64>(0)?), FileId(row.get::<_, i64>(1)?)),
|
||||
row.get::<_, u32>(2)?,
|
||||
))
|
||||
})?;
|
||||
for row in rows {
|
||||
let (key, count) = row?;
|
||||
map.insert(key, count);
|
||||
}
|
||||
map
|
||||
};
|
||||
|
||||
let store = Self {
|
||||
db: Mutex::new(db),
|
||||
sequence_counts: RwLock::new(sequence_counts),
|
||||
time_patterns: RwLock::new(HashMap::new()),
|
||||
max_history,
|
||||
};
|
||||
let sequence_count = store.sequence_counts.read().len();
|
||||
info!(path = ?db_path, sequence_count = sequence_count, max_history = max_history, "Pattern store opened");
|
||||
Ok(store)
|
||||
}
|
||||
|
||||
pub fn record(&self, file_id: FileId, _context: AccessContext) -> Result<(), PatternError> {
|
||||
trace!(file_id = file_id.0, "Recording access pattern");
|
||||
let now = SystemTime::now();
|
||||
let timestamp = now.duration_since(UNIX_EPOCH).unwrap().as_secs() as i64;
|
||||
let hour = (timestamp / 3600 % 24) as u8;
|
||||
|
||||
let db = self.db.lock();
|
||||
|
||||
db.execute(
|
||||
"INSERT INTO access_log (file_id, access_time, hour_of_day) VALUES (?1, ?2, ?3)",
|
||||
rusqlite::params![file_id.0, timestamp, hour],
|
||||
)?;
|
||||
|
||||
{
|
||||
let mut time_patterns = self.time_patterns.write();
|
||||
time_patterns.entry(hour).or_default().push(file_id);
|
||||
}
|
||||
|
||||
let prev_file_id: Option<i64> = db
|
||||
.query_row(
|
||||
"SELECT file_id FROM access_log WHERE id = (SELECT MAX(id) - 1 FROM access_log)",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.ok();
|
||||
|
||||
if let Some(prev_id) = prev_file_id {
|
||||
let prev = FileId(prev_id);
|
||||
|
||||
{
|
||||
let mut sequences = self.sequence_counts.write();
|
||||
*sequences.entry((prev, file_id)).or_insert(0) += 1;
|
||||
}
|
||||
|
||||
db.execute(
|
||||
"INSERT INTO sequence_counts (from_file_id, to_file_id, count)
|
||||
VALUES (?1, ?2, 1)
|
||||
ON CONFLICT(from_file_id, to_file_id) DO UPDATE SET count = count + 1",
|
||||
rusqlite::params![prev_id, file_id.0],
|
||||
)?;
|
||||
}
|
||||
|
||||
let cutoff = timestamp - (self.max_history as i64 * 86400);
|
||||
db.execute("DELETE FROM access_log WHERE access_time < ?1", [cutoff])?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn predict_next(&self, current: FileId, limit: usize) -> Vec<FileId> {
|
||||
let sequences = self.sequence_counts.read();
|
||||
|
||||
let mut predictions: Vec<_> = sequences
|
||||
.iter()
|
||||
.filter(|((from, _), count)| *from == current && **count >= 2)
|
||||
.map(|((_, to), count)| (*to, *count))
|
||||
.collect();
|
||||
|
||||
predictions.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
let result: Vec<FileId> = predictions
|
||||
.into_iter()
|
||||
.take(limit)
|
||||
.map(|(id, _)| id)
|
||||
.collect();
|
||||
debug!(
|
||||
file_id = current.0,
|
||||
predictions = result.len(),
|
||||
"Predicted next files"
|
||||
);
|
||||
result
|
||||
}
|
||||
|
||||
pub fn predict_for_time(&self, hour: u8, limit: usize) -> Vec<FileId> {
|
||||
let time_patterns = self.time_patterns.read();
|
||||
|
||||
time_patterns
|
||||
.get(&hour)
|
||||
.map(|files| files.iter().rev().take(limit).copied().collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn recently_played(&self, days: u32) -> Result<Vec<FileId>, PatternError> {
|
||||
let cutoff = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs() as i64
|
||||
- (days as i64 * 86400);
|
||||
|
||||
let db = self.db.lock();
|
||||
let mut stmt = db.prepare(
|
||||
"SELECT DISTINCT file_id FROM access_log WHERE access_time >= ?1 ORDER BY access_time DESC",
|
||||
)?;
|
||||
|
||||
let files: Vec<FileId> = stmt
|
||||
.query_map([cutoff], |row| Ok(FileId(row.get(0)?)))?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
pub fn most_played(&self, limit: u32) -> Result<Vec<FileId>, PatternError> {
|
||||
let db = self.db.lock();
|
||||
let mut stmt = db.prepare(
|
||||
"SELECT file_id, COUNT(*) as play_count FROM access_log
|
||||
GROUP BY file_id ORDER BY play_count DESC LIMIT ?1",
|
||||
)?;
|
||||
|
||||
let files: Vec<FileId> = stmt
|
||||
.query_map([limit], |row| Ok(FileId(row.get(0)?)))?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
Ok(files)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum PatternError {
|
||||
#[error("database error: {0}")]
|
||||
Database(#[from] rusqlite::Error),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn test_pattern_prediction() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let db_path = dir.path().join("patterns.db");
|
||||
let store = PatternStore::new(&db_path, 30).unwrap();
|
||||
let ctx = AccessContext::default();
|
||||
|
||||
for _ in 0..5 {
|
||||
store.record(FileId(1), ctx.clone()).unwrap();
|
||||
store.record(FileId(2), ctx.clone()).unwrap();
|
||||
store.record(FileId(3), ctx.clone()).unwrap();
|
||||
}
|
||||
|
||||
let predictions = store.predict_next(FileId(1), 3);
|
||||
assert!(!predictions.is_empty());
|
||||
assert_eq!(predictions[0], FileId(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pattern_persistence() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let db_path = dir.path().join("patterns.db");
|
||||
let ctx = AccessContext::default();
|
||||
|
||||
{
|
||||
let store = PatternStore::new(&db_path, 30).unwrap();
|
||||
for _ in 0..3 {
|
||||
store.record(FileId(1), ctx.clone()).unwrap();
|
||||
store.record(FileId(2), ctx.clone()).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let store = PatternStore::new(&db_path, 30).unwrap();
|
||||
let predictions = store.predict_next(FileId(1), 3);
|
||||
assert!(!predictions.is_empty());
|
||||
assert_eq!(predictions[0], FileId(2));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_recently_played() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let db_path = dir.path().join("patterns.db");
|
||||
let store = PatternStore::new(&db_path, 30).unwrap();
|
||||
let ctx = AccessContext::default();
|
||||
|
||||
store.record(FileId(100), ctx.clone()).unwrap();
|
||||
store.record(FileId(200), ctx.clone()).unwrap();
|
||||
|
||||
let recent = store.recently_played(7).unwrap();
|
||||
assert!(recent.contains(&FileId(100)));
|
||||
assert!(recent.contains(&FileId(200)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_most_played() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let db_path = dir.path().join("patterns.db");
|
||||
let store = PatternStore::new(&db_path, 30).unwrap();
|
||||
let ctx = AccessContext::default();
|
||||
|
||||
for _ in 0..5 {
|
||||
store.record(FileId(1), ctx.clone()).unwrap();
|
||||
}
|
||||
for _ in 0..2 {
|
||||
store.record(FileId(2), ctx.clone()).unwrap();
|
||||
}
|
||||
|
||||
let most = store.most_played(10).unwrap();
|
||||
assert_eq!(most[0], FileId(1));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
use crate::patterns::{AccessContext, PatternStore};
|
||||
use musicfs_cas::ContentFetcher;
|
||||
use musicfs_core::{Event, EventBus, FileId};
|
||||
use parking_lot::Mutex as ParkingMutex;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::Semaphore;
|
||||
use tokio::task::JoinHandle;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
const DEFAULT_PREFETCH_LOOKAHEAD: usize = 3;
|
||||
const DEFAULT_MAX_CONCURRENT: usize = 2;
|
||||
const DEFAULT_COOLDOWN_MS: u64 = 100;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PrefetchConfig {
|
||||
pub lookahead: usize,
|
||||
pub max_concurrent: usize,
|
||||
pub cooldown: Duration,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
impl Default for PrefetchConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
lookahead: DEFAULT_PREFETCH_LOOKAHEAD,
|
||||
max_concurrent: DEFAULT_MAX_CONCURRENT,
|
||||
cooldown: Duration::from_millis(DEFAULT_COOLDOWN_MS),
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PrefetchEngine {
|
||||
config: PrefetchConfig,
|
||||
fetcher: Arc<ContentFetcher>,
|
||||
in_flight: Arc<ParkingMutex<HashSet<FileId>>>,
|
||||
semaphore: Arc<Semaphore>,
|
||||
running: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
pub struct PrefetchHandle {
|
||||
handle: JoinHandle<()>,
|
||||
running: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl PrefetchHandle {
|
||||
pub async fn stop(self) {
|
||||
self.running.store(false, Ordering::SeqCst);
|
||||
let _ = self.handle.await;
|
||||
}
|
||||
}
|
||||
|
||||
impl PrefetchEngine {
|
||||
pub fn new(
|
||||
config: PrefetchConfig,
|
||||
_pattern_store: Arc<PatternStore>,
|
||||
fetcher: Arc<ContentFetcher>,
|
||||
) -> Self {
|
||||
let semaphore = Arc::new(Semaphore::new(config.max_concurrent));
|
||||
|
||||
Self {
|
||||
config,
|
||||
fetcher,
|
||||
in_flight: Arc::new(ParkingMutex::new(HashSet::new())),
|
||||
semaphore,
|
||||
running: Arc::new(AtomicBool::new(false)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start(
|
||||
self: Arc<Self>,
|
||||
event_bus: Arc<EventBus>,
|
||||
pattern_store: Arc<PatternStore>,
|
||||
) -> PrefetchHandle {
|
||||
self.running.store(true, Ordering::SeqCst);
|
||||
let running = self.running.clone();
|
||||
|
||||
let config = self.config.clone();
|
||||
let fetcher = self.fetcher.clone();
|
||||
let in_flight = self.in_flight.clone();
|
||||
let semaphore = self.semaphore.clone();
|
||||
let running_inner = running.clone();
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
let mut rx = event_bus.subscribe();
|
||||
|
||||
while running_inner.load(Ordering::SeqCst) {
|
||||
match tokio::time::timeout(Duration::from_secs(1), rx.recv()).await {
|
||||
Ok(Ok(event)) => {
|
||||
if let Event::FileAccessed { file_id, .. } = event {
|
||||
if config.enabled {
|
||||
let ctx = AccessContext::default();
|
||||
if let Err(e) = pattern_store.record(file_id, ctx) {
|
||||
warn!("Failed to record access pattern: {}", e);
|
||||
continue;
|
||||
}
|
||||
|
||||
let predictions =
|
||||
pattern_store.predict_next(file_id, config.lookahead);
|
||||
|
||||
for predicted_id in predictions {
|
||||
prefetch_file(predicted_id, &fetcher, &in_flight, &semaphore)
|
||||
.await;
|
||||
}
|
||||
|
||||
tokio::time::sleep(config.cooldown).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Err(_)) => break,
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
|
||||
info!("Prefetch engine stopped");
|
||||
});
|
||||
|
||||
PrefetchHandle { handle, running }
|
||||
}
|
||||
|
||||
pub fn is_running(&self) -> bool {
|
||||
self.running.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
pub fn in_flight_count(&self) -> usize {
|
||||
self.in_flight.lock().len()
|
||||
}
|
||||
|
||||
pub fn update_config(&mut self, config: PrefetchConfig) {
|
||||
self.config = config;
|
||||
}
|
||||
}
|
||||
|
||||
async fn prefetch_file(
|
||||
file_id: FileId,
|
||||
fetcher: &Arc<ContentFetcher>,
|
||||
in_flight: &Arc<ParkingMutex<HashSet<FileId>>>,
|
||||
semaphore: &Arc<Semaphore>,
|
||||
) {
|
||||
{
|
||||
let mut guard = in_flight.lock();
|
||||
if guard.contains(&file_id) {
|
||||
debug!("Skipping prefetch for {:?} - already in flight", file_id);
|
||||
return;
|
||||
}
|
||||
guard.insert(file_id);
|
||||
}
|
||||
|
||||
let permit = match semaphore.clone().try_acquire_owned() {
|
||||
Ok(p) => p,
|
||||
Err(_) => {
|
||||
debug!("Skipping prefetch for {:?} - concurrency limit", file_id);
|
||||
in_flight.lock().remove(&file_id);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let fetcher = fetcher.clone();
|
||||
let in_flight = in_flight.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
debug!("Prefetching file {:?}", file_id);
|
||||
|
||||
match fetcher.ensure_cached(file_id).await {
|
||||
Ok(manifest) => {
|
||||
info!(
|
||||
"Prefetched {:?}: {} chunks, {} bytes",
|
||||
file_id,
|
||||
manifest.chunks.len(),
|
||||
manifest.total_size
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
debug!("Prefetch failed for {:?}: {}", file_id, e);
|
||||
}
|
||||
}
|
||||
|
||||
in_flight.lock().remove(&file_id);
|
||||
drop(permit);
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_prefetch_config_defaults() {
|
||||
let config = PrefetchConfig::default();
|
||||
assert_eq!(config.lookahead, 3);
|
||||
assert_eq!(config.max_concurrent, 2);
|
||||
assert!(config.enabled);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA foreign_keys = ON;
|
||||
PRAGMA synchronous = NORMAL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS files (
|
||||
id INTEGER PRIMARY KEY,
|
||||
origin_id TEXT NOT NULL,
|
||||
real_path TEXT NOT NULL,
|
||||
virtual_path TEXT NOT NULL,
|
||||
|
||||
title TEXT,
|
||||
artist TEXT,
|
||||
album TEXT,
|
||||
album_artist TEXT,
|
||||
genre TEXT,
|
||||
year INTEGER,
|
||||
track INTEGER,
|
||||
disc INTEGER,
|
||||
duration_ms INTEGER,
|
||||
bitrate INTEGER,
|
||||
sample_rate INTEGER,
|
||||
format TEXT,
|
||||
|
||||
origin_mtime INTEGER NOT NULL,
|
||||
origin_size INTEGER NOT NULL,
|
||||
content_hash TEXT,
|
||||
chunk_manifest BLOB,
|
||||
last_sync INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
|
||||
|
||||
UNIQUE(origin_id, real_path)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS artwork (
|
||||
id INTEGER PRIMARY KEY,
|
||||
file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE,
|
||||
art_type TEXT NOT NULL,
|
||||
chunk_hash TEXT NOT NULL,
|
||||
width INTEGER,
|
||||
height INTEGER,
|
||||
mime_type TEXT,
|
||||
UNIQUE(file_id, art_type)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS collections (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
query_json TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
|
||||
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_files_virtual ON files(virtual_path);
|
||||
CREATE INDEX IF NOT EXISTS idx_files_artist_album ON files(artist, album);
|
||||
CREATE INDEX IF NOT EXISTS idx_files_content_hash ON files(content_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_files_real ON files(origin_id, real_path);
|
||||
CREATE INDEX IF NOT EXISTS idx_files_origin ON files(origin_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_files_last_sync ON files(last_sync);
|
||||
CREATE INDEX IF NOT EXISTS idx_artwork_file ON artwork(file_id);
|
||||
@@ -0,0 +1,448 @@
|
||||
use musicfs_core::{FileId, FileMeta, VirtualPath};
|
||||
use parking_lot::RwLock;
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::ffi::{OsStr, OsString};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{Duration, SystemTime};
|
||||
use tracing::{debug, trace};
|
||||
|
||||
pub type Inode = u64;
|
||||
pub const ROOT_INODE: Inode = 1;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum VirtualNode {
|
||||
Directory(DirNode),
|
||||
File(FileNode),
|
||||
}
|
||||
|
||||
impl VirtualNode {
|
||||
pub fn inode(&self) -> Inode {
|
||||
match self {
|
||||
VirtualNode::Directory(d) => d.inode,
|
||||
VirtualNode::File(f) => f.inode,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn name(&self) -> &OsStr {
|
||||
match self {
|
||||
VirtualNode::Directory(d) => &d.name,
|
||||
VirtualNode::File(f) => &f.name,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_dir(&self) -> bool {
|
||||
matches!(self, VirtualNode::Directory(_))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct DirNode {
|
||||
pub inode: Inode,
|
||||
pub parent: Inode,
|
||||
pub name: OsString,
|
||||
pub children: BTreeMap<OsString, Inode>,
|
||||
pub mtime: SystemTime,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct FileNode {
|
||||
pub inode: Inode,
|
||||
pub name: OsString,
|
||||
pub file_id: FileId,
|
||||
pub size: u64,
|
||||
pub mtime: SystemTime,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RefreshPolicy {
|
||||
pub ttl: Duration,
|
||||
pub refresh_on_access: bool,
|
||||
pub background_interval: Option<Duration>,
|
||||
}
|
||||
|
||||
impl Default for RefreshPolicy {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
ttl: Duration::from_secs(300),
|
||||
refresh_on_access: false,
|
||||
background_interval: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct VirtualTree {
|
||||
nodes: HashMap<Inode, VirtualNode>,
|
||||
path_to_inode: HashMap<VirtualPath, Inode>,
|
||||
next_inode: AtomicU64,
|
||||
last_refresh: RwLock<SystemTime>,
|
||||
refresh_policy: RefreshPolicy,
|
||||
}
|
||||
|
||||
impl VirtualTree {
|
||||
pub fn new() -> Self {
|
||||
Self::with_policy(RefreshPolicy::default())
|
||||
}
|
||||
|
||||
pub fn with_policy(policy: RefreshPolicy) -> Self {
|
||||
let mut tree = Self {
|
||||
nodes: HashMap::new(),
|
||||
path_to_inode: HashMap::new(),
|
||||
next_inode: AtomicU64::new(ROOT_INODE + 1),
|
||||
last_refresh: RwLock::new(SystemTime::now()),
|
||||
refresh_policy: policy,
|
||||
};
|
||||
|
||||
tree.nodes.insert(
|
||||
ROOT_INODE,
|
||||
VirtualNode::Directory(DirNode {
|
||||
inode: ROOT_INODE,
|
||||
parent: ROOT_INODE,
|
||||
name: OsString::from(""),
|
||||
children: BTreeMap::new(),
|
||||
mtime: SystemTime::now(),
|
||||
}),
|
||||
);
|
||||
tree.path_to_inode.insert(VirtualPath::new("/"), ROOT_INODE);
|
||||
|
||||
tree
|
||||
}
|
||||
|
||||
fn alloc_inode(&self) -> Inode {
|
||||
self.next_inode.fetch_add(1, Ordering::SeqCst)
|
||||
}
|
||||
|
||||
pub fn get(&self, inode: Inode) -> Option<&VirtualNode> {
|
||||
self.nodes.get(&inode)
|
||||
}
|
||||
|
||||
pub fn get_by_path(&self, path: &VirtualPath) -> Option<&VirtualNode> {
|
||||
self.path_to_inode
|
||||
.get(path)
|
||||
.and_then(|ino| self.nodes.get(ino))
|
||||
}
|
||||
|
||||
pub fn lookup(&self, parent_inode: Inode, name: &OsStr) -> Option<Inode> {
|
||||
if let Some(VirtualNode::Directory(dir)) = self.nodes.get(&parent_inode) {
|
||||
let result = dir.children.get(name).copied();
|
||||
let hit = result.is_some();
|
||||
trace!(inode = parent_inode, name = ?name, hit, "tree lookup");
|
||||
result
|
||||
} else {
|
||||
trace!(inode = parent_inode, name = ?name, hit = false, "tree lookup");
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn readdir(&self, inode: Inode) -> Option<Vec<(OsString, Inode, bool)>> {
|
||||
if let Some(VirtualNode::Directory(dir)) = self.nodes.get(&inode) {
|
||||
Some(
|
||||
dir.children
|
||||
.iter()
|
||||
.map(|(name, &ino)| {
|
||||
let is_dir = self.nodes.get(&ino).map(|n| n.is_dir()).unwrap_or(false);
|
||||
(name.clone(), ino, is_dir)
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_parent(&self, inode: Inode) -> Option<Inode> {
|
||||
match self.nodes.get(&inode) {
|
||||
Some(VirtualNode::Directory(dir)) => Some(dir.parent),
|
||||
Some(VirtualNode::File(_)) => self.find_parent_by_path_lookup(inode),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn find_parent_by_path_lookup(&self, inode: Inode) -> Option<Inode> {
|
||||
for (path, &ino) in &self.path_to_inode {
|
||||
if ino == inode {
|
||||
return std::path::Path::new(path.as_str()).parent().and_then(|p| {
|
||||
self.path_to_inode
|
||||
.get(&VirtualPath::new(p.to_string_lossy().into_owned()))
|
||||
.copied()
|
||||
});
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn insert_file(&mut self, meta: &FileMeta) -> Inode {
|
||||
let path = &meta.virtual_path;
|
||||
|
||||
let parent_inode = self.ensure_parents(path);
|
||||
|
||||
let inode = self.alloc_inode();
|
||||
let name = std::path::Path::new(path.as_str())
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_os_string();
|
||||
|
||||
let file_node = FileNode {
|
||||
inode,
|
||||
name: name.clone(),
|
||||
file_id: meta.id,
|
||||
size: meta.size,
|
||||
mtime: meta.mtime,
|
||||
};
|
||||
|
||||
self.nodes.insert(inode, VirtualNode::File(file_node));
|
||||
self.path_to_inode.insert(path.clone(), inode);
|
||||
|
||||
if let Some(VirtualNode::Directory(dir)) = self.nodes.get_mut(&parent_inode) {
|
||||
dir.children.insert(name, inode);
|
||||
}
|
||||
|
||||
debug!(inode, path = path.as_str(), file_id = ?meta.id, "add file to tree");
|
||||
inode
|
||||
}
|
||||
|
||||
fn ensure_parents(&mut self, path: &VirtualPath) -> Inode {
|
||||
let path_str = path.as_str();
|
||||
let components: Vec<&str> = path_str
|
||||
.trim_start_matches('/')
|
||||
.split('/')
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
|
||||
if components.len() <= 1 {
|
||||
return ROOT_INODE;
|
||||
}
|
||||
|
||||
let mut current_inode = ROOT_INODE;
|
||||
let mut current_path = String::from("/");
|
||||
|
||||
for component in &components[..components.len() - 1] {
|
||||
current_path.push_str(component);
|
||||
|
||||
let vpath = VirtualPath::new(¤t_path);
|
||||
|
||||
if let Some(&existing) = self.path_to_inode.get(&vpath) {
|
||||
current_inode = existing;
|
||||
} else {
|
||||
let new_inode = self.alloc_inode();
|
||||
let name = OsString::from(*component);
|
||||
|
||||
let dir_node = DirNode {
|
||||
inode: new_inode,
|
||||
parent: current_inode,
|
||||
name: name.clone(),
|
||||
children: BTreeMap::new(),
|
||||
mtime: SystemTime::now(),
|
||||
};
|
||||
|
||||
self.nodes
|
||||
.insert(new_inode, VirtualNode::Directory(dir_node));
|
||||
self.path_to_inode.insert(vpath, new_inode);
|
||||
|
||||
if let Some(VirtualNode::Directory(parent)) = self.nodes.get_mut(¤t_inode) {
|
||||
parent.children.insert(name, new_inode);
|
||||
}
|
||||
|
||||
current_inode = new_inode;
|
||||
}
|
||||
|
||||
current_path.push('/');
|
||||
}
|
||||
|
||||
current_inode
|
||||
}
|
||||
|
||||
pub fn remove_file(&mut self, path: &VirtualPath) -> Option<FileId> {
|
||||
let inode = self.path_to_inode.remove(path)?;
|
||||
|
||||
if let Some(VirtualNode::File(file)) = self.nodes.remove(&inode) {
|
||||
let parent_path = std::path::Path::new(path.as_str())
|
||||
.parent()
|
||||
.map(|p| VirtualPath::new(p.to_string_lossy().into_owned()))
|
||||
.unwrap_or_else(|| VirtualPath::new("/"));
|
||||
|
||||
if let Some(&parent_inode) = self.path_to_inode.get(&parent_path) {
|
||||
if let Some(VirtualNode::Directory(dir)) = self.nodes.get_mut(&parent_inode) {
|
||||
dir.children.remove(&file.name);
|
||||
}
|
||||
}
|
||||
|
||||
debug!(inode, path = path.as_str(), file_id = ?file.file_id, "remove file from tree");
|
||||
Some(file.file_id)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn file_count(&self) -> usize {
|
||||
self.nodes
|
||||
.values()
|
||||
.filter(|n| matches!(n, VirtualNode::File(_)))
|
||||
.count()
|
||||
}
|
||||
|
||||
pub fn dir_count(&self) -> usize {
|
||||
self.nodes
|
||||
.values()
|
||||
.filter(|n| matches!(n, VirtualNode::Directory(_)))
|
||||
.count()
|
||||
}
|
||||
|
||||
pub fn needs_refresh(&self) -> bool {
|
||||
let last = *self.last_refresh.read();
|
||||
last.elapsed().unwrap_or(Duration::MAX) > self.refresh_policy.ttl
|
||||
}
|
||||
|
||||
pub fn force_refresh(&mut self) {
|
||||
self.nodes.retain(|&ino, _| ino == ROOT_INODE);
|
||||
self.path_to_inode.retain(|p, _| p.as_str() == "/");
|
||||
|
||||
if let Some(VirtualNode::Directory(root)) = self.nodes.get_mut(&ROOT_INODE) {
|
||||
root.children.clear();
|
||||
}
|
||||
|
||||
*self.last_refresh.write() = SystemTime::now();
|
||||
}
|
||||
|
||||
pub fn mark_refreshed(&self) {
|
||||
*self.last_refresh.write() = SystemTime::now();
|
||||
}
|
||||
|
||||
pub fn refresh_policy(&self) -> &RefreshPolicy {
|
||||
&self.refresh_policy
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for VirtualTree {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TreeBuilder {
|
||||
tree: VirtualTree,
|
||||
}
|
||||
|
||||
impl TreeBuilder {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
tree: VirtualTree::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_policy(policy: RefreshPolicy) -> Self {
|
||||
Self {
|
||||
tree: VirtualTree::with_policy(policy),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_file(&mut self, meta: &FileMeta) {
|
||||
self.tree.insert_file(meta);
|
||||
}
|
||||
|
||||
pub fn build(self) -> VirtualTree {
|
||||
self.tree
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TreeBuilder {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use musicfs_core::{OriginId, RealPath};
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn make_file_meta(id: i64, vpath: &str) -> FileMeta {
|
||||
FileMeta {
|
||||
id: FileId(id),
|
||||
virtual_path: VirtualPath::new(vpath),
|
||||
real_path: RealPath {
|
||||
origin_id: OriginId::from("test"),
|
||||
path: PathBuf::from("/test"),
|
||||
},
|
||||
size: 1000,
|
||||
mtime: SystemTime::now(),
|
||||
content_hash: None,
|
||||
audio: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tree_creation() {
|
||||
let tree = VirtualTree::new();
|
||||
assert!(tree.get(ROOT_INODE).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_insert_file() {
|
||||
let mut tree = VirtualTree::new();
|
||||
let meta = make_file_meta(1, "/Artist/Album/Track.flac");
|
||||
tree.insert_file(&meta);
|
||||
|
||||
assert!(tree.get_by_path(&VirtualPath::new("/Artist")).is_some());
|
||||
assert!(tree
|
||||
.get_by_path(&VirtualPath::new("/Artist/Album"))
|
||||
.is_some());
|
||||
assert!(tree
|
||||
.get_by_path(&VirtualPath::new("/Artist/Album/Track.flac"))
|
||||
.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_readdir() {
|
||||
let mut tree = VirtualTree::new();
|
||||
tree.insert_file(&make_file_meta(1, "/Artist/Album/Track1.flac"));
|
||||
tree.insert_file(&make_file_meta(2, "/Artist/Album/Track2.flac"));
|
||||
|
||||
let root_children = tree.readdir(ROOT_INODE).unwrap();
|
||||
assert_eq!(root_children.len(), 1);
|
||||
assert_eq!(root_children[0].0, "Artist");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lookup() {
|
||||
let mut tree = VirtualTree::new();
|
||||
tree.insert_file(&make_file_meta(1, "/Artist/Album/Track.flac"));
|
||||
|
||||
let artist_inode = tree.lookup(ROOT_INODE, OsStr::new("Artist")).unwrap();
|
||||
assert!(tree.lookup(artist_inode, OsStr::new("Album")).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_and_dir_count() {
|
||||
let mut tree = VirtualTree::new();
|
||||
tree.insert_file(&make_file_meta(1, "/A/B/Track1.flac"));
|
||||
tree.insert_file(&make_file_meta(2, "/A/B/Track2.flac"));
|
||||
tree.insert_file(&make_file_meta(3, "/A/C/Track3.flac"));
|
||||
|
||||
assert_eq!(tree.file_count(), 3);
|
||||
assert_eq!(tree.dir_count(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remove_file() {
|
||||
let mut tree = VirtualTree::new();
|
||||
let path = VirtualPath::new("/Artist/Album/Track.flac");
|
||||
tree.insert_file(&make_file_meta(1, path.as_str()));
|
||||
|
||||
assert!(tree.get_by_path(&path).is_some());
|
||||
|
||||
let removed_id = tree.remove_file(&path);
|
||||
assert_eq!(removed_id, Some(FileId(1)));
|
||||
assert!(tree.get_by_path(&path).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tree_builder() {
|
||||
let mut builder = TreeBuilder::new();
|
||||
builder.add_file(&make_file_meta(1, "/A/Track1.flac"));
|
||||
builder.add_file(&make_file_meta(2, "/A/Track2.flac"));
|
||||
|
||||
let tree = builder.build();
|
||||
assert_eq!(tree.file_count(), 2);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user