Use new metadata API for providers

Signed-off-by: Louis Chemineau <louis@chmn.me>
This commit is contained in:
Louis Chemineau 2023-10-25 18:10:26 +02:00
parent 5edd0b76d0
commit a87c19ee24
No known key found for this signature in database
45 changed files with 473 additions and 493 deletions

View File

@ -32,7 +32,6 @@
<commands>
<command>OCA\Photos\Command\UpdateReverseGeocodingFilesCommand</command>
<command>OCA\Photos\Command\MapMediaToPlaceCommand</command>
</commands>
<sabre>
@ -48,4 +47,4 @@
<background-jobs>
<job>OCA\Photos\Jobs\AutomaticPlaceMapperJob</job>
</background-jobs>
</info>
</info>

View File

@ -35,7 +35,7 @@ describe('Manage places', () => {
cy.createRandomUser()
.then((user) => {
uploadTestMedia(user)
runOccCommand(`photos:map-media-to-place --user ${user.userId}`)
runOccCommand('files:scan --all --generate-metadata')
cy.login(user)
cy.visit('/apps/photos')
})

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -28,15 +28,19 @@ namespace OCA\Photos\AppInfo;
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 @@ class Application extends App implements IBootstrap {
/** 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);

View File

@ -1,116 +0,0 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2022 Louis Chemineau <louis@chmn.me>
*
* @author Louis Chemineau <louis@chmn.me>
*
* @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 <http://www.gnu.org/licenses/>.
*
*/
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;
}
}

View File

@ -25,12 +25,7 @@ declare(strict_types=1);
namespace OCA\Photos\DB;
use OC\Metadata\FileMetadata;
class PhotosFile {
/** @var array<string, FileMetadata> */
private array $metaData = [];
public function __construct(
private int $fileId,
private string $name,
@ -64,16 +59,4 @@ class PhotosFile {
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];
}
}

View File

@ -25,20 +25,22 @@ declare(strict_types=1);
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,22 @@ class PlaceMapper {
->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_string')
->from('filecache', 'file');
$metadataQuery = $this->filesMetadataManager->getMetadataQuery($qb, 'file', 'fileid');
$metadataQuery->joinIndex(self::METADATA_KEY);
$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('meta_value_string IS NOT NULL')
->executeQuery()
->fetchAll();
return array_map(fn ($row) => new PlaceInfo($userId, $row['value']), $rows);
return array_map(fn ($row) => new PlaceInfo($userId, $row['meta_value_string']), $rows);
}
/** @return PlaceInfo */
@ -72,17 +76,18 @@ class PlaceMapper {
->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_string')
->from('filecache', 'file');
$metadataQuery = $this->filesMetadataManager->getMetadataQuery($qb, 'file', 'fileid');
$metadataQuery->joinIndex(self::METADATA_KEY);
$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_string', $qb->createNamedParameter($place)))
->andWhere('meta_value_string IS NOT NULL')
->executeQuery()
->fetchAll();
@ -90,7 +95,7 @@ class PlaceMapper {
throw new NotFoundException();
}
return new PlaceInfo($userId, $rows[0]['value']);
return new PlaceInfo($userId, $rows[0]['meta_value_string']);
}
/** @return PlaceFile[] */
@ -100,17 +105,18 @@ class PlaceMapper {
->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_string')
->from('filecache', 'file');
$metadataQuery = $this->filesMetadataManager->getMetadataQuery($qb, 'file', 'fileid');
$metadataQuery->joinIndex(self::METADATA_KEY);
$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_string', $qb->createNamedParameter($place)))
->andWhere('meta_value_string IS NOT NULL')
->executeQuery()
->fetchAll();
@ -122,7 +128,7 @@ class PlaceMapper {
(int)$row['size'],
(int)$row['mtime'],
$row['etag'],
$row['value']
$row['meta_value_string']
),
$rows,
);
@ -134,19 +140,20 @@ class PlaceMapper {
->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_string')
->from('filecache', 'file');
$metadataQuery = $this->filesMetadataManager->getMetadataQuery($qb, 'file', 'fileid');
$metadataQuery->joinIndex(self::METADATA_KEY);
$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_string', $qb->createNamedParameter($place)))
->andWhere('meta_value_string IS NOT NULL')
->executeQuery()
->fetchAll();
@ -161,35 +168,12 @@ class PlaceMapper {
(int)$rows[0]['size'],
(int)$rows[0]['mtime'],
$rows[0]['etag'],
$rows[0]['value']
$rows[0]['meta_value_string']
);
}
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->setString('gps', $place, true);
}
}

View File

@ -1,48 +0,0 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2022 Louis Chemineau <louis@chmn.me>
*
* @author Louis Chemineau <louis@chmn.me>
*
* @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 <http://www.gnu.org/licenses/>.
*
*/
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);
}
}

View File

@ -1,71 +0,0 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2022 Louis Chemineau <louis@chmn.me>
*
* @author Louis Chemineau <louis@chmn.me>
*
* @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 <http://www.gnu.org/licenses/>.
*
*/
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/');
}
}

View File

@ -0,0 +1,155 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright 2022 Carl Schwan <carl@carlschwan.eu>
* @copyright Copyright 2022 Louis Chmn <louis@chmn.me>
* @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 <http://www.gnu.org/licenses/>
*
*/
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;
}
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', $this->base64Encode($rawExifData['EXIF']));
}
if ($rawExifData && array_key_exists('IFD0', $rawExifData)) {
$event->getMetadata()->setArray('photos-ifd0', $this->base64Encode($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);
}
/**
* Exif data can contain anything.
* This method will base 64 encode any non UTF-8 string in an array.
*/
private function base64Encode(array $data): array {
foreach ($data as $key => $value) {
if (is_string($value) && !mb_check_encoding($value, 'UTF-8')) {
$data[$key] = 'base64:'.base64_encode($value);
}
}
return $data;
}
}

