diff --git a/appinfo/info.xml b/appinfo/info.xml index cbcefa5bc..677c63729 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -32,7 +32,6 @@ OCA\Photos\Command\UpdateReverseGeocodingFilesCommand - OCA\Photos\Command\MapMediaToPlaceCommand @@ -48,4 +47,4 @@ OCA\Photos\Jobs\AutomaticPlaceMapperJob - + \ No newline at end of file diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index a0f50f199..5c4523b72 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -28,15 +28,19 @@ use OCA\DAV\Connector\Sabre\Principal; use OCA\DAV\Events\SabrePluginAuthInitEvent; use OCA\Photos\Listener\AlbumsManagementEventListener; -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\PlaceMetadataProvider; +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\MetadataBackgroundEvent; +use OCP\FilesMetadata\Event\MetadataLiveEvent; use OCP\Group\Events\GroupDeletedEvent; use OCP\Group\Events\UserRemovedEvent; use OCP\Share\Events\ShareDeletedEvent; @@ -75,8 +79,12 @@ 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); + $context->registerEventListener(MetadataLiveEvent::class, SizeMetadataProvider::class); + $context->registerEventListener(MetadataLiveEvent::class, OriginalDateTimeMetadataProvider::class); + $context->registerEventListener(MetadataLiveEvent::class, PlaceMetadataProvider::class); + $context->registerEventListener(MetadataBackgroundEvent::class, PlaceMetadataProvider::class); $context->registerEventListener(NodeDeletedEvent::class, AlbumsManagementEventListener::class); $context->registerEventListener(UserRemovedEvent::class, AlbumsManagementEventListener::class); diff --git a/lib/Command/MapMediaToPlaceCommand.php b/lib/Command/MapMediaToPlaceCommand.php deleted file mode 100644 index 2c95d8bc8..000000000 --- a/lib/Command/MapMediaToPlaceCommand.php +++ /dev/null @@ -1,116 +0,0 @@ - - * - * @author Louis Chemineau - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * 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 - * along with this program. If not, see . - * - */ -namespace OCA\Photos\Command; - -use OCA\Photos\Service\MediaPlaceManager; -use OCP\Files\Folder; -use OCP\Files\IRootFolder; -use OCP\IConfig; -use OCP\IUserManager; -use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; -use Symfony\Component\Console\Output\OutputInterface; - -class MapMediaToPlaceCommand extends Command { - public function __construct( - private IRootFolder $rootFolder, - private MediaPlaceManager $mediaPlaceManager, - private IConfig $config, - private IUserManager $userManager, - ) { - parent::__construct(); - } - - /** - * Configure the command - */ - protected function configure(): void { - $this->setName('photos:map-media-to-place') - ->setDescription('Reverse geocode media coordinates.') - ->addOption('user', 'u', InputOption::VALUE_REQUIRED, 'Limit the mapping to a user.', null); - } - - /** - * Execute the command - */ - protected function execute(InputInterface $input, OutputInterface $output): int { - if (!$this->config->getSystemValueBool('enable_file_metadata', true)) { - throw new \Exception('File metadata is not enabled.'); - } - - $userId = $input->getOption('user'); - if ($userId === null) { - $this->scanForAllUsers($output); - } else { - $this->scanFilesForUser($userId, $output); - } - - return 0; - } - - private function scanForAllUsers(OutputInterface $output): void { - $users = $this->userManager->search(''); - - $output->writeln("Scanning all users:"); - foreach ($users as $user) { - $this->scanFilesForUser($user->getUID(), $output); - } - } - - private function scanFilesForUser(string $userId, OutputInterface $output): void { - $userFolder = $this->rootFolder->getUserFolder($userId); - $output->write(" - Scanning files for $userId"); - $startTime = time(); - $count = $this->scanFolder($userFolder); - $timeElapse = time() - $startTime; - $output->writeln(" - $count files, $timeElapse sec"); - } - - private function scanFolder(Folder $folder): int { - $count = 0; - - // Do not scan share and other moveable mounts. - if ($folder->getMountPoint() instanceof \OC\Files\Mount\MoveableMount) { - return $count; - } - - foreach ($folder->getDirectoryListing() as $node) { - if ($node instanceof Folder) { - $count += $this->scanFolder($node); - continue; - } - - if (!str_starts_with($node->getMimeType(), 'image')) { - continue; - } - - $this->mediaPlaceManager->setPlaceForFile($node->getId()); - $count++; - } - - return $count; - } -} diff --git a/lib/DB/PhotosFile.php b/lib/DB/PhotosFile.php index 7a3ab90a9..982237d1f 100644 --- a/lib/DB/PhotosFile.php +++ b/lib/DB/PhotosFile.php @@ -25,12 +25,7 @@ namespace OCA\Photos\DB; -use OC\Metadata\FileMetadata; - class PhotosFile { - /** @var array */ - private array $metaData = []; - public function __construct( private int $fileId, private string $name, @@ -64,16 +59,4 @@ public function getMTime(): int { public function getEtag(): string { return $this->etag; } - - public function setMetadata(string $key, FileMetadata $value): void { - $this->metaData[$key] = $value; - } - - public function hasMetadata(string $key): bool { - return isset($this->metaData[$key]); - } - - public function getMetadata(string $key): FileMetadata { - return $this->metaData[$key]; - } } diff --git a/lib/DB/Place/PlaceMapper.php b/lib/DB/Place/PlaceMapper.php index 7147dc8d7..eef5fe322 100644 --- a/lib/DB/Place/PlaceMapper.php +++ b/lib/DB/Place/PlaceMapper.php @@ -25,20 +25,22 @@ namespace OCA\Photos\DB\Place; -use Doctrine\DBAL\Exception\UniqueConstraintViolationException; +use OCA\Photos\AppInfo\Application; use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\Files\IMimeTypeLoader; use OCP\Files\IRootFolder; use OCP\Files\NotFoundException; +use OCP\FilesMetadata\IFilesMetadataManager; use OCP\IDBConnection; class PlaceMapper { - public const METADATA_GROUP = 'photos_place'; + public const METADATA_KEY = 'photos-place'; public function __construct( private IDBConnection $connection, private IMimeTypeLoader $mimeTypeLoader, private IRootFolder $rootFolder, + private IFilesMetadataManager $filesMetadataManager, ) { } @@ -49,20 +51,21 @@ public function findPlacesForUser(string $userId): array { ->getMountPoint() ->getNumericStorageId(); - $mimepart = $this->mimeTypeLoader->getId('image'); + + $mimetypes = array_map(fn ($mimetype) => $this->mimeTypeLoader->getId($mimetype), Application::IMAGE_MIMES); $qb = $this->connection->getQueryBuilder(); - $rows = $qb->selectDistinct('meta.value') - ->from('file_metadata', 'meta') - ->join('meta', 'filecache', 'file', $qb->expr()->eq('file.fileid', 'meta.id', IQueryBuilder::PARAM_INT)) - ->where($qb->expr()->eq('file.storage', $qb->createNamedParameter($storageId, IQueryBuilder::PARAM_INT))) - ->andWhere($qb->expr()->eq('file.mimepart', $qb->createNamedParameter($mimepart, IQueryBuilder::PARAM_INT))) - ->andWhere($qb->expr()->eq('meta.group_name', $qb->createNamedParameter(self::METADATA_GROUP))) + $qb->selectDistinct('meta_value') + ->from('filecache', 'file'); + $metadataQuery = $this->filesMetadataManager->getMetadataQuery($qb, 'file', 'fileid'); + $metadataQuery->joinIndex(self::METADATA_KEY, true); + $rows = $qb->where($qb->expr()->eq('file.storage', $qb->createNamedParameter($storageId, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->in('file.mimetype', $qb->createNamedParameter($mimetypes, IQueryBuilder::PARAM_INT_ARRAY))) ->executeQuery() ->fetchAll(); - return array_map(fn ($row) => new PlaceInfo($userId, $row['value']), $rows); + return array_map(fn ($row) => new PlaceInfo($userId, $row['meta_value']), $rows); } /** @return PlaceInfo */ @@ -72,17 +75,17 @@ public function findPlaceForUser(string $userId, string $place): PlaceInfo { ->getMountPoint() ->getNumericStorageId(); - $mimepart = $this->mimeTypeLoader->getId('image'); + $mimetypes = array_map(fn ($mimetype) => $this->mimeTypeLoader->getId($mimetype), Application::IMAGE_MIMES); $qb = $this->connection->getQueryBuilder(); - $rows = $qb->selectDistinct('meta.value') - ->from('file_metadata', 'meta') - ->join('meta', 'filecache', 'file', $qb->expr()->eq('file.fileid', 'meta.id', IQueryBuilder::PARAM_INT)) - ->where($qb->expr()->eq('file.storage', $qb->createNamedParameter($storageId, IQueryBuilder::PARAM_INT))) - ->andWhere($qb->expr()->eq('file.mimepart', $qb->createNamedParameter($mimepart, IQueryBuilder::PARAM_INT))) - ->andWhere($qb->expr()->eq('meta.group_name', $qb->createNamedParameter(self::METADATA_GROUP))) - ->andWhere($qb->expr()->eq('meta.value', $qb->createNamedParameter($place))) + $qb->selectDistinct('meta_value') + ->from('filecache', 'file'); + $metadataQuery = $this->filesMetadataManager->getMetadataQuery($qb, 'file', 'fileid'); + $metadataQuery->joinIndex(self::METADATA_KEY, true); + $rows = $qb->where($qb->expr()->eq('file.storage', $qb->createNamedParameter($storageId, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->in('file.mimetype', $qb->createNamedParameter($mimetypes, IQueryBuilder::PARAM_INT_ARRAY))) + ->andWhere($qb->expr()->eq('meta_value', $qb->createNamedParameter($place))) ->executeQuery() ->fetchAll(); @@ -90,7 +93,7 @@ public function findPlaceForUser(string $userId, string $place): PlaceInfo { throw new NotFoundException(); } - return new PlaceInfo($userId, $rows[0]['value']); + return new PlaceInfo($userId, $rows[0]['meta_value']); } /** @return PlaceFile[] */ @@ -100,17 +103,17 @@ public function findFilesForUserAndPlace(string $userId, string $place) { ->getMountPoint() ->getNumericStorageId(); - $mimepart = $this->mimeTypeLoader->getId('image'); + $mimetypes = array_map(fn ($mimetype) => $this->mimeTypeLoader->getId($mimetype), Application::IMAGE_MIMES); $qb = $this->connection->getQueryBuilder(); - $rows = $qb->select('file.fileid', 'file.name', 'file.mimetype', 'file.size', 'file.mtime', 'file.etag', 'meta.value') - ->from('file_metadata', 'meta') - ->join('meta', 'filecache', 'file', $qb->expr()->eq('file.fileid', 'meta.id', IQueryBuilder::PARAM_INT)) - ->where($qb->expr()->eq('file.storage', $qb->createNamedParameter($storageId, IQueryBuilder::PARAM_INT))) - ->andWhere($qb->expr()->eq('file.mimepart', $qb->createNamedParameter($mimepart, IQueryBuilder::PARAM_INT))) - ->andWhere($qb->expr()->eq('meta.group_name', $qb->createNamedParameter(self::METADATA_GROUP))) - ->andWhere($qb->expr()->eq('meta.value', $qb->createNamedParameter($place))) + $rows = $qb->select('file.fileid', 'file.name', 'file.mimetype', 'file.size', 'file.mtime', 'file.etag', 'meta_value') + ->from('filecache', 'file'); + $metadataQuery = $this->filesMetadataManager->getMetadataQuery($qb, 'file', 'fileid'); + $metadataQuery->joinIndex(self::METADATA_KEY, true); + $rows = $qb->where($qb->expr()->eq('file.storage', $qb->createNamedParameter($storageId, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->in('file.mimetype', $qb->createNamedParameter($mimetypes, IQueryBuilder::PARAM_INT_ARRAY))) + ->andWhere($qb->expr()->eq('meta_value', $qb->createNamedParameter($place))) ->executeQuery() ->fetchAll(); @@ -122,7 +125,7 @@ public function findFilesForUserAndPlace(string $userId, string $place) { (int)$row['size'], (int)$row['mtime'], $row['etag'], - $row['value'] + $row['meta_value'] ), $rows, ); @@ -134,19 +137,19 @@ public function findFileForUserAndPlace(string $userId, string $place, string $f ->getMountPoint() ->getNumericStorageId(); - $mimepart = $this->mimeTypeLoader->getId('image'); + $mimetypes = array_map(fn ($mimetype) => $this->mimeTypeLoader->getId($mimetype), Application::IMAGE_MIMES); $qb = $this->connection->getQueryBuilder(); - $rows = $qb->select('file.fileid', 'file.name', 'file.mimetype', 'file.size', 'file.mtime', 'file.etag', 'meta.value') - ->from('file_metadata', 'meta') - ->join('meta', 'filecache', 'file', $qb->expr()->eq('file.fileid', 'meta.id', IQueryBuilder::PARAM_INT)) - ->where($qb->expr()->eq('file.storage', $qb->createNamedParameter($storageId, IQueryBuilder::PARAM_INT))) - ->andWhere($qb->expr()->eq('file.mimepart', $qb->createNamedParameter($mimepart, IQueryBuilder::PARAM_INT))) + $rows = $qb->select('file.fileid', 'file.name', 'file.mimetype', 'file.size', 'file.mtime', 'file.etag', 'meta_value') + ->from('filecache', 'file'); + $metadataQuery = $this->filesMetadataManager->getMetadataQuery($qb, 'file', 'fileid'); + $metadataQuery->joinIndex(self::METADATA_KEY, true); + $rows = $qb->where($qb->expr()->eq('file.storage', $qb->createNamedParameter($storageId, IQueryBuilder::PARAM_INT))) ->andWhere($qb->expr()->eq('file.fileid', $qb->createNamedParameter($fileId))) ->andWhere($qb->expr()->eq('file.name', $qb->createNamedParameter($fileName))) - ->andWhere($qb->expr()->eq('meta.group_name', $qb->createNamedParameter(self::METADATA_GROUP))) - ->andWhere($qb->expr()->eq('meta.value', $qb->createNamedParameter($place))) + ->andWhere($qb->expr()->in('file.mimetype', $qb->createNamedParameter($mimetypes, IQueryBuilder::PARAM_INT_ARRAY))) + ->andWhere($qb->expr()->eq('meta_value', $qb->createNamedParameter($place))) ->executeQuery() ->fetchAll(); @@ -161,35 +164,12 @@ public function findFileForUserAndPlace(string $userId, string $place, string $f (int)$rows[0]['size'], (int)$rows[0]['mtime'], $rows[0]['etag'], - $rows[0]['value'] + $rows[0]['meta_value'] ); } public function setPlaceForFile(string $place, int $fileId): void { - try { - $query = $this->connection->getQueryBuilder(); - $query->insert('file_metadata') - ->values([ - "id" => $query->createNamedParameter($fileId, IQueryBuilder::PARAM_INT), - "group_name" => $query->createNamedParameter(self::METADATA_GROUP), - "value" => $query->createNamedParameter($place), - ]) - ->executeStatement(); - } catch (\Exception $ex) { - if ($ex->getPrevious() instanceof UniqueConstraintViolationException) { - $this->updatePlaceForFile($place, $fileId); - } else { - throw $ex; - } - } - } - - public function updatePlaceForFile(string $place, int $fileId): void { - $query = $this->connection->getQueryBuilder(); - $query->update('file_metadata') - ->set("value", $query->createNamedParameter($place)) - ->where($query->expr()->eq('id', $query->createNamedParameter($fileId, IQueryBuilder::PARAM_INT))) - ->andWhere($query->expr()->eq('group_name', $query->createNamedParameter(self::METADATA_GROUP))) - ->executeStatement(); + $metadata = $this->filesMetadataManager->getMetadata($fileId, true); + $metadata->set('gps', $place, true); } } diff --git a/lib/Jobs/MapMediaToPlaceJob.php b/lib/Jobs/MapMediaToPlaceJob.php deleted file mode 100644 index 2facda95a..000000000 --- a/lib/Jobs/MapMediaToPlaceJob.php +++ /dev/null @@ -1,48 +0,0 @@ - - * - * @author Louis Chemineau - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * 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 - * along with this program. If not, see . - * - */ - -namespace OCA\Photos\Jobs; - -use OCA\Photos\Service\MediaPlaceManager; -use OCP\AppFramework\Utility\ITimeFactory; -use OCP\BackgroundJob\QueuedJob; - -class MapMediaToPlaceJob extends QueuedJob { - private MediaPlaceManager $mediaPlaceManager; - - public function __construct( - ITimeFactory $time, - MediaPlaceManager $mediaPlaceManager - ) { - parent::__construct($time); - $this->mediaPlaceManager = $mediaPlaceManager; - } - - protected function run($argument) { - [$fileId] = $argument; - - $this->mediaPlaceManager->setPlaceForFile($fileId); - } -} diff --git a/lib/Listener/PlaceManagerEventListener.php b/lib/Listener/PlaceManagerEventListener.php deleted file mode 100644 index c9954f853..000000000 --- a/lib/Listener/PlaceManagerEventListener.php +++ /dev/null @@ -1,71 +0,0 @@ - - * - * @author Louis Chemineau - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * 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 - * along with this program. If not, see . - * - */ - -namespace OCA\Photos\Listener; - -use OCA\Photos\Jobs\MapMediaToPlaceJob; -use OCA\Photos\Service\MediaPlaceManager; -use OCP\BackgroundJob\IJobList; -use OCP\EventDispatcher\Event; -use OCP\EventDispatcher\IEventListener; -use OCP\Files\Events\Node\NodeWrittenEvent; -use OCP\IConfig; - -/** - * Listener to add place info from the database. - */ -class PlaceManagerEventListener implements IEventListener { - public function __construct( - private MediaPlaceManager $mediaPlaceManager, - private IConfig $config, - private IJobList $jobList, - ) { - } - - public function handle(Event $event): void { - if (!$this->config->getSystemValueBool('enable_file_metadata', true)) { - return; - } - - if ($event instanceof NodeWrittenEvent) { - if (!$this->isCorrectPath($event->getNode()->getPath())) { - return; - } - - if (!str_starts_with($event->getNode()->getMimeType(), 'image')) { - return; - } - - $fileId = $event->getNode()->getId(); - - $this->jobList->add(MapMediaToPlaceJob::class, [$fileId]); - } - } - - private function isCorrectPath(string $path): bool { - // TODO make this more dynamic, we have the same issue in other places - return !str_starts_with($path, 'appdata_') && !str_starts_with($path, 'files_versions/'); - } -} diff --git a/lib/MetadataProvider/ExifMetadataProvider.php b/lib/MetadataProvider/ExifMetadataProvider.php new file mode 100644 index 000000000..3d6f4b652 --- /dev/null +++ b/lib/MetadataProvider/ExifMetadataProvider.php @@ -0,0 +1,146 @@ + + * @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) + ) { + $gps = []; + + if ( + array_key_exists('GPSLatitude', $rawExifData['GPS']) && array_key_exists('GPSLatitudeRef', $rawExifData['GPS']) && + array_key_exists('GPSLongitude', $rawExifData['GPS']) && array_key_exists('GPSLongitudeRef', $rawExifData['GPS']) + ) { + $gps['latitude'] = $this->gpsDegreesToDecimal($rawExifData['GPS']['GPSLatitude'], $rawExifData['GPS']['GPSLatitudeRef']); + $gps['longitude'] = $this->gpsDegreesToDecimal($rawExifData['GPS']['GPSLongitude'], $rawExifData['GPS']['GPSLongitudeRef']); + } + + if (array_key_exists('GPSAltitude', $rawExifData['GPS']) && array_key_exists('GPSAltitudeRef', $rawExifData['GPS'])) { + $gps['altitude'] = ($rawExifData['GPS']['GPSAltitudeRef'] === "\u{0000}" ? 1 : -1) * $this->parseGPSData($rawExifData['GPS']['GPSAltitude']); + } + + if (!empty($gps)) { + $event->getMetadata()->setArray('photos-gps', $gps); + } + } + } + + /** + * @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..6db62a8a1 --- /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 array $regexpToDateFormatMap = [ + "/^IMG_([0-9]{8}_[0-9]{6})/" => "Ymd_Gis", + "/^PANO_([0-9]{8}_[0-9]{6})/" => "Ymd_Gis", + "/^PXL_([0-9]{8}_[0-9]{6})/" => "Ymd_Gis", + "/^([0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{2}-[0-9]{2}-[0-9]{2}-[0-9]{4})/" => "Y-m-d-G-i-s", + ]; + + 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(); + + // Try to use EXIF data. + 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; + } + + // Try to parse the date out of the name. + $name = $node->getName(); + $matches = []; + + foreach ($this->regexpToDateFormatMap as $regexp => $format) { + $matchesCount = preg_match($regexp, $name, $matches); + if ($matchesCount === 0) { + continue; + } + + $dateTimeOriginal = DateTime::createFromFormat($format, $matches[1]); + $metadata->setInt('photos-original_date_time', $dateTimeOriginal->getTimestamp(), true); + return; + } + + // Fallback to the mtime. + $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..384074984 --- /dev/null +++ b/lib/MetadataProvider/PlaceMetadataProvider.php @@ -0,0 +1,67 @@ + + * @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 OCA\Photos\Service\MediaPlaceManager; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use OCP\Files\File; +use OCP\FilesMetadata\Event\MetadataBackgroundEvent; +use OCP\FilesMetadata\Event\MetadataLiveEvent; + +class PlaceMetadataProvider implements IEventListener { + public function __construct( + private MediaPlaceManager $mediaPlaceManager + ) { + } + + public function handle(Event $event): void { + if ($event instanceof MetadataLiveEvent) { + $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; + } + + $event->requestBackgroundJob(); + } + + if ($event instanceof MetadataBackgroundEvent) { + $metadata = $event->getMetadata(); + $place = $this->mediaPlaceManager->getPlaceForFile($event->getNode()->getId()); + if ($place !== null) { + $metadata->set('photos-place', $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], + ]); + } +} diff --git a/lib/Sabre/PropFindPlugin.php b/lib/Sabre/PropFindPlugin.php index b94a3a07e..a37d006f9 100644 --- a/lib/Sabre/PropFindPlugin.php +++ b/lib/Sabre/PropFindPlugin.php @@ -23,7 +23,6 @@ namespace OCA\Photos\Sabre; -use OC\Metadata\IMetadataManager; use OCA\DAV\Connector\Sabre\FilesPlugin; use OCA\Photos\Album\AlbumMapper; use OCA\Photos\Sabre\Album\AlbumPhoto; @@ -53,23 +52,18 @@ class PropFindPlugin extends ServerPlugin { public const PERMISSIONS_PROPERTYNAME = '{http://owncloud.org/ns}permissions'; private IConfig $config; - private IMetadataManager $metadataManager; private IPreview $previewManager; - private bool $metadataEnabled; private ?Tree $tree; private AlbumMapper $albumMapper; public function __construct( IConfig $config, - IMetadataManager $metadataManager, IPreview $previewManager, AlbumMapper $albumMapper ) { $this->config = $config; - $this->metadataManager = $metadataManager; $this->previewManager = $previewManager; $this->albumMapper = $albumMapper; - $this->metadataEnabled = $this->config->getSystemValueBool('enable_file_metadata', true); } /** @@ -111,21 +105,8 @@ public function propFind(PropFind $propFind, INode $node): void { $propFind->handle(FilesPlugin::HAS_PREVIEW_PROPERTYNAME, fn () => json_encode($this->previewManager->isAvailable($fileInfo))); // Remove G permission as it does not make sense in the context of photos. $propFind->handle(FilesPlugin::PERMISSIONS_PROPERTYNAME, fn () => str_replace('G', '', DavUtil::getDavPermissions($node->getFileInfo()))); - - if ($this->metadataEnabled) { - $propFind->handle(FilesPlugin::FILE_METADATA_SIZE, function () use ($node) { - if (!str_starts_with($node->getFile()->getMimetype(), 'image')) { - return json_encode((object)[]); - } - - if ($node->getFile()->hasMetadata('size')) { - $sizeMetadata = $node->getFile()->getMetadata('size'); - } else { - $sizeMetadata = $this->metadataManager->fetchMetadataFor('size', [$node->getFile()->getFileId()])[$node->getFile()->getFileId()]; - } - - return $sizeMetadata->getValue(); - }); + foreach ($node->getFileInfo()->getMetadata() as $metadataKey => $metadataValue) { + $propFind->handle(FilesPlugin::FILE_METADATA_PREFIX.$metadataKey, $metadataValue['value']); } } @@ -136,35 +117,11 @@ public function propFind(PropFind $propFind, INode $node): void { $propFind->handle(self::LOCATION_PROPERTYNAME, fn () => $node->getAlbum()->getAlbum()->getLocation()); $propFind->handle(self::DATE_RANGE_PROPERTYNAME, fn () => json_encode($node->getDateRange())); $propFind->handle(self::COLLABORATORS_PROPERTYNAME, fn () => $node->getCollaborators()); - - // TODO detect dynamically which metadata groups are requested and - // preload all of them and not just size - if ($this->metadataEnabled && in_array(FilesPlugin::FILE_METADATA_SIZE, $propFind->getRequestedProperties(), true)) { - $fileIds = $node->getAlbum()->getFileIds(); - - $preloadedMetadata = $this->metadataManager->fetchMetadataFor('size', $fileIds); - foreach ($node->getAlbum()->getFiles() as $file) { - if (str_starts_with($file->getMimeType(), 'image')) { - $file->setMetadata('size', $preloadedMetadata[$file->getFileId()]); - } - } - } } if ($node instanceof PlaceRoot) { $propFind->handle(self::LAST_PHOTO_PROPERTYNAME, fn () => $node->getFirstPhoto()); $propFind->handle(self::NBITEMS_PROPERTYNAME, fn () => count($node->getChildren())); - - // TODO detect dynamically which metadata groups are requested and - // preload all of them and not just size - if ($this->metadataEnabled && in_array(FilesPlugin::FILE_METADATA_SIZE, $propFind->getRequestedProperties(), true)) { - $fileIds = $node->getFileIds(); - $preloadedMetadata = $this->metadataManager->fetchMetadataFor('size', $fileIds); - - foreach ($node->getChildren() as $file) { - $file->getFile()->setMetadata('size', $preloadedMetadata[$file->getFileId()]); - } - } } } diff --git a/lib/Service/MediaPlaceManager.php b/lib/Service/MediaPlaceManager.php index c1e11f7b6..24943d183 100644 --- a/lib/Service/MediaPlaceManager.php +++ b/lib/Service/MediaPlaceManager.php @@ -25,12 +25,12 @@ namespace OCA\Photos\Service; -use OC\Metadata\IMetadataManager; use OCA\Photos\DB\Place\PlaceMapper; +use OCP\FilesMetadata\IFilesMetadataManager; class MediaPlaceManager { public function __construct( - private IMetadataManager $metadataManager, + private IFilesMetadataManager $filesMetadataManager, private ReverseGeoCoderService $rgcService, private PlaceMapper $placeMapper, ) { @@ -46,30 +46,27 @@ public function setPlaceForFile(int $fileId): void { $this->placeMapper->setPlaceForFile($place, $fileId); } - public function updatePlaceForFile(int $fileId): void { - $place = $this->getPlaceForFile($fileId); + // public function updatePlaceForFile(int $fileId): void { + // $place = $this->getPlaceForFile($fileId); - if ($place === null) { - return; - } + // if ($place === null) { + // return; + // } - $this->placeMapper->updatePlaceForFile($place, $fileId); - } + // $this->placeMapper->setPlaceForFile($place, $fileId); + // } - private function getPlaceForFile(int $fileId): ?string { - $gpsMetadata = $this->metadataManager->fetchMetadataFor('gps', [$fileId])[$fileId]; - $metadata = $gpsMetadata->getDecodedValue(); + public function getPlaceForFile(int $fileId): ?string { + $metadata = $this->filesMetadataManager->getMetadata($fileId, true); - if (count($metadata) === 0) { + if (!$metadata->hasKey('photos-gps')) { return null; } - $latitude = $metadata['latitude']; - $longitude = $metadata['longitude']; + $coordinate = $metadata->getArray('photos-gps'); - if ($latitude === null || $longitude === null) { - return null; - } + $latitude = $coordinate['latitude']; + $longitude = $coordinate['longitude']; return $this->rgcService->getPlaceForCoordinates($latitude, $longitude); } diff --git a/src/components/FilesListViewer.vue b/src/components/FilesListViewer.vue index c9685d7d3..d4e803850 100644 --- a/src/components/FilesListViewer.vue +++ b/src/components/FilesListViewer.vue @@ -250,9 +250,9 @@ export default { const file = this.files[fileId] return { id: file.fileid, - width: file.fileMetadataSizeParsed.width, - height: file.fileMetadataSizeParsed.height, - ratio: this.croppedLayout ? 1 : file.fileMetadataSizeParsed.width / file.fileMetadataSizeParsed.height, + width: file.metadataPhotosSize.width, + height: file.metadataPhotosSize.height, + ratio: this.croppedLayout ? 1 : file.metadataPhotosSize.width / file.metadataPhotosSize.height, } }, diff --git a/src/services/DavRequest.js b/src/services/DavRequest.js index a218b45ef..97e2933ef 100644 --- a/src/services/DavRequest.js +++ b/src/services/DavRequest.js @@ -27,7 +27,8 @@ const props = ` - + + diff --git a/src/services/collectionFetcher.js b/src/services/collectionFetcher.js index 8f22884f4..19d09e48a 100644 --- a/src/services/collectionFetcher.js +++ b/src/services/collectionFetcher.js @@ -42,9 +42,9 @@ import { genFileInfo } from '../utils/fileUtils.js' * @property {string} basename - The name of the file (ex: "790-IMG_20180906_085724.jpg"). * @property {string} filename - The file name of the file (ex: "/photos/admin/places/Athens/790-IMG_20180906_085724.jpg"). * @property {string} source - The full source of the collection (ex: "https://nextcloud_server1.test/remote.php/dav//photos/admin/places/Athens/790-IMG_20180906_085724.jpg"). - * @property {object} fileMetadataSizeParsed - The metadata of the file. - * @property {number} fileMetadataSizeParsed.width - The width of the file. - * @property {number} fileMetadataSizeParsed.height - The height of the file. + * @property {object} metadataPhotosSize - The metadata of the file. + * @property {number} metadataPhotosSize.width - The width of the file. + * @property {number} metadataPhotosSize.height - The height of the file. */ /** @typedef {Object} IndexedCollections */ @@ -84,7 +84,8 @@ function getCollectionFilesDavRequest(extraProps = []) { - + + diff --git a/src/services/fileFetcher.js b/src/services/fileFetcher.js index a5fcca8b7..0d537fadc 100644 --- a/src/services/fileFetcher.js +++ b/src/services/fileFetcher.js @@ -39,7 +39,8 @@ function getCollectionFilesDavRequest(extraProps = []) { - + + diff --git a/src/store/files.js b/src/store/files.js index 8b55c3b03..e9ac283f9 100644 --- a/src/store/files.js +++ b/src/store/files.js @@ -50,12 +50,12 @@ const mutations = { } if (file.fileid >= 0) { - if (file.fileMetadataSize?.length > 1) { - file.fileMetadataSizeParsed = JSON.parse(file.fileMetadataSize?.replace(/"/g, '"') ?? '{}') - file.fileMetadataSizeParsed.width = file.fileMetadataSizeParsed?.width ?? 256 - file.fileMetadataSizeParsed.height = file.fileMetadataSizeParsed?.height ?? 256 + file.metadataPhotosSize = {} + if (file.width && file.height) { + file.metadataPhotosSize.width = file.width + file.metadataPhotosSize.height = file.height } else { - file.fileMetadataSizeParsed = { width: 256, height: 256 } + file.metadataPhotosSize = { width: 256, height: 256 } } } @@ -63,9 +63,10 @@ const mutations = { file.fileid = file.fileid.toString() // Precalculate dates as it is expensive. - file.timestamp = moment(file.lastmod).unix() // For sorting - file.month = moment(file.lastmod).format('YYYYMM') // For grouping by month - file.day = moment(file.lastmod).format('MMDD') // For On this day + const date = moment(file.lastmod) + file.timestamp = date.unix() // For sorting + file.month = date.format('YYYYMM') // For grouping by month + file.day = date.format('MMDD') // For On this day // Schedule the file to add files[file.fileid] = file diff --git a/tests/stub.phpstub b/tests/stub.phpstub index edb8340e5..dcd5fc438 100644 --- a/tests/stub.phpstub +++ b/tests/stub.phpstub @@ -583,74 +583,6 @@ namespace OC\Files\Storage\Wrapper{ } } -namespace OC\Metadata { - -use OCP\Files\File; - -/** - * Interface to manage additional metadata for files - */ -interface IMetadataManager { - /** - * @param class-string $className - */ - public function registerProvider(string $className): void; - - /** - * Generate the metadata for one file - */ - public function generateMetadata(File $node, bool $checkExisting = false): void; - - /** - * Clear the metadata for one file - */ - public function clearMetadata(int $fileId): void; - - /** @return array */ - public function fetchMetadataFor(string $group, array $fileIds): array; - - /** - * Get the capabilites as an array of mimetype regex to the type provided - */ - public function getCapabilities(): array; -} - -/** - * Interface for the metadata providers. If you want an application to provide - * some metadata, you can use this to store them. - */ -interface IMetadataProvider { - /** - * The list of groups that this metadata provider is able to provide. - * - * @return string[] - */ - public static function groupsProvided(): array; - - /** - * Check if the metadata provider is available. A metadata provider might be - * unavailable due to a php extension not being installed. - */ - public static function isAvailable(): bool; - - /** - * Get the mimetypes supported as a regex. - */ - public static function getMimetypesSupported(): string; - - /** - * Execute the extraction on the specified file. The metadata should be - * grouped by metadata - * - * Each group should be json serializable and the string representation - * shouldn't be longer than 4000 characters. - * - * @param File $file The file to extract the metadata from - * @param array An array containing all the metadata fetched. - */ - public function execute(File $file): array; -} - use OCP\AppFramework\Db\Entity; use OCP\DB\Types;