diff --git a/CHANGELOG.md b/CHANGELOG.md index 580327b..5b25794 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Database schema v19 adds a nullable, indexed `files.motion_score` for the + derived video motion metric. No value is computed yet: the column and its + plumbing ship first so scoring can follow without another migration. Existing + rows report `NULL`, which means "not measured" rather than "still", and a + rescan preserves a score that has been written instead of clearing it. - Filter videos by pixel width and height (with SD / HD / FHD+ / 4K+ presets in the filter drawer) and by codec (`h264`, `hevc`, `vp8`, `vp9`, `av1`). The `width:`, `height:` and `codec:` query tokens share the existing range syntax, diff --git a/crates/omera-domain/src/image_file.rs b/crates/omera-domain/src/image_file.rs index 7536e3c..59af969 100644 --- a/crates/omera-domain/src/image_file.rs +++ b/crates/omera-domain/src/image_file.rs @@ -94,6 +94,13 @@ pub struct ImageFile { pub rating: Option, /// Calculated or extracted aesthetic score, if available. pub aesthetic_score: Option, + /// Derived motion score for video assets, on a 0–100 scale. + /// + /// `None` means the clip has not been measured, which is distinct from a + /// clip that measured as still. Only videos carry this value; see + /// [MOTION_SCORE_DESIGN.md](../../docs/MOTION_SCORE_DESIGN.md). + #[serde(default)] + pub motion_score: Option, /// Whether this file is marked as a favorite. #[serde(default)] pub is_favorite: bool, diff --git a/crates/omera-scan/benches/large_library.rs b/crates/omera-scan/benches/large_library.rs index a1f060f..cce5722 100644 --- a/crates/omera-scan/benches/large_library.rs +++ b/crates/omera-scan/benches/large_library.rs @@ -85,6 +85,7 @@ fn generate_mock_files(folder_id: i64, start_index: usize, count: usize) -> Vec< None }, aesthetic_score: Some(6.0 + ((i % 40) as f64) * 0.1), + motion_score: None, is_favorite: i % 10 == 0, is_nsfw: false, stack_id: if i % 20 == 0 { diff --git a/crates/omera-scan/src/cloud_sync.rs b/crates/omera-scan/src/cloud_sync.rs index 9aa9e03..1cbf875 100644 --- a/crates/omera-scan/src/cloud_sync.rs +++ b/crates/omera-scan/src/cloud_sync.rs @@ -279,6 +279,7 @@ mod tests { metadata: None, rating: None, aesthetic_score: None, + motion_score: None, is_favorite: false, is_nsfw: false, stack_id: None, diff --git a/crates/omera-scan/src/export.rs b/crates/omera-scan/src/export.rs index 50788ae..1de1fa3 100644 --- a/crates/omera-scan/src/export.rs +++ b/crates/omera-scan/src/export.rs @@ -818,6 +818,7 @@ mod tests { metadata: None, rating: None, aesthetic_score: None, + motion_score: None, is_favorite: false, is_nsfw: false, stack_id: None, @@ -887,6 +888,7 @@ mod tests { metadata: None, rating: Some(5), aesthetic_score: None, + motion_score: None, is_favorite: true, is_nsfw: false, stack_id: None, @@ -949,6 +951,7 @@ mod tests { metadata: Some(metadata), rating: Some(4), aesthetic_score: None, + motion_score: None, is_favorite: true, is_nsfw: false, stack_id: None, @@ -1019,6 +1022,7 @@ mod tests { metadata: None, rating: None, aesthetic_score: None, + motion_score: None, is_favorite: false, is_nsfw: false, stack_id: None, @@ -1066,6 +1070,7 @@ mod tests { metadata: None, rating: None, aesthetic_score: None, + motion_score: None, is_favorite: false, is_nsfw: false, stack_id: None, @@ -1132,6 +1137,7 @@ mod tests { metadata: None, // Intentionally None to test extraction from bytes rating: Some(4), aesthetic_score: None, + motion_score: None, is_favorite: true, is_nsfw: false, stack_id: None, @@ -1212,6 +1218,7 @@ mod tests { metadata: None, rating: None, aesthetic_score: None, + motion_score: None, is_favorite: false, is_nsfw: false, stack_id: None, @@ -1279,6 +1286,7 @@ mod tests { metadata: None, rating: None, aesthetic_score: None, + motion_score: None, is_favorite: false, is_nsfw: false, stack_id: None, @@ -1335,6 +1343,7 @@ mod tests { metadata: None, rating: None, aesthetic_score: None, + motion_score: None, is_favorite: false, is_nsfw: false, stack_id: None, @@ -1401,6 +1410,7 @@ mod tests { metadata: Some(metadata), rating: None, aesthetic_score: None, + motion_score: None, is_favorite: false, is_nsfw: false, stack_id: None, diff --git a/crates/omera-scan/src/file_operations.rs b/crates/omera-scan/src/file_operations.rs index 3a67d68..98d0430 100644 --- a/crates/omera-scan/src/file_operations.rs +++ b/crates/omera-scan/src/file_operations.rs @@ -217,6 +217,7 @@ mod tests { metadata: None, rating: None, aesthetic_score: None, + motion_score: None, is_favorite: false, is_nsfw: false, stack_id: None, @@ -276,6 +277,7 @@ mod tests { metadata: None, rating: None, aesthetic_score: None, + motion_score: None, is_favorite: false, is_nsfw: false, stack_id: None, @@ -344,6 +346,7 @@ mod tests { metadata: None, rating: None, aesthetic_score: None, + motion_score: None, is_favorite: false, is_nsfw: false, stack_id: None, diff --git a/crates/omera-scan/src/pipeline.rs b/crates/omera-scan/src/pipeline.rs index e826d93..fc80f5c 100644 --- a/crates/omera-scan/src/pipeline.rs +++ b/crates/omera-scan/src/pipeline.rs @@ -684,6 +684,7 @@ fn harvest_selected( metadata, rating: None, aesthetic_score: None, + motion_score: None, is_favorite: false, is_nsfw: false, stack_id: None, @@ -1566,6 +1567,7 @@ mod tests { metadata: None, rating: None, aesthetic_score: None, + motion_score: None, is_favorite: false, is_nsfw: false, stack_id: None, @@ -1624,6 +1626,7 @@ mod tests { metadata: None, rating: None, aesthetic_score: None, + motion_score: None, is_favorite: false, is_nsfw: false, stack_id: None, @@ -1652,6 +1655,7 @@ mod tests { metadata: None, rating: None, aesthetic_score: None, + motion_score: None, is_favorite: false, is_nsfw: false, stack_id: None, @@ -1697,6 +1701,7 @@ mod tests { metadata: None, rating: None, aesthetic_score: None, + motion_score: None, is_favorite: false, is_nsfw: false, stack_id: None, @@ -1811,6 +1816,7 @@ mod tests { metadata: None, rating: None, aesthetic_score: None, + motion_score: None, is_favorite: false, is_nsfw: false, stack_id: None, diff --git a/crates/omera-scan/src/scanner.rs b/crates/omera-scan/src/scanner.rs index 807eb68..15c85f9 100644 --- a/crates/omera-scan/src/scanner.rs +++ b/crates/omera-scan/src/scanner.rs @@ -319,6 +319,7 @@ impl Scanner { metadata, rating: None, aesthetic_score: None, + motion_score: None, is_favorite: false, is_nsfw, stack_id: None, @@ -425,6 +426,7 @@ impl Scanner { metadata, rating: None, aesthetic_score: None, + motion_score: None, is_favorite: false, is_nsfw, stack_id: None, diff --git a/crates/omera-scan/src/transform.rs b/crates/omera-scan/src/transform.rs index a606ba9..5a0c0b3 100644 --- a/crates/omera-scan/src/transform.rs +++ b/crates/omera-scan/src/transform.rs @@ -887,6 +887,7 @@ fn publish_managed_import_item_with_sidecars( metadata, rating: None, aesthetic_score: None, + motion_score: None, is_favorite: false, is_nsfw, stack_id: None, @@ -1409,6 +1410,7 @@ mod tests { metadata: None, rating: None, aesthetic_score: None, + motion_score: None, is_favorite: false, is_nsfw: false, stack_id: None, @@ -1686,6 +1688,7 @@ mod tests { metadata: None, rating: Some(4), aesthetic_score: None, + motion_score: None, is_favorite: true, is_nsfw: false, stack_id: None, @@ -1703,6 +1706,7 @@ mod tests { metadata: None, rating: Some(5), aesthetic_score: None, + motion_score: None, is_favorite: false, is_nsfw: false, stack_id: None, @@ -1773,6 +1777,7 @@ mod tests { metadata: None, rating: Some(5), aesthetic_score: None, + motion_score: None, is_favorite: true, is_nsfw: false, stack_id: None, @@ -2196,6 +2201,7 @@ mod tests { metadata: None, rating: Some(4), aesthetic_score: None, + motion_score: None, is_favorite: true, is_nsfw: false, stack_id: None, @@ -2286,6 +2292,7 @@ mod tests { metadata: None, rating: None, aesthetic_score: None, + motion_score: None, is_favorite: false, is_nsfw: false, stack_id: None, diff --git a/crates/omera-scan/tests/avif_discovery.rs b/crates/omera-scan/tests/avif_discovery.rs index 6e6b722..403f654 100644 --- a/crates/omera-scan/tests/avif_discovery.rs +++ b/crates/omera-scan/tests/avif_discovery.rs @@ -28,6 +28,7 @@ fn exported_fixture() -> (TempDir, Database, PathBuf, PathBuf, i64) { metadata: None, rating: None, aesthetic_score: None, + motion_score: None, is_favorite: false, is_nsfw: false, stack_id: None, diff --git a/crates/omera-scan/tests/avif_export_only.rs b/crates/omera-scan/tests/avif_export_only.rs index b97c7e9..5b7c691 100644 --- a/crates/omera-scan/tests/avif_export_only.rs +++ b/crates/omera-scan/tests/avif_export_only.rs @@ -39,6 +39,7 @@ fn fixture() -> (TempDir, Database, i64, i64) { metadata: None, rating: Some(8), aesthetic_score: Some(6.0), + motion_score: None, is_favorite: true, is_nsfw: false, stack_id: Some("curated".into()), diff --git a/crates/omera-scan/tests/batch_transform_dimensions.rs b/crates/omera-scan/tests/batch_transform_dimensions.rs index 17e1321..c737a55 100644 --- a/crates/omera-scan/tests/batch_transform_dimensions.rs +++ b/crates/omera-scan/tests/batch_transform_dimensions.rs @@ -108,6 +108,7 @@ fn decoded_dimensions_survive_batch_transform_gallery_query_and_restart() { metadata: omera_metadata::extract_metadata(Container::Png, &source), rating: Some(8), aesthetic_score: Some(7.5), + motion_score: None, is_favorite: true, is_nsfw: true, stack_id: Some("same-stack".into()), diff --git a/crates/omera-scan/tests/batch_transform_privacy.rs b/crates/omera-scan/tests/batch_transform_privacy.rs index 5bac9c7..d35d1bc 100644 --- a/crates/omera-scan/tests/batch_transform_privacy.rs +++ b/crates/omera-scan/tests/batch_transform_privacy.rs @@ -40,6 +40,7 @@ fn fixture() -> (TempDir, Database, i64) { metadata: omera_metadata::extract_metadata(Container::Png, &source), rating: Some(8), aesthetic_score: Some(6.5), + motion_score: None, is_favorite: true, is_nsfw: true, stack_id: Some("curated-stack".into()), diff --git a/crates/omera-storage/src/db.rs b/crates/omera-storage/src/db.rs index acc2880..97af646 100644 --- a/crates/omera-storage/src/db.rs +++ b/crates/omera-storage/src/db.rs @@ -116,8 +116,8 @@ impl PartialOrd for RankedMatch { /// SQL that inserts or updates a file row keyed by its unique path. const UPSERT_FILE_SQL: &str = - "INSERT INTO files (folder_id, path, container, size_bytes, modified_at, metadata, rating, aesthetic_score, is_favorite, is_nsfw, stack_id, stack_order) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12) + "INSERT INTO files (folder_id, path, container, size_bytes, modified_at, metadata, rating, aesthetic_score, motion_score, is_favorite, is_nsfw, stack_id, stack_order) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13) ON CONFLICT(path) DO UPDATE SET folder_id = excluded.folder_id, container = excluded.container, @@ -126,6 +126,7 @@ const UPSERT_FILE_SQL: &str = metadata = excluded.metadata, rating = coalesce(excluded.rating, files.rating), aesthetic_score = coalesce(excluded.aesthetic_score, files.aesthetic_score), + motion_score = coalesce(excluded.motion_score, files.motion_score), is_favorite = files.is_favorite, is_nsfw = files.is_nsfw, stack_id = coalesce(excluded.stack_id, files.stack_id), @@ -133,8 +134,8 @@ const UPSERT_FILE_SQL: &str = /// SQL that inserts or updates a file row keyed by its unique path, returning the affected row id. const UPSERT_FILE_RETURNING_ID_SQL: &str = - "INSERT INTO files (folder_id, path, container, size_bytes, modified_at, metadata, rating, aesthetic_score, is_favorite, is_nsfw, stack_id, stack_order) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12) + "INSERT INTO files (folder_id, path, container, size_bytes, modified_at, metadata, rating, aesthetic_score, motion_score, is_favorite, is_nsfw, stack_id, stack_order) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13) ON CONFLICT(path) DO UPDATE SET folder_id = excluded.folder_id, container = excluded.container, @@ -143,6 +144,7 @@ const UPSERT_FILE_RETURNING_ID_SQL: &str = metadata = excluded.metadata, rating = coalesce(excluded.rating, files.rating), aesthetic_score = coalesce(excluded.aesthetic_score, files.aesthetic_score), + motion_score = coalesce(excluded.motion_score, files.motion_score), is_favorite = files.is_favorite, is_nsfw = files.is_nsfw, stack_id = coalesce(excluded.stack_id, files.stack_id), @@ -470,6 +472,7 @@ impl Database { metadata, file.rating.map(|r| r as i64), file.aesthetic_score, + file.motion_score, file.is_favorite as i64, file.is_nsfw as i64, file.stack_id, @@ -509,6 +512,7 @@ impl Database { metadata, file.rating.map(|r| r as i64), file.aesthetic_score, + file.motion_score, file.is_favorite as i64, file.is_nsfw as i64, file.stack_id, @@ -571,10 +575,11 @@ impl Database { let metadata: Option = row.get(6)?; let rating: Option = row.get(7)?; let aesthetic_score: Option = row.get(8)?; - let is_favorite: i64 = row.get(9).unwrap_or(0); - let is_nsfw: i64 = row.get(10).unwrap_or(0); - let stack_id: Option = row.get(11).unwrap_or(None); - let stack_order: i64 = row.get(12).unwrap_or(0); + let motion_score: Option = row.get(9).unwrap_or(None); + let is_favorite: i64 = row.get(10).unwrap_or(0); + let is_nsfw: i64 = row.get(11).unwrap_or(0); + let stack_id: Option = row.get(12).unwrap_or(None); + let stack_order: i64 = row.get(13).unwrap_or(0); let container = Container::from_id(&container_id) .ok_or_else(|| DatabaseError::UnknownContainer(container_id))?; @@ -592,6 +597,7 @@ impl Database { metadata, rating: rating.map(|r| r as u8), aesthetic_score, + motion_score, is_favorite: is_favorite != 0, is_nsfw: is_nsfw != 0, stack_id, @@ -618,7 +624,7 @@ impl Database { /// Retrieve a file by its file path. pub fn get_file_by_path(&self, path: &str) -> Result, DatabaseError> { let mut stmt = self.conn.prepare_cached( - "SELECT id, folder_id, path, container, size_bytes, modified_at, metadata, rating, aesthetic_score, is_favorite, is_nsfw, stack_id, stack_order + "SELECT id, folder_id, path, container, size_bytes, modified_at, metadata, rating, aesthetic_score, motion_score, is_favorite, is_nsfw, stack_id, stack_order FROM files WHERE path = ?1 LIMIT 1", )?; let mut rows = stmt.query_and_then([path], Self::map_row)?; @@ -631,7 +637,7 @@ impl Database { /// Retrieve a file by its database row ID. pub fn get_file_by_id(&self, id: i64) -> Result, DatabaseError> { let mut stmt = self.conn.prepare_cached( - "SELECT id, folder_id, path, container, size_bytes, modified_at, metadata, rating, aesthetic_score, is_favorite, is_nsfw, stack_id, stack_order + "SELECT id, folder_id, path, container, size_bytes, modified_at, metadata, rating, aesthetic_score, motion_score, is_favorite, is_nsfw, stack_id, stack_order FROM files WHERE id = ?1 LIMIT 1", )?; let mut rows = stmt.query_and_then([id], Self::map_row)?; @@ -1128,7 +1134,7 @@ impl Database { }; let sql = format!( - "SELECT id, folder_id, path, container, size_bytes, modified_at, {metadata_projection} AS metadata, rating, aesthetic_score, is_favorite, is_nsfw, stack_id, stack_order, COUNT(*) OVER() AS total_count + "SELECT id, folder_id, path, container, size_bytes, modified_at, {metadata_projection} AS metadata, rating, aesthetic_score, motion_score, is_favorite, is_nsfw, stack_id, stack_order, COUNT(*) OVER() AS total_count FROM files{where_clause} ORDER BY {order_clause}{limit_clause}" ); @@ -1138,7 +1144,7 @@ impl Database { let mut total = 0usize; while let Some(row) = rows.next()? { if files.is_empty() { - total = row.get::<_, i64>(13)? as usize; + total = row.get::<_, i64>(14)? as usize; } files.push(Self::map_row(row)?); } @@ -1285,7 +1291,7 @@ impl Database { }; let sql = format!( - "SELECT id, folder_id, path, container, size_bytes, modified_at, {metadata_projection} AS metadata, rating, aesthetic_score, is_favorite, is_nsfw, stack_id, stack_order, {total_projection} AS total_count + "SELECT id, folder_id, path, container, size_bytes, modified_at, {metadata_projection} AS metadata, rating, aesthetic_score, motion_score, is_favorite, is_nsfw, stack_id, stack_order, {total_projection} AS total_count FROM files{where_clause} ORDER BY {order_clause} LIMIT ?" ); @@ -1296,7 +1302,7 @@ impl Database { while let Some(row) = rows.next()? { if files.is_empty() && criteria.cursor.is_none() { - total = Some(row.get::<_, i64>(13)? as usize); + total = Some(row.get::<_, i64>(14)? as usize); } files.push(Self::map_row(row)?); } @@ -2928,7 +2934,7 @@ impl Database { let mut files = Vec::new(); for chunk in ids.chunks(400) { let placeholders = vec!["?"; chunk.len()].join(","); - let sql = format!("SELECT id, folder_id, path, container, size_bytes, modified_at, metadata, rating, aesthetic_score, is_favorite, is_nsfw, stack_id, stack_order FROM files WHERE id IN ({placeholders})"); + let sql = format!("SELECT id, folder_id, path, container, size_bytes, modified_at, metadata, rating, aesthetic_score, motion_score, is_favorite, is_nsfw, stack_id, stack_order FROM files WHERE id IN ({placeholders})"); let mut statement = self.conn.prepare(&sql)?; let mut rows = statement.query(rusqlite::params_from_iter(chunk))?; while let Some(row) = rows.next()? { @@ -3122,7 +3128,7 @@ impl Database { } let mut stmt = self.conn.prepare_cached( - "SELECT id, folder_id, path, container, size_bytes, modified_at, metadata, rating, aesthetic_score, is_favorite, is_nsfw, stack_id, stack_order + "SELECT id, folder_id, path, container, size_bytes, modified_at, metadata, rating, aesthetic_score, motion_score, is_favorite, is_nsfw, stack_id, stack_order FROM files WHERE container IN ('png', 'jpg', 'webp') AND id NOT IN (SELECT file_id FROM file_embeddings WHERE model_id = ?1) @@ -3270,7 +3276,7 @@ impl Database { /// Get all images belonging to a specific stack, ordered by stack_order ASC, id ASC. pub fn get_stack_members(&self, stack_id: &str) -> Result, DatabaseError> { let mut stmt = self.conn.prepare_cached( - "SELECT id, folder_id, path, container, size_bytes, modified_at, metadata, rating, aesthetic_score, is_favorite, is_nsfw, stack_id, stack_order + "SELECT id, folder_id, path, container, size_bytes, modified_at, metadata, rating, aesthetic_score, motion_score, is_favorite, is_nsfw, stack_id, stack_order FROM files WHERE stack_id = ?1 ORDER BY stack_order ASC, id ASC", )?; let rows = stmt.query_and_then([stack_id], Self::map_row)?; @@ -3729,6 +3735,87 @@ mod tests { assert_eq!(db.meta_get("anything").unwrap(), None); } + #[test] + fn v19_adds_a_nullable_indexed_motion_score() { + let conn = Connection::open_in_memory().unwrap(); + for migration in crate::migrations::MIGRATIONS { + conn.execute_batch(migration).unwrap(); + } + + // The column exists, admits NULL, and carries an index for the facet. + let nullable: i64 = conn + .query_row( + "SELECT \"notnull\" FROM pragma_table_info('files') WHERE name = 'motion_score'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(nullable, 0, "motion_score must remain nullable"); + + let indexed: i64 = conn + .query_row( + "SELECT COUNT(*) FROM sqlite_master + WHERE type = 'index' AND tbl_name = 'files' AND name = 'idx_files_motion_score'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(indexed, 1); + + // An unmeasured file reads back as NULL, not as zero. + conn.execute("INSERT INTO folders (id, path) VALUES (1, '/tmp/v')", []) + .unwrap(); + conn.execute( + "INSERT INTO files (id, folder_id, path, container, size_bytes, modified_at) + VALUES (1, 1, '/tmp/v/a.mp4', 'mp4', 10, 100)", + [], + ) + .unwrap(); + let unmeasured: Option = conn + .query_row("SELECT motion_score FROM files WHERE id = 1", [], |row| { + row.get(0) + }) + .unwrap(); + assert_eq!(unmeasured, None); + } + + #[test] + fn upsert_preserves_an_existing_motion_score() { + let db = Database::connect_in_memory().unwrap(); + let folder = db.add_folder("/tmp/motion").unwrap(); + + let mut file = image(folder.id, "/tmp/motion/clip.mp4"); + file.container = Container::Mp4; + file.motion_score = Some(42.5); + db.upsert_file(&file).unwrap(); + assert_eq!( + db.get_file_by_path("/tmp/motion/clip.mp4") + .unwrap() + .unwrap() + .motion_score, + Some(42.5) + ); + + // A rescan re-reads metadata and passes no score; the measurement + // must survive rather than being wiped by the upsert. + let mut rescan = image(folder.id, "/tmp/motion/clip.mp4"); + rescan.container = Container::Mp4; + rescan.size_bytes = 99; + rescan.motion_score = None; + db.upsert_file(&rescan).unwrap(); + + let reloaded = db + .get_file_by_path("/tmp/motion/clip.mp4") + .unwrap() + .unwrap(); + assert_eq!(reloaded.size_bytes, 99, "rescan should update size"); + assert_eq!( + reloaded.motion_score, + Some(42.5), + "rescan must not clear a measured motion score" + ); + } + #[test] fn file_database_persists_and_tracks_path() { let dir = std::env::temp_dir().join(format!("omera-storage-test-{}", std::process::id())); @@ -3764,6 +3851,7 @@ mod tests { metadata: None, rating: None, aesthetic_score: None, + motion_score: None, is_favorite: false, is_nsfw: false, stack_id: None, @@ -6233,6 +6321,7 @@ mod tests { metadata: None, rating: Some(4), aesthetic_score: None, + motion_score: None, is_favorite: false, is_nsfw: false, stack_id: None, @@ -6324,6 +6413,7 @@ mod tests { metadata: None, rating: None, aesthetic_score: None, + motion_score: None, is_favorite: false, is_nsfw: false, stack_id: None, @@ -6376,6 +6466,7 @@ mod tests { metadata: None, rating: None, aesthetic_score: None, + motion_score: None, is_favorite: false, is_nsfw: false, stack_id: None, @@ -6413,6 +6504,7 @@ mod tests { metadata: None, rating: None, aesthetic_score: None, + motion_score: None, is_favorite: false, is_nsfw: false, stack_id: None, diff --git a/crates/omera-storage/src/migration_export.rs b/crates/omera-storage/src/migration_export.rs index f2af668..d619d15 100644 --- a/crates/omera-storage/src/migration_export.rs +++ b/crates/omera-storage/src/migration_export.rs @@ -178,6 +178,7 @@ CREATE TABLE IF NOT EXISTS `files` ( `modified_at` BIGINT NOT NULL, `rating` TINYINT UNSIGNED NULL, `aesthetic_score` DOUBLE NULL, + `motion_score` DOUBLE NULL, `is_favorite` TINYINT(1) NOT NULL DEFAULT 0, `is_nsfw` TINYINT(1) NOT NULL DEFAULT 0, `is_deleted` TINYINT(1) NOT NULL DEFAULT 0, @@ -259,6 +260,7 @@ CREATE TABLE IF NOT EXISTS "files" ( "modified_at" BIGINT NOT NULL, "rating" SMALLINT NULL, "aesthetic_score" DOUBLE PRECISION NULL, + "motion_score" DOUBLE PRECISION NULL, "is_favorite" BOOLEAN NOT NULL DEFAULT FALSE, "is_nsfw" BOOLEAN NOT NULL DEFAULT FALSE, "is_deleted" BOOLEAN NOT NULL DEFAULT FALSE, @@ -601,6 +603,7 @@ struct FileRow { modified_at: i64, rating: Option, aesthetic_score: Option, + motion_score: Option, is_favorite: bool, is_nsfw: bool, stack_id: Option, @@ -626,7 +629,7 @@ fn write_files( let mut stmt = conn.prepare( "SELECT id, path, container, size_bytes, modified_at, rating, aesthetic_score, - is_favorite, is_nsfw, stack_id, stack_order, metadata, version + motion_score, is_favorite, is_nsfw, stack_id, stack_order, metadata, version FROM files ORDER BY id ASC", )?; @@ -640,12 +643,13 @@ fn write_files( modified_at: row.get(4)?, rating: row.get(5)?, aesthetic_score: row.get(6)?, - is_favorite: row.get::<_, i64>(7)? != 0, - is_nsfw: row.get::<_, i64>(8)? != 0, - stack_id: row.get(9)?, - stack_order: row.get(10)?, - metadata: row.get(11)?, - version: row.get::<_, Option>(12)?.unwrap_or(1), + motion_score: row.get(7)?, + is_favorite: row.get::<_, i64>(8)? != 0, + is_nsfw: row.get::<_, i64>(9)? != 0, + stack_id: row.get(10)?, + stack_order: row.get(11)?, + metadata: row.get(12)?, + version: row.get::<_, Option>(13)?.unwrap_or(1), }) })?; @@ -683,7 +687,7 @@ fn flush_files_batch( let mut sql = format!( "INSERT INTO {} ( id, root_uuid, relative_path, file_name, file_extension, size_bytes, modified_at, - rating, aesthetic_score, is_favorite, is_nsfw, is_deleted, stack_id, stack_order, + rating, aesthetic_score, motion_score, is_favorite, is_nsfw, is_deleted, stack_id, stack_order, metadata_format, metadata_json, version, created_at, updated_at, updated_by ) VALUES\n", table @@ -721,6 +725,11 @@ fn flush_files_batch( None => "NULL".to_string(), }; + let motion_str = match row.motion_score { + Some(s) => format!("{:.4}", s), + None => "NULL".to_string(), + }; + let meta_json_str = match &row.metadata { Some(json) => { if is_mysql { @@ -738,7 +747,7 @@ fn flush_files_batch( }; sql.push_str(&format!( - "({}, '{}', '{}', '{}', '{}', {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, '{}')", + "({}, '{}', '{}', '{}', '{}', {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, '{}')", row.id, escape_sql(target_root, is_mysql), escape_sql(&rel_path, is_mysql), @@ -748,6 +757,7 @@ fn flush_files_batch( row.modified_at, rating_str, score_str, + motion_str, sql_bool(row.is_favorite, is_mysql), sql_bool(row.is_nsfw, is_mysql), sql_bool(false, is_mysql), // is_deleted @@ -901,4 +911,63 @@ mod tests { assert!(pg_content.contains("CREATE TABLE IF NOT EXISTS \"files\"")); assert!(pg_content.contains("nas_vault_01")); } + + #[test] + fn motion_score_exports_in_both_dialects() { + let conn = Connection::open_in_memory().unwrap(); + for migration in crate::migrations::MIGRATIONS { + conn.execute_batch(migration).unwrap(); + } + + conn.execute("INSERT INTO folders (id, path) VALUES (1, '/Vault')", []) + .unwrap(); + conn.execute( + "INSERT INTO files (id, folder_id, path, container, size_bytes, modified_at, motion_score, version) + VALUES (1, 1, '/Vault/busy.mp4', 'mp4', 2048, 1726000000, 73.25, 1), + (2, 1, '/Vault/still.mp4', 'mp4', 1024, 1726000001, NULL, 1)", + [], + ) + .unwrap(); + + for (dialect, quoted_column, ddl_marker) in [ + ( + "mysql", + "`motion_score`", + "CREATE TABLE IF NOT EXISTS `files`", + ), + ( + "postgres", + "\"motion_score\"", + "CREATE TABLE IF NOT EXISTS \"files\"", + ), + ] { + let tmp = NamedTempFile::new().unwrap(); + let dest = tmp.path().to_str().unwrap().to_string(); + export_migration_sql( + &conn, + &MigrationOptions { + target_dialect: dialect.to_string(), + target_root_uuid: "vault".to_string(), + destination: dest.clone(), + }, + ) + .unwrap(); + + let content = std::fs::read_to_string(&dest).unwrap(); + assert!(content.contains(ddl_marker), "{dialect}: files DDL missing"); + assert!( + content.contains(quoted_column), + "{dialect}: motion_score column missing from DDL" + ); + // The measured value is written verbatim; the unmeasured row stays NULL. + assert!( + content.contains("73.2500"), + "{dialect}: measured score missing" + ); + assert!( + content.contains("'still.mp4', 'mp4', 1024, 1726000001, NULL"), + "{dialect}: an unmeasured clip must export as NULL, not 0" + ); + } + } } diff --git a/crates/omera-storage/src/migrations.rs b/crates/omera-storage/src/migrations.rs index 38ede10..4ef4681 100644 --- a/crates/omera-storage/src/migrations.rs +++ b/crates/omera-storage/src/migrations.rs @@ -252,6 +252,13 @@ pub const MIGRATIONS: &[&str] = &[ CREATE INDEX idx_civitai_cache_model ON civitai_cache(model_id); "#, + // v19: derived motion score for video assets, measured by the WebView + // decode path. Nullable with no default: NULL means "not measured yet". + r#" + ALTER TABLE files ADD COLUMN motion_score REAL; + + CREATE INDEX idx_files_motion_score ON files(motion_score); + "#, ]; /// The schema version the current code migrates databases to. diff --git a/src-tauri/src/cloud_sync_namespace_tests.rs b/src-tauri/src/cloud_sync_namespace_tests.rs index f707a31..5bfce4e 100644 --- a/src-tauri/src/cloud_sync_namespace_tests.rs +++ b/src-tauri/src/cloud_sync_namespace_tests.rs @@ -32,6 +32,7 @@ impl Fixture { metadata: None, rating: None, aesthetic_score: None, + motion_score: None, is_favorite: false, is_nsfw: false, stack_id: None, diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 8e48425..854199a 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -5007,6 +5007,7 @@ mod tests { metadata: None, rating: None, aesthetic_score: None, + motion_score: None, is_favorite: false, is_nsfw: false, stack_id: None, @@ -5248,6 +5249,7 @@ mod tests { metadata: None, rating: None, aesthetic_score: None, + motion_score: None, is_favorite: false, is_nsfw: false, stack_id: None, @@ -5309,6 +5311,7 @@ mod tests { metadata: None, rating: Some(5), aesthetic_score: None, + motion_score: None, is_favorite: true, is_nsfw: false, stack_id: None, @@ -5364,6 +5367,7 @@ mod tests { metadata: None, rating: Some(5), aesthetic_score: None, + motion_score: None, is_favorite: true, is_nsfw: false, stack_id: Some(stack_id.to_string()), @@ -5384,6 +5388,7 @@ mod tests { metadata: None, rating: Some(1), aesthetic_score: None, + motion_score: None, is_favorite: false, is_nsfw: false, stack_id: Some(stack_id.to_string()), @@ -5404,6 +5409,7 @@ mod tests { metadata: None, rating: Some(4), aesthetic_score: None, + motion_score: None, is_favorite: false, is_nsfw: false, stack_id: Some(stack_id.to_string()), diff --git a/src/types.ts b/src/types.ts index 177bcb2..28e0687 100644 --- a/src/types.ts +++ b/src/types.ts @@ -77,6 +77,8 @@ export interface ImageFile { metadata: ExtractedMetadata | null; rating?: number | null; aesthetic_score?: number | null; + /** Derived 0–100 motion score; null or absent means not measured. */ + motion_score?: number | null; is_favorite?: boolean; is_nsfw?: boolean; similarity_score?: number | null;