View File

@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright 2022 Carl Schwan <carl@carlschwan.eu>
* @copyright Copyright 2022 Louis Chmn <louis@chmn.me>
* @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 <http://www.gnu.org/licenses/>
*
*/
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;
}
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);
}
}

View File

@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright 2023 Louis Chmn <louis@chmn.me>
* @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 <http://www.gnu.org/licenses/>
*
*/
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;
/**
* @template-implements IEventListener
*/
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;
}
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->setString('photos-place', $place, true);
}
}
}
}

View File

@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright 2022 Carl Schwan <carl@carlschwan.eu>
* @copyright Copyright 2022 Louis Chmn <louis@chmn.me>
* @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 <http://www.gnu.org/licenses/>
*
*/
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;
}
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],
]);
}
}

View File

@ -23,7 +23,6 @@ declare(strict_types=1);
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;
@ -54,23 +53,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);
}
/**
@ -122,20 +116,8 @@ class PropFindPlugin extends ServerPlugin {
return $filteredPermissions;
});
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);
}
}
@ -146,35 +128,11 @@ class PropFindPlugin extends ServerPlugin {
$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()]);
}
}
}
}

View File

@ -25,12 +25,13 @@ declare(strict_types=1);
namespace OCA\Photos\Service;
use OC\Metadata\IMetadataManager;
use OCA\Photos\DB\Place\PlaceMapper;
use OCP\FilesMetadata\Exceptions\FilesMetadataNotFoundException;
use OCP\FilesMetadata\IFilesMetadataManager;
class MediaPlaceManager {
public function __construct(
private IMetadataManager $metadataManager,
private IFilesMetadataManager $filesMetadataManager,
private ReverseGeoCoderService $rgcService,
private PlaceMapper $placeMapper,
) {
@ -46,31 +47,23 @@ class MediaPlaceManager {
$this->placeMapper->setPlaceForFile($place, $fileId);
}
public function updatePlaceForFile(int $fileId): void {
$place = $this->getPlaceForFile($fileId);
if ($place === null) {
return;
}
$this->placeMapper->updatePlaceForFile($place, $fileId);
}
private function getPlaceForFile(int $fileId): ?string {
$gpsMetadata = $this->metadataManager->fetchMetadataFor('gps', [$fileId])[$fileId];
$metadata = $gpsMetadata->getDecodedValue();
if (count($metadata) === 0) {
public function getPlaceForFile(int $fileId): ?string {
try {
$metadata = $this->filesMetadataManager->getMetadata($fileId, true);
} catch (FilesMetadataNotFoundException) {
return null;
}
$latitude = $metadata['latitude'];
$longitude = $metadata['longitude'];
if ($latitude === null || $longitude === null) {
if (!$metadata->hasKey('photos-gps')) {
return null;
}
$coordinate = $metadata->getArray('photos-gps');
$latitude = $coordinate['latitude'];
$longitude = $coordinate['longitude'];
return $this->rgcService->getPlaceForCoordinates($latitude, $longitude);
}
}

View File

@ -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,
}
},

View File

@ -27,7 +27,8 @@ const props = `
<d:resourcetype />
<nc:face-detections />
<nc:face-preview-image />
<nc:file-metadata-size />
<nc:metadata-photos-size />
<nc:metadata-photos-original_date_time />
<nc:has-preview />
<nc:realpath />
<oc:favorite />

View File

@ -129,7 +129,7 @@ export default async function(path = '', options = {}) {
</d:where>
<d:orderby>
<d:order>
<d:prop><d:getlastmodified/></d:prop>
<d:prop><nc:metadata-photos-original_date_time/></d:prop>
<d:descending/>
</d:order>
</d:orderby>

View File

@ -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<string, Collection>} IndexedCollections */
@ -84,7 +84,8 @@ function getCollectionFilesDavRequest(extraProps = []) {
<d:getetag />
<d:getlastmodified />
<d:resourcetype />
<nc:file-metadata-size />
<nc:metadata-photos-size />
<nc:metadata-photos-original_date_time />
<nc:has-preview />
<oc:favorite />
<oc:fileid />

View File

@ -39,7 +39,8 @@ function getCollectionFilesDavRequest(extraProps = []) {
<d:getetag />
<d:getlastmodified />
<d:resourcetype />
<nc:file-metadata-size />
<nc:metadata-photos-size />
<nc:metadata-photos-original_date_time />
<nc:has-preview />
<oc:favorite />
<oc:fileid />

View File

@ -50,12 +50,12 @@ const mutations = {
}
if (file.fileid >= 0) {
if (file.fileMetadataSize?.length > 1) {
file.fileMetadataSizeParsed = JSON.parse(file.fileMetadataSize?.replace(/&quot;/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

View File

@ -583,96 +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<IMetadataProvider> $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<int, FileMetadata> */
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<string, FileMetadata> An array containing all the metadata fetched.
*/
public function execute(File $file): array;
}
use OCP\AppFramework\Db\Entity;
use OCP\DB\Types;
/**
* @method string getGroupName()
* @method void setGroupName(string $groupName)
* @method string getValue()
* @method void setValue(string $value)
* @see \OC\Core\Migrations\Version240000Date20220404230027
*/
class FileMetadata extends Entity {
public function getDecodedValue(): array {
return json_decode($this->getValue(), true) ?? [];
}
public function setArrayAsValue(array $value): void {
$this->setValue(json_encode($value, JSON_THROW_ON_ERROR));
}
}
}
namespace OCA\DAV\Connector\Sabre {
class Node extends \Sabre\Dav\INode {
public function getNode(): \OCP\Files\Node { }