From cd339a781292374c8d208e9ead949179dbbe23ea Mon Sep 17 00:00:00 2001 From: jake <77554505+brxken128@users.noreply.github.com> Date: Tue, 29 Aug 2023 18:02:55 +0100 Subject: [PATCH] [ENG-288, ENG-601] Media data (image) extraction, thumbnail orientation fix (#1099) * basic layout * lock * add codec to image * very messy wip * rm that * lock and toml * working perfect exif extraction * formatting * migration and formatting * mostly working * fix * set date created properly * fix tsc * working media data creation * fix bad main merge? sorry brendan * schema, migrations, bindings * working exif data extraction * why won't it work * update migrations * fix bad merge * final cleanup * cleanup migrations * remove test (that was purely used for testing my code) * working media data pulling, correct thumbnail orientation * slightly optimise thumbnail rotation * rename location to prevent specta clashes * further improvements (location parsing is still broken) * fix coordinate parsing i think * rspc add some todos regarding final cleanup * further thoughts * major upgrades * Some improved handling of errors and async calls * accordion component * heavily wip flash refactor * fix builds all because of a (terrible) merge/move * annoying missing newline * i really hate exif * remove dead code * further flash progress :D * docs(media-data): clarification * minor cleanup * cleanup and some async removal * fix location parsing * remove async (will do proper impl for async eventually) and major cleanup * big W * clippy and `FlashMode::Unknown` instead of `Invalid` * add `NIKON` gps ref support * comments about gps refs * commit the submodule -_- * major cleanup & composite image support * remove old test image * major cleanup and easier to use API * remove old consts * move `ExifReader` to dedicated module * Media Data Extractor job and shallow job * Extracting media data on watcher * report no exif data on file gracefully * cleanup errors and doctests * Merging thumbnailer and media data extractor * Job name and some strings in UI * remove reliance on `sd-prisma` within the media data crate * rename query to be more specific * custom serializer for `MediaTime` * tweak to format utc -> naive correctly * generate migrations * comment out duration in mobile * delete test-assets folder * all optional fields * fix migrations again * make requested name changes * make further requested changes * remove erroneous files from another wip branch * updates procedures * use strings where appropriate * regen pnpm-lock * add base layouts for video and audio media data * use appropriate data types in schema and add audio media data framework * make requested changes * general cleanup and renaming of enum * cleanup media data api * rename media metadata type * finishing touches --------- Co-authored-by: Ericson Soares Co-authored-by: ameer2468 <33054370+ameer2468@users.noreply.github.com> Co-authored-by: Jamie Pine <32987599+jamiepine@users.noreply.github.com> Co-authored-by: Oscar Beaumont Co-authored-by: Utku <74243531+utkubakir@users.noreply.github.com> Co-authored-by: Brendan Allan --- Cargo.lock | Bin 245033 -> 245663 bytes .../modal/inspector/FileInfoModal.tsx | 6 +- apps/mobile/src/hooks/useZodForm.ts | 21 - core/Cargo.toml | 11 +- .../20230828195811_media_data/migration.sql | 152 +++++++ core/prisma/schema.prisma | 31 +- core/src/api/files.rs | 38 +- core/src/api/jobs.rs | 5 +- core/src/api/search.rs | 2 +- core/src/job/error.rs | 30 +- core/src/job/manager.rs | 4 +- core/src/job/mod.rs | 44 +- core/src/library/library.rs | 2 +- .../isolated_file_path_data.rs | 4 +- core/src/location/file_path_helper/mod.rs | 4 +- core/src/location/indexer/indexer_job.rs | 1 + core/src/location/manager/watcher/utils.rs | 76 +++- core/src/location/mod.rs | 20 +- core/src/location/non_indexed.rs | 2 +- .../file_identifier/file_identifier_job.rs | 25 +- core/src/object/file_identifier/mod.rs | 9 +- core/src/object/media/media_data_extractor.rs | 167 ++++++++ core/src/object/media/media_processor/job.rs | 270 +++++++++++++ core/src/object/media/media_processor/mod.rs | 188 +++++++++ .../object/media/media_processor/shallow.rs | 196 +++++++++ core/src/object/media/mod.rs | 61 +++ .../{preview => media}/thumbnail/directory.rs | 16 +- core/src/object/media/thumbnail/mod.rs | 382 ++++++++++++++++++ .../{preview => media}/thumbnail/shard.rs | 0 core/src/object/mod.rs | 4 +- core/src/object/preview/media_data.rs | 140 ------- core/src/object/preview/media_data_job.rs | 0 core/src/object/preview/mod.rs | 5 - core/src/object/preview/thumbnail/mod.rs | 269 ------------ core/src/object/preview/thumbnail/shallow.rs | 152 ------- .../preview/thumbnail/thumbnailer_job.rs | 259 ------------ core/src/object/thumbnail_remover.rs | 2 +- crates/file-ext/src/extensions.rs | 1 + crates/heif/src/lib.rs | 17 +- crates/media-metadata/Cargo.toml | 16 + crates/media-metadata/README.md | 1 + crates/media-metadata/clippy.toml | 1 + crates/media-metadata/src/audio.rs | 19 + crates/media-metadata/src/error.rs | 28 ++ crates/media-metadata/src/image/composite.rs | 45 +++ crates/media-metadata/src/image/consts.rs | 31 ++ crates/media-metadata/src/image/dimensions.rs | 47 +++ .../media-metadata/src/image/flash/consts.rs | 13 + crates/media-metadata/src/image/flash/data.rs | 168 ++++++++ crates/media-metadata/src/image/flash/mod.rs | 6 + .../media-metadata/src/image/flash/values.rs | 136 +++++++ crates/media-metadata/src/image/location.rs | 197 +++++++++ crates/media-metadata/src/image/mod.rs | 124 ++++++ .../media-metadata/src/image/orientation.rs | 63 +++ crates/media-metadata/src/image/profile.rs | 55 +++ crates/media-metadata/src/image/reader.rs | 54 +++ crates/media-metadata/src/image/time.rs | 101 +++++ crates/media-metadata/src/lib.rs | 40 ++ crates/media-metadata/src/video.rs | 20 + .../library/locations/AddLocationDialog.tsx | 48 +-- interface/components/Accordion.tsx | 34 ++ packages/client/package.json | 1 + packages/client/src/core.ts | 34 +- packages/test-files | 2 +- pnpm-lock.yaml | Bin 991477 -> 948540 bytes 65 files changed, 2913 insertions(+), 987 deletions(-) delete mode 100644 apps/mobile/src/hooks/useZodForm.ts create mode 100644 core/prisma/migrations/20230828195811_media_data/migration.sql create mode 100644 core/src/object/media/media_data_extractor.rs create mode 100644 core/src/object/media/media_processor/job.rs create mode 100644 core/src/object/media/media_processor/mod.rs create mode 100644 core/src/object/media/media_processor/shallow.rs create mode 100644 core/src/object/media/mod.rs rename core/src/object/{preview => media}/thumbnail/directory.rs (92%) create mode 100644 core/src/object/media/thumbnail/mod.rs rename core/src/object/{preview => media}/thumbnail/shard.rs (100%) delete mode 100644 core/src/object/preview/media_data.rs delete mode 100644 core/src/object/preview/media_data_job.rs delete mode 100644 core/src/object/preview/mod.rs delete mode 100644 core/src/object/preview/thumbnail/mod.rs delete mode 100644 core/src/object/preview/thumbnail/shallow.rs delete mode 100644 core/src/object/preview/thumbnail/thumbnailer_job.rs create mode 100644 crates/media-metadata/Cargo.toml create mode 100644 crates/media-metadata/README.md create mode 100644 crates/media-metadata/clippy.toml create mode 100644 crates/media-metadata/src/audio.rs create mode 100644 crates/media-metadata/src/error.rs create mode 100644 crates/media-metadata/src/image/composite.rs create mode 100644 crates/media-metadata/src/image/consts.rs create mode 100644 crates/media-metadata/src/image/dimensions.rs create mode 100644 crates/media-metadata/src/image/flash/consts.rs create mode 100644 crates/media-metadata/src/image/flash/data.rs create mode 100644 crates/media-metadata/src/image/flash/mod.rs create mode 100644 crates/media-metadata/src/image/flash/values.rs create mode 100644 crates/media-metadata/src/image/location.rs create mode 100644 crates/media-metadata/src/image/mod.rs create mode 100644 crates/media-metadata/src/image/orientation.rs create mode 100644 crates/media-metadata/src/image/profile.rs create mode 100644 crates/media-metadata/src/image/reader.rs create mode 100644 crates/media-metadata/src/image/time.rs create mode 100644 crates/media-metadata/src/lib.rs create mode 100644 crates/media-metadata/src/video.rs create mode 100644 interface/components/Accordion.tsx diff --git a/Cargo.lock b/Cargo.lock index f4a5f843acbaadde54bbccf301808a88c3cb0d2a..37c71259f80b8f2f0358b68c5957ec777e385811 100644 GIT binary patch delta 344 zcmY+9J4?e*7>3F5f}|QLh!F*;nZ2BxoSY;=#oy3L=pf&@NhoPiqll9_=uihshPa7~ z;Nm1?6>;h8;vaAot%KmA#YJ7-;ThhC=lavS__7Xe4XZv42}o!xsJ&P_=C0b>j$6%2 zwc_B#yj(VFD@s!>0xU7`K>3_;(BNU>GGLsB0rnL{j0&j*i3IksCe$S)@)ZX^#94XC zHj|wcQnlV{ip-suDaPu?N3HY=RX+D@z^coHW}yqj{n!+W`y484uG@)4qLbNTLJb`x2~@N^V65Ig96jHn}h((props, ref) => { value={`${byteSize(filePathData?.size_in_bytes_bytes)}`} /> {/* Duration */} - {fullObjectData.data?.media_data?.duration_seconds && ( + {/* {fullObjectData.data?.media_data?.duration && ( - )} + )} */} {/* Created */} - extends Exclude>, 'resolver'> { - schema?: S; -} - -export const useZodForm = >>( - props?: UseZodFormProps -) => { - const { schema, ...formProps } = props ?? {}; - - return useForm({ - ...formProps, - resolver: zodResolver(schema || z.object({})) - }); -}; - -export { z } from 'zod'; diff --git a/core/Cargo.toml b/core/Cargo.toml index f25ecdb8d..15e7c38cf 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -18,6 +18,8 @@ location-watcher = ["dep:notify"] heif = ["dep:sd-heif"] [dependencies] +sd-media-metadata = { path = "../crates/media-metadata" } +sd-prisma = { path = "../crates/prisma" } sd-ffmpeg = { path = "../crates/ffmpeg", optional = true } sd-crypto = { path = "../crates/crypto", features = [ "rspc", @@ -29,9 +31,7 @@ sd-heif = { path = "../crates/heif", optional = true } sd-file-ext = { path = "../crates/file-ext" } sd-sync = { path = "../crates/sync" } sd-p2p = { path = "../crates/p2p", features = ["specta", "serde"] } -sd-prisma = { path = "../crates/prisma" } sd-utils = { path = "../crates/utils" } - sd-core-sync = { path = "./crates/sync" } rspc = { workspace = true, features = [ @@ -52,8 +52,9 @@ tokio = { workspace = true, features = [ "time", "process", ] } - +kamadak-exif = "0.5.5" base64 = "0.21.2" + serde = { version = "1.0", features = ["derive"] } chrono = { version = "0.4.25", features = ["serde"] } serde_json = { workspace = true } @@ -71,9 +72,7 @@ async-trait = "^0.1.68" image = "0.24.6" webp = "0.2.2" tracing = { workspace = true } -tracing-subscriber = { workspace = true, features = [ - "env-filter", -] } +tracing-subscriber = { workspace = true, features = ["env-filter"] } async-stream = "0.3.5" once_cell = "1.17.2" ctor = "0.1.26" diff --git a/core/prisma/migrations/20230828195811_media_data/migration.sql b/core/prisma/migrations/20230828195811_media_data/migration.sql new file mode 100644 index 000000000..4a362f217 --- /dev/null +++ b/core/prisma/migrations/20230828195811_media_data/migration.sql @@ -0,0 +1,152 @@ +/* + Warnings: + + - You are about to drop the column `capture_device_make` on the `media_data` table. All the data in the column will be lost. + - You are about to drop the column `capture_device_model` on the `media_data` table. All the data in the column will be lost. + - You are about to drop the column `capture_device_software` on the `media_data` table. All the data in the column will be lost. + - You are about to drop the column `codecs` on the `media_data` table. All the data in the column will be lost. + - You are about to drop the column `duration_seconds` on the `media_data` table. All the data in the column will be lost. + - You are about to drop the column `fps` on the `media_data` table. All the data in the column will be lost. + - You are about to drop the column `latitude` on the `media_data` table. All the data in the column will be lost. + - You are about to drop the column `longitude` on the `media_data` table. All the data in the column will be lost. + - You are about to drop the column `pixel_height` on the `media_data` table. All the data in the column will be lost. + - You are about to drop the column `pixel_width` on the `media_data` table. All the data in the column will be lost. + - You are about to drop the column `streams` on the `media_data` table. All the data in the column will be lost. + - Added the required column `object_id` to the `media_data` table without a default value. This is not possible if the table is not empty. + +*/ +-- RedefineTables +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_label_on_object" ( + "date_created" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "label_id" INTEGER NOT NULL, + "object_id" INTEGER NOT NULL, + + PRIMARY KEY ("label_id", "object_id"), + CONSTRAINT "label_on_object_label_id_fkey" FOREIGN KEY ("label_id") REFERENCES "label" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "label_on_object_object_id_fkey" FOREIGN KEY ("object_id") REFERENCES "object" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); +INSERT INTO "new_label_on_object" ("date_created", "label_id", "object_id") SELECT "date_created", "label_id", "object_id" FROM "label_on_object"; +DROP TABLE "label_on_object"; +ALTER TABLE "new_label_on_object" RENAME TO "label_on_object"; +CREATE TABLE "new_tag_on_object" ( + "tag_id" INTEGER NOT NULL, + "object_id" INTEGER NOT NULL, + + PRIMARY KEY ("tag_id", "object_id"), + CONSTRAINT "tag_on_object_tag_id_fkey" FOREIGN KEY ("tag_id") REFERENCES "tag" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "tag_on_object_object_id_fkey" FOREIGN KEY ("object_id") REFERENCES "object" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); +INSERT INTO "new_tag_on_object" ("object_id", "tag_id") SELECT "object_id", "tag_id" FROM "tag_on_object"; +DROP TABLE "tag_on_object"; +ALTER TABLE "new_tag_on_object" RENAME TO "tag_on_object"; +CREATE TABLE "new_file_path" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "pub_id" BLOB NOT NULL, + "is_dir" BOOLEAN, + "cas_id" TEXT, + "integrity_checksum" TEXT, + "location_id" INTEGER, + "materialized_path" TEXT, + "name" TEXT, + "extension" TEXT, + "size_in_bytes" TEXT, + "size_in_bytes_bytes" BLOB, + "inode" BLOB, + "device" BLOB, + "object_id" INTEGER, + "key_id" INTEGER, + "date_created" DATETIME, + "date_modified" DATETIME, + "date_indexed" DATETIME, + CONSTRAINT "file_path_location_id_fkey" FOREIGN KEY ("location_id") REFERENCES "location" ("id") ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT "file_path_object_id_fkey" FOREIGN KEY ("object_id") REFERENCES "object" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); +INSERT INTO "new_file_path" ("cas_id", "date_created", "date_indexed", "date_modified", "device", "extension", "id", "inode", "integrity_checksum", "is_dir", "key_id", "location_id", "materialized_path", "name", "object_id", "pub_id", "size_in_bytes", "size_in_bytes_bytes") SELECT "cas_id", "date_created", "date_indexed", "date_modified", "device", "extension", "id", "inode", "integrity_checksum", "is_dir", "key_id", "location_id", "materialized_path", "name", "object_id", "pub_id", "size_in_bytes", "size_in_bytes_bytes" FROM "file_path"; +DROP TABLE "file_path"; +ALTER TABLE "new_file_path" RENAME TO "file_path"; +CREATE UNIQUE INDEX "file_path_pub_id_key" ON "file_path"("pub_id"); +CREATE INDEX "file_path_location_id_idx" ON "file_path"("location_id"); +CREATE INDEX "file_path_location_id_materialized_path_idx" ON "file_path"("location_id", "materialized_path"); +CREATE UNIQUE INDEX "file_path_location_id_materialized_path_name_extension_key" ON "file_path"("location_id", "materialized_path", "name", "extension"); +CREATE UNIQUE INDEX "file_path_location_id_inode_device_key" ON "file_path"("location_id", "inode", "device"); +CREATE TABLE "new_location" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "pub_id" BLOB NOT NULL, + "name" TEXT, + "path" TEXT, + "total_capacity" INTEGER, + "available_capacity" INTEGER, + "is_archived" BOOLEAN, + "generate_preview_media" BOOLEAN, + "sync_preview_media" BOOLEAN, + "hidden" BOOLEAN, + "date_created" DATETIME, + "instance_id" INTEGER, + CONSTRAINT "location_instance_id_fkey" FOREIGN KEY ("instance_id") REFERENCES "instance" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); +INSERT INTO "new_location" ("available_capacity", "date_created", "generate_preview_media", "hidden", "id", "instance_id", "is_archived", "name", "path", "pub_id", "sync_preview_media", "total_capacity") SELECT "available_capacity", "date_created", "generate_preview_media", "hidden", "id", "instance_id", "is_archived", "name", "path", "pub_id", "sync_preview_media", "total_capacity" FROM "location"; +DROP TABLE "location"; +ALTER TABLE "new_location" RENAME TO "location"; +CREATE UNIQUE INDEX "location_pub_id_key" ON "location"("pub_id"); +CREATE TABLE "new_job" ( + "id" BLOB NOT NULL PRIMARY KEY, + "name" TEXT, + "action" TEXT, + "status" INTEGER, + "errors_text" TEXT, + "data" BLOB, + "metadata" BLOB, + "parent_id" BLOB, + "task_count" INTEGER, + "completed_task_count" INTEGER, + "date_estimated_completion" DATETIME, + "date_created" DATETIME, + "date_started" DATETIME, + "date_completed" DATETIME, + CONSTRAINT "job_parent_id_fkey" FOREIGN KEY ("parent_id") REFERENCES "job" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); +INSERT INTO "new_job" ("action", "completed_task_count", "data", "date_completed", "date_created", "date_estimated_completion", "date_started", "errors_text", "id", "metadata", "name", "parent_id", "status", "task_count") SELECT "action", "completed_task_count", "data", "date_completed", "date_created", "date_estimated_completion", "date_started", "errors_text", "id", "metadata", "name", "parent_id", "status", "task_count" FROM "job"; +DROP TABLE "job"; +ALTER TABLE "new_job" RENAME TO "job"; +CREATE TABLE "new_indexer_rule_in_location" ( + "location_id" INTEGER NOT NULL, + "indexer_rule_id" INTEGER NOT NULL, + + PRIMARY KEY ("location_id", "indexer_rule_id"), + CONSTRAINT "indexer_rule_in_location_location_id_fkey" FOREIGN KEY ("location_id") REFERENCES "location" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "indexer_rule_in_location_indexer_rule_id_fkey" FOREIGN KEY ("indexer_rule_id") REFERENCES "indexer_rule" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); +INSERT INTO "new_indexer_rule_in_location" ("indexer_rule_id", "location_id") SELECT "indexer_rule_id", "location_id" FROM "indexer_rule_in_location"; +DROP TABLE "indexer_rule_in_location"; +ALTER TABLE "new_indexer_rule_in_location" RENAME TO "indexer_rule_in_location"; +CREATE TABLE "new_media_data" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "dimensions" BLOB, + "media_date" BLOB, + "media_location" BLOB, + "camera_data" BLOB, + "artist" TEXT, + "description" TEXT, + "copyright" TEXT, + "exif_version" TEXT, + "object_id" INTEGER NOT NULL, + CONSTRAINT "media_data_object_id_fkey" FOREIGN KEY ("object_id") REFERENCES "object" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); +INSERT INTO "new_media_data" ("id") SELECT "id" FROM "media_data"; +DROP TABLE "media_data"; +ALTER TABLE "new_media_data" RENAME TO "media_data"; +CREATE UNIQUE INDEX "media_data_object_id_key" ON "media_data"("object_id"); +CREATE TABLE "new_object_in_space" ( + "space_id" INTEGER NOT NULL, + "object_id" INTEGER NOT NULL, + + PRIMARY KEY ("space_id", "object_id"), + CONSTRAINT "object_in_space_space_id_fkey" FOREIGN KEY ("space_id") REFERENCES "space" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "object_in_space_object_id_fkey" FOREIGN KEY ("object_id") REFERENCES "object" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); +INSERT INTO "new_object_in_space" ("object_id", "space_id") SELECT "object_id", "space_id" FROM "object_in_space"; +DROP TABLE "object_in_space"; +ALTER TABLE "new_object_in_space" RENAME TO "object_in_space"; +PRAGMA foreign_key_check; +PRAGMA foreign_keys=ON; diff --git a/core/prisma/schema.prisma b/core/prisma/schema.prisma index 24d3bbedc..ab7b8c337 100644 --- a/core/prisma/schema.prisma +++ b/core/prisma/schema.prisma @@ -293,20 +293,25 @@ model Object { // } model MediaData { - id Int @id - pixel_width Int? - pixel_height Int? - longitude Float? - latitude Float? - fps Int? - capture_device_make String? // eg: "Apple" - capture_device_model String? // eg: "iPhone 12" - capture_device_software String? // eg: "12.1.1" - duration_seconds Int? - codecs String? // eg: "h264,acc" - streams Int? + id Int @id @default(autoincrement()) - object Object? @relation(fields: [id], references: [id], onDelete: Cascade) + dimensions Bytes? + media_date Bytes? + media_location Bytes? + camera_data Bytes? + artist String? + description String? + copyright String? + exif_version String? + + // video-specific + // duration Int? + // fps Int? + // streams Int? + // codecs String? // eg: "h264,acc" + + object_id Int @unique + object Object @relation(fields: [object_id], references: [id], onDelete: Cascade) @@map("media_data") } diff --git a/core/src/api/files.rs b/core/src/api/files.rs index add34c551..cdc05f9f5 100644 --- a/core/src/api/files.rs +++ b/core/src/api/files.rs @@ -9,9 +9,12 @@ use crate::{ }, find_location, LocationError, }, - object::fs::{ - copy::FileCopierJobInit, cut::FileCutterJobInit, delete::FileDeleterJobInit, - erase::FileEraserJobInit, + object::{ + fs::{ + copy::FileCopierJobInit, cut::FileCutterJobInit, delete::FileDeleterJobInit, + erase::FileEraserJobInit, + }, + media::media_data_image_from_prisma_data, }, prisma::{file_path, location, object}, util::{db::maybe_missing, error::FileIOError}, @@ -23,6 +26,8 @@ use chrono::Utc; use futures::future::join_all; use regex::Regex; use rspc::{alpha::AlphaRouter, ErrorCode}; +use sd_file_ext::kind::ObjectKind; +use sd_media_metadata::MediaMetadata; use serde::Deserialize; use specta::Type; use tokio::{fs, io}; @@ -43,11 +48,36 @@ pub(crate) fn mount() -> AlphaRouter { .db .object() .find_unique(object::id::equals(args.id)) - .include(object::include!({ file_paths media_data })) + .include(object::include!({ file_paths })) .exec() .await?) }) }) + .procedure("getMediaData", { + R.with2(library()) + .query(|(_, library), args: object::id::Type| async move { + library + .db + .object() + .find_unique(object::id::equals(args)) + .select(object::select!({ id kind media_data })) + .exec() + .await? + .and_then(|obj| { + Some(match obj.kind { + Some(v) if v == ObjectKind::Image as i32 => { + MediaMetadata::Image(Box::new( + media_data_image_from_prisma_data(obj.media_data?).ok()?, + )) + } + _ => return None, // TODO(brxken128): audio and video + }) + }) + .ok_or_else(|| { + rspc::Error::new(ErrorCode::NotFound, "Object not found".to_string()) + }) + }) + }) .procedure("getPath", { R.with2(library()) .query(|(_, library), id: i32| async move { diff --git a/core/src/api/jobs.rs b/core/src/api/jobs.rs index 1ee8878fc..520bb0626 100644 --- a/core/src/api/jobs.rs +++ b/core/src/api/jobs.rs @@ -3,8 +3,7 @@ use crate::{ job::{job_without_data, Job, JobReport, JobStatus, Jobs}, location::{find_location, LocationError}, object::{ - file_identifier::file_identifier_job::FileIdentifierJobInit, - preview::thumbnailer_job::ThumbnailerJobInit, + file_identifier::file_identifier_job::FileIdentifierJobInit, media::MediaProcessorJobInit, validation::validator_job::ObjectValidatorJobInit, }, prisma::{job, location, SortOrder}, @@ -236,7 +235,7 @@ pub(crate) fn mount() -> AlphaRouter { return Err(LocationError::IdNotFound(args.id).into()); }; - Job::new(ThumbnailerJobInit { + Job::new(MediaProcessorJobInit { location, sub_path: Some(args.path), }) diff --git a/core/src/api/search.rs b/core/src/api/search.rs index 61ec34108..a812f6ee1 100644 --- a/core/src/api/search.rs +++ b/core/src/api/search.rs @@ -8,7 +8,7 @@ use crate::{ file_path_helper::{check_file_path_exists, IsolatedFilePathData}, non_indexed, LocationError, }, - object::preview::get_thumb_key, + object::media::thumbnail::get_thumb_key, prisma::{self, file_path, location, object, tag, tag_on_object, PrismaClient}, }; diff --git a/core/src/job/error.rs b/core/src/job/error.rs index e296b7c57..f9ebb4203 100644 --- a/core/src/job/error.rs +++ b/core/src/job/error.rs @@ -2,7 +2,7 @@ use crate::{ location::{indexer::IndexerError, LocationError}, object::{ file_identifier::FileIdentifierJobError, fs::error::FileSystemJobsError, - preview::ThumbnailerError, validation::ValidatorError, + media::media_processor::MediaProcessorError, validation::ValidatorError, }, util::{db::MissingFieldError, error::FileIOError}, }; @@ -43,20 +43,6 @@ pub enum JobError { FileIO(#[from] FileIOError), #[error("Location error: {0}")] Location(#[from] LocationError), - - // Specific job errors - #[error(transparent)] - Indexer(#[from] IndexerError), - #[error(transparent)] - ThumbnailError(#[from] ThumbnailerError), - #[error(transparent)] - IdentifierError(#[from] FileIdentifierJobError), - #[error(transparent)] - Validator(#[from] ValidatorError), - #[error(transparent)] - FileSystemJobsError(#[from] FileSystemJobsError), - #[error(transparent)] - CryptoError(#[from] CryptoError), #[error("missing-field: {0}")] MissingField(#[from] MissingFieldError), #[error("item of type '{0}' with id '{1}' is missing from the db")] @@ -64,6 +50,20 @@ pub enum JobError { #[error("Thumbnail skipped")] ThumbnailSkipped, + // Specific job errors + #[error(transparent)] + Indexer(#[from] IndexerError), + #[error(transparent)] + MediaProcessor(#[from] MediaProcessorError), + #[error(transparent)] + FileIdentifier(#[from] FileIdentifierJobError), + #[error(transparent)] + Validator(#[from] ValidatorError), + #[error(transparent)] + FileSystemJobsError(#[from] FileSystemJobsError), + #[error(transparent)] + CryptoError(#[from] CryptoError), + // Not errors #[error("job had a early finish: ")] EarlyFinish { name: String, reason: String }, diff --git a/core/src/job/manager.rs b/core/src/job/manager.rs index 0cb947532..b4829762d 100644 --- a/core/src/job/manager.rs +++ b/core/src/job/manager.rs @@ -8,7 +8,7 @@ use crate::{ copy::FileCopierJobInit, cut::FileCutterJobInit, delete::FileDeleterJobInit, erase::FileEraserJobInit, }, - preview::thumbnailer_job::ThumbnailerJobInit, + media::media_processor::MediaProcessorJobInit, validation::validator_job::ObjectValidatorJobInit, }, prisma::job, @@ -388,7 +388,7 @@ fn initialize_resumable_job( Err(JobError::UnknownJobName(job_report.id, job_report.name)) }, jobs = [ - ThumbnailerJobInit, + MediaProcessorJobInit, IndexerJobInit, FileIdentifierJobInit, ObjectValidatorJobInit, diff --git a/core/src/job/mod.rs b/core/src/job/mod.rs index 2320b8f4c..5c5ff0412 100644 --- a/core/src/job/mod.rs +++ b/core/src/job/mod.rs @@ -30,9 +30,15 @@ pub type JobMetadata = Option; #[derive(Debug, Default)] pub struct JobRunErrors(pub Vec); -impl From> for JobRunErrors { - fn from(errors: Vec) -> Self { - Self(errors) +impl> From for JobRunErrors { + fn from(errors: I) -> Self { + Self(errors.into_iter().collect()) + } +} + +impl fmt::Display for JobRunErrors { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0.join("\n")) } } @@ -63,6 +69,7 @@ pub trait StatefulJob: /// The name of the job is a unique human readable identifier for the job. const NAME: &'static str; const IS_BACKGROUND: bool = false; + const IS_BATCHED: bool = false; /// initialize the steps for the job async fn init( @@ -290,10 +297,13 @@ impl From<(RunMetadata, Vec)> for JobInitOutput From> for JobInitOutput<(), Step> { +impl From> for JobInitOutput +where + RunMetadata: Default, +{ fn from(steps: Vec) -> Self { Self { - run_metadata: (), + run_metadata: RunMetadata::default(), steps: VecDeque::from(steps), errors: Default::default(), } @@ -365,6 +375,18 @@ impl From<(Vec, RunMetadata)> } } +impl From<(RunMetadata, JobRunErrors)> + for JobStepOutput +{ + fn from((more_metadata, errors): (RunMetadata, JobRunErrors)) -> Self { + Self { + maybe_more_steps: None, + maybe_more_metadata: Some(more_metadata), + errors, + } + } +} + impl From<(Vec, RunMetadata, JobRunErrors)> for JobStepOutput { @@ -456,11 +478,9 @@ impl DynJob for Job { let res = stateful_job.init(&inner_ctx, &mut new_data).await; if let Ok(res) = res.as_ref() { - inner_ctx.progress(vec![JobReportUpdate::TaskCount(res.steps.len())]); - } - - if let Ok(res) = res.as_ref() { - inner_ctx.progress(vec![JobReportUpdate::TaskCount(res.steps.len())]); + if !::IS_BATCHED { + inner_ctx.progress(vec![JobReportUpdate::TaskCount(res.steps.len())]); + } } (new_data, res) @@ -801,7 +821,9 @@ impl DynJob for Job { run_metadata.update(more_metadata); } - ctx.progress(events); + if !::IS_BATCHED { + ctx.progress(events); + } if !new_errors.is_empty() { warn!("Job had a step with errors"); diff --git a/core/src/library/library.rs b/core/src/library/library.rs index 8ae20a234..bcc025c5e 100644 --- a/core/src/library/library.rs +++ b/core/src/library/library.rs @@ -5,7 +5,7 @@ use crate::{ }, location::file_path_helper::{file_path_to_full_path, IsolatedFilePathData}, notifications, - object::{orphan_remover::OrphanRemoverActor, preview::get_thumbnail_path}, + object::{media::thumbnail::get_thumbnail_path, orphan_remover::OrphanRemoverActor}, prisma::{file_path, location, PrismaClient}, sync, util::{db::maybe_missing, error::FileIOError}, diff --git a/core/src/location/file_path_helper/isolated_file_path_data.rs b/core/src/location/file_path_helper/isolated_file_path_data.rs index b14c39e9c..f4d889803 100644 --- a/core/src/location/file_path_helper/isolated_file_path_data.rs +++ b/core/src/location/file_path_helper/isolated_file_path_data.rs @@ -14,7 +14,7 @@ use regex::RegexSet; use serde::{Deserialize, Serialize}; use super::{ - file_path_for_file_identifier, file_path_for_object_validator, file_path_for_thumbnailer, + file_path_for_file_identifier, file_path_for_media_processor, file_path_for_object_validator, file_path_to_full_path, file_path_to_handle_custom_uri, file_path_to_isolate, file_path_to_isolate_with_id, file_path_walker, file_path_with_object, FilePathError, }; @@ -454,7 +454,7 @@ impl_from_db!( impl_from_db_without_location_id!( file_path_for_file_identifier, file_path_to_full_path, - file_path_for_thumbnailer, + file_path_for_media_processor, file_path_for_object_validator, file_path_to_handle_custom_uri ); diff --git a/core/src/location/file_path_helper/mod.rs b/core/src/location/file_path_helper/mod.rs index 8e5775e9b..721581daf 100644 --- a/core/src/location/file_path_helper/mod.rs +++ b/core/src/location/file_path_helper/mod.rs @@ -45,12 +45,14 @@ file_path::select!(file_path_for_object_validator { extension integrity_checksum }); -file_path::select!(file_path_for_thumbnailer { +file_path::select!(file_path_for_media_processor { + id materialized_path is_dir name extension cas_id + object_id }); file_path::select!(file_path_to_isolate { location_id diff --git a/core/src/location/indexer/indexer_job.rs b/core/src/location/indexer/indexer_job.rs index 7f139a7c3..d4c7273bd 100644 --- a/core/src/location/indexer/indexer_job.rs +++ b/core/src/location/indexer/indexer_job.rs @@ -134,6 +134,7 @@ impl StatefulJob for IndexerJobInit { type RunMetadata = IndexerJobRunMetadata; const NAME: &'static str = "indexer"; + const IS_BATCHED: bool = true; /// Creates a vector of valid path buffers from a directory, chunked into batches of `BATCH_SIZE`. async fn init( diff --git a/core/src/location/manager/watcher/utils.rs b/core/src/location/manager/watcher/utils.rs index aea74b5a3..1a3f1b905 100644 --- a/core/src/location/manager/watcher/utils.rs +++ b/core/src/location/manager/watcher/utils.rs @@ -15,7 +15,13 @@ use crate::{ scan_location_sub_path, }, object::{ - file_identifier::FileMetadata, preview::get_thumbnail_path, validation::hash::file_checksum, + file_identifier::FileMetadata, + media::{ + media_data_extractor::{can_extract_media_data_for_image, extract_media_data}, + media_data_image_to_query, + thumbnail::get_thumbnail_path, + }, + validation::hash::file_checksum, }, prisma::{file_path, location, object}, util::{ @@ -36,9 +42,12 @@ use std::{ ffi::OsStr, fs::Metadata, path::{Path, PathBuf}, + str::FromStr, sync::Arc, }; +use sd_file_ext::{extensions::ImageExtension, kind::ObjectKind}; + use chrono::{DateTime, Local, Utc}; use notify::Event; use prisma_client_rust::{raw, PrismaValue}; @@ -46,7 +55,7 @@ use sd_prisma::prisma_sync; use sd_sync::OperationFactory; use serde_json::json; use tokio::{fs, io::ErrorKind}; -use tracing::{debug, trace, warn}; +use tracing::{debug, error, trace, warn}; use uuid::Uuid; use super::INodeAndDevice; @@ -309,14 +318,36 @@ async fn inner_create_file( .exec() .await?; - if !extension.is_empty() { + if !extension.is_empty() && matches!(kind, ObjectKind::Image | ObjectKind::Video) { // Running in a detached task as thumbnail generation can take a while and we don't want to block the watcher - let path = path.to_path_buf(); + let inner_path = path.to_path_buf(); let node = node.clone(); - + let inner_extension = extension.clone(); tokio::spawn(async move { - generate_thumbnail(&extension, &cas_id, path, &node).await; + generate_thumbnail(&inner_extension, &cas_id, inner_path, &node).await; }); + + // TODO: Currently we only extract media data for images, remove this if later + if matches!(kind, ObjectKind::Image) { + if let Ok(image_extension) = ImageExtension::from_str(&extension) { + if can_extract_media_data_for_image(&image_extension) { + if let Ok(media_data) = extract_media_data(path) + .await + .map_err(|e| error!("Failed to extract media data: {e:#?}")) + { + if let Ok(media_data_params) = + media_data_image_to_query(media_data, object.id).map_err(|e| { + error!("Failed to prepare media data create params: {e:#?}") + }) { + db.media_data() + .create_many(vec![media_data_params]) + .exec() + .await?; + } + } + } + } + } } invalidate_query!(library, "search.paths"); @@ -533,8 +564,13 @@ async fn inner_update_file( if let Some(ref object) = file_path.object { // if this file had a thumbnail previously, we update it to match the new content if library.thumbnail_exists(node, old_cas_id).await? { - if let Some(ext) = &file_path.extension { - generate_thumbnail(ext, &cas_id, full_path, node).await; + if let Some(ext) = file_path.extension.clone() { + // Running in a detached task as thumbnail generation can take a while and we don't want to block the watcher + let inner_path = full_path.to_path_buf(); + let inner_node = node.clone(); + tokio::spawn(async move { + generate_thumbnail(&ext, &cas_id, inner_path, &inner_node).await; + }); // remove the old thumbnail as we're generating a new one let thumb_path = get_thumbnail_path(node, old_cas_id); @@ -563,6 +599,30 @@ async fn inner_update_file( ) .await?; } + + // TODO: Change this if to include ObjectKind::Video in the future + if let Some(ext) = &file_path.extension { + if let Ok(image_extension) = ImageExtension::from_str(ext) { + if can_extract_media_data_for_image(&image_extension) + && matches!(kind, ObjectKind::Image) + { + if let Ok(media_data) = extract_media_data(full_path) + .await + .map_err(|e| error!("Failed to extract media data: {e:#?}")) + { + if let Ok(media_data_params) = + media_data_image_to_query(media_data, object.id).map_err(|e| { + error!("Failed to prepare media data create params: {e:#?}") + }) { + db.media_data() + .create_many(vec![media_data_params]) + .exec() + .await?; + } + } + } + } + } } invalidate_query!(library, "search.paths"); diff --git a/core/src/location/mod.rs b/core/src/location/mod.rs index 003a8c520..60e759471 100644 --- a/core/src/location/mod.rs +++ b/core/src/location/mod.rs @@ -6,9 +6,13 @@ use crate::{ location::file_path_helper::filter_existing_file_path_params, object::{ file_identifier::{self, file_identifier_job::FileIdentifierJobInit}, - preview::{ - can_generate_thumbnail_for_image, generate_image_thumbnail, get_thumb_key, - get_thumbnail_path, shallow_thumbnailer, thumbnailer_job::ThumbnailerJobInit, + media::{ + media_processor, + thumbnail::{ + can_generate_thumbnail_for_image, generate_image_thumbnail, get_thumb_key, + get_thumbnail_path, + }, + MediaProcessorJobInit, }, }, prisma::{file_path, indexer_rules_in_location, location, PrismaClient}, @@ -405,7 +409,7 @@ pub async fn scan_location( location: location_base_data.clone(), sub_path: None, }) - .queue_next(ThumbnailerJobInit { + .queue_next(MediaProcessorJobInit { location: location_base_data, sub_path: None, }) @@ -443,7 +447,7 @@ pub async fn scan_location_sub_path( location: location_base_data.clone(), sub_path: Some(sub_path.clone()), }) - .queue_next(ThumbnailerJobInit { + .queue_next(MediaProcessorJobInit { location: location_base_data, sub_path: Some(sub_path), }) @@ -469,7 +473,7 @@ pub async fn light_scan_location( indexer::shallow(&location, &sub_path, &node, &library).await?; file_identifier::shallow(&location_base_data, &sub_path, &library).await?; - shallow_thumbnailer(&location_base_data, &sub_path, &library, &node).await?; + media_processor::shallow(&location_base_data, &sub_path, &library, &node).await?; Ok(()) } @@ -873,7 +877,9 @@ pub(super) async fn generate_thumbnail( #[cfg(feature = "ffmpeg")] { - use crate::object::preview::{can_generate_thumbnail_for_video, generate_video_thumbnail}; + use crate::object::media::thumbnail::{ + can_generate_thumbnail_for_video, generate_video_thumbnail, + }; use sd_file_ext::extensions::VideoExtension; if let Ok(extension) = VideoExtension::from_str(extension) { diff --git a/core/src/location/non_indexed.rs b/core/src/location/non_indexed.rs index 5cceee63f..4c4464784 100644 --- a/core/src/location/non_indexed.rs +++ b/core/src/location/non_indexed.rs @@ -1,7 +1,7 @@ use crate::{ api::locations::ExplorerItem, library::Library, - object::{cas::generate_cas_id, preview::get_thumb_key}, + object::{cas::generate_cas_id, media::thumbnail::get_thumb_key}, prisma::location, util::error::FileIOError, Node, diff --git a/core/src/object/file_identifier/file_identifier_job.rs b/core/src/object/file_identifier/file_identifier_job.rs index 96798e709..5e267736b 100644 --- a/core/src/object/file_identifier/file_identifier_job.rs +++ b/core/src/object/file_identifier/file_identifier_job.rs @@ -1,7 +1,7 @@ use crate::{ job::{ - CurrentStep, JobError, JobInitOutput, JobResult, JobRunMetadata, JobStepOutput, - StatefulJob, WorkerContext, + CurrentStep, JobError, JobInitOutput, JobReportUpdate, JobResult, JobRunMetadata, + JobStepOutput, StatefulJob, WorkerContext, }, library::Library, location::file_path_helper::{ @@ -75,6 +75,7 @@ impl StatefulJob for FileIdentifierJobInit { type RunMetadata = FileIdentifierJobRunMetadata; const NAME: &'static str = "file_identifier"; + const IS_BATCHED: bool = true; async fn init( &self, @@ -152,7 +153,12 @@ impl StatefulJob for FileIdentifierJobInit { .select(file_path::select!({ id })) .exec() .await? - .expect("We already validated before that there are orphans `file_path`s"); // SAFETY: We already validated before that there are orphans `file_path`s + .expect("We already validated before that there are orphans `file_path`s"); + + ctx.progress(vec![ + JobReportUpdate::TaskCount(orphan_count), + JobReportUpdate::Message(format!("Found {orphan_count} files to be identified")), + ]); Ok(( FileIdentifierJobRunMetadata { @@ -211,11 +217,14 @@ impl StatefulJob for FileIdentifierJobInit { new_metadata.total_objects_linked = total_objects_linked; new_metadata.cursor = new_cursor; - ctx.progress_msg(format!( - "Processed {} of {} orphan Paths", - step_number * CHUNK_SIZE, - run_metadata.total_orphan_paths - )); + ctx.progress(vec![ + JobReportUpdate::CompletedTaskCount(step_number * CHUNK_SIZE + file_paths.len()), + JobReportUpdate::Message(format!( + "Processed {} of {} orphan Paths", + step_number * CHUNK_SIZE, + run_metadata.total_orphan_paths + )), + ]); Ok(new_metadata.into()) } diff --git a/core/src/object/file_identifier/mod.rs b/core/src/object/file_identifier/mod.rs index f344b169c..d9dc25a3f 100644 --- a/core/src/object/file_identifier/mod.rs +++ b/core/src/object/file_identifier/mod.rs @@ -10,17 +10,19 @@ use crate::{ }; use sd_file_ext::{extensions::Extension, kind::ObjectKind}; + use sd_prisma::prisma_sync; use sd_sync::{CRDTOperation, OperationFactory}; +use sd_utils::uuid_to_bytes; use std::{ collections::{HashMap, HashSet}, + fmt::Debug, path::Path, }; use futures::future::join_all; use serde_json::json; -use thiserror::Error; use tokio::fs; use tracing::{error, trace}; use uuid::Uuid; @@ -33,7 +35,7 @@ pub use shallow::*; // we break these jobs into chunks of 100 to improve performance const CHUNK_SIZE: usize = 100; -#[derive(Error, Debug)] +#[derive(thiserror::Error, Debug)] pub enum FileIdentifierJobError { #[error("received sub path not in database: ", .0.display())] SubPathNotFound(Box), @@ -227,7 +229,6 @@ async fn identifier_job_step( .iter() .map(|(file_path_pub_id, (meta, fp))| { let object_pub_id = Uuid::new_v4(); - let sync_id = || prisma_sync::object::SyncId { pub_id: sd_utils::uuid_to_bytes(object_pub_id), }; @@ -249,7 +250,7 @@ async fn identifier_job_step( let object_creation_args = ( sync.shared_create(sync_id(), sync_params), - object::create_unchecked(sd_utils::uuid_to_bytes(object_pub_id), db_params), + object::create_unchecked(uuid_to_bytes(object_pub_id), db_params), ); (object_creation_args, { diff --git a/core/src/object/media/media_data_extractor.rs b/core/src/object/media/media_data_extractor.rs new file mode 100644 index 000000000..72131056d --- /dev/null +++ b/core/src/object/media/media_data_extractor.rs @@ -0,0 +1,167 @@ +use crate::{ + job::JobRunErrors, + location::file_path_helper::{file_path_for_media_processor, IsolatedFilePathData}, + prisma::{location, media_data, PrismaClient}, + util::error::FileIOError, +}; + +use sd_file_ext::extensions::{Extension, ImageExtension, ALL_IMAGE_EXTENSIONS}; +use sd_media_metadata::ImageMetadata; + +use std::{collections::HashSet, path::Path}; + +use futures_concurrency::future::Join; +use once_cell::sync::Lazy; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use tokio::task::spawn_blocking; +use tracing::error; + +use super::media_data_image_to_query; + +#[derive(Error, Debug)] +pub enum MediaDataError { + // Internal errors + #[error("database error: {0}")] + Database(#[from] prisma_client_rust::QueryError), + #[error(transparent)] + FileIO(#[from] FileIOError), + #[error(transparent)] + MediaData(#[from] sd_media_metadata::Error), + #[error("failed to join tokio task: {0}")] + TokioJoinHandle(#[from] tokio::task::JoinError), +} + +#[derive(Serialize, Deserialize, Default, Debug)] +pub struct MediaDataExtractorMetadata { + pub extracted: u32, + pub skipped: u32, +} + +pub(super) static FILTERED_IMAGE_EXTENSIONS: Lazy> = Lazy::new(|| { + ALL_IMAGE_EXTENSIONS + .iter() + .cloned() + .filter(can_extract_media_data_for_image) + .map(Extension::Image) + .collect() +}); + +pub const fn can_extract_media_data_for_image(image_extension: &ImageExtension) -> bool { + use ImageExtension::*; + matches!( + image_extension, + Tiff | Dng | Jpeg | Jpg | Heif | Heifs | Heic | Avif | Avcs | Avci | Hif | Png | Webp + ) +} + +pub async fn extract_media_data(path: impl AsRef) -> Result { + let path = path.as_ref().to_path_buf(); + + // Running in a separated blocking thread due to MediaData blocking behavior (due to sync exif lib) + spawn_blocking(|| ImageMetadata::from_path(path)) + .await? + .map_err(Into::into) +} + +pub async fn process( + files_paths: impl IntoIterator, + location_id: location::id::Type, + location_path: impl AsRef, + db: &PrismaClient, +) -> Result<(MediaDataExtractorMetadata, JobRunErrors), MediaDataError> { + let mut run_metadata = MediaDataExtractorMetadata::default(); + let files_paths = files_paths.into_iter().collect::>(); + if files_paths.is_empty() { + return Ok((run_metadata, JobRunErrors::default())); + } + + let location_path = location_path.as_ref(); + + let objects_already_with_media_data = db + .media_data() + .find_many(vec![media_data::object_id::in_vec( + files_paths + .iter() + .filter_map(|file_path| file_path.object_id) + .collect(), + )]) + .select(media_data::select!({ object_id })) + .exec() + .await? + .into_iter() + .map(|media_data| media_data.object_id) + .collect::>(); + + run_metadata.skipped = objects_already_with_media_data.len() as u32; + + let (media_datas, errors) = { + let maybe_media_data = files_paths + .into_iter() + .filter_map(|file_path| { + file_path.object_id.and_then(|object_id| { + (!objects_already_with_media_data.contains(&object_id)) + .then_some((file_path, object_id)) + }) + }) + .filter_map(|(file_path, object_id)| { + IsolatedFilePathData::try_from((location_id, file_path)) + .map_err(|e| error!("{e:#?}")) + .ok() + .map(|iso_file_path| (location_path.join(iso_file_path), object_id)) + }) + .map( + |(path, object_id)| async move { (extract_media_data(&path).await, path, object_id) }, + ) + .collect::>() + .join() + .await; + + let total_media_data = maybe_media_data.len(); + + maybe_media_data.into_iter().fold( + // In the good case, all media data were extracted + (Vec::with_capacity(total_media_data), Vec::new()), + |(mut media_datas, mut errors), (maybe_media_data, path, object_id)| { + match maybe_media_data { + Ok(media_data) => media_datas.push((media_data, object_id)), + Err(MediaDataError::MediaData(sd_media_metadata::Error::NoExifDataOnPath( + _, + ))) => { + // No exif data on path, skipping + run_metadata.skipped += 1; + } + Err(e) => errors.push((e, path)), + } + (media_datas, errors) + }, + ) + }; + + let created = db + .media_data() + .create_many( + media_datas + .into_iter() + .filter_map(|(media_data, object_id)| { + media_data_image_to_query(media_data, object_id) + .map_err(|e| error!("{e:#?}")) + .ok() + }) + .collect(), + ) + .exec() + .await?; + + run_metadata.extracted = created as u32; + run_metadata.skipped += errors.len() as u32; + + Ok(( + run_metadata, + errors + .into_iter() + .map(|(e, path)| format!("Couldn't process file: \"{}\"; Error: {e}", path.display())) + .collect::>() + .into(), + )) +} diff --git a/core/src/object/media/media_processor/job.rs b/core/src/object/media/media_processor/job.rs new file mode 100644 index 000000000..2baf2409e --- /dev/null +++ b/core/src/object/media/media_processor/job.rs @@ -0,0 +1,270 @@ +use crate::{ + invalidate_query, + job::{ + CurrentStep, JobError, JobInitOutput, JobReportUpdate, JobResult, JobStepOutput, + StatefulJob, WorkerContext, + }, + library::Library, + location::file_path_helper::{ + ensure_file_path_exists, ensure_sub_path_is_directory, ensure_sub_path_is_in_location, + file_path_for_media_processor, IsolatedFilePathData, + }, + object::media::media_data_extractor, + object::media::thumbnail::{self, init_thumbnail_dir}, + prisma::{location, PrismaClient}, + util::db::maybe_missing, +}; + +use std::{ + collections::HashMap, + hash::Hash, + path::{Path, PathBuf}, +}; + +use itertools::Itertools; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use tracing::{debug, info}; + +use super::{ + get_all_children_files_by_extensions, process, MediaProcessorEntry, MediaProcessorEntryKind, + MediaProcessorError, MediaProcessorMetadata, ThumbnailerEntryKind, +}; + +const BATCH_SIZE: usize = 10; + +#[derive(Serialize, Deserialize, Debug)] +pub struct MediaProcessorJobInit { + pub location: location::Data, + pub sub_path: Option, +} + +impl Hash for MediaProcessorJobInit { + fn hash(&self, state: &mut H) { + self.location.id.hash(state); + if let Some(ref sub_path) = self.sub_path { + sub_path.hash(state); + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct MediaProcessorJobData { + thumbnails_base_dir: PathBuf, + location_path: PathBuf, + to_process_path: PathBuf, +} + +type MediaProcessorJobStep = Vec; + +#[async_trait::async_trait] +impl StatefulJob for MediaProcessorJobInit { + type Data = MediaProcessorJobData; + type Step = MediaProcessorJobStep; + type RunMetadata = MediaProcessorMetadata; + + const NAME: &'static str = "media_processor"; + const IS_BATCHED: bool = true; + + async fn init( + &self, + ctx: &WorkerContext, + data: &mut Option, + ) -> Result, JobError> { + let Library { db, .. } = ctx.library.as_ref(); + + let thumbnails_base_dir = init_thumbnail_dir(ctx.node.config.data_directory()) + .await + .map_err(MediaProcessorError::from)?; + + let location_id = self.location.id; + let location_path = + maybe_missing(&self.location.path, "location.path").map(PathBuf::from)?; + + let (to_process_path, iso_file_path) = match &self.sub_path { + Some(sub_path) if sub_path != Path::new("") => { + let full_path = ensure_sub_path_is_in_location(&location_path, sub_path) + .await + .map_err(MediaProcessorError::from)?; + ensure_sub_path_is_directory(&location_path, sub_path) + .await + .map_err(MediaProcessorError::from)?; + + let sub_iso_file_path = + IsolatedFilePathData::new(location_id, &location_path, &full_path, true) + .map_err(MediaProcessorError::from)?; + + ensure_file_path_exists( + sub_path, + &sub_iso_file_path, + db, + MediaProcessorError::SubPathNotFound, + ) + .await?; + + (full_path, sub_iso_file_path) + } + _ => ( + location_path.to_path_buf(), + IsolatedFilePathData::new(location_id, &location_path, &location_path, true) + .map_err(MediaProcessorError::from)?, + ), + }; + + debug!( + "Searching for media files in location {location_id} at directory \"{iso_file_path}\"" + ); + + let thumbnailer_files = get_files_for_thumbnailer(db, &iso_file_path).await?; + + let mut media_data_files_map = get_files_for_media_data_extraction(db, &iso_file_path) + .await? + .map(|file_path| (file_path.id, file_path)) + .collect::>(); + + let mut total_files_for_thumbnailer = 0; + + let chunked_files = thumbnailer_files + .into_iter() + .map(|(file_path, thumb_kind)| { + total_files_for_thumbnailer += 1; + MediaProcessorEntry { + operation_kind: if media_data_files_map.remove(&file_path.id).is_some() { + MediaProcessorEntryKind::MediaDataAndThumbnailer(thumb_kind) + } else { + MediaProcessorEntryKind::Thumbnailer(thumb_kind) + }, + file_path, + } + }) + .collect::>() + .into_iter() + .chain( + media_data_files_map + .into_values() + .map(|file_path| MediaProcessorEntry { + operation_kind: MediaProcessorEntryKind::MediaData, + file_path, + }), + ) + .chunks(BATCH_SIZE) + .into_iter() + .map(|chunk| chunk.collect::>()) + .collect::>(); + + ctx.progress(vec![ + JobReportUpdate::TaskCount(total_files_for_thumbnailer), + JobReportUpdate::Message(format!( + "Preparing to process {total_files_for_thumbnailer} files in {} chunks", + chunked_files.len() + )), + ]); + + *data = Some(MediaProcessorJobData { + thumbnails_base_dir, + location_path, + to_process_path, + }); + + Ok(chunked_files.into()) + } + + async fn execute_step( + &self, + ctx: &WorkerContext, + CurrentStep { step, step_number }: CurrentStep<'_, Self::Step>, + data: &Self::Data, + _: &Self::RunMetadata, + ) -> Result, JobError> { + process( + step, + self.location.id, + &data.location_path, + &data.thumbnails_base_dir, + &ctx.library, + |completed_count| { + ctx.progress(vec![JobReportUpdate::CompletedTaskCount( + step_number * BATCH_SIZE + completed_count, + )]); + }, + ) + .await + .map(Into::into) + .map_err(Into::into) + } + + async fn finalize( + &self, + ctx: &WorkerContext, + data: &Option, + run_metadata: &Self::RunMetadata, + ) -> JobResult { + info!( + "Finished media processing for location {} at {}", + self.location.id, + data.as_ref() + .expect("critical error: missing data on job state") + .to_process_path + .display() + ); + + if run_metadata.thumbnailer.created > 0 || run_metadata.media_data.extracted > 0 { + invalidate_query!(ctx.library, "search.paths"); + } + + Ok(Some(json!({"init: ": self, "run_metadata": run_metadata}))) + } +} + +async fn get_files_for_thumbnailer( + db: &PrismaClient, + parent_iso_file_path: &IsolatedFilePathData<'_>, +) -> Result< + impl Iterator, + MediaProcessorError, +> { + // query database for all image files in this location that need thumbnails + let image_thumb_files = get_all_children_files_by_extensions( + db, + parent_iso_file_path, + &thumbnail::FILTERED_IMAGE_EXTENSIONS, + ) + .await? + .into_iter() + .map(|file_path| (file_path, ThumbnailerEntryKind::Image)); + + #[cfg(feature = "ffmpeg")] + let all_files = { + // query database for all video files in this location that need thumbnails + let video_files = get_all_children_files_by_extensions( + db, + parent_iso_file_path, + &thumbnail::FILTERED_VIDEO_EXTENSIONS, + ) + .await?; + + image_thumb_files.chain( + video_files + .into_iter() + .map(|file_path| (file_path, ThumbnailerEntryKind::Video)), + ) + }; + #[cfg(not(feature = "ffmpeg"))] + let all_files = { image_thumb_files }; + + Ok(all_files) +} + +async fn get_files_for_media_data_extraction( + db: &PrismaClient, + parent_iso_file_path: &IsolatedFilePathData<'_>, +) -> Result, MediaProcessorError> { + get_all_children_files_by_extensions( + db, + parent_iso_file_path, + &media_data_extractor::FILTERED_IMAGE_EXTENSIONS, + ) + .await + .map(|file_paths| file_paths.into_iter()) + .map_err(Into::into) +} diff --git a/core/src/object/media/media_processor/mod.rs b/core/src/object/media/media_processor/mod.rs new file mode 100644 index 000000000..7f5bff23a --- /dev/null +++ b/core/src/object/media/media_processor/mod.rs @@ -0,0 +1,188 @@ +use crate::{ + job::{JobRunErrors, JobRunMetadata}, + library::Library, + location::file_path_helper::{ + file_path_for_media_processor, FilePathError, IsolatedFilePathData, + }, +}; + +use sd_file_ext::extensions::Extension; +use sd_prisma::prisma::{file_path, location, PrismaClient}; + +use std::path::Path; + +use futures_concurrency::future::TryJoin; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use super::{ + media_data_extractor::{self, MediaDataError, MediaDataExtractorMetadata}, + thumbnail::{self, ThumbnailerEntryKind, ThumbnailerError, ThumbnailerMetadata}, +}; + +mod job; +mod shallow; + +pub use job::MediaProcessorJobInit; +pub use shallow::shallow; + +#[derive(Error, Debug)] +pub enum MediaProcessorError { + #[error("sub path not found: ", .0.display())] + SubPathNotFound(Box), + + #[error("database error: {0}")] + Database(#[from] prisma_client_rust::QueryError), + #[error(transparent)] + FilePath(#[from] FilePathError), + + #[error(transparent)] + Thumbnailer(#[from] ThumbnailerError), + #[error(transparent)] + MediaDataExtractor(#[from] MediaDataError), +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy)] +pub enum MediaProcessorEntryKind { + MediaData, + Thumbnailer(ThumbnailerEntryKind), + MediaDataAndThumbnailer(ThumbnailerEntryKind), +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct MediaProcessorEntry { + file_path: file_path_for_media_processor::Data, + operation_kind: MediaProcessorEntryKind, +} + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct MediaProcessorMetadata { + media_data: MediaDataExtractorMetadata, + thumbnailer: ThumbnailerMetadata, +} + +impl JobRunMetadata for MediaProcessorMetadata { + fn update(&mut self, new_data: Self) { + self.media_data.extracted += new_data.media_data.extracted; + self.media_data.skipped += new_data.media_data.skipped; + + self.thumbnailer.created += new_data.thumbnailer.created; + self.thumbnailer.skipped += new_data.thumbnailer.skipped; + } +} + +async fn get_all_children_files_by_extensions( + db: &PrismaClient, + parent_iso_file_path: &IsolatedFilePathData<'_>, + extensions: &[Extension], +) -> Result, MediaProcessorError> { + db.file_path() + .find_many(vec![ + file_path::location_id::equals(Some(parent_iso_file_path.location_id())), + file_path::extension::in_vec(extensions.iter().map(ToString::to_string).collect()), + file_path::materialized_path::starts_with( + parent_iso_file_path + .materialized_path_for_children() + .expect("sub path iso_file_path must be a directory"), + ), + ]) + .select(file_path_for_media_processor::select()) + .exec() + .await + .map_err(Into::into) +} + +async fn get_files_by_extensions( + db: &PrismaClient, + parent_iso_file_path: &IsolatedFilePathData<'_>, + extensions: &[Extension], +) -> Result, MediaDataError> { + db.file_path() + .find_many(vec![ + file_path::location_id::equals(Some(parent_iso_file_path.location_id())), + file_path::extension::in_vec(extensions.iter().map(ToString::to_string).collect()), + file_path::materialized_path::equals(Some( + parent_iso_file_path + .materialized_path_for_children() + .expect("sub path iso_file_path must be a directory"), + )), + ]) + .select(file_path_for_media_processor::select()) + .exec() + .await + .map_err(Into::into) +} + +async fn process( + entries: &[MediaProcessorEntry], + location_id: location::id::Type, + location_path: impl AsRef, + thumbnails_base_dir: impl AsRef, + library: &Library, + ctx_update_fn: impl Fn(usize), +) -> Result<(MediaProcessorMetadata, JobRunErrors), MediaProcessorError> { + let location_path = location_path.as_ref(); + + let ((media_data_metadata, mut media_data_errors), (thumbnailer_metadata, thumbnailer_errors)) = + ( + async { + media_data_extractor::process( + entries.iter().filter_map( + |MediaProcessorEntry { + file_path, + operation_kind, + }| { + matches!( + operation_kind, + MediaProcessorEntryKind::MediaDataAndThumbnailer(_) + | MediaProcessorEntryKind::MediaData + ) + .then_some(file_path) + }, + ), + location_id, + location_path, + &library.db, + ) + .await + .map_err(MediaProcessorError::from) + }, + async { + thumbnail::process( + entries.iter().filter_map( + |MediaProcessorEntry { + file_path, + operation_kind, + }| { + if let MediaProcessorEntryKind::Thumbnailer(thumb_kind) + | MediaProcessorEntryKind::MediaDataAndThumbnailer(thumb_kind) = operation_kind + { + Some((file_path, *thumb_kind)) + } else { + None + } + }, + ), + location_id, + location_path, + thumbnails_base_dir, + library, + ctx_update_fn, + ) + .await + .map_err(MediaProcessorError::from) + }, + ) + .try_join() + .await?; + + media_data_errors.0.extend(thumbnailer_errors.0.into_iter()); + + Ok(( + MediaProcessorMetadata { + media_data: media_data_metadata, + thumbnailer: thumbnailer_metadata, + }, + media_data_errors, + )) +} diff --git a/core/src/object/media/media_processor/shallow.rs b/core/src/object/media/media_processor/shallow.rs new file mode 100644 index 000000000..8aed04028 --- /dev/null +++ b/core/src/object/media/media_processor/shallow.rs @@ -0,0 +1,196 @@ +use crate::{ + invalidate_query, + job::{JobError, JobRunMetadata}, + library::Library, + location::file_path_helper::{ + ensure_file_path_exists, ensure_sub_path_is_directory, ensure_sub_path_is_in_location, + file_path_for_media_processor, IsolatedFilePathData, + }, + object::media::{ + media_data_extractor, + thumbnail::{self, init_thumbnail_dir, ThumbnailerEntryKind}, + }, + prisma::{location, PrismaClient}, + util::db::maybe_missing, + Node, +}; + +use std::{ + collections::HashMap, + path::{Path, PathBuf}, +}; + +use itertools::Itertools; +use tracing::{debug, error, info}; + +use super::{ + get_files_by_extensions, process, MediaProcessorEntry, MediaProcessorEntryKind, + MediaProcessorError, MediaProcessorMetadata, +}; + +const BATCH_SIZE: usize = 10; + +pub async fn shallow( + location: &location::Data, + sub_path: &PathBuf, + library: &Library, + node: &Node, +) -> Result<(), JobError> { + let Library { db, .. } = library; + + let thumbnails_base_dir = init_thumbnail_dir(node.config.data_directory()) + .await + .map_err(MediaProcessorError::from)?; + + let location_id = location.id; + let location_path = maybe_missing(&location.path, "location.path").map(PathBuf::from)?; + + let iso_file_path = if sub_path != Path::new("") { + let full_path = ensure_sub_path_is_in_location(&location_path, &sub_path) + .await + .map_err(MediaProcessorError::from)?; + ensure_sub_path_is_directory(&location_path, &sub_path) + .await + .map_err(MediaProcessorError::from)?; + + let sub_iso_file_path = + IsolatedFilePathData::new(location_id, &location_path, &full_path, true) + .map_err(MediaProcessorError::from)?; + + ensure_file_path_exists( + &sub_path, + &sub_iso_file_path, + db, + MediaProcessorError::SubPathNotFound, + ) + .await?; + + sub_iso_file_path + } else { + IsolatedFilePathData::new(location_id, &location_path, &location_path, true) + .map_err(MediaProcessorError::from)? + }; + + debug!("Searching for images in location {location_id} at path {iso_file_path}"); + + let thumbnailer_files = get_files_for_thumbnailer(db, &iso_file_path).await?; + + let mut media_data_files_map = get_files_for_media_data_extraction(db, &iso_file_path) + .await? + .map(|file_path| (file_path.id, file_path)) + .collect::>(); + + let mut total_files = 0; + + let chunked_files = thumbnailer_files + .into_iter() + .map(|(file_path, thumb_kind)| MediaProcessorEntry { + operation_kind: if media_data_files_map.remove(&file_path.id).is_some() { + MediaProcessorEntryKind::MediaDataAndThumbnailer(thumb_kind) + } else { + MediaProcessorEntryKind::Thumbnailer(thumb_kind) + }, + file_path, + }) + .collect::>() + .into_iter() + .chain( + media_data_files_map + .into_values() + .map(|file_path| MediaProcessorEntry { + operation_kind: MediaProcessorEntryKind::MediaData, + file_path, + }), + ) + .chunks(BATCH_SIZE) + .into_iter() + .map(|chunk| { + let chunk = chunk.collect::>(); + total_files += chunk.len(); + chunk + }) + .collect::>(); + + debug!( + "Preparing to process {total_files} files in {} chunks", + chunked_files.len() + ); + + let mut run_metadata = MediaProcessorMetadata::default(); + + for files in chunked_files { + let (more_run_metadata, errors) = process( + &files, + location.id, + &location_path, + &thumbnails_base_dir, + library, + |_| {}, + ) + .await?; + run_metadata.update(more_run_metadata); + + error!("Errors processing chunk of media data shallow extraction:\n{errors}"); + } + + info!("Media shallow processor run metadata: {run_metadata:#?}"); + + if run_metadata.media_data.extracted > 0 || run_metadata.thumbnailer.created > 0 { + invalidate_query!(library, "search.paths"); + } + + Ok(()) +} + +async fn get_files_for_thumbnailer( + db: &PrismaClient, + parent_iso_file_path: &IsolatedFilePathData<'_>, +) -> Result< + impl Iterator, + MediaProcessorError, +> { + // query database for all image files in this location that need thumbnails + let image_thumb_files = get_files_by_extensions( + db, + parent_iso_file_path, + &thumbnail::FILTERED_IMAGE_EXTENSIONS, + ) + .await? + .into_iter() + .map(|file_path| (file_path, ThumbnailerEntryKind::Image)); + + #[cfg(feature = "ffmpeg")] + let all_files = { + // query database for all video files in this location that need thumbnails + let video_files = get_files_by_extensions( + db, + parent_iso_file_path, + &thumbnail::FILTERED_VIDEO_EXTENSIONS, + ) + .await?; + + image_thumb_files.chain( + video_files + .into_iter() + .map(|file_path| (file_path, ThumbnailerEntryKind::Video)), + ) + }; + #[cfg(not(feature = "ffmpeg"))] + let all_files = { image_thumb_files }; + + Ok(all_files) +} + +async fn get_files_for_media_data_extraction( + db: &PrismaClient, + parent_iso_file_path: &IsolatedFilePathData<'_>, +) -> Result, MediaProcessorError> { + get_files_by_extensions( + db, + parent_iso_file_path, + &media_data_extractor::FILTERED_IMAGE_EXTENSIONS, + ) + .await + .map(|file_paths| file_paths.into_iter()) + .map_err(Into::into) +} diff --git a/core/src/object/media/mod.rs b/core/src/object/media/mod.rs new file mode 100644 index 000000000..a76e771e3 --- /dev/null +++ b/core/src/object/media/mod.rs @@ -0,0 +1,61 @@ +pub mod media_data_extractor; +pub mod media_processor; +pub mod thumbnail; + +pub use media_processor::MediaProcessorJobInit; +use sd_media_metadata::ImageMetadata; +use sd_prisma::prisma::media_data::*; + +use self::media_data_extractor::MediaDataError; + +pub fn media_data_image_to_query( + mdi: ImageMetadata, + object_id: object_id::Type, +) -> Result { + Ok(CreateUnchecked { + object_id, + _params: vec![ + camera_data::set(serde_json::to_vec(&mdi.camera_data).ok()), + media_date::set(serde_json::to_vec(&mdi.date_taken).ok()), + dimensions::set(serde_json::to_vec(&mdi.dimensions).ok()), + media_location::set(serde_json::to_vec(&mdi.location).ok()), + artist::set(serde_json::to_string(&mdi.artist).ok()), + description::set(serde_json::to_string(&mdi.description).ok()), + copyright::set(serde_json::to_string(&mdi.copyright).ok()), + exif_version::set(serde_json::to_string(&mdi.exif_version).ok()), + ], + }) +} + +pub fn media_data_image_from_prisma_data( + data: sd_prisma::prisma::media_data::Data, +) -> Result { + Ok(ImageMetadata { + dimensions: from_slice_option_to_option(data.dimensions).unwrap_or_default(), + camera_data: from_slice_option_to_option(data.camera_data).unwrap_or_default(), + date_taken: from_slice_option_to_option(data.media_date).unwrap_or_default(), + description: from_string_option_to_option(data.description), + copyright: from_string_option_to_option(data.copyright), + artist: from_string_option_to_option(data.artist), + location: from_slice_option_to_option(data.media_location), + exif_version: from_string_option_to_option(data.exif_version), + }) +} + +#[must_use] +fn from_slice_option_to_option( + value: Option>, +) -> Option { + value + .map(|x| serde_json::from_slice(&x).ok()) + .unwrap_or_default() +} + +#[must_use] +fn from_string_option_to_option( + value: Option, +) -> Option { + value + .map(|x| serde_json::from_str(&x).ok()) + .unwrap_or_default() +} diff --git a/core/src/object/preview/thumbnail/directory.rs b/core/src/object/media/thumbnail/directory.rs similarity index 92% rename from core/src/object/preview/thumbnail/directory.rs rename to core/src/object/media/thumbnail/directory.rs index 1d5484831..0de9cf416 100644 --- a/core/src/object/preview/thumbnail/directory.rs +++ b/core/src/object/media/thumbnail/directory.rs @@ -1,16 +1,16 @@ +use crate::util::{error::FileIOError, version_manager::VersionManager}; + use std::path::PathBuf; -use tokio::fs as async_fs; use int_enum::IntEnum; +use tokio::fs; use tracing::{debug, error, trace}; -use crate::util::{error::FileIOError, version_manager::VersionManager}; - use super::{get_shard_hex, ThumbnailerError, THUMBNAIL_CACHE_DIR_NAME}; #[derive(IntEnum, Debug, Clone, Copy, Eq, PartialEq)] #[repr(i32)] -pub enum ThumbnailVersion { +enum ThumbnailVersion { V1 = 1, V2 = 2, Unknown = 0, @@ -27,7 +27,7 @@ pub async fn init_thumbnail_dir(data_dir: PathBuf) -> Result Result Result<(), ThumbnailerError> { - let mut dir_entries = async_fs::read_dir(dir) + let mut dir_entries = fs::read_dir(dir) .await .map_err(|source| FileIOError::from((dir, source)))?; let mut count = 0; @@ -81,12 +81,12 @@ async fn move_webp_files(dir: &PathBuf) -> Result<(), ThumbnailerError> { let shard_folder = get_shard_hex(filename); let new_dir = dir.join(shard_folder); - async_fs::create_dir_all(&new_dir) + fs::create_dir_all(&new_dir) .await .map_err(|source| FileIOError::from((new_dir.clone(), source)))?; let new_path = new_dir.join(filename); - async_fs::rename(&path, &new_path) + fs::rename(&path, &new_path) .await .map_err(|source| FileIOError::from((path.clone(), source)))?; count += 1; diff --git a/core/src/object/media/thumbnail/mod.rs b/core/src/object/media/thumbnail/mod.rs new file mode 100644 index 000000000..9cb4160ec --- /dev/null +++ b/core/src/object/media/thumbnail/mod.rs @@ -0,0 +1,382 @@ +use crate::{ + api::CoreEvent, + job::JobRunErrors, + library::Library, + location::file_path_helper::{file_path_for_media_processor, IsolatedFilePathData}, + prisma::location, + util::{error::FileIOError, version_manager::VersionManagerError}, + Node, +}; + +use sd_file_ext::extensions::{Extension, ImageExtension, ALL_IMAGE_EXTENSIONS}; +use sd_media_metadata::image::Orientation; + +#[cfg(feature = "ffmpeg")] +use sd_file_ext::extensions::{VideoExtension, ALL_VIDEO_EXTENSIONS}; + +use std::{ + collections::HashMap, + error::Error, + ops::Deref, + path::{Path, PathBuf}, +}; + +use futures_concurrency::future::{Join, TryJoin}; +use image::{self, imageops, DynamicImage, GenericImageView}; +use once_cell::sync::Lazy; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use tokio::{fs, io, task::block_in_place}; +use tracing::{error, trace, warn}; +use webp::Encoder; + +mod directory; +mod shard; + +pub use directory::init_thumbnail_dir; +pub use shard::get_shard_hex; + +const THUMBNAIL_SIZE_FACTOR: f32 = 0.2; +const THUMBNAIL_QUALITY: f32 = 30.0; +pub const THUMBNAIL_CACHE_DIR_NAME: &str = "thumbnails"; + +/// This does not check if a thumbnail exists, it just returns the path that it would exist at +pub fn get_thumbnail_path(node: &Node, cas_id: &str) -> PathBuf { + let mut thumb_path = node.config.data_directory(); + + thumb_path.push(THUMBNAIL_CACHE_DIR_NAME); + thumb_path.push(get_shard_hex(cas_id)); + thumb_path.push(cas_id); + thumb_path.set_extension("webp"); + + thumb_path +} + +// this is used to pass the relevant data to the frontend so it can request the thumbnail +// it supports extending the shard hex to support deeper directory structures in the future +pub fn get_thumb_key(cas_id: &str) -> Vec { + vec![get_shard_hex(cas_id), cas_id.to_string()] +} + +#[cfg(feature = "ffmpeg")] +pub(super) static FILTERED_VIDEO_EXTENSIONS: Lazy> = Lazy::new(|| { + ALL_VIDEO_EXTENSIONS + .iter() + .cloned() + .filter(can_generate_thumbnail_for_video) + .map(Extension::Video) + .collect() +}); + +pub(super) static FILTERED_IMAGE_EXTENSIONS: Lazy> = Lazy::new(|| { + ALL_IMAGE_EXTENSIONS + .iter() + .cloned() + .filter(can_generate_thumbnail_for_image) + .map(Extension::Image) + .collect() +}); + +#[derive(Error, Debug)] +pub enum ThumbnailerError { + // Internal errors + #[error("database error: {0}")] + Database(#[from] prisma_client_rust::QueryError), + #[error(transparent)] + FileIO(#[from] FileIOError), + #[error(transparent)] + VersionManager(#[from] VersionManagerError), +} + +#[derive(Debug, Serialize, Deserialize, Clone, Copy)] +pub enum ThumbnailerEntryKind { + Image, + #[cfg(feature = "ffmpeg")] + Video, +} + +#[derive(Serialize, Deserialize, Default, Debug)] +pub struct ThumbnailerMetadata { + pub created: u32, + pub skipped: u32, +} + +// TOOD(brxken128): validate avci and avcs +#[cfg(all(feature = "heif", not(target_os = "linux")))] +const HEIF_EXTENSIONS: [&str; 7] = ["heif", "heifs", "heic", "heics", "avif", "avci", "avcs"]; + +pub async fn generate_image_thumbnail>( + file_path: P, + output_path: P, +) -> Result<(), Box> { + // Webp creation has blocking code + let webp = block_in_place(|| -> Result, Box> { + #[cfg(all(feature = "heif", not(target_os = "linux")))] + let img = { + let ext = file_path + .as_ref() + .extension() + .unwrap_or_default() + .to_ascii_lowercase(); + if HEIF_EXTENSIONS + .iter() + .any(|e| ext == std::ffi::OsStr::new(e)) + { + sd_heif::heif_to_dynamic_image(file_path.as_ref())? + } else { + image::open(file_path.as_ref())? + } + }; + + #[cfg(not(all(feature = "heif", not(target_os = "linux"))))] + let img = image::open(file_path.as_ref())?; + + let orientation = Orientation::source_orientation(&file_path); + + let (w, h) = img.dimensions(); + // Optionally, resize the existing photo and convert back into DynamicImage + let mut img = DynamicImage::ImageRgba8(imageops::resize( + &img, + // FIXME : Think of a better heuristic to get the thumbnail size + (w as f32 * THUMBNAIL_SIZE_FACTOR) as u32, + (h as f32 * THUMBNAIL_SIZE_FACTOR) as u32, + imageops::FilterType::Triangle, + )); + + // this corrects the rotation/flip of the image based on the available exif data + if let Some(x) = orientation { + img = x.correct_thumbnail(img); + } + + // Create the WebP encoder for the above image + let encoder = Encoder::from_image(&img)?; + + // Encode the image at a specified quality 0-100 + + // Type WebPMemory is !Send, which makes the Future in this function !Send, + // this make us `deref` to have a `&[u8]` and then `to_owned` to make a Vec + // which implies on a unwanted clone... + Ok(encoder.encode(THUMBNAIL_QUALITY).deref().to_owned()) + })?; + + let output_path = output_path.as_ref(); + + if let Some(shard_dir) = output_path.parent() { + fs::create_dir_all(shard_dir) + .await + .map_err(|e| FileIOError::from((shard_dir, e)))?; + } else { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Cannot determine parent shard directory for thumbnail", + ) + .into()); + } + + fs::write(output_path, &webp) + .await + .map_err(|e| FileIOError::from((output_path, e))) + .map_err(Into::into) +} + +#[cfg(feature = "ffmpeg")] +pub async fn generate_video_thumbnail>( + file_path: P, + output_path: P, +) -> Result<(), Box> { + use sd_ffmpeg::to_thumbnail; + + to_thumbnail(file_path, output_path, 256, THUMBNAIL_QUALITY).await?; + + Ok(()) +} + +#[cfg(feature = "ffmpeg")] +pub const fn can_generate_thumbnail_for_video(video_extension: &VideoExtension) -> bool { + use VideoExtension::*; + // File extensions that are specifically not supported by the thumbnailer + !matches!(video_extension, Mpg | Swf | M2v | Hevc | M2ts | Mts | Ts) +} + +pub const fn can_generate_thumbnail_for_image(image_extension: &ImageExtension) -> bool { + use ImageExtension::*; + + #[cfg(all(feature = "heif", not(target_os = "linux")))] + let res = matches!( + image_extension, + Jpg | Jpeg | Png | Webp | Gif | Heic | Heics | Heif | Heifs | Avif + ); + + #[cfg(not(all(feature = "heif", not(target_os = "linux"))))] + let res = matches!(image_extension, Jpg | Jpeg | Png | Webp | Gif); + + res +} + +pub(super) async fn process( + entries: impl IntoIterator, + location_id: location::id::Type, + location_path: impl AsRef, + thumbnails_base_dir: impl AsRef, + library: &Library, + ctx_update_fn: impl Fn(usize), +) -> Result<(ThumbnailerMetadata, JobRunErrors), ThumbnailerError> { + let mut run_metadata = ThumbnailerMetadata::default(); + + let location_path = location_path.as_ref(); + let thumbnails_base_dir = thumbnails_base_dir.as_ref(); + let mut errors = vec![]; + + let mut to_create_dirs = HashMap::new(); + + struct WorkTable<'a> { + kind: ThumbnailerEntryKind, + input_path: PathBuf, + cas_id: &'a str, + output_path: PathBuf, + metadata_res: io::Result<()>, + } + + let entries = entries + .into_iter() + .filter_map(|(file_path, kind)| { + IsolatedFilePathData::try_from((location_id, file_path)) + .map(|iso_file_path| (file_path, kind, location_path.join(iso_file_path))) + .map_err(|e| { + errors.push(format!( + "Failed to build path for file with id {}: {e}", + file_path.id + )) + }) + .ok() + }) + .filter_map(|(file_path, kind, path)| { + if let Some(cas_id) = &file_path.cas_id { + Some((kind, path, cas_id)) + } else { + warn!( + "Skipping thumbnail generation for {} due to missing cas_id", + path.display() + ); + run_metadata.skipped += 1; + None + } + }) + .map(|(kind, input_path, cas_id)| { + let thumbnails_shard_dir = thumbnails_base_dir.join(get_shard_hex(cas_id)); + let output_path = thumbnails_shard_dir.join(format!("{cas_id}.webp")); + + // Putting all sharding directories in a map to avoid trying to create repeteaded ones + to_create_dirs + .entry(thumbnails_shard_dir.clone()) + .or_insert_with(|| async move { + fs::create_dir_all(&thumbnails_shard_dir) + .await + .map_err(|e| FileIOError::from((thumbnails_shard_dir, e))) + }); + + async move { + WorkTable { + kind, + input_path, + cas_id, + // Discarding the ok part as we don't actually care about metadata here, maybe avoiding extra space + metadata_res: fs::metadata(&output_path).await.map(|_| ()), + output_path, + } + } + }) + .collect::>(); + if entries.is_empty() { + return Ok((run_metadata, errors.into())); + } + + // Resolving these futures first, as we want to fail early if we can't create the directories + to_create_dirs + .into_values() + .collect::>() + .try_join() + .await?; + + // Running thumbs generation sequentially to don't overload the system, if we're wasting too much time on I/O we can + // try to run them in parallel + for ( + idx, + WorkTable { + kind, + input_path, + cas_id, + output_path, + metadata_res, + }, + ) in entries.join().await.into_iter().enumerate() + { + ctx_update_fn(idx + 1); + match metadata_res { + Ok(_) => { + trace!( + "Thumb already exists, skipping generation for {}", + output_path.display() + ); + run_metadata.skipped += 1; + continue; + } + + Err(e) if e.kind() == io::ErrorKind::NotFound => { + trace!( + "Writing {} to {}", + input_path.display(), + output_path.display() + ); + + match kind { + ThumbnailerEntryKind::Image => { + if let Err(e) = generate_image_thumbnail(&input_path, &output_path).await { + error!( + "Error generating thumb for image \"{}\": {e:#?}", + input_path.display() + ); + errors.push(format!( + "Had an error generating thumbnail for \"{}\"", + input_path.display() + )); + continue; + } + } + #[cfg(feature = "ffmpeg")] + ThumbnailerEntryKind::Video => { + if let Err(e) = generate_video_thumbnail(&input_path, &output_path).await { + error!( + "Error generating thumb for video \"{}\": {e:#?}", + input_path.display() + ); + errors.push(format!( + "Had an error generating thumbnail for \"{}\"", + input_path.display() + )); + continue; + } + } + } + + trace!("Emitting new thumbnail event"); + library.emit(CoreEvent::NewThumbnail { + thumb_key: get_thumb_key(cas_id), + }); + run_metadata.created += 1; + } + Err(e) => { + error!( + "Error getting metadata for thumb: {:#?}", + FileIOError::from((output_path, e)) + ); + errors.push(format!( + "Had an error generating thumbnail for \"{}\"", + input_path.display() + )); + } + } + } + + Ok((run_metadata, errors.into())) +} diff --git a/core/src/object/preview/thumbnail/shard.rs b/core/src/object/media/thumbnail/shard.rs similarity index 100% rename from core/src/object/preview/thumbnail/shard.rs rename to core/src/object/media/thumbnail/shard.rs diff --git a/core/src/object/mod.rs b/core/src/object/mod.rs index b02ee2331..5bb43d7ac 100644 --- a/core/src/object/mod.rs +++ b/core/src/object/mod.rs @@ -6,8 +6,8 @@ use specta::Type; pub mod cas; pub mod file_identifier; pub mod fs; +pub mod media; pub mod orphan_remover; -pub mod preview; pub mod tag; pub mod thumbnail_remover; pub mod validation; @@ -19,7 +19,7 @@ pub mod validation; // Object selectables! object::select!(object_for_file_identifier { pub_id - file_paths: select { pub_id cas_id } + file_paths: select { pub_id cas_id extension is_dir materialized_path name } }); // The response to provide the Explorer when looking at Objects diff --git a/core/src/object/preview/media_data.rs b/core/src/object/preview/media_data.rs deleted file mode 100644 index 99eff7757..000000000 --- a/core/src/object/preview/media_data.rs +++ /dev/null @@ -1,140 +0,0 @@ -// #[cfg(feature = "ffmpeg")] -// use std::{ffi::OsStr, path::PathBuf}; -// -// #[cfg(feature = "ffmpeg")] -// use ffmpeg_next::{codec::context::Context, format, media::Type}; -// -// #[derive(Default, Debug)] -// pub struct MediaItem { -// pub created_at: Option, -// pub brand: Option, -// pub model: Option, -// pub duration_seconds: i32, -// pub best_video_stream_index: usize, -// pub best_audio_stream_index: usize, -// pub best_subtitle_stream_index: usize, -// pub steams: Vec, -// } -// -// #[derive(Debug)] -// pub struct Stream { -// pub codec: String, -// pub frames: f64, -// pub duration_seconds: f64, -// #[cfg(feature = "ffmpeg")] -// pub kind: Option, -// } -// -// #[cfg(feature = "ffmpeg")] -// #[derive(Debug, PartialEq, Eq)] -// pub enum StreamKind { -// Video(VideoStream), -// Audio(AudioStream), -// } -// -// #[derive(Debug, PartialEq, Eq)] -// pub struct VideoStream { -// pub width: u32, -// pub height: u32, -// pub aspect_ratio: String, -// #[cfg(feature = "ffmpeg")] -// pub format: format::Pixel, -// pub bitrate: usize, -// } -// -// #[derive(Debug, PartialEq, Eq)] -// pub struct AudioStream { -// pub channels: u16, -// #[cfg(feature = "ffmpeg")] -// pub format: format::Sample, -// pub bitrate: usize, -// pub rate: u32, -// } -// -// #[cfg(feature = "ffmpeg")] -// fn extract(iter: &mut ffmpeg_next::dictionary::Iter, key: &str) -> Option { -// iter.find(|k| k.0.contains(key)).map(|k| k.1.to_string()) -// } - -// #[cfg(feature = "ffmpeg")] -// pub fn extract_media_data(path: &PathBuf) -> Result { -// use chrono::NaiveDateTime; -// -// ffmpeg_next::init().unwrap(); -// -// let mut name = path -// .file_name() -// .and_then(OsStr::to_str) -// .map(ToString::to_string) -// .unwrap_or_default(); -// -// // strip to exact potential date length and attempt to parse -// name = name.chars().take(19).collect(); -// // specifically OBS uses this format for time, other checks could be added -// let potential_date = NaiveDateTime::parse_from_str(&name, "%Y-%m-%d %H-%M-%S"); -// -// let context = format::input(&path)?; -// -// let mut media_item = MediaItem::default(); -// let metadata = context.metadata(); -// let mut iter = metadata.iter(); -// -// // creation_time is usually the creation date of the file -// media_item.created_at = extract(&mut iter, "creation_time"); -// // apple photos use "com.apple.quicktime.creationdate", which we care more about than the creation_time -// media_item.created_at = extract(&mut iter, "creationdate"); -// // fallback to potential time if exists -// if media_item.created_at.is_none() { -// media_item.created_at = potential_date.map(|d| d.to_string()).ok(); -// } -// // origin metadata -// media_item.brand = extract(&mut iter, "major_brand"); -// media_item.brand = extract(&mut iter, "make"); -// media_item.model = extract(&mut iter, "model"); -// -// if let Some(stream) = context.streams().best(Type::Video) { -// media_item.best_video_stream_index = stream.index(); -// } -// if let Some(stream) = context.streams().best(Type::Audio) { -// media_item.best_audio_stream_index = stream.index(); -// } -// if let Some(stream) = context.streams().best(Type::Subtitle) { -// media_item.best_subtitle_stream_index = stream.index(); -// } -// media_item.duration_seconds = context.duration() as i32 / ffmpeg_next::ffi::AV_TIME_BASE; -// -// for stream in context.streams() { -// let codec = Context::from_parameters(stream.parameters())?; -// -// let mut stream_item = Stream { -// codec: codec.id().name().to_string(), -// frames: stream.frames() as f64, -// duration_seconds: stream.duration() as f64 * f64::from(stream.time_base()), -// kind: None, -// }; -// -// if codec.medium() == Type::Video { -// if let Ok(video) = codec.decoder().video() { -// stream_item.kind = Some(StreamKind::Video(VideoStream { -// bitrate: video.bit_rate(), -// format: video.format(), -// width: video.width(), -// height: video.height(), -// aspect_ratio: video.aspect_ratio().to_string(), -// })); -// } -// } else if codec.medium() == Type::Audio { -// if let Ok(audio) = codec.decoder().audio() { -// stream_item.kind = Some(StreamKind::Audio(AudioStream { -// channels: audio.channels(), -// bitrate: audio.bit_rate(), -// rate: audio.rate(), -// format: audio.format(), -// })); -// } -// } -// media_item.steams.push(stream_item); -// } -// -// Ok(media_item) -// } diff --git a/core/src/object/preview/media_data_job.rs b/core/src/object/preview/media_data_job.rs deleted file mode 100644 index e69de29bb..000000000 diff --git a/core/src/object/preview/mod.rs b/core/src/object/preview/mod.rs deleted file mode 100644 index ef1bda723..000000000 --- a/core/src/object/preview/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -mod media_data; -mod thumbnail; - -pub use media_data::*; -pub use thumbnail::*; diff --git a/core/src/object/preview/thumbnail/mod.rs b/core/src/object/preview/thumbnail/mod.rs deleted file mode 100644 index 32187b785..000000000 --- a/core/src/object/preview/thumbnail/mod.rs +++ /dev/null @@ -1,269 +0,0 @@ -use crate::{ - api::CoreEvent, - job::JobError, - library::Library, - location::file_path_helper::{file_path_for_thumbnailer, FilePathError, IsolatedFilePathData}, - prisma::location, - util::{db::maybe_missing, error::FileIOError, version_manager::VersionManagerError}, - Node, -}; - -use std::{ - error::Error, - ops::Deref, - path::{Path, PathBuf}, -}; - -use sd_file_ext::extensions::{Extension, ImageExtension}; - -#[cfg(feature = "ffmpeg")] -use sd_file_ext::extensions::VideoExtension; - -use image::{self, imageops, DynamicImage, GenericImageView}; -use once_cell::sync::Lazy; -use serde::{Deserialize, Serialize}; -use thiserror::Error; -use tokio::{fs, io, task::block_in_place}; -use tracing::{error, trace, warn}; -use webp::Encoder; - -mod directory; -mod shallow; -mod shard; -pub mod thumbnailer_job; - -pub use directory::*; -pub use shallow::*; -pub use shard::*; - -const THUMBNAIL_SIZE_FACTOR: f32 = 0.2; -const THUMBNAIL_QUALITY: f32 = 30.0; -pub const THUMBNAIL_CACHE_DIR_NAME: &str = "thumbnails"; - -/// This does not check if a thumbnail exists, it just returns the path that it would exist at -pub fn get_thumbnail_path(node: &Node, cas_id: &str) -> PathBuf { - let mut thumb_path = node.config.data_directory(); - - thumb_path.push(THUMBNAIL_CACHE_DIR_NAME); - thumb_path.push(get_shard_hex(cas_id)); - thumb_path.push(cas_id); - thumb_path.set_extension("webp"); - - thumb_path -} - -// this is used to pass the relevant data to the frontend so it can request the thumbnail -// it supports extending the shard hex to support deeper directory structures in the future -pub fn get_thumb_key(cas_id: &str) -> Vec { - vec![get_shard_hex(cas_id), cas_id.to_string()] -} - -#[cfg(feature = "ffmpeg")] -static FILTERED_VIDEO_EXTENSIONS: Lazy> = Lazy::new(|| { - sd_file_ext::extensions::ALL_VIDEO_EXTENSIONS - .iter() - .map(Clone::clone) - .filter(can_generate_thumbnail_for_video) - .map(Extension::Video) - .collect() -}); - -static FILTERED_IMAGE_EXTENSIONS: Lazy> = Lazy::new(|| { - sd_file_ext::extensions::ALL_IMAGE_EXTENSIONS - .iter() - .map(Clone::clone) - .filter(can_generate_thumbnail_for_image) - .map(Extension::Image) - .collect() -}); - -#[derive(Error, Debug)] -pub enum ThumbnailerError { - #[error("sub path not found: ", .0.display())] - SubPathNotFound(Box), - - // Internal errors - #[error("database error: {0}")] - Database(#[from] prisma_client_rust::QueryError), - #[error(transparent)] - FilePath(#[from] FilePathError), - #[error(transparent)] - FileIO(#[from] FileIOError), - #[error(transparent)] - VersionManager(#[from] VersionManagerError), -} -#[derive(Debug, Serialize, Deserialize, Clone, Copy)] -enum ThumbnailerJobStepKind { - Image, - #[cfg(feature = "ffmpeg")] - Video, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct ThumbnailerJobStep { - file_path: file_path_for_thumbnailer::Data, - kind: ThumbnailerJobStepKind, -} - -// TOOD(brxken128): validate avci and avcs -#[cfg(all(feature = "heif", not(target_os = "linux")))] -const HEIF_EXTENSIONS: [&str; 7] = ["heif", "heifs", "heic", "heics", "avif", "avci", "avcs"]; - -pub async fn generate_image_thumbnail>( - file_path: P, - output_path: P, -) -> Result<(), Box> { - // Webp creation has blocking code - let webp = block_in_place(|| -> Result, Box> { - #[cfg(all(feature = "heif", not(target_os = "linux")))] - let img = { - let ext = file_path - .as_ref() - .extension() - .unwrap_or_default() - .to_ascii_lowercase(); - if HEIF_EXTENSIONS - .iter() - .any(|e| ext == std::ffi::OsStr::new(e)) - { - sd_heif::heif_to_dynamic_image(file_path.as_ref())? - } else { - image::open(file_path)? - } - }; - - #[cfg(not(all(feature = "heif", not(target_os = "linux"))))] - let img = image::open(file_path)?; - - let (w, h) = img.dimensions(); - // Optionally, resize the existing photo and convert back into DynamicImage - let img = DynamicImage::ImageRgba8(imageops::resize( - &img, - // FIXME : Think of a better heuristic to get the thumbnail size - (w as f32 * THUMBNAIL_SIZE_FACTOR) as u32, - (h as f32 * THUMBNAIL_SIZE_FACTOR) as u32, - imageops::FilterType::Triangle, - )); - // Create the WebP encoder for the above image - let encoder = Encoder::from_image(&img)?; - - // Encode the image at a specified quality 0-100 - - // Type WebPMemory is !Send, which makes the Future in this function !Send, - // this make us `deref` to have a `&[u8]` and then `to_owned` to make a Vec - // which implies on a unwanted clone... - Ok(encoder.encode(THUMBNAIL_QUALITY).deref().to_owned()) - })?; - - fs::create_dir_all(output_path.as_ref().parent().ok_or(io::Error::new( - io::ErrorKind::InvalidInput, - "Cannot determine parent directory", - ))?) - .await?; - - fs::write(output_path, &webp).await.map_err(Into::into) -} - -#[cfg(feature = "ffmpeg")] -pub async fn generate_video_thumbnail>( - file_path: P, - output_path: P, -) -> Result<(), Box> { - use sd_ffmpeg::to_thumbnail; - - to_thumbnail(file_path, output_path, 256, THUMBNAIL_QUALITY).await?; - - Ok(()) -} - -#[cfg(feature = "ffmpeg")] -pub const fn can_generate_thumbnail_for_video(video_extension: &VideoExtension) -> bool { - use VideoExtension::*; - // File extensions that are specifically not supported by the thumbnailer - !matches!(video_extension, Mpg | Swf | M2v | Hevc | M2ts | Mts | Ts) -} - -pub const fn can_generate_thumbnail_for_image(image_extension: &ImageExtension) -> bool { - use ImageExtension::*; - - #[cfg(all(feature = "heif", not(target_os = "linux")))] - let res = matches!( - image_extension, - Jpg | Jpeg | Png | Webp | Gif | Heic | Heics | Heif | Heifs | Avif - ); - - #[cfg(not(all(feature = "heif", not(target_os = "linux"))))] - let res = matches!(image_extension, Jpg | Jpeg | Png | Webp | Gif); - - res -} - -pub async fn inner_process_step( - step: &ThumbnailerJobStep, - location_path: impl AsRef, - thumbnail_dir: impl AsRef, - location: &location::Data, - library: &Library, -) -> Result { - let ThumbnailerJobStep { file_path, kind } = step; - let location_path = location_path.as_ref(); - let thumbnail_dir = thumbnail_dir.as_ref(); - - // assemble the file path - let path = location_path.join(IsolatedFilePathData::try_from((location.id, file_path))?); - trace!("image_file {:?}", file_path); - - // get cas_id, if none found skip - let Some(cas_id) = &file_path.cas_id else { - warn!( - "skipping thumbnail generation for {}", - maybe_missing(&file_path.materialized_path, "file_path.materialized_path")? - ); - return Ok(false); - }; - - let thumb_dir = thumbnail_dir.join(get_shard_hex(cas_id)); - - // Create the directory if it doesn't exist - if let Err(e) = fs::create_dir_all(&thumb_dir).await { - error!("Error creating thumbnail directory {:#?}", e); - } - - // Define and write the WebP-encoded file to a given path - let output_path = thumb_dir.join(format!("{cas_id}.webp")); - - match fs::metadata(&output_path).await { - Ok(_) => { - trace!( - "Thumb already exists, skipping generation for {}", - output_path.display() - ); - return Ok(false); - } - Err(e) if e.kind() == io::ErrorKind::NotFound => { - trace!("Writing {:?} to {:?}", path, output_path); - - match kind { - ThumbnailerJobStepKind::Image => { - if let Err(e) = generate_image_thumbnail(&path, &output_path).await { - error!("Error generating thumb for image {:#?}", e); - } - } - #[cfg(feature = "ffmpeg")] - ThumbnailerJobStepKind::Video => { - if let Err(e) = generate_video_thumbnail(&path, &output_path).await { - error!("Error generating thumb for video: {:?} {:#?}", &path, e); - } - } - } - - trace!("Emitting new thumbnail event"); - library.emit(CoreEvent::NewThumbnail { - thumb_key: get_thumb_key(cas_id), - }); - } - Err(e) => return Err(ThumbnailerError::from(FileIOError::from((output_path, e))).into()), - } - - Ok(true) -} diff --git a/core/src/object/preview/thumbnail/shallow.rs b/core/src/object/preview/thumbnail/shallow.rs deleted file mode 100644 index 86462bcbc..000000000 --- a/core/src/object/preview/thumbnail/shallow.rs +++ /dev/null @@ -1,152 +0,0 @@ -use super::{ - ThumbnailerError, ThumbnailerJobStep, ThumbnailerJobStepKind, FILTERED_IMAGE_EXTENSIONS, -}; -use crate::{ - invalidate_query, - job::JobError, - library::Library, - location::file_path_helper::{ - ensure_file_path_exists, ensure_sub_path_is_directory, ensure_sub_path_is_in_location, - file_path_for_thumbnailer, IsolatedFilePathData, - }, - object::preview::thumbnail, - prisma::{file_path, location, PrismaClient}, - util::error::FileIOError, - Node, -}; -use sd_file_ext::extensions::Extension; -use std::path::{Path, PathBuf}; -use thumbnail::init_thumbnail_dir; -use tokio::fs; -use tracing::{debug, trace}; - -#[cfg(feature = "ffmpeg")] -use super::FILTERED_VIDEO_EXTENSIONS; - -pub async fn shallow_thumbnailer( - location: &location::Data, - sub_path: &PathBuf, - library: &Library, - node: &Node, -) -> Result<(), JobError> { - let Library { db, .. } = library; - - let thumbnail_dir = init_thumbnail_dir(node.config.data_directory()).await?; - - let location_id = location.id; - let location_path = match &location.path { - Some(v) => PathBuf::from(v), - None => return Ok(()), - }; - - let (path, iso_file_path) = if sub_path != Path::new("") { - let full_path = ensure_sub_path_is_in_location(&location_path, &sub_path) - .await - .map_err(ThumbnailerError::from)?; - ensure_sub_path_is_directory(&location_path, &sub_path) - .await - .map_err(ThumbnailerError::from)?; - - let sub_iso_file_path = - IsolatedFilePathData::new(location_id, &location_path, &full_path, true) - .map_err(ThumbnailerError::from)?; - - ensure_file_path_exists( - &sub_path, - &sub_iso_file_path, - db, - ThumbnailerError::SubPathNotFound, - ) - .await?; - - (full_path, sub_iso_file_path) - } else { - ( - location_path.to_path_buf(), - IsolatedFilePathData::new(location_id, &location_path, &location_path, true) - .map_err(ThumbnailerError::from)?, - ) - }; - - debug!( - "Searching for images in location {location_id} at path {}", - path.display() - ); - - // create all necessary directories if they don't exist - fs::create_dir_all(&thumbnail_dir) - .await - .map_err(|e| FileIOError::from((&thumbnail_dir, e)))?; - - // query database for all image files in this location that need thumbnails - let image_files = get_files_by_extensions( - &library.db, - location_id, - &iso_file_path, - &FILTERED_IMAGE_EXTENSIONS, - ThumbnailerJobStepKind::Image, - ) - .await?; - - trace!("Found {:?} image files", image_files.len()); - - #[cfg(feature = "ffmpeg")] - let video_files = { - // query database for all video files in this location that need thumbnails - let video_files = get_files_by_extensions( - &library.db, - location_id, - &iso_file_path, - &FILTERED_VIDEO_EXTENSIONS, - ThumbnailerJobStepKind::Video, - ) - .await?; - - trace!("Found {:?} video files", video_files.len()); - - video_files - }; - - let all_files = [ - image_files, - #[cfg(feature = "ffmpeg")] - video_files, - ] - .into_iter() - .flatten(); - - for file in all_files { - thumbnail::inner_process_step(&file, &location_path, &thumbnail_dir, location, library) - .await?; - } - - invalidate_query!(library, "search.paths"); - - Ok(()) -} - -async fn get_files_by_extensions( - db: &PrismaClient, - location_id: location::id::Type, - parent_isolated_file_path_data: &IsolatedFilePathData<'_>, - extensions: &[Extension], - kind: ThumbnailerJobStepKind, -) -> Result, JobError> { - Ok(db - .file_path() - .find_many(vec![ - file_path::location_id::equals(Some(location_id)), - file_path::extension::in_vec(extensions.iter().map(ToString::to_string).collect()), - file_path::materialized_path::equals(Some( - parent_isolated_file_path_data - .materialized_path_for_children() - .expect("sub path iso_file_path must be a directory"), - )), - ]) - .select(file_path_for_thumbnailer::select()) - .exec() - .await? - .into_iter() - .map(|file_path| ThumbnailerJobStep { file_path, kind }) - .collect()) -} diff --git a/core/src/object/preview/thumbnail/thumbnailer_job.rs b/core/src/object/preview/thumbnail/thumbnailer_job.rs deleted file mode 100644 index 7aee2ffcd..000000000 --- a/core/src/object/preview/thumbnail/thumbnailer_job.rs +++ /dev/null @@ -1,259 +0,0 @@ -use crate::{ - invalidate_query, - job::{ - CurrentStep, JobError, JobInitOutput, JobResult, JobRunMetadata, JobStepOutput, - StatefulJob, WorkerContext, - }, - library::Library, - location::file_path_helper::{ - ensure_file_path_exists, ensure_sub_path_is_directory, ensure_sub_path_is_in_location, - file_path_for_thumbnailer, IsolatedFilePathData, - }, - object::preview::thumbnail::directory::init_thumbnail_dir, - prisma::{file_path, location, PrismaClient}, - util::db::maybe_missing, -}; - -use std::{ - hash::Hash, - path::{Path, PathBuf}, -}; - -use sd_file_ext::extensions::Extension; - -use serde::{Deserialize, Serialize}; - -use serde_json::json; -use tracing::{debug, info, trace}; - -use super::{ - inner_process_step, ThumbnailerError, ThumbnailerJobStep, ThumbnailerJobStepKind, - FILTERED_IMAGE_EXTENSIONS, -}; - -#[cfg(feature = "ffmpeg")] -use super::FILTERED_VIDEO_EXTENSIONS; - -#[derive(Serialize, Deserialize, Debug)] -pub struct ThumbnailerJobInit { - pub location: location::Data, - pub sub_path: Option, -} - -impl Hash for ThumbnailerJobInit { - fn hash(&self, state: &mut H) { - self.location.id.hash(state); - if let Some(ref sub_path) = self.sub_path { - sub_path.hash(state); - } - } -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct ThumbnailerJobData { - thumbnail_dir: PathBuf, - location_path: PathBuf, - path: PathBuf, -} - -#[derive(Serialize, Deserialize, Default, Debug)] -pub struct ThumbnailerJobRunMetadata { - thumbnails_created: u32, - thumbnails_skipped: u32, -} - -impl JobRunMetadata for ThumbnailerJobRunMetadata { - fn update(&mut self, new_data: Self) { - self.thumbnails_created += new_data.thumbnails_created; - self.thumbnails_skipped += new_data.thumbnails_skipped; - } -} - -#[async_trait::async_trait] -impl StatefulJob for ThumbnailerJobInit { - type Data = ThumbnailerJobData; - type Step = ThumbnailerJobStep; - type RunMetadata = ThumbnailerJobRunMetadata; - - const NAME: &'static str = "thumbnailer"; - - async fn init( - &self, - ctx: &WorkerContext, - data: &mut Option, - ) -> Result, JobError> { - let init = self; - let Library { db, .. } = &*ctx.library; - - let thumbnail_dir = init_thumbnail_dir(ctx.node.config.data_directory()).await?; - - let location_id = init.location.id; - let location_path = - maybe_missing(&init.location.path, "location.path").map(PathBuf::from)?; - - let (path, iso_file_path) = match &init.sub_path { - Some(sub_path) if sub_path != Path::new("") => { - let full_path = ensure_sub_path_is_in_location(&location_path, sub_path) - .await - .map_err(ThumbnailerError::from)?; - ensure_sub_path_is_directory(&location_path, sub_path) - .await - .map_err(ThumbnailerError::from)?; - - let sub_iso_file_path = - IsolatedFilePathData::new(location_id, &location_path, &full_path, true) - .map_err(ThumbnailerError::from)?; - - ensure_file_path_exists( - sub_path, - &sub_iso_file_path, - db, - ThumbnailerError::SubPathNotFound, - ) - .await?; - - (full_path, sub_iso_file_path) - } - _ => ( - location_path.to_path_buf(), - IsolatedFilePathData::new(location_id, &location_path, &location_path, true) - .map_err(ThumbnailerError::from)?, - ), - }; - - debug!("Searching for images in location {location_id} at directory {iso_file_path}"); - - // query database for all image files in this location that need thumbnails - let image_files = get_files_by_extensions( - db, - &iso_file_path, - &FILTERED_IMAGE_EXTENSIONS, - ThumbnailerJobStepKind::Image, - ) - .await?; - trace!("Found {:?} image files", image_files.len()); - - #[cfg(feature = "ffmpeg")] - let all_files = { - // query database for all video files in this location that need thumbnails - let video_files = get_files_by_extensions( - db, - &iso_file_path, - &FILTERED_VIDEO_EXTENSIONS, - ThumbnailerJobStepKind::Video, - ) - .await?; - trace!("Found {:?} video files", video_files.len()); - - image_files - .into_iter() - .chain(video_files) - .collect::>() - }; - #[cfg(not(feature = "ffmpeg"))] - let all_files = { image_files.into_iter().collect::>() }; - - ctx.progress_msg(format!("Preparing to process {} files", all_files.len())); - - *data = Some(ThumbnailerJobData { - thumbnail_dir, - location_path, - path, - }); - - Ok(( - ThumbnailerJobRunMetadata { - thumbnails_created: 0, - thumbnails_skipped: 0, - }, - all_files, - ) - .into()) - } - - async fn execute_step( - &self, - ctx: &WorkerContext, - CurrentStep { step, .. }: CurrentStep<'_, Self::Step>, - data: &Self::Data, - _: &Self::RunMetadata, - ) -> Result, JobError> { - let init = self; - ctx.progress_msg(format!( - "Processing {}", - maybe_missing( - &step.file_path.materialized_path, - "file_path.materialized_path" - )? - )); - - let mut new_metadata = Self::RunMetadata::default(); - - let step_result = inner_process_step( - step, - &data.location_path, - &data.thumbnail_dir, - &init.location, - &ctx.library, - ) - .await; - - step_result.map(|thumbnail_was_created| { - if thumbnail_was_created { - new_metadata.thumbnails_created += 1; - } else { - new_metadata.thumbnails_skipped += 1; - } - })?; - - Ok(new_metadata.into()) - } - - async fn finalize( - &self, - ctx: &WorkerContext, - data: &Option, - run_metadata: &Self::RunMetadata, - ) -> JobResult { - let init = self; - info!( - "Finished thumbnail generation for location {} at {}", - init.location.id, - data.as_ref() - .expect("critical error: missing data on job state") - .path - .display() - ); - - if run_metadata.thumbnails_created > 0 { - invalidate_query!(ctx.library, "search.paths"); - } - - Ok(Some(json!({"init: ": init, "run_metadata": run_metadata}))) - } -} - -async fn get_files_by_extensions( - db: &PrismaClient, - iso_file_path: &IsolatedFilePathData<'_>, - extensions: &[Extension], - kind: ThumbnailerJobStepKind, -) -> Result, JobError> { - Ok(db - .file_path() - .find_many(vec![ - file_path::location_id::equals(Some(iso_file_path.location_id())), - file_path::extension::in_vec(extensions.iter().map(ToString::to_string).collect()), - file_path::materialized_path::starts_with( - iso_file_path - .materialized_path_for_children() - .expect("sub path iso_file_path must be a directory"), - ), - ]) - .select(file_path_for_thumbnailer::select()) - .exec() - .await? - .into_iter() - .map(|file_path| ThumbnailerJobStep { file_path, kind }) - .collect()) -} diff --git a/core/src/object/thumbnail_remover.rs b/core/src/object/thumbnail_remover.rs index 9a69289fb..fe20e70dc 100644 --- a/core/src/object/thumbnail_remover.rs +++ b/core/src/object/thumbnail_remover.rs @@ -26,7 +26,7 @@ use tokio_util::sync::{CancellationToken, DropGuard}; use tracing::{debug, error, trace}; use uuid::Uuid; -use super::preview::THUMBNAIL_CACHE_DIR_NAME; +use super::media::thumbnail::THUMBNAIL_CACHE_DIR_NAME; const THIRTY_SECS: Duration = Duration::from_secs(30); const HALF_HOUR: Duration = Duration::from_secs(30 * 60); diff --git a/crates/file-ext/src/extensions.rs b/crates/file-ext/src/extensions.rs index 8f9de35ed..b5377e0d9 100644 --- a/crates/file-ext/src/extensions.rs +++ b/crates/file-ext/src/extensions.rs @@ -80,6 +80,7 @@ extension_category_enum! { Heics = [0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70, 0x68, 0x65, 0x69, 0x63], Heif = [], Heifs = [], + Hif = [], Avif = [], Avci = [], Avcs = [], diff --git a/crates/heif/src/lib.rs b/crates/heif/src/lib.rs index c86b4d317..67463a045 100644 --- a/crates/heif/src/lib.rs +++ b/crates/heif/src/lib.rs @@ -21,8 +21,8 @@ pub enum HeifError { LibHeif(#[from] libheif_rs::HeifError), #[error("error while loading the image (via the `image` crate): {0}")] Image(#[from] image::ImageError), - #[error("io error: {0}")] - Io(#[from] std::io::Error), + #[error("io error: {0} at {}", .1.display())] + Io(std::io::Error, Box), #[error("there was an error while converting the image to an `RgbImage`")] RgbImageConversion, #[error("the image provided is unsupported")] @@ -36,7 +36,10 @@ pub enum HeifError { } pub fn heif_to_dynamic_image(path: &Path) -> HeifResult { - if fs::metadata(path)?.len() > HEIF_MAXIMUM_FILE_SIZE { + if fs::metadata(path) + .map_err(|e| HeifError::Io(e, path.to_path_buf().into_boxed_path()))? + .len() > HEIF_MAXIMUM_FILE_SIZE + { return Err(HeifError::TooLarge); } @@ -65,10 +68,14 @@ pub fn heif_to_dynamic_image(path: &Path) -> HeifResult { // this is the interpolation stuff, it essentially just makes the image correct // in regards to stretching/resolution, etc for y in 0..img.height() { - reader.seek(SeekFrom::Start((i.stride * y as usize) as u64))?; + reader + .seek(SeekFrom::Start((i.stride * y as usize) as u64)) + .map_err(|e| HeifError::Io(e, path.to_path_buf().into_boxed_path()))?; for _ in 0..img.width() { - reader.read_exact(&mut buffer)?; + reader + .read_exact(&mut buffer) + .map_err(|e| HeifError::Io(e, path.to_path_buf().into_boxed_path()))?; sequence.extend_from_slice(&buffer); } } diff --git a/crates/media-metadata/Cargo.toml b/crates/media-metadata/Cargo.toml new file mode 100644 index 000000000..373dd305a --- /dev/null +++ b/crates/media-metadata/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "sd-media-metadata" +version = "0.0.0" +authors = ["Jake Robinson "] +edition = "2021" + +[dependencies] +kamadak-exif = "0.5.5" +thiserror = "1.0.43" +image-rs = { package = "image", version = '0.24.6' } +serde = { version = "1.0.183", features = ["derive"] } +serde_json = { version = "1.0.104" } +specta = { workspace = true, features = ["chrono"] } +chrono = { version = "0.4.26", features = ["serde"] } + +# symphonia crate looks great for audio metadata diff --git a/crates/media-metadata/README.md b/crates/media-metadata/README.md new file mode 100644 index 000000000..5e16b2721 --- /dev/null +++ b/crates/media-metadata/README.md @@ -0,0 +1 @@ +# Spacedrive's EXIF/media data parsing library diff --git a/crates/media-metadata/clippy.toml b/crates/media-metadata/clippy.toml new file mode 100644 index 000000000..154626ef4 --- /dev/null +++ b/crates/media-metadata/clippy.toml @@ -0,0 +1 @@ +allow-unwrap-in-tests = true diff --git a/crates/media-metadata/src/audio.rs b/crates/media-metadata/src/audio.rs new file mode 100644 index 000000000..683947424 --- /dev/null +++ b/crates/media-metadata/src/audio.rs @@ -0,0 +1,19 @@ +use std::path::Path; + +use crate::Result; + +#[derive( + Default, Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize, specta::Type, +)] +pub struct AudioMetadata { + duration: Option, // can't use `Duration` due to bigint + audio_codec: Option, +} + +impl AudioMetadata { + #[allow(clippy::missing_errors_doc)] + #[allow(clippy::missing_panics_doc)] + pub fn from_path(_path: impl AsRef) -> Result { + todo!() + } +} diff --git a/crates/media-metadata/src/error.rs b/crates/media-metadata/src/error.rs new file mode 100644 index 000000000..22b9ab3e3 --- /dev/null +++ b/crates/media-metadata/src/error.rs @@ -0,0 +1,28 @@ +use std::{num::ParseFloatError, path::PathBuf}; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("there was an i/o error")] + Io(#[from] std::io::Error), + #[error("error from the exif crate: {0}")] + Exif(#[from] exif::Error), + #[error("there was an error while parsing time with chrono: {0}")] + Chrono(#[from] chrono::ParseError), + #[error("there was an error while converting between types")] + Conversion, + #[error("there was an error while parsing the location of an image")] + MediaLocationParse, + #[error("there was an error while parsing a float")] + FloatParse(#[from] ParseFloatError), + #[error("there was an error while initializing the exif reader")] + Init, + #[error("the file provided at ({0}) contains no exif data")] + NoExifDataOnPath(PathBuf), + #[error("the slice provided contains no exif data")] + NoExifDataOnSlice, + + #[error("serde error {0}")] + Serde(#[from] serde_json::Error), +} + +pub type Result = std::result::Result; diff --git a/crates/media-metadata/src/image/composite.rs b/crates/media-metadata/src/image/composite.rs new file mode 100644 index 000000000..49bbdc244 --- /dev/null +++ b/crates/media-metadata/src/image/composite.rs @@ -0,0 +1,45 @@ +use std::path::Path; + +use exif::Tag; + +use super::ExifReader; + +#[derive( + Default, Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize, specta::Type, +)] +pub enum Composite { + /// The data is present, but we're unable to determine what they mean + #[default] + Unknown, + /// Not a composite image + False, + /// A general composite image + General, + /// The composite image was captured while shooting + Live, +} + +impl Composite { + /// This is used for quickly sourcing [`Composite`] data from a path + #[allow(clippy::future_not_send)] + pub fn source_composite(path: impl AsRef) -> Option { + let reader = ExifReader::from_path(path).ok()?; + reader.get_tag_int(Tag::CompositeImage).map(Into::into) + } + + /// This is used for quickly sourcing a [`Composite`] type from an [`ExifReader`] + pub fn from_reader(reader: &ExifReader) -> Option { + reader.get_tag_int(Tag::CompositeImage).map(Into::into) + } +} + +impl From for Composite { + fn from(value: u32) -> Self { + match value { + 1 => Self::False, + 2 => Self::General, + 3 => Self::Live, + _ => Self::Unknown, + } + } +} diff --git a/crates/media-metadata/src/image/consts.rs b/crates/media-metadata/src/image/consts.rs new file mode 100644 index 000000000..eed34d4ae --- /dev/null +++ b/crates/media-metadata/src/image/consts.rs @@ -0,0 +1,31 @@ +use exif::Tag; + +/// Used for converting DMS to decimal coordinates, and is the amount to divide by. +/// +/// # Examples: +/// +/// ``` +/// use sd_media_metadata::image::DMS_DIVISION; +/// +/// let latitude = [53_f64, 19_f64, 35.11_f64]; // in DMS +/// latitude.iter().zip(DMS_DIVISION.iter()); +/// ``` +pub const DMS_DIVISION: [f64; 3] = [1_f64, 60_f64, 3600_f64]; + +/// The amount of significant figures we wish to retain after the decimal point. +/// +/// This is currrently 8 digits (after the integer) as that is precise enough for most +/// applications. +/// +/// This is calculated with `10^n`, where `n` is the desired amount of SFs. +pub const DECIMAL_SF: f64 = 100_000_000_f64; + +/// All possible time tags, to be zipped with [`OFFSET_TAGS`] +pub const TIME_TAGS: [Tag; 3] = [Tag::DateTime, Tag::DateTimeOriginal, Tag::DateTimeDigitized]; + +/// All possible time offset tags, to be zipped with [`TIME_TAGS`] +pub const OFFSET_TAGS: [Tag; 3] = [ + Tag::OffsetTime, + Tag::OffsetTimeOriginal, + Tag::OffsetTimeDigitized, +]; diff --git a/crates/media-metadata/src/image/dimensions.rs b/crates/media-metadata/src/image/dimensions.rs new file mode 100644 index 000000000..46f011ad5 --- /dev/null +++ b/crates/media-metadata/src/image/dimensions.rs @@ -0,0 +1,47 @@ +use std::fmt::Display; + +use exif::Tag; + +use super::ExifReader; + +#[derive( + Default, Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize, specta::Type, +)] +pub struct Dimensions { + pub width: i32, + pub height: i32, +} + +impl Dimensions { + #[must_use] + /// Creates a new width and height container + /// + /// # Examples + /// + /// ``` + /// use sd_media_metadata::image::Dimensions; + /// + /// Dimensions::new(1920, 1080); + /// ``` + pub const fn new(width: i32, height: i32) -> Self { + Self { width, height } + } + + #[must_use] + pub fn from_reader(reader: &ExifReader) -> Self { + Self { + width: reader + .get_tag(Tag::PixelXDimension) + .unwrap_or_else(|| reader.get_tag(Tag::XResolution).unwrap_or_default()), + height: reader + .get_tag(Tag::PixelYDimension) + .unwrap_or_else(|| reader.get_tag(Tag::YResolution).unwrap_or_default()), + } + } +} + +impl Display for Dimensions { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_fmt(format_args!("{}x{}", self.width, self.height)) + } +} diff --git a/crates/media-metadata/src/image/flash/consts.rs b/crates/media-metadata/src/image/flash/consts.rs new file mode 100644 index 000000000..5aec37048 --- /dev/null +++ b/crates/media-metadata/src/image/flash/consts.rs @@ -0,0 +1,13 @@ +use super::FlashMode; + +pub const FLASH_AUTO: [u32; 8] = [0x18, 0x19, 0x1d, 0x1f, 0x58, 0x59, 0x5d, 0x5f]; +pub const FLASH_ENABLED: [u32; 7] = [0x08, 0x09, 0x0d, 0x0f, 0x49, 0x4d, 0x4f]; +pub const FLASH_DISABLED: [u32; 4] = [0x10, 0x14, 0x30, 0x50]; +pub const FLASH_FORCED: [u32; 3] = [0x41, 0x45, 0x47]; + +pub const FLASH_MODES: [(FlashMode, &[u32]); 4] = [ + (FlashMode::Auto, &FLASH_AUTO), + (FlashMode::On, &FLASH_ENABLED), + (FlashMode::Off, &FLASH_DISABLED), + (FlashMode::Forced, &FLASH_FORCED), +]; diff --git a/crates/media-metadata/src/image/flash/data.rs b/crates/media-metadata/src/image/flash/data.rs new file mode 100644 index 000000000..e5ca637bc --- /dev/null +++ b/crates/media-metadata/src/image/flash/data.rs @@ -0,0 +1,168 @@ +use exif::Tag; + +use super::FlashValue; +use crate::image::{flash::consts::FLASH_MODES, ExifReader}; + +#[derive( + Default, Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize, specta::Type, +)] +pub struct Flash { + /// Specifies how flash was used (on, auto, off, forced, onvalid) + /// + /// [`FlashMode::Unknown`] isn't a valid EXIF state, but it's included as the default, + /// just in case we're unable to correctly match it to a known (valid) state. + /// + /// This type should only ever be evaluated if flash EXIF data is present, so having this as a non-option shouldn't be an issue. + pub mode: FlashMode, + /// Did the flash actually fire? + pub fired: Option, + /// Did flash return to the camera? (Unsure of the meaning) + pub returned: Option, + /// Was red eye reduction used? + pub red_eye_reduction: Option, +} + +impl Flash { + #[must_use] + pub fn from_reader(reader: &ExifReader) -> Option { + let value = reader.get_tag_int(Tag::Flash)?; + FlashValue::try_from(value).ok()?.into() + } +} + +#[derive( + Default, Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize, specta::Type, +)] +pub enum FlashMode { + /// The data is present, but we're unable to determine what they mean + #[default] + Unknown, + /// FLash was on + On, + /// Flash was off + Off, + /// Flash was set to automatically fire in certain conditions + Auto, + /// Flash was forcefully fired + Forced, +} + +impl From for FlashMode { + fn from(value: u32) -> Self { + FLASH_MODES + .into_iter() + .find_map(|(mode, slice)| slice.contains(&value).then_some(mode)) + .unwrap_or_default() + } +} + +impl From for Option { + // TODO(brxken128): This can be heavily optimised with bitwise AND + // e.g. to see if flash was fired, `(value & 1) != 0` + // or to see if red eye reduction was enabled, `(value & 64) != 0` + // May not be worth it as some states may be invalid according to `https://www.awaresystems.be/imaging/tiff/tifftags/privateifd/exif/flash.html` + fn from(value: FlashValue) -> Self { + #[allow(clippy::as_conversions)] + let mut data = Flash { + mode: FlashMode::from(value as u32), + ..Default::default() + }; + + #[allow(clippy::match_same_arms)] + match value { + FlashValue::Fired => { + data.fired = Some(true); + } + FlashValue::NoFire => { + data.fired = Some(false); + } + FlashValue::FiredReturn => { + data.fired = Some(true); + data.returned = Some(true); + } + FlashValue::FiredNoReturn => { + data.fired = Some(true); + data.returned = Some(false); + } + FlashValue::AutoFired => { + data.fired = Some(true); + } + FlashValue::AutoFiredNoReturn => { + data.fired = Some(true); + data.returned = Some(false); + } + FlashValue::OffNoFire => data.fired = Some(false), + FlashValue::AutoNoFire => data.fired = Some(false), + FlashValue::NoFlashFunction | FlashValue::OffNoFlashFunction | FlashValue::Unknown => { + data = Flash::default(); + } + FlashValue::AutoFiredRedEyeReduction => { + data.fired = Some(true); + data.red_eye_reduction = Some(true); + } + FlashValue::AutoFiredRedEyeReductionNoReturn => { + data.fired = Some(true); + data.red_eye_reduction = Some(true); + data.returned = Some(false); + } + FlashValue::AutoFiredRedEyeReductionReturn => { + data.fired = Some(true); + data.red_eye_reduction = Some(true); + data.returned = Some(true); + } + FlashValue::OnFired => { + data.fired = Some(true); + } + FlashValue::OnNoFire => { + data.fired = Some(false); + } + FlashValue::AutoFiredReturn => { + data.fired = Some(true); + data.returned = Some(true); + } + FlashValue::OnReturn => { + data.returned = Some(true); + } + FlashValue::OnNoReturn => data.returned = Some(false), + FlashValue::AutoNoFireRedEyeReduction => { + data.fired = Some(false); + data.red_eye_reduction = Some(true); + } + FlashValue::OffNoFireNoReturn => { + data.fired = Some(false); + data.returned = Some(false); + } + FlashValue::OffRedEyeReduction => data.red_eye_reduction = Some(true), + FlashValue::OnRedEyeReduction => data.red_eye_reduction = Some(true), + FlashValue::FiredRedEyeReductionNoReturn => { + data.fired = Some(true); + data.red_eye_reduction = Some(true); + data.returned = Some(false); + } + FlashValue::FiredRedEyeReduction => { + data.fired = Some(true); + data.red_eye_reduction = Some(true); + } + FlashValue::FiredRedEyeReductionReturn => { + data.fired = Some(true); + data.red_eye_reduction = Some(true); + data.returned = Some(false); + } + FlashValue::OnRedEyeReductionReturn => { + data.red_eye_reduction = Some(true); + data.returned = Some(true); + } + FlashValue::OnRedEyeReductionNoReturn => { + data.red_eye_reduction = Some(true); + data.returned = Some(false); + } + } + + // this means it had a value of Flash::NoFlashFunctionality + if data == Flash::default() { + None + } else { + Some(data) + } + } +} diff --git a/crates/media-metadata/src/image/flash/mod.rs b/crates/media-metadata/src/image/flash/mod.rs new file mode 100644 index 000000000..29b8bad02 --- /dev/null +++ b/crates/media-metadata/src/image/flash/mod.rs @@ -0,0 +1,6 @@ +pub mod consts; +mod data; +mod values; + +pub use data::{Flash, FlashMode}; +pub use values::FlashValue; diff --git a/crates/media-metadata/src/image/flash/values.rs b/crates/media-metadata/src/image/flash/values.rs new file mode 100644 index 000000000..8df205515 --- /dev/null +++ b/crates/media-metadata/src/image/flash/values.rs @@ -0,0 +1,136 @@ +use std::fmt::Display; + +// https://exiftool.org/TagNames/EXIF.html scroll to bottom to get codds +#[derive( + Clone, Copy, Default, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize, specta::Type, +)] +pub enum FlashValue { + #[default] + Unknown, + NoFire, + Fired, + FiredNoReturn, + FiredReturn, + OnNoFire, + OnFired, + OnNoReturn, + OnReturn, + OffNoFire, + OffNoFireNoReturn, + AutoNoFire, + AutoFired, + AutoFiredNoReturn, + AutoFiredReturn, + NoFlashFunction, + OffNoFlashFunction, + FiredRedEyeReduction, + FiredRedEyeReductionNoReturn, + FiredRedEyeReductionReturn, + OnRedEyeReduction, + OnRedEyeReductionNoReturn, + OnRedEyeReductionReturn, + OffRedEyeReduction, + AutoNoFireRedEyeReduction, + AutoFiredRedEyeReduction, + AutoFiredRedEyeReductionNoReturn, + AutoFiredRedEyeReductionReturn, +} + +impl FlashValue { + #[must_use] + pub fn new(value: u32) -> Option { + value.try_into().ok() + } +} + +impl From for FlashValue { + fn from(value: u32) -> Self { + match value { + 0x00 => Self::NoFire, + 0x01 => Self::Fired, + 0x05 => Self::FiredNoReturn, + 0x07 => Self::FiredReturn, + 0x08 => Self::OnNoFire, + 0x09 => Self::OnFired, + 0x0d => Self::OnNoReturn, + 0x0f => Self::OnReturn, + 0x10 => Self::OffNoFire, + 0x14 => Self::OffNoFireNoReturn, + 0x18 => Self::AutoNoFire, + 0x19 => Self::AutoFired, + 0x1d => Self::AutoFiredNoReturn, + 0x1f => Self::AutoFiredReturn, + 0x20 => Self::NoFlashFunction, + 0x30 => Self::OffNoFlashFunction, + 0x41 => Self::FiredRedEyeReduction, + 0x45 => Self::FiredRedEyeReductionNoReturn, + 0x47 => Self::FiredRedEyeReductionReturn, + 0x49 => Self::OnRedEyeReduction, + 0x4d => Self::OnRedEyeReductionNoReturn, + 0x4f => Self::OnRedEyeReductionReturn, + 0x50 => Self::OffRedEyeReduction, + 0x58 => Self::AutoNoFireRedEyeReduction, + 0x59 => Self::AutoFiredRedEyeReduction, + 0x5d => Self::AutoFiredRedEyeReductionNoReturn, + 0x5f => Self::AutoFiredRedEyeReductionReturn, + _ => Self::default(), + } + } +} + +impl Display for FlashValue { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Unknown => f.write_str("Flash data was present but we were unable to parse it"), + Self::NoFire => f.write_str("Flash didn't fire"), + Self::Fired => f.write_str("Flash fired"), + Self::FiredNoReturn => f.write_str("Flash fired but no return detected"), + Self::FiredReturn => f.write_str("Flash fired and return was detected"), + Self::OnNoFire => f.write_str("Flash was enabled but not fired"), + Self::OnFired => f.write_str("Flash was enabled and fired"), + Self::OnNoReturn => f.write_str("Flash was enabled but no return detected"), + Self::OnReturn => f.write_str("Flash was enabled and return was detected"), + Self::OffNoFire => f.write_str("Flash was disabled"), + Self::OffNoFireNoReturn => { + f.write_str("FLash was disabled, did not fire and no return was detected") + } + Self::AutoNoFire => f.write_str("Auto was enabled but flash did not fire"), + Self::AutoFired => f.write_str("Auto was enabled and fired"), + Self::AutoFiredNoReturn => { + f.write_str("Auto was enabled and fired, no return was detected") + } + Self::AutoFiredReturn => f.write_str("Auto was enabled and fired, return was detected"), + Self::NoFlashFunction => f.write_str("Device has no flash function"), + Self::OffNoFlashFunction => f.write_str("Off as device has no flash function"), + Self::FiredRedEyeReduction => f.write_str("Flash fired with red eye reduction"), + Self::FiredRedEyeReductionNoReturn => { + f.write_str("Flash fired with red eye reduction, no return was detected") + } + Self::FiredRedEyeReductionReturn => { + f.write_str("Flash fired with red eye reduction, return was detecteed") + } + Self::OnRedEyeReduction => f.write_str("Flash was enabled with red eye reduction"), + Self::OnRedEyeReductionNoReturn => { + f.write_str("Flash was enabled with red eye reduction, no return was detected") + } + Self::OnRedEyeReductionReturn => { + f.write_str("Flash was enabled with red eye reduction, return was detected") + } + Self::OffRedEyeReduction => { + f.write_str("Flash was disabled, but red eye reduction was enabled") + } + Self::AutoNoFireRedEyeReduction => { + f.write_str("Auto was enabled but didn't fire, and red eye reduction was used") + } + Self::AutoFiredRedEyeReduction => { + f.write_str("Auto was enabled and fired, and red eye reduction was used") + } + Self::AutoFiredRedEyeReductionNoReturn => f.write_str( + "Auto was enabled and fired, and red eye reduction was enabled but did not return", + ), + Self::AutoFiredRedEyeReductionReturn => f.write_str( + "Auto was enabled and fired, and red eye reduction was enabled and returned", + ), + } + } +} diff --git a/crates/media-metadata/src/image/location.rs b/crates/media-metadata/src/image/location.rs new file mode 100644 index 000000000..75207c9e6 --- /dev/null +++ b/crates/media-metadata/src/image/location.rs @@ -0,0 +1,197 @@ +use super::{ + consts::{DECIMAL_SF, DMS_DIVISION}, + ExifReader, +}; +use crate::{Error, Result}; +use exif::Tag; +use std::{fmt::Display, ops::Neg}; + +#[derive(Default, Clone, PartialEq, Debug, serde::Serialize, serde::Deserialize, specta::Type)] +pub struct MediaLocation { + latitude: f64, + longitude: f64, + altitude: Option, + direction: Option, // the direction that the image was taken in, as a bearing (should always be <= 0 && <= 360) +} + +const LAT_MAX_POS: f64 = 90_f64; +const LONG_MAX_POS: f64 = 180_f64; + +impl MediaLocation { + /// This is used to clamp and format coordinates. They are rounded to 8 significant figures after the decimal point. + /// + /// `max` must be a positive `f64`, and it should be the maximum distance allowed (e.g. 90 or 180 degrees) + #[must_use] + fn format_coordinate(v: f64, max: f64) -> f64 { + (v.clamp(max.neg(), max) * DECIMAL_SF).round() / DECIMAL_SF + } + + /// Create a new [`MediaLocation`] from a latitude and longitude pair. + /// + /// Both of the provided values will be rounded to 8 digits after the decimal point ([`DECIMAL_SF`]), + /// + /// # Examples + /// + /// ``` + /// use sd_media_metadata::image::MediaLocation; + /// + /// let x = MediaLocation::new(38.89767633, -7.36560353, Some(32), Some(20)); + /// ``` + #[must_use] + pub fn new(lat: f64, long: f64, altitude: Option, direction: Option) -> Self { + let latitude = Self::format_coordinate(lat, LAT_MAX_POS); + let longitude = Self::format_coordinate(long, LONG_MAX_POS); + + Self { + latitude, + longitude, + altitude, + direction, + } + } + + /// Create a new [`MediaLocation`] from an [`ExifReader`] instance. + /// + /// Both of the provided values will be rounded to 8 digits after the decimal point ([`DECIMAL_SF`]), + /// + /// This does not take into account the poles, e.g. N/E/S/W, but should still produce valid results (Untested!) + /// + /// # Examples + /// + /// ```ignore + /// use sd_media_metadata::image::{ExifReader, Location}; + /// + /// let mut reader = ExifReader::from_path("path").unwrap(); + /// MediaLocation::from_exif_reader(&mut reader).unwrap(); + /// ``` + pub fn from_exif_reader(reader: &ExifReader) -> Result { + let res = [ + ( + reader.get_tag(Tag::GPSLatitude), + reader.get_tag(Tag::GPSLatitudeRef), + ), + ( + reader.get_tag(Tag::GPSLongitude), + reader.get_tag(Tag::GPSLongitudeRef), + ), + ] + .into_iter() + .filter_map(|(item, reference)| { + let mut item: String = item.unwrap_or_default(); + let reference: String = reference.unwrap_or_default(); + item.retain(|x| { + x.is_numeric() || x.is_whitespace() || x == '.' || x == '/' || x == '-' + }); + let i = item + .split_whitespace() + .filter_map(|x| x.parse::().ok()); + (i.clone().count() == 3) + .then(|| i.zip(DMS_DIVISION.iter()).map(|(x, y)| x / y).sum::()) + .map(|mut x| { + (reference == "W" || reference == "S" || reference == "3" || reference == "1") + .then(|| x = x.neg()); + x + }) + }) + .collect::>(); + + (!res.is_empty() && res.len() == 2) + .then(|| { + Self::new( + Self::format_coordinate(res[0], LAT_MAX_POS), + Self::format_coordinate(res[1], LONG_MAX_POS), + reader.get_tag(Tag::GPSAltitude), + reader + .get_tag(Tag::GPSImgDirection) + .map(|x: i32| x.clamp(0, 360)), + ) + }) + .ok_or(Error::MediaLocationParse) + } + + /// # Examples + /// + /// ``` + /// use sd_media_metadata::image::MediaLocation; + /// + /// let mut home = MediaLocation::new(38.89767633, -7.36560353, Some(32), Some(20)); + /// home.update_latitude(60_f64); + /// ``` + pub fn update_latitude(&mut self, lat: f64) { + self.latitude = Self::format_coordinate(lat, LAT_MAX_POS); + } + + /// # Examples + /// + /// ``` + /// use sd_media_metadata::image::MediaLocation; + /// + /// let mut home = MediaLocation::new(38.89767633, -7.36560353, Some(32), Some(20)); + /// home.update_longitude(20_f64); + /// ``` + pub fn update_longitude(&mut self, long: f64) { + self.longitude = Self::format_coordinate(long, LONG_MAX_POS); + } + + /// # Examples + /// + /// ``` + /// use sd_media_metadata::image::MediaLocation; + /// + /// let mut home = MediaLocation::new(38.89767633, -7.36560353, Some(32), Some(20)); + /// home.update_altitude(20); + /// ``` + pub fn update_altitude(&mut self, altitude: i32) { + self.altitude = Some(altitude); + } + + /// # Examples + /// + /// ``` + /// use sd_media_metadata::image::MediaLocation; + /// + /// let mut home = MediaLocation::new(38.89767633, -7.36560353, Some(32), Some(20)); + /// home.update_direction(233); + /// ``` + pub fn update_direction(&mut self, bearing: i32) { + self.direction = Some(bearing.clamp(0, 360)); + } +} + +impl TryFrom for MediaLocation { + type Error = Error; + + /// This tries to parse a standard "34.2493458, -23.4923843" string to a [`MediaLocation`] + /// + /// # Examples: + /// + /// ``` + /// use sd_media_metadata::image::MediaLocation; + /// + /// let s = String::from("32.47583923, -28.49238495"); + /// MediaLocation::try_from(s).unwrap(); + /// + /// ``` + fn try_from(value: String) -> std::result::Result { + let iter = value + .split_terminator(", ") + .filter_map(|x| x.parse::().ok()); + if iter.clone().count() == 2 { + let items = iter.collect::>(); + Ok(Self::new( + Self::format_coordinate(items[0], LAT_MAX_POS), + Self::format_coordinate(items[1], LONG_MAX_POS), + None, + None, + )) + } else { + Err(Error::Conversion) + } + } +} + +impl Display for MediaLocation { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_fmt(format_args!("{}, {}", self.latitude, self.longitude)) + } +} diff --git a/crates/media-metadata/src/image/mod.rs b/crates/media-metadata/src/image/mod.rs new file mode 100644 index 000000000..4b8d93f0c --- /dev/null +++ b/crates/media-metadata/src/image/mod.rs @@ -0,0 +1,124 @@ +use exif::Tag; +use std::path::Path; + +mod composite; +mod consts; +mod dimensions; +mod flash; +mod location; +mod orientation; +mod profile; +mod reader; +mod time; + +pub use composite::Composite; +pub use consts::DMS_DIVISION; +pub use dimensions::Dimensions; +pub use flash::{Flash, FlashMode, FlashValue}; +pub use location::MediaLocation; +pub use orientation::Orientation; +pub use profile::ColorProfile; +pub use reader::ExifReader; +pub use time::MediaTime; + +use crate::Result; + +#[derive(Default, Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize, specta::Type)] +pub struct ImageMetadata { + pub dimensions: Dimensions, + pub date_taken: MediaTime, + pub location: Option, + pub camera_data: ImageData, + pub artist: Option, + pub description: Option, + pub copyright: Option, + pub exif_version: Option, +} + +#[derive(Default, Clone, PartialEq, Debug, serde::Serialize, serde::Deserialize, specta::Type)] +pub struct ImageData { + pub device_make: Option, + pub device_model: Option, + pub color_space: Option, + pub color_profile: Option, + pub focal_length: Option, + pub shutter_speed: Option, + pub flash: Option, + pub orientation: Orientation, + pub lens_make: Option, + pub lens_model: Option, + pub bit_depth: Option, + pub red_eye: Option, + pub zoom: Option, + pub iso: Option, + pub software: Option, + pub serial_number: Option, + pub lens_serial_number: Option, + pub contrast: Option, + pub saturation: Option, + pub sharpness: Option, + pub composite: Option, +} + +impl ImageMetadata { + pub fn from_path(path: impl AsRef) -> Result { + Self::from_reader(&ExifReader::from_path(path)?) + } + + pub fn from_slice(bytes: &[u8]) -> Result { + Self::from_reader(&ExifReader::from_slice(bytes)?) + } + + #[allow(clippy::field_reassign_with_default)] + pub fn from_reader(reader: &ExifReader) -> Result { + let mut data = Self::default(); + let camera_data = &mut data.camera_data; + + data.date_taken = MediaTime::from_reader(reader); + data.dimensions = Dimensions::from_reader(reader); + data.artist = reader.get_tag(Tag::Artist); + data.description = reader.get_tag(Tag::ImageDescription); + data.copyright = reader.get_tag(Tag::Copyright); + data.exif_version = reader.get_tag(Tag::ExifVersion); + data.location = MediaLocation::from_exif_reader(reader).ok(); + + camera_data.device_make = reader.get_tag(Tag::Make); + camera_data.device_model = reader.get_tag(Tag::Model); + camera_data.focal_length = reader.get_tag(Tag::FocalLength); + camera_data.shutter_speed = reader.get_tag(Tag::ShutterSpeedValue); + camera_data.color_space = reader.get_tag(Tag::ColorSpace); + camera_data.color_profile = ColorProfile::from_reader(reader); + + camera_data.lens_make = reader.get_tag(Tag::LensMake); + camera_data.lens_model = reader.get_tag(Tag::LensModel); + camera_data.iso = reader.get_tag(Tag::PhotographicSensitivity); + camera_data.zoom = reader + .get_tag(Tag::DigitalZoomRatio) + .map(|x: String| x.replace("unused", "1").parse().ok()) + .unwrap_or_default(); + + camera_data.bit_depth = reader.get_tag::(Tag::BitsPerSample).map_or_else( + || { + reader + .get_tag::(Tag::CompressedBitsPerPixel) + .unwrap_or_default() + .parse() + .ok() + }, + |x| x.parse::().ok(), + ); + + camera_data.orientation = Orientation::from_reader(reader).unwrap_or_default(); + camera_data.flash = Flash::from_reader(reader); + camera_data.software = reader.get_tag(Tag::Software); + camera_data.serial_number = reader.get_tag(Tag::BodySerialNumber); + camera_data.lens_serial_number = reader.get_tag(Tag::LensSerialNumber); + camera_data.software = reader.get_tag(Tag::Software); + camera_data.contrast = reader.get_tag(Tag::Contrast); + camera_data.saturation = reader.get_tag(Tag::Saturation); + camera_data.sharpness = reader.get_tag(Tag::Sharpness); + camera_data.composite = Composite::from_reader(reader); + + Ok(data) + } +} diff --git a/crates/media-metadata/src/image/orientation.rs b/crates/media-metadata/src/image/orientation.rs new file mode 100644 index 000000000..3cf0b5cff --- /dev/null +++ b/crates/media-metadata/src/image/orientation.rs @@ -0,0 +1,63 @@ +use super::ExifReader; +use exif::Tag; +use image_rs::DynamicImage; +use std::path::Path; + +#[derive( + Default, Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize, specta::Type, +)] +pub enum Orientation { + #[default] + Normal, + MirroredHorizontal, + CW90, + MirroredVertical, + MirroredHorizontalAnd270CW, + MirroredHorizontalAnd90CW, + CW180, + CW270, +} + +impl Orientation { + /// This is used for quickly sourcing [`Orientation`] data from a path, to be later used by one of the modification functions. + #[allow(clippy::future_not_send)] + pub fn source_orientation(path: impl AsRef) -> Option { + let reader = ExifReader::from_path(path).ok()?; + reader.get_tag_int(Tag::Orientation).map(Into::into) + } + + /// This is used for quickly sourcing an [`Orientation`] data from an [`ExifReader`] + pub fn from_reader(reader: &ExifReader) -> Option { + reader.get_tag_int(Tag::Orientation).map(Into::into) + } + + /// This is used to correct thumbnails in the thumbnailer, if we are able to source orientation data for the file at hand. + #[must_use] + pub fn correct_thumbnail(&self, img: DynamicImage) -> DynamicImage { + match self { + Self::Normal => img, + Self::CW180 => img.rotate180(), + Self::CW270 => img.rotate270(), + Self::CW90 => img.rotate90(), + Self::MirroredHorizontal => img.fliph(), + Self::MirroredVertical => img.flipv(), + Self::MirroredHorizontalAnd90CW => img.fliph().rotate90(), + Self::MirroredHorizontalAnd270CW => img.fliph().rotate270(), + } + } +} + +impl From for Orientation { + fn from(value: u32) -> Self { + match value { + 2 => Self::MirroredHorizontal, + 3 => Self::CW180, + 4 => Self::MirroredVertical, + 5 => Self::MirroredHorizontalAnd270CW, + 6 => Self::CW90, + 7 => Self::MirroredHorizontalAnd90CW, + 8 => Self::CW270, + _ => Self::Normal, + } + } +} diff --git a/crates/media-metadata/src/image/profile.rs b/crates/media-metadata/src/image/profile.rs new file mode 100644 index 000000000..ac86eae1b --- /dev/null +++ b/crates/media-metadata/src/image/profile.rs @@ -0,0 +1,55 @@ +use super::ExifReader; +use exif::Tag; +use std::fmt::Display; + +#[derive( + Default, Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize, specta::Type, +)] +pub enum ColorProfile { + #[default] + Normal, + Custom, + HDRNoOriginal, + HDRWithOriginal, + OriginalForHDR, + Panorama, + PortraitHDR, + Portrait, +} + +impl ColorProfile { + /// This is used for quickly sourcing a [`ColorProfile`] data from an [`ExifReader`] + pub fn from_reader(reader: &ExifReader) -> Option { + reader.get_tag_int(Tag::CustomRendered).map(Into::into) + } +} + +impl From for ColorProfile { + fn from(value: u32) -> Self { + match value { + 0 => Self::Custom, + 2 => Self::HDRNoOriginal, + 3 => Self::HDRWithOriginal, + 4 => Self::OriginalForHDR, + 6 => Self::Panorama, + 7 => Self::Portrait, + 8 => Self::PortraitHDR, + _ => Self::Normal, + } + } +} + +impl Display for ColorProfile { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Normal => f.write_str("Normal"), + Self::Custom => f.write_str("Custom"), + Self::HDRNoOriginal => f.write_str("HDR (with no original saved)"), + Self::HDRWithOriginal => f.write_str("HDR (with original saved)"), + Self::OriginalForHDR => f.write_str("Original for HDR image"), + Self::Panorama => f.write_str("Panorama"), + Self::Portrait => f.write_str("Portrait"), + Self::PortraitHDR => f.write_str("HDR Portrait"), + } + } +} diff --git a/crates/media-metadata/src/image/reader.rs b/crates/media-metadata/src/image/reader.rs new file mode 100644 index 000000000..4d4a64d1b --- /dev/null +++ b/crates/media-metadata/src/image/reader.rs @@ -0,0 +1,54 @@ +use std::{ + fs::File, + io::{BufReader, Cursor}, + path::Path, + str::FromStr, +}; + +use exif::{Exif, In, Tag}; + +use crate::{Error, Result}; + +/// An [`ExifReader`]. This can get exif tags from images (either files or slices). +pub struct ExifReader(Exif); + +impl ExifReader { + pub fn from_path(path: impl AsRef) -> Result { + exif::Reader::new() + .read_from_container(&mut BufReader::new(File::open(&path)?)) + .map_or_else( + |_| Err(Error::NoExifDataOnPath(path.as_ref().to_path_buf())), + |reader| Ok(Self(reader)), + ) + } + + pub fn from_slice(slice: &[u8]) -> Result { + exif::Reader::new() + .read_from_container(&mut Cursor::new(slice)) + .map_or_else(|_| Err(Error::NoExifDataOnSlice), |reader| Ok(Self(reader))) + } + + /// A helper function which gets the target `Tag` as `T`, provided `T` impls `FromStr`. + /// + /// This function strips any erroneous newlines + #[must_use] + pub fn get_tag(&self, tag: Tag) -> Option + where + T: FromStr, + { + self.0.get_field(tag, In::PRIMARY).map(|x| { + x.display_value() + .to_string() + .replace(['\\', '\"'], "") + .parse::() + .ok() + })? + } + + pub(crate) fn get_tag_int(&self, tag: Tag) -> Option { + self.0 + .get_field(tag, In::PRIMARY) + .map(|x| x.value.get_uint(0)) + .unwrap_or_default() + } +} diff --git a/crates/media-metadata/src/image/time.rs b/crates/media-metadata/src/image/time.rs new file mode 100644 index 000000000..f33a2646a --- /dev/null +++ b/crates/media-metadata/src/image/time.rs @@ -0,0 +1,101 @@ +use super::{ + consts::{OFFSET_TAGS, TIME_TAGS}, + ExifReader, +}; +use crate::Error; +use chrono::{DateTime, FixedOffset, NaiveDateTime}; +use std::fmt::Display; + +pub const NAIVE_FORMAT_STR: &str = "%Y-%m-%d %H:%M:%S"; + +#[derive(Default, Clone, Debug, PartialEq, Eq, serde::Deserialize, specta::Type)] +/// This can be either naive with no TZ (`YYYY-MM-DD HH-MM-SS`) or UTC with a fixed offset (`rfc3339`). +/// +/// This may also be `undefined`. +pub enum MediaTime { + Naive(NaiveDateTime), + Utc(DateTime), + #[default] + Undefined, +} + +impl MediaTime { + /// This iterates over all 3 pairs of time/offset tags in an attempt to create a UTC time. + /// + /// If the above fails, we fall back to Naive time - if that's not present this is `Undefined`. + pub fn from_reader(reader: &ExifReader) -> Self { + let z = TIME_TAGS + .into_iter() + .zip(OFFSET_TAGS) + .filter_map(|(time_tag, offset_tag)| { + let time = reader.get_tag::(time_tag); + let offset = reader.get_tag::(offset_tag); + + if let (Some(t), Some(o)) = (time.clone(), offset) { + DateTime::parse_and_remainder(&format!("{t} {o}"), "%F %X %#z") + .ok() + .map(|x| Self::Utc(x.0)) + } else if let Some(t) = time { + Some( + NaiveDateTime::parse_from_str(&t, NAIVE_FORMAT_STR) + .map_or(Self::Undefined, Self::Naive), + ) + } else { + Some(Self::Undefined) + } + }) + .collect::>(); + + z.iter() + .find(|x| match x { + Self::Utc(_) | Self::Naive(_) => true, + Self::Undefined => false, + }) + .map_or(Self::Undefined, Clone::clone) + } +} + +impl TryFrom for MediaTime { + type Error = Error; + + fn try_from(value: String) -> Result { + if &value == "Undefined" { + return Ok(Self::Undefined); + } + + if let Ok(time) = DateTime::parse_from_rfc3339(&value) { + return Ok(Self::Utc(time)); + } + + Ok(NaiveDateTime::parse_from_str(&value, NAIVE_FORMAT_STR) + .map_or(Self::Undefined, Self::Naive)) + } +} + +impl Display for MediaTime { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Undefined => f.write_str("Undefined"), + Self::Naive(l) => f.write_str(&l.to_string()), + Self::Utc(u) => f.write_str(&u.to_rfc3339()), + } + } +} + +impl serde::Serialize for MediaTime { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + Self::Naive(t) => serializer.collect_str(&t.to_string()), + Self::Utc(t) => { + let local = NaiveDateTime::from_timestamp_millis(t.timestamp_millis()).ok_or_else( + || serde::ser::Error::custom("Error converting UTC to Naive time"), + )?; + serializer.collect_str(&local.format("%Y-%m-%d %H:%M:%S").to_string()) + } + Self::Undefined => serializer.collect_str("Undefined"), + } + } +} diff --git a/crates/media-metadata/src/lib.rs b/crates/media-metadata/src/lib.rs new file mode 100644 index 000000000..2e9759cf2 --- /dev/null +++ b/crates/media-metadata/src/lib.rs @@ -0,0 +1,40 @@ +#![doc = include_str!("../README.md")] +#![warn( + clippy::all, + clippy::pedantic, + clippy::correctness, + clippy::perf, + clippy::style, + clippy::suspicious, + clippy::complexity, + clippy::nursery, + clippy::unwrap_used, + unused_qualifications, + rust_2018_idioms, + clippy::expect_used, + trivial_casts, + trivial_numeric_casts, + unused_allocation, + clippy::as_conversions, + clippy::dbg_macro +)] +#![forbid(unsafe_code)] +#![allow(clippy::missing_errors_doc, clippy::module_name_repetitions)] + +pub mod audio; +mod error; +pub mod image; +pub mod video; + +pub use audio::AudioMetadata; +pub use error::{Error, Result}; +pub use image::ImageMetadata; +pub use video::VideoMetadata; + +#[derive(Clone, PartialEq, Debug, serde::Serialize, serde::Deserialize, specta::Type)] +#[serde(tag = "type")] +pub enum MediaMetadata { + Image(Box), + Video(Box), + Audio(Box), +} diff --git a/crates/media-metadata/src/video.rs b/crates/media-metadata/src/video.rs new file mode 100644 index 000000000..a1c4922dd --- /dev/null +++ b/crates/media-metadata/src/video.rs @@ -0,0 +1,20 @@ +use std::path::Path; + +use crate::Result; + +#[derive( + Default, Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize, specta::Type, +)] +pub struct VideoMetadata { + duration: Option, // bigint + video_codec: Option, + audio_codec: Option, +} + +impl VideoMetadata { + #[allow(clippy::missing_errors_doc)] + #[allow(clippy::missing_panics_doc)] + pub fn from_path(_path: impl AsRef) -> Result { + todo!() + } +} diff --git a/interface/app/$libraryId/settings/library/locations/AddLocationDialog.tsx b/interface/app/$libraryId/settings/library/locations/AddLocationDialog.tsx index 7c8ba9008..0bc3d8748 100644 --- a/interface/app/$libraryId/settings/library/locations/AddLocationDialog.tsx +++ b/interface/app/$libraryId/settings/library/locations/AddLocationDialog.tsx @@ -13,6 +13,7 @@ import { } from '@sd/client'; import { Dialog, ErrorMessage, InputField, UseDialogProps, useDialog, z } from '@sd/ui'; import { showAlertDialog } from '~/components'; +import Accordion from '~/components/Accordion'; import { useCallbackToWatchForm } from '~/hooks'; import { Platform, usePlatform } from '~/util/Platform'; import IndexerRuleEditor from './IndexerRuleEditor'; @@ -68,7 +69,6 @@ export const AddLocationDialog = ({ const relinkLocation = useLibraryMutation('locations.relink'); const listIndexerRules = useLibraryQuery(['locations.indexer_rules.list']); const addLocationToLibrary = useLibraryMutation('locations.addLibrary'); - const [toggleSettings, setToggleSettings] = useState(false); // This is required because indexRules is undefined on first render const indexerRulesIds = useMemo( @@ -217,7 +217,7 @@ export const AddLocationDialog = ({ : '' } > - + -
-
setToggleSettings((t) => !t)} - className="flex items-center justify-between px-3 py-2" - > -

