Improve media indexing and processing flow
This commit is contained in:
@@ -37,7 +37,6 @@ pub async fn add_folder(
|
||||
app: AppHandle,
|
||||
db: State<'_, DbState>,
|
||||
path: String,
|
||||
cache_dir: String,
|
||||
) -> Result<Folder, String> {
|
||||
let folder_path = PathBuf::from(&path);
|
||||
|
||||
@@ -65,8 +64,7 @@ pub async fn add_folder(
|
||||
.find(|f| f.id == folder_id)
|
||||
.ok_or("Folder not found after insert")?;
|
||||
|
||||
let cache_path = PathBuf::from(cache_dir);
|
||||
indexer::index_folder(app, db.inner().clone(), folder_id, folder_path, cache_path);
|
||||
indexer::index_folder(app, db.inner().clone(), folder_id, folder_path);
|
||||
|
||||
Ok(folder)
|
||||
}
|
||||
@@ -135,7 +133,6 @@ pub async fn reindex_folder(
|
||||
app: AppHandle,
|
||||
db: State<'_, DbState>,
|
||||
folder_id: i64,
|
||||
cache_dir: String,
|
||||
) -> Result<(), String> {
|
||||
let folder_path = {
|
||||
let conn = db.get().map_err(|e| e.to_string())?;
|
||||
@@ -147,7 +144,6 @@ pub async fn reindex_folder(
|
||||
.ok_or("Folder not found")?
|
||||
};
|
||||
|
||||
let cache_path = PathBuf::from(cache_dir);
|
||||
indexer::index_folder(app, db.inner().clone(), folder_id, folder_path, cache_path);
|
||||
indexer::index_folder(app, db.inner().clone(), folder_id, folder_path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+236
-26
@@ -46,6 +46,11 @@ pub struct ImageRecord {
|
||||
pub modified_at: Option<String>,
|
||||
pub mime_type: String,
|
||||
pub media_kind: String,
|
||||
pub duration_ms: Option<i64>,
|
||||
pub video_codec: Option<String>,
|
||||
pub audio_codec: Option<String>,
|
||||
pub metadata_updated_at: Option<String>,
|
||||
pub metadata_error: Option<String>,
|
||||
pub favorite: bool,
|
||||
pub rating: i64,
|
||||
pub embedding_status: String,
|
||||
@@ -68,10 +73,34 @@ pub struct EmbeddingJob {
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ThumbnailJob {
|
||||
pub image_id: i64,
|
||||
pub folder_id: i64,
|
||||
pub path: String,
|
||||
pub media_kind: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MetadataJob {
|
||||
pub image_id: i64,
|
||||
pub folder_id: i64,
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IndexedMediaEntry {
|
||||
pub id: i64,
|
||||
pub path: String,
|
||||
pub modified_at: Option<String>,
|
||||
pub file_size: i64,
|
||||
pub media_kind: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FolderJobProgress {
|
||||
pub folder_id: i64,
|
||||
pub thumbnail_pending: i64,
|
||||
pub metadata_pending: i64,
|
||||
}
|
||||
|
||||
pub fn create_pool(db_path: &Path) -> Result<DbPool> {
|
||||
vector::register_sqlite_vec();
|
||||
let manager = SqliteConnectionManager::file(db_path);
|
||||
@@ -127,10 +156,20 @@ pub fn migrate(conn: &Connection) -> Result<()> {
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS metadata_jobs (
|
||||
image_id INTEGER PRIMARY KEY REFERENCES images(id) ON DELETE CASCADE,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
last_error TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_images_folder_id ON images(folder_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_images_modified_at ON images(modified_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_embedding_jobs_status ON embedding_jobs(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_thumbnail_jobs_status ON thumbnail_jobs(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_metadata_jobs_status ON metadata_jobs(status);
|
||||
",
|
||||
)?;
|
||||
|
||||
@@ -151,6 +190,11 @@ pub fn migrate(conn: &Connection) -> Result<()> {
|
||||
)?;
|
||||
ensure_column(conn, "images", "favorite", "INTEGER NOT NULL DEFAULT 0")?;
|
||||
ensure_column(conn, "images", "rating", "INTEGER NOT NULL DEFAULT 0")?;
|
||||
ensure_column(conn, "images", "duration_ms", "INTEGER")?;
|
||||
ensure_column(conn, "images", "video_codec", "TEXT")?;
|
||||
ensure_column(conn, "images", "audio_codec", "TEXT")?;
|
||||
ensure_column(conn, "images", "metadata_updated_at", "TEXT")?;
|
||||
ensure_column(conn, "images", "metadata_error", "TEXT")?;
|
||||
|
||||
vector::migrate(conn)?;
|
||||
Ok(())
|
||||
@@ -171,8 +215,8 @@ pub fn insert_folder(conn: &Connection, path: &str, name: &str) -> Result<i64> {
|
||||
|
||||
pub fn upsert_image(conn: &Connection, img: &ImageRecord) -> Result<i64> {
|
||||
let id = conn.query_row(
|
||||
"INSERT INTO images (folder_id, path, filename, thumbnail_path, width, height, file_size, created_at, modified_at, mime_type, media_kind, favorite, rating, embedding_status, embedding_model, embedding_updated_at, embedding_error)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)
|
||||
"INSERT INTO images (folder_id, path, filename, thumbnail_path, width, height, file_size, created_at, modified_at, mime_type, media_kind, duration_ms, video_codec, audio_codec, metadata_updated_at, metadata_error, favorite, rating, embedding_status, embedding_model, embedding_updated_at, embedding_error)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22)
|
||||
ON CONFLICT(path) DO UPDATE SET
|
||||
folder_id = excluded.folder_id,
|
||||
filename = excluded.filename,
|
||||
@@ -184,6 +228,11 @@ pub fn upsert_image(conn: &Connection, img: &ImageRecord) -> Result<i64> {
|
||||
modified_at = excluded.modified_at,
|
||||
mime_type = excluded.mime_type,
|
||||
media_kind = excluded.media_kind,
|
||||
duration_ms = excluded.duration_ms,
|
||||
video_codec = excluded.video_codec,
|
||||
audio_codec = excluded.audio_codec,
|
||||
metadata_updated_at = excluded.metadata_updated_at,
|
||||
metadata_error = excluded.metadata_error,
|
||||
embedding_status = excluded.embedding_status,
|
||||
embedding_model = excluded.embedding_model,
|
||||
embedding_updated_at = excluded.embedding_updated_at,
|
||||
@@ -201,6 +250,11 @@ pub fn upsert_image(conn: &Connection, img: &ImageRecord) -> Result<i64> {
|
||||
img.modified_at,
|
||||
img.mime_type,
|
||||
img.media_kind,
|
||||
img.duration_ms,
|
||||
img.video_codec,
|
||||
img.audio_codec,
|
||||
img.metadata_updated_at,
|
||||
img.metadata_error,
|
||||
img.favorite,
|
||||
img.rating,
|
||||
img.embedding_status,
|
||||
@@ -239,6 +293,19 @@ pub fn enqueue_thumbnail_job(conn: &Connection, image_id: i64) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn enqueue_metadata_job(conn: &Connection, image_id: i64) -> Result<()> {
|
||||
conn.execute(
|
||||
"INSERT INTO metadata_jobs (image_id, status, attempts, last_error, created_at, updated_at)
|
||||
VALUES (?1, 'pending', 0, NULL, datetime('now'), datetime('now'))
|
||||
ON CONFLICT(image_id) DO UPDATE SET
|
||||
status = 'pending',
|
||||
last_error = NULL,
|
||||
updated_at = datetime('now')",
|
||||
[image_id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn get_next_embedding_job(conn: &Connection) -> Result<Option<EmbeddingJob>> {
|
||||
let mut stmt = conn.prepare(
|
||||
@@ -312,26 +379,97 @@ pub fn update_folder_count(conn: &Connection, folder_id: i64) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_next_thumbnail_job(conn: &Connection) -> Result<Option<ThumbnailJob>> {
|
||||
pub fn get_folder_media_index(conn: &Connection, folder_id: i64) -> Result<Vec<IndexedMediaEntry>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT j.image_id, i.path, i.media_kind
|
||||
"SELECT id, path, modified_at, file_size, media_kind
|
||||
FROM images
|
||||
WHERE folder_id = ?1",
|
||||
)?;
|
||||
let rows = stmt.query_map([folder_id], |row| {
|
||||
Ok(IndexedMediaEntry {
|
||||
id: row.get(0)?,
|
||||
path: row.get(1)?,
|
||||
modified_at: row.get(2)?,
|
||||
file_size: row.get(3)?,
|
||||
media_kind: row.get(4)?,
|
||||
})
|
||||
})?;
|
||||
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
|
||||
}
|
||||
|
||||
pub fn delete_images_by_ids(conn: &Connection, image_ids: &[i64]) -> Result<()> {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
for image_id in image_ids {
|
||||
vector::delete_embedding(&tx, *image_id)?;
|
||||
tx.execute("DELETE FROM images WHERE id = ?1", [image_id])?;
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_folder_job_progress(conn: &Connection, folder_id: i64) -> Result<FolderJobProgress> {
|
||||
let thumbnail_pending = conn.query_row(
|
||||
"SELECT COUNT(*)
|
||||
FROM thumbnail_jobs j
|
||||
JOIN images i ON i.id = j.image_id
|
||||
WHERE i.folder_id = ?1 AND j.status IN ('pending', 'processing')",
|
||||
[folder_id],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
|
||||
let metadata_pending = conn.query_row(
|
||||
"SELECT COUNT(*)
|
||||
FROM metadata_jobs j
|
||||
JOIN images i ON i.id = j.image_id
|
||||
WHERE i.folder_id = ?1 AND j.status IN ('pending', 'processing')",
|
||||
[folder_id],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
|
||||
Ok(FolderJobProgress {
|
||||
folder_id,
|
||||
thumbnail_pending,
|
||||
metadata_pending,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_pending_thumbnail_jobs(conn: &Connection, limit: usize) -> Result<Vec<ThumbnailJob>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT j.image_id, i.folder_id, i.path, i.media_kind
|
||||
FROM thumbnail_jobs j
|
||||
JOIN images i ON i.id = j.image_id
|
||||
WHERE j.status = 'pending'
|
||||
ORDER BY j.updated_at, j.image_id
|
||||
LIMIT 1",
|
||||
LIMIT ?1",
|
||||
)?;
|
||||
let rows = stmt.query_map([limit as i64], |row| {
|
||||
Ok(ThumbnailJob {
|
||||
image_id: row.get(0)?,
|
||||
folder_id: row.get(1)?,
|
||||
path: row.get(2)?,
|
||||
media_kind: row.get(3)?,
|
||||
})
|
||||
})?;
|
||||
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
|
||||
}
|
||||
|
||||
let mut rows = stmt.query([])?;
|
||||
let Some(row) = rows.next()? else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
Ok(Some(ThumbnailJob {
|
||||
image_id: row.get(0)?,
|
||||
path: row.get(1)?,
|
||||
media_kind: row.get(2)?,
|
||||
}))
|
||||
pub fn get_pending_metadata_jobs(conn: &Connection, limit: usize) -> Result<Vec<MetadataJob>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT j.image_id, i.folder_id, i.path
|
||||
FROM metadata_jobs j
|
||||
JOIN images i ON i.id = j.image_id
|
||||
WHERE j.status = 'pending' AND i.media_kind = 'video'
|
||||
ORDER BY j.updated_at, j.image_id
|
||||
LIMIT ?1",
|
||||
)?;
|
||||
let rows = stmt.query_map([limit as i64], |row| {
|
||||
Ok(MetadataJob {
|
||||
image_id: row.get(0)?,
|
||||
folder_id: row.get(1)?,
|
||||
path: row.get(2)?,
|
||||
})
|
||||
})?;
|
||||
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
|
||||
}
|
||||
|
||||
pub fn mark_thumbnail_job_processing(conn: &Connection, image_id: i64) -> Result<()> {
|
||||
@@ -348,10 +486,16 @@ pub fn mark_thumbnail_ready(
|
||||
conn: &Connection,
|
||||
image_id: i64,
|
||||
thumbnail_path: Option<&str>,
|
||||
width: Option<i64>,
|
||||
height: Option<i64>,
|
||||
) -> Result<ImageRecord> {
|
||||
conn.execute(
|
||||
"UPDATE images SET thumbnail_path = ?2 WHERE id = ?1",
|
||||
params![image_id, thumbnail_path],
|
||||
"UPDATE images
|
||||
SET thumbnail_path = ?2,
|
||||
width = COALESCE(?3, width),
|
||||
height = COALESCE(?4, height)
|
||||
WHERE id = ?1",
|
||||
params![image_id, thumbnail_path, width, height],
|
||||
)?;
|
||||
conn.execute("DELETE FROM thumbnail_jobs WHERE image_id = ?1", [image_id])?;
|
||||
get_image_by_id(conn, image_id)
|
||||
@@ -367,6 +511,64 @@ pub fn mark_thumbnail_failed(conn: &Connection, image_id: i64, error: &str) -> R
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn mark_metadata_job_processing(conn: &Connection, image_id: i64) -> Result<()> {
|
||||
conn.execute(
|
||||
"UPDATE metadata_jobs
|
||||
SET status = 'processing', attempts = attempts + 1, updated_at = datetime('now')
|
||||
WHERE image_id = ?1",
|
||||
[image_id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn mark_metadata_ready(
|
||||
conn: &Connection,
|
||||
image_id: i64,
|
||||
duration_ms: Option<i64>,
|
||||
width: Option<i64>,
|
||||
height: Option<i64>,
|
||||
video_codec: Option<&str>,
|
||||
audio_codec: Option<&str>,
|
||||
) -> Result<ImageRecord> {
|
||||
conn.execute(
|
||||
"UPDATE images
|
||||
SET duration_ms = ?2,
|
||||
width = COALESCE(?3, width),
|
||||
height = COALESCE(?4, height),
|
||||
video_codec = ?5,
|
||||
audio_codec = ?6,
|
||||
metadata_updated_at = datetime('now'),
|
||||
metadata_error = NULL
|
||||
WHERE id = ?1",
|
||||
params![
|
||||
image_id,
|
||||
duration_ms,
|
||||
width,
|
||||
height,
|
||||
video_codec,
|
||||
audio_codec
|
||||
],
|
||||
)?;
|
||||
conn.execute("DELETE FROM metadata_jobs WHERE image_id = ?1", [image_id])?;
|
||||
get_image_by_id(conn, image_id)
|
||||
}
|
||||
|
||||
pub fn mark_metadata_failed(conn: &Connection, image_id: i64, error: &str) -> Result<()> {
|
||||
conn.execute(
|
||||
"UPDATE images
|
||||
SET metadata_error = ?2
|
||||
WHERE id = ?1",
|
||||
params![image_id, error],
|
||||
)?;
|
||||
conn.execute(
|
||||
"UPDATE metadata_jobs
|
||||
SET status = 'failed', last_error = ?2, updated_at = datetime('now')
|
||||
WHERE image_id = ?1",
|
||||
params![image_id, error],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn update_image_details(
|
||||
conn: &Connection,
|
||||
image_id: i64,
|
||||
@@ -383,7 +585,8 @@ pub fn update_image_details(
|
||||
|
||||
conn.query_row(
|
||||
"SELECT id, folder_id, path, filename, thumbnail_path, width, height, file_size, created_at, modified_at, mime_type,
|
||||
media_kind, favorite, rating, embedding_status, embedding_model, embedding_updated_at, embedding_error
|
||||
media_kind, duration_ms, video_codec, audio_codec, metadata_updated_at, metadata_error,
|
||||
favorite, rating, embedding_status, embedding_model, embedding_updated_at, embedding_error
|
||||
FROM images
|
||||
WHERE id = ?1",
|
||||
[image_id],
|
||||
@@ -395,7 +598,8 @@ pub fn update_image_details(
|
||||
pub fn get_image_by_id(conn: &Connection, image_id: i64) -> Result<ImageRecord> {
|
||||
conn.query_row(
|
||||
"SELECT id, folder_id, path, filename, thumbnail_path, width, height, file_size, created_at, modified_at, mime_type,
|
||||
media_kind, favorite, rating, embedding_status, embedding_model, embedding_updated_at, embedding_error
|
||||
media_kind, duration_ms, video_codec, audio_codec, metadata_updated_at, metadata_error,
|
||||
favorite, rating, embedding_status, embedding_model, embedding_updated_at, embedding_error
|
||||
FROM images
|
||||
WHERE id = ?1",
|
||||
[image_id],
|
||||
@@ -443,7 +647,8 @@ pub fn get_images(
|
||||
let favorites_flag = i64::from(favorites_only);
|
||||
let sql = format!(
|
||||
"SELECT id, folder_id, path, filename, thumbnail_path, width, height, file_size, created_at, modified_at, mime_type,
|
||||
media_kind, favorite, rating, embedding_status, embedding_model, embedding_updated_at, embedding_error
|
||||
media_kind, duration_ms, video_codec, audio_codec, metadata_updated_at, metadata_error,
|
||||
favorite, rating, embedding_status, embedding_model, embedding_updated_at, embedding_error
|
||||
FROM images
|
||||
WHERE (?1 IS NULL OR folder_id = ?1)
|
||||
AND (?2 IS NULL OR filename LIKE ?2)
|
||||
@@ -510,12 +715,17 @@ fn map_image_row(row: &Row<'_>) -> rusqlite::Result<ImageRecord> {
|
||||
modified_at: row.get(9)?,
|
||||
mime_type: row.get(10)?,
|
||||
media_kind: row.get(11)?,
|
||||
favorite: row.get::<_, i64>(12)? != 0,
|
||||
rating: row.get(13)?,
|
||||
embedding_status: row.get(14)?,
|
||||
embedding_model: row.get(15)?,
|
||||
embedding_updated_at: row.get(16)?,
|
||||
embedding_error: row.get(17)?,
|
||||
duration_ms: row.get(12)?,
|
||||
video_codec: row.get(13)?,
|
||||
audio_codec: row.get(14)?,
|
||||
metadata_updated_at: row.get(15)?,
|
||||
metadata_error: row.get(16)?,
|
||||
favorite: row.get::<_, i64>(17)? != 0,
|
||||
rating: row.get(18)?,
|
||||
embedding_status: row.get(19)?,
|
||||
embedding_model: row.get(20)?,
|
||||
embedding_updated_at: row.get(21)?,
|
||||
embedding_error: row.get(22)?,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+494
-256
@@ -1,10 +1,14 @@
|
||||
use crate::db::{self, DbPool, ImageRecord};
|
||||
use crate::db::{self, DbPool, FolderJobProgress, ImageRecord, IndexedMediaEntry};
|
||||
use crate::media::{probe_video_metadata, MediaTools};
|
||||
use crate::thumbnail;
|
||||
use crate::vector;
|
||||
use anyhow::Result;
|
||||
use rayon::prelude::*;
|
||||
use serde::Serialize;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::{Duration, Instant};
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use walkdir::WalkDir;
|
||||
|
||||
@@ -14,12 +18,497 @@ const IMAGE_EXTENSIONS: &[&str] = &[
|
||||
|
||||
const VIDEO_EXTENSIONS: &[&str] = &["mp4", "mov", "m4v", "webm"];
|
||||
|
||||
const INDEX_BATCH_SIZE: usize = 250;
|
||||
const WORKER_BATCH_SIZE: usize = 12;
|
||||
const WORKER_FETCH_SIZE: usize = 64;
|
||||
const JOB_PROGRESS_EMIT_INTERVAL: Duration = Duration::from_millis(750);
|
||||
|
||||
static LAST_JOB_PROGRESS_EMIT: OnceLock<Mutex<HashMap<i64, Instant>>> = OnceLock::new();
|
||||
static ACTIVE_INDEXING_FOLDERS: OnceLock<Mutex<HashSet<i64>>> = OnceLock::new();
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
pub struct IndexProgress {
|
||||
pub folder_id: i64,
|
||||
pub total: usize,
|
||||
pub indexed: usize,
|
||||
pub current_file: String,
|
||||
pub done: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
pub struct IndexedImagesBatch {
|
||||
pub folder_id: i64,
|
||||
pub images: Vec<ImageRecord>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
pub struct MediaUpdateBatch {
|
||||
pub images: Vec<ImageRecord>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
pub struct MediaJobProgressEvent {
|
||||
pub progress: Vec<FolderJobProgress>,
|
||||
}
|
||||
|
||||
pub fn index_folder(app: AppHandle, pool: DbPool, folder_id: i64, folder_path: PathBuf) {
|
||||
std::thread::spawn(move || {
|
||||
set_folder_indexing_state(folder_id, true);
|
||||
if let Err(error) = do_index(app, pool, folder_id, folder_path) {
|
||||
eprintln!("Indexing error: {}", error);
|
||||
}
|
||||
set_folder_indexing_state(folder_id, false);
|
||||
});
|
||||
}
|
||||
|
||||
pub fn start_thumbnail_worker(
|
||||
app: AppHandle,
|
||||
pool: DbPool,
|
||||
media_tools: MediaTools,
|
||||
cache_dir: PathBuf,
|
||||
) {
|
||||
std::thread::spawn(move || loop {
|
||||
if let Err(error) = process_thumbnail_batch(&app, &pool, &media_tools, &cache_dir) {
|
||||
eprintln!("Thumbnail worker error: {}", error);
|
||||
}
|
||||
|
||||
std::thread::sleep(std::time::Duration::from_millis(250));
|
||||
});
|
||||
}
|
||||
|
||||
pub fn start_metadata_worker(app: AppHandle, pool: DbPool, media_tools: MediaTools) {
|
||||
std::thread::spawn(move || loop {
|
||||
if let Err(error) = process_metadata_batch(&app, &pool, &media_tools) {
|
||||
eprintln!("Metadata worker error: {}", error);
|
||||
}
|
||||
|
||||
std::thread::sleep(std::time::Duration::from_millis(250));
|
||||
});
|
||||
}
|
||||
|
||||
fn do_index(app: AppHandle, pool: DbPool, folder_id: i64, folder_path: PathBuf) -> Result<()> {
|
||||
let existing_entries = {
|
||||
let conn = pool.get()?;
|
||||
db::get_folder_media_index(&conn, folder_id)?
|
||||
};
|
||||
let existing_by_path = existing_entries
|
||||
.into_iter()
|
||||
.map(|entry| (entry.path.clone(), entry))
|
||||
.collect::<HashMap<_, _>>();
|
||||
|
||||
let media_paths: Vec<PathBuf> = WalkDir::new(&folder_path)
|
||||
.follow_links(true)
|
||||
.into_iter()
|
||||
.filter_map(|entry| entry.ok())
|
||||
.filter(|entry| entry.file_type().is_file() && is_supported_media(entry.path()))
|
||||
.map(|entry| entry.path().to_path_buf())
|
||||
.collect();
|
||||
|
||||
let total = media_paths.len();
|
||||
emit_progress(
|
||||
&app,
|
||||
&IndexProgress {
|
||||
folder_id,
|
||||
total,
|
||||
indexed: 0,
|
||||
current_file: String::new(),
|
||||
done: false,
|
||||
},
|
||||
);
|
||||
|
||||
let mut seen_paths = HashSet::with_capacity(total);
|
||||
let mut processed = 0usize;
|
||||
|
||||
for path_chunk in media_paths.chunks(INDEX_BATCH_SIZE) {
|
||||
let records: Vec<ImageRecord> = path_chunk
|
||||
.par_iter()
|
||||
.filter_map(|path| {
|
||||
let path_str = path.to_string_lossy().to_string();
|
||||
let existing = existing_by_path.get(&path_str);
|
||||
build_record(path, folder_id, existing)
|
||||
})
|
||||
.collect();
|
||||
|
||||
for path in path_chunk {
|
||||
seen_paths.insert(path.to_string_lossy().to_string());
|
||||
}
|
||||
|
||||
if !records.is_empty() {
|
||||
let committed = commit_batch(&pool, &records)?;
|
||||
emit_images(
|
||||
&app,
|
||||
&IndexedImagesBatch {
|
||||
folder_id,
|
||||
images: committed,
|
||||
},
|
||||
);
|
||||
emit_folder_job_progress(&app, &pool, &[folder_id]);
|
||||
}
|
||||
|
||||
processed += path_chunk.len();
|
||||
let current_file = path_chunk
|
||||
.last()
|
||||
.and_then(|path| path.file_name())
|
||||
.map(|name| name.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
emit_progress(
|
||||
&app,
|
||||
&IndexProgress {
|
||||
folder_id,
|
||||
total,
|
||||
indexed: processed,
|
||||
current_file,
|
||||
done: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let missing_ids = existing_by_path
|
||||
.values()
|
||||
.filter(|entry| !seen_paths.contains(&entry.path))
|
||||
.map(|entry| entry.id)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
{
|
||||
let conn = pool.get()?;
|
||||
if !missing_ids.is_empty() {
|
||||
db::delete_images_by_ids(&conn, &missing_ids)?;
|
||||
}
|
||||
db::update_folder_count(&conn, folder_id)?;
|
||||
}
|
||||
|
||||
emit_progress(
|
||||
&app,
|
||||
&IndexProgress {
|
||||
folder_id,
|
||||
total,
|
||||
indexed: processed,
|
||||
current_file: String::new(),
|
||||
done: true,
|
||||
},
|
||||
);
|
||||
emit_folder_job_progress(&app, &pool, &[folder_id]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_record(
|
||||
path: &Path,
|
||||
folder_id: i64,
|
||||
existing: Option<&IndexedMediaEntry>,
|
||||
) -> Option<ImageRecord> {
|
||||
let path_str = path.to_string_lossy().to_string();
|
||||
let filename = path.file_name()?.to_string_lossy().to_string();
|
||||
let metadata = std::fs::metadata(path).ok()?;
|
||||
let file_size = metadata.len() as i64;
|
||||
let modified_at = metadata.modified().ok().map(|time| {
|
||||
let date_time: chrono::DateTime<chrono::Utc> = time.into();
|
||||
date_time.to_rfc3339()
|
||||
});
|
||||
|
||||
let ext = path
|
||||
.extension()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("jpg");
|
||||
let media_kind = media_kind_for_ext(ext).to_string();
|
||||
|
||||
if let Some(existing) = existing {
|
||||
if existing.file_size == file_size
|
||||
&& existing.modified_at == modified_at
|
||||
&& existing.media_kind == media_kind
|
||||
{
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
Some(ImageRecord {
|
||||
id: existing.map(|entry| entry.id).unwrap_or(0),
|
||||
folder_id,
|
||||
path: path_str,
|
||||
filename,
|
||||
thumbnail_path: None,
|
||||
width: None,
|
||||
height: None,
|
||||
file_size,
|
||||
created_at: None,
|
||||
modified_at,
|
||||
mime_type: mime_for_ext(ext).to_string(),
|
||||
media_kind: media_kind.clone(),
|
||||
duration_ms: None,
|
||||
video_codec: None,
|
||||
audio_codec: None,
|
||||
metadata_updated_at: None,
|
||||
metadata_error: None,
|
||||
favorite: false,
|
||||
rating: 0,
|
||||
embedding_status: "pending".to_string(),
|
||||
embedding_model: Some(vector::CLIP_MODEL_NAME.to_string()),
|
||||
embedding_updated_at: None,
|
||||
embedding_error: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn commit_batch(pool: &DbPool, records: &[ImageRecord]) -> Result<Vec<ImageRecord>> {
|
||||
let mut conn = pool.get()?;
|
||||
let tx = conn.transaction()?;
|
||||
let mut committed = Vec::with_capacity(records.len());
|
||||
|
||||
for record in records {
|
||||
let mut committed_record = record.clone();
|
||||
committed_record.id = db::upsert_image(&tx, record)?;
|
||||
db::enqueue_embedding_job(&tx, committed_record.id)?;
|
||||
db::enqueue_thumbnail_job(&tx, committed_record.id)?;
|
||||
if committed_record.media_kind == "video" {
|
||||
db::enqueue_metadata_job(&tx, committed_record.id)?;
|
||||
}
|
||||
committed.push(committed_record);
|
||||
}
|
||||
|
||||
tx.commit()?;
|
||||
Ok(committed)
|
||||
}
|
||||
|
||||
fn process_thumbnail_batch(
|
||||
app: &AppHandle,
|
||||
pool: &DbPool,
|
||||
media_tools: &MediaTools,
|
||||
cache_dir: &Path,
|
||||
) -> Result<()> {
|
||||
let jobs = {
|
||||
let conn = pool.get()?;
|
||||
let active_folders = active_indexing_folders();
|
||||
db::get_pending_thumbnail_jobs(&conn, WORKER_FETCH_SIZE)?
|
||||
.into_iter()
|
||||
.filter(|job| !active_folders.contains(&job.folder_id))
|
||||
.take(WORKER_BATCH_SIZE)
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
|
||||
if jobs.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for job in &jobs {
|
||||
let conn = pool.get()?;
|
||||
db::mark_thumbnail_job_processing(&conn, job.image_id)?;
|
||||
}
|
||||
|
||||
let (image_jobs, video_jobs): (Vec<_>, Vec<_>) =
|
||||
jobs.into_iter().partition(|job| job.media_kind == "image");
|
||||
|
||||
let mut results = image_jobs
|
||||
.par_iter()
|
||||
.map(|job| {
|
||||
(
|
||||
job.image_id,
|
||||
if job.media_kind == "image" {
|
||||
thumbnail::generate_image_thumbnail(Path::new(&job.path), cache_dir).map(Some)
|
||||
} else {
|
||||
thumbnail::generate_video_thumbnail(
|
||||
media_tools,
|
||||
Path::new(&job.path),
|
||||
cache_dir,
|
||||
)
|
||||
.map(Some)
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
for job in video_jobs {
|
||||
results.push((
|
||||
job.image_id,
|
||||
thumbnail::generate_video_thumbnail(media_tools, Path::new(&job.path), cache_dir)
|
||||
.map(Some),
|
||||
));
|
||||
}
|
||||
|
||||
let mut updated_images = Vec::new();
|
||||
for (image_id, thumbnail_result) in results {
|
||||
let generated = match thumbnail_result {
|
||||
Ok(path) => path,
|
||||
Err(error) => {
|
||||
let conn = pool.get()?;
|
||||
db::mark_thumbnail_failed(&conn, image_id, &error.to_string())?;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let thumbnail_path = generated
|
||||
.as_ref()
|
||||
.map(|thumb| thumb.path.to_string_lossy().to_string());
|
||||
let width = generated.as_ref().and_then(|thumb| thumb.width);
|
||||
let height = generated.as_ref().and_then(|thumb| thumb.height);
|
||||
|
||||
let updated_image = {
|
||||
let conn = pool.get()?;
|
||||
db::mark_thumbnail_ready(&conn, image_id, thumbnail_path.as_deref(), width, height)?
|
||||
};
|
||||
updated_images.push(updated_image);
|
||||
}
|
||||
|
||||
if !updated_images.is_empty() {
|
||||
let folder_ids = updated_images
|
||||
.iter()
|
||||
.map(|image| image.folder_id)
|
||||
.collect::<HashSet<_>>();
|
||||
emit_media_updates(
|
||||
app,
|
||||
&MediaUpdateBatch {
|
||||
images: updated_images,
|
||||
},
|
||||
);
|
||||
emit_folder_job_progress(app, pool, &folder_ids.into_iter().collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn process_metadata_batch(app: &AppHandle, pool: &DbPool, media_tools: &MediaTools) -> Result<()> {
|
||||
let jobs = {
|
||||
let conn = pool.get()?;
|
||||
let active_folders = active_indexing_folders();
|
||||
db::get_pending_metadata_jobs(&conn, WORKER_FETCH_SIZE)?
|
||||
.into_iter()
|
||||
.filter(|job| !active_folders.contains(&job.folder_id))
|
||||
.take(WORKER_BATCH_SIZE)
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
|
||||
if jobs.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for job in &jobs {
|
||||
let conn = pool.get()?;
|
||||
db::mark_metadata_job_processing(&conn, job.image_id)?;
|
||||
}
|
||||
|
||||
let mut updated_images = Vec::new();
|
||||
|
||||
for job in jobs {
|
||||
let metadata = match probe_video_metadata(media_tools, Path::new(&job.path)) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) => {
|
||||
let conn = pool.get()?;
|
||||
db::mark_metadata_failed(&conn, job.image_id, &error.to_string())?;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let updated_image = {
|
||||
let conn = pool.get()?;
|
||||
db::mark_metadata_ready(
|
||||
&conn,
|
||||
job.image_id,
|
||||
metadata.duration_ms,
|
||||
metadata.width,
|
||||
metadata.height,
|
||||
metadata.video_codec.as_deref(),
|
||||
metadata.audio_codec.as_deref(),
|
||||
)?
|
||||
};
|
||||
updated_images.push(updated_image);
|
||||
}
|
||||
|
||||
if !updated_images.is_empty() {
|
||||
let folder_ids = updated_images
|
||||
.iter()
|
||||
.map(|image| image.folder_id)
|
||||
.collect::<HashSet<_>>();
|
||||
emit_media_updates(
|
||||
app,
|
||||
&MediaUpdateBatch {
|
||||
images: updated_images,
|
||||
},
|
||||
);
|
||||
emit_folder_job_progress(app, pool, &folder_ids.into_iter().collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn active_indexing_folders() -> HashSet<i64> {
|
||||
ACTIVE_INDEXING_FOLDERS
|
||||
.get_or_init(|| Mutex::new(HashSet::new()))
|
||||
.lock()
|
||||
.map(|folders| folders.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn set_folder_indexing_state(folder_id: i64, is_active: bool) {
|
||||
if let Ok(mut folders) = ACTIVE_INDEXING_FOLDERS
|
||||
.get_or_init(|| Mutex::new(HashSet::new()))
|
||||
.lock()
|
||||
{
|
||||
if is_active {
|
||||
folders.insert(folder_id);
|
||||
} else {
|
||||
folders.remove(&folder_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_progress(app: &AppHandle, progress: &IndexProgress) {
|
||||
let _ = app.emit("index-progress", progress);
|
||||
}
|
||||
|
||||
fn emit_images(app: &AppHandle, batch: &IndexedImagesBatch) {
|
||||
let _ = app.emit("indexed-images", batch);
|
||||
}
|
||||
|
||||
fn emit_media_updates(app: &AppHandle, batch: &MediaUpdateBatch) {
|
||||
let _ = app.emit("media-updated", batch);
|
||||
}
|
||||
|
||||
fn emit_folder_job_progress(app: &AppHandle, pool: &DbPool, folder_ids: &[i64]) {
|
||||
let mut unique_folder_ids = folder_ids.iter().copied().collect::<Vec<_>>();
|
||||
unique_folder_ids.sort_unstable();
|
||||
unique_folder_ids.dedup();
|
||||
|
||||
let now = Instant::now();
|
||||
let emit_tracker = LAST_JOB_PROGRESS_EMIT.get_or_init(|| Mutex::new(HashMap::new()));
|
||||
let mut tracker = match emit_tracker.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(_) => return,
|
||||
};
|
||||
unique_folder_ids.retain(|folder_id| {
|
||||
let should_emit = tracker
|
||||
.get(folder_id)
|
||||
.map(|last_emit| now.duration_since(*last_emit) >= JOB_PROGRESS_EMIT_INTERVAL)
|
||||
.unwrap_or(true);
|
||||
if should_emit {
|
||||
tracker.insert(*folder_id, now);
|
||||
}
|
||||
should_emit
|
||||
});
|
||||
drop(tracker);
|
||||
|
||||
if unique_folder_ids.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let Ok(conn) = pool.get() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let progress = unique_folder_ids
|
||||
.into_iter()
|
||||
.filter_map(|folder_id| db::get_folder_job_progress(&conn, folder_id).ok())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if !progress.is_empty() {
|
||||
let _ = app.emit("media-job-progress", MediaJobProgressEvent { progress });
|
||||
}
|
||||
}
|
||||
|
||||
fn is_supported_media(path: &Path) -> bool {
|
||||
path.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|e| {
|
||||
let ext = e.to_lowercase();
|
||||
IMAGE_EXTENSIONS.contains(&ext.as_str()) || VIDEO_EXTENSIONS.contains(&ext.as_str())
|
||||
.and_then(|value| value.to_str())
|
||||
.map(|value| {
|
||||
let extension = value.to_lowercase();
|
||||
IMAGE_EXTENSIONS.contains(&extension.as_str())
|
||||
|| VIDEO_EXTENSIONS.contains(&extension.as_str())
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
@@ -47,254 +536,3 @@ fn mime_for_ext(ext: &str) -> &'static str {
|
||||
_ => "image/jpeg",
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
pub struct IndexProgress {
|
||||
pub folder_id: i64,
|
||||
pub total: usize,
|
||||
pub indexed: usize,
|
||||
pub current_file: String,
|
||||
pub done: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
pub struct IndexedImagesBatch {
|
||||
pub folder_id: i64,
|
||||
pub images: Vec<ImageRecord>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
pub struct ThumbnailBatch {
|
||||
pub images: Vec<ImageRecord>,
|
||||
}
|
||||
|
||||
const INDEX_BATCH_SIZE: usize = 25;
|
||||
const THUMBNAIL_BATCH_SIZE: usize = 12;
|
||||
|
||||
pub fn index_folder(
|
||||
app: AppHandle,
|
||||
pool: DbPool,
|
||||
folder_id: i64,
|
||||
folder_path: PathBuf,
|
||||
_cache_dir: PathBuf,
|
||||
) {
|
||||
std::thread::spawn(move || {
|
||||
if let Err(e) = do_index(app, pool, folder_id, folder_path) {
|
||||
eprintln!("Indexing error: {}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn do_index(app: AppHandle, pool: DbPool, folder_id: i64, folder_path: PathBuf) -> Result<()> {
|
||||
let image_paths: Vec<PathBuf> = WalkDir::new(&folder_path)
|
||||
.follow_links(true)
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.file_type().is_file() && is_supported_media(e.path()))
|
||||
.map(|e| e.path().to_path_buf())
|
||||
.collect();
|
||||
|
||||
let total = image_paths.len();
|
||||
|
||||
emit_progress(
|
||||
&app,
|
||||
&IndexProgress {
|
||||
folder_id,
|
||||
total,
|
||||
indexed: 0,
|
||||
current_file: String::new(),
|
||||
done: false,
|
||||
},
|
||||
);
|
||||
|
||||
let mut indexed = 0usize;
|
||||
for path_chunk in image_paths.chunks(INDEX_BATCH_SIZE) {
|
||||
let records: Vec<ImageRecord> = path_chunk
|
||||
.par_iter()
|
||||
.filter_map(|path| build_record(path, folder_id))
|
||||
.collect();
|
||||
|
||||
if records.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let committed = commit_batch(&pool, &records)?;
|
||||
indexed += committed.len();
|
||||
emit_images(
|
||||
&app,
|
||||
&IndexedImagesBatch {
|
||||
folder_id,
|
||||
images: committed,
|
||||
},
|
||||
);
|
||||
|
||||
let current_file = path_chunk
|
||||
.last()
|
||||
.and_then(|path| path.file_name())
|
||||
.map(|name| name.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
emit_progress(
|
||||
&app,
|
||||
&IndexProgress {
|
||||
folder_id,
|
||||
total,
|
||||
indexed,
|
||||
current_file,
|
||||
done: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
let conn = pool.get()?;
|
||||
db::update_folder_count(&conn, folder_id)?;
|
||||
}
|
||||
|
||||
emit_progress(
|
||||
&app,
|
||||
&IndexProgress {
|
||||
folder_id,
|
||||
total,
|
||||
indexed,
|
||||
current_file: String::new(),
|
||||
done: true,
|
||||
},
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_record(path: &Path, folder_id: i64) -> Option<ImageRecord> {
|
||||
let path_str = path.to_string_lossy().to_string();
|
||||
let filename = path.file_name()?.to_string_lossy().to_string();
|
||||
|
||||
let meta = std::fs::metadata(path).ok();
|
||||
let file_size = meta.as_ref().map(|m| m.len() as i64).unwrap_or(0);
|
||||
let modified_at = meta.as_ref().and_then(|m| m.modified().ok()).map(|t| {
|
||||
let dt: chrono::DateTime<chrono::Utc> = t.into();
|
||||
dt.to_rfc3339()
|
||||
});
|
||||
|
||||
let (width, height) = thumbnail::get_dimensions(path)
|
||||
.map(|(w, h)| (Some(w as i64), Some(h as i64)))
|
||||
.unwrap_or((None, None));
|
||||
|
||||
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("jpg");
|
||||
|
||||
Some(ImageRecord {
|
||||
id: 0,
|
||||
folder_id,
|
||||
path: path_str,
|
||||
filename,
|
||||
thumbnail_path: None,
|
||||
width,
|
||||
height,
|
||||
file_size,
|
||||
created_at: None,
|
||||
modified_at,
|
||||
mime_type: mime_for_ext(ext).to_string(),
|
||||
media_kind: media_kind_for_ext(ext).to_string(),
|
||||
favorite: false,
|
||||
rating: 0,
|
||||
embedding_status: "pending".to_string(),
|
||||
embedding_model: Some(vector::CLIP_MODEL_NAME.to_string()),
|
||||
embedding_updated_at: None,
|
||||
embedding_error: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn emit_progress(app: &AppHandle, progress: &IndexProgress) {
|
||||
let _ = app.emit("index-progress", progress);
|
||||
}
|
||||
|
||||
fn emit_images(app: &AppHandle, batch: &IndexedImagesBatch) {
|
||||
let _ = app.emit("indexed-images", batch);
|
||||
}
|
||||
|
||||
fn emit_thumbnails(app: &AppHandle, batch: &ThumbnailBatch) {
|
||||
let _ = app.emit("thumbnail-updated", batch);
|
||||
}
|
||||
|
||||
fn commit_batch(pool: &DbPool, records: &[ImageRecord]) -> Result<Vec<ImageRecord>> {
|
||||
let mut conn = pool.get()?;
|
||||
let tx = conn.transaction()?;
|
||||
let mut committed = Vec::with_capacity(records.len());
|
||||
|
||||
for record in records {
|
||||
let mut committed_record = record.clone();
|
||||
committed_record.id = db::upsert_image(&tx, record)?;
|
||||
db::enqueue_embedding_job(&tx, committed_record.id)?;
|
||||
db::enqueue_thumbnail_job(&tx, committed_record.id)?;
|
||||
committed.push(committed_record);
|
||||
}
|
||||
|
||||
tx.commit()?;
|
||||
Ok(committed)
|
||||
}
|
||||
|
||||
pub fn start_thumbnail_worker(app: AppHandle, pool: DbPool, cache_dir: PathBuf) {
|
||||
std::thread::spawn(move || loop {
|
||||
if let Err(error) = process_thumbnail_batch(&app, &pool, &cache_dir) {
|
||||
eprintln!("Thumbnail worker error: {}", error);
|
||||
}
|
||||
|
||||
std::thread::sleep(std::time::Duration::from_millis(250));
|
||||
});
|
||||
}
|
||||
|
||||
fn process_thumbnail_batch(app: &AppHandle, pool: &DbPool, cache_dir: &Path) -> Result<()> {
|
||||
let mut updated_images = Vec::new();
|
||||
|
||||
for _ in 0..THUMBNAIL_BATCH_SIZE {
|
||||
let job = {
|
||||
let conn = pool.get()?;
|
||||
db::get_next_thumbnail_job(&conn)?
|
||||
};
|
||||
|
||||
let Some(job) = job else {
|
||||
break;
|
||||
};
|
||||
|
||||
{
|
||||
let conn = pool.get()?;
|
||||
db::mark_thumbnail_job_processing(&conn, job.image_id)?;
|
||||
}
|
||||
|
||||
let thumbnail_result = match job.media_kind.as_str() {
|
||||
"image" => thumbnail::generate_thumbnail(Path::new(&job.path), cache_dir),
|
||||
"video" => thumbnail::generate_video_poster(Path::new(&job.path), cache_dir),
|
||||
_ => {
|
||||
let conn = pool.get()?;
|
||||
updated_images.push(db::mark_thumbnail_ready(&conn, job.image_id, None)?);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let thumbnail_path = match thumbnail_result {
|
||||
Ok(path) => Some(path.to_string_lossy().to_string()),
|
||||
Err(error) => {
|
||||
let conn = pool.get()?;
|
||||
db::mark_thumbnail_failed(&conn, job.image_id, &error.to_string())?;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let updated_image = {
|
||||
let conn = pool.get()?;
|
||||
db::mark_thumbnail_ready(&conn, job.image_id, thumbnail_path.as_deref())?
|
||||
};
|
||||
updated_images.push(updated_image);
|
||||
}
|
||||
|
||||
if !updated_images.is_empty() {
|
||||
emit_thumbnails(
|
||||
app,
|
||||
&ThumbnailBatch {
|
||||
images: updated_images,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+12
-1
@@ -1,6 +1,7 @@
|
||||
mod commands;
|
||||
mod db;
|
||||
mod indexer;
|
||||
mod media;
|
||||
mod thumbnail;
|
||||
mod vector;
|
||||
|
||||
@@ -20,8 +21,11 @@ pub fn run() {
|
||||
|
||||
std::fs::create_dir_all(&app_dir).expect("Failed to create app data dir");
|
||||
|
||||
media::MediaTools::ensure_installed().expect("Failed to provision FFmpeg sidecar");
|
||||
|
||||
let db_path = app_dir.join("gallery.db");
|
||||
let pool = db::create_pool(&db_path).expect("Failed to create database pool");
|
||||
let media_tools = media::MediaTools::resolve();
|
||||
|
||||
{
|
||||
let conn = pool.get().expect("Failed to get connection for migration");
|
||||
@@ -31,9 +35,16 @@ pub fn run() {
|
||||
let thumb_dir = app_dir.join("thumbnails");
|
||||
std::fs::create_dir_all(&thumb_dir).expect("Failed to create thumbnail dir");
|
||||
|
||||
indexer::start_thumbnail_worker(app.handle().clone(), pool.clone(), thumb_dir);
|
||||
indexer::start_thumbnail_worker(
|
||||
app.handle().clone(),
|
||||
pool.clone(),
|
||||
media_tools.clone(),
|
||||
thumb_dir,
|
||||
);
|
||||
indexer::start_metadata_worker(app.handle().clone(), pool.clone(), media_tools.clone());
|
||||
|
||||
app.manage(pool);
|
||||
app.manage(media_tools);
|
||||
|
||||
Ok(())
|
||||
})
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
image_gallery_lib::run()
|
||||
phokus_lib::run()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use ffmpeg_sidecar::download::{auto_download_with_progress, FfmpegDownloadProgressEvent};
|
||||
use ffmpeg_sidecar::ffprobe::ffprobe_path;
|
||||
use ffmpeg_sidecar::paths::ffmpeg_path;
|
||||
use serde::Deserialize;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MediaTools {
|
||||
ffmpeg_path: PathBuf,
|
||||
ffprobe_path: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VideoMetadata {
|
||||
pub duration_ms: Option<i64>,
|
||||
pub width: Option<i64>,
|
||||
pub height: Option<i64>,
|
||||
pub video_codec: Option<String>,
|
||||
pub audio_codec: Option<String>,
|
||||
}
|
||||
|
||||
impl MediaTools {
|
||||
pub fn resolve() -> Self {
|
||||
Self {
|
||||
ffmpeg_path: ffmpeg_path(),
|
||||
ffprobe_path: ffprobe_path(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ensure_installed() -> Result<()> {
|
||||
auto_download_with_progress(|event| match event {
|
||||
FfmpegDownloadProgressEvent::Starting => {
|
||||
println!("Downloading bundled FFmpeg...");
|
||||
}
|
||||
FfmpegDownloadProgressEvent::Downloading {
|
||||
total_bytes,
|
||||
downloaded_bytes,
|
||||
} => {
|
||||
println!(
|
||||
"Downloading bundled FFmpeg: {}/{} bytes",
|
||||
downloaded_bytes, total_bytes
|
||||
);
|
||||
}
|
||||
FfmpegDownloadProgressEvent::UnpackingArchive => {
|
||||
println!("Unpacking bundled FFmpeg...");
|
||||
}
|
||||
FfmpegDownloadProgressEvent::Done => {
|
||||
println!("Bundled FFmpeg ready.");
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn ffmpeg_command(&self) -> Command {
|
||||
Command::new(&self.ffmpeg_path)
|
||||
}
|
||||
|
||||
pub fn ffprobe_command(&self) -> Command {
|
||||
Command::new(&self.ffprobe_path)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn probe_video_metadata(tools: &MediaTools, video_path: &Path) -> Result<VideoMetadata> {
|
||||
let output = tools
|
||||
.ffprobe_command()
|
||||
.args([
|
||||
"-v",
|
||||
"error",
|
||||
"-print_format",
|
||||
"json",
|
||||
"-show_format",
|
||||
"-show_streams",
|
||||
&video_path.to_string_lossy(),
|
||||
])
|
||||
.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(anyhow!(
|
||||
"ffprobe failed for {}: {}",
|
||||
video_path.display(),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
));
|
||||
}
|
||||
|
||||
let probe: FfprobeOutput = serde_json::from_slice(&output.stdout)?;
|
||||
let video_stream = probe
|
||||
.streams
|
||||
.iter()
|
||||
.find(|stream| stream.codec_type.as_deref() == Some("video"));
|
||||
let audio_stream = probe
|
||||
.streams
|
||||
.iter()
|
||||
.find(|stream| stream.codec_type.as_deref() == Some("audio"));
|
||||
|
||||
let duration_ms = probe
|
||||
.format
|
||||
.and_then(|format| format.duration)
|
||||
.and_then(|duration| parse_duration_ms(&duration))
|
||||
.or_else(|| {
|
||||
video_stream
|
||||
.and_then(|stream| stream.duration.as_deref())
|
||||
.and_then(parse_duration_ms)
|
||||
});
|
||||
|
||||
Ok(VideoMetadata {
|
||||
duration_ms,
|
||||
width: video_stream.and_then(|stream| stream.width),
|
||||
height: video_stream.and_then(|stream| stream.height),
|
||||
video_codec: video_stream.and_then(|stream| stream.codec_name.clone()),
|
||||
audio_codec: audio_stream.and_then(|stream| stream.codec_name.clone()),
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_duration_ms(value: &str) -> Option<i64> {
|
||||
let seconds = value.parse::<f64>().ok()?;
|
||||
Some((seconds * 1000.0).round() as i64)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct FfprobeOutput {
|
||||
format: Option<FfprobeFormat>,
|
||||
#[serde(default)]
|
||||
streams: Vec<FfprobeStream>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct FfprobeFormat {
|
||||
duration: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct FfprobeStream {
|
||||
codec_type: Option<String>,
|
||||
codec_name: Option<String>,
|
||||
duration: Option<String>,
|
||||
width: Option<i64>,
|
||||
height: Option<i64>,
|
||||
}
|
||||
+151
-62
@@ -1,10 +1,150 @@
|
||||
use crate::media::MediaTools;
|
||||
use anyhow::{anyhow, Result};
|
||||
use fast_image_resize as fir;
|
||||
use fast_image_resize::IntoImageView;
|
||||
use image::ImageDecoder;
|
||||
use image::ImageFormat;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
pub const THUMB_SIZE: u32 = 320;
|
||||
|
||||
pub struct GeneratedThumbnail {
|
||||
pub path: PathBuf,
|
||||
pub width: Option<i64>,
|
||||
pub height: Option<i64>,
|
||||
}
|
||||
|
||||
pub fn generate_image_thumbnail(image_path: &Path, cache_dir: &Path) -> Result<GeneratedThumbnail> {
|
||||
let path_str = image_path.to_string_lossy();
|
||||
let out_path = thumb_path(cache_dir, &path_str);
|
||||
|
||||
let original_dimensions = image::image_dimensions(image_path)
|
||||
.ok()
|
||||
.map(|(width, height)| (Some(width as i64), Some(height as i64)))
|
||||
.unwrap_or((None, None));
|
||||
|
||||
if out_path.exists() {
|
||||
return Ok(GeneratedThumbnail {
|
||||
path: out_path,
|
||||
width: original_dimensions.0,
|
||||
height: original_dimensions.1,
|
||||
});
|
||||
}
|
||||
|
||||
let reader = image::ImageReader::open(image_path)?.with_guessed_format()?;
|
||||
let mut decoder = reader.into_decoder()?;
|
||||
let orientation = decoder.orientation()?;
|
||||
let mut img = image::DynamicImage::from_decoder(decoder)?;
|
||||
img.apply_orientation(orientation);
|
||||
|
||||
let src = image::DynamicImage::ImageRgba8(img.into_rgba8());
|
||||
let (dst_width, dst_height) = fit_dimensions(src.width(), src.height(), THUMB_SIZE);
|
||||
|
||||
let mut dst = fir::images::Image::new(dst_width, dst_height, src.pixel_type().unwrap());
|
||||
let mut resizer = fir::Resizer::new();
|
||||
let options = fir::ResizeOptions::new()
|
||||
.resize_alg(fir::ResizeAlg::Convolution(fir::FilterType::Lanczos3));
|
||||
resizer.resize(&src, &mut dst, Some(&options))?;
|
||||
|
||||
let thumb = image::RgbaImage::from_raw(dst_width, dst_height, dst.buffer().to_vec())
|
||||
.ok_or_else(|| anyhow!("failed to construct resized thumbnail buffer"))?;
|
||||
|
||||
if let Some(parent) = out_path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
image::DynamicImage::ImageRgba8(thumb).save_with_format(&out_path, ImageFormat::WebP)?;
|
||||
Ok(GeneratedThumbnail {
|
||||
path: out_path,
|
||||
width: original_dimensions.0,
|
||||
height: original_dimensions.1,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn generate_video_thumbnail(
|
||||
tools: &MediaTools,
|
||||
video_path: &Path,
|
||||
cache_dir: &Path,
|
||||
) -> Result<GeneratedThumbnail> {
|
||||
let path_str = video_path.to_string_lossy();
|
||||
let out_path = video_poster_path(cache_dir, &path_str);
|
||||
|
||||
if out_path.exists() {
|
||||
return Ok(GeneratedThumbnail {
|
||||
path: out_path,
|
||||
width: None,
|
||||
height: None,
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(parent) = out_path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let output_path = out_path.to_string_lossy().into_owned();
|
||||
let attempts: [&[&str]; 3] = [
|
||||
&[
|
||||
"-y",
|
||||
"-ss",
|
||||
"00:00:00.000",
|
||||
"-i",
|
||||
path_str.as_ref(),
|
||||
"-frames:v",
|
||||
"1",
|
||||
"-vf",
|
||||
"thumbnail,scale=320:-1:force_original_aspect_ratio=decrease",
|
||||
"-q:v",
|
||||
"4",
|
||||
&output_path,
|
||||
],
|
||||
&[
|
||||
"-y",
|
||||
"-ss",
|
||||
"00:00:00.250",
|
||||
"-i",
|
||||
path_str.as_ref(),
|
||||
"-frames:v",
|
||||
"1",
|
||||
"-vf",
|
||||
"thumbnail,scale=320:-1:force_original_aspect_ratio=decrease",
|
||||
"-q:v",
|
||||
"4",
|
||||
&output_path,
|
||||
],
|
||||
&[
|
||||
"-y",
|
||||
"-i",
|
||||
path_str.as_ref(),
|
||||
"-frames:v",
|
||||
"1",
|
||||
"-vf",
|
||||
"thumbnail,scale=320:-1:force_original_aspect_ratio=decrease",
|
||||
"-q:v",
|
||||
"4",
|
||||
&output_path,
|
||||
],
|
||||
];
|
||||
|
||||
let mut last_error = String::new();
|
||||
for args in attempts {
|
||||
let output = tools.ffmpeg_command().args(args).output()?;
|
||||
if output.status.success() && out_path.exists() {
|
||||
return Ok(GeneratedThumbnail {
|
||||
path: out_path,
|
||||
width: None,
|
||||
height: None,
|
||||
});
|
||||
}
|
||||
last_error = String::from_utf8_lossy(&output.stderr).to_string();
|
||||
}
|
||||
|
||||
Err(anyhow!(
|
||||
"ffmpeg failed generating poster for {}: {}",
|
||||
path_str,
|
||||
last_error
|
||||
))
|
||||
}
|
||||
|
||||
pub fn thumb_path(cache_dir: &Path, image_path: &str) -> PathBuf {
|
||||
thumb_path_with_ext(cache_dir, image_path, "webp")
|
||||
}
|
||||
@@ -19,70 +159,19 @@ fn thumb_path_with_ext(cache_dir: &Path, input_path: &str, extension: &str) -> P
|
||||
}
|
||||
|
||||
fn hash_path(s: &str) -> String {
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
let mut hasher = DefaultHasher::new();
|
||||
s.hash(&mut hasher);
|
||||
format!("{:016x}", hasher.finish())
|
||||
format!("{:016x}", xxhash_rust::xxh3::xxh3_64(s.as_bytes()))
|
||||
}
|
||||
|
||||
pub fn generate_thumbnail(image_path: &Path, cache_dir: &Path) -> Result<PathBuf> {
|
||||
let path_str = image_path.to_string_lossy();
|
||||
let out_path = thumb_path(cache_dir, &path_str);
|
||||
|
||||
if out_path.exists() {
|
||||
return Ok(out_path);
|
||||
fn fit_dimensions(width: u32, height: u32, max_size: u32) -> (u32, u32) {
|
||||
if width <= max_size && height <= max_size {
|
||||
return (width.max(1), height.max(1));
|
||||
}
|
||||
|
||||
let img = image::open(image_path)?;
|
||||
let thumb = img.thumbnail(THUMB_SIZE, THUMB_SIZE);
|
||||
|
||||
if let Some(parent) = out_path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
if width >= height {
|
||||
let scaled_height = ((height as f64 / width as f64) * max_size as f64).round() as u32;
|
||||
(max_size, scaled_height.max(1))
|
||||
} else {
|
||||
let scaled_width = ((width as f64 / height as f64) * max_size as f64).round() as u32;
|
||||
(scaled_width.max(1), max_size)
|
||||
}
|
||||
|
||||
thumb.save_with_format(&out_path, ImageFormat::WebP)?;
|
||||
Ok(out_path)
|
||||
}
|
||||
|
||||
pub fn generate_video_poster(video_path: &Path, cache_dir: &Path) -> Result<PathBuf> {
|
||||
let path_str = video_path.to_string_lossy();
|
||||
let out_path = video_poster_path(cache_dir, &path_str);
|
||||
|
||||
if out_path.exists() {
|
||||
return Ok(out_path);
|
||||
}
|
||||
|
||||
if let Some(parent) = out_path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let status = Command::new("ffmpeg")
|
||||
.args([
|
||||
"-y",
|
||||
"-ss",
|
||||
"00:00:01.000",
|
||||
"-i",
|
||||
&path_str,
|
||||
"-frames:v",
|
||||
"1",
|
||||
"-vf",
|
||||
"scale=320:-1:force_original_aspect_ratio=decrease",
|
||||
"-q:v",
|
||||
"4",
|
||||
out_path.to_string_lossy().as_ref(),
|
||||
])
|
||||
.status()?;
|
||||
|
||||
if !status.success() {
|
||||
return Err(anyhow!("ffmpeg failed generating poster for {}", path_str));
|
||||
}
|
||||
|
||||
Ok(out_path)
|
||||
}
|
||||
|
||||
/// Gets image dimensions without fully decoding.
|
||||
pub fn get_dimensions(image_path: &Path) -> Option<(u32, u32)> {
|
||||
image::image_dimensions(image_path).ok()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user