From 5db737a6329b948fe468173a81b7cabdec95d518 Mon Sep 17 00:00:00 2001 From: Louis Chemineau Date: Wed, 22 Feb 2023 14:58:09 +0100 Subject: [PATCH] Add commands and listeners to generate location data of files: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The location data is stored inside `oc_files_metadata`. ## Added commands - `occ photos:update-1000-cities` to update the cities1000 file. - `occ photos:map-media-to-location`to map picture coordinates to a location ## Architecture - `ReverseGeoCoderService` download the necessary files and build the `KDTree` - `UpdateReverseGeocodingFilesCommand` command to allow to manually create the needed reverse geocoding files - `MediaLocationManager` to manager the location mappings - `MapMediaToLocationCommand` command to manually trigger location data mapping. Useful for pre-existing pictures. - `LocationManagerNodeEventListener` to react to node, user and share events. - `MapMediaToLocationJob` to reduce the load in event listeners ```php ┌─────────────────────┐ ┌────────────►│MapMediaToLocationJob│ │ └─────────┬───────────┘ │ │ ┌────────────────────────┴───────┐ │ │LocationManagerNodeEventListener├──┐ ▼ └────────────────────────────────┘ │ ┌────────────────────┐ ┌──────────────┐ ├─►│MediaLocationManager├────►│LocationMapper│ ┌─────────────────────────┐ │ └─────────┬──────────┘ └──────────────┘ │MapMediaToLocationCommand├─────────┘ │ └─────────────────────────┘ │ ▼ ┌──────────────────────────────────┐ ┌──────────────────────┐ │UpdateReverseGeocodingFilesCommand├──►│ReverseGeoCoderService│ └──────────────────────────────────┘ └──────────────────────┘ ``` Signed-off-by: Louis Chemineau --- .github/workflows/cypress.yml | 5 +- .php-cs-fixer.dist.php | 2 +- appinfo/info.xml | 21 ++- composer.json | 3 + composer.lock | 66 ++++++- lib/Album/AlbumFile.php | 63 ++----- lib/AppInfo/Application.php | 8 + lib/Command/MapMediaToLocationCommand.php | 116 ++++++++++++ .../UpdateReverseGeocodingFilesCommand.php | 61 +++++++ lib/DB/Location/LocationFile.php | 53 ++++++ lib/DB/Location/LocationInfo.php | 42 +++++ lib/DB/Location/LocationMapper.php | 127 +++++++++++++ lib/DB/PhotosFile.php | 79 ++++++++ lib/Jobs/AutomaticLocationMapperJob.php | 114 ++++++++++++ lib/Jobs/MapMediaToLocationJob.php | 48 +++++ lib/Listener/LocationManagerEventListener.php | 71 ++++++++ lib/Service/MediaLocationManager.php | 76 ++++++++ lib/Service/ReverseGeoCoderService.php | 168 ++++++++++++++++++ package.json | 2 +- tests/stub.phpstub | 53 ++++-- 20 files changed, 1100 insertions(+), 78 deletions(-) create mode 100644 lib/Command/MapMediaToLocationCommand.php create mode 100644 lib/Command/UpdateReverseGeocodingFilesCommand.php create mode 100644 lib/DB/Location/LocationFile.php create mode 100644 lib/DB/Location/LocationInfo.php create mode 100644 lib/DB/Location/LocationMapper.php create mode 100644 lib/DB/PhotosFile.php create mode 100644 lib/Jobs/AutomaticLocationMapperJob.php create mode 100644 lib/Jobs/MapMediaToLocationJob.php create mode 100644 lib/Listener/LocationManagerEventListener.php create mode 100644 lib/Service/MediaLocationManager.php create mode 100644 lib/Service/ReverseGeoCoderService.php diff --git a/.github/workflows/cypress.yml b/.github/workflows/cypress.yml index bae6e124b..bf5fac24e 100644 --- a/.github/workflows/cypress.yml +++ b/.github/workflows/cypress.yml @@ -21,6 +21,9 @@ jobs: - name: Checkout app uses: actions/checkout@v3 + - name: Install server dependencies + run: composer install + - name: Read package.json node and npm engines version uses: skjnldsv/read-package-engines-version-actions@v1.1 id: versions @@ -36,7 +39,7 @@ jobs: - name: Set up npm ${{ steps.versions.outputs.npmVersion }} run: npm i -g npm@"${{ steps.versions.outputs.npmVersion }}" - - name: Install dependencies & build app + - name: Install node dependencies & build app run: | npm ci TESTING=true npm run build --if-present diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index f7bbdd812..2dc85d25b 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -2,7 +2,7 @@ declare(strict_types=1); -require_once './vendor/autoload.php'; +require_once __DIR__ . '/vendor/autoload.php'; use Nextcloud\CodingStandard\Config; diff --git a/appinfo/info.xml b/appinfo/info.xml index 140167a40..8ea43379b 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -1,13 +1,13 @@ - + photos Photos Your memories under your control Your memories under your control 2.2.0 agpl - John Molakvoæ + John Molakvoæ Photos multimedia @@ -15,8 +15,8 @@ - https://github.com/nextcloud/photos - https://github.com/nextcloud/photos/issues + https://github.com/nextcloud/photos + https://github.com/nextcloud/photos/issues https://github.com/nextcloud/photos.git @@ -30,6 +30,11 @@ + + OCA\Photos\Command\UpdateReverseGeocodingFilesCommand + OCA\Photos\Command\MapMediaToLocationCommand + + OCA\Photos\Sabre\RootCollection @@ -39,4 +44,8 @@ OCA\Photos\Sabre\Album\PropFindPlugin - + + + OCA\Photos\Jobs\AutomaticLocationMapperJob + + \ No newline at end of file diff --git a/composer.json b/composer.json index 60fcf9716..757985b6d 100644 --- a/composer.json +++ b/composer.json @@ -21,5 +21,8 @@ "vimeo/psalm": "^4.22", "sabre/dav": "^4.2.1", "nextcloud/ocp": "dev-master" + }, + "require": { + "hexogen/kdtree": "^0.2.5" } } diff --git a/composer.lock b/composer.lock index 55ad9624b..31a4b3a9b 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,70 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "602bc2404448c321b5bbb50160f12007", - "packages": [], + "content-hash": "88bed2a916ac8f06153e69bb691c154c", + "packages": [ + { + "name": "hexogen/kdtree", + "version": "v0.2.5", + "source": { + "type": "git", + "url": "https://github.com/hexogen/kdtree.git", + "reference": "f739186638445990463762d467e07a8262228daa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/hexogen/kdtree/zipball/f739186638445990463762d467e07a8262228daa", + "reference": "f739186638445990463762d467e07a8262228daa", + "shasum": "" + }, + "require": { + "php": "^7.1|^8.0" + }, + "require-dev": { + "league/csv": "^9.7.0", + "mockery/mockery": "dev-master", + "phpunit/phpunit": "^9.0", + "squizlabs/php_codesniffer": "^3.5.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "psr-4": { + "Hexogen\\KDTree\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Volodymyr Basarab", + "email": "volodymyrbas@gmail.com", + "homepage": "https://github.com/hexogen", + "role": "Developer" + } + ], + "description": "file system KDTree index", + "homepage": "https://github.com/hexogen/kdtree", + "keywords": [ + "algorithms", + "data structures", + "hexogen", + "kdtree", + "search" + ], + "support": { + "issues": "https://github.com/hexogen/kdtree/issues", + "source": "https://github.com/hexogen/kdtree/tree/v0.2.5" + }, + "time": "2022-11-21T13:19:19+00:00" + } + ], "packages-dev": [ { "name": "amphp/amp", diff --git a/lib/Album/AlbumFile.php b/lib/Album/AlbumFile.php index d6f09caa2..7d084bfd2 100644 --- a/lib/Album/AlbumFile.php +++ b/lib/Album/AlbumFile.php @@ -23,19 +23,11 @@ namespace OCA\Photos\Album; -use OC\Metadata\FileMetadata; +use OCA\Photos\DB\PhotosFile; -class AlbumFile { - private int $fileId; - private string $name; - private string $mimeType; - private int $size; - private int $mtime; - private string $etag; +class AlbumFile extends PhotosFile { private int $added; private string $owner; - /** @var array */ - private array $metaData = []; public function __construct( int $fileId, @@ -47,52 +39,19 @@ public function __construct( int $added, string $owner ) { - $this->fileId = $fileId; - $this->name = $name; - $this->mimeType = $mimeType; - $this->size = $size; - $this->mtime = $mtime; - $this->etag = $etag; + parent::__construct( + $fileId, + $name, + $mimeType, + $size, + $mtime, + $etag + ); + $this->added = $added; $this->owner = $owner; } - public function getFileId(): int { - return $this->fileId; - } - - public function getName(): string { - return $this->name; - } - - public function getMimeType(): string { - return $this->mimeType; - } - - public function getSize(): int { - return $this->size; - } - - public function getMTime(): int { - return $this->mtime; - } - - 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]; - } - public function getAdded(): int { return $this->added; } diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index d4a67e359..e8c90a58d 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -32,6 +32,7 @@ use OCA\Photos\Listener\TagListener; use OCA\Photos\Listener\GroupUserRemovedListener; use OCA\Photos\Listener\GroupDeletedListener; +use OCA\Photos\Listener\LocationManagerEventListener; use OCP\AppFramework\App; use OCP\AppFramework\Bootstrap\IBootContext; use OCP\AppFramework\Bootstrap\IBootstrap; @@ -40,6 +41,10 @@ use OCP\SystemTag\MapperEvent; use OCP\Group\Events\UserRemovedEvent; use OCP\Group\Events\GroupDeletedEvent; +use OCP\Files\Events\Node\NodeWrittenEvent; +use OCP\User\Events\UserDeletedEvent; + +require_once __DIR__ . '/../../vendor/autoload.php'; class Application extends App implements IBootstrap { public const APP_ID = 'photos'; @@ -78,6 +83,9 @@ public function register(IRegistrationContext $context): void { $context->registerEventListener(GroupDeletedEvent::class, GroupDeletedListener::class); + // Priority of -1 to be triggered after event listeners populating metadata. + $context->registerEventListener(NodeWrittenEvent::class, LocationManagerEventListener::class, -1); + $context->registerEventListener(SabrePluginAuthInitEvent::class, SabrePluginAuthInitListener::class); $context->registerEventListener(MapperEvent::EVENT_ASSIGN, TagListener::class); diff --git a/lib/Command/MapMediaToLocationCommand.php b/lib/Command/MapMediaToLocationCommand.php new file mode 100644 index 000000000..5895b2919 --- /dev/null +++ b/lib/Command/MapMediaToLocationCommand.php @@ -0,0 +1,116 @@ + + * + * @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 OCP\IConfig; +use OCP\IUserManager; +use OCP\Files\IRootFolder; +use OCP\Files\Folder; +use OCA\Photos\Service\MediaLocationManager; +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 MapMediaToLocationCommand extends Command { + public function __construct( + private IRootFolder $rootFolder, + private MediaLocationManager $mediaLocationManager, + private IConfig $config, + private IUserManager $userManager, + ) { + parent::__construct(); + } + + /** + * Configure the command + */ + protected function configure(): void { + $this->setName('photos:map-media-to-location') + ->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->mediaLocationManager->setLocationForFile($node->getId()); + $count++; + } + + return $count; + } +} diff --git a/lib/Command/UpdateReverseGeocodingFilesCommand.php b/lib/Command/UpdateReverseGeocodingFilesCommand.php new file mode 100644 index 000000000..3d6250f3e --- /dev/null +++ b/lib/Command/UpdateReverseGeocodingFilesCommand.php @@ -0,0 +1,61 @@ + + * + * @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\ReverseGeoCoderService; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +class UpdateReverseGeocodingFilesCommand extends Command { + public function __construct( + private ReverseGeoCoderService $rgcService, + ) { + parent::__construct(); + } + + /** + * Configure the command + */ + protected function configure(): void { + $this->setName('photos:update-1000-cities') + ->setDescription('Update the list of 1000 and more inhabitant cities'); + } + + /** + * Execute the command + */ + protected function execute(InputInterface $input, OutputInterface $output): int { + try { + $this->rgcService->buildKDTree(true); + } catch (\Exception $ex) { + $output->writeln('Failed to update reverse geocoding files'); + $output->writeln($ex->getMessage()); + return 1; + } + + return 0; + } +} diff --git a/lib/DB/Location/LocationFile.php b/lib/DB/Location/LocationFile.php new file mode 100644 index 000000000..4296ea0e4 --- /dev/null +++ b/lib/DB/Location/LocationFile.php @@ -0,0 +1,53 @@ + + * + * @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\DB\Location; + +use OCA\Photos\DB\PhotosFile; + +class LocationFile extends PhotosFile { + public function __construct( + int $fileId, + string $name, + string $mimeType, + int $size, + int $mtime, + string $etag, + private string $location, + ) { + parent::__construct( + $fileId, + $name, + $mimeType, + $size, + $mtime, + $etag, + ); + } + + public function getLocation(): string { + return $this->location; + } +} diff --git a/lib/DB/Location/LocationInfo.php b/lib/DB/Location/LocationInfo.php new file mode 100644 index 000000000..24b0c5dfd --- /dev/null +++ b/lib/DB/Location/LocationInfo.php @@ -0,0 +1,42 @@ + + * + * @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\DB\Location; + +class LocationInfo { + public function __construct( + private string $userId, + private string $location + ) { + } + + public function getUserId(): string { + return $this->userId; + } + + public function getLocation(): string { + return $this->location; + } +} diff --git a/lib/DB/Location/LocationMapper.php b/lib/DB/Location/LocationMapper.php new file mode 100644 index 000000000..e3b9cac39 --- /dev/null +++ b/lib/DB/Location/LocationMapper.php @@ -0,0 +1,127 @@ + + * + * @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\DB\Location; + +use Doctrine\DBAL\Exception\UniqueConstraintViolationException; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\Files\IMimeTypeLoader; +use OCP\Files\IRootFolder; +use OCP\IDBConnection; + +class LocationMapper { + public const METADATA_TYPE = 'photos_location'; + + public function __construct( + private IDBConnection $connection, + private IMimeTypeLoader $mimeTypeLoader, + private IRootFolder $rootFolder, + ) { + } + + /** @return LocationInfo[] */ + public function findLocationsForUser(string $userId): array { + $mountId = $this->rootFolder + ->getUserFolder($userId) + ->getMountPoint() + ->getMountId(); + $mimepart = $this->mimeTypeLoader->getId('image'); + + $qb = $this->connection->getQueryBuilder(); + + $rows = $qb->selectDistinct('meta.metadata') + ->from('mounts', 'mount') + ->join('mount', 'filecache', 'file', $qb->expr()->eq('file.storage', 'mount.storage_id', IQueryBuilder::PARAM_INT)) + ->join('file', 'file_metadata', 'meta', $qb->expr()->eq('file.fileid', 'meta.id', IQueryBuilder::PARAM_INT)) + ->where($qb->expr()->eq('mount.id', $qb->createNamedParameter($mountId), 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_TYPE))) + ->executeQuery() + ->fetchAll(); + + return array_map(fn ($row) => new LocationInfo($userId, $row['metadata']), $rows); + } + + /** @return LocationFile[] */ + public function findFilesForUserAndLocation(string $userId, string $location) { + $mountId = $this->rootFolder + ->getUserFolder($userId) + ->getMountPoint() + ->getMountId(); + $mimepart = $this->mimeTypeLoader->getId('image'); + + $qb = $this->connection->getQueryBuilder(); + + $rows = $qb->select('file.fileid', 'file.name', 'file.mimetype', 'file.size', 'file.mtime', 'file.etag', 'meta.metadata') + ->from('mounts', 'mount') + ->join('mount', 'filecache', 'file', $qb->expr()->eq('file.storage', 'mount.storage_id', IQueryBuilder::PARAM_INT)) + ->join('file', 'file_metadata', 'meta', $qb->expr()->eq('file.fileid', 'meta.id', IQueryBuilder::PARAM_INT)) + ->where($qb->expr()->eq('mount.id', $qb->createNamedParameter($mountId), 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_TYPE))) + ->andWhere($qb->expr()->eq('meta.metadata', $qb->createNamedParameter($location))) + ->executeQuery() + ->fetchAll(); + + return array_map( + fn ($row) => new LocationFile( + (int)$row['fileid'], + $row['name'], + $this->mimeTypeLoader->getMimetypeById($row['mimetype']), + (int)$row['size'], + (int)$row['mtime'], + $row['etag'], + $row['metadata'] + ), + $rows, + ); + } + + public function setLocationForFile(string $location, 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_TYPE), + "metadata" => $query->createNamedParameter($location), + ]) + ->executeStatement(); + } catch (\Exception $ex) { + if ($ex->getPrevious() instanceof UniqueConstraintViolationException) { + $this->updateLocationForFile($location, $fileId); + } + } + } + + public function updateLocationForFile(string $location, int $fileId): void { + $query = $this->connection->getQueryBuilder(); + $query->update('file_metadata') + ->set("metadata", $query->createNamedParameter($location)) + ->where($query->expr()->eq('id', $query->createNamedParameter($fileId, IQueryBuilder::PARAM_INT))) + ->andWhere($query->expr()->eq('group_name', $query->createNamedParameter(self::METADATA_TYPE))) + ->executeStatement(); + } +} diff --git a/lib/DB/PhotosFile.php b/lib/DB/PhotosFile.php new file mode 100644 index 000000000..7a3ab90a9 --- /dev/null +++ b/lib/DB/PhotosFile.php @@ -0,0 +1,79 @@ + + * + * @author Louis Chemineau + * + * @license GNU AGPL version 3 or any later version + * + * 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\DB; + +use OC\Metadata\FileMetadata; + +class PhotosFile { + /** @var array */ + private array $metaData = []; + + public function __construct( + private int $fileId, + private string $name, + private string $mimeType, + private int $size, + private int $mtime, + private string $etag, + ) { + } + + public function getFileId(): int { + return $this->fileId; + } + + public function getName(): string { + return $this->name; + } + + public function getMimeType(): string { + return $this->mimeType; + } + + public function getSize(): int { + return $this->size; + } + + public function getMTime(): int { + return $this->mtime; + } + + 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/Jobs/AutomaticLocationMapperJob.php b/lib/Jobs/AutomaticLocationMapperJob.php new file mode 100644 index 000000000..fdfc0eb26 --- /dev/null +++ b/lib/Jobs/AutomaticLocationMapperJob.php @@ -0,0 +1,114 @@ + + * + * @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\AppInfo\Application; +use OCA\Photos\Service\MediaLocationManager; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\TimedJob; +use OCP\Files\Folder; +use OCP\Files\IRootFolder; +use OCP\IConfig; +use OCP\IUserManager; + +class AutomaticLocationMapperJob extends TimedJob { + public function __construct( + ITimeFactory $time, + private IConfig $config, + private IRootFolder $rootFolder, + private IUserManager $userManager, + private MediaLocationManager $mediaLocationManager, + ) { + parent::__construct($time); + $this->mediaLocationManager = $mediaLocationManager; + + $this->setTimeSensitivity(\OCP\BackgroundJob\IJob::TIME_INSENSITIVE); + $this->setInterval(24 * 3600); + } + + protected function run($argument) { + $locationMappingDone = $this->config->getAppValue(Application::APP_ID, 'lastLocationMappingDone', 'false'); + + if ($locationMappingDone === 'true') { + return; + } + + $users = $this->userManager->search(''); + $lastMappedUser = $this->config->getAppValue(Application::APP_ID, 'lastLocationMappedUser', ''); + + if ($lastMappedUser === '') { + $lastMappedUser = $users[array_key_first($users)]->getUID(); + } + + $startTime = null; + foreach ($users as $user) { + if ($startTime === null) { + // Skip all user before lastMappedUser. + if ($lastMappedUser !== $user->getUID()) { + continue; + } + + $startTime = time(); + } + + // Stop if execution time is more than one hour. + if (time() - $startTime > 60 * 60) { + return; + } + + $this->scanFilesForUser($user->getUID()); + $this->config->setAppValue(Application::APP_ID, 'lastLocationMappedUser', $user->getUID()); + } + + $this->config->setAppValue(Application::APP_ID, 'lastLocationMappingDone', 'true'); + } + + private function scanFilesForUser(string $userId): void { + $userFolder = $this->rootFolder->getUserFolder($userId); + $this->scanFolder($userFolder); + } + + + private function scanFolder(Folder $folder): void { + // Do not scan share and other moveable mounts. + if ($folder->getMountPoint() instanceof \OC\Files\Mount\MoveableMount) { + return; + } + + foreach ($folder->getDirectoryListing() as $node) { + if ($node instanceof Folder) { + $this->scanFolder($node); + continue; + } + + if (!str_starts_with($node->getMimeType(), 'image')) { + continue; + } + + $this->mediaLocationManager->setLocationForFile($node->getId()); + } + } +} diff --git a/lib/Jobs/MapMediaToLocationJob.php b/lib/Jobs/MapMediaToLocationJob.php new file mode 100644 index 000000000..1f3a9f4f0 --- /dev/null +++ b/lib/Jobs/MapMediaToLocationJob.php @@ -0,0 +1,48 @@ + + * + * @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\MediaLocationManager; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\QueuedJob; + +class MapMediaToLocationJob extends QueuedJob { + private MediaLocationManager $mediaLocationManager; + + public function __construct( + ITimeFactory $time, + MediaLocationManager $mediaLocationManager + ) { + parent::__construct($time); + $this->mediaLocationManager = $mediaLocationManager; + } + + protected function run($argument) { + [$fileId] = $argument; + + $this->mediaLocationManager->setLocationForFile($fileId); + } +} diff --git a/lib/Listener/LocationManagerEventListener.php b/lib/Listener/LocationManagerEventListener.php new file mode 100644 index 000000000..9a78243e8 --- /dev/null +++ b/lib/Listener/LocationManagerEventListener.php @@ -0,0 +1,71 @@ + + * + * @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\MapMediaToLocationJob; +use OCA\Photos\Service\MediaLocationManager; +use OCP\BackgroundJob\IJobList; +use OCP\IConfig; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use OCP\Files\Events\Node\NodeWrittenEvent; + +/** + * Listener to create, update or remove location info from the database. + */ +class LocationManagerEventListener implements IEventListener { + public function __construct( + private MediaLocationManager $mediaLocationManager, + 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(MapMediaToLocationJob::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/Service/MediaLocationManager.php b/lib/Service/MediaLocationManager.php new file mode 100644 index 000000000..ddaa24d34 --- /dev/null +++ b/lib/Service/MediaLocationManager.php @@ -0,0 +1,76 @@ + + * + * @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\Service; + +use OC\Metadata\IMetadataManager; +use OCA\Photos\DB\Location\LocationMapper; + +class MediaLocationManager { + public function __construct( + private IMetadataManager $metadataManager, + private ReverseGeoCoderService $rgcService, + private LocationMapper $locationMapper, + ) { + } + + public function setLocationForFile(int $fileId): void { + $location = $this->getLocationForFile($fileId); + + if ($location === null) { + return; + } + + $this->locationMapper->setLocationForFile($location, $fileId); + } + + public function updateLocationForFile(int $fileId): void { + $location = $this->getLocationForFile($fileId); + + if ($location === null) { + return; + } + + $this->locationMapper->updateLocationForFile($location, $fileId); + } + + private function getLocationForFile(int $fileId): ?string { + $gpsMetadata = $this->metadataManager->fetchMetadataFor('gps', [$fileId])[$fileId]; + $metadata = $gpsMetadata->getMetadata(); + + if (count($metadata) === 0) { + return null; + } + + $latitude = $metadata['latitude']; + $longitude = $metadata['longitude']; + + if ($latitude === null || $longitude === null) { + return null; + } + + return $this->rgcService->getLocationForCoordinates($latitude, $longitude); + } +} diff --git a/lib/Service/ReverseGeoCoderService.php b/lib/Service/ReverseGeoCoderService.php new file mode 100644 index 000000000..94685b02f --- /dev/null +++ b/lib/Service/ReverseGeoCoderService.php @@ -0,0 +1,168 @@ + + * + * @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\Service; + +use OCP\Files\IAppData; +use OCP\Files\SimpleFS\ISimpleFolder; +use OCP\Files\NotFoundException; +use OCP\Http\Client\IClientService; +use Hexogen\KDTree\FSTreePersister; +use Hexogen\KDTree\FSKDTree; +use Hexogen\KDTree\KDTree; +use Hexogen\KDTree\Item; +use Hexogen\KDTree\ItemList; +use Hexogen\KDTree\ItemFactory; +use Hexogen\KDTree\NearestSearch; +use Hexogen\KDTree\Point; + +class ReverseGeoCoderService { + private ISimpleFolder $geoNameFolder; + private ?NearestSearch $fsSearcher = null; + /** @var array */ + private ?array $citiesMapping = null; + + public function __construct( + IAppData $appData, + private IClientService $clientService, + ) { + try { + $this->geoNameFolder = $appData->getFolder("geonames"); + } catch (NotFoundException $ex) { + $this->geoNameFolder = $appData->newFolder("geonames"); + } + } + + public function getLocationForCoordinates(float $latitude, float $longitude): string { + $this->loadKdTree(); + $result = $this->fsSearcher->search(new Point([$latitude, $longitude]), 1); + return $this->getLocationNameForLocationId($result[0]->getId()); + } + + private function getLocationNameForLocationId(int $locationId): string { + if ($this->citiesMapping === null) { + $this->downloadCities1000(); + $cities1000 = $this->loadCities1000(); + $this->citiesMapping = []; + foreach ($cities1000 as $city) { + $this->citiesMapping[$city['id']] = $city['name']; + } + } + + return $this->citiesMapping[$locationId]; + } + + private function downloadCities1000(bool $force = false): void { + if ($this->geoNameFolder->fileExists('cities1000.csv') && !$force) { + return; + } + + // Download zip file to a tmp file. + $response = $this->clientService->newClient()->get("https://download.geonames.org/export/dump/cities1000.zip"); + $tmpFile = tmpfile(); + $cities1000ZipTmpFileName = stream_get_meta_data($tmpFile)['uri']; + fclose($tmpFile); + file_put_contents($cities1000ZipTmpFileName, $response->getBody()); + + // Unzip the txt file into a stream. + $zip = new \ZipArchive; + $res = $zip->open($cities1000ZipTmpFileName); + if ($res !== true) { + throw new \Exception("Fail to unzip location file: $res", $res); + } + $cities1000TxtSteam = $zip->getStream('cities1000.txt'); + + // Dump the txt file info into a smaller csv file. + $destinationStream = $this->geoNameFolder->newFile('cities1000.csv')->write(); + + while (($fields = fgetcsv($cities1000TxtSteam, 0, " ")) !== false) { + $result = fputcsv( + $destinationStream, + [ + 'id' => (int)$fields[0], + 'name' => $fields[1], + 'latitude' => (float)$fields[4], + 'longitude' => (float)$fields[5], + ] + ); + + if ($result === false) { + throw new \Exception('Failed to write csv line to tmp stream'); + } + } + + $zip->close(); + } + + private function loadCities1000(): array { + $csvStream = $this->geoNameFolder->getFile('cities1000.csv')->read(); + $cities = []; + + while (($fields = fgetcsv($csvStream)) !== false) { + $cities[] = [ + 'id' => (int)$fields[0], + 'name' => $fields[1], + 'latitude' => (float)$fields[2], + 'longitude' => (float)$fields[3], + ]; + } + + return $cities; + } + + public function buildKDTree($force = false): void { + if ($this->geoNameFolder->fileExists('cities1000.bin') && !$force) { + return; + } + + $this->downloadCities1000($force); + $cities1000 = $this->loadCities1000(); + + $itemList = new ItemList(2); + foreach ($cities1000 as $city) { + $itemList->addItem(new Item($city['id'], [$city['latitude'], $city['longitude']])); + } + $tree = new KDTree($itemList); + + // Persiste KDTree in app data. + $persister = new FSTreePersister('/'); + $kdTreeTmpFileName = tempnam(sys_get_temp_dir(), "nextcloud_photos_"); + $persister->convert($tree, $kdTreeTmpFileName); + $kdTreeString = file_get_contents($kdTreeTmpFileName); + $this->geoNameFolder->newFile('cities1000.bin', $kdTreeString); + } + + private function loadKdTree(): void { + if ($this->fsSearcher !== null) { + return; + } + + $this->buildKDTree(); + $kdTreeFileContent = $this->geoNameFolder->getFile("cities1000.bin")->getContent(); + $kdTreeTmpFileName = tempnam(sys_get_temp_dir(), "nextcloud_photos_"); + file_put_contents($kdTreeTmpFileName, $kdTreeFileContent); + $fsTree = new FSKDTree($kdTreeTmpFileName, new ItemFactory()); + $this->fsSearcher = new NearestSearch($fsTree); + } +} diff --git a/package.json b/package.json index bc86c3127..b1688341b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "photos", "description": "Your memories under your control", - "version": "2.2.0", + "version": "2.2.1", "author": "John Molakvoæ ", "contributors": [ "John Molakvoæ " diff --git a/tests/stub.phpstub b/tests/stub.phpstub index 038e42e2b..1cf150a3b 100644 --- a/tests/stub.phpstub +++ b/tests/stub.phpstub @@ -118,6 +118,7 @@ namespace Symfony\Component\Console\Question { namespace Symfony\Component\Console\Output { class OutputInterface { public const VERBOSITY_VERBOSE = 1; + public function write($messages, $newline = false, $options = 0); public function writeln(string $text, int $flat = 0) {} } } @@ -269,10 +270,10 @@ namespace OC\Files\Mount { protected $class; protected $storageId; protected $rootId = null; - + /** @var int|null */ protected $mountId; - + /** * @param string|\OCP\Files\Storage\IStorage $storage * @param string $mountpoint @@ -285,7 +286,7 @@ namespace OC\Files\Mount { public function __construct($storage, $mountpoint, $arguments = null, $loader = null, $mountOptions = null, $mountId = null) { throw new \Exception('stub'); } - + /** * get complete path to the mount point, relative to data/ * @@ -294,7 +295,7 @@ namespace OC\Files\Mount { public function getMountPoint() { throw new \Exception('stub'); } - + /** * Sets the mount point path, relative to data/ * @@ -303,28 +304,28 @@ namespace OC\Files\Mount { public function setMountPoint($mountPoint) { throw new \Exception('stub'); } - + /** * @return \OCP\Files\Storage\IStorage */ public function getStorage() { throw new \Exception('stub'); } - + /** * @return string */ public function getStorageId() { throw new \Exception('stub'); } - + /** * @return int */ public function getNumericStorageId() { throw new \Exception('stub'); } - + /** * @param string $path * @return string @@ -332,14 +333,14 @@ namespace OC\Files\Mount { public function getInternalPath($path) { throw new \Exception('stub'); } - + /** * @param callable $wrapper */ public function wrapStorage($wrapper) { throw new \Exception('stub'); } - + /** * Get a mount option * @@ -350,7 +351,7 @@ namespace OC\Files\Mount { public function getOption($name, $default) { throw new \Exception('stub'); } - + /** * Get all options for the mount * @@ -359,18 +360,18 @@ namespace OC\Files\Mount { public function getOptions() { throw new \Exception('stub'); } - + /** * @return int */ public function getStorageRootId() { throw new \Exception('stub'); } - + public function getMountId() { throw new \Exception('stub'); } - + public function getMountType() { throw new \Exception('stub'); } @@ -656,7 +657,7 @@ use OCP\DB\Types; /** * @method string getGroupName() * @method void setGroupName(string $groupName) - * @method string getMetadata() + * @method array getMetadata() * @method void setMetadata(array $metadata) * @see \OC\Core\Migrations\Version240000Date20220404230027 */ @@ -686,3 +687,25 @@ namespace OCA\DAV\Upload { namespace Doctrine\DBAL\Exception { class UniqueConstraintViolationException extends \Exception {} } + +namespace OC\Files\Mount; + +/** + * Defines the mount point to be (re)moved by the user + */ +interface MoveableMount { + /** + * Move the mount point to $target + * + * @param string $target the target mount point + * @return bool + */ + public function moveMount($target); + + /** + * Remove the mount points + * + * @return bool + */ + public function removeMount(); +}