Advanced settings

- -
- {toggleSettings && ( -
- ( - - )} - control={form.control} + + ( + -
- )} -
+ )} + control={form.control} + /> + ); }; diff --git a/interface/components/Accordion.tsx b/interface/components/Accordion.tsx new file mode 100644 index 000000000..35a4821d6 --- /dev/null +++ b/interface/components/Accordion.tsx @@ -0,0 +1,34 @@ +import clsx from 'clsx'; +import { CaretDown } from 'phosphor-react'; +import { useState } from 'react'; + +interface Props { + children: React.ReactNode; + className?: string; + title: string; +} + +const Accordion = ({ title, className, children }: Props) => { + const [toggle, setToggle] = useState(false); + + return ( +
+
setToggle((t) => !t)} + className="flex items-center justify-between px-3 py-2" + > +

{title}

+ +
+ {toggle && ( +
+ {children} +
+ )} +
+ ); +}; + +export default Accordion; diff --git a/packages/client/package.json b/packages/client/package.json index 81eda8249..a388bf39a 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -22,6 +22,7 @@ "@zxcvbn-ts/language-common": "^2.0.1", "@zxcvbn-ts/language-en": "^2.1.0", "plausible-tracker": "^0.3.8", + "react-hook-form": "~7.45.2", "valtio": "^1.7.4", "zod": "~3.22.2" }, diff --git a/packages/client/src/core.ts b/packages/client/src/core.ts index d9028e4ee..58b3ace68 100644 --- a/packages/client/src/core.ts +++ b/packages/client/src/core.ts @@ -6,7 +6,8 @@ export type Procedures = { { key: "backups.getAll", input: never, result: GetAll } | { key: "buildInfo", input: never, result: BuildInfo } | { key: "categories.list", input: LibraryArgs, result: { [key in Category]: number } } | - { key: "files.get", input: LibraryArgs, result: { id: number; pub_id: number[]; kind: number | null; key_id: number | null; hidden: boolean | null; favorite: boolean | null; important: boolean | null; note: string | null; date_created: string | null; date_accessed: string | null; file_paths: FilePath[]; media_data: MediaData | null } | null } | + { key: "files.get", input: LibraryArgs, result: { id: number; pub_id: number[]; kind: number | null; key_id: number | null; hidden: boolean | null; favorite: boolean | null; important: boolean | null; note: string | null; date_created: string | null; date_accessed: string | null; file_paths: FilePath[] } | null } | + { key: "files.getMediaData", input: LibraryArgs, result: MediaMetadata } | { key: "files.getPath", input: LibraryArgs, result: string | null } | { key: "invalidation.test-invalidate", input: never, result: number } | { key: "jobs.isActive", input: LibraryArgs, result: boolean } | @@ -96,6 +97,8 @@ export type Procedures = { { key: "sync.newMessage", input: LibraryArgs, result: null } }; +export type AudioMetadata = { duration: number | null; audio_codec: string | null } + export type Backup = ({ id: string; timestamp: string; library_id: string; library_name: string }) & { path: string } export type BuildInfo = { version: string; commit: string } @@ -111,8 +114,14 @@ export type Category = "Recents" | "Favorites" | "Albums" | "Photos" | "Videos" export type ChangeNodeNameArgs = { name: string | null } +export type ColorProfile = "Normal" | "Custom" | "HDRNoOriginal" | "HDRWithOriginal" | "OriginalForHDR" | "Panorama" | "PortraitHDR" | "Portrait" + +export type Composite = "Unknown" | "False" | "General" | "Live" + export type CreateLibraryArgs = { name: LibraryName } +export type Dimensions = { width: number; height: number } + export type DiskType = "SSD" | "HDD" | "Removable" export type DoubleClickAction = "openFile" | "quickPreview" @@ -152,6 +161,10 @@ export type FilePathSearchOrdering = { field: "name"; value: SortOrder } | { fie export type FilePathWithObject = { id: number; pub_id: number[]; is_dir: boolean | null; cas_id: string | null; integrity_checksum: string | null; location_id: number | null; materialized_path: string | null; name: string | null; extension: string | null; size_in_bytes: string | null; size_in_bytes_bytes: number[] | null; inode: number[] | null; device: number[] | null; object_id: number | null; key_id: number | null; date_created: string | null; date_modified: string | null; date_indexed: string | null; object: Object | null } +export type Flash = { mode: FlashMode; fired: boolean | null; returned: boolean | null; red_eye_reduction: boolean | null } + +export type FlashMode = "Unknown" | "On" | "Off" | "Auto" | "Forced" + export type FromPattern = { pattern: string; replace_all: boolean } export type FullRescanArgs = { location_id: number; reidentify_objects: boolean } @@ -166,6 +179,10 @@ export type Header = { id: string; timestamp: string; library_id: string; librar export type IdentifyUniqueFilesArgs = { id: number; path: string } +export type ImageData = { device_make: string | null; device_model: string | null; color_space: string | null; color_profile: ColorProfile | null; focal_length: number | null; shutter_speed: number | null; flash: Flash | null; orientation: Orientation; lens_make: string | null; lens_model: string | null; bit_depth: number | null; red_eye: boolean | null; zoom: number | null; iso: number | null; software: string | null; serial_number: string | null; lens_serial_number: string | null; contrast: number | null; saturation: number | null; sharpness: number | null; composite: Composite | null } + +export type ImageMetadata = { dimensions: Dimensions; date_taken: MediaTime; location: MediaLocation | null; camera_data: ImageData; artist: string | null; description: string | null; copyright: string | null; exif_version: string | null } + export type IndexerRule = { id: number; pub_id: number[]; name: string | null; default: boolean | null; rules_per_kind: number[] | null; date_created: string | null; date_modified: string | null } /** @@ -239,7 +256,16 @@ export type MaybeNot = T | { not: T } export type MaybeUndefined = null | null | T -export type MediaData = { id: number; pixel_width: number | null; pixel_height: number | null; longitude: number | null; latitude: number | null; fps: number | null; capture_device_make: string | null; capture_device_model: string | null; capture_device_software: string | null; duration_seconds: number | null; codecs: string | null; streams: number | null } +export type MediaLocation = { latitude: number; longitude: number; altitude: number | null; direction: number | null } + +export type MediaMetadata = ({ type: "Image" } & ImageMetadata) | ({ type: "Video" } & VideoMetadata) | ({ type: "Audio" } & AudioMetadata) + +/** + * This can be either naive with no TZ (`YYYY-MM-DD HH-MM-SS`) or UTC with a fixed offset (`rfc3339`). + * + * This may also be `undefined`. + */ +export type MediaTime = { Naive: string } | { Utc: string } | "Undefined" export type NodeState = ({ id: string; name: string; p2p_port: number | null; p2p_email: string | null; p2p_img_url: string | null }) & { data_path: string } @@ -288,6 +314,8 @@ export type OperatingSystem = "Windows" | "Linux" | "MacOS" | "Ios" | "Android" export type OptionalRange = { from: T | null; to: T | null } +export type Orientation = "Normal" | "MirroredHorizontal" | "CW90" | "MirroredVertical" | "MirroredHorizontalAnd270CW" | "MirroredHorizontalAnd90CW" | "CW180" | "CW270" + /** * TODO: P2P event for the frontend */ @@ -343,4 +371,6 @@ export type TagCreateArgs = { name: string; color: string } export type TagUpdateArgs = { id: number; name: string | null; color: string | null } +export type VideoMetadata = { duration: number | null; video_codec: string | null; audio_codec: string | null } + export type Volume = { name: string; mount_points: string[]; total_capacity: string; available_capacity: string; disk_type: DiskType; file_system: string | null; is_root_filesystem: boolean } diff --git a/packages/test-files b/packages/test-files index 58edee8a3..146fbb543 160000 --- a/packages/test-files +++ b/packages/test-files @@ -1 +1 @@ -Subproject commit 58edee8a341e243559b6990fe78067d8d7398556 +Subproject commit 146fbb543fc001bcd8fe5c0d4d59d2bc2948c5f8 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5cb8705c05a896f09dbc7bada73529d65ac9a194..9686111b6ca58dfefb3f9896633b1e43cd8f557b 100644 GIT binary patch delta 35697 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 delta 47111 zcmceWhEYi;K3PqLa0FO0{OoAYHz8>I#mt z50jv^CDziZPej+_==kd_`pD7`g!se z22?+hT|U*wo-v9^r+#+9O?~LS`}!75j2!BlIA^kV>by&_9<=VNo~;w7Pwtrd>lJ6P z>9?uVC!_5rDBm`9;)kzJzipV>{9`*d)61&67f#)I%d(S7PW*T7q>{es})FlRx{@42loj z|Ln}m@dpN`eocdG=G|zT+h=Lk?W?1?FG$PwanXreNx_S9_*=oHy~}qkR(( zy*6F_MHj(kQ;+@ad$Zuq|Hsd|Rr4l1Z+zH&8J;S>*;#e8{~LJE#BFc=2HkWibMeHU zx0g)Z^3g^&*J#w6wIa9LBqY39z08`anG@Agz0$0tYQDTCthJP^wWe0q(zoPNf7X`r zRP+INOcLS|9GA^1(Mq^lb=IU)1@p?dT&c^*5()I~+1TcZ1D~y&@Vz}B<4q*rSvzsZ zTLb9F9Bhl*m#yanVsR}P;fPi8qFN!4i#=RrIN&LW1&UDJlMP!9VS&R|ttS)wbe$(~ zrR+7Y$D0WowIPKq!jW=$9zvx~Tf@ngL+dY>3w|WWr$-z`&8x7XsY~B^07E-><0F&v z2S$-TgpZ)(L)gxV1IJ8=Wy3}%cQ6JRf*lfc?}CA|FzJN-#B%h{?d<-E+KF4yz84sM z==dG%epEOeWB*9;E_U@+Y;I5M&@ZeQ2OZyqE3q>A%!)1ha*r!9Vj_Ryf6(SpY;a=y z{S#e$*3qa?ZMUP57QSTSZ~xwco`Vt4!36^{#Jdmco!EWiS0|y-8_t)aCr>&(f zT2l2$Djtia(*kXoC%5n-UcS=kF@?1fbmb!K%!v=*-}Tk=rKHE&Cbon^eOvo^@&7gZ zsr(5x12})}qY#E(oy3<-=sz54qi?L3%%N;D+^iY}@-$cDQW`uVRW5DeJ2NgRHyw^@ zT*;~`nRK|_PIFFYPDUC|Wkf5=Mba*-PFKk1v)(9I)JWHKX|**MuBZHZ3Vl0za~0N) z`qwiD&^@N1<*0W(b7*4sM~h&SYagvam#qewW7&X>Onm(DpV7{Xu%UL*uKD;@Bwm1R zb7%Q#n=s?C+6om1N1&>!_yQu{N(L<9Y~JG3X|o20)tGNeC7Ha}ZEqTa9-k^+$d;6krr4iFu!+LI=OcV6Htv!X{UwW8 ztV@>&Z7c1-C3pSa>;8iSJ1n@(yuYE{ez^ zS*@vI@mo_-g|zHe1v3IK+B?QvGr1lMpaSaQpfM%33>{s|7(<)z;bC-qV{b3Ydns*u1jo{{=QX(9t}$_&;DHr4cEu#N?-bS=9dlhgmBEh%7@FZ5!Tn>ap40 z=2ZulFgBgENmGK)FX*FoNr83^V54YE0qoRPZziR(s#WAKX^jmNH)=6RGcmP}AXGU= zCa<+w%h8}*YSCCs;*{K-%W;h1a8U0|YJx3giqPjXHk&G?bw*nDme13&syvROn_{PX z(EbLdL%d~}0G;yzwhhG@*mdaO^Y{Y9>w(%X9t+XY9JcY);6VZ&m*UiHPd_ySP!qiv z*#<$uwK1`Ib7y-4#haI+!$tfIYRbK6HxnbK=e>ZYhf{~vMQu9RJ&0}k3f!*mGU6{| zw+W3g@WtdyJ=k$3y5})$6@telu#0=Q4R1zkda=JEZ3v%FLeH-=+Cr=_ADbYLk6;Om zdS8e}9>7KDd-E}b4h>=}(5g{v5j|TJ7{wl$mZh`=>_`M53E;c&S?u&BB|Z<0EW&27 zSn`8K*y|var&%s><%v_hEzNjcc$n4l8z36I+gsZovA`InQ8= zCtt+aNV^={iOx9+Ft&TOE>XcEFO$v)Dr!H

WF%^=mxR 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