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}; use tracing::{debug, info, warn}; const SCHEMA: &str = include_str!("schema.sql"); pub struct Database { conn: Arc>, } impl Database { pub fn open(path: &Path) -> Result { 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 { 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 { 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 { 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>, ) -> Result { let conn = self.conn.lock().unwrap(); let mtime_secs = origin_mtime .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_secs() as i64; // Serialize format_layout as msgpack BLOB let format_layout_blob: Option> = 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 = 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 ( origin_id, real_path, virtual_path, 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, ?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, 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, 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') "#, 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), &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, ], ) .map_err(|e| Error::Database(format!("upsert failed: {}", e)))?; let id = conn.last_insert_rowid(); let file_id = if id == 0 { conn.query_row( "SELECT id FROM files WHERE origin_id = ?1 AND real_path = ?2", params![&origin_id.0, real_path.to_string_lossy()], |row| row.get::<_, i64>(0), ) .map_err(|e| Error::Database(format!("failed to get file id after upsert: {}", e)))? } else { id }; debug!(id = file_id, vpath = virtual_path.as_str(), "Upserted file"); Ok(FileId(file_id)) } pub fn get_file_by_virtual_path(&self, path: &VirtualPath) -> Result> { 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, 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 "#, params![path.as_str()], |row| { let format_str: Option = row.get(15)?; let format = format_str .as_deref() .map(parse_audio_format) .unwrap_or(AudioFormat::Unknown); let compilation_int: Option = row.get(23)?; let content_hash: Option = row.get(42)?; 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>(12)?.map(|d| d as u64), bitrate: row.get(13)?, sample_rate: row.get(14)?, format, 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>(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)), }) }, ) .optional() .map_err(|e| Error::Database(format!("query failed: {}", e))) } pub fn get_file_by_id(&self, id: FileId) -> Result> { let conn = self.conn.lock().unwrap(); let vpath: Option = 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> { 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 = 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 { 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> { 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))) } pub fn path_exists(&self, path: &VirtualPath) -> Result { let conn = self.conn.lock().unwrap(); let count: i64 = conn .query_row( "SELECT COUNT(*) FROM files WHERE virtual_path = ?1", params![path.as_str()], |row| row.get(0), ) .map_err(|e| Error::Database(format!("path_exists query failed: {}", e)))?; Ok(count > 0) } pub fn update_virtual_path(&self, id: FileId, new_path: &VirtualPath) -> Result<()> { let conn = self.conn.lock().unwrap(); let rows = conn .execute( "UPDATE files SET virtual_path = ?1 WHERE id = ?2", params![new_path.as_str(), id.0], ) .map_err(|e| Error::Database(format!("update_virtual_path failed: {}", e)))?; if rows == 0 { return Err(Error::FileNotFound(format!("file id {} not found", id.0))); } debug!( id = id.0, new_path = new_path.as_str(), "updated virtual path" ); Ok(()) } pub fn rename_directory(&self, old_prefix: &str, new_prefix: &str) -> Result { let conn = self.conn.lock().unwrap(); let pattern = format!("{}%", old_prefix); let old_len = old_prefix.len(); let rows = conn .execute( "UPDATE files SET virtual_path = ?1 || substr(virtual_path, ?2) WHERE virtual_path LIKE ?3", params![new_prefix, old_len as i64 + 1, pattern], ) .map_err(|e| Error::Database(format!("rename_directory failed: {}", e)))?; debug!(old_prefix, new_prefix, rows, "renamed directory paths"); Ok(rows as u64) } pub fn get_files_by_prefix(&self, prefix: &str) -> Result> { let conn = self.conn.lock().unwrap(); let pattern = format!("{}%", prefix); let mut stmt = conn .prepare("SELECT id, virtual_path FROM files WHERE virtual_path LIKE ?1") .map_err(|e| Error::Database(format!("prepare failed: {}", e)))?; let files: Vec<(FileId, VirtualPath)> = stmt .query_map(params![pattern], |row| { Ok(( FileId(row.get(0)?), VirtualPath::new(row.get::<_, String>(1)?), )) }) .map_err(|e| Error::Database(format!("query failed: {}", e)))? .filter_map(|r| r.ok()) .collect(); Ok(files) } pub fn insert_directory(&self, path: &VirtualPath) -> Result<()> { let conn = self.conn.lock().unwrap(); conn.execute( "INSERT OR IGNORE INTO directories (path) VALUES (?1)", params![path.as_str()], ) .map_err(|e| Error::Database(format!("insert_directory failed: {}", e)))?; debug!(path = path.as_str(), "inserted directory"); Ok(()) } pub fn delete_directory(&self, path: &VirtualPath) -> Result<()> { let conn = self.conn.lock().unwrap(); conn.execute( "DELETE FROM directories WHERE path = ?1", params![path.as_str()], ) .map_err(|e| Error::Database(format!("delete_directory failed: {}", e)))?; Ok(()) } pub fn rename_directories(&self, old_prefix: &str, new_prefix: &str) -> Result { let conn = self.conn.lock().unwrap(); let pattern = format!("{}%", old_prefix); let old_len = old_prefix.len(); let rows = conn .execute( "UPDATE directories SET path = ?1 || substr(path, ?2) WHERE path LIKE ?3", params![new_prefix, old_len as i64 + 1, pattern], ) .map_err(|e| Error::Database(format!("rename_directories failed: {}", e)))?; debug!(old_prefix, new_prefix, rows, "renamed directory paths"); Ok(rows as u64) } pub fn list_directories(&self) -> Result> { let conn = self.conn.lock().unwrap(); let mut stmt = conn .prepare("SELECT path FROM directories ORDER BY path") .map_err(|e| Error::Database(format!("prepare failed: {}", e)))?; let dirs: Vec = stmt .query_map([], |row| Ok(VirtualPath::new(row.get::<_, String>(0)?))) .map_err(|e| Error::Database(format!("query failed: {}", e)))? .filter_map(|r| r.ok()) .collect(); Ok(dirs) } pub fn get_file_by_real_path( &self, origin_id: &OriginId, real_path: &Path, ) -> Result> { let conn = self.conn.lock().unwrap(); conn.query_row( "SELECT virtual_path FROM files WHERE origin_id = ?1 AND real_path = ?2", params![&origin_id.0, real_path.to_string_lossy()], |row| Ok(VirtualPath::new(row.get::<_, String>(0)?)), ) .optional() .map_err(|e| Error::Database(format!("query failed: {}", e))) } pub fn get_file_metadata_row(&self, file_id: FileId) -> Result { 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 = row.get(11)?; let format = format_str .as_deref() .map(parse_audio_format) .unwrap_or(AudioFormat::Unknown); let compilation_int: Option = 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>(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> { let conn = self.conn.lock().unwrap(); let blob: Option> = 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>> { let conn = self.conn.lock().unwrap(); let json: Option = 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 = 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> = 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 update_enrichment(&self, file_id: FileId, enrichment: &EnrichmentUpdate) -> Result<()> { let conn = self.conn.lock().unwrap(); let mut set_clauses = vec![ "label = ?1".to_string(), "album_type = ?2".to_string(), "cover_url = ?3".to_string(), "enrichment_source = ?4".to_string(), "enriched_at = strftime('%s', 'now')".to_string(), "enrichment_attempts = 0".to_string(), "last_enrichment_error = NULL".to_string(), ]; let mut params_vec: Vec> = vec![ Box::new(enrichment.label.clone()), Box::new(enrichment.album_type.clone()), Box::new(enrichment.cover_url.clone()), Box::new(enrichment.source.clone()), ]; if let Some(ref genres) = enrichment.genres_json { params_vec.push(Box::new(genres.clone())); set_clauses.push(format!("genres_json = ?{}", params_vec.len())); } if let Some(ref genre) = enrichment.primary_genre { params_vec.push(Box::new(genre.clone())); set_clauses.push(format!("genre = ?{}", params_vec.len())); } params_vec.push(Box::new(file_id.0)); let id_param = params_vec.len(); let sql = format!( "UPDATE files SET {} WHERE id = ?{}", set_clauses.join(", "), id_param ); 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_enrichment failed: {}", e)))?; if rows == 0 { return Err(Error::FileNotFound(format!( "file id {} not found", file_id.0 ))); } debug!( id = file_id.0, source = &enrichment.source, "updated enrichment 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, label = NULL, album_type = NULL, cover_url = NULL, genres_json = NULL, enrichment_source = NULL, enriched_at = NULL, enrichment_attempts = 0, last_enrichment_error = 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 .execute( "UPDATE files SET trashed = 1, original_path = ?1, trashed_at = strftime('%s', 'now') WHERE id = ?2", params![original_path.as_str(), id.0], ) .map_err(|e| Error::Database(format!("mark_trashed failed: {}", e)))?; if rows == 0 { return Err(Error::FileNotFound(format!("file id {} not found", id.0))); } debug!( id = id.0, original_path = original_path.as_str(), "marked file as trashed" ); Ok(()) } pub fn unmark_trashed(&self, id: FileId) -> Result<()> { let conn = self.conn.lock().unwrap(); conn.execute( "UPDATE files SET trashed = 0, original_path = NULL, trashed_at = NULL WHERE id = ?1", params![id.0], ) .map_err(|e| Error::Database(format!("unmark_trashed failed: {}", e)))?; debug!(id = id.0, "unmarked file as trashed"); Ok(()) } pub fn list_trashed(&self, filter: &TrashedFilter) -> Result> { let conn = self.conn.lock().unwrap(); let mut sql = String::from( "SELECT id, virtual_path, original_path, trashed_at, origin_id FROM files WHERE trashed = 1", ); let mut params_vec: Vec> = Vec::new(); if let Some(ref origin) = filter.origin_id { sql.push_str(" AND origin_id = ?"); params_vec.push(Box::new(origin.0.clone())); } if let Some(ref prefix) = filter.path_prefix { sql.push_str(" AND original_path LIKE ?"); params_vec.push(Box::new(format!("{}%", prefix))); } if let Some(since) = filter.since { let cutoff = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_secs() as i64 - since.as_secs() as i64; sql.push_str(" AND trashed_at >= ?"); params_vec.push(Box::new(cutoff)); } sql.push_str(" ORDER BY trashed_at DESC"); let mut stmt = conn .prepare(&sql) .map_err(|e| Error::Database(format!("prepare failed: {}", e)))?; let params_refs: Vec<&dyn rusqlite::ToSql> = params_vec.iter().map(|p| p.as_ref()).collect(); let files: Vec = stmt .query_map(params_refs.as_slice(), |row| { Ok(TrashedFile { file_id: FileId(row.get(0)?), current_path: VirtualPath::new(row.get::<_, String>(1)?), original_path: VirtualPath::new(row.get::<_, String>(2)?), trashed_at: row.get(3)?, origin_id: OriginId(row.get(4)?), }) }) .map_err(|e| Error::Database(format!("query failed: {}", e)))? .filter_map(|r| r.ok()) .collect(); Ok(files) } pub fn get_trashed_by_prefix(&self, prefix: &str) -> Result> { self.list_trashed(&TrashedFilter { path_prefix: Some(prefix.to_string()), ..Default::default() }) } pub fn is_trashed(&self, path: &VirtualPath) -> Result { let conn = self.conn.lock().unwrap(); let count: i64 = conn .query_row( "SELECT COUNT(*) FROM files WHERE virtual_path = ?1 AND trashed = 1", params![path.as_str()], |row| row.get(0), ) .map_err(|e| Error::Database(format!("is_trashed query failed: {}", e)))?; Ok(count > 0) } pub fn purge_trashed(&self, filter: &TrashedFilter) -> Result { let trashed = self.list_trashed(filter)?; let count = trashed.len() as u64; let conn = self.conn.lock().unwrap(); for file in trashed { conn.execute("DELETE FROM files WHERE id = ?1", params![file.file_id.0]) .map_err(|e| Error::Database(format!("purge delete failed: {}", e)))?; } debug!(count, "purged trashed files"); Ok(count) } } #[derive(Debug, Clone)] pub struct TrashedFile { pub file_id: FileId, pub current_path: VirtualPath, pub original_path: VirtualPath, pub trashed_at: i64, pub origin_id: OriginId, } #[derive(Debug, Clone, Default)] pub struct EnrichmentUpdate { pub label: Option, pub album_type: Option, pub cover_url: Option, pub genres_json: Option, pub primary_genre: Option, pub source: String, } #[derive(Debug, Clone, Default)] pub struct TrashedFilter { pub origin_id: Option, pub path_prefix: Option, pub since: Option, } 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 { 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()); } #[test] fn test_path_exists() { let db = Database::open_memory().unwrap(); let path = VirtualPath::new("/Artist/Album/Track.flac"); assert!(!db.path_exists(&path).unwrap()); db.upsert_file( &OriginId::from("local"), Path::new("/test.flac"), &path, &AudioMeta::default(), UNIX_EPOCH, 100, ) .unwrap(); assert!(db.path_exists(&path).unwrap()); assert!(!db .path_exists(&VirtualPath::new("/Other/Path.flac")) .unwrap()); } #[test] fn test_update_virtual_path() { let db = Database::open_memory().unwrap(); let old_path = VirtualPath::new("/Old/Path/Track.flac"); let new_path = VirtualPath::new("/New/Path/Track.flac"); let id = db .upsert_file( &OriginId::from("local"), Path::new("/test.flac"), &old_path, &AudioMeta::default(), UNIX_EPOCH, 100, ) .unwrap(); db.update_virtual_path(id, &new_path).unwrap(); assert!(db.get_file_by_virtual_path(&old_path).unwrap().is_none()); assert!(db.get_file_by_virtual_path(&new_path).unwrap().is_some()); } #[test] fn test_rename_directory() { let db = Database::open_memory().unwrap(); let origin = OriginId::from("local"); db.upsert_file( &origin, Path::new("/a.flac"), &VirtualPath::new("/Artist/Album/Track1.flac"), &AudioMeta::default(), UNIX_EPOCH, 100, ) .unwrap(); db.upsert_file( &origin, Path::new("/b.flac"), &VirtualPath::new("/Artist/Album/Track2.flac"), &AudioMeta::default(), UNIX_EPOCH, 100, ) .unwrap(); db.upsert_file( &origin, Path::new("/c.flac"), &VirtualPath::new("/Other/Track.flac"), &AudioMeta::default(), UNIX_EPOCH, 100, ) .unwrap(); let count = db.rename_directory("/Artist/", "/Renamed Artist/").unwrap(); assert_eq!(count, 2); assert!(db .path_exists(&VirtualPath::new("/Renamed Artist/Album/Track1.flac")) .unwrap()); assert!(db .path_exists(&VirtualPath::new("/Renamed Artist/Album/Track2.flac")) .unwrap()); assert!(db .path_exists(&VirtualPath::new("/Other/Track.flac")) .unwrap()); assert!(!db .path_exists(&VirtualPath::new("/Artist/Album/Track1.flac")) .unwrap()); } #[test] fn test_get_files_by_prefix() { let db = Database::open_memory().unwrap(); let origin = OriginId::from("local"); db.upsert_file( &origin, Path::new("/a.flac"), &VirtualPath::new("/Artist/Album/Track1.flac"), &AudioMeta::default(), UNIX_EPOCH, 100, ) .unwrap(); db.upsert_file( &origin, Path::new("/b.flac"), &VirtualPath::new("/Artist/Album/Track2.flac"), &AudioMeta::default(), UNIX_EPOCH, 100, ) .unwrap(); db.upsert_file( &origin, Path::new("/c.flac"), &VirtualPath::new("/Other/Track.flac"), &AudioMeta::default(), UNIX_EPOCH, 100, ) .unwrap(); let files = db.get_files_by_prefix("/Artist/").unwrap(); assert_eq!(files.len(), 2); let files = db.get_files_by_prefix("/Other/").unwrap(); assert_eq!(files.len(), 1); } #[test] fn test_mark_trashed() { let db = Database::open_memory().unwrap(); let id = db .upsert_file( &OriginId::from("local"), Path::new("/test.flac"), &VirtualPath::new("/Artist/Track.flac"), &AudioMeta::default(), UNIX_EPOCH, 100, ) .unwrap(); db.mark_trashed(id, &VirtualPath::new("/Artist/Track.flac")) .unwrap(); let trashed = db.list_trashed(&TrashedFilter::default()).unwrap(); assert_eq!(trashed.len(), 1); assert_eq!(trashed[0].original_path.as_str(), "/Artist/Track.flac"); } #[test] fn test_unmark_trashed() { let db = Database::open_memory().unwrap(); let id = db .upsert_file( &OriginId::from("local"), Path::new("/test.flac"), &VirtualPath::new("/Artist/Track.flac"), &AudioMeta::default(), UNIX_EPOCH, 100, ) .unwrap(); db.mark_trashed(id, &VirtualPath::new("/Artist/Track.flac")) .unwrap(); assert_eq!(db.list_trashed(&TrashedFilter::default()).unwrap().len(), 1); db.unmark_trashed(id).unwrap(); assert_eq!(db.list_trashed(&TrashedFilter::default()).unwrap().len(), 0); } #[test] fn test_list_trashed_with_filter() { let db = Database::open_memory().unwrap(); let origin1 = OriginId::from("local1"); let origin2 = OriginId::from("local2"); let id1 = db .upsert_file( &origin1, Path::new("/a.flac"), &VirtualPath::new("/Artist1/Track.flac"), &AudioMeta::default(), UNIX_EPOCH, 100, ) .unwrap(); let id2 = db .upsert_file( &origin2, Path::new("/b.flac"), &VirtualPath::new("/Artist2/Track.flac"), &AudioMeta::default(), UNIX_EPOCH, 100, ) .unwrap(); db.mark_trashed(id1, &VirtualPath::new("/Artist1/Track.flac")) .unwrap(); db.mark_trashed(id2, &VirtualPath::new("/Artist2/Track.flac")) .unwrap(); let all = db.list_trashed(&TrashedFilter::default()).unwrap(); assert_eq!(all.len(), 2); let filtered = db .list_trashed(&TrashedFilter { origin_id: Some(origin1.clone()), ..Default::default() }) .unwrap(); assert_eq!(filtered.len(), 1); assert_eq!(filtered[0].origin_id, origin1); let by_path = db.get_trashed_by_prefix("/Artist1").unwrap(); assert_eq!(by_path.len(), 1); } #[test] fn test_purge_trashed() { 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, 100, ) .unwrap(); db.mark_trashed(id, &VirtualPath::new("/Track.flac")) .unwrap(); assert_eq!(db.list_trashed(&TrashedFilter::default()).unwrap().len(), 1); let count = db.purge_trashed(&TrashedFilter::default()).unwrap(); assert_eq!(count, 1); 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()); } }