1793 lines
62 KiB
Rust
1793 lines
62 KiB
Rust
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<Mutex<Connection>>,
|
|
}
|
|
|
|
impl Database {
|
|
pub fn open(path: &Path) -> Result<Self> {
|
|
debug!(?path, "Opening database");
|
|
|
|
let conn =
|
|
Connection::open(path).map_err(|e| Error::Database(format!("open failed: {}", e)))?;
|
|
|
|
conn.execute_batch(SCHEMA)
|
|
.map_err(|e| Error::Database(format!("schema init failed: {}", e)))?;
|
|
|
|
let db = Self {
|
|
conn: Arc::new(Mutex::new(conn)),
|
|
};
|
|
let count = db.file_count().unwrap_or(0);
|
|
info!(path = ?path, file_count = count, "Database opened");
|
|
Ok(db)
|
|
}
|
|
|
|
pub fn open_with_integrity_check(path: &Path) -> Result<Self> {
|
|
debug!(?path, "Opening database with integrity check");
|
|
|
|
let conn =
|
|
Connection::open(path).map_err(|e| Error::Database(format!("open failed: {}", e)))?;
|
|
|
|
let integrity: String = conn
|
|
.query_row("PRAGMA integrity_check(1)", [], |row| row.get(0))
|
|
.map_err(|e| Error::Database(format!("integrity check failed: {}", e)))?;
|
|
|
|
if integrity != "ok" {
|
|
warn!(path = ?path, result = %integrity, "Database integrity check failed");
|
|
return Err(Error::DatabaseCorrupted(format!(
|
|
"integrity check failed: {}",
|
|
integrity
|
|
)));
|
|
}
|
|
|
|
conn.execute_batch(SCHEMA)
|
|
.map_err(|e| Error::Database(format!("schema init failed: {}", e)))?;
|
|
|
|
let db = Self {
|
|
conn: Arc::new(Mutex::new(conn)),
|
|
};
|
|
let count = db.file_count().unwrap_or(0);
|
|
info!(path = ?path, file_count = count, "Database opened (integrity verified)");
|
|
Ok(db)
|
|
}
|
|
|
|
pub fn open_memory() -> Result<Self> {
|
|
let conn = Connection::open_in_memory()
|
|
.map_err(|e| Error::Database(format!("open_in_memory failed: {}", e)))?;
|
|
|
|
conn.execute_batch(SCHEMA)
|
|
.map_err(|e| Error::Database(format!("schema init failed: {}", e)))?;
|
|
|
|
Ok(Self {
|
|
conn: Arc::new(Mutex::new(conn)),
|
|
})
|
|
}
|
|
|
|
pub fn upsert_file(
|
|
&self,
|
|
origin_id: &OriginId,
|
|
real_path: &Path,
|
|
virtual_path: &VirtualPath,
|
|
audio_meta: &AudioMeta,
|
|
origin_mtime: SystemTime,
|
|
origin_size: u64,
|
|
) -> Result<FileId> {
|
|
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();
|
|
|
|
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<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 (
|
|
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<Option<FileMeta>> {
|
|
let conn = self.conn.lock().unwrap();
|
|
|
|
conn.query_row(
|
|
r#"
|
|
SELECT id, origin_id, real_path, virtual_path,
|
|
title, artist, album, album_artist, genre,
|
|
year, track, disc,
|
|
duration_ms, bitrate, sample_rate, format,
|
|
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<String> = row.get(15)?;
|
|
let format = format_str
|
|
.as_deref()
|
|
.map(parse_audio_format)
|
|
.unwrap_or(AudioFormat::Unknown);
|
|
|
|
let compilation_int: Option<i32> = row.get(23)?;
|
|
let content_hash: Option<String> = 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<i64>>(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<Option<FileMeta>> {
|
|
let conn = self.conn.lock().unwrap();
|
|
|
|
let vpath: Option<String> = conn
|
|
.query_row(
|
|
"SELECT virtual_path FROM files WHERE id = ?1",
|
|
params![id.0],
|
|
|row| row.get(0),
|
|
)
|
|
.optional()
|
|
.map_err(|e| Error::Database(format!("query failed: {}", e)))?;
|
|
|
|
drop(conn);
|
|
|
|
match vpath {
|
|
Some(p) => self.get_file_by_virtual_path(&VirtualPath::new(p)),
|
|
None => Ok(None),
|
|
}
|
|
}
|
|
|
|
pub fn list_files_by_origin(&self, origin_id: &OriginId) -> Result<Vec<VirtualPath>> {
|
|
let conn = self.conn.lock().unwrap();
|
|
|
|
let mut stmt = conn
|
|
.prepare("SELECT virtual_path FROM files WHERE origin_id = ?1")
|
|
.map_err(|e| Error::Database(format!("prepare failed: {}", e)))?;
|
|
|
|
let paths: Vec<VirtualPath> = stmt
|
|
.query_map(params![&origin_id.0], |row| {
|
|
Ok(VirtualPath::new(row.get::<_, String>(0)?))
|
|
})
|
|
.map_err(|e| Error::Database(format!("query failed: {}", e)))?
|
|
.filter_map(|r| r.ok())
|
|
.collect();
|
|
|
|
Ok(paths)
|
|
}
|
|
|
|
pub fn delete_file(&self, id: FileId) -> Result<()> {
|
|
let conn = self.conn.lock().unwrap();
|
|
conn.execute("DELETE FROM files WHERE id = ?1", params![id.0])
|
|
.map_err(|e| Error::Database(format!("delete failed: {}", e)))?;
|
|
Ok(())
|
|
}
|
|
|
|
pub fn file_count(&self) -> Result<u64> {
|
|
let conn = self.conn.lock().unwrap();
|
|
conn.query_row("SELECT COUNT(*) FROM files", [], |row| row.get::<_, i64>(0))
|
|
.map(|c| c as u64)
|
|
.map_err(|e| Error::Database(format!("count failed: {}", e)))
|
|
}
|
|
|
|
pub fn update_content_hash(&self, id: FileId, hash: &ContentHash) -> Result<()> {
|
|
let conn = self.conn.lock().unwrap();
|
|
conn.execute(
|
|
"UPDATE files SET content_hash = ?1 WHERE id = ?2",
|
|
params![hash.to_hex(), id.0],
|
|
)
|
|
.map_err(|e| Error::Database(format!("update hash failed: {}", e)))?;
|
|
Ok(())
|
|
}
|
|
|
|
pub fn get_mtime_by_real_path(
|
|
&self,
|
|
origin_id: &OriginId,
|
|
real_path: &Path,
|
|
) -> Result<Option<SystemTime>> {
|
|
let conn = self.conn.lock().unwrap();
|
|
|
|
conn.query_row(
|
|
"SELECT origin_mtime FROM files WHERE origin_id = ?1 AND real_path = ?2",
|
|
params![&origin_id.0, real_path.to_string_lossy()],
|
|
|row| {
|
|
let mtime_secs: i64 = row.get(0)?;
|
|
Ok(UNIX_EPOCH + Duration::from_secs(mtime_secs as u64))
|
|
},
|
|
)
|
|
.optional()
|
|
.map_err(|e| Error::Database(format!("query mtime failed: {}", e)))
|
|
}
|
|
|
|
pub fn path_exists(&self, path: &VirtualPath) -> Result<bool> {
|
|
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<u64> {
|
|
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<Vec<(FileId, VirtualPath)>> {
|
|
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<u64> {
|
|
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<Vec<VirtualPath>> {
|
|
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<VirtualPath> = 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<Option<VirtualPath>> {
|
|
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<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 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<Box<dyn rusqlite::ToSql>> = 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<Vec<TrashedFile>> {
|
|
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<Box<dyn rusqlite::ToSql>> = 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<TrashedFile> = 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<Vec<TrashedFile>> {
|
|
self.list_trashed(&TrashedFilter {
|
|
path_prefix: Some(prefix.to_string()),
|
|
..Default::default()
|
|
})
|
|
}
|
|
|
|
pub fn is_trashed(&self, path: &VirtualPath) -> Result<bool> {
|
|
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<u64> {
|
|
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<String>,
|
|
pub album_type: Option<String>,
|
|
pub cover_url: Option<String>,
|
|
pub genres_json: Option<String>,
|
|
pub primary_genre: Option<String>,
|
|
pub source: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct TrashedFilter {
|
|
pub origin_id: Option<OriginId>,
|
|
pub path_prefix: Option<String>,
|
|
pub since: Option<Duration>,
|
|
}
|
|
|
|
fn parse_audio_format(s: &str) -> AudioFormat {
|
|
match s {
|
|
"Flac" => AudioFormat::Flac,
|
|
"Mp3" => AudioFormat::Mp3,
|
|
"Aac" => AudioFormat::Aac,
|
|
"Opus" => AudioFormat::Opus,
|
|
"Vorbis" => AudioFormat::Vorbis,
|
|
"Wav" => AudioFormat::Wav,
|
|
"Alac" => AudioFormat::Alac,
|
|
_ => AudioFormat::Unknown,
|
|
}
|
|
}
|
|
|
|
fn parse_content_hash(hex: &str) -> Option<ContentHash> {
|
|
if hex.len() != 16 {
|
|
return None;
|
|
}
|
|
let mut bytes = [0u8; 8];
|
|
for (i, chunk) in hex.as_bytes().chunks(2).enumerate() {
|
|
if i >= 8 {
|
|
break;
|
|
}
|
|
let s = std::str::from_utf8(chunk).ok()?;
|
|
bytes[i] = u8::from_str_radix(s, 16).ok()?;
|
|
}
|
|
Some(ContentHash(bytes))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_database_creation() {
|
|
let db = Database::open_memory().unwrap();
|
|
assert_eq!(db.file_count().unwrap(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_upsert_and_retrieve() {
|
|
let db = Database::open_memory().unwrap();
|
|
|
|
let origin_id = OriginId::from("local");
|
|
let real_path = Path::new("/music/test.flac");
|
|
let virtual_path = VirtualPath::new("/Artist/Album/01 - Track.flac");
|
|
let audio_meta = AudioMeta {
|
|
title: Some("Track".to_string()),
|
|
artist: Some("Artist".to_string()),
|
|
album: Some("Album".to_string()),
|
|
track: Some(1),
|
|
format: AudioFormat::Flac,
|
|
..Default::default()
|
|
};
|
|
|
|
let id = db
|
|
.upsert_file(
|
|
&origin_id,
|
|
real_path,
|
|
&virtual_path,
|
|
&audio_meta,
|
|
UNIX_EPOCH,
|
|
1000,
|
|
)
|
|
.unwrap();
|
|
|
|
let retrieved = db.get_file_by_virtual_path(&virtual_path).unwrap().unwrap();
|
|
assert_eq!(retrieved.id, id);
|
|
assert_eq!(
|
|
retrieved.audio.as_ref().unwrap().title,
|
|
Some("Track".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_upsert_updates_existing() {
|
|
let db = Database::open_memory().unwrap();
|
|
|
|
let origin_id = OriginId::from("local");
|
|
let real_path = Path::new("/music/test.flac");
|
|
let virtual_path = VirtualPath::new("/Artist/Album/01 - Track.flac");
|
|
|
|
let meta1 = AudioMeta {
|
|
title: Some("Original".to_string()),
|
|
..Default::default()
|
|
};
|
|
db.upsert_file(
|
|
&origin_id,
|
|
real_path,
|
|
&virtual_path,
|
|
&meta1,
|
|
UNIX_EPOCH,
|
|
1000,
|
|
)
|
|
.unwrap();
|
|
|
|
let meta2 = AudioMeta {
|
|
title: Some("Updated".to_string()),
|
|
..Default::default()
|
|
};
|
|
db.upsert_file(
|
|
&origin_id,
|
|
real_path,
|
|
&virtual_path,
|
|
&meta2,
|
|
UNIX_EPOCH,
|
|
1000,
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(db.file_count().unwrap(), 1);
|
|
|
|
let retrieved = db.get_file_by_virtual_path(&virtual_path).unwrap().unwrap();
|
|
assert_eq!(
|
|
retrieved.audio.as_ref().unwrap().title,
|
|
Some("Updated".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_metadata_persistence() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let db_path = dir.path().join("test.db");
|
|
|
|
{
|
|
let db = Database::open(&db_path).unwrap();
|
|
db.upsert_file(
|
|
&OriginId::from("local"),
|
|
Path::new("/test.flac"),
|
|
&VirtualPath::new("/Test.flac"),
|
|
&AudioMeta::default(),
|
|
UNIX_EPOCH,
|
|
100,
|
|
)
|
|
.unwrap();
|
|
}
|
|
|
|
{
|
|
let db = Database::open(&db_path).unwrap();
|
|
assert_eq!(db.file_count().unwrap(), 1);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_delete_file() {
|
|
let db = Database::open_memory().unwrap();
|
|
|
|
let id = db
|
|
.upsert_file(
|
|
&OriginId::from("local"),
|
|
Path::new("/test.flac"),
|
|
&VirtualPath::new("/Test.flac"),
|
|
&AudioMeta::default(),
|
|
UNIX_EPOCH,
|
|
100,
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(db.file_count().unwrap(), 1);
|
|
db.delete_file(id).unwrap();
|
|
assert_eq!(db.file_count().unwrap(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_list_files_by_origin() {
|
|
let db = Database::open_memory().unwrap();
|
|
let origin = OriginId::from("local");
|
|
|
|
db.upsert_file(
|
|
&origin,
|
|
Path::new("/a.flac"),
|
|
&VirtualPath::new("/A.flac"),
|
|
&AudioMeta::default(),
|
|
UNIX_EPOCH,
|
|
100,
|
|
)
|
|
.unwrap();
|
|
|
|
db.upsert_file(
|
|
&origin,
|
|
Path::new("/b.flac"),
|
|
&VirtualPath::new("/B.flac"),
|
|
&AudioMeta::default(),
|
|
UNIX_EPOCH,
|
|
100,
|
|
)
|
|
.unwrap();
|
|
|
|
let paths = db.list_files_by_origin(&origin).unwrap();
|
|
assert_eq!(paths.len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn test_content_hash_update() {
|
|
let db = Database::open_memory().unwrap();
|
|
|
|
let id = db
|
|
.upsert_file(
|
|
&OriginId::from("local"),
|
|
Path::new("/test.flac"),
|
|
&VirtualPath::new("/Test.flac"),
|
|
&AudioMeta::default(),
|
|
UNIX_EPOCH,
|
|
100,
|
|
)
|
|
.unwrap();
|
|
|
|
let hash = ContentHash::from_bytes(b"test data");
|
|
db.update_content_hash(id, &hash).unwrap();
|
|
|
|
let retrieved = db
|
|
.get_file_by_virtual_path(&VirtualPath::new("/Test.flac"))
|
|
.unwrap()
|
|
.unwrap();
|
|
assert!(retrieved.content_hash.is_some());
|
|
}
|
|
|
|
#[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());
|
|
}
|
|
}
|