From bfc41f0aca82681bab573fae4a9859d499be5a5d Mon Sep 17 00:00:00 2001 From: Louis Chemineau Date: Wed, 25 Oct 2023 18:10:26 +0200 Subject: [PATCH] Use new metadata API Signed-off-by: Louis Chemineau --- lib/AppInfo/Application.php | 12 +- lib/MetadataProvider/ExifMetadataProvider.php | 136 ++++++++++++++++++ .../OriginalDateTimeMetadataProvider.php | 94 ++++++++++++ .../PlaceMetadataProvider.php | 62 ++++++++ lib/MetadataProvider/SizeMetadataProvider.php | 72 ++++++++++ 5 files changed, 373 insertions(+), 3 deletions(-) create mode 100644 lib/MetadataProvider/ExifMetadataProvider.php create mode 100644 lib/MetadataProvider/OriginalDateTimeMetadataProvider.php create mode 100644 lib/MetadataProvider/PlaceMetadataProvider.php create mode 100644 lib/MetadataProvider/SizeMetadataProvider.php diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index a0f50f199..d1e987d06 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -31,12 +31,15 @@ use OCA\Photos\Listener\PlaceManagerEventListener; use OCA\Photos\Listener\SabrePluginAuthInitListener; use OCA\Photos\Listener\TagListener; +use OCA\Photos\MetadataProvider\ExifMetadataProvider; +use OCA\Photos\MetadataProvider\OriginalDateTimeMetadataProvider; +use OCA\Photos\MetadataProvider\SizeMetadataProvider; use OCP\AppFramework\App; use OCP\AppFramework\Bootstrap\IBootContext; use OCP\AppFramework\Bootstrap\IBootstrap; use OCP\AppFramework\Bootstrap\IRegistrationContext; use OCP\Files\Events\Node\NodeDeletedEvent; -use OCP\Files\Events\Node\NodeWrittenEvent; +use OCP\FilesMetadata\Event\MetadataLiveEvent; use OCP\Group\Events\GroupDeletedEvent; use OCP\Group\Events\UserRemovedEvent; use OCP\Share\Events\ShareDeletedEvent; @@ -75,8 +78,11 @@ public function register(IRegistrationContext $context): void { /** Register $principalBackend for the DAV collection */ $context->registerServiceAlias('principalBackend', Principal::class); - // Priority of -1 to be triggered after event listeners populating metadata. - $context->registerEventListener(NodeWrittenEvent::class, PlaceManagerEventListener::class, -1); + // Metadata + $context->registerEventListener(MetadataLiveEvent::class, ExifMetadataProvider::class, 1); + $context->registerEventListener(MetadataLiveEvent::class, SizeMetadataProvider::class); + $context->registerEventListener(MetadataLiveEvent::class, OriginalDateTimeMetadataProvider::class); + $context->registerEventListener(MetadataLiveEvent::class, PlaceManagerEventListener::class, -1); $context->registerEventListener(NodeDeletedEvent::class, AlbumsManagementEventListener::class); $context->registerEventListener(UserRemovedEvent::class, AlbumsManagementEventListener::class); diff --git a/lib/MetadataProvider/ExifMetadataProvider.php b/lib/MetadataProvider/ExifMetadataProvider.php new file mode 100644 index 000000000..167b98b1e --- /dev/null +++ b/lib/MetadataProvider/ExifMetadataProvider.php @@ -0,0 +1,136 @@ + + * @copyright Copyright 2022 Louis Chmn + * @license AGPL-3.0-or-later + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License, version 3, + * along with this program. If not, see + * + */ + +namespace OCA\Photos\MetadataProvider; + +use OCA\Photos\AppInfo\Application; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use OCP\Files\File; +use OCP\FilesMetadata\Event\MetadataLiveEvent; +use Psr\Log\LoggerInterface; + +/** + * Extract EXIF, IFD0, and GPS data from a picture file. + * EXIF data reference: https://web.archive.org/web/20220428165430/exif.org/Exif2-2.PDF + * + * @template-implements IEventListener + */ +class ExifMetadataProvider implements IEventListener { + public function __construct( + private LoggerInterface $logger + ) { + } + + public function handle(Event $event): void { + if (!($event instanceof MetadataLiveEvent)) { + return; + } + + $node = $event->getNode(); + + if (!$node instanceof File) { + return; + } + + $path = $node->getPath(); + if (str_starts_with($path, 'appdata_') || str_starts_with($path, 'files_versions/') || str_starts_with($path, 'files_trashbin/')) { + return; + } + + if (!in_array($node->getMimeType(), Application::IMAGE_MIMES)) { + return; + } + + if (!extension_loaded('exif')) { + return; + } + + $fileDescriptor = $node->fopen('rb'); + if ($fileDescriptor === false) { + return; + } + + $rawExifData = null; + + try { + // HACK: The stream_set_chunk_size call is needed to make reading exif data reliable. + // This is to trigger this condition: https://github.com/php/php-src/blob/d64aa6f646a7b5e58359dc79479860164239580a/main/streams/streams.c#L710 + // But I don't understand yet why 1 as a special meaning. + $oldBufferSize = stream_set_chunk_size($fileDescriptor, 1); + $rawExifData = @exif_read_data($fileDescriptor, 'EXIF, GPS', true); + // We then revert the change after having read the exif data. + stream_set_chunk_size($fileDescriptor, $oldBufferSize); + } catch (\Exception $ex) { + $this->logger->info("Failed to extract metadata for " . $node->getId(), ['exception' => $ex]); + } + + if ($rawExifData && array_key_exists('EXIF', $rawExifData)) { + $event->getMetadata()->setArray('photos-exif', $rawExifData['EXIF']); + } + + if ($rawExifData && array_key_exists('IFD0', $rawExifData)) { + $event->getMetadata()->setArray('photos-ifd0', $rawExifData['IFD0']); + } + + if ( + $rawExifData && + array_key_exists('GPS', $rawExifData) && + array_key_exists('GPSLatitude', $rawExifData['GPS']) && array_key_exists('GPSLatitudeRef', $rawExifData['GPS']) && + array_key_exists('GPSLongitude', $rawExifData['GPS']) && array_key_exists('GPSLongitudeRef', $rawExifData['GPS']) + ) { + $event->getMetadata()->setArray('photos-gps', [ + 'latitude' => $this->gpsDegreesToDecimal($rawExifData['GPS']['GPSLatitude'], $rawExifData['GPS']['GPSLatitudeRef']), + 'longitude' => $this->gpsDegreesToDecimal($rawExifData['GPS']['GPSLongitude'], $rawExifData['GPS']['GPSLongitudeRef']), + 'altitude' => ($rawExifData['GPS']['GPSAltitudeRef'] === "\u{0000}" ? 1 : -1) * $this->parseGPSData($rawExifData['GPS']['GPSAltitude']), + ]); + } + } + + /** + * @param array|string $coordinates + */ + private function gpsDegreesToDecimal($coordinates, ?string $hemisphere): float { + if (is_string($coordinates)) { + $coordinates = array_map("trim", explode(",", $coordinates)); + } + + if (count($coordinates) !== 3) { + throw new \Exception('Invalid coordinate format: ' . json_encode($coordinates)); + } + + [$degrees, $minutes, $seconds] = array_map(fn ($rawDegree) => $this->parseGPSData($rawDegree), $coordinates); + + $sign = ($hemisphere === 'W' || $hemisphere === 'S') ? -1 : 1; + return $sign * ($degrees + $minutes / 60 + $seconds / 3600); + } + + private function parseGPSData(string $rawData): float { + $parts = explode('/', $rawData); + + if ($parts[1] === '0') { + return 0; + } + + return floatval($parts[0]) / floatval($parts[1] ?? 1); + } +} diff --git a/lib/MetadataProvider/OriginalDateTimeMetadataProvider.php b/lib/MetadataProvider/OriginalDateTimeMetadataProvider.php new file mode 100644 index 000000000..fbf467dc7 --- /dev/null +++ b/lib/MetadataProvider/OriginalDateTimeMetadataProvider.php @@ -0,0 +1,94 @@ + + * @copyright Copyright 2022 Louis Chmn + * @license AGPL-3.0-or-later + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License, version 3, + * along with this program. If not, see + * + */ + +namespace OCA\Photos\MetadataProvider; + +use DateTime; +use OCA\Photos\AppInfo\Application; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use OCP\Files\File; +use OCP\FilesMetadata\Event\MetadataLiveEvent; + +/** + * @template-implements IEventListener + */ +class OriginalDateTimeMetadataProvider implements IEventListener { + public function __construct() { + } + + public function handle(Event $event): void { + if (!($event instanceof MetadataLiveEvent)) { + return; + } + + $node = $event->getNode(); + + if (!$node instanceof File) { + return; + } + + $path = $node->getPath(); + if (str_starts_with($path, 'appdata_') || str_starts_with($path, 'files_versions/') || str_starts_with($path, 'files_trashbin/')) { + return; + } + + if (!in_array($node->getMimeType(), Application::IMAGE_MIMES)) { + return; + } + + $metadata = $event->getMetadata(); + + if ($metadata->hasKey('photos-exif') && array_key_exists('DateTimeOriginal', $metadata->getArray('photos-exif'))) { + $rawDateTimeOriginal = $metadata->getArray('photos-exif')['DateTimeOriginal']; + $dateTimeOriginal = DateTime::createFromFormat("Y:m:d G:i:s", $rawDateTimeOriginal); + $metadata->setInt('photos-original_date_time', $dateTimeOriginal->getTimestamp(), true); + return; + } + + $name = $node->getName(); + $matches = []; + + $matchesCount = preg_match('/^IMG_([0-9]{8}_[0-9]{6})/', $name, $matches); + if ($matchesCount > 0) { + $dateTimeOriginal = DateTime::createFromFormat("Ymd_Gis", $matches[1]); + $metadata->setInt('photos-original_date_time', $dateTimeOriginal->getTimestamp(), true); + return; + } + + $matchesCount = preg_match('/^PANO_([0-9]{8}_[0-9]{6})/', $name, $matches); + if ($matchesCount > 0) { + $dateTimeOriginal = DateTime::createFromFormat("Ymd_Gis", $matches[1]); + $metadata->setInt('photos-original_date_time', $dateTimeOriginal->getTimestamp(), true); + return; + } + + $matchesCount = preg_match('/^([0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{2}-[0-9]{2}-[0-9]{2}-[0-9]{4})/', $name, $matches); + if ($matchesCount > 0) { + $dateTimeOriginal = DateTime::createFromFormat("Y-m-d-G-i-s", $matches[1]); + $metadata->setInt('photos-original_date_time', $dateTimeOriginal->getTimestamp(), true); + return; + } + + $metadata->setInt('photos-original_date_time', $node->getMTime(), true); + } +} diff --git a/lib/MetadataProvider/PlaceMetadataProvider.php b/lib/MetadataProvider/PlaceMetadataProvider.php new file mode 100644 index 000000000..b4788a334 --- /dev/null +++ b/lib/MetadataProvider/PlaceMetadataProvider.php @@ -0,0 +1,62 @@ + + * @license AGPL-3.0-or-later + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License, version 3, + * along with this program. If not, see + * + */ + +namespace OC\FilesMetadata\Provider; + +use DateTime; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use OCP\Files\File; +use OCP\FilesMetadata\Event\MetadataLiveEvent; + +class OriginalDateTimeMetadataProvider implements IEventListener { + public function __construct() { + } + + public function handle(Event $event): void { + if ($event instanceof MetadataLiveEvent) { + $node = $event->getNode(); + + if (!$node instanceof File) { + return; + } + + if (!preg_match('/image\/(png|jpeg|heif|webp|tiff)/', $node->getMimetype())) { + return; + } + + $event->requestBackgroundJob(); + return; + } + + if (!($event instanceof MetadataBackgroundEvent)) { + return; + } + + return; + + $metadata = $event->getMetadata(); + + $this->mediaPlaceManager->setPlaceForFile($fileId); + + $metadata->set('photos-place', '', true) + } +} diff --git a/lib/MetadataProvider/SizeMetadataProvider.php b/lib/MetadataProvider/SizeMetadataProvider.php new file mode 100644 index 000000000..e262a4436 --- /dev/null +++ b/lib/MetadataProvider/SizeMetadataProvider.php @@ -0,0 +1,72 @@ + + * @copyright Copyright 2022 Louis Chmn + * @license AGPL-3.0-or-later + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License, version 3, + * along with this program. If not, see + * + */ + +namespace OCA\Photos\MetadataProvider; + +use OCA\Photos\AppInfo\Application; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use OCP\Files\File; +use OCP\FilesMetadata\Event\MetadataLiveEvent; +use Psr\Log\LoggerInterface; + +/** + * @template-implements IEventListener + */ +class SizeMetadataProvider implements IEventListener { + public function __construct( + private LoggerInterface $logger + ) { + } + + public function handle(Event $event): void { + if (!($event instanceof MetadataLiveEvent)) { + return; + } + + $node = $event->getNode(); + + if (!$node instanceof File) { + return; + } + + $path = $node->getPath(); + if (str_starts_with($path, 'appdata_') || str_starts_with($path, 'files_versions/') || str_starts_with($path, 'files_trashbin/')) { + return; + } + + if (!in_array($node->getMimeType(), Application::IMAGE_MIMES)) { + return; + } + + $size = getimagesizefromstring($node->getContent()); + + if ($size === false) { + return; + } + + $event->getMetadata()->setArray('photos-size', [ + 'width' => $size[0], + 'height' => $size[1], + ]); + } +}