pinakes-core: improve media management features; various configuration improvements

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I2d1f04f13970d21c36067f30bc04a9176a6a6964
This commit is contained in:
raf 2026-02-05 00:54:10 +03:00
commit e02c15490e
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
31 changed files with 1087 additions and 197 deletions

View file

@ -113,6 +113,24 @@ fn row_to_media_item(row: &Row) -> rusqlite::Result<MediaItem> {
custom_fields: HashMap::new(), // loaded separately
// file_mtime may not be present in all queries, so handle gracefully
file_mtime: row.get::<_, Option<i64>>("file_mtime").unwrap_or(None),
// Photo-specific fields (may not be present in all queries)
date_taken: row
.get::<_, Option<String>>("date_taken")
.ok()
.flatten()
.and_then(|s| DateTime::parse_from_rfc3339(&s).ok())
.map(|dt| dt.with_timezone(&Utc)),
latitude: row.get::<_, Option<f64>>("latitude").ok().flatten(),
longitude: row.get::<_, Option<f64>>("longitude").ok().flatten(),
camera_make: row.get::<_, Option<String>>("camera_make").ok().flatten(),
camera_model: row.get::<_, Option<String>>("camera_model").ok().flatten(),
rating: row.get::<_, Option<i32>>("rating").ok().flatten(),
perceptual_hash: row
.get::<_, Option<String>>("perceptual_hash")
.ok()
.flatten(),
created_at: parse_datetime(&created_str),
updated_at: parse_datetime(&updated_str),
})
@ -610,8 +628,9 @@ impl StorageBackend for SqliteBackend {
db.execute(
"INSERT INTO media_items (id, path, file_name, media_type, content_hash, \
file_size, title, artist, album, genre, year, duration_secs, description, \
thumbnail_path, file_mtime, created_at, updated_at) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)",
thumbnail_path, file_mtime, date_taken, latitude, longitude, camera_make, \
camera_model, rating, perceptual_hash, created_at, updated_at) \
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)",
params![
item.id.0.to_string(),
item.path.to_string_lossy().as_ref(),
@ -630,6 +649,13 @@ impl StorageBackend for SqliteBackend {
.as_ref()
.map(|p| p.to_string_lossy().to_string()),
item.file_mtime,
item.date_taken.as_ref().map(|d| d.to_rfc3339()),
item.latitude,
item.longitude,
item.camera_make,
item.camera_model,
item.rating,
item.perceptual_hash,
item.created_at.to_rfc3339(),
item.updated_at.to_rfc3339(),
],
@ -781,7 +807,9 @@ impl StorageBackend for SqliteBackend {
"UPDATE media_items SET path = ?2, file_name = ?3, media_type = ?4, \
content_hash = ?5, file_size = ?6, title = ?7, artist = ?8, album = ?9, \
genre = ?10, year = ?11, duration_secs = ?12, description = ?13, \
thumbnail_path = ?14, file_mtime = ?15, updated_at = ?16 WHERE id = ?1",
thumbnail_path = ?14, file_mtime = ?15, date_taken = ?16, latitude = ?17, \
longitude = ?18, camera_make = ?19, camera_model = ?20, rating = ?21, \
perceptual_hash = ?22, updated_at = ?23 WHERE id = ?1",
params![
item.id.0.to_string(),
item.path.to_string_lossy().as_ref(),
@ -800,6 +828,13 @@ impl StorageBackend for SqliteBackend {
.as_ref()
.map(|p| p.to_string_lossy().to_string()),
item.file_mtime,
item.date_taken.as_ref().map(|d| d.to_rfc3339()),
item.latitude,
item.longitude,
item.camera_make,
item.camera_model,
item.rating,
item.perceptual_hash,
item.updated_at.to_rfc3339(),
],
)?;
@ -1534,6 +1569,77 @@ impl StorageBackend for SqliteBackend {
.map_err(|e| PinakesError::Database(e.to_string()))?
}
async fn find_perceptual_duplicates(&self, threshold: u32) -> Result<Vec<Vec<MediaItem>>> {
let conn = Arc::clone(&self.conn);
tokio::task::spawn_blocking(move || {
let db = conn
.lock()
.map_err(|e| PinakesError::Database(e.to_string()))?;
// Get all images with perceptual hashes
let mut stmt = db.prepare(
"SELECT * FROM media_items WHERE perceptual_hash IS NOT NULL ORDER BY id",
)?;
let mut items: Vec<MediaItem> = stmt
.query_map([], row_to_media_item)?
.collect::<rusqlite::Result<Vec<_>>>()?;
load_custom_fields_batch(&db, &mut items)?;
// Compare each pair and build groups
use image_hasher::ImageHash;
let mut groups: Vec<Vec<MediaItem>> = Vec::new();
let mut grouped_indices: std::collections::HashSet<usize> =
std::collections::HashSet::new();
for i in 0..items.len() {
if grouped_indices.contains(&i) {
continue;
}
let hash_a = match &items[i].perceptual_hash {
Some(h) => match ImageHash::<Vec<u8>>::from_base64(h) {
Ok(hash) => hash,
Err(_) => continue,
},
None => continue,
};
let mut group = vec![items[i].clone()];
grouped_indices.insert(i);
for (j, item_j) in items.iter().enumerate().skip(i + 1) {
if grouped_indices.contains(&j) {
continue;
}
let hash_b = match &item_j.perceptual_hash {
Some(h) => match ImageHash::<Vec<u8>>::from_base64(h) {
Ok(hash) => hash,
Err(_) => continue,
},
None => continue,
};
let distance = hash_a.dist(&hash_b);
if distance <= threshold {
group.push(item_j.clone());
grouped_indices.insert(j);
}
}
// Only add groups with more than one item (actual duplicates)
if group.len() > 1 {
groups.push(group);
}
}
Ok(groups)
})
.await
.map_err(|e| PinakesError::Database(e.to_string()))?
}
// -- Database management -----------------------------------------------
async fn database_stats(&self) -> Result<crate::storage::DatabaseStats> {