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:
Generated
+1
@@ -1925,6 +1925,7 @@ dependencies = [
|
|||||||
"rmp-serde",
|
"rmp-serde",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"sled",
|
"sled",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ tracing.workspace = true
|
|||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
rmp-serde.workspace = true
|
rmp-serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
image.workspace = true
|
image.workspace = true
|
||||||
lofty = "0.24"
|
lofty = "0.24"
|
||||||
parking_lot.workspace = true
|
parking_lot.workspace = true
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
use crate::FormatLayout;
|
||||||
use musicfs_core::{
|
use musicfs_core::{
|
||||||
AudioFormat, AudioMeta, ContentHash, Error, FileId, FileMeta, OriginId, RealPath, Result,
|
AudioFormat, AudioMeta, ContentHash, Error, FileId, FileMeta, OriginId, RealPath, Result,
|
||||||
VirtualPath,
|
VirtualPath,
|
||||||
};
|
};
|
||||||
use rusqlite::{params, Connection, OptionalExtension};
|
use rusqlite::{params, Connection, OptionalExtension};
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
@@ -81,6 +83,29 @@ impl Database {
|
|||||||
audio_meta: &AudioMeta,
|
audio_meta: &AudioMeta,
|
||||||
origin_mtime: SystemTime,
|
origin_mtime: SystemTime,
|
||||||
origin_size: u64,
|
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> {
|
) -> Result<FileId> {
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
|
|
||||||
@@ -89,6 +114,18 @@ impl Database {
|
|||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.as_secs() as i64;
|
.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(
|
conn.execute(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO files (
|
INSERT INTO files (
|
||||||
@@ -96,13 +133,27 @@ impl Database {
|
|||||||
title, artist, album, album_artist, genre,
|
title, artist, album, album_artist, genre,
|
||||||
year, track, disc,
|
year, track, disc,
|
||||||
duration_ms, bitrate, sample_rate, format,
|
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
|
origin_mtime, origin_size
|
||||||
) VALUES (
|
) VALUES (
|
||||||
?1, ?2, ?3,
|
?1, ?2, ?3,
|
||||||
?4, ?5, ?6, ?7, ?8,
|
?4, ?5, ?6, ?7, ?8,
|
||||||
?9, ?10, ?11,
|
?9, ?10, ?11,
|
||||||
?12, ?13, ?14, ?15,
|
?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
|
ON CONFLICT(origin_id, real_path) DO UPDATE SET
|
||||||
virtual_path = excluded.virtual_path,
|
virtual_path = excluded.virtual_path,
|
||||||
@@ -118,6 +169,32 @@ impl Database {
|
|||||||
bitrate = excluded.bitrate,
|
bitrate = excluded.bitrate,
|
||||||
sample_rate = excluded.sample_rate,
|
sample_rate = excluded.sample_rate,
|
||||||
format = excluded.format,
|
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_mtime = excluded.origin_mtime,
|
||||||
origin_size = excluded.origin_size,
|
origin_size = excluded.origin_size,
|
||||||
last_sync = strftime('%s', 'now')
|
last_sync = strftime('%s', 'now')
|
||||||
@@ -138,6 +215,32 @@ impl Database {
|
|||||||
&audio_meta.bitrate,
|
&audio_meta.bitrate,
|
||||||
&audio_meta.sample_rate,
|
&audio_meta.sample_rate,
|
||||||
format!("{:?}", audio_meta.format),
|
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,
|
mtime_secs,
|
||||||
origin_size as i64,
|
origin_size as i64,
|
||||||
],
|
],
|
||||||
@@ -169,6 +272,12 @@ impl Database {
|
|||||||
title, artist, album, album_artist, genre,
|
title, artist, album, album_artist, genre,
|
||||||
year, track, disc,
|
year, track, disc,
|
||||||
duration_ms, bitrate, sample_rate, format,
|
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
|
origin_mtime, origin_size, content_hash
|
||||||
FROM files
|
FROM files
|
||||||
WHERE virtual_path = ?1
|
WHERE virtual_path = ?1
|
||||||
@@ -181,7 +290,8 @@ impl Database {
|
|||||||
.map(parse_audio_format)
|
.map(parse_audio_format)
|
||||||
.unwrap_or(AudioFormat::Unknown);
|
.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 {
|
Ok(FileMeta {
|
||||||
id: FileId(row.get(0)?),
|
id: FileId(row.get(0)?),
|
||||||
@@ -203,10 +313,33 @@ impl Database {
|
|||||||
bitrate: row.get(13)?,
|
bitrate: row.get(13)?,
|
||||||
sample_rate: row.get(14)?,
|
sample_rate: row.get(14)?,
|
||||||
format,
|
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,
|
size: row.get::<_, i64>(41)? as u64,
|
||||||
mtime: UNIX_EPOCH + Duration::from_secs(row.get::<_, i64>(16)? 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)),
|
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)))
|
.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<()> {
|
pub fn mark_trashed(&self, id: FileId, original_path: &VirtualPath) -> Result<()> {
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
let rows = conn
|
let rows = conn
|
||||||
@@ -1047,4 +1431,289 @@ mod tests {
|
|||||||
assert_eq!(db.list_trashed(&TrashedFilter::default()).unwrap().len(), 0);
|
assert_eq!(db.list_trashed(&TrashedFilter::default()).unwrap().len(), 0);
|
||||||
assert_eq!(db.file_count().unwrap(), 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