diff --git a/appinfo/info.xml b/appinfo/info.xml index 19e2997f6..334249bd0 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -1,28 +1,36 @@ - photos - Photos - Your memories under your control - Your memories under your control - 1.3.0 - agpl - John Molakvoæ - Photos - multimedia + xsi:noNamespaceSchemaLocation="https://apps.nextcloud.com/schema/apps/info.xsd"> +photos +Photos +Your memories under your control +Your memories under your control +1.3.1 +agpl +John Molakvoæ +Photos +multimedia + + + + + +https://github.com/nextcloud/photos +https://github.com/nextcloud/photos/issues +https://github.com/nextcloud/photos.git + + + + + + + Photos + photos.page.index + 1 + + + + OCA\Photos\Command\ExtractMetadata + - https://github.com/nextcloud/photos - https://github.com/nextcloud/photos/issues - https://github.com/nextcloud/photos.git - - - - - - - Photos - photos.page.index - 1 - - diff --git a/appinfo/routes.php b/appinfo/routes.php index 62f3ce4a1..ecbbc0c8a 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -73,5 +73,30 @@ 'path' => '', ], ], + [ + 'name' => 'albums#getNumberByMonth', + 'url' => '/api/v1/numberbymonth/{path}', + 'verb' => 'GET', + 'requirements' => [ + 'path' => '.*', + ], + 'defaults' => [ + 'path' => '', + ], + ], + [ + 'name' => 'albums#getPhotosOfMonth', + 'url' => '/api/v1/photosofmonth/{yearandmonth}', + 'verb' => 'GET', + 'requirements' => [ + 'yearandmonth' => '.*', + 'path' => '.*', + ], + 'defaults' => [ + 'yearandmonth' => '', + 'path' => '', + ], + ], + ] ]; diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 7abc80cae..5fd037696 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -29,6 +29,7 @@ use OCP\AppFramework\Bootstrap\IBootContext; use OCP\AppFramework\Bootstrap\IBootstrap; use OCP\AppFramework\Bootstrap\IRegistrationContext; +use OCP\Files\Events\NodeAddedToCache; class Application extends App implements IBootstrap { public const APP_ID = 'photos'; @@ -52,11 +53,18 @@ class Application extends App implements IBootstrap { public function __construct() { parent::__construct(self::APP_ID); + /* @var IEventDispatcher $eventDispatcher */ + $dispatcher = $this->getContainer()->get("OCP\EventDispatcher\IEventDispatcher" ); + $dispatcher->addListener(NodeAddedToCache::class, function(NodeAddedToCache $event) { + $filePath = $event->getPath(); + }); + } public function register(IRegistrationContext $context): void { } public function boot(IBootContext $context): void { + \OCP\Util::connectHook('OC_Filesystem', 'preSetup', 'OCA\Photos\Storage', 'setupStorage'); } } diff --git a/lib/Command/ExtractMetadata.php b/lib/Command/ExtractMetadata.php new file mode 100644 index 000000000..33cecd617 --- /dev/null +++ b/lib/Command/ExtractMetadata.php @@ -0,0 +1,171 @@ +userManager = $userManager; + $this->rootFolder = $rootFolder; + $this->config = $config; + $this->encryptionManager = $encryptionManager; + $this->photoMetadataMapper = $photoMetadataMapper; + $this->metadataService = $metadataService; + } + + protected function configure() { + $this + ->setName('photos:extractmetadata') + ->setDescription('extract metadata from image files: date taken, location...') + ->addArgument( + 'user_id', + InputArgument::OPTIONAL, + 'extract photo metadata for the given user' + )->addOption( + 'path', + 'p', + InputOption::VALUE_OPTIONAL, + 'limit extraction to this photo folder(album), eg. --path="/alice/files/Photos/2020-3/", the user_id is determined by the path and the user_id parameter is ignored' + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + if ($this->encryptionManager->isEnabled()) { + $output->writeln('Encryption is enabled. Aborted.'); + return 1; + } + + $this->output = $output; + + $inputPath = $input->getOption('path'); + if ($inputPath) { + $inputPath = '/' . trim($inputPath, '/'); + list (, $userId,) = explode('/', $inputPath, 3); + $user = $this->userManager->get($userId); + if ($user !== null) { + $this->extractPathPhotosMetadata($user, $inputPath); + } + } else { + $userId = $input->getArgument('user_id'); + if ($userId === null) { + $this->userManager->callForSeenUsers(function (IUser $user) { + $this->extractUserPhotosMetadata($user); + }); + } else { + $user = $this->userManager->get($userId); + if ($user !== null) { + $this->extractUserPhotosMetadata($user); + } + } + } + + return 0; + } + + private function extractPathPhotosMetadata(IUser $user, string $path) { + \OC_Util::tearDownFS(); + \OC_Util::setupFS($user->getUID()); + $userFolder = $this->rootFolder->getUserFolder($user->getUID()); + try { + $relativePath = $userFolder->getRelativePath($path); + } catch (NotFoundException $e) { + $this->output->writeln('Path not found'); + return; + } + $pathFolder = $userFolder->get($relativePath); + $this->parseFolder($pathFolder); + } + + private function extractUserPhotosMetadata(IUser $user) { + \OC_Util::tearDownFS(); + \OC_Util::setupFS($user->getUID()); + + $userFolder = $this->rootFolder->getUserFolder($user->getUID()); + $this->parseFolder($userFolder); + + } + + private function parseFolder(Folder $folder) { + // Respect the '.nomedia' file. If present don't traverse the folder + if ($folder->nodeExists('.nomedia')) { + $this->output->writeln('Skipping folder ' . $folder->getPath()); + return; + } + + $this->output->writeln('Scanning folder ' . $folder->getPath()); + + $nodes = $folder->getDirectoryListing(); + + foreach ($nodes as $node) { + if ($node instanceof Folder) { + $this->parseFolder($node); + } else if ($node instanceof File) { + $this->parseFile($node); + } + } + } + + private function parseFile(File $file) { + if ($this->output->getVerbosity() > OutputInterface::VERBOSITY_VERBOSE) { + $this->output->writeln('Extracting metadata from ' . $file->getPath()); + } + $mimeType = \OC::$server->getMimeTypeDetector()->detectPath($file->getPath()); + if (in_array($mimeType, ['image/jpeg'])) { + $fileStorage = $file->getStorage(); + $tempStream = $fileStorage->fopen($file->getInternalPath(),'r'); + $photoMetadata = $this->metadataService->extractPhotoMetadata($tempStream,$file->getId()); + fclose($tempStream); + $this->photoMetadataMapper->insert($photoMetadata); + } + } + +} + diff --git a/lib/Controller/AlbumsController.php b/lib/Controller/AlbumsController.php index d047474b2..0c6edcf98 100644 --- a/lib/Controller/AlbumsController.php +++ b/lib/Controller/AlbumsController.php @@ -27,6 +27,7 @@ use OCA\Files_Sharing\SharedStorage; use OCA\Photos\AppInfo\Application; +use OCA\Photos\Db\PhotoMetadataMapper; use OCP\AppFramework\Controller; use OCP\AppFramework\Http; use OCP\AppFramework\Http\JSONResponse; @@ -44,11 +45,18 @@ class AlbumsController extends Controller { private $userId; /** @var IRootFolder */ private $rootFolder; + /** @var PhotoMetadataMapper */ + private $photoMetadataMapper; - public function __construct($appName, IRequest $request, string $userId, IRootFolder $rootFolder) { + public function __construct($appName, + IRequest $request, + string $userId, + IRootFolder $rootFolder, + PhotoMetadataMapper $photoMetadataMapper) { parent::__construct($appName, $request); $this->userId = $userId; $this->rootFolder = $rootFolder; + $this->photoMetadataMapper = $photoMetadataMapper; } /** @@ -65,18 +73,86 @@ public function sharedAlbums(string $path = ''): JSONResponse { return $this->generate($path, true); } - private function generate(string $path, bool $shared): JSONResponse { - $userFolder = $this->rootFolder->getUserFolder($this->userId); + /** + * @NoAdminRequired + */ + public function getNumberByMonth(string $path = ''): JSONResponse { + return $this->formatPhotosByMonth($path); + } - $folder = $userFolder; - if ($path !== '') { - try { - $folder = $userFolder->get($path); - } catch (NotFoundException $e) { - return new JSONResponse([], Http::STATUS_NOT_FOUND); + /** + * @NoAdminRequired + * @var yearandmonth : 2016-08 + */ + public function getPhotosOfMonth(string $yearandmonth = ''): JSONResponse { + if ($yearandmonth == '') { + $targetYearMonth = ''; + }else{ + $targetYearMonth = $this->convertDate($yearandmonth); + } + + $nodes = $this->getAllUserPhotos(''); + $photosMetadata = $this->getPhotosMetadata($nodes); + $photosOfMonth = []; + foreach ($photosMetadata as $metadata) { + $dateTime = $metadata->getDateTimeOriginal(); + + if ($dateTime == '') { + if ($targetYearMonth == '') { + $photosOfMonth[]= $metadata->getFileId(); + } + continue; + } + + $yearMonth = date('Y-M',strtotime($dateTime)); + if ($yearMonth == $targetYearMonth) { + $photosOfMonth[] = $metadata->getFileId(); } } + return new JSONResponse($photosOfMonth, Http::STATUS_OK); + } + + private function formatPhotosByMonth(string $path): JSONResponse { + $nodes = $this->getAllUserPhotos($path); + $photosMetadata = $this->getPhotosMetadata($nodes); + //caculate photos in each month + $photosByMonth = ["unknown" => 0]; + foreach ($photosMetadata as $metadata) { + $dateTime = $metadata->getDateTimeOriginal(); + if ($dateTime == "") { + $photosByMonth['unknown'] = $photosByMonth['unknown'] + 1; + continue; + } + $yearMonth = date('Y-M',strtotime($dateTime)); + if (array_key_exists($yearMonth,$photosByMonth)) { + $photosByMonth[$yearMonth] = $photosByMonth[$yearMonth] + 1; + } else { + $photosByMonth[$yearMonth] = 1; + } + } + return new JSONResponse($photosByMonth,Http::STATUS_OK); + } + + private function getAllUserPhotos(string $path) { + $folder = $this->getFolder($path); + if (is_null($folder)) { + return new JSONResponse([], Http::STATUS_NOT_FOUND); + } + $nodes = $this->scanCurrentFolderRecusive($folder,false); + return $nodes; + } + + private function getPhotosMetadata(iterable $nodes) { + $fileIds = []; + foreach ($nodes as $node) { + $fileIds[] = $node->getId(); + } + return $this->photoMetadataMapper->findAll($fileIds); + } + + private function generate(string $path, bool $shared): JSONResponse { + $folder = $this->getFolder($path); $data = $this->scanCurrentFolder($folder, $shared); $result = $this->formatData($data); @@ -108,6 +184,25 @@ private function formatData(iterable $nodes): array { return $result; } + + private function scanCurrentFolderRecusive(Folder $folder): iterable + { + $nodes = $folder->getDirectoryListing(); + + foreach ($nodes as $node) { + if ($node instanceof Folder) { + foreach ($this->scanCurrentFolderRecusive($node) as $subnode) { + if ($subnode instanceof File) { + yield $subnode; + } + } + } elseif ($node instanceof File) { + if ($this->validFile($node, false)) { + yield $node; + } + } + } + } private function scanCurrentFolder(Folder $folder, bool $shared): iterable { $nodes = $folder->getDirectoryListing(); @@ -125,6 +220,22 @@ private function scanCurrentFolder(Folder $folder, bool $shared): iterable { } } } + + private function getFolder(string $path) { + $userFolder = $this->rootFolder->getUserFolder($this->userId); + + $folder = $userFolder; + if ($path !== '') { + try { + $folder = $userFolder->get($path); + } catch (NotFoundException $e) { + return null; + } + } + return $folder; + } + + private function validFile(File $file, bool $shared): bool { if (in_array($file->getMimeType(), Application::MIMES) && $this->isShared($file) === $shared) { @@ -173,4 +284,13 @@ private function scanFolder(Folder $folder, int $depth, bool $shared): bool { return false; } + + /** + * convert 2016-08 to 2016-Aug + */ + private function convertDate(string $yearMonth) { + $temp = str_replace('-','',$yearMonth); + $month = date('M', strtotime($temp . '01')); + return explode('-',$yearMonth)[0] . '-' . $month; + } } diff --git a/lib/Db/PhotoMetadata.php b/lib/Db/PhotoMetadata.php new file mode 100644 index 000000000..ecf851b42 --- /dev/null +++ b/lib/Db/PhotoMetadata.php @@ -0,0 +1,36 @@ +addType('id','integer'); + $this->addType('fileId','integer'); + } + + public function jsonSerialize() { + return [ + 'id' => $this->id, + 'dateTimeOriginal' => $this->dateTimeOriginal, + 'gpsLatitude' => $this->gpsLatitude, + 'gpsLongitude' => $this->gpsLongitude, + 'gpsLatitudeRefl' => $this->gpsLatitudeRef, + 'gpsLongitudeRef' => $this->gpsLongitudeRef, + 'path' => $this->path, + ]; + } +} + +?> diff --git a/lib/Db/PhotoMetadataMapper.php b/lib/Db/PhotoMetadataMapper.php new file mode 100644 index 000000000..28a891d23 --- /dev/null +++ b/lib/Db/PhotoMetadataMapper.php @@ -0,0 +1,50 @@ +table_name,PhotoMetadata::class); + } + + public function find(int $fileId){ + $qb = $this->db->getQueryBuilder(); + + $qb->select('*') + ->from($this->table_name) + ->where( + $qb->expr()->eq('file_id', $qb->createNamedParameter($fileId, IQueryBuilder::PARAM_INT)) + ); + try{ + return $this->findEntity($qb); + }catch(DoesNotExistException $e){ + return null; + } + + } + /** + * @throws \OCP\AppFramework\Db\DoesNotExistException if not found + * + * @param array $fileIds + */ + public function findAll(array $fileIds) { + $qb = $this->db->getQueryBuilder(); + + $qb->select('*') + ->from($this->table_name); + + $filesMetadata = $this->findEntities($qb); + return array_filter($filesMetadata, function($fileMetadata) use ($fileIds) { + return in_array($fileMetadata->getFileId(),$fileIds); + }); + } +} diff --git a/lib/Migration/Version000000Date20201002183800.php b/lib/Migration/Version000000Date20201002183800.php new file mode 100644 index 000000000..7aab0e321 --- /dev/null +++ b/lib/Migration/Version000000Date20201002183800.php @@ -0,0 +1,61 @@ +hasTable($tableName)) { + $table = $schema->createTable($tableName); + $table->addColumn('id', 'integer', [ + 'notnull' => true, + 'autoincrement' => true, + ]); + + $table->addColumn('file_id', 'integer', [ + 'notnull' => false, + 'length' => 64, + ]); + $table->addColumn('date_time_original', 'string', [ + 'notnull' => false, + 'length' => 200 + ]); + $table->addColumn('gps_latitude', 'string', [ + 'notnull' => false, + ]); + $table->addColumn('gps_longitude', 'string', [ + 'notnull' => false + ]); + $table->addColumn('gps_latitude_ref', 'string', [ + 'notnull' => false + ]); + $table->addColumn('gps_longitude_ref', 'string', [ + 'notnull' => false + ]); + + $table->addColumn('path', 'string', [ + 'notnull' => false + ]); + + + $table->setPrimaryKey(['id']); + } + return $schema; + } +} diff --git a/lib/Service/MetadataService.php b/lib/Service/MetadataService.php new file mode 100644 index 000000000..a004c755e --- /dev/null +++ b/lib/Service/MetadataService.php @@ -0,0 +1,34 @@ + $metadata['DateTimeOriginal'] ?? "", + 'GPSLatitude' => implode('-',$metadata['GPSLatitude']) ?? "", + 'GPSLongitude' => implode('-',$metadata['GPSLongitude']) ?? "", + 'GPSLatitudeRef' => $metadata['GPSLatitudeRef'] ?? "", + 'GPSLongitudeRef' => $metadata['GPSLongitudeRef'] ?? "" + ]; + // save metadatat to database + $photoMetadata = new PhotoMetadata(); + $photoMetadata->setFileId($fileId); + $photoMetadata->setDateTimeOriginal($metadata['DateTimeOriginal']); + $photoMetadata->setGpsLatitude($metadata['GPSLatitude']); + $photoMetadata->setGpsLongitude($metadata['GPSLongitude']); + $photoMetadata->setGpsLatitudeRef($metadata['GPSLatitudeRef']); + $photoMetadata->setGpsLongitudeRef($metadata['GPSLongitudeRef']); + return $photoMetadata; + } +} + + +?> diff --git a/lib/Storage.php b/lib/Storage.php new file mode 100644 index 000000000..a9c29d0b6 --- /dev/null +++ b/lib/Storage.php @@ -0,0 +1,106 @@ + + * @author Björn Schießle + * @author Christoph Wurst + * @author Julius Härtl + * @author Morris Jobke + * @author Robin Appelman + * @author Roeland Jago Douma + * @author Vincent Petry + * + * @license AGPL-3.0 + * + * 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; + +use OC\Files\Filesystem; +use OC\Files\Storage\Wrapper\Wrapper; +use OCP\Files\IRootFolder; +use OCP\ILogger; + +use OCA\Photos\Service\MetadataService; +use OCA\Photos\Db\PhotoMetadataMapper; + +class Storage extends Wrapper { + + /** @var ILogger */ + private $logger; + + /** @var IRootFolder */ + private $rootFolder; + +/** @var MetadataService */ + private $metadataService; + +/** @var PhotoMetadataMapper */ + private $photoMetadataMapper; + + /** + * Storage constructor. + * + * @param array $parameters + * @param ILogger|null $logger + * @param IRootFolder|null $rootFolder + * @param MetadataService|null $metadataService + + */ + public function __construct( + $parameters, + ILogger $logger = null, + IRootFolder $rootFolder = null, + MetadataService $metadataService = null, + PhotoMetadataMapper $photoMetadataMapper = null + ) { + $this->logger = $logger; + $this->rootFolder = $rootFolder; + $this->metadataService = $metadataService; + $this->photoMetadataMapper = $photoMetadataMapper; + parent::__construct($parameters); + } + + /** + * @return bool + */ + public function writeStream(string $path, $stream, int $size = null): int { + $mimeType = \OC::$server->getMimeTypeDetector()->detectPath($path); + if (in_array($mimeType, ['image/jpeg'])) { + $photoMetadata = $this->metadataService->extractPhotoMetadata($stream); + $filePath = $path; + $filePath = preg_replace('/\\.[^.\\s]+$/', '', trim($filePath,'.part')); + $photoMetadata->setPath($filePath); + $this->photoMetadataMapper->insert($photoMetadata); + } + return parent::writeStream($path,$stream,$size); + } + + /** + * Setup the storate wrapper callback + */ + public static function setupStorage() { + \OC\Files\Filesystem::addStorageWrapper('oc_photos', function ($mountPoint, $storage) { + return new \OCA\Photos\Storage( + ['storage' => $storage], + \OC::$server->getLogger(), + \OC::$server->getLazyRootFolder(), + \OC::$server->get("OCA\Photos\Service\MetadataService"), + \OC::$server->get("OCA\Photos\Db\PhotoMetadataMapper") + ); + }, 1); + } +}