feat(cache): update database layer for expanded metadata
- Update upsert_file() to include all 26 new AudioMeta fields - Update get_file_by_virtual_path() to read all new columns - Add get_file_metadata_row() for overlay synthesis - Add update_metadata() for partial metadata updates - Add clear_overlay() to reset metadata to NULL - Handle format_layout BLOB with msgpack serialization - Handle custom_tags JSON with serde_json - Add 8 comprehensive unit tests - All 92 tests pass, LSP diagnostics clean
This commit is contained in:
@@ -14,6 +14,7 @@ tracing.workspace = true
|
||||
thiserror.workspace = true
|
||||
serde.workspace = true
|
||||
rmp-serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
image.workspace = true
|
||||
lofty = "0.24"
|
||||
parking_lot.workspace = true
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
use crate::FormatLayout;
|
||||
use musicfs_core::{
|
||||
AudioFormat, AudioMeta, ContentHash, Error, FileId, FileMeta, OriginId, RealPath, Result,
|
||||
VirtualPath,
|
||||
};
|
||||
use rusqlite::{params, Connection, OptionalExtension};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
@@ -81,6 +83,29 @@ impl Database {
|
||||
audio_meta: &AudioMeta,
|
||||
origin_mtime: SystemTime,
|
||||
origin_size: u64,
|
||||
) -> Result<FileId> {
|
||||
self.upsert_file_with_layout(
|
||||
origin_id,
|
||||
real_path,
|
||||
virtual_path,
|
||||
audio_meta,
|
||||
origin_mtime,
|
||||
origin_size,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn upsert_file_with_layout(
|
||||
&self,
|
||||
origin_id: &OriginId,
|
||||
real_path: &Path,
|
||||
virtual_path: &VirtualPath,
|
||||
audio_meta: &AudioMeta,
|
||||
origin_mtime: SystemTime,
|
||||
origin_size: u64,
|
||||
format_layout: Option<&FormatLayout>,
|
||||
custom_tags: Option<&HashMap<String, String>>,
|
||||
) -> Result<FileId> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
|
||||
@@ -89,6 +114,18 @@ impl Database {
|
||||
.unwrap_or_default()
|
||||
.as_secs() as i64;
|
||||
|
||||
// Serialize format_layout as msgpack BLOB
|
||||
let format_layout_blob: Option<Vec<u8>> = format_layout
|
||||
.map(|fl| rmp_serde::to_vec(fl))
|
||||
.transpose()
|
||||
.map_err(|e| Error::Database(format!("format_layout serialization failed: {}", e)))?;
|
||||
|
||||
// Serialize custom_tags as JSON TEXT
|
||||
let custom_tags_json: Option<String> = custom_tags
|
||||
.map(|ct| serde_json::to_string(ct))
|
||||
.transpose()
|
||||
.map_err(|e| Error::Database(format!("custom_tags serialization failed: {}", e)))?;
|
||||
|
||||
conn.execute(
|
||||
r#"
|
||||
INSERT INTO files (
|
||||
@@ -96,13 +133,27 @@ impl Database {
|
||||
title, artist, album, album_artist, genre,
|
||||
year, track, disc,
|
||||
duration_ms, bitrate, sample_rate, format,
|
||||
track_total, disc_total, date, composer, comment,
|
||||
lyrics, copyright, compilation,
|
||||
artist_sort, album_artist_sort, album_sort, title_sort,
|
||||
mb_recording_id, mb_album_id, mb_artist_id, mb_album_artist_id, mb_release_group_id,
|
||||
replaygain_track_gain, replaygain_track_peak, replaygain_album_gain, replaygain_album_peak,
|
||||
channels, bits_per_sample, encoder,
|
||||
custom_tags, format_layout,
|
||||
origin_mtime, origin_size
|
||||
) VALUES (
|
||||
?1, ?2, ?3,
|
||||
?4, ?5, ?6, ?7, ?8,
|
||||
?9, ?10, ?11,
|
||||
?12, ?13, ?14, ?15,
|
||||
?16, ?17
|
||||
?16, ?17, ?18, ?19, ?20,
|
||||
?21, ?22, ?23,
|
||||
?24, ?25, ?26, ?27,
|
||||
?28, ?29, ?30, ?31, ?32,
|
||||
?33, ?34, ?35, ?36,
|
||||
?37, ?38, ?39,
|
||||
?40, ?41,
|
||||
?42, ?43
|
||||
)
|
||||
ON CONFLICT(origin_id, real_path) DO UPDATE SET
|
||||
virtual_path = excluded.virtual_path,
|
||||
@@ -118,6 +169,32 @@ impl Database {
|
||||
bitrate = excluded.bitrate,
|
||||
sample_rate = excluded.sample_rate,
|
||||
format = excluded.format,
|
||||
track_total = excluded.track_total,
|
||||
disc_total = excluded.disc_total,
|
||||
date = excluded.date,
|
||||
composer = excluded.composer,
|
||||
comment = excluded.comment,
|
||||
lyrics = excluded.lyrics,
|
||||
copyright = excluded.copyright,
|
||||
compilation = excluded.compilation,
|
||||
artist_sort = excluded.artist_sort,
|
||||
album_artist_sort = excluded.album_artist_sort,
|
||||
album_sort = excluded.album_sort,
|
||||
title_sort = excluded.title_sort,
|
||||
mb_recording_id = excluded.mb_recording_id,
|
||||
mb_album_id = excluded.mb_album_id,
|
||||
mb_artist_id = excluded.mb_artist_id,
|
||||
mb_album_artist_id = excluded.mb_album_artist_id,
|
||||
mb_release_group_id = excluded.mb_release_group_id,
|
||||
replaygain_track_gain = excluded.replaygain_track_gain,
|
||||
replaygain_track_peak = excluded.replaygain_track_peak,
|
||||
replaygain_album_gain = excluded.replaygain_album_gain,
|
||||
replaygain_album_peak = excluded.replaygain_album_peak,
|
||||
channels = excluded.channels,
|
||||
bits_per_sample = excluded.bits_per_sample,
|
||||
encoder = excluded.encoder,
|
||||
custom_tags = excluded.custom_tags,
|
||||
format_layout = excluded.format_layout,
|
||||
origin_mtime = excluded.origin_mtime,
|
||||
origin_size = excluded.origin_size,
|
||||
last_sync = strftime('%s', 'now')
|
||||
@@ -138,6 +215,32 @@ impl Database {
|
||||
&audio_meta.bitrate,
|
||||
&audio_meta.sample_rate,
|
||||
format!("{:?}", audio_meta.format),
|
||||
&audio_meta.track_total,
|
||||
&audio_meta.disc_total,
|
||||
&audio_meta.date,
|
||||
&audio_meta.composer,
|
||||
&audio_meta.comment,
|
||||
&audio_meta.lyrics,
|
||||
&audio_meta.copyright,
|
||||
&audio_meta.compilation.map(|b| if b { 1i32 } else { 0i32 }),
|
||||
&audio_meta.artist_sort,
|
||||
&audio_meta.album_artist_sort,
|
||||
&audio_meta.album_sort,
|
||||
&audio_meta.title_sort,
|
||||
&audio_meta.mb_recording_id,
|
||||
&audio_meta.mb_album_id,
|
||||
&audio_meta.mb_artist_id,
|
||||
&audio_meta.mb_album_artist_id,
|
||||
&audio_meta.mb_release_group_id,
|
||||
&audio_meta.replaygain_track_gain,
|
||||
&audio_meta.replaygain_track_peak,
|
||||
&audio_meta.replaygain_album_gain,
|
||||
&audio_meta.replaygain_album_peak,
|
||||
&audio_meta.channels,
|
||||
&audio_meta.bits_per_sample,
|
||||
&audio_meta.encoder,
|
||||
&custom_tags_json,
|
||||
&format_layout_blob,
|
||||
mtime_secs,
|
||||
origin_size as i64,
|
||||
],
|
||||
@@ -169,6 +272,12 @@ impl Database {
|
||||
title, artist, album, album_artist, genre,
|
||||
year, track, disc,
|
||||
duration_ms, bitrate, sample_rate, format,
|
||||
track_total, disc_total, date, composer, comment,
|
||||
lyrics, copyright, compilation,
|
||||
artist_sort, album_artist_sort, album_sort, title_sort,
|
||||
mb_recording_id, mb_album_id, mb_artist_id, mb_album_artist_id, mb_release_group_id,
|
||||
replaygain_track_gain, replaygain_track_peak, replaygain_album_gain, replaygain_album_peak,
|
||||
channels, bits_per_sample, encoder,
|
||||
origin_mtime, origin_size, content_hash
|
||||
FROM files
|
||||
WHERE virtual_path = ?1
|
||||
@@ -181,7 +290,8 @@ impl Database {
|
||||
.map(parse_audio_format)
|
||||
.unwrap_or(AudioFormat::Unknown);
|
||||
|
||||
let content_hash: Option<String> = row.get(18)?;
|
||||
let compilation_int: Option<i32> = row.get(23)?;
|
||||
let content_hash: Option<String> = row.get(42)?;
|
||||
|
||||
Ok(FileMeta {
|
||||
id: FileId(row.get(0)?),
|
||||
@@ -203,10 +313,33 @@ impl Database {
|
||||
bitrate: row.get(13)?,
|
||||
sample_rate: row.get(14)?,
|
||||
format,
|
||||
..Default::default()
|
||||
track_total: row.get(16)?,
|
||||
disc_total: row.get(17)?,
|
||||
date: row.get(18)?,
|
||||
composer: row.get(19)?,
|
||||
comment: row.get(20)?,
|
||||
lyrics: row.get(21)?,
|
||||
copyright: row.get(22)?,
|
||||
compilation: compilation_int.map(|i| i != 0),
|
||||
artist_sort: row.get(24)?,
|
||||
album_artist_sort: row.get(25)?,
|
||||
album_sort: row.get(26)?,
|
||||
title_sort: row.get(27)?,
|
||||
mb_recording_id: row.get(28)?,
|
||||
mb_album_id: row.get(29)?,
|
||||
mb_artist_id: row.get(30)?,
|
||||
mb_album_artist_id: row.get(31)?,
|
||||
mb_release_group_id: row.get(32)?,
|
||||
replaygain_track_gain: row.get(33)?,
|
||||
replaygain_track_peak: row.get(34)?,
|
||||
replaygain_album_gain: row.get(35)?,
|
||||
replaygain_album_peak: row.get(36)?,
|
||||
channels: row.get(37)?,
|
||||
bits_per_sample: row.get(38)?,
|
||||
encoder: row.get(39)?,
|
||||
}),
|
||||
size: row.get::<_, i64>(17)? as u64,
|
||||
mtime: UNIX_EPOCH + Duration::from_secs(row.get::<_, i64>(16)? as u64),
|
||||
size: row.get::<_, i64>(41)? as u64,
|
||||
mtime: UNIX_EPOCH + Duration::from_secs(row.get::<_, i64>(40)? as u64),
|
||||
content_hash: content_hash.and_then(|s| parse_content_hash(&s)),
|
||||
})
|
||||
},
|
||||
@@ -436,6 +569,257 @@ impl Database {
|
||||
.map_err(|e| Error::Database(format!("query failed: {}", e)))
|
||||
}
|
||||
|
||||
pub fn get_file_metadata_row(&self, file_id: FileId) -> Result<AudioMeta> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
|
||||
conn.query_row(
|
||||
r#"
|
||||
SELECT title, artist, album, album_artist, genre,
|
||||
year, track, disc,
|
||||
duration_ms, bitrate, sample_rate, format,
|
||||
track_total, disc_total, date, composer, comment,
|
||||
lyrics, copyright, compilation,
|
||||
artist_sort, album_artist_sort, album_sort, title_sort,
|
||||
mb_recording_id, mb_album_id, mb_artist_id, mb_album_artist_id, mb_release_group_id,
|
||||
replaygain_track_gain, replaygain_track_peak, replaygain_album_gain, replaygain_album_peak,
|
||||
channels, bits_per_sample, encoder
|
||||
FROM files
|
||||
WHERE id = ?1
|
||||
"#,
|
||||
params![file_id.0],
|
||||
|row| {
|
||||
let format_str: Option<String> = row.get(11)?;
|
||||
let format = format_str
|
||||
.as_deref()
|
||||
.map(parse_audio_format)
|
||||
.unwrap_or(AudioFormat::Unknown);
|
||||
|
||||
let compilation_int: Option<i32> = row.get(19)?;
|
||||
|
||||
Ok(AudioMeta {
|
||||
title: row.get(0)?,
|
||||
artist: row.get(1)?,
|
||||
album: row.get(2)?,
|
||||
album_artist: row.get(3)?,
|
||||
genre: row.get(4)?,
|
||||
year: row.get(5)?,
|
||||
track: row.get(6)?,
|
||||
disc: row.get(7)?,
|
||||
duration_ms: row.get::<_, Option<i64>>(8)?.map(|d| d as u64),
|
||||
bitrate: row.get(9)?,
|
||||
sample_rate: row.get(10)?,
|
||||
format,
|
||||
track_total: row.get(12)?,
|
||||
disc_total: row.get(13)?,
|
||||
date: row.get(14)?,
|
||||
composer: row.get(15)?,
|
||||
comment: row.get(16)?,
|
||||
lyrics: row.get(17)?,
|
||||
copyright: row.get(18)?,
|
||||
compilation: compilation_int.map(|i| i != 0),
|
||||
artist_sort: row.get(20)?,
|
||||
album_artist_sort: row.get(21)?,
|
||||
album_sort: row.get(22)?,
|
||||
title_sort: row.get(23)?,
|
||||
mb_recording_id: row.get(24)?,
|
||||
mb_album_id: row.get(25)?,
|
||||
mb_artist_id: row.get(26)?,
|
||||
mb_album_artist_id: row.get(27)?,
|
||||
mb_release_group_id: row.get(28)?,
|
||||
replaygain_track_gain: row.get(29)?,
|
||||
replaygain_track_peak: row.get(30)?,
|
||||
replaygain_album_gain: row.get(31)?,
|
||||
replaygain_album_peak: row.get(32)?,
|
||||
channels: row.get(33)?,
|
||||
bits_per_sample: row.get(34)?,
|
||||
encoder: row.get(35)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.map_err(|e| Error::Database(format!("get_file_metadata_row failed: {}", e)))
|
||||
}
|
||||
|
||||
pub fn get_format_layout(&self, file_id: FileId) -> Result<Option<FormatLayout>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
|
||||
let blob: Option<Vec<u8>> = conn
|
||||
.query_row(
|
||||
"SELECT format_layout FROM files WHERE id = ?1",
|
||||
params![file_id.0],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()
|
||||
.map_err(|e| Error::Database(format!("get_format_layout query failed: {}", e)))?
|
||||
.flatten();
|
||||
|
||||
match blob {
|
||||
Some(data) => {
|
||||
let layout: FormatLayout = rmp_serde::from_slice(&data).map_err(|e| {
|
||||
Error::Database(format!("format_layout deserialization failed: {}", e))
|
||||
})?;
|
||||
Ok(Some(layout))
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_custom_tags(&self, file_id: FileId) -> Result<Option<HashMap<String, String>>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
|
||||
let json: Option<String> = conn
|
||||
.query_row(
|
||||
"SELECT custom_tags FROM files WHERE id = ?1",
|
||||
params![file_id.0],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()
|
||||
.map_err(|e| Error::Database(format!("get_custom_tags query failed: {}", e)))?
|
||||
.flatten();
|
||||
|
||||
match json {
|
||||
Some(data) => {
|
||||
let tags: HashMap<String, String> = serde_json::from_str(&data).map_err(|e| {
|
||||
Error::Database(format!("custom_tags deserialization failed: {}", e))
|
||||
})?;
|
||||
Ok(Some(tags))
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_metadata(&self, file_id: FileId, metadata: &AudioMeta) -> Result<()> {
|
||||
let mut updates = Vec::new();
|
||||
let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
|
||||
|
||||
macro_rules! add_field {
|
||||
($field:ident, $col:literal) => {
|
||||
if let Some(ref val) = metadata.$field {
|
||||
updates.push(concat!($col, " = ?"));
|
||||
params_vec.push(Box::new(val.clone()));
|
||||
}
|
||||
};
|
||||
($field:ident, $col:literal, u32) => {
|
||||
if let Some(val) = metadata.$field {
|
||||
updates.push(concat!($col, " = ?"));
|
||||
params_vec.push(Box::new(val as i64));
|
||||
}
|
||||
};
|
||||
($field:ident, $col:literal, u64) => {
|
||||
if let Some(val) = metadata.$field {
|
||||
updates.push(concat!($col, " = ?"));
|
||||
params_vec.push(Box::new(val as i64));
|
||||
}
|
||||
};
|
||||
($field:ident, $col:literal, f32) => {
|
||||
if let Some(val) = metadata.$field {
|
||||
updates.push(concat!($col, " = ?"));
|
||||
params_vec.push(Box::new(val as f64));
|
||||
}
|
||||
};
|
||||
($field:ident, $col:literal, bool) => {
|
||||
if let Some(val) = metadata.$field {
|
||||
updates.push(concat!($col, " = ?"));
|
||||
params_vec.push(Box::new(if val { 1i32 } else { 0i32 }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
add_field!(title, "title");
|
||||
add_field!(artist, "artist");
|
||||
add_field!(album, "album");
|
||||
add_field!(album_artist, "album_artist");
|
||||
add_field!(genre, "genre");
|
||||
add_field!(year, "year", u32);
|
||||
add_field!(track, "track", u32);
|
||||
add_field!(disc, "disc", u32);
|
||||
add_field!(duration_ms, "duration_ms", u64);
|
||||
add_field!(bitrate, "bitrate", u32);
|
||||
add_field!(sample_rate, "sample_rate", u32);
|
||||
add_field!(track_total, "track_total", u32);
|
||||
add_field!(disc_total, "disc_total", u32);
|
||||
add_field!(date, "date");
|
||||
add_field!(composer, "composer");
|
||||
add_field!(comment, "comment");
|
||||
add_field!(lyrics, "lyrics");
|
||||
add_field!(copyright, "copyright");
|
||||
add_field!(compilation, "compilation", bool);
|
||||
add_field!(artist_sort, "artist_sort");
|
||||
add_field!(album_artist_sort, "album_artist_sort");
|
||||
add_field!(album_sort, "album_sort");
|
||||
add_field!(title_sort, "title_sort");
|
||||
add_field!(mb_recording_id, "mb_recording_id");
|
||||
add_field!(mb_album_id, "mb_album_id");
|
||||
add_field!(mb_artist_id, "mb_artist_id");
|
||||
add_field!(mb_album_artist_id, "mb_album_artist_id");
|
||||
add_field!(mb_release_group_id, "mb_release_group_id");
|
||||
add_field!(replaygain_track_gain, "replaygain_track_gain", f32);
|
||||
add_field!(replaygain_track_peak, "replaygain_track_peak", f32);
|
||||
add_field!(replaygain_album_gain, "replaygain_album_gain", f32);
|
||||
add_field!(replaygain_album_peak, "replaygain_album_peak", f32);
|
||||
add_field!(channels, "channels", u32);
|
||||
add_field!(bits_per_sample, "bits_per_sample", u32);
|
||||
add_field!(encoder, "encoder");
|
||||
|
||||
if updates.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let sql = format!("UPDATE files SET {} WHERE id = ?", updates.join(", "));
|
||||
params_vec.push(Box::new(file_id.0));
|
||||
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let params_refs: Vec<&dyn rusqlite::ToSql> =
|
||||
params_vec.iter().map(|p| p.as_ref()).collect();
|
||||
|
||||
let rows = conn
|
||||
.execute(&sql, params_refs.as_slice())
|
||||
.map_err(|e| Error::Database(format!("update_metadata failed: {}", e)))?;
|
||||
|
||||
if rows == 0 {
|
||||
return Err(Error::FileNotFound(format!(
|
||||
"file id {} not found",
|
||||
file_id.0
|
||||
)));
|
||||
}
|
||||
|
||||
debug!(id = file_id.0, fields = updates.len(), "updated metadata");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn clear_overlay(&self, file_id: FileId) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
|
||||
let rows = conn
|
||||
.execute(
|
||||
r#"
|
||||
UPDATE files SET
|
||||
title = NULL, artist = NULL, album = NULL, album_artist = NULL, genre = NULL,
|
||||
year = NULL, track = NULL, disc = NULL,
|
||||
duration_ms = NULL, bitrate = NULL, sample_rate = NULL, format = NULL,
|
||||
track_total = NULL, disc_total = NULL, date = NULL, composer = NULL, comment = NULL,
|
||||
lyrics = NULL, copyright = NULL, compilation = NULL,
|
||||
artist_sort = NULL, album_artist_sort = NULL, album_sort = NULL, title_sort = NULL,
|
||||
mb_recording_id = NULL, mb_album_id = NULL, mb_artist_id = NULL, mb_album_artist_id = NULL, mb_release_group_id = NULL,
|
||||
replaygain_track_gain = NULL, replaygain_track_peak = NULL, replaygain_album_gain = NULL, replaygain_album_peak = NULL,
|
||||
channels = NULL, bits_per_sample = NULL, encoder = NULL,
|
||||
custom_tags = NULL, format_layout = NULL
|
||||
WHERE id = ?1
|
||||
"#,
|
||||
params![file_id.0],
|
||||
)
|
||||
.map_err(|e| Error::Database(format!("clear_overlay failed: {}", e)))?;
|
||||
|
||||
if rows == 0 {
|
||||
return Err(Error::FileNotFound(format!(
|
||||
"file id {} not found",
|
||||
file_id.0
|
||||
)));
|
||||
}
|
||||
|
||||
debug!(id = file_id.0, "cleared overlay metadata");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn mark_trashed(&self, id: FileId, original_path: &VirtualPath) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let rows = conn
|
||||
@@ -1047,4 +1431,289 @@ mod tests {
|
||||
assert_eq!(db.list_trashed(&TrashedFilter::default()).unwrap().len(), 0);
|
||||
assert_eq!(db.file_count().unwrap(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_new_metadata_fields_roundtrip() {
|
||||
let db = Database::open_memory().unwrap();
|
||||
|
||||
let audio_meta = AudioMeta {
|
||||
title: Some("Test Track".to_string()),
|
||||
artist: Some("Test Artist".to_string()),
|
||||
album: Some("Test Album".to_string()),
|
||||
album_artist: Some("Test Album Artist".to_string()),
|
||||
genre: Some("Rock".to_string()),
|
||||
year: Some(2024),
|
||||
track: Some(5),
|
||||
disc: Some(1),
|
||||
duration_ms: Some(180000),
|
||||
bitrate: Some(320),
|
||||
sample_rate: Some(44100),
|
||||
format: AudioFormat::Flac,
|
||||
track_total: Some(12),
|
||||
disc_total: Some(2),
|
||||
date: Some("2024-03-15".to_string()),
|
||||
composer: Some("Test Composer".to_string()),
|
||||
comment: Some("Test comment".to_string()),
|
||||
lyrics: Some("La la la".to_string()),
|
||||
copyright: Some("2024 Test Records".to_string()),
|
||||
compilation: Some(true),
|
||||
artist_sort: Some("Artist, Test".to_string()),
|
||||
album_artist_sort: Some("Album Artist, Test".to_string()),
|
||||
album_sort: Some("Album, Test".to_string()),
|
||||
title_sort: Some("Track, Test".to_string()),
|
||||
mb_recording_id: Some("rec-123".to_string()),
|
||||
mb_album_id: Some("alb-456".to_string()),
|
||||
mb_artist_id: Some("art-789".to_string()),
|
||||
mb_album_artist_id: Some("aa-012".to_string()),
|
||||
mb_release_group_id: Some("rg-345".to_string()),
|
||||
replaygain_track_gain: Some(-6.5),
|
||||
replaygain_track_peak: Some(0.95),
|
||||
replaygain_album_gain: Some(-7.2),
|
||||
replaygain_album_peak: Some(0.98),
|
||||
channels: Some(2),
|
||||
bits_per_sample: Some(24),
|
||||
encoder: Some("FLAC 1.4.0".to_string()),
|
||||
};
|
||||
|
||||
let vpath = VirtualPath::new("/Artist/Album/05 - Track.flac");
|
||||
let id = db
|
||||
.upsert_file(
|
||||
&OriginId::from("local"),
|
||||
Path::new("/music/test.flac"),
|
||||
&vpath,
|
||||
&audio_meta,
|
||||
UNIX_EPOCH,
|
||||
5000000,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let retrieved = db.get_file_by_virtual_path(&vpath).unwrap().unwrap();
|
||||
let audio = retrieved.audio.unwrap();
|
||||
|
||||
assert_eq!(audio.title, Some("Test Track".to_string()));
|
||||
assert_eq!(audio.track_total, Some(12));
|
||||
assert_eq!(audio.disc_total, Some(2));
|
||||
assert_eq!(audio.date, Some("2024-03-15".to_string()));
|
||||
assert_eq!(audio.composer, Some("Test Composer".to_string()));
|
||||
assert_eq!(audio.comment, Some("Test comment".to_string()));
|
||||
assert_eq!(audio.lyrics, Some("La la la".to_string()));
|
||||
assert_eq!(audio.copyright, Some("2024 Test Records".to_string()));
|
||||
assert_eq!(audio.compilation, Some(true));
|
||||
assert_eq!(audio.artist_sort, Some("Artist, Test".to_string()));
|
||||
assert_eq!(audio.mb_recording_id, Some("rec-123".to_string()));
|
||||
assert_eq!(audio.mb_album_id, Some("alb-456".to_string()));
|
||||
assert!(audio.replaygain_track_gain.is_some());
|
||||
assert!((audio.replaygain_track_gain.unwrap() - (-6.5)).abs() < 0.01);
|
||||
assert_eq!(audio.channels, Some(2));
|
||||
assert_eq!(audio.bits_per_sample, Some(24));
|
||||
assert_eq!(audio.encoder, Some("FLAC 1.4.0".to_string()));
|
||||
|
||||
let meta_row = db.get_file_metadata_row(id).unwrap();
|
||||
assert_eq!(meta_row.title, Some("Test Track".to_string()));
|
||||
assert_eq!(meta_row.track_total, Some(12));
|
||||
assert_eq!(meta_row.mb_album_id, Some("alb-456".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_metadata_partial() {
|
||||
let db = Database::open_memory().unwrap();
|
||||
|
||||
let audio_meta = AudioMeta {
|
||||
title: Some("Original Title".to_string()),
|
||||
artist: Some("Original Artist".to_string()),
|
||||
album: Some("Original Album".to_string()),
|
||||
track: Some(1),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let vpath = VirtualPath::new("/Artist/Album/Track.flac");
|
||||
let id = db
|
||||
.upsert_file(
|
||||
&OriginId::from("local"),
|
||||
Path::new("/test.flac"),
|
||||
&vpath,
|
||||
&audio_meta,
|
||||
UNIX_EPOCH,
|
||||
1000,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let update = AudioMeta {
|
||||
title: Some("Updated Title".to_string()),
|
||||
composer: Some("New Composer".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
db.update_metadata(id, &update).unwrap();
|
||||
|
||||
let retrieved = db.get_file_metadata_row(id).unwrap();
|
||||
assert_eq!(retrieved.title, Some("Updated Title".to_string()));
|
||||
assert_eq!(retrieved.artist, Some("Original Artist".to_string()));
|
||||
assert_eq!(retrieved.album, Some("Original Album".to_string()));
|
||||
assert_eq!(retrieved.composer, Some("New Composer".to_string()));
|
||||
assert_eq!(retrieved.track, Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_metadata_empty_noop() {
|
||||
let db = Database::open_memory().unwrap();
|
||||
|
||||
let id = db
|
||||
.upsert_file(
|
||||
&OriginId::from("local"),
|
||||
Path::new("/test.flac"),
|
||||
&VirtualPath::new("/Track.flac"),
|
||||
&AudioMeta {
|
||||
title: Some("Title".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
UNIX_EPOCH,
|
||||
1000,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let empty_update = AudioMeta::default();
|
||||
db.update_metadata(id, &empty_update).unwrap();
|
||||
|
||||
let retrieved = db.get_file_metadata_row(id).unwrap();
|
||||
assert_eq!(retrieved.title, Some("Title".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clear_overlay() {
|
||||
let db = Database::open_memory().unwrap();
|
||||
|
||||
let audio_meta = AudioMeta {
|
||||
title: Some("Title".to_string()),
|
||||
artist: Some("Artist".to_string()),
|
||||
album: Some("Album".to_string()),
|
||||
composer: Some("Composer".to_string()),
|
||||
mb_album_id: Some("mb-123".to_string()),
|
||||
replaygain_track_gain: Some(-5.0),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let id = db
|
||||
.upsert_file(
|
||||
&OriginId::from("local"),
|
||||
Path::new("/test.flac"),
|
||||
&VirtualPath::new("/Track.flac"),
|
||||
&audio_meta,
|
||||
UNIX_EPOCH,
|
||||
1000,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
db.clear_overlay(id).unwrap();
|
||||
|
||||
let retrieved = db.get_file_metadata_row(id).unwrap();
|
||||
assert!(retrieved.title.is_none());
|
||||
assert!(retrieved.artist.is_none());
|
||||
assert!(retrieved.album.is_none());
|
||||
assert!(retrieved.composer.is_none());
|
||||
assert!(retrieved.mb_album_id.is_none());
|
||||
assert!(retrieved.replaygain_track_gain.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_layout_roundtrip() {
|
||||
use crate::FormatLayout;
|
||||
|
||||
let db = Database::open_memory().unwrap();
|
||||
|
||||
let layout = FormatLayout {
|
||||
audio_start: 1024,
|
||||
audio_end: 5000000,
|
||||
format: AudioFormat::Flac,
|
||||
format_data: Some(vec![0x66, 0x4c, 0x61, 0x43]),
|
||||
};
|
||||
|
||||
let id = db
|
||||
.upsert_file_with_layout(
|
||||
&OriginId::from("local"),
|
||||
Path::new("/test.flac"),
|
||||
&VirtualPath::new("/Track.flac"),
|
||||
&AudioMeta::default(),
|
||||
UNIX_EPOCH,
|
||||
5000000,
|
||||
Some(&layout),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let retrieved = db.get_format_layout(id).unwrap().unwrap();
|
||||
assert_eq!(retrieved.audio_start, 1024);
|
||||
assert_eq!(retrieved.audio_end, 5000000);
|
||||
assert_eq!(retrieved.format, AudioFormat::Flac);
|
||||
assert_eq!(retrieved.format_data, Some(vec![0x66, 0x4c, 0x61, 0x43]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_custom_tags_roundtrip() {
|
||||
let db = Database::open_memory().unwrap();
|
||||
|
||||
let mut custom_tags = HashMap::new();
|
||||
custom_tags.insert("CUSTOM_FIELD".to_string(), "custom value".to_string());
|
||||
custom_tags.insert("ANOTHER_TAG".to_string(), "another value".to_string());
|
||||
|
||||
let id = db
|
||||
.upsert_file_with_layout(
|
||||
&OriginId::from("local"),
|
||||
Path::new("/test.flac"),
|
||||
&VirtualPath::new("/Track.flac"),
|
||||
&AudioMeta::default(),
|
||||
UNIX_EPOCH,
|
||||
1000,
|
||||
None,
|
||||
Some(&custom_tags),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let retrieved = db.get_custom_tags(id).unwrap().unwrap();
|
||||
assert_eq!(
|
||||
retrieved.get("CUSTOM_FIELD"),
|
||||
Some(&"custom value".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
retrieved.get("ANOTHER_TAG"),
|
||||
Some(&"another value".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_layout_none() {
|
||||
let db = Database::open_memory().unwrap();
|
||||
|
||||
let id = db
|
||||
.upsert_file(
|
||||
&OriginId::from("local"),
|
||||
Path::new("/test.flac"),
|
||||
&VirtualPath::new("/Track.flac"),
|
||||
&AudioMeta::default(),
|
||||
UNIX_EPOCH,
|
||||
1000,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let layout = db.get_format_layout(id).unwrap();
|
||||
assert!(layout.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_custom_tags_none() {
|
||||
let db = Database::open_memory().unwrap();
|
||||
|
||||
let id = db
|
||||
.upsert_file(
|
||||
&OriginId::from("local"),
|
||||
Path::new("/test.flac"),
|
||||
&VirtualPath::new("/Track.flac"),
|
||||
&AudioMeta::default(),
|
||||
UNIX_EPOCH,
|
||||
1000,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let tags = db.get_custom_tags(id).unwrap();
|
||||
assert!(tags.